diff options
Diffstat (limited to 'libpanto/src/agent.zig')
| -rw-r--r-- | libpanto/src/agent.zig | 551 |
1 files changed, 550 insertions, 1 deletions
diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig index a42c16c..411abc5 100644 --- a/libpanto/src/agent.zig +++ b/libpanto/src/agent.zig @@ -31,6 +31,7 @@ const Io = std.Io; const provider_mod = @import("provider.zig"); const config_mod = @import("config.zig"); const conversation = @import("conversation.zig"); +const compaction_mod = @import("compaction.zig"); const tool_mod = @import("tool.zig"); const tool_source_mod = @import("tool_source.zig"); const tool_registry_mod = @import("tool_registry.zig"); @@ -43,6 +44,91 @@ const Entry = tool_registry_mod.Entry; pub const Config = config_mod.Config; +/// Re-export for the `compact` usages parameter (provider-reported token +/// usage per message, used for retention sizing). +pub const conversation_Usage = @import("session.zig").Usage; + +/// Deep-copy a message (role + all content blocks) into fresh owned +/// allocations. Used when rebuilding the conversation after compaction. +fn cloneMessage(alloc: Allocator, msg: conversation.Message) !conversation.Message { + var content: std.ArrayList(conversation.ContentBlock) = .empty; + errdefer { + for (content.items) |*b| b.deinit(alloc); + content.deinit(alloc); + } + try content.ensureTotalCapacity(alloc, msg.content.items.len); + for (msg.content.items) |block| { + content.appendAssumeCapacity(try cloneBlock(alloc, block)); + } + return .{ .role = msg.role, .content = content, .usage = msg.usage }; +} + +fn cloneBlock(alloc: Allocator, block: conversation.ContentBlock) !conversation.ContentBlock { + return switch (block) { + .Text => |b| .{ .Text = try conversation.textualBlockFromSlice(alloc, b.items) }, + .Thinking => |b| blk: { + const tb = try conversation.textualBlockFromSlice(alloc, b.text.items); + errdefer { + var mut = tb; + mut.deinit(alloc); + } + const sig: ?[]const u8 = if (b.signature) |s| try alloc.dupe(u8, s) else null; + break :blk .{ .Thinking = .{ .text = tb, .signature = sig } }; + }, + .ToolUse => |b| blk: { + const id = try alloc.dupe(u8, b.id); + errdefer alloc.free(id); + const name = try alloc.dupe(u8, b.name); + errdefer alloc.free(name); + const input = try conversation.textualBlockFromSlice(alloc, b.input.items); + break :blk .{ .ToolUse = .{ .id = id, .name = name, .input = input } }; + }, + .ToolResult => |b| blk: { + const tuid = try alloc.dupe(u8, b.tool_use_id); + errdefer alloc.free(tuid); + const content = try conversation.textualBlockFromSlice(alloc, b.content.items); + break :blk .{ .ToolResult = .{ .tool_use_id = tuid, .content = content } }; + }, + .System => |b| .{ .System = .{ + .text = try conversation.textualBlockFromSlice(alloc, b.text.items), + .mode = b.mode, + } }, + .CompactionSummary => |b| .{ .CompactionSummary = .{ + .text = try conversation.textualBlockFromSlice(alloc, b.text.items), + } }, + }; +} + +/// A minimal receiver that captures the assistant's streamed message for +/// compaction. We don't need incremental events — the assembled message is +/// read off the conversation after the turn — so all callbacks are no-ops. +const CompactionCapture = struct { + allocator: Allocator, + + fn receiver(self: *CompactionCapture) provider_mod.Receiver { + return .{ .ptr = self, .vtable = &vt }; + } + fn deinit(self: *CompactionCapture) void { + _ = self; + } + const vt: provider_mod.ReceiverVTable = .{ + .onMessageStart = onMessageStart, + .onBlockStart = onBlockStart, + .onToolDetails = onToolDetails, + .onContentDelta = onContentDelta, + .onBlockComplete = onBlockComplete, + .onMessageComplete = onMessageComplete, + .onError = onError, + }; + fn onMessageStart(_: *anyopaque, _: conversation.MessageRole) anyerror!void {} + fn onBlockStart(_: *anyopaque, _: provider_mod.ContentBlockType, _: usize) anyerror!void {} + fn onToolDetails(_: *anyopaque, _: usize, _: []const u8, _: []const u8) anyerror!void {} + fn onContentDelta(_: *anyopaque, _: usize, _: []const u8) anyerror!void {} + fn onBlockComplete(_: *anyopaque, _: usize, _: conversation.ContentBlock) anyerror!void {} + fn onMessageComplete(_: *anyopaque, _: conversation.Message, _: ?provider_mod.Usage) anyerror!void {} + fn onError(_: *anyopaque, _: anyerror) void {} +}; + fn isValidToolInput(input: []const u8) bool { if (input.len == 0) return true; if (input[0] != '{') return true; // legacy tests/tools may use opaque bytes @@ -72,6 +158,15 @@ pub const Agent = struct { /// Injectable streaming seam. Defaults to the real provider dispatch /// (`provider_mod.streamStep`); tests override it with a stub. stream_fn: provider_mod.StreamFn = provider_mod.streamStep, + /// Compaction system prompt used for automatic compaction on context + /// overflow. Borrowed; set by the embedder (resolved from its + /// `COMPACTION.md` layers). When null, auto-compaction is disabled and + /// a context-overflow error propagates unchanged. + compaction_system_prompt: ?[]const u8 = null, + /// Set by the embedder after `runStep` returns to learn whether an + /// automatic compaction occurred this turn (so it can persist the + /// rewritten conversation). Reset at the top of each `runStep`. + auto_compacted: bool = false, pub fn init(allocator: Allocator, io: Io, config: *const Config) Agent { return .{ @@ -106,11 +201,24 @@ pub const Agent = struct { conv: *conversation.Conversation, receiver: *provider_mod.Receiver, ) !void { + self.auto_compacted = false; while (true) { // Re-read the config snapshot at the top of each turn so a // mid-conversation swap takes effect here, never mid-stream. const cfg = self.config; - try self.stream_fn(self.allocator, self.io, cfg, conv, receiver); + self.stream_fn(self.allocator, self.io, cfg, conv, receiver) catch |err| { + // Automatic compaction on context overflow: compact the + // conversation once and retry the failed request a single + // time. If the retry also overflows, surface the error. + if (err != error.ContextOverflow) return err; + if (self.auto_compacted) return err; // already retried once + const sys = self.compaction_system_prompt orelse return err; + const res = try self.compact(conv, sys, null); + if (!res.compacted) return err; // nothing to shed; give up + self.auto_compacted = true; + // Retry the same request against the compacted context. + try self.stream_fn(self.allocator, self.io, cfg, conv, receiver); + }; const last = conv.messages.items[conv.messages.items.len - 1]; std.debug.assert(last.role == .assistant); @@ -133,6 +241,235 @@ pub const Agent = struct { return false; } + /// Outcome of a compaction attempt. + pub const CompactionResult = struct { + /// Whether the conversation was actually compacted. False means the + /// active conversation already fit within the keep-verbatim budget + /// (nothing to summarize) — the conversation is unchanged. + compacted: bool, + /// Number of whole turns kept verbatim after the summary. + kept_turns: usize = 0, + /// Number of conversation messages folded into the summary. + summarized_messages: usize = 0, + }; + + /// Compact the conversation: summarize an older prefix into a single + /// `.CompactionSummary` block and keep a recent suffix of whole turns + /// verbatim. Mutates `conv` in place. The embedder is responsible for + /// persisting the resulting new messages (the agent never touches the + /// session log). + /// + /// The system prompt survives untouched: all `.system`-role messages + /// are preserved in order, and no `replace` block is written. Only the + /// conversation (user/assistant) prefix is summarized. + /// + /// Per-message provider usage is read directly off the conversation + /// (`Message.usage`, set live by the provider and on replay from disk). + /// `computeSplit` uses it to size the retention window; messages + /// lacking usage fall back to word counting. + /// + /// `extra_instructions`, when non-null, is appended to the compaction + /// system prompt for this run (the `/compact $ARGUMENTS` path). + /// + /// `system_prompt` is the compaction system prompt (resolved by the + /// embedder from its `COMPACTION.md` layers, or a built-in default). + pub fn compact( + self: *Agent, + conv: *conversation.Conversation, + system_prompt: []const u8, + extra_instructions: ?[]const u8, + ) !CompactionResult { + const messages = conv.messages.items; + + // Project per-message usage off the conversation for sizing. + const usages = try self.allocator.alloc(?conversation_Usage, messages.len); + defer self.allocator.free(usages); + for (messages, 0..) |m, i| usages[i] = m.usage; + + const split = compaction_mod.computeSplit(messages, usages, self.config.compaction.keep_verbatim); + + // Determine the active conversation start (after any prior summary). + const active_start: usize = if (conversation.latestCompactionIndex(messages)) |a| a + 1 else 0; + + // Nothing to summarize: the active conversation already fits, or the + // prefix boundary is at/under the first active turn. + if (split.prefix_end <= active_start) { + return .{ .compacted = false }; + } + // Count how many *conversation* (non-system) messages are in the + // summarized prefix. If none, this is also a no-op. + var summarized: usize = 0; + for (messages[active_start..split.prefix_end]) |m| { + if (m.role != .system) summarized += 1; + } + if (summarized == 0) return .{ .compacted = false }; + + // Serialize the prefix transcript and carry forward the latest + // existing summary (chained-compaction invariant). + const transcript = try compaction_mod.serializeTranscript( + self.allocator, + messages[active_start..split.prefix_end], + ); + defer self.allocator.free(transcript); + + const previous_summary = compaction_mod.latestSummaryText(messages); + const body = try compaction_mod.buildRequestBody(self.allocator, transcript, previous_summary); + defer self.allocator.free(body); + + const summary = try self.runCompactionRequest(system_prompt, body, extra_instructions); + defer self.allocator.free(summary); + + try self.rewriteWithSummary(conv, split.prefix_end, summary); + + return .{ + .compacted = true, + .kept_turns = split.kept_turns, + .summarized_messages = summarized, + }; + } + + /// Rewrite `conv.messages` to `[all system messages..., summary, + /// kept-suffix...]`. The summarized conversation prefix (everything + /// before `prefix_end` that isn't a system message) is dropped; system + /// messages survive in order; a `.CompactionSummary` user message is + /// inserted; the kept suffix (`messages[prefix_end..]`) is preserved. + fn rewriteWithSummary( + self: *Agent, + conv: *conversation.Conversation, + prefix_end: usize, + summary: []const u8, + ) !void { + const alloc = self.allocator; + const old = conv.messages.items; + + var rebuilt: std.ArrayList(conversation.Message) = .empty; + errdefer { + for (rebuilt.items) |*m| m.deinit(alloc); + rebuilt.deinit(alloc); + } + + // 1. All system messages from the summarized prefix survive, in + // order. (System messages in the kept suffix come along with it + // below, so only scan the prefix here.) + for (old[0..prefix_end]) |*m| { + if (m.role != .system) continue; + try rebuilt.append(alloc, try cloneMessage(alloc, m.*)); + } + + // 2. The compaction summary, alone in a user message. + { + const tb = try conversation.textualBlockFromSlice(alloc, summary); + var content: std.ArrayList(conversation.ContentBlock) = .empty; + errdefer { + for (content.items) |*b| b.deinit(alloc); + content.deinit(alloc); + } + try content.append(alloc, .{ .CompactionSummary = .{ .text = tb } }); + try rebuilt.append(alloc, .{ .role = .user, .content = content }); + } + + // 3. The kept verbatim suffix. + for (old[prefix_end..]) |*m| { + try rebuilt.append(alloc, try cloneMessage(alloc, m.*)); + } + + // Swap in the rebuilt list and free the old one. + for (conv.messages.items) |*m| m.deinit(alloc); + conv.messages.deinit(alloc); + conv.messages = rebuilt; + } + + /// Run a single compaction provider call against a throwaway + /// conversation. Returns the assistant's summary text (caller owns). + /// + /// Model selection: try `config.compaction.model` if set; on failure, + /// fall back to the active chat model. Compaction runs with an empty + /// tool registry and a single user message (the request body); no tools + /// are exposed and no session logging occurs. + fn runCompactionRequest( + self: *Agent, + system_prompt: []const u8, + body: []const u8, + extra_instructions: ?[]const u8, + ) ![]u8 { + const alloc = self.allocator; + + // Assemble the effective compaction system prompt (+ extra + // instructions for a `/compact $ARGUMENTS` run). + var sys_text: []const u8 = system_prompt; + var sys_owned: ?[]u8 = null; + defer if (sys_owned) |s| alloc.free(s); + if (extra_instructions) |extra| { + if (extra.len > 0) { + const combined = try std.fmt.allocPrint( + alloc, + "{s}\n\n## Additional instructions for this compaction run\n\n{s}", + .{ system_prompt, extra }, + ); + sys_owned = combined; + sys_text = combined; + } + } + + var empty_registry = ToolRegistry.init(alloc); + defer empty_registry.deinit(); + + // Try the configured compaction model first, then fall back to the + // active chat model on any failure. + if (self.config.compaction.model) |comp_provider| { + const cfg: config_mod.Config = .{ + .provider = comp_provider, + .registry = &empty_registry, + .compaction = self.config.compaction, + }; + if (self.runSingleCompactionTurn(&cfg, sys_text, body)) |summary| { + return summary; + } else |err| { + std.log.warn("compaction model failed ({t}); falling back to active model", .{err}); + } + } + + const cfg: config_mod.Config = .{ + .provider = self.config.provider, + .registry = &empty_registry, + .compaction = self.config.compaction, + }; + return self.runSingleCompactionTurn(&cfg, sys_text, body); + } + + /// 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. + fn runSingleCompactionTurn( + self: *Agent, + cfg: *const config_mod.Config, + system_prompt: []const u8, + body: []const u8, + ) ![]u8 { + const alloc = self.allocator; + var conv = conversation.Conversation.init(alloc); + defer conv.deinit(); + try conv.addSystemMessage(system_prompt); + try conv.addUserMessage(body); + + var capture = CompactionCapture{ .allocator = alloc }; + defer capture.deinit(); + var recv = capture.receiver(); + + try self.stream_fn(alloc, self.io, cfg, &conv, &recv); + + // The provider appended an assistant message; gather its text. + const last = conv.messages.items[conv.messages.items.len - 1]; + if (last.role != .assistant) return error.CompactionNoResponse; + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(alloc); + for (last.content.items) |block| { + if (block == .Text) try out.appendSlice(alloc, block.Text.items); + } + if (out.items.len == 0) return error.CompactionEmptySummary; + return out.toOwnedSlice(alloc); + } + /// Dispatch every ToolUse block in `assistant_msg`. Groups by owning /// registration; one OS thread per group; results assembled in the /// original call order. @@ -431,6 +768,10 @@ const StubProvider = struct { allocator: Allocator, scripted: []const ScriptedTurn, next: usize = 0, + /// Number of leading stream calls that should fail with + /// `error.ContextOverflow` before any scripted turn is served. Used to + /// drive the auto-compaction path. Decremented on each overflow. + overflow_calls: usize = 0, const ScriptedTurn = struct { blocks: []const TestBlock, @@ -463,6 +804,10 @@ fn stubStreamStep( ) anyerror!void { const self = stub_active orelse return error.NoStubInstalled; _ = allocator; + if (self.overflow_calls > 0) { + self.overflow_calls -= 1; + return error.ContextOverflow; + } if (self.next >= self.scripted.len) return error.NoMoreScriptedTurns; const turn = self.scripted[self.next]; self.next += 1; @@ -1285,3 +1630,207 @@ test "setConfig swaps the visible tool set between turns" { try testing.expectEqualStrings("2", tr.tool_use_id); try testing.expectEqualStrings("B:B", tr.content.items); } + +test "compact: summarizes prefix, keeps suffix, system survives" { + const allocator = testing.allocator; + + // The stub returns a single text turn — used as the summary text. + const scripted = [_]StubProvider.ScriptedTurn{ + .{ .blocks = &.{.{ .Text = "SUMMARY OF EARLIER" }} }, + }; + 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(); + // keep_verbatim sized so only the last (short) turn fits: q2+a2 are + // 3 words each => ceil(3*1.3)=4 tokens each => 8 total <= 10, while + // adding the longer first turn exceeds it. + h.config.compaction = .{ .keep_verbatim = 10 }; + var agent = Agent.init(allocator, io, &h.config); + agent.stream_fn = stub.install(); + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addSystemMessage("you are helpful"); + try conv.addUserMessage("first question here with several words"); + try conv.addAssistantMessage(&.{ + .{ .Text = try conversation.textualBlockFromSlice(allocator, "first answer with several words") }, + }); + try conv.addUserMessage("second recent question"); + try conv.addAssistantMessage(&.{ + .{ .Text = try conversation.textualBlockFromSlice(allocator, "second recent answer") }, + }); + + const res = try agent.compact(&conv, "Summarize the conversation.", null); + try testing.expect(res.compacted); + + // Expected rebuilt: [system, compaction summary(user), user q2, asst a2] + try testing.expectEqual(@as(usize, 4), conv.messages.items.len); + try testing.expectEqual(conversation.MessageRole.system, conv.messages.items[0].role); + try testing.expectEqualStrings( + "you are helpful", + conv.messages.items[0].content.items[0].System.text.items, + ); + try testing.expectEqual(conversation.MessageRole.user, conv.messages.items[1].role); + try testing.expectEqualStrings( + "SUMMARY OF EARLIER", + conv.messages.items[1].content.items[0].CompactionSummary.text.items, + ); + try testing.expectEqualStrings( + "second recent question", + conv.messages.items[2].content.items[0].Text.items, + ); + try testing.expectEqualStrings( + "second recent answer", + conv.messages.items[3].content.items[0].Text.items, + ); +} + +test "compact: no-op when conversation already fits the budget" { + const allocator = testing.allocator; + + const scripted = [_]StubProvider.ScriptedTurn{ + .{ .blocks = &.{.{ .Text = "should not be used" }} }, + }; + 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(); + h.config.compaction = .{ .keep_verbatim = 1_000_000 }; + var agent = Agent.init(allocator, io, &h.config); + agent.stream_fn = stub.install(); + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addSystemMessage("sys"); + try conv.addUserMessage("hi"); + try conv.addAssistantMessage(&.{ + .{ .Text = try conversation.textualBlockFromSlice(allocator, "hello") }, + }); + + const res = try agent.compact(&conv, "Summarize.", null); + try testing.expect(!res.compacted); + try testing.expectEqual(@as(usize, 3), conv.messages.items.len); + // Stub was never consumed. + try testing.expectEqual(@as(usize, 0), stub.next); +} + +test "compact: extra instructions are appended to the system prompt" { + const allocator = testing.allocator; + + // Capture the system prompt the stub sees by scripting a turn and + // inspecting the throwaway conversation isn't directly possible via the + // current stub; instead we just assert compaction succeeds with extra + // instructions present (smoke test of the append path). + const scripted = [_]StubProvider.ScriptedTurn{ + .{ .blocks = &.{.{ .Text = "S" }} }, + }; + 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(); + h.config.compaction = .{ .keep_verbatim = 1 }; + var agent = Agent.init(allocator, io, &h.config); + agent.stream_fn = stub.install(); + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addUserMessage("question one two three"); + try conv.addAssistantMessage(&.{ + .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer one two three") }, + }); + try conv.addUserMessage("question two"); + try conv.addAssistantMessage(&.{ + .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer two") }, + }); + + const res = try agent.compact(&conv, "Base prompt.", "keep bug #3 details"); + try testing.expect(res.compacted); +} + +test "runStep: auto-compacts on context overflow and retries once" { + const allocator = testing.allocator; + + // First stream call overflows; then the compaction request returns a + // summary; then the retried main request returns a final text turn. + const scripted = [_]StubProvider.ScriptedTurn{ + .{ .blocks = &.{.{ .Text = "COMPACTED SUMMARY" }} }, // compaction call + .{ .blocks = &.{.{ .Text = "final answer" }} }, // retried main call + }; + var stub = StubProvider{ + .allocator = allocator, + .scripted = &scripted, + .overflow_calls = 1, + }; + var threaded: std.Io.Threaded = .init(allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + var h = TestHarness.init(allocator); + defer h.deinit(); + h.activate(); + h.config.compaction = .{ .keep_verbatim = 10 }; + var agent = Agent.init(allocator, io, &h.config); + agent.stream_fn = stub.install(); + agent.compaction_system_prompt = "Summarize the conversation."; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addSystemMessage("you are helpful"); + try conv.addUserMessage("first question with several words here"); + try conv.addAssistantMessage(&.{ + .{ .Text = try conversation.textualBlockFromSlice(allocator, "first answer with several words") }, + }); + try conv.addUserMessage("second recent question"); + + var recv = NoopReceiver.make(); + try agent.runStep(&conv, &recv); + + try testing.expect(agent.auto_compacted); + // After compaction + retry: [system, summary, user q2, assistant final]. + const msgs = conv.messages.items; + try testing.expectEqual(conversation.MessageRole.system, msgs[0].role); + try testing.expectEqualStrings( + "COMPACTED SUMMARY", + msgs[1].content.items[0].CompactionSummary.text.items, + ); + try testing.expectEqualStrings("second recent question", msgs[2].content.items[0].Text.items); + try testing.expectEqualStrings("final answer", msgs[msgs.len - 1].content.items[0].Text.items); +} + +test "runStep: context overflow without compaction prompt propagates" { + const allocator = testing.allocator; + + const scripted = [_]StubProvider.ScriptedTurn{ + .{ .blocks = &.{.{ .Text = "unused" }} }, + }; + var stub = StubProvider{ + .allocator = allocator, + .scripted = &scripted, + .overflow_calls = 1, + }; + var threaded: std.Io.Threaded = .init(allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + var h = TestHarness.init(allocator); + defer h.deinit(); + h.activate(); + var agent = Agent.init(allocator, io, &h.config); + agent.stream_fn = stub.install(); + // No compaction_system_prompt set -> overflow propagates. + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addUserMessage("hi"); + + var recv = NoopReceiver.make(); + try testing.expectError(error.ContextOverflow, agent.runStep(&conv, &recv)); +} |
