diff options
| author | t <t@tjp.lol> | 2026-07-07 11:26:32 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-07-07 11:26:45 -0600 |
| commit | f83578fdc9264019a1a1cef8c5484a161167d3dd (patch) | |
| tree | 888f11767f944d61e5ca8eb92fa1b2dba295a4b8 /src/turn_persist.zig | |
initial commit, moved libpanto over from the pantograph repo
Diffstat (limited to 'src/turn_persist.zig')
| -rw-r--r-- | src/turn_persist.zig | 133 |
1 files changed, 133 insertions, 0 deletions
diff --git a/src/turn_persist.zig b/src/turn_persist.zig new file mode 100644 index 0000000..6d260c8 --- /dev/null +++ b/src/turn_persist.zig @@ -0,0 +1,133 @@ +//! Turn → session-log persistence: map in-memory `Conversation` messages +//! to rich `PersistentMessage` write records and append them through a +//! `Session` handle. +//! +//! 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. +//! +//! 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_store = @import("session_store.zig"); + +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` through +/// `session` as a single atomic batch. +/// +/// 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. +pub fn persistTurn( + alloc: Allocator, + session: *Session, + conv: *conversation.Conversation, + start_index: usize, + identity: WireIdentity, + tools: []const ToolDecl, +) !void { + return persistRange(alloc, session, conv, start_index, conv.messages.items.len, identity, tools); +} + +/// Persist conversation messages in `[start_index, end_index)`. Like +/// `persistTurn` but with an explicit upper bound, so an incremental flush can +/// exclude a trailing not-yet-coherent message (a dangling tool call awaiting +/// its results) while still committing everything before it. +pub fn persistRange( + alloc: Allocator, + session: *Session, + conv: *conversation.Conversation, + start_index: usize, + end_index: usize, + identity: WireIdentity, + tools: []const ToolDecl, +) !void { + var batch: std.ArrayList(PersistentMessage) = .empty; + defer batch.deinit(alloc); + + const all_messages = conv.messages.items; + var i = start_index; + while (i < end_index) : (i += 1) { + const msg = &all_messages[i]; + if (msg.role == .assistant and hasToolUseWithoutFollowingResults(conv, i)) { + continue; + } + // Stamp the producing identity once, on first persist. A message that + // already carries one (e.g. a kept-verbatim turn carried through + // compaction, or one rebuilt from disk) keeps it — only the messages + // freshly produced under `identity` are stamped now. This is what + // makes the stamp survive a later compaction onto a different model. + if (msg.identity == null) { + msg.identity = try conversation.dupeWireIdentity(alloc, identity); + } + try batch.append(alloc, .{ + .message = msg.*, + .usage = msg.usage, + .identity = msg.identity.?, + .conversation = all_messages, + .tools_available = tools, + }); + } + + 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. +pub fn persistCompaction( + alloc: Allocator, + session: *Session, + conv: *conversation.Conversation, + identity: WireIdentity, + tools: []const ToolDecl, +) !void { + const start = conversation.latestCompactionIndex(conv.messages.items) orelse return; + try persistTurn(alloc, session, conv, start, identity, tools); +} + +/// True when the assistant message at `index` contains a ToolUse block +/// with no matching ToolResult in the immediately following user message +/// — i.e. a dangling tool call (e.g. a turn cut short by an error). +pub fn hasToolUseWithoutFollowingResults( + conv: *const conversation.Conversation, + index: usize, +) bool { + const msg = conv.messages.items[index]; + for (msg.content.items) |block| { + if (block != .ToolUse) continue; + if (index + 1 >= conv.messages.items.len) return true; + const next = conv.messages.items[index + 1]; + if (next.role != .user) return true; + var found = false; + for (next.content.items) |next_block| { + if (next_block == .ToolResult and std.mem.eql(u8, next_block.ToolResult.tool_use_id, block.ToolUse.id)) { + found = true; + break; + } + } + if (!found) return true; + } + return false; +} |
