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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<id>.turn-<n>'` 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<typeof schema>` 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.
Expand All @@ -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: '<message>' }`; a call left without a result on resume gave `{ error: 'Tool call was not run: ...' }`. Now each is `{ error: '<ToolNotFoundError | ToolRejectedError | <thrown error name> | ToolNotRunError>', toolName, message: '<the old text>', 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).
Expand Down
7 changes: 6 additions & 1 deletion docs/api-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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.
Expand Down
35 changes: 33 additions & 2 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
Expand Down
42 changes: 39 additions & 3 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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 |
Expand Down
5 changes: 4 additions & 1 deletion src/createAgentApprovals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
});
Expand Down
4 changes: 2 additions & 2 deletions src/execution/durableExecution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"' });
});

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

Expand Down
2 changes: 2 additions & 0 deletions src/execution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
41 changes: 0 additions & 41 deletions src/execution/propagatingToolError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
2 changes: 1 addition & 1 deletion src/execution/resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
Loading
Loading