diff options
| author | t <t@tjp.lol> | 2026-06-05 12:13:11 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-05 12:13:23 -0600 |
| commit | f4a3b704aece421d2b21be2209ff19d82bf71676 (patch) | |
| tree | 25b4244a21646d2bb2b79cfc64f25e196f077ce3 /libpanto/src | |
| parent | ccdaef8e682960ecadf73d3b2df70559a0baaf56 (diff) | |
Recompute restated-suffix usage after compaction
The kept-verbatim suffix is no longer copied with stale usage. After
compaction the conversation begins [summary(user), kept_user,
kept_assistant, ...]; we rewrite each kept assistant's usage so the window
reads as a fresh conversation anchored at the summary.
- runSingleCompactionTurn returns the summary text plus its size (the
provider-reported output token count for the summary turn, falling back
to the word-count heuristic when usage is absent).
- rewriteWithSummary takes summary_size and forward-walks the kept suffix
maintaining a synthetic cumulative input: non-assistant messages add
their messageTokenEstimate; each assistant gets input = running
cumulative total, with the full prompt (input+cache_read+cache_write
worth) collapsed into input and cache buckets zeroed (a rewrite busts the
provider prefix cache). output/reasoning are copied verbatim; assistants
without prior usage stay null.
This keeps the cumulative-delta sizing (computeSplit/turnTokenEstimate) and
any future TUI context-window readout correct without special-casing
compaction. Tests cover the single- and multi-turn restated chains
(including cache-bucket collapse).
Test stub ScriptedTurn gains an optional usage stamp.
Diffstat (limited to 'libpanto/src')
| -rw-r--r-- | libpanto/src/agent.zig | 159 |
1 files changed, 150 insertions, 9 deletions
diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig index 5a78722..122da1a 100644 --- a/libpanto/src/agent.zig +++ b/libpanto/src/agent.zig @@ -621,9 +621,9 @@ pub const Agent = struct { defer self.allocator.free(body); const summary = try self.runCompactionRequest(system_prompt, body, extra_instructions); - defer self.allocator.free(summary); + defer self.allocator.free(summary.text); - try self.rewriteWithSummary(conv, split.prefix_end, summary); + try self.rewriteWithSummary(conv, split.prefix_end, summary.text, summary.size); return .{ .compacted = true, @@ -666,6 +666,7 @@ pub const Agent = struct { conv: *conversation.Conversation, prefix_end: usize, summary: []const u8, + summary_size: u64, ) !void { const alloc = self.allocator; const old = conv.messages.items; @@ -696,9 +697,46 @@ pub const Agent = struct { try rebuilt.append(alloc, .{ .role = .user, .content = content }); } - // 3. The kept verbatim suffix. + // 3. The kept verbatim suffix, with usage recomputed so the + // restated window reads like a fresh conversation anchored at + // the summary. + // + // The post-compaction window is `[summary(user), kept_user, + // kept_assistant, ...]`. `usage.input` is *cumulative* (the whole + // prior prompt). We walk forward maintaining `running_input` — + // the synthetic cumulative prompt size as of "just before the + // next assistant output" — seeded with the summary's size: + // + // - non-assistant message → it occupies context; add its size + // (word-count heuristic, the same `messageTokenEstimate` the + // splitter uses for usage-less messages). + // - assistant message → its synthetic prompt is `running_input`. + // The whole prompt total collapses into `input` (the rewrite + // busts any provider prefix cache, so cache_read/cache_write + // are zeroed). `output`/`reasoning` are copied verbatim (real + // generation). Then `running_input += output` for the next + // turn. + // + // Assistants that had no usage stay null (we can't invent an + // output we never measured; the splitter already tolerates null). + var running_input: u64 = summary_size; for (old[prefix_end..]) |*m| { - try rebuilt.append(alloc, try cloneMessage(alloc, m.*)); + var cloned = try cloneMessage(alloc, m.*); + if (cloned.role == .assistant) { + if (m.usage) |u| { + cloned.usage = .{ + .input = running_input, + .output = u.output, + .cache_read = 0, + .cache_write = 0, + .reasoning = u.reasoning, + }; + running_input += u.output; + } + } else { + running_input += compaction_mod.messageTokenEstimate(m.*, null); + } + try rebuilt.append(alloc, cloned); } // Swap in the rebuilt list and free the old one. @@ -719,7 +757,7 @@ pub const Agent = struct { system_prompt: []const u8, body: []const u8, extra_instructions: ?[]const u8, - ) ![]u8 { + ) !CompactionSummary { const alloc = self.allocator; // Assemble the effective compaction system prompt (+ extra @@ -765,15 +803,26 @@ pub const Agent = struct { return self.runSingleCompactionTurn(&cfg, sys_text, body); } + /// A generated compaction summary plus the token size to attribute to + /// it when it becomes the new conversation anchor. `size` is the + /// provider-reported `output` token count for the summary turn (the + /// real generated length), falling back to the word-count heuristic + /// when the provider emitted no usage. `text` is caller-owned. + const CompactionSummary = struct { + text: []u8, + size: u64, + }; + /// One provider call for compaction. Builds a throwaway conversation /// (system prompt + one user message), streams a single turn through a - /// capturing receiver, and returns the assembled assistant text. + /// capturing receiver, and returns the assembled assistant text plus + /// the summary's token size (for usage anchoring on the rewrite). fn runSingleCompactionTurn( self: *Agent, cfg: *const config_mod.Config, system_prompt: []const u8, body: []const u8, - ) ![]u8 { + ) !CompactionSummary { const alloc = self.allocator; var conv = conversation.Conversation.init(alloc); defer conv.deinit(); @@ -795,7 +844,16 @@ pub const Agent = struct { if (block == .Text) try out.appendSlice(alloc, block.Text.items); } if (out.items.len == 0) return error.CompactionEmptySummary; - return out.toOwnedSlice(alloc); + + // Summary size: prefer the provider's reported output token count + // for this turn; otherwise word-count the assembled summary text + // (the same fallback the splitter uses for usage-less messages). + const size: u64 = if (last.usage) |u| + u.output + else + compaction_mod.messageTokenEstimate(last, null); + + return .{ .text = try out.toOwnedSlice(alloc), .size = size }; } /// Dispatch every ToolUse block in `assistant_msg`. Groups by owning @@ -1234,6 +1292,9 @@ const StubProvider = struct { const ScriptedTurn = struct { blocks: []const TestBlock, + /// Optional provider usage to stamp on the committed assistant + /// message (mirrors a real provider's `onMessageComplete` usage). + usage: ?conversation.Usage = null, }; const TestBlock = union(enum) { @@ -1312,7 +1373,7 @@ fn stubStreamStep( } const moved = try blocks.toOwnedSlice(self.allocator); defer self.allocator.free(moved); - try conv.addAssistantMessage(moved); + try conv.addAssistantMessageWithUsage(moved, turn.usage); } /// Build a stack registry + active `Config` snapshot wired together, for @@ -2427,6 +2488,86 @@ test "compact: summarizes prefix, keeps suffix, system survives" { ); } +test "compact: restated suffix usage reconstructs a fresh cumulative chain" { + const allocator = testing.allocator; + + // The compaction turn reports a known output token count (the summary + // size used as the new anchor). + const scripted = [_]StubProvider.ScriptedTurn{ + .{ + .blocks = &.{.{ .Text = "SUMMARY" }}, + .usage = .{ .input = 9999, .output = 100 }, // input is ignored; only output anchors + }, + }; + var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; + var threaded: std.Io.Threaded = .init(allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + var h = TestHarness.init(allocator); + defer h.deinit(); + h.activate(); + // Budget keeps the last two turns verbatim (deltas 8 + 6 = 14) but + // summarizes the prefix. + h.config.compaction = .{ .keep_verbatim = 20 }; + var ns = null_store_mod.NullStore.init(allocator); + var agent = Agent.init(allocator, io, &h.config, ns.store(), null); + defer agent.deinit(); + agent.stream_fn = stub.install(); + + const conv = &agent.conversation; + // Prefix turn (will be summarized). Cumulative footprint = 500+40+10+50 + // = 600. Its real usage (with cache buckets) is irrelevant + // post-compaction. + try conv.addUserMessage("first question here with several words"); + try conv.addAssistantMessageWithUsage( + &.{.{ .Text = try conversation.textualBlockFromSlice(allocator, "first answer with words") }}, + .{ .input = 500, .output = 50, .cache_read = 40, .cache_write = 10 }, + ); + // Kept turn 1: cumulative 608 (delta 8 over prefix). Reasoning is a + // subset of output. + try conv.addUserMessage("kept question"); // 2 words => ceil(2*1.3)=3 tokens + try conv.addAssistantMessageWithUsage( + &.{.{ .Text = try conversation.textualBlockFromSlice(allocator, "kept answer") }}, + .{ .input = 600, .output = 8, .reasoning = 5 }, + ); + // Kept turn 2: cumulative 614 (delta 6). Cache buckets present to prove + // they collapse into `input` on restatement. + try conv.addUserMessage("two more words here"); // 4 words => ceil(4*1.3)=6 + try conv.addAssistantMessageWithUsage( + &.{.{ .Text = try conversation.textualBlockFromSlice(allocator, "final answer") }}, + .{ .input = 600, .output = 4, .cache_read = 8, .cache_write = 2 }, + ); + + const res = try agent.compact("Summarize.", null); + try testing.expect(res.compacted); + + // Rebuilt: [summary(user), u1, a1, u2, a2]. + try testing.expectEqual(@as(usize, 5), conv.messages.items.len); + const asst1 = conv.messages.items[2]; + const asst2 = conv.messages.items[4]; + try testing.expect(conv.messages.items[1].usage == null); // kept user + + // First restated assistant: input = summary_size(100) + + // user_size("kept question" => 3) = 103. Cache zeroed; output/reasoning + // verbatim. + const us1 = asst1.usage.?; + try testing.expectEqual(@as(u64, 103), us1.input); + try testing.expectEqual(@as(u64, 0), us1.cache_read); + try testing.expectEqual(@as(u64, 0), us1.cache_write); + try testing.expectEqual(@as(u64, 8), us1.output); + try testing.expectEqual(@as(u64, 5), us1.reasoning); + + // Second restated assistant: input = prev.input(103) + prev.output(8) + + // user_size("two more words here" => 6) = 117. Original cache buckets + // (8+2) collapse away; only the synthetic cumulative total survives in + // `input`. + const us2 = asst2.usage.?; + try testing.expectEqual(@as(u64, 117), us2.input); + try testing.expectEqual(@as(u64, 0), us2.cache_read); + try testing.expectEqual(@as(u64, 0), us2.cache_write); + try testing.expectEqual(@as(u64, 4), us2.output); +} + test "compact: no-op when conversation already fits the budget" { const allocator = testing.allocator; |
