summaryrefslogtreecommitdiff
path: root/libpanto/src/turn_persist.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-07 11:35:49 -0600
committert <t@tjp.lol>2026-06-07 11:35:49 -0600
commitb14859b9726185ab873356390068e887b7f486d3 (patch)
tree6530d8ee62e7639b50c99a67d23f89e25ef572dc /libpanto/src/turn_persist.zig
parentd36a51358efbc48de3d5b732727455efc60acae1 (diff)
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).
Diffstat (limited to 'libpanto/src/turn_persist.zig')
-rw-r--r--libpanto/src/turn_persist.zig153
1 files changed, 46 insertions, 107 deletions
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