From 4dce84e4393ec36745875512d6ad1fcd8b38e502 Mon Sep 17 00:00:00 2001 From: sam Date: Tue, 25 Aug 2026 16:21:22 +0000 Subject: [PATCH] feat(agent): allow manual tools to supply a wire input schema Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .changeset/bright-tools-wire-schema.md | 23 +++ packages/agent/README.md | 8 + packages/agent/src/lib/tool-executor.ts | 6 +- packages/agent/src/lib/tool-types.ts | 9 + packages/agent/src/lib/tool.ts | 40 ++-- .../unit/manual-tool-wire-schema.test-d.ts | 31 ++++ .../unit/manual-tool-wire-schema.test.ts | 172 ++++++++++++++++++ 7 files changed, 277 insertions(+), 12 deletions(-) create mode 100644 .changeset/bright-tools-wire-schema.md create mode 100644 packages/agent/tests/unit/manual-tool-wire-schema.test-d.ts create mode 100644 packages/agent/tests/unit/manual-tool-wire-schema.test.ts diff --git a/.changeset/bright-tools-wire-schema.md b/.changeset/bright-tools-wire-schema.md new file mode 100644 index 00000000..c6c4c122 --- /dev/null +++ b/.changeset/bright-tools-wire-schema.md @@ -0,0 +1,23 @@ +--- +'@openrouter/agent': minor +--- + +Allow manual tools to provide a caller-owned JSON Schema for wire serialization. + +```ts +import { tool } from '@openrouter/agent'; +import { z } from 'zod'; + +const confirmTool = tool({ + name: 'confirm_action', + inputSchema: z.object({ action: z.string() }), + wireInputSchema: { + type: 'object', + properties: { + action: { type: 'string' }, + }, + required: ['action'], + }, + execute: false, +}); +``` diff --git a/packages/agent/README.md b/packages/agent/README.md index a9047b76..9a852933 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -221,6 +221,14 @@ const analysisTool = tool({ const confirmTool = tool({ name: 'confirm_action', inputSchema: z.object({ action: z.string() }), + // Forward an existing JSON Schema without converting it through Zod. + wireInputSchema: { + type: 'object', + properties: { + action: { type: 'string' }, + }, + required: ['action'], + }, execute: false, }); ``` diff --git a/packages/agent/src/lib/tool-executor.ts b/packages/agent/src/lib/tool-executor.ts index 5ccdc7b7..dcc37b3b 100644 --- a/packages/agent/src/lib/tool-executor.ts +++ b/packages/agent/src/lib/tool-executor.ts @@ -22,6 +22,7 @@ import { isDeferredHandle, isGeneratorTool, isHITLTool, + isManualTool, isMcpTool, isRegularExecuteTool, isServerTool, @@ -129,7 +130,10 @@ export function convertToolsToAPIFormat( name: tool.function.name, description: tool.function.description || null, strict: tool.function.strict ?? null, - parameters: convertZodToJsonSchema(tool.function.inputSchema), + parameters: + isManualTool(tool) && tool.function.wireInputSchema !== undefined + ? sanitizeJsonSchema(tool.function.wireInputSchema) + : convertZodToJsonSchema(tool.function.inputSchema), }; return apiTool; }); diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index 038470b9..9984d3e1 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -575,6 +575,15 @@ export interface ManualToolFunction< TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, TName extends string = string, > extends BaseToolFunction { + /** + * JSON Schema to serialize for this tool instead of regenerating one from + * `inputSchema`. Manual tools are never executed or input-validated by the + * SDK, so a caller that already owns a JSON Schema can forward it verbatim + * (sanitized of `~`-prefixed metadata keys) and skip the round trip through + * Zod, which both costs CPU/allocations and cannot represent constructs like + * `anyOf` / `oneOf`. + */ + readonly wireInputSchema?: Readonly>; outputSchema?: TOutput; } diff --git a/packages/agent/src/lib/tool.ts b/packages/agent/src/lib/tool.ts index a19717ca..e4916a32 100644 --- a/packages/agent/src/lib/tool.ts +++ b/packages/agent/src/lib/tool.ts @@ -150,6 +150,8 @@ type ManualToolConfig< name: TName; description?: string; inputSchema: TInput; + /** JSON Schema to serialize instead of regenerating `inputSchema`; manual tools only. */ + readonly wireInputSchema?: Readonly>; /** Strict schema adherence for tool-call generation — see {@link BaseToolFunction.strict} */ strict?: boolean | null; /** Zod schema declaring the context data this tool needs */ @@ -243,19 +245,27 @@ type ToolConfigWithSharedContext< timeoutMs?: number; /** Max simultaneous in-flight executions of this tool across the run */ maxConcurrency?: number; - execute: - | (( - params: Record, - context?: ToolExecuteContext, TShared>, - ) => unknown) - | (( - params: Record, - context?: ToolExecuteContext, TShared>, - ) => AsyncGenerator) - | false; /** Convert tool execution output to model-facing output */ toModelOutput?: ToModelOutputFunction, unknown>; -}; +} & ( + | { + execute: false; + /** JSON Schema to serialize instead of regenerating `inputSchema`; manual tools only. */ + readonly wireInputSchema?: Readonly>; + } + | { + execute: + | (( + params: Record, + context?: ToolExecuteContext, TShared>, + ) => unknown) + | (( + params: Record, + context?: ToolExecuteContext, TShared>, + ) => AsyncGenerator); + readonly wireInputSchema?: never; + } +); /** * Shared fields for unified `run` tool configs. @@ -674,6 +684,14 @@ export function tool( fn.strict = config.strict; } + if ('wireInputSchema' in config && config.wireInputSchema !== undefined) { + ( + fn as { + wireInputSchema?: unknown; + } + ).wireInputSchema = config.wireInputSchema; + } + return { type: ToolType.Function, function: fn, diff --git a/packages/agent/tests/unit/manual-tool-wire-schema.test-d.ts b/packages/agent/tests/unit/manual-tool-wire-schema.test-d.ts new file mode 100644 index 00000000..b17ee079 --- /dev/null +++ b/packages/agent/tests/unit/manual-tool-wire-schema.test-d.ts @@ -0,0 +1,31 @@ +import { z } from 'zod/v4'; +import { tool } from '../../src/lib/tool.js'; + +const manualTool = tool({ + name: 'manual_wire_schema', + inputSchema: z.object({ + value: z.string(), + }), + wireInputSchema: { + type: 'object', + properties: { + value: { + type: 'string', + }, + }, + }, + execute: false, +}); +void manualTool; + +// @ts-expect-error wireInputSchema is only accepted for manual tools +tool({ + name: 'executable_wire_schema', + inputSchema: z.object({ + value: z.string(), + }), + execute: () => 'done', + wireInputSchema: { + type: 'object', + }, +}); diff --git a/packages/agent/tests/unit/manual-tool-wire-schema.test.ts b/packages/agent/tests/unit/manual-tool-wire-schema.test.ts new file mode 100644 index 00000000..8f8f3819 --- /dev/null +++ b/packages/agent/tests/unit/manual-tool-wire-schema.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod/v4'; +import { tool } from '../../src/lib/tool.js'; +import { convertToolsToAPIFormat } from '../../src/lib/tool-executor.js'; + +describe('manual tool wireInputSchema', () => { + it('serializes anyOf and oneOf while sanitizing tilde-prefixed keys', () => { + const wireInputSchema = { + type: 'object', + '~rootMetadata': 'remove me', + properties: { + choice: { + '~propertyMetadata': 'remove me', + anyOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + ], + oneOf: [ + { + const: 'first', + }, + { + const: 'second', + }, + ], + }, + }, + required: [ + 'choice', + ], + }; + + const manualTool = tool({ + name: 'choose_value', + description: 'Choose a value', + inputSchema: z.object({ + choice: z.string(), + }), + wireInputSchema, + execute: false, + strict: true, + }); + const api = convertToolsToAPIFormat([ + manualTool, + ]); + const emitted = api[0]; + const parameters = 'parameters' in emitted ? emitted.parameters : undefined; + + expect(emitted).toMatchObject({ + type: 'function', + name: 'choose_value', + description: 'Choose a value', + strict: true, + }); + expect(parameters).toEqual({ + type: 'object', + properties: { + choice: { + anyOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + ], + oneOf: [ + { + const: 'first', + }, + { + const: 'second', + }, + ], + }, + }, + required: [ + 'choice', + ], + }); + }); + + it('does not mutate the caller-owned schema and emits a copy', () => { + const wireInputSchema = { + type: 'object', + '~rootMetadata': true, + properties: { + value: { + type: 'string', + '~nestedMetadata': true, + }, + }, + }; + const originalSchema = structuredClone(wireInputSchema); + const manualTool = tool({ + name: 'copy_schema', + inputSchema: z.object({ + value: z.string(), + }), + wireInputSchema, + execute: false, + }); + + const api = convertToolsToAPIFormat([ + manualTool, + ]); + const emitted = api[0]; + const parameters = 'parameters' in emitted ? emitted.parameters : undefined; + + expect(wireInputSchema).toEqual(originalSchema); + expect(parameters).not.toBe(wireInputSchema); + }); + + it('falls back to the Zod-derived schema without wireInputSchema', () => { + const manualTool = tool({ + name: 'fallback_schema', + inputSchema: z.object({ + value: z.string(), + }), + execute: false, + }); + + const api = convertToolsToAPIFormat([ + manualTool, + ]); + const emitted = api[0]; + const parameters = 'parameters' in emitted ? emitted.parameters : undefined; + + expect(parameters).toMatchObject({ + type: 'object', + properties: { + value: { + type: 'string', + }, + }, + }); + }); + + it('uses the Zod-derived schema for executable shared-context tools', () => { + const executableTool = tool<{ + sessionId: string; + }>()({ + name: 'shared_context_tool', + inputSchema: z.object({ + count: z.number(), + }), + execute: (params, context) => { + context?.shared.sessionId; + return params.count; + }, + }); + + const api = convertToolsToAPIFormat([ + executableTool, + ]); + const emitted = api[0]; + const parameters = 'parameters' in emitted ? emitted.parameters : undefined; + + expect(parameters).toMatchObject({ + type: 'object', + properties: { + count: { + type: 'number', + }, + }, + }); + }); +});