#include "session.h" #include #include #ifndef LLSH_DBG #define LLSH_DBG() (getenv("LLSH_DBG")!=NULL) #define dbgf(...) do{ if(LLSH_DBG()) fprintf(stderr, __VA_ARGS__); }while(0) #endif #include #include #include #include #include #include #include #include #include #include "escape.h" #include "util.h" #include "config.h" #include "dotagents.h" #include "message.h" #include "compact.h" #include "goal.h" #include "agent.h" #include "tools.h" #include "llm.h" /* ANSI colors (session.rs), from escape.h. */ #define DIM ESC_DIM #define RESET ESC_RESET #define RED ESC_COLOR_RED #define YELLOW ESC_COLOR_YELLOW #define SNAPSHOT_VERSION 1UL /* ------------------------------------------------------------------ */ /* Small local helpers */ /* ------------------------------------------------------------------ */ static bool trimmed_nonempty(const char *s) { if (!s) return false; while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++; return *s != 0; } static const char *json_str(const cJSON *v, const char *key) { const cJSON *item = cJSON_GetObjectItem(v, key); if (cJSON_IsString(item) && item->valuestring) return item->valuestring; return ""; } static config config_clone(const config *c) { config out; out.provider = c->provider; out.model = c->model ? xstrdup(c->model) : NULL; out.base_url = c->base_url ? xstrdup(c->base_url) : NULL; out.api_key = c->api_key ? xstrdup(c->api_key) : NULL; out.preamble = c->preamble ? xstrdup(c->preamble) : NULL; out.max_turns = c->max_turns; out.max_retries = c->max_retries; out.context_limit = c->context_limit; out.compact_threshold = c->compact_threshold; out.clear_threshold = c->clear_threshold; out.verbose = c->verbose; out.skill_index_cap = c->skill_index_cap; out.shell_timeout_secs = c->shell_timeout_secs; out.max_output_chars = c->max_output_chars; out.max_file_bytes = c->max_file_bytes; sl_init(&out.shell_allowlist); for (size_t i = 0; i < c->shell_allowlist.len; i++) sl_push(&out.shell_allowlist, xstrdup(c->shell_allowlist.items[i])); sl_init(&out.shell_denylist); for (size_t i = 0; i < c->shell_denylist.len; i++) sl_push(&out.shell_denylist, xstrdup(c->shell_denylist.items[i])); out.confirm_destructive = c->confirm_destructive; return out; } /* ------------------------------------------------------------------ */ /* Free helpers */ /* ------------------------------------------------------------------ */ void sess_emit_free(sess_emit *e) { if (!e) return; free(e->text); e->text = NULL; } void sess_error_clear(sess_error *e) { if (!e) return; free(e->message); e->message = NULL; e->http_status = 0; e->transport = 0; e->retry_after_secs = 0; } void sess_retry_free(sess_retry *r) { if (!r) return; free(r->reason); free(r); } /* ------------------------------------------------------------------ */ /* Pure helpers */ /* ------------------------------------------------------------------ */ bool sess_is_transient_status(int status) { return status == 408 || status == 429 || status == 500 || status == 502 || status == 503 || status == 504 || status == 529; } unsigned long sess_backoff_secs(unsigned long attempt) { unsigned long exp = attempt > 1 ? attempt - 1 : 0; if (exp > 5) exp = 5; unsigned long ms = 1000UL << exp; if (ms > 30000) ms = 30000; return ms / 1000; } unsigned long sess_human_secs(unsigned long secs) { return secs < 1 ? 1 : secs; } sess_retry *sess_classify_retry(const sess_error *e) { if (!e) return NULL; if (e->http_status) { if (sess_is_transient_status(e->http_status)) { sess_retry *r = (sess_retry *) xmalloc(sizeof *r); r->reason = xstrfmt("HTTP %d", e->http_status); unsigned long ra = e->retry_after_secs; if (ra > 60) ra = 60; r->retry_after_secs = ra; r->has_retry_after = ra > 0; return r; } return NULL; } if (e->transport) { sess_retry *r = (sess_retry *) xmalloc(sizeof *r); r->reason = xstrdup("transient connection error"); r->retry_after_secs = 0; r->has_retry_after = false; return r; } return NULL; } bool sess_error_is_context_overflow(const sess_error *e) { if (!e || !e->message) return false; if (e->http_status == 413) return true; if (!e->http_status || e->http_status < 400 || e->http_status >= 500) return false; if (llm_strcasestr(e->message, "context") || llm_strcasestr(e->message, "exceed")) return true; return false; } int sess_assistant_emit(const char *kind, const char *text, const char *args, sess_emit *out) { if (xstreq(kind, "text")) { if (out) { *out = (sess_emit){0}; out->kind = SESS_EMIT_TEXT; out->text = text ? xstrdup(text) : NULL; } return 0; } if (xstreq(kind, "reasoning")) { if (out) { *out = (sess_emit){0}; out->kind = SESS_EMIT_REASONING; out->text = text ? xstrdup(text) : NULL; } return 0; } if (xstreq(kind, "tool_call")) { /* One-line transcript display. Tools with a custom renderer in the * display vtable (e.g. run_shell -> `$ `) use it; everything * else falls back to `# name `, where is the raw JSON the * model sent (tool params are a JSON object). `# name` is yellow, the * args are dimmed. Presentation only -- the call is executed directly * by exec_builtin. */ const char *name = text ? text : ""; const char *a = (args && args[0]) ? args : NULL; char *body = NULL; const tool_display *op = tool_display_for(name); if (op && op->render_call) body = op->render_call(a); if (!body) body = a ? xstrfmt("\n%s# %s%s %s%s%s\n", YELLOW, name, RESET, DIM, a, RESET) : xstrfmt("\n%s# %s%s\n", YELLOW, name, RESET); if (out) { *out = (sess_emit){0}; out->kind = SESS_EMIT_VERBATIM; out->text = body; } else free(body); return 0; } return 1; } int sess_tool_result_emit(const char *tool, const char *result, sess_emit *out) { if (!result || !result[0] || !out) return 1; size_t len = strlen(result); while (len > 0 && result[len - 1] == '\n') len--; xbuf b; xbuf_init(&b); xbuf_putn(&b, result, len); xbuf_push(&b, '\n'); char *body = xbuf_steal(&b); xbuf_free(&b); *out = (sess_emit){0}; out->kind = SESS_EMIT_RESULT; out->text = body; /* Shell-style tools (per the display vtable) render their results * muted; other tool results stay in the normal color. */ const tool_display *op = tool ? tool_display_for(tool) : NULL; out->from_shell = op != NULL && op->dim_result; return 0; } /* ------------------------------------------------------------------ */ /* Preamble / preset / profile helpers */ /* ------------------------------------------------------------------ */ char *session_active_preset_from(const dotagents *dot) { if (dot && dot->has_models && dot->models.default_name && dot->models.default_name[0]) return xstrdup(dot->models.default_name); return NULL; } char *sess_base_preamble_for(const config *cfg, const dotagents *dot) { const char *base = trimmed_nonempty(dot->system_prompt) ? dot->system_prompt : (cfg->preamble ? cfg->preamble : ""); return dotagents_compose_preamble(dot, base, cfg->skill_index_cap); } char *sess_preamble_for_profile(const dotagents *dot, const config *cfg, const char *profile) { if (profile) { const sub_agent *a = dotagents_enabled_sub_agent(dot, profile); if (a) { char *body = xstr_rtrim_dup(a->body ? a->body : ""); char *sections = dotagents_project_sections(dot, cfg->skill_index_cap); char *r = xstrjoin2(body, sections); free(body); free(sections); return r; } } return sess_base_preamble_for(cfg, dot); } int sess_apply_overrides(config *cfg, const char *model, const char *provider, const char *base_url, const char *api_key, bool strict, char **err) { if (provider) { char *perr = NULL; provider_kind pk = provider_parse(provider, &perr); if (perr) { if (strict) { if (err) *err = perr; else free(perr); return -1; } free(perr); } else { cfg->provider = pk; } } if (model) { free(cfg->model); cfg->model = xstrdup(model); } if (base_url) { free(cfg->base_url); cfg->base_url = xstrdup(base_url); } if (api_key) { free(cfg->api_key); cfg->api_key = xstrdup(api_key); } return 0; } config sess_effective_for_profile(const config *cfg, const dotagents *dot, const char *profile) { config out = config_clone(cfg); if (profile) { const sub_agent *a = dotagents_enabled_sub_agent(dot, profile); if (a && a->config) { cJSON *root = cJSON_Parse(a->config); if (root) { const char *model = json_str(root, "model"); const char *provider = json_str(root, "provider"); const char *base_url = json_str(root, "base_url"); const char *api_key = json_str(root, "api_key"); if (provider[0]) { char *perr = NULL; provider_kind pk = provider_parse(provider, &perr); if (perr) free(perr); else out.provider = pk; } if (model[0]) { free(out.model); out.model = xstrdup(model); } if (base_url[0]) { free(out.base_url); out.base_url = xstrdup(base_url); } if (api_key[0]) { free(out.api_key); out.api_key = xstrdup(api_key); } cJSON_Delete(root); } } } return out; } /* ------------------------------------------------------------------ */ /* Session state */ /* ------------------------------------------------------------------ */ struct session { config cfg; dotagents dot; history hist; char *base_preamble; char *active_preset; char *profile; compact_config compact; unsigned long last_context_tokens; bool summary_attempted_this_turn; /* Rubric compaction (SelfCompact) bookkeeping. */ unsigned long total_rounds; /* completed tool rounds (the "iteration") */ unsigned long rounds_since_probe; unsigned long rubric_summaries; char *debug_path; /* --debug: history is dumped here (NULL = off) */ llsh_model *model; llsh_model *summarizer; llsh_model *goal_evaluator; llsh_model *rubric; /* SelfCompact judge (PROV_ROLE_RUBRIC) */ goal_report *goal; /* name avoids the C++ type/member clash */ /* Cached effective state for (cfg, dot, profile). Rebuilt lazily; * invalidated whenever any of the three changes. */ config active; bool active_valid; char *active_preamble; strlist active_allowed; }; static void session_active_reset(session *s) { if (s->active_valid) { config_free(&s->active); s->active_valid = false; } free(s->active_preamble); s->active_preamble = NULL; sl_free(&s->active_allowed); } static void session_active_build(session *s) { if (s->active_valid) return; s->active = sess_effective_for_profile(&s->cfg, &s->dot, s->profile); s->active_preamble = sess_preamble_for_profile(&s->dot, &s->cfg, s->profile); if (s->profile) { const sub_agent *a = dotagents_enabled_sub_agent(&s->dot, s->profile); if (a && a->config) { cJSON *root = cJSON_Parse(a->config); if (root) { cJSON *tools = cJSON_GetObjectItem(root, "tools"); if (cJSON_IsArray(tools)) { const cJSON *t = NULL; cJSON_ArrayForEach(t, tools) if (cJSON_IsString(t) && t->valuestring) sl_push_copy(&s->active_allowed, t->valuestring); } cJSON_Delete(root); } } } s->active_valid = true; } session *session_new_with_model(llsh_model *model, config cfg, dotagents dot) { session *s = (session *) xmalloc(sizeof *s); new (s) session{}; s->model = model; s->cfg = cfg; s->dot = dot; s->base_preamble = sess_base_preamble_for(&cfg, &dot); s->active_preset = session_active_preset_from(&dot); s->profile = NULL; s->last_context_tokens = 0; s->summary_attempted_this_turn = false; s->total_rounds = 0; s->rounds_since_probe = 0; s->rubric_summaries = 0; s->debug_path = NULL; s->summarizer = NULL; s->goal_evaluator = NULL; s->goal = NULL; compact_config_from(cfg.context_limit, cfg.compact_threshold, cfg.clear_threshold, &s->compact); return s; } void session_free(session *s) { if (!s) return; session_active_reset(s); history_free(&s->hist); free(s->debug_path); free(s->base_preamble); free(s->active_preset); free(s->profile); goal_report_free(s->goal); llsh_model_free(s->model); llsh_model_free(s->summarizer); llsh_model_free(s->goal_evaluator); llsh_model_free(s->rubric); config_free(&s->cfg); dotagents_free(&s->dot); free(s); } session *session_with_summarizer(session *s, llsh_model *summarizer) { llsh_model_free(s->summarizer); s->summarizer = summarizer; return s; } session *session_with_goal_evaluator(session *s, llsh_model *evaluator) { llsh_model_free(s->goal_evaluator); s->goal_evaluator = evaluator; return s; } session *session_with_rubric(session *s, llsh_model *rubric) { llsh_model_free(s->rubric); s->rubric = rubric; return s; } /* ------------------------------------------------------------------ */ /* Accessors */ /* ------------------------------------------------------------------ */ char *session_model(session *s) { const config *cfg = session_active_config(s); return xstrdup(cfg->model ? cfg->model : ""); } const config *session_config(const session *s) { return &s->cfg; } char *session_active_preset(session *s) { return s->active_preset ? xstrdup(s->active_preset) : NULL; } char *session_profile_name(session *s) { return s->profile ? xstrdup(s->profile) : NULL; } const dotagents *session_dot(const session *s) { return &s->dot; } const strlist *session_allowed_tools(session *s) { session_active_build(s); return &s->active_allowed; } unsigned long session_context_tokens(session *s) { if (s->last_context_tokens > 0) return s->last_context_tokens; return compact_estimate_tokens(s->hist.msgs.data(), s->hist.msgs.size()); } unsigned long session_context_limit(session *s) { return s->compact.context_limit; } const goal_report *session_goal_report(session *s) { return s->goal; } void session_set_goal_report(session *s, goal_report *r) { goal_report_free(s->goal); s->goal = r; } char *session_last_assistant_text(session *s) { for (size_t i = s->hist.msgs.size(); i-- > 0;) { const message *m = &s->hist.msgs[i]; if (!m->is_assistant()) continue; xbuf b; xbuf_init(&b); bool first = true; for (size_t j = 0; j < m->assistant().parts.size(); j++) { const auto &p = m->assistant().parts[j]; const asst_text_part *t = std::get_if(&p); if (t && !t->text.empty()) { if (!first) xbuf_push(&b, '\n'); xbuf_put(&b, t->text.c_str()); first = false; } } char *text = xbuf_steal(&b); xbuf_free(&b); const char *p = text; while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++; if (*p) return text; free(text); } return NULL; } const char *session_current_preamble(session *s) { session_active_build(s); return s->active_preamble ? s->active_preamble : ""; } const config *session_active_config(session *s) { session_active_build(s); return &s->active; } int session_refresh_context_limit(session *s) { unsigned long limit = agent_fetch_context_limit(&s->cfg); if (limit > 0) { s->cfg.context_limit = limit; s->compact.context_limit = limit; return 1; } return 0; } /* ------------------------------------------------------------------ */ /* History */ /* ------------------------------------------------------------------ */ size_t session_history_len(session *s) { return s->hist.msgs.size(); } const message *session_history_at(session *s, size_t i) { return i < s->hist.msgs.size() ? &s->hist.msgs[i] : NULL; } void session_push_history(session *s, message m) { s->hist.msgs.push_back(std::move(m)); } unsigned long session_last_context_tokens(session *s) { return s->last_context_tokens; } void session_set_last_context_tokens(session *s, unsigned long t) { s->last_context_tokens = t; } void session_clear_history(session *s) { s->hist.msgs.clear(); s->last_context_tokens = 0; s->total_rounds = 0; s->rounds_since_probe = 0; s->rubric_summaries = 0; } int session_undo_last_turn(session *s) { size_t n = s->hist.msgs.size(); size_t last_user = compact_keep_boundary(s->hist.msgs.data(), n); if (last_user >= n) return -1; size_t removed = n - last_user; s->hist.msgs.erase(s->hist.msgs.begin() + static_cast(last_user), s->hist.msgs.end()); s->last_context_tokens = 0; return (int)removed; } size_t session_message_count(session *s) { return s->hist.msgs.size(); } char *session_transcript_markdown(session *s) { return compact_transcript_markdown(s->hist.msgs.data(), s->hist.msgs.size()); } /* ------------------------------------------------------------------ */ /* Compaction */ /* ------------------------------------------------------------------ */ void session_print_compact(const compact_report *report) { const char *icon, *label; if (report->tier == TIER_CLEARED) { icon = "\xE2\x97\x88"; label = "cleared stale tool output"; } else if (report->tier == TIER_SUMMARIZED) { icon = "\xE2\x97\x86"; label = "summarized earlier context"; } else { icon = "!"; label = "summarization failed; cleared stale tool output"; } char *details; if (report->tier == TIER_SUMMARIZED) details = xstrfmt("%zu file(s) re-injected", report->files_reinjected); else details = xstrfmt("%zu tool result(s) cleared", report->cleared_tool_results); char *line = xstrfmt("\n" DIM "%s %s: %lu \xE2\x86\x92 %lu tokens (%s)" RESET "\n", icon, label, report->tokens_before, report->tokens_after, details); free(details); fputs(line, stdout); fflush(stdout); free(line); } int session_compact_if_needed(session *s, bool force, compact_report *report, char **err) { dbgf("CFN enter force=%d n=%zu limit=%lu\n",(int)force,s->hist.msgs.size(),s->compact.context_limit); (void)err; size_t n = s->hist.msgs.size(); if (n == 0) return 0; if (s->compact.context_limit == 0 && !force) return 0; unsigned long tokens = s->last_context_tokens > 0 ? s->last_context_tokens : compact_estimate_tokens(s->hist.msgs.data(), n); if (!force) { unsigned long cbudget, kbudget; bool clear_due = clear_at(&s->compact, &cbudget) && tokens >= cbudget; bool compact_due = compact_at(&s->compact, &kbudget) && tokens >= kbudget; if (!clear_due && !compact_due) return 0; } size_t boundary = compact_keep_boundary(s->hist.msgs.data(), n); dbgf("CFN boundary=%zu tokens=%lu last_tok=%lu\n",boundary,tokens,s->last_context_tokens); if (boundary == 0 || boundary >= n) return 0; fputs("\n" RED "~ compaction" RESET "\n", stdout); fflush(stdout); unsigned long kb; bool want_summary = (force || (compact_at(&s->compact, &kb) && tokens >= kb)) && !s->summary_attempted_this_turn; dbgf("CFN want_summary=%d\n",(int)want_summary); if (!want_summary) { size_t cleared = compact_clear_old_tool_results(s->hist.msgs.data(), n, boundary); if (report) { report->tier = TIER_CLEARED; report->tokens_before = tokens; report->tokens_after = compact_estimate_tokens(s->hist.msgs.data(), n); report->cleared_tool_results = cleared; report->files_reinjected = 0; } s->last_context_tokens = 0; return 1; } /* stale = msgs[0..boundary); tail = msgs[boundary..n) */ const message *stale = s->hist.msgs.data(); char *summary = NULL; bool summary_ok = false; if (s->summarizer) { s->summary_attempted_this_turn = true; char *transcript = compact_transcript(stale, boundary); sess_error se = {0}; sess_error_clear(&se); summary = s->summarizer->complete(s->summarizer, transcript, &se); dbgf("CFN summarizer called -> summary=%p\n",(void*)summary); free(transcript); if (summary) summary_ok = true; else sess_error_clear(&se); } dbgf("CFN summary_ok=%d\n",(int)summary_ok); if (summary_ok) { strlist paths; sl_init(&paths); compact_recent_file_paths(summary, &paths); const char **file_paths = NULL; const char **file_contents = NULL; size_t nfiles = 0; if (paths.len) { file_paths = (const char* * ) xmalloc(paths.len * sizeof *file_paths); file_contents = (const char* * ) xmalloc(paths.len * sizeof *file_contents); } for (size_t i = 0; i < paths.len; i++) { char *content = NULL; size_t clen = 0; char *ferr = NULL; if (read_file_to_buf(paths.items[i], &content, &clen, &ferr) == 0) { file_paths[nfiles] = xstrdup(paths.items[i]); file_contents[nfiles] = content; nfiles++; } free(ferr); } sl_free(&paths); const message *tail = s->hist.msgs.data() + boundary; history *newhist = compact_compose_history( summary, file_paths, file_contents, nfiles, tail, n - boundary); s->hist.msgs = std::move(newhist->msgs); history_free(newhist); dbgf("CFN composed newhist len=%zu\n",s->hist.msgs.size()); free(newhist); free(summary); for (size_t i = 0; i < nfiles; i++) { free((char *)file_paths[i]); free((char *)file_contents[i]); } free(file_paths); free(file_contents); if (report) { report->tier = TIER_SUMMARIZED; report->tokens_before = tokens; report->tokens_after = compact_estimate_tokens( s->hist.msgs.data(), s->hist.msgs.size()); report->cleared_tool_results = 0; report->files_reinjected = nfiles; } s->last_context_tokens = 0; return 1; } /* fallback: clear stale tool results in place */ size_t cleared = compact_clear_old_tool_results(s->hist.msgs.data(), boundary, boundary); if (report) { report->tier = TIER_FALLBACK_CLEAR; report->tokens_before = tokens; report->tokens_after = compact_estimate_tokens(s->hist.msgs.data(), n); report->cleared_tool_results = cleared; report->files_reinjected = 0; } fprintf(stderr, DIM "[compact] summarization failed; cleared stale tool results instead" RESET "\n"); s->last_context_tokens = 0; return 1; } /* ------------------------------------------------------------------ */ /* Rubric compaction (SelfCompact, arXiv:2606.23525) */ /* ------------------------------------------------------------------ */ void session_note_tool_round(session *s) { if (!s) return; s->total_rounds++; s->rounds_since_probe++; } int session_force_compact(session *s, compact_report *report, char **err) { if (!s) { if (err) *err = xstrdup("no session"); return -1; } return session_compact_if_needed(s, true, report, err); } int session_rubric_probe(session *s, char **err) { if (err) *err = NULL; if (!s || !s->cfg.rubric_enabled || !s->rubric) return 0; unsigned long tokens = s->last_context_tokens > 0 ? s->last_context_tokens : compact_estimate_tokens(s->hist.msgs.data(), s->hist.msgs.size()); if (!compact_rubric_due(s->total_rounds, tokens, s->rubric_summaries, s->cfg.rubric_max_summaries, s->rounds_since_probe)) return 0; /* Probe the model with the whole trajectory plus the search rubric. */ char *transcript = session_transcript_markdown(s); if (!transcript) return 0; char *prompt = xstrfmt("%s\n\n%s", transcript, compact_rubric_prompt()); free(transcript); sess_error se = {0}; sess_error_clear(&se); char *answer = s->rubric->complete(s->rubric, prompt, &se); free(prompt); if (!answer) { char *msg = xstrdup(se.message ? se.message : "rubric probe failed"); sess_error_clear(&se); if (err) *err = msg; else free(msg); return -1; } compact_rubric r; memset(&r, 0, sizeof r); char *perr = NULL; int prc = compact_rubric_parse(answer, &r, &perr); free(answer); if (prc != 0) { char *msg = xstrdup(perr ? perr : "unparseable rubric verdict"); free(perr); if (err) *err = msg; else free(msg); return -1; } /* A probe was made: restart the round gate regardless of the verdict. */ s->rounds_since_probe = 0; if (!compact_rubric_fire(&r)) return 0; compact_report report; memset(&report, 0, sizeof report); int rc = session_compact_if_needed(s, true, &report, err); if (rc > 0) { s->rubric_summaries++; session_print_compact(&report); } return rc > 0 ? 1 : 0; } /* ------------------------------------------------------------------ */ /* Goal */ /* ------------------------------------------------------------------ */ int session_evaluate_goal(session *s, const goal_spec *spec, const char *last_reply, goal_verdict *out, char **err) { if (!s->goal_evaluator) { if (err) *err = xstrdup("no goal evaluator configured"); return -1; } const char *reply = trimmed_nonempty(last_reply) ? last_reply : "(the agent finished this turn without a text " "reply \xE2\x80\x94 it made only tool calls)"; char *prompt = xstrfmt( "GOAL (the success condition):\n%s\n\nAGENT'S LATEST REPLY:\n%s", spec->criteria, reply); sess_error se = {0}; sess_error_clear(&se); char *verdict_text = s->goal_evaluator->complete(s->goal_evaluator, prompt, &se); free(prompt); if (!verdict_text) { char *msg = xstrfmt("asking the goal evaluator: %s", se.message ? se.message : "unknown"); sess_error_clear(&se); if (err) *err = msg; else free(msg); return -1; } goal_parse_verdict(verdict_text, out); free(verdict_text); return 0; } /* ------------------------------------------------------------------ */ /* Model / profile */ /* ------------------------------------------------------------------ */ static int session_apply_preset(session *s, const char *name, char **err) { const model_preset *p = (s->dot.has_models) ? models_file_get(&s->dot.models, name) : NULL; if (!p) { char *e = xstrfmt("preset `%s` not found", name); if (err) *err = e; else free(e); return -1; } if (sess_apply_overrides(&s->cfg, p->model, p->provider, p->base_url, p->api_key, true, err) != 0) return -1; if (p->has_max_turns) s->cfg.max_turns = p->max_turns; if (p->has_context_limit) s->cfg.context_limit = p->context_limit; compact_config_from(s->cfg.context_limit, s->cfg.compact_threshold, s->cfg.clear_threshold, &s->compact); s->last_context_tokens = 0; if (s->cfg.provider != PROV_OLLAMA && !s->cfg.base_url && !s->cfg.api_key) { char *e = xstrfmt("no API key for provider `%s` from preset `%s`", provider_name(s->cfg.provider), name); if (err) *err = e; else free(e); return -1; } free(s->active_preset); s->active_preset = xstrdup(name); session_active_reset(s); return 0; } int session_set_model(session *s, const char *name, char **err) { if (s->dot.has_models && models_file_get(&s->dot.models, name) != NULL) return session_apply_preset(s, name, err); free(s->active_preset); s->active_preset = NULL; free(s->cfg.model); s->cfg.model = xstrdup(name); s->last_context_tokens = 0; session_active_reset(s); return 0; } int session_set_profile(session *s, const char *id, char **err) { if (id) { if (!dotagents_enabled_sub_agent(&s->dot, id)) { xbuf b; xbuf_init(&b); bool first = true; for (size_t i = 0; i < s->dot.sub_agent_count; i++) { if (!first) xbuf_put(&b, ", "); xbuf_put(&b, s->dot.sub_agents[i].id); first = false; } char *known = s->dot.sub_agent_count ? xbuf_steal(&b) : xstrdup("none defined"); xbuf_free(&b); char *e = xstrfmt("no enabled sub-agent `%s` (known: %s)", id, known); free(known); if (err) *err = e; else free(e); return -1; } } free(s->profile); s->profile = id ? xstrdup(id) : NULL; session_active_reset(s); return 0; } int session_refresh_dot(session *s, const char *workspace, const char *global, char **err) { (void)err; dotagents fresh; dotagents_load_from(&fresh, workspace, global); char *bp = sess_base_preamble_for(&s->cfg, &fresh); dotagents_free(&s->dot); s->dot = fresh; free(s->base_preamble); s->base_preamble = bp; session_active_reset(s); return 0; } /* ------------------------------------------------------------------ */ /* Save / load / resume */ /* ------------------------------------------------------------------ */ void session_snapshot_free(session_snapshot *snap) { if (!snap) return; free(snap->cwd); config_free(&snap->cfg); history_free(&snap->hist); free(snap->active_preset); free(snap->profile); free(snap); } static unsigned long long jnum(const nlohmann::json &j, const char *key) { if (j.contains(key) && j.at(key).is_number()) return (unsigned long long) j.at(key).get(); return 0; } static nlohmann::json snapshot_to_json(const session_snapshot *snap) { nlohmann::json cfg = nlohmann::json::object(); cfg["model"] = snap->cfg.model ? snap->cfg.model : ""; cfg["provider"] = provider_name(snap->cfg.provider); if (snap->cfg.base_url) cfg["base_url"] = snap->cfg.base_url; if (snap->cfg.api_key) cfg["api_key"] = snap->cfg.api_key; cfg["max_turns"] = snap->cfg.max_turns; cfg["max_retries"] = snap->cfg.max_retries; cfg["context_limit"] = snap->cfg.context_limit; cfg["compact_threshold"] = snap->cfg.compact_threshold; cfg["clear_threshold"] = snap->cfg.clear_threshold; cfg["verbose"] = snap->cfg.verbose; cfg["skill_index_cap"] = snap->cfg.skill_index_cap; cfg["confirm_destructive"] = snap->cfg.confirm_destructive; cfg["shell_allowlist"] = nlohmann::json::array(); for (size_t i = 0; i < snap->cfg.shell_allowlist.len; i++) cfg["shell_allowlist"].push_back(snap->cfg.shell_allowlist.items[i]); cfg["shell_denylist"] = nlohmann::json::array(); for (size_t i = 0; i < snap->cfg.shell_denylist.len; i++) cfg["shell_denylist"].push_back(snap->cfg.shell_denylist.items[i]); nlohmann::json hist = nlohmann::json::array(); for (const auto &m : snap->hist.msgs) hist.push_back(m); nlohmann::json o; o["version"] = snap->version; o["cwd"] = snap->cwd ? snap->cwd : ""; o["saved_at"] = snap->saved_at; o["config"] = cfg; o["history"] = std::move(hist); if (snap->active_preset) o["active_preset"] = snap->active_preset; if (snap->profile) o["profile"] = snap->profile; o["last_context_tokens"] = snap->last_context_tokens; return o; } static session_snapshot *json_to_snapshot(const nlohmann::json &o) { session_snapshot *snap = (session_snapshot *) xmalloc(sizeof *snap); new (snap) session_snapshot{}; snap->version = (unsigned long) jnum(o, "version"); sl_init(&snap->cfg.shell_allowlist); sl_init(&snap->cfg.shell_denylist); { std::string cwd = jstring(o, "cwd"); snap->cwd = xstrdup(cwd.c_str()); } snap->saved_at = (unsigned long long) jnum(o, "saved_at"); /* config */ if (o.contains("config") && o.at("config").is_object()) { const nlohmann::json &cfg = o.at("config"); snap->cfg.model = xstrdup(jstring(cfg, "model").c_str()); std::string provider = jstring(cfg, "provider"); char *perr = NULL; snap->cfg.provider = provider.empty() ? PROV_OPENAI : provider_parse(provider.c_str(), &perr); free(perr); std::string base_url = jstring(cfg, "base_url"); if (!base_url.empty()) snap->cfg.base_url = xstrdup(base_url.c_str()); std::string api_key = jstring(cfg, "api_key"); if (!api_key.empty()) snap->cfg.api_key = xstrdup(api_key.c_str()); snap->cfg.max_turns = (unsigned long) jnum(cfg, "max_turns"); snap->cfg.max_retries = (unsigned long) jnum(cfg, "max_retries"); snap->cfg.context_limit = (unsigned long) jnum(cfg, "context_limit"); if (cfg.contains("compact_threshold") && cfg.at("compact_threshold").is_number()) snap->cfg.compact_threshold = cfg.at("compact_threshold").get(); if (cfg.contains("clear_threshold") && cfg.at("clear_threshold").is_number()) snap->cfg.clear_threshold = cfg.at("clear_threshold").get(); if (cfg.contains("verbose") && cfg.at("verbose").is_boolean()) snap->cfg.verbose = cfg.at("verbose").get(); snap->cfg.skill_index_cap = (unsigned long) jnum(cfg, "skill_index_cap"); if (cfg.contains("confirm_destructive") && cfg.at("confirm_destructive").is_boolean()) snap->cfg.confirm_destructive = cfg.at("confirm_destructive").get(); if (cfg.contains("shell_allowlist") && cfg.at("shell_allowlist").is_array()) for (const auto &item : cfg.at("shell_allowlist")) if (item.is_string()) sl_push_copy(&snap->cfg.shell_allowlist, item.get_ref().c_str()); if (cfg.contains("shell_denylist") && cfg.at("shell_denylist").is_array()) for (const auto &item : cfg.at("shell_denylist")) if (item.is_string()) sl_push_copy(&snap->cfg.shell_denylist, item.get_ref().c_str()); } /* history */ if (o.contains("history") && o.at("history").is_array()) for (const auto &m : o.at("history")) if (m.is_object()) snap->hist.msgs.push_back(m.get()); std::string ap = jstring(o, "active_preset"); if (!ap.empty()) snap->active_preset = xstrdup(ap.c_str()); std::string prof = jstring(o, "profile"); if (!prof.empty()) snap->profile = xstrdup(prof.c_str()); snap->last_context_tokens = (unsigned long) jnum(o, "last_context_tokens"); return snap; } session_snapshot *session_snapshot_of(session *s) { session_snapshot *snap = (session_snapshot *) xmalloc(sizeof *snap); new (snap) session_snapshot{}; snap->version = SNAPSHOT_VERSION; char buf[4096]; snap->cwd = getcwd(buf, sizeof buf) ? xstrdup(buf) : xstrdup(""); struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts); snap->saved_at = (unsigned long long)ts.tv_sec; snap->cfg = config_clone(&s->cfg); for (const auto &m : s->hist.msgs) snap->hist.msgs.push_back(m); snap->active_preset = s->active_preset ? xstrdup(s->active_preset) : NULL; snap->profile = s->profile ? xstrdup(s->profile) : NULL; snap->last_context_tokens = s->last_context_tokens; return snap; } /* Write `raw` to `path` atomically (same-dir temp file + rename). */ static int write_atomic(const char *path, const char *raw, size_t len, char **err) { /* ensure parent dir(s) exist */ const char *slash = strrchr(path, '/'); if (slash && slash != path) { char *parent = xstrndup(path, (size_t)(slash - path)); if (mkdir_p(parent) != 0) { free(parent); if (err) *err = xstrfmt("creating parent dirs for `%s`: %s", path, strerror(errno)); return -1; } free(parent); } char *tmp = xstrfmt("%s.XXXXXX", path); int fd = mkstemp(tmp); int rc = -1; if (fd < 0) { if (err) *err = xstrfmt("creating temporary session file for `%s`: %s", path, strerror(errno)); } else if (fchmod(fd, 0600) != 0) { if (err) *err = xstrfmt("setting session file permissions: %s", strerror(errno)); close(fd); unlink(tmp); } else { size_t off = 0; while (off < len) { ssize_t n = write(fd, raw + off, len - off); if (n > 0) off += (size_t)n; else if (n < 0 && errno == EINTR) continue; else break; } if (off == len && fsync(fd) == 0 && close(fd) == 0) { rc = rename(tmp, path); if (rc != 0 && err) *err = xstrfmt("renaming session file to `%s`: %s", path, strerror(errno)); } else { int e = errno ? errno : EIO; close(fd); errno = e; if (err) *err = xstrfmt("writing session file: %s", strerror(errno)); } if (rc != 0) unlink(tmp); } free(tmp); return rc; } void session_set_debug_path(session *s, char *path) { if (!s) return; free(s->debug_path); s->debug_path = path; /* may be NULL (disabled) */ } int session_debug_dump(session *s, char **err) { if (!s) return 0; if (!s->debug_path || s->debug_path[0] == '\0') return 0; nlohmann::json hist = nlohmann::json::array(); for (const auto &m : s->hist.msgs) hist.push_back(m); std::string raw; try { raw = hist.dump(2); } catch (...) { if (err) *err = xstrdup("encoding debug dump"); return -1; } return write_atomic(s->debug_path, raw.c_str(), raw.size(), err); } int session_save(session *s, const char *path, char **err) { session_snapshot *snap = session_snapshot_of(s); std::string raw; try { raw = snapshot_to_json(snap).dump(); } catch (...) { session_snapshot_free(snap); if (err) *err = xstrdup("encoding session"); return -1; } session_snapshot_free(snap); return write_atomic(path, raw.c_str(), raw.size(), err); } session_snapshot *session_load(const char *path, char **err) { char *raw = NULL; size_t len = 0; char *rerr = NULL; if (read_file_to_buf(path, &raw, &len, &rerr) != 0) { free(rerr); if (raw) free(raw); char *e = xstrfmt("reading %s", path); if (err) *err = e; else free(e); return NULL; } free(rerr); nlohmann::json json; try { json = nlohmann::json::parse(raw); } catch (...) { free(raw); char *e = xstrfmt("parsing %s", path); if (err) *err = e; else free(e); return NULL; } free(raw); long long version = -1; if (json.is_object() && json.contains("version") && json.at("version").is_number()) version = (long long) json.at("version").get(); if (version != (long long)SNAPSHOT_VERSION) { char *e = xstrfmt("unsupported session version %lld (this build " "understands %lu)", version, SNAPSHOT_VERSION); if (err) *err = e; else free(e); return NULL; } session_snapshot *snap = json_to_snapshot(json); return snap; } session *session_resume(session_snapshot *snap, dotagents dot, llsh_model *model, char **err) { (void)err; char *profile = NULL; if (snap->profile) { if (dotagents_enabled_sub_agent(&dot, snap->profile)) { profile = xstrdup(snap->profile); } else { fprintf(stderr, "warning: saved sub-agent `%s` is not available " "here; using the main agent\n", snap->profile); } } session *s = (session *) xmalloc(sizeof *s); new (s) session{}; s->model = model; s->cfg = config_clone(&snap->cfg); s->dot = dot; s->hist.msgs = snap->hist.msgs; s->base_preamble = sess_base_preamble_for(&s->cfg, &s->dot); s->active_preset = snap->active_preset ? xstrdup(snap->active_preset) : NULL; s->profile = profile; s->last_context_tokens = snap->last_context_tokens; s->summarizer = NULL; s->goal_evaluator = NULL; s->goal = NULL; compact_config_from(s->cfg.context_limit, s->cfg.compact_threshold, s->cfg.clear_threshold, &s->compact); session_snapshot_free(snap); return s; } /* ------------------------------------------------------------------ */ /* Turn */ /* ------------------------------------------------------------------ */ static void session_append_streamed(session *s, const char *prompt, char *assistant, message *new_messages, size_t n_new) { if (new_messages && n_new > 0) { for (size_t i = 0; i < n_new; i++) s->hist.msgs.push_back(std::move(new_messages[i])); delete[] new_messages; } else { history_push(&s->hist, msg_user_text(prompt)); if (assistant && assistant[0]) history_push(&s->hist, msg_assistant_text(assistant)); } free(assistant); } static void session_discard_streamed(char *assistant, message *new_messages, size_t n_new) { if (new_messages) delete[] new_messages; free(assistant); } static int session_run_turn_impl(session *s, const char *prompt, std::atomic *stop, sess_emit_cb cb, void *user, char **err, int *result) { compact_report report; memset(&report, 0, sizeof report); char *cerr = NULL; s->summary_attempted_this_turn = false; int rc = session_compact_if_needed(s, false, &report, &cerr); if (rc < 0) { if (err) *err = cerr; else free(cerr); return -1; } if (rc == 1) session_print_compact(&report); if (stop && stop->load()) { history_push(&s->hist, msg_user_text(prompt)); if (result) *result = SESS_TURN_STOPPED; return 0; } unsigned long max_retries = s->cfg.max_retries; unsigned long retry = 0; bool overflow_retried = false; for (;;) { sess_error e = {0}; sess_error_clear(&e); char *assistant = NULL; unsigned long input_tokens = 0; message *new_messages = NULL; size_t n_new = 0; int rcs = s->model->stream( s->model, s->hist.msgs.data(), s->hist.msgs.size(), prompt, cb, user, stop, &assistant, &input_tokens, &new_messages, &n_new, &e); if (rcs == SESS_STREAM_OK) { session_append_streamed(s, prompt, assistant, new_messages, n_new); if (input_tokens > 0) s->last_context_tokens = input_tokens; /* SelfCompact rubric probe: the turn is complete and the context * size is final. A probe failure never fails the turn. */ char *perr = NULL; if (session_rubric_probe(s, &perr) != 0) { fprintf(stderr, DIM "[rubric] %s" RESET "\n", perr ? perr : "probe failed"); free(perr); } if (result) *result = SESS_TURN_COMPLETED; return 0; } if (rcs == SESS_STREAM_STOPPED) { session_append_streamed(s, prompt, assistant, new_messages, n_new); sess_error_clear(&e); if (result) *result = SESS_TURN_STOPPED; return 0; } if (rcs == SESS_STREAM_RETRYABLE) { session_discard_streamed(assistant, new_messages, n_new); sess_retry *r = sess_classify_retry(&e); if (retry < max_retries) { retry++; unsigned long delay = (r && r->has_retry_after) ? r->retry_after_secs : sess_backoff_secs(retry); char *notice = xstrfmt( "\n" DIM "[provider error (%s); retry %lu/%lu in %lus]" RESET "\n", r ? r->reason : "unknown", retry, max_retries, sess_human_secs(delay)); sess_emit ev; memset(&ev, 0, sizeof ev); ev.kind = SESS_EMIT_VERBATIM; ev.text = notice; if (cb) cb(&ev, user); free(notice); sess_retry_free(r); sess_error_clear(&e); /* Actually wait before retrying; a stop request ends the turn. */ unsigned long delay_ms = delay * 1000UL; for (unsigned long slept = 0; slept < delay_ms; slept += 50) { if (stop && stop->load()) break; sleep_ms(50); } if (stop && stop->load()) { history_push(&s->hist, msg_user_text(prompt)); if (result) *result = SESS_TURN_STOPPED; return 0; } continue; } else { char *msg = xstrfmt("provider error after %lu retries: %s", retry, e.message ? e.message : "unknown"); sess_error_clear(&e); sess_retry_free(r); if (err) *err = msg; else free(msg); return -1; } } /* SESS_STREAM_FAILED */ /* Context overflow is not a provider fault: the request grew past * the window, so compact the history and retry once. One recovery * per turn keeps a too-large tail (or a provider that reports a * smaller window than it enforces) from looping the turn. */ dbgf("RUN overflow-retried=%d is_overflow=%d limit=%lu\n",(int)overflow_retried,(int)sess_error_is_context_overflow(&e),s->compact.context_limit); if (!overflow_retried && s->compact.context_limit > 0 && sess_error_is_context_overflow(&e)) { overflow_retried = true; char *reason = e.message ? xstrdup(e.message) : xstrdup("context overflow"); sess_error_clear(&e); sess_emit ev; memset(&ev, 0, sizeof ev); ev.kind = SESS_EMIT_VERBATIM; ev.text = xstrfmt( "\n" DIM "[request exceeds the context window: %s; compacting and " "retrying]" RESET "\n", reason); if (cb) cb(&ev, user); sess_emit_free(&ev); free(reason); compact_report crep; memset(&crep, 0, sizeof crep); char *cerr2 = NULL; int crr = session_compact_if_needed(s, true, &crep, &cerr2); if (cerr2) { free(cerr2); crr = 0; } if (crr > 0) session_print_compact(&crep); /* Retry once even when the compact pass was a no-op: a provider * that reports a smaller window than it enforces may still accept * the resubmitted request. overflow_retried stops a second pass. * e was cleared above; the fresh error (if any) is surfaced below. */ continue; } char *msg = xstrfmt("stream error: %s", e.message ? e.message : "unknown"); sess_error_clear(&e); if (err) *err = msg; else free(msg); return -1; } } int session_run_turn(session *s, const char *prompt, std::atomic *stop, sess_emit_cb cb, void *user, char **err, int *result) { int rc = session_run_turn_impl(s, prompt, stop, cb, user, err, result); /* --debug: refresh the history dump on every turn, whatever the outcome. * A failed dump never fails the turn; it is reported on stderr. */ char *derr = NULL; if (session_debug_dump(s, &derr) != 0) { if (s && s->debug_path) fprintf(stderr, "debug: %s\n", derr ? derr : "dump failed"); free(derr); } return rc; }