@arnilo/prism 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/CHANGELOG.md +62 -1
  2. package/README.md +13 -12
  3. package/dist/agent-approval.d.ts +17 -2
  4. package/dist/agent-approval.js +15 -6
  5. package/dist/agent-event-source.d.ts +9 -1
  6. package/dist/agent-event-source.js +10 -3
  7. package/dist/agent-loops.js +7 -4
  8. package/dist/agent-run-lifecycle.d.ts +15 -1
  9. package/dist/agent-run-lifecycle.js +82 -11
  10. package/dist/agent-run-state.d.ts +47 -6
  11. package/dist/agent-run-state.js +154 -6
  12. package/dist/agent-session/event-subscriber.d.ts +2 -0
  13. package/dist/agent-session/event-subscriber.js +3 -0
  14. package/dist/agent-session/helpers.js +14 -0
  15. package/dist/agent-session/session/assemble.js +281 -32
  16. package/dist/agent-session/session/persist.d.ts +11 -0
  17. package/dist/agent-session/session/persist.js +48 -16
  18. package/dist/agent-session/session/provider-round.d.ts +14 -4
  19. package/dist/agent-session/session/provider-round.js +226 -19
  20. package/dist/agent-session/session/tool-round.d.ts +2 -2
  21. package/dist/agent-session/session/tool-round.js +78 -6
  22. package/dist/agent-session/session/types.d.ts +44 -3
  23. package/dist/agent-session/session.d.ts +100 -5
  24. package/dist/agent-session/session.js +224 -13
  25. package/dist/attention-compiler.d.ts +51 -2
  26. package/dist/attention-compiler.js +282 -21
  27. package/dist/cache-helpers.d.ts +4 -2
  28. package/dist/cache-helpers.js +8 -6
  29. package/dist/checkpoint-restore.d.ts +45 -0
  30. package/dist/checkpoint-restore.js +54 -0
  31. package/dist/context-budget.d.ts +13 -1
  32. package/dist/context-budget.js +57 -4
  33. package/dist/contracts-core/agent.d.ts +52 -1
  34. package/dist/contracts-core/attention.d.ts +95 -0
  35. package/dist/contracts-core/content.d.ts +10 -0
  36. package/dist/contracts-core/extensions.d.ts +3 -0
  37. package/dist/contracts-core/guardrail-packs.d.ts +46 -0
  38. package/dist/contracts-core/guardrail-packs.js +2 -0
  39. package/dist/contracts-core/loop.d.ts +36 -0
  40. package/dist/contracts-core/provider.d.ts +30 -0
  41. package/dist/contracts-core/run-limits.d.ts +29 -1
  42. package/dist/contracts-core/session.d.ts +23 -5
  43. package/dist/contracts-core/session.js +21 -2
  44. package/dist/contracts-core/usage.d.ts +40 -0
  45. package/dist/contracts-core/usage.js +8 -0
  46. package/dist/contracts-core.d.ts +2 -0
  47. package/dist/contracts-core.js +2 -0
  48. package/dist/contracts-protocol.d.ts +81 -5
  49. package/dist/contracts-run-state.d.ts +91 -2
  50. package/dist/contributions.d.ts +2 -1
  51. package/dist/contributions.js +1 -0
  52. package/dist/extensions.d.ts +15 -1
  53. package/dist/extensions.js +68 -0
  54. package/dist/guardrail-packs/coding-standard.d.ts +3 -0
  55. package/dist/guardrail-packs/coding-standard.js +63 -0
  56. package/dist/guardrail-packs/destructive-commands.d.ts +3 -0
  57. package/dist/guardrail-packs/destructive-commands.js +46 -0
  58. package/dist/guardrail-packs/errors.d.ts +7 -0
  59. package/dist/guardrail-packs/errors.js +9 -0
  60. package/dist/guardrail-packs/index.d.ts +4 -0
  61. package/dist/guardrail-packs/index.js +15 -0
  62. package/dist/guardrail-packs/secrets-hygiene.d.ts +3 -0
  63. package/dist/guardrail-packs/secrets-hygiene.js +23 -0
  64. package/dist/guardrail-packs/types.d.ts +26 -0
  65. package/dist/guardrail-packs/types.js +2 -0
  66. package/dist/guardrail-packs/validation-respect.d.ts +3 -0
  67. package/dist/guardrail-packs/validation-respect.js +69 -0
  68. package/dist/guardrails.d.ts +61 -1
  69. package/dist/guardrails.js +377 -0
  70. package/dist/index.d.ts +16 -11
  71. package/dist/index.js +10 -7
  72. package/dist/input.d.ts +8 -1
  73. package/dist/input.js +68 -6
  74. package/dist/middleware.d.ts +37 -2
  75. package/dist/middleware.js +41 -0
  76. package/dist/node/session-store-jsonl.js +18 -3
  77. package/dist/observability.js +6 -0
  78. package/dist/provider-events.d.ts +8 -2
  79. package/dist/provider-events.js +60 -2
  80. package/dist/providers/openai-compatible.js +6 -3
  81. package/dist/run-bundle.d.ts +6 -1
  82. package/dist/run-bundle.js +5 -1
  83. package/dist/run-limits.d.ts +11 -1
  84. package/dist/run-limits.js +59 -0
  85. package/dist/session-stores.d.ts +12 -1
  86. package/dist/session-stores.js +21 -4
  87. package/dist/testing/agent-event-source-conformance.js +41 -2
  88. package/dist/testing/prefix-stability-conformance.d.ts +59 -0
  89. package/dist/testing/prefix-stability-conformance.js +172 -0
  90. package/dist/testing/session-store-conformance.d.ts +3 -2
  91. package/dist/testing/session-store-conformance.js +48 -0
  92. package/dist/tools.d.ts +5 -0
  93. package/dist/tools.js +21 -6
  94. package/dist/usage-estimation.d.ts +29 -0
  95. package/dist/usage-estimation.js +79 -0
  96. package/docs/agent-events.md +75 -4
  97. package/docs/agent-session-runtime.md +10 -6
  98. package/docs/attention-compiler.md +89 -8
  99. package/docs/caveman.md +1 -1
  100. package/docs/coding-agent-tools.md +1 -1
  101. package/docs/compaction-and-retry.md +1 -1
  102. package/docs/compaction-llm.md +2 -0
  103. package/docs/compaction-observational-memory.md +54 -7
  104. package/docs/durable-runs.md +46 -3
  105. package/docs/embeddings.md +9 -0
  106. package/docs/evaluations.md +5 -0
  107. package/docs/execution-timeline.md +79 -1
  108. package/docs/extensions.md +20 -3
  109. package/docs/guardrails.md +50 -4
  110. package/docs/hooks.md +282 -0
  111. package/docs/index.md +37 -15
  112. package/docs/input-and-prompt-assembly.md +4 -4
  113. package/docs/instruction-injection.md +1 -0
  114. package/docs/knowledge-sync.md +4 -0
  115. package/docs/live-testing.md +3 -1
  116. package/docs/memory-fabric.md +28 -0
  117. package/docs/middleware-hooks.md +90 -4
  118. package/docs/migrate-to-0.9.md +210 -0
  119. package/docs/migration.md +26 -0
  120. package/docs/multi-agent-patterns.md +25 -2
  121. package/docs/node-jsonl-session-store.md +7 -1
  122. package/docs/observability.md +7 -3
  123. package/docs/options-index.md +4 -1
  124. package/docs/policy-and-audit.md +26 -1
  125. package/docs/prefix-stability-conformance.md +143 -0
  126. package/docs/provider-caching.md +4 -4
  127. package/docs/provider-conformance.md +16 -0
  128. package/docs/provider-packages.md +20 -20
  129. package/docs/public-contracts.md +3 -2
  130. package/docs/rag.md +188 -3
  131. package/docs/release-and-install.md +45 -40
  132. package/docs/runs-and-usage.md +56 -10
  133. package/docs/scoped-agent-memory.md +270 -0
  134. package/docs/scoped-memory.md +138 -0
  135. package/docs/session-store-conformance.md +1 -2
  136. package/docs/session-stores.md +17 -17
  137. package/docs/supervisors.md +32 -12
  138. package/docs/tools.md +18 -1
  139. package/docs/wiki.md +4 -2
  140. package/docs/workflows.md +5 -0
  141. package/package.json +8 -2
@@ -10,7 +10,7 @@ Events are emitted by the runtime and by loops through `LoopContext.emit`, both
10
10
 
11
11
  ## When to use it
12
12
 
13
- Subscribe via `session.stream()` for a single owned run, or `session.subscribe()` when a host needs a long-lived observer across runs: render streamed assistant text in a UI, react to tool execution, drive observability/telemetry, or audit artifact validation outcomes. Do not parse provider stream events directly for these — `AgentEvent` is the stable, normalized surface across providers and loops.
13
+ Subscribe via `session.stream()` for a single owned run, or `session.subscribe({ acrossRuns: true })` when a host needs a long-lived observer across runs (a default `session.subscribe()` is run-scoped: run end — finish, suspension, or denial — closes it): render streamed assistant text in a UI, react to tool execution, drive observability/telemetry, or audit artifact validation outcomes. Do not parse provider stream events directly for these — `AgentEvent` is the stable, normalized surface across providers and loops.
14
14
 
