diff --git a/api/executor.api.md b/api/executor.api.md index 1e36d0b9..7aada9bd 100644 --- a/api/executor.api.md +++ b/api/executor.api.md @@ -765,6 +765,7 @@ export interface ExecuteOptions extends PermissionOptions { temperature?: number; tokens?: OAuthTokenStore; toolConcurrency?: ToolConcurrency; + toolErrorMessage?: ToolErrorMessageFn; toolRegistry?: ToolRegistry; toolSearch?: false | ToolSearchOptions; } @@ -2655,6 +2656,12 @@ interface ToolErrorEvent extends AgentEventBase<'tool.error'> { toolName: string; } +// @public +type ToolErrorMessageFn = (error: unknown, ctx: { + toolName: string; + toolCallId: string; +}) => string; + // @public interface ToolExecutionContext { abortSignal?: AbortSignal; diff --git a/api/index.api.md b/api/index.api.md index ab516a48..729a18cc 100644 --- a/api/index.api.md +++ b/api/index.api.md @@ -1440,6 +1440,7 @@ export interface CreateAgentBase; toolSearch?: false | ToolSearchOptions; } @@ -2190,6 +2191,7 @@ interface ExecuteOptions extends PermissionOptions { temperature?: number; tokens?: OAuthTokenStore; toolConcurrency?: ToolConcurrency; + toolErrorMessage?: ToolErrorMessageFn; toolRegistry?: ToolRegistry; toolSearch?: false | ToolSearchOptions; } @@ -6795,6 +6797,12 @@ export interface ToolErrorInput { // @public export type ToolErrorKind = 'execution' | 'validation' | 'not-found' | 'rejected' | 'not-run' | 'mcp' | 'sandbox' | 'denied' | 'interrupted'; +// @public +export type ToolErrorMessageFn = (error: unknown, ctx: { + toolName: string; + toolCallId: string; +}) => string; + // @public export interface ToolErrorResult { [extra: string]: unknown; diff --git a/api/worker.api.md b/api/worker.api.md index 432bb417..f696b3ff 100644 --- a/api/worker.api.md +++ b/api/worker.api.md @@ -1039,6 +1039,7 @@ export interface CreateAgentBase; toolSearch?: false | ToolSearchOptions; } @@ -3985,6 +3986,12 @@ interface ToolErrorEvent extends AgentEventBase<'tool.error'> { toolName: string; } +// @public +type ToolErrorMessageFn = (error: unknown, ctx: { + toolName: string; + toolCallId: string; +}) => string; + // @public interface ToolExecutionContext { abortSignal?: AbortSignal; diff --git a/docs/configuration.md b/docs/configuration.md index 39d01bdd..d879904d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -366,6 +366,7 @@ createAgent({ model: 'openai/gpt-4o-mini', subagents: { researcher }, rateLimit: | `codeMode` | `true` or `CodeModeOptions`: adds a `run_code` tool that lets the model call the agent's tools from one short script in a QuickJS sandbox. See [Code mode](./code-mode.md). | | `guardrails` | Input, output and tool guardrails of each run; sub-agents inherit them. See [Guardrails](./guardrails.md). | | `maxToolResultChars` | The most characters of one tool result the model gets (default `50_000`; `Infinity` turns the cap off). See [Tools](./tools.md). | +| `toolErrorMessage` | `(error, { toolName, toolCallId }) => string`: replaces the text of a thrown tool error before the model, events and hooks see it (default: the error's own message). See [Tools](./tools.md#errors). | | `onAgentDrift` | What a resume does when the agent differs from the one that paused the run: `'warn'` (default), `'error'` or `'ignore'`. See [Durable execution](./durable-execution.md#resuming-with-a-changed-agent). | | `reasoning` | How much the model reasons before it answers: an effort, or `{ effort, budgetTokens, summary, force }`. See [Reasoning](./reasoning.md). | | `promptCaching` | `'auto'` (default) marks Anthropic `cache_control` breakpoints for Claude models; `false` marks none. See [Prompt caching](./models-and-cost.md#prompt-caching). | diff --git a/docs/tools.md b/docs/tools.md index c0296cd6..d11c0bdd 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -153,6 +153,31 @@ The transcript message carries `isError: true`; `tool-result` events, `onToolRes | `sandbox` | `SandboxRequiredError` | The tool has `requiresSandbox` but no `sandboxExecute`, so it was refused rather than run unsandboxed. | | `denied` | `ToolDeniedError` | A `deny` [permission rule](./approvals.md#permission-policies), the tool's `needsApproval` returning `'deny'`, a `preToolCall` hook's `deny`, or the permission mode refused the call; `execute` did not run. Adds `reason` when given. | +**Hiding exception text.** A thrown error's message goes to the model as it is, so +`new Error('DB password=hunter2 connection refused')` reaches it verbatim. Pass +`toolErrorMessage` to `createAgent` to replace the text of `execution` errors: + +```ts +import { createAgent } from '@lousho/build-ai-agent'; + +const agent = createAgent({ + instructions: 'You answer questions about orders.', + model: 'openai/gpt-4o-mini', + tools: [], + toolErrorMessage: (error, { toolName, toolCallId }) => { + console.error(`tool ${toolName} (${toolCallId}) failed`, error); // the original error stays here + return `${toolName} failed. Try again later or use another approach.`; + }, +}); +``` + +The string becomes `message` in the result above (`kind` stays `execution`). The same text +reaches `tool.error` events, `onToolResult` and `postToolCall` hooks, so a client-facing stream +never carries the original; only your function sees the thrown error. Sub-agents inherit it. It +does not touch `validation` errors (they describe the model's own bad arguments) or the other +kinds. If the function throws or returns a non-string, the message is `The tool failed.` and the run +carries on. Without the option, nothing changes. + **Argument validation.** The model's arguments are parsed with the tool's zod `inputSchema` (for a legacy descriptor, `tool.parameters`) before the tool runs, so pre-tool hooks, the `needsApproval` predicate and `execute` all receive the **parsed** value. Tools without a zod schema are diff --git a/llms-full.txt b/llms-full.txt index 5d793ae0..8cec3f9a 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -6627,6 +6627,7 @@ createAgent({ model: 'openai/gpt-4o-mini', subagents: { researcher }, rateLimit: | `codeMode` | `true` or `CodeModeOptions`: adds a `run_code` tool that lets the model call the agent's tools from one short script in a QuickJS sandbox. See [Code mode](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/code-mode.md). | | `guardrails` | Input, output and tool guardrails of each run; sub-agents inherit them. See [Guardrails](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/guardrails.md). | | `maxToolResultChars` | The most characters of one tool result the model gets (default `50_000`; `Infinity` turns the cap off). See [Tools](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/tools.md). | +| `toolErrorMessage` | `(error, { toolName, toolCallId }) => string`: replaces the text of a thrown tool error before the model, events and hooks see it (default: the error's own message). See [Tools](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/tools.md#errors). | | `onAgentDrift` | What a resume does when the agent differs from the one that paused the run: `'warn'` (default), `'error'` or `'ignore'`. See [Durable execution](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/durable-execution.md#resuming-with-a-changed-agent). | | `reasoning` | How much the model reasons before it answers: an effort, or `{ effort, budgetTokens, summary, force }`. See [Reasoning](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/reasoning.md). | | `promptCaching` | `'auto'` (default) marks Anthropic `cache_control` breakpoints for Claude models; `false` marks none. See [Prompt caching](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/models-and-cost.md#prompt-caching). | @@ -19463,6 +19464,31 @@ The transcript message carries `isError: true`; `tool-result` events, `onToolRes | `sandbox` | `SandboxRequiredError` | The tool has `requiresSandbox` but no `sandboxExecute`, so it was refused rather than run unsandboxed. | | `denied` | `ToolDeniedError` | A `deny` [permission rule](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/approvals.md#permission-policies), the tool's `needsApproval` returning `'deny'`, a `preToolCall` hook's `deny`, or the permission mode refused the call; `execute` did not run. Adds `reason` when given. | +**Hiding exception text.** A thrown error's message goes to the model as it is, so +`new Error('DB password=hunter2 connection refused')` reaches it verbatim. Pass +`toolErrorMessage` to `createAgent` to replace the text of `execution` errors: + +```ts +import { createAgent } from '@lousho/build-ai-agent'; + +const agent = createAgent({ + instructions: 'You answer questions about orders.', + model: 'openai/gpt-4o-mini', + tools: [], + toolErrorMessage: (error, { toolName, toolCallId }) => { + console.error(`tool ${toolName} (${toolCallId}) failed`, error); // the original error stays here + return `${toolName} failed. Try again later or use another approach.`; + }, +}); +``` + +The string becomes `message` in the result above (`kind` stays `execution`). The same text +reaches `tool.error` events, `onToolResult` and `postToolCall` hooks, so a client-facing stream +never carries the original; only your function sees the thrown error. Sub-agents inherit it. It +does not touch `validation` errors (they describe the model's own bad arguments) or the other +kinds. If the function throws or returns a non-string, the message is `The tool failed.` and the run +carries on. Without the option, nothing changes. + **Argument validation.** The model's arguments are parsed with the tool's zod `inputSchema` (for a legacy descriptor, `tool.parameters`) before the tool runs, so pre-tool hooks, the `needsApproval` predicate and `execute` all receive the **parsed** value. Tools without a zod schema are diff --git a/src/agentDir/loadAgentDir.test.ts b/src/agentDir/loadAgentDir.test.ts index 90125b45..50c38dd3 100644 --- a/src/agentDir/loadAgentDir.test.ts +++ b/src/agentDir/loadAgentDir.test.ts @@ -115,7 +115,7 @@ const CREATE_AGENT_OPTIONS = { captureContent: true, redactContent: true, projectInstructions: true, store: true, approvalStore: true, approve: true, approvalTtlMs: true, askQuestion: true, retry: true, fallbackModels: true, output: true, hooks: true, compaction: true, memory: true, permissions: true, onPermissionDecision: true, permissionMode: true, onPermissionModeChange: true, - maxToolResultChars: true, promptCaching: true, rateLimit: true, + maxToolResultChars: true, toolErrorMessage: true, promptCaching: true, rateLimit: true, } satisfies Record; describe('createAgent() overrides', () => { diff --git a/src/createAgent.ts b/src/createAgent.ts index 99197d75..d5295d1b 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -36,6 +36,7 @@ import { withAskQuestion, type ToolEntries } from './tools/built-in/askQuestion' import { toolEntries, type ToolsOption } from './tools/toolEntries'; import { ToolConcurrency, assertToolConcurrency } from './execution/toolBatch'; import { assertMaxToolResultChars } from './execution/toolResult'; +import type { ToolErrorMessageFn } from './execution/toolErrors'; import type { Skill } from './skills/defineSkill'; import { assertUsable as assertSkillsUsable } from './skills/withSkills'; import type { Message, ModelSettings } from './providers/llm'; @@ -305,6 +306,21 @@ export interface CreateAgentBase `${toolName} failed` }); + * ``` + */ + toolErrorMessage?: ToolErrorMessageFn; /** * What a resume does when this agent differs from the one that * paused or crashed the run (another model, tools with other names or @@ -987,6 +1003,7 @@ export function createAgent toolConcurrency: config.toolConcurrency, // Also for resumed runs. maxToolResultChars: config.maxToolResultChars, + toolErrorMessage: config.toolErrorMessage, // TTL: the default pause deadline; an `ask` rule's `ttlMs` overrides it. approvalTtlMs: config.approvalTtlMs, onAgentDrift: config.onAgentDrift, diff --git a/src/createAgentKeys.ts b/src/createAgentKeys.ts index 5278128e..b943ef44 100644 --- a/src/createAgentKeys.ts +++ b/src/createAgentKeys.ts @@ -28,6 +28,7 @@ const CREATE_AGENT_KEYS = { guardrails: true, toolConcurrency: true, maxToolResultChars: true, + toolErrorMessage: true, onAgentDrift: true, reasoning: true, modelSettings: true, diff --git a/src/execution/AgentExecutor.ts b/src/execution/AgentExecutor.ts index 70f858ba..dca9385b 100644 --- a/src/execution/AgentExecutor.ts +++ b/src/execution/AgentExecutor.ts @@ -53,6 +53,7 @@ import { } from './toolCallExecution'; import { resolveToolCallNames } from './toolNames'; import { assertMaxToolResultChars } from './toolResult'; +import type { ToolErrorMessageFn } from './toolErrors'; import { StartedToolCall, ToolBatchResult, @@ -631,6 +632,12 @@ export interface ExecuteOptions extends PermissionOptions { * `tool.done` events still get the whole result. */ maxToolResultChars?: number; + /** + * Rewrites the text of a thrown tool error before the model, the event + * stream (`tool.done`) and `postToolCall` hooks see it. See + * `createAgent({ toolErrorMessage })`. + */ + toolErrorMessage?: ToolErrorMessageFn; /** * A zod schema the final reply must match, as JSON (tools can * still be called first). It is validated into `result.object`; an invalid diff --git a/src/execution/delegation.ts b/src/execution/delegation.ts index 224d657b..f0a2d924 100644 --- a/src/execution/delegation.ts +++ b/src/execution/delegation.ts @@ -215,6 +215,7 @@ function childOptions( sandbox: runtime.sandbox, signal: abortSignal ?? runtime.signal, toolConcurrency: spec.toolConcurrency ?? runtime.toolConcurrency, + toolErrorMessage: runtime.toolErrorMessage, skills: spec.skills, subagents: spec.subagents, // The top-level run's limit bounds the whole tree: one level used up here. diff --git a/src/execution/index.ts b/src/execution/index.ts index 5192aa64..186c5b82 100644 --- a/src/execution/index.ts +++ b/src/execution/index.ts @@ -76,7 +76,7 @@ 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 { ToolErrorKind, ToolErrorResult, ToolErrorInput, ToolErrorMessageFn } from './toolErrors'; export { allow, ask, deny, APPROVAL_EXPIRED_REASON } from './permissions'; export type { PermissionAction, diff --git a/src/execution/resume.ts b/src/execution/resume.ts index 5a508ca8..7107ac3e 100644 --- a/src/execution/resume.ts +++ b/src/execution/resume.ts @@ -38,7 +38,7 @@ import { runPreToolHooks, type ToolCallOutcome } from './toolCallExecution'; import { activeAgentOf, handOff, handoffNamed, handoffToolRegistry, type HandoffMarker, type ResolvedHandoff } from './handoffRun'; import { markPropagating, toolErrorMessage } from './propagatingToolError'; import { ConfigurationError, SDKError } from './errors'; -import { toolErrorResult, type ToolErrorResult } from './toolErrors'; +import { toolErrorResult, thrownToolError, type ToolErrorResult } from './toolErrors'; import { toolResultContent } from './toolResult'; import { insertToolResult, splitPendingTurn } from './transcript'; import { collectToolCalls } from './agentRunState'; @@ -1545,11 +1545,11 @@ async function executeApprovedTool( // here would mean the whole resume just fails with no retry path. // 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 }), - }; + const { message, result } = thrownToolError( + { toolName: pending.toolName, toolCallId: pending.toolCallId, error, plainMessage: toolErrorMessage(error) }, + executeOptions.toolErrorMessage + ); + return { result: undefined, toolError: message, errorResult: result }; } } diff --git a/src/execution/subagentRuntime.ts b/src/execution/subagentRuntime.ts index 51aaaab5..9dde462b 100644 --- a/src/execution/subagentRuntime.ts +++ b/src/execution/subagentRuntime.ts @@ -39,6 +39,8 @@ export type InheritedRuntime = Pick< | 'redactContent' | 'approvalStore' | 'toolConcurrency' + // And its tools' error text is sanitized the same way. + | 'toolErrorMessage' | 'sandbox' | 'signal' | 'maxSubagentDepth' diff --git a/src/execution/toolCallExecution.ts b/src/execution/toolCallExecution.ts index 483d34df..1a45331d 100644 --- a/src/execution/toolCallExecution.ts +++ b/src/execution/toolCallExecution.ts @@ -14,7 +14,7 @@ import { executeToolWithSandboxGuard } from './sandboxGuard'; import type { ToolRunContext } from './toolRunContext'; import { HookRegistry, ToolCallHookContext, type PreToolCallDecision } from './hooks'; import { toolErrorMessage } from './propagatingToolError'; -import { toolErrorResult, type ToolErrorKind } from './toolErrors'; +import { toolErrorResult, thrownToolError, type ToolErrorKind } from './toolErrors'; import { ToolArgumentsValidationError, decodeToolArguments, invalidJsonMessage, parseToolArguments, validateToolArguments } from './toolArgsValidation'; import { toolNotFoundMessage } from './toolNames'; import { @@ -338,7 +338,7 @@ async function checkPermissionRules( const gate = entry?.decision === 'allow' ? { requiresApproval: false } : undefined; return { entry, gate }; } catch (error) { - return { gate: { requiresApproval: false, rejection: thrownToolFailure(toolCall, error) } }; + return { gate: { requiresApproval: false, rejection: thrownToolFailure(toolCall, error, ctx) } }; } } @@ -376,7 +376,7 @@ async function checkNeedsApproval( if ((outcome === true || outcome === 'ask') && rememberedApproval(ctx.messages, toolCall.function.name, args)) return { requiresApproval: false }; return approvalGate(toolCall, outcome); } catch (error) { - return { requiresApproval: false, rejection: thrownToolFailure(toolCall, error) }; + return { requiresApproval: false, rejection: thrownToolFailure(toolCall, error, ctx) }; } } @@ -548,11 +548,12 @@ function approvalOutcome(prepared: PreparedToolCall): ToolCallOutcome { * The model gets a structured error (not the string "null"), * while `outcome.error` keeps the plain message for events/hooks. */ -function thrownToolFailure(toolCall: ToolCall, error: unknown): ToolCallOutcome { - return { - ...toolFailure(toolCall, 'execution', toolErrorMessage(error)), - result: toolErrorResult({ toolName: toolCall.function.name, error }), - }; +function thrownToolFailure(toolCall: ToolCall, error: unknown, ctx: ToolCallContext): ToolCallOutcome { + const { message, result } = thrownToolError( + { toolName: toolCall.function.name, toolCallId: toolCall.id, error, plainMessage: toolErrorMessage(error) }, + ctx.scope?.runtime.toolErrorMessage + ); + return { ...toolFailure(toolCall, 'execution', message), result }; } /** @@ -625,7 +626,7 @@ async function doExecuteToolCall( if (isSignInRequired(error)) { return signInOutcome(toolCall, overrideArgs ?? {}, error, ctx); } - return thrownToolFailure(toolCall, error); + return thrownToolFailure(toolCall, error, ctx); } } @@ -640,7 +641,7 @@ function partialReporter(report: NonNullable, */ async function signInOutcome(toolCall: ToolCall, args: Record, signal: SignInRequired, ctx: ToolCallContext): Promise { const settled = await settleSignInRequired(signal, ctx.scope?.runtime.tokens); - if ('error' in settled) return thrownToolFailure(toolCall, settled.error); + if ('error' in settled) return thrownToolFailure(toolCall, settled.error, ctx); return { toolCallId: toolCall.id, toolName: toolCall.function.name, result: null, args, signIn: settled.pause }; } diff --git a/src/execution/toolErrorResult.test.ts b/src/execution/toolErrorResult.test.ts index 29177011..9f514c3a 100644 --- a/src/execution/toolErrorResult.test.ts +++ b/src/execution/toolErrorResult.test.ts @@ -145,3 +145,76 @@ describe('thrown tool errors reach the model', () => { ).rejects.toThrow('stop everything'); }); }); + +describe('toolErrorMessage sanitizer', () => { + const secret = 'DB password=hunter2 connection refused'; + + it('replaces the text of a thrown error for the model, events and hooks', async () => { + const { toolRegistry, agent } = setup(async () => { + throw new Error(secret); + }); + const { provider, seenMessages } = scriptedProvider(1); + const events: AgentEvent[] = []; + const toolErrorMessage = vi.fn(() => 'the database is unavailable'); + + await AgentExecutor.execute({ agent, input: 'go', provider, toolRegistry, toolErrorMessage, onAgentEvent: e => events.push(e) }); + + expect(toolErrorMessage).toHaveBeenCalledWith(expect.objectContaining({ message: secret }), { toolName: 'boom', toolCallId: 'call-1' }); + const payload = JSON.parse(seenMessages[1].find(m => m.role === 'tool')!.content); + expect(payload).toEqual({ error: 'Error', toolName: 'boom', message: 'the database is unavailable', kind: 'execution' }); + expect(JSON.stringify(seenMessages[1])).not.toContain('hunter2'); + expect(events.find(e => e.type === 'tool.error')!.error.message).toBe('the database is unavailable'); + }); + + it('leaves argument validation errors unchanged', async () => { + const toolRegistry = new ToolRegistry(); + toolRegistry.register('boom', { + displayName: 'Boom', + tool: { parameters: z.object({ n: z.number() }), execute: async () => ({ ok: true }) } as Tool, + }); + const agent = AgentBuilder.create().setName('Test Agent').addTool('boom', { tool: 'boom', options: {} }).build(); + const { provider, seenMessages } = scriptedProvider(1); + const toolErrorMessage = vi.fn(() => 'hidden'); + + await AgentExecutor.execute({ agent, input: 'go', provider, toolRegistry, toolErrorMessage }); + + const payload = JSON.parse(seenMessages[1].find(m => m.role === 'tool')!.content); + expect(payload.kind).toBe('validation'); + expect(payload.message).not.toBe('hidden'); + expect(toolErrorMessage).not.toHaveBeenCalled(); + }); + + it('falls back to a generic message when the sanitizer throws, and the run continues', async () => { + const { toolRegistry, agent } = setup(async () => { + throw new Error(secret); + }); + const { provider, seenMessages } = scriptedProvider(1); + + const result = await AgentExecutor.execute({ + agent, + input: 'go', + provider, + toolRegistry, + toolErrorMessage: () => { + throw new Error('sanitizer bug'); + }, + }); + + expect(result.text).toBe('done'); + const payload = JSON.parse(seenMessages[1].find(m => m.role === 'tool')!.content); + expect(payload).toMatchObject({ message: 'The tool failed.', kind: 'execution' }); + expect(JSON.stringify(seenMessages[1])).not.toContain('hunter2'); + }); + + it('is identical to today without the option', async () => { + const { toolRegistry, agent } = setup(async () => { + throw new Error(secret); + }); + const { provider, seenMessages } = scriptedProvider(1); + + 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: secret, kind: 'execution' }); + }); +}); diff --git a/src/execution/toolErrors.ts b/src/execution/toolErrors.ts index ed984d7d..a714b0a7 100644 --- a/src/execution/toolErrors.ts +++ b/src/execution/toolErrors.ts @@ -102,3 +102,35 @@ export function toolErrorResult(input: ToolErrorInput): ToolErrorResult { ...(toolCallId !== undefined && { toolCallId }), }; } + +/** + * Replaces the text of a tool's thrown error before the model (and the + * event stream) sees it. See `createAgent({ toolErrorMessage })`. + */ +export type ToolErrorMessageFn = (error: unknown, ctx: { toolName: string; toolCallId: string }) => string; + +/** What the model gets when a `toolErrorMessage` sanitizer throws or returns a non-string. */ +const SANITIZER_FALLBACK_MESSAGE = 'The tool failed.'; + +/** + * The model-facing result of a thrown tool error, plus the message the events + * and `postToolCall` hooks carry. With a sanitizer, an `'execution'` error's + * message is the sanitizer's string (a throwing sanitizer yields a generic one, + * never a crash); other kinds (`mcp`, `sandbox`, ...) are left as they are. + */ +export function thrownToolError( + input: { toolName: string; toolCallId: string; error: unknown; plainMessage: string }, + sanitize: ToolErrorMessageFn | undefined +): { message: string; result: ToolErrorResult } { + const { toolName, toolCallId, error, plainMessage } = input; + const result = toolErrorResult({ toolName, error }); + if (!sanitize || result.kind !== 'execution') return { message: plainMessage, result }; + let message = SANITIZER_FALLBACK_MESSAGE; + try { + const sanitized = sanitize(error, { toolName, toolCallId }); + if (typeof sanitized === 'string') message = sanitized; + } catch { + // Keep the generic message: a broken sanitizer must not crash the run or leak the original. + } + return { message, result: toolErrorResult({ toolName, error: { name: result.error, message }, kind: 'execution' }) }; +}