//! 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: *const conversation.Conversation, start_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 < all_messages.len) : (i += 1) { const msg = all_messages[i]; if (msg.role == .assistant and hasToolUseWithoutFollowingResults(conv, i)) { continue; } try batch.append(alloc, .{ .message = msg, .usage = msg.usage, .identity = 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: *const 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; }