Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion docs/api-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/execution/generateStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -44,7 +45,7 @@ export function buildTools(
function: {
name: toolName,
description: toolDesc.tool.description || toolConfig.description || '',
parameters: toolDesc.tool.parameters || {},
parameters: getToolInputSchema(toolDesc) || {},
},
});
}
Expand Down
3 changes: 2 additions & 1 deletion src/execution/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`);
}

Expand Down
4 changes: 3 additions & 1 deletion src/execution/sandboxGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
3 changes: 2 additions & 1 deletion src/execution/toolArgsValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -89,7 +90,7 @@ export async function validateToolArguments(
toolDesc: ToolDescriptor,
args: unknown
): Promise<unknown> {
const schema = (toolDesc.tool as { parameters?: unknown }).parameters;
const schema = getToolInputSchema(toolDesc);
if (!isParseable(schema)) {
return args;
}
Expand Down
3 changes: 2 additions & 1 deletion src/execution/toolCallExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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. */
Expand Down
11 changes: 11 additions & 0 deletions src/tools/defineTool.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof input>();
expectTypeOf(t.execute).parameter(0).toEqualTypeOf<{ to: string; count: number }>();
expectTypeOf(t.execute).parameter(1).toEqualTypeOf<ToolExecutionOptions>();
expectTypeOf(t.execute).returns.toEqualTypeOf<Promise<{ ok: boolean }>>();
// @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',
Expand Down
23 changes: 17 additions & 6 deletions src/tools/defineTool.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -35,12 +35,17 @@ export interface DefineToolOptions<S extends z.ZodTypeAny, R> {
* 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<S extends z.ZodTypeAny = z.ZodTypeAny, O = unknown>
extends ToolDescriptor {
readonly name: string;
readonly description: string;
readonly input: S;
/** Same schema as `input`. */
readonly inputSchema: S;
execute(args: z.output<S>, ctx: ToolExecutionOptions): Promise<O>;
/** Type-only marker; never set at runtime. */
readonly _types?: { input: z.output<S>; output: O };
}
Expand Down Expand Up @@ -126,16 +131,22 @@ export function defineTool<S extends z.ZodTypeAny, R>(
): DefinedTool<S, Awaited<R>> {
assertValidOptions(opts);

const execute = async (args: z.output<S>, ctx: ToolExecutionOptions): Promise<Awaited<R>> =>
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<S, Awaited<R>> = {
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'],
Expand Down
3 changes: 2 additions & 1 deletion src/tools/mcp/server/buildServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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),
Expand Down
86 changes: 86 additions & 0 deletions src/tools/toolContract.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('ai')>();
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<typeof input>) => ({ 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();
});
});
21 changes: 21 additions & 0 deletions src/tools/toolContract.ts
Original file line number Diff line number Diff line change
@@ -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<ToolDescriptor['execute']>;

/** 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);
}
18 changes: 17 additions & 1 deletion src/types/tool.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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<boolean>);
injectStreamingController?: (controller: ReadableStreamDefaultController<unknown>) => void;
Expand Down
Loading