summaryrefslogtreecommitdiff
path: root/libpanto/src/turn_persist.zig
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto/src/turn_persist.zig')
-rw-r--r--libpanto/src/turn_persist.zig170
1 files changed, 170 insertions, 0 deletions
diff --git a/libpanto/src/turn_persist.zig b/libpanto/src/turn_persist.zig
new file mode 100644
index 0000000..a8b3faa
--- /dev/null
+++ b/libpanto/src/turn_persist.zig
@@ -0,0 +1,170 @@
+//! Turn → session-log persistence: map in-memory `Conversation` messages
+//! to neutral `DiskMessage`s and append them to a `SessionStore`.
+//!
+//! 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.
+//!
+//! 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.
+
+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;
+
+/// Persist every conversation message at index `>= start_index` to `store`
+/// 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).
+///
+/// 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,
+ conv: *const conversation.Conversation,
+ start_index: usize,
+ provider: []const u8,
+ model: []const u8,
+) !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 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;
+ }
+ 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);
+ },
+ }
+ }
+
+ if (batch_messages.items.len == 0) return;
+ try store.appendMessages(batch_messages.items, batch_providers.items, batch_models.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).
+pub fn persistCompaction(
+ alloc: Allocator,
+ store: SessionStore,
+ conv: *const conversation.Conversation,
+ provider: []const u8,
+ model: []const u8,
+) !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;
+}
+
+/// 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;
+}