summaryrefslogtreecommitdiff
path: root/libpanto
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto')
-rw-r--r--libpanto/src/agent.zig240
-rw-r--r--libpanto/src/config.zig31
-rw-r--r--libpanto/src/conversation.zig7
-rw-r--r--libpanto/src/file_system_jsonl_store.zig (renamed from libpanto/src/session_manager.zig)877
-rw-r--r--libpanto/src/null_store.zig104
-rw-r--r--libpanto/src/root.zig2
-rw-r--r--libpanto/src/session.zig302
-rw-r--r--libpanto/src/session_store.zig252
-rw-r--r--libpanto/src/turn_persist.zig153
9 files changed, 1129 insertions, 839 deletions
diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig
index e8be325..604ee55 100644
--- a/libpanto/src/agent.zig
+++ b/libpanto/src/agent.zig
@@ -197,20 +197,12 @@ pub const Agent = struct {
/// 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 = "",
+ /// The session this agent appends to. Minted from the store at `init`
+ /// (fresh: `store.create()`) or supplied by the embedder on resume
+ /// (`resolve`/`latest`). `Session.append` proxies to the store and
+ /// updates the session's last-used wire identity. The embedder owns the
+ /// underlying store, which must outlive the agent.
+ session: session_store_mod.Session,
/// Injectable streaming seam. Defaults to the real provider dispatch
/// (`provider_mod.openStream`); tests override it with a stub.
open_stream_fn: provider_mod.OpenStreamFn = provider_mod.openStream,
@@ -239,7 +231,7 @@ pub const Agent = struct {
allocator: Allocator,
io: Io,
config: *const Config,
- store: session_store_mod.SessionStore,
+ session: session_store_mod.Session,
maybe_conversation: ?conversation.Conversation,
) Agent {
return .{
@@ -248,16 +240,18 @@ pub const Agent = struct {
.config = config,
.registry = ToolRegistry.init(allocator),
.conversation = maybe_conversation orelse conversation.Conversation.init(allocator),
- .session_store = store,
+ .session = session,
};
}
pub fn deinit(self: *Agent) void {
- // The agent owns the conversation and the tool registry; it borrows
- // the config snapshot and the session store, which the embedder
- // tears down.
+ // The agent owns the conversation, the tool registry, and the
+ // session handle's `info` (minted by `store.create()` or resolved
+ // by the embedder and handed in). It borrows the config snapshot
+ // and the underlying store, which the embedder tears down.
self.registry.deinit();
self.conversation.deinit();
+ self.session.info.deinit(self.allocator);
}
/// Add a single tool to this agent's tool set. Visible at the next turn.
@@ -271,22 +265,11 @@ pub const Agent = struct {
try self.registry.registerSource(src);
}
- /// 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 };
+ /// The wire-format provider identity stamped on persisted entries,
+ /// derived from the active config snapshot. Ground truth: never a CLI
+ /// alias, never any `api_key` material.
+ fn wireIdentity(self: *const Agent) session_store_mod.WireIdentity {
+ return self.config.provider.wireIdentity();
}
/// Swap the active configuration snapshot. Takes effect at the start of
@@ -312,14 +295,13 @@ pub const Agent = struct {
.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.session,
&self.conversation,
start,
- pm.provider,
- pm.model,
+ self.wireIdentity(),
+ &.{},
);
}
@@ -355,14 +337,13 @@ pub const Agent = struct {
// recovery guarantee).
const user_start = self.conversation.messages.items.len;
try self.conversation.addUserMessage(message.text);
- const pm = self.providerModel();
try turn_persist.persistTurn(
self.allocator,
- self.session_store,
+ &self.session,
&self.conversation,
user_start,
- pm.provider,
- pm.model,
+ self.wireIdentity(),
+ &.{},
);
const s = try self.allocator.create(Stream);
@@ -375,23 +356,23 @@ pub const Agent = struct {
/// `[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();
+ const id = self.wireIdentity();
if (self.auto_compacted) {
try turn_persist.persistCompaction(
self.allocator,
- self.session_store,
+ &self.session,
&self.conversation,
- pm.provider,
- pm.model,
+ id,
+ &.{},
);
} else {
try turn_persist.persistTurn(
self.allocator,
- self.session_store,
+ &self.session,
&self.conversation,
start,
- pm.provider,
- pm.model,
+ id,
+ &.{},
);
}
}
@@ -621,13 +602,12 @@ pub const Agent = struct {
) !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.session,
&self.conversation,
- pm.provider,
- pm.model,
+ self.wireIdentity(),
+ &.{},
);
}
return res;
@@ -1798,59 +1778,81 @@ const HardFailTool = struct {
};
/// An in-memory `SessionStore` test double: records every appended
-/// `DiskMessage` (role + provider/model stamp) so tests can assert the
+/// `StoredMessage` (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,
+ roles: std.ArrayList(conversation.MessageRole) = .empty,
+ base_urls: 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);
+ for (self.base_urls.items) |s| self.allocator.free(s);
+ self.base_urls.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 {
+ fn createVT(ctx: *anyopaque) session_store_mod.Session {
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);
+ const a = self.allocator;
+ const info: session_store_mod.SessionInfo = .{
+ .id = a.dupe(u8, "cap") catch "cap",
+ .created = a.dupe(u8, "") catch "",
+ .modified = a.dupe(u8, "") catch "",
+ .message_count = 0,
+ .last_user_message = a.dupe(u8, "") catch "",
+ .api_style = .openai_chat,
+ .base_url = a.dupe(u8, "") catch "",
+ .model = a.dupe(u8, "") catch "",
+ .reasoning = .default,
+ };
+ return .{ .info = info, .store = self.store() };
}
- fn loadConversationVT(_: *anyopaque, alloc: Allocator) anyerror!session_store_mod.LoadedSession {
- return .{ .conversation = conversation.Conversation.init(alloc), .dangling_user = null };
+ fn listVT(ctx: *anyopaque) anyerror![]session_store_mod.SessionInfo {
+ const self: *CapturingStore = @ptrCast(@alignCast(ctx));
+ return self.allocator.alloc(session_store_mod.SessionInfo, 0);
+ }
+ fn freeSessionInfosVT(ctx: *anyopaque, infos: []session_store_mod.SessionInfo) void {
+ const self: *CapturingStore = @ptrCast(@alignCast(ctx));
+ for (infos) |i| i.deinit(self.allocator);
+ self.allocator.free(infos);
}
- fn sessionIdVT(_: *anyopaque) []const u8 {
- return "cap";
+ fn resolveVT(_: *anyopaque, _: []const u8) anyerror!?session_store_mod.Session {
+ return null;
+ }
+ fn latestVT(_: *anyopaque) anyerror!?session_store_mod.Session {
+ return null;
}
- fn activeModelVT(_: *anyopaque) ?session_store_mod.ActiveModel {
+ fn loadVT(_: *anyopaque, _: []const u8) anyerror!?conversation.Conversation {
return null;
}
+ fn appendMessagesVT(
+ ctx: *anyopaque,
+ _: []const u8,
+ messages: []session_store_mod.PersistentMessage,
+ ) anyerror!void {
+ const self: *CapturingStore = @ptrCast(@alignCast(ctx));
+ for (messages) |m| {
+ try self.roles.append(self.allocator, m.message.role);
+ try self.base_urls.append(self.allocator, try self.allocator.dupe(u8, m.identity.base_url));
+ }
+ }
+
const vtable: session_store_mod.SessionStore.VTable = .{
+ .create = createVT,
+ .list = listVT,
+ .freeSessionInfos = freeSessionInfosVT,
+ .resolve = resolveVT,
+ .latest = latestVT,
+ .load = loadVT,
.appendMessages = appendMessagesVT,
- .loadConversation = loadConversationVT,
- .sessionId = sessionIdVT,
- .activeModel = activeModelVT,
};
fn store(self: *CapturingStore) session_store_mod.SessionStore {
@@ -1880,27 +1882,25 @@ test "agent persists user, assistant, and tool-result messages of a turn" {
var cap = CapturingStore.init(allocator);
defer cap.deinit();
- var agent = Agent.init(allocator, io, &h.config, cap.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, cap.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
- agent.persist_provider = "openai";
- agent.persist_model = "gpt-4o";
try drainTurn(&agent, "call a tool");
// 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]);
+ try testing.expectEqual(conversation.MessageRole.user, cap.roles.items[0]);
+ try testing.expectEqual(conversation.MessageRole.assistant, cap.roles.items[1]);
+ try testing.expectEqual(conversation.MessageRole.user, cap.roles.items[2]);
+ try testing.expectEqual(conversation.MessageRole.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.?);
+ // The wire identity (base_url from the active config) rode through on
+ // every entry.
+ for (cap.base_urls.items) |b| {
+ try testing.expectEqualStrings("u", b);
}
}
@@ -1919,7 +1919,7 @@ test "agent runs a turn against NullStore without persisting or erroring" {
h.activate();
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -2209,7 +2209,7 @@ test "runStep dispatches a tool call and loops to a final text turn" {
try h.registry.register(try EchoTool.create(allocator, "echo", "ECHO:"));
h.activate();
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -2260,7 +2260,7 @@ test "runStep dispatches multiple tool calls in parallel" {
try h.registry.register(try BarrierTool.create(allocator, "barrierC", &barrier));
h.activate();
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -2300,7 +2300,7 @@ test "runStep: native tool handler error becomes an error result and the model g
try h.registry.register(try FailingTool.create(allocator, "boom"));
h.activate();
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -2334,7 +2334,7 @@ test "runStep: unknown tool becomes an error tool result and the loop continues"
defer h.deinit();
h.activate();
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -2367,7 +2367,7 @@ test "runStep with no tool calls returns after one provider step" {
defer h.deinit();
h.activate();
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -2394,7 +2394,7 @@ test "runStep surfaces EmptyAssistantResponse when provider commits an empty mes
defer h.deinit();
h.activate();
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -2425,7 +2425,7 @@ test "runStep delivers all source-backed calls in one batch on one thread" {
try h.registry.registerSource(try TestSource.create(allocator, "panto-lua", &.{ "lua_x", "lua_y" }));
h.activate();
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -2477,7 +2477,7 @@ test "runStep: distinct sources run on distinct threads in parallel" {
try h.registry.registerSource(try TestSource.create(allocator, "src_b", &.{"src_b_t"}));
h.activate();
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -2515,7 +2515,7 @@ test "runStep: source whole-batch error becomes per-call error results and conti
try h.registry.registerSource(try FailingSource.create(allocator, "fs", &.{ "fa", "fb" }));
h.activate();
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -2559,7 +2559,7 @@ test "runStep: mixed single Tools and source-backed tools coexist in one turn" {
try h.registry.registerSource(try TestSource.create(allocator, "src", &.{ "src_t1", "src_t2" }));
h.activate();
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -2599,7 +2599,7 @@ test "setConfig swaps provider between turns; agent tool set persists" {
};
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &cfg_a, ns.store(), null);
+ var agent = Agent.init(allocator, io, &cfg_a, ns.store().create(), null);
defer agent.deinit();
try agent.registerTool(try EchoTool.create(allocator, "late", "B:"));
agent.open_stream_fn = stub.install();
@@ -2638,7 +2638,7 @@ test "compact: summarizes prefix, keeps suffix, system survives" {
// adding the longer first turn exceeds it.
h.config.compaction = .{ .keep_verbatim = 10 };
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -2701,7 +2701,7 @@ test "compact: restated suffix usage reconstructs a fresh cumulative chain" {
// summarizes the prefix.
h.config.compaction = .{ .keep_verbatim = 20 };
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -2775,7 +2775,7 @@ test "compact: no-op when conversation already fits the budget" {
h.activate();
h.config.compaction = .{ .keep_verbatim = 1_000_000 };
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -2813,7 +2813,7 @@ test "compact: extra instructions are appended to the system prompt" {
h.activate();
h.config.compaction = .{ .keep_verbatim = 1 };
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -2854,7 +2854,7 @@ test "runStep: auto-compacts on context overflow and retries once" {
h.activate();
h.config.compaction = .{ .keep_verbatim = 10 };
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -2899,7 +2899,7 @@ test "runStep: context overflow without compaction prompt propagates" {
defer h.deinit();
h.activate();
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -2971,7 +2971,7 @@ test "runStep: provider 429 retries then succeeds without duplicate messages" {
defer h.deinit();
fastRetryHarness(&h);
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -3015,7 +3015,7 @@ test "runStep: provider 500 retries with backoff notification" {
defer h.deinit();
fastRetryHarness(&h);
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -3053,7 +3053,7 @@ test "runStep: provider auth failure does not retry" {
defer h.deinit();
fastRetryHarness(&h);
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -3092,7 +3092,7 @@ test "runStep: retries exhaust and hard-fail after max_attempts" {
defer h.deinit();
fastRetryHarness(&h);
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -3130,7 +3130,7 @@ test "runStep: Retry-After is honored and reported" {
// 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 ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -3165,7 +3165,7 @@ test "runStep: cancellation from a tool still hard-fails" {
try h.registry.register(try HardFailTool.create(allocator, "hard"));
h.activate();
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -3196,7 +3196,7 @@ test "runStep: source per-call error produces a per-call error result and contin
try h.registry.registerSource(try PartialSource.create(allocator, "ps", &.{ "pa", "pb" }));
h.activate();
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
@@ -3235,7 +3235,7 @@ test "runStep: context-overflow compaction fires a compaction retry notification
h.activate();
h.config.compaction = .{ .keep_verbatim = 10 };
var ns = null_store_mod.NullStore.init(allocator);
- var agent = Agent.init(allocator, io, &h.config, ns.store(), null);
+ var agent = Agent.init(allocator, io, &h.config, ns.store().create(), null);
defer agent.deinit();
h.seedInto(&agent);
agent.open_stream_fn = stub.install();
diff --git a/libpanto/src/config.zig b/libpanto/src/config.zig
index 8efa810..203a091 100644
--- a/libpanto/src/config.zig
+++ b/libpanto/src/config.zig
@@ -67,6 +67,37 @@ pub const ProviderConfig = union(APIStyle) {
pub fn style(self: ProviderConfig) APIStyle {
return @as(APIStyle, self);
}
+
+ /// The wire-format provider identity for this config: the ground-truth
+ /// `{api_style, base_url, model, reasoning}` that a turn is sent with.
+ /// Anthropic has no reasoning-effort knob, so it reports `.default`.
+ /// Borrowed slices; valid as long as the config is.
+ pub fn wireIdentity(self: ProviderConfig) WireIdentity {
+ return switch (self) {
+ .openai_chat => |c| .{
+ .api_style = .openai_chat,
+ .base_url = c.base_url,
+ .model = c.model,
+ .reasoning = c.reasoning,
+ },
+ .anthropic_messages => |c| .{
+ .api_style = .anthropic_messages,
+ .base_url = c.base_url,
+ .model = c.model,
+ .reasoning = .default,
+ },
+ };
+ }
+};
+
+/// Wire-format provider identity (see `ProviderConfig.wireIdentity`). This
+/// is the same shape as `session_store.WireIdentity`; defined here to avoid
+/// a module cycle (config must not import session_store).
+pub const WireIdentity = struct {
+ api_style: APIStyle,
+ base_url: []const u8,
+ model: []const u8,
+ reasoning: ReasoningEffort = .default,
};
/// Compaction settings the agent consults when summarizing old history.
diff --git a/libpanto/src/conversation.zig b/libpanto/src/conversation.zig
index f0c822d..1fc5016 100644
--- a/libpanto/src/conversation.zig
+++ b/libpanto/src/conversation.zig
@@ -209,6 +209,13 @@ pub const Message = struct {
/// null on user/system messages and when the provider emitted no usage.
/// Used by compaction to size the retention window.
usage: ?Usage = null,
+ /// Opaque per-message metadata bag. `libpanto` never interprets these
+ /// bytes; the documented contract is that, when present, they are valid
+ /// JSON (so a store may keep them as a JSON column and tools may
+ /// deserialize them). Round-trips through persistence: set before a turn
+ /// commits, read back off the `Message` after `load`. Borrowed; owned by
+ /// whoever set it (the conversation's allocator on the replay path).
+ metadata: ?[]const u8 = null,
pub fn deinit(self: *Message, alloc: Allocator) void {
for (self.content.items) |*block| {
diff --git a/libpanto/src/session_manager.zig b/libpanto/src/file_system_jsonl_store.zig
index b659506..10b65b3 100644
--- a/libpanto/src/session_manager.zig
+++ b/libpanto/src/file_system_jsonl_store.zig
@@ -36,10 +36,10 @@ const session_store_mod = @import("session_store.zig");
pub const SessionHeader = session_mod.SessionHeader;
pub const SessionEntry = session_mod.SessionEntry;
pub const MessageEntry = session_mod.MessageEntry;
-pub const DiskMessage = session_mod.DiskMessage;
-pub const DiskMessageRole = session_mod.DiskMessageRole;
-pub const DiskSystemMode = session_mod.DiskSystemMode;
-pub const DiskContentBlock = session_mod.DiskContentBlock;
+pub const StoredMessage = session_mod.StoredMessage;
+pub const StoredMessageRole = session_mod.StoredMessageRole;
+pub const StoredSystemMode = session_mod.StoredSystemMode;
+pub const StoredContentBlock = session_mod.StoredContentBlock;
pub const Usage = session_mod.Usage;
pub const CURRENT_VERSION = session_mod.CURRENT_VERSION;
@@ -129,33 +129,33 @@ pub fn isoTimestamp(allocator: Allocator, io: Io) ![]u8 {
}
// =============================================================================
-// SessionInfo (listing)
+// FileInfo (per-file listing scan result)
// =============================================================================
-pub const SessionInfo = struct {
+/// Internal scan result for one session file. Carries the file `path` (the
+/// catalog needs it to open/resolve) plus everything the public
+/// `session_store.SessionInfo` needs.
+const FileInfo = struct {
path: []u8,
id: []u8,
- cwd: []u8,
created: []u8, // ISO 8601 from header timestamp
- modified: []u8, // ISO 8601 from last user/assistant entry, falling back to header, then file mtime
+ modified: []u8, // ISO 8601 from last activity, falling back to header
message_count: usize,
+ last_user_message: []u8,
+ stamp: ?session_mod.WireStamp, // last-used wire identity
- pub fn deinit(self: SessionInfo, alloc: Allocator) void {
+ pub fn deinit(self: FileInfo, alloc: Allocator) void {
alloc.free(self.path);
alloc.free(self.id);
- alloc.free(self.cwd);
alloc.free(self.created);
alloc.free(self.modified);
+ alloc.free(self.last_user_message);
+ if (self.stamp) |s| s.deinit(alloc);
}
};
-pub fn freeSessionInfos(alloc: Allocator, infos: []SessionInfo) void {
- for (infos) |info| info.deinit(alloc);
- alloc.free(infos);
-}
-
// =============================================================================
-// SessionManager
+// SessionFile
// =============================================================================
pub const Error = error{
@@ -165,7 +165,7 @@ pub const Error = error{
InvalidSessionFile,
} || Allocator.Error || Io.Cancelable;
-pub const SessionManager = struct {
+pub const SessionFile = struct {
allocator: Allocator,
io: Io,
@@ -211,11 +211,26 @@ pub const SessionManager = struct {
io: Io,
session_dir: []const u8,
cwd: []const u8,
- ) !SessionManager {
+ ) !SessionFile {
+ const id = try newUuidV7(allocator, io);
+ defer allocator.free(id);
+ return initWithId(allocator, io, session_dir, cwd, id);
+ }
+
+ /// Like `init`, but uses a caller-supplied session id (duped here)
+ /// rather than minting a fresh UUIDv7. Used by the catalog when a
+ /// `Session` handle was minted (with its id) before the first append.
+ pub fn initWithId(
+ allocator: Allocator,
+ io: Io,
+ session_dir: []const u8,
+ cwd: []const u8,
+ session_id: []const u8,
+ ) !SessionFile {
const dir = try allocator.dupe(u8, session_dir);
errdefer allocator.free(dir);
- const id = try newUuidV7(allocator, io);
+ const id = try allocator.dupe(u8, session_id);
errdefer allocator.free(id);
const timestamp = try isoTimestamp(allocator, io);
@@ -255,7 +270,7 @@ pub const SessionManager = struct {
allocator: Allocator,
io: Io,
file_path: []const u8,
- ) !SessionManager {
+ ) !SessionFile {
const path_copy = try allocator.dupe(u8, file_path);
errdefer allocator.free(path_copy);
@@ -393,7 +408,7 @@ pub const SessionManager = struct {
};
}
- pub fn deinit(self: *SessionManager) void {
+ pub fn deinit(self: *SessionFile) void {
self.header.deinit(self.allocator);
for (self.entries.items) |e| e.deinit(self.allocator);
self.entries.deinit(self.allocator);
@@ -404,36 +419,36 @@ pub const SessionManager = struct {
// ---------- Accessors ----------
- pub fn getCwd(self: *const SessionManager) []const u8 {
+ pub fn getCwd(self: *const SessionFile) []const u8 {
return self.header.cwd;
}
- pub fn getSessionId(self: *const SessionManager) []const u8 {
+ pub fn getSessionId(self: *const SessionFile) []const u8 {
return self.header.id;
}
- pub fn getSessionFile(self: *const SessionManager) []const u8 {
+ pub fn getSessionFile(self: *const SessionFile) []const u8 {
return self.session_file;
}
- pub fn getSessionDir(self: *const SessionManager) []const u8 {
+ pub fn getSessionDir(self: *const SessionFile) []const u8 {
return self.session_dir;
}
- pub fn getLeafId(self: *const SessionManager) ?[]const u8 {
+ pub fn getLeafId(self: *const SessionFile) ?[]const u8 {
return self.leaf_id;
}
- pub fn getEntry(self: *const SessionManager, id: []const u8) ?*const SessionEntry {
+ pub fn getEntry(self: *const SessionFile, id: []const u8) ?*const SessionEntry {
const idx = self.by_id.get(id) orelse return null;
return &self.entries.items[idx];
}
- pub fn getEntries(self: *const SessionManager) []const SessionEntry {
+ pub fn getEntries(self: *const SessionFile) []const SessionEntry {
return self.entries.items;
}
- pub fn isFlushed(self: *const SessionManager) bool {
+ pub fn isFlushed(self: *const SessionFile) bool {
return self.flushed;
}
@@ -445,20 +460,14 @@ pub const SessionManager = struct {
/// yet, which is only reachable on a freshly-`init`'d session before
/// the first user prompt (and therefore before any disk flush).
///
- /// Returns borrowed slices owned by the manager; do not free.
- pub fn activeModel(self: *const SessionManager) ?struct { provider: []const u8, model: []const u8 } {
+ /// Returns a borrowed stamp owned by the manager; do not free.
+ pub fn activeStamp(self: *const SessionFile) ?session_mod.WireStamp {
var i = self.entries.items.len;
while (i > 0) : (i -= 1) {
const e = self.entries.items[i - 1];
switch (e) {
.message => |m| {
- if (m.message.role == .user) {
- if (m.provider) |p| {
- if (m.model) |mo| {
- return .{ .provider = p, .model = mo };
- }
- }
- }
+ if (m.stamp) |st| return st;
},
}
}
@@ -476,12 +485,11 @@ pub const SessionManager = struct {
/// all buffered entries + the new entry, then sets `flushed`.
/// Otherwise: buffers in memory only.
pub fn appendMessage(
- self: *SessionManager,
- msg: DiskMessage,
- // Top-level stamps on the entry (vs. inside the message).
- // Stamped only on user messages.
- provider: ?[]const u8,
- model: ?[]const u8,
+ self: *SessionFile,
+ msg: StoredMessage,
+ // Wire-format provider identity for the entry. Null on system
+ // messages. Borrowed; duplicated here into the entry.
+ stamp: ?session_mod.WireStamp,
) ![]const u8 {
// Build the entry up-front, taking ownership of the inputs.
var msg_local = msg;
@@ -496,16 +504,12 @@ pub const SessionManager = struct {
const parent_id_copy: ?[]const u8 = if (self.leaf_id) |l| try self.allocator.dupe(u8, l) else null;
errdefer if (parent_id_copy) |p| self.allocator.free(p);
- const provider_copy: ?[]const u8 = if (provider) |p| try self.allocator.dupe(u8, p) else null;
- errdefer if (provider_copy) |p| self.allocator.free(p);
-
- const model_copy: ?[]const u8 = if (model) |m| try self.allocator.dupe(u8, m) else null;
- errdefer if (model_copy) |m| self.allocator.free(m);
+ const stamp_copy: ?session_mod.WireStamp = if (stamp) |st| try st.dupe(self.allocator) else null;
+ errdefer if (stamp_copy) |st| st.deinit(self.allocator);
const entry: SessionEntry = .{ .message = .{
.base = .{ .id = id_buf, .parent_id = parent_id_copy, .timestamp = timestamp },
- .provider = provider_copy,
- .model = model_copy,
+ .stamp = stamp_copy,
.message = msg_local,
} };
@@ -544,7 +548,7 @@ pub const SessionManager = struct {
/// Returns a freshly allocated 8-character hex id, guaranteed not to
/// collide with any existing entry id in this session.
- fn newEntryId(self: *SessionManager) ![]u8 {
+ fn newEntryId(self: *SessionFile) ![]u8 {
const max_tries = 100;
var i: usize = 0;
while (i < max_tries) : (i += 1) {
@@ -571,7 +575,7 @@ pub const SessionManager = struct {
/// Write the header + all currently-buffered entries + `new_entries`
/// to the file as a single batch. Creates the directory and file.
- fn flushBufferedMany(self: *SessionManager, new_entries: []const SessionEntry) !void {
+ fn flushBufferedMany(self: *SessionFile, new_entries: []const SessionEntry) !void {
try mkdirP(self.io, self.session_dir);
const file = try Io.Dir.cwd().createFile(self.io, self.session_file, .{
@@ -611,24 +615,22 @@ pub const SessionManager = struct {
self.written_bytes = offset;
}
- fn flushBuffered(self: *SessionManager, final_entry: SessionEntry) !void {
+ fn flushBuffered(self: *SessionFile, final_entry: SessionEntry) !void {
try self.flushBufferedMany(&.{final_entry});
}
/// Append a single line for `entry` to the open session file. Caller
/// must have already verified `flushed`.
- fn persistEntry(self: *SessionManager, entry: SessionEntry) !void {
+ fn persistEntry(self: *SessionFile, entry: SessionEntry) !void {
try self.persistEntries(&.{entry});
}
pub fn appendMessagesAtomic(
- self: *SessionManager,
- messages: []DiskMessage,
- providers: []const ?[]const u8,
- models: []const ?[]const u8,
+ self: *SessionFile,
+ messages: []StoredMessage,
+ stamps: []const ?session_mod.WireStamp,
) !void {
- std.debug.assert(messages.len == providers.len);
- std.debug.assert(messages.len == models.len);
+ std.debug.assert(messages.len == stamps.len);
if (messages.len == 0) return;
const base_len = self.entries.items.len;
@@ -651,14 +653,11 @@ pub const SessionManager = struct {
errdefer self.allocator.free(timestamp);
const parent_id_copy: ?[]const u8 = if (prev_leaf) |l| try self.allocator.dupe(u8, l) else null;
errdefer if (parent_id_copy) |p| self.allocator.free(p);
- const provider_copy: ?[]const u8 = if (providers[i]) |p| try self.allocator.dupe(u8, p) else null;
- errdefer if (provider_copy) |p| self.allocator.free(p);
- const model_copy: ?[]const u8 = if (models[i]) |m| try self.allocator.dupe(u8, m) else null;
- errdefer if (model_copy) |m| self.allocator.free(m);
+ const stamp_copy: ?session_mod.WireStamp = if (stamps[i]) |st| try st.dupe(self.allocator) else null;
+ errdefer if (stamp_copy) |st| st.deinit(self.allocator);
entries[i] = .{ .message = .{
.base = .{ .id = id_buf, .parent_id = parent_id_copy, .timestamp = timestamp },
- .provider = provider_copy,
- .model = model_copy,
+ .stamp = stamp_copy,
.message = msg_local,
} };
built += 1;
@@ -679,7 +678,7 @@ pub const SessionManager = struct {
built = 0;
}
- fn persistEntries(self: *SessionManager, entries: []const SessionEntry) !void {
+ fn persistEntries(self: *SessionFile, entries: []const SessionEntry) !void {
const file = try Io.Dir.cwd().openFile(self.io, self.session_file, .{
.mode = .write_only,
});
@@ -704,7 +703,7 @@ pub const SessionManager = struct {
/// Build a fresh `Conversation` from the entry log. Caller owns the
/// returned conversation (call `deinit`).
- pub fn rebuildConversation(self: *const SessionManager) !conversation_mod.Conversation {
+ pub fn rebuildConversation(self: *const SessionFile) !conversation_mod.Conversation {
var conv = conversation_mod.Conversation.init(self.allocator);
errdefer conv.deinit();
@@ -716,101 +715,12 @@ pub const SessionManager = struct {
return conv;
}
- // =============================================================================
- // SessionStore interface
- // =============================================================================
-
- /// Reconstruct a linear `Conversation` plus an optional dangling
- /// trailing user prompt (a user entry with no following assistant —
- /// e.g. a crash/quit right after submission). The returned conversation
- /// **excludes** that dangling user turn; its text is returned as
- /// `dangling_user` (owned by `alloc`) so a resumed agent never
- /// auto-sends it.
- pub fn loadConversation(
- self: *const SessionManager,
- alloc: Allocator,
- ) !session_store_mod.LoadedSession {
- // Detect a dangling trailing user entry: the last message entry is
- // a user message and nothing follows it. (Entries are linear; the
- // last entry is the leaf.)
- const items = self.entries.items;
- var dangling: ?[]const u8 = null;
- var stop_at: usize = items.len;
- if (items.len > 0) {
- const last = items[items.len - 1];
- switch (last) {
- .message => |me| {
- if (me.message.role == .user) {
- dangling = try extractUserText(alloc, me.message);
- // Only treat it as dangling (and exclude it) when we
- // could actually recover prompt text; otherwise keep
- // it in the conversation rather than silently drop.
- if (dangling != null) stop_at = items.len - 1;
- }
- },
- }
- }
- errdefer if (dangling) |d| alloc.free(d);
-
- var conv = conversation_mod.Conversation.init(alloc);
- errdefer conv.deinit();
- for (items[0..stop_at]) |entry| {
- switch (entry) {
- .message => |me| try appendMessageToConv(&conv, alloc, me.message),
- }
- }
-
- return .{ .conversation = conv, .dangling_user = dangling };
- }
-
- fn appendMessagesVT(
- ctx: *anyopaque,
- messages: []session_store_mod.DiskMessage,
- providers: []const ?[]const u8,
- models: []const ?[]const u8,
- ) anyerror!void {
- const self: *SessionManager = @ptrCast(@alignCast(ctx));
- try self.appendMessagesAtomic(messages, providers, models);
- }
-
- fn loadConversationVT(
- ctx: *anyopaque,
- alloc: Allocator,
- ) anyerror!session_store_mod.LoadedSession {
- const self: *const SessionManager = @ptrCast(@alignCast(ctx));
- return self.loadConversation(alloc);
- }
-
- fn sessionIdVT(ctx: *anyopaque) []const u8 {
- const self: *const SessionManager = @ptrCast(@alignCast(ctx));
- return self.getSessionId();
- }
-
- fn activeModelVT(ctx: *anyopaque) ?session_store_mod.ActiveModel {
- const self: *const SessionManager = @ptrCast(@alignCast(ctx));
- const am = self.activeModel() orelse return null;
- return .{ .provider = am.provider, .model = am.model };
- }
-
- const store_vtable: session_store_mod.SessionStore.VTable = .{
- .appendMessages = appendMessagesVT,
- .loadConversation = loadConversationVT,
- .sessionId = sessionIdVT,
- .activeModel = activeModelVT,
- };
-
- /// Wrap this concrete manager as a neutral `SessionStore`. The returned
- /// store borrows `self`; `self` must outlive it.
- pub fn store(self: *SessionManager) session_store_mod.SessionStore {
- return .{ .ptr = self, .vtable = &store_vtable };
- }
};
-/// Best-effort extraction of plain prompt text from a user `DiskMessage`.
-/// Returns null if the message carries no plain text block (e.g. it is a
-/// tool-result-only user message, which is never a "dangling prompt").
-/// Caller owns the returned slice.
-fn extractUserText(alloc: Allocator, msg: DiskMessage) !?[]const u8 {
+/// Best-effort extraction of plain prompt text from a user `StoredMessage`.
+/// Used to populate `SessionInfo.last_user_message`. Returns null if the
+/// message carries no plain text block. Caller owns the returned slice.
+fn extractUserText(alloc: Allocator, msg: StoredMessage) !?[]u8 {
for (msg.content) |block| {
if (block == .text) {
return try alloc.dupe(u8, block.text.text);
@@ -822,7 +732,7 @@ fn extractUserText(alloc: Allocator, msg: DiskMessage) !?[]const u8 {
fn appendMessageToConv(
conv: *conversation_mod.Conversation,
allocator: Allocator,
- disk_msg: DiskMessage,
+ disk_msg: StoredMessage,
) !void {
var content: std.ArrayList(conversation_mod.ContentBlock) = .empty;
errdefer {
@@ -903,7 +813,7 @@ fn elideDanglingToolUses(allocator: Allocator, entries: *std.ArrayList(SessionEn
}
if (msg.role != .assistant) continue;
- var kept: std.ArrayList(DiskContentBlock) = .empty;
+ var kept: std.ArrayList(StoredContentBlock) = .empty;
defer kept.deinit(allocator);
var removed = false;
for (msg.content) |block| {
@@ -1022,9 +932,9 @@ pub fn listSessions(
io: Io,
session_dir: []const u8,
on_progress: ?*const fn (loaded: usize, total: usize) void,
-) ![]SessionInfo {
+) ![]FileInfo {
var dir = Io.Dir.cwd().openDir(io, session_dir, .{}) catch |err| switch (err) {
- error.FileNotFound => return try allocator.alloc(SessionInfo, 0),
+ error.FileNotFound => return try allocator.alloc(FileInfo, 0),
else => return err,
};
defer dir.close(io);
@@ -1044,7 +954,7 @@ pub fn listSessions(
try names.append(allocator, copy);
}
- var infos: std.ArrayList(SessionInfo) = .empty;
+ var infos: std.ArrayList(FileInfo) = .empty;
errdefer {
for (infos.items) |i| i.deinit(allocator);
infos.deinit(allocator);
@@ -1055,7 +965,7 @@ pub fn listSessions(
for (names.items) |name| {
const full = try std.fs.path.join(allocator, &.{ session_dir, name });
defer allocator.free(full);
- const info_opt = buildSessionInfo(allocator, io, full) catch null;
+ const info_opt = buildFileInfo(allocator, io, full) catch null;
if (info_opt) |info| {
infos.appendAssumeCapacity(info);
}
@@ -1064,19 +974,19 @@ pub fn listSessions(
}
const slice = try infos.toOwnedSlice(allocator);
- std.sort.pdq(SessionInfo, slice, {}, sessionInfoNewerFirst);
+ std.sort.pdq(FileInfo, slice, {}, fileInfoNewerFirst);
return slice;
}
-fn sessionInfoNewerFirst(_: void, a: SessionInfo, b: SessionInfo) bool {
+fn fileInfoNewerFirst(_: void, a: FileInfo, b: FileInfo) bool {
return std.mem.order(u8, a.modified, b.modified) == .gt;
}
-fn buildSessionInfo(
+fn buildFileInfo(
allocator: Allocator,
io: Io,
file_path: []const u8,
-) !?SessionInfo {
+) !?FileInfo {
const bytes = readWholeFile(allocator, io, file_path) catch return null;
defer allocator.free(bytes);
@@ -1086,6 +996,10 @@ fn buildSessionInfo(
var message_count: usize = 0;
var last_activity: ?[]u8 = null;
defer if (last_activity) |la| allocator.free(la);
+ var last_user: ?[]u8 = null;
+ defer if (last_user) |lu| allocator.free(lu);
+ var last_stamp: ?session_mod.WireStamp = null;
+ defer if (last_stamp) |st| st.deinit(allocator);
var lines = std.mem.splitScalar(u8, bytes, '\n');
while (lines.next()) |line| {
@@ -1109,6 +1023,16 @@ fn buildSessionInfo(
if (last_activity) |la| allocator.free(la);
last_activity = try allocator.dupe(u8, m.base.timestamp);
}
+ if (m.stamp) |st| {
+ if (last_stamp) |old| old.deinit(allocator);
+ last_stamp = try st.dupe(allocator);
+ }
+ if (m.message.role == .user) {
+ if (try extractUserText(allocator, m.message)) |ut| {
+ if (last_user) |lu| allocator.free(lu);
+ last_user = ut;
+ }
+ }
},
}
},
@@ -1116,29 +1040,36 @@ fn buildSessionInfo(
}
const header = header_opt orelse return null;
- // We will keep header alive through the deinit defer, and dupe its
- // fields into the result. Cheap, avoids any ownership shuffling.
const path = try allocator.dupe(u8, file_path);
errdefer allocator.free(path);
const id = try allocator.dupe(u8, header.id);
errdefer allocator.free(id);
- const cwd = try allocator.dupe(u8, header.cwd);
- errdefer allocator.free(cwd);
const created = try allocator.dupe(u8, header.timestamp);
errdefer allocator.free(created);
const modified = if (last_activity) |la| blk: {
last_activity = null;
break :blk la;
} else try allocator.dupe(u8, header.timestamp);
+ errdefer allocator.free(modified);
+ const last_user_message = if (last_user) |lu| blk: {
+ last_user = null;
+ break :blk lu;
+ } else try allocator.dupe(u8, "");
+ errdefer allocator.free(last_user_message);
+ const stamp_out = if (last_stamp) |st| blk: {
+ last_stamp = null;
+ break :blk st;
+ } else null;
return .{
.path = path,
.id = id,
- .cwd = cwd,
.created = created,
.modified = modified,
.message_count = message_count,
+ .last_user_message = last_user_message,
+ .stamp = stamp_out,
};
}
@@ -1214,11 +1145,292 @@ pub fn resolveSessionId(
}
// =============================================================================
+// FileSystemJSONLStore — the directory-backed catalog (SessionStore impl)
+// =============================================================================
+
+/// A directory-backed `SessionStore`: each session is one `<id>.jsonl` file
+/// under `dir`. The catalog mints `Session` handles, lists/resolves files,
+/// loads conversations, and routes appends to the right `SessionFile`.
+///
+/// Open `SessionFile`s are cached by id for the catalog's lifetime so the
+/// buffered-until-first-assistant write discipline survives across the
+/// separate user-prompt and assistant-turn appends of a single turn.
+///
+/// `dir` is the already-resolved sessions directory (the panto CLI derives
+/// the per-cwd grouping; the store itself is cwd-agnostic). `cwd` is stamped
+/// into new session headers for display/provenance only.
+pub const FileSystemJSONLStore = struct {
+ allocator: Allocator,
+ io: Io,
+ dir: []u8, // owned: the sessions directory
+ cwd: []u8, // owned: recorded in new session headers
+ open: std.StringHashMap(*SessionFile),
+
+ pub fn init(allocator: Allocator, io: Io, dir: []const u8, cwd: []const u8) !FileSystemJSONLStore {
+ const dir_copy = try allocator.dupe(u8, dir);
+ errdefer allocator.free(dir_copy);
+ const cwd_copy = try allocator.dupe(u8, cwd);
+ return .{
+ .allocator = allocator,
+ .io = io,
+ .dir = dir_copy,
+ .cwd = cwd_copy,
+ .open = std.StringHashMap(*SessionFile).init(allocator),
+ };
+ }
+
+ pub fn deinit(self: *FileSystemJSONLStore) void {
+ var it = self.open.iterator();
+ while (it.next()) |e| {
+ e.value_ptr.*.deinit();
+ self.allocator.destroy(e.value_ptr.*);
+ self.allocator.free(e.key_ptr.*);
+ }
+ self.open.deinit();
+ self.allocator.free(self.dir);
+ self.allocator.free(self.cwd);
+ }
+
+ /// Borrow (opening if needed) the `SessionFile` for `id`, caching it.
+ /// Returns null if the file does not exist on disk and `create_missing`
+ /// is false.
+ fn fileFor(self: *FileSystemJSONLStore, id: []const u8, create_missing: bool) !?*SessionFile {
+ if (self.open.get(id)) |sf| return sf;
+ // Locate the file by exact id.
+ const path = try std.fs.path.join(self.allocator, &.{ self.dir, id });
+ defer self.allocator.free(path);
+ const full = try std.fmt.allocPrint(self.allocator, "{s}.jsonl", .{path});
+ defer self.allocator.free(full);
+
+ const exists = blk: {
+ Io.Dir.cwd().access(self.io, full, .{}) catch break :blk false;
+ break :blk true;
+ };
+ if (!exists and !create_missing) return null;
+
+ const sf = try self.allocator.create(SessionFile);
+ errdefer self.allocator.destroy(sf);
+ sf.* = if (exists)
+ try SessionFile.open(self.allocator, self.io, full)
+ else
+ try SessionFile.initWithId(self.allocator, self.io, self.dir, self.cwd, id);
+ errdefer sf.deinit();
+
+ const key = try self.allocator.dupe(u8, id);
+ errdefer self.allocator.free(key);
+ try self.open.put(key, sf);
+ return sf;
+ }
+
+ fn infoFromFileInfo(self: *FileSystemJSONLStore, fi: FileInfo) !session_store_mod.SessionInfo {
+ const id = try self.allocator.dupe(u8, fi.id);
+ errdefer self.allocator.free(id);
+ const created = try self.allocator.dupe(u8, fi.created);
+ errdefer self.allocator.free(created);
+ const modified = try self.allocator.dupe(u8, fi.modified);
+ errdefer self.allocator.free(modified);
+ const last_user = try self.allocator.dupe(u8, fi.last_user_message);
+ errdefer self.allocator.free(last_user);
+ const base_url = try self.allocator.dupe(u8, if (fi.stamp) |s| s.base_url else "");
+ errdefer self.allocator.free(base_url);
+ const model = try self.allocator.dupe(u8, if (fi.stamp) |s| s.model else "");
+ return .{
+ .id = id,
+ .created = created,
+ .modified = modified,
+ .message_count = fi.message_count,
+ .last_user_message = last_user,
+ .api_style = if (fi.stamp) |s| s.api_style else .openai_chat,
+ .base_url = base_url,
+ .model = model,
+ .reasoning = if (fi.stamp) |s| s.reasoning else .default,
+ };
+ }
+
+ // ---------- vtable ----------
+
+ fn createVT(ctx: *anyopaque) session_store_mod.Session {
+ const self: *FileSystemJSONLStore = @ptrCast(@alignCast(ctx));
+ // Mint a fresh id; nothing hits disk until the first append.
+ const id = newUuidV7(self.allocator, self.io) catch "";
+ // The SessionFile is created lazily on first append via fileFor.
+ const info: session_store_mod.SessionInfo = .{
+ .id = id,
+ .created = self.allocator.dupe(u8, "") catch "",
+ .modified = self.allocator.dupe(u8, "") catch "",
+ .message_count = 0,
+ .last_user_message = self.allocator.dupe(u8, "") catch "",
+ .api_style = .openai_chat,
+ .base_url = self.allocator.dupe(u8, "") catch "",
+ .model = self.allocator.dupe(u8, "") catch "",
+ .reasoning = .default,
+ };
+ return .{ .info = info, .store = self.store() };
+ }
+
+ fn listVT(ctx: *anyopaque) anyerror![]session_store_mod.SessionInfo {
+ const self: *FileSystemJSONLStore = @ptrCast(@alignCast(ctx));
+ const fis = try listSessions(self.allocator, self.io, self.dir, null);
+ defer {
+ for (fis) |fi| fi.deinit(self.allocator);
+ self.allocator.free(fis);
+ }
+ var out = try self.allocator.alloc(session_store_mod.SessionInfo, fis.len);
+ var built: usize = 0;
+ errdefer {
+ for (out[0..built]) |i| i.deinit(self.allocator);
+ self.allocator.free(out);
+ }
+ for (fis, 0..) |fi, i| {
+ out[i] = try self.infoFromFileInfo(fi);
+ built += 1;
+ }
+ return out;
+ }
+
+ fn freeSessionInfosVT(ctx: *anyopaque, infos: []session_store_mod.SessionInfo) void {
+ const self: *FileSystemJSONLStore = @ptrCast(@alignCast(ctx));
+ for (infos) |i| i.deinit(self.allocator);
+ self.allocator.free(infos);
+ }
+
+ fn resolveVT(ctx: *anyopaque, id_or_prefix: []const u8) anyerror!?session_store_mod.Session {
+ const self: *FileSystemJSONLStore = @ptrCast(@alignCast(ctx));
+ const path = resolveSessionId(self.allocator, self.io, self.dir, id_or_prefix) catch |err| switch (err) {
+ error.SessionNotFound => return null,
+ else => return err,
+ };
+ defer self.allocator.free(path);
+ return try self.sessionFromPath(path);
+ }
+
+ fn latestVT(ctx: *anyopaque) anyerror!?session_store_mod.Session {
+ const self: *FileSystemJSONLStore = @ptrCast(@alignCast(ctx));
+ const path = (try findMostRecentSession(self.allocator, self.io, self.dir)) orelse return null;
+ defer self.allocator.free(path);
+ return try self.sessionFromPath(path);
+ }
+
+ fn sessionFromPath(self: *FileSystemJSONLStore, path: []const u8) !?session_store_mod.Session {
+ const fi = (try buildFileInfo(self.allocator, self.io, path)) orelse return null;
+ defer fi.deinit(self.allocator);
+ const info = try self.infoFromFileInfo(fi);
+ return .{ .info = info, .store = self.store() };
+ }
+
+ fn loadVT(ctx: *anyopaque, id: []const u8) anyerror!?conversation_mod.Conversation {
+ const self: *FileSystemJSONLStore = @ptrCast(@alignCast(ctx));
+ const sf = (try self.fileFor(id, false)) orelse return null;
+ return try sf.rebuildConversation();
+ }
+
+ fn appendMessagesVT(
+ ctx: *anyopaque,
+ session_id: []const u8,
+ messages: []session_store_mod.PersistentMessage,
+ ) anyerror!void {
+ const self: *FileSystemJSONLStore = @ptrCast(@alignCast(ctx));
+ if (messages.len == 0) return;
+ const sf = (try self.fileFor(session_id, true)).?;
+
+ // Convert each rich PersistentMessage to a StoredMessage + wire stamp.
+ // The FS store deliberately ignores the `conversation` and
+ // `tools_available` provenance fields.
+ var stored = try self.allocator.alloc(StoredMessage, messages.len);
+ var stamps = try self.allocator.alloc(?session_mod.WireStamp, messages.len);
+ defer self.allocator.free(stored);
+ defer self.allocator.free(stamps);
+ var built: usize = 0;
+ errdefer for (stored[0..built]) |sm| sm.deinit(self.allocator);
+
+ for (messages, 0..) |pm, i| {
+ stored[i] = try persistentToStored(self.allocator, pm);
+ // System entries carry no wire stamp; user/assistant do.
+ stamps[i] = if (pm.message.role == .system) null else .{
+ .api_style = pm.identity.api_style,
+ .base_url = pm.identity.base_url,
+ .model = pm.identity.model,
+ .reasoning = pm.identity.reasoning,
+ };
+ built += 1;
+ }
+
+ // appendMessagesAtomic consumes the StoredMessages; it dupes stamps.
+ try sf.appendMessagesAtomic(stored, stamps);
+ }
+
+ const store_vtable: session_store_mod.SessionStore.VTable = .{
+ .create = createVT,
+ .list = listVT,
+ .freeSessionInfos = freeSessionInfosVT,
+ .resolve = resolveVT,
+ .latest = latestVT,
+ .load = loadVT,
+ .appendMessages = appendMessagesVT,
+ };
+
+ /// Wrap this catalog as a neutral `SessionStore`. The handle borrows
+ /// `self`; `self` must outlive it.
+ pub fn store(self: *FileSystemJSONLStore) session_store_mod.SessionStore {
+ return .{ .ptr = self, .vtable = &store_vtable };
+ }
+};
+
+/// Convert a rich in-memory `PersistentMessage` to the on-disk
+/// `StoredMessage`. Strings are duplicated; the source is untouched.
+fn persistentToStored(
+ alloc: Allocator,
+ pm: session_store_mod.PersistentMessage,
+) !StoredMessage {
+ const msg = pm.message;
+ const blocks = try alloc.alloc(session_mod.StoredContentBlock, 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_mod.contentBlockToDisk(alloc, block);
+ allocated += 1;
+ }
+ const mode: session_mod.StoredSystemMode = blk: {
+ for (msg.content.items) |block| {
+ if (block == .System and block.System.mode == .replace) break :blk .replace;
+ }
+ break :blk .append;
+ };
+ const stop_reason: ?[]const u8 = if (msg.role == .assistant) try alloc.dupe(u8, "stop") else null;
+ errdefer if (stop_reason) |s| alloc.free(s);
+ const metadata: ?[]const u8 = if (msg.metadata) |m| try alloc.dupe(u8, m) else null;
+ const role: session_mod.StoredMessageRole = switch (msg.role) {
+ .system => .system,
+ .user => .user,
+ .assistant => .assistant,
+ };
+ return .{
+ .role = role,
+ .content = blocks,
+ .mode = mode,
+ .stop_reason = stop_reason,
+ .usage = pm.usage,
+ .metadata = metadata,
+ };
+}
+
+// =============================================================================
// Tests
// =============================================================================
const testing = std.testing;
+/// Borrowed wire stamps for tests (no allocation; the manager dupes them).
+fn oaStamp() session_mod.WireStamp {
+ return .{ .api_style = .openai_chat, .base_url = "https://api.openai.com/v1", .model = "gpt-4o" };
+}
+fn anStamp() session_mod.WireStamp {
+ return .{ .api_style = .anthropic_messages, .base_url = "https://api.anthropic.com", .model = "claude-sonnet-4-20250514" };
+}
+
test "newUuidV7: produces 36-char hyphenated string with version 7" {
const io = testing.io;
const id = try newUuidV7(testing.allocator, io);
@@ -1264,7 +1476,7 @@ const TmpSessionDir = struct {
}
};
-test "SessionManager.init: does not create file yet" {
+test "SessionFile.init: does not create file yet" {
const io = testing.io;
var td = try TmpSessionDir.init(testing.allocator);
@@ -1275,7 +1487,7 @@ test "SessionManager.init: does not create file yet" {
const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
defer testing.allocator.free(sessions);
- var mgr = try SessionManager.init(
+ var mgr = try SessionFile.init(
testing.allocator,
io,
sessions,
@@ -1290,7 +1502,7 @@ test "SessionManager.init: does not create file yet" {
try testing.expectError(error.FileNotFound, stat_err);
}
-test "SessionManager: full flow — buffer, flush on assistant, append, resume" {
+test "SessionFile: full flow — buffer, flush on assistant, append, resume" {
const io = testing.io;
var td = try TmpSessionDir.init(testing.allocator);
@@ -1300,7 +1512,7 @@ test "SessionManager: full flow — buffer, flush on assistant, append, resume"
defer testing.allocator.free(sessions);
const session_file: []u8 = blk: {
- var mgr = try SessionManager.init(
+ var mgr = try SessionFile.init(
testing.allocator,
io,
sessions,
@@ -1309,52 +1521,46 @@ test "SessionManager: full flow — buffer, flush on assistant, append, resume"
defer mgr.deinit();
// System message — non-assistant, should NOT trigger flush.
- const sys_blocks = try testing.allocator.alloc(DiskContentBlock, 1);
+ const sys_blocks = try testing.allocator.alloc(StoredContentBlock, 1);
sys_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "You are helpful.") } };
_ = try mgr.appendMessage(
.{ .role = .system, .content = sys_blocks },
null,
- null,
);
try testing.expect(!mgr.isFlushed());
// User message — also doesn't flush.
- const usr_blocks = try testing.allocator.alloc(DiskContentBlock, 1);
+ const usr_blocks = try testing.allocator.alloc(StoredContentBlock, 1);
usr_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi there") } };
_ = try mgr.appendMessage(
.{ .role = .user, .content = usr_blocks },
- "openai",
- "gpt-4o",
+ oaStamp(),
);
try testing.expect(!mgr.isFlushed());
// Assistant message — triggers flush.
- const a_blocks = try testing.allocator.alloc(DiskContentBlock, 1);
+ const a_blocks = try testing.allocator.alloc(StoredContentBlock, 1);
a_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hello") } };
_ = try mgr.appendMessage(
.{
.role = .assistant,
.content = a_blocks,
- .provider = try testing.allocator.dupe(u8, "openai"),
- .model = try testing.allocator.dupe(u8, "gpt-4o"),
.stop_reason = try testing.allocator.dupe(u8, "stop"),
},
- null,
- null,
+ oaStamp(),
);
try testing.expect(mgr.isFlushed());
// Append another user/assistant round.
- const u_two = try testing.allocator.alloc(DiskContentBlock, 1);
+ const u_two = try testing.allocator.alloc(StoredContentBlock, 1);
u_two[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "what's 2+2?") } };
- _ = try mgr.appendMessage(.{ .role = .user, .content = u_two }, "openai", "gpt-4o");
+ _ = try mgr.appendMessage(.{ .role = .user, .content = u_two }, oaStamp());
- const a2 = try testing.allocator.alloc(DiskContentBlock, 1);
+ const a2 = try testing.allocator.alloc(StoredContentBlock, 1);
a2[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "4") } };
_ = try mgr.appendMessage(
.{ .role = .assistant, .content = a2, .stop_reason = try testing.allocator.dupe(u8, "stop") },
- null,
- null,
+ oaStamp(),
);
try testing.expectEqual(@as(usize, 5), mgr.getEntries().len);
@@ -1376,20 +1582,20 @@ test "SessionManager: full flow — buffer, flush on assistant, append, resume"
}
// Resume.
- var resumed = try SessionManager.open(testing.allocator, io, session_file);
+ var resumed = try SessionFile.open(testing.allocator, io, session_file);
defer resumed.deinit();
try testing.expect(resumed.isFlushed());
try testing.expectEqual(@as(usize, 5), resumed.getEntries().len);
try testing.expectEqualStrings("/proj/foo", resumed.getCwd());
// Continue the conversation.
- const u_three = try testing.allocator.alloc(DiskContentBlock, 1);
+ const u_three = try testing.allocator.alloc(StoredContentBlock, 1);
u_three[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "thanks") } };
- _ = try resumed.appendMessage(.{ .role = .user, .content = u_three }, "openai", "gpt-4o");
+ _ = try resumed.appendMessage(.{ .role = .user, .content = u_three }, oaStamp());
try testing.expectEqual(@as(usize, 6), resumed.getEntries().len);
}
-test "SessionManager: assistant message tags the message metadata and the entry leaf id is the assistant entry" {
+test "SessionFile: assistant message tags the message metadata and the entry leaf id is the assistant entry" {
const io = testing.io;
var td = try TmpSessionDir.init(testing.allocator);
@@ -1397,16 +1603,16 @@ test "SessionManager: assistant message tags the message metadata and the entry
const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
defer testing.allocator.free(sessions);
- var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
defer mgr.deinit();
- const u_blocks = try testing.allocator.alloc(DiskContentBlock, 1);
+ const u_blocks = try testing.allocator.alloc(StoredContentBlock, 1);
u_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "ping") } };
- const user_id = try mgr.appendMessage(.{ .role = .user, .content = u_blocks }, "openai", "gpt-4o");
+ const user_id = try mgr.appendMessage(.{ .role = .user, .content = u_blocks }, oaStamp());
- const a_blocks = try testing.allocator.alloc(DiskContentBlock, 1);
+ const a_blocks = try testing.allocator.alloc(StoredContentBlock, 1);
a_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "pong") } };
- const asst_id = try mgr.appendMessage(.{ .role = .assistant, .content = a_blocks }, null, null);
+ const asst_id = try mgr.appendMessage(.{ .role = .assistant, .content = a_blocks }, null);
// Leaf is the assistant entry.
try testing.expectEqualStrings(asst_id, mgr.getLeafId().?);
@@ -1418,7 +1624,7 @@ test "SessionManager: assistant message tags the message metadata and the entry
try testing.expect(user_entry.base().parent_id == null);
}
-test "SessionManager: activeModel is null before any user message, then tracks the latest user stamp" {
+test "SessionFile: activeStamp is null before any user message, then tracks the latest user stamp" {
const io = testing.io;
var td = try TmpSessionDir.init(testing.allocator);
@@ -1426,25 +1632,25 @@ test "SessionManager: activeModel is null before any user message, then tracks t
const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
defer testing.allocator.free(sessions);
- var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
defer mgr.deinit();
// No user messages yet — there is no "active" model on disk yet.
- try testing.expect(mgr.activeModel() == null);
+ try testing.expect(mgr.activeStamp() == null);
// Stamp a user message with anthropic.
- const u_blocks = try testing.allocator.alloc(DiskContentBlock, 1);
+ const u_blocks = try testing.allocator.alloc(StoredContentBlock, 1);
u_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi") } };
- _ = try mgr.appendMessage(.{ .role = .user, .content = u_blocks }, "anthropic", "claude-sonnet-4-20250514");
+ _ = try mgr.appendMessage(.{ .role = .user, .content = u_blocks }, anStamp());
{
- const am = mgr.activeModel().?;
- try testing.expectEqualStrings("anthropic", am.provider);
+ const am = mgr.activeStamp().?;
+ try testing.expectEqual(session_mod.APIStyle.anthropic_messages, am.api_style);
try testing.expectEqualStrings("claude-sonnet-4-20250514", am.model);
}
}
-test "SessionManager: rebuildConversation reconstructs system/user/assistant turn" {
+test "SessionFile: rebuildConversation reconstructs system/user/assistant turn" {
const io = testing.io;
var td = try TmpSessionDir.init(testing.allocator);
@@ -1452,20 +1658,20 @@ test "SessionManager: rebuildConversation reconstructs system/user/assistant tur
const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
defer testing.allocator.free(sessions);
- var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
defer mgr.deinit();
- const sys = try testing.allocator.alloc(DiskContentBlock, 1);
+ const sys = try testing.allocator.alloc(StoredContentBlock, 1);
sys[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "you are helpful") } };
- _ = try mgr.appendMessage(.{ .role = .system, .content = sys }, null, null);
+ _ = try mgr.appendMessage(.{ .role = .system, .content = sys }, null);
- const u = try testing.allocator.alloc(DiskContentBlock, 1);
+ const u = try testing.allocator.alloc(StoredContentBlock, 1);
u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hello") } };
- _ = try mgr.appendMessage(.{ .role = .user, .content = u }, "openai", "gpt-4o");
+ _ = try mgr.appendMessage(.{ .role = .user, .content = u }, oaStamp());
- const a = try testing.allocator.alloc(DiskContentBlock, 1);
+ const a = try testing.allocator.alloc(StoredContentBlock, 1);
a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi!") } };
- _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null, null);
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null);
var conv = try mgr.rebuildConversation();
defer conv.deinit();
@@ -1478,7 +1684,7 @@ test "SessionManager: rebuildConversation reconstructs system/user/assistant tur
try testing.expectEqualStrings("hi!", conv.messages.items[2].content.items[0].Text.items);
}
-test "SessionManager: crash recovery truncates corrupted trailing line" {
+test "SessionFile: crash recovery truncates corrupted trailing line" {
const io = testing.io;
var td = try TmpSessionDir.init(testing.allocator);
@@ -1488,14 +1694,14 @@ test "SessionManager: crash recovery truncates corrupted trailing line" {
// Build a valid session first.
const session_file: []u8 = blk: {
- var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
defer mgr.deinit();
- const u = try testing.allocator.alloc(DiskContentBlock, 1);
+ const u = try testing.allocator.alloc(StoredContentBlock, 1);
u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "ping") } };
- _ = try mgr.appendMessage(.{ .role = .user, .content = u }, "openai", "gpt-4o");
- const a = try testing.allocator.alloc(DiskContentBlock, 1);
+ _ = try mgr.appendMessage(.{ .role = .user, .content = u }, oaStamp());
+ const a = try testing.allocator.alloc(StoredContentBlock, 1);
a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "pong") } };
- _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null, null);
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null);
break :blk try testing.allocator.dupe(u8, mgr.getSessionFile());
};
defer testing.allocator.free(session_file);
@@ -1517,7 +1723,7 @@ test "SessionManager: crash recovery truncates corrupted trailing line" {
}
// Now resume — the partial line should be truncated.
- var resumed = try SessionManager.open(testing.allocator, io, session_file);
+ var resumed = try SessionFile.open(testing.allocator, io, session_file);
defer resumed.deinit();
try testing.expectEqual(@as(usize, 2), resumed.getEntries().len);
@@ -1541,21 +1747,24 @@ test "listSessions: returns most recent first, with counts" {
// Create two sessions.
for (0..2) |i| {
- var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
defer mgr.deinit();
- const u = try testing.allocator.alloc(DiskContentBlock, 1);
+ const u = try testing.allocator.alloc(StoredContentBlock, 1);
u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u") } };
- _ = try mgr.appendMessage(.{ .role = .user, .content = u }, "openai", "gpt-4o");
- const a = try testing.allocator.alloc(DiskContentBlock, 1);
+ _ = try mgr.appendMessage(.{ .role = .user, .content = u }, oaStamp());
+ const a = try testing.allocator.alloc(StoredContentBlock, 1);
a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a") } };
- _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null, null);
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null);
// Small sleep so UUIDv7 timestamps differ.
io.sleep(.fromMilliseconds(2), .real) catch {};
_ = i;
}
const infos = try listSessions(testing.allocator, io, sessions, null);
- defer freeSessionInfos(testing.allocator, infos);
+ defer {
+ for (infos) |fi| fi.deinit(testing.allocator);
+ testing.allocator.free(infos);
+ }
try testing.expectEqual(@as(usize, 2), infos.len);
try testing.expectEqual(@as(usize, 2), infos[0].message_count);
try testing.expect(std.mem.order(u8, infos[0].modified, infos[1].modified) != .lt);
@@ -1577,14 +1786,14 @@ test "findMostRecentSession: picks lexicographically greatest" {
defer if (second_file) |s| testing.allocator.free(s);
for (0..2) |i| {
- var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
defer mgr.deinit();
- const u = try testing.allocator.alloc(DiskContentBlock, 1);
+ const u = try testing.allocator.alloc(StoredContentBlock, 1);
u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u") } };
- _ = try mgr.appendMessage(.{ .role = .user, .content = u }, "openai", "gpt-4o");
- const a = try testing.allocator.alloc(DiskContentBlock, 1);
+ _ = try mgr.appendMessage(.{ .role = .user, .content = u }, oaStamp());
+ const a = try testing.allocator.alloc(StoredContentBlock, 1);
a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a") } };
- _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null, null);
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null);
if (i == 1) second_file = try testing.allocator.dupe(u8, mgr.getSessionFile());
io.sleep(.fromMilliseconds(2), .real) catch {};
}
@@ -1594,7 +1803,7 @@ test "findMostRecentSession: picks lexicographically greatest" {
try testing.expectEqualStrings(second_file.?, found);
}
-test "SessionManager: tool-use round-trip — assistant w/ ToolUse, user w/ ToolResult, assistant" {
+test "SessionFile: tool-use round-trip — assistant w/ ToolUse, user w/ ToolResult, assistant" {
const io = testing.io;
var td = try TmpSessionDir.init(testing.allocator);
defer td.deinit(testing.allocator);
@@ -1602,58 +1811,58 @@ test "SessionManager: tool-use round-trip — assistant w/ ToolUse, user w/ Tool
defer testing.allocator.free(sessions);
const session_file: []u8 = blk: {
- var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
defer mgr.deinit();
- const u = try testing.allocator.alloc(DiskContentBlock, 1);
+ const u = try testing.allocator.alloc(StoredContentBlock, 1);
u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "list files") } };
- _ = try mgr.appendMessage(.{ .role = .user, .content = u }, "openai", "gpt-4o");
+ _ = try mgr.appendMessage(.{ .role = .user, .content = u }, oaStamp());
// Assistant emits a ToolUse.
- const am1 = try testing.allocator.alloc(DiskContentBlock, 2);
+ const am1 = try testing.allocator.alloc(StoredContentBlock, 2);
am1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "checking...") } };
am1[1] = .{ .tool_use = .{
.id = try testing.allocator.dupe(u8, "tool_abc"),
.name = try testing.allocator.dupe(u8, "bash"),
.input = try testing.allocator.dupe(u8, "{\"command\":\"ls\"}"),
} };
- _ = try mgr.appendMessage(.{ .role = .assistant, .content = am1 }, null, null);
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = am1 }, null);
// Tool-result user message.
- const tr = try testing.allocator.alloc(DiskContentBlock, 1);
- const trp = try testing.allocator.alloc(session_mod.DiskResultPart, 1);
+ const tr = try testing.allocator.alloc(StoredContentBlock, 1);
+ const trp = try testing.allocator.alloc(session_mod.StoredResultPart, 1);
trp[0] = .{ .text = try testing.allocator.dupe(u8, "a.txt\nb.txt") };
tr[0] = .{ .tool_result = .{
.tool_use_id = try testing.allocator.dupe(u8, "tool_abc"),
.parts = trp,
} };
- _ = try mgr.appendMessage(.{ .role = .user, .content = tr }, "openai", "gpt-4o");
+ _ = try mgr.appendMessage(.{ .role = .user, .content = tr }, oaStamp());
// Final assistant reply.
- const a2 = try testing.allocator.alloc(DiskContentBlock, 1);
+ const a2 = try testing.allocator.alloc(StoredContentBlock, 1);
a2[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "two files: a.txt and b.txt") } };
- _ = try mgr.appendMessage(.{ .role = .assistant, .content = a2 }, null, null);
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = a2 }, null);
break :blk try testing.allocator.dupe(u8, mgr.getSessionFile());
};
defer testing.allocator.free(session_file);
// Reopen and verify content blocks survive.
- var resumed = try SessionManager.open(testing.allocator, io, session_file);
+ var resumed = try SessionFile.open(testing.allocator, io, session_file);
defer resumed.deinit();
const entries = resumed.getEntries();
try testing.expectEqual(@as(usize, 4), entries.len);
// [1] = assistant with ToolUse
- try testing.expectEqual(DiskMessageRole.assistant, entries[1].message.message.role);
+ try testing.expectEqual(StoredMessageRole.assistant, entries[1].message.message.role);
try testing.expectEqual(@as(usize, 2), entries[1].message.message.content.len);
try testing.expect(entries[1].message.message.content[1] == .tool_use);
try testing.expectEqualStrings("bash", entries[1].message.message.content[1].tool_use.name);
try testing.expectEqualStrings("{\"command\":\"ls\"}", entries[1].message.message.content[1].tool_use.input);
- // [2] = user with ToolResult, stamped with provider/model.
- try testing.expectEqual(DiskMessageRole.user, entries[2].message.message.role);
- try testing.expectEqualStrings("openai", entries[2].message.provider.?);
+ // [2] = user with ToolResult, stamped with wire identity.
+ try testing.expectEqual(StoredMessageRole.user, entries[2].message.message.role);
+ try testing.expectEqual(session_mod.APIStyle.openai_chat, entries[2].message.stamp.?.api_style);
try testing.expect(entries[2].message.message.content[0] == .tool_result);
try testing.expectEqualStrings("tool_abc", entries[2].message.message.content[0].tool_result.tool_use_id);
try testing.expectEqualStrings("a.txt\nb.txt", entries[2].message.message.content[0].tool_result.parts[0].text);
@@ -1666,36 +1875,36 @@ test "SessionManager: tool-use round-trip — assistant w/ ToolUse, user w/ Tool
try testing.expect(conv.messages.items[2].content.items[0] == .ToolResult);
}
-test "SessionManager: linear chain — each entry's parent_id is the previous entry's id" {
+test "SessionFile: linear chain — each entry's parent_id is the previous entry's id" {
const io = testing.io;
var td = try TmpSessionDir.init(testing.allocator);
defer td.deinit(testing.allocator);
const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
defer testing.allocator.free(sessions);
- var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
defer mgr.deinit();
// Three rounds: sys, user, asst, user, asst.
- const sys = try testing.allocator.alloc(DiskContentBlock, 1);
+ const sys = try testing.allocator.alloc(StoredContentBlock, 1);
sys[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "sys") } };
- _ = try mgr.appendMessage(.{ .role = .system, .content = sys }, null, null);
+ _ = try mgr.appendMessage(.{ .role = .system, .content = sys }, null);
- const u_one = try testing.allocator.alloc(DiskContentBlock, 1);
+ const u_one = try testing.allocator.alloc(StoredContentBlock, 1);
u_one[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u1") } };
- _ = try mgr.appendMessage(.{ .role = .user, .content = u_one }, "openai", "gpt-4o");
+ _ = try mgr.appendMessage(.{ .role = .user, .content = u_one }, oaStamp());
- const a_one = try testing.allocator.alloc(DiskContentBlock, 1);
+ const a_one = try testing.allocator.alloc(StoredContentBlock, 1);
a_one[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a1") } };
- _ = try mgr.appendMessage(.{ .role = .assistant, .content = a_one }, null, null);
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = a_one }, null);
- const u_two = try testing.allocator.alloc(DiskContentBlock, 1);
+ const u_two = try testing.allocator.alloc(StoredContentBlock, 1);
u_two[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u2") } };
- _ = try mgr.appendMessage(.{ .role = .user, .content = u_two }, "openai", "gpt-4o");
+ _ = try mgr.appendMessage(.{ .role = .user, .content = u_two }, oaStamp());
- const a_two = try testing.allocator.alloc(DiskContentBlock, 1);
+ const a_two = try testing.allocator.alloc(StoredContentBlock, 1);
a_two[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a2") } };
- _ = try mgr.appendMessage(.{ .role = .assistant, .content = a_two }, null, null);
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = a_two }, null);
const entries = mgr.getEntries();
try testing.expectEqual(@as(usize, 5), entries.len);
@@ -1714,14 +1923,14 @@ test "resolveSessionId: unique prefix → match, ambiguous → error" {
defer testing.allocator.free(sessions);
// Create one session.
- var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
defer mgr.deinit();
- const u = try testing.allocator.alloc(DiskContentBlock, 1);
+ const u = try testing.allocator.alloc(StoredContentBlock, 1);
u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u") } };
- _ = try mgr.appendMessage(.{ .role = .user, .content = u }, "openai", "gpt-4o");
- const a = try testing.allocator.alloc(DiskContentBlock, 1);
+ _ = try mgr.appendMessage(.{ .role = .user, .content = u }, oaStamp());
+ const a = try testing.allocator.alloc(StoredContentBlock, 1);
a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a") } };
- _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null, null);
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null);
const id = mgr.getSessionId();
const prefix = id[0..8];
@@ -1742,35 +1951,35 @@ test "compaction summary round-trips through persist + resume + rebuild" {
defer testing.allocator.free(sessions);
const session_file: []u8 = blk: {
- var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
defer mgr.deinit();
// System + an old turn that will be superseded.
- const sys = try testing.allocator.alloc(DiskContentBlock, 1);
+ const sys = try testing.allocator.alloc(StoredContentBlock, 1);
sys[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "you are helpful") } };
- _ = try mgr.appendMessage(.{ .role = .system, .content = sys }, null, null);
+ _ = try mgr.appendMessage(.{ .role = .system, .content = sys }, null);
- const uo = try testing.allocator.alloc(DiskContentBlock, 1);
+ const uo = try testing.allocator.alloc(StoredContentBlock, 1);
uo[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "old q") } };
- _ = try mgr.appendMessage(.{ .role = .user, .content = uo }, "openai", "gpt-4o");
- const ao = try testing.allocator.alloc(DiskContentBlock, 1);
+ _ = try mgr.appendMessage(.{ .role = .user, .content = uo }, oaStamp());
+ const ao = try testing.allocator.alloc(StoredContentBlock, 1);
ao[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "old a") } };
- _ = try mgr.appendMessage(.{ .role = .assistant, .content = ao }, null, null);
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = ao }, null);
// Compaction: summary message + duplicated kept suffix.
- const cs = try testing.allocator.alloc(DiskContentBlock, 1);
+ const cs = try testing.allocator.alloc(StoredContentBlock, 1);
cs[0] = .{ .compaction_summary = .{ .text = try testing.allocator.dupe(u8, "SUMMARY") } };
- _ = try mgr.appendMessage(.{ .role = .user, .content = cs }, "openai", "gpt-4o");
+ _ = try mgr.appendMessage(.{ .role = .user, .content = cs }, oaStamp());
- const ur = try testing.allocator.alloc(DiskContentBlock, 1);
+ const ur = try testing.allocator.alloc(StoredContentBlock, 1);
ur[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "recent q") } };
- _ = try mgr.appendMessage(.{ .role = .user, .content = ur }, "openai", "gpt-4o");
+ _ = try mgr.appendMessage(.{ .role = .user, .content = ur }, oaStamp());
break :blk try testing.allocator.dupe(u8, mgr.getSessionFile());
};
defer testing.allocator.free(session_file);
- var resumed = try SessionManager.open(testing.allocator, io, session_file);
+ var resumed = try SessionFile.open(testing.allocator, io, session_file);
defer resumed.deinit();
var conv = try resumed.rebuildConversation();
defer conv.deinit();
@@ -1802,34 +2011,31 @@ test "loadConversation: trailing user prompt is split out as dangling, excluded
const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
defer testing.allocator.free(sessions);
- var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c");
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
defer mgr.deinit();
- // A completed user/assistant round, then a dangling user prompt with no
- // following assistant (simulating a crash right after submission).
- const um1 = try testing.allocator.alloc(DiskContentBlock, 1);
+ // A completed user/assistant round, then a trailing user prompt with no
+ // following assistant. The dangling-prompt recovery feature was dropped
+ // in R2: the trailing user message simply round-trips into the rebuilt
+ // conversation like any other.
+ const um1 = try testing.allocator.alloc(StoredContentBlock, 1);
um1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi") } };
- _ = try mgr.appendMessage(.{ .role = .user, .content = um1 }, "openai", "gpt-4o");
- const am1 = try testing.allocator.alloc(DiskContentBlock, 1);
+ _ = try mgr.appendMessage(.{ .role = .user, .content = um1 }, oaStamp());
+ const am1 = try testing.allocator.alloc(StoredContentBlock, 1);
am1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hello") } };
- _ = try mgr.appendMessage(.{ .role = .assistant, .content = am1 }, null, null);
- const um2 = try testing.allocator.alloc(DiskContentBlock, 1);
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = am1 }, null);
+ const um2 = try testing.allocator.alloc(StoredContentBlock, 1);
um2[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "what's 2+2?") } };
- _ = try mgr.appendMessage(.{ .role = .user, .content = um2 }, "openai", "gpt-4o");
+ _ = try mgr.appendMessage(.{ .role = .user, .content = um2 }, oaStamp());
- var loaded = try mgr.loadConversation(testing.allocator);
- defer loaded.deinit(testing.allocator);
-
- // Dangling prompt recovered.
- try testing.expect(loaded.dangling_user != null);
- try testing.expectEqualStrings("what's 2+2?", loaded.dangling_user.?);
- // Conversation excludes the dangling user turn: [user hi, assistant hello].
- try testing.expectEqual(@as(usize, 2), loaded.conversation.messages.items.len);
- try testing.expectEqual(conversation_mod.MessageRole.user, loaded.conversation.messages.items[0].role);
- try testing.expectEqual(conversation_mod.MessageRole.assistant, loaded.conversation.messages.items[1].role);
+ var conv = try mgr.rebuildConversation();
+ defer conv.deinit();
+ // All three messages are present (dangling recovery dropped).
+ try testing.expectEqual(@as(usize, 3), conv.messages.items.len);
+ try testing.expectEqual(conversation_mod.MessageRole.user, conv.messages.items[2].role);
}
-test "loadConversation: no dangling prompt when log ends with assistant" {
+test "FileSystemJSONLStore catalog: create → append → load round-trips" {
const io = testing.io;
var td = try TmpSessionDir.init(testing.allocator);
@@ -1837,50 +2043,33 @@ test "loadConversation: no dangling prompt when log ends with assistant" {
const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
defer testing.allocator.free(sessions);
- var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c");
- defer mgr.deinit();
-
- const um1 = try testing.allocator.alloc(DiskContentBlock, 1);
- um1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi") } };
- _ = try mgr.appendMessage(.{ .role = .user, .content = um1 }, "openai", "gpt-4o");
- const am1 = try testing.allocator.alloc(DiskContentBlock, 1);
- am1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hello") } };
- _ = try mgr.appendMessage(.{ .role = .assistant, .content = am1 }, null, null);
-
- var loaded = try mgr.loadConversation(testing.allocator);
- defer loaded.deinit(testing.allocator);
- try testing.expect(loaded.dangling_user == null);
- try testing.expectEqual(@as(usize, 2), loaded.conversation.messages.items.len);
-}
-
-test "store(): SessionStore wrapper delegates to the concrete manager" {
- const io = testing.io;
-
- var td = try TmpSessionDir.init(testing.allocator);
- defer td.deinit(testing.allocator);
- const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
- defer testing.allocator.free(sessions);
+ var catalog = try FileSystemJSONLStore.init(testing.allocator, io, sessions, "/c");
+ defer catalog.deinit();
+ const store = catalog.store();
- var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c");
- defer mgr.deinit();
+ var sess = store.create();
+ defer sess.info.deinit(testing.allocator);
- const s = mgr.store();
+ // Build a user + assistant PersistentMessage batch (borrows in-memory
+ // messages owned here).
+ var conv = conversation_mod.Conversation.init(testing.allocator);
+ defer conv.deinit();
+ try conv.addUserMessage("ping");
+ try conv.addAssistantMessage(&.{});
- // Append a user+assistant batch through the neutral interface.
- var messages = try testing.allocator.alloc(DiskMessage, 2);
- defer testing.allocator.free(messages);
- const uc = try testing.allocator.alloc(DiskContentBlock, 1);
- uc[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "ping") } };
- messages[0] = .{ .role = .user, .content = uc };
- const ac = try testing.allocator.alloc(DiskContentBlock, 1);
- ac[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "pong") } };
- messages[1] = .{ .role = .assistant, .content = ac };
+ const id: session_store_mod.WireIdentity = .{ .api_style = .openai_chat, .base_url = "u", .model = "m" };
+ var batch = [_]session_store_mod.PersistentMessage{
+ .{ .message = conv.messages.items[0], .identity = id },
+ .{ .message = conv.messages.items[1], .identity = id },
+ };
+ try sess.append(&batch);
- try s.appendMessages(messages, &.{ "openai", null }, &.{ "gpt-4o", null });
- try testing.expectEqual(@as(usize, 2), mgr.getEntries().len);
+ // The session's last-used api_style updated after append.
+ try testing.expectEqual(session_store_mod.APIStyle.openai_chat, sess.info.api_style);
- const am = s.activeModel().?;
- try testing.expectEqualStrings("openai", am.provider);
- try testing.expectEqualStrings("gpt-4o", am.model);
- try testing.expectEqualStrings(mgr.getSessionId(), s.sessionId());
+ // Load it back by id.
+ var loaded = (try store.load(sess.info.id)).?;
+ defer loaded.deinit();
+ try testing.expectEqual(@as(usize, 2), loaded.messages.items.len);
+ try testing.expectEqual(conversation_mod.MessageRole.user, loaded.messages.items[0].role);
}
diff --git a/libpanto/src/null_store.zig b/libpanto/src/null_store.zig
index d2337ee..1f3ab74 100644
--- a/libpanto/src/null_store.zig
+++ b/libpanto/src/null_store.zig
@@ -2,15 +2,10 @@
//! persistence (and the default backing for an `Agent` constructed without
//! a real store).
//!
-//! 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.
+//! Every append is dropped. `load` returns null. `list` returns an empty
+//! slice. `create`/`resolve`/`latest` mint/return empty handles. The struct
+//! holds an allocator (needed to satisfy the `SessionInfo` ownership
+//! contract for the empty handles it mints) and is trivially copyable.
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -19,9 +14,10 @@ const session_store_mod = @import("session_store.zig");
const conversation_mod = @import("conversation.zig");
const SessionStore = session_store_mod.SessionStore;
-const LoadedSession = session_store_mod.LoadedSession;
-const DiskMessage = session_store_mod.DiskMessage;
-const ActiveModel = session_store_mod.ActiveModel;
+const Session = session_store_mod.Session;
+const SessionInfo = session_store_mod.SessionInfo;
+const PersistentMessage = session_store_mod.PersistentMessage;
+const Conversation = conversation_mod.Conversation;
pub const NullStore = struct {
allocator: Allocator,
@@ -30,38 +26,62 @@ pub const NullStore = struct {
return .{ .allocator = allocator };
}
- fn appendMessagesVT(
- ctx: *anyopaque,
- messages: []DiskMessage,
- _: []const ?[]const u8,
- _: []const ?[]const u8,
- ) anyerror!void {
+ fn emptyInfo(self: *NullStore) SessionInfo {
+ const a = self.allocator;
+ return .{
+ .id = a.dupe(u8, "") catch "",
+ .created = a.dupe(u8, "") catch "",
+ .modified = a.dupe(u8, "") catch "",
+ .message_count = 0,
+ .last_user_message = a.dupe(u8, "") catch "",
+ .api_style = .openai_chat,
+ .base_url = a.dupe(u8, "") catch "",
+ .model = a.dupe(u8, "") catch "",
+ .reasoning = .default,
+ };
+ }
+
+ fn createVT(ctx: *anyopaque) Session {
const self: *NullStore = @ptrCast(@alignCast(ctx));
- // Appends consume their messages; we drop them, so free them.
- for (messages) |m| m.deinit(self.allocator);
+ return .{ .info = self.emptyInfo(), .store = self.store() };
}
- fn loadConversationVT(ctx: *anyopaque, alloc: Allocator) anyerror!LoadedSession {
- _ = ctx;
- return .{
- .conversation = conversation_mod.Conversation.init(alloc),
- .dangling_user = null,
- };
+ fn listVT(ctx: *anyopaque) anyerror![]SessionInfo {
+ const self: *NullStore = @ptrCast(@alignCast(ctx));
+ return self.allocator.alloc(SessionInfo, 0);
}
- fn sessionIdVT(_: *anyopaque) []const u8 {
- return "";
+ fn freeSessionInfosVT(ctx: *anyopaque, infos: []SessionInfo) void {
+ const self: *NullStore = @ptrCast(@alignCast(ctx));
+ for (infos) |i| i.deinit(self.allocator);
+ self.allocator.free(infos);
}
- fn activeModelVT(_: *anyopaque) ?ActiveModel {
+ fn resolveVT(_: *anyopaque, _: []const u8) anyerror!?Session {
return null;
}
+ fn latestVT(_: *anyopaque) anyerror!?Session {
+ return null;
+ }
+
+ fn loadVT(_: *anyopaque, _: []const u8) anyerror!?Conversation {
+ return null;
+ }
+
+ fn appendMessagesVT(_: *anyopaque, _: []const u8, _: []PersistentMessage) anyerror!void {
+ // Drop everything. The PersistentMessages borrow in-memory data
+ // owned by the caller (the conversation); nothing to free here.
+ }
+
const vtable: SessionStore.VTable = .{
+ .create = createVT,
+ .list = listVT,
+ .freeSessionInfos = freeSessionInfosVT,
+ .resolve = resolveVT,
+ .latest = latestVT,
+ .load = loadVT,
.appendMessages = appendMessagesVT,
- .loadConversation = loadConversationVT,
- .sessionId = sessionIdVT,
- .activeModel = activeModelVT,
};
/// Wrap this `NullStore` as a `SessionStore`. The handle borrows
@@ -73,20 +93,16 @@ pub const NullStore = struct {
const testing = std.testing;
-test "NullStore: appends are dropped (and freed) and load returns empty" {
+test "NullStore: appends are dropped and load returns null" {
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 }};
- // No defer-free: the store consumes (frees) the message.
- try s.appendMessages(&messages, &.{null}, &.{null});
+ try s.appendMessages("sid", &.{});
+ try testing.expect((try s.load("sid")) == null);
+ try testing.expect((try s.resolve("sid")) == null);
+ try testing.expect((try s.latest()) == null);
- var loaded = try s.loadConversation(testing.allocator);
- defer loaded.deinit(testing.allocator);
- try testing.expectEqual(@as(usize, 0), loaded.conversation.messages.items.len);
- try testing.expect(loaded.dangling_user == null);
- try testing.expect(s.activeModel() == null);
- try testing.expectEqualStrings("", s.sessionId());
+ const infos = try s.list();
+ defer s.freeSessionInfos(infos);
+ try testing.expectEqual(@as(usize, 0), infos.len);
}
diff --git a/libpanto/src/root.zig b/libpanto/src/root.zig
index b5fab1a..fb8c1df 100644
--- a/libpanto/src/root.zig
+++ b/libpanto/src/root.zig
@@ -10,7 +10,7 @@ pub const tool = @import("tool.zig");
pub const tool_source = @import("tool_source.zig");
pub const tool_registry = @import("tool_registry.zig");
pub const session = @import("session.zig");
-pub const session_manager = @import("session_manager.zig");
+pub const session_manager = @import("file_system_jsonl_store.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");
diff --git a/libpanto/src/session.zig b/libpanto/src/session.zig
index 0491157..ca7046c 100644
--- a/libpanto/src/session.zig
+++ b/libpanto/src/session.zig
@@ -23,6 +23,38 @@ const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;
const conversation = @import("conversation.zig");
+const config = @import("config.zig");
+
+pub const APIStyle = config.APIStyle;
+pub const ReasoningEffort = config.ReasoningEffort;
+
+/// Wire-format provider identity stamped on a message entry. This is the
+/// ground truth of which endpoint a turn was sent to — never a CLI config
+/// alias, and never any `api_key` material. Recorded on user/assistant
+/// entries; null on system entries.
+pub const WireStamp = struct {
+ api_style: APIStyle,
+ base_url: []const u8, // owned
+ model: []const u8, // owned
+ reasoning: ReasoningEffort = .default,
+
+ pub fn deinit(self: WireStamp, alloc: Allocator) void {
+ alloc.free(self.base_url);
+ alloc.free(self.model);
+ }
+
+ pub fn dupe(self: WireStamp, alloc: Allocator) !WireStamp {
+ const burl = try alloc.dupe(u8, self.base_url);
+ errdefer alloc.free(burl);
+ const mdl = try alloc.dupe(u8, self.model);
+ return .{
+ .api_style = self.api_style,
+ .base_url = burl,
+ .model = mdl,
+ .reasoning = self.reasoning,
+ };
+ }
+};
/// Bumped whenever the on-disk format changes in a way that older readers
/// cannot tolerate. Older files are upgraded by `migrate()` on load and
@@ -83,47 +115,45 @@ pub const SessionEntry = union(enum) {
pub const MessageEntry = struct {
base: EntryBase,
- /// Recorded on user message entries (both human prompts and tool-result
- /// messages). Both are submissions to a provider API; the stamp says
- /// which one. Null on system and assistant entries.
- provider: ?[]const u8 = null, // owned
- model: ?[]const u8 = null, // owned
- message: DiskMessage,
+ /// Wire-format provider identity for this entry. Recorded on user and
+ /// assistant message entries (both are tied to a provider API call);
+ /// null on system entries.
+ stamp: ?WireStamp = null,
+ message: StoredMessage,
pub fn deinit(self: MessageEntry, alloc: Allocator) void {
self.base.deinit(alloc);
- if (self.provider) |p| alloc.free(p);
- if (self.model) |m| alloc.free(m);
+ if (self.stamp) |s| s.deinit(alloc);
self.message.deinit(alloc);
}
};
-pub const DiskMessageRole = enum { system, user, assistant };
+pub const StoredMessageRole = enum { system, user, assistant };
/// Mode for a system-role message. Mirrors `conversation.SystemMode`.
/// `append` adds to the effective prompt; `replace` discards all prior
/// system text. Only meaningful on system messages; absent on disk means
/// `append` (back-compatible with pre-mode logs).
-pub const DiskSystemMode = enum { append, replace };
+pub const StoredSystemMode = enum { append, replace };
-pub const DiskMessage = struct {
- role: DiskMessageRole,
- content: []DiskContentBlock, // owned
+pub const StoredMessage = struct {
+ role: StoredMessageRole,
+ content: []StoredContentBlock, // owned
/// System-message mode. Recorded only for system-role messages; an
/// absent `mode` on disk parses back as `.append`.
- mode: DiskSystemMode = .append,
- // Assistant-only metadata. Null for system/user messages.
- provider: ?[]const u8 = null, // owned
- model: ?[]const u8 = null, // owned
+ mode: StoredSystemMode = .append,
+ /// Assistant-only stop reason. Null for system/user messages.
stop_reason: ?[]const u8 = null, // owned
usage: ?Usage = null,
+ /// Opaque per-message metadata bag (see `conversation.Message.metadata`).
+ /// Round-trips verbatim; `libpanto` never interprets it.
+ metadata: ?[]const u8 = null, // owned
- pub fn deinit(self: DiskMessage, alloc: Allocator) void {
+ pub fn deinit(self: StoredMessage, alloc: Allocator) void {
for (self.content) |block| block.deinit(alloc);
alloc.free(self.content);
- if (self.provider) |p| alloc.free(p);
- if (self.model) |m| alloc.free(m);
if (self.stop_reason) |s| alloc.free(s);
+ if (self.metadata) |m| alloc.free(m);
}
};
@@ -138,14 +168,14 @@ pub const Usage = conversation.Usage;
// Content blocks
// =============================================================================
-pub const DiskContentBlock = union(enum) {
- text: DiskTextBlock,
- thinking: DiskThinkingBlock,
- tool_use: DiskToolUseBlock,
- tool_result: DiskToolResultBlock,
- compaction_summary: DiskCompactionSummaryBlock,
+pub const StoredContentBlock = union(enum) {
+ text: StoredTextBlock,
+ thinking: StoredThinkingBlock,
+ tool_use: StoredToolUseBlock,
+ tool_result: StoredToolResultBlock,
+ compaction_summary: StoredCompactionSummaryBlock,
- pub fn deinit(self: DiskContentBlock, alloc: Allocator) void {
+ pub fn deinit(self: StoredContentBlock, alloc: Allocator) void {
switch (self) {
.text => |b| b.deinit(alloc),
.thinking => |b| b.deinit(alloc),
@@ -156,30 +186,30 @@ pub const DiskContentBlock = union(enum) {
}
};
-pub const DiskTextBlock = struct {
+pub const StoredTextBlock = struct {
text: []const u8, // owned
- pub fn deinit(self: DiskTextBlock, alloc: Allocator) void {
+ pub fn deinit(self: StoredTextBlock, alloc: Allocator) void {
alloc.free(self.text);
}
};
-pub const DiskThinkingBlock = struct {
+pub const StoredThinkingBlock = struct {
thinking: []const u8, // owned
/// Anthropic's opaque integrity token. Other providers do not produce
/// one. Preserved here so resumed sessions can be sent back to
/// Anthropic with the original thinking block intact.
signature: ?[]const u8 = null, // owned
- pub fn deinit(self: DiskThinkingBlock, alloc: Allocator) void {
+ pub fn deinit(self: StoredThinkingBlock, alloc: Allocator) void {
alloc.free(self.thinking);
if (self.signature) |s| alloc.free(s);
}
};
-pub const DiskToolUseBlock = struct {
+pub const StoredToolUseBlock = struct {
id: []const u8, // owned
name: []const u8, // owned
input: []const u8, // raw JSON bytes, owned
- pub fn deinit(self: DiskToolUseBlock, alloc: Allocator) void {
+ pub fn deinit(self: StoredToolUseBlock, alloc: Allocator) void {
alloc.free(self.id);
alloc.free(self.name);
alloc.free(self.input);
@@ -188,13 +218,13 @@ pub const DiskToolUseBlock = struct {
/// One on-disk tool-result part: either text or an inline base64 media
/// attachment (no sidecar files).
-pub const DiskResultPart = union(enum) {
+pub const StoredResultPart = union(enum) {
text: []const u8, // owned
media: struct {
media_type: []const u8, // owned
data: []const u8, // owned (base64)
},
- pub fn deinit(self: DiskResultPart, alloc: Allocator) void {
+ pub fn deinit(self: StoredResultPart, alloc: Allocator) void {
switch (self) {
.text => |t| alloc.free(t),
.media => |m| {
@@ -205,11 +235,11 @@ pub const DiskResultPart = union(enum) {
}
};
-pub const DiskToolResultBlock = struct {
+pub const StoredToolResultBlock = struct {
tool_use_id: []const u8, // owned
- parts: []DiskResultPart, // owned
+ parts: []StoredResultPart, // owned
is_error: bool = false,
- pub fn deinit(self: DiskToolResultBlock, alloc: Allocator) void {
+ pub fn deinit(self: StoredToolResultBlock, alloc: Allocator) void {
alloc.free(self.tool_use_id);
for (self.parts) |p| p.deinit(alloc);
alloc.free(self.parts);
@@ -219,9 +249,9 @@ pub const DiskToolResultBlock = struct {
/// A compaction summary block: the synthetic seed text standing in for a
/// compacted conversation prefix. Sits alone in a `user`-role message. See
/// `conversation.CompactionSummaryBlock`.
-pub const DiskCompactionSummaryBlock = struct {
+pub const StoredCompactionSummaryBlock = struct {
text: []const u8, // owned
- pub fn deinit(self: DiskCompactionSummaryBlock, alloc: Allocator) void {
+ pub fn deinit(self: StoredCompactionSummaryBlock, alloc: Allocator) void {
alloc.free(self.text);
}
};
@@ -294,21 +324,25 @@ fn writeMessageEntry(s: *std.json.Stringify, m: MessageEntry) !void {
if (m.base.parent_id) |p| try s.write(p) else try s.write(null);
try s.objectField("timestamp");
try s.write(m.base.timestamp);
- // Top-level provider/model on user message entries.
- if (m.provider) |p| {
- try s.objectField("provider");
- try s.write(p);
- }
- if (m.model) |mm| {
- try s.objectField("model");
- try s.write(mm);
- }
+ // Wire-format provider identity on user/assistant entries.
+ if (m.stamp) |st| try writeWireStamp(s, st);
try s.objectField("message");
try writeDiskMessage(s, m.message);
try s.endObject();
}
-fn writeDiskMessage(s: *std.json.Stringify, msg: DiskMessage) !void {
+fn writeWireStamp(s: *std.json.Stringify, st: WireStamp) !void {
+ try s.objectField("apiStyle");
+ try s.write(@tagName(st.api_style));
+ try s.objectField("baseUrl");
+ try s.write(st.base_url);
+ try s.objectField("model");
+ try s.write(st.model);
+ try s.objectField("reasoning");
+ try s.write(@tagName(st.reasoning));
+}
+
+fn writeDiskMessage(s: *std.json.Stringify, msg: StoredMessage) !void {
try s.beginObject();
try s.objectField("role");
try s.write(@tagName(msg.role));
@@ -324,18 +358,14 @@ fn writeDiskMessage(s: *std.json.Stringify, msg: DiskMessage) !void {
try writeDiskBlock(s, block);
}
try s.endArray();
- if (msg.provider) |p| {
- try s.objectField("provider");
- try s.write(p);
- }
- if (msg.model) |mm| {
- try s.objectField("model");
- try s.write(mm);
- }
if (msg.stop_reason) |sr| {
try s.objectField("stopReason");
try s.write(sr);
}
+ if (msg.metadata) |md| {
+ try s.objectField("metadata");
+ try s.write(md);
+ }
if (msg.usage) |u| {
try s.objectField("usage");
try s.beginObject();
@@ -363,7 +393,7 @@ fn writeDiskMessage(s: *std.json.Stringify, msg: DiskMessage) !void {
try s.endObject();
}
-fn writeDiskBlock(s: *std.json.Stringify, block: DiskContentBlock) !void {
+fn writeDiskBlock(s: *std.json.Stringify, block: StoredContentBlock) !void {
switch (block) {
.text => |b| {
try s.beginObject();
@@ -518,10 +548,8 @@ fn parseMessageEntry(allocator: Allocator, obj: std.json.ObjectMap) ParseError!M
};
errdefer if (parent_id) |p| allocator.free(p);
- const provider: ?[]const u8 = try dupeOptionalStringField(allocator, obj, "provider");
- errdefer if (provider) |p| allocator.free(p);
- const model: ?[]const u8 = try dupeOptionalStringField(allocator, obj, "model");
- errdefer if (model) |m| allocator.free(m);
+ const stamp = try parseWireStamp(allocator, obj);
+ errdefer if (stamp) |st| st.deinit(allocator);
const msg_v = obj.get("message") orelse return error.MissingField;
if (msg_v != .object) return error.MissingField;
@@ -529,28 +557,45 @@ fn parseMessageEntry(allocator: Allocator, obj: std.json.ObjectMap) ParseError!M
return .{
.base = .{ .id = id, .parent_id = parent_id, .timestamp = timestamp },
- .provider = provider,
- .model = model,
+ .stamp = stamp,
.message = msg,
};
}
-fn parseDiskMessage(allocator: Allocator, obj: std.json.ObjectMap) ParseError!DiskMessage {
+/// Parse the wire-format provider stamp from a message entry object.
+/// Returns null when no `apiStyle` field is present (system entries).
+fn parseWireStamp(allocator: Allocator, obj: std.json.ObjectMap) ParseError!?WireStamp {
+ const style_v = obj.get("apiStyle") orelse return null;
+ if (style_v != .string) return null;
+ const api_style = std.meta.stringToEnum(APIStyle, style_v.string) orelse return error.MissingField;
+ const base_url = try dupeStringField(allocator, obj, "baseUrl");
+ errdefer allocator.free(base_url);
+ const model = try dupeStringField(allocator, obj, "model");
+ errdefer allocator.free(model);
+ const reasoning: ReasoningEffort = blk: {
+ const rv = obj.get("reasoning") orelse break :blk .default;
+ if (rv != .string) break :blk .default;
+ break :blk std.meta.stringToEnum(ReasoningEffort, rv.string) orelse .default;
+ };
+ return .{ .api_style = api_style, .base_url = base_url, .model = model, .reasoning = reasoning };
+}
+
+fn parseDiskMessage(allocator: Allocator, obj: std.json.ObjectMap) ParseError!StoredMessage {
const role_v = obj.get("role") orelse return error.MissingField;
if (role_v != .string) return error.MissingField;
- const role = std.meta.stringToEnum(DiskMessageRole, role_v.string) orelse return error.UnknownRole;
+ const role = std.meta.stringToEnum(StoredMessageRole, role_v.string) orelse return error.UnknownRole;
// `mode` is optional; absent defaults to `.append`. Unknown values are
// tolerated as `.append` rather than rejecting an otherwise-valid log.
- const mode: DiskSystemMode = blk: {
+ const mode: StoredSystemMode = blk: {
const mv = obj.get("mode") orelse break :blk .append;
if (mv != .string) break :blk .append;
- break :blk std.meta.stringToEnum(DiskSystemMode, mv.string) orelse .append;
+ break :blk std.meta.stringToEnum(StoredSystemMode, mv.string) orelse .append;
};
const content_v = obj.get("content") orelse return error.MissingField;
if (content_v != .array) return error.MissingField;
- var content_list = try std.ArrayList(DiskContentBlock).initCapacity(allocator, content_v.array.items.len);
+ var content_list = try std.ArrayList(StoredContentBlock).initCapacity(allocator, content_v.array.items.len);
errdefer {
for (content_list.items) |b| b.deinit(allocator);
content_list.deinit(allocator);
@@ -566,12 +611,10 @@ fn parseDiskMessage(allocator: Allocator, obj: std.json.ObjectMap) ParseError!Di
allocator.free(content);
}
- const provider: ?[]const u8 = try dupeOptionalStringField(allocator, obj, "provider");
- errdefer if (provider) |p| allocator.free(p);
- const model: ?[]const u8 = try dupeOptionalStringField(allocator, obj, "model");
- errdefer if (model) |m| allocator.free(m);
const stop_reason: ?[]const u8 = try dupeOptionalStringField(allocator, obj, "stopReason");
errdefer if (stop_reason) |s| allocator.free(s);
+ const metadata: ?[]const u8 = try dupeOptionalStringField(allocator, obj, "metadata");
+ errdefer if (metadata) |m| allocator.free(m);
var usage: ?Usage = null;
if (obj.get("usage")) |uv| {
@@ -590,14 +633,13 @@ fn parseDiskMessage(allocator: Allocator, obj: std.json.ObjectMap) ParseError!Di
.role = role,
.content = content,
.mode = mode,
- .provider = provider,
- .model = model,
.stop_reason = stop_reason,
.usage = usage,
+ .metadata = metadata,
};
}
-fn parseDiskBlock(allocator: Allocator, obj: std.json.ObjectMap) ParseError!DiskContentBlock {
+fn parseDiskBlock(allocator: Allocator, obj: std.json.ObjectMap) ParseError!StoredContentBlock {
const type_v = obj.get("type") orelse return error.MissingField;
if (type_v != .string) return error.MissingField;
const t = type_v.string;
@@ -634,8 +676,8 @@ fn parseDiskBlock(allocator: Allocator, obj: std.json.ObjectMap) ParseError!Disk
/// Parse the `parts` array of a `toolResult` disk block. Falls back to a
/// legacy single `content` string field (older session logs) -> one text
/// part. Each element is {type:"text",text} or {type:"image",mimeType,data}.
-fn parseDiskResultParts(allocator: Allocator, obj: std.json.ObjectMap) ParseError![]DiskResultPart {
- var list: std.ArrayList(DiskResultPart) = .empty;
+fn parseDiskResultParts(allocator: Allocator, obj: std.json.ObjectMap) ParseError![]StoredResultPart {
+ var list: std.ArrayList(StoredResultPart) = .empty;
errdefer {
for (list.items) |p| p.deinit(allocator);
list.deinit(allocator);
@@ -698,13 +740,13 @@ fn dupeOptionalStringField(allocator: Allocator, obj: std.json.ObjectMap, name:
// Bridge between in-memory and on-disk content blocks
// =============================================================================
-/// Convert an in-memory `ContentBlock` to a `DiskContentBlock`. All strings
+/// Convert an in-memory `ContentBlock` to a `StoredContentBlock`. All strings
/// are duplicated; the source block remains untouched and the resulting
/// disk block is independently owned.
pub fn contentBlockToDisk(
allocator: Allocator,
block: conversation.ContentBlock,
-) !DiskContentBlock {
+) !StoredContentBlock {
switch (block) {
.Text => |tb| {
const text = try allocator.dupe(u8, tb.items);
@@ -727,7 +769,7 @@ pub fn contentBlockToDisk(
.ToolResult => |tr| {
const tuid = try allocator.dupe(u8, tr.tool_use_id);
errdefer allocator.free(tuid);
- var parts: std.ArrayList(DiskResultPart) = .empty;
+ var parts: std.ArrayList(StoredResultPart) = .empty;
errdefer {
for (parts.items) |p| p.deinit(allocator);
parts.deinit(allocator);
@@ -751,7 +793,7 @@ pub fn contentBlockToDisk(
} };
},
// A `.System` block becomes a disk text block; its mode rides on
- // the enclosing `DiskMessage.mode` (set by the session manager),
+ // the enclosing `StoredMessage.mode` (set by the session manager),
// not on the block itself.
.System => |sb| {
const text = try allocator.dupe(u8, sb.text.items);
@@ -764,12 +806,12 @@ pub fn contentBlockToDisk(
}
}
-/// Convert a `DiskContentBlock` to an in-memory `ContentBlock`. Allocates
+/// Convert a `StoredContentBlock` to an in-memory `ContentBlock`. Allocates
/// fresh owned buffers for every string field. The returned block is
/// independently owned.
pub fn diskContentBlockToInternal(
allocator: Allocator,
- block: DiskContentBlock,
+ block: StoredContentBlock,
) !conversation.ContentBlock {
switch (block) {
.text => |b| {
@@ -856,7 +898,7 @@ test "serialize/parse header round-trip" {
test "serialize/parse user message entry round-trip (with provider/model stamp)" {
const a = testing.allocator;
- var content = try a.alloc(DiskContentBlock, 1);
+ var content = try a.alloc(StoredContentBlock, 1);
content[0] = .{ .text = .{ .text = try dupe(a, "hello world") } };
const entry: SessionEntry = .{ .message = .{
@@ -865,8 +907,12 @@ test "serialize/parse user message entry round-trip (with provider/model stamp)"
.parent_id = try dupe(a, "00000000"),
.timestamp = try dupe(a, "2026-04-25T17:40:16.000Z"),
},
- .provider = try dupe(a, "openai"),
- .model = try dupe(a, "gpt-4o"),
+ .stamp = .{
+ .api_style = .openai_chat,
+ .base_url = try dupe(a, "https://api.openai.com/v1"),
+ .model = try dupe(a, "gpt-4o"),
+ .reasoning = .high,
+ },
.message = .{
.role = .user,
.content = content,
@@ -883,9 +929,11 @@ test "serialize/parse user message entry round-trip (with provider/model stamp)"
const got = fe.entry.message;
try testing.expectEqualStrings("a1b2c3d4", got.base.id);
try testing.expectEqualStrings("00000000", got.base.parent_id.?);
- try testing.expectEqualStrings("openai", got.provider.?);
- try testing.expectEqualStrings("gpt-4o", got.model.?);
- try testing.expectEqual(DiskMessageRole.user, got.message.role);
+ try testing.expectEqual(APIStyle.openai_chat, got.stamp.?.api_style);
+ try testing.expectEqualStrings("https://api.openai.com/v1", got.stamp.?.base_url);
+ try testing.expectEqualStrings("gpt-4o", got.stamp.?.model);
+ try testing.expectEqual(ReasoningEffort.high, got.stamp.?.reasoning);
+ try testing.expectEqual(StoredMessageRole.user, got.message.role);
try testing.expectEqual(@as(usize, 1), got.message.content.len);
try testing.expectEqualStrings("hello world", got.message.content[0].text.text);
}
@@ -893,7 +941,7 @@ test "serialize/parse user message entry round-trip (with provider/model stamp)"
test "serialize/parse assistant message entry with metadata" {
const a = testing.allocator;
- var content = try a.alloc(DiskContentBlock, 3);
+ var content = try a.alloc(StoredContentBlock, 3);
content[0] = .{ .thinking = .{
.thinking = try dupe(a, "let me think"),
.signature = try dupe(a, "sig-xyz"),
@@ -911,13 +959,17 @@ test "serialize/parse assistant message entry with metadata" {
.parent_id = try dupe(a, "a1b2c3d4"),
.timestamp = try dupe(a, "2026-04-25T17:40:17.000Z"),
},
+ .stamp = .{
+ .api_style = .anthropic_messages,
+ .base_url = try dupe(a, "https://api.anthropic.com"),
+ .model = try dupe(a, "claude-sonnet-4-20250514"),
+ },
.message = .{
.role = .assistant,
.content = content,
- .provider = try dupe(a, "anthropic"),
- .model = try dupe(a, "claude-sonnet-4-20250514"),
.stop_reason = try dupe(a, "toolUse"),
.usage = .{ .input = 1500, .output = 85 },
+ .metadata = try dupe(a, "{\"k\":1}"),
},
} };
defer entry.deinit(a);
@@ -928,14 +980,15 @@ test "serialize/parse assistant message entry with metadata" {
var fe = try parseLine(a, line);
defer fe.deinit(a);
const got = fe.entry.message;
- try testing.expectEqual(DiskMessageRole.assistant, got.message.role);
+ try testing.expectEqual(StoredMessageRole.assistant, got.message.role);
try testing.expectEqual(@as(usize, 3), got.message.content.len);
try testing.expectEqualStrings("let me think", got.message.content[0].thinking.thinking);
try testing.expectEqualStrings("sig-xyz", got.message.content[0].thinking.signature.?);
try testing.expectEqualStrings("bash", got.message.content[2].tool_use.name);
try testing.expectEqualStrings("{\"command\":\"ls\"}", got.message.content[2].tool_use.input);
- try testing.expectEqualStrings("anthropic", got.message.provider.?);
+ try testing.expectEqualStrings("anthropic", @tagName(got.stamp.?.api_style)[0..9]);
try testing.expectEqualStrings("toolUse", got.message.stop_reason.?);
+ try testing.expectEqualStrings("{\"k\":1}", got.message.metadata.?);
try testing.expect(got.message.usage != null);
try testing.expectEqual(@as(u64, 1500), got.message.usage.?.input);
try testing.expectEqual(@as(u64, 85), got.message.usage.?.output);
@@ -944,8 +997,8 @@ test "serialize/parse assistant message entry with metadata" {
test "serialize/parse tool result message entry" {
const a = testing.allocator;
- var content = try a.alloc(DiskContentBlock, 1);
- var trp = try a.alloc(DiskResultPart, 1);
+ var content = try a.alloc(StoredContentBlock, 1);
+ var trp = try a.alloc(StoredResultPart, 1);
trp[0] = .{ .text = try dupe(a, "file1.txt\nfile2.txt") };
content[0] = .{ .tool_result = .{
.tool_use_id = try dupe(a, "tool_abc"),
@@ -958,8 +1011,11 @@ test "serialize/parse tool result message entry" {
.parent_id = try dupe(a, "b2c3d4e5"),
.timestamp = try dupe(a, "2026-04-25T17:40:18.000Z"),
},
- .provider = try dupe(a, "anthropic"),
- .model = try dupe(a, "claude-sonnet-4-20250514"),
+ .stamp = .{
+ .api_style = .anthropic_messages,
+ .base_url = try dupe(a, "https://api.anthropic.com"),
+ .model = try dupe(a, "claude-sonnet-4-20250514"),
+ },
.message = .{
.role = .user,
.content = content,
@@ -973,11 +1029,11 @@ test "serialize/parse tool result message entry" {
var fe = try parseLine(a, line);
defer fe.deinit(a);
const got = fe.entry.message;
- try testing.expectEqual(DiskMessageRole.user, got.message.role);
+ try testing.expectEqual(StoredMessageRole.user, got.message.role);
try testing.expectEqualStrings("tool_abc", got.message.content[0].tool_result.tool_use_id);
try testing.expectEqual(@as(usize, 1), got.message.content[0].tool_result.parts.len);
try testing.expectEqualStrings("file1.txt\nfile2.txt", got.message.content[0].tool_result.parts[0].text);
- try testing.expectEqualStrings("anthropic", got.provider.?);
+ try testing.expectEqual(APIStyle.anthropic_messages, got.stamp.?.api_style);
// Unset is_error defaults to false and serializes without the field.
try testing.expect(!got.message.content[0].tool_result.is_error);
try testing.expect(std.mem.indexOf(u8, line, "isError") == null);
@@ -986,8 +1042,8 @@ test "serialize/parse tool result message entry" {
test "serialize/parse tool result preserves is_error = true" {
const a = testing.allocator;
- var content = try a.alloc(DiskContentBlock, 1);
- var trp = try a.alloc(DiskResultPart, 1);
+ var content = try a.alloc(StoredContentBlock, 1);
+ var trp = try a.alloc(StoredResultPart, 1);
trp[0] = .{ .text = try dupe(a, "file not found") };
content[0] = .{ .tool_result = .{
.tool_use_id = try dupe(a, "tool_err"),
@@ -1001,8 +1057,11 @@ test "serialize/parse tool result preserves is_error = true" {
.parent_id = try dupe(a, "e0"),
.timestamp = try dupe(a, "2026-04-25T17:40:18.000Z"),
},
- .provider = try dupe(a, "anthropic"),
- .model = try dupe(a, "claude-sonnet-4-20250514"),
+ .stamp = .{
+ .api_style = .anthropic_messages,
+ .base_url = try dupe(a, "https://api.anthropic.com"),
+ .model = try dupe(a, "claude-sonnet-4-20250514"),
+ },
.message = .{ .role = .user, .content = content },
} };
defer entry.deinit(a);
@@ -1030,8 +1089,8 @@ test "parse tool result without isError defaults to false" {
test "serialize/parse tool result with text + image part round-trips" {
const a = testing.allocator;
- var content = try a.alloc(DiskContentBlock, 1);
- var trp = try a.alloc(DiskResultPart, 2);
+ var content = try a.alloc(StoredContentBlock, 1);
+ var trp = try a.alloc(StoredResultPart, 2);
trp[0] = .{ .text = try dupe(a, "here is the image") };
trp[1] = .{ .media = .{
.media_type = try dupe(a, "image/png"),
@@ -1048,8 +1107,11 @@ test "serialize/parse tool result with text + image part round-trips" {
.parent_id = try dupe(a, "img00000"),
.timestamp = try dupe(a, "2026-04-25T17:40:18.000Z"),
},
- .provider = try dupe(a, "anthropic"),
- .model = try dupe(a, "claude-sonnet-4-20250514"),
+ .stamp = .{
+ .api_style = .anthropic_messages,
+ .base_url = try dupe(a, "https://api.anthropic.com"),
+ .model = try dupe(a, "claude-sonnet-4-20250514"),
+ },
.message = .{ .role = .user, .content = content },
} };
defer entry.deinit(a);
@@ -1072,7 +1134,7 @@ test "system message mode round-trips; absent mode defaults to append" {
// replace-mode system entry round-trips.
{
- var content = try a.alloc(DiskContentBlock, 1);
+ var content = try a.alloc(StoredContentBlock, 1);
content[0] = .{ .text = .{ .text = try dupe(a, "fresh seed") } };
const entry: SessionEntry = .{ .message = .{
.base = .{
@@ -1094,7 +1156,7 @@ test "system message mode round-trips; absent mode defaults to append" {
var fe = try parseLine(a, line);
defer fe.deinit(a);
- try testing.expectEqual(DiskSystemMode.replace, fe.entry.message.message.mode);
+ try testing.expectEqual(StoredSystemMode.replace, fe.entry.message.message.mode);
}
// A legacy system entry with no `mode` parses back as append.
@@ -1104,7 +1166,7 @@ test "system message mode round-trips; absent mode defaults to append" {
;
var fe = try parseLine(a, line);
defer fe.deinit(a);
- try testing.expectEqual(DiskSystemMode.append, fe.entry.message.message.mode);
+ try testing.expectEqual(StoredSystemMode.append, fe.entry.message.message.mode);
}
}
@@ -1147,7 +1209,7 @@ test "contentBlockToDisk: Text round-trips via in-memory" {
test "diskContentBlockToInternal: ToolUse preserves id/name/input" {
const a = testing.allocator;
- const disk: DiskContentBlock = .{ .tool_use = .{
+ const disk: StoredContentBlock = .{ .tool_use = .{
.id = try a.dupe(u8, "tu_1"),
.name = try a.dupe(u8, "bash"),
.input = try a.dupe(u8, "{\"command\":\"ls\"}"),
@@ -1164,7 +1226,7 @@ test "diskContentBlockToInternal: ToolUse preserves id/name/input" {
test "Usage: all five fields round-trip; zero-valued fields omitted from JSON" {
const a = testing.allocator;
- var content = try a.alloc(DiskContentBlock, 1);
+ var content = try a.alloc(StoredContentBlock, 1);
content[0] = .{ .text = .{ .text = try dupe(a, "hi") } };
const entry: SessionEntry = .{ .message = .{
@@ -1176,8 +1238,6 @@ test "Usage: all five fields round-trip; zero-valued fields omitted from JSON" {
.message = .{
.role = .assistant,
.content = content,
- .provider = try dupe(a, "anthropic"),
- .model = try dupe(a, "claude-sonnet-4-20250514"),
.stop_reason = try dupe(a, "stop"),
.usage = .{
.input = 100,
@@ -1213,7 +1273,7 @@ test "Usage: all five fields round-trip; zero-valued fields omitted from JSON" {
test "Usage: zero-valued auxiliary fields are omitted but parse back as 0" {
const a = testing.allocator;
- var content = try a.alloc(DiskContentBlock, 1);
+ var content = try a.alloc(StoredContentBlock, 1);
content[0] = .{ .text = .{ .text = try dupe(a, "hi") } };
const entry: SessionEntry = .{ .message = .{
@@ -1248,7 +1308,7 @@ test "Usage: zero-valued auxiliary fields are omitted but parse back as 0" {
test "diskContentBlockToInternal: Thinking preserves signature" {
const a = testing.allocator;
- const disk: DiskContentBlock = .{ .thinking = .{
+ const disk: StoredContentBlock = .{ .thinking = .{
.thinking = try a.dupe(u8, "reasoning..."),
.signature = try a.dupe(u8, "sig123"),
} };
@@ -1263,7 +1323,7 @@ test "diskContentBlockToInternal: Thinking preserves signature" {
test "compactionSummary block round-trips through serialize/parse" {
const a = testing.allocator;
- var content = try a.alloc(DiskContentBlock, 1);
+ var content = try a.alloc(StoredContentBlock, 1);
content[0] = .{ .compaction_summary = .{ .text = try dupe(a, "earlier history summary") } };
const entry: SessionEntry = .{ .message = .{
@@ -1283,7 +1343,7 @@ test "compactionSummary block round-trips through serialize/parse" {
var fe = try parseLine(a, line);
defer fe.deinit(a);
const got = fe.entry.message;
- try testing.expectEqual(DiskMessageRole.user, got.message.role);
+ try testing.expectEqual(StoredMessageRole.user, got.message.role);
try testing.expectEqualStrings("earlier history summary", got.message.content[0].compaction_summary.text);
}
diff --git a/libpanto/src/session_store.zig b/libpanto/src/session_store.zig
index b929c66..de25e21 100644
--- a/libpanto/src/session_store.zig
+++ b/libpanto/src/session_store.zig
@@ -1,79 +1,132 @@
//! `SessionStore`: the neutral persistence seam for the `Agent`.
//!
-//! Session logging used to be a single concrete type (`SessionManager`,
-//! filesystem JSONL). This interface lets a `libpanto` consumer swap in its
-//! own backend — e.g. a web service backed by Postgres — without the agent
-//! knowing how (or whether) persistence happens.
+//! The interface is **asymmetric**: rich on write (audit/provenance-capable),
+//! minimal on read (resume-oriented). The store decides how much write-side
+//! richness it durably keeps.
//!
-//! The interface follows the same `{ ptr, vtable }` shape as the other
-//! `libpanto` seams (`Tool`/`ToolSource`/`Provider`). It traffics in
-//! `DiskMessage` as the neutral in-memory representation: the default
-//! backend (`FSJSONLStore`) emits JSONL, but a Postgres backend would map
-//! `DiskMessage` to columns and never produce a byte of JSONL.
+//! ## Write side (maximalist)
//!
-//! ## What lives here vs. on the concrete backend
+//! `appendMessages` takes `[]PersistentMessage` — the rich, audit-oriented
+//! write record. Each carries the in-memory `Message` being appended, its
+//! `usage`, the **wire-format** provider identity (`api_style`, `base_url`,
+//! `model`, `reasoning` — never CLI config aliases, and never any `api_key`
+//! material, not even a hash), and full provenance context (the entire
+//! current conversation and the tool set offered for this turn). The library
+//! *offers* all of it on every append; a store keeps what it wants. The
+//! built-in `FileSystemJSONLStore` deliberately ignores the `conversation`
+//! and `tools_available` provenance fields.
//!
-//! On the interface (every store must do these):
-//! - `appendMessages` — the batch-atomic append primitive. A single
-//! append is a length-1 batch.
-//! - `loadConversation` — reconstruct one linear `Conversation` from the
-//! store, plus an optional dangling trailing user prompt (see
-//! `LoadedSession`). A store you cannot read is not a valid store.
-//! - `sessionId` — opaque id string.
-//! - `activeModel` — the provider/model last stamped on a user entry.
+//! ## Read side (minimal)
//!
-//! NOT on the interface (backend-specific, stay as free functions / methods
-//! on the concrete type):
-//! - filesystem path accessors (`getSessionFile`),
-//! - catalog/listing/resume helpers (`listSessions`,
-//! `findMostRecentSession`, `resolveSessionId`) — a web backend lists
-//! via SQL, not by walking a directory.
+//! `load` reconstructs one linear `Conversation`; `list`/`resolve`/`latest`
+//! traffic in `SessionInfo` (display/selection metadata) and `Session`
+//! (an `info` + a store to proxy to). The read path never reproduces a
+//! `PersistentMessage` — a store may not have kept the provenance.
+//!
+//! ## Store construction
+//!
+//! Stores own their own (unprescribed) `init`: a Postgres store takes a DSN,
+//! the FS store takes a directory. Nothing in the vtable carries an
+//! allocator or io — the store captured whatever it needs at its own init.
const std = @import("std");
const Allocator = std.mem.Allocator;
const session_mod = @import("session.zig");
const conversation_mod = @import("conversation.zig");
+const config_mod = @import("config.zig");
+const tool_source_mod = @import("tool_source.zig");
-// Re-export the disk types so the interface is self-contained: an embedder
-// implementing a `SessionStore` imports everything it needs from here.
-pub const DiskMessage = session_mod.DiskMessage;
-pub const DiskMessageRole = session_mod.DiskMessageRole;
-pub const DiskSystemMode = session_mod.DiskSystemMode;
-pub const DiskContentBlock = session_mod.DiskContentBlock;
-pub const Usage = session_mod.Usage;
pub const Conversation = conversation_mod.Conversation;
+pub const Message = conversation_mod.Message;
+pub const Usage = conversation_mod.Usage;
+pub const APIStyle = config_mod.APIStyle;
+pub const ReasoningEffort = config_mod.ReasoningEffort;
+pub const ToolDecl = tool_source_mod.ToolDecl;
-/// The default filesystem-JSONL backend. Defined in `session_manager.zig`;
-/// re-exported here under its interface-facing name. Its concrete
-/// constructors (`init`/`open`) and catalog helpers are backend-specific
-/// and stay on that module.
-pub const FSJSONLStore = @import("session_manager.zig").SessionManager;
+/// The default filesystem-JSONL backend, re-exported under its
+/// interface-facing name. Its concrete constructor (`init`/`open`) and
+/// catalog helpers are backend-specific and stay on that module.
+pub const FileSystemJSONLStore = @import("file_system_jsonl_store.zig").FileSystemJSONLStore;
-/// The read side's result: a reconstructed linear `Conversation` plus an
-/// optional dangling trailing user prompt.
-///
-/// `dangling_user` is set when the log ends with a user entry that has no
-/// following assistant entry — e.g. a crash or quit right after the prompt
-/// was submitted and durably logged but before the model replied. The
-/// reconstructed `conversation` **excludes** that dangling turn, so a
-/// resumed agent never auto-sends it. Consumers may surface the dangling
-/// text (e.g. a TUI prefilling it for editing) or ignore it.
-pub const LoadedSession = struct {
- conversation: Conversation,
- /// Owned by the caller's allocator when present; free it when done.
- dangling_user: ?[]const u8 = null,
+/// Wire-format provider identity. This is the **ground truth** of which
+/// endpoint a turn was sent to — never a CLI config alias (aliases get
+/// renamed; two keys for one endpoint are indistinguishable on the wire).
+/// `reasoning` disambiguates otherwise-identical endpoints. No `api_key`
+/// material ever appears here. Aliased from `config` to avoid a module
+/// cycle (config must not import session_store).
+pub const WireIdentity = config_mod.WireIdentity;
- pub fn deinit(self: *LoadedSession, alloc: Allocator) void {
- self.conversation.deinit();
- if (self.dangling_user) |d| alloc.free(d);
- }
+/// The rich, audit-oriented write record. The library offers all of this on
+/// every append; the store keeps what it wants.
+pub const PersistentMessage = struct {
+ /// The in-memory message being appended (carries its own `metadata`).
+ message: Message,
+ /// Provider usage for this message (assistant turns), or null.
+ usage: ?Usage = null,
+ /// Wire-format provider identity for the turn this message belongs to.
+ identity: WireIdentity,
+ /// Full provenance: the entire current conversation at write time. The
+ /// FS store ignores this; an audit store may content-address it.
+ conversation: []const Message = &.{},
+ /// Full provenance: the tool set offered for this turn. The FS store
+ /// ignores this; an audit store may content-address it.
+ tools_available: []const ToolDecl = &.{},
};
-/// The active provider/model, as last stamped on a user entry.
-pub const ActiveModel = struct {
- provider: []const u8,
+/// Display/selection metadata for one session — pure data, aliased. Used by
+/// `panto sessions` and resume pre-selection. The last-used wire identity is
+/// updated on append (for resume), never a CLI config alias.
+pub const SessionInfo = struct {
+ id: []const u8,
+ created: []const u8,
+ modified: []const u8,
+ message_count: usize,
+ /// May be truncated.
+ last_user_message: []const u8,
+ /// Last-used wire identity, updated on append.
+ api_style: APIStyle,
+ base_url: []const u8,
model: []const u8,
+ reasoning: ReasoningEffort,
+
+ pub fn deinit(self: SessionInfo, alloc: Allocator) void {
+ alloc.free(self.id);
+ alloc.free(self.created);
+ alloc.free(self.modified);
+ alloc.free(self.last_user_message);
+ alloc.free(self.base_url);
+ alloc.free(self.model);
+ }
+};
+
+/// A session handle: pure data (a `SessionInfo`) plus a store to proxy to.
+pub const Session = struct {
+ info: SessionInfo,
+ store: SessionStore,
+
+ /// Reconstruct the conversation. The id came from `resolve`/`latest`, so
+ /// the conversation must exist; a `null` from the store is promoted to
+ /// an error.
+ pub fn load(self: Session) !Conversation {
+ return (try self.store.load(self.info.id)) orelse error.SessionNotFound;
+ }
+
+ /// Append a batch of messages, proxying to the store. Takes `*Session`
+ /// for API symmetry and to allow future in-place `info` updates; today
+ /// it only updates the non-owning `api_style`/`reasoning` last-used
+ /// fields (the `base_url`/`model` strings stay the owned originals to
+ /// avoid aliasing borrowed config memory — resume picks the default
+ /// model rather than matching the stored wire identity, so the stale
+ /// display strings are harmless).
+ pub fn append(self: *Session, messages: []PersistentMessage) !void {
+ try self.store.appendMessages(self.info.id, messages);
+ if (messages.len > 0) {
+ const id = messages[messages.len - 1].identity;
+ self.info.api_style = id.api_style;
+ self.info.reasoning = id.reasoning;
+ }
+ }
};
/// A pluggable session-persistence backend.
@@ -82,59 +135,54 @@ pub const SessionStore = struct {
vtable: *const VTable,
pub const VTable = 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.
- ///
- /// 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,
- providers: []const ?[]const u8,
- models: []const ?[]const u8,
- ) anyerror!void,
+ /// Mint an in-memory session handle. Cannot fail: nothing hits the
+ /// backend until the first `appendMessages` (create-on-demand), so
+ /// no record exists before the first assistant message.
+ create: *const fn (ctx: *anyopaque) Session,
+
+ /// List known sessions, newest first. Caller frees via
+ /// `freeSessionInfos`.
+ list: *const fn (ctx: *anyopaque) anyerror![]SessionInfo,
+
+ /// Free a slice returned by `list`.
+ freeSessionInfos: *const fn (ctx: *anyopaque, infos: []SessionInfo) void,
- /// Reconstruct one linear `Conversation` from the store, with the
- /// dangling trailing user prompt (if any) split out. The returned
- /// `LoadedSession` owns its allocations against `alloc`.
- loadConversation: *const fn (
- ctx: *anyopaque,
- alloc: Allocator,
- ) anyerror!LoadedSession,
+ /// Resolve a (possibly abbreviated) id to a session, or null if no
+ /// match.
+ resolve: *const fn (ctx: *anyopaque, id: []const u8) anyerror!?Session,
- /// Opaque session id. Borrowed; lifetime owned by the store.
- sessionId: *const fn (ctx: *anyopaque) []const u8,
+ /// The most recent session, or null if none exist.
+ latest: *const fn (ctx: *anyopaque) anyerror!?Session,
- /// Provider/model last stamped on a user entry, or null if no user
- /// message has been recorded yet. Borrowed slices owned by the
- /// store.
- activeModel: *const fn (ctx: *anyopaque) ?ActiveModel,
+ /// Reconstruct one linear `Conversation` for `id`, or null if absent.
+ /// The returned `Conversation` self-describes its allocator.
+ load: *const fn (ctx: *anyopaque, id: []const u8) anyerror!?Conversation,
+
+ /// Append a batch atomically. A single append is a length-1 batch.
+ /// The store reads what it wants off each `PersistentMessage` and
+ /// is responsible for any de-duplication of provenance.
+ appendMessages: *const fn (ctx: *anyopaque, session_id: []const u8, messages: []PersistentMessage) anyerror!void,
};
- pub fn appendMessages(
- self: SessionStore,
- messages: []DiskMessage,
- providers: []const ?[]const u8,
- models: []const ?[]const u8,
- ) !void {
- return self.vtable.appendMessages(self.ptr, messages, providers, models);
+ pub fn create(self: SessionStore) Session {
+ return self.vtable.create(self.ptr);
}
-
- pub fn loadConversation(self: SessionStore, alloc: Allocator) !LoadedSession {
- return self.vtable.loadConversation(self.ptr, alloc);
+ pub fn list(self: SessionStore) ![]SessionInfo {
+ return self.vtable.list(self.ptr);
}
-
- pub fn sessionId(self: SessionStore) []const u8 {
- return self.vtable.sessionId(self.ptr);
+ pub fn freeSessionInfos(self: SessionStore, infos: []SessionInfo) void {
+ self.vtable.freeSessionInfos(self.ptr, infos);
}
-
- pub fn activeModel(self: SessionStore) ?ActiveModel {
- return self.vtable.activeModel(self.ptr);
+ pub fn resolve(self: SessionStore, id: []const u8) !?Session {
+ return self.vtable.resolve(self.ptr, id);
+ }
+ pub fn latest(self: SessionStore) !?Session {
+ return self.vtable.latest(self.ptr);
+ }
+ pub fn load(self: SessionStore, id: []const u8) !?Conversation {
+ return self.vtable.load(self.ptr, id);
+ }
+ pub fn appendMessages(self: SessionStore, session_id: []const u8, messages: []PersistentMessage) !void {
+ return self.vtable.appendMessages(self.ptr, session_id, messages);
}
};
diff --git a/libpanto/src/turn_persist.zig b/libpanto/src/turn_persist.zig
index a8b3faa..0147fbd 100644
--- a/libpanto/src/turn_persist.zig
+++ b/libpanto/src/turn_persist.zig
@@ -1,147 +1,86 @@
//! Turn → session-log persistence: map in-memory `Conversation` messages
-//! to neutral `DiskMessage`s and append them to a `SessionStore`.
+//! to rich `PersistentMessage` write records and append them through a
+//! `Session` handle.
//!
-//! 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.
+//! This logic 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 `Session`; 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.
+//! The write record is **maximalist** (full wire identity + usage + the
+//! entire current conversation + the offered tool set). The library offers
+//! it all on every append; each store keeps what it wants. `PersistentMessage`
+//! borrows the in-memory `Message` directly — no disk conversion happens
+//! here; the store does its own serialization.
+//!
+//! Per-message usage is read directly off `Message.usage`.
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;
+const PersistentMessage = session_store.PersistentMessage;
+const WireIdentity = session_store.WireIdentity;
+const ToolDecl = session_store.ToolDecl;
+const Session = session_store.Session;
-/// Persist every conversation message at index `>= start_index` to `store`
-/// as a single atomic batch.
+/// Persist every conversation message at index `>= start_index` through
+/// `session` 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).
+/// Each `PersistentMessage` borrows the in-memory `Message`, the full
+/// current conversation, and `tools` (the offered tool set) — all owned by
+/// the caller and valid for the duration of the call. The wire `identity`
+/// is stamped on every message; the store decides per-role what to keep
+/// (the FS store drops the stamp on system entries).
///
/// 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,
+ session: *Session,
conv: *const conversation.Conversation,
start_index: usize,
- provider: []const u8,
- model: []const u8,
+ identity: WireIdentity,
+ tools: []const ToolDecl,
) !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 batch: std.ArrayList(PersistentMessage) = .empty;
+ defer batch.deinit(alloc);
+ const all_messages = conv.messages.items;
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;
- }
+ while (i < all_messages.len) : (i += 1) {
+ const msg = all_messages[i];
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);
- },
- }
+ try batch.append(alloc, .{
+ .message = msg,
+ .usage = msg.usage,
+ .identity = identity,
+ .conversation = all_messages,
+ .tools_available = tools,
+ });
}
- if (batch_messages.items.len == 0) return;
- try store.appendMessages(batch_messages.items, batch_providers.items, batch_models.items);
+ if (batch.items.len == 0) return;
+ try session.append(batch.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).
+/// latest compaction summary onward as fresh entries.
pub fn persistCompaction(
alloc: Allocator,
- store: SessionStore,
+ session: *Session,
conv: *const conversation.Conversation,
- provider: []const u8,
- model: []const u8,
+ identity: WireIdentity,
+ tools: []const ToolDecl,
) !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;
+ try persistTurn(alloc, session, conv, start, identity, tools);
}
/// True when the assistant message at `index` contains a ToolUse block