Tools - Table-driven tool registry with permission classes and default/minimal profiles; new tools: edit_file, list_dir, glob, grep, delete_file, move_file, todo_write, web_fetch, ask_user (plus read_file paging). - Built-in Myers diff; write tools return unified diffs. - Persistent permission rules (.agent/permissions.json), per-turn undo snapshots (.agent/undo), AGENTS.md project notes, auto-compact at 80% of the model context, cancellable run_shell with process-group kill. TUI - Table-driven slash commands: /provider /key /effort /status /tools /permissions /yolo /undo /retry /diff /export /copy /rename /delete /init /max-steps /todo, with / and @ completion. - Permission modal with diff and prefix rules, ask_user modal, todo panel, header with model/effort/context, bracketed paste, line editing, wide-char cursor, NO_COLOR and ASCII fallback; Esc never quits. Startup and configuration - API keys are entered in the TUI (hidden) and saved per provider in <config>/credentials (0600); env variables are only a fallback. - Last provider, per-provider model and reasoning effort are remembered in <config>/settings.json; --effort/-e and /effort send reasoning_effort (low|medium|high|max) to models that accept it. - DeepSeek preset uses deepseek-flash / deepseek-v4-pro. - Session flags --new/--continue/--resume/--list-sessions, --yolo, --confirm. Compatibility and tests - ncurses detection, macOS/Homebrew support, make install, XDG config dir, POSIX cleanups; Gitea CI (gcc/clang/asan/musl/macOS). - Unit tests for diff, fs tools, registry, undo/perms and config; e2e fixtures multi_file_refactor and ask_or_todo; vendored cJSON.
296 lines
9.3 KiB
C
296 lines
9.3 KiB
C
#define _POSIX_C_SOURCE 200809L
|
|
|
|
#include "cJSON.h"
|
|
#include "ctl.h"
|
|
#include "cst_agent.h"
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <unistd.h>
|
|
|
|
/*
|
|
* Truncation experiment on recorded model streams (tests/fixtures/sse).
|
|
*
|
|
* For every recorded assistant message we rebuild the logical stream the
|
|
* runtime sees and cut it at every byte:
|
|
* - CTL traces: the content text; executable calls = CALL_READY events.
|
|
* - native traces: tool calls as a JSON array of {"name","arguments"};
|
|
* json-whole : execute only when the whole array parses (EOS baseline)
|
|
* json-object: execute each object once it parses (incremental JSON)
|
|
*
|
|
* Checked properties (the CST soundness claims):
|
|
* S1 no call is executable on a prefix unless its bytes are complete;
|
|
* S2 every call executed on a prefix is identical to the same call in the
|
|
* full stream (prefix consistency, i.e. execution is never revoked);
|
|
* S3 the number of executable calls is monotone in the prefix length.
|
|
* The printed table reports how early calls become executable (experiment C).
|
|
*/
|
|
|
|
static int nfail;
|
|
|
|
#define EXPECT(cond, msg) \
|
|
do { \
|
|
if (!(cond)) { \
|
|
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
|
|
nfail++; \
|
|
} \
|
|
} while (0)
|
|
|
|
typedef struct {
|
|
char *buf;
|
|
size_t len, cap;
|
|
} sbuf_t;
|
|
|
|
static void sb_add(sbuf_t *b, const char *s, size_t n) {
|
|
if (b->len + n + 1 > b->cap) {
|
|
size_t nc = b->cap ? b->cap * 2 : 1024;
|
|
while (nc < b->len + n + 1)
|
|
nc *= 2;
|
|
b->buf = realloc(b->buf, nc);
|
|
b->cap = nc;
|
|
}
|
|
memcpy(b->buf + b->len, s, n);
|
|
b->len += n;
|
|
b->buf[b->len] = 0;
|
|
}
|
|
|
|
#define MAXC 32
|
|
|
|
typedef struct {
|
|
sbuf_t content;
|
|
int ncalls;
|
|
char names[MAXC][64];
|
|
sbuf_t args[MAXC];
|
|
} trace_t;
|
|
|
|
static void trace_free(trace_t *t) {
|
|
free(t->content.buf);
|
|
for (int i = 0; i < MAXC; i++)
|
|
free(t->args[i].buf);
|
|
memset(t, 0, sizeof(*t));
|
|
}
|
|
|
|
static int load_trace(const char *path, trace_t *t) {
|
|
memset(t, 0, sizeof(*t));
|
|
FILE *fp = fopen(path, "rb");
|
|
if (!fp)
|
|
return -1;
|
|
char *line = NULL;
|
|
size_t cap = 0;
|
|
ssize_t n;
|
|
while ((n = getline(&line, &cap, fp)) > 0) {
|
|
if (strncmp(line, "data:", 5) != 0)
|
|
continue;
|
|
cJSON *j = cJSON_Parse(line + 5);
|
|
if (!j)
|
|
continue;
|
|
const cJSON *ch = cJSON_GetArrayItem(cJSON_GetObjectItem(j, "choices"), 0);
|
|
const cJSON *d = cJSON_GetObjectItem(ch, "delta");
|
|
const cJSON *c = cJSON_GetObjectItem(d, "content");
|
|
if (cJSON_IsString(c))
|
|
sb_add(&t->content, c->valuestring, strlen(c->valuestring));
|
|
const cJSON *tc = NULL;
|
|
cJSON_ArrayForEach(tc, cJSON_GetObjectItem(d, "tool_calls")) {
|
|
const cJSON *ji = cJSON_GetObjectItem(tc, "index");
|
|
int idx = cJSON_IsNumber(ji) ? ji->valueint : 0;
|
|
if (idx < 0 || idx >= MAXC)
|
|
continue;
|
|
if (idx >= t->ncalls)
|
|
t->ncalls = idx + 1;
|
|
const cJSON *fn = cJSON_GetObjectItem(tc, "function");
|
|
const cJSON *nm = cJSON_GetObjectItem(fn, "name");
|
|
const cJSON *ar = cJSON_GetObjectItem(fn, "arguments");
|
|
if (cJSON_IsString(nm) && nm->valuestring[0])
|
|
snprintf(t->names[idx], sizeof(t->names[idx]), "%s", nm->valuestring);
|
|
if (cJSON_IsString(ar))
|
|
sb_add(&t->args[idx], ar->valuestring, strlen(ar->valuestring));
|
|
}
|
|
cJSON_Delete(j);
|
|
}
|
|
free(line);
|
|
fclose(fp);
|
|
return 0;
|
|
}
|
|
|
|
/* --- CTL side ------------------------------------------------------------- */
|
|
|
|
typedef struct {
|
|
int n;
|
|
char sig[MAXC][512]; /* id|name|k=v... (truncated) as identity */
|
|
} ready_t;
|
|
|
|
static void on_ctl(const ctl_event_t *ev, void *ud) {
|
|
ready_t *r = ud;
|
|
if (ev->type != CTL_EV_CALL_READY || r->n >= MAXC)
|
|
return;
|
|
char *s = r->sig[r->n++];
|
|
int o = snprintf(s, 512, "%s|%s", ev->call->id, ev->call->name);
|
|
for (int i = 0; i < ev->call->nargs && o < 500; i++)
|
|
o += snprintf(s + o, (size_t)(512 - o), "|%s=%.*s", ev->call->args[i].key,
|
|
(int)(ev->call->args[i].value_len > 200
|
|
? 200
|
|
: ev->call->args[i].value_len),
|
|
ev->call->args[i].value);
|
|
}
|
|
|
|
static void ctl_ready(const char *s, size_t n, int finish, ready_t *r) {
|
|
memset(r, 0, sizeof(*r));
|
|
ctl_parser_t *p = ctl_parser_create(on_ctl, r);
|
|
ctl_parser_feed(p, s, n);
|
|
if (finish)
|
|
ctl_parser_finish(p);
|
|
ctl_parser_destroy(p);
|
|
}
|
|
|
|
/* --- JSON side ------------------------------------------------------------ */
|
|
|
|
static void build_json_stream(const trace_t *t, sbuf_t *out) {
|
|
sb_add(out, "[", 1);
|
|
for (int i = 0; i < t->ncalls; i++) {
|
|
char head[160];
|
|
int hn = snprintf(head, sizeof(head), "%s{\"name\":\"%s\",\"arguments\":",
|
|
i ? "," : "", t->names[i]);
|
|
sb_add(out, head, (size_t)hn);
|
|
if (t->args[i].len)
|
|
sb_add(out, t->args[i].buf, t->args[i].len);
|
|
else
|
|
sb_add(out, "{}", 2);
|
|
sb_add(out, "}", 1);
|
|
}
|
|
sb_add(out, "]", 1);
|
|
}
|
|
|
|
static int json_whole(const char *s, size_t n) {
|
|
cJSON *j = cJSON_ParseWithLength(s, n);
|
|
int k = cJSON_IsArray(j) ? cJSON_GetArraySize(j) : 0;
|
|
cJSON_Delete(j);
|
|
return k;
|
|
}
|
|
|
|
static int json_objects(const char *s, size_t n) {
|
|
int k = 0;
|
|
size_t off = 0;
|
|
while (off < n) {
|
|
cst_json_tool_t t;
|
|
const char *p = memchr(s + off, '{', n - off);
|
|
if (!p)
|
|
break;
|
|
if (!cst_json_try_parse_tool(p, n - (size_t)(p - s), &t))
|
|
break;
|
|
k++;
|
|
/* skip past this object */
|
|
const char *end = NULL;
|
|
cJSON *j = cJSON_ParseWithLengthOpts(p, n - (size_t)(p - s), &end, 0);
|
|
cJSON_Delete(j);
|
|
if (!end || end <= p)
|
|
break;
|
|
off = (size_t)(end - s);
|
|
}
|
|
return k;
|
|
}
|
|
|
|
/* --- experiment ----------------------------------------------------------- */
|
|
|
|
static void report_row(const char *label, size_t n, const int *at_pct,
|
|
int total) {
|
|
printf(" %-26s bytes=%-6zu calls=%d executable@25/50/75/90/100%%: "
|
|
"%d/%d/%d/%d/%d\n",
|
|
label, n, total, at_pct[0], at_pct[1], at_pct[2], at_pct[3],
|
|
at_pct[4]);
|
|
}
|
|
|
|
static const int PCTS[5] = {25, 50, 75, 90, 100};
|
|
|
|
static void run_ctl(const char *label, const sbuf_t *c) {
|
|
ready_t full;
|
|
ctl_ready(c->buf, c->len, 1, &full);
|
|
int prev = 0, at[5] = {0};
|
|
size_t first_ready_at = 0;
|
|
for (size_t cut = 0; cut <= c->len; cut++) {
|
|
ready_t r;
|
|
ctl_ready(c->buf, cut, cut == c->len, &r);
|
|
EXPECT(r.n >= prev, "S3 monotone");
|
|
EXPECT(r.n <= full.n, "S1 never more than the full stream");
|
|
for (int i = 0; i < r.n && i < full.n; i++)
|
|
EXPECT(strcmp(r.sig[i], full.sig[i]) == 0, "S2 prefix-consistent call");
|
|
if (r.n > 0 && prev == 0)
|
|
first_ready_at = cut;
|
|
prev = r.n;
|
|
for (int k = 0; k < 5; k++)
|
|
if (cut == c->len * (size_t)PCTS[k] / 100)
|
|
at[k] = r.n;
|
|
}
|
|
report_row(label, c->len, at, full.n);
|
|
if (full.n > 0)
|
|
printf(" %-26s first call executable at byte %zu (%.0f%% of message)\n",
|
|
"", first_ready_at, 100.0 * (double)first_ready_at / (double)c->len);
|
|
}
|
|
|
|
static void run_native(const char *label, const trace_t *t) {
|
|
sbuf_t js = {0};
|
|
build_json_stream(t, &js);
|
|
int whole_full = json_whole(js.buf, js.len);
|
|
int obj_full = json_objects(js.buf, js.len);
|
|
EXPECT(whole_full == t->ncalls, "json-whole parses at full length");
|
|
EXPECT(obj_full == t->ncalls, "json-object parses all at full length");
|
|
int at_w[5] = {0}, at_o[5] = {0}, prev_o = 0;
|
|
for (size_t cut = 0; cut <= js.len; cut++) {
|
|
int w = json_whole(js.buf, cut);
|
|
int o = json_objects(js.buf, cut);
|
|
if (cut < js.len)
|
|
EXPECT(w == 0, "json-whole executes nothing before the end");
|
|
EXPECT(o >= prev_o, "S3 monotone (json-object)");
|
|
prev_o = o;
|
|
for (int k = 0; k < 5; k++) {
|
|
if (cut == js.len * (size_t)PCTS[k] / 100) {
|
|
at_w[k] = w;
|
|
at_o[k] = o;
|
|
}
|
|
}
|
|
}
|
|
char l[80];
|
|
snprintf(l, sizeof(l), "%s json-whole", label);
|
|
report_row(l, js.len, at_w, whole_full);
|
|
snprintf(l, sizeof(l), "%s json-object", label);
|
|
report_row(l, js.len, at_o, obj_full);
|
|
free(js.buf);
|
|
}
|
|
|
|
int main(void) {
|
|
static const char *dirs[] = {"tests/fixtures/sse/deepseek-ctl-heredoc",
|
|
"tests/fixtures/sse/deepseek-ctl",
|
|
"tests/fixtures/sse/deepseek-native-eos"};
|
|
int traces = 0;
|
|
for (size_t d = 0; d < sizeof(dirs) / sizeof(dirs[0]); d++) {
|
|
printf("%s\n", dirs[d]);
|
|
for (int seq = 0; seq < 64; seq++) {
|
|
char path[512];
|
|
snprintf(path, sizeof(path), "%s/%04d.sse", dirs[d], seq);
|
|
if (access(path, R_OK) != 0)
|
|
break;
|
|
trace_t t;
|
|
if (load_trace(path, &t) != 0)
|
|
continue;
|
|
traces++;
|
|
char label[64];
|
|
snprintf(label, sizeof(label), "#%04d", seq);
|
|
if (t.ncalls > 0)
|
|
run_native(label, &t);
|
|
else if (t.content.len > 0 && strstr(t.content.buf, "call "))
|
|
run_ctl(label, &t.content);
|
|
else
|
|
printf(" %-26s (final answer, no calls)\n", label);
|
|
trace_free(&t);
|
|
}
|
|
}
|
|
if (traces == 0)
|
|
printf("(no recorded traces found; run from the repo root)\n");
|
|
if (nfail) {
|
|
fprintf(stderr, "test_replay_real: %d failed\n", nfail);
|
|
return 1;
|
|
}
|
|
printf("test_replay_real: OK\n");
|
|
return 0;
|
|
}
|