summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-05 11:27:33 -0600
committert <t@tjp.lol>2026-06-05 11:27:43 -0600
commit03ac69658517a0054d2a573d1e1b60c1bfaaf977 (patch)
treec38c4b009c98e96497db7482d04f803bf2ee78f9
parent003908344336cc13e74618291aa9f3af137f030f (diff)
Add pluggable SessionStore interface with FSJSONL + Null backends
Introduce a neutral persistence seam (session_store.zig) following the {ptr, vtable} shape of the other libpanto seams. The interface traffics in DiskMessage so non-JSONL backends (e.g. Postgres) can implement it. - session_store.zig: SessionStore vtable (appendMessages, loadConversation, sessionId, activeModel), LoadedSession{conversation, dangling_user}, re-exported disk types, and an FSJSONLStore alias. - session_manager.zig: add store() wrapper + loadConversation() with dangling trailing-user detection (excluded from the rebuilt conversation, returned as dangling_user). - null_store.zig: no-op backend, stateless singleton. - root.zig: export session_store + null_store. Phase 1+2 of docs/pluggable-session-store.md. Behavior-preserving; CLI not yet rewired.
-rw-r--r--libpanto/src/null_store.zig77
-rw-r--r--libpanto/src/root.zig2
-rw-r--r--libpanto/src/session_manager.zig202
-rw-r--r--libpanto/src/session_store.zig134
4 files changed, 411 insertions, 4 deletions
diff --git a/libpanto/src/null_store.zig b/libpanto/src/null_store.zig
new file mode 100644
index 0000000..a9bd813
--- /dev/null
+++ b/libpanto/src/null_store.zig
@@ -0,0 +1,77 @@
+//! `NullStore`: a no-op `SessionStore` for embedders who opt out of
+//! persistence (and the default backing for an `Agent` constructed without
+//! an explicit store).
+//!
+//! Every append is dropped. `loadConversation` returns an empty
+//! conversation with no dangling prompt. `activeModel` is null and the
+//! session id is the empty string. The store is stateless, so a single
+//! process-global instance backs every `NullStore` handle.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+
+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;
+
+/// Stateless singleton context. The vtable ignores `ctx` entirely; we hand
+/// out a pointer to this so the `*anyopaque` is always valid.
+var singleton: u8 = 0;
+
+fn appendMessagesVT(
+ _: *anyopaque,
+ _: []DiskMessage,
+ _: []const ?[]const u8,
+ _: []const ?[]const u8,
+) anyerror!void {}
+
+fn loadConversationVT(_: *anyopaque, alloc: Allocator) anyerror!LoadedSession {
+ return .{
+ .conversation = conversation_mod.Conversation.init(alloc),
+ .dangling_user = null,
+ };
+}
+
+fn sessionIdVT(_: *anyopaque) []const u8 {
+ return "";
+}
+
+fn activeModelVT(_: *anyopaque) ?ActiveModel {
+ return null;
+}
+
+const vtable: SessionStore.VTable = .{
+ .appendMessages = appendMessagesVT,
+ .loadConversation = loadConversationVT,
+ .sessionId = sessionIdVT,
+ .activeModel = activeModelVT,
+};
+
+/// A no-op `SessionStore`. Cheap to call repeatedly; all handles share one
+/// stateless context.
+pub fn store() SessionStore {
+ return .{ .ptr = &singleton, .vtable = &vtable };
+}
+
+const testing = std.testing;
+
+test "NullStore: appends are dropped and load returns empty" {
+ var msg_content = try testing.allocator.alloc(session_store_mod.DiskContentBlock, 1);
+ msg_content[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi") } };
+ var messages = [_]DiskMessage{.{ .role = .user, .content = msg_content }};
+ defer messages[0].deinit(testing.allocator);
+
+ const s = store();
+ try s.appendMessages(&messages, &.{null}, &.{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());
+}
diff --git a/libpanto/src/root.zig b/libpanto/src/root.zig
index 3f5549c..39797dd 100644
--- a/libpanto/src/root.zig
+++ b/libpanto/src/root.zig
@@ -10,6 +10,8 @@ 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_store = @import("session_store.zig");
+pub const null_store = @import("null_store.zig");
pub const pricing = @import("pricing.zig");
pub const compaction = @import("compaction.zig");
pub const image = @import("image.zig");
diff --git a/libpanto/src/session_manager.zig b/libpanto/src/session_manager.zig
index bbc996b..b659506 100644
--- a/libpanto/src/session_manager.zig
+++ b/libpanto/src/session_manager.zig
@@ -31,6 +31,7 @@ const Io = std.Io;
const session_mod = @import("session.zig");
const conversation_mod = @import("conversation.zig");
+const session_store_mod = @import("session_store.zig");
pub const SessionHeader = session_mod.SessionHeader;
pub const SessionEntry = session_mod.SessionEntry;
@@ -714,8 +715,110 @@ 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 {
+ for (msg.content) |block| {
+ if (block == .text) {
+ return try alloc.dupe(u8, block.text.text);
+ }
+ }
+ return null;
+}
+
fn appendMessageToConv(
conv: *conversation_mod.Conversation,
allocator: Allocator,
@@ -1507,14 +1610,14 @@ test "SessionManager: tool-use round-trip — assistant w/ ToolUse, user w/ Tool
_ = try mgr.appendMessage(.{ .role = .user, .content = u }, "openai", "gpt-4o");
// Assistant emits a ToolUse.
- const a1 = try testing.allocator.alloc(DiskContentBlock, 2);
- a1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "checking...") } };
- a1[1] = .{ .tool_use = .{
+ const am1 = try testing.allocator.alloc(DiskContentBlock, 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 = a1 }, null, null);
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = am1 }, null, null);
// Tool-result user message.
const tr = try testing.allocator.alloc(DiskContentBlock, 1);
@@ -1690,3 +1793,94 @@ test "compaction summary round-trips through persist + resume + rebuild" {
try testing.expectEqual(@as(usize, 1), sys_blocks.items.len);
try testing.expectEqualStrings("you are helpful", sys_blocks.items[0]);
}
+
+test "loadConversation: trailing user prompt is split out as dangling, excluded from conversation" {
+ 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");
+ 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);
+ 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);
+ const um2 = try testing.allocator.alloc(DiskContentBlock, 1);
+ um2[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "what's 2+2?") } };
+ _ = try mgr.appendMessage(.{ .role = .user, .content = um2 }, "openai", "gpt-4o");
+
+ 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);
+}
+
+test "loadConversation: no dangling prompt when log ends with assistant" {
+ 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");
+ 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 mgr = try SessionManager.init(testing.allocator, io, sessions, "/c");
+ defer mgr.deinit();
+
+ const s = mgr.store();
+
+ // 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 };
+
+ try s.appendMessages(messages, &.{ "openai", null }, &.{ "gpt-4o", null });
+ try testing.expectEqual(@as(usize, 2), mgr.getEntries().len);
+
+ const am = s.activeModel().?;
+ try testing.expectEqualStrings("openai", am.provider);
+ try testing.expectEqualStrings("gpt-4o", am.model);
+ try testing.expectEqualStrings(mgr.getSessionId(), s.sessionId());
+}
diff --git a/libpanto/src/session_store.zig b/libpanto/src/session_store.zig
new file mode 100644
index 0000000..83bf136
--- /dev/null
+++ b/libpanto/src/session_store.zig
@@ -0,0 +1,134 @@
+//! `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 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.
+//!
+//! ## What lives here vs. on the concrete backend
+//!
+//! 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.
+//!
+//! 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.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+
+const session_mod = @import("session.zig");
+const conversation_mod = @import("conversation.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;
+
+/// 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 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,
+
+ pub fn deinit(self: *LoadedSession, alloc: Allocator) void {
+ self.conversation.deinit();
+ if (self.dangling_user) |d| alloc.free(d);
+ }
+};
+
+/// The active provider/model, as last stamped on a user entry.
+pub const ActiveModel = struct {
+ provider: []const u8,
+ model: []const u8,
+};
+
+/// A pluggable session-persistence backend.
+pub const SessionStore = struct {
+ ptr: *anyopaque,
+ 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. Implementations consume nothing — the caller
+ /// retains ownership of `messages` and frees them after return.
+ appendMessages: *const fn (
+ ctx: *anyopaque,
+ messages: []DiskMessage,
+ providers: []const ?[]const u8,
+ models: []const ?[]const u8,
+ ) anyerror!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,
+
+ /// Opaque session id. Borrowed; lifetime owned by the store.
+ sessionId: *const fn (ctx: *anyopaque) []const u8,
+
+ /// 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,
+ };
+
+ 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 loadConversation(self: SessionStore, alloc: Allocator) !LoadedSession {
+ return self.vtable.loadConversation(self.ptr, alloc);
+ }
+
+ pub fn sessionId(self: SessionStore) []const u8 {
+ return self.vtable.sessionId(self.ptr);
+ }
+
+ pub fn activeModel(self: SessionStore) ?ActiveModel {
+ return self.vtable.activeModel(self.ptr);
+ }
+};