diff options
| author | t <t@tjp.lol> | 2026-06-02 15:00:44 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-02 16:37:32 -0600 |
| commit | 8b88b886346460b1ab29edb03ad85bc3bb565a45 (patch) | |
| tree | 13b9ab56061a722641389b5fc8ac1113d09b66e5 /libpanto/src/compaction.zig | |
| parent | 7268c0b8e8bcf4b325581fabe7476a394f167738 (diff) | |
compaction
Diffstat (limited to 'libpanto/src/compaction.zig')
| -rw-r--r-- | libpanto/src/compaction.zig | 743 |
1 files changed, 743 insertions, 0 deletions
diff --git a/libpanto/src/compaction.zig b/libpanto/src/compaction.zig new file mode 100644 index 0000000..254b876 --- /dev/null +++ b/libpanto/src/compaction.zig @@ -0,0 +1,743 @@ +//! 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| { + const text: []const u8 = switch (block) { + .Text => |b| b.items, + .Thinking => |b| b.text.items, + .ToolUse => |b| b.input.items, + .ToolResult => |b| b.content.items, + .System => |b| b.text.items, + .CompactionSummary => |b| b.text.items, + }; + total += countWordsIn(text); + } + return total; +} + +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); + + const active_start = blk: { + if (conversation.latestCompactionIndex(messages)) |anchor| { + // The summary message itself anchors the window; active turns + // begin after it. + break :blk anchor + 1; + } + break :blk 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(msg)) { + 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 }; +} + +/// 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 { + if (msg.role != .user) return false; + for (msg.content.items) |block| { + if (block == .ToolResult) return false; + } + return true; +} + +// ============================================================================= +// 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: <name> (<id>)]: <input> +/// [Tool result (<id>)]: ... +/// [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| { + const line = try std.fmt.allocPrint(allocator, "[Tool result ({s})]: {s}", .{ + b.tool_use_id, b.content.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 `<previous-summary>` section, and the +/// transcript of the prefix being summarized in a `<transcript>` 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<previous-summary>\n"); + try out.appendSlice(allocator, prev); + try out.appendSlice(allocator, + "\n</previous-summary>\n\nThe conversation since that previous compaction:\n\n<transcript>\n"); + } else { + try out.appendSlice(allocator, + "The conversation to summarize:\n\n<transcript>\n"); + } + try out.appendSlice(allocator, transcript); + try out.appendSlice(allocator, "\n</transcript>"); + 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); +} + +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, .{ .ToolResult = .{ + .tool_use_id = try a.dupe(u8, "t1"), + .content = try conversation.textualBlockFromSlice(a, "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 - 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, .{ .ToolResult = .{ + .tool_use_id = try a.dupe(u8, "tc1"), + .content = try conversation.textualBlockFromSlice(a, "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, "<transcript>\nTRANSCRIPT\n</transcript>") != 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, "<previous-summary>\nPRIOR\n</previous-summary>") != null); + try testing.expect(std.mem.indexOf(u8, body, "<transcript>\nTRANSCRIPT\n</transcript>") != 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).?); + } +} |
