Skip to content
Merged
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
20 changes: 20 additions & 0 deletions .changeset/strict-function-tools.md
Original file line number Diff line number Diff line change
@@ -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),
});
```
30 changes: 30 additions & 0 deletions packages/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand Down
8 changes: 8 additions & 0 deletions packages/agent/src/lib/agent-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -327,6 +334,7 @@ export function agentToolBuilder<
};
const optionalFields = [
'description',
'strict',
'contextSchema',
'nextTurnParams',
'requireApproval',
Expand Down
2 changes: 1 addition & 1 deletion packages/agent/src/lib/tool-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions packages/agent/src/lib/tool-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
w0nche0l marked this conversation as resolved.
/**
* Zod schema declaring the context data this tool needs.
* `readonly` keeps TCtx covariant so tools carrying a concrete schema stay
Expand Down
30 changes: 30 additions & 0 deletions packages/agent/src/lib/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<zodInfer<TInput>>;
Expand Down Expand Up @@ -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<zodInfer<TInput>>;
Expand Down Expand Up @@ -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<zodInfer<TInput>>;
Expand Down Expand Up @@ -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<zodInfer<TInput>>;
Expand Down Expand Up @@ -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<zodInfer<TInput>>;
Expand Down Expand Up @@ -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<Record<string, unknown>>;
requireApproval?: boolean | ToolApprovalCheck<Record<string, unknown>>;
Expand Down Expand Up @@ -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<zodInfer<TInput>>;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -814,6 +843,7 @@ function assignCommonToolFields(
): void {
const fields = [
'description',
'strict',
Comment thread
w0nche0l marked this conversation as resolved.
'contextSchema',
'nextTurnParams',
'requireApproval',
Expand Down
10 changes: 9 additions & 1 deletion packages/agent/tests/unit/full-field-examples.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
}),
Expand All @@ -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(),
Expand Down
100 changes: 100 additions & 0 deletions packages/agent/tests/unit/server-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,106 @@ describe('convertToolsToAPIFormat', () => {
});
});

it('serializes strict: true from the tool definition', () => {
Comment thread
w0nche0l marked this conversation as resolved.
Comment thread
w0nche0l marked this conversation as resolved.
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,
Comment thread
w0nche0l marked this conversation as resolved.
}),
});
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', () => {
Comment thread
LukasParke marked this conversation as resolved.
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',
Expand Down