summaryrefslogtreecommitdiff
path: root/libpanto
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-05 11:43:29 -0600
committert <t@tjp.lol>2026-06-05 11:43:43 -0600
commitccdaef8e682960ecadf73d3b2df70559a0baaf56 (patch)
tree2a6e455ca3359b68bf5cb5b83015054bd6e2eb6f /libpanto
parent03ac69658517a0054d2a573d1e1b60c1bfaaf977 (diff)
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.
Diffstat (limited to 'libpanto')
-rw-r--r--libpanto/src/agent.zig550
-rw-r--r--libpanto/src/null_store.zig97
-rw-r--r--libpanto/src/root.zig1
-rw-r--r--libpanto/src/session_store.zig10
-rw-r--r--libpanto/src/turn_persist.zig170
5 files changed, 670 insertions, 158 deletions
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 = &reg_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.
diff --git a/libpanto/src/null_store.zig b/libpanto/src/null_store.zig
index a9bd813..d2337ee 100644
--- a/libpanto/src/null_store.zig
+++ b/libpanto/src/null_store.zig
@@ -1,11 +1,16 @@
//! `NullStore`: a no-op `SessionStore` for embedders who opt out of
//! persistence (and the default backing for an `Agent` constructed without
-//! an explicit store).
+//! a real store).
//!
-//! Every append is dropped. `loadConversation` returns an empty
-//! conversation with no dangling prompt. `activeModel` is null and the
-//! session id is the empty string. The store is stateless, so a single
-//! process-global instance backs every `NullStore` handle.
+//! Every append is dropped (but its messages are freed, honoring the store
+//! ownership contract — appends consume their messages). `loadConversation`
+//! returns an empty conversation with no dangling prompt. `activeModel` is
+//! null and the session id is the empty string.
+//!
+//! Because freeing the dropped messages needs an allocator, `NullStore`
+//! carries one. Construct it with `init(alloc)` and call `.store()` for the
+//! interface handle. The struct is trivially copyable and holds no other
+//! state, so the handle may borrow it for the agent's lifetime.
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -18,54 +23,64 @@ const LoadedSession = session_store_mod.LoadedSession;
const DiskMessage = session_store_mod.DiskMessage;
const ActiveModel = session_store_mod.ActiveModel;
-/// Stateless singleton context. The vtable ignores `ctx` entirely; we hand
-/// out a pointer to this so the `*anyopaque` is always valid.
-var singleton: u8 = 0;
+pub const NullStore = struct {
+ allocator: Allocator,
-fn appendMessagesVT(
- _: *anyopaque,
- _: []DiskMessage,
- _: []const ?[]const u8,
- _: []const ?[]const u8,
-) anyerror!void {}
+ pub fn init(allocator: Allocator) NullStore {
+ return .{ .allocator = allocator };
+ }
-fn loadConversationVT(_: *anyopaque, alloc: Allocator) anyerror!LoadedSession {
- return .{
- .conversation = conversation_mod.Conversation.init(alloc),
- .dangling_user = null,
- };
-}
+ fn appendMessagesVT(
+ ctx: *anyopaque,
+ messages: []DiskMessage,
+ _: []const ?[]const u8,
+ _: []const ?[]const u8,
+ ) anyerror!void {
+ const self: *NullStore = @ptrCast(@alignCast(ctx));
+ // Appends consume their messages; we drop them, so free them.
+ for (messages) |m| m.deinit(self.allocator);
+ }
-fn sessionIdVT(_: *anyopaque) []const u8 {
- return "";
-}
+ fn loadConversationVT(ctx: *anyopaque, alloc: Allocator) anyerror!LoadedSession {
+ _ = ctx;
+ return .{
+ .conversation = conversation_mod.Conversation.init(alloc),
+ .dangling_user = null,
+ };
+ }
-fn activeModelVT(_: *anyopaque) ?ActiveModel {
- return null;
-}
+ fn sessionIdVT(_: *anyopaque) []const u8 {
+ return "";
+ }
-const vtable: SessionStore.VTable = .{
- .appendMessages = appendMessagesVT,
- .loadConversation = loadConversationVT,
- .sessionId = sessionIdVT,
- .activeModel = activeModelVT,
-};
+ fn activeModelVT(_: *anyopaque) ?ActiveModel {
+ return null;
+ }
-/// A no-op `SessionStore`. Cheap to call repeatedly; all handles share one
-/// stateless context.
-pub fn store() SessionStore {
- return .{ .ptr = &singleton, .vtable = &vtable };
-}
+ const vtable: SessionStore.VTable = .{
+ .appendMessages = appendMessagesVT,
+ .loadConversation = loadConversationVT,
+ .sessionId = sessionIdVT,
+ .activeModel = activeModelVT,
+ };
+
+ /// Wrap this `NullStore` as a `SessionStore`. The handle borrows
+ /// `self`; `self` must outlive it.
+ pub fn store(self: *NullStore) SessionStore {
+ return .{ .ptr = self, .vtable = &vtable };
+ }
+};
const testing = std.testing;
-test "NullStore: appends are dropped and load returns empty" {
+test "NullStore: appends are dropped (and freed) and load returns empty" {
+ var ns = NullStore.init(testing.allocator);
+ const s = ns.store();
+
var msg_content = try testing.allocator.alloc(session_store_mod.DiskContentBlock, 1);
msg_content[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi") } };
var messages = [_]DiskMessage{.{ .role = .user, .content = msg_content }};
- defer messages[0].deinit(testing.allocator);
-
- const s = store();
+ // No defer-free: the store consumes (frees) the message.
try s.appendMessages(&messages, &.{null}, &.{null});
var loaded = try s.loadConversation(testing.allocator);
diff --git a/libpanto/src/root.zig b/libpanto/src/root.zig
index 39797dd..a49a428 100644
--- a/libpanto/src/root.zig
+++ b/libpanto/src/root.zig
@@ -12,6 +12,7 @@ pub const session = @import("session.zig");
pub const session_manager = @import("session_manager.zig");
pub const session_store = @import("session_store.zig");
pub const null_store = @import("null_store.zig");
+pub const turn_persist = @import("turn_persist.zig");
pub const pricing = @import("pricing.zig");
pub const compaction = @import("compaction.zig");
pub const image = @import("image.zig");
diff --git a/libpanto/src/session_store.zig b/libpanto/src/session_store.zig
index 83bf136..b929c66 100644
--- a/libpanto/src/session_store.zig
+++ b/libpanto/src/session_store.zig
@@ -85,8 +85,14 @@ pub const SessionStore = struct {
/// Append a batch of messages atomically. `providers`/`models` are
/// parallel arrays (one per message) carrying the top-level entry
/// stamps (recorded on user entries). A single append is a
- /// length-1 batch. Implementations consume nothing — the caller
- /// retains ownership of `messages` and frees them after return.
+ /// length-1 batch.
+ ///
+ /// Ownership: the store **consumes** each `DiskMessage` on success
+ /// (takes ownership of its heap allocations). On error the store
+ /// frees any messages it had already taken and the caller frees the
+ /// rest — i.e. after this call returns the caller must not free the
+ /// `DiskMessage`s regardless of outcome. (The `messages` *slice*
+ /// itself remains the caller's.)
appendMessages: *const fn (
ctx: *anyopaque,
messages: []DiskMessage,
diff --git a/libpanto/src/turn_persist.zig b/libpanto/src/turn_persist.zig
new file mode 100644
index 0000000..a8b3faa
--- /dev/null
+++ b/libpanto/src/turn_persist.zig
@@ -0,0 +1,170 @@
+//! Turn → session-log persistence: map in-memory `Conversation` messages
+//! to neutral `DiskMessage`s and append them to a `SessionStore`.
+//!
+//! This logic used to live in the `panto` CLI (`src/session_persist.zig`),
+//! driven externally around `runStep`. It now lives in `libpanto` and is
+//! called by the `Agent` itself, so every embedder gets persistence for
+//! free. The functions here are stateless helpers over a `SessionStore`;
+//! the agent owns the store and the conversation.
+//!
+//! Per-message usage is read directly off `Message.usage` (canonical: both
+//! providers stamp it via `addAssistantMessageWithUsage`). There is no
+//! separate usage list to thread through.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+
+const conversation = @import("conversation.zig");
+const session = @import("session.zig");
+const session_store = @import("session_store.zig");
+
+const DiskMessage = session_store.DiskMessage;
+const DiskContentBlock = session_store.DiskContentBlock;
+const SessionStore = session_store.SessionStore;
+
+/// Persist every conversation message at index `>= start_index` to `store`
+/// as a single atomic batch.
+///
+/// Mapping:
+/// - assistant → assistant entry with provider/model/stop_reason and the
+/// message's own `usage`.
+/// - user (incl. ToolResult-only messages) → user entry stamped with
+/// provider/model.
+/// - system → system entry, verbatim, unstamped (mid-turn system
+/// messages aren't produced by pantograph today, but persist harmless).
+///
+/// An assistant message carrying a ToolUse with no following matching
+/// ToolResult is skipped (a dangling tool call from an interrupted turn);
+/// persisting it would make the log un-replayable.
+///
+/// `stop_reason` is `"stop"` for now; the real wire value needs provider
+/// plumbing (separate future work).
+pub fn persistTurn(
+ alloc: Allocator,
+ store: SessionStore,
+ conv: *const conversation.Conversation,
+ start_index: usize,
+ provider: []const u8,
+ model: []const u8,
+) !void {
+ // Ownership note: the messages we build here are handed to
+ // `store.appendMessages`, which *consumes* them. We therefore never
+ // free entries in `batch_messages` ourselves — only the parallel
+ // metadata arrays and the per-block transient on the error path before
+ // a message is appended to the batch. This mirrors the original CLI
+ // `persistTurn` exactly (it transferred ownership to the manager and
+ // never freed the DiskMessages).
+ var batch_messages: std.ArrayList(DiskMessage) = .empty;
+ defer batch_messages.deinit(alloc);
+ var batch_providers: std.ArrayList(?[]const u8) = .empty;
+ defer batch_providers.deinit(alloc);
+ var batch_models: std.ArrayList(?[]const u8) = .empty;
+ defer batch_models.deinit(alloc);
+
+ var i = start_index;
+ while (i < conv.messages.items.len) : (i += 1) {
+ const msg = conv.messages.items[i];
+ const blocks = try alloc.alloc(DiskContentBlock, msg.content.items.len);
+ var allocated: usize = 0;
+ errdefer {
+ for (blocks[0..allocated]) |b| b.deinit(alloc);
+ alloc.free(blocks);
+ }
+ for (msg.content.items) |block| {
+ blocks[allocated] = try session.contentBlockToDisk(alloc, block);
+ allocated += 1;
+ }
+ if (msg.role == .assistant and hasToolUseWithoutFollowingResults(conv, i)) {
+ for (blocks[0..allocated]) |b| b.deinit(alloc);
+ alloc.free(blocks);
+ continue;
+ }
+ switch (msg.role) {
+ .system => {
+ // The System block carries the append/replace mode; it
+ // rides on `DiskMessage.mode`, not on the disk block. Derive
+ // it from the in-memory message so reconciliation on replay
+ // reconstructs the same effective prompt.
+ try batch_messages.append(alloc, .{
+ .role = .system,
+ .mode = systemModeOf(msg),
+ .content = blocks,
+ });
+ try batch_providers.append(alloc, null);
+ try batch_models.append(alloc, null);
+ },
+ .user => {
+ try batch_messages.append(alloc, .{ .role = .user, .content = blocks });
+ try batch_providers.append(alloc, provider);
+ try batch_models.append(alloc, model);
+ },
+ .assistant => {
+ try batch_messages.append(alloc, .{
+ .role = .assistant,
+ .content = blocks,
+ .provider = try alloc.dupe(u8, provider),
+ .model = try alloc.dupe(u8, model),
+ .stop_reason = try alloc.dupe(u8, "stop"),
+ .usage = msg.usage,
+ });
+ try batch_providers.append(alloc, null);
+ try batch_models.append(alloc, null);
+ },
+ }
+ }
+
+ if (batch_messages.items.len == 0) return;
+ try store.appendMessages(batch_messages.items, batch_providers.items, batch_models.items);
+}
+
+/// Persist a compaction result. The agent rewrote the conversation to
+/// `[system..., summary, kept-suffix...]`; persist everything from the
+/// latest compaction summary onward as fresh entries. On replay the latest
+/// summary resets effective context, so the duplicated suffix is what
+/// survives. Usage isn't re-derived for the restated suffix (those turns
+/// were already sized when first logged).
+pub fn persistCompaction(
+ alloc: Allocator,
+ store: SessionStore,
+ conv: *const conversation.Conversation,
+ provider: []const u8,
+ model: []const u8,
+) !void {
+ const start = conversation.latestCompactionIndex(conv.messages.items) orelse return;
+ try persistTurn(alloc, store, conv, start, provider, model);
+}
+
+/// Derive the disk system-mode from a system message's blocks. A `.System`
+/// block carries the mode; a `replace` anywhere makes the message a
+/// replace. Defaults to `append`.
+fn systemModeOf(msg: conversation.Message) session.DiskSystemMode {
+ for (msg.content.items) |block| {
+ if (block == .System and block.System.mode == .replace) return .replace;
+ }
+ return .append;
+}
+
+/// True when the assistant message at `index` contains a ToolUse block
+/// with no matching ToolResult in the immediately following user message
+/// — i.e. a dangling tool call (e.g. a turn cut short by an error).
+pub fn hasToolUseWithoutFollowingResults(
+ conv: *const conversation.Conversation,
+ index: usize,
+) bool {
+ const msg = conv.messages.items[index];
+ for (msg.content.items) |block| {
+ if (block != .ToolUse) continue;
+ if (index + 1 >= conv.messages.items.len) return true;
+ const next = conv.messages.items[index + 1];
+ if (next.role != .user) return true;
+ var found = false;
+ for (next.content.items) |next_block| {
+ if (next_block == .ToolResult and std.mem.eql(u8, next_block.ToolResult.tool_use_id, block.ToolUse.id)) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) return true;
+ }
+ return false;
+}