Files
Hermit/src/agent_loop.c
T
WHC c2320f946b v0.9.0: the Qt front end as an Android APK, images in the transcript
Two strands land together: the window draws pictures now, and the whole front
end builds, installs and runs on the phone with the engine linked in-process.

Images (the "make a picture actually show up" part)
- Engine, additively: cst_agent_hooks_t.on_tool_images(call, list, n, ud) fires
  when a call left attachments behind, and cst_agent_pending_rel() /
  cst_agent_pending_is_image() read that list without the app touching the
  store. tests/test_attach.c::test_agent_pending pins the three cases (an
  image, a stored name that passes cst_attach_rel_ok, out-of-range NULL).
- App: image://cstattach/<stored name> (app/src/ui/SessionImages.cpp) serves
  only the current session's attachment directory, checked with
  cst_attach_rel_ok, decodes down to the requested sourceSize and never
  upscales, and holds a mutex because requestImage() runs on the render
  thread while the directory can change underneath it. AgentCore gained
  attachmentDir(), pendingImages(), the toolImages()/promptImages() signals
  and hookToolImages(); history() gives replayed rows their images too.
- QML: ImageStrip.qml (a thumb strip, <=150 px tall, shaped by the picture,
  tooltip with name/size/origin, the file's name when it went missing),
  wired into MessageCard and ToolCard. Qt 6's ListModel cannot hold a
  list-valued role (append() reads back undefined, setProperty drops it), so
  a row carries one image per line as <stored>|<name>|<origin>|<w>x<h> and
  ImageStrip decodes that.

Android (the engine is already in the APK; this is the front end)
- app/src/main.cpp: on Android the workspace, config, tmp and HOME go under
  the app's own data directory before the engine opens anything, because
  QDir::currentPath() is "/" there and /tmp is not writable.
- app/android/: the manifest (QtApplication/QtActivity, INTERNET and
  POST_NOTIFICATIONS, adjustResize, no storage permission -- the picker will
  be the system one), strings, a vector icon; minSdk 24 and targetSdk 35 come
  from app/CMakeLists.txt through QT_ANDROID_MIN/TARGET_SDK_VERSION.
- app/src/platform/android/AndroidTools.cpp: the Android file behind
  PlatformInit(), owner "app:android" (notification, clipboard, share sheet,
  file picker are stubs for now, so the tool list does not lie about them).
- app/tools/selftest.c decides its scratch workspace with os_temp_dir(), so
  the C smoke test runs on the device too.
- scripts/build_apk_android.sh: sources to installed APK in one command
  (environment checks, qt-cmake configure, --target apk, adb install), and
  the host-side plumbing the NixOS + qt.io combination needs -- the NDK
  sysroot include dirs for Qt's FindGLESv2/FindEGL (they only look under
  <sysroot>/include), -L$PREFIX/lib on the shared/module link lines as well
  as the executables', the Android debug keystore created on first use with
  QT_ANDROID_SIGN_APK + QT_ANDROID_KEYSTORE_* (androiddeployqt copies the
  unsigned gradle output and signs it itself), and the #!/bin/bash shebang of
  the build-tools wrappers, which NixOS does not have.
- scripts/gradle-android-init.gradle (installed into ~/.gradle/init.d/): the
  release lint task is switched off -- lintVitalAnalyzeRelease resolves its
  own tool jars straight from dl.google.com, bypassing every repository the
  build configures, and fails the build after all the real work is done.
