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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/fuzzy-geese-validate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@openrouter/agent': minor
---

Add Standard Schema v1 support for tool input, output, event, context, shared context, check, and custom hook schemas while preserving the existing Zod v4 fast path.
Comment thread
LukasParke marked this conversation as resolved.

```ts
import { tool } from '@openrouter/agent';
import { toStandardJsonSchema } from '@valibot/to-json-schema';
import * as v from 'valibot';

// Trait path: toStandardJsonSchema exposes StandardJSONSchemaV1, so no
// inputJsonSchema is needed.
const search = tool({
name: 'search',
inputSchema: toStandardJsonSchema(v.object({ query: v.string() })),
outputSchema: v.object({ results: v.array(v.string()) }),
execute: async ({ query }) => ({ results: await searchWeb(query) }),
});

// Escape hatch: validation-only Standard Schema inputs supply the
// provider-facing JSON Schema explicitly (always wins when present).
const lookup = tool({
name: 'lookup',
inputSchema: v.object({ id: v.pipe(v.string(), v.transform(Number)) }),
inputJsonSchema: {
type: 'object',
properties: { id: { type: 'string' } },
required: ['id'],
},
execute: async ({ id }) => db.get(id), // id is the transformed number
});
```
26 changes: 24 additions & 2 deletions packages/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,29 @@ emit per model call, with `turnType`/`turnNumber`) or read each round's

### Tool Types

