diff --git a/Makefile b/Makefile index 72ed73bee..2f0b12401 100644 --- a/Makefile +++ b/Makefile @@ -57,15 +57,15 @@ ds4-bench: ds4_bench.o ds4_help.o $(CORE_OBJS) ds4-eval: ds4_eval.o ds4_help.o $(CORE_OBJS) $(CC) $(CFLAGS) -o $@ ds4_eval.o ds4_help.o $(CORE_OBJS) $(METAL_LDLIBS) -ds4-agent: ds4_agent.o ds4_help.o ds4_web.o ds4_kvstore.o linenoise.o $(CORE_OBJS) - $(CC) $(CFLAGS) -o $@ ds4_agent.o ds4_help.o ds4_web.o ds4_kvstore.o linenoise.o $(CORE_OBJS) $(METAL_LDLIBS) +ds4-agent: ds4_agent.o ds4_acp.o ds4_mcp.o ds4_help.o ds4_web.o ds4_kvstore.o linenoise.o $(CORE_OBJS) + $(CC) $(CFLAGS) -o $@ ds4_agent.o ds4_acp.o ds4_mcp.o ds4_help.o ds4_web.o ds4_kvstore.o linenoise.o $(CORE_OBJS) $(METAL_LDLIBS) -cpu: ds4_cli_cpu.o ds4_server_cpu.o ds4_bench_cpu.o ds4_eval_cpu.o ds4_agent_cpu.o ds4_help.o ds4_web.o ds4_kvstore.o linenoise.o rax.o $(CPU_CORE_OBJS) +cpu: ds4_cli_cpu.o ds4_server_cpu.o ds4_bench_cpu.o ds4_eval_cpu.o ds4_agent_cpu.o ds4_acp.o ds4_mcp.o ds4_help.o ds4_web.o ds4_kvstore.o linenoise.o rax.o $(CPU_CORE_OBJS) $(CC) $(CFLAGS) -o ds4 ds4_cli_cpu.o ds4_help.o linenoise.o $(CPU_CORE_OBJS) $(LDLIBS) $(CC) $(CFLAGS) -o ds4-server ds4_server_cpu.o ds4_help.o ds4_kvstore.o rax.o $(CPU_CORE_OBJS) $(LDLIBS) $(CC) $(CFLAGS) -o ds4-bench ds4_bench_cpu.o ds4_help.o $(CPU_CORE_OBJS) $(LDLIBS) $(CC) $(CFLAGS) -o ds4-eval ds4_eval_cpu.o ds4_help.o $(CPU_CORE_OBJS) $(LDLIBS) - $(CC) $(CFLAGS) -o ds4-agent ds4_agent_cpu.o ds4_help.o ds4_web.o ds4_kvstore.o linenoise.o $(CPU_CORE_OBJS) $(LDLIBS) + $(CC) $(CFLAGS) -o ds4-agent ds4_agent_cpu.o ds4_acp.o ds4_mcp.o ds4_help.o ds4_web.o ds4_kvstore.o linenoise.o $(CPU_CORE_OBJS) $(LDLIBS) cuda-regression: @echo "cuda-regression requires a CUDA build" @@ -107,15 +107,15 @@ ds4-bench: ds4_bench.o ds4_help.o $(CORE_OBJS) ds4-eval: ds4_eval.o ds4_help.o $(CORE_OBJS) $(NVCC) $(NVCCFLAGS) -o $@ $^ $(CUDA_LDLIBS) -ds4-agent: ds4_agent.o ds4_help.o ds4_web.o ds4_kvstore.o linenoise.o $(CORE_OBJS) +ds4-agent: ds4_agent.o ds4_acp.o ds4_mcp.o ds4_help.o ds4_web.o ds4_kvstore.o linenoise.o $(CORE_OBJS) $(NVCC) $(NVCCFLAGS) -o $@ $^ $(CUDA_LDLIBS) -cpu: ds4_cli_cpu.o ds4_server_cpu.o ds4_bench_cpu.o ds4_eval_cpu.o ds4_agent_cpu.o ds4_help.o ds4_web.o ds4_kvstore.o linenoise.o rax.o $(CPU_CORE_OBJS) +cpu: ds4_cli_cpu.o ds4_server_cpu.o ds4_bench_cpu.o ds4_eval_cpu.o ds4_agent_cpu.o ds4_acp.o ds4_mcp.o ds4_help.o ds4_web.o ds4_kvstore.o linenoise.o rax.o $(CPU_CORE_OBJS) $(CC) $(CFLAGS) -o ds4 ds4_cli_cpu.o ds4_help.o linenoise.o $(CPU_CORE_OBJS) $(LDLIBS) $(CC) $(CFLAGS) -o ds4-server ds4_server_cpu.o ds4_help.o ds4_kvstore.o rax.o $(CPU_CORE_OBJS) $(LDLIBS) $(CC) $(CFLAGS) -o ds4-bench ds4_bench_cpu.o ds4_help.o $(CPU_CORE_OBJS) $(LDLIBS) $(CC) $(CFLAGS) -o ds4-eval ds4_eval_cpu.o ds4_help.o $(CPU_CORE_OBJS) $(LDLIBS) - $(CC) $(CFLAGS) -o ds4-agent ds4_agent_cpu.o ds4_help.o ds4_web.o ds4_kvstore.o linenoise.o $(CPU_CORE_OBJS) $(LDLIBS) + $(CC) $(CFLAGS) -o ds4-agent ds4_agent_cpu.o ds4_acp.o ds4_mcp.o ds4_help.o ds4_web.o ds4_kvstore.o linenoise.o $(CPU_CORE_OBJS) $(LDLIBS) cuda-regression: tests/cuda_long_context_smoke ./tests/cuda_long_context_smoke @@ -145,16 +145,22 @@ ds4_bench.o: ds4_bench.c ds4.h ds4_ssd.h ds4_distributed.h ds4_help.h ds4_eval.o: ds4_eval.c ds4.h ds4_ssd.h ds4_distributed.h ds4_help.h $(CC) $(CFLAGS) -c -o $@ ds4_eval.c -ds4_agent.o: ds4_agent.c ds4.h ds4_ssd.h ds4_distributed.h ds4_help.h ds4_kvstore.h ds4_web.h linenoise.h +ds4_agent.o: ds4_agent.c ds4.h ds4_acp.h ds4_mcp.h ds4_ssd.h ds4_distributed.h ds4_help.h ds4_kvstore.h ds4_web.h linenoise.h $(CC) $(CFLAGS) -c -o $@ ds4_agent.c +ds4_acp.o: ds4_acp.c ds4_acp.h + $(CC) $(CFLAGS) -c -o $@ ds4_acp.c + +ds4_mcp.o: ds4_mcp.c ds4_mcp.h ds4_acp.h + $(CC) $(CFLAGS) -c -o $@ ds4_mcp.c + ds4_web.o: ds4_web.c ds4_web.h $(CC) $(CFLAGS) -c -o $@ ds4_web.c ds4_kvstore.o: ds4_kvstore.c ds4_kvstore.h ds4.h ds4_ssd.h $(CC) $(CFLAGS) -c -o $@ ds4_kvstore.c -ds4_test.o: tests/ds4_test.c ds4_server.c ds4.h ds4_ssd.h ds4_distributed.h ds4_help.h ds4_kvstore.h rax.h +ds4_test.o: tests/ds4_test.c ds4_server.c ds4_acp.h ds4_mcp.h ds4.h ds4_ssd.h ds4_distributed.h ds4_help.h ds4_kvstore.h rax.h $(CC) $(CFLAGS) -Wno-unused-function -c -o $@ tests/ds4_test.c tests/cuda_long_context_smoke.o: tests/cuda_long_context_smoke.c ds4_gpu.h @@ -181,7 +187,7 @@ ds4_bench_cpu.o: ds4_bench.c ds4.h ds4_ssd.h ds4_distributed.h ds4_help.h ds4_eval_cpu.o: ds4_eval.c ds4.h ds4_ssd.h ds4_distributed.h ds4_help.h $(CC) $(CFLAGS) -DDS4_NO_GPU -c -o $@ ds4_eval.c -ds4_agent_cpu.o: ds4_agent.c ds4.h ds4_ssd.h ds4_distributed.h ds4_help.h ds4_kvstore.h ds4_web.h linenoise.h +ds4_agent_cpu.o: ds4_agent.c ds4.h ds4_acp.h ds4_mcp.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.c ds4_metal.o: ds4_metal.m ds4_gpu.h $(METAL_SRCS) @@ -193,11 +199,11 @@ ds4_cuda.o: ds4_cuda.cu ds4_gpu.h ds4_iq2_tables_cuda.inc tests/cuda_long_context_smoke: tests/cuda_long_context_smoke.o ds4_cuda.o $(NVCC) $(NVCCFLAGS) -o $@ $^ $(CUDA_LDLIBS) -ds4_test: ds4_test.o ds4_help.o ds4_kvstore.o rax.o $(CORE_OBJS) +ds4_test: ds4_test.o ds4_acp.o ds4_mcp.o ds4_help.o ds4_kvstore.o rax.o $(CORE_OBJS) ifeq ($(UNAME_S),Darwin) - $(CC) $(CFLAGS) -o $@ ds4_test.o ds4_help.o ds4_kvstore.o rax.o $(CORE_OBJS) $(METAL_LDLIBS) + $(CC) $(CFLAGS) -o $@ ds4_test.o ds4_acp.o ds4_mcp.o ds4_help.o ds4_kvstore.o rax.o $(CORE_OBJS) $(METAL_LDLIBS) else - $(NVCC) $(NVCCFLAGS) -o $@ ds4_test.o ds4_help.o ds4_kvstore.o rax.o $(CORE_OBJS) $(CUDA_LDLIBS) + $(NVCC) $(NVCCFLAGS) -o $@ ds4_test.o ds4_acp.o ds4_mcp.o ds4_help.o ds4_kvstore.o rax.o $(CORE_OBJS) $(CUDA_LDLIBS) endif test: ds4_test ds4-eval q4k-dot-test diff --git a/README.md b/README.md index fcd259385..d42438f7f 100644 --- a/README.md +++ b/README.md @@ -587,6 +587,10 @@ stripped session rebuilds the KV cache by prefilling the saved text. Use `--chdir /path/to/ds4` when launching `ds4-agent` from another directory, so relative runtime files such as `metal/*.metal` resolve from the project tree. +`ds4-agent --acp` runs the agent over the Agent Client Protocol using +newline-delimited JSON-RPC on stdin/stdout. In this mode stdout is reserved for +protocol messages; diagnostics continue to go to stderr. + However while the system already works, there is a lot of work to do in order to make it ready for prime time. When finally the agent will reach the wanted shape, we will *likely* split the server and the client creating a stateful diff --git a/ds4_acp.c b/ds4_acp.c new file mode 100644 index 000000000..2e02a6cef --- /dev/null +++ b/ds4_acp.c @@ -0,0 +1,577 @@ +#include "ds4_acp.h" + +#include +#include +#include +#include +#include + +#define DS4_ACP_JSON_MAX_NESTING 256 + +typedef struct { + char *ptr; + size_t len; + size_t cap; +} acp_buf; + +static void acp_oom(const char *what) { + perror(what); + exit(1); +} + +static void acp_buf_append(acp_buf *b, const char *s, size_t n) { + if (!n) return; + if (b->len + n + 1 > b->cap) { + size_t cap = b->cap ? b->cap * 2 : 256; + while (cap < b->len + n + 1) cap *= 2; + char *p = realloc(b->ptr, cap); + if (!p) acp_oom("ds4-acp: realloc"); + b->ptr = p; + b->cap = cap; + } + memcpy(b->ptr + b->len, s, n); + b->len += n; + b->ptr[b->len] = '\0'; +} + +static void acp_buf_puts(acp_buf *b, const char *s) { + acp_buf_append(b, s, strlen(s)); +} + +static void acp_buf_putc(acp_buf *b, char c) { + acp_buf_append(b, &c, 1); +} + +static char *acp_buf_take(acp_buf *b) { + if (!b->ptr) { + char *p = malloc(1); + if (!p) acp_oom("ds4-acp: malloc"); + p[0] = '\0'; + return p; + } + char *p = b->ptr; + memset(b, 0, sizeof(*b)); + return p; +} + +static char *acp_xstrndup(const char *s, size_t n) { + char *p = malloc(n + 1); + if (!p) acp_oom("ds4-acp: malloc"); + memcpy(p, s, n); + p[n] = '\0'; + return p; +} + +static bool acp_utf8_valid(const char *s, size_t n) { + size_t i = 0; + while (i < n) { + unsigned char c = (unsigned char)s[i++]; + if (c < 0x80) continue; + int need = 0; + if (c >= 0xc2 && c <= 0xdf) need = 1; + else if (c >= 0xe0 && c <= 0xef) need = 2; + else if (c >= 0xf0 && c <= 0xf4) need = 3; + else return false; + if (i + (size_t)need > n) return false; + unsigned char c1 = (unsigned char)s[i]; + if (c == 0xe0 && c1 < 0xa0) return false; + if (c == 0xed && c1 >= 0xa0) return false; + if (c == 0xf0 && c1 < 0x90) return false; + if (c == 0xf4 && c1 >= 0x90) return false; + for (int j = 0; j < need; j++) { + unsigned char cc = (unsigned char)s[i + (size_t)j]; + if ((cc & 0xc0) != 0x80) return false; + } + i += (size_t)need; + } + return true; +} + +void ds4_acp_json_ws(const char **p) { + while (**p && isspace((unsigned char)**p)) (*p)++; +} + +static bool json_lit(const char **p, const char *lit) { + size_t n = strlen(lit); + if (strncmp(*p, lit, n) != 0) return false; + *p += n; + return true; +} + +static int json_hex(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +static bool json_u16(const char **p, uint32_t *out) { + if ((*p)[0] != '\\' || (*p)[1] != 'u') return false; + uint32_t cp = 0; + for (int i = 0; i < 4; i++) { + int h = json_hex((*p)[2 + i]); + if (h < 0) return false; + cp = (cp << 4) | (uint32_t)h; + } + *p += 6; + *out = cp; + return true; +} + +static void json_utf8(acp_buf *b, uint32_t cp) { + char tmp[4]; + if (cp <= 0x7f) { + tmp[0] = (char)cp; + acp_buf_append(b, tmp, 1); + } else if (cp <= 0x7ff) { + tmp[0] = (char)(0xc0 | (cp >> 6)); + tmp[1] = (char)(0x80 | (cp & 0x3f)); + acp_buf_append(b, tmp, 2); + } else if (cp <= 0xffff) { + tmp[0] = (char)(0xe0 | (cp >> 12)); + tmp[1] = (char)(0x80 | ((cp >> 6) & 0x3f)); + tmp[2] = (char)(0x80 | (cp & 0x3f)); + acp_buf_append(b, tmp, 3); + } else { + tmp[0] = (char)(0xf0 | (cp >> 18)); + tmp[1] = (char)(0x80 | ((cp >> 12) & 0x3f)); + tmp[2] = (char)(0x80 | ((cp >> 6) & 0x3f)); + tmp[3] = (char)(0x80 | (cp & 0x3f)); + acp_buf_append(b, tmp, 4); + } +} + +bool ds4_acp_json_string(const char **p, char **out) { + ds4_acp_json_ws(p); + if (**p != '"') return false; + (*p)++; + acp_buf b = {0}; + while (**p && **p != '"') { + unsigned char c = (unsigned char)**p; + if (c < 0x20) goto fail; + if (c == '\\') { + (*p)++; + switch (**p) { + case '"': acp_buf_putc(&b, '"'); (*p)++; break; + case '\\': acp_buf_putc(&b, '\\'); (*p)++; break; + case '/': acp_buf_putc(&b, '/'); (*p)++; break; + case 'b': acp_buf_putc(&b, '\b'); (*p)++; break; + case 'f': acp_buf_putc(&b, '\f'); (*p)++; break; + case 'n': acp_buf_putc(&b, '\n'); (*p)++; break; + case 'r': acp_buf_putc(&b, '\r'); (*p)++; break; + case 't': acp_buf_putc(&b, '\t'); (*p)++; break; + case 'u': { + (*p)--; + uint32_t cp, lo = 0; + if (!json_u16(p, &cp)) goto fail; + if (cp >= 0xd800 && cp <= 0xdbff) { + const char *q = *p; + if (!json_u16(&q, &lo) || + lo < 0xdc00 || lo > 0xdfff) + goto fail; + *p = q; + cp = 0x10000 + (((cp - 0xd800) << 10) | (lo - 0xdc00)); + } else if (cp >= 0xdc00 && cp <= 0xdfff) { + goto fail; + } + json_utf8(&b, cp); + break; + } + default: + goto fail; + } + } else { + acp_buf_putc(&b, (char)c); + (*p)++; + } + } + if (**p != '"') goto fail; + (*p)++; + if (b.ptr && !acp_utf8_valid(b.ptr, b.len)) goto fail; + *out = acp_buf_take(&b); + return true; + +fail: + free(b.ptr); + return false; +} + +static bool json_number(const char **p) { + const char *s = *p; + if (*s == '-') s++; + if (*s == '0') { + s++; + } else if (isdigit((unsigned char)*s)) { + while (isdigit((unsigned char)*s)) s++; + } else { + return false; + } + if (*s == '.') { + s++; + if (!isdigit((unsigned char)*s)) return false; + while (isdigit((unsigned char)*s)) s++; + } + if (*s == 'e' || *s == 'E') { + s++; + if (*s == '+' || *s == '-') s++; + if (!isdigit((unsigned char)*s)) return false; + while (isdigit((unsigned char)*s)) s++; + } + *p = s; + return true; +} + +static bool json_skip_value_depth(const char **p, int depth); + +static bool json_skip_array_depth(const char **p, int depth) { + if (depth >= DS4_ACP_JSON_MAX_NESTING) return false; + ds4_acp_json_ws(p); + if (**p != '[') return false; + (*p)++; + ds4_acp_json_ws(p); + if (**p == ']') { + (*p)++; + return true; + } + while (**p) { + if (!json_skip_value_depth(p, depth + 1)) return false; + ds4_acp_json_ws(p); + if (**p == ']') { + (*p)++; + return true; + } + if (**p != ',') return false; + (*p)++; + } + return false; +} + +static bool json_skip_object_depth(const char **p, int depth) { + if (depth >= DS4_ACP_JSON_MAX_NESTING) return false; + ds4_acp_json_ws(p); + if (**p != '{') return false; + (*p)++; + ds4_acp_json_ws(p); + if (**p == '}') { + (*p)++; + return true; + } + while (**p) { + char *key = NULL; + if (!ds4_acp_json_string(p, &key)) return false; + free(key); + ds4_acp_json_ws(p); + if (**p != ':') return false; + (*p)++; + if (!json_skip_value_depth(p, depth + 1)) return false; + ds4_acp_json_ws(p); + if (**p == '}') { + (*p)++; + return true; + } + if (**p != ',') return false; + (*p)++; + } + return false; +} + +static bool json_skip_value_depth(const char **p, int depth) { + ds4_acp_json_ws(p); + if (**p == '"') { + char *s = NULL; + bool ok = ds4_acp_json_string(p, &s); + free(s); + return ok; + } + if (**p == '{') return json_skip_object_depth(p, depth); + if (**p == '[') return json_skip_array_depth(p, depth); + if (json_lit(p, "true") || json_lit(p, "false") || json_lit(p, "null")) + return true; + return json_number(p); +} + +bool ds4_acp_json_skip_value(const char **p) { + return json_skip_value_depth(p, 0); +} + +bool ds4_acp_json_raw_value(const char **p, char **out) { + ds4_acp_json_ws(p); + const char *start = *p; + if (!ds4_acp_json_skip_value(p)) return false; + *out = acp_xstrndup(start, (size_t)(*p - start)); + return true; +} + +static bool json_id_value(const char *raw) { + const char *p = raw; + ds4_acp_json_ws(&p); + if (*p == '"' || *p == '-' || isdigit((unsigned char)*p)) return true; + return !strcmp(p, "null"); +} + +bool ds4_acp_object_get_raw(const char *json, const char *key, char **out) { + const char *p = json; + ds4_acp_json_ws(&p); + if (*p != '{') return false; + p++; + ds4_acp_json_ws(&p); + while (*p && *p != '}') { + char *k = NULL; + if (!ds4_acp_json_string(&p, &k)) return false; + ds4_acp_json_ws(&p); + if (*p != ':') { + free(k); + return false; + } + p++; + if (!strcmp(k, key)) { + free(k); + return ds4_acp_json_raw_value(&p, out); + } + free(k); + if (!ds4_acp_json_skip_value(&p)) return false; + ds4_acp_json_ws(&p); + if (*p == ',') { + p++; + ds4_acp_json_ws(&p); + } else if (*p != '}') { + return false; + } + } + return false; +} + +bool ds4_acp_object_get_string(const char *json, const char *key, char **out) { + char *raw = NULL; + *out = NULL; + if (!ds4_acp_object_get_raw(json, key, &raw)) return false; + const char *p = raw; + bool ok = ds4_acp_json_string(&p, out); + ds4_acp_json_ws(&p); + if (*p) ok = false; + if (!ok) { + free(*out); + *out = NULL; + } + free(raw); + return ok; +} + +void ds4_acp_request_free(ds4_acp_request *r) { + free(r->id_json); + free(r->method); + free(r->params_json); + memset(r, 0, sizeof(*r)); +} + +void ds4_acp_message_free(ds4_acp_message *m) { + free(m->id_json); + free(m->method); + free(m->params_json); + free(m->result_json); + free(m->error_json); + memset(m, 0, sizeof(*m)); +} + +ds4_acp_parse_result ds4_acp_parse_message(const char *json, + ds4_acp_message *out, + char *err, size_t err_len) { + memset(out, 0, sizeof(*out)); + const char *p = json; + bool saw_jsonrpc = false; + bool jsonrpc_ok = false; + ds4_acp_json_ws(&p); + if (*p != '{') { + snprintf(err, err_len, "expected JSON object"); + return DS4_ACP_PARSE_JSON; + } + p++; + ds4_acp_json_ws(&p); + while (*p && *p != '}') { + char *key = NULL; + if (!ds4_acp_json_string(&p, &key)) { + snprintf(err, err_len, "invalid object key"); + ds4_acp_message_free(out); + return DS4_ACP_PARSE_JSON; + } + ds4_acp_json_ws(&p); + if (*p != ':') { + free(key); + snprintf(err, err_len, "expected ':'"); + ds4_acp_message_free(out); + return DS4_ACP_PARSE_JSON; + } + p++; + if (!strcmp(key, "jsonrpc")) { + char *v = NULL; + if (!ds4_acp_json_string(&p, &v)) { + free(key); + snprintf(err, err_len, "invalid jsonrpc"); + ds4_acp_message_free(out); + return DS4_ACP_PARSE_REQUEST; + } + saw_jsonrpc = true; + jsonrpc_ok = !strcmp(v, "2.0"); + free(v); + } else if (!strcmp(key, "id")) { + free(out->id_json); + out->id_json = NULL; + if (!ds4_acp_json_raw_value(&p, &out->id_json)) { + free(key); + snprintf(err, err_len, "invalid id"); + ds4_acp_message_free(out); + return DS4_ACP_PARSE_JSON; + } + out->has_id = true; + } else if (!strcmp(key, "method")) { + free(out->method); + out->method = NULL; + if (!ds4_acp_json_string(&p, &out->method)) { + free(key); + snprintf(err, err_len, "invalid method"); + ds4_acp_message_free(out); + return DS4_ACP_PARSE_REQUEST; + } + out->has_method = true; + } else if (!strcmp(key, "params")) { + free(out->params_json); + out->params_json = NULL; + if (!ds4_acp_json_raw_value(&p, &out->params_json)) { + free(key); + snprintf(err, err_len, "invalid params"); + ds4_acp_message_free(out); + return DS4_ACP_PARSE_JSON; + } + out->has_params = true; + } else if (!strcmp(key, "result")) { + free(out->result_json); + out->result_json = NULL; + if (!ds4_acp_json_raw_value(&p, &out->result_json)) { + free(key); + snprintf(err, err_len, "invalid result"); + ds4_acp_message_free(out); + return DS4_ACP_PARSE_JSON; + } + out->has_result = true; + } else if (!strcmp(key, "error")) { + free(out->error_json); + out->error_json = NULL; + if (!ds4_acp_json_raw_value(&p, &out->error_json)) { + free(key); + snprintf(err, err_len, "invalid error"); + ds4_acp_message_free(out); + return DS4_ACP_PARSE_JSON; + } + out->has_error = true; + } else if (!ds4_acp_json_skip_value(&p)) { + free(key); + snprintf(err, err_len, "invalid value"); + ds4_acp_message_free(out); + return DS4_ACP_PARSE_JSON; + } + free(key); + ds4_acp_json_ws(&p); + if (*p == ',') { + p++; + ds4_acp_json_ws(&p); + } else if (*p != '}') { + snprintf(err, err_len, "expected ',' or '}'"); + ds4_acp_message_free(out); + return DS4_ACP_PARSE_JSON; + } + } + if (*p != '}') { + snprintf(err, err_len, "unterminated object"); + ds4_acp_message_free(out); + return DS4_ACP_PARSE_JSON; + } + p++; + ds4_acp_json_ws(&p); + if (*p) { + snprintf(err, err_len, "trailing data"); + ds4_acp_message_free(out); + return DS4_ACP_PARSE_JSON; + } + if (!saw_jsonrpc || !jsonrpc_ok || + (out->has_id && !json_id_value(out->id_json))) + { + snprintf(err, err_len, "invalid JSON-RPC message"); + ds4_acp_message_free(out); + return DS4_ACP_PARSE_REQUEST; + } + if (out->has_method && (out->has_result || out->has_error)) { + snprintf(err, err_len, "request cannot include result or error"); + ds4_acp_message_free(out); + return DS4_ACP_PARSE_REQUEST; + } + if (!out->has_method && out->has_result && out->has_error) { + snprintf(err, err_len, "response cannot include result and error"); + ds4_acp_message_free(out); + return DS4_ACP_PARSE_REQUEST; + } + if (!out->has_method && + (!out->has_id || (!out->has_result && !out->has_error))) + { + snprintf(err, err_len, "invalid JSON-RPC response"); + ds4_acp_message_free(out); + return DS4_ACP_PARSE_REQUEST; + } + return DS4_ACP_PARSE_OK; +} + +ds4_acp_parse_result ds4_acp_parse_request(const char *json, + ds4_acp_request *out, + char *err, size_t err_len) { + memset(out, 0, sizeof(*out)); + ds4_acp_message msg; + ds4_acp_parse_result rc = ds4_acp_parse_message(json, &msg, err, err_len); + if (rc != DS4_ACP_PARSE_OK) return rc; + if (!msg.has_method) { + snprintf(err, err_len, "missing method"); + ds4_acp_message_free(&msg); + return DS4_ACP_PARSE_REQUEST; + } + out->has_id = msg.has_id; + out->id_json = msg.id_json; + out->method = msg.method; + out->has_params = msg.has_params; + out->params_json = msg.params_json; + msg.id_json = NULL; + msg.method = NULL; + msg.params_json = NULL; + ds4_acp_message_free(&msg); + return DS4_ACP_PARSE_OK; +} + +char *ds4_acp_json_escape(const char *s, size_t n) { + acp_buf b = {0}; + bool valid_utf8 = acp_utf8_valid(s, n); + acp_buf_putc(&b, '"'); + for (size_t i = 0; i < n; i++) { + unsigned char c = (unsigned char)s[i]; + switch (c) { + case '"': acp_buf_puts(&b, "\\\""); break; + case '\\': acp_buf_puts(&b, "\\\\"); break; + case '\b': acp_buf_puts(&b, "\\b"); break; + case '\f': acp_buf_puts(&b, "\\f"); break; + case '\n': acp_buf_puts(&b, "\\n"); break; + case '\r': acp_buf_puts(&b, "\\r"); break; + case '\t': acp_buf_puts(&b, "\\t"); break; + default: + if (c < 0x20) { + char tmp[8]; + snprintf(tmp, sizeof(tmp), "\\u%04x", c); + acp_buf_puts(&b, tmp); + } else if (!valid_utf8 && c >= 0x80) { + char tmp[8]; + snprintf(tmp, sizeof(tmp), "\\u%04x", c); + acp_buf_puts(&b, tmp); + } else { + acp_buf_putc(&b, (char)c); + } + break; + } + } + acp_buf_putc(&b, '"'); + return acp_buf_take(&b); +} diff --git a/ds4_acp.h b/ds4_acp.h new file mode 100644 index 000000000..fe3e60a08 --- /dev/null +++ b/ds4_acp.h @@ -0,0 +1,53 @@ +#ifndef DS4_ACP_H +#define DS4_ACP_H + +#include +#include + +typedef enum { + DS4_ACP_PARSE_OK, + DS4_ACP_PARSE_JSON, + DS4_ACP_PARSE_REQUEST, +} ds4_acp_parse_result; + +typedef struct { + bool has_id; + char *id_json; + char *method; + bool has_params; + char *params_json; +} ds4_acp_request; + +typedef struct { + bool has_id; + char *id_json; + bool has_method; + char *method; + bool has_params; + char *params_json; + bool has_result; + char *result_json; + bool has_error; + char *error_json; +} ds4_acp_message; + +void ds4_acp_message_free(ds4_acp_message *m); +void ds4_acp_request_free(ds4_acp_request *r); +ds4_acp_parse_result ds4_acp_parse_message(const char *json, + ds4_acp_message *out, + char *err, size_t err_len); +ds4_acp_parse_result ds4_acp_parse_request(const char *json, + ds4_acp_request *out, + char *err, size_t err_len); + +void ds4_acp_json_ws(const char **p); +bool ds4_acp_json_string(const char **p, char **out); +bool ds4_acp_json_skip_value(const char **p); +bool ds4_acp_json_raw_value(const char **p, char **out); + +bool ds4_acp_object_get_string(const char *json, const char *key, char **out); +bool ds4_acp_object_get_raw(const char *json, const char *key, char **out); + +char *ds4_acp_json_escape(const char *s, size_t n); + +#endif diff --git a/ds4_agent.c b/ds4_agent.c index bbd62e9de..fa0c79056 100644 --- a/ds4_agent.c +++ b/ds4_agent.c @@ -1,7 +1,9 @@ #include "ds4.h" +#include "ds4_acp.h" #include "ds4_distributed.h" #include "ds4_help.h" #include "ds4_kvstore.h" +#include "ds4_mcp.h" #include "ds4_web.h" #include "linenoise.h" @@ -65,6 +67,7 @@ typedef struct { agent_generation_options gen; const char *chdir_path; bool non_interactive; + bool acp; } agent_config; typedef enum { @@ -95,6 +98,14 @@ typedef struct { typedef struct agent_bash_job agent_bash_job; +typedef enum { + AGENT_TURN_NONE, + AGENT_TURN_END, + AGENT_TURN_MAX_TOKENS, + AGENT_TURN_CANCELLED, + AGENT_TURN_ERROR, +} agent_turn_result; + typedef struct { ds4_engine *engine; agent_config *cfg; @@ -111,12 +122,14 @@ typedef struct { pthread_t thread; pthread_mutex_t mu; pthread_cond_t cond; + pthread_mutex_t acp_mu; int wake_fd[2]; FILE *trace; bool wake_pending; bool stop; bool interrupt; bool initialized; + agent_turn_result last_turn_result; bool save_requested; bool compact_requested; bool power_requested; @@ -145,6 +158,21 @@ typedef struct { bool more_valid; agent_bash_job *bash_jobs; int next_bash_job_id; + char acp_session_id[64]; + char *acp_cwd; + bool acp_web_allow; + int next_acp_request_id; + int next_acp_message_id; + bool acp_permission_pending; + bool acp_permission_answered; + int acp_permission_id; + bool acp_permission_allow; + char acp_permission_error[160]; + char acp_user_message_id[64]; + char acp_assistant_message_id[64]; + int next_acp_tool_call_id; + ds4_mcp mcp; + char *mcp_tools_prompt; } agent_worker; static unsigned agent_next_prefill_label(void); @@ -318,6 +346,8 @@ static void agent_trace(agent_worker *w, const char *fmt, ...); static void agent_trace_text(agent_worker *w, const char *label, const char *text, size_t len); static void agent_publish_system_status(agent_worker *w, const char *msg); +static void agent_acp_notify_assistant(agent_worker *w, const char *s, size_t n); +static char *agent_acp_current_cwd(agent_worker *w); static int agent_web_confirm(void *privdata, const char *message, char *err, size_t err_len); static void agent_web_log(void *privdata, const char *message); @@ -408,6 +438,20 @@ static char *agent_input_buf_take(agent_input_buf *b) { return p; } +static char *agent_input_buf_take_line(agent_input_buf *b) { + if (!b->ptr) return NULL; + char *nl = memchr(b->ptr, '\n', b->len); + if (!nl) return NULL; + size_t len = (size_t)(nl - b->ptr); + if (len && b->ptr[len - 1] == '\r') len--; + char *line = xstrndup(b->ptr, len); + size_t used = (size_t)(nl - b->ptr) + 1; + memmove(b->ptr, b->ptr + used, b->len - used); + b->len -= used; + b->ptr[b->len] = '\0'; + return line; +} + static void agent_input_buf_free(agent_input_buf *b) { free(b->ptr); memset(b, 0, sizeof(*b)); @@ -557,6 +601,8 @@ static agent_config parse_options(int argc, char **argv) { c.gen.prompt = need_arg(&i, argc, argv, arg); } else if (!strcmp(arg, "--non-interactive")) { c.non_interactive = true; + } else if (!strcmp(arg, "--acp")) { + c.acp = true; } else if (!strcmp(arg, "-sys") || !strcmp(arg, "--system")) { c.gen.system = need_arg(&i, argc, argv, arg); } else if (!strcmp(arg, "--trace")) { @@ -662,6 +708,10 @@ static agent_config parse_options(int argc, char **argv) { if (c.engine.directional_steering_file && !steering_scale_set) c.engine.directional_steering_ffn = 1.0f; + if (c.acp && c.non_interactive) { + fprintf(stderr, "ds4-agent: --acp cannot be combined with --non-interactive\n"); + exit(2); + } char dist_err[256]; if (ds4_dist_prepare_engine_options(&c.engine.distributed, &c.engine, @@ -944,15 +994,18 @@ static const char agent_tools_prompt_after_edit[] = "- Work in a way that preserves the current system configuration integrity, " "unless explicitly asked otherwise by the user.\n"; -static char *agent_build_tools_prompt(void) { +static char *agent_build_tools_prompt(const char *extra_tools) { const char *edit = agent_tools_prompt_edit_line; size_t a = strlen(agent_tools_prompt_intro); size_t b = strlen(edit); size_t c = strlen(agent_tools_prompt_after_edit); - char *out = xmalloc(a + b + c + 1); + size_t d = extra_tools ? strlen(extra_tools) : 0; + char *out = xmalloc(a + b + c + d + 1); memcpy(out, agent_tools_prompt_intro, a); memcpy(out + a, edit, b); - memcpy(out + a + b, agent_tools_prompt_after_edit, c + 1); + memcpy(out + a + b, agent_tools_prompt_after_edit, c); + if (d) memcpy(out + a + b + c, extra_tools, d); + out[a + b + c + d] = '\0'; return out; } @@ -966,8 +1019,8 @@ static const char agent_dsml_syntax_reminder[] = #define AGENT_SYSTEM_PROMPT_REMINDER_TOKENS 50000 -static char *agent_build_system_prompt_reminder(void) { - char *tools = agent_build_tools_prompt(); +static char *agent_build_system_prompt_reminder(agent_worker *w) { + char *tools = agent_build_tools_prompt(w ? w->mcp_tools_prompt : NULL); const char *start = "\n\n[System prompt reminder follows.]\n"; const char *end = "[End system prompt reminder.]\n\n"; size_t len = strlen(start) + strlen(tools) + strlen(end) + 1; @@ -981,13 +1034,14 @@ static char *agent_build_system_prompt_reminder(void) { } static void agent_append_system_prompt(ds4_engine *engine, ds4_tokens *tokens, - const char *extra) { + const char *extra, + const char *extra_tools) { /* The built-in tool prompt is trusted DS4 control text. Tokenize it like a * rendered chat prompt so the literal |DSML| markers in the examples become * the model's dedicated DSML token. Do not apply that tokenizer to user * supplied -sys text: arbitrary user text containing <|User|>, , or * |DSML| must remain plain content, not control tokens. */ - char *tools_prompt = agent_build_tools_prompt(); + char *tools_prompt = agent_build_tools_prompt(extra_tools); ds4_tokenize_rendered_chat(engine, tools_prompt, tokens); free(tools_prompt); @@ -1039,7 +1093,7 @@ static void agent_worker_maybe_append_system_prompt_reminder(agent_worker *w) { return; } - char *reminder = agent_build_system_prompt_reminder(); + char *reminder = agent_build_system_prompt_reminder(w); agent_publish_system_status(w, "Re-injecting system prompt reminder..."); agent_trace(w, "system prompt reminder injected at transcript=%d", w->transcript.len); @@ -1073,6 +1127,7 @@ static void agent_wake_locked(agent_worker *w) { * to the terminal, which keeps linenoise redraws serialized in one place. */ static void agent_publish(agent_worker *w, const char *s, size_t n) { if (!n) return; + if (w->cfg && w->cfg->acp) return; pthread_mutex_lock(&w->mu); if (w->out_len + n + 1 > w->out_cap) { size_t cap = w->out_cap ? w->out_cap * 2 : 4096; @@ -1130,11 +1185,192 @@ static void agent_set_error(agent_worker *w, const char *msg) { w->status.state = AGENT_WORKER_ERROR; w->status.prefill_tps = 0.0; w->status.greedy_sampling = false; + w->last_turn_result = AGENT_TURN_ERROR; snprintf(w->status.error, sizeof(w->status.error), "%s", msg ? msg : "unknown error"); agent_wake_locked(w); pthread_mutex_unlock(&w->mu); } +static void agent_set_turn_result(agent_worker *w, agent_turn_result result) { + pthread_mutex_lock(&w->mu); + w->last_turn_result = result; + agent_wake_locked(w); + pthread_mutex_unlock(&w->mu); +} + +static const char *agent_turn_stop_reason(agent_turn_result result) { + switch (result) { + case AGENT_TURN_MAX_TOKENS: return "max_tokens"; + case AGENT_TURN_CANCELLED: return "cancelled"; + case AGENT_TURN_END: return "end_turn"; + case AGENT_TURN_ERROR: + case AGENT_TURN_NONE: + default: return NULL; + } +} + +static void agent_acp_write_all_locked(const char *s, size_t n) { + write_all(STDOUT_FILENO, s, n); +} + +static void agent_acp_puts_locked(const char *s) { + agent_acp_write_all_locked(s, strlen(s)); +} + +static void agent_acp_write_response(agent_worker *w, const char *id_json, + const char *result_json) { + pthread_mutex_lock(&w->acp_mu); + agent_acp_puts_locked("{\"jsonrpc\":\"2.0\",\"id\":"); + agent_acp_write_all_locked(id_json ? id_json : "null", + strlen(id_json ? id_json : "null")); + agent_acp_puts_locked(",\"result\":"); + agent_acp_write_all_locked(result_json ? result_json : "{}", + strlen(result_json ? result_json : "{}")); + agent_acp_puts_locked("}\n"); + pthread_mutex_unlock(&w->acp_mu); +} + +static void agent_acp_write_error(agent_worker *w, const char *id_json, + int code, const char *message) { + char codebuf[32]; + snprintf(codebuf, sizeof(codebuf), "%d", code); + char *qmsg = ds4_acp_json_escape(message ? message : "error", + strlen(message ? message : "error")); + pthread_mutex_lock(&w->acp_mu); + agent_acp_puts_locked("{\"jsonrpc\":\"2.0\",\"id\":"); + agent_acp_write_all_locked(id_json ? id_json : "null", + strlen(id_json ? id_json : "null")); + agent_acp_puts_locked(",\"error\":{\"code\":"); + agent_acp_write_all_locked(codebuf, strlen(codebuf)); + agent_acp_puts_locked(",\"message\":"); + agent_acp_write_all_locked(qmsg, strlen(qmsg)); + agent_acp_puts_locked("}}\n"); + pthread_mutex_unlock(&w->acp_mu); + free(qmsg); +} + +static void agent_acp_write_request_error(agent_worker *w, + const ds4_acp_request *req, + int code, const char *message) { + if (req->has_id) agent_acp_write_error(w, req->id_json, code, message); +} + +static void agent_acp_notify_text(agent_worker *w, const char *session_update, + const char *message_id, + const char *s, size_t n) { + if (!w->cfg || !w->cfg->acp || !w->acp_session_id[0] || !n) return; + char *qsid = ds4_acp_json_escape(w->acp_session_id, strlen(w->acp_session_id)); + char *qtext = ds4_acp_json_escape(s, n); + char *qupdate = ds4_acp_json_escape(session_update, strlen(session_update)); + char *qmsg = ds4_acp_json_escape(message_id, strlen(message_id)); + pthread_mutex_lock(&w->acp_mu); + agent_acp_puts_locked( + "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":"); + agent_acp_write_all_locked(qsid, strlen(qsid)); + agent_acp_puts_locked(",\"update\":{\"sessionUpdate\":"); + agent_acp_write_all_locked(qupdate, strlen(qupdate)); + agent_acp_puts_locked(",\"messageId\":"); + agent_acp_write_all_locked(qmsg, strlen(qmsg)); + agent_acp_puts_locked(",\"content\":{\"type\":\"text\",\"text\":"); + agent_acp_write_all_locked(qtext, strlen(qtext)); + agent_acp_puts_locked("}}}}\n"); + pthread_mutex_unlock(&w->acp_mu); + free(qsid); + free(qtext); + free(qupdate); + free(qmsg); +} + +static void agent_acp_notify_assistant(agent_worker *w, const char *s, size_t n) { + const char *id = w->acp_assistant_message_id[0] ? + w->acp_assistant_message_id : "msg_agent"; + agent_acp_notify_text(w, "agent_message_chunk", id, s, n); +} + +static void agent_acp_notify_user(agent_worker *w, const char *s) { + if (!s) return; + const char *id = w->acp_user_message_id[0] ? + w->acp_user_message_id : "msg_user"; + agent_acp_notify_text(w, "user_message_chunk", id, s, strlen(s)); +} + +static bool agent_acp_request_permission(agent_worker *w, const char *title, + const char *kind, + const char *message, + char *err, size_t err_len) { + if (!w->acp_session_id[0]) { + snprintf(err, err_len, "ACP session is not ready for permission request"); + return false; + } + + pthread_mutex_lock(&w->mu); + int id = ++w->next_acp_request_id; + w->acp_permission_pending = true; + w->acp_permission_answered = false; + w->acp_permission_id = id; + w->acp_permission_allow = false; + w->acp_permission_error[0] = '\0'; + pthread_mutex_unlock(&w->mu); + + char idbuf[32], tool_id[64]; + snprintf(idbuf, sizeof(idbuf), "%d", id); + snprintf(tool_id, sizeof(tool_id), "permission_%d", id); + char *qsid = ds4_acp_json_escape(w->acp_session_id, strlen(w->acp_session_id)); + char *qtool = ds4_acp_json_escape(tool_id, strlen(tool_id)); + char *qtitle = ds4_acp_json_escape(title ? title : "Request permission", + strlen(title ? title : "Request permission")); + char *qkind = ds4_acp_json_escape(kind ? kind : "other", + strlen(kind ? kind : "other")); + char *qmsg = ds4_acp_json_escape(message ? message : "Allow this action?", + strlen(message ? message : "Allow this action?")); + + pthread_mutex_lock(&w->acp_mu); + agent_acp_puts_locked( + "{\"jsonrpc\":\"2.0\",\"id\":"); + agent_acp_puts_locked(idbuf); + agent_acp_puts_locked( + ",\"method\":\"session/request_permission\",\"params\":{\"sessionId\":"); + agent_acp_write_all_locked(qsid, strlen(qsid)); + agent_acp_puts_locked(",\"toolCall\":{\"toolCallId\":"); + agent_acp_write_all_locked(qtool, strlen(qtool)); + agent_acp_puts_locked(",\"title\":"); + agent_acp_write_all_locked(qtitle, strlen(qtitle)); + agent_acp_puts_locked(",\"kind\":"); + agent_acp_write_all_locked(qkind, strlen(qkind)); + agent_acp_puts_locked( + ",\"status\":\"pending\",\"content\":[{\"type\":\"content\",\"content\":{\"type\":\"text\",\"text\":"); + agent_acp_write_all_locked(qmsg, strlen(qmsg)); + agent_acp_puts_locked("}}]},\"options\":[" + "{\"optionId\":\"allow-once\",\"name\":\"Allow once\",\"kind\":\"allow_once\"}," + "{\"optionId\":\"reject-once\",\"name\":\"Reject\",\"kind\":\"reject_once\"}]}}\n"); + pthread_mutex_unlock(&w->acp_mu); + + free(qsid); + free(qtool); + free(qtitle); + free(qkind); + free(qmsg); + + pthread_mutex_lock(&w->mu); + while (!w->stop && !w->interrupt && !w->acp_permission_answered) + pthread_cond_wait(&w->cond, &w->mu); + bool allow = w->acp_permission_allow; + if (!w->acp_permission_answered && (w->stop || w->interrupt)) { + allow = false; + snprintf(w->acp_permission_error, sizeof(w->acp_permission_error), + "interrupted"); + } + if (!allow) { + snprintf(err, err_len, "%s", + w->acp_permission_error[0] ? w->acp_permission_error : + "permission denied"); + } + w->acp_permission_pending = false; + w->acp_permission_answered = false; + pthread_mutex_unlock(&w->mu); + return allow; +} + /* ============================================================================ * Trace Logging * ============================================================================ @@ -1613,6 +1849,7 @@ static char *agent_tail_capture_take(agent_tail_capture *t, size_t *len) { static void renderer_write(agent_token_renderer *r, const char *s, size_t n) { if (r->capture) agent_tail_capture_append(r->capture, s, n); + else if (r->worker->cfg && r->worker->cfg->acp) agent_acp_notify_assistant(r->worker, s, n); else agent_publish(r->worker, s, n); } @@ -1660,6 +1897,7 @@ static void renderer_restore_text_attrs(agent_token_renderer *r) { } static void renderer_write_complete_char_raw(agent_token_renderer *r, const char *s, size_t n) { + if (r->worker->cfg && r->worker->cfg->acp && r->in_think) return; bool styled = r->use_color && renderer_has_text_attrs(r); if (styled && !r->color_open) { renderer_set_text_attrs(r); @@ -2729,6 +2967,10 @@ static const char *agent_tool_param_color(agent_tool_param_kind kind) { } static void agent_tool_viz_write(agent_stream_renderer *sr, const char *s, size_t n) { + if (sr->renderer->worker->cfg && sr->renderer->worker->cfg->acp) { + for (size_t i = 0; i < n; i++) sr->viz.last_output_newline = s[i] == '\n'; + return; + } renderer_plain(sr->renderer, s, n); for (size_t i = 0; i < n; i++) sr->viz.last_output_newline = s[i] == '\n'; } @@ -3439,11 +3681,13 @@ static void agent_stream_text(agent_stream_renderer *sr, const char *text, size_ agent_stream_flush_start_tail(sr); sr->in_think = false; sr->renderer->in_think = false; - renderer_reset_color(sr->renderer); - if (!sr->renderer->last_output_newline) + if (!sr->renderer->worker->cfg->acp) { + renderer_reset_color(sr->renderer); + if (!sr->renderer->last_output_newline) + renderer_write(sr->renderer, "\n", 1); renderer_write(sr->renderer, "\n", 1); - renderer_write(sr->renderer, "\n", 1); - sr->renderer->last_output_newline = true; + sr->renderer->last_output_newline = true; + } sr->post_think_gap = true; i += strlen(think_close); continue; @@ -3992,10 +4236,12 @@ static void agent_worker_build_system_tokens(agent_worker *w, ds4_tokens *out) { if (w->cfg->gen.think_mode == DS4_THINK_MAX && effective_think_mode(w->cfg) == DS4_THINK_MAX) ds4_chat_append_max_effort_prefix(w->engine, out); - agent_append_system_prompt(w->engine, out, w->cfg->gen.system); + agent_append_system_prompt(w->engine, out, w->cfg->gen.system, + w->mcp_tools_prompt); } static void agent_publish_system_status(agent_worker *w, const char *msg) { + if (w->cfg->acp) return; if (w->cfg->non_interactive) return; if (isatty(STDOUT_FILENO)) { static const char marker[] = "\x1b[33m✦ \x1b[38;5;218m"; @@ -4032,6 +4278,12 @@ static void agent_publishf_system_status(agent_worker *w, const char *fmt, ...) static int agent_web_confirm(void *privdata, const char *message, char *err, size_t err_len) { agent_worker *w = privdata; + if (w && w->cfg->acp) { + if (w->acp_web_allow) return 1; + return agent_acp_request_permission(w, "Start visible Chrome browser", + "fetch", message, err, err_len) ? + 1 : 0; + } if (!w || w->cfg->non_interactive) { snprintf(err, err_len, "visible Chrome browser startup requires interactive approval"); @@ -4105,6 +4357,7 @@ static void worker_answer_web_approval(agent_worker *w, bool allow, * after the tool result is appended, so the next model input can contain both * the tool observation and the user's pending correction. */ static char *worker_request_queued_user_drain(agent_worker *w) { + if (w->cfg->acp) return NULL; pthread_mutex_lock(&w->mu); w->queued_user_drain_pending = true; w->queued_user_drain_answered = false; @@ -4401,7 +4654,7 @@ static bool agent_worker_save_session(agent_worker *w, char *err, size_t err_len char sha[41]; int tokens = 0; bool ok = agent_worker_save_session_now(w, sha, &tokens, err, err_len); - if (ok) printf("saved session %.8s (%d tokens)\n", sha, tokens); + if (ok && !w->cfg->acp) printf("saved session %.8s (%d tokens)\n", sha, tokens); return ok; } @@ -5379,8 +5632,10 @@ static bool agent_worker_switch_session(agent_worker *w, const char *prefix, ds4_kvstore_entry_free(&entry); } if (stripped) { - printf("rebuilding stripped session %.8s from rendered text...\n", sha); - fflush(stdout); + if (!w->cfg->acp) { + printf("rebuilding stripped session %.8s from rendered text...\n", sha); + fflush(stdout); + } } ds4_tokens loaded = {0}; @@ -5409,8 +5664,9 @@ static bool agent_worker_switch_session(agent_worker *w, const char *prefix, w->status.error[0] = '\0'; agent_wake_locked(w); pthread_mutex_unlock(&w->mu); - printf("switched to session %.8s (%d tokens%s)\n", - sha, w->transcript.len, stripped ? ", rebuilt from text" : ""); + if (!w->cfg->acp) + printf("switched to session %.8s (%d tokens%s)\n", + sha, w->transcript.len, stripped ? ", rebuilt from text" : ""); if (history_turns > 0) (void)agent_worker_show_history(w, history_turns, err, err_len); } else { @@ -7035,6 +7291,195 @@ static pid_t agent_tool_pid(const agent_tool_call *call) { /* Execute one parsed DSML tool call and return the text that will be appended as * the tool-role result. UI visualization already happened while streaming; this * function is only about side effects and the model-visible observation. */ +static const char *agent_acp_tool_kind(const char *name) { + if (!name) return "other"; + if (!strcmp(name, "read") || !strcmp(name, "more") || !strcmp(name, "list")) + return "read"; + if (!strcmp(name, "write") || !strcmp(name, "edit")) + return "edit"; + if (!strcmp(name, "search")) return "search"; + if (!strcmp(name, "google_search")) return "search"; + if (!strcmp(name, "visit_page")) return "fetch"; + if (!strcmp(name, "bash") || !strcmp(name, "bash_status") || + !strcmp(name, "bash_stop")) + return "execute"; + return "other"; +} + +static void agent_acp_tool_title(const agent_tool_call *call, + char *buf, size_t len) { + const char *name = call->name ? call->name : "tool"; + const char *detail = agent_tool_arg_value(call, "path"); + if (!detail) detail = agent_tool_arg_value(call, "command"); + if (!detail) detail = agent_tool_arg_value(call, "query"); + if (!detail) detail = agent_tool_arg_value(call, "url"); + if (detail && detail[0]) + snprintf(buf, len, "%s %s", name, detail); + else + snprintf(buf, len, "%s", name); +} + +static char *agent_acp_tool_raw_input(const agent_tool_call *call) { + agent_buf b = {0}; + char *qname = ds4_acp_json_escape(call->name ? call->name : "", + strlen(call->name ? call->name : "")); + agent_buf_puts(&b, "{\"name\":"); + agent_buf_puts(&b, qname); + agent_buf_puts(&b, ",\"args\":{"); + free(qname); + for (int i = 0; i < call->argc; i++) { + if (i) agent_buf_puts(&b, ","); + const char *name = call->args[i].name ? call->args[i].name : ""; + const char *value = call->args[i].value ? call->args[i].value : ""; + char *qarg = ds4_acp_json_escape(name, strlen(name)); + char *qval = ds4_acp_json_escape(value, strlen(value)); + agent_buf_puts(&b, qarg); + agent_buf_puts(&b, ":"); + agent_buf_puts(&b, qval); + free(qarg); + free(qval); + } + agent_buf_puts(&b, "}}"); + return agent_buf_take(&b); +} + +static char *agent_acp_tool_locations(agent_worker *w, + const agent_tool_call *call) { + const char *path = agent_tool_arg_value(call, "path"); + if (!path) path = agent_tool_arg_value(call, "file"); + if (!path) path = agent_tool_arg_value(call, "filename"); + if (!path || !path[0]) return NULL; + + char *abs = NULL; + if (path[0] == '/') { + abs = xstrdup(path); + } else { + char *cwd = agent_acp_current_cwd(w); + size_t len = strlen(cwd) + strlen(path) + 2; + abs = xmalloc(len); + snprintf(abs, len, "%s/%s", cwd, path); + free(cwd); + } + char *qpath = ds4_acp_json_escape(abs, strlen(abs)); + free(abs); + + agent_buf b = {0}; + agent_buf_puts(&b, "[{\"path\":"); + agent_buf_puts(&b, qpath); + free(qpath); + int line = agent_parse_int_default(agent_tool_arg_value(call, "line"), + 0, 0, INT_MAX); + if (line == 0) + line = agent_parse_int_default(agent_tool_arg_value(call, "start_line"), + 0, 0, INT_MAX); + if (line > 0) { + char linebuf[32]; + snprintf(linebuf, sizeof(linebuf), "%d", line); + agent_buf_puts(&b, ",\"line\":"); + agent_buf_puts(&b, linebuf); + } + agent_buf_puts(&b, "}]"); + return agent_buf_take(&b); +} + +static bool agent_json_value_valid(const char *s) { + const char *p = s; + if (!ds4_acp_json_skip_value(&p)) return false; + ds4_acp_json_ws(&p); + return *p == '\0'; +} + +static char *agent_tool_args_json(const agent_tool_call *call) { + agent_buf b = {0}; + agent_buf_puts(&b, "{"); + for (int i = 0; i < call->argc; i++) { + if (i) agent_buf_puts(&b, ","); + const char *name = call->args[i].name ? call->args[i].name : ""; + const char *value = call->args[i].value ? call->args[i].value : ""; + char *qname = ds4_acp_json_escape(name, strlen(name)); + agent_buf_puts(&b, qname); + agent_buf_puts(&b, ":"); + free(qname); + if (call->args[i].is_string || !agent_json_value_valid(value)) { + char *qval = ds4_acp_json_escape(value, strlen(value)); + agent_buf_puts(&b, qval); + free(qval); + } else { + agent_buf_puts(&b, value); + } + } + agent_buf_puts(&b, "}"); + return agent_buf_take(&b); +} + +/* ACP tool updates mirror the existing DSML tool execution: one tool_call when + * DS4 starts running the tool, followed by a tool_call_update with the final + * status and output. */ +static void agent_acp_notify_tool(agent_worker *w, const char *session_update, + const char *tool_id, + const agent_tool_call *call, + const char *status, + const char *output) { + if (!w->cfg->acp || !w->acp_session_id[0]) return; + char title[512]; + agent_acp_tool_title(call, title, sizeof(title)); + char *qsid = ds4_acp_json_escape(w->acp_session_id, strlen(w->acp_session_id)); + char *qid = ds4_acp_json_escape(tool_id, strlen(tool_id)); + char *qtitle = ds4_acp_json_escape(title, strlen(title)); + char *qkind = ds4_acp_json_escape(agent_acp_tool_kind(call->name), + strlen(agent_acp_tool_kind(call->name))); + char *qstatus = ds4_acp_json_escape(status, strlen(status)); + char *raw_input = agent_acp_tool_raw_input(call); + char *locations = agent_acp_tool_locations(w, call); + char *qout = output ? ds4_acp_json_escape(output, strlen(output)) : NULL; + + pthread_mutex_lock(&w->acp_mu); + agent_acp_puts_locked( + "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":"); + agent_acp_write_all_locked(qsid, strlen(qsid)); + agent_acp_puts_locked(",\"update\":{\"sessionUpdate\":"); + char *qupdate = ds4_acp_json_escape(session_update, strlen(session_update)); + agent_acp_write_all_locked(qupdate, strlen(qupdate)); + free(qupdate); + agent_acp_puts_locked(",\"toolCallId\":"); + agent_acp_write_all_locked(qid, strlen(qid)); + if (!strcmp(session_update, "tool_call")) { + agent_acp_puts_locked(",\"title\":"); + agent_acp_write_all_locked(qtitle, strlen(qtitle)); + agent_acp_puts_locked(",\"kind\":"); + agent_acp_write_all_locked(qkind, strlen(qkind)); + agent_acp_puts_locked(",\"rawInput\":"); + agent_acp_write_all_locked(raw_input, strlen(raw_input)); + if (locations) { + agent_acp_puts_locked(",\"locations\":"); + agent_acp_write_all_locked(locations, strlen(locations)); + } + } + agent_acp_puts_locked(",\"status\":"); + agent_acp_write_all_locked(qstatus, strlen(qstatus)); + if (qout) { + agent_acp_puts_locked(",\"rawOutput\":{\"text\":"); + agent_acp_write_all_locked(qout, strlen(qout)); + agent_acp_puts_locked("}"); + /* ToolCallContent wraps a normal ContentBlock, so this outer type is + * "content" while the inner block keeps its own "text" discriminator. */ + agent_acp_puts_locked(",\"content\":[{\"type\":\"content\",\"content\":{\"type\":\"text\",\"text\":"); + agent_acp_write_all_locked(qout, strlen(qout)); + agent_acp_puts_locked("}}]"); + } + agent_acp_puts_locked("}}}\n"); + pthread_mutex_unlock(&w->acp_mu); + + free(qsid); + free(qid); + free(qtitle); + free(qkind); + free(qstatus); + free(raw_input); + free(locations); + free(qout); +} + static char *agent_execute_tool_call(agent_worker *w, const agent_tool_call *call) { agent_buf result = {0}; if (!call->name) return xstrdup("Tool error: missing tool name\n"); @@ -7084,6 +7529,21 @@ static char *agent_execute_tool_call(agent_worker *w, const agent_tool_call *cal return agent_bash_job_tool_result(w, job, wait, refresh, stop, true); } + if (w->cfg->acp && ds4_mcp_find_tool(&w->mcp, call->name)) { + char *args = agent_tool_args_json(call); + char err[256] = {0}; + char *res = ds4_mcp_call_tool(&w->mcp, call->name, args, + agent_web_cancel, w, err, sizeof(err)); + free(args); + if (!res) { + agent_buf_puts(&result, "Tool error: MCP call failed: "); + agent_buf_puts(&result, err[0] ? err : "unknown error"); + agent_buf_puts(&result, "\n"); + return agent_buf_take(&result); + } + return res; + } + { char header[256]; snprintf(header, sizeof(header), "\n[tool:%s] unknown tool\n", call->name); @@ -7100,7 +7560,16 @@ static char *agent_execute_tool_call(agent_worker *w, const agent_tool_call *cal static char *agent_execute_tool_calls(agent_worker *w, const agent_tool_calls *calls) { agent_buf all = {0}; for (int i = 0; i < calls->len; i++) { + char tool_id[64]; + snprintf(tool_id, sizeof(tool_id), "tool_%d", ++w->next_acp_tool_call_id); + agent_acp_notify_tool(w, "tool_call", tool_id, &calls->v[i], + "pending", NULL); + agent_acp_notify_tool(w, "tool_call_update", tool_id, &calls->v[i], + "in_progress", NULL); char *res = agent_execute_tool_call(w, &calls->v[i]); + agent_acp_notify_tool(w, "tool_call_update", tool_id, &calls->v[i], + !strncmp(res, "Tool error:", 11) ? + "failed" : "completed", res); char hdr[128]; snprintf(hdr, sizeof(hdr), "Tool result %d (%s):\n", i + 1, calls->v[i].name ? calls->v[i].name : "unknown"); @@ -7543,6 +8012,7 @@ static int worker_run_turn(agent_worker *w, const char *user_text) { if (agent_err_is_interrupted(compact_err)) { worker_clear_interrupt(w); agent_set_status(w, AGENT_WORKER_IDLE); + agent_set_turn_result(w, AGENT_TURN_CANCELLED); return 0; } agent_set_error(w, compact_err[0] ? compact_err : "context compaction failed"); @@ -7575,6 +8045,11 @@ static int worker_run_turn(agent_worker *w, const char *user_text) { * after a DSML stanza completes we terminate that assistant message, append * the tool result as a tool message, then ask the model to continue. */ for (int tool_round = 0; ; tool_round++) { + if (worker_should_interrupt(w)) { + agent_set_status(w, AGENT_WORKER_IDLE); + agent_set_turn_result(w, AGENT_TURN_CANCELLED); + return 0; + } if (tool_round > 0 && !agent_worker_compact_if_needed(w, "soft limit before tool continuation", compact_err, sizeof(compact_err))) @@ -7582,6 +8057,7 @@ static int worker_run_turn(agent_worker *w, const char *user_text) { if (agent_err_is_interrupted(compact_err)) { worker_clear_interrupt(w); agent_set_status(w, AGENT_WORKER_IDLE); + agent_set_turn_result(w, AGENT_TURN_CANCELLED); return 0; } agent_set_error(w, compact_err[0] ? compact_err : "context compaction failed"); @@ -7631,6 +8107,7 @@ static int worker_run_turn(agent_worker *w, const char *user_text) { ds4_tokens_push(&w->transcript, ds4_token_eos(w->engine)); worker_clear_interrupt(w); agent_set_status(w, AGENT_WORKER_IDLE); + agent_set_turn_result(w, AGENT_TURN_CANCELLED); return 0; } if (sync_rc != 0) { @@ -7732,8 +8209,8 @@ static int worker_run_turn(agent_worker *w, const char *user_text) { break; } } - bool interrupted = worker_should_interrupt(w); + bool hit_max_tokens = !interrupted && generated >= max_tokens; agent_stream_text(&stream, NULL, 0, true); renderer_finish(&renderer); worker_set_greedy_sampling(w, false); @@ -7743,6 +8220,7 @@ static int worker_run_turn(agent_worker *w, const char *user_text) { agent_publish_system_status(w, "Stopped by user"); worker_clear_interrupt(w); agent_set_status(w, AGENT_WORKER_IDLE); + agent_set_turn_result(w, AGENT_TURN_CANCELLED); return 0; } if (stream.dsml_in_think) { @@ -7768,6 +8246,8 @@ static int worker_run_turn(agent_worker *w, const char *user_text) { if (!got_tool && !malformed_tool && !early_tool_error) { agent_dsml_parser_free(&dsml); agent_set_status(w, AGENT_WORKER_IDLE); + agent_set_turn_result(w, hit_max_tokens ? + AGENT_TURN_MAX_TOKENS : AGENT_TURN_END); return 0; } @@ -7803,6 +8283,7 @@ static int worker_run_turn(agent_worker *w, const char *user_text) { if (agent_err_is_interrupted(compact_err)) { worker_clear_interrupt(w); agent_set_status(w, AGENT_WORKER_IDLE); + agent_set_turn_result(w, AGENT_TURN_CANCELLED); return 0; } agent_set_error(w, compact_err[0] ? compact_err : "context compaction failed"); @@ -8059,6 +8540,7 @@ static bool worker_submit(agent_worker *w, const char *text) { w->status.generated = 0; w->status.gen_tps = 0.0; w->status.greedy_sampling = false; + w->last_turn_result = AGENT_TURN_NONE; pthread_cond_signal(&w->cond); } pthread_mutex_unlock(&w->mu); @@ -8125,6 +8607,13 @@ static void worker_get_status(agent_worker *w, agent_status *status) { pthread_mutex_unlock(&w->mu); } +static agent_turn_result worker_turn_result(agent_worker *w) { + pthread_mutex_lock(&w->mu); + agent_turn_result result = w->last_turn_result; + pthread_mutex_unlock(&w->mu); + return result; +} + static bool worker_is_idle(agent_worker *w) { pthread_mutex_lock(&w->mu); bool idle = w->initialized && @@ -9308,6 +9797,8 @@ static int agent_worker_init(agent_worker *w, ds4_engine *engine, agent_config * w->wake_fd[1] = -1; pthread_mutex_init(&w->mu, NULL); pthread_cond_init(&w->cond, NULL); + pthread_mutex_init(&w->acp_mu, NULL); + ds4_mcp_init(&w->mcp); w->status.state = AGENT_WORKER_IDLE; if (pipe(w->wake_fd) != 0) return -1; int old_flags; @@ -9354,18 +9845,22 @@ static void agent_worker_free(agent_worker *w) { if (w->thread) pthread_join(w->thread, NULL); agent_bash_jobs_free(w); ds4_web_free(w->web); + ds4_mcp_close(&w->mcp); ds4_session_free(w->session); ds4_tokens_free(&w->transcript); free(w->cache_dir); free(w->sysprompt_path); free(w->session_title); free(w->legacy_session_path_to_delete); + free(w->acp_cwd); + free(w->mcp_tools_prompt); free(w->queued_user_drain_text); if (w->wake_fd[0] >= 0) close(w->wake_fd[0]); if (w->wake_fd[1] >= 0) close(w->wake_fd[1]); if (w->trace) fclose(w->trace); free(w->cmd_text); free(w->out); + pthread_mutex_destroy(&w->acp_mu); pthread_cond_destroy(&w->cond); pthread_mutex_destroy(&w->mu); } @@ -9502,6 +9997,1033 @@ static int agent_read_stdin_available(agent_input_buf *in, bool *eof) { } } +static void agent_acp_make_session_id(agent_worker *w) { + uint64_t bits[3]; + bits[0] = (uint64_t)time(NULL); + bits[1] = (uint64_t)getpid(); + bits[2] = (uint64_t)clock(); + char sha[41]; + ds4_kvstore_sha1_bytes_hex(bits, sizeof(bits), sha); + snprintf(w->acp_session_id, sizeof(w->acp_session_id), "ds4-%s", sha); +} + +static bool agent_acp_wait_initialized(agent_worker *w, char *err, size_t err_len) { + for (;;) { + agent_status st; + if (worker_is_initialized(w, &st)) { + if (st.state == AGENT_WORKER_ERROR) { + snprintf(err, err_len, "%s", + st.error[0] ? st.error : "worker initialization failed"); + return false; + } + return true; + } + struct pollfd pfd = {.fd = w->wake_fd[0], .events = POLLIN}; + int pr = poll(&pfd, 1, -1); + if (pr < 0) { + if (errno == EINTR) continue; + snprintf(err, err_len, "poll failed: %s", strerror(errno)); + return false; + } + if (pfd.revents & POLLIN) { + char *out = NULL; + size_t out_len = 0; + drain_wake_fd(w->wake_fd[0]); + worker_consume(w, &out, &out_len, &st); + free(out); + } + } +} + +static bool agent_acp_check_session(agent_worker *w, const char *params, + char *err, size_t err_len) { + char *session_id = NULL; + if (!params || !ds4_acp_object_get_string(params, "sessionId", &session_id)) { + snprintf(err, err_len, "missing sessionId"); + return false; + } + bool ok = w->acp_session_id[0] && !strcmp(session_id, w->acp_session_id); + if (!ok) snprintf(err, err_len, "unknown sessionId"); + free(session_id); + return ok; +} + +static char *agent_acp_current_cwd(agent_worker *w) { + if (w->acp_cwd) return xstrdup(w->acp_cwd); + char cwd[PATH_MAX]; + if (getcwd(cwd, sizeof(cwd))) return xstrdup(cwd); + return xstrdup("/"); +} + +static void agent_acp_set_cwd(agent_worker *w, const char *cwd) { + free(w->acp_cwd); + w->acp_cwd = xstrdup(cwd); +} + +static void agent_acp_iso8601(uint64_t ts, char *buf, size_t len) { + time_t t = (time_t)(ts ? ts : (uint64_t)time(NULL)); + struct tm tm; + if (gmtime_r(&t, &tm)) + strftime(buf, len, "%Y-%m-%dT%H:%M:%SZ", &tm); + else + snprintf(buf, len, "1970-01-01T00:00:00Z"); +} + +static char *agent_acp_config_options_json(agent_worker *w) { + const char *current = w->acp_web_allow ? "allow" : "ask"; + agent_buf b = {0}; + agent_buf_puts(&b, + "[{\"id\":\"_ds4_browser_permission\"," + "\"name\":\"Browser Permission\"," + "\"description\":\"Controls visible Chrome startup for browser tools.\"," + "\"category\":\"_ds4_tool_policy\"," + "\"type\":\"select\",\"currentValue\":\""); + agent_buf_puts(&b, current); + agent_buf_puts(&b, + "\",\"options\":[" + "{\"value\":\"ask\",\"name\":\"Ask\"," + "\"description\":\"Ask before starting visible Chrome.\"}," + "{\"value\":\"allow\",\"name\":\"Allow\"," + "\"description\":\"Start visible Chrome without asking in this session.\"}" + "]}]"); + return agent_buf_take(&b); +} + +static char *agent_acp_new_session_result(agent_worker *w) { + char *qsid = ds4_acp_json_escape(w->acp_session_id, + strlen(w->acp_session_id)); + char *config = agent_acp_config_options_json(w); + agent_buf b = {0}; + agent_buf_puts(&b, "{\"sessionId\":"); + agent_buf_puts(&b, qsid); + agent_buf_puts(&b, ",\"configOptions\":"); + agent_buf_puts(&b, config); + agent_buf_puts(&b, "}"); + free(qsid); + free(config); + return agent_buf_take(&b); +} + +static char *agent_acp_resume_session_result(agent_worker *w) { + char *config = agent_acp_config_options_json(w); + agent_buf b = {0}; + agent_buf_puts(&b, "{\"configOptions\":"); + agent_buf_puts(&b, config); + agent_buf_puts(&b, "}"); + free(config); + return agent_buf_take(&b); +} + +static void agent_acp_notify_config(agent_worker *w) { + if (!w->cfg->acp || !w->acp_session_id[0]) return; + char *qsid = ds4_acp_json_escape(w->acp_session_id, strlen(w->acp_session_id)); + char *config = agent_acp_config_options_json(w); + pthread_mutex_lock(&w->acp_mu); + agent_acp_puts_locked( + "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":"); + agent_acp_write_all_locked(qsid, strlen(qsid)); + agent_acp_puts_locked( + ",\"update\":{\"sessionUpdate\":\"config_option_update\",\"configOptions\":"); + agent_acp_write_all_locked(config, strlen(config)); + agent_acp_puts_locked("}}}\n"); + pthread_mutex_unlock(&w->acp_mu); + free(qsid); + free(config); +} + +static bool agent_acp_setup_mcp(agent_worker *w, const char *params, + char *err, size_t err_len) { + char *servers = NULL; + if (!params || !ds4_acp_object_get_raw(params, "mcpServers", &servers)) + servers = xstrdup("[]"); + bool ok = ds4_mcp_connect_json(&w->mcp, servers, agent_web_cancel, w, + err, err_len); + free(servers); + if (!ok) { + ds4_mcp_close(&w->mcp); + free(w->mcp_tools_prompt); + w->mcp_tools_prompt = xstrdup(""); + return false; + } + free(w->mcp_tools_prompt); + w->mcp_tools_prompt = ds4_mcp_tools_prompt(&w->mcp); + return true; +} + +static bool agent_acp_prepare_session_cwd(agent_worker *w, const char *params, + const char *method, + char *err, size_t err_len) { + char *cwd = NULL; + if (!params || !ds4_acp_object_get_string(params, "cwd", &cwd) || + cwd[0] != '/') + { + free(cwd); + snprintf(err, err_len, "%s requires absolute cwd", method); + return false; + } + if (chdir(cwd) != 0) { + snprintf(err, err_len, "failed to chdir to %s: %s", cwd, strerror(errno)); + free(cwd); + return false; + } + agent_acp_set_cwd(w, cwd); + free(cwd); + return true; +} + +static char *agent_acp_session_list_json(agent_worker *w, const char *params, + char *err, size_t err_len) { + char *cwd_filter = NULL; + char *cursor = NULL; + if (params && ds4_acp_object_get_string(params, "cwd", &cwd_filter) && + cwd_filter[0] != '/') + { + free(cwd_filter); + snprintf(err, err_len, "session/list cwd must be absolute"); + return NULL; + } + if (params && ds4_acp_object_get_string(params, "cursor", &cursor) && + cursor[0]) + { + free(cwd_filter); + free(cursor); + snprintf(err, err_len, "session/list cursor is invalid"); + return NULL; + } + free(cursor); + + /* DS4 session KV files do not carry workspace metadata. Report the ACP + * workspace cwd for saved sessions until the session format grows that + * field, rather than inventing a second persistence layer here. */ + char *cwd = agent_acp_current_cwd(w); + bool cwd_matches = !cwd_filter || !strcmp(cwd_filter, cwd); + agent_buf b = {0}; + agent_buf_puts(&b, "{\"sessions\":["); + if (cwd_matches) { + DIR *d = opendir(w->cache_dir); + if (d) { + agent_session_list_item *sessions = NULL; + int sessions_len = 0, sessions_cap = 0; + const uint8_t model_id = (uint8_t)ds4_engine_model_id(w->engine); + struct dirent *de; + while ((de = readdir(d)) != NULL) { + char sha[41]; + if (!ds4_kvstore_sha_hex_name(de->d_name, sha)) continue; + char *path = ds4_kvstore_path_join(w->cache_dir, de->d_name); + ds4_kvstore_entry e = {0}; + if (ds4_kvstore_read_entry_file(path, sha, &e)) { + if (e.model_id == model_id) { + char *title = agent_session_title_from_file(path, 160); + agent_session_list_push(&sessions, &sessions_len, + &sessions_cap, e, title); + } else { + ds4_kvstore_entry_free(&e); + } + } + free(path); + } + closedir(d); + qsort(sessions, (size_t)sessions_len, sizeof(sessions[0]), + agent_session_list_cmp_recent); + for (int i = 0; i < sessions_len; i++) { + if (i) agent_buf_puts(&b, ","); + ds4_kvstore_entry *e = &sessions[i].entry; + char ts[32]; + agent_acp_iso8601(e->last_used ? e->last_used : e->created_at, + ts, sizeof(ts)); + char *qsid = ds4_acp_json_escape(e->sha, strlen(e->sha)); + char *qcwd = ds4_acp_json_escape(cwd, strlen(cwd)); + const char *title = sessions[i].title ? + sessions[i].title : "(no user prompt)"; + char *qtitle = ds4_acp_json_escape(title, strlen(title)); + char *qts = ds4_acp_json_escape(ts, strlen(ts)); + agent_buf_puts(&b, "{\"sessionId\":"); + agent_buf_puts(&b, qsid); + agent_buf_puts(&b, ",\"cwd\":"); + agent_buf_puts(&b, qcwd); + agent_buf_puts(&b, ",\"title\":"); + agent_buf_puts(&b, qtitle); + agent_buf_puts(&b, ",\"updatedAt\":"); + agent_buf_puts(&b, qts); + agent_buf_puts(&b, "}"); + free(qsid); + free(qcwd); + free(qtitle); + free(qts); + } + agent_session_list_free(sessions, sessions_len); + } + } + agent_buf_puts(&b, "]}"); + free(cwd_filter); + free(cwd); + return agent_buf_take(&b); +} + +static char *agent_acp_strip_thinking(const char *s, size_t n) { + static const char open[] = ""; + static const char close[] = ""; + agent_buf b = {0}; + const char *p = s; + const char *end = s + n; + while (p < end) { + const char *o = agent_memmem(p, (size_t)(end - p), + open, sizeof(open) - 1); + if (!o) { + agent_buf_append(&b, p, (size_t)(end - p)); + break; + } + agent_buf_append(&b, p, (size_t)(o - p)); + const char *q = o + sizeof(open) - 1; + const char *c = agent_memmem(q, (size_t)(end - q), + close, sizeof(close) - 1); + p = c ? c + sizeof(close) - 1 : end; + } + return agent_buf_take(&b); +} + +static void agent_acp_replay_history(agent_worker *w) { + size_t text_len = 0; + char *text = ds4_kvstore_render_tokens_text(w->engine, &w->transcript, + &text_len); + if (!text) return; + const char *p = text, *end = text + text_len; + int next_id = 0; + while (p < end) { + agent_history_mark mark = AGENT_HISTORY_MARK_NONE; + size_t mark_len = 0; + const char *m = agent_history_next_marker(p, end, &mark, &mark_len); + if (!m) break; + const char *content = m + mark_len; + agent_history_mark next_mark = AGENT_HISTORY_MARK_NONE; + size_t next_len = 0; + const char *next = agent_history_next_marker(content, end, + &next_mark, &next_len); + const char *content_end = next ? next : end; + agent_history_trim(&content, &content_end); + if (content < content_end) { + char msg_id[64]; + snprintf(msg_id, sizeof(msg_id), "msg_replay_%d", ++next_id); + if (mark == AGENT_HISTORY_MARK_USER && + !agent_history_is_tool_user(content, content_end)) + { + agent_acp_notify_text(w, "user_message_chunk", msg_id, + content, (size_t)(content_end - content)); + } else if (mark == AGENT_HISTORY_MARK_ASSISTANT) { + char *visible = agent_acp_strip_thinking( + content, (size_t)(content_end - content)); + if (visible[0]) + agent_acp_notify_text(w, "agent_message_chunk", msg_id, + visible, strlen(visible)); + free(visible); + } + } + p = content_end; + } + free(text); +} + +static void agent_acp_begin_message_ids(agent_worker *w) { + snprintf(w->acp_user_message_id, sizeof(w->acp_user_message_id), + "msg_%d", ++w->next_acp_message_id); + snprintf(w->acp_assistant_message_id, sizeof(w->acp_assistant_message_id), + "msg_%d", ++w->next_acp_message_id); +} + +static bool agent_acp_protocol_version_param(const char *params) { + char *raw = NULL; + if (!params || !ds4_acp_object_get_raw(params, "protocolVersion", &raw)) + return false; + const char *p = raw; + ds4_acp_json_ws(&p); + bool ok = false; + if (isdigit((unsigned char)*p)) { + errno = 0; + char *end = NULL; + long version = strtol(p, &end, 10); + const char *q = end; + ds4_acp_json_ws(&q); + ok = errno == 0 && *q == '\0' && version >= 0 && version <= 65535; + } + free(raw); + return ok; +} + +static bool agent_acp_append_prompt_block(agent_buf *out, const char *block, + char *err, size_t err_len) { + char *type = NULL; + if (!ds4_acp_object_get_string(block, "type", &type)) { + snprintf(err, err_len, "prompt block missing type"); + return false; + } + if (!strcmp(type, "text")) { + char *text = NULL; + if (!ds4_acp_object_get_string(block, "text", &text)) { + free(type); + snprintf(err, err_len, "text block missing text"); + return false; + } + if (out->len && text[0]) agent_buf_puts(out, "\n"); + agent_buf_puts(out, text); + free(text); + free(type); + return true; + } + if (!strcmp(type, "resource_link")) { + char *uri = NULL; + char *name = NULL; + if (!ds4_acp_object_get_string(block, "uri", &uri) || + !ds4_acp_object_get_string(block, "name", &name)) + { + free(type); + free(uri); + free(name); + snprintf(err, err_len, "resource_link block missing name or uri"); + return false; + } + if (out->len) agent_buf_puts(out, "\n"); + agent_buf_puts(out, "[resource_link"); + if (name && name[0]) { + agent_buf_puts(out, " "); + agent_buf_puts(out, name); + } + agent_buf_puts(out, ": "); + agent_buf_puts(out, uri); + agent_buf_puts(out, "]"); + free(uri); + free(name); + free(type); + return true; + } + if (!strcmp(type, "resource")) { + char *res = NULL; + char *uri = NULL; + char *mime = NULL; + char *text = NULL; + if (!ds4_acp_object_get_raw(block, "resource", &res)) { + free(type); + snprintf(err, err_len, "resource block missing resource"); + return false; + } + (void)ds4_acp_object_get_string(res, "uri", &uri); + (void)ds4_acp_object_get_string(res, "mimeType", &mime); + if (!ds4_acp_object_get_string(res, "text", &text)) { + free(type); + free(res); + free(uri); + free(mime); + snprintf(err, err_len, "resource block requires text content"); + return false; + } + if (out->len) agent_buf_puts(out, "\n"); + agent_buf_puts(out, "[resource"); + if (uri && uri[0]) { + agent_buf_puts(out, " "); + agent_buf_puts(out, uri); + } + if (mime && mime[0]) { + agent_buf_puts(out, " mime="); + agent_buf_puts(out, mime); + } + agent_buf_puts(out, "]\n"); + agent_buf_puts(out, text); + free(type); + free(res); + free(uri); + free(mime); + free(text); + return true; + } + snprintf(err, err_len, "unsupported prompt block type: %s", type); + free(type); + return false; +} + +/* ACP prompt blocks can carry richer content than DS4's native text prompt. + * DS4 flattens text resources into the same user text path used by the + * terminal agent, and rejects image/audio because the model is not multimodal. */ +static bool agent_acp_flatten_prompt(const char *params, + char **prompt_out, + char *err, size_t err_len) { + char *prompt = NULL; + if (!params || !ds4_acp_object_get_raw(params, "prompt", &prompt)) { + snprintf(err, err_len, "missing prompt"); + return false; + } + const char *p = prompt; + ds4_acp_json_ws(&p); + if (*p != '[') { + free(prompt); + snprintf(err, err_len, "prompt must be an array"); + return false; + } + p++; + ds4_acp_json_ws(&p); + agent_buf out = {0}; + while (*p && *p != ']') { + char *block = NULL; + if (!ds4_acp_json_raw_value(&p, &block)) { + free(prompt); + free(out.ptr); + snprintf(err, err_len, "invalid prompt block"); + return false; + } + bool ok = agent_acp_append_prompt_block(&out, block, err, err_len); + free(block); + if (!ok) { + free(prompt); + free(out.ptr); + return false; + } + ds4_acp_json_ws(&p); + if (*p == ',') { + p++; + ds4_acp_json_ws(&p); + } else if (*p != ']') { + free(prompt); + free(out.ptr); + snprintf(err, err_len, "invalid prompt array"); + return false; + } + } + if (*p != ']' || out.len == 0) { + free(prompt); + free(out.ptr); + snprintf(err, err_len, "empty or unterminated prompt"); + return false; + } + free(prompt); + *prompt_out = agent_buf_take(&out); + return true; +} + +static void agent_acp_finish_prompt_if_needed(agent_worker *worker, + bool *prompt_active, + char **prompt_id_json) { + if (!*prompt_active) return; + agent_status st; + worker_get_status(worker, &st); + agent_turn_result result = worker_turn_result(worker); + if (result == AGENT_TURN_NONE || + (st.state != AGENT_WORKER_IDLE && st.state != AGENT_WORKER_ERROR)) + { + return; + } + /* ACP has no generic error stop reason; worker failures are reported as + * JSON-RPC internal errors instead of successful prompt responses. */ + if (result == AGENT_TURN_ERROR || st.state == AGENT_WORKER_ERROR) { + agent_acp_write_error(worker, *prompt_id_json, -32603, + st.error[0] ? st.error : "agent turn failed"); + } else { + const char *stop_reason = agent_turn_stop_reason(result); + if (!stop_reason) { + agent_acp_write_error(worker, *prompt_id_json, -32603, + "agent turn ended without stop reason"); + } else { + char response[96]; + snprintf(response, sizeof(response), "{\"stopReason\":\"%s\"}", + stop_reason); + agent_acp_write_response(worker, *prompt_id_json, response); + } + } + free(*prompt_id_json); + *prompt_id_json = NULL; + *prompt_active = false; + worker->acp_user_message_id[0] = '\0'; + worker->acp_assistant_message_id[0] = '\0'; +} + +static bool agent_acp_wait_worker_idle(agent_worker *w, + char *err, size_t err_len) { + for (;;) { + agent_status st; + worker_get_status(w, &st); + if (st.state == AGENT_WORKER_IDLE || st.state == AGENT_WORKER_ERROR) + return true; + struct pollfd pfd = {.fd = w->wake_fd[0], .events = POLLIN}; + int pr = poll(&pfd, 1, -1); + if (pr < 0) { + if (errno == EINTR) continue; + snprintf(err, err_len, "poll failed: %s", strerror(errno)); + return false; + } + if (pfd.revents & POLLIN) { + char *out = NULL; + size_t out_len = 0; + drain_wake_fd(w->wake_fd[0]); + worker_consume(w, &out, &out_len, &st); + free(out); + } + } +} + +static void agent_acp_clear_session(agent_worker *w) { + ds4_mcp_close(&w->mcp); + free(w->mcp_tools_prompt); + w->mcp_tools_prompt = xstrdup(""); + w->acp_session_id[0] = '\0'; + free(w->acp_cwd); + w->acp_cwd = NULL; + w->acp_web_allow = false; + w->acp_user_message_id[0] = '\0'; + w->acp_assistant_message_id[0] = '\0'; + w->next_acp_tool_call_id = 0; +} + +static int agent_acp_id_number(const char *id_json) { + if (!id_json) return -1; + const char *p = id_json; + ds4_acp_json_ws(&p); + if (!isdigit((unsigned char)*p)) return -1; + errno = 0; + char *end = NULL; + long id = strtol(p, &end, 10); + const char *q = end; + ds4_acp_json_ws(&q); + if (errno || *q || id < 0 || id > INT_MAX) return -1; + return (int)id; +} + +static void agent_acp_handle_response(agent_worker *worker, + const ds4_acp_message *msg) { + int id = agent_acp_id_number(msg->id_json); + if (id < 0) return; + + pthread_mutex_lock(&worker->mu); + bool pending = worker->acp_permission_pending && + worker->acp_permission_id == id; + pthread_mutex_unlock(&worker->mu); + if (!pending) return; + + bool allow = false; + char error[160] = "permission request failed"; + if (msg->has_error) { + snprintf(error, sizeof(error), "permission request failed"); + } else if (msg->has_result) { + char *outcome = NULL; + if (ds4_acp_object_get_raw(msg->result_json, "outcome", &outcome)) { + char *kind = NULL; + if (ds4_acp_object_get_string(outcome, "outcome", &kind)) { + if (!strcmp(kind, "selected")) { + char *option = NULL; + if (ds4_acp_object_get_string(outcome, "optionId", &option) && + !strcmp(option, "allow-once")) + { + allow = true; + error[0] = '\0'; + } else { + snprintf(error, sizeof(error), "permission denied"); + } + free(option); + } else if (!strcmp(kind, "cancelled")) { + snprintf(error, sizeof(error), "permission cancelled"); + } + } + free(kind); + free(outcome); + } + } + + pthread_mutex_lock(&worker->mu); + if (worker->acp_permission_pending && worker->acp_permission_id == id) { + worker->acp_permission_allow = allow; + snprintf(worker->acp_permission_error, + sizeof(worker->acp_permission_error), "%s", error); + worker->acp_permission_answered = true; + pthread_cond_signal(&worker->cond); + agent_wake_locked(worker); + } + pthread_mutex_unlock(&worker->mu); +} + +static bool agent_acp_delete_session_id(agent_worker *w, const char *session_id, + char *err, size_t err_len) { + if (!session_id) return true; + size_t n = strlen(session_id); + if (n != 40) return true; + for (size_t i = 0; i < n; i++) { + if (!isxdigit((unsigned char)session_id[i])) return true; + } + char sha[41]; + char *path = NULL; + if (!agent_worker_find_session(w, session_id, sha, &path, err, err_len)) + return true; + if (unlink(path) != 0) { + snprintf(err, err_len, "%s", strerror(errno)); + free(path); + return false; + } + free(path); + return true; +} + +static void agent_acp_handle_request(agent_worker *worker, + ds4_acp_request *req, + bool *initialized, + bool *session_created, + bool *prompt_active, + char **prompt_id_json) { + char err[256] = {0}; + if (!strcmp(req->method, "initialize")) { + if (!req->has_params || + !agent_acp_protocol_version_param(req->params_json)) + { + agent_acp_write_request_error(worker, req, -32602, + "initialize requires protocolVersion"); + return; + } + *initialized = true; + if (req->has_id) { + agent_acp_write_response(worker, req->id_json, + "{\"protocolVersion\":1," + "\"agentInfo\":{\"name\":\"ds4-agent\"," + "\"title\":\"DS4 Agent\",\"version\":\"0\"}," + "\"agentCapabilities\":{" + "\"loadSession\":true," + "\"sessionCapabilities\":{" + "\"list\":{},\"delete\":{},\"resume\":{},\"close\":{}}," + "\"promptCapabilities\":{\"embeddedContext\":true}}}"); + } + return; + } + + if (!*initialized) { + agent_acp_write_request_error(worker, req, -32600, + "initialize must be called first"); + return; + } + + if (!strcmp(req->method, "session/list")) { + char *res = agent_acp_session_list_json(worker, + req->has_params ? req->params_json : "{}", err, sizeof(err)); + if (!res) { + agent_acp_write_request_error(worker, req, -32602, err); + return; + } + if (req->has_id) agent_acp_write_response(worker, req->id_json, res); + free(res); + return; + } + + if (!strcmp(req->method, "session/delete")) { + char *session_id = NULL; + if (!req->has_params || + !ds4_acp_object_get_string(req->params_json, "sessionId", &session_id)) + { + agent_acp_write_request_error(worker, req, -32602, + "session/delete requires sessionId"); + return; + } + bool ok = agent_acp_delete_session_id(worker, session_id, + err, sizeof(err)); + free(session_id); + if (!ok) { + agent_acp_write_request_error(worker, req, -32603, err); + return; + } + if (req->has_id) agent_acp_write_response(worker, req->id_json, "{}"); + return; + } + + if (!strcmp(req->method, "session/new")) { + if (*session_created) { + agent_acp_write_request_error(worker, req, -32603, + "ACP session already exists"); + return; + } + if (!agent_acp_prepare_session_cwd(worker, req->params_json, + "session/new", err, sizeof(err))) + { + agent_acp_write_request_error(worker, req, -32602, err); + return; + } + if (!agent_acp_wait_initialized(worker, err, sizeof(err))) { + agent_acp_write_request_error(worker, req, -32603, err); + return; + } + if (!agent_acp_setup_mcp(worker, req->params_json, err, sizeof(err))) { + agent_acp_write_request_error(worker, req, -32602, err); + return; + } + if (!agent_worker_reset_to_sysprompt(worker, err, sizeof(err))) { + agent_acp_write_request_error(worker, req, -32603, err); + return; + } + agent_acp_make_session_id(worker); + *session_created = true; + if (req->has_id) { + char *res = agent_acp_new_session_result(worker); + agent_acp_write_response(worker, req->id_json, res); + free(res); + } + return; + } + + if (!strcmp(req->method, "session/load") || + !strcmp(req->method, "session/resume")) + { + char *session_id = NULL; + bool replay = !strcmp(req->method, "session/load"); + if (!req->has_params || + !ds4_acp_object_get_string(req->params_json, "sessionId", &session_id)) + { + agent_acp_write_request_error(worker, req, -32602, + replay ? "session/load requires sessionId" : + "session/resume requires sessionId"); + return; + } + if (*prompt_active || !worker_is_idle(worker)) { + free(session_id); + agent_acp_write_request_error(worker, req, -32603, "agent is busy"); + return; + } + if (!agent_acp_prepare_session_cwd(worker, req->params_json, + req->method, err, sizeof(err))) + { + free(session_id); + agent_acp_write_request_error(worker, req, -32602, err); + return; + } + if (!agent_acp_wait_initialized(worker, err, sizeof(err))) { + free(session_id); + agent_acp_write_request_error(worker, req, -32603, err); + return; + } + if (!agent_acp_setup_mcp(worker, req->params_json, err, sizeof(err))) { + free(session_id); + agent_acp_write_request_error(worker, req, -32602, err); + return; + } + if (!agent_worker_switch_session(worker, session_id, 0, + err, sizeof(err))) + { + free(session_id); + agent_acp_write_request_error(worker, req, -32602, err); + return; + } + snprintf(worker->acp_session_id, sizeof(worker->acp_session_id), + "%s", session_id); + *session_created = true; + free(session_id); + if (replay) agent_acp_replay_history(worker); + if (req->has_id) { + char *res = agent_acp_resume_session_result(worker); + agent_acp_write_response(worker, req->id_json, res); + free(res); + } + return; + } + + if (!strcmp(req->method, "session/prompt")) { + char *prompt = NULL; + if (!*session_created || + !agent_acp_check_session(worker, req->params_json, err, sizeof(err))) + { + agent_acp_write_request_error(worker, req, -32602, + err[0] ? err : "invalid session"); + return; + } + if (!req->has_id) return; + if (*prompt_active) { + agent_acp_write_request_error(worker, req, -32603, + "agent is busy"); + return; + } + if (!agent_acp_flatten_prompt(req->params_json, &prompt, + err, sizeof(err))) + { + agent_acp_write_request_error(worker, req, -32602, err); + return; + } + if (!worker_is_idle(worker)) { + free(prompt); + agent_acp_write_request_error(worker, req, -32603, + "agent is not ready"); + return; + } + agent_acp_begin_message_ids(worker); + agent_acp_notify_user(worker, prompt); + if (!worker_submit(worker, prompt)) { + worker->acp_user_message_id[0] = '\0'; + worker->acp_assistant_message_id[0] = '\0'; + free(prompt); + agent_acp_write_request_error(worker, req, -32603, + "agent is not ready"); + return; + } + free(prompt); + *prompt_active = true; + *prompt_id_json = req->id_json; + req->id_json = NULL; + req->has_id = false; + return; + } + + if (!strcmp(req->method, "session/cancel")) { + if (!req->has_params || + !agent_acp_check_session(worker, req->params_json, err, sizeof(err))) + { + if (req->has_id) + agent_acp_write_error(worker, req->id_json, -32602, + err[0] ? err : "invalid session"); + return; + } + worker_interrupt(worker); + if (req->has_id) agent_acp_write_response(worker, req->id_json, "{}"); + return; + } + + if (!strcmp(req->method, "session/close")) { + if (!req->has_params || + !agent_acp_check_session(worker, req->params_json, err, sizeof(err))) + { + agent_acp_write_request_error(worker, req, -32602, + err[0] ? err : "invalid session"); + return; + } + if (*prompt_active) { + worker_interrupt(worker); + if (agent_acp_wait_worker_idle(worker, err, sizeof(err))) + agent_acp_finish_prompt_if_needed(worker, prompt_active, + prompt_id_json); + } + agent_acp_clear_session(worker); + *session_created = false; + if (req->has_id) agent_acp_write_response(worker, req->id_json, "{}"); + return; + } + + if (!strcmp(req->method, "session/set_config_option")) { + char *config_id = NULL; + char *value = NULL; + if (!req->has_params || + !agent_acp_check_session(worker, req->params_json, err, sizeof(err)) || + !ds4_acp_object_get_string(req->params_json, "configId", &config_id) || + !ds4_acp_object_get_string(req->params_json, "value", &value)) + { + free(config_id); + free(value); + agent_acp_write_request_error(worker, req, -32602, + err[0] ? err : "invalid config option"); + return; + } + if (strcmp(config_id, "_ds4_browser_permission") || + (strcmp(value, "ask") && strcmp(value, "allow"))) + { + free(config_id); + free(value); + agent_acp_write_request_error(worker, req, -32602, + "unsupported config option"); + return; + } + worker->acp_web_allow = !strcmp(value, "allow"); + free(config_id); + free(value); + if (req->has_id) { + char *res = agent_acp_resume_session_result(worker); + agent_acp_write_response(worker, req->id_json, res); + free(res); + } + agent_acp_notify_config(worker); + return; + } + + if (req->has_id) + agent_acp_write_error(worker, req->id_json, -32601, "method not found"); +} + +static int run_agent_acp(ds4_engine *engine, agent_config *cfg) { + agent_worker worker; + if (agent_worker_init(&worker, engine, cfg) != 0) return 1; + + int old_stdin_flags = -1; + set_nonblock(STDIN_FILENO, true, &old_stdin_flags); + + bool initialized = false; + bool session_created = false; + bool prompt_active = false; + bool stdin_eof = false; + char *prompt_id_json = NULL; + agent_input_buf in = {0}; + int rc = 0; + + while (!stdin_eof || prompt_active) { + struct pollfd fds[2] = { + {.fd = STDIN_FILENO, .events = stdin_eof ? 0 : POLLIN}, + {.fd = worker.wake_fd[0], .events = POLLIN}, + }; + int pr = poll(fds, 2, -1); + if (pr < 0) { + if (errno == EINTR) continue; + perror("ds4-agent: poll"); + rc = 1; + break; + } + if (fds[0].revents & (POLLIN | POLLHUP)) { + if (agent_read_stdin_available(&in, &stdin_eof) != 0) { + rc = 1; + break; + } + char *line; + while ((line = agent_input_buf_take_line(&in)) != NULL) { + if (!line[0]) { + free(line); + continue; + } + ds4_acp_message msg; + char err[256] = {0}; + ds4_acp_parse_result parsed = + ds4_acp_parse_message(line, &msg, err, sizeof(err)); + if (parsed != DS4_ACP_PARSE_OK) { + agent_acp_write_error(&worker, NULL, + parsed == DS4_ACP_PARSE_JSON ? -32700 : -32600, + err[0] ? err : "invalid JSON-RPC request"); + free(line); + continue; + } + if (msg.has_method) { + ds4_acp_request req = { + .has_id = msg.has_id, + .id_json = msg.id_json, + .method = msg.method, + .has_params = msg.has_params, + .params_json = msg.params_json, + }; + msg.has_id = false; + msg.id_json = NULL; + msg.method = NULL; + msg.has_params = false; + msg.params_json = NULL; + agent_acp_handle_request(&worker, &req, &initialized, + &session_created, &prompt_active, + &prompt_id_json); + ds4_acp_request_free(&req); + } else { + agent_acp_handle_response(&worker, &msg); + } + ds4_acp_message_free(&msg); + free(line); + } + } + if (fds[1].revents & POLLIN) { + char *out = NULL; + size_t out_len = 0; + agent_status st; + drain_wake_fd(worker.wake_fd[0]); + worker_consume(&worker, &out, &out_len, &st); + free(out); + } + agent_acp_finish_prompt_if_needed(&worker, &prompt_active, + &prompt_id_json); + } + + free(prompt_id_json); + agent_input_buf_free(&in); + if (old_stdin_flags >= 0) fcntl(STDIN_FILENO, F_SETFL, old_stdin_flags); + agent_worker_free(&worker); + return rc; +} + /* Headless mode is intentionally just another front-end for the same worker. * With -p/--prompt it is a one-shot execution. Without -p it becomes a small * stdin protocol: announce readiness on stderr, collect bytes until stdin has @@ -10088,12 +11610,13 @@ int main(int argc, char **argv) { memset(&sa, 0, sizeof(sa)); sigemptyset(&sa.sa_mask); sa.sa_handler = agent_sigint_handler; - bool sigint_installed = !cfg.non_interactive && + bool sigint_installed = !cfg.non_interactive && !cfg.acp && sigaction(SIGINT, &sa, &old_int) == 0; - int rc = cfg.non_interactive ? - run_agent_non_interactive(engine, &cfg) : - run_agent(engine, &cfg); + int rc = cfg.acp ? run_agent_acp(engine, &cfg) : + (cfg.non_interactive ? + run_agent_non_interactive(engine, &cfg) : + run_agent(engine, &cfg)); if (sigint_installed) sigaction(SIGINT, &old_int, NULL); ds4_engine_close(engine); diff --git a/ds4_help.c b/ds4_help.c index 92f184a96..9b3653b62 100644 --- a/ds4_help.c +++ b/ds4_help.c @@ -273,6 +273,7 @@ static void print_agent_specific(FILE *fp, const help_colors *c) { title(fp, c, "Agent Options"); opt(fp, c, "-p, --prompt TEXT", "Submit an initial prompt after startup."); opt(fp, c, "--non-interactive", "Run without TUI. With -p: one turn; without -p: repeated stdin prompts."); + opt(fp, c, "--acp", "Run Agent Client Protocol JSON-RPC over stdin/stdout."); opt(fp, c, "-sys, --system TEXT", "Extra system prompt. Empty disables extra text."); opt(fp, c, "--trace FILE", "Write prompt, token, and DSML debug trace."); opt(fp, c, "--chdir DIR", "Change working directory before loading runtime assets."); diff --git a/ds4_mcp.c b/ds4_mcp.c new file mode 100644 index 000000000..115547fe1 --- /dev/null +++ b/ds4_mcp.c @@ -0,0 +1,952 @@ +#include "ds4_mcp.h" +#include "ds4_acp.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* MCP servers are client-provided subprocesses. A silent server should fail the + * ACP session setup or tool call instead of leaving the protocol loop stuck. */ +#define DS4_MCP_CONNECT_TIMEOUT_MS 15000 +#define DS4_MCP_CALL_TIMEOUT_MS 300000 + +typedef struct { + char *ptr; + size_t len; + size_t cap; +} mcp_buf; + +static void mcp_oom(const char *what) { + fprintf(stderr, "%s\n", what); + exit(1); +} + +static void *mcp_xmalloc(size_t n) { + void *p = malloc(n ? n : 1); + if (!p) mcp_oom("ds4-mcp: malloc"); + return p; +} + +static void *mcp_xrealloc(void *p, size_t n) { + p = realloc(p, n ? n : 1); + if (!p) mcp_oom("ds4-mcp: realloc"); + return p; +} + +static char *mcp_xstrdup(const char *s) { + size_t n = strlen(s ? s : ""); + char *p = mcp_xmalloc(n + 1); + memcpy(p, s ? s : "", n + 1); + return p; +} + +static void mcp_buf_append(mcp_buf *b, const char *s, size_t n) { + if (!n) return; + if (b->len + n + 1 > b->cap) { + size_t cap = b->cap ? b->cap * 2 : 1024; + while (cap < b->len + n + 1) cap *= 2; + b->ptr = mcp_xrealloc(b->ptr, cap); + b->cap = cap; + } + memcpy(b->ptr + b->len, s, n); + b->len += n; + b->ptr[b->len] = '\0'; +} + +static void mcp_buf_puts(mcp_buf *b, const char *s) { + mcp_buf_append(b, s, strlen(s)); +} + +static char *mcp_buf_take(mcp_buf *b) { + if (!b->ptr) return mcp_xstrdup(""); + char *p = b->ptr; + memset(b, 0, sizeof(*b)); + return p; +} + +static void mcp_set_err(char *err, size_t err_len, const char *fmt, ...) { + if (!err || !err_len) return; + va_list ap; + va_start(ap, fmt); + vsnprintf(err, err_len, fmt, ap); + va_end(ap); +} + +static ssize_t mcp_write_pipe(int fd, const char *s, size_t n) { + sigset_t set, oldset, pending; + bool blocked = false, had_sigpipe = false; + + sigemptyset(&set); + sigaddset(&set, SIGPIPE); + if (pthread_sigmask(SIG_BLOCK, &set, &oldset) == 0) { + blocked = true; + if (sigpending(&pending) == 0 && + sigismember(&pending, SIGPIPE) == 1) + had_sigpipe = true; + } + + ssize_t wr = write(fd, s, n); + int saved_errno = errno; + if (blocked && wr < 0 && saved_errno == EPIPE && !had_sigpipe) { + if (sigpending(&pending) == 0 && + sigismember(&pending, SIGPIPE) == 1) + { + int sig; + (void)sigwait(&set, &sig); + } + } + if (blocked) pthread_sigmask(SIG_SETMASK, &oldset, NULL); + errno = saved_errno; + return wr; +} + +static bool mcp_write_all(int fd, const char *s, size_t n) { + while (n) { + ssize_t wr = mcp_write_pipe(fd, s, n); + if (wr > 0) { + s += wr; + n -= (size_t)wr; + continue; + } + if (wr < 0 && errno == EINTR) continue; + return false; + } + return true; +} + +static bool mcp_cancelled(ds4_mcp_cancel_fn cancel, void *privdata) { + return cancel && cancel(privdata); +} + +static int64_t mcp_now_ms(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} + +static int mcp_timeout_default(int timeout_ms, int default_ms) { + return timeout_ms > 0 ? timeout_ms : default_ms; +} + +static bool mcp_read_line(ds4_mcp_server *s, char **line_out, + ds4_mcp_cancel_fn cancel, void *cancel_privdata, + int64_t deadline_ms, char *err, size_t err_len) { + mcp_buf b = {0}; + for (;;) { + if (mcp_cancelled(cancel, cancel_privdata)) { + free(b.ptr); + mcp_set_err(err, err_len, "interrupted"); + return false; + } + int poll_ms = 100; + if (deadline_ms > 0) { + int64_t rem = deadline_ms - mcp_now_ms(); + if (rem <= 0) { + free(b.ptr); + mcp_set_err(err, err_len, "MCP server %s timed out", s->name); + return false; + } + if (rem < poll_ms) poll_ms = (int)rem; + } + struct pollfd pfd = {.fd = s->out_fd, .events = POLLIN}; + int pr = poll(&pfd, 1, poll_ms); + if (pr < 0) { + if (errno == EINTR) continue; + free(b.ptr); + mcp_set_err(err, err_len, "poll failed: %s", strerror(errno)); + return false; + } + if (pr == 0) continue; + if (pfd.revents & (POLLHUP | POLLERR | POLLNVAL)) { + free(b.ptr); + mcp_set_err(err, err_len, "MCP server %s closed stdout", s->name); + return false; + } + if (!(pfd.revents & POLLIN)) continue; + char c; + ssize_t n = read(s->out_fd, &c, 1); + if (n > 0) { + if (c == '\n') { + *line_out = mcp_buf_take(&b); + return true; + } + mcp_buf_append(&b, &c, 1); + if (b.len > 4 * 1024 * 1024) { + free(b.ptr); + mcp_set_err(err, err_len, "MCP server %s line too large", s->name); + return false; + } + continue; + } + if (n == 0) { + free(b.ptr); + mcp_set_err(err, err_len, "MCP server %s closed stdout", s->name); + return false; + } + if (errno == EINTR) continue; + free(b.ptr); + mcp_set_err(err, err_len, "read failed: %s", strerror(errno)); + return false; + } +} + +static void mcp_server_free(ds4_mcp_server *s) { + if (!s) return; + if (s->in_fd >= 0) close(s->in_fd); + if (s->out_fd >= 0) close(s->out_fd); + if (s->pid > 0) { + kill(s->pid, SIGTERM); + for (int i = 0; i < 20; i++) { + if (waitpid(s->pid, NULL, WNOHANG) == s->pid) break; + usleep(50000); + } + if (waitpid(s->pid, NULL, WNOHANG) == 0) { + kill(s->pid, SIGKILL); + while (waitpid(s->pid, NULL, 0) < 0 && errno == EINTR) {} + } + } + free(s->name); + free(s->command); + for (int i = 0; i < s->argc; i++) free(s->args[i]); + free(s->args); + for (int i = 0; i < s->envc; i++) { + free(s->env_names[i]); + free(s->env_values[i]); + } + free(s->env_names); + free(s->env_values); + memset(s, 0, sizeof(*s)); + s->in_fd = -1; + s->out_fd = -1; +} + +static void mcp_tool_free(ds4_mcp_tool *t) { + if (!t) return; + free(t->name); + free(t->title); + free(t->description); + free(t->input_schema); + free(t->dsml_name); + memset(t, 0, sizeof(*t)); +} + +void ds4_mcp_init(ds4_mcp *mcp) { + memset(mcp, 0, sizeof(*mcp)); + mcp->connect_timeout_ms = DS4_MCP_CONNECT_TIMEOUT_MS; + mcp->call_timeout_ms = DS4_MCP_CALL_TIMEOUT_MS; +} + +void ds4_mcp_close(ds4_mcp *mcp) { + if (!mcp) return; + for (int i = 0; i < mcp->servers_len; i++) mcp_server_free(&mcp->servers[i]); + for (int i = 0; i < mcp->tools_len; i++) mcp_tool_free(&mcp->tools[i]); + free(mcp->servers); + free(mcp->tools); + memset(mcp, 0, sizeof(*mcp)); +} + +static bool mcp_json_array_next(const char **p, char **raw_out, + char *err, size_t err_len) { + ds4_acp_json_ws(p); + if (**p == ']') { + (*p)++; + *raw_out = NULL; + return true; + } + if (!ds4_acp_json_raw_value(p, raw_out)) { + mcp_set_err(err, err_len, "invalid JSON array value"); + return false; + } + ds4_acp_json_ws(p); + if (**p == ',') { + (*p)++; + return true; + } + if (**p == ']') { + return true; + } + mcp_set_err(err, err_len, "invalid JSON array"); + free(*raw_out); + *raw_out = NULL; + return false; +} + +static bool mcp_json_array_done(const char *p) { + ds4_acp_json_ws(&p); + return *p == '\0'; +} + +static bool mcp_parse_string_array(const char *json, char ***out, int *len_out, + char *err, size_t err_len) { + const char *p = json; + ds4_acp_json_ws(&p); + if (*p != '[') { + mcp_set_err(err, err_len, "expected string array"); + return false; + } + p++; + char **v = NULL; + int len = 0, cap = 0; + for (;;) { + char *raw = NULL; + if (!mcp_json_array_next(&p, &raw, err, err_len)) goto fail; + if (!raw) break; + const char *q = raw; + char *s = NULL; + bool ok = ds4_acp_json_string(&q, &s); + ds4_acp_json_ws(&q); + free(raw); + if (!ok || *q) { + free(s); + mcp_set_err(err, err_len, "array item must be a string"); + goto fail; + } + if (len == cap) { + cap = cap ? cap * 2 : 4; + v = mcp_xrealloc(v, (size_t)cap * sizeof(v[0])); + } + v[len++] = s; + } + if (!mcp_json_array_done(p)) { + mcp_set_err(err, err_len, "trailing data after array"); + goto fail; + } + *out = v; + *len_out = len; + return true; + +fail: + for (int i = 0; i < len; i++) free(v[i]); + free(v); + return false; +} + +static bool mcp_parse_env_array(const char *json, ds4_mcp_server *s, + char *err, size_t err_len) { + const char *p = json; + ds4_acp_json_ws(&p); + if (*p != '[') { + mcp_set_err(err, err_len, "env must be an array"); + return false; + } + p++; + for (;;) { + char *raw = NULL; + if (!mcp_json_array_next(&p, &raw, err, err_len)) return false; + if (!raw) break; + char *name = NULL; + char *value = NULL; + bool ok = ds4_acp_object_get_string(raw, "name", &name) && + ds4_acp_object_get_string(raw, "value", &value); + free(raw); + if (!ok) { + free(name); + free(value); + mcp_set_err(err, err_len, "env entries require name and value"); + return false; + } + s->env_names = mcp_xrealloc(s->env_names, + (size_t)(s->envc + 1) * sizeof(s->env_names[0])); + s->env_values = mcp_xrealloc(s->env_values, + (size_t)(s->envc + 1) * sizeof(s->env_values[0])); + s->env_names[s->envc] = name; + s->env_values[s->envc] = value; + s->envc++; + } + if (!mcp_json_array_done(p)) { + mcp_set_err(err, err_len, "trailing data after env"); + return false; + } + return true; +} + +static void mcp_servers_push(ds4_mcp *mcp, ds4_mcp_server *s) { + if (mcp->servers_len == mcp->servers_cap) { + mcp->servers_cap = mcp->servers_cap ? mcp->servers_cap * 2 : 2; + mcp->servers = mcp_xrealloc(mcp->servers, + (size_t)mcp->servers_cap * sizeof(mcp->servers[0])); + } + mcp->servers[mcp->servers_len++] = *s; + memset(s, 0, sizeof(*s)); + s->in_fd = -1; + s->out_fd = -1; +} + +static bool mcp_parse_server(const char *json, ds4_mcp_server *s, + char *err, size_t err_len) { + memset(s, 0, sizeof(*s)); + s->in_fd = -1; + s->out_fd = -1; + char *type = NULL; + if (ds4_acp_object_get_string(json, "type", &type) && strcmp(type, "stdio")) { + mcp_set_err(err, err_len, "unsupported MCP transport: %s", type); + free(type); + return false; + } + free(type); + if (!ds4_acp_object_get_string(json, "name", &s->name) || + !ds4_acp_object_get_string(json, "command", &s->command)) + { + mcp_set_err(err, err_len, "MCP stdio server requires name and command"); + return false; + } + char *args = NULL; + if (!ds4_acp_object_get_raw(json, "args", &args)) { + mcp_set_err(err, err_len, "MCP stdio server requires args"); + return false; + } + if (!mcp_parse_string_array(args, &s->args, &s->argc, err, err_len)) { + free(args); + return false; + } + free(args); + char *env = NULL; + if (ds4_acp_object_get_raw(json, "env", &env)) { + bool ok = mcp_parse_env_array(env, s, err, err_len); + free(env); + if (!ok) return false; + } + return true; +} + +static bool mcp_start_server(ds4_mcp_server *s, char *err, size_t err_len) { + int to_child[2]; + int from_child[2]; + if (pipe(to_child) != 0) { + mcp_set_err(err, err_len, "pipe failed: %s", strerror(errno)); + return false; + } + if (pipe(from_child) != 0) { + mcp_set_err(err, err_len, "pipe failed: %s", strerror(errno)); + close(to_child[0]); + close(to_child[1]); + return false; + } + pid_t pid = fork(); + if (pid < 0) { + mcp_set_err(err, err_len, "fork failed: %s", strerror(errno)); + close(to_child[0]); + close(to_child[1]); + close(from_child[0]); + close(from_child[1]); + return false; + } + if (pid == 0) { + dup2(to_child[0], STDIN_FILENO); + dup2(from_child[1], STDOUT_FILENO); + close(to_child[0]); + close(to_child[1]); + close(from_child[0]); + close(from_child[1]); + for (int i = 0; i < s->envc; i++) + setenv(s->env_names[i], s->env_values[i], 1); + char **argv = mcp_xmalloc((size_t)(s->argc + 2) * sizeof(argv[0])); + argv[0] = s->command; + for (int i = 0; i < s->argc; i++) argv[i + 1] = s->args[i]; + argv[s->argc + 1] = NULL; + execvp(s->command, argv); + _exit(127); + } + close(to_child[0]); + close(from_child[1]); + s->pid = pid; + s->in_fd = to_child[1]; + s->out_fd = from_child[0]; + s->next_id = 1; + return true; +} + +static bool mcp_send_raw(ds4_mcp_server *s, const char *json, + char *err, size_t err_len) { + if (!mcp_write_all(s->in_fd, json, strlen(json)) || + !mcp_write_all(s->in_fd, "\n", 1)) + { + mcp_set_err(err, err_len, "write to MCP server %s failed: %s", + s->name, strerror(errno)); + return false; + } + return true; +} + +static bool mcp_request(ds4_mcp_server *s, const char *method, + const char *params_json, char **result_out, + ds4_mcp_cancel_fn cancel, void *cancel_privdata, + int timeout_ms, char *err, size_t err_len) { + int id = s->next_id++; + int64_t deadline_ms = timeout_ms > 0 ? mcp_now_ms() + timeout_ms : 0; + char idbuf[32]; + snprintf(idbuf, sizeof(idbuf), "%d", id); + char *qmethod = ds4_acp_json_escape(method, strlen(method)); + mcp_buf req = {0}; + mcp_buf_puts(&req, "{\"jsonrpc\":\"2.0\",\"id\":"); + mcp_buf_puts(&req, idbuf); + mcp_buf_puts(&req, ",\"method\":"); + mcp_buf_puts(&req, qmethod); + if (params_json) { + mcp_buf_puts(&req, ",\"params\":"); + mcp_buf_puts(&req, params_json); + } + mcp_buf_puts(&req, "}"); + char *wire = mcp_buf_take(&req); + bool ok = mcp_send_raw(s, wire, err, err_len); + free(wire); + free(qmethod); + if (!ok) return false; + + for (;;) { + char *line = NULL; + if (!mcp_read_line(s, &line, cancel, cancel_privdata, deadline_ms, + err, err_len)) + return false; + ds4_acp_message msg; + char parse_err[160] = {0}; + ds4_acp_parse_result parsed = + ds4_acp_parse_message(line, &msg, parse_err, sizeof(parse_err)); + free(line); + if (parsed != DS4_ACP_PARSE_OK) continue; + if (msg.has_method) { + if (msg.has_id) { + mcp_buf resp = {0}; + mcp_buf_puts(&resp, "{\"jsonrpc\":\"2.0\",\"id\":"); + mcp_buf_puts(&resp, msg.id_json); + mcp_buf_puts(&resp, + ",\"error\":{\"code\":-32601,\"message\":\"Method not found\"}}"); + char *r = mcp_buf_take(&resp); + (void)mcp_send_raw(s, r, NULL, 0); + free(r); + } + ds4_acp_message_free(&msg); + continue; + } + if (!msg.has_id || strcmp(msg.id_json, idbuf)) { + ds4_acp_message_free(&msg); + continue; + } + if (msg.has_error) { + mcp_set_err(err, err_len, "MCP server %s returned error: %s", + s->name, msg.error_json ? msg.error_json : "unknown"); + ds4_acp_message_free(&msg); + return false; + } + if (!msg.has_result) { + mcp_set_err(err, err_len, "MCP server %s returned no result", s->name); + ds4_acp_message_free(&msg); + return false; + } + *result_out = msg.result_json; + msg.result_json = NULL; + ds4_acp_message_free(&msg); + return true; + } +} + +static bool mcp_notify_initialized(ds4_mcp_server *s, char *err, size_t err_len) { + return mcp_send_raw(s, "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}", + err, err_len); +} + +static void mcp_sanitize_name(mcp_buf *b, const char *s) { + bool last_us = false; + for (; s && *s; s++) { + unsigned char c = (unsigned char)*s; + if (isalnum(c)) { + mcp_buf_append(b, (const char *)&c, 1); + last_us = false; + } else if (!last_us) { + mcp_buf_puts(b, "_"); + last_us = true; + } + } + if (b->len && b->ptr[b->len - 1] == '_') b->ptr[--b->len] = '\0'; +} + +static char *mcp_make_dsml_name(ds4_mcp *mcp, const char *server, const char *tool) { + mcp_buf b = {0}; + mcp_buf_puts(&b, "mcp__"); + mcp_sanitize_name(&b, server); + mcp_buf_puts(&b, "__"); + mcp_sanitize_name(&b, tool); + char *base = mcp_buf_take(&b); + if (!base[0] || !strcmp(base, "mcp____")) { + free(base); + base = mcp_xstrdup("mcp__tool"); + } + for (int suffix = 0;; suffix++) { + bool used = false; + char candidate[512]; + if (suffix == 0) snprintf(candidate, sizeof(candidate), "%s", base); + else snprintf(candidate, sizeof(candidate), "%s_%d", base, suffix + 1); + for (int i = 0; i < mcp->tools_len; i++) { + if (!strcmp(mcp->tools[i].dsml_name, candidate)) { + used = true; + break; + } + } + if (!used) { + free(base); + return mcp_xstrdup(candidate); + } + } +} + +static void mcp_tools_push(ds4_mcp *mcp, ds4_mcp_tool *tool) { + if (mcp->tools_len == mcp->tools_cap) { + mcp->tools_cap = mcp->tools_cap ? mcp->tools_cap * 2 : 8; + mcp->tools = mcp_xrealloc(mcp->tools, + (size_t)mcp->tools_cap * sizeof(mcp->tools[0])); + } + mcp->tools[mcp->tools_len++] = *tool; + memset(tool, 0, sizeof(*tool)); +} + +static bool mcp_parse_tools(ds4_mcp *mcp, int server_index, + const char *result_json, char **next_cursor, + char *err, size_t err_len) { + *next_cursor = NULL; + char *tools = NULL; + if (!ds4_acp_object_get_raw(result_json, "tools", &tools)) { + mcp_set_err(err, err_len, "tools/list result missing tools"); + return false; + } + const char *p = tools; + ds4_acp_json_ws(&p); + if (*p != '[') { + free(tools); + mcp_set_err(err, err_len, "tools must be an array"); + return false; + } + p++; + for (;;) { + char *raw = NULL; + if (!mcp_json_array_next(&p, &raw, err, err_len)) { + free(tools); + return false; + } + if (!raw) break; + ds4_mcp_tool t = {0}; + t.server_index = server_index; + bool ok = ds4_acp_object_get_string(raw, "name", &t.name); + (void)ds4_acp_object_get_string(raw, "title", &t.title); + (void)ds4_acp_object_get_string(raw, "description", &t.description); + if (!ds4_acp_object_get_raw(raw, "inputSchema", &t.input_schema)) + t.input_schema = mcp_xstrdup("{\"type\":\"object\",\"properties\":{}}"); + if (ok) { + t.dsml_name = mcp_make_dsml_name(mcp, mcp->servers[server_index].name, + t.name); + mcp_tools_push(mcp, &t); + } else { + mcp_tool_free(&t); + } + free(raw); + } + free(tools); + (void)ds4_acp_object_get_string(result_json, "nextCursor", next_cursor); + return true; +} + +static bool mcp_initialize_server(ds4_mcp *mcp, int index, + ds4_mcp_cancel_fn cancel, + void *cancel_privdata, + char *err, size_t err_len) { + ds4_mcp_server *s = &mcp->servers[index]; + char *result = NULL; + bool ok = mcp_request(s, "initialize", + "{\"protocolVersion\":\"2025-06-18\"," + "\"capabilities\":{}," + "\"clientInfo\":{\"name\":\"ds4-agent\",\"version\":\"0\"}}", + &result, cancel, cancel_privdata, mcp->connect_timeout_ms, + err, err_len); + free(result); + if (!ok) return false; + if (!mcp_notify_initialized(s, err, err_len)) return false; + char *cursor = NULL; + do { + mcp_buf params = {0}; + if (cursor) { + char *qcursor = ds4_acp_json_escape(cursor, strlen(cursor)); + mcp_buf_puts(¶ms, "{\"cursor\":"); + mcp_buf_puts(¶ms, qcursor); + mcp_buf_puts(¶ms, "}"); + free(qcursor); + } else { + mcp_buf_puts(¶ms, "{}"); + } + char *params_json = mcp_buf_take(¶ms); + result = NULL; + ok = mcp_request(s, "tools/list", params_json, &result, cancel, + cancel_privdata, mcp->connect_timeout_ms, + err, err_len); + free(params_json); + if (!ok) { + free(cursor); + return false; + } + free(cursor); + cursor = NULL; + ok = mcp_parse_tools(mcp, index, result, &cursor, err, err_len); + free(result); + if (!ok) { + free(cursor); + return false; + } + } while (cursor); + free(cursor); + return ok; +} + +bool ds4_mcp_connect_json(ds4_mcp *mcp, const char *servers_json, + ds4_mcp_cancel_fn cancel, void *cancel_privdata, + char *err, size_t err_len) { + int connect_timeout_ms = + mcp_timeout_default(mcp->connect_timeout_ms, DS4_MCP_CONNECT_TIMEOUT_MS); + int call_timeout_ms = + mcp_timeout_default(mcp->call_timeout_ms, DS4_MCP_CALL_TIMEOUT_MS); + ds4_mcp_close(mcp); + ds4_mcp_init(mcp); + mcp->connect_timeout_ms = connect_timeout_ms; + mcp->call_timeout_ms = call_timeout_ms; + if (!servers_json) return true; + const char *p = servers_json; + ds4_acp_json_ws(&p); + if (*p != '[') { + mcp_set_err(err, err_len, "mcpServers must be an array"); + return false; + } + p++; + for (;;) { + char *raw = NULL; + if (!mcp_json_array_next(&p, &raw, err, err_len)) return false; + if (!raw) break; + ds4_mcp_server s; + bool ok = mcp_parse_server(raw, &s, err, err_len); + free(raw); + if (!ok) { + mcp_server_free(&s); + return false; + } + if (!mcp_start_server(&s, err, err_len)) { + mcp_server_free(&s); + return false; + } + mcp_servers_push(mcp, &s); + } + if (!mcp_json_array_done(p)) { + mcp_set_err(err, err_len, "trailing data after mcpServers"); + return false; + } + for (int i = 0; i < mcp->servers_len; i++) { + if (!mcp_initialize_server(mcp, i, cancel, cancel_privdata, + err, err_len)) + return false; + } + return true; +} + +char *ds4_mcp_tools_prompt(const ds4_mcp *mcp) { + if (!mcp || !mcp->tools_len) return mcp_xstrdup(""); + mcp_buf b = {0}; + mcp_buf_puts(&b, + "\n\n### MCP Tools\n\n" + "The following tools are provided by MCP stdio servers. Invoke them by " + "using their DSML name exactly as listed.\n\n"); + for (int i = 0; i < mcp->tools_len; i++) { + const ds4_mcp_tool *t = &mcp->tools[i]; + const ds4_mcp_server *s = &mcp->servers[t->server_index]; + char *qname = ds4_acp_json_escape(t->dsml_name, strlen(t->dsml_name)); + char *qdesc = ds4_acp_json_escape(t->description ? t->description : "", + strlen(t->description ? t->description : "")); + mcp_buf_puts(&b, "{\n \"type\": \"function\",\n \"function\": {\n"); + mcp_buf_puts(&b, " \"name\": "); + mcp_buf_puts(&b, qname); + mcp_buf_puts(&b, ",\n \"description\": "); + if (t->description && t->description[0]) { + mcp_buf_puts(&b, qdesc); + } else { + char desc[512]; + snprintf(desc, sizeof(desc), "MCP tool %s from server %s.", + t->name, s->name); + char *qd = ds4_acp_json_escape(desc, strlen(desc)); + mcp_buf_puts(&b, qd); + free(qd); + } + mcp_buf_puts(&b, ",\n \"parameters\": "); + mcp_buf_puts(&b, t->input_schema ? t->input_schema : + "{\"type\":\"object\",\"properties\":{}}"); + mcp_buf_puts(&b, "\n }\n}\n\n"); + free(qname); + free(qdesc); + } + return mcp_buf_take(&b); +} + +const ds4_mcp_tool *ds4_mcp_find_tool(const ds4_mcp *mcp, const char *dsml_name) { + if (!mcp || !dsml_name) return NULL; + for (int i = 0; i < mcp->tools_len; i++) { + if (!strcmp(mcp->tools[i].dsml_name, dsml_name)) return &mcp->tools[i]; + } + return NULL; +} + +static bool mcp_json_bool(const char *json, bool *out) { + const char *p = json; + ds4_acp_json_ws(&p); + if (!strncmp(p, "true", 4)) { + p += 4; + ds4_acp_json_ws(&p); + if (*p) return false; + *out = true; + return true; + } + if (!strncmp(p, "false", 5)) { + p += 5; + ds4_acp_json_ws(&p); + if (*p) return false; + *out = false; + return true; + } + return false; +} + +static void mcp_append_content_text(mcp_buf *b, const char *block) { + char *type = NULL; + if (!ds4_acp_object_get_string(block, "type", &type)) return; + if (!strcmp(type, "text")) { + char *text = NULL; + if (ds4_acp_object_get_string(block, "text", &text)) { + if (b->len) mcp_buf_puts(b, "\n"); + mcp_buf_puts(b, text); + } + free(text); + } else if (!strcmp(type, "resource_link")) { + char *uri = NULL; + char *name = NULL; + (void)ds4_acp_object_get_string(block, "uri", &uri); + (void)ds4_acp_object_get_string(block, "name", &name); + if (uri) { + if (b->len) mcp_buf_puts(b, "\n"); + mcp_buf_puts(b, "[resource_link"); + if (name && name[0]) { + mcp_buf_puts(b, " "); + mcp_buf_puts(b, name); + } + mcp_buf_puts(b, ": "); + mcp_buf_puts(b, uri); + mcp_buf_puts(b, "]"); + } + free(uri); + free(name); + } else if (!strcmp(type, "resource")) { + char *res = NULL; + if (ds4_acp_object_get_raw(block, "resource", &res)) { + char *uri = NULL; + char *text = NULL; + (void)ds4_acp_object_get_string(res, "uri", &uri); + if (ds4_acp_object_get_string(res, "text", &text)) { + if (b->len) mcp_buf_puts(b, "\n"); + mcp_buf_puts(b, "[resource"); + if (uri && uri[0]) { + mcp_buf_puts(b, " "); + mcp_buf_puts(b, uri); + } + mcp_buf_puts(b, "]\n"); + mcp_buf_puts(b, text); + } + free(uri); + free(text); + } + free(res); + } else if (!strcmp(type, "image") || !strcmp(type, "audio")) { + if (b->len) mcp_buf_puts(b, "\n"); + mcp_buf_puts(b, "[unsupported MCP "); + mcp_buf_puts(b, type); + mcp_buf_puts(b, " content]"); + } + free(type); +} + +static char *mcp_result_text(const char *result_json, bool *is_error) { + *is_error = false; + char *raw = NULL; + if (ds4_acp_object_get_raw(result_json, "isError", &raw)) { + (void)mcp_json_bool(raw, is_error); + free(raw); + } + mcp_buf b = {0}; + char *content = NULL; + if (ds4_acp_object_get_raw(result_json, "content", &content)) { + const char *p = content; + ds4_acp_json_ws(&p); + if (*p == '[') { + p++; + for (;;) { + char *block = NULL; + char err[80]; + if (!mcp_json_array_next(&p, &block, err, sizeof(err))) break; + if (!block) break; + mcp_append_content_text(&b, block); + free(block); + } + } + free(content); + } + if (!b.len && ds4_acp_object_get_raw(result_json, "structuredContent", &raw)) { + mcp_buf_puts(&b, raw); + free(raw); + } + if (!b.len) mcp_buf_puts(&b, "(no MCP tool content)"); + if (!b.ptr || b.ptr[b.len - 1] != '\n') mcp_buf_puts(&b, "\n"); + return mcp_buf_take(&b); +} + +char *ds4_mcp_call_tool(ds4_mcp *mcp, const char *dsml_name, + const char *arguments_json, + ds4_mcp_cancel_fn cancel, void *cancel_privdata, + char *err, size_t err_len) { + const ds4_mcp_tool *t = ds4_mcp_find_tool(mcp, dsml_name); + if (!t) { + mcp_set_err(err, err_len, "unknown MCP tool: %s", dsml_name); + return NULL; + } + ds4_mcp_server *s = &mcp->servers[t->server_index]; + char *qname = ds4_acp_json_escape(t->name, strlen(t->name)); + mcp_buf params = {0}; + mcp_buf_puts(¶ms, "{\"name\":"); + mcp_buf_puts(¶ms, qname); + mcp_buf_puts(¶ms, ",\"arguments\":"); + mcp_buf_puts(¶ms, arguments_json ? arguments_json : "{}"); + mcp_buf_puts(¶ms, "}"); + char *params_json = mcp_buf_take(¶ms); + char *result = NULL; + bool ok = mcp_request(s, "tools/call", params_json, &result, cancel, + cancel_privdata, + mcp_timeout_default(mcp->call_timeout_ms, + DS4_MCP_CALL_TIMEOUT_MS), + err, err_len); + free(params_json); + free(qname); + if (!ok) return NULL; + bool is_error = false; + char *text = mcp_result_text(result, &is_error); + free(result); + if (is_error) { + mcp_buf b = {0}; + mcp_buf_puts(&b, "Tool error: MCP tool "); + mcp_buf_puts(&b, dsml_name); + mcp_buf_puts(&b, " returned an error\n"); + mcp_buf_puts(&b, text); + free(text); + return mcp_buf_take(&b); + } + return text; +} diff --git a/ds4_mcp.h b/ds4_mcp.h new file mode 100644 index 000000000..7492710d3 --- /dev/null +++ b/ds4_mcp.h @@ -0,0 +1,56 @@ +#ifndef DS4_MCP_H +#define DS4_MCP_H + +#include +#include +#include + +typedef bool (*ds4_mcp_cancel_fn)(void *privdata); + +typedef struct { + char *name; + char *title; + char *description; + char *input_schema; + char *dsml_name; + int server_index; +} ds4_mcp_tool; + +typedef struct { + char *name; + char *command; + char **args; + int argc; + char **env_names; + char **env_values; + int envc; + pid_t pid; + int in_fd; + int out_fd; + int next_id; +} ds4_mcp_server; + +typedef struct { + ds4_mcp_server *servers; + int servers_len; + int servers_cap; + ds4_mcp_tool *tools; + int tools_len; + int tools_cap; + int connect_timeout_ms; + int call_timeout_ms; +} ds4_mcp; + +void ds4_mcp_init(ds4_mcp *mcp); +void ds4_mcp_close(ds4_mcp *mcp); +bool ds4_mcp_connect_json(ds4_mcp *mcp, const char *servers_json, + ds4_mcp_cancel_fn cancel, void *cancel_privdata, + char *err, size_t err_len); +char *ds4_mcp_tools_prompt(const ds4_mcp *mcp); +const ds4_mcp_tool *ds4_mcp_find_tool(const ds4_mcp *mcp, const char *dsml_name); +char *ds4_mcp_call_tool(ds4_mcp *mcp, const char *dsml_name, + const char *arguments_json, + ds4_mcp_cancel_fn cancel, void *cancel_privdata, + char *err, size_t err_len); + +#endif diff --git a/tests/ds4_test.c b/tests/ds4_test.c index 35af6bafe..9b14677b4 100644 --- a/tests/ds4_test.c +++ b/tests/ds4_test.c @@ -1,6 +1,13 @@ #define DS4_SERVER_TEST #define DS4_SERVER_TEST_NO_MAIN #include "../ds4_server.c" +#include "../ds4_acp.h" +#include "../ds4_mcp.h" +#include +#include +#include +#include +#include #ifndef DS4_NO_GPU #include "../ds4_gpu.h" #include @@ -1801,6 +1808,438 @@ static void test_server_unit_group(void) { ds4_server_unit_tests_run(); } +static void test_acp_jsonrpc_parser(void) { + ds4_acp_request r; + ds4_acp_message m; + char err[128]; + TEST_ASSERT(ds4_acp_parse_request( + "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"initialize\",\"params\":{\"protocolVersion\":1}}", + &r, err, sizeof(err)) == DS4_ACP_PARSE_OK); + TEST_ASSERT(r.has_id); + TEST_ASSERT(!strcmp(r.id_json, "7")); + TEST_ASSERT(!strcmp(r.method, "initialize")); + TEST_ASSERT(r.has_params); + ds4_acp_request_free(&r); + + TEST_ASSERT(ds4_acp_parse_request( + "{\"jsonrpc\":\"2.0\",\"id\":\"abc\",\"method\":\"session/new\",\"params\":{\"cwd\":\"/tmp\"}}", + &r, err, sizeof(err)) == DS4_ACP_PARSE_OK); + TEST_ASSERT(!strcmp(r.id_json, "\"abc\"")); + char *cwd = NULL; + TEST_ASSERT(ds4_acp_object_get_string(r.params_json, "cwd", &cwd)); + TEST_ASSERT(!strcmp(cwd, "/tmp")); + free(cwd); + ds4_acp_request_free(&r); + + TEST_ASSERT(ds4_acp_parse_request("{bad", &r, err, sizeof(err)) == + DS4_ACP_PARSE_JSON); + TEST_ASSERT(ds4_acp_parse_request("{\"jsonrpc\":\"2.0\",\"id\":1}", &r, + err, sizeof(err)) == + DS4_ACP_PARSE_REQUEST); + + TEST_ASSERT(ds4_acp_parse_message( + "{\"jsonrpc\":\"2.0\",\"id\":11,\"result\":{\"outcome\":{\"outcome\":\"selected\",\"optionId\":\"allow-once\"}}}", + &m, err, sizeof(err)) == DS4_ACP_PARSE_OK); + TEST_ASSERT(m.has_id); + TEST_ASSERT(!strcmp(m.id_json, "11")); + TEST_ASSERT(!m.has_method); + TEST_ASSERT(m.has_result); + TEST_ASSERT(!m.has_error); + char *outcome = NULL; + TEST_ASSERT(ds4_acp_object_get_raw(m.result_json, "outcome", &outcome)); + free(outcome); + ds4_acp_message_free(&m); + + TEST_ASSERT(ds4_acp_parse_message( + "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"s\"}}", + &m, err, sizeof(err)) == DS4_ACP_PARSE_OK); + TEST_ASSERT(!m.has_id); + TEST_ASSERT(m.has_method); + TEST_ASSERT(!strcmp(m.method, "session/update")); + ds4_acp_message_free(&m); + + TEST_ASSERT(ds4_acp_parse_message( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{},\"error\":{\"code\":-1,\"message\":\"bad\"}}", + &m, err, sizeof(err)) == DS4_ACP_PARSE_REQUEST); +} + +static void test_acp_json_strings(void) { + const char *p = "\"a\\n\\\"b\\u0021\""; + char *s = NULL; + TEST_ASSERT(ds4_acp_json_string(&p, &s)); + TEST_ASSERT(!strcmp(s, "a\n\"b!")); + free(s); + + p = "\"\\ud83d\\ude00\""; + s = NULL; + TEST_ASSERT(ds4_acp_json_string(&p, &s)); + TEST_ASSERT(!strcmp(s, "\xf0\x9f\x98\x80")); + free(s); + + p = "\"\\ud800\""; + s = NULL; + TEST_ASSERT(!ds4_acp_json_string(&p, &s)); + p = "\"\\ud800\\u0041\""; + s = NULL; + TEST_ASSERT(!ds4_acp_json_string(&p, &s)); + p = "\"\\udc00\""; + s = NULL; + TEST_ASSERT(!ds4_acp_json_string(&p, &s)); + const char bad_raw_utf8[] = {'"', (char)0xff, '"', 0}; + p = bad_raw_utf8; + s = NULL; + TEST_ASSERT(!ds4_acp_json_string(&p, &s)); + const char bad_overlong_utf8[] = {'"', (char)0xc0, (char)0x80, '"', 0}; + p = bad_overlong_utf8; + s = NULL; + TEST_ASSERT(!ds4_acp_json_string(&p, &s)); + + char *q = ds4_acp_json_escape("a\n\"b\\", strlen("a\n\"b\\")); + TEST_ASSERT(!strcmp(q, "\"a\\n\\\"b\\\\\"")); + free(q); + + const char bad_utf8[] = {(char)0xff, 0}; + q = ds4_acp_json_escape(bad_utf8, 1); + TEST_ASSERT(!strcmp(q, "\"\\u00ff\"")); + free(q); +} + +static char test_self_path[PATH_MAX]; + +static void test_fake_mcp_response(const ds4_acp_message *m, + const char *result_json) { + if (!m->has_id) return; + printf("{\"jsonrpc\":\"2.0\",\"id\":%s,\"result\":%s}\n", + m->id_json, result_json); + fflush(stdout); +} + +static void test_fake_mcp_error(const ds4_acp_message *m, int code, + const char *message) { + if (!m->has_id) return; + char *qmsg = ds4_acp_json_escape(message, strlen(message)); + printf("{\"jsonrpc\":\"2.0\",\"id\":%s,\"error\":{\"code\":%d,\"message\":%s}}\n", + m->id_json, code, qmsg); + fflush(stdout); + free(qmsg); +} + +static int test_fake_mcp_server(void) { + char line[8192]; + while (fgets(line, sizeof(line), stdin)) { + ds4_acp_message m; + char err[160] = {0}; + if (ds4_acp_parse_message(line, &m, err, sizeof(err)) != DS4_ACP_PARSE_OK) + continue; + if (!m.has_method) { + ds4_acp_message_free(&m); + continue; + } + if (!strcmp(m.method, "initialize")) { + test_fake_mcp_response(&m, + "{\"protocolVersion\":\"2025-06-18\"," + "\"capabilities\":{\"tools\":{}}," + "\"serverInfo\":{\"name\":\"fake-mcp\",\"version\":\"1\"}}"); + } else if (!strcmp(m.method, "notifications/initialized")) { + /* no response */ + } else if (!strcmp(m.method, "tools/list")) { + char *cursor = NULL; + bool second = m.has_params && + ds4_acp_object_get_string(m.params_json, "cursor", &cursor) && + !strcmp(cursor, "p2"); + free(cursor); + if (second) { + test_fake_mcp_response(&m, + "{\"tools\":[{\"name\":\"fail\",\"description\":\"Fail\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}]}"); + } else { + test_fake_mcp_response(&m, + "{\"tools\":[{\"name\":\"echo\",\"description\":\"Echo\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{" + "\"text\":{\"type\":\"string\"}}}}],\"nextCursor\":\"p2\"}"); + } + } else if (!strcmp(m.method, "tools/call")) { + char *name = NULL; + if (m.has_params) + (void)ds4_acp_object_get_string(m.params_json, "name", &name); + if (name && !strcmp(name, "echo")) { + test_fake_mcp_response(&m, + "{\"content\":[{\"type\":\"text\",\"text\":\"echo ok\"}]}"); + } else if (name && !strcmp(name, "fail")) { + test_fake_mcp_response(&m, + "{\"isError\":true,\"content\":[{\"type\":\"text\"," + "\"text\":\"fixture failure\"}]}"); + } else { + test_fake_mcp_error(&m, -32602, "unknown tool"); + } + free(name); + } else { + test_fake_mcp_error(&m, -32601, "method not found"); + } + ds4_acp_message_free(&m); + } + return 0; +} + +static int test_fake_mcp_hang(void) { + char line[8192]; + while (fgets(line, sizeof(line), stdin)) { + /* Keep stdout open without answering. */ + } + return 0; +} + +static int test_fake_mcp_close_stdin(void) { + char line[8192]; + while (fgets(line, sizeof(line), stdin)) { + ds4_acp_message m; + char err[160] = {0}; + if (ds4_acp_parse_message(line, &m, err, sizeof(err)) != DS4_ACP_PARSE_OK) + continue; + if (!m.has_method) { + ds4_acp_message_free(&m); + continue; + } + if (!strcmp(m.method, "initialize")) { + test_fake_mcp_response(&m, + "{\"protocolVersion\":\"2025-06-18\"," + "\"capabilities\":{\"tools\":{}}," + "\"serverInfo\":{\"name\":\"close-stdin\",\"version\":\"1\"}}"); + } else if (!strcmp(m.method, "notifications/initialized")) { + /* no response */ + } else if (!strcmp(m.method, "tools/list")) { + test_fake_mcp_response(&m, + "{\"tools\":[{\"name\":\"echo\",\"description\":\"Echo\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}]}"); + ds4_acp_message_free(&m); + close(STDIN_FILENO); + sleep(2); + return 0; + } else { + test_fake_mcp_error(&m, -32601, "method not found"); + } + ds4_acp_message_free(&m); + } + return 0; +} + +static char *test_mcp_config(const char *name, const char *command, + const char *arg) { + char *qname = ds4_acp_json_escape(name, strlen(name)); + char *qcmd = ds4_acp_json_escape(command, strlen(command)); + char *qarg = ds4_acp_json_escape(arg, strlen(arg)); + size_t len = strlen(qname) + strlen(qcmd) + strlen(qarg) + 128; + char *cfg = malloc(len); + TEST_ASSERT(cfg != NULL); + if (!cfg) { + free(qname); + free(qcmd); + free(qarg); + return NULL; + } + snprintf(cfg, len, + "[{\"name\":%s,\"command\":%s,\"args\":[%s],\"env\":[]}]", + qname, qcmd, qarg); + free(qname); + free(qcmd); + free(qarg); + return cfg; +} + +static char *test_mcp_fixture_config(void) { + return test_mcp_config("fixture", test_self_path, "--fake-mcp-server"); +} + +static void test_acp_mcp_path_command(void) { + char tmp[] = "/tmp/ds4-mcp-path-XXXXXX"; + char *dir = mkdtemp(tmp); + TEST_ASSERT(dir != NULL); + if (!dir) return; + + char command_path[PATH_MAX]; + int n = snprintf(command_path, sizeof(command_path), + "%s/ds4-mcp-fixture", dir); + TEST_ASSERT(n > 0 && (size_t)n < sizeof(command_path)); + if (n <= 0 || (size_t)n >= sizeof(command_path)) { + rmdir(dir); + return; + } + int symlink_rc = symlink(test_self_path, command_path); + TEST_ASSERT(symlink_rc == 0); + if (symlink_rc != 0) { + rmdir(dir); + return; + } + + char *old_path = test_save_env("PATH"); + const char *path = getenv("PATH"); + size_t path_len = strlen(dir) + 1 + strlen(path ? path : "") + 1; + char *new_path = malloc(path_len); + TEST_ASSERT(new_path != NULL); + if (!new_path) { + test_restore_env("PATH", old_path); + unlink(command_path); + rmdir(dir); + return; + } + snprintf(new_path, path_len, "%s:%s", dir, path ? path : ""); + setenv("PATH", new_path, 1); + free(new_path); + + ds4_mcp mcp; + char err[160] = {0}; + ds4_mcp_init(&mcp); + char *cfg = test_mcp_config("pathfixture", "ds4-mcp-fixture", + "--fake-mcp-server"); + TEST_ASSERT(cfg != NULL); + if (cfg) { + TEST_ASSERT(ds4_mcp_connect_json(&mcp, cfg, NULL, NULL, + err, sizeof(err))); + char *prompt = ds4_mcp_tools_prompt(&mcp); + TEST_ASSERT(strstr(prompt, "mcp__pathfixture__echo") != NULL); + free(prompt); + free(cfg); + } + ds4_mcp_close(&mcp); + + test_restore_env("PATH", old_path); + unlink(command_path); + rmdir(dir); +} + +static void test_acp_mcp_timeout(void) { + ds4_mcp mcp; + char err[160] = {0}; + ds4_mcp_init(&mcp); + mcp.connect_timeout_ms = 100; + char *cfg = test_mcp_config("hang", test_self_path, "--fake-mcp-hang"); + TEST_ASSERT(cfg != NULL); + if (cfg) { + TEST_ASSERT(!ds4_mcp_connect_json(&mcp, cfg, NULL, NULL, + err, sizeof(err))); + TEST_ASSERT(strstr(err, "timed out") != NULL); + free(cfg); + } + ds4_mcp_close(&mcp); +} + +static int test_acp_mcp_sigpipe_child(void) { + struct sigaction dfl_sa, got_sa; + memset(&dfl_sa, 0, sizeof(dfl_sa)); + sigemptyset(&dfl_sa.sa_mask); + dfl_sa.sa_handler = SIG_DFL; + if (sigaction(SIGPIPE, &dfl_sa, NULL) != 0) return 1; + + ds4_mcp mcp; + char err[160] = {0}; + ds4_mcp_init(&mcp); + if (sigaction(SIGPIPE, NULL, &got_sa) != 0 || + got_sa.sa_handler != SIG_DFL) + return 1; + + char *cfg = test_mcp_config("closed", test_self_path, + "--fake-mcp-close-stdin"); + if (!cfg) return 1; + bool connected = ds4_mcp_connect_json(&mcp, cfg, NULL, NULL, + err, sizeof(err)); + free(cfg); + if (!connected) { + ds4_mcp_close(&mcp); + return 1; + } + if (sigaction(SIGPIPE, NULL, &got_sa) != 0 || + got_sa.sa_handler != SIG_DFL) + { + ds4_mcp_close(&mcp); + return 1; + } + + err[0] = '\0'; + char *result = ds4_mcp_call_tool(&mcp, "mcp__closed__echo", + "{}", NULL, NULL, + err, sizeof(err)); + bool ok = result == NULL && err[0] != '\0'; + free(result); + ds4_mcp_close(&mcp); + return ok ? 0 : 1; +} + +static void test_acp_mcp_sigpipe(void) { + pid_t pid = fork(); + TEST_ASSERT(pid >= 0); + if (pid < 0) return; + if (pid == 0) _exit(test_acp_mcp_sigpipe_child()); + + int status = 0; + while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {} + TEST_ASSERT(WIFEXITED(status)); + TEST_ASSERT(WIFEXITED(status) && WEXITSTATUS(status) == 0); +} + +static void test_acp_mcp_config(void) { + ds4_mcp mcp; + char err[160]; + ds4_mcp_init(&mcp); + TEST_ASSERT(ds4_mcp_connect_json(&mcp, "[]", NULL, NULL, + err, sizeof(err))); + char *prompt = ds4_mcp_tools_prompt(&mcp); + TEST_ASSERT(prompt != NULL); + TEST_ASSERT(prompt[0] == '\0'); + free(prompt); + ds4_mcp_close(&mcp); + + ds4_mcp_init(&mcp); + TEST_ASSERT(!ds4_mcp_connect_json(&mcp, + "[{\"type\":\"http\",\"name\":\"api\",\"url\":\"https://example.com/mcp\",\"headers\":[]}]", + NULL, NULL, err, sizeof(err))); + TEST_ASSERT(strstr(err, "unsupported MCP transport") != NULL); + ds4_mcp_close(&mcp); + + ds4_mcp_init(&mcp); + TEST_ASSERT(!ds4_mcp_connect_json(&mcp, + "[{\"name\":\"toolbox\",\"command\":\"/bin/echo\"}]", + NULL, NULL, err, sizeof(err))); + TEST_ASSERT(strstr(err, "requires args") != NULL); + ds4_mcp_close(&mcp); + + ds4_mcp_init(&mcp); + char *cfg = test_mcp_fixture_config(); + TEST_ASSERT(cfg != NULL); + if (!ds4_mcp_connect_json(&mcp, cfg, NULL, NULL, err, sizeof(err))) + fprintf(stderr, "MCP fixture connect failed: %s config=%s\n", err, cfg); + else + err[0] = '\0'; + TEST_ASSERT(err[0] == '\0'); + free(cfg); + prompt = ds4_mcp_tools_prompt(&mcp); + TEST_ASSERT(strstr(prompt, "mcp__fixture__echo") != NULL); + TEST_ASSERT(strstr(prompt, "mcp__fixture__fail") != NULL); + free(prompt); + char *result = ds4_mcp_call_tool(&mcp, "mcp__fixture__echo", + "{\"text\":\"hello\"}", NULL, NULL, + err, sizeof(err)); + TEST_ASSERT(result != NULL); + TEST_ASSERT(strstr(result, "echo ok") != NULL); + free(result); + result = ds4_mcp_call_tool(&mcp, "mcp__fixture__fail", + "{}", NULL, NULL, err, sizeof(err)); + TEST_ASSERT(result != NULL); + TEST_ASSERT(strstr(result, "Tool error: MCP tool") != NULL); + TEST_ASSERT(strstr(result, "fixture failure") != NULL); + free(result); + ds4_mcp_close(&mcp); + + test_acp_mcp_path_command(); + test_acp_mcp_timeout(); + test_acp_mcp_sigpipe(); +} + +static void test_acp_unit_group(void) { + test_acp_jsonrpc_parser(); + test_acp_json_strings(); + test_acp_mcp_config(); +} + typedef void (*test_fn)(void); typedef struct { @@ -1822,6 +2261,7 @@ static const ds4_test_entry test_entries[] = { {"--streaming-decode-prefill-correctness", "streaming-decode-prefill-correctness", "streaming decode-style cold prefill drift and repeatability", test_streaming_decode_prefill_correctness}, #endif {"--server", "server", "server parser/rendering/cache unit tests", test_server_unit_group}, + {"--acp", "acp", "ACP JSON-RPC parser and writer unit tests", test_acp_unit_group}, }; static void test_print_help(const char *prog) { @@ -1878,6 +2318,18 @@ static void test_run_entry(const ds4_test_entry *entry) { } int main(int argc, char **argv) { + if (argc == 2 && !strcmp(argv[1], "--fake-mcp-server")) + return test_fake_mcp_server(); + if (argc == 2 && !strcmp(argv[1], "--fake-mcp-hang")) + return test_fake_mcp_hang(); + if (argc == 2 && !strcmp(argv[1], "--fake-mcp-close-stdin")) + return test_fake_mcp_close_stdin(); + + if (!realpath(argv[0], test_self_path)) { + fprintf(stderr, "failed to resolve %s: %s\n", argv[0], strerror(errno)); + return 1; + } + bool run_all = argc == 1; bool selected[sizeof(test_entries) / sizeof(test_entries[0])] = {0};