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.zig116
1 files changed, 116 insertions, 0 deletions
diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig
index faf0a4a..3616ab6 100644
--- a/libpanto/src/agent.zig
+++ b/libpanto/src/agent.zig
@@ -374,6 +374,68 @@ pub const Agent = struct {
self.flushPersist();
}
+ /// Replace the text of the ToolResult block matching `tool_use_id` in
+ /// the conversation's LAST message. Media parts are preserved (the new
+ /// text becomes the single leading text part); `is_error` is untouched.
+ /// Returns false when the last message holds no such block.
+ ///
+ /// This is the application half of the embedder's "tool_result output
+ /// override" (a `tool_result` event handler assigning `ev.output`):
+ /// called between tool dispatch and the next provider request, the
+ /// override becomes canonical — wire, persistence, and compaction all
+ /// see the replaced text.
+ pub fn overrideToolResultOutput(self: *Agent, tool_use_id: []const u8, text: []const u8) !bool {
+ const msgs = self.conversation.messages.items;
+ if (msgs.len == 0) return false;
+ const last = &msgs[msgs.len - 1];
+ if (last.role != .user) return false;
+ for (last.content.items) |*block| {
+ if (block.* != .ToolResult) continue;
+ const tr = &block.ToolResult;
+ if (!std.mem.eql(u8, tr.tool_use_id, tool_use_id)) continue;
+
+ var new_parts: std.ArrayList(conversation.ResultPartStored) = .empty;
+ errdefer new_parts.deinit(self._allocator);
+ try new_parts.ensureTotalCapacity(self._allocator, tr.parts.items.len + 1);
+ var new_text: conversation.TextualBlock = .empty;
+ try new_text.appendSlice(self._allocator, text);
+ errdefer new_text.deinit(self._allocator);
+ new_parts.appendAssumeCapacity(.{ .text = new_text });
+ for (tr.parts.items) |*p| switch (p.*) {
+ .text => |*t| t.deinit(self._allocator),
+ .media => |m| new_parts.appendAssumeCapacity(.{ .media = m }),
+ };
+ tr.parts.deinit(self._allocator);
+ tr.parts = new_parts;
+ return true;
+ }
+ return false;
+ }
+
+ /// Replace the input JSON of the ToolUse block matching `tool_use_id`
+ /// in the conversation's LAST message (the committed assistant message,
+ /// between response completion and tool dispatch). Returns false when
+ /// no such block exists there.
+ ///
+ /// Application half of the "tool_call_complete input override": the
+ /// rewritten input is what the tool executes with AND what the model
+ /// sees of its own call in future requests.
+ pub fn overrideToolUseInput(self: *Agent, tool_use_id: []const u8, input_json: []const u8) !bool {
+ const msgs = self.conversation.messages.items;
+ if (msgs.len == 0) return false;
+ const last = &msgs[msgs.len - 1];
+ if (last.role != .assistant) return false;
+ for (last.content.items) |*block| {
+ if (block.* != .ToolUse) continue;
+ const tu = &block.ToolUse;
+ if (!std.mem.eql(u8, tu.id, tool_use_id)) continue;
+ tu.input.clearRetainingCapacity();
+ try tu.input.appendSlice(self._allocator, input_json);
+ return true;
+ }
+ return false;
+ }
+
/// Submit a user message and begin a turn, returning a resumable pull
/// `Stream`.
///
@@ -2216,6 +2278,60 @@ test "agent runs a turn against NullStore without persisting or erroring" {
try testing.expectEqual(@as(usize, 2), agent.conversation.messages.items.len);
}
+test "override mutators rewrite the conversation tail's tool blocks" {
+ const allocator = testing.allocator;
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var h = TestHarness.init(allocator);
+ defer h.deinit();
+ h.activate();
+ var ns = null_store_mod.NullStore.init(allocator);
+ const agent = try Agent.init(allocator, io, &h.config, ns.store().create(), null);
+ defer agent.deinit();
+
+ const conv = &agent.conversation;
+ // Committed assistant message carrying one ToolUse.
+ var a_content: std.ArrayList(conversation.ContentBlock) = .empty;
+ try a_content.append(allocator, .{ .ToolUse = .{
+ .id = try allocator.dupe(u8, "tc_1"),
+ .name = try allocator.dupe(u8, "read"),
+ .input = try conversation.textualBlockFromSlice(allocator, "{\"path\":\"orig\"}"),
+ } });
+ try conv.messages.append(allocator, .{ .role = .assistant, .content = a_content });
+
+ // Input override rewrites the tail's matching ToolUse; unknown ids miss.
+ try testing.expect(try agent.overrideToolUseInput("tc_1", "{\"path\":\"new\"}"));
+ try testing.expect(!try agent.overrideToolUseInput("tc_missing", "{}"));
+ const tu = conv.messages.items[0].content.items[0].ToolUse;
+ try testing.expectEqualStrings("{\"path\":\"new\"}", tu.input.items);
+
+ // Tool-result user message: a text part followed by a media part.
+ var parts: std.ArrayList(conversation.ResultPartStored) = .empty;
+ try parts.append(allocator, .{ .text = try conversation.textualBlockFromSlice(allocator, "original output") });
+ try parts.append(allocator, .{ .media = .{
+ .media_type = try allocator.dupe(u8, "image/png"),
+ .data = try conversation.textualBlockFromSlice(allocator, "aW1n"),
+ } });
+ var u_content: std.ArrayList(conversation.ContentBlock) = .empty;
+ try u_content.append(allocator, .{ .ToolResult = .{
+ .tool_use_id = try allocator.dupe(u8, "tc_1"),
+ .parts = parts,
+ } });
+ try conv.messages.append(allocator, .{ .role = .user, .content = u_content });
+
+ // The ToolUse override only targets the tail — now a user message.
+ try testing.expect(!try agent.overrideToolUseInput("tc_1", "{}"));
+
+ // Output override replaces the text, keeps the media part.
+ try testing.expect(try agent.overrideToolResultOutput("tc_1", "replaced output"));
+ try testing.expect(!try agent.overrideToolResultOutput("tc_other", "x"));
+ const tr = conv.messages.items[1].content.items[0].ToolResult;
+ try testing.expectEqual(@as(usize, 2), tr.parts.items.len);
+ try testing.expectEqualStrings("replaced output", tr.parts.items[0].text.items);
+ try testing.expectEqualStrings("image/png", tr.parts.items[1].media.media_type);
+}
+
/// A configurable ToolSource for testing the grouped-dispatch path.
/// Stores every batch it receives so tests can assert "calls X and Y
/// arrived in the same batch on the same thread".