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
- 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.
- Checkpoint history (LOU-D43): `memoryStore()`, `SqliteStore` and `LocalStorageCheckpointStore` keep a bounded history per session: every `save()` also appends the record to a ring (`historyLimit` option, default 50, `0` keeps none; the oldest are dropped), and the new optional `CheckpointStore.history(sessionId, { limit? })` returns `{ step, savedAt, status, checkpoint }` entries newest first. `CheckpointStore.delete(sessionId, { keepHistory: true })` keeps the history (by default `delete()` clears it too), `getCheckpointHistory(store, sessionId)` reads it and returns `undefined` for a store without `history()`, and `SqliteStore` gains the `checkpoint_history` table (added when an existing database file is opened; `prune()` removes old entries). New types `CheckpointHistoryEntry`, `CheckpointHistoryOptions`, `CheckpointDeleteOptions` and `MemoryStoreOptions`. `KVCheckpointStore` and Agent Forge's file store keep no history yet (LOU-D43.2). This is the first step of time-travel from a past step. See docs/durable-execution.md#checkpoint-history.
Expand Down
5 changes: 4 additions & 1 deletion docs/api-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,10 @@ and, if the run is still too big, replaces old turns with a summary written
by `model`; `summarizeStrategy()` only summarizes. `pinMessage(message)` marks
a message that is never pruned or summarized. `compactMessages(messages, options)`
does the same once, by hand (async), and `CompactionStrategy` is the
pluggable interface (`compact()` may be async). See [Context compaction](./compaction.md).
pluggable interface (`compact()` may be async). `createAgent({ compaction: true })`
installs the hook on an agent (`createAgent({ hooks })` takes any other
`AgentHook`s), and `stream()` emits `compaction.start` / `compaction.done`.
See [Context compaction](./compaction.md).

## Flows, evals, observability and security

Expand Down
60 changes: 57 additions & 3 deletions docs/compaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,60 @@ first it replaces old tool results with a short marker, and if that is not
enough it replaces the oldest turns with a summary written by a (cheaper)
model.

## Compacting an agent

With `createAgent()`, turn compaction on with the `compaction` option. `true`
installs the hook with its defaults (prune old tool results above 90% of the
model's window); an object configures it, and `summarizer` selects
`twoPhaseStrategy()` with that model (a `"provider/model"` string or an
`LLMProvider`):

```ts
import { createAgent } from '@loushy/build-ai-agent';

const simple = createAgent({ model: 'openai/gpt-4o', compaction: true });

const agent = createAgent({
model: 'openai/gpt-4o',
compaction: {
thresholdPercent: 0.8, // compact above 80% of the context window (default 0.9)
protectedTokens: 20_000, // never change the newest 20K tokens (default 40_000)
summarizer: 'openai/gpt-4o-mini', // prune, then summarize if still too big
},
});

for await (const event of agent.stream('Audit the repository.')) {
if (event.type === 'compaction.done') console.log(`${event.tokensBefore} -> ${event.tokensAfter} tokens`);
}
```

The object takes `strategy`, `thresholdPercent`, `contextWindow` and
`protectedTokens` (as in the table below) and `summarizer`; combining
`summarizer` with `strategy` is a configuration error (pass
`twoPhaseStrategy({ model })` as the `strategy` instead). `createAgent({ hooks })`
takes any other `AgentHook`s, which run before the compaction hook, so
compaction sees what they added. Hooks and compaction apply to `send()`,
`stream()` and sessions.

### Stream events

`agent.stream()`, `session.stream()` and `AgentExecutor.stream()` report each
compaction as two events, inside the step and before the model call that
triggered it (see [Streaming](./streaming.md#event-schema-version-1)):

| `type` | Fields |
| --- | --- |
| `compaction.start` | `strategy`, `tokensBefore`, `contextWindow`, `thresholdTokens` |
| `compaction.done` | `strategy`, `tokensBefore`, `tokensAfter`, `prunedToolCallIds`, `summary?: boolean`, `error?: { message }` |

Every `compaction.start` is followed by exactly one `compaction.done`. When the
strategy could not shrink anything, `tokensAfter` equals `tokensBefore`; when it
failed (or the summarizer failed and the hook fell back to pruning), `error` is
set and the run continues. `summary` is `true` when old turns were replaced by
a summary (the text itself is not sent; use `onCompaction` for it). A
non-streaming `send()` emits no events. Hooks add their own events with
`ctx.emit?.(...)` on the `preGenerate` context, which exists only in streamed runs.

## Compacting a run

`createCompactionHook()` returns an `AgentHook` named `compaction`. Register it
Expand Down Expand Up @@ -192,6 +246,6 @@ const keepRecent: CompactionStrategy = {
const hook = createCompactionHook({ strategy: keepRecent });
```

Typed `compaction.*` stream events and a `createAgent({ compaction })` option
are planned (LOU-W3.2); until then `onCompaction` is where to observe
compactions.
A custom strategy gets the same `compaction.start` / `compaction.done` events as
the built-in ones. `onCompaction` (on `createCompactionHook()`) still receives
the summary text and the `Error`.
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,8 @@ const agent = createAgent({ prompt: 'You are helpful.', provider });
| `projectInstructions` | `true` or `{ cwd?, files? }`: append the nearest `AGENTS.md` / `CLAUDE.md` to the instructions (off by default; see [Project instructions](#project-instructions)). |
| `retry` | `withRetry()` options for failed model calls, or `false`. Default `{ maxRetries: 2 }` for `model` strings; a `provider` instance is wrapped only when set. See [Provider retries and fallback](#provider-retries-and-fallback). |
| `fallbackModels` | `provider/model` strings tried in order when the model still fails after its retries. |
| `hooks` | `AgentHook[]` run around every model call and tool call, in order, before the compaction hook (see `AgentHook` in the [API overview](./api-overview.md)). |
| `compaction` | `true` (prune old tool results above 90% of the context window) or `{ strategy?, thresholdPercent?, contextWindow?, protectedTokens?, summarizer? }`; `summarizer` (`'provider/model'` or an `LLMProvider`) selects the two-phase strategy. `stream()` reports `compaction.start` / `compaction.done`. See [Context compaction](./compaction.md#compacting-an-agent). |

With neither `model` nor `provider`, `createAgent()` resolves from the
environment: `LOUSHY_MODEL` (a `'provider/model'` string) if set, otherwise
Expand Down
7 changes: 5 additions & 2 deletions docs/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ The event types and their extra fields:
| `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). |
| `provider.fallback` | `from: string`, `to: string`, `error: { message: string }` | A model call still failed after its retries and the next provider takes over (`createAgent({ fallbackModels })` or any `withFallback()` provider). `from` and `to` are provider names. |
| `compaction.start` | `strategy: string`, `tokensBefore: number`, `contextWindow: number`, `thresholdTokens: number` | The compaction hook (`createAgent({ compaction })`) found the next model request above its threshold and starts compacting it. Exactly one `compaction.done` follows. See [Context compaction](./compaction.md). |
| `compaction.done` | `strategy: string`, `tokensBefore: number`, `tokensAfter: number`, `prunedToolCallIds: string[]`, `summary?: boolean`, `error?: { message: string }` | A compaction ended. `tokensAfter` equals `tokensBefore` when nothing could be compacted; `summary` is set when old turns were replaced by a summary; `error` is set when the strategy failed or fell back (the run continues). |
| `run.done` | `finishReason: string`, `text: string`, `usage?: { promptTokens, completionTokens, totalTokens }`, `object?: unknown` | Last event of every run, exactly once, including aborted, failed and awaiting-approval runs. `finishReason` and `text` match `run.result` (`'max-steps'` when the `maxSteps` budget ran out while the model still wanted to continue); a failed run has `finishReason: 'error'`, `text: ''` and no `usage`. `object` is `run.result`'s validated `object` for an agent with an `output` schema (see [Structured output](./structured-output.md)), absent otherwise. |

Optional fields are left out when they have no value. They are never
Expand All @@ -122,8 +124,9 @@ Optional fields are left out when they have no value. They are never
- Each `step.start` is followed by exactly one `step.done` with the same
`step`, before the next `step.start`. Everything a step does happens between
the two.
- Inside a step: `provider.retry` / `provider.fallback` events (if the model
call fails), then `text.delta` events, then `text.done`, then the tool events.
- Inside a step: `compaction.start` / `compaction.done` (if the request was
compacted, before the model call), `provider.retry` / `provider.fallback`
events (if the model call fails), then `text.delta` events, then `text.done`, then the tool events.
- Tool calls of one step run in parallel (see `toolConcurrency`):
`tool.start` events come in the model's call order and `tool.done` /
`tool.error` events in completion order. Match them by `toolCallId`.
Expand Down
Loading
Loading