diff options
Diffstat (limited to 'libpanto/src/session.zig')
| -rw-r--r-- | libpanto/src/session.zig | 181 |
1 files changed, 169 insertions, 12 deletions
diff --git a/libpanto/src/session.zig b/libpanto/src/session.zig index 21193c8..daa6cfa 100644 --- a/libpanto/src/session.zig +++ b/libpanto/src/session.zig @@ -186,12 +186,32 @@ pub const DiskToolUseBlock = struct { } }; +/// One on-disk tool-result part: either text or an inline base64 media +/// attachment (no sidecar files). +pub const DiskResultPart = union(enum) { + text: []const u8, // owned + media: struct { + media_type: []const u8, // owned + data: []const u8, // owned (base64) + }, + pub fn deinit(self: DiskResultPart, alloc: Allocator) void { + switch (self) { + .text => |t| alloc.free(t), + .media => |m| { + alloc.free(m.media_type); + alloc.free(m.data); + }, + } + } +}; + pub const DiskToolResultBlock = struct { tool_use_id: []const u8, // owned - content: []const u8, // owned + parts: []DiskResultPart, // owned pub fn deinit(self: DiskToolResultBlock, alloc: Allocator) void { alloc.free(self.tool_use_id); - alloc.free(self.content); + for (self.parts) |p| p.deinit(alloc); + alloc.free(self.parts); } }; @@ -382,8 +402,33 @@ fn writeDiskBlock(s: *std.json.Stringify, block: DiskContentBlock) !void { try s.write("toolResult"); try s.objectField("toolUseId"); try s.write(b.tool_use_id); - try s.objectField("content"); - try s.write(b.content); + // `parts` is an array of {type:"text",text} and + // {type:"image",mimeType,data} (data = inline base64). + try s.objectField("parts"); + try s.beginArray(); + for (b.parts) |part| { + switch (part) { + .text => |t| { + try s.beginObject(); + try s.objectField("type"); + try s.write("text"); + try s.objectField("text"); + try s.write(t); + try s.endObject(); + }, + .media => |m| { + try s.beginObject(); + try s.objectField("type"); + try s.write("image"); + try s.objectField("mimeType"); + try s.write(m.media_type); + try s.objectField("data"); + try s.write(m.data); + try s.endObject(); + }, + } + } + try s.endArray(); try s.endObject(); }, .compaction_summary => |b| { @@ -567,8 +612,8 @@ fn parseDiskBlock(allocator: Allocator, obj: std.json.ObjectMap) ParseError!Disk } else if (std.mem.eql(u8, t, "toolResult")) { const tuid = try dupeStringField(allocator, obj, "toolUseId"); errdefer allocator.free(tuid); - const content = try dupeStringField(allocator, obj, "content"); - return .{ .tool_result = .{ .tool_use_id = tuid, .content = content } }; + const parts = try parseDiskResultParts(allocator, obj); + return .{ .tool_result = .{ .tool_use_id = tuid, .parts = parts } }; } else if (std.mem.eql(u8, t, "compactionSummary")) { const text = try dupeStringField(allocator, obj, "text"); return .{ .compaction_summary = .{ .text = text } }; @@ -577,6 +622,43 @@ fn parseDiskBlock(allocator: Allocator, obj: std.json.ObjectMap) ParseError!Disk } } +/// Parse the `parts` array of a `toolResult` disk block. Falls back to a +/// legacy single `content` string field (older session logs) -> one text +/// part. Each element is {type:"text",text} or {type:"image",mimeType,data}. +fn parseDiskResultParts(allocator: Allocator, obj: std.json.ObjectMap) ParseError![]DiskResultPart { + var list: std.ArrayList(DiskResultPart) = .empty; + errdefer { + for (list.items) |p| p.deinit(allocator); + list.deinit(allocator); + } + const parts_v = obj.get("parts"); + if (parts_v == null or parts_v.? == .null) { + // Legacy: a single `content` string. + const content = try dupeStringField(allocator, obj, "content"); + try list.append(allocator, .{ .text = content }); + return list.toOwnedSlice(allocator); + } + if (parts_v.? != .array) return error.MissingField; + for (parts_v.?.array.items) |item| { + if (item != .object) return error.MissingField; + const po = item.object; + const pt_v = po.get("type") orelse return error.MissingField; + if (pt_v != .string) return error.MissingField; + if (std.mem.eql(u8, pt_v.string, "text")) { + const text = try dupeStringField(allocator, po, "text"); + try list.append(allocator, .{ .text = text }); + } else if (std.mem.eql(u8, pt_v.string, "image")) { + const mt = try dupeStringField(allocator, po, "mimeType"); + errdefer allocator.free(mt); + const data = try dupeStringField(allocator, po, "data"); + try list.append(allocator, .{ .media = .{ .media_type = mt, .data = data } }); + } else { + return error.UnknownBlockType; + } + } + return list.toOwnedSlice(allocator); +} + fn readU64(obj: std.json.ObjectMap, name: []const u8) u64 { const v = obj.get(name) orelse return 0; if (v != .integer) return 0; @@ -630,8 +712,24 @@ pub fn contentBlockToDisk( .ToolResult => |tr| { const tuid = try allocator.dupe(u8, tr.tool_use_id); errdefer allocator.free(tuid); - const content = try allocator.dupe(u8, tr.content.items); - return .{ .tool_result = .{ .tool_use_id = tuid, .content = content } }; + var parts: std.ArrayList(DiskResultPart) = .empty; + errdefer { + for (parts.items) |p| p.deinit(allocator); + parts.deinit(allocator); + } + try parts.ensureTotalCapacity(allocator, tr.parts.items.len); + for (tr.parts.items) |src| { + switch (src) { + .text => |tb| parts.appendAssumeCapacity(.{ .text = try allocator.dupe(u8, tb.items) }), + .media => |m| { + const mt = try allocator.dupe(u8, m.media_type); + errdefer allocator.free(mt); + const data = try allocator.dupe(u8, m.data.items); + parts.appendAssumeCapacity(.{ .media = .{ .media_type = mt, .data = data } }); + }, + } + } + return .{ .tool_result = .{ .tool_use_id = tuid, .parts = try parts.toOwnedSlice(allocator) } }; }, // A `.System` block becomes a disk text block; its mode rides on // the enclosing `DiskMessage.mode` (set by the session manager), @@ -679,8 +777,24 @@ pub fn diskContentBlockToInternal( .tool_result => |b| { const tuid = try allocator.dupe(u8, b.tool_use_id); errdefer allocator.free(tuid); - const content = try conversation.textualBlockFromSlice(allocator, b.content); - return .{ .ToolResult = .{ .tool_use_id = tuid, .content = content } }; + var parts: std.ArrayList(conversation.ResultPartStored) = .empty; + errdefer { + for (parts.items) |*p| p.deinit(allocator); + parts.deinit(allocator); + } + try parts.ensureTotalCapacity(allocator, b.parts.len); + for (b.parts) |src| { + switch (src) { + .text => |t| parts.appendAssumeCapacity(.{ .text = try conversation.textualBlockFromSlice(allocator, t) }), + .media => |m| { + const mt = try allocator.dupe(u8, m.media_type); + errdefer allocator.free(mt); + const data = try conversation.textualBlockFromSlice(allocator, m.data); + parts.appendAssumeCapacity(.{ .media = .{ .media_type = mt, .data = data } }); + }, + } + } + return .{ .ToolResult = .{ .tool_use_id = tuid, .parts = parts } }; }, .compaction_summary => |b| { const tb = try conversation.textualBlockFromSlice(allocator, b.text); @@ -812,9 +926,11 @@ test "serialize/parse tool result message entry" { const a = testing.allocator; var content = try a.alloc(DiskContentBlock, 1); + var trp = try a.alloc(DiskResultPart, 1); + trp[0] = .{ .text = try dupe(a, "file1.txt\nfile2.txt") }; content[0] = .{ .tool_result = .{ .tool_use_id = try dupe(a, "tool_abc"), - .content = try dupe(a, "file1.txt\nfile2.txt"), + .parts = trp, } }; const entry: SessionEntry = .{ .message = .{ @@ -840,10 +956,51 @@ test "serialize/parse tool result message entry" { const got = fe.entry.message; try testing.expectEqual(DiskMessageRole.user, got.message.role); try testing.expectEqualStrings("tool_abc", got.message.content[0].tool_result.tool_use_id); - try testing.expectEqualStrings("file1.txt\nfile2.txt", got.message.content[0].tool_result.content); + try testing.expectEqual(@as(usize, 1), got.message.content[0].tool_result.parts.len); + try testing.expectEqualStrings("file1.txt\nfile2.txt", got.message.content[0].tool_result.parts[0].text); try testing.expectEqualStrings("anthropic", got.provider.?); } +test "serialize/parse tool result with text + image part round-trips" { + const a = testing.allocator; + + var content = try a.alloc(DiskContentBlock, 1); + var trp = try a.alloc(DiskResultPart, 2); + trp[0] = .{ .text = try dupe(a, "here is the image") }; + trp[1] = .{ .media = .{ + .media_type = try dupe(a, "image/png"), + .data = try dupe(a, "iVBORw0KGgo="), + } }; + content[0] = .{ .tool_result = .{ + .tool_use_id = try dupe(a, "tool_img"), + .parts = trp, + } }; + + const entry: SessionEntry = .{ .message = .{ + .base = .{ + .id = try dupe(a, "img00001"), + .parent_id = try dupe(a, "img00000"), + .timestamp = try dupe(a, "2026-04-25T17:40:18.000Z"), + }, + .provider = try dupe(a, "anthropic"), + .model = try dupe(a, "claude-sonnet-4-20250514"), + .message = .{ .role = .user, .content = content }, + } }; + defer entry.deinit(a); + + const line = try serializeEntry(a, entry); + defer a.free(line); + + var fe = try parseLine(a, line); + defer fe.deinit(a); + const tr = fe.entry.message.message.content[0].tool_result; + try testing.expectEqualStrings("tool_img", tr.tool_use_id); + try testing.expectEqual(@as(usize, 2), tr.parts.len); + try testing.expectEqualStrings("here is the image", tr.parts[0].text); + try testing.expectEqualStrings("image/png", tr.parts[1].media.media_type); + try testing.expectEqualStrings("iVBORw0KGgo=", tr.parts[1].media.data); +} + test "system message mode round-trips; absent mode defaults to append" { const a = testing.allocator; |
