summaryrefslogtreecommitdiff
path: root/libpanto/src/conversation.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-02 15:00:44 -0600
committert <t@tjp.lol>2026-06-02 16:37:32 -0600
commit8b88b886346460b1ab29edb03ad85bc3bb565a45 (patch)
tree13b9ab56061a722641389b5fc8ac1113d09b66e5 /libpanto/src/conversation.zig
parent7268c0b8e8bcf4b325581fabe7476a394f167738 (diff)
compaction
Diffstat (limited to 'libpanto/src/conversation.zig')
-rw-r--r--libpanto/src/conversation.zig176
1 files changed, 176 insertions, 0 deletions
diff --git a/libpanto/src/conversation.zig b/libpanto/src/conversation.zig
index f145d7f..780f650 100644
--- a/libpanto/src/conversation.zig
+++ b/libpanto/src/conversation.zig
@@ -68,12 +68,31 @@ pub const SystemBlock = struct {
}
};
+/// A compaction summary block. Carries the synthetic seed text that
+/// stands in for a compacted conversation prefix. Like a `replace`-mode
+/// `.System` block, it changes replay semantics: when the effective
+/// conversation is rebuilt, a `.CompactionSummary` block resets all prior
+/// *conversation* turns (user/assistant), and only the latest summary
+/// plus the messages after it contribute to active context. System blocks
+/// are unaffected — they are derived separately by `effectiveSystemBlocks`
+/// and survive compaction untouched.
+///
+/// A `.CompactionSummary` block sits alone in a `user`-role message.
+pub const CompactionSummaryBlock = struct {
+ text: TextualBlock = .empty,
+
+ pub fn deinit(self: *CompactionSummaryBlock, alloc: Allocator) void {
+ self.text.deinit(alloc);
+ }
+};
+
pub const ContentBlock = union(enum) {
Text: TextualBlock,
Thinking: ThinkingBlock,
ToolUse: ToolUseBlock,
ToolResult: ToolResultBlock,
System: SystemBlock,
+ CompactionSummary: CompactionSummaryBlock,
pub fn deinit(self: *ContentBlock, alloc: Allocator) void {
switch (self.*) {
@@ -82,6 +101,7 @@ pub const ContentBlock = union(enum) {
.ToolUse => |*b| b.deinit(alloc),
.ToolResult => |*b| b.deinit(alloc),
.System => |*b| b.deinit(alloc),
+ .CompactionSummary => |*b| b.deinit(alloc),
}
}
};
@@ -92,9 +112,52 @@ pub const MessageRole = enum {
assistant,
};
+/// Token usage reported by a provider for a single assistant turn.
+///
+/// All input categories sum to the total prompt tokens billed for the turn:
+/// `input + cache_read + cache_write` = total prompt tokens.
+///
+/// Crucially, `input` is *cumulative*: it is the entire prompt sent for the
+/// turn (the whole prior conversation), not the size of the turn's own
+/// message. Compaction relies on this to back out per-turn sizes from
+/// neighbor deltas (see `compaction.computeSplit`).
+///
+/// `reasoning` is a **subset** of `output`, not additive — the portion of
+/// the output spent on internal reasoning (OpenAI o-series, Anthropic
+/// extended thinking). Cost is computed from `output` only; `reasoning` is
+/// tracked for display.
+///
+/// All fields are `u64` and default to 0. Providers that don't report a
+/// given category leave it at 0.
+///
+/// Defined here (rather than in `session.zig`) so in-memory `Message`s can
+/// carry usage without a module cycle; `session.zig` re-exports it.
+pub const Usage = struct {
+ /// Fresh input tokens billed at the model's base input rate.
+ input: u64 = 0,
+ /// Output tokens billed at the model's base output rate.
+ output: u64 = 0,
+ /// Input tokens served from cache (typ. 0.1× base input rate on
+ /// Anthropic, 0.5× on OpenAI).
+ cache_read: u64 = 0,
+ /// Input tokens written to a new cache entry (Anthropic only at
+ /// time of writing; typ. 1.25× base input rate). OpenAI's cache
+ /// hits don't bill a write premium, so this stays 0 there.
+ cache_write: u64 = 0,
+ /// Subset of `output` spent on reasoning. For OpenAI o-series
+ /// models and Anthropic extended thinking. Display-only — cost is
+ /// already accounted for via `output`.
+ reasoning: u64 = 0,
+};
+
pub const Message = struct {
role: MessageRole,
content: std.ArrayList(ContentBlock) = .empty,
+ /// Provider-reported token usage for this message's turn. Set on
+ /// assistant messages (live, via the provider; replayed, from disk);
+ /// null on user/system messages and when the provider emitted no usage.
+ /// Used by compaction to size the retention window.
+ usage: ?Usage = null,
pub fn deinit(self: *Message, alloc: Allocator) void {
for (self.content.items) |*block| {
@@ -154,9 +217,36 @@ pub const Conversation = struct {
});
}
+ /// Append a `user`-role message carrying a single `.CompactionSummary`
+ /// block holding `text` (copied). This is the seed that stands in for
+ /// a compacted conversation prefix; see `CompactionSummaryBlock`.
+ pub fn addCompactionSummary(self: *Conversation, text: []const u8) !void {
+ const tb = try textualBlockFromSlice(self.allocator, text);
+ var content: std.ArrayList(ContentBlock) = .empty;
+ errdefer {
+ for (content.items) |*b| b.deinit(self.allocator);
+ content.deinit(self.allocator);
+ }
+ try content.append(self.allocator, .{ .CompactionSummary = .{ .text = tb } });
+ try self.messages.append(self.allocator, .{
+ .role = .user,
+ .content = content,
+ });
+ }
+
/// Append an assistant message. Ownership of the blocks is transferred
/// to the conversation; the caller must not deinit them after this call.
pub fn addAssistantMessage(self: *Conversation, blocks: []const ContentBlock) !void {
+ return self.addAssistantMessageWithUsage(blocks, null);
+ }
+
+ /// Append an assistant message tagged with its provider-reported
+ /// `usage`. Ownership of the blocks is transferred to the conversation.
+ pub fn addAssistantMessageWithUsage(
+ self: *Conversation,
+ blocks: []const ContentBlock,
+ usage: ?Usage,
+ ) !void {
var content: std.ArrayList(ContentBlock) = .empty;
try content.ensureTotalCapacity(self.allocator, blocks.len);
for (blocks) |block| {
@@ -165,6 +255,7 @@ pub const Conversation = struct {
try self.messages.append(self.allocator, .{
.role = .assistant,
.content = content,
+ .usage = usage,
});
}
@@ -220,6 +311,42 @@ pub fn effectiveSystemBlocks(
return out;
}
+/// Index of the message carrying the latest `.CompactionSummary` block,
+/// or null if the conversation has never been compacted.
+///
+/// Compaction replay is reset-like: only the latest compaction summary and
+/// the messages after it contribute to active conversation context. This
+/// returns the anchor message (the summary itself); active conversation is
+/// `messages[anchor..]`. System messages are unaffected and are derived
+/// independently via `effectiveSystemBlocks`.
+pub fn latestCompactionIndex(messages: []const Message) ?usize {
+ var anchor: ?usize = null;
+ for (messages, 0..) |msg, i| {
+ for (msg.content.items) |block| {
+ if (block == .CompactionSummary) {
+ anchor = i;
+ break;
+ }
+ }
+ }
+ return anchor;
+}
+
+/// The active (post-compaction) conversation message window that should be
+/// sent to a provider. If the conversation has been compacted, this is the
+/// latest compaction summary message plus everything after it; otherwise
+/// it is the whole message list. System messages within the window are
+/// still emitted/handled by the caller's own role filtering — this only
+/// trims the *prefix* superseded by compaction.
+///
+/// The returned slice borrows from `messages`.
+pub fn activeMessageWindow(messages: []const Message) []const Message {
+ if (latestCompactionIndex(messages)) |anchor| {
+ return messages[anchor..];
+ }
+ return messages;
+}
+
test "Conversation - add messages and verify content" {
const allocator = std.testing.allocator;
@@ -360,3 +487,52 @@ test "effectiveSystemBlocks - prefix reconstructs prompt as of that point" {
try std.testing.expectEqual(@as(usize, 1), blocks.items.len);
try std.testing.expectEqualStrings("a", blocks.items[0]);
}
+
+test "addCompactionSummary - sits alone in a user message" {
+ const allocator = std.testing.allocator;
+
+ var conv = Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addUserMessage("hi");
+ try conv.addCompactionSummary("summary of earlier history");
+
+ const m = conv.messages.items[1];
+ try std.testing.expectEqual(MessageRole.user, m.role);
+ try std.testing.expectEqual(@as(usize, 1), m.content.items.len);
+ try std.testing.expectEqualStrings(
+ "summary of earlier history",
+ m.content.items[0].CompactionSummary.text.items,
+ );
+}
+
+test "latestCompactionIndex - null when never compacted" {
+ const allocator = std.testing.allocator;
+
+ var conv = Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("sys");
+ try conv.addUserMessage("hi");
+ try conv.addAssistantMessage(&.{
+ .{ .Text = try textualBlockFromSlice(allocator, "hello") },
+ });
+
+ try std.testing.expect(latestCompactionIndex(conv.messages.items) == null);
+}
+
+test "latestCompactionIndex - returns the latest summary anchor" {
+ const allocator = std.testing.allocator;
+
+ var conv = Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("sys");
+ try conv.addUserMessage("old");
+ try conv.addCompactionSummary("S1");
+ try conv.addUserMessage("mid");
+ try conv.addCompactionSummary("S2"); // index 4
+ try conv.addUserMessage("recent");
+
+ try std.testing.expectEqual(@as(?usize, 4), latestCompactionIndex(conv.messages.items));
+}