diff options
| author | Ethan Werner <mail@ethanwerner.com> | 2026-10-10 20:55:33 +0000 |
|---|---|---|
| committer | Ethan Werner <mail@ethanwerner.com> | 2026-10-10 20:56:50 +0000 |
| commit | f8423ebea413605b42de23f5dd06c5479d3fa1dd (patch) | |
| tree | 2612a5eb026c59e7aae01f10a1cf62f2ee43042c /src/compact.cpp | |
Diffstat (limited to 'src/compact.cpp')
| -rw-r--r-- | src/compact.cpp | 596 |
1 files changed, 596 insertions, 0 deletions
diff --git a/src/compact.cpp b/src/compact.cpp new file mode 100644 index 0000000..65e2210 --- /dev/null +++ b/src/compact.cpp @@ -0,0 +1,596 @@ +#include "compact.h" + +#include <ctype.h> +#include <stdlib.h> +#include <string.h> + +/* ------------------------------------------------------------------ */ +/* Preamble */ +/* ------------------------------------------------------------------ */ + +const char *compact_summarizer_preamble(void) { + return "You are a context compactor for a terminal coding agent. You are " + "given the transcript of the earlier part of that agent's " + "conversation, which no longer fits in the context window. Compress " + "it into a faithful, dense summary that lets the agent continue " + "seamlessly.\n" + "\n" + "Write ONLY the summary, in Markdown, with these exact sections in " + "this order:\n" + "\n" + "## Goal\n" + "What the user is trying to accomplish.\n" + "\n" + "## Decisions\n" + "Choices made and the reasons that still matter.\n" + "\n" + "## Work done\n" + "Concrete steps taken: files touched, commands run, and their " + "results.\n" + "\n" + "## Open issues\n" + "Errors, edge cases, or problems that remain unresolved.\n" + "\n" + "## Next steps\n" + "What should happen next.\n" + "\n" + "## Files\n" + "One bullet per file the agent read or edited, as `- <path>`.\n" + "\n" + "Rules:\n" + "- Recall over precision: keep every fact the agent would otherwise " + "have to rediscover; drop nothing load-bearing.\n" + "- Be factual and terse; never invent details that are not in the " + "transcript.\n" + "- Discard raw tool output and verbatim file contents; summarize " + "their substance instead.\n" + "- Keep it under 800 words."; +} + +/* ------------------------------------------------------------------ */ +/* Config */ +/* ------------------------------------------------------------------ */ + +void compact_config_from(unsigned long context_limit, double compact_threshold, + double clear_threshold, compact_config *out) { + out->context_limit = context_limit; + out->compact_threshold = compact_threshold; + out->clear_threshold = clear_threshold; +} + +bool compact_at(const compact_config *c, unsigned long *out) { + if (c->compact_threshold <= 0.0) + return false; + *out = (unsigned long)(c->compact_threshold * (double)c->context_limit); + return true; +} + +bool clear_at(const compact_config *c, unsigned long *out) { + if (c->clear_threshold <= 0.0) + return false; + *out = (unsigned long)(c->clear_threshold * (double)c->context_limit); + return true; +} + +/* ------------------------------------------------------------------ */ +/* Token estimation */ +/* ------------------------------------------------------------------ */ + +static size_t trc_bytes(const tool_result_content &c) { + if (std::holds_alternative<trc_image>(c)) + return 8; + if (const auto *t = std::get_if<trc_text>(&c)) + return t->text.size(); + if (const auto *s = std::get_if<trc_json>(&c)) + return s->value.size(); + return 0; +} + +static size_t uc_bytes(const user_part &p) { + if (const auto *t = std::get_if<user_text_part>(&p)) + return t->text.size(); + const auto &r = std::get<user_tool_result_part>(p); + size_t total = r.name.size(); + for (const auto &c : r.content) + total += trc_bytes(c); + return total; +} + +static size_t ac_bytes(const assistant_part &p) { + if (std::holds_alternative<asst_image_part>(p)) + return 0; + if (const auto *c = std::get_if<asst_tool_call_part>(&p)) + return c->name.size() + c->args.size(); + if (const auto *t = std::get_if<asst_text_part>(&p)) + return t->text.size(); + if (const auto *r = std::get_if<asst_reasoning_part>(&p)) + return r->text.size(); + return 0; +} + +static size_t message_bytes(const message *m) { + if (m->is_system()) + return m->system().text.size(); + size_t total = 0; + if (m->is_user()) { + for (const auto &p : m->user().parts) + total += uc_bytes(p); + } else if (m->is_assistant()) { + for (const auto &p : m->assistant().parts) + total += ac_bytes(p); + } + return total; +} + +unsigned long compact_estimate_tokens(const message *msgs, size_t n) { + unsigned long long bytes = 0; + for (size_t i = 0; i < n; i++) + bytes += message_bytes(&msgs[i]); + return (unsigned long)((bytes + 3) / 4); +} + +/* ------------------------------------------------------------------ */ +/* Boundary + clearing */ +/* ------------------------------------------------------------------ */ + +static bool compact_is_pure_text_user(const message *m) { + return m->is_user() && m->user().parts.size() == 1 && + std::holds_alternative<user_text_part>(m->user().parts[0]); +} + +size_t compact_keep_boundary(const message *msgs, size_t n) { + for (size_t i = n; i-- > 0;) { + if (compact_is_pure_text_user(&msgs[i])) + return i; + } + return n; +} + +size_t compact_clear_old_tool_results(message *msgs, size_t n, size_t boundary) { + size_t cleared = 0; + for (size_t i = 0; i < boundary && i < n; i++) { + message *m = &msgs[i]; + if (!m->is_user()) + continue; + for (auto &p : m->user().parts) { + user_tool_result_part *r = std::get_if<user_tool_result_part>(&p); + if (!r) + continue; + bool already = false; + for (const auto &c : r->content) { + const trc_text *t = std::get_if<trc_text>(&c); + if (t && t->text == CLEARED_PLACEHOLDER) + already = true; + } + if (already) + continue; + r->content.clear(); + trc_text t; + t.text = CLEARED_PLACEHOLDER; + r->content.push_back(std::move(t)); + cleared++; + } + } + return cleared; +} + +/* ------------------------------------------------------------------ */ +/* Transcripts */ +/* ------------------------------------------------------------------ */ + +static void put_trc(xbuf *b, const tool_result_content &c) { + if (std::holds_alternative<trc_image>(c)) { + xbuf_put(b, "[image]"); + return; + } + if (const auto *t = std::get_if<trc_text>(&c)) { + if (!t->text.empty()) + xbuf_put(b, t->text.c_str()); + return; + } + if (const auto *s = std::get_if<trc_json>(&c)) + if (!s->value.empty()) + xbuf_put(b, s->value.c_str()); +} + +char *compact_transcript(const message *msgs, size_t n) { + xbuf b; + xbuf_init(&b); + for (size_t i = 0; i < n; i++) { + const message *m = &msgs[i]; + if (m->is_system()) { + xbuf_put(&b, "System: "); + xbuf_put(&b, m->system().text.c_str()); + xbuf_push(&b, '\n'); + } else if (m->is_user()) { + for (size_t j = 0; j < m->user().parts.size(); j++) { + const auto &p = m->user().parts[j]; + if (const auto *t = std::get_if<user_text_part>(&p)) { + xbuf_put(&b, "User: "); + xbuf_put(&b, t->text.c_str()); + xbuf_push(&b, '\n'); + } else { + const auto &r = std::get<user_tool_result_part>(p); + xbuf_fmt(&b, "ToolResult(%s): ", r.name.c_str()); + for (const auto &c : r.content) + put_trc(&b, c); + xbuf_push(&b, '\n'); + } + } + } else if (m->is_assistant()) { + for (size_t j = 0; j < m->assistant().parts.size(); j++) { + const auto &p = m->assistant().parts[j]; + if (const auto *t = std::get_if<asst_text_part>(&p)) { + xbuf_put(&b, "Assistant: "); + xbuf_put(&b, t->text.c_str()); + xbuf_push(&b, '\n'); + } else if (const auto *c = std::get_if<asst_tool_call_part>(&p)) { + xbuf_fmt(&b, "Assistant tool call %s: %s", + c->name.c_str(), c->args.c_str()); + xbuf_push(&b, '\n'); + } else if (const auto *r = std::get_if<asst_reasoning_part>(&p)) { + xbuf_put(&b, "Assistant reasoning: "); + xbuf_put(&b, r->text.c_str()); + xbuf_push(&b, '\n'); + } + } + } + } + return xbuf_steal(&b); +} + +static void push_md_tool_result(xbuf *b, const tool_result_content &c) { + if (const auto *t = std::get_if<trc_text>(&c)) { + xbuf_put(b, t->text.c_str()); + xbuf_put(b, "\n\n"); + } else if (const auto *s = std::get_if<trc_json>(&c)) { + xbuf_put(b, "```json\n"); + xbuf_put(b, s->value.c_str()); + xbuf_put(b, "\n```\n\n"); + } else { + xbuf_put(b, "[image]\n\n"); + } +} + +char *compact_transcript_markdown(const message *msgs, size_t n) { + xbuf b; + xbuf_init(&b); + xbuf_put(&b, "# llsh transcript\n\n"); + if (n == 0) { + xbuf_put(&b, "_No messages yet._\n"); + return xbuf_steal(&b); + } + for (size_t i = 0; i < n; i++) { + const message *m = &msgs[i]; + if (m->is_system()) { + xbuf_put(&b, "## System\n\n"); + xbuf_put(&b, m->system().text.c_str()); + xbuf_put(&b, "\n\n"); + } else if (m->is_user()) { + for (size_t j = 0; j < m->user().parts.size(); j++) { + const auto &p = m->user().parts[j]; + if (const auto *t = std::get_if<user_text_part>(&p)) { + xbuf_put(&b, "## User\n\n"); + xbuf_put(&b, t->text.c_str()); + xbuf_put(&b, "\n\n"); + } else { + const auto &r = std::get<user_tool_result_part>(p); + xbuf_fmt(&b, "### Tool result: `%s`\n\n", + r.name.c_str()); + for (const auto &c : r.content) + push_md_tool_result(&b, c); + } + } + } else if (m->is_assistant()) { + for (size_t j = 0; j < m->assistant().parts.size(); j++) { + const auto &p = m->assistant().parts[j]; + if (const auto *t = std::get_if<asst_text_part>(&p)) { + xbuf_put(&b, "## Assistant\n\n"); + xbuf_put(&b, t->text.c_str()); + xbuf_put(&b, "\n\n"); + } else if (const auto *c = std::get_if<asst_tool_call_part>(&p)) { + xbuf_fmt(&b, "### Tool call: `%s`\n\n", + c->name.c_str()); + if (c->args.empty() || c->args == "null") { + xbuf_put(&b, "_no arguments_\n\n"); + } else { + xbuf_put(&b, "```json\n"); + xbuf_put(&b, c->args.c_str()); + xbuf_put(&b, "\n```\n\n"); + } + } else if (const auto *r = std::get_if<asst_reasoning_part>(&p)) { + if (!r->text.empty()) { + xbuf_put(&b, "> **reasoning** "); + xbuf_put(&b, r->text.c_str()); + xbuf_put(&b, "\n\n"); + } + } + } + } + } + return xbuf_steal(&b); +} + +/* ------------------------------------------------------------------ */ +/* File-path extraction */ +/* ------------------------------------------------------------------ */ + +static bool starts_with_i(const char *s, size_t n, const char *prefix) { + size_t pn = strlen(prefix); + if (n < pn) + return false; + for (size_t i = 0; i < pn; i++) { + char a = s[i]; + if (a >= 'A' && a <= 'Z') + a = (char)(a - 'A' + 'a'); + if (a != prefix[i]) + return false; + } + return true; +} + +void compact_recent_file_paths(const char *summary, strlist *out) { + sl_init(out); + if (!summary) + return; + bool in_files = false; + const char *p = summary; + while (*p) { + const char *nl = strchr(p, '\n'); + size_t linelen = nl ? (size_t)(nl - p) : strlen(p); + const char *line_end = p + linelen; + size_t tlen; + const char *line = xtrim_span(p, linelen, &tlen); + if (tlen > 0 && line[0] == '#') { + const char *h = line; + while (*h == '#') + h++; + size_t hl; + h = xtrim_span(h, (size_t)(line_end - h), &hl); + in_files = starts_with_i(h, hl, "files"); + } else if (in_files && tlen > 0 && (line[0] == '-' || line[0] == '*')) { + const char *rest = line + 1; + size_t rl; + rest = xtrim_span(rest, (size_t)(line_end - rest), &rl); + if (rl > 0) + sl_push(out, xstrndup(rest, rl)); + } + p = nl ? nl + 1 : p + linelen; + } +} + +/* ------------------------------------------------------------------ */ +/* Compose history */ +/* ------------------------------------------------------------------ */ + +history *compact_compose_history(const char *summary, + const char **file_paths, + const char **file_contents, size_t nfiles, + const message *tail, size_t ntail) { + xbuf body; + xbuf_init(&body); + xbuf_put(&body, + "The earlier conversation was summarized to fit the context " + "window. Continue from this summary.\n\n"); + if (summary) { + size_t tlen; + const char *s = xtrim_span(summary, strlen(summary), &tlen); + xbuf_putn(&body, s, tlen); + xbuf_push(&body, '\n'); + } + + unsigned long total = 0; + size_t taken = 0; + for (size_t i = 0; i < nfiles && i < MAX_FILES; i++) { + if (total >= FILES_CAP) + break; + const char *content = file_contents[i] ? file_contents[i] : ""; + size_t cpoints = utf8_len(content); + unsigned long take = PER_FILE_CAP; + if (take > FILES_CAP - total) + take = FILES_CAP - total; + if (take > cpoints) + take = cpoints; + if (take == 0) + break; + size_t take_bytes = utf8_byte_len(content, take); + total += take; + taken++; + xbuf_fmt(&body, "\n### %s\n```\n", file_paths[i] ? file_paths[i] : ""); + xbuf_putn(&body, content, take_bytes); + xbuf_put(&body, "\n```\n"); + } + + history *h = (history *) xmalloc(sizeof(*h)); + new (h) history{}; + char *body_text = xbuf_steal(&body); + h->msgs.push_back(msg_user_text(body_text)); + free(body_text); + for (size_t i = 0; i < ntail; i++) + h->msgs.push_back(tail[i]); + (void)taken; + return h; +} + +/* ------------------------------------------------------------------ */ +/* SelfCompact-style rubric probe (arXiv:2606.23525) */ +/* ------------------------------------------------------------------ */ + +const char *compact_rubric_preamble(void) { + return "You are a careful judge for a terminal coding agent. You are " + "given the agent's trajectory and a search rubric of four yes/no " + "questions (C1, C2, C3, N1). Answer each question exactly in the " + "requested line format, with the evidence it asks for. Do not " + "summarize the trajectory, do not restate the questions, and add " + "no text beyond the four verdict lines."; +} + +const char *compact_rubric_prompt(void) { + return "You are about to decide whether to compress your conversation " + "history into a summary that REPLACES the full history above. " + "After compression, you continue from only [system, original " + "question, assistant summary, user continue]. Compression is " + "irreversible: anything not preserved in the summary is gone.\n" + "\n" + "Compression is safe ONLY when ALL FOUR of the following hold: " + "(C1) the trajectory has reached a closed unit (not mid-thought), " + "(C2) the essential information is reducible to 3-5 citable " + "facts without loss, (C3) something has progressed since the last " + "compression, (N1) you are NOT currently stuck in a way " + "summarization would mask. Answer C1, C2, C3, N1 honestly. Each " + "Y answer requires verbatim evidence quoted from the trajectory " + "above; answers without evidence default to N.\n" + "\n" + "C1 CLOSED-UNIT: The most recent assistant message is a closed " + "unit - a completed tool call whose result is now visible, or a " + "completed sub-analysis with a clear stopping point. It is NOT " + "mid-sentence reasoning (\"Let me now check...\", \"I should " + "next look at...\"), and not a half-formulated query. *If Y*, " + "quote the closing fragment of the last assistant message. *If " + "N*, quote the open fragment that shows the trajectory is " + "mid-thought.\n" + "C2 SUMMARIZABLE: You can write 3-5 essential facts (with " + "verbatim citations from the trajectory) that future-you needs " + "to continue after compression. Each fact must be a single " + "concrete statement: a name, date, URL, quoted claim, or " + "resolved sub-question. Answer N if the trajectory's value is " + "dispersed across many small inferences (e.g., a list of dead-" + "end queries needed to avoid retries, negative results that " + "constrain the hypothesis space) that would be lost without the " + "dispersal. *If Y*, list the 3-5 facts numbered, each with a " + "verbatim citation in quotes. *If N*, name in one sentence the " + "class of information that would be lost.\n" + "C3 PROGRESS: Since the most recent compression (or since the " + "start of the conversation if none), you have either obtained a " + "new concrete fact (name, date, URL, or quoted claim) OR " + "refined the sub-question being pursued. *If Y*, name the new " + "fact or refined sub-question. *If N*, state that you are " + "returning the same state you compressed from.\n" + "N1 STUCK: At least 3 of your last 4 tool calls returned no new " + "information (i.e., were duplicates or returned already-known " + "content). If you have made fewer than 4 tool calls total, " + "answer N. *If Y*, name 1 distinct strategy you have NOT yet " + "tried (different tool, different query type, different angle " + "on the question). *If N*, name one new fact obtained " + "recently.\n" + "\n" + "Output: exactly 4 lines, no preamble or trailing text:\n" + "C1: Y/N -- <evidence>\n" + "C2: Y/N -- <evidence>\n" + "C3: Y/N -- <evidence>\n" + "N1: Y/N -- <evidence>"; +} + +/* First line whose start matches `label` (case-insensitive, followed by + * whitespace or ':'), or NULL. */ +static const char *rubric_line(const char *answer, const char *label) { + size_t ll = strlen(label); + const char *p = answer; + while (*p) { + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + if (len >= ll && strncasecmp(p, label, ll) == 0 && + (len == ll || p[ll] == ' ' || p[ll] == '\t' || p[ll] == ':')) + return p; + if (!nl) + break; + p = nl + 1; + } + return NULL; +} + +/* Verdict at `line` (just past the label). Sets *evidence to the + * remainder of the line after an optional `--` separator. Returns 1 = Y, + * 0 = N, -1 = no verdict token. A Y with empty evidence is returned as N + * (the paper's "answers without evidence default to N"). */ +static int rubric_verdict(const char *line, const char **evidence) { + while (*line == ' ' || *line == '\t') + line++; + if (*line == ':') { + line++; + while (*line == ' ' || *line == '\t') + line++; + } + char v = (char)toupper((unsigned char)*line); + if (v != 'Y' && v != 'N') + return -1; + int verdict = (v == 'Y'); + line++; + while (*line == ' ' || *line == '\t') + line++; + if (line[0] == '-' && line[1] == '-') + line += 2; + while (*line == ' ' || *line == '\t') + line++; + *evidence = line; + if (verdict) { + /* A Y needs real evidence on the same line (the paper's rule). */ + const char *p = line; + while (*p && *p != '\n') + p++; + if (p == line) + verdict = 0; + } + return verdict; +} + +int compact_rubric_parse(const char *answer, compact_rubric *out, + char **err) { + if (err) + *err = NULL; + if (!out) + return -1; + out->c1 = out->c2 = out->c3 = out->n1 = 0; + if (!answer || !*answer) { + if (err) + *err = xstrdup("rubric: empty verdict"); + return -1; + } + struct { + const char *label; + int *slot; + } items[4] = { + {"C1", &out->c1}, + {"C2", &out->c2}, + {"C3", &out->c3}, + {"N1", &out->n1}, + }; + for (int i = 0; i < 4; i++) { + const char *line = rubric_line(answer, items[i].label); + if (!line) { + if (err) + *err = xstrfmt("rubric: missing `%s` verdict line", + items[i].label); + return -1; + } + const char *ev = NULL; + int verdict = rubric_verdict(line + strlen(items[i].label), &ev); + if (verdict < 0) { + if (err) + *err = xstrfmt("rubric: malformed `%s` verdict line", + items[i].label); + return -1; + } + *items[i].slot = verdict; + } + return 0; +} + +bool compact_rubric_fire(const compact_rubric *r) { + return r && r->c1 && r->c2 && r->c3 && !r->n1; +} + +bool compact_rubric_due(unsigned long iteration, unsigned long prompt_tokens, + unsigned long summaries_so_far, + unsigned long max_summaries, + unsigned long rounds_since_probe) { + if (iteration < RUBRIC_MIN_ITERATION) + return false; + if (prompt_tokens < RUBRIC_MIN_TOKENS) + return false; + if (max_summaries > 0 && summaries_so_far >= max_summaries) + return false; + if (rounds_since_probe < RUBRIC_MIN_ROUNDS_SINCE_PROBE) + return false; + return true; +} |