@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.
@@ -12,7 +12,9 @@ Hand a provider a conversation and get back one assembled `ProviderResult` (`gen
12
12
 
13
13
  Tools and files are borrowed, not owned. Callable tools come from [`@orkestrel/tool`](tool.md): the loop advertises their definitions to the model, dispatches the calls that come back, and feeds each `ToolResult` in as a tool message. A tool is loop machinery — it is never rendered into the prompt. Documents come from [`@orkestrel/workspace`](workspace.md): the context renders the active workspace into every turn, split by carrier — text as fenced reference blocks in the system message, images attached to the last user turn. That split is this package's own product policy, decided here because only the prompt-assembly layer knows what a turn looks like.
14
14
 
15
- A turn is bounded and always terminates. One `AbortSignal` — a cancel, a [timeout](timeout.md), and a [budget](budget.md) folded together through `AbortSignal.any` — bounds the whole run, and tool iteration is capped at `limit`. A cancel is not an error: it commits a partial `AgentResult` that resolves, so only a genuine provider or tool failure rejects. `generate` and `stream` share one private run, so the one-shot result can never diverge from the live stream, and a buggy observer cannot corrupt either, because the emitter isolates a listener's throw.
15
+ A turn has cancellation and iteration bounds. The run's `AbortSignal` folds an agent abort, a stream abort, an external signal, a [timeout](timeout.md), and the [budget](budget.md) through `AbortSignal.any` and bounds the provider. A handler's `context.signal` is that same signal. The budget is charged during provider streaming and between turns, before tool dispatch; exhaustion ends the run without dispatching, never inside a handler. Tool iteration is capped at `limit`. A running tool must cooperate with cancellation: the loop awaits its result even when it ignores the signal. A cancel commits a partial `AgentResult` that resolves after the running work settles. `generate` and `stream` share one private run, so their results agree, and the emitter isolates a listener's throw.
16
+
17
+ The loop and its tool handlers execute in the host that constructs them. See [Placement proofs](#placement-proofs) for the receipts distinguishing a Node process, a browser page, and a page that relays inference to Node.
16
18
 
17
19
  ## Surface
18
20
 
@@ -67,7 +69,7 @@ const results = await tools.execute([
67
69
  ])
68
70
  ```
69
71
 
70
- Contained failure is the registry's contract, not a limitation of it: in-process code that wants a typed error calls the tool itself — `tools.tool(name)` then `tool.execute(args)` inside its own `try`/`catch`. Registration, advertising, dispatch, and error containment are documented in [`tool.md`](tool.md).
72
+ Contained failure is the registry's contract, not a limitation of it: in-process code that wants a typed error calls the tool itself — `tools.tool(name)` then `tool.execute(args, context)` inside its own `try`/`catch`. Registration, advertising, dispatch, and error containment are documented in [`tool.md`](tool.md).
71
73
 
72
74
  Collect a turn's conversation in an `AgentContext`. Add turns through `context.messages` — the active conversation's live tail, always present, satisfying `MessageManagerInterface` by minting each `id` on `add` and keeping stored messages immutable and in insertion order — then `build()` the provider input: `[systemMessage?, ...messages]`. `context.tools` sits beside them, but it is a different kind of thing: the other managers assemble prompt text, while the tool registry exists so the loop can advertise definitions and dispatch calls. Its contents reach the model as the `tools` argument, never as a message:
73
75
 
@@ -413,6 +415,8 @@ A cancel is not in that taxonomy: the call's own deadline and the caller's signa
413
415
 
414
416
  ### The relay
415
417
 
418
+ In the page-to-Node placement, the page owns `Agent`, `RelayProvider`, and its tool registry. Node owns the `createRelay` handler and upstream provider. The hop carries inference requests and replies; a tool handler executes where its registry lives. The Chromium receipt is planned in the `@orkestrel/mcp` distribution proof named under [Placement proofs](#placement-proofs) and is not recorded here as passed.
419
+
416
420
  A browser must not hold a model credential, so this package ships the hop rather than the credential. `createRelay` returns a `RelayHandler` — a plain `(request: Request) => Promise<Response>` you mount on any fetch-standard router — that authorizes the request, validates its JSON body against `providerRequestContract`, and streams one upstream `provider.stream` call back as newline-delimited `RelayFrame` records under `RELAY_CONTENT_TYPE`. `createRelayProvider` is the other end: a `ProviderInterface` the browser drives exactly like a local one, which posts the request and decodes those frames back into deltas and the settled result. `RelayStream` is the response half `createRelay` composes, exported for a host that mounts its own route.
417
421
 
418
422
  ```ts
@@ -440,7 +444,7 @@ const browser = createRelayProvider({
440
444
 
441
445
  The frame vocabulary is `RelayFrame`: a `ProviderDelta` (`content` or `thinking`) per streamed delta, one `{ channel: 'result', result }` when the turn settles, `{ channel: 'abort', partial }` when the upstream call was cancelled, and `{ channel: 'error', message }` for anything else — always the fixed `RELAY_PROVIDER_MESSAGE` text, so an upstream failure's own message never reaches the browser. A refusal never becomes a frame: the handler answers `401` when `authorize` refuses or throws, `400` when the body is missing, unreadable, or rejected by the contract, `413` when the body reaches the byte limit, and `502` when the upstream call cannot be constructed. Each refusal carries no body and reaches the browser as a `ProviderError` with the `HTTP` code and that status.
442
446
 
443
- The wire is strictly narrower than the domain on purpose. `providerRequestContract` and `relayFrameContract` are compiled from the shapes in [Shapes and contracts](#shapes-and-contracts), and `RelayProvider.body` refuses a request the JSON wire cannot carry — a function-valued tool argument, a parameter schema holding one — before it fetches. The body it sends is an owned snapshot of that projection read through property descriptors, so a serializer reachable only through a `get` trap or a prototype is never consulted; an own function-valued property such as a `toJSON` method is a value outside JSON and is refused before fetching, with the clone's failure as the refusal's `cause`. A `ToolCall.caller` is local context and never crosses the hop.
447
+ The wire is strictly narrower than the domain on purpose. `providerRequestContract` and `relayFrameContract` are compiled from the shapes in [Shapes and contracts](#shapes-and-contracts), and `RelayProvider.body` refuses a request the JSON wire cannot carry — a function-valued tool argument, a parameter schema holding one — before it fetches. The body it sends is an owned snapshot of that projection read through property descriptors, so a serializer reachable only through a `get` trap or a prototype is never consulted; an own function-valued property such as a `toJSON` method is a value outside JSON and is refused before fetching, with the clone's failure as the refusal's `cause`. A `ToolContext` stays with local execution; the call envelope carries only `id`, `name`, and `arguments`.
444
448
 
445
449
  ### Factories
446
450
 
@@ -973,7 +977,7 @@ The driver-backed implementation keeps an explicit table because its class name
973
977
  These invariants hold across `src/core` ↔ `agent.md`:
974
978
 
975
979
  1. **Doc ↔ source bijection.** Every `function` / `class` / `const` / `interface` / `type` row in the `## Surface` tables is a real export of `src/core`, and every export appears as a Surface row — exhaustive, both directions.
976
- 2. **`ProviderInterface` is the inference boundary; `AgentProvider` is the engine behind it.** A provider turns a conversation (plus optional `tools`, a non-empty `ToolDefinition[]`) into a turn: `generate` resolves the assembled `ProviderResult`, `stream` yields channel-tagged `ProviderDelta`s and returns the assembled result. Both methods accept optional `ProviderStreamOptions`; `think` is the per-call reasoning override. It carries an `id` (a per-instance trace label) and `name` (the backend identifier). This module defines that contract and the host-independent HTTP engine every provider over it would otherwise repeat — the deadline, the transport, the authorization hook, the bounded error read, the decode loop, reasoning separation, and result assembly (the provider-engine clause). One vendor's wire stays the concrete provider's own: it reaches the engine through `frame` / `body` / `read` / `finish`, and nothing in `src/core` names a vendor.
980
+ 2. **`ProviderInterface` is the inference boundary; `AgentProvider` is the engine behind it.** A provider turns a conversation (plus optional `tools`, a non-empty `ToolDefinition[]`) into a turn: `generate` resolves the assembled `ProviderResult`, `stream` yields channel-tagged `ProviderDelta`s and returns the assembled result. Both methods accept optional `ProviderStreamOptions`; `think` is the per-call reasoning override. It carries an `id` (a per-instance trace label) and `name` (the backend identifier). This module defines that contract and the host-independent HTTP engine every provider over it would otherwise repeat — the deadline, the transport, the authorization hook, the bounded error read, the decode loop, reasoning separation, and result assembly (the provider-engine clause). One vendor's wire stays the concrete provider's own: it reaches the engine through `frame` / `body` / `read` / `finish`, and nothing in `src/core` names a vendor. The loop runs with its tools in Node or in a page; a page can delegate inference to a Node relay while keeping tool execution local. See [Placement proofs](#placement-proofs) for each project and receipt.
977
981
  3. **`stream` yields deltas + returns the assembled result.** A `ProviderInterface.stream` yields each non-empty answer delta as `{ channel: 'content', text }` and each native live reasoning delta as `{ channel: 'thinking', text }`; its return value is the assembled `ProviderResult` whose `content` is the provider's authoritative clean answer, plus any tool calls and usage the turn reported. So a caller can render tokens and reasoning live while still recovering the complete outcome from the generator's return value. A thinking model's reasoning is separated once, at the wire that carried it. A provider reading a raw wire routes its content through a `ThinkSplitter` (`createThinkSplitter` — one per stream, armed by the base's `split` switch), yields the clean content, assembles `content` from the splitter's authoritative accumulation (an implicit pre-seeded open — the qwen3-template bare `</think>` — reclassifies the already-yielded prefix into thinking, the one shape where live content deltas can transiently over-report), and surfaces the accumulated reasoning as `ProviderResult.thinking` — which never re-enters the conversation (the `Agent` joins it across a run's calls onto `AgentResult.thinking` as display/audit metadata). A relay's deltas arrive already separated by the upstream provider that did that work, so `RelayProvider` constructs with `split: false` and preserves each delta verbatim, a literal `<think>` tag in the answer included: splitting a second time would re-classify text the upstream already ruled on (the relay-protocol clause).
978
982
  4. **Usage reuses `TokenUsage`.** `ProviderResult.usage` is the [budgets](budget.md) `TokenUsage` shape (`{ prompt, completion, total }`), imported not redefined, present only when the turn reported it — so a caller folds it straight into a token budget. A provider surfaces it when the wire carries it (for example a stream's `done` line / a non-stream body) and omits it otherwise.
979
983
  5. **The caller's signal and the engine's own deadline both bound the call.** Both `generate` and `stream` take an `AbortSignal`, so a caller bounds the request — a cancel, a [timeout](timeout.md), and a token [budget](budget.md) folded into one signal through `AbortSignal.any`. An already-aborted signal rejects the call before any content streams. `AgentProvider` arms a second bound the caller does not have to supply: a per-call `Timeout` of `AgentProviderInput.timeout` milliseconds (`DEFAULT_PROVIDER_TIMEOUT` when omitted), folded with the caller's signal through `AbortSignal.any`. Whichever trips first cancels the call, the deadline covers the `headers` hook and the error-body read as well as the stream, and it is cleared in a `finally` on every exit — a completion, a transport rejection, a non-OK response, a decoder failure, an early generator return, and a remote abort alike.
@@ -1013,9 +1017,9 @@ These invariants hold across `src/core` ↔ `agent.md`:
1013
1017
 
1014
1018
  34. **The provider engine and its seams (`AgentProvider`).** The instance owns a minted `id` (`crypto.randomUUID()`), taken in the constructor and returned by every call it serves, and `format` exposed exactly as supplied. The base owns, for each call: the deadline folded with the caller's signal (the bounding clause); `AgentProviderInput.fetch`, or the global transport bound to its global receiver; an awaited `headers` hook merged over a `Content-Type: application/json` default and raced against that combined bound, so an unresolved hook cannot outlive the call and its abort listener is released on every exit; one `POST` of `JSON.stringify(body(request))` to `url + (path ?? '')`; the response body decoded through `readChunks`; one `frame()` parser armed for that call; every framed record through `read`; the `finish(parser)` tail fed through `read` as well; the splitter flushed as a final content delta; and the outcome assembled by `buildProviderResult`. A subclass owns `name`, `frame`, `body`, `read`, and `finish`, and nothing else — an implementation that needs a second transport, a second deadline, or a second error taxonomy has left the seam rather than extended it. `split` (default `true`) arms one `ThinkSplitter` for the call; `strict` (default `false`) decides how a stream may end — with `strict: true` a stream that reaches end of input carrying no `ProviderIncrement.result` throws `ProviderError('PROTOCOL', 'provider error: missing settled result')`, and with `strict: false` the engine assembles the result from its accumulation instead. A record whose `read` returns a `result` is authoritative: the engine returns it at once without folding that record's other fields, leaves any later record undecoded, and cancels the body. **The bounded error read.** A non-OK response's body is decoded through `readText` to at most `MAX_ERROR_BODY_LENGTH` bytes and its remainder is cancelled, so a stalled error body is refused inside the deadline rather than after it. The bound counts bytes handed to the decoder. A source may deliver one chunk larger than the remaining budget; the read admits that chunk's leading bytes up to the budget and cancels the remainder, so the excerpt is decoded from at most `MAX_ERROR_BODY_LENGTH` source bytes and the read may have pulled one whole source chunk from the network. A multibyte character cut at that bound decodes to a replacement character, so the excerpt's own encoded length can exceed the bound by that character. **Reader-owned cancellation.** `readText` and `readChunks` each own their reader: each registers its abort listener on its own controller, cancels the source when the bound fires, and releases the lock in a `finally`, so a cancellation that itself fails never escapes and a body is never left locked. The parser clear and the deadline clear sit in nested `finally` blocks, so one failing cleanup never skips the other.
1015
1019
 
1016
- 35. **The wire shapes and the compiled contracts.** `toolCallShape`, `messageShape`, `providerRequestShape`, `providerResultShape`, and `relayFrameShape` declare each domain type's JSON projection, and `messageContract`, `providerRequestContract`, `providerResultContract`, and `relayFrameContract` compile them into guards and parsers. Every projection is strictly narrower than the domain type it mirrors, and the narrowing is the point: `ToolCall.arguments` is `Record<string, unknown>` in the domain and a JSON record on the wire, so a function-valued argument is refused rather than dropped silently, and a tool's `parameters` and a call's `schema` are refused the same way. `ToolCall.caller` is local context and appears in no shape — the guard refuses a record carrying it and the parser drops it, so it cannot cross a hop. `relayFrameShape` is a channel-discriminated union that admits no member outside the arm it matched, so a decorative field on an `error` frame is a refusal rather than a tolerated extra. The round trip is closed in both directions: the request `RelayProvider.body` projects parses back to the value the relay hands upstream, and the frame `RelayStream` writes parses back to the delta or the result the browser decodes. **The wire body is an owned snapshot.** `body` clones the projection through Contract's `cloneJSONValue` and validates that clone, so the bytes `JSON.stringify` produces are exactly what the guard saw. The clone reads property descriptors rather than accessors, so a serializer reachable only through a `get` trap or a prototype is never consulted; an own function-valued property on a call's `arguments`, a tool's `parameters`, or the options `schema` — such as a `toJSON` method — is a value outside JSON and is refused before fetching, with the clone's own failure as the refusal's `cause` — the snapshot is the sole mechanism, and the refusal that remains also covers a hostile read and a snapshot the contract rejects. A caller mutating its own objects after the call cannot change what was sent.
1020
+ 35. **The wire shapes and the compiled contracts.** `toolCallShape`, `messageShape`, `providerRequestShape`, `providerResultShape`, and `relayFrameShape` declare each domain type's JSON projection, and `messageContract`, `providerRequestContract`, `providerResultContract`, and `relayFrameContract` compile them into guards and parsers. Every projection is strictly narrower than the domain type it mirrors, and the narrowing is the point: `ToolCall.arguments` is `Record<string, unknown>` in the domain and a JSON record on the wire, so a function-valued argument is refused rather than dropped silently, and a tool's `parameters` and a call's `schema` are refused the same way. `ToolContext` is a separate execution argument and appears in no wire shape. An extra execution-context field on a call is refused by the wire guard and dropped by its parser. `relayFrameShape` is a channel-discriminated union that admits no member outside the arm it matched, so a decorative field on an `error` frame is a refusal rather than a tolerated extra. The round trip is closed in both directions: the request `RelayProvider.body` projects parses back to the value the relay hands upstream, and the frame `RelayStream` writes parses back to the delta or the result the browser decodes. **The wire body is an owned snapshot.** `body` clones the projection through Contract's `cloneJSONValue` and validates that clone, so the bytes `JSON.stringify` produces are exactly what the guard saw. The clone reads property descriptors rather than accessors, so a serializer reachable only through a `get` trap or a prototype is never consulted; an own function-valued property on a call's `arguments`, a tool's `parameters`, or the options `schema` — such as a `toJSON` method — is a value outside JSON and is refused before fetching, with the clone's own failure as the refusal's `cause` — the snapshot is the sole mechanism, and the refusal that remains also covers a hostile read and a snapshot the contract rejects. A caller mutating its own objects after the call cannot change what was sent.
1017
1021
 
1018
- 36. **The relay protocol (`createRelay` / `RelayStream` / `RelayProvider`).** The request is a `POST` whose body is one `providerRequestContract` JSON document — `{ messages, tools?, options? }` and nothing beyond it. The response is newline-delimited `RelayFrame` records under `RELAY_CONTENT_TYPE` with `cache-control: no-store`, written under response backpressure: `RelayStream` awaits one `provider.stream` step per pull, so a slow reader parks the upstream turn rather than buffering it. `RelayProvider` constructs on `split: false` and `strict: true`, so the browser end preserves each delta verbatim and treats a stream that ended without a `result` frame as a protocol failure rather than a silently assembled answer. **The frame vocabulary.** `{ channel: 'content' | 'thinking', text }` carries each delta, `{ channel: 'result', result }` carries the settled turn, `{ channel: 'abort', partial }` carries an upstream cancel with its partial, and `{ channel: 'error', message }` carries every other failure — `message` is always `RELAY_PROVIDER_MESSAGE`, so an upstream failure's own text never reaches the browser. **`authorize` and its obligations.** It is mandatory, it runs before the body is read, and it must not consume that body: a body-reading hook locks the stream and the handler answers `400`. The mechanism performs no origin check and no method check, so an application authorizing on an ambient credential such as a cookie must compose origin and CSRF middleware in front of the handler; a bearer header the browser sets explicitly is not reachable cross-site and needs no such composition. **The refusals**, each carrying no body: `401` when `authorize` returns anything but `true` or throws; `413` when the request body reaches `limit` bytes (`DEFAULT_RELAY_LIMIT` when omitted) or the inbound read is aborted — at the limit, not merely above it, because a body that fills the budget without reporting end of input is indistinguishable from one that exceeds it; `400` when the body is missing, unreadable, or rejected by the contract; `502` when the upstream provider call cannot be constructed. Each refusal reaches the browser through the engine's HTTP path as a `ProviderError` with code `'HTTP'`, that `status`, and the message `provider error: <status>` — no excerpt, because a refusal carries no body. **Cancellation runs both ways.** An inbound abort — the client disconnecting, or the request's own signal firing — aborts the upstream controller, returns the generator, and releases the request listener even with a frame still queued unread; a browser-side cancel reaches the same path through its `fetch` signal, and the local cancel wins over any frame still in flight (the local-and-remote-cancel clause). What a disconnected client cannot recover is the turn itself: the upstream call is cancelled, whatever streamed but never reached the socket is gone, nothing is replayed, and no frame is held for a reconnect. A client that needs a resumable turn owns that above the relay. **The honest browser limit.** Host independence is proven here by the core scope's typecheck (no host global is in scope), by the transport defaulting to the global `fetch` bound to its global receiver, and by a recorded Chrome 148 run of the built core entry: that entry and its whole `@orkestrel` import closure load as ES modules with no console error, and a browser-side `RelayProvider` round-trips a turn, cancels mid-stream and keeps its local partial, and receives a refused bearer as a `ProviderError` with code `'HTTP'` and status `401`. It is not proven by a browser test project, because this package has none.
1022
+ 36. **The relay protocol (`createRelay` / `RelayStream` / `RelayProvider`).** The request is a `POST` whose body is one `providerRequestContract` JSON document — `{ messages, tools?, options? }` and nothing beyond it. The response is newline-delimited `RelayFrame` records under `RELAY_CONTENT_TYPE` with `cache-control: no-store`, written under response backpressure: `RelayStream` awaits one `provider.stream` step per pull, so a slow reader parks the upstream turn rather than buffering it. `RelayProvider` constructs on `split: false` and `strict: true`, so the browser end preserves each delta verbatim and treats a stream that ended without a `result` frame as a protocol failure rather than a silently assembled answer. **The frame vocabulary.** `{ channel: 'content' | 'thinking', text }` carries each delta, `{ channel: 'result', result }` carries the settled turn, `{ channel: 'abort', partial }` carries an upstream cancel with its partial, and `{ channel: 'error', message }` carries every other failure — `message` is always `RELAY_PROVIDER_MESSAGE`, so an upstream failure's own text never reaches the browser. **`authorize` and its obligations.** It is mandatory, it runs before the body is read, and it must not consume that body: a body-reading hook locks the stream and the handler answers `400`. The mechanism performs no origin check and no method check, so an application authorizing on an ambient credential such as a cookie must compose origin and CSRF middleware in front of the handler; a bearer header the browser sets explicitly is not reachable cross-site and needs no such composition. **The refusals**, each carrying no body: `401` when `authorize` returns anything but `true` or throws; `413` when the request body reaches `limit` bytes (`DEFAULT_RELAY_LIMIT` when omitted) or the inbound read is aborted — at the limit, not merely above it, because a body that fills the budget without reporting end of input is indistinguishable from one that exceeds it; `400` when the body is missing, unreadable, or rejected by the contract; `502` when the upstream provider call cannot be constructed. Each refusal reaches the browser through the engine's HTTP path as a `ProviderError` with code `'HTTP'`, that `status`, and the message `provider error: <status>` — no excerpt, because a refusal carries no body. **Cancellation runs both ways.** An inbound abort — the client disconnecting, or the request's own signal firing — aborts the upstream controller, returns the generator, and releases the request listener even with a frame still queued unread; a browser-side cancel reaches the same path through its `fetch` signal, and the local cancel wins over any frame still in flight (the local-and-remote-cancel clause). What a disconnected client cannot recover is the turn itself: the upstream call is cancelled, whatever streamed but never reached the socket is gone, nothing is replayed, and no frame is held for a reconnect. A client that needs a resumable turn owns that above the relay. **The honest browser limit.** Host independence is proven here by the core scope's typecheck (no host global is in scope), by the transport defaulting to the global `fetch` bound to its global receiver, and by a recorded Chrome 148 run of the built core entry: that entry and its whole `@orkestrel` import closure load as ES modules with no console error. This package publishes no browser test project of its own. The browser-side `RelayProvider` round trip through `createRelay` and a refused bearer arriving as a `ProviderError` with code `'HTTP'` and status `401` are proven in `@orkestrel/ollama`'s service suite (`tests/service/page.test.ts`), run in a real Chromium against a live daemon with the page's requests recorded; a turn cancelled mid-stream keeping its local partial is proven in that suite's Node relay case (`tests/service/relay.test.ts`).
1019
1023
 
1020
1024
  37. **`ProviderError` and its codes.** `ProviderError` carries a machine-readable `code` and a `status` that is present for an `HTTP` failure and `undefined` under every other code; `ProviderErrorOptions` carries that `status` and the underlying `cause`. `isProviderError` narrows a caught value so a caller can branch on the code. `'HTTP'` reports a non-OK response: the message is `provider error: <status>`, and ` - <excerpt>` is appended only when the bounded error read returned text, so an empty body leaves no separator and no trailing space; when that read fails outright the message is `provider error: <status> - (error body unavailable)` and the read's failure rides as the `cause`. `'PROTOCOL'` reports a successful response with no body, a record the concrete provider refuses (a frame outside `relayFrameContract`, a request the JSON wire cannot carry), or a `strict` stream that ended with no settled result. `'PROVIDER'` reports an upstream failure a relay carried in an `error` frame, and its message is `RELAY_PROVIDER_MESSAGE`. A cancel is in none of them: it is `ProviderAbortError` (the local-and-remote-cancel clause).
1021
1025
 
@@ -1092,6 +1096,8 @@ That provider drives the whole runtime unchanged — `createAgent`, the tool loo
1092
1096
 
1093
1097
  ### Relaying a browser provider through your own server
1094
1098
 
1099
+ When you compose `createAgent(browser, { tools })` in the page, the agent dispatches page tools there after the Node provider returns a tool call. The Node server runs the relay handler and upstream inference. See [Placement proofs](#placement-proofs) for the planned Chromium receipt; the executed transcription described next proves an HTTP hop in Node.
1100
+
1095
1101
  A browser must never hold a model credential. Mount `createRelay` on your own server, where the credential already lives, and give the browser `createRelayProvider` pointed at that route: the browser drives a `ProviderInterface` like any other, and the credential never leaves the server. The handler is a plain `(request: Request) => Promise<Response>`, so any fetch-standard router mounts it — this composition mounts it on an `@orkestrel/router` dispatcher. Each half that follows runs in its own process: copy the server half into your server and the browser half into your browser bundle.
1096
1102
 
1097
1103
  The `@orkestrel/agent` package declares no dependency on a newline-delimited JSON parser, and no runtime dependency on a router or on a server adapter, so the browser application supplies the parser — `createNDJSONParser` from `@orkestrel/ndjson` here — and the server application supplies the router and the adapter. The `@orkestrel/router` and `@orkestrel/server` development dependencies this package declares serve the executed transcription of these fences in [`tests/guides.test.ts`](../tests/guides.test.ts). That transcription runs the server half and the browser half for real: it mounts the relay handler on the dispatcher route the server half declares, starts `@orkestrel/server` on a loopback listener, and drives the browser half against that listener over an HTTP hop in Node. The hop proves the round trip returning the upstream's settled result, the `405` with its `Allow: POST` header a `GET` to `/relay` answers, the `404` a `POST` to another path answers, the `401` a wrong bearer answers with the upstream provider unentered, and the upstream turn a disconnected reader cancels through the adapter's abort of the inbound request.
@@ -1189,6 +1195,50 @@ const result = await agent.generate()
1189
1195
  if (result.partial) keep(result.content) // a cancel RESOLVED partial, not an error
1190
1196
  ```
1191
1197
 
1198
+ ### Cancelling work inside a tool
1199
+
1200
+ Every handler receives `(args, context)`, with the run's bound signal at `context.signal`; handlers may omit unused parameters. The agent passes this context through the authority and no-authority dispatch branches and supplies no `caller` identity. An agent abort, a stream abort, an external signal, or the deadline can abort a running handler's signal. The token budget is charged during provider streaming and between turns, before tool dispatch; exhaustion ends the run without dispatching, never inside a handler.
1201
+
1202
+ When the signal has aborted before the tool block, the agent leaves the prior conversation intact: it appends neither an assistant turn carrying calls nor tool messages and emits no `tool` chunk. The streamed content remains in the result, which settles with `partial: true`. After dispatch begins, cancellation is cooperative: the handler must stop its own work, and the agent waits for a handler that ignores the signal, records its real result, and then settles the run with `partial: true`.
1203
+
1204
+ This fence uses a provider that requests the `wait` tool. The handler resolves from its abort listener, and the run commits the partial result:
1205
+
1206
+ ```ts
1207
+ import type { ProviderInterface } from '@orkestrel/agent'
1208
+ import { createAgent } from '@orkestrel/agent'
1209
+ import { createTool, createToolManager } from '@orkestrel/tool'
1210
+
1211
+ declare const provider: ProviderInterface // requests the wait tool
1212
+ const entered = Promise.withResolvers<void>()
1213
+ const cancelled = Promise.withResolvers<boolean>()
1214
+ const tools = createToolManager()
1215
+ tools.add(
1216
+ createTool({
1217
+ name: 'wait',
1218
+ execute: (_args, context) => {
1219
+ context.signal.addEventListener(
1220
+ 'abort',
1221
+ () => {
1222
+ cancelled.resolve(context.signal.aborted)
1223
+ },
1224
+ { once: true },
1225
+ )
1226
+ entered.resolve()
1227
+ return cancelled.promise
1228
+ },
1229
+ }),
1230
+ )
1231
+ const agent = createAgent(provider, { tools })
1232
+ const stream = agent.stream()
1233
+ await entered.promise
1234
+ agent.abort('request ended')
1235
+ await cancelled.promise // true — observed inside the handler
1236
+ const result = await stream.result
1237
+ result.partial // true
1238
+ ```
1239
+
1240
+ The executed transcription in [`tests/guides.test.ts`](../tests/guides.test.ts), “observes cancellation inside the handler as the tool cancellation fence claims”, supplies the scripted provider and asserts those values.
1241
+
1192
1242
  ### Bounding cost mid-stream (the token `budget`)
1193
1243
 
1194
1244
  An `AgentOptions.budget` (or a per-run override) is not only charged from each turn's final reported `usage` — the loop also charges it incrementally, mid-stream, from an estimated token count as content deltas arrive (the `estimateTokens` `ceil(length / 4)` heuristic), so a runaway completion trips the ceiling without waiting for the turn to finish. When the mid-stream estimate crosses the budget, the budget's `signal` fires, folding into the run's bound abort exactly like an external cancel or a `timeout` — the provider is cancelled, and the run resolves `partial: true` with an `abort` event (the same funnel as any other cancel).
@@ -1487,10 +1537,18 @@ void agent
1487
1537
  - [`tests/src/core/Authority.test.ts`](../tests/src/core/Authority.test.ts) — the `Authority` gate in isolation: ordered first-match-wins; a matched rule allows by default and denies on `allowed: false` (carrying `zone` / `reason`); no-match → the fallback; the default fallback is allow-`'default'`; an empty rules list always returns the fallback; a deny-by-default `fallback` makes unmatched calls denied (an allowlist); the matcher receives the `{ call }` context (branching on `call.name` and `call.arguments`).
1488
1538
  - [`tests/src/core/AgentProvider.test.ts`](../tests/src/core/AgentProvider.test.ts) — the shared HTTP engine over a scripted wire subclass and real `Response` bodies (no transport mock): identity and exact `format` exposure, the default transport bound to its global receiver, the posted body and case-insensitive header overrides, and a `headers` hook that adds only authentication leaving the JSON content type intact. Stream assembly — content, native reasoning, tool calls, and replacing usage; the qwen3 implicit-open reclassification in the authoritative result; a held content tail flushed as the final delta; verbatim content when `split` is disabled; a buffered record fed through `finish` with the parser cleared; a multibyte character split across byte chunks; a settled `result` record returned unchanged, accepted from `finish`, and leaving a following poison record undecoded with the body cancelled; and `strict` end of input with no settled result rejected. HTTP failures — the bounded error-body read with its remainder cancelled, the documented single-chunk overshoot, an exact-bound stalled body rejected before the deadline, the omitted separator for an empty excerpt, the retained status and cause when the body cannot be read at all, a successful response with no body as a protocol failure, and code / status / cause / `instanceof` narrowing. Failures that reach the caller unchanged — a hostile record decoder error and a remotely reported abort, the remote abort's identity preserved and its open body cancelled with the local signal unaborted. The `headers` hook inside the bound — raced against the deadline, a rejection preserved with no request issued, cancellation through the caller signal, and every abort listener removed after success, rejection, caller cancellation, and deadline expiry. Cancellation and partials — an already-aborted call rejected before framing or fetching, held content flushed into the partial, a stop between channels retaining the complete increment, a stalled body cancelled on the deadline, buffered `finish` records refused after a cancel, a transport `AbortError` normalized, a caller `ProviderAbortError` reason replaced by the local partial, a decoder failure that raced the cancel carried as the abort's `cause`, and that cause left `undefined` when the cancel was the only failure. Deadline clearing and reader release on every exit, and isolation between concurrent calls on one instance (including `generate` deep-equal to a drained `stream`).
1489
1539
  - [`tests/src/core/RelayStream.test.ts`](../tests/src/core/RelayStream.test.ts) — the relay's response half over real `Response` bodies: validated deltas and the authoritative result written with `RELAY_CONTENT_TYPE` and `cache-control: no-store`; an upstream failure reduced to the fixed `RELAY_PROVIDER_MESSAGE` with no secret text; a remote abort's partial written through the same compiled frame contract; a result outside the JSON frame contract refused; serialized `next` calls that stop pulling while the response queue is full (the backpressure proof); upstream aborted before the iterator returns with a late pending pull suppressed; an already-aborted inbound signal linked before the upstream turn starts; and an inbound abort with a frame still queued unread returning and finalizing the generator.
1490
- - [`tests/src/core/providers/RelayProvider.test.ts`](../tests/src/core/providers/RelayProvider.test.ts) — the browser end: a synthetic `parameters`, `schema`, or call-`arguments` serializer ignored with the snapshot sent in its place, a projection failure carried as the refusal's `cause`, a valid request owned as a snapshot whose JSON values survive caller mutation, an `error` frame carrying a decorative `code` refused, the declared request fields projected with `caller` omitted across a JSON round trip, non-JSON arguments / parameters / schemas rejected, validated `content` and `thinking` frames mapped with literal `<think>` tags preserved, an `abort` frame reconstructed with its complete partial while the local signal stays unaborted, malformed frames and a missing terminal result refused, and a fragmented unterminated final result recovered with fresh framing for each call.
1540
+ - [`tests/src/core/providers/RelayProvider.test.ts`](../tests/src/core/providers/RelayProvider.test.ts) — the browser end: a synthetic `parameters`, `schema`, or call-`arguments` serializer ignored with the snapshot sent in its place, a projection failure carried as the refusal's `cause`, a valid request owned as a snapshot whose JSON values survive caller mutation, an `error` frame carrying a decorative `code` refused, the declared request fields projected with extra execution context omitted across a JSON round trip, non-JSON arguments / parameters / schemas rejected, validated `content` and `thinking` frames mapped with literal `<think>` tags preserved, an `abort` frame reconstructed with its complete partial while the local signal stays unaborted, malformed frames and a missing terminal result refused, and a fragmented unterminated final result recovered with fresh framing for each call.
1491
1541
  - [`tests/src/core/integration.test.ts`](../tests/src/core/integration.test.ts) — the in-process hop, browser end to server end with no network: each refusal (`401`, `413`, `400`) carried to the browser as a `ProviderError` with its status while the upstream provider is never entered; a full round trip of identified messages, tools, options, deltas, and the authoritative result, deep-equal to the same provider driven directly; browser cancellation propagated through the request and upstream signals; a server-side abort reconstructed while the browser signal stays unaborted; and a secret upstream failure translated to the fixed public provider error. Beside it, provider-agnosticism over a minimal provider driving the full loop, a drop-in swap between differently named providers, and each provider's `format` reaching `build()`.
1492
1542
  - [`tests/src/core/contracts.test.ts`](../tests/src/core/contracts.test.ts) — the compiled wire contracts: a message, a request, a result, and every relay channel round-tripped, each reporting the path of its malformed field; function-valued tool arguments accepted in the domain and refused on the wire; non-JSON parameters and schemas refused before serialization can drop them; and every accepted message fixture still inside the domain guard.
1493
- - [`tests/src/core/shapers.test.ts`](../tests/src/core/shapers.test.ts) — the wire shapes at the type level: each projection inferred assignable to the domain type it mirrors, and `ToolCall.caller` excluded from the tool wire shape.
1543
+ - [`tests/src/core/shapers.test.ts`](../tests/src/core/shapers.test.ts) — the wire shapes at the type level: each projection inferred assignable to the domain type it mirrors, and execution context kept separate from the call envelope and excluded from the tool wire shape.
1544
+
1545
+ ### Placement proofs
1546
+
1547
+ Each placement has a named proof location:
1548
+
1549
+ - **Node alone.** The agent, provider, and tools run in Node. The `src:core` project executes “runs the agent tool loop in Node and feeds the result into the next provider turn” in [`tests/src/core/integration.test.ts`](../tests/src/core/integration.test.ts).
1550
+ - **Page alone.** The agent, an in-page provider, and page tools run in the page. The receipt is planned in the `@orkestrel/mcp` checkout's `tests/distribution.test.ts` file, in the `distribution` project: the planned proof “executes a page tool through an agent without network requests”. That receipt must come from installed artifacts in Chromium; this guide does not record it as passed.
1551
+ - **Page to Node relay.** The agent, relay provider, and page tools run in the page; `createRelay` and the upstream provider run in Node. The receipt is planned in the same `@orkestrel/mcp` distribution proof file: the planned proof “executes a page tool through an agent over a Node relay”. This guide does not record that Chromium receipt as passed. The guide transcription's HTTP hop runs in Node and proves that narrower placement.
1494
1552
 
1495
1553
  ## See also
1496
1554