diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ddaad700..99804f19e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - 2026-09-28 ### Added +- One tool-error shape (LOU-U14): every failed tool call reaches the model as `{ error, toolName, message, kind }` (message capped at 2,000 characters, transcript message flagged `isError`), whichever path failed: `kind` is `'execution'`, `'validation'` (plus `issues`), `'not-found'`, `'rejected'` (plus `note`), `'not-run'`, `'mcp'` or `'sandbox'`. New exports `toolErrorResult()`, `ToolErrorResult`, `ToolErrorKind` and `ToolErrorInput`; `McpToolError` and the sandbox guard's error (named `SandboxRequiredError`, not exported) carry a `toolErrorKind`. `kind` is additive on thrown-error and validation results. The paths that built their own shapes changed, see Changed. See docs/tools.md. - Background sub-agents end with the lead run (LOU-Y4.2): when a run ends (any `finishReason`, or an error), its background sub-agents still queued or running are cancelled, and the run resolves once their runs have stopped, so none of their events reaches `onEvent` afterwards. `withSubagentOptions(subagents, { awaitBackgroundOnFinish: true })` waits for them instead (an aborted or failed lead still cancels them). The new `result.backgroundTasks` lists the run's background tasks as `{ taskId, agent, status, elapsedMs, ... }` (absent when it started none). `createAgent({ subagentOptions: { maxConcurrent, awaitBackgroundOnFinish } })` sets the same options (over any attached with `withSubagentOptions()`, without changing the `subagents` value). The executor hook behind it is public: `ExecuteOptions.onRunEnd({ result } | { error })` is called exactly once per `execute()` / `stream()` run, however it ends; an error it throws rejects the run. See docs/sub-agents.md. - Durable sessions (LOU-W9): `agent.session({ id, store, checkpointStore })` (or a `store` that carries both, `{ sessions, checkpoints }`, such as a `SqliteStore`) runs each turn with `sessionId: '.turn-'` and that `CheckpointStore`, so it is checkpointed after every model response and tool result. `session.resume()` finishes a turn interrupted by a crash, a failed checkpoint write or a `PropagatingToolError` (also in a new process) without re-running recorded tool calls or model responses, adds it to the transcript as `send()` would and returns its result (`null` when nothing is pending); `session.pending()` reports an unfinished turn and `session.discardPending()` drops it. `send()` / `stream()` resume a pending turn before sending the new message. A turn paused for approval stays in its checkpoint until it finishes: `resume()` / `send()` throw `SessionAwaitingApprovalError`, and `agent.approvals.resolve()` continues it in the session. Sessions without a checkpoint store are unchanged. See docs/sessions.md. - Structured output (LOU-V4): `createAgent({ output: zodSchema })` (and `ExecuteOptions.output`) makes the final reply a JSON object validated by the schema. `send()`, `run.result` and `agent.approvals.resolve()` resolve with it as `result.object`, typed `z.output` from `send()` and `stream()` of `createAgent()` agents (`SimpleAgent`, `AgentRun` and `ExecutionResult` gain a type parameter that defaults to `unknown`); `result.text` keeps the raw JSON. The system prompt gets an `## Output format` section with the schema as JSON Schema, and each model call carries the new `GenerateOptions.responseFormat: { type: 'json', schema? }` hint, which the `ai`-SDK providers map to `experimental_output` JSON mode. The reply is parsed (a code fence is tolerated) and validated; when invalid, one repair step sends the model the issues (it counts against `maxSteps`), and a reply still invalid ends the run with the new `finishReason: 'output-invalid'`, no `object` and `outputError: { message, issues }`. `run.done` gains an optional `object` field (additive: the event schema version stays 1). See docs/structured-output.md. @@ -34,6 +35,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 +- 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 }`. - Heavy dependencies are optional peers (LOU-D40): `dockerode` (Docker sandboxing, `SubprocessSandbox`), `@modelcontextprotocol/sdk` (`serveMcp`, `loushy mcp`, MCP clients) and `prompts` (the interactive questions of `loushy init`) moved from `dependencies` to `peerDependencies`, marked optional in `peerDependenciesMeta`. They were already loaded lazily; now a project that uses none of them does not install them. Using one without its package fails that call with `MissingPeerDependencyError`, which now also carries `feature` and names the exact command (for example `npm install dockerode@^5.0.1`). `loushy doctor` lists the three with what they enable (a missing `dockerode` fails the check when the agent spec uses a sandboxed tool), and its version lookup no longer returns `undefined` for packages whose `exports` map hides `package.json`. **Migration:** install the peer if you use Docker sandboxing (`npm install dockerode`), MCP (`npm install @modelcontextprotocol/sdk`), or interactive `loushy init` (`npm install prompts`; `npm create loushy-agent` already does, and `loushy init --yes` needs nothing). See docs/installation.md. - The `http` tool no longer loads `undici` for default requests (LOU-D40): with TLS verification on (the default) it uses the runtime's global `fetch`; `undici` (still a regular dependency) is loaded only for `validateSSL: false`, whose per-request TLS dispatcher the global `fetch` cannot express. `yaml` stays a dependency (agent specs and skills need a YAML parser). diff --git a/docs/api-overview.md b/docs/api-overview.md index 63b5d55f6..7c2e4fe42 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -408,6 +408,7 @@ result; `preToolCall` hooks are skipped because there is no valid call): "error": "ToolArgumentsValidationError", "toolName": "sendEmail", "message": "Invalid arguments for tool 'sendEmail': 2 issues (to: Required; count: Expected number, received string)", + "kind": "validation", "issues": [ { "path": "to", "message": "Required" }, { "path": "count", "message": "Expected number, received string" } @@ -423,9 +424,13 @@ the tool name and the message only (never a stack trace). Messages are capped at 2,000 characters and end with `... (truncated)` when cut: ```json -{ "error": "TypeError", "toolName": "search", "message": "query must not be empty" } +{ "error": "TypeError", "toolName": "search", "message": "query must not be empty", "kind": "execution" } ``` +Every other failure (unknown tool, rejected approval, a call that was not run, +an MCP error, a refused sandboxed tool) uses the same `{ error, toolName, +message, kind }` shape; see [Errors](./tools.md#errors) for the `kind` values. + Errors extending `PropagatingToolError` (for example the delegation depth guard) are the exception: they are rethrown and abort the run instead of being shown to the model. diff --git a/docs/tools.md b/docs/tools.md index c61ac1436..88162e365 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -54,8 +54,8 @@ descriptors that set neither `inputSchema` nor `execute`. the model gets a structured `ToolArgumentsValidationError` result and can retry. - **Errors are results.** A tool that throws gives the model - `{ error, toolName, message }` (no stack trace) and the run continues. See - [Tool errors](./api-overview.md#tool-errors) for the exact shapes. + `{ error, toolName, message, kind }` (no stack trace) and the run continues. + See [Errors](#errors). - **Parallel calls.** Several calls in one model turn run concurrently; cap them with `toolConcurrency` (`1` for strictly sequential). Results reach the transcript in the model's call order. See @@ -66,6 +66,37 @@ descriptors that set neither `inputSchema` nor `execute`. once across a crash; use `ctx.toolCallId` as an idempotency key. See [Durable execution](./durable-execution.md#at-least-once-tools-make-side-effects-idempotent). +## Errors + +Every way a tool call can fail reaches the model as the same result, so one +check works everywhere (including the call that runs after an approval): + +```json +{ "error": "TypeError", "toolName": "search", "message": "query must not be empty", "kind": "execution" } +``` + +- `error`: the error's name (`TypeError`, `ToolArgumentsValidationError`, ...), or the kind's default name for a failure that is not a thrown error. +- `toolName` and `message`: the message only, never a stack, capped at 2,000 characters (`... (truncated)` marks a cut). +- `kind`: why the call failed. +- Some kinds add fields: `issues` for `validation`, `note` for `rejected`. + +The transcript message carries `isError: true`; `tool-result` events, `onToolResult`, `postToolCall` hooks and `tool.error` events see the call as failed. + +| `kind` | `error` | When | +| ------ | ------- | ---- | +| `execution` | the thrown error's name | `execute` (or `needsApproval`) threw. | +| `validation` | `ToolArgumentsValidationError` | The arguments did not match `inputSchema`; `execute` did not run. Adds `issues`. | +| `not-found` | `ToolNotFoundError` | The model called a tool the registry does not have (or the run has no registry). | +| `rejected` | `ToolRejectedError` | A reviewer rejected the call at an approval. Adds `note` when given. | +| `not-run` | `ToolNotRunError` | The call was never started (a resumed run whose approval was saved without its remaining calls). | +| `mcp` | `McpToolError` | An MCP server answered `isError: true`; `message` is the server's text. | +| `sandbox` | `SandboxRequiredError` | The tool has `requiresSandbox` but no `sandboxExecute`, so it was refused rather than run unsandboxed. | + +`toolErrorResult({ toolName, error, kind?, toolCallId?, details? })` builds this +result; use it in your own tool wrappers so they match. A thrown error can pick +its kind by carrying a `toolErrorKind` property. An error extending +`PropagatingToolError` is not a result: it aborts the run. + ## Built-in tools | Export | Tool | diff --git a/llms-full.txt b/llms-full.txt index 1ad09fcaf..964e3f85b 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1088,6 +1088,7 @@ result; `preToolCall` hooks are skipped because there is no valid call): "error": "ToolArgumentsValidationError", "toolName": "sendEmail", "message": "Invalid arguments for tool 'sendEmail': 2 issues (to: Required; count: Expected number, received string)", + "kind": "validation", "issues": [ { "path": "to", "message": "Required" }, { "path": "count", "message": "Expected number, received string" } @@ -1103,9 +1104,13 @@ the tool name and the message only (never a stack trace). Messages are capped at 2,000 characters and end with `... (truncated)` when cut: ```json -{ "error": "TypeError", "toolName": "search", "message": "query must not be empty" } +{ "error": "TypeError", "toolName": "search", "message": "query must not be empty", "kind": "execution" } ``` +Every other failure (unknown tool, rejected approval, a call that was not run, +an MCP error, a refused sandboxed tool) uses the same `{ error, toolName, +message, kind }` shape; see [Errors](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/tools.md#errors) for the `kind` values. + Errors extending `PropagatingToolError` (for example the delegation depth guard) are the exception: they are rethrown and abort the run instead of being shown to the model. @@ -5273,8 +5278,8 @@ descriptors that set neither `inputSchema` nor `execute`. the model gets a structured `ToolArgumentsValidationError` result and can retry. - **Errors are results.** A tool that throws gives the model - `{ error, toolName, message }` (no stack trace) and the run continues. See - [Tool errors](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/api-overview.md#tool-errors) for the exact shapes. + `{ error, toolName, message, kind }` (no stack trace) and the run continues. + See [Errors](#errors). - **Parallel calls.** Several calls in one model turn run concurrently; cap them with `toolConcurrency` (`1` for strictly sequential). Results reach the transcript in the model's call order. See @@ -5285,6 +5290,37 @@ descriptors that set neither `inputSchema` nor `execute`. once across a crash; use `ctx.toolCallId` as an idempotency key. See [Durable execution](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/durable-execution.md#at-least-once-tools-make-side-effects-idempotent). +## Errors + +Every way a tool call can fail reaches the model as the same result, so one +check works everywhere (including the call that runs after an approval): + +```json +{ "error": "TypeError", "toolName": "search", "message": "query must not be empty", "kind": "execution" } +``` + +- `error`: the error's name (`TypeError`, `ToolArgumentsValidationError`, ...), or the kind's default name for a failure that is not a thrown error. +- `toolName` and `message`: the message only, never a stack, capped at 2,000 characters (`... (truncated)` marks a cut). +- `kind`: why the call failed. +- Some kinds add fields: `issues` for `validation`, `note` for `rejected`. + +The transcript message carries `isError: true`; `tool-result` events, `onToolResult`, `postToolCall` hooks and `tool.error` events see the call as failed. + +| `kind` | `error` | When | +| ------ | ------- | ---- | +| `execution` | the thrown error's name | `execute` (or `needsApproval`) threw. | +| `validation` | `ToolArgumentsValidationError` | The arguments did not match `inputSchema`; `execute` did not run. Adds `issues`. | +| `not-found` | `ToolNotFoundError` | The model called a tool the registry does not have (or the run has no registry). | +| `rejected` | `ToolRejectedError` | A reviewer rejected the call at an approval. Adds `note` when given. | +| `not-run` | `ToolNotRunError` | The call was never started (a resumed run whose approval was saved without its remaining calls). | +| `mcp` | `McpToolError` | An MCP server answered `isError: true`; `message` is the server's text. | +| `sandbox` | `SandboxRequiredError` | The tool has `requiresSandbox` but no `sandboxExecute`, so it was refused rather than run unsandboxed. | + +`toolErrorResult({ toolName, error, kind?, toolCallId?, details? })` builds this +result; use it in your own tool wrappers so they match. A thrown error can pick +its kind by carrying a `toolErrorKind` property. An error extending +`PropagatingToolError` is not a result: it aborts the run. + ## Built-in tools | Export | Tool | diff --git a/src/createAgentApprovals.test.ts b/src/createAgentApprovals.test.ts index b7716c41e..d1ded5c8d 100644 --- a/src/createAgentApprovals.test.ts +++ b/src/createAgentApprovals.test.ts @@ -64,7 +64,10 @@ describe('createAgent approvals (LOU-D21)', () => { const rejection = toolResult(model.calls[1].messages as Message[]); expect(rejection?.isError).toBe(true); expect(JSON.parse(rejection?.content as string)).toEqual({ - error: 'Tool execution was rejected by the reviewer', + error: 'ToolRejectedError', + toolName: 'send_email', + message: 'Tool execution was rejected by the reviewer', + kind: 'rejected', note: 'Not today', }); }); diff --git a/src/execution/durableExecution.test.ts b/src/execution/durableExecution.test.ts index c4d61e128..e7a7074da 100644 --- a/src/execution/durableExecution.test.ts +++ b/src/execution/durableExecution.test.ts @@ -182,7 +182,7 @@ describe('LOU-U7: approval in the middle of a tool batch', () => { expect(runs).toEqual({ a: 1, b: 0, c: 1 }); const [, rejected, c] = result.messages.filter((m) => m.role === 'tool'); expect(rejected).toMatchObject({ toolCallId: 'call_b', isError: true }); - expect(JSON.parse(rejected.content)).toEqual({ error: expect.stringContaining('rejected'), note: 'not today' }); + expect(JSON.parse(rejected.content)).toMatchObject({ kind: 'rejected', message: expect.stringContaining('rejected'), note: 'not today' }); expect(c).toMatchObject({ toolCallId: 'call_c', content: '"c done"' }); }); @@ -248,7 +248,7 @@ describe('LOU-U7: approval in the middle of a tool batch', () => { expect(toolIds(result.messages)).toEqual(['call_a', 'call_b', 'call_c']); const c = result.messages.find((m) => m.toolCallId === 'call_c')!; expect(c.isError).toBe(true); - expect(JSON.parse(c.content).error).toMatch(/not run/); + expect(JSON.parse(c.content)).toMatchObject({ kind: 'not-run', message: expect.stringMatching(/not run/) }); }); }); diff --git a/src/execution/index.ts b/src/execution/index.ts index 5d8201b02..7118829d1 100644 --- a/src/execution/index.ts +++ b/src/execution/index.ts @@ -20,6 +20,8 @@ export * from './guardrails'; export * from './hooks'; export { ToolArgumentsValidationError } from './toolArgsValidation'; export type { ToolArgumentIssue } from './toolArgsValidation'; +export { toolErrorResult } from './toolErrors'; +export type { ToolErrorKind, ToolErrorResult, ToolErrorInput } from './toolErrors'; export type { OutputError } from './structuredOutput'; export type { ToolConcurrency } from './toolBatch'; export { diff --git a/src/execution/propagatingToolError.ts b/src/execution/propagatingToolError.ts index de42b30eb..0c28d0863 100644 --- a/src/execution/propagatingToolError.ts +++ b/src/execution/propagatingToolError.ts @@ -59,44 +59,3 @@ export function toolErrorMessage(error: unknown): string { } return (error as Error | undefined)?.message ?? String(error); } - -/** Max characters of an error message sent to the model (see {@link toolErrorResult}). */ -const MAX_TOOL_ERROR_MESSAGE_LENGTH = 2000; - -/** - * The structured tool result the model sees when a tool's `execute` throws - * (mirrors `ToolArgumentsValidationError.toToolResult()`, minus `issues`). - * - * @example - * ```ts - * // execute: async () => { throw new TypeError('bad input') } - * // tool result the model sees: - * // { error: 'TypeError', toolName: 'search', message: 'bad input' } - * ``` - */ -interface ToolErrorResult { - /** The thrown error's `name` (`'Error'`, `'TypeError'`, ...). */ - error: string; - toolName: string; - /** The error message only (never a stack), truncated to 2,000 characters. */ - message: string; -} - -/** - * Builds the {@link ToolErrorResult} for a thrown tool error. Only the name - * and (length-capped) message are exposed - never the stack - so a huge or - * sensitive error cannot blow the context window. - */ -export function toolErrorResult(toolName: string, error: unknown): ToolErrorResult { - const err = error as { name?: unknown; message?: unknown } | null | undefined; - const raw = typeof err?.message === 'string' ? err.message : String(error); - const message = - raw.length > MAX_TOOL_ERROR_MESSAGE_LENGTH - ? `${raw.slice(0, MAX_TOOL_ERROR_MESSAGE_LENGTH)}... (truncated)` - : raw; - return { - error: typeof err?.name === 'string' && err.name ? err.name : 'Error', - toolName, - message, - }; -} diff --git a/src/execution/resume.test.ts b/src/execution/resume.test.ts index d028c8913..7b5e1f86c 100644 --- a/src/execution/resume.test.ts +++ b/src/execution/resume.test.ts @@ -244,7 +244,7 @@ describe('Execution - resumeAfterApproval', () => { const toolMessage = resumed.messages[paused.messages.length]; expect(toolMessage.role).toBe('tool'); const parsed = JSON.parse(toolMessage.content); - expect(parsed.error).toBe('payment gateway timeout'); + expect(parsed).toEqual({ error: 'Error', toolName: 'chargeCard', message: 'payment gateway timeout', kind: 'execution' }); expect(toolMessage.isError).toBe(true); }); diff --git a/src/execution/resume.ts b/src/execution/resume.ts index 7a8b32b04..4ed4e7d2d 100644 --- a/src/execution/resume.ts +++ b/src/execution/resume.ts @@ -19,6 +19,7 @@ import { NoopSandbox } from '../security/sandboxCore'; import { executeToolWithSandboxGuard } from './sandboxGuard'; import { HookRegistry } from './hooks'; import { toolErrorMessage } from './propagatingToolError'; +import { toolErrorResult, type ToolErrorResult } from './toolErrors'; import { splitPendingTurn } from './transcript'; import { replaceToolResult, type ToolCallScope } from './subagentRuntime'; import type { RunUsage } from '../models/usage'; @@ -173,7 +174,16 @@ async function decidedToolMessage( } if (!ctx.decision.approved) { return { - message: toolResultMessage(pending, { error: 'Tool execution was rejected by the reviewer', note: ctx.decision.note }, true), + message: toolResultMessage( + pending, + toolErrorResult({ + toolName: pending.toolName, + error: 'Tool execution was rejected by the reviewer', + kind: 'rejected', + details: { note: ctx.decision.note }, + }), + true + ), }; } // A sub-agent the approved tool starts inherits this resumed run's runtime. @@ -276,9 +286,13 @@ function closeUnlistedToolCalls(messages: Message[], remaining: ToolCall[] | und } messages.push({ role: 'tool', - content: JSON.stringify({ - error: 'Tool call was not run: the run paused for approval before reaching it and this approval was saved without its remaining calls', - }), + content: JSON.stringify( + toolErrorResult({ + toolName: call.function.name, + error: 'Tool call was not run: the run paused for approval before reaching it and this approval was saved without its remaining calls', + kind: 'not-run', + }) + ), name: call.function.name, toolCallId: call.id, toolName: call.function.name, @@ -319,7 +333,15 @@ async function runApprovedToolCall( // means there is genuinely no way to run the tool (neither a direct // `execute` nor a `sandboxExecute`). if (!toolDesc || !toolDesc.tool || (!getToolExecute(toolDesc) && !toolDesc.sandboxExecute)) { - throw new Error(`Tool '${pending.toolName}' not found in registry`); + return toolResultMessage( + pending, + toolErrorResult({ + toolName: pending.toolName, + error: `Tool '${pending.toolName}' not found in registry`, + kind: 'not-found', + }), + true + ); } // LOU-Q1: hooks must fire for this deferred, post-approval execution @@ -349,7 +371,7 @@ async function runApprovedToolCall( await hooks.runPreToolCall(hookCtx); } - const { result, toolError } = await executeApprovedTool(pending, toolDesc, hookArgs, executeOptions, scope); + const { result, toolError, errorResult } = await executeApprovedTool(pending, toolDesc, hookArgs, executeOptions, scope); // Fires (with the settled result/error) regardless of how the tool // settled - matching AgentHook.postToolCall's documented contract @@ -362,7 +384,7 @@ async function runApprovedToolCall( await hooks.runPostToolCall(hookCtx, { result, error: toolError }); } - return toolResultMessage(pending, toolError ? { error: toolError } : result, Boolean(toolError)); + return toolResultMessage(pending, errorResult ?? result, errorResult !== undefined); } /** @@ -386,7 +408,7 @@ async function executeApprovedTool( args: Record, executeOptions: ResumeExecuteOptions, scope?: ToolCallScope -): Promise<{ result: unknown; toolError?: string }> { +): Promise<{ result: unknown; toolError?: string; errorResult?: ToolErrorResult }> { try { // Mirrors AgentExecutor.executeToolCall()'s fail-closed handling of // `requiresSandbox` tools (LOU-F5) via the shared @@ -421,11 +443,13 @@ async function executeApprovedTool( // the pending-approval record has already been deleted // (ApprovalGate.resolve() is delete-on-read), so failing to catch // here would mean the whole resume just fails with no retry path. - // Uses the same `(error as Error).message` extraction AgentExecutor's - // catch block uses, wrapped in the `{error}`-shaped payload the - // rejection branch of resumeAfterApproval() already uses for - // non-approved decisions. - return { result: undefined, toolError: toolErrorMessage(error) }; + // LOU-U14: the model gets the same structured error as in the main loop; + // `toolError` keeps the plain message for the postToolCall hook. + return { + result: undefined, + toolError: toolErrorMessage(error), + errorResult: toolErrorResult({ toolName: pending.toolName, error }), + }; } } diff --git a/src/execution/sandboxGuard.ts b/src/execution/sandboxGuard.ts index 0f5c91ee1..e6e70e45d 100644 --- a/src/execution/sandboxGuard.ts +++ b/src/execution/sandboxGuard.ts @@ -25,6 +25,19 @@ import { SandboxAdapter } from '../security/sandboxCore'; import { bindToolCallScope, type ToolCallScope } from './subagentRuntime'; import type { RunUsage } from '../models/usage'; +/** + * Thrown when a `requiresSandbox` tool cannot be sandboxed (LOU-U14: reaches + * the model as an error result of `kind: 'sandbox'`). + */ +class SandboxRequiredError extends Error { + readonly toolErrorKind = 'sandbox' as const; + + constructor(message: string) { + super(message); + this.name = 'SandboxRequiredError'; + } +} + /** 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. */ @@ -67,7 +80,7 @@ export async function executeToolWithSandboxGuard( ): Promise { if (toolDesc.requiresSandbox) { if (!toolDesc.sandboxExecute) { - throw new Error( + throw new SandboxRequiredError( `Tool "${toolName}" is flagged requiresSandbox but does not implement sandboxExecute() ` + `- cannot be safely sandboxed, refusing to fall back to unsandboxed execution` ); diff --git a/src/execution/toolArgsValidation.ts b/src/execution/toolArgsValidation.ts index 8a3aa8966..16571d8ef 100644 --- a/src/execution/toolArgsValidation.ts +++ b/src/execution/toolArgsValidation.ts @@ -6,6 +6,7 @@ import { ToolDescriptor } from '../types'; import { ToolExecutionError } from './errors'; import { getToolInputSchema } from '../tools/toolContract'; +import { toolErrorResult, type ToolErrorResult } from './toolErrors'; /** One problem found while validating a tool call's arguments. */ export interface ToolArgumentIssue { @@ -31,10 +32,13 @@ export function formatIssues(issues: ToolArgumentIssue[]): string { * // tool result the model sees: * // { error: 'ToolArgumentsValidationError', toolName: 'sendEmail', * // message: "Invalid arguments for tool 'sendEmail': 1 issue (to: Required)", - * // issues: [{ path: 'to', message: 'Required' }] } + * // kind: 'validation', issues: [{ path: 'to', message: 'Required' }] } * ``` */ export class ToolArgumentsValidationError extends ToolExecutionError { + /** Read by toolErrorResult() when this is thrown from a tool. */ + readonly toolErrorKind = 'validation' as const; + constructor( toolName: string, public readonly issues: ToolArgumentIssue[] @@ -44,16 +48,13 @@ export class ToolArgumentsValidationError extends ToolExecutionError { } /** The structured tool result handed to the model. */ - toToolResult(): { - error: 'ToolArgumentsValidationError'; - toolName: string; - message: string; - issues: ToolArgumentIssue[]; - } { + toToolResult(): ToolErrorResult & { issues: ToolArgumentIssue[] } { return { - error: 'ToolArgumentsValidationError', - toolName: this.toolName as string, - message: this.message, + ...toolErrorResult({ + toolName: this.toolName as string, + error: this, + kind: 'validation', + }), issues: this.issues, }; } diff --git a/src/execution/toolCallExecution.ts b/src/execution/toolCallExecution.ts index e9236e251..f89bd7c1d 100644 --- a/src/execution/toolCallExecution.ts +++ b/src/execution/toolCallExecution.ts @@ -12,7 +12,8 @@ import { SandboxAdapter } from '../security/sandboxCore'; import { executeToolWithSandboxGuard } from './sandboxGuard'; import type { ToolRunContext } from './sandboxGuard'; import { HookRegistry, ToolCallHookContext } from './hooks'; -import { toolErrorMessage, toolErrorResult } from './propagatingToolError'; +import { toolErrorMessage } from './propagatingToolError'; +import { toolErrorResult, type ToolErrorKind } from './toolErrors'; import { ToolArgumentsValidationError, validateToolArguments } from './toolArgsValidation'; import type { ExecuteOptions } from './AgentExecutor'; import type { SubagentSuspension } from './ApprovalGate'; @@ -243,17 +244,21 @@ async function checkToolArguments( } return { args: rawArgs as Record, - rejection: { ...toolFailure(toolCall, error.message), result: error.toToolResult() }, + rejection: { ...toolFailure(toolCall, 'validation', error.message), result: error.toToolResult() }, }; } } -/** The `{error}` outcome for a tool call that could not produce a result. */ -function toolFailure(toolCall: ToolCall, error: string): ToolCallOutcome { +/** + * The error outcome for a tool call that could not produce a result (LOU-U14): + * `error` keeps the plain message for events and hooks, `result` is the + * structured {@link toolErrorResult} the model sees. + */ +function toolFailure(toolCall: ToolCall, kind: ToolErrorKind, error: string): ToolCallOutcome { return { toolCallId: toolCall.id, toolName: toolCall.function.name, - result: null, + result: toolErrorResult({ toolName: toolCall.function.name, error, kind }), error, }; } @@ -298,8 +303,8 @@ function approvalOutcome(prepared: PreparedToolCall): ToolCallOutcome { */ function thrownToolFailure(toolCall: ToolCall, error: unknown): ToolCallOutcome { return { - ...toolFailure(toolCall, toolErrorMessage(error)), - result: toolErrorResult(toolCall.function.name, error), + ...toolFailure(toolCall, 'execution', toolErrorMessage(error)), + result: toolErrorResult({ toolName: toolCall.function.name, error }), }; } @@ -315,13 +320,13 @@ async function doExecuteToolCall( ): Promise { const { toolRegistry } = ctx; if (!toolRegistry) { - return toolFailure(toolCall, 'No tool registry available'); + return toolFailure(toolCall, 'not-found', 'No tool registry available'); } try { const toolDesc = findExecutableTool(toolRegistry, toolCall.function.name); if (!toolDesc) { - return toolFailure(toolCall, `Tool '${toolCall.function.name}' not found`); + return toolFailure(toolCall, 'not-found', `Tool '${toolCall.function.name}' not found`); } // `overrideArgs` is the (possibly hook-mutated) object built by diff --git a/src/execution/toolErrorResult.test.ts b/src/execution/toolErrorResult.test.ts index 010a0db14..13ca3763c 100644 --- a/src/execution/toolErrorResult.test.ts +++ b/src/execution/toolErrorResult.test.ts @@ -92,6 +92,7 @@ describe('thrown tool errors reach the model (LOU-U12)', () => { error: 'TypeError', toolName: 'boom', message: 'query must not be empty', + kind: 'execution', }); // The follow-up call succeeded. const secondTools = seenMessages[2].filter(m => m.role === 'tool'); @@ -126,7 +127,7 @@ describe('thrown tool errors reach the model (LOU-U12)', () => { await AgentExecutor.execute({ agent, input: 'go', provider, toolRegistry }); const payload = JSON.parse(seenMessages[1].find(m => m.role === 'tool').content); - expect(payload).toEqual({ error: 'Error', toolName: 'boom', message: 'plain string' }); + expect(payload).toEqual({ error: 'Error', toolName: 'boom', message: 'plain string', kind: 'execution' }); }); it('still aborts the run on a PropagatingToolError', async () => { diff --git a/src/execution/toolErrors.test.ts b/src/execution/toolErrors.test.ts new file mode 100644 index 000000000..26c032e38 --- /dev/null +++ b/src/execution/toolErrors.test.ts @@ -0,0 +1,352 @@ +/** + * LOU-U14: every tool failure path hands the model the same + * `{ error, toolName, message, kind }` result, flagged `isError`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { z } from 'zod'; +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { AgentExecutor, ExecutionEvent } from './AgentExecutor'; +import { InMemoryApprovalStore } from './InMemoryApprovalStore'; +import type { ApprovalStore } from './ApprovalGate'; +import { HookRegistry } from './hooks'; +import { resumeAfterApproval } from './resume'; +import { toolErrorResult, type ToolErrorKind } from './toolErrors'; +import { defineTool, DefinedTool } from '../tools/defineTool'; +import { ToolRegistry } from '../tools'; +import { loadMcpTools } from '../tools/mcp/McpToolLoader'; +import { mockModel, MockTurn } from '../testing'; +import { AgentConfig, AgentType } from '../types'; +import type { Message } from '../providers'; + +const input = z.object({ n: z.number() }); + +function simpleTool(name: string, extra: Partial[0]> = {}): DefinedTool { + return defineTool({ + name, + description: name, + input, + execute: async () => 'ok', + ...extra, + } as Parameters[0]); +} + +function agentFor(names: string[]): AgentConfig { + const tools: AgentConfig['tools'] = {}; + for (const name of names) tools[name] = { tool: name }; + return { id: 'agent-1', name: 'Agent', agentType: AgentType.SmartAssistant, prompt: 'p', tools }; +} + +function registryOf(tools: DefinedTool[]): ToolRegistry { + const registry = new ToolRegistry(); + registry.registerMany(tools); + return registry; +} + +const call = (name: string, args: Record = { n: 1 }): MockTurn => ({ + toolCalls: [{ name, args, id: `call_${name}` }], +}); + +/** The one `tool` message the model sees on its second turn, plus what observers saw. */ +interface Observed { + message: Message; + payload: Record; + hookError?: string; + /** The `tool-result` event's outcome: `tool.error` events are derived from it. */ + eventOutcome?: { error?: string; result?: { error?: string } }; +} + +async function runMainLoop( + registry: ToolRegistry | undefined, + toolName: string, + args?: Record +): Promise { + const model = mockModel([call(toolName, args), 'done']); + const postToolCall = vi.fn(); + const hooks = new HookRegistry(); + hooks.register({ name: 'spy', postToolCall }); + const events: ExecutionEvent[] = []; + await AgentExecutor.execute({ + agent: agentFor([toolName]), + input: 'go', + provider: model, + toolRegistry: registry, + hooks, + onEvent: (e) => events.push(e), + }); + const message = (model.calls[1].messages as Message[]).find((m) => m.role === 'tool')!; + const resultEvent = events.find((e) => e.type === 'tool-result') as { toolResult?: Observed['eventOutcome'] } | undefined; + return { + message, + payload: JSON.parse(message.content as string) as Record, + hookError: postToolCall.mock.calls[0]?.[1]?.error, + eventOutcome: resultEvent?.toolResult, + }; +} + +/** Pauses a run on `approve_me`, lets `tamper` adjust the store, then resumes with `decision`. */ +async function runResume(options: { + tools: DefinedTool[]; + calls?: string[]; + approved: boolean; + resumeTools?: DefinedTool[]; + dropRemaining?: boolean; +}): Promise { + const inner = new InMemoryApprovalStore(); + const approvalStore: ApprovalStore = { + save: async (pending, snapshot) => { + if (options.dropRemaining) delete snapshot.remainingToolCalls; + await inner.save(pending, snapshot); + }, + resolve: (id) => inner.resolve(id), + }; + const names = options.calls ?? ['approve_me']; + const paused = await AgentExecutor.execute({ + agent: agentFor(options.tools.map((t) => t.name)), + input: 'go', + provider: mockModel([{ toolCalls: names.map((name) => ({ name, args: { n: 1 }, id: `call_${name}` })) }]), + toolRegistry: registryOf(options.tools), + approvalStore, + }); + expect(paused.finishReason).toBe('awaiting-approval'); + const resumed = await resumeAfterApproval( + { id: paused.approvalId!, approved: options.approved, note: 'nope' }, + approvalStore, + registryOf(options.resumeTools ?? options.tools), + mockModel(['done']) + ); + return resumed.messages.filter((m) => m.role === 'tool'); +} + +const approveMe = (extra: Partial[0]> = {}) => + simpleTool('approve_me', { needsApproval: true, ...extra }); + +const boom = () => + simpleTool('boom', { + execute: async () => { + throw new TypeError('bad input'); + }, + } as never); + +const noSandbox = (extra: Partial[0]> = {}) => + simpleTool('risky', { requiresSandbox: true, ...extra }); + +function fakeMcpClient(): Client { + return { + listTools: async () => ({ tools: [{ name: 'do_it', description: 'Do it', inputSchema: { type: 'object' } }] }), + callTool: async () => ({ isError: true, content: [{ type: 'text', text: 'rate limited' }] }), + } as unknown as Client; +} + +interface Row { + path: string; + kind: ToolErrorKind; + error: string; + message: string | RegExp; + run: () => Promise<{ message: Message; payload: Record; observed?: Observed }>; +} + +const viaMain = (run: () => Promise): Row['run'] => + async () => { + const observed = await run(); + return { message: observed.message, payload: observed.payload, observed }; + }; + +const viaResume = (run: () => Promise, pick = 0): Row['run'] => + async () => { + const message = (await run())[pick]; + return { message, payload: JSON.parse(message.content as string) as Record }; + }; + +const rows: Row[] = [ + { + path: 'thrown error (main loop)', + kind: 'execution', + error: 'TypeError', + message: 'bad input', + run: viaMain(() => runMainLoop(registryOf([boom()]), 'boom')), + }, + { + path: 'validation failure (main loop)', + kind: 'validation', + error: 'ToolArgumentsValidationError', + message: /Invalid arguments for tool 'strict'/, + run: viaMain(() => runMainLoop(registryOf([simpleTool('strict')]), 'strict', { n: 'x' })), + }, + { + path: 'tool not found (main loop)', + kind: 'not-found', + error: 'ToolNotFoundError', + message: "Tool 'ghost' not found", + run: viaMain(() => runMainLoop(registryOf([]), 'ghost')), + }, + { + path: 'no registry (main loop)', + kind: 'not-found', + error: 'ToolNotFoundError', + message: 'No tool registry available', + run: viaMain(() => runMainLoop(undefined, 'ghost')), + }, + { + path: 'sandbox fail-closed (main loop)', + kind: 'sandbox', + error: 'SandboxRequiredError', + message: /requiresSandbox but does not implement sandboxExecute/, + run: viaMain(() => runMainLoop(registryOf([noSandbox()]), 'risky')), + }, + { + path: 'MCP isError result (main loop)', + kind: 'mcp', + error: 'McpToolError', + message: 'rate limited', + run: viaMain(async () => { + const registry = new ToolRegistry(); + registry.registerMany(await loadMcpTools(fakeMcpClient(), 'srv')); + return runMainLoop(registry, 'srv__do_it', {}); + }), + }, + { + path: 'rejected approval (resume)', + kind: 'rejected', + error: 'ToolRejectedError', + message: 'Tool execution was rejected by the reviewer', + run: viaResume(() => runResume({ tools: [approveMe()], approved: false })), + }, + { + path: 'remaining calls "not run" (resume, old snapshot)', + kind: 'not-run', + error: 'ToolNotRunError', + message: /Tool call was not run/, + run: viaResume( + () => + runResume({ + tools: [approveMe(), simpleTool('later')], + calls: ['approve_me', 'later'], + approved: true, + dropRemaining: true, + }), + 1 + ), + }, + { + path: 'tool not found (resume)', + kind: 'not-found', + error: 'ToolNotFoundError', + message: "Tool 'approve_me' not found in registry", + run: viaResume(() => runResume({ tools: [approveMe()], resumeTools: [], approved: true })), + }, + { + path: 'thrown error after approval (resume)', + kind: 'execution', + error: 'TypeError', + message: 'bad input', + run: viaResume(() => + runResume({ + tools: [ + approveMe({ + execute: async () => { + throw new TypeError('bad input'); + }, + } as never), + ], + approved: true, + }) + ), + }, + { + path: 'sandbox fail-closed after approval (resume)', + kind: 'sandbox', + error: 'SandboxRequiredError', + message: /requiresSandbox but does not implement sandboxExecute/, + run: viaResume(() => runResume({ tools: [approveMe({ requiresSandbox: true })], approved: true })), + }, +]; + +describe('one tool-error shape on every failure path (LOU-U14)', () => { + it.each(rows)('$path -> kind $kind', async (row) => { + const { message, payload, observed } = await row.run(); + + expect(message.isError).toBe(true); + expect(payload).toMatchObject({ error: row.error, kind: row.kind }); + expect(typeof payload.toolName).toBe('string'); + expect(payload.message).toEqual(typeof row.message === 'string' ? row.message : expect.stringMatching(row.message)); + expect(message.toolName).toBe(payload.toolName); + // Only the shared fields (plus per-kind extras) are present: no stack, no raw error. + expect(Object.keys(payload)).not.toContain('stack'); + + if (observed) { + // Hooks and events (which `tool.error` is derived from) agree that the call failed. + expect(observed.hookError).toBe(payload.message); + expect(observed.eventOutcome?.error).toBe(payload.message); + expect(observed.eventOutcome?.result?.error).toBe(row.error); + } + }); + + it('keeps the per-kind extras next to the shared fields', async () => { + const rejected = await viaResume(() => runResume({ tools: [approveMe()], approved: false }))(); + expect(rejected.payload).toEqual({ + error: 'ToolRejectedError', + toolName: 'approve_me', + message: 'Tool execution was rejected by the reviewer', + kind: 'rejected', + note: 'nope', + }); + + const model = mockModel([call('strict', { n: 'x' }), 'done']); + await AgentExecutor.execute({ + agent: agentFor(['strict']), + input: 'go', + provider: model, + toolRegistry: registryOf([simpleTool('strict')]), + }); + const message = (model.calls[1].messages as Message[]).find((m) => m.role === 'tool')!; + expect(JSON.parse(message.content as string)).toEqual({ + error: 'ToolArgumentsValidationError', + toolName: 'strict', + message: expect.stringContaining("Invalid arguments for tool 'strict'"), + kind: 'validation', + issues: [{ path: 'n', message: expect.any(String) }], + }); + }); +}); + +describe('toolErrorResult()', () => { + it('caps every message at 2,000 characters', () => { + const result = toolErrorResult({ toolName: 't', error: 'x'.repeat(5000), kind: 'not-run' }); + expect(result.message).toBe(`${'x'.repeat(2000)}... (truncated)`); + }); + + it('takes the name and message of an Error and defaults the kind to execution', () => { + expect(toolErrorResult({ toolName: 't', error: new RangeError('too big') })).toEqual({ + error: 'RangeError', + toolName: 't', + message: 'too big', + kind: 'execution', + }); + }); + + it('uses the kind an error declares, and a plain name for a plain message', () => { + const declared = Object.assign(new Error('x'), { toolErrorKind: 'mcp' }); + expect(toolErrorResult({ toolName: 't', error: declared }).kind).toBe('mcp'); + expect(toolErrorResult({ toolName: 't', error: 'gone', kind: 'not-found' }).error).toBe('ToolNotFoundError'); + expect(toolErrorResult({ toolName: 't', error: 42 }).message).toBe('42'); + }); + + it('adds the call id and details without letting details override the shared fields', () => { + const result = toolErrorResult({ + toolName: 't', + toolCallId: 'call_1', + error: 'no', + kind: 'rejected', + details: { note: 'why', message: 'spoofed', kind: 'mcp' }, + }); + expect(result).toEqual({ + error: 'ToolRejectedError', + toolName: 't', + message: 'no', + kind: 'rejected', + toolCallId: 'call_1', + note: 'why', + }); + }); +}); diff --git a/src/execution/toolErrors.ts b/src/execution/toolErrors.ts new file mode 100644 index 000000000..22709d6fd --- /dev/null +++ b/src/execution/toolErrors.ts @@ -0,0 +1,100 @@ +/** + * LOU-U14: the one shape of a failed tool call. Every failure path (a thrown + * error, invalid arguments, an unknown tool, a rejected approval, a call that + * was never run, an MCP `isError` result, a failed sandbox guard) hands the + * model the object {@link toolErrorResult} builds. + */ + +/** Why a tool call produced an error result. */ +export type ToolErrorKind = + | 'execution' + | 'validation' + | 'not-found' + | 'rejected' + | 'not-run' + | 'mcp' + | 'sandbox'; + +/** Max characters of an error message sent to the model (see {@link toolErrorResult}). */ +const MAX_TOOL_ERROR_MESSAGE_LENGTH = 2000; + +/** The `error` name used when the failure is a plain message rather than an `Error`. */ +const DEFAULT_ERROR_NAME: Record = { + execution: 'Error', + validation: 'ToolArgumentsValidationError', + 'not-found': 'ToolNotFoundError', + rejected: 'ToolRejectedError', + 'not-run': 'ToolNotRunError', + mcp: 'McpToolError', + sandbox: 'SandboxRequiredError', +}; + +/** + * The structured tool result the model sees for any failed tool call. + * + * @example + * ```ts + * // execute: async () => { throw new TypeError('bad input') } + * // tool result the model sees: + * // { error: 'TypeError', toolName: 'search', message: 'bad input', kind: 'execution' } + * ``` + */ +export interface ToolErrorResult { + /** The error's name (`'TypeError'`, `'ToolNotFoundError'`, ...); always a non-empty string. */ + error: string; + toolName: string; + /** The message only (never a stack), truncated to 2,000 characters. */ + message: string; + /** What kind of failure this is. */ + kind: ToolErrorKind; + /** The model's id for the call, when the caller supplied it. */ + toolCallId?: string; + /** Kind-specific extras, such as `issues` (validation) or `note` (rejected). */ + [extra: string]: unknown; +} + +const KINDS: ReadonlySet = new Set(Object.keys(DEFAULT_ERROR_NAME)); + +/** + * The kind a thrown error declares through its `toolErrorKind` property + * (`McpToolError`, the sandbox guard's error, ...), or `'execution'`. + */ +function declaredKind(error: unknown): ToolErrorKind { + const declared = (error as { toolErrorKind?: unknown } | null | undefined)?.toolErrorKind; + return typeof declared === 'string' && KINDS.has(declared) ? (declared as ToolErrorKind) : 'execution'; +} + +/** Input of {@link toolErrorResult}. */ +export interface ToolErrorInput { + toolName: string; + toolCallId?: string; + /** A thrown value (its `name` and `message` are used) or a plain message. */ + error: unknown; + /** Defaults to the kind the error declares, else `'execution'`. */ + kind?: ToolErrorKind; + /** Extra fields (`issues`, `note`, ...); they never override the standard ones. */ + details?: Record; +} + +/** + * Builds the {@link ToolErrorResult} for a failed tool call. Only the name + * and (length-capped) message are exposed - never the stack - so a huge or + * sensitive error cannot blow the context window. + */ +export function toolErrorResult(input: ToolErrorInput): ToolErrorResult { + const { toolName, toolCallId, error, details } = input; + const kind = input.kind ?? declaredKind(error); + const err = typeof error === 'string' ? undefined : (error as { name?: unknown; message?: unknown } | null | undefined); + const raw = typeof error === 'string' ? error : typeof err?.message === 'string' ? err.message : String(error); + return { + ...details, + error: typeof err?.name === 'string' && err.name ? err.name : DEFAULT_ERROR_NAME[kind], + toolName, + message: + raw.length > MAX_TOOL_ERROR_MESSAGE_LENGTH + ? `${raw.slice(0, MAX_TOOL_ERROR_MESSAGE_LENGTH)}... (truncated)` + : raw, + kind, + ...(toolCallId !== undefined && { toolCallId }), + }; +} diff --git a/src/providers/toolCallTurns.contract.test.ts b/src/providers/toolCallTurns.contract.test.ts index 570137204..ad06a36dc 100644 --- a/src/providers/toolCallTurns.contract.test.ts +++ b/src/providers/toolCallTurns.contract.test.ts @@ -74,7 +74,7 @@ const history: Message[] = [ }, { role: 'tool', - content: '{"error":"Error","toolName":"get_weather","message":"Rome station offline"}', + content: '{"error":"Error","toolName":"get_weather","message":"Rome station offline","kind":"execution"}', name: 'get_weather', toolCallId: 'call_B', toolName: 'get_weather', @@ -107,7 +107,7 @@ const expectedPayload: CoreMessage[] = [ type: 'tool-result', toolCallId: 'call_B', toolName: 'get_weather', - result: { error: 'Error', toolName: 'get_weather', message: 'Rome station offline' }, + result: { error: 'Error', toolName: 'get_weather', message: 'Rome station offline', kind: 'execution' }, isError: true, }, ], diff --git a/src/tools/mcp/mcpResult.test.ts b/src/tools/mcp/mcpResult.test.ts index b29581d3a..62fc83924 100644 --- a/src/tools/mcp/mcpResult.test.ts +++ b/src/tools/mcp/mcpResult.test.ts @@ -125,6 +125,7 @@ describe('MCP result handling (LOU-Z2)', () => { error: 'McpToolError', toolName: 'srv__do_it', message: 'rate limited', + kind: 'mcp', }); expect(onToolResult.mock.calls[0][1]?.error).toBeDefined(); }); diff --git a/src/tools/mcp/result.ts b/src/tools/mcp/result.ts index ae2f9b898..034ea3445 100644 --- a/src/tools/mcp/result.ts +++ b/src/tools/mcp/result.ts @@ -48,10 +48,13 @@ export interface McpStructuredToolResult { /** * Thrown from a loaded MCP tool when the server reports `isError: true`. - * The model sees `{ error: 'McpToolError', toolName, message }`, where - * `message` is the server's text content. + * The model sees `{ error: 'McpToolError', toolName, message, kind: 'mcp' }`, + * where `message` is the server's text content. */ export class McpToolError extends Error { + /** Read by the executor's shared tool-error shape (LOU-U14). */ + readonly toolErrorKind = 'mcp' as const; + constructor(message: string) { super(message); this.name = 'McpToolError';