#ifndef _DEFAULT_SOURCE #define _DEFAULT_SOURCE #endif #include "cJSON.h" #include "cst_attach.h" #include "cst_agent.h" #include "cst_mcp.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 #include #include #ifndef _WIN32 #include #endif #include #include /* --- 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 exit= \n" \ " \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 \n" "arg \n" "\n" "end\n\n" "Rules:\n" "- 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 \n" "arg <\n" "TAG\n" "end\n\n" "Rules:\n" "- 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 <\n" "\n" "README.md\n" "\n" "\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)); } /* /mode general (cst_mode_name covers "" and unknown values). */ static int general_mode(const cst_agent_config_t *cfg) { return cfg && strcmp(cst_mode_name(cfg->mode), "general") == 0; } /* Workflow for /mode general: gather, then do the work, then check it. */ static void put_workflow_general(cst_buf_t *b, const cst_agent_config_t *cfg) { cst_buf_puts(b, "\nWorkflow:\n1) Gather 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, " "and use the documents the user attached"); cst_buf_puts(b, ".\n2) Do the work"); n = 0; if (tool_on(cfg, "write_file")) { cst_buf_puts(b, ": write_file for new files"); n++; } if (tool_on(cfg, "edit_file")) cst_buf_printf(b, "%sedit_file for small exact replacements", n++ ? ", " : ": "); if (tool_on(cfg, "apply_patch")) cst_buf_printf(b, "%sapply_patch for multi-hunk diffs to existing files", n++ ? ", " : ": "); if (tool_on(cfg, "run_shell")) cst_buf_printf(b, "%srun_shell for commands, computations, data " "processing and other CLIs", n++ ? ", " : ": "); cst_buf_puts(b, ".\n"); if (tool_on(cfg, "run_shell")) cst_buf_puts(b, "3) Check the result before saying it works (re-read it, " "run it, or verify the numbers); fix and re-run until it " "holds.\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 the result.\n" "Never invent other tool names.\n"); } /* 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) { if (general_mode(cfg)) { put_workflow_general(b, cfg); return; } 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"; /* Same role, framed for /mode general: the workspace may hold documents, * data or anything else, not just code. */ static const char DEF_INTRO_GENERAL[] = "You are CST-Agent, a general-purpose assistant working in the user's " "local workspace. All tool paths are relative to the workspace root and " "shell commands run there; the material you work on may be code, " "documents, data or anything else.\n\n" "Principles:\n" "- Reply in the user's language. Be concise: lead with the result, no " "filler.\n" "- Understand before answering: read the files, documents and sources the " "task refers to instead of guessing; never invent content, numbers or " "tool results.\n" "- Do what was asked, at the scope asked: leave unrelated files, data and " "material alone.\n" "- Check your work when you can (re-read it, run it, cross-check the " "source); if you could not verify something, 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"); if (general_mode(cfg) && tool_on(cfg, "web_fetch")) cst_buf_puts(b, "- Use web_fetch for pages the user points you to; say so " "when a page could not be fetched instead of guessing its " "content.\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) { if (general_mode(cfg)) cst_buf_puts(b, "\nSafety:\n" "- Do not delete or overwrite files or data you did not " "create unless the task requires it, and do not send the " "user's content anywhere the task did not ask for. Never " "print secrets or API keys.\n"); else 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; the network works). 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: " */ 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 today(char *out, size_t n) { time_t now = time(NULL); struct tm tmv; os_localtime(&now, &tmv); strftime(out, n, "%Y-%m-%d", &tmv); } static const char *approval_desc(const cst_agent_config_t *cfg) { return 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)"; } 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)); today(day, sizeof(day)); 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 = approval_desc(cfg); 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 : general_mode(cfg) ? DEF_INTRO_GENERAL : 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; /* Images the user picked for the next turn (owned; consumed on send). */ cst_attachment_t *pending; int npending, cappending; /* Prefix cache: the request is the frozen system prompt + tools snapshot * followed by the session, which only grows until a rebase. */ cst_model_caps_t caps; char caps_model[128]; char *tools_json; /* frozen tools[] (owned) */ uint64_t tools_sig; /* tool settings it was built from */ int win_start; /* first session message still sent (chunked window) */ uint64_t *prev_hash; /* per-message hashes of the previous request */ int nprev, capprev; uint64_t prev_tools; char prev_model[128]; int rebased; /* the session was compacted since the last request */ int prefix_off; /* DeepSeek refused prefix completion */ int hints_off; /* the provider refused cache hints (HTTP 400) */ int images_off; /* the provider refused a request with images (HTTP 400) */ char images_off_model[128]; /* ...for this model */ char cache_key[128]; cst_usage_t usage, turn_usage; }; 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; cst_attachment_t *attach; /* images the tool produced (owned) */ int nattach; 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 */ cst_buf_t think; /* reasoning, kept for providers that want it back */ size_t raw_mark; int in_think; int native_opened; /* a native tool call started streaming */ int cont; /* prefix continuations done in this step */ int prefix_break; /* first message that differs from the last request */ const char *prefix_reason; 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); cst_attachments_free(t->calls[i].attach, t->calls[i].nattach); } free(t->calls); t->calls = NULL; t->ncalls = t->cap = 0; cst_buf_free(&t->text); cst_buf_free(&t->raw); cst_buf_free(&t->think); 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 = "\n[cst: blocked by the sandbox (writes are limited to the " "workspace and temp dir); 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; } /* Plan mode keeps these in tools[] (a stable prefix) and refuses here. */ if (a->cfg.plan_mode && (def->perm == CST_TP_WRITE || def->perm == CST_TP_SHELL)) { char msg[320]; snprintf(msg, sizeof(msg), "error: Plan mode is on (read-only), so %.64s was not run. Keep " "investigating with read-only tools and answer with a plan; the " "user switches to Build mode to carry it out.", c->name); set_output(pc, msg, strlen(msg), 1); pc->status = PC_DENIED; hook_tool(t, "denied", c, detail); 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) { if (cst_sandbox_probe() != CST_SANDBOX_NONE) { ctx->sandbox = 1; ask = 0; } 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); if (tr->attach && tr->nattach > 0) { cst_attachments_free(pc->attach, pc->nattach); pc->attach = cst_attachments_dup(tr->attach, tr->nattach); pc->nattach = pc->attach ? tr->nattach : 0; } 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 (t->hooks && t->hooks->on_tool_images && pc->nattach > 0) t->hooks->on_tool_images(c, pc->attach, pc->nattach, 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) { /* cut after each '\n' and '>': a call only becomes ready there, and * raw_mark must not take in what follows it in the same chunk */ while (len && !t->stop) { size_t n = 0; while (n < len && data[n] != '\n' && data[n] != '>') n++; if (n < len) n++; cst_buf_append(&t->raw, data, n); if (t->ctl) ctl_parser_feed(t->ctl, data, n); else if (t->mm) mm_scan_feed(t->mm, data, n); data += n; len -= n; } } static void think_out(turn_t *t, const char *s, size_t n) { if (n && t->a->caps.reasoning_passback) cst_buf_append(&t->think, s, n); if (t->hooks && t->hooks->on_think_delta && n) t->hooks->on_think_delta(s, n, t->hooks->userdata); } /* Strip ... (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 ? "" : ""; size_t wl = strlen(want); if (t->taglen == 0 && c != '<') { /* the whole run up to the next '<' in one call: byte by byte, a * front end would get every CJK character in three invalid pieces */ size_t j = i + 1; while (j < len && data[j] != '<') j++; if (t->in_think) think_out(t, data + i, j - i); else feed_visible(t, data + i, j - i); i = j - 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 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); t->native_opened = 1; 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; int ntail; /* trailing request-only messages (nudge, prefix) */ 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, "%s\n", key, v); } else if (p == CST_PROTO_CTL_HEREDOC) { cst_buf_printf(b, "arg %s <valuestring : "tool"; if (p == CST_PROTO_XML) cst_buf_printf(&b, "\n\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 ? "\n\n" : "end\n"); } cJSON_Delete(arr); return cst_buf_steal(&b); } int cst_message_is_note(const cst_message_t *m) { return m && m->role == CST_ROLE_USER && m->meta_json && strstr(m->meta_json, "\"kind\":\"note\""); } #define msg_is_note cst_message_is_note /* Where the request starts in the session. Normally the very first message, * so every request extends the previous one. CST_CTX_WINDOW=N keeps the * start fixed until more than N messages follow it, then jumps so N/2 * remain: one cache miss per jump instead of one per step. When the last * request filled 85% of the context window the oldest half is dropped. */ static int window_start(cst_agent_t *a, int n) { int start = a->win_start > 0 && a->win_start < n ? a->win_start : 0; const char *e = getenv("CST_CTX_WINDOW"); int win = e ? atoi(e) : 0; int count = 0; for (int i = start; i < n; i++) count += cst_session_get(a->session, i)->role != CST_ROLE_SYSTEM; int keep = -1; if (win > 0 && count > win) keep = win / 2 > 0 ? win / 2 : 1; long ctx = cst_model_context_tokens(a->cfg.model); if (ctx > 0 && a->last_prompt_tokens > ctx / 100 * 85 && count > 2) { int half = count / 2; keep = keep < 0 || half < keep ? half : keep; a->last_prompt_tokens = 0; /* once until the next usage report */ } if (keep >= 0) { int seen = 0; for (int i = n - 1; i > start; i--) { if (cst_session_get(a->session, i)->role == CST_ROLE_SYSTEM) continue; if (++seen >= keep) { start = i; break; } } } a->win_start = start; return start; } 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 + 3, sizeof(cst_message_t)); r->owned = calloc((size_t)n + 4, sizeof(char *)); if (!r->msgs || !r->owned) { req_msgs_free(r); return -1; } /* the system prompt frozen in the session (see ensure_system) */ const cst_message_t *sys = NULL; for (int i = 0; i < n && !sys; i++) { const cst_message_t *m = cst_session_get(a->session, i); if (m->role == CST_ROLE_SYSTEM) sys = m; } r->msgs[r->n].role = CST_ROLE_SYSTEM; r->msgs[r->n].content = sys ? sys->content : own(r, cst_agent_build_prompt(&a->cfg, proto, a->notes)); r->n++; int text_proto = cst_protocol_is_text(proto); int leading = 1; for (int i = window_start(a, n); i < n; i++) { const cst_message_t *m = cst_session_get(a->session, i); if (m->role == CST_ROLE_SYSTEM) continue; /* 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; mm.prefix = 0; 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; mm.role = CST_ROLE_USER; mm.content = own(r, result_frame(m->tool_call_id, code, body, strlen(body), a->cfg.tool_out_cap)); } r->msgs[r->n++] = mm; } if (nudge) { r->msgs[r->n].role = CST_ROLE_USER; r->msgs[r->n].content = (char *)nudge; r->n++; r->ntail++; } return 0; } /* Request-only assistant prefix the model continues (DeepSeek beta). */ static void push_prefix(req_msgs_t *r, const char *text) { cst_message_t *m = &r->msgs[r->n++]; memset(m, 0, sizeof(*m)); m->role = CST_ROLE_ASSISTANT; m->content = own(r, strdup(text ? text : "")); m->prefix = 1; r->ntail++; } /* --- prefix cache bookkeeping -------------------------------------------- */ static uint64_t hash_str(uint64_t h, const char *s) { h = cst_fnv1a64(h, s ? s : "", s ? strlen(s) : 0); return cst_fnv1a64(h, "\x1f", 1); } static uint64_t msg_hash(const cst_message_t *m, int with_reasoning) { uint64_t h = cst_fnv1a64(CST_FNV_INIT, &m->role, sizeof(m->role)); h = hash_str(h, m->content); h = hash_str(h, m->tool_call_id); h = hash_str(h, m->tool_calls_json); if (with_reasoning) h = hash_str(h, m->reasoning); for (int i = 0; i < m->nattach; i++) h = hash_str(h, m->attach[i].id); return h; } /* Compare this request with the previous one: t->prefix_break is the first * message that differs (-1: the request only appends; -2: no previous * request to compare with). */ static void check_prefix(cst_agent_t *a, turn_t *t, const req_msgs_t *r, const char *tools_json) { int n = r->n - r->ntail; uint64_t th = hash_str(CST_FNV_INIT, tools_json); int brk = -1; const char *why = NULL; if (a->nprev == 0) { brk = -2; } else if (strcmp(a->prev_model, a->cfg.model) != 0) { brk = 0; why = "model"; } else if (th != a->prev_tools) { brk = 0; why = "tools"; } if (n > a->capprev) { uint64_t *nh = realloc(a->prev_hash, (size_t)n * sizeof(*nh)); if (!nh) { a->nprev = 0; t->prefix_break = -2; return; } a->prev_hash = nh; a->capprev = n; } for (int i = 0; i < n; i++) { uint64_t h = msg_hash(&r->msgs[i], a->caps.reasoning_passback); if (brk == -1 && i < a->nprev && h != a->prev_hash[i]) { brk = i; why = i == 0 ? "system" : "history"; } a->prev_hash[i] = h; } if (brk == -1 && n < a->nprev) { brk = n; why = "shrunk"; } if (brk >= 0 && a->rebased) why = "rebase"; a->rebased = 0; a->nprev = n; a->prev_tools = th; snprintf(a->prev_model, sizeof(a->prev_model), "%s", a->cfg.model); t->prefix_break = brk; t->prefix_reason = why; } static int images_blocked(const cst_agent_t *a) { return a->caps.vision == 0 || (a->images_off && strcmp(a->images_off_model, a->cfg.model) == 0); } static int msgs_have_images(const req_msgs_t *r) { for (int i = 0; i < r->n; i++) if (r->msgs[i].nattach > 0) return 1; return 0; } static uint64_t tools_sig(const cst_agent_t *a) { const cst_agent_config_t *c = &a->cfg; uint64_t h = hash_str(CST_FNV_INIT, c->tools_off); h = hash_str(h, c->tools_only); h = cst_fnv1a64(h, &c->read_only, sizeof(c->read_only)); h = cst_fnv1a64(h, &c->depth, sizeof(c->depth)); int blind = images_blocked(a); h = cst_fnv1a64(h, &blind, sizeof(blind)); return cst_fnv1a64(h, &c->tool_profile, sizeof(c->tool_profile)); } /* tools[] as sent, frozen for the session: rebuilt only when the tool * settings change or at a rebase, so MCP servers that come up later do not * reshuffle the cached prefix mid-session. */ static const char *agent_tools_json(cst_agent_t *a) { uint64_t sig = tools_sig(a); if (a->tools_json && sig == a->tools_sig) return a->tools_json; if (!a->tools_json) { const char *e = getenv("CST_MCP_WAIT_MS"); cst_mcp_wait_ready(e && atoi(e) >= 0 ? atoi(e) : 3000); } cst_agent_config_t c = a->cfg; if (images_blocked(a)) /* a model that cannot see gets no view_image */ cst_tool_set_disabled(&c, "view_image", 1); cJSON *arr = cst_tools_schema_cfg(&c); char *s = arr ? cJSON_PrintUnformatted(arr) : NULL; cJSON_Delete(arr); if (!s) return a->tools_json; free(a->tools_json); a->tools_json = s; a->tools_sig = sig; return s; } static void reset_prefix_state(cst_agent_t *a) { free(a->tools_json); a->tools_json = NULL; a->win_start = 0; a->rebased = 1; } /* "https://api.deepseek.com/v1" -> "https://api.deepseek.com/beta" */ static void beta_base(const char *base, char *out, size_t n) { size_t l = strlen(base); while (l && base[l - 1] == '/') l--; if (l >= 3 && strncmp(base + l - 3, "/v1", 3) == 0) l -= 3; else if (l >= 5 && strncmp(base + l - 5, "/beta", 5) == 0) l -= 5; snprintf(out, n, "%.*s/beta", (int)l, base); } static void fill_request(cst_agent_t *a, cst_llm_request_t *req, const req_msgs_t *rm, cst_protocol_t proto) { memset(req, 0, sizeof(*req)); cst_model_caps(a->cfg.provider, a->cfg.api_base, a->cfg.model, &a->caps); 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->session_path = cst_session_path(a->session); req->cancel_flag = &a->cancel; req->tool_out_cap = a->cfg.tool_out_cap; req->temperature = a->cfg.temperature; req->top_p = a->cfg.top_p; req->max_tokens = a->cfg.max_tokens; if (cst_protocol_is_native(proto)) { req->enable_tools = 1; req->tool_cfg = &a->cfg; req->tools_json = agent_tools_json(a); } req->send_reasoning = a->caps.reasoning_passback; req->images_off = images_blocked(a); if (!a->hints_off) { req->cache_style = a->caps.cache_style; cst_agent_session_id(a, a->cache_key, sizeof(a->cache_key)); req->cache_key = a->cache_key; req->cache_ttl = getenv("CST_CACHE_TTL"); } } /* Count one response into the session and turn totals; returns its cost, * or -1 when the model has no known price. */ static double account(cst_agent_t *a, const cst_llm_stats_t *st) { if (st->prompt_tokens <= 0 && st->completion_tokens <= 0) return -1; cst_price_t p; int priced = cst_model_price(a->cfg.model, &p) == 0; double c = priced ? cst_price_cost(&p, st->cache_miss_tokens, st->cached_tokens, st->cache_write_tokens, st->completion_tokens, time(NULL)) : -1; cst_usage_t *us[2] = {&a->usage, &a->turn_usage}; for (int i = 0; i < 2; i++) { cst_usage_t *u = us[i]; if (u->requests == 0) { u->priced = 1; snprintf(u->currency, sizeof(u->currency), "%s", priced ? p.currency : ""); } u->requests++; u->prompt += st->prompt_tokens; u->cached += st->cached_tokens; u->miss += st->cache_miss_tokens; u->write += st->cache_write_tokens; u->out += st->completion_tokens; if (priced && strcmp(u->currency, p.currency) == 0) u->cost += c; else u->priced = 0; } return c; } void cst_agent_usage(const cst_agent_t *a, cst_usage_t *session, cst_usage_t *turn) { if (session) memset(session, 0, sizeof(*session)); if (turn) memset(turn, 0, sizeof(*turn)); if (!a) return; if (session) *session = a->usage; if (turn) *turn = a->turn_usage; } /* --- 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_full(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, const cst_attachment_t *attach, int nattach, const char *reasoning) { cst_message_t m; memset(&m, 0, sizeof(m)); m.role = role; m.content = (char *)(content ? content : ""); m.reasoning = (char *)reasoning; 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; if (attach && nattach > 0) { m.attach = cst_attachments_dup(attach, nattach); m.nattach = m.attach ? nattach : 0; } cst_session_append(a->session, &m); cst_attachments_free(m.attach, m.nattach); free(meta); } 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) { append_msg_full(a, role, content, tool_id, tool_name, tool_calls_json, meta, NULL, 0, NULL); } /* An assistant reply, with the turn's reasoning when the provider wants the * history to keep it (DeepSeek thinking + tools). */ static void append_reply(turn_t *t, const char *content, const char *tool_calls_json, char *meta) { const char *r = t->a->caps.reasoning_passback && t->think.len ? t->think.buf : NULL; append_msg_full(t->a, CST_ROLE_ASSISTANT, content, NULL, NULL, tool_calls_json, meta, NULL, 0, r); } 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_reply(t, raw ? raw : "", 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_reply(t, t->text.buf ? trim_left(t->text.buf) : "", 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_full(a, CST_ROLE_TOOL, b.buf, pc->call.id, pc->call.name, NULL, meta_json(NULL, t->proto), pc->attach, pc->nattach, NULL); 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_session_path(const cst_agent_t *a) { return a && a->session ? cst_session_path(a->session) : NULL; } void cst_agent_pending_clear(cst_agent_t *a) { if (!a) return; cst_attachments_free(a->pending, a->npending); a->pending = NULL; a->npending = a->cappending = 0; } int cst_agent_pending_count(const cst_agent_t *a) { return a ? a->npending : 0; } int cst_agent_pending_describe(const cst_agent_t *a, int i, char *out, size_t out_sz) { if (!a || !out || i < 0 || i >= a->npending) return -1; cst_attach_describe(&a->pending[i], out, out_sz); return 0; } int cst_agent_sees_images(const cst_agent_t *a) { if (!a) return 0; cst_model_caps_t c; cst_model_caps(a->cfg.provider, a->cfg.api_base, a->cfg.model, &c); if (c.vision == 0 || (a->images_off && strcmp(a->images_off_model, a->cfg.model) == 0)) return 0; return c.vision; } /* Room for one more picked image: the session to store it in, or NULL. */ static const char *pending_room(cst_agent_t *a, long long *total, char *err, size_t err_sz) { if (a->npending >= CST_ATTACH_MAX_COUNT) { snprintf(err, err_sz, "at most %d images per message", CST_ATTACH_MAX_COUNT); return NULL; } *total = 0; for (int i = 0; i < a->npending; i++) *total += a->pending[i].bytes; if (*total >= CST_ATTACH_MAX_TOTAL) { snprintf(err, err_sz, "the images already picked fill %d MB", (int)(CST_ATTACH_MAX_TOTAL >> 20)); return NULL; } const char *session = cst_agent_session_path(a); if (!session || !session[0]) { snprintf(err, err_sz, "no session to store the image in"); return NULL; } return session; } static int pending_add(cst_agent_t *a, cst_attachment_t *one, long long total, char *err, size_t err_sz) { if (!one) return -1; int rc = 0; if (one->bytes > CST_ATTACH_MAX_TOTAL - total || cst_attachments_push(&a->pending, &a->npending, &a->cappending, one) != 0) { snprintf(err, err_sz, "the images together exceed %d MB", (int)(CST_ATTACH_MAX_TOTAL >> 20)); rc = -1; } cst_attach_free(one); return rc; } int cst_agent_attach_file(cst_agent_t *a, const char *path, char *err, size_t err_sz) { char dummy[8]; if (!err || !err_sz) { err = dummy; err_sz = sizeof(dummy); } err[0] = 0; if (!a || !path || !path[0]) { snprintf(err, err_sz, "no file to attach"); return -1; } long long total; const char *session = pending_room(a, &total, err, err_sz); if (!session) return -1; char full[OS_PATH_MAX]; if (os_realpath(path, full, sizeof(full)) != 0) snprintf(full, sizeof(full), "%s", path); return pending_add(a, cst_attach_import_file(session, full, err, err_sz), total, err, err_sz); } int cst_agent_attach_bytes(cst_agent_t *a, const char *name, const char *data, size_t n, char *err, size_t err_sz) { char dummy[8]; if (!err || !err_sz) { err = dummy; err_sz = sizeof(dummy); } err[0] = 0; if (!a) { snprintf(err, err_sz, "no agent"); return -1; } long long total; const char *session = pending_room(a, &total, err, err_sz); if (!session) return -1; return pending_add( a, cst_attach_import_bytes(session, name, data, n, err, err_sz), total, err, err_sz); } /* The stored name of a pending attachment: the window serves the file from the * session's attachment directory while the message is still being written. */ const char *cst_agent_pending_rel(const cst_agent_t *a, int i) { if (!a || i < 0 || i >= a->npending) return NULL; return a->pending[i].rel; } int cst_agent_pending_is_image(const cst_agent_t *a, int i) { if (!a || i < 0 || i >= a->npending) return 0; return cst_attach_is_image(&a->pending[i]); } 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 && !msg_is_note(m) && strncmp(m->content, "[session compacted", 18) != 0) { idx = i; break; } } char *prompt = idx > 0 ? strdup(cst_session_get(s, idx)->content) : NULL; int nimg = 0; cst_attachment_t *imgs = NULL; if (prompt) { const cst_message_t *m = cst_session_get(s, idx); imgs = cst_attachments_dup(m->attach, m->nattach); nimg = imgs ? m->nattach : 0; } cst_session_unlock(s); if (!prompt) return NULL; if (cst_session_rewrite(s, cst_session_get(s, 0), idx) != 0) { free(prompt); cst_attachments_free(imgs, nimg); return NULL; } /* the prompt's images go back to the queue with its text */ for (int i = 0; i < nimg && a->npending < CST_ATTACH_MAX_COUNT; i++) cst_attachments_push(&a->pending, &a->npending, &a->cappending, &imgs[i]); cst_attachments_free(imgs, nimg); 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); } /* --- frozen system prompt ------------------------------------------------ * Every request starts with the session's system message. It is written * once (Build mode, the state at that moment) and changes only at a rebase; * later changes reach the model as persisted notes appended to the history, * which keeps the cached prefix intact. */ #define PROMPT_VERSION 3 typedef struct { int plan; char approval[96]; char mode[16]; char branch[128]; char day[16]; char notes[24]; /* hash of AGENTS.md, "" = none */ } pstate_t; static void state_now(const cst_agent_t *a, pstate_t *s) { memset(s, 0, sizeof(*s)); s->plan = a->cfg.plan_mode; snprintf(s->approval, sizeof(s->approval), "%s", approval_desc(&a->cfg)); snprintf(s->mode, sizeof(s->mode), "%s", cst_mode_name(a->cfg.mode)); git_branch(a->cfg.workspace_root, s->branch, sizeof(s->branch)); today(s->day, sizeof(s->day)); if (a->notes && a->notes[0]) snprintf(s->notes, sizeof(s->notes), "%016llx", (unsigned long long)cst_fnv1a64(CST_FNV_INIT, a->notes, strlen(a->notes))); } static cJSON *state_json(const pstate_t *s) { cJSON *o = cJSON_CreateObject(); cJSON_AddNumberToObject(o, "plan", s->plan); cJSON_AddStringToObject(o, "approval", s->approval); cJSON_AddStringToObject(o, "mode", s->mode); cJSON_AddStringToObject(o, "branch", s->branch); cJSON_AddStringToObject(o, "day", s->day); cJSON_AddStringToObject(o, "notes", s->notes); return o; } static void state_parse(const cJSON *o, pstate_t *s) { memset(s, 0, sizeof(*s)); const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, "plan"); s->plan = cJSON_IsNumber(v) ? v->valueint : 0; #define STATE_STR(key, field) \ v = cJSON_GetObjectItemCaseSensitive(o, key); \ if (cJSON_IsString(v)) \ snprintf(s->field, sizeof(s->field), "%s", v->valuestring); STATE_STR("approval", approval) STATE_STR("mode", mode) STATE_STR("branch", branch) STATE_STR("day", day) STATE_STR("notes", notes) #undef STATE_STR } static char *system_meta(const cst_agent_t *a, const pstate_t *s) { cJSON *o = cJSON_CreateObject(); cJSON_AddStringToObject(o, "protocol", cst_protocol_name(a->cfg.protocol)); cJSON_AddNumberToObject(o, "pv", PROMPT_VERSION); cJSON_AddNumberToObject(o, "tools", a->cfg.tool_profile); cJSON_AddStringToObject(o, "mode", cst_mode_name(a->cfg.mode)); cJSON_AddItemToObject(o, "state", state_json(s)); char *out = cJSON_PrintUnformatted(o); cJSON_Delete(o); return out; } static char *frozen_prompt(const cst_agent_t *a, pstate_t *st) { cst_agent_config_t c = a->cfg; c.plan_mode = 0; state_now(a, st); st->plan = 0; return cst_agent_build_prompt(&c, c.protocol, a->notes); } static int system_current(const cst_message_t *m, const cst_agent_config_t *c) { cJSON *meta = m->meta_json ? cJSON_Parse(m->meta_json) : NULL; const cJSON *pv = cJSON_GetObjectItemCaseSensitive(meta, "pv"); const cJSON *pr = cJSON_GetObjectItemCaseSensitive(meta, "protocol"); const cJSON *tp = cJSON_GetObjectItemCaseSensitive(meta, "tools"); const cJSON *md = cJSON_GetObjectItemCaseSensitive(meta, "mode"); int ok = cJSON_IsNumber(pv) && pv->valueint == PROMPT_VERSION && cJSON_IsString(pr) && strcmp(pr->valuestring, cst_protocol_name(c->protocol)) == 0 && cJSON_IsNumber(tp) && tp->valueint == c->tool_profile; ok = ok && cJSON_IsString(md) && strcmp(md->valuestring, cst_mode_name(c->mode)) == 0; cJSON_Delete(meta); return ok; } /* Sessions from older versions, or after a protocol / tool profile change, * get a new system prompt (a one-time cache miss). */ static void ensure_system(cst_agent_t *a) { cst_session_t *s = a->session; int n = cst_session_count(s); for (int i = 0; i < n; i++) { const cst_message_t *m = cst_session_get(s, i); if (m->role != CST_ROLE_SYSTEM) continue; if (system_current(m, &a->cfg)) return; break; } pstate_t st; char *sp = frozen_prompt(a, &st); char *meta = system_meta(a, &st); if (n == 0) { append_msg(a, CST_ROLE_SYSTEM, sp, NULL, NULL, NULL, meta); free(sp); return; } cst_message_t *v = calloc((size_t)n + 1, sizeof(*v)); if (v) { v[0].role = CST_ROLE_SYSTEM; v[0].content = sp; v[0].meta_json = meta; int k = 1; for (int i = 0; i < n; i++) { const cst_message_t *m = cst_session_get(s, i); if (m->role != CST_ROLE_SYSTEM) v[k++] = *m; } cst_session_rewrite(s, v, k); free(v); } free(sp); free(meta); reset_prefix_state(a); } /* State as of the newest note, or the system prompt. */ static void last_state(cst_agent_t *a, pstate_t *st) { memset(st, 0, sizeof(*st)); for (int i = cst_session_count(a->session) - 1; i >= 0; i--) { const cst_message_t *m = cst_session_get(a->session, i); if (!m->meta_json || !strstr(m->meta_json, "\"state\"")) continue; cJSON *meta = cJSON_Parse(m->meta_json); state_parse(cJSON_GetObjectItemCaseSensitive(meta, "state"), st); cJSON_Delete(meta); return; } } /* One persisted note for whatever changed since the last one. */ static void sync_notes(cst_agent_t *a) { pstate_t was, now; last_state(a, &was); state_now(a, &now); cst_buf_t b = {0}; if (now.plan != was.plan) cst_buf_puts(&b, now.plan ? "[cst] Plan mode is now ON: you may only read and " "research; tools that change files or run shell " "commands are refused. 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" : "[cst] Plan mode is now OFF (Build mode): every tool " "is available again.\n"); if (strcmp(now.approval, was.approval) != 0) cst_buf_printf(&b, "[cst] Approval mode is now %s.\n", now.approval); if (strcmp(now.mode, was.mode) != 0) cst_buf_printf(&b, "[cst] Working mode is now %s%s. Earlier instructions about " "how to frame the work no longer apply.\n", now.mode, strcmp(now.mode, "general") == 0 ? " (general: documents, data, research)" : " (coding: repo, build, tests)"); if (strcmp(now.branch, was.branch) != 0) cst_buf_printf(&b, "[cst] The git branch is now %s.\n", now.branch[0] ? now.branch : "(none)"); if (strcmp(now.day, was.day) != 0) cst_buf_printf(&b, "[cst] Today's date is now %s.\n", now.day); if (strcmp(now.notes, was.notes) != 0) { if (now.notes[0]) { size_t len = strlen(a->notes); if (len > PROJECT_NOTES_MAX) len = cst_utf8_floor(a->notes, PROJECT_NOTES_MAX); cst_buf_puts(&b, "[cst] The workspace's AGENTS.md changed; follow " "these project instructions from now on:\n"); cst_buf_append(&b, a->notes, len); cst_buf_puts(&b, "\n"); } else { cst_buf_puts(&b, "[cst] The workspace's AGENTS.md was removed; its " "earlier instructions no longer apply.\n"); } } if (!b.len) return; while (b.len && b.buf[b.len - 1] == '\n') b.buf[--b.len] = 0; cJSON *o = cJSON_CreateObject(); cJSON_AddStringToObject(o, "kind", "note"); cJSON_AddItemToObject(o, "state", state_json(&now)); char *meta = cJSON_PrintUnformatted(o); cJSON_Delete(o); append_msg(a, CST_ROLE_USER, b.buf, NULL, NULL, NULL, meta); cst_buf_free(&b); } 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; /* The generation parameters the user saved for this provider (Settings -> * Advanced in the app, "params" in settings.json). They are read here so * that the CLI and the TUI send what the GUI was set to; a caller that * named one explicitly wins. */ { cst_settings_t st; if (cst_settings_load(&st) == 0) { const cst_gen_params_t *g = cst_settings_params(&st, a->cfg.provider); if (g) { if (a->cfg.temperature <= 0) a->cfg.temperature = g->temperature; if (a->cfg.top_p <= 0) a->cfg.top_p = g->top_p; if (a->cfg.max_tokens <= 0) a->cfg.max_tokens = g->max_tokens; } } } 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); cst_model_caps(a->cfg.provider, a->cfg.api_base, a->cfg.model, &a->caps); ensure_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); free(a->tools_json); free(a->prev_hash); cst_agent_pending_clear(a); 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, double cost) { 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, "cache_miss_tokens", (double)st->cache_miss_tokens); if (st->cache_write_tokens) cJSON_AddNumberToObject(o, "cache_write_tokens", (double)st->cache_write_tokens); if (cost >= 0) cJSON_AddNumberToObject(o, "cost", cost); cJSON_AddNumberToObject(o, "prefix_break", t->prefix_break); if (t->prefix_reason) cJSON_AddStringToObject(o, "prefix_reason", t->prefix_reason); if (t->cont) cJSON_AddNumberToObject(o, "continuation", t->cont); 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); } /* DeepSeek can resume a reply that was cut off (max tokens, dropped stream) * from its own text via prefix completion, instead of a fresh step. */ static int can_continue(const cst_agent_t *a, const turn_t *t, const cst_llm_stats_t *st, int lrc, cst_protocol_t proto) { if (!cst_protocol_is_native(proto) || !a->caps.prefix_completion || a->prefix_off || a->cancel || t->stop || t->cont >= 2 || t->ncalls || t->native_opened || t->in_think || !t->raw.len) return 0; if (lrc == 0) return strcmp(st->finish_reason, "length") == 0; return st->http_status > 0 && st->http_status < 400 && !st->client_stopped; } /* Prompt size at which the session is compacted: CST_CTX_BUDGET, else 75% of * the context window, capped at 256K tokens. Even a cached prompt is billed on * every step, so a 1M-token window is not worth filling. */ static long ctx_budget(const cst_agent_t *a) { const char *e = getenv("CST_CTX_BUDGET"); if (e && atol(e) > 0) return atol(e); long win = cst_model_context_tokens(a->cfg.model); if (win <= 0) return 0; long b = win / 4 * 3; return b > 262144 ? 262144 : b; } /* Compact before a turn when the last request crossed the budget. */ 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 budget = ctx_budget(a); if (a->last_prompt_tokens <= 0 || budget <= 0 || a->last_prompt_tokens < budget) return; if (hooks && hooks->on_status) { char msg[128]; snprintf(msg, sizeof(msg), "prompt %ldK tokens (budget %ldK) — compacting...", a->last_prompt_tokens / 1000, budget / 1000); 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(); memset(&a->turn_usage, 0, sizeof(a->turn_usage)); ensure_system(a); sync_notes(a); append_msg_full(a, CST_ROLE_USER, user_text, NULL, NULL, NULL, meta_json(NULL, proto), a->pending, a->npending, NULL); cst_agent_pending_clear(a); /* the images now live in the session */ 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; } if (step > 0) sync_notes(a); 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); } /* The final pass keeps tools and tool_choice as they were: changing * either would miss the cache for the whole prompt. Calls it still * makes are dropped below. */ cst_llm_stats_t st; cst_llm_request_t req; fill_request(a, &req, &rm, proto); req.on_delta = on_delta; req.userdata = &t; req.stop_flag = &t.stop; req.stats = &st; if (cst_protocol_is_native(proto)) { req.on_native_tool = on_native_tool; req.on_native_open = on_native_open; } check_prefix(a, &t, &rm, req.tools_json); t.t0 = os_now_ms(); char lerr[512]; int lrc = cst_llm_chat_stream(&req, lerr, sizeof(lerr)); if (lrc != 0 && st.http_status == 400 && req.cache_style != CST_CACHE_AUTO && !t.raw.len && !t.ncalls && !a->cancel) { a->hints_off = 1; /* the gateway rejects cache hints */ req.cache_style = CST_CACHE_AUTO; req.cache_key = NULL; req.cache_ttl = NULL; lrc = cst_llm_chat_stream(&req, lerr, sizeof(lerr)); } if (lrc != 0 && st.http_status == 400 && !req.images_off && msgs_have_images(&rm) && !t.raw.len && !t.ncalls && !a->cancel) { a->images_off = 1; /* unknown model turned out not to take images */ snprintf(a->images_off_model, sizeof(a->images_off_model), "%s", a->cfg.model); req.images_off = 1; if (req.tools_json) req.tools_json = agent_tools_json(a); hook_status(&t, "the model refused images — sending notes instead..."); lrc = cst_llm_chat_stream(&req, lerr, sizeof(lerr)); } const int lrc0 = lrc; while (can_continue(a, &t, &st, lrc, proto)) { emit_step_metrics(&t, &st, lrc, account(a, &st)); if (st.prompt_tokens > 0) a->last_prompt_tokens = st.prompt_tokens; t.cont++; t.prefix_break = -1; t.prefix_reason = NULL; cst_buf_t pre = {0}; cst_buf_append(&pre, t.raw.buf ? t.raw.buf : "", t.raw.len); cst_buf_append(&pre, t.tagbuf, t.taglen); push_prefix(&rm, pre.buf); cst_buf_free(&pre); if (a->caps.reasoning_passback && t.think.len) rm.msgs[rm.n - 1].reasoning = t.think.buf; char beta[1024]; beta_base(a->cfg.api_base, beta, sizeof(beta)); req.api_base = beta; req.msgs = rm.msgs; req.nmsgs = rm.n; hook_status(&t, "output cut off — continuing where it stopped..."); lrc = cst_llm_chat_stream(&req, lerr, sizeof(lerr)); rm.n--; rm.ntail--; req.api_base = a->cfg.api_base; if (lrc != 0 && st.http_status >= 400) { if (st.http_status == 400 || st.http_status == 404) a->prefix_off = 1; /* no prefix completion behind this base */ lrc = lrc0; break; } } 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, " 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); cst_attachments_free(t.calls[i].attach, t.calls[i].nattach); } } 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\n" "\nVALUE" "\n\n", 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_reply(&t, body, 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; cst_agent_pending_clear(a); /* images belong to the session that made them */ 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); reset_prefix_state(a); a->nprev = 0; a->prefix_off = a->hints_off = a->images_off = 0; memset(&a->usage, 0, sizeof(a->usage)); memset(&a->turn_usage, 0, sizeof(a->turn_usage)); ensure_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], path[4096]; /* ids are per second: never reopen (and append to) one that exists */ for (int tries = 0;; tries++) { if (tries == 16 || cst_agent_store_new_id(id, sizeof(id)) != 0 || cst_agent_store_path_for_id(a->cfg.workspace_root, id, path, sizeof(path)) != 0) return -1; FILE *fp = fopen(path, "rb"); if (!fp) break; fclose(fp); } 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_set_workspace(cst_agent_t *a, const char *path, char *err, size_t err_sz) { char dummy[8]; if (!err || !err_sz) { err = dummy; err_sz = sizeof(dummy); } err[0] = 0; if (!a || !path || !path[0]) { snprintf(err, err_sz, "empty path"); return -1; } char abs[sizeof(a->cfg.workspace_root)]; if (os_realpath(path, abs, sizeof(abs)) != 0) { snprintf(err, err_sz, "no such directory: %s", path); return -1; } /* a file, or a tree we may not write to, fails here instead of halfway * through the next turn */ if (cst_agent_store_ensure(abs) != 0) { snprintf(err, err_sz, "cannot use as a workspace: %s", abs); return -1; } snprintf(a->cfg.workspace_root, sizeof(a->cfg.workspace_root), "%s", abs); /* everything that is kept per workspace follows the new root */ cst_perms_close(a->perms); a->perms = cst_perms_open(a->cfg.workspace_root); cst_metrics_close(a->metrics); a->metrics = cst_metrics_open(a->cfg.workspace_root, cst_session_path(a->session)); cst_agent_reload_notes(a); ensure_system(a); /* the prompt names the directory */ a->last_prompt_tokens = 0; /* the old budget was measured elsewhere */ 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); } #define COMPACT_ASK \ "Summarize the coding session 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." #define COMPACT_HEAD "[session compacted — summary of earlier conversation]\n" /* The summary request reuses the session's own prefix (system prompt, tools, * history) and only appends the ask, so it reads almost entirely from the * cache. DeepSeek also gets the summary heading as an assistant prefix. */ static int compact_same_prefix(cst_agent_t *a, cst_buf_t *acc, char *lerr, size_t lerr_sz) { const cst_protocol_t proto = a->cfg.protocol; req_msgs_t rm; if (build_request_msgs(a, proto, "[cst] " COMPACT_ASK, &rm) != 0) return -1; cst_llm_stats_t st; cst_llm_request_t req; fill_request(a, &req, &rm, proto); req.on_delta = acc_feed; req.userdata = acc; req.stats = &st; char beta[1024]; int prefix = cst_protocol_is_native(proto) && a->caps.prefix_completion && !a->prefix_off; if (prefix) { push_prefix(&rm, COMPACT_HEAD); beta_base(a->cfg.api_base, beta, sizeof(beta)); req.api_base = beta; req.nmsgs = rm.n; } int rc = cst_llm_chat_stream(&req, lerr, lerr_sz); if (rc != 0 && prefix && !a->cancel && (st.http_status == 400 || st.http_status == 404)) { a->prefix_off = 1; req.nmsgs = --rm.n; rm.ntail--; req.api_base = a->cfg.api_base; acc->len = 0; if (acc->buf) acc->buf[0] = 0; rc = cst_llm_chat_stream(&req, lerr, lerr_sz); } account(a, &st); req_msgs_free(&rm); return rc; } /* Fallback: a standalone request carrying a condensed transcript. */ static int compact_blob(cst_agent_t *a, cst_buf_t *acc, char *lerr, size_t lerr_sz) { int n = cst_session_count(a->session); cst_buf_t blob = {0}; cst_buf_puts(&blob, COMPACT_ASK "\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_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 = reqs; req.nmsgs = 2; req.on_delta = acc_feed; req.userdata = acc; req.cancel_flag = &a->cancel; req.stats = &st; int rc = cst_llm_chat_stream(&req, lerr, lerr_sz); cst_buf_free(&blob); account(a, &st); return rc; } /* Strip ... blocks and surrounding blanks; returns the * length left. */ static size_t clean_summary(cst_buf_t *acc) { if (!acc->buf) return 0; char *src = acc->buf, *dst = acc->buf; while (*src) { if (strncmp(src, "", 7) == 0) { char *end = strstr(src, ""); 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; return acc->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 acc = {0}; char lerr[512]; lerr[0] = 0; int rc = compact_same_prefix(a, &acc, lerr, sizeof(lerr)); if ((rc != 0 || clean_summary(&acc) < 8) && !a->cancel) { cst_buf_free(&acc); lerr[0] = 0; rc = compact_blob(a, &acc, lerr, sizeof(lerr)); if (rc == 0) clean_summary(&acc); } if (rc != 0 || acc.len == 0) { cst_buf_free(&acc); snprintf(err, err_sz, "compact llm: %s", lerr[0] ? lerr : "empty"); return -1; } if (acc.len < 8) { cst_buf_free(&acc); snprintf(err, err_sz, "compact produced empty summary"); return -1; } const char *body = trim_left(acc.buf); if (strncmp(body, COMPACT_HEAD, strlen(COMPACT_HEAD) - 1) == 0) body += strlen(COMPACT_HEAD) - 1; cst_buf_t summary = {0}; cst_buf_printf(&summary, COMPACT_HEAD "%s", trim_left(body)); cst_buf_free(&acc); pstate_t st; cst_message_t keep[2]; memset(keep, 0, sizeof(keep)); keep[0].role = CST_ROLE_SYSTEM; keep[0].content = frozen_prompt(a, &st); keep[0].meta_json = system_meta(a, &st); keep[1].role = CST_ROLE_USER; keep[1].content = summary.buf; rc = cst_session_rewrite(a->session, keep, 2); free(keep[0].content); free(keep[0].meta_json); cst_buf_free(&summary); if (rc != 0) { snprintf(err, err_sz, "rewrite session failed"); return -1; } reset_prefix_state(a); return 0; }