diff options
Diffstat (limited to 'src/session.cpp')
| -rw-r--r-- | src/session.cpp | 1555 |
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; +} |