diff --git a/CHANGELOG.md b/CHANGELOG.md index 3818b3858..4127bdaf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `AgentSpec.mcpServers` (LOU-D20): a validated map of MCP servers (stdio `command`/`args`/`env` or HTTP `url`/`headers`) in agent spec files. `loadSpec()` reports a bad entry with its name, `loushy doctor` reads the validated field instead of the raw file, and `specToAgent()` exposes the parsed servers as `agent.mcpServers` (connecting them is TODO(LOU-D20.2)). Existing specs are unaffected. - `createAgent()` agents can pause for approval (LOU-D21): a `needsApproval` tool no longer fails the run with "requires approval but no approvalStore". The run pauses (`finishReason: 'awaiting-approval'`) in a per-agent `InMemoryApprovalStore` (or the new `approvalStore` option), and `agent.approvals.list()` / `agent.approvals.resolve({ id, approved, note? })` continue it, in its session if it paused in one. The `approve` option decides calls in code without pausing. +- Tools own their contract (LOU-D22): `ToolDescriptor` gains optional `inputSchema` (zod) and `execute(args, ctx)`, which are now the canonical fields. `defineTool()` sets both and no longer calls `ai`'s `tool()`. Argument validation, the schema sent to the model, and tool execution read them first and fall back to `tool.parameters` / `tool.execute`. `ToolDescriptor.tool` (the `ai` v4 `Tool`) is now legacy: it is still built by `defineTool()` and still accepted on hand-written descriptors this release, but new code should set `inputSchema` and `execute`. ### Changed - `AgentType` is optional and deprecated (LOU-D34, not breaking): `AgentBuilder.build()` no longer requires `setType()` and `AgentConfig.agentType` is now optional (`createAgent()` and `specToAgent()` agents carry no type). `AgentType`, `setType()` and the `agent-types` registry/validators are marked `@deprecated`: they have no runtime effect and will be removed in the next minor; they stay exported for now. Drop your `setType(...)` calls. diff --git a/docs/api-overview.md b/docs/api-overview.md index 11732eff6..b21f75102 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -227,6 +227,12 @@ Optional fields: `displayName`, `needsApproval` (boolean or predicate), description and zod `input` immediately; registering two tools with the same name throws an error naming the conflict. +A defined tool carries its schema as `inputSchema` (the same zod schema as +`input`) and its `execute` function directly. These are the canonical fields of +a `ToolDescriptor`; the `.tool` object (an `ai` v4 `{ description, parameters, +execute }`) is legacy, still built for compatibility, and used only for +descriptors that do not set `inputSchema` / `execute`. + ```ts import { loadMcpTools } from '@loushy/build-ai-agent/mcp'; @@ -309,7 +315,7 @@ so it can recover. `tool-result` events, tracing, `onToolResult` and `postToolCall` hooks see the call as an error. **Argument validation.** Before a tool runs, the model's arguments are parsed with the tool's zod -`parameters` schema. Validation happens first, so pre-tool hooks, the +`inputSchema` (for a legacy descriptor, `tool.parameters`). Validation happens first, so pre-tool hooks, the `needsApproval` predicate and `execute` all receive the **parsed** value (defaults, coercions and transforms applied). Tools without a zod schema are passed through unchanged. diff --git a/llms-full.txt b/llms-full.txt index c62008148..6fd834db5 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1426,6 +1426,12 @@ Optional fields: `displayName`, `needsApproval` (boolean or predicate), description and zod `input` immediately; registering two tools with the same name throws an error naming the conflict. +A defined tool carries its schema as `inputSchema` (the same zod schema as +`input`) and its `execute` function directly. These are the canonical fields of +a `ToolDescriptor`; the `.tool` object (an `ai` v4 `{ description, parameters, +execute }`) is legacy, still built for compatibility, and used only for +descriptors that do not set `inputSchema` / `execute`. + ```ts import { loadMcpTools } from '@loushy/build-ai-agent/mcp'; @@ -1508,7 +1514,7 @@ so it can recover. `tool-result` events, tracing, `onToolResult` and `postToolCall` hooks see the call as an error. **Argument validation.** Before a tool runs, the model's arguments are parsed with the tool's zod -`parameters` schema. Validation happens first, so pre-tool hooks, the +`inputSchema` (for a legacy descriptor, `tool.parameters`). Validation happens first, so pre-tool hooks, the `needsApproval` predicate and `execute` all receive the **parsed** value (defaults, coercions and transforms applied). Tools without a zod schema are passed through unchanged. diff --git a/src/execution/generateStep.ts b/src/execution/generateStep.ts index 8406815fa..ebff20002 100644 --- a/src/execution/generateStep.ts +++ b/src/execution/generateStep.ts @@ -8,6 +8,7 @@ import { GenerateOptions, GenerateResult, Message, ToolDefinition } from '../providers'; import { AgentConfig } from '../types'; import { ToolRegistry } from '../tools'; +import { getToolInputSchema } from '../tools/toolContract'; import { withSpan } from './tracing'; import type { CallUsage } from '../models/usage'; import { measureUsage } from './runUsage'; @@ -44,7 +45,7 @@ export function buildTools( function: { name: toolName, description: toolDesc.tool.description || toolConfig.description || '', - parameters: toolDesc.tool.parameters || {}, + parameters: getToolInputSchema(toolDesc) || {}, }, }); } diff --git a/src/execution/resume.ts b/src/execution/resume.ts index bca6bfce3..7a8b32b04 100644 --- a/src/execution/resume.ts +++ b/src/execution/resume.ts @@ -5,6 +5,7 @@ import { LLMProvider, Message, ToolCall } from '../providers'; import { ToolRegistry } from '../tools'; +import { getToolExecute } from '../tools/toolContract'; import { ToolDescriptor } from '../types'; import { ApprovalDecision, @@ -317,7 +318,7 @@ async function runApprovedToolCall( // "not found" guard below must not reject that case outright; it only // means there is genuinely no way to run the tool (neither a direct // `execute` nor a `sandboxExecute`). - if (!toolDesc || !toolDesc.tool || (!toolDesc.tool.execute && !toolDesc.sandboxExecute)) { + if (!toolDesc || !toolDesc.tool || (!getToolExecute(toolDesc) && !toolDesc.sandboxExecute)) { throw new Error(`Tool '${pending.toolName}' not found in registry`); } diff --git a/src/execution/sandboxGuard.ts b/src/execution/sandboxGuard.ts index abe026887..0f5c91ee1 100644 --- a/src/execution/sandboxGuard.ts +++ b/src/execution/sandboxGuard.ts @@ -20,6 +20,7 @@ import type { ToolExecutionOptions } from 'ai'; import { ToolDescriptor } from '../types'; +import { getToolExecute } from '../tools/toolContract'; import { SandboxAdapter } from '../security/sandboxCore'; import { bindToolCallScope, type ToolCallScope } from './subagentRuntime'; import type { RunUsage } from '../models/usage'; @@ -83,5 +84,6 @@ export async function executeToolWithSandboxGuard( const executeOptions = { abortSignal: signal, ...runContext } as ToolExecutionOptions; // LOU-Y1: lets a delegate/`task` tool's sub-agent inherit from this run. bindToolCallScope(executeOptions, scope); - return toolDesc.tool.execute ? toolDesc.tool.execute(args, executeOptions) : null; + const execute = getToolExecute(toolDesc); + return execute ? execute(args, executeOptions) : null; } diff --git a/src/execution/toolArgsValidation.ts b/src/execution/toolArgsValidation.ts index 5fdd458af..26c4ae842 100644 --- a/src/execution/toolArgsValidation.ts +++ b/src/execution/toolArgsValidation.ts @@ -5,6 +5,7 @@ import { ToolDescriptor } from '../types'; import { ToolExecutionError } from './errors'; +import { getToolInputSchema } from '../tools/toolContract'; /** One problem found while validating a tool call's arguments. */ export interface ToolArgumentIssue { @@ -89,7 +90,7 @@ export async function validateToolArguments( toolDesc: ToolDescriptor, args: unknown ): Promise { - const schema = (toolDesc.tool as { parameters?: unknown }).parameters; + const schema = getToolInputSchema(toolDesc); if (!isParseable(schema)) { return args; } diff --git a/src/execution/toolCallExecution.ts b/src/execution/toolCallExecution.ts index fdc4cedea..e9236e251 100644 --- a/src/execution/toolCallExecution.ts +++ b/src/execution/toolCallExecution.ts @@ -7,6 +7,7 @@ import { Message, ToolCall } from '../providers'; import { AgentConfig, ToolDescriptor } from '../types'; import { ToolRegistry } from '../tools'; +import { getToolExecute } from '../tools/toolContract'; import { SandboxAdapter } from '../security/sandboxCore'; import { executeToolWithSandboxGuard } from './sandboxGuard'; import type { ToolRunContext } from './sandboxGuard'; @@ -263,7 +264,7 @@ function findExecutableTool( toolName: string ): ToolDescriptor | undefined { const toolDesc = toolRegistry.get(toolName); - return toolDesc?.tool?.execute ? toolDesc : undefined; + return toolDesc && getToolExecute(toolDesc) ? toolDesc : undefined; } /** Resolves a tool's static or per-call `needsApproval` setting. */ diff --git a/src/tools/defineTool.test-d.ts b/src/tools/defineTool.test-d.ts index 0325edb5d..38ab634e5 100644 --- a/src/tools/defineTool.test-d.ts +++ b/src/tools/defineTool.test-d.ts @@ -43,6 +43,17 @@ describe('defineTool types', () => { new ToolRegistry().register(sendEmail); }); + it('carries the canonical inputSchema and execute (LOU-D22)', () => { + const input = z.object({ to: z.string().email(), count: z.number().default(1) }); + const t = defineTool({ name: 't', description: 'd', input, execute: async () => ({ ok: true }) }); + expectTypeOf(t.inputSchema).toEqualTypeOf(); + expectTypeOf(t.execute).parameter(0).toEqualTypeOf<{ to: string; count: number }>(); + expectTypeOf(t.execute).parameter(1).toEqualTypeOf(); + expectTypeOf(t.execute).returns.toEqualTypeOf>(); + // @ts-expect-error - `count` must be a number + void t.execute({ to: 'a@b.c', count: 'x' }, {} as ToolExecutionOptions); + }); + it('rejects execute args that do not match the schema', () => { defineTool({ name: 'bad', diff --git a/src/tools/defineTool.ts b/src/tools/defineTool.ts index 7fa7a85db..590348a8a 100644 --- a/src/tools/defineTool.ts +++ b/src/tools/defineTool.ts @@ -1,4 +1,4 @@ -import { tool, type ToolExecutionOptions } from 'ai'; +import type { ToolExecutionOptions } from 'ai'; import type { z } from 'zod'; import type { ToolDescriptor } from '../types'; import type { SandboxAdapter } from '../security/sandboxCore'; @@ -35,12 +35,17 @@ export interface DefineToolOptions { * A tool created by {@link defineTool}. It is a regular {@link ToolDescriptor} * (so it works anywhere a descriptor does) that also carries its `name` and * input/output types - see {@link ToolInput} and {@link ToolOutput}. + * `inputSchema` and `execute` are the canonical contract; `tool` is a + * legacy `ai` v4-shaped copy of them. */ export interface DefinedTool extends ToolDescriptor { readonly name: string; readonly description: string; readonly input: S; + /** Same schema as `input`. */ + readonly inputSchema: S; + execute(args: z.output, ctx: ToolExecutionOptions): Promise; /** Type-only marker; never set at runtime. */ readonly _types?: { input: z.output; output: O }; } @@ -126,16 +131,22 @@ export function defineTool( ): DefinedTool> { assertValidOptions(opts); + const execute = async (args: z.output, ctx: ToolExecutionOptions): Promise> => + await opts.execute(args, ctx); + // Hand-built `ai` v4 Tool shape (its `tool()` is the identity function). + const legacyTool: ToolDescriptor['tool'] = { + description: opts.description, + parameters: opts.input, + execute, + }; const defined: DefinedTool> = { name: opts.name, description: opts.description, input: opts.input, + inputSchema: opts.input, + execute, displayName: opts.displayName ?? opts.name, - tool: tool({ - description: opts.description, - parameters: opts.input, - execute: async (args, ctx) => opts.execute(args, ctx), - }), + tool: legacyTool, needsApproval: opts.needsApproval, requiresSandbox: opts.requiresSandbox, sandboxExecute: opts.sandboxExecute as ToolDescriptor['sandboxExecute'], diff --git a/src/tools/mcp/server/buildServer.ts b/src/tools/mcp/server/buildServer.ts index d053cd035..93174fda2 100644 --- a/src/tools/mcp/server/buildServer.ts +++ b/src/tools/mcp/server/buildServer.ts @@ -8,6 +8,7 @@ import { z } from 'zod'; import type { SimpleAgent } from '../../../createAgent'; import type { ExecutionResult } from '../../../execution/AgentExecutor'; import type { DefinedTool } from '../../defineTool'; +import { getToolExecute } from '../../toolContract'; import { loadOptionalPeer } from '../../../providers/optionalPeer'; import { needsApprovalGate } from './toolNames'; @@ -84,7 +85,7 @@ function registerDirectTool(server: McpServer, tool: DefinedTool): void { { description: tool.description, inputSchema: tool.input }, async (args: unknown, extra) => { try { - const execute = tool.tool.execute as ToolExecute | undefined; + const execute = getToolExecute(tool) as ToolExecute | undefined; if (!execute) return textResult(`Tool '${tool.name}' has no execute function.`, true); const output = await execute(args, { toolCallId: String(extra.requestId), diff --git a/src/tools/toolContract.test.ts b/src/tools/toolContract.test.ts new file mode 100644 index 000000000..a37a1bc46 --- /dev/null +++ b/src/tools/toolContract.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi } from 'vitest'; +import { z } from 'zod'; +import * as ai from 'ai'; +import { defineTool } from './defineTool'; +import { getToolExecute, getToolInputSchema } from './toolContract'; +import { createAgent } from '../createAgent'; +import { mockModel } from '../testing'; +import type { Message } from '../providers'; +import type { ToolDescriptor } from '../types'; + +vi.mock('ai', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, tool: vi.fn(actual.tool) }; +}); + +const input = z.object({ city: z.string(), unit: z.enum(['C', 'F']).default('C') }); + +function toolResults(messages: Message[]): unknown[] { + return messages.filter((m) => m.role === 'tool').map((m) => JSON.parse(String(m.content))); +} + +describe('tool contract (LOU-D22)', () => { + it('defineTool sets inputSchema and execute and runs end-to-end without ai.tool()', async () => { + vi.mocked(ai.tool).mockClear(); + const execute = vi.fn(async ({ city, unit }: z.output) => ({ city, temp: `21${unit}` })); + const weather = defineTool({ name: 'weather', description: 'Weather', input, execute }); + + expect(weather.inputSchema).toBe(input); + expect(weather.tool).toMatchObject({ description: 'Weather', parameters: input }); + expect(weather.tool.execute).toBe(weather.execute); + + const model = mockModel([ + { toolCalls: [{ name: 'weather', args: { city: 7 } }] }, + { toolCalls: [{ name: 'weather', args: { city: 'Paris' } }] }, + 'Sunny.', + ]); + const agent = createAgent({ prompt: 'p', provider: model, tools: [weather] }); + const result = await agent.send('go'); + + expect(result.text).toBe('Sunny.'); + expect(model.calls[0].tools?.[0].function).toMatchObject({ name: 'weather', parameters: input }); + const [invalid, ok] = toolResults(result.messages); + expect(invalid).toMatchObject({ error: 'ToolArgumentsValidationError' }); + expect(ok).toEqual({ city: 'Paris', temp: '21C' }); + expect(execute).toHaveBeenCalledTimes(1); + expect(ai.tool).not.toHaveBeenCalled(); + }); + + it('still validates and runs a legacy descriptor built with ai.tool()', async () => { + const execute = vi.fn(async ({ city }: { city: string }) => `legacy:${city}`); + const legacy: ToolDescriptor = { + displayName: 'Legacy', + tool: ai.tool({ description: 'Legacy weather', parameters: z.object({ city: z.string() }), execute }), + }; + const model = mockModel([ + { toolCalls: [{ name: 'legacy', args: {} }] }, + { toolCalls: [{ name: 'legacy', args: { city: 'Rome' } }] }, + 'done', + ]); + const agent = createAgent({ prompt: 'p', provider: model, tools: { legacy } }); + const result = await agent.send('go'); + + const [invalid, ok] = toolResults(result.messages); + expect(invalid).toMatchObject({ error: 'ToolArgumentsValidationError' }); + expect(ok).toBe('legacy:Rome'); + expect(execute).toHaveBeenCalledTimes(1); + expect(model.calls[0].tools?.[0].function.description).toBe('Legacy weather'); + }); + + it('prefers the canonical fields over the legacy tool and falls back to it', async () => { + const canonical = z.object({ a: z.string() }); + const desc: ToolDescriptor = { + displayName: 'd', + inputSchema: canonical, + execute: async () => 'canonical', + tool: { parameters: z.object({}), execute: async () => 'legacy' }, + }; + expect(getToolInputSchema(desc)).toBe(canonical); + expect(await getToolExecute(desc)?.({}, {} as never)).toBe('canonical'); + + const legacyOnly: ToolDescriptor = { displayName: 'd', tool: desc.tool }; + expect(getToolInputSchema(legacyOnly)).toBe(desc.tool.parameters); + expect(await getToolExecute(legacyOnly)?.({}, {} as never)).toBe('legacy'); + expect(getToolExecute({ displayName: 'd', tool: { parameters: canonical } })).toBeUndefined(); + }); +}); diff --git a/src/tools/toolContract.ts b/src/tools/toolContract.ts new file mode 100644 index 000000000..ced03411b --- /dev/null +++ b/src/tools/toolContract.ts @@ -0,0 +1,21 @@ +/** + * The one place runtime code reads a tool's schema and `execute` (LOU-D22). + * Prefers the descriptor's canonical `inputSchema` / `execute` and falls + * back to the legacy `ai` v4 `tool.parameters` / `tool.execute`. + */ + +import type { ToolDescriptor } from '../types'; + +/** A tool's `execute`, as the runtime calls it. */ +type ToolExecuteFn = NonNullable; + +/** The tool's argument schema: `inputSchema`, else the legacy `tool.parameters`. */ +export function getToolInputSchema(desc: ToolDescriptor): unknown { + return desc.inputSchema ?? (desc.tool as { parameters?: unknown } | undefined)?.parameters; +} + +/** The tool's `execute`: the descriptor's own, else the legacy `tool.execute`. */ +export function getToolExecute(desc: ToolDescriptor): ToolExecuteFn | undefined { + const legacy = desc.tool?.execute as ToolExecuteFn | undefined; + return desc.execute?.bind(desc) ?? legacy?.bind(desc.tool); +} diff --git a/src/types/tool.ts b/src/types/tool.ts index 3bd07b890..fb4a68ff2 100644 --- a/src/types/tool.ts +++ b/src/types/tool.ts @@ -1,4 +1,5 @@ -import { Tool as AITool } from 'ai'; +import type { Tool as AITool, ToolExecutionOptions } from 'ai'; +import type { z } from 'zod'; import { SandboxAdapter } from '../security/sandboxCore'; /** @@ -34,6 +35,21 @@ export interface ToolConfiguration { */ export interface ToolDescriptor { displayName: string; + /** + * Zod schema of the tool's arguments. Canonical: when set, it is used + * instead of `tool.parameters` for argument validation and the schema + * sent to the model (LOU-D22). {@link defineTool} sets it. + */ + inputSchema?: z.ZodTypeAny; + /** + * Runs the tool. Canonical: when set, it is called instead of + * `tool.execute` (LOU-D22). {@link defineTool} sets it. + */ + execute?(args: unknown, ctx: ToolExecutionOptions): unknown; + /** + * Legacy: an `ai` v4 `Tool` (`{ description, parameters, execute }`), + * kept for compatibility this release. Prefer `inputSchema` and `execute`. + */ tool: AITool; needsApproval?: boolean | ((args: any) => boolean | Promise); injectStreamingController?: (controller: ReadableStreamDefaultController) => void;