summaryrefslogtreecommitdiff
path: root/libpanto/src/compaction.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-07 11:29:23 -0600
committert <t@tjp.lol>2026-07-07 12:15:28 -0600
commit621d7fee0ace729f8d44126032d2c6e13f72ee7f (patch)
treea2938b0a9e5c0930e8644721abbe94875df9ff08 /libpanto/src/compaction.zig
parent0fa6d4209ff9b4a95e7d1955887aa4c73ee3423c (diff)
Move libpanto projects to libpantograph dependencyHEADmain
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.
Diffstat (limited to 'libpanto/src/compaction.zig')
-rw-r--r--libpanto/src/compaction.zig838
1 files changed, 0 insertions, 838 deletions
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: <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| {
- 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 `<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);
-}
-
-/// 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, "<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).?);
- }
-}