From fc77c3900ef80ff0e2934c03ca37dfc309a6e7b9 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 18 Jun 2026 11:47:07 -0600 Subject: fix compaction in the presence of consecutive user-role messages --- libpanto/src/compaction.zig | 85 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 6 deletions(-) (limited to 'libpanto/src/compaction.zig') diff --git a/libpanto/src/compaction.zig b/libpanto/src/compaction.zig index 3a1c012..dfa3242 100644 --- a/libpanto/src/compaction.zig +++ b/libpanto/src/compaction.zig @@ -247,7 +247,7 @@ pub fn computeSplit( while (i < messages.len) : (i += 1) { const msg = messages[i]; if (msg.role == .system) continue; - if (isTurnStart(msg)) { + if (isTurnStart(messages, i)) { if (n_turns < turn_starts_buf.len) { turn_starts_buf[n_turns] = i; } @@ -302,18 +302,52 @@ pub fn computeSplit( return .{ .prefix_end = first_turn_msg, .kept_turns = kept_turns }; } -/// A user message starts a new turn unless it carries any tool-result -/// block (in which case it's a continuation of the assistant's tool round -/// within the current turn). System and assistant messages never start a -/// turn. -fn isTurnStart(msg: Message) bool { +/// Whether the message at `index` begins a new turn. +/// +/// A turn starts at a user message that is both: +/// (a) not a tool-result message — a user message carrying any tool-result +/// block continues the assistant's current tool round, and +/// (b) the *first* user message after a non-user message (or the start of +/// the conversation). +/// +/// Condition (b) collapses a run of consecutive plain user messages into a +/// single turn: only the first is a boundary. Without it, each bare user +/// message would be its own turn, and the ones with no trailing assistant +/// would fall to the per-message word-count fallback and be counted a second +/// time on top of the turn's cumulative-usage delta (which already includes +/// them). System and assistant messages never start a turn; preceding system +/// messages are transparent for the "after a non-user message" test. +fn isTurnStart(messages: []const Message, index: usize) bool { + const msg = messages[index]; if (msg.role != .user) return false; for (msg.content.items) |block| { if (block == .ToolResult) return false; } + // First user message after a non-user message, or the start of the + // active window. Structural frame messages are transparent here: leading + // system prompts and the compaction-summary anchor (a synthetic `.user` + // message) are not conversational user turns, so a real user message + // following one still opens a turn. + var j = index; + while (j > 0) { + j -= 1; + if (isStructuralFrame(messages[j])) continue; + return messages[j].role != .user; + } return true; } +/// Whether a message is part of the structural frame rather than a +/// conversational turn: a system message, or the synthetic `.user` message +/// carrying a compaction summary that anchors an active window. +fn isStructuralFrame(msg: Message) bool { + if (msg.role == .system) return true; + for (msg.content.items) |block| { + if (block == .CompactionSummary) return true; + } + return false; +} + // ============================================================================= // Transcript serialization // ============================================================================= @@ -592,6 +626,45 @@ test "computeSplit - tool-result user messages attach to the current turn" { try testing.expectEqual(@as(usize, 1), split.kept_turns); } +test "computeSplit - consecutive user messages collapse into a single turn" { + const a = testing.allocator; + // Three plain user messages in a row open ONE turn, not three: only the + // first follows a non-user message; the others are continuations. + var msgs = [_]Message{ + try userMsg(a, "a"), + try userMsg(a, "b"), + try userMsg(a, "c"), + try asstMsg(a, "a1"), + try userMsg(a, "q2"), + try asstMsg(a, "a2"), + }; + defer freeMsgs(a, &msgs); + + // Whole conversation fits: nothing is summarized and the leading run of + // user messages counts as a single turn, so kept_turns == 2 (not 4). + { + const split = computeSplit(&msgs, null, 1_000_000); + try testing.expectEqual(@as(usize, 0), split.prefix_end); + try testing.expectEqual(@as(usize, 2), split.kept_turns); + } + + // With usage, the first turn's cumulative delta already accounts for + // a+b+c+a1, so the per-message fallback must not charge a/b a second + // time. Turn deltas: turn 1 = cumAt(a1) - 0 = 400; turn 2 = cumAt(a2) - + // cumAt(a1) = 600 - 400 = 200. Budget 250 keeps only turn 2 and + // summarizes the entire collapsed first turn, so the kept suffix begins + // at q2 (index 4). + { + const usages = [_]?Usage{ + .{ .input = 0 }, .{ .input = 0 }, .{ .input = 0 }, .{ .input = 300, .output = 100 }, + .{ .input = 0 }, .{ .input = 500, .output = 100 }, + }; + const split = computeSplit(&msgs, &usages, 250); + try testing.expectEqual(@as(usize, 4), split.prefix_end); + try testing.expectEqual(@as(usize, 1), split.kept_turns); + } +} + test "computeSplit - active window starts after an existing compaction summary" { const a = testing.allocator; var msgs = [_]Message{ -- cgit v1.3