diff options
Diffstat (limited to 'libpanto/src/agent.zig')
| -rw-r--r-- | libpanto/src/agent.zig | 116 |
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". |
