Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions api/executor.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,7 @@ export interface ExecuteOptions extends PermissionOptions {
temperature?: number;
tokens?: OAuthTokenStore;
toolConcurrency?: ToolConcurrency;
toolErrorMessage?: ToolErrorMessageFn;
toolRegistry?: ToolRegistry;
toolSearch?: false | ToolSearchOptions;
}
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions api/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1440,6 +1440,7 @@ export interface CreateAgentBase<TOutput extends StandardSchemaV1 = StandardSche
subagentOptions?: SubagentOptions;
subagents?: Subagents;
toolConcurrency?: ToolConcurrency;
toolErrorMessage?: ToolErrorMessageFn;
tools?: PerRun<AgentToolsOption>;
toolSearch?: false | ToolSearchOptions;
}
Expand Down Expand Up @@ -2190,6 +2191,7 @@ interface ExecuteOptions extends PermissionOptions {
temperature?: number;
tokens?: OAuthTokenStore;
toolConcurrency?: ToolConcurrency;
toolErrorMessage?: ToolErrorMessageFn;
toolRegistry?: ToolRegistry;
toolSearch?: false | ToolSearchOptions;
}
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions api/worker.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,7 @@ export interface CreateAgentBase<TOutput extends StandardSchemaV1 = StandardSche
subagentOptions?: SubagentOptions;
subagents?: Subagents;
toolConcurrency?: ToolConcurrency;
toolErrorMessage?: ToolErrorMessageFn;
tools?: PerRun<AgentToolsOption>;
toolSearch?: false | ToolSearchOptions;
}
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
25 changes: 25 additions & 0 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/agentDir/loadAgentDir.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<keyof CreateAgentBase, true>;

describe('createAgent() overrides', () => {
Expand Down
17 changes: 17 additions & 0 deletions src/createAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -305,6 +306,21 @@ export interface CreateAgentBase<TOutput extends StandardSchemaV1 = StandardSche
* ```
*/
maxToolResultChars?: number;
/**
* Rewrites the text of an exception a tool throws before it reaches the
* model, the event stream (`tool.done`) and `postToolCall` hooks. The
* result's `message` becomes your string; `kind` stays `'execution'`.
* Receives the original error, so log it here. Argument-validation errors
* and other kinds (MCP, sandbox, ...) are not rewritten. If it throws, the
* message is a generic "The tool failed." and the run continues. Unset, the
* error's own message is sent. See docs/tools.md.
*
* @example
* ```ts
* const agent = createAgent({ instructions: '...', provider, tools, toolErrorMessage: (error, { toolName }) => `${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
Expand Down Expand Up @@ -987,6 +1003,7 @@ export function createAgent<TOutput extends StandardSchemaV1 = StandardSchemaV1>
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,
Expand Down
1 change: 1 addition & 0 deletions src/createAgentKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const CREATE_AGENT_KEYS = {
guardrails: true,
toolConcurrency: true,
maxToolResultChars: true,
toolErrorMessage: true,
onAgentDrift: true,
reasoning: true,
modelSettings: true,
Expand Down
7 changes: 7 additions & 0 deletions src/execution/AgentExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
} from './toolCallExecution';
import { resolveToolCallNames } from './toolNames';
import { assertMaxToolResultChars } from './toolResult';
import type { ToolErrorMessageFn } from './toolErrors';
import {
StartedToolCall,
ToolBatchResult,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/execution/delegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/execution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 6 additions & 6 deletions src/execution/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/execution/subagentRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
21 changes: 11 additions & 10 deletions src/execution/toolCallExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) } };
}
}

Expand Down Expand Up @@ -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) };
}
}

Expand Down Expand Up @@ -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 };
}

/**
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -640,7 +641,7 @@ function partialReporter(report: NonNullable<ToolCallContext['onToolPartial']>,
*/
async function signInOutcome(toolCall: ToolCall, args: Record<string, unknown>, signal: SignInRequired, ctx: ToolCallContext): Promise<ToolCallOutcome> {
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 };
}

Expand Down
Loading
Loading