summaryrefslogtreecommitdiff
path: root/libpanto/src
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto/src')
-rw-r--r--libpanto/src/agent.zig159
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;