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/act-schema-additional-properties.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@browserbasehq/stagehand": patch
---

Fix OpenAI strict-schema rejection of the nullable `act` action while still stripping unknown keys when parsing.
68 changes: 38 additions & 30 deletions packages/core/lib/inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,37 +422,45 @@ export async function act({
logger: (message: LogLine) => void;
logInferenceToFile?: boolean;
}) {
const actSchema = z.object({
action: z
.object({
elementId: z
.string()
.regex(/^\d+-\d+$/)
.describe(
"the ID string associated with the element. Never include surrounding square brackets. This field must follow the format of 'number-number'. for example, '0-76' or '16-21'",
),
description: z
.string()
.describe("a description of the accessible element and its purpose"),
method: z
.enum(
// Use Object.values() for Zod v3 compatibility - z.enum() in v3 doesn't accept TypeScript enums directly
Object.values(SupportedUnderstudyAction) as unknown as readonly [
string,
...string[],
],
)
.describe(
"the candidate method/action to interact with the element. Select one of the available Understudy interaction methods.",
),
arguments: z.array(
z
.string()
.describe(
"the arguments to pass to the method. For example, for a click, the arguments are empty, but for a fill, the arguments are the value to fill in.",
),
const actionSchema = z.object({
elementId: z
.string()
.regex(/^\d+-\d+$/)
.describe(
"the ID string associated with the element. Never include surrounding square brackets. This field must follow the format of 'number-number'. for example, '0-76' or '16-21'",
),
description: z
.string()
.describe("a description of the accessible element and its purpose"),
method: z
.enum(
// Use Object.values() for Zod v3 compatibility - z.enum() in v3 doesn't accept TypeScript enums directly
Object.values(SupportedUnderstudyAction) as unknown as readonly [
string,
...string[],
],
)
.describe(
"the candidate method/action to interact with the element. Select one of the available Understudy interaction methods.",
),
arguments: z.array(
z
.string()
.describe(
"the arguments to pass to the method. For example, for a click, the arguments are empty, but for a fill, the arguments are the value to fill in.",
),
})
),
});

const actSchema = z.object({
// OpenAI strict mode requires additionalProperties: false on every object.
// The AI SDK's Zod 4 converter omits it inside anyOf, so set it in the JSON
// schema only; parsing still strips unknown keys. .meta() is Zod 4 only;
// the Zod 3 converter already emits it.
action: (typeof actionSchema.meta === "function"
? actionSchema.meta({ additionalProperties: false })
: actionSchema
)
.nullable()
.describe(
"The element to act on. Return null if no element on the page matches the instruction — do NOT fabricate or guess an element, and never emit empty strings or placeholder values.",
Expand Down
35 changes: 35 additions & 0 deletions packages/core/tests/unit/act-response-schema-zod3.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { zodSchema } from "ai";
import { describe, expect, it, vi } from "vitest";
import { act } from "../../lib/inference.js";
import type { LLMClient } from "../../lib/v3/llm/LLMClient.js";

// zod is a peer dependency (^3.25 || ^4); under Zod 3 the root import is v3.
vi.mock("zod", async () => await import("zod/v3"));

describe("act response schema with a Zod 3 peer", () => {
it("builds the schema without Zod 4-only methods and keeps additionalProperties: false", async () => {
const createChatCompletion = vi.fn().mockResolvedValue({
data: { action: null, twoStep: false },
});
await act({
instruction: "fill the email field",
domElements: "[0-1] textbox: Email",
llmClient: { createChatCompletion } as unknown as LLMClient,
logger: vi.fn(),
});

const schema =
createChatCompletion.mock.calls[0][0].options.response_model.schema;
expect(schema._zod).toBeUndefined();
expect(await zodSchema(schema).jsonSchema).toMatchObject({
properties: {
action: {
anyOf: [
{ type: "object", additionalProperties: false },
{ type: "null" },
],
},
},
});
});
});
66 changes: 66 additions & 0 deletions packages/core/tests/unit/act-response-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { zodSchema } from "ai";
import { describe, expect, it, vi } from "vitest";
import { act } from "../../lib/inference.js";
import type { LLMClient } from "../../lib/v3/llm/LLMClient.js";
import { toJsonSchema } from "../../lib/v3/zodCompat.js";

async function captureActSchema() {
const createChatCompletion = vi.fn().mockResolvedValue({
data: { action: null, twoStep: false },
});
await act({
instruction: "fill the email field",
domElements: "[0-1] textbox: Email",
llmClient: { createChatCompletion } as unknown as LLMClient,
logger: vi.fn(),
});
return createChatCompletion.mock.calls[0][0].options.response_model.schema;
}

const strictNullableAction = {
type: "object",
additionalProperties: false,
properties: {
action: {
anyOf: [
{ type: "object", additionalProperties: false },
{ type: "null" },
],
},
},
};

describe("act response schema", () => {
// generateObject (AI SDK clients) converts with the AI SDK's zodSchema.
it("sets additionalProperties: false on the nullable action for the AI SDK converter", async () => {
const schema = await captureActSchema();
expect(await zodSchema(schema).jsonSchema).toMatchObject(
strictNullableAction,
);
});

// Direct provider clients convert with toJsonSchema.
it("sets additionalProperties: false on the nullable action for toJsonSchema", async () => {
const schema = await captureActSchema();
expect(toJsonSchema(schema)).toMatchObject(strictNullableAction);
});

it("still strips unknown keys when parsing instead of rejecting them", async () => {
const schema = await captureActSchema();
const action = {
elementId: "0-1",
description: "Email",
method: "fill",
arguments: ["john@example.com"],
};
expect(schema.safeParse({ action: null, twoStep: false }).success).toBe(
true,
);
expect(
schema.parse({
action: { ...action, reasoning: "matches the email field" },
twoStep: false,
}),
).toEqual({ action, twoStep: false });
});
});
Loading