Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/unwrap-anthropic-parameter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@browserbasehq/stagehand": patch
---

fix: unwrap Anthropic $PARAMETER_NAME wrapper in tool responses
3 changes: 2 additions & 1 deletion packages/core/lib/v3/llm/AnthropicClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions packages/core/lib/v3/llm/aisdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ProviderOptionValue>;
Expand Down Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions packages/core/lib/v3/llm/unwrapToolResponse.ts
Original file line number Diff line number Diff line change
@@ -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<T>(data: T): T {
if (
data !== null &&
typeof data === "object" &&
!Array.isArray(data) &&
Object.keys(data as Record<string, unknown>).length === 1
) {
const key = Object.keys(data as Record<string, unknown>)[0];
if (key.startsWith("$")) {
return (data as Record<string, unknown>)[key] as T;
}
}
return data;
}
59 changes: 59 additions & 0 deletions packages/core/tests/unit/unwrapToolResponse.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading