@orkestrel/scaffold 0.0.103 → 0.0.104

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.
Files changed (88) hide show
  1. package/dist/agents/skills/orkestrel-dispatch/scripts/helpers.js +84 -1
  2. package/dist/agents/skills/orkestrel-dispatch/scripts/launch.js +21 -6
  3. package/dist/agents/skills/orkestrel-harden/scripts/titles.js +338 -0
  4. package/dist/agents/skills/orkestrel-publish/scripts/swap.js +3 -39
  5. package/dist/agents/skills/orkestrel-publish/scripts/wave.js +433 -47
  6. package/dist/agents/skills/orkestrel-publish/scripts/window.js +2 -1
  7. package/dist/bin/main.js +16 -9
  8. package/dist/bin/main.js.map +1 -1
  9. package/dist/host/agents/orchestration.md +3 -3
  10. package/dist/host/agents/skills/enterprise-bootstrap/references/components.md +2 -2
  11. package/dist/host/agents/skills/enterprise-bootstrap/references/inspection.md +1 -1
  12. package/dist/host/agents/skills/orkestrel-build/SKILL.md +2 -2
  13. package/dist/host/agents/skills/orkestrel-debrief/SKILL.md +2 -2
  14. package/dist/host/agents/skills/orkestrel-debrief/references/field-testing.md +1 -1
  15. package/dist/host/agents/skills/orkestrel-debrief/references/instruction-audit.md +3 -3
  16. package/dist/host/agents/skills/orkestrel-dispatch/SKILL.md +10 -10
  17. package/dist/host/agents/skills/orkestrel-dispatch/references/launch.md +1 -0
  18. package/dist/host/agents/skills/orkestrel-dispatch/scripts/helpers.ts +100 -1
  19. package/dist/host/agents/skills/orkestrel-dispatch/scripts/launch.ts +27 -6
  20. package/dist/host/agents/skills/orkestrel-falsify/SKILL.md +4 -4
  21. package/dist/host/agents/skills/orkestrel-falsify/agents/openai.yaml +1 -1
  22. package/dist/host/agents/skills/orkestrel-falsify/references/brief.md +8 -7
  23. package/dist/host/agents/skills/orkestrel-falsify/references/reconcile.md +6 -6
  24. package/dist/host/agents/skills/orkestrel-harden/SKILL.md +6 -1
  25. package/dist/host/agents/skills/orkestrel-harden/references/bindings.md +1700 -0
  26. package/dist/host/agents/skills/orkestrel-harden/references/contract.md +2 -2
  27. package/dist/host/agents/skills/orkestrel-harden/references/equivalence.md +48 -0
  28. package/dist/host/agents/skills/orkestrel-harden/references/hardening.md +4 -2
  29. package/dist/host/agents/skills/orkestrel-harden/scripts/titles.ts +425 -0
  30. package/dist/host/agents/skills/orkestrel-journey/SKILL.md +2 -2
  31. package/dist/host/agents/skills/orkestrel-journey/references/captures.md +9 -8
  32. package/dist/host/agents/skills/orkestrel-polish/SKILL.md +3 -3
  33. package/dist/host/agents/skills/orkestrel-publish/SKILL.md +2 -2
  34. package/dist/host/agents/skills/orkestrel-publish/references/release.md +1 -0
  35. package/dist/host/agents/skills/orkestrel-publish/references/wave.md +56 -13
  36. package/dist/host/agents/skills/orkestrel-publish/references/window.md +6 -6
  37. package/dist/host/agents/skills/orkestrel-publish/scripts/swap.ts +5 -49
  38. package/dist/host/agents/skills/orkestrel-publish/scripts/wave.ts +646 -49
  39. package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +2 -1
  40. package/dist/host/agents/transports/codex.md +1 -1
  41. package/dist/host/claude/agents/orkestrel.md +54 -53
  42. package/dist/host/claude/agents/reviewer.md +4 -4
  43. package/dist/host/claude/rules/documentation.md +2 -1
  44. package/dist/host/claude/rules/patterns.md +1 -1
  45. package/dist/host/claude/rules/quality.md +2 -2
  46. package/dist/host/claude/rules/styles.md +1 -1
  47. package/dist/host/claude/rules/tests.md +34 -8
  48. package/dist/host/claude/rules/typescript.md +29 -0
  49. package/dist/host/claude/rules/workspace.md +32 -22
  50. package/dist/host/claude/rules/writing.md +34 -26
  51. package/dist/host/claude/settings.json +2 -0
  52. package/dist/host/claude/skills/orkestrel-debrief/SKILL.md +1 -1
  53. package/dist/host/claude/skills/orkestrel-falsify/SKILL.md +1 -1
  54. package/dist/host/codex/agents/analyst.toml +3 -3
  55. package/dist/host/codex/agents/reviewer.toml +4 -4
  56. package/dist/host/configs/policy.ts +13 -2
  57. package/dist/host/dotfiles/editorconfig +0 -1
  58. package/dist/host/dotfiles/gitattributes +0 -1
  59. package/dist/host/dotfiles/gitignore +0 -1
  60. package/dist/host/dotfiles/oxlintrc.json +24 -0
  61. package/dist/host/guides/agent.md +1638 -598
  62. package/dist/host/guides/budget.md +89 -29
  63. package/dist/host/guides/form.md +477 -91
  64. package/dist/host/guides/mcp.md +14 -2
  65. package/dist/host/guides/ollama.md +97 -73
  66. package/dist/host/guides/probe.md +1 -1
  67. package/dist/host/guides/scaffold.md +158 -101
  68. package/dist/host/guides/study.md +2635 -0
  69. package/dist/host/guides/table.md +225 -15
  70. package/dist/host/guides/terminal.md +22 -20
  71. package/dist/host/guides/test.md +689 -255
  72. package/dist/host/manifest.json +87 -63
  73. package/dist/host/tests/config.test.ts +136 -82
  74. package/dist/host/tests/policy.test.ts +39 -1
  75. package/dist/host/tests/setupPolicy.ts +86 -28
  76. package/dist/src/core/index.cjs +1387 -611
  77. package/dist/src/core/index.cjs.map +1 -1
  78. package/dist/src/core/index.d.cts +35 -20
  79. package/dist/src/core/index.d.ts +35 -20
  80. package/dist/src/core/index.js +1385 -612
  81. package/dist/src/core/index.js.map +1 -1
  82. package/dist/src/server/index.cjs +12 -12
  83. package/dist/src/server/index.cjs.map +1 -1
  84. package/dist/src/server/index.d.cts +12 -12
  85. package/dist/src/server/index.d.ts +12 -12
  86. package/dist/src/server/index.js +12 -12
  87. package/dist/src/server/index.js.map +1 -1
  88. package/package.json +4 -4
@@ -6,24 +6,24 @@
6
6
  > assembly — and the bounded context → provider → tools → repeat loop that carries a turn to
7
7
  > its end.
8
8
 
9
- An agent is a conversation with a model and the loop that carries it forward. A `Conversation` holds the history — a live tail of immutable messages plus the sections older turns were compacted into. An `AgentContext` assembles that history into the next prompt, folding in the instructions and the active workspace and applying the active scope. An `Agent` drives that prompt through a provider, dispatches requested tools admitted by scope and authority, feeds the results back, and repeats until the model stops. Everything else in this module either configures those nouns or observes them. Source: [`src/core`](../src/core). Published through `@orkestrel/agent`.
9
+ An agent is a conversation with a model and the loop that carries it forward. A `Conversation` holds the history — a live tail of immutable messages plus the sections older turns were compacted into. An `AgentContext` assembles that history into the next prompt, folding in the instructions and the active workspace and applying the active scope. An `Agent` drives that prompt through a provider, dispatches requested tools admitted by scope and authority, feeds the results back, and repeats until the model stops. A `Ledger` composes all three to serve one conversation through a briefing projected from what a judge filed. Everything else in this module either configures those nouns or observes them. Source: [`src/core`](../src/core). Published through `@orkestrel/agent`.
10
10
 
11
- Hand a provider a conversation and get back one assembled `ProviderResult` (`generate`), or a live stream of channel-tagged `ProviderDelta`s that returns that same assembled result when it ends (`stream`). Reasoning separation, the authority gate, and durable jobs sit around that boundary. The model itself is the one thing this package does not supply. `ProviderInterface` stays the boundary any backend can satisfy, and `AgentProvider` is the host-independent HTTP engine a concrete provider extends rather than rewrites: it owns the deadline, the transport, the bounded error read, the framing loop, reasoning separation, and result assembly, and the subclass fills in one vendor's wire. `RelayProvider` and `createRelay` carry that same boundary across your own server, so a browser drives a model it holds no credential for. There is no hidden global state, a plugin lifecycle, a prompt-template DSL, or an implicit memory store. This is a kit of composable primitives: the loop is the convenient way to use them, not the only one, and a caller that would rather bound and drive a provider by hand can skip it entirely.
11
+ Hand a provider a conversation and get back one assembled `ProviderResult` (`generate`), or a live stream of channel-tagged `ProviderDelta` values that returns that same assembled result when it ends (`stream`). Reasoning separation, the authority gate, and durable jobs sit around that boundary. The model itself is the one thing this package doesn't supply. `ProviderInterface` stays the boundary any backend can satisfy, and `AgentProvider` is the host-independent HTTP engine a concrete provider extends rather than rewrites: it owns the deadline, the transport, the bounded error read, the framing loop, reasoning separation, and result assembly, and the subclass fills in one vendor's wire. `RelayProvider` and `createRelay` carry that same boundary across your own server, so a browser drives a model it holds no credential for. There is no hidden global state, a plugin lifecycle, a prompt-template DSL, or an implicit memory store. This is a kit of composable primitives: the loop is the convenient way to use them, not the only one, and a caller that would rather bound and drive a provider by hand can skip it entirely.
12
12
 
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.
13
+ Tools and files are borrowed, not owned. Callable tools come from the `@orkestrel/tool` package (see [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 the `@orkestrel/workspace` package (see [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 defines what a turn contains.
14
14
 
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.
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) and `allowance` signals through `AbortSignal.any` and bounds the provider. A handler's `context.signal` is that same signal. The allowance is charged during provider streaming and the budget after each provider call 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
16
 
17
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.
18
18
 
19
19
  ## Surface
20
20
 
21
- The agent-owned surface: the inference boundary and the HTTP engine behind it, the relay that carries that boundary to a browser, the judge boundary with its HTTP engine and the System One wire, the conversation layer, the context and its managers, the loop, the authority gate, and the durable-job bridge. Tool and workspace entities belong to their originating packages and are consumed directly — never re-exported here — and one vendor's wire belongs to the concrete provider that extends `AgentProvider`.
21
+ The agent-owned surface: the inference boundary and the HTTP engine behind it, the relay that carries that boundary to a browser, the judge boundary with its HTTP engine and the System One wire, the conversation layer, the context and its managers, the loop, the authority gate, the durable-job bridge, and the ledger that serves a conversation through a judge's filing. Tool and workspace entities belong to their originating packages and are consumed directly — never re-exported here — and one vendor's wire belongs to the concrete provider that extends `AgentProvider`.
22
22
 
23
- A provider turns a conversation (plus optional tools) into a turn: `generate` resolves the assembled `ProviderResult` (content + any tool calls + any usage); `stream` yields channel-tagged `ProviderDelta`s as they arrive (`content` for answer text, `thinking` for live reasoning) and returns the same assembled result when the stream completes, so a caller can render tokens / reasoning live and still get the full outcome. Both bound the call with an `AbortSignal`:
23
+ A provider turns a conversation (plus optional tools) into a turn: `generate` resolves the assembled `ProviderResult` (content + any tool calls + any usage); `stream` yields channel-tagged `ProviderDelta` values as they arrive (`content` for answer text, `thinking` for live reasoning) and returns the same assembled result when the stream completes, so a caller can render tokens / reasoning live and still get the full outcome. Both bound the call with an `AbortSignal`:
24
24
 
25
25
  ```ts
26
- import type { ProviderInterface } from '@orkestrel/agent'
26
+ import type { ProviderDelta, ProviderInterface, ProviderResult } from '@orkestrel/agent'
27
27
  import { createAbort } from '@orkestrel/abort'
28
28
 
29
29
  declare const provider: ProviderInterface // any concrete implementation supplied by the host app
@@ -34,19 +34,24 @@ const result = await provider.generate(messages, abort.signal)
34
34
  result.content // the assembled content
35
35
  result.usage // { prompt, completion, total } — folds into a token budget
36
36
 
37
- const generator = provider.stream(messages, abort.signal)
38
- let step = await generator.next()
39
- while (!step.done) {
40
- if (step.value.channel === 'content') process.stdout.write(step.value.text)
41
- if (step.value.channel === 'thinking') process.stderr.write(step.value.text)
42
- step = await generator.next()
37
+ // Renders each delta live and returns the assembled result the stream settles on.
38
+ async function print(
39
+ stream: AsyncGenerator<ProviderDelta, ProviderResult>,
40
+ ): Promise<ProviderResult> {
41
+ for (;;) {
42
+ const step = await stream.next()
43
+ if (step.done) return step.value
44
+ if (step.value.channel === 'content') process.stdout.write(step.value.text)
45
+ if (step.value.channel === 'thinking') process.stderr.write(step.value.text)
46
+ }
43
47
  }
44
- const streamed = step.value // the assembled ProviderResult (content === the joined content deltas)
48
+
49
+ const streamed = await print(provider.stream(messages, abort.signal)) // the assembled ProviderResult (content === the joined content deltas)
45
50
  ```
46
51
 
47
- Pass `tools` (a non-empty `ToolDefinition[]`) to advertise callable tools for the turn; when the model calls one, `result.tools` is a `ToolCall[]` (each with a guaranteed `id`, the tool `name`, and parsed `arguments`). Aborting a `stream` mid-flight throws a `ProviderAbortError` whose `partial` holds whatever streamed before the cancel.
52
+ Pass `tools` (a non-empty `ToolDefinition[]`) to advertise callable tools for the turn; when the model calls one, `result.tools` is a `ToolCall[]` (each with an `id`, the tool `name`, and parsed `arguments`). Aborting a `stream` mid-flight throws a `ProviderAbortError` whose `partial` holds whatever streamed before the cancel.
48
53
 
49
- A tool is a JSON-Schema-described callable from the [`@orkestrel/tool` package](tool.md). The registry's `definitions()` method advertises tools, and its `execute` method dispatches calls. When a turn advertises tools, the loop refuses a name excluded by the scope's tool allow-list before reaching the registry, with the scope denial. Without a tool allow-list, an unadvertised name passes through the authority gate and reaches the registry's `tool not found` failure if authority admits it. A name included in the allow-list also reaches the registry if authority admits it, even if unregistered. The returned `ToolResult` value uses the `success` discriminant: an unknown name and a throwing handler produce the failure arm, and batch execution isolates each call from its siblings. Direct registry calls behave as follows:
54
+ A tool is a JSON-Schema-described callable from the `@orkestrel/tool` package (see [Tool](tool.md)). The registry's `definitions()` method advertises tools, and its `execute` method dispatches calls. When a turn advertises tools, the loop refuses a name excluded by the scope's tool allow-list before reaching the registry, with the scope denial. Without a tool allow-list, an unadvertised name passes through the authority gate and reaches the registry's `tool not found` failure if authority admits it. A name included in the allow-list also reaches the registry if authority admits it, even if unregistered. The returned `ToolResult` value uses the `success` discriminant: an unknown name and a throwing handler produce the failure arm, and batch execution isolates each call from its siblings. Direct registry calls behave as follows:
50
55
 
51
56
  ```ts
52
57
  import { createTool, createToolManager } from '@orkestrel/tool'
@@ -69,9 +74,9 @@ const results = await tools.execute([
69
74
  ])
70
75
  ```
71
76
 
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).
77
+ 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`. For registration, advertising, dispatch, and error containment, see [Tool](tool.md).
73
78
 
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:
79
+ 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 call `build()` to produce 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:
75
80
 
76
81
  ```ts
77
82
  import type { ProviderInterface } from '@orkestrel/agent'
@@ -124,7 +129,7 @@ for await (const chunk of stream.events) {
124
129
  const result = await stream.result // { content, usage?, partial } — usage summed across the turn
125
130
  ```
126
131
 
127
- Both `generate` and `stream` accept optional per-run `AgentRunOptions` — `think` and `schema` (forwarded to the provider as `ProviderStreamOptions`) plus `limit` / `timeout` / `budget` / `signal`, each overriding its `AgentOptions` construction default for this run only. Omitting one keeps the constructed default, so a caller that passes no options gets the agent it configured. A per-run `signal` composes with (never replaces) a constructed `signal` — either aborting cancels the run; a per-run `budget` is `start()`ed for that run and is the one the loop charges, leaving a constructed `budget` untouched:
132
+ Both `generate` and `stream` accept optional per-run `AgentRunOptions` — `think` and `schema` (forwarded to the provider as `ProviderStreamOptions`) plus `limit` / `timeout` / `budget` / `allowance` / `signal`, each overriding its `AgentOptions` construction default for this run only. Omitting one keeps the constructed default, so a caller that passes no options gets the agent it configured. A per-run `signal` composes with (never replaces) a constructed `signal` — either aborting cancels the run; the loop calls `start()` on a per-run `budget` or `allowance` for that run and charges it, leaving a constructed one untouched:
128
133
 
129
134
  ```ts
130
135
  const agent = createAgent(provider, { tools, limit: 10, timeout: 60_000 }) // construction defaults
@@ -140,14 +145,15 @@ const result = await agent.generate({
140
145
 
141
146
  `schema`, like `think`, rides into `provider.stream` as a `ProviderStreamOptions`: the loop composes both into one options object, omitting whichever key is unset, and passes no options object at all when neither is present — a provider that never received one still never does.
142
147
 
143
- The turn is bounded by one cancel folded from the external `signal` + the `timeout` deadline + the `budget` signal through `AbortSignal.any`; `agent.abort(reason)` (or `stream.abort(reason)`) fires it. A cancel — external, deadline, budget, or `abort()` — commits a partial result: the `result` promise resolves with `{ partial: true, content: <what accumulated> }`, never rejects (a cancel is not an error); only a genuine provider / tool error rejects. An optional `scheduler.yield`s between turns; tool iteration is capped at `limit` (default `DEFAULT_AGENT_LIMIT`). Exhausting `limit` while the model still holds unresolved tool intent (it requested tools on the very last allowed turn) is a distinct, non-cancel cause of `partial: true` — it fires an `exhaust` event (the turns reached) instead of `abort`. A natural finish on the last allowed turn, or `limit: 0` (which never enters the loop), stays `partial: false`. `agent.status` transitions `idle` → `running` → `done` / `error`.
148
+ The turn is bounded by one cancel folded from the external `signal` + the `timeout` deadline + the `budget` and `allowance` signals through `AbortSignal.any`; `agent.abort(reason)` (or `stream.abort(reason)`) fires it. A cancel — external, deadline, budget, allowance, or `abort()` — commits a partial result: the `result` promise resolves with `{ partial: true, content: <what accumulated> }`, never rejects (a cancel isn't an error); only a genuine provider / tool error rejects. A genuine provider error after streaming still charges the `budget` the failed call's streamed-answer estimate, once, before the run rejects. The loop yields to an optional `scheduler` between turns; tool iteration is capped at `limit` (default `DEFAULT_AGENT_LIMIT`). Exhausting `limit` while the model still holds unresolved tool intent (it requested tools on the very last allowed turn) is a distinct, non-cancel cause of `partial: true` — it fires an `exhaust` event (the turns reached) instead of `abort`. A natural finish on the last allowed turn, or `limit: 0` (which never enters the loop), stays `partial: false`. `agent.status` transitions `idle` → `running` → `done` / `error`.
144
149
 
145
- Gate the model's tool calls with an optional `Authority` (`createAuthority`) — a synchronous policy gate the loop consults after scope admits a call, passed through `AgentOptions.authority`. It walks ordered `rules` first-match-wins (a matched rule allows unless its `allowed` is `false`), falling back to a configurable default when none match — allow-unmatched by default (a denylist), or deny-by-default when its `fallback` denies (an allowlist). A denied call is never executed: the loop synthesizes the failure arm of `ToolResult` (`error: 'denied: <reason>'`) and feeds it back as a `tool` chunk and a tool message, so no handler runs, no budget is spent, and the model still sees what happened and can choose something else. An allowed call dispatches normally, and with no `authority` set every scope-admitted call dispatches:
150
+ Gate the model's tool calls with an optional `Authority` (`createAuthority`) — a synchronous policy gate the loop consults after scope admits a call, passed through `AgentOptions.authority`. It walks ordered `rules` first-match-wins (a matched rule allows unless its `allowed` is `false`), falling back to a configurable default when none match — allow-unmatched by default (a denylist), or deny-by-default when its `fallback` denies (an allowlist). A denied call is never executed: the loop synthesizes the failure arm of `ToolResult` (`error: 'denied: <reason>'`) and feeds it back as a `tool` chunk and a tool message, so no handler runs, no budget is spent, and the model still receives the denial and can request another call. An allowed call dispatches normally, and with no `authority` set every scope-admitted call dispatches:
146
151
 
147
152
  ```ts
148
153
  import { createAgent, createAuthority } from '@orkestrel/agent'
149
154
  import { createTool, createToolManager } from '@orkestrel/tool'
150
155
 
156
+ // `provider` and `drop` come from the host application.
151
157
  const tools = createToolManager()
152
158
  tools.add([
153
159
  createTool({ name: 'add', execute: (args) => Number(args.a) + Number(args.b) }),
@@ -185,19 +191,21 @@ instructions.open // '## Instructions'
185
191
  instructions.render(safety) // 'Refuse unsafe requests.'
186
192
  ```
187
193
 
188
- Documents reach a turn one way only: through the active workspace. A [`@orkestrel/workspace`](workspace.md) workspace is a flat map of immutable files, and it takes no position on prompts — deciding how a file becomes part of a turn is this package's job, and the decision is a split by carrier. A text file renders as a fenced reference block in a `## Workspace` system section, where the model can read it as quoted material. An image file cannot be text, so its `base64` payload attaches to the last user message instead, which is where a vision model looks. A message carries that payload on its optional `images` field — `Message` and `MessageInput` both accept `readonly images?: readonly string[]` — and a vision-capable provider forwards it onto the wire (an empty or absent array is never sent). It is input-only; `ProviderResult` is unchanged.
194
+ Documents reach a turn one way only: through the active workspace. A workspace from the `@orkestrel/workspace` package (see [Workspace](workspace.md)) is a flat map of immutable files, and it takes no position on prompts — deciding how a file becomes part of a turn is this package's job, and the decision is a split by carrier. A text file renders as a fenced reference block in a `## Workspace` system section, where the model can read it as quoted material. An image file can't be text, so its `base64` payload attaches to the last user message instead, the turn a vision provider reads images from. A message carries that payload on its optional `images` field — `Message` and `MessageInput` both accept `readonly images?: readonly string[]` — and a vision-capable provider forwards it onto the wire (an empty or absent array is never sent). It is input-only; `ProviderResult` is unchanged. In the following example, the `IMAGE_BASE64` placeholder stands for the base64 text of one image.
189
195
 
190
196
  ```ts
191
197
  import type { ProviderInterface } from '@orkestrel/agent'
192
198
 
199
+ // IMAGE_BASE64 stands for the base64 text of one image.
200
+ // `abort` is an AbortController supplied by the host application.
193
201
  declare const provider: ProviderInterface // a vision-capable model
194
202
  const result = await provider.generate(
195
- [{ id: '1', role: 'user', content: 'Describe this image.', images: ['<payload>'] }],
203
+ [{ id: '1', role: 'user', content: 'Describe this image.', images: ['IMAGE_BASE64'] }],
196
204
  abort.signal,
197
205
  )
198
206
  ```
199
207
 
200
- `AgentContext` wires the instruction manager and the workspace registry in. Beyond `system`, `messages`, and `tools`, a context exposes its own `instructions` manager and `workspaces` registry — pass pre-built ones through `AgentContextOptions`, or fresh empty ones are created — and `build()` folds them into the turn. The assembly order is one leading `system` message holding the system prompt, then the non-empty instructions block (its `description` header followed by every item's `format`), then the active workspace's text files under a `## Workspace` header, joined by blank lines; then the conversation. With no instructions, no active workspace, and no scope, that reduces to exactly the lean `[systemMessage?, ...messages]`. The carrier split shows here: text rides the system block, image data rides the last user message.
208
+ `AgentContext` wires the instruction manager and the workspace registry in. Beyond `system`, `messages`, and `tools`, a context exposes its own `instructions` manager and `workspaces` registry — pass pre-built ones through `AgentContextOptions`, or fresh empty ones are created — and `build()` folds them into the turn. The assembly order is one leading `system` message holding the system prompt, then the non-empty instructions block (its `open` header, each item's `render` output, and its `close` line when set), then the active workspace's text files under a `## Workspace` header, then the non-empty `briefing` member of a selection passed to `build` without a `fault`, joined by blank lines; then the conversation. With no instructions, no active workspace, and no scope, that reduces to exactly the lean `[systemMessage?, ...messages]`. The carrier split shows here: text rides the system block, image data rides the last user message.
201
209
 
202
210
  ````ts
203
211
  import { createAgentContext } from '@orkestrel/agent'
@@ -215,7 +223,7 @@ const input = context.build()
215
223
 
216
224
  ### Conversations & compaction
217
225
 
218
- Above the flat `MessageManagerInterface` sits the `Conversation` (`createConversation` / a `ConversationManager`) — it owns its messages directly and compacts older ones into summarized `sections` so a long history fits a turn's context window without discarding the originals. Append turns through the conversation's own `add` (the live uncompacted tail; `message` / `messages` / `remove` / `clear` / `count` round it out); `compact()` folds the older live messages into a summarized `Section` (retaining their originals), regenerates the conversation rollup `summary`, and shrinks `view()` — the model input, where each section becomes one summary message followed by the live tail. Compaction is driven by a provider-agnostic `ConversationSummaryHandler` seam (`(messages) => Promise<string>`) the agent runtime supplies, so a `compact()` without one throws a `ConversationError`. `keep` retains a recent tail (default `DEFAULT_CONVERSATION_KEEP` = `0`, fold all); `rehydrate(id)` / `search(query)` read the retained originals:
226
+ Above the flat `MessageManagerInterface` sits the `Conversation` (`createConversation` / a `ConversationManager`) — it owns its messages directly and compacts older ones into summarized `sections` so a long history fits a turn's context window without discarding the originals. Append turns through the conversation's own `add` (the live uncompacted tail; `message` / `messages` / `remove` / `clear` / `count` round it out); `compact()` folds the older live messages into a summarized `Section` (retaining their originals) and shrinks `view()` — the model input, where each section becomes one summary message followed by the live tail. Compaction is driven by a provider-agnostic `ConversationSummaryHandler` seam (`(messages, signal?) => Promise<string>`) the agent runtime supplies, so a `compact()` without one throws a `ConversationError`. `keep` retains a recent tail (default `DEFAULT_CONVERSATION_KEEP` = `0`, no retained tail); `rehydrate(id)` / `search(query)` read the retained originals. A fold never takes the newest user message or any message after it, because that message is the request a run serves. An exchange is a user message and every message after it up to the next user message. The messages before the first user message form their own exchange, which folds only together with the first user exchange. A fold removes whole exchanges: a cut inside an exchange moves back to its start. A fold never splits an assistant message with calls from the tool messages that answer it, matched by each tool message's `call` member with a positional fallback: a cut inside that group moves before the assistant message. When those rules leave nothing to fold, `compact()` returns `undefined`:
219
227
 
220
228
  ```ts
221
229
  import type { ProviderInterface } from '@orkestrel/agent'
@@ -226,45 +234,49 @@ declare const provider: ProviderInterface // any concrete implementation supplie
226
234
  // Append the instruction as the FINAL user turn: a chat model emits nothing when the prompt
227
235
  // ends on an assistant turn, so a leading-system instruction is unreliable.
228
236
  const conversation = createConversation({
229
- summarize: async (messages) =>
237
+ summarize: async (messages, signal) =>
230
238
  (
231
239
  await provider.generate(
232
240
  [
233
241
  ...messages,
234
242
  { id: 's', role: 'user', content: 'Summarize the conversation so far concisely.' },
235
243
  ],
236
- AbortSignal.timeout(30_000),
244
+ signal ?? AbortSignal.timeout(30_000), // the compact() call's signal, when it passed one
237
245
  )
238
246
  ).content,
239
- keep: 2, // retain the two most recent turns verbatim on each compaction
247
+ keep: 2, // retain at least the 2 most recent messages verbatim on each compaction
240
248
  })
241
249
  conversation.add([
242
250
  { role: 'user', content: 'My name is Ada.' },
243
251
  { role: 'assistant', content: 'Nice to meet you, Ada.' },
252
+ { role: 'user', content: 'Book a table for two at 19:00.' },
253
+ { role: 'assistant', content: 'Booked for two at 19:00.' },
244
254
  { role: 'user', content: 'What did I say my name was?' },
245
255
  ])
246
256
 
247
- const section = await conversation.compact() // folds the older turns → a summarized section
248
- conversation.view() // [<section summary message>, ...the retained recent tail] — the model input
249
- conversation.summary // the regenerated rollup (a summary-of-summaries over all sections)
257
+ const section = await conversation.compact() // folds the first exchange → a summarized section
258
+ conversation.view() // [<section summary message>, ...the retained recent exchanges] — the model input
250
259
  conversation.search('ada') // case-insensitive across sections' originals + the live tail
251
260
  section && conversation.rehydrate(section.id) // the section's full original messages (a pure read)
252
261
  ```
253
262
 
254
- Register a conversation in a `ConversationManager` and pass that registry through `AgentContextOptions.conversations` to make it the message source. `context.messages` then is the active conversation's live tail, and `build()` folds its `view()`; the conversation owns message inclusion through compaction, which is why a scope never filters messages. When the registry is omitted, the context creates one with an active default conversation.
263
+ Calls to `compact()` run one at a time: a call made while another is in flight waits for it, then folds from the live tail it finds. A fold commits only when every folded message is still the same live entry after the summarizer settles, so a `remove` or `clear` during the initial summarizer makes `compact()` resolve `undefined` and leaves no section. A summarizer must not call `compact()` on the conversation it serves. When no other fold is in flight, a fold counts as in flight only after its summarizer returns, so the summarizer's own call waits for nothing and summarizes the same slice again; the commit check keeps one section, because the later fold finds its slice already folded and resolves `undefined`. Pass `CompactOptions.signal` to cancel a fold: the summarizer receives the signal as its second argument, and an abort rejects `compact()` with the signal's reason at once, even when the summarizer ignores the signal, and commits nothing after the abort; a fold committed before an aborted overflow merge stays. `keep` must be a nonnegative safe integer: `NaN`, a fraction, a negative, or `Infinity` throws `ConversationError` with code `KEEP` before any summarizer call.
264
+
265
+ Register a conversation in a `ConversationManager` and pass that registry through `AgentContextOptions.conversations` to make it the message source. `context.messages` then is the active conversation's live tail, and `build()` folds its `view()`. The scope's filters never touch messages; message inclusion is the conversation's through compaction and, when a handler is set, the selection's. When the registry is omitted, the context creates one with an active default conversation.
255
266
 
256
- Pass that registry through `AgentOptions.conversations` together with an `AgentOptions.window` context [`Budget`](budget.md) to enable automatic compaction. Each turn the loop estimates the current full prompt against the window and, when it reaches the ceiling, compacts the active summarizable conversation before continuing on the rebuilt view. Omit `window` and the loop does not auto-compact. See the [Contract](#contract) (the automatic-compaction clause) for the exact trigger and single-level limitation.
267
+ Pass that registry through `AgentOptions.conversations` together with an `AgentOptions.window` context [`Budget`](budget.md) to enable automatic compaction. Before the first provider request and between turns, the loop measures the working message array against the window and, when it reaches the ceiling, compacts the active summarizable conversation before continuing on the rebuilt view. Omit `window` and the loop doesn't auto-compact. See the [Contract](#contract) (the automatic-compaction clause) for the exact trigger and single-level limitation.
257
268
 
258
269
  ```ts
259
270
  import { createAgent, createConversationManager, estimateMessages } from '@orkestrel/agent'
260
271
  import { createBudget } from '@orkestrel/budget'
261
272
 
273
+ // `provider` and `summarize` come from the host application.
262
274
  const conversations = createConversationManager({ summarize, keep: 2 })
263
275
  const conversation = conversations.add()
264
276
  const agent = createAgent(provider, {
265
277
  conversations,
266
278
  // A context Budget: consumer = a token estimator, max = the context window. The loop measures
267
- // the CURRENT FULL prompt against it each turn; when the prompt reaches the window it compacts
279
+ // the working message array against it; when that array reaches the window it compacts
268
280
  // + continues on the rebuilt (smaller) view (compact-and-continue), never aborts.
269
281
  window: createBudget({ max: 8_000, consumer: estimateMessages }),
270
282
  })
@@ -280,6 +292,7 @@ await agent.generate() // folds older turns into a section mid-run when the prom
280
292
  import { createAgent, createConversationManager, estimateMessages } from '@orkestrel/agent'
281
293
  import { createBudget } from '@orkestrel/budget'
282
294
 
295
+ // `provider` and `summarize` come from the host application.
283
296
  const threads = createConversationManager({ summarize, keep: 2 }) // its defaults flow into each thread
284
297
  const agent = createAgent(provider, {
285
298
  conversations: threads, // the agent's message source
@@ -289,7 +302,7 @@ const agent = createAgent(provider, {
289
302
  // Per request: make the request's thread active, append the user turn, run.
290
303
  async function handle(threadId: string, text: string): Promise<string> {
291
304
  if (threads.conversation(threadId) === undefined) threads.add({ id: threadId })
292
- threads.switch(threadId) // SWITCH — context.messages now IS this thread's tail
305
+ threads.switch(threadId) // SWITCH — context.messages IS this thread's tail from here
293
306
  agent.context.messages.add({ role: 'user', content: text })
294
307
  return (await agent.generate()).content
295
308
  }
@@ -299,17 +312,21 @@ await handle('user-2', 'What is 2 + 2?') // thread user-2 is fully independent
299
312
  await handle('user-1', 'What did I say my name was?') // back to user-1 — its own history is intact
300
313
  ```
301
314
 
302
- > **Concurrency caveat.** Switch the active conversation between runs, never during one (the loop reads the active conversation at run entry and drives it through to the end). The framework ships the switch mechanism; the app owns concurrency policy — for threads that must run concurrently, use a separate `Agent` per concurrent thread (each agent is cheap; they can share the provider and tool registry). Switching mid-flight would repoint the live run's message source under it.
315
+ > **Concurrency caveat.** Switch the active conversation between runs, never during one (the loop reads the active conversation at run entry and drives it through to the end). The framework ships the switch mechanism; the app owns concurrency policy — for threads that must run concurrently, use a separate `Agent` per concurrent thread; the agents can share the provider and tool registry. Switching mid-flight would repoint the live run's message source under it.
303
316
 
304
317
  ##### Production behaviors of automatic compaction
305
318
 
306
319
  Auto-compaction (the `window` budget) is hardened for a long-running app:
307
320
 
308
321
  - **Pre-first-turn + run-entry reset.** The budget check runs before the first provider request and between turns — so a resumed or already-long conversation whose initial prompt already exceeds the window compacts immediately (not only after a tool turn). The `window` budget is reset at run entry, so no stale measurement carries across runs or a conversation switch.
309
- - **Non-fatal, observable summarizer failure.** If the automatic `compact()`'s summarizer throws, the agent run does not crash: the loop skips compaction that turn, surfaces the error as a `fault` event (so the failure is observable, never silently lost), and continues (the over-window prompt proceeds to the provider). Only the agent's auto path is resilient — a manual `conversation.compact()` you call yourself still propagates its error.
310
- - **Futile-compaction guard (the single-level limit).** If `compact()` folds nothing (returns `undefined`) while the prompt is still over the window — that is, the section summaries alone already exceed it — the loop stops auto-compacting for the rest of that run (a per-run latch), avoiding per-turn churn. The over-window prompt then proceeds to the provider, which surfaces a genuine context-length error if it truly cannot fit — the real limit. Compaction is single-level: it folds the live tail, never the existing sections.
322
+ - **No fold after a cancel.** When the run's signal has aborted by the time tool dispatch ends, the loop records the tool messages and folds nothing, so a cancel never waits on the summarizer. The next run's pre-first-turn check folds the earlier exchanges, call groups whole.
323
+ - **Observable summarizer failure, non-fatal unless strict.** If the summarizer throws during automatic compaction, the loop emits the error as a `fault` event, so the failure is never silently lost. Without `AgentOptions.strict`, the run doesn't crash: the loop skips compaction that turn and continues, and the over-window prompt proceeds to the provider. With `strict` set, the run settles `error` after the `fault` event (see the `strict` clause under [Contract](#contract)). A manual `conversation.compact()` call you make yourself always propagates its error.
324
+ - **Futile-compaction guard (the single-level limit).** If `compact()` folds nothing (returns `undefined`) while the prompt is still over the window — that is, the section summaries, the run's request, and the run's own turns, which a fold never takes, already exceed it — the loop stops auto-compacting for the rest of that run (a per-run latch), avoiding per-turn churn. The over-window prompt then proceeds to the provider, which surfaces a genuine context-length error if it truly can't fit — the real limit. Compaction is single-level: it folds the live tail, never the existing sections.
325
+
326
+ The following listener observes an automatic-compaction summarizer failure:
311
327
 
312
328
  ```ts
329
+ // `agent` comes from the preceding example; `log` comes from the host application.
313
330
  agent.emitter.on('fault', (error) =>
314
331
  log('auto-compaction summarizer failed (run continues)', error),
315
332
  )
@@ -317,7 +334,7 @@ agent.emitter.on('fault', (error) =>
317
334
 
318
335
  ### Scoping a turn
319
336
 
320
- A `Scope` (`createScope` / a `ScopeManager`) is a named allow-list filter the context applies at `build()` time and at the loop's tool-advertise step. It carries an optional `instructions` / `tools` / `files` list keyed by each category's identity — `instructions` (by `name`), `tools` (by `name`), `files` (the active workspace's files, by `path`) — each three-way: `undefined` ⇒ no constraint (all pass), `[]` ⇒ none pass, a non-empty list ⇒ only the listed keys. Conversation messages are not scoped — the active conversation owns message inclusion through compaction (`view()`), so there is no `messages` allow-list. Apply the active filter through `context.apply(scope)`; call `context.apply(undefined)` to remove filtering. The readonly `context.scope` getter reports the current filter, and `build()` reflects whatever scope is active when it runs (recomputed fresh each call). `narrow(config)` composes a tighter child scope by set-intersection (an `undefined` side imposes no constraint), so narrowing can only tighten — a parent-excluded key never returns:
337
+ A `Scope` (`createScope` / a `ScopeManager`) is a named allow-list filter the context applies at `build()` time and at the loop's tool-advertise step. It carries an optional `instructions` / `tools` / `files` list keyed by each category's identity — `instructions` (by `name`), `tools` (by `name`), `files` (the active workspace's files, by `path`) — each three-way: `undefined` ⇒ no constraint (all pass), `[]` ⇒ none pass, a non-empty list ⇒ only the listed keys. The scope's filters never touch messages; message inclusion is the conversation's through compaction and, when a handler is set, the selection's, so a scope has no `messages` allow-list. A scope also carries an optional `select` handler, covered in [Selecting the messages a turn carries](#selecting-the-messages-a-turn-carries). Apply the active filter through `context.apply(scope)`; call `context.apply(undefined)` to remove filtering. The readonly `context.scope` getter reports the current filter, and `build()` reflects whatever scope is active when it runs (recomputed fresh each call). `narrow(config)` composes a tighter child scope by set-intersection (an `undefined` side imposes no constraint), so narrowing can only tighten — a parent-excluded key never returns:
321
338
 
322
339
  ```ts
323
340
  import { createAgent, createScope } from '@orkestrel/agent'
@@ -338,7 +355,9 @@ agent.context.apply(
338
355
  const result = await agent.generate()
339
356
  ```
340
357
 
341
- A scoped-out tool is neither described nor callable. The loop snapshots the scope's tool allow-list before advertising and checks each returned call against that list before consulting the `AgentOptions.authority` option. An `undefined` scope value, or an absent `tools` list, admits every name, including an unknown name that the registry answers with its failure arm. An empty `tools` list admits none. A scope change through the `context.apply` method takes effect on the next turn.
358
+ A scoped-out tool is neither described nor callable. The loop snapshots the scope's tool allow-list before advertising and checks each returned call against that list before consulting the `AgentOptions.authority` option. An `undefined` scope value, or an absent `tools` list, admits every name, including an unknown name that the registry answers with its failure arm. An empty `tools` list admits none.
359
+
360
+ A scope change through the `context.apply` method applies at each later site that reads the scope. The tool half applies at the next turn's snapshot: a `tool` or `usage` listener changes the next turn's advertising, and a `turn` listener reaches the same turn, because `turn` fires before the snapshot. The prompt half (instructions, files, and `select`) applies at the next build or select site: run entry, the pre-first-turn compaction fold, or a compaction rebuild.
342
361
 
343
362
  When no tool is advertised, including an empty registry, the provider's `content` field ends the run as the answer with a `partial: false` result. The assistant message is recorded without calls. Each dropped call emits a `deny` event with the `no tool is advertised in the active scope` reason, without a tool result or tool message.
344
363
 
@@ -346,16 +365,155 @@ When at least one tool is advertised, a call excluded by the tool allow-list emi
346
365
 
347
366
  A `ScopeManager` registry stores reusable named scopes, keyed by a minted `id` field; scopes can share a `name` field. It is observable like the other managers.
348
367
 
368
+ ### Selecting the messages a turn carries
369
+
370
+ A selection handler chooses the conversation messages the next prompt carries for one user request. It has two homes: `AgentOptions.select` is the agent default, forwarded as `AgentContextOptions.select`, and the active scope's `select` overrides it while that scope is active. The `context.select` method resolves `scope?.select ?? default` once per call and returns `undefined` when neither home holds a handler, so the default path awaits nothing before the first provider request.
371
+
372
+ The loop calls the handler at each select site (run entry, the pre-first-turn compaction fold, and a compaction rebuild) with the user message that ends the active conversation's view at run entry and the run's signal; when the view ends on another role, it calls no handler. It folds the result through `build(selection)`, which carries `selection.messages` in place of `view()` and appends a non-empty `selection.briefing` as the last part of the system message unless the selection has a `fault`, and then emits `select` with the `Selection`. That event fires at each select site, so twice before turn 0 when the pre-first-turn fold runs, and never for a run cancelled during selection.
373
+
374
+ A `Selection` carries the `messages`, the `judgments` keys they rest on, the judge `usage` spent, an optional `fault`, and an optional `briefing`, the text `build` appends as the last system part. It has no tool member: what a turn advertises and dispatches stays the scope's `tools` allow-list.
375
+
376
+ The following agent selects through its default handler, then through a mode's override:
377
+
378
+ ```ts
379
+ import type { ProviderInterface, Selection, SelectionHandler } from '@orkestrel/agent'
380
+ import { createAgent, createScope } from '@orkestrel/agent'
381
+
382
+ declare const provider: ProviderInterface
383
+
384
+ // The agent default keeps the user turns; the focus mode keeps the request alone.
385
+ const userTurns: SelectionHandler = async (conversation) => ({
386
+ messages: conversation.view().filter((message) => message.role === 'user'),
387
+ judgments: [],
388
+ })
389
+ const requestOnly: SelectionHandler = async (_conversation, request) => ({
390
+ messages: [request],
391
+ judgments: [],
392
+ })
393
+
394
+ const agent = createAgent(provider, { system: 'You triage billing tickets.', select: userTurns })
395
+ const receipts: Selection[] = []
396
+ agent.emitter.on('select', (selection) => receipts.push(selection))
397
+ agent.context.messages.add([
398
+ { role: 'user', content: 'The invoice total is wrong.' },
399
+ { role: 'assistant', content: 'Which invoice?' },
400
+ { role: 'user', content: 'Invoice 42.' },
401
+ ])
402
+ await agent.generate() // the provider reads the system block and the two user turns
403
+
404
+ agent.context.apply(createScope({ name: 'focus', select: requestOnly }))
405
+ agent.context.messages.add({ role: 'user', content: 'Refund it.' })
406
+ await agent.generate() // the provider reads the system block and 'Refund it.'
407
+ receipts.map((selection) => selection.messages.length) // [2, 1]
408
+
409
+ const plain = createAgent(provider)
410
+ const request = plain.context.messages.add({ role: 'user', content: 'Invoice 42.' })
411
+ plain.context.select(request, new AbortController().signal) // undefined — no handler in either home
412
+ ```
413
+
414
+ A selection fails three ways. A handler throw emits `fault`, and the loop builds from `view()` with no `select` event. A returned `fault` charges its usage first, then emits `fault`, and the loop builds from the returned `messages` and emits `select`. A conversation changed under the handler is a fault as well: `context.select` returns the current `view()` with a `fault` that names the change. With `AgentOptions.strict` set, all three settle the run `error`. A cancel at a select site wins over all of them, and the run commits partial with no provider call.
415
+
416
+ Selection usage is charged in full to the cost `budget` and folded into `AgentResult.usage`. No `usage` chunk or `usage` event carries it, so that chunk keeps its one-provider-call meaning.
417
+
418
+ ### Judgments on the conversation
419
+
420
+ A conversation's `judgments` store records what a judge answered about its messages. Each record is keyed by the caller's question id, and adding an existing key replaces the record in place, last write wins, at its original insertion position, and the `add` method stamps the `time` member. A record carries the question, the answer or the refusal, the judge's configured `model`, the ordered `sources` ids, and the rendered `state`, and it carries `usage` only when its request asked that one question.
421
+
422
+ The `resolve(judge, request, sources, signal)` method reuses every recorded judgment that matches and asks the judge once for the rest, recording what returns. A match compares the question's JSON text with its key order kept, the sources in order, the rendered state, and the judge's configured `model` rather than the name a server reports, so reordered criteria, a changed state, or a changed source asks again. A cancel keeps the records of the questions that completed and rethrows the abort.
423
+
424
+ A question id is a caller key that the prompt never carries, so the whole question lives in `instructions`, and a judgment that needs an earlier answer is a second request. A snapshot carries `judgments` only while the store holds one, so a conversation without judgments serializes as `{ id, sections, messages }`, and a snapshot written without the member still validates and restores.
425
+
426
+ The following conversation records one answer and then reuses it:
427
+
428
+ ```ts
429
+ import type { JudgeRequest } from '@orkestrel/agent'
430
+ import { createConversation, createSystemOneJudge } from '@orkestrel/agent'
431
+
432
+ declare const signal: AbortSignal
433
+
434
+ const judge = createSystemOneJudge({ url: 'http://localhost:11434', model: 'tev1:0.8b' })
435
+ const conversation = createConversation()
436
+ const ticket = conversation.add({
437
+ role: 'user',
438
+ content: 'Our checkout has returned 500 errors since 9am. I want a refund for today.',
439
+ })
440
+ const request: JudgeRequest = {
441
+ state: ticket.content,
442
+ questions: {
443
+ refund: {
444
+ form: 'noul',
445
+ instructions: 'Does the customer ask for money back?',
446
+ criteria: {
447
+ true: 'The customer asks for a refund or for money back.',
448
+ false: 'The customer does not ask for money back.',
449
+ },
450
+ },
451
+ },
452
+ }
453
+
454
+ const [asked] = await conversation.judgments.resolve(judge, request, [ticket.id], signal)
455
+ asked?.model // 'tev1:0.8b' — the configured judge
456
+ asked?.usage // { prompt: 975, completion: 4, total: 979 } — the request asked this one question
457
+ const [reused] = await conversation.judgments.resolve(judge, request, [ticket.id], signal)
458
+ reused?.time === asked?.time // true — the matching record answers without a call
459
+ conversation.snapshot().judgments?.length // 1
460
+ ```
461
+
462
+ The usage in the comment is the one the recorded Ollama response of 2026-10-07 reports; the executed transcription serves that response from a loopback listener (see [the `tests/guides.test.ts` parity test](../tests/guides.test.ts)).
463
+
464
+ Treat a judge as frozen at inference: change thresholds and criteria in code, record corrections as judgments, and train another model only offline, as a batch on rows collected for that purpose, refitting temperature on rows the training run didn't use.
465
+
466
+ Write criteria for a judge that reads literally (see [Jev 1.13 jaggedness](https://docs.typesafe.ai/model-jaggedness/jev-1.13.md)). Scoping words and negations count, so keep a `noul` whose true side means yes, and keep indirection such as a double negative out of the text. Keep arithmetic and date comparison in code, filter irrelevant state before asking, and treat a note inside the state as data that can move the answer. Reorder options to check for a lean toward the first one.
467
+
468
+ ### Serving a conversation through a ledger
469
+
470
+ A ledger serves one conversation as an event-sourced briefing with per-owner records. The `createLedger(provider, options)` factory returns a `LedgerInterface` value that owns the conversation, the agent that answers it, and that agent's tools, and its `respond` method appends a request as a user message and serves it through to the reply. The conversation is the event log. Each request reads a briefing that the ledger projects from what a judge filed about every message, then a tail of the seed history (the messages added before the first request), then the request. An earlier request and its reply don't re-enter a planned prompt; what its lookups returned reaches a later request through the records and the `recall` tool. When the selection faults, the agent builds the prompt from the whole conversation view instead, earlier requests and replies included, with no briefing.
471
+
472
+ Run the ledger's agent only through the `respond` method. An active `respond` call owns a run whose request, the newest message, is that call's request or one of the ledger's notes. A run that no active `respond` call owns, such as a direct call to the agent's `generate` method, gets a faulted selection when its newest message is a user message: the selection's `fault` is a `LedgerError` with code `'REQUEST'`, so the agent emits `fault` and builds the prompt from the whole conversation view with no briefing, and the ledger asks its judge nothing for that run. When its newest message isn't a user message, the run gets no selection and builds from the whole view. Calibration admits no run, so a run during the `calibrate` method, or during the calibration that opens a `respond` call, is one that no active `respond` call owns.
473
+
474
+ The method has the following pieces, each named by its export:
475
+
476
+ - **The filing.** The `Classifier` class files each message through the judge the `judge` option injects. The ledger files a tool result, its own notes, an assistant call, and an assistant reply after the first request by their shape, and asks the judge about the rest: the `LEDGER_QUESTIONS` category question about each remaining non-request message, and the topic question, for each desk topic, about each non-quiet statement and each request unless that topic has `requested: false`; and the `amends` question about a later user message whose `correction` probability reaches the `correction` cutoff and an earlier message that isn't quiet and shares an id, an owner, or a desk topic with it, followed by the `supersedes` question about the same pair when the `amends` answer reaches its cutoff. Every answer lands in the conversation's `judgments` store, so a later request asks only what no earlier one asked. A judge failure leaves its question unanswered, and the next request asks it again, except for two failures that hold the question so that no later request asks it: a refused question, which the judge reports as a `JudgeError` with code `'QUESTION'`, and the deterministic logprob failure the `DETERMINISTIC_JUDGE_ERROR` constant matches. The `LedgerClassification` value reads the filing back at the cutoffs: an `amends` answer at its cutoff marks a pair amended only when the two messages share an id or a number, and a `supersedes` answer at its cutoff marks the pair superseded and amended whatever they share. Each `classify` call also returns one `ClassifierReading` value per logical filing question it attempted, in asking order, and observes the filing without changing it. A reading names the question's key and its `ClassifierPhase` value, and its `ClassifierOutcome` value says whether the conversation already held the judgment (`cached`), the classifier held the question after a deterministic failure (`skipped`), or the judgment manager's `resolve` call fulfilled (`resolved`), rejected under a live signal (`failed`), or was cut short by an abort (`aborted`). A `resolved`, `failed`, or `aborted` reading carries the monotonic milliseconds the classifier waited on that call as `duration`, and a `cached` or `skipped` reading carries none. A question that a caller abort preceded has no reading, because it never began. The readings count logical question attempts and resolve requests, never the physical wire calls a judge makes for them, and they record no join of pending requests. The `readings` member of the `LedgerResult` value holds the readings of the filing selections of that `respond` call alone; the `Selection` values the ledger emits carry none.
477
+ - **The records.** The `buildRecords` helper projects the filing into one `LedgerRecord` value per owner a lookup result names, plus one rules record for the rules and corrections no owner claims. Each line is a sentence of a live message, verbatim except that a sentence that opens with a pronoun opens with its party and a colon. A message filed quiet or superseded isn't live, and a sentence that shares an id or a number with a later message that amends it is stale: the projection lists it in its `stale` member and leaves it out of every record and the briefing. The `recall` tool and the answer note keep the stored content of each message they carry, stale sentences included. The seed tail keeps stored content except for lookup results, which the plan replaces with stubs.
478
+ - **The plan.** For each request, the ledger selects the records the request names through the `selectRecords` helper and fits the briefing and the tail inside a token budget: the `capacity` option less the `predict` option, times the `prompt` share, less the overhead cost of the gauge and held back by the `LEDGER_SCALE_DRIFT` constant, of which the tail takes at most the `tail` share. The budget bounds only the briefing and the tail of a request's first call. The request, each lookup result, the pass's own assistant turns with their calls and replayed thinking, the answer note and the `cue` note, and the whole view a faulted selection builds from enter the prompt unbounded, so any of them can carry a prompt past the budget. The plan prices each lookup stub in the tail at the longer of its `shown` and `hidden` forms, so a tail that fits the plan still fits after the briefing decides which results it shows. The `share` option sets both shares, and the `DEFAULT_LEDGER_SHARE` constant holds the defaults. Until the briefing fits, the ledger drops the rules off the request's topics, then the sources outside the selected records, then the rules on its topics, then the owner lines. The `Gauge` class prices the prompt; without the `gauge` option, the ledger calibrates before its first pass, and the `calibrate` method measures the price on demand. A lookup in the tail reaches the model as the stub the `renderStub` helper writes, which says whether the briefing shows its result. The plan reaches the agent as a `Selection` value: its `messages` member is the tail, and its `briefing` member is the text the `build` method appends as the last part of the system message.
479
+ - **The `recall` tool.** The ledger registers a `recall` tool beside the application's lookups. It lists the earlier messages and lookup readings that match a topic (an owner name, an id, or a desk topic) with their stored content, newest first, each source followed by the amenders the topic doesn't match itself; an amender the topic matches lists as its own newer item. The tool cuts the list by whole items to the room the gauge leaves for the reply, naming how many older items it left out. A request can call it as many times as the `limit` member of the `recall` option allows, the `DEFAULT_RECALL_LIMIT` constant by default; after that, or when the capacity left less the `predict` option falls under twice the reply's reserve, it returns the `closure` note.
480
+ - **The repeat stop.** A lookup or `recall` call that repeats an earlier call of the same request, compared by tool name and canonical arguments, returns the `repeat` note as its failure, and the ledger aborts the pass. For a lookup, the repeat stop's identity compares string arguments as written. The projection's lookup identity, which the `identifyLookup` helper writes, trims and uppercases each top-level string argument and decides which reading replaces an earlier one. So `lookup_order` calls for `bw-5512` and `BW-5512` both run in one request, and the second reading replaces the first in the records.
481
+ - **The answer pass.** When the first pass ends partial or without final text and the caller didn't abort, the ledger adds an answer note when the pass's lookups or recalls returned anything, carrying what they returned without their call text, then the `cue` note, and runs one answer pass that advertises no tools and always turns thinking off. The `passes` member of the `LedgerResult` value holds both passes, and its `content` member is the last pass's. A rejected first pass counts as unfinished. When the answer pass rejects as well, the `respond` method rejects with that pass's error, and the ledger stays free for a later request.
482
+ - **The thinking budget.** The `predict` option reserves the generation room that thinking and the reply share, taking it out of the plan budget, the recall room, and the close rule. The gauge counts only the thinking the next request carries: under the provider's default `replay` of `'none'`, it leaves a call's thinking out of the capacity that call used, and the `computeThinking` helper apportions that thinking from the completion by character share. Under every policy, the reply reserve reads the reply without its thinking. The `think` option requests or suppresses thinking for the first pass, and omission leaves the provider's default; the answer pass always runs with thinking off. With replay `'none'`, a ledger at a `capacity` of `W + P` with a `predict` of `P`, where `W` is a context window and `P` a generation cap, budgets like a ledger at `W` without thinking.
483
+
484
+ The application supplies the following through `LedgerOptions`:
485
+
486
+ - the judge, through the `judge` option;
487
+ - the question wording, through the `questions` option, where the `LEDGER_QUESTIONS` constant is the wording, and its own cutoffs through the `thresholds` option, which has no default because a cutoff holds only for the wording and the judge it was fitted on;
488
+ - the desk topics, through the `topics` option;
489
+ - the lookups, through the `lookups` option, each a tool the model calls and a reading handler that names the ids and the owners in its result;
490
+ - the capacity, through the `capacity` option, the model's context window in tokens;
491
+ - the generation cap, through the `predict` option, in tokens with thinking included, 0 by default and less than `capacity`;
492
+ - the thinking of the first pass, through the boolean `think` option; the answer pass always runs with thinking off.
493
+
494
+ The method has the following documented limits:
495
+
496
+ - **The person prefix trusts capital letters.** A sentence that opens with a pronoun takes the last capitalized name of the sentence before it as its party, so a capitalized word that names no person, such as a carrier named mid-sentence, is read as the party.
497
+ - **A desk-wide correction stays in one owner's record.** A message that names an owner joins that owner's record and not the rules record, so a correction to a desk-wide rule stated inside one owner's message isn't among the rules a request about another owner reads.
498
+ - **Recall identity is the topic alone.** The repeat stop compares two `recall` calls by their trimmed `topic` argument, so a second call with the same topic is a repeat whatever other arguments it carries.
499
+ - **A concurrent direct run can pass for an owned run.** The ledger tells its runs apart by the newest message alone, so a direct run started while a `respond` call is active and that call's request or one of the ledger's notes is the newest message gets a planned selection, as the call's own run does. Any run started during a `respond` call, faulted or planned, adds its turns and usage to that call's gauge readings and shares its repeat stop.
500
+ - **A run no call owns keeps no request state.** The ledger holds a request's state in a `LedgerRequest` value from the start of its `respond` call until the call settles. A run that no `respond` call owns, including a run during calibration, reads no request's state: its lookup and `recall` calls run with no repeat stop, recall limit, or close rule, and none of its turns reach a gauge reading.
501
+ - **A seed tool message counts as successful.** A tool message the application adds to the conversation has no recorded result, so the ledger reads it as a successful lookup and files it as a fact, and its reading handler decides what the result names.
502
+ - **An abort during calibration rejects.** When the caller's signal aborts while the ledger calibrates, the `respond` method rejects with the abort reason instead of resolving a partial result; after calibration, an abort ends the request partial and skips the answer pass.
503
+ - **A foreign signal rejects before anything is appended.** A `signal` that isn't a native `AbortSignal` rejects the `respond` method with a `ContractError` before the request is appended, and the ledger stays free for a later request.
504
+ - **Only the seed forms the tail.** Only the messages added before the first request form the seed tail, so an application message added after the first request reaches a planned prompt only through the briefing or the `recall` tool.
505
+ - **Records don't retire.** No age or request count retires a message: it leaves the records only when a later message supersedes it or makes its sentences stale, a later reading of the same lookup replaces it, or a later answer files it quiet. Only the plan decides which of its lines a briefing shows.
506
+ - **The ledger refuses no over-window prompt.** A request, a lookup result, the pass's own turns, the answer note, or the whole view a faulted selection builds from reaches the provider whole when it's larger than the room the `capacity` option leaves, and the provider's own context limit decides what happens to it.
507
+
349
508
  ### Customizing the format (the cascade)
350
509
 
351
- Each context section frames as `[open, ...items.map(render), close]` — a top line rendered once before the items, each item's text, and a bottom line rendered once after — with empty / absent slots dropped and the survivors blank-line (`\n\n`) joined. The slots resolve independently through a cascade, most-specific-first; each level is optional, what you omit falls through to the next, and omitting everything leaves each section on its manager's built-in framing — only the header and the items, with no closing line. From most to least specific:
510
+ Each context section frames as `[open, ...items.map(render), close]` — a top line rendered once before the items, each item's text, and a bottom line rendered once after — with empty / absent slots dropped and the survivors blank-line (`\n\n`) joined. The instruction manager resolves each slot independently through a cascade, most-specific-first; each level is optional, what you omit falls through to the next, and omitting everything leaves each section on its manager's built-in framing — only the header and the items, with no closing line. From most to least specific:
352
511
 
353
- 1. **Item override** — `override?: string` on a single `InstructionInput`: a fully-rendered string for that item, round-tripped onto the stored entity. Beats everything for that item's `render`.
354
- 2. **Manager-options override** — `format?: ContextSectionFormat<…>` on a manager's `Options` (an `{ open?; render?; close? }` trio): a per-section open / item-render / close override for that whole manager. Beats the provider default + the built-in. A manager exposes it as the `readonly format` accessor, and its own `open` / `render(item)` already consult that override's `open` / `render` (so a manager used standalone renders with it).
355
- 3. **Provider default** — `format?: ContextFormat` on a `ProviderInterface` (keyed by section kind — the `instructions` section): the model's preferred framing. Optional — an agnostic provider supplies none, and the agent loop passes `provider.format` (often `undefined`) into `build()`.
356
- 4. **Built-in** — the manager's hardcoded `open` getter + `render(item)` method (`## Instructions` + the content) — the floor for `open` and `render`. There is no built-in `close`: an unset `close` yields no closing line.
512
+ 1. **Item override** — `override?: string` on a single `InstructionInput`: a fully-rendered string for that item, round-tripped onto the stored entity. Beats every other level for that item's `render`.
513
+ 2. **Manager-options override** — `format?: ContextSectionFormat<…>` on `InstructionManagerOptions` (an `{ open?; render?; close? }` trio): a per-section open / item-render / close override for that whole manager. Beats the built-in.
514
+ 3. **Built-in** — the manager's `## Instructions` header and each item's content — the floor for `open` and `render`. There is no built-in `close`: an unset `close` yields no closing line.
357
515
 
358
- So for a section kind `K`, manager `M`, and a provider format `F`: **open** = `M.format?.open ?? F?.[K]?.open ?? M.open` (manager-options > provider > built-in — the leading text has no per-item level); **per item** `I` = `I.override ?? M.format?.render?.(I) ?? F?.[K]?.render?.(I) ?? M.render(I)` (item > manager-options > provider > built-in); **close** = `M.format?.close ?? F?.[K]?.close` (manager-options > provider, no built-in ⇒ no closing line when unset). `open`, the rendering, and `close` resolve independently, so an override may set only the open, only the rendering, only the close, or any mix — and `open` + `close` together wrap the whole group. (The `## Workspace` text section has no cascade level of its own — it renders with the fixed `renderFencedFile` framing.)
516
+ The manager applies all three levels itself, so `context.build()` reads its `open`, `render(item)`, and `close` as given. For a manager whose options carry the format `O`: **open** = `O.open ?? '## Instructions'` (manager-options > built-in — the leading text has no per-item level); **per item** `I` = `I.override ?? O.render?.(I) ?? I.content` (item > manager-options > built-in); **close** = `O.close` (manager-options only, no built-in ⇒ no closing line when unset). `open`, the rendering, and `close` resolve independently, so an override can set only the open, only the rendering, only the close, or any mix — and `open` + `close` together wrap the whole group. (The `## Workspace` text section has no cascade level of its own — it renders with the fixed `renderFencedFile` framing.)
359
517
 
360
518
  ```ts
361
519
  import { createAgentContext, createInstructionManager } from '@orkestrel/agent'
@@ -374,7 +532,7 @@ context.instructions.add({ name: 'tone', content: 'Be terse.' })
374
532
  context.instructions.add({
375
533
  name: 'raw',
376
534
  content: 'ignored',
377
- format: '<rule priority="high">Escalate.</rule>',
535
+ override: '<rule priority="high">Escalate.</rule>',
378
536
  })
379
537
 
380
538
  context.build()
@@ -382,11 +540,11 @@ context.build()
382
540
  // '<rules>\n\n<rule>Be terse.</rule>\n\n<rule priority="high">Escalate.</rule>\n\n</rules>'
383
541
  ```
384
542
 
385
- A provider declares its framing default by exposing `format` on its `ProviderInterface`; the `Agent` passes it into `build()` automatically. Because it is optional, no provider is forced to supply one — omitting it leaves every section on the managers' built-in framing.
543
+ When a provider's model has a framing preference, such as XML tags, install that framing as the manager-options `format` on the agent's instruction manager (`createAgent(provider, { instructions: createInstructionManager({ format }) })`). A provider carries no framing of its own, so the request it receives depends only on the agent's context.
386
544
 
387
545
  ### The HTTP provider engine
388
546
 
389
- `ProviderInterface` is the boundary; `AgentProvider` is the engine behind it. Every HTTP-backed provider needs the same machinery — a deadline, a transport, an authorization hook, a bounded error read, a chunk decoder, reasoning separation, and result assembly — and rewriting that per vendor is how a fleet of providers drifts apart. `AgentProvider` owns all of it and leaves exactly one thing open: the wire. A subclass supplies `name` and the wire seams — `frame()` returns fresh framing state for the call, `body(request)` projects a `ProviderRequest` onto the vendor's request shape, `read(record)` decodes one framed record into a `ProviderIncrement`, and `finish(parser)` returns whatever the parser still held at end of input. The engine is generic over `TRecord`: the record type a call's `frame()` parser emits and `read` consumes, defaulting to `Readonly<Record<string, unknown>>` for a wire whose records are JSON objects. The constructor switches decide the rest: `split` separates in-content `<think>` reasoning from the answer (default `true`), and `strict` requires the wire to carry a settled `result` record rather than assembling one at end of input (default `false`).
547
+ `ProviderInterface` is the boundary; `AgentProvider` is the engine behind it. Every HTTP-backed provider needs the same machinery — a deadline, a transport, an authorization hook, a bounded error read, a chunk decoder, reasoning separation, and result assembly — and rewriting that per vendor is how a fleet of providers drifts apart. `AgentProvider` owns all of it and leaves exactly one thing open: the wire. A subclass supplies `name` and the wire seams — `frame()` returns fresh framing state for the call, `encode(request)` projects a `ProviderRequest` onto the vendor's request shape, `read(record)` decodes one framed record into a `ProviderIncrement`, and `finish(parser)` returns whatever the parser still held at end of input. The engine is generic over `TRecord`: the record type a call's `frame()` parser emits and `read` consumes, defaulting to `Readonly<Record<string, unknown>>` for a wire whose records are JSON objects. The constructor switches decide the rest: `split` separates in-content `<think>` reasoning from the answer (default `true`), and `strict` requires the wire to carry a settled `result` record rather than assembling one at end of input (default `false`).
390
548
 
391
549
  ```ts
392
550
  import type { AgentProviderInput, ProviderInterface } from '@orkestrel/agent'
@@ -413,39 +571,85 @@ result.content // the assembled answer, with any <think> span routed to result.t
413
571
 
414
572
  The engine's failures arrive as one error class. A `ProviderError` carries a machine-readable `code` and, for an HTTP failure alone, the response `status`; `isProviderError` narrows a caught value so a caller can branch on the code:
415
573
 
416
- - `'HTTP'` — a non-OK response. The message is `provider error: <status>`, with ` - <excerpt>` appended only when the response body carried text. The excerpt is read to at most `MAX_ERROR_BODY_LENGTH` bytes and the remainder of the body is cancelled, so a stalled error body cannot hold the call open past its deadline.
574
+ - `'HTTP'` — a non-OK response. The message is `provider error: <status>`, with ` - <excerpt>` appended only when the response body carried text. The excerpt is read to at most `MAX_ERROR_BODY_LENGTH` bytes and the remainder of the body is cancelled, so a stalled error body can't hold the call open past its deadline.
417
575
  - `'PROTOCOL'` — a successful response with no body, a wire record the concrete provider refuses, or a `strict` stream that ended without a settled result.
418
576
  - `'PROVIDER'` — an upstream failure a relay reported in an `error` frame.
419
577
 
420
- A cancel is not in that taxonomy: the call's own deadline and the caller's signal are folded into one bound, and a cancel throws `ProviderAbortError` carrying the partial assembled so far.
578
+ A cancel isn't in that taxonomy: the call's own deadline and the caller's signal are folded into one bound, and a cancel throws `ProviderAbortError` carrying the partial assembled so far.
579
+
580
+ ### Recording and replaying thinking
581
+
582
+ A thinking model's reasoning arrives as `ProviderResult.thinking`, apart from the answer in `content`. The agent loop records each call's non-empty thinking as the `thinking` member of the assistant message that call appends, on a tool-call turn and on the final answer alike. The joined `AgentResult.thinking` also includes thinking from calls that appended no message, such as an aborted call. Only recorded thinking can return to a provider. Thinking stays out of `content`, the selection state a judge reads, a ledger briefing, and a section summary. Compaction removes thinking before calling the summarizer and retains it on the section's original messages.
583
+
584
+ The provider's `replay` member decides which recorded thinking goes back to the model. Set it through the `replay` option of `ProviderOptions`, which `AgentProviderInput` and `RelayProviderOptions` extend, or declare it on a `ProviderInterface` you write. A `ThinkingReplay` value is one of the following:
585
+
586
+ - `'none'` sends no thinking. It is the default. Under it, the agent loop and a relay send the same messages they would send if no thinking were recorded; a ledger still reads recorded thinking to measure what a call left.
587
+ - `'turn'` sends the thinking of the assistant messages after the last `user` message, the turn in progress, and drops the rest.
588
+ - `'all'` sends the thinking of every assistant message.
589
+
590
+ Choose `'turn'` for a backend that reads thinking inside a tool loop. Some chat formats keep the thinking of the turn in progress and drop older thinking, such as the Qwen3 template (see [the Qwen3 model card](https://huggingface.co/Qwen/Qwen3-235B-A22B)), and some APIs refuse a tool turn that leaves out its thinking, such as DeepSeek's (see [DeepSeek thinking mode](https://api-docs.deepseek.com/guides/thinking_mode)). The `thinking` member is a plain string, so it can't carry a signature that an API requires beside the text, such as Anthropic's (see [Anthropic extended thinking](https://platform.claude.com/docs/en/build-with-claude/thinking)).
591
+
592
+ The agent loop, a relay server, and a ledger apply the policy before calling the provider. A direct `generate` or `stream` call sends messages as given. The agent applies the policy through `stripThinking` before every provider call and before every compaction estimate, and a provider without a `replay` member counts as `'none'`. `estimateRequest` counts a message's `thinking`, so the `window` budget counts the thinking the request carries and no other. A ledger applies the same policy to the tail it plans and each call it measures, and calibration applies the policy at the next user boundary, as if a user message followed the conversation view. Calibration prices the whole view; a planned request carries only the selected tail, so the retained messages can differ.
593
+
594
+ A relay applies both policies. The agent in the browser strips the request by the policy of its `RelayProvider`, the request carries each remaining `thinking` member, and `RelayStream` strips it again by the upstream provider's policy, so the upstream call carries only the thinking that both policies keep.
595
+
596
+ The following fence applies two policies to a conversation whose second request is in a tool turn:
597
+
598
+ ```ts
599
+ import type { Message } from '@orkestrel/agent'
600
+ import { stripThinking } from '@orkestrel/agent'
601
+
602
+ const messages: readonly Message[] = [
603
+ { id: '1', role: 'user', content: 'Is order BW-5512 refundable?' },
604
+ {
605
+ id: '2',
606
+ role: 'assistant',
607
+ content: 'Yes, within 30 days.',
608
+ thinking: 'The window is 30 days.',
609
+ },
610
+ { id: '3', role: 'user', content: 'Refund it.' },
611
+ {
612
+ id: '4',
613
+ role: 'assistant',
614
+ content: '',
615
+ calls: [{ id: 'call-1', name: 'refund_order', arguments: { id: 'BW-5512' } }],
616
+ thinking: 'Refund through the order tool.',
617
+ },
618
+ { id: '5', role: 'tool', call: 'call-1', content: 'Refunded $148.50.' },
619
+ ]
620
+
621
+ const none = stripThinking(messages, 'none')
622
+ const turn = stripThinking(messages, 'turn')
623
+ none.filter((message) => 'thinking' in message).length // 0 — no thinking goes back
624
+ turn.filter((message) => 'thinking' in message).map(({ id }) => id) // ['4'] — the turn in progress
625
+ ```
421
626
 
422
627
  ### The judge engine
423
628
 
424
- The `JudgeInterface` contract is the decision boundary beside `ProviderInterface`, and it is never a provider. Its `ask(request, signal)` method answers typed questions about one state with a probability distribution per question. A `JudgeRequest` carries the `state` and the `questions` keyed by your own ids, which the model never sees; each question is a `ChoiceQuestion`, a `ScoreQuestion`, or a `NoulQuestion`, named by its `form` member. A `JudgeResult` carries the answering model in its `model` member, the `answers` map keyed by the same ids, the `refusals` map of the questions a wire refused, and the `usage` member with the tokens the calls spent. An answer stores only the distribution the server returned: `probabilities` for a choice or a score, and `noul`, the probability of yes, for a noul question. The `computeReading` function derives every measure from that distribution, so every server reads on one scale and no stored measure can drift from its distribution (the derived-reading clause).
629
+ The `JudgeInterface` contract is the decision boundary beside `ProviderInterface`, and it is never a provider. Its `ask(request, signal)` method answers typed questions about one state with a probability distribution per question. A `JudgeRequest` carries the `state` and the `questions` keyed by your own ids, which the prompt never carries; each question is a `ChoiceQuestion`, a `ScoreQuestion`, or a `NoulQuestion`, named by its `form` member. A `JudgeResult` carries the answering model in its `model` member, the `answers` map keyed by the same ids, the `refusals` map of the questions a wire refused, and the `usage` member with the tokens the calls spent. An answer stores only the distribution the server returned: `probabilities` for a choice or a score, and `noul`, the probability of yes, for a noul question. The `computeReading` function derives every measure from that distribution, so every server reads on one scale and no stored measure can drift from its distribution (the derived-reading clause).
425
630
 
426
- The `AgentJudge` class is the engine behind that boundary, as the `AgentProvider` class is the engine behind `ProviderInterface`. It owns each call's deadline, the transport, the `headers` hook, the validation before inference, the bounded error read, and the merge of every call's result, and it leaves the wire open: a subclass supplies `name`, `body(request)`, which projects one call's `JudgeRequest` onto the protocol's request shape, and `read(value, request)`, which decodes one call's parsed response into a `JudgeResult`. A one-shot JSON response has no framing state and no buffered tail, so the engine has no `frame` or `finish` seam. In place of the `split` and `strict` switches it takes one `batch` switch: `true`, the default, sends every question in one call, and `false` sends one call per question in key order.
631
+ The `AgentJudge` class is the engine behind that boundary, as the `AgentProvider` class is the engine behind `ProviderInterface`. It owns each call's deadline, the transport, the `headers` hook, the validation before inference, the bounded error read, and the merge of every call's result, and it leaves the wire open: a subclass supplies `name`, `encode(request)`, which projects one call's `JudgeRequest` onto the protocol's request shape, and `read(value, request)`, which decodes one call's parsed response into a `JudgeResult`. A one-shot JSON response has no framing state and no buffered tail, so the engine has no `frame` or `finish` seam. In place of the `split` and `strict` switches it takes one `batch` switch: `true`, the default, sends every question in one call, and `false` sends one call per question in key order.
427
632
 
428
633
  The `SystemOneJudge` class is the wire this package ships on that engine, and the `createSystemOneJudge` factory returns it behind `JudgeInterface`. It speaks the System One decision protocol at `SYSTEM_ONE_PATH` and sends every question in one call. See [Asking a System One server a choice, a noul, and a score](#asking-a-system-one-server-a-choice-a-noul-and-a-score) for the servers it serves and a whole request; see [Writing a judge wire](#writing-a-judge-wire) for a wire of your own.
429
634
 
430
635
  The engine's failures arrive as one error class. A `JudgeError` carries a machine-readable `code` and, for an HTTP failure alone, the response `status`; the `isJudgeError` function narrows a caught value so a caller can branch on the code:
431
636
 
432
- - `'QUESTION'` — a request refused before inference: an empty question map, a malformed question named by its id, a state that is not a `JudgeEntry`, or a request a wire's `body` method refuses. No call is made.
637
+ - `'QUESTION'` — a request refused before inference: an empty question map, a malformed question named by its id, a state that isn't a `JudgeEntry`, or a request a wire's `encode` method refuses. No call is made.
433
638
  - `'HTTP'` — a non-OK response. The message is `judge error: <status>`, with ` - <excerpt>` appended only when the response body carried text, read to at most `MAX_ERROR_BODY_LENGTH` bytes.
434
- - `'PROTOCOL'` — a successful response with no body or with a body that is not JSON, or a response the wire cannot read.
639
+ - `'PROTOCOL'` — a successful response with no body or with a body that isn't JSON, or a response the wire can't read.
435
640
 
436
- A cancel is not in that taxonomy: it throws `JudgeAbortError`, whose `partial` merges the calls that completed before the cancel, and the `isJudgeAbortError` function narrows a caught value to it (the abort-partial clause).
641
+ A cancel isn't in that taxonomy: it throws `JudgeAbortError`, whose `partial` merges the calls that completed before the cancel, and the `isJudgeAbortError` function narrows a caught value to it (the abort-partial clause).
437
642
 
438
643
  ### The relay
439
644
 
440
- 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.
645
+ 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 isn't recorded here as passed.
441
646
 
442
647
  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.
443
648
 
444
649
  ```ts
445
650
  import type { ProviderInterface } from '@orkestrel/agent'
446
651
  import { createRelay, createRelayProvider } from '@orkestrel/agent'
447
- // The browser application supplies this parser dependency.
448
- import { createNDJSONParser } from '@orkestrel/ndjson'
652
+ // ... RelayFrameParser from "Reaching the relay from the browser" omitted.
449
653
 
450
654
  declare const upstream: ProviderInterface // the server-side provider holding the credential
451
655
 
@@ -459,101 +663,201 @@ const handler = createRelay({
459
663
  // In the browser: a provider like any other, reached over that route.
460
664
  const browser = createRelayProvider({
461
665
  url: 'https://app.example/relay',
462
- parser: createNDJSONParser,
666
+ parser: () => new RelayFrameParser(),
463
667
  headers: () => ({ authorization: 'Bearer session-token' }),
464
668
  })
465
669
  ```
466
670
 
467
- 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.
468
-
469
- 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`.
470
-
471
- ### Factories
472
-
473
- | API | Kind | Summary |
474
- | --------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
475
- | `createConversation` | function | Creates a conversation — a `ConversationInterface` grouping messages above a flat message store it owns directly, with compaction into summarized sections, a regenerated rollup `summary`, on-demand `rehydrate`, and substring `search`, driven by a provider-agnostic `ConversationSummaryHandler` seam. |
476
- | `createConversationManager` | function | Creates a conversation registry — a `ConversationManagerInterface` holding `ConversationInterface`s keyed by their `id`, in insertion order, with an active pointer: the id-keyed store over the conversation layer plus the `active` / `switch` seam the context renders. `add` auto-activates the first conversation and flows the registry's default `summarize` / `keep` into every conversation it creates. |
477
- | `createMemoryConversationStore` | function | Creates the in-memory conversation store — a `ConversationStoreInterface` backed by a process-lifetime `Map` of `ConversationSnapshot`s keyed by conversation id, the default backing for the durable `ConversationManagerInterface.open` / `ConversationManagerInterface.save` seam. The exact twin of `createMemoryWorkspaceStore`. |
478
- | `createDatabaseConversationStore` | function | Creates a `DatabaseConversationStore` over any `DriverInterface`, defaulting to `createMemoryDriver()` — the durable, driver-pluggable backing for the conversation persistence seam, holding each snapshot as one opaque JSON column and standing as the opt-in twin of `createMemoryConversationStore`. The exact twin of `createDatabaseWorkspaceStore`. |
479
- | `createInstruction` | function | Creates an instruction — an immutable `InstructionInterface` (a named directive) from its `name` / `content` and optional `priority`, the `id` minted at construction. |
480
- | `createInstructionManager` | function | Creates an instruction registry — an `InstructionManagerInterface` holding immutable instructions keyed by `name`, listed by descending `priority`. |
481
- | `createScope` | function | Creates a named scope — an immutable `ScopeInterface` from its `name` and its per-category allow-lists, the `id` minted at construction. |
482
- | `createScopeManager` | function | Creates a scope registry — a `ScopeManagerInterface` holding immutable scopes keyed by their minted `id`, in insertion order. |
483
- | `createAgentContext` | function | Creates a richer turn context — an `AgentContextInterface` assembling a provider request from the optional system prompt, the instruction registry, the workspace registry (the only document channel), the conversation registry that is its `messages` source, the tool registry, and the active scope, which `build()` folds into the next turn's input. |
484
- | `createAgent` | function | Creates an agent loop — an `AgentInterface` composing a `ProviderInterface`, its `AgentContextInterface`, and a tool registry into a bounded context → provider → tools → repeat turn, exposed as a one-shot `generate` and a live `stream`. |
485
- | `createAuthority` | function | Creates a policy gate — an `AuthorityInterface` the agent loop consults before each tool call runs, evaluating the ordered rules first-match-wins and falling back to the configured default when none match. |
486
- | `createRelay` | function | Creates an authorized relay handler that validates a bounded JSON request before streaming. |
487
- | `createRelayProvider` | function | Creates a provider that carries calls through a relay endpoint. |
488
- | `createSystemOneJudge` | function | Creates a judge that sends every question through the configured System One server. |
489
- | `createThinkSplitter` | function | Creates a fresh stream-stateful `<think>` separator — a `ThinkSplitterInterface` that splits a thinking model's in-content `<think>…</think>` reasoning spans away from the answer, delta by delta, so a provider yields clean content alone and surfaces the accumulated reasoning as `ProviderResult.thinking`. One splitter serves one stream. |
490
- | `createChannel` | function | Creates an empty unbounded async channel — a `ChannelInterface` a producer writes values into (`push`) and ends (`close` / `fail`) regardless of consumption, while a consumer reads them back live through `drain`. |
491
- | `createAgentRegistry` | function | Creates an agent registry — an `AgentRegistryInterface` holding the named pools of live, non-serializable pieces (providers, tools, authorities, schedulers) that a serializable `AgentJobInput`'s names resolve against, and `build`ing a seeded, signal-wired `AgentInterface` from a job. |
492
- | `createAgentQueue` | function | Creates a durable, bounded-concurrency agent-job queue — a `QueueInterface` over serializable `AgentJobInput`s that composes `createQueue`: each job is rehydrated through the `registry` into a live `AgentInterface`, run to its `AgentResult`, and subjected to the partial-as-configurable-failure policy. |
493
- | `createAgentRunner` | function | Creates an agent-job runner — a `RunnerInterface` over serializable `AgentJobInput`s that composes `createRunner` (one-shot, ordered, fail-fast), each unit rehydrated through the `registry` and subjected to the partial policy. The runner also carries sub-agent fan-out: a parent job's handler can `controller.spawn(childJob)`. |
494
-
495
- ### Classes
496
-
497
- | API | Kind | Summary |
498
- | --------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
499
- | `AgentProvider` | class | Implements bounded HTTP streaming and result assembly behind concrete wire seams. |
500
- | `RelayProvider` | class | Carries provider calls over an authenticated NDJSON relay endpoint. |
501
- | `RelayStream` | class | Streams a provider call as validated NDJSON frames under response backpressure. |
502
- | `AgentJudge` | class | Implements the bounded HTTP calls, validation, and result merging of a judge behind concrete wire seams. |
503
- | `SystemOneJudge` | class | Carries judge questions over the System One protocol and derives answers from server distributions. |
504
- | `Conversation` | class | Represents a conversation — a live uncompacted tail of messages it owns directly above a flat message store, plus compacted, summarized `Section`s, a regenerated rollup `summary`, and a `summarizable` flag, with on-demand `rehydrate` and substring `search`, driven by a provider-agnostic `ConversationSummaryHandler` seam so `core` never imports a provider. Observable through its own `emitter`. |
505
- | `ConversationManager` | class | Registers `Conversation`s keyed by `id`, in insertion order, with an active pointer — the id-keyed store over the conversation layer, the `active` / `switch` seam the `AgentContext` renders, and the durable `open` / `save` store seam. Event-free (a registry, like `WorkspaceManager`); the observability lives on each `Conversation`. |
506
- | `MemoryConversationStore` | class | Implements the `ConversationStoreInterface` in memory — a process-lifetime `Map` of `ConversationSnapshot`s keyed by conversation id, the default store `createMemoryConversationStore` builds and the default backing for `open` / `save`. The exact twin of `MemoryWorkspaceStore`. |
507
- | `DatabaseConversationStore` | class | Backs a `ConversationStoreInterface` with one table of the `databases` layer — a conversation's durable state is a row holding the snapshot as one opaque JSON column, narrowed back on `get` by `isConversationSnapshot`, so persistence reduces to keyed point-access (`get` / `set` / `delete`) over a `TableInterface`. The driver-pluggable twin of the plain-`Map` `MemoryConversationStore`, and the exact twin of `DatabaseWorkspaceStore`. |
508
- | `Instruction` | class | Represents an immutable named directive — an `InstructionInterface` assembled once from its input (`name` / `content`, an optional `priority` defaulting to `0`), the `id` minted at construction. |
509
- | `InstructionManager` | class | Registers the immutable `Instruction`s a richer context assembles a directives block from — keyed by `name` so a re-`add` overwrites, last write wins, and listed by descending `priority`, carrying the `open` / `render` build contract and an observable `emitter`. |
510
- | `Scope` | class | Represents a named, immutable filter over a richer context's items — an optional allow-list per category (`instructions` / `tools` / `files`), each keyed by that category's identity (an instruction's `name`, a tool's `name`, a workspace file's `path`) and read as an allow-list: `undefined` lets everything pass, `[]` lets nothing pass, and a non-empty list passes the listed keys alone. `narrow` composes a tighter child by set intersection. |
511
- | `ScopeManager` | class | Registers the named filters a richer context reuses — immutable `Scope`s keyed by their minted `id`, in insertion order, where `create` always mints and stores rather than overwriting, and an observable `emitter` reports each change. |
512
- | `AgentContext` | class | Assembles a provider request from the richer turn context — the optional system prompt, the observable context managers (instructions / workspaces), the `ConversationManagerInterface` message source whose active conversation is `messages`, the `ToolManagerInterface` registry, and an active `ScopeInterface` changed through `AgentContextInterface.apply`. `build()` folds the scoped managers and the active workspace into one system block, then the conversation, and never reads `tools`. |
513
- | `Agent` | class | Composes a `ProviderInterface`, an `AgentContext`, and a `ToolManagerInterface` into a bounded context → provider → tools → repeat turn, exposed as both a one-shot `generate` and a live `stream` that share one private run — bounded by the run `signal`, the `timeout`, and the `budget` folded through `AbortSignal.any`, paced by `scheduler`, with tool iteration capped at `limit`. |
514
- | `Authority` | class | Gates the agent loop's tool calls — the synchronous policy consulted before each call runs, turning one `AuthorityContext` into an `AuthorityDecision` by walking the ordered rules first-match-wins and falling back to a configurable default, which allows an unmatched call unless its `fallback` denies. |
515
- | `AgentRegistry` | class | Makes a durable, JSON-serializable `AgentJobInput` runnable — holds the named pools of live, non-serializable pieces (providers, tools, authorities, schedulers), throws on a name absent from its pool, and `build`s a seeded, signal-wired `Agent` from a job's names and data. |
516
- | `Channel` | class | Buffers chunks in a minimal unbounded async channel — the eager pump writes them in (`push`) and ends it (`close` / `fail`) regardless of consumption, while a consumer reads them back live through the `drain` async-iterator. Decoupling write from read is what lets a producer make progress without a consumer pulling, and it is why an agent's `result` settles whether or not its `events` are drained. |
517
- | `ThinkSplitter` | class | Feeds raw content deltas through a tiny stream-stateful state machine that routes everything inside a `<think>…</think>` span to `thinking` and returns everything outside it as clean content, so a provider yields the answer alone and surfaces the reasoning as `ProviderResult.thinking`. A tag split across deltas is held until disambiguated, `flush()` settles the stream end, and one splitter serves one stream. |
518
-
519
- ### Constants
520
-
521
- A `Shape` cell holds the constant's declared type.
522
-
523
- | API | Kind | Shape | Summary |
524
- | --------------------------- | ----- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
525
- | `CONVERSATION_RECAP_PREFIX` | const | `string` | Names the framing label a `ConversationInterface`'s `view()` prefixes onto each compacted section's summary so a small model reads it as a condensed recap of earlier turns — the lean `'[Summary of earlier messages] '` marker, never a literal assistant turn to echo or treat as the live answer. |
526
- | `DEFAULT_AGENT_LIMIT` | const | `number` | Caps an `AgentInterface` turn's tool iterations by default — `10` context → provider → tools cycles before the loop stops, so a model that keeps requesting tools can never loop forever. Overridable per agent through `AgentOptions.limit`. |
527
- | `DEFAULT_AUTHORITY_ZONE` | const | `string` | Names the zone an `AuthorityInterface`'s default fallback `AuthorityDecision` carries — `'default'`, the classification for a tool call that matched no rule. Paired with the default `allowed: true` fallback, an unmatched call is allowed under this zone, so a rules list of denials acts as a denylist; a caller wanting deny-by-default supplies an `allowed: false` `fallback` of their own (see `AuthorityOptions`). |
528
- | `DEFAULT_CONVERSATION_KEEP` | const | `number` | Sets the default number of recent live messages a `ConversationInterface`'s `compact()` retains verbatim — `0`, so a manual `compact()` folds every current live message into one summarized section and keeps no tail. A caller retains a recent tail by passing `keep` (on `ConversationOptions`, `ConversationManagerOptions`, or per-fold through `CompactOptions`), folding only the older `count - keep` messages and leaving the most recent `keep` live for the next turn. Overridable everywhere `keep` is accepted. |
529
- | `THINK_OPEN` | const | `string` | Names the opening tag a `ThinkSplitter` recognizes as the start of an in-content reasoning span — `'<think>'`, the de-facto wire convention thinking models (qwen3, DeepSeek-R1 family) emit their chain-of-thought under when a daemon renders it inline instead of on a separate wire field. Paired with `THINK_CLOSE`. |
530
- | `THINK_CLOSE` | const | `string` | Names the closing tag that ends a `THINK_OPEN` reasoning span — `'</think>'`. A span the stream never closes (the model was cut off mid-reasoning) is treated as thinking to its end, and `ThinkSplitterInterface.flush` settles it. |
531
- | `WORKSPACE_SECTION_HEADER` | const | `string` | Names the section header `AgentContext`'s `build()` renders the active workspace's text files under — `'## Workspace'`, the leading line of the dedicated workspace block in the system message and the carrier-split counterpart to the documents and images section headers. |
532
- | `MESSAGE_TOKEN_OVERHEAD` | const | `number` | Estimates the per-message role and framing overhead `estimateMessages` adds on top of a message's content estimate — `4` tokens for the fixed wire framing every conversation turn carries (its role tag, its delimiters) that `estimateTokens`'s content-only heuristic does not otherwise capture. |
533
- | `IMAGE_TOKEN_ESTIMATE` | const | `number` | Names the coarse, deliberately approximate per-image token cost `estimateMessages` charges for each attached image — `512`, because a base64 payload's length is no reliable token proxy. |
534
- | `DEFAULT_PROVIDER_TIMEOUT` | const | `number` | Holds the default provider deadline in milliseconds — `120_000`, the wall-clock bound a call runs under when `AgentProviderInput.timeout` is omitted, folded with the caller's signal so whichever trips first cancels the call. |
535
- | `MAX_ERROR_BODY_LENGTH` | const | `number` | Bounds the decoded error excerpt's input in bytes — `2048`, the leading bytes of a non-OK response body handed to the decoder before the read cancels the remainder, so a `ProviderError` message never carries a longer excerpt. |
536
- | `DEFAULT_RELAY_LIMIT` | const | `number` | Holds the default relay request limit in bytes — `1_048_576`, the byte budget a relay applies to an inbound body when `RelayOptions.limit` is omitted, refusing a body that reaches it. |
537
- | `RELAY_CONTENT_TYPE` | const | `string` | Names the relay's newline-delimited JSON content type — `'application/x-ndjson; charset=utf-8'`, the header a relay response carries beside `cache-control: no-store`. |
538
- | `RELAY_PROVIDER_MESSAGE` | const | `string` | Names the public message for an unexpected upstream relay failure — `'relay provider failed'`, the fixed text every `error` frame carries, so an upstream failure's own message never reaches the browser. |
539
- | `UNAUTHORIZED_RELAY_STATUS` | const | `number` | Names the status a relay answers when the `authorize` callback returns anything but `true` or throws — `401`, carried with no body and reaching the browser as a `ProviderError` with the `HTTP` code. |
540
- | `INVALID_RELAY_STATUS` | const | `number` | Names the status a relay answers for a body that is missing, unreadable, or rejected by `providerRequestContract` — `400`, carried with no body and reaching the browser as a `ProviderError` with the `HTTP` code. |
541
- | `OVERSIZED_RELAY_STATUS` | const | `number` | Names the status a relay answers for a request body at or above its byte budget — `413`, carried with no body and answered for an aborted inbound read as well. |
542
- | `UPSTREAM_RELAY_STATUS` | const | `number` | Names the status a relay answers when the upstream provider call cannot be constructed — `502`, carried with no body after `provider.stream` was entered and threw before returning its iterator. |
543
- | `SYSTEM_ONE_PATH` | const | `string` | Names the System One decision endpoint shared by compatible servers. |
544
-
545
- ### Shapes and contracts
546
-
547
- The JSON wire projections the relay validates against, and the contracts compiled from them. On a shape row a `Shape` cell holds the projected record's members as bare names in braces, `?` marking an optional member and a union's arms escaped as `\|`; on a contract row it holds the domain type that contract narrows to. Each projection is strictly narrower than the domain type it mirrors: a value JSON cannot carry is refused rather than silently dropped, and a `ToolCall`'s `caller` is local context that never appears here.
671
+ 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 can't be constructed. Each refusal carries no body and reaches the browser as a `ProviderError` with the `HTTP` code and that status.
672
+
673
+ The wire is strictly narrower than the domain on purpose. `providerRequestContract` and `relayFrameContract` are compiled from the shapes listed under [Providers module](#providers-module), and `RelayProvider.encode` refuses a request the JSON wire can't 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`.
674
+
675
+ ### Root module
676
+
677
+ The root holds the package's shared vocabulary: the message and judge value types, the role list, the judge abort error, the message and tool-call wire shapes with the message contract, and the cloner, helpers, and guards that more than one module reads.
678
+
679
+ Each module section lists its exports by kind file, and a `Shape` cell reads by table. On a constant it holds the declared type. On a type it holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, with an extended interface's name before `plus`, or a type alias's own type literal with a union's arms escaped as `\|`. On a shape it holds the projected record's members in the same notation, on a contract the domain type the contract narrows to, and on a guard the type the guard narrows to. Each guard reads an `unknown`, returns `false` off-shape, and never throws, and an error guard stays in its module's Errors table beside the error it narrows.
680
+
681
+ #### Types
682
+
683
+ The following table lists the types the root declares:
684
+
685
+ | Type | Kind | Shape | Summary |
686
+ | ------------------- | --------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
687
+ | `MessageRole` | type | `'system' \| 'user' \| 'assistant' \| 'tool'` | Names the role a `Message` plays in a conversation turn. |
688
+ | `ThinkingReplay` | type | `'none' \| 'turn' \| 'all'` | Names which thinking the agent loop, a relay server, and a ledger retain before calling a provider: `'none'` retains none, `'turn'` retains thinking after the last `user` message, and `'all'` retains all thinking. A direct `generate` or `stream` call sends messages as given. |
689
+ | `Message` | interface | `{ id, role, content, calls?, call?, images?, thinking? }` | Represents one conversation turn fed to a `ProviderInterface` — a stored, identified message. |
690
+ | `MessageInput` | interface | `{ role, content, calls?, call?, images?, thinking? }` | Carries the minimal data needed to author a `Message` — the `id` is assigned by the layer that stores it, so a caller supplies only role / content (and, for a replayed assistant turn, its `calls`; for a tool turn, the `call` it answers). |
691
+ | `JudgeEntry` | type | `string \| JSONRecord \| readonly JSONValue[]` | Carries text or structured JSON the model reads; mirrors the TypeSafe `EntryType` without its null arm. |
692
+ | `ChoiceCriteria` | type | `Readonly<Record<string, JudgeEntry \| null>>` | Maps each option name the model receives to its description; null keeps an undescribed option in the map. |
693
+ | `ScoreCriteria` | type | `readonly [JudgeEntry \| null, JudgeEntry \| null, ...Array<JudgeEntry \| null>]` | Lists at least 2 score levels from level 0 upward; null leaves a level undescribed. |
694
+ | `NoulCriteria` | interface | `{ true?, false? }` | Carries what makes a noul answer true and what makes it false; an omitted side is undescribed. |
695
+ | `ChoiceQuestion` | interface | `{ form, instructions?, criteria }` | Asks the model to pick one named option from its criteria. |
696
+ | `ScoreQuestion` | interface | `{ form, instructions?, criteria }` | Asks the model to place the state on an ordered scale of levels. |
697
+ | `NoulQuestion` | interface | `{ form, instructions?, criteria? }` | Asks the model whether a statement about the state holds. |
698
+ | `JudgeQuestion` | type | `ChoiceQuestion \| ScoreQuestion \| NoulQuestion` | Names one question by its form, the protocol's type field under the fleet's named discriminant. |
699
+ | `JudgeRequest` | interface | `{ state, questions }` | Carries one state and the questions asked about it, keyed by caller ids the prompt never carries; each question is evaluated on its own. |
700
+ | `ChoiceAnswer` | interface | `{ form, probabilities }` | Carries a choice distribution keyed by option name, in criteria order. |
701
+ | `ScoreAnswer` | interface | `{ form, probabilities }` | Carries a score distribution indexed by level. |
702
+ | `NoulAnswer` | interface | `{ form, noul }` | Carries the probability that the answer is yes; the protocol's noul field. |
703
+ | `JudgeAnswer` | type | `ChoiceAnswer \| ScoreAnswer \| NoulAnswer` | Names one answer by its form; it stores only the distribution a server returned. |
704
+ | `Refusal` | interface | `{ missing }` | Reports a question a wire could not read a candidate for; it lists the caller's keys and invents no probability. |
705
+ | `JudgeResult` | interface | `{ model, answers, refusals?, usage? }` | Carries the answering model, the answers keyed by question id, the refusals, and the usage the request's calls spent. |
706
+ | `JudgeInterface` | interface | `{ id, name, model } plus ask` | Answers typed questions about one state with probabilities; the sibling of `ProviderInterface`, never a provider. |
707
+ | `Judgment` | interface | `{ id, question, answer?, refusal?, model, sources, state, time, usage? }` | Records an answered or refused question with its sources, state, model, and storage time. |
708
+ | `JudgmentInput` | interface | `{ id, question, answer?, refusal?, model, sources, state, usage? }` | Supplies an answered or refused question for storage before its time is stamped. |
709
+ | `CodedErrorOptions` | interface | `{ cause?, context? }` | Carries the optional cause and context an error class with a machine-readable `code` takes at construction. |
710
+
711
+ #### Constants
712
+
713
+ The following table lists the constants the root declares:
714
+
715
+ | API | Kind | Shape | Summary |
716
+ | --------------- | ----- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
717
+ | `MESSAGE_ROLES` | const | `readonly ['system', 'user', 'assistant', 'tool']` | Lists the roles a conversation message can play, in the order the wire contract names them — the one list the `MessageRole` union derives from, the message guard tests membership against, and the message shape passes to its literal contract. |
718
+
719
+ #### Errors
720
+
721
+ The following table lists the errors the root declares and the guards that narrow them:
722
+
723
+ | API | Kind | Summary |
724
+ | ------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
725
+ | `JudgeAbortError` | class | Reports a judge call cancelled by the caller's signal or its deadline, carrying the `JudgeResult` merged from the calls that completed before the cancel and the machine-readable `code` `'ABORT'`. |
726
+ | `isJudgeAbortError` | function | Narrows a caught value to a `JudgeAbortError` through `instanceof`, so a `catch` can recover its `partial` result. |
727
+
728
+ #### Shapes and contracts
729
+
730
+ The following table lists the root wire shapes and the contract compiled from them:
731
+
732
+ | API | Kind | Shape | Summary |
733
+ | ----------------- | ----- | ---------------------------------------------------------- | --------------------------------------------------------------------- |
734
+ | `toolCallShape` | const | `{ id, name, arguments }` | Describes a tool call's JSON wire projection. |
735
+ | `messageShape` | const | `{ id, role, content, calls?, call?, images?, thinking? }` | Describes a conversation message's JSON wire projection. |
736
+ | `messageContract` | const | `Message` | Validates and projects conversation messages at a JSON wire boundary. |
737
+
738
+ #### Cloners
739
+
740
+ The following table lists the cloners the root declares:
741
+
742
+ | API | Kind | Summary |
743
+ | ---------- | -------- | -------------------------------------------------------------------------------------------------------- |
744
+ | `copyJSON` | function | Owns a value by serializing it to JSON and parsing the text, so the copy shares nothing with its source. |
745
+
746
+ #### Helpers
747
+
748
+ The following table lists the helpers the root declares:
749
+
750
+ | API | Kind | Summary |
751
+ | ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
752
+ | `filterAllowList` | function | Filters a list of members by a `ScopeInterface` allow-list of keys — `undefined` passes everything, `[]` passes nothing, and a non-empty list passes the listed keys alone, order preserved. The pure, total set-membership primitive the context's build step and the agent loop's tool-advertise step apply a scope through. |
753
+ | `sanitizeToken` | function | Sanitizes one reported token count into a non-negative integer — a non-finite or non-positive value becomes `0`, and a positive fractional value floors down. |
754
+ | `sanitizeUsage` | function | Sanitizes a `TokenUsage` into non-negative integers — the guard an agent's abort-usage path applies to a provider's partial usage before it is charged against a budget or folded into the run total. |
755
+ | `joinThinking` | function | Joins the reasoning a run's provider calls separated from the answer — the first call seeds the accumulation, a later call appends blank-line separated so each turn's reasoning stays readable. |
756
+ | `stripThinking` | function | Applies a `ThinkingReplay` policy to a conversation, returning the messages a provider sends with only the assistant thinking the policy allows. |
757
+ | `sumUsage` | function | Adds two `TokenUsage` values field by field — the running total an agent run keeps across its provider calls. |
758
+ | `removeEntries` | function | Removes each key through a single-key remover and folds the outcomes, so a batch `remove` reports whether the whole batch applied. |
759
+ | `collectExchanges` | function | Collects whole exchanges, joining every exchange spanned by a tool group. |
760
+ | `collectToolGroups` | function | Collects each assistant message that carries calls together with the tool messages that answer it, then each run of tool messages that no assistant message owns. |
761
+ | `hasCalls` | function | Checks whether a message carries at least one tool call. |
762
+ | `renderJSON` | function | Renders a value as the JSON text `JSON.stringify` produces, or `undefined` when JSON cannot carry the value. |
763
+ | `matchesJudgment` | function | Matches a recorded question, ordered sources, rendered state, and judge identity by JSON text, so key order counts. |
764
+
765
+ #### Validators
766
+
767
+ The following table lists the guards the root declares:
768
+
769
+ | API | Kind | Shape | Summary |
770
+ | -------------------------- | -------- | --------------- | ------------------------------------------------------------------------------------------ |
771
+ | `isJudgeEntry` | function | `JudgeEntry` | Checks whether a value is a judge entry: a string, a JSON record, or a JSON array. |
772
+ | `isJudgeQuestion` | function | `JudgeQuestion` | Checks whether a value is a well-formed judge question of the choice, score, or noul form. |
773
+ | `isNonNegativeSafeInteger` | function | `number` | Checks whether a value is a non-negative safe integer, `-0` included. |
774
+
775
+ ### Providers module
776
+
777
+ The `providers` module holds the inference boundary and its HTTP engine, the relay, the reasoning splitter, and the judge engine with the System One wire.
778
+
779
+ #### Types
780
+
781
+ The following table lists the types the providers module declares:
782
+
783
+ | Type | Kind | Shape | Summary |
784
+ | ------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
785
+ | `ProviderResult` | interface | `{ content, thinking?, tools?, usage?, truncated? }` | Holds a single inference turn's structured outcome — the assembled assistant content, any reasoning the provider separated from it, any tool calls the model requested, and the token usage it reported. |
786
+ | `ProviderDelta` | type | `{ channel: 'content', text } \| { channel: 'thinking', text }` | Represents one streamed delta the `stream` method of a `ProviderInterface` yields — a unit tagged by the channel it belongs to, so the agent loop can forward answer content and live reasoning separately as each delta arrives. |
787
+ | `ProviderStreamOptions` | interface | `{ think?, schema? }` | Carries the per-call options threaded into the `generate` and `stream` methods of a `ProviderInterface` — the bag a caller passes to influence one inference call without reconfiguring the provider instance. |
788
+ | `ProviderInterface` | interface | `{ id, name, replay? } plus generate, stream` | Defines the pluggable LLM inference boundary — the one contract every agent chunk depends on. A provider turns a conversation (plus optional tools) into either a single assembled `ProviderResult` (`generate`) or a stream of `ProviderDelta` values that returns the assembled result (`stream`). The agent loop, a relay server, and a ledger apply its `replay` policy before calling it. A direct `generate` or `stream` call sends messages as given. |
789
+ | `ProviderParserInterface` | interface | `{} plus parse, clear` | Defines the structural framing seam supplied by a concrete provider. |
790
+ | `ProviderRequest` | interface | `{ messages, tools?, options? }` | Carries the conversation and per-call configuration sent to a provider. |
791
+ | `ProviderIncrement` | interface | `{ content, thinking, tools, usage?, truncated?, result? }` | Holds the decoded contribution of a wire record to a provider turn. |
792
+ | `ProviderOptions` | interface | `{ timeout?, replay?, fetch?, headers? }` | Configures a provider's deadline, transport, and headers. |
793
+ | `AgentProviderInput` | interface | `ProviderOptions plus { url, path?, split?, strict? }` | Configures the HTTP destination and stream assembly of a provider base. |
794
+ | `AgentProviderInterface` | interface | `ProviderInterface plus frame, encode, read, finish` | Defines the wire-specific seams of the shared HTTP provider engine. |
795
+ | `ProviderErrorCode` | type | `'HTTP' \| 'PROTOCOL' \| 'PROVIDER'` | Names the machine-readable provider failure conditions. |
796
+ | `ProviderErrorOptions` | interface | `{ status?, cause? }` | Carries a provider failure's HTTP status and underlying cause. |
797
+ | `FailureRead` | interface | `{ message, status, cause? }` | Carries an HTTP failure message, the response status, and the underlying body-read cause when available. |
798
+ | `TextRead` | interface | `{ text, complete }` | Carries a decoded stream prefix and whether the stream ended within its byte budget. |
799
+ | `RelayFrame` | type | `ProviderDelta \| { channel: 'result', result } \| { channel: 'abort', partial } \| { channel: 'error', message }` | Carries a relay delta, settled result, remote abort, or remote failure. |
800
+ | `RelayHandler` | type | `(request: Request) => Promise<Response>` | Defines a host-independent relay request handler. |
801
+ | `RelayOptions` | interface | `{ provider, authorize, limit? }` | Configures the upstream provider, mandatory authorization, and request byte limit. |
802
+ | `RelayProviderOptions` | interface | `ProviderOptions plus { url, parser }` | Configures a relay destination and its fresh structural parser factory. |
803
+ | `RelayStreamOptions` | interface | `{ provider, request, signal }` | Carries the upstream call and cancellation bound of a relay response stream. |
804
+ | `ThinkSplitterInterface` | interface | `{ content, thinking } plus split, flush` | Splits a thinking model's in-content `<think>…</think>` reasoning spans away from the answer, delta by delta with per-stream state, so a provider yields clean content alone and surfaces the reasoning as `ProviderResult.thinking`. |
805
+ | `AgentJudgeInput` | interface | `Pick<ProviderOptions, 'timeout' \| 'fetch' \| 'headers'> plus { url, path?, model, batch? }` | Configures the judge engine's destination, identity, and call split. |
806
+ | `AgentJudgeInterface` | interface | `JudgeInterface plus encode, read` | Defines the wire seams of the shared judge engine. |
807
+ | `JudgeErrorCode` | type | `'HTTP' \| 'PROTOCOL' \| 'QUESTION'` | Names the machine-readable judge failure conditions. |
808
+ | `Reading` | interface | `{ winner, probability, confidence, score? }` | Carries the measures `computeReading` derives from an answer; nothing stores them. |
809
+ | `SystemOneJudgeOptions` | interface | `Pick<ProviderOptions, 'timeout' \| 'fetch' \| 'headers'> plus { url, model }` | Configures the System One server, model, transport, authentication, and deadline. |
810
+ | `SystemOneEntry` | type | `JudgeEntry \| null` | Carries a TypeSafe System One entry, including the wire's explicit null. |
811
+ | `SystemOneQuestion` | interface | `{ type, instructions?, criteria? }` | Transliterates a TypeSafe System One question with its protocol discriminant and criteria. |
812
+ | `SystemOneRequest` | interface | `{ state, model, questions }` | Transliterates the TypeSafe System One request body. |
813
+ | `SystemOneUsage` | interface | `{ input_tokens?, output_tokens? }` | Transliterates TypeSafe System One token counts with missing or null counts permitted. |
814
+ | `SystemOneChoiceAnswer` | interface | `{ type, probabilities, choice?, confidence? }` | Transliterates a TypeSafe System One choice distribution and optional server measures. |
815
+ | `SystemOneScoreAnswer` | interface | `{ type, probabilities, score?, legend?, confidence? }` | Accepts System One score probabilities and legends as maps or llama.cpp arrays. |
816
+ | `SystemOneNoulAnswer` | interface | `{ type, noul, confidence? }` | Transliterates a TypeSafe System One yes probability and optional server confidence. |
817
+ | `SystemOneAnswer` | type | `SystemOneChoiceAnswer \| SystemOneScoreAnswer \| SystemOneNoulAnswer` | Unites the TypeSafe System One answer forms. |
818
+ | `SystemOneResponse` | interface | `{ model?, answers, usage? }` | Transliterates the System One response envelope before question-specific answer validation. |
819
+
820
+ #### Constants
821
+
822
+ The following table lists the constants the providers module declares:
823
+
824
+ | API | Kind | Shape | Summary |
825
+ | --------------------------- | ----- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
826
+ | `THINK_OPEN` | const | `string` | Names the opening tag a `ThinkSplitter` recognizes as the start of an in-content reasoning span — `'<think>'`, the de-facto wire convention thinking models (qwen3, DeepSeek-R1 family) emit their chain-of-thought under when a daemon renders it inline instead of on a separate wire field. Paired with `THINK_CLOSE`. |
827
+ | `THINK_CLOSE` | const | `string` | Names the closing tag that ends a `THINK_OPEN` reasoning span — `'</think>'`. A span the stream never closes (the model was cut off mid-reasoning) is treated as thinking to its end, and `ThinkSplitterInterface.flush` settles it. |
828
+ | `DEFAULT_PROVIDER_TIMEOUT` | const | `number` | Holds the default provider deadline in milliseconds — `120_000`, the wall-clock bound a call runs under when `AgentProviderInput.timeout` is omitted, folded with the caller's signal so whichever trips first cancels the call. |
829
+ | `MAX_ERROR_BODY_LENGTH` | const | `number` | Bounds the decoded error excerpt's input in bytes — `2048`, the leading bytes of a non-OK response body handed to the decoder before the read cancels the remainder, so a `ProviderError` message never carries a longer excerpt. |
830
+ | `DEFAULT_RELAY_LIMIT` | const | `number` | Holds the default relay request limit in bytes — `1_048_576`, the byte budget a relay applies to an inbound body when `RelayOptions.limit` is omitted, refusing a body that reaches it. |
831
+ | `RELAY_CONTENT_TYPE` | const | `string` | Names the relay's newline-delimited JSON content type — `'application/x-ndjson; charset=utf-8'`, the header a relay response carries beside `cache-control: no-store`. |
832
+ | `RELAY_PROVIDER_MESSAGE` | const | `string` | Names the public message for an unexpected upstream relay failure — `'relay provider failed'`, the fixed text every `error` frame carries, so an upstream failure's own message never reaches the browser. |
833
+ | `UNAUTHORIZED_RELAY_STATUS` | const | `number` | Names the status a relay answers when the `authorize` callback returns anything but `true` or throws — `401`, carried with no body and reaching the browser as a `ProviderError` with the `HTTP` code. |
834
+ | `INVALID_RELAY_STATUS` | const | `number` | Names the status a relay answers for a body that is missing, unreadable, or rejected by `providerRequestContract` — `400`, carried with no body and reaching the browser as a `ProviderError` with the `HTTP` code. |
835
+ | `OVERSIZED_RELAY_STATUS` | const | `number` | Names the status a relay answers for a request body at or above its byte budget — `413`, carried with no body and answered for an aborted inbound read as well. |
836
+ | `UPSTREAM_RELAY_STATUS` | const | `number` | Names the status a relay answers when the upstream provider call cannot be constructed — `502`, carried with no body after `provider.stream` was entered and threw before returning its iterator. |
837
+ | `SYSTEM_ONE_PATH` | const | `string` | Names the System One decision endpoint shared by compatible servers. |
838
+
839
+ #### Errors
840
+
841
+ The following table lists the errors the providers module declares and the guards that narrow them:
842
+
843
+ | API | Kind | Summary |
844
+ | ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
845
+ | `ProviderAbortError` | class | Reports a provider stream cancelled mid-flight by its bound signal — thrown by the `stream` method of a `ProviderInterface`, carrying the `ProviderResult` assembled from whatever streamed before the cancel and the machine-readable `code` `'ABORT'`. |
846
+ | `isProviderAbortError` | function | Narrows an unknown caught value to a `ProviderAbortError` through `instanceof`, so a `catch` can recover its `partial` result. |
847
+ | `ProviderError` | class | Reports a coded provider failure with its HTTP status and underlying cause when available. |
848
+ | `isProviderError` | function | Narrows a caught value to the provider failure class through `instanceof`. |
849
+ | `JudgeError` | class | Reports a coded judge failure with its HTTP status and underlying cause when available. |
850
+ | `isJudgeError` | function | Narrows a caught value to the judge failure class through `instanceof`. |
851
+
852
+ #### Shapes and contracts
853
+
854
+ The JSON wire projections the relay validates against, and the contracts compiled from them, sit here. Each projection is strictly narrower than the domain type it mirrors: a value JSON can't carry is refused rather than silently dropped, and the `caller` member of a `ToolCall` is local context that never appears here.
548
855
 
549
856
  | API | Kind | Shape | Summary |
550
857
  | ------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
551
- | `toolCallShape` | const | `{ id, name, arguments }` | Describes a tool call's JSON wire projection. |
552
- | `messageShape` | const | `{ id, role, content, calls?, images? }` | Describes a conversation message's JSON wire projection. |
553
858
  | `providerRequestShape` | const | `{ messages, tools?, options? }` | Describes a provider request's JSON wire projection. |
554
- | `providerResultShape` | const | `{ content, thinking?, tools?, usage? }` | Describes a provider result's JSON wire projection. |
859
+ | `providerResultShape` | const | `{ content, thinking?, tools?, usage?, truncated? }` | Describes a provider result's JSON wire projection. |
555
860
  | `relayFrameShape` | const | `{ channel: 'content' \| 'thinking', text } \| { channel: 'result', result } \| { channel: 'abort', partial } \| { channel: 'error', message }` | Describes the channel-discriminated JSON relay wire projection. |
556
- | `messageContract` | const | `Message` | Validates and projects conversation messages at a JSON wire boundary. |
557
861
  | `providerRequestContract` | const | `ProviderRequest` | Validates and projects provider requests at a JSON wire boundary. |
558
862
  | `providerResultContract` | const | `ProviderResult` | Validates and projects provider results at a JSON wire boundary. |
559
863
  | `relayFrameContract` | const | `RelayFrame` | Validates and projects channel-discriminated relay frames at a JSON wire boundary. |
@@ -579,44 +883,344 @@ relayFrameContract.is({ channel: 'error', message: 'oops', code: 'X' }) // false
579
883
  relayFrameContract.parse({ channel: 'error', message: 'oops', code: 'X' }) // { channel: 'error', message: 'oops' } — parse projects the extra member away
580
884
  ```
581
885
 
582
- ### Helpers
583
-
584
- | API | Kind | Summary |
585
- | ------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
586
- | `agentResultToJSON` | function | Projects an unknown value onto a fresh, exact `JSONValue` representation of an `AgentResult` — capturing each structural field once through a total boundary, accepting conforming accessors and inherited properties, preserving finite negative and fractional usage counts, dropping extras, and resolving `undefined` for a malformed field, a non-finite usage number, a throwing getter, or a hostile or revoked proxy. |
587
- | `filterAllowList` | function | Filters a list of items by a `ScopeInterface` allow-list of keys — `undefined` passes everything, `[]` passes nothing, and a non-empty list passes the listed keys alone, order preserved. The pure, total set-membership primitive the context's build step and the agent loop's tool-advertise step apply a scope through. |
588
- | `estimateTokens` | function | Estimates the context-token footprint of a string — the deterministic `ceil(length / 4)` character heuristic `estimateMessages` sums over a conversation's messages (the default context-budget estimator). |
589
- | `estimateMessages` | function | Estimates the context-token footprint of a batch of messages — each message's content plus `MESSAGE_TOKEN_OVERHEAD`, a tool-call JSON estimate, and `IMAGE_TOKEN_ESTIMATE` for each attached image. The default `consumer` estimator for an agent's context budget (the `AgentOptions` `window`), total and never throwing, and a deliberate provider-agnostic approximation rather than an exact tokenizer count. |
590
- | `sanitizeToken` | function | Sanitizes one reported token count into a safe non-negative integer — a non-finite or non-positive value becomes `0`, and a positive fractional value floors down. |
591
- | `sanitizeUsage` | function | Sanitizes a `TokenUsage` into safe, non-negative integers — the guard an agent's abort-usage path applies to a provider's partial usage before it is charged against a budget or folded into the run total. |
592
- | `settleAgentJob` | function | Runs one rehydrated agent and applies the partial-as-configurable-failure policy — a partial run throws an `AgentJobError` unless the `partial` policy allows it, and a natural finish resolves. The shared job-handler step `createAgentQueue` and `createAgentRunner` both settle each job through, so the policy can never diverge between them. |
593
- | `handleAgentQueueJob` | function | Handles one queued agent job by rehydrating it through a registry with the queue attempt's signal, then applying the shared partial-result policy. |
594
- | `handleAgentRunnerJob` | function | Handles one runner agent job by fanning out its declared children, rehydrating the parent through a registry with the controller signal, and applying the shared partial-result policy. |
595
- | `renderFencedFile` | function | Renders a path-addressed text body as a fenced reference block — a `File: <path>` label line over a language-tagged fence, the framing an `AgentContext`'s active-workspace text-file render emits. |
596
- | `joinThinking` | function | Joins the reasoning a run's provider calls separated from the answer — the first call seeds the accumulation, a later call appends blank-line separated so each turn's reasoning stays readable. |
597
- | `sumUsage` | function | Adds two `TokenUsage` values field by field — the running total an agent run keeps across its provider calls. |
598
- | `assembleResult` | function | Assembles the settled `AgentResult` from a run's `RunOutcome` — `thinking` and `usage` are carried only when the run surfaced them, and the loop-internal `exhausted` flag is left out. |
599
- | `denyCall` | function | Synthesizes the denial `ToolResult` an authority-blocked call is fed back with — the call's `id` / `name` keyed back, carrying a denial `error` instead of a value. |
600
- | `renderSection` | function | Renders one context section — the resolved `open`, each item's rendering, and the resolved `close` when one exists, blank-line joined; `undefined` when the section has no items. |
601
- | `resolveOpen` | function | Resolves one section's open text through the format cascade — manager-options override > provider default > built-in header. |
602
- | `resolveClose` | function | Resolves one section's close text through the format cascade — manager-options override > provider default; `undefined` when neither sets one, because there is no built-in close. |
603
- | `resolveItem` | function | Resolves one item's rendering through the format cascade — item override > manager-options override > provider default > built-in rendering. |
604
- | `attachImages` | function | Copies a message with image data merged onto its `images` — the message's own images first, then the attached data, carrying `calls` only when present and never mutating the original. |
605
- | `attachUserImages` | function | Attaches image data to a conversation's last user message — the turn a vision provider reads images off — as a new array with that one message replaced by its carrying copy, and unchanged when there is no data or no user turn. |
606
- | `collectImageData` | function | Collects the `base64` payload of the image files in a workspace file list — the data an agent context attaches to the last user message. |
607
- | `buildSummaryMessage` | function | Builds the raw synthetic summary message for one compacted section — role `'assistant'`, the section's stable `id`, and its `summary` verbatim as content. |
608
- | `buildRecapMessage` | function | Builds the framed recap message for one compacted section — the same role and stable `id` as `buildSummaryMessage`, with the content prefixed by `CONVERSATION_RECAP_PREFIX`. |
609
- | `buildProviderResult` | function | Assembles a provider result with only populated optional fields. |
610
- | `readText` | function | Reads a UTF-8 prefix of a byte stream and cancels its remainder. |
611
- | `readChunks` | function | Decodes UTF-8 chunks with a final flush and releases the stream on every exit. |
612
- | `intersectKeys` | function | Intersects two scope category lists under the "`undefined` is the universal set" rule — a fresh copy that can only tighten, and the primitive a scope narrows through. |
613
- | `computeReading` | function | Derives the winner, its probability, the published confidence, and a score answer's expected level from a judge answer's distribution. |
614
- | `buildJudgeResult` | function | Merges the results of a judge request's calls into one result. |
615
- | `copyJSON` | function | Owns a value by serializing it to JSON and parsing the text, so the copy shares nothing with its source. |
616
- | `readHeaders` | function | Builds a request's JSON headers, awaiting the caller's header hook inside the call's cancellation bound. |
617
- | `questionToSystemOne` | function | Projects a judge question onto the System One wire while preserving omitted members. |
618
- | `extractSystemOneAnswer` | function | Extracts a System One distribution in question criteria order and drops server measures. |
619
- | `extractSystemOneUsage` | function | Maps complete System One token counts onto validated token usage. |
886
+ #### Factories
887
+
888
+ The following table lists the factories the providers module declares:
889
+
890
+ | API | Kind | Summary |
891
+ | ---------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
892
+ | `createRelay` | function | Creates an authorized relay handler that validates a bounded JSON request before streaming. |
893
+ | `createRelayProvider` | function | Creates a provider that carries calls through a relay endpoint. |
894
+ | `createSystemOneJudge` | function | Creates a judge that sends every question through the configured System One server. |
895
+ | `createThinkSplitter` | function | Creates a fresh stream-stateful `<think>` separator — a `ThinkSplitterInterface` that splits a thinking model's in-content `<think>…</think>` reasoning spans away from the answer, delta by delta, so a provider yields clean content alone and surfaces the accumulated reasoning as `ProviderResult.thinking`. One splitter serves one stream. |
896
+
897
+ #### Classes
898
+
899
+ The following table lists the classes the providers module declares:
900
+
901
+ | API | Kind | Summary |
902
+ | ---------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
903
+ | `AgentProvider` | class | Implements bounded HTTP streaming and result assembly behind concrete wire seams. |
904
+ | `RelayProvider` | class | Carries provider calls over an authenticated NDJSON relay endpoint. |
905
+ | `RelayStream` | class | Streams a provider call as validated NDJSON frames under response backpressure. |
906
+ | `AgentJudge` | class | Implements the bounded HTTP calls, validation, and result merging of a judge behind concrete wire seams. |
907
+ | `SystemOneJudge` | class | Carries judge questions over the System One protocol and derives answers from server distributions. |
908
+ | `ThinkSplitter` | class | Separates `<think>…</think>` spans from raw content deltas with per-stream state: text inside a span accumulates on `thinking`, and text outside it returns as clean content, so a provider yields the answer alone and surfaces the reasoning as `ProviderResult.thinking`. A tag split across deltas is held until disambiguated, `flush()` settles the stream end, and one splitter serves one stream. |
909
+
910
+ #### Helpers
911
+
912
+ The following table lists the helpers the providers module declares:
913
+
914
+ | API | Kind | Summary |
915
+ | ------------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------- |
916
+ | `buildProviderResult` | function | Assembles a provider result with only populated optional fields. |
917
+ | `armReaderAbort` | function | Arms signal propagation to a byte reader and returns the controller that removes the listener. |
918
+ | `readFailure` | function | Reads a bounded HTTP failure excerpt and preserves an unreadable body's cause. |
919
+ | `readText` | function | Reads a UTF-8 prefix of a byte stream and cancels its remainder. |
920
+ | `readChunks` | function | Decodes UTF-8 chunks with a final flush and releases the stream on every exit. |
921
+ | `releaseReader` | function | Cancels a stream reader and releases its lock, swallowing a cancellation failure so the caller's own outcome stands. |
922
+ | `computeReading` | function | Derives the winner, its probability, the published confidence, and a score answer's expected level from a judge answer's distribution. |
923
+ | `buildJudgeResult` | function | Merges the results of a judge request's calls into one result. |
924
+ | `buildProviderHeaders` | function | Builds a request's JSON headers, awaiting the caller's header hook inside the call's cancellation bound. |
925
+ | `requestToRelay` | function | Projects a provider request onto the declared members the relay wire carries. |
926
+ | `questionToSystemOne` | function | Projects a judge question onto the System One wire while preserving omitted members. |
927
+ | `extractSystemOneAnswer` | function | Extracts a System One distribution in question criteria order and drops server measures. |
928
+ | `extractSystemOneUsage` | function | Maps complete System One token counts onto validated token usage. |
929
+
930
+ #### Validators
931
+
932
+ The following table lists the guards the providers module declares:
933
+
934
+ | API | Kind | Shape | Summary |
935
+ | --------------------- | -------- | ------------------- | -------------------------------------------------------------------------------------------- |
936
+ | `isSystemOneResponse` | function | `SystemOneResponse` | Checks whether a value is a System One response envelope with optional model and usage. |
937
+ | `isSystemOneAnswer` | function | `SystemOneAnswer` | Checks whether a value is a System One answer whose type and distribution the wire can read. |
938
+
939
+ ### Conversations module
940
+
941
+ The `conversations` module holds the conversation, its judgment store, the conversation registry, and the two stores.
942
+
943
+ #### Types
944
+
945
+ The following table lists the types the conversations module declares:
946
+
947
+ | Type | Kind | Shape | Summary |
948
+ | ------------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
949
+ | `MessageManagerInterface` | interface | `{ count } plus add, message, messages, remove, clear` | Stores immutable `Message` records in insertion order and mints each `id` on `add` — the message-store contract `AgentContextInterface.messages` is typed to, which the active `ConversationInterface` satisfies structurally. |
950
+ | `ConversationSummaryHandler` | type | `(messages: readonly Message[], signal?: AbortSignal) => Promise<string>` | Summarizes a conversation, provider-agnostically — the seam the agent runtime supplies so core never imports a provider. Given the folded messages, it resolves their digest, the model-written summary of a compacted `Section` or of the oldest sections a `sections` cap merges. |
951
+ | `Section` | interface | `{ id, summary, messages }` | Holds a slice of folded messages digested into a summary — the unit of compaction a `ConversationInterface` produces when its `compact()` call folds the live tail. |
952
+ | `ConversationEventMap` | type | `{ compact, rehydrate, collapse }` | Maps the push observation surface of a `ConversationInterface` — the compaction moments a fire-and-forget observer subscribes to through `conversation.emitter.on`. |
953
+ | `ConversationOptions` | interface | `{ id?, on?, error?, summarize?, keep?, sections?, snapshot? }` | Configures `createConversation` — the optional `id`, the reserved `on` hooks, the provider-agnostic `summarize` seam, the retained-tail size, an optional cap on the compacted `sections` list, and a `ConversationSnapshot` to hydrate from. |
954
+ | `CompactOptions` | interface | `{ keep?, sections?, signal? }` | Configures one `ConversationInterface.compact` call — the retained-tail size, the `sections` cap, or both, overridden for one fold. |
955
+ | `ConversationReferenceOptions` | interface | `{ label?, messages? }` | Configures `ConversationInterface.reference` — how to render one conversation as a self-labeled, fenced provenance block to pull into another conversation by writing it to the active workspace of the active context: `label` names the source and `messages` lists the cherry-picked excerpts. |
956
+ | `ConversationInterface` | interface | `{ id, judgments, emitter, sections, summarizable, count } plus add, message, messages, remove, clear, view, compact, rehydrate, search, reference, snapshot` | Groups messages above the flat `MessageManagerInterface` — a live uncompacted tail plus compacted, summarized `Section` records, with on-demand `rehydrate`, substring `search`, a cross-conversation `reference`, and a JSON `snapshot`, driven by a provider-agnostic `ConversationSummaryHandler` seam; `summarizable` reports whether that seam was supplied, and the agent loop gates automatic compaction on it. |
957
+ | `ConversationInput` | interface | `{ id?, summarize?, keep?, sections?, rolling?, on?, error?, snapshot? }` | Carries the data to author a `ConversationInterface` through a `ConversationManagerInterface` — the optional `id`, a `summarize` override, a `keep` override, a `sections` cap override, the `rolling` switch, the reserved `on` hooks, and a `ConversationSnapshot` to hydrate from. |
958
+ | `ConversationManagerOptions` | interface | `{ summarize?, keep?, sections?, store? }` | Configures `createConversationManager` — the default `ConversationSummaryHandler`, retained-tail size, and `sections` cap the conversations it creates inherit, plus the optional durable `store` backing `open` / `save`. |
959
+ | `ConversationManagerInterface` | interface | `{ count, active } plus conversation, conversations, add, switch, open, save, remove, clear` | Registers `ConversationInterface` instances keyed by their `id`, in insertion order, with an active pointer — the id-keyed store over the conversation layer, the `active` / `switch` seam the `AgentContextInterface` renders, and the durable `open` / `save` store seam. Event-free (a registry, like `WorkspaceManagerInterface`); the observability lives on each `ConversationInterface`. |
960
+ | `ConversationSnapshot` | interface | `{ id, sections, messages, judgments? }` | Holds a JSON-serializable snapshot of the state of a conversation — its `id`, the compacted `sections`, and the live tail `messages` — the durable payload the `ConversationStoreInterface` persists. The exact analogue of `WorkspaceSnapshot`. |
961
+ | `ConversationStoreInterface` | interface | `{} plus get, set, delete` | Persists a `ConversationSnapshot` durably — the async `get` / `set` / `delete` primitives, keyed by a conversation id and holding no expiry, the exact analogue of `WorkspaceStoreInterface`. |
962
+ | `ConversationSnapshotRow` | interface | `{ id, snapshot }` | Represents one row of the table a `DatabaseConversationStore` persists — a conversation `id` plus its `ConversationSnapshot` held as one opaque JSON column, read back as `unknown` and narrowed on `get`. The exact analogue of `WorkspaceSnapshotRow`. |
963
+ | `ConversationErrorCode` | type | `'SUMMARIZER' \| 'SECTIONS' \| 'JUDGMENT' \| 'KEEP'` | Names the machine-readable condition a `ConversationError` reports. |
964
+ | `JudgmentManagerInterface` | interface | `{ count } plus add, judgment, judgments, remove, clear, resolve` | Stores judgments by caller key and resolves requests by reusing matching records. |
965
+
966
+ #### Constants
967
+
968
+ The following table lists the constants the conversations module declares:
969
+
970
+ | API | Kind | Shape | Summary |
971
+ | --------------------------- | ----- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
972
+ | `CONVERSATION_RECAP_PREFIX` | const | `string` | Names the framing label the `view()` method of a `ConversationInterface` prefixes onto the summary of each compacted section so a small model reads it as a condensed recap of earlier turns — the lean `'[Summary of earlier messages] '` marker, never a literal assistant turn to echo or treat as the live answer. |
973
+ | `DEFAULT_CONVERSATION_KEEP` | const | `number` | Sets the default number of recent live messages the `compact()` method of a `ConversationInterface` retains verbatim — `0`, so a manual `compact()` keeps no recent tail and folds every exchange before the newest user message into one summarized section. A caller retains a recent tail by passing `keep` (on `ConversationOptions`, `ConversationManagerOptions`, or per-fold through `CompactOptions`), folding at most the older `count - keep` messages, cut back to whole exchanges, and leaving at least the most recent `keep` live for the next turn. Overridable everywhere `keep` is accepted. |
974
+
975
+ #### Errors
976
+
977
+ The following table lists the errors the conversations module declares and the guards that narrow them:
978
+
979
+ | API | Kind | Summary |
980
+ | --------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
981
+ | `ConversationError` | class | Reports a conversation with no `ConversationSummaryHandler` to fold its messages with, a `sections` cap below `1`, a `keep` that is not a nonnegative safe integer, or a judgment or judge request that JSON cannot carry — thrown by the `compact()` method of a `ConversationInterface`, its construction, or its judgment store, carrying the machine-readable `code` `'SUMMARIZER' \| 'SECTIONS' \| 'JUDGMENT' \| 'KEEP'`. |
982
+ | `isConversationError` | function | Narrows an unknown caught value to a `ConversationError` through `instanceof`, so a `catch` can branch on its `code`. |
983
+
984
+ #### Factories
985
+
986
+ The following table lists the factories the conversations module declares:
987
+
988
+ | API | Kind | Summary |
989
+ | --------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
990
+ | `createConversation` | function | Creates a conversation — a `ConversationInterface` grouping messages above a flat message store it owns directly, with compaction into summarized sections, on-demand `rehydrate`, and substring `search`, driven by a provider-agnostic `ConversationSummaryHandler` seam. |
991
+ | `createConversationManager` | function | Creates a conversation registry — a `ConversationManagerInterface` holding `ConversationInterface` instances keyed by their `id`, in insertion order, with an active pointer: the id-keyed store over the conversation layer plus the `active` / `switch` seam the context renders. `add` auto-activates the first conversation and flows the registry's default `summarize` / `keep` into every conversation it creates. |
992
+ | `createMemoryConversationStore` | function | Creates the in-memory conversation store — a `ConversationStoreInterface` backed by a process-lifetime `Map` of `ConversationSnapshot` records keyed by conversation id, the default backing for the durable `ConversationManagerInterface.open` / `ConversationManagerInterface.save` seam. The exact twin of `createMemoryWorkspaceStore`. |
993
+ | `createDatabaseConversationStore` | function | Creates a `DatabaseConversationStore` over any `DriverInterface` — the durable, driver-pluggable backing for the conversation persistence seam, holding each snapshot as one opaque JSON column and standing as the opt-in twin of `createMemoryConversationStore`. The exact twin of `createDatabaseWorkspaceStore`. |
994
+
995
+ #### Classes
996
+
997
+ The following table lists the classes the conversations module declares:
998
+
999
+ | API | Kind | Summary |
1000
+ | --------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1001
+ | `Conversation` | class | Represents a conversation — a live uncompacted tail of messages it owns directly above a flat message store, plus compacted, summarized `Section` records and a `summarizable` flag, with on-demand `rehydrate` and substring `search`, driven by a provider-agnostic `ConversationSummaryHandler` seam so `core` never imports a provider. Observable through its own `emitter`. |
1002
+ | `ConversationManager` | class | Registers `Conversation` instances keyed by `id`, in insertion order, with an active pointer — the id-keyed store over the conversation layer, the `active` / `switch` seam the `AgentContext` renders, and the durable `open` / `save` store seam. Event-free (a registry, like `WorkspaceManager`); the observability lives on each `Conversation`. |
1003
+ | `MemoryConversationStore` | class | Implements the `ConversationStoreInterface` in memory — a process-lifetime `Map` of `ConversationSnapshot` records keyed by conversation id, the default store `createMemoryConversationStore` builds and the default backing for `open` / `save`. The exact twin of `MemoryWorkspaceStore`. |
1004
+ | `DatabaseConversationStore` | class | Backs a `ConversationStoreInterface` with one table of the `databases` layer — a conversation's durable state is a row holding the snapshot as one opaque JSON column, narrowed back on `get` by `isConversationSnapshot`, so persistence reduces to keyed point-access (`get` / `set` / `delete`) over a `TableInterface`. The driver-pluggable twin of the plain-`Map` `MemoryConversationStore`, and the exact twin of `DatabaseWorkspaceStore`. |
1005
+ | `JudgmentManager` | class | Stores judgments in insertion order and asks a judge only for unmatched question identities. |
1006
+
1007
+ #### Helpers
1008
+
1009
+ The following table lists the helpers the conversations module declares:
1010
+
1011
+ | API | Kind | Summary |
1012
+ | --------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1013
+ | `buildSummaryMessage` | function | Builds the raw synthetic summary message for one compacted section — role `'assistant'`, the section's stable `id`, and its `summary` verbatim as content. |
1014
+ | `buildRecapMessage` | function | Builds the framed recap message for one compacted section — the same role and stable `id` as `buildSummaryMessage`, with the content prefixed by `CONVERSATION_RECAP_PREFIX`. |
1015
+ | `requireJudgment` | function | Returns a stored judgment record after checking it, or throws a coded error. |
1016
+ | `requireSectionsCap` | function | Returns a `sections` cap after checking its lower bound, or throws a coded error. |
1017
+ | `requireRolling` | function | Returns the rolling switch after checking it does not combine with a `sections` cap, or throws a coded error. |
1018
+ | `requireKeep` | function | Returns a retained-tail size after checking it is a nonnegative safe integer, or throws a coded error. |
1019
+ | `raceSignal` | function | Settles with a promise, or rejects with the reason of a signal that aborts first, so a caller is released at once even when the work behind the promise ignores the signal. |
1020
+ | `buildJudgments` | function | Builds records for answered or refused request keys, attaching usage only for a single question. |
1021
+
1022
+ #### Validators
1023
+
1024
+ The following table lists the guards the conversations module declares:
1025
+
1026
+ | API | Kind | Shape | Summary |
1027
+ | ------------------------ | -------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1028
+ | `isMessage` | function | `Message` | Checks whether a value satisfies the domain conversation-message contract. |
1029
+ | `isSection` | function | `Section` | Checks whether an `unknown` is structurally a `Section` record — a `string` `id` and `summary` beside a `messages` array of valid `Message` records, the per-section step of the `isConversationSnapshot` read-boundary narrow. Total, never throwing, and never an assertion. |
1030
+ | `isConversationSnapshot` | function | `ConversationSnapshot` | Narrows an `unknown` to a `ConversationSnapshot` — a `string` `id` and valid `sections` and `messages` arrays; the total boundary guard for an untrusted snapshot read (a storage row a `DatabaseConversationStore` reads back from its opaque JSON column, a snapshot loaded from disk), never throwing. The exact analogue of `isWorkspaceSnapshot`. |
1031
+ | `isJudgment` | function | `Judgment` | Checks whether a stored judgment carries a valid question and exactly one answer or refusal. |
1032
+
1033
+ A `DatabaseConversationStore` reads its snapshot column back as `unknown` and narrows it through `isConversationSnapshot`, so a malformed blob resolves `undefined` rather than a broken conversation:
1034
+
1035
+ ```ts
1036
+ import { isConversationSnapshot } from '@orkestrel/agent'
1037
+
1038
+ declare const row: unknown
1039
+ const snapshot = isConversationSnapshot(row) ? row : undefined
1040
+ ```
1041
+
1042
+ ### Contexts module
1043
+
1044
+ The `contexts` module holds the turn context, its instruction and scope managers, and the selection seam.
1045
+
1046
+ #### Types
1047
+
1048
+ The following table lists the types the contexts module declares:
1049
+
1050
+ | Type | Kind | Shape | Summary |
1051
+ | ----------------------------- | --------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1052
+ | `ContextSectionFormat` | interface | `{ open?, render?, close? }` | Overrides one context section's format — an `open` / `render` / `close` trio that frames a section in the `AgentContext` build cascade: a top line rendered once before the items, a per-item rendering, and a bottom line rendered once after the items. |
1053
+ | `InstructionInterface` | interface | `{ id, name, content, priority, override? }` | Represents an immutable instruction — a named directive a richer context places between the system prompt and the conversation, ordered by descending `priority`. |
1054
+ | `InstructionInput` | interface | `{ name, content, priority?, override? }` | Carries the minimal data to author an `InstructionInterface` — the `id` is minted by the `InstructionManagerInterface` that stores it, so a caller supplies only `name` / `content` and an optional `priority`. Default: `0`. |
1055
+ | `InstructionManagerEventMap` | type | `{ add, remove, clear }` | Maps the push observation surface of an `InstructionManagerInterface` — the mutation moments a fire-and-forget observer subscribes to through `manager.emitter.on`. |
1056
+ | `InstructionManagerOptions` | interface | `{ on?, error?, format? }` | Configures `createInstructionManager` — the reserved `on` hooks plus an optional per-section format override. |
1057
+ | `InstructionManagerInterface` | interface | `{ emitter, count, open, close } plus add, instruction, instructions, render, remove, clear` | Registers `InstructionInterface` values keyed by `name` — `add` (one or a batch) mints each `id` and overwrites a same-name instruction, last write wins, while `instructions()` lists them sorted by descending `priority` and stable for ties. |
1058
+ | `ScopeFilter` | interface | `{ instructions?, tools?, files? }` | Lists the per-category allow-lists a `ScopeInterface` carries — an optional `readonly string[]` for `instructions`, for `tools`, and for `files`, each keyed by that category's identity (an instruction's `name`, a tool's `name`, a workspace file's `path`) and read as an allow-list: `undefined` lets everything pass, `[]` lets nothing pass, and a non-empty list passes the listed keys alone. |
1059
+ | `Selection` | interface | `{ messages, judgments, usage?, fault?, briefing? }` | Carries the conversation part of the next prompt and the receipt for it. |
1060
+ | `SelectionHandler` | type | `(conversation: ConversationInterface, request: Message, signal: AbortSignal) => Promise<Selection>` | Chooses the conversation messages the next prompt carries for one user request. |
1061
+ | `ScopeInput` | interface | `ScopeFilter plus { name, select? }` | Carries the data to author a `ScopeInterface` — a `ScopeFilter` plus the required `name` (a human label; the `id` is minted by the layer that stores it). |
1062
+ | `ScopeInterface` | interface | `ScopeFilter plus { id, name, select? } plus narrow` | Represents a named, immutable filter over a richer context's items — the per-category allow-lists (`ScopeFilter`) plus an `id` / `name`, and a `narrow` that composes a tighter child by set intersection. |
1063
+ | `ScopeManagerEventMap` | type | `{ add, remove, clear }` | Maps the push observation surface of a `ScopeManagerInterface` — analogous to `InstructionManagerEventMap`, but keyed by the minted `id`, so `add` never reports a replaced scope. |
1064
+ | `ScopeManagerOptions` | interface | `{ on?, error? }` | Configures `createScopeManager` — the reserved `on` hooks: initial listeners for the manager's `ScopeManagerEventMap`, wired at construction. |
1065
+ | `ScopeManagerInterface` | interface | `{ emitter, count } plus add, scope, scopes, remove, clear` | Registers reusable `ScopeInterface` instances keyed by their minted `id` — `add` mints + stores one (never overwrites), `scopes()` lists them in insertion order. |
1066
+ | `AgentContextOptions` | interface | `{ system?, tools?, instructions?, workspaces?, scope?, conversations?, select? }` | Configures `createAgentContext` — the optional system prompt plus the pre-built managers to reuse: an `instructions` registry, a `workspaces` registry (the only document channel), a `conversations` registry (the message source), a `tools` registry (the loop's advertise and dispatch surface), and an initial `scope`. |
1067
+ | `AgentContextInterface` | interface | `{ system, instructions, workspaces, messages, conversations, tools, scope } plus apply, select, build` | Assembles a turn's provider input from the system prompt + the context managers + the conversation, applying the active scope per category. |
1068
+
1069
+ #### Constants
1070
+
1071
+ The following table lists the constants the contexts module declares:
1072
+
1073
+ | API | Kind | Shape | Summary |
1074
+ | -------------------------- | ----- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1075
+ | `WORKSPACE_SECTION_HEADER` | const | `string` | Names the section header the `build()` method of `AgentContext` renders the active workspace's text files under — `'## Workspace'`, the leading line of the dedicated workspace block in the system message. |
1076
+
1077
+ #### Factories
1078
+
1079
+ The following table lists the factories the contexts module declares:
1080
+
1081
+ | API | Kind | Summary |
1082
+ | -------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1083
+ | `createInstruction` | function | Creates an instruction — an immutable `InstructionInterface` (a named directive) from its `name` / `content` and optional `priority`, the `id` minted at construction. |
1084
+ | `createInstructionManager` | function | Creates an instruction registry — an `InstructionManagerInterface` holding immutable instructions keyed by `name`, listed by descending `priority`. |
1085
+ | `createScope` | function | Creates a named scope — an immutable `ScopeInterface` from its `name` and its per-category allow-lists, the `id` minted at construction. |
1086
+ | `createScopeManager` | function | Creates a scope registry — a `ScopeManagerInterface` holding immutable scopes keyed by their minted `id`, in insertion order. |
1087
+ | `createAgentContext` | function | Creates a richer turn context — an `AgentContextInterface` assembling a provider request from the optional system prompt, the instruction registry, the workspace registry (the only document channel), the conversation registry that is its `messages` source, the tool registry, and the active scope, which `build()` folds into the next turn's input. |
1088
+
1089
+ #### Classes
1090
+
1091
+ The following table lists the classes the contexts module declares:
1092
+
1093
+ | API | Kind | Summary |
1094
+ | -------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1095
+ | `Instruction` | class | Represents an immutable named directive — an `InstructionInterface` assembled once from its input (`name` / `content` and an optional `priority`), the `id` minted at construction. Default: `0` for `priority`. |
1096
+ | `InstructionManager` | class | Registers the immutable `Instruction` instances a richer context assembles a directives block from — keyed by `name` so adding an instruction whose name is already stored overwrites it, last write wins, and listed by descending `priority`, carrying the `open` / `render` / `close` build contract and an observable `emitter`. |
1097
+ | `Scope` | class | Represents a named, immutable filter over a richer context's items — an optional allow-list per category (`instructions` / `tools` / `files`), each keyed by that category's identity (an instruction's `name`, a tool's `name`, a workspace file's `path`) and read as an allow-list: `undefined` lets everything pass, `[]` lets nothing pass, and a non-empty list passes the listed keys alone. `narrow` composes a tighter child by set intersection. |
1098
+ | `ScopeManager` | class | Registers the named filters a richer context reuses — immutable `Scope` instances keyed by their minted `id`, in insertion order, where `add` always mints and stores rather than overwriting, and an observable `emitter` reports each change. |
1099
+ | `AgentContext` | class | Assembles a provider request from the richer turn context — the optional system prompt, the observable context managers (instructions / workspaces), the `ConversationManagerInterface` message source whose active conversation is `messages`, the `ToolManagerInterface` registry, and an active `ScopeInterface` changed through `AgentContextInterface.apply`. `build()` folds the scoped managers and the active workspace into one system block, then the conversation, and never reads `tools`. |
1100
+
1101
+ #### Helpers
1102
+
1103
+ The following table lists the helpers the contexts module declares:
1104
+
1105
+ | API | Kind | Summary |
1106
+ | ------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1107
+ | `renderFencedFile` | function | Renders a path-addressed text body as a fenced reference block — a `File: <path>` label line over a language-tagged fence, the framing the active-workspace text-file render of `AgentContext` emits. |
1108
+ | `renderSection` | function | Renders one context section — the resolved `open`, each item's rendering, and the resolved `close` when one exists, blank-line joined; `undefined` when the section has no items. |
1109
+ | `attachImages` | function | Copies a message with image data merged onto its `images` — the message's own images first, then the attached data, keeping every other field and never mutating the original. |
1110
+ | `attachUserImages` | function | Attaches image data to a conversation's last user message — the turn a vision provider reads images off — as a new array with that one message replaced by its carrying copy, and unchanged when there is no data or no user turn. |
1111
+ | `collectImageData` | function | Collects the `base64` payload of the image files in a workspace file list — the data an agent context attaches to the last user message. |
1112
+ | `intersectKeys` | function | Intersects two scope category lists under the "`undefined` is the universal set" rule — a fresh copy that can only tighten, and the primitive a scope narrows through. |
1113
+
1114
+ ### Agents module
1115
+
1116
+ The `agents` module holds the loop, the authority gate, the channel, and the durable-job bridge.
1117
+
1118
+ #### Types
1119
+
1120
+ The following table lists the types the agents module declares:
1121
+
1122
+ | Type | Kind | Shape | Summary |
1123
+ | ------------------------ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1124
+ | `AgentStatus` | type | `'idle' \| 'running' \| 'done' \| 'error'` | Names the lifecycle state of an `AgentInterface` turn — `idle` before a run, `running` while the loop is in flight, then the settled `done` (a normal finish or a cancel) or `error` (a genuine provider / tool failure). |
1125
+ | `AgentChunk` | type | `{ category: 'token', content } \| { category: 'think', content } \| { category: 'tool', call, result } \| { category: 'usage', usage, estimate }` | Represents a streamed step of an agent turn — the union the loop yields as it runs, discriminated by the `category` of step it carries, and the pull surface beside the push `AgentEventMap`. |
1126
+ | `AgentEventMap` | type | `{ start, turn, tool, usage, deny, finish, error, abort, exhaust, fault, select }` | Maps the push observation surface of an `AgentInterface` — the lifecycle, usage, and tool moments a fire-and-forget observer (logging, metrics, tracing) subscribes to, beside the pull `AgentChunk` stream. |
1127
+ | `AgentResult` | interface | `{ content, thinking?, usage?, partial, stop? }` | Holds the settled outcome of an agent turn — the assembled assistant `content`, the `usage` summed across the turn's provider calls, and whether it was committed `partial`. |
1128
+ | `AgentRunResult` | interface | `{ content, thinking, usage, partial, exhausted, stop }` | Holds the immutable outcome of one agent loop, assembled into the `AgentResult` resolved by the stream handle. |
1129
+ | `AgentTurn` | interface | `{ content, thinking, usage, spent, messages, ending, partial, futile, estimate, interrupted, truncated }` | Holds what one agent loop turn contributed to its run and how the turn ended — the readonly record the loop appends for each turn, from which the run derives its `AgentRunResult`. |
1130
+ | `AgentStop` | type | `{ layer: 'meter' \| 'budget', id, max, consumed, estimate?, usage? } \| { layer: 'model', id, estimate?, usage? }` | Represents why one of a run's three allowed failure layers stopped it — the meter's estimate allowance, the cost budget, or the model's own token limit — with the readings that layer crossed. |
1131
+ | `AgentErrorCode` | type | `'CONCURRENCY' \| 'LIMIT' \| 'REGISTRY' \| 'WINDOW'` | Names the machine-readable agent concurrency, limit, registry, and window failures. |
1132
+ | `MessageMeasure` | interface | `{ id, role, content, framing, calls, thinking, images }` | Holds the disjoint estimate components of one message, in heuristic estimate units. |
1133
+ | `RequestMeasure` | interface | `{ messages, tools, schema }` | Holds the disjoint estimate components of one provider request, in heuristic estimate units. |
1134
+ | `ChannelInterface` | interface | `{} plus push, close, fail, drain` | Buffers values in an unbounded async channel — a producer writes them in (`push`) and ends it (`close` / `fail`) regardless of consumption, while a consumer reads them back live through `drain`. |
1135
+ | `StreamInterface` | interface | `{ events, result } plus abort` | Pairs a live event stream with the eventual settled result and a cancel — the generic pull/streaming handle a long-running operation hands back. |
1136
+ | `AgentStreamInterface` | type | `StreamInterface<AgentChunk, AgentResult>` | Names the agent turn's live handle carrying `AgentChunk` values and resolving an `AgentResult`. |
1137
+ | `AgentOptions` | interface | `{ on?, error?, system?, tools?, instructions?, workspaces?, scope?, limit?, timeout?, budget?, allowance?, scheduler?, signal?, authority?, conversations?, window?, strict?, select? }` | Configures `createAgent` — the loop's bounds and pacing, the reserved `on` hooks, the construction-time context wiring (`instructions` / `workspaces` / `scope`), the `conversations` registry that is the message source, the context `window` budget that opts into automatic compaction of the active conversation, and the `strict` switch that aborts the run on an automatic-compaction summarizer failure instead of the lenient default. |
1138
+ | `AgentRunOptions` | interface | `{ think?, schema?, limit?, timeout?, budget?, allowance?, signal? }` | Carries overrides for one call to the `generate` or `stream` method of an `AgentInterface`. Each member overrides the matching `AgentOptions` value, while `signal` composes with the construction signal. |
1139
+ | `AgentInterface` | interface | `{ emitter, id, status, context } plus generate, stream, abort` | Composes a `ProviderInterface`, an `AgentContextInterface`, and a `ToolManagerInterface` into a bounded context → provider → tools → repeat turn. |
1140
+ | `AuthorityContext` | interface | `{ call }` | Carries what an `AuthorityInterface` evaluates for one tool call — the call under consideration. |
1141
+ | `AuthorityDecision` | interface | `{ zone, allowed, reason? }` | Holds the authority verdict on one tool call. |
1142
+ | `AuthorityRule` | interface | `{ match, zone, allowed?, reason? }` | Represents one ordered policy rule an `AuthorityInterface` evaluates. |
1143
+ | `AuthorityOptions` | interface | `{ rules?, fallback? }` | Configures `createAuthority` — the ordered rules and the no-match fallback. |
1144
+ | `AuthorityInterface` | interface | `{} plus evaluate` | Gates each tool call before it runs — the synchronous policy that turns one `AuthorityContext` into an `AuthorityDecision`. |
1145
+ | `AgentJobInput` | interface | `{ provider, messages, system?, tools?, authority?, scheduler?, limit?, timeout?, budget?, children? }` | Represents a JSON-serializable agent job — the descriptor a durable queue or runner runs. Its non-serializable pieces (the provider, tools, authority, scheduler) are referenced by name and resolved to live objects through an `AgentRegistryInterface` at handler time, while its data fields (the seed `messages`, `system`, `limit`, `timeout`, and a token `budget` ceiling) carry directly. |
1146
+ | `AgentRegistryInterface` | interface | `{} plus provider, tool, authority, scheduler, build` | Resolves the names in an `AgentJobInput` to live dependencies and rehydrates a seeded, signal-wired `AgentInterface`. |
1147
+ | `AgentRegistryOptions` | interface | `{ providers, tools?, authorities?, schedulers?, store? }` | Configures the named pools of live dependencies used by `createAgentRegistry` and the optional durable store shared by built agents. |
1148
+ | `AgentQueueOptions` | interface | `{ registry, partial?, concurrency?, retries?, timeout?, store? }` | Configures `createAgentQueue` — the registry that rehydrates jobs, the partial-result policy, and the substrate knobs threaded into the backing `createQueue`. |
1149
+ | `AgentRunnerOptions` | interface | `{ registry, partial?, concurrency?, retries?, timeout? }` | Configures `createAgentRunner` — the registry that rehydrates jobs, the partial-result policy, and the substrate knobs threaded into the backing `createRunner`. |
1150
+
1151
+ #### Constants
1152
+
1153
+ The following table lists the constants the agents module declares:
1154
+
1155
+ | API | Kind | Shape | Summary |
1156
+ | ------------------------ | ----- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1157
+ | `DEFAULT_AGENT_LIMIT` | const | `number` | Caps an `AgentInterface` turn's tool iterations by default — `10` context → provider → tools cycles before the loop stops, so a model that keeps requesting tools can never loop forever. Overridable per agent through `AgentOptions.limit`. |
1158
+ | `DEFAULT_AUTHORITY_ZONE` | const | `string` | Names the default zone for an unmatched tool call allowed by an authority. |
1159
+ | `MESSAGE_TOKEN_OVERHEAD` | const | `number` | Estimates the role and framing overhead added to each message by `estimateMessages`. |
1160
+ | `IMAGE_TOKEN_ESTIMATE` | const | `number` | Names the coarse, deliberately approximate per-image token cost `estimateMessages` charges for each attached image — `512`, because a base64 payload's length is no reliable token proxy. |
1161
+
1162
+ #### Errors
1163
+
1164
+ The following table lists the errors the agents module declares and the guards that narrow them:
1165
+
1166
+ | API | Kind | Summary |
1167
+ | ----------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1168
+ | `AgentJobError` | class | Reports an `AgentInterface` run that ended `AgentResult.partial` under a `partial` policy of `false` (the default) — thrown by an agent-job handler (a `createAgentQueue` / `createAgentRunner` job), carrying the partial `AgentResult` so the failure stays inspectable, and the machine-readable `code` `'PARTIAL'`. |
1169
+ | `isAgentJobError` | function | Narrows an unknown caught value to an `AgentJobError` through `instanceof`, so a `catch` can recover its `partial` result. |
1170
+ | `AgentError` | class | Reports a run sharing accounting with a live run, a `limit` that is not a nonnegative safe integer, a name absent from a registry pool, or a strict run whose request exceeds its window. Agent construction, the `stream` and `generate` methods, and registry accessors throw synchronously, before returning a handle or promise; a `'WINDOW'` refusal rejects the run's `result` instead. Await the call inside `try`/`catch` to catch it. |
1171
+ | `isAgentError` | function | Narrows an unknown caught value to an `AgentError` through `instanceof`, so a `catch` can branch on its `code`. |
1172
+
1173
+ #### Factories
1174
+
1175
+ The following table lists the factories the agents module declares:
1176
+
1177
+ | API | Kind | Summary |
1178
+ | --------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1179
+ | `createAgent` | function | Creates an agent loop — an `AgentInterface` composing a `ProviderInterface`, its `AgentContextInterface`, and a tool registry into a bounded context → provider → tools → repeat turn, exposed as a one-shot `generate` and a live `stream`. |
1180
+ | `createAuthority` | function | Creates a policy gate — an `AuthorityInterface` the agent loop consults before each tool call runs, evaluating the ordered rules first-match-wins and falling back to the configured default when none match. |
1181
+ | `createChannel` | function | Creates an empty unbounded async channel — a `ChannelInterface` a producer writes values into (`push`) and ends (`close` / `fail`) regardless of consumption, while a consumer reads them back live through `drain`. |
1182
+ | `createAgentRegistry` | function | Creates a registry of live providers, tools, authorities, and schedulers whose `build` method returns a seeded, signal-wired `AgentInterface` from a serializable job. |
1183
+ | `createAgentQueue` | function | Creates a durable, bounded-concurrency agent-job queue — a `QueueInterface` over serializable values of `AgentJobInput` that composes `createQueue`: each job is rehydrated through the `registry` into a live `AgentInterface`, run to its `AgentResult`, and subjected to the partial-as-configurable-failure policy. |
1184
+ | `createAgentRunner` | function | Creates an agent-job runner — a `RunnerInterface` over serializable values of `AgentJobInput` that composes `createRunner` (one-shot, ordered, fail-fast), each unit rehydrated through the `registry` and subjected to the partial policy. The runner also carries sub-agent fan-out: a parent job's handler can call `controller.spawn` with a child job. |
1185
+
1186
+ #### Classes
1187
+
1188
+ The following table lists the classes the agents module declares:
1189
+
1190
+ | API | Kind | Summary |
1191
+ | --------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1192
+ | `Agent` | class | Composes a `ProviderInterface`, an `AgentContext`, and a `ToolManagerInterface` into a bounded context → provider → tools → repeat turn, exposed as both a one-shot `generate` and a live `stream` that share one private run — bounded by the run `signal`, the `timeout`, and the `budget` folded through `AbortSignal.any`, paced by `scheduler`, with tool iteration capped at `limit`. |
1193
+ | `AgentFanout` | class | Executes one runner's agent jobs — spawning each unit's declared children on its first attempt only, then rehydrating the unit through a registry with the controller signal and settling it under the shared partial-result policy. |
1194
+ | `Authority` | class | Gates the agent loop's tool calls — the synchronous policy consulted before each call runs, turning one `AuthorityContext` into an `AuthorityDecision` by walking the ordered rules first-match-wins and falling back to a configurable default, which allows an unmatched call unless its `fallback` denies. |
1195
+ | `AgentRegistry` | class | Makes a durable, JSON-serializable `AgentJobInput` runnable — holds the named pools of live, non-serializable pieces (providers, tools, authorities, schedulers), throws on a name absent from its pool, and its `build` method returns a seeded, signal-wired `Agent` from a job's names and data. |
1196
+ | `Channel` | class | Buffers chunks in a minimal unbounded async channel — the eager pump writes them in (`push`) and ends it (`close` / `fail`) regardless of consumption, while a consumer reads them back live through the `drain` async-iterator. Decoupling write from read is what lets a producer make progress without a consumer pulling, and it is why an agent's `result` settles whether or not its `events` are drained. |
1197
+
1198
+ #### Helpers
1199
+
1200
+ The following table lists the helpers the agents module declares:
1201
+
1202
+ | API | Kind | Summary |
1203
+ | --------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1204
+ | `agentResultToJSON` | function | Projects an unknown value onto a fresh, exact `JSONValue` representation of an `AgentResult` — capturing each structural field once through a total boundary, accepting conforming accessors and inherited properties, preserving finite negative and fractional usage counts, dropping extras, and resolving `undefined` for a malformed field, a non-finite usage number, a throwing getter, or a hostile or revoked proxy. |
1205
+ | `tokenUsageToJSON` | function | Projects an unknown value onto a fresh, exact `TokenUsage` — capturing `prompt`, `completion`, and `total` once each through a total boundary, preserving finite negative and fractional counts, dropping extras, and resolving `undefined` for a non-object, a missing or non-finite count, a throwing getter, or a hostile or revoked proxy. |
1206
+ | `inferStop` | function | Infers which of a run's three failure layers stopped it — the meter allowance, a cost budget, or the model's own token limit — and returns the stop record with the final call's readings, or `undefined` when no layer stopped the run. |
1207
+ | `estimateTokens` | function | Estimates the context-token footprint of a string — the deterministic `ceil(length / 4)` character heuristic `estimateMessages` sums over a conversation's messages (the default context-budget estimator). |
1208
+ | `estimateMessages` | function | Estimates the context-token footprint of a batch of messages — each message's content plus `MESSAGE_TOKEN_OVERHEAD`, a tool-call JSON estimate, a thinking estimate, and `IMAGE_TOKEN_ESTIMATE` for each attached image. The `estimateRequest` message term for an agent's context budget (the `AgentOptions` `window`), a provider-independent approximation for conforming messages rather than an exact tokenizer count. |
1209
+ | `estimateRequest` | function | Estimates the context-token footprint of a complete provider request — its messages through `estimateMessages`, plus the rendered advertised `tools`, plus the rendered `options.schema`. The `consumer` estimator an agent's `window` budget measures every request through, a provider-independent heuristic rather than an exact tokenizer count. |
1210
+ | `measureMessage` | function | Measures the disjoint estimate components of one message — its content, framing, rendered calls, thinking, and attached images — as the same heuristic units `estimateMessages` sums. |
1211
+ | `measureRequest` | function | Measures the disjoint estimate components of a complete provider request — each message through `measureMessage`, plus the rendered advertised `tools` and `options.schema` — as the same heuristic units `estimateRequest` sums. |
1212
+ | `settleAgentJob` | function | Runs one rehydrated agent and applies the partial-as-configurable-failure policy — a partial run throws an `AgentJobError` unless the `partial` policy allows it, and a natural finish resolves. The shared job-handler step `createAgentQueue` and `createAgentRunner` both settle each job through, so the policy can never diverge between them. |
1213
+ | `handleAgentQueueJob` | function | Handles one queued agent job by rehydrating it through a registry with the queue attempt's signal, then applying the shared partial-result policy. |
1214
+ | `assembleResult` | function | Assembles the settled `AgentResult` from a run's `AgentRunResult` — `thinking`, `usage`, and `stop` are carried only when the run surfaced them, and the loop-internal `exhausted` flag is left out. |
1215
+ | `denyCall` | function | Synthesizes the denial `ToolResult` an authority-blocked call is fed back with — the call's `id` / `name` keyed back, carrying a denial `error` instead of a value. |
1216
+ | `chargeUsage` | function | Consumes a reported usage against a budget over what was already charged, so a turn's total draw is never less than the report and nothing is charged twice. |
1217
+ | `requireLimit` | function | Returns a tool-iteration cap after checking it is a nonnegative safe integer, or throws a coded error. |
1218
+ | `requireBudget` | function | Returns a run's budget or allowance after checking it implements `BudgetInterface`, or throws a coded error before the run starts it. |
1219
+ | `renderToolValue` | function | Renders a tool's output as the string content of its tool message. |
1220
+ | `requireEntry` | function | Returns a registered value or throws when its name is absent. |
1221
+ | `extractQueueOptions` | function | Extracts the present concurrency, retry, and deadline options for an agent job queue. |
1222
+
1223
+ The measures are structural estimates, not tokenizer allocations. A message's `id` and `role` join a measure back to its message and are not token components, and a provider's template effects and the estimation error are not individually attributed.
620
1224
 
621
1225
  Project an agent result at its originating package before carrying it through a JSON boundary:
622
1226
 
@@ -630,198 +1234,246 @@ if (portable === undefined) throw new Error('invalid agent result')
630
1234
  JSON.stringify(portable)
631
1235
  ```
632
1236
 
633
- The queue and runner factories bind their named handlers to a registry and partial policy; callers composing the lower-level substrates can do the same:
1237
+ The queue factory binds its named handler to a registry and partial policy, and the runner factory binds the `execute` method of one `AgentFanout` per runner; callers composing the lower-level substrates can do the same:
634
1238
 
635
1239
  ```ts
636
1240
  import type { AgentRegistryInterface } from '@orkestrel/agent'
637
- import { handleAgentQueueJob, handleAgentRunnerJob, sanitizeToken } from '@orkestrel/agent'
1241
+ import {
1242
+ AgentFanout,
1243
+ handleAgentQueueJob,
1244
+ renderToolValue,
1245
+ requireLimit,
1246
+ sanitizeToken,
1247
+ } from '@orkestrel/agent'
638
1248
 
639
1249
  declare const registry: AgentRegistryInterface
640
1250
 
641
1251
  const tokens = sanitizeToken(12.7) // 12
1252
+ const limit = requireLimit(3) // 3
1253
+ const content = renderToolValue({ count: 2 }) // '{"count":2}'
642
1254
  const queueHandler = handleAgentQueueJob.bind(undefined, registry, false)
643
- const runnerHandler = handleAgentRunnerJob.bind(undefined, registry, false)
1255
+ const fanout = new AgentFanout(registry, false) // one per runner: it records the units it fanned out
1256
+ const runnerHandler = fanout.execute.bind(fanout)
644
1257
  ```
645
1258
 
646
- ### Validators
647
-
648
- In a guard table a `Shape` cell holds the type the guard narrows to. Each guard reads an `unknown`, returns `false` off-shape, and never throws. An error guard stays in the Errors table beside the error it narrows.
649
-
650
- | API | Kind | Shape | Summary |
651
- | ------------------------ | -------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
652
- | `isMessage` | function | `Message` | Checks whether a value satisfies the domain conversation-message contract. |
653
- | `isSection` | function | `Section` | Checks whether an `unknown` is structurally a `Section` record — a `string` `id` and `summary` beside a `messages` array of valid `Message`s, the per-section step of the `isConversationSnapshot` read-boundary narrow. Total, never throwing, and never an assertion. |
654
- | `isConversationSnapshot` | function | `ConversationSnapshot` | Narrows an `unknown` to a `ConversationSnapshot` — a `string` `id`, an optional `string` `summary`, and valid `sections` and `messages` arrays; the total boundary guard for an untrusted snapshot read (a storage row a `DatabaseConversationStore` reads back from its opaque JSON column, a snapshot loaded from disk), never throwing. The exact analogue of `isWorkspaceSnapshot`. |
655
- | `isJudgeEntry` | function | `JudgeEntry` | Checks whether a value is a judge entry: a string, a JSON record, or a JSON array. |
656
- | `isJudgeQuestion` | function | `JudgeQuestion` | Checks whether a value is a well-formed judge question of the choice, score, or noul form. |
657
- | `isSystemOneResponse` | function | `SystemOneResponse` | Checks whether a value is a System One response envelope with optional model and usage. |
658
- | `isSystemOneAnswer` | function | `SystemOneAnswer` | Checks whether a value is a System One answer whose type and distribution the wire can read. |
659
-
660
- A `DatabaseConversationStore` reads its snapshot column back as `unknown` and narrows it through the last of them, so a malformed blob resolves `undefined` rather than a broken conversation:
1259
+ ### Ledgers module
1260
+
1261
+ The `ledgers` module serves a conversation through an event-sourced briefing: the `Ledger` entity, the `LedgerRequest` class that holds one request's state while it is served, the `Classifier` class that files messages through a judge, the `Gauge` class that prices a prompt, and the pure projection helpers. For the method, see [Serving a conversation through a ledger](#serving-a-conversation-through-a-ledger); for a ledger built end to end, see [Serving requests through a ledger](#serving-requests-through-a-ledger).
1262
+
1263
+ #### Types
1264
+
1265
+ The following table lists the types the ledger declares, each with its shape:
1266
+
1267
+ | Type | Kind | Shape | Summary |
1268
+ | ------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1269
+ | `LedgerCategory` | type | `'fact' \| 'rule' \| 'correction' \| 'request' \| 'opinion' \| 'chatter' \| 'distractor'` | Names the category the ledger files a message under, one of `LEDGER_CATEGORIES`. |
1270
+ | `LedgerQuestion` | interface | `{ category, topic, amends, supersedes }` | Carries the wording of every question the ledger asks its judge. |
1271
+ | `LedgerThreshold` | interface | `{ category, topic, amends, supersedes, correction }` | Carries the probability cutoff of each reading the ledger takes from its judge. |
1272
+ | `LedgerTopic` | interface | `{ name, criterion, requested? }` | Carries one desk topic: the subject the ledger asks its judge about for statements and, when enabled, requests. |
1273
+ | `LedgerShare` | interface | `{ prompt, tail }` | Carries the share of the context capacity the prompt can take and the share of that budget the tail can take. |
1274
+ | `LedgerNote` | interface | `{ cue, results, repeat, closure }` | Carries the text of each note the ledger writes into its conversation or returns to the model. |
1275
+ | `LedgerRecallOptions` | interface | `{ limit? }` | Configures the ledger's `recall` tool. |
1276
+ | `LedgerOwner` | interface | `{ id, names }` | Carries one owner a lookup result names: its id and the names it goes by. |
1277
+ | `LedgerLookupResult` | interface | `{ ids, owners }` | Carries what a lookup handler read from one lookup result: the ids it names and the owners among them. |
1278
+ | `LedgerLookupHandler` | type | `(args, text) => LedgerLookupResult \| undefined` | Reads one successful lookup result into the ids and owners it names. |
1279
+ | `LedgerLookup` | interface | `{ tool, read }` | Carries one application lookup: the tool the model calls and the handler that reads its results. |
1280
+ | `LedgerLookupReading` | interface | `{ id, name, arguments, text, result }` | Carries one successful lookup result as the ledger read it. |
1281
+ | `LedgerLookupState` | type | `'failed' \| 'empty' \| 'shown' \| 'hidden'` | Names what became of a lookup of an earlier request, as the tail stub of its result reports it. |
1282
+ | `LedgerRegistry` | interface | `{ ids, owners }` | Carries the ids the ledger's lookups named and the owners among them. |
1283
+ | `LedgerGauge` | interface | `{ scale, overhead }` | Carries the price of a prompt in tokens, measured against the model the ledger serves. |
1284
+ | `LedgerAgentOptions` | type | `Pick<AgentOptions, 'limit' \| 'timeout' \| 'on'>` | Selects the agent bounds and hooks a ledger passes through to the agent it builds. |
1285
+ | `LedgerOptions` | interface | `{ judge, system, topics, questions, thresholds, capacity, predict?, think?: boolean, gauge?, lookups?, share?, recall?, agent? }` | Configures a ledger: its judge and the wording and cutoffs it files with, the desk topics, the context capacity, and the optional first-pass thinking, lookups, gauge, shares, recall, and agent bounds. |
1286
+ | `LedgerResult` | interface | `{ content, thinking?, usage?, partial, stop?, passes, readings }` | Carries the outcome of one request a ledger served: the agent result of its reply and every pass it took. |
1287
+ | `LedgerInterface` | interface | `{ agent, conversation, gauge } plus respond, calibrate` | Serves the requests of one conversation through an agent whose prompt the ledger projects from what the judge filed. |
1288
+ | `LedgerRequestInterface` | interface | `{ message, plan, selection, boundary, usage, calls, readings } plus enter, open, adopt, observe, spend, think, file, admit, recall, close, list, listing` | Holds what one `respond` call of a ledger keeps while it serves its request: the request message, the filing readings and the plan its selections produced, the gauge calls of its passes, and its repeat and recall stops. |
1289
+ | `LedgerTokenSet` | interface | `{ ids, numbers }` | Carries the id-shaped tokens and the numbers of a text. |
1290
+ | `LedgerWordSet` | interface | `{ words, names }` | Carries the lowercase words and non-opening capitalized names of a message. |
1291
+ | `LedgerClassification` | interface | `{ quiet, categories, topics, amendments, supersessions }` | Carries the ledger's filing of its conversation's messages, keyed by message id. |
1292
+ | `LedgerLine` | interface | `{ text, source, sentence, party?, topics, role }` | Carries one record line: a sentence of a live message, verbatim except that a sentence that opens with a pronoun opens with its party and a colon. |
1293
+ | `LedgerRecord` | interface | `{ key, title, members, lines }` | Carries one record: the live messages placed on one owner or on the rules, as lines. |
1294
+ | `LedgerStaleSentence` | interface | `{ source, sentence, tokens }` | Carries one sentence a later message made stale, with the tokens the two share. |
1295
+ | `LedgerProjection` | interface | `{ records, stale, orphans }` | Carries the records projected from a conversation, the stale sentences, and the live messages no record placed. |
1296
+ | `LedgerProjectionInput` | interface | `{ system, exclusions, owners, messages, readings, entities, classification }` | Carries what a projection reads. |
1297
+ | `LedgerProjectionRequest` | interface | `{ owners, topics }` | Carries the owners and the desk topics one request names, which select its records. |
1298
+ | `LedgerCategoryHandler` | type | `(message) => LedgerCategory \| undefined` | Reads a message's category from its shape, or returns undefined to leave it to the judge. |
1299
+ | `LedgerEntityHandler` | type | `(text, partial) => ReadonlySet<string>` | Lists the registry ids and owner ids a text names. |
1300
+ | `ClassifierOptions` | interface | `{ conversation, judge, questions, topics, thresholds, assign, entities }` | Configures a classifier: the conversation it files, the judge and the wording it asks with, the desk topics, the cutoffs, and the ledger's handlers. |
1301
+ | `ClassifierPhase` | type | `'category' \| 'topic' \| 'amends' \| 'supersedes'` | Names the filing question a classifier asked: a message's category, a message's topic, or an earlier and later message's amendment or supersession. |
1302
+ | `ClassifierOutcome` | type | `'cached' \| 'skipped' \| 'resolved' \| 'failed' \| 'aborted'` | Names what became of one filing question. |
1303
+ | `ClassifierReading` | interface | `{ key, phase, outcome, duration? }` | Carries what became of one filing question: its judgment key, its phase, its outcome, and the time spent resolving it. |
1304
+ | `ClassifierResult` | interface | `{ judgments, usage?, readings, fault? }` | Carries the judgment keys one classification rests on and the judge usage it spent. |
1305
+ | `ClassifierInterface` | interface | `{} plus classify, category, quiet, decisive, topics, classification` | Files a conversation's messages through a judge and reads the filing. |
1306
+ | `GaugeCall` | interface | `{ estimate, prompt?, completion?, thinking?, tools }` | Carries one agent call as the gauge reads it. |
1307
+ | `GaugeOptions` | interface | `{ scale, overhead, capacity, predict?, replay? }` | Configures a gauge: its starting price of a prompt and the context capacity it measures against. |
1308
+ | `GaugeInterface` | interface | `{ scale, overhead } plus rate, remainder, reserve, room, observe` | Prices prompts in tokens and measures the room a request has left. |
1309
+ | `LedgerPlanningGroup` | type | `1 \| 2 \| 3` | Names a briefing source's planning group: a topic match, an off-topic rule or correction, or a name match. |
1310
+ | `LedgerErrorCode` | type | `'REQUEST' \| 'THRESHOLD' \| 'SHARE' \| 'CAPACITY' \| 'LIMIT' \| 'TOPIC' \| 'LOOKUP' \| 'GAUGE'` | Names the machine-readable conditions a `LedgerError` error reports. |
1311
+
1312
+ #### Constants
1313
+
1314
+ The following table lists the constants that hold the ledger's categories, record keys, measured wording, shares, and limits:
1315
+
1316
+ | API | Kind | Shape | Summary |
1317
+ | --------------------------- | ----- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1318
+ | `LEDGER_CATEGORIES` | const | `readonly LedgerCategory[]` | Lists the categories the ledger files a message under, in the order the category question names them — the one list the `LedgerCategory` union derives from. |
1319
+ | `QUIET_CATEGORIES` | const | `readonly LedgerCategory[]` | Lists the categories whose messages the projection leaves out as quiet. |
1320
+ | `DECISIVE_CATEGORIES` | const | `readonly LedgerCategory[]` | Lists the categories whose messages state what the desk acts on, which the briefing renders. |
1321
+ | `PLACED_CATEGORIES` | const | `readonly LedgerCategory[]` | Lists the categories that place a message no owner claims on the rules record. |
1322
+ | `LEDGER_RULES_KEY` | const | `string` | Names the key of the record that holds the rules no owner claims. |
1323
+ | `LEDGER_OWNER_PREFIX` | const | `string` | Prefixes the key of an owner's record, which the owner's id follows. |
1324
+ | `LEDGER_QUESTIONS` | const | `LedgerQuestion` | Supplies the wording of every question the ledger asks its judge. |
1325
+ | `LEDGER_NOTES` | const | `LedgerNote` | Supplies the text of each ledger note, worded for a model that answers in its final message. |
1326
+ | `DEFAULT_LEDGER_SHARE` | const | `LedgerShare` | Supplies the default prompt and tail shares. |
1327
+ | `DEFAULT_LEDGER_LIMIT` | const | `number` | Caps the tool-iteration turns of a ledger's agent at 8 turns. |
1328
+ | `DEFAULT_RECALL_LIMIT` | const | `number` | Caps the `recall` calls of one request at 2 calls. |
1329
+ | `LEDGER_SCALE_DRIFT` | const | `number` | Holds back the share of the prompt budget the scale can rise by between calibration and a request's first call. |
1330
+ | `DETERMINISTIC_JUDGE_ERROR` | const | `RegExp` | Matches the logprob decoding failure `invalid or duplicate top logprob token`, which the ledger holds for the ledger's life. |
1331
+
1332
+ #### Errors
1333
+
1334
+ The following table lists the error the ledger raises and the guard that narrows it:
1335
+
1336
+ | API | Kind | Summary |
1337
+ | --------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------- |
1338
+ | `LedgerError` | class | Reports an invalid ledger configuration, failed calibration, or unowned request selection, carrying the machine-readable `code`. |
1339
+ | `isLedgerError` | function | Narrows an unknown caught value to a `LedgerError` through `instanceof`, so a `catch` can branch on its `code`. |
1340
+
1341
+ #### Factories
1342
+
1343
+ The following table lists the factory that builds a ledger:
1344
+
1345
+ | API | Kind | Signature | Summary |
1346
+ | -------------- | -------- | ---------------------------------------- | ---------------------------------------------------------------------------------------- |
1347
+ | `createLedger` | function | `(provider, options) => LedgerInterface` | Creates a conversation ledger after checking its thresholds, allocation, and tool names. |
1348
+
1349
+ #### Classes
1350
+
1351
+ The following table lists the classes that serve, file, and price a conversation and hold one request:
1352
+
1353
+ | API | Kind | Summary |
1354
+ | --------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1355
+ | `Ledger` | class | Serves one conversation through classified records, a bounded briefing, and a final answer pass. |
1356
+ | `LedgerRequest` | class | Holds what one ledger request keeps while it is served: its message, its filing readings and plan, the gauge calls of its passes, and its repeat and recall stops. |
1357
+ | `Classifier` | class | Files messages through the conversation's judgment manager and reads their categories and corrections. |
1358
+ | `Gauge` | class | Prices prompts in tokens from a measured scale and overhead cost, and measures the room a request has left. |
1359
+
1360
+ #### Validators
1361
+
1362
+ The following guards validate ledger fractions and capacity:
1363
+
1364
+ | API | Kind | Summary |
1365
+ | ----------------------- | -------- | ----------------------------------------------------------------- |
1366
+ | `isFraction` | function | Checks whether a value is finite and lies in the interval (0, 1]. |
1367
+ | `isPositiveSafeInteger` | function | Checks whether a value is a positive safe integer. |
1368
+
1369
+ #### Helpers
1370
+
1371
+ The following table lists the pure helpers that read text, project and select records, render a briefing, and fit a price:
1372
+
1373
+ | API | Kind | Signature | Summary |
1374
+ | ---------------------- | -------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
1375
+ | `splitSentences` | function | `(text) => readonly string[]` | Splits a message into its sentences. |
1376
+ | `extractTokens` | function | `(text) => LedgerTokenSet` | Reads the id-shaped tokens and the numbers of a text. |
1377
+ | `collectNames` | function | `(text) => readonly string[]` | Collects the capitalized name runs of a text, leaving out the run that opens each sentence. |
1378
+ | `identifyLookup` | function | `(name, args) => string \| undefined` | Identifies a lookup reading for projection by its tool name and normalized arguments. |
1379
+ | `linkOwners` | function | `(readings, owners) => ReadonlyMap<string, string>` | Links each id-shaped lookup argument to its owner. |
1380
+ | `collectRegistry` | function | `(readings) => LedgerRegistry` | Collects the ids and the owner names the lookup readings named. |
1381
+ | `matchEntities` | function | `(registry, text, partial) => ReadonlySet<string>` | Matches the registry ids and owners a text names. |
1382
+ | `buildRecords` | function | `(input) => LedgerProjection` | Projects the owner records and the rules record from a conversation's messages. |
1383
+ | `selectRecords` | function | `(projection, request) => readonly LedgerRecord[]` | Selects a request's view of the projected records. |
1384
+ | `findLedgerCall` | function | `(group, message) => ToolCall \| undefined` | Finds the call that owns a tool message within a collected tool group. |
1385
+ | `collectProjectionIds` | function | `(projection) => ReadonlySet<string>` | Collects the live message ids placed in records or left without a record. |
1386
+ | `scanAmendments` | function | `(amendments, start, admit) => readonly string[]` | Scans admitted amendments in breadth-first order without revisiting a message. |
1387
+ | `buildRecallMessage` | function | `(topic) => Message` | Builds the placeholder assistant recall call used to price recall framing. |
1388
+ | `renderTopicNames` | function | `(topics) => string` | Renders topic names as a comma-separated list in their supplied order. |
1389
+ | `renderCauseChain` | function | `(error) => string` | Renders an error and its causes, stopping after 4 entries. |
1390
+ | `extractWords` | function | `(message) => LedgerWordSet` | Extracts lowercase words and capitalized names that do not open a sentence. |
1391
+ | `splitWords` | function | `(part) => readonly string[]` | Splits a topic part into lowercase words with surrounding punctuation removed. |
1392
+ | `rankLedgerCut` | function | `(group, unsettled, category) => number` | Ranks a briefing source for removal before the next prompt. |
1393
+ | `renderLedgerRecord` | function | `(record) => string` | Renders one record as a heading and one list item per line. |
1394
+ | `renderLedgerPinned` | function | `(record) => string` | Renders one owner record under a `###` heading, which the briefing nests under its one `## Pinned` heading. |
1395
+ | `splitTopic` | function | `(topic) => readonly string[]` | Splits a recall topic at its joints. |
1396
+ | `cutListing` | function | `(entries, room) => string` | Cuts entries to a room and names how many it left out. |
1397
+ | `matchesCutLine` | function | `(line) => boolean` | Checks whether a line is the cut line `cutListing` writes. |
1398
+ | `normalizeArgument` | function | `(value) => string` | Normalizes a lookup argument string to the spelling that lookup identity and owner linking compare. |
1399
+ | `splitResultLines` | function | `(text) => readonly string[]` | Splits a tool result into its lines at each line feed, with or without a carriage return before it. |
1400
+ | `renderLookupCall` | function | `(name, args) => string` | Renders a lookup call as its tool name and its JSON arguments. |
1401
+ | `renderStub` | function | `(name, args, state) => string` | Renders the tail stub of a lookup result from an earlier request. |
1402
+ | `computeThinking` | function | `(message, completion) => number` | Computes the completion tokens attributable to a message's thinking. |
1403
+ | `resolvePredict` | function | `(predict, capacity) => number` | Resolves the generation cap and checks it against the context capacity. |
1404
+ | `fitSlope` | function | `(groups) => number \| undefined` | Fits the marginal tokens one estimate unit adds within a request. |
1405
+ | `collectLive` | function | `(input) => readonly string[]` | Collects the ids of the live messages in conversation order. |
1406
+ | `placeMember` | function | `(input, links, amending, id, seen, settled?) => ReadonlySet<string>` | Places a message on the record keys it joins. |
1407
+ | `collectStale` | function | `(input, byId, live) => readonly LedgerStaleSentence[]` | Collects the sentences that live messages made stale. |
1408
+ | `buildLines` | function | `(input, byId, id, dead, holders, system) => readonly LedgerLine[]` | Builds the record lines of one message. |
1409
+
1410
+ The `Classifier` and `Gauge` classes the ledger composes are public, so a harness can file a conversation or price a prompt without a ledger. The following classifier files one rule, and the following gauge prices the calls of one request:
661
1411
 
662
1412
  ```ts
663
- import { isConversationSnapshot } from '@orkestrel/agent'
1413
+ import type { JudgeInterface, LedgerThreshold } from '@orkestrel/agent'
1414
+ import { Classifier, createConversation, Gauge, LEDGER_QUESTIONS } from '@orkestrel/agent'
664
1415
 
665
- declare const row: unknown
666
- const snapshot = isConversationSnapshot(row) ? row : undefined
1416
+ declare const judge: JudgeInterface // answers 0.9 for the rule category and 0.9 for the topic
1417
+ declare const thresholds: LedgerThreshold // fitted on LEDGER_QUESTIONS and this judge
1418
+ declare const signal: AbortSignal
1419
+
1420
+ const conversation = createConversation()
1421
+ const rule = conversation.add({ role: 'user', content: 'Refunds over $100 need a manager.' })
1422
+ const classifier = new Classifier({
1423
+ conversation,
1424
+ judge,
1425
+ questions: LEDGER_QUESTIONS,
1426
+ topics: [{ name: 'refunds', criterion: 'refund amounts and approvals' }],
1427
+ thresholds,
1428
+ assign: () => undefined, // the judge decides every message
1429
+ entities: () => new Set(), // no lookups, so no shared id asks a pair question
1430
+ })
1431
+ const filed = await classifier.classify(new Set(), signal)
1432
+ filed.judgments.length // 2 — the category question and the refunds topic question
1433
+ filed.readings.map((reading) => reading.outcome) // ['resolved', 'resolved'] — the judge answered both
1434
+ classifier.category(rule.id) // 'rule'
1435
+ classifier.decisive(rule.id) // true
1436
+ classifier.quiet(rule.id) // false
1437
+ classifier.topics(rule.id) // Set { 'refunds' }
1438
+ classifier.classification().categories.get(rule.id) // 'rule'
1439
+
1440
+ const gauge = new Gauge({ scale: 1.25, overhead: 120, capacity: 32_768 })
1441
+ const calls = [{ estimate: 400, prompt: 640, completion: 30, tools: 2 }]
1442
+ gauge.rate(calls) // 1.25 — the scale, because no two calls with one tool count are observed
1443
+ gauge.remainder(calls) // 32098 — the capacity less the last call's prompt and completion
1444
+ gauge.reserve(calls, '') // 36.25 — an empty reply and one recall call, priced at the rate
1445
+ gauge.room(calls, '') // 12824.7 — half of what is left beyond the reserve, in estimate units
1446
+ gauge.observe(calls)
1447
+ gauge.scale // 1.3 — the first call's prompt less the overhead cost, over its estimate
667
1448
  ```
668
1449
 
669
- ### Errors
670
-
671
- | API | Kind | Summary |
672
- | ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
673
- | `ProviderAbortError` | class | Reports a provider stream cancelled mid-flight by its bound signal — thrown by a `ProviderInterface`'s `stream`, carrying the `ProviderResult` assembled from whatever streamed before the cancel and the machine-readable `code` `'ABORT'`. |
674
- | `isProviderAbortError` | function | Narrows an unknown caught value to a `ProviderAbortError` through `instanceof`, so a `catch` can recover its `partial` result. |
675
- | `ProviderError` | class | Reports a coded provider failure with its HTTP status and underlying cause when available. |
676
- | `isProviderError` | function | Narrows a caught value to the provider failure class through instanceof. |
677
- | `AgentJobError` | class | Reports an `AgentInterface` run that ended `AgentResult.partial` under a `partial` policy of `false` (the default) — thrown by an agent-job handler (a `createAgentQueue` / `createAgentRunner` job), carrying the partial `AgentResult` so the failure stays inspectable, and the machine-readable `code` `'PARTIAL'`. |
678
- | `isAgentJobError` | function | Narrows an unknown caught value to an `AgentJobError` through `instanceof`, so a `catch` can recover its `partial` result. |
679
- | `ConversationError` | class | Reports a conversation with no `ConversationSummaryHandler` to fold its messages with, or with a `sections` cap below `1` — thrown by a `ConversationInterface`'s `compact()` or its construction, carrying the machine-readable `code` `'SUMMARIZER' \| 'SECTIONS'`. |
680
- | `isConversationError` | function | Narrows an unknown caught value to a `ConversationError` through `instanceof`, so a `catch` can branch on its `code`. |
681
- | `AgentError` | class | Reports a concurrent run that would corrupt shared per-agent accounting, or a rehydration name absent from its registry pool — thrown synchronously by an `AgentInterface`'s `stream()` (and so by `generate()`, which calls it) and by an `AgentRegistryInterface`'s accessors, carrying the machine-readable `code` `'CONCURRENCY' \| 'REGISTRY'`. Synchronous means a fire-and-forget `agent.generate().catch(…)` never catches it: `await` the call inside `try`/`catch`, or wrap the call expression itself. |
682
- | `isAgentError` | function | Narrows an unknown caught value to an `AgentError` through `instanceof`, so a `catch` can branch on its `code`. |
683
- | `JudgeError` | class | Reports a coded judge failure with its HTTP status and underlying cause when available. |
684
- | `isJudgeError` | function | Narrows a caught value to the judge failure class through `instanceof`. |
685
- | `JudgeAbortError` | class | Reports a judge call cancelled by the caller's signal or its deadline, carrying the `JudgeResult` merged from the calls that completed before the cancel and the machine-readable `code` `'ABORT'`. |
686
- | `isJudgeAbortError` | function | Narrows a caught value to a `JudgeAbortError` through `instanceof`, so a `catch` can recover its `partial` result. |
687
-
688
- ### Types
689
-
690
- A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. An extended interface's name comes before `plus`, with the members it adds after.
691
-
692
- | Type | Kind | Shape | Summary |
693
- | ------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
694
- | `MessageRole` | type | `'system' \| 'user' \| 'assistant' \| 'tool'` | Names the role a `Message` plays in a conversation turn. |
695
- | `Message` | interface | `{ id, role, content, calls?, images? }` | Represents one conversation turn fed to a `ProviderInterface` — a stored, identified message. |
696
- | `MessageInput` | interface | `{ role, content, calls?, images? }` | Carries the minimal data needed to author a `Message` — the `id` is assigned by the layer that stores it, so a caller supplies only role / content (and, for a replayed assistant turn, its `calls`). |
697
- | `ProviderResult` | interface | `{ content, thinking?, tools?, usage? }` | Holds a single inference turn's structured outcome — the assembled assistant content, any reasoning the provider separated from it, any tool calls the model requested, and the token usage it reported. |
698
- | `ProviderDelta` | type | `{ channel: 'content', text } \| { channel: 'thinking', text }` | Represents one streamed delta a `ProviderInterface`'s `stream` yields — a unit tagged by the channel it belongs to, so the agent loop can re-surface answer content and live reasoning separately as it pumps. |
699
- | `ProviderStreamOptions` | interface | `{ think?, schema? }` | Carries the per-call options threaded into a `ProviderInterface`'s `generate` / `stream` — the bag a caller passes to influence one inference call without reconfiguring the provider instance. |
700
- | `ProviderInterface` | interface | `{ id, name, format? } plus generate, stream` | Defines the pluggable LLM inference boundary — the one contract every agent chunk depends on. A provider turns a conversation (plus optional tools) into either a single assembled `ProviderResult` (`generate`) or a stream of `ProviderDelta`s that returns the assembled result (`stream`). |
701
- | `ProviderParserInterface` | interface | `{} plus parse, clear` | Defines the structural framing seam supplied by a concrete provider. |
702
- | `ProviderRequest` | interface | `{ messages, tools?, options? }` | Carries the conversation and per-call configuration sent to a provider. |
703
- | `ProviderIncrement` | interface | `{ content, thinking, tools, usage?, result? }` | Holds the decoded contribution of a wire record to a provider turn. |
704
- | `ProviderOptions` | interface | `{ timeout?, fetch?, headers?, format? }` | Configures a provider's deadline, transport, headers, and context framing. |
705
- | `AgentProviderInput` | interface | `ProviderOptions plus { url, path?, split?, strict? }` | Configures the HTTP destination and stream assembly of a provider base. |
706
- | `AgentProviderInterface` | interface | `ProviderInterface plus frame, body, read, finish` | Defines the wire-specific seams of the shared HTTP provider engine. |
707
- | `ProviderErrorCode` | type | `'HTTP' \| 'PROTOCOL' \| 'PROVIDER'` | Names the machine-readable provider failure conditions. |
708
- | `ProviderErrorOptions` | interface | `{ status?, cause? }` | Carries a provider failure's HTTP status and underlying cause. |
709
- | `TextRead` | interface | `{ text, complete }` | Carries a decoded stream prefix and whether the stream ended within its byte budget. |
710
- | `RelayFrame` | type | `ProviderDelta \| { channel: 'result', result } \| { channel: 'abort', partial } \| { channel: 'error', message }` | Carries a relay delta, settled result, remote abort, or remote failure. |
711
- | `RelayHandler` | type | `(request: Request) => Promise<Response>` | Defines a host-independent relay request handler. |
712
- | `RelayOptions` | interface | `{ provider, authorize, limit? }` | Configures the upstream provider, mandatory authorization, and request byte limit. |
713
- | `RelayProviderOptions` | interface | `ProviderOptions plus { url, parser }` | Configures a relay destination and its fresh structural parser factory. |
714
- | `RelayStreamOptions` | interface | `{ provider, request, signal }` | Carries the upstream call and cancellation bound of a relay response stream. |
715
- | `ThinkSplitterInterface` | interface | `{ content, thinking } plus split, flush` | Splits a thinking model's in-content `<think>…</think>` reasoning spans away from the answer, delta by delta with per-stream state, so a provider yields clean content alone and surfaces the reasoning as `ProviderResult.thinking`. |
716
- | `ContextSectionFormat` | interface | `{ open?, render?, close? }` | Overrides one context section's format — an `open` / `render` / `close` trio that frames a section in the `AgentContext` build cascade: a top line rendered once before the items, a per-item rendering, and a bottom line rendered once after the items. |
717
- | `ContextFormat` | interface | `{ instructions? }` | Holds a provider's optional context-framing default, keyed by section kind — the framing a model prefers (for example XML tags against Markdown headers), declared by a `ProviderInterface` that opts in. |
718
- | `ContextSectionSourceInterface` | interface | `{ open, format } plus render` | Exposes the manager surface one context section's format cascade reads — its built-in `open` / `render`, plus the raw options override the cascade layers a provider default beneath; `InstructionManagerInterface` satisfies it structurally. |
719
- | `MessageManagerInterface` | interface | `{ count } plus add, message, messages, remove, clear` | Stores immutable `Message`s in insertion order and mints each `id` on `add` — the message-store contract `AgentContextInterface.messages` is typed to, which the active `ConversationInterface` satisfies structurally. |
720
- | `InstructionInterface` | interface | `{ id, name, content, priority, override? }` | Represents an immutable instruction — a named directive a richer context places between the system prompt and the conversation, ordered by descending `priority`. |
721
- | `InstructionInput` | interface | `{ name, content, priority?, override? }` | Carries the minimal data to author an `InstructionInterface` — the `id` is minted by the `InstructionManagerInterface` that stores it, so a caller supplies only `name` / `content` (and an optional `priority`, defaulting to `0`). |
722
- | `InstructionManagerEventMap` | type | `{ add, remove, clear }` | Maps the push observation surface of an `InstructionManagerInterface` — the mutation moments a fire-and-forget observer subscribes to through `manager.emitter.on`. |
723
- | `InstructionManagerOptions` | interface | `{ on?, error?, format? }` | Configures `createInstructionManager` — the reserved `on` hooks plus an optional per-section format override. |
724
- | `InstructionManagerInterface` | interface | `{ emitter, count, open, format } plus add, instruction, instructions, render, remove, clear` | Registers `InstructionInterface`s keyed by `name` — `add` (one or a batch) mints each `id` and overwrites a same-name instruction, last write wins, while `instructions()` lists them sorted by descending `priority` and stable for ties. |
725
- | `ScopeFilter` | interface | `{ instructions?, tools?, files? }` | Lists the per-category allow-lists a `ScopeInterface` carries — an optional `readonly string[]` for `instructions`, for `tools`, and for `files`, each keyed by that category's identity (an instruction's `name`, a tool's `name`, a workspace file's `path`) and read as an allow-list: `undefined` lets everything pass, `[]` lets nothing pass, and a non-empty list passes the listed keys alone. |
726
- | `ScopeInput` | interface | `ScopeFilter plus { name }` | Carries the data to author a `ScopeInterface` — a `ScopeFilter` plus the required `name` (a human label; the `id` is minted by the layer that stores it). |
727
- | `ScopeInterface` | interface | `ScopeFilter plus { id, name } plus narrow` | Represents a named, immutable filter over a richer context's items — the per-category allow-lists (`ScopeFilter`) plus an `id` / `name`, and a `narrow` that composes a tighter child by set intersection. |
728
- | `ScopeManagerEventMap` | type | `{ create, remove, clear }` | Maps the push observation surface of a `ScopeManagerInterface` — analogous to `InstructionManagerEventMap`, but keyed by the minted `id` and carrying `create` (a scope always mints, never overwrites) rather than `add`. |
729
- | `ScopeManagerOptions` | interface | `{ on?, error? }` | Configures `createScopeManager` — the reserved `on` hooks: initial listeners for the manager's `ScopeManagerEventMap`, wired at construction. |
730
- | `ScopeManagerInterface` | interface | `{ emitter, count } plus create, scope, scopes, remove, clear` | Registers reusable `ScopeInterface`s keyed by their minted `id` — `create` mints + stores one (never overwrites), `scopes()` lists them in insertion order. |
731
- | `AgentContextOptions` | interface | `{ system?, tools?, instructions?, workspaces?, scope?, conversations? }` | Configures `createAgentContext` — the optional system prompt plus the pre-built managers to reuse: an `instructions` registry, a `workspaces` registry (the only document channel), a `conversations` registry (the message source), a `tools` registry (the loop's advertise and dispatch surface), and an initial `scope`. |
732
- | `AgentContextInterface` | interface | `{ system, instructions, workspaces, messages, conversations, tools, scope } plus apply, build` | Assembles a turn's provider input from the system prompt + the context managers + the conversation, applying the active scope per category. |
733
- | `AgentStatus` | type | `'idle' \| 'running' \| 'done' \| 'error'` | Names the lifecycle state of an `AgentInterface` turn — `idle` before a run, `running` while the loop is in flight, then the settled `done` (a normal finish or a cancel) or `error` (a genuine provider / tool failure). |
734
- | `AgentChunk` | type | `{ category: 'token', content } \| { category: 'think', content } \| { category: 'tool', call, result } \| { category: 'usage', usage }` | Represents a streamed step of an agent turn — the union the loop yields as it runs, discriminated by the `category` of step it carries, and the pull surface beside the push `AgentEventMap`. |
735
- | `AgentEventMap` | type | `{ start, turn, tool, usage, deny, finish, error, abort, exhaust, fault }` | Maps the push observation surface of an `AgentInterface` — the lifecycle, usage, and tool moments a fire-and-forget observer (logging, metrics, tracing) subscribes to, beside the pull `AgentChunk` stream. |
736
- | `AgentResult` | interface | `{ content, thinking?, usage?, partial }` | Holds the settled outcome of an agent turn — the assembled assistant `content`, the `usage` summed across the turn's provider calls, and whether it was committed `partial`. |
737
- | `RunOutcome` | interface | `{ content, thinking, usage, partial, exhausted }` | Holds the immutable per-run outcome an `AgentInterface`'s loop settles on — the value its run returns, assembled from there into the `AgentResult` its `stream`'s `result` promise resolves. |
738
- | `ChannelInterface` | interface | `{} plus push, close, fail, drain` | Buffers values in an unbounded async channel — a producer writes them in (`push`) and ends it (`close` / `fail`) regardless of consumption, while a consumer reads them back live through `drain`. |
739
- | `StreamInterface` | interface | `{ events, result } plus abort` | Pairs a live event stream with the eventual settled result and a cancel — the generic pull/streaming handle a long-running operation hands back. |
740
- | `AgentStreamInterface` | type | `StreamInterface<AgentChunk, AgentResult>` | Names the agent turn's live handle — a `StreamInterface` of `AgentChunk`s resolving an `AgentResult`. |
741
- | `AgentOptions` | interface | `{ on?, error?, system?, tools?, instructions?, workspaces?, scope?, limit?, timeout?, budget?, scheduler?, signal?, authority?, conversations?, window?, strict? }` | Configures `createAgent` — the loop's bounds and pacing, the reserved `on` hooks, the construction-time context wiring (`instructions` / `workspaces` / `scope`), the `conversations` registry that is the message source, the context `window` budget that opts into automatic compaction of the active conversation, and the `strict` switch that aborts the run on an automatic-compaction summarizer failure instead of the lenient default. |
742
- | `AgentRunOptions` | interface | `{ think?, schema?, limit?, timeout?, budget?, signal? }` | Carries the per-run override bag an `AgentInterface`'s `generate` / `stream` accepts — each member overrides the matching `AgentOptions` value for one run, where `think` and `schema` forward to the provider call and `signal` composes with the constructed one. |
743
- | `AgentInterface` | interface | `{ emitter, id, status, context } plus generate, stream, abort` | Composes a `ProviderInterface`, an `AgentContextInterface`, and a `ToolManagerInterface` into a bounded context → provider → tools → repeat turn. |
744
- | `AuthorityContext` | interface | `{ call }` | Carries what an `AuthorityInterface` evaluates for one tool call — the call under consideration. |
745
- | `AuthorityDecision` | interface | `{ zone, allowed, reason? }` | Holds an `AuthorityInterface`'s verdict on one tool call. |
746
- | `AuthorityRule` | interface | `{ match, zone, allowed?, reason? }` | Represents one ordered policy rule an `AuthorityInterface` evaluates. |
747
- | `AuthorityOptions` | interface | `{ rules?, fallback? }` | Configures `createAuthority` — the ordered rules and the no-match fallback. |
748
- | `AuthorityInterface` | interface | `{} plus evaluate` | Gates each tool call before it runs — the synchronous policy that turns one `AuthorityContext` into an `AuthorityDecision`. |
749
- | `AgentJobInput` | interface | `{ provider, messages, system?, tools?, authority?, scheduler?, limit?, timeout?, budget?, children? }` | Represents a JSON-serializable agent job — the descriptor a durable queue or runner runs. Its non-serializable pieces (the provider, tools, authority, scheduler) are referenced by name and resolved to live objects through an `AgentRegistryInterface` at handler time, while its data fields (the seed `messages`, `system`, `limit`, `timeout`, and a token `budget` ceiling) carry directly. |
750
- | `AgentRegistryInterface` | interface | `{} plus provider, tool, authority, scheduler, build` | Resolves an `AgentJobInput`'s names to the live, non-serializable pieces and rehydrates a seeded, signal-wired `AgentInterface` — the bridge that makes a durable, serializable job runnable. |
751
- | `AgentRegistryOptions` | interface | `{ providers, tools?, authorities?, schedulers?, store? }` | Configures `createAgentRegistry` — the named pools of live, non-serializable pieces an `AgentJobInput`'s names resolve against, plus the optional durable `store` every built agent's conversation manager shares. |
752
- | `AgentQueueOptions` | interface | `{ registry, partial?, concurrency?, retries?, timeout?, store? }` | Configures `createAgentQueue` — the registry that rehydrates jobs, the partial-result policy, and the substrate knobs threaded into the backing `createQueue`. |
753
- | `AgentRunnerOptions` | interface | `{ registry, partial?, concurrency?, retries?, timeout? }` | Configures `createAgentRunner` — the registry that rehydrates jobs, the partial-result policy, and the substrate knobs threaded into the backing `createRunner`. |
754
- | `ConversationSummaryHandler` | type | `(messages: readonly Message[]) => Promise<string>` | Summarizes a conversation, provider-agnostically — the seam the agent runtime supplies so core never imports a provider. Given the folded messages, it resolves their digest, the model-written summary used to summarize a compacted `Section` and to regenerate a `ConversationInterface`'s rollup `summary`. |
755
- | `Section` | interface | `{ id, summary, messages }` | Holds a slice of folded messages digested into a summary — the unit of compaction a `ConversationInterface` produces when it `compact`s its live tail. |
756
- | `ConversationEventMap` | type | `{ compact, summary, rehydrate, collapse }` | Maps the push observation surface of a `ConversationInterface` — the compaction moments a fire-and-forget observer subscribes to through `conversation.emitter.on`. |
757
- | `ConversationOptions` | interface | `{ id?, on?, error?, summarize?, keep?, sections?, snapshot? }` | Configures `createConversation` — the optional `id`, the reserved `on` hooks, the provider-agnostic `summarize` seam, the retained-tail size, an optional cap on the compacted `sections` list, and a `ConversationSnapshot` to hydrate from. |
758
- | `CompactOptions` | interface | `{ keep?, sections? }` | Configures one `ConversationInterface.compact` call — the retained-tail size, the `sections` cap, or both, overridden for one fold. |
759
- | `ConversationReferenceOptions` | interface | `{ label?, summary?, messages? }` | Configures `ConversationInterface.reference` — how to render one conversation as a self-labeled, fenced provenance block to pull into another conversation by writing it to the active context's active workspace: `label` defaults to the `id`, `summary` defaults to `true`, and `messages` are cherry-picked excerpts defaulting to none. |
760
- | `ConversationInterface` | interface | `{ id, emitter, summary, sections, summarizable, count } plus add, message, messages, remove, clear, view, compact, rehydrate, search, reference, snapshot` | Groups messages above the flat `MessageManagerInterface` — a live uncompacted tail plus compacted, summarized `Section`s and a conversation rollup `summary`, with on-demand `rehydrate`, substring `search`, a cross-conversation `reference`, and a JSON `snapshot`, driven by a provider-agnostic `ConversationSummaryHandler` seam; `summarizable` reports whether that seam was supplied, and the agent loop gates automatic compaction on it. |
761
- | `ConversationInput` | interface | `{ id?, summarize?, keep?, sections?, on?, snapshot? }` | Carries the data to author a `ConversationInterface` through a `ConversationManagerInterface` — the optional `id`, a `summarize` override, a `keep` override, a `sections` cap override, the reserved `on` hooks, and a `ConversationSnapshot` to hydrate from. |
762
- | `ConversationManagerOptions` | interface | `{ summarize?, keep?, sections?, store? }` | Configures `createConversationManager` — the default `ConversationSummaryHandler`, retained-tail size, and `sections` cap the conversations it creates inherit, plus the optional durable `store` backing `open` / `save`. |
763
- | `ConversationManagerInterface` | interface | `{ count, active } plus conversation, conversations, add, switch, open, save, remove, clear` | Registers `ConversationInterface`s keyed by their `id`, in insertion order, with an active pointer — the id-keyed store over the conversation layer, the `active` / `switch` seam the `AgentContextInterface` renders, and the durable `open` / `save` store seam. Event-free (a registry, like `WorkspaceManagerInterface`); the observability lives on each `ConversationInterface`. |
764
- | `ConversationSnapshot` | interface | `{ id, summary?, sections, messages }` | Holds a JSON-serializable snapshot of a conversation's state — its `id`, the rollup `summary`, the compacted `sections`, and the live tail `messages` — the durable payload the `ConversationStoreInterface` persists. The exact analogue of `WorkspaceSnapshot`. |
765
- | `ConversationStoreInterface` | interface | `{} plus get, set, delete` | Persists a `ConversationSnapshot` durably — the async `get` / `set` / `delete` primitives, keyed by a conversation id and holding no expiry, the exact analogue of `WorkspaceStoreInterface`. |
766
- | `ConversationSnapshotRow` | interface | `{ id, snapshot }` | Represents one row of the table a `DatabaseConversationStore` persists — a conversation `id` plus its `ConversationSnapshot` held as one opaque JSON column, read back as `unknown` and narrowed on `get`. The exact analogue of `WorkspaceSnapshotRow`. |
767
- | `JudgeEntry` | type | `string \| JSONRecord \| readonly JSONValue[]` | Carries text or structured JSON the model reads; mirrors the TypeSafe `EntryType` without its null arm. |
768
- | `ChoiceCriteria` | type | `Readonly<Record<string, JudgeEntry \| null>>` | Maps each option name the model sees to its description; null keeps an undescribed option in the map. |
769
- | `ScoreCriteria` | type | `readonly [JudgeEntry \| null, JudgeEntry \| null, ...Array<JudgeEntry \| null>]` | Lists at least two score levels from level 0 upward; null leaves a level undescribed. |
770
- | `NoulCriteria` | interface | `{ true?, false? }` | Carries what makes a noul answer true and what makes it false; an omitted side is undescribed. |
771
- | `ChoiceQuestion` | interface | `{ form, instructions?, criteria }` | Asks the model to pick one named option from its criteria. |
772
- | `ScoreQuestion` | interface | `{ form, instructions?, criteria }` | Asks the model to place the state on an ordered scale of levels. |
773
- | `NoulQuestion` | interface | `{ form, instructions?, criteria? }` | Asks the model whether a statement about the state holds. |
774
- | `JudgeQuestion` | type | `ChoiceQuestion \| ScoreQuestion \| NoulQuestion` | Names one question by its form, the protocol's type field under the fleet's named discriminant. |
775
- | `JudgeRequest` | interface | `{ state, questions }` | Carries one state and the questions asked about it, keyed by caller ids the model never sees; each question is evaluated on its own. |
776
- | `ChoiceAnswer` | interface | `{ form, probabilities }` | Carries a choice distribution keyed by option name, in criteria order. |
777
- | `ScoreAnswer` | interface | `{ form, probabilities }` | Carries a score distribution indexed by level. |
778
- | `NoulAnswer` | interface | `{ form, noul }` | Carries the probability that the answer is yes; the protocol's noul field. |
779
- | `JudgeAnswer` | type | `ChoiceAnswer \| ScoreAnswer \| NoulAnswer` | Names one answer by its form; it stores only the distribution a server returned. |
780
- | `Refusal` | interface | `{ missing }` | Reports a question a wire could not read a candidate for; it lists the caller's keys and invents no probability. |
781
- | `JudgeResult` | interface | `{ model, answers, refusals?, usage? }` | Carries the answering model, the answers keyed by question id, the refusals, and the usage the request's calls spent. |
782
- | `JudgeInterface` | interface | `{ id, name, model } plus ask` | Answers typed questions about one state with probabilities; the sibling of `ProviderInterface`, never a provider. |
783
- | `Reading` | interface | `{ winner, probability, confidence, score? }` | Carries the measures `computeReading` derives from an answer; nothing stores them. |
784
- | `AgentJudgeInput` | interface | `Pick<ProviderOptions, 'timeout' \| 'fetch' \| 'headers'> plus { url, path?, model, batch? }` | Configures the judge engine's destination, identity, and call split. |
785
- | `AgentJudgeInterface` | interface | `JudgeInterface plus body, read` | Defines the wire seams of the shared judge engine. |
786
- | `JudgeErrorCode` | type | `'HTTP' \| 'PROTOCOL' \| 'QUESTION'` | Names the machine-readable judge failure conditions. |
787
- | `SystemOneJudgeOptions` | interface | `Pick<ProviderOptions, 'timeout' \| 'fetch' \| 'headers'> plus { url, model }` | Configures the System One server, model, transport, authentication, and deadline. |
788
- | `SystemOneEntry` | type | `JudgeEntry \| null` | Carries a TypeSafe System One entry, including the wire's explicit null. |
789
- | `SystemOneQuestion` | interface | `{ type, instructions?, criteria? }` | Transliterates a TypeSafe System One question with its protocol discriminant and criteria. |
790
- | `SystemOneRequest` | interface | `{ state, model, questions }` | Transliterates the TypeSafe System One request body. |
791
- | `SystemOneUsage` | interface | `{ input_tokens?, output_tokens? }` | Transliterates TypeSafe System One token counts with missing or null counts permitted. |
792
- | `SystemOneChoiceAnswer` | interface | `{ type, probabilities, choice?, confidence? }` | Transliterates a TypeSafe System One choice distribution and optional server measures. |
793
- | `SystemOneScoreAnswer` | interface | `{ type, probabilities, score?, legend?, confidence? }` | Accepts System One score probabilities and legends as maps or llama.cpp arrays. |
794
- | `SystemOneNoulAnswer` | interface | `{ type, noul, confidence? }` | Transliterates a TypeSafe System One yes probability and optional server confidence. |
795
- | `SystemOneAnswer` | type | `SystemOneChoiceAnswer \| SystemOneScoreAnswer \| SystemOneNoulAnswer` | Unites the TypeSafe System One answer forms. |
796
- | `SystemOneResponse` | interface | `{ model?, answers, usage? }` | Transliterates the System One response envelope before question-specific answer validation. |
797
-
798
- Agent-owned readonly data members stay in the preceding Surface tables; their call-signature methods are documented under [`## Methods`](#methods). Tool contracts resolve to [`tool.md`](tool.md) and workspace contracts to [`workspace.md`](workspace.md) — neither dependency surface is duplicated or re-exported here. Note where the boundary falls inside the context: `instructions`, `conversations`, and `workspaces` are the managers `build()` renders a prompt from, while `tools` is loop machinery for advertising and dispatch and is never read by `build()` at all.
1450
+ Agent-owned readonly data members stay in the preceding Surface tables; their call-signature methods are documented under [`## Methods`](#methods). For tool contracts, see [Tool](tool.md), and for workspace contracts, see [Workspace](workspace.md); neither dependency surface is duplicated or re-exported here. Note where the boundary falls inside the context: `instructions`, `conversations`, and `workspaces` are the managers `build()` renders a prompt from, while `tools` is loop machinery for advertising and dispatch and is never read by `build()` at all.
799
1451
 
800
1452
  ## Methods
801
1453
 
802
- The tables list every public call-signature member of `ProviderInterface`, `AgentProviderInterface`, `ProviderParserInterface`, `RelayProvider`, `JudgeInterface`, `AgentJudgeInterface`, `SystemOneJudge`, `ThinkSplitterInterface`, `MessageManagerInterface`, `InstructionManagerInterface`, `ContextSectionSourceInterface`, `ScopeInterface`, `ScopeManagerInterface`, `AgentContextInterface`, `AgentInterface`, `StreamInterface`, `ChannelInterface`, `AuthorityInterface`, `AgentRegistryInterface`, `ConversationInterface`, `ConversationManagerInterface`, `ConversationStoreInterface`, `MemoryConversationStore`, and `DatabaseConversationStore`. Their readonly data members remain Surface rows. `AgentProvider`, `AgentJudge`, `ThinkSplitter`, `InstructionManager`, `Scope`, `ScopeManager`, `AgentContext`, `Agent`, `Authority`, `AgentRegistry`, `Conversation`, and `ConversationManager` implement their interfaces exactly, so the tables also describe those classes' instance methods. `RelayProvider`, `SystemOneJudge`, and the store classes keep explicit tables because their class names have no same-name interface contract. `MessageManagerInterface` has no separate concrete class here: the active `Conversation` satisfies it structurally. `RelayStream` exposes `response` alone, a data member, so it keeps its Surface row and takes no table. Tool and workspace methods live in their dependency guides.
1454
+ The tables list every public call-signature member of `ProviderInterface`, `AgentProviderInterface`, `ProviderParserInterface`, `RelayProvider`, `JudgeInterface`, `AgentJudgeInterface`, `SystemOneJudge`, `ThinkSplitterInterface`, `JudgmentManagerInterface`, `MessageManagerInterface`, `InstructionManagerInterface`, `ScopeInterface`, `ScopeManagerInterface`, `AgentContextInterface`, `AgentInterface`, `StreamInterface`, `ChannelInterface`, `AuthorityInterface`, `AgentRegistryInterface`, `ConversationInterface`, `ConversationManagerInterface`, `ConversationStoreInterface`, `MemoryConversationStore`, `DatabaseConversationStore`, `LedgerInterface`, `LedgerRequestInterface`, `ClassifierInterface`, and `GaugeInterface`. Their readonly data members remain Surface rows. `AgentProvider`, `AgentJudge`, `ThinkSplitter`, `InstructionManager`, `Scope`, `ScopeManager`, `AgentContext`, `Agent`, `Authority`, `AgentRegistry`, `Conversation`, `ConversationManager`, `JudgmentManager`, `Ledger`, `LedgerRequest`, `Classifier`, and `Gauge` implement their interfaces exactly, so the tables also describe those classes' instance methods. `RelayProvider`, `SystemOneJudge`, and the store classes keep explicit tables because their class names have no same-name interface contract. `MessageManagerInterface` has no separate concrete class here: the active `Conversation` satisfies it structurally. `RelayStream` exposes `response` alone, a data member, so it keeps its Surface row and takes no table. Tool and workspace methods live in their dependency guides.
803
1455
 
804
1456
  #### `ProviderInterface`
805
1457
 
806
- `generate` produces one complete turn; `stream` yields `ProviderDelta`s and returns the assembled result. Both take the conversation, a bounding `AbortSignal`, optional `tools`, and optional per-call `ProviderStreamOptions`.
1458
+ `generate` produces one complete turn; `stream` yields `ProviderDelta` values and returns the assembled result. Both take the conversation, a bounding `AbortSignal`, optional `tools`, and optional per-call `ProviderStreamOptions`.
807
1459
 
808
- | Method | Returns | Summary |
809
- | ---------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
810
- | `generate` | `Promise<ProviderResult>` | Generates one complete turn — resolves the assembled `ProviderResult`. |
811
- | `stream` | `AsyncGenerator<ProviderDelta, ProviderResult>` | Streams one turn — yields channel-tagged `content` / `thinking` `ProviderDelta`s as they arrive and returns the assembled `ProviderResult` (the concatenated content, any separated reasoning, any tool calls, and any usage) when the stream completes. A mid-stream abort throws a `ProviderAbortError` carrying the partial result. |
1460
+ | Method | Returns | Summary |
1461
+ | ---------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1462
+ | `generate` | `Promise<ProviderResult>` | Generates one complete turn — resolves the assembled `ProviderResult`. |
1463
+ | `stream` | `AsyncGenerator<ProviderDelta, ProviderResult>` | Streams one turn — yields channel-tagged `content` / `thinking` `ProviderDelta` values as they arrive and returns the assembled `ProviderResult` (the concatenated content, any separated reasoning, any tool calls, and any usage) when the stream completes. A mid-stream abort throws a `ProviderAbortError` carrying the partial result. |
812
1464
 
813
1465
  #### `AgentProviderInterface`
814
1466
 
815
- The seams a subclass fills, beneath the boundary members it inherits. `AgentProviderInterface` extends `ProviderInterface`, so `generate` and `stream` are part of it and repeat here with their boundary contracts; `AgentProvider` implements each of them once, for every subclass. A subclass writes `frame` / `body` / `read` / `finish` and nothing else. The `id` / `name` / `format` data members stay Surface rows.
1467
+ The seams a subclass fills, beneath the boundary members it inherits. `AgentProviderInterface` extends `ProviderInterface`, so `generate` and `stream` are part of it and repeat here with their boundary contracts; `AgentProvider` implements each of them once, for every subclass. A subclass writes `frame` / `encode` / `read` / `finish` and nothing else. The `id`, `name`, and `replay` data members stay Surface rows.
816
1468
 
817
- | Method | Returns | Summary |
818
- | ---------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
819
- | `generate` | `Promise<ProviderResult>` | Generates one complete turn — resolves the assembled `ProviderResult`. |
820
- | `stream` | `AsyncGenerator<ProviderDelta, ProviderResult>` | Streams one turn — yields channel-tagged `content` / `thinking` `ProviderDelta`s as they arrive and returns the assembled `ProviderResult` (the concatenated content, any separated reasoning, any tool calls, and any usage) when the stream completes. A mid-stream abort throws a `ProviderAbortError` carrying the partial result. |
821
- | `frame` | `ProviderParserInterface<TRecord>` | Creates fresh framing state for a call. |
822
- | `body` | `object` | Projects a request to the concrete protocol's serializable body. |
823
- | `read` | `ProviderIncrement` | Decodes a framed record into its contribution to the turn. |
824
- | `finish` | `readonly TRecord[]` | Returns records retained at end of input before the parser is cleared. |
1469
+ | Method | Returns | Summary |
1470
+ | ---------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1471
+ | `generate` | `Promise<ProviderResult>` | Generates one complete turn — resolves the assembled `ProviderResult`. |
1472
+ | `stream` | `AsyncGenerator<ProviderDelta, ProviderResult>` | Streams one turn — yields channel-tagged `content` / `thinking` `ProviderDelta` values as they arrive and returns the assembled `ProviderResult` (the concatenated content, any separated reasoning, any tool calls, and any usage) when the stream completes. A mid-stream abort throws a `ProviderAbortError` carrying the partial result. |
1473
+ | `frame` | `ProviderParserInterface<TRecord>` | Creates fresh framing state for a call. |
1474
+ | `encode` | `object` | Projects a request to the concrete protocol's serializable body. |
1475
+ | `read` | `ProviderIncrement` | Decodes a framed record into its contribution to the turn. |
1476
+ | `finish` | `readonly TRecord[]` | Returns records retained at end of input before the parser is cleared. |
825
1477
 
826
1478
  #### `ProviderParserInterface`
827
1479
 
@@ -839,7 +1491,7 @@ The relay wire over `AgentProvider`: it fills every seam and inherits `generate`
839
1491
  | Method | Returns | Summary |
840
1492
  | -------- | -------------------------------------------------- | --------------------------------------------------------------------------------- |
841
1493
  | `frame` | `ProviderParserInterface` | Creates fresh framing state for each response. |
842
- | `body` | `object` | Projects declared request fields and refuses values the JSON wire cannot carry. |
1494
+ | `encode` | `object` | Projects declared request fields and refuses values the JSON wire cannot carry. |
843
1495
  | `read` | `ProviderIncrement` | Validates a relay frame and translates its channel into the shared stream engine. |
844
1496
  | `finish` | `ReadonlyArray<Readonly<Record<string, unknown>>>` | Recovers an unterminated final frame by completing its NDJSON line. |
845
1497
 
@@ -853,31 +1505,44 @@ The decision boundary. `ask` is its only method; the `id`, `name`, and `model` d
853
1505
 
854
1506
  #### `AgentJudgeInterface`
855
1507
 
856
- The seams a judge wire fills, beneath the boundary method it inherits. `AgentJudgeInterface` extends `JudgeInterface`, so `ask` is part of it and repeats here with its boundary contract; `AgentJudge` implements `ask` once, for every subclass. A subclass writes `body` and `read` and nothing else. The `id`, `name`, and `model` data members stay Surface rows.
1508
+ The seams a judge wire fills, beneath the boundary method it inherits. `AgentJudgeInterface` extends `JudgeInterface`, so `ask` is part of it and repeats here with its boundary contract; `AgentJudge` implements `ask` once, for every subclass. A subclass writes `encode` and `read` and nothing else. The `id`, `name`, and `model` data members stay Surface rows.
857
1509
 
858
- | Method | Returns | Summary |
859
- | ------ | ---------------------- | ----------------------------------------------------------------------------------- |
860
- | `ask` | `Promise<JudgeResult>` | Asks every question of the request about its state and returns the merged answers. |
861
- | `body` | `object` | Projects one call's request onto the concrete protocol's serializable body. |
862
- | `read` | `JudgeResult` | Decodes one call's parsed response body into the answers for that call's questions. |
1510
+ | Method | Returns | Summary |
1511
+ | -------- | ---------------------- | ----------------------------------------------------------------------------------- |
1512
+ | `ask` | `Promise<JudgeResult>` | Asks every question of the request about its state and returns the merged answers. |
1513
+ | `encode` | `object` | Projects one call's request onto the concrete protocol's serializable body. |
1514
+ | `read` | `JudgeResult` | Decodes one call's parsed response body into the answers for that call's questions. |
863
1515
 
864
1516
  #### `SystemOneJudge`
865
1517
 
866
1518
  The System One wire over `AgentJudge`: it fills both seams and inherits `ask` from the engine, so a caller drives it through `JudgeInterface` like any judge. Its `name` reports `'systemone'` and stays a data member.
867
1519
 
868
- | Method | Returns | Summary |
869
- | ------ | ------------------ | --------------------------------------------------------------------------------------- |
870
- | `body` | `SystemOneRequest` | Projects the state and questions onto the System One request with the configured model. |
871
- | `read` | `JudgeResult` | Decodes requested System One answers and reports the server model and available usage. |
1520
+ | Method | Returns | Summary |
1521
+ | -------- | ------------------ | --------------------------------------------------------------------------------------- |
1522
+ | `encode` | `SystemOneRequest` | Projects the state and questions onto the System One request with the configured model. |
1523
+ | `read` | `JudgeResult` | Decodes requested System One answers and reports the server model and available usage. |
872
1524
 
873
1525
  #### `ThinkSplitterInterface`
874
1526
 
875
- The stream-stateful `<think>…</think>` separator a provider routes raw content deltas through, so it yields clean content and surfaces the reasoning as `ProviderResult.thinking`. The `content` / `thinking` data members (the authoritative clean-content + reasoning accumulations) stay Surface rows — `content` matters because some chat templates pre-seed `<think>` into the prompt scaffold (the qwen3 shape), so only a bare `</think>` ever appears on the wire: before any tag event, that bare close reclassifies everything surfaced so far into `thinking` (one-shot — afterwards a bare close is plain text), correcting `content` retroactively where the already-returned deltas cannot be recalled. One splitter serves one stream — create a fresh one for each call (`createThinkSplitter`).
1527
+ The stream-stateful `<think>…</think>` separator a provider routes raw content deltas through, so it yields clean content and surfaces the reasoning as `ProviderResult.thinking`. The `content` / `thinking` data members (the authoritative clean-content + reasoning accumulations) stay Surface rows — `content` matters because some chat templates pre-seed `<think>` into the prompt scaffold (the qwen3 shape), so only a bare `</think>` ever appears on the wire: before any tag event, that bare close reclassifies everything surfaced so far into `thinking` (one-shot — afterwards a bare close is plain text), correcting `content` retroactively where the already-returned deltas can't be recalled. One splitter serves one stream — create a fresh one for each call (`createThinkSplitter`).
876
1528
 
877
- | Method | Returns | Summary |
878
- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
879
- | `split` | `string` | Feeds one raw delta and returns the clean, non-think content to surface for it (possibly `''`) — a tag split across deltas is held until disambiguated, never leaked as content and never mis-eaten as thinking. |
880
- | `flush` | `string` | Settles the stream end — a held partial tag that never completed returns as the final content delta, and an unclosed think span's tail lands on `thinking`. |
1529
+ | Method | Returns | Summary |
1530
+ | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1531
+ | `split` | `string` | Feeds one raw delta and returns the clean, non-think content to surface for it (possibly `''`) — a tag split across deltas is held until disambiguated, so it is neither returned as content nor routed to thinking. |
1532
+ | `flush` | `string` | Settles the stream end — a held partial tag that never completed returns as the final content delta, and an unclosed think span's tail lands on `thinking`. |
1533
+
1534
+ #### `JudgmentManagerInterface`
1535
+
1536
+ The conversation's judgment store. `add` stamps each record's `time` and carries batch overloads (one input → one record, a batch → the array), replacing an existing key in place; `remove` carries batch overloads (one key or a list). The `count` data member stays a Surface row.
1537
+
1538
+ | Method | Returns | Summary |
1539
+ | ----------- | ---------------------------------- | ------------------------------------------------------------------------------- |
1540
+ | `add` | `Judgment` / `readonly Judgment[]` | Stores inputs with the current epoch milliseconds; an existing key is replaced. |
1541
+ | `judgment` | `Judgment \| undefined` | Returns the record for a key, or `undefined` when absent. |
1542
+ | `judgments` | `readonly Judgment[]` | Returns stored records in insertion order. |
1543
+ | `remove` | `boolean` | Removes every supplied key; returns `true` only when all were present. |
1544
+ | `clear` | `void` | Removes all records. |
1545
+ | `resolve` | `Promise<readonly Judgment[]>` | Reuses matching records and asks for the unmatched questions in one request. |
881
1546
 
882
1547
  #### `MessageManagerInterface`
883
1548
 
@@ -893,63 +1558,56 @@ The immutable conversation store. `add` mints each message's `id` and carries ba
893
1558
 
894
1559
  #### `InstructionManagerInterface`
895
1560
 
896
- The name-keyed instruction registry a richer context renders a directives block from. `add` mints each `id` and carries batch overloads (a re-`add` of the same name overwrites it, last write wins); `remove` carries batch overloads. The `emitter` / `count` / `open` / `format` data members stay Surface rows.
897
-
898
- | Method | Returns | Summary |
899
- | -------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
900
- | `add` | `InstructionInterface` / `readonly InstructionInterface[]` | Adds one `InstructionInput`, or a batch — mints each `id`; a re-`add` of the same name overwrites it, last write wins. |
901
- | `instruction` | `InstructionInterface \| undefined` | Looks up one instruction by name (`undefined` when absent). |
902
- | `instructions` | `readonly InstructionInterface[]` | Lists every instruction, sorted by descending `priority` (stable for equal priorities). |
903
- | `render` | `string` | Renders one instruction for the prompt — its `content`. |
904
- | `remove` | `boolean` | Removes one instruction by name, or a batch — `true` only when every supplied name was removed. |
905
- | `clear` | `void` | Removes every instruction. |
1561
+ The name-keyed instruction registry a richer context renders a directives block from. `add` mints each `id` and carries batch overloads (adding an instruction whose name is already stored overwrites it, last write wins); `remove` carries batch overloads. The `emitter` / `count` / `open` / `close` data members stay Surface rows.
906
1562
 
907
- #### `ContextSectionSourceInterface`
908
-
909
- The manager surface one section's format cascade reads. `render` is its only method — the `open` (the built-in header) and `format` (the raw manager-options override) data members stay Surface rows. An `InstructionManagerInterface` satisfies it structurally, which is what lets `resolveOpen` / `resolveClose` / `resolveItem` stay independent of which manager supplies the section.
910
-
911
- | Method | Returns | Summary |
912
- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------ |
913
- | `render` | `string` | Renders one section item, already resolved against the manager-options override and otherwise on the built-in rendering. |
1563
+ | Method | Returns | Summary |
1564
+ | -------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
1565
+ | `add` | `InstructionInterface` / `readonly InstructionInterface[]` | Adds one `InstructionInput`, or a batch — mints each `id`; adding an instruction whose name is already stored overwrites it, last write wins. |
1566
+ | `instruction` | `InstructionInterface \| undefined` | Looks up one instruction by name (`undefined` when absent). |
1567
+ | `instructions` | `readonly InstructionInterface[]` | Lists every instruction, sorted by descending `priority` (stable for equal priorities). |
1568
+ | `render` | `string` | Renders one instruction for the prompt — its `override`, else the manager-options `render`, else its `content`. |
1569
+ | `remove` | `boolean` | Removes one instruction by name, or a batch. True if every supplied name was removed; false otherwise. |
1570
+ | `clear` | `void` | Removes every instruction. |
914
1571
 
915
1572
  #### `ScopeInterface`
916
1573
 
917
1574
  The named, immutable allow-list filter. `narrow` is the only method — the `id` / `name` data members and the per-category allow-lists stay Surface rows.
918
1575
 
919
- | Method | Returns | Summary |
920
- | -------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
921
- | `narrow` | `ScopeInterface` | Composes a tighter child scope — each category is the set intersection of this scope's list and `config`'s (an `undefined` side imposing no constraint), returned as a new scope that leaves this one unchanged. |
1576
+ | Method | Returns | Summary |
1577
+ | -------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1578
+ | `narrow` | `ScopeInterface` | Composes a tighter child scope — each category is the set intersection of this scope's list and the list in `config` (an `undefined` side imposing no constraint), returned as a new scope that leaves this one unchanged. |
922
1579
 
923
1580
  #### `ScopeManagerInterface`
924
1581
 
925
- The id-keyed registry of reusable named scopes. `create` mints + stores a scope (always adds — never overwrites, since two scopes may share a `name`); `remove` carries batch overloads. The `emitter` / `count` data members stay Surface rows.
1582
+ The id-keyed registry of reusable named scopes. `add` mints + stores a scope (never overwrites, because two scopes may share a `name`); `remove` carries batch overloads. The `emitter` / `count` data members stay Surface rows.
926
1583
 
927
- | Method | Returns | Summary |
928
- | -------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
929
- | `create` | `ScopeInterface` | Mints a scope from a `ScopeInput` (an `id` plus the per-category allow-lists) and stores it — always adds, never overwrites. |
930
- | `scope` | `ScopeInterface \| undefined` | Looks up one scope by id (`undefined` when absent). |
931
- | `scopes` | `readonly ScopeInterface[]` | Lists every scope, in insertion order. |
932
- | `remove` | `boolean` | Removes one scope by id, or a batch — `true` only when every supplied id was removed. |
933
- | `clear` | `void` | Removes every scope. |
1584
+ | Method | Returns | Summary |
1585
+ | -------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
1586
+ | `add` | `ScopeInterface` | Mints a scope from a `ScopeInput` (a `name` plus the per-category allow-lists) and stores it — always adds, never overwrites. |
1587
+ | `scope` | `ScopeInterface \| undefined` | Looks up one scope by id (`undefined` when absent). |
1588
+ | `scopes` | `readonly ScopeInterface[]` | Lists every scope, in insertion order. |
1589
+ | `remove` | `boolean` | Removes one scope by id, or a batch. True if every supplied id was removed; false otherwise. |
1590
+ | `clear` | `void` | Removes every scope. |
934
1591
 
935
1592
  #### `AgentContextInterface`
936
1593
 
937
- The richer turn context. `apply` changes the active per-turn filter and `build` assembles the provider input. The `system` / `instructions` / `messages` / `tools` / `scope` / `workspaces` / `conversations` readonly data members stay Surface rows.
1594
+ The richer turn context. `apply` changes the active per-turn filter, `select` runs the selection handler for one request, and `build` assembles the provider input. The `system` / `instructions` / `messages` / `tools` / `scope` / `workspaces` / `conversations` readonly data members stay Surface rows.
938
1595
 
939
- | Method | Returns | Summary |
940
- | ------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
941
- | `apply` | `void` | Applies the given scope as the active per-turn filter; passing `undefined` explicitly removes filtering. |
942
- | `build` | `readonly Message[]` | Builds the provider input for the next turn: a leading `system` message folding the prompt, the scope-filtered instructions (each section's header and each item's rendering resolved through the format cascade), and the active workspace's scope-filtered (`scope.files`) text files as fenced reference blocks in a `## Workspace` section, then the active conversation's `view()`, with the active workspace's image files' `base64` payload attached to the last user message. Takes an optional `format` — typically `provider.format`, the provider level of the cascade — and omitting it with no overrides set renders each section on its manager's built-in framing. The `system` message is prepended only when some part of it exists, the workspace render covers the active workspace alone, tools are advertised structurally rather than in the prompt, and the input is built fresh on each call. |
1596
+ | Method | Returns | Summary |
1597
+ | -------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1598
+ | `apply` | `void` | Applies the given scope as the active per-turn filter; passing `undefined` explicitly removes filtering. |
1599
+ | `select` | `Promise<Selection> \| undefined` | Runs the selection handler for one request — the active scope's `select`, else the agent default — and checks that the conversation did not change under it. |
1600
+ | `build` | `readonly Message[]` | Builds the provider input for the next turn: a leading `system` message folding the prompt, the scope-filtered instructions (each section's header and each item's rendering resolved through the format cascade), and the active workspace's scope-filtered (`scope.files`) text files as fenced reference blocks in a `## Workspace` section, then the active conversation's `view()`, or the selection's `messages` when a selection is passed, with the active workspace's image files' `base64` payload attached to the last user message. With no override set, each section renders on its manager's built-in framing. The `system` message is prepended only when some part of it exists, the workspace render covers the active workspace alone, tools are advertised structurally rather than in the prompt, and the input is built fresh on each call. |
943
1601
 
944
1602
  #### `AgentInterface`
945
1603
 
946
- The bounded agent loop. `generate` and `stream` share one private run (`generate` drains the same stream `stream` exposes, so they can't diverge); `abort` cancels the in-flight turn. The `emitter` / `id` / `status` / `context` data members stay Surface rows (`emitter` is a `readonly` accessor — a property, not a method).
1604
+ The bounded agent loop. `generate` and `stream` share one private run (`generate` awaits the settled result of the run `stream` exposes and leaves its live chunks undrained, so they can't diverge); `abort` cancels the in-flight turn. The `emitter` / `id` / `status` / `context` data members stay Surface rows (`emitter` is a `readonly` accessor — a property, not a method).
947
1605
 
948
- | Method | Returns | Summary |
949
- | ---------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
950
- | `generate` | `Promise<AgentResult>` | Runs the turn to completion, discarding the live chunks — drains the shared stream and resolves the settled `AgentResult` (`partial: true` when cancelled). |
951
- | `stream` | `AgentStreamInterface` | Runs the turn as a live stream — iterate `events` for `AgentChunk`s and `await result` for the settled outcome; `result` resolves partial on a cancel and rejects on a genuine error. |
952
- | `abort` | `void` | Cancels the in-flight turn — fires the turn's signal; the `result` settles `partial: true` with whatever content accumulated. |
1606
+ | Method | Returns | Summary |
1607
+ | ---------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1608
+ | `generate` | `Promise<AgentResult>` | Runs the turn to completion — awaits the settled `AgentResult` (`partial: true` when cancelled) and leaves the live chunks undrained. |
1609
+ | `stream` | `AgentStreamInterface` | Runs the turn as a live stream — iterate `events` for values of `AgentChunk` and `await result` for the settled outcome; `result` resolves partial on a cancel and rejects on a genuine error. |
1610
+ | `abort` | `void` | Cancels the in-flight turn — fires the turn's signal; the `result` settles `partial: true` with whatever content accumulated. |
953
1611
 
954
1612
  #### `StreamInterface`
955
1613
 
@@ -966,10 +1624,12 @@ The unbounded async channel. A producer writes with `push` and ends it with `clo
966
1624
  | Method | Returns | Summary |
967
1625
  | ------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
968
1626
  | `push` | `void` | Writes one value — buffered, then handed to a parked consumer; a value pushed at an already-parked reader is delivered, never dropped. |
969
- | `close` | `void` | Ends the channel normally — a draining consumer returns once the buffer is empty. |
970
- | `fail` | `void` | Ends the channel with a failure — a draining consumer throws it once the buffer is empty; the first failure wins. |
1627
+ | `close` | `void` | Ends the channel normally — a draining consumer returns after the buffer empties. |
1628
+ | `fail` | `void` | Ends the channel with a failure — a draining consumer throws it after the buffer empties; the first failure wins. |
971
1629
  | `drain` | `AsyncGenerator<T, void>` | Reads the values back live, in write order — returning on `close` and throwing on `fail`. |
972
1630
 
1631
+ Concurrent `drain` readers share one buffer: each value goes to exactly one reader, and `close` or `fail` ends every reader. A `push` after `close` or `fail` is buffered rather than refused, so a later `drain` yields it before reporting the end.
1632
+
973
1633
  Buffered values are always delivered before the end is reported, so a `close` or `fail` arriving alongside the last values still hands them over first:
974
1634
 
975
1635
  ```ts
@@ -1007,58 +1667,87 @@ The job-rehydration bridge. `provider` / `tool` / `authority` / `scheduler` reso
1007
1667
  | `scheduler` | `SchedulerInterface` | Resolves a registered `SchedulerInterface` by name — throws `unknown scheduler: <name>` when absent. |
1008
1668
  | `build` | `AgentInterface` | Rehydrates a live, seeded `AgentInterface` from a serializable `AgentJobInput` — resolving its names, rebuilding its token budget, seeding its conversation, and wiring `signal`; a name absent from its pool throws. |
1009
1669
 
1670
+ #### `AgentFanout`
1671
+
1672
+ The runner handler `createAgentRunner` binds. One instance serves one runner, because it records every unit whose declared children it spawned, so a retried attempt of a unit spawns none again. It has no data members.
1673
+
1674
+ | Method | Returns | Summary |
1675
+ | --------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1676
+ | `execute` | `Promise<AgentResult>` | Executes one unit: spawns its declared children unless an earlier attempt of the unit already did, then runs the rehydrated agent and settles it under the partial policy. |
1677
+
1678
+ Bind the `execute` method of a fresh instance as the handler of a runner you compose yourself:
1679
+
1680
+ ```ts
1681
+ import type { AgentJobInput, ProviderInterface } from '@orkestrel/agent'
1682
+ import { AgentFanout, createAgentRegistry } from '@orkestrel/agent'
1683
+ import { createRunner } from '@orkestrel/workflow'
1684
+
1685
+ declare const provider: ProviderInterface // any concrete implementation supplied by the host app
1686
+ const fanout = new AgentFanout(createAgentRegistry({ providers: { main: provider } }), false)
1687
+ const runner = createRunner({ handler: fanout.execute.bind(fanout), retries: 1 })
1688
+ const child: AgentJobInput = { provider: 'main', messages: [{ role: 'user', content: 'child' }] }
1689
+ const parent: AgentJobInput = {
1690
+ ...child,
1691
+ messages: [{ role: 'user', content: 'parent' }],
1692
+ children: [child],
1693
+ }
1694
+ const results = await runner.execute([parent]) // the parent's result, then the child's
1695
+ ```
1696
+
1010
1697
  #### `ConversationInterface`
1011
1698
 
1012
- A conversation that owns its live message tail directly (the flat store verbs folded in, like a `Workspace` owns its files). `add` mints each message's `id` and stores it (batch overloads); `message` / `messages` look up the live tail; `remove` / `clear` drop from it. `view` is the model input; `compact` folds the older live messages into a summarized `Section` (regenerating the rollup, emitting `summary` then `compact`); `rehydrate` / `search` read the retained originals; `reference` renders this conversation as a provenance-labeled block to pull into another (a pure string, no model call). The `id` / `emitter` / `summary` / `sections` / `count` data members stay Surface rows (`emitter` is a `readonly` accessor — a property, not a method).
1013
-
1014
- | Method | Returns | Summary |
1015
- | ----------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1016
- | `add` | `Message` / `readonly Message[]` | Appends one `MessageInput` to the live tail, or a batch — mints each message's `id` (a random UUID) and returns the created message or messages; a stored message is immutable. |
1017
- | `message` | `Message \| undefined` | Looks up one live message by id (`undefined` when absent). |
1018
- | `messages` | `readonly Message[]` | Lists every live, uncompacted message in the tail, in insertion order. |
1019
- | `remove` | `boolean` | Removes one live message by id, or a batch, from the tail — `true` only when every supplied id was removed. |
1020
- | `clear` | `void` | Empties the live tail, leaving the compacted `sections` untouched. |
1021
- | `view` | `readonly Message[]` | Builds the model input for the next turn — each section as one synthetic recap message, its summary prefixed with `CONVERSATION_RECAP_PREFIX` so a small model reads it as a recap rather than a literal turn, then the live tail verbatim; the rollup `summary` is not injected. |
1022
- | `compact` | `Promise<Section \| undefined>` | Folds the oldest `count - keep` live messages into a summarized `Section` through the `ConversationSummaryHandler`, removes them from the live tail, regenerates the rollup, and emits `summary` then `compact` — resolving `undefined` when nothing folds (`count <= keep`). Throws a `ConversationError` when no summarizer was supplied. |
1023
- | `rehydrate` | `readonly Message[]` | Returns a section's full original messages — a pure read that emits `rehydrate`, empty for an unknown id and never reinserting. |
1024
- | `search` | `readonly Message[]` | Searches `content` for a case-insensitive substring across every message — each section's retained originals, then the live tail. |
1025
- | `reference` | `string` | Renders this conversation as a self-labeled, fenced provenance block to pull into another conversation — a pure string with no model call: a leading `[Reference — conversation "<label>" — NOT part of this conversation]` marker, the rollup `Summary:` when `summary` is not `false` and a rollup exists, and the cherry-picked excerpts (`- role: content`) when `messages` is supplied. `label` defaults to the `id`. |
1026
- | `snapshot` | `ConversationSnapshot` | Serializes this conversation to a plain, JSON-serializable `ConversationSnapshot` — its `id`, the rollup `summary`, the compacted `sections`, and the live tail; the live `summarize` / `keep` are configuration re-supplied on hydrate rather than serialized. |
1699
+ A conversation that owns its live message tail directly (the flat store verbs folded in, like a `Workspace` owns its files). `add` mints each message's `id` and stores it (batch overloads); `message` / `messages` look up the live tail; `remove` / `clear` drop from it. `view` is the model input; `compact` folds the older live messages into a summarized `Section` and emits `compact`; `rehydrate` / `search` read the retained originals; `reference` renders this conversation as a provenance-labeled block to pull into another (a pure string, no model call). The `id` / `judgments` / `emitter` / `sections` / `summarizable` / `count` data members stay Surface rows (`emitter` is a `readonly` accessor — a property, not a method).
1700
+
1701
+ | Method | Returns | Summary |
1702
+ | ----------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1703
+ | `add` | `Message` / `readonly Message[]` | Appends one `MessageInput` to the live tail, or a batch — mints each message's `id` (a random UUID) and returns the created message or messages; a stored message is immutable. |
1704
+ | `message` | `Message \| undefined` | Looks up one live message by id (`undefined` when absent). |
1705
+ | `messages` | `readonly Message[]` | Lists every live, uncompacted message in the tail, in insertion order. |
1706
+ | `remove` | `boolean` | Removes one live message by id, or a batch, from the tail — `true` only when every supplied id was removed. |
1707
+ | `clear` | `void` | Empties the live tail, leaving the compacted `sections` untouched. |
1708
+ | `view` | `readonly Message[]` | Builds the model input for the next turn — each section as one synthetic recap message, its summary prefixed with `CONVERSATION_RECAP_PREFIX` so a small model reads it as a recap rather than a literal turn, then the live tail verbatim. |
1709
+ | `compact` | `Promise<Section \| undefined>` | Folds whole exchanges from the oldest `count - keep` live messages, cut short at the newest user message, into a summarized `Section` through the `ConversationSummaryHandler`, removes them from the live tail, and emits `compact` — resolving `undefined` when nothing folds. |
1710
+ | `rehydrate` | `readonly Message[] \| undefined` | Returns the full original messages of a known section and emits its `id` through `rehydrate`, including an empty list; returns `undefined` without an event for an unknown `id`. Never reinserts messages. |
1711
+ | `search` | `readonly Message[]` | Searches `content` for a case-insensitive substring across every message — the retained originals of each section, then the live tail. |
1712
+ | `reference` | `string` | Renders this conversation as a self-labeled, fenced provenance block to pull into another conversation — a pure string with no model call: a leading `[Reference — conversation "<label>" — NOT part of this conversation]` marker, then the cherry-picked excerpts (`- role: content`) when `messages` is supplied. The `label` option names the source. Default: the `id`. |
1713
+ | `snapshot` | `ConversationSnapshot` | Serializes this conversation to a plain, JSON-serializable `ConversationSnapshot` — its `id`, the compacted `sections`, and the live tail; the live `summarize` / `keep` are configuration re-supplied on hydrate rather than serialized. |
1714
+
1715
+ A supplied `sections` cap must satisfy `>= 1`; `NaN` throws `ConversationError` with code `SECTIONS` before a fold starts. A successful capped merge retains at most `Math.ceil(sections)` entries. An absent cap, fractional caps `>= 1`, and positive infinity retain their existing behavior. A supplied `keep` must be a nonnegative safe integer; any other value throws `ConversationError` with code `KEEP`, carrying the value as `context.keep`, before a fold starts. `compact()` calls run one at a time. A `remove` or `clear` during the initial summarizer leaves no section. `CompactOptions.signal` rejects a call with its reason at once and commits nothing after the abort; a fold committed before an aborted overflow merge stays.
1027
1716
 
1028
1717
  #### `ConversationManagerInterface`
1029
1718
 
1030
- The id-keyed registry of `Conversation`s with an active pointer. `add(input?)` mints a conversation (flowing the manager's default `summarize` / `keep` in unless the input overrides them) and auto-activates the first one; a later `add` leaves `active` unchanged. `switch(id)` re-points `active` (an unknown `id` returns `undefined`, leaving `active` unchanged — lenient, never throws); `remove` carries batch overloads (the array overload first) and clears `active` when the removed conversation was active. The `count` and `active` data members stay Surface rows (`active` is a `readonly` accessor — a property, not a method); the manager is event-free (each conversation owns its `emitter`).
1719
+ The id-keyed registry of `Conversation` instances with an active pointer. `add(input?)` mints a conversation (flowing the manager's default `summarize` / `keep` in unless the input overrides them) and auto-activates the first one; a later `add` leaves `active` unchanged. `add` forwards the input's `on` hooks and `error` handler to the conversation's emitter, so a throwing listener reports to that handler. `open(id)` returns the conversation registered under `id` while its store read was pending rather than overwriting it, and resolves `undefined` for a stored snapshot whose `id` is not `id`. `switch(id)` re-points `active` (an unknown `id` returns `undefined`, leaving `active` unchanged — lenient, never throws); `remove` carries batch overloads and clears `active` when the removed conversation was active. The `count` and `active` data members stay Surface rows (`active` is a `readonly` accessor — a property, not a method); the manager is event-free (each conversation owns its `emitter`).
1031
1720
 
1032
- | Method | Returns | Summary |
1033
- | --------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1034
- | `conversation` | `ConversationInterface \| undefined` | Looks up one conversation by id (`undefined` when absent). |
1035
- | `conversations` | `readonly ConversationInterface[]` | Lists every conversation, in insertion order. |
1036
- | `add` | `ConversationInterface` | Mints a conversation, taking its `id` from the input or a fresh UUID and flowing the manager's default `summarize` / `keep` in unless the input overrides them — auto-activates the first, and an already-present `id` overwrites, last write wins. |
1037
- | `switch` | `ConversationInterface \| undefined` | Re-points `active` at the conversation with `id` and returns it; an unknown `id` returns `undefined` and leaves `active` unchanged, never throwing. |
1038
- | `open` | `Promise<ConversationInterface \| undefined>` | Resolves a conversation by id and activates it — from the registry when present, else hydrated from the optional `ConversationStoreInterface` (`store`); `undefined` when it is neither registered nor stored. |
1039
- | `save` | `Promise<boolean>` | Persists a registered conversation's `ConversationInterface.snapshot` to the optional `ConversationStoreInterface` (`store`) — `true` when persisted, `false` when there is no store or the id is unknown, and never throwing. |
1040
- | `remove` | `boolean` | Removes one conversation by id, or a batch — `true` only when every supplied id was removed; clears `active` when a removed conversation was the active one. |
1041
- | `clear` | `void` | Removes every conversation and clears `active`. |
1721
+ | Method | Returns | Summary |
1722
+ | --------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1723
+ | `conversation` | `ConversationInterface \| undefined` | Looks up one conversation by id (`undefined` when absent). |
1724
+ | `conversations` | `readonly ConversationInterface[]` | Lists every conversation, in insertion order. |
1725
+ | `add` | `ConversationInterface` | Mints a conversation, taking its `id` from the input or a fresh UUID and flowing the default `summarize` / `keep` of the manager in unless the input overrides them — auto-activates the first, and an already-present `id` overwrites, last write wins. |
1726
+ | `switch` | `ConversationInterface \| undefined` | Re-points `active` at the conversation with `id` and returns it; an unknown `id` returns `undefined` and leaves `active` unchanged, never throwing. |
1727
+ | `open` | `Promise<ConversationInterface \| undefined>` | Resolves a conversation by id and activates it — from the registry when present, else hydrated from the optional `ConversationStoreInterface` (`store`); `undefined` when it is neither registered nor stored. |
1728
+ | `save` | `Promise<boolean>` | Persists a registered conversation's `ConversationInterface.snapshot` to the optional `ConversationStoreInterface` (`store`) — `true` when persisted, `false` when there is no store or the id is unknown; a store failure propagates. |
1729
+ | `remove` | `boolean` | Removes one conversation by id, or a batch — `true` only when every supplied id was removed; clears `active` when a removed conversation was the active one. |
1730
+ | `clear` | `void` | Removes every conversation and clears `active`. |
1042
1731
 
1043
1732
  #### `ConversationStoreInterface`
1044
1733
 
1045
1734
  The persistence contract stores a `ConversationSnapshot` under its own identity.
1046
1735
 
1047
- | Method | Returns | Summary |
1048
- | -------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
1049
- | `get` | `Promise<ConversationSnapshot \| undefined>` | Resolves the persisted snapshot for `id`, or `undefined` if none is stored. |
1050
- | `set` | `Promise<void>` | Inserts or replaces a snapshot under its own `snapshot.id` (no separate id param — mirroring `WorkspaceStoreInterface`'s `set`). |
1051
- | `delete` | `Promise<void>` | Drops a snapshot by id; an absent id is a no-op (no throw). |
1736
+ | Method | Returns | Summary |
1737
+ | -------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
1738
+ | `get` | `Promise<ConversationSnapshot \| undefined>` | Resolves the persisted snapshot for `id`, or `undefined` if none is stored. |
1739
+ | `set` | `Promise<void>` | Inserts or replaces a snapshot under its own `snapshot.id` (no separate id param — mirroring the `set` method of `WorkspaceStoreInterface`). |
1740
+ | `delete` | `Promise<void>` | Drops a snapshot by id; an absent id is a no-op (no throw). |
1052
1741
 
1053
1742
  #### `MemoryConversationStore`
1054
1743
 
1055
1744
  The in-memory implementation keeps an explicit table because its class name has no same-name interface contract.
1056
1745
 
1057
- | Method | Returns | Summary |
1058
- | -------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
1059
- | `get` | `Promise<ConversationSnapshot \| undefined>` | Resolves the persisted snapshot for `id`, or `undefined` if none is stored. |
1060
- | `set` | `Promise<void>` | Inserts or replaces a snapshot under its own `snapshot.id` (no separate id param — mirroring `WorkspaceStoreInterface`'s `set`). |
1061
- | `delete` | `Promise<void>` | Drops a snapshot by id; an absent id is a no-op (no throw). |
1746
+ | Method | Returns | Summary |
1747
+ | -------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
1748
+ | `get` | `Promise<ConversationSnapshot \| undefined>` | Resolves the persisted snapshot for `id`, or `undefined` if none is stored. |
1749
+ | `set` | `Promise<void>` | Inserts or replaces a snapshot under its own `snapshot.id` (no separate id param — mirroring the `set` method of `WorkspaceStoreInterface`). |
1750
+ | `delete` | `Promise<void>` | Drops a snapshot by id; an absent id is a no-op (no throw). |
1062
1751
 
1063
1752
  #### `DatabaseConversationStore`
1064
1753
 
@@ -1070,66 +1759,151 @@ The driver-backed implementation keeps an explicit table because its class name
1070
1759
  | `set` | `Promise<void>` | Inserts or replaces under the snapshot's own `id` (no separate id param) — the row is `{ id, snapshot }`. |
1071
1760
  | `delete` | `Promise<void>` | Drops a snapshot by id; an absent id is a no-op (no throw). |
1072
1761
 
1762
+ #### `LedgerInterface`
1763
+
1764
+ The conversation ledger. The `respond` method serves one request through to its reply, and the `calibrate` method measures the price of a prompt. The `agent`, `conversation`, and `gauge` data members stay Surface rows. A call to either method while the other or itself is in flight rejects with `AgentError` code `CONCURRENCY`.
1765
+
1766
+ | Method | Returns | Summary |
1767
+ | ----------- | ----------------------- | ----------------------------------------------------------------------- |
1768
+ | `respond` | `Promise<LedgerResult>` | Appends `content` as a user message and serves it through to its reply. |
1769
+ | `calibrate` | `Promise<LedgerGauge>` | Measures the gauge, holds it as `gauge`, and returns it. |
1770
+
1771
+ #### `LedgerRequestInterface`
1772
+
1773
+ The state of one `respond` call. The ledger creates one request per call and drops it when the call settles, so no request state reaches a later call or a run no call owns. Each agent run the call makes is a pass: the `open` method starts one, and the `selection`, `boundary`, and `usage` data members read the current pass. The `message`, `plan`, `selection`, `boundary`, `usage`, `calls`, and `readings` data members stay Surface rows.
1774
+
1775
+ | Method | Returns | Summary |
1776
+ | --------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
1777
+ | `enter` | `Message` | Appends the request's content to a conversation as a user message, once, and holds it as `message`. |
1778
+ | `open` | `void` | Opens a pass, which becomes the current pass. |
1779
+ | `adopt` | `void` | Records the selection the current pass's run adopted and adds the usage it spent. |
1780
+ | `observe` | `void` | Starts a gauge call in the current pass, making that pass's call in flight final. |
1781
+ | `spend` | `void` | Adds one call's reported usage to the current pass and records it on the call in flight. |
1782
+ | `think` | `void` | Records the thinking share of the call in flight, read from the first assistant message after the call's position. |
1783
+ | `file` | `void` | Appends the readings of one planning selection and holds its plan when planning succeeded. |
1784
+ | `admit` | `void` | Admits one tool call of the request, compared by tool name and canonical arguments. |
1785
+ | `recall` | `void` | Counts one `recall` call, closing the request's recall tool when its recalls reach the limit. |
1786
+ | `close` | `void` | Closes the request's recall tool, so every later `recall` call throws the closure note. |
1787
+ | `list` | `void` | Holds the stored first line of each lookup a recall result listed, keyed by its listed line. |
1788
+ | `listing` | `ReadonlyMap<string, string> \| undefined` | Returns the stored first lines a recall result listed. |
1789
+
1790
+ The ledger drives each request through these methods, and a harness can drive one directly to check its stops. The following request serves one pass, then refuses a repeated lookup and a recall past its limit:
1791
+
1792
+ ```ts
1793
+ import type { Selection } from '@orkestrel/agent'
1794
+ import { createConversation, LedgerRequest } from '@orkestrel/agent'
1795
+
1796
+ const conversation = createConversation()
1797
+ const request = new LedgerRequest('Does the $148.50 refund need a manager?', 1) // 1 recall call
1798
+ const message = request.enter(conversation) // the appended user message
1799
+ request.enter(conversation) === message // true — a later call appends nothing
1800
+
1801
+ // One pass: its run adopts a selection, then makes one call.
1802
+ request.open(conversation.count)
1803
+ const selection: Selection = {
1804
+ messages: [message],
1805
+ judgments: [],
1806
+ usage: { prompt: 30, completion: 2, total: 32 },
1807
+ }
1808
+ request.adopt(selection, conversation.count)
1809
+ request.observe({ estimate: 40, tools: 2 }, conversation.count)
1810
+ request.spend({ prompt: 52, completion: 8, total: 60 })
1811
+ request.usage // { prompt: 82, completion: 10, total: 92 } — the selection's usage and the call's
1812
+ request.calls // [{ estimate: 40, tools: 2, prompt: 52, completion: 8 }] — the call in flight took its usage
1813
+
1814
+ request.admit('lookup_order', { id: 'BW-5512' })
1815
+ request.admit('lookup_order', { id: 'BW-5512' }) // throws Error(LEDGER_NOTES.repeat)
1816
+ request.recall()
1817
+ request.recall() // throws Error(LEDGER_NOTES.closure) — the 1 recall call is spent
1818
+
1819
+ const listed = 'lookup_order {"id":"BW-5512"}: Refund due $148.50.'
1820
+ request.list(listed, new Map([[listed, 'Order BW-5512 for account BW-20931: Brightwater Studio.']]))
1821
+ request.listing(listed)?.get(listed) // 'Order BW-5512 for account BW-20931: Brightwater Studio.'
1822
+ request.listing('Refunds over $100 need a manager.') // undefined — no recall returned that text
1823
+ ```
1824
+
1825
+ #### `ClassifierInterface`
1826
+
1827
+ The ledger's filing engine. The `classify` method asks the judge what the conversation's recorded judgments lack, and the readers derive every answer from those judgments and the cutoffs. It carries no data members.
1828
+
1829
+ | Method | Returns | Summary |
1830
+ | ---------------- | ----------------------------- | ------------------------------------------------------------------------------------------------ |
1831
+ | `classify` | `Promise<ClassifierResult>` | Asks every filing question the conversation's judgments lack an answer for. |
1832
+ | `category` | `LedgerCategory \| undefined` | Returns the category a message is filed under, or undefined when no category reaches its cutoff. |
1833
+ | `quiet` | `boolean` | Returns true if the message is filed as quiet; false otherwise. |
1834
+ | `decisive` | `boolean` | Returns true if the message is filed as decisive; false otherwise. |
1835
+ | `topics` | `ReadonlySet<string>` | Returns the desk topics the message is filed under. |
1836
+ | `classification` | `LedgerClassification` | Returns the filing of every message in the conversation. |
1837
+
1838
+ #### `GaugeInterface`
1839
+
1840
+ The prompt price. The `rate`, `remainder`, `reserve`, and `room` methods read the calls of the request in progress, and the `observe` method folds a finished request in. The `scale` and `overhead` data members stay Surface rows. The `scale` member holds the price from the last observation; the `overhead` member holds the construction price.
1841
+
1842
+ | Method | Returns | Summary |
1843
+ | ----------- | -------- | ---------------------------------------------------------------------------------------------------- |
1844
+ | `rate` | `number` | Returns the tokens one more estimate unit adds within a request, fitted over the observed calls. |
1845
+ | `remainder` | `number` | Returns the tokens of the capacity the last call left. |
1846
+ | `reserve` | `number` | Returns the tokens a reply turn needs after the calls, given the longest reply text written so far. |
1847
+ | `room` | `number` | Returns the estimate units a recall result can take without taking the reply's room. |
1848
+ | `observe` | `void` | Rescales from a completed request's first call and keeps its calls and the final reply's completion. |
1849
+
1073
1850
  ## Contract
1074
1851
 
1075
1852
  These invariants hold across `src/core` ↔ `agent.md`:
1076
1853
 
1077
- 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.
1078
- 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 no provider in `src/core` speaks a vendor's wire. 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.
1079
- 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).
1080
- 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.
1081
- 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.
1082
- 6. **A local cancel and a remotely reported one are different failures.** A `stream` cancelled mid-flight throws a `ProviderAbortError` whose `partial` is the `ProviderResult` assembled from whatever streamed so far (content + any tool calls + any usage); `isProviderAbortError` narrows a caught value so the loop can recover the partial content. A non-abort error propagates unchanged. `ProviderAbortError` is the boundary's error — it stays in this module so the agent loop catches it regardless of which backend is in use. The origins stay distinguishable. A **local** cancel is the combined bound firing: the engine assembles the partial from what it decoded locally, and when a throw raced that cancel it rides along as the error's `cause` rather than replacing it. A **remote** abort is a report, not a cancel: a relay `abort` frame makes `RelayProvider.read` throw a reconstructed `ProviderAbortError` carrying the upstream's own partial while the local signal stays unaborted, so the engine propagates that instance unchanged instead of folding it into a local partial, and the `Agent` runtime treats it as a genuine error unless its own bound signal aborted. A caller that cancels locally therefore always outranks the wire: the local partial is what it gets.
1083
- 7. **The message-store contract (`MessageManagerInterface`).** `MessageManagerInterface` is the immutable message-store contract `AgentContextInterface.messages` is typed to — `count` + `add` / `message` / `messages` / `remove` / `clear`. It has no concrete class in this module: the active `Conversation` (which owns its live tail directly) satisfies it structurally, so `context.messages` is the active conversation (the conversation-layer clause). `add` takes one `MessageInput` or a batch and mints each message's `id` (`crypto.randomUUID()`), carrying the input's `role` / `content` and `calls` only when supplied (an absent `calls` is omitted, never present-but-undefined) — returning the created message(s). A stored message is immutable: assembled once from its input and never mutated, and the object `add` returns is the same one `message(id)` later resolves. `count` is the live tail size, `message(id)` looks one up (`undefined` when absent), `messages()` lists them in insertion order, `remove` (one or a batch) returns `true` only when every supplied id was removed, and `clear` empties it.
1084
- 8. **The richer turn context (`AgentContext`).** `AgentContext` composes the optional `system` prompt; the agent-owned `instructions` / `conversations` managers; a `WorkspaceManagerInterface` consumed from `@orkestrel/workspace`; `messages` (the active conversation's live tail); a `ToolManagerInterface` consumed from `@orkestrel/tool` solely for provider advertising and call dispatch; and a readonly active `scope`. Each omitted manager is created fresh, and the context ensures the conversation registry has an active default, so `messages` is always defined. `build()` folds the system prompt, scope-filtered instructions, and the active workspace's scope-filtered text files (the active-workspace clause) into one leading `system` message, then appends the active conversation's `view()`. The tool registry is the one member `build()` never reads: it is loop machinery for advertising and dispatch, not a prompt-context manager, so no tool renders into the prompt. The result is computed fresh on every call and never mutates a manager or stored message.
1085
- 9. **Scope filtering + image attachment.** A `Scope` is a named allow-list filter with one list per category (`instructions` by `name`, `tools` by `name`, `files` by the active workspace file's `path`), each three-way through `filterAllowList`: `undefined` ⇒ all pass, `[]` ⇒ none, a non-empty list ⇒ only-listed. `build()` applies the active `scope` to the instruction + workspace-file categories before rendering them (`scope.files` filters the active workspace's `files()` before the carrier split — the active-workspace clause). Conversation messages are deliberately not a scope category: the active conversation owns message inclusion through compaction (the active-conversation clause), so its `view()` is authoritative and a second, competing message filter would only let them disagree. `narrow(config)` composes a tighter child by set-intersection over every category's list (an `undefined` side imposes no constraint — `undefined ∩ list = list`, `undefined ∩ undefined = undefined`), so narrowing only tightens. The active workspace's scoped-in image files' `base64` payload is attached to the last user message (rebuilt as a copy carrying the merged `images`, never mutating the stored message; skipped when no user message exists — the active workspace is the sole image source); `build()` still returns `readonly Message[]`. **Tools stay structural, never in the prompt.** The loop hands the model its tools through `tools.definitions()` (the `tools` argument to `provider.generate` / `.stream`), not by serializing them — and it filters those definitions by the active `scope.tools` first (through `filterAllowList`), so a scoped-out tool is neither advertised nor callable (the model never sees it). `AgentContext.build()`'s output therefore never contains a tool's `name`, `description`, `parameters`, or definition, scoped or not. A `ScopeManager` (`createScopeManager`) is the optional reuse registry of named scopes, keyed by a minted `id` (two scopes may share a `name`), observable like the other managers.
1086
- 10. **The agent loop (`Agent` / `createAgent`).** `Agent` composes a `ProviderInterface`, an `AgentContext`, and its `@orkestrel/tool` registry into the bounded context → provider → tools → repeat turn. It builds the provider input, then iterates up to `limit`: stream a provider turn, accumulate content/thinking/usage, append any assistant tool calls, and execute them. Each discriminated `ToolResult` becomes a tool message. On success, a string value becomes its content unchanged; any other value becomes `JSON.stringify(result.value)`. On failure, the content is `result.error` unchanged. A final assistant turn stops the loop.
1087
- 11. **One `#run` shared by `generate` + `stream`.** A single private async generator drives the whole turn. `stream()` exposes it as an `AgentStreamInterface` (`events` + `result` + `abort`); `generate()` drains that same stream (iterating `events`, discarding chunks) and returns its `result` — it has zero loop logic of its own, so `generate` and `stream` can never diverge (a `generate()` result deep-equals draining `stream()` on the same input).
1088
- 12. **The `AgentChunk` stream.** `stream().events` yields, per turn, each content delta as `{ category: 'token', content }`, each live reasoning delta as `{ category: 'think', content }`, then optional usage, then one `{ category: 'tool', call, result }` per dispatched call. The call and discriminated result use the contracts imported from `@orkestrel/tool`. `result` resolves the settled `AgentResult`: final or partial content, joined thinking, summed optional usage, and `partial`.
1089
- 13. **Bounded, paced, capped.** Each run arms one cancel through `createAbort({ signal: AbortSignal.any([…]) })` folding whichever of the external `signal`, the `timeout` deadline (a started `Timeout`), and the `budget` signal (a started `Budget`) are present; `agent.abort(reason)` / `stream.abort(reason)` fires it, and the timeout is always cleared in a `finally`. A cancel — external, deadline, budget, or `abort()` — stops the loop and the `result` promise resolves `{ partial: true, content: <accumulated> }`; it is not an error. The accumulated `content` already holds whatever streamed before the cancel — the loop accumulates each delta as it yields the `token` chunk, and a `ProviderAbortError.partial.content` is exactly those same yielded deltas (the `stream` contract), so the loop never re-adds it. A genuine provider / tool error (the signal is not aborted) rejects the `result` (and `status` → `error`). The optional `scheduler.yield({ signal })`s between turns (before turns 2…N, never after the last); tool iteration is capped at `limit` so the loop always terminates. `status` transitions `idle` → `running` → `done` / `error`.
1090
- 14. **Pull and push observation surfaces on the `Agent`; the rest event-free.** The provider contract, the tool registry, the conversation store, and the `AgentContext` itself carry no Emitter, no `EventMap`, no `on` hook — they stay purely functional (of the managers the context composes, the `InstructionManager` and `ScopeManager` carry their own `emitter`s and each `Conversation` owns one, while the `ToolManager`, `WorkspaceManager`, and `ConversationManager` are event-free — as is the context that composes them). The `Agent` itself carries both a pull and a push surface. Pull: the `AgentChunk` stream (`stream().events`) yields per-token answer deltas, per-think reasoning deltas, usage chunks, and tool chunks for a live consumer. Push: the `emitter` (`AgentEventMap`) — `start` (run begins), `turn` (each iteration), `tool` (a dispatched call + result), `usage` (a turn's usage), `deny` (an authority denial — not in the chunk stream), `finish` (the settled result), `error` (a genuine failure), `abort` (a cancel), `exhaust` (a limit exhausted with unresolved tool intent — fires instead of `abort`), and `fault` (a non-fatal automatic-compaction summarizer throw — the run continues; the automatic-compaction clause) — wired through the emitter pattern (`AgentOptions.on` hooks, the `AgentOptions.error` listener-error handler, a `readonly emitter`, `new Emitter({ on: options?.on, error: options?.error })`). Per-token / per-thinking deltas are the stream's job exclusively — there is deliberately no `token` or `think` event. The emitter isolates a listener throw (it can never escape into the settle-once / wake-park loop) and routes it to its own `error` handler (the `error` option, surfaced as `(error, event)`, not a domain event; itself guarded against re-entrancy), so observation is provably side-effect-free on the 3×-hardened loop — and every emit sits after the relevant state transition / settle, so it cannot reorder control flow. A cancelled run emits `abort` (the cancel reason) then `finish` (the settled partial); a genuine error emits `error` instead of `finish`. The loop's deterministic logic is pinned in the `src:core` mirror with a scripted `ProviderInterface`.
1091
- 15. **Doc ↔ source method bijection.** The `## Methods` tables list exactly the public methods of every agent-owned interface named there, exhaustive in both directions, and each agent-owned concrete class exposes the same public methods as its interface. `MessageManagerInterface` is implemented structurally by the active `Conversation`; `ProviderInterface` is implemented by the host. Dependency method surfaces belong to [`tool.md`](tool.md) and [`workspace.md`](workspace.md).
1092
- 16. **The authority gate (`Authority` / `createAuthority`).** An optional `Authority` is the synchronous policy gate consulted before each tool call runs — passed through `AgentOptions.authority`. `evaluate({ call })` walks its ordered `rules` first-match-wins: the first rule whose `match(context)` is true decides as `{ zone: rule.zone, allowed: rule.allowed ?? true, reason: rule.reason }` (a matched rule allows unless its `allowed` is explicitly `false`); when none match it returns the `fallback`, which defaults to `{ zone: DEFAULT_AUTHORITY_ZONE, allowed: true }` (allow-unmatched — a rules list of denials acts as a denylist; pass an `allowed: false` `fallback` to flip the gate to deny-by-default, an allowlist). The matcher receives the `AuthorityContext` (`{ call }`), so a rule can branch on the call's `name` and its `arguments`. Synchronous — `evaluate` returns the verdict directly. Event-free.
1093
- 17. **A denied call is fed back, never executed (the gate's effect on the loop).** With no `authority` set, the loop sends calls straight through the tool registry. With one set, allowed calls run as a batch and each denied call becomes the `@orkestrel/tool` failure union arm `{ success: false, id, name, error }` without executing the tool. Executed results and denials merge back into original call order, so every denial still produces a tool chunk and unquoted failure-text tool message the model can react to. The gate lives in the `Agent`, not the dependency registry.
1094
- 18. **Durable, serializable agent jobs (`AgentJobInput` + `AgentRegistry`).** An `AgentJobInput` is a JSON-serializable descriptor — not a live agent: its non-serializable pieces (the `provider`, `tools`, `authority`, `scheduler`) are referenced by name and its data (`messages`, `system`, `limit`, `timeout`, a token `budget` ceiling, nested `children`) carries directly. An `AgentRegistry` (`createAgentRegistry`) holds the named pools (`providers` required; `tools` / `authorities` / `schedulers` optional) and `build(input, signal?)` rehydrates a live, seeded `Agent`: it resolves the `provider`, assembles a fresh `ToolManager` from the `tools` names, rebuilds the token `budget` from its ceiling (`createTokenBudget({ max })`), resolves the `authority` / `scheduler` names, constructs the agent with `system` / `limit` / `timeout` and the threaded `signal`, and seeds its context with the `messages`. Because the descriptor is serializable, a job survives a crash through a Queue's `store` + `restore()` — the registry rehydrates the live pieces from the names on the way back in. An unknown name throws an `AgentError` carrying `code: 'REGISTRY'` and the message `unknown <category>: <name>`, never a silent `undefined`.
1095
- 19. **Partial = configurable failure (`partial`).** An `Agent.generate()` resolves `partial: true` on a cancel (abort / budget / timeout) — a cancel is not an error. For a durable job that is, by default, a failure: the `createAgentQueue` / `createAgentRunner` handler throws an `AgentJobError` (carrying the partial `AgentResult`), so the Queue's retries re-run the job and a Runner's fail-fast aborts its siblings. Pass `partial: true` to treat a partial as success instead — the handler resolves the partial result rather than throwing. `isAgentJobError` narrows a caught value to recover its `partial`.
1096
- 20. **Bounded concurrency + retries + persistence by composing the substrate (no new engine).** `createAgentQueue` returns a `QueueInterface<AgentJobInput, AgentResult>` built by `createQueue`: its handler `(input, context) => registry.build(input, context.signal).generate()` + the partial policy is the only new logic — bounded `concurrency`, `retries`, the per-attempt `timeout`, and the durable `store` (+ `restore()`) all belong to the `@orkestrel/queue` `Queue`. `createAgentRunner` returns a `RunnerInterface<AgentJobInput, AgentResult>` built by `@orkestrel/workflow`'s `createRunner` (one-shot, ordered, fail-fast). No second concurrency / orchestration engine is written; the verified `Agent` loop + `Authority` gate are untouched. Layering is `agent → (queue, workflow)` with no cycle.
1097
- 21. **Sub-agent fan-out through `controller.spawn`; cancellation threaded.** On a `createAgentRunner`, each unit's handler receives a `ControllerInterface`; before running the (parent) job it `controller.spawn`s each of the job's declared `children` (fire-and-track, never inline-awaited — a slot-holding bounded handler awaiting its own spawn can deadlock), so each child is a real sub-agent run through the same bounded queue whose result joins the run after the declared jobs (in spawn order). `createAgentQueue` ignores `children` (a queue has no fan-out). Cancellation threads through in both: the handler passes `context.signal` (queue) / `controller.signal` (runner) into `registry.build`, so a queue / runner `abort()` or a per-attempt timeout fires the rehydrated agent's signal (which commits a partial — the `partial` policy then decides). All event-free.
1098
- 22. **The conversation layer (`Conversation` + `ConversationManager`).** A `Conversation` groups messages above the flat `MessageManager`: `messages` is the live uncompacted tail (a real `MessageManagerInterface` a caller appends turns to), `sections` are the compacted history (oldest → newest), and `summary` is the rollup (`undefined` until the first compaction). Compaction folds older messages into summarized `Section`s through a provider-agnostic `ConversationSummaryHandler` seam — `compact(options?)` determines `keep` (`options.keep ?? the conversation's keep ?? DEFAULT_CONVERSATION_KEEP` = `0`), folds the oldest `count - keep` live messages (a no-op resolving `undefined` when `count <= keep`), summarizes that slice into a section (`id` minted, `summary` the seam's output, `messages` the retained originals), removes those messages from the live tail by id, regenerates the rollup (a second seam call over all section summaries), and emits `summary` then `compact` — a summarizer call for the folded slice and another for the rollup. `compact()` throws a `ConversationError` (`code: 'SUMMARIZER'`) when no summarizer was supplied (a conversation can still store + `view()` without one). `summarizable` is `true` exactly when a summarizer was supplied — the clean signal the agent loop's automatic compaction gates on (the automatic-compaction clause), so a non-summarizable conversation is never auto-compacted and the auto path never throws this error; a manual `compact()` still throws. `view()` is the model input — each section as one synthetic recap message (role `'assistant'`, keyed by the section's stable `id`), then the live messages verbatim (the rollup `summary` is not injected). Each recap's content is the section summary prefixed with `CONVERSATION_RECAP_PREFIX` (`'[Summary of earlier messages] '`) so a small model reads it as a condensed recap of earlier turns, not a literal assistant turn it must echo or treat as the live answer — a deliberately lean label (a fixed handful of tokens; the rollup regeneration in `compact()` re-reads the UNframed section summaries, the label being a `view()`-only presentation concern). `rehydrate(id)` returns a section's full original messages (`[]` for an unknown id) and emits `rehydrate` — a pure read (the caller decides whether to re-add them; `rehydrate` never reinserts). `search(query)` is a case-insensitive substring scan of `content` across all messages (every section's originals, then the live tail). `reference(options?)` renders this conversation as a self-labeled, fenced cross-conversation provenance block — a pure string (no model call) to pull into another conversation by writing it to the active context's active workspace: a leading `[Reference — conversation "<label>" — NOT part of this conversation]` marker (`label` defaults to the `id`), the rollup `Summary:` line when `options.summary !== false` and a rollup exists, and the cherry-picked `Relevant messages:` excerpts (each `- role: content`) when `options.messages` is supplied (default none). It frames foreign content so a small model attributes it to its source rather than reading it as part of the live thread; the cherry-pick comes from this conversation's own `search` / `rehydrate`, never its whole history. Observable: the owned `emitter` (`ConversationEventMap` — `compact` / `summary` / `rehydrate`) isolates a listener throw, routing it to its `error` handler (the `error` option), never corrupting a compaction. `ConversationManager` (`createConversationManager`) is the id-keyed registry with an active pointer (the `active` / `switch` seam the `AgentContext` renders): `add(input?)` mints a conversation flowing the manager's default `summarize` / `keep` in unless the input overrides them (an already-present `id` overwrites) and auto-activates the first one (a later `add` leaves `active`); `switch(id)` re-points `active` (an unknown `id` is a lenient `undefined`, leaving `active` unchanged); `conversation(id)` / `conversations()` look up; `remove` (one or a batch) reports `true` only when every supplied id was removed and clears `active` when the removed one was active; `clear` empties it and clears `active`; `count`. It carries the durable `open(id)` / `save(id)` seam over an optional `ConversationManagerOptions.store` (the durable-store clause) and `Conversation.snapshot()` serializes a conversation to a JSON `ConversationSnapshot`. It is event-free (each conversation owns its `emitter`). `estimateTokens(text)` is the deterministic `ceil(length / 4)` char heuristic (it never calls a provider) that `estimateMessages(messages)` sums over a message batch — the default `consumer` estimator for an agent's context `window` budget (the automatic-compaction clause), not a conversation member. The layer's deterministic logic is pinned in the `src:core` mirror with a data-stub summarizer.
1099
- 23. **`AgentContext` folds the active conversation's view; the message source is the readonly `conversations` registry.** `messages` is the `conversations` registry's active conversation — always defined: at construction the context ensures the registry has an active conversation, `add`ing a default one when it has none. The dynamic `context.messages` getter returns that active conversation itself (the same reference it exposes — no duplication; computed on every read, so it follows `conversations.switch(id)`, with no captured copy), and `build()` folds that conversation's `view()` (the per-section summaries + the live tail) as the authoritative message inclusion — the conversation owns inclusion through compaction, so there is no competing scope category for messages; scope still filters instructions / tools / workspace files, and the active workspace's scoped-in image-data attachment to the last user message still applies to the view output. With the default (uncompacted) conversation, the message path is exactly the lean `[systemMessage?, ...messages]`. The registry is structural: supply it through `AgentContextOptions.conversations` and change its active conversation through `manager.switch(id)`. **Multi-conversation.** Because `context.messages` / `context.conversations` are read dynamically and the `Agent` reads them fresh on each run, one agent switches its active conversation between runs to serve many conversations from its `conversations` registry (the real app pattern — `manager.switch(id)` per request, creating through `manager.add({ id })` when absent, not an agent per thread): each conversation accumulates its own history and (with `window`, the automatic-compaction clause) compacts independently, one thread's sections never leaking into another. Switch between runs, never during one (the loop drives the run-entry active conversation to completion); for concurrent threads use a separate `Agent` per thread — the framework ships the switch mechanism, the app owns concurrency policy. The `Agent` forwards its `AgentOptions.conversations` straight into this context as the message source; the auto-compaction trigger over the active conversation lives in the loop (the automatic-compaction clause).
1100
- 24. **Automatic compaction — the context `window` budget (opt-in, additive, production-hardened).** `AgentOptions.window` is a context [`Budget`](budget.md) (`BudgetInterface<readonly Message[]>`) for automatic conversation compaction, enabled only when both a `window` budget is set and the active conversation is `summarizable` (it has a summarizer — the conversation-layer clause). There is always an active conversation (the active-conversation clause), but the default one has no summarizer, so this `summarizable` gate preserves the shipped behavior: a non-summarizable conversation is never auto-compacted, and the auto path never throws the `compact()` `SUMMARIZER` error. Its `consumer` is a pluggable token estimator (for example the exported `estimateMessages`) and its `max` is the context window — the same consume-to-a-ceiling primitive as the cost `budget` (the bounded-paced-capped clause), but its ceiling action is compact instead of abort. The loop's private `#trim` runs the check at these points: (1) before the first provider request (so a resumed / already-long conversation whose initial prompt already exceeds the window compacts at once, not only after a tool turn), and (2) between turns on the tool-iteration `continue` path (after the prior turn's `usage` was folded and its assistant + tool messages were appended, before the next provider request; never after the final assistant turn that ends the loop). The `window` budget is reset (`clear()`) at run entry, so no stale `consumed` carries across runs or a conversation switch. Each check measures the absolute current prompt: it `clear()`s the budget then `consume`s the working message array — the exact next prompt (the system block + the active conversation's `view()` + this turn's appended messages, that is, what the next `provider.stream` will receive) — so `window.consumed` is the current full prompt's estimated footprint and `window.exhausted` means that prompt has reached the context window `max`. When the prompt `exhausted`s the window, `#trim` `await`s `conversation.compact()` (folding the older live tail into a summarized `Section` through the conversation's own `ConversationSummaryHandler`), then rebuilds the working message array from `context.build(provider.format)` — the same projection the loop opened with — so the run continues on the (now smaller) compacted context. No post-compact `clear()` is needed: the next check's `clear()` + `consume` re-measures the now-shrunken prompt. This is distinct from the hard `budget` ceiling (the bounded-paced-capped clause), which aborts the run with a partial. **Production hardening:** (a) non-fatal summarizer failure — the automatic `compact()` is wrapped in try/catch: a thrown summarizer error does not crash the run; the loop skips compaction that turn, surfaces the error as a `fault` event (so it is observable, never silently lost), and continues (the over-window prompt proceeds to the provider). Only the auto path is resilient — a manual `conversation.compact()` still propagates its error. (b) futile-compaction guard (the single-level limit) — when `compact()` resolves `undefined` (nothing left to fold) while the prompt is still over the window (the section summaries alone exceed it), a per-run flag latches so auto-compaction stops for the rest of that run (no per-turn churn); the over-window prompt then proceeds to the provider, which surfaces a genuine context-length error if it truly cannot fit (the real limit) — the loop does not loop futilely. The whole path is opt-in: with no `window` budget, or a non-summarizable active conversation, `#trim` (the run-entry reset, the pre-first-turn check, the between-turns check) is skipped entirely, adding no `await` before the first provider request, so a cost-budget-only agent's eager-pump and abort timing are untouched. Observability is the conversation's own `compact` / `summary` events (the conversation-layer clause) plus the agent's `fault` (the `strict` clause). These limits are deliberate: the automatic summarizer call is the conversation's configured one and is not separately bound to the run's abort signal; and compaction is single-level, so a conversation whose section summaries alone exceed `window` cannot shrink further — the futile guard stops the churn rather than pretending otherwise. The deterministic behavior (the absolute prompt crossing `max`, the fold, the rebuilt-smaller prompt, the pre-first-turn fold, the non-fatal `fault` path, the futile guard, no-fire below the ceiling) is pinned in the `src:core` mirror with a scripted provider, a data-stub (and a throwing) summarizer, and the real `estimateMessages` estimator, including a run forced through two or more folds that stays coherent.
1101
-
1102
- 25. **`context.workspaces` — active workspace rendering by carrier.** `AgentContextInterface.workspaces` is the readonly `WorkspaceManagerInterface` supplied from `@orkestrel/workspace`, or a fresh dependency manager when omitted. `build()` reads only its active workspace, fresh each call, and filters that workspace's files through `scope.files`. The dependency's `isText` narrows text files, which render under `WORKSPACE_SECTION_HEADER` through the agent-owned `renderFencedFile` helper; the image carrier is `isBinary(file.content) && file.content.mime.startsWith('image/')`, and matching base64 data attaches to the last user message. With no active workspace, nothing renders. Both halves of that split are this package's own decision and belong here: `@orkestrel/workspace` holds files without knowing what a prompt is, and only the assembly layer knows that a model reads text as quoted material and images off a user turn. What agent does not own is the workspace domain itself — creation, editing, events, snapshots, and stores all stay in the originating package.
1103
-
1104
- 26. **The durable `ConversationStore` + the manager's `open` / `save` seam.** A `ConversationSnapshot` is the plain JSON payload `{ id, summary?, sections, messages }`; live summarizer/configuration functions are re-supplied when hydrating. `Conversation.snapshot()` creates it, and `ConversationInput.snapshot` restores identity, summary, sections, and live tail without emitting edit events. `isConversationSnapshot` is the total read-boundary guard for unknown storage values. `ConversationStoreInterface` persists that one payload through `get(id)`, `set(snapshot)`, and `delete(id)`, with no TTL. `MemoryConversationStore` keeps snapshots in a process-lifetime map; `DatabaseConversationStore` stores each snapshot in one opaque JSON column and narrows it on read. `ConversationManager.open(id)` activates a registry hit or hydrates a store hit; `save(id)` persists a registered snapshot and returns `false` when no store or conversation exists. The real stores, driver, guard, snapshot hydration, and manager semantics are pinned in the core tests.
1105
-
1106
- 27. **Limit exhaustion, mid-stream budget metering, per-run bounds, and per-run `schema`.** `RunOutcome.exhausted` (and the settled outcome's `partial`) flips `true` when the turn loop exhausts the effective `limit` while the most recently completed turn still held unresolved tool intent (the model requested tools on the very last allowed turn) — a cause distinct from a cancel: it fires the `exhaust` event (carrying the effective `limit`) instead of `abort`, still followed by `finish` carrying the partial result. A natural final answer on the last allowed turn, or `limit: 0` (which never enters the loop), stays `partial: false` with no `exhaust`. **Mid-stream budget charging.** During each provider turn, `#provide`'s `onDelta` re-estimates the turn's accumulated content through `estimateTokens` (`ceil(length / 4)`) and `budget.consume`s only the increment over what was already charged this turn (`{ prompt: 0, completion: increment, total: increment }`) — so the budget trip can land mid-stream, before the turn's final `usage` is known; the tripped signal folds into the run's bound abort exactly like any other cancel (the run resolves `partial: true` with an `abort` event, the provider genuinely cancelled). Once the turn's `usage` is known, a residual reconcile charges the remainder (`{ prompt: usage.prompt, completion: max(0, usage.completion - charged), total: max(0, usage.total - charged) }`), so the turn's total budget draw nets to exactly the authoritative usage — never double-charged, never lost. The reported `AgentResult.usage` / `usage` chunks are always the full authoritative usage, unaffected by how the budget was charged. **Per-run overrides (`AgentRunOptions`).** `limit` / `timeout` / `budget` / `signal` each override their `AgentOptions` construction default for this run only (`??` semantics — an omitted key keeps the constructed default); a per-run `signal` composes with (never replaces) a constructed `signal` through `AbortSignal.any` — either aborting cancels the run; a per-run `budget` is `start()`ed for that run and is the one the loop charges, leaving a constructed `budget` untouched for that run. **Per-run `schema`.** `AgentRunOptions.schema` (and `ProviderStreamOptions.schema`) mirrors `think` — a per-run structured-output constraint forwarded to `provider.stream`. `#provide` composes `think` and `schema` into one options object, omitting whichever key is `undefined`, and passes no options object at all when both are absent.
1107
-
1108
- 28. **`AgentOptions.instructions` / `.workspaces` / `.scope` — construction-time context wiring.** These mirror the identically-named `AgentContextOptions` fields (the richer-turn-context clause) and forward straight into the `AgentContext` the constructor builds: `instructions` a pre-built `InstructionManagerInterface` (an empty one created when omitted), `workspaces` a pre-built `WorkspaceManagerInterface` (a fresh empty one when omitted), and `scope` the initial active `ScopeInterface` (`undefined` ⇒ no filtering). They are construction sugar: the same result is reachable by building an `AgentContext` first and passing it in, and these fields spare that indirection when a caller only needs `createAgent`.
1109
- 29. **`strict` — automatic-compaction failure escalation.** `AgentOptions.strict` (default `false`) governs what happens when the automatic compaction path's `conversation.compact()` throws (the automatic-compaction clause): lenient (the default) surfaces the caught error as the `fault` event and continues over-window; `strict: true` still fires `fault` first (the failure stays observable either way), then rethrows the caught error so it propagates out of `#trim` through `#run`, rejecting the run's `result` with a genuine `error` settle (`status` → `error`) instead of a `partial: true` resolve. A manual `conversation.compact()` is unaffected by `strict` — it always propagates its own error regardless.
1110
- 30. **Bounded `sections` — cap the compacted history, `collapse` on overflow.** `ConversationOptions.sections` / `ConversationManagerOptions.sections` (a manager default, overridden per-`add` by `ConversationInput.sections`) / `CompactOptions.sections` (a per-compaction override) each set a cap (`>= 1`) on `Conversation.sections`'s length; omitted at every level ⇒ unlimited. A sub-1 cap — at construction (`ConversationOptions.sections`) or at a `compact()` call (the effective `options.sections ?? the conversation's own cap`) — throws a `ConversationError` with `code: 'SECTIONS'`. When a `compact()` fold pushes a new section past the effective cap, the oldest overflow sections are immediately folded into one merged section (a third summarizer call over the folded sections' summaries) so `sections.length` never exceeds the cap afterward, and a `collapse` event fires carrying the merged `Section`. This is orthogonal to `keep` (which bounds the live tail folded per compaction) — `sections` bounds the compacted history's length instead.
1111
- 31. **`sanitizeUsage` — normalizing a provider's abort-time partial usage.** A `ProviderAbortError.partial.usage` a provider surfaces mid-abort can be malformed (non-finite, negative, or fractional fields) since it was assembled from a cut-off stream rather than a clean settle. `sanitizeToken(value)` is the shared per-field primitive: it floors non-finite or non-positive values to `0` and positive fractional values to their integer part. `sanitizeUsage(usage)` applies it to `prompt` / `completion` / `total`, so a caller charging a token `Budget` never consumes a negative or fractional amount. The `Agent` loop applies it automatically to an abort's partial usage before folding it into the run's accounted usage / budget charge; both helpers are also exported standalone.
1112
- 32. **`AgentError` — the synchronous shared-accounting concurrency guard.** `Agent.stream()` throws `AgentError('CONCURRENCY', …)` synchronously — before any state mutation or emit — when a run is already in flight on the same agent (`this.#runs.size > 0`) and the agent carries shared per-agent accounting for the new run: either a construction-level `window` (a shared context budget, always shared since it has no per-run override) or a construction-level `budget` with no per-run `AgentRunOptions.budget` override (a shared cost budget). A concurrent run that supplies its own per-run `budget` override (with no `window` set) is still allowed — it charges a separate instance. `isAgentError` narrows a caught value; branch on `error.code` — `'CONCURRENCY'` here, `'REGISTRY'` for an `AgentRegistry` accessor whose name is absent from its pool. A sequential/awaited caller is never affected — this guards only genuinely concurrent `stream()` calls on one agent.
1113
-
1114
- 33. **`agentResultToJSON` — the canonical portable AgentResult projection.** The helper accepts `unknown` and never throws, including for throwing getters, hostile nested usage, and revoked proxies. It captures `content`, `thinking`, `usage`, and `partial` exactly once through Contract's sanctioned `attempt` boundary, so conforming accessors and inherited structural properties are supported without a second read. `content` must be a string and `partial` a boolean; absent/`undefined` `thinking` and `usage` are omitted, while present `thinking` must be a string and present `usage` an object whose `prompt` / `completion` / `total` fields are finite numbers. Finite negative and fractional counts are preserved rather than normalized through `sanitizeUsage`, because the authoritative `TokenUsage` contract is numeric. Extra input fields are dropped. The helper rebuilds a fresh exact plain `{ content, thinking?, usage?: { prompt, completion, total }, partial }` object, deep-gates it through Contract's `parseJSONValue`, and returns Contract's imported `JSONValue`; invalid input returns `undefined`.
1115
-
1116
- 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.
1117
-
1118
- 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.
1119
-
1120
- 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`).
1121
-
1122
- 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).
1123
-
1124
- 38. **The judge boundary (`JudgeInterface`).** `JudgeInterface` is the sibling of `ProviderInterface` and never a provider. It carries an `id` (a per-instance trace label), a `name` (the wire), and the configured `model`, and `ask(request, signal)` answers every question of a `JudgeRequest` about its one `state`. Questions are keyed by the caller's ids, which the model never sees, and each question is evaluated on its own. A `JudgeResult` keys each answer by the same id; an id appears in `answers` or in `refusals`, never both, and `refusals` is absent when no question was refused. A `Refusal` lists the candidate keys a wire could not read and invents no probability for them. The `form` member names the kind of a question and of an answer where the protocol writes `type`.
1125
- 39. **The stored distribution and the derived reading (`computeReading`).** An answer stores only its distribution: a `ChoiceAnswer` keys `probabilities` by option name in criteria order, a `ScoreAnswer` indexes them by level, and a `NoulAnswer` holds `noul`, the probability of yes. `computeReading(answer)` derives a `Reading`, and nothing stores it. `winner` is the first strictly greatest candidate in enumeration order: an option name, a level index written as a string, or `'false'` then `'true'` for a noul, so a noul of exactly 0.5 names `'false'`. `probability` is the winner's probability. `confidence` follows the published TypeSafe formulas: `(max(p) - 1/n) / (1 - 1/n)` for a choice, `|2p - 1|` for a noul (the choice formula at n = 2), and `max(0, 1 - spread / even_spread)` for a score, where `spread` sums each level's probability times its distance from the winning level and `even_spread` is the mean distance from the middle level. `score` is the expected level `sum(i * p_i)` and is set for a score answer alone. A choice or a score with fewer than 2 candidates throws `JudgeError` with code `PROTOCOL`, because both formulas divide by the candidate count.
1126
- 40. **The protocol's null and the absence law.** An undescribed choice option stays `null` in `ChoiceCriteria`, because a map holding an option with no description and a map without that option ask different questions and JSON carries no `undefined` member. An undescribed score level stays `null` in `ScoreCriteria`, because a level is positional. `instructions`, `NoulCriteria`, and each side of a `NoulCriteria` value are omitted when absent and never `null`, and `isJudgeQuestion` refuses a `null` in those places. `state` is required and never `null`. On the System One wire an omitted member stays omitted and a `null` criterion is sent as `null`.
1127
- 41. **Validation before inference, a wire's own limits, and the server's limits.** `ask` checks the universal shape before any call: a `state` that `isJudgeEntry` accepts, a non-empty question map, and every question accepted by `isJudgeQuestion`, which needs at least 2 choice options and 2 score levels and each entry a `JudgeEntry` or a permitted `null`. A failure throws `JudgeError` with code `QUESTION` and one of the messages `judge error: state is not a judge entry`, `judge error: no questions`, and `judge error: question <id> is malformed`, and no call is made. A wire can refuse its own limit before transport with code `QUESTION`: the Ollama judge in the `@orkestrel/ollama` package refuses a choice of more than 20 options or a score of more than 10 levels. The System One wire leaves every other limit, such as a server's cap on options, levels, questions, or body bytes, to the server. A server refusal throws `JudgeError` with code `HTTP` and carries the response status in its `status` member. Its message is `judge error: <status> - <excerpt>` when the error body is not empty, with the excerpt read from at most `MAX_ERROR_BODY_LENGTH` bytes of the body, and `judge error: <status>` with no separator when the body is empty, such as `judge error: 400`. A successful response with a `null` body or a body that is not JSON throws `JudgeError` with code `PROTOCOL`. Retrying is the caller's decision.
1128
- 42. **Each call's own deadline, and the abort partial (`JudgeAbortError`).** Each call runs under a `Timeout` of `AgentJudgeInput.timeout` milliseconds (`DEFAULT_PROVIDER_TIMEOUT` when omitted) folded with the caller's signal through `AbortSignal.any`. That bound covers the `headers` hook, the transport, the error-body read, the response read, and `read`, and the timer is cleared on every exit. A signal already aborted throws `JudgeAbortError` with an empty `partial` before any call. A cancel by either bound throws `JudgeAbortError` whose `code` is `'ABORT'` and whose `partial` is the `buildJudgeResult` merge of the calls that completed, with their answers, refusals, and usage, so usage reported by completed calls is retained. A throw that raced the cancel rides as the error's `cause`. A cancel that lands while `read` decodes the answer is reported with no `cause`, and the answer that call decoded is left out of the `partial`. A transport failure with neither bound fired reaches the caller unchanged.
1129
- 43. **The batch switch and the merge (`buildJudgeResult`).** `AgentJudgeInput.batch` (default `true`) is the one binary behavior a judge wire varies on: `true` sends every question in one call, and `false` sends one call per question in the request's key order, each carrying the shared `state`. `ask` builds and serializes every call's body before the first call, so a wire's refusal from `body` lands before any inference, and then runs the calls one after another. `buildJudgeResult` merges their results: answers and refusals joined by id, `refusals` omitted when empty, each call's usage passed through `sanitizeUsage` and added with `sumUsage`, `usage` omitted when no call reported one, and `model` taken from the first call, or the judge's configured `model` when no call completed.
1130
- 44. **The System One wire (`SystemOneJudge` / `createSystemOneJudge`).** `SystemOneJudge` passes `SYSTEM_ONE_PATH` and `batch: true` to the engine, so every question travels in one `POST` to `url` plus `'/v1/systemone'`, and its `name` is `'systemone'`. `body` sends the TypeSafe System One request: the `state`, the configured `model`, and each question through `questionToSystemOne`, which writes `form` as `type` and leaves an omitted member omitted. `read` checks the envelope with `isSystemOneResponse`, then decodes each requested id with `isSystemOneAnswer` and `extractSystemOneAnswer`. A score's `probabilities` member is accepted as a map keyed by level (TypeSafe Jev, Ollama, and Mica's server) or as an array (the llama.cpp server). The requested candidates are kept in criteria order, and unrequested answers, extra candidates, and unknown members are ignored. The server's `confidence`, `score`, and `choice` members are ignored, as is its `legend` member, which repeats the question's criteria: the servers compute those measures by different formulas, while the stored distribution reads the same on all of them, so the `computeReading` function derives every measure locally. A System One server answers only from the supplied options, so the wire never reports a refusal: an answer that is missing, whose `type` differs from the question's `form`, that lacks a finite probability in `[0, 1]` for a requested label or level, or whose `noul` lies outside `[0, 1]` throws `JudgeError` with code `PROTOCOL` naming the question id. `url` has no default, so no state leaves the host until the caller names a server.
1131
- 45. **The System One response model and usage.** On the System One wire, the `JudgeResult.model` member reports the model the response named, verbatim, so a configured alias or a gateway's provider prefix reaches the caller as the server reported it; a response that names no model reports the configured `model` option. Another wire reports the identity its guide defines. `extractSystemOneUsage` maps `input_tokens` and `output_tokens` onto `{ prompt, completion, total }`, and `usage` is absent when either count is missing, `null`, negative, or not finite.
1132
- 46. **Header-hook authentication.** A judge authenticates through the `headers` hook, as a provider does, and both engines read it through `readHeaders`: the headers start from `Content-Type: application/json`, the hook is awaited with the call's combined signal, each entry it returns is set over the defaults, and the hook races that signal, so an unresolved hook cannot outlive the call. A server that takes a key, such as TypeSafe Jev, receives it from a hook that returns an `authorization` header; a local server that takes none, such as Ollama, needs no hook.
1854
+ - **Doc ↔ source bijection.** Every row in the `## Surface` tables is a real export of `src/core`, and every export appears as a Surface row.
1855
+ - **`ProviderInterface` is the inference boundary; `AgentProvider` is the engine behind it.** A provider turns a conversation, optional `tools`, and optional `ProviderStreamOptions` into a turn: `generate` resolves the assembled `ProviderResult`, and `stream` yields channel-tagged `ProviderDelta` values and returns the same result. `AgentProvider` owns the deadline, the transport, the `headers` hook, the bounded error read, the decode loop, reasoning separation, and result assembly, and a subclass supplies one vendor's wire through `frame`, `encode`, `read`, and `finish`. The loop and its tools run in the host that constructs them; see [Placement proofs](#placement-proofs) for each placement's receipt.
1856
+ - **`stream` yields deltas and returns the assembled result.** `stream` yields each non-empty answer delta as `{ channel: 'content', text }` and each native reasoning delta as `{ channel: 'thinking', text }`, and returns the `ProviderResult` whose `content` is the authoritative clean answer. A provider reading a raw wire routes content through a per-stream `ThinkSplitter` armed by `split` and surfaces the reasoning as `ProviderResult.thinking`, which the agent records on the assistant message and sends back only as the provider's `replay` policy allows. `RelayProvider` constructs with `split: false`, so it preserves each upstream delta verbatim, a literal `<think>` tag included.
1857
+ - **Usage reuses `TokenUsage`.** `ProviderResult.usage` is the [budgets](budget.md) `TokenUsage` shape `{ prompt, completion, total }`, present only when the turn reported it.
1858
+ - **The caller's signal and the engine's deadline both bound the call.** `generate` and `stream` take an `AbortSignal`, and an already-aborted signal rejects before any content streams. `AgentProvider` folds a per-call `Timeout` of `AgentProviderInput.timeout` milliseconds (`DEFAULT_PROVIDER_TIMEOUT` when omitted) with that signal through `AbortSignal.any`, covering the `headers` hook, the error-body read, and the stream. The deadline is cleared on every exit.
1859
+ - **A local cancel and a remotely reported one are different failures.** A `stream` cancelled by its bound throws `ProviderAbortError` whose `partial` holds what was assembled locally, with a throw that raced the cancel carried as its `cause`. A relay `abort` frame makes `RelayProvider.read` throw a reconstructed `ProviderAbortError` carrying the upstream partial while the local signal stays unaborted, and the engine propagates that instance unchanged.
1860
+ - **The message-store contract (`MessageManagerInterface`).** `context.messages` is typed to `MessageManagerInterface`, which the active `Conversation` satisfies structurally. `add` mints each message's `id`, carries `calls` only when supplied, and returns the stored immutable message that `message(id)` later resolves; `remove` reports `true` only when every supplied id was removed.
1861
+ - **The richer turn context (`AgentContext`).** `AgentContext` composes the optional `system` prompt, the `instructions`, `workspaces`, and `conversations` managers, `messages`, the `tools` registry, and a readonly active `scope`, creating each omitted manager fresh. `build()` folds the system prompt, the scope-filtered instructions, and the active workspace's scope-filtered text files into one leading `system` message, with a passed selection's non-empty `briefing` as its last part when the selection has no `fault`, then appends the active conversation's `view()`, or a passed selection's `messages`. It never reads `tools`, never mutates a manager or a stored message, and builds fresh on each call.
1862
+ - **Scope filtering.** A scope holds one three-way allow-list per category (`instructions` by `name`, `tools` by `name`, `files` by `path`), applied through `filterAllowList`, and `narrow` intersects each list, so narrowing only tightens. The scope's filters never touch messages; message inclusion is the conversation's through compaction and, when a handler is set, the selection's. The loop filters the advertised definitions by `scope.tools`, so a scoped-out tool is neither described nor callable, and `build()` never contains a tool's name or schema.
1863
+ - **The agent loop (`Agent` / `createAgent`).** `Agent` builds the provider input, then iterates up to `limit`: stream a turn, fold its usage, append any assistant tool calls, and dispatch them. Each `ToolResult` becomes a tool message whose `call` names the paired call, in the reply's call order, because ids can repeat; a string value is the content unchanged, `undefined` is `''`, another value is its `JSON.stringify` text, or `String(value)` when JSON can't carry it, and a failure's content is its `error`. A reply with no tool calls ends the loop.
1864
+ - **One run shared by `generate` and `stream`.** `generate` drains the private run `stream` exposes, so a `generate` result deep-equals a drained `stream` on the same input.
1865
+ - **The `AgentChunk` stream.** `stream().events` yields, per turn, each answer delta as `token`, each reasoning delta as `think`, then optional `usage`, then one `tool` chunk per dispatched call. `result` resolves the settled `AgentResult`: the content, the joined thinking, the summed optional usage, `partial`, and the optional `stop`. A `usage` chunk carries the call's streamed-answer `estimate` beside its usage.
1866
+ - **Bounded, paced, capped.** Each run folds the external `signal`, the `timeout`, and the `budget` and `allowance` signals into one cancel; a cancel resolves `{ partial: true, content }` with what streamed, while a genuine provider or tool error rejects `result` and sets `status` to `error`. `scheduler.yield` runs between turns and never after the last, and `limit` caps tool iteration.
1867
+ - **Pull and push observation on the `Agent`; the rest event-free.** The provider contract, the conversation registry, and the context carry no emitter, while the instruction and scope managers and each conversation own one. The `Agent` pairs the `AgentChunk` stream with an `emitter` over `AgentEventMap` (`start`, `turn`, `tool`, `usage`, `deny`, `finish`, `error`, `abort`, `exhaust`, `fault`, `select`) and has no `token` or `think` event. The emitter routes a listener throw to the `error` option, and a cancelled run emits `abort` then `finish`.
1868
+ - **Doc ↔ source method bijection.** The `## Methods` tables list exactly the call-signature members of every interface named there, and each implementing class exposes exactly its interface's methods.
1869
+ - **The authority gate (`Authority` / `createAuthority`).** `evaluate({ call })` walks the ordered `rules` first-match-wins, a matched rule allowing unless its `allowed` is `false`, and returns the `fallback` when none match, which defaults to `{ zone: DEFAULT_AUTHORITY_ZONE, allowed: true }`. It is synchronous and event-free.
1870
+ - **A denied call is fed back, never executed.** The loop consults `authority` after scope admits a call, and a denied call becomes the failure arm `{ success: false, id, name, error }` without running the tool. Executed results and denials merge in the reply's call order, each producing a `tool` chunk and a tool message.
1871
+ - **Durable, serializable agent jobs (`AgentJobInput` + `AgentRegistry`).** An `AgentJobInput` names its `provider`, `tools`, `authority`, and `scheduler` and carries its data directly, and `AgentRegistry.build(input, signal?)` rehydrates a seeded, signal-wired `Agent` from the named pools. An unknown name throws `AgentError` with code `REGISTRY` and the message `unknown <category>: <name>`.
1872
+ - **Partial is a configurable failure (`partial`).** A durable job's handler throws `AgentJobError` carrying the partial `AgentResult` unless `partial: true`, which resolves the partial as success. `isAgentJobError` narrows a caught value.
1873
+ - **Concurrency, retries, and persistence come from the substrate.** `createAgentQueue` composes `createQueue` and `createAgentRunner` composes `createRunner`, adding only rehydration through the registry and the partial policy.
1874
+ - **Sub-agent fan-out through `controller.spawn`.** A runner job's handler spawns each declared child through `controller.spawn`, and the child runs as a sub-agent beside the parent. Both pass their signal into `registry.build`, so a queue or runner cancel commits the rehydrated agent's partial. A unit spawns its declared children on its first attempt only, so a retried parent never runs a child twice: the runner's handler is the `execute` method of one `AgentFanout`, which records each unit it fanned out.
1875
+ - **The conversation layer (`Conversation` + `ConversationManager`).** `compact()` folds the oldest `count - keep` live messages through the `ConversationSummaryHandler` into a `Section`, stopping before the newest user message, moving a cut inside an exchange back to its start so a fold removes whole exchanges, folding the messages before the first user message, which form their own exchange, only together with the first user exchange, and moving a cut that would split an assistant call from its tool results before that call, emits `compact`, and throws `ConversationError` with code `SUMMARIZER` when no summarizer was supplied. Calls run one at a time, and a fold commits only when its messages are still the live entries after the summarizer settles. A `remove` or `clear` during the initial summarizer leaves no section. A summarizer must not compact the conversation it serves: with no other fold in flight, its own call summarizes the same slice again, and one section commits. An abort of `CompactOptions.signal` rejects with the reason at once and commits nothing after the abort; a fold committed before an aborted overflow merge stays. `view()` renders each section as one assistant recap prefixed with `CONVERSATION_RECAP_PREFIX`, then the live tail, while `rehydrate`, `search`, and `reference` are pure reads. `ConversationManager` is the event-free id-keyed registry with an active pointer, `switch`, and the `open` / `save` store seam.
1876
+ - **The context's message source.** `context.messages` is the `conversations` registry's active conversation, always defined and read on each access, so it follows `conversations.switch(id)`. The `Agent` reads it fresh on each run, so one agent serves many conversations by switching between runs.
1877
+ - **Automatic compaction (the context `window` budget).** With `AgentOptions.window` set and a summarizable active conversation, the loop measures the working message array alone, with only the thinking the provider's `replay` policy keeps, against the window before the first provider request and between turns, with tool definitions and a per-run schema unmeasured, and compacts and rebuilds when the window is exhausted. A summarizer throw emits `fault` and the run continues over-window unless `strict` is set, and a fold that compacts nothing between turns while over the window stops auto-compaction for the rest of the run. A run whose signal aborted by the end of tool dispatch folds nothing before it settles. When no selection handler runs, and either `window` is absent or the active conversation has no summarizer, the loop adds no `await` before the first provider request.
1878
+ - **Active workspace rendering by carrier.** `build()` reads only the active workspace, filters its files through `scope.files`, renders text files under `WORKSPACE_SECTION_HEADER` through `renderFencedFile`, and attaches the base64 data of binary `image/` files to a copy of the last user message. With no active workspace, nothing renders.
1879
+ - **The durable `ConversationStore` + the manager's `open` / `save` seam.** A `ConversationSnapshot` is the JSON payload `{ id, sections, messages, judgments? }`, created by `snapshot()` and restored through `ConversationInput.snapshot` without emitting events. `MemoryConversationStore` keeps snapshots in a map and `DatabaseConversationStore` in one opaque JSON column narrowed by `isConversationSnapshot`, both through `get`, `set`, and `delete`. `open(id)` activates a registry hit or hydrates a store hit, keeps a conversation registered under `id` during the store read, and refuses a snapshot whose `id` is not `id`; `DatabaseConversationStore.get` refuses such a row too. `save(id)` returns `false` when no store or conversation exists.
1880
+ - **Limit exhaustion, the three failure layers, and per-run options.** A run that exhausts `limit` while its last turn still requested tools settles `partial: true` and emits `exhaust` instead of `abort`. The loop charges the `allowance` mid-stream from the `estimateTokens` estimate of each turn's content, and charges the cost `budget` the reported usage after each call, net of the estimate only when the `allowance` is the `budget`, or the estimate when the call reported none, a call that failed with a genuine provider error included; it never charges twice. `AgentResult.stop` names the one layer, `meter`, `budget`, or `model`, that stopped the final provider call first. `AgentRunOptions` members override their construction defaults for one run, a per-run `signal` composes with the constructed one, and `think` and `schema` reach the provider in one options object, omitted when both are absent. A construction or per-run `limit` that is not a nonnegative safe integer throws `AgentError` with code `LIMIT`.
1881
+ - **Construction-time context wiring.** `AgentOptions.instructions`, `workspaces`, `scope`, `conversations`, and `select` forward into the `AgentContext` the constructor builds.
1882
+ - **`strict` — fault escalation.** With `AgentOptions.strict` set, an automatic-compaction summarizer failure and each selection fault emit `fault` and then settle the run `error`; without it, the run continues. A cancel at a select site wins over `strict`, and a manual `conversation.compact()` always propagates its own error.
1883
+ - **Bounded `sections`.** A `sections` cap, set per conversation, as a manager default, or per `compact()` call, bounds `Conversation.sections` to at most `Math.ceil(sections)` entries after a successful merge; when a fold exceeds it, the oldest sections merge into one through a summarizer call and `collapse` fires. A cap below 1 throws `ConversationError` with code `SECTIONS`, and a `keep` that is not a nonnegative safe integer throws it with code `KEEP`.
1884
+ - **`rolling` folds.** `ConversationOptions.rolling` and `ConversationInput.rolling` are booleans that default to `false`; the manager holds no default and `CompactOptions` has no member. A rolling `compact()` makes one summarizer call over the framed recaps of every prior section followed by the eligible live slice with thinking stripped, using the same cut rules and protecting the newest user exchange. After the call settles and the abort check passes, it verifies the slice and the prior sections are unchanged, then replaces all prior sections with one section holding their original messages in order followed by the slice, removes only the slice from the live tail, and emits `compact` once with no `collapse`. A throw, an abort, or a changed input commits nothing, and a fold with no eligible slice makes no call. The originals behind the recaps are never sent to the summarizer, and a hydrated snapshot with several sections folds them all at the next `compact()`. Combining `rolling` with a `sections` cap, whether configured, inherited from a manager, or passed to `compact()`, throws `ConversationError` with code `SECTIONS`. The memory study forwards `rolling: true` from its settings only in the Compaction arm.
1885
+ - **`sanitizeUsage`.** `sanitizeToken` turns a non-finite or non-positive count into `0` and floors a positive fraction, and `sanitizeUsage` applies it to each field. The loop sanitizes a provider's usage and an abort partial's usage before charging or folding it.
1886
+ - **`AgentError` — the concurrency guard.** `stream()` throws `AgentError` with code `CONCURRENCY` and `context: { agent }` synchronously when a run is in flight and the agent carries a `window`, or when the run's effective `budget` or `allowance` is an instance a live run holds; runs holding distinct instances with no `window` overlap. The refused run never starts either bound, so the live run stays bound to it. A `budget` or `allowance` that does not implement `BudgetInterface` throws `ContractError` with code `placement` before either bound starts. `isAgentError` narrows a caught value, and a `REGISTRY` error's `context` names the `category` and `name`.
1887
+ - **`agentResultToJSON` — the portable `AgentResult` projection.** The helper accepts `unknown`, never throws, reads each structural field once, and returns a fresh exact `{ content, thinking?, usage?, partial, stop? }` `JSONValue`, or `undefined` for invalid input; a malformed `stop` refuses the whole value. Finite negative and fractional usage counts are preserved, and extra fields are dropped.
1888
+ - **The provider engine's request and stream.** The engine posts `JSON.stringify(encode(request))` to `url + (path ?? '')` with a JSON content type the `headers` hook can extend, decodes the body through `readChunks`, and feeds every framed record and the `finish` tail through `read`. A record whose `read` returns a `result` ends the call with that result, and with `strict: true` a stream that ends with no settled result throws `ProviderError` with code `PROTOCOL`. A non-OK body is read through `readText` to at most `MAX_ERROR_BODY_LENGTH` bytes and its remainder cancelled, and each reader releases its lock on every exit.
1889
+ - **The wire shapes and the compiled contracts.** The shapes declare each JSON projection and the contracts compile them, each strictly narrower than its domain type, so a value JSON can't carry is refused rather than dropped and a `ToolContext` appears in no wire shape. `RelayProvider.encode` projects the request through `requestToRelay`, which reads each declared member as the request presents it: a getter runs, and an inherited declared member crosses. The clone of that projection uses property descriptors and consults no getter, prototype, or `toJSON` serializer; a non-JSON value is refused before fetching.
1890
+ - **The relay protocol (`createRelay` / `RelayStream` / `RelayProvider`).** The request is a `POST` of one `providerRequestContract` document, and the response is newline-delimited `RelayFrame` records under `RELAY_CONTENT_TYPE`, written under backpressure, with every upstream failure reduced to `RELAY_PROVIDER_MESSAGE`. `authorize` is mandatory and runs before the body is read; the handler answers `401`, `413` at the byte `limit`, `400`, or `502` with no body, and the browser receives each as a `ProviderError` with code `HTTP`. An inbound abort, a client disconnecting included, cancels the upstream turn.
1891
+ - **`ProviderError` and its codes.** `ProviderError` carries a `code` and, for `HTTP` alone, a `status`: `HTTP` reports a non-OK response as `provider error: <status>` with ` - <excerpt>` only when the error body had text, `PROTOCOL` a missing body, a refused record, or a `strict` stream with no settled result, and `PROVIDER` a relay `error` frame. A cancel throws `ProviderAbortError`, never `ProviderError`.
1892
+ - **The judge boundary (`JudgeInterface`).** `JudgeInterface` is the sibling of `ProviderInterface` and never a provider: `ask(request, signal)` answers each question of a `JudgeRequest` about its one `state`, keyed by caller ids the prompt never carries. An id appears in `answers` or in `refusals`, never both, `refusals` is absent when nothing was refused, and a `Refusal` invents no probability.
1893
+ - **The stored distribution and the derived reading (`computeReading`).** An answer stores only its distribution, and `computeReading` derives the `Reading`: `winner` is the first strictly greatest candidate in enumeration order, so a noul of exactly 0.5 names `'false'`, and `probability` is the winner's. `confidence` is `(max(p) - 1/n) / (1 - 1/n)` for a choice, `|2p - 1|` for a noul, and for a score one minus the probability-weighted distance from the winning level over the mean distance from the middle level, floored at 0, and `score`, the expected level `sum(i * p_i)`, is set for a score answer alone. A choice or score with fewer than 2 candidates throws `JudgeError` with code `PROTOCOL`.
1894
+ - **The protocol's null and the absence law.** An undescribed choice option or score level stays `null`, because JSON carries no `undefined` member and a level is positional, while `instructions`, `NoulCriteria`, and each noul side are omitted when absent and `isJudgeQuestion` refuses a `null` there. The System One wire sends an omitted member omitted and a `null` criterion as `null`.
1895
+ - **Validation before inference and the server's limits.** `ask` refuses an invalid `state`, an empty question map, or a question `isJudgeQuestion` rejects with `JudgeError` code `QUESTION` before any call, and a wire can refuse its own limits the same way. A server refusal throws code `HTTP` with the response `status` and the message `judge error: <status>`, plus ` - <excerpt>` read from at most `MAX_ERROR_BODY_LENGTH` bytes when the body has text, and a `null` or non-JSON success body throws code `PROTOCOL`.
1896
+ - **Each call's own deadline, and the abort partial (`JudgeAbortError`).** Each call runs under a `Timeout` of `AgentJudgeInput.timeout` milliseconds (`DEFAULT_PROVIDER_TIMEOUT` when omitted) folded with the caller's signal, covering the `headers` hook, the transport, both body reads, and `read`. A cancel throws `JudgeAbortError` whose `partial` merges the answers, refusals, and usage of the completed calls, with a throw that raced the cancel as its `cause`. A cancel that lands while `read` decodes the answer is reported with no `cause`, and that call's answer is left out of the `partial`.
1897
+ - **The batch switch and the merge (`buildJudgeResult`).** `AgentJudgeInput.batch` (default `true`) sends every question in one call, and `false` sends one call per question in key order with the shared `state`, every body built before the first call. `buildJudgeResult` joins answers and refusals by id, sums each call's sanitized usage, and takes `model` from the first call, or the configured `model` when no call completed.
1898
+ - **The System One wire (`SystemOneJudge` / `createSystemOneJudge`).** `SystemOneJudge` posts every question in one call to `url` plus `SYSTEM_ONE_PATH`, writing `form` as `type` and leaving an omitted member omitted, and decodes each requested id in criteria order, accepting a score's `probabilities` as a map or an array. It ignores unrequested answers and the server's `confidence`, `score`, `choice`, and `legend` members, so `computeReading` derives every measure locally. A System One server answers only from the supplied options, so the wire never reports a refusal: a missing, mismatched, or out-of-range answer throws `JudgeError` with code `PROTOCOL` naming the question id.
1899
+ - **The System One response model and usage.** On the System One wire, `JudgeResult.model` reports the model the response named, verbatim, and a response that names no model reports the configured `model` option. `extractSystemOneUsage` maps `input_tokens` and `output_tokens` onto `{ prompt, completion, total }`, and `usage` is absent when either count is missing, `null`, negative, or not finite.
1900
+ - **Header-hook authentication.** Both engines read the `headers` hook through `buildProviderHeaders`: the headers start from `Content-Type: application/json`, the hook is awaited with the call's combined signal and raced against it, and each entry it returns is set over the defaults.
1901
+ - **Two selection homes.** `AgentOptions.select` is the agent default and the active scope's `select` overrides it, and `context.select` resolves the handler once per call and returns `undefined` when neither home holds one, so the default path awaits nothing.
1902
+ - **Select sites and the receipt.** The loop calls the handler with the request captured at run entry and the run's signal at run entry, the pre-first-turn compaction fold, and each compaction rebuild, folds the result through `build(selection)`, which appends a non-empty `briefing` as the last system part unless the selection has a `fault`, and then emits `select`. A `Selection` has no tool member, so advertising and admission stay the scope's.
1903
+ - **Selection faults.** A thrown handler, a returned `fault`, and a conversation changed under the handler each emit `fault`; a throw builds from `view()` with no `select` event, and a returned `fault` charges its usage before `fault` fires. A cancel at a select site wins over every fault, and the run commits partial without a further provider call.
1904
+ - **Selection cost.** Selection usage is charged in full to the cost `budget` and folded into `AgentResult.usage`, with no `usage` chunk or `usage` event.
1905
+ - **Judgments.** A conversation's `judgments` store keys each record by the caller's question id, last write wins in place, stamps `time` on `add`, and attaches `usage` only for a one-question request. `resolve` reuses each record whose question JSON text, ordered sources, rendered state, and configured judge `model` match, asks once for the rest, and keeps the completed records when a cancel interrupts it.
1906
+ - **Judgments in a snapshot.** A snapshot carries `judgments` only while the store holds one, and a snapshot without the member validates and restores.
1133
1907
 
1134
1908
  ## Patterns
1135
1909
 
@@ -1158,7 +1932,7 @@ budget.consume(result.usage ?? { prompt: 0, completion: 0, total: 0 })
1158
1932
 
1159
1933
  ### Writing a provider for a new wire
1160
1934
 
1161
- Reach a backend this package has never heard of by extending `AgentProvider` rather than implementing `ProviderInterface` from nothing. The base already holds the deadline, the transport, the `headers` hook, the request, the bounded error read, the decode loop, reasoning separation, and result assembly (the provider-engine clause), so a subclass is the wire and nothing else: `name` identifies the backend, `frame` returns fresh framing state for the call, `body` projects a `ProviderRequest` onto the vendor's request shape, `read` turns one framed record into a `ProviderIncrement`, and `finish` hands back whatever the parser still held at end of input. Pass the vendor's endpoint through `super`, and set `split` and `strict` to match what the wire actually delivers. The fence writes `AgentProvider<string>` because this wire frames nothing: each decoded chunk is one raw-text record, so `TRecord` is `string`. A wire that frames JSON objects extends bare `AgentProvider` and takes the default record type, and a wire with its own record type names that type instead.
1935
+ Reach a backend that this package doesn't ship a provider for by extending `AgentProvider` rather than implementing `ProviderInterface` from nothing. The base already holds the deadline, the transport, the `headers` hook, the request, the bounded error read, the decode loop, reasoning separation, and result assembly (the provider-engine clause), so a subclass is the wire and nothing else: `name` identifies the backend, `frame` returns fresh framing state for the call, `encode` projects a `ProviderRequest` onto the vendor's request shape, `read` turns one framed record into a `ProviderIncrement`, and `finish` hands back whatever the parser still held at end of input. Pass the vendor's endpoint through `super`, and set `split` and `strict` to match what the wire actually delivers. The fence writes `AgentProvider<string>` because this wire frames nothing: each decoded chunk is one raw-text record, so `TRecord` is `string`. A wire that frames JSON objects extends bare `AgentProvider` and takes the default record type, and a wire with its own record type names that type instead.
1162
1936
 
1163
1937
  ```ts
1164
1938
  import type {
@@ -1188,7 +1962,7 @@ class TextProvider extends AgentProvider<string> {
1188
1962
  frame(): ProviderParserInterface<string> {
1189
1963
  return new TextFrame()
1190
1964
  }
1191
- body(request: ProviderRequest): object {
1965
+ encode(request: ProviderRequest): object {
1192
1966
  return { messages: request.messages }
1193
1967
  }
1194
1968
  read(record: string): ProviderIncrement {
@@ -1204,7 +1978,7 @@ That provider drives the whole runtime unchanged — `createAgent`, the tool loo
1204
1978
 
1205
1979
  ### Asking a System One server a choice, a noul, and a score
1206
1980
 
1207
- Ask every question about one state in one request, and read each answer with the `computeReading` function. The `SystemOneJudge` class speaks the System One decision protocol. Its wire was recorded against Ollama 0.40.0 serving the `tev1:0.8b` model on 2026-10-07, and it decodes the llama.cpp server's array form and the extra members of Mica's `typesafe_server.py` script as their sources document them. Point the `url` option at the server origin; the wire appends the `SYSTEM_ONE_PATH` constant and fills the request's `model` member from the judge's configured model.
1981
+ Ask every question about one state in one request, and read each answer with the `computeReading` function. The `SystemOneJudge` class speaks the System One decision protocol. Its wire was recorded against Ollama 0.40.0 serving the `tev1:0.8b` model on 2026-10-07. It also accepts a score's `probabilities` and `legend` as an array, and it drops the measures a server adds beside the distribution. Point the `url` option at the server origin; the wire appends the `SYSTEM_ONE_PATH` constant and fills the request's `model` member from the judge's configured model.
1208
1982
 
1209
1983
  ```ts
1210
1984
  import { computeReading, createSystemOneJudge } from '@orkestrel/agent'
@@ -1241,12 +2015,12 @@ const readings = Object.fromEntries(
1241
2015
  )
1242
2016
  result.model // 'tev1:0.8b' — the model the server named
1243
2017
  result.usage // { prompt: 975, completion: 4, total: 979 }
1244
- readings.label // { winner: 'bug', probability: 0.9691, confidence: 0.9536 } to four decimals
1245
- readings.refund // { winner: 'true', probability: 0.9979, confidence: 0.9958 } to four decimals
1246
- readings.severity // { winner: '1', probability: 0.9494, confidence: 0.9241, score: 0.9919 } to four decimals
2018
+ readings.label // { winner: 'bug', probability: 0.9691, confidence: 0.9536 } to 4 decimals
2019
+ readings.refund // { winner: 'true', probability: 0.9979, confidence: 0.9958 } to 4 decimals
2020
+ readings.severity // { winner: '1', probability: 0.9494, confidence: 0.9241, score: 0.9919 } to 4 decimals
1247
2021
  ```
1248
2022
 
1249
- The values in the comments are the readings of the response Ollama 0.40.0 returned for this request on 2026-10-07. That same response carried the server's own measures, and the wire drops them: a `confidence` of 0.8718 for `label` and 0.7864 for `severity`, where the published formulas read 0.9536 and 0.9241. The servers disagree on those formulas: Ollama reports an entropy measure, Mica's server reports the greatest probability as its `confidence` and the most likely level as its `score`, and Jev reports the published ratio. The stored `probabilities` and `noul` mean the same on every server, so the `computeReading` function derives each measure locally and every server reads on one scale. A `choice` member repeats the most likely option and a `legend` member repeats the question's criteria, so the wire drops both as well (the System One wire clause).
2023
+ The values in the comments are the readings of the response Ollama 0.40.0 returned for this request on 2026-10-07. That same response carried the server's own measures, and the wire drops them: a `confidence` of 0.8718 for `label` and 0.7864 for `severity`, where the published formulas read 0.9536 and 0.9241. A server's own measures can follow other formulas, while the stored `probabilities` and `noul` mean the same on every server, so the `computeReading` function derives each measure locally and every server reads on one scale. A `choice` member repeats the most likely option and a `legend` member repeats the question's criteria, so the wire drops both as well (the System One wire clause).
1250
2024
 
1251
2025
  A server that takes a key receives it through the `headers` hook, which each call awaits inside its own deadline (the header-hook authentication clause). TypeSafe Jev takes a bearer key:
1252
2026
 
@@ -1262,11 +2036,11 @@ const jev = createSystemOneJudge({
1262
2036
  })
1263
2037
  ```
1264
2038
 
1265
- The executed transcription in [`tests/guides.test.ts`](../tests/guides.test.ts) serves the recorded Ollama response from a loopback `@orkestrel/server` listener and points the first fence's `url` option at that listener in place of `http://localhost:11434`, and it supplies a recording `fetch` transport to the second fence in place of the network. It also decodes the same distribution in the llama.cpp array form and with Mica's server measures, both transliterated from that recording, and reads the same answers from all three.
2039
+ The executed transcription serves the recorded Ollama response from a loopback `@orkestrel/server` listener and points the first fence's `url` option at that listener in place of `http://localhost:11434`, and it supplies a recording `fetch` transport to the second fence in place of the network; see [the `tests/guides.test.ts` parity test](../tests/guides.test.ts). It also decodes the same distribution in its array form and with extra server measures, both transliterated from that recording, and reads the same answers from all three.
1266
2040
 
1267
2041
  ### Writing a judge wire
1268
2042
 
1269
- Reach a decision server this package does not speak by extending `AgentJudge` rather than implementing `JudgeInterface` from nothing. The engine already holds each call's deadline, the transport, the `headers` hook, the validation before inference, the bounded error read, and the merge of the calls (the batch-switch clause), so a subclass is the wire and nothing else: `name` identifies it, `body` projects one call's `JudgeRequest` onto the server's request shape, and `read` decodes one call's parsed response into a `JudgeResult`. This server reads one question per call, so the fence sets `batch: false`.
2043
+ Reach a decision server this package doesn't speak by extending `AgentJudge` rather than implementing `JudgeInterface` from nothing. The engine already holds each call's deadline, the transport, the `headers` hook, the validation before inference, the bounded error read, and the merge of the calls (the batch-switch clause), so a subclass is the wire and nothing else: `name` identifies it, `encode` projects one call's `JudgeRequest` onto the server's request shape, and `read` decodes one call's parsed response into a `JudgeResult`. This server reads one question per call, so the fence sets `batch: false`.
1270
2044
 
1271
2045
  ```ts
1272
2046
  import type { JudgeRequest, JudgeResult } from '@orkestrel/agent'
@@ -1276,7 +2050,7 @@ import { isFiniteNumber, isRecord } from '@orkestrel/contract'
1276
2050
  // A wire whose server answers one yes/no question per call as { "yes": 0.93 }.
1277
2051
  class YesJudge extends AgentJudge {
1278
2052
  readonly name = 'yes'
1279
- body(request: JudgeRequest): object {
2053
+ encode(request: JudgeRequest): object {
1280
2054
  return { model: this.model, state: request.state, questions: request.questions }
1281
2055
  }
1282
2056
  read(value: unknown, request: JudgeRequest): JudgeResult {
@@ -1296,7 +2070,7 @@ const judge = new YesJudge({
1296
2070
  })
1297
2071
  ```
1298
2072
 
1299
- That judge answers through `ask` like any other. With `batch: false` the engine makes one call per question in key order, each carrying the shared `state` and one question, and merges their answers into one `JudgeResult`. Throw `JudgeError` with code `PROTOCOL` from `read` for a response the wire cannot read; the engine passes it to the caller unchanged. Throw `JudgeError` with code `QUESTION` from `body` for a question the protocol cannot carry; the engine builds every body before the first call, so that refusal lands before any inference. A wire that reads candidates out of a model's own output and cannot find one returns a `Refusal` under `refusals`, listing the caller's keys it could not read, instead of inventing a probability.
2073
+ That judge answers through `ask` like any other. With `batch: false` the engine makes one call per question in key order, each carrying the shared `state` and one question, and merges their answers into one `JudgeResult`. Throw `JudgeError` with code `PROTOCOL` from `read` for a response the wire can't read; the engine passes it to the caller unchanged. Throw `JudgeError` with code `QUESTION` from `encode` for a question the protocol can't carry; the engine builds every body before the first call, so that refusal lands before any inference. A wire that reads candidates out of a model's own output and can't find one returns a `Refusal` under `refusals`, listing the caller's keys it couldn't read, instead of inventing a probability.
1300
2074
 
1301
2075
  ### Relaying a browser provider through your own server
1302
2076
 
@@ -1304,9 +2078,9 @@ When you compose `createAgent(browser, { tools })` in the page, the agent dispat
1304
2078
 
1305
2079
  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.
1306
2080
 
1307
- 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.
2081
+ 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 a `ProviderParserInterface` implementation — the `RelayFrameParser` class following uses `parseJSONAs` and `isRecord` from the declared `@orkestrel/contract` dependency — 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 (see [the `tests/guides.test.ts` parity test](../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.
1308
2082
 
1309
- The transcription substitutes where a test process differs from a deployment. The fence's `declare` placeholders become a scripted upstream provider and a fictional bearer, and the `messages` binding is typed as `readonly Message[]` rather than narrowed with `as const`. It frames the relay response with a parser from this repository's own test infrastructure, which throws on a malformed line where the published parser skips it. It adds `host: '127.0.0.1'` to the `createServer` call, because a test listener binds loopback where a deployed server binds every interface. It points the browser half's `url` option at that address and the port `await server.start()` resolved, in place of the fence's fixed public URL. It closes each case with `await server.stop()` — the fence's own call — rather than the `SIGTERM` listener, which a test process outlives. The byte-limit refusal stays a direct handler call. The `limit` option of the `createRelay` factory caps the body the relay reads, and the `limit` option of the `createServer` factory caps the `body()` read a middleware context makes. This composition registers no middleware, so the server's cap never sees these bytes.
2083
+ The transcription substitutes where a test process differs from a deployment. The fence's `declare` placeholders become a scripted upstream provider and a fictional bearer, and the `messages` binding is typed as `readonly Message[]` rather than narrowed with `as const`. It transcribes the `RelayFrameParser` class and drives that same framing through the HTTP hop. The parser retains incomplete records across chunks, accepts LF and CRLF record terminators, ignores blank lines, and throws a `ProviderError` with code `PROTOCOL` for malformed JSON or a non-object record. Each provider call receives a fresh parser; `clear()` discards any buffered fragment. It adds `host: '127.0.0.1'` to the `createServer` call, because a test listener binds loopback where a deployed server binds every interface. It points the browser half's `url` option at that address and the port `await server.start()` resolved, in place of the fence's fixed public URL. It closes each case with `await server.stop()` — the fence's own call — rather than the `SIGTERM` listener, which a test process outlives. The byte-limit refusal stays a direct handler call. The `limit` option of the `createRelay` factory caps the body the relay reads, and the `limit` option of the `createServer` factory caps the `body()` read a middleware context makes. This composition registers no middleware, so the `limit` option of the `createServer` factory never applies to these bytes.
1310
2084
 
1311
2085
  #### Mounting the relay on your server
1312
2086
 
@@ -1336,16 +2110,34 @@ await server.start()
1336
2110
  process.on('SIGTERM', () => server.stop()) // signal cancellation, drain, then close the listener
1337
2111
  ```
1338
2112
 
1339
- The `serve` function is the entry a server runtime's adapter calls, and the adapter owns what the relay deliberately does not: turning the runtime's inbound request into a `Request`, handing the returned `Response` back to the runtime, and aborting that request's signal when the client disconnects, so a reader that goes away cancels the upstream turn instead of leaving it running. `@orkestrel/server` supplies that adapter — `createServer` binds the same `dispatcher` and starts listening, as the fence shows — and a runtime whose own handler is already `(request: Request) => Promise<Response>` needs none.
2113
+ The `serve` function is the entry a server runtime's adapter calls, and the adapter owns what the relay deliberately doesn't: turning the runtime's inbound request into a `Request`, handing the returned `Response` back to the runtime, and aborting that request's signal when the client disconnects, so a reader that goes away cancels the upstream turn instead of leaving it running. `@orkestrel/server` supplies that adapter — `createServer` binds the same `dispatcher` and starts listening, as the fence shows — and a runtime whose own handler is already `(request: Request) => Promise<Response>` needs none.
1340
2114
 
1341
2115
  #### Reaching the relay from the browser
1342
2116
 
1343
2117
  ```ts
1344
- import type { ProviderInterface } from '@orkestrel/agent'
1345
- import { createRelayProvider } from '@orkestrel/agent'
2118
+ import type { ProviderInterface, ProviderParserInterface } from '@orkestrel/agent'
2119
+ import { createRelayProvider, ProviderError } from '@orkestrel/agent'
1346
2120
  import { createAbort } from '@orkestrel/abort'
1347
- // The browser application supplies this parser dependency.
1348
- import { createNDJSONParser } from '@orkestrel/ndjson'
2121
+ import { isRecord, parseJSONAs } from '@orkestrel/contract'
2122
+
2123
+ class RelayFrameParser implements ProviderParserInterface {
2124
+ #pending = ''
2125
+ parse(chunk: string): ReadonlyArray<Readonly<Record<string, unknown>>> {
2126
+ this.#pending += chunk
2127
+ const lines = this.#pending.split(/\r\n|\n/)
2128
+ this.#pending = lines.pop() ?? ''
2129
+ return lines
2130
+ .filter((line) => line.trim().length > 0)
2131
+ .map((line) => {
2132
+ const record = parseJSONAs(line, isRecord)
2133
+ if (record === undefined) throw new ProviderError('PROTOCOL', 'invalid JSON record')
2134
+ return record
2135
+ })
2136
+ }
2137
+ clear(): void {
2138
+ this.#pending = ''
2139
+ }
2140
+ }
1349
2141
 
1350
2142
  declare const bearer: string
1351
2143
  const abort = createAbort()
@@ -1353,22 +2145,23 @@ const messages = [{ id: '1', role: 'user', content: 'Say hello.' }] as const
1353
2145
 
1354
2146
  const browser: ProviderInterface = createRelayProvider({
1355
2147
  url: 'https://app.example/relay',
1356
- parser: createNDJSONParser,
2148
+ parser: () => new RelayFrameParser(),
1357
2149
  headers: () => ({ authorization: `Bearer ${bearer}` }),
1358
2150
  })
1359
2151
  const result = await browser.generate(messages, abort.signal) // a ProviderResult like a local provider's
1360
2152
  ```
1361
2153
 
1362
- `authorize` is mandatory and carries obligations the mechanism deliberately does not assume for you: it must not read the request body, and it performs no origin or method check of its own, so an application that authorizes on an ambient credential such as a cookie composes origin and CSRF middleware in front of the handler. A bearer header the browser sets explicitly, as here, is not reachable cross-site. Every refusal — `401` for a failed authorization, `413` for a body that reaches the byte limit, `400` for a missing, unreadable, or rejected body, `502` for an upstream call that cannot be constructed — arrives at the browser as a `ProviderError` with code `'HTTP'` and that status. The `401`, `400`, and `413` refusals leave the upstream provider unentered; the `502` is answered after `provider.stream` was entered and threw before returning its iterator, so that refusal has already reached the provider. An upstream failure that does happen mid-stream reaches the browser as the fixed `RELAY_PROVIDER_MESSAGE` text, never the upstream's own message (the relay-protocol clause).
2154
+ `authorize` is mandatory and carries obligations the mechanism deliberately doesn't assume for you: it must not read the request body, and it performs no origin or method check of its own, so an application that authorizes on an ambient credential such as a cookie composes origin and CSRF middleware in front of the handler. A bearer header the browser sets explicitly, as here, isn't reachable cross-site. Every refusal — `401` for a failed authorization, `413` for a body that reaches the byte limit, `400` for a missing, unreadable, or rejected body, `502` for an upstream call that can't be constructed — arrives at the browser as a `ProviderError` with code `'HTTP'` and that status. The `401`, `400`, and `413` refusals leave the upstream provider unentered; the `502` is answered after `provider.stream` was entered and threw before returning its iterator, so that refusal has already reached the provider. An upstream failure that does happen mid-stream reaches the browser as the fixed `RELAY_PROVIDER_MESSAGE` text, never the upstream's own message (the relay-protocol clause).
1363
2155
 
1364
2156
  ### Dispatching the model's tool calls
1365
2157
 
1366
- Advertising and dispatch are the halves of one exchange: hand `definitions()` to the provider, and feed the `ToolCall`s that come back through `execute`. Results are correlated by `id` and discriminated on `success`, so a handler throw arrives as a `ToolResult` the model can read rather than an exception the caller must catch.
2158
+ Advertising and dispatch are the halves of one exchange: hand `definitions()` to the provider, and feed the `ToolCall` values that come back through `execute`. Results are correlated by `id` and discriminated on `success`, so a handler throw arrives as a `ToolResult` the model can read rather than an exception the caller must catch.
1367
2159
 
1368
2160
  ```ts
1369
2161
  import type { ProviderInterface } from '@orkestrel/agent'
1370
2162
  import { createToolManager, createTool } from '@orkestrel/tool'
1371
2163
 
2164
+ // `messages` and `signal` come from the host application.
1372
2165
  declare const provider: ProviderInterface
1373
2166
  const tools = createToolManager()
1374
2167
  tools.add(createTool({ name: 'add', execute: (args) => Number(args.a) + Number(args.b) }))
@@ -1396,14 +2189,15 @@ agent.context.messages.add({ role: 'user', content: 'Summarize the news.' })
1396
2189
  setTimeout(() => agent.abort('user navigated away'), 5_000)
1397
2190
 
1398
2191
  const result = await agent.generate()
2192
+ // `keep` comes from the host application.
1399
2193
  if (result.partial) keep(result.content) // a cancel RESOLVED partial, not an error
1400
2194
  ```
1401
2195
 
1402
2196
  ### Cancelling work inside a tool
1403
2197
 
1404
- 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.
2198
+ 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 allowance is charged during provider streaming and the token budget after each provider call and between turns, before tool dispatch; exhaustion ends the run without dispatching, never inside a handler.
1405
2199
 
1406
- 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`.
2200
+ 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`. A cancelled run runs no automatic compaction after dispatch; the next run's pre-first-turn check folds instead.
1407
2201
 
1408
2202
  This fence uses a provider that requests the `wait` tool. The handler resolves from its abort listener, and the run commits the partial result:
1409
2203
 
@@ -1441,42 +2235,50 @@ const result = await stream.result
1441
2235
  result.partial // true
1442
2236
  ```
1443
2237
 
1444
- 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.
2238
+ The executed transcription (see [the `tests/guides.test.ts` parity test](../tests/guides.test.ts)), “observes cancellation inside the handler as the tool cancellation fence claims”, supplies the scripted provider and asserts those values.
2239
+
2240
+ ### Three failure layers: meter, budget, model
2241
+
2242
+ A run can be stopped by three token layers, and its result names the one that did. The meter layer charges each content delta's estimated token growth (the `estimateTokens` `ceil(length / 4)` heuristic) against the `AgentOptions.allowance` budget while the provider streams, so a runaway answer stops before the provider reports its usage. The cost `budget` is charged each call's reported `usage` after the call returns. The model layer is the provider's own limit: an answer the model ended at its token cap arrives with `ProviderResult.truncated`. The `allowance` and `budget` signals fold into the run's bound abort like an external cancel or a `timeout`, so a trip cancels the provider, and the run resolves `partial: true` with an `abort` event carrying the tripped budget's `BudgetError`.
1445
2243
 
1446
- ### Bounding cost mid-stream (the token `budget`)
2244
+ The meter charges completion growth as `{ prompt: 0, completion: n, total: n }`, so a `completion`- or `total`-scoped allowance tracks the answer, while a `prompt`-scoped allowance is charged `0` and never trips through the meter.
1447
2245
 
1448
- 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).
2246
+ Pass the `budget` itself as the `allowance` to make the estimate and the reported usage share one ceiling. The loop then reconciles each call against what the meter already charged: the full `prompt`, plus `completion` and `total` less the streamed estimate, floored at 0, so a shared ceiling is never charged less than the reported usage and never charged the estimate twice; an estimate above the report is not refunded. With a separate `allowance`, or none, the `budget` is charged exactly the reported usage, or the streamed-answer estimate when the call reported none. Omit `allowance` to turn the meter layer off; the estimate is still measured. Give a separate `allowance` a smaller `max` than the budget's headroom to let the estimate stop the run first, or a larger one to let the reported usage or the model's own limit stop it. `AgentResult.usage` and the `usage` chunks stay the full reported usage either way.
1449
2247
 
1450
- Once a turn does complete, the loop reconciles: it charges the budget the remainder of the turn's authoritative `usage` (`completion - alreadyCharged`, `total - alreadyCharged`, plus the full `prompt` — which is never estimated mid-stream, having no live delta channel) — so the turn's total budget draw always nets to exactly the reported usage, never double-charged and never under-charged. The `AgentResult.usage` / the `usage` chunks you observe stay the full authoritative usage regardless — this reconcile affects only what the `budget` itself was charged, never what you're told the turn cost. This mid-stream enforcement is bounded, not exact — the estimate can under- or over-shoot the eventual real usage by a turn's tail, so treat the `budget.max` as a firm ceiling with some slack, not a byte-exact cutoff.
2248
+ `AgentResult.stop` names the layer with the readings it crossed: a `meter` or `budget` stop copies the tripped budget's `id`, `max`, and `consumed` from its `BudgetError`, and a `model` stop carries the provider's trace `id`. A run names one layer, the one that stopped the final provider call first. A truncated final answer whose call returned unaborted is a `model` stop and settles `partial: false`; when its reconcile then exhausts a budget, or a cancel lands before the run settles, the stop stays `model`, `partial` is `true`, and the `abort` event carries that later reason. An external `signal`, a `timeout`, and `abort()` name no layer, and neither does a natural finish or an exhausted `limit`. A model error is a rejection, not a stop: a refusal or an oversized prompt rejects the run with a `ProviderError` on the `error` event, and no result carries a `stop`. The `usage` event and chunk carry each call's streamed-answer `estimate` beside its reported usage, so you can tune `allowance.max` from that calibration reading without a failure.
1451
2249
 
1452
2250
  ```ts
1453
2251
  import { createAgent } from '@orkestrel/agent'
1454
2252
  import { createTokenBudget } from '@orkestrel/budget'
1455
2253
 
1456
- const budget = createTokenBudget({ max: 2_000, scope: 'completion' })
1457
- const agent = createAgent(provider, { budget })
1458
- agent.emitter.on('abort', (reason) => log('budget tripped mid-stream', reason))
2254
+ // `provider` and `log` come from the host application.
2255
+ const budget = createTokenBudget({ id: 'budget', max: 50_000, scope: 'total' })
2256
+ const allowance = createTokenBudget({ id: 'meter', max: 2_000, scope: 'completion' })
2257
+ const agent = createAgent(provider, { budget, allowance })
2258
+ agent.emitter.on('usage', (usage, estimate) => log('calibration', estimate, usage.completion))
1459
2259
  agent.context.messages.add({ role: 'user', content: 'Write a very long story.' })
1460
- const result = await agent.generate() // partial: true if the story ran the budget out mid-stream
2260
+ const result = await agent.generate()
2261
+ result.stop?.layer // 'meter' when the estimate ran out first, 'budget' when the reported usage did, 'model' when the model ended its own answer
1461
2262
  ```
1462
2263
 
1463
- **A `think: true` run needs headroom for reasoning.** Live reasoning deltas (`ProviderDelta` `'thinking'`) are not metered mid-stream (only `'content'` deltas are — thinking is charged, like content, solely through the post-turn reconcile), so a thinking model can spend a large share of a tight budget's ceiling on its reasoning before any answer content streams — the mid-stream trip can land while the model is still reasoning, committing an empty (or near-empty) `content` alongside `partial: true`. Give a `think: true` run enough budget headroom to cover its reasoning, not only its expected answer length.
2264
+ **A `think: true` run needs headroom for reasoning.** Live reasoning deltas (`ProviderDelta` `'thinking'`) aren't metered mid-stream (only `'content'` deltas are), and the reasoning is charged to the `budget` through the reported usage alone, so a thinking model can spend a large share of a tight ceiling on reasoning the meter never saw. Give a `think: true` run enough headroom to cover its reasoning, not only its expected answer length; the `usage` event's `estimate` shows the gap, because reasoning is in `usage.completion` and not in `estimate`.
1464
2265
 
1465
2266
  ### Observing an agent (push vs. pull)
1466
2267
 
1467
- An `Agent` exposes a pull and a push observation surface. Pull — the `AgentChunk` stream (`stream().events`) — is for a live consumer rendering per-token answer deltas and per-think reasoning deltas as they arrive. Push — the `emitter` (`AgentEventMap`) — is for fire-and-forget observers (logging, metrics, tracing) that want the loop's lifecycle moments without draining the stream: `start` (a run begins), `turn` (each iteration), `tool` (a dispatched call + its result), `usage` (a turn's token usage), `deny` (an authority denial — which never reaches the chunk stream), `finish` (the settled result), `error` (a genuine failure), `abort` (a cancel), and `exhaust` (the limit was reached while the model still held unresolved tool intent — fires instead of `abort`, still followed by `finish`). Per-token / per-thinking deltas stay the stream's job exclusively — there is deliberately no `token` or `think` event on the emitter; reach for the stream when you need live output, the emitter when you need lifecycle.
2268
+ An `Agent` exposes a pull and a push observation surface. Pull — the `AgentChunk` stream (`stream().events`) — is for a live consumer rendering per-token answer deltas and per-think reasoning deltas as they arrive. Push — the `emitter` (`AgentEventMap`) — is for fire-and-forget observers (logging, metrics, tracing) that want the loop's lifecycle moments without draining the stream: `start` (a run begins), `turn` (each iteration), `tool` (a dispatched call + its result), `usage` (a turn's token usage and the `estimate` of the answer it streamed, as `(usage, estimate)`), `deny` (a denied call; the event is never a chunk, and a call that scope or authority denies still yields a `tool` chunk that carries its denial result, while a call made when the active scope advertises no tools yields no chunk), `finish` (the settled result), `error` (a genuine failure), `abort` (a cancel), and `exhaust` (the limit was reached while the model still held unresolved tool intent — fires instead of `abort`, still followed by `finish`). Per-token / per-thinking deltas stay the stream's job exclusively — there is deliberately no `token` or `think` event on the emitter; reach for the stream when you need live output, the emitter when you need lifecycle.
1468
2269
 
1469
2270
  ```ts
1470
2271
  import type { ProviderInterface } from '@orkestrel/agent'
1471
2272
  import { createAgent } from '@orkestrel/agent'
1472
2273
 
1473
2274
  declare const provider: ProviderInterface
2275
+ // `trace`, `meter`, `audit`, and `log` come from the host application.
1474
2276
  // Wire fire-and-forget observers at construction through the reserved `on` option …
1475
2277
  const agent = createAgent(provider, {
1476
2278
  on: {
1477
2279
  start: (id) => trace.begin(id),
1478
2280
  usage: (usage) => meter.add(usage.total),
1479
- deny: (call, reason) => audit(call.name, reason), // not visible on the chunk stream
2281
+ deny: (call, reason) => audit(call.name, reason), // an event, not a chunk
1480
2282
  finish: (result) => trace.end(result),
1481
2283
  },
1482
2284
  })
@@ -1484,15 +2286,15 @@ const agent = createAgent(provider, {
1484
2286
  agent.emitter.on('abort', (reason) => log('cancelled', reason))
1485
2287
  ```
1486
2288
 
1487
- **Observation can never corrupt the loop.** The emitter isolates a listener that throws (the throw can never escape into the settle-once / wake-park engine — `generate()` / `stream()` still settle the exact same result), routing the caught error to its own `error` handler (the `error` option, surfaced as `(error, event)`, not a domain event) so the observer bug is not silently lost. Every throwing listener surfaces (not only the first); a throwing `error` handler is swallowed too (it can neither recurse nor escape); with no handler the throw is dropped silently. So a buggy observer degrades to a routed error — it never reorders, throws into, or corrupts the run.
2289
+ **Observation can never corrupt the loop.** The emitter isolates a listener that throws (the throw can never escape into the settle-once / wake-park engine — `generate()` / `stream()` still settle the exact same result), routing the caught error to its own `error` handler (the `error` option, surfaced as `(error, event)`, not a domain event) so the observer bug isn't silently lost. Every throwing listener surfaces (not only the first); a throwing `error` handler is swallowed too (it can neither recurse nor escape); with no handler the throw is dropped silently. So a buggy observer degrades to a routed error — it never reorders, throws into, or corrupts the run.
1488
2290
 
1489
- **A cancelled run emits `abort` then `finish`.** A cancel (an external `signal`, the `timeout` deadline, an exhausted `budget`, or `abort()`) still resolves a partial result — so the emitter fires `abort` (carrying the cancel reason) and then `finish` (carrying the settled partial), letting an observer see both that the run was cancelled and the partial outcome it committed. A natural / cap-bounded finish fires `finish` only; a genuine provider / tool error fires `error` instead of `finish`. `generate()` and `stream()` drive the same events (they share one `#run`).
2291
+ **A cancelled run emits `abort` then `finish`.** A cancel (an external `signal`, the `timeout` deadline, an exhausted `budget` or `allowance`, or `abort()`) still resolves a partial result — so the emitter fires `abort` (carrying the cancel reason) and then `finish` (carrying the settled partial), letting an observer see both that the run was cancelled and the partial outcome it committed. A natural / cap-bounded finish fires `finish` only; a genuine provider / tool error fires `error` instead of `finish`. `generate()` and `stream()` drive the same events (they share one `#execute`).
1490
2292
 
1491
2293
  ### Pulling context from another conversation (with provenance)
1492
2294
 
1493
- One agent serves many conversations by switching the active conversation between runs (`conversations.switch(id)` — the active-conversation clause); each thread keeps its own history. When the active conversation A needs something decided in another conversation B, don'T merge B's turns into A's live tail — that pollutes A's thread and (for a small model) blurs which conversation said what. Instead pull a provenance-labeled reference of B into A's active workspace (the fenced reference channel `build()` folds into the system block), so the model reads it as clearly-foreign material and attributes it to B.
2295
+ One agent serves many conversations by switching the active conversation between runs (`conversations.switch(id)` — the active-conversation clause); each thread keeps its own history. When the active conversation A needs something decided in another conversation B, don't merge B's turns into A's live tail — that pollutes A's thread and (for a small model) blurs which conversation said what. Instead pull a provenance-labeled reference of B into A's active workspace (the fenced reference channel `build()` folds into the system block), so the model reads it as clearly-foreign material and attributes it to B.
1494
2296
 
1495
- The flow is **summary → search / rehydrate → reference → write-to-workspace** — and cherry-pick, never dump:
2297
+ The flow is **search / rehydrate → reference → write-to-workspace** — and cherry-pick, never dump:
1496
2298
 
1497
2299
  ```ts
1498
2300
  import type { ProviderInterface } from '@orkestrel/agent'
@@ -1507,21 +2309,19 @@ const b = conversations.add({ id: 'planning' }) // the OTHER thread to pull from
1507
2309
 
1508
2310
  const agent = createAgent(provider, { conversations }) // its active conversation (a) is the message source
1509
2311
 
1510
- // 1. summary → decide B is relevant (its rollup is a cheap digest of the whole thread)
1511
- b.summary // for example "the team evaluated databases and chose Postgres"
1512
- // 2. search / rehydrate → SELECT the few right turns (never B's whole history)
2312
+ // 1. search / rehydrate → SELECT the few right turns (never B's whole history)
1513
2313
  const picked = b.search('database') // or b.rehydrate(sectionId) for a compacted slice
1514
- // 3. reference → FRAME them as a self-labeled provenance block (a pure string, no model call)
2314
+ // 2. reference → FRAME them as a self-labeled provenance block (a pure string, no model call)
1515
2315
  const block = b.reference({ label: 'planning', messages: picked })
1516
- // 4. write-to-workspace → into the ACTIVE conversation's context active workspace, keyed by the source id
2316
+ // 3. write-to-workspace → into the ACTIVE conversation's context active workspace, keyed by the source id
1517
2317
  agent.context.workspaces.add().write(`conversation:${b.id}.md`, block)
1518
2318
 
1519
- // Now the model can use B's decision AND attribute it: "Postgres, decided in the planning conversation."
2319
+ // The model can then use B's decision AND attribute it: "Postgres, decided in the planning conversation."
1520
2320
  ```
1521
2321
 
1522
- Why this shape: `reference()` leads with `[Reference — conversation "<label>" — NOT part of this conversation]`, so the model treats the rollup + excerpts as a quoted foreign source (it answers "decided in the planning conversation", not "we decided here"). Keep the excerpts cherry-picked — this content enters another context window a small model must read, and a full dump re-bloats it. `reference()` is event-free and never calls a model; provenance lives in the `label` (default the conversation's `id`).
2322
+ Why this shape: `reference()` leads with `[Reference — conversation "<label>" — NOT part of this conversation]`, so the model treats the excerpts as a quoted foreign source (it answers "decided in the planning conversation", not "we decided here"). Keep the excerpts cherry-picked — this content enters another context window a small model must read, and a full dump re-bloats it. `reference()` is event-free and never calls a model; provenance lives in the `label` (default the conversation's `id`).
1523
2323
 
1524
- **Within a conversation, the same provenance instinct applies to recaps.** `view()` folds each compacted section into a synthetic `assistant` recap — and prefixes it with `CONVERSATION_RECAP_PREFIX` (`[Summary of earlier messages] …`) so a small model reads it as a condensed recap of earlier turns rather than a literal turn to echo or treat as the live answer. The label is deliberately lean (a fixed handful of tokens — no per-section blow-up) and is a `view()`-only presentation concern (the rollup regeneration re-reads the unframed summaries). Empirically, on a 2B model this tightening is the difference between the model correctly attributing a recapped fact and mis-attributing it — at temperature 0 the recap label reliably steers correct attribution where the bare assistant turn does not.
2324
+ **Within a conversation, the same provenance instinct applies to recaps.** `view()` folds each compacted section into a synthetic `assistant` recap — and prefixes it with `CONVERSATION_RECAP_PREFIX` (`[Summary of earlier messages] …`) so a small model reads it as a condensed recap of earlier turns rather than a literal turn to echo or treat as the live answer. The label is deliberately lean (a fixed handful of tokens — no per-section blow-up) and is a `view()`-only presentation concern (a `sections` cap merge re-reads the unframed summaries).
1525
2325
 
1526
2326
  ### Persisting a conversation through either store
1527
2327
 
@@ -1552,16 +2352,41 @@ for (const store of stores) {
1552
2352
  }
1553
2353
  ```
1554
2354
 
2355
+ ### Recording a judgment on a conversation
2356
+
2357
+ A conversation carries its own judgment store, so a judge's answer about a message is recorded beside the messages it concerns and survives the snapshot round trip. `add` stamps the record's `time`; `judgment` reads one record by its key and `judgments` lists them in insertion order.
2358
+
2359
+ ```ts
2360
+ import { createConversation } from '@orkestrel/agent'
2361
+
2362
+ const conversation = createConversation()
2363
+ const complaint = conversation.add({ role: 'user', content: 'I was charged twice for one order.' })
2364
+
2365
+ conversation.judgments.add({
2366
+ id: 'refund',
2367
+ question: { form: 'noul', instructions: 'Is a refund owed?' },
2368
+ answer: { form: 'noul', noul: 0.9 },
2369
+ model: 'tev1:0.8b',
2370
+ sources: [complaint.id],
2371
+ state: complaint.content,
2372
+ })
2373
+
2374
+ const refund = conversation.judgments.judgment('refund')
2375
+ const recorded = conversation.judgments.judgments()
2376
+ const snapshot = conversation.snapshot()
2377
+ ```
2378
+
1555
2379
  ### Running many durable agents as jobs
1556
2380
 
1557
- When you need many agents — bounded, retried, surviving a crash — describe each as a serializable `AgentJobInput` (names for the live pieces, data for the rest), register the live pieces once, and run them through a `createAgentQueue` (durable, bounded) or a `createAgentRunner` (one-shot, ordered, fail-fast, with sub-agent fan-out). The layer composes the `@orkestrel/queue` `Queue` and the `@orkestrel/workflow` `Runner` — it adds only rehydration and the partial policy, no new engine.
2381
+ When you need many agents — bounded, retried, surviving a crash — describe each as a serializable `AgentJobInput` (names for the live pieces, data for the rest), register the live pieces once, and run them through a `createAgentQueue` (durable, bounded) or a `createAgentRunner` (one-shot, ordered, fail-fast, with sub-agent fan-out). The layer composes the `@orkestrel/queue` `Queue` and the `@orkestrel/workflow` `Runner` — it adds only rehydration and the partial policy, no engine of its own.
1558
2382
 
1559
2383
  ```ts
1560
2384
  import type { AgentJobInput } from '@orkestrel/agent'
2385
+ import type { QueueStoreInterface } from '@orkestrel/queue'
1561
2386
  import { createAgentQueue, createAgentRegistry } from '@orkestrel/agent'
1562
- import { createMemoryQueueStore } from '@orkestrel/queue'
1563
2387
 
1564
- declare const store: ReturnType<typeof createMemoryQueueStore> // or a server JSON / SQLite store
2388
+ declare const store: QueueStoreInterface<AgentJobInput> // a durable store the host supplies
2389
+ // `provider` comes from the host application.
1565
2390
 
1566
2391
  // Register the live, non-serializable pieces ONCE; jobs reference them by name.
1567
2392
  const registry = createAgentRegistry({ providers: { main: provider } })
@@ -1577,11 +2402,12 @@ const results = await Promise.all(jobs.map((job) => queue.enqueue(job)))
1577
2402
  await queue.restore()
1578
2403
  ```
1579
2404
 
1580
- Fan out sub-agents by declaring `children` on a parent job; `createAgentRunner` `controller.spawn`s each through the same bounded queue, so the children run as sibling sub-agents:
2405
+ Fan out sub-agents by declaring `children` on a parent job; the runner that `createAgentRunner` returns spawns each child through `controller.spawn` on the same bounded queue, so the children run as sibling sub-agents; a retried parent spawns them only on its first attempt:
1581
2406
 
1582
2407
  ```ts
1583
2408
  import { createAgentRunner } from '@orkestrel/agent'
1584
2409
 
2410
+ // `registry` and the `AgentJobInput` type come from the preceding example.
1585
2411
  const runner = createAgentRunner({ registry, concurrency: 4 })
1586
2412
  const parent: AgentJobInput = {
1587
2413
  provider: 'main',
@@ -1611,9 +2437,9 @@ workspace.write('briefing.txt', 'The vault code is 7731.')
1611
2437
 
1612
2438
  Reading is one half. To let the model edit what it reads, register the `createWorkspaceTool` published by `@orkestrel/toolbox` on `agent.context.tools` over this same `context.workspaces` registry: an `operation`-keyed `ToolInterface` whose dispatch and error semantics are that package's to document. The surfaces then close a loop — the model reads the workspace from the prompt, edits it through a tool call, and reads the edited version on the next turn.
1613
2439
 
1614
- ### Switching which workspace the model sees
2440
+ ### Switching which workspace the prompt carries
1615
2441
 
1616
- Only the active workspace renders; the other registered workspaces never reach the model at all. `switch` changes which one the model sees between runs:
2442
+ Only the active workspace renders; the other registered workspaces never reach the model at all. `switch` changes which one the prompt carries between runs:
1617
2443
 
1618
2444
  ```ts
1619
2445
  import type { ProviderInterface } from '@orkestrel/agent'
@@ -1631,12 +2457,194 @@ await agent.generate() // the model reads the active workspace's file from the p
1631
2457
  // Serve a different workspace next run — switch the active pointer:
1632
2458
  const other = agent.context.workspaces.add() // NOT active (a later add leaves active unchanged)
1633
2459
  other.write('notes.txt', 'different context')
1634
- agent.context.workspaces.switch(other.id) // now build() renders other's files instead
2460
+ agent.context.workspaces.switch(other.id) // build() renders other's files from here
1635
2461
  // Narrow which files render with scope.files (by path):
1636
2462
  agent.context.apply(createScope({ name: 'cfg', files: ['src/config.ts'] }))
1637
2463
  ```
1638
2464
 
1639
- Because the editing tool drives the same registry, a switch moves both surfaces together: the model's next prompt and its next edit land in the same workspace whether the host called `context.workspaces.switch` or the model asked the tool to.
2465
+ A switch between runs changes the files the next run's prompt carries, and `scope.files` then filters the switched workspace's files by path.
2466
+
2467
+ ### Switching modes with a scope
2468
+
2469
+ A mode is a scope an application keeps ready: its allow-lists and an optional `select` handler. Load the data from a plain file, and attach each handler in code through a guarded lookup, because a file carries no function. Switch modes with `context.apply`; each change applies at the sites [Scoping a turn](#scoping-a-turn) names.
2470
+
2471
+ `mode.narrow({ tools: [] })` is the answer mode that keeps the mode's handler, because `narrow` keeps the parent's `name` and `select`. `apply(undefined)` and a plain answer scope with no handler fall back to the agent default. A mode that must run with no selection under a default carries a pass-through handler that returns `view()`; the package exports no such handler.
2472
+
2473
+ The following modes load from data, attach their handlers in code, and switch between runs:
2474
+
2475
+ ```ts
2476
+ import type { ProviderInterface, ScopeInterface, SelectionHandler } from '@orkestrel/agent'
2477
+ import type { ToolManagerInterface } from '@orkestrel/tool'
2478
+ import { createAgent, createScope } from '@orkestrel/agent'
2479
+
2480
+ declare const provider: ProviderInterface
2481
+ declare const tools: ToolManagerInterface // holds the `lookup` tool
2482
+ declare const judged: SelectionHandler // the agent default
2483
+ declare const recent: SelectionHandler // the triage mode's own policy
2484
+
2485
+ // Plain data, as a JSON file holds it; the data names a policy because a file carries no function.
2486
+ const MODES = [
2487
+ { name: 'triage', tools: ['lookup'], policy: 'recent' },
2488
+ { name: 'verbatim', tools: [], policy: 'none' },
2489
+ ]
2490
+ const POLICIES: Readonly<Record<string, SelectionHandler>> = {
2491
+ recent,
2492
+ // No selection under the judging default: a pass-through handler returns view().
2493
+ none: async (conversation) => ({ messages: conversation.view(), judgments: [] }),
2494
+ }
2495
+ const modes = new Map(
2496
+ MODES.map(({ policy, ...data }): [string, ScopeInterface] => {
2497
+ const select = POLICIES[policy]
2498
+ return [data.name, createScope(select === undefined ? data : { ...data, select })]
2499
+ }),
2500
+ )
2501
+
2502
+ const agent = createAgent(provider, { tools, select: judged })
2503
+ async function ask(content: string): Promise<void> {
2504
+ agent.context.messages.add({ role: 'user', content })
2505
+ await agent.generate()
2506
+ }
2507
+
2508
+ const triage = modes.get('triage')
2509
+ agent.context.apply(triage)
2510
+ await ask('Sort ticket 7.') // `recent` selects; `lookup` is advertised
2511
+ agent.context.apply(triage?.narrow({ tools: [] }))
2512
+ await ask('Answer from what you have.') // `recent` selects; no tool is advertised
2513
+ agent.context.apply(createScope({ name: 'answer', tools: [] }))
2514
+ await ask('Answer briefly.') // `judged` selects
2515
+ agent.context.apply(undefined)
2516
+ await ask('Close ticket 7.') // `judged` selects
2517
+
2518
+ // A one-run mode: apply, generate, and restore after the result settles.
2519
+ const previous = agent.context.scope
2520
+ agent.context.apply(modes.get('verbatim'))
2521
+ try {
2522
+ await ask('Quote the thread.') // the pass-through selects; `judged` is not called
2523
+ } finally {
2524
+ agent.context.apply(previous)
2525
+ }
2526
+ ```
2527
+
2528
+ Overlapping runs on one agent share its scope and its default handler, so an `apply` made for one run reaches every run in flight at its next site. Runs that need different modes at the same moment need separate agents.
2529
+
2530
+ ### Answering without tools
2531
+
2532
+ When the next turn must answer from what it already holds, apply a scope with an empty `tools` list: `createScope({ name: 'answer', tools: [] })`. The package exports no constant for it. The provider's `content` ends the run as the answer with `partial: false`, and each call the reply still carries emits `deny` with the reason `no tool is advertised in the active scope`, as [Scoping a turn](#scoping-a-turn) states. That reason and `TOOL is not in the active scope` are frozen copy, so a harness can match them verbatim.
2533
+
2534
+ The following run ends answer-only:
2535
+
2536
+ ```ts
2537
+ import type { ProviderInterface } from '@orkestrel/agent'
2538
+ import { createAgent, createScope } from '@orkestrel/agent'
2539
+ import { createTool, createToolManager } from '@orkestrel/tool'
2540
+
2541
+ declare const provider: ProviderInterface // replies with text and a stray `refund` call
2542
+ declare function log(name: string, reason: string | undefined): void
2543
+
2544
+ const tools = createToolManager()
2545
+ tools.add(createTool({ name: 'refund', execute: () => 'refunded' }))
2546
+ const agent = createAgent(provider, { tools })
2547
+ agent.emitter.on('deny', (call, reason) => log(call.name, reason)) // 'refund', 'no tool is advertised in the active scope'
2548
+ agent.context.apply(createScope({ name: 'answer', tools: [] }))
2549
+ agent.context.messages.add({ role: 'user', content: 'Refund invoice 42.' })
2550
+ const result = await agent.generate() // { content: 'Refunds need a manager.', partial: false }
2551
+ ```
2552
+
2553
+ ### Serving requests through a ledger
2554
+
2555
+ Build a ledger from a provider, a judge, the lookups, and the measured questions, add the seed history to its conversation, and send each request through the `respond` method. Read the `briefing` member of each `select` event's receipt to see what the ledger told the model. The following ledger serves two requests; a scripted provider stands in for the model and a scripted judge for Mica:
2556
+
2557
+ ```ts
2558
+ import type {
2559
+ JudgeAnswer,
2560
+ JudgeInterface,
2561
+ LedgerLookup,
2562
+ LedgerThreshold,
2563
+ ProviderInterface,
2564
+ ProviderResult,
2565
+ } from '@orkestrel/agent'
2566
+ import { createLedger, LEDGER_QUESTIONS } from '@orkestrel/agent'
2567
+
2568
+ declare const thresholds: LedgerThreshold // fitted on LEDGER_QUESTIONS and your judge
2569
+
2570
+ // Each provider call takes the next reply: two calibration calls, then one reply per turn.
2571
+ const script: readonly ProviderResult[] = [
2572
+ { content: '', usage: { prompt: 160, completion: 0, total: 160 } }, // calibration with the tools
2573
+ { content: '', usage: { prompt: 40, completion: 0, total: 40 } }, // calibration without them
2574
+ { content: '', tools: [{ id: 'call-1', name: 'lookup_order', arguments: { id: 'BW-5512' } }] },
2575
+ { content: 'Order BW-5512 qualifies for a $148.50 refund.' },
2576
+ { content: 'Yes. Refunds over $100 need a manager.' },
2577
+ ]
2578
+ const replies = script.values()
2579
+ const provider: ProviderInterface = {
2580
+ id: 'scripted',
2581
+ name: 'scripted',
2582
+ generate: async () => replies.next().value ?? { content: '' },
2583
+ async *stream() {
2584
+ const reply = replies.next().value ?? { content: '' }
2585
+ if (reply.content !== '') yield { channel: 'content', text: reply.content }
2586
+ return reply
2587
+ },
2588
+ }
2589
+ // The judge files every message it categorizes as a rule and answers no to every noul question.
2590
+ const judge: JudgeInterface = {
2591
+ id: 'scripted',
2592
+ name: 'scripted',
2593
+ model: 'scripted',
2594
+ ask: async (request) => ({
2595
+ model: 'scripted',
2596
+ answers: Object.fromEntries(
2597
+ Object.entries(request.questions).map(([id, question]): [string, JudgeAnswer] => [
2598
+ id,
2599
+ question.form === 'choice'
2600
+ ? { form: 'choice', probabilities: { rule: 0.9, fact: 0.1 } }
2601
+ : { form: 'noul', noul: 0.1 },
2602
+ ]),
2603
+ ),
2604
+ }),
2605
+ }
2606
+ // One lookup: the tool the model calls and the handler that reads the owner out of its result.
2607
+ const lookup: LedgerLookup = {
2608
+ tool: {
2609
+ name: 'lookup_order',
2610
+ description: 'Read an order by its id.',
2611
+ parameters: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
2612
+ execute: (args) =>
2613
+ `Order ${String(args.id)} for account BW-20931: Brightwater Studio. Refund due $148.50.`,
2614
+ },
2615
+ read: (args, text) =>
2616
+ text.startsWith('No order')
2617
+ ? undefined // the lookup found nothing
2618
+ : { ids: [String(args.id)], owners: [{ id: 'BW-20931', names: ['Brightwater Studio'] }] },
2619
+ }
2620
+
2621
+ const ledger = createLedger(provider, {
2622
+ judge,
2623
+ system: 'You staff the Larkspur support desk. Today is 2026-10-09.',
2624
+ topics: [{ name: 'refunds', criterion: 'refund amounts and approvals' }],
2625
+ questions: LEDGER_QUESTIONS,
2626
+ thresholds,
2627
+ capacity: 32_768,
2628
+ lookups: [lookup],
2629
+ })
2630
+ const briefings: Array<string | undefined> = []
2631
+ ledger.agent.emitter.on('select', (selection) => briefings.push(selection.briefing))
2632
+ ledger.conversation.add({ role: 'user', content: 'Refunds over $100 need a manager.' })
2633
+
2634
+ const gauge = await ledger.calibrate(AbortSignal.timeout(30_000))
2635
+ gauge.overhead // 120 — what advertising the tools adds to a prompt
2636
+ const first = await ledger.respond('Can Brightwater Studio get a refund on order BW-5512?')
2637
+ first.content // 'Order BW-5512 qualifies for a $148.50 refund.'
2638
+ const second = await ledger.respond('Does the Brightwater Studio refund need a manager?')
2639
+ second.content // 'Yes. Refunds over $100 need a manager.'
2640
+ briefings[0] // '## Rules\n- Refunds over $100 need a manager.' — no lookup has named an owner yet
2641
+ briefings[1]
2642
+ // '## Pinned\n### Brightwater Studio (account BW-20931)\n- Order BW-5512 for account BW-20931: Brightwater Studio.\n- Refund due $148.50.\n\n## Rules\n- Refunds over $100 need a manager.'
2643
+ ```
2644
+
2645
+ The second request names Brightwater Studio, so its briefing pins that owner's record, built from the first request's lookup result, while neither the first request nor its reply reaches the second prompt. The executed transcription runs this fence with its own cutoffs and reads the prompts the provider received; see [the `tests/guides.test.ts` parity test](../tests/guides.test.ts).
2646
+
2647
+ To serve a thinking model, pass its context window as the `capacity` option and its generation cap, thinking included, as the `predict` option, and set the boolean `think` option to request or suppress thinking for the first pass. The answer pass always runs with thinking off. The plan then budgets what the window leaves after the cap, the recall room keeps the cap free, and the gauge counts only the thinking the next request carries; see [Serving a conversation through a ledger](#serving-a-conversation-through-a-ledger) for the budget.
1640
2648
 
1641
2649
  ### Removing / clearing entries, and the less-common accessors
1642
2650
 
@@ -1672,9 +2680,9 @@ context.messages.clear()
1672
2680
  context.instructions.remove('tone')
1673
2681
  context.instructions.clear()
1674
2682
 
1675
- // ScopeManager — `create` mints + stores, `scopes` lists, `remove` / `clear` drop.
2683
+ // ScopeManager — `add` mints + stores, `scopes` lists, `remove` / `clear` drop.
1676
2684
  const scopes = createScopeManager()
1677
- const scope = scopes.create({ name: 'read-only' })
2685
+ const scope = scopes.add({ name: 'read-only' })
1678
2686
  scopes.scopes() // every stored scope, in insertion order
1679
2687
  scopes.remove(scope.id)
1680
2688
  scopes.clear()
@@ -1702,7 +2710,7 @@ conversations.clear()
1702
2710
  const message = thread.add({ role: 'user', content: 'hi' })
1703
2711
  thread.remove(message.id)
1704
2712
  thread.clear()
1705
- thread.snapshot() // { id, summary?, sections, messages } — the durable payload
2713
+ thread.snapshot() // { id, sections, messages, judgments? } — the durable payload
1706
2714
 
1707
2715
  const agent = createAgent(provider)
1708
2716
  void agent
@@ -1710,64 +2718,96 @@ void agent
1710
2718
 
1711
2719
  ### Practices
1712
2720
 
2721
+ The following practices apply when you build on this package:
2722
+
1713
2723
  - **Bound every call** — pass an `AbortSignal` (an [abort](abort.md), or an `AbortSignal.any` over abort + [timeout](timeout.md) + [budget](budget.md)) so a request can be cancelled, deadlined, or capped.
1714
2724
  - **Recover the stream's partial** — wrap a driven `stream` in `try`/`catch` and narrow with `isProviderAbortError` to keep the content that arrived before a cancel.
1715
- - **Fold usage into a budget** — `result.usage` is the [budgets](budget.md) `TokenUsage`; `consume` it per turn to enforce a token ceiling.
1716
- - **Register tools in a `ToolManager`** — `add` your `Tool`s, hand `definitions()` to the provider, and dispatch the model's `ToolCall`s through `execute`; read the outcome by narrowing on `success` rather than catching, since a handler's throw already arrives as the failure arm. When in-process code wants a typed error instead, call `tools.tool(name)` and `execute` it directly.
2725
+ - **Fold usage into a budget** — `result.usage` is the [budgets](budget.md) `TokenUsage`; pass it to the `consume` method of the budget each turn to enforce a token ceiling.
2726
+ - **Register tools in a `ToolManager`** — add your `Tool` instances through `add`, hand `definitions()` to the provider, and dispatch the `ToolCall` values the model returns through `execute`; read the outcome by narrowing on `success` rather than catching, because a handler's throw already arrives as the failure arm. When in-process code wants a typed error instead, call `tools.tool(name)` and call the `execute` method of that tool directly.
1717
2727
  - **Narrow tool `args`** — a `ToolCall.arguments` is model-supplied `unknown`; narrow it inside `execute` with a guard.
1718
- - **Collect turns in an `AgentContext`** — `add` to `context.messages` (the `id` is minted for you), then `build()` the provider input each turn; tools travel as the provider's `tools` argument, so never fold a tool's schema into a message yourself.
1719
- - **Pull cross-conversation context with provenance, never by merging turns** — to use something from another conversation B in the active one, follow `B.summary` (decide relevance) → `B.search` / `B.rehydrate` (cherry-pick the few right turns) → `B.reference({ label, messages })` (frame it) → `context.workspaces.active?.write(...)` (write it into the active workspace). The provenance label keeps a small model from reading B's content (or a recap) as part of the live thread; cherry-pick — don't dump B's whole history into another context window.
2728
+ - **Collect turns in an `AgentContext`** — append turns through `context.messages.add` (the `id` is minted for you), then call `build()` each turn to produce the provider input; tools travel as the provider's `tools` argument, so never fold a tool's schema into a message yourself.
2729
+ - **Pull cross-conversation context with provenance, never by merging turns** — to use something from another conversation B in the active one, follow `B.search` / `B.rehydrate` (cherry-pick the few right turns) → `B.reference({ label, messages })` (frame it) → `context.workspaces.active?.write(...)` (write it into the active workspace). The provenance label keeps a small model from reading B's content (or a recap) as part of the live thread; cherry-pick — don't dump B's whole history into another context window.
1720
2730
  - **Run the loop with `createAgent`** — don't hand-roll the context → provider → tools cycle; `createAgent` does it, bounded by `AbortSignal.any([signal, timeout, budget])`, paced by `scheduler`, capped at `limit`. Drain `stream().events` to render `token` / `think` / `tool` / `usage` chunks live, or `generate()` for the settled result; either way `result` resolves partial on a cancel (read `result.partial`), rejecting only on a real error.
1721
2731
  - **Run many durable agents with `createAgentQueue`** — describe each agent as a serializable `AgentJobInput` (names for the provider / tools / authority / scheduler, data for the rest), register the live pieces once in a `createAgentRegistry`, and enqueue the jobs. Persist them with a `store` so `restore()` re-runs outstanding work after a crash. Decide the partial policy up front — `partial: false` (default) retries a cancelled job, `true` accepts the partial.
1722
- - **Fan out sub-agents with `createAgentRunner`** — declare a parent job's sub-agents in its `children`; the runner `controller.spawn`s each through the same bounded queue. Don't reach for the controller yourself — express fan-out as data on the job (it stays serializable) and let the runner spawn it.
1723
- - **Pull and push surfaces on the `Agent`, none elsewhere** — observe the `Agent` each way: pull the `AgentChunk` stream for per-token / per-thinking deltas + usage/tool chunks, or push `agent.emitter.on(...)` (`AgentEventMap`) for lifecycle + usage/tool/deny moments a fire-and-forget observer wants. A listener throw can never corrupt the loop (the emitter isolates it, routing it to the `error` option). Do not reach for an Emitter on the provider contract, the tool registry, the conversation store, the context, or the job layer — those stay event-free; and do not expect per-token `token` or `think` events (those stay the stream's job).
1724
- - **Create and edit files through `@orkestrel/workspace`** — the file domain is that package's, and `AgentContext` borrows only its `isText` / `isBinary` guards to decide a file's carrier. The rendering is agent's: the `## Workspace` fencing and the binary-plus-`image/` attachment are prompt policy that belongs here, not workspace helpers that belong there.
1725
- - **Give the model both halves of a workspace** — `context.workspaces` is what it reads, rendered by carrier every turn, active-only and filtered by `scope.files`; the `createWorkspaceTool` published by `@orkestrel/toolbox` over that same registry is what it writes. Workspace editing, errors, and persistence live in [`workspace.md`](workspace.md); tool dispatch semantics live in [`tool.md`](tool.md).
2732
+ - **Fan out sub-agents with `createAgentRunner`** — declare a parent job's sub-agents in its `children`; the runner spawns each through `controller.spawn` on the same bounded queue. Don't reach for the controller yourself — express fan-out as data on the job (it stays serializable) and let the runner spawn it.
2733
+ - **Pull and push surfaces on the `Agent`, none elsewhere** — observe the `Agent` each way: pull the `AgentChunk` stream for per-token / per-thinking deltas + usage/tool chunks, or push `agent.emitter.on(...)` (`AgentEventMap`) for lifecycle + usage/tool/deny moments a fire-and-forget observer wants. A listener throw can never corrupt the loop (the emitter isolates it, routing it to the `error` option). Don't reach for an Emitter on the provider contract, the tool registry, the conversation store, the context, or the job layer — those stay event-free; and don't expect per-token `token` or `think` events (those stay the stream's job).
2734
+ - **Create and edit files through `@orkestrel/workspace`** — the file domain is that package's, and `AgentContext` borrows only its `isText` / `isBinary` guards to decide a file's carrier. The rendering belongs to this package: the `## Workspace` fencing and the binary-plus-`image/` attachment are prompt policy that belongs here, not workspace helpers that belong there.
2735
+ - **Judge the argument the model passed** — a tool handler that asks a judge about a model's tool call judges the argument that call carried, or its tool description says it judges a fixed input.
2736
+ - **Write a refusal as the next step** — a refusal text a tool returns names the one next call and says nothing about who asked, because a reasoning model mid-task reads a refusal that addresses the user as a user turn.
2737
+ - **Give the model both halves of a workspace** — `context.workspaces` is what it reads, rendered by carrier every turn, active-only and filtered by `scope.files`; the `createWorkspaceTool` published by `@orkestrel/toolbox` over that same registry is what it writes. For workspace editing, errors, and persistence, see [Workspace](workspace.md); for tool dispatch semantics, see [Tool](tool.md).
1726
2738
 
1727
2739
  ## Tests
1728
2740
 
1729
- - [`tests/guides.test.ts`](../tests/guides.test.ts) — the `## Surface` ↔ `src/core` bijection for agent-owned values and types, exhaustive method parity for every agent-owned interface and class pair documented here, and the equality gate: every `Summary` cell against its declaration's description paragraph, the titled `Conversations & compaction` 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, including the relay halves driven over a started `@orkestrel/server` listener on loopback — the declared route's own `405` and `404`, the `401` a wrong bearer answers, the round trip, and the upstream turn a disconnected reader cancels. It runs the judge fences the same way — the System One request against the recorded Ollama response served from a loopback listener, the readings `computeReading` derives from it, the `headers` hook fence, and the judge-wire fence — and backs each judge Contract clause with an executed assertion.
1730
- - [`tests/src/core/conversations/Conversation.test.ts`](../tests/src/core/conversations/Conversation.test.ts) — `add` single mints a fresh `id` + carries `role` / `content` (and `calls` only when given, the field omitted otherwise); `add` batch returns the created messages in order with unique ids; the returned message reflects its input and is the same object `message(id)` resolves (immutable); `message` lookup / miss; `messages()` insertion order; `remove` single + batch (`true` only when every supplied id was removed) + `clear` + `count`; and hydration through the `ConversationOptions.snapshot` seam — the one way to restore (a `createConversation` restore of the stored id, rollup, sections, and live tail that re-snapshots identically, the snapshot id winning over an options id, a restored conversation's `view` / `search` / `count` and continued compaction, and no event emitted while restoring).
1731
- - [`tests/src/core/AgentContext.test.ts`](../tests/src/core/AgentContext.test.ts) — `build()` with a system prompt prepends `{ role: 'system', content }` then the conversation in order; without one (and empty managers) returns only the conversation (no system turn, `system === undefined`, empty → `[]`, and an explicit `''` / whitespace system still prepended); `context.tools` is the passed registry (or a fresh empty `ToolManager`) and `build()` never includes a tool's name / description; `build()` is fresh each call (reflects messages added between builds, mints a new system `id`, snapshot-independent). And the richer assembly: the `instructions` manager is fresh empty (or reused by identity); an empty manager contributes nothing (lean behavior preserved); it folds into the system block under its `description` + per-item `override`; the readonly `scope` getter (default `undefined`, initial through options) + `apply(scope)` / `apply(undefined)` through an `AgentContextInterface` binding; and scope filtering per category — `undefined` ⇒ all, a named allow-list ⇒ only-listed, `[]` ⇒ none over the instructions, with the conversation passing through unfiltered, recomputed fresh when the scope is changed between builds. And the active-workspace render by carrier (the only document/image channel): `context.workspaces` is always present (fresh empty `WorkspaceManager`) or supplied structurally through options; the active workspace's text files fold into a `## Workspace` system section (fenced through `renderFencedFile`, placed after instructions) and its image files' base64 attaches to the last user message (own-images-first merge; multiple in insertion order; skipped when no user message; the stored message is never mutated); `scope.files` filters both carriers; the render is active-only (a non-active workspace's files never render, reflected through a `switch`); no active workspace ⇒ nothing rendered. And the injected-conversation message source (a data-stub summarizer): with one, `context.messages` is its live tail and `build()` folds its `view()` (the compacted view after a `compact()`), no scope filtering over messages (the conversation is authoritative) while scope still filters instructions; without one, the plain message-store path. And the structural conversation registry (the multi-conversation switch): supplied through options, its active conversation changes through `conversations.switch(id)`, re-pointing `messages` to the new live tail by identity (no duplication), and `build()` follows the switch; constructing with an empty supplied registry adds a default active conversation; a `compact()` on the active conversation is reflected through the switch.
1732
- - [`tests/src/core/scopes/Scope.test.ts`](../tests/src/core/scopes/Scope.test.ts) — `Scope` construction (a minted `id` + the per-category lists including `files`, copied in so a later mutation of the caller array can't leak in; `[]` distinct from `undefined`); and `narrow`'s set-intersection semantics — `list ∩ list` = the keys in both, `undefined ∩ list` = the list (no parent constraint), `list ∩ undefined` = the list, `undefined ∩ undefined` = `undefined`, `[]` ⇒ none either side, narrowing only tightens (a parent-excluded key never returns), per-category independence (incl. `files`), name preserved, immutable (a new scope, parent untouched), and chained narrows compose.
1733
- - [`tests/src/core/scopes/ScopeManager.test.ts`](../tests/src/core/scopes/ScopeManager.test.ts) — the id-keyed registry: `create` mints an `id` + stores (always adds — two scopes sharing a `name` coexist), `scope` / `scopes` insertion-order lookup, `remove` single + batch (`true` only when every supplied id was removed) + `clear` + `count`; the `create` / `remove` / `clear` event emissions (one `remove` per actually-removed id, the reserved `on` option); and emit-safety (a throwing `create` listener can't corrupt the registry + routes to the emitter's `error` handler; a throwing `error` handler neither escapes nor recurses) mirroring the Table / InstructionManager emitter convention.
1734
- - [`tests/src/core/helpers.test.ts`](../tests/src/core/helpers.test.ts) — agent-owned pure helpers: `agentResultToJSON` full/minimal fresh exact projections + extras dropped + compile-time exhaustive `AgentResult` field-map review, conforming accessor/inherited structural values, and total rejection of wrong/missing fields, malformed present optionals, non-finite/missing usage counts, throwing getters, non-object inputs, and revoked root/nested proxies; `filterAllowList`'s semantics (`undefined` ⇒ all, `[]` ⇒ none, a list ⇒ only-listed) preserving item order (not allow-list order), ignoring unknown keys, matching through the key extractor (not identity), and returning `[]` (not throwing) for an empty item list; `estimateTokens` / `estimateMessages` (the per-message `MESSAGE_TOKEN_OVERHEAD`, an empty batch ⇒ 0, empty content ⇒ overhead only, the JSON-stringified `calls` contribution with its documented fixed fallback on a circular argument and no contribution for an empty `calls` array, and `images.length * IMAGE_TOKEN_ESTIMATE` per image); `renderFencedFile` (the `File:` label + language-tagged fence, the body verbatim across lines, and a workspace text file framed from its own text arm); `sanitizeToken` / `sanitizeUsage` (identity on a well-formed non-negative integer usage; `NaN` / negative / `±Infinity` floored to 0 and a fractional field to its integer part, each field independently); and `settleAgentJob`'s partial policy (a natural finish resolves `partial: false`, a disallowed partial throws an `AgentJobError` carrying the partial, an allowed partial resolves as success). And the extracted loop / cascade / conversation / scope leaves on their own contracts: `joinThinking` / `sumUsage` seeding then accumulating, `assembleResult` omitting an absent `thinking` / `usage` and keeping the loop-internal `exhausted` out of the public result, `denyCall`'s denial texts, `renderSection` rendering nothing for an empty item list, `resolveOpen` / `resolveClose` / `resolveItem` at each cascade level (item override beating every other), `attachImages` merging own-images-first without mutating the source, `attachUserImages` replacing only the last user turn (and returning the conversation unchanged for no data or no user turn), `collectImageData` skipping a text file, `buildSummaryMessage` / `buildRecapMessage` (raw vs. `CONVERSATION_RECAP_PREFIX`-framed), and `intersectKeys` treating `undefined` as the universal set and returning a copy.
1735
- - [`tests/src/core/factories.test.ts`](../tests/src/core/factories.test.ts) — agent-owned factories over real declared tool/workspace dependencies: `createAgentContext` (the `[system?, ...messages]` assembly; a pre-built tool registry surfacing through `context.tools`); `createAgent` (one turn to its result; passed instructions / workspaces managers surface through `agent.context` — an added text file appears in `build()`; a no-tools scope empties the advertised definitions and filters instructions; omitted managers still yield working empty managers); `createChannel` (pushed values drain in write order then `close` ends it; a buffered value is delivered before a `fail` surfaces); `createAgentRegistry` (a serializable job round-trips build→run; a job naming a provider or tool missing from the registry rejects loudly on enqueue); `createAgentQueue` (each enqueue resolving its own result; the `concurrency` bound incl. a 12-job batch at 3; the partial policy — throws by default carrying the partial, re-runs for the full retry budget then rejects, `partial: true` resolves, a `budget: 0` partial settles the same way, a non-partial sibling resolves beside a throwing partial, a per-attempt timeout rejects as the substrate fault (not an `AgentJobError`) and retries, a pre-aborted entry signal hard-cancels without running; pause parks a job until resume, stop rejects a pending job, and `abort()` threads into the agent's signal); durability (an `AgentJobInput` JSON round-trips unchanged; a queue store round-trips the row; `restore()` re-runs an outstanding job to its real result then removes the row); `createAgentRunner` (an ordered batch, fail-fast on a partial, a parent spawning a child sub-agent through `controller.spawn`, cancel threading); and `AgentJobError` / `isAgentJobError` (carries the partial `AgentResult`; the guard narrows the real error and rejects everything else).
1736
- - [`tests/src/core/conversations/stores/MemoryConversationStore.test.ts`](../tests/src/core/conversations/stores/MemoryConversationStore.test.ts) — the in-memory `ConversationStoreInterface` (`get` / `set` / `delete`, async, keyed by a snapshot's own id) over real `ConversationSnapshot`s carrying compacted sections (from a genuine `compact()`) + a live tail + a rollup `summary`: set→get round-trip + JSON-portability parity, upsert under the same id, delete + absent, two distinct ids coexist; and the `isToolCall` per-call guard (the fail-closed element check) — accepts the real `ToolCall` shape (string `id` / `name` + a record `arguments`), rejects every hostile shape (non-record, missing / wrong-typed `id` / `name` / `arguments`) without throwing.
1737
- - [`tests/src/core/validators.test.ts`](../tests/src/core/validators.test.ts) — `isMessage`, `isSection`, and `isConversationSnapshot` (the per-message, per-section, and total read-boundary guards): each accepts the real shape with and without its optionals, rejects a non-record / nullish / primitive without throwing, rejects a missing or wrong-typed required field, and rejects a malformed nested element (`calls`, `messages`, `sections`); `isConversationSnapshot` also accepts a JSON-revived snapshot (the storage-read shape the database store narrows) and rejects a snapshot whose assistant `calls[]` carries a tampered element.
1738
- - [`tests/src/core/conversations/stores/DatabaseConversationStore.test.ts`](../tests/src/core/conversations/stores/DatabaseConversationStore.test.ts) — the driver-pluggable twin over a real `createMemoryDriver` (no mocks): the same set→get round-trip through the one opaque JSON column (sections + tail + rollup summary survive), the default-driver factory overload (no arg) works the same, cross-instance durability (a second store over the same driver reads the snapshot back), upsert under the same id, delete + absent, two distinct ids coexisting, and a conversation store + a workspace store over separate drivers not colliding.
1739
- - [`tests/src/core/AgentRegistry.test.ts`](../tests/src/core/AgentRegistry.test.ts) — the registry in isolation (a scripted provider): the accessors resolve a registered `provider` / `tool` / `authority` / `scheduler` and throw a category-specific `unknown <category>: <name>` on a miss; `build` rehydrates a seeded, signal-wired agent — the seed `messages` + `system` reach the agent and its `build()`, the `tools` names resolve into a fresh per-build manager (an unknown name throws), a threaded pre-aborted `signal` commits a partial, a `budget` ceiling becomes a token budget that bounds the loop, and a resolved `limit` / `scheduler` / `authority` reach the rehydrated loop (the cap bounds it, the scheduler paces between turns, the authority denies a call without executing it).
1740
- - [`tests/src/core/Agent.test.ts`](../tests/src/core/Agent.test.ts) — the loop's deterministic logic over a local scripted `ProviderInterface`: a single no-tools turn → `generate` returns the content; the system prompt prepended + tools advertised structurally; tool iteration (turn 1's `ToolCall` → `execute` → the result fed back → turn 2's final content); a tool throw fed back as the tool message (the loop never throws); `generate` ↔ `stream` parity (same script → deep-equal); the `AgentChunk` sequence (`token` / `think` / `usage` / `tool` chunks, with the tool chunk carrying the executed call + result and usage summed); per-run `think` forwarding into the provider; the iteration cap (an always-tool script stops at `limit`); abort (a pre-aborted `signal` commits a partial without calling the provider; `abort()` mid-stream resolves `partial: true` with the accumulated content; a genuine provider error rejects); the token `budget` bound (exhausted usage stops the turn, `partial`); the `scheduler` yielding between turns (not after the last); `status` transitions; the authority gate wired into the loop (no authority → unchanged; an allowed call executes; a denied call is not executed [a counter tool proves it] yet a `tool` chunk + tool message carry the denial and the next provider call sees it; a mixed batch merges allowed + denied in original call order; an all-denied turn feeds back denials and the cap still bounds it); and the scope-filtered tool advertisement (no scope → all tools advertised; a `tools` allow-list → only the listed definitions reach the provider, a scoped-out tool absent on every turn so its handler never runs — neither described nor callable; an empty `tools` list → the provider is handed `undefined`); and automatic compaction (the context `window` budget): the between-turns trigger (the absolute prompt crossing `max` fires `compact()` + rebuilds smaller, exactly twice over the script, the run answering through the compacted view), the no-fire-below-the-window guard, and the additive regressions (no `window` ⇒ never folds; no conversation ⇒ the trigger is skipped + the budget untouched); and the production hardening (a long-conversation initial prompt compacted pre-first-turn so the first provider call sees the compacted view; a throwing auto-summarizer caught + surfaced as `fault` with the run continuing to a valid answer while a manual `compact()` still throws; the futile guard — a `compact()` that folds nothing while over the window latches per-run so auto-compaction stops, no churn); and the multi-conversation pattern (one agent + a `ConversationManager`, switching the active conversation with `agent.context.conversations.switch(id)` per request: independent accumulated histories with no cross-talk, and independent per-thread compaction whose sections retain only their own thread's originals).
1741
- - [`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`).
1742
- - [`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`).
1743
- - [`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.
1744
- - [`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.
1745
- - [`tests/src/core/AgentJudge.test.ts`](../tests/src/core/AgentJudge.test.ts) — the judge engine over a scripted wire subclass and real `Response` bodies: a minted `id` and the configured `model`; one call carrying every question when `batch` is omitted, and one call per question in key order with summed usage when it is `false`; the configured model and no usage when a response names neither; the global transport bound when `fetch` is omitted; an empty question map, a malformed question, and a state outside `JudgeEntry` refused with no call; every body built before the first call; the HTTP status with a bounded excerpt, an empty or unreadable error body, and a null or non-JSON body; a wire or transport failure passed through unchanged; the call's combined signal handed to the `headers` hook; and the cancellation partials — an already-aborted signal, an abort in the second call keeping the first call's answer and usage, a cancel during decoding with no cause, a stalled call aborted at its deadline, and an unresolved header hook cancelled.
1746
- - [`tests/src/core/providers/SystemOneJudge.test.ts`](../tests/src/core/providers/SystemOneJudge.test.ts) — the System One wire against the bodies recorded on 2026-10-07: the TypeSafe quickstart projected with no extra members, the exact recorded Ollama request posted once with authentication and its response decoded, a structured state and structured instructions preserved, the llama.cpp array form and Mica's server measures and extra fields decoded to the same answers, a gateway model name reported verbatim, unrequested answers ignored, and a malformed envelope, a missing, mismatched, or incomplete answer, and each recorded HTTP 400 refusal reported with its code.
1747
- - [`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()`.
1748
- - [`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.
1749
- - [`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.
2741
+ The following test files back this guide:
2742
+
2743
+ - [`tests/guides.test.ts`](../tests/guides.test.ts) — the Surface and method parity with `src/core`, the `Summary`, titled example, and pitch equality, and the executed flagship fences with the Contract clauses they back: scope timing, selection, judgments, modes, answer-only runs, recorded thinking and the replay policy, the ledger over a scripted provider and judge with its thinking budget, the classifier and the gauge, the relay over a loopback listener, and the System One judge over a recorded response.
2744
+ - [`tests/src/core/helpers.test.ts`](../tests/src/core/helpers.test.ts) — the root helpers `filterAllowList`, `joinThinking`, `stripThinking`, `sanitizeToken`, `sanitizeUsage`, `sumUsage`, `removeEntries`, `collectExchanges`, `hasCalls`, `collectToolGroups`, `renderJSON`, and `matchesJudgment`, and the `MESSAGE_ROLES` role list.
2745
+ - [`tests/src/core/cloners.test.ts`](../tests/src/core/cloners.test.ts) — `copyJSON` copying a proxied record where a structured clone refuses it and returning `undefined` for a value JSON can't carry.
2746
+ - [`tests/src/core/validators.test.ts`](../tests/src/core/validators.test.ts) — the root guards `isJudgeEntry`, `isJudgeQuestion`, and `isNonNegativeSafeInteger`, each total over hostile input.
2747
+ - [`tests/src/core/contracts.test.ts`](../tests/src/core/contracts.test.ts) — the message contract's round trip and malformed paths, and function-valued arguments accepted in the domain and refused on the wire.
2748
+ - [`tests/src/core/shapers.test.ts`](../tests/src/core/shapers.test.ts) — the message and tool-call wire shapes at the type level, with execution context kept off the wire.
2749
+ - [`tests/src/core/integration.test.ts`](../tests/src/core/integration.test.ts) — the in-process relay hop and provider-agnosticism, where a minimal provider drives the full loop and differently named providers swap in.
2750
+ - [`tests/src/core/providers/AgentProvider.test.ts`](../tests/src/core/providers/AgentProvider.test.ts) — the HTTP engine's request composition, stream assembly and settled results, bounded error body, header hook inside the cancellation bound, cancellation partials, cleanup on every exit, and isolation between concurrent calls.
2751
+ - [`tests/src/core/providers/RelayProvider.test.ts`](../tests/src/core/providers/RelayProvider.test.ts) — the browser end's owned request snapshot, wire refusals, frame mapping, remote abort reconstruction, and terminal-result recovery.
2752
+ - [`tests/src/core/providers/RelayStream.test.ts`](../tests/src/core/providers/RelayStream.test.ts) — the relay response half's upstream replay policy, validated frames, fixed error message, backpressure, and inbound-abort finalization.
2753
+ - [`tests/src/core/providers/ThinkSplitter.test.ts`](../tests/src/core/providers/ThinkSplitter.test.ts) — the `ThinkSplitter` state machine.
2754
+ - [`tests/src/core/providers/AgentJudge.test.ts`](../tests/src/core/providers/AgentJudge.test.ts) — the judge engine's identity and request composition, refusals before inference, HTTP and protocol failures, header hook, and cancellation partials.
2755
+ - [`tests/src/core/providers/SystemOneJudge.test.ts`](../tests/src/core/providers/SystemOneJudge.test.ts) — the System One wire against the bodies recorded on 2026-10-07.
2756
+ - [`tests/src/core/providers/factories.test.ts`](../tests/src/core/providers/factories.test.ts) — the `createThinkSplitter` factory and the authorization, body-limit, malformed-body, and upstream-construction refusals of `createRelay`.
2757
+ - [`tests/src/core/providers/helpers.test.ts`](../tests/src/core/providers/helpers.test.ts) — `buildProviderResult`, `armReaderAbort`, `readFailure`, `readText`, `readChunks`, `computeReading`, `buildJudgeResult`, `buildProviderHeaders`, `requestToRelay`, the System One helpers, and `releaseReader`.
2758
+ - [`tests/src/core/providers/contracts.test.ts`](../tests/src/core/providers/contracts.test.ts) — the request, result, and relay-frame contracts' round trips, malformed paths, and non-JSON refusals.
2759
+ - [`tests/src/core/providers/shapers.test.ts`](../tests/src/core/providers/shapers.test.ts) — the request, result, and relay wire shapes assignable to their domain types.
2760
+ - [`tests/src/core/providers/validators.test.ts`](../tests/src/core/providers/validators.test.ts) — the System One guards `isSystemOneResponse` and `isSystemOneAnswer` over recorded, transliterated, and hostile envelopes.
2761
+ - [`tests/src/core/conversations/Conversation.test.ts`](../tests/src/core/conversations/Conversation.test.ts) — the conversation's construction, `view`, `compact` with `keep` and its no-op and missing-summarizer cases, `rehydrate`, `search`, `reference`, `snapshot`, the `sections` cap, and hydration.
2762
+ - [`tests/src/core/conversations/ConversationManager.test.ts`](../tests/src/core/conversations/ConversationManager.test.ts) — the registry's accessors, active pointer and `switch`, removal, the inherited `summarize`, `keep`, and `sections` defaults, independence, the durable `open` / `save` seam, and a 0.0.29 snapshot read through `open` without its conversation `summary`.
2763
+ - [`tests/src/core/conversations/JudgmentManager.test.ts`](../tests/src/core/conversations/JudgmentManager.test.ts) — the keyed storage of `JudgmentManager` and its `resolve` method, which asks only unmatched questions, attaches one-question usage, records partial answers on abort, and restores from a snapshot.
2764
+ - [`tests/src/core/conversations/factories.test.ts`](../tests/src/core/conversations/factories.test.ts) — `createConversation`, `createConversationManager`, `createMemoryConversationStore`, and `createDatabaseConversationStore`, each driving the instance it returns.
2765
+ - [`tests/src/core/conversations/helpers.test.ts`](../tests/src/core/conversations/helpers.test.ts) — `buildJudgments`, `buildSummaryMessage`, `buildRecapMessage`, `requireJudgment`, `requireSectionsCap`, `requireKeep`, and `raceSignal`.
2766
+ - [`tests/src/core/conversations/validators.test.ts`](../tests/src/core/conversations/validators.test.ts) — `isMessage`, `isJudgment` with snapshot carriage, `isSection`, and `isConversationSnapshot`, each total over malformed input.
2767
+ - [`tests/src/core/conversations/stores/MemoryConversationStore.test.ts`](../tests/src/core/conversations/stores/MemoryConversationStore.test.ts) — the in-memory store's round trip, upsert, deletion, coexisting ids, JSON parity, and tool messages with and without `call`.
2768
+ - [`tests/src/core/conversations/stores/DatabaseConversationStore.test.ts`](../tests/src/core/conversations/stores/DatabaseConversationStore.test.ts) — the driver-backed store's round trip, upsert, deletion, coexisting ids, driver overloads, and durability across instances.
2769
+ - [`tests/src/core/contexts/AgentContext.test.ts`](../tests/src/core/contexts/AgentContext.test.ts) — the system boundary, freshness, and tool exclusion of `build()`, the context managers, workspace carriers and `scope.files`, scope filtering, format cascade, conversation message source and switch, `select` resolution, and `build(selection)`.
2770
+ - [`tests/src/core/contexts/factories.test.ts`](../tests/src/core/contexts/factories.test.ts) — `createInstruction`, `createInstructionManager`, `createScope`, `createScopeManager`, and `createAgentContext`.
2771
+ - [`tests/src/core/contexts/helpers.test.ts`](../tests/src/core/contexts/helpers.test.ts) — `renderFencedFile`, `renderSection`, `attachImages`, `attachUserImages`, `collectImageData`, and `intersectKeys`.
2772
+ - [`tests/src/core/contexts/instructions/Instruction.test.ts`](../tests/src/core/contexts/instructions/Instruction.test.ts) — the minted id, default priority, carried override, and immutability of `Instruction`.
2773
+ - [`tests/src/core/contexts/instructions/InstructionManager.test.ts`](../tests/src/core/contexts/instructions/InstructionManager.test.ts) — the instruction registry's lookup, ordering, build contract, manager-options format, removal, and emitter.
2774
+ - [`tests/src/core/contexts/scopes/Scope.test.ts`](../tests/src/core/contexts/scopes/Scope.test.ts) — `Scope` construction, the set intersection of `narrow`, and the `select` handler travelling with the scope.
2775
+ - [`tests/src/core/contexts/scopes/ScopeManager.test.ts`](../tests/src/core/contexts/scopes/ScopeManager.test.ts) — the scope registry's add, lookup, removal, and emitter.
2776
+ - [`tests/src/core/agents/Agent.test.ts`](../tests/src/core/agents/Agent.test.ts) — the loop over a scripted provider: the recorded-request control, scope dispatch, tool iteration, authority, `generate` and `stream` parity, chunks, bounds, cancellation timing, the emitter, automatic compaction, recorded thinking and the replay policy, multi-conversation runs, limit exhaustion, budget reconciliation, per-run options, the concurrency guard, usage sanitizing, the selection seam, the turn records, and the three failure layers.
2777
+ - [`tests/src/core/agents/AgentRegistry.test.ts`](../tests/src/core/agents/AgentRegistry.test.ts) — the registry's accessors, rehydrating `build` and its field wiring, isolation, the conversation store seam, and an empty registry.
2778
+ - [`tests/src/core/agents/Authority.test.ts`](../tests/src/core/agents/Authority.test.ts) — first-match-wins ordering, a matched rule's default allow, the fallback, and the `{ call }` context.
2779
+ - [`tests/src/core/agents/Channel.test.ts`](../tests/src/core/agents/Channel.test.ts) — the channel's lost-wakeup, `undefined` values, buffer-before-close, `fail`, concurrent readers, push after end, FIFO order, and end semantics.
2780
+ - [`tests/src/core/agents/factories.test.ts`](../tests/src/core/agents/factories.test.ts) — `createChannel`, `createAgent`, `createAgentRegistry`, `createAgentQueue` with its durability, partial policy, and lifecycle, `createAgentRunner` with fan-out, and the `AgentJobError` / `isAgentJobError` block.
2781
+ - [`tests/src/core/agents/helpers.test.ts`](../tests/src/core/agents/helpers.test.ts) — `agentResultToJSON` with its `stop` projection, `tokenUsageToJSON`, `inferStop`, `requireBudget`, `estimateMessages`, `estimateRequest`, `measureMessage`, `measureRequest`, `settleAgentJob`, `assembleResult`, `denyCall`, and `chargeUsage`.
2782
+ - [`tests/src/core/agents/AgentFanout.test.ts`](../tests/src/core/agents/AgentFanout.test.ts) — `AgentFanout` through a real runner: one fan-out per unit across a retried attempt, every distinct unit's children, and the partial policy.
2783
+ - [`tests/src/core/ledgers/Ledger.test.ts`](../tests/src/core/ledgers/Ledger.test.ts) — the ledger over a scripted provider: owner records, seed stubs paired with their calls by call id and priced at their longer form, and the seed tail, the `'REQUEST'` fault of a run no active `respond` call owns, the repeat stop, the answer pass and the abort that skips it, calibration with its replay at the next user boundary and its refusals, the filing of notes and failed lookups, the `recall` tool's listing, limit, and room, the answer note's lines, the generation cap and thinking per pass, and the stale sentences the briefing drops while `recall`, the answer note, and the seed tail keep stored content.
2784
+ - [`tests/src/core/ledgers/LedgerRequest.test.ts`](../tests/src/core/ledgers/LedgerRequest.test.ts) — the request's recall-limit refusal, its single `enter`, its passes with their selections, usage, calls, and thinking, its filing readings and plan, the repeat stop, the recall limit and `close`, and the listings of a recall result.
2785
+ - [`tests/src/core/ledgers/Classifier.test.ts`](../tests/src/core/ledgers/Classifier.test.ts) — the measured question bytes and asking order, judgment reuse and usage, exact cutoffs, pair questions, deterministic and transient judge failures, abort faults that keep the completed judgments, and the cached, skipped, resolved, failed, and aborted readings each of those paths records.
2786
+ - [`tests/src/core/ledgers/Gauge.test.ts`](../tests/src/core/ledgers/Gauge.test.ts) — the gauge's construction refusals and its `observe`, `rate`, `remainder`, `reserve`, and `room` methods, under the generation cap and each replay policy.
2787
+ - [`tests/src/core/ledgers/factories.test.ts`](../tests/src/core/ledgers/factories.test.ts) — `createLedger` and its `LedgerError` refusals for thresholds, shares, capacity, limits, topics, lookups, and a supplied gauge, and the barrel exports of the ledger module.
2788
+ - [`tests/src/core/ledgers/helpers.test.ts`](../tests/src/core/ledgers/helpers.test.ts) — the projection helpers from `splitSentences` through `buildRecords` and `selectRecords`, the record renderers, `findLedgerCall`, `rankLedgerCut`, `splitTopic`, `cutListing` and `matchesCutLine`, `renderStub`, `normalizeArgument`, `splitResultLines`, `renderLookupCall`, `computeThinking`, `resolvePredict`, and `fitSlope`.
1750
2789
 
1751
2790
  ### Placement proofs
1752
2791
 
1753
2792
  Each placement has a named proof location:
1754
2793
 
1755
2794
  - **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).
1756
- - **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.
1757
- - **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.
2795
+ - **Page alone.** The agent, an in-page provider, and page tools run in the page. The receipt is planned in the `tests/distribution.test.ts` file of the `@orkestrel/mcp` checkout, 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 doesn't record it as passed.
2796
+ - **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 doesn't record that Chromium receipt as passed. The guide transcription's HTTP hop runs in Node and proves that narrower placement.
1758
2797
 
1759
2798
  ## See also
1760
2799
 
1761
- - [`budget.md`](budget.md) — the cost primitive; `ProviderResult.usage` and `AgentResult.usage` reuse its `TokenUsage`, and a token budget bounds a provider call / an agent turn.
1762
- - [`abort.md`](abort.md) / [`timeout.md`](timeout.md) — the bounding signals folded into a call's `AbortSignal` through `AbortSignal.any`; `@orkestrel/timeout` is also the deadline `AgentProvider` arms for every call it makes.
1763
- - `@orkestrel/ndjson` — the newline-delimited JSON parser a relay browser application hands `createRelayProvider` as its `parser`. This package declares no dependency on it, so the application chooses the parser.
2800
+ The following guides and packages relate to this one:
2801
+
2802
+ - [Budget](budget.md) — the cost primitive; `ProviderResult.usage` and `AgentResult.usage` reuse its `TokenUsage`, and a token budget bounds a provider call / an agent turn.
2803
+ - [Abort](abort.md) / [Timeout](timeout.md) — the bounding signals folded into a call's `AbortSignal` through `AbortSignal.any`; `@orkestrel/timeout` is also the deadline `AgentProvider` arms for every call it makes.
1764
2804
  - `@orkestrel/router` — one dispatcher a `RelayHandler` mounts on. The handler is fetch-standard, so any router that routes a `Request` to a `Response` works.
1765
- - [`queue.md`](queue.md) — the bounded-concurrency, retrying, durable `Queue` `createAgentQueue` composes for many agent jobs.
1766
- - [`workflow.md`](workflow.md) — the `SchedulerInterface` the loop yields to between turns, and the fail-fast `Runner` `createAgentRunner` composes for sub-agent fan-out.
1767
- - [`emitter.md`](emitter.md) — the foundational observable primitive the `Agent` owns as its push `emitter`; `AgentEventMap` is its event map, wired through the reserved `on` option.
1768
- - [`tool.md`](tool.md) — the tool runtime this loop advertises from and dispatches through: definitions, calls, and the success-discriminated `ToolResult`.
1769
- - [`workspace.md`](workspace.md) — the file domain whose active workspace `AgentContext` renders into a turn: files, editing, events, and persistence.
1770
- - [`contract.md`](contract.md) — the shape DSL other tools (for example `@orkestrel/toolbox`'s `createWorkspaceTool`) compile against; the shared `describedLiteral` (a discriminant's description-carrier) and `schemaToParameters` (the tool-parameters narrowing) live there.
1771
- - [`database.md`](database.md) — the `DriverInterface` / `TableInterface` seam `createDatabaseConversationStore` persists conversation snapshots through.
1772
- - [`AGENTS.md`](../AGENTS.md) — the repository's authority pointer; the coding rules it resolves to live in `@orkestrel/scaffold`.
1773
- - [`README.md`](README.md) — the guides index.
2805
+ - [Queue](queue.md) — the bounded-concurrency, retrying, durable `Queue` `createAgentQueue` composes for many agent jobs.
2806
+ - [Workflow](workflow.md) — the `SchedulerInterface` the loop yields to between turns, and the fail-fast `Runner` `createAgentRunner` composes for sub-agent fan-out.
2807
+ - [Emitter](emitter.md) — the foundational observable primitive the `Agent` owns as its push `emitter`; `AgentEventMap` is its event map, wired through the reserved `on` option.
2808
+ - [Tool](tool.md) — the tool runtime this loop advertises from and dispatches through: definitions, calls, and the success-discriminated `ToolResult`.
2809
+ - [Workspace](workspace.md) — the file domain whose active workspace `AgentContext` renders into a turn: files, editing, events, and persistence.
2810
+ - [Contract](contract.md) — the shape DSL other tools (for example the `createWorkspaceTool` function of the `@orkestrel/toolbox` package) compile against; the shared `describedLiteral` (a discriminant's description-carrier) and `schemaToParameters` (the tool-parameters narrowing) live there.
2811
+ - [Database](database.md) — the `DriverInterface` / `TableInterface` seam `createDatabaseConversationStore` persists conversation snapshots through.
2812
+ - [AGENTS.md](../AGENTS.md) — the repository's authority pointer; the coding rules it resolves to live in `@orkestrel/scaffold`.
2813
+ - [Guides](README.md) — the guides index.