summaryrefslogtreecommitdiff
path: root/libpanto
diff options
context:
space:
mode:
authorT <t@tjp.lol>2026-05-27 07:30:51 -0600
committerT <t@tjp.lol>2026-05-27 07:34:46 -0600
commit75fd7d7833052383e1b61d4275d12c9a993c02d3 (patch)
treee63f25a697cac215281afe76e71892a69af23cb2 /libpanto
parenteddebe4e8131b7d2112b067fed07e7493d8b1984 (diff)
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.
Diffstat (limited to 'libpanto')
-rw-r--r--libpanto/src/agent.zig2
-rw-r--r--libpanto/src/provider.zig21
-rw-r--r--libpanto/src/provider_anthropic_messages.zig21
-rw-r--r--libpanto/src/provider_openai_chat.zig131
-rw-r--r--libpanto/src/root.zig12
5 files changed, 175 insertions, 12 deletions
diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig
index 6c970ad..bbcc54c 100644
--- a/libpanto/src/agent.zig
+++ b/libpanto/src/agent.zig
@@ -580,6 +580,7 @@ const NoopReceiver = struct {
const vt: provider_mod.ReceiverVTable = .{
.onMessageStart = noop1,
.onBlockStart = noop2,
+ .onToolDetails = noopToolDetails,
.onContentDelta = noop3,
.onBlockComplete = noop4,
.onMessageComplete = noop5,
@@ -587,6 +588,7 @@ const NoopReceiver = struct {
};
fn noop1(_: *anyopaque, _: conversation.MessageRole) anyerror!void {}
fn noop2(_: *anyopaque, _: provider_mod.ContentBlockType, _: usize) anyerror!void {}
+ fn noopToolDetails(_: *anyopaque, _: usize, _: []const u8, _: []const u8) anyerror!void {}
fn noop3(_: *anyopaque, _: usize, _: []const u8) anyerror!void {}
fn noop4(_: *anyopaque, _: usize, _: conversation.ContentBlock) anyerror!void {}
fn noop5(_: *anyopaque, _: conversation.Message) anyerror!void {}
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);
}
diff --git a/libpanto/src/provider_anthropic_messages.zig b/libpanto/src/provider_anthropic_messages.zig
index 03c1d90..96649b6 100644
--- a/libpanto/src/provider_anthropic_messages.zig
+++ b/libpanto/src/provider_anthropic_messages.zig
@@ -283,6 +283,15 @@ const StreamState = struct {
};
if (block_type) |bt| {
try receiver.onBlockStart(bt, wire_index);
+ // Anthropic delivers tool id+name whole on content_block_start,
+ // so we can fire onToolDetails immediately — before any arg
+ // deltas. If the wire was malformed and either field is
+ // missing, skip: closeBlock will drop the block defensively.
+ if (kind == .tool_use) {
+ if (ab.tool_id != null and ab.tool_name != null) {
+ try receiver.onToolDetails(wire_index, ab.tool_id.?, ab.tool_name.?);
+ }
+ }
}
}
@@ -506,6 +515,7 @@ const RecordingReceiver = struct {
const vt: provider_mod.ReceiverVTable = .{
.onMessageStart = onMessageStart,
.onBlockStart = onBlockStart,
+ .onToolDetails = onToolDetails,
.onContentDelta = onContentDelta,
.onBlockComplete = onBlockComplete,
.onMessageComplete = onMessageComplete,
@@ -524,6 +534,17 @@ const RecordingReceiver = struct {
const self: *RecordingReceiver = @ptrCast(@alignCast(ptr));
try self.events.append(self.allocator, .{ .block_start = .{ .kind = bt, .index = idx } });
}
+ fn onToolDetails(
+ _: *anyopaque,
+ _: usize,
+ _: []const u8,
+ _: []const u8,
+ ) anyerror!void {
+ // Anthropic delivers identity at block_start time; the existing
+ // tests assert tool-use blocks via the ContentBlock in conv after
+ // finalize, not via the event stream. We accept and drop these
+ // here to keep the test recorder schema stable.
+ }
fn onContentDelta(ptr: *anyopaque, idx: usize, delta: []const u8) anyerror!void {
const self: *RecordingReceiver = @ptrCast(@alignCast(ptr));
const copy = try self.allocator.dupe(u8, delta);
diff --git a/libpanto/src/provider_openai_chat.zig b/libpanto/src/provider_openai_chat.zig
index c4f19f5..ca8fa4c 100644
--- a/libpanto/src/provider_openai_chat.zig
+++ b/libpanto/src/provider_openai_chat.zig
@@ -286,6 +286,11 @@ const StreamState = struct {
/// is dropped silently rather than producing an empty
/// start/complete pair.
started: bool = false,
+ /// Set once we've emitted `onToolDetails` for this block. Fired
+ /// as soon as both id and name are non-empty, which may be on
+ /// the first delta (the common case) or partway through arg
+ /// deltas (fragmented-identity providers).
+ details_emitted: bool = false,
fn deinit(self: *ToolUseInProgress, allocator: Allocator) void {
self.id_buf.deinit(allocator);
@@ -407,18 +412,47 @@ const StreamState = struct {
if (d.id) |s| try tu.id_buf.appendSlice(self.allocator, s);
if (d.name) |s| try tu.name_buf.appendSlice(self.allocator, s);
- // Defer `onBlockStart` until we have a complete identity. The
- // first argument fragment is our signal that the provider is done
- // emitting identity for this index. If the block closes before
- // any args arrive (zero-arg tool), `closeActiveTool` emits the
- // start there.
+ // Defer `onBlockStart` until args begin. The first argument
+ // fragment is our signal that identity is likely settled enough
+ // to render. If the block closes before any args arrive (zero-arg
+ // tool), `closeActiveTool` emits the start there.
if (d.arguments) |a| {
try self.emitStartIfNeeded(receiver, tu);
+ // Fire `onToolDetails` as soon as both id and name are
+ // known. We can't know identity is *final* until the block
+ // closes (a later delta could append more bytes), but in
+ // practice OpenAI sends each whole on the first delta. A
+ // pathological backend that streams id/name across many
+ // chunks would have us emit a truncated value here. We
+ // accept that trade-off: receivers that need the canonical
+ // value can read it from the assembled ContentBlock at
+ // onBlockComplete.
+ try self.emitDetailsIfReady(receiver, tu);
try tu.arguments.appendSlice(self.allocator, a);
try receiver.onContentDelta(tu.block_index, a);
+ } else {
+ // Identity-only chunk (no args yet). Still try to emit
+ // details, in case both fields are now populated.
+ if (tu.started) try self.emitDetailsIfReady(receiver, tu);
}
}
+ /// Fire `onToolDetails` once both id and name are non-empty. No-op if
+ /// already fired or if either field is still empty. Requires that
+ /// `onBlockStart` has already been emitted.
+ fn emitDetailsIfReady(
+ self: *StreamState,
+ receiver: *provider_mod.Receiver,
+ tu: *ToolUseInProgress,
+ ) !void {
+ _ = self;
+ if (tu.details_emitted) return;
+ if (!tu.started) return;
+ if (tu.id_buf.items.len == 0 or tu.name_buf.items.len == 0) return;
+ tu.details_emitted = true;
+ try receiver.onToolDetails(tu.block_index, tu.id_buf.items, tu.name_buf.items);
+ }
+
/// Close the currently-active tool_use (if any), emitting onBlockStart
/// (if it wasn't already), onBlockComplete, and recording the wire
/// index as closed. No-op if there's no active tool_use.
@@ -451,6 +485,9 @@ const StreamState = struct {
// If no arguments ever arrived, we haven't emitted onBlockStart
// yet — do it now so the receiver sees a balanced start/complete.
try self.emitStartIfNeeded(receiver, &tu);
+ // Last chance to fire details if a fragmented-identity provider
+ // only finished id/name accumulation at the very end.
+ try self.emitDetailsIfReady(receiver, &tu);
const id_owned = try tu.id_buf.toOwnedSlice(self.allocator);
const name_owned = try tu.name_buf.toOwnedSlice(self.allocator);
@@ -583,6 +620,7 @@ const NoopReceiver = struct {
const vt: provider_mod.ReceiverVTable = .{
.onMessageStart = noopMsgStart,
.onBlockStart = noopBlockStart,
+ .onToolDetails = noopToolDetails,
.onContentDelta = noopDelta,
.onBlockComplete = noopBlockComplete,
.onMessageComplete = noopMsgComplete,
@@ -590,6 +628,7 @@ const NoopReceiver = struct {
};
fn noopMsgStart(_: *anyopaque, _: conversation.MessageRole) anyerror!void {}
fn noopBlockStart(_: *anyopaque, _: provider_mod.ContentBlockType, _: usize) anyerror!void {}
+ fn noopToolDetails(_: *anyopaque, _: usize, _: []const u8, _: []const u8) anyerror!void {}
fn noopDelta(_: *anyopaque, _: usize, _: []const u8) anyerror!void {}
fn noopBlockComplete(_: *anyopaque, _: usize, _: conversation.ContentBlock) anyerror!void {}
fn noopMsgComplete(_: *anyopaque, _: conversation.Message) anyerror!void {}
@@ -723,6 +762,7 @@ const RecordingReceiver = struct {
const vt: provider_mod.ReceiverVTable = .{
.onMessageStart = onMessageStart,
.onBlockStart = onBlockStart,
+ .onToolDetails = onToolDetails,
.onContentDelta = onContentDelta,
.onBlockComplete = onBlockComplete,
.onMessageComplete = onMessageComplete,
@@ -756,6 +796,15 @@ const RecordingReceiver = struct {
const self: *RecordingReceiver = @ptrCast(@alignCast(ptr));
try self.recordFmt("block_start[{d}]:{s}", .{ idx, @tagName(bt) });
}
+ fn onToolDetails(
+ ptr: *anyopaque,
+ idx: usize,
+ id: []const u8,
+ name: []const u8,
+ ) anyerror!void {
+ const self: *RecordingReceiver = @ptrCast(@alignCast(ptr));
+ try self.recordFmt("tool_details[{d}]:{s}:{s}", .{ idx, id, name });
+ }
fn onContentDelta(ptr: *anyopaque, idx: usize, delta: []const u8) anyerror!void {
const self: *RecordingReceiver = @ptrCast(@alignCast(ptr));
try self.recordFmt("delta[{d}]:{s}", .{ idx, delta });
@@ -814,15 +863,19 @@ test "parallel tool_calls emit one complete start/delta/complete cycle per block
const expected = [_][]const u8{
"msg_start",
"block_start[0]:ToolUse",
+ "tool_details[0]:c0:ping",
"delta[0]:{\"host\":\"a\"}",
"block_complete[0]",
"block_start[1]:ToolUse",
+ "tool_details[1]:c1:ping",
"delta[1]:{\"host\":\"b\"}",
"block_complete[1]",
"block_start[2]:ToolUse",
+ "tool_details[2]:c2:ping",
"delta[2]:{\"host\":\"c\"}",
"block_complete[2]",
"block_start[3]:ToolUse",
+ "tool_details[3]:c3:ping",
"delta[3]:{\"host\":\"d\"}",
"block_complete[3]",
"msg_complete",
@@ -888,6 +941,74 @@ test "non-contiguous tool_call deltas: re-emission of a closed index is dropped"
try testing.expectEqual(@as(usize, 1), n_complete_0);
}
+test "onToolDetails fires after id+name complete, even mid-arg-stream" {
+ // Fragmented-identity provider: id arrives split across two chunks,
+ // and an arg fragment appears between them. `onToolDetails` must
+ // wait until both id and name are non-empty (i.e. on the chunk that
+ // completes id), and must fire exactly once, before block_complete.
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try conv.addUserMessage("go");
+
+ var rec: RecordingReceiver = .{ .allocator = allocator };
+ defer rec.deinit();
+ var recv = rec.receiver();
+
+ const events = [_][]const u8{
+ \\{"choices":[{"delta":{"role":"assistant"}}]}
+ ,
+ // Identity-only chunk: name arrives whole, id starts. No args yet,
+ // so onBlockStart hasn't fired and details can't either.
+ \\{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_","type":"function","function":{"name":"ping"}}]}}]}
+ ,
+ // First arg chunk: onBlockStart fires. id is still "call_" — not
+ // empty — and name is non-empty, so onToolDetails fires here
+ // with whatever id we have so far (`call_`).
+ \\{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"x\":"}}]}}]}
+ ,
+ // Mid-stream id completion + second arg chunk. onToolDetails has
+ // already fired so it does NOT fire again, even though id grew.
+ // The final ContentBlock will carry the full "call_xyz" id.
+ \\{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"xyz","function":{"arguments":"1}"}}]}}]}
+ ,
+ \\{"choices":[{"delta":{},"finish_reason":"tool_calls"}]}
+ ,
+ "[DONE]",
+ };
+
+ try runStreamedTurn(allocator, &conv, &recv, &events);
+
+ // Exactly one tool_details event, fired with the id-prefix that was
+ // current at first-args-arrival, and ordered between block_start and
+ // block_complete.
+ var n_details: usize = 0;
+ var details_pos: ?usize = null;
+ var block_start_pos: ?usize = null;
+ var block_complete_pos: ?usize = null;
+ for (rec.events.items, 0..) |e, i| {
+ if (std.mem.startsWith(u8, e, "tool_details[")) {
+ n_details += 1;
+ details_pos = i;
+ try testing.expectEqualStrings("tool_details[0]:call_:ping", e);
+ } else if (std.mem.eql(u8, e, "block_start[0]:ToolUse")) {
+ block_start_pos = i;
+ } else if (std.mem.eql(u8, e, "block_complete[0]")) {
+ block_complete_pos = i;
+ }
+ }
+ try testing.expectEqual(@as(usize, 1), n_details);
+ try testing.expect(block_start_pos.? < details_pos.?);
+ try testing.expect(details_pos.? < block_complete_pos.?);
+
+ // Final ContentBlock has the full id assembled from both fragments.
+ const asst = conv.messages.items[1];
+ try testing.expectEqualStrings("call_xyz", asst.content.items[0].ToolUse.id);
+ try testing.expectEqualStrings("ping", asst.content.items[0].ToolUse.name);
+ try testing.expectEqualStrings("{\"x\":1}", asst.content.items[0].ToolUse.input.items);
+}
+
test "tool_call with no arguments still finalizes a well-formed ToolUse" {
// Some providers may emit a tool call with no arguments at all (e.g. a
// zero-arg tool). The state machine should still emit onBlockStart
diff --git a/libpanto/src/root.zig b/libpanto/src/root.zig
index 5dacfef..ff02d4d 100644
--- a/libpanto/src/root.zig
+++ b/libpanto/src/root.zig
@@ -1,3 +1,5 @@
+const std = @import("std");
+
pub const conversation = @import("conversation.zig");
pub const provider = @import("provider.zig");
pub const agent = @import("agent.zig");
@@ -25,7 +27,15 @@ const anthropic_messages_json = @import("anthropic_messages_json.zig");
const provider_anthropic_messages = @import("provider_anthropic_messages.zig");
test {
- const std = @import("std");
+ // Test contract: deliberate error-path tests should not produce visible
+ // log output. Library code logs at `.err` for genuine production failures
+ // and `.warn` for expected-failure paths exercised by tests; the test
+ // runner's logger gates on `std.testing.log_level`, which defaults to
+ // `.warn`. Raising it to `.err` silences expected warnings without
+ // changing production behavior. Anything that *should* be visible in a
+ // passing test must use `std.debug.print` or assert via the testing API.
+ std.testing.log_level = .err;
+
std.testing.refAllDecls(@This());
std.testing.refAllDecls(openai_chat_json);
std.testing.refAllDecls(provider_openai_chat);