From 1ebdc09c5b15f52645098c75dc0112b89134ff29 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:01:54 -0500 Subject: [PATCH 01/13] feat(agent): support Standard Schema validators --- .changeset/fuzzy-geese-validate.md | 5 + packages/agent/README.md | 25 +- packages/agent/package.json | 4 + packages/agent/src/index.ts | 2 + packages/agent/src/inner-loop/call-model.ts | 8 +- .../src/inner-loop/resume-tool-results.ts | 8 +- packages/agent/src/lib/agent-tool.ts | 38 ++- packages/agent/src/lib/hooks-emit.ts | 56 +++- packages/agent/src/lib/hooks-manager.ts | 23 +- packages/agent/src/lib/hooks-schemas.ts | 6 +- packages/agent/src/lib/model-result.ts | 11 +- packages/agent/src/lib/schema.ts | 129 ++++++++ packages/agent/src/lib/tool-check.ts | 8 +- packages/agent/src/lib/tool-context.ts | 38 ++- packages/agent/src/lib/tool-executor.ts | 145 +++++---- packages/agent/src/lib/tool-types.ts | 185 ++++++----- packages/agent/src/lib/tool.ts | 261 ++++++++------- packages/agent/src/openrouter.ts | 9 +- .../unit/standard-schema-inference.test-d.ts | 84 +++++ .../agent/tests/unit/standard-schema.test.ts | 303 ++++++++++++++++++ packages/agent/tsconfig.typecheck.json | 6 +- pnpm-lock.yaml | 19 ++ 22 files changed, 1018 insertions(+), 355 deletions(-) create mode 100644 .changeset/fuzzy-geese-validate.md create mode 100644 packages/agent/src/lib/schema.ts create mode 100644 packages/agent/tests/unit/standard-schema-inference.test-d.ts create mode 100644 packages/agent/tests/unit/standard-schema.test.ts diff --git a/.changeset/fuzzy-geese-validate.md b/.changeset/fuzzy-geese-validate.md new file mode 100644 index 00000000..f75a2f64 --- /dev/null +++ b/.changeset/fuzzy-geese-validate.md @@ -0,0 +1,5 @@ +--- +'@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. diff --git a/packages/agent/README.md b/packages/agent/README.md index 32f7abfe..f02aee71 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -153,7 +153,28 @@ 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). + +Zod remains the zero-config path: the agent uses Zod's validator and JSON Schema converter directly. Standard Schema defines validation and type inference, but not JSON Schema conversion, so non-Zod input validators must also provide the raw JSON Schema sent to the model: + +```typescript +import * as v from 'valibot'; + +const searchTool = tool({ + name: 'search', + inputSchema: v.object({ query: v.string() }), + inputJsonSchema: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + additionalProperties: false, + }, + outputSchema: v.object({ results: v.array(v.string()) }), + execute: async ({ query }) => ({ results: await search(query) }), +}); +``` + +`inputJsonSchema` is only needed for `inputSchema`: output, event, and context schemas are validated locally and never sent to the model. The agent sanitizes both generated and supplied JSON Schema before the SDK boundary, including removing `~`-prefixed metadata keys. Standard Schema validators may validate synchronously or asynchronously; synchronous context mutation methods (`ctx.setContext()` and `ctx.setSharedContext()`) require a synchronous validator. **Regular tools** — automatically executed by the agent loop: @@ -913,7 +934,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({ diff --git a/packages/agent/package.json b/packages/agent/package.json index add01ee5..72a6379a 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -149,6 +149,10 @@ }, "dependencies": { "@openrouter/sdk": "^0.13.7", + "@standard-schema/spec": "^1.1.0", "zod": "^4.0.0" + }, + "devDependencies": { + "valibot": "^1.4.2" } } diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 3b612660..1c523a85 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -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'; // Stop condition helpers export { finishReasonIs, diff --git a/packages/agent/src/inner-loop/call-model.ts b/packages/agent/src/inner-loop/call-model.ts index 6c1ff25a..1efc99a6 100644 --- a/packages/agent/src/inner-loop/call-model.ts +++ b/packages/agent/src/inner-loop/call-model.ts @@ -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'; @@ -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 = TSharedSchema extends $ZodObject<$ZodShape> - ? zodInfer + TSharedSchema extends ObjectSchema | undefined = undefined, + TShared extends Record = TSharedSchema extends ObjectSchema + ? InferSchemaOutput : Record, >( client: OpenRouterCore, diff --git a/packages/agent/src/inner-loop/resume-tool-results.ts b/packages/agent/src/inner-loop/resume-tool-results.ts index a3c0145f..5aae431f 100644 --- a/packages/agent/src/inner-loop/resume-tool-results.ts +++ b/packages/agent/src/inner-loop/resume-tool-results.ts @@ -246,7 +246,7 @@ export async function resumeToolResults( ); } - 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' @@ -333,11 +333,11 @@ export async function resumeToolResults( * 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 { 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 @@ -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', diff --git a/packages/agent/src/lib/agent-tool.ts b/packages/agent/src/lib/agent-tool.ts index bf3a551a..4e583cf2 100644 --- a/packages/agent/src/lib/agent-tool.ts +++ b/packages/agent/src/lib/agent-tool.ts @@ -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, 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'; @@ -129,15 +129,16 @@ 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, > = { name: TName; description?: string; inputSchema: TInput; + inputJsonSchema?: Record; /** * Whether providers should enforce strict schema adherence for this agent * tool's generated arguments. OpenAI-style strict mode requires every @@ -152,7 +153,7 @@ export type AgentToolConfig< outputSchema: TOutput; /** Build the child run spec from this call's arguments. */ agent: ( - params: zodInfer, + params: InferSchemaOutput, context?: ToolExecuteContext>, ) => AgentRunSpec | Promise>; /** @@ -160,7 +161,9 @@ export type AgentToolConfig< * `{ text: await child.getText() }` — so the natural outputSchema is * `z.object({ text: z.string() })`. */ - result?: (child: ModelResult) => Promise> | zodInfer; + result?: ( + child: ModelResult, + ) => Promise> | InferSchemaOutput; /** Hold the round this long before placeholdering. Default 250ms. */ graceMs?: number; /** Deadline for the whole child run, in ms. */ @@ -168,13 +171,13 @@ export type AgentToolConfig< /** Max simultaneous child runs of this tool. */ maxConcurrency?: number; /** Model-facing acknowledgement merged into the pending placeholder. */ - ack?: AsyncToolAck>; + ack?: AsyncToolAck>; /** Check-in config (the SDK default reports turns + activity). */ check?: ToolCheckConfig; contextSchema?: TCtx; - nextTurnParams?: NextTurnParamsFunctions>; - requireApproval?: boolean | ToolApprovalCheck>; - loopKey?: ToolLoopKey>; + nextTurnParams?: NextTurnParamsFunctions>; + requireApproval?: boolean | ToolApprovalCheck>; + loopKey?: ToolLoopKey>; }; /** Paused child statuses that an in-memory agent child cannot recover from. */ @@ -196,14 +199,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, -): UnifiedTool, Record, TCtx> { +): UnifiedTool, 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. @@ -233,7 +236,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, + params: InferSchemaOutput, ctx?: ToolExecuteContext> & { client?: OpenRouterCore; log?: (entry: unknown) => void; @@ -242,7 +245,7 @@ export function agentToolBuilder< transcriptSource?: TaskTranscriptSource; }; }, - ): Promise> { + ): Promise> { const client = ctx?.client; if (!client) { throw new Error( @@ -334,6 +337,7 @@ export function agentToolBuilder< }; const optionalFields = [ 'description', + 'inputJsonSchema', 'strict', 'contextSchema', 'nextTurnParams', @@ -356,7 +360,7 @@ export function agentToolBuilder< function: fn as unknown as UnifiedTool< TInput, TOutput, - $ZodType, + Schema, Record, TCtx >['function'], diff --git a/packages/agent/src/lib/hooks-emit.ts b/packages/agent/src/lib/hooks-emit.ts index a3c6eaef..2b5edfcf 100644 --- a/packages/agent/src/lib/hooks-emit.ts +++ b/packages/agent/src/lib/hooks-emit.ts @@ -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 { @@ -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; @@ -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 @@ -127,7 +128,9 @@ export async function executeHandlerChain( try { const returnValue = await entry.handler(currentPayload, context); - const outcome = classifyHandlerReturn(returnValue, i, options); + const outcome = isZodSchema(options.resultSchema) + ? classifyZodHandlerReturn(returnValue, i, options) + : await classifyHandlerReturn(returnValue, i, options); if (outcome.kind === 'async') { // Fire-and-forget: track the (optional) work promise for drain/timeout. @@ -224,6 +227,47 @@ type HandlerReturnOutcome = result: R; }; +function classifyZodHandlerReturn( + returnValue: unknown, + index: number, + options: ExecuteChainOptions, +): HandlerReturnOutcome { + if (isAsyncOutput(returnValue)) { + return { + kind: 'async', + trackedWork: trackAsyncWork(returnValue, options.hookName, options.onAsyncTimeout), + }; + } + if (returnValue === undefined || returnValue === null) { + return { + kind: 'skip', + }; + } + if (!options.resultSchema || !isZodSchema(options.resultSchema)) { + return { + kind: 'result', + 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, + }; +} + /** * Classify a handler's return value into one of three outcomes: * @@ -236,11 +280,11 @@ type HandlerReturnOutcome = * with .transform() / .default() / .catch() / .coerce -- so downstream * callers see transformed values. Validation failure in strict mode throws. */ -function classifyHandlerReturn( +async function classifyHandlerReturn( returnValue: unknown, index: number, options: ExecuteChainOptions, -): HandlerReturnOutcome { +): Promise> { if (isAsyncOutput(returnValue)) { return { kind: 'async', @@ -258,7 +302,7 @@ function classifyHandlerReturn( result: returnValue as R, }; } - const validation = safeParse(options.resultSchema, returnValue); + const validation = await safeValidateSchema(options.resultSchema, returnValue); if (!validation.success) { const err = new Error( `[HooksManager] Handler ${index} for hook "${options.hookName}" returned an invalid result: ${validation.error.message}`, diff --git a/packages/agent/src/lib/hooks-manager.ts b/packages/agent/src/lib/hooks-manager.ts index 2405de0d..abb0a2bb 100644 --- a/packages/agent/src/lib/hooks-manager.ts +++ b/packages/agent/src/lib/hooks-manager.ts @@ -1,4 +1,3 @@ -import type { $ZodType, infer as zodInfer, input as zodInput } from 'zod/v4/core'; import { safeParse } from 'zod/v4/core'; import { executeHandlerChain } from './hooks-emit.js'; import { BUILT_IN_HOOK_NAMES, BUILT_IN_HOOKS } from './hooks-schemas.js'; @@ -12,6 +11,8 @@ import type { HooksManagerOptions, LifecycleHookContext, } from './hooks-types.js'; +import type { InferSchemaInput, InferSchemaOutput, Schema } from './schema.js'; +import { isZodSchema, safeValidateSchema } from './schema.js'; //#region Types @@ -34,9 +35,9 @@ type AllHooks = { }; } & { [K in keyof Custom]: { - payload: zodInfer; - payloadIn: zodInput; - result: zodInfer; + payload: InferSchemaOutput; + payloadIn: InferSchemaInput; + result: InferSchemaOutput; }; }; @@ -185,7 +186,9 @@ export class HooksManager> { let chainPayload = payload as unknown as AllHooks[K]['payload']; const definition = this._definitionFor(hookName); if (definition) { - const parsed = safeParse(definition.payload, payload); + const parsed = isZodSchema(definition.payload) + ? safeParse(definition.payload, payload) + : await safeValidateSchema(definition.payload, payload); if (!parsed.success) { const err = new Error( `[HooksManager] Invalid payload for hook "${hookName}": ${parsed.error.message}`, @@ -332,8 +335,8 @@ export class HooksManager> { private _definitionFor(hookName: string): | { - payload: $ZodType; - result: $ZodType; + payload: Schema; + result: Schema; } | undefined { const builtIn = (BUILT_IN_HOOKS as Record)[hookName]; @@ -365,8 +368,8 @@ export function getInternalRegistrar(manager: HooksManager): InternalRegistrar { * without tripping validation -- for built-ins and custom hooks alike. * * Implementation note: `schema._zod.def.type` is zod v4's designated - * introspection surface for library authors (every `$ZodType` carries a - * `_zod: $ZodTypeInternals` with a stable `def.type` discriminator). A string + * introspection surface for library authors (every `Schema` carries a + * `_zod: SchemaInternals` with a stable `def.type` discriminator). A string * check is deliberately preferred over `instanceof $ZodVoid`, which breaks * across duplicated zod module instances (dual-package hazard) and mixed * zod/v4 vs zod/v4-mini usage. Behavior is pinned by tests in @@ -377,7 +380,7 @@ export function getInternalRegistrar(manager: HooksManager): InternalRegistrar { * Exported for the pinning tests only; NOT re-exported from the package * index and NOT part of the public API. */ -export function isVoidSchema(schema: $ZodType): boolean { +export function isVoidSchema(schema: Schema): boolean { const def = ( schema as { _zod?: { diff --git a/packages/agent/src/lib/hooks-schemas.ts b/packages/agent/src/lib/hooks-schemas.ts index aa0b2023..adf3651d 100644 --- a/packages/agent/src/lib/hooks-schemas.ts +++ b/packages/agent/src/lib/hooks-schemas.ts @@ -1,5 +1,5 @@ import * as z4 from 'zod/v4'; -import type { $ZodType } from 'zod/v4/core'; +import type { Schema } from './schema.js'; //#region Hook Names & Definition Shape // @@ -28,8 +28,8 @@ export type HookName = (typeof HookName)[keyof typeof HookName]; * A hook definition is a pair of Zod schemas: one for the payload and one for the result. */ export interface HookDefinition { - readonly payload: $ZodType; - readonly result: $ZodType; + readonly payload: Schema; + readonly result: Schema; } /** diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index e8d07757..3db174f1 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -3,7 +3,6 @@ import { betaResponsesSend } from '@openrouter/sdk/funcs/betaResponsesSend'; import type { EventStream } from '@openrouter/sdk/lib/event-streams'; import type { RequestOptions } from '@openrouter/sdk/lib/sdks'; import type * as models from '@openrouter/sdk/models'; -import type { $ZodObject, $ZodShape } from 'zod/v4/core'; import type { CallModelInput, ResolvedCallModelInput } from './async-params.js'; import { hasAsyncFunctions, resolveAsyncFunctions } from './async-params.js'; import type { SettledToolTask, TaskToolInput, ToolSemaphore, ToolTaskMode } from './async-tools.js'; @@ -54,6 +53,7 @@ import { executeNextTurnParamsFunctions, } from './next-turn-params.js'; import { ReusableReadableStream } from './reusable-stream.js'; +import type { ObjectSchema } from './schema.js'; import { isStopConditionMet } from './stop-conditions.js'; import type { ItemInProgress, StreamableOutputItem } from './stream-transformers.js'; import { @@ -439,7 +439,7 @@ export interface GetResponseOptions< /** Typed context data passed to tools via contextSchema. `shared` key for shared context. */ context?: ContextInput>; /** Zod schema for shared context validation */ - sharedContextSchema?: $ZodObject<$ZodShape>; + sharedContextSchema?: ObjectSchema; /** * Call-level approval check - overrides tool-level requireApproval setting @@ -4140,7 +4140,10 @@ export class ModelResult< let input: TaskToolInput; try { - input = validateToolInput(TaskToolInputSchema, toolCall.arguments ?? {}) as TaskToolInput; + input = (await validateToolInput( + TaskToolInputSchema, + toolCall.arguments ?? {}, + )) as TaskToolInput; } catch (error) { return answer(null, error instanceof Error ? error : new Error(String(error))); } @@ -4251,7 +4254,7 @@ export class ModelResult< // its check.schema when both are present. let customParams: Record = input.params ?? {}; if (schema && input.params !== undefined) { - customParams = validateToolInput(schema, input.params) as Record; + customParams = (await validateToolInput(schema, input.params)) as Record; } if (liveTask) { diff --git a/packages/agent/src/lib/schema.ts b/packages/agent/src/lib/schema.ts new file mode 100644 index 00000000..463e0087 --- /dev/null +++ b/packages/agent/src/lib/schema.ts @@ -0,0 +1,129 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import * as z4 from 'zod/v4'; +import type { $ZodObject, $ZodShape, $ZodType, infer as zodInfer } from 'zod/v4/core'; + +export type Schema = + | $ZodType + | StandardSchemaV1; + +export type ObjectSchema = + | $ZodObject<$ZodShape> + | StandardSchemaV1>; + +export type InferSchemaInput = TSchema extends $ZodType + ? TSchema['_zod']['input'] + : TSchema extends StandardSchemaV1 + ? StandardSchemaV1.InferInput + : unknown; + +export type InferSchemaOutput = TSchema extends $ZodType + ? zodInfer + : TSchema extends StandardSchemaV1 + ? StandardSchemaV1.InferOutput + : unknown; + +export interface SchemaIssue { + readonly message: string; + readonly path: PropertyKey[]; +} + +export class StandardSchemaError extends Error { + readonly issues: SchemaIssue[]; + + constructor(issues: readonly StandardSchemaV1.Issue[]) { + const normalized = issues.map((issue) => ({ + message: issue.message, + path: (issue.path ?? []).map((segment) => + typeof segment === 'object' && segment !== null && 'key' in segment ? segment.key : segment, + ), + })); + super(JSON.stringify(normalized)); + this.name = 'StandardSchemaError'; + this.issues = normalized; + } +} + +export function isZodSchema(schema: unknown): schema is $ZodType { + return ( + typeof schema === 'object' && + schema !== null && + '_zod' in schema && + typeof schema._zod === 'object' + ); +} + +function isStandardSchema(schema: unknown): schema is StandardSchemaV1 { + if (typeof schema !== 'object' || schema === null || !('~standard' in schema)) { + return false; + } + const standard = schema['~standard'] as { + version?: unknown; + validate?: unknown; + }; + return standard.version === 1 && typeof standard.validate === 'function'; +} + +export async function validateSchema( + schema: TSchema, + value: unknown, +): Promise> { + if (isZodSchema(schema)) { + return z4.parse(schema, value) as InferSchemaOutput; + } + if (!isStandardSchema(schema)) { + throw new Error('Invalid Standard Schema v1 validator provided'); + } + const result = await schema['~standard'].validate(value); + if (result.issues) { + throw new StandardSchemaError(result.issues); + } + return result.value as InferSchemaOutput; +} + +export function validateSchemaSync( + schema: TSchema, + value: unknown, +): InferSchemaOutput { + if (isZodSchema(schema)) { + return z4.parse(schema, value) as InferSchemaOutput; + } + if (!isStandardSchema(schema)) { + throw new Error('Invalid Standard Schema v1 validator provided'); + } + const result = schema['~standard'].validate(value); + if (result instanceof Promise) { + throw new Error( + 'Async Standard Schema validators are not supported for synchronous context updates', + ); + } + if (result.issues) { + throw new StandardSchemaError(result.issues); + } + return result.value as InferSchemaOutput; +} + +export async function safeValidateSchema( + schema: TSchema, + value: unknown, +): Promise< + | { + success: true; + data: InferSchemaOutput; + } + | { + success: false; + error: Error; + } +> { + try { + return { + success: true, + data: await validateSchema(schema, value), + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error : new Error(String(error)), + }; + } +} diff --git a/packages/agent/src/lib/tool-check.ts b/packages/agent/src/lib/tool-check.ts index 0a18963e..e8600971 100644 --- a/packages/agent/src/lib/tool-check.ts +++ b/packages/agent/src/lib/tool-check.ts @@ -1,5 +1,5 @@ import * as z4 from 'zod/v4'; -import type { $ZodObject, $ZodShape, $ZodType } from 'zod/v4/core'; +import type { ObjectSchema, Schema } from './schema.js'; import type { APITool, PendingAsyncTool, @@ -71,7 +71,7 @@ let taskToolParameters: Record | undefined; * conversion runs once per process (memoized on first call). */ export function buildTaskToolApiDefinition( - convertZod: (schema: $ZodType) => Record, + convertZod: (schema: Schema) => Record, ): APITool { taskToolParameters ??= convertZod(TaskToolInputSchema); return { @@ -135,7 +135,7 @@ export function buildTaskToolStub(): Tool { type: ToolType.Function, function: { name: TASK_TOOL_NAME, - inputSchema: TaskToolInputSchema as unknown as $ZodObject<$ZodShape>, + inputSchema: TaskToolInputSchema as unknown as ObjectSchema, execute: false as never, } as never, }; @@ -143,7 +143,7 @@ export function buildTaskToolStub(): Tool { /** Resolve a tool's check config to `{ schema, execute }` with defaults. */ export function resolveCheckConfig(check: ToolCheckConfig | undefined): { - schema: $ZodObject<$ZodShape> | undefined; + schema: ObjectSchema | undefined; execute: | ((params: Record, turnContext: TurnContext) => unknown | Promise) | undefined; diff --git a/packages/agent/src/lib/tool-context.ts b/packages/agent/src/lib/tool-context.ts index de776a42..b90358a5 100644 --- a/packages/agent/src/lib/tool-context.ts +++ b/packages/agent/src/lib/tool-context.ts @@ -1,5 +1,6 @@ import * as z4 from 'zod/v4'; -import type { $ZodObject, $ZodShape } from 'zod/v4/core'; +import type { ObjectSchema } from './schema.js'; +import { validateSchemaSync } from './schema.js'; import type { ToolExecuteContext, TurnContext } from './tool-types.js'; import { SHARED_CONTEXT_KEY } from './tool-types.js'; @@ -112,24 +113,27 @@ export class ToolContextStore { */ function validatePartialAgainstSchema( partial: Record, - schema: $ZodObject<$ZodShape>, + current: Record, + schema: ObjectSchema, ): Record { - const schemaKeys = Object.keys(schema._zod.def.shape); - const filteredPartial: Record = {}; - for (const [key, value] of Object.entries(partial)) { - if (schemaKeys.includes(key)) { - filteredPartial[key] = value; - } + if (!('_zod' in schema)) { + validateSchemaSync(schema, { + ...current, + ...partial, + }); + return partial; } const shape = schema._zod.def.shape; + const filteredPartial = Object.fromEntries( + Object.entries(partial).filter(([key]) => key in shape), + ); for (const [key, value] of Object.entries(filteredPartial)) { const keySchema = shape[key]; if (keySchema) { z4.parse(keySchema, value); } } - return filteredPartial; } @@ -206,8 +210,8 @@ export function buildToolExecuteContext< turnContext: TurnContext, store: ToolContextStore | undefined, toolName: TName, - schema: $ZodObject<$ZodShape> | undefined, - sharedSchema?: $ZodObject<$ZodShape> | undefined, + schema: ObjectSchema | undefined, + sharedSchema?: ObjectSchema | undefined, extras?: ToolExecutionExtras, ): ToolExecuteContext { // Validate initial context eagerly (throws on bad data) @@ -240,6 +244,7 @@ export function buildToolExecuteContext< } const filteredPartial = validatePartialAgainstSchema( partial as Record, + store.getToolContext(toolName), schema, ); store.mergeToolContext(toolName, filteredPartial); @@ -256,6 +261,7 @@ export function buildToolExecuteContext< } const filteredPartial = validatePartialAgainstSchema( partial as Record, + store.getToolContext(SHARED_CONTEXT_KEY), sharedSchema, ); store.mergeToolContext(SHARED_CONTEXT_KEY, filteredPartial); @@ -279,8 +285,8 @@ export function buildToolRunContext< turnContext: TurnContext, store: ToolContextStore | undefined, toolName: TName, - schema: $ZodObject<$ZodShape> | undefined, - sharedSchema?: $ZodObject<$ZodShape> | undefined, + schema: ObjectSchema | undefined, + sharedSchema?: ObjectSchema | undefined, extras?: ToolExecutionExtras, ): ToolExecuteContext & { defer: (taskId: string, options?: Record) => unknown; @@ -369,7 +375,7 @@ export async function resolveContext | undefined, + schema: ObjectSchema | undefined, ): Record { if (!schema) { return {}; @@ -377,8 +383,8 @@ export function extractToolContext( const toolData = store.getToolContext(toolName); - // Validate the extracted values against the schema - z4.parse(schema, toolData); + // Context mutation APIs are synchronous, so async validators cannot be used here. + validateSchemaSync(schema, toolData); // getToolContext already returns a shallow copy return toolData; diff --git a/packages/agent/src/lib/tool-executor.ts b/packages/agent/src/lib/tool-executor.ts index 5ccdc7b7..0244b515 100644 --- a/packages/agent/src/lib/tool-executor.ts +++ b/packages/agent/src/lib/tool-executor.ts @@ -1,7 +1,14 @@ import type * as models from '@openrouter/sdk/models'; import * as z4 from 'zod/v4'; -import type { $ZodObject, $ZodShape, $ZodType } from 'zod/v4/core'; import { isContentArray } from './conversation-state.js'; +import type { InferSchemaOutput, ObjectSchema, Schema } from './schema.js'; +import { + isZodSchema, + StandardSchemaError, + safeValidateSchema, + validateSchema, + validateSchemaSync, +} from './schema.js'; import { isFunctionCallItem, isFunctionCallOutputItem } from './stream-type-guards.js'; import type { ToolContextStore, ToolExecutionExtras } from './tool-context.js'; import { buildToolExecuteContext, buildToolRunContext } from './tool-context.js'; @@ -73,38 +80,36 @@ export function sanitizeJsonSchema(obj: unknown): unknown { return result; } -/** - * Typeguard to check if a value is a valid Zod schema compatible with zod/v4. - * Zod schemas have a _zod property that contains schema metadata. - */ -function isZodSchema(value: unknown): value is z4.ZodType { - if (typeof value !== 'object' || value === null) { - return false; - } - if (!('_zod' in value)) { - return false; - } - // After the 'in' check, TypeScript knows value has _zod property - return typeof value._zod === 'object'; -} - /** * Convert a Zod schema to JSON Schema using Zod v4's toJSONSchema function. * Accepts ZodType from the main zod package for user compatibility. * The resulting schema is sanitized to remove metadata properties (like ~standard) * that would cause 400 errors with downstream providers. */ -export function convertZodToJsonSchema(zodSchema: $ZodType): Record { +export function convertZodToJsonSchema(zodSchema: Schema): Record { if (!isZodSchema(zodSchema)) { throw new Error('Invalid Zod schema provided'); } - // Use draft-7 as it's closest to OpenAPI 3.0's JSON Schema variant - const jsonSchema = z4.toJSONSchema(zodSchema, { - target: 'draft-7', - }); - // jsonSchema is always a Record from toJSONSchema - // The overloaded sanitizeJsonSchema preserves this type - return sanitizeJsonSchema(jsonSchema); + return sanitizeJsonSchema( + z4.toJSONSchema(zodSchema, { + target: 'draft-7', + }), + ); +} + +export function convertSchemaToJsonSchema( + schema: Schema, + jsonSchema?: Record, +): Record { + if (isZodSchema(schema)) { + return convertZodToJsonSchema(schema); + } + if (jsonSchema) { + return sanitizeJsonSchema(jsonSchema); + } + throw new Error( + 'Non-Zod inputSchema requires inputJsonSchema because Standard Schema does not define JSON Schema conversion.', + ); } /** @@ -129,35 +134,31 @@ export function convertToolsToAPIFormat( name: tool.function.name, description: tool.function.description || null, strict: tool.function.strict ?? null, - parameters: convertZodToJsonSchema(tool.function.inputSchema), + parameters: convertSchemaToJsonSchema( + tool.function.inputSchema, + tool.function.inputJsonSchema, + ), }; return apiTool; }); } -/** - * Validate tool input against Zod schema - * @throws ZodError if validation fails - */ -export function validateToolInput(schema: $ZodType, args: unknown): T { - return z4.parse(schema, args); +export async function validateToolInput( + schema: TSchema, + args: unknown, +): Promise> { + return validateSchema(schema, args); } -/** - * Validate tool output against Zod schema - * @throws ZodError if validation fails - */ -export function validateToolOutput(schema: $ZodType, result: unknown): T { - return z4.parse(schema, result); +export async function validateToolOutput( + schema: TSchema, + result: unknown, +): Promise> { + return validateSchema(schema, result); } -/** - * Try to validate a value against a Zod schema without throwing - * @returns true if validation succeeds, false otherwise - */ -function tryValidate(schema: $ZodType, value: unknown): boolean { - const result = z4.safeParse(schema, value); - return result.success; +async function tryValidate(schema: Schema, value: unknown): Promise { + return (await safeValidateSchema(schema, value)).success; } /** @@ -199,7 +200,7 @@ function buildExecuteCtx( toolCall: ParsedToolCall | undefined, turnContext: TurnContext, contextStore?: ToolContextStore, - sharedSchema?: $ZodObject<$ZodShape>, + sharedSchema?: ObjectSchema, extras?: ToolExecutionExtras, ): ToolExecuteContext { const resolvedToolCall = turnContext.toolCall ?? (toolCall && toFunctionCallItem(toolCall)); @@ -250,7 +251,7 @@ export async function executeRegularTool( toolCall: ParsedToolCall, context: TurnContext, contextStore?: ToolContextStore, - sharedSchema?: $ZodObject<$ZodShape>, + sharedSchema?: ObjectSchema, extras?: ToolExecutionExtras, ): Promise> { if (!isRegularExecuteTool(tool)) { @@ -262,7 +263,7 @@ export async function executeRegularTool( const source = isMcpTool(tool) ? 'mcp' : 'client'; try { - const validatedInput = validateToolInput(tool.function.inputSchema, toolCall.arguments); + const validatedInput = await validateToolInput(tool.function.inputSchema, toolCall.arguments); const executeContext = buildExecuteCtx( tool, toolCall, @@ -277,7 +278,7 @@ export async function executeRegularTool( // Validate output if schema is provided if (tool.function.outputSchema) { - const validatedOutput = validateToolOutput(tool.function.outputSchema, result); + const validatedOutput = await validateToolOutput(tool.function.outputSchema, result); return { toolCallId: toolCall.id, @@ -317,7 +318,7 @@ export async function executeGeneratorTool( context: TurnContext, onPreliminaryResult?: (toolCallId: string, result: unknown) => void, contextStore?: ToolContextStore, - sharedSchema?: $ZodObject<$ZodShape>, + sharedSchema?: ObjectSchema, extras?: ToolExecutionExtras, ): Promise> { if (!isGeneratorTool(tool)) { @@ -327,7 +328,7 @@ export async function executeGeneratorTool( const source = isMcpTool(tool) ? 'mcp' : 'client'; try { - const validatedInput = validateToolInput(tool.function.inputSchema, toolCall.arguments); + const validatedInput = await validateToolInput(tool.function.inputSchema, toolCall.arguments); const executeContext = buildExecuteCtx( tool, toolCall, @@ -351,14 +352,14 @@ export async function executeGeneratorTool( lastEmittedValue = event; hasEmittedValue = true; - const matchesOutputSchema = tryValidate(tool.function.outputSchema, event); - const matchesEventSchema = tryValidate(tool.function.eventSchema, event); + const matchesOutputSchema = await tryValidate(tool.function.outputSchema, event); + const matchesEventSchema = await tryValidate(tool.function.eventSchema, event); if (matchesOutputSchema && !matchesEventSchema && !hasFinalResult) { - finalResult = validateToolOutput(tool.function.outputSchema, event); + finalResult = await validateToolOutput(tool.function.outputSchema, event); hasFinalResult = true; } else { - const validatedPreliminary = validateToolOutput(tool.function.eventSchema, event); + const validatedPreliminary = await validateToolOutput(tool.function.eventSchema, event); preliminaryResults.push(validatedPreliminary); if (onPreliminaryResult) { onPreliminaryResult(toolCall.id, validatedPreliminary); @@ -369,7 +370,7 @@ export async function executeGeneratorTool( } if (iterResult.value !== undefined) { - finalResult = validateToolOutput(tool.function.outputSchema, iterResult.value); + finalResult = await validateToolOutput(tool.function.outputSchema, iterResult.value); hasFinalResult = true; } @@ -379,7 +380,7 @@ export async function executeGeneratorTool( `Generator tool "${toolCall.name}" completed without emitting any values or returning a result`, ); } - finalResult = validateToolOutput(tool.function.outputSchema, lastEmittedValue); + finalResult = await validateToolOutput(tool.function.outputSchema, lastEmittedValue); } return { @@ -414,7 +415,7 @@ export async function executeHITLTool( toolCall: ParsedToolCall, context: TurnContext, contextStore?: ToolContextStore, - sharedSchema?: $ZodObject<$ZodShape>, + sharedSchema?: ObjectSchema, extras?: ToolExecutionExtras, ): Promise | null> { if (!isHITLTool(tool)) { @@ -424,7 +425,7 @@ export async function executeHITLTool( const source = isMcpTool(tool) ? 'mcp' : 'client'; try { - const validatedInput = validateToolInput(tool.function.inputSchema, toolCall.arguments); + const validatedInput = await validateToolInput(tool.function.inputSchema, toolCall.arguments); const executeContext = buildExecuteCtx( tool, toolCall, @@ -444,7 +445,7 @@ export async function executeHITLTool( } // outputSchema is required on HITL tools — validate unconditionally. - const validatedOutput = validateToolOutput(tool.function.outputSchema, result); + const validatedOutput = await validateToolOutput(tool.function.outputSchema, result); return { toolCallId: toolCall.id, toolName: toolCall.name, @@ -543,7 +544,9 @@ async function runUnifiedTool(args: { const iterator = (returned as AsyncGenerator)[Symbol.asyncIterator](); let step = await iterator.next(); while (!step.done) { - const event = fn.eventSchema ? validateToolOutput(fn.eventSchema, step.value) : step.value; + const event = fn.eventSchema + ? await validateToolOutput(fn.eventSchema, step.value) + : step.value; onYield(event); step = await iterator.next(); } @@ -561,7 +564,7 @@ async function runUnifiedTool(args: { return result; } - return fn.outputSchema ? validateToolOutput(fn.outputSchema, result) : result; + return fn.outputSchema ? await validateToolOutput(fn.outputSchema, result) : result; } /** @@ -583,7 +586,7 @@ export async function prepareUnifiedInvocation( context: TurnContext, onPreliminaryResult?: (toolCallId: string, result: unknown) => void, contextStore?: ToolContextStore, - sharedSchema?: $ZodObject<$ZodShape>, + sharedSchema?: ObjectSchema, extras?: ToolExecutionExtras, ): Promise | AsyncToolInvocation> { if (!isUnifiedTool(tool)) { @@ -594,7 +597,7 @@ export async function prepareUnifiedInvocation( let validatedInput: Record; try { - validatedInput = validateToolInput(fn.inputSchema, toolCall.arguments) as Record< + validatedInput = (await validateToolInput(fn.inputSchema, toolCall.arguments)) as Record< string, unknown >; @@ -629,7 +632,7 @@ export async function prepareUnifiedInvocation( log: (entry: unknown) => { const validated = fn.eventSchema && typeof entry !== 'string' - ? validateToolOutput(fn.eventSchema, entry) + ? validateSchemaSync(fn.eventSchema, entry) : entry; onYield(validated); }, @@ -735,7 +738,7 @@ export async function executeTool( context: TurnContext, onPreliminaryResult?: (toolCallId: string, result: unknown) => void, contextStore?: ToolContextStore, - sharedSchema?: $ZodObject<$ZodShape>, + sharedSchema?: ObjectSchema, extras?: ToolExecutionExtras, ): Promise | AsyncToolInvocation | null> { if (isHITLTool(tool)) { @@ -801,7 +804,7 @@ export function formatToolResultForModel(result: ToolExecutionResult): str * Create a user-friendly error message for tool execution errors */ export function formatToolExecutionError(error: Error, toolCall: ParsedToolCall): string { - if (error instanceof ZodError) { + if (error instanceof ZodError || error instanceof StandardSchemaError) { const issues = error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message, @@ -928,7 +931,7 @@ async function invokeOnResponseReceived( parsed: unknown, context: TurnContext, contextStore?: ToolContextStore, - sharedSchema?: $ZodObject<$ZodShape>, + sharedSchema?: ObjectSchema, ): Promise { const hook = tool.function.onResponseReceived; if (!hook) { @@ -942,7 +945,7 @@ async function invokeOnResponseReceived( const executeContext = buildExecuteCtx(tool, undefined, context, contextStore, sharedSchema); try { const hookResult = await Promise.resolve(hook(parsed, executeContext)); - const validation = z4.safeParse(tool.function.outputSchema, hookResult); + const validation = await safeValidateSchema(tool.function.outputSchema, hookResult); if (!validation.success) { return formatHookError(validation.error.message, parsed); } @@ -965,7 +968,7 @@ async function computeHitlItemOutput( item: models.FunctionCallOutputItem, context: TurnContext, contextStore?: ToolContextStore, - sharedSchema?: $ZodObject<$ZodShape>, + sharedSchema?: ObjectSchema, ): Promise { const parsed = parseRawFunctionCallOutput(item.output); @@ -975,7 +978,7 @@ async function computeHitlItemOutput( // No hook — validate the parsed raw output against outputSchema. On // success, leave the item untouched; on failure, surface an error wrapper. - const validation = z4.safeParse(tool.function.outputSchema, parsed); + const validation = await safeValidateSchema(tool.function.outputSchema, parsed); if (validation.success) { return null; } @@ -1009,7 +1012,7 @@ export async function applyOnResponseReceivedHooks( tools: readonly Tool[] | undefined, context: TurnContext, contextStore?: ToolContextStore, - sharedSchema?: $ZodObject<$ZodShape>, + sharedSchema?: ObjectSchema, ): Promise { if (!tools || tools.length === 0 || !isItemArray(input)) { return input; diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index 0ea4140d..bbfb8b2c 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -1,7 +1,7 @@ import type * as models from '@openrouter/sdk/models'; import type { StreamEvents } from '@openrouter/sdk/models'; -import type { $ZodObject, $ZodShape, $ZodType, infer as zodInfer } from 'zod/v4/core'; import type { DoomLoopSerializedState } from './doom-loop.js'; +import type { InferSchemaOutput, ObjectSchema, Schema } from './schema.js'; import type { TaskLogLimits, ToolTaskMode, ToolTaskStatus } from './tool-task.js'; /** @@ -86,27 +86,26 @@ export type InferToolContext = T extends { ? [ S, ] extends [ - $ZodObject<$ZodShape>, + ObjectSchema, ] - ? $ZodObject<$ZodShape> extends S + ? ObjectSchema extends S ? Record // wide/default schema type ⇒ tool declared no context - : zodInfer extends Record - ? zodInfer - : zodInfer & Record + : InferSchemaOutput extends Record + ? InferSchemaOutput + : InferSchemaOutput & Record : Record : Record; /** * Resolve execute-context shape from a contextSchema generic. - * - Wide/default `$ZodObject<$ZodShape>` (no schema provided) → `Record` + * - Wide/default `ObjectSchema` (no schema provided) → `Record` * - Concrete schema → its Zod-inferred shape */ -export type ContextFromSchema> = - $ZodObject<$ZodShape> extends TCtx - ? Record - : zodInfer extends Record - ? zodInfer - : zodInfer & Record; +export type ContextFromSchema = ObjectSchema extends TCtx + ? Record + : InferSchemaOutput extends Record + ? InferSchemaOutput + : InferSchemaOutput & Record; /** * Extract tool name from a tool definition @@ -420,12 +419,18 @@ export type ToModelOutputFunction = { * @template TCtx - Zod schema for tool context (optional; default = erased wide type) */ export interface BaseToolFunction< - TInput extends $ZodObject<$ZodShape>, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TInput extends ObjectSchema, + TCtx extends ObjectSchema = ObjectSchema, > { name: string; description?: string; inputSchema: TInput; + /** + * JSON Schema sent to the model for non-Zod validators. Standard Schema v1 + * standardizes validation and inference, but not JSON Schema generation. + * Zod schemas continue to use z.toJSONSchema and ignore this field. + */ + inputJsonSchema?: Record; /** * Whether providers should enforce strict schema adherence when generating * this tool's call arguments (OpenAI structured-outputs style). Serialized @@ -443,19 +448,19 @@ export interface BaseToolFunction< * assignable to the wide `Tool` union. */ readonly contextSchema?: TCtx; - nextTurnParams?: NextTurnParamsFunctions>; + nextTurnParams?: NextTurnParamsFunctions>; /** * Whether this tool requires human approval before execution * Can be a boolean or an async function that receives the tool's input params and context */ - requireApproval?: boolean | ToolApprovalCheck>; + requireApproval?: boolean | ToolApprovalCheck>; /** * Doom-loop identity for this tool's calls — see {@link ToolLoopKey}. * A computed function over the call's arguments, a field-name array * (serializable — used by MCP tool caches), or `false` (exempt). * Absent: the full validated arguments object is the identity. */ - loopKey?: ToolLoopKey>; + loopKey?: ToolLoopKey>; /** * Deadline for one execution of this tool, in milliseconds. When it * elapses the round stops waiting: the model receives a @@ -485,11 +490,11 @@ export interface BaseToolFunction< * @template TName - The tool's literal name string */ export interface ToolFunctionWithExecute< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType = $ZodType, + TInput extends ObjectSchema, + TOutput extends Schema = Schema, TContext extends Record = Record, TName extends string = string, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TCtx extends ObjectSchema = ObjectSchema, > extends BaseToolFunction { outputSchema?: TOutput; /** @@ -504,11 +509,11 @@ export interface ToolFunctionWithExecute< // bivariantly, so tools carrying concrete TInput/TContext types remain // assignable to the wide `Tool` union despite contravariant params. execute( - params: zodInfer, + params: InferSchemaOutput, context?: ToolExecuteContext, - ): Promise> | zodInfer; + ): Promise> | InferSchemaOutput; /** Convert tool execution output to model-facing output */ - toModelOutput?: ToModelOutputFunction, zodInfer>; + toModelOutput?: ToModelOutputFunction, InferSchemaOutput>; } /** @@ -534,31 +539,34 @@ export interface ToolFunctionWithExecute< * ``` */ export interface ToolFunctionWithGenerator< - TInput extends $ZodObject<$ZodShape>, - TEvent extends $ZodType = $ZodType, - TOutput extends $ZodType = $ZodType, + TInput extends ObjectSchema, + TEvent extends Schema = Schema, + TOutput extends Schema = Schema, TContext extends Record = Record, TName extends string = string, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TCtx extends ObjectSchema = ObjectSchema, > extends BaseToolFunction { eventSchema: TEvent; outputSchema: TOutput; // Method syntax for bivariant param checking — see ToolFunctionWithExecute. execute( - params: zodInfer, + params: InferSchemaOutput, context?: ToolExecuteContext, - ): AsyncGenerator | zodInfer, zodInfer | undefined>; + ): AsyncGenerator< + InferSchemaOutput | InferSchemaOutput, + InferSchemaOutput | undefined + >; /** Convert tool execution output to model-facing output */ - toModelOutput?: ToModelOutputFunction, zodInfer>; + toModelOutput?: ToModelOutputFunction, InferSchemaOutput>; } /** * Manual tool without execute function - requires manual handling by developer */ export interface ManualToolFunction< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType = $ZodType, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TInput extends ObjectSchema, + TOutput extends Schema = Schema, + TCtx extends ObjectSchema = ObjectSchema, > extends BaseToolFunction { outputSchema?: TOutput; } @@ -576,11 +584,11 @@ export interface ManualToolFunction< * Throwing surfaces as a tool error to the model. */ export interface HITLToolFunction< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType = $ZodType, + TInput extends ObjectSchema, + TOutput extends Schema = Schema, TContext extends Record = Record, TName extends string = string, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TCtx extends ObjectSchema = ObjectSchema, > extends BaseToolFunction { /** * Required for HITL tools. Used to validate both the `onToolCalled` return @@ -591,14 +599,14 @@ export interface HITLToolFunction< outputSchema: TOutput; // Method syntax for bivariant param checking — see ToolFunctionWithExecute. onToolCalled( - params: zodInfer, + params: InferSchemaOutput, context?: ToolExecuteContext, - ): Promise | null> | zodInfer | null; + ): Promise | null> | InferSchemaOutput | null; onResponseReceived?( rawResult: unknown, context?: ToolExecuteContext, - ): Promise> | zodInfer; - toModelOutput?: ToModelOutputFunction, zodInfer>; + ): Promise> | InferSchemaOutput; + toModelOutput?: ToModelOutputFunction, InferSchemaOutput>; } /** @@ -645,7 +653,7 @@ export type ToolCheckConfig> = * into steering/cancel or any other side effect, exactly as you * would validate a tool's inputSchema. */ - schema?: $ZodObject<$ZodShape>; + schema?: ObjectSchema; // Method syntax for bivariant param checking — see ToolFunctionWithExecute. execute?: { bivarianceHack(params: TCheckParams, turnContext: TurnContext): unknown | Promise; @@ -673,12 +681,12 @@ export type ToolCheckConfig> = * legacy `execute` generators, which accept a final yield as the result.) */ export interface UnifiedToolFunction< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType = $ZodType, - TEvent extends $ZodType = $ZodType, + TInput extends ObjectSchema, + TOutput extends Schema = Schema, + TEvent extends Schema = Schema, TContext extends Record = Record, TName extends string = string, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TCtx extends ObjectSchema = ObjectSchema, > extends BaseToolFunction { /** Discriminator against every legacy kind. */ readonly lifecycle: ToolLifecycle; @@ -690,7 +698,7 @@ export interface UnifiedToolFunction< /** Validates `run` yields / `ctx.log()` entries when declared. */ eventSchema?: TEvent; /** Model-facing acknowledgement merged into the pending placeholder. */ - ack?: AsyncToolAck>; + ack?: AsyncToolAck>; /** * Background only: hold the round this long (ms) before emitting a * placeholder. Work settling in-window produces a plain synchronous @@ -707,13 +715,16 @@ export interface UnifiedToolFunction< logLimits?: Partial; // Method syntax for bivariant param checking — see ToolFunctionWithExecute. run( - params: zodInfer, - context?: ToolRunContext>, + params: InferSchemaOutput, + context?: ToolRunContext>, ): - | Promise | DeferredHandle>> - | zodInfer - | DeferredHandle> - | AsyncGenerator, zodInfer | DeferredHandle>>; + | Promise | DeferredHandle>> + | InferSchemaOutput + | DeferredHandle> + | AsyncGenerator< + InferSchemaOutput, + InferSchemaOutput | DeferredHandle> + >; /** * Convert tool execution output to model-facing output. * @@ -725,7 +736,7 @@ export interface UnifiedToolFunction< * carry this mapper's content-item arrays; they deliver the validated * output verbatim. */ - toModelOutput?: ToModelOutputFunction, zodInfer>; + toModelOutput?: ToModelOutputFunction, InferSchemaOutput>; /** Absent on unified tools — keeps them disjoint from legacy kinds. */ readonly execute?: undefined; readonly onToolCalled?: undefined; @@ -736,10 +747,10 @@ export interface UnifiedToolFunction< * @template TCtx - The concrete contextSchema type when one was provided to `tool()` */ export type ToolWithExecute< - TInput extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, - TOutput extends $ZodType = $ZodType, + TInput extends ObjectSchema = ObjectSchema, + TOutput extends Schema = Schema, TContext extends Record = Record, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TCtx extends ObjectSchema = ObjectSchema, > = { type: ToolType.Function; function: ToolFunctionWithExecute; @@ -750,11 +761,11 @@ export type ToolWithExecute< * @template TCtx - The concrete contextSchema type when one was provided to `tool()` */ export type ToolWithGenerator< - TInput extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, - TEvent extends $ZodType = $ZodType, - TOutput extends $ZodType = $ZodType, + TInput extends ObjectSchema = ObjectSchema, + TEvent extends Schema = Schema, + TOutput extends Schema = Schema, TContext extends Record = Record, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TCtx extends ObjectSchema = ObjectSchema, > = { type: ToolType.Function; function: ToolFunctionWithGenerator; @@ -765,9 +776,9 @@ export type ToolWithGenerator< * @template TCtx - The concrete contextSchema type when one was provided to `tool()` */ export type ManualTool< - TInput extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, - TOutput extends $ZodType = $ZodType, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TInput extends ObjectSchema = ObjectSchema, + TOutput extends Schema = Schema, + TCtx extends ObjectSchema = ObjectSchema, > = { type: ToolType.Function; function: ManualToolFunction; @@ -778,10 +789,10 @@ export type ManualTool< * @template TCtx - The concrete contextSchema type when one was provided to `tool()` */ export type HITLTool< - TInput extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, - TOutput extends $ZodType = $ZodType, + TInput extends ObjectSchema = ObjectSchema, + TOutput extends Schema = Schema, TContext extends Record = Record, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TCtx extends ObjectSchema = ObjectSchema, > = { type: ToolType.Function; function: HITLToolFunction; @@ -792,11 +803,11 @@ export type HITLTool< * @template TCtx - The concrete contextSchema type when one was provided */ export type UnifiedTool< - TInput extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, - TOutput extends $ZodType = $ZodType, - TEvent extends $ZodType = $ZodType, + TInput extends ObjectSchema = ObjectSchema, + TOutput extends Schema = Schema, + TEvent extends Schema = Schema, TContext extends Record = Record, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TCtx extends ObjectSchema = ObjectSchema, > = { type: ToolType.Function; function: UnifiedToolFunction; @@ -808,11 +819,11 @@ export type UnifiedTool< * tool execution loop. */ export type ClientTool = - | ToolWithExecute<$ZodObject<$ZodShape>, $ZodType> - | ToolWithGenerator<$ZodObject<$ZodShape>, $ZodType, $ZodType> - | ManualTool<$ZodObject<$ZodShape>, $ZodType> - | HITLTool<$ZodObject<$ZodShape>, $ZodType> - | UnifiedTool<$ZodObject<$ZodShape>, $ZodType, $ZodType>; + | ToolWithExecute + | ToolWithGenerator + | ManualTool + | HITLTool + | UnifiedTool; /** * Config payload for an OpenRouter server-executed tool. Derived directly @@ -913,8 +924,8 @@ export type InferToolInput = T extends { inputSchema: infer S; }; } - ? S extends $ZodType - ? zodInfer + ? S extends Schema + ? InferSchemaOutput : unknown : unknown; @@ -926,8 +937,8 @@ export type InferToolOutput = T extends { outputSchema: infer S; }; } - ? S extends $ZodType - ? zodInfer + ? S extends Schema + ? InferSchemaOutput : unknown : unknown; @@ -977,8 +988,8 @@ export type InferToolEvent = T extends { eventSchema: infer S; }; } - ? S extends $ZodType - ? zodInfer + ? S extends Schema + ? InferSchemaOutput : never : never; @@ -1183,12 +1194,12 @@ export interface ToolExecutionResult { ] ? unknown // wide `Tool`: result not statically known : T extends - | ToolWithExecute<$ZodObject<$ZodShape>, infer O> - | ToolWithGenerator<$ZodObject<$ZodShape>, $ZodType, infer O> - ? zodInfer + | ToolWithExecute + | ToolWithGenerator + ? InferSchemaOutput : unknown; // Final result (sent to model) - preliminaryResults?: T extends ToolWithGenerator<$ZodObject<$ZodShape>, infer E> - ? zodInfer[] + preliminaryResults?: T extends ToolWithGenerator + ? InferSchemaOutput[] : undefined; // All yielded values from generator error?: Error; } diff --git a/packages/agent/src/lib/tool.ts b/packages/agent/src/lib/tool.ts index 3731e676..cbfec7f0 100644 --- a/packages/agent/src/lib/tool.ts +++ b/packages/agent/src/lib/tool.ts @@ -1,9 +1,9 @@ import type { OpenRouterCore } from '@openrouter/sdk/core'; import type { RequestOptions } from '@openrouter/sdk/lib/sdks'; -import type { $ZodObject, $ZodShape, $ZodType, infer as zodInfer } from 'zod/v4/core'; import { agentToolBuilder } from './agent-tool.js'; import type { CallModelInput } from './async-params.js'; import type { ModelResult } from './model-result.js'; +import type { InferSchemaOutput, ObjectSchema, Schema } from './schema.js'; import { TASK_TOOL_NAME } from './tool-check.js'; import type { TaskLogLimits } from './tool-task.js'; import type { @@ -40,123 +40,124 @@ import { isClientTool, SHARED_CONTEXT_KEY, ToolType } from './tool-types.js'; * execute's `ctx.local` and the returned tool's `function.contextSchema` stay typed. */ type RegularToolConfigWithOutput< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TInput extends ObjectSchema, + TOutput extends Schema, + TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, > = { name: TName; description?: string; inputSchema: TInput; + inputJsonSchema?: Record; 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>; - requireApproval?: boolean | ToolApprovalCheck>; + nextTurnParams?: NextTurnParamsFunctions>; + requireApproval?: boolean | ToolApprovalCheck>; /** Doom-loop identity: computed function, or false to exempt — see {@link ToolLoopKey} */ - loopKey?: ToolLoopKey>; + loopKey?: ToolLoopKey>; /** Deadline for one execution of this tool, in ms (see BaseToolFunction.timeoutMs) */ timeoutMs?: number; /** Max simultaneous in-flight executions of this tool across the run */ maxConcurrency?: number; execute: ( - params: zodInfer, + params: InferSchemaOutput, context?: ToolExecuteContext>, - ) => Promise> | zodInfer; + ) => Promise> | InferSchemaOutput; /** Convert tool execution output to model-facing output */ - toModelOutput?: ToModelOutputFunction, zodInfer>; + toModelOutput?: ToModelOutputFunction, InferSchemaOutput>; }; /** * Configuration for a regular tool without outputSchema (infers return type from execute) */ type RegularToolConfigWithoutOutput< - TInput extends $ZodObject<$ZodShape>, + TInput extends ObjectSchema, TReturn, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, > = { name: TName; description?: string; inputSchema: TInput; + inputJsonSchema?: Record; 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>; - requireApproval?: boolean | ToolApprovalCheck>; + nextTurnParams?: NextTurnParamsFunctions>; + requireApproval?: boolean | ToolApprovalCheck>; /** Doom-loop identity: computed function, or false to exempt — see {@link ToolLoopKey} */ - loopKey?: ToolLoopKey>; + loopKey?: ToolLoopKey>; /** Deadline for one execution of this tool, in ms (see BaseToolFunction.timeoutMs) */ timeoutMs?: number; /** Max simultaneous in-flight executions of this tool across the run */ maxConcurrency?: number; execute: ( - params: zodInfer, + params: InferSchemaOutput, context?: ToolExecuteContext>, ) => Promise | TReturn; /** Convert tool execution output to model-facing output */ - toModelOutput?: ToModelOutputFunction, TReturn>; + toModelOutput?: ToModelOutputFunction, TReturn>; }; /** * Configuration for a generator tool (with eventSchema) */ type GeneratorToolConfig< - TInput extends $ZodObject<$ZodShape>, - TEvent extends $ZodType, - TOutput extends $ZodType, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TInput extends ObjectSchema, + TEvent extends Schema, + TOutput extends Schema, + TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, > = { name: TName; description?: string; inputSchema: TInput; + inputJsonSchema?: Record; 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>; - requireApproval?: boolean | ToolApprovalCheck>; + nextTurnParams?: NextTurnParamsFunctions>; + requireApproval?: boolean | ToolApprovalCheck>; /** Doom-loop identity: computed function, or false to exempt — see {@link ToolLoopKey} */ - loopKey?: ToolLoopKey>; + loopKey?: ToolLoopKey>; /** Deadline for one execution of this tool, in ms (see BaseToolFunction.timeoutMs) */ timeoutMs?: number; /** Max simultaneous in-flight executions of this tool across the run */ maxConcurrency?: number; execute: ( - params: zodInfer, + params: InferSchemaOutput, context?: ToolExecuteContext>, - ) => AsyncGenerator | zodInfer>; + ) => AsyncGenerator | InferSchemaOutput>; /** Convert tool execution output to model-facing output */ - toModelOutput?: ToModelOutputFunction, zodInfer>; + toModelOutput?: ToModelOutputFunction, InferSchemaOutput>; }; /** * Configuration for a manual tool (execute: false, no eventSchema or outputSchema) */ -type ManualToolConfig< - TInput extends $ZodObject<$ZodShape>, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, -> = { +type ManualToolConfig = { name: string; // Manual tools don't use TName since they have no execute description?: string; inputSchema: TInput; + inputJsonSchema?: Record; /** 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>; - requireApproval?: boolean | ToolApprovalCheck>; + nextTurnParams?: NextTurnParamsFunctions>; + requireApproval?: boolean | ToolApprovalCheck>; /** Doom-loop identity: computed function, or false to exempt — see {@link ToolLoopKey} */ - loopKey?: ToolLoopKey>; + loopKey?: ToolLoopKey>; /** Deadline for one execution of this tool, in ms (see BaseToolFunction.timeoutMs) */ timeoutMs?: number; /** Max simultaneous in-flight executions of this tool across the run */ @@ -176,14 +177,15 @@ type ManualToolConfig< * returned value replaces what the model ultimately sees. */ type HITLToolConfig< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TInput extends ObjectSchema, + TOutput extends Schema, + TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, > = { name: TName; description?: string; inputSchema: TInput; + inputJsonSchema?: Record; /** * Required for HITL tools. Used to validate both the `onToolCalled` return * value (when non-null) and the caller-supplied response that comes back via @@ -197,24 +199,24 @@ type HITLToolConfig< strict?: boolean | null; /** Zod schema declaring the context data this tool needs */ contextSchema?: TCtx; - nextTurnParams?: NextTurnParamsFunctions>; - requireApproval?: boolean | ToolApprovalCheck>; + nextTurnParams?: NextTurnParamsFunctions>; + requireApproval?: boolean | ToolApprovalCheck>; /** Doom-loop identity: computed function, or false to exempt — see {@link ToolLoopKey} */ - loopKey?: ToolLoopKey>; + loopKey?: ToolLoopKey>; /** Deadline for one execution of this tool, in ms (see BaseToolFunction.timeoutMs) */ timeoutMs?: number; /** Max simultaneous in-flight executions of this tool across the run */ maxConcurrency?: number; onToolCalled: ( - params: zodInfer, + params: InferSchemaOutput, context?: ToolExecuteContext>, - ) => Promise | null> | zodInfer | null; + ) => Promise | null> | InferSchemaOutput | null; onResponseReceived?: ( rawResult: unknown, context?: ToolExecuteContext>, - ) => Promise> | zodInfer; + ) => Promise> | InferSchemaOutput; /** Convert tool execution output to model-facing output */ - toModelOutput?: ToModelOutputFunction, zodInfer>; + toModelOutput?: ToModelOutputFunction, InferSchemaOutput>; }; /** @@ -223,13 +225,14 @@ type HITLToolConfig< */ type ToolConfigWithSharedContext< TShared extends Record, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TCtx extends ObjectSchema = ObjectSchema, > = { name: string; description?: string; - inputSchema: $ZodObject<$ZodShape>; - outputSchema?: $ZodType; - eventSchema?: $ZodType; + inputSchema: ObjectSchema; + inputJsonSchema?: Record; + outputSchema?: Schema; + eventSchema?: Schema; /** Strict schema adherence for tool-call generation — see {@link BaseToolFunction.strict} */ strict?: boolean | null; contextSchema?: TCtx; @@ -259,13 +262,14 @@ type ToolConfigWithSharedContext< * Shared fields for unified `run` tool configs. */ type RunToolConfigBase< - TInput extends $ZodObject<$ZodShape>, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TInput extends ObjectSchema, + TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, > = { name: TName; description?: string; inputSchema: TInput; + inputJsonSchema?: Record; /** Never present on run configs — keeps them disjoint from legacy overloads. */ execute?: undefined; onToolCalled?: undefined; @@ -273,10 +277,10 @@ type RunToolConfigBase< strict?: boolean | null; /** Zod schema declaring the context data this tool needs */ contextSchema?: TCtx; - nextTurnParams?: NextTurnParamsFunctions>; - requireApproval?: boolean | ToolApprovalCheck>; + nextTurnParams?: NextTurnParamsFunctions>; + requireApproval?: boolean | ToolApprovalCheck>; /** Doom-loop identity: computed function, or false to exempt — see {@link ToolLoopKey} */ - loopKey?: ToolLoopKey>; + loopKey?: ToolLoopKey>; /** Deadline for one execution of this tool, in ms (see BaseToolFunction.timeoutMs) */ timeoutMs?: number; /** Max simultaneous in-flight executions of this tool across the run */ @@ -292,10 +296,10 @@ type RunToolConfigBase< * outputSchema. `lifecycle` selects sync (default) / background / deferred. */ type RunToolConfigWithOutput< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType, - TEvent extends $ZodType = $ZodType, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TInput extends ObjectSchema, + TOutput extends Schema, + TEvent extends Schema = Schema, + TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, > = RunToolConfigBase & { outputSchema: TOutput; @@ -303,21 +307,24 @@ type RunToolConfigWithOutput< eventSchema?: TEvent; lifecycle?: ToolLifecycle; /** Model-facing acknowledgement merged into the pending placeholder. */ - ack?: AsyncToolAck>; + ack?: AsyncToolAck>; /** Background: settles-fast window (ms). Default 250; 0 always placeholders. */ graceMs?: number; /** Deferred: default poll-interval hint. */ pollAfterMs?: number; run: ( - params: zodInfer, - context?: ToolRunContext, zodInfer>, + params: InferSchemaOutput, + context?: ToolRunContext, InferSchemaOutput>, ) => - | Promise | DeferredHandle>> - | zodInfer - | DeferredHandle> - | AsyncGenerator, zodInfer | DeferredHandle>>; + | Promise | DeferredHandle>> + | InferSchemaOutput + | DeferredHandle> + | AsyncGenerator< + InferSchemaOutput, + InferSchemaOutput | DeferredHandle> + >; /** Convert tool execution output to model-facing output */ - toModelOutput?: ToModelOutputFunction, zodInfer>; + toModelOutput?: ToModelOutputFunction, InferSchemaOutput>; }; /** @@ -327,21 +334,21 @@ type RunToolConfigWithOutput< * another process), so this config pins lifecycle to 'sync'/absent. */ type SyncRunToolConfigWithoutOutput< - TInput extends $ZodObject<$ZodShape>, + TInput extends ObjectSchema, TReturn, - TEvent extends $ZodType = $ZodType, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TEvent extends Schema = Schema, + TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, > = RunToolConfigBase & { outputSchema?: undefined; eventSchema?: TEvent; lifecycle?: 'sync'; run: ( - params: zodInfer, + params: InferSchemaOutput, context?: ToolRunContext, TReturn>, - ) => Promise | TReturn | AsyncGenerator, TReturn>; + ) => Promise | TReturn | AsyncGenerator, TReturn>; /** Convert tool execution output to model-facing output */ - toModelOutput?: ToModelOutputFunction, TReturn>; + toModelOutput?: ToModelOutputFunction, TReturn>; }; //#endregion @@ -352,10 +359,10 @@ type SyncRunToolConfigWithoutOutput< * Union type for all regular tool configs */ type RegularToolConfig< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType, + TInput extends ObjectSchema, + TOutput extends Schema, TReturn, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, > = | RegularToolConfigWithOutput @@ -400,10 +407,10 @@ type RegularToolConfig< // Overload for deferred unified tools — returns the tool + typed // .resolve()/.fail()/.cancel() completion methods. export function tool< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType, - TEvent extends $ZodType = $ZodType, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TInput extends ObjectSchema, + TOutput extends Schema, + TEvent extends Schema = Schema, + TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, >( config: RunToolConfigWithOutput & { @@ -413,10 +420,10 @@ export function tool< // Overload for unified run tools with outputSchema (any lifecycle). export function tool< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType, - TEvent extends $ZodType = $ZodType, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TInput extends ObjectSchema, + TOutput extends Schema, + TEvent extends Schema = Schema, + TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, >( config: RunToolConfigWithOutput, @@ -425,14 +432,14 @@ export function tool< // Overload for SYNC unified run tools without outputSchema (output inferred // from run's return — including a generator's TReturn). export function tool< - TInput extends $ZodObject<$ZodShape>, + TInput extends ObjectSchema, TReturn, - TEvent extends $ZodType = $ZodType, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TEvent extends Schema = Schema, + TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, >( config: SyncRunToolConfigWithoutOutput, -): UnifiedTool, TEvent, Record, TCtx>; +): UnifiedTool, TEvent, Record, TCtx>; // Overload for generator tools (when eventSchema is provided). // TContext on the *returned* tool stays the wide default so specific tools remain @@ -440,10 +447,10 @@ export function tool< // `ctx.local` is provided by the *config* execute signature via ContextFromSchema; // the concrete schema is preserved on the return via TCtx on `BaseToolFunction.contextSchema`. export function tool< - TInput extends $ZodObject<$ZodShape>, - TEvent extends $ZodType, - TOutput extends $ZodType, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TInput extends ObjectSchema, + TEvent extends Schema, + TOutput extends Schema, + TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, >( config: GeneratorToolConfig, @@ -451,25 +458,24 @@ export function tool< // Overload for HITL tools (when onToolCalled is provided) export function tool< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TInput extends ObjectSchema, + TOutput extends Schema, + TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, >( config: HITLToolConfig, ): HITLTool, TCtx>; // Overload for manual tools (execute: false) -export function tool< - TInput extends $ZodObject<$ZodShape>, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, ->(config: ManualToolConfig): ManualTool, TCtx>; +export function tool( + config: ManualToolConfig, +): ManualTool; // Overload for regular tools with outputSchema export function tool< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TInput extends ObjectSchema, + TOutput extends Schema, + TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, >( config: RegularToolConfigWithOutput, @@ -477,13 +483,13 @@ export function tool< // Overload for regular tools without outputSchema (infers return type) export function tool< - TInput extends $ZodObject<$ZodShape>, + TInput extends ObjectSchema, TReturn, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, >( config: RegularToolConfigWithoutOutput, -): ToolWithExecute, Record, TCtx>; +): ToolWithExecute, Record, TCtx>; // Overload for explicit TShared: tool({...}) // When a non-ZodObject type is provided as the first generic, @@ -496,12 +502,12 @@ export function tool>( // Implementation export function tool( config: - | GeneratorToolConfig<$ZodObject<$ZodShape>, $ZodType, $ZodType> - | RegularToolConfig<$ZodObject<$ZodShape>, $ZodType, unknown> - | ManualToolConfig<$ZodObject<$ZodShape>> - | HITLToolConfig<$ZodObject<$ZodShape>, $ZodType> - | RunToolConfigWithOutput<$ZodObject<$ZodShape>, $ZodType> - | SyncRunToolConfigWithoutOutput<$ZodObject<$ZodShape>, unknown> + | GeneratorToolConfig + | RegularToolConfig + | ManualToolConfig + | HITLToolConfig + | RunToolConfigWithOutput + | SyncRunToolConfigWithoutOutput | ToolConfigWithSharedContext>, ): Tool { // 'shared' is reserved for shared context — forbid it as a tool name @@ -526,8 +532,8 @@ export function tool( if ('run' in config && typeof config.run === 'function') { return buildUnifiedTool( config as - | RunToolConfigWithOutput<$ZodObject<$ZodShape>, $ZodType> - | SyncRunToolConfigWithoutOutput<$ZodObject<$ZodShape>, unknown>, + | RunToolConfigWithOutput + | SyncRunToolConfigWithoutOutput, ); } @@ -542,9 +548,12 @@ export function tool( ); } - const fn: HITLTool<$ZodObject<$ZodShape>, $ZodType>['function'] = { + const fn: HITLTool['function'] = { name: config.name, inputSchema: config.inputSchema, + ...(config.inputJsonSchema !== undefined && { + inputJsonSchema: config.inputJsonSchema, + }), outputSchema: config.outputSchema, onToolCalled: config.onToolCalled, }; @@ -603,9 +612,12 @@ export function tool( // Check for manual tool first (execute === false) if (config.execute === false) { - const fn: ManualTool<$ZodObject<$ZodShape>>['function'] = { + const fn: ManualTool['function'] = { name: config.name, inputSchema: config.inputSchema, + ...(config.inputJsonSchema !== undefined && { + inputJsonSchema: config.inputJsonSchema, + }), }; if (config.description !== undefined) { @@ -657,10 +669,13 @@ export function tool( const fn = { name: config.name, inputSchema: config.inputSchema, + ...(config.inputJsonSchema !== undefined && { + inputJsonSchema: config.inputJsonSchema, + }), eventSchema: config.eventSchema, outputSchema: config.outputSchema, execute: config.execute, - } as ToolWithGenerator<$ZodObject<$ZodShape>, $ZodType, $ZodType>['function']; + } as ToolWithGenerator['function']; if (config.description !== undefined) { fn.description = config.description; @@ -714,6 +729,9 @@ export function tool( const functionObj = { name: config.name, inputSchema: config.inputSchema, + ...(config.inputJsonSchema !== undefined && { + inputJsonSchema: config.inputJsonSchema, + }), execute: config.execute, ...(config.description !== undefined && { description: config.description, @@ -829,12 +847,12 @@ export interface DeferredToolMethods { * `tools: [...]` arrays unchanged. */ export type BuiltDeferredTool< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType, - TEvent extends $ZodType = $ZodType, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TInput extends ObjectSchema, + TOutput extends Schema, + TEvent extends Schema = Schema, + TCtx extends ObjectSchema = ObjectSchema, > = UnifiedTool, TCtx> & - DeferredToolMethods>; + DeferredToolMethods>; /** Copy shared config fields onto a function object when present. */ function assignCommonToolFields( @@ -843,6 +861,7 @@ function assignCommonToolFields( ): void { const fields = [ 'description', + 'inputJsonSchema', 'strict', 'contextSchema', 'nextTurnParams', @@ -924,8 +943,8 @@ function bindDeferredCompletion( */ function buildUnifiedTool( config: - | RunToolConfigWithOutput<$ZodObject<$ZodShape>, $ZodType> - | SyncRunToolConfigWithoutOutput<$ZodObject<$ZodShape>, unknown>, + | RunToolConfigWithOutput + | SyncRunToolConfigWithoutOutput, ): Tool { const lifecycle: ToolLifecycle = ('lifecycle' in config ? config.lifecycle : undefined) ?? 'sync'; diff --git a/packages/agent/src/openrouter.ts b/packages/agent/src/openrouter.ts index 5aee752a..439dbb24 100644 --- a/packages/agent/src/openrouter.ts +++ b/packages/agent/src/openrouter.ts @@ -9,11 +9,10 @@ import type { } from '@openrouter/sdk/hooks/types'; import type { SDKOptions } from '@openrouter/sdk/lib/config'; import type { RequestOptions } from '@openrouter/sdk/lib/sdks'; -import type { $ZodObject, $ZodShape, infer as zodInfer } from 'zod/v4/core'; - import { callModel } from './inner-loop/call-model.js'; import type { CallModelInput } from './lib/async-params.js'; import type { ModelResult } from './lib/model-result.js'; +import type { InferSchemaOutput, ObjectSchema } from './lib/schema.js'; import type { Tool } from './lib/tool-types.js'; export type { SDKOptions } from '@openrouter/sdk/lib/config'; @@ -85,9 +84,9 @@ export class OpenRouter extends OpenRouterCore { callModel = < TTools extends readonly Tool[], - TSharedSchema extends $ZodObject<$ZodShape> | undefined = undefined, - TShared extends Record = TSharedSchema extends $ZodObject<$ZodShape> - ? zodInfer + TSharedSchema extends ObjectSchema | undefined = undefined, + TShared extends Record = TSharedSchema extends ObjectSchema + ? InferSchemaOutput : Record, >( request: CallModelInput & { diff --git a/packages/agent/tests/unit/standard-schema-inference.test-d.ts b/packages/agent/tests/unit/standard-schema-inference.test-d.ts new file mode 100644 index 00000000..cf165fe3 --- /dev/null +++ b/packages/agent/tests/unit/standard-schema-inference.test-d.ts @@ -0,0 +1,84 @@ +import * as v from 'valibot'; +import { expectTypeOf } from 'vitest'; +import { z } from 'zod/v4'; +import { tool } from '../../src/lib/tool.js'; +import type { InferToolInput } from '../../src/lib/tool-types.js'; + +const standardTool = tool({ + name: 'standard', + inputSchema: v.object({ + value: v.pipe( + v.string(), + v.transform((value) => value.length), + ), + }), + inputJsonSchema: { + type: 'object', + properties: { + value: { + type: 'string', + }, + }, + required: [ + 'value', + ], + }, + outputSchema: v.object({ + ok: v.boolean(), + }), + eventSchema: v.object({ + progress: v.number(), + }), + contextSchema: v.object({ + token: v.string(), + }), + execute: async function* ({ value }, ctx) { + expectTypeOf(value).toEqualTypeOf(); + expectTypeOf(ctx!.local.token).toEqualTypeOf(); + yield { + progress: value, + }; + return { + ok: true, + }; + }, + toModelOutput: ({ output, input }) => { + expectTypeOf(output).toEqualTypeOf<{ + ok: boolean; + }>(); + expectTypeOf(input).toEqualTypeOf<{ + value: number; + }>(); + return { + type: 'content', + value: [], + }; + }, +}); + +expectTypeOf>().toEqualTypeOf<{ + value: number; +}>(); + +const zodTool = tool({ + name: 'zod', + inputSchema: z.object({ + value: z.string(), + }), + outputSchema: z.object({ + ok: z.boolean(), + }), + execute: ({ value }) => { + expectTypeOf(value).toEqualTypeOf(); + return { + ok: true, + }; + }, +}); + +expectTypeOf>().toEqualTypeOf<{ + value: string; +}>(); +expectTypeOf(zodTool.function.execute).parameter(0).toEqualTypeOf<{ + value: string; +}>(); diff --git a/packages/agent/tests/unit/standard-schema.test.ts b/packages/agent/tests/unit/standard-schema.test.ts new file mode 100644 index 00000000..ac269501 --- /dev/null +++ b/packages/agent/tests/unit/standard-schema.test.ts @@ -0,0 +1,303 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import * as v from 'valibot'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod/v4'; +import { tool } from '../../src/lib/tool.js'; +import { buildToolExecuteContext, ToolContextStore } from '../../src/lib/tool-context.js'; +import { + convertToolsToAPIFormat, + executeGeneratorTool, + executeRegularTool, + formatToolExecutionError, +} from '../../src/lib/tool-executor.js'; +import type { ParsedToolCall, Tool, TurnContext } from '../../src/lib/tool-types.js'; + +const context: TurnContext = { + numberOfTurns: 1, +}; + +const inputSchema = v.object({ + name: v.pipe( + v.string(), + v.transform((name) => name.toUpperCase()), + ), +}); +const outputSchema = v.object({ + greeting: v.string(), +}); +const inputJsonSchema = { + type: 'object', + properties: { + name: { + type: 'string', + }, + }, + required: [ + 'name', + ], + '~standard': { + vendor: 'remove-me', + }, +}; + +function call(name: string, args: unknown): ParsedToolCall { + return { + id: 'call-1', + name, + arguments: args, + }; +} + +describe('Standard Schema tool support', () => { + it('keeps Zod validation and JSON Schema generation unchanged', async () => { + const zodTool = tool({ + name: 'zod_tool', + inputSchema: z.object({ + value: z.string(), + }), + outputSchema: z.object({ + length: z.number(), + }), + execute: ({ value }) => ({ + length: value.length, + }), + }); + + const result = await executeRegularTool( + zodTool, + call('zod_tool', { + value: 'abc', + }), + context, + ); + const [apiTool] = convertToolsToAPIFormat([ + zodTool, + ]); + + expect(result.result).toEqual({ + length: 3, + }); + expect(apiTool).toMatchObject({ + type: 'function', + parameters: { + type: 'object', + required: [ + 'value', + ], + }, + }); + }); + + it('validates and transforms Valibot input and output', async () => { + const valibotTool = tool({ + name: 'valibot_tool', + inputSchema, + inputJsonSchema, + outputSchema, + execute: ({ name }) => ({ + greeting: `Hello ${name}`, + }), + }); + + const result = await executeRegularTool( + valibotTool, + call('valibot_tool', { + name: 'luke', + }), + context, + ); + + expect(result.error).toBeUndefined(); + expect(result.result).toEqual({ + greeting: 'Hello LUKE', + }); + }); + + it('maps Standard Schema issues into existing validation errors', async () => { + const valibotTool = tool({ + name: 'valibot_tool', + inputSchema, + inputJsonSchema, + execute: () => null, + }); + + const result = await executeRegularTool( + valibotTool, + call('valibot_tool', { + name: 123, + }), + context, + ); + + expect(result.error).toBeDefined(); + expect(formatToolExecutionError(result.error!, call('valibot_tool', {}))).toContain( + '"path": "name"', + ); + }); + + it('uses and sanitizes the explicit JSON Schema escape hatch', () => { + const valibotTool = tool({ + name: 'valibot_tool', + inputSchema, + inputJsonSchema, + execute: () => null, + }); + + const [apiTool] = convertToolsToAPIFormat([ + valibotTool, + ]); + expect(apiTool).toMatchObject({ + parameters: { + type: 'object', + required: [ + 'name', + ], + }, + }); + expect( + ( + apiTool as { + parameters: Record; + } + ).parameters, + ).not.toHaveProperty('~standard'); + }); + + it('requires raw JSON Schema for non-Zod input validators', () => { + const valibotTool = tool({ + name: 'valibot_tool', + inputSchema, + execute: () => null, + }); + + expect(() => + convertToolsToAPIFormat([ + valibotTool, + ]), + ).toThrow('requires inputJsonSchema'); + }); + + it('awaits async Standard Schema validation', async () => { + const asyncSchema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'test', + validate: async (value) => + typeof value === 'string' + ? { + value: value.length, + } + : { + issues: [ + { + message: 'Expected string', + }, + ], + }, + types: undefined, + }, + }; + + const asyncTool = tool({ + name: 'async_validator', + inputSchema: asyncSchema, + inputJsonSchema: { + type: 'string', + }, + execute: (length) => length * 2, + }); + + const result = await executeRegularTool(asyncTool, call('async_validator', 'hello'), context); + expect(result.result).toBe(10); + }); + + it('rejects invalid output with Standard Schema issues', async () => { + const invalidOutputTool = tool({ + name: 'invalid_output', + inputSchema, + inputJsonSchema, + outputSchema, + execute: () => ({ + greeting: 123 as unknown as string, + }), + }); + + const result = await executeRegularTool( + invalidOutputTool, + call('invalid_output', { + name: 'luke', + }), + context, + ); + expect(result.error?.message).toContain('Invalid type'); + }); + + it('validates and updates Standard Schema context', () => { + const store = new ToolContextStore({ + standard: { + token: 'initial', + }, + }); + const ctx = buildToolExecuteContext< + 'standard', + { + token: string; + } + >( + context, + store, + 'standard', + v.object({ + token: v.string(), + }), + ); + + ctx.setContext({ + token: 'updated', + }); + expect(ctx.local).toEqual({ + token: 'updated', + }); + expect(() => + ctx.setContext({ + token: 123 as unknown as string, + }), + ).toThrow('Invalid type'); + }); + + it('validates generator events through Standard Schema', async () => { + const generator = tool({ + name: 'generator', + inputSchema, + inputJsonSchema, + eventSchema: v.object({ + progress: v.number(), + }), + outputSchema, + execute: async function* ({ name }) { + yield { + progress: 1, + }; + return { + greeting: `Hello ${name}`, + }; + }, + }); + + const result = await executeGeneratorTool( + generator, + call('generator', { + name: 'luke', + }), + context, + ); + expect(result.preliminaryResults).toEqual([ + { + progress: 1, + }, + ]); + expect(result.result).toEqual({ + greeting: 'Hello LUKE', + }); + }); +}); diff --git a/packages/agent/tsconfig.typecheck.json b/packages/agent/tsconfig.typecheck.json index 543147e6..ad6e94e7 100644 --- a/packages/agent/tsconfig.typecheck.json +++ b/packages/agent/tsconfig.typecheck.json @@ -1,6 +1,10 @@ { "extends": "./tsconfig.json", "compilerOptions": { "noEmit": true, "rootDir": "." }, - "include": ["src/**/*.ts", "tests/unit/context-schema-inference.test-d.ts"], + "include": [ + "src/**/*.ts", + "tests/unit/context-schema-inference.test-d.ts", + "tests/unit/standard-schema-inference.test-d.ts" + ], "exclude": ["node_modules", "esm"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d118b7e..0ad71f21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,9 +44,16 @@ importers: '@openrouter/sdk': specifier: ^0.13.7 version: 0.13.7 + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 zod: specifier: ^4.0.0 version: 4.3.6 + devDependencies: + valibot: + specifier: ^1.4.2 + version: 1.4.2(typescript@5.8.3) packages/mcp: dependencies: @@ -1080,6 +1087,14 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2124,6 +2139,10 @@ snapshots: universalify@0.1.2: {} + valibot@1.4.2(typescript@5.8.3): + optionalDependencies: + typescript: 5.8.3 + vite@7.3.1(@types/node@22.19.15): dependencies: esbuild: 0.27.4 From 5dfd09d52bab964262eafe64a31d01197cfdbc7c Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:04:26 -0500 Subject: [PATCH 02/13] fix(agent): await Standard Schema context validation --- packages/agent/README.md | 2 +- packages/agent/src/lib/tool-context.ts | 18 ++++-- packages/agent/src/lib/tool-executor.ts | 43 ++++++++++--- .../agent/tests/unit/standard-schema.test.ts | 60 +++++++++++++++++++ 4 files changed, 109 insertions(+), 14 deletions(-) diff --git a/packages/agent/README.md b/packages/agent/README.md index f02aee71..a57ad939 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -174,7 +174,7 @@ const searchTool = tool({ }); ``` -`inputJsonSchema` is only needed for `inputSchema`: output, event, and context schemas are validated locally and never sent to the model. The agent sanitizes both generated and supplied JSON Schema before the SDK boundary, including removing `~`-prefixed metadata keys. Standard Schema validators may validate synchronously or asynchronously; synchronous context mutation methods (`ctx.setContext()` and `ctx.setSharedContext()`) require a synchronous validator. +`inputJsonSchema` is only needed for `inputSchema`: output, event, and context schemas are validated locally and never sent to the model. The agent sanitizes both 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: diff --git a/packages/agent/src/lib/tool-context.ts b/packages/agent/src/lib/tool-context.ts index b90358a5..be2f6266 100644 --- a/packages/agent/src/lib/tool-context.ts +++ b/packages/agent/src/lib/tool-context.ts @@ -144,6 +144,8 @@ function validatePartialAgainstSchema( */ export interface ToolExecutionExtras { signal?: AbortSignal; + /** Internal: context schemas were already validated asynchronously by the executor. */ + contextValidated?: boolean; callId?: string; conversationId?: string; /** The parent run's client — agent tools use it to start child runs. */ @@ -214,12 +216,16 @@ export function buildToolExecuteContext< sharedSchema?: ObjectSchema | undefined, extras?: ToolExecutionExtras, ): ToolExecuteContext { - // Validate initial context eagerly (throws on bad data) - if (store && schema) { - extractToolContext(store, toolName, schema); - } - if (store && sharedSchema) { - extractToolContext(store, SHARED_CONTEXT_KEY, sharedSchema); + // Validate initial context eagerly (throws on bad data). Tool execution does + // this asynchronously before calling us so Promise-returning Standard Schema + // validators work; direct callers retain this synchronous validation path. + if (!extras?.contextValidated) { + if (store && schema) { + extractToolContext(store, toolName, schema); + } + if (store && sharedSchema) { + extractToolContext(store, SHARED_CONTEXT_KEY, sharedSchema); + } } const ctx: ToolExecuteContext = { diff --git a/packages/agent/src/lib/tool-executor.ts b/packages/agent/src/lib/tool-executor.ts index 0244b515..c3ca37cb 100644 --- a/packages/agent/src/lib/tool-executor.ts +++ b/packages/agent/src/lib/tool-executor.ts @@ -195,14 +195,24 @@ export function parseToolCallArguments(argumentsString: string): unknown { * otherwise. */ // biome-ignore lint: parameters match the internal API shape -function buildExecuteCtx( +async function buildExecuteCtx( tool: ClientTool, toolCall: ParsedToolCall | undefined, turnContext: TurnContext, contextStore?: ToolContextStore, sharedSchema?: ObjectSchema, extras?: ToolExecutionExtras, -): ToolExecuteContext { +): Promise { + if (contextStore && tool.function.contextSchema) { + await validateSchema( + tool.function.contextSchema, + contextStore.getToolContext(tool.function.name), + ); + } + if (contextStore && sharedSchema) { + await validateSchema(sharedSchema, contextStore.getToolContext('shared')); + } + const resolvedToolCall = turnContext.toolCall ?? (toolCall && toFunctionCallItem(toolCall)); return buildToolExecuteContext( resolvedToolCall @@ -215,7 +225,10 @@ function buildExecuteCtx( tool.function.name, tool.function.contextSchema, sharedSchema, - extras, + { + ...extras, + contextValidated: true, + }, ); } @@ -264,7 +277,7 @@ export async function executeRegularTool( try { const validatedInput = await validateToolInput(tool.function.inputSchema, toolCall.arguments); - const executeContext = buildExecuteCtx( + const executeContext = await buildExecuteCtx( tool, toolCall, context, @@ -329,7 +342,7 @@ export async function executeGeneratorTool( try { const validatedInput = await validateToolInput(tool.function.inputSchema, toolCall.arguments); - const executeContext = buildExecuteCtx( + const executeContext = await buildExecuteCtx( tool, toolCall, context, @@ -426,7 +439,7 @@ export async function executeHITLTool( try { const validatedInput = await validateToolInput(tool.function.inputSchema, toolCall.arguments); - const executeContext = buildExecuteCtx( + const executeContext = await buildExecuteCtx( tool, toolCall, context, @@ -637,6 +650,15 @@ export async function prepareUnifiedInvocation( onYield(validated); }, }) as NonNullable; + if (contextStore && tool.function.contextSchema) { + await validateSchema( + tool.function.contextSchema, + contextStore.getToolContext(tool.function.name), + ); + } + if (contextStore && sharedSchema) { + await validateSchema(sharedSchema, contextStore.getToolContext('shared')); + } const runContext = buildToolRunContext( context, contextStore, @@ -645,6 +667,7 @@ export async function prepareUnifiedInvocation( sharedSchema, { ...extras, + contextValidated: true, runExtras, }, ); @@ -942,7 +965,13 @@ async function invokeOnResponseReceived( * call's arguments are not — so no synthetic `toolCall` is threaded. A * caller-provided `turnContext.toolCall` still flows through. */ - const executeContext = buildExecuteCtx(tool, undefined, context, contextStore, sharedSchema); + const executeContext = await buildExecuteCtx( + tool, + undefined, + context, + contextStore, + sharedSchema, + ); try { const hookResult = await Promise.resolve(hook(parsed, executeContext)); const validation = await safeValidateSchema(tool.function.outputSchema, hookResult); diff --git a/packages/agent/tests/unit/standard-schema.test.ts b/packages/agent/tests/unit/standard-schema.test.ts index ac269501..7d50373e 100644 --- a/packages/agent/tests/unit/standard-schema.test.ts +++ b/packages/agent/tests/unit/standard-schema.test.ts @@ -232,6 +232,66 @@ describe('Standard Schema tool support', () => { expect(result.error?.message).toContain('Invalid type'); }); + it('awaits async Standard Schema context validation during execution', async () => { + const contextSchema: StandardSchemaV1< + unknown, + { + token: string; + } + > = { + '~standard': { + version: 1, + vendor: 'test', + validate: async (value) => { + const token = ( + value as { + token?: unknown; + } + ).token; + return typeof token === 'string' + ? { + value: { + token, + }, + } + : { + issues: [ + { + message: 'Expected token', + path: [ + 'token', + ], + }, + ], + }; + }, + types: undefined, + }, + }; + const contextTool = tool({ + name: 'async_context', + inputSchema, + inputJsonSchema, + contextSchema, + execute: (_input, ctx) => ctx!.local.token, + }); + const store = new ToolContextStore({ + async_context: { + token: 'secret', + }, + }); + + const result = await executeRegularTool( + contextTool, + call('async_context', { + name: 'luke', + }), + context, + store, + ); + expect(result.result).toBe('secret'); + }); + it('validates and updates Standard Schema context', () => { const store = new ToolContextStore({ standard: { From 496eba6be4d4a4c40eb1a6696c4f665434b8e7c4 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:09:33 -0500 Subject: [PATCH 03/13] refactor(agent): keep Standard Schema adapter modular --- packages/agent/src/lib/model-result.ts | 5 +- packages/agent/src/lib/tool-executor.ts | 114 ++++++++++++++++-------- 2 files changed, 78 insertions(+), 41 deletions(-) diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index 3db174f1..6aa0cea6 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -3,6 +3,8 @@ import { betaResponsesSend } from '@openrouter/sdk/funcs/betaResponsesSend'; import type { EventStream } from '@openrouter/sdk/lib/event-streams'; import type { RequestOptions } from '@openrouter/sdk/lib/sdks'; import type * as models from '@openrouter/sdk/models'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import type { $ZodObject, $ZodShape } from 'zod/v4/core'; import type { CallModelInput, ResolvedCallModelInput } from './async-params.js'; import { hasAsyncFunctions, resolveAsyncFunctions } from './async-params.js'; import type { SettledToolTask, TaskToolInput, ToolSemaphore, ToolTaskMode } from './async-tools.js'; @@ -53,7 +55,6 @@ import { executeNextTurnParamsFunctions, } from './next-turn-params.js'; import { ReusableReadableStream } from './reusable-stream.js'; -import type { ObjectSchema } from './schema.js'; import { isStopConditionMet } from './stop-conditions.js'; import type { ItemInProgress, StreamableOutputItem } from './stream-transformers.js'; import { @@ -439,7 +440,7 @@ export interface GetResponseOptions< /** Typed context data passed to tools via contextSchema. `shared` key for shared context. */ context?: ContextInput>; /** Zod schema for shared context validation */ - sharedContextSchema?: ObjectSchema; + sharedContextSchema?: $ZodObject<$ZodShape> | StandardSchemaV1>; /** * Call-level approval check - overrides tool-level requireApproval setting diff --git a/packages/agent/src/lib/tool-executor.ts b/packages/agent/src/lib/tool-executor.ts index c3ca37cb..3e4aa573 100644 --- a/packages/agent/src/lib/tool-executor.ts +++ b/packages/agent/src/lib/tool-executor.ts @@ -580,6 +580,61 @@ async function runUnifiedTool(args: { return fn.outputSchema ? await validateToolOutput(fn.outputSchema, result) : result; } +function unifiedExecutionResult({ + toolCall, + source, + result, + error, +}: { + toolCall: ParsedToolCall; + source: 'client' | 'mcp'; + result: unknown; + error?: unknown; +}): ToolExecutionResult { + return { + toolCallId: toolCall.id, + toolName: toolCall.name, + source, + result, + ...(error !== undefined && { + error: error instanceof Error ? error : new Error(String(error)), + }), + }; +} + +function deferredInvocation({ + toolCall, + result, + fn, + validatedInput, +}: { + toolCall: ParsedToolCall; + result: DeferredHandle; + fn: UnifiedTool['function']; + validatedInput: Record; +}): AsyncToolInvocation { + if (result.taskId.length === 0 || result.taskId.length > 256) { + throw new Error(`Tool "${toolCall.name}": ctx.defer() taskId must be 1-256 characters`); + } + return { + asyncMode: 'defer', + taskId: result.taskId, + ...(result.ack !== undefined + ? { + ack: result.ack, + } + : fn.ack !== undefined && { + ack: resolveAck(fn.ack, validatedInput), + }), + ...((result.pollAfterMs ?? fn.pollAfterMs) !== undefined && { + pollAfterMs: result.pollAfterMs ?? fn.pollAfterMs, + }), + ...(result.expiresAt !== undefined && { + expiresAt: result.expiresAt, + }), + }; +} + /** * Prepare a unified tool invocation. Validates input eagerly; builds the * ToolRunContext (defer/log/onMessage/client wired by the engine through @@ -615,13 +670,12 @@ export async function prepareUnifiedInvocation( unknown >; } catch (error) { - return { - toolCallId: toolCall.id, - toolName: toolCall.name, + return unifiedExecutionResult({ + toolCall, source, result: null, - error: error instanceof Error ? error : new Error(String(error)), - }; + error, + }); } // Yield pipeline: eventSchema validation happened in runUnifiedTool; here @@ -704,43 +758,25 @@ export async function prepareUnifiedInvocation( try { const result = await invokeRun(); - if (isDeferredHandle(result)) { - if (result.taskId.length === 0 || result.taskId.length > 256) { - throw new Error(`Tool "${toolCall.name}": ctx.defer() taskId must be 1-256 characters`); - } - return { - asyncMode: 'defer', - taskId: result.taskId, - ...(result.ack !== undefined - ? { - ack: result.ack, - } - : fn.ack !== undefined && { - ack: resolveAck(fn.ack, validatedInput), - }), - ...((result.pollAfterMs ?? fn.pollAfterMs) !== undefined && { - pollAfterMs: result.pollAfterMs ?? fn.pollAfterMs, - }), - ...(result.expiresAt !== undefined && { - expiresAt: result.expiresAt, - }), - }; - } - - return { - toolCallId: toolCall.id, - toolName: toolCall.name, - source, - result, - }; + return isDeferredHandle(result) + ? deferredInvocation({ + toolCall, + result, + fn, + validatedInput, + }) + : unifiedExecutionResult({ + toolCall, + source, + result, + }); } catch (error) { - return { - toolCallId: toolCall.id, - toolName: toolCall.name, + return unifiedExecutionResult({ + toolCall, source, result: null, - error: error instanceof Error ? error : new Error(String(error)), - }; + error, + }); } } From d7c71d1744595ef9769604c9a3cc838e833a3550 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:32:02 -0500 Subject: [PATCH 04/13] feat(agent): require JSON Schema for non-Zod tools --- packages/agent/src/lib/agent-tool.ts | 6 +-- packages/agent/src/lib/schema.ts | 11 +++++ packages/agent/src/lib/tool.ts | 43 ++++++++----------- .../unit/standard-schema-inference.test-d.ts | 13 ++++++ .../agent/tests/unit/standard-schema.test.ts | 15 ++++--- 5 files changed, 52 insertions(+), 36 deletions(-) diff --git a/packages/agent/src/lib/agent-tool.ts b/packages/agent/src/lib/agent-tool.ts index 4e583cf2..fb203e47 100644 --- a/packages/agent/src/lib/agent-tool.ts +++ b/packages/agent/src/lib/agent-tool.ts @@ -2,7 +2,7 @@ import type { OpenRouterCore } from '@openrouter/sdk/core'; import type { CallModelInput } from './async-params.js'; import { extractTextFromResponse } from './conversation-state.js'; import type { ModelResult } from './model-result.js'; -import type { InferSchemaOutput, ObjectSchema, Schema } from './schema.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'; @@ -134,11 +134,9 @@ export type AgentToolConfig< TChildTools extends readonly Tool[] = readonly Tool[], TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, -> = { +> = InputSchemaConfig & { name: TName; description?: string; - inputSchema: TInput; - inputJsonSchema?: Record; /** * Whether providers should enforce strict schema adherence for this agent * tool's generated arguments. OpenAI-style strict mode requires every diff --git a/packages/agent/src/lib/schema.ts b/packages/agent/src/lib/schema.ts index 463e0087..bbecf797 100644 --- a/packages/agent/src/lib/schema.ts +++ b/packages/agent/src/lib/schema.ts @@ -10,6 +10,17 @@ export type ObjectSchema = | $ZodObject<$ZodShape> | StandardSchemaV1>; +export type InputSchemaConfig = + TInput extends $ZodObject<$ZodShape> + ? { + inputSchema: TInput; + inputJsonSchema?: Record; + } + : { + inputSchema: TInput; + inputJsonSchema: Record; + }; + export type InferSchemaInput = TSchema extends $ZodType ? TSchema['_zod']['input'] : TSchema extends StandardSchemaV1 diff --git a/packages/agent/src/lib/tool.ts b/packages/agent/src/lib/tool.ts index cbfec7f0..2a5ae01b 100644 --- a/packages/agent/src/lib/tool.ts +++ b/packages/agent/src/lib/tool.ts @@ -3,7 +3,7 @@ import type { RequestOptions } from '@openrouter/sdk/lib/sdks'; import { agentToolBuilder } from './agent-tool.js'; import type { CallModelInput } from './async-params.js'; import type { ModelResult } from './model-result.js'; -import type { InferSchemaOutput, ObjectSchema, Schema } from './schema.js'; +import type { InferSchemaOutput, InputSchemaConfig, ObjectSchema, Schema } from './schema.js'; import { TASK_TOOL_NAME } from './tool-check.js'; import type { TaskLogLimits } from './tool-task.js'; import type { @@ -44,11 +44,9 @@ type RegularToolConfigWithOutput< TOutput extends Schema, TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, -> = { +> = InputSchemaConfig & { name: TName; description?: string; - inputSchema: TInput; - inputJsonSchema?: Record; outputSchema: TOutput; eventSchema?: undefined; /** Strict schema adherence for tool-call generation — see {@link BaseToolFunction.strict} */ @@ -79,11 +77,9 @@ type RegularToolConfigWithoutOutput< TReturn, TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, -> = { +> = InputSchemaConfig & { name: TName; description?: string; - inputSchema: TInput; - inputJsonSchema?: Record; outputSchema?: undefined; eventSchema?: undefined; /** Strict schema adherence for tool-call generation — see {@link BaseToolFunction.strict} */ @@ -115,11 +111,9 @@ type GeneratorToolConfig< TOutput extends Schema, TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, -> = { +> = InputSchemaConfig & { name: TName; description?: string; - inputSchema: TInput; - inputJsonSchema?: Record; eventSchema: TEvent; outputSchema: TOutput; /** Strict schema adherence for tool-call generation — see {@link BaseToolFunction.strict} */ @@ -145,11 +139,12 @@ type GeneratorToolConfig< /** * Configuration for a manual tool (execute: false, no eventSchema or outputSchema) */ -type ManualToolConfig = { +type ManualToolConfig< + TInput extends ObjectSchema, + TCtx extends ObjectSchema = ObjectSchema, +> = InputSchemaConfig & { name: string; // Manual tools don't use TName since they have no execute description?: string; - inputSchema: TInput; - inputJsonSchema?: Record; /** Strict schema adherence for tool-call generation — see {@link BaseToolFunction.strict} */ strict?: boolean | null; /** Zod schema declaring the context data this tool needs */ @@ -181,11 +176,9 @@ type HITLToolConfig< TOutput extends Schema, TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, -> = { +> = InputSchemaConfig & { name: TName; description?: string; - inputSchema: TInput; - inputJsonSchema?: Record; /** * Required for HITL tools. Used to validate both the `onToolCalled` return * value (when non-null) and the caller-supplied response that comes back via @@ -225,12 +218,11 @@ type HITLToolConfig< */ type ToolConfigWithSharedContext< TShared extends Record, + TInput extends ObjectSchema, TCtx extends ObjectSchema = ObjectSchema, -> = { +> = InputSchemaConfig & { name: string; description?: string; - inputSchema: ObjectSchema; - inputJsonSchema?: Record; outputSchema?: Schema; eventSchema?: Schema; /** Strict schema adherence for tool-call generation — see {@link BaseToolFunction.strict} */ @@ -265,11 +257,9 @@ type RunToolConfigBase< TInput extends ObjectSchema, TCtx extends ObjectSchema = ObjectSchema, TName extends string = string, -> = { +> = InputSchemaConfig & { name: TName; description?: string; - inputSchema: TInput; - inputJsonSchema?: Record; /** Never present on run configs — keeps them disjoint from legacy overloads. */ execute?: undefined; onToolCalled?: undefined; @@ -495,9 +485,10 @@ export function tool< // When a non-ZodObject type is provided as the first generic, // the specific overloads above won't match (constraint mismatch), // so TypeScript falls through to this catch-all. -export function tool>( - config: ToolConfigWithSharedContext, -): Tool; +export function tool< + TShared extends Record, + TInput extends ObjectSchema = ObjectSchema, +>(config: ToolConfigWithSharedContext): Tool; // Implementation export function tool( @@ -508,7 +499,7 @@ export function tool( | HITLToolConfig | RunToolConfigWithOutput | SyncRunToolConfigWithoutOutput - | ToolConfigWithSharedContext>, + | ToolConfigWithSharedContext, ObjectSchema>, ): Tool { // 'shared' is reserved for shared context — forbid it as a tool name if (config.name === SHARED_CONTEXT_KEY) { diff --git a/packages/agent/tests/unit/standard-schema-inference.test-d.ts b/packages/agent/tests/unit/standard-schema-inference.test-d.ts index cf165fe3..6abb69f5 100644 --- a/packages/agent/tests/unit/standard-schema-inference.test-d.ts +++ b/packages/agent/tests/unit/standard-schema-inference.test-d.ts @@ -4,6 +4,18 @@ import { z } from 'zod/v4'; import { tool } from '../../src/lib/tool.js'; import type { InferToolInput } from '../../src/lib/tool-types.js'; +const valibotInputSchema = v.object({ + value: v.string(), +}); + +// @ts-expect-error non-Zod input schemas require the provider-facing JSON Schema +const missingInputJsonSchema = tool({ + name: 'missing_json_schema', + inputSchema: valibotInputSchema, + execute: ({ value }) => value, +}); +void missingInputJsonSchema; + const standardTool = tool({ name: 'standard', inputSchema: v.object({ @@ -60,6 +72,7 @@ expectTypeOf>().toEqualTypeOf<{ value: number; }>(); +// Zod keeps its built-in JSON Schema conversion and needs no inputJsonSchema. const zodTool = tool({ name: 'zod', inputSchema: z.object({ diff --git a/packages/agent/tests/unit/standard-schema.test.ts b/packages/agent/tests/unit/standard-schema.test.ts index 7d50373e..eab20155 100644 --- a/packages/agent/tests/unit/standard-schema.test.ts +++ b/packages/agent/tests/unit/standard-schema.test.ts @@ -163,12 +163,15 @@ describe('Standard Schema tool support', () => { ).not.toHaveProperty('~standard'); }); - it('requires raw JSON Schema for non-Zod input validators', () => { - const valibotTool = tool({ - name: 'valibot_tool', - inputSchema, - execute: () => null, - }); + it('requires raw JSON Schema for non-Zod input validators at runtime', () => { + const valibotTool = { + type: 'function', + function: { + name: 'valibot_tool', + inputSchema, + execute: () => null, + }, + } as unknown as Tool; expect(() => convertToolsToAPIFormat([ From 613454144e33a20f37683ec3ca32dd0c033fb455 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:32:16 -0500 Subject: [PATCH 05/13] refactor(agent): deduplicate hook result validation --- packages/agent/src/lib/hooks-emit.ts | 69 +++++++++++----------------- 1 file changed, 28 insertions(+), 41 deletions(-) diff --git a/packages/agent/src/lib/hooks-emit.ts b/packages/agent/src/lib/hooks-emit.ts index 2b5edfcf..f2c6fb4f 100644 --- a/packages/agent/src/lib/hooks-emit.ts +++ b/packages/agent/src/lib/hooks-emit.ts @@ -128,9 +128,8 @@ export async function executeHandlerChain( try { const returnValue = await entry.handler(currentPayload, context); - const outcome = isZodSchema(options.resultSchema) - ? classifyZodHandlerReturn(returnValue, i, options) - : await classifyHandlerReturn(returnValue, i, options); + const classified = classifyHandlerReturn(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. @@ -227,29 +226,30 @@ type HandlerReturnOutcome = result: R; }; -function classifyZodHandlerReturn( +type HandlerValidation = + | { + success: true; + data: unknown; + } + | { + success: false; + error: Error; + }; + +function validateHandlerReturn( + schema: Schema, returnValue: unknown, +): HandlerValidation | Promise { + return isZodSchema(schema) + ? safeParse(schema, returnValue) + : safeValidateSchema(schema, returnValue); +} + +function validationOutcome( + validation: HandlerValidation, index: number, options: ExecuteChainOptions, ): HandlerReturnOutcome { - if (isAsyncOutput(returnValue)) { - return { - kind: 'async', - trackedWork: trackAsyncWork(returnValue, options.hookName, options.onAsyncTimeout), - }; - } - if (returnValue === undefined || returnValue === null) { - return { - kind: 'skip', - }; - } - if (!options.resultSchema || !isZodSchema(options.resultSchema)) { - return { - kind: 'result', - 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}`, @@ -280,11 +280,11 @@ function classifyZodHandlerReturn( * with .transform() / .default() / .catch() / .coerce -- so downstream * callers see transformed values. Validation failure in strict mode throws. */ -async function classifyHandlerReturn( +function classifyHandlerReturn( returnValue: unknown, index: number, options: ExecuteChainOptions, -): Promise> { +): HandlerReturnOutcome | Promise> { if (isAsyncOutput(returnValue)) { return { kind: 'async', @@ -302,23 +302,10 @@ async function classifyHandlerReturn( result: returnValue as R, }; } - const validation = await safeValidateSchema(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(result, index, options)) + : validationOutcome(validation, index, options); } /** From f13796da53742dbe9e99782e6bc07a7d0644c1c4 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:01:37 -0500 Subject: [PATCH 06/13] feat(agent): use Standard JSON Schema trait --- packages/agent/README.md | 19 +-- packages/agent/package.json | 1 + packages/agent/src/lib/schema.ts | 45 ++++-- packages/agent/src/lib/tool-executor.ts | 7 +- .../unit/standard-schema-inference.test-d.ts | 10 ++ .../agent/tests/unit/standard-schema.test.ts | 129 +++++++++++++++++- pnpm-lock.yaml | 12 ++ 7 files changed, 200 insertions(+), 23 deletions(-) diff --git a/packages/agent/README.md b/packages/agent/README.md index a57ad939..ebeadf2a 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -155,26 +155,27 @@ emit per model call, with `turnType`/`turnNumber`) or read each round's 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). -Zod remains the zero-config path: the agent uses Zod's validator and JSON Schema converter directly. Standard Schema defines validation and type inference, but not JSON Schema conversion, so non-Zod input validators must also provide the raw JSON Schema sent to the model: +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: v.object({ query: v.string() }), - inputJsonSchema: { - type: 'object', - properties: { query: { type: 'string' } }, - required: ['query'], - additionalProperties: false, - }, + inputSchema: toStandardJsonSchema(v.object({ query: v.string() })), outputSchema: v.object({ results: v.array(v.string()) }), execute: async ({ query }) => ({ results: await search(query) }), }); ``` -`inputJsonSchema` is only needed for `inputSchema`: output, event, and context schemas are validated locally and never sent to the model. The agent sanitizes both 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. +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: diff --git a/packages/agent/package.json b/packages/agent/package.json index 72a6379a..d66ebe6c 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -153,6 +153,7 @@ "zod": "^4.0.0" }, "devDependencies": { + "@valibot/to-json-schema": "^1.7.1", "valibot": "^1.4.2" } } diff --git a/packages/agent/src/lib/schema.ts b/packages/agent/src/lib/schema.ts index bbecf797..97e0d88e 100644 --- a/packages/agent/src/lib/schema.ts +++ b/packages/agent/src/lib/schema.ts @@ -1,4 +1,4 @@ -import type { StandardSchemaV1 } from '@standard-schema/spec'; +import type { StandardJSONSchemaV1, StandardSchemaV1 } from '@standard-schema/spec'; import * as z4 from 'zod/v4'; import type { $ZodObject, $ZodShape, $ZodType, infer as zodInfer } from 'zod/v4/core'; @@ -10,16 +10,15 @@ export type ObjectSchema = | $ZodObject<$ZodShape> | StandardSchemaV1>; -export type InputSchemaConfig = - TInput extends $ZodObject<$ZodShape> - ? { - inputSchema: TInput; - inputJsonSchema?: Record; - } - : { - inputSchema: TInput; - inputJsonSchema: Record; - }; +export type InputSchemaConfig = { + inputSchema: TInput; +} & (TInput extends $ZodObject<$ZodShape> | StandardJSONSchemaV1 + ? { + inputJsonSchema?: Record; + } + : { + inputJsonSchema: Record; + }); export type InferSchemaInput = TSchema extends $ZodType ? TSchema['_zod']['input'] @@ -63,6 +62,30 @@ export function isZodSchema(schema: unknown): schema is $ZodType { ); } +export function tryStandardJsonSchema( + schema: unknown, + target: StandardJSONSchemaV1.Target, +): Record | undefined { + if (typeof schema !== 'object' || schema === null || !('~standard' in schema)) { + return undefined; + } + const standard = schema['~standard'] as { + jsonSchema?: { + input?: unknown; + }; + }; + if (typeof standard.jsonSchema?.input !== 'function') { + return undefined; + } + try { + return (standard.jsonSchema.input as StandardJSONSchemaV1.Converter['input'])({ + target, + }); + } catch { + return undefined; + } +} + function isStandardSchema(schema: unknown): schema is StandardSchemaV1 { if (typeof schema !== 'object' || schema === null || !('~standard' in schema)) { return false; diff --git a/packages/agent/src/lib/tool-executor.ts b/packages/agent/src/lib/tool-executor.ts index 3e4aa573..3008f20f 100644 --- a/packages/agent/src/lib/tool-executor.ts +++ b/packages/agent/src/lib/tool-executor.ts @@ -6,6 +6,7 @@ import { isZodSchema, StandardSchemaError, safeValidateSchema, + tryStandardJsonSchema, validateSchema, validateSchemaSync, } from './schema.js'; @@ -107,8 +108,12 @@ export function convertSchemaToJsonSchema( if (jsonSchema) { return sanitizeJsonSchema(jsonSchema); } + const standardJsonSchema = tryStandardJsonSchema(schema, 'draft-07'); + if (standardJsonSchema) { + return sanitizeJsonSchema(standardJsonSchema); + } throw new Error( - 'Non-Zod inputSchema requires inputJsonSchema because Standard Schema does not define JSON Schema conversion.', + 'Non-Zod inputSchema must implement StandardJSONSchemaV1 or provide inputJsonSchema.', ); } diff --git a/packages/agent/tests/unit/standard-schema-inference.test-d.ts b/packages/agent/tests/unit/standard-schema-inference.test-d.ts index 6abb69f5..f92ea63e 100644 --- a/packages/agent/tests/unit/standard-schema-inference.test-d.ts +++ b/packages/agent/tests/unit/standard-schema-inference.test-d.ts @@ -1,3 +1,4 @@ +import { toStandardJsonSchema } from '@valibot/to-json-schema'; import * as v from 'valibot'; import { expectTypeOf } from 'vitest'; import { z } from 'zod/v4'; @@ -16,6 +17,15 @@ const missingInputJsonSchema = tool({ }); void missingInputJsonSchema; +const traitTool = tool({ + name: 'standard_json_schema_trait', + inputSchema: toStandardJsonSchema(valibotInputSchema), + execute: ({ value }) => value, +}); +expectTypeOf(traitTool.function.execute).parameter(0).toEqualTypeOf<{ + value: string; +}>(); + const standardTool = tool({ name: 'standard', inputSchema: v.object({ diff --git a/packages/agent/tests/unit/standard-schema.test.ts b/packages/agent/tests/unit/standard-schema.test.ts index eab20155..741c82e7 100644 --- a/packages/agent/tests/unit/standard-schema.test.ts +++ b/packages/agent/tests/unit/standard-schema.test.ts @@ -1,4 +1,5 @@ -import type { StandardSchemaV1 } from '@standard-schema/spec'; +import type { StandardJSONSchemaV1, StandardSchemaV1 } from '@standard-schema/spec'; +import { toStandardJsonSchema } from '@valibot/to-json-schema'; import * as v from 'valibot'; import { describe, expect, it } from 'vitest'; import { z } from 'zod/v4'; @@ -135,6 +136,130 @@ describe('Standard Schema tool support', () => { ); }); + it('converts the StandardJSONSchemaV1 trait without inputJsonSchema', () => { + const schema = toStandardJsonSchema(inputSchema); + const valibotTool = tool({ + name: 'standard_json_schema', + inputSchema: schema, + execute: () => null, + }); + + const [apiTool] = convertToolsToAPIFormat([ + valibotTool, + ]); + expect(apiTool).toMatchObject({ + parameters: { + type: 'object', + required: [ + 'name', + ], + }, + }); + }); + + it('falls through when the trait converter throws', () => { + const schema = { + ...inputSchema, + '~standard': { + ...inputSchema['~standard'], + jsonSchema: { + input: () => { + throw new Error('not convertible'); + }, + output: () => { + throw new Error('not convertible'); + }, + }, + }, + } satisfies typeof inputSchema & StandardJSONSchemaV1; + const valibotTool = tool({ + name: 'throwing_standard_json_schema', + inputSchema: schema, + execute: () => null, + }); + + expect(() => + convertToolsToAPIFormat([ + valibotTool, + ]), + ).toThrow('must implement StandardJSONSchemaV1 or provide inputJsonSchema'); + }); + + it('accepts explicit inputJsonSchema when the trait converter would throw', () => { + const schema = { + ...inputSchema, + '~standard': { + ...inputSchema['~standard'], + jsonSchema: { + input: () => { + throw new Error('not convertible'); + }, + output: () => { + throw new Error('not convertible'); + }, + }, + }, + } satisfies typeof inputSchema & StandardJSONSchemaV1; + const valibotTool = tool({ + name: 'throwing_standard_json_schema', + inputSchema: schema, + inputJsonSchema, + execute: () => null, + }); + + const [apiTool] = convertToolsToAPIFormat([ + valibotTool, + ]); + expect(apiTool).toMatchObject({ + parameters: { + required: [ + 'name', + ], + }, + }); + }); + + it('prefers explicit inputJsonSchema over the trait', () => { + let converted = false; + const schema = { + ...inputSchema, + '~standard': { + ...inputSchema['~standard'], + jsonSchema: { + input: () => { + converted = true; + return { + type: 'object', + title: 'trait', + }; + }, + output: () => ({ + type: 'object', + }), + }, + }, + } satisfies typeof inputSchema & StandardJSONSchemaV1; + const valibotTool = tool({ + name: 'overridden_standard_json_schema', + inputSchema: schema, + inputJsonSchema: { + type: 'object', + title: 'explicit', + }, + execute: () => null, + }); + + const [apiTool] = convertToolsToAPIFormat([ + valibotTool, + ]); + expect(apiTool).toMatchObject({ + parameters: { + title: 'explicit', + }, + }); + expect(converted).toBe(false); + }); + it('uses and sanitizes the explicit JSON Schema escape hatch', () => { const valibotTool = tool({ name: 'valibot_tool', @@ -177,7 +302,7 @@ describe('Standard Schema tool support', () => { convertToolsToAPIFormat([ valibotTool, ]), - ).toThrow('requires inputJsonSchema'); + ).toThrow('must implement StandardJSONSchemaV1 or provide inputJsonSchema'); }); it('awaits async Standard Schema validation', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ad71f21..34723e92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: specifier: ^4.0.0 version: 4.3.6 devDependencies: + '@valibot/to-json-schema': + specifier: ^1.7.1 + version: 1.7.1(valibot@1.4.2(typescript@5.8.3)) valibot: specifier: ^1.4.2 version: 1.4.2(typescript@5.8.3) @@ -585,6 +588,11 @@ packages: '@types/node@22.19.15': resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + '@valibot/to-json-schema@1.7.1': + resolution: {integrity: sha512-3qkmU6KXWh8GIThEAW3kuRHPQBMjWkKy+Ppz3WkUucx53DTpOa6siMn4xDGSOhlVyMrDaJTCTMLYPZVAIk1P0A==} + peerDependencies: + valibot: ^1.4.0 + '@vitest/coverage-v8@4.1.10': resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} peerDependencies: @@ -1657,6 +1665,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@valibot/to-json-schema@1.7.1(valibot@1.4.2(typescript@5.8.3))': + dependencies: + valibot: 1.4.2(typescript@5.8.3) + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': dependencies: '@bcoe/v8-coverage': 1.0.2 From 4354fa10bfe9106974ca42c5cce3b8be2485acbd Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:16:36 -0500 Subject: [PATCH 07/13] fix(agent): harden sync Standard Schema context validation - reject thenable-returning validators (not just instanceof Promise) in validateSchemaSync so async validators can't silently pass setContext - restore Zod parity for Standard Schema context updates: filter unknown keys and store the validator's (possibly transformed) output values - reword async-validator error; fix stale isVoidSchema doc; use the ObjectSchema alias for sharedContextSchema --- packages/agent/src/lib/hooks-manager.ts | 4 +- packages/agent/src/lib/model-result.ts | 7 +- packages/agent/src/lib/schema.ts | 13 ++-- packages/agent/src/lib/tool-context.ts | 24 ++++-- .../agent/tests/unit/standard-schema.test.ts | 78 +++++++++++++++++++ 5 files changed, 108 insertions(+), 18 deletions(-) diff --git a/packages/agent/src/lib/hooks-manager.ts b/packages/agent/src/lib/hooks-manager.ts index abb0a2bb..cce88e9c 100644 --- a/packages/agent/src/lib/hooks-manager.ts +++ b/packages/agent/src/lib/hooks-manager.ts @@ -368,8 +368,8 @@ export function getInternalRegistrar(manager: HooksManager): InternalRegistrar { * without tripping validation -- for built-ins and custom hooks alike. * * Implementation note: `schema._zod.def.type` is zod v4's designated - * introspection surface for library authors (every `Schema` carries a - * `_zod: SchemaInternals` with a stable `def.type` discriminator). A string + * introspection surface for library authors (every zod `$ZodType` carries a + * `_zod: $ZodTypeInternals` with a stable `def.type` discriminator). A string * check is deliberately preferred over `instanceof $ZodVoid`, which breaks * across duplicated zod module instances (dual-package hazard) and mixed * zod/v4 vs zod/v4-mini usage. Behavior is pinned by tests in diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index 6aa0cea6..e297a338 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -3,8 +3,6 @@ import { betaResponsesSend } from '@openrouter/sdk/funcs/betaResponsesSend'; import type { EventStream } from '@openrouter/sdk/lib/event-streams'; import type { RequestOptions } from '@openrouter/sdk/lib/sdks'; import type * as models from '@openrouter/sdk/models'; -import type { StandardSchemaV1 } from '@standard-schema/spec'; -import type { $ZodObject, $ZodShape } from 'zod/v4/core'; import type { CallModelInput, ResolvedCallModelInput } from './async-params.js'; import { hasAsyncFunctions, resolveAsyncFunctions } from './async-params.js'; import type { SettledToolTask, TaskToolInput, ToolSemaphore, ToolTaskMode } from './async-tools.js'; @@ -55,6 +53,7 @@ import { executeNextTurnParamsFunctions, } from './next-turn-params.js'; import { ReusableReadableStream } from './reusable-stream.js'; +import type { ObjectSchema } from './schema.js'; import { isStopConditionMet } from './stop-conditions.js'; import type { ItemInProgress, StreamableOutputItem } from './stream-transformers.js'; import { @@ -439,8 +438,8 @@ export interface GetResponseOptions< state?: StateAccessor; /** Typed context data passed to tools via contextSchema. `shared` key for shared context. */ context?: ContextInput>; - /** Zod schema for shared context validation */ - sharedContextSchema?: $ZodObject<$ZodShape> | StandardSchemaV1>; + /** Schema (Zod or Standard Schema v1) for shared context validation */ + sharedContextSchema?: ObjectSchema; /** * Call-level approval check - overrides tool-level requireApproval setting diff --git a/packages/agent/src/lib/schema.ts b/packages/agent/src/lib/schema.ts index 97e0d88e..9ea6afa4 100644 --- a/packages/agent/src/lib/schema.ts +++ b/packages/agent/src/lib/schema.ts @@ -125,15 +125,18 @@ export function validateSchemaSync( throw new Error('Invalid Standard Schema v1 validator provided'); } const result = schema['~standard'].validate(value); - if (result instanceof Promise) { + // Thenable check, not `instanceof Promise`: cross-realm promises and custom + // thenables must be rejected here too, or they'd pass as a silent success. + if (result !== null && typeof (result as PromiseLike).then === 'function') { throw new Error( - 'Async Standard Schema validators are not supported for synchronous context updates', + 'Async Standard Schema validators are not supported in synchronous validation paths', ); } - if (result.issues) { - throw new StandardSchemaError(result.issues); + const syncResult = result as StandardSchemaV1.Result; + if (syncResult.issues) { + throw new StandardSchemaError(syncResult.issues); } - return result.value as InferSchemaOutput; + return syncResult.value as InferSchemaOutput; } export async function safeValidateSchema( diff --git a/packages/agent/src/lib/tool-context.ts b/packages/agent/src/lib/tool-context.ts index be2f6266..b158f9f8 100644 --- a/packages/agent/src/lib/tool-context.ts +++ b/packages/agent/src/lib/tool-context.ts @@ -1,6 +1,6 @@ import * as z4 from 'zod/v4'; import type { ObjectSchema } from './schema.js'; -import { validateSchemaSync } from './schema.js'; +import { isZodSchema, validateSchemaSync } from './schema.js'; import type { ToolExecuteContext, TurnContext } from './tool-types.js'; import { SHARED_CONTEXT_KEY } from './tool-types.js'; @@ -108,20 +108,30 @@ export class ToolContextStore { //#region buildToolExecuteContext /** - * Validate a partial update against a schema's shape, filtering to known keys - * and validating each key individually. Returns the filtered partial. + * Validate a partial update against a schema. Zod keeps the legacy per-field + * path (filter to shape keys, parse each individually). Standard Schema + * validators only see the merged object, so we validate the merge and store + * the validator's output for the partial's keys — preserving Zod's + * unknown-key filtering and applying any transforms. */ function validatePartialAgainstSchema( partial: Record, current: Record, schema: ObjectSchema, ): Record { - if (!('_zod' in schema)) { - validateSchemaSync(schema, { + if (!isZodSchema(schema)) { + const validated = validateSchemaSync(schema, { ...current, ...partial, - }); - return partial; + }) as Record; + return Object.fromEntries( + Object.keys(partial) + .filter((key) => key in validated) + .map((key) => [ + key, + validated[key], + ]), + ); } const shape = schema._zod.def.shape; diff --git a/packages/agent/tests/unit/standard-schema.test.ts b/packages/agent/tests/unit/standard-schema.test.ts index 741c82e7..fc2cbcad 100644 --- a/packages/agent/tests/unit/standard-schema.test.ts +++ b/packages/agent/tests/unit/standard-schema.test.ts @@ -488,4 +488,82 @@ describe('Standard Schema tool support', () => { greeting: 'Hello LUKE', }); }); + + it('rejects thenable-returning validators in synchronous context mutation', async () => { + const thenableSchema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'test', + // A custom thenable (not a Promise instance) must still be rejected. + validate: () => ({ + // biome-ignore lint/suspicious/noThenProperty: intentionally testing thenable rejection + then: (resolve: (result: { value: unknown }) => void) => + resolve({ + value: {}, + }), + }), + types: undefined, + }, + }; + const store = new ToolContextStore({ + thenable: { + token: 'initial', + }, + }); + const ctx = buildToolExecuteContext(context, store, 'thenable', thenableSchema, undefined, { + contextValidated: true, + }); + + expect(() => + ctx.setContext({ + token: 'updated', + }), + ).toThrow('Async Standard Schema validators are not supported'); + + // The same validator is fine on the async execution path. + const asyncTool = tool({ + name: 'thenable_tool', + inputSchema: thenableSchema, + inputJsonSchema: { + type: 'object', + }, + execute: () => 'ok', + }); + const result = await executeRegularTool(asyncTool, call('thenable_tool', {}), context); + expect(result.result).toBe('ok'); + }); + + it('filters unknown keys and stores transformed values on Standard Schema context updates', () => { + const store = new ToolContextStore({ + standard: { + count: 1, + }, + }); + const ctx = buildToolExecuteContext< + 'standard', + { + count: number; + } + >( + context, + store, + 'standard', + v.object({ + count: v.pipe( + v.number(), + v.transform((count) => count * 2), + ), + }), + ); + + ctx.setContext({ + count: 5, + junk: 'dropped', + } as unknown as { + count: number; + }); + expect(ctx.local).toEqual({ + count: 10, + }); + }); }); From 864f45bb6dcc7f2a9f2b6a9f1a7256044d6999a2 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:21:04 -0500 Subject: [PATCH 08/13] fix(agent): keep model-result below the sentrux god-file threshold Importing ObjectSchema from ./schema.js added a 16th internal edge to model-result.ts, tripping sentrux's no_god_files gate. Revert to the external type imports (unresolved, uncounted) to stay at fan-out 15. --- packages/agent/src/lib/model-result.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index e297a338..6aa0cea6 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -3,6 +3,8 @@ import { betaResponsesSend } from '@openrouter/sdk/funcs/betaResponsesSend'; import type { EventStream } from '@openrouter/sdk/lib/event-streams'; import type { RequestOptions } from '@openrouter/sdk/lib/sdks'; import type * as models from '@openrouter/sdk/models'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import type { $ZodObject, $ZodShape } from 'zod/v4/core'; import type { CallModelInput, ResolvedCallModelInput } from './async-params.js'; import { hasAsyncFunctions, resolveAsyncFunctions } from './async-params.js'; import type { SettledToolTask, TaskToolInput, ToolSemaphore, ToolTaskMode } from './async-tools.js'; @@ -53,7 +55,6 @@ import { executeNextTurnParamsFunctions, } from './next-turn-params.js'; import { ReusableReadableStream } from './reusable-stream.js'; -import type { ObjectSchema } from './schema.js'; import { isStopConditionMet } from './stop-conditions.js'; import type { ItemInProgress, StreamableOutputItem } from './stream-transformers.js'; import { @@ -438,8 +439,8 @@ export interface GetResponseOptions< state?: StateAccessor; /** Typed context data passed to tools via contextSchema. `shared` key for shared context. */ context?: ContextInput>; - /** Schema (Zod or Standard Schema v1) for shared context validation */ - sharedContextSchema?: ObjectSchema; + /** Zod schema for shared context validation */ + sharedContextSchema?: $ZodObject<$ZodShape> | StandardSchemaV1>; /** * Call-level approval check - overrides tool-level requireApproval setting From 1e98c46e8503a4e0b5b2d270bac0dbff145881a5 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:30:55 -0500 Subject: [PATCH 09/13] fix(agent): address Devin review on explicit schemas, key filtering, and error propagation - convertSchemaToJsonSchema: explicit inputJsonSchema now wins for Zod too (z4.toJSONSchema throws on unrepresentable constructs, where the escape hatch is the only way through) - validatePartialAgainstSchema: Object.hasOwn instead of `in` so prototype-named keys (constructor, toString) are filtered again - unifiedExecutionResult: normalize caught values eagerly so `throw undefined` / Promise.reject() still surface as tool errors - isVoidSchema: probe Standard Schema validators with undefined so v.void() custom hooks skip result validation like z.void() --- packages/agent/src/lib/hooks-manager.ts | 23 ++++- packages/agent/src/lib/tool-context.ts | 4 +- packages/agent/src/lib/tool-executor.ts | 19 ++-- packages/agent/src/lib/tool-types.ts | 8 +- .../tests/unit/hooks-void-schema.test.ts | 8 ++ .../agent/tests/unit/standard-schema.test.ts | 99 +++++++++++++++++++ 6 files changed, 144 insertions(+), 17 deletions(-) diff --git a/packages/agent/src/lib/hooks-manager.ts b/packages/agent/src/lib/hooks-manager.ts index cce88e9c..b40713f3 100644 --- a/packages/agent/src/lib/hooks-manager.ts +++ b/packages/agent/src/lib/hooks-manager.ts @@ -12,7 +12,7 @@ import type { LifecycleHookContext, } from './hooks-types.js'; import type { InferSchemaInput, InferSchemaOutput, Schema } from './schema.js'; -import { isZodSchema, safeValidateSchema } from './schema.js'; +import { isZodSchema, safeValidateSchema, validateSchemaSync } from './schema.js'; //#region Types @@ -363,13 +363,13 @@ export function getInternalRegistrar(manager: HooksManager): InternalRegistrar { } /** - * Detect a `z.void()` schema (zod v4 core). Result validation is skipped for + * Detect a void-result schema. Result validation is skipped for * void-result hooks so side-effect-only handlers can return arbitrary values * without tripping validation -- for built-ins and custom hooks alike. * - * Implementation note: `schema._zod.def.type` is zod v4's designated - * introspection surface for library authors (every zod `$ZodType` carries a - * `_zod: $ZodTypeInternals` with a stable `def.type` discriminator). A string + * Zod: `schema._zod.def.type` is zod v4's designated introspection surface + * for library authors (every zod `$ZodType` carries a `_zod: + * `$ZodTypeInternals` with a stable `def.type` discriminator). A string * check is deliberately preferred over `instanceof $ZodVoid`, which breaks * across duplicated zod module instances (dual-package hazard) and mixed * zod/v4 vs zod/v4-mini usage. Behavior is pinned by tests in @@ -377,10 +377,23 @@ export function getInternalRegistrar(manager: HooksManager): InternalRegistrar { * that restructures the internals fails loudly instead of silently * re-enabling result validation on void hooks. * + * Standard Schema: no introspection surface exists, so we probe — a + * synchronous validator that accepts `undefined` (v.void(), v.undefined(), + * v.optional(...)) is treated as void. Async validators can't be probed + * here and fall back to enforced result validation. + * * Exported for the pinning tests only; NOT re-exported from the package * index and NOT part of the public API. */ export function isVoidSchema(schema: Schema): boolean { + if (!isZodSchema(schema)) { + try { + validateSchemaSync(schema, undefined); + return true; + } catch { + return false; + } + } const def = ( schema as { _zod?: { diff --git a/packages/agent/src/lib/tool-context.ts b/packages/agent/src/lib/tool-context.ts index b158f9f8..99791f64 100644 --- a/packages/agent/src/lib/tool-context.ts +++ b/packages/agent/src/lib/tool-context.ts @@ -126,7 +126,7 @@ function validatePartialAgainstSchema( }) as Record; return Object.fromEntries( Object.keys(partial) - .filter((key) => key in validated) + .filter((key) => Object.hasOwn(validated, key)) .map((key) => [ key, validated[key], @@ -136,7 +136,7 @@ function validatePartialAgainstSchema( const shape = schema._zod.def.shape; const filteredPartial = Object.fromEntries( - Object.entries(partial).filter(([key]) => key in shape), + Object.entries(partial).filter(([key]) => Object.hasOwn(shape, key)), ); for (const [key, value] of Object.entries(filteredPartial)) { const keySchema = shape[key]; diff --git a/packages/agent/src/lib/tool-executor.ts b/packages/agent/src/lib/tool-executor.ts index 3008f20f..a42ce1ba 100644 --- a/packages/agent/src/lib/tool-executor.ts +++ b/packages/agent/src/lib/tool-executor.ts @@ -102,12 +102,15 @@ export function convertSchemaToJsonSchema( schema: Schema, jsonSchema?: Record, ): Record { - if (isZodSchema(schema)) { - return convertZodToJsonSchema(schema); - } + // Explicit caller intent always wins — including for Zod, where + // z4.toJSONSchema throws on unrepresentable constructs (z.custom(), + // some transforms) and a hand-written schema is the only way through. if (jsonSchema) { return sanitizeJsonSchema(jsonSchema); } + if (isZodSchema(schema)) { + return convertZodToJsonSchema(schema); + } const standardJsonSchema = tryStandardJsonSchema(schema, 'draft-07'); if (standardJsonSchema) { return sanitizeJsonSchema(standardJsonSchema); @@ -594,7 +597,7 @@ function unifiedExecutionResult({ toolCall: ParsedToolCall; source: 'client' | 'mcp'; result: unknown; - error?: unknown; + error?: Error; }): ToolExecutionResult { return { toolCallId: toolCall.id, @@ -602,7 +605,7 @@ function unifiedExecutionResult({ source, result, ...(error !== undefined && { - error: error instanceof Error ? error : new Error(String(error)), + error, }), }; } @@ -679,7 +682,9 @@ export async function prepareUnifiedInvocation( toolCall, source, result: null, - error, + // A catch value can be `undefined` (`throw undefined`, `Promise.reject()`) + // — normalize eagerly so the failure always surfaces as an error. + error: error instanceof Error ? error : new Error(String(error)), }); } @@ -780,7 +785,7 @@ export async function prepareUnifiedInvocation( toolCall, source, result: null, - error, + error: error instanceof Error ? error : new Error(String(error)), }); } } diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index bbfb8b2c..dae57435 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -426,9 +426,11 @@ export interface BaseToolFunction< description?: string; inputSchema: TInput; /** - * JSON Schema sent to the model for non-Zod validators. Standard Schema v1 - * standardizes validation and inference, but not JSON Schema generation. - * Zod schemas continue to use z.toJSONSchema and ignore this field. + * JSON Schema sent to the model. Explicit caller intent always wins: when + * supplied, this overrides both Zod's z.toJSONSchema fast path and the + * StandardJSONSchemaV1 trait. Compile-time required for validation-only + * non-Zod input schemas (Standard Schema v1 standardizes validation and + * inference, but not JSON Schema generation). */ inputJsonSchema?: Record; /** diff --git a/packages/agent/tests/unit/hooks-void-schema.test.ts b/packages/agent/tests/unit/hooks-void-schema.test.ts index 8ee0be2e..8b11ba8a 100644 --- a/packages/agent/tests/unit/hooks-void-schema.test.ts +++ b/packages/agent/tests/unit/hooks-void-schema.test.ts @@ -26,6 +26,14 @@ describe('void schema detection (pins zod v4 internals against upgrades)', () => expect(isVoidSchema(z4.null())).toBe(false); }); + it('probes Standard Schema validators by validating undefined', async () => { + const v = await import('valibot'); + expect(isVoidSchema(v.void())).toBe(true); + expect(isVoidSchema(v.undefined())).toBe(true); + expect(isVoidSchema(v.string())).toBe(false); + expect(isVoidSchema(v.object({}))).toBe(false); + }); + it('the _zod.def.type introspection surface exists on v4 schemas', () => { // Canary: if this fails after a zod upgrade, isVoidSchema needs a new // detection strategy. Parse the schema object itself so the shape is diff --git a/packages/agent/tests/unit/standard-schema.test.ts b/packages/agent/tests/unit/standard-schema.test.ts index fc2cbcad..8c251659 100644 --- a/packages/agent/tests/unit/standard-schema.test.ts +++ b/packages/agent/tests/unit/standard-schema.test.ts @@ -9,6 +9,7 @@ import { convertToolsToAPIFormat, executeGeneratorTool, executeRegularTool, + executeTool, formatToolExecutionError, } from '../../src/lib/tool-executor.js'; import type { ParsedToolCall, Tool, TurnContext } from '../../src/lib/tool-types.js'; @@ -566,4 +567,102 @@ describe('Standard Schema tool support', () => { count: 10, }); }); + + it('filters prototype-named keys from context updates', () => { + const zodStore = new ToolContextStore({ + zod: { + token: 'a', + }, + }); + const zodCtx = buildToolExecuteContext< + 'zod', + { + token: string; + } + >( + context, + zodStore, + 'zod', + z.object({ + token: z.string(), + }), + ); + zodCtx.setContext({ + constructor: 'sneaky', + token: 'b', + } as unknown as { + token: string; + }); + expect(zodCtx.local).toEqual({ + token: 'b', + }); + + const standardStore = new ToolContextStore({ + standard: { + token: 'a', + }, + }); + const standardCtx = buildToolExecuteContext< + 'standard', + { + token: string; + } + >( + context, + standardStore, + 'standard', + v.object({ + token: v.string(), + }), + ); + standardCtx.setContext({ + toString: 'sneaky', + token: 'b', + } as unknown as { + token: string; + }); + expect(standardCtx.local).toEqual({ + token: 'b', + }); + }); + + it('prefers an explicit inputJsonSchema for Zod tools too', () => { + const zodTool = tool({ + name: 'zod_explicit', + inputSchema: z.object({ + value: z.string(), + }), + inputJsonSchema: { + type: 'object', + title: 'explicit', + }, + execute: () => null, + }); + + const [apiTool] = convertToolsToAPIFormat([ + zodTool, + ]); + expect(apiTool).toMatchObject({ + parameters: { + title: 'explicit', + }, + }); + }); + + it('reports a unified tool failure with no error value as an error', async () => { + const failing = tool({ + name: 'failing_unified', + inputSchema: z.object({}), + outputSchema: z.object({ + ok: z.boolean(), + }), + run: () => { + // biome-ignore lint: intentionally throwing no value + throw undefined; + }, + }); + + const result = await executeTool(failing, call('failing_unified', {}), context); + expect(result && 'error' in result && result.error).toBeInstanceOf(Error); + }); }); From 14dbd6f0636230a2e79e3fa0efa2e0c7851f47e2 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:43:40 -0500 Subject: [PATCH 10/13] =?UTF-8?q?fix(agent):=20address=20second=20Devin=20?= =?UTF-8?q?round=20=E2=80=94=20raw=20context=20storage,=20strict=20void=20?= =?UTF-8?q?probe,=20changeset=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validatePartialAgainstSchema: persist raw caller-supplied values (Zod parity); validator output is only used to filter unknown keys. Storing transformed output poisoned the store for type-changing validators. - isVoidSchema: a Standard Schema is void only if it accepts undefined AND rejects null/string/number/object probes, so v.any()/v.optional() keep result validation like their Zod equivalents. - changeset: add the fenced consumer example required by .agents/skills/public-api-examples. --- .changeset/fuzzy-geese-validate.md | 28 +++++++++++ packages/agent/src/lib/hooks-manager.ts | 29 +++++++---- packages/agent/src/lib/tool-context.ts | 10 ++-- .../tests/unit/hooks-void-schema.test.ts | 6 +++ .../agent/tests/unit/standard-schema.test.ts | 48 +++++++++++++++++-- 5 files changed, 105 insertions(+), 16 deletions(-) diff --git a/.changeset/fuzzy-geese-validate.md b/.changeset/fuzzy-geese-validate.md index f75a2f64..bdaf83e3 100644 --- a/.changeset/fuzzy-geese-validate.md +++ b/.changeset/fuzzy-geese-validate.md @@ -3,3 +3,31 @@ --- 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. + +```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 +}); +``` diff --git a/packages/agent/src/lib/hooks-manager.ts b/packages/agent/src/lib/hooks-manager.ts index b40713f3..ca02ad17 100644 --- a/packages/agent/src/lib/hooks-manager.ts +++ b/packages/agent/src/lib/hooks-manager.ts @@ -378,21 +378,32 @@ export function getInternalRegistrar(manager: HooksManager): InternalRegistrar { * re-enabling result validation on void hooks. * * Standard Schema: no introspection surface exists, so we probe — a - * synchronous validator that accepts `undefined` (v.void(), v.undefined(), - * v.optional(...)) is treated as void. Async validators can't be probed - * here and fall back to enforced result validation. + * synchronous validator that accepts `undefined` and rejects other value + * kinds (v.void(), v.undefined()) is treated as void. Permissive schemas + * that also accept undefined (v.any(), v.optional(...)) stay validated, + * matching the Zod treatment of z.unknown()/z.optional(). Async validators + * can't be probed here and fall back to enforced result validation. * * Exported for the pinning tests only; NOT re-exported from the package * index and NOT part of the public API. */ export function isVoidSchema(schema: Schema): boolean { if (!isZodSchema(schema)) { - try { - validateSchemaSync(schema, undefined); - return true; - } catch { - return false; - } + // Probe: a void-result validator accepts `undefined` and nothing else. + // Permissive schemas (v.any(), v.optional(...), ...) also accept + // undefined — requiring rejection of other probes keeps them validated, + // matching how z.unknown()/z.optional() are treated above. + const accepts = (value: unknown): boolean => { + try { + validateSchemaSync(schema, value); + return true; + } catch { + return false; + } + }; + return ( + accepts(undefined) && !accepts(null) && !accepts('sentinel') && !accepts(0) && !accepts({}) + ); } const def = ( schema as { diff --git a/packages/agent/src/lib/tool-context.ts b/packages/agent/src/lib/tool-context.ts index 99791f64..99bda7a8 100644 --- a/packages/agent/src/lib/tool-context.ts +++ b/packages/agent/src/lib/tool-context.ts @@ -110,9 +110,11 @@ export class ToolContextStore { /** * Validate a partial update against a schema. Zod keeps the legacy per-field * path (filter to shape keys, parse each individually). Standard Schema - * validators only see the merged object, so we validate the merge and store - * the validator's output for the partial's keys — preserving Zod's - * unknown-key filtering and applying any transforms. + * validators only see the merged object, so we validate the merge and use + * the validator's output to filter unknown keys — but persist the raw + * caller-supplied values like the Zod branch. Storing transformed output + * would let a type-changing transform poison the store: every other context + * path validates the raw stored value and discards the parse output. */ function validatePartialAgainstSchema( partial: Record, @@ -129,7 +131,7 @@ function validatePartialAgainstSchema( .filter((key) => Object.hasOwn(validated, key)) .map((key) => [ key, - validated[key], + partial[key], ]), ); } diff --git a/packages/agent/tests/unit/hooks-void-schema.test.ts b/packages/agent/tests/unit/hooks-void-schema.test.ts index 8b11ba8a..fe2bc840 100644 --- a/packages/agent/tests/unit/hooks-void-schema.test.ts +++ b/packages/agent/tests/unit/hooks-void-schema.test.ts @@ -32,6 +32,12 @@ describe('void schema detection (pins zod v4 internals against upgrades)', () => expect(isVoidSchema(v.undefined())).toBe(true); expect(isVoidSchema(v.string())).toBe(false); expect(isVoidSchema(v.object({}))).toBe(false); + // Permissive schemas accept undefined but are NOT void — result + // validation must still run, as it does for z.unknown()/z.optional(). + expect(isVoidSchema(v.any())).toBe(false); + expect(isVoidSchema(v.unknown())).toBe(false); + expect(isVoidSchema(v.optional(v.string()))).toBe(false); + expect(isVoidSchema(v.nullish(v.string()))).toBe(false); }); it('the _zod.def.type introspection surface exists on v4 schemas', () => { diff --git a/packages/agent/tests/unit/standard-schema.test.ts b/packages/agent/tests/unit/standard-schema.test.ts index 8c251659..7cd6c8b9 100644 --- a/packages/agent/tests/unit/standard-schema.test.ts +++ b/packages/agent/tests/unit/standard-schema.test.ts @@ -534,7 +534,7 @@ describe('Standard Schema tool support', () => { expect(result.result).toBe('ok'); }); - it('filters unknown keys and stores transformed values on Standard Schema context updates', () => { + it('filters unknown keys and stores raw values on Standard Schema context updates', () => { const store = new ToolContextStore({ standard: { count: 1, @@ -563,9 +563,52 @@ describe('Standard Schema tool support', () => { } as unknown as { count: number; }); + // Raw caller-supplied value is stored (Zod parity) — storing the + // transform's output would poison later merged validations. expect(ctx.local).toEqual({ - count: 10, + count: 5, + }); + }); + + it('keeps context usable after updates with a type-changing validator', async () => { + const contextSchema = v.object({ + n: v.pipe(v.string(), v.transform(Number)), + }); + const store = new ToolContextStore({ + convert: { + n: '5', + }, }); + const ctx = buildToolExecuteContext< + 'convert', + { + n: number; + } + >(context, store, 'convert', contextSchema); + ctx.setContext({ + n: '7', + } as unknown as { + n: number; + }); + + const contextTool = tool({ + name: 'convert', + inputSchema, + inputJsonSchema, + contextSchema, + execute: (_input, execCtx) => execCtx!.local.n, + }); + const result = await executeRegularTool( + contextTool, + call('convert', { + name: 'luke', + }), + context, + store, + ); + expect(result.error).toBeUndefined(); + // Context values are validated but never transformed (raw storage). + expect(result.result).toBe('7'); }); it('filters prototype-named keys from context updates', () => { @@ -657,7 +700,6 @@ describe('Standard Schema tool support', () => { ok: z.boolean(), }), run: () => { - // biome-ignore lint: intentionally throwing no value throw undefined; }, }); From 0c0bc75487932fd4f13683adf07d6f7f3011228f Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:53:20 -0500 Subject: [PATCH 11/13] fix(agent): validate ctx.log entries asynchronously for async event validators The unified log sink used validateSchemaSync, so a tool declaring an async Standard Schema event validator threw out of its own body on the first ctx.log while the same event passed via yield. Zod keeps the sync throw; non-Zod entries are validated out-of-band and forwarded on success (invalid ones dropped with a warning). --- packages/agent/src/lib/tool-executor.ts | 27 ++++++-- .../agent/tests/unit/standard-schema.test.ts | 65 +++++++++++++++++++ 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/packages/agent/src/lib/tool-executor.ts b/packages/agent/src/lib/tool-executor.ts index a42ce1ba..fd1a7a84 100644 --- a/packages/agent/src/lib/tool-executor.ts +++ b/packages/agent/src/lib/tool-executor.ts @@ -702,16 +702,33 @@ export async function prepareUnifiedInvocation( // (TaskLogEntry.kind 'text'), so a tool that declares a structured // eventSchema can still log a plain sentence. // + // Non-Zod event schemas are validated asynchronously: the sink is sync, + // but an async Standard Schema validator must not throw out of the tool + // body (the parallel yield path awaits validation). Invalid async entries + // are dropped with a warning; valid ones are forwarded once validated. + // // Object.create (not spread): the engine's runExtras exposes `taskId` as // a LIVE getter backed by the run binding — a spread would snapshot its // current value (undefined; the ToolTask doesn't exist yet). const runExtras = Object.assign(Object.create(extras?.runExtras ?? null), { log: (entry: unknown) => { - const validated = - fn.eventSchema && typeof entry !== 'string' - ? validateSchemaSync(fn.eventSchema, entry) - : entry; - onYield(validated); + if (!fn.eventSchema || typeof entry === 'string') { + onYield(entry); + return; + } + if (isZodSchema(fn.eventSchema)) { + onYield(validateSchemaSync(fn.eventSchema, entry)); + return; + } + void safeValidateSchema(fn.eventSchema, entry).then((validation) => { + if (validation.success) { + onYield(validation.data); + } else { + console.warn( + `[tool] ${fn.name}: dropping invalid log entry: ${validation.error.message}`, + ); + } + }); }, }) as NonNullable; if (contextStore && tool.function.contextSchema) { diff --git a/packages/agent/tests/unit/standard-schema.test.ts b/packages/agent/tests/unit/standard-schema.test.ts index 7cd6c8b9..122a8f41 100644 --- a/packages/agent/tests/unit/standard-schema.test.ts +++ b/packages/agent/tests/unit/standard-schema.test.ts @@ -707,4 +707,69 @@ describe('Standard Schema tool support', () => { const result = await executeTool(failing, call('failing_unified', {}), context); expect(result && 'error' in result && result.error).toBeInstanceOf(Error); }); + + it('validates logged events asynchronously for async event validators', async () => { + const asyncEventSchema: StandardSchemaV1< + unknown, + { + progress: number; + } + > = { + '~standard': { + version: 1, + vendor: 'test', + validate: async (value) => { + const progress = ( + value as { + progress?: unknown; + } + ).progress; + return typeof progress === 'number' + ? { + value: { + progress, + }, + } + : { + issues: [ + { + message: 'Expected progress', + }, + ], + }; + }, + types: undefined, + }, + }; + const logging = tool({ + name: 'logging_unified', + inputSchema: z.object({}), + eventSchema: asyncEventSchema, + outputSchema: z.object({ + ok: z.boolean(), + }), + run: (_params, ctx) => { + ctx?.log({ + progress: 1, + }); + return { + ok: true, + }; + }, + }); + + const preliminary: unknown[] = []; + const result = await executeTool(logging, call('logging_unified', {}), context, (_id, event) => + preliminary.push(event), + ); + // The log sink validates async validators out-of-band; flush it. + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(result && 'error' in result && result.error).toBeFalsy(); + expect(preliminary).toEqual([ + { + progress: 1, + }, + ]); + }); }); From 0f2e8dd52c481eb74e649f5fc9a6a61becae299e Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:05:52 -0500 Subject: [PATCH 12/13] fix(agent): fail safe on non-Zod void-hook detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Value probing cannot distinguish 'accepts only undefined' from 'undefined | T' (v.optional(v.object(...)) rejects every finite sentinel set), so the sentinel probe could silently disable result validation for ordinary optional schemas. Non-Zod result schemas are now always validated: side-effect-only handlers returning undefined still pass a v.void() result schema, and handlers returning real values on a void hook are warned — stricter than Zod, but sound. --- packages/agent/src/lib/hooks-manager.ts | 31 ++++++------------- .../tests/unit/hooks-void-schema.test.ts | 14 ++++----- 2 files changed, 15 insertions(+), 30 deletions(-) diff --git a/packages/agent/src/lib/hooks-manager.ts b/packages/agent/src/lib/hooks-manager.ts index ca02ad17..af9bdf18 100644 --- a/packages/agent/src/lib/hooks-manager.ts +++ b/packages/agent/src/lib/hooks-manager.ts @@ -12,7 +12,7 @@ import type { LifecycleHookContext, } from './hooks-types.js'; import type { InferSchemaInput, InferSchemaOutput, Schema } from './schema.js'; -import { isZodSchema, safeValidateSchema, validateSchemaSync } from './schema.js'; +import { isZodSchema, safeValidateSchema } from './schema.js'; //#region Types @@ -377,33 +377,20 @@ export function getInternalRegistrar(manager: HooksManager): InternalRegistrar { * that restructures the internals fails loudly instead of silently * re-enabling result validation on void hooks. * - * Standard Schema: no introspection surface exists, so we probe — a - * synchronous validator that accepts `undefined` and rejects other value - * kinds (v.void(), v.undefined()) is treated as void. Permissive schemas - * that also accept undefined (v.any(), v.optional(...)) stay validated, - * matching the Zod treatment of z.unknown()/z.optional(). Async validators - * can't be probed here and fall back to enforced result validation. + * Standard Schema: the spec has no introspection surface, and probing + * values can't distinguish "accepts only undefined" from "undefined | T" + * (v.optional(v.object(...)) rejects every finite sentinel set). So we fail + * safe: non-Zod result schemas are ALWAYS validated. A side-effect-only + * handler that returns undefined still passes a v.void() result schema; + * handlers returning real values on a void hook get warned/thrown, which is + * stricter than the Zod path but sane. * * Exported for the pinning tests only; NOT re-exported from the package * index and NOT part of the public API. */ export function isVoidSchema(schema: Schema): boolean { if (!isZodSchema(schema)) { - // Probe: a void-result validator accepts `undefined` and nothing else. - // Permissive schemas (v.any(), v.optional(...), ...) also accept - // undefined — requiring rejection of other probes keeps them validated, - // matching how z.unknown()/z.optional() are treated above. - const accepts = (value: unknown): boolean => { - try { - validateSchemaSync(schema, value); - return true; - } catch { - return false; - } - }; - return ( - accepts(undefined) && !accepts(null) && !accepts('sentinel') && !accepts(0) && !accepts({}) - ); + return false; } const def = ( schema as { diff --git a/packages/agent/tests/unit/hooks-void-schema.test.ts b/packages/agent/tests/unit/hooks-void-schema.test.ts index fe2bc840..7fc43872 100644 --- a/packages/agent/tests/unit/hooks-void-schema.test.ts +++ b/packages/agent/tests/unit/hooks-void-schema.test.ts @@ -26,18 +26,16 @@ describe('void schema detection (pins zod v4 internals against upgrades)', () => expect(isVoidSchema(z4.null())).toBe(false); }); - it('probes Standard Schema validators by validating undefined', async () => { + it('never treats Standard Schema validators as void (fail-safe)', async () => { + // The spec has no introspection surface and value probing can't tell + // "only undefined" apart from "undefined | T", so non-Zod result + // schemas are always validated. const v = await import('valibot'); - expect(isVoidSchema(v.void())).toBe(true); - expect(isVoidSchema(v.undefined())).toBe(true); + expect(isVoidSchema(v.void())).toBe(false); + expect(isVoidSchema(v.undefined())).toBe(false); expect(isVoidSchema(v.string())).toBe(false); - expect(isVoidSchema(v.object({}))).toBe(false); - // Permissive schemas accept undefined but are NOT void — result - // validation must still run, as it does for z.unknown()/z.optional(). expect(isVoidSchema(v.any())).toBe(false); - expect(isVoidSchema(v.unknown())).toBe(false); expect(isVoidSchema(v.optional(v.string()))).toBe(false); - expect(isVoidSchema(v.nullish(v.string()))).toBe(false); }); it('the _zod.def.type introspection surface exists on v4 schemas', () => { From e34a2f1d5488d51dd0884985fe696cb94f01b53a Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:17:14 -0500 Subject: [PATCH 13/13] docs(agent): add JSDoc to the new public schema exports --- packages/agent/src/lib/schema.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/agent/src/lib/schema.ts b/packages/agent/src/lib/schema.ts index 9ea6afa4..bcd221da 100644 --- a/packages/agent/src/lib/schema.ts +++ b/packages/agent/src/lib/schema.ts @@ -2,14 +2,28 @@ import type { StandardJSONSchemaV1, StandardSchemaV1 } from '@standard-schema/sp import * as z4 from 'zod/v4'; import type { $ZodObject, $ZodShape, $ZodType, infer as zodInfer } from 'zod/v4/core'; +/** + * Any validator the agent accepts for tool input, output, event, context, + * shared-context, check, and custom-hook schemas: a Zod v4 schema (kept on + * the native fast path) or any Standard Schema v1 validator (Valibot, + * ArkType, Effect Schema, ...). + */ export type Schema = | $ZodType | StandardSchemaV1; +/** A {@link Schema} whose validated output is an object shape. */ export type ObjectSchema = | $ZodObject<$ZodShape> | StandardSchemaV1>; +/** + * Tool config shape for the input schema. `inputJsonSchema` — the JSON + * Schema sent to the model — is optional when the validator can produce one + * itself (Zod via z.toJSONSchema, or the StandardJSONSchemaV1 trait) and + * required at compile time for validation-only non-Zod input schemas. When + * supplied it always wins. + */ export type InputSchemaConfig = { inputSchema: TInput; } & (TInput extends $ZodObject<$ZodShape> | StandardJSONSchemaV1 @@ -20,23 +34,31 @@ export type InputSchemaConfig = { inputJsonSchema: Record; }); +/** Infer the input (pre-validation) type of a Zod or Standard Schema v1 schema. */ export type InferSchemaInput = TSchema extends $ZodType ? TSchema['_zod']['input'] : TSchema extends StandardSchemaV1 ? StandardSchemaV1.InferInput : unknown; +/** Infer the output (post-validation) type of a Zod or Standard Schema v1 schema. */ export type InferSchemaOutput = TSchema extends $ZodType ? zodInfer : TSchema extends StandardSchemaV1 ? StandardSchemaV1.InferOutput : unknown; +/** A single normalized Standard Schema validation issue. */ export interface SchemaIssue { readonly message: string; readonly path: PropertyKey[]; } +/** + * Error thrown when a Standard Schema v1 validator rejects a value. Issues + * are normalized to the same `{ message, path }` shape the agent surfaces + * for Zod validation errors. + */ export class StandardSchemaError extends Error { readonly issues: SchemaIssue[];