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