From 8b88b886346460b1ab29edb03ad85bc3bb565a45 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 2 Jun 2026 15:00:44 -0600 Subject: compaction --- src/session_persist.zig | 153 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/session_persist.zig (limited to 'src/session_persist.zig') diff --git a/src/session_persist.zig b/src/session_persist.zig new file mode 100644 index 0000000..d5349bc --- /dev/null +++ b/src/session_persist.zig @@ -0,0 +1,153 @@ +//! Session-log persistence helpers shared between the REPL loop +//! (`main.zig`) and slash-command handlers (e.g. `compaction.zig`). +//! +//! These were previously private to `main.zig`. They were lifted into +//! their own module so that command handlers living outside the root +//! source file can reuse them without depending on `main.zig` (which, +//! being the executable root, can't be imported as a normal module). + +const std = @import("std"); +const panto = @import("panto"); + +/// Append a single user-prompt message to the session log. +pub fn appendUserPromptToSession( + alloc: std.mem.Allocator, + mgr: *panto.session_manager.SessionManager, + text: []const u8, + provider: []const u8, + model: []const u8, +) !void { + const blocks = try alloc.alloc(panto.session_manager.DiskContentBlock, 1); + blocks[0] = .{ .text = .{ .text = try alloc.dupe(u8, text) } }; + _ = try mgr.appendMessage( + .{ .role = .user, .content = blocks }, + provider, + model, + ); +} + +/// After the agent loop has driven a turn to completion, persist every +/// new in-memory message at index `>= start_index` to the session log. +/// +/// Each in-memory message is mapped to disk: +/// - assistant → assistant entry with provider/model/stop_reason metadata +/// and Usage (from `per_message_usage`, one slot per assistant message in +/// completion order). +/// - user (with ToolResult blocks) → user entry stamped with provider/model. +/// +/// `stop_reason` is recorded as `"stop"` for now; surfacing the real wire +/// value requires plumbing it through the Receiver vtable (future work, +/// separate from token plumbing). +pub fn persistTurn( + alloc: std.mem.Allocator, + mgr: *panto.session_manager.SessionManager, + conv: *const panto.conversation.Conversation, + start_index: usize, + provider: []const u8, + model: []const u8, + per_message_usage: []const ?panto.session_manager.Usage, +) !void { + var batch_messages: std.ArrayList(panto.session_manager.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; + var assistant_seen: usize = 0; + while (i < conv.messages.items.len) : (i += 1) { + const msg = conv.messages.items[i]; + const blocks = try alloc.alloc(panto.session_manager.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 panto.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 => { + // Mid-turn system messages aren't a thing in pantograph today. + // Treat as harmless and persist verbatim, sans stamps. + try batch_messages.append(alloc, .{ .role = .system, .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 => { + // Pair this assistant message with its usage, if the + // receiver captured one for this position. (A turn + // ending in a stream error can have fewer usage entries + // than assistant messages; treat as null and move on.) + const usage: ?panto.session_manager.Usage = if (assistant_seen < per_message_usage.len) + per_message_usage[assistant_seen] + else + null; + assistant_seen += 1; + 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 = usage, + }); + try batch_providers.append(alloc, null); + try batch_models.append(alloc, null); + }, + } + } + try mgr.appendMessagesAtomic(batch_messages.items, batch_providers.items, batch_models.items); +} + +/// Persist a compaction to the session log. The agent rewrote the +/// in-memory conversation to `[system..., summary, kept-suffix...]`; the +/// superseded prefix stays on disk untouched. We append everything from +/// the compaction summary onward as fresh entries: the summary message +/// (carrying the `compactionSummary` block) followed by the duplicated +/// kept-verbatim suffix. On replay, the latest summary resets effective +/// context, so the duplicated suffix is what survives. +/// +/// Usage is not threaded through here: the duplicated entries are replays +/// of already-sized turns, and the summary itself isn't an assistant turn. +pub fn persistCompaction( + alloc: std.mem.Allocator, + mgr: *panto.session_manager.SessionManager, + conv: *const panto.conversation.Conversation, + provider: []const u8, + model: []const u8, +) !void { + const start = panto.conversation.latestCompactionIndex(conv.messages.items) orelse return; + try persistTurn(alloc, mgr, conv, start, provider, model, &.{}); +} + +pub fn hasToolUseWithoutFollowingResults(conv: *const panto.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; +} -- cgit v1.3