From ccdaef8e682960ecadf73d3b2df70559a0baaf56 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 5 Jun 2026 11:43:29 -0600 Subject: Move session persistence into the Agent; collapse CLI plumbing The Agent now owns its Conversation and a SessionStore, and persists everything it generates as turns progress. Embedders get persistence for free; the CLI no longer drives the session log. libpanto: - Agent.init takes a SessionStore + optional Conversation to adopt (resume path). Agent owns/tears down the conversation; turn-driving methods drop the *Conversation param and operate on self.conversation. - New Agent methods: submitUserMessage (adds+persists the user prompt immediately), addSystemMessage(text, mode), runStep persists the turn on every exit path (incl. partial turns on error), compactAndPersist for the explicit /compact path. Auto-compaction persists its window via runStep's tail. - turn_persist.zig: DiskMessage mapping moved out of the CLI; reads usage off Message.usage (no per-message-usage list). System mode rides on DiskMessage.mode, derived from the System block. - NullStore is now an allocator-bearing struct (frees consumed messages per the store-consumes-messages contract). - Tests: in-memory CapturingStore round-trip + NullStore turn test. panto CLI: - Delete src/session_persist.zig. REPL is submitUserMessage + runStep. - Reorder construction: store -> registry -> agent -> bootstrap -> seed/ reconcile system prompt through the agent -> load extensions. - command.Context drops conv/session_mgr; commands reach ctx.agent. - system_prompt seed/reconcile call agent.addSystemMessage. - CLIReceiver is display-only (per_message_usage removed). Phases 3-5 of docs/pluggable-session-store.md. Behavior preserved; full build + test suite green. --- libpanto/src/agent.zig | 550 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 435 insertions(+), 115 deletions(-) (limited to 'libpanto/src/agent.zig') diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig index c7d0536..5a78722 100644 --- a/libpanto/src/agent.zig +++ b/libpanto/src/agent.zig @@ -36,6 +36,9 @@ const tool_mod = @import("tool.zig"); const image_mod = @import("image.zig"); const tool_source_mod = @import("tool_source.zig"); const tool_registry_mod = @import("tool_registry.zig"); +const session_store_mod = @import("session_store.zig"); +const null_store_mod = @import("null_store.zig"); +const turn_persist = @import("turn_persist.zig"); pub const Tool = tool_mod.Tool; pub const ToolSource = tool_source_mod.ToolSource; @@ -205,7 +208,6 @@ fn toolErrorResult( return tool_mod.ownedTextResult(allocator, msg); } - pub const Agent = struct { allocator: Allocator, io: Io, @@ -215,6 +217,24 @@ pub const Agent = struct { /// the visible tool set atomically. The pointee and its registry are /// owned by the embedder, not the agent. config: *const Config, + /// The live conversation the agent drives. Owned by the agent (adopted + /// at `init`); torn down in `deinit`. Turn-driving methods operate on + /// this directly rather than taking a `*Conversation` parameter. + conversation: conversation.Conversation, + /// Pluggable persistence backend. Every message that enters + /// `conversation` is persisted here as it is added. Defaults to a no-op + /// `NullStore` so embedders/tests that don't care about persistence + /// keep working. Borrowed: the embedder owns the concrete store and + /// must outlive the agent. + session_store: session_store_mod.SessionStore, + /// Display provider/model names used to stamp persisted entries. These + /// are the embedder's user-facing identity strings (e.g. the + /// `"provider:model"` ref's parts), which can't be derived from the + /// `ProviderConfig` snapshot (it only carries the `APIStyle` tag and + /// the raw model string). Borrowed; lifetime owned by the embedder. The + /// model defaults to the config snapshot's model when left empty. + persist_provider: []const u8 = "", + persist_model: []const u8 = "", /// 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, @@ -232,18 +252,52 @@ pub const Agent = struct { /// no synchronization is needed. retry_prng: ?std.Random.DefaultPrng = null, - pub fn init(allocator: Allocator, io: Io, config: *const Config) Agent { + /// Construct an agent. + /// + /// `store` is the persistence backend (use `null_store.store()` to opt + /// out). `maybe_conversation` is adopted (ownership transferred) when + /// non-null — the resume path: open a store, ask it for the + /// conversation, hand it here. When null, a fresh empty conversation is + /// created. Either way the agent owns and tears down the conversation. + pub fn init( + allocator: Allocator, + io: Io, + config: *const Config, + store: session_store_mod.SessionStore, + maybe_conversation: ?conversation.Conversation, + ) Agent { return .{ .allocator = allocator, .io = io, .config = config, + .conversation = maybe_conversation orelse conversation.Conversation.init(allocator), + .session_store = store, }; } pub fn deinit(self: *Agent) void { - // The agent owns neither the config snapshot nor the registry it - // borrows; the embedder tears those down. - _ = self; + // The agent owns the conversation; it borrows the config snapshot, + // the registry, and the session store, which the embedder tears + // down. + self.conversation.deinit(); + } + + /// The provider/model identity used to stamp persisted entries. Uses + /// the embedder-supplied display names when set, falling back to the + /// active config snapshot's model string (and the `APIStyle` tag name + /// for the provider) when not. + fn providerModel(self: *const Agent) struct { provider: []const u8, model: []const u8 } { + const cfg_model = switch (self.config.provider) { + inline else => |c| c.model, + }; + const provider = if (self.persist_provider.len > 0) + self.persist_provider + else switch (self.config.provider) { + .openai_chat => "openai_chat", + .anthropic_messages => "anthropic_messages", + }; + const model = if (self.persist_model.len > 0) self.persist_model else cfg_model; + return .{ .provider = provider, .model = model }; } /// Swap the active configuration snapshot. Takes effect at the start of @@ -259,18 +313,74 @@ pub const Agent = struct { return self.config.registry; } + /// Add a user message to the conversation and durably persist it + /// immediately (before any provider call). The user prompt is logged on + /// submission, so a crash before the model replies leaves a recoverable + /// dangling prompt in the store. + pub fn submitUserMessage(self: *Agent, text: []const u8) !void { + const start = self.conversation.messages.items.len; + try self.conversation.addUserMessage(text); + const pm = self.providerModel(); + try turn_persist.persistTurn( + self.allocator, + self.session_store, + &self.conversation, + start, + pm.provider, + pm.model, + ); + } + + /// Add a system message (append or replace mode) to the conversation + /// and persist it. The persisted entry records the mode so replay + /// reconstructs the same effective system prompt. + pub fn addSystemMessage( + self: *Agent, + text: []const u8, + mode: conversation.SystemMode, + ) !void { + const start = self.conversation.messages.items.len; + switch (mode) { + .append => try self.conversation.addSystemMessage(text), + .replace => try self.conversation.replaceSystemMessage(text), + } + const pm = self.providerModel(); + try turn_persist.persistTurn( + self.allocator, + self.session_store, + &self.conversation, + start, + pm.provider, + pm.model, + ); + } + /// Drive the conversation forward until the model stops calling tools. + /// + /// Persists everything the turn appends to the conversation: assistant + /// messages and tool-result user messages. Persistence runs on exit + /// regardless of how the turn ended — including error paths that + /// committed messages before failing — so a partial turn is durably + /// logged. An automatic compaction during the turn is persisted as a + /// fresh compaction window instead. pub fn runStep( self: *Agent, - conv: *conversation.Conversation, receiver: *provider_mod.Receiver, ) !void { self.auto_compacted = false; + const conv = &self.conversation; + const start = conv.messages.items.len; + + // Persist whatever the turn committed, on every exit path. + defer self.persistTurnTail(start) catch |e| { + std.log.err("session: failed to persist turn: {t}", .{e}); + }; + 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.streamWithRetries(cfg, conv, receiver); + try self.streamWithRetries(cfg, receiver); const last = conv.messages.items[conv.messages.items.len - 1]; std.debug.assert(last.role == .assistant); @@ -282,7 +392,33 @@ pub const Agent = struct { if (!hasToolUseBlock(last)) return; - try self.dispatchToolCalls(conv, last); + try self.dispatchToolCalls(last); + } + } + + /// Persist the messages a turn produced. When the turn auto-compacted, + /// message indices shifted (the conversation was rewritten to + /// `[system..., summary, kept-suffix...]`), so persist the whole + /// post-compaction window instead of `[start..]`. + fn persistTurnTail(self: *Agent, start: usize) !void { + const pm = self.providerModel(); + if (self.auto_compacted) { + try turn_persist.persistCompaction( + self.allocator, + self.session_store, + &self.conversation, + pm.provider, + pm.model, + ); + } else { + try turn_persist.persistTurn( + self.allocator, + self.session_store, + &self.conversation, + start, + pm.provider, + pm.model, + ); } } @@ -312,16 +448,15 @@ pub const Agent = struct { fn streamWithRetries( self: *Agent, cfg: *const Config, - conv: *conversation.Conversation, receiver: *provider_mod.Receiver, ) !void { const policy = cfg.retry; var attempt: usize = 1; while (true) { var diag: provider_mod.ProviderDiagnostic = .{}; - self.stream_fn(self.allocator, self.io, cfg, conv, receiver, &diag) catch |err| { + self.stream_fn(self.allocator, self.io, cfg, &self.conversation, receiver, &diag) catch |err| { if (err == error.ContextOverflow) { - try self.handleContextOverflow(cfg, conv, receiver, err); + try self.handleContextOverflow(cfg, receiver, err); return; } if (!provider_mod.isRetryableProviderError(err)) return err; @@ -356,13 +491,12 @@ pub const Agent = struct { fn handleContextOverflow( self: *Agent, cfg: *const Config, - conv: *conversation.Conversation, receiver: *provider_mod.Receiver, err: anyerror, ) !void { if (self.auto_compacted) return err; // already retried once this turn const sys = self.compaction_system_prompt orelse return err; - const res = try self.compact(conv, sys, null); + const res = try self.compact(sys, null); if (!res.compacted) return err; // nothing to shed; give up self.auto_compacted = true; receiver.onProviderRetry(.{ @@ -375,7 +509,7 @@ pub const Agent = struct { // Retry the same request against the compacted context. A second // overflow (or any other error) propagates. var diag: provider_mod.ProviderDiagnostic = .{}; - try self.stream_fn(self.allocator, self.io, cfg, conv, receiver, &diag); + try self.stream_fn(self.allocator, self.io, cfg, &self.conversation, receiver, &diag); } /// Compute the backoff delay (ms) for the just-failed `attempt` @@ -422,9 +556,12 @@ pub const Agent = struct { /// 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). + /// verbatim. Mutates `self.conversation` in place. + /// + /// This is the pure transform — it does **not** persist. The explicit + /// `/compact` path uses `compactAndPersist`; the automatic + /// (context-overflow) path persists via `runStep`'s turn tail (the + /// rewritten window is logged as a fresh compaction window). /// /// The system prompt survives untouched: all `.system`-role messages /// are preserved in order, and no `replace` block is written. Only the @@ -442,10 +579,10 @@ pub const Agent = struct { /// 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 conv = &self.conversation; const messages = conv.messages.items; // Project per-message usage off the conversation for sizing. @@ -495,6 +632,30 @@ pub const Agent = struct { }; } + /// Compact and persist the result to the session store. This is the + /// explicit `/compact` entry point: it summarizes (via `compact`) and, + /// if anything was compacted, appends the new compaction window + /// (summary + restated kept suffix) to the store. Returns the same + /// `CompactionResult` for the embedder to report. + pub fn compactAndPersist( + self: *Agent, + system_prompt: []const u8, + extra_instructions: ?[]const u8, + ) !CompactionResult { + const res = try self.compact(system_prompt, extra_instructions); + if (res.compacted) { + const pm = self.providerModel(); + try turn_persist.persistCompaction( + self.allocator, + self.session_store, + &self.conversation, + pm.provider, + pm.model, + ); + } + return res; + } + /// 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 @@ -642,9 +803,9 @@ pub const Agent = struct { /// original call order. fn dispatchToolCalls( self: *Agent, - conv: *conversation.Conversation, assistant_msg: conversation.Message, ) !void { + const conv = &self.conversation; // Build the flat call list (in original order) and group calls // by owning registration. var calls: std.array_list.Managed(FlatCall) = .init(self.allocator); @@ -1354,6 +1515,141 @@ const NoopReceiver = struct { fn noop7(_: *anyopaque, _: provider_mod.ProviderRetryInfo) void {} }; +/// An in-memory `SessionStore` test double: records every appended +/// `DiskMessage` (role + provider/model stamp) so tests can assert the +/// agent persisted the right turn without touching disk. Honors the store +/// ownership contract by freeing each consumed message after recording its +/// salient fields. +const CapturingStore = struct { + allocator: Allocator, + roles: std.ArrayList(session_store_mod.DiskMessageRole) = .empty, + providers: std.ArrayList(?[]const u8) = .empty, + + fn init(allocator: Allocator) CapturingStore { + return .{ .allocator = allocator }; + } + + fn deinit(self: *CapturingStore) void { + for (self.providers.items) |p| if (p) |s| self.allocator.free(s); + self.providers.deinit(self.allocator); + self.roles.deinit(self.allocator); + } + + fn appendMessagesVT( + ctx: *anyopaque, + messages: []session_store_mod.DiskMessage, + providers: []const ?[]const u8, + _: []const ?[]const u8, + ) anyerror!void { + const self: *CapturingStore = @ptrCast(@alignCast(ctx)); + for (messages, 0..) |m, i| { + try self.roles.append(self.allocator, m.role); + // Prefer the assistant message's own provider stamp, else the + // entry-level provider stamp (user messages). + const prov = m.provider orelse providers[i]; + const dup: ?[]const u8 = if (prov) |p| try self.allocator.dupe(u8, p) else null; + try self.providers.append(self.allocator, dup); + } + // Contract: appends consume their messages. + for (messages) |m| m.deinit(self.allocator); + } + + fn loadConversationVT(_: *anyopaque, alloc: Allocator) anyerror!session_store_mod.LoadedSession { + return .{ .conversation = conversation.Conversation.init(alloc), .dangling_user = null }; + } + fn sessionIdVT(_: *anyopaque) []const u8 { + return "cap"; + } + fn activeModelVT(_: *anyopaque) ?session_store_mod.ActiveModel { + return null; + } + + const vtable: session_store_mod.SessionStore.VTable = .{ + .appendMessages = appendMessagesVT, + .loadConversation = loadConversationVT, + .sessionId = sessionIdVT, + .activeModel = activeModelVT, + }; + + fn store(self: *CapturingStore) session_store_mod.SessionStore { + return .{ .ptr = self, .vtable = &vtable }; + } +}; + +test "agent persists user, assistant, and tool-result messages of a turn" { + const allocator = testing.allocator; + + const scripted = [_]StubProvider.ScriptedTurn{ + .{ .blocks = &.{ + .{ .ToolUse = .{ .id = "tc_1", .name = "echo", .input = "hello" } }, + } }, + .{ .blocks = &.{ + .{ .Text = "ok" }, + } }, + }; + 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(); + try h.registry.register(try EchoTool.create(allocator, "echo", "ECHO:")); + h.activate(); + + var cap = CapturingStore.init(allocator); + defer cap.deinit(); + var agent = Agent.init(allocator, io, &h.config, cap.store(), null); + defer agent.deinit(); + agent.stream_fn = stub.install(); + agent.persist_provider = "openai"; + agent.persist_model = "gpt-4o"; + + var recv = NoopReceiver.make(); + try agent.submitUserMessage("call a tool"); + try agent.runStep(&recv); + + // Persisted, in order: user prompt, assistant(ToolUse), user(ToolResult), + // assistant(text). + try testing.expectEqual(@as(usize, 4), cap.roles.items.len); + try testing.expectEqual(session_store_mod.DiskMessageRole.user, cap.roles.items[0]); + try testing.expectEqual(session_store_mod.DiskMessageRole.assistant, cap.roles.items[1]); + try testing.expectEqual(session_store_mod.DiskMessageRole.user, cap.roles.items[2]); + try testing.expectEqual(session_store_mod.DiskMessageRole.assistant, cap.roles.items[3]); + + // The display provider stamp rode through on every entry. + for (cap.providers.items) |p| { + try testing.expect(p != null); + try testing.expectEqualStrings("openai", p.?); + } +} + +test "agent runs a turn against NullStore without persisting or erroring" { + const allocator = testing.allocator; + + const scripted = [_]StubProvider.ScriptedTurn{ + .{ .blocks = &.{.{ .Text = "hi" }} }, + }; + 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(); + + 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(); + + var recv = NoopReceiver.make(); + try agent.submitUserMessage("hello"); + try agent.runStep(&recv); + + // Nothing crashed; the conversation has the user + assistant messages. + try testing.expectEqual(@as(usize, 2), agent.conversation.messages.items.len); +} + /// A configurable ToolSource for testing the grouped-dispatch path. /// Stores every batch it receives so tests can assert "calls X and Y /// arrived in the same batch on the same thread". @@ -1632,15 +1928,16 @@ test "runStep dispatches a tool call and loops to a final text turn" { defer h.deinit(); try h.registry.register(try EchoTool.create(allocator, "echo", "ECHO:")); h.activate(); - var agent = Agent.init(allocator, io, &h.config); + 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(); - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addUserMessage("call a tool"); var recv = NoopReceiver.make(); - try agent.runStep(&conv, &recv); + try agent.runStep(&recv); try testing.expectEqual(@as(usize, 4), conv.messages.items.len); @@ -1683,15 +1980,16 @@ test "runStep dispatches multiple tool calls in parallel" { try h.registry.register(try BarrierTool.create(allocator, "barrierB", &barrier)); try h.registry.register(try BarrierTool.create(allocator, "barrierC", &barrier)); h.activate(); - var agent = Agent.init(allocator, io, &h.config); + 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(); - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addUserMessage("go"); var recv = NoopReceiver.make(); - try agent.runStep(&conv, &recv); + try agent.runStep(&recv); const tr_msg = conv.messages.items[2]; try testing.expectEqual(@as(usize, 3), tr_msg.content.items.len); @@ -1723,15 +2021,16 @@ test "runStep: native tool handler error becomes an error result and the model g defer h.deinit(); try h.registry.register(try FailingTool.create(allocator, "boom")); h.activate(); - var agent = Agent.init(allocator, io, &h.config); + 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(); - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addUserMessage("break it"); var recv = NoopReceiver.make(); - try agent.runStep(&conv, &recv); + try agent.runStep(&recv); // user, assistant(tool_use), user(tool_result), assistant(text) try testing.expectEqual(@as(usize, 4), conv.messages.items.len); @@ -1757,15 +2056,16 @@ test "runStep: unknown tool becomes an error tool result and the loop continues" var h = TestHarness.init(allocator); defer h.deinit(); h.activate(); - var agent = Agent.init(allocator, io, &h.config); + 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(); - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addUserMessage("call a ghost"); var recv = NoopReceiver.make(); - try agent.runStep(&conv, &recv); + try agent.runStep(&recv); // messages: user, assistant(tool_use), user(tool_result), assistant(text) try testing.expectEqual(@as(usize, 4), conv.messages.items.len); @@ -1790,15 +2090,16 @@ test "runStep with no tool calls returns after one provider step" { var h = TestHarness.init(allocator); defer h.deinit(); h.activate(); - var agent = Agent.init(allocator, io, &h.config); + 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(); - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addUserMessage("hello"); var recv = NoopReceiver.make(); - try agent.runStep(&conv, &recv); + try agent.runStep(&recv); try testing.expectEqual(@as(usize, 2), conv.messages.items.len); try testing.expectEqualStrings("hi", conv.messages.items[1].content.items[0].Text.items); @@ -1817,15 +2118,16 @@ test "runStep surfaces EmptyAssistantResponse when provider commits an empty mes var h = TestHarness.init(allocator); defer h.deinit(); h.activate(); - var agent = Agent.init(allocator, io, &h.config); + 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(); - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addUserMessage("hi"); var recv = NoopReceiver.make(); - try testing.expectError(error.EmptyAssistantResponse, agent.runStep(&conv, &recv)); + try testing.expectError(error.EmptyAssistantResponse, agent.runStep(&recv)); } // ------------ ToolSource tests ------------ @@ -1849,15 +2151,16 @@ test "runStep delivers all source-backed calls in one batch on one thread" { defer h.deinit(); try h.registry.registerSource(try TestSource.create(allocator, "panto-lua", &.{ "lua_x", "lua_y" })); h.activate(); - var agent = Agent.init(allocator, io, &h.config); + 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(); - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addUserMessage("go"); var recv = NoopReceiver.make(); - try agent.runStep(&conv, &recv); + try agent.runStep(&recv); // Locate the source and inspect its observed batches. const view = h.registry.lookup("lua_x") orelse return error.NotFound; @@ -1901,15 +2204,16 @@ test "runStep: distinct sources run on distinct threads in parallel" { try h.registry.registerSource(try TestSource.create(allocator, "src_a", &.{"src_a_t"})); try h.registry.registerSource(try TestSource.create(allocator, "src_b", &.{"src_b_t"})); h.activate(); - var agent = Agent.init(allocator, io, &h.config); + 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(); - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addUserMessage("go"); var recv = NoopReceiver.make(); - try agent.runStep(&conv, &recv); + try agent.runStep(&recv); const view_a = h.registry.lookup("src_a_t") orelse return error.NotFound; const view_b = h.registry.lookup("src_b_t") orelse return error.NotFound; @@ -1940,15 +2244,16 @@ test "runStep: source whole-batch error becomes per-call error results and conti defer h.deinit(); try h.registry.registerSource(try FailingSource.create(allocator, "fs", &.{ "fa", "fb" })); h.activate(); - var agent = Agent.init(allocator, io, &h.config); + 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(); - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addUserMessage("kaboom"); var recv = NoopReceiver.make(); - try agent.runStep(&conv, &recv); + try agent.runStep(&recv); // user, assistant(tool_use x2), user(tool_result x2), assistant(text) try testing.expectEqual(@as(usize, 4), conv.messages.items.len); @@ -1984,15 +2289,16 @@ test "runStep: mixed single Tools and source-backed tools coexist in one turn" { try h.registry.register(try EchoTool.create(allocator, "single", "S:")); try h.registry.registerSource(try TestSource.create(allocator, "src", &.{ "src_t1", "src_t2" })); h.activate(); - var agent = Agent.init(allocator, io, &h.config); + 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(); - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addUserMessage("go"); var recv = NoopReceiver.make(); - try agent.runStep(&conv, &recv); + try agent.runStep(&recv); const tr_msg = conv.messages.items[2]; try testing.expectEqual(@as(usize, 3), tr_msg.content.items.len); @@ -2036,7 +2342,9 @@ test "setConfig swaps the visible tool set between turns" { .registry = ®_b, }; - var agent = Agent.init(allocator, io, &cfg_a); + var ns = null_store_mod.NullStore.init(allocator); + var agent = Agent.init(allocator, io, &cfg_a, ns.store(), null); + defer agent.deinit(); agent.stream_fn = stub.install(); // Under A: `echo` visible, `late` not. @@ -2050,11 +2358,10 @@ test "setConfig swaps the visible tool set between turns" { // A real turn under B resolves `late` (which would have been // UnknownTool under A), then loops to the final text turn. - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addUserMessage("go"); var recv = NoopReceiver.make(); - try agent.runStep(&conv, &recv); + try agent.runStep(&recv); const tr = conv.messages.items[2].content.items[0].ToolResult; try testing.expectEqualStrings("2", tr.tool_use_id); @@ -2079,11 +2386,12 @@ test "compact: summarizes prefix, keeps suffix, system survives" { // 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); + 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(); - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addSystemMessage("you are helpful"); try conv.addUserMessage("first question here with several words"); try conv.addAssistantMessage(&.{ @@ -2094,7 +2402,7 @@ test "compact: summarizes prefix, keeps suffix, system survives" { .{ .Text = try conversation.textualBlockFromSlice(allocator, "second recent answer") }, }); - const res = try agent.compact(&conv, "Summarize the conversation.", null); + const res = try agent.compact("Summarize the conversation.", null); try testing.expect(res.compacted); // Expected rebuilt: [system, compaction summary(user), user q2, asst a2] @@ -2133,18 +2441,19 @@ test "compact: no-op when conversation already fits the budget" { defer h.deinit(); h.activate(); h.config.compaction = .{ .keep_verbatim = 1_000_000 }; - var agent = Agent.init(allocator, io, &h.config); + 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(); - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; 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); + const res = try agent.compact("Summarize.", null); try testing.expect(!res.compacted); try testing.expectEqual(@as(usize, 3), conv.messages.items.len); // Stub was never consumed. @@ -2169,11 +2478,12 @@ test "compact: extra instructions are appended to the system prompt" { defer h.deinit(); h.activate(); h.config.compaction = .{ .keep_verbatim = 1 }; - var agent = Agent.init(allocator, io, &h.config); + 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(); - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addUserMessage("question one two three"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer one two three") }, @@ -2183,7 +2493,7 @@ test "compact: extra instructions are appended to the system prompt" { .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer two") }, }); - const res = try agent.compact(&conv, "Base prompt.", "keep bug #3 details"); + const res = try agent.compact("Base prompt.", "keep bug #3 details"); try testing.expect(res.compacted); } @@ -2208,12 +2518,13 @@ test "runStep: auto-compacts on context overflow and retries once" { defer h.deinit(); h.activate(); h.config.compaction = .{ .keep_verbatim = 10 }; - var agent = Agent.init(allocator, io, &h.config); + 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(); agent.compaction_system_prompt = "Summarize the conversation."; - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addSystemMessage("you are helpful"); try conv.addUserMessage("first question with several words here"); try conv.addAssistantMessage(&.{ @@ -2222,7 +2533,7 @@ test "runStep: auto-compacts on context overflow and retries once" { try conv.addUserMessage("second recent question"); var recv = NoopReceiver.make(); - try agent.runStep(&conv, &recv); + try agent.runStep(&recv); try testing.expect(agent.auto_compacted); // After compaction + retry: [system, summary, user q2, assistant final]. @@ -2253,16 +2564,17 @@ test "runStep: context overflow without compaction prompt propagates" { var h = TestHarness.init(allocator); defer h.deinit(); h.activate(); - var agent = Agent.init(allocator, io, &h.config); + 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(); // No compaction_system_prompt set -> overflow propagates. - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addUserMessage("hi"); var recv = NoopReceiver.make(); - try testing.expectError(error.ContextOverflow, agent.runStep(&conv, &recv)); + try testing.expectError(error.ContextOverflow, agent.runStep(&recv)); } // ----------------------------------------------------------------------------- @@ -2339,17 +2651,18 @@ test "runStep: provider 429 retries then succeeds without duplicate messages" { var h = TestHarness.init(allocator); defer h.deinit(); fastRetryHarness(&h); - var agent = Agent.init(allocator, io, &h.config); + 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(); - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addUserMessage("hi"); var rr = RetryRecordingReceiver{ .allocator = allocator }; defer rr.deinit(); var recv = rr.make(); - try agent.runStep(&conv, &recv); + try agent.runStep(&recv); // Two failures + one success. try testing.expectEqual(@as(usize, 3), stub.calls_made); @@ -2383,17 +2696,18 @@ test "runStep: provider 500 retries with backoff notification" { var h = TestHarness.init(allocator); defer h.deinit(); fastRetryHarness(&h); - var agent = Agent.init(allocator, io, &h.config); + 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(); - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addUserMessage("hi"); var rr = RetryRecordingReceiver{ .allocator = allocator }; defer rr.deinit(); var recv = rr.make(); - try agent.runStep(&conv, &recv); + try agent.runStep(&recv); try testing.expectEqual(@as(usize, 2), stub.calls_made); try testing.expectEqual(@as(usize, 1), rr.infos.items.len); @@ -2422,17 +2736,18 @@ test "runStep: provider auth failure does not retry" { var h = TestHarness.init(allocator); defer h.deinit(); fastRetryHarness(&h); - var agent = Agent.init(allocator, io, &h.config); + 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(); - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addUserMessage("hi"); var rr = RetryRecordingReceiver{ .allocator = allocator }; defer rr.deinit(); var recv = rr.make(); - try testing.expectError(error.ProviderAuthFailed, agent.runStep(&conv, &recv)); + try testing.expectError(error.ProviderAuthFailed, agent.runStep(&recv)); // Exactly one attempt, no retry notification. try testing.expectEqual(@as(usize, 1), stub.calls_made); @@ -2462,17 +2777,18 @@ test "runStep: retries exhaust and hard-fail after max_attempts" { var h = TestHarness.init(allocator); defer h.deinit(); fastRetryHarness(&h); - var agent = Agent.init(allocator, io, &h.config); + 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(); - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addUserMessage("hi"); var rr = RetryRecordingReceiver{ .allocator = allocator }; defer rr.deinit(); var recv = rr.make(); - try testing.expectError(error.ProviderUnavailable, agent.runStep(&conv, &recv)); + try testing.expectError(error.ProviderUnavailable, agent.runStep(&recv)); // 4 attempts total (max_attempts), 3 retry notifications. try testing.expectEqual(@as(usize, 4), stub.calls_made); @@ -2501,17 +2817,18 @@ test "runStep: Retry-After is honored and reported" { h.activate(); // Cap below the Retry-After to verify the policy cap applies. h.config.retry = .{ .initial_delay_ms = 0, .max_delay_ms = 1, .jitter = false }; - var agent = Agent.init(allocator, io, &h.config); + 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(); - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addUserMessage("hi"); var rr = RetryRecordingReceiver{ .allocator = allocator }; defer rr.deinit(); var recv = rr.make(); - try agent.runStep(&conv, &recv); + try agent.runStep(&recv); try testing.expectEqual(@as(usize, 1), rr.infos.items.len); // Reported Retry-After is the raw provider value... @@ -2537,15 +2854,16 @@ test "runStep: cancellation from a tool still hard-fails" { defer h.deinit(); try h.registry.register(try HardFailTool.create(allocator, "hard")); h.activate(); - var agent = Agent.init(allocator, io, &h.config); + 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(); - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addUserMessage("go"); var recv = NoopReceiver.make(); - try testing.expectError(error.Canceled, agent.runStep(&conv, &recv)); + try testing.expectError(error.Canceled, agent.runStep(&recv)); // Turn aborts: no tool result appended (user + assistant only). try testing.expectEqual(@as(usize, 2), conv.messages.items.len); } @@ -2568,15 +2886,16 @@ test "runStep: source per-call error produces a per-call error result and contin defer h.deinit(); try h.registry.registerSource(try PartialSource.create(allocator, "ps", &.{ "pa", "pb" })); h.activate(); - var agent = Agent.init(allocator, io, &h.config); + 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(); - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addUserMessage("go"); var recv = NoopReceiver.make(); - try agent.runStep(&conv, &recv); + try agent.runStep(&recv); try testing.expectEqual(@as(usize, 4), conv.messages.items.len); const tr_msg = conv.messages.items[2]; @@ -2607,12 +2926,13 @@ test "runStep: context-overflow compaction fires a compaction retry notification defer h.deinit(); h.activate(); h.config.compaction = .{ .keep_verbatim = 10 }; - var agent = Agent.init(allocator, io, &h.config); + 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(); agent.compaction_system_prompt = "Summarize the conversation."; - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); + const conv = &agent.conversation; try conv.addSystemMessage("you are helpful"); try conv.addUserMessage("first question with several words here"); try conv.addAssistantMessage(&.{ @@ -2623,7 +2943,7 @@ test "runStep: context-overflow compaction fires a compaction retry notification var rr = RetryRecordingReceiver{ .allocator = allocator }; defer rr.deinit(); var recv = rr.make(); - try agent.runStep(&conv, &recv); + try agent.runStep(&recv); try testing.expect(agent.auto_compacted); // Exactly one notification, flagged as a compaction retry with no delay. -- cgit v1.3