The `tool()` factory creates type-safe tools with full Zod schema inference. In addition to the legacy kinds below, the unified `run` interface with `lifecycle: 'sync' | 'background' | 'deferred'` covers [async tools](#async-tools) whose results arrive after the tool round, and `tool.agent()` creates [subagent tools](#agent-tools-subagents).
The `tool()` factory creates type-safe tools from Zod v4 or any [Standard Schema v1](https://standardschema.dev) validator, including Valibot, ArkType, and Effect Schema. In addition to the legacy kinds below, the unified `run` interface with `lifecycle: 'sync' | 'background' | 'deferred'` covers [async tools](#async-tools) whose results arrive after the tool round, and `tool.agent()` creates [subagent tools](#agent-tools-subagents).

Input JSON Schema generation uses three tiers:

1. Zod v4 stays on the existing `z.toJSONSchema(..., { target: 'draft-7' })` fast path, including Zod versions older than 4.2.
2. Other validators can implement the [Standard JSON Schema v1](https://standardschema.dev/json-schema) companion trait. The agent calls `schema['~standard'].jsonSchema.input({ target: 'draft-07' })`.
3. `inputJsonSchema` is the explicit escape hatch and overrides the trait when supplied. It is also the fallback when a trait converter throws.

Zod 4.2+, ArkType 2.1.28+, Zod Mini, VineJS, and Sury implement the trait natively. Valibot schemas can opt in with `toStandardJsonSchema()`:

```typescript
import { toStandardJsonSchema } from '@valibot/to-json-schema';
import * as v from 'valibot';

const searchTool = tool({
name: 'search',
inputSchema: toStandardJsonSchema(v.object({ query: v.string() })),
outputSchema: v.object({ results: v.array(v.string()) }),
execute: async ({ query }) => ({ results: await search(query) }),
});
```

Validation-only Standard Schema inputs must provide `inputJsonSchema`. Output, event, and context schemas are validated locally and never sent to the model. The agent sanitizes generated and supplied JSON Schema before the SDK boundary, including removing `~`-prefixed metadata keys. Standard Schema validators may validate synchronously or asynchronously. Initial context validation supports asynchronous validators; synchronous context mutation methods (`ctx.setContext()` and `ctx.setSharedContext()`) require a synchronous validator.

**Regular tools** — automatically executed by the agent loop:

Expand Down Expand Up @@ -942,7 +964,7 @@ logged and skipped by default, thrown in strict mode.

### Tool Context

Provide typed context data to tools without passing it through the model:
Provide typed context data to tools without passing it through the model. `contextSchema` accepts Zod or any synchronous Standard Schema validator:

```typescript
const dbTool = tool({
Expand Down
5 changes: 4 additions & 1 deletion packages/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@
},
"dependencies": {
"@openrouter/sdk": "^0.13.7",
"@standard-schema/spec": "^1.1.0",
"zod": "^4.0.0"
},
"peerDependencies": {
Expand All @@ -190,6 +191,8 @@
}
},
"devDependencies": {
"@modelcontextprotocol/client": "^2.0.0"
"@modelcontextprotocol/client": "^2.0.0",
"@valibot/to-json-schema": "^1.7.1",
"valibot": "^1.4.2"
}
}
2 changes: 2 additions & 0 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ export {
executeNextTurnParamsFunctions,
} from './lib/next-turn-params.js';
export type { StreamReplay } from './lib/reusable-stream.js';
export type { InferSchemaInput, InferSchemaOutput, ObjectSchema, Schema } from './lib/schema.js';
export { StandardSchemaError } from './lib/schema.js';
Comment thread
LukasParke marked this conversation as resolved.
// Stop condition helpers
export {
finishReasonIs,
Expand Down
8 changes: 4 additions & 4 deletions packages/agent/src/inner-loop/call-model.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { OpenRouterCore } from '@openrouter/sdk/core';
import type { RequestOptions } from '@openrouter/sdk/lib/sdks';
import type { $ZodObject, $ZodShape, infer as zodInfer } from 'zod/v4/core';
import type { CallModelInput } from '../lib/async-params.js';
import { stripToolSetSnapshotMetadata } from '../lib/async-params.js';
import { resolveHooks } from '../lib/hooks-resolve.js';
import type { GetResponseOptions } from '../lib/model-result.js';
import { ModelResult } from '../lib/model-result.js';
import type { InferSchemaOutput, ObjectSchema } from '../lib/schema.js';
import { buildTaskToolApiDefinition, needsTaskTool } from '../lib/tool-check.js';
import { convertToolsToAPIFormat, convertZodToJsonSchema } from '../lib/tool-executor.js';
import type { Tool } from '../lib/tool-types.js';
Expand Down Expand Up @@ -84,9 +84,9 @@ export type { CallModelInput } from '../lib/async-params.js';
*/
export function callModel<
TTools extends readonly Tool[],
TSharedSchema extends $ZodObject<$ZodShape> | undefined = undefined,
TShared extends Record<string, unknown> = TSharedSchema extends $ZodObject<$ZodShape>
? zodInfer<TSharedSchema>
TSharedSchema extends ObjectSchema | undefined = undefined,
TShared extends Record<string, unknown> = TSharedSchema extends ObjectSchema
? InferSchemaOutput<TSharedSchema>
: Record<string, never>,
>(
client: OpenRouterCore,
Expand Down
8 changes: 4 additions & 4 deletions packages/agent/src/inner-loop/resume-tool-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ export async function resumeToolResults<TTools extends readonly Tool[]>(
);
}

const envelope = buildResumeEnvelope(entry, task, request.tools);
const envelope = await buildResumeEnvelope(entry, task, request.tools);

envelopes.push(buildTaskResultMessage(envelope));
// Persist the entry's real terminal status. 'expired' / 'timed_out'
Expand Down Expand Up @@ -333,11 +333,11 @@ export async function resumeToolResults<TTools extends readonly Tool[]>(
* tool is available; error entries carry the caller's refined status
* (default `'failed'`).
*/
function buildResumeEnvelope(
async function buildResumeEnvelope(
entry: ResumeToolResultEntry,
task: PendingAsyncTool,
tools: readonly Tool[] | undefined,
): ToolTaskResultEnvelope {
): Promise<ToolTaskResultEnvelope> {
if ('output' in entry && entry.error === undefined) {
const tool = tools?.find((t) => isClientTool(t) && t.function.name === task.name);
// Fail closed: when a tools list was supplied but the owning tool is
Expand All @@ -350,7 +350,7 @@ function buildResumeEnvelope(
}
let output = entry.output;
if (tool && isUnifiedTool(tool) && tool.function.outputSchema !== undefined) {
output = validateToolOutput(tool.function.outputSchema, output);
output = await validateToolOutput(tool.function.outputSchema, output);
}
return {
type: 'tool_task_result',
Expand Down
40 changes: 21 additions & 19 deletions packages/agent/src/lib/agent-tool.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { OpenRouterCore } from '@openrouter/sdk/core';
import type { $ZodObject, $ZodShape, $ZodType, infer as zodInfer } from 'zod/v4/core';
import type { CallModelInput } from './async-params.js';
import { extractTextFromResponse } from './conversation-state.js';
import type { ModelResult } from './model-result.js';
import type { InferSchemaOutput, InputSchemaConfig, ObjectSchema, Schema } from './schema.js';
import { TASK_TOOL_NAME } from './tool-check.js';
import type { TaskTranscriptSource } from './tool-task.js';
import { truncateTranscriptTail } from './tool-task.js';
Expand Down Expand Up @@ -129,15 +129,14 @@ export class AgentTranscriptSource implements TaskTranscriptSource {

/** Configuration for `tool.agent()`. */
export type AgentToolConfig<
TInput extends $ZodObject<$ZodShape>,
TOutput extends $ZodType,
TInput extends ObjectSchema,
TOutput extends Schema,
TChildTools extends readonly Tool[] = readonly Tool[],
TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>,
TCtx extends ObjectSchema = ObjectSchema,
TName extends string = string,
> = {
> = InputSchemaConfig<TInput> & {
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
Expand All @@ -152,29 +151,31 @@ export type AgentToolConfig<
outputSchema: TOutput;
/** Build the child run spec from this call's arguments. */
agent: (
params: zodInfer<TInput>,
params: InferSchemaOutput<TInput>,
context?: ToolExecuteContext<TName, ContextFromSchema<TCtx>>,
) => AgentRunSpec<TChildTools> | Promise<AgentRunSpec<TChildTools>>;
/**
* Map the finished child run to this tool's output. Default:
* `{ text: await child.getText() }` — so the natural outputSchema is
* `z.object({ text: z.string() })`.
*/
result?: (child: ModelResult<TChildTools>) => Promise<zodInfer<TOutput>> | zodInfer<TOutput>;
result?: (
child: ModelResult<TChildTools>,
) => Promise<InferSchemaOutput<TOutput>> | InferSchemaOutput<TOutput>;
/** Hold the round this long before placeholdering. Default 250ms. */
graceMs?: number;
/** Deadline for the whole child run, in ms. */
timeoutMs?: number;
/** Max simultaneous child runs of this tool. */
maxConcurrency?: number;
/** Model-facing acknowledgement merged into the pending placeholder. */
ack?: AsyncToolAck<zodInfer<TInput>>;
ack?: AsyncToolAck<InferSchemaOutput<TInput>>;
/** Check-in config (the SDK default reports turns + activity). */
check?: ToolCheckConfig;
contextSchema?: TCtx;
nextTurnParams?: NextTurnParamsFunctions<zodInfer<TInput>>;
requireApproval?: boolean | ToolApprovalCheck<zodInfer<TInput>>;
loopKey?: ToolLoopKey<zodInfer<TInput>>;
nextTurnParams?: NextTurnParamsFunctions<InferSchemaOutput<TInput>>;
requireApproval?: boolean | ToolApprovalCheck<InferSchemaOutput<TInput>>;
loopKey?: ToolLoopKey<InferSchemaOutput<TInput>>;
};

/** Paused child statuses that an in-memory agent child cannot recover from. */
Expand All @@ -196,14 +197,14 @@ const CHILD_PAUSE_STATUSES = new Set([
* turn boundary. `cancelTask` / parent abort cancel the child run.
*/
export function agentToolBuilder<
TInput extends $ZodObject<$ZodShape>,
TOutput extends $ZodType,
TInput extends ObjectSchema,
TOutput extends Schema,
TChildTools extends readonly Tool[] = readonly Tool[],
TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>,
TCtx extends ObjectSchema = ObjectSchema,
TName extends string = string,
>(
config: AgentToolConfig<TInput, TOutput, TChildTools, TCtx, TName>,
): UnifiedTool<TInput, TOutput, $ZodType<unknown>, Record<string, unknown>, TCtx, TName> {
): UnifiedTool<TInput, TOutput, Schema, Record<string, unknown>, TCtx, TName> {
// Same reserved-name guards as tool() — a subagent named 'shared' would
// collide with the shared-context store key, one named 'task' would
// disable the built-in task-interaction tool.
Expand Down Expand Up @@ -233,7 +234,7 @@ export function agentToolBuilder<
// Turn activity surfaces through ctx.log (task log + preliminary events);
// the transcript reads the child's live in-memory conversation state.
async function run(
params: zodInfer<TInput>,
params: InferSchemaOutput<TInput>,
ctx?: ToolExecuteContext<TName, ContextFromSchema<TCtx>> & {
client?: OpenRouterCore;
log?: (entry: unknown) => void;
Expand All @@ -242,7 +243,7 @@ export function agentToolBuilder<
transcriptSource?: TaskTranscriptSource;
};
},
): Promise<zodInfer<TOutput>> {
): Promise<InferSchemaOutput<TOutput>> {
const client = ctx?.client;
if (!client) {
throw new Error(
Expand Down Expand Up @@ -334,6 +335,7 @@ export function agentToolBuilder<
};
const optionalFields = [
'description',
'inputJsonSchema',
'strict',
'contextSchema',
'nextTurnParams',
Expand All @@ -356,7 +358,7 @@ export function agentToolBuilder<
function: fn as unknown as UnifiedTool<
TInput,
TOutput,
$ZodType<unknown>,
Schema,
Record<string, unknown>,
TCtx,
TName
Expand Down
16 changes: 7 additions & 9 deletions packages/agent/src/lib/conversation-state.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import type * as models from '@openrouter/sdk/models';
// Same zod entry point the executor validates through (see
// `validateToolInput` in tool-executor.ts) so the approval predicate and
// `execute` agree on parse semantics. Imported directly rather than reusing
// that helper because tool-executor.ts imports this module — sharing it would
// create an import cycle.
import * as z4 from 'zod/v4';
import type {
ConversationState,
ParsedToolCall,
Tool,
TurnContext,
UnsentToolResult,
} from './tool-types.js';
import { isClientTool } from './tool-types.js';
// Same validation semantics the executor uses (schema.ts's Standard Schema +
// zod dual path, see `validateSchemaSync`) so the approval predicate and
// `execute` agree on parse behavior. Re-exported through tool-types, whose
// schema dependency is already part of this module's graph.
import { isClientTool, safeParseSchemaSync } from './tool-types.js';

import { normalizeInputToArray } from './turn-context.js';

Expand Down Expand Up @@ -294,7 +292,7 @@ export async function toolRequiresApproval<TTools extends readonly Tool[]>(
return callLevelCheck(toolCall, context);
}

const parsed = z4.safeParse(tool.function.inputSchema, toolCall.arguments);
const parsed = safeParseSchemaSync(tool.function.inputSchema, toolCall.arguments);
if (!parsed.success) {
return callLevelCheck(toolCall, context);
}
Expand Down Expand Up @@ -330,7 +328,7 @@ export async function toolRequiresApproval<TTools extends readonly Tool[]>(
// Parse with the same schema the executor uses so the predicate decides on
// exactly the values `execute` will receive.
if (typeof requireApproval === 'function') {
const parsed = z4.safeParse(tool.function.inputSchema, toolCall.arguments);
const parsed = safeParseSchemaSync(tool.function.inputSchema, toolCall.arguments);
if (!parsed.success) {
// There is no trustworthy value to pass to the predicate. Fail closed:
// a PreToolUse hook may later replace invalid input with executable
Expand Down
Loading
Loading