diff --git a/.changeset/unwrap-anthropic-parameter.md b/.changeset/unwrap-anthropic-parameter.md new file mode 100644 index 000000000..96560fdbb --- /dev/null +++ b/.changeset/unwrap-anthropic-parameter.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/stagehand": patch +--- + +fix: unwrap Anthropic $PARAMETER_NAME wrapper in tool responses diff --git a/packages/core/lib/v3/llm/AnthropicClient.ts b/packages/core/lib/v3/llm/AnthropicClient.ts index 2ba202686..db2c3514e 100644 --- a/packages/core/lib/v3/llm/AnthropicClient.ts +++ b/packages/core/lib/v3/llm/AnthropicClient.ts @@ -17,6 +17,7 @@ import { } from "./LLMClient.js"; import { CreateChatCompletionResponseError } from "../types/public/sdkErrors.js"; import { toJsonSchema } from "../zodCompat.js"; +import { unwrapToolResponse } from "./unwrapToolResponse.js"; export class AnthropicClient extends LLMClient { public type = "anthropic" as const; @@ -247,7 +248,7 @@ export class AnthropicClient extends LLMClient { if (options.response_model) { const toolUse = response.content.find((c) => c.type === "tool_use"); if (toolUse && "input" in toolUse) { - const result = toolUse.input; + const result = unwrapToolResponse(toolUse.input); const finalParsedResponse = { data: result, diff --git a/packages/core/lib/v3/llm/aisdk.ts b/packages/core/lib/v3/llm/aisdk.ts index dd2e097e2..0a55a181a 100644 --- a/packages/core/lib/v3/llm/aisdk.ts +++ b/packages/core/lib/v3/llm/aisdk.ts @@ -22,6 +22,7 @@ import { extractLlmPromptSummary, } from "../flowlogger/FlowLogger.js"; import { toJsonSchema } from "../zodCompat.js"; +import { unwrapToolResponse } from "./unwrapToolResponse.js"; type ProviderOptionValue = string | number | boolean | null; type ProviderOptionMap = Record; @@ -293,6 +294,43 @@ You must respond in JSON format. respond WITH JSON. Do not include any other tex }, }); + // Attempt to recover from $PARAMETER_NAME wrapper (common with Anthropic models) + if (err.text) { + try { + const parsed = JSON.parse(err.text); + const unwrapped = unwrapToolResponse(parsed); + if (unwrapped !== parsed) { + const validated = options.response_model.schema.parse(unwrapped); + this.logger?.({ + category: "aisdk", + message: "recovered from $PARAMETER_NAME wrapper", + level: 1, + }); + + FlowLogger.logLlmResponse({ + requestId: llmRequestId, + model: this.model.modelId, + output: JSON.stringify(validated), + inputTokens: err.usage?.inputTokens, + outputTokens: err.usage?.outputTokens, + }); + + return { + data: validated, + usage: { + prompt_tokens: err.usage?.inputTokens ?? 0, + completion_tokens: err.usage?.outputTokens ?? 0, + reasoning_tokens: err.usage?.reasoningTokens ?? 0, + cached_input_tokens: err.usage?.cachedInputTokens ?? 0, + total_tokens: err.usage?.totalTokens ?? 0, + }, + } as T; + } + } catch { + // Recovery failed, throw original error + } + } + throw err; } throw err; diff --git a/packages/core/lib/v3/llm/unwrapToolResponse.ts b/packages/core/lib/v3/llm/unwrapToolResponse.ts new file mode 100644 index 000000000..6541ecec4 --- /dev/null +++ b/packages/core/lib/v3/llm/unwrapToolResponse.ts @@ -0,0 +1,21 @@ +/** + * Unwrap Anthropic's $PARAMETER_NAME wrapper from tool responses. + * + * Some Anthropic models wrap tool_use output in a `{ $PARAMETER_NAME: { ... } }` + * envelope. This helper detects and strips that wrapper so downstream Zod + * validation sees the expected flat structure. + */ +export function unwrapToolResponse(data: T): T { + if ( + data !== null && + typeof data === "object" && + !Array.isArray(data) && + Object.keys(data as Record).length === 1 + ) { + const key = Object.keys(data as Record)[0]; + if (key.startsWith("$")) { + return (data as Record)[key] as T; + } + } + return data; +} diff --git a/packages/core/tests/unit/unwrapToolResponse.test.ts b/packages/core/tests/unit/unwrapToolResponse.test.ts new file mode 100644 index 000000000..3ec7f1c74 --- /dev/null +++ b/packages/core/tests/unit/unwrapToolResponse.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { unwrapToolResponse } from "../lib/v3/llm/unwrapToolResponse.js"; + +describe("unwrapToolResponse", () => { + it("unwraps $PARAMETER_NAME wrapper", () => { + const wrapped = { + $PARAMETER_NAME: { + elementId: "11-811", + description: "Create Invoice link button", + method: "click", + arguments: [], + twoStep: false, + }, + }; + const result = unwrapToolResponse(wrapped); + expect(result).toEqual({ + elementId: "11-811", + description: "Create Invoice link button", + method: "click", + arguments: [], + twoStep: false, + }); + }); + + it("unwraps any $-prefixed single-key wrapper", () => { + const wrapped = { $result: { foo: "bar" } }; + expect(unwrapToolResponse(wrapped)).toEqual({ foo: "bar" }); + }); + + it("does not unwrap non-$ single-key objects", () => { + const data = { elementId: "123" }; + expect(unwrapToolResponse(data)).toBe(data); + }); + + it("does not unwrap multi-key objects", () => { + const data = { $a: 1, $b: 2 }; + expect(unwrapToolResponse(data)).toBe(data); + }); + + it("passes through arrays unchanged", () => { + const arr = [1, 2, 3]; + expect(unwrapToolResponse(arr)).toBe(arr); + }); + + it("passes through null unchanged", () => { + expect(unwrapToolResponse(null)).toBe(null); + }); + + it("passes through primitives unchanged", () => { + expect(unwrapToolResponse("hello")).toBe("hello"); + expect(unwrapToolResponse(42)).toBe(42); + expect(unwrapToolResponse(true)).toBe(true); + }); + + it("passes through empty objects unchanged", () => { + const data = {}; + expect(unwrapToolResponse(data)).toBe(data); + }); +});