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.
555 lines
14 KiB
C
555 lines
14 KiB
C
#include "ctl.h"
|
|
|
|
#include <ctype.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
#define CTL_MAX_TAG 32
|
|
|
|
typedef enum {
|
|
ST_TEXT = 0, /* outside a call; accumulate text lines */
|
|
ST_LINE, /* collecting a line (call/arg/end or text) */
|
|
ST_ARG_BODY, /* reading nbytes of arg body */
|
|
ST_HEREDOC, /* reading heredoc lines until the tag line */
|
|
ST_ERROR
|
|
} ctl_state_t;
|
|
|
|
struct ctl_parser {
|
|
ctl_on_event_fn on_event;
|
|
void *userdata;
|
|
ctl_state_t state;
|
|
char line[CTL_MAX_LINE];
|
|
size_t line_len;
|
|
ctl_tool_call_t call;
|
|
int in_call;
|
|
/* current arg being filled */
|
|
char arg_key[CTL_MAX_KEY];
|
|
char *arg_buf;
|
|
size_t arg_cap;
|
|
size_t arg_got;
|
|
size_t arg_need;
|
|
char tag[CTL_MAX_TAG + 1];
|
|
size_t pending;
|
|
ctl_parser_stats_t st;
|
|
char errbuf[256];
|
|
/* last length-prefixed arg, for actionable contract-error messages */
|
|
char last_nkey[CTL_MAX_KEY];
|
|
size_t last_nbytes;
|
|
};
|
|
|
|
const char *ctl_error_kind_name(ctl_error_kind_t k) {
|
|
switch (k) {
|
|
case CTL_EK_NONE:
|
|
return "none";
|
|
case CTL_EK_BAD_CALL:
|
|
return "bad_call";
|
|
case CTL_EK_BAD_ARG:
|
|
return "bad_arg";
|
|
case CTL_EK_UNEXPECTED_LINE:
|
|
return "unexpected_line";
|
|
case CTL_EK_BODY_EOF:
|
|
return "body_eof";
|
|
case CTL_EK_HEREDOC_EOF:
|
|
return "heredoc_eof";
|
|
case CTL_EK_LINE_TOO_LONG:
|
|
return "line_too_long";
|
|
case CTL_EK_LIMIT:
|
|
return "limit";
|
|
case CTL_EK_OOM:
|
|
return "oom";
|
|
}
|
|
return "unknown";
|
|
}
|
|
|
|
static void bump_pending(ctl_parser_t *p, size_t n) {
|
|
p->pending = n;
|
|
if (n > p->st.pending_peak)
|
|
p->st.pending_peak = n;
|
|
}
|
|
|
|
static void emit(ctl_parser_t *p, ctl_event_t *ev) {
|
|
if (p->on_event)
|
|
p->on_event(ev, p->userdata);
|
|
}
|
|
|
|
static void fail(ctl_parser_t *p, ctl_error_kind_t kind, const char *msg) {
|
|
snprintf(p->errbuf, sizeof(p->errbuf), "%s", msg);
|
|
p->state = ST_ERROR;
|
|
p->st.error_kind = kind;
|
|
ctl_event_t ev = {0};
|
|
ev.type = CTL_EV_ERROR;
|
|
ev.error = p->errbuf;
|
|
emit(p, &ev);
|
|
}
|
|
|
|
/* free args in current call */
|
|
static void clear_call_args(ctl_tool_call_t *c) {
|
|
for (int i = 0; i < c->nargs; i++) {
|
|
free(c->args[i].value);
|
|
c->args[i].value = NULL;
|
|
c->args[i].value_len = 0;
|
|
}
|
|
c->nargs = 0;
|
|
c->id[0] = 0;
|
|
c->name[0] = 0;
|
|
}
|
|
|
|
static void reset_arg_body(ctl_parser_t *p) {
|
|
free(p->arg_buf);
|
|
p->arg_buf = NULL;
|
|
p->arg_cap = 0;
|
|
p->arg_got = 0;
|
|
p->arg_need = 0;
|
|
p->arg_key[0] = 0;
|
|
p->tag[0] = 0;
|
|
}
|
|
|
|
ctl_parser_t *ctl_parser_create(ctl_on_event_fn on_event, void *userdata) {
|
|
ctl_parser_t *p = calloc(1, sizeof(*p));
|
|
if (!p)
|
|
return NULL;
|
|
p->on_event = on_event;
|
|
p->userdata = userdata;
|
|
p->state = ST_TEXT;
|
|
return p;
|
|
}
|
|
|
|
void ctl_parser_destroy(ctl_parser_t *p) {
|
|
if (!p)
|
|
return;
|
|
clear_call_args(&p->call);
|
|
reset_arg_body(p);
|
|
free(p);
|
|
}
|
|
|
|
void ctl_parser_reset(ctl_parser_t *p) {
|
|
if (!p)
|
|
return;
|
|
clear_call_args(&p->call);
|
|
reset_arg_body(p);
|
|
p->state = ST_TEXT;
|
|
p->line_len = 0;
|
|
p->in_call = 0;
|
|
p->pending = 0;
|
|
p->st.error_kind = CTL_EK_NONE;
|
|
p->last_nkey[0] = 0;
|
|
/* keep counters / pending_peak for experiments */
|
|
}
|
|
|
|
size_t ctl_parser_pending(const ctl_parser_t *p) { return p ? p->pending : 0; }
|
|
|
|
size_t ctl_parser_pending_peak(const ctl_parser_t *p) {
|
|
return p ? p->st.pending_peak : 0;
|
|
}
|
|
|
|
void ctl_parser_stats(const ctl_parser_t *p, ctl_parser_stats_t *out) {
|
|
if (!out)
|
|
return;
|
|
if (!p) {
|
|
memset(out, 0, sizeof(*out));
|
|
return;
|
|
}
|
|
*out = p->st;
|
|
}
|
|
|
|
static int starts_with(const char *s, const char *pfx) {
|
|
return strncmp(s, pfx, strlen(pfx)) == 0;
|
|
}
|
|
|
|
static int parse_call_line(ctl_parser_t *p, const char *line) {
|
|
/* call <id> <name> */
|
|
const char *s = line + 5;
|
|
while (*s == ' ' || *s == '\t')
|
|
s++;
|
|
if (!*s)
|
|
return -1;
|
|
const char *sp = strchr(s, ' ');
|
|
if (!sp)
|
|
return -1;
|
|
size_t idlen = (size_t)(sp - s);
|
|
if (idlen == 0 || idlen >= CTL_MAX_ID)
|
|
return -1;
|
|
while (*sp == ' ' || *sp == '\t')
|
|
sp++;
|
|
if (!*sp)
|
|
return -1;
|
|
size_t nlen = strlen(sp);
|
|
while (nlen > 0 && (sp[nlen - 1] == ' ' || sp[nlen - 1] == '\t'))
|
|
nlen--;
|
|
if (nlen == 0 || nlen >= CTL_MAX_NAME)
|
|
return -1;
|
|
for (size_t i = 0; i < nlen; i++) {
|
|
if (!(isalnum((unsigned char)sp[i]) || sp[i] == '_' || sp[i] == '-'))
|
|
return -1;
|
|
}
|
|
clear_call_args(&p->call);
|
|
memcpy(p->call.id, s, idlen);
|
|
p->call.id[idlen] = 0;
|
|
memcpy(p->call.name, sp, nlen);
|
|
p->call.name[nlen] = 0;
|
|
p->in_call = 1;
|
|
p->st.calls_open++;
|
|
ctl_event_t ev = {0};
|
|
ev.type = CTL_EV_CALL_OPEN;
|
|
ev.call = &p->call;
|
|
emit(p, &ev);
|
|
return 0;
|
|
}
|
|
|
|
static int finish_arg(ctl_parser_t *p) {
|
|
int i = p->call.nargs;
|
|
snprintf(p->call.args[i].key, CTL_MAX_KEY, "%s", p->arg_key);
|
|
if (!p->arg_buf) {
|
|
p->arg_buf = malloc(1);
|
|
if (!p->arg_buf)
|
|
return -1;
|
|
}
|
|
p->arg_buf[p->arg_got] = 0;
|
|
p->call.args[i].value = p->arg_buf;
|
|
p->call.args[i].value_len = p->arg_got;
|
|
p->arg_buf = NULL; /* ownership moved */
|
|
p->arg_cap = 0;
|
|
p->call.nargs++;
|
|
p->arg_need = 0;
|
|
p->arg_got = 0;
|
|
p->arg_key[0] = 0;
|
|
p->tag[0] = 0;
|
|
p->state = ST_LINE;
|
|
bump_pending(p, 0);
|
|
ctl_event_t ev = {0};
|
|
ev.type = CTL_EV_ARG;
|
|
ev.call = &p->call;
|
|
emit(p, &ev);
|
|
return 0;
|
|
}
|
|
|
|
static int valid_tag(const char *t, size_t n) {
|
|
if (n == 0 || n > CTL_MAX_TAG)
|
|
return 0;
|
|
for (size_t i = 0; i < n; i++) {
|
|
if (!(isalnum((unsigned char)t[i]) || t[i] == '_'))
|
|
return 0;
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
/* Returns 0 ok, -1 contract error, -2 oom, -3 limit. */
|
|
static int parse_arg_header(ctl_parser_t *p, const char *line) {
|
|
/* Forms:
|
|
* arg <key> <nbytes> → length-prefixed body follows
|
|
* arg <key> <<TAG → heredoc body until a line equal to TAG
|
|
* arg <key> <value...> → value is rest of line (confirmed now)
|
|
*/
|
|
const char *s = line + 4;
|
|
while (*s == ' ' || *s == '\t')
|
|
s++;
|
|
if (!*s)
|
|
return -1;
|
|
const char *sp = strchr(s, ' ');
|
|
if (!sp)
|
|
return -1;
|
|
size_t klen = (size_t)(sp - s);
|
|
if (klen == 0 || klen >= CTL_MAX_KEY)
|
|
return -1;
|
|
while (*sp == ' ' || *sp == '\t')
|
|
sp++;
|
|
if (!*sp)
|
|
return -1;
|
|
if (p->call.nargs >= CTL_MAX_ARGS)
|
|
return -3;
|
|
|
|
memcpy(p->arg_key, s, klen);
|
|
p->arg_key[klen] = 0;
|
|
free(p->arg_buf);
|
|
p->arg_buf = NULL;
|
|
p->arg_cap = 0;
|
|
p->arg_got = 0;
|
|
|
|
if (sp[0] == '<' && sp[1] == '<') {
|
|
const char *t = sp + 2;
|
|
while (*t == ' ')
|
|
t++;
|
|
size_t tl = strlen(t);
|
|
while (tl > 0 && (t[tl - 1] == ' ' || t[tl - 1] == '\t'))
|
|
tl--;
|
|
if (tl >= 2 && (t[0] == '\'' || t[0] == '"') && t[tl - 1] == t[0]) {
|
|
t++;
|
|
tl -= 2;
|
|
}
|
|
if (!valid_tag(t, tl))
|
|
return -1;
|
|
memcpy(p->tag, t, tl);
|
|
p->tag[tl] = 0;
|
|
p->state = ST_HEREDOC;
|
|
p->st.args_heredoc++;
|
|
bump_pending(p, 0);
|
|
return 0;
|
|
}
|
|
|
|
char *end = NULL;
|
|
unsigned long nbytes = strtoul(sp, &end, 10);
|
|
int length_prefixed =
|
|
(end != sp && isdigit((unsigned char)sp[0]) &&
|
|
(*end == '\0' || *end == ' ' || *end == '\t'));
|
|
if (length_prefixed && *end != '\0') {
|
|
while (*end == ' ' || *end == '\t')
|
|
end++;
|
|
if (*end != '\0')
|
|
length_prefixed = 0; /* "9 apples" is an inline value */
|
|
}
|
|
|
|
if (!length_prefixed) {
|
|
size_t vlen = strlen(sp);
|
|
if (vlen > CTL_MAX_ARG_BYTES)
|
|
return -3;
|
|
p->arg_buf = malloc(vlen + 1);
|
|
if (!p->arg_buf)
|
|
return -2;
|
|
memcpy(p->arg_buf, sp, vlen);
|
|
p->arg_got = vlen;
|
|
p->arg_cap = vlen;
|
|
p->st.args_inline++;
|
|
return finish_arg(p) == 0 ? 0 : -2;
|
|
}
|
|
|
|
if (nbytes > CTL_MAX_ARG_BYTES)
|
|
return -3;
|
|
p->arg_need = (size_t)nbytes;
|
|
p->st.args_nbytes++;
|
|
snprintf(p->last_nkey, sizeof(p->last_nkey), "%s", p->arg_key);
|
|
p->last_nbytes = p->arg_need;
|
|
if (p->arg_need > 0) {
|
|
p->arg_buf = malloc(p->arg_need + 1);
|
|
if (!p->arg_buf)
|
|
return -2;
|
|
p->arg_cap = p->arg_need;
|
|
}
|
|
if (p->arg_need == 0)
|
|
return finish_arg(p) == 0 ? 0 : -2;
|
|
p->state = ST_ARG_BODY;
|
|
bump_pending(p, p->arg_need);
|
|
return 0;
|
|
}
|
|
|
|
static void emit_text_line(ctl_parser_t *p, const char *line, size_t len) {
|
|
/* include newline as confirmed text */
|
|
char tmp[CTL_MAX_LINE + 1];
|
|
if (len + 1 >= sizeof(tmp))
|
|
len = sizeof(tmp) - 2;
|
|
memcpy(tmp, line, len);
|
|
tmp[len] = '\n';
|
|
tmp[len + 1] = 0;
|
|
ctl_event_t ev = {0};
|
|
ev.type = CTL_EV_TEXT;
|
|
ev.text = tmp;
|
|
ev.text_len = len + 1;
|
|
emit(p, &ev);
|
|
}
|
|
|
|
static int is_fence_line(const char *line) {
|
|
return strncmp(line, "```", 3) == 0 || strncmp(line, "~~~", 3) == 0;
|
|
}
|
|
|
|
static void emit_ready(ctl_parser_t *p) {
|
|
ctl_event_t ev = {0};
|
|
ev.type = CTL_EV_CALL_READY;
|
|
ev.call = &p->call;
|
|
p->st.calls_ready++;
|
|
emit(p, &ev);
|
|
clear_call_args(&p->call);
|
|
p->in_call = 0;
|
|
bump_pending(p, 0);
|
|
p->state = ST_LINE;
|
|
}
|
|
|
|
static int heredoc_line(ctl_parser_t *p, const char *line, size_t len) {
|
|
if (strcmp(line, p->tag) == 0)
|
|
return finish_arg(p) == 0 ? 0 : -1;
|
|
size_t need = p->arg_got + len + 1;
|
|
if (need > CTL_MAX_ARG_BYTES) {
|
|
fail(p, CTL_EK_LIMIT, "heredoc body too large");
|
|
return -1;
|
|
}
|
|
if (need + 1 > p->arg_cap) {
|
|
size_t nc = p->arg_cap ? p->arg_cap * 2 : 256;
|
|
while (nc < need + 1)
|
|
nc *= 2;
|
|
char *nb = realloc(p->arg_buf, nc);
|
|
if (!nb) {
|
|
fail(p, CTL_EK_OOM, "oom");
|
|
return -1;
|
|
}
|
|
p->arg_buf = nb;
|
|
p->arg_cap = nc;
|
|
}
|
|
memcpy(p->arg_buf + p->arg_got, line, len);
|
|
p->arg_got += len;
|
|
p->arg_buf[p->arg_got++] = '\n';
|
|
bump_pending(p, 0);
|
|
return 0;
|
|
}
|
|
|
|
static int handle_line(ctl_parser_t *p, const char *line) {
|
|
/* Ignore markdown fences so models can wrap CTL in code blocks. */
|
|
if (is_fence_line(line) && !p->in_call) {
|
|
bump_pending(p, 0);
|
|
return 0;
|
|
}
|
|
if (line[0] == '\0' || (p->in_call && is_fence_line(line))) {
|
|
bump_pending(p, 0);
|
|
return 0;
|
|
}
|
|
|
|
if (!p->in_call) {
|
|
if (starts_with(line, "call ")) {
|
|
if (parse_call_line(p, line) != 0) {
|
|
fail(p, CTL_EK_BAD_CALL, "invalid call line");
|
|
return -1;
|
|
}
|
|
p->last_nkey[0] = 0;
|
|
bump_pending(p, 0);
|
|
return 0;
|
|
}
|
|
emit_text_line(p, line, strlen(line));
|
|
bump_pending(p, 0);
|
|
return 0;
|
|
}
|
|
|
|
/* in call */
|
|
if (starts_with(line, "arg ")) {
|
|
int rc = parse_arg_header(p, line);
|
|
if (rc == -2) {
|
|
fail(p, CTL_EK_OOM, "oom");
|
|
return -1;
|
|
}
|
|
if (rc == -3) {
|
|
fail(p, CTL_EK_LIMIT, "too many args or arg too large");
|
|
return -1;
|
|
}
|
|
if (rc != 0) {
|
|
fail(p, CTL_EK_BAD_ARG, "invalid arg header");
|
|
return -1;
|
|
}
|
|
return 0;
|
|
}
|
|
if (strcmp(line, "end") == 0) {
|
|
emit_ready(p);
|
|
return 0;
|
|
}
|
|
char msg[256];
|
|
if (p->last_nkey[0])
|
|
snprintf(msg, sizeof(msg),
|
|
"unexpected line \"%.40s\" inside call; arg %s declared %zu "
|
|
"bytes, so its real body was longer (byte count too small)",
|
|
line, p->last_nkey, p->last_nbytes);
|
|
else
|
|
snprintf(msg, sizeof(msg), "unexpected line \"%.40s\" inside call",
|
|
line);
|
|
fail(p, CTL_EK_UNEXPECTED_LINE, msg);
|
|
return -1;
|
|
}
|
|
|
|
ctl_status_t ctl_parser_feed(ctl_parser_t *p, const char *data, size_t len) {
|
|
if (!p)
|
|
return CTL_ERR_CONTRACT;
|
|
if (p->state == ST_ERROR)
|
|
return CTL_ERR_CONTRACT;
|
|
|
|
size_t i = 0;
|
|
while (i < len) {
|
|
if (p->state == ST_ARG_BODY) {
|
|
size_t left = p->arg_need - p->arg_got;
|
|
size_t take = len - i;
|
|
if (take > left)
|
|
take = left;
|
|
if (take > 0) {
|
|
memcpy(p->arg_buf + p->arg_got, data + i, take);
|
|
p->arg_got += take;
|
|
i += take;
|
|
bump_pending(p, p->arg_need - p->arg_got);
|
|
}
|
|
if (p->arg_got == p->arg_need && finish_arg(p) != 0) {
|
|
fail(p, CTL_EK_OOM, "oom");
|
|
return CTL_ERR_NOMEM;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (p->state != ST_HEREDOC)
|
|
p->state = ST_LINE;
|
|
unsigned char c = (unsigned char)data[i++];
|
|
if (c == '\r')
|
|
continue; /* normalize CRLF */
|
|
if (c == '\n') {
|
|
p->line[p->line_len] = 0;
|
|
int rc = p->state == ST_HEREDOC ? heredoc_line(p, p->line, p->line_len)
|
|
: handle_line(p, p->line);
|
|
p->line_len = 0;
|
|
if (rc != 0)
|
|
return p->st.error_kind == CTL_EK_LIMIT ? CTL_ERR_LIMIT
|
|
: CTL_ERR_CONTRACT;
|
|
continue;
|
|
}
|
|
if (p->line_len + 1 >= CTL_MAX_LINE) {
|
|
fail(p, CTL_EK_LINE_TOO_LONG, "line too long");
|
|
return CTL_ERR_LIMIT;
|
|
}
|
|
p->line[p->line_len++] = (char)c;
|
|
bump_pending(p, p->line_len);
|
|
}
|
|
return CTL_OK;
|
|
}
|
|
|
|
ctl_status_t ctl_parser_finish(ctl_parser_t *p) {
|
|
if (!p)
|
|
return CTL_ERR_CONTRACT;
|
|
if (p->state == ST_ERROR)
|
|
return CTL_ERR_CONTRACT;
|
|
/* Mid-argument body cannot be confirmed at EOF. */
|
|
if (p->state == ST_ARG_BODY) {
|
|
char msg[200];
|
|
snprintf(msg, sizeof(msg),
|
|
"stream ended inside arg %s: declared %zu bytes but only %zu "
|
|
"arrived (byte count too large)",
|
|
p->arg_key, p->arg_need, p->arg_got);
|
|
fail(p, CTL_EK_BODY_EOF, msg);
|
|
return CTL_ERR_CONTRACT;
|
|
}
|
|
if (p->state == ST_HEREDOC) {
|
|
p->line[p->line_len] = 0;
|
|
if (p->line_len > 0 && strcmp(p->line, p->tag) == 0) {
|
|
p->line_len = 0;
|
|
if (finish_arg(p) != 0)
|
|
return CTL_ERR_NOMEM;
|
|
} else {
|
|
fail(p, CTL_EK_HEREDOC_EOF, "stream ended before heredoc terminator");
|
|
return CTL_ERR_CONTRACT;
|
|
}
|
|
}
|
|
/* Trailing text without final newline is still confirmed text. */
|
|
if (!p->in_call && p->line_len > 0) {
|
|
p->line[p->line_len] = 0;
|
|
emit_text_line(p, p->line, p->line_len);
|
|
p->line_len = 0;
|
|
}
|
|
/* EOF confirms an open call whose args are complete (implicit end).
|
|
* Matches paper EOF adequacy: stream end closes the frontier. */
|
|
if (p->in_call) {
|
|
if (p->line_len > 0) {
|
|
p->line[p->line_len] = 0;
|
|
if (strcmp(p->line, "end") == 0 || is_fence_line(p->line) ||
|
|
p->line[0] == '\0') {
|
|
p->line_len = 0;
|
|
} else {
|
|
fail(p, CTL_EK_UNEXPECTED_LINE,
|
|
"unexpected trailing line in call at EOF");
|
|
return CTL_ERR_CONTRACT;
|
|
}
|
|
}
|
|
emit_ready(p);
|
|
}
|
|
bump_pending(p, 0);
|
|
return CTL_OK;
|
|
}
|