summaryrefslogtreecommitdiffstats
path: root/src/session.cpp
diff options
context:
space:
mode:
authorEthan Werner <mail@ethanwerner.com>2026-10-10 20:55:33 +0000
committerEthan Werner <mail@ethanwerner.com>2026-10-10 20:56:50 +0000
commitf8423ebea413605b42de23f5dd06c5479d3fa1dd (patch)
tree2612a5eb026c59e7aae01f10a1cf62f2ee43042c /src/session.cpp
initial commitHEADmaster
Diffstat (limited to 'src/session.cpp')
-rw-r--r--src/session.cpp1555
1 files changed, 1555 insertions, 0 deletions
diff --git a/src/session.cpp b/src/session.cpp
new file mode 100644
index 0000000..7e82173
--- /dev/null
+++ b/src/session.cpp
@@ -0,0 +1,1555 @@
+#include "session.h"
+#include <unistd.h>
+#include <stdlib.h>
+#ifndef LLSH_DBG
+#define LLSH_DBG() (getenv("LLSH_DBG")!=NULL)
+#define dbgf(...) do{ if(LLSH_DBG()) fprintf(stderr, __VA_ARGS__); }while(0)
+#endif
+
+#include <errno.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+#include <sys/stat.h>
+
+#include <cjson/cJSON.h>
+
+#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 -> `$ <command>`) use it; everything
+ * else falls back to `# name <args>`, where <args> 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<asst_text_part>(&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<long>(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<double>();
+ 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<double>();
+ if (cfg.contains("clear_threshold") &&
+ cfg.at("clear_threshold").is_number())
+ snap->cfg.clear_threshold = cfg.at("clear_threshold").get<double>();
+ if (cfg.contains("verbose") && cfg.at("verbose").is_boolean())
+ snap->cfg.verbose = cfg.at("verbose").get<bool>();
+ 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<bool>();
+ 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<const std::string &>().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<const std::string &>().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<message>());
+ 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<double>();
+ 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<bool> *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<bool> *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;
+}