summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--examples/extensions/echo.lua2
-rw-r--r--libpanto/src/agent.zig2
-rw-r--r--libpanto/src/provider.zig19
-rw-r--r--libpanto/src/provider_anthropic_messages.zig21
-rw-r--r--libpanto/src/provider_openai_chat.zig403
-rw-r--r--src/main.zig14
6 files changed, 331 insertions, 130 deletions
diff --git a/examples/extensions/echo.lua b/examples/extensions/echo.lua
index d13920e..53b1a3a 100644
--- a/examples/extensions/echo.lua
+++ b/examples/extensions/echo.lua
@@ -1,7 +1,5 @@
-- A trivial Lua tool that echoes back whatever the LLM asks it to.
-- Useful for exercising the panto CLI's Lua extension path end-to-end.
---
--- Load with: panto --lua examples/extensions/echo.lua
panto.register_tool {
name = "echo",
diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig
index 55396d1..6c970ad 100644
--- a/libpanto/src/agent.zig
+++ b/libpanto/src/agent.zig
@@ -586,7 +586,7 @@ const NoopReceiver = struct {
.onError = noop6,
};
fn noop1(_: *anyopaque, _: conversation.MessageRole) anyerror!void {}
- fn noop2(_: *anyopaque, _: provider_mod.ContentBlockType, _: usize, _: ?provider_mod.BlockMeta) anyerror!void {}
+ fn noop2(_: *anyopaque, _: provider_mod.ContentBlockType, _: usize) 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 177fc2f..fd170f0 100644
--- a/libpanto/src/provider.zig
+++ b/libpanto/src/provider.zig
@@ -12,12 +12,6 @@ pub const ContentBlockType = enum {
ToolResult,
};
-pub const BlockMeta = struct {
- /// Only populated for ToolUse blocks. Null for Text/Thinking.
- tool_id: ?[]const u8 = null,
- tool_name: ?[]const u8 = null,
-};
-
/// Vtable for receiving streaming events from a Provider.
///
/// The lifecycle callbacks (`onMessageStart` ... `onMessageComplete`) return
@@ -25,6 +19,13 @@ pub const BlockMeta = struct {
/// 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`.
+///
/// `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)
/// or in the Provider (HTTP/parse/stream failure). It is the last callback the
@@ -32,7 +33,7 @@ pub const BlockMeta = struct {
/// must swallow secondary failures during cleanup.
pub const ReceiverVTable = struct {
onMessageStart: *const fn (*anyopaque, conversation.MessageRole) anyerror!void,
- onBlockStart: *const fn (*anyopaque, ContentBlockType, usize, ?BlockMeta) anyerror!void,
+ onBlockStart: *const fn (*anyopaque, ContentBlockType, usize) 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,
@@ -47,8 +48,8 @@ pub const Receiver = struct {
try self.vtable.onMessageStart(self.ptr, role);
}
- pub fn onBlockStart(self: Receiver, block_type: ContentBlockType, index: usize, meta: ?BlockMeta) !void {
- try self.vtable.onBlockStart(self.ptr, block_type, index, meta);
+ pub fn onBlockStart(self: Receiver, block_type: ContentBlockType, index: usize) !void {
+ try self.vtable.onBlockStart(self.ptr, block_type, index);
}
pub fn onContentDelta(self: Receiver, block_index: usize, delta: []const u8) !void {
diff --git a/libpanto/src/provider_anthropic_messages.zig b/libpanto/src/provider_anthropic_messages.zig
index 7511ce8..03c1d90 100644
--- a/libpanto/src/provider_anthropic_messages.zig
+++ b/libpanto/src/provider_anthropic_messages.zig
@@ -257,7 +257,8 @@ const StreamState = struct {
receiver: *provider_mod.Receiver,
wire_index: usize,
kind: BlockKind,
- tool_meta: ?provider_mod.BlockMeta,
+ tool_id: ?[]const u8,
+ tool_name: ?[]const u8,
) !void {
// Defensive: if a prior block didn't get an explicit stop, drop it.
if (self.active != null) {
@@ -267,12 +268,11 @@ const StreamState = struct {
.wire_index = wire_index,
.kind = kind,
};
- // For tool_use blocks, capture the identity fields from the meta.
+ // For tool_use blocks, capture the identity fields. Anthropic
+ // delivers both whole on content_block_start.
if (kind == .tool_use) {
- if (tool_meta) |m| {
- if (m.tool_id) |id| ab.tool_id = try self.allocator.dupe(u8, id);
- if (m.tool_name) |n| ab.tool_name = try self.allocator.dupe(u8, n);
- }
+ if (tool_id) |id| ab.tool_id = try self.allocator.dupe(u8, id);
+ if (tool_name) |n| ab.tool_name = try self.allocator.dupe(u8, n);
}
self.active = ab;
const block_type: ?provider_mod.ContentBlockType = switch (kind) {
@@ -282,7 +282,7 @@ const StreamState = struct {
.unsupported => null,
};
if (block_type) |bt| {
- try receiver.onBlockStart(bt, wire_index, tool_meta);
+ try receiver.onBlockStart(bt, wire_index);
}
}
@@ -418,11 +418,7 @@ fn handleEvent(
.tool_use => .tool_use,
.unknown => .unsupported,
};
- const meta: ?provider_mod.BlockMeta = if (s.kind == .tool_use)
- .{ .tool_id = s.tool_id, .tool_name = s.tool_name }
- else
- null;
- try state.openBlock(receiver, s.index, kind, meta);
+ try state.openBlock(receiver, s.index, kind, s.tool_id, s.tool_name);
},
.content_block_delta => |d| {
if (d.text_delta) |t| try state.appendTextDelta(receiver, t);
@@ -524,7 +520,6 @@ const RecordingReceiver = struct {
ptr: *anyopaque,
bt: provider_mod.ContentBlockType,
idx: usize,
- _: ?provider_mod.BlockMeta,
) anyerror!void {
const self: *RecordingReceiver = @ptrCast(@alignCast(ptr));
try self.events.append(self.allocator, .{ .block_start = .{ .kind = bt, .index = idx } });
diff --git a/libpanto/src/provider_openai_chat.zig b/libpanto/src/provider_openai_chat.zig
index 2343ac1..c4f19f5 100644
--- a/libpanto/src/provider_openai_chat.zig
+++ b/libpanto/src/provider_openai_chat.zig
@@ -223,12 +223,19 @@ pub const OpenAIChatProvider = struct {
/// being assembled, accumulated content, and the assistant message being
/// built up for the final `onMessageComplete` callback.
///
-/// Text and thinking blocks are mutually exclusive at the active position
-/// (openai_chat streams one at a time and we infer transitions). ToolUse
-/// blocks live in a side map keyed by the wire's per-call `index`; multiple
-/// tool calls can stream concurrently in the same response. At finalize
-/// time, tool_use blocks are appended after the text/thinking blocks in
-/// ascending wire-index order.
+/// We model the assistant message as a sequence of blocks, exactly one of
+/// which is active at a time. Text/thinking transitions are inferred from
+/// which field a delta carries. Tool_use blocks arrive as a per-call wire
+/// `index`; the OpenAI Chat Completions streaming spec does not formally
+/// promise that all fragments for a given index arrive contiguously, but in
+/// practice every well-behaved backend (and the official Node SDK's own
+/// reassembly logic) treats a delta for a new index as the implicit close
+/// of the previous one. We do the same: seeing a delta for an index that
+/// differs from `current_tool_index` closes the prior tool_use and opens a
+/// new one. `finish_reason` closes the last still-open tool_use. A delta
+/// arriving for an index that has already been closed is a degenerate
+/// backend behavior (e.g. vLLM with speculative decoding under some
+/// configurations) — we log an error and drop the fragment.
const StreamState = struct {
allocator: Allocator,
started: bool = false,
@@ -239,8 +246,6 @@ const StreamState = struct {
finalized: bool = false,
active: ActiveBlock = .none,
/// Block index reported to the receiver. Increments per block boundary.
- /// Tool-use blocks reuse a per-tool-call counter (`tool_block_index`
- /// inside `ToolUseInProgress`) for their `onContentDelta` reports.
block_index: usize = 0,
/// Buffer for the currently-streaming text/thinking block. Owned by
@@ -248,11 +253,18 @@ const StreamState = struct {
/// transfers to the assembled Message.
current_buf: conversation.TextualBlock = .empty,
- /// Assembled non-tool-use blocks for the final message, in stream order.
+ /// Assembled blocks for the final message, in stream order.
blocks: std.ArrayList(conversation.ContentBlock) = .empty,
- /// In-progress tool_use blocks keyed by wire index.
- tool_uses: std.AutoHashMap(usize, ToolUseInProgress),
+ /// The currently-streaming tool_use, if any. Closed when a delta for
+ /// a different wire index arrives, or at finalize.
+ active_tool: ?ToolUseInProgress = null,
+ /// Wire index of `active_tool` (when non-null).
+ current_tool_index: ?usize = null,
+ /// Wire indices that have already been closed. Used solely to detect
+ /// (and report) the degenerate case of a delta arriving for an index
+ /// whose block we've already emitted.
+ closed_tool_indices: std.AutoHashMap(usize, void),
const ToolUseInProgress = struct {
/// Block index emitted to the receiver for this tool call's
@@ -266,10 +278,13 @@ const StreamState = struct {
id_buf: conversation.TextualBlock = .empty,
name_buf: conversation.TextualBlock = .empty,
arguments: conversation.TextualBlock = .empty,
- /// We defer `onBlockStart` until either the first argument fragment
- /// arrives or finalize runs — whichever happens first — because by
- /// then identity is almost certainly complete and we can pass a
- /// well-formed `BlockMeta` to the receiver.
+ /// Set once we've emitted `onBlockStart(.ToolUse, ...)` for this
+ /// block. We defer until either the first argument fragment
+ /// arrives or the block is closed — not for identity reasons
+ /// (identity is no longer passed at start) but to keep block
+ /// indices clean: a tool_call that turns out to lack id or name
+ /// is dropped silently rather than producing an empty
+ /// start/complete pair.
started: bool = false,
fn deinit(self: *ToolUseInProgress, allocator: Allocator) void {
@@ -282,7 +297,7 @@ const StreamState = struct {
fn init(allocator: Allocator) StreamState {
return .{
.allocator = allocator,
- .tool_uses = std.AutoHashMap(usize, ToolUseInProgress).init(allocator),
+ .closed_tool_indices = std.AutoHashMap(usize, void).init(allocator),
};
}
@@ -290,9 +305,8 @@ const StreamState = struct {
self.current_buf.deinit(self.allocator);
for (self.blocks.items) |*b| b.deinit(self.allocator);
self.blocks.deinit(self.allocator);
- var it = self.tool_uses.iterator();
- while (it.next()) |entry| entry.value_ptr.deinit(self.allocator);
- self.tool_uses.deinit();
+ if (self.active_tool) |*tu| tu.deinit(self.allocator);
+ self.closed_tool_indices.deinit();
}
/// Close the active text/thinking block (if any) and emit
@@ -332,7 +346,7 @@ const StreamState = struct {
.thinking => .Thinking,
.tool_use, .none => unreachable,
};
- try receiver.onBlockStart(block_type, self.block_index, null);
+ try receiver.onBlockStart(block_type, self.block_index);
}
fn appendDelta(
@@ -344,25 +358,47 @@ const StreamState = struct {
try receiver.onContentDelta(self.block_index, delta);
}
- /// Apply one streaming tool_call delta. Allocates a new
- /// `ToolUseInProgress` slot on first sight of each wire index.
+ /// Apply one streaming tool_call delta. Opens a new tool_use on the
+ /// first sight of a wire index, closing any prior tool_use (or active
+ /// text/thinking block) first. A delta for an already-closed index is
+ /// a malformed stream — we log and drop it.
fn applyToolCallDelta(
self: *StreamState,
receiver: *provider_mod.Receiver,
d: json_mod.ToolCallDelta,
) !void {
- const gop = try self.tool_uses.getOrPut(d.index);
- if (!gop.found_existing) {
- // First sight: close any open text/thinking block so the
- // tool_use gets its own block_index.
+ // Degenerate backend: a delta arrived for an index whose block we
+ // already finalized. Drop the fragment so we don't reopen a closed
+ // block, but log loudly enough to make this diagnosable.
+ if (self.closed_tool_indices.contains(d.index)) {
+ if (!@import("builtin").is_test) {
+ std.log.err(
+ "openai_chat: dropping tool_call delta for already-closed wire index {d} (non-contiguous tool_call stream); id={?s} name={?s} args={?s}",
+ .{ d.index, d.id, d.name, d.arguments },
+ );
+ }
+ return;
+ }
+
+ // Wire-index change closes the previously-active tool_use. This is
+ // the only signal openai_chat gives us for mid-stream tool_use
+ // boundaries; see the StreamState doc-comment for the rationale.
+ if (self.current_tool_index) |cur| {
+ if (cur != d.index) try self.closeActiveTool(receiver);
+ }
+
+ if (self.active_tool == null) {
+ // Opening a new tool_use. First close any open text/thinking
+ // block so the tool_use gets its own block_index.
if (self.active != .none) {
try self.closeActive(receiver);
self.block_index += 1;
}
- gop.value_ptr.* = .{ .block_index = self.block_index };
+ self.active_tool = .{ .block_index = self.block_index };
+ self.current_tool_index = d.index;
self.block_index += 1;
}
- const tu = gop.value_ptr;
+ const tu = &self.active_tool.?;
// Append identity fragments. Most providers send id+name whole on
// the first delta and never repeat them, but appending is the only
@@ -373,8 +409,9 @@ const StreamState = struct {
// 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 finalize runs first (i.e.
- // a tool call with no arguments), we emit it there.
+ // emitting identity for this index. 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);
try tu.arguments.appendSlice(self.allocator, a);
@@ -382,9 +419,60 @@ const StreamState = struct {
}
}
- /// Emit `onBlockStart(.ToolUse, ...)` once per in-progress tool use,
- /// passing whatever identity we have. Callers must invoke this before
- /// the first `onContentDelta` or `onBlockComplete` for the block.
+ /// 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.
+ fn closeActiveTool(self: *StreamState, receiver: *provider_mod.Receiver) !void {
+ var tu = self.active_tool orelse return;
+ self.active_tool = null;
+ const wire_index = self.current_tool_index.?;
+ self.current_tool_index = null;
+ try self.closed_tool_indices.put(wire_index, {});
+
+ // Drop entries lacking id or name. The stream closed the block
+ // before the provider sent enough to identify which tool was
+ // being called — there's nothing we can dispatch.
+ if (tu.id_buf.items.len == 0 or tu.name_buf.items.len == 0) {
+ if (!@import("builtin").is_test) {
+ std.log.err(
+ "openai_chat: dropping incomplete tool_use at wire index {d}: id={d} bytes, name=\"{s}\", args={d} bytes",
+ .{
+ wire_index,
+ tu.id_buf.items.len,
+ tu.name_buf.items,
+ tu.arguments.items.len,
+ },
+ );
+ }
+ tu.deinit(self.allocator);
+ return;
+ }
+
+ // 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);
+
+ const id_owned = try tu.id_buf.toOwnedSlice(self.allocator);
+ const name_owned = try tu.name_buf.toOwnedSlice(self.allocator);
+ const block: conversation.ContentBlock = .{ .ToolUse = .{
+ .id = id_owned,
+ .name = name_owned,
+ .input = tu.arguments,
+ } };
+ // Ownership has moved into `block`; clear the local before it
+ // goes out of scope so deinit doesn't double-free.
+ tu.arguments = .empty;
+
+ try self.blocks.append(self.allocator, block);
+ try receiver.onBlockComplete(tu.block_index, self.blocks.items[self.blocks.items.len - 1]);
+ }
+
+ /// Emit `onBlockStart(.ToolUse, ...)` once per in-progress tool use.
+ /// Callers must invoke this before the first `onContentDelta` or
+ /// `onBlockComplete` for the block. Identity (id/name) is *not*
+ /// passed at start — see provider.zig's ReceiverVTable docs for the
+ /// rationale. Receivers get identity from the assembled ContentBlock
+ /// at onBlockComplete time.
fn emitStartIfNeeded(
self: *StreamState,
receiver: *provider_mod.Receiver,
@@ -393,19 +481,12 @@ const StreamState = struct {
_ = self;
if (tu.started) return;
tu.started = true;
- const meta: ?provider_mod.BlockMeta = if (tu.id_buf.items.len > 0 or tu.name_buf.items.len > 0)
- .{
- .tool_id = if (tu.id_buf.items.len > 0) tu.id_buf.items else null,
- .tool_name = if (tu.name_buf.items.len > 0) tu.name_buf.items else null,
- }
- else
- null;
- try receiver.onBlockStart(.ToolUse, tu.block_index, meta);
+ try receiver.onBlockStart(.ToolUse, tu.block_index);
}
- /// End the stream: close any open text/thinking block, finalize all
- /// in-flight tool_use blocks (in ascending wire-index order), then
- /// commit the assembled assistant Message to the conversation.
+ /// End the stream: close any open text/thinking block, close the still-
+ /// active tool_use (if any), then commit the assembled assistant
+ /// Message to the conversation.
fn finalize(
self: *StreamState,
receiver: *provider_mod.Receiver,
@@ -415,59 +496,7 @@ const StreamState = struct {
self.finalized = true;
try self.closeActive(receiver);
-
- // Collect tool_use indices in ascending order for deterministic
- // ordering in the final message.
- var indices: std.ArrayList(usize) = .empty;
- defer indices.deinit(self.allocator);
- var it = self.tool_uses.iterator();
- while (it.next()) |entry| try indices.append(self.allocator, entry.key_ptr.*);
- std.mem.sort(usize, indices.items, {}, std.sort.asc(usize));
-
- for (indices.items) |idx| {
- const tu_ptr = self.tool_uses.getPtr(idx).?;
- // Drop entries lacking id or name. The stream ended before
- // the provider sent enough to identify which tool was being
- // called — there's nothing we can dispatch. We log enough
- // detail to make this diagnosable; the agent will surface the
- // resulting empty assistant message as EmptyAssistantResponse.
- if (tu_ptr.id_buf.items.len == 0 or tu_ptr.name_buf.items.len == 0) {
- if (!@import("builtin").is_test) {
- std.log.err(
- "openai_chat: dropping incomplete tool_use at wire index {d}: id={d} bytes, name=\"{s}\", args={d} bytes",
- .{
- idx,
- tu_ptr.id_buf.items.len,
- tu_ptr.name_buf.items,
- tu_ptr.arguments.items.len,
- },
- );
- }
- tu_ptr.deinit(self.allocator);
- continue;
- }
-
- // 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_ptr);
-
- const block_index = tu_ptr.block_index;
- const id_owned = try tu_ptr.id_buf.toOwnedSlice(self.allocator);
- const name_owned = try tu_ptr.name_buf.toOwnedSlice(self.allocator);
- const block: conversation.ContentBlock = .{ .ToolUse = .{
- .id = id_owned,
- .name = name_owned,
- .input = tu_ptr.arguments,
- } };
- // Ownership has moved into `block`; clear the in-progress slot.
- tu_ptr.arguments = .empty;
-
- try self.blocks.append(self.allocator, block);
- try receiver.onBlockComplete(
- block_index,
- self.blocks.items[self.blocks.items.len - 1],
- );
- }
+ try self.closeActiveTool(receiver);
// Move blocks into a fresh conversation message.
const moved_blocks = try self.blocks.toOwnedSlice(self.allocator);
@@ -560,7 +589,7 @@ const NoopReceiver = struct {
.onError = noopErr,
};
fn noopMsgStart(_: *anyopaque, _: conversation.MessageRole) anyerror!void {}
- fn noopBlockStart(_: *anyopaque, _: provider_mod.ContentBlockType, _: usize, _: ?provider_mod.BlockMeta) anyerror!void {}
+ fn noopBlockStart(_: *anyopaque, _: provider_mod.ContentBlockType, _: usize) 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 {}
@@ -681,6 +710,184 @@ test "fragmented tool_call id and name are reassembled" {
try testing.expectEqualStrings("{\"host\":\"a.com\"}", tu.input.items);
}
+/// A Receiver that records the sequence of callback events as compact
+/// strings. Useful for asserting per-block start/complete ordering.
+const RecordingReceiver = struct {
+ allocator: Allocator,
+ events: std.ArrayList([]const u8) = .empty,
+
+ fn receiver(self: *RecordingReceiver) provider_mod.Receiver {
+ return .{ .ptr = self, .vtable = &vt };
+ }
+
+ const vt: provider_mod.ReceiverVTable = .{
+ .onMessageStart = onMessageStart,
+ .onBlockStart = onBlockStart,
+ .onContentDelta = onContentDelta,
+ .onBlockComplete = onBlockComplete,
+ .onMessageComplete = onMessageComplete,
+ .onError = onError,
+ };
+
+ fn record(self: *RecordingReceiver, s: []const u8) !void {
+ const owned = try self.allocator.dupe(u8, s);
+ try self.events.append(self.allocator, owned);
+ }
+
+ fn recordFmt(self: *RecordingReceiver, comptime fmt: []const u8, args: anytype) !void {
+ const owned = try std.fmt.allocPrint(self.allocator, fmt, args);
+ try self.events.append(self.allocator, owned);
+ }
+
+ fn deinit(self: *RecordingReceiver) void {
+ for (self.events.items) |e| self.allocator.free(e);
+ self.events.deinit(self.allocator);
+ }
+
+ fn onMessageStart(ptr: *anyopaque, _: conversation.MessageRole) anyerror!void {
+ const self: *RecordingReceiver = @ptrCast(@alignCast(ptr));
+ try self.record("msg_start");
+ }
+ fn onBlockStart(
+ ptr: *anyopaque,
+ bt: provider_mod.ContentBlockType,
+ idx: usize,
+ ) anyerror!void {
+ const self: *RecordingReceiver = @ptrCast(@alignCast(ptr));
+ try self.recordFmt("block_start[{d}]:{s}", .{ idx, @tagName(bt) });
+ }
+ 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 });
+ }
+ fn onBlockComplete(
+ ptr: *anyopaque,
+ idx: usize,
+ _: conversation.ContentBlock,
+ ) anyerror!void {
+ const self: *RecordingReceiver = @ptrCast(@alignCast(ptr));
+ try self.recordFmt("block_complete[{d}]", .{idx});
+ }
+ fn onMessageComplete(ptr: *anyopaque, _: conversation.Message) anyerror!void {
+ const self: *RecordingReceiver = @ptrCast(@alignCast(ptr));
+ try self.record("msg_complete");
+ }
+ fn onError(_: *anyopaque, _: anyerror) void {}
+};
+
+test "parallel tool_calls emit one complete start/delta/complete cycle per block" {
+ // Regression test: previously, the OpenAI provider deferred ALL
+ // tool_use onBlockComplete callbacks to finalize, so a four-tool
+ // parallel batch produced start/start/start/start/delta*/complete/
+ // complete/complete/complete — the receiver couldn't render each tool
+ // as its own discrete block. With the new contiguity-driven close-on-
+ // next-index logic, each tool_use should produce a contiguous
+ // start → delta(s) → complete trio.
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try conv.addUserMessage("ping four hosts");
+
+ var rec: RecordingReceiver = .{ .allocator = allocator };
+ defer rec.deinit();
+ var recv = rec.receiver();
+
+ const events = [_][]const u8{
+ \\{"choices":[{"delta":{"role":"assistant"}}]}
+ ,
+ \\{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c0","type":"function","function":{"name":"ping","arguments":"{\"host\":\"a\"}"}}]}}]}
+ ,
+ \\{"choices":[{"delta":{"tool_calls":[{"index":1,"id":"c1","type":"function","function":{"name":"ping","arguments":"{\"host\":\"b\"}"}}]}}]}
+ ,
+ \\{"choices":[{"delta":{"tool_calls":[{"index":2,"id":"c2","type":"function","function":{"name":"ping","arguments":"{\"host\":\"c\"}"}}]}}]}
+ ,
+ \\{"choices":[{"delta":{"tool_calls":[{"index":3,"id":"c3","type":"function","function":{"name":"ping","arguments":"{\"host\":\"d\"}"}}]}}]}
+ ,
+ \\{"choices":[{"delta":{},"finish_reason":"tool_calls"}]}
+ ,
+ "[DONE]",
+ };
+
+ try runStreamedTurn(allocator, &conv, &recv, &events);
+
+ const expected = [_][]const u8{
+ "msg_start",
+ "block_start[0]:ToolUse",
+ "delta[0]:{\"host\":\"a\"}",
+ "block_complete[0]",
+ "block_start[1]:ToolUse",
+ "delta[1]:{\"host\":\"b\"}",
+ "block_complete[1]",
+ "block_start[2]:ToolUse",
+ "delta[2]:{\"host\":\"c\"}",
+ "block_complete[2]",
+ "block_start[3]:ToolUse",
+ "delta[3]:{\"host\":\"d\"}",
+ "block_complete[3]",
+ "msg_complete",
+ };
+
+ // Identity arrives in the assembled ContentBlock at completion time.
+ const asst = conv.messages.items[1];
+ try testing.expectEqual(@as(usize, 4), asst.content.items.len);
+ for (asst.content.items) |b| {
+ try testing.expectEqualStrings("ping", b.ToolUse.name);
+ }
+ try testing.expectEqual(expected.len, rec.events.items.len);
+ for (expected, rec.events.items) |want, got| {
+ try testing.expectEqualStrings(want, got);
+ }
+}
+
+test "non-contiguous tool_call deltas: re-emission of a closed index is dropped" {
+ // Degenerate backend behavior: a delta for an already-closed wire
+ // index. We must not reopen the block; instead drop the fragment and
+ // log. The successfully-closed prior blocks remain intact.
+ 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"}}]}
+ ,
+ \\{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c0","type":"function","function":{"name":"ping","arguments":"{\"x\":1}"}}]}}]}
+ ,
+ \\{"choices":[{"delta":{"tool_calls":[{"index":1,"id":"c1","type":"function","function":{"name":"ping","arguments":"{\"y\":2}"}}]}}]}
+ ,
+ // Delta for already-closed index 0: must be dropped.
+ \\{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":",extra"}}]}}]}
+ ,
+ \\{"choices":[{"delta":{},"finish_reason":"tool_calls"}]}
+ ,
+ "[DONE]",
+ };
+
+ try runStreamedTurn(allocator, &conv, &recv, &events);
+
+ // Two well-formed tool_use blocks in the final message, args unaffected
+ // by the dropped fragment.
+ const asst = conv.messages.items[1];
+ try testing.expectEqual(@as(usize, 2), asst.content.items.len);
+ try testing.expectEqualStrings("{\"x\":1}", asst.content.items[0].ToolUse.input.items);
+ try testing.expectEqualStrings("{\"y\":2}", asst.content.items[1].ToolUse.input.items);
+
+ // Callback sequence: index 0 closed cleanly before any stray delta.
+ // There must be exactly one block_complete[0] in the event log
+ // (i.e. the stray delta did not produce a second open/close cycle).
+ var n_complete_0: usize = 0;
+ for (rec.events.items) |e| {
+ if (std.mem.eql(u8, e, "block_complete[0]")) n_complete_0 += 1;
+ }
+ try testing.expectEqual(@as(usize, 1), n_complete_0);
+}
+
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/src/main.zig b/src/main.zig
index a752a12..c1212e8 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -19,7 +19,6 @@ test {
const Receiver = panto.provider.Receiver;
const ReceiverVTable = panto.provider.ReceiverVTable;
const ContentBlockType = panto.provider.ContentBlockType;
-const BlockMeta = panto.provider.BlockMeta;
const MessageRole = panto.conversation.MessageRole;
/// Receiver that prints streaming deltas to stdout. Thinking blocks are
@@ -50,16 +49,16 @@ const CLIReceiver = struct {
ptr: *anyopaque,
block_type: ContentBlockType,
index: usize,
- meta: ?BlockMeta,
) anyerror!void {
_ = index;
const self: *CLIReceiver = @ptrCast(@alignCast(ptr));
switch (block_type) {
.Thinking => try self.stdout.writeAll("\x1b[2m[thinking] "),
- .ToolUse => {
- const name = if (meta) |m| (m.tool_name orelse "?") else "?";
- try self.stdout.print("\n\x1b[36m[tool: {s}]\x1b[0m ", .{name});
- },
+ // Tool name is not known reliably at start time (OpenAI may
+ // stream id/name across fragments). Open with a bare prefix;
+ // the tool name lands at onBlockComplete from the assembled
+ // ContentBlock.
+ .ToolUse => try self.stdout.writeAll("\n\x1b[36mtool: \x1b[0m"),
else => {},
}
try self.file.flush();
@@ -81,7 +80,8 @@ const CLIReceiver = struct {
const self: *CLIReceiver = @ptrCast(@alignCast(ptr));
switch (block) {
.Thinking => try self.stdout.writeAll("\x1b[0m\n"),
- .ToolUse => try self.stdout.writeAll("\n"),
+ // Append the tool name now that we know it for certain.
+ .ToolUse => |tu| try self.stdout.print("\x1b[36m : ({s})\x1b[0m\n", .{tu.name}),
else => {},
}
try self.file.flush();