From 16ea45b6854232541fb45f7d2e5b767118cccf43 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 2 Jun 2026 10:50:05 -0600 Subject: tool call resiliency --- libpanto/src/provider_anthropic_messages.zig | 69 ++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 10 deletions(-) (limited to 'libpanto/src/provider_anthropic_messages.zig') diff --git a/libpanto/src/provider_anthropic_messages.zig b/libpanto/src/provider_anthropic_messages.zig index 02277be..5c24b8f 100644 --- a/libpanto/src/provider_anthropic_messages.zig +++ b/libpanto/src/provider_anthropic_messages.zig @@ -181,6 +181,7 @@ const StreamState = struct { /// `onMessageComplete`, the latter stamps `null`. usage: provider_mod.Usage = .{}, usage_seen: bool = false, + stop_reason: ?[]u8 = null, const ActiveBlock = struct { /// Index reported on the wire (Anthropic's content-array index). @@ -210,6 +211,7 @@ const StreamState = struct { } for (self.blocks.items) |*b| b.deinit(self.allocator); self.blocks.deinit(self.allocator); + if (self.stop_reason) |s| self.allocator.free(s); } fn ensureStarted(self: *StreamState, receiver: *provider_mod.Receiver) !void { @@ -322,6 +324,40 @@ const StreamState = struct { a.signature = try self.allocator.dupe(u8, sig); } + fn setStopReason(self: *StreamState, reason: ?[]const u8) !void { + if (self.stop_reason) |old| self.allocator.free(old); + self.stop_reason = if (reason) |r| try self.allocator.dupe(u8, r) else null; + } + + fn isJSONObject(input: []const u8) bool { + if (input.len == 0) return true; + var parsed = std.json.parseFromSlice(std.json.Value, std.heap.page_allocator, input, .{}) catch return false; + defer parsed.deinit(); + return parsed.value == .object; + } + + fn appendAnthropicFailureResult( + allocator: Allocator, + blocks: *std.ArrayList(conversation.ContentBlock), + tool_use_id: []const u8, + input: []const u8, + stop_reason: ?[]const u8, + ) !void { + const id_copy = try allocator.dupe(u8, tool_use_id); + errdefer allocator.free(id_copy); + const msg = try std.fmt.allocPrint( + allocator, + "Tool call was not executed: Anthropic stopped before completing valid tool input JSON (stop_reason={s}). Partial input: {s}", + .{ stop_reason orelse "unknown", input }, + ); + errdefer allocator.free(msg); + var content: conversation.TextualBlock = .empty; + errdefer content.deinit(allocator); + try content.appendSlice(allocator, msg); + allocator.free(msg); + try blocks.append(allocator, .{ .ToolResult = .{ .tool_use_id = id_copy, .content = content } }); + } + /// Close the active block: append it to `blocks` and emit onBlockComplete. fn closeBlock( self: *StreamState, @@ -355,11 +391,22 @@ const StreamState = struct { .text = a.text_buf, .signature = a.signature, } }, - .tool_use => .{ .ToolUse = .{ - .id = a.tool_id.?, - .name = a.tool_name.?, - .input = a.text_buf, - } }, + .tool_use => blk: { + if (!isJSONObject(a.text_buf.items)) { + try appendAnthropicFailureResult( + self.allocator, + &self.blocks, + a.tool_id.?, + a.text_buf.items, + self.stop_reason, + ); + } + break :blk .{ .ToolUse = .{ + .id = a.tool_id.?, + .name = a.tool_name.?, + .input = a.text_buf, + } }; + }, .unsupported => unreachable, }; @@ -389,7 +436,8 @@ const StreamState = struct { self.finalized = true; if (self.active != null) { - // Wire dropped us mid-block. Close it cleanly anyway. + // Preserve an interrupted tool call so the agent can answer it + // with a synthetic error ToolResult instead of invoking it. try self.closeBlock(receiver); } @@ -434,12 +482,13 @@ fn handleEvent( if (d.signature_delta) |sig| try state.setSignature(sig); if (d.input_json_delta) |j| try state.appendInputJsonDelta(receiver, j); }, - .content_block_stop => { - try state.closeBlock(receiver); + .content_block_stop => |s| { + if (state.active) |a| { + if (a.wire_index == s.index) try state.closeBlock(receiver); + } }, .message_delta => |d| { - // We don't act on stop_reason directly; message_stop is the - // authoritative end-of-stream signal. + try state.setStopReason(d.stop_reason); state.mergeUsage(d.usage); }, .message_stop => { -- cgit v1.3