15
15
  Do not use live `session.subscribe()` for cross-replica reconnect — use durable `AgentEventSource` below. Live subscribe remains process-local.
16
16
 
@@ -24,6 +24,8 @@ Event records preserve emission order within a run because the runtime drains pe
24
24
 
25
25
  `AgentEventSource` (`createMemoryAgentEventSource` / `persistence.events` on PostgreSQL) appends, pages, and subscribes with opaque ownership-bound cursors. `subscribe` registers wake interest before replaying history so replay-to-live handoff has no gap. Delivery is at-least-once; consumers dedupe `record.id`. PostgreSQL uses transactional sequence allocation plus `LISTEN`/`NOTIFY` wakeups with polling fallback. Transport adapters (server SSE `Last-Event-ID`, AG-UI, A2A `afterEventId`) map source envelopes only — they do not invent private replay loops. This is not exactly-once.
26
26
 
27
+ Exactly three event types are terminal — `agent_finished`, `agent_denied`, and `error` — and one exported predicate answers the question for every consumer: `isTerminalAgentEventType(type)`. The memory, NATS, and Postgres sources, AG-UI replay, the A2A stream break, AG-UI `filterRun`, and conversation replay all route through it, so pages, subscriptions, and replays end on the same set. Attribution records such as `run_limit_exceeded` and `budget_exhausted` are not terminal (see [run limit events](#run-limit-events)).
28
+
27
29
  ### Placement (FR-7 answer, 0.0.26)
28
30
 
29
31
  The durable `AgentEventSource` **stays in `@arnilo/prism-core/sessions/postgres`** for the 0.0.26 line and is importable from the package root (FR-6):
@@ -72,12 +74,15 @@ The `AgentEvent` union (grouped by concern):
72
74
  | --- | --- |
73
75
  | Agent lifecycle | `agent_started`, `agent_suspended`, `agent_resumed`, `agent_denied`, `agent_finished` |
74
76
  | Turns | `turn_started`, `turn_finished` |
77
+ | Deterministic turns | `deterministic_turn` |
75
78
  | Provider turns | `provider_turn_started`, `provider_turn_finished` |
76
79
  | Assistant messages | `message_started`, `message_delta`, `message_finished` |
77
80
  | Delegated agents | `delegated_agent_step` |
78
81
  | Tool execution | `tool_execution_started`, `tool_execution_progress`, `tool_execution_finished`, `tool_execution_error`, `tool_execution_blocked` |
82
+ | Tool narrowing | `tool_narrowing_clamped` |
79
83
  | Guardrails | `guardrail_decision` |
80
84
  | Queue/subscribers | `queue_updated`, `event_subscriber_overflow`, `steer_rejected` |
85
+ | Run limits | `run_limit_exceeded`, `budget_exhausted` |
81
86
  | Compaction | `compaction_started`, `compaction_finished` |
82
87
  | Retry | `retry_scheduled` |
83
88
  | Artifacts | `artifact_validation_started`, `artifact_validation_finished`, `artifact_revision_started`, `artifact_finished`, `artifact_failed` |
@@ -92,7 +97,7 @@ Agent / turn / message events:
92
97
  | Variant | Fields |
93
98
  | --- | --- |
94
99
  | `agent_started` | `sessionId`, `runId` |
95
- | `agent_finished` | `sessionId`, `runId`, `usage?: Usage` (aggregate of all usage-bearing provider turns), `finishReason?: "turn_limit" \| "token_limit" \| "refusal"` (why a limit/ceiling ended the run cleanly — F4; absent = natural end) |
100
+ | `agent_finished` | `sessionId`, `runId`, `usage?: Usage` (aggregate of all usage-bearing provider turns), `finishReason?: "turn_limit" \| "token_limit" \| "refusal" \| "host_policy" \| "hook_limit"` (why a limit/ceiling/hook cap ended the run cleanly — F4, plan 106 R1; absent = natural end) |
96
101
  | `agent_suspended` | `sessionId`, `runId`, redacted `interruption`, checkpoint `version`; no tool side effect has started. |
97
102
  | `agent_resumed` | `sessionId`, `runId`, checkpoint `version`. |
98
103
  | `agent_denied` | `sessionId`, `runId`, redacted `interruption`, checkpoint `version`; no tool side effect runs. |
@@ -107,6 +112,8 @@ Adapters should call `createDelegatedAgentStep({ sessionId, runId, adapterId, ex
107
112
 
108
113
  Coding hosts call `observeSupervisorLifecycle(supervisor, { onEvent, delegatedAgentStep })` to turn supervisor milestones into `subagent_started` / `subagent_stopped` coding lifecycle events. Both carry only redacted `childId`, `delegationId`, and `depth`; stopped events add terminal `AgentRunStatus`. Supplying `delegatedAgentStep` emits the bounded `delegated_agent_step` records AG-UI already maps. Child inputs, outputs, paths, and delegation error text never cross either bridge.
109
114
 
115
+ Supervisor child reporting is opt-in per child (`SupervisorChild.policy.report` ceiling; a request can only lower it). With `report: "milestones"` the supervisor publishes `child_milestone` (`childId`, `delegationId`, `depth`, `turn`, redacted `childEvent`) at the configured `milestone.everyTurns` cadence or host predicate; with `report: "stream"` it publishes `delegation_child_event` for every per-turn provider/tool/turn child event (never per-token `message_delta`). Both are redacted, count/byte-capped, and rate-coalesced (`delegation_child_events_coalesced` reports dropped events); the cap marker is `delegation_child_events_capped`. `child_failed` carries failure attribution for any child that died on an error or a limit: the redacted `reason`, the terminal `status`/`stopReason`, and the plan-086/087 `RunLimitBreach` in `limit` when a configured ceiling fired. Host cancels, policy denials, and hook rejections are not failures and never emit it. Hosts that want recovery counters rather than events read `supervisor.summary()` (`attempts`, `retries`, `failures`, `failureRadius`, `outcome` per child). Child events stay on the supervisor stream unless the host passes `childEventSink`, which receives the identical payload tagged with `child: { childId, delegationId, depth }` (`ChildEventOrigin`) for routing onto a parent session stream; they are not native `AgentEvent`s of the parent session, and hosts that surface them there re-attach the parent `sessionId`/`runId` themselves if needed.
116
+
110
117
  `message_delta.content.type === "tool_call_delta"` carries `{ index, id?, name?, argumentsText? }`. Treat it as a streaming fragment. The runtime reconstructs and persists a final `tool_call` before executing tools. Deltas missing `id`/`name` at stream end fail the provider turn with `ErrorInfo.code: "incomplete_delta"` (typed `ProviderTransportError`); they never throw a bare `Error`. Malformed JSON with id+name present recovers as a blocked tool result (`invalid_json_arguments`) instead.
111
118
 
112
119
  Tool execution events:
@@ -117,7 +124,8 @@ Tool execution events:
117
124
  | `tool_execution_progress` | `sessionId`, `runId`, `toolCallId`, `name`, `progress?`, `metadata?` |
118
125
  | `tool_execution_finished` | `sessionId`, `runId`, `result: ToolResult`, `metadata: ToolExecutionMetadata` |
119
126
  | `tool_execution_error` | `sessionId`, `runId`, `call: ToolCallContent`, `error: ErrorInfo`, `metadata: ToolExecutionMetadata` |
120
- | `tool_execution_blocked` | `sessionId`, `runId`, `toolCallId`, `name`, `reason: string`, `error: ErrorInfo`, `metadata: ToolExecutionMetadata` |
127
+ | `tool_execution_blocked` | `sessionId`, `runId`, `toolCallId`, `name`, `reason: string` (machine code, e.g. `guardrail_blocked`), `error: ErrorInfo` (model-visible text — for a pack rule `Blocked by guardrail rule pack:<pack>/<rule>`, bounded and redacted), `metadata: ToolExecutionMetadata` |
128
+ | `tool_narrowing_clamped` | `sessionId`, `runId`, `turn`, `dropped: readonly string[]` (names the host returned outside the run grant; no tool args) |
121
129
 
122
130
  Guardrail events:
123
131
 
@@ -139,12 +147,73 @@ Queue / subscriber / compaction / retry / provider events:
139
147
  | `attention_compiled` | `sessionId`, `runId?`, `used: number`, `usedAfter: number`, `inputCap: number`, `triggerRatio: number`, `droppedThinkingTurns: number`, `stubbedToolResults: number`, `stubbedBytes: number`, `truncated: boolean` — one per mutated turn of the opt-in [attention compiler](attention-compiler.md); counts only, never message text |
140
148
  | `retry_scheduled` | `sessionId`, `runId`, `attempt: number`, `delayMs: number`, `error: ErrorInfo` |
141
149
 
150
+ ### Run limit events
151
+
152
+ Terminal attribution — see [Runs and usage § Run limits](runs-and-usage.md#run-limits).
153
+
154
+ | Variant | Fields |
155
+ | --- | --- |
156
+ | `run_limit_exceeded` | `sessionId`, `runId`, `breach: RunLimitBreach` (`limit`, `maximum`, `observed`, optional `currency`) — emitted once, when an axis first exceeds its cap |
157
+ | `budget_exhausted` | `sessionId`, `runId`, `limit: RunLimitName`, `consumed: { turns, inputTokens, providerAttempts, requestBytes }`, `closestOtherAxes: [{ axis, usedRatio }]`, `recentToolCalls: [{ id, name, argHash }]` |
158
+
159
+ `budget_exhausted` is the terminal attribution for a run that died on a limit: it is emitted once per
160
+ limit death, before the terminal `error` event and the finish `RunRecord`. A limit death therefore
161
+ delivers three records in order — `run_limit_exceeded` (breach), `budget_exhausted` (attribution),
162
+ then the terminal `error` — and a page, subscription, or replay stays open across the first two:
163
+ keep reading until the stream ends rather than stopping at the first breach record. `limit` names the axis that fired
164
+ (`maxTurns`, `maxInputTokens`, `maxCost`, …). `closestOtherAxes` is the three other finite product
165
+ axes with the highest `used / cap` ratio, so a host can answer "how close was everything else";
166
+ request/response byte axes stay out because their caps are per-frame, and `usedRatio` is clamped to
167
+ `[0, 1]`. `recentToolCalls` holds the last ten host tool calls dispatched in this run (in dispatch
168
+ order, reset at run start and after a durable resume) as id, name, and `argHash` —
169
+ `sha256:<64 hex>` over the canonicalized arguments, never the arguments themselves. `consumed`
170
+ counters are the run-lifetime tracker snapshot at exhaustion. Events stay counts and hashes only, so
171
+ no new redaction class is introduced. Both events project onto the [execution timeline](execution-timeline.md)
172
+ as `timeline.exhaustion` plus the `turns[i].stopReason` badges, with a one-line summary on
173
+ `summarizeTimeline().exhaustion`.
174
+
142
175
  Provider turn events (metadata only — see [Observability](observability.md)):
143
176
 
144
177
  | Variant | Fields |
145
178
  | --- | --- |
179
+ | `deterministic_turn` | `sessionId`, `runId`, `turn`, `middleware` — host middleware answered this turn without a provider request ([Middleware hooks](middleware-hooks.md#no-model-turns-beforeproviderturn)). Carries no `usage` key: provider accounting stays absent, never zero-filled. The same provenance reaches the persisted transcript as `message.metadata.deterministic = { middleware }` on the assistant `message_finished` message. |
146
180
  | `provider_turn_started` | `sessionId`, `runId`, `turn`, `metadata: ProviderTurnMetadata` |
147
- | `provider_turn_finished` | `sessionId`, `runId`, `turn`, `metadata` (includes `latencyMs` on finish), `usage?`, `error?` |
181
+ | `provider_turn_finished` | `sessionId`, `runId`, `turn`, `metadata` (includes `latencyMs`, `stopReason`, `budgets`, `tools`, and provider-reported `cache` metrics on finish), `usage?`, `error?` |
182
+
183
+ `provider_turn_finished.metadata.stopReason` names why that provider turn stopped, from one closed
184
+ taxonomy. Adapters map native wire values (`finish_reason`, `stop_reason`, `finishReason`, Converse
185
+ `stopReason`) through the shared `mapProviderStopReason` table, so a new provider value degrades to
186
+ `unknown` instead of failing a run; the normalized `done` provider event carries the same mapped
187
+ value when the adapter saw a native reason.
188
+
189
+ | `stopReason` | Meaning |
190
+ | --- | --- |
191
+ | `end_turn` | Model finished its answer (native `stop`, `end_turn`, `stop_sequence`, `STOP`, `completed`) |
192
+ | `tool_calls` | Turn requested host tools; also what a generic `end_turn` becomes when the turn produced tool calls |
193
+ | `max_output_tokens` | Output truncated at the provider's token cap (native `length`, `max_tokens`, `MAX_TOKENS`) |
194
+ | `content_filter` | Provider safety/refusal path (native `content_filter`, `refusal`, `SAFETY`, `guardrail_intervened`) |
195
+ | `abort` | The run or turn was aborted (host abort, steer soft interrupt) |
196
+ | `provider_error` | The turn failed with a provider error |
197
+ | `unknown` | Unmapped or absent native reason |
198
+
199
+ `provider_turn_finished.metadata.budgets` is an O(1) snapshot from the run limit tracker:
200
+ `{ inputTokens?, inputTokensSource?, inputCap?, runInputBudget?, runInputUsed, turns, maxTurns }` —
201
+ current-turn charged input tokens (provider-reported, or the labeled fallback estimate when the
202
+ provider reported none) against the resolved per-request input cap, cumulative run input against
203
+ `limits.maxInputTokens`, and provider turns against `limits.maxTurns` (`null` when disabled).
204
+ `inputTokensSource` is `"reported"` or `"estimated"` and is absent together with `inputTokens`.
205
+ Optional fields are absent when the provider reported no usage or no input cap can be derived; hosts
206
+ that ignore the fields are unaffected.
207
+
208
+ `provider_turn_started` / `provider_turn_finished` metadata includes `tools: { count, idsHash }` for the
209
+ effective menu sent on that request (after run scoping, per-turn `toolNarrowing`, and disclosure).
210
+ `idsHash` is `sha256:` plus 64 lowercase hex over `JSON.stringify(names)` in request order. Count and
211
+ hash only — never tool args, schemas, or descriptions. Identical consecutive subsets keep the same hash.
212
+
213
+ `provider_turn_finished.metadata.cache` is present only when the provider reported
214
+ `cacheReadTokens` or `cacheWriteTokens`: `{ cacheReadTokens?, cacheWriteTokens?, hitRate? }`.
215
+ `hitRate` is cache reads divided by reported input tokens. Unknown cache usage is absent, never
216
+ zero-filled; it contains counts only, never cache keys or prompt content.
148
217
 
149
218
  Artifact validation/refinement events (emitted only by `generateValidateReviseLoop`; `singleShotLoop` emits zero artifact events):
150
219
 
@@ -231,6 +300,8 @@ for await (const event of session.stream("draft", { loop: { strategy: "generate-
231
300
 
232
301
  ## Extension and configuration notes
233
302
 
303
+ Extension packages can subscribe to lifecycle events on the extension bus; `forwardAgentEvents(session.subscribe(), kernel.events)` maps this stream onto that bus as read-only notifications (`agent_started` → `before_agent_start`, turns → `turn`, tool execution → `tool_call`/`tool_result`). See [Extension kernel and event bus](extensions.md) and [Hooks](hooks.md).
304
+
234
305
  - All events flow through `redactAgentEvent(event, activeRedactor)` before subscribers observe them. Configure `AgentConfig.redactor` / `RunOptions.redactor` via `createSecretRedactor([...knownSecretStrings])` so secret values are redacted in `message` content, `errors[].message`, `metadata`, and artifact `result`/`failure` payloads.
235
306
  - The artifact variants are emitted only by `generateValidateReviseLoop`. `singleShotLoop` (the default when no `AgentConfig.loop` / `RunOptions.loop` is set) emits zero artifact events. See [Agent loops](agent-loops.md).
236
307
  - Subscribers are in-process; the broadcaster is in-memory and live-only. Multiple `subscribe()` calls receive the same stream. `resumeAgentRunStream()` and `AgentRunLifecycle.resumeStream()` subscribe before resumed execution and yield only their selected durable `runId`; approval emits the normal `agent_started` then `agent_resumed` envelope, denial emits only `agent_denied`.
@@ -12,7 +12,9 @@ The agent/session runtime adds the minimal shared SDK surface for running provid
12
12
  - `session.prompt(input, options)` → `AgentRunResult`
13
13
  - `session.stream(input, options)` → owned-run `AsyncIterable<AgentEvent>`
14
14
  - `session.compact(options?)`
15
+ - `session.contextMeter()` → `ContextMeter` (latest provider-turn input tokens, reported or labeled estimate, with cap/budget/ratio)
15
16
  - `session.subscribe(options?)`
17
+ - `session.close()` → dispatches `session_shutdown` middleware once, then closes every subscriber
16
18
  - `session.abort()`
17
19
  - `session.entries()`
18
20
  - `session.checkout(leafId?)`
@@ -57,13 +59,15 @@ string | Message | readonly Message[]
57
59
 
58
60
  `session.fork(options?)` / `session.clone(options?)` take `AgentSessionForkOptions` / `AgentSessionCloneOptions` (leaf id, new session id, metadata, and store overrides), and `session.steer(input, options?)` takes `SteerOptions`. See [Public contracts](public-contracts.md) for the field tables, and the [options index](options-index.md) for every session option surface.
59
61
 
62
+ `session.close()` is the session teardown seam: it dispatches `session_shutdown` middleware exactly once (idempotent — a second `close()` dispatches nothing) and then closes every subscriber, run-scoped and `acrossRuns` alike. It does not abort an active run, so call it after the run settles. `session_start` middleware, the mirror dispatch, runs once at the session's first run start (the two hooks are the only per-session middleware calls — every other hook is per turn or per boundary). See [Middleware hooks](middleware-hooks.md).
63
+
60
64
  `session.run()` / `session.prompt()` resolve to an `AgentRunResult` with `sessionId`, `runId`, `status`, `text`, `content`, optional `message`/`usage`/`leafId`, and terminal `error`/`abortReason` when applicable. Callers may ignore the return value. Failed and aborted runs still emit their terminal events, then reject with `AgentRunError` whose `.result` carries the same shape.
61
65
 
62
- `session.stream(input, options?)` subscribes first, starts exactly one run, yields only that run's events, and terminates when the run succeeds, fails, or aborts. Early consumer return aborts the owned run and releases the session. `SubscribeOptions.maxQueuedEvents` / `overflow` may be passed alongside `RunOptions`.
66
+ `session.stream(input, options?)` subscribes first, starts exactly one run, yields only that run's events, and terminates when the run succeeds, fails, or aborts. The subscription belongs to `stream()`: it closes it when the owned run settles, so even a run that fails before its first event (a pre-flight validation rejection returns before run-end cleanup) ends the consumer instead of parking it, and no run-end close is required for `stream()` to be correct. Early consumer return aborts the owned run and releases the session. `SubscribeOptions.maxQueuedEvents` / `overflow` may be passed alongside `RunOptions`.
63
67
 
64
68
  `resumeAgentRunStream(agent, ref, resume, options?)` does the same for one existing suspended durable run. It validates checkpoint ownership, revision/fingerprint, and `expectedVersion`, then subscribes before emitting `agent_started` / `agent_resumed` and resumed message/tool/terminal events. `AgentRunResumeStreamOptions` combines `AgentRunResumeOptions` (including the optional `onSession` observer seam a supervisor uses to attach a child event pump to the rebuilt session) with `maxQueuedEvents` and `overflow`; early return aborts only resumed execution. Since 0.8.0 (plan 080 Task 3), `AgentRunResumeOptions.signal` is inherited by both entrypoints, so `resumeAgentRun()` aborts a live resumed provider/tool turn the same way `resumeAgentRunStream()` does — checked before each preparation step and threaded into the resumed execution. It does not replay a claimed/dispatched tool, poll a ledger, or retain a worker. `createAgentRunLifecycle().resumeStream(ref, resume, request?)` adds the same behavior after host agent-capability resolution.
65
69
 
66
- `session.subscribe(options?)` remains available for hosts that want a long-lived subscriber across runs. Subscribe before `run()` to observe that run's events. The consumer loop and `session.run()` must run concurrently (e.g. start the `for await` consumer, then `await Promise.all([consumer, session.run("Hi")])`): events are only emitted during a live run, so awaiting the subscribe loop before calling `run()` deadlocks. Prefer `session.stream()` when you only need one run's events. `SubscribeOptions.maxQueuedEvents` defaults to `1024` (minimum `1`) and caps events queued while the consumer is not awaiting `next()`. `SubscribeOptions.overflow` defaults to `"close"`; it clears queued payload events, delivers one `event_subscriber_overflow` notice to that subscriber, then closes it. `"drop_oldest"` keeps newest events; `"drop_newest"` ignores new events while full.
70
+ `session.subscribe(options?)` returns an in-memory live subscription. By default it is **run-scoped**: the run-end cleanup (`cleanupRun`), a durable suspension, and a durable denial all close it, which is what `stream()` and the examples rely on. `SubscribeOptions.acrossRuns: true` opts one subscriber out of that close, so it keeps receiving the next run's events on the same session; it is then ended only by the host (`break` out of the `for await`, or the iterator's `return()`/`[Symbol.asyncIterator]().return()`), by `closeSubscribers()` on session teardown, or by an overflow under the default `close` policy. The run-scoped close is `closeRunSubscribers()`; `closeSubscribers()` still means every subscriber. Subscribe before `run()` to observe that run's events. The consumer loop and `session.run()` must run concurrently (e.g. start the `for await` consumer, then `await Promise.all([consumer, session.run("Hi")])`): events are only emitted during a live run, so awaiting the subscribe loop before calling `run()` deadlocks. Prefer `session.stream()` when you only need one run's events. `SubscribeOptions.maxQueuedEvents` defaults to `1024` (minimum `1`) and caps events queued while the consumer is not awaiting `next()`. `SubscribeOptions.overflow` defaults to `"close"`; it clears queued payload events, delivers one `event_subscriber_overflow` notice to that subscriber, then closes it. `"drop_oldest"` keeps newest events; `"drop_newest"` ignores new events while full.
67
71
 
68
72
  For a text-only provider turn, the runtime emits:
69
73
 
@@ -177,17 +181,17 @@ await agent.createSession().run("Hi", { model: overrideModel });
177
181
  - Compaction context contains branch entries and explicit compaction options only; it does not include provider objects, provider requests, credential resolvers, resolved credentials, settings, or hidden metadata.
178
182
  - Store entries contain explicit session data only; Prism does not store provider objects, credential resolvers, resolved credentials, full provider requests, settings, or hidden metadata.
179
183
  - Runtime events contain messages/content only; do not put secrets in prompts, metadata, provider events, session entries, or docs examples.
180
- - The event broadcaster is in-memory, live-only, and bounded per subscriber by `SubscribeOptions`. It adds no dependency, timer, filesystem/network discovery, worker, or durable queue.
184
+ - The event broadcaster is in-memory, live-only, and bounded per subscriber by `SubscribeOptions`. It adds no dependency, timer, filesystem/network discovery, worker, or durable queue. An `acrossRuns: true` subscriber holds that bounded queue (default `maxQueuedEvents` 1024) for the session's lifetime instead of one run, and it subscribes to no other session: the broadcaster stays session-scoped, so no subscriber can observe another session's or ownership scope's events.
181
185
 
182
186
  ## Durable interruption
183
187
 
184
- Set `runState` with a host-owned `CheckpointStore`, stable `definitionRevision`, and `interruptBeforeTool: true` to suspend at a persisted pre-side-effect boundary. A suspended result has `status: "suspended"`, a redacted `interruption`, and `runState.version`; it releases session resources before returning. When a provider turn requests several tools, the round is collected into **one** suspension whose `interruption.pendingDecisions` holds one redacted `PendingDecision` per gated call (`approvalId`, kind, scope with tool name/effect kind/identity/arguments hash — never raw arguments); ungated calls still dispatch.
188
+ Set `runState` with a host-owned `CheckpointStore`, stable `definitionRevision`, and `interruptBeforeTool: true` to suspend at a persisted pre-side-effect boundary. A compiled pack rule with `action: "ask"` gates exactly the calls it matches the same way, without the all-tools switch (see [Guardrails § ask rules](guardrails.md#asking-for-approval-ask-rules)). A suspended result has `status: "suspended"`, a redacted `interruption`, and `runState.version`; it releases session resources before returning. When a provider turn requests several tools, the round is collected into **one** suspension whose `interruption.pendingDecisions` holds one redacted `PendingDecision` per gated call (`approvalId`, kind, scope with tool name/effect kind/identity/arguments hash — never raw arguments); ungated calls still dispatch.
185
189
 
186
190
  `resumeAgentRun` accepts exactly one of:
187
191
 
188
192
  - `decision: "approve" | "deny"` — legacy single-approval path. `approve` allows every pending decision once; `deny` terminates the run as `denied`.
189
193
  - `decision: "continue"` — crash recovery for a running-state checkpoint written by [`checkpointPolicy: "every-turn"`](durable-runs.md): resumes from the last provider-turn boundary without re-dispatching tools. It requires a running state and never bypasses a gate — a suspended run still needs `approve`/`deny` or a decision batch.
190
- - `decisions: readonly RunDecision[]` — one atomic batch. Every entry validates against the recorded pending set (unknown/foreign `approvalId`, duplicates, stale `expectedVersion`, invalid outcomes fail the whole batch closed with `AgentDecisionError` and leave state and version untouched). Outcomes: `allow_once`, `allow_for_run`, `reject_once`, `reject_for_run`. `reject_*` continues the run with a blocked tool result carrying the bounded (2 KB) `reason`. `modifiedArguments` are revalidated (schema, then input guardrails; permission/trust re-run at dispatch) and produce a new arguments hash. `elicitation` payloads are validated against the pending decision's `elicitationSchema` (required keys plus the configured host validator) and resolve the suspended call without executing it. A batch deciding a strict subset persists the decided entries and re-suspends with the remainder pending at the bumped version.
194
+ - `decisions: readonly RunDecision[]` — one atomic batch. Every entry validates against the recorded pending set (unknown/foreign `approvalId`, duplicates, stale `expectedVersion`, invalid outcomes fail the whole batch closed with `AgentDecisionError` and leave state and version untouched). Outcomes: `allow_once`, `allow_for_run`, `reject_once`, `reject_for_run`. `reject_*` continues the run with a blocked tool result carrying the bounded (2 KB) `reason`. `modifiedArguments` are revalidated (schema, then input guardrails — including the session's restored guardrail pack rules, so an edit into a pack-violating state, a `deny` or an `ask` rule alike, is refused here with `ERR_PRISM_DECISION_INVALID` naming the rule instead of being accepted and only stopped at dispatch; permission/trust re-run at dispatch) and produce a new arguments hash. `elicitation` payloads are validated against the pending decision's `elicitationSchema` (required keys plus the configured host validator) and resolve the suspended call without executing it. A batch deciding a strict subset persists the decided entries and re-suspends with the remainder pending at the bumped version.
191
195
 
192
196
  `*_for_run` outcomes append a `StickyDecision` to the durable run state: later calls in the same run matching the scope exactly (all recorded fields) proceed or are blocked without a new suspension, policy still enforced at dispatch. Sticky decisions expire when the run reaches any terminal status. Caps: 32 pending decisions per run (hard 128), 64 sticky decisions (hard 256), 2 KB decision reasons, 16 KB elicitation payloads. Frontend adapters (such as AG-UI with `capabilities.humanInTheLoop.approveWithEdits`) and the server resume endpoint (`POST .../resume` with `modifiedArguments`) map human edits directly to `RunDecision` entries with `modifiedArguments` under single atomic CAS, revalidating tool parameter schemas and invalidating stale draft approvals.
193
197
 
@@ -204,7 +208,7 @@ if (result.status === "suspended") {
204
208
  }
205
209
  ```
206
210
 
207
- Resume requires exact checkpoint ownership, version, agent fingerprint, and revision. A checkpoint load or delete under a non-matching ownership scope reads as absent (`null`), and a save against a foreign-owned record fails as a generic `ERR_PRISM_CHECKPOINT_CONFLICT` (plan 080 Task 3) — a tenant cannot distinguish “another tenant owns this key” from “missing”, and callers that relied on the old `Checkpoint ownership mismatch` throw now see the same miss they would for an unknown key. The fingerprint hashes the agent id/name, `definitionRevision`, model, instructions, system-prompt contributions, skills (name/instructions/tool names), tool definitions (name/parameters/exclusive), guardrail definitions (name/stage/revision), and loop strategy — changing any of them without bumping `definitionRevision` fails resume closed instead of silently continuing with different agent semantics. Prism CAS-claims approval before work, rechecks normal guardrail/permission/validation/limit paths, and marks a pending tool dispatched before its side effect. `createAgentRunLifecycle()` wraps the same core path for server/MCP hosts: adapters pass only authorized ownership, status returns only `{ state, version }`, and `resolveAgent()` supplies current agent/revision. `resumeStream()` uses that same claim path and bounded subscriber, so adapters do not poll or duplicate resume logic. Remote restart requires both checkpoint and session stores to be durable. A crash after that mark is ambiguous and is never replayed automatically; use host tool idempotency keyed by `runId`/`toolCallId` or resolve it manually. Checkpoints contain bounded redacted state plus session/leaf references, never provider objects, callbacks, signals, credentials, or raw secrets. State is bounded at save by `runState.maxStateBytes` (default 256 KB, at most the 1 MB hard cap); load bounds against the 1 MB hard cap only, so state saved with a raised limit stays resumable while oversized records are still rejected. Since 0.1.3 (plan 015 Task 4), durable runs may opt in to session-state persistence with `persistSessionState: true` on both the run and resume options: the loaded-skill **name catalog** (≤64 names, ≤256 chars each) rides the checkpoint and is restored into the resumed session's `LoadedSkillSet`; skill **bodies are never persisted** and re-resolve from the live registry via `load_skill`. Since 0.1.6 (plan 018 closeout `checkpoint-bodies`), `includeSkillBodies: true` on BOTH the run and resume options additionally persists the exact loaded-skill **instructions** (`{name, instructions}` pairs, redacted at the checkpoint boundary like all state, ≤64 bodies / ≤256-char names / ≤262144-byte bodies / ≤1 MiB total) so resume re-renders them registry-independently — no `load_skill` round-trip and no dependence on the registry still serving the same text; `maxStateBytes` (default 256 KB) refuses oversize bodies with a recorded error, never silently truncates. Default off keeps the checkpoint shape byte-identical to 0.1.3. Since 0.7.0 (plan 074 P3), `persistSessionState: true` also carries the opt-in [attention compiler](attention-compiler.md)'s sticky frontier (`sessionState.attentionSticky`: 32-hex thinking keys plus tool-call ids, newest 256 of each, redacted like all state) so a resumed run keeps its thinking strips and tool stubs instead of re-deciding its first turn from the ratio; a malformed frontier is dropped entry by entry and never blocks a resume. Since 0.7.0, `onSession` hands the reconstructed session to a caller-supplied observer before the resumed run starts, so an observer (the supervisor's child-event pump) can subscribe while the run is still live; it is called for every resume outcome, a throw fails closed before any event or tool work, and the session is valid only for the duration of that resume. Built-in loop options are durable; custom `AgentLoopStrategy` instances are durable when they declare `snapshot`/`restore` hooks (see [Agent loops § Durable runs](agent-loops.md#durable-runs)) and reject before provider work otherwise. For mid-run crash recovery (`checkpointPolicy: "every-turn"` plus `decision: "continue"`), see [Durable runs](durable-runs.md).
211
+ Resume requires exact checkpoint ownership, version, agent fingerprint, and revision. A checkpoint load or delete under a non-matching ownership scope reads as absent (`null`), and a save against a foreign-owned record fails as a generic `ERR_PRISM_CHECKPOINT_CONFLICT` (plan 080 Task 3) — a tenant cannot distinguish “another tenant owns this key” from “missing”, and callers that relied on the old `Checkpoint ownership mismatch` throw now see the same miss they would for an unknown key. The fingerprint hashes the agent id/name, `definitionRevision`, model, instructions, system-prompt contributions, skills (name/instructions/tool names), tool definitions (name/parameters/exclusive), guardrail definitions (name/stage/revision), and loop strategy — changing any of them without bumping `definitionRevision` fails resume closed instead of silently continuing with different agent semantics. Prism CAS-claims approval before work, rechecks normal guardrail/permission/validation/limit paths, and marks a pending tool dispatched before its side effect. `createAgentRunLifecycle()` wraps the same core path for server/MCP hosts: adapters pass only authorized ownership, status returns only `{ state, version }`, and `resolveAgent()` supplies current agent/revision. `resumeStream()` uses that same claim path and bounded subscriber, so adapters do not poll or duplicate resume logic. Remote restart requires both checkpoint and session stores to be durable. A crash after that mark is ambiguous and is never replayed automatically; use host tool idempotency keyed by `runId`/`toolCallId` or resolve it manually. Checkpoints contain bounded redacted state plus session/leaf references, never provider objects, callbacks, signals, credentials, or raw secrets. State is bounded at save by `runState.maxStateBytes` (default 256 KB, at most the 1 MB hard cap); load bounds against the 1 MB hard cap only, so state saved with a raised limit stays resumable while oversized records are still rejected. Since 0.1.3 (plan 015 Task 4), durable runs may opt in to session-state persistence with `persistSessionState: true` on both the run and resume options: the loaded-skill **name catalog** (≤64 names, ≤256 chars each) rides the checkpoint and is restored into the resumed session's `LoadedSkillSet`; skill **bodies are never persisted** and re-resolve from the live registry via `load_skill`. Since 0.1.6 (plan 018 closeout `checkpoint-bodies`), `includeSkillBodies: true` on BOTH the run and resume options additionally persists the exact loaded-skill **instructions** (`{name, instructions}` pairs, redacted at the checkpoint boundary like all state, ≤64 bodies / ≤256-char names / ≤262144-byte bodies / ≤1 MiB total) so resume re-renders them registry-independently — no `load_skill` round-trip and no dependence on the registry still serving the same text; `maxStateBytes` (default 256 KB) refuses oversize bodies with a recorded error, never silently truncates. Default off keeps the checkpoint shape byte-identical to 0.1.3. Since 0.7.0 (plan 074 P3), `persistSessionState: true` also carries the opt-in [attention compiler](attention-compiler.md)'s sticky frontier (`sessionState.attentionSticky`: 32-hex thinking keys plus tool-call ids, newest 256 of each, redacted like all state) so a resumed run keeps its thinking strips and tool stubs instead of re-deciding its first turn from the ratio; a malformed frontier is dropped entry by entry and never blocks a resume. Since 0.9.0 (plan 104 Task 2/3), `persistSessionState: true` also carries the session's guardrail pack refs (`sessionState.guardrailPacks`: `id`, `version`, bounded host `options` — or, for an inline pack, its pattern `rules`, which Task 3 allows to ride the checkpoint while a `deny` predicate or `RegExp` pattern refuses the save) plus each pack's own state-codec snapshot, so a resumed run recompiles and reinstates exactly the packs the suspended run enforced — including the `ask` rules that gate its later calls. The key's presence is the opt-in on resume (the run that wrote it had already opted in), and an unresolvable block — unknown pack id, version mismatch against the installed definition, a pack with persisted state but no codec, more than 8 packs, or malformed/oversized state — fails closed with `AgentRunStateError` before any provider or tool turn rather than resuming unenforced; `session.guardrailPackRefs` then feeds `snapshotRunBundle({ packs })` so recorded identity matches enforcement. Since 0.7.0, `onSession` hands the reconstructed session to a caller-supplied observer before the resumed run starts, so an observer (the supervisor's child-event pump) can subscribe while the run is still live; it is called for every resume outcome, a throw fails closed before any event or tool work, and the session is valid only for the duration of that resume. Built-in loop options are durable; custom `AgentLoopStrategy` instances are durable when they declare `snapshot`/`restore` hooks (see [Agent loops § Durable runs](agent-loops.md#durable-runs)) and reject before provider work otherwise. For mid-run crash recovery (`checkpointPolicy: "every-turn"` plus `decision: "continue"`), see [Durable runs](durable-runs.md).
208
212
 
209
213
  ## Secure composition
210
214
 
@@ -48,16 +48,17 @@ await session.run("cheap run", { attentionCompiler: { triggerRatio: 0.95, compac
48
48
  The agent setting is resolved with the run's model at run start, before any provider turn, so a malformed setting or a widening overlay fails the run immediately instead of on the turn that crosses the ratio:
49
49
 
50
50
  - **Allowed in the overlay:** `triggerRatio` / `compactRatio` at or above the agent setting, `keepLast` / `thinkingKeepTurns` at or below it, and extra `excludeTools` (unioned with the agent list, never removed).
51
- - **Rejected:** a lower gate ratio, more protected rows, and `maxInputTokens` / `reserveTokens` — cap inputs are agent-config only, because moving the cap moves the gate itself. Raising `triggerRatio` at or above the agent's `compactRatio` needs `compactRatio` raised in the same overlay.
51
+ - **Rejected:** a lower gate ratio, more protected rows, and `maxInputTokens` / `reserveTokens` / `trigger` — cap inputs and fold axes are agent-config only, because moving either moves the gate itself. Raising `triggerRatio` at or above the agent's `compactRatio` needs `compactRatio` raised in the same overlay.
52
52
  - **Enabling from a run is rejected:** a run may disable or relax the compiler, never switch it on where the agent config left it off.
53
53
 
54
- The **sticky frontier is session-owned and created lazily** the first time an enabled run assembles a request: one `{ thinking, toolCallIds }` set pair per session, shared across runs, provider rounds, and branches, so a stub or strip made once stays applied even on a later under-ratio turn. It lives in memory only — a resumed process simply re-decides from the ratio it sees.
54
+ The **sticky frontier is session-owned and created lazily** the first time an enabled run assembles a request: one `{ thinking, toolCallIds }` set pair per session, shared across runs, provider rounds, and branches, so a stub or strip made once stays applied even on a later under-ratio turn. A durable run with `persistSessionState: true` writes its bounded snapshot into the checkpoint and restores it on resume, so a resumed process keeps its stubs instead of re-deciding its first turn from the ratio.
55
55
 
56
56
  `AttentionCompilerOptions` (all optional):
57
57
 
58
58
  | Field | Type | Default | Meaning |
59
59
  | --- | --- | --- | --- |
60
- | `triggerRatio` | `number` | `0.75` | Fraction of `inputCap` that enables mutation; must be in `(0, 1)` (exclusive). |
60
+ | `triggerRatio` | `number` | `0.75` | Fraction of `inputCap` that enables mutation; must be in `(0, 1)` (exclusive). The reference ratio the report carries and `compactRatio` is checked against. |
61
+ | `trigger` | `AttentionTriggerInput` | — | Fold axes (plan 086 T2). **Replaces** the `triggerRatio` axis when set; omitted keeps it alone, so behavior is unchanged. See [Trigger axes](#trigger-axes). |
61
62
  | `compactRatio` | `number` | `0.9` | Where compaction should fire relative to the compiler; must exceed `triggerRatio`. |
62
63
  | `thinkingKeepTurns` | `number` | `1` | Newest thinking-bearing assistant turns kept intact. |
63
64
  | `keepLast` | `number` | `3` | Newest tool results kept full. |
@@ -71,8 +72,9 @@ The **sticky frontier is session-owned and created lazily** the first time an en
71
72
  | --- | --- | --- |
72
73
  | `model` | `{ limits?: ModelLimits }` | Source of `contextWindow` / `maxOutputTokens` when `maxInputTokens` is absent. |
73
74
  | `compactionTrigger` | `CompactionTrigger` | Optional: validated here so an unknown trigger `type` fails at create time, not on the first turn. An `input_ratio` trigger must exceed `triggerRatio`. |
75
+ | `runInputBudget` | `number \| null` | Cumulative run input budget the `run_input_ratio` axis folds against — pass the resolved `RunLimits.maxInputTokens`. `null` or omitted means the run declares no budget, so that axis falls back to the input cap. Distinct from `maxInputTokens`, which caps a single request. |
74
76
 
75
- **Public surface.** `createAttentionCompiler(options?: AttentionCompilerOptions, context?)` is the factory; `AttentionCompilerOptions` carries the gate ratios, sticky-stage tuning (`thinkingKeepTurns`, `keepLast`), `excludeTools`, and `reserveTokens`. `resolveInputCap(options?: AttentionInputCapOptions, model?)` is the cap resolver, `compileAttention(options: AttentionCompileOptions)` is the per-turn call `assembleProviderInput` makes (`AttentionCompileOptions` also carries `fold`, `frontier`, `redactor`, `signal`, and the `turn`/`sessionId`/`runId` telemetry ids), and `createAttentionTruncationTrigger(options?: AttentionTruncationTriggerOptions)` builds the host-programmable compaction trigger.
77
+ **Public surface.** `createAttentionCompiler(options?: AttentionCompilerOptions, context?)` is the factory; `AttentionCompilerOptions` carries the gate ratios, the optional `trigger` axes, sticky-stage tuning (`thinkingKeepTurns`, `keepLast`), `excludeTools`, and `reserveTokens`. `resolveInputCap(options?: AttentionInputCapOptions, model?)` is the cap resolver, `compileAttention(options: AttentionCompileOptions)` is the per-turn call `assembleProviderInput` makes (`AttentionCompileOptions` also carries `fold`, `frontier`, `redactor`, `signal`, `runInputTokens`, and the `turn`/`sessionId`/`runId` telemetry ids), and `createAttentionTruncationTrigger(options?: AttentionTruncationTriggerOptions)` builds the host-programmable compaction trigger. The frozen handle carries the normalized `trigger` axes, the resolved `runInputBudget`, and `durable`, so a caller can never resolve one and evaluate against another.
76
78
 
77
79
  Input cap resolution: `maxInputTokens` when set, otherwise `contextWindow - (maxOutputTokens ?? 0) - reserveTokens`. Both `resolveInputCap(options?, model?)` and the compiler fail closed with a `TypeError` when neither source is present, when a declared limit is malformed, or when the computed cap is not positive.
78
80
 
@@ -82,10 +84,56 @@ Turn options, passed to `assembleProviderInput`:
82
84
  | --- | --- | --- |
83
85
  | `attentionCompiler` | `AttentionCompilerOptions \| AttentionCompiler` | Raw options are validated for that call; a resolved handle reuses one validation. The session passes the run's resolved handle so a tuning typo fails before the first provider turn. |
84
86
  | `attentionSticky` | `AttentionStickyFrontier` | `{ thinking, toolCallIds }` sets from `createAttentionStickyFrontier()`. The session supplies its own; a direct `assembleProviderInput` caller owns it, and omitting it makes each call mutate for its turn only. |
87
+ | `runInputTokens` | `number` | Run input tokens already charged by provider usage this run (default 0), so the cumulative `run_input_ratio` axis can project this turn onto the spend. The session passes the run limit counter. |
85
88
  | `onAttentionReport` | `(report: AttentionReport) => void` | Called once per **mutated** turn, before `input_assembly` middleware; silent under the ratio. The session uses it to emit `attention_compiled`. |
86
89
 
87
90
  `attentionCompiler` and `contextBudget` are **mutually exclusive** — a compiler-on turn that is still over throws `AttentionBudgetError` rather than evicting through the budget, so passing both fails closed with a `TypeError`.
88
91
 
92
+ ### Trigger axes
93
+
94
+ `trigger` replaces `triggerRatio` as the gate (plan 086 T2). It takes one axis, one predicate function, or an array of them; an array is **any-of**, and the first axis that fires is the one attributed on the report.
95
+
96
+ | Kind | Fires when | Folds to | Fails closed? |
97
+ | --- | --- | --- | --- |
98
+ | `{ kind: "input_ratio", ratio }` | the assembled request reaches `ratio × inputCap` — the legacy `triggerRatio` axis | `ratio × inputCap` | yes |
99
+ | `{ kind: "run_input_ratio", ratio }` | `runInputTokens + estimatedInputTokens` reaches `ratio × runInputBudget`, so a run capped below the model window folds before the cap kills it | every eligible row (a cumulative gate has no per-request target) | **no** — the spend is already booked; folding only slows the counter, and the run limit owns the cap |
100
+ | `{ kind: "token_floor", tokens }` | the assembled request reaches `tokens` tokens, whatever the cap | `tokens` | yes |
101
+ | `{ kind: "predicate", shouldFold }` (or a bare function) | `shouldFold(state)` returns `true` | every eligible row | yes |
102
+
103
+ ```ts
104
+ // The synapta Plan 118 shape: a 1M-window model under a 500k run input cap, where the legacy
105
+ // 0.75 × window gate (745k) could never open before the run died at its cap.
106
+ const compiler = createAttentionCompiler(
107
+ { trigger: { kind: "run_input_ratio", ratio: 0.75 }, keepLast: 3 },
108
+ { model, runInputBudget: 500_000 },
109
+ );
110
+
111
+ // Any-of, attributed in order: the floor is reported when both would fire.
112
+ createAttentionCompiler({ trigger: [{ kind: "token_floor", tokens: 120_000 }, { kind: "input_ratio", ratio: 0.9 }] }, { model });
113
+
114
+ // Host predicate: synchronous, evaluated at most twice per turn (once to decide, once to
115
+ // confirm the stages settled it) with a frozen `AttentionTriggerState`.
116
+ createAttentionCompiler(
117
+ { trigger: (state) => state.estimatedInputTokens > 150_000 && state.turn > 5 },
118
+ { model },
119
+ );
120
+ ```
121
+
122
+ `AttentionTriggerState` is frozen and carries estimates only: `estimatedInputTokens` (this turn's assembled request), `inputCapTokens`, `runInputBudgetTokens` (absent when the run declares none), `runInputTokens` (charged spend so far), and `turn`. A predicate that returns a non-boolean — including a `Promise` from an `async` function — fails closed with a `TypeError` naming the option, rather than silently never firing.
123
+
124
+ Predicate axes run **host-supplied code**, under the same trust as `CompactionTrigger.custom`: the compiler passes host data in and takes a boolean out, never credentials or payloads. Keep them synchronous and side-effect free; they see token estimates and ids, never message text.
125
+
126
+ Rules that hold for every axis:
127
+
128
+ - **Config-time validation.** Unknown kinds, a ratio outside `(0, 1)`, a non-positive `token_floor.tokens`, an empty array, and a predicate that is not a function all throw a `TypeError` naming the option (`attentionCompiler.trigger`, or `attentionCompiler.trigger[1]` inside an array).
129
+ - **Gate is agent-config only.** A run overlay may not set `trigger`, `maxInputTokens`, or `reserveTokens` — the gate and the cap move together, so moving either belongs in the agent config.
130
+ - **One evaluation per turn.** The axes are evaluated at turn start against the measured request and once more after the stages. The per-row loop then compares numbers, so a predicate costs two calls a turn no matter how many rows are eligible.
131
+ - **Sticky and monotonic as ever.** An axis only decides *whether* to fold; the stages still stop as soon as a target is reached, and mutations stay applied on later under-gate turns.
132
+ - **`run_input_ratio` needs its budget.** With `compactAfterTokens`-style run limits declared (`RunLimits.maxInputTokens`, which defaults to `40_000` and kills the run cumulatively), pass the resolved value as `runInputBudget`. Without one the axis is the per-request `input_ratio` comparison.
133
+ - **`triggerRatio` stays the reference.** Omitted alongside `trigger`, it takes the first `input_ratio` axis's ratio so `compactRatio` and the report still describe the real fold point; with no `input_ratio` axis it keeps the default `0.75` as the compaction reference.
134
+
135
+ Runnable end to end: [`examples/attention-budget-axes.ts`](../examples/attention-budget-axes.ts) runs the scenario both axes exist for — a 1M window, a 500k run budget, 24 provider turns. The window axis would need 743k tokens and never gets near (`maxUsed` ≈ 7k, so it never fires); cumulative spend crosses 1 % of the budget on turn 6, the gate opens there, and the run finishes having spent 36k of its 500k with the newest 2 rows raw and every older body a stub. `src/__tests__/attention-compiler-budget.test.ts` asserts the same numbers, including that the first fold could only be explained by carried-over spend.
136
+
89
137
  ## Outputs / response / events
90
138
 
91
139
  `createAttentionCompiler` returns an `AttentionCompiler`: `inputCap`, `reserveTokens`, `triggerRatio`, `compactRatio`, `thinkingKeepTurns`, `keepLast`, and a frozen, de-duplicated `excludeTools`. It performs no I/O and calls no provider.
@@ -97,10 +145,12 @@ Turn options, passed to `assembleProviderInput`:
97
145
  | `used` | `number` | Estimated tokens measured before this turn's mutation. |
98
146
  | `usedAfter` | `number` | Estimated tokens of the same request after the mutation, so `used` → `usedAfter` is the per-turn cost curve. |
99
147
  | `inputCap` | `number` | Resolved cap the ratio was compared against. |
100
- | `triggerRatio` | `number` | Configured ratio. |
148
+ | `triggerRatio` | `number` | Configured ratio — the reference axis, whether or not a `trigger` replaced the gate. |
149
+ | `firedAxis` | `AttentionTriggerKind?` | Axis that opened the gate on this turn, in configured order; absent on an under-gate turn. Plan 087 attribution reads this. |
101
150
  | `droppedThinkingTurns` | `number` | Thinking turns absent from this request — rows re-applied from the sticky frontier count again. |
102
151
  | `stubbedToolResults` | `number` | Tool results stubbed in this request — re-applied rows count again. |
103
152
  | `stubbedBytes` | `number` | Payload bytes those stubs took out of the request (message bytes minus the stub header). |
153
+ | `newFoldedBodies` | `number` | Folded bodies this turn added to the ledger: the `summarize` calls the cache saved, and the durable-fold checkpoint signal. `0` on a turn that only re-applied stored bodies. |
104
154
  | `truncated` | `boolean` | `true` when the gate stopped with eligible rows left, so the sticky frontier is partial. |
105
155
  | `runId` / `sessionId` | `string?` | Owning run/session when known. |
106
156
 
@@ -119,7 +169,7 @@ Tool result read_file [call_1]: omitted 41_982 bytes (sha256 3f9a1c2b4d5e6f70a1b
119
169
 
120
170
  Never stubbed: rows named in `excludeTools`, tool **errors**, results stamped as a decision/approval payload (`approval`, `approvalId`, `prismApproval`, `decision`, `decisions`, `pendingDecisions`, `elicitation` metadata), rows the host fold's own age/byte gates exclude, and any row whose stub would cost more than the payload it replaces. When `toolResultFold.summarize` is configured, that function produces the stub body for the rows the compiler picked (capped by its `maxSummaryBytes`); otherwise the deterministic digest above is used.
121
171
 
122
- `compileAttention({ compiler, groups, context?, skills?, tools?, fold?, frontier?, redactor?, signal?, turn?, sessionId?, runId? })` is what `assembleProviderInput` calls; it returns `{ groups, mutated, report }`. Under the ratio it returns the **same groups object** it was given; when it mutates it returns new `history` / `toolResults` arrays and never writes into the caller's arrays.
172
+ `compileAttention({ compiler, groups, context?, skills?, tools?, fold?, frontier?, attentionFold?, redactor?, signal?, turn?, runInputTokens?, sessionId?, runId? })` is what `assembleProviderInput` calls; it returns `{ groups, mutated, report }`. Under the ratio it returns the **same groups object** it was given; when it mutates it returns new `history` / `toolResults` arrays and never writes into the caller's arrays.
123
173
 
124
174
  Errors:
125
175
 
@@ -137,6 +187,7 @@ Errors:
137
187
  "keepLast": 3,
138
188
  "excludeTools": ["submit_payment"],
139
189
  "reserveTokens": 1024,
190
+ "durable": false,
140
191
  "compaction": {
141
192
  "trigger": {
142
193
  "type": "custom",
@@ -244,11 +295,40 @@ See [Compaction and retry policies](compaction-and-retry.md) for the trigger uni
244
295
  - `excludeTools` is fail closed: entries are validated as non-empty bounded strings, de-duplicated, and frozen; a named tool is never stubbed even when the request stays over the ratio.
245
296
  - The compiler never orchestrates other levers: `toolResultFold.summarize` still wins for fold-eligible rows when a host supplies it, `applyContextBudget` keeps working unchanged for compiler-off agents, and compaction stays a task-boundary operation (`session.compact()` still throws while a run is in flight).
246
297
  - Sticky means sticky: a stripped thinking turn is never restored and a stubbed call id is never un-stubbed, even on a later under-ratio turn — restoring either would rewrite the cached prefix. Pass no `attentionSticky` for one-shot assemblies.
247
- - The frontier is bounded (256 thinking keys, 256 tool-call ids, newest kept) and lives on the session, so it survives turns and runs. A durable run with `persistSessionState: true` also writes it into the checkpoint (`sessionState.attentionSticky`) and restores it on resume, so a resumed run keeps its stubs instead of re-deciding its first turn from the ratio; a malformed or hand-edited frontier is dropped entry by entry, never fatal to a resume.
298
+ - The frontier is bounded (256 thinking keys, 256 tool-call ids, newest kept) and lives on the session, so it survives turns and runs. A durable run with `persistSessionState: true`, or any run with `durable: true`, also writes it into the checkpoint (`sessionState.attentionSticky`) and restores it on resume, so a resumed run keeps its stubs instead of re-deciding its first turn from the ratio; a malformed or hand-edited frontier is dropped entry by entry, never fatal to a resume.
248
299
  - A compiler-on turn assembles from the default message groups (instructions, summaries, history, input, attachments, tool results) exactly like a `contextBudget` turn, so a custom `inputBuilder` is not consulted while the compiler is on.
249
300
  - Compaction timing is programmable per agent through `CompactionOptions.trigger` (`threshold_entries` | `input_ratio` | `custom`); omitting it keeps today's `thresholdEntries` gate. `assertCompactionTrigger(trigger)` validates a trigger independently of the compiler.
250
301
  - The gate is opt-in per agent/run; omit the option for current assembly bytes.
251
302
 
303
+ ### Durable folding
304
+
305
+ `durable: true` puts the fold state on disk (plan 086 T3), so a run that dies mid-investigation resumes
306
+ with the rows it had already folded instead of re-deciding them from the ratio.
307
+
308
+ ```ts
309
+ const agent = createAgent({
310
+ // ...
311
+ attentionCompiler: {
312
+ trigger: { kind: "run_input_ratio", ratio: 0.75 },
313
+ keepLast: 2,
314
+ durable: true,
315
+ },
316
+ runState: { checkpoints, definitionRevision: "1" }, // the write target; `persistSessionState` not required
317
+ toolResultFold: { summarize: hostSummarize }, // optional: bodies become durable too
318
+ });
319
+
320
+ // After a crash the worker resumes where the fold left off:
321
+ await resumeAgentRun(agent, { runId, sessionId }, { decision: "continue", expectedVersion }, { checkpoints, definitionRevision: "1" });
322
+ ```
323
+
324
+ - **One write per fold, never per turn.** The checkpoint is written after the turn's request is assembled and before the provider sees it, only on turns that added folded bodies. A turn that re-applies what the ledger already holds writes nothing.
325
+ - **The fold ledger.** Each folded body is stored once, keyed by tool call id (newest 64, 4 KiB each), and re-applied on every later turn: the host `summarize` runs once per row instead of once per turn, and a sticky row stays byte-identical for the provider cache. Bodies are already redacted and capped by the fold that produced them.
326
+ - **Independent of `persistSessionState`.** That option governs skill and tool-activation state. `durable` is its own opt-in for the fold ledger plus its sticky frontier (`sessionState.attentionFold` / `attentionSticky`), because a resumed run needs both: the frontier decides *what* stays folded, the ledger decides *what body* it was folded to.
327
+ - **Restore is fault-tolerant.** A malformed ledger shape starts from an empty ledger, and a malformed entry is dropped one by one — the row simply re-folds on the next over-gate turn. A hand-edited checkpoint never blocks a resume.
328
+ - **Sizing.** Off by default. On, it costs one checkpoint write per fold turn plus `bodies × (body ≤ maxSummaryBytes)` bytes in the run state (default cap: 64 bodies), and it makes the fold the run's first crash-recovery point when `checkpointPolicy` is `"decision"`.
329
+ - **Requires a durable run.** `durable: true` without `runState` (a checkpoint store) throws `AgentRunStateError` at run start, before the first provider turn. A run overlay may not set `durable`.
330
+ - **Still projection-only.** Durability changes *where the projection is remembered*, not what the store holds: the session store, observational-memory ledger, and semantic stores keep every original payload for recall, branching, and audit.
331
+
252
332
  ## Security and performance notes
253
333
 
254
334
  - Validation is synchronous with no provider I/O, and the returned handle plus `excludeTools` are frozen.
@@ -264,9 +344,10 @@ See [Compaction and retry policies](compaction-and-retry.md) for the trigger uni
264
344
 
265
345
  - [`assembleProviderInput`](input-and-prompt-assembly.md): the compose path the compiler pre-passes when enabled.
266
346
  - [`toolResultFold`](input-and-prompt-assembly.md): host summarizer that wins over the deterministic stub for eligible rows.
267
- - [`CompactionOptions`](compaction-and-retry.md): `trigger` is the host compact-when seam; `thresholdEntries` remains the default gate.
347
+ - [`CompactionOptions`](compaction-and-retry.md): `trigger` is the host compact-when seam; `thresholdEntries` remains the default gate. For fold state that outlives a crash, see [Durable folding](#durable-folding).
268
348
  - [`observational-memory`](compaction-observational-memory.md): host `shouldCompact` / trigger overrides `compactAfterTokens` for post-run compaction.
269
349
  - [`provider caching`](provider-caching.md): why mutations are monotonic and in-place.
270
350
  - [`AttentionReport` measurements](_evidence/phase74-attention-measurements.md): the hermetic fixture behind the savings, cache, resume, and truncation numbers.
351
+ - Example: [`examples/attention-budget-axes.ts`](../examples/attention-budget-axes.ts) — budget-capped long run where only the cumulative axis can open the gate.
271
352
  - [Memory fabric](memory-fabric.md): a context source whose blocks are measured like any other (`working-memory` / `semantic-memory` tags, no layer id).
272
353
  - [`thinking and reasoning`](thinking-and-reasoning.md): the `thinking` blocks the first stage strips.
package/docs/caveman.md CHANGED
@@ -105,7 +105,7 @@ See `examples/caveman-ponytail.ts` for progressive catalog + `load_skill` wiring
105
105
 
106
106
  - Import alone registers nothing and starts no timers, watchers, or network I/O (`sideEffects: false`).
107
107
  - `kernel.load` calls `setup`, which resolves upstream first; failure throws before any `register*`.
108
- - Level restore scans `getEntries()` for the latest `data.type === "caveman-level"` — same OM attach pattern; core does not auto-emit `session_start`.
108
+ - Level restore scans `getEntries()` for the latest `data.type === "caveman-level"` — same OM attach pattern; it is entry-scan based rather than `session_start` middleware so a reloaded or resumed session restores its level too.
109
109
  - Host must register `createLoadSkillTool` and pass `skillsDisclosure: "progressive"` for catalog-only skill bodies.
110
110
  - `caveman-stats` dispatches skill metadata only; full stats need host session-log integration.
111
111
  - `caveman-init` returns upstream guidance text; it does not write files in the host repo.
@@ -610,7 +610,7 @@ Every configurable value is a positive safe integer (context may be zero); Prism
610
610
  - [Language intelligence](language-intelligence.md): optional host-activated LSP contract (`createLanguageIntelligence`) — symbols/definitions/references/diagnostics/hover/rename.
611
611
  - [Process sessions](process-sessions.md): optional managed long-running processes (`createProcessSessions`) — start/output/input/wait/signal/kill/release.
612
612
  - [Forge integration](forge-integration.md): optional GitHub adapter (`createGitHubForge`) — issue context, authenticated push, PR create/update, review comments, checks, bounded handoff reconcile; effect-store idempotency, no duplicate PRs/comments on retry, tokens never in argv/logs/events.
613
- - [Tools](tools.md): the host-owned tool harness — `createToolRegistry`, `dispatchToolCall`, filtering, and the `ToolDefinition` contract these factories satisfy.
613
+ - [Tools](tools.md): the host-owned tool harness — `createToolRegistry`, `dispatchToolCall`, filtering, `toolNarrowing` per-turn menus, and the `ToolDefinition` contract these factories satisfy.
614
614
  - [Public contracts](public-contracts.md): `ToolDefinition`, `ToolResult`, `ToolExecutionContext`, `ContentBlock`, and `JsonObject` shapes.
615
615
  - [Host security guide](host-security.md): fail-closed checklist for permission policies, tool validation, and trust boundaries that must gate these tools.
616
616
  - [Tool conformance](tool-conformance.md): assertions for the tool-dispatch blocked-reason matrix these tools participate in.
@@ -203,7 +203,7 @@ The default strategy does not call a provider. Hosts that need model-generated s
203
203
  - [Session stores and branching](session-stores-and-branching.md): branch entries, compaction entries, and `rebuildSessionContext()` behavior.
204
204
  - [Input and prompt assembly](input-and-prompt-assembly.md): compacted summaries become default summary messages for provider input.
205
205
  - [Agent/session runtime](agent-session-runtime.md): `session.compact()`, opt-in auto-compaction, `RunOptions.retry`, and `retry_scheduled` runtime behavior.
206
- - [Attention compiler](attention-compiler.md): resolves the same input cap and shrinks an over-ratio request before compaction is considered.
206
+ - [Attention compiler](attention-compiler.md): resolves the same input cap and shrinks an over-ratio request before compaction is considered; with `attention.compiler.durable` the fold ledger and frontier are checkpointed per fold, so a run that dies mid-investigation resumes already folded instead of replaying the pre-fold tail.
207
207
  - Example: [`examples/autonomous-coding-loop.ts`](../examples/autonomous-coding-loop.ts) — task-boundary compact after each iteration.
208
208
  - [Middleware hooks](middleware-hooks.md): `compaction` and `retry` middleware payload timing.
209
209
  - [Contribution registries](contribution-registries.md): compaction strategy and retry policy contributions.
@@ -122,6 +122,8 @@ const agent = createAgent({ model, provider, compaction: { strategy, thresholdEn
122
122
 
123
123
  Registration only contributes an inert strategy. The host must resolve and pass it to runtime config.
124
124
 
125
+ Both compaction routes (`session.compact()` and auto-compaction) hand this strategy its `CompactionContext` through the pre-strategy `compaction_request` middleware hook, so a host can rewrite the entries the strategy summarizes; the strategy itself needs no change — see [Middleware hooks](middleware-hooks.md).
126
+
125
127
  ## Security and performance notes
126
128
  Preparation is O(n) over branch entries and uses only arrays, strings, and JSON serialization. Limit options must be positive safe integers at or below their hard caps and reject during strategy creation. Missing output options use a 16,384-token summary ceiling; reserve ratio/model metadata may narrow the provider request, never remove its finite `maxTokens`. A request policy that replaces `maxTokens` with NaN, Infinity, zero, an unsafe integer, or above-hard-cap input fails before provider generation.
127
129