From 75fd7d7833052383e1b61d4275d12c9a993c02d3 Mon Sep 17 00:00:00 2001 From: T Date: Wed, 27 May 2026 07:30:51 -0600 Subject: separate event for providing tool details OpenAI and Anthropic have tool details (id, name) converge on different timelines. In all cases they are available in `onBlockComplete` which comes with the full block, and previously we had an optional `BlockMeta` argument to `onBlockStart` - the anthropic timeline. We removed the `BlockMeta` from `onBlockStart` since it was always going to be unreliable, and now have an explicit `onToolDetails` instead. This allows us to provide the tool id and name as early as possible from the openai provider (could be in the middle of content deltas), and provide a reliable signal across providers. Also, fix test output. --- libpanto/src/provider.zig | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) (limited to 'libpanto/src/provider.zig') diff --git a/libpanto/src/provider.zig b/libpanto/src/provider.zig index fd170f0..bc39026 100644 --- a/libpanto/src/provider.zig +++ b/libpanto/src/provider.zig @@ -19,12 +19,16 @@ pub const ContentBlockType = enum { /// stops streaming, calls `onError(err)`, and propagates `err` out of /// `streamStep`. No partial assistant message is appended to the conversation. /// -/// Identity fields (tool id/name for ToolUse blocks) are intentionally *not* -/// surfaced at `onBlockStart`. They cannot be reliably known at start time -/// across all wire protocols — OpenAI streaming may deliver `id` and `name` -/// in fragments across multiple deltas — so any "meta" we passed at start -/// would be a phantom guarantee. Receivers that need identity get it from -/// the fully-assembled `ContentBlock` delivered to `onBlockComplete`. +/// Tool-use identity (`id`, `name`) is delivered via `onToolDetails`, fired +/// once per ToolUse block at the earliest moment both fields are known. For +/// Anthropic this is immediately after `onBlockStart`, before any deltas. For +/// OpenAI Chat Completions this may be partway through the arg-deltas, since +/// the wire protocol can split `id` and `name` across multiple streaming +/// chunks. The only guarantees are: it fires strictly after the block's +/// `onBlockStart`, strictly before its `onBlockComplete`, and at most once +/// per ToolUse block. It never fires for non-ToolUse blocks. If a tool_use +/// block is dropped because identity never fully arrived, `onToolDetails` +/// (and `onBlockComplete`) never fire for it. /// /// `onError` is the receiver's cleanup hook. It fires exactly once per failed /// turn, whether the error originated in the receiver itself (a write failure) @@ -34,6 +38,7 @@ pub const ContentBlockType = enum { pub const ReceiverVTable = struct { onMessageStart: *const fn (*anyopaque, conversation.MessageRole) anyerror!void, onBlockStart: *const fn (*anyopaque, ContentBlockType, usize) anyerror!void, + onToolDetails: *const fn (*anyopaque, usize, []const u8, []const u8) anyerror!void, onContentDelta: *const fn (*anyopaque, usize, []const u8) anyerror!void, onBlockComplete: *const fn (*anyopaque, usize, conversation.ContentBlock) anyerror!void, onMessageComplete: *const fn (*anyopaque, conversation.Message) anyerror!void, @@ -52,6 +57,10 @@ pub const Receiver = struct { try self.vtable.onBlockStart(self.ptr, block_type, index); } + pub fn onToolDetails(self: Receiver, block_index: usize, id: []const u8, name: []const u8) !void { + try self.vtable.onToolDetails(self.ptr, block_index, id, name); + } + pub fn onContentDelta(self: Receiver, block_index: usize, delta: []const u8) !void { try self.vtable.onContentDelta(self.ptr, block_index, delta); } -- cgit v1.3