From 621d7fee0ace729f8d44126032d2c6e13f72ee7f Mon Sep 17 00:00:00 2001 From: t Date: Tue, 7 Jul 2026 11:29:23 -0600 Subject: Move libpanto projects to libpantograph dependency Remove the in-repo libpanto sources and binding projects from pantograph. Consume libpantograph through the Zig package URL at code.tjp.lol/libpantograph.git, including the Lua module artifact used by CLI extensions. --- libpanto/src/compaction.zig | 838 -------------------------------------------- 1 file changed, 838 deletions(-) delete mode 100644 libpanto/src/compaction.zig (limited to 'libpanto/src/compaction.zig') diff --git a/libpanto/src/compaction.zig b/libpanto/src/compaction.zig deleted file mode 100644 index f620f30..0000000 --- a/libpanto/src/compaction.zig +++ /dev/null @@ -1,838 +0,0 @@ -//! Compaction sizing and retention policy. -//! -//! Compaction reduces context size by summarizing an older prefix of the -//! conversation into a synthetic seed (a `.CompactionSummary` block) and -//! keeping a recent suffix of whole turns verbatim. This module owns the -//! *policy* half — deciding where to split prefix-to-summarize from -//! suffix-to-keep — and the transcript serialization of the prefix. The -//! *execution* half (running the compaction request, mutating the -//! conversation) lives in `agent.zig`. -//! -//! ## Retention model -//! -//! The retention unit is a whole **turn**: a user message followed by the -//! assistant message(s) it elicited (including any interleaved tool-result -//! user messages that belong to the same request/response cycle). v1 never -//! splits a turn. -//! -//! Splitting walks the active conversation backward, accumulating a token -//! estimate per turn, and stops as soon as the running total *exceeds* -//! `keep_verbatim`. The turn that crosses the threshold is the last turn -//! folded into the summary; turns after it are kept verbatim. So -//! `keep_verbatim` is an upper bound on the kept-suffix size, not a target. -//! -//! ## Sizing -//! -//! When provider-reported `Usage` is available for a message, its token -//! total drives sizing. Otherwise we fall back to a cheap monotonic -//! heuristic: word count scaled by `word_to_token_factor`. The fallback is -//! not exact but is sufficient to choose a conservative recent suffix. - -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const conversation = @import("conversation.zig"); -const session_mod = @import("session.zig"); - -pub const Message = conversation.Message; -pub const Usage = session_mod.Usage; - -/// Multiplier applied to a word count to approximate token count when no -/// provider usage data is available. Tokens-per-word runs ~1.3 for English -/// prose and code on common BPE tokenizers (rough, deliberately so). -pub const word_to_token_factor: f64 = 1.3; - -/// Estimate the token size of a single message. -/// -/// If `usage` is non-null, its prompt+output token total is used directly. -/// Otherwise the message's textual content is word-counted and scaled by -/// `word_to_token_factor`. -pub fn messageTokenEstimate(msg: Message, usage: ?Usage) u64 { - if (usage) |u| { - // Total tokens attributable to this message: all input categories - // plus output. (For a user message only inputs are typically set; - // for an assistant message output dominates.) - return u.input + u.cache_read + u.cache_write + u.output; - } - const words = countWords(msg); - const est = @as(f64, @floatFromInt(words)) * word_to_token_factor; - return @intFromFloat(@ceil(est)); -} - -/// The full prompt size (all input categories) reported by a turn's usage. -/// Provider `input` may exclude cached tokens, which appear under -/// `cache_read`/`cache_write`; summing them recovers the true context size -/// that was sent to the model. -fn promptTokens(u: Usage) u64 { - return u.input + u.cache_read + u.cache_write; -} - -/// The cumulative context footprint at a turn's final assistant message: -/// the full prompt it consumed plus the output it produced. This is the -/// running total of *all* prior conversation tokens as of that turn. -fn cumulativeAt(u: Usage) u64 { - return promptTokens(u) + u.output; -} - -/// Estimate the incremental token cost of the turn spanning -/// `messages[start..end)` using cumulative provider usage. -/// -/// Provider `usage.input` is *cumulative*: it is the entire prompt sent for -/// that turn (the whole prior conversation), not the size of one message. -/// So a turn's own contribution is the delta between its final assistant's -/// cumulative footprint and the previous turn's: -/// -/// cumulativeAt(this_turn_last_assistant) -/// - cumulativeAt(prev_turn_last_assistant) -/// -/// This single delta captures the turn-start user prompt, every -/// tool-result, every intermediate assistant output, and the final -/// assistant output at once — no per-message decomposition needed. At -/// session start there is no previous assistant, so `before` is zero and -/// the first turn's cost is its own full cumulative footprint (which fairly -/// includes the system prompt). -/// -/// `prev_usage` is the usage of the assistant message immediately before -/// `start` (the previous turn's final assistant), or null at session start. -/// -/// Returns null when usage data is insufficient (no usage on this turn's -/// assistant), signalling the caller to fall back to word counting. -fn turnTokenEstimate( - messages: []const Message, - usages: []const ?Usage, - start: usize, - end: usize, - prev_usage: ?Usage, -) ?u64 { - // Find this turn's final assistant usage (cumulative as of turn end). - var after: ?Usage = null; - var j = end; - while (j > start) { - j -= 1; - if (messages[j].role == .assistant) { - if (usages[j]) |u| { - after = u; - break; - } - } - } - const after_usage = after orelse return null; - const after_total = cumulativeAt(after_usage); - const before_total = if (prev_usage) |b| cumulativeAt(b) else 0; - // Saturating subtraction: usage is an estimate and can be non-monotone. - return if (after_total > before_total) after_total - before_total else 0; -} - -/// Find the usage of the assistant message immediately before index -/// `start`, scanning backward. Returns null if none precedes it. -fn priorAssistantUsage( - messages: []const Message, - usages: []const ?Usage, - start: usize, -) ?Usage { - var j = start; - while (j > 0) { - j -= 1; - if (messages[j].role == .assistant) { - if (usages[j]) |u| return u; - } - } - return null; -} - -/// Count whitespace-delimited words across all textual content in a -/// message. Tool-use input JSON and tool-result content count too — they -/// occupy real context. -fn countWords(msg: Message) u64 { - var total: u64 = 0; - for (msg.content.items) |block| { - switch (block) { - .Text => |b| total += countWordsIn(b.items), - .Thinking => |b| total += countWordsIn(b.text.items), - .ToolUse => |b| total += countWordsIn(b.input.items), - .ToolResult => |b| { - // Text parts count by words. Media parts (base64 image / - // PDF blobs) would wildly distort the retention window if - // counted by length, so each is charged a fixed estimate - // (matching the pi reference impl's ~4800-char image cost, - // divided back out to words via `word_to_token_factor`). - for (b.parts.items) |p| switch (p) { - .text => |t| total += countWordsIn(t.items), - .media => total += media_part_word_estimate, - }; - }, - .System => |b| total += countWordsIn(b.text.items), - .CompactionSummary => |b| total += countWordsIn(b.text.items), - } - } - return total; -} - -/// Fixed per-media-part word estimate. The pi reference impl sizes each -/// inline image at ~4800 characters for compaction token math; expressed -/// as words (≈4800/6 chars-per-word) this lands near 800. -const media_part_word_estimate: u64 = 800; - -fn countWordsIn(text: []const u8) u64 { - var count: u64 = 0; - var in_word = false; - for (text) |c| { - const is_space = c == ' ' or c == '\t' or c == '\n' or c == '\r'; - if (is_space) { - in_word = false; - } else if (!in_word) { - in_word = true; - count += 1; - } - } - return count; -} - -/// The result of a retention split over a conversation. -pub const Split = struct { - /// Index into the *full* message list. Messages in `[0, prefix_end)` - /// are summarized; messages in `[prefix_end, len)` are kept verbatim. - /// A value equal to the message count means "summarize everything". - prefix_end: usize, - /// Number of whole turns kept verbatim. - kept_turns: usize, -}; - -/// Decide the prefix/suffix split for compaction. -/// -/// `messages` is the conversation to compact. Only the active window -/// (everything after the latest existing `.CompactionSummary`, if any) is -/// considered — an earlier compacted prefix is already summarized and is -/// never re-walked. `usages`, when provided, must be the same length as -/// `messages` and supplies per-message provider usage (null entries fall -/// back to word counting). -/// -/// Leading system messages are never part of a turn and are always treated -/// as belonging to the (kept) structural frame, not the summarized prefix: -/// the split index is reported relative to the full list but the walk only -/// accumulates user/assistant conversation turns. -/// -/// Returns the split. When the entire active conversation fits within -/// `keep_verbatim`, `prefix_end` points at the first active turn (nothing -/// to summarize) — callers should treat that as a no-op. -pub fn computeSplit( - messages: []const Message, - usages: ?[]const ?Usage, - keep_verbatim: u64, -) Split { - if (usages) |u| std.debug.assert(u.len == messages.len); - - // The summary message itself anchors the window; active turns begin after it. - const active_start = if (conversation.latestCompactionIndex(messages)) |anchor| anchor + 1 else 0; - - // Identify turn boundaries within the active window. A turn starts at a - // user message that is NOT purely tool-results (a fresh human/user - // prompt or compaction-follow user prompt) and runs until the next such - // boundary. Tool-result user messages and assistant messages attach to - // the current turn. - // - // We collect the start index of each turn, then walk turns backward. - var turn_starts_buf: [4096]usize = undefined; - var n_turns: usize = 0; - var i = active_start; - // Skip leading system messages (structural frame, not a turn). - while (i < messages.len and messages[i].role == .system) : (i += 1) {} - const first_turn_msg = i; - while (i < messages.len) : (i += 1) { - const msg = messages[i]; - if (msg.role == .system) continue; - if (isTurnStart(messages, i)) { - if (n_turns < turn_starts_buf.len) { - turn_starts_buf[n_turns] = i; - } - n_turns += 1; - } - } - const turn_starts = turn_starts_buf[0..@min(n_turns, turn_starts_buf.len)]; - - if (turn_starts.len == 0) { - // No turns to keep or summarize. - return .{ .prefix_end = messages.len, .kept_turns = 0 }; - } - - // Walk turns backward, accumulating token totals. Stop as soon as the - // running total exceeds the budget: the crossing turn is summarized. - var running: u64 = 0; - var kept_turns: usize = 0; - var t = turn_starts.len; - while (t > 0) { - t -= 1; - const start = turn_starts[t]; - const end = if (t + 1 < turn_starts.len) turn_starts[t + 1] else messages.len; - var turn_tokens: u64 = 0; - // Prefer cumulative-usage deltas: provider `input` is the whole - // prior conversation, so a turn's true cost is the delta between - // its final assistant footprint and the previous turn's. Fall back - // to per-message word counting when usage is unavailable. - const turn_estimate: ?u64 = if (usages) |u| - turnTokenEstimate(messages, u, start, end, priorAssistantUsage(messages, u, start)) - else - null; - if (turn_estimate) |est| { - turn_tokens = est; - } else { - var j = start; - while (j < end) : (j += 1) { - const usage: ?Usage = if (usages) |u| u[j] else null; - turn_tokens += messageTokenEstimate(messages[j], usage); - } - } - if (running + turn_tokens > keep_verbatim) { - // This turn crosses the budget: it (and everything before it) - // is summarized. Kept suffix starts at the next turn. - const kept_start = if (t + 1 < turn_starts.len) turn_starts[t + 1] else messages.len; - return .{ .prefix_end = kept_start, .kept_turns = kept_turns }; - } - running += turn_tokens; - kept_turns += 1; - } - - // Everything fits: nothing to summarize. Prefix ends at the first turn. - return .{ .prefix_end = first_turn_msg, .kept_turns = kept_turns }; -} - -/// 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 -// ============================================================================= - -/// Serialize a range of conversation messages into a plain-text transcript -/// artifact. The output marks message roles and structure so the -/// compaction model treats it as material under analysis rather than live -/// chat to continue. Caller owns the returned bytes. -/// -/// Format (one labelled section per block, blank-line separated): -/// [User]: ... -/// [Assistant]: ... -/// [Assistant thinking]: ... -/// [Assistant tool call: ()]: -/// [Tool result ()]: ... -/// [Previous summary]: ... -/// -/// System messages are skipped: the system prompt survives compaction and -/// is not part of the material being summarized. -pub fn serializeTranscript( - allocator: Allocator, - messages: []const Message, -) ![]u8 { - var out: std.ArrayList(u8) = .empty; - errdefer out.deinit(allocator); - const w = &out; - - var first = true; - for (messages) |msg| { - if (msg.role == .system) continue; - for (msg.content.items) |block| { - if (!first) try w.appendSlice(allocator, "\n\n"); - first = false; - switch (block) { - .Text => |b| { - const label = if (msg.role == .assistant) "[Assistant]: " else "[User]: "; - try w.appendSlice(allocator, label); - try w.appendSlice(allocator, b.items); - }, - .Thinking => |b| { - try w.appendSlice(allocator, "[Assistant thinking]: "); - try w.appendSlice(allocator, b.text.items); - }, - .ToolUse => |b| { - const line = try std.fmt.allocPrint(allocator, "[Assistant tool call: {s} ({s})]: {s}", .{ - b.name, b.id, b.input.items, - }); - defer allocator.free(line); - try w.appendSlice(allocator, line); - }, - .ToolResult => |b| { - var body: conversation.TextualBlock = .empty; - defer body.deinit(allocator); - for (b.parts.items) |p| switch (p) { - .text => |t| try body.appendSlice(allocator, t.items), - .media => |m| { - const note = try std.fmt.allocPrint(allocator, "[{s} attachment]", .{m.media_type}); - defer allocator.free(note); - try body.appendSlice(allocator, note); - }, - }; - const line = try std.fmt.allocPrint(allocator, "[Tool result ({s})]: {s}", .{ - b.tool_use_id, body.items, - }); - defer allocator.free(line); - try w.appendSlice(allocator, line); - }, - .CompactionSummary => |b| { - try w.appendSlice(allocator, "[Previous summary]: "); - try w.appendSlice(allocator, b.text.items); - }, - .System => {}, - } - } - } - return out.toOwnedSlice(allocator); -} - -// ============================================================================= -// Compaction request user-prompt assembly -// ============================================================================= - -/// Build the user-prompt body for a compaction request: a brief framing, -/// the optional previous summary in a `` section, and the -/// transcript of the prefix being summarized in a `` section. -/// -/// `transcript` is the serialized prefix (see `serializeTranscript`). -/// `previous_summary`, when non-null, is the latest active compaction -/// summary text carried forward (the chained-compaction invariant: at most -/// one previous summary, never an accumulating stack). Caller owns the -/// returned bytes. -pub fn buildRequestBody( - allocator: Allocator, - transcript: []const u8, - previous_summary: ?[]const u8, -) ![]u8 { - var out: std.ArrayList(u8) = .empty; - errdefer out.deinit(allocator); - - if (previous_summary) |prev| { - try out.appendSlice(allocator, - "The previous compaction generated this summary:\n\n\n"); - try out.appendSlice(allocator, prev); - try out.appendSlice(allocator, - "\n\n\nThe conversation since that previous compaction:\n\n\n"); - } else { - try out.appendSlice(allocator, - "The conversation to summarize:\n\n\n"); - } - try out.appendSlice(allocator, transcript); - try out.appendSlice(allocator, "\n"); - return out.toOwnedSlice(allocator); -} - -/// The latest active compaction summary text in `messages`, or null if the -/// conversation has never been compacted. Borrowed from `messages`. -pub fn latestSummaryText(messages: []const Message) ?[]const u8 { - const anchor = conversation.latestCompactionIndex(messages) orelse return null; - for (messages[anchor].content.items) |block| { - if (block == .CompactionSummary) return block.CompactionSummary.text.items; - } - return null; -} - -// ============================================================================= -// Tests -// ============================================================================= - -const testing = std.testing; - -fn userMsg(alloc: Allocator, text: []const u8) !Message { - var content: std.ArrayList(conversation.ContentBlock) = .empty; - try content.append(alloc, .{ .Text = try conversation.textualBlockFromSlice(alloc, text) }); - return .{ .role = .user, .content = content }; -} - -fn asstMsg(alloc: Allocator, text: []const u8) !Message { - var content: std.ArrayList(conversation.ContentBlock) = .empty; - try content.append(alloc, .{ .Text = try conversation.textualBlockFromSlice(alloc, text) }); - return .{ .role = .assistant, .content = content }; -} - -fn freeMsgs(alloc: Allocator, msgs: []Message) void { - for (msgs) |*m| m.deinit(alloc); -} - -/// Build a `.ToolResult` content block with a single text part. -fn textToolResult(alloc: Allocator, id: []const u8, text: []const u8) !conversation.ContentBlock { - var parts: std.ArrayList(conversation.ResultPartStored) = .empty; - try parts.append(alloc, .{ .text = try conversation.textualBlockFromSlice(alloc, text) }); - return .{ .ToolResult = .{ - .tool_use_id = try alloc.dupe(u8, id), - .parts = parts, - } }; -} - -test "countWordsIn - basic word counting" { - try testing.expectEqual(@as(u64, 0), countWordsIn("")); - try testing.expectEqual(@as(u64, 0), countWordsIn(" \n\t ")); - try testing.expectEqual(@as(u64, 1), countWordsIn("hello")); - try testing.expectEqual(@as(u64, 3), countWordsIn(" hello there world ")); -} - -test "messageTokenEstimate - usage wins when present" { - const a = testing.allocator; - var m = try userMsg(a, "one two three"); - defer m.deinit(a); - const u: Usage = .{ .input = 100, .output = 7, .cache_read = 3 }; - try testing.expectEqual(@as(u64, 110), messageTokenEstimate(m, u)); -} - -test "messageTokenEstimate - word-count fallback when usage absent" { - const a = testing.allocator; - var m = try userMsg(a, "one two three four"); // 4 words * 1.3 = 5.2 -> ceil 6 - defer m.deinit(a); - try testing.expectEqual(@as(u64, 6), messageTokenEstimate(m, null)); -} - -test "computeSplit - everything fits keeps all turns, summarizes nothing" { - const a = testing.allocator; - var msgs = [_]Message{ - try userMsg(a, "q1"), - try asstMsg(a, "a1"), - try userMsg(a, "q2"), - try asstMsg(a, "a2"), - }; - defer freeMsgs(a, &msgs); - - 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); -} - -test "computeSplit - tiny budget summarizes all but the last turn" { - const a = testing.allocator; - // Each message ~ a few words; usage forces deterministic sizes. - var msgs = [_]Message{ - try userMsg(a, "q1"), - try asstMsg(a, "a1"), - try userMsg(a, "q2"), - try asstMsg(a, "a2"), - try userMsg(a, "q3"), - try asstMsg(a, "a3"), - }; - defer freeMsgs(a, &msgs); - - // Each turn contributes ~200 incremental tokens (100 user + 100 - // output). Provider `input` is cumulative: it is the whole prior - // conversation as of that assistant turn. So the assistant inputs grow - // 100, 300, 500 and the per-turn delta is a steady 200. Budget 250 - // keeps only the last turn (200 <= 250); adding the prior turn (400) - // exceeds it. - const usages = [_]?Usage{ - .{ .input = 0 }, .{ .input = 100, .output = 100 }, - .{ .input = 0 }, .{ .input = 300, .output = 100 }, - .{ .input = 0 }, .{ .input = 500, .output = 100 }, - }; - const split = computeSplit(&msgs, &usages, 250); - // Kept suffix = turn starting at index 4 (q3/a3). - try testing.expectEqual(@as(usize, 4), split.prefix_end); - try testing.expectEqual(@as(usize, 1), split.kept_turns); -} - -test "computeSplit - crossing turn goes into the summary (upper-bound semantics)" { - const a = testing.allocator; - var msgs = [_]Message{ - try userMsg(a, "q1"), - try asstMsg(a, "a1"), - try userMsg(a, "q2"), - try asstMsg(a, "a2"), - }; - defer freeMsgs(a, &msgs); - - // 200 incremental tokens per turn via cumulative inputs (100, 300). - // Budget exactly 200: last turn (delta 200) does NOT exceed 200, so - // it's kept. Adding turn 1 => 400 > 200, so turn 1 summarized. - const usages = [_]?Usage{ - .{ .input = 0 }, .{ .input = 100, .output = 100 }, - .{ .input = 0 }, .{ .input = 300, .output = 100 }, - }; - const split = computeSplit(&msgs, &usages, 200); - try testing.expectEqual(@as(usize, 2), split.prefix_end); - try testing.expectEqual(@as(usize, 1), split.kept_turns); -} - -test "computeSplit - tool-result user messages attach to the current turn" { - const a = testing.allocator; - - // Turn 1: user q1 -> assistant(tool_use) -> user(tool_result) -> assistant a1 - var tr_content: std.ArrayList(conversation.ContentBlock) = .empty; - try tr_content.append(a, try textToolResult(a, "t1", "result")); - - var msgs = [_]Message{ - try userMsg(a, "q1"), - try asstMsg(a, "calling tool"), - .{ .role = .user, .content = tr_content }, - try asstMsg(a, "a1"), - try userMsg(a, "q2"), - try asstMsg(a, "a2"), - }; - defer freeMsgs(a, &msgs); - - // Budget small enough to keep only the last turn. The tool-result - // user message must NOT be mistaken for a turn boundary. Turn 1's - // final assistant (index 3) carries the cumulative input; the inner - // tool-round assistant (index 1) is subsumed by it. Turn 1 delta = - // 300; turn 2 delta = cumAt(a2) - cumAt(a1) = 500 - 300 = 200. - const usages = [_]?Usage{ - .{ .input = 0 }, .{ .input = 100, .output = 50 }, .{ .input = 0 }, .{ .input = 200, .output = 100 }, - .{ .input = 0 }, .{ .input = 400, .output = 100 }, - }; - const split = computeSplit(&msgs, &usages, 250); - // Kept suffix = q2/a2 turn at index 4. The whole 4-message first turn - // is summarized. - try testing.expectEqual(@as(usize, 4), split.prefix_end); - 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{ - try userMsg(a, "old q"), - try asstMsg(a, "old a"), - undefined, // compaction summary, filled below - try userMsg(a, "new q"), - try asstMsg(a, "new a"), - }; - var cs_content: std.ArrayList(conversation.ContentBlock) = .empty; - try cs_content.append(a, .{ .CompactionSummary = .{ - .text = try conversation.textualBlockFromSlice(a, "S1"), - } }); - msgs[2] = .{ .role = .user, .content = cs_content }; - defer freeMsgs(a, &msgs); - - // Large budget: the whole *active* window (new q / new a) fits, so - // nothing new is summarized. prefix_end points at first active turn. - const split = computeSplit(&msgs, null, 1_000_000); - try testing.expectEqual(@as(usize, 3), split.prefix_end); - try testing.expectEqual(@as(usize, 1), split.kept_turns); -} - -test "computeSplit - leading system messages are not turns" { - const a = testing.allocator; - var sys_content: std.ArrayList(conversation.ContentBlock) = .empty; - try sys_content.append(a, .{ .System = .{ - .text = try conversation.textualBlockFromSlice(a, "you are helpful"), - .mode = .append, - } }); - var msgs = [_]Message{ - .{ .role = .system, .content = sys_content }, - try userMsg(a, "q1"), - try asstMsg(a, "a1"), - }; - defer freeMsgs(a, &msgs); - - const split = computeSplit(&msgs, null, 1_000_000); - // First turn is index 1 (after the system message). - try testing.expectEqual(@as(usize, 1), split.prefix_end); - try testing.expectEqual(@as(usize, 1), split.kept_turns); -} - -test "computeSplit - regression: real cumulative session overflows keep_verbatim" { - // Reproduces a real session whose final assistant reported - // usage.input=28098, usage.output=5477 (total context 33,575). Earlier - // code treated cumulative `input` as a per-message cost and so either - // double-counted or (when usages were absent) word-counted below the - // budget, yielding a spurious "nothing to compact". With the - // turn-delta model this conversation must split at keep_verbatim=20000. - const a = testing.allocator; - // 12 user/assistant turns. User messages carry null usage; assistant - // messages carry the cumulative inputs observed on disk. - var msgs = [_]Message{ - try userMsg(a, "q1"), try asstMsg(a, "a1"), - try userMsg(a, "q2"), try asstMsg(a, "a2"), - try userMsg(a, "q3"), try asstMsg(a, "a3"), - try userMsg(a, "q4"), try asstMsg(a, "a4"), - try userMsg(a, "q5"), try asstMsg(a, "a5"), - try userMsg(a, "q6"), try asstMsg(a, "a6"), - try userMsg(a, "q7"), try asstMsg(a, "a7"), - try userMsg(a, "q8"), try asstMsg(a, "a8"), - try userMsg(a, "q9"), try asstMsg(a, "a9"), - try userMsg(a, "q10"), try asstMsg(a, "a10"), - try userMsg(a, "q11"), try asstMsg(a, "a11"), - try userMsg(a, "q12"), try asstMsg(a, "a12"), - }; - defer freeMsgs(a, &msgs); - - const U = struct { - fn mk(input: u64, output: u64) ?Usage { - return .{ .input = input, .output = output }; - } - }; - const usages = [_]?Usage{ - null, U.mk(1286, 118), - null, U.mk(2167, 62), - null, U.mk(2352, 146), - null, U.mk(9710, 153), - null, U.mk(10233, 180), - null, U.mk(12562, 127), - null, U.mk(13087, 110), - null, U.mk(14153, 68), - null, U.mk(16794, 838), - null, U.mk(17666, 228), - null, U.mk(24762, 203), - null, U.mk(28098, 5477), - }; - - const split = computeSplit(&msgs, &usages, 20_000); - // Something must be summarized: the split cannot sit at/before the - // first active turn (index 0). - try testing.expect(split.prefix_end > 0); - // The total context (33,575) exceeds the 20k budget, so at least the - // oldest turn(s) are shed and fewer than all 12 turns are kept. - try testing.expect(split.kept_turns < 12); -} - -test "serializeTranscript - labels roles and skips system" { - const a = testing.allocator; - var sys_content: std.ArrayList(conversation.ContentBlock) = .empty; - try sys_content.append(a, .{ .System = .{ - .text = try conversation.textualBlockFromSlice(a, "sys prompt"), - .mode = .append, - } }); - var msgs = [_]Message{ - .{ .role = .system, .content = sys_content }, - try userMsg(a, "hello"), - try asstMsg(a, "hi there"), - }; - defer freeMsgs(a, &msgs); - - const t = try serializeTranscript(a, &msgs); - defer a.free(t); - try testing.expect(std.mem.indexOf(u8, t, "sys prompt") == null); - try testing.expect(std.mem.indexOf(u8, t, "[User]: hello") != null); - try testing.expect(std.mem.indexOf(u8, t, "[Assistant]: hi there") != null); -} - -test "serializeTranscript - tool call and result framing" { - const a = testing.allocator; - var tu_content: std.ArrayList(conversation.ContentBlock) = .empty; - try tu_content.append(a, .{ .ToolUse = .{ - .id = try a.dupe(u8, "tc1"), - .name = try a.dupe(u8, "bash"), - .input = try conversation.textualBlockFromSlice(a, "{\"cmd\":\"ls\"}"), - } }); - var tr_content: std.ArrayList(conversation.ContentBlock) = .empty; - try tr_content.append(a, try textToolResult(a, "tc1", "file.txt")); - var msgs = [_]Message{ - .{ .role = .assistant, .content = tu_content }, - .{ .role = .user, .content = tr_content }, - }; - defer freeMsgs(a, &msgs); - - const t = try serializeTranscript(a, &msgs); - defer a.free(t); - try testing.expect(std.mem.indexOf(u8, t, "[Assistant tool call: bash (tc1)]: {\"cmd\":\"ls\"}") != null); - try testing.expect(std.mem.indexOf(u8, t, "[Tool result (tc1)]: file.txt") != null); -} - -test "buildRequestBody - without previous summary" { - const a = testing.allocator; - const body = try buildRequestBody(a, "TRANSCRIPT", null); - defer a.free(body); - try testing.expect(std.mem.indexOf(u8, body, "previous-summary") == null); - try testing.expect(std.mem.indexOf(u8, body, "\nTRANSCRIPT\n") != null); -} - -test "buildRequestBody - with previous summary" { - const a = testing.allocator; - const body = try buildRequestBody(a, "TRANSCRIPT", "PRIOR"); - defer a.free(body); - try testing.expect(std.mem.indexOf(u8, body, "\nPRIOR\n") != null); - try testing.expect(std.mem.indexOf(u8, body, "\nTRANSCRIPT\n") != null); -} - -test "latestSummaryText - returns latest, null when none" { - const a = testing.allocator; - { - var msgs = [_]Message{ try userMsg(a, "hi") }; - defer freeMsgs(a, &msgs); - try testing.expect(latestSummaryText(&msgs) == null); - } - { - var cs: std.ArrayList(conversation.ContentBlock) = .empty; - try cs.append(a, .{ .CompactionSummary = .{ - .text = try conversation.textualBlockFromSlice(a, "S2"), - } }); - var msgs = [_]Message{ - try userMsg(a, "hi"), - .{ .role = .user, .content = cs }, - }; - defer freeMsgs(a, &msgs); - try testing.expectEqualStrings("S2", latestSummaryText(&msgs).?); - } -} -- cgit v1.3