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.
413 lines
10 KiB
C
413 lines
10 KiB
C
#include "mm_tool.h"
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
#define MM_OPEN "<minimax:tool_call>"
|
|
#define MM_CLOSE "</minimax:tool_call>"
|
|
#define MM_OPEN_LEN 19
|
|
#define MM_CLOSE_LEN 20
|
|
|
|
struct mm_scan {
|
|
mm_on_event_fn on_event;
|
|
void *userdata;
|
|
char *buf;
|
|
size_t len;
|
|
size_t cap;
|
|
size_t pending_peak;
|
|
int in_tool; /* 1 if last open is unmatched */
|
|
char idbuf[CTL_MAX_ID];
|
|
unsigned id_seq;
|
|
};
|
|
|
|
static void emit(mm_scan_t *s, ctl_event_t *ev) {
|
|
if (s->on_event)
|
|
s->on_event(ev, s->userdata);
|
|
}
|
|
|
|
static void emit_text(mm_scan_t *s, const char *t, size_t n) {
|
|
if (!t || !n)
|
|
return;
|
|
ctl_event_t ev = {0};
|
|
ev.type = CTL_EV_TEXT;
|
|
ev.text = t;
|
|
ev.text_len = n;
|
|
emit(s, &ev);
|
|
}
|
|
|
|
static int ensure(mm_scan_t *s, size_t need) {
|
|
if (s->len + need + 1 <= s->cap)
|
|
return 0;
|
|
size_t nc = s->cap ? s->cap * 2 : 4096;
|
|
while (nc < s->len + need + 1)
|
|
nc *= 2;
|
|
char *nb = realloc(s->buf, nc);
|
|
if (!nb)
|
|
return -1;
|
|
s->buf = nb;
|
|
s->cap = nc;
|
|
return 0;
|
|
}
|
|
|
|
static void bump_pending(mm_scan_t *s) {
|
|
if (s->len > s->pending_peak)
|
|
s->pending_peak = s->len;
|
|
}
|
|
|
|
mm_scan_t *mm_scan_create(mm_on_event_fn on_event, void *userdata) {
|
|
mm_scan_t *s = calloc(1, sizeof(*s));
|
|
if (!s)
|
|
return NULL;
|
|
s->on_event = on_event;
|
|
s->userdata = userdata;
|
|
s->id_seq = 1;
|
|
return s;
|
|
}
|
|
|
|
void mm_scan_destroy(mm_scan_t *s) {
|
|
if (!s)
|
|
return;
|
|
free(s->buf);
|
|
free(s);
|
|
}
|
|
|
|
void mm_scan_reset(mm_scan_t *s) {
|
|
if (!s)
|
|
return;
|
|
s->len = 0;
|
|
s->in_tool = 0;
|
|
if (s->buf)
|
|
s->buf[0] = 0;
|
|
}
|
|
|
|
size_t mm_scan_pending(const mm_scan_t *s) {
|
|
return s ? (s->in_tool ? s->len : 0) : 0;
|
|
}
|
|
|
|
static void clear_call(ctl_tool_call_t *c) {
|
|
for (int i = 0; i < c->nargs; i++) {
|
|
free(c->args[i].value);
|
|
c->args[i].value = NULL;
|
|
}
|
|
memset(c, 0, sizeof(*c));
|
|
}
|
|
|
|
static int add_arg(ctl_tool_call_t *c, const char *key, const char *val,
|
|
size_t vlen) {
|
|
if (c->nargs >= CTL_MAX_ARGS)
|
|
return -1;
|
|
if (strlen(key) >= CTL_MAX_KEY)
|
|
return -1;
|
|
snprintf(c->args[c->nargs].key, CTL_MAX_KEY, "%s", key);
|
|
c->args[c->nargs].value = malloc(vlen + 1);
|
|
if (!c->args[c->nargs].value)
|
|
return -1;
|
|
memcpy(c->args[c->nargs].value, val, vlen);
|
|
c->args[c->nargs].value[vlen] = 0;
|
|
c->args[c->nargs].value_len = vlen;
|
|
c->nargs++;
|
|
return 0;
|
|
}
|
|
|
|
/* Extract name="..." or name='...' from a tag start. */
|
|
static int extract_name_attr(const char *tag, char *out, size_t out_sz) {
|
|
const char *p = strstr(tag, "name=");
|
|
if (!p)
|
|
return -1;
|
|
p += 5;
|
|
char quote = 0;
|
|
if (*p == '"' || *p == '\'') {
|
|
quote = *p++;
|
|
}
|
|
size_t i = 0;
|
|
while (*p && i + 1 < out_sz) {
|
|
if (quote) {
|
|
if (*p == quote)
|
|
break;
|
|
} else {
|
|
if (*p == ' ' || *p == '>' || *p == '/')
|
|
break;
|
|
}
|
|
out[i++] = *p++;
|
|
}
|
|
out[i] = 0;
|
|
return i > 0 ? 0 : -1;
|
|
}
|
|
|
|
static void emit_call(mm_scan_t *s, ctl_tool_call_t *call) {
|
|
/* Only emit allowlisted tools — prevents "cmd" etc. from becoming tools. */
|
|
if (!call->name[0])
|
|
return;
|
|
if (strcmp(call->name, "read_file") != 0 &&
|
|
strcmp(call->name, "run_shell") != 0 &&
|
|
strcmp(call->name, "write_file") != 0 &&
|
|
strcmp(call->name, "apply_patch") != 0)
|
|
return;
|
|
ctl_event_t ev = {0};
|
|
ev.type = CTL_EV_CALL_OPEN;
|
|
ev.call = call;
|
|
emit(s, &ev);
|
|
ev.type = CTL_EV_CALL_READY;
|
|
ev.call = call;
|
|
emit(s, &ev);
|
|
}
|
|
|
|
/*
|
|
* Parse one complete tool_call inner body (between open/close tags).
|
|
* Emits one CALL_READY per invoke.
|
|
*/
|
|
static void parse_tool_block(mm_scan_t *s, const char *inner, size_t n) {
|
|
char *block = malloc(n + 1);
|
|
if (!block)
|
|
return;
|
|
memcpy(block, inner, n);
|
|
block[n] = 0;
|
|
|
|
/* Normalize: support both <invoke name="x"> and bare "invoke x" lines. */
|
|
char *p = block;
|
|
while (*p) {
|
|
/* Skip whitespace */
|
|
while (*p == ' ' || *p == '\n' || *p == '\r' || *p == '\t')
|
|
p++;
|
|
if (!*p)
|
|
break;
|
|
|
|
ctl_tool_call_t call;
|
|
memset(&call, 0, sizeof(call));
|
|
snprintf(call.id, sizeof(call.id), "mm%u", s->id_seq++);
|
|
|
|
if (strncmp(p, "<invoke", 7) == 0) {
|
|
char *gt = strchr(p, '>');
|
|
if (!gt) {
|
|
break;
|
|
}
|
|
char tag[256];
|
|
size_t tlen = (size_t)(gt - p) + 1;
|
|
if (tlen >= sizeof(tag))
|
|
tlen = sizeof(tag) - 1;
|
|
memcpy(tag, p, tlen);
|
|
tag[tlen] = 0;
|
|
if (extract_name_attr(tag, call.name, sizeof(call.name)) != 0) {
|
|
p = gt + 1;
|
|
continue;
|
|
}
|
|
p = gt + 1;
|
|
char *end_inv = strstr(p, "</invoke>");
|
|
if (!end_inv) {
|
|
/* unclosed invoke — treat rest as parameters until end */
|
|
end_inv = block + n;
|
|
}
|
|
/* parameters / args inside */
|
|
char *q = p;
|
|
while (q < end_inv) {
|
|
char *arg = strstr(q, "<parameter");
|
|
char *arg2 = strstr(q, "<arg");
|
|
char *use = NULL;
|
|
int is_param = 0;
|
|
if (arg && (!arg2 || arg <= arg2) && arg < end_inv) {
|
|
use = arg;
|
|
is_param = 1;
|
|
} else if (arg2 && arg2 < end_inv) {
|
|
use = arg2;
|
|
is_param = 0;
|
|
} else {
|
|
break;
|
|
}
|
|
char *agt = strchr(use, '>');
|
|
if (!agt || agt >= end_inv)
|
|
break;
|
|
char atag[256];
|
|
size_t al = (size_t)(agt - use) + 1;
|
|
if (al >= sizeof(atag))
|
|
al = sizeof(atag) - 1;
|
|
memcpy(atag, use, al);
|
|
atag[al] = 0;
|
|
char key[CTL_MAX_KEY];
|
|
if (extract_name_attr(atag, key, sizeof(key)) != 0) {
|
|
q = agt + 1;
|
|
continue;
|
|
}
|
|
const char *close = is_param ? "</parameter>" : "</arg>";
|
|
char *aend = strstr(agt + 1, close);
|
|
if (!aend || aend > end_inv) {
|
|
q = agt + 1;
|
|
continue;
|
|
}
|
|
size_t vlen = (size_t)(aend - (agt + 1));
|
|
add_arg(&call, key, agt + 1, vlen);
|
|
q = aend + strlen(close);
|
|
}
|
|
emit_call(s, &call);
|
|
clear_call(&call);
|
|
if (end_inv < block + n && strncmp(end_inv, "</invoke>", 9) == 0)
|
|
p = end_inv + 9;
|
|
else
|
|
p = end_inv;
|
|
continue;
|
|
}
|
|
|
|
/* Bare: invoke NAME\n */
|
|
if (strncmp(p, "invoke ", 7) == 0) {
|
|
p += 7;
|
|
while (*p == ' ' || *p == '\t')
|
|
p++;
|
|
size_t ni = 0;
|
|
while (p[ni] && p[ni] != '\n' && p[ni] != '\r' && p[ni] != ' ' &&
|
|
p[ni] != '<' && ni + 1 < sizeof(call.name)) {
|
|
call.name[ni] = p[ni];
|
|
ni++;
|
|
}
|
|
call.name[ni] = 0;
|
|
p += ni;
|
|
while (*p == '\n' || *p == '\r' || *p == ' ' || *p == '\t')
|
|
p++;
|
|
|
|
/* Collect <arg>...</arg> until next invoke or end */
|
|
while (*p) {
|
|
while (*p == ' ' || *p == '\n' || *p == '\r' || *p == '\t')
|
|
p++;
|
|
if (strncmp(p, "invoke ", 7) == 0 || strncmp(p, "<invoke", 7) == 0)
|
|
break;
|
|
if (strncmp(p, "<arg", 4) != 0 && strncmp(p, "<parameter", 10) != 0)
|
|
break;
|
|
int is_param = (strncmp(p, "<parameter", 10) == 0);
|
|
char *agt = strchr(p, '>');
|
|
if (!agt)
|
|
break;
|
|
char atag[256];
|
|
size_t al = (size_t)(agt - p) + 1;
|
|
if (al >= sizeof(atag))
|
|
al = sizeof(atag) - 1;
|
|
memcpy(atag, p, al);
|
|
atag[al] = 0;
|
|
char key[CTL_MAX_KEY];
|
|
if (extract_name_attr(atag, key, sizeof(key)) != 0) {
|
|
p = agt + 1;
|
|
continue;
|
|
}
|
|
const char *close = is_param ? "</parameter>" : "</arg>";
|
|
char *aend = strstr(agt + 1, close);
|
|
if (!aend)
|
|
break;
|
|
size_t vlen = (size_t)(aend - (agt + 1));
|
|
add_arg(&call, key, agt + 1, vlen);
|
|
p = aend + strlen(close);
|
|
}
|
|
if (call.name[0]) {
|
|
emit_call(s, &call);
|
|
}
|
|
clear_call(&call);
|
|
continue;
|
|
}
|
|
|
|
/* Unknown content — skip one char */
|
|
p++;
|
|
}
|
|
free(block);
|
|
}
|
|
|
|
/* Drop stray close tags that MiniMax sometimes emits outside a tool block. */
|
|
static void strip_stray_closes(mm_scan_t *s) {
|
|
for (;;) {
|
|
char *c = strstr(s->buf, MM_CLOSE);
|
|
if (!c)
|
|
return;
|
|
/* Only strip if it appears before any open (orphan). */
|
|
char *o = strstr(s->buf, MM_OPEN);
|
|
if (o && o < c)
|
|
return;
|
|
size_t at = (size_t)(c - s->buf);
|
|
size_t rest = s->len - at - MM_CLOSE_LEN;
|
|
if (at > 0)
|
|
emit_text(s, s->buf, at);
|
|
memmove(s->buf, c + MM_CLOSE_LEN, rest);
|
|
s->len = rest;
|
|
s->buf[s->len] = 0;
|
|
}
|
|
}
|
|
|
|
/* Process buffer: emit text before tools, parse complete tool blocks. */
|
|
static void drain(mm_scan_t *s) {
|
|
if (!s->buf)
|
|
return;
|
|
for (;;) {
|
|
if (!s->in_tool) {
|
|
strip_stray_closes(s);
|
|
char *open = strstr(s->buf, MM_OPEN);
|
|
if (!open) {
|
|
/* Keep a short tail that could be a partial open tag. */
|
|
size_t keep = MM_OPEN_LEN - 1;
|
|
if (s->len <= keep)
|
|
return;
|
|
size_t emit_n = s->len - keep;
|
|
/* Don't split a UTF-8 codepoint across emit/keep. */
|
|
while (emit_n > 0 &&
|
|
((unsigned char)s->buf[emit_n] & 0xC0) == 0x80)
|
|
emit_n--;
|
|
if (emit_n == 0)
|
|
return;
|
|
emit_text(s, s->buf, emit_n);
|
|
memmove(s->buf, s->buf + emit_n, s->len - emit_n);
|
|
s->len -= emit_n;
|
|
s->buf[s->len] = 0;
|
|
return;
|
|
}
|
|
size_t before = (size_t)(open - s->buf);
|
|
if (before)
|
|
emit_text(s, s->buf, before);
|
|
/* drop open tag from buffer, enter tool mode */
|
|
size_t rest = s->len - before - MM_OPEN_LEN;
|
|
memmove(s->buf, open + MM_OPEN_LEN, rest);
|
|
s->len = rest;
|
|
s->buf[s->len] = 0;
|
|
s->in_tool = 1;
|
|
bump_pending(s);
|
|
continue;
|
|
}
|
|
|
|
/* in tool: wait for close */
|
|
char *close = strstr(s->buf, MM_CLOSE);
|
|
if (!close)
|
|
return; /* pending */
|
|
size_t inner_len = (size_t)(close - s->buf);
|
|
parse_tool_block(s, s->buf, inner_len);
|
|
size_t rest = s->len - inner_len - MM_CLOSE_LEN;
|
|
memmove(s->buf, close + MM_CLOSE_LEN, rest);
|
|
s->len = rest;
|
|
s->buf[s->len] = 0;
|
|
s->in_tool = 0;
|
|
}
|
|
}
|
|
|
|
int mm_scan_feed(mm_scan_t *s, const char *data, size_t len) {
|
|
if (!s || !data)
|
|
return -1;
|
|
if (ensure(s, len) != 0)
|
|
return -1;
|
|
memcpy(s->buf + s->len, data, len);
|
|
s->len += len;
|
|
s->buf[s->len] = 0;
|
|
bump_pending(s);
|
|
drain(s);
|
|
return 0;
|
|
}
|
|
|
|
int mm_scan_finish(mm_scan_t *s) {
|
|
if (!s)
|
|
return -1;
|
|
drain(s);
|
|
if (s->in_tool) {
|
|
/* Incomplete tool — do NOT execute; emit as visible text so UI isn't stuck */
|
|
emit_text(s, MM_OPEN, MM_OPEN_LEN);
|
|
if (s->len)
|
|
emit_text(s, s->buf, s->len);
|
|
s->len = 0;
|
|
s->in_tool = 0;
|
|
} else if (s->len) {
|
|
emit_text(s, s->buf, s->len);
|
|
s->len = 0;
|
|
}
|
|
return 0;
|
|
}
|