summaryrefslogtreecommitdiff
path: root/libpanto/src/agent.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-02 15:00:44 -0600
committert <t@tjp.lol>2026-06-02 16:37:32 -0600
commit8b88b886346460b1ab29edb03ad85bc3bb565a45 (patch)
tree13b9ab56061a722641389b5fc8ac1113d09b66e5 /libpanto/src/agent.zig
parent7268c0b8e8bcf4b325581fabe7476a394f167738 (diff)
compaction
Diffstat (limited to 'libpanto/src/agent.zig')
-rw-r--r--libpanto/src/agent.zig551
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));
+}