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/session.zig | |
| parent | 7268c0b8e8bcf4b325581fabe7476a394f167738 (diff) | |
compaction
Diffstat (limited to 'libpanto/src/session.zig')
| -rw-r--r-- | libpanto/src/session.zig | 112 |
1 files changed, 82 insertions, 30 deletions
diff --git a/libpanto/src/session.zig b/libpanto/src/session.zig index c1fd1c4..21193c8 100644 --- a/libpanto/src/session.zig +++ b/libpanto/src/session.zig @@ -129,36 +129,10 @@ pub const DiskMessage = struct { /// Token usage reported by a provider for a single assistant turn. /// -/// All four input categories sum to the total prompt tokens that were -/// billed for the turn: -/// `input + cache_read + cache_write` = total prompt tokens. -/// -/// `reasoning` is a **subset** of `output`, not additive — it's the -/// portion of the output that the model 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. Future cache-tier breakdowns (e.g. -/// Anthropic's 5m vs 1h tiers) can be added as new fields without -/// breaking the format. -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, -}; +/// Defined in `conversation.zig` (so in-memory `Message`s can carry it +/// without a module cycle) and re-exported here for the on-disk types and +/// historical call sites that import it as `session.Usage`. +pub const Usage = conversation.Usage; // ============================================================================= // Content blocks @@ -169,6 +143,7 @@ pub const DiskContentBlock = union(enum) { thinking: DiskThinkingBlock, tool_use: DiskToolUseBlock, tool_result: DiskToolResultBlock, + compaction_summary: DiskCompactionSummaryBlock, pub fn deinit(self: DiskContentBlock, alloc: Allocator) void { switch (self) { @@ -176,6 +151,7 @@ pub const DiskContentBlock = union(enum) { .thinking => |b| b.deinit(alloc), .tool_use => |b| b.deinit(alloc), .tool_result => |b| b.deinit(alloc), + .compaction_summary => |b| b.deinit(alloc), } } }; @@ -219,6 +195,16 @@ pub const DiskToolResultBlock = struct { } }; +/// A compaction summary block: the synthetic seed text standing in for a +/// compacted conversation prefix. Sits alone in a `user`-role message. See +/// `conversation.CompactionSummaryBlock`. +pub const DiskCompactionSummaryBlock = struct { + text: []const u8, // owned + pub fn deinit(self: DiskCompactionSummaryBlock, alloc: Allocator) void { + alloc.free(self.text); + } +}; + // ============================================================================= // File entry (header or entry) // ============================================================================= @@ -400,6 +386,14 @@ fn writeDiskBlock(s: *std.json.Stringify, block: DiskContentBlock) !void { try s.write(b.content); try s.endObject(); }, + .compaction_summary => |b| { + try s.beginObject(); + try s.objectField("type"); + try s.write("compactionSummary"); + try s.objectField("text"); + try s.write(b.text); + try s.endObject(); + }, } } @@ -575,6 +569,9 @@ fn parseDiskBlock(allocator: Allocator, obj: std.json.ObjectMap) ParseError!Disk errdefer allocator.free(tuid); const content = try dupeStringField(allocator, obj, "content"); return .{ .tool_result = .{ .tool_use_id = tuid, .content = content } }; + } else if (std.mem.eql(u8, t, "compactionSummary")) { + const text = try dupeStringField(allocator, obj, "text"); + return .{ .compaction_summary = .{ .text = text } }; } else { return error.UnknownBlockType; } @@ -643,6 +640,10 @@ pub fn contentBlockToDisk( const text = try allocator.dupe(u8, sb.text.items); return .{ .text = .{ .text = text } }; }, + .CompactionSummary => |cs| { + const text = try allocator.dupe(u8, cs.text.items); + return .{ .compaction_summary = .{ .text = text } }; + }, } } @@ -681,6 +682,10 @@ pub fn diskContentBlockToInternal( const content = try conversation.textualBlockFromSlice(allocator, b.content); return .{ .ToolResult = .{ .tool_use_id = tuid, .content = content } }; }, + .compaction_summary => |b| { + const tb = try conversation.textualBlockFromSlice(allocator, b.text); + return .{ .CompactionSummary = .{ .text = tb } }; + }, } } @@ -1031,3 +1036,50 @@ test "diskContentBlockToInternal: Thinking preserves signature" { try testing.expectEqualStrings("reasoning...", inmem.Thinking.text.items); try testing.expectEqualStrings("sig123", inmem.Thinking.signature.?); } + +test "compactionSummary block round-trips through serialize/parse" { + const a = testing.allocator; + + var content = try a.alloc(DiskContentBlock, 1); + content[0] = .{ .compaction_summary = .{ .text = try dupe(a, "earlier history summary") } }; + + const entry: SessionEntry = .{ .message = .{ + .base = .{ + .id = try dupe(a, "cafef00d"), + .parent_id = null, + .timestamp = try dupe(a, "2026-04-25T17:40:00Z"), + }, + .message = .{ .role = .user, .content = content }, + } }; + defer entry.deinit(a); + + const line = try serializeEntry(a, entry); + defer a.free(line); + try testing.expect(std.mem.indexOf(u8, line, "\"type\":\"compactionSummary\"") != null); + + var fe = try parseLine(a, line); + defer fe.deinit(a); + const got = fe.entry.message; + try testing.expectEqual(DiskMessageRole.user, got.message.role); + try testing.expectEqualStrings("earlier history summary", got.message.content[0].compaction_summary.text); +} + +test "compactionSummary bridges in-memory <-> disk both directions" { + const a = testing.allocator; + + // in-memory -> disk + const tb = try conversation.textualBlockFromSlice(a, "S1"); + const block: conversation.ContentBlock = .{ .CompactionSummary = .{ .text = tb } }; + defer { + var mut = block; + mut.deinit(a); + } + const disk = try contentBlockToDisk(a, block); + defer disk.deinit(a); + try testing.expectEqualStrings("S1", disk.compaction_summary.text); + + // disk -> in-memory + var inmem = try diskContentBlockToInternal(a, disk); + defer inmem.deinit(a); + try testing.expectEqualStrings("S1", inmem.CompactionSummary.text.items); +} |
