@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 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.