@fifthrevision/axle 0.30.2 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1031 -0
- package/dist/index.d.ts +27 -13
- package/dist/index.js +21 -21
- package/dist/models.d.ts +9 -2
- package/dist/models.js +1 -1
- package/dist/{transcript-D69LP3XR.d.ts → transcript-BuUBiQX6.d.ts} +22 -3
- package/dist/ui.d.ts +1 -1
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
# Axle
|
|
2
|
+
|
|
3
|
+
Axle is a TypeScript library for building multi-turn LLM agents. It provides a
|
|
4
|
+
small, focused API for building agentic applications.
|
|
5
|
+
|
|
6
|
+
**Documentation:** https://axle.fifthrevision.com
|
|
7
|
+
|
|
8
|
+
## Introduction
|
|
9
|
+
|
|
10
|
+
I built Axle while working on a command line AI task runner. I wanted a TypeScript-native library that would work across different inference providers.
|
|
11
|
+
|
|
12
|
+
It started as a workflow runner inspired by the composability of DSPy. As models got better with reasoning and tool use, many of the early abstractions, such as workflow shapes and explicit chain-of-thought constructs became unnecessary.
|
|
13
|
+
|
|
14
|
+
Today, Axle focuses on bringing modern agentic patterns to TypeScript with sensible defaults and minimal setup.
|
|
15
|
+
|
|
16
|
+
This library is for you if:
|
|
17
|
+
|
|
18
|
+
- You want a TypeScript-native library.
|
|
19
|
+
- You want to build multi-turn LLM agents without wiring up a framework.
|
|
20
|
+
- You want an ergonomic API with thoughtful defaults.
|
|
21
|
+
- You want to switch inference providers without rewriting your agents.
|
|
22
|
+
|
|
23
|
+
Axle powers [Sunnyday](https://www.sunnyday.run), a hosted
|
|
24
|
+
AI Agent platform. It also forms the core of [Axle CLI](https://www.npmjs.com/package/@fifthrevision/axle-cli) and other experiments such as [Axle Code](https://github.com/johncch/axle-code)
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
import { Agent, Instruct, anthropic } from "@fifthrevision/axle";
|
|
30
|
+
|
|
31
|
+
const provider = anthropic(process.env.ANTHROPIC_API_KEY);
|
|
32
|
+
const agent = new Agent({ provider, model: "claude-sonnet-4-5-20250929" });
|
|
33
|
+
|
|
34
|
+
const r1 = await agent.send("What is the capital of France?").final;
|
|
35
|
+
if (!r1.ok) throw new Error(r1.error.kind);
|
|
36
|
+
console.log(r1.response); // "Paris is the capital of France."
|
|
37
|
+
|
|
38
|
+
// Multi-turn — history is managed automatically
|
|
39
|
+
const r2 = await agent.send("And what about Germany?").final;
|
|
40
|
+
if (!r2.ok) throw new Error(r2.error.kind);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Core Concepts
|
|
44
|
+
|
|
45
|
+
### Agent
|
|
46
|
+
|
|
47
|
+
Agent is the primary interface. It owns the provider, model, system prompt,
|
|
48
|
+
tools, and conversation history. `send()` starts immediately when the agent is
|
|
49
|
+
idle and otherwise queues FIFO. It accepts either a plain string or an
|
|
50
|
+
Instruct.
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
const agent = new Agent({
|
|
54
|
+
provider: anthropic(apiKey),
|
|
55
|
+
model: "claude-sonnet-4-5-20250929",
|
|
56
|
+
system: "You are a helpful assistant.",
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
To interject while the agent is working, stop the active turn and send the
|
|
61
|
+
follow-up:
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
const h1 = agent.send("Build the feature.");
|
|
65
|
+
|
|
66
|
+
// later, from an event handler while h1 is executing:
|
|
67
|
+
agent.stop(); // returns false if no turn is executing yet
|
|
68
|
+
const h2 = agent.send("Make the button blue.");
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`agent.stop()` asks the active turn to finish at its next complete tool-batch
|
|
72
|
+
boundary: every tool in the in-flight batch completes—including parallel
|
|
73
|
+
calls—and commits, then the handle settles without another provider request.
|
|
74
|
+
A turn whose response requests no tools completes normally. `stop()` returns
|
|
75
|
+
`false` when no turn is executing, and never affects queued sends. To drop
|
|
76
|
+
queued work as well, call `agent.clear()`: it cancels every queued operation
|
|
77
|
+
(each cleared handle rejects with an `AxleAgentAbortError`, committing
|
|
78
|
+
nothing) and returns the number cleared, leaving the active turn untouched.
|
|
79
|
+
`stop(); clear(); send(next)` makes `next` the very next turn. The
|
|
80
|
+
transcript stays linear: the committed batch is visible to the follow-up
|
|
81
|
+
turn.
|
|
82
|
+
|
|
83
|
+
Each `final` resolves only that handle's result: `h1` settles at the stop
|
|
84
|
+
boundary and does not absorb `h2`'s response. A stopped turn ends on its
|
|
85
|
+
tool-call exchange, so a plain send resolves with whatever text that turn
|
|
86
|
+
produced (often empty) and an Instruct send may resolve `ok: false` with a
|
|
87
|
+
parse error — no final answer exists yet by design.
|
|
88
|
+
|
|
89
|
+
Cancellation is handle-local, and the user message commits when its
|
|
90
|
+
`turn:user` event is emitted, after setup succeeds. Cancelling a queued handle
|
|
91
|
+
or a running handle during setup removes it without committing its user
|
|
92
|
+
message. Once `turn:user` is emitted, the committed message remains and the
|
|
93
|
+
agent turn is marked cancelled. This includes cancellation during
|
|
94
|
+
`beforeTurn` compaction: compaction is ordinary work inside the already-open
|
|
95
|
+
turn and cancellation does not unwind the transcript or active conversation. Other
|
|
96
|
+
queued handles continue. `stop()` never interrupts a running provider request
|
|
97
|
+
or tool batch; use cancellation when a hard stop is required.
|
|
98
|
+
|
|
99
|
+
### Instruct
|
|
100
|
+
|
|
101
|
+
Instruct is a rich message. Use it when you need structured output, file
|
|
102
|
+
attachments, bound template inputs, or host-supplied supporting context.
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
import * as z from "zod";
|
|
106
|
+
|
|
107
|
+
const instruct = new Instruct({
|
|
108
|
+
prompt: "Summarize the following {{topic}}.",
|
|
109
|
+
schema: z.object({
|
|
110
|
+
summary: z.string(),
|
|
111
|
+
keyPoints: z.array(z.string()),
|
|
112
|
+
}),
|
|
113
|
+
}).withInputs({ topic: "document" });
|
|
114
|
+
instruct.addContext("Files available: report.pdf", {
|
|
115
|
+
title: "Sandbox manifest",
|
|
116
|
+
});
|
|
117
|
+
instruct.addFile(await loadFileContent("./report.pdf"));
|
|
118
|
+
|
|
119
|
+
const result = await agent.send(instruct).final;
|
|
120
|
+
if (!result.ok) throw new Error(result.error.kind);
|
|
121
|
+
// result.response is { summary: string, keyPoints: string[] }
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
For plain text interactions, pass a string directly to `send()` instead.
|
|
125
|
+
|
|
126
|
+
### Providers
|
|
127
|
+
|
|
128
|
+
Axle ships with first-party support for Anthropic, OpenAI, and Gemini, plus a
|
|
129
|
+
generic ChatCompletions provider for any OpenAI-compatible API.
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
import { anthropic, openai, gemini, chatCompletions } from "@fifthrevision/axle";
|
|
133
|
+
|
|
134
|
+
const a = anthropic(process.env.ANTHROPIC_API_KEY);
|
|
135
|
+
const o = openai(process.env.OPENAI_API_KEY);
|
|
136
|
+
const g = gemini(process.env.GEMINI_API_KEY);
|
|
137
|
+
const local = chatCompletions("http://localhost:11434/v1");
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### `stream()` and `generate()`
|
|
141
|
+
|
|
142
|
+
Agent is built on two lower-level primitives that can be used directly when you
|
|
143
|
+
want full control without conversation management.
|
|
144
|
+
|
|
145
|
+
`stream()` runs a tool loop over a streaming request and returns a handle with
|
|
146
|
+
callbacks for real-time output:
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
import { stream } from "@fifthrevision/axle";
|
|
150
|
+
|
|
151
|
+
const handle = stream({
|
|
152
|
+
provider,
|
|
153
|
+
model,
|
|
154
|
+
messages: [{ role: "user", content: "Hello" }],
|
|
155
|
+
tools: [myTool],
|
|
156
|
+
onToolCall: async (name, params) => ({ type: "success", content: "result" }),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
handle.on((event) => {
|
|
160
|
+
if (event.type === "text:delta") process.stdout.write(event.delta);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const result = await handle.final;
|
|
164
|
+
if (!result.ok) throw new Error(result.error.kind);
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`generate()` does the same but without streaming — it returns the final result
|
|
168
|
+
directly as a promise:
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
import { generate } from "@fifthrevision/axle";
|
|
172
|
+
|
|
173
|
+
const result = await generate({
|
|
174
|
+
provider,
|
|
175
|
+
model,
|
|
176
|
+
messages: [{ role: "user", content: "Hello" }],
|
|
177
|
+
tools: [myTool],
|
|
178
|
+
onToolCall: async (name, params) => ({ type: "success", content: "result" }),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (!result.ok) throw new Error(result.error.kind);
|
|
182
|
+
result.response; // final assistant message
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Both `stream()` and `generate()` also accept an `Instruct` as the latest user
|
|
186
|
+
turn. When `messages` is provided with `instruct`, `messages` is treated as
|
|
187
|
+
prior context and the rendered `Instruct` is appended as the new user message.
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
import * as z from "zod";
|
|
191
|
+
import { generate, Instruct } from "@fifthrevision/axle";
|
|
192
|
+
|
|
193
|
+
const result = await generate({
|
|
194
|
+
provider,
|
|
195
|
+
model,
|
|
196
|
+
messages: previousMessages,
|
|
197
|
+
instruct: new Instruct({
|
|
198
|
+
prompt: "Answer {{question}}.",
|
|
199
|
+
schema: z.object({
|
|
200
|
+
answer: z.string(),
|
|
201
|
+
}),
|
|
202
|
+
}).withInput("question", "Should we proceed?"),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (!result.ok) throw new Error(result.error.kind);
|
|
206
|
+
result.response.answer; // string
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Both handle the full tool-call loop automatically. Agent uses `stream()`
|
|
210
|
+
internally and adds history management, system prompt, and callback wiring on
|
|
211
|
+
top.
|
|
212
|
+
|
|
213
|
+
Two options bound the tool loop. `maxSteps` caps the number of model requests;
|
|
214
|
+
`maxContextTokens` caps the context budget, checked after each step's tools are
|
|
215
|
+
answered against that step's reported usage (effective input + output).
|
|
216
|
+
Crossing either limit is a stop, not an error: the loop returns `ok: true` with
|
|
217
|
+
everything accumulated so far and `stopped` set to `"max-steps"` or
|
|
218
|
+
`"token-limit"`. The caller decides what happens next — e.g. compact the
|
|
219
|
+
conversation and start a new call. Non-positive limits throw at call time.
|
|
220
|
+
|
|
221
|
+
### Reasoning
|
|
222
|
+
|
|
223
|
+
`reasoning` is the one portable control over provider thinking. It is
|
|
224
|
+
accepted by `Agent`, `generate()`, `stream()`, and `PromptCompactor`:
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
type ReasoningSetting = "default" | "off" | "on" | { effort: "low" | "medium" | "high" };
|
|
228
|
+
|
|
229
|
+
await generate({ provider, model, messages, reasoning: { effort: "high" } });
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
| Setting | Meaning |
|
|
233
|
+
| --------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
234
|
+
| omitted / `"default"` | No reasoning fields are sent; the model runs at its provider default |
|
|
235
|
+
| `"off"` | The provider's explicit disable. Models that cannot turn thinking off (Fable, Gemini 3, Gemini 2.5 Pro) reject the request |
|
|
236
|
+
| `"on"` | Same as `{ effort: "medium" }` |
|
|
237
|
+
| `{ effort }` | A named level on modern models, or a fixed token budget (2,048 / 8,192 / 16,384) on models that only accept budgets |
|
|
238
|
+
|
|
239
|
+
Effort is relative within a model, not comparable across models. `"on"`
|
|
240
|
+
enables reasoning but does not guarantee a visible thinking block on every
|
|
241
|
+
response. Axle does not validate support: an unsupported combination comes
|
|
242
|
+
back as a provider error. Anything beyond these levels, such as `xhigh`, an
|
|
243
|
+
exact budget, or thinking display, goes through `providerOptions`, which is
|
|
244
|
+
applied after the portable mapping and overrides it.
|
|
245
|
+
|
|
246
|
+
Anthropic needs an output cap on every request. When you don't pass
|
|
247
|
+
`maxOutputTokens`, `stream()` uses the model's ceiling and `generate()` uses
|
|
248
|
+
21,000, the largest cap the Anthropic SDK sends without streaming. The
|
|
249
|
+
per-provider translation is documented in `docs/architecture/reasoning.md`.
|
|
250
|
+
|
|
251
|
+
### Results
|
|
252
|
+
|
|
253
|
+
`generate(...)`, `stream(...).final`, and `agent.send(...).final` all resolve
|
|
254
|
+
to a two-state result:
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
if (!result.ok) {
|
|
258
|
+
result.error.kind; // "model" | "tool" | "parse"
|
|
259
|
+
result.error.message; // present for every error kind
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
result.response; // always present when ok is true
|
|
264
|
+
result.stopped; // "max-steps" | "token-limit" when a loop limit ended the run
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
For `generate()` and `stream()`, plain calls return the final assistant message.
|
|
268
|
+
For `Agent.send("...")`, plain calls return the assistant text. `Instruct`
|
|
269
|
+
calls return the parsed schema value. Model, tool, and parse failures return
|
|
270
|
+
`ok: false`; abort, fatal tool, configuration, and unexpected execution errors
|
|
271
|
+
still throw.
|
|
272
|
+
|
|
273
|
+
Cancellation follows standard JavaScript abort semantics:
|
|
274
|
+
|
|
275
|
+
- `handle.cancel(reason)` aborts that stream or send handle only.
|
|
276
|
+
- A cancelled Agent handle commits no user turn unless its `turn:user` event
|
|
277
|
+
was already emitted. After that point the committed user turn remains and
|
|
278
|
+
the agent turn is marked cancelled, including when cancellation occurs
|
|
279
|
+
during `beforeTurn` compaction before a provider request is made.
|
|
280
|
+
- `stream().final`, `generate(...)`, and Agent handle finals reject with an
|
|
281
|
+
error whose `name` is `"AbortError"`.
|
|
282
|
+
- Axle abort errors preserve `reason`, `usage`, and partial state where
|
|
283
|
+
available (`messages`, `partial`, and for Agent handles, `turn`).
|
|
284
|
+
|
|
285
|
+
## Details
|
|
286
|
+
|
|
287
|
+
### Structured Output
|
|
288
|
+
|
|
289
|
+
Pass a Zod schema to Instruct. Axle compiles the schema
|
|
290
|
+
into output format instructions, then parses the response back into typed
|
|
291
|
+
objects.
|
|
292
|
+
|
|
293
|
+
```typescript
|
|
294
|
+
import * as z from "zod";
|
|
295
|
+
|
|
296
|
+
const instruct = new Instruct({
|
|
297
|
+
prompt: "Tell me about Mars.",
|
|
298
|
+
schema: z.object({
|
|
299
|
+
name: z.string(),
|
|
300
|
+
distanceFromSun: z.number(),
|
|
301
|
+
moons: z.array(z.string()),
|
|
302
|
+
}),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const agent = new Agent({ provider, model });
|
|
306
|
+
const result = await agent.send(instruct).final;
|
|
307
|
+
if (!result.ok) throw new Error(result.error.kind);
|
|
308
|
+
|
|
309
|
+
result.response.name; // string
|
|
310
|
+
result.response.distanceFromSun; // number
|
|
311
|
+
result.response.moons; // string[]
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
For one-shot structured calls without agent-managed history, pass the same
|
|
315
|
+
`Instruct` directly to `generate()` or `stream()`.
|
|
316
|
+
|
|
317
|
+
### Supporting Context and Files
|
|
318
|
+
|
|
319
|
+
Use `addContext` for host-supplied information that should remain separate from
|
|
320
|
+
the user-authored prompt until final rendering. Typical examples include a
|
|
321
|
+
sandbox file manifest, environment details, retrieved records, or application
|
|
322
|
+
state:
|
|
323
|
+
|
|
324
|
+
```typescript
|
|
325
|
+
const instruct = new Instruct({
|
|
326
|
+
prompt: "Review the sandbox and propose the next change.",
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
instruct
|
|
330
|
+
.addContext("src/index.ts\nsrc/server.ts\npackage.json", {
|
|
331
|
+
title: "Sandbox files",
|
|
332
|
+
})
|
|
333
|
+
.addContext("Node.js 24\nPackage manager: pnpm", {
|
|
334
|
+
title: "Environment",
|
|
335
|
+
});
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Context sections are ordered, preserved by `clone()`/`withInputs()`, and do not
|
|
339
|
+
perform `{{variable}}` substitution. They still become part of the same final
|
|
340
|
+
user-message text, so `addContext` is a composition boundary, not a separate
|
|
341
|
+
model instruction priority.
|
|
342
|
+
|
|
343
|
+
Use `addFile` for actual file content or attachments:
|
|
344
|
+
|
|
345
|
+
```typescript
|
|
346
|
+
instruct.addFile("Inline reference text", { name: "notes.txt" });
|
|
347
|
+
instruct.addFile(await loadFileContent("./chart.png"));
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Inline text files render as reference sections. Images and PDFs remain file
|
|
351
|
+
parts and are converted to the selected provider's native input format.
|
|
352
|
+
|
|
353
|
+
### Tools
|
|
354
|
+
|
|
355
|
+
A tool is an object with a name, description, Zod schema, and an `execute`
|
|
356
|
+
function. Pass tools to the Agent constructor.
|
|
357
|
+
|
|
358
|
+
```typescript
|
|
359
|
+
import { z } from "zod";
|
|
360
|
+
|
|
361
|
+
const weatherTool = {
|
|
362
|
+
name: "getWeather",
|
|
363
|
+
description: "Get current weather for a city",
|
|
364
|
+
schema: z.object({ city: z.string() }),
|
|
365
|
+
async execute(input) {
|
|
366
|
+
return JSON.stringify({ temp: 72, condition: "sunny" });
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const agent = new Agent({
|
|
371
|
+
provider,
|
|
372
|
+
model,
|
|
373
|
+
tools: [weatherTool],
|
|
374
|
+
});
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
The core package does not ship concrete local tools. Define application tools
|
|
378
|
+
directly, or use the CLI package's job-file tool names when running jobs through
|
|
379
|
+
`axle`.
|
|
380
|
+
|
|
381
|
+
`execute` receives a `ToolContext` as its second argument. Long-running tools
|
|
382
|
+
can stream progress with `ctx.emit(...)`, and tools that call models can report
|
|
383
|
+
their token usage with `ctx.reportUsage(usage)` so it is rolled into the parent
|
|
384
|
+
operation's totals.
|
|
385
|
+
|
|
386
|
+
#### File results and deferred references
|
|
387
|
+
|
|
388
|
+
Tools can return structured text/file parts. A file may be inline, a URL, or a
|
|
389
|
+
host-owned deferred reference resolved only when a provider request needs it:
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
import type { ExecutableTool, FileResolver } from "@fifthrevision/axle";
|
|
393
|
+
import { z } from "zod";
|
|
394
|
+
|
|
395
|
+
const readFileSchema = z.object({ id: z.string() });
|
|
396
|
+
|
|
397
|
+
const readFile: ExecutableTool<typeof readFileSchema> = {
|
|
398
|
+
name: "read_file",
|
|
399
|
+
description: "Read a file from the sandbox",
|
|
400
|
+
schema: readFileSchema,
|
|
401
|
+
async execute({ id }) {
|
|
402
|
+
return [
|
|
403
|
+
{
|
|
404
|
+
type: "file",
|
|
405
|
+
file: {
|
|
406
|
+
kind: "text",
|
|
407
|
+
mimeType: "text/plain",
|
|
408
|
+
name: "result.txt",
|
|
409
|
+
source: { type: "ref", ref: { id } },
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
];
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const fileResolver: FileResolver = async ({ ref, accepted }) => {
|
|
417
|
+
// Authorize the opaque host ref and return one of the requested formats.
|
|
418
|
+
if (!accepted.includes("text")) {
|
|
419
|
+
throw new Error(`Text resolution is not supported here: ${accepted.join(", ")}`);
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
type: "text",
|
|
423
|
+
content: await sandbox.readText((ref as { id: string }).id),
|
|
424
|
+
};
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const agent = new Agent({
|
|
428
|
+
provider,
|
|
429
|
+
model,
|
|
430
|
+
tools: [readFile],
|
|
431
|
+
fileResolver,
|
|
432
|
+
});
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
Deferred refs remain in message history and session snapshots. Axle resolves
|
|
436
|
+
them again on every provider conversion, which avoids persisting expiring
|
|
437
|
+
signed URLs. Persisted `ref` values should therefore be JSON-serializable, and
|
|
438
|
+
the host must restore a compatible `FileResolver` when resuming a session.
|
|
439
|
+
|
|
440
|
+
Anthropic, OpenAI Responses, and Gemini accept tool-result files within their
|
|
441
|
+
normal image/PDF/text constraints. Chat Completions currently accepts text
|
|
442
|
+
tool-result files only.
|
|
443
|
+
|
|
444
|
+
### Subagent Tools
|
|
445
|
+
|
|
446
|
+
> **Experimental** — the API is usable today, but event and part shapes
|
|
447
|
+
> (notably `SubagentAction`) may change in a minor release while this feature
|
|
448
|
+
> is validated in real applications.
|
|
449
|
+
|
|
450
|
+
`createAgentTool` exposes a child Agent as a normal tool, letting a parent
|
|
451
|
+
model delegate bounded work and receive only the child's final response.
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
import { Agent, createAgentTool } from "@fifthrevision/axle";
|
|
455
|
+
import { z } from "zod";
|
|
456
|
+
|
|
457
|
+
const researcher = createAgentTool({
|
|
458
|
+
name: "research",
|
|
459
|
+
description: "Delegate a research question to a focused subagent",
|
|
460
|
+
schema: z.object({ question: z.string() }),
|
|
461
|
+
createAgent: () =>
|
|
462
|
+
new Agent({
|
|
463
|
+
provider: anthropic(apiKey),
|
|
464
|
+
model: "claude-haiku-4-5-20251001",
|
|
465
|
+
system: "You are a focused researcher. Answer concisely.",
|
|
466
|
+
}),
|
|
467
|
+
prompt: (input) => input.question,
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
const agent = new Agent({ provider, model, tools: [researcher] });
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
The child's turn events are forwarded through the parent's event stream
|
|
474
|
+
(rendered as an `agent` action part with nested child turns), and its token
|
|
475
|
+
usage is reported into the parent's totals with per-model attribution (see
|
|
476
|
+
[Usage stats](#usage-stats)). Create a fresh child Agent per call — `createAgent`
|
|
477
|
+
runs once per tool invocation.
|
|
478
|
+
|
|
479
|
+
### Parallelizing Tools
|
|
480
|
+
|
|
481
|
+
> **Experimental** — the generated tool's result parts (`ParallelToolResult`)
|
|
482
|
+
> may change in a minor release.
|
|
483
|
+
|
|
484
|
+
`parallelize` wraps a tool in a batch variant that runs many inputs
|
|
485
|
+
concurrently in a single tool call. Combined with `createAgentTool`, this fans
|
|
486
|
+
out subagents.
|
|
487
|
+
|
|
488
|
+
```typescript
|
|
489
|
+
import { parallelize } from "@fifthrevision/axle";
|
|
490
|
+
|
|
491
|
+
const batchResearch = parallelize(researcher, { maxConcurrency: 4 });
|
|
492
|
+
// → tool "research_batch" accepting { items: [{ question }, ...] }
|
|
493
|
+
|
|
494
|
+
const agent = new Agent({ provider, model, tools: [batchResearch] });
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
The generated tool preserves input order and reports per-item failures instead
|
|
498
|
+
of failing the whole batch; fatal (`AxleToolFatalError`) and abort errors still
|
|
499
|
+
terminate the run like an unbatched tool. It returns ordered tool-result parts:
|
|
500
|
+
each item starts with a text marker containing `index` and `ok`/`error`,
|
|
501
|
+
followed by the child's text or file parts. Options: `name`, `description`,
|
|
502
|
+
`maxItems` (default 50), `maxConcurrency` (default 8), and `maxResultBytes`
|
|
503
|
+
(default 20 MiB). Over-budget child output is omitted per item with a marker
|
|
504
|
+
that includes the item index, input, output size, remaining budget, and total
|
|
505
|
+
limit; later items still render if they fit. The batch tool inherits the wrapped
|
|
506
|
+
tool's `kind`, so batched subagents still stream their child turns under the
|
|
507
|
+
batch action (interleaved across items).
|
|
508
|
+
|
|
509
|
+
### Usage Stats
|
|
510
|
+
|
|
511
|
+
> **Experimental** — the aggregate fields are stable; the `breakdown` entry
|
|
512
|
+
> shape (`UsageEntry`) may gain dimensions (e.g. a per-agent name) in a minor
|
|
513
|
+
> release.
|
|
514
|
+
|
|
515
|
+
Every result exposes `usage` totals (`in`, `out`, plus cache/reasoning detail
|
|
516
|
+
when reported). When an operation spans models — for example subagent tools on
|
|
517
|
+
different providers — `usage.breakdown` holds one entry per provider+model pair
|
|
518
|
+
so cost can be reconstructed:
|
|
519
|
+
|
|
520
|
+
```typescript
|
|
521
|
+
const result = await agent.send("...").final;
|
|
522
|
+
// result.usage.breakdown:
|
|
523
|
+
// [
|
|
524
|
+
// { provider: "anthropic", model: "claude-sonnet-4-6", in: 1200, out: 340 },
|
|
525
|
+
// { provider: "openai", model: "gpt-5", in: 800, out: 120 },
|
|
526
|
+
// ]
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
Breakdown entries explain the aggregate totals; they are attribution metadata,
|
|
530
|
+
not additional usage.
|
|
531
|
+
|
|
532
|
+
### Provider Tools
|
|
533
|
+
|
|
534
|
+
Provider tools are tools that execute on the LLM provider's side (e.g. web
|
|
535
|
+
search, code interpreter). Pass them via the `providerTools` option using
|
|
536
|
+
`{ type: "provider", name: "..." }`.
|
|
537
|
+
|
|
538
|
+
```typescript
|
|
539
|
+
import { Agent } from "@fifthrevision/axle";
|
|
540
|
+
import type { ProviderTool } from "@fifthrevision/axle";
|
|
541
|
+
|
|
542
|
+
const agent = new Agent({
|
|
543
|
+
provider,
|
|
544
|
+
model,
|
|
545
|
+
providerTools: [{ type: "provider", name: "web_search" }],
|
|
546
|
+
});
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
Axle maps common names to provider-specific identifiers automatically:
|
|
550
|
+
|
|
551
|
+
| Name | Anthropic | OpenAI | Gemini |
|
|
552
|
+
| ---------------- | --------------------- | -------------------- | --------------- |
|
|
553
|
+
| `web_search` | `web_search_20250305` | `web_search_preview` | `googleSearch` |
|
|
554
|
+
| `code_execution` | — | `code_interpreter` | `codeExecution` |
|
|
555
|
+
|
|
556
|
+
You can also pass provider-specific names directly. Use the optional `config`
|
|
557
|
+
field for provider-specific options:
|
|
558
|
+
|
|
559
|
+
```typescript
|
|
560
|
+
{ type: "provider", name: "web_search", config: { max_results: 5 } }
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
Provider tool events stream as `provider-tool:start` and `provider-tool:complete`.
|
|
564
|
+
|
|
565
|
+
### Web Search Fallback
|
|
566
|
+
|
|
567
|
+
`web_search` is native-first. OpenAI, Anthropic, Gemini, and OpenRouter use their
|
|
568
|
+
provider-managed search implementation. Providers without native search use the
|
|
569
|
+
process-wide fallback configured at application startup:
|
|
570
|
+
|
|
571
|
+
```typescript
|
|
572
|
+
import { braveWebSearch, configureAxle } from "@fifthrevision/axle";
|
|
573
|
+
|
|
574
|
+
configureAxle({
|
|
575
|
+
webSearchFallback: braveWebSearch({
|
|
576
|
+
apiKey: process.env.BRAVE_API_KEY!,
|
|
577
|
+
maxResults: 5,
|
|
578
|
+
maxTokens: 4_096,
|
|
579
|
+
}),
|
|
580
|
+
});
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
The bundled backend uses Brave Search's LLM Context endpoint. Each result
|
|
584
|
+
contains a title, URL, and query-relevant extracted passages:
|
|
585
|
+
|
|
586
|
+
```typescript
|
|
587
|
+
interface WebSearchResult {
|
|
588
|
+
title: string;
|
|
589
|
+
url: string;
|
|
590
|
+
snippets: string[];
|
|
591
|
+
}
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
Axle recognizes the official OpenRouter and Together endpoint hostnames and
|
|
595
|
+
applies their request differences automatically:
|
|
596
|
+
|
|
597
|
+
```typescript
|
|
598
|
+
const together = chatCompletions("https://api.together.ai/v1", {
|
|
599
|
+
apiKey: process.env.TOGETHER_API_KEY!,
|
|
600
|
+
});
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
Set `vendor: "openrouter"` or `vendor: "together"` explicitly when using a
|
|
604
|
+
proxy or gateway with a different hostname.
|
|
605
|
+
|
|
606
|
+
Application code continues to request the provider-neutral capability:
|
|
607
|
+
|
|
608
|
+
```typescript
|
|
609
|
+
const agent = new Agent({
|
|
610
|
+
provider,
|
|
611
|
+
model,
|
|
612
|
+
providerTools: [{ type: "provider", name: "web_search" }],
|
|
613
|
+
});
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
Axle snapshots global configuration when `generate()`, `stream()`, or
|
|
617
|
+
`Agent.send()` starts. If the selected provider has no native search and no
|
|
618
|
+
fallback is configured, the operation fails before sending a model request.
|
|
619
|
+
Provider-specific `web_search.config` is ignored when the fallback is
|
|
620
|
+
selected; configure fallback behavior on `braveWebSearch()` instead.
|
|
621
|
+
|
|
622
|
+
The fallback is exposed to the model as an ordinary executable tool, so it
|
|
623
|
+
produces `tool:*` events rather than `provider-tool:*` events. Applications that
|
|
624
|
+
want completely custom search behavior can register their own executable
|
|
625
|
+
`web_search` tool instead of requesting the provider tool.
|
|
626
|
+
|
|
627
|
+
### MCP (Model Context Protocol)
|
|
628
|
+
|
|
629
|
+
Axle supports connecting to MCP servers via stdio or HTTP transport. Create an
|
|
630
|
+
MCP instance, connect it, and pass it to Agent.
|
|
631
|
+
|
|
632
|
+
```typescript
|
|
633
|
+
import { Agent, MCP } from "@fifthrevision/axle";
|
|
634
|
+
|
|
635
|
+
const mcp = new MCP({
|
|
636
|
+
transport: "stdio",
|
|
637
|
+
name: "wc",
|
|
638
|
+
command: "npx",
|
|
639
|
+
args: ["tsx", "path/to/wordcount-server.ts"],
|
|
640
|
+
});
|
|
641
|
+
await mcp.connect();
|
|
642
|
+
|
|
643
|
+
const agent = new Agent({ provider, model, mcps: [mcp] });
|
|
644
|
+
const result = await agent.send("Count the words in 'hello world'").final;
|
|
645
|
+
if (!result.ok) throw new Error(result.error.kind);
|
|
646
|
+
|
|
647
|
+
await mcp.close();
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
The optional `name` field prefixes all tool names from that server (e.g.
|
|
651
|
+
`wc_word_count`) to avoid collisions when using multiple MCPs. When omitted,
|
|
652
|
+
the server's self-reported name is used as the prefix if available.
|
|
653
|
+
|
|
654
|
+
HTTP transport works the same way:
|
|
655
|
+
|
|
656
|
+
```typescript
|
|
657
|
+
const mcp = new MCP({
|
|
658
|
+
transport: "http",
|
|
659
|
+
url: "http://localhost:3100/mcp",
|
|
660
|
+
});
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
### Streaming
|
|
664
|
+
|
|
665
|
+
Axle has two event models, used at different levels:
|
|
666
|
+
|
|
667
|
+
- `Agent.on(...)` emits `TurnEvent` — a high-level turn view organized
|
|
668
|
+
around parts (text, thinking, action).
|
|
669
|
+
- `stream(...).on(...)` emits `StreamEvent` — a lower-level view that
|
|
670
|
+
surfaces every text/thinking/tool transition the provider produces.
|
|
671
|
+
|
|
672
|
+
`Agent` uses `stream()` internally and translates each `StreamEvent` into
|
|
673
|
+
one or more `TurnEvent`s.
|
|
674
|
+
|
|
675
|
+
#### Turn events
|
|
676
|
+
|
|
677
|
+
```typescript
|
|
678
|
+
const agent = new Agent({ provider, model });
|
|
679
|
+
|
|
680
|
+
agent.on((event) => {
|
|
681
|
+
switch (event.type) {
|
|
682
|
+
case "text:delta":
|
|
683
|
+
process.stdout.write(event.delta);
|
|
684
|
+
break;
|
|
685
|
+
case "part:start":
|
|
686
|
+
if (event.part.type === "action") {
|
|
687
|
+
console.log(`Tool: ${event.part.detail.name}`);
|
|
688
|
+
}
|
|
689
|
+
break;
|
|
690
|
+
case "action:complete":
|
|
691
|
+
console.log("Tool complete");
|
|
692
|
+
break;
|
|
693
|
+
case "turn:end":
|
|
694
|
+
console.log(`Turn ${event.status} (in: ${event.usage.in})`);
|
|
695
|
+
break;
|
|
696
|
+
case "error":
|
|
697
|
+
console.error(event.error);
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
const handle = agent.send("Write me a poem.");
|
|
703
|
+
// handle.cancel(reason) aborts mid-stream and rejects handle.final with an AbortError
|
|
704
|
+
try {
|
|
705
|
+
const result = await handle.final;
|
|
706
|
+
if (!result.ok) {
|
|
707
|
+
console.error(result.error);
|
|
708
|
+
}
|
|
709
|
+
} catch (err) {
|
|
710
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
711
|
+
// Cancellation preserves partial state on AxleAbortError: reason, turn, partial, usage
|
|
712
|
+
console.log("Cancelled");
|
|
713
|
+
} else {
|
|
714
|
+
throw err;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
`TurnEvent` types: `turn:user`, `turn:start`, `turn:end`, `part:start`,
|
|
720
|
+
`part:end`, `text:delta`, `text:citation`, `thinking:delta`,
|
|
721
|
+
`thinking:summary-delta`, `thinking:update`, `action:args-delta`,
|
|
722
|
+
`action:running`, `action:progress`, `action:complete`, `action:error`,
|
|
723
|
+
`action:child-event`, `compaction:update`, `compaction:complete`,
|
|
724
|
+
`compaction:error`, `annotation:start`, `annotation:update`,
|
|
725
|
+
`annotation:end`, `error`.
|
|
726
|
+
|
|
727
|
+
The `compaction:*` events mirror the action lifecycle: a compaction part
|
|
728
|
+
arrives `running` via `part:start`, `compaction:update` replaces transient
|
|
729
|
+
`summary` and `progress` fields, and exactly one of `compaction:complete` /
|
|
730
|
+
`compaction:error` settles it. Completion sets `progress` to `1` and its
|
|
731
|
+
returned summary replaces any transient summary.
|
|
732
|
+
|
|
733
|
+
`part:start` carries a `TurnPart`, discriminated by `part.type` (`"text"`,
|
|
734
|
+
`"thinking"`, `"file"`, `"citation"`, `"action"`, `"compaction"`). Action parts
|
|
735
|
+
further discriminate on `part.kind` (`"tool" | "agent" | "provider-tool"`).
|
|
736
|
+
|
|
737
|
+
Callbacks are registered once and fire on every subsequent `send()`, and also
|
|
738
|
+
receive the events of a manual `agent.compact()` (an engine-opened turn
|
|
739
|
+
wrapping the compaction part).
|
|
740
|
+
|
|
741
|
+
#### Transcript
|
|
742
|
+
|
|
743
|
+
`Turn` objects are accumulated render state. They are the snapshot counterpart
|
|
744
|
+
to `TurnEvent` streams: text deltas are folded into text parts, tool call
|
|
745
|
+
lifecycles become stable action parts, and tool results are collapsed back into
|
|
746
|
+
the action part that produced them. `AxleMessage[]` remains the canonical model
|
|
747
|
+
conversation state; turns do not affect model input or tool routing.
|
|
748
|
+
Model and provider failures are retained on the agent turn as `turn.error`, so
|
|
749
|
+
accumulated and restored render state includes the terminal error message.
|
|
750
|
+
|
|
751
|
+
The Agent holds no turns: it emits events, and whoever wants a transcript
|
|
752
|
+
folds and stores them. Attach a `Transcript`, persist its `turns` alongside
|
|
753
|
+
`agent.snapshot()`, and pass the saved turns to the constructor on restore.
|
|
754
|
+
Compaction (see below) appears in the fold as an ordinary `compaction` part;
|
|
755
|
+
renderers that don't handle that part type simply render nothing for it.
|
|
756
|
+
|
|
757
|
+
Hosts that transport Axle events over SSE, WebSockets, or another mixed event
|
|
758
|
+
stream can use `Transcript` instead of reimplementing this reducer:
|
|
759
|
+
|
|
760
|
+
```typescript
|
|
761
|
+
import { Transcript, type Annotation } from "@fifthrevision/axle/ui";
|
|
762
|
+
|
|
763
|
+
type AppAnnotation =
|
|
764
|
+
Annotation<{ image: string }, "sandbox"> | Annotation<{ score: number; passed: boolean }, "eval">;
|
|
765
|
+
|
|
766
|
+
type HostEvent = { type: "run:terminal"; status: string };
|
|
767
|
+
|
|
768
|
+
const transcript = new Transcript<AppAnnotation, HostEvent>();
|
|
769
|
+
|
|
770
|
+
for await (const event of events) {
|
|
771
|
+
const result = transcript.apply(event);
|
|
772
|
+
|
|
773
|
+
if (result.handled === false) {
|
|
774
|
+
// result.event is typed as HostEvent here
|
|
775
|
+
applyHostEvent(result.event);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
render(transcript.turns);
|
|
779
|
+
}
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
Use `@fifthrevision/axle/ui` for browser-safe presentation primitives. It
|
|
783
|
+
exports turns, annotations, turn events, and `Transcript` without importing
|
|
784
|
+
providers, MCP, tools, or other server-side runtime code.
|
|
785
|
+
|
|
786
|
+
`transcript.turns` is a readonly array and serves as both the read and
|
|
787
|
+
persistence surface. The constructor accepts a readonly array and makes a
|
|
788
|
+
shallow copy, so later changes to the supplied array do not alter the
|
|
789
|
+
transcript. The transcript accepts open event objects. Unknown host events, such as
|
|
790
|
+
`run:terminal` or `session:expired`, return `handled: false` and leave the
|
|
791
|
+
state unchanged. Annotations are embedded on their turn or part targets. The
|
|
792
|
+
transcript is not idempotent; callers should deduplicate replayed transport
|
|
793
|
+
events before applying them.
|
|
794
|
+
|
|
795
|
+
#### Turn metadata
|
|
796
|
+
|
|
797
|
+
User messages can carry stable host-owned metadata for rendering. Metadata is
|
|
798
|
+
stored in history, copied onto the corresponding user `Turn`, and ignored by
|
|
799
|
+
providers.
|
|
800
|
+
|
|
801
|
+
```typescript
|
|
802
|
+
await agent.send("Rewrite this prompt", {
|
|
803
|
+
metadata: { surface: "prompt-editor" },
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
const instruct = new Instruct({
|
|
807
|
+
prompt: "Review this prompt",
|
|
808
|
+
metadata: { surface: "prompt-review" },
|
|
809
|
+
});
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
Use metadata for stable facts about the message, such as which UI surface
|
|
813
|
+
created it. Use annotations for lifecycle UI, async status, or render data that
|
|
814
|
+
needs explicit placement before or after a turn or part.
|
|
815
|
+
|
|
816
|
+
#### Annotations
|
|
817
|
+
|
|
818
|
+
Annotations are embedded render metadata for sessions, turns, and parts. They
|
|
819
|
+
are useful for out-of-band UI such as sandbox startup, eval results, deployment
|
|
820
|
+
state, or any other consumer-owned status that should render alongside turns
|
|
821
|
+
without becoming model state.
|
|
822
|
+
|
|
823
|
+
```typescript
|
|
824
|
+
type EvalAnnotation = Annotation<{ score: number; passed: boolean }, "eval">;
|
|
825
|
+
|
|
826
|
+
const annotation: EvalAnnotation = {
|
|
827
|
+
id: crypto.randomUUID(),
|
|
828
|
+
kind: "eval",
|
|
829
|
+
label: "Plan adherence",
|
|
830
|
+
placement: "after",
|
|
831
|
+
status: "complete",
|
|
832
|
+
data: { score: 0.92, passed: true },
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
agentEventSink({
|
|
836
|
+
type: "annotation:end",
|
|
837
|
+
target: { type: "turn", turnId },
|
|
838
|
+
annotation,
|
|
839
|
+
});
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
Annotation `label` is required so generic renderers have a common UI surface.
|
|
843
|
+
`placement` defaults to `"after"`, and `annotation:end` defaults missing
|
|
844
|
+
`status` to `"complete"` in accumulated state. `annotation:update` and
|
|
845
|
+
`annotation:end` carry the full updated annotation object; Axle does not define
|
|
846
|
+
patch or merge semantics for annotation data.
|
|
847
|
+
|
|
848
|
+
#### stream() events
|
|
849
|
+
|
|
850
|
+
The low-level `stream()` primitive emits a different event shape — closer
|
|
851
|
+
to the raw provider stream, with separate `start`/`end` events for each
|
|
852
|
+
text and thinking block, and distinct events for tool request, execution,
|
|
853
|
+
and completion.
|
|
854
|
+
|
|
855
|
+
`StreamEvent` types: `step:start`, `step:complete`, `tool-results:start`,
|
|
856
|
+
`tool-results:complete`, `text:start`, `text:delta`, `text:citation`,
|
|
857
|
+
`text:end`, `citation`, `thinking:start`, `thinking:delta`,
|
|
858
|
+
`thinking:summary-delta`, `thinking:update`, `thinking:end`, `tool:request`,
|
|
859
|
+
`tool:args-delta`, `tool:exec-start`, `tool:exec-delta`, `tool:exec-complete`,
|
|
860
|
+
`tool:exec-error`, `provider-tool:start`, `provider-tool:complete`, `error`.
|
|
861
|
+
|
|
862
|
+
Tool and provider-tool events correlate by `id`. Text and thinking parts
|
|
863
|
+
stream sequentially within a step, so their deltas belong to the most
|
|
864
|
+
recently opened part.
|
|
865
|
+
|
|
866
|
+
The `step:complete` and `tool-results:complete` events carry complete
|
|
867
|
+
`AxleAssistantMessage` and `AxleToolCallMessage` objects for client-server
|
|
868
|
+
architectures that need authoritative message boundaries.
|
|
869
|
+
|
|
870
|
+
`StreamHandle.onToolBatchComplete(callback)` installs one awaited callback
|
|
871
|
+
after a complete tool batch has executed and its tool-result message has been
|
|
872
|
+
committed:
|
|
873
|
+
|
|
874
|
+
```typescript
|
|
875
|
+
const handle = stream({ provider, model, messages, tools });
|
|
876
|
+
|
|
877
|
+
handle.onToolBatchComplete(async (toolResultsMessage) => {
|
|
878
|
+
await persist(toolResultsMessage);
|
|
879
|
+
return shouldHandoff() ? "finish" : "continue";
|
|
880
|
+
});
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
Return `"finish"` to resolve successfully without starting another provider
|
|
884
|
+
request, or `"continue"` to resume the tool loop. The callback receives one
|
|
885
|
+
`AxleToolCallMessage` containing the whole batch; it is an awaited control
|
|
886
|
+
boundary, not a synthetic stream event. Agent uses this hook internally for
|
|
887
|
+
`stop()`.
|
|
888
|
+
|
|
889
|
+
### Compaction (experimental)
|
|
890
|
+
|
|
891
|
+
Compaction replaces the agent's active conversation with a shorter one — for
|
|
892
|
+
example a summary — so long sessions can continue past the model's context
|
|
893
|
+
limit. The API is experimental and may change in any release.
|
|
894
|
+
|
|
895
|
+
Axle ships a prompt-based implementation for the common case:
|
|
896
|
+
|
|
897
|
+
```typescript
|
|
898
|
+
import { PromptCompactor } from "@fifthrevision/axle";
|
|
899
|
+
|
|
900
|
+
const compactor = new PromptCompactor({
|
|
901
|
+
provider,
|
|
902
|
+
model,
|
|
903
|
+
prompt:
|
|
904
|
+
"Create a continuation summary. Preserve decisions, constraints, completed work, and open tasks.",
|
|
905
|
+
thresholdTokens: 100_000,
|
|
906
|
+
summaryWords: 1_000,
|
|
907
|
+
appendixTokens: 10_000,
|
|
908
|
+
providerOptions: {
|
|
909
|
+
reasoning: { effort: "medium" },
|
|
910
|
+
},
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
agent.setCompaction({
|
|
914
|
+
shouldCompactOnTrigger: compactor.shouldCompactOnTrigger,
|
|
915
|
+
compact: compactor.compact,
|
|
916
|
+
triggers: {
|
|
917
|
+
beforeTurn: true,
|
|
918
|
+
},
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
const applied = await agent.compact(); // true when applied; false when no compactor is configured
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
Compaction is split into three layers, each with one job. `triggers` say
|
|
925
|
+
_when to ask_: omitting them makes compaction manual-only; `beforeTurn` asks
|
|
926
|
+
at the start of the next `send()`'s turn, `afterTurn` after the model work of
|
|
927
|
+
a successful turn, before it settles. `shouldCompactOnTrigger` says whether
|
|
928
|
+
to accept an automatic request; a synchronous `false` is the only silent
|
|
929
|
+
automatic path: nothing is emitted and no id is allocated. A thrown policy
|
|
930
|
+
error propagates as a client implementation error. Omitting the policy means
|
|
931
|
+
every configured automatic trigger runs. Explicit `agent.compact()` bypasses
|
|
932
|
+
the policy and always invokes the configured compactor. `compact` does _the
|
|
933
|
+
work_ and always returns `{ messages, summary? }` — the complete new
|
|
934
|
+
conversation, plus an optional reader-facing summary for the transcript —
|
|
935
|
+
there is no decline return; failures throw. The `summary` is a presentation
|
|
936
|
+
choice, independent of the model-facing messages: it can be the summary text
|
|
937
|
+
itself, or something else entirely ("Reduced the context by 50%"); omitted,
|
|
938
|
+
the latest emitted summary remains, or the compaction part renders as a bare
|
|
939
|
+
divider if none was emitted.
|
|
940
|
+
|
|
941
|
+
Once an automatic policy accepts—or `agent.compact()` is called—the
|
|
942
|
+
compaction is ordinary fallible turn work, streamed like a tool call: a
|
|
943
|
+
`running` compaction part lands in the natural turn — head of the send's turn
|
|
944
|
+
for `beforeTurn`, tail for `afterTurn`, its own engine-opened turn for
|
|
945
|
+
`manual` — `ctx.emit({ progress, summary? })`
|
|
946
|
+
replaces transient reader-facing state on it (liveness for long
|
|
947
|
+
summarizations, and real traffic for idle-timeout-prone transports), and it
|
|
948
|
+
settles `complete` or `error`.
|
|
949
|
+
**Failures are non-fatal for automatic triggers**: the errored part is the
|
|
950
|
+
record, and the send continues on the uncompacted conversation — if that
|
|
951
|
+
genuinely overflows the context, the provider failure surfaces as the turn's
|
|
952
|
+
model error. A failed `manual` compact rejects, since it was explicitly
|
|
953
|
+
requested. `agent.compact({ signal })` follows the same cancellation contract
|
|
954
|
+
as every other operation: aborting rejects with an error whose `name` is
|
|
955
|
+
`"AbortError"`.
|
|
956
|
+
|
|
957
|
+
`PromptCompactor` returns a model-written summary and an appendix of up to
|
|
958
|
+
the latest 10 user messages in oldest-to-newest order, evicted oldest-first
|
|
959
|
+
to fit `appendixTokens` (default: a tenth of `thresholdTokens`; 0 keeps no
|
|
960
|
+
appendix). `summaryWords`
|
|
961
|
+
(default 1000) is a soft bound enforced by escalation, not by the request's
|
|
962
|
+
output cap: the prompt steers the size in words, the result is measured in
|
|
963
|
+
words, one relative-shrink rewrite runs if it lands over ~1.3× the request,
|
|
964
|
+
and word-boundary truncation is the last resort. The request sends no
|
|
965
|
+
output cap, so reasoning models think within the provider's own ceiling.
|
|
966
|
+
While
|
|
967
|
+
generating, the compactor reports estimated progress without exposing the
|
|
968
|
+
model's token stream, emits 100% immediately before completion, and leaves
|
|
969
|
+
the compaction part's optional reader-facing `summary` unset. Both messages
|
|
970
|
+
are stamped via metadata
|
|
971
|
+
(`axleCompaction: { id, role: "summary" | "appendix" }`, see
|
|
972
|
+
`CompactionStamp`). The stamp is a compactor-side convention — it is how the
|
|
973
|
+
compactor recognizes its own prior output, so carried-over messages are
|
|
974
|
+
excluded from the appendix and repeated compactions never re-collect an
|
|
975
|
+
earlier summary as a "recent" user message. The engine does not read stamps;
|
|
976
|
+
custom `CompactionCallback`s that don't stamp are valid.
|
|
977
|
+
|
|
978
|
+
The compactor accepts `reasoning` and `providerOptions` with the same semantics
|
|
979
|
+
as `Agent`, `generate()`, and `stream()` (see [Reasoning](#reasoning)); leaving
|
|
980
|
+
`reasoning` unset sends no thinking parameters, so the model runs at its
|
|
981
|
+
provider default. Use `providerOptions` for exact native controls such as a
|
|
982
|
+
thinking-token budget. The example above uses OpenAI's native reasoning shape;
|
|
983
|
+
other providers receive their own native options unchanged.
|
|
984
|
+
|
|
985
|
+
Like tool callbacks, the compaction callbacks run while the agent's scheduler
|
|
986
|
+
is held: scheduling more work on the same agent from inside them queues behind
|
|
987
|
+
the current operation, so awaiting that work from inside a callback
|
|
988
|
+
deadlocks. Fire-and-forget scheduling is safe — the work runs after the
|
|
989
|
+
current operation settles.
|
|
990
|
+
|
|
991
|
+
Compaction is destructive at the message layer: the returned messages become
|
|
992
|
+
the entire active conversation, and the old messages cease to exist the
|
|
993
|
+
moment the part settles `complete` (settle ⇔ applied, atomically). Hosts
|
|
994
|
+
wanting the pre-compaction messages (undo, audit) copy them in their own
|
|
995
|
+
wrapper before returning. Compaction runs on the agent's work queue, so it
|
|
996
|
+
never interleaves with in-flight Agent work. `agent.context()` returns the
|
|
997
|
+
current `ContextUsage` estimate if you want to decide outside the callbacks.
|
|
998
|
+
|
|
999
|
+
The normative design — invariants, rationale, and rejected alternatives —
|
|
1000
|
+
lives in [docs/architecture/compaction.md](../../docs/architecture/compaction.md)
|
|
1001
|
+
and [docs/architecture/agent-state.md](../../docs/architecture/agent-state.md).
|
|
1002
|
+
See [Migrating to Axle 0.30.0](../../docs/0.30.0-migration.md) for the turn
|
|
1003
|
+
ownership and compaction protocol changes.
|
|
1004
|
+
|
|
1005
|
+
### Hosting / Sessions
|
|
1006
|
+
|
|
1007
|
+
Axle stops at the agent runtime boundary. If you need long-lived sessions,
|
|
1008
|
+
SSE transport, resumable cursors, or React client hooks, build those concerns
|
|
1009
|
+
in your host application on top of `Agent`, `agent.on(...)`, and the streamed
|
|
1010
|
+
turn events that Axle emits.
|
|
1011
|
+
|
|
1012
|
+
`agent.messages` exposes the active, model-facing conversation as a copy —
|
|
1013
|
+
requests are built from it, and compaction replaces it. Read it for inspection
|
|
1014
|
+
or to drive your own persistence; mutating the returned array has no effect.
|
|
1015
|
+
|
|
1016
|
+
To persist and resume an agent, snapshot it and construct a new agent with the
|
|
1017
|
+
session. The snapshot is the pure continuation (`{ sessionId, messages }`) —
|
|
1018
|
+
persist your transcript's turns next to it if you want the transcript back:
|
|
1019
|
+
|
|
1020
|
+
```typescript
|
|
1021
|
+
const session = await agent.snapshot(); // waits for in-flight work to settle
|
|
1022
|
+
const turns = transcript.turns;
|
|
1023
|
+
// ...store both, then later:
|
|
1024
|
+
const resumed = new Agent(config, session);
|
|
1025
|
+
const resumedTranscript = new Transcript(turns);
|
|
1026
|
+
resumed.on((event) => resumedTranscript.apply(event));
|
|
1027
|
+
```
|
|
1028
|
+
|
|
1029
|
+
## Known Limitations
|
|
1030
|
+
|
|
1031
|
+
1. Axle does not support multi-modal output right now.
|