#ifndef _DEFAULT_SOURCE #define _DEFAULT_SOURCE #endif #ifndef _XOPEN_SOURCE #define _XOPEN_SOURCE 700 #endif #include "cst_subagent.h" #include #include #include #include #include #include #include #include "cJSON.h" #include "cst_agent.h" #include "cst_skills.h" #include "cst_tools.h" #include "cst_util.h" #include "os_compat.h" #define ROLE_FILE_MAX (64 * 1024) #define INDEX_MAX 2048 static pthread_mutex_t g_hook_mu = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t g_seq_mu = PTHREAD_MUTEX_INITIALIZER; static char g_ws[OS_PATH_MAX]; /* ---------- roles ---------- */ static const struct { const char *name, *desc, *prompt; int read_only; } BUILTIN[] = { {"general", "General-purpose agent for multi-step work: researches, edits files and " "runs commands as needed.", "", 0}, {"explore", "Fast read-only explorer: searches and reads the workspace to answer a " "question (where is X, how does Y work, which files do Z). Several can " "run at once.", "You only read. Search broadly first (grep, glob, list_dir), then read " "just the relevant parts. Report concrete findings: file paths with line " "numbers, short excerpts when they matter, and a direct answer. Say what " "you looked for and did not find.", 1}, {"plan", "Read-only planner: investigates the material involved and returns a " "step-by-step implementation plan.", "You only read. Investigate what is involved, then return a concrete " "plan: what to produce or change and where, risks and edge cases, and " "how to check the result. Sketch code only where it removes ambiguity.", 1}, }; #define NBUILTIN ((int)(sizeof(BUILTIN) / sizeof(BUILTIN[0]))) static char *slurp(const char *path, size_t max) { FILE *fp = fopen(path, "rb"); if (!fp) return NULL; cst_buf_t b = {0}; char tmp[8192]; size_t n; while ((n = fread(tmp, 1, sizeof(tmp), fp)) > 0) { if (b.len + n > max) { cst_buf_free(&b); fclose(fp); return NULL; } cst_buf_append(&b, tmp, n); } fclose(fp); if (!b.buf) cst_buf_puts(&b, ""); cst_strip_bom(b.buf, &b.len); return cst_buf_steal(&b); } static int find_role(const cst_agent_role_t *v, int n, const char *name) { for (int i = 0; i < n; i++) if (strcmp(v[i].name, name) == 0) return i; return -1; } /* "a, b ,c" -> ",a,b,c," */ static void tool_list(const char *in, char *out, size_t sz) { size_t o = 0; out[0] = 0; if (!in || !in[0]) return; if (o + 1 < sz) out[o++] = ','; for (const char *p = in; *p && o + 2 < sz; p++) { if (*p == ' ' || *p == '\t' || *p == '[' || *p == ']' || *p == '"') continue; if (*p == ',' && out[o - 1] == ',') continue; out[o++] = *p; } if (out[o - 1] != ',' && o + 1 < sz) out[o++] = ','; out[o] = 0; if (strcmp(out, ",") == 0) out[0] = 0; } static int truthy(const char *v) { return strcmp(v, "true") == 0 || strcmp(v, "yes") == 0 || strcmp(v, "1") == 0; } static void load_dir(const char *dir, const char *source, cst_agent_role_t *v, int *n, int max) { os_dir_t *d = os_dir_open(dir); if (!d) return; const os_dirent_t *de; while ((de = os_dir_next(d)) != NULL && *n < max) { size_t len = strlen(de->name); if (de->name[0] == '.' || len < 4 || strcmp(de->name + len - 3, ".md") != 0) continue; char p[OS_PATH_MAX]; if (snprintf(p, sizeof(p), "%s/%s", dir, de->name) >= (int)sizeof(p)) continue; char *text = slurp(p, ROLE_FILE_MAX); if (!text) continue; char name[64], val[1024]; if (!cst_fm_get(text, "name", name, sizeof(name)) || !name[0]) snprintf(name, sizeof(name), "%.*s", (int)(len - 3), de->name); char clean[64]; cst_tool_sanitize_name(name, clean, sizeof(clean)); int at = find_role(v, *n, clean); if (at >= 0 && strcmp(v[at].source, "builtin") != 0) { free(text); /* the workspace one came first */ continue; } cst_agent_role_t *r = at >= 0 ? &v[at] : &v[(*n)++]; free(r->prompt); memset(r, 0, sizeof(*r)); snprintf(r->name, sizeof(r->name), "%s", clean); snprintf(r->source, sizeof(r->source), "%s", source); if (cst_fm_get(text, "description", val, sizeof(val))) snprintf(r->desc, sizeof(r->desc), "%.255s", val); if (cst_fm_get(text, "model", val, sizeof(val))) snprintf(r->model, sizeof(r->model), "%.127s", val); if (cst_fm_get(text, "tools", val, sizeof(val))) tool_list(val, r->tools, sizeof(r->tools)); if (cst_fm_get(text, "max_steps", val, sizeof(val))) r->max_steps = atoi(val) > 0 ? atoi(val) : 0; if (cst_fm_get(text, "readonly", val, sizeof(val)) || cst_fm_get(text, "read_only", val, sizeof(val))) r->read_only = truthy(val); const char *body = cst_fm_body(text); while (*body == '\n' || *body == '\r') body++; r->prompt = strdup(body); free(text); } os_dir_close(d); } int cst_roles_list(const char *ws, cst_agent_role_t *out, int max) { int n = 0; for (int i = 0; i < NBUILTIN && n < max; i++, n++) { memset(&out[n], 0, sizeof(out[n])); snprintf(out[n].name, sizeof(out[n].name), "%s", BUILTIN[i].name); snprintf(out[n].desc, sizeof(out[n].desc), "%s", BUILTIN[i].desc); snprintf(out[n].source, sizeof(out[n].source), "builtin"); out[n].read_only = BUILTIN[i].read_only; out[n].prompt = strdup(BUILTIN[i].prompt); } char p[OS_PATH_MAX]; if (ws && ws[0]) { snprintf(p, sizeof(p), "%.4000s/.agent/agents", ws); load_dir(p, "workspace", out, &n, max); } char cfg[OS_PATH_MAX]; if (os_config_dir(cfg, sizeof(cfg)) == 0) { snprintf(p, sizeof(p), "%.4000s/agents", cfg); load_dir(p, "user", out, &n, max); } /* custom roles after the built-ins, by name */ for (int i = NBUILTIN + 1; i < n; i++) for (int j = i; j > NBUILTIN && strcmp(out[j - 1].name, out[j].name) > 0; j--) { cst_agent_role_t t = out[j]; out[j] = out[j - 1]; out[j - 1] = t; } return n; } void cst_roles_free(cst_agent_role_t *v, int n) { for (int i = 0; i < n; i++) { free(v[i].prompt); v[i].prompt = NULL; } } char *cst_subagent_index(const char *ws) { cst_agent_role_t *v = calloc(CST_ROLES_MAX, sizeof(*v)); if (!v) return NULL; int n = cst_roles_list(ws, v, CST_ROLES_MAX); cst_buf_t b = {0}; cst_buf_puts(&b, "\nSub-agents (task tool, agent=; default " "general):\n"); for (int i = 0; i < n && b.len < INDEX_MAX; i++) cst_buf_printf(&b, "- %s%s: %.200s\n", v[i].name, v[i].read_only ? " (read-only)" : "", v[i].desc); cst_roles_free(v, n); free(v); return cst_buf_steal(&b); } /* ---------- transcripts ---------- */ static int sub_dir(const char *ws, const char *parent_id, char *out, size_t sz) { char dir[OS_PATH_MAX]; if (!parent_id || !parent_id[0] || strchr(parent_id, '/') || strchr(parent_id, '\\') || cst_agent_store_dir(ws, dir, sizeof(dir)) != 0) return -1; return snprintf(out, sz, "%s/%s", dir, parent_id) < (int)sz ? 0 : -1; } int cst_subagent_path(const char *ws, const char *parent_id, const char *file, char *out, size_t sz) { char dir[OS_PATH_MAX]; if (!file || strchr(file, '/') || strchr(file, '\\') || sub_dir(ws, parent_id, dir, sizeof(dir)) != 0) return -1; return snprintf(out, sz, "%s/%s.jsonl", dir, file) < (int)sz ? 0 : -1; } /* Reserve the next sub-.jsonl. */ static int next_file(const char *dir, char *file, size_t fsz) { pthread_mutex_lock(&g_seq_mu); int best = 0; os_dir_t *d = os_dir_open(dir); if (d) { const os_dirent_t *de; while ((de = os_dir_next(d)) != NULL) { int k = 0; if (sscanf(de->name, "sub-%d.jsonl", &k) == 1 && k > best) best = k; } os_dir_close(d); } int rc = -1; for (int k = best + 1; k < best + 100; k++) { char p[OS_PATH_MAX]; snprintf(file, fsz, "sub-%d", k); if (snprintf(p, sizeof(p), "%s/%s.jsonl", dir, file) >= (int)sizeof(p)) break; struct stat st; if (stat(p, &st) == 0) continue; FILE *fp = fopen(p, "ab"); if (fp) { fclose(fp); rc = 0; } break; } pthread_mutex_unlock(&g_seq_mu); return rc; } static void write_meta(const char *dir, const char *file, const char *role, const char *desc, const char *status, int steps, int tools, double ms) { char p[OS_PATH_MAX]; if (snprintf(p, sizeof(p), "%s/%s.json", dir, file) >= (int)sizeof(p)) return; cJSON *o = cJSON_CreateObject(); cJSON_AddStringToObject(o, "role", role); cJSON_AddStringToObject(o, "description", desc); cJSON_AddStringToObject(o, "status", status); cJSON_AddNumberToObject(o, "steps", steps); cJSON_AddNumberToObject(o, "tools", tools); cJSON_AddNumberToObject(o, "ms", ms); char *txt = cJSON_PrintUnformatted(o); cJSON_Delete(o); FILE *fp = txt ? fopen(p, "wb") : NULL; if (fp) { fputs(txt, fp); fputc('\n', fp); fclose(fp); } free(txt); } int cst_subagent_list(const char *ws, const char *parent_id, cst_subagent_rec_t *out, int max) { char dir[OS_PATH_MAX]; if (sub_dir(ws, parent_id, dir, sizeof(dir)) != 0) return 0; int n = 0; os_dir_t *d = os_dir_open(dir); if (!d) return 0; const os_dirent_t *de; while ((de = os_dir_next(d)) != NULL && n < max) { int k = 0; char tail[8] = ""; if (sscanf(de->name, "sub-%d.%7s", &k, tail) != 2 || strcmp(tail, "json") != 0) continue; char p[OS_PATH_MAX]; if (snprintf(p, sizeof(p), "%s/%s", dir, de->name) >= (int)sizeof(p)) continue; char *txt = slurp(p, 64 * 1024); cJSON *o = txt ? cJSON_Parse(txt) : NULL; free(txt); cst_subagent_rec_t *r = &out[n]; memset(r, 0, sizeof(*r)); snprintf(r->file, sizeof(r->file), "sub-%d", k); const cJSON *it; if ((it = cJSON_GetObjectItemCaseSensitive(o, "role")) && it->valuestring) snprintf(r->role, sizeof(r->role), "%s", it->valuestring); if ((it = cJSON_GetObjectItemCaseSensitive(o, "description")) && it->valuestring) snprintf(r->desc, sizeof(r->desc), "%s", it->valuestring); if ((it = cJSON_GetObjectItemCaseSensitive(o, "status")) && it->valuestring) snprintf(r->status, sizeof(r->status), "%s", it->valuestring); if ((it = cJSON_GetObjectItemCaseSensitive(o, "steps"))) r->steps = it->valueint; if ((it = cJSON_GetObjectItemCaseSensitive(o, "tools"))) r->tools = it->valueint; if ((it = cJSON_GetObjectItemCaseSensitive(o, "ms"))) r->ms = it->valuedouble; cJSON_Delete(o); struct stat st; if (stat(p, &st) == 0) r->mtime = (long long)st.st_mtime; n++; } os_dir_close(d); /* newest first: higher number */ for (int i = 1; i < n; i++) for (int j = i; j > 0 && atoi(out[j - 1].file + 4) < atoi(out[j].file + 4); j--) { cst_subagent_rec_t t = out[j]; out[j] = out[j - 1]; out[j - 1] = t; } return n; } /* ---------- the task tool ---------- */ typedef struct { const cst_agent_hooks_t *ph; const ctl_tool_call_t *pcall; const char *role; const char *ws; char last[2][80]; int ntools; volatile int *parent_cancel; cst_agent_t *child; atomic_int done; } sub_run_t; static const char *arg(const ctl_tool_call_t *c, const char *key) { for (int i = 0; i < c->nargs; i++) if (strcmp(c->args[i].key, key) == 0) return c->args[i].value ? c->args[i].value : ""; return NULL; } /* "grep foo" / "read_file src/x.c" for the progress line. */ static void step_summary(const ctl_tool_call_t *c, const char *ws, char *out, size_t sz) { static const char *keys[] = {"path", "pattern", "cmd", "command", "url", "query", "name", "file"}; const char *v = NULL; for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]) && !v; i++) v = arg(c, keys[i]); char one[48] = ""; size_t wl = ws ? strlen(ws) : 0; if (v && wl && strncmp(v, ws, wl) == 0 && v[wl] == '/' && v[wl + 1]) v += wl + 1; if (v) { size_t k = 0; for (; v[k] && v[k] != '\n' && k < 40; k++) one[k] = v[k]; while (k > 0 && ((unsigned char)one[k] & 0xC0) == 0x80 && v[k]) k--; one[k] = 0; } snprintf(out, sz, "%.30s%s%.40s", c->name, one[0] ? " " : "", one); } static void sub_tool_event(const char *phase, const ctl_tool_call_t *call, const char *detail, void *ud) { (void)detail; sub_run_t *r = ud; if (!call || strcmp(phase, "run") != 0) return; r->ntools++; memcpy(r->last[0], r->last[1], sizeof(r->last[0])); step_summary(call, r->ws, r->last[1], sizeof(r->last[1])); if (!r->ph || !r->ph->on_tool_event) return; char line[256]; snprintf(line, sizeof(line), "%s%s%s · %s", r->role, r->last[0][0] ? " · " : "", r->last[0], r->last[1]); pthread_mutex_lock(&g_hook_mu); r->ph->on_tool_event("progress", r->pcall, line, r->ph->userdata); pthread_mutex_unlock(&g_hook_mu); } static int sub_permission(const char *tool, const char *detail, const char *preview, void *ud) { sub_run_t *r = ud; if (!r->ph || !r->ph->on_permission) return CST_PERM_DENY; cst_buf_t d = {0}; size_t sbl = strlen(CST_PERM_SANDBOX); if (detail && strncmp(detail, CST_PERM_SANDBOX, sbl) == 0) { cst_buf_printf(&d, "%s[%s] %s", CST_PERM_SANDBOX, r->role, detail + sbl); } else { cst_buf_printf(&d, "[%s] %s", r->role, detail ? detail : tool); } /* one question at a time, whichever sub-agent asks */ pthread_mutex_lock(&g_hook_mu); int ans = r->ph->on_permission(tool, d.buf ? d.buf : tool, preview, r->ph->userdata); pthread_mutex_unlock(&g_hook_mu); cst_buf_free(&d); return ans; } static void *watch_main(void *p) { sub_run_t *r = p; while (!atomic_load(&r->done)) { if (r->parent_cancel && *r->parent_cancel) cst_agent_request_cancel(r->child); struct timespec ts = {0, 50 * 1000000L}; nanosleep(&ts, NULL); } return NULL; } static int out_text(cst_tool_result_t *r, int code, const char *text) { r->exit_code = code; r->out = strdup(text); r->out_len = r->out ? strlen(r->out) : 0; return 0; } static int task_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, cst_tool_result_t *r, void *ud) { (void)ud; if (!ctx->agent) return out_text(r, 1, "error: task needs a running agent"); const cst_agent_config_t *pcfg = cst_agent_config(ctx->agent); if (pcfg->depth > 0) return out_text(r, 1, "error: a sub-agent cannot start sub-agents; do " "the work yourself"); const char *desc = arg(c, "description"); const char *prompt = arg(c, "prompt"); const char *want = arg(c, "agent"); if (!want || !want[0]) want = "general"; if (!prompt || !prompt[0]) return out_text(r, 1, "error: prompt is required"); if (!desc || !desc[0]) desc = want; cst_agent_role_t *roles = calloc(CST_ROLES_MAX, sizeof(*roles)); if (!roles) return out_text(r, 1, "error: out of memory"); int nr = cst_roles_list(ctx->workspace_root, roles, CST_ROLES_MAX); int ri = find_role(roles, nr, want); if (ri < 0) { cst_buf_t b = {0}; cst_buf_printf(&b, "error: no agent named \"%s\" (available:", want); for (int i = 0; i < nr; i++) cst_buf_printf(&b, "%s %s", i ? "," : "", roles[i].name); cst_buf_puts(&b, ")"); cst_roles_free(roles, nr); free(roles); r->exit_code = 1; r->out_len = b.len; r->out = cst_buf_steal(&b); return 0; } const cst_agent_role_t *role = &roles[ri]; char pid[256], dir[OS_PATH_MAX], file[64], path[OS_PATH_MAX]; cst_agent_session_id(ctx->agent, pid, sizeof(pid)); if (sub_dir(ctx->workspace_root, pid, dir, sizeof(dir)) != 0 || os_mkdir_p(dir, 0755) != 0 || next_file(dir, file, sizeof(file)) != 0 || snprintf(path, sizeof(path), "%s/%s.jsonl", dir, file) >= (int)sizeof(path)) { cst_roles_free(roles, nr); free(roles); return out_text(r, 1, "error: cannot create the sub-agent transcript"); } cst_agent_config_t cc = *pcfg; cc.depth = pcfg->depth + 1; cc.plan_mode = 0; cc.read_only = role->read_only || pcfg->plan_mode; snprintf(cc.tools_only, sizeof(cc.tools_only), "%s", role->tools); cst_tool_set_disabled(&cc, "ask_user", 1); cc.role_prompt = role->prompt; if (role->model[0]) snprintf(cc.model, sizeof(cc.model), "%s", role->model); if (role->max_steps > 0) cc.max_steps = role->max_steps; snprintf(cc.session_path, sizeof(cc.session_path), "%s", path); cst_agent_undo_key(ctx->agent, cc.undo_sid, sizeof(cc.undo_sid), cc.undo_tag, sizeof(cc.undo_tag)); volatile int *parent_cancel = os_get_cancel_flag(); sub_run_t run; memset(&run, 0, sizeof(run)); run.ph = ctx->hooks; run.pcall = c; run.role = role->name; run.ws = ctx->workspace_root; run.parent_cancel = parent_cancel; run.child = cst_agent_create(&cc); if (!run.child) { cst_roles_free(roles, nr); free(roles); return out_text(r, 1, "error: cannot start the sub-agent"); } write_meta(dir, file, role->name, desc, "running", 0, 0, 0); cst_agent_hooks_t h; memset(&h, 0, sizeof(h)); h.on_tool_event = sub_tool_event; h.on_permission = sub_permission; h.userdata = &run; if (run.ph && run.ph->on_tool_event) { char line[160]; snprintf(line, sizeof(line), "%s · starting", role->name); pthread_mutex_lock(&g_hook_mu); run.ph->on_tool_event("progress", c, line, run.ph->userdata); pthread_mutex_unlock(&g_hook_mu); } pthread_t watcher; int watching = pthread_create(&watcher, NULL, watch_main, &run) == 0; double t0 = os_now_ms(); char err[512] = ""; int rc = cst_agent_run_turn_ex(run.child, prompt, &h, err, sizeof(err)); double ms = os_now_ms() - t0; atomic_store(&run.done, 1); if (watching) pthread_join(watcher, NULL); int cancelled = parent_cancel && *parent_cancel; /* the report is the last assistant text after the prompt */ cst_session_t *s = cst_agent_session(run.child); int n = cst_session_count(s), steps = 0; const char *final = NULL; int seen_user = 0; for (int i = 0; i < n; i++) { const cst_message_t *m = cst_session_get(s, i); if (m->role == CST_ROLE_USER && !m->tool_call_id[0] && !seen_user) { seen_user = 1; continue; } if (!seen_user || m->role != CST_ROLE_ASSISTANT) continue; steps++; if (m->content && m->content[0] && !m->tool_calls_json) final = m->content; } long tokens = cst_agent_last_prompt_tokens(run.child); cst_buf_t b = {0}; if (rc != 0) cst_buf_printf(&b, "error: sub-agent %s: %s\n", cancelled ? "cancelled" : "failed", err[0] ? err : "unknown error"); if (final) { while (*final == ' ' || *final == '\n') final++; cst_buf_puts(&b, final); } else if (rc == 0) { cst_buf_puts(&b, "(the sub-agent ended without a final report; its " "transcript has the details)"); } while (b.len && (b.buf[b.len - 1] == '\n' || b.buf[b.len - 1] == ' ')) b.buf[--b.len] = 0; char tok[32] = ""; if (tokens > 0) snprintf(tok, sizeof(tok), " · %.1fk tokens", (double)tokens / 1000.0); cst_buf_printf(&b, "\n\n[task %s · %d step%s · %d tool call%s · %.1f s%s · " "transcript .agent/sessions/%s/%s.jsonl]", role->name, steps, steps == 1 ? "" : "s", run.ntools, run.ntools == 1 ? "" : "s", ms / 1000.0, tok, pid, file); write_meta(dir, file, role->name, desc, rc == 0 ? "done" : cancelled ? "cancelled" : "error", steps, run.ntools, ms); cst_agent_destroy(run.child); /* run_turn and destroy re-pointed this thread's cancel flag */ os_set_cancel_flag(parent_cancel); cst_roles_free(roles, nr); free(roles); r->exit_code = rc == 0 ? 0 : cancelled ? 130 : 1; r->out_len = b.len; r->out = cst_buf_steal(&b); return 0; } /* Read-only roles may run side by side. */ static int task_parallel(const ctl_tool_call_t *c, void *ud) { (void)ud; const char *want = arg(c, "agent"); if (!want || !want[0]) return 0; /* general */ for (int i = 0; i < NBUILTIN; i++) if (strcmp(BUILTIN[i].name, want) == 0) return BUILTIN[i].read_only; cst_agent_role_t *v = calloc(CST_ROLES_MAX, sizeof(*v)); if (!v) return 0; int n = cst_roles_list(g_ws, v, CST_ROLES_MAX); int i = find_role(v, n, want); int ro = i >= 0 && v[i].read_only; cst_roles_free(v, n); free(v); return ro; } int cst_subagent_register_tool(const char *ws) { snprintf(g_ws, sizeof(g_ws), "%s", ws ? ws : ""); if (cst_tool_find("task")) return 0; cst_agent_role_t *v = calloc(CST_ROLES_MAX, sizeof(*v)); if (!v) return -1; int n = cst_roles_list(g_ws, v, CST_ROLES_MAX); cst_buf_t names = {0}; for (int i = 0; i < n; i++) cst_buf_printf(&names, "%s%s", i ? ", " : "", v[i].name); cst_roles_free(v, n); free(v); cst_buf_t schema = {0}; cst_buf_printf( &schema, "{\"type\":\"object\",\"properties\":{" "\"description\":{\"type\":\"string\",\"description\":\"3-6 word " "summary shown to the user\"}," "\"prompt\":{\"type\":\"string\",\"description\":\"the complete task: " "goal, context, constraints and what to report back\"}," "\"agent\":{\"type\":\"string\",\"description\":\"one of: %s " "(default general)\"}}," "\"required\":[\"description\",\"prompt\"]}", names.buf ? names.buf : "general"); cst_buf_free(&names); cst_tool_def_t d; memset(&d, 0, sizeof(d)); d.name = "task"; d.desc = "Hand a self-contained task to a sub-agent with its own context; " "you get back only its final report. Use it for broad searches " "or investigations that would flood your context, and for " "independent subtasks. It sees none of this conversation, so put " "everything it needs in prompt. Read-only agents (explore, plan) " "called together in one step run in parallel."; d.schema_json = schema.buf; d.perm = CST_TP_ASK; d.run_ex = task_run; d.parallel = task_parallel; d.owner = "builtin:task"; int rc = cst_tool_register(&d); cst_buf_free(&schema); return rc; }