From b14859b9726185ab873356390068e887b7f486d3 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 7 Jun 2026 11:35:49 -0600 Subject: R2: redesign session store with wire-format identity Replace the single-session SessionManager seam with a directory-backed catalog. session_manager.zig -> file_system_jsonl_store.zig; the old machinery becomes internal SessionFile, and a new FileSystemJSONLStore implements the redesigned SessionStore vtable (create/list/resolve/latest/ load/appendMessages) minting Session/SessionInfo handles. Session logs now record wire-format provider identity ({api_style, base_url, model, reasoning}) instead of a single provider string or CLI aliases; no api_key material is ever stored. Disk* content types renamed Stored*; the rich audit-oriented write record is PersistentMessage (in-memory Message + WireIdentity + provenance). Agent.init now takes a Session; persist_provider/persist_model display strings deleted (banner stays alias-based, resume picks default model). Message.metadata round-trips. Dangling-prompt recovery dropped. CLI migrated to the catalog store for run/resume/list. Clean break, no version bump (old logs wiped). --- src/main.zig | 102 ++++++++++++++++++--------------------------- src/subcommand.zig | 8 +++- src/system_prompt.zig | 113 +++++++++++++++++++++++++++----------------------- 3 files changed, 108 insertions(+), 115 deletions(-) (limited to 'src') diff --git a/src/main.zig b/src/main.zig index 46b6320..247817a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -329,48 +329,51 @@ pub fn main(init: std.process.Init) !void { }; const banner_provider_initial: []const u8 = model_ref.provider; + // The session store: a directory-backed JSONL catalog rooted at the + // per-cwd `session_dir` (the CLI owns the cwd→dir grouping; the store + // is cwd-agnostic). Must outlive the agent, which appends through a + // `Session` handle minted/resolved below. + var session_store_impl = try panto.session_manager.FileSystemJSONLStore.init(alloc, io, session_dir, cwd); + defer session_store_impl.deinit(); + const session_store = session_store_impl.store(); + // Create or resume the session. Resume failures (missing/ambiguous id) // are user errors — print a tidy message and exit 1 rather than // printing a Zig stack trace. - var session_mgr = openSession( - alloc, - io, - session_dir, - cwd, + var session = openSession( + session_store, cli_flags, + session_dir, stdout, &stdout_file, ) catch |err| switch (err) { error.SessionNotFound, error.AmbiguousSessionId => std.process.exit(1), else => return err, }; - defer session_mgr.deinit(); + // `session.info` is adopted by the agent below (`Agent.init` can't fail) + // and freed in the agent's `deinit`; no separate cleanup here. - const is_resume = session_mgr.getEntries().len > 0; + const is_resume = cli_flags.resume_kind != .none and session.info.message_count > 0; - // On resume, reconstruct the conversation from the store. The dangling - // user prompt (a trailing user entry with no assistant reply) is split - // out and currently ignored (a future TUI will prefill it for editing). - // On a fresh session, the agent starts with an empty conversation. + // On resume, reconstruct the conversation from the store. (The + // dangling-prompt recovery feature was dropped in R2.) On a fresh + // session, the agent starts with an empty conversation. var adopted_conversation: ?panto.conversation.Conversation = null; if (is_resume) { - var loaded = try session_mgr.loadConversation(alloc); - // We don't use the dangling prompt yet; free it. - if (loaded.dangling_user) |d| alloc.free(d); - loaded.dangling_user = null; - adopted_conversation = loaded.conversation; + adopted_conversation = try session.load(); + const sid = session.info.id; try stdout.print( - "resumed session {s} ({d} entries)\n", - .{ session_mgr.getSessionId()[0..@min(8, session_mgr.getSessionId().len)], session_mgr.getEntries().len }, + "resumed session {s} ({d} messages)\n", + .{ sid[0..@min(8, sid.len)], session.info.message_count }, ); } // Assemble the active configuration snapshot and build the agent now, // before luarocks bootstrap and extension loading. The agent adopts the - // store (for free persistence) and the resumed conversation (or starts - // fresh). The agent owns its own tool registry (post-R1); extensions - // register their tool source onto it *after* the agent is built but - // before the first turn, so in-place registration is visible. + // session handle (for free persistence) and the resumed conversation + // (or starts fresh). The agent owns its own tool registry (post-R1); + // extensions register their tool source onto it *after* the agent is + // built but before the first turn, so in-place registration is visible. // System-prompt seeding/reconciliation below runs *through* the agent // so those entries persist. const active_config: panto.config.Config = .{ @@ -381,12 +384,10 @@ pub fn main(init: std.process.Init) !void { alloc, io, &active_config, - session_mgr.store(), + session, adopted_conversation, ); defer agent.deinit(); - agent.persist_provider = banner_provider_initial; - agent.persist_model = banner_model_initial; // Spin up the long-lived Lua runtime. All Lua extensions load into // one `lua_State`; module-global state survives across calls. The @@ -642,54 +643,31 @@ fn parseAgentFlags(alloc: std.mem.Allocator, args: std.process.Args) !AgentFlags // Session bootstrap // ----------------------------------------------------------------------------- +/// Mint or resolve the `Session` to drive, against the catalog `store`. +/// Fresh sessions are created on demand; resume resolves by id or picks the +/// most recent. The returned `Session` owns its `info` (freed via the +/// agent's `deinit`, which adopts it). fn openSession( - alloc: std.mem.Allocator, - io: std.Io, - session_dir: []const u8, - cwd: []const u8, + store: panto.session_store.SessionStore, flags: AgentFlags, + session_dir: []const u8, stdout: *std.Io.Writer, stdout_file: *std.Io.File.Writer, -) !panto.session_manager.SessionManager { +) !panto.session_store.Session { switch (flags.resume_kind) { - .none => return try panto.session_manager.SessionManager.init( - alloc, - io, - session_dir, - cwd, - ), + .none => return store.create(), .most_recent => { - const path_opt = panto.session_manager.findMostRecentSession(alloc, io, session_dir) catch null; - if (path_opt) |path| { - defer alloc.free(path); - return try panto.session_manager.SessionManager.open(alloc, io, path); - } + if (try store.latest()) |sess| return sess; try stdout.print("no sessions to resume; starting fresh.\n", .{}); try stdout_file.flush(); - return try panto.session_manager.SessionManager.init( - alloc, - io, - session_dir, - cwd, - ); + return store.create(); }, .by_id => { const id = flags.resume_id.?; - const path = panto.session_manager.resolveSessionId(alloc, io, session_dir, id) catch |err| switch (err) { - error.SessionNotFound => { - try stdout.print("error: no session matching '{s}' in {s}\n", .{ id, session_dir }); - try stdout_file.flush(); - return err; - }, - error.AmbiguousSessionId => { - try stdout.print("error: session id '{s}' is ambiguous\n", .{id}); - try stdout_file.flush(); - return err; - }, - else => return err, - }; - defer alloc.free(path); - return try panto.session_manager.SessionManager.open(alloc, io, path); + if (try store.resolve(id)) |sess| return sess; + try stdout.print("error: no session matching '{s}' in {s}\n", .{ id, session_dir }); + try stdout_file.flush(); + return error.SessionNotFound; }, } } diff --git a/src/subcommand.zig b/src/subcommand.zig index c50429e..3ccd2c6 100644 --- a/src/subcommand.zig +++ b/src/subcommand.zig @@ -272,8 +272,12 @@ fn runSessionsSubcommand( const session_dir = try session_paths.sessionDirForCwd(allocator, environ_map, cwd); defer allocator.free(session_dir); - const infos = try panto.session_manager.listSessions(allocator, io, session_dir, null); - defer panto.session_manager.freeSessionInfos(allocator, infos); + var store_impl = try panto.session_manager.FileSystemJSONLStore.init(allocator, io, session_dir, cwd); + defer store_impl.deinit(); + const store = store_impl.store(); + + const infos = try store.list(); + defer store.freeSessionInfos(infos); var stdout_buffer: [4096]u8 = undefined; var stdout_file = std.Io.File.stdout().writer(io, &stdout_buffer); diff --git a/src/system_prompt.zig b/src/system_prompt.zig index 6104034..d8c06bb 100644 --- a/src/system_prompt.zig +++ b/src/system_prompt.zig @@ -462,29 +462,28 @@ const TmpLayers = struct { } }; -fn openTmpSession(arena: Allocator, root: []const u8) !panto.session_manager.SessionManager { +fn openTmpStore(arena: Allocator, root: []const u8) !panto.session_manager.FileSystemJSONLStore { const sessions = try std.fs.path.join(arena, &.{ root, "sessions" }); - return panto.session_manager.SessionManager.init(testing.allocator, testing.io, sessions, "/cwd"); + return panto.session_manager.FileSystemJSONLStore.init(testing.allocator, testing.io, sessions, "/cwd"); } /// Minimal agent harness for system-prompt tests: a throwaway provider -/// config wrapping a session store and (optionally) an adopted -/// conversation. Post-R1 the agent owns its own (empty) tool registry, so -/// the harness only holds the config to keep the agent's borrowed pointer -/// valid for its lifetime. +/// config plus a session (minted from / resolved against the catalog +/// store). Post-R1 the agent owns its own (empty) tool registry, so the +/// harness only holds the config to keep the agent's borrowed pointer valid. const SPAgentHarness = struct { config: panto.config.Config, agent: panto.agent.Agent, fn init( self: *SPAgentHarness, - store: panto.session_store.SessionStore, + session: panto.session_store.Session, adopted: ?panto.conversation.Conversation, ) void { self.config = .{ .provider = .{ .openai_chat = .{ .api_key = "k", .base_url = "u", .model = "m" } }, }; - self.agent = panto.agent.Agent.init(testing.allocator, testing.io, &self.config, store, adopted); + self.agent = panto.agent.Agent.init(testing.allocator, testing.io, &self.config, session, adopted); } fn deinit(self: *SPAgentHarness) void { @@ -492,17 +491,19 @@ const SPAgentHarness = struct { } }; -/// Force the session to flush to disk by appending an assistant entry. -/// (System/user entries buffer in memory until the first assistant entry; -/// a realistic resume only sees what a completed turn persisted.) -fn forceFlush(mgr: *panto.session_manager.SessionManager) !void { - const blocks = try mgr.allocator.alloc(panto.session_manager.DiskContentBlock, 1); - blocks[0] = .{ .text = .{ .text = try mgr.allocator.dupe(u8, "ok") } }; - _ = try mgr.appendMessage( - .{ .role = .assistant, .content = blocks }, - "prov", - "model", - ); +/// Force the session to flush to disk by appending an assistant message +/// through the agent's session. (System/user messages buffer in memory +/// until the first assistant message; a realistic resume only sees what a +/// completed turn persisted.) +fn forceFlush(agent: *panto.agent.Agent) !void { + var conv = panto.conversation.Conversation.init(testing.allocator); + defer conv.deinit(); + try conv.addAssistantMessage(&.{}); + const id: panto.session_store.WireIdentity = .{ .api_style = .openai_chat, .base_url = "u", .model = "m" }; + var batch = [_]panto.session_store.PersistentMessage{ + .{ .message = conv.messages.items[0], .identity = id }, + }; + try agent.session.append(&batch); } test "seedFresh then no-config-change resume is a no-op" { @@ -518,29 +519,39 @@ test "seedFresh then no-config-change resume is a no-op" { const project_dir = try layers.projectDir(arena); // Fresh session: seed + persist (through the agent). - var mgr = try openTmpSession(arena, layers.root); + var store_impl = try openTmpStore(arena, layers.root); + defer store_impl.deinit(); + const store = store_impl.store(); + var session_id_buf: [64]u8 = undefined; + var session_id_len: usize = 0; { var h: SPAgentHarness = undefined; - h.init(mgr.store(), null); + h.init(store.create(), null); defer h.deinit(); try seedFreshLayers(arena, testing.io, null, null, project_dir, &h.agent); + try forceFlush(&h.agent); // persist seed (+ assistant) to disk + @memcpy(session_id_buf[0..h.agent.session.info.id.len], h.agent.session.info.id); + session_id_len = h.agent.session.info.id.len; } - try forceFlush(&mgr); // persist seed + append (+ assistant) to disk - const entries_on_disk = mgr.getEntries().len; - try testing.expectEqual(@as(usize, 3), entries_on_disk); // seed + append + assistant - const session_file = try arena.dupe(u8, mgr.getSessionFile()); - mgr.deinit(); - - // Resume: reopen the file, adopt the rebuilt conversation + reconcile - // against unchanged config → no new entries. - var mgr2 = try panto.session_manager.SessionManager.open(alloc, testing.io, session_file); - defer mgr2.deinit(); - const conv2 = try mgr2.rebuildConversation(); + const session_id = session_id_buf[0..session_id_len]; + + // The persisted conversation reflects the seeded system prompt. + var conv_disk = (try store.load(session_id)).?; + const eff_disk = try panto.conversation.effectiveSystemBlocks(arena, conv_disk.messages.items); + const seeded_count = eff_disk.items.len; + conv_disk.deinit(); + try testing.expect(seeded_count > 0); + + // Resume: adopt the rebuilt conversation + reconcile against unchanged + // config → the effective system prompt is unchanged. + var sess2 = (try store.resolve(session_id)).?; + const conv2 = try sess2.load(); var h2: SPAgentHarness = undefined; - h2.init(mgr2.store(), conv2); + h2.init(sess2, conv2); defer h2.deinit(); try reconcileResumeLayers(arena, testing.io, null, null, project_dir, &h2.agent); - try testing.expectEqual(entries_on_disk, mgr2.getEntries().len); + const eff_after = try panto.conversation.effectiveSystemBlocks(arena, h2.agent.conversation.messages.items); + try testing.expectEqual(seeded_count, eff_after.items.len); } test "resume after config change appends replace + append sequence" { @@ -554,44 +565,44 @@ test "resume after config change appends replace + append sequence" { try layers.writeProject("SYSTEM.md", "old seed"); const project_dir = try layers.projectDir(arena); - var mgr = try openTmpSession(arena, layers.root); + var store_impl = try openTmpStore(arena, layers.root); + defer store_impl.deinit(); + const store = store_impl.store(); + var session_id_buf: [64]u8 = undefined; + var session_id_len: usize = 0; { var h: SPAgentHarness = undefined; - h.init(mgr.store(), null); + h.init(store.create(), null); defer h.deinit(); try seedFreshLayers(arena, testing.io, null, null, project_dir, &h.agent); + try forceFlush(&h.agent); // persist old seed (+ assistant) to disk + @memcpy(session_id_buf[0..h.agent.session.info.id.len], h.agent.session.info.id); + session_id_len = h.agent.session.info.id.len; } - try forceFlush(&mgr); // persist old seed (+ assistant) to disk - try testing.expectEqual(@as(usize, 2), mgr.getEntries().len); // seed + assistant - const session_file = try arena.dupe(u8, mgr.getSessionFile()); - mgr.deinit(); + const session_id = session_id_buf[0..session_id_len]; // Change the config: new seed + a new append. try layers.writeProject("SYSTEM.md", "new seed"); try layers.writeProject("APPEND_SYSTEM.md", "new append"); - var mgr2 = try panto.session_manager.SessionManager.open(alloc, testing.io, session_file); - defer mgr2.deinit(); - const conv2 = try mgr2.rebuildConversation(); + var sess2 = (try store.resolve(session_id)).?; + const conv2 = try sess2.load(); var h2: SPAgentHarness = undefined; - h2.init(mgr2.store(), conv2); + h2.init(sess2, conv2); defer h2.deinit(); try reconcileResumeLayers(arena, testing.io, null, null, project_dir, &h2.agent); - // old seed + assistant + replace(new seed) + append(new append) = 4. - try testing.expectEqual(@as(usize, 4), mgr2.getEntries().len); - // The effective prompt now reflects only the new config blocks. const eff = try panto.conversation.effectiveSystemBlocks(arena, h2.agent.conversation.messages.items); try testing.expectEqual(@as(usize, 2), eff.items.len); try testing.expectEqualStrings("new seed", eff.items[0]); try testing.expectEqualStrings("new append", eff.items[1]); - // A second no-op resume must not append again (anchors to the new - // `replace` window, not the stale original seed). - const after_first = mgr2.getEntries().len; + // A second no-op resume must not change the effective prompt (anchors + // to the new `replace` window, not the stale original seed). try reconcileResumeLayers(arena, testing.io, null, null, project_dir, &h2.agent); - try testing.expectEqual(after_first, mgr2.getEntries().len); + const eff2 = try panto.conversation.effectiveSystemBlocks(arena, h2.agent.conversation.messages.items); + try testing.expectEqual(@as(usize, 2), eff2.items.len); } test "Resolved.blocks - seed leads appends" { -- cgit v1.3