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 @@ -153,7 +153,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 @@ -913,7 +935,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: 5 additions & 0 deletions packages/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@
},
"dependencies": {
"@openrouter/sdk": "^0.13.7",
"@standard-schema/spec": "^1.1.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@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 @@ -201,6 +201,8 @@ export {
buildNextTurnParamsContext,
executeNextTurnParamsFunctions,
} from './lib/next-turn-params.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,10 +1,10 @@
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 { 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 @@ -82,9 +82,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> {
): UnifiedTool<TInput, TOutput, Schema, Record<string, unknown>, TCtx> {
// 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
>['function'],
Expand Down
73 changes: 52 additions & 21 deletions packages/agent/src/lib/hooks-emit.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { $ZodType } from 'zod/v4/core';
import { safeParse } from 'zod/v4/core';
import { matchesTool } from './hooks-matchers.js';
import type {
Expand All @@ -9,6 +8,8 @@ import type {
LifecycleHookContext,
} from './hooks-types.js';
import { DEFAULT_ASYNC_TIMEOUT, HOOK_BEHAVIOR, isAsyncOutput } from './hooks-types.js';
import type { Schema } from './schema.js';
import { isZodSchema, safeValidateSchema } from './schema.js';

export interface ExecuteChainOptions {
readonly hookName: string;
Expand All @@ -23,7 +24,7 @@ export interface ExecuteChainOptions {
* Void-typed hooks typically pass `undefined` here; results in that case are
* not validated.
*/
readonly resultSchema?: $ZodType | undefined;
readonly resultSchema?: Schema | undefined;
/**
* Invoked when a handler's fire-and-forget `work` exceeds its
* `asyncTimeout`. The manager uses this to abort the emit's signal so
Expand Down Expand Up @@ -127,7 +128,8 @@ export async function executeHandlerChain<P, R>(

try {
const returnValue = await entry.handler(currentPayload, context);
const outcome = classifyHandlerReturn<R>(returnValue, i, options);
const classified = classifyHandlerReturn<R>(returnValue, i, options);
const outcome = classified instanceof Promise ? await classified : classified;

if (outcome.kind === 'async') {
// Fire-and-forget: track the (optional) work promise for drain/timeout.
Expand Down Expand Up @@ -224,6 +226,48 @@ type HandlerReturnOutcome<R> =
result: R;
};

type HandlerValidation =
| {
success: true;
data: unknown;
}
| {
success: false;
error: Error;
};

function validateHandlerReturn(
schema: Schema,
returnValue: unknown,
): HandlerValidation | Promise<HandlerValidation> {
return isZodSchema(schema)
? safeParse(schema, returnValue)
: safeValidateSchema(schema, returnValue);
}

function validationOutcome<R>(
validation: HandlerValidation,
index: number,
options: ExecuteChainOptions,
): HandlerReturnOutcome<R> {
if (!validation.success) {
const err = new Error(
`[HooksManager] Handler ${index} for hook "${options.hookName}" returned an invalid result: ${validation.error.message}`,
);
if (options.throwOnHandlerError) {
throw err;
}
console.warn(err.message);
return {
kind: 'skip',
};
}
return {
kind: 'result',
result: validation.data as R,
};
}

/**
* Classify a handler's return value into one of three outcomes:
*
Expand All @@ -240,7 +284,7 @@ function classifyHandlerReturn<R>(
returnValue: unknown,
index: number,
options: ExecuteChainOptions,
): HandlerReturnOutcome<R> {
): HandlerReturnOutcome<R> | Promise<HandlerReturnOutcome<R>> {
if (isAsyncOutput(returnValue)) {
return {
kind: 'async',
Expand All @@ -258,23 +302,10 @@ function classifyHandlerReturn<R>(
result: returnValue as R,
};
}
const validation = safeParse(options.resultSchema, returnValue);
if (!validation.success) {
const err = new Error(
`[HooksManager] Handler ${index} for hook "${options.hookName}" returned an invalid result: ${validation.error.message}`,
);
if (options.throwOnHandlerError) {
throw err;
}
console.warn(err.message);
return {
kind: 'skip',
};
}
return {
kind: 'result',
result: validation.data as R,
};
const validation = validateHandlerReturn(options.resultSchema, returnValue);
return validation instanceof Promise
? validation.then((result) => validationOutcome<R>(result, index, options))
: validationOutcome<R>(validation, index, options);
}

/**
Expand Down
Loading
Loading