summaryrefslogtreecommitdiff
path: root/libpanto/src/agent.zig
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto/src/agent.zig')
-rw-r--r--libpanto/src/agent.zig143
1 files changed, 110 insertions, 33 deletions
diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig
index 411abc5..c078c24 100644
--- a/libpanto/src/agent.zig
+++ b/libpanto/src/agent.zig
@@ -33,6 +33,7 @@ const config_mod = @import("config.zig");
const conversation = @import("conversation.zig");
const compaction_mod = @import("compaction.zig");
const tool_mod = @import("tool.zig");
+const image_mod = @import("image.zig");
const tool_source_mod = @import("tool_source.zig");
const tool_registry_mod = @import("tool_registry.zig");
@@ -86,8 +87,27 @@ fn cloneBlock(alloc: Allocator, block: conversation.ContentBlock) !conversation.
.ToolResult => |b| blk: {
const tuid = try alloc.dupe(u8, b.tool_use_id);
errdefer alloc.free(tuid);
- const content = try conversation.textualBlockFromSlice(alloc, b.content.items);
- break :blk .{ .ToolResult = .{ .tool_use_id = tuid, .content = content } };
+ var parts: std.ArrayList(conversation.ResultPartStored) = .empty;
+ errdefer {
+ for (parts.items) |*p| p.deinit(alloc);
+ parts.deinit(alloc);
+ }
+ try parts.ensureTotalCapacity(alloc, b.parts.items.len);
+ for (b.parts.items) |src| {
+ switch (src) {
+ .text => |tb| {
+ const t = try conversation.textualBlockFromSlice(alloc, tb.items);
+ parts.appendAssumeCapacity(.{ .text = t });
+ },
+ .media => |m| {
+ const mt = try alloc.dupe(u8, m.media_type);
+ errdefer alloc.free(mt);
+ const data = try conversation.textualBlockFromSlice(alloc, m.data.items);
+ parts.appendAssumeCapacity(.{ .media = .{ .media_type = mt, .data = data } });
+ },
+ }
+ }
+ break :blk .{ .ToolResult = .{ .tool_use_id = tuid, .parts = parts } };
},
.System => |b| .{ .System = .{
.text = try conversation.textualBlockFromSlice(alloc, b.text.items),
@@ -137,12 +157,13 @@ fn isValidToolInput(input: []const u8) bool {
return parsed.value == .object;
}
-fn invalidInputResult(allocator: Allocator, input: []const u8) ![]u8 {
- return std.fmt.allocPrint(
+fn invalidInputResult(allocator: Allocator, input: []const u8) ![]tool_mod.ResultPart {
+ const msg = try std.fmt.allocPrint(
allocator,
"Tool call was not executed: tool input was incomplete or invalid JSON. Partial input: {s}",
.{input},
);
+ return tool_mod.ownedTextResult(allocator, msg);
}
@@ -536,7 +557,7 @@ pub const Agent = struct {
defer task_group.cancel(self.io);
errdefer {
for (calls.items) |*c| {
- if (c.result) |r| self.allocator.free(r);
+ if (c.result) |r| tool_mod.freeResultParts(self.allocator, r);
}
}
@@ -563,25 +584,68 @@ pub const Agent = struct {
first_err = e;
continue;
}
- const result_bytes = c.result orelse {
+ const result_parts = c.result orelse {
// Internal error: every successful call should have left
- // bytes behind. Treat as MissingToolResult.
+ // parts behind. Treat as MissingToolResult.
first_err = error.MissingToolResult;
continue;
};
c.result = null; // ownership transferred below
+ defer tool_mod.freeResultParts(self.allocator, result_parts);
const id_copy = try self.allocator.dupe(u8, c.tool_use_id);
errdefer self.allocator.free(id_copy);
- var content_buf: conversation.TextualBlock = .empty;
- errdefer content_buf.deinit(self.allocator);
- try content_buf.appendSlice(self.allocator, result_bytes);
- self.allocator.free(result_bytes);
+ var stored: std.ArrayList(conversation.ResultPartStored) = .empty;
+ errdefer {
+ for (stored.items) |*p| p.deinit(self.allocator);
+ stored.deinit(self.allocator);
+ }
+ try stored.ensureTotalCapacity(self.allocator, result_parts.len);
+ for (result_parts) |part| {
+ switch (part) {
+ .text => |t| {
+ var buf: conversation.TextualBlock = .empty;
+ errdefer buf.deinit(self.allocator);
+ try buf.appendSlice(self.allocator, t);
+ stored.appendAssumeCapacity(.{ .text = buf });
+ },
+ .media => |m| {
+ // libpanto owns the heavy lifting: detect the type
+ // (when the tool gave no hint), resize large
+ // rasters, then base64-encode for storage. Tools
+ // hand over raw bytes only.
+ const processed = image_mod.process(self.allocator, m.data, m.media_type) catch |e| {
+ // Unrecognized bytes: keep the turn alive by
+ // dropping the attachment and noting it as text.
+ if (e == error.UnknownMediaType) {
+ var note: conversation.TextualBlock = .empty;
+ errdefer note.deinit(self.allocator);
+ try note.appendSlice(self.allocator, "[unrecognized binary attachment dropped]");
+ stored.appendAssumeCapacity(.{ .text = note });
+ continue;
+ }
+ return e;
+ };
+ defer self.allocator.free(processed.data);
+
+ const mt = try self.allocator.dupe(u8, processed.media_type);
+ errdefer self.allocator.free(mt);
+
+ const enc = std.base64.standard.Encoder;
+ var buf: conversation.TextualBlock = .empty;
+ errdefer buf.deinit(self.allocator);
+ try buf.resize(self.allocator, enc.calcSize(processed.data.len));
+ _ = enc.encode(buf.items, processed.data);
+
+ stored.appendAssumeCapacity(.{ .media = .{ .media_type = mt, .data = buf } });
+ },
+ }
+ }
content.appendAssumeCapacity(.{ .ToolResult = .{
.tool_use_id = id_copy,
- .content = content_buf,
+ .parts = stored,
} });
}
@@ -603,10 +667,10 @@ const FlatCall = struct {
input: []const u8, // borrowed from assistant_msg
entry: ?Entry,
- /// Owned result bytes from `Tool.invoke` or `ToolSource.invoke_batch`.
+ /// Owned result parts from `Tool.invoke` or `ToolSource.invoke_batch`.
/// Allocated with the agent's allocator. Transferred into a
/// ToolResultBlock on success.
- result: ?[]u8,
+ result: ?[]tool_mod.ResultPart,
/// If non-null, the call failed and the turn must abort.
err: ?anyerror,
@@ -733,7 +797,7 @@ fn runSourceGroup(agent: *Agent, sg: Group.SourceGroup, calls: []FlatCall) void
// Whole-batch failure: free any partial successes the source
// already wrote, then mark every member as failed.
for (batch_results) |r| switch (r) {
- .ok => |b| agent.allocator.free(b),
+ .ok => |b| tool_mod.freeResultParts(agent.allocator, b),
.err => {},
};
for (sg.member_indices) |i| calls[i].err = e;
@@ -755,6 +819,14 @@ fn runSourceGroup(agent: *Agent, sg: Group.SourceGroup, calls: []FlatCall) void
const testing = std.testing;
+/// Test helper: the items of a ToolResultBlock's first text part.
+fn trText(tr: conversation.ToolResultBlock) []const u8 {
+ for (tr.parts.items) |p| {
+ if (p == .text) return p.text.items;
+ }
+ return "";
+}
+
/// Test harness for the injectable `stream_fn` seam.
///
/// `provider_mod.StreamFn` carries no user context (it mirrors the real
@@ -894,9 +966,10 @@ const EchoTool = struct {
const vt: Tool.VTable = .{ .invoke = invoke, .deinit = deinit };
- fn invoke(ctx: *anyopaque, input: []const u8, allocator: Allocator) anyerror![]u8 {
+ fn invoke(ctx: *anyopaque, input: []const u8, allocator: Allocator) anyerror![]tool_mod.ResultPart {
const self: *EchoTool = @ptrCast(@alignCast(ctx));
- return try std.fmt.allocPrint(allocator, "{s}{s}", .{ self.prefix_owned, input });
+ const msg = try std.fmt.allocPrint(allocator, "{s}{s}", .{ self.prefix_owned, input });
+ return tool_mod.ownedTextResult(allocator, msg);
}
fn deinit(ctx: *anyopaque, allocator: Allocator) void {
@@ -937,7 +1010,7 @@ const BarrierTool = struct {
const vt: Tool.VTable = .{ .invoke = invoke, .deinit = deinit };
- fn invoke(ctx: *anyopaque, _: []const u8, allocator: Allocator) anyerror![]u8 {
+ fn invoke(ctx: *anyopaque, _: []const u8, allocator: Allocator) anyerror![]tool_mod.ResultPart {
const self: *BarrierTool = @ptrCast(@alignCast(ctx));
const arrived = self.barrier.arrived.fetchAdd(1, .acq_rel);
if (arrived < self.barrier.thread_ids.len) {
@@ -949,7 +1022,7 @@ const BarrierTool = struct {
if (i > 50_000) return error.BarrierTimeout;
std.Thread.yield() catch {};
}
- return try allocator.dupe(u8, "done");
+ return tool_mod.textResult(allocator, "done");
}
fn deinit(ctx: *anyopaque, allocator: Allocator) void {
@@ -979,7 +1052,7 @@ const FailingTool = struct {
const vt: Tool.VTable = .{ .invoke = invoke, .deinit = deinit };
- fn invoke(_: *anyopaque, _: []const u8, _: Allocator) anyerror![]u8 {
+ fn invoke(_: *anyopaque, _: []const u8, _: Allocator) anyerror![]tool_mod.ResultPart {
return error.ToolExploded;
}
@@ -1100,12 +1173,16 @@ const TestSource = struct {
try self.batches.append(batch);
for (calls, 0..) |c, i| {
+ const msg = std.fmt.allocPrint(
+ allocator,
+ "{s}->{s}",
+ .{ c.tool_name, c.input },
+ ) catch |e| {
+ results[i] = .{ .err = e };
+ continue;
+ };
results[i] = .{
- .ok = std.fmt.allocPrint(
- allocator,
- "{s}->{s}",
- .{ c.tool_name, c.input },
- ) catch |e| {
+ .ok = tool_mod.ownedTextResult(allocator, msg) catch |e| {
results[i] = .{ .err = e };
continue;
},
@@ -1247,7 +1324,7 @@ test "runStep dispatches a tool call and loops to a final text turn" {
try testing.expectEqual(@as(usize, 1), conv.messages.items[2].content.items.len);
const tr = conv.messages.items[2].content.items[0].ToolResult;
try testing.expectEqualStrings("tc_1", tr.tool_use_id);
- try testing.expectEqualStrings("ECHO:hello", tr.content.items);
+ try testing.expectEqualStrings("ECHO:hello", trText(tr));
try testing.expectEqual(conversation.MessageRole.assistant, conv.messages.items[3].role);
try testing.expectEqualStrings("ok", conv.messages.items[3].content.items[0].Text.items);
@@ -1455,11 +1532,11 @@ test "runStep delivers all source-backed calls in one batch on one thread" {
const tr_msg = conv.messages.items[2];
try testing.expectEqual(@as(usize, 3), tr_msg.content.items.len);
try testing.expectEqualStrings("a", tr_msg.content.items[0].ToolResult.tool_use_id);
- try testing.expectEqualStrings("lua_x->1", tr_msg.content.items[0].ToolResult.content.items);
+ try testing.expectEqualStrings("lua_x->1", trText(tr_msg.content.items[0].ToolResult));
try testing.expectEqualStrings("b", tr_msg.content.items[1].ToolResult.tool_use_id);
- try testing.expectEqualStrings("lua_y->2", tr_msg.content.items[1].ToolResult.content.items);
+ try testing.expectEqualStrings("lua_y->2", trText(tr_msg.content.items[1].ToolResult));
try testing.expectEqualStrings("c", tr_msg.content.items[2].ToolResult.tool_use_id);
- try testing.expectEqualStrings("lua_x->3", tr_msg.content.items[2].ToolResult.content.items);
+ try testing.expectEqualStrings("lua_x->3", trText(tr_msg.content.items[2].ToolResult));
}
test "runStep: distinct sources run on distinct threads in parallel" {
@@ -1566,9 +1643,9 @@ test "runStep: mixed single Tools and source-backed tools coexist in one turn" {
const tr_msg = conv.messages.items[2];
try testing.expectEqual(@as(usize, 3), tr_msg.content.items.len);
- try testing.expectEqualStrings("S:X", tr_msg.content.items[0].ToolResult.content.items);
- try testing.expectEqualStrings("src_t1->Y", tr_msg.content.items[1].ToolResult.content.items);
- try testing.expectEqualStrings("src_t2->Z", tr_msg.content.items[2].ToolResult.content.items);
+ try testing.expectEqualStrings("S:X", trText(tr_msg.content.items[0].ToolResult));
+ try testing.expectEqualStrings("src_t1->Y", trText(tr_msg.content.items[1].ToolResult));
+ try testing.expectEqualStrings("src_t2->Z", trText(tr_msg.content.items[2].ToolResult));
}
test "setConfig swaps the visible tool set between turns" {
@@ -1628,7 +1705,7 @@ test "setConfig swaps the visible tool set between turns" {
const tr = conv.messages.items[2].content.items[0].ToolResult;
try testing.expectEqualStrings("2", tr.tool_use_id);
- try testing.expectEqualStrings("B:B", tr.content.items);
+ try testing.expectEqualStrings("B:B", trText(tr));
}
test "compact: summarizes prefix, keeps suffix, system survives" {