summaryrefslogtreecommitdiff
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
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.
-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
-rw-r--r--src/main.zig26
6 files changed, 201 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);
diff --git a/src/main.zig b/src/main.zig
index c1212e8..15b2dd2 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -10,6 +10,15 @@ const extension_loader = @import("extension_loader.zig");
const lua = lua_bridge.c;
test {
+ // Test contract: deliberate error-path tests should not produce visible
+ // log output. 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());
_ = lua_bridge;
_ = lua_runtime;
@@ -34,12 +43,29 @@ const CLIReceiver = struct {
const vtable: ReceiverVTable = .{
.onMessageStart = onMessageStart,
.onBlockStart = onBlockStart,
+ .onToolDetails = onToolDetails,
.onContentDelta = onContentDelta,
.onBlockComplete = onBlockComplete,
.onMessageComplete = onMessageComplete,
.onError = onError,
};
+ /// The print-based CLI defers tool-name rendering to onBlockComplete
+ /// to avoid cursor gymnastics (we can't go back and edit the prefix).
+ /// Receivers backed by a TUI capable of in-place updates would render
+ /// the name as soon as it's known here.
+ fn onToolDetails(
+ ptr: *anyopaque,
+ index: usize,
+ id: []const u8,
+ name: []const u8,
+ ) anyerror!void {
+ _ = ptr;
+ _ = index;
+ _ = id;
+ _ = name;
+ }
+
fn onMessageStart(ptr: *anyopaque, role: MessageRole) anyerror!void {
_ = ptr;
_ = role;