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
- Context compaction (LOU-W2): `createCompactionHook()` returns an `AgentHook` that, before a model call estimated above `thresholdPercent` (default 0.9) of the model's context window, replaces tool results older than the newest `protectedTokens` (default 40,000) with a `[pruned: <tool> result, N chars]` marker, in the run's transcript itself. `compactMessages()` compacts a conversation by hand; `CompactionStrategy` / `pruneToolResultsStrategy()` make the strategy pluggable. See docs/compaction.md.
- `AgentSpec.mcpServers` (LOU-D20): a validated map of MCP servers (stdio `command`/`args`/`env` or HTTP `url`/`headers`) in agent spec files. `loadSpec()` reports a bad entry with its name, `loushy doctor` reads the validated field instead of the raw file, and `specToAgent()` exposes the parsed servers as `agent.mcpServers` (connecting them is TODO(LOU-D20.2)). Existing specs are unaffected.
- `createAgent()` agents can pause for approval (LOU-D21): a `needsApproval` tool no longer fails the run with "requires approval but no approvalStore". The run pauses (`finishReason: 'awaiting-approval'`) in a per-agent `InMemoryApprovalStore` (or the new `approvalStore` option), and `agent.approvals.list()` / `agent.approvals.resolve({ id, approved, note? })` continue it, in its session if it paused in one. The `approve` option decides calls in code without pausing.
- Tools own their contract (LOU-D22): `ToolDescriptor` gains optional `inputSchema` (zod) and `execute(args, ctx)`, which are now the canonical fields. `defineTool()` sets both and no longer calls `ai`'s `tool()`. Argument validation, the schema sent to the model, and tool execution read them first and fall back to `tool.parameters` / `tool.execute`. `ToolDescriptor.tool` (the `ai` v4 `Tool`) is now legacy: it is still built by `defineTool()` and still accepted on hand-written descriptors this release, but new code should set `inputSchema` and `execute`.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ console.log(session.id, session.messages.length);
- [Sessions](docs/sessions.md) - multi-turn conversations: `agent.session()`, `MemorySessionStore`, `FileSessionStore`
- [Skills](docs/skills.md) - on-demand instructions: `defineSkill()`, `loadSkills()`, how they save context
- [Sub-agents](docs/sub-agents.md) - the `subagents` option and its `task` tool, what sub-agents inherit, approvals inside sub-agents
- [Context compaction](docs/compaction.md) - keep long runs under the context window: prune old tool results with `createCompactionHook()`
- [Agent directories](docs/agent-directories.md) - define an agent as a folder: layout, mapping to `createAgent()`, moving to code, security
- [Agent Forge](docs/agent-forge.md) - the visual dashboard (`loushy studio`): quickstart, first-agent walkthrough, hook authoring
- Full guides site: [linuxdevil.github.io/agent-sdk-docs](https://linuxdevil.github.io/agent-sdk-docs/)
Expand Down
11 changes: 11 additions & 0 deletions docs/api-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,17 @@ registerModel({
});
```

### Context compaction

`createCompactionHook({ thresholdPercent?, contextWindow?, protectedTokens?, strategy?, onCompaction? })`
returns an `AgentHook` that, before each model call above 90% (by default) of
the model's context window, replaces tool results older than the newest
40,000 tokens with a `[pruned: <tool> result, N chars]` marker. It edits the
run's transcript in place, so pruning persists in checkpoints and
`result.messages`. `compactMessages(messages, options)` does the same once,
by hand, and `pruneToolResultsStrategy()` / `CompactionStrategy` are the
built-in and pluggable strategies. See [Context compaction](./compaction.md).

## Flows, evals, observability and security

- `FlowBuilder` / `FlowExecutor` - multi-step workflow graphs.
Expand Down
110 changes: 110 additions & 0 deletions docs/compaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Context compaction

A long agent run fills its context window mostly with tool results: file
contents, search hits, API responses the model read once and no longer needs
in full. Compaction keeps such a run under the model's limit by replacing old
tool results with a short marker before the request is sent.

## Pruning old tool results in a run

`createCompactionHook()` returns an `AgentHook` named `compaction`. Register it
and pass the registry to `AgentExecutor.execute()`:

```ts
import { AgentExecutor, HookRegistry, createCompactionHook } from '@loushy/build-ai-agent';

const hooks = new HookRegistry();
hooks.register(
createCompactionHook({
thresholdPercent: 0.9, // compact above 90% of the context window (the default)
protectedTokens: 40_000, // never prune the newest 40K tokens (the default)
onCompaction: ({ tokensBefore, tokensAfter, prunedToolCallIds }) =>
console.log(`compacted ${tokensBefore} -> ${tokensAfter} tokens (${prunedToolCallIds.length} results)`),
})
);

const result = await AgentExecutor.execute({ agent, input, provider, toolRegistry, hooks });
```

Before every model call the hook estimates the request's size with
`estimateTokens` (see [Models, tokens and cost](./api-overview.md#models-tokens-and-cost)).
When it is above `thresholdPercent` of the context window, the hook runs its
strategy and calls `onCompaction` with the token counts, the pruned
`toolCallId`s and the strategy name.

| Option | Default | Meaning |
| --- | --- | --- |
| `thresholdPercent` | `0.9` | Compact when the estimated request is above this share of the context window. Must be in (0, 1]. |
| `contextWindow` | registry, else `128_000` | Context window in tokens. By default it is looked up for `request.model` with `getModelInfo()`; register your own models with `registerModel()`. |
| `protectedTokens` | `40_000` | The newest messages that fit in this many tokens are never changed. |
| `strategy` | `pruneToolResultsStrategy()` | How to compact (see below). |
| `onCompaction` | none | Called after each compaction that changed the conversation. |

## What pruning changes

`pruneToolResultsStrategy()` replaces the `content` of each tool result older
than the protected tail with a marker such as
`[pruned: search result, 18234 chars]`. It never changes:

- system messages, user messages (the first one included) or assistant turns,
so every tool call keeps its result message and the transcript stays valid
for every provider;
- the results of the latest assistant turn, which the model has not read yet,
even when they are bigger than `protectedTokens`;
- results that are already a marker, or shorter than one.

The hook rewrites the run's transcript itself, not a copy: the request's
`messages` array is the run's message list, and the hook changes it in place.
A pruned result therefore stays pruned in later steps, in checkpoints (a
resumed session does not bring the full result back) and in
`result.messages`, and it is not pruned again on the next step. Pruning is
lossy: if the model needs a pruned result again, it has to call the tool
again.

## Compacting by hand

`compactMessages(messages, options)` runs a strategy once, whatever the size
of the conversation, and returns a new array (its input is not modified). Use
it to shrink a stored transcript before you continue it:

```ts
import { compactMessages, type Message } from '@loushy/build-ai-agent';

declare const history: Message[];

const { messages, tokensBefore, tokensAfter, prunedToolCallIds } = compactMessages(history, {
protectedTokens: 8_000,
model: 'gpt-4o-mini', // for the context-window lookup and the token estimator
});
console.log(`${tokensBefore} -> ${tokensAfter} tokens, pruned ${prunedToolCallIds.length} results`);
```

## Writing a strategy

A strategy is a name and a `compact()` function. It receives the messages, a
token counter for the request's model, the context window and
`protectedTokens`, and returns the new messages with before/after token counts.
Return the input array unchanged when there is nothing to do: the hook then
leaves the run alone and does not call `onCompaction`.

```ts
import { createCompactionHook, type CompactionStrategy } from '@loushy/build-ai-agent';

// Keep the system prompt, the first user message and the last 20 messages.
const keepRecent: CompactionStrategy = {
name: 'keep-recent',
compact({ messages, estimateTokens }) {
const tokensBefore = estimateTokens(messages);
if (messages.length <= 22) return { messages, tokensBefore, tokensAfter: tokensBefore, prunedToolCallIds: [] };
// A real strategy must not split a tool call from its result.
const kept = [...messages.slice(0, 2), ...messages.slice(-20)];
return { messages: kept, tokensBefore, tokensAfter: estimateTokens(kept), prunedToolCallIds: [] };
},
};

const hook = createCompactionHook({ strategy: keepRecent });
```

A summarizing strategy (replace old turns with a model-written summary) and
typed `compaction.*` stream events are planned; `onCompaction` is where they
will plug in.
125 changes: 125 additions & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ console.log(session.id, session.messages.length);
- [Sessions](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sessions.md) - multi-turn conversations: `agent.session()`, `MemorySessionStore`, `FileSessionStore`
- [Skills](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/skills.md) - on-demand instructions: `defineSkill()`, `loadSkills()`, how they save context
- [Sub-agents](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sub-agents.md) - the `subagents` option and its `task` tool, what sub-agents inherit, approvals inside sub-agents
- [Context compaction](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/compaction.md) - keep long runs under the context window: prune old tool results with `createCompactionHook()`
- [Agent directories](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/agent-directories.md) - define an agent as a folder: layout, mapping to `createAgent()`, moving to code, security
- [Agent Forge](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/agent-forge.md) - the visual dashboard (`loushy studio`): quickstart, first-agent walkthrough, hook authoring
- Full guides site: [linuxdevil.github.io/agent-sdk-docs](https://linuxdevil.github.io/agent-sdk-docs/)
Expand Down Expand Up @@ -1618,6 +1619,17 @@ registerModel({
});
```

### Context compaction

`createCompactionHook({ thresholdPercent?, contextWindow?, protectedTokens?, strategy?, onCompaction? })`
returns an `AgentHook` that, before each model call above 90% (by default) of
the model's context window, replaces tool results older than the newest
40,000 tokens with a `[pruned: <tool> result, N chars]` marker. It edits the
run's transcript in place, so pruning persists in checkpoints and
`result.messages`. `compactMessages(messages, options)` does the same once,
by hand, and `pruneToolResultsStrategy()` / `CompactionStrategy` are the
built-in and pluggable strategies. See [Context compaction](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/compaction.md).

## Flows, evals, observability and security

- `FlowBuilder` / `FlowExecutor` - multi-step workflow graphs.
Expand Down Expand Up @@ -2223,6 +2235,119 @@ npm run test:e2e:studio # from the repo root, or apps/agent-forge/
This builds Agent Forge first (`build:studio`), then runs Playwright
against it.

# Context compaction

Source: docs/compaction.md

A long agent run fills its context window mostly with tool results: file
contents, search hits, API responses the model read once and no longer needs
in full. Compaction keeps such a run under the model's limit by replacing old
tool results with a short marker before the request is sent.

## Pruning old tool results in a run

`createCompactionHook()` returns an `AgentHook` named `compaction`. Register it
and pass the registry to `AgentExecutor.execute()`:

```ts
import { AgentExecutor, HookRegistry, createCompactionHook } from '@loushy/build-ai-agent';

const hooks = new HookRegistry();
hooks.register(
createCompactionHook({
thresholdPercent: 0.9, // compact above 90% of the context window (the default)
protectedTokens: 40_000, // never prune the newest 40K tokens (the default)
onCompaction: ({ tokensBefore, tokensAfter, prunedToolCallIds }) =>
console.log(`compacted ${tokensBefore} -> ${tokensAfter} tokens (${prunedToolCallIds.length} results)`),
})
);

const result = await AgentExecutor.execute({ agent, input, provider, toolRegistry, hooks });
```

Before every model call the hook estimates the request's size with
`estimateTokens` (see [Models, tokens and cost](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/api-overview.md#models-tokens-and-cost)).
When it is above `thresholdPercent` of the context window, the hook runs its
strategy and calls `onCompaction` with the token counts, the pruned
`toolCallId`s and the strategy name.

| Option | Default | Meaning |
| --- | --- | --- |
| `thresholdPercent` | `0.9` | Compact when the estimated request is above this share of the context window. Must be in (0, 1]. |
| `contextWindow` | registry, else `128_000` | Context window in tokens. By default it is looked up for `request.model` with `getModelInfo()`; register your own models with `registerModel()`. |
| `protectedTokens` | `40_000` | The newest messages that fit in this many tokens are never changed. |
| `strategy` | `pruneToolResultsStrategy()` | How to compact (see below). |
| `onCompaction` | none | Called after each compaction that changed the conversation. |

## What pruning changes

`pruneToolResultsStrategy()` replaces the `content` of each tool result older
than the protected tail with a marker such as
`[pruned: search result, 18234 chars]`. It never changes:

- system messages, user messages (the first one included) or assistant turns,
so every tool call keeps its result message and the transcript stays valid
for every provider;
- the results of the latest assistant turn, which the model has not read yet,
even when they are bigger than `protectedTokens`;
- results that are already a marker, or shorter than one.

The hook rewrites the run's transcript itself, not a copy: the request's
`messages` array is the run's message list, and the hook changes it in place.
A pruned result therefore stays pruned in later steps, in checkpoints (a
resumed session does not bring the full result back) and in
`result.messages`, and it is not pruned again on the next step. Pruning is
lossy: if the model needs a pruned result again, it has to call the tool
again.

## Compacting by hand

`compactMessages(messages, options)` runs a strategy once, whatever the size
of the conversation, and returns a new array (its input is not modified). Use
it to shrink a stored transcript before you continue it:

```ts
import { compactMessages, type Message } from '@loushy/build-ai-agent';

declare const history: Message[];

const { messages, tokensBefore, tokensAfter, prunedToolCallIds } = compactMessages(history, {
protectedTokens: 8_000,
model: 'gpt-4o-mini', // for the context-window lookup and the token estimator
});
console.log(`${tokensBefore} -> ${tokensAfter} tokens, pruned ${prunedToolCallIds.length} results`);
```

## Writing a strategy

A strategy is a name and a `compact()` function. It receives the messages, a
token counter for the request's model, the context window and
`protectedTokens`, and returns the new messages with before/after token counts.
Return the input array unchanged when there is nothing to do: the hook then
leaves the run alone and does not call `onCompaction`.

```ts
import { createCompactionHook, type CompactionStrategy } from '@loushy/build-ai-agent';

// Keep the system prompt, the first user message and the last 20 messages.
const keepRecent: CompactionStrategy = {
name: 'keep-recent',
compact({ messages, estimateTokens }) {
const tokensBefore = estimateTokens(messages);
if (messages.length <= 22) return { messages, tokensBefore, tokensAfter: tokensBefore, prunedToolCallIds: [] };
// A real strategy must not split a tool call from its result.
const kept = [...messages.slice(0, 2), ...messages.slice(-20)];
return { messages: kept, tokensBefore, tokensAfter: estimateTokens(kept), prunedToolCallIds: [] };
},
};

const hook = createCompactionHook({ strategy: keepRecent });
```

A summarizing strategy (replace old turns with a model-written summary) and
typed `compaction.*` stream events are planned; `onCompaction` is where they
will plug in.

# Configuration

Source: docs/configuration.md
Expand Down
1 change: 1 addition & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- [Quick Start](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/quick-start.md): Every TypeScript snippet on this page is a complete, standalone ES module (`.mts`).
- [API Overview](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/api-overview.md): The package root (`@loushy/build-ai-agent`) exports everything below.
- [Agent directories](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/agent-directories.md): An agent can be defined as a directory.
- [Context compaction](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/compaction.md): A long agent run fills its context window mostly with tool results: file contents, search hits, API responses the model read once and no longer needs in full.
- [Configuration](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/configuration.md): A declarative agent is a YAML (`.yaml`/`.yml`) or JSON (`.json`) file, validated with zod by `loadSpec()` (`src/spec/schema.ts`).
- [Durable execution](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/durable-execution.md): Pass a `sessionId` and a `checkpointStore` to `AgentExecutor.execute()` and the run survives a crash, a restart, an abort or a human approval that takes days.
- [Evals](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/evals.md): Evals are regression tests for agent *behaviour*: which tools the agent called, in what order, with which arguments, how many steps it took, and what it said.
Expand Down
Loading
Loading