diff --git a/CHANGELOG.md b/CHANGELOG.md index e0b2919ec..ea5d5d8bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,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 +- 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. - BREAKING (small): tool failures that did not go through the thrown-error path now use the shared shape (LOU-U14), so `error` is an error name instead of a message. Before: an unknown tool or a missing registry gave `{ error: "Tool 'x' not found" }` / `{ error: 'No tool registry available' }`; a rejected approval gave `{ error: 'Tool execution was rejected by the reviewer', note }`; a tool that threw after an approval gave `{ error: '' }`; a call left without a result on resume gave `{ error: 'Tool call was not run: ...' }`. Now each is `{ error: ' | ToolNotRunError>', toolName, message: '', kind, ... }`. A tool approved but missing from the registry on resume used to reject `resumeAfterApproval()` with `Tool 'x' not found in registry`; it now resolves with a `not-found` error result. The sandbox guard throws `SandboxRequiredError` (same message, still an `Error`). **Migration:** read `message` instead of `error` when you want the text, and branch on `kind`. `ToolArgumentsValidationError.toToolResult()` now also returns `kind: 'validation'`. - Background sub-agents still running when the lead run ends are now cancelled (LOU-Y4.2) instead of running to completion on their own; a lead run that pauses for approval cancels them too (the resumed run starts with none). **Migration:** to keep them, have the lead collect them with `agent_await` before its final answer, or set `subagentOptions: { awaitBackgroundOnFinish: true }`. diff --git a/docs/tools.md b/docs/tools.md index 4b88e062b..d00f51279 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -68,7 +68,9 @@ descriptors that set neither `inputSchema` nor `execute`. ## Execute context -`execute(args, ctx)` always gets a real second argument, on every path that +`execute(args, ctx)` always gets a real second argument, typed +`ToolExecutionContext` (exported from the package root; it replaces the `ai` +SDK's `ToolExecutionOptions`), on every path that runs a tool: the main loop, the call that runs after an approval, tools that run in a sandbox (`sandboxExecute(args, sandbox, ctx)`) and a flow's tool-call node. @@ -78,6 +80,7 @@ node. | `ctx.toolCallId` | The model's id for this call. It stays the same when the call is re-run after a crash. A call with no model turn behind it (a flow node) gets a generated id. | | `ctx.messages` | A read-only copy of the transcript the model had seen before it made the call: no system prompt and not the assistant turn that made the call. Empty for a flow node. | | `ctx.abortSignal` | The run's `AbortSignal`, set when the run has one. | +| `ctx.sessionId` | Reserved: the type has it and `buildToolRunContext()` passes it through, but the executor does not set it yet. | A `sandboxExecute(args, sandbox)` that ignores the third argument keeps working. diff --git a/llms-full.txt b/llms-full.txt index 99718831b..c21b69571 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -5908,7 +5908,9 @@ descriptors that set neither `inputSchema` nor `execute`. ## Execute context -`execute(args, ctx)` always gets a real second argument, on every path that +`execute(args, ctx)` always gets a real second argument, typed +`ToolExecutionContext` (exported from the package root; it replaces the `ai` +SDK's `ToolExecutionOptions`), on every path that runs a tool: the main loop, the call that runs after an approval, tools that run in a sandbox (`sandboxExecute(args, sandbox, ctx)`) and a flow's tool-call node. @@ -5918,6 +5920,7 @@ node. | `ctx.toolCallId` | The model's id for this call. It stays the same when the call is re-run after a crash. A call with no model turn behind it (a flow node) gets a generated id. | | `ctx.messages` | A read-only copy of the transcript the model had seen before it made the call: no system prompt and not the assistant turn that made the call. Empty for a flow node. | | `ctx.abortSignal` | The run's `AbortSignal`, set when the run has one. | +| `ctx.sessionId` | Reserved: the type has it and `buildToolRunContext()` passes it through, but the executor does not set it yet. | A `sandboxExecute(args, sandbox)` that ignores the third argument keeps working. diff --git a/src/execution/toolRunContext.test.ts b/src/execution/toolRunContext.test.ts index e7ee9140f..66fbf4d6a 100644 --- a/src/execution/toolRunContext.test.ts +++ b/src/execution/toolRunContext.test.ts @@ -1,5 +1,4 @@ import { describe, it, expect } from 'vitest'; -import type { ToolExecutionOptions } from 'ai'; import { AgentExecutor } from './AgentExecutor'; import { resumeAfterApproval } from './resume'; import type { ApprovalStore, ExecutionSnapshot, PendingApproval } from './ApprovalGate'; @@ -7,14 +6,14 @@ import { executeToolWithSandboxGuard } from './sandboxGuard'; import { buildToolRunContext } from './toolRunContext'; import { ToolRegistry } from '../tools'; import { AgentBuilder } from '../core'; -import { AgentType } from '../types'; +import { AgentType, type ToolExecutionContext } from '../types'; import type { SandboxAdapter } from '../security/sandboxCore'; import { NoopSandbox } from '../security/sandboxCore'; import { mockModel } from '../testing'; /** What a tool saw as its execute context. */ interface Seen { - ctx: ToolExecutionOptions; + ctx: ToolExecutionContext; } function inMemoryApprovalStore(): ApprovalStore { @@ -38,20 +37,20 @@ const sandbox: SandboxAdapter = { }; function register(registry: ToolRegistry, seen: Partial, flags: { sandboxed: boolean; approval: boolean }) { - const record = (ctx: ToolExecutionOptions) => { + const record = (ctx: ToolExecutionContext) => { seen.ctx = ctx; return { ok: true }; }; registry.register('probe', { displayName: 'Probe', - tool: { description: 'probe', parameters: {}, execute: async (_a: unknown, ctx: ToolExecutionOptions) => record(ctx) } as never, + tool: { description: 'probe', parameters: {}, execute: async (_a: unknown, ctx: ToolExecutionContext) => record(ctx) } as never, needsApproval: flags.approval, ...(flags.sandboxed && { requiresSandbox: true, // Records the third argument, as a tool author would read it. - sandboxExecute: async (_args: unknown, sb: SandboxAdapter, ctx?: ToolExecutionOptions) => { + sandboxExecute: async (_args: unknown, sb: SandboxAdapter, ctx?: ToolExecutionContext) => { expect(sb).toBe(sandbox); - return record(ctx as ToolExecutionOptions); + return record(ctx as ToolExecutionContext); }, }), }); diff --git a/src/execution/toolRunContext.ts b/src/execution/toolRunContext.ts index 22ec5ef5a..09f8268d6 100644 --- a/src/execution/toolRunContext.ts +++ b/src/execution/toolRunContext.ts @@ -2,30 +2,25 @@ * The one builder of the context a tool's `execute(args, ctx)` receives * (LOU-U15). Every path that runs a tool - the main loop, resume after an * approval, the sandbox route and FlowExecutor's tool-call node - goes - * through {@link buildToolRunContext}, so the `ToolExecutionOptions` the + * through {@link buildToolRunContext}, so the `ToolExecutionContext` the * type promises (`toolCallId`, `messages`, `abortSignal`) are always real. */ import { newId } from '../utils/id'; -import type { ToolExecutionOptions } from 'ai'; import type { Message } from '../providers'; -import type { RunUsage } from '../models/usage'; +import type { ToolExecutionContext } from '../types/tool'; import { bindToolCallScope, type ToolCallScope } from './subagentRuntime'; -/** Extra context the executor hands a tool next to the 'ai' SDK's own execute options (LOU-V5). */ -export interface ToolRunContext { - /** Called by the delegate tool with a finished child run's usage, so the parent run adds it to its totals. */ - onDelegatedUsage?: (usage: RunUsage) => void; - /** - * LOU-U9: the model's id for this tool call - unchanged when a call that - * was running when the process died is re-run on resume, so tools can use - * it as an idempotency key. - */ - toolCallId?: string; -} +/** + * @deprecated Use {@link ToolExecutionContext}, the one public execute-context + * type. Kept as an alias of its fields (all optional, `messages` left out, as + * before) so existing imports compile and 'ai' `tool()` execute options still + * fit it. + */ +export type ToolRunContext = Partial>; /** What {@link buildToolRunContext} builds the context from. */ -export interface ToolRunInput extends ToolRunContext { +export interface ToolRunInput extends Pick { /** The run's transcript. The tool gets the part before the model turn that made this call. */ messages?: readonly Message[]; /** The run's cancellation signal; the tool gets it as `abortSignal`. */ @@ -38,7 +33,7 @@ export interface ToolRunInput extends ToolRunContext { * A read-only copy of what the model had seen when it made `toolCallId`'s * call: the transcript without the system prompt and without the assistant * turn that made the call (or anything after it) - the same meaning - * `ToolExecutionOptions.messages` has in the 'ai' SDK. + * `ToolExecutionContext.messages` has (as in the 'ai' SDK). */ function transcriptBefore(messages: readonly Message[], toolCallId: string): readonly Message[] { const turn = messages.findIndex((m) => m.toolCalls?.some((call) => call.id === toolCallId)); @@ -48,17 +43,18 @@ function transcriptBefore(messages: readonly Message[], toolCallId: string): rea /** * The context for one tool call: `{ toolCallId, messages, abortSignal }` - * plus the SDK's own {@link ToolRunContext} fields. `toolCallId` falls back + * plus the optional `sessionId` and `onDelegatedUsage`. `toolCallId` falls back * to a generated id for callers with no model turn behind the call. */ -export function buildToolRunContext(input: ToolRunInput): ToolExecutionOptions & ToolRunContext { +export function buildToolRunContext(input: ToolRunInput): ToolExecutionContext { const toolCallId = input.toolCallId ?? newId('call'); - const ctx = { + const ctx: ToolExecutionContext = { toolCallId, messages: transcriptBefore(input.messages ?? [], toolCallId), abortSignal: input.signal, onDelegatedUsage: input.onDelegatedUsage, - } as unknown as ToolExecutionOptions & ToolRunContext; + ...(input.sessionId !== undefined && { sessionId: input.sessionId }), + }; bindToolCallScope(ctx, input.scope); return ctx; } diff --git a/src/tools/defineTool.test-d.ts b/src/tools/defineTool.test-d.ts index 38ab634e5..adbcfcded 100644 --- a/src/tools/defineTool.test-d.ts +++ b/src/tools/defineTool.test-d.ts @@ -1,10 +1,12 @@ import { describe, it, expectTypeOf } from 'vitest'; import { z } from 'zod'; -import type { ToolExecutionOptions } from 'ai'; +import { tool as aiTool } from 'ai'; +import type { Message } from '../providers/llm'; import { defineTool, type DefinedTool, type ToolInput, type ToolOutput } from './defineTool'; import { createAgent } from '../createAgent'; import { createMockProvider } from '../providers/mock'; import { ToolRegistry } from './ToolRegistry'; +import type { ToolDescriptor, ToolExecutionContext } from '../types'; const sendEmail = defineTool({ name: 'send_email', @@ -19,7 +21,7 @@ const sendEmail = defineTool({ async execute(args, ctx) { expectTypeOf(args.to).toBeString(); expectTypeOf(args.count).toBeNumber(); - expectTypeOf(ctx).toEqualTypeOf(); + expectTypeOf(ctx).toEqualTypeOf(); // @ts-expect-error - `nope` is not an argument of this tool void args.nope; return { messageId: 'x', sent: args.count }; @@ -48,10 +50,39 @@ describe('defineTool types', () => { 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).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); + void t.execute({ to: 'a@b.c', count: 'x' }, {} as ToolExecutionContext); + }); + + it('types ctx as our ToolExecutionContext, not ai\'s options (LOU-D23)', () => { + defineTool({ + name: 'ctx_probe', + description: 'd', + input: z.object({ q: z.string() }), + execute: async ({ q }, ctx) => { + expectTypeOf(q).toBeString(); + expectTypeOf(ctx).toEqualTypeOf(); + expectTypeOf(ctx.toolCallId).toBeString(); + expectTypeOf(ctx.messages).toEqualTypeOf(); + expectTypeOf(ctx.abortSignal).toEqualTypeOf(); + expectTypeOf(ctx.sessionId).toEqualTypeOf(); + return q; + }, + }); + expectTypeOf().parameter(1).toEqualTypeOf(); + expectTypeOf>() + .parameter(2) + .toEqualTypeOf(); + }); + + it('still registers a legacy ai tool() descriptor', () => { + const legacy: ToolDescriptor = { + displayName: 'legacy', + tool: aiTool({ description: 'd', parameters: z.object({ a: z.string() }), execute: async ({ a }) => a }), + }; + new ToolRegistry().register('legacy', legacy); }); it('rejects execute args that do not match the schema', () => { diff --git a/src/tools/defineTool.ts b/src/tools/defineTool.ts index 590348a8a..b6e2a9af4 100644 --- a/src/tools/defineTool.ts +++ b/src/tools/defineTool.ts @@ -1,6 +1,5 @@ -import type { ToolExecutionOptions } from 'ai'; import type { z } from 'zod'; -import type { ToolDescriptor } from '../types'; +import type { ToolDescriptor, ToolExecutionContext } from '../types'; import type { SandboxAdapter } from '../security/sandboxCore'; /** Tool names must satisfy the constraint LLM providers impose on function names. */ @@ -28,7 +27,7 @@ export interface DefineToolOptions { /** 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; + execute: (args: z.output, ctx: ToolExecutionContext) => R | Promise; } /** @@ -45,7 +44,7 @@ export interface DefinedTool readonly input: S; /** Same schema as `input`. */ readonly inputSchema: S; - execute(args: z.output, ctx: ToolExecutionOptions): Promise; + execute(args: z.output, ctx: ToolExecutionContext): Promise; /** Type-only marker; never set at runtime. */ readonly _types?: { input: z.output; output: O }; } @@ -131,13 +130,14 @@ export function defineTool( ): DefinedTool> { assertValidOptions(opts); - const execute = async (args: z.output, ctx: ToolExecutionOptions): Promise> => + const execute = async (args: z.output, ctx: ToolExecutionContext): 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, + // legacy (.tool): `ai` v4 types `messages` as CoreMessage[]; the SDK always passes ours. Removed in D26. + execute: execute as ToolDescriptor['tool']['execute'], }; const defined: DefinedTool> = { name: opts.name, diff --git a/src/types/agent.ts b/src/types/agent.ts index 0a82f7f43..0bfa95f6b 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -1,4 +1,4 @@ -import { CoreMessage } from 'ai'; +import type { Message } from '../providers/llm'; import { ToolConfiguration } from './tool'; import { AgentFlow } from './flow'; @@ -52,7 +52,7 @@ export interface AgentConfig { export interface AgentExecutionOptions { streaming?: boolean; sessionId: string; - messages: CoreMessage[]; + messages: Message[]; attachments?: any[]; locale?: string; timezone?: string; diff --git a/src/types/tool.ts b/src/types/tool.ts index 5adb53473..90a017b63 100644 --- a/src/types/tool.ts +++ b/src/types/tool.ts @@ -1,5 +1,7 @@ -import type { Tool as AITool, ToolExecutionOptions } from 'ai'; +import type { Tool as AITool } from 'ai'; // legacy (.tool), removed in D26 import type { z } from 'zod'; +import type { RunUsage } from '../models/usage'; +import type { Message } from '../providers/llm'; import { SandboxAdapter } from '../security/sandboxCore'; /** @@ -30,6 +32,30 @@ export interface ToolConfiguration { options?: Record; } +/** + * The second argument of a tool's `execute(args, ctx)`: what the SDK tells + * a tool about the call it is running. The same object reaches `defineTool`'s + * `execute`, `ToolDescriptor.execute` and `sandboxExecute`, on every path + * that runs a tool (main loop, resume after an approval, sandbox, flow node). + * Structurally it stays assignable from the `ai` SDK's own execute options. + */ +export interface ToolExecutionContext { + /** + * The model's id for this tool call. It stays the same when a call that + * was running when the process died is re-run on resume, so a tool can use + * it as an idempotency key (LOU-U9). + */ + toolCallId: string; + /** A read-only copy of the transcript the model had seen before it made this call. */ + messages: readonly Message[]; + /** The run's cancellation signal, set when the run has one. */ + abortSignal?: AbortSignal; + /** The session the run belongs to, when it has one. */ + sessionId?: string; + /** Called by the delegate tool with a finished child run's usage, so the parent run adds it to its totals (LOU-V5). */ + onDelegatedUsage?: (usage: RunUsage) => void; +} + /** * Tool descriptor with display name */ @@ -45,7 +71,7 @@ export interface ToolDescriptor { * 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; + execute?(args: unknown, ctx: ToolExecutionContext): unknown; /** * Legacy: an `ai` v4 `Tool` (`{ description, parameters, execute }`), * kept for compatibility this release. Prefer `inputSchema` and `execute`. @@ -77,14 +103,14 @@ export interface ToolDescriptor { * silently falling back to unsandboxed in-process execution. * * LOU-U15: the third argument is the same execute context - * `tool.execute()` receives (`toolCallId`, `messages`, `abortSignal`). + * `tool.execute()` receives (a {@link ToolExecutionContext}). * It is optional to declare: an implementation that takes only * `(args, sandbox)` keeps working. */ sandboxExecute?: ( args: unknown, sandbox: SandboxAdapter, - ctx?: ToolExecutionOptions + ctx?: ToolExecutionContext ) => Promise; }