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). --- libpanto/src/turn_persist.zig | 153 +++++++++++++----------------------------- 1 file changed, 46 insertions(+), 107 deletions(-) (limited to 'libpanto/src/turn_persist.zig') 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 -- cgit v1.3