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
1 change: 1 addition & 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
- Declarative permission policies (LOU-X2): `permissions?: PermissionRule[]` on `createAgent()`, `ExecuteOptions` and `resumeAfterApproval()` options. A rule is `{ tool: string | string[] | RegExp | '*', when?: (args, { toolName, toolCallId, sessionId }) => boolean | Promise<boolean>, action: 'allow' | 'deny' | 'ask', reason? }`; rules are checked in order for each tool call (after argument validation and `preToolCall` hooks, before `needsApproval`) and the first match decides: `allow` runs the call without approval even when `needsApproval` would ask, `deny` gives the model a tool error with the new `kind: 'denied'` (`ToolDeniedError`, plus `reason`) and streams `tool.error`, `ask` pauses for approval (or asks `approve`). No match keeps today's behaviour. New helpers `allow(tools)`, `deny(tools, reason?)`, `ask(tools)` and types `PermissionRule`, `PermissionAction`, `PermissionToolMatcher`, `PermissionContext`, `PermissionDecision`, `PermissionDecisionEntry`, `PermissionOptions`. Audit log: `onPermissionDecision(entry)` gets `{ toolName, toolCallId, decision: 'allow' | 'deny' | 'ask' | 'default', rule?: { index, reason? }, args?, at }` per call (`args` left out under `redactContent`), and streams get the same entry as the new typed `permission.decision` event (`PermissionDecisionEvent`; additive, the event schema version stays 1). Neither is produced unless `permissions` or `onPermissionDecision` is set. Sub-agents inherit the lead's rules (checked before their own) and its `onPermissionDecision`. See docs/approvals.md#permission-policies.
- Compaction events and the `compaction` option (LOU-W3.2): `agent.stream()`, `session.stream()` and `AgentExecutor.stream()` emit two new typed events whenever the compaction hook compacts a request: `compaction.start` (`strategy`, `tokensBefore`, `contextWindow`, `thresholdTokens`) before the work and `compaction.done` (`strategy`, `tokensBefore`, `tokensAfter`, `prunedToolCallIds`, `summary?`, `error?: { message }`) after it, inside the step and before the model call; `error` is set when the strategy failed and the hook fell back. Additive: the event schema version stays 1. `GenerateHookContext` gains the optional `emit(event)` (set in streamed runs only) that the hook uses. `createAgent({ compaction })` takes `true` (the prune hook with its defaults) or `{ strategy?, thresholdPercent?, contextWindow?, protectedTokens?, summarizer? }`, where `summarizer` (a `'provider/model'` string or an `LLMProvider`) selects `twoPhaseStrategy()` with that model; `summarizer` together with `strategy` is a `ConfigurationError`. `createAgent({ hooks })` (new) takes any `AgentHook[]`, run before the compaction hook. See docs/compaction.md.
- Connect MCP servers from config (LOU-Z4): `connectMcp(servers, { lazy?, onError?, logger? })` (`@loushy/build-ai-agent/mcp` and the root) connects stdio (`command`/`args`/`env`) and streamable HTTP (`url`/`headers`) servers, lists their tools as `<server>__<tool>` descriptors and returns `{ tools, close(), status() }`. Every server connects up front (listing tools needs a connection); `lazy` (default `true`) reconnects on the next tool call after `close()` or a dropped connection. `onError: 'skip'` leaves a failing server out with a warning instead of rejecting. `createAgent({ mcpServers })` connects them on the new `agent.ready()` or the first `send()` / `stream()` and adds their tools; the new `agent.close()` disconnects them (both are no-ops without `mcpServers`). `specToAgent()` now connects `spec.mcpServers` the same way (closes TODO(LOU-D20.2)); `agent.mcpServers` still lists them. `loadMcpTools()` accepts any `{ listTools, callTool }` (`McpClientLike`). See docs/configuration.md#connect-mcp-servers-mcpservers-connectmcp.
- Error codes with fixes (LOU-D2): every `SDKError` now has a stable `code` (`LOUSHY_<AREA>_<NAME>`, e.g. `LOUSHY_CONFIG_MISSING_PROVIDER`, `LOUSHY_PEER_MISSING`, `LOUSHY_SPEC_INVALID`, `LOUSHY_SESSION_AWAITING_APPROVAL`), a one-sentence `hint` and a `docs` link to its section of the new docs/errors.md; its message ends with a `[code] hint (docs)` line (`detail` is the message without it), and `toString()` always shows it. `ERROR_CODES` / `ErrorCode` (new exports) are the registry, and a test keeps it, the codes used in `src/` and docs/errors.md in sync. The plain `Error`s thrown by `createAgent()` (no model, `instructions` + `prompt`, `sessionId` without checkpoints), `resolveProvider()` (bad spec, unknown prefix, missing API key, missing peer), `AgentExecutor.execute()` / `stream()` (missing `provider` / `agent` / `input`, approval with no `approvalStore`), `resumeAfterApproval()` (unknown approval), sessions (invalid id, corrupt file, second iteration, no stream runner), `loadSpec()` and `specToAgent()` are now `ConfigurationError` / `ValidationError` / `SDKError` with codes; `MissingPeerDependencyError` extends `SDKError`. Existing message text is unchanged (the help line is appended). `loadSpec()` suggests the field a top-level typo meant (`unknown field 'promt' (did you mean 'prompt'?)`). See docs/errors.md.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ agent can be an `agent.yaml` spec served with `npx loushy dev agent.yaml`
## Features

- **Tools**: `defineTool()` with a zod `input`; arguments are typed, validated, and run in parallel. [Tools](docs/tools.md)
- **Approvals**: `needsApproval` pauses a run; `agent.approvals.resolve()` continues it, or `approve` decides in code. [Approvals](docs/approvals.md)
- **Approvals and permission policies**: `needsApproval` pauses a run; `agent.approvals.resolve()` continues it, or `approve` decides in code. `permissions` rules allow, deny or ask per tool before that, with an audit log. [Approvals](docs/approvals.md)
- **Sessions**: `agent.session()` keeps a multi-turn conversation in memory, files or SQLite. [Sessions](docs/sessions.md)
- **Structured output**: `output: zodSchema` makes the final reply a typed, validated `result.object`, with one repair step. [Structured output](docs/structured-output.md)
- **Streaming**: `agent.stream()` and `session.stream()` yield typed, versioned JSON events ready for SSE. [Streaming](docs/streaming.md)
Expand Down
67 changes: 67 additions & 0 deletions docs/approvals.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,73 @@ approval stops the batch: the calls before it run, the run pauses on it, and
the calls after it run once it is decided (see
[Approvals in the middle of a tool batch](./durable-execution.md#approvals-in-the-middle-of-a-tool-batch)).

## Permission policies

`permissions` sets rules for the whole agent instead of tool by tool: a list of
`{ tool, when?, action, reason? }` rules, checked in order for every tool call
before the tool's own `needsApproval`. The first rule that matches decides:

- `allow` runs the call, without approval even if `needsApproval` would ask.
- `deny` does not run it. The model gets a tool error with `kind: 'denied'`
and the rule's `reason` (see [Tool errors](./tools.md)), and streams see
`tool.error`.
- `ask` pauses the run for approval, exactly like `needsApproval` (or asks the
`approve` callback).

When no rule matches, the tool's own `needsApproval` decides, as before.
`tool` is a name, a list of names, a `RegExp` tested against the name, or
`'*'` for every tool. `when` narrows a rule to some calls: it gets the
validated arguments (after `preToolCall` hooks) and `{ toolName, toolCallId,
sessionId }`, and may be async; when it throws, the call fails with that
error. `allow(tools)`, `deny(tools, reason?)` and `ask(tools)` build the
common rules.

```ts
import { allow, ask, createAgent, defineTool, deny, type PermissionRule } from '@loushy/build-ai-agent';
import { z } from 'zod';

const shell = defineTool({
name: 'shell',
description: 'Run a shell command',
input: z.object({ command: z.string() }),
execute: async ({ command }) => `ran ${command}`,
});

const noDeletes: PermissionRule = {
tool: 'shell',
when: (args) => /\brm\b/.test(String(args.command)),
action: 'deny',
reason: 'Deleting files is not allowed',
};

const agent = createAgent({
provider,
instructions: 'You are a coding assistant.',
tools: [shell],
permissions: [
noDeletes, // first match wins: this beats the `ask` below
allow(/^read_/), // read-only tools never pause
deny('delete_file', 'Use the trash tool instead'),
ask(['shell', 'write_file']),
],
onPermissionDecision: (entry) => console.log(entry.at, entry.toolName, entry.decision, entry.rule?.reason),
});
```

`onPermissionDecision` is the audit log: it is called once per tool call
(except calls already refused for invalid arguments) with
`{ toolName, toolCallId, decision, rule?, args?, at }`. `decision` is the
matching rule's action or `'default'` when none matched, `rule` is that rule's
`{ index, reason? }`, `at` is an ISO timestamp, and `args` is left out when the
run sets `redactContent`. Streams get the same entry as a `permission.decision`
event (see [Streaming](./streaming.md)). Both are only produced when the agent
sets `permissions` or `onPermissionDecision`.

Both options also exist on `AgentExecutor.execute()` / `stream()` and
`resumeAfterApproval()`. Sub-agents inherit the lead agent's rules, checked
before the sub-agent's own, and report their decisions to the lead's
`onPermissionDecision`.

## `createAgent()` agents

- A paused `send()` resolves (it does not throw) with
Expand Down
1 change: 1 addition & 0 deletions docs/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ The event types and their extra fields:
| `tool.done` | `toolCallId`, `toolName`, `result: unknown`, `durationMs: number` | A tool call returned. `result` is the value as it would be JSON-encoded (`undefined` becomes `null`, a `Date` becomes a string). `durationMs` counts from its `tool.start`. |
| `tool.error` | `toolCallId`, `toolName`, `error: { name: string, message: string }`, `durationMs: number` | A tool call failed: it threw, its arguments did not match its schema (`name: 'ToolArgumentsValidationError'`), or the tool does not exist. The model gets the error as the call's result and the run continues. |
| `approval.requested` | `approvalId: string`, `toolCallId`, `toolName`, `args: Record<string, unknown>` | A tool call needs a human decision. The run then stops; resume it with `agent.approvals.resolve()` or `resumeAfterApproval()` (see [Approvals](./approvals.md)). |
| `permission.decision` | `toolCallId`, `toolName`, `decision: 'allow' \| 'deny' \| 'ask' \| 'default'`, `rule?: { index: number, reason?: string }`, `args?: Record<string, unknown>`, `at: string` | How a tool call's [permission rules](./approvals.md#permission-policies) decided it: after its `tool.start`, before the call's own `tool.done` / `tool.error` / `approval.requested`. Only when the run sets `permissions` or `onPermissionDecision`; `args` is left out under `redactContent`. |
| `step.done` | `step: number`, `finishReason: string`, `usage?: { promptTokens, completionTokens, totalTokens }` | A step ends. `finishReason` is the model's (`'stop'`, `'tool_calls'`, `'length'`, ...), or `'awaiting-approval'`, `'aborted'` or `'error'` when the step ended that way (a run that runs out of `maxSteps` still wanting to continue ends with `run.done` `'max-steps'`). `usage` is this step's model call, absent when the call produced no response. |
| `error` | `error: { name: string, message: string }` | An error. If it ends the run, `run.done` with `finishReason: 'error'` follows. A provider error retried under `surfaceRetryableProviderErrors` is followed by further steps instead. |
| `provider.retry` | `attempt: number`, `maxRetries: number`, `delayMs: number`, `error: { message: string, category?: string }`, `provider: string` | A model call failed and is retried after `delayMs` (`createAgent({ retry })` or any `withRetry()` provider). `attempt` is the attempt that failed (1 = first); `category` is `'rate-limit'`, `'timeout'`, ... and absent when unknown (a 5xx, for example). |
Expand Down
3 changes: 2 additions & 1 deletion docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ check works everywhere (including the call that runs after an approval):
- `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`.
- Some kinds add fields: `issues` for `validation`, `note` for `rejected`, `reason` for `denied`.

The transcript message carries `isError: true`; `tool-result` events, `onToolResult`, `postToolCall` hooks and `tool.error` events see the call as failed.

Expand All @@ -109,6 +109,7 @@ The transcript message carries `isError: true`; `tool-result` events, `onToolRes
| `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. |
| `denied` | `ToolDeniedError` | A `deny` [permission rule](./approvals.md#permission-policies) refused the call; `execute` did not run. Adds `reason` when the rule has one. |

`toolErrorResult({ toolName, error, kind?, toolCallId?, details? })` builds this
result; use it in your own tool wrappers so they match. A thrown error can pick
Expand Down
Loading
Loading