From 109d4940fec5599b5812e4bbf9360f4eca82590e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 23 Oct 2025 23:45:45 +0900 Subject: [PATCH 1/2] feat: #561 support both zod3 and zod4 --- .changeset/goofy-llamas-listen.md | 9 + integration-tests/deno.test.ts | 2 +- integration-tests/node-zod3.test.ts | 32 ++ integration-tests/node-zod3/.npmrc | 2 + integration-tests/node-zod3/index.cjs | 37 +++ integration-tests/node-zod3/index.mjs | 33 ++ integration-tests/node-zod3/package.json | 13 + integration-tests/node-zod4.test.ts | 32 ++ integration-tests/node-zod4/.npmrc | 2 + integration-tests/node-zod4/index.cjs | 37 +++ integration-tests/node-zod4/index.mjs | 33 ++ integration-tests/node-zod4/package.json | 13 + integration-tests/node/index.cjs | 1 - integration-tests/node/package.json | 2 +- integration-tests/vite-react.test.ts | 24 ++ packages/agents-core/package.json | 4 +- packages/agents-core/src/utils/tools.ts | 36 ++- packages/agents-core/src/utils/typeGuards.ts | 26 +- packages/agents-core/src/utils/zodFallback.ts | 289 ++++++++++++++++++ packages/agents-extensions/package.json | 4 +- packages/agents-openai/package.json | 4 +- packages/agents-realtime/package.json | 4 +- packages/agents/package.json | 4 +- pnpm-lock.yaml | 10 +- 24 files changed, 620 insertions(+), 33 deletions(-) create mode 100644 .changeset/goofy-llamas-listen.md create mode 100644 integration-tests/node-zod3.test.ts create mode 100644 integration-tests/node-zod3/.npmrc create mode 100644 integration-tests/node-zod3/index.cjs create mode 100644 integration-tests/node-zod3/index.mjs create mode 100644 integration-tests/node-zod3/package.json create mode 100644 integration-tests/node-zod4.test.ts create mode 100644 integration-tests/node-zod4/.npmrc create mode 100644 integration-tests/node-zod4/index.cjs create mode 100644 integration-tests/node-zod4/index.mjs create mode 100644 integration-tests/node-zod4/package.json create mode 100644 packages/agents-core/src/utils/zodFallback.ts diff --git a/.changeset/goofy-llamas-listen.md b/.changeset/goofy-llamas-listen.md new file mode 100644 index 00000000..54d33fa5 --- /dev/null +++ b/.changeset/goofy-llamas-listen.md @@ -0,0 +1,9 @@ +--- +'@openai/agents-extensions': minor +'@openai/agents-realtime': minor +'@openai/agents-openai': minor +'@openai/agents-core': minor +'@openai/agents': minor +--- + +feat: #561 support both zod3 and zod4 diff --git a/integration-tests/deno.test.ts b/integration-tests/deno.test.ts index 3112b610..ac40e419 100644 --- a/integration-tests/deno.test.ts +++ b/integration-tests/deno.test.ts @@ -13,7 +13,7 @@ describe('Deno', () => { await execa`deno install`; }, 60000); - test('should be able to run', async () => { + test('should be able to run', { timeout: 60000 }, async () => { const { stdout } = await execa`deno --allow-net --allow-env main.ts`; expect(stdout).toContain('[RESPONSE]Hello there![/RESPONSE]'); }); diff --git a/integration-tests/node-zod3.test.ts b/integration-tests/node-zod3.test.ts new file mode 100644 index 00000000..a8fa1b1a --- /dev/null +++ b/integration-tests/node-zod3.test.ts @@ -0,0 +1,32 @@ +import { describe, test, expect, beforeAll } from 'vitest'; +import { execa as execaBase } from 'execa'; + +const execa = execaBase({ + cwd: './integration-tests/node-zod3', + env: { + ...process.env, + NODE_OPTIONS: '', + TS_NODE_PROJECT: '', + TS_NODE_COMPILER_OPTIONS: '', + }, +}); + +describe('Node.js', () => { + beforeAll(async () => { + // remove lock file to avoid errors + console.log('[node] Removing node_modules'); + await execa`rm -rf node_modules`; + console.log('[node] Installing dependencies'); + await execa`npm install`; + }, 60000); + + test('should be able to run using CommonJS', async () => { + const { stdout } = await execa`npm run start:cjs`; + expect(stdout).toContain('[RESPONSE]Hello there![/RESPONSE]'); + }); + + test('should be able to run using ESM', async () => { + const { stdout } = await execa`npm run start:esm`; + expect(stdout).toContain('[RESPONSE]Hello there![/RESPONSE]'); + }); +}); diff --git a/integration-tests/node-zod3/.npmrc b/integration-tests/node-zod3/.npmrc new file mode 100644 index 00000000..de52ca18 --- /dev/null +++ b/integration-tests/node-zod3/.npmrc @@ -0,0 +1,2 @@ +@openai:registry=http://localhost:4873 +package-lock=false \ No newline at end of file diff --git a/integration-tests/node-zod3/index.cjs b/integration-tests/node-zod3/index.cjs new file mode 100644 index 00000000..df221e54 --- /dev/null +++ b/integration-tests/node-zod3/index.cjs @@ -0,0 +1,37 @@ +// @ts-check + +const { + Agent, + run, + tool, + setTraceProcessors, + ConsoleSpanExporter, + BatchTraceProcessor, +} = require('@openai/agents'); + +const { z } = require('zod'); + +setTraceProcessors([new BatchTraceProcessor(new ConsoleSpanExporter())]); + +const getWeatherTool = tool({ + name: 'get_weather', + description: 'Get the weather for a given city', + parameters: z.object({ city: z.string() }), + execute: async (input) => { + return `The weather in ${input.city} is sunny`; + }, +}); + +const agent = new Agent({ + name: 'Test Agent', + instructions: + 'You will always only respond with "Hello there!". Not more not less.', + tools: [getWeatherTool], +}); + +async function main() { + const result = await run(agent, 'Hey there!'); + console.log(`[RESPONSE]${result.finalOutput}[/RESPONSE]`); +} + +main().catch(console.error); diff --git a/integration-tests/node-zod3/index.mjs b/integration-tests/node-zod3/index.mjs new file mode 100644 index 00000000..d565b533 --- /dev/null +++ b/integration-tests/node-zod3/index.mjs @@ -0,0 +1,33 @@ +// @ts-check + +import { z } from 'zod'; + +import { + Agent, + run, + tool, + setTraceProcessors, + ConsoleSpanExporter, + BatchTraceProcessor, +} from '@openai/agents'; + +setTraceProcessors([new BatchTraceProcessor(new ConsoleSpanExporter())]); + +const getWeatherTool = tool({ + name: 'get_weather', + description: 'Get the weather for a given city', + parameters: z.object({ city: z.string() }), + execute: async (input) => { + return `The weather in ${input.city} is sunny`; + }, +}); + +const agent = new Agent({ + name: 'Test Agent', + instructions: + 'You will always only respond with "Hello there!". Not more not less.', + tools: [getWeatherTool], +}); + +const result = await run(agent, 'What is the weather in San Francisco?'); +console.log(`[RESPONSE]${result.finalOutput}[/RESPONSE]`); diff --git a/integration-tests/node-zod3/package.json b/integration-tests/node-zod3/package.json new file mode 100644 index 00000000..56ba972d --- /dev/null +++ b/integration-tests/node-zod3/package.json @@ -0,0 +1,13 @@ +{ + "private": true, + "type": "commonjs", + "scripts": { + "start:cjs": "node --no-experimental-require-module index.cjs", + "start:esm": "node --no-experimental-require-module index.mjs" + }, + "dependencies": { + "@openai/agents": "latest", + "typescript": "^5.9.3", + "zod": "^3.25.40" + } +} diff --git a/integration-tests/node-zod4.test.ts b/integration-tests/node-zod4.test.ts new file mode 100644 index 00000000..e7a19696 --- /dev/null +++ b/integration-tests/node-zod4.test.ts @@ -0,0 +1,32 @@ +import { describe, test, expect, beforeAll } from 'vitest'; +import { execa as execaBase } from 'execa'; + +const execa = execaBase({ + cwd: './integration-tests/node-zod4', + env: { + ...process.env, + NODE_OPTIONS: '', + TS_NODE_PROJECT: '', + TS_NODE_COMPILER_OPTIONS: '', + }, +}); + +describe('Node.js', () => { + beforeAll(async () => { + // remove lock file to avoid errors + console.log('[node] Removing node_modules'); + await execa`rm -rf node_modules`; + console.log('[node] Installing dependencies'); + await execa`npm install`; + }, 60000); + + test('should be able to run using CommonJS', async () => { + const { stdout } = await execa`npm run start:cjs`; + expect(stdout).toContain('[RESPONSE]Hello there![/RESPONSE]'); + }); + + test('should be able to run using ESM', async () => { + const { stdout } = await execa`npm run start:esm`; + expect(stdout).toContain('[RESPONSE]Hello there![/RESPONSE]'); + }); +}); diff --git a/integration-tests/node-zod4/.npmrc b/integration-tests/node-zod4/.npmrc new file mode 100644 index 00000000..de52ca18 --- /dev/null +++ b/integration-tests/node-zod4/.npmrc @@ -0,0 +1,2 @@ +@openai:registry=http://localhost:4873 +package-lock=false \ No newline at end of file diff --git a/integration-tests/node-zod4/index.cjs b/integration-tests/node-zod4/index.cjs new file mode 100644 index 00000000..df221e54 --- /dev/null +++ b/integration-tests/node-zod4/index.cjs @@ -0,0 +1,37 @@ +// @ts-check + +const { + Agent, + run, + tool, + setTraceProcessors, + ConsoleSpanExporter, + BatchTraceProcessor, +} = require('@openai/agents'); + +const { z } = require('zod'); + +setTraceProcessors([new BatchTraceProcessor(new ConsoleSpanExporter())]); + +const getWeatherTool = tool({ + name: 'get_weather', + description: 'Get the weather for a given city', + parameters: z.object({ city: z.string() }), + execute: async (input) => { + return `The weather in ${input.city} is sunny`; + }, +}); + +const agent = new Agent({ + name: 'Test Agent', + instructions: + 'You will always only respond with "Hello there!". Not more not less.', + tools: [getWeatherTool], +}); + +async function main() { + const result = await run(agent, 'Hey there!'); + console.log(`[RESPONSE]${result.finalOutput}[/RESPONSE]`); +} + +main().catch(console.error); diff --git a/integration-tests/node-zod4/index.mjs b/integration-tests/node-zod4/index.mjs new file mode 100644 index 00000000..d565b533 --- /dev/null +++ b/integration-tests/node-zod4/index.mjs @@ -0,0 +1,33 @@ +// @ts-check + +import { z } from 'zod'; + +import { + Agent, + run, + tool, + setTraceProcessors, + ConsoleSpanExporter, + BatchTraceProcessor, +} from '@openai/agents'; + +setTraceProcessors([new BatchTraceProcessor(new ConsoleSpanExporter())]); + +const getWeatherTool = tool({ + name: 'get_weather', + description: 'Get the weather for a given city', + parameters: z.object({ city: z.string() }), + execute: async (input) => { + return `The weather in ${input.city} is sunny`; + }, +}); + +const agent = new Agent({ + name: 'Test Agent', + instructions: + 'You will always only respond with "Hello there!". Not more not less.', + tools: [getWeatherTool], +}); + +const result = await run(agent, 'What is the weather in San Francisco?'); +console.log(`[RESPONSE]${result.finalOutput}[/RESPONSE]`); diff --git a/integration-tests/node-zod4/package.json b/integration-tests/node-zod4/package.json new file mode 100644 index 00000000..664a05bc --- /dev/null +++ b/integration-tests/node-zod4/package.json @@ -0,0 +1,13 @@ +{ + "private": true, + "type": "commonjs", + "scripts": { + "start:cjs": "node --no-experimental-require-module index.cjs", + "start:esm": "node --no-experimental-require-module index.mjs" + }, + "dependencies": { + "@openai/agents": "latest", + "typescript": "^5.9.3", + "zod": "^4" + } +} diff --git a/integration-tests/node/index.cjs b/integration-tests/node/index.cjs index 8d6c7a92..71ee3a7c 100644 --- a/integration-tests/node/index.cjs +++ b/integration-tests/node/index.cjs @@ -7,7 +7,6 @@ const { ConsoleSpanExporter, BatchTraceProcessor, } = require('@openai/agents'); -const { assert } = require('node:console'); setTraceProcessors([new BatchTraceProcessor(new ConsoleSpanExporter())]); diff --git a/integration-tests/node/package.json b/integration-tests/node/package.json index d4a455ac..5d775ed6 100644 --- a/integration-tests/node/package.json +++ b/integration-tests/node/package.json @@ -7,6 +7,6 @@ }, "dependencies": { "@openai/agents": "latest", - "typescript": "^5.9.2" + "typescript": "^5.9.3" } } diff --git a/integration-tests/vite-react.test.ts b/integration-tests/vite-react.test.ts index d83a9433..f0de030c 100644 --- a/integration-tests/vite-react.test.ts +++ b/integration-tests/vite-react.test.ts @@ -1,12 +1,21 @@ import { describe, test, expect, beforeAll, afterAll } from 'vitest'; import { chromium } from 'playwright'; import { execa as execaBase, ResultPromise } from 'execa'; +import { writeFile, unlink } from 'node:fs/promises'; +import path from 'node:path'; const execa = execaBase({ cwd: './integration-tests/vite-react', }); let server: ResultPromise; +const envPath = path.join( + process.cwd(), + 'integration-tests', + 'vite-react', + '.env', +); +let wroteEnvFile = false; describe('Vite React', () => { beforeAll(async () => { @@ -16,10 +25,21 @@ describe('Vite React', () => { await execa`rm -rf node_modules`; console.log('[vite-react] Installing dependencies'); await execa`npm install`; + + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error( + 'OPENAI_API_KEY must be set to run the Vite React integration test.', + ); + } + await writeFile(envPath, `VITE_OPENAI_API_KEY=${apiKey}\n`, 'utf8'); + wroteEnvFile = true; + console.log('[vite-react] Building'); await execa`npm run build`; console.log('[vite-react] Starting server'); server = execa`npm run preview -- --port 9999`; + server.catch(() => {}); await new Promise((resolve) => { server.stdout?.on('data', (data) => { if (data.toString().includes('http://localhost')) { @@ -41,6 +61,7 @@ describe('Vite React', () => { const root = await page.$('#root'); const span = await root?.waitForSelector('span[data-testid="response"]', { state: 'attached', + timeout: 60000, }); expect(await span?.textContent()).toBe('[RESPONSE]Hello there![/RESPONSE]'); await browser.close(); @@ -50,5 +71,8 @@ describe('Vite React', () => { if (server) { server.kill(); } + if (wroteEnvFile) { + await unlink(envPath).catch(() => {}); + } }); }); diff --git a/packages/agents-core/package.json b/packages/agents-core/package.json index c0f99610..b23fb200 100644 --- a/packages/agents-core/package.json +++ b/packages/agents-core/package.json @@ -74,7 +74,7 @@ "debug": "^4.4.0" }, "peerDependencies": { - "zod": "^3.25.40" + "zod": "^3.25.40 || ^4.0" }, "peerDependenciesMeta": { "zod": { @@ -102,7 +102,7 @@ }, "devDependencies": { "@types/debug": "^4.1.12", - "zod": "^3.25.40" + "zod": "^3.25.40 || ^4.0" }, "files": [ "dist" diff --git a/packages/agents-core/src/utils/tools.ts b/packages/agents-core/src/utils/tools.ts index 8f086c07..57e47c35 100644 --- a/packages/agents-core/src/utils/tools.ts +++ b/packages/agents-core/src/utils/tools.ts @@ -1,14 +1,28 @@ +import type { ZodObject } from 'zod'; import { zodResponsesFunction, zodTextFormat } from 'openai/helpers/zod'; import { UserError } from '../errors'; import { ToolInputParameters } from '../tool'; import { JsonObjectSchema, JsonSchemaDefinition, TextOutput } from '../types'; import { isZodObject } from './typeGuards'; import { AgentOutputType } from '../agent'; +import { + fallbackJsonSchemaFromZodObject, + hasJsonSchemaObjectShape, +} from './zodFallback'; export type FunctionToolName = string & { __brand?: 'ToolName' } & { readonly __pattern?: '^[a-zA-Z0-9_]+$'; }; +// openai/helpers/zod cannot emit strict schemas for every Zod runtime +// (notably Zod v4), so we delegate to a small local converter living in +// zodFallback.ts whenever its output is missing the required JSON Schema bits. +function buildJsonSchemaFromZod( + inputType: ZodObject, +): JsonObjectSchema | undefined { + return fallbackJsonSchemaFromZodObject(inputType); +} + /** * Convert a string to a function tool name by replacing spaces with underscores and * non-alphanumeric characters with underscores. @@ -54,11 +68,25 @@ export function getSchemaAndParserFromInputType( function: () => {}, // empty function here to satisfy the OpenAI helper description: '', }); + if (hasJsonSchemaObjectShape(formattedFunction.parameters)) { + return { + schema: formattedFunction.parameters as JsonObjectSchema, + parser: formattedFunction.$parseRaw, + }; + } - return { - schema: formattedFunction.parameters as JsonObjectSchema, - parser: formattedFunction.$parseRaw, - }; + const fallbackSchema = buildJsonSchemaFromZod(inputType); + + if (fallbackSchema) { + return { + schema: fallbackSchema, + parser: (rawInput: string) => inputType.parse(JSON.parse(rawInput)), + }; + } + + throw new UserError( + 'Unable to convert the provided Zod schema to JSON Schema. Ensure that the `zod` package is available at runtime or provide a JSON schema object instead.', + ); } else if (typeof inputType === 'object' && inputType !== null) { return { schema: inputType, diff --git a/packages/agents-core/src/utils/typeGuards.ts b/packages/agents-core/src/utils/typeGuards.ts index 6e3ce865..7675f093 100644 --- a/packages/agents-core/src/utils/typeGuards.ts +++ b/packages/agents-core/src/utils/typeGuards.ts @@ -6,24 +6,28 @@ import type { ZodObject } from 'zod'; * @param input * @returns */ - export function isZodObject(input: unknown): input is ZodObject { - return ( - typeof input === 'object' && - input !== null && - '_def' in input && - typeof input._def === 'object' && - input._def !== null && - 'typeName' in input._def && - input._def.typeName === 'ZodObject' - ); + if ( + typeof input !== 'object' || + input === null || + !('_def' in input) || + typeof input._def !== 'object' || + input._def === null + ) { + return false; + } + + const def = input._def as Record; + const typeName = typeof def.typeName === 'string' ? def.typeName : undefined; + const type = typeof def.type === 'string' ? def.type : undefined; + return typeName === 'ZodObject' || type === 'object'; } + /** * Verifies that an input is an object with an `input` property. * @param input * @returns */ - export function isAgentToolInput(input: unknown): input is { input: string; } { diff --git a/packages/agents-core/src/utils/zodFallback.ts b/packages/agents-core/src/utils/zodFallback.ts new file mode 100644 index 00000000..f1275194 --- /dev/null +++ b/packages/agents-core/src/utils/zodFallback.ts @@ -0,0 +1,289 @@ +import type { ZodObject } from 'zod'; +import type { JsonObjectSchema, JsonSchemaDefinitionEntry } from '../types'; + +type ZodDefinition = Record | undefined; +type ZodLike = { + _def?: Record; + def?: Record; + _zod?: { def?: Record }; + shape?: Record | (() => Record); +}; + +type LooseJsonObjectSchema = { + type: 'object'; + properties: Record; + required?: string[]; + additionalProperties?: boolean; + $schema?: string; +}; + +const JSON_SCHEMA_DRAFT_07 = 'http://json-schema.org/draft-07/schema#'; +const OPTIONAL_WRAPPERS = new Set(['optional']); +const DECORATOR_WRAPPERS = new Set([ + 'brand', + 'catch', + 'default', + 'effects', + 'pipeline', + 'prefault', + 'readonly', + 'refinement', + 'transform', +]); + +export function hasJsonSchemaObjectShape( + value: unknown, +): value is LooseJsonObjectSchema { + return ( + typeof value === 'object' && + value !== null && + (value as { type?: string }).type === 'object' && + 'properties' in value && + 'additionalProperties' in value + ); +} + +export function fallbackJsonSchemaFromZodObject( + input: ZodObject, +): JsonObjectSchema | undefined { + const schema = buildObjectSchema(input); + if (!schema) { + return undefined; + } + + if (!Array.isArray(schema.required)) { + schema.required = []; + } + + if (typeof schema.additionalProperties === 'undefined') { + schema.additionalProperties = false; + } + + if (typeof schema.$schema !== 'string') { + schema.$schema = JSON_SCHEMA_DRAFT_07; + } + + return schema as JsonObjectSchema>; +} + +function buildObjectSchema(value: unknown): LooseJsonObjectSchema | undefined { + const shape = readShape(value); + if (!shape) { + return undefined; + } + + const properties: Record = {}; + const required: string[] = []; + + for (const [key, field] of Object.entries(shape)) { + const { schema, optional } = convertProperty(field); + if (!schema) { + return undefined; + } + + properties[key] = schema; + if (!optional) { + required.push(key); + } + } + + return { type: 'object', properties, required, additionalProperties: false }; +} + +function convertProperty(value: unknown): { + schema?: JsonSchemaDefinitionEntry; + optional: boolean; +} { + let current = unwrapDecorators(value); + let optional = false; + + while (OPTIONAL_WRAPPERS.has(readType(current) ?? '')) { + optional = true; + const def = readDefinition(current); + const next = unwrapDecorators(def?.innerType); + if (!next || next === current) { + break; + } + current = next; + } + + return { schema: convertSchema(current), optional }; +} + +function convertSchema(value: unknown): JsonSchemaDefinitionEntry | undefined { + const type = readType(value); + const def = readDefinition(value); + + switch (type) { + case 'string': + return { type: 'string' }; + case 'number': + return { type: 'number' }; + case 'bigint': + return { type: 'integer' }; + case 'boolean': + return { type: 'boolean' }; + case 'date': + return { type: 'string', format: 'date-time' }; + case 'literal': { + const literal = (def?.value ?? def?.literal) as + | string + | number + | boolean + | null; + return literal === undefined + ? undefined + : { const: literal, type: literal === null ? 'null' : typeof literal }; + } + case 'enum': + case 'nativeenum': { + const values = ((Array.isArray(def?.values) && def?.values) || + (Array.isArray(def?.options) && def?.options) || + (def?.values && + typeof def?.values === 'object' && + Object.values(def.values)) || + (def?.enum && + typeof def?.enum === 'object' && + Object.values(def.enum))) as unknown[] | undefined; + return values && values.length + ? { enum: values as unknown[] } + : undefined; + } + case 'array': { + const element = def?.element ?? def?.items ?? def?.type; + const items = convertSchema(element); + return items ? { type: 'array', items } : undefined; + } + case 'tuple': { + const tupleItems = Array.isArray(def?.items) ? def?.items : []; + const converted = tupleItems + .map((item) => convertSchema(item)) + .filter(Boolean) as JsonSchemaDefinitionEntry[]; + if (!converted.length) { + return undefined; + } + const schema: JsonSchemaDefinitionEntry = { + type: 'array', + items: converted, + minItems: converted.length, + }; + if (!def?.rest) { + schema.maxItems = converted.length; + } + return schema; + } + case 'union': { + const options = + (Array.isArray(def?.options) && def?.options) || + (Array.isArray(def?.schemas) && def?.schemas); + if (!options) { + return undefined; + } + const anyOf = options + .map((option) => convertSchema(option)) + .filter(Boolean) as JsonSchemaDefinitionEntry[]; + return anyOf.length ? { anyOf } : undefined; + } + case 'intersection': { + const left = convertSchema(def?.left); + const right = convertSchema(def?.right); + return left && right ? { allOf: [left, right] } : undefined; + } + case 'record': { + const valueSchema = convertSchema(def?.valueType ?? def?.values); + return valueSchema + ? { type: 'object', additionalProperties: valueSchema } + : undefined; + } + case 'map': { + const valueSchema = convertSchema(def?.valueType ?? def?.values); + return valueSchema ? { type: 'array', items: valueSchema } : undefined; + } + case 'set': { + const valueSchema = convertSchema(def?.valueType); + return valueSchema + ? { type: 'array', items: valueSchema, uniqueItems: true } + : undefined; + } + case 'nullable': { + const inner = convertSchema(def?.innerType ?? def?.type); + return inner ? { anyOf: [inner, { type: 'null' }] } : undefined; + } + case 'object': + return buildObjectSchema(value); + default: + return undefined; + } +} + +function readDefinition(input: unknown): ZodDefinition { + if (typeof input !== 'object' || input === null) { + return undefined; + } + const candidate = input as ZodLike; + return candidate._zod?.def || candidate._def || candidate.def; +} + +function readType(input: unknown): string | undefined { + const def = readDefinition(input); + const rawType = + (typeof def?.typeName === 'string' && def?.typeName) || + (typeof def?.type === 'string' && def?.type); + if (!rawType) { + return undefined; + } + const lower = rawType.toLowerCase(); + return lower.startsWith('zod') ? lower.slice(3) : lower; +} + +function unwrapDecorators(value: unknown): unknown { + let current = value; + while (DECORATOR_WRAPPERS.has(readType(current) ?? '')) { + const def = readDefinition(current); + const next = + def?.innerType ?? + def?.schema ?? + def?.base ?? + def?.type ?? + def?.wrapped ?? + def?.underlying; + if (!next || next === current) { + return current; + } + current = next; + } + return current; +} + +function readShape(input: unknown): Record | undefined { + if (typeof input !== 'object' || input === null) { + return undefined; + } + + const candidate = input as ZodLike; + if (candidate.shape && typeof candidate.shape === 'object') { + return candidate.shape; + } + if (typeof candidate.shape === 'function') { + try { + return candidate.shape(); + } catch (_error) { + return undefined; + } + } + + const def = readDefinition(candidate); + const shape = def?.shape; + if (shape && typeof shape === 'object') { + return shape as Record; + } + if (typeof shape === 'function') { + try { + return shape(); + } catch (_error) { + return undefined; + } + } + + return undefined; +} diff --git a/packages/agents-extensions/package.json b/packages/agents-extensions/package.json index f9e05989..a736231d 100644 --- a/packages/agents-extensions/package.json +++ b/packages/agents-extensions/package.json @@ -27,7 +27,7 @@ "peerDependencies": { "@openai/agents": "workspace:>=0.0.0", "ws": "^8.18.1", - "zod": "^3.25.40" + "zod": "^3.25.40 || ^4.0" }, "keywords": [ "openai", @@ -40,7 +40,7 @@ "@openai/agents": "workspace:>=0.0.0", "@types/debug": "^4.1.12", "ws": "^8.18.1", - "zod": "^3.25.40" + "zod": "^3.25.40 || ^4.0" }, "files": [ "dist" diff --git a/packages/agents-openai/package.json b/packages/agents-openai/package.json index ef69bf5b..09001da9 100644 --- a/packages/agents-openai/package.json +++ b/packages/agents-openai/package.json @@ -32,12 +32,12 @@ ], "license": "MIT", "peerDependencies": { - "zod": "^3.25.40" + "zod": "^3.25.40 || ^4.0" }, "devDependencies": { "@ai-sdk/provider": "^1.1.3", "@types/debug": "^4.1.12", - "zod": "^3.25.40" + "zod": "^3.25.40 || ^4.0" }, "files": [ "dist" diff --git a/packages/agents-realtime/package.json b/packages/agents-realtime/package.json index d7f514d2..a4444d8e 100644 --- a/packages/agents-realtime/package.json +++ b/packages/agents-realtime/package.json @@ -67,12 +67,12 @@ ], "license": "MIT", "peerDependencies": { - "zod": "^3.25.40" + "zod": "^3.25.40 || ^4.0" }, "devDependencies": { "@types/debug": "^4.1.12", "vite": "^6.4.1", - "zod": "^3.25.40" + "zod": "^3.25.40 || ^4.0" }, "files": [ "dist" diff --git a/packages/agents/package.json b/packages/agents/package.json index b102957c..9a9b3d3f 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -45,10 +45,10 @@ "license": "MIT", "devDependencies": { "@types/debug": "^4.1.12", - "zod": "^3.25.40" + "zod": "^3.25.40 || ^4.0" }, "peerDependencies": { - "zod": "^3.25.40" + "zod": "^3.25.40 || ^4.0" }, "files": [ "dist" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e982cf8..536796af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -423,7 +423,7 @@ importers: specifier: ^4.1.12 version: 4.1.12 zod: - specifier: ^3.25.40 + specifier: ^3.25.40 || ^4.0 version: 3.25.76 packages/agents-core: @@ -439,7 +439,7 @@ importers: specifier: ^4.1.12 version: 4.1.12 zod: - specifier: ^3.25.40 + specifier: ^3.25.40 || ^4.0 version: 3.25.76 optionalDependencies: '@modelcontextprotocol/sdk': @@ -468,7 +468,7 @@ importers: specifier: ^8.18.1 version: 8.18.3 zod: - specifier: ^3.25.40 + specifier: ^3.25.40 || ^4.0 version: 3.25.76 packages/agents-openai: @@ -490,7 +490,7 @@ importers: specifier: ^4.1.12 version: 4.1.12 zod: - specifier: ^3.25.40 + specifier: ^3.25.40 || ^4.0 version: 3.25.76 packages/agents-realtime: @@ -515,7 +515,7 @@ importers: specifier: ^6.4.1 version: 6.4.1(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) zod: - specifier: ^3.25.40 + specifier: ^3.25.40 || ^4.0 version: 3.25.76 packages: From 8f4da4b055ae02fadc3a294f78d14260e688e789 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 24 Oct 2025 15:48:12 +0900 Subject: [PATCH 2/2] refactor the zod adapter code --- packages/agents-core/src/agent.ts | 107 ++--- packages/agents-core/src/tool.ts | 10 +- packages/agents-core/src/types/helpers.ts | 25 +- packages/agents-core/src/utils/tools.ts | 17 +- packages/agents-core/src/utils/typeGuards.ts | 20 +- packages/agents-core/src/utils/zodCompat.ts | 54 +++ packages/agents-core/src/utils/zodFallback.ts | 289 ------------- .../src/utils/zodJsonSchemaCompat.ts | 384 ++++++++++++++++++ packages/agents-core/test/utils/tools.test.ts | 8 + .../agents-core/test/utils/typeGuards.test.ts | 5 + .../test/utils/zodJsonSchemaCompat.test.ts | 103 +++++ 11 files changed, 647 insertions(+), 375 deletions(-) create mode 100644 packages/agents-core/src/utils/zodCompat.ts delete mode 100644 packages/agents-core/src/utils/zodFallback.ts create mode 100644 packages/agents-core/src/utils/zodJsonSchemaCompat.ts create mode 100644 packages/agents-core/test/utils/zodJsonSchemaCompat.test.ts diff --git a/packages/agents-core/src/agent.ts b/packages/agents-core/src/agent.ts index cd85cbfb..75e64f16 100644 --- a/packages/agents-core/src/agent.ts +++ b/packages/agents-core/src/agent.ts @@ -1,4 +1,3 @@ -import type { ZodObject } from 'zod'; import { z } from 'zod'; import type { InputGuardrail, OutputGuardrail } from './guardrail'; @@ -37,8 +36,15 @@ import { RunToolApprovalItem } from './items'; import logger from './logger'; import { UnknownContext, TextOutput } from './types'; import type * as protocol from './types/protocol'; +import type { ZodObjectLike } from './utils/zodCompat'; type AnyAgentRunResult = RunResult>; +type CompletedRunResult< + TContext, + TAgent extends Agent, +> = RunResult & { + finalOutput: ResolvedAgentOutput; +}; // Per-process, ephemeral map linking a function tool call to its nested // Agent run result within the same run; entry is removed after consumption. @@ -114,7 +120,7 @@ export type ToolsToFinalOutputResult = */ export type AgentOutputType = | TextOutput - | ZodObject + | ZodObjectLike | JsonSchemaDefinition | HandoffsOutput; @@ -485,49 +491,51 @@ export class Agent< * @param options - Options for the tool. * @returns A tool that runs the agent and returns the output text. */ - asTool(options: { - /** - * The name of the tool. If not provided, the name of the agent will be used. - */ - toolName?: string; - /** - * The description of the tool, which should indicate what the tool does and when to use it. - */ - toolDescription?: string; - /** - * A function that extracts the output text from the agent. If not provided, the last message - * from the agent will be used. - */ - customOutputExtractor?: ( - output: RunResult>, - ) => string | Promise; - /** - * Whether invoking this tool requires approval, matching the behavior of {@link tool} helpers. - * When provided as a function it receives the tool arguments and can implement custom approval - * logic. - */ - needsApproval?: - | boolean - | ToolApprovalFunction; - /** - * Run configuration for initializing the internal agent runner. - */ - runConfig?: Partial; - /** - * Additional run options for the agent (as tool) execution. - */ - runOptions?: NonStreamRunOptions; - - /** - * Determines whether this tool should be exposed to the model for the current run. - */ - isEnabled?: - | boolean - | ((args: { - runContext: RunContext; - agent: Agent; - }) => boolean | Promise); - }): FunctionTool { + asTool = Agent>( + this: TAgent, + options: { + /** + * The name of the tool. If not provided, the name of the agent will be used. + */ + toolName?: string; + /** + * The description of the tool, which should indicate what the tool does and when to use it. + */ + toolDescription?: string; + /** + * A function that extracts the output text from the agent. If not provided, the last message + * from the agent will be used. + */ + customOutputExtractor?: ( + output: CompletedRunResult, + ) => string | Promise; + /** + * Whether invoking this tool requires approval, matching the behavior of {@link tool} helpers. + * When provided as a function it receives the tool arguments and can implement custom approval + * logic. + */ + needsApproval?: + | boolean + | ToolApprovalFunction; + /** + * Run configuration for initializing the internal agent runner. + */ + runConfig?: Partial; + /** + * Additional run options for the agent (as tool) execution. + */ + runOptions?: NonStreamRunOptions; + /** + * Determines whether this tool should be exposed to the model for the current run. + */ + isEnabled?: + | boolean + | ((args: { + runContext: RunContext; + agent: Agent; + }) => boolean | Promise); + }, + ): FunctionTool { const { toolName, toolDescription, @@ -553,6 +561,7 @@ export class Agent< context, ...(runOptions ?? {}), }); + const completedResult = result as CompletedRunResult; const usesStopAtToolNames = typeof this.toolUseBehavior === 'object' && @@ -569,15 +578,17 @@ export class Agent< } const outputText = typeof customOutputExtractor === 'function' - ? await customOutputExtractor(result as any) + ? await customOutputExtractor(completedResult) : getOutputText( - result.rawResponses[result.rawResponses.length - 1], + completedResult.rawResponses[ + completedResult.rawResponses.length - 1 + ], ); if (details?.toolCall) { saveAgentToolRunResult( details.toolCall, - result as RunResult>, + completedResult as RunResult>, ); } return outputText; diff --git a/packages/agents-core/src/tool.ts b/packages/agents-core/src/tool.ts index 11c88525..1f306a73 100644 --- a/packages/agents-core/src/tool.ts +++ b/packages/agents-core/src/tool.ts @@ -1,6 +1,5 @@ import type { Agent } from './agent'; import type { Computer } from './computer'; -import type { infer as zInfer, ZodObject } from 'zod'; import { JsonObjectSchema, JsonObjectSchemaNonStrict, @@ -20,6 +19,7 @@ import { RunToolApprovalItem, RunToolCallOutputItem } from './items'; import { toSmartString } from './utils/smartString'; import * as ProviderData from './types/providerData'; import * as protocol from './types/protocol'; +import type { ZodInfer, ZodObjectLike } from './utils/zodCompat'; /** * A function that determines if a tool call should be approved. @@ -387,7 +387,7 @@ export type FunctionToolResult< */ export type ToolInputParameters = | undefined - | ZodObject + | ZodObjectLike | JsonObjectSchema; /** @@ -404,7 +404,7 @@ export type ToolInputParameters = */ export type ToolInputParametersStrict = | undefined - | ZodObject + | ZodObjectLike | JsonObjectSchemaStrict; /** @@ -427,8 +427,8 @@ export type ToolInputParametersNonStrict = * match the inferred Zod type. Otherwise the type is `string` */ export type ToolExecuteArgument = - TParameters extends ZodObject - ? zInfer + TParameters extends ZodObjectLike + ? ZodInfer : TParameters extends JsonObjectSchema ? unknown : string; diff --git a/packages/agents-core/src/types/helpers.ts b/packages/agents-core/src/types/helpers.ts index b5ef3fd5..2898cd41 100644 --- a/packages/agents-core/src/types/helpers.ts +++ b/packages/agents-core/src/types/helpers.ts @@ -1,9 +1,9 @@ -import type { ZodObject, infer as zInfer } from 'zod'; import { Agent, AgentOutputType } from '../agent'; import { ToolInputParameters } from '../tool'; import { Handoff } from '../handoff'; import { ModelItem, StreamEvent } from './protocol'; import { TextOutput } from './aliases'; +import type { ZodInfer, ZodObjectLike } from '../utils/zodCompat'; /** * Item representing an output in a model response. @@ -17,25 +17,26 @@ export type ResponseStreamEvent = StreamEvent; export type ResolveParsedToolParameters< TInputType extends ToolInputParameters, -> = - TInputType extends ZodObject - ? zInfer - : TInputType extends JsonObjectSchema - ? unknown - : string; +> = TInputType extends ZodObjectLike + ? ZodInfer + : TInputType extends JsonObjectSchema + ? unknown + : string; export type ResolvedAgentOutput< TOutput extends AgentOutputType, H = unknown, > = TOutput extends TextOutput ? string - : TOutput extends ZodObject - ? zInfer + : TOutput extends ZodObjectLike + ? ZodInfer : TOutput extends HandoffsOutput ? HandoffsOutput - : TOutput extends Record - ? unknown - : never; + : unknown extends TOutput + ? any + : TOutput extends Record + ? unknown + : never; export type JsonSchemaDefinitionEntry = Record; diff --git a/packages/agents-core/src/utils/tools.ts b/packages/agents-core/src/utils/tools.ts index 57e47c35..5333bd86 100644 --- a/packages/agents-core/src/utils/tools.ts +++ b/packages/agents-core/src/utils/tools.ts @@ -1,4 +1,3 @@ -import type { ZodObject } from 'zod'; import { zodResponsesFunction, zodTextFormat } from 'openai/helpers/zod'; import { UserError } from '../errors'; import { ToolInputParameters } from '../tool'; @@ -6,9 +5,11 @@ import { JsonObjectSchema, JsonSchemaDefinition, TextOutput } from '../types'; import { isZodObject } from './typeGuards'; import { AgentOutputType } from '../agent'; import { - fallbackJsonSchemaFromZodObject, + zodJsonSchemaCompat, hasJsonSchemaObjectShape, -} from './zodFallback'; +} from './zodJsonSchemaCompat'; +import type { ZodObjectLike } from './zodCompat'; +import { asZodType } from './zodCompat'; export type FunctionToolName = string & { __brand?: 'ToolName' } & { readonly __pattern?: '^[a-zA-Z0-9_]+$'; @@ -16,11 +17,11 @@ export type FunctionToolName = string & { __brand?: 'ToolName' } & { // openai/helpers/zod cannot emit strict schemas for every Zod runtime // (notably Zod v4), so we delegate to a small local converter living in -// zodFallback.ts whenever its output is missing the required JSON Schema bits. +// zodJsonSchemaCompat.ts whenever its output is missing the required JSON Schema bits. function buildJsonSchemaFromZod( - inputType: ZodObject, + inputType: ZodObjectLike, ): JsonObjectSchema | undefined { - return fallbackJsonSchemaFromZodObject(inputType); + return zodJsonSchemaCompat(inputType); } /** @@ -64,7 +65,7 @@ export function getSchemaAndParserFromInputType( if (isZodObject(inputType)) { const formattedFunction = zodResponsesFunction({ name, - parameters: inputType, + parameters: asZodType(inputType), function: () => {}, // empty function here to satisfy the OpenAI helper description: '', }); @@ -108,7 +109,7 @@ export function convertAgentOutputTypeToSerializable( } if (isZodObject(outputType)) { - const output = zodTextFormat(outputType, 'output'); + const output = zodTextFormat(asZodType(outputType), 'output'); return { type: output.type, name: output.name, diff --git a/packages/agents-core/src/utils/typeGuards.ts b/packages/agents-core/src/utils/typeGuards.ts index 7675f093..6fd880a0 100644 --- a/packages/agents-core/src/utils/typeGuards.ts +++ b/packages/agents-core/src/utils/typeGuards.ts @@ -1,4 +1,5 @@ -import type { ZodObject } from 'zod'; +import type { ZodObjectLike } from './zodCompat'; +import { readZodDefinition, readZodType } from './zodCompat'; /** * Verifies that an input is a ZodObject without needing to have Zod at runtime since it's an @@ -6,21 +7,14 @@ import type { ZodObject } from 'zod'; * @param input * @returns */ -export function isZodObject(input: unknown): input is ZodObject { - if ( - typeof input !== 'object' || - input === null || - !('_def' in input) || - typeof input._def !== 'object' || - input._def === null - ) { +export function isZodObject(input: unknown): input is ZodObjectLike { + const definition = readZodDefinition(input); + if (!definition) { return false; } - const def = input._def as Record; - const typeName = typeof def.typeName === 'string' ? def.typeName : undefined; - const type = typeof def.type === 'string' ? def.type : undefined; - return typeName === 'ZodObject' || type === 'object'; + const type = readZodType(input); + return type === 'object'; } /** diff --git a/packages/agents-core/src/utils/zodCompat.ts b/packages/agents-core/src/utils/zodCompat.ts new file mode 100644 index 00000000..9aea7dac --- /dev/null +++ b/packages/agents-core/src/utils/zodCompat.ts @@ -0,0 +1,54 @@ +import type { ZodObject as ZodObjectV3, ZodTypeAny } from 'zod'; +import type { ZodObject as ZodObjectV4, ZodType as ZodTypeV4 } from 'zod/v4'; + +type ZodDefinition = Record | undefined; +type ZodLike = { + _def?: Record; + def?: Record; + _zod?: { def?: Record }; + shape?: Record | (() => Record); +}; + +type ZodTypeV4Any = ZodTypeV4; + +export type ZodTypeLike = ZodTypeAny | ZodTypeV4Any; +export type ZodObjectLike = + | ZodObjectV3 + | ZodObjectV4; + +export function asZodType(schema: ZodTypeLike): ZodTypeAny { + return schema as unknown as ZodTypeAny; +} + +export function readZodDefinition(input: unknown): ZodDefinition { + if (typeof input !== 'object' || input === null) { + return undefined; + } + + const candidate = input as ZodLike; + return candidate._zod?.def || candidate._def || candidate.def; +} + +export function readZodType(input: unknown): string | undefined { + const def = readZodDefinition(input); + if (!def) { + return undefined; + } + + const rawType = + (typeof def.typeName === 'string' && def.typeName) || + (typeof def.type === 'string' && def.type); + + if (typeof rawType !== 'string') { + return undefined; + } + + const lower = rawType.toLowerCase(); + return lower.startsWith('zod') ? lower.slice(3) : lower; +} + +export type ZodInfer = T extends { + _output: infer Output; +} + ? Output + : never; diff --git a/packages/agents-core/src/utils/zodFallback.ts b/packages/agents-core/src/utils/zodFallback.ts deleted file mode 100644 index f1275194..00000000 --- a/packages/agents-core/src/utils/zodFallback.ts +++ /dev/null @@ -1,289 +0,0 @@ -import type { ZodObject } from 'zod'; -import type { JsonObjectSchema, JsonSchemaDefinitionEntry } from '../types'; - -type ZodDefinition = Record | undefined; -type ZodLike = { - _def?: Record; - def?: Record; - _zod?: { def?: Record }; - shape?: Record | (() => Record); -}; - -type LooseJsonObjectSchema = { - type: 'object'; - properties: Record; - required?: string[]; - additionalProperties?: boolean; - $schema?: string; -}; - -const JSON_SCHEMA_DRAFT_07 = 'http://json-schema.org/draft-07/schema#'; -const OPTIONAL_WRAPPERS = new Set(['optional']); -const DECORATOR_WRAPPERS = new Set([ - 'brand', - 'catch', - 'default', - 'effects', - 'pipeline', - 'prefault', - 'readonly', - 'refinement', - 'transform', -]); - -export function hasJsonSchemaObjectShape( - value: unknown, -): value is LooseJsonObjectSchema { - return ( - typeof value === 'object' && - value !== null && - (value as { type?: string }).type === 'object' && - 'properties' in value && - 'additionalProperties' in value - ); -} - -export function fallbackJsonSchemaFromZodObject( - input: ZodObject, -): JsonObjectSchema | undefined { - const schema = buildObjectSchema(input); - if (!schema) { - return undefined; - } - - if (!Array.isArray(schema.required)) { - schema.required = []; - } - - if (typeof schema.additionalProperties === 'undefined') { - schema.additionalProperties = false; - } - - if (typeof schema.$schema !== 'string') { - schema.$schema = JSON_SCHEMA_DRAFT_07; - } - - return schema as JsonObjectSchema>; -} - -function buildObjectSchema(value: unknown): LooseJsonObjectSchema | undefined { - const shape = readShape(value); - if (!shape) { - return undefined; - } - - const properties: Record = {}; - const required: string[] = []; - - for (const [key, field] of Object.entries(shape)) { - const { schema, optional } = convertProperty(field); - if (!schema) { - return undefined; - } - - properties[key] = schema; - if (!optional) { - required.push(key); - } - } - - return { type: 'object', properties, required, additionalProperties: false }; -} - -function convertProperty(value: unknown): { - schema?: JsonSchemaDefinitionEntry; - optional: boolean; -} { - let current = unwrapDecorators(value); - let optional = false; - - while (OPTIONAL_WRAPPERS.has(readType(current) ?? '')) { - optional = true; - const def = readDefinition(current); - const next = unwrapDecorators(def?.innerType); - if (!next || next === current) { - break; - } - current = next; - } - - return { schema: convertSchema(current), optional }; -} - -function convertSchema(value: unknown): JsonSchemaDefinitionEntry | undefined { - const type = readType(value); - const def = readDefinition(value); - - switch (type) { - case 'string': - return { type: 'string' }; - case 'number': - return { type: 'number' }; - case 'bigint': - return { type: 'integer' }; - case 'boolean': - return { type: 'boolean' }; - case 'date': - return { type: 'string', format: 'date-time' }; - case 'literal': { - const literal = (def?.value ?? def?.literal) as - | string - | number - | boolean - | null; - return literal === undefined - ? undefined - : { const: literal, type: literal === null ? 'null' : typeof literal }; - } - case 'enum': - case 'nativeenum': { - const values = ((Array.isArray(def?.values) && def?.values) || - (Array.isArray(def?.options) && def?.options) || - (def?.values && - typeof def?.values === 'object' && - Object.values(def.values)) || - (def?.enum && - typeof def?.enum === 'object' && - Object.values(def.enum))) as unknown[] | undefined; - return values && values.length - ? { enum: values as unknown[] } - : undefined; - } - case 'array': { - const element = def?.element ?? def?.items ?? def?.type; - const items = convertSchema(element); - return items ? { type: 'array', items } : undefined; - } - case 'tuple': { - const tupleItems = Array.isArray(def?.items) ? def?.items : []; - const converted = tupleItems - .map((item) => convertSchema(item)) - .filter(Boolean) as JsonSchemaDefinitionEntry[]; - if (!converted.length) { - return undefined; - } - const schema: JsonSchemaDefinitionEntry = { - type: 'array', - items: converted, - minItems: converted.length, - }; - if (!def?.rest) { - schema.maxItems = converted.length; - } - return schema; - } - case 'union': { - const options = - (Array.isArray(def?.options) && def?.options) || - (Array.isArray(def?.schemas) && def?.schemas); - if (!options) { - return undefined; - } - const anyOf = options - .map((option) => convertSchema(option)) - .filter(Boolean) as JsonSchemaDefinitionEntry[]; - return anyOf.length ? { anyOf } : undefined; - } - case 'intersection': { - const left = convertSchema(def?.left); - const right = convertSchema(def?.right); - return left && right ? { allOf: [left, right] } : undefined; - } - case 'record': { - const valueSchema = convertSchema(def?.valueType ?? def?.values); - return valueSchema - ? { type: 'object', additionalProperties: valueSchema } - : undefined; - } - case 'map': { - const valueSchema = convertSchema(def?.valueType ?? def?.values); - return valueSchema ? { type: 'array', items: valueSchema } : undefined; - } - case 'set': { - const valueSchema = convertSchema(def?.valueType); - return valueSchema - ? { type: 'array', items: valueSchema, uniqueItems: true } - : undefined; - } - case 'nullable': { - const inner = convertSchema(def?.innerType ?? def?.type); - return inner ? { anyOf: [inner, { type: 'null' }] } : undefined; - } - case 'object': - return buildObjectSchema(value); - default: - return undefined; - } -} - -function readDefinition(input: unknown): ZodDefinition { - if (typeof input !== 'object' || input === null) { - return undefined; - } - const candidate = input as ZodLike; - return candidate._zod?.def || candidate._def || candidate.def; -} - -function readType(input: unknown): string | undefined { - const def = readDefinition(input); - const rawType = - (typeof def?.typeName === 'string' && def?.typeName) || - (typeof def?.type === 'string' && def?.type); - if (!rawType) { - return undefined; - } - const lower = rawType.toLowerCase(); - return lower.startsWith('zod') ? lower.slice(3) : lower; -} - -function unwrapDecorators(value: unknown): unknown { - let current = value; - while (DECORATOR_WRAPPERS.has(readType(current) ?? '')) { - const def = readDefinition(current); - const next = - def?.innerType ?? - def?.schema ?? - def?.base ?? - def?.type ?? - def?.wrapped ?? - def?.underlying; - if (!next || next === current) { - return current; - } - current = next; - } - return current; -} - -function readShape(input: unknown): Record | undefined { - if (typeof input !== 'object' || input === null) { - return undefined; - } - - const candidate = input as ZodLike; - if (candidate.shape && typeof candidate.shape === 'object') { - return candidate.shape; - } - if (typeof candidate.shape === 'function') { - try { - return candidate.shape(); - } catch (_error) { - return undefined; - } - } - - const def = readDefinition(candidate); - const shape = def?.shape; - if (shape && typeof shape === 'object') { - return shape as Record; - } - if (typeof shape === 'function') { - try { - return shape(); - } catch (_error) { - return undefined; - } - } - - return undefined; -} diff --git a/packages/agents-core/src/utils/zodJsonSchemaCompat.ts b/packages/agents-core/src/utils/zodJsonSchemaCompat.ts new file mode 100644 index 00000000..bfa22932 --- /dev/null +++ b/packages/agents-core/src/utils/zodJsonSchemaCompat.ts @@ -0,0 +1,384 @@ +import type { JsonObjectSchema, JsonSchemaDefinitionEntry } from '../types'; +import type { ZodObjectLike } from './zodCompat'; +import { readZodDefinition, readZodType } from './zodCompat'; + +/** + * The JSON-schema helpers in openai/helpers/zod only emit complete schemas for + * a subset of Zod constructs. In particular, Zod v4 (and several decorators in v3) + * omit `type`, `properties`, or `required` metadata, which breaks tool execution + * when a user relies on automatic schema extraction. + * + * This module provides a minimal, type-directed fallback converter that inspects + * Zod internals and synthesises the missing JSON Schema bits on demand. The + * converter only covers the constructs we actively depend on (objects, optionals, + * unions, tuples, records, sets, etc.); anything more exotic simply returns + * `undefined`, signalling to the caller that it should surface a user error. + * + * The implementation is intentionally explicit: helper functions isolate each + * Zod shape, making the behaviour both testable and easier to trim back if the + * upstream helper gains first-class support. See zodJsonSchemaCompat.test.ts for + * the regression cases we guarantee. + */ + +type LooseJsonObjectSchema = { + type: 'object'; + properties: Record; + required?: string[]; + additionalProperties?: boolean; + $schema?: string; +}; + +type ShapeCandidate = { + shape?: Record | (() => Record); +}; + +const JSON_SCHEMA_DRAFT_07 = 'http://json-schema.org/draft-07/schema#'; +const OPTIONAL_WRAPPERS = new Set(['optional']); +const DECORATOR_WRAPPERS = new Set([ + 'brand', + 'branded', + 'catch', + 'default', + 'effects', + 'pipeline', + 'pipe', + 'prefault', + 'readonly', + 'refinement', + 'transform', +]); + +// Primitive leaf nodes map 1:1 to JSON Schema types; everything else is handled +// by the specialised builders further down. +const SIMPLE_TYPE_MAPPING: Record = { + string: { type: 'string' }, + number: { type: 'number' }, + bigint: { type: 'integer' }, + boolean: { type: 'boolean' }, + date: { type: 'string', format: 'date-time' }, +}; + +export function hasJsonSchemaObjectShape( + value: unknown, +): value is LooseJsonObjectSchema { + return ( + typeof value === 'object' && + value !== null && + (value as { type?: string }).type === 'object' && + 'properties' in value && + 'additionalProperties' in value + ); +} + +export function zodJsonSchemaCompat( + input: ZodObjectLike, +): JsonObjectSchema | undefined { + // Attempt to build an object schema from Zod's internal shape. If we cannot + // understand the structure we return undefined, letting callers raise a + // descriptive error instead of emitting an invalid schema. + const schema = buildObjectSchema(input); + if (!schema) { + return undefined; + } + + if (!Array.isArray(schema.required)) { + schema.required = []; + } + + if (typeof schema.additionalProperties === 'undefined') { + schema.additionalProperties = false; + } + + if (typeof schema.$schema !== 'string') { + schema.$schema = JSON_SCHEMA_DRAFT_07; + } + + return schema as JsonObjectSchema>; +} + +function buildObjectSchema(value: unknown): LooseJsonObjectSchema | undefined { + const shape = readShape(value); + if (!shape) { + return undefined; + } + + const properties: Record = {}; + const required: string[] = []; + + for (const [key, field] of Object.entries(shape)) { + const { schema, optional } = convertProperty(field); + if (!schema) { + return undefined; + } + + properties[key] = schema; + if (!optional) { + required.push(key); + } + } + + return { type: 'object', properties, required, additionalProperties: false }; +} + +function convertProperty(value: unknown): { + schema?: JsonSchemaDefinitionEntry; + optional: boolean; +} { + // Remove wrapper decorators (brand, transform, etc.) before attempting to + // classify the node, tracking whether we crossed an `optional` boundary so we + // can populate the `required` array later. + let current = unwrapDecorators(value); + let optional = false; + + while (OPTIONAL_WRAPPERS.has(readZodType(current) ?? '')) { + optional = true; + const def = readZodDefinition(current); + const next = unwrapDecorators(def?.innerType); + if (!next || next === current) { + break; + } + current = next; + } + + return { schema: convertSchema(current), optional }; +} + +function convertSchema(value: unknown): JsonSchemaDefinitionEntry | undefined { + if (value === undefined) { + return undefined; + } + + const unwrapped = unwrapDecorators(value); + const type = readZodType(unwrapped); + const def = readZodDefinition(unwrapped); + + if (!type) { + return undefined; + } + + if (type in SIMPLE_TYPE_MAPPING) { + return SIMPLE_TYPE_MAPPING[type]; + } + + switch (type) { + case 'object': + return buildObjectSchema(unwrapped); + case 'array': + return buildArraySchema(def); + case 'tuple': + return buildTupleSchema(def); + case 'union': + return buildUnionSchema(def); + case 'intersection': + return buildIntersectionSchema(def); + case 'literal': + return buildLiteral(def); + case 'enum': + case 'nativeenum': + return buildEnum(def); + case 'record': + return buildRecordSchema(def); + case 'map': + return buildMapSchema(def); + case 'set': + return buildSetSchema(def); + case 'nullable': + return buildNullableSchema(def); + default: + return undefined; + } +} + +// --- JSON Schema builders ------------------------------------------------- + +function buildArraySchema( + def: Record | undefined, +): JsonSchemaDefinitionEntry | undefined { + const items = convertSchema(extractFirst(def, 'element', 'items', 'type')); + return items ? { type: 'array', items } : undefined; +} + +function buildTupleSchema( + def: Record | undefined, +): JsonSchemaDefinitionEntry | undefined { + const items = coerceArray(def?.items) + .map((item) => convertSchema(item)) + .filter(Boolean) as JsonSchemaDefinitionEntry[]; + if (!items.length) { + return undefined; + } + const schema: JsonSchemaDefinitionEntry = { + type: 'array', + items, + minItems: items.length, + }; + if (!def?.rest) { + schema.maxItems = items.length; + } + return schema; +} + +function buildUnionSchema( + def: Record | undefined, +): JsonSchemaDefinitionEntry | undefined { + const options = coerceArray(def?.options ?? def?.schemas) + .map((option) => convertSchema(option)) + .filter(Boolean) as JsonSchemaDefinitionEntry[]; + return options.length ? { anyOf: options } : undefined; +} + +function buildIntersectionSchema( + def: Record | undefined, +): JsonSchemaDefinitionEntry | undefined { + const left = convertSchema(def?.left); + const right = convertSchema(def?.right); + return left && right ? { allOf: [left, right] } : undefined; +} + +function buildRecordSchema( + def: Record | undefined, +): JsonSchemaDefinitionEntry | undefined { + const valueSchema = convertSchema(def?.valueType ?? def?.values); + return valueSchema + ? { type: 'object', additionalProperties: valueSchema } + : undefined; +} + +function buildMapSchema( + def: Record | undefined, +): JsonSchemaDefinitionEntry | undefined { + const valueSchema = convertSchema(def?.valueType ?? def?.values); + return valueSchema ? { type: 'array', items: valueSchema } : undefined; +} + +function buildSetSchema( + def: Record | undefined, +): JsonSchemaDefinitionEntry | undefined { + const valueSchema = convertSchema(def?.valueType); + return valueSchema + ? { type: 'array', items: valueSchema, uniqueItems: true } + : undefined; +} + +function buildNullableSchema( + def: Record | undefined, +): JsonSchemaDefinitionEntry | undefined { + const inner = convertSchema(def?.innerType ?? def?.type); + return inner ? { anyOf: [inner, { type: 'null' }] } : undefined; +} + +function unwrapDecorators(value: unknown): unknown { + let current = value; + while (DECORATOR_WRAPPERS.has(readZodType(current) ?? '')) { + const def = readZodDefinition(current); + const next = + def?.innerType ?? + def?.schema ?? + def?.base ?? + def?.type ?? + def?.wrapped ?? + def?.underlying; + if (!next || next === current) { + return current; + } + current = next; + } + return current; +} + +function extractFirst( + def: Record | undefined, + ...keys: string[] +): unknown { + if (!def) { + return undefined; + } + for (const key of keys) { + if (key in def && def[key] !== undefined) { + return (def as Record)[key]; + } + } + return undefined; +} + +function coerceArray(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value; + } + return value === undefined ? [] : [value]; +} + +function buildLiteral( + def: Record | undefined, +): JsonSchemaDefinitionEntry | undefined { + if (!def) { + return undefined; + } + const literal = extractFirst(def, 'value', 'literal') as + | string + | number + | boolean + | null + | undefined; + if (literal === undefined) { + return undefined; + } + return { + const: literal, + type: literal === null ? 'null' : typeof literal, + }; +} + +function buildEnum( + def: Record | undefined, +): JsonSchemaDefinitionEntry | undefined { + if (!def) { + return undefined; + } + if (Array.isArray(def.values)) { + return { enum: def.values as unknown[] }; + } + if (Array.isArray(def.options)) { + return { enum: def.options as unknown[] }; + } + if (def.values && typeof def.values === 'object') { + return { enum: Object.values(def.values as Record) }; + } + if (def.enum && typeof def.enum === 'object') { + return { enum: Object.values(def.enum as Record) }; + } + return undefined; +} + +function readShape(input: unknown): Record | undefined { + if (typeof input !== 'object' || input === null) { + return undefined; + } + + const candidate = input as ShapeCandidate; + if (candidate.shape && typeof candidate.shape === 'object') { + return candidate.shape; + } + if (typeof candidate.shape === 'function') { + try { + return candidate.shape(); + } catch (_error) { + return undefined; + } + } + + const def = readZodDefinition(candidate); + const shape = def?.shape; + if (shape && typeof shape === 'object') { + return shape as Record; + } + if (typeof shape === 'function') { + try { + return shape(); + } catch (_error) { + return undefined; + } + } + + return undefined; +} diff --git a/packages/agents-core/test/utils/tools.test.ts b/packages/agents-core/test/utils/tools.test.ts index aafe597d..d539615f 100644 --- a/packages/agents-core/test/utils/tools.test.ts +++ b/packages/agents-core/test/utils/tools.test.ts @@ -4,6 +4,7 @@ import { getSchemaAndParserFromInputType, } from '../../src/utils/tools'; import { z } from 'zod'; +import { z as z4 } from 'zod/v4'; import { UserError } from '../../src/errors'; import { JsonObjectSchema, JsonSchemaDefinitionEntry } from '../../src/types'; @@ -37,6 +38,13 @@ describe('utils/tools', () => { expect(res.parser('{"bar":2}')).toEqual({ bar: 2 }); }); + it('getSchemaAndParserFromInputType with ZodObject v4', () => { + const zodSchema = z4.object({ baz: z4.string() }); + const res = getSchemaAndParserFromInputType(zodSchema, 'toolv4'); + expect(res.schema).toHaveProperty('type', 'object'); + expect(res.parser('{"baz":"ok"}')).toEqual({ baz: 'ok' }); + }); + it('getSchemaAndParserFromInputType rejects invalid input', () => { expect(() => getSchemaAndParserFromInputType('bad' as any, 't')).toThrow( UserError, diff --git a/packages/agents-core/test/utils/typeGuards.test.ts b/packages/agents-core/test/utils/typeGuards.test.ts index 5ce96777..505d060a 100644 --- a/packages/agents-core/test/utils/typeGuards.test.ts +++ b/packages/agents-core/test/utils/typeGuards.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { isZodObject, isAgentToolInput } from '../../src/utils/typeGuards'; import { z } from 'zod'; +import { z as z4 } from 'zod/v4'; describe('type guards', () => { it('isZodObject detects zod objects', () => { @@ -8,6 +9,10 @@ describe('type guards', () => { expect(isZodObject({})).toBe(false); }); + it('isZodObject detects zod v4 objects', () => { + expect(isZodObject(z4.object({}))).toBe(true); + }); + it('isAgentToolInput checks for string input property', () => { expect(isAgentToolInput({ input: 'x' })).toBe(true); expect(isAgentToolInput({ input: 42 })).toBe(false); diff --git a/packages/agents-core/test/utils/zodJsonSchemaCompat.test.ts b/packages/agents-core/test/utils/zodJsonSchemaCompat.test.ts new file mode 100644 index 00000000..e3e46c7f --- /dev/null +++ b/packages/agents-core/test/utils/zodJsonSchemaCompat.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { z as z4 } from 'zod/v4'; +import { zodJsonSchemaCompat } from '../../src/utils/zodJsonSchemaCompat'; + +describe('utils/zodJsonSchemaCompat', () => { + it('builds schema for basic object with optional property', () => { + const schema = z.object({ + name: z.string(), + age: z.number().optional(), + }); + + const jsonSchema = zodJsonSchemaCompat(schema); + expect(jsonSchema).toBeDefined(); + expect(jsonSchema).toMatchObject({ + type: 'object', + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + }); + expect(jsonSchema?.required).toEqual(['name']); + }); + + it('unwraps decorators and nullable types', () => { + const schema = z.object({ + branded: z.string().brand('Tagged'), + readonly: z.string().readonly(), + nullable: z.string().nullable(), + }); + + const jsonSchema = zodJsonSchemaCompat(schema); + expect(jsonSchema).toBeDefined(); + expect(jsonSchema?.properties.branded).toEqual({ type: 'string' }); + expect(jsonSchema?.properties.readonly).toEqual({ type: 'string' }); + expect(jsonSchema?.properties.nullable).toEqual({ + anyOf: [{ type: 'string' }, { type: 'null' }], + }); + }); + + it('handles compound schemas such as tuples and unions', () => { + const schema = z.object({ + tuple: z.tuple([z.string(), z.number()]), + union: z.union([z.string(), z.number()]), + }); + + const jsonSchema = zodJsonSchemaCompat(schema); + expect(jsonSchema).toBeDefined(); + expect(jsonSchema?.properties.tuple).toMatchObject({ + type: 'array', + minItems: 2, + maxItems: 2, + items: [{ type: 'string' }, { type: 'number' }], + }); + expect(jsonSchema?.properties.union).toMatchObject({ + anyOf: [{ type: 'string' }, { type: 'number' }], + }); + }); + + it('converts nested record and array structures', () => { + const schema = z.object({ + record: z.record(z.number()), + list: z.array(z.object({ id: z.string() })), + }); + + const jsonSchema = zodJsonSchemaCompat(schema); + expect(jsonSchema).toBeDefined(); + expect(jsonSchema?.properties.record).toMatchObject({ + type: 'object', + additionalProperties: { type: 'number' }, + }); + expect(jsonSchema?.properties.list).toMatchObject({ + type: 'array', + items: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + additionalProperties: false, + }, + }); + }); + + it('supports Zod v4 objects', () => { + const schema = z4.object({ + title: z4.string(), + score: z4.number().optional(), + tags: z4.set(z4.string()), + }); + + const jsonSchema = zodJsonSchemaCompat(schema as any); + expect(jsonSchema).toBeDefined(); + expect(jsonSchema?.properties.title).toEqual({ type: 'string' }); + expect(jsonSchema?.properties.score).toEqual({ type: 'number' }); + expect(jsonSchema?.properties.tags).toEqual({ + type: 'array', + uniqueItems: true, + items: { type: 'string' }, + }); + expect(jsonSchema?.required).toEqual(['title', 'tags']); + }); +});