From 93411f1f4358c182a7bbd6e5ee9c52d47cc43080 Mon Sep 17 00:00:00 2001 From: BerserkTime <89993099+Berserk-hub150@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:01:56 +0200 Subject: [PATCH 01/15] hooks: define agent hook events and payloads --- ds4_hooks.h | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 ds4_hooks.h diff --git a/ds4_hooks.h b/ds4_hooks.h new file mode 100644 index 000000000..ee57af338 --- /dev/null +++ b/ds4_hooks.h @@ -0,0 +1,45 @@ +#ifndef DS4_HOOKS_H +#define DS4_HOOKS_H + +#include +#include + +typedef enum { + DS4_HOOK_BEFORE_RESPONSE = 0, + DS4_HOOK_AFTER_RESPONSE = 1, +} ds4_hook_event; + +typedef struct { + const char *before_response_command; + const char *after_response_command; + int timeout_seconds; +} ds4_hook_config; + +typedef struct { + ds4_hook_event event; + const char *model; + const char *user_text; + const char *response_text; + int response_index; + int generated_tokens; + bool interrupted; + bool tool_call; +} ds4_hook_payload; + +typedef struct { + bool attempted; + bool timed_out; + int exit_code; + int term_signal; + char error[160]; +} ds4_hook_result; + +const char *ds4_hook_event_name(ds4_hook_event event); +bool ds4_hook_command_enabled(const ds4_hook_config *config, + ds4_hook_event event); +char *ds4_hook_payload_json(const ds4_hook_payload *payload); +int ds4_hook_run(const ds4_hook_config *config, + const ds4_hook_payload *payload, + ds4_hook_result *result); + +#endif From 499321d7ce751b65573cdff5af39c15e33e8fb59 Mon Sep 17 00:00:00 2001 From: BerserkTime <89993099+Berserk-hub150@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:03:24 +0200 Subject: [PATCH 02/15] hooks: serialize events and execute commands safely --- ds4_hooks.c | 368 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 ds4_hooks.c diff --git a/ds4_hooks.c b/ds4_hooks.c new file mode 100644 index 000000000..a2f602548 --- /dev/null +++ b/ds4_hooks.c @@ -0,0 +1,368 @@ +#define _POSIX_C_SOURCE 200809L +#include "ds4_hooks.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define DS4_HOOK_DEFAULT_TIMEOUT_SECONDS 10 +#define DS4_HOOK_MAX_TEXT_BYTES (1024u * 1024u) +#define DS4_HOOK_POLL_MILLISECONDS 20 +#define DS4_HOOK_TERM_GRACE_MILLISECONDS 200 + +typedef struct { + char *ptr; + size_t len; + size_t cap; +} hook_buf; + +static void hook_result_reset(ds4_hook_result *result) { + if (!result) return; + memset(result, 0, sizeof(*result)); + result->exit_code = -1; +} + +static void hook_result_error(ds4_hook_result *result, const char *fmt, ...) { + if (!result) return; + va_list ap; + va_start(ap, fmt); + vsnprintf(result->error, sizeof(result->error), fmt, ap); + va_end(ap); +} + +static bool hook_buf_reserve(hook_buf *b, size_t add) { + if (add > SIZE_MAX - b->len - 1) return false; + size_t needed = b->len + add + 1; + if (needed <= b->cap) return true; + size_t cap = b->cap ? b->cap : 256; + while (cap < needed) { + if (cap > SIZE_MAX / 2) { + cap = needed; + break; + } + cap *= 2; + } + char *next = realloc(b->ptr, cap); + if (!next) return false; + b->ptr = next; + b->cap = cap; + return true; +} + +static bool hook_buf_append(hook_buf *b, const void *data, size_t len) { + if (!hook_buf_reserve(b, len)) return false; + memcpy(b->ptr + b->len, data, len); + b->len += len; + b->ptr[b->len] = '\0'; + return true; +} + +static bool hook_buf_puts(hook_buf *b, const char *text) { + return hook_buf_append(b, text, strlen(text)); +} + +static bool hook_buf_printf(hook_buf *b, const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + va_list copy; + va_copy(copy, ap); + int needed = vsnprintf(NULL, 0, fmt, ap); + va_end(ap); + if (needed < 0 || !hook_buf_reserve(b, (size_t)needed)) { + va_end(copy); + return false; + } + vsnprintf(b->ptr + b->len, b->cap - b->len, fmt, copy); + va_end(copy); + b->len += (size_t)needed; + return true; +} + +static bool hook_json_string_n(hook_buf *b, const char *text, size_t len) { + if (!hook_buf_puts(b, "\"")) return false; + for (size_t i = 0; i < len; i++) { + unsigned char c = (unsigned char)text[i]; + switch (c) { + case '"': + if (!hook_buf_puts(b, "\\\"")) return false; + break; + case '\\': + if (!hook_buf_puts(b, "\\\\")) return false; + break; + case '\b': + if (!hook_buf_puts(b, "\\b")) return false; + break; + case '\f': + if (!hook_buf_puts(b, "\\f")) return false; + break; + case '\n': + if (!hook_buf_puts(b, "\\n")) return false; + break; + case '\r': + if (!hook_buf_puts(b, "\\r")) return false; + break; + case '\t': + if (!hook_buf_puts(b, "\\t")) return false; + break; + default: + if (c < 0x20) { + if (!hook_buf_printf(b, "\\u%04x", (unsigned)c)) return false; + } else if (!hook_buf_append(b, &c, 1)) { + return false; + } + break; + } + } + return hook_buf_puts(b, "\""); +} + +static bool hook_json_string(hook_buf *b, const char *text) { + if (!text) return hook_buf_puts(b, "null"); + size_t len = strlen(text); + if (len > DS4_HOOK_MAX_TEXT_BYTES) len = DS4_HOOK_MAX_TEXT_BYTES; + return hook_json_string_n(b, text, len); +} + +const char *ds4_hook_event_name(ds4_hook_event event) { + switch (event) { + case DS4_HOOK_BEFORE_RESPONSE: + return "before_response"; + case DS4_HOOK_AFTER_RESPONSE: + return "after_response"; + } + return "unknown"; +} + +bool ds4_hook_command_enabled(const ds4_hook_config *config, + ds4_hook_event event) { + if (!config) return false; + const char *command = event == DS4_HOOK_BEFORE_RESPONSE ? + config->before_response_command : config->after_response_command; + return command && command[0] != '\0'; +} + +char *ds4_hook_payload_json(const ds4_hook_payload *payload) { + if (!payload) return NULL; + hook_buf b = {0}; + const char *user = payload->user_text; + const char *response = payload->response_text; + bool user_truncated = user && strlen(user) > DS4_HOOK_MAX_TEXT_BYTES; + bool response_truncated = response && + strlen(response) > DS4_HOOK_MAX_TEXT_BYTES; + + bool ok = hook_buf_puts(&b, "{") && + hook_buf_puts(&b, "\"event\":") && + hook_json_string(&b, ds4_hook_event_name(payload->event)) && + hook_buf_printf(&b, ",\"pid\":%ld", (long)getpid()) && + hook_buf_puts(&b, ",\"model\":") && + hook_json_string(&b, payload->model) && + hook_buf_puts(&b, ",\"user_text\":") && + hook_json_string(&b, user) && + hook_buf_puts(&b, ",\"response_text\":") && + hook_json_string(&b, response) && + hook_buf_printf(&b, ",\"response_index\":%d", payload->response_index) && + hook_buf_printf(&b, ",\"generated_tokens\":%d", payload->generated_tokens) && + hook_buf_printf(&b, ",\"interrupted\":%s", payload->interrupted ? "true" : "false") && + hook_buf_printf(&b, ",\"tool_call\":%s", payload->tool_call ? "true" : "false") && + hook_buf_printf(&b, ",\"user_text_truncated\":%s", user_truncated ? "true" : "false") && + hook_buf_printf(&b, ",\"response_text_truncated\":%s", response_truncated ? "true" : "false") && + hook_buf_puts(&b, "}\n"); + + if (!ok) { + free(b.ptr); + return NULL; + } + return b.ptr; +} + +static double hook_monotonic_seconds(void) { + struct timespec ts; + if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) return 0.0; + return (double)ts.tv_sec + (double)ts.tv_nsec / 1000000000.0; +} + +static void hook_sleep_milliseconds(int milliseconds) { + struct timespec req = { + .tv_sec = milliseconds / 1000, + .tv_nsec = (long)(milliseconds % 1000) * 1000000L, + }; + while (nanosleep(&req, &req) != 0 && errno == EINTR) {} +} + +static int hook_set_nonblocking(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + if (flags < 0) return -1; + return fcntl(fd, F_SETFL, flags | O_NONBLOCK); +} + +static int hook_wait_child(pid_t pid, int *status, double deadline) { + for (;;) { + pid_t waited = waitpid(pid, status, WNOHANG); + if (waited == pid) return 0; + if (waited < 0 && errno != EINTR) return -1; + if (hook_monotonic_seconds() >= deadline) return 1; + hook_sleep_milliseconds(DS4_HOOK_POLL_MILLISECONDS); + } +} + +static void hook_terminate_child(pid_t pid) { + (void)kill(-pid, SIGTERM); + double deadline = hook_monotonic_seconds() + + (double)DS4_HOOK_TERM_GRACE_MILLISECONDS / 1000.0; + int status = 0; + if (hook_wait_child(pid, &status, deadline) == 0) return; + (void)kill(-pid, SIGKILL); + while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {} +} + +static int hook_write_payload(int fd, const char *json, size_t len, + double deadline) { + size_t written = 0; + while (written < len) { + ssize_t n = write(fd, json + written, len - written); + if (n > 0) { + written += (size_t)n; + continue; + } + if (n < 0 && errno == EINTR) continue; + if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + double remaining = deadline - hook_monotonic_seconds(); + if (remaining <= 0.0) return 1; + int timeout_ms = (int)(remaining * 1000.0); + if (timeout_ms > DS4_HOOK_POLL_MILLISECONDS) + timeout_ms = DS4_HOOK_POLL_MILLISECONDS; + struct pollfd pfd = {.fd = fd, .events = POLLOUT}; + int rc = poll(&pfd, 1, timeout_ms); + if (rc < 0 && errno == EINTR) continue; + if (rc < 0) return -1; + continue; + } + if (n < 0 && errno == EPIPE) return -2; + return -1; + } + return 0; +} + +int ds4_hook_run(const ds4_hook_config *config, + const ds4_hook_payload *payload, + ds4_hook_result *result) { + hook_result_reset(result); + if (!payload || !ds4_hook_command_enabled(config, payload->event)) return 0; + + const char *command = payload->event == DS4_HOOK_BEFORE_RESPONSE ? + config->before_response_command : config->after_response_command; + int timeout = config->timeout_seconds > 0 ? + config->timeout_seconds : DS4_HOOK_DEFAULT_TIMEOUT_SECONDS; + + char *json = ds4_hook_payload_json(payload); + if (!json) { + hook_result_error(result, "failed to serialize hook payload"); + return -1; + } + + int input_pipe[2]; + if (pipe(input_pipe) != 0) { + hook_result_error(result, "pipe failed: %s", strerror(errno)); + free(json); + return -1; + } + + pid_t pid = fork(); + if (pid < 0) { + hook_result_error(result, "fork failed: %s", strerror(errno)); + close(input_pipe[0]); + close(input_pipe[1]); + free(json); + return -1; + } + + if (pid == 0) { + (void)setpgid(0, 0); + close(input_pipe[1]); + if (dup2(input_pipe[0], STDIN_FILENO) < 0) _exit(126); + close(input_pipe[0]); + execl("/bin/sh", "sh", "-c", command, (char *)NULL); + _exit(127); + } + + if (result) result->attempted = true; + close(input_pipe[0]); + (void)setpgid(pid, pid); + if (hook_set_nonblocking(input_pipe[1]) != 0) { + hook_result_error(result, "failed to configure hook input: %s", strerror(errno)); + close(input_pipe[1]); + hook_terminate_child(pid); + free(json); + return -1; + } + + double deadline = hook_monotonic_seconds() + (double)timeout; + sigset_t pipe_set; + sigset_t old_set; + sigemptyset(&pipe_set); + sigaddset(&pipe_set, SIGPIPE); + int mask_rc = pthread_sigmask(SIG_BLOCK, &pipe_set, &old_set); + int write_rc = hook_write_payload(input_pipe[1], json, strlen(json), deadline); + close(input_pipe[1]); + if (mask_rc == 0) { + struct timespec zero = {0}; + siginfo_t ignored; + while (sigtimedwait(&pipe_set, &ignored, &zero) >= 0) {} + (void)pthread_sigmask(SIG_SETMASK, &old_set, NULL); + } + free(json); + + if (write_rc == 1) { + if (result) result->timed_out = true; + hook_result_error(result, "timed out while delivering hook payload"); + hook_terminate_child(pid); + return -1; + } + if (write_rc < 0 && write_rc != -2) { + hook_result_error(result, "failed to write hook payload: %s", strerror(errno)); + hook_terminate_child(pid); + return -1; + } + + int status = 0; + int wait_rc = hook_wait_child(pid, &status, deadline); + if (wait_rc == 1) { + if (result) result->timed_out = true; + hook_result_error(result, "hook timed out after %d second%s", + timeout, timeout == 1 ? "" : "s"); + hook_terminate_child(pid); + return -1; + } + if (wait_rc < 0) { + hook_result_error(result, "waitpid failed: %s", strerror(errno)); + return -1; + } + + if (WIFEXITED(status)) { + int exit_code = WEXITSTATUS(status); + if (result) result->exit_code = exit_code; + if (exit_code == 0) return 0; + hook_result_error(result, "hook exited with status %d", exit_code); + return -1; + } + if (WIFSIGNALED(status)) { + int signal_number = WTERMSIG(status); + if (result) result->term_signal = signal_number; + hook_result_error(result, "hook terminated by signal %d", signal_number); + return -1; + } + + hook_result_error(result, "hook ended with an unknown process status"); + return -1; +} From c3f30b1ea13318b5b880d25625f131db2d3f6ba9 Mon Sep 17 00:00:00 2001 From: BerserkTime <89993099+Berserk-hub150@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:05:56 +0200 Subject: [PATCH 03/15] agent: parse hook options and emit response events --- ds4_agent_hooks.c | 305 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 ds4_agent_hooks.c diff --git a/ds4_agent_hooks.c b/ds4_agent_hooks.c new file mode 100644 index 000000000..60f83eb67 --- /dev/null +++ b/ds4_agent_hooks.c @@ -0,0 +1,305 @@ +#define _POSIX_C_SOURCE 200809L +#include "ds4.h" +#include "ds4_hooks.h" + +#include +#include +#include +#include +#include +#include +#include + +int ds4_agent_core_main(int argc, char **argv); + +typedef struct { + pthread_mutex_t mutex; + ds4_hook_config config; + ds4_tokens *transcript; + ds4_engine *engine; + char *user_text; + char *response_text; + size_t response_len; + size_t response_cap; + int response_index; + int generated_tokens; + bool response_open; +} ds4_agent_hook_state; + +typedef struct { + ds4_hook_payload payload; + char *model; + char *user_text; + char *response_text; +} ds4_agent_hook_snapshot; + +static ds4_agent_hook_state g_agent_hooks = { + .mutex = PTHREAD_MUTEX_INITIALIZER, +}; + +static char *hook_strdup(const char *text) { + if (!text) return NULL; + size_t len = strlen(text); + char *copy = malloc(len + 1); + if (!copy) return NULL; + memcpy(copy, text, len + 1); + return copy; +} + +static bool hook_text_reserve(size_t add) { + if (add > SIZE_MAX - g_agent_hooks.response_len - 1) return false; + size_t needed = g_agent_hooks.response_len + add + 1; + if (needed <= g_agent_hooks.response_cap) return true; + size_t cap = g_agent_hooks.response_cap ? g_agent_hooks.response_cap : 256; + while (cap < needed) cap *= 2; + char *next = realloc(g_agent_hooks.response_text, cap); + if (!next) return false; + g_agent_hooks.response_text = next; + g_agent_hooks.response_cap = cap; + return true; +} + +static void hook_text_reset_locked(void) { + g_agent_hooks.response_len = 0; + if (g_agent_hooks.response_text) g_agent_hooks.response_text[0] = '\0'; +} + +static void hook_text_append_locked(const char *text, size_t len) { + if (!text || !len || !hook_text_reserve(len)) return; + memcpy(g_agent_hooks.response_text + g_agent_hooks.response_len, text, len); + g_agent_hooks.response_len += len; + g_agent_hooks.response_text[g_agent_hooks.response_len] = '\0'; +} + +static void hook_snapshot_free(ds4_agent_hook_snapshot *snapshot) { + if (!snapshot) return; + free(snapshot->model); + free(snapshot->user_text); + free(snapshot->response_text); + memset(snapshot, 0, sizeof(*snapshot)); +} + +static ds4_agent_hook_snapshot hook_snapshot_locked(ds4_hook_event event, + bool interrupted, + bool tool_call) { + ds4_agent_hook_snapshot snapshot = {0}; + snapshot.model = hook_strdup(g_agent_hooks.engine ? + ds4_engine_model_name(g_agent_hooks.engine) : NULL); + snapshot.user_text = hook_strdup(g_agent_hooks.user_text); + snapshot.response_text = event == DS4_HOOK_AFTER_RESPONSE ? + hook_strdup(g_agent_hooks.response_text ? g_agent_hooks.response_text : "") : NULL; + snapshot.payload.event = event; + snapshot.payload.model = snapshot.model; + snapshot.payload.user_text = snapshot.user_text; + snapshot.payload.response_text = snapshot.response_text; + snapshot.payload.response_index = g_agent_hooks.response_index; + snapshot.payload.generated_tokens = g_agent_hooks.generated_tokens; + snapshot.payload.interrupted = interrupted; + snapshot.payload.tool_call = tool_call; + return snapshot; +} + +static void hook_run_snapshot(ds4_agent_hook_snapshot *snapshot) { + ds4_hook_result result; + int rc = ds4_hook_run(&g_agent_hooks.config, &snapshot->payload, &result); + if (rc != 0 && result.attempted) + fprintf(stderr, "ds4-agent: %s hook: %s\n", + ds4_hook_event_name(snapshot->payload.event), + result.error[0] ? result.error : "hook failed"); + hook_snapshot_free(snapshot); +} + +static bool hook_response_looks_like_tool_call(const char *text) { + return text && (strstr(text, "|DSML|") || + strstr(text, " 3600) return false; + *out = (int)value; + return true; +} + +static const char *hook_option_value(const char *arg, const char *name) { + size_t len = strlen(name); + return strncmp(arg, name, len) == 0 && arg[len] == '=' ? arg + len + 1 : NULL; +} + +static int hook_filter_options(int argc, char **argv, char ***filtered_out) { + char **filtered = calloc((size_t)argc + 1, sizeof(*filtered)); + if (!filtered) return -1; + int outc = 1; + filtered[0] = argv[0]; + for (int i = 1; i < argc; i++) { + const char *arg = argv[i]; + const char *value = NULL; + if (!strcmp(arg, "--help") && i + 1 < argc && !strcmp(argv[i + 1], "hooks")) { + hook_print_help(stdout); + free(filtered); + return 0; + } + if (!strcmp(arg, "--before-response-hook")) { + if (++i >= argc) goto missing; + g_agent_hooks.config.before_response_command = argv[i]; + continue; + } + if ((value = hook_option_value(arg, "--before-response-hook"))) { + g_agent_hooks.config.before_response_command = value; + continue; + } + if (!strcmp(arg, "--after-response-hook")) { + if (++i >= argc) goto missing; + g_agent_hooks.config.after_response_command = argv[i]; + continue; + } + if ((value = hook_option_value(arg, "--after-response-hook"))) { + g_agent_hooks.config.after_response_command = value; + continue; + } + if (!strcmp(arg, "--hook-timeout")) { + if (++i >= argc || !hook_parse_positive_int(argv[i], &g_agent_hooks.config.timeout_seconds)) + goto timeout_error; + continue; + } + if ((value = hook_option_value(arg, "--hook-timeout"))) { + if (!hook_parse_positive_int(value, &g_agent_hooks.config.timeout_seconds)) goto timeout_error; + continue; + } + if (!strcmp(arg, "--help") || !strcmp(arg, "-h")) hook_print_help(stdout); + filtered[outc++] = argv[i]; + } + filtered[outc] = NULL; + *filtered_out = filtered; + return outc; +missing: + fprintf(stderr, "ds4-agent: hook option requires a command\n"); + free(filtered); + return -1; +timeout_error: + fprintf(stderr, "ds4-agent: --hook-timeout must be an integer from 1 to 3600\n"); + free(filtered); + return -1; +} + +int main(int argc, char **argv) { + g_agent_hooks.config.timeout_seconds = 10; + char **filtered = NULL; + int filtered_argc = hook_filter_options(argc, argv, &filtered); + if (filtered_argc == 0) return 0; + if (filtered_argc < 0) return 2; + (void)atexit(hook_flush_at_exit); + int rc = ds4_agent_core_main(filtered_argc, filtered); + free(filtered); + return rc; +} + +void ds4_hooks_chat_append_message(ds4_engine *engine, ds4_tokens *tokens, + const char *role, const char *content) { + if (role && !strcmp(role, "tool")) hook_finish_response(false, true); + if (role && !strcmp(role, "user")) { + pthread_mutex_lock(&g_agent_hooks.mutex); + bool finish = g_agent_hooks.transcript == tokens && g_agent_hooks.response_open; + pthread_mutex_unlock(&g_agent_hooks.mutex); + if (finish) hook_finish_response(false, false); + } + ds4_chat_append_message(engine, tokens, role, content); + if (role && !strcmp(role, "user")) { + pthread_mutex_lock(&g_agent_hooks.mutex); + if (!g_agent_hooks.transcript || g_agent_hooks.transcript == tokens) { + g_agent_hooks.transcript = tokens; + g_agent_hooks.engine = engine; + free(g_agent_hooks.user_text); + g_agent_hooks.user_text = hook_strdup(content ? content : ""); + } + pthread_mutex_unlock(&g_agent_hooks.mutex); + } +} + +void ds4_hooks_chat_append_assistant_prefix(ds4_engine *engine, ds4_tokens *tokens, + ds4_think_mode think_mode) { + ds4_chat_append_assistant_prefix(engine, tokens, think_mode); + ds4_agent_hook_snapshot snapshot = {0}; + bool run = false; + pthread_mutex_lock(&g_agent_hooks.mutex); + if (g_agent_hooks.transcript == tokens) { + g_agent_hooks.engine = engine; + g_agent_hooks.response_index++; + g_agent_hooks.generated_tokens = 0; + g_agent_hooks.response_open = true; + hook_text_reset_locked(); + snapshot = hook_snapshot_locked(DS4_HOOK_BEFORE_RESPONSE, false, false); + run = ds4_hook_command_enabled(&g_agent_hooks.config, DS4_HOOK_BEFORE_RESPONSE); + } + pthread_mutex_unlock(&g_agent_hooks.mutex); + if (run) hook_run_snapshot(&snapshot); + else hook_snapshot_free(&snapshot); +} + +void ds4_hooks_tokens_push(ds4_tokens *tokens, int token) { + ds4_tokens_push(tokens, token); + pthread_mutex_lock(&g_agent_hooks.mutex); + bool active = g_agent_hooks.response_open && g_agent_hooks.transcript == tokens; + ds4_engine *engine = g_agent_hooks.engine; + pthread_mutex_unlock(&g_agent_hooks.mutex); + if (!active || !engine) return; + if (token == ds4_token_eos(engine)) { + hook_finish_response(false, false); + return; + } + size_t text_len = 0; + char *text = ds4_token_text(engine, token, &text_len); + pthread_mutex_lock(&g_agent_hooks.mutex); + if (g_agent_hooks.response_open && g_agent_hooks.transcript == tokens) { + hook_text_append_locked(text, text_len); + g_agent_hooks.generated_tokens++; + } + pthread_mutex_unlock(&g_agent_hooks.mutex); + free(text); +} + +bool ds4_hooks_token_is_stop_for_think_mode(ds4_engine *engine, int token, + ds4_think_mode mode) { + bool stop = ds4_token_is_stop_for_think_mode(engine, token, mode); + if (stop) hook_finish_response(false, false); + return stop; +} From d5893176724a7cd1f9d7e98900932b7b87b5be35 Mon Sep 17 00:00:00 2001 From: BerserkTime <89993099+Berserk-hub150@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:07:45 +0200 Subject: [PATCH 04/15] agent: wrap chat and token boundaries for hooks --- ds4_agent_with_hooks.c | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 ds4_agent_with_hooks.c diff --git a/ds4_agent_with_hooks.c b/ds4_agent_with_hooks.c new file mode 100644 index 000000000..01e775268 --- /dev/null +++ b/ds4_agent_with_hooks.c @@ -0,0 +1,17 @@ +/* Hook-enabled ds4-agent translation unit. + * The wrapper keeps the original implementation intact while interposing only + * the chat/token boundaries needed to emit hook events. */ +#define main ds4_agent_core_main +#define ds4_chat_append_message ds4_hooks_chat_append_message +#define ds4_chat_append_assistant_prefix ds4_hooks_chat_append_assistant_prefix +#define ds4_tokens_push ds4_hooks_tokens_push +#define ds4_token_is_stop_for_think_mode ds4_hooks_token_is_stop_for_think_mode +#include "ds4_agent.c" +#undef ds4_token_is_stop_for_think_mode +#undef ds4_tokens_push +#undef ds4_chat_append_assistant_prefix +#undef ds4_chat_append_message +#undef main + +#include "ds4_hooks.c" +#include "ds4_agent_hooks.c" From 219ab4d9c8ca94f2420c133f68f26632b14388f4 Mon Sep 17 00:00:00 2001 From: BerserkTime <89993099+Berserk-hub150@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:07:57 +0200 Subject: [PATCH 05/15] build: compile the hook-enabled agent translation unit --- GNUmakefile | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 GNUmakefile diff --git a/GNUmakefile b/GNUmakefile new file mode 100644 index 000000000..88ed52e25 --- /dev/null +++ b/GNUmakefile @@ -0,0 +1,20 @@ +# GNU make prefers this file over Makefile. Keep all upstream targets and only +# replace the two ds4-agent compilation rules with the hook-enabled translation +# unit. Other binaries and backends remain unchanged. +include Makefile + +DS4_AGENT_HOOK_SRCS := ds4_agent_with_hooks.c ds4_agent.c ds4_agent_hooks.c ds4_hooks.c ds4_hooks.h + +ds4_agent.o: $(DS4_AGENT_HOOK_SRCS) ds4.h ds4_ssd.h ds4_distributed.h ds4_help.h ds4_kvstore.h ds4_web.h linenoise.h + $(CC) $(CFLAGS) -c -o $@ ds4_agent_with_hooks.c + +ds4_agent_cpu.o: $(DS4_AGENT_HOOK_SRCS) ds4.h ds4_ssd.h ds4_distributed.h ds4_help.h ds4_kvstore.h ds4_web.h linenoise.h + $(CC) $(CFLAGS) -DDS4_NO_GPU -c -o $@ ds4_agent_with_hooks.c + +.PHONY: test-hooks + +tests/ds4_hooks_test: tests/ds4_hooks_test.c ds4_hooks.c ds4_hooks.h + $(CC) $(CFLAGS) -I. -o $@ tests/ds4_hooks_test.c ds4_hooks.c $(LDLIBS) + +test-hooks: tests/ds4_hooks_test + ./tests/ds4_hooks_test From ae35c55072d4950e50fe4a83af8959ef8f91fecc Mon Sep 17 00:00:00 2001 From: BerserkTime <89993099+Berserk-hub150@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:08:23 +0200 Subject: [PATCH 06/15] test: cover hook JSON execution and timeout behavior --- tests/ds4_hooks_test.c | 93 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/ds4_hooks_test.c diff --git a/tests/ds4_hooks_test.c b/tests/ds4_hooks_test.c new file mode 100644 index 000000000..f9a47dcf2 --- /dev/null +++ b/tests/ds4_hooks_test.c @@ -0,0 +1,93 @@ +#define _POSIX_C_SOURCE 200809L +#include "../ds4_hooks.h" + +#include +#include +#include +#include + +static int failures; +#define TEST_ASSERT(expr) do { if (!(expr)) { \ + fprintf(stderr, "%s:%d: assertion failed: %s\n", __FILE__, __LINE__, #expr); \ + failures++; \ +} } while (0) + +static ds4_hook_payload test_payload(ds4_hook_event event) { + ds4_hook_payload payload = { + .event = event, + .model = "test-model", + .user_text = "say \"hello\"\nnext", + .response_text = event == DS4_HOOK_AFTER_RESPONSE ? "hello\\world\n" : NULL, + .response_index = 2, + .generated_tokens = 3, + }; + return payload; +} + +static void test_payload_json(void) { + ds4_hook_payload payload = test_payload(DS4_HOOK_AFTER_RESPONSE); + char *json = ds4_hook_payload_json(&payload); + TEST_ASSERT(json != NULL); + if (json) { + TEST_ASSERT(strstr(json, "\"event\":\"after_response\"") != NULL); + TEST_ASSERT(strstr(json, "say \\\"hello\\\"\\nnext") != NULL); + TEST_ASSERT(strstr(json, "hello\\\\world\\n") != NULL); + TEST_ASSERT(strstr(json, "\"generated_tokens\":3") != NULL); + } + free(json); +} + +static void test_disabled_hook(void) { + ds4_hook_config config = {0}; + ds4_hook_payload payload = test_payload(DS4_HOOK_BEFORE_RESPONSE); + ds4_hook_result result; + TEST_ASSERT(ds4_hook_run(&config, &payload, &result) == 0); + TEST_ASSERT(!result.attempted); +} + +static void test_hook_stdin(void) { + char path[] = "/tmp/ds4-hook-test-XXXXXX"; + int fd = mkstemp(path); + TEST_ASSERT(fd >= 0); + if (fd < 0) return; + close(fd); + char command[512]; + snprintf(command, sizeof(command), "cat > '%s'", path); + ds4_hook_config config = {.after_response_command = command, .timeout_seconds = 2}; + ds4_hook_payload payload = test_payload(DS4_HOOK_AFTER_RESPONSE); + ds4_hook_result result; + TEST_ASSERT(ds4_hook_run(&config, &payload, &result) == 0); + FILE *fp = fopen(path, "rb"); + TEST_ASSERT(fp != NULL); + if (fp) { + char data[2048]; + size_t n = fread(data, 1, sizeof(data) - 1, fp); + data[n] = '\0'; + fclose(fp); + TEST_ASSERT(strstr(data, "\"model\":\"test-model\"") != NULL); + } + unlink(path); +} + +static void test_failures(void) { + ds4_hook_payload before = test_payload(DS4_HOOK_BEFORE_RESPONSE); + ds4_hook_config bad = {.before_response_command = "exit 7", .timeout_seconds = 2}; + ds4_hook_result result; + TEST_ASSERT(ds4_hook_run(&bad, &before, &result) != 0); + TEST_ASSERT(result.exit_code == 7); + + ds4_hook_payload after = test_payload(DS4_HOOK_AFTER_RESPONSE); + ds4_hook_config slow = {.after_response_command = "sleep 5", .timeout_seconds = 1}; + TEST_ASSERT(ds4_hook_run(&slow, &after, &result) != 0); + TEST_ASSERT(result.timed_out); +} + +int main(void) { + test_payload_json(); + test_disabled_hook(); + test_hook_stdin(); + test_failures(); + if (failures) return 1; + puts("ds4 hook tests: ok"); + return 0; +} From 51d6a199443515bb7b7b9a98d3e83d03e90c6ee6 Mon Sep 17 00:00:00 2001 From: BerserkTime <89993099+Berserk-hub150@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:08:36 +0200 Subject: [PATCH 07/15] docs: explain agent hook lifecycle and safety --- docs/AGENT_HOOKS.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 docs/AGENT_HOOKS.md diff --git a/docs/AGENT_HOOKS.md b/docs/AGENT_HOOKS.md new file mode 100644 index 000000000..2fe1538c9 --- /dev/null +++ b/docs/AGENT_HOOKS.md @@ -0,0 +1,23 @@ +# ds4-agent hooks + +`ds4-agent` can run optional shell commands immediately before model generation and after a response finishes. + +```sh +./ds4-agent \ + --before-response-hook 'cat >> /tmp/ds4-before.jsonl' \ + --after-response-hook 'curl -fsS -X POST -H "Content-Type: application/json" --data-binary @- https://example.test/ds4-hook' \ + --hook-timeout 10 +``` + +The hook receives one JSON object on standard input. Hooks are disabled by default. + +## Events + +- `before_response`: emitted after the user turn is added and immediately before generation. +- `after_response`: emitted after the assistant turn ends, including accumulated response text, token count, interruption state, and tool-call state. + +## Failure behavior + +Hook failures are reported on stderr but do not abort the conversation. Commands run as `/bin/sh -c COMMAND` in their own process group. The runner enforces the configured timeout, sends `SIGTERM`, then uses `SIGKILL` after a short grace period. + +Treat hook commands as trusted local configuration. Read event data from stdin instead of inserting prompt text into the command string. From 310dc3a822953e9cda9270ffc9cce44f82363708 Mon Sep 17 00:00:00 2001 From: BerserkTime <89993099+Berserk-hub150@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:08:47 +0200 Subject: [PATCH 08/15] docs: publish the hook event JSON schema --- docs/AGENT_HOOKS_SCHEMA.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 docs/AGENT_HOOKS_SCHEMA.json diff --git a/docs/AGENT_HOOKS_SCHEMA.json b/docs/AGENT_HOOKS_SCHEMA.json new file mode 100644 index 000000000..21ca0a5c5 --- /dev/null +++ b/docs/AGENT_HOOKS_SCHEMA.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ds4-agent hook event", + "type": "object", + "required": ["event", "pid", "response_index", "generated_tokens", "interrupted", "tool_call"], + "properties": { + "event": {"enum": ["before_response", "after_response"]}, + "pid": {"type": "integer", "minimum": 1}, + "model": {"type": ["string", "null"]}, + "user_text": {"type": ["string", "null"]}, + "response_text": {"type": ["string", "null"]}, + "response_index": {"type": "integer", "minimum": 0}, + "generated_tokens": {"type": "integer", "minimum": 0}, + "interrupted": {"type": "boolean"}, + "tool_call": {"type": "boolean"}, + "user_text_truncated": {"type": "boolean"}, + "response_text_truncated": {"type": "boolean"} + }, + "additionalProperties": false +} From fbb06a6571a56b5a45dae5debcd26fb4681606b5 Mon Sep 17 00:00:00 2001 From: BerserkTime <89993099+Berserk-hub150@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:09:01 +0200 Subject: [PATCH 09/15] examples: add an HTTP callback hook --- examples/hooks/notify-curl.sh | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 examples/hooks/notify-curl.sh diff --git a/examples/hooks/notify-curl.sh b/examples/hooks/notify-curl.sh new file mode 100644 index 000000000..a6c5dd339 --- /dev/null +++ b/examples/hooks/notify-curl.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu +: "${DS4_HOOK_URL:?set DS4_HOOK_URL}" +curl --fail --silent --show-error \ + -H 'Content-Type: application/json' \ + --data-binary @- \ + "$DS4_HOOK_URL" From 60369697d19fc433959b8f5aa11740f75a9b630d Mon Sep 17 00:00:00 2001 From: BerserkTime <89993099+Berserk-hub150@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:09:12 +0200 Subject: [PATCH 10/15] examples: add a JSONL audit hook --- examples/hooks/log-json.sh | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 examples/hooks/log-json.sh diff --git a/examples/hooks/log-json.sh b/examples/hooks/log-json.sh new file mode 100644 index 000000000..2a61b456e --- /dev/null +++ b/examples/hooks/log-json.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu +log=${DS4_HOOK_LOG:-./ds4-agent-hooks.jsonl} +cat >> "$log" From b1ae5fbbf9a229926608bde76c94e692f47863b6 Mon Sep 17 00:00:00 2001 From: BerserkTime <89993099+Berserk-hub150@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:09:23 +0200 Subject: [PATCH 11/15] examples: add a PowerShell callback hook --- examples/hooks/notify-powershell.ps1 | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 examples/hooks/notify-powershell.ps1 diff --git a/examples/hooks/notify-powershell.ps1 b/examples/hooks/notify-powershell.ps1 new file mode 100644 index 000000000..53f014ea3 --- /dev/null +++ b/examples/hooks/notify-powershell.ps1 @@ -0,0 +1,4 @@ +$ErrorActionPreference = 'Stop' +if (-not $env:DS4_HOOK_URL) { throw 'Set DS4_HOOK_URL' } +$payload = [Console]::In.ReadToEnd() +Invoke-RestMethod -Method Post -Uri $env:DS4_HOOK_URL -ContentType 'application/json' -Body $payload | Out-Null From 310aed31b8cf4be59b4de4d54015a8d0aaafdc17 Mon Sep 17 00:00:00 2001 From: BerserkTime <89993099+Berserk-hub150@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:09:31 +0200 Subject: [PATCH 12/15] test: add a standalone hook smoke command --- tests/hooks_smoke.sh | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 tests/hooks_smoke.sh diff --git a/tests/hooks_smoke.sh b/tests/hooks_smoke.sh new file mode 100644 index 000000000..49896d3e1 --- /dev/null +++ b/tests/hooks_smoke.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu +make test-hooks +printf '%s\n' 'hook smoke: ok' From b87671a7bb8abf91729081b78ca878b64827bb0c Mon Sep 17 00:00:00 2001 From: BerserkTime <89993099+Berserk-hub150@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:09:45 +0200 Subject: [PATCH 13/15] qa: add the agent hook regression checklist --- QA_AGENT_HOOKS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 QA_AGENT_HOOKS.md diff --git a/QA_AGENT_HOOKS.md b/QA_AGENT_HOOKS.md new file mode 100644 index 000000000..dcad9ca7c --- /dev/null +++ b/QA_AGENT_HOOKS.md @@ -0,0 +1,13 @@ +# Agent hooks QA + +- [ ] `make test-hooks` +- [ ] `make cpu` +- [ ] `./ds4-agent --help hooks` lists all three options +- [ ] no hook command means no child process is started +- [ ] before event contains user text and no response text +- [ ] after event contains reconstructed response text and token count +- [ ] non-zero exits are reported without ending the session +- [ ] timeout kills the command process group +- [ ] interrupted responses set `interrupted: true` +- [ ] tool turns set `tool_call: true` +- [ ] quotes, backslashes, control bytes, and UTF-8 serialize correctly From d7a1df03a9329ee4bcd1baf7c4f2ca0375a65d4b Mon Sep 17 00:00:00 2001 From: BerserkTime <89993099+Berserk-hub150@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:09:56 +0200 Subject: [PATCH 14/15] examples: add hook environment defaults --- misc/agent-hooks-example.env | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 misc/agent-hooks-example.env diff --git a/misc/agent-hooks-example.env b/misc/agent-hooks-example.env new file mode 100644 index 000000000..42d58644c --- /dev/null +++ b/misc/agent-hooks-example.env @@ -0,0 +1,3 @@ +# Example environment consumed by examples/hooks/*.sh +DS4_HOOK_URL=https://example.invalid/ds4-agent-event +DS4_HOOK_LOG=./ds4-agent-hooks.jsonl From 979b1bd47d4717b3001538732e12171eab491776 Mon Sep 17 00:00:00 2001 From: BerserkTime <89993099+Berserk-hub150@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:11:34 +0200 Subject: [PATCH 15/15] docs: add runnable hook examples and finalize wrapper declarations --- ds4_agent_with_hooks.c | 7 +++++++ examples/hooks/README.md | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 examples/hooks/README.md diff --git a/ds4_agent_with_hooks.c b/ds4_agent_with_hooks.c index 01e775268..aac9f3dfe 100644 --- a/ds4_agent_with_hooks.c +++ b/ds4_agent_with_hooks.c @@ -13,5 +13,12 @@ #undef ds4_chat_append_message #undef main +/* ds4.h was included while the interposition macros were active, so declare + * the original functions explicitly for the wrapper implementation below. */ +void ds4_chat_append_message(ds4_engine *, ds4_tokens *, const char *, const char *); +void ds4_chat_append_assistant_prefix(ds4_engine *, ds4_tokens *, ds4_think_mode); +void ds4_tokens_push(ds4_tokens *, int); +bool ds4_token_is_stop_for_think_mode(ds4_engine *, int, ds4_think_mode); + #include "ds4_hooks.c" #include "ds4_agent_hooks.c" diff --git a/examples/hooks/README.md b/examples/hooks/README.md new file mode 100644 index 000000000..8d76e429d --- /dev/null +++ b/examples/hooks/README.md @@ -0,0 +1,21 @@ +# Hook command examples + +All commands read exactly one JSON event from standard input. + +```sh +export DS4_HOOK_LOG="$PWD/hooks.jsonl" +./ds4-agent --after-response-hook './examples/hooks/log-json.sh' +``` + +For an HTTP callback: + +```sh +export DS4_HOOK_URL='https://example.test/events' +./ds4-agent --after-response-hook './examples/hooks/notify-curl.sh' +``` + +PowerShell can be invoked from a POSIX host when `pwsh` is available: + +```sh +./ds4-agent --after-response-hook 'pwsh -File ./examples/hooks/notify-powershell.ps1' +```