summaryrefslogtreecommitdiff
path: root/libpanto
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-18 11:47:07 -0600
committert <t@tjp.lol>2026-06-18 11:47:14 -0600
commitfc77c3900ef80ff0e2934c03ca37dfc309a6e7b9 (patch)
tree05fd40a4a734c02006ce23d66680c6f13a039d9c /libpanto
parente1ed3d82446defe127f87c90364b51bbd78f1eda (diff)
fix compaction in the presence of consecutive user-role messages
Diffstat (limited to 'libpanto')
-rw-r--r--libpanto/src/compaction.zig85
1 files changed, 79 insertions, 6 deletions
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{