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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/bright-tools-wire-schema.md
Original file line number Diff line number Diff line change
@@ -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,
});
```
8 changes: 8 additions & 0 deletions packages/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
```
Expand Down
6 changes: 5 additions & 1 deletion packages/agent/src/lib/tool-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
isDeferredHandle,
isGeneratorTool,
isHITLTool,
isManualTool,
isMcpTool,
isRegularExecuteTool,
isServerTool,
Expand Down Expand Up @@ -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;
});
Expand Down
9 changes: 9 additions & 0 deletions packages/agent/src/lib/tool-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,15 @@ export interface ManualToolFunction<
TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>,
TName extends string = string,
> extends BaseToolFunction<TInput, TCtx, TName> {
/**
* 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<Record<string, unknown>>;
outputSchema?: TOutput;
}

Expand Down
40 changes: 29 additions & 11 deletions packages/agent/src/lib/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>;
/** Strict schema adherence for tool-call generation — see {@link BaseToolFunction.strict} */
strict?: boolean | null;
/** Zod schema declaring the context data this tool needs */
Expand Down Expand Up @@ -243,19 +245,27 @@ type ToolConfigWithSharedContext<
timeoutMs?: number;
/** Max simultaneous in-flight executions of this tool across the run */
maxConcurrency?: number;
execute:
| ((
params: Record<string, unknown>,
context?: ToolExecuteContext<TName, ContextFromSchema<TCtx>, TShared>,
) => unknown)
| ((
params: Record<string, unknown>,
context?: ToolExecuteContext<TName, ContextFromSchema<TCtx>, TShared>,
) => AsyncGenerator<unknown>)
| false;
/** Convert tool execution output to model-facing output */
toModelOutput?: ToModelOutputFunction<Record<string, unknown>, unknown>;
};
} & (
| {
execute: false;
/** JSON Schema to serialize instead of regenerating `inputSchema`; manual tools only. */
readonly wireInputSchema?: Readonly<Record<string, unknown>>;
}
| {
execute:
| ((
params: Record<string, unknown>,
context?: ToolExecuteContext<TName, ContextFromSchema<TCtx>, TShared>,
) => unknown)
| ((
params: Record<string, unknown>,
context?: ToolExecuteContext<TName, ContextFromSchema<TCtx>, TShared>,
) => AsyncGenerator<unknown>);
readonly wireInputSchema?: never;
}
);

/**
* Shared fields for unified `run` tool configs.
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions packages/agent/tests/unit/manual-tool-wire-schema.test-d.ts
Original file line number Diff line number Diff line change
@@ -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',
},
});
172 changes: 172 additions & 0 deletions packages/agent/tests/unit/manual-tool-wire-schema.test.ts
Original file line number Diff line number Diff line change
@@ -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',
},
},
});
});
});
Loading