@orkestrel/scaffold 0.0.68 → 0.0.70

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.
@@ -111,7 +111,7 @@ These invariants hold across `src/core` ↔ `ollama.md`. The engine contract its
111
111
 
112
112
  1. **Doc ↔ source bijection.** Every `function` / `class` / `const` / `interface` / `type` row in the `## Surface` table is a real export of the `src/core` surface, and every export appears as a Surface row — exhaustive, both directions.
113
113
  2. **Imports each boundary from its owner, no cycle.** `OllamaProvider` extends `AgentProvider` and implements `AgentProviderInterface`; that base, `ProviderRequest`, `ProviderIncrement`, `ProviderParserInterface`, `ProviderInterface`, `ProviderOptions`, and `Message` come from `@orkestrel/agent`. `createNDJSONParser` comes from `@orkestrel/ndjson`, `ToolCall` from `@orkestrel/tool`, `TokenUsage` from `@orkestrel/budget`, and the guards (`isRecord` / `isString` / `isNumber` / `parseJSONAs`) from `@orkestrel/contract`. The provider errors are owned by `@orkestrel/agent` and `ToolDefinition` by `@orkestrel/tool`: `src/core` imports neither, and both reach a caller through the base — the base throws the errors this wire's failures become, and it accepts the `ToolDefinition[]` a call advertises. Each dependency is one-way and never imports from `@src/core`. No module under `src/` imports `@orkestrel/timeout` — the base arms the deadline — and the manifest declares it as a development dependency for the guide's bounding pattern, which imports it as a consumer would.
114
- 3. **The `/api/chat` wire body.** `OllamaProvider` POSTs to `${url}${OLLAMA_CHAT_PATH}`, defaulting `url` to `DEFAULT_OLLAMA_URL` and `keep_alive` to `DEFAULT_KEEP_ALIVE`. The body carries `model`, the mapped `messages`, `stream: true` on every call, `keep_alive`, and `think` (the per-call `ProviderStreamOptions.think` override when present, else `OllamaOptions.think`, default `false`), adding `options` only when configured, `format` only when the call supplies a `ProviderStreamOptions.schema`, and `tools` only when a non-empty `ToolDefinition[]` is passed. Each tool maps to `{ type: 'function', function: { name } }`, with `description` and `parameters` added only when the corresponding `ToolDefinition` fields are supplied. Messages map to the wire's minimal `{ role, content }` turn, with `tool_calls` added only on a turn that replays them and `images` only on a multimodal turn. A non-OK status is the base's failure, not this package's: it throws `ProviderError` with code `'HTTP'`, the response `status`, and a message bounded to an excerpt of the response body.
114
+ 3. **The `/api/chat` wire body.** `OllamaProvider` POSTs to `${url}${OLLAMA_CHAT_PATH}`, defaulting `url` to `DEFAULT_OLLAMA_URL` and `keep_alive` to `DEFAULT_KEEP_ALIVE`. The body carries `model`, the mapped `messages`, `stream: true` on every call, `keep_alive`, and `think` (the per-call `ProviderStreamOptions.think` override when present, else `OllamaOptions.think`, default `false`), adding `options` only when configured, `format` only when the call supplies a `ProviderStreamOptions.schema`, and `tools` only when a non-empty `ToolDefinition[]` is passed. Each tool maps to `{ type: 'function', function: { name } }`, with `description` and `parameters` added only when the corresponding `ToolDefinition` fields are supplied. `title` and `annotations` are **never sent**: the `/api/chat` tool function object carries no field for either. Messages map to the wire's minimal `{ role, content }` turn, with `tool_calls` added only on a turn that replays them and `images` only on a multimodal turn. A non-OK status is the base's failure, not this package's: it throws `ProviderError` with code `'HTTP'`, the response `status`, and a message bounded to an excerpt of the response body.
115
115
  4. **Configurable `think` on the wire; the base's splitter keeps the assembled content clean.** For a thinking-capable model (for example `qwen3`) the per-request `think` flag is the only wire-level reasoning control (its native renderer honours neither the qwen3 `/no_think` token nor a Modelfile `PARAMETER think false`). It is configurable through `OllamaOptions.think` (default `false` — so the general-purpose provider stays immediate for non-thinking models and tests fast) and overrideable per call through `ProviderStreamOptions.think`. Set `think: true` when the caller displays reasoning separately from the answer: the daemon then separates reasoning natively, returning it on the distinct `message.thinking` channel, which `read` reports and the engine yields as `ProviderDelta` `{ channel: 'thinking', text }` and accumulates onto `ProviderResult.thinking`. Either way the base's `split` behaviour stays armed, because a daemon may ignore `think: false` for a thinking model and render reasoning inline as `<think>…</think>` content; `OllamaProvider` passes no `split` to `super`, so the default separation applies and the assembled `content` is the splitter's clean accumulation. The reasoning never re-enters the conversation.
116
116
  5. **One NDJSON path.** Every call consumes NDJSON — one JSON object per `\n`-terminated line. `frame()` returns `createNDJSONParser()`, fresh per call, so no call inherits another's half-read record, and the base pairs it with a streaming `TextDecoder` so a record split across byte reads is reassembled. At end of input the base calls `finish(parser)`, which feeds a trailing `\n` through the parser: a non-conformant proxy's final unterminated line is recovered rather than silently dropped. `OllamaProvider` passes no `strict` to `super`, so a stream that ends without a settled record still assembles its result from the deltas it carried.
117
117
  6. **`stream` yields deltas and returns the assembled result.** The engine yields each non-empty clean content delta as `{ channel: 'content', text }` and each daemon-side reasoning delta as `{ channel: 'thinking', text }`; its return value is the assembled `ProviderResult` whose `content` is the splitter's authoritative clean accumulation — the concatenation of the yielded content deltas, except across an implicit-open reclassification, where the reasoning prefix had already streamed before a bare `</think>` revealed it and the yields cannot be recalled — plus any tool calls collected across lines, the usage from the `done` line, and the separated `thinking` when the turn produced any.
@@ -121,7 +121,7 @@ These invariants hold across `src/core` ↔ `ollama.md`. The engine contract its
121
121
  10. **The base owns everything that is not the wire.** The per-call deadline (`OllamaOptions.timeout`, `120_000`ms when omitted, folded with the caller's signal so either cancels the request and the timer is always cleared), the cancellation rule (a `stream` cancelled mid-flight throws `ProviderAbortError` carrying the partial, and the reader and parser are released in a `finally`), the transport seam (`OllamaOptions.fetch` and the per-request `OllamaOptions.headers` hook, awaited inside the deadline and raced against the combined signal), and the bounded error read all live in `AgentProvider`. `OllamaOptions` extends `ProviderOptions`, so those keys are the base's and behave identically for every provider built on it; [`agent.md`](agent.md) states each rule once.
122
122
  11. **Context-framing `format` (provider-default cascade level, expose-only).** `OllamaOptions.format` is an optional `ContextFormat` (from `@orkestrel/agent`) — the provider's context-framing default, passed to `super` and exposed as `provider.format`. It is the provider-default level of the `AgentContext` build cascade (beats the managers' built-in framing, beaten by a manager-options or per-item override; see [`agent.md`](agent.md)), read by the Agent when it assembles the prompt. Omitted ⇒ `undefined` (framing-agnostic; core's built-in framing applies unchanged). It is **never sent on the `/api/chat` wire**: it is consumed by core, absent from the request `body`, and unrelated to Ollama's structured-output `format` parameter — that one is sent in the request `body`, but only when a per-call `ProviderStreamOptions.schema` is supplied — the framing default and that wire parameter only share a word.
123
123
  12. **Event-free.** A pure functional boundary — no Emitter, no events. Each call is a function of its arguments.
124
- 13. **A core face that runs in a browser.** The package publishes the `.` entry alone, built from `src/core`, which imports no `node:*` module and no DOM global; the scoped core typecheck compiles it with the `ESNext` and `WebWorker` libraries and no ambient `@types`, so neither Node's globals nor the DOM's are in scope, and the base binds `globalThis.fetch` to its own receiver so a browser does not throw `Illegal invocation` on a bare transport reference. On 2026-09-14 in Chrome 148 the built `@orkestrel/ollama` core entry and its whole `@orkestrel` import closure loaded as ES modules with no console error: `OllamaProvider` drove the local daemon directly for a settled answer carrying its usage counts, a cancel mid-stream returned `ProviderAbortError` holding the partial that had streamed, and the same page reached that daemon through a `createRelay` server, where a wrong bearer arrived as `ProviderError` with code `'HTTP'` and status `401`. The daemon is the remaining limit — a page reaches `/api/chat` directly only where the daemon, or a proxy in front of it, answers the page's origin; otherwise relay the call through your own server ([Relaying through your own server](#relaying-through-your-own-server)).
124
+ 13. **A core face that runs in a browser.** The package publishes the `.` entry alone, built from `src/core`, which imports no `node:*` module and no DOM global; the scoped core typecheck compiles it with the `ESNext` and `WebWorker` libraries and no ambient `@types`, so neither Node's globals nor the DOM's are in scope, and the base binds `globalThis.fetch` to its own receiver so a browser does not throw `Illegal invocation` on a bare transport reference. [`tests/service/page.test.ts`](../tests/service/page.test.ts) gates that claim rather than recording it: it launches the host's own Chrome or Edge through `@orkestrel/browser`, serves the built `@orkestrel/ollama` core entry and its whole `@orkestrel` import closure from an import map on a `127.0.0.1` fixture, and reads the page's requests from the browser's own request log. The page loads with no uncaught error and no console error, on the recorders a deliberate page fault then makes report; an `Agent` holding a page-defined DOM-writing `Tool` completes a tool loop against the live daemon through a same-origin `createRelay` server, where the model's recorded call reaches the page, the tool's minted receipt is read back out of the real DOM and found again as the `role: 'tool'` message of the relay request and the `/api/chat` request that follow the turn the call was dispatched in, and the run settles with `partial: false`; the page issues exactly one `POST /inference` per turn and nothing else across the whole operation window, with a deliberate `/control` request proving such a window can report one; a wrong bearer arrives as `ProviderError` with code `'HTTP'` and status `401` with no daemon request made; and `createOllama` in the page answers a prompt over `/api/chat` on the daemon's own origin after `requireDaemonOrigin` confirms the daemon permits the page's origin. Each attempt ends within its allowance plus its release share, so nothing the attempt schedules outlasts the case that holds it. The attempt's signal cancels the part of the connection the installed `BrowserOptions.signal` races — discovery, the port-free check, the launch, and `client.connect()` — and the attempt's own race is what bounds every phase after that, the target listing the connection ends with included; [`tests/service/page.test.ts`](../tests/service/page.test.ts) and the `PAGE_BOUNDS` table it spends state the mechanism. The model's choice to call a tool is retried at most 3 times, and that choice is the only reading another attempt buys: every attempt retains its observations before any page read can throw, and only a run that completed an answer without dispatching the tool spends another launch. An acquisition, read, release, or run failure ends the retry, and so does a run that deadline interrupted before it settled — an interrupted run reports no tool call for a reason another launch cannot clear, so retrying it would report the exhaustion as the model never choosing the tool. The request accounting is asserted over each retained attempt, so what the gate establishes is that the model called the tool within those attempts and that no attempt's traffic went unread. The daemon is the remaining limit — a page reaches `/api/chat` directly only where the daemon, or a proxy in front of it, answers the page's origin; otherwise relay the call through your own server ([Relaying through your own server](#relaying-through-your-own-server)).
125
125
  14. **Tested live against a real local Ollama (no `skipIf`).** The live tests run against a real Ollama daemon — no mocks, only genuine third-party calls — model `qwen3.5:2b-q4_K_M`, with `OLLAMA_HOST` / `OLLAMA_MODEL` overridable. Unlike the other surfaces, the dedicated `service` project requires the daemon: `tests/setupService.ts` throws a clear error if it is unreachable and warms the model (a `num_predict: 1` chat) before the suite, so the live tests run unconditionally (no `describe.skipIf`). The project runs serially (`fileParallelism: false`) with a 120s test/hook timeout so a cold load cannot flake it; `keep_alive` keeps the model resident across files, and `bash scripts/ollama.sh` brings the daemon and model up before the battery in CI. Assertions are structural — they hold whatever wording a small model produces — and never pin exact output. The hermetic half sits in `tests/src/core/`, and the relay round trip is proved hermetically in [`tests/src/core/integration.test.ts`](../tests/src/core/integration.test.ts) and live in [`tests/service/relay.test.ts`](../tests/service/relay.test.ts).
126
126
  15. **Doc ↔ source method bijection.** The `## Methods` table lists exactly the members `OllamaProvider` declares — `frame`, `body`, `read`, and `finish` — and the class declares exactly those. `generate` and `stream` reach a caller through the same class but are declared once, in `AgentProvider`, and documented in [`agent.md`](agent.md).
127
127
 
@@ -198,7 +198,7 @@ provider.finish(parser) // [] — the parser held nothing back
198
198
 
199
199
  ### Running in the browser
200
200
 
201
- The published face is core, so the same `createOllama` call runs unchanged in a browser module. Point `url` at whatever the page can actually reach: the daemon's own origin where it answers that origin, or your own server standing in front of it. A page that reaches a daemon directly is a development arrangement — it puts the model endpoint on the network the page runs on — so prefer one of the server patterns that follow for anything a user runs.
201
+ The published face is core, so the same `createOllama` call runs unchanged in a browser module. Point `url` at whatever the page can actually reach: the daemon's own origin where it answers that origin, or your own server standing in front of it. A page that reaches a daemon directly is a development arrangement — it puts the model endpoint on the network the page runs on — so prefer one of the server patterns that follow for anything a user runs. The daemon decides the page's origin before the call: a browser sends an `OPTIONS` preflight to `/api/chat` first, and a daemon that does not permit that origin refuses it, so set `OLLAMA_ORIGINS` to include the origin the page is served from. [`tests/service/page.test.ts`](../tests/service/page.test.ts) drives this arrangement for real, and gates it behind that same preflight.
202
202
 
203
203
  ```ts
204
204
  import { createOllama } from '@orkestrel/ollama'
@@ -259,7 +259,7 @@ const relayed = await browser.generate(messages, abort.signal)
259
259
  relayed.content // the daemon's answer, reassembled from the relay's frames
260
260
  ```
261
261
 
262
- A refusal never becomes a frame: a wrong credential reaches the browser as a `ProviderError` with code `'HTTP'` and status `401`, and the provider on the server is never entered. [`agent.md`](agent.md) states the frame vocabulary, the byte limit, and the rest of the refusal statuses. A page calling the relay from another origin needs CORS permission headers the relay route does not send, so serve the page from the relay server's own origin (as the recorded Chrome 148 run did) or put an origin-checking, CORS-answering middleware in front of the route (see the server guide), naming the `OPTIONS` preflight the browser sends with `authorization` and `content-type` as the requested headers.
262
+ A refusal never becomes a frame: a wrong credential reaches the browser as a `ProviderError` with code `'HTTP'` and status `401`, and the provider on the server is never entered. [`agent.md`](agent.md) states the frame vocabulary, the byte limit, and the rest of the refusal statuses. A page calling the relay from another origin needs CORS permission headers the relay route does not send, so serve the page from the relay server's own origin (which is what [`tests/service/page.test.ts`](../tests/service/page.test.ts) does, from one loopback fixture serving the page, its modules, and the route) or put an origin-checking, CORS-answering middleware in front of the route (see the server guide), naming the `OPTIONS` preflight the browser sends with `authorization` and `content-type` as the requested headers.
263
263
 
264
264
  ### Routing through your own server (obfuscated tokens)
265
265
 
@@ -348,16 +348,16 @@ A cancel is outside that taxonomy: the caller's signal and the deadline both thr
348
348
 
349
349
  ## Tests
350
350
 
351
- The hermetic projects — `src:core`, `setup`, `guides`, `conformance`, `policy`, and `config` — run with no daemon and no network. The `service` project requires a warm local Ollama, and `distribution` packs and installs the artifact.
351
+ The hermetic projects — `src:core`, `setup`, `guides`, `conformance`, `policy`, and `config` — run with no daemon and no network. The `service` project requires a warm local Ollama and, for the page proof, a Chromium-family browser and a built `dist`; `distribution` packs and installs the artifact.
352
352
 
353
353
  - [`tests/guides.test.ts`](../tests/guides.test.ts) — the `## Surface` ↔ `src/core` bijection (value + type exports), the `OllamaProvider` method bijection, and the equality gate: every `Summary` cell against its declaration's description paragraph, the titled `createOllama + generate` fence against the `@example` block of that title (pinned so the titled pair cannot be retired silently), and the README pitch against this guide's tagline. It also runs the flagship fences and asserts the values their comments claim.
354
354
  - [`tests/src/core/OllamaProvider.test.ts`](../tests/src/core/OllamaProvider.test.ts) — hermetic provider request-shape, framing, transport-seam, abort, deadline, and unreachable-upstream coverage, plus the streaming fold — deltas, tool-call accumulation, done-line usage, the unterminated tail flush, and think separation — driven over a canned NDJSON transport. Its recording proxy uses a deliberately unreachable default and asserts only captured requests and local provider behavior.
355
355
  - [`tests/src/core/helpers.test.ts`](../tests/src/core/helpers.test.ts) — the wire leaves: the request projection and every response extraction, including each malformed-wire degradation.
356
356
  - [`tests/src/core/factories.test.ts`](../tests/src/core/factories.test.ts) — hermetic `createOllama` shape, identity, defaults, passthrough, and unreachable-upstream coverage.
357
357
  - [`tests/src/core/integration.test.ts`](../tests/src/core/integration.test.ts) — the `src/core` integration scope. A real `@orkestrel/server` + `@orkestrel/router` relay in front of this provider carries ordered content and thinking deltas, tools, and usage while keeping the browser and daemon credentials apart, refuses a wrong bearer with an empty `401` before the daemon transport is entered, crosses a browser cancel and a server deadline as an abort frame with its partial, reduces a daemon stream failure to the fixed public error frame, and replays a returned tool call. Beside those, hermetic context framing, recap, reference, cherry-pick, canonical assembly order, scope allow-lists, workspace injection, and conversation selection run through the unreachable recording proxy, with a compile-time drift gate asserting the wire request and response shapes stay compatible with the official `ollama` client's types; that gate's authoritative check is `npm run check` (root tsc), while its run under vitest is an incidental no-op (`expectTypeOf` performs no runtime assertions).
358
- - [`tests/setup.test.ts`](../tests/setup.test.ts) — the shared host-independent test infrastructure, over the conversation padding, the throwing and recording summarizers, and the workspace seeder `tests/setup.ts` exports, and over the host-independent half of `tests/setupServer.ts`: its request-narrowing guards, its refusing and streaming transports, its tool fixtures, its scripted agent stream and agent-stream driver, and its environment readers.
359
- - [`tests/setupServer.test.ts`](../tests/setupServer.test.ts) — the Node-resource half of `tests/setupServer.ts`: the recording proxy and the relay server on real loopback sockets, the captured and open transports, whose fixtures answer in memory, the capture wait, the provider-stream driver, and the shared wire tables `WEATHER_TOOL` and the insatiable tool's chunk line.
360
- - [`tests/setupService.test.ts`](../tests/setupService.test.ts) — the hermetic half of the `service` project's setup: the environment readers, the sampling tables, and the readiness contract.
358
+ - [`tests/setup.test.ts`](../tests/setup.test.ts) — the shared host-independent test infrastructure, over the conversation padding, the throwing and recording summarizers, the workspace seeder, and the `PAGE_TOOL` definition `tests/setup.ts` exports, and over the host-independent half of `tests/setupServer.ts`: its request-narrowing guards, its refusing and streaming transports, its tool fixtures, its scripted agent stream and agent-stream driver, and its environment readers.
359
+ - [`tests/setupServer.test.ts`](../tests/setupServer.test.ts) — the Node-resource half of `tests/setupServer.ts`: the recording proxy and the relay server on real loopback sockets, the captured and open transports, whose fixtures answer in memory, the capture wait, the provider-stream driver, the wire tables `WEATHER_TOOL` and the insatiable tool's chunk line, and the page fixture's Node half — the request recorder the relay server and the page fixture share, the derived import map, the served document's presence guards, the contained file route, the control route, the port reservation, the membership the page bounds declare — the attempt allowance, the release share beside it, the per-call shares spent inside them, the case bound that contains the pair, and the launch count the retry spends — the ordering the live controls' own intervals hold, the attempt deadline's own expiry error, the elapsed completion a bounded attempt reports on each interleaving that can outlast it (an acquisition that never settles, one that settles inside the release share, a release that parks past that share, and a release that crossed the allowance), each of those measured against the deadlines it had to wait out plus one named scheduling allowance, so what the case proves is timer-bounded completion rather than a nominal sum, the failure each of them keeps reachable as its cause — the attempt's own failure under a composed strand, and the deadline's `TimeoutError` under a crossed release — the reading each thrown failure carries into a composed message, the decision the bounded retry's predicate makes over an inert attempt record, including the interrupted run it refuses, the release order a real fixture takes when the release after it rejects, the evaluate boundary's narrowing and the deadline it carries, and the guards that narrow what the page reports back.
360
+ - [`tests/setupService.test.ts`](../tests/setupService.test.ts) — the hermetic half of the `service` project's setup: the environment readers, the sampling tables, the readiness contract, and each live page gate's refusal — a host with no browser, a tree with no build, and a daemon that refuses the page's origin.
361
361
  - [`tests/conformance.test.ts`](../tests/conformance.test.ts) — where this package's wire types drift from the official `ollama` client they are written against.
362
362
  - [`tests/distribution.test.ts`](../tests/distribution.test.ts) — the packed package installed into a throwaway consumer: the exports map, the shipped declarations, and the module objects a real runtime hands that consumer.
363
363
  - [`tests/policy.test.ts`](../tests/policy.test.ts) and [`tests/config.test.ts`](../tests/config.test.ts) — the vendored cross-cutting proofs: the path- and text-shaped repository laws, and that the root configuration resolves its aliases, projects, and outputs.
@@ -365,6 +365,7 @@ The hermetic projects — `src:core`, `setup`, `guides`, `conformance`, `policy`
365
365
  - [`tests/service/factories.test.ts`](../tests/service/factories.test.ts) — live `createOllama` generation and streaming coverage.
366
366
  - [`tests/service/relay.test.ts`](../tests/service/relay.test.ts) — the browser-to-server relay against the live daemon: a generated answer through the authenticated route, streamed deltas joining to the settled content, and a cancel after a live delta returning the relay's partial.
367
367
  - [`tests/service/transport.test.ts`](../tests/service/transport.test.ts) — the transparent wire proxy, end-to-end. A `createRecordingProxy(OLLAMA_CONFIG.host)` — a real `@orkestrel/server` + `@orkestrel/router` HTTP server — records the inbound request, forwards it to the selected Ollama service, and streams the response back.
368
+ - [`tests/service/page.test.ts`](../tests/service/page.test.ts) — the live page receipt, in a real Chrome or Edge that `@orkestrel/browser` launches against a `127.0.0.1` fixture serving the page, the installed `@orkestrel` module tree, this workspace's own build, a `/control` route, and the authenticated relay from one origin. It proves that the published closure evaluates in the page with the page error recorder and the console recorder quiet, that the page parks exactly the operation table this proof evaluates by name, read back out of the running browser, that an `Agent` holding a page-defined DOM-writing `Tool` completes a tool loop against the live daemon over the same-origin relay and carries the minted receipt into the next turn's `role: 'tool'` message on the relay wire and the daemon wire, that the browser log, the page's Resource Timing drain, and the fixture's own record each report a request the page deliberately made, and the page error recorder and the console recorder each report a fault the page deliberately produced, that a wrong bearer is refused before the daemon is contacted, and that `createOllama` in the page answers a prompt on the daemon's own origin. Controls beside those drive a real browser through each interleaving the attempt bound covers, and assert the elapsed time of the whole call against the allowance plus the release share every time: an allowance too short to finish reads the attempt's own expiry error back rather than an inner browser timeout; an allowance the observation outlasts leaves the session released; an acquisition that settles after the allowance is released rather than abandoned, and is never reported as stranded; and an attempt whose release crossed the allowance reports that release rather than the answer the observation had already returned. Every precondition is a hard throw naming its fix — the daemon and model from `tests/setupService.ts`, the browser from `requirePageBrowser`, the build from `requireBuild`, and the daemon's permission for the page origin from `requireDaemonOrigin` — so a host missing one fails the project rather than skipping.
368
369
  - [`tests/service/tools.test.ts`](../tests/service/tools.test.ts) and [`tests/service/authority.test.ts`](../tests/service/authority.test.ts) — live tool dispatch through the agent loop, and the authority gate's approval and denial paths.
369
370
  - [`tests/service/budget.test.ts`](../tests/service/budget.test.ts) and [`tests/service/lifecycle.test.ts`](../tests/service/lifecycle.test.ts) — live token-budget enforcement mid-stream, and the agent's status, event, and abort lifecycle.
370
371
  - [`tests/service/schema.test.ts`](../tests/service/schema.test.ts) and [`tests/service/scopes.test.ts`](../tests/service/scopes.test.ts) — a live structured-output `schema` constraining the answer, and a live scope filtering what reaches the wire.
@@ -11,10 +11,11 @@ call whose shape is data, so whoever calls it can discover it, present it, and i
11
11
  knowing anything about the code behind it.
12
12
 
13
13
  `Tool` and `ToolManager` carry the runtime. A `Tool` is inert — a definition plus a handler, with
14
- no lifecycle and no failure handling of its own. A `ToolManager` is the live surface a caller
15
- holds: it hands `definitions()` outward, takes a `ToolCall` back, and answers with a `ToolResult`,
16
- a result rather than a throw for a call whose members are plain values. Tools stay in the map by
17
- name in insertion order. Everything else in this module is the plain data those two exchange.
14
+ no lifecycle. A configured contract validates arguments before its handler runs. A `ToolManager`
15
+ is the live surface a caller holds: it hands `definitions()` outward, takes a `ToolCall` back, and
16
+ answers with a `ToolResult`, a result rather than a throw for a call whose members are plain
17
+ values. Tools stay in the map by name in insertion order. Everything else in this module is the
18
+ plain data those two exchange.
18
19
 
19
20
  **Anyone can call a tool.** Nothing here is model-specific — `tools.execute(call)` is an ordinary
20
21
  async call returning an ordinary result, and plain application code may drive it directly. The
@@ -24,8 +25,9 @@ capability to a remote client, a backend dispatching a named operation. `@orkest
24
25
  `@orkestrel/mcp` are two such callers; ready-made tools ship in `@orkestrel/toolbox`.
25
26
 
26
27
  **Mechanism only.** This runtime advertises, dispatches, and contains failure. It transports
27
- nothing, validates no arguments against a tool's schema, authorizes no call, and ships no concrete
28
- tools. Optional caller context is consumer-asserted and forwarded without verification. Each trust
28
+ nothing, authorizes no call, and ships no concrete tools. A `contract` derives the advertised
29
+ parameter schema and validates arguments; `parameters` alone remains descriptive. Caller identity
30
+ in the execution context is consumer-asserted and forwarded without verification. Each trust
29
31
  decision belongs to the invoking consumer, to a policy layer, or to the tool itself. Progress
30
32
  reporting belongs there too: it is a property of the invoking consumer's execution context, one
31
33
  layer up — the `@orkestrel/mcp` package's execution context carries a progress reporter — never of
@@ -35,7 +37,7 @@ Source: [`src/core`](../src/core). Published through `@orkestrel/tool`.
35
37
 
36
38
  ## Surface
37
39
 
38
- ### Contracts
40
+ ### Types
39
41
 
40
42
  The data shapes, from [`types.ts`](../src/core/types.ts). Every property is readonly, and an
41
43
  optional field the caller did not supply is absent from the value. A `Shape` cell holds an
@@ -43,26 +45,34 @@ interface's data members as bare names in braces, `?` marking an optional member
43
45
  introducing its call-signature members, and a type alias's own type literal with a union's arms
44
46
  escaped as `\|`. An extended interface's name comes before `plus`, with the members it adds after.
45
47
 
46
- | Name | Kind | Shape | Summary |
47
- | ---------------------- | --------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
48
- | `ToolDefinition` | interface | `{ name, description?, parameters? }` | Describes a tool as advertised to a caller. |
49
- | `ToolCall` | interface | `{ id, name, arguments, caller? }` | Describes one request to run a named tool. |
50
- | `ToolSuccess` | interface | `Success<unknown> plus { id, name }` | Reports the successful outcome of executing a `ToolCall`. |
51
- | `ToolFailure` | interface | `Failure<string> plus { id, name }` | Reports the failed outcome of executing a `ToolCall`. |
52
- | `ToolOptions` | interface | `{ name, description?, summary?, parameters?, execute }` | Configures an executable tool. |
53
- | `ToolInterface` | interface | `ToolDefinition plus { summary? } plus execute` | Represents an executable tool: its advertised definition plus its local handler. |
54
- | `ToolManagerInterface` | interface | `{ count } plus add, tool, tools, definitions, execute, remove, clear` | Represents a registry of executable tools with per-call error isolation. |
55
- | `ToolResult` | type | `ToolSuccess \| ToolFailure` | Represents the outcome of executing a `ToolCall`. |
48
+ | Name | Kind | Shape | Summary |
49
+ | ---------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
50
+ | `ToolDefinition` | interface | `{ name, title?, description?, parameters?, annotations? }` | Describes a tool as advertised to a caller. |
51
+ | `ToolCall` | interface | `{ id, name, arguments }` | Describes one request to run a named tool. |
52
+ | `ToolSuccess` | interface | `Success<unknown> plus { id, name }` | Reports the successful outcome of executing a `ToolCall`. |
53
+ | `ToolFailure` | interface | `Failure<string> plus { id, name }` | Reports the failed outcome of executing a `ToolCall`. |
54
+ | `ToolOptions` | interface | `{ name, title?, description?, summary?, parameters?, contract?, annotations?, execute }` | Configures an executable tool. |
55
+ | `ToolInterface` | interface | `ToolDefinition plus { summary? } plus execute` | Represents an executable tool: its advertised definition plus its local handler. |
56
+ | `ToolManagerInterface` | interface | `{ count, emitter } plus add, tool, tools, definitions, execute, remove, clear, destroy` | Represents a registry of executable tools with per-call error isolation. |
57
+ | `ToolManagerEventMap` | type | `{ readonly add: readonly [tool: ToolInterface]; readonly remove: readonly [tool: ToolInterface]; readonly clear: readonly [tools: readonly ToolInterface[]] }` | Names the events a tool registry publishes. |
58
+ | `ToolManagerOptions` | interface | `{ on?, error? }` | Configures a tool registry's initial listeners and error handling. |
59
+ | `ToolResult` | type | `ToolSuccess \| ToolFailure` | Represents the outcome of executing a `ToolCall`. |
60
+ | `ToolContext` | interface | `{ signal, caller? }` | Carries the signal and consumer-asserted identity for an execution. |
61
+ | `ToolAnnotations` | interface | `{ pure?, untrusted?, consequential? }` | Describes the observable effects and content of a tool. |
62
+ | `ToolErrorCode` | type | `'SCHEMA' \| 'ARGUMENTS'` | Identifies a schema conflict or an argument validation failure. |
63
+ | `ToolErrorContext` | interface | `{ faults? }` | Carries the structured faults behind an argument validation failure. |
56
64
 
57
65
  `ToolInterface` and `ToolManagerInterface` list every member they declare or inherit. The
58
66
  call-signature members of each are documented under [Methods](#methods); the readonly `count` of
59
67
  `ToolManagerInterface` reports how many tools are registered and is a Surface member with no
60
- method row.
68
+ method row. Its readonly `emitter` publishes `add`, `remove`, and `clear` with the payloads
69
+ declared by `ToolManagerEventMap`. The `ToolManagerOptions` fields supply initial `on` hooks
70
+ and an `error` handler for listener throws.
61
71
 
62
72
  ### Validators
63
73
 
64
- The call-envelope guard, from [`validators.ts`](../src/core/validators.ts). In a guard table a
65
- `Shape` cell holds the type the guard narrows to.
74
+ The call-envelope guard, from [`validators.ts`](../src/core/validators.ts). In a guard
75
+ table a `Shape` cell holds the type the guard narrows to.
66
76
 
67
77
  | Name | Kind | Shape | Summary |
68
78
  | ------------ | -------- | ---------- | -------------------------------------------------------------------------------------------------------------------- |
@@ -72,19 +82,19 @@ The call-envelope guard, from [`validators.ts`](../src/core/validators.ts). In a
72
82
 
73
83
  The advertised-definition projection, from [`helpers.ts`](../src/core/helpers.ts).
74
84
 
75
- | Name | Kind | Signature | Summary |
76
- | ------------------ | -------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
77
- | `toolToDefinition` | function | `(tool: ToolInterface) => ToolDefinition` | Projects a tool onto the plain definition advertised to a caller, advertising an authored `summary` in place of the full description and carrying the parameter schema by reference. |
85
+ | Name | Kind | Signature | Summary |
86
+ | ------------------ | -------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
87
+ | `toolToDefinition` | function | `(tool: ToolInterface) => ToolDefinition` | Projects a tool onto the plain definition advertised to a caller, advertising an authored `summary` in place of the full description and carrying `parameters` and `annotations` by reference. |
78
88
 
79
89
  ### Factories
80
90
 
81
91
  From [`factories.ts`](../src/core/factories.ts) — the constructor-free way to reach `Tool` and
82
92
  `ToolManager`.
83
93
 
84
- | Name | Kind | Signature | Summary |
85
- | ------------------- | -------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
86
- | `createTool` | function | `(options: ToolOptions) => ToolInterface` | Creates an executable tool bound to the supplied handler, returned as a `ToolInterface` so a call site holds the published contract rather than the `Tool` class. |
87
- | `createToolManager` | function | `() => ToolManagerInterface` | Creates an empty registry that advertises definitions and executes calls with per-call error isolation, returned as a `ToolManagerInterface` so a caller holds the published contract rather than the `ToolManager` class. |
94
+ | Name | Kind | Signature | Summary |
95
+ | ------------------- | -------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
96
+ | `createTool` | function | `(options: ToolOptions) => ToolInterface` | Creates an executable tool bound to the supplied handler, returned as a `ToolInterface` so a call site holds the published contract rather than the `Tool` class. |
97
+ | `createToolManager` | function | `(options?: ToolManagerOptions) => ToolManagerInterface` | Creates an empty registry that advertises definitions and executes calls with per-call error isolation, returned as a `ToolManagerInterface` so a caller holds the published contract rather than the `ToolManager` class. |
88
98
 
89
99
  ### Classes
90
100
 
@@ -92,51 +102,67 @@ The implementing classes, from [`Tool.ts`](../src/core/tools/Tool.ts) and
92
102
  [`ToolManager.ts`](../src/core/tools/ToolManager.ts) — each documented in full under its own
93
103
  heading following this table.
94
104
 
95
- | Name | Kind | Summary |
96
- | ------------- | ----- | ---------------------------------------------------------------------------- |
97
- | `Tool` | class | Binds an executable tool definition to a handler. |
98
- | `ToolManager` | class | Represents an insertion-ordered tool registry with per-call error isolation. |
105
+ | Name | Kind | Summary |
106
+ | ------------- | ----- | -------------------------------------------------------------------------------------- |
107
+ | `Tool` | class | Binds an executable tool definition to a handler. |
108
+ | `ToolManager` | class | Represents an insertion-ordered tool registry with per-call error isolation. |
109
+ | `ToolError` | class | Reports a schema conflict or argument validation failure with a machine-readable code. |
99
110
 
100
111
  ### `Tool`
101
112
 
102
113
  The implementing class of `ToolInterface`, from [`Tool.ts`](../src/core/tools/Tool.ts). It
103
114
  copies the fields it was given — omitting each optional one that was not supplied — and keeps
104
115
  the handler in a private field, so a tool's advertised shape cannot drift from what it executes.
105
- The parameter schema, argument record, and present caller context are forwarded by reference,
106
- never cloned. `Tool` deliberately does not catch: a handler that throws throws, and per-call
107
- isolation belongs to the registry that dispatched it. See [`## Methods`](#methods) for its public
108
- call surface.
116
+ An explicit parameter schema and the execution context are forwarded by reference. Without a
117
+ contract, the argument record retains its identity too. A contract compiles at construction and
118
+ derives the parameter schema through the contract package's projection. The compiled contract
119
+ checks arguments before handler entry and supplies its parsed copy to the handler.
120
+ `Tool` deliberately does not catch: a handler that throws throws, and per-call isolation belongs
121
+ to the registry that dispatched it. See [`## Methods`](#methods) for its public call surface.
109
122
 
110
123
  ### `ToolManager`
111
124
 
112
125
  The implementing class of `ToolManagerInterface`, from
113
- [`ToolManager.ts`](../src/core/tools/ToolManager.ts). One name-keyed map is its whole state:
114
- tools stay in insertion order, `tools()` and `definitions()` return fresh readonly arrays rather
126
+ [`ToolManager.ts`](../src/core/tools/ToolManager.ts). It stores tools in a name-keyed map and owns
127
+ an emitter for registry changes. Tools stay in insertion order, `tools()` and `definitions()` return fresh readonly arrays rather
115
128
  than a view of that map, and every projection is computed on demand so a mutation can never
116
129
  leave a stale copy behind. It is the only place a call can fail into a result instead of an
117
130
  exception. See [`## Methods`](#methods) for its public call surface.
118
131
 
132
+ ### `ToolError`
133
+
134
+ The error class from [`errors.ts`](../src/core/errors.ts) extends `Error`. Its constructor takes
135
+ `code`, `message`, and optional `context`. Direct tool execution throws an `ARGUMENTS` error with
136
+ the full fault report in `context.faults`; the manager contains it as a message. Use `isToolError`
137
+ to narrow a caught value. See [Contract validation and errors](#contract-validation-and-errors)
138
+ for an executed example.
139
+
140
+ | Name | Kind | Shape | Summary |
141
+ | ------------- | -------- | ----------- | ---------------------------------------------------------------------------- |
142
+ | `isToolError` | function | `ToolError` | Checks whether a value is a tool error, containing hostile prototype access. |
143
+
119
144
  ## Methods
120
145
 
121
146
  The public call-signature members of each behavioral interface, one table per interface.
122
147
 
123
148
  #### `ToolInterface`
124
149
 
125
- | Method | Returns | Summary |
126
- | --------- | ----------------------------- | ---------------------------------------------------------------------------------------------------- |
127
- | `execute` | `Promise<unknown> \| unknown` | Runs the tool's handler with the caller-supplied arguments and any consumer-asserted caller context. |
150
+ | Method | Returns | Summary |
151
+ | --------- | ----------------------------- | --------------------------------------------------------------------------------- |
152
+ | `execute` | `Promise<unknown> \| unknown` | Runs the tool's handler with the caller-supplied arguments and execution context. |
128
153
 
129
154
  #### `ToolManagerInterface`
130
155
 
131
- | Method | Returns | Summary |
132
- | ------------- | ---------------------------------------------- | ---------------------------------------------- |
133
- | `add` | `void` | Registers one tool. |
134
- | `tool` | `ToolInterface \| undefined` | Finds one registered tool by name. |
135
- | `tools` | `readonly ToolInterface[]` | Lists the registered tools in insertion order. |
136
- | `definitions` | `readonly ToolDefinition[]` | Lists the definitions advertised to a caller. |
137
- | `execute` | `Promise<ToolResult \| readonly ToolResult[]>` | Executes one call with error isolation. |
138
- | `remove` | `boolean` | Removes one registered tool. |
139
- | `clear` | `void` | Removes every registered tool. |
156
+ | Method | Returns | Summary |
157
+ | ------------- | ---------------------------------------------- | -------------------------------------------------------- |
158
+ | `add` | `void` | Registers one tool. |
159
+ | `tool` | `ToolInterface \| undefined` | Finds one registered tool by name. |
160
+ | `tools` | `readonly ToolInterface[]` | Lists the registered tools in insertion order. |
161
+ | `definitions` | `readonly ToolDefinition[]` | Lists the definitions advertised to a caller. |
162
+ | `execute` | `Promise<ToolResult \| readonly ToolResult[]>` | Executes one call with error isolation. |
163
+ | `remove` | `boolean` | Removes one registered tool. |
164
+ | `clear` | `void` | Removes every registered tool. |
165
+ | `destroy` | `void` | Removes every tool and releases the emitter's listeners. |
140
166
 
141
167
  `add`, `execute`, and `remove` each take one value or a readonly batch of them. A batch `add`
142
168
  registers every tool, later entries winning over earlier ones with the same name; a batch
@@ -170,15 +196,16 @@ const add = createTool({
170
196
  `new Tool({ … })` builds the same thing; reach for `createTool` where a call site must not name
171
197
  a class.
172
198
 
173
- The schema is descriptive runtime data, forwarded by reference and never interpreted here. A
174
- handler always receives the open `Readonly<Record<string, unknown>>` the caller sent and narrows
175
- the fields it consumes — declaring `required` tells the caller what to send, not this runtime
176
- what to reject. Its optional second parameter is the call's `caller` value verbatim. That value
177
- is asserted by the invoking consumer and is never verified here; a handler or policy layer must
178
- make every authorization and trust decision. Handlers may be synchronous or asynchronous; the
179
- registry awaits either. A handler can declare no parameter, one, or two. When the call carries
180
- no caller context the registry invokes the handler with the arguments record alone, so a handler
181
- that reads its own arity sees one argument and a handler that declares `caller` sees `undefined`.
199
+ An explicit `parameters` schema is descriptive runtime data, forwarded by reference. Declaring
200
+ `required` in that schema tells the caller what to send; it does not validate the payload. Use the
201
+ `contract` option when the runtime must validate arguments.
202
+
203
+ A handler receives a `Readonly<Record<string, unknown>>` and a required `ToolContext`.
204
+ Without a contract, that record is the original input; with a contract, it is the parsed value.
205
+ The context holds an `AbortSignal` and optional unverified caller identity. Handlers may omit
206
+ unused parameters from their declaration. Every invocation still supplies the arguments and the
207
+ context. A direct `tool.execute(args, context)` call must provide the context; the manager creates
208
+ one when its caller omits it. Handlers may return synchronously or asynchronously.
182
209
 
183
210
  When one was authored, `definitions()` projects the tool's `summary` as `description`, advertising
184
211
  it in place of the full description. The full text stays on the tool for direct lookup through
@@ -214,10 +241,10 @@ moving it — the sequence a caller sees stays stable while a tool behind a name
214
241
  Remove a name and add it again and it lands at the end, because the name is genuinely new to the
215
242
  map. In a batch, later entries win over earlier ones with the same name.
216
243
 
217
- `definitions()` projects fresh plain objects on every call: `name`, then `description` only when
218
- a summary or description exists, then `parameters` only when a schema exists, with the schema
219
- object's original identity preserved. Nothing that arrives on a definition is a live handle on
220
- the registry — advertising cannot be used to reach the handlers.
244
+ `definitions()` projects fresh plain objects on every call: `name`, followed by present `title`,
245
+ `description`, `parameters`, and `annotations` fields. A summary replaces the advertised
246
+ description. The schema and annotations retain their original identities. Nothing that arrives on
247
+ a definition is a live handle on the registry — advertising cannot be used to reach the handlers.
221
248
 
222
249
  ## Calls and results
223
250
 
@@ -227,11 +254,12 @@ it, then execute:
227
254
  ```ts
228
255
  import { isToolCall } from '@orkestrel/tool'
229
256
 
257
+ tools.add(add) // restores the tool removed by the registry example
258
+
230
259
  const incoming: unknown = {
231
260
  id: 'call-1',
232
261
  name: 'add',
233
262
  arguments: { left: 2, right: 3 },
234
- caller: { subject: 'user-42' },
235
263
  }
236
264
 
237
265
  if (isToolCall(incoming)) {
@@ -250,13 +278,12 @@ const batch = await tools.execute([
250
278
  ```
251
279
 
252
280
  `isToolCall` validates the envelope only: the `id`, the `name`, and that `arguments` is a plain
253
- record. Optional `caller` remains opaque `unknown`; the guard does not verify it. It never checks
254
- arguments against a tool's schema, so a well-formed call for a badly shaped payload still reaches
255
- the handler, which is exactly where the domain knowledge to reject it lives.
281
+ record. The guard ignores extra fields without reading them. A call carries no execution context.
282
+ The registered tool's contract, when configured, validates the arguments during execution.
256
283
 
257
- Caller context exists only on the call and in the tool execution chain. This package never adds
258
- it to advertised definitions, schemas, results, or logs. When present it is forwarded by
259
- identity; when absent it is not passed at all.
284
+ Execution context travels as a separate argument. This package adds neither the signal nor caller
285
+ identity to definitions, schemas, calls, or results. A handler can explicitly return a context
286
+ member as its own value. The context and caller identity reach the handler unchanged.
260
287
 
261
288
  Execution always resolves for a call whose members are plain values; a call whose `id` or `name`
262
289
  accessor throws when read makes `execute` reject, because no correlated result can be built
@@ -270,7 +297,7 @@ carries `error`. Narrow on `success` to distinguish the two; a present success v
270
297
  necessarily meaningful or truthy.
271
298
 
272
299
  An in-process caller needing a typed error can call `tools.tool(name)`, then
273
- `tool.execute(args)` inside its own `try`/`catch`.
300
+ `tool.execute(args, context)` inside its own `try`/`catch`.
274
301
 
275
302
  A batch is dispatched concurrently and answered in input order, with each call whose members are
276
303
  plain values isolated from its siblings — a handler failure never voids the batch, a call whose
@@ -278,6 +305,174 @@ plain values isolated from its siblings — a handler failure never voids the ba
278
305
  calls rather than collapsing into one. That isolation is what lets a caller feed every result back
279
306
  to whatever produced the calls and let it react to the failures itself.
280
307
 
308
+ The sections that follow are independent examples.
309
+
310
+ ## Execution context
311
+
312
+ For versions from 0.0.15, `caller` lives on `ToolContext` instead of `ToolCall`. A handler whose
313
+ second parameter is annotated `unknown` still compiles and receives the context object rather
314
+ than the caller value. Update such a handler to read `context.caller`.
315
+
316
+ Pass a context when the caller owns cancellation or carries an asserted identity:
317
+
318
+ ```ts
319
+ import type { ToolContext } from '@orkestrel/tool'
320
+ import { createTool, createToolManager } from '@orkestrel/tool'
321
+
322
+ const controller = new AbortController()
323
+ const context: ToolContext = { signal: controller.signal, caller: { subject: 'reader' } }
324
+ const tools = createToolManager()
325
+ tools.add(createTool({ name: 'signal', execute: (_args, execution) => execution.signal.aborted }))
326
+ const call = { id: 'signal-1', name: 'signal', arguments: {} }
327
+ const result = await tools.execute(call, context)
328
+ result // { id: 'signal-1', name: 'signal', success: true, value: false }
329
+ controller.abort('request ended')
330
+ const aborted = await tools.execute(call, context)
331
+ aborted // { id: 'signal-1', name: 'signal', success: false, error: 'request ended' }
332
+ ```
333
+
334
+ The manager creates a non-aborted signal for each execution that omits a context. A batch shares
335
+ one context, whether supplied or created. Before entering each registered handler, the manager
336
+ checks the signal. A batch dispatches every call in one synchronous pass, so an abort raised after
337
+ dispatch reaches only handlers that observe the signal. A synchronous abort inside the dispatch
338
+ pass prevents later handler entry. An already-aborted signal produces a failure with
339
+ `String(signal.reason)`, or `aborted` if the reason is `undefined`. An unknown tool still produces
340
+ its not-found failure.
341
+ After handler entry, the handler must observe the signal and stop its own work. The manager awaits
342
+ that handler and contains its throws as usual; an abort does not force a running handler to settle.
343
+
344
+ ## Contract validation and errors
345
+
346
+ A contract compiles at construction. Its schema projects to `parameters` through
347
+ `schemaToParameters(createContract(shape).schema)` from `@orkestrel/contract`; an undefined
348
+ projection leaves parameters absent. Supplying `contract` and `parameters` together throws a
349
+ `ToolError` with code `SCHEMA`.
350
+
351
+ The contract's `explain` method reports parse faults before the handler runs. It accepts coercible
352
+ values, such as a numeric string for a number. After a clean report, `Tool.execute` forwards
353
+ `contract.parse(args)`: the owned, normalized copy in the schema's types, with undeclared keys
354
+ dropped. A handler cannot rely on undeclared input keys being present. Without a contract, the
355
+ raw argument record is forwarded unchanged. A parse fault throws `ToolError` with code
356
+ `ARGUMENTS`. The message names the first fault's path and reason, then its expected and received
357
+ values when the fault carries them; a constraint fault also names its constraint and limit when
358
+ present. Array paths join with `.`; string paths stay unchanged. A root array path is empty, so
359
+ its message starts with `: `. The error's `context.faults` holds the full report. A `variant` fault
360
+ carries `variants`, appended as `; variants <n>`; a `oneOf` fault carries `matched`, appended as
361
+ `; matched <n>`. A missing field carries expected alone. Contract construction errors from the
362
+ dependency propagate unchanged. If parsing returns a value that isn't a record after a clean
363
+ explanation, execution throws `ToolError` with code `ARGUMENTS` and message
364
+ `Arguments did not parse`, without `context`, before handler entry.
365
+
366
+ Use the guard to narrow an error at the direct execution boundary:
367
+
368
+ ```ts
369
+ import type { ToolErrorCode, ToolErrorContext } from '@orkestrel/tool'
370
+ import { numberShape, objectShape } from '@orkestrel/contract'
371
+ import { ToolError, createTool, isToolError } from '@orkestrel/tool'
372
+
373
+ const tool = createTool({
374
+ name: 'amount',
375
+ contract: objectShape({ amount: numberShape() }),
376
+ execute: (args) => args.amount,
377
+ })
378
+ const context = { signal: new AbortController().signal }
379
+ tool.execute({ amount: 3 }, context) // 3
380
+ try {
381
+ tool.execute({ amount: 'invalid' }, context)
382
+ } catch (error) {
383
+ if (!isToolError(error)) throw error
384
+ const code: ToolErrorCode = error.code
385
+ code // 'ARGUMENTS'
386
+ const details: ToolErrorContext | undefined = error.context
387
+ details?.faults?.[0]?.reason // 'type'
388
+ error.message // 'amount: type; expected number; received "invalid"'
389
+ }
390
+ const conflict = new ToolError('SCHEMA', 'Choose contract or parameters')
391
+ conflict.code // 'SCHEMA'
392
+ isToolError(conflict) // true
393
+ isToolError(new Error('Unrelated')) // false
394
+ ```
395
+
396
+ The manager contains this same argument refusal as a `ToolFailure`; it carries the message rather
397
+ than the error instance or fault report. `ToolError` extends `Error`, exposes its readonly `code`
398
+ and optional readonly `context`, and inherits the standard error methods.
399
+
400
+ ## Advertising title and annotations
401
+
402
+ A title supplies display text. Annotations describe observable effects and content: `pure` reports
403
+ no state changes the caller can observe, `untrusted` reports that the result can carry content the
404
+ tool did not author, and `consequential` reports an effect the caller must confirm. These are
405
+ claims by the tool author; this runtime neither verifies them nor enforces confirmation.
406
+
407
+ Project the advertising fields while keeping the detailed description on the tool:
408
+
409
+ ```ts
410
+ import type { ToolAnnotations } from '@orkestrel/tool'
411
+ import { createTool, toolToDefinition } from '@orkestrel/tool'
412
+
413
+ const annotations: ToolAnnotations = { pure: true, untrusted: false, consequential: false }
414
+ const tool = createTool({
415
+ name: 'echo',
416
+ title: 'Echo',
417
+ description: 'Return the supplied value unchanged.',
418
+ summary: 'Echo a value.',
419
+ annotations,
420
+ execute: (args) => args.value,
421
+ })
422
+ const definition = toolToDefinition(tool)
423
+ definition.title // 'Echo'
424
+ definition.description // 'Echo a value.'
425
+ definition.annotations === annotations // true
426
+ ```
427
+
428
+ ## Patterns
429
+
430
+ ### Observe registry changes
431
+
432
+ Subscribe through the `on` option or `tools.emitter.on`. Each event describes the registry at the
433
+ moment it is published. A listener that mutates the registry re-enters synchronously; its own
434
+ events publish before the outer call resumes.
435
+
436
+ An addition publishes `add` with the map holding that exact tool. A replacement keeps its
437
+ registration position and publishes `remove` with the previous instance while the replacement
438
+ is already installed. A listener must not read absence from the map to confirm that removal.
439
+ After the removal listeners return, `add` publishes only if the map still holds that exact
440
+ replacement. Removing a present name publishes `remove` after deletion; a missing name publishes
441
+ nothing. Batches apply their operations in argument order.
442
+ Each `clear` call publishes one `clear` with the removed tools in registration order, including
443
+ an empty array when the registry was empty. Execution publishes no registry events.
444
+
445
+ Collect event names while registering, replacing, removing, and clearing a tool:
446
+
447
+ ```ts
448
+ import { createTool, createToolManager } from '@orkestrel/tool'
449
+
450
+ const events: string[] = []
451
+ const tools = createToolManager({
452
+ on: {
453
+ add: () => events.push('add'),
454
+ remove: () => events.push('remove'),
455
+ clear: () => events.push('clear'),
456
+ },
457
+ })
458
+ tools.add(createTool({ name: 'echo', execute: (args) => args.value }))
459
+ tools.add(createTool({ name: 'echo', execute: () => 'replacement' }))
460
+ tools.remove('echo')
461
+ tools.clear()
462
+ events // ['add', 'remove', 'add', 'remove', 'clear']
463
+ tools.destroy()
464
+ tools.emitter.destroyed // true
465
+ ```
466
+
467
+ Listeners run synchronously. A listener throw reaches the optional `error` handler as
468
+ `(error, event)` and does not prevent sibling listeners. Without an error handler, the emitter
469
+ swallows listener throws. Destruction clears the registry while listeners remain attached,
470
+ destroys the emitter, then empties the map again without publishing. It returns with an empty
471
+ registry even if a `clear` listener added a tool. An emission already underway delivers to its
472
+ remaining snapshotted listeners, even when a listener destroys the registry before its siblings
473
+ run. A destroyed registry publishes nothing; later additions still update its tool map, and
474
+ later subscriptions do nothing.
475
+
281
476
  ## Callers
282
477
 
283
478
  The registry's two-sided shape — `definitions()` out, `execute()` back — is all a caller needs,
@@ -297,12 +492,13 @@ registers here unchanged.
297
492
 
298
493
  ## Tests
299
494
 
300
- - [`guides.test.ts`](../tests/guides.test.ts) — the `## Surface` ↔ `src/core` bijection, the `ToolInterface` ↔ `Tool` and `ToolManagerInterface` ↔ `ToolManager` method bijections, and the equality gate: every `Summary` cell against its declaration's description paragraph, the titled `Anatomy of a tool` fence against the `@example` block of that title (pinned so the titled pair cannot be retired silently), and the README pitch against this guide's tagline. It also runs the flagship fences and asserts the values their comments claim.
301
- - [`Tool.test.ts`](../tests/src/core/tools/Tool.test.ts) — definition binding, optional-field omission, argument identity, return values, and the deliberate absence of handler isolation.
302
- - [`ToolManager.test.ts`](../tests/src/core/tools/ToolManager.test.ts) — insertion order, overwrite and removal lifecycle, definition projection, and isolated single and batch execution.
303
- - [`factories.test.ts`](../tests/src/core/factories.test.ts) — factory construction and working instances.
304
- - [`helpers.test.ts`](../tests/src/core/helpers.test.ts) — definition projection: summary preference, omitted optional keys, projected key order, schema identity, and a fresh object per call.
495
+ - [`guides.test.ts`](../tests/guides.test.ts) — the `## Surface` ↔ `src/core` bijection, the `ToolInterface` ↔ `Tool` and `ToolManagerInterface` ↔ `ToolManager` method bijections, and the equality gate: every `Summary` cell against its declaration's description paragraph, the titled `Anatomy of a tool` fence against the `@example` block of that title (pinned so the titled pair cannot be retired silently), and the README pitch against this guide's tagline. It also runs the flagship fences, including `Observe registry changes`, and asserts the values their comments claim against byte-equal transcriptions.
496
+ - [`Tool.test.ts`](../tests/src/core/tools/Tool.test.ts) — definition binding, optional-field omission, argument and context identity, contract validation, error diagnostics, return values, and direct error propagation.
497
+ - [`ToolManager.test.ts`](../tests/src/core/tools/ToolManager.test.ts) — insertion order, overwrite and removal lifecycle, definition projection, cancellation, context sharing, and isolated single and batch execution. Event proofs cover registration before `add`, ordered batch additions, the installed replacement during `remove`, `remove` before replacement `add`, synchronous replacement re-entry, a third instance a removal listener installs during a replacement, deletion before `remove`, silent missing names, ordered batch removals, populated and empty `clear` snapshots by identity, emitter destruction after clearing, an empty registry after teardown listeners re-add a tool, sibling delivery during mid-emission destruction, silent additions after destruction, and execution without registry events.
498
+ - [`factories.test.ts`](../tests/src/core/factories.test.ts) — factory construction, working instances, initial registry hooks in publication order, sibling listener isolation, and listener-error forwarding.
499
+ - [`helpers.test.ts`](../tests/src/core/helpers.test.ts) — definition projection: summary preference, omitted optional keys, projected key order, schema identity, title and annotations forwarding, and a fresh object per call.
305
500
  - [`validators.test.ts`](../tests/src/core/validators.test.ts) — tool-call envelope boundaries: incomplete calls, wrong field types, and non-record arguments.
501
+ - [`errors.test.ts`](../tests/src/core/errors.test.ts) — `isToolError` recognition, unrelated-value rejection, and hostile prototype containment.
306
502
 
307
503
  ## See also
308
504