Files
CST-agent/src/agent_loop.c
T
WHC 2a43ae6622 Windows: official port -- UCRT static build, UTF-8 throughout, Low IL sandbox, ncurses TUI, installer; v0.6.0
Build
  * cross build switches to pkgsCross.ucrt64 (shell-win.nix); curl (Schannel),
    winpthreads and ncurses 6.6 are linked statically, so the release is
    cst-agent.exe + busybox.exe (busybox-w32 Unicode) with no extra DLLs
  * windres embeds a manifest with activeCodePage=UTF-8 and a version resource
  * ncurses 6.6 Windows console driver patches (mouse button state, A_DIM,
    wide-char declarations): scripts/patches/
  * scripts/bootstrap_msys2.sh builds the same release natively in MSYS2
    UCRT64; PR #1's MINGW64 notes now point there
  * vendor/tre-regex (musl's TRE) provides <regex.h> on MinGW

UTF-8
  * paths cross the system call boundary as UTF-16 (*W APIs); console code
    pages set to 65001 and restored on exit; files are read and written in
    binary mode
  * child output that is not valid UTF-8 is decoded from the console, OEM or
    ANSI system code page
  * read_file hides CR; edit_file, apply_patch and write_file keep CRLF line
    endings and a UTF-8 BOM

OS layer and processes
  * os_dir_* and cst_fnmatch replace the POSIX-only directory walks in
    subagent, plugins, skills, undo and fs_tools; realpath / path_under and
    symlink rejection work on Windows
  * every child goes through CreateProcessW with its own environment block
    inside a Job Object, so timeouts and Esc end the whole process tree

Sandbox (auto mode, "auto · lowil")
  * run_shell runs with a restricted Low integrity token; the workspace gets
    an inheritable Low label once, the sandbox TEMP is labeled Low, and the
    credentials file gets a no-read-up label
  * drive roots, the profile and system folders are never labeled
  * network commands still ask, since Low IL does not block the network;
    Access denied and MSYS2's 0xc0000142 offer a rerun outside the sandbox
  * /sandbox lists labeled folders, /sandbox reset-labels restores them

TUI
  * raster console fonts are switched to Consolas / NSimSun while running
  * the home logo falls back to text when the console font lacks U+2580
  * mouse drag copy goes to the Windows clipboard

Distribution
  * scripts/install_windows.ps1 installs a Gitea release into
    %LOCALAPPDATA%\Programs\cst-agent and adds it to the user PATH;
    cst-agent-update.cmd reruns it; scripts/release_windows.sh uploads the
    zip with the token taken from the environment
  * scripts/run_windows.bat is gone; docs/README-windows.md is rewritten

Tests
  * new test_lowil, test_win_utf8 and test_os_dir; tests made portable
    (cst_test_util.h)
  * scripts/win_test.sh and scripts/winvm/ run the suite on English and
    Chinese Windows Server VMs as Administrator, a standard user and a
    Chinese user name: 29/29 on all six
2026-10-06 23:08:14 +08:00

2451 lines
81 KiB
C

#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#include "cJSON.h"
#include "cst_agent.h"
#include "cst_metrics.h"
#include "cst_perms.h"
#include "cst_sandbox.h"
#include "cst_skills.h"
#include "cst_subagent.h"
#include "cst_tools.h"
#include "cst_undo.h"
#include "cst_util.h"
#include "mm_tool.h"
#include "os_compat.h"
#include <stdint.h>
#include <stdio.h>
#include <time.h>
#ifndef _WIN32
#include <sys/utsname.h>
#endif
#include <stdlib.h>
#include <string.h>
/* --- protocols ----------------------------------------------------------- */
static const char *const PROTO_NAMES[] = {"native-eos", "native-inc", "ctl",
"ctl-heredoc", "xml"};
const char *cst_protocol_name(cst_protocol_t p) {
if ((int)p < 0 || (size_t)p >= sizeof(PROTO_NAMES) / sizeof(PROTO_NAMES[0]))
return "native-eos";
return PROTO_NAMES[p];
}
int cst_protocol_parse(const char *s, cst_protocol_t *out) {
if (!s || !out)
return -1;
if (strcmp(s, "native") == 0) {
*out = CST_PROTO_NATIVE_EOS;
return 0;
}
for (size_t i = 0; i < sizeof(PROTO_NAMES) / sizeof(PROTO_NAMES[0]); i++) {
if (strcmp(s, PROTO_NAMES[i]) == 0) {
*out = (cst_protocol_t)i;
return 0;
}
}
return -1;
}
int cst_protocol_is_native(cst_protocol_t p) {
return p == CST_PROTO_NATIVE_EOS || p == CST_PROTO_NATIVE_INC;
}
int cst_protocol_is_ctl(cst_protocol_t p) {
return p == CST_PROTO_CTL || p == CST_PROTO_CTL_HEREDOC;
}
int cst_protocol_is_text(cst_protocol_t p) { return !cst_protocol_is_native(p); }
int cst_protocol_is_eager(cst_protocol_t p) {
return p == CST_PROTO_NATIVE_INC || cst_protocol_is_ctl(p);
}
/* --- system prompts ------------------------------------------------------ */
#define PROMPT_BASE \
"You are CST-Agent, a coding assistant working inside a local workspace. " \
"All tool paths are relative to the workspace root and shell commands run " \
"there.\n" \
"Reply in the user's language. Be concise.\n\n"
#define PROMPT_TOOLS \
"Tools:\n" \
"- read_file(path): read a text file.\n" \
"- run_shell(cmd): run a non-interactive shell command (stdin is empty; " \
"long-running commands are killed).\n" \
"- write_file(path, content): create or overwrite a whole file.\n" \
"- apply_patch(patch): unified diff with ---/+++ headers and " \
"@@ -a,b +c,d @@ hunks (keep 3 context lines). Prefer it for small edits " \
"to existing files.\n\n" \
"Workflow:\n" \
"1) Inspect only what you need (read_file; ls/grep via run_shell).\n" \
"2) Make the change.\n" \
"3) Verify with the project's build/tests via run_shell; fix and re-run " \
"until they pass.\n" \
"4) Finish with a short plain-text summary of what changed.\n" \
"Never invent other tool names.\n"
#define PROMPT_RESULTS \
"- After your last frame, STOP writing and wait. Results arrive in the " \
"next user message as:\n" \
" result <id> exit=<code> <nbytes>\n" \
" <output>\n" \
" end\n" \
" Never write result frames yourself.\n"
static const char SEC_NATIVE[] =
"Call tools through the native function-calling interface.\n\n";
static const char SEC_CTL[] =
"\nHow to call tools: write CTL frames as plain text in your reply. There "
"is NO function-calling API here; do not use JSON or XML.\n\n"
"call <id> <tool>\n"
"arg <key> <nbytes>\n"
"<exactly nbytes bytes of the raw value>\n"
"end\n\n"
"Rules:\n"
"- <id> is a short unique token per call (c1, c2, ...). Every frame line "
"starts at the beginning of a line.\n"
"- nbytes counts UTF-8 BYTES of the raw value (ASCII = 1 byte, most CJK "
"characters = 3 bytes), including every newline inside it. The value is "
"copied verbatim, with no escaping. One newline after the value, before "
"`end`, is optional and not counted.\n"
"- Short single-line values may be inline instead: `arg path src/main.c` "
"(the value is the rest of the line; it must not be only digits).\n"
"- You may write several frames in one reply.\n" PROMPT_RESULTS
"\nExample (the content value is \"hello world\\n\" = 12 bytes):\n"
"call c1 read_file\n"
"arg path README.md\n"
"end\n"
"call c2 write_file\n"
"arg path hello.txt\n"
"arg content 12\n"
"hello world\n"
"end\n";
static const char SEC_CTL_HEREDOC[] =
"\nHow to call tools: write CTL frames as plain text in your reply. There "
"is NO function-calling API here; do not use JSON or XML.\n\n"
"call <id> <tool>\n"
"arg <key> <<TAG\n"
"<value lines, verbatim>\n"
"TAG\n"
"end\n\n"
"Rules:\n"
"- <id> is a short unique token per call (c1, c2, ...). Every frame line "
"starts at the beginning of a line.\n"
"- TAG is a word of letters/digits/underscore that never appears alone on "
"a line inside the value (e.g. EOF_1). The value is the lines between the "
"header and the TAG line, each ending with a newline. No escaping.\n"
"- Short single-line values may be inline instead: `arg path src/main.c` "
"(the value is the rest of the line; it must not be only digits).\n"
"- You may write several frames in one reply.\n" PROMPT_RESULTS
"\nExample:\n"
"call c1 read_file\n"
"arg path README.md\n"
"end\n"
"call c2 write_file\n"
"arg path hello.txt\n"
"arg content <<EOF_1\n"
"hello world\n"
"EOF_1\n"
"end\n";
static const char SEC_XML[] =
"\nHow to call tools: write tool blocks as plain text in your reply. There "
"is NO function-calling API here.\n\n"
"<minimax:tool_call>\n"
"<invoke name=\"read_file\">\n"
"<parameter name=\"path\">README.md</parameter>\n"
"</invoke>\n"
"</minimax:tool_call>\n\n"
"- Parameter values are verbatim text (no escaping).\n"
"- You may write several blocks in one reply.\n" PROMPT_RESULTS;
static const char SEC_CTL_NUMBERS[] =
"- A purely numeric inline value must be quoted (`arg limit \"50\"`); "
"bare digits are read as a byte count.\n";
static int tool_on(const cst_agent_config_t *cfg, const char *name) {
return cst_tool_active(cfg, cst_tool_find(name));
}
/* Workflow section for the default tool profile; names only active tools. */
static void put_workflow(cst_buf_t *b, const cst_agent_config_t *cfg) {
cst_buf_puts(b, "\nWorkflow:\n1) Inspect only what you need");
const char *explore[] = {"list_dir", "glob", "grep"};
int n = 0;
for (int i = 0; i < 3; i++) {
if (tool_on(cfg, explore[i]))
cst_buf_printf(b, "%s%s", n++ ? ", " : " (", explore[i]);
}
if (n)
cst_buf_puts(b, ")");
if (tool_on(cfg, "read_file"))
cst_buf_puts(b, "; read big files in slices with read_file offset/limit");
cst_buf_puts(b, ".\n2) Make the change");
n = 0;
if (tool_on(cfg, "edit_file")) {
cst_buf_puts(b, ": edit_file for small exact replacements");
n++;
}
if (tool_on(cfg, "apply_patch"))
cst_buf_printf(b, "%sapply_patch for multi-hunk or multi-file diffs",
n++ ? ", " : ": ");
if (tool_on(cfg, "write_file"))
cst_buf_printf(b, "%swrite_file for new files or full rewrites",
n++ ? ", " : ": ");
cst_buf_puts(b, ".\n");
if (tool_on(cfg, "run_shell"))
cst_buf_puts(b, "3) Verify with the project's build/tests via run_shell; "
"fix and re-run until they pass.\n");
if (tool_on(cfg, "todo_write"))
cst_buf_puts(b, "- For work with several steps keep a todo list with "
"todo_write and update it as you go.\n");
if (tool_on(cfg, "ask_user"))
cst_buf_puts(b, "- Use ask_user only when a decision is truly the user's; "
"otherwise choose sensibly and say so.\n");
cst_buf_puts(b, "Finish with a short plain-text summary of what changed.\n"
"Never invent other tool names.\n");
}
#define PROJECT_NOTES_MAX (16 * 1024)
/* Default-profile prompt: static sections first (prefix-cache friendly),
* then the protocol, then per-session facts. */
static const char DEF_INTRO[] =
"You are CST-Agent, a coding agent working in the user's local "
"workspace. All tool paths are relative to the workspace root and shell "
"commands run there.\n\n"
"Principles:\n"
"- Reply in the user's language. Be concise: lead with the result, no "
"filler.\n"
"- Understand before changing: look at the relevant code instead of "
"guessing; never invent files, APIs or tool results.\n"
"- Make the smallest change that fully solves the task, in the style of "
"the surrounding code; leave unrelated code alone.\n"
"- Verify (build, tests, or running it) before saying it works; if you "
"could not verify, say so.\n"
"- Keep going until the task is done or truly blocked.\n\n";
static void put_tool_use(cst_buf_t *b, const cst_agent_config_t *cfg) {
cst_buf_puts(b, "\nTool use:\n");
if (tool_on(cfg, "run_shell") &&
(tool_on(cfg, "grep") || tool_on(cfg, "glob")))
cst_buf_puts(b, "- Use the file tools rather than run_shell to look "
"around: read_file (not cat/head/tail), grep (not "
"grep/rg), glob and list_dir (not find/ls).\n");
cst_buf_puts(b, "- Independent read-only calls can go out together in one "
"reply; they run in parallel.\n");
if (tool_on(cfg, "read_file"))
cst_buf_puts(b, "- Read a file before editing it and again after a failed "
"edit. The `N|` prefixes in read_file output are not part "
"of the file.\n");
cst_buf_puts(b, "- When a call fails, read the error and change approach "
"instead of repeating the same call.\n");
}
static void put_safety(cst_buf_t *b, const cst_agent_config_t *cfg) {
cst_buf_puts(b, "\nSafety:\n"
"- Do not commit, push or rewrite git history unless asked. "
"Do not delete or overwrite files you did not create unless "
"the task requires it. Never print secrets or API keys.\n");
if (tool_on(cfg, "run_shell"))
cst_buf_puts(b, "- run_shell may run in a sandbox (writes only inside the "
"workspace and temp dir, no network). If a command is "
"blocked the user is asked whether to rerun it outside; "
"do not try to work around the sandbox.\n");
}
/* Current branch from .git/HEAD of the workspace or a parent directory. */
static void git_branch(const char *ws, char *out, size_t n) {
out[0] = 0;
char dir[4096];
snprintf(dir, sizeof(dir), "%s", ws ? ws : ".");
for (int depth = 0; depth < 8; depth++) {
char p[4200], line[512];
snprintf(p, sizeof(p), "%s/.git", dir);
FILE *fp = fopen(p, "rb");
if (fp) {
/* worktree / submodule: ".git" is a file "gitdir: <path>" */
char gd[4096] = "";
if (fgets(line, sizeof(line), fp) && strncmp(line, "gitdir: ", 8) == 0) {
line[strcspn(line, "\r\n")] = 0;
if (line[8] == '/')
snprintf(gd, sizeof(gd), "%s", line + 8);
else
snprintf(gd, sizeof(gd), "%.2000s/%.2000s", dir, line + 8);
}
fclose(fp);
if (gd[0])
snprintf(p, sizeof(p), "%s/HEAD", gd);
else
snprintf(p, sizeof(p), "%s/.git/HEAD", dir);
fp = fopen(p, "rb");
if (fp) {
if (fgets(line, sizeof(line), fp)) {
line[strcspn(line, "\r\n")] = 0;
if (strncmp(line, "ref: refs/heads/", 16) == 0)
snprintf(out, n, "%.100s", line + 16);
else if (strlen(line) >= 12)
snprintf(out, n, "detached at %.12s", line);
}
fclose(fp);
}
return;
}
char *sl = strrchr(dir, '/');
if (!sl || sl == dir)
return;
*sl = 0;
}
}
static void os_label(char *out, size_t n) {
#ifdef _WIN32
char ver[64];
os_windows_version(ver, sizeof(ver));
snprintf(out, n,
"%s; run_shell is busybox sh (POSIX syntax and applets; write paths "
"as C:/dir or relative, Windows programs like git or python run "
"from PATH)",
ver);
#else
struct utsname u;
if (uname(&u) == 0)
snprintf(out, n, "%s %s %s", u.sysname, u.release, u.machine);
else
snprintf(out, n, "Unix");
#endif
}
static void put_env(cst_buf_t *b, const cst_agent_config_t *cfg) {
char osl[256], br[128], day[32];
os_label(osl, sizeof(osl));
time_t now = time(NULL);
struct tm tmv;
os_localtime(&now, &tmv);
strftime(day, sizeof(day), "%Y-%m-%d", &tmv);
git_branch(cfg->workspace_root, br, sizeof(br));
cst_buf_printf(b, "\nEnvironment:\n- OS: %s; date: %s\n- Workspace: %s%s%s%s\n",
osl, day, cfg->workspace_root[0] ? cfg->workspace_root : ".",
br[0] ? " (git: " : " (not a git repo", br,
")");
const char *ap = cfg->auto_approve ? "yolo (no confirmations)"
: cfg->approval == CST_APPROVAL_ASK
? "ask (the user confirms writes and commands)"
: cfg->approval == CST_APPROVAL_AUTO
? "auto (edits run; risky commands need confirmation)"
: "yolo (no confirmations)";
if (tool_on(cfg, "run_shell")) {
int sb = cst_sandbox_probe();
cst_buf_printf(b, "- Approval: %s; shell sandbox: %s\n", ap,
sb == CST_SANDBOX_NONE ? "none" : cst_sandbox_name(sb));
} else {
cst_buf_printf(b, "- Approval: %s\n", ap);
}
}
char *cst_agent_build_prompt(const cst_agent_config_t *cfg, cst_protocol_t p,
const char *project_notes) {
int minimal = !cfg || cfg->tool_profile == CST_TOOLS_MINIMAL;
cst_buf_t b = {0};
cst_buf_puts(&b, minimal ? PROMPT_BASE : DEF_INTRO);
if (cst_protocol_is_native(p))
cst_buf_puts(&b, minimal ? SEC_NATIVE
: "Call tools through the native "
"function-calling interface.\n");
if (minimal) {
cst_buf_puts(&b, PROMPT_TOOLS);
} else {
/* native requests carry the descriptions in tools[] already */
if (!cst_protocol_is_native(p)) {
char *tools = cst_tools_prompt(cfg);
cst_buf_puts(&b, tools ? tools : "");
free(tools);
}
put_workflow(&b, cfg);
put_tool_use(&b, cfg);
put_safety(&b, cfg);
}
switch (p) {
case CST_PROTO_CTL:
cst_buf_puts(&b, SEC_CTL);
break;
case CST_PROTO_CTL_HEREDOC:
cst_buf_puts(&b, SEC_CTL_HEREDOC);
break;
case CST_PROTO_XML:
cst_buf_puts(&b, SEC_XML);
break;
default:
break;
}
if (!minimal && cst_protocol_is_ctl(p))
cst_buf_puts(&b, SEC_CTL_NUMBERS);
/* per-workspace, rarely changing */
if (!minimal && tool_on(cfg, "skill")) {
char *ix = cst_skills_index(cfg->workspace_root);
if (ix)
cst_buf_puts(&b, ix);
free(ix);
}
if (!minimal && tool_on(cfg, "task")) {
char *ix = cst_subagent_index(cfg->workspace_root);
if (ix)
cst_buf_puts(&b, ix);
free(ix);
}
if (project_notes && project_notes[0]) {
size_t n = strlen(project_notes);
if (n > PROJECT_NOTES_MAX)
n = cst_utf8_floor(project_notes, PROJECT_NOTES_MAX);
cst_buf_puts(&b, "\nProject instructions (from the workspace's "
"AGENTS.md; follow them):\n");
cst_buf_append(&b, project_notes, n);
cst_buf_puts(&b, "\n");
}
/* per-session facts last */
if (!minimal)
put_env(&b, cfg);
if (!minimal && cfg->depth > 0) {
cst_buf_puts(&b, "\nSUB-AGENT: another agent delegated one task to you "
"with the task tool. It sees none of your tool calls or "
"outputs, only your final message, and the user is not "
"watching this conversation (you cannot ask questions). "
"Work autonomously, then end with one self-contained "
"report: what you found or changed, with file paths "
"and line numbers, and anything left open.\n");
if (cfg->role_prompt && cfg->role_prompt[0]) {
cst_buf_puts(&b, "\nRole instructions:\n");
cst_buf_puts(&b, cfg->role_prompt);
cst_buf_puts(&b, "\n");
}
}
if (cfg && cfg->plan_mode)
cst_buf_puts(&b, "\nPLAN MODE: you may only read and research (no file "
"changes, no shell). Investigate, then answer with a "
"concrete step-by-step plan: files to touch, what to "
"change, how to verify. The user switches to Build mode "
"to carry it out.\n");
return cst_buf_steal(&b);
}
/* Legacy prompts: minimal tool set, no project notes. */
const char *cst_agent_system_prompt_for(cst_protocol_t p) {
static char *cache[5];
int i = (int)p < 0 || (int)p > 4 ? 0 : (int)p;
if (!cache[i])
cache[i] = cst_agent_build_prompt(NULL, (cst_protocol_t)i, NULL);
return cache[i] ? cache[i] : PROMPT_BASE;
}
const char *cst_agent_system_prompt(void) {
return cst_agent_system_prompt_for(CST_PROTO_NATIVE_EOS);
}
/* Request-only nudges (never persisted). */
static const char NUDGE_EMPTY[] =
"[system] Your previous reply was empty. If the task is not finished, "
"continue with tool calls; otherwise give the final answer now in plain "
"text, in the user's language.";
static const char NUDGE_FINAL[] =
"[system] The tool budget for this turn is exhausted. Do NOT call any "
"tools. Answer now in plain text, in the user's language: summarize what "
"was done and what remains.";
/* Session keeps tool output up to this size; the wire cap is separate. */
#define TOOL_OUT_CAP (48 * 1024)
#define READ_FP_MAX 256
#define FAIL_FP_MAX 32
#define PAR_MAX 4
#define MAX_CTL_RETRIES 3
/* --- agent --------------------------------------------------------------- */
typedef struct {
uint64_t hash;
unsigned epoch;
} read_fp_t;
struct cst_agent {
cst_agent_config_t cfg;
cst_session_t *session;
cst_metrics_t *metrics;
volatile int cancel;
unsigned write_epoch;
read_fp_t reads[READ_FP_MAX];
int nreads;
/* this user turn: failing calls (hash -> count) and applied writes */
struct {
uint64_t hash;
int count;
} fails[FAIL_FP_MAX];
int nfails;
read_fp_t writes[FAIL_FP_MAX];
int nwrites;
int turn_no;
char *notes; /* AGENTS.md text, or NULL */
char *todo; /* latest todo_write list, or NULL */
char undo_tag[64];
long last_prompt_tokens;
cst_perms_t *perms;
};
typedef enum {
PC_OK = 0,
PC_DENIED,
PC_DUP,
PC_UNKNOWN,
PC_INVALID
} pc_status_t;
typedef struct {
ctl_tool_call_t call; /* owns arg values */
char *raw_args; /* native: arguments string as streamed */
int invalid;
int done;
int exit_code;
pc_status_t status;
char *output;
size_t output_len;
const char *origin; /* native / ctl / xml */
double t_open, t_ready, t_exec_start, t_exec_end;
} pcall_t;
typedef struct {
cst_agent_t *a;
const cst_agent_hooks_t *hooks;
cst_protocol_t proto;
int step;
int final_pass;
double t0;
cst_buf_t text; /* confirmed answer text (no tool syntax) */
cst_buf_t raw; /* visible stream with tool syntax, think stripped */
size_t raw_mark;
int in_think;
char tagbuf[16];
size_t taglen;
ctl_parser_t *ctl;
mm_scan_t *mm;
pcall_t *calls;
int ncalls;
int cap;
int n_native;
int saw_ready;
volatile int stop;
int parse_error;
char perr[256];
double native_open_t[64];
double ctl_open_t;
} turn_t;
static double rel_ms(const turn_t *t) { return os_now_ms() - t->t0; }
static void hook_status(const turn_t *t, const char *msg) {
if (t->hooks && t->hooks->on_status)
t->hooks->on_status(msg, t->hooks->userdata);
}
static double ms2(double v) {
return v < 0 ? v : (double)(long long)(v * 100.0 + 0.5) / 100.0;
}
static void hook_tool(const turn_t *t, const char *phase,
const ctl_tool_call_t *c, const char *detail) {
if (t->hooks && t->hooks->on_tool_event)
t->hooks->on_tool_event(phase, c, detail, t->hooks->userdata);
}
static int text_is_meaningful(const char *s, size_t n) {
size_t visible = 0;
for (size_t i = 0; i < n; i++) {
if ((unsigned char)s[i] > 0x20)
visible++;
}
return visible >= 2;
}
static const char *trim_left(const char *s) {
while (*s == ' ' || *s == '\n' || *s == '\r' || *s == '\t')
s++;
return s;
}
static void copy_call(ctl_tool_call_t *dst, const ctl_tool_call_t *src) {
memset(dst, 0, sizeof(*dst));
memcpy(dst->id, src->id, sizeof(dst->id));
memcpy(dst->name, src->name, sizeof(dst->name));
for (int i = 0; i < src->nargs; i++) {
ctl_arg_t *d = &dst->args[dst->nargs];
memcpy(d->key, src->args[i].key, sizeof(d->key));
d->value_len = src->args[i].value_len;
d->value = malloc(d->value_len + 1);
if (!d->value)
continue;
if (d->value_len)
memcpy(d->value, src->args[i].value, d->value_len);
d->value[d->value_len] = 0;
dst->nargs++;
}
}
static pcall_t *push_call(turn_t *t) {
if (t->ncalls == t->cap) {
int nc = t->cap ? t->cap * 2 : 4;
pcall_t *nv = realloc(t->calls, (size_t)nc * sizeof(pcall_t));
if (!nv)
return NULL;
t->calls = nv;
t->cap = nc;
}
pcall_t *pc = &t->calls[t->ncalls++];
memset(pc, 0, sizeof(*pc));
pc->t_exec_start = pc->t_exec_end = -1;
return pc;
}
static void turn_free(turn_t *t) {
for (int i = 0; i < t->ncalls; i++) {
cst_tool_call_clear(&t->calls[i].call);
free(t->calls[i].raw_args);
free(t->calls[i].output);
}
free(t->calls);
t->calls = NULL;
t->ncalls = t->cap = 0;
cst_buf_free(&t->text);
cst_buf_free(&t->raw);
if (t->mm)
mm_scan_destroy(t->mm);
if (t->ctl)
ctl_parser_destroy(t->ctl);
t->mm = NULL;
t->ctl = NULL;
}
static uint64_t call_hash(const ctl_tool_call_t *c) {
uint64_t h = cst_fnv1a64(CST_FNV_INIT, c->name, strlen(c->name));
for (int i = 0; i < c->nargs; i++) {
h = cst_fnv1a64(h, "\x1f", 1);
h = cst_fnv1a64(h, c->args[i].key, strlen(c->args[i].key));
h = cst_fnv1a64(h, "=", 1);
if (c->args[i].value)
h = cst_fnv1a64(h, c->args[i].value, c->args[i].value_len);
}
return h;
}
static void args_detail(const ctl_tool_call_t *c, char *out, size_t n) {
size_t used = 0;
out[0] = 0;
for (int i = 0; i < c->nargs && used + 8 < n; i++) {
const char *v = c->args[i].value ? c->args[i].value : "";
int k = snprintf(out + used, n - used, "%s%s=%.*s", i ? " " : "",
c->args[i].key, (int)(c->args[i].value_len > 160
? 160
: c->args[i].value_len),
v);
if (k < 0)
break;
used += (size_t)k;
if (used >= n)
used = n - 1;
}
}
static void set_output(pcall_t *pc, const char *s, size_t n, int code) {
free(pc->output);
size_t keep = n;
int trunc = 0;
if (keep > TOOL_OUT_CAP) {
keep = cst_utf8_floor(s, TOOL_OUT_CAP);
trunc = 1;
}
cst_buf_t b = {0};
cst_buf_append(&b, s ? s : "", keep);
if (trunc)
cst_buf_printf(&b, "\n...(truncated, %zu bytes total)", n);
pc->output_len = b.len;
pc->output = cst_buf_steal(&b);
pc->exit_code = code;
}
static void append_output(pcall_t *pc, const char *note) {
cst_buf_t b = {0};
cst_buf_append(&b, pc->output ? pc->output : "", pc->output_len);
cst_buf_puts(&b, note);
free(pc->output);
pc->output_len = b.len;
pc->output = cst_buf_steal(&b);
}
static void emit_call_metrics(turn_t *t, const pcall_t *pc) {
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "type", "call");
cJSON_AddNumberToObject(o, "turn", t->a->turn_no);
cJSON_AddNumberToObject(o, "step", t->step);
cJSON_AddStringToObject(o, "protocol", cst_protocol_name(t->proto));
cJSON_AddStringToObject(o, "origin", pc->origin ? pc->origin : "");
cJSON_AddStringToObject(o, "id", pc->call.id);
cJSON_AddStringToObject(o, "name", pc->call.name);
cJSON_AddNumberToObject(o, "t_call_open", ms2(pc->t_open));
cJSON_AddNumberToObject(o, "t_call_ready", ms2(pc->t_ready));
cJSON_AddNumberToObject(o, "t_exec_start", ms2(pc->t_exec_start));
cJSON_AddNumberToObject(o, "t_exec_end", ms2(pc->t_exec_end));
cJSON_AddNumberToObject(o, "exit", pc->exit_code);
cJSON_AddNumberToObject(o, "bytes_out", (double)pc->output_len);
static const char *const st[] = {"ok", "denied", "dup", "unknown",
"invalid_args"};
cJSON_AddStringToObject(o, "status", st[pc->status]);
cst_metrics_emit(t->a->metrics, o);
}
static void undo_snapshot(cst_agent_t *a, const ctl_tool_call_t *c) {
char paths[CST_TOOL_MAX_TARGETS][1024];
int n = cst_tool_targets(c, paths, CST_TOOL_MAX_TARGETS);
if (n <= 0)
return;
char sid[256];
if (a->cfg.undo_sid[0])
snprintf(sid, sizeof(sid), "%s", a->cfg.undo_sid);
else
cst_agent_session_id(a, sid, sizeof(sid));
for (int i = 0; i < n; i++)
cst_undo_snapshot(a->cfg.workspace_root, sid, a->undo_tag, paths[i]);
}
void cst_agent_undo_key(const cst_agent_t *a, char *sid, size_t sid_sz,
char *tag, size_t tag_sz) {
if (a->cfg.undo_sid[0])
snprintf(sid, sid_sz, "%s", a->cfg.undo_sid);
else
cst_agent_session_id(a, sid, sid_sz);
snprintf(tag, tag_sz, "%s", a->undo_tag);
}
static void agent_set_todo(const char *text, void *ud) {
turn_t *t = ud;
free(t->a->todo);
t->a->todo = text ? strdup(text) : NULL;
if (t->hooks && t->hooks->on_todo)
t->hooks->on_todo(text, t->hooks->userdata);
}
static int agent_ask(const char *q, const char *opts, char *ans, size_t n,
void *ud) {
turn_t *t = ud;
return t->hooks->on_ask_user(q, opts, ans, n, t->hooks->userdata);
}
static const char *tool_cmd(const ctl_tool_call_t *c) {
for (int i = 0; i < c->nargs; i++) {
if (strcmp(c->args[i].key, "cmd") == 0 ||
strcmp(c->args[i].key, "command") == 0)
return c->args[i].value ? c->args[i].value : "";
}
return NULL;
}
static int needs_confirm(const cst_agent_config_t *cfg, cst_tool_perm_t p) {
switch (p) {
case CST_TP_SHELL:
return cfg->confirm_shell;
case CST_TP_WRITE:
return cfg->confirm_write;
case CST_TP_NET:
return cfg->confirm_net;
default:
return 0;
}
}
/* A sandboxed run_shell looked blocked: ask whether to rerun it outside the
* sandbox. On yes, *tr is replaced by the unsandboxed result; on no, a note
* tells the model why it failed. */
static void sandbox_escalate(turn_t *t, cst_tool_ctx_t *ctx,
const ctl_tool_call_t *c, const char *cmd,
cst_tool_result_t *tr) {
cst_agent_t *a = t->a;
int ans = CST_PERM_DENY;
if (t->hooks && t->hooks->on_permission && !a->cancel) {
size_t tail = tr->out_len > 1500 ? 1500 : tr->out_len;
cst_buf_t d = {0};
cst_buf_puts(&d, CST_PERM_SANDBOX);
if (tr->out)
cst_buf_append(&d, tr->out + tr->out_len - tail, tail);
char *detail = cst_buf_steal(&d);
hook_status(t, "blocked by the sandbox");
ans = t->hooks->on_permission(c->name, detail ? detail : CST_PERM_SANDBOX,
cmd, t->hooks->userdata);
free(detail);
}
if (ans == CST_PERM_DENY) {
const char *note =
cst_sandbox_probe() == CST_SANDBOX_LOWIL
? "\n[cst: blocked by the sandbox (writes are limited to the "
"workspace and temp dir); the user did not allow running it "
"outside]\n"
: "\n[cst: blocked by the sandbox (writes are limited to "
"the workspace and temp dir, no network); the user did "
"not allow running it outside]\n";
cst_buf_t b = {0};
if (tr->out)
cst_buf_append(&b, tr->out, tr->out_len);
cst_buf_puts(&b, note);
free(tr->out);
tr->out_len = b.len;
tr->out = cst_buf_steal(&b);
return;
}
if (ans == CST_PERM_ALWAYS_PREFIX) {
char pre[128];
cst_perms_suggest_prefix(cmd, pre, sizeof(pre));
if (pre[0])
cst_perms_allow_prefix(a->perms, pre);
} else if (ans == CST_PERM_ALWAYS_TOOL) {
cst_perms_allow_tool(a->perms, c->name);
}
hook_status(t, "rerunning outside the sandbox");
cst_tool_result_free(tr);
memset(tr, 0, sizeof(*tr));
ctx->sandbox = 0;
cst_tool_run(ctx, c, tr);
cst_buf_t b = {0};
cst_buf_puts(&b, "[cst: the sandbox blocked this command; the user allowed "
"rerunning it outside the sandbox. Output of that rerun:]\n");
if (tr->out)
cst_buf_append(&b, tr->out, tr->out_len);
free(tr->out);
tr->out_len = b.len;
tr->out = cst_buf_steal(&b);
}
/* One call through approval, execution and bookkeeping. exec_begin and
* exec_end run on the agent thread (they call hooks); exec_run may run on a
* worker thread for read-only batches. */
typedef struct {
cst_tool_ctx_t ctx;
const cst_tool_def_t *def;
const char *cmd;
uint64_t h;
int run;
int mutating;
cst_tool_result_t tr;
} exec_t;
static int fail_count(cst_agent_t *a, uint64_t h, int bump) {
for (int i = 0; i < a->nfails; i++) {
if (a->fails[i].hash == h)
return bump ? ++a->fails[i].count : a->fails[i].count;
}
if (!bump)
return 0;
int slot = a->nfails < FAIL_FP_MAX ? a->nfails++ : (int)(h % FAIL_FP_MAX);
a->fails[slot].hash = h;
a->fails[slot].count = 1;
return 1;
}
static void exec_begin(turn_t *t, pcall_t *pc, exec_t *x) {
memset(x, 0, sizeof(*x));
pc->done = 1;
cst_agent_t *a = t->a;
ctl_tool_call_t *c = &pc->call;
pc->t_exec_start = rel_ms(t);
char detail[512];
args_detail(c, detail, sizeof(detail));
const cst_tool_def_t *def = cst_tool_find(c->name);
x->def = def;
cst_tool_ctx_t *ctx = &x->ctx;
ctx->workspace_root = a->cfg.workspace_root;
ctx->compact = a->cfg.tool_profile != CST_TOOLS_MINIMAL;
ctx->set_todo = agent_set_todo;
ctx->todo_ud = t;
ctx->agent = a;
ctx->hooks = t->hooks;
if (t->hooks && t->hooks->on_ask_user) {
ctx->ask = agent_ask;
ctx->ask_ud = t;
}
if (a->cancel) {
const char *msg = "error: cancelled by the user before it ran";
set_output(pc, msg, strlen(msg), 130);
pc->status = PC_DENIED;
return;
}
if (!def || !cst_tool_active(&a->cfg, def)) {
char *names = cst_tools_names(&a->cfg);
char msg[768];
snprintf(msg, sizeof(msg), "error: unknown tool \"%.64s\" (allowed: %s)",
c->name, names ? names : "");
free(names);
set_output(pc, msg, strlen(msg), 1);
pc->status = PC_UNKNOWN;
hook_status(t, "unknown tool");
return;
}
if (pc->invalid) {
const char *msg = "error: tool arguments are not a valid JSON object (or "
"exceed 1 MiB); nothing was executed";
set_output(pc, msg, strlen(msg), 1);
pc->status = PC_INVALID;
return;
}
/* a sub-agent that may write changes files like a write tool does */
x->mutating = def->perm == CST_TP_SHELL || def->perm == CST_TP_WRITE ||
(strcmp(def->name, "task") == 0 &&
!(def->parallel && def->parallel(c, def->ud)));
uint64_t h = call_hash(c);
x->h = h;
int dup = 0;
/* plugin and MCP results can change between identical calls */
int external = def->owner && strncmp(def->owner, "builtin:", 8) != 0;
if (def->perm == CST_TP_READ && !external) {
for (int i = 0; i < a->nreads; i++) {
if (a->reads[i].hash == h && a->reads[i].epoch == a->write_epoch) {
dup = 1;
break;
}
}
} else if (def->perm == CST_TP_WRITE && ctx->compact) {
/* the same write already applied and nothing changed since */
for (int i = 0; i < a->nwrites; i++) {
if (a->writes[i].hash == h && a->writes[i].epoch == a->write_epoch) {
dup = 2;
break;
}
}
}
int allow = 1, ask = 0;
const char *cmd = def->perm == CST_TP_SHELL ? tool_cmd(c) : NULL;
x->cmd = cmd;
if (!dup && needs_confirm(&a->cfg, def->perm) && !a->cfg.auto_approve &&
!cst_perms_tool_allowed(a->perms, c->name) &&
!(cmd && cst_perms_shell_allowed(a->perms, cmd))) {
ask = 1;
if (a->cfg.approval == CST_APPROVAL_AUTO) {
if (def->perm == CST_TP_WRITE) {
ask = 0;
} else if (def->perm == CST_TP_SHELL) {
int sb = cst_sandbox_probe();
if (sb != CST_SANDBOX_NONE) {
ctx->sandbox = 1;
/* the Low-IL sandbox does not stop network access */
ask = sb == CST_SANDBOX_LOWIL && cmd && cst_shell_uses_network(cmd);
} else if (cmd && cst_shell_is_safe(cmd)) {
ask = 0;
}
}
}
}
if (ask) {
if (t->hooks && t->hooks->on_permission) {
char *preview = NULL;
if (def->preview)
cst_tool_preview(ctx, c, &preview);
else if (cmd)
preview = strdup(cmd);
int ans = t->hooks->on_permission(c->name, detail[0] ? detail : c->name,
preview, t->hooks->userdata);
free(preview);
allow = ans != CST_PERM_DENY;
if (ans == CST_PERM_ALWAYS_TOOL) {
cst_perms_allow_tool(a->perms, c->name);
} else if (ans == CST_PERM_ALWAYS_PREFIX && cmd) {
char pre[128];
cst_perms_suggest_prefix(cmd, pre, sizeof(pre));
if (pre[0])
cst_perms_allow_prefix(a->perms, pre);
}
} else {
allow = 0;
}
}
if (dup == 2) {
const char *msg = "note: this exact edit was already applied earlier in "
"this turn and nothing changed since; it was not run "
"again. Re-read the file if unsure of its state.";
set_output(pc, msg, strlen(msg), 0);
pc->status = PC_DUP;
hook_status(t, "skip repeated edit");
} else if (dup) {
const char *msg =
strcmp(c->name, "read_file") == 0
? "note: this exact read_file already ran earlier in this turn "
"and no file changed since; reuse that output."
: "note: this exact call already ran earlier in this turn and "
"no file changed since; reuse that output.";
set_output(pc, msg, strlen(msg), 0);
pc->status = PC_DUP;
hook_status(t, "skip duplicate read");
} else if (!allow) {
set_output(pc, "denied by user", 14, 1);
pc->status = PC_DENIED;
hook_tool(t, "denied", c, detail);
} else {
hook_tool(t, "run", c, NULL);
if (def->perm == CST_TP_WRITE)
undo_snapshot(a, c);
x->run = 1;
}
}
static void exec_run(exec_t *x, const ctl_tool_call_t *c) {
cst_tool_run(&x->ctx, c, &x->tr);
}
static void exec_end(turn_t *t, pcall_t *pc, exec_t *x) {
cst_agent_t *a = t->a;
ctl_tool_call_t *c = &pc->call;
if (x->run) {
if (x->ctx.sandbox && x->cmd &&
cst_sandbox_blocked(x->tr.out, x->tr.out_len, x->tr.exit_code))
sandbox_escalate(t, &x->ctx, c, x->cmd, &x->tr);
cst_tool_result_t *tr = &x->tr;
if (x->ctx.compact) {
if (tr->exit_code != 0 && fail_count(a, x->h, 1) >= 2) {
cst_buf_t b = {0};
cst_buf_append(&b, tr->out ? tr->out : "", tr->out_len);
cst_buf_printf(&b, "\n[cst: this exact call has now failed %d times "
"in this turn. Do not repeat it: read the error, "
"inspect the code, and change approach.]",
fail_count(a, x->h, 0));
free(tr->out);
tr->out_len = b.len;
tr->out = cst_buf_steal(&b);
}
}
set_output(pc, tr->out ? tr->out : "", tr->out_len, tr->exit_code);
cst_tool_result_free(tr);
if (x->mutating)
a->write_epoch++;
if (x->def->perm == CST_TP_WRITE && pc->exit_code == 0) {
int slot = a->nwrites < FAIL_FP_MAX ? a->nwrites++
: (int)(x->h % FAIL_FP_MAX);
a->writes[slot].hash = x->h;
a->writes[slot].epoch = a->write_epoch;
} else if (pc->exit_code == 0 && x->def->perm == CST_TP_READ) {
int slot = a->nreads < READ_FP_MAX ? a->nreads++ : (int)(x->h % READ_FP_MAX);
a->reads[slot].hash = x->h;
a->reads[slot].epoch = a->write_epoch;
}
}
pc->t_exec_end = rel_ms(t);
if (t->hooks && t->hooks->on_tool_result)
t->hooks->on_tool_result(c, pc->output, pc->output_len, pc->exit_code,
t->hooks->userdata);
if (pc->status != PC_DENIED) {
char d[96];
snprintf(d, sizeof(d), "%sexit=%d bytes=%zu",
pc->status == PC_DUP ? "skipped duplicate · " : "", pc->exit_code,
pc->output_len);
hook_tool(t, "done", c, d);
}
emit_call_metrics(t, pc);
}
/* Execute (or refuse) one confirmed call. Irreversible once started. */
static void exec_call(turn_t *t, pcall_t *pc) {
if (pc->done)
return;
exec_t x;
exec_begin(t, pc, &x);
if (x.run)
exec_run(&x, &pc->call);
exec_end(t, pc, &x);
}
/* Read-only calls that may share a parallel batch. */
static int parallel_ok(const turn_t *t, const pcall_t *pc) {
if (pc->done || pc->invalid)
return 0;
const cst_tool_def_t *def = cst_tool_find(pc->call.name);
if (!def || !cst_tool_active(&t->a->cfg, def))
return 0;
return def->perm == CST_TP_READ ||
(def->parallel && def->parallel(&pc->call, def->ud));
}
typedef struct {
exec_t *x;
const ctl_tool_call_t *c;
volatile int *cancel;
} par_job_t;
static void par_main(void *p) {
par_job_t *j = p;
/* not reset after: the job may run inline on the agent thread */
os_set_cancel_flag(j->cancel);
exec_run(j->x, j->c);
}
static void exec_parallel(turn_t *t, int from, int to) {
exec_t xs[PAR_MAX];
par_job_t jobs[PAR_MAX];
void *args[PAR_MAX];
int n = 0;
for (int i = from; i < to; i++)
exec_begin(t, &t->calls[i], &xs[i - from]);
for (int i = from; i < to; i++) {
if (!xs[i - from].run)
continue;
jobs[n].x = &xs[i - from];
jobs[n].c = &t->calls[i].call;
jobs[n].cancel = &t->a->cancel;
args[n] = &jobs[n];
n++;
}
if (n > 1)
hook_status(t, "running reads in parallel");
os_run_parallel(par_main, args, n);
for (int i = from; i < to; i++)
exec_end(t, &t->calls[i], &xs[i - from]);
}
/* Calls left after the stream: consecutive read-only calls run together. */
static void exec_pending(turn_t *t) {
int par = t->a->cfg.tool_profile != CST_TOOLS_MINIMAL;
for (int i = 0; i < t->ncalls;) {
int j = i;
while (par && j < t->ncalls && j - i < PAR_MAX &&
parallel_ok(t, &t->calls[j]))
j++;
if (j - i >= 2) {
exec_parallel(t, i, j);
i = j;
} else {
exec_call(t, &t->calls[i]);
i++;
}
}
}
/* --- stream routing ------------------------------------------------------ */
static void on_text(turn_t *t, const char *s, size_t n) {
if (t->stop || !n)
return;
cst_buf_append(&t->text, s, n);
if (t->hooks && t->hooks->on_assistant_delta)
t->hooks->on_assistant_delta(s, n, t->hooks->userdata);
}
static int is_blank(const char *s, size_t n) {
for (size_t i = 0; i < n; i++) {
if (s[i] != ' ' && s[i] != '\t' && s[i] != '\r' && s[i] != '\n')
return 0;
}
return 1;
}
static void add_text_call(turn_t *t, const ctl_tool_call_t *c,
const char *origin) {
if (t->stop)
return;
if (t->final_pass) {
t->stop = 1; /* no tools allowed now: keep the text, drop the call */
return;
}
pcall_t *pc = push_call(t);
if (!pc)
return;
copy_call(&pc->call, c);
if (!pc->call.id[0])
snprintf(pc->call.id, sizeof(pc->call.id), "%c%d", origin[0],
t->ncalls);
pc->origin = origin;
pc->t_open = cst_protocol_is_ctl(t->proto) ? t->ctl_open_t : rel_ms(t);
pc->t_ready = rel_ms(t);
hook_tool(t, "ready", &pc->call, NULL);
t->saw_ready = 1;
t->raw_mark = t->raw.len;
if (cst_protocol_is_eager(t->proto))
exec_call(t, &t->calls[t->ncalls - 1]);
}
static void on_ctl(const ctl_event_t *ev, void *ud) {
turn_t *t = ud;
switch (ev->type) {
case CTL_EV_TEXT:
if (t->saw_ready && !is_blank(ev->text, ev->text_len)) {
/* prose after a confirmed call: likely an invented result. Stop. */
t->stop = 1;
return;
}
on_text(t, ev->text, ev->text_len);
break;
case CTL_EV_CALL_OPEN:
t->ctl_open_t = rel_ms(t);
if (!t->stop)
hook_tool(t, "open", ev->call, NULL);
break;
case CTL_EV_ARG:
if (!t->stop)
hook_tool(t, "arg", ev->call, NULL);
break;
case CTL_EV_CALL_READY:
add_text_call(t, ev->call, "ctl");
break;
case CTL_EV_ERROR:
t->parse_error = 1;
snprintf(t->perr, sizeof(t->perr), "%s", ev->error ? ev->error : "ctl");
break;
default:
break;
}
}
static void on_mm(const ctl_event_t *ev, void *ud) {
turn_t *t = ud;
switch (ev->type) {
case CTL_EV_TEXT:
on_text(t, ev->text, ev->text_len);
break;
case CTL_EV_CALL_OPEN:
case CTL_EV_ARG:
hook_tool(t, ev->type == CTL_EV_ARG ? "arg" : "open", ev->call, NULL);
break;
case CTL_EV_CALL_READY:
/* native modes: XML is only a fallback when no native call came in */
if (cst_protocol_is_native(t->proto) && t->n_native > 0)
return;
add_text_call(t, ev->call, "xml");
break;
case CTL_EV_ERROR:
t->parse_error = 1;
snprintf(t->perr, sizeof(t->perr), "%s", ev->error ? ev->error : "xml");
break;
default:
break;
}
}
static void feed_visible(turn_t *t, const char *data, size_t len) {
if (!len || t->stop)
return;
cst_buf_append(&t->raw, data, len);
if (t->ctl)
ctl_parser_feed(t->ctl, data, len);
else if (t->mm)
mm_scan_feed(t->mm, data, len);
}
static void think_out(turn_t *t, const char *s, size_t n) {
if (t->hooks && t->hooks->on_think_delta && n)
t->hooks->on_think_delta(s, n, t->hooks->userdata);
}
/* Strip <think>...</think> (MiniMax inline / reasoning_content wrapper). */
static void on_delta(const char *data, size_t len, void *ud) {
turn_t *t = ud;
for (size_t i = 0; i < len; i++) {
char c = data[i];
const char *want = t->in_think ? "</think>" : "<think>";
size_t wl = strlen(want);
if (t->taglen == 0 && c != '<') {
if (t->in_think)
think_out(t, &c, 1);
else
feed_visible(t, &c, 1);
continue;
}
t->tagbuf[t->taglen++] = c;
if (t->taglen <= wl && strncmp(want, t->tagbuf, t->taglen) == 0) {
if (t->taglen == wl) {
t->in_think = !t->in_think;
t->taglen = 0;
}
continue;
}
/* not a tag: flush all but a possible new '<' start */
size_t flush = t->taglen;
int restart = 0;
if (c == '<' && t->taglen > 1) {
flush--;
restart = 1;
}
if (t->in_think)
think_out(t, t->tagbuf, flush);
else
feed_visible(t, t->tagbuf, flush);
t->taglen = 0;
if (restart)
t->tagbuf[t->taglen++] = '<';
}
}
/* A native call starts: release prose still held by the <think> splitter or
* the XML scanner so it reaches the UI before the tool card. */
static void flush_held_text(turn_t *t) {
if (t->taglen && !t->in_think) {
feed_visible(t, t->tagbuf, t->taglen);
t->taglen = 0;
}
if (t->mm)
mm_scan_finish(t->mm);
}
static void on_native_open(int index, const char *id, const char *name,
void *ud) {
turn_t *t = ud;
flush_held_text(t);
if (index >= 0 && index < 64)
t->native_open_t[index] = rel_ms(t);
ctl_tool_call_t tmp;
memset(&tmp, 0, sizeof(tmp));
snprintf(tmp.id, sizeof(tmp.id), "%s", id ? id : "");
snprintf(tmp.name, sizeof(tmp.name), "%s", name ? name : "");
hook_tool(t, "open", &tmp, NULL);
}
static void on_native_tool(const char *id, const char *name,
const char *args_json, void *ud) {
turn_t *t = ud;
if (!name || t->final_pass)
return;
pcall_t *pc = push_call(t);
if (!pc)
return;
int idx = t->n_native++;
if (cst_tool_call_from_native(&pc->call, id, name, args_json) != 0)
pc->invalid = 1;
if (!pc->call.id[0])
snprintf(pc->call.id, sizeof(pc->call.id), "call_%d_%d", t->step, idx + 1);
pc->raw_args = strdup(args_json && !pc->invalid ? args_json : "{}");
pc->origin = "native";
pc->t_open = idx < 64 && t->native_open_t[idx] > 0 ? t->native_open_t[idx]
: rel_ms(t);
pc->t_ready = rel_ms(t);
hook_tool(t, "ready", &pc->call, NULL);
if (t->proto == CST_PROTO_NATIVE_INC)
exec_call(t, &t->calls[t->ncalls - 1]);
}
/* --- request building ---------------------------------------------------- */
typedef struct {
cst_message_t *msgs;
int n;
char **owned;
int nowned;
} req_msgs_t;
static void req_msgs_free(req_msgs_t *r) {
for (int i = 0; i < r->nowned; i++)
free(r->owned[i]);
free(r->owned);
free(r->msgs);
memset(r, 0, sizeof(*r));
}
static char *own(req_msgs_t *r, char *s) {
if (s)
r->owned[r->nowned++] = s;
return s;
}
static char *result_frame(const char *id, int exit_code, const char *body,
size_t n, size_t cap) {
size_t keep = n;
cst_buf_t b = {0};
char note[96];
note[0] = 0;
if (cap && n > cap) {
keep = cst_utf8_floor(body, cap);
snprintf(note, sizeof(note), "\n…(%zu more bytes not shown)", n - keep);
}
cst_buf_printf(&b, "result %s exit=%d %zu\n", id, exit_code,
keep + strlen(note));
cst_buf_append(&b, body, keep);
cst_buf_puts(&b, note);
cst_buf_puts(&b, "\nend");
return cst_buf_steal(&b);
}
static void render_value(cst_buf_t *b, cst_protocol_t p, const char *key,
const char *v) {
size_t n = strlen(v);
if (p == CST_PROTO_XML) {
cst_buf_printf(b, "<parameter name=\"%s\">%s</parameter>\n", key, v);
} else if (p == CST_PROTO_CTL_HEREDOC) {
cst_buf_printf(b, "arg %s <<EOF_%s\n%s%s", key, key, v,
(n && v[n - 1] == '\n') ? "" : "\n");
cst_buf_printf(b, "EOF_%s\n", key);
} else {
cst_buf_printf(b, "arg %s %zu\n", key, n);
cst_buf_append(b, v, n);
cst_buf_puts(b, "\n");
}
}
/* Native assistant tool_calls rendered as text-protocol syntax. */
static char *render_calls_as_text(cst_protocol_t p, const char *content,
const char *tool_calls_json) {
cst_buf_t b = {0};
if (content && content[0]) {
cst_buf_puts(&b, content);
cst_buf_puts(&b, "\n");
}
cJSON *arr = cJSON_Parse(tool_calls_json);
const cJSON *tc = NULL;
cJSON_ArrayForEach(tc, arr) {
const cJSON *id = cJSON_GetObjectItemCaseSensitive(tc, "id");
const cJSON *fn = cJSON_GetObjectItemCaseSensitive(tc, "function");
const cJSON *nm = cJSON_GetObjectItemCaseSensitive(fn, "name");
const cJSON *ar = cJSON_GetObjectItemCaseSensitive(fn, "arguments");
const char *name = cJSON_IsString(nm) ? nm->valuestring : "tool";
if (p == CST_PROTO_XML)
cst_buf_printf(&b, "<minimax:tool_call>\n<invoke name=\"%s\">\n", name);
else
cst_buf_printf(&b, "call %s %s\n",
cJSON_IsString(id) ? id->valuestring : "c", name);
cJSON *args = cJSON_IsString(ar) ? cJSON_Parse(ar->valuestring) : NULL;
const cJSON *it = NULL;
cJSON_ArrayForEach(it, args) {
if (cJSON_IsString(it)) {
render_value(&b, p, it->string, it->valuestring);
} else {
char *v = cJSON_PrintUnformatted(it);
render_value(&b, p, it->string, v ? v : "");
free(v);
}
}
cJSON_Delete(args);
cst_buf_puts(&b, p == CST_PROTO_XML ? "</invoke>\n</minimax:tool_call>\n"
: "end\n");
}
cJSON_Delete(arr);
return cst_buf_steal(&b);
}
static int ctx_window(void) {
const char *e = getenv("CST_CTX_WINDOW");
if (e && atoi(e) > 0)
return atoi(e);
return CST_AGENT_CTX_WINDOW;
}
/* Big tool outputs from turns this far back go out as a one-line stub (the
* session file keeps them whole). */
#define STUB_TURNS 3
#define STUB_MIN (2 * 1024)
/* "path"/"pattern"/"cmd"/"url" argument of call id in a tool_calls array. */
static void call_hint(const cJSON *calls, const char *id, char *out,
size_t n) {
out[0] = 0;
const cJSON *tc = NULL;
cJSON_ArrayForEach(tc, calls) {
const cJSON *cid = cJSON_GetObjectItemCaseSensitive(tc, "id");
if (!cJSON_IsString(cid) || strcmp(cid->valuestring, id) != 0)
continue;
const cJSON *fn = cJSON_GetObjectItemCaseSensitive(tc, "function");
const cJSON *ar = cJSON_GetObjectItemCaseSensitive(fn, "arguments");
cJSON *args = cJSON_IsString(ar) ? cJSON_Parse(ar->valuestring) : NULL;
static const char *const keys[] = {"path", "pattern", "cmd", "command",
"url"};
for (size_t k = 0; k < sizeof(keys) / sizeof(keys[0]); k++) {
const cJSON *v = cJSON_GetObjectItemCaseSensitive(args, keys[k]);
if (cJSON_IsString(v)) {
size_t l = strlen(v->valuestring);
size_t keep = cst_utf8_floor(v->valuestring, l > 80 ? 80 : l);
snprintf(out, n, " %.*s%s", (int)keep, v->valuestring,
keep < l ? "…" : "");
for (char *q = out; *q; q++)
if (*q == '\n')
*q = ' ';
break;
}
}
cJSON_Delete(args);
return;
}
}
static char *stub_text(const char *name, const char *hint, const char *body,
size_t n) {
size_t lines = 0;
for (size_t i = 0; i < n; i++)
lines += body[i] == '\n';
if (n && body[n - 1] != '\n')
lines++;
cst_buf_t b = {0};
cst_buf_printf(&b,
"[output elided: %s%s returned %zu line%s (%.1f KB) in an "
"earlier turn; call it again if you still need it]",
name && name[0] ? name : "tool", hint, lines,
lines == 1 ? "" : "s", (double)n / 1024.0);
return cst_buf_steal(&b);
}
static int build_request_msgs(cst_agent_t *a, cst_protocol_t proto,
const char *nudge, req_msgs_t *r) {
memset(r, 0, sizeof(*r));
int n = cst_session_count(a->session);
r->msgs = calloc((size_t)n + 2, sizeof(cst_message_t));
r->owned = calloc((size_t)n + 3, sizeof(char *));
if (!r->msgs || !r->owned) {
req_msgs_free(r);
return -1;
}
r->msgs[r->n].role = CST_ROLE_SYSTEM;
r->msgs[r->n].content =
own(r, cst_agent_build_prompt(&a->cfg, proto, a->notes));
r->n++;
int nonsys = 0;
for (int i = 0; i < n; i++) {
if (cst_session_get(a->session, i)->role != CST_ROLE_SYSTEM)
nonsys++;
}
int skip = nonsys > ctx_window() ? nonsys - ctx_window() : 0;
int text_proto = cst_protocol_is_text(proto);
int leading = 1;
/* real user messages after each index (drives stubbing) */
int *later = NULL;
if (a->cfg.tool_profile != CST_TOOLS_MINIMAL && n > 0) {
later = calloc((size_t)n, sizeof(int));
int cnt = 0;
for (int i = n - 1; later && i >= 0; i--) {
later[i] = cnt;
const cst_message_t *m = cst_session_get(a->session, i);
if (m->role == CST_ROLE_USER && !m->tool_call_id[0])
cnt++;
}
}
cJSON *calls = NULL;
for (int i = 0; i < n; i++) {
const cst_message_t *m = cst_session_get(a->session, i);
if (m->role == CST_ROLE_SYSTEM)
continue;
if (m->role == CST_ROLE_ASSISTANT && later) {
cJSON_Delete(calls);
calls = m->tool_calls_json && later[i] >= STUB_TURNS
? cJSON_Parse(m->tool_calls_json)
: NULL;
}
if (skip > 0) {
skip--;
continue;
}
int stub = later && later[i] >= STUB_TURNS && m->content &&
strlen(m->content) > STUB_MIN &&
(m->role == CST_ROLE_TOOL ||
(m->role == CST_ROLE_USER && m->tool_call_id[0]));
/* never start the window with orphan results */
if (leading && (m->role == CST_ROLE_TOOL ||
(m->role == CST_ROLE_USER && m->tool_call_id[0])))
continue;
leading = 0;
cst_message_t mm = *m;
if (text_proto && m->role == CST_ROLE_ASSISTANT && m->tool_calls_json) {
mm.content = own(r, render_calls_as_text(proto, m->content,
m->tool_calls_json));
mm.tool_calls_json = NULL;
} else if (text_proto && m->role == CST_ROLE_TOOL) {
int code = 0;
const char *ex = m->content ? strstr(m->content, " exit=") : NULL;
if (ex)
code = atoi(ex + 6);
const char *body = m->content ? m->content : "";
const char *nl = strchr(body, '\n');
if (strncmp(body, "[tool ", 6) == 0 && nl)
body = nl + 1;
char *st = NULL;
if (stub) {
char hint[128];
call_hint(calls, m->tool_call_id, hint, sizeof(hint));
st = stub_text(m->tool_name, hint, body, strlen(body));
if (st)
body = st;
}
mm.role = CST_ROLE_USER;
mm.content = own(r, result_frame(m->tool_call_id, code, body,
strlen(body), a->cfg.tool_out_cap));
free(st);
} else if (stub && m->role == CST_ROLE_TOOL) {
const char *nl = strchr(m->content, '\n');
size_t hl = nl && strncmp(m->content, "[tool ", 6) == 0
? (size_t)(nl - m->content) + 1
: 0;
char hint[128];
call_hint(calls, m->tool_call_id, hint, sizeof(hint));
char *st = stub_text(m->tool_name, hint, m->content + hl,
strlen(m->content + hl));
cst_buf_t b = {0};
cst_buf_append(&b, m->content, hl);
cst_buf_puts(&b, st ? st : "");
free(st);
mm.content = own(r, cst_buf_steal(&b));
} else if (stub && text_proto && strncmp(m->content, "result ", 7) == 0) {
/* text-protocol frame: "result <id> exit=<c> <n>\n<body>\nend" */
const char *ex = strstr(m->content, " exit=");
const char *nl = strchr(m->content, '\n');
size_t len = strlen(m->content);
if (ex && nl && len >= 4 && strcmp(m->content + len - 4, "\nend") == 0 &&
nl < m->content + len - 4) {
const char *body = nl + 1;
size_t bn = (size_t)(m->content + len - 4 - body);
char *st = stub_text(m->tool_name, "", body, bn);
if (st)
mm.content = own(r, result_frame(m->tool_call_id, atoi(ex + 6), st,
strlen(st), 0));
free(st);
}
}
r->msgs[r->n++] = mm;
}
cJSON_Delete(calls);
free(later);
if (nudge) {
r->msgs[r->n].role = CST_ROLE_USER;
r->msgs[r->n].content = (char *)nudge;
r->n++;
}
return 0;
}
/* --- persistence --------------------------------------------------------- */
static char *meta_json(const char *kind, cst_protocol_t p) {
cJSON *o = cJSON_CreateObject();
if (kind)
cJSON_AddStringToObject(o, "kind", kind);
cJSON_AddStringToObject(o, "protocol", cst_protocol_name(p));
char *s = cJSON_PrintUnformatted(o);
cJSON_Delete(o);
return s;
}
static void append_msg(cst_agent_t *a, cst_role_t role, const char *content,
const char *tool_id, const char *tool_name,
const char *tool_calls_json, char *meta) {
cst_message_t m;
memset(&m, 0, sizeof(m));
m.role = role;
m.content = (char *)(content ? content : "");
if (tool_id)
snprintf(m.tool_call_id, sizeof(m.tool_call_id), "%s", tool_id);
if (tool_name)
snprintf(m.tool_name, sizeof(m.tool_name), "%s", tool_name);
m.tool_calls_json = (char *)tool_calls_json;
m.meta_json = meta;
cst_session_append(a->session, &m);
free(meta);
}
static char *build_tool_calls_json(const turn_t *t) {
cJSON *arr = cJSON_CreateArray();
for (int i = 0; i < t->ncalls; i++) {
const pcall_t *pc = &t->calls[i];
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "id", pc->call.id);
cJSON_AddStringToObject(o, "type", "function");
cJSON *fn = cJSON_AddObjectToObject(o, "function");
cJSON_AddStringToObject(fn, "name", pc->call.name);
if (pc->raw_args) {
cJSON_AddStringToObject(fn, "arguments", pc->raw_args);
} else {
cJSON *args = cJSON_CreateObject();
for (int k = 0; k < pc->call.nargs; k++)
cJSON_AddStringToObject(args, pc->call.args[k].key,
pc->call.args[k].value ? pc->call.args[k].value
: "");
char *s = cJSON_PrintUnformatted(args);
cJSON_Delete(args);
cJSON_AddStringToObject(fn, "arguments", s ? s : "{}");
free(s);
}
cJSON_AddItemToArray(arr, o);
}
char *s = cJSON_PrintUnformatted(arr);
cJSON_Delete(arr);
return s;
}
static void persist_calls(turn_t *t) {
cst_agent_t *a = t->a;
if (cst_protocol_is_text(t->proto)) {
size_t n = t->stop ? t->raw_mark : t->raw.len;
char *raw = malloc(n + 1);
if (raw) {
memcpy(raw, t->raw.buf ? t->raw.buf : "", n);
raw[n] = 0;
}
append_msg(a, CST_ROLE_ASSISTANT, raw ? raw : "", NULL, NULL, NULL,
meta_json(NULL, t->proto));
free(raw);
for (int i = 0; i < t->ncalls; i++) {
pcall_t *pc = &t->calls[i];
char *frame = result_frame(pc->call.id, pc->exit_code,
pc->output ? pc->output : "", pc->output_len,
a->cfg.tool_out_cap);
append_msg(a, CST_ROLE_USER, frame, pc->call.id, pc->call.name, NULL,
meta_json("tool_result", t->proto));
free(frame);
}
return;
}
char *tcj = build_tool_calls_json(t);
append_msg(a, CST_ROLE_ASSISTANT, t->text.buf ? trim_left(t->text.buf) : "",
NULL, NULL, tcj, meta_json(NULL, t->proto));
free(tcj);
for (int i = 0; i < t->ncalls; i++) {
pcall_t *pc = &t->calls[i];
cst_buf_t b = {0};
cst_buf_printf(&b, "[tool %s id=%s exit=%d]\n", pc->call.name, pc->call.id,
pc->exit_code);
cst_buf_append(&b, pc->output ? pc->output : "", pc->output_len);
append_msg(a, CST_ROLE_TOOL, b.buf, pc->call.id, pc->call.name, NULL,
meta_json(NULL, t->proto));
cst_buf_free(&b);
}
}
/* --- agent lifecycle ----------------------------------------------------- */
void cst_agent_session_id(const cst_agent_t *a, char *out, size_t out_sz) {
if (!out || !out_sz)
return;
out[0] = 0;
if (!a)
return;
const char *p = a->cfg.session_path;
const char *sl = strrchr(p, '/');
const char *base = sl ? sl + 1 : p;
size_t n = strlen(base);
if (n >= out_sz)
n = out_sz - 1;
memcpy(out, base, n);
out[n] = 0;
if (n > 6 && strcmp(out + n - 6, ".jsonl") == 0)
out[n - 6] = 0;
}
const char *cst_agent_todo(const cst_agent_t *a) { return a ? a->todo : NULL; }
const char *cst_agent_notes(const cst_agent_t *a) {
return a ? a->notes : NULL;
}
long cst_agent_last_prompt_tokens(const cst_agent_t *a) {
return a ? a->last_prompt_tokens : 0;
}
void cst_agent_reload_notes(cst_agent_t *a) {
if (!a)
return;
free(a->notes);
a->notes = NULL;
const char *e = getenv("CST_NO_PROJECT_NOTES");
if (e && e[0] == '1')
return;
static const char *const names[] = {"AGENTS.md", "CST.md"};
for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) {
char p[4200];
snprintf(p, sizeof(p), "%s/%s", a->cfg.workspace_root, names[i]);
FILE *fp = fopen(p, "rb");
if (!fp)
continue;
cst_buf_t b = {0};
char tmp[4096];
size_t n;
while ((n = fread(tmp, 1, sizeof(tmp), fp)) > 0 &&
b.len < PROJECT_NOTES_MAX)
cst_buf_append(&b, tmp, n);
fclose(fp);
if (b.len && b.buf) {
size_t clean_len = 0;
size_t bom = cst_bom_len(b.buf, b.len);
a->notes = cst_utf8_sanitize(b.buf + bom, b.len - bom, &clean_len);
}
cst_buf_free(&b);
return;
}
}
/* Latest todo_write result in the session (tool output lists `[ ] x`). */
/* Rough context size of a reopened session until the next real usage
* report (about 4 bytes per token). */
static void estimate_tokens(cst_agent_t *a) {
size_t bytes = 0;
cst_session_lock(a->session);
for (int i = 0; i < cst_session_count(a->session); i++) {
const cst_message_t *m = cst_session_get(a->session, i);
bytes += (m->content ? strlen(m->content) : 0) +
(m->tool_calls_json ? strlen(m->tool_calls_json) : 0);
}
cst_session_unlock(a->session);
a->last_prompt_tokens = (long)(bytes / 4);
}
static void restore_todo(cst_agent_t *a) {
free(a->todo);
a->todo = NULL;
cst_session_lock(a->session);
int n = cst_session_count(a->session);
for (int i = n - 1; i >= 0; i--) {
const cst_message_t *m = cst_session_get(a->session, i);
if (!m || strcmp(m->tool_name, "todo_write") != 0 || !m->content)
continue;
cst_buf_t b = {0};
const char *p = m->content;
while (*p) {
const char *e = strchr(p, '\n');
size_t len = e ? (size_t)(e - p) : strlen(p);
if (len >= 3 && p[0] == '[' && p[2] == ']' &&
(p[1] == ' ' || p[1] == 'x' || p[1] == '~')) {
cst_buf_append(&b, p, len);
cst_buf_puts(&b, "\n");
}
if (!e)
break;
p = e + 1;
}
if (b.len) {
a->todo = cst_buf_steal(&b);
break;
}
cst_buf_free(&b);
}
cst_session_unlock(a->session);
}
int cst_agent_undo(cst_agent_t *a, char *report, size_t report_sz) {
if (!a || !report || !report_sz)
return -1;
char sid[256];
cst_agent_session_id(a, sid, sizeof(sid));
int rc = cst_undo_restore_last(a->cfg.workspace_root, sid, report,
report_sz);
if (rc == 0)
a->write_epoch++;
return rc;
}
cst_perms_t *cst_agent_perms(cst_agent_t *a) { return a ? a->perms : NULL; }
/* Drop the last user prompt and everything after it; returns the prompt. */
char *cst_agent_rewind_last_turn(cst_agent_t *a) {
if (!a || !a->session)
return NULL;
cst_session_t *s = a->session;
cst_session_lock(s);
int n = cst_session_count(s), idx = -1;
for (int i = n - 1; i > 0; i--) {
const cst_message_t *m = cst_session_get(s, i);
if (m->role == CST_ROLE_USER && !m->tool_call_id[0] && m->content &&
strncmp(m->content, "[session compacted", 18) != 0) {
idx = i;
break;
}
}
char *prompt = idx > 0 ? strdup(cst_session_get(s, idx)->content) : NULL;
cst_session_unlock(s);
if (!prompt)
return NULL;
if (cst_session_rewrite(s, cst_session_get(s, 0), idx) != 0) {
free(prompt);
return NULL;
}
a->write_epoch++;
return prompt;
}
int cst_agent_undo_available(const cst_agent_t *a) {
if (!a)
return 0;
char sid[256];
cst_agent_session_id(a, sid, sizeof(sid));
return cst_undo_count(a->cfg.workspace_root, sid);
}
static void seed_system(cst_agent_t *a) {
if (cst_session_count(a->session) == 0) {
char *sp = cst_agent_build_prompt(&a->cfg, a->cfg.protocol, a->notes);
append_msg(a, CST_ROLE_SYSTEM, sp, NULL, NULL, NULL,
meta_json(NULL, a->cfg.protocol));
free(sp);
}
}
cst_agent_t *cst_agent_create(const cst_agent_config_t *cfg) {
if (!cfg)
return NULL;
cst_agent_t *a = calloc(1, sizeof(*a));
if (!a)
return NULL;
a->cfg = *cfg;
if (a->cfg.max_steps <= 0)
a->cfg.max_steps = CST_DEFAULT_MAX_STEPS;
if (a->cfg.tool_out_cap == 0)
a->cfg.tool_out_cap = CST_DEFAULT_TOOL_OUT_CAP;
a->session = cst_session_open(cfg->session_path[0] ? cfg->session_path
: "session.jsonl");
if (!a->session) {
free(a);
return NULL;
}
a->metrics = cst_metrics_open(a->cfg.workspace_root,
cst_session_path(a->session));
snprintf(a->cfg.session_path, sizeof(a->cfg.session_path), "%s",
cst_session_path(a->session));
a->perms = cst_perms_open(a->cfg.workspace_root);
cst_agent_reload_notes(a);
seed_system(a);
restore_todo(a);
estimate_tokens(a);
return a;
}
void cst_agent_destroy(cst_agent_t *a) {
if (!a)
return;
os_set_cancel_flag(NULL);
cst_metrics_close(a->metrics);
cst_session_close(a->session);
free(a->notes);
free(a->todo);
cst_perms_close(a->perms);
free(a);
}
cst_session_t *cst_agent_session(cst_agent_t *a) { return a ? a->session : NULL; }
const cst_agent_config_t *cst_agent_config(const cst_agent_t *a) {
return a ? &a->cfg : NULL;
}
cst_agent_config_t *cst_agent_config_mut(cst_agent_t *a) {
return a ? &a->cfg : NULL;
}
void cst_agent_request_cancel(cst_agent_t *a) {
if (a)
a->cancel = 1;
}
int cst_agent_run_turn(cst_agent_t *a, const char *user_text, char *err,
size_t err_sz) {
return cst_agent_run_turn_ex(a, user_text, NULL, err, err_sz);
}
static void emit_step_metrics(turn_t *t, const cst_llm_stats_t *st, int lrc) {
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "type", "step");
cJSON_AddNumberToObject(o, "turn", t->a->turn_no);
cJSON_AddNumberToObject(o, "step", t->step);
cJSON_AddStringToObject(o, "protocol", cst_protocol_name(t->proto));
cJSON_AddStringToObject(o, "model", t->a->cfg.model);
cJSON_AddBoolToObject(o, "final_pass", t->final_pass);
cJSON_AddBoolToObject(o, "ok", lrc == 0);
cJSON_AddNumberToObject(o, "http", (double)st->http_status);
cJSON_AddStringToObject(o, "finish_reason", st->finish_reason);
cJSON_AddNumberToObject(o, "t_first_byte", ms2(st->t_first_byte_ms));
cJSON_AddNumberToObject(o, "t_stream_end", ms2(st->t_end_ms));
cJSON_AddNumberToObject(o, "prompt_tokens", (double)st->prompt_tokens);
cJSON_AddNumberToObject(o, "completion_tokens",
(double)st->completion_tokens);
cJSON_AddNumberToObject(o, "cached_tokens", (double)st->cached_tokens);
cJSON_AddNumberToObject(o, "retries", st->retries);
cJSON_AddBoolToObject(o, "client_stopped", st->client_stopped);
cJSON_AddNumberToObject(o, "calls", t->ncalls);
cJSON_AddNumberToObject(o, "text_bytes", (double)t->text.len);
if (t->ctl) {
ctl_parser_stats_t cs;
ctl_parser_stats(t->ctl, &cs);
cJSON *c = cJSON_AddObjectToObject(o, "ctl");
cJSON_AddNumberToObject(c, "calls_open", (double)cs.calls_open);
cJSON_AddNumberToObject(c, "calls_ready", (double)cs.calls_ready);
cJSON_AddNumberToObject(c, "args_nbytes", (double)cs.args_nbytes);
cJSON_AddNumberToObject(c, "args_inline", (double)cs.args_inline);
cJSON_AddNumberToObject(c, "args_heredoc", (double)cs.args_heredoc);
cJSON_AddNumberToObject(c, "pending_peak", (double)cs.pending_peak);
cJSON_AddStringToObject(c, "error_kind",
ctl_error_kind_name(cs.error_kind));
}
if (t->parse_error)
cJSON_AddStringToObject(o, "parse_error", t->perr);
if (lrc != 0 && st->error[0])
cJSON_AddStringToObject(o, "error", st->error);
cst_metrics_emit(t->a->metrics, o);
}
static void emit_turn_metrics(cst_agent_t *a, int steps, int rc,
double total_ms, const char *err) {
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "type", "turn");
cJSON_AddNumberToObject(o, "turn", a->turn_no);
cJSON_AddStringToObject(o, "protocol", cst_protocol_name(a->cfg.protocol));
cJSON_AddStringToObject(o, "model", a->cfg.model);
cJSON_AddNumberToObject(o, "steps", steps);
cJSON_AddNumberToObject(o, "rc", rc);
cJSON_AddNumberToObject(o, "t_total", ms2(total_ms));
if (err && err[0])
cJSON_AddStringToObject(o, "error", err);
cst_metrics_emit(a->metrics, o);
}
/* Compact before a turn when the last request used most of the window. */
static void maybe_auto_compact(cst_agent_t *a, const cst_agent_hooks_t *hooks) {
const char *e = getenv("CST_AUTO_COMPACT");
if (e && e[0] == '0')
return;
long win = cst_model_context_tokens(a->cfg.model);
if (a->last_prompt_tokens <= 0 || win <= 0 ||
a->last_prompt_tokens * 10 < win * 8)
return;
if (hooks && hooks->on_status) {
char msg[128];
snprintf(msg, sizeof(msg), "context %ld%% full — compacting...",
a->last_prompt_tokens * 100 / win);
hooks->on_status(msg, hooks->userdata);
}
char err[256];
if (cst_agent_compact(a, err, sizeof(err)) == 0) {
a->last_prompt_tokens = 0;
if (hooks && hooks->on_status)
hooks->on_status("context compacted", hooks->userdata);
}
}
int cst_agent_run_turn_ex(cst_agent_t *a, const char *user_text,
const cst_agent_hooks_t *hooks, char *err,
size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
if (!a || !user_text)
return -1;
a->cancel = 0;
maybe_auto_compact(a, hooks);
a->turn_no++;
a->nreads = 0;
a->nfails = 0;
a->nwrites = 0;
{
time_t now = time(NULL);
struct tm tmv;
os_localtime(&now, &tmv);
char ts[32];
strftime(ts, sizeof(ts), "%Y%m%d-%H%M%S", &tmv);
snprintf(a->undo_tag, sizeof(a->undo_tag), "%s-%d-%03d", ts, os_getpid(),
a->turn_no % 1000);
if (a->cfg.undo_tag[0])
snprintf(a->undo_tag, sizeof(a->undo_tag), "%s", a->cfg.undo_tag);
}
os_set_cancel_flag(&a->cancel);
const cst_protocol_t proto = a->cfg.protocol;
const double turn_t0 = os_now_ms();
append_msg(a, CST_ROLE_USER, user_text, NULL, NULL, NULL, NULL);
int empty_retries = 0, ctl_retries = 0, tools_ran = 0;
const char *nudge = NULL;
int rc = -1;
int step = 0;
for (step = 0; step < a->cfg.max_steps; step++) {
turn_t t;
memset(&t, 0, sizeof(t));
t.a = a;
t.hooks = hooks;
t.proto = proto;
t.step = step;
t.final_pass = tools_ran && step == a->cfg.max_steps - 1;
if (t.final_pass)
nudge = NUDGE_FINAL;
if (cst_protocol_is_ctl(proto))
t.ctl = ctl_parser_create(on_ctl, &t);
else
t.mm = mm_scan_create(on_mm, &t);
if (!t.ctl && !t.mm) {
snprintf(err, err_sz, "parser create failed");
break;
}
req_msgs_t rm;
if (build_request_msgs(a, proto, nudge, &rm) != 0) {
turn_free(&t);
snprintf(err, err_sz, "oom");
break;
}
nudge = NULL;
{
char s[64];
snprintf(s, sizeof(s), "calling model (step %d)...", step + 1);
hook_status(&t, t.final_pass ? "final answer pass..." : s);
}
cst_llm_stats_t st;
cst_llm_request_t req;
memset(&req, 0, sizeof(req));
req.api_base = a->cfg.api_base;
req.api_key = a->cfg.api_key;
req.model = a->cfg.model;
req.reasoning_effort =
cst_effort_wire(a->cfg.provider, a->cfg.model, a->cfg.effort);
req.msgs = rm.msgs;
req.nmsgs = rm.n;
req.on_delta = on_delta;
req.userdata = &t;
req.cancel_flag = &a->cancel;
req.stop_flag = &t.stop;
req.stats = &st;
req.tool_out_cap = a->cfg.tool_out_cap;
if (cst_protocol_is_native(proto)) {
req.on_native_tool = on_native_tool;
req.on_native_open = on_native_open;
req.enable_tools = 1;
req.tool_cfg = &a->cfg;
req.tool_choice_none = t.final_pass;
}
t.t0 = os_now_ms();
char lerr[512];
int lrc = cst_llm_chat_stream(&req, lerr, sizeof(lerr));
req_msgs_free(&rm);
/* flush a dangling partial tag */
if (t.taglen && !t.in_think)
feed_visible(&t, t.tagbuf, t.taglen);
t.taglen = 0;
if (t.mm)
mm_scan_finish(t.mm);
if (t.ctl && !t.stop)
ctl_parser_finish(t.ctl); /* EOF may confirm a final call */
if (proto == CST_PROTO_XML && t.ncalls == 0 && !t.parse_error &&
t.raw.buf &&
(strstr(t.raw.buf, "<minimax:tool_call") ||
strstr(t.raw.buf, "<invoke"))) {
t.parse_error = 1;
snprintf(t.perr, sizeof(t.perr),
"malformed tool block, nothing could be parsed");
}
emit_step_metrics(&t, &st, lrc);
if (st.prompt_tokens > 0)
a->last_prompt_tokens = st.prompt_tokens;
if (lrc != 0) {
/* side effects of eagerly executed calls must still be recorded */
int kept = 0;
for (int i = 0; i < t.ncalls; i++) {
if (t.calls[i].done) {
t.calls[kept++] = t.calls[i];
} else {
cst_tool_call_clear(&t.calls[i].call);
free(t.calls[i].raw_args);
free(t.calls[i].output);
}
}
t.ncalls = kept;
if (t.ncalls > 0)
persist_calls(&t);
turn_free(&t);
snprintf(err, err_sz, "llm: %s", lerr);
break;
}
exec_pending(&t); /* eagerly executed calls are already done */
if (t.ncalls > 0 && a->cfg.tool_profile != CST_TOOLS_MINIMAL &&
a->cfg.max_steps - (step + 1) == 3)
append_output(&t.calls[t.ncalls - 1],
"\n[cst: 3 model calls left in this turn. Finish the "
"essential part now, verify it, and then summarize what "
"is done and what remains.]");
if (a->cancel) {
if (t.ncalls > 0)
persist_calls(&t);
else if (t.text.len)
append_msg(a, CST_ROLE_ASSISTANT, trim_left(t.text.buf), NULL, NULL,
NULL, meta_json("cancelled", proto));
turn_free(&t);
snprintf(err, err_sz, "cancelled");
break;
}
const char *body = t.text.buf ? trim_left(t.text.buf) : "";
int has_text = text_is_meaningful(body, strlen(body));
if (t.ncalls > 0) {
persist_calls(&t);
tools_ran = 1;
empty_retries = 0;
if (t.parse_error && cst_protocol_is_text(proto)) {
char msg[768];
snprintf(msg, sizeof(msg),
"[%s] contract error after your last confirmed call: %s. "
"Frames after it were ignored; re-send them with exact "
"syntax.",
proto == CST_PROTO_XML ? "xml" : "ctl", t.perr);
append_msg(a, CST_ROLE_USER, msg, NULL, NULL, NULL,
meta_json("ctl_error", proto));
}
turn_free(&t);
hook_status(&t, "tool round done...");
continue;
}
if (t.parse_error && cst_protocol_is_text(proto)) {
if (t.raw.len)
append_msg(a, CST_ROLE_ASSISTANT, t.raw.buf, NULL, NULL, NULL,
meta_json(NULL, proto));
char msg[768];
if (proto == CST_PROTO_XML)
snprintf(msg, sizeof(msg),
"[xml] contract error: %s. Nothing was executed. Re-send the "
"tool block with exact syntax:\n<minimax:tool_call>\n"
"<invoke name=\"TOOL\">\n<parameter name=\"KEY\">VALUE"
"</parameter>\n</invoke>\n</minimax:tool_call>",
t.perr);
else
snprintf(msg, sizeof(msg),
"[ctl] contract error: %s. Nothing was executed. Re-send the "
"call with exact CTL syntax (byte counts must be exact; use "
"inline values for short single-line arguments).",
t.perr);
append_msg(a, CST_ROLE_USER, msg, NULL, NULL, NULL,
meta_json("ctl_error", proto));
turn_free(&t);
if (++ctl_retries > MAX_CTL_RETRIES) {
snprintf(err, err_sz, "%s: repeated contract errors",
cst_protocol_name(proto));
break;
}
hook_status(&t, "contract error — asking model to re-send...");
continue;
}
if (has_text) {
append_msg(a, CST_ROLE_ASSISTANT, body, NULL, NULL, NULL,
meta_json(NULL, proto));
turn_free(&t);
hook_status(&t, "done");
rc = 0;
break;
}
turn_free(&t);
if (empty_retries < 2 && step + 1 < a->cfg.max_steps) {
empty_retries++;
nudge = NUDGE_EMPTY;
hook_status(&t, "empty response — retrying...");
continue;
}
/* Last resort: surface the latest tool output so the turn isn't blank. */
if (tools_ran) {
int n = cst_session_count(a->session);
for (int i = n - 1; i >= 0; i--) {
const cst_message_t *m = cst_session_get(a->session, i);
if (!m->tool_call_id[0] || !m->content)
continue;
cst_buf_t b = {0};
cst_buf_puts(&b, "(模型未返回正文,以下为最近工具输出)\n");
cst_buf_append(&b, m->content, cst_utf8_floor(m->content,
strlen(m->content) > 2000
? 2000
: strlen(m->content)));
append_msg(a, CST_ROLE_ASSISTANT, b.buf, NULL, NULL, NULL,
meta_json("fallback", proto));
cst_buf_free(&b);
rc = 0;
break;
}
if (rc == 0)
break;
}
snprintf(err, err_sz, "empty model response");
break;
}
if (rc != 0 && !err[0])
snprintf(err, err_sz, "step limit (%d) reached", a->cfg.max_steps);
emit_turn_metrics(a, step + (step < a->cfg.max_steps ? 1 : 0), rc,
os_now_ms() - turn_t0, err);
return rc;
}
static int agent_reopen_session(cst_agent_t *a, const char *path) {
cst_session_t *ns = cst_session_open(path);
if (!ns)
return -1;
cst_session_close(a->session);
a->session = ns;
snprintf(a->cfg.session_path, sizeof(a->cfg.session_path), "%s", path);
cst_metrics_close(a->metrics);
a->metrics = cst_metrics_open(a->cfg.workspace_root, path);
seed_system(a);
restore_todo(a);
estimate_tokens(a);
return 0;
}
int cst_agent_new_session(cst_agent_t *a, char *id_out, size_t id_sz) {
if (!a)
return -1;
if (cst_agent_store_ensure(a->cfg.workspace_root) != 0)
return -1;
char id[128];
if (cst_agent_store_new_id(id, sizeof(id)) != 0)
return -1;
char path[4096];
if (cst_agent_store_path_for_id(a->cfg.workspace_root, id, path,
sizeof(path)) != 0)
return -1;
if (agent_reopen_session(a, path) != 0)
return -1;
cst_agent_store_set_current(a->cfg.workspace_root, id);
if (id_out && id_sz)
snprintf(id_out, id_sz, "%s", id);
return 0;
}
int cst_agent_switch_session(cst_agent_t *a, const char *id) {
if (!a || !id || !id[0])
return -1;
char path[4096];
if (cst_agent_store_path_for_id(a->cfg.workspace_root, id, path,
sizeof(path)) != 0)
return -1;
if (agent_reopen_session(a, path) != 0)
return -1;
char bare[128];
snprintf(bare, sizeof(bare), "%s", id);
size_t n = strlen(bare);
if (n > 6 && strcmp(bare + n - 6, ".jsonl") == 0)
bare[n - 6] = 0;
cst_agent_store_set_current(a->cfg.workspace_root, bare);
return 0;
}
static void acc_feed(const char *data, size_t len, void *ud) {
cst_buf_append((cst_buf_t *)ud, data, len);
}
int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
if (!a || !a->session)
return -1;
int n = cst_session_count(a->session);
if (n <= 1) {
snprintf(err, err_sz, "nothing to compact");
return -1;
}
cst_buf_t blob = {0};
cst_buf_puts(&blob,
"Summarize the coding session below so work can continue from "
"the summary alone. Keep: the user's goals, files and paths "
"involved, decisions made, current state (what works, what "
"fails), and open items. Write it in the language the user "
"used. Do not call tools; output only the summary.\n\n---\n");
for (int i = 0; i < n && blob.len < 120000; i++) {
const cst_message_t *m = cst_session_get(a->session, i);
if (!m || m->role == CST_ROLE_SYSTEM)
continue;
const char *role = m->role == CST_ROLE_USER
? (m->tool_call_id[0] ? "tool" : "user")
: m->role == CST_ROLE_ASSISTANT ? "assistant"
: "tool";
const char *c = m->content ? m->content : "";
size_t cl = strlen(c);
if (cl > 2000)
cl = cst_utf8_floor(c, 2000);
cst_buf_printf(&blob, "\n[%s]\n", role);
cst_buf_append(&blob, c, cl);
}
cst_message_t reqs[2];
memset(reqs, 0, sizeof(reqs));
reqs[0].role = CST_ROLE_SYSTEM;
reqs[0].content = (char *)"You summarize coding-agent sessions. No tools.";
reqs[1].role = CST_ROLE_USER;
reqs[1].content = blob.buf;
cst_buf_t acc = {0};
cst_llm_request_t req;
memset(&req, 0, sizeof(req));
req.api_base = a->cfg.api_base;
req.api_key = a->cfg.api_key;
req.model = a->cfg.model;
req.reasoning_effort =
cst_effort_wire(a->cfg.provider, a->cfg.model, a->cfg.effort);
req.msgs = reqs;
req.nmsgs = 2;
req.on_delta = acc_feed;
req.userdata = &acc;
req.cancel_flag = &a->cancel;
char lerr[512];
lerr[0] = 0;
int rc = cst_llm_chat_stream(&req, lerr, sizeof(lerr));
cst_buf_free(&blob);
if (rc != 0 || !acc.buf || acc.len == 0) {
cst_buf_free(&acc);
snprintf(err, err_sz, "compact llm: %s", lerr[0] ? lerr : "empty");
return -1;
}
/* Strip <think>...</think> blocks from summary. */
char *src = acc.buf, *dst = acc.buf;
while (*src) {
if (strncmp(src, "<think>", 7) == 0) {
char *end = strstr(src, "</think>");
if (end) {
src = end + 8;
continue;
}
}
*dst++ = *src++;
}
*dst = 0;
acc.len = (size_t)(dst - acc.buf);
while (acc.len > 0 &&
(acc.buf[acc.len - 1] == '\n' || acc.buf[acc.len - 1] == ' '))
acc.buf[--acc.len] = 0;
if (acc.len < 8) {
cst_buf_free(&acc);
snprintf(err, err_sz, "compact produced empty summary");
return -1;
}
cst_buf_t summary = {0};
cst_buf_printf(&summary, "[session compacted — summary of earlier "
"conversation]\n%s",
trim_left(acc.buf));
cst_buf_free(&acc);
cst_message_t keep[2];
memset(keep, 0, sizeof(keep));
keep[0].role = CST_ROLE_SYSTEM;
keep[0].content = cst_agent_build_prompt(&a->cfg, a->cfg.protocol, a->notes);
keep[1].role = CST_ROLE_USER;
keep[1].content = summary.buf;
rc = cst_session_rewrite(a->session, keep, 2);
free(keep[0].content);
cst_buf_free(&summary);
if (rc != 0) {
snprintf(err, err_sz, "rewrite session failed");
return -1;
}
return 0;
}