From 28fec7544da83b5ec419340b061adb0669018529 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 1 Oct 2026 09:05:55 +0300 Subject: [PATCH] LOU-X1: defineTool with inferred types Adds defineTool({ name, description, input, needsApproval, execute, ... }): one function, args typed from the zod input (output type), execute return type preserved, with ToolInput/ToolOutput helper types. The result is a ToolDescriptor, so it works anywhere tools are accepted today. - createAgent({ tools }) accepts an array of defined tools (plus the existing record form); AgentBuilder.addTool(tool); ToolRegistry .register(tool) / registerMany([...]) alongside (name, descriptor). - Definition-time validation of name (^[a-zA-Z0-9_-]{1,64}$), description, zod input and execute; duplicate names throw an error naming both tools. - Type tests (vitest typecheck, npm run test:types) and runtime tests. - Docs: README, docs/quick-start.md, docs/api-overview.md lead with defineTool. Co-Authored-By: Claude Sonnet 5.5 --- .fallowrc.json | 1 + README.md | 73 +++++++------- docs/api-overview.md | 24 ++++- docs/quick-start.md | 32 +++++-- package.json | 1 + src/core/AgentBuilder.ts | 21 +++- src/createAgent.ts | 27 ++++-- src/tools/ToolRegistry.ts | 60 ++++++++++-- src/tools/built-in/github.ts | 11 ++- src/tools/built-in/jira.ts | 13 ++- src/tools/defineTool.test-d.ts | 55 +++++++++++ src/tools/defineTool.test.ts | 170 +++++++++++++++++++++++++++++++++ src/tools/defineTool.ts | 146 ++++++++++++++++++++++++++++ src/tools/index.ts | 1 + tsconfig.json | 2 +- 15 files changed, 571 insertions(+), 66 deletions(-) create mode 100644 src/tools/defineTool.test-d.ts create mode 100644 src/tools/defineTool.test.ts create mode 100644 src/tools/defineTool.ts diff --git a/.fallowrc.json b/.fallowrc.json index 5fdc941a5..cfe3ddceb 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -3,6 +3,7 @@ "entry": [ "src/cli/{dev,build,studio}.ts", "src/data/index.ts", + "**/*.test-d.ts", "**/*.{test,spec}.{ts,tsx}", "**/*.eval.ts" ], diff --git a/README.md b/README.md index 6b6225e78..5718b2d55 100644 --- a/README.md +++ b/README.md @@ -47,19 +47,27 @@ const result = await agent.send('Hello!'); console.log(result.text); ``` -The same agent with an approval gate on a sensitive tool — `AgentExecutor` -pauses before calling it and persists a resumable snapshot instead: +The same agent with an approval gate on a sensitive tool — `defineTool()` +infers the argument types from the zod `input`, and `AgentExecutor` pauses +before calling the tool and persists a resumable snapshot instead: ```typescript -import { AgentExecutor, resumeAfterApproval, StorageServiceApprovalStore, ToolRegistry } from '@loushy/build-ai-agent'; +import { AgentExecutor, resumeAfterApproval, StorageServiceApprovalStore, ToolRegistry, defineTool } from '@loushy/build-ai-agent'; +import { z } from 'zod'; -const registry = new ToolRegistry(); -registry.register('send_email', { - displayName: 'Send email', - tool: emailTool, - needsApproval: (args) => args.to.includes('@external.com'), // pauses for a human +const sendEmail = defineTool({ + name: 'send_email', + description: 'Send an email', + input: z.object({ to: z.string().email(), subject: z.string(), body: z.string() }), + needsApproval: ({ to }) => !to.endsWith('@mycompany.com'), // `to` is typed; pauses for a human + async execute({ to, subject, body }) { + return { messageId: '...' }; + }, }); +const registry = new ToolRegistry(); +registry.register(sendEmail); + const approvalStore = new StorageServiceApprovalStore(storage); const paused = await AgentExecutor.execute({ agent, input, provider, toolRegistry: registry, approvalStore, @@ -200,41 +208,36 @@ control over the resulting `AgentConfig`. ### Tools +Define a tool with `defineTool()` — argument and result types are inferred from +the zod `input`, and the result drops in anywhere tools are accepted +(`createAgent({ tools: [...] })`, `ToolRegistry.register(tool)`, +`AgentBuilder.addTool(tool)`): + ```typescript -import { ToolRegistry } from '@loushy/build-ai-agent'; -import { tool } from 'ai'; +import { defineTool, createAgent, type ToolInput, type ToolOutput } from '@loushy/build-ai-agent'; import { z } from 'zod'; -const registry = new ToolRegistry(); - -registry.register('weather', { - displayName: 'Get weather', - tool: tool({ - description: 'Get weather information', - parameters: z.object({ - location: z.string(), - units: z.enum(['celsius', 'fahrenheit']), - }), - execute: async ({ location, units }) => { - // Your implementation - return { temperature: 72, conditions: 'sunny' }; - }, - }), +const weather = defineTool({ + name: 'weather', // 1-64 chars: letters, digits, _ and - + description: 'Get weather information', + input: z.object({ location: z.string(), units: z.enum(['celsius', 'fahrenheit']) }), + execute: async ({ location, units }) => ({ temperature: 72, conditions: 'sunny' }), }); -``` -A `ToolDescriptor` can also opt into two safety primitives: +type WeatherArgs = ToolInput; // { location: string; units: 'celsius' | 'fahrenheit' } +type WeatherResult = ToolOutput; // { temperature: number; conditions: string } -```typescript -registry.register('send_email', { - displayName: 'Send email', - tool: emailTool, - needsApproval: (args) => args.to.includes('@external.com'), // pauses for a human - requiresSandbox: true, // routes through the configured SandboxAdapter - sandboxExecute: async (args, sandbox) => sandbox.run('node', ['send-email.js', JSON.stringify(args)]), -}); +const agent = createAgent({ prompt: '...', provider, tools: [weather] }); ``` +Optional fields mirror `ToolDescriptor`: `displayName`, `needsApproval` (boolean +or a predicate typed from `input`), `requiresSandbox` and `sandboxExecute` +(routes through the configured `SandboxAdapter`). + +**Advanced: `ToolRegistry`.** For raw `ToolDescriptor`s (built-in tools, MCP +tools, an existing `tool()` from the `ai` SDK) register by name: +`registry.register('weather', { displayName: 'Get weather', tool: aiSdkTool })`. + ### Flows Flows orchestrate multi-step workflows within a single agent: diff --git a/docs/api-overview.md b/docs/api-overview.md index 6ec6d583c..c07ad4eab 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -40,10 +40,32 @@ npm run docs:build # writes docs/api/index.html | Export | Description | | -------------------------------------------- | --------------------------------------------- | -| `ToolRegistry` | Holds the tools an agent config refers to. | +| `defineTool({ name, description, input, execute, ... })` | Define a tool; `execute`/`needsApproval` args are inferred from the zod `input`. Accepted by `createAgent({ tools: [...] })`, `ToolRegistry.register(tool)` and `AgentBuilder.addTool(tool)`. | +| `ToolInput`, `ToolOutput` | Argument and result types of a defined tool. | +| `ToolRegistry` | Holds the tools an agent config refers to (advanced: `register(tool)` or `register(name, descriptor)`). | | `httpTool`, `currentDateTool`, `dayNameTool` | Built-in tools. | | `loadMcpTools(client, connectionName)` | Load a connected MCP server's tools as `ToolDescriptor`s. Available from the package root, `@loushy/build-ai-agent/tools`, and `@loushy/build-ai-agent/mcp`. | +```ts +import { defineTool } from '@loushy/build-ai-agent'; +import { z } from 'zod'; + +const sendEmail = defineTool({ + name: 'send_email', // 1-64 chars: letters, digits, _ and - + description: 'Send an email', + input: z.object({ to: z.string().email(), subject: z.string() }), + needsApproval: ({ to }) => !to.endsWith('@mycompany.com'), // `to` is typed + async execute({ to, subject }) { + return { messageId: `${to}:${subject}` }; + }, +}); +``` + +Optional fields: `displayName`, `needsApproval` (boolean or predicate), +`requiresSandbox` and `sandboxExecute`. Defining a tool validates its name, +description and zod `input` immediately; registering two tools with the same +name throws an error naming the conflict. + ```ts import { loadMcpTools } from '@loushy/build-ai-agent/mcp'; diff --git a/docs/quick-start.md b/docs/quick-start.md index 7ac12c558..7889e8f94 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -58,19 +58,29 @@ console.log(result.text); ## 3. Adding tools -Tools are passed to `createAgent()` keyed by the name the agent calls them -by. The SDK ships several built-in tools (`currentDateTool`, `dayNameTool`, -`httpTool`, ...). The mock provider simulates a tool call whenever the user -message mentions a tool's name, so this snippet exercises a real tool round -trip without an LLM. +Define a tool with `defineTool()`: the `execute` and `needsApproval` arguments +are typed from the zod `input`, and the result goes straight into +`createAgent({ tools: [...] })`. The mock provider simulates a tool call +whenever the user message mentions a tool's name, so this snippet exercises a +real tool round trip without an LLM. ```ts -import { createAgent, createMockProvider, currentDateTool } from '@loushy/build-ai-agent'; +import { createAgent, createMockProvider, defineTool } from '@loushy/build-ai-agent'; +import { z } from 'zod'; + +const currentDate = defineTool({ + name: 'current_date', + description: 'Get the current date', + input: z.object({}), + async execute() { + return { date: new Date().toISOString().slice(0, 10) }; + }, +}); const agent = createAgent({ prompt: 'You are a scheduling assistant. Use tools when helpful.', provider: createMockProvider({ responses: ['Let me check.', 'Here is the date you asked for.'] }), - tools: { current_date: currentDateTool }, + tools: [currentDate], }); const result = await agent.send('Please call current_date for me'); @@ -78,6 +88,14 @@ console.log(result.toolCalls.map((call) => call.function.name)); // [ 'current_d console.log(result.text); ``` +Names must be 1-64 characters of letters, digits, `_` or `-`. The SDK also +ships built-in tools (`currentDateTool`, `dayNameTool`, `httpTool`, ...) that +you pass keyed by name: `tools: { current_date: currentDateTool }`. + +*Advanced: `ToolRegistry`.* To share tools across agents or register raw +`ToolDescriptor`s, use `registry.register(tool)` for a defined tool or +`registry.register(name, descriptor)` for a descriptor. + ## 4. Full control: `AgentBuilder` + `AgentExecutor` `createAgent()` is a thin wrapper over `AgentBuilder` and the static diff --git a/package.json b/package.json index ce35ffd52..6d0ad7ae3 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "test:evals:judge": "vitest run --config vitest.judge.config.ts", "lint": "eslint src", "typecheck": "tsc --noEmit", + "test:types": "vitest run --typecheck.only", "example:tracing:console": "tsx examples/tracing/run-console.ts", "example:tracing:otel": "tsx examples/tracing/run-otel.ts", "example:support-bot": "tsx examples/support-bot/index.ts", diff --git a/src/core/AgentBuilder.ts b/src/core/AgentBuilder.ts index 73f7569c2..196e10e96 100644 --- a/src/core/AgentBuilder.ts +++ b/src/core/AgentBuilder.ts @@ -1,6 +1,7 @@ import { AgentConfig, AgentType, ToolConfiguration, AgentFlow } from '../types'; import { validateAgentConfig, validateAgentTools } from '../agent-types'; import { nanoid } from 'nanoid'; +import type { DefinedTool } from '../tools/defineTool'; /** * Fluent API for building agents @@ -41,13 +42,27 @@ export class AgentBuilder { } /** - * Add a tool + * Add a tool: either a `defineTool()` result (keyed by its name; register + * the same tool with a ToolRegistry for execution) or an explicit + * key + configuration. */ - public addTool(key: string, config: ToolConfiguration): this { + public addTool(tool: DefinedTool): this; + public addTool(key: string, config: ToolConfiguration): this; + public addTool(keyOrTool: string | DefinedTool, config?: ToolConfiguration): this { if (!this.config.tools) { this.config.tools = {}; } - this.config.tools[key] = config; + if (typeof keyOrTool === 'string') { + if (!config) { + throw new Error( + `AgentBuilder.addTool('${keyOrTool}'): a configuration is required. ` + + `Example: addTool('${keyOrTool}', { tool: '${keyOrTool}' }), or pass a defineTool() result.` + ); + } + this.config.tools[keyOrTool] = config; + } else { + this.config.tools[keyOrTool.name] = { tool: keyOrTool.name, description: keyOrTool.description }; + } return this; } diff --git a/src/createAgent.ts b/src/createAgent.ts index bda04bfbf..3bd62533d 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -21,6 +21,7 @@ import { AgentExecutor, ExecutionResult } from './execution/AgentExecutor'; import { LLMProvider } from './providers/llm'; import { ToolRegistry } from './tools/ToolRegistry'; import { ToolDescriptor } from './types'; +import type { DefinedTool } from './tools/defineTool'; /** * Configuration for createAgent(). Tools are keyed by the name the agent @@ -32,8 +33,14 @@ export interface CreateAgentConfig { prompt: string; /** LLM provider instance (real or mock) used to generate responses. */ provider: LLMProvider; - /** Optional tools, keyed by the name the agent should call them by. */ - tools?: Record; + /** + * Optional tools: an array of `defineTool()` results (named by the tool), + * or a record of descriptors keyed by the name the agent should call them by. + * + * @example + * createAgent({ prompt: '...', provider, tools: [sendEmail] }); + */ + tools?: readonly DefinedTool[] | Record; /** Optional agent name; defaults to 'agent'. */ name?: string; /** Optional maxSteps passed through to AgentExecutor.execute(). */ @@ -93,20 +100,26 @@ function assertCreateAgentConfig(config: CreateAgentConfig): void { * the matching AgentConfig.tools entries. No registry is built when there * are no tools. */ -function registerTools(tools: Record): { +function registerTools(tools: readonly DefinedTool[] | Record): { toolRegistry: ToolRegistry | undefined; toolsConfig: Record; } { - const toolNames = Object.keys(tools); + const entries: Array<[string, ToolDescriptor | DefinedTool]> = Array.isArray(tools) + ? (tools as readonly DefinedTool[]).map((t): [string, DefinedTool] => [t.name, t]) + : Object.entries(tools as Record); const toolsConfig: Record = {}; - if (toolNames.length === 0) { + if (entries.length === 0) { return { toolRegistry: undefined, toolsConfig }; } const toolRegistry = new ToolRegistry(); - for (const name of toolNames) { - toolRegistry.register(name, tools[name]); + for (const [name, descriptor] of entries) { + if (Array.isArray(tools)) { + toolRegistry.register(descriptor as DefinedTool); + } else { + toolRegistry.register(name, descriptor); + } toolsConfig[name] = { tool: name }; } return { toolRegistry, toolsConfig }; diff --git a/src/tools/ToolRegistry.ts b/src/tools/ToolRegistry.ts index 667302744..6b3335a3b 100644 --- a/src/tools/ToolRegistry.ts +++ b/src/tools/ToolRegistry.ts @@ -1,4 +1,5 @@ import { ToolDescriptor } from '../types'; +import { DefinedTool, isDefinedTool } from './defineTool'; /** * Tool Registry @@ -8,20 +9,63 @@ export class ToolRegistry { private tools: Map = new Map(); /** - * Register a tool + * Register a tool defined with `defineTool()` under its own name. Throws if + * a tool with that name is already registered. + * + * @example + * registry.register(sendEmail); */ - public register(name: string, descriptor: ToolDescriptor): void { - if (this.tools.has(name)) { - console.warn(`Tool '${name}' is already registered. Overwriting.`); + public register(tool: DefinedTool): void; + /** + * Register a tool descriptor under an explicit name. Re-registering a name + * overwrites the previous entry (with a warning). + */ + public register(name: string, descriptor: ToolDescriptor): void; + public register(nameOrTool: string | DefinedTool, descriptor?: ToolDescriptor): void { + if (typeof nameOrTool !== 'string') { + this.registerDefined(nameOrTool); + return; + } + if (!descriptor) { + throw new Error( + `ToolRegistry.register('${nameOrTool}'): a descriptor is required. ` + + `Pass one (register('${nameOrTool}', descriptor)) or register a defineTool() result directly.` + ); } - this.tools.set(name, descriptor); + if (this.tools.has(nameOrTool)) { + console.warn(`Tool '${nameOrTool}' is already registered. Overwriting.`); + } + this.tools.set(nameOrTool, descriptor); + } + + private registerDefined(tool: DefinedTool): void { + if (!isDefinedTool(tool)) { + throw new Error( + 'ToolRegistry.register(tool): expected a tool created with defineTool(). ' + + 'For a raw descriptor pass a name: register(name, descriptor).' + ); + } + const existing = this.tools.get(tool.name); + if (existing) { + const describe = (t: ToolDescriptor) => `"${t.displayName}"`; + throw new Error( + `Tool name '${tool.name}' is already registered: existing tool ${describe(existing)} ` + + `conflicts with new tool ${describe(tool)}. Give one of them a different name.` + ); + } + this.tools.set(tool.name, tool); } /** - * Register multiple tools at once + * Register multiple tools at once: a record of descriptors keyed by name, + * or an array of `defineTool()` results. */ - public registerMany(tools: Record): void { - Object.entries(tools).forEach(([name, descriptor]) => { + public registerMany(tools: Record | readonly DefinedTool[]): void { + if (Array.isArray(tools)) { + (tools as readonly DefinedTool[]).forEach((tool) => this.register(tool)); + return; + } + Object.entries(tools as Record).forEach(([name, descriptor]) => { this.register(name, descriptor); }); } diff --git a/src/tools/built-in/github.ts b/src/tools/built-in/github.ts index 8ae6bcf29..d94dad2ef 100644 --- a/src/tools/built-in/github.ts +++ b/src/tools/built-in/github.ts @@ -60,6 +60,7 @@ import { tool } from 'ai'; import { z } from 'zod'; import { ToolRegistry } from '../ToolRegistry'; +import type { DefinedTool } from '../defineTool'; import { ToolDescriptor } from '../../types'; import { routeFetchThroughSandbox } from './sandboxFetch'; import { assertOk } from './assertOk'; @@ -361,7 +362,15 @@ export class GitHubTools extends ToolRegistry { * before; this override applies uniformly without touching their * individual implementations. */ - public register(name: string, descriptor: ToolDescriptor): void { + public register(tool: DefinedTool): void; + public register(name: string, descriptor: ToolDescriptor): void; + public register(nameOrTool: string | DefinedTool, maybeDescriptor?: ToolDescriptor): void { + if (typeof nameOrTool !== 'string') { + super.register(nameOrTool); + return; + } + const name = nameOrTool; + const descriptor = maybeDescriptor as ToolDescriptor; if (GitHubTools.OUT_OF_SCOPE_TOOLS.has(name) && descriptor.tool) { descriptor.tool.execute = (async () => { throw new Error( diff --git a/src/tools/built-in/jira.ts b/src/tools/built-in/jira.ts index 42f4c5575..7f91b7630 100644 --- a/src/tools/built-in/jira.ts +++ b/src/tools/built-in/jira.ts @@ -21,6 +21,7 @@ import { tool } from 'ai'; import { z } from 'zod'; import { ToolRegistry } from '../ToolRegistry'; +import type { DefinedTool } from '../defineTool'; import { ToolDescriptor } from '../../types'; import { routeFetchThroughSandbox } from './sandboxFetch'; import { assertOk } from './assertOk'; @@ -105,9 +106,15 @@ export class JiraTools extends ToolRegistry { * for callers that invoke descriptor.tool.execute() directly rather than * through the guard. */ - public register(name: string, descriptor: ToolDescriptor): void { - routeFetchThroughSandbox(descriptor); - super.register(name, descriptor); + public register(tool: DefinedTool): void; + public register(name: string, descriptor: ToolDescriptor): void; + public register(...args: [DefinedTool] | [string, ToolDescriptor]): void { + if (args.length === 1) { + super.register(args[0]); + return; + } + routeFetchThroughSandbox(args[1]); + super.register(args[0], args[1]); } private registerAllTools() { diff --git a/src/tools/defineTool.test-d.ts b/src/tools/defineTool.test-d.ts new file mode 100644 index 000000000..0325edb5d --- /dev/null +++ b/src/tools/defineTool.test-d.ts @@ -0,0 +1,55 @@ +import { describe, it, expectTypeOf } from 'vitest'; +import { z } from 'zod'; +import type { ToolExecutionOptions } from 'ai'; +import { defineTool, type DefinedTool, type ToolInput, type ToolOutput } from './defineTool'; +import { createAgent } from '../createAgent'; +import { createMockProvider } from '../providers/mock'; +import { ToolRegistry } from './ToolRegistry'; + +const sendEmail = defineTool({ + name: 'send_email', + description: 'Send an email', + input: z.object({ to: z.string().email(), count: z.number().default(1) }), + needsApproval: (args) => { + expectTypeOf(args).toEqualTypeOf<{ to: string; count: number }>(); + // @ts-expect-error - `nope` is not an argument of this tool + void args.nope; + return !args.to.endsWith('@mycompany.com'); + }, + async execute(args, ctx) { + expectTypeOf(args.to).toBeString(); + expectTypeOf(args.count).toBeNumber(); + expectTypeOf(ctx).toEqualTypeOf(); + // @ts-expect-error - `nope` is not an argument of this tool + void args.nope; + return { messageId: 'x', sent: args.count }; + }, +}); + +describe('defineTool types', () => { + it('infers input and output', () => { + expectTypeOf>().toEqualTypeOf<{ to: string; count: number }>(); + expectTypeOf>().toEqualTypeOf<{ messageId: string; sent: number }>(); + expectTypeOf(sendEmail).toMatchTypeOf(); + }); + + it('awaits async results and preserves sync ones', () => { + const sync = defineTool({ name: 's', description: 'd', input: z.object({}), execute: () => 42 }); + expectTypeOf>().toEqualTypeOf(); + }); + + it('is accepted everywhere tools are', () => { + createAgent({ prompt: 'p', provider: createMockProvider({ responses: ['x'] }), tools: [sendEmail] }); + new ToolRegistry().register(sendEmail); + }); + + it('rejects execute args that do not match the schema', () => { + defineTool({ + name: 'bad', + description: 'd', + input: z.object({ a: z.string() }), + // @ts-expect-error - `a` is a string, not a number + execute: ({ a }: { a: number }) => a, + }); + }); +}); diff --git a/src/tools/defineTool.test.ts b/src/tools/defineTool.test.ts new file mode 100644 index 000000000..9899455a8 --- /dev/null +++ b/src/tools/defineTool.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { defineTool } from './defineTool'; +import { ToolRegistry } from './ToolRegistry'; +import { createAgent } from '../createAgent'; +import { createMockProvider } from '../providers/mock'; +import { AgentBuilder } from '../core/AgentBuilder'; +import { createDelegateTool } from '../execution/DelegationTool'; +import { AgentType } from '../types'; + +const input = z.object({ to: z.string(), subject: z.string() }); + +function makeEmail(name = 'send_email') { + return defineTool({ + name, + description: 'Send an email', + input, + needsApproval: ({ to }) => !to.endsWith('@mycompany.com'), + async execute({ to, subject }) { + return { messageId: `${to}:${subject}` }; + }, + }); +} + +describe('defineTool', () => { + it('builds a descriptor-compatible tool carrying its name', async () => { + const t = makeEmail(); + expect(t.name).toBe('send_email'); + expect(t.displayName).toBe('send_email'); + expect(t.tool.description).toBe('Send an email'); + expect(await t.tool.execute!({ to: 'a@b.c', subject: 's' }, {} as never)).toEqual({ + messageId: 'a@b.c:s', + }); + expect(await (t.needsApproval as (a: unknown) => unknown)({ to: 'x@other.com' })).toBe(true); + }); + + it('carries optional descriptor fields', () => { + const t = defineTool({ + name: 'x', + description: 'd', + input, + displayName: 'Nice', + requiresSandbox: true, + sandboxExecute: async ({ to }) => to, + execute: () => 1, + }); + expect(t.displayName).toBe('Nice'); + expect(t.requiresSandbox).toBe(true); + expect(typeof t.sandboxExecute).toBe('function'); + }); + + describe('validation', () => { + const base = { name: 'ok', description: 'd', input, execute: () => 1 }; + it.each([ + [{ ...base, name: undefined }, /'name' is required/], + [{ ...base, name: 'has space' }, /invalid tool name "has space".*"has_space"/], + [{ ...base, name: 'a'.repeat(65) }, /invalid tool name/], + [{ ...base, description: undefined }, /missing a 'description'/], + [{ ...base, description: ' ' }, /missing a 'description'/], + [{ ...base, input: { type: 'object' } }, /zod schema as 'input'/], + [{ ...base, input: undefined }, /zod schema as 'input'/], + [{ ...base, execute: undefined }, /'execute' function/], + ])('rejects %#', (opts, message) => { + expect(() => defineTool(opts as never)).toThrow(message); + }); + }); +}); + +describe('ToolRegistry.register(tool)', () => { + it('registers under the tool name and keeps the (name, descriptor) form', () => { + const registry = new ToolRegistry(); + const t = makeEmail(); + registry.register(t); + registry.register('other', t); + expect(registry.get('send_email')).toBe(t); + expect(registry.has('other')).toBe(true); + }); + + it('throws a clear error naming both tools on a duplicate name', () => { + const registry = new ToolRegistry(); + registry.register(makeEmail()); + const other = defineTool({ + name: 'send_email', + description: 'd', + input, + displayName: 'Other', + execute: () => 1, + }); + expect(() => registry.register(other)).toThrow( + /'send_email' is already registered.*"send_email".*"Other"/ + ); + }); + + it('rejects non-defined tools and a missing descriptor', () => { + const registry = new ToolRegistry(); + expect(() => registry.register({ displayName: 'x', tool: {} } as never)).toThrow(/defineTool/); + expect(() => (registry.register as (n: string) => void)('x')).toThrow(/descriptor is required/); + }); + + it('registerMany accepts an array of defined tools', () => { + const registry = new ToolRegistry(); + registry.registerMany([makeEmail('a'), makeEmail('b')]); + expect(registry.list()).toEqual(['a', 'b']); + }); +}); + +describe('entry points', () => { + it('createAgent accepts an array of defined tools and runs them', async () => { + const agent = createAgent({ + prompt: 'p', + provider: createMockProvider({ responses: ['calling', 'done'] }), + tools: [makeEmail('lookup')], + }); + const result = await agent.send('please call lookup'); + expect(result.toolCalls.map((c) => c.function.name)).toEqual(['lookup']); + }); + + it('createAgent still accepts a record, including defined tools', () => { + expect(() => + createAgent({ + prompt: 'p', + provider: createMockProvider({ responses: ['x'] }), + tools: { mine: makeEmail() }, + }) + ).not.toThrow(); + }); + + it('createAgent rejects two tools with the same name', () => { + expect(() => + createAgent({ + prompt: 'p', + provider: createMockProvider({ responses: ['x'] }), + tools: [makeEmail(), makeEmail()], + }) + ).toThrow(/already registered/); + }); + + it('AgentBuilder.addTool(tool) keys the config by tool name', () => { + const agent = AgentBuilder.create() + .setType(AgentType.SmartAssistant) + .setName('a') + .setPrompt('p') + .addTool(makeEmail()) + .addTool('legacy', { tool: 'legacy' }) + .build(); + expect(agent.tools.send_email).toEqual({ tool: 'send_email', description: 'Send an email' }); + expect(agent.tools.legacy).toEqual({ tool: 'legacy' }); + expect(() => (AgentBuilder.create().addTool as (k: string) => void)('x')).toThrow( + /configuration is required/ + ); + }); + + it('delegation: a child agent can use a registry holding defined tools', async () => { + const registry = new ToolRegistry(); + registry.register(makeEmail('child_tool')); + const child = AgentBuilder.create() + .setType(AgentType.SmartAssistant) + .setName('child') + .setPrompt('p') + .addTool(makeEmail('child_tool')) + .build(); + const delegate = createDelegateTool({ + agent: child, + provider: createMockProvider({ responses: ['using it', 'child answer'] }), + toolRegistry: registry, + }); + const res = await delegate.tool.execute!({ task: 'please call child_tool' }, {} as never); + expect(res).toMatchObject({ text: expect.any(String) }); + }); +}); diff --git a/src/tools/defineTool.ts b/src/tools/defineTool.ts new file mode 100644 index 000000000..7fa7a85db --- /dev/null +++ b/src/tools/defineTool.ts @@ -0,0 +1,146 @@ +import { tool, type ToolExecutionOptions } from 'ai'; +import type { z } from 'zod'; +import type { ToolDescriptor } from '../types'; +import type { SandboxAdapter } from '../security/sandboxCore'; + +/** Tool names must satisfy the constraint LLM providers impose on function names. */ +const TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/; + +/** Options accepted by {@link defineTool}. */ +export interface DefineToolOptions { + /** Name the model calls the tool by. Must match `^[a-zA-Z0-9_-]{1,64}$`. */ + name: string; + /** What the tool does; shown to the model. */ + description: string; + /** Zod schema of the arguments. `execute` and `needsApproval` receive its parsed (output) type. */ + input: S; + /** Human-readable label for UIs. Defaults to `name`. */ + displayName?: string; + /** + * Pause for human approval before running. A boolean, or a predicate + * receiving the validated arguments (typed from `input`). + */ + needsApproval?: boolean | ((args: z.output) => boolean | Promise); + /** Route execution through the configured SandboxAdapter (requires `sandboxExecute`). */ + requiresSandbox?: boolean; + /** Sandboxed execution path used instead of `execute` when `requiresSandbox` is true. */ + sandboxExecute?: (args: z.output, sandbox: SandboxAdapter) => Promise; + /** See {@link ToolDescriptor.injectStreamingController}. */ + injectStreamingController?: ToolDescriptor['injectStreamingController']; + /** Runs the tool. Arguments are typed from `input`; the return type is preserved on the result. */ + execute: (args: z.output, ctx: ToolExecutionOptions) => R | Promise; +} + +/** + * 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}. + */ +export interface DefinedTool + extends ToolDescriptor { + readonly name: string; + readonly description: string; + readonly input: S; + /** Type-only marker; never set at runtime. */ + readonly _types?: { input: z.output; output: O }; +} + +/** The (parsed) argument type of a defined tool. */ +export type ToolInput = z.output; + +/** The awaited return type of a defined tool's `execute`. */ +export type ToolOutput = NonNullable['output']; + +const definedTools = new WeakSet(); + +/** True when `value` was created by {@link defineTool}. */ +export function isDefinedTool(value: unknown): value is DefinedTool { + return typeof value === 'object' && value !== null && definedTools.has(value); +} + +function fail(problem: string, fix: string): never { + throw new Error(`defineTool: ${problem}. ${fix}`); +} + +function isZodSchema(value: unknown): value is z.ZodTypeAny { + const candidate = value as { safeParse?: unknown; _def?: unknown } | null; + return ( + typeof candidate === 'object' && + candidate !== null && + typeof candidate.safeParse === 'function' && + typeof candidate._def === 'object' + ); +} + +function assertValidOptions(opts: Partial>): void { + const { name, description, input, execute } = opts; + if (typeof name !== 'string' || name === '') { + fail("'name' is required", "Example: defineTool({ name: 'send_email', ... })"); + } + if (!TOOL_NAME_PATTERN.test(name)) { + fail( + `invalid tool name ${JSON.stringify(name)} (LLM providers only accept 1-64 characters from A-Z, a-z, 0-9, '_' and '-')`, + `Rename it, e.g. ${JSON.stringify(name.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 64) || 'my_tool')}` + ); + } + if (typeof description !== 'string' || description.trim() === '') { + fail( + `tool '${name}' is missing a 'description' (the model uses it to decide when to call the tool)`, + `Example: defineTool({ name: '${name}', description: 'Send an email', ... })` + ); + } + if (!isZodSchema(input)) { + fail( + `tool '${name}' needs a zod schema as 'input'`, + `Import { z } from 'zod' and pass e.g. input: z.object({ query: z.string() })` + ); + } + if (typeof execute !== 'function') { + fail(`tool '${name}' needs an 'execute' function`, `Example: execute: async (args) => ({ ok: true })`); + } +} + +/** + * Define a tool with arguments and result types inferred from its zod schema. + * + * The result is a {@link ToolDescriptor}, so it can be passed to + * `createAgent({ tools: [...] })`, `ToolRegistry.register(tool)`, + * `AgentBuilder.addTool(tool)` and anywhere else tools are accepted. + * + * @example + * ```ts + * const sendEmail = defineTool({ + * name: 'send_email', + * description: 'Send an email', + * input: z.object({ to: z.string().email(), subject: z.string() }), + * needsApproval: ({ to }) => !to.endsWith('@mycompany.com'), // `to` is a string + * async execute({ to, subject }) { + * return { messageId: `${to}:${subject}` }; + * }, + * }); + * type Result = ToolOutput; // { messageId: string } + * ``` + */ +export function defineTool( + opts: DefineToolOptions +): DefinedTool> { + assertValidOptions(opts); + + const defined: DefinedTool> = { + name: opts.name, + description: opts.description, + input: opts.input, + displayName: opts.displayName ?? opts.name, + tool: tool({ + description: opts.description, + parameters: opts.input, + execute: async (args, ctx) => opts.execute(args, ctx), + }), + needsApproval: opts.needsApproval, + requiresSandbox: opts.requiresSandbox, + sandboxExecute: opts.sandboxExecute as ToolDescriptor['sandboxExecute'], + injectStreamingController: opts.injectStreamingController, + }; + definedTools.add(defined); + return defined; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 08affdd71..6ffdc871a 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,4 +1,5 @@ export * from './ToolRegistry'; +export * from './defineTool'; export * from './built-in'; export * from './mcp'; diff --git a/tsconfig.json b/tsconfig.json index 1c2d086b8..32dded837 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,5 +23,5 @@ "types": ["node"] }, "include": ["src/**/*", "typings/**/*.d.ts"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "**/*.test-d.ts"] }