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.zig61
1 files changed, 60 insertions, 1 deletions
diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig
index 0ff231a..8a8a2e2 100644
--- a/libpanto/src/agent.zig
+++ b/libpanto/src/agent.zig
@@ -1150,6 +1150,10 @@ pub const Stream = struct {
streaming,
/// A provider response completed; decide tools-vs-done.
after_response,
+ /// Tool dispatch has been announced to callers; run the blocking
+ /// dispatch on the next pull so `tool_dispatch_start` is observable
+ /// before long-running tools complete.
+ dispatching_tools,
/// The turn reached its terminal `turn_complete`.
done,
/// A failure already propagated; `next()` is poisoned.
@@ -1328,12 +1332,25 @@ pub const Stream = struct {
return .turn_complete;
}
- // Dispatch the tool calls, bracketed by boundary events.
+ // Announce tool dispatch and return immediately. The
+ // dispatch itself can block for a long time (especially
+ // with parallel tools); yielding this boundary event first
+ // lets UIs/renderers show all tool calls as running instead
+ // of appearing frozen until the slowest tool completes.
const count = toolUseCount(last);
self._queue.push(.{ .tool_dispatch_start = .{ .count = count } }) catch |e| {
self.state = .failed;
return e;
};
+ self.state = .dispatching_tools;
+ if (self._queue.pop()) |ev| return ev;
+ },
+ .dispatching_tools => {
+ const conv = &self._agent.conversation;
+ const last = conv.messages.items[conv.messages.items.len - 1];
+ std.debug.assert(last.role == .assistant);
+ std.debug.assert(Agent.hasToolUseBlock(last));
+
self._agent.dispatchToolCalls(last) catch |err| {
self.state = .failed;
return err;
@@ -2394,6 +2411,48 @@ test "runStep dispatches a tool call and loops to a final text turn" {
try testing.expectEqualStrings("ok", conv.messages.items[3].content.items[0].Text.items);
}
+test "Stream emits tool_dispatch_start before running tools" {
+ const allocator = testing.allocator;
+
+ const scripted = [_]StubProvider.ScriptedTurn{
+ .{ .blocks = &.{
+ .{ .ToolUse = .{ .id = "tc_1", .name = "echo", .input = "hello" } },
+ } },
+ .{ .blocks = &.{.{ .Text = "ok" }} },
+ };
+ var stub = StubProvider{ .allocator = allocator, .scripted = &scripted };
+ var threaded: std.Io.Threaded = .init(allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
+ var h = TestHarness.init(allocator);
+ defer h.deinit();
+ try h.registry.register(try EchoTool.create(allocator, "echo", "ECHO:"));
+ 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();
+ h.seedInto(agent);
+ agent._open_stream_fn = stub.install();
+
+ var s = try agent.run(.{ .text = "call a tool" });
+ defer s.deinit();
+
+ const first = (try s.next()).?;
+ try testing.expect(first == .message_complete);
+ try testing.expectEqual(@as(usize, 2), agent.conversation.messages.items.len);
+
+ const start = (try s.next()).?;
+ try testing.expect(start == .tool_dispatch_start);
+ try testing.expectEqual(@as(usize, 1), start.tool_dispatch_start.count);
+ // The ToolResult user message must not exist yet; otherwise callers do
+ // not get a chance to render the tool as running before dispatch blocks.
+ try testing.expectEqual(@as(usize, 2), agent.conversation.messages.items.len);
+
+ const complete = (try s.next()).?;
+ try testing.expect(complete == .tool_dispatch_complete);
+ try testing.expectEqual(@as(usize, 3), agent.conversation.messages.items.len);
+}
+
test "runStep dispatches multiple tool calls in parallel" {
const allocator = testing.allocator;