summaryrefslogtreecommitdiff
path: root/libpanto/src/null_store.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-05 11:43:29 -0600
committert <t@tjp.lol>2026-06-05 11:43:43 -0600
commitccdaef8e682960ecadf73d3b2df70559a0baaf56 (patch)
tree2a6e455ca3359b68bf5cb5b83015054bd6e2eb6f /libpanto/src/null_store.zig
parent03ac69658517a0054d2a573d1e1b60c1bfaaf977 (diff)
Move session persistence into the Agent; collapse CLI plumbing
The Agent now owns its Conversation and a SessionStore, and persists everything it generates as turns progress. Embedders get persistence for free; the CLI no longer drives the session log. libpanto: - Agent.init takes a SessionStore + optional Conversation to adopt (resume path). Agent owns/tears down the conversation; turn-driving methods drop the *Conversation param and operate on self.conversation. - New Agent methods: submitUserMessage (adds+persists the user prompt immediately), addSystemMessage(text, mode), runStep persists the turn on every exit path (incl. partial turns on error), compactAndPersist for the explicit /compact path. Auto-compaction persists its window via runStep's tail. - turn_persist.zig: DiskMessage mapping moved out of the CLI; reads usage off Message.usage (no per-message-usage list). System mode rides on DiskMessage.mode, derived from the System block. - NullStore is now an allocator-bearing struct (frees consumed messages per the store-consumes-messages contract). - Tests: in-memory CapturingStore round-trip + NullStore turn test. panto CLI: - Delete src/session_persist.zig. REPL is submitUserMessage + runStep. - Reorder construction: store -> registry -> agent -> bootstrap -> seed/ reconcile system prompt through the agent -> load extensions. - command.Context drops conv/session_mgr; commands reach ctx.agent. - system_prompt seed/reconcile call agent.addSystemMessage. - CLIReceiver is display-only (per_message_usage removed). Phases 3-5 of docs/pluggable-session-store.md. Behavior preserved; full build + test suite green.
Diffstat (limited to 'libpanto/src/null_store.zig')
-rw-r--r--libpanto/src/null_store.zig97
1 files changed, 56 insertions, 41 deletions
diff --git a/libpanto/src/null_store.zig b/libpanto/src/null_store.zig
index a9bd813..d2337ee 100644
--- a/libpanto/src/null_store.zig
+++ b/libpanto/src/null_store.zig
@@ -1,11 +1,16 @@
//! `NullStore`: a no-op `SessionStore` for embedders who opt out of
//! persistence (and the default backing for an `Agent` constructed without
-//! an explicit store).
+//! a real store).
//!
-//! Every append is dropped. `loadConversation` returns an empty
-//! conversation with no dangling prompt. `activeModel` is null and the
-//! session id is the empty string. The store is stateless, so a single
-//! process-global instance backs every `NullStore` handle.
+//! Every append is dropped (but its messages are freed, honoring the store
+//! ownership contract — appends consume their messages). `loadConversation`
+//! returns an empty conversation with no dangling prompt. `activeModel` is
+//! null and the session id is the empty string.
+//!
+//! Because freeing the dropped messages needs an allocator, `NullStore`
+//! carries one. Construct it with `init(alloc)` and call `.store()` for the
+//! interface handle. The struct is trivially copyable and holds no other
+//! state, so the handle may borrow it for the agent's lifetime.
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -18,54 +23,64 @@ const LoadedSession = session_store_mod.LoadedSession;
const DiskMessage = session_store_mod.DiskMessage;
const ActiveModel = session_store_mod.ActiveModel;
-/// Stateless singleton context. The vtable ignores `ctx` entirely; we hand
-/// out a pointer to this so the `*anyopaque` is always valid.
-var singleton: u8 = 0;
+pub const NullStore = struct {
+ allocator: Allocator,
-fn appendMessagesVT(
- _: *anyopaque,
- _: []DiskMessage,
- _: []const ?[]const u8,
- _: []const ?[]const u8,
-) anyerror!void {}
+ pub fn init(allocator: Allocator) NullStore {
+ return .{ .allocator = allocator };
+ }
-fn loadConversationVT(_: *anyopaque, alloc: Allocator) anyerror!LoadedSession {
- return .{
- .conversation = conversation_mod.Conversation.init(alloc),
- .dangling_user = null,
- };
-}
+ fn appendMessagesVT(
+ ctx: *anyopaque,
+ messages: []DiskMessage,
+ _: []const ?[]const u8,
+ _: []const ?[]const u8,
+ ) anyerror!void {
+ const self: *NullStore = @ptrCast(@alignCast(ctx));
+ // Appends consume their messages; we drop them, so free them.
+ for (messages) |m| m.deinit(self.allocator);
+ }
-fn sessionIdVT(_: *anyopaque) []const u8 {
- return "";
-}
+ fn loadConversationVT(ctx: *anyopaque, alloc: Allocator) anyerror!LoadedSession {
+ _ = ctx;
+ return .{
+ .conversation = conversation_mod.Conversation.init(alloc),
+ .dangling_user = null,
+ };
+ }
-fn activeModelVT(_: *anyopaque) ?ActiveModel {
- return null;
-}
+ fn sessionIdVT(_: *anyopaque) []const u8 {
+ return "";
+ }
-const vtable: SessionStore.VTable = .{
- .appendMessages = appendMessagesVT,
- .loadConversation = loadConversationVT,
- .sessionId = sessionIdVT,
- .activeModel = activeModelVT,
-};
+ fn activeModelVT(_: *anyopaque) ?ActiveModel {
+ return null;
+ }
-/// A no-op `SessionStore`. Cheap to call repeatedly; all handles share one
-/// stateless context.
-pub fn store() SessionStore {
- return .{ .ptr = &singleton, .vtable = &vtable };
-}
+ const vtable: SessionStore.VTable = .{
+ .appendMessages = appendMessagesVT,
+ .loadConversation = loadConversationVT,
+ .sessionId = sessionIdVT,
+ .activeModel = activeModelVT,
+ };
+
+ /// Wrap this `NullStore` as a `SessionStore`. The handle borrows
+ /// `self`; `self` must outlive it.
+ pub fn store(self: *NullStore) SessionStore {
+ return .{ .ptr = self, .vtable = &vtable };
+ }
+};
const testing = std.testing;
-test "NullStore: appends are dropped and load returns empty" {
+test "NullStore: appends are dropped (and freed) and load returns empty" {
+ var ns = NullStore.init(testing.allocator);
+ const s = ns.store();
+
var msg_content = try testing.allocator.alloc(session_store_mod.DiskContentBlock, 1);
msg_content[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi") } };
var messages = [_]DiskMessage{.{ .role = .user, .content = msg_content }};
- defer messages[0].deinit(testing.allocator);
-
- const s = store();
+ // No defer-free: the store consumes (frees) the message.
try s.appendMessages(&messages, &.{null}, &.{null});
var loaded = try s.loadConversation(testing.allocator);