diff --git a/.changeset/strict-function-tools.md b/.changeset/strict-function-tools.md new file mode 100644 index 00000000..a6baf11d --- /dev/null +++ b/.changeset/strict-function-tools.md @@ -0,0 +1,20 @@ +--- +'@openrouter/agent': minor +--- + +Add `strict` to all client function-tool definitions, including `tool.agent()`, and pass it through serialization instead of hardcoding `strict: null`, so providers can enforce structured-outputs-style schema adherence on tool-call arguments. + +The SDK forwards the caller's generated schema unchanged and propagates provider validation errors. OpenAI-style strict schemas require every object property to be listed in `required`; use Zod `.nullable()` for conceptually optional values because `.optional()` allows the key to be omitted. + +```ts +import { tool } from '@openrouter/agent'; +import { z } from 'zod/v4'; + +const searchTool = tool({ + name: 'search', + inputSchema: z.object({ query: z.string() }), + strict: true, // was: silently dropped (serialized as strict: null) + // now: serialized as strict: true on the wire tool definition + execute: async ({ query }) => runSearch(query), +}); +``` diff --git a/packages/agent/README.md b/packages/agent/README.md index ee5e5365..25fac34d 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -536,6 +536,36 @@ const researcher = tool.agent({ The parent keeps working while children run (several can run concurrently under the background pool). The child's conversation is the check-in **transcript**; each child turn is a **log** entry; `status` reports `turnsCompleted` and `currentActivity`. `cancelTask(taskId)` (or parent abort / `timeoutMs`) cancels the child; `sendToTask` steers it mid-run. Children run **in-memory** (no `StateAccessor`) and do not inherit the parent's hooks — pass child hooks explicitly in the `agent` spec if needed. A child that pauses (HITL/manual/approval/deferred tools inside it) fails the task with a clear error. +### Strict Tool Schemas + +Every client tool kind, including `tool.agent()`, accepts `strict: true` to +request provider-enforced schema adherence for generated tool-call arguments. +The SDK faithfully converts the caller's `inputSchema`; it does not rewrite +the runtime Zod contract. + +OpenAI-style strict function calling requires every declared object property +to appear in JSON Schema's `required` list. Use `.nullable()` for a value that +may be absent conceptually, because `.optional()` omits the property from +`required`: + +```typescript +const weatherTool = tool({ + name: 'get_weather', + inputSchema: z.object({ + location: z.string(), + // The key is required, but the model may return null. + units: z.enum(['celsius', 'fahrenheit']).nullable(), + }), + strict: true, + execute: async ({ location, units }) => getWeather(location, units), +}); +``` + +The SDK sends the generated schema unchanged. Providers validate it according +to their own strict-mode dialect and the SDK propagates any API error. Use +`.nullable()`, or set `strict: false` when omission is part of the tool's +contract. Provider support and strict-schema restrictions can vary. + ### Per-Tool Timeouts & Concurrency Every tool kind accepts `timeoutMs` (per-execution deadline; the run-level `toolTimeoutMs` sets a default) and `maxConcurrency` (max simultaneous executions of that tool). On timeout the round stops waiting — the model receives `{ error, code: 'tool_timeout' }` and the tool's `ctx.signal` aborts; the timeout bounds the round's *wait*, not the tool body, so signal-ignoring bodies can't hang the run. `ctx.signal` also fires on run abort (`signal` option) and `ModelResult.cancel()`. diff --git a/packages/agent/src/lib/agent-tool.ts b/packages/agent/src/lib/agent-tool.ts index 72441238..bf3a551a 100644 --- a/packages/agent/src/lib/agent-tool.ts +++ b/packages/agent/src/lib/agent-tool.ts @@ -138,6 +138,13 @@ export type AgentToolConfig< name: TName; description?: string; inputSchema: TInput; + /** + * Whether providers should enforce strict schema adherence for this agent + * tool's generated arguments. OpenAI-style strict mode requires every + * property to be required; those providers may reject `.optional()` fields, + * so use `.nullable()` when the key may conceptually have no value. + */ + strict?: boolean | null; /** * Required. The mapped child result is validated against it — the same * rule every long-running tool obeys (results settle after the round). @@ -327,6 +334,7 @@ export function agentToolBuilder< }; const optionalFields = [ 'description', + 'strict', 'contextSchema', 'nextTurnParams', 'requireApproval', diff --git a/packages/agent/src/lib/tool-executor.ts b/packages/agent/src/lib/tool-executor.ts index e3375a1e..5ccdc7b7 100644 --- a/packages/agent/src/lib/tool-executor.ts +++ b/packages/agent/src/lib/tool-executor.ts @@ -128,7 +128,7 @@ export function convertToolsToAPIFormat( type: 'function' as const, name: tool.function.name, description: tool.function.description || null, - strict: null, + strict: tool.function.strict ?? null, parameters: convertZodToJsonSchema(tool.function.inputSchema), }; return apiTool; diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index eaf95d10..a061453b 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -426,6 +426,17 @@ export interface BaseToolFunction< name: string; description?: string; inputSchema: TInput; + /** + * Whether providers should enforce strict schema adherence when generating + * this tool's call arguments (OpenAI structured-outputs style). Serialized + * onto the wire tool definition; `null`/absent leaves provider default. + * + * OpenAI-style strict mode requires every declared object property to be + * listed in `required`. Use Zod `.nullable()` for values that may be absent + * conceptually; `.optional()` produces a schema those providers may reject. + * The SDK forwards the caller's schema unchanged. + */ + strict?: boolean | null; /** * Zod schema declaring the context data this tool needs. * `readonly` keeps TCtx covariant so tools carrying a concrete schema stay diff --git a/packages/agent/src/lib/tool.ts b/packages/agent/src/lib/tool.ts index 1f226864..3731e676 100644 --- a/packages/agent/src/lib/tool.ts +++ b/packages/agent/src/lib/tool.ts @@ -50,6 +50,8 @@ type RegularToolConfigWithOutput< inputSchema: TInput; outputSchema: TOutput; eventSchema?: undefined; + /** Strict schema adherence for tool-call generation — see {@link BaseToolFunction.strict} */ + strict?: boolean | null; /** Zod schema declaring the context data this tool needs */ contextSchema?: TCtx; nextTurnParams?: NextTurnParamsFunctions>; @@ -82,6 +84,8 @@ type RegularToolConfigWithoutOutput< inputSchema: TInput; outputSchema?: undefined; eventSchema?: undefined; + /** Strict schema adherence for tool-call generation — see {@link BaseToolFunction.strict} */ + strict?: boolean | null; /** Zod schema declaring the context data this tool needs */ contextSchema?: TCtx; nextTurnParams?: NextTurnParamsFunctions>; @@ -115,6 +119,8 @@ type GeneratorToolConfig< inputSchema: TInput; eventSchema: TEvent; outputSchema: TOutput; + /** Strict schema adherence for tool-call generation — see {@link BaseToolFunction.strict} */ + strict?: boolean | null; /** Zod schema declaring the context data this tool needs */ contextSchema?: TCtx; nextTurnParams?: NextTurnParamsFunctions>; @@ -143,6 +149,8 @@ type ManualToolConfig< name: string; // Manual tools don't use TName since they have no execute description?: string; inputSchema: TInput; + /** Strict schema adherence for tool-call generation — see {@link BaseToolFunction.strict} */ + strict?: boolean | null; /** Zod schema declaring the context data this tool needs */ contextSchema?: TCtx; nextTurnParams?: NextTurnParamsFunctions>; @@ -185,6 +193,8 @@ type HITLToolConfig< outputSchema: TOutput; eventSchema?: undefined; execute?: undefined; + /** Strict schema adherence for tool-call generation — see {@link BaseToolFunction.strict} */ + strict?: boolean | null; /** Zod schema declaring the context data this tool needs */ contextSchema?: TCtx; nextTurnParams?: NextTurnParamsFunctions>; @@ -220,6 +230,8 @@ type ToolConfigWithSharedContext< inputSchema: $ZodObject<$ZodShape>; outputSchema?: $ZodType; eventSchema?: $ZodType; + /** Strict schema adherence for tool-call generation — see {@link BaseToolFunction.strict} */ + strict?: boolean | null; contextSchema?: TCtx; nextTurnParams?: NextTurnParamsFunctions>; requireApproval?: boolean | ToolApprovalCheck>; @@ -257,6 +269,8 @@ type RunToolConfigBase< /** Never present on run configs — keeps them disjoint from legacy overloads. */ execute?: undefined; onToolCalled?: undefined; + /** Strict schema adherence for tool-call generation — see {@link BaseToolFunction.strict} */ + strict?: boolean | null; /** Zod schema declaring the context data this tool needs */ contextSchema?: TCtx; nextTurnParams?: NextTurnParamsFunctions>; @@ -569,6 +583,10 @@ export function tool( fn.maxConcurrency = config.maxConcurrency; } + if (config.strict !== undefined) { + fn.strict = config.strict; + } + if (config.onResponseReceived !== undefined) { fn.onResponseReceived = config.onResponseReceived; } @@ -624,6 +642,10 @@ export function tool( fn.maxConcurrency = config.maxConcurrency; } + if (config.strict !== undefined) { + fn.strict = config.strict; + } + return { type: ToolType.Function, function: fn, @@ -678,6 +700,10 @@ export function tool( fn.toModelOutput = config.toModelOutput; } + if (config.strict !== undefined) { + fn.strict = config.strict; + } + return { type: ToolType.Function, function: fn, @@ -713,6 +739,9 @@ export function tool( ...(config.maxConcurrency !== undefined && { maxConcurrency: config.maxConcurrency, }), + ...(config.strict !== undefined && { + strict: config.strict, + }), ...('toModelOutput' in config && config.toModelOutput !== undefined && { toModelOutput: config.toModelOutput, @@ -814,6 +843,7 @@ function assignCommonToolFields( ): void { const fields = [ 'description', + 'strict', 'contextSchema', 'nextTurnParams', 'requireApproval', diff --git a/packages/agent/tests/unit/full-field-examples.test-d.ts b/packages/agent/tests/unit/full-field-examples.test-d.ts index 0fec045f..0de83650 100644 --- a/packages/agent/tests/unit/full-field-examples.test-d.ts +++ b/packages/agent/tests/unit/full-field-examples.test-d.ts @@ -57,6 +57,9 @@ describe('full-field examples (PR documentation, compile-verified)', () => { // Lifecycle: 'sync' (default) | 'background' | 'deferred' lifecycle: 'background', + // Zod emits defaulted fields as required, but provider support for the + // `default` JSON Schema keyword varies, so this example stays non-strict. + strict: false, // Schemas inputSchema: z.object({ @@ -155,6 +158,9 @@ describe('full-field examples (PR documentation, compile-verified)', () => { name: 'request_legal_review', description: 'Send a contract for legal review. Pauses until the webhook resolves it.', lifecycle: 'deferred', + // Zod emits defaulted fields as required, but provider support for the + // `default` JSON Schema keyword varies, so this example stays non-strict. + strict: false, inputSchema: z.object({ contractId: z.string(), urgency: z @@ -204,6 +210,7 @@ describe('full-field examples (PR documentation, compile-verified)', () => { // ─── Example 3: agent tool — every field ───────────────────────────────── const childSearch = tool({ name: 'web_search', + strict: true, inputSchema: z.object({ q: z.string(), }), @@ -215,9 +222,10 @@ describe('full-field examples (PR documentation, compile-verified)', () => { const researcher = tool.agent({ name: 'research_topic', description: 'Deep-research a topic as a background subagent.', + strict: true, inputSchema: z.object({ topic: z.string(), - depth: z.number().int().min(1).max(20).default(5), + depth: z.number().int().min(1).max(20), }), outputSchema: z.object({ text: z.string(), diff --git a/packages/agent/tests/unit/server-tool.test.ts b/packages/agent/tests/unit/server-tool.test.ts index 6daffc97..da555529 100644 --- a/packages/agent/tests/unit/server-tool.test.ts +++ b/packages/agent/tests/unit/server-tool.test.ts @@ -96,6 +96,106 @@ describe('convertToolsToAPIFormat', () => { }); }); + it('serializes strict: true from the tool definition', () => { + const strictTool = tool({ + name: 'echo', + inputSchema: z.object({ + msg: z.string(), + }), + strict: true, + execute: ({ msg }) => msg, + }); + const api = convertToolsToAPIFormat([ + strictTool, + ]); + expect(api[0]).toMatchObject({ + type: 'function', + name: 'echo', + strict: true, + }); + }); + + it('serializes strict: true from a unified run tool', () => { + const strictRunTool = tool({ + name: 'run-echo', + inputSchema: z.object({ + msg: z.string(), + }), + strict: true, + lifecycle: 'sync', + run: ({ msg }) => msg, + }); + const api = convertToolsToAPIFormat([ + strictRunTool, + ]); + expect(api[0]).toMatchObject({ + type: 'function', + name: 'run-echo', + strict: true, + }); + }); + + it('serializes strict: true from an agent tool', () => { + const strictAgentTool = tool.agent({ + name: 'research', + inputSchema: z.object({ + topic: z.string(), + }), + outputSchema: z.object({ + text: z.string(), + }), + strict: true, + agent: ({ topic }) => ({ + model: 'test-model', + input: topic, + }), + }); + const api = convertToolsToAPIFormat([ + strictAgentTool, + ]); + expect(api[0]).toMatchObject({ + type: 'function', + name: 'research', + strict: true, + }); + }); + + it('serializes strict: false as false instead of null', () => { + const nonStrictTool = tool({ + name: 'echo', + inputSchema: z.object({ + msg: z.string(), + }), + strict: false, + execute: ({ msg }) => msg, + }); + const api = convertToolsToAPIFormat([ + nonStrictTool, + ]); + expect(api[0]).toMatchObject({ + type: 'function', + strict: false, + }); + expect(api[0]?.strict).not.toBeNull(); + }); + + it('defaults strict to null when not declared', () => { + const plainTool = tool({ + name: 'echo', + inputSchema: z.object({ + msg: z.string(), + }), + execute: ({ msg }) => msg, + }); + const api = convertToolsToAPIFormat([ + plainTool, + ]); + expect(api[0]).toMatchObject({ + type: 'function', + strict: null, + }); + }); + it('mixes client + server tools in one array', () => { const clientTool = tool({ name: 'echo',