diff options
| author | t <t@tjp.lol> | 2026-06-05 11:27:33 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-05 11:27:43 -0600 |
| commit | 03ac69658517a0054d2a573d1e1b60c1bfaaf977 (patch) | |
| tree | c38c4b009c98e96497db7482d04f803bf2ee78f9 /libpanto | |
| parent | 003908344336cc13e74618291aa9f3af137f030f (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.
Diffstat (limited to 'libpanto')
| -rw-r--r-- | libpanto/src/null_store.zig | 77 | ||||
| -rw-r--r-- | libpanto/src/root.zig | 2 | ||||
| -rw-r--r-- | libpanto/src/session_manager.zig | 202 | ||||
| -rw-r--r-- | libpanto/src/session_store.zig | 134 |
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); + } +}; |
