diff --git a/CHANGELOG.md b/CHANGELOG.md index a958e832c..dcbe38613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - README revamped into a short front page (LOU-D52); the details it dropped moved to `docs/` (new pages: tools, approvals, providers, cli, flows, guardrails, utilities). ### Changed +- Small built-in tools are defined with `defineTool` (LOU-D24, not breaking): `currentDateTool`, `dayNameTool`, `createEmailTool()`, `createHttpTool()`, `createSlackTool()`, `createDelegateTool()` and the descriptors `loadMcpTools()` builds no longer call `ai`'s `tool()`. They now carry canonical `inputSchema` / `execute` (the legacy `.tool` stays), and the built-ins have the tool names `current_date`, `day_name`, `send_email`, `http_request`, `slack_alert` and `delegate_to_`. Descriptions, schemas, approval and sandbox behaviour and the factory signatures are unchanged. MCP descriptors are built with the new internal `toolDescriptorFromSchema()`, which (unlike `defineTool`) accepts any server-provided name or empty description. `routeFetchThroughSandbox()` reads the canonical `execute` first. - BREAKING (types only): `Message.content` is widened from `string` to `string | ContentPart[]` (LOU-V11). Messages you build keep working unchanged, but code that reads `message.content` as a string (`.length`, `.startsWith()`, a template literal) no longer type-checks. **Migration:** read `textOf(message)` instead, or narrow with `typeof message.content === 'string'`. A custom `LLMProvider` that forwards `content` should handle the parts or send `textOf(message)`. - The execute context is our own type (LOU-D23, not breaking): the new exported `ToolExecutionContext` (`{ toolCallId, messages: readonly Message[], abortSignal?, sessionId?, onDelegatedUsage? }`, `Message` from `@loushy/build-ai-agent`) replaces the `ai` SDK's `ToolExecutionOptions` as the type of the second argument of `defineTool()`'s `execute`, `ToolDescriptor.execute`, `DefinedTool.execute` and the third argument of `sandboxExecute`, and is what `buildToolRunContext()` returns; the public types no longer import `ToolExecutionOptions` from `ai`. A function typed for `ai`'s options that only reads `toolCallId` or `abortSignal` stays assignable. `ToolRunContext` is now a deprecated alias of the optional fields of `ToolExecutionContext` (it already existed with `onDelegatedUsage` and `toolCallId`). `AgentExecutionOptions.messages` is typed `Message[]` instead of the `ai` SDK's `CoreMessage[]` (the field is unused by the SDK). The legacy `ToolDescriptor.tool` (`ai` v4 `Tool`) is unchanged. See docs/tools.md#execute-context. - BREAKING (small): `SDKError.code` values are now the `LOUSHY_*` codes (LOU-D2): `AGENT_EXECUTION_ERROR` -> `LOUSHY_AGENT_EXECUTION_FAILED`, `TOOL_EXECUTION_ERROR` -> `LOUSHY_TOOL_EXECUTION_FAILED`, `LLM_PROVIDER_ERROR` -> `LOUSHY_PROVIDER_REQUEST_FAILED`, `SESSION_AWAITING_APPROVAL` -> `LOUSHY_SESSION_AWAITING_APPROVAL`, `FLOW_EXECUTION_ERROR` -> `LOUSHY_FLOW_EXECUTION_FAILED`, `CONFIGURATION_ERROR` -> `LOUSHY_CONFIG_INVALID`, `VALIDATION_ERROR` -> `LOUSHY_VALIDATION_FAILED`, `TIMEOUT_ERROR` -> `LOUSHY_OPERATION_TIMEOUT`, `RATE_LIMIT_ERROR` -> `LOUSHY_PROVIDER_RATE_LIMITED`; `code` is no longer optional (`new SDKError(message)` gets `LOUSHY_GENERIC_ERROR`). No class was renamed. The messages of `SDKError`s other than `ToolExecutionError`, `LLMProviderError`, `TimeoutError` and `RateLimitError` gain a second line with the code, hint and docs link, and `loadSpec()` now rejects a spec whose top-level field is a likely typo of a spec field (`LOUSHY_SPEC_UNKNOWN_FIELD`; other unknown fields are still ignored). **Migration:** compare `code` against the new values (or use `instanceof`); match messages with `toContain` / a regex, or compare `error.detail`, instead of exact equality. diff --git a/examples/ops-pipeline/fixer.ts b/examples/ops-pipeline/fixer.ts index 2e769c8d8..76af5522b 100644 --- a/examples/ops-pipeline/fixer.ts +++ b/examples/ops-pipeline/fixer.ts @@ -14,13 +14,13 @@ * this module's output with the LOU-E guardrails and LOU-J5 Slack tool * rather than being fixer-core itself. */ -import { tool } from 'ai'; import { AgentExecutor, ExecuteOptions, ExecutionResult, } from '../../src/execution/AgentExecutor'; -import { AgentConfig, ToolDescriptor } from '../../src/types'; +import { AgentConfig, ToolDescriptor, ToolExecutionContext } from '../../src/types'; +import { getToolExecute } from '../../src/tools/toolContract'; import { LLMProvider } from '../../src/providers'; import { createDelegateTool, @@ -162,21 +162,21 @@ export async function runFixer( */ export function createFixerDelegateTool(opts: DelegateAgentOptions): ToolDescriptor { const base = createDelegateTool(opts); - const baseTool = base.tool; + const baseExecute = getToolExecute(base)!; + // `base` carries a canonical `execute` (LOU-D24), which wins over the + // legacy `tool.execute`: wrap that one, and keep `tool` in step. + const execute = async (args: unknown, context: ToolExecutionContext) => { + const result = await baseExecute(args, context); + const patch = extractDiffBlock((result as { text: string }).text); + if (!patch.trim()) { + throw new EmptyPatchError(); + } + return { ...(result as object), patch }; + }; return { ...base, - tool: tool({ - description: baseTool.description, - parameters: baseTool.parameters, - execute: async (args: any, context: any) => { - const result = await baseTool.execute!(args, context); - const patch = extractDiffBlock((result as { text: string }).text); - if (!patch.trim()) { - throw new EmptyPatchError(); - } - return { ...(result as object), patch }; - }, - }) as typeof baseTool, + execute, + tool: { ...base.tool, execute: execute as typeof base.tool.execute }, }; } diff --git a/src/execution/DelegationTool.ts b/src/execution/DelegationTool.ts index 4a300826f..dd042c192 100644 --- a/src/execution/DelegationTool.ts +++ b/src/execution/DelegationTool.ts @@ -5,7 +5,6 @@ */ import { z } from 'zod'; -import { tool } from 'ai'; import { AsyncLocalStorage } from 'node:async_hooks'; import { PropagatingToolError } from './propagatingToolError'; import { runSubagent } from './delegation'; @@ -13,7 +12,7 @@ import { AgentExecutor } from './AgentExecutor'; import { LLMProvider, Message } from '../providers'; import { AgentConfig, ToolDescriptor } from '../types'; import { ToolRegistry } from '../tools'; -import type { ToolRunContext } from './sandboxGuard'; +import { defineTool } from '../tools/defineTool'; /** * Thrown when a delegation chain exceeds the configured maxDepth without @@ -103,63 +102,54 @@ export interface DelegateAgentResult { export function createDelegateTool(opts: DelegateAgentOptions): ToolDescriptor { const maxDepth = opts.maxDepth ?? 3; - return { + return defineTool({ + // Agent names are free text; tool names are not (see defineTool). + name: `delegate_to_${opts.agent.name.replace(/[^a-zA-Z0-9_-]+/g, '_')}`.slice(0, 64), displayName: `Delegate to ${opts.agent.name}`, - tool: tool({ - description: `Delegates a task to the "${opts.agent.name}" agent and returns its response.`, - parameters: z.object({ - task: z.string().describe('The task/instructions to delegate to the child agent'), - context: z - .array( - z.object({ - role: z.enum(['system', 'user', 'assistant', 'tool']), - content: z.string(), - }) - ) - .optional() - .describe( - 'Optional prior conversation history to share with the child agent (only used when contextMode is "full-history")' - ), - }), - execute: async ( - { - task, - context, - }: { - task: string; - context?: Message[]; - }, - options?: { abortSignal?: AbortSignal } & ToolRunContext - ): Promise => { - const currentDepth = delegationDepthStorage.getStore() ?? 0; + description: `Delegates a task to the "${opts.agent.name}" agent and returns its response.`, + input: z.object({ + task: z.string().describe('The task/instructions to delegate to the child agent'), + context: z + .array( + z.object({ + role: z.enum(['system', 'user', 'assistant', 'tool']), + content: z.string(), + }) + ) + .optional() + .describe( + 'Optional prior conversation history to share with the child agent (only used when contextMode is "full-history")' + ), + }), + execute: async ({ task, context }, options): Promise => { + const currentDepth = delegationDepthStorage.getStore() ?? 0; - if (currentDepth >= maxDepth) { - throw new DelegationDepthExceededError(maxDepth); - } + if (currentDepth >= maxDepth) { + throw new DelegationDepthExceededError(maxDepth); + } - const childInput: Message[] = - opts.contextMode === 'full-history' && context - ? [...context, { role: 'user', content: task }] - : [{ role: 'user', content: task }]; + const childInput: Message[] = + opts.contextMode === 'full-history' && context + ? [...context, { role: 'user', content: task }] + : [{ role: 'user', content: task }]; - return delegationDepthStorage.run(currentDepth + 1, async () => { - const result = await runSubagent( - { agent: opts.agent, provider: opts.provider, toolRegistry: opts.toolRegistry, maxSteps: opts.maxSteps }, - // LOU-V1: aborting the parent run aborts the child with it. - { name: opts.agent.name, input: childInput, toolOptions: options }, - (childOptions) => AgentExecutor.execute(childOptions) - ); + return delegationDepthStorage.run(currentDepth + 1, async () => { + const result = await runSubagent( + { agent: opts.agent, provider: opts.provider, toolRegistry: opts.toolRegistry, maxSteps: opts.maxSteps }, + // LOU-V1: aborting the parent run aborts the child with it. + { name: opts.agent.name, input: childInput, toolOptions: options }, + (childOptions) => AgentExecutor.execute(childOptions) + ); - // LOU-V5: runSubagent() rolled the child's full usage into the - // parent run's totals (`usage.delegated`); the model only sees the - // token counts. - const { promptTokens, completionTokens, totalTokens } = result.usage; - return { - text: result.text, - usage: { promptTokens, completionTokens, totalTokens }, - }; - }); - }, - }), - }; + // LOU-V5: runSubagent() rolled the child's full usage into the + // parent run's totals (`usage.delegated`); the model only sees the + // token counts. + const { promptTokens, completionTokens, totalTokens } = result.usage; + return { + text: result.text, + usage: { promptTokens, completionTokens, totalTokens }, + }; + }); + }, + }); } diff --git a/src/tools/built-in/currentDate.ts b/src/tools/built-in/currentDate.ts index 0524e08a3..33def51ba 100644 --- a/src/tools/built-in/currentDate.ts +++ b/src/tools/built-in/currentDate.ts @@ -1,18 +1,17 @@ import { z } from 'zod'; -import { tool } from 'ai'; +import { defineTool } from '../defineTool'; import { ToolDescriptor } from '../../types'; /** * Current Date Tool * Returns the current date in ISO format */ -export const currentDateTool: ToolDescriptor = { +export const currentDateTool: ToolDescriptor = defineTool({ + name: 'current_date', displayName: 'Get current date', - tool: tool({ - description: 'Get the current date and time in ISO format (UTC timezone)', - parameters: z.object({}), - execute: async () => { - return new Date().toISOString(); - }, - }), -}; + description: 'Get the current date and time in ISO format (UTC timezone)', + input: z.object({}), + execute: async () => { + return new Date().toISOString(); + }, +}); diff --git a/src/tools/built-in/dayName.ts b/src/tools/built-in/dayName.ts index a0d5d8da7..45f5ed081 100644 --- a/src/tools/built-in/dayName.ts +++ b/src/tools/built-in/dayName.ts @@ -1,21 +1,20 @@ import { z } from 'zod'; -import { tool } from 'ai'; +import { defineTool } from '../defineTool'; import { ToolDescriptor } from '../../types'; /** * Day Name Tool * Returns the name of the day for a given date */ -export const dayNameTool: ToolDescriptor = { +export const dayNameTool: ToolDescriptor = defineTool({ + name: 'day_name', displayName: 'Get day name', - tool: tool({ - description: 'Get the name of the day (e.g., Monday, Tuesday) for a given date', - parameters: z.object({ - date: z.string().describe('The date to get the day name for in ISO format (e.g., 2024-01-15)'), - locale: z.string().optional().describe('The locale to use for the day name (e.g., en-US, pl-PL). Defaults to en-US'), - }), - execute: async ({ date, locale = 'en-US' }) => { - return new Date(date).toLocaleDateString(locale, { weekday: 'long' }); - }, + description: 'Get the name of the day (e.g., Monday, Tuesday) for a given date', + input: z.object({ + date: z.string().describe('The date to get the day name for in ISO format (e.g., 2024-01-15)'), + locale: z.string().optional().describe('The locale to use for the day name (e.g., en-US, pl-PL). Defaults to en-US'), }), -}; + execute: async ({ date, locale = 'en-US' }) => { + return new Date(date).toLocaleDateString(locale, { weekday: 'long' }); + }, +}); diff --git a/src/tools/built-in/email.ts b/src/tools/built-in/email.ts index 07020e47d..9d6a37a6f 100644 --- a/src/tools/built-in/email.ts +++ b/src/tools/built-in/email.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { tool } from 'ai'; +import { defineTool } from '../defineTool'; import { ToolDescriptor } from '../../types'; /** @@ -87,34 +87,33 @@ export function createEmailTool(options: EmailToolOptions): ToolDescriptor { throw new Error('Email tool requires an apiKey in options'); } - return { + return defineTool({ + name: 'send_email', displayName: 'Send email', - tool: tool({ - description: 'Sends an email using the Resend.com API. Provide sender, recipients, subject, and content.', - parameters: z.object({ - from: z.string().describe('The sender email address (e.g., "Sender Name "). Can use default if configured.').optional(), - to: z.array(z.string()).describe('Array of recipient email addresses (e.g., ["user@example.com"])'), - subject: z.string().describe('The subject line of the email'), - text: z.string().describe('Plain text content of the email'), - html: z.string().describe('HTML content of the email'), - }), - execute: async ({ from, to, subject, text, html }) => { - const senderEmail = from || defaultFrom; - - if (!senderEmail) { - throw new Error('Email "from" address is required. Provide it in the parameters or set defaultFrom in options.'); - } - - return sendEmail({ - from: senderEmail, - to, - subject, - text, - html, - apiKey, - apiUrl, - }); - }, + description: 'Sends an email using the Resend.com API. Provide sender, recipients, subject, and content.', + input: z.object({ + from: z.string().describe('The sender email address (e.g., "Sender Name "). Can use default if configured.').optional(), + to: z.array(z.string()).describe('Array of recipient email addresses (e.g., ["user@example.com"])'), + subject: z.string().describe('The subject line of the email'), + text: z.string().describe('Plain text content of the email'), + html: z.string().describe('HTML content of the email'), }), - }; + execute: async ({ from, to, subject, text, html }) => { + const senderEmail = from || defaultFrom; + + if (!senderEmail) { + throw new Error('Email "from" address is required. Provide it in the parameters or set defaultFrom in options.'); + } + + return sendEmail({ + from: senderEmail, + to, + subject, + text, + html, + apiKey, + apiUrl, + }); + }, + }); } diff --git a/src/tools/built-in/http.ts b/src/tools/built-in/http.ts index 721be0ad1..5240796cf 100644 --- a/src/tools/built-in/http.ts +++ b/src/tools/built-in/http.ts @@ -1,11 +1,11 @@ import { z } from 'zod'; -import { tool } from 'ai'; import { isIP } from 'net'; import { promises as dnsPromises } from 'dns'; import { lazyValue, loadOptionalPeer } from '../../providers/optionalPeer'; -import { ToolDescriptor } from '../../types'; +import { ToolDescriptor, ToolExecutionContext } from '../../types'; import { SandboxAdapter } from '../../security/sandboxCore'; import { sandboxHttpFetch } from './sandboxFetch'; +import { defineTool } from '../defineTool'; /** * HTTP Tool Configuration Options @@ -403,38 +403,32 @@ export async function makeHttpRequestViaSandbox( * Factory function to create an HTTP tool with custom options */ export function createHttpTool(options: HttpToolOptions = {}): ToolDescriptor { - return { + return defineTool({ + name: 'http_request', displayName: 'Make HTTP request', - tool: tool({ - description: 'Makes HTTP requests to specified URLs with configurable method, headers, and body. Supports GET, POST, PUT, DELETE, and PATCH methods.', - parameters: z.object({ - url: z.string().describe('The URL to make the request to (must be a valid HTTP/HTTPS URL)'), - method: z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']).describe('The HTTP method to use'), - headers: z.record(z.string()).optional().describe('Optional headers to include in the request as key-value pairs'), - body: z.string().optional().describe('The body of the request. For POST/PUT/PATCH, this should be a JSON string. Not used for GET/DELETE.'), - }), - execute: async ({ url, method, headers, body }, executeOptions) => { - // `?.`: direct callers have historically passed no options object. - const signal = executeOptions?.abortSignal; - return makeHttpRequest({ url, method, headers, body, options, signal }); - }, + description: 'Makes HTTP requests to specified URLs with configurable method, headers, and body. Supports GET, POST, PUT, DELETE, and PATCH methods.', + input: z.object({ + url: z.string().describe('The URL to make the request to (must be a valid HTTP/HTTPS URL)'), + method: z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']).describe('The HTTP method to use'), + headers: z.record(z.string()).optional().describe('Optional headers to include in the request as key-value pairs'), + body: z.string().optional().describe('The body of the request. For POST/PUT/PATCH, this should be a JSON string. Not used for GET/DELETE.'), }), + execute: async ({ url, method, headers, body }, ctx) => { + // `?.`: direct callers have historically passed no context object. + const signal = ctx?.abortSignal; + return makeHttpRequest({ url, method, headers, body, options, signal }); + }, // LOU-K2: httpTool makes arbitrary, model-chosen outbound HTTP requests // - the highest-risk built-in tool for a sandbox boundary to be // meaningful on. A caller going through executeToolWithSandboxGuard() // (AgentExecutor, resume.ts) gets the actual fetch routed through the // configured SandboxAdapter via sandboxExecute(); execute() above is // left unchanged (still real, directly callable) for callers that - // invoke descriptor.tool.execute() directly. + // invoke descriptor.execute() directly. requiresSandbox: true, - sandboxExecute: async (args, sandbox, callOptions) => { - const { url, method, headers, body } = args as Omit; - return makeHttpRequestViaSandbox( - { url, method, headers, body, options, signal: callOptions?.abortSignal }, - sandbox - ); - }, - }; + sandboxExecute: async (args, sandbox, callOptions?: ToolExecutionContext) => + makeHttpRequestViaSandbox({ ...args, options, signal: callOptions?.abortSignal }, sandbox), + }); } /** diff --git a/src/tools/built-in/sandboxFetch.ts b/src/tools/built-in/sandboxFetch.ts index c2d604ebb..c7e182cda 100644 --- a/src/tools/built-in/sandboxFetch.ts +++ b/src/tools/built-in/sandboxFetch.ts @@ -20,6 +20,7 @@ import { SandboxAdapter } from '../../security/sandboxCore'; import { ToolDescriptor } from '../../types'; +import { getToolExecute } from '../toolContract'; /** * The request shape encoded (as base64 JSON, via the SANDBOX_FETCH_REQUEST @@ -235,11 +236,11 @@ async function withSandboxedFetch(sandbox: SandboxAdapter, fn: () => Promise< * left as-is. */ export function routeFetchThroughSandbox(descriptor: ToolDescriptor): void { - if (!descriptor.tool?.execute) { + const originalExecute = getToolExecute(descriptor); + if (!originalExecute) { return; } - const originalExecute = descriptor.tool.execute; descriptor.requiresSandbox = true; descriptor.sandboxExecute = (args: unknown, sandbox: SandboxAdapter) => - withSandboxedFetch(sandbox, async () => originalExecute(args as any, {} as any)); + withSandboxedFetch(sandbox, async () => originalExecute(args, {} as any)); } diff --git a/src/tools/built-in/slack.ts b/src/tools/built-in/slack.ts index 158a37678..995fcb65f 100644 --- a/src/tools/built-in/slack.ts +++ b/src/tools/built-in/slack.ts @@ -10,9 +10,7 @@ * it) - this tool itself only sends the message; it does not bypass or * reimplement the approval gate. * - * Follows the same ToolDescriptor pattern established in - * src/tools/built-in/http.ts and github.ts: wraps an AI SDK `tool()` call - * (not a plain object literal). + * Built with `defineTool()`, like src/tools/built-in/http.ts. * * ## Env var convention (LOU-F8 style) * @@ -21,9 +19,9 @@ * convention resolveProvider.ts's PROVIDER_ENV_TABLE uses for LLM * providers (e.g. `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`). */ -import { tool } from 'ai'; import { z } from 'zod'; -import { ToolDescriptor } from '../../types'; +import { ToolDescriptor, ToolExecutionContext } from '../../types'; +import { defineTool } from '../defineTool'; import { SandboxAdapter } from '../../security/sandboxCore'; import { sandboxHttpFetch } from './sandboxFetch'; @@ -179,21 +177,20 @@ export async function postSlackAlertViaSandbox( * via resumeAfterApproval(). */ export function createSlackTool(options: SlackToolOptions = {}): ToolDescriptor { - return { + return defineTool({ + name: 'slack_alert', displayName: 'Post Slack Alert', - tool: tool({ - description: - 'Posts an alert to a Slack channel with a "Fix it" button that resumes a pending approval.', - parameters: z.object({ - channel: z.string().describe('Slack channel to post to (e.g. "#incidents")'), - message: z.string().describe('Alert message text'), - approvalId: z.string().describe('The pending approval id the "Fix it" button will resolve'), - }), - execute: async ({ channel, message, approvalId }, executeOptions) => { - // `?.`: direct callers have historically passed no options object. - return postSlackAlert(channel, message, approvalId, options, executeOptions?.abortSignal); - }, + description: + 'Posts an alert to a Slack channel with a "Fix it" button that resumes a pending approval.', + input: z.object({ + channel: z.string().describe('Slack channel to post to (e.g. "#incidents")'), + message: z.string().describe('Alert message text'), + approvalId: z.string().describe('The pending approval id the "Fix it" button will resolve'), }), + execute: async ({ channel, message, approvalId }, ctx) => { + // `?.`: direct callers have historically passed no context object. + return postSlackAlert(channel, message, approvalId, options, ctx?.abortSignal); + }, // LOU-K2: this tool POSTs to a webhook URL read from an env var / // options (an external endpoint, not something the model chooses // directly, but still a real outbound network call) - route it through @@ -201,11 +198,9 @@ export function createSlackTool(options: SlackToolOptions = {}): ToolDescriptor // above is left unchanged for direct callers (e.g. // examples/ops-pipeline, which calls descriptor.tool.execute() itself). requiresSandbox: true, - sandboxExecute: async (args, sandbox, callOptions) => { - const { channel, message, approvalId } = args as { channel: string; message: string; approvalId: string }; - return postSlackAlertViaSandbox(channel, message, approvalId, sandbox, options, callOptions?.abortSignal); - }, - }; + sandboxExecute: async ({ channel, message, approvalId }, sandbox, callOptions?: ToolExecutionContext) => + postSlackAlertViaSandbox(channel, message, approvalId, sandbox, options, callOptions?.abortSignal), + }); } /** Default Slack tool instance, reading SLACK_WEBHOOK_URL at call time. */ diff --git a/src/tools/mcp/McpToolLoader.ts b/src/tools/mcp/McpToolLoader.ts index 6c2eef515..74ecd8173 100644 --- a/src/tools/mcp/McpToolLoader.ts +++ b/src/tools/mcp/McpToolLoader.ts @@ -7,11 +7,11 @@ */ import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { tool as aiTool } from 'ai'; import { ToolDescriptor } from '../../types'; import { noopLogger, type Logger } from '../../execution/logger'; import { handleCallToolResult } from './result'; import { jsonSchemaToZod } from './schema'; +import { toolDescriptorFromSchema } from '../toolContract'; /** * Minimal shape of a tool as returned by an MCP server's `tools/list` @@ -121,17 +121,11 @@ export async function loadMcpTools( } function buildDescriptor(client: McpClientLike, rawTool: RawMcpTool): ToolDescriptor { - const parameters = jsonSchemaToZod(rawTool.inputSchema); - return { + return toolDescriptorFromSchema({ displayName: rawTool.description || rawTool.name, - tool: aiTool({ - description: rawTool.description || '', - parameters: parameters as any, - execute: async (args: Record) => - handleCallToolResult( - await client.callTool({ name: rawTool.name, arguments: args }), - rawTool.name - ), - }), - }; + description: rawTool.description || '', + inputSchema: jsonSchemaToZod(rawTool.inputSchema), + execute: async (args) => + handleCallToolResult(await client.callTool({ name: rawTool.name, arguments: args }), rawTool.name), + }); } diff --git a/src/tools/noAiToolImport.test.ts b/src/tools/noAiToolImport.test.ts new file mode 100644 index 000000000..0c05b43df --- /dev/null +++ b/src/tools/noAiToolImport.test.ts @@ -0,0 +1,31 @@ +/** + * Static check (LOU-D24): the built-in tools below are built with + * `defineTool` (or `toolDescriptorFromSchema`) and must not import from `ai`. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const SRC = path.resolve(__dirname, '..'); + +const CONVERTED_FILES = [ + 'tools/built-in/currentDate.ts', + 'tools/built-in/dayName.ts', + 'tools/built-in/email.ts', + 'tools/built-in/http.ts', + 'tools/built-in/slack.ts', + 'tools/built-in/sandboxFetch.ts', + 'tools/mcp/McpToolLoader.ts', + 'execution/DelegationTool.ts', +]; + +const AI_IMPORT = /(?:from|import)\s*\(?\s*['"]ai['"]|require\(\s*['"]ai['"]\s*\)/; + +describe('converted built-in tools do not import from "ai"', () => { + for (const file of CONVERTED_FILES) { + it(file, () => { + const source = readFileSync(path.join(SRC, file), 'utf8'); + expect(source).not.toMatch(AI_IMPORT); + }); + } +}); diff --git a/src/tools/toolContract.test.ts b/src/tools/toolContract.test.ts index a37a1bc46..eed37f956 100644 --- a/src/tools/toolContract.test.ts +++ b/src/tools/toolContract.test.ts @@ -2,7 +2,7 @@ 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 { getToolExecute, getToolInputSchema, toolDescriptorFromSchema } from './toolContract'; import { createAgent } from '../createAgent'; import { mockModel } from '../testing'; import type { Message } from '../providers'; @@ -83,4 +83,18 @@ describe('tool contract (LOU-D22)', () => { expect(await getToolExecute(legacyOnly)?.({}, {} as never)).toBe('legacy'); expect(getToolExecute({ displayName: 'd', tool: { parameters: canonical } })).toBeUndefined(); }); + + it('toolDescriptorFromSchema sets canonical and legacy fields without validating the name or description', async () => { + const schema = z.object({ q: z.string() }); + const desc = toolDescriptorFromSchema({ + displayName: 'srv.search', + description: '', + inputSchema: schema, + execute: async (args) => ({ echoed: args }), + }); + expect(desc.inputSchema).toBe(schema); + expect(desc.tool).toMatchObject({ description: '', parameters: schema }); + expect(desc.tool.execute).toBe(desc.execute); + expect(await getToolExecute(desc)?.({ q: 'x' }, {} as never)).toEqual({ echoed: { q: 'x' } }); + }); }); diff --git a/src/tools/toolContract.ts b/src/tools/toolContract.ts index ced03411b..eeb38e3e8 100644 --- a/src/tools/toolContract.ts +++ b/src/tools/toolContract.ts @@ -4,7 +4,8 @@ * back to the legacy `ai` v4 `tool.parameters` / `tool.execute`. */ -import type { ToolDescriptor } from '../types'; +import type { z } from 'zod'; +import type { ToolDescriptor, ToolExecutionContext } from '../types'; /** A tool's `execute`, as the runtime calls it. */ type ToolExecuteFn = NonNullable; @@ -19,3 +20,35 @@ export function getToolExecute(desc: ToolDescriptor): ToolExecuteFn | undefined const legacy = desc.tool?.execute as ToolExecuteFn | undefined; return desc.execute?.bind(desc) ?? legacy?.bind(desc.tool); } + +/** Options for {@link toolDescriptorFromSchema}. */ +export interface ToolFromSchemaOptions { + displayName: string; + description: string; + /** Zod schema of the arguments, e.g. one built by `jsonSchemaToZod()`. */ + inputSchema: z.ZodTypeAny; + execute: (args: Record, ctx: ToolExecutionContext) => unknown; +} + +/** + * Build a descriptor from a schema that is only known at runtime (an MCP + * server's tool list). Unlike `defineTool` it does not validate the name or + * require a description, since both come from a remote server. It sets the + * canonical `inputSchema` / `execute` and a hand-built legacy `.tool` (the + * `ai` v4 shape; `tool()` was the identity function). Removed in D26. + */ +export function toolDescriptorFromSchema(opts: ToolFromSchemaOptions): ToolDescriptor { + const execute = async (args: unknown, ctx: ToolExecutionContext) => + opts.execute(args as Record, ctx); + return { + displayName: opts.displayName, + inputSchema: opts.inputSchema, + execute, + // legacy (.tool): the same schema and execute in the `ai` v4 shape. Removed in D26. + tool: { + description: opts.description, + parameters: opts.inputSchema, + execute: execute as ToolDescriptor['tool']['execute'], + }, + }; +}