diff options
Diffstat (limited to 'libpanto/src/conversation.zig')
| -rw-r--r-- | libpanto/src/conversation.zig | 176 |
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)); +} |