- Icons: app/icons/*.svg (Breeze's 24@3x files, the 19 names the window asks
  for) are compiled in as :/cst-icons and ThemeIcons.cpp falls back to them
  when the platform has no icon theme. On the phone the Qt theme lookup found
  nothing and the QML glyph fallback was an empty box in the phone's fonts.
- docs/android.md: "The APK (Qt front end)" -- the SDK assembled without
  sdkmanager, the host Qt's missing libraries on NixOS, the Gradle mirrors,
  lint, signing -- and a Status that says what runs where. app/README.md
  gains the images and APK lines, and stops claiming Android platform tools
  that are still stubs.

Verified: engine `make check` green; desktop `cst-app-selftest: OK` and
--uitest all checks pass; phone (NX789J, Android 16, arm64-v8a) -- the APK
installs, starts on the workspace under /data/user/0/xyz.whcself.cstagent/
files/, a turn reaches a configured provider over HTTPS from inside the ART
process and renders the reply, rotation keeps the session and docks the
sidebar, and the toolbar/card icons draw.

Version 0.9.0.

mode = coding
2026-10-11 15:22:10 +08:00

3353 lines
113 KiB
C

#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 <stdint.h>
#include <stdio.h>
#include <time.h>
#ifndef _WIN32
#include <sys/utsname.h>
#endif
#include <stdlib.h>
#include <string.h>
/* --- protocols ----------------------------------------------------------- */
static const char *const PROTO_NAMES[] = {"native-eos", "native-inc", "ctl",
"ctl-heredoc", "xml"};
const char *cst_protocol_name(cst_protocol_t p) {
if ((int)p < 0 || (size_t)p >= sizeof(PROTO_NAMES) / sizeof(PROTO_NAMES[0]))
return "native-eos";
return PROTO_NAMES[p];
}
int cst_protocol_parse(const char *s, cst_protocol_t *out) {
if (!s || !out)
return -1;
if (strcmp(s, "native") == 0) {
*out = CST_PROTO_NATIVE_EOS;
return 0;
}
for (size_t i = 0; i < sizeof(PROTO_NAMES) / sizeof(PROTO_NAMES[0]); i++) {
if (strcmp(s, PROTO_NAMES[i]) == 0) {
*out = (cst_protocol_t)i;
return 0;
}
}
return -1;
}
int cst_protocol_is_native(cst_protocol_t p) {
return p == CST_PROTO_NATIVE_EOS || p == CST_PROTO_NATIVE_INC;
}
int cst_protocol_is_ctl(cst_protocol_t p) {
return p == CST_PROTO_CTL || p == CST_PROTO_CTL_HEREDOC;
}
int cst_protocol_is_text(cst_protocol_t p) { return !cst_protocol_is_native(p); }
int cst_protocol_is_eager(cst_protocol_t p) {
return p == CST_PROTO_NATIVE_INC || cst_protocol_is_ctl(p);
}
/* --- system prompts ------------------------------------------------------ */
#define PROMPT_BASE \
"You are CST-Agent, a coding assistant working inside a local workspace. " \
"All tool paths are relative to the workspace root and shell commands run " \
"there.\n" \
"Reply in the user's language. Be concise.\n\n"
#define PROMPT_TOOLS \
"Tools:\n" \
"- read_file(path): read a text file.\n" \
"- run_shell(cmd): run a non-interactive shell command (stdin is empty; " \
"long-running commands are killed).\n" \
"- write_file(path, content): create or overwrite a whole file.\n" \
"- apply_patch(patch): unified diff with ---/+++ headers and " \
"@@ -a,b +c,d @@ hunks (keep 3 context lines). Prefer it for small edits " \
"to existing files.\n\n" \
"Workflow:\n" \
"1) Inspect only what you need (read_file; ls/grep via run_shell).\n" \
"2) Make the change.\n" \
"3) Verify with the project's build/tests via run_shell; fix and re-run " \
"until they pass.\n" \
"4) Finish with a short plain-text summary of what changed.\n" \
"Never invent other tool names.\n"
#define PROMPT_RESULTS \
"- After your last frame, STOP writing and wait. Results arrive in the " \
"next user message as:\n" \
" result <id> exit=<code> <nbytes>\n" \
" <output>\n" \
" end\n" \
" Never write result frames yourself.\n"
static const char SEC_NATIVE[] =
"Call tools through the native function-calling interface.\n\n";
static const char SEC_CTL[] =
"\nHow to call tools: write CTL frames as plain text in your reply. There "
"is NO function-calling API here; do not use JSON or XML.\n\n"
"call <id> <tool>\n"
"arg <key> <nbytes>\n"
"<exactly nbytes bytes of the raw value>\n"
"end\n\n"
"Rules:\n"
"- <id> is a short unique token per call (c1, c2, ...). Every frame line "
"starts at the beginning of a line.\n"
"- nbytes counts UTF-8 BYTES of the raw value (ASCII = 1 byte, most CJK "
"characters = 3 bytes), including every newline inside it. The value is "
"copied verbatim, with no escaping. One newline after the value, before "
"`end`, is optional and not counted.\n"
"- Short single-line values may be inline instead: `arg path src/main.c` "
"(the value is the rest of the line; it must not be only digits).\n"
"- You may write several frames in one reply.\n" PROMPT_RESULTS
"\nExample (the content value is \"hello world\\n\" = 12 bytes):\n"
"call c1 read_file\n"
"arg path README.md\n"
"end\n"
"call c2 write_file\n"
"arg path hello.txt\n"
"arg content 12\n"
"hello world\n"
"end\n";
static const char SEC_CTL_HEREDOC[] =
"\nHow to call tools: write CTL frames as plain text in your reply. There "
"is NO function-calling API here; do not use JSON or XML.\n\n"
"call <id> <tool>\n"
"arg <key> <<TAG\n"
"<value lines, verbatim>\n"
"TAG\n"
"end\n\n"
"Rules:\n"
"- <id> is a short unique token per call (c1, c2, ...). Every frame line "
"starts at the beginning of a line.\n"
"- TAG is a word of letters/digits/underscore that never appears alone on "
"a line inside the value (e.g. EOF_1). The value is the lines between the "
"header and the TAG line, each ending with a newline. No escaping.\n"
"- Short single-line values may be inline instead: `arg path src/main.c` "
"(the value is the rest of the line; it must not be only digits).\n"
"- You may write several frames in one reply.\n" PROMPT_RESULTS
"\nExample:\n"
"call c1 read_file\n"
"arg path README.md\n"
"end\n"
"call c2 write_file\n"
"arg path hello.txt\n"
"arg content <<EOF_1\n"
"hello world\n"
"EOF_1\n"
"end\n";
static const char SEC_XML[] =
"\nHow to call tools: write tool blocks as plain text in your reply. There "
"is NO function-calling API here.\n\n"
"<minimax:tool_call>\n"
"<invoke name=\"read_file\">\n"
"<parameter name=\"path\">README.md</parameter>\n"
"</invoke>\n"
"</minimax:tool_call>\n\n"
"- Parameter values are verbatim text (no escaping).\n"
"- You may write several blocks in one reply.\n" PROMPT_RESULTS;
static const char SEC_CTL_NUMBERS[] =
"- A purely numeric inline value must be quoted (`arg limit \"50\"`); "
"bare digits are read as a byte count.\n";
static int tool_on(const cst_agent_config_t *cfg, const char *name) {
return cst_tool_active(cfg, cst_tool_find(name));
}
/* /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: <path>" */
char gd[4096] = "";
if (fgets(line, sizeof(line), fp) && strncmp(line, "gitdir: ", 8) == 0) {
line[strcspn(line, "\r\n")] = 0;
if (line[8] == '/')
snprintf(gd, sizeof(gd), "%s", line + 8);
else
snprintf(gd, sizeof(gd), "%.2000s/%.2000s", dir, line + 8);
}
fclose(fp);
if (gd[0])
snprintf(p, sizeof(p), "%s/HEAD", gd);
else
snprintf(p, sizeof(p), "%s/.git/HEAD", dir);
fp = fopen(p, "rb");
if (fp) {
if (fgets(line, sizeof(line), fp)) {
line[strcspn(line, "\r\n")] = 0;
if (strncmp(line, "ref: refs/heads/", 16) == 0)
snprintf(out, n, "%.100s", line + 16);
else if (strlen(line) >= 12)
snprintf(out, n, "detached at %.12s", line);
}
fclose(fp);
}
return;
}
char *sl = strrchr(dir, '/');
if (!sl || sl == dir)
return;
*sl = 0;
}
}
static void os_label(char *out, size_t n) {
#ifdef _WIN32
char ver[64];
os_windows_version(ver, sizeof(ver));
snprintf(out, n,
"%s; run_shell is busybox sh (POSIX syntax and applets; write paths "
"as C:/dir or relative, Windows programs like git or python run "
"from PATH)",
ver);
#else
struct utsname u;
if (uname(&u) == 0)
snprintf(out, n, "%s %s %s", u.sysname, u.release, u.machine);
else
snprintf(out, n, "Unix");
#endif
}
static void 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 <think>...</think> (MiniMax inline / reasoning_content wrapper). */
static void on_delta(const char *data, size_t len, void *ud) {
turn_t *t = ud;
for (size_t i = 0; i < len; i++) {
char c = data[i];
const char *want = t->in_think ? "</think>" : "<think>";
size_t wl = strlen(want);
if (t->taglen == 0 && c != '<') {
/* 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 <think> splitter or
* the XML scanner so it reaches the UI before the tool card. */
static void flush_held_text(turn_t *t) {
if (t->taglen && !t->in_think) {
feed_visible(t, t->tagbuf, t->taglen);
t->taglen = 0;
}
if (t->mm)
mm_scan_finish(t->mm);
}
static void on_native_open(int index, const char *id, const char *name,
void *ud) {
turn_t *t = ud;
flush_held_text(t);
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, "<parameter name=\"%s\">%s</parameter>\n", key, v);
} else if (p == CST_PROTO_CTL_HEREDOC) {
cst_buf_printf(b, "arg %s <<EOF_%s\n%s%s", key, key, v,
(n && v[n - 1] == '\n') ? "" : "\n");
cst_buf_printf(b, "EOF_%s\n", key);
} else {
cst_buf_printf(b, "arg %s %zu\n", key, n);
cst_buf_append(b, v, n);
cst_buf_puts(b, "\n");
}
}
/* Native assistant tool_calls rendered as text-protocol syntax. */
static char *render_calls_as_text(cst_protocol_t p, const char *content,
const char *tool_calls_json) {
cst_buf_t b = {0};
if (content && content[0]) {
cst_buf_puts(&b, content);
cst_buf_puts(&b, "\n");
}
cJSON *arr = cJSON_Parse(tool_calls_json);
const cJSON *tc = NULL;
cJSON_ArrayForEach(tc, arr) {
const cJSON *id = cJSON_GetObjectItemCaseSensitive(tc, "id");
const cJSON *fn = cJSON_GetObjectItemCaseSensitive(tc, "function");
const cJSON *nm = cJSON_GetObjectItemCaseSensitive(fn, "name");
const cJSON *ar = cJSON_GetObjectItemCaseSensitive(fn, "arguments");
const char *name = cJSON_IsString(nm) ? nm->valuestring : "tool";
if (p == CST_PROTO_XML)
cst_buf_printf(&b, "<minimax:tool_call>\n<invoke name=\"%s\">\n", name);
else
cst_buf_printf(&b, "call %s %s\n",
cJSON_IsString(id) ? id->valuestring : "c", name);
cJSON *args = cJSON_IsString(ar) ? cJSON_Parse(ar->valuestring) : NULL;
const cJSON *it = NULL;
cJSON_ArrayForEach(it, args) {
if (cJSON_IsString(it)) {
render_value(&b, p, it->string, it->valuestring);
} else {
char *v = cJSON_PrintUnformatted(it);
render_value(&b, p, it->string, v ? v : "");
free(v);
}
}
cJSON_Delete(args);
cst_buf_puts(&b, p == CST_PROTO_XML ? "</invoke>\n</minimax:tool_call>\n"
: "end\n");
}
cJSON_Delete(arr);
return cst_buf_steal(&b);
}
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, "<minimax:tool_call") ||
strstr(t.raw.buf, "<invoke"))) {
t.parse_error = 1;
snprintf(t.perr, sizeof(t.perr),
"malformed tool block, nothing could be parsed");
}
emit_step_metrics(&t, &st, lrc, account(a, &st));
if (st.prompt_tokens > 0)
a->last_prompt_tokens = st.prompt_tokens;
if (lrc != 0) {
/* side effects of eagerly executed calls must still be recorded */
int kept = 0;
for (int i = 0; i < t.ncalls; i++) {
if (t.calls[i].done) {
t.calls[kept++] = t.calls[i];
} else {
cst_tool_call_clear(&t.calls[i].call);
free(t.calls[i].raw_args);
free(t.calls[i].output);
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<minimax:tool_call>\n"
"<invoke name=\"TOOL\">\n<parameter name=\"KEY\">VALUE"
"</parameter>\n</invoke>\n</minimax:tool_call>",
t.perr);
else
snprintf(msg, sizeof(msg),
"[ctl] contract error: %s. Nothing was executed. Re-send the "
"call with exact CTL syntax (byte counts must be exact; use "
"inline values for short single-line arguments).",
t.perr);
append_msg(a, CST_ROLE_USER, msg, NULL, NULL, NULL,
meta_json("ctl_error", proto));
turn_free(&t);
if (++ctl_retries > MAX_CTL_RETRIES) {
snprintf(err, err_sz, "%s: repeated contract errors",
cst_protocol_name(proto));
break;
}
hook_status(&t, "contract error — asking model to re-send...");
continue;
}
if (has_text) {
append_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 <think>...</think> 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, "<think>", 7) == 0) {
char *end = strstr(src, "</think>");
if (end) {
src = end + 8;
continue;
}
}
*dst++ = *src++;
}
*dst = 0;
acc->len = (size_t)(dst - acc->buf);
while (acc->len > 0 &&
(acc->buf[acc->len - 1] == '\n' || acc->buf[acc->len - 1] == ' '))
acc->buf[--acc->len] = 0;
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;
}