@arnilo/prism 0.9.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 (75) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/README.md +13 -12
  3. package/dist/agent-approval.d.ts +7 -1
  4. package/dist/agent-approval.js +15 -6
  5. package/dist/agent-run-lifecycle.js +19 -5
  6. package/dist/agent-run-state.d.ts +26 -5
  7. package/dist/agent-run-state.js +97 -1
  8. package/dist/agent-session/event-subscriber.d.ts +2 -0
  9. package/dist/agent-session/event-subscriber.js +3 -0
  10. package/dist/agent-session/session/assemble.js +156 -9
  11. package/dist/agent-session/session/persist.js +11 -5
  12. package/dist/agent-session/session/provider-round.js +54 -13
  13. package/dist/agent-session/session/tool-round.d.ts +2 -2
  14. package/dist/agent-session/session/tool-round.js +58 -5
  15. package/dist/agent-session/session/types.d.ts +20 -2
  16. package/dist/agent-session/session.d.ts +65 -4
  17. package/dist/agent-session/session.js +156 -16
  18. package/dist/context-budget.d.ts +11 -0
  19. package/dist/context-budget.js +33 -2
  20. package/dist/contracts-core/agent.d.ts +26 -5
  21. package/dist/contracts-core/extensions.d.ts +3 -0
  22. package/dist/contracts-core/guardrail-packs.d.ts +8 -3
  23. package/dist/contracts-core/loop.d.ts +36 -0
  24. package/dist/contracts-core/provider.d.ts +6 -1
  25. package/dist/contracts-core/run-limits.d.ts +10 -1
  26. package/dist/contracts-protocol.d.ts +6 -4
  27. package/dist/contracts-run-state.d.ts +37 -3
  28. package/dist/contributions.d.ts +2 -1
  29. package/dist/contributions.js +1 -0
  30. package/dist/extensions.d.ts +15 -1
  31. package/dist/extensions.js +68 -0
  32. package/dist/guardrail-packs/types.d.ts +10 -0
  33. package/dist/guardrail-packs/validation-respect.js +16 -0
  34. package/dist/guardrails.d.ts +42 -1
  35. package/dist/guardrails.js +124 -15
  36. package/dist/index.d.ts +6 -6
  37. package/dist/index.js +4 -4
  38. package/dist/middleware.d.ts +1 -1
  39. package/dist/run-bundle.d.ts +6 -1
  40. package/dist/run-bundle.js +4 -1
  41. package/dist/run-limits.js +13 -0
  42. package/dist/testing/prefix-stability-conformance.d.ts +29 -0
  43. package/dist/testing/prefix-stability-conformance.js +91 -23
  44. package/dist/tools.js +10 -3
  45. package/docs/agent-events.md +12 -8
  46. package/docs/agent-session-runtime.md +9 -6
  47. package/docs/caveman.md +1 -1
  48. package/docs/compaction-llm.md +2 -0
  49. package/docs/compaction-observational-memory.md +21 -1
  50. package/docs/durable-runs.md +4 -3
  51. package/docs/embeddings.md +5 -1
  52. package/docs/execution-timeline.md +3 -2
  53. package/docs/extensions.md +20 -3
  54. package/docs/guardrails.md +16 -6
  55. package/docs/hooks.md +282 -0
  56. package/docs/index.md +18 -15
  57. package/docs/input-and-prompt-assembly.md +1 -1
  58. package/docs/instruction-injection.md +1 -0
  59. package/docs/live-testing.md +3 -1
  60. package/docs/memory-fabric.md +28 -0
  61. package/docs/middleware-hooks.md +54 -4
  62. package/docs/migration.md +13 -0
  63. package/docs/options-index.md +3 -1
  64. package/docs/policy-and-audit.md +14 -1
  65. package/docs/prefix-stability-conformance.md +57 -7
  66. package/docs/provider-packages.md +20 -20
  67. package/docs/public-contracts.md +1 -0
  68. package/docs/rag.md +93 -6
  69. package/docs/release-and-install.md +42 -39
  70. package/docs/runs-and-usage.md +17 -8
  71. package/docs/scoped-agent-memory.md +17 -9
  72. package/docs/scoped-memory.md +138 -0
  73. package/docs/tools.md +1 -1
  74. package/docs/wiki.md +4 -2
  75. package/package.json +4 -2
package/dist/tools.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isJsonObject } from "./config.js";
2
- import { GuardrailError, runGuardrails } from "./guardrails.js";
2
+ import { GuardrailError, guardrailRefusalText, runGuardrails } from "./guardrails.js";
3
3
  import { assertIdentityActive, assertIdentityMatchesOwnership, ownershipFromIdentity } from "./identity.js";
4
4
  import { createId } from "./ids.js";
5
5
  import { errorToErrorInfo, redactRunLedgerRecord, redactSecrets } from "./redaction.js";
@@ -133,7 +133,7 @@ export async function dispatchToolCall(options) {
133
133
  if (inputGuards.terminal) {
134
134
  if (inputGuards.terminal.action !== "block")
135
135
  throw new GuardrailError(inputGuards.terminal);
136
- return blocked(mediatedCall, options.context, "guardrail_blocked", { message: "Tool call blocked by guardrail" }, options, startedAt);
136
+ return blocked(mediatedCall, options.context, "guardrail_blocked", { message: guardrailBlockMessage(inputGuards.terminal) }, options, startedAt);
137
137
  }
138
138
  const tool = options.registry.get(mediatedCall.name);
139
139
  const postcheck = await checkCall(mediatedCall, options, startedAt);
@@ -240,7 +240,7 @@ export async function dispatchToolCall(options) {
240
240
  throw new GuardrailError(outputGuards.terminal);
241
241
  if (effect)
242
242
  return finishUnknownEffect(effect, mediatedCall, context, options, startedAt);
243
- return blocked(mediatedCall, context, "guardrail_blocked", { message: "Tool result blocked by guardrail" }, options, startedAt);
243
+ return blocked(mediatedCall, context, "guardrail_blocked", { message: guardrailBlockMessage(outputGuards.terminal) }, options, startedAt);
244
244
  }
245
245
  if (effect && mediatedResult.error)
246
246
  return finishUnknownEffect(effect, mediatedCall, context, options, startedAt);
@@ -450,6 +450,13 @@ function isSuspended(error) {
450
450
  function isLoopStateError(error) {
451
451
  return typeof error?.code === "string" && error.code.startsWith("ERR_PRISM_LOOP_");
452
452
  }
453
+ /**
454
+ * Plan 104 T3/T4: the model-visible refusal line for a terminal guardrail decision. `guardrailRefusalText`
455
+ * names a compiled pack rule (bounded, redacted); any other guardrail keeps the neutral stage text.
456
+ */
457
+ function guardrailBlockMessage(record) {
458
+ return (guardrailRefusalText(record) ?? (record.stage === "tool_output" ? "Tool result blocked by guardrail" : "Tool call blocked by guardrail"));
459
+ }
453
460
  function isDelegationSuspended(error) {
454
461
  return error?.code === "ERR_PRISM_DELEGATION_SUSPENDED";
455
462
  }
@@ -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
 
@@ -97,7 +97,7 @@ Agent / turn / message events:
97
97
  | Variant | Fields |
98
98
  | --- | --- |
99
99
  | `agent_started` | `sessionId`, `runId` |
100
- | `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) |
101
101
  | `agent_suspended` | `sessionId`, `runId`, redacted `interruption`, checkpoint `version`; no tool side effect has started. |
102
102
  | `agent_resumed` | `sessionId`, `runId`, checkpoint `version`. |
103
103
  | `agent_denied` | `sessionId`, `runId`, redacted `interruption`, checkpoint `version`; no tool side effect runs. |
@@ -124,7 +124,7 @@ Tool execution events:
124
124
  | `tool_execution_progress` | `sessionId`, `runId`, `toolCallId`, `name`, `progress?`, `metadata?` |
125
125
  | `tool_execution_finished` | `sessionId`, `runId`, `result: ToolResult`, `metadata: ToolExecutionMetadata` |
126
126
  | `tool_execution_error` | `sessionId`, `runId`, `call: ToolCallContent`, `error: ErrorInfo`, `metadata: ToolExecutionMetadata` |
127
- | `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
128
  | `tool_narrowing_clamped` | `sessionId`, `runId`, `turn`, `dropped: readonly string[]` (names the host returned outside the run grant; no tool args) |
129
129
 
130
130
  Guardrail events:
@@ -197,11 +197,13 @@ value when the adapter saw a native reason.
197
197
  | `unknown` | Unmapped or absent native reason |
198
198
 
199
199
  `provider_turn_finished.metadata.budgets` is an O(1) snapshot from the run limit tracker:
200
- `{ inputTokens?, inputCap?, runInputBudget?, runInputUsed, turns, maxTurns }` — current-turn
201
- provider-reported input tokens against the resolved per-request input cap, cumulative run input
202
- against `limits.maxInputTokens`, and provider turns against `limits.maxTurns` (`null` when
203
- disabled). Optional fields are absent when the provider reported no usage or no input cap can be
204
- derived; hosts that ignore the fields are unaffected.
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.
205
207
 
206
208
  `provider_turn_started` / `provider_turn_finished` metadata includes `tools: { count, idsHash }` for the
207
209
  effective menu sent on that request (after run scoping, per-turn `toolNarrowing`, and disclosure).
@@ -298,6 +300,8 @@ for await (const event of session.stream("draft", { loop: { strategy: "generate-
298
300
 
299
301
  ## Extension and configuration notes
300
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
+
301
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.
302
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).
303
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`.
@@ -14,6 +14,7 @@ The agent/session runtime adds the minimal shared SDK surface for running provid
14
14
  - `session.compact(options?)`
15
15
  - `session.contextMeter()` → `ContextMeter` (latest provider-turn input tokens, reported or labeled estimate, with cap/budget/ratio)
16
16
  - `session.subscribe(options?)`
17
+ - `session.close()` → dispatches `session_shutdown` middleware once, then closes every subscriber
17
18
  - `session.abort()`
18
19
  - `session.entries()`
19
20
  - `session.checkout(leafId?)`
@@ -58,13 +59,15 @@ string | Message | readonly Message[]
58
59
 
59
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.
60
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
+
61
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.
62
65
 
63
- `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`.
64
67
 
65
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.
66
69
 
67
- `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.
68
71
 
69
72
  For a text-only provider turn, the runtime emits:
70
73
 
@@ -178,17 +181,17 @@ await agent.createSession().run("Hi", { model: overrideModel });
178
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.
179
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.
180
183
  - Runtime events contain messages/content only; do not put secrets in prompts, metadata, provider events, session entries, or docs examples.
181
- - 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.
182
185
 
183
186
  ## Durable interruption
184
187
 
185
- 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.
186
189
 
187
190
  `resumeAgentRun` accepts exactly one of:
188
191
 
189
192
  - `decision: "approve" | "deny"` — legacy single-approval path. `approve` allows every pending decision once; `deny` terminates the run as `denied`.
190
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.
191
- - `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.
192
195
 
193
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.
194
197
 
@@ -205,7 +208,7 @@ if (result.status === "suspended") {
205
208
  }
206
209
  ```
207
210
 
208
- 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).
209
212
 
210
213
  ## Secure composition
211
214
 
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.
@@ -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
 
@@ -88,6 +88,7 @@ Key exports:
88
88
  | `renderObservationalMemory()` | Render reflections and observations into a prepared memory summary. |
89
89
  | `recallObservationalMemory()` | Recover source evidence for a known observation/reflection id from supplied current-branch entries. `invalidatedIds` withholds content (`reason: "revoked"`) without injecting derived text. |
90
90
  | `listInvalidatedIds()` (`@arnilo/prism-memory`) | Read the ids one exact scope currently withholds (`corrected` stays) and pass them as `invalidatedIds`, so blocks that rest on a source revoked mid-turn go stale on the next build. Empty for stores without lineage invalidation. |
91
+ | `createObservationalMemoryDropHandler()` | The OM leg of `createDeletionPropagator`: folds the session ledger once and appends one `om.observations.dropped` entry naming every active observation whose id or `sourceEntryIds` intersect the tombstone set (`coversUpToId` omitted — a tombstone set is not a coverage position). Register with `{ session, appendEntry }`; pair it with `listInvalidatedIds()` for the read path. |
91
92
  | `recallObservationalMemoryBranchPage()` | Page eligible user/assistant/tool messages around a cursor entry id (`forward`/`backward`, optional `detail: summary|full`). |
92
93
  | `createMemoryId()` / `isMemoryId()` | Create/check 12-character ids. |
93
94
  | `resolveObservationalMemorySettings()` | Merge `observational-memory` settings with defaults and overrides. |
@@ -101,6 +102,25 @@ Key exports:
101
102
 
102
103
  Pure utilities create no events, workers, tools, commands, credentials, or provider requests. `createObservationalMemoryExtension()` and import alone start nothing. `createObservationalMemory().attach()` runs workers only after proxied `run`/`prompt`/`stream`/`compact` complete (or after `wrapResumeRun` / `wrapResumeStream`). `createObservationalMemoryRuntime().flush()` remains for manual/advanced use. Attached `contextProvider` renders two blocks each turn: `observational-memory` (active reflections/observations aligned to the recent-message boundary) and `recent-messages` (last `keepRecentEntries` message entries in branch order, optionally trimmed by `recentMessageMaxTokens` using `estimateEntryTokens`; oldest dropped first). Compaction uses the same `keepRecentEntries` setting. Observer input includes only eligible `message` entries (`user`, `assistant`, `tool`); memory/compaction/bookkeeping entries advance `coversUpToId` scan coverage without entering the observer prompt. Successful observer/reflector runs append coverage markers even when they record zero facts. Reflection uses only active observations recorded after the last `om.reflections.recorded` entry unless `flush({ fullReflectionRebuild: true })`. Attached `flush()` skips with `run_active` while a proxied run is in flight. The compaction strategy is O(n) over supplied entries and makes no provider call.
103
104
 
105
+ ### Revocation wiring (plan 102 Tasks 2/8)
106
+
107
+ A revoked source must stop feeding memory on both sides of the write. The two halves share one tombstone set:
108
+
109
+ ```ts
110
+ import { listInvalidatedIds } from "@arnilo/prism-memory";
111
+ import { buildObservationalMemoryContextBlocks, createObservationalMemoryDropHandler } from "@arnilo/prism-memory/compaction/observational-memory";
112
+
113
+ // Write path: register the OM leg on the host's deletion propagator (see docs/rag.md for the full wiring).
114
+ const handlers = [createObservationalMemoryDropHandler({ session, appendEntry })];
115
+
116
+ // Read path: withhold at build time for a projection whose ledger has no drop entry yet.
117
+ const blocked = await listInvalidatedIds(store, scope); // one scope read; `corrected` ids stay
118
+ const blocks = buildObservationalMemoryContextBlocks(entries, { invalidatedIds: blocked });
119
+ ```
120
+
121
+ - Both paths render the same memory for the same tombstones, so an id is withheld whether or not the physical drop ran — the drop entry answers "what did the revocation retire" for audit, `invalidatedIds` answers "what must not be injected right now".
122
+ - The drop entry carries observation ids only (no observation text), and the fold treats it as a drop rather than progress (`coversUpToId` omitted).
123
+
104
124
  ### Work-scope index (opt-in)
105
125
 
106
126
  `WorkScope` is a host-named, append-only index over one observational-memory ledger. Without `om.scope.*` entries, the map has only its implicit `session` root, context renders the existing active pool, and the dropper keeps its existing behavior. Shared work scopes extend that index across sessions under explicit grants — see "Shared work scopes (opt-in)" below.
@@ -237,7 +257,7 @@ Worker `provider.generate` calls use a **derived** correlation id `om:{session.i
237
257
 
238
258
  The runtime requires host-supplied `session`, an `appendEntry` callback bound to that session's owning store/branch, and at least one worker provider (`observation.provider` / `reflection.provider` / `dropper.provider`). Model selection uses [use-case model selection](use-case-model-selection.md): pass per-worker `model` (or settings `observation.model` / `reflection.model` / `dropper.model`) to override, and `sessionModel: agent.config.model` so workers fall back to the session model when no worker model is configured. `requireExplicitModel: true` restores the historical `missing_model` skip when no explicit worker model is set. It no longer accepts a separate `store` option because mismatched session/store pairs can append memory entries outside the active branch. After each memory append, the runtime checks the appended entry is visible at the session leaf and fails closed/restores the previous checkout if the callback points elsewhere. Optional credential resolution is explicit; missing requested credentials skip worker execution. Default credential requests use the **resolved** model's provider id.
239
259
 
240
- `createObservationalMemoryCompactionStrategy()` keeps recent message entries like the default compaction strategy, renders existing observations/reflections as the summary, and returns a standard Prism compaction entry. Its `data` includes `throughEntryId`, `keepEntryIds`, `strategy`, `trigger`, and `memory: { type: "om.folded", version: 1, fullFold, observations, reflections, droppedObservationIds }`. When active observations exceed `context.observationsPoolMaxTokens`, it performs a full fold and synchronously trims lowest-relevance observations until the folded payload fits hard byte/token caps (or throws a typed error).
260
+ `createObservationalMemoryCompactionStrategy()` keeps recent message entries like the default compaction strategy, renders existing observations/reflections as the summary, and returns a standard Prism compaction entry. Its `data` includes `throughEntryId`, `keepEntryIds`, `strategy`, `trigger`, and `memory: { type: "om.folded", version: 1, fullFold, observations, reflections, droppedObservationIds }`. When active observations exceed `context.observationsPoolMaxTokens`, it performs a full fold and synchronously trims lowest-relevance observations until the folded payload fits hard byte/token caps (or throws a typed error). Because the runtime routes both compaction paths through the pre-strategy `compaction_request` middleware hook, a host can also rewrite the entries this strategy folds without forking it — see [Middleware hooks](middleware-hooks.md).
241
261
 
242
262
  `createRecallMemoryTool()` accepts either `{ id }` for exact memory recall or `{ cursor, limit?, direction?, detail? }` for current-branch raw-message paging (default limit 20, hard cap 100). Reflection recall resolves supporting observations from the full ledger and reports `droppedSupportingObservationIds` / `missingSupportingObservationIds`; dropped supports still return available raw sources. Invalid ids, ambiguous requests, wrong `sessionId`, missing cursors, non-message cursors, and oversized pages fail closed. It does not search by topic.
243
263
 
@@ -12,7 +12,7 @@ This is crash recovery for the in-run state, not an orchestrator. The host workf
12
12
  - The host wants a bounded, explicit recovery point rather than "restart the whole run".
13
13
  - An external orchestrator needs to resume a single run without replaying its tools.
14
14
 
15
- For approval suspension and batch decisions, see [Agent/session runtime § Durable interruption](agent-session-runtime.md#durable-interruption); `every-turn` is additive to that machinery and uses the same store, redaction, bounds, fingerprint, and CAS.
15
+ For approval suspension and batch decisions — including approve-with-edits revalidation against the session's restored pack rules — see [Agent/session runtime § Durable interruption](agent-session-runtime.md#durable-interruption); `every-turn` is additive to that machinery and uses the same store, redaction, bounds, fingerprint, and CAS.
16
16
 
17
17
  ## Inputs / request
18
18
 
@@ -23,7 +23,7 @@ For approval suspension and batch decisions, see [Agent/session runtime § Durab
23
23
  | `checkpointPolicy` | `"decision"` (default) persists only on suspension/terminal status. `"every-turn"` adds one running-state checkpoint per provider turn. |
24
24
  | `checkpoints` | The host's `CheckpointStore`; the same store serves suspension, crash recovery, and status. |
25
25
  | `definitionRevision` | Host-authored revision participating in the fingerprint; a change without a revision bump refuses resume. |
26
- | `persistSessionState` | Also carries loaded-skill names and the attention sticky frontier into each turn checkpoint. |
26
+ | `persistSessionState` | Also carries loaded-skill names, the attention sticky frontier, and (plan 104 Task 2/3) the session's guardrail pack refs (or an inline pack's pattern rules) plus each pack's own state snapshot into each turn checkpoint. |
27
27
  | `includeSkillBodies` | Alongside `persistSessionState`, carries exact skill instructions. |
28
28
  | `maxStateBytes` | Save-side byte ceiling (default 256 KB, hard 1 MB). Applies to every turn checkpoint identically. |
29
29
  | `checkpointMetadata` | Sidecar map (`Record<string, string>`, ≤ 4 KB, redacted) written with every checkpoint record — never inside the state value, so it costs no `maxStateBytes` budget. A function is resolved at each write, so a host closure can pin state that moves mid-run (git commit, document version). |
@@ -72,11 +72,12 @@ Resume uses `resumeAgentRun` / `resumeAgentRunStream` with `{ expectedVersion, d
72
72
 
73
73
  ## Outputs / response / events
74
74
 
75
- Each turn checkpoint is a normal durable state (schema v1) carrying status `running`, the current `leafId`, run counters and wall deadline, loop-local state when the loop declares `snapshot`/`restore`, the run's `toolNames` grant, and — with `persistSessionState` — the loaded-skill catalog plus sticky attention frontier. Hard gates are unchanged: CAS `expectedVersion`, ownership/fencing, redaction at the checkpoint boundary, `maxStateBytes`, and the agent fingerprint (`agentFingerprint`) over id, revision, model, instructions, system prompt, skills, tools, guardrails, and loop revision.
75
+ Each turn checkpoint is a normal durable state (schema v1) carrying status `running`, the current `leafId`, run counters and wall deadline, loop-local state when the loop declares `snapshot`/`restore`, the run's `toolNames` grant, and — with `persistSessionState` — the loaded-skill catalog, sticky attention frontier, and the guardrail pack block (`sessionState.guardrailPacks`: `{ packs: [{ id, version, options?, rules? }], state?: { <packId>: <pack state> } }`, ≤ 8 packs and ≤ 64 rules each, ≤ 8 KiB per pack row/rule/options/state, ids ≤ 96 chars, redacted like all state). Measured cost: a 656-byte `persistSessionState` checkpoint grows by 185 bytes for all four built-in packs' rows (≈ 46 bytes/pack); a `validation-respect` row with non-default options and live `validationFailed` state adds ≈ 169 bytes in total. Hard gates are unchanged: CAS `expectedVersion`, ownership/fencing, redaction at the checkpoint boundary, `maxStateBytes`, and the agent fingerprint (`agentFingerprint`) over id, revision, model, instructions, system prompt, skills, tools, guardrails, and loop revision.
76
76
 
77
77
  A crash leaves the last checkpoint at status `running`. `decision: "continue"` accepts exactly that: a running checkpoint with no interruption and no unresolved pending decisions. Everything else fails closed with `AgentRunStateError` and zero checkpoint writes:
78
78
 
79
79
  - `expectedVersion` mismatch, ownership/fencing mismatch, revision or fingerprint mismatch (`Stale or non-running agent run resume`, `Agent revision or fingerprint mismatch on resume`).
80
+ - `sessionState.guardrailPacks` that cannot be replayed: an unknown pack id, an inline rule carrying a `deny` predicate or `RegExp` pattern, a row `version` that no longer matches the installed pack definition, a pack whose state has no codec, more than 8 rows (or 64 rules in one), or malformed/oversized pack state. The resume refuses with `AgentRunStateError` and dispatches nothing — a suspended run's enforcement never silently downgrades. A checkpoint written before plan 104 (no key) resumes with no packs and no error.
80
81
  - Status `suspended` — approvals, elicitations, and input guardrails still require `approve`/`deny` or a `RunDecision` batch; `continue` never bypasses a gate.
81
82
  - Any interruption, pending decision, or ready-to-dispatch pending call recorded in the state.
82
83
 
@@ -91,7 +91,11 @@ await runEmbeddingsConformance({
91
91
  reranker (`resolveReranker({ kind: "local" })` / `createLocalReranker`) runs a
92
92
  cross-encoder in the host process through a `LocalRerankRuntime`, so no
93
93
  inference dependency name enters any manifest — see
94
- [RAG local reranker](rag.md#local-reranker).
94
+ [RAG local reranker](rag.md#local-reranker). A host that runs both an embedder
95
+ and the reranker through the same runtime should point them at one weight cache:
96
+ one `cacheDir` per host (e.g. `~/.cache/prism/models`), one subdirectory per
97
+ model id, so each model is downloaded once and shared by every process on that
98
+ host; a cache miss downloads into that directory and later runs stay on disk.
95
99
  - Adapters never auto-chunk: a batch over the provider cap rejects with
96
100
  `batch_too_large`, so `embedBatched`-style callers own batching and preserve
97
101
  per-item error attribution.
@@ -139,7 +139,8 @@ interface TimelineExhaustion {
139
139
 
140
140
  `turns` is the per-turn trace, derived in one pass over folded provider steps: turn number, status,
141
141
  timing, attempts (retries included), input-token-weighted `cacheHitRate`, the last provider
142
- attempt's recorded `budgets`, and its stop reason. Cache rate is absent when cache usage is unknown;
142
+ attempt's recorded `budgets` (with its `inputTokensSource` provenance label), and its stop reason.
143
+ Cache rate is absent when cache usage is unknown;
143
144
  `budgets` is copied verbatim from `provider_turn_finished.metadata.budgets` and is absent on legacy
144
145
  events. `ExecutionTimeline.cacheHitRate` is the same input-token-weighted calculation across all
145
146
  provider attempts. The stop reason also rides the `provider` step's metadata (`metadata.stopReason`),
@@ -155,7 +156,7 @@ first group and empty axes. Argument hashes only — `recentToolCalls` never con
155
156
 
156
157
  `deterministic_turn` folds into a `"deterministic"` step whose `name` is the answering middleware id and whose metadata carries `{ turn, middleware }`. A deterministic turn has no provider step, no `usage`, and no `stopReason`, so a host-answered turn can never be read as model output; its `turns` entry carries `providerAttempts: 0`, and `summarizeTimeline()`/`summarizeSession()` split the turn count into `turns: { model, deterministic }`. The same provenance is copied onto the assistant message as `message.metadata.deterministic = { middleware }`, so the persisted transcript alone proves the turn had no model behind it.
157
158
 
158
- `guardrail_decision` folds into a `"guardrail"` step whose `name` is the stage (`input`/`output`/`tool_input`/`tool_output`) and whose metadata carries `action`, the rule identity `metadata.guardrail` (compiled packs name it `pack:<pack>/<rule>`, other guardrails their configured name), and `toolName`/`toolCallId` when the decision is tool-scoped. A denying action (`deny`, `block`, `tripwire`) sets status `denied`; the free-text guardrail reason stays on the event, not the step.
159
+ `guardrail_decision` folds into a `"guardrail"` step whose `name` is the stage (`input`/`output`/`tool_input`/`tool_output`) and whose metadata carries `action`, the rule identity `metadata.guardrail` (compiled packs name it `pack:<pack>/<rule>`, other guardrails their configured name), and `toolName`/`toolCallId` when the decision is tool-scoped. A denying action (`deny`, `block`, `tripwire`) sets status `denied`; `interrupt` (a pack `ask` rule in a durable run) sets status `succeeded` and leaves the run `suspended` awaiting a decision, while the same rule in a run that cannot suspend reports `block` and sets `denied`; the free-text guardrail reason stays on the event, not the step.
159
160
 
160
161
  Step statuses: `"running"`, `"succeeded"`, `"failed"`, `"blocked"`, `"skipped"`, `"suspended"`, `"denied"`, `"aborted"`.
161
162
 
@@ -8,6 +8,7 @@ APIs:
8
8
 
9
9
  - `createExtensionKernel()` / `ExtensionKernel`
10
10
  - `createExtensionEventBus()` / `ExtensionEventBus`
11
+ - `forwardAgentEvents()` / `AgentEventBridgeOptions`
11
12
  - `ExtensionAPI`, `ExtensionEvent`, and `extension_error` events
12
13
  - Shared `MiddlewareRegistry` access and `api.use()` registration
13
14
 
@@ -42,7 +43,8 @@ createExtensionEventBus(options?: { errorPolicy?: "event" | "throw"; secrets?: r
42
43
  - `kernel.events.on(type, handler)` registers ordered event handlers and returns an unsubscribe function.
43
44
  - `kernel.events.emit(event)` calls matching handlers in registration order.
44
45
  - `kernel.middleware.run(hook, value)` runs matching middleware in registration order.
45
- - `activateKernel(kernel)` copies the `createAgent()` array slots into one config: `{ tools, skills, instructionInjectors, context, commands, middleware }`. Contributions stay inert until the host passes them into runtime config; single-slot builders, `compaction`/`retry`, provider/model selection, and skill activation remain host-owned decisions.
46
+ - `forwardAgentEvents(source, events, options?)` is host-invoked wiring for a live `AgentEvent` stream: it maps `agent_started` → `before_agent_start`, `turn_started`/`turn_finished` → `turn`, `tool_execution_started` → `tool_call`, and `tool_execution_finished` → `tool_result`, carrying the original event as read-only `payload`. Other events are ignored. Handlers run in event order and never in the run's path, so a slow or throwing listener cannot stall or fail the observed run; the returned function stops forwarding and releases the source iterator. Bridge failures go to `options.onError` (or become `extension_error` under the bus's own policy) — never to the run.
47
+ - `activateKernel(kernel)` copies the `createAgent()` array slots into one config: `{ tools, skills, instructionInjectors, context, stopHooks, commands, middleware }`. Contributions stay inert until the host passes them into runtime config; single-slot builders, `compaction`/`retry`, provider/model selection, and skill activation remain host-owned decisions.
46
48
  - With default `errorPolicy: "event"`, setup/listener/middleware errors become `extension_error` events with redacted `ErrorInfo`.
47
49
  - With `errorPolicy: "throw"`, setup/listener/middleware errors reject/throw.
48
50
 
@@ -58,7 +60,7 @@ createExtensionEventBus(options?: { errorPolicy?: "event" | "throw"; secrets?: r
58
60
  ## Implementation example
59
61
 
60
62
  ```ts
61
- import { activateKernel, createAgent, createExtensionKernel, type Extension } from "@arnilo/prism";
63
+ import { activateKernel, createAgent, createExtensionKernel, forwardAgentEvents, type Extension } from "@arnilo/prism";
62
64
 
63
65
  const extension: Extension = {
64
66
  name: "demo-extension",
@@ -76,9 +78,11 @@ const extension: Extension = {
76
78
  api.registerAgent({ name: "demo", create: () => createAgent({ model, provider }) });
77
79
  api.registerCompactionStrategy({ name: "compact", compact: () => ({ summary: "summary" }) });
78
80
  api.registerRetryPolicy({ name: "retry", decide: () => ({ retry: false }) });
79
- api.on("session_start", (event) => {
81
+ api.registerStopHook({ name: "checklist", decide: (ctx) => (ctx.stopHookActive ? { action: "stop" } : { action: "continue", reason: "Verify the checklist." }) });
82
+ api.on("demo:ready", (event) => {
80
83
  console.log(event.type);
81
84
  });
85
+ api.use("session_start", (payload) => payload);
82
86
  api.use("provider_request", (request) => request);
83
87
  api.use("compaction", (payload) => payload);
84
88
  api.use("retry", (payload) => payload);
@@ -105,9 +109,16 @@ const agent = createAgent({
105
109
  tools: activated.tools,
106
110
  skills: activated.skills,
107
111
  instructionInjectors: activated.instructionInjectors,
112
+ stopHooks: activated.stopHooks,
108
113
  context: activated.context,
109
114
  middleware: activated.middleware,
110
115
  });
116
+
117
+ // Forward live AgentEvents onto the bus; stop() ends forwarding and releases the subscription.
118
+ const session = agent.createSession();
119
+ const stop = forwardAgentEvents(session.subscribe(), kernel.events, { onError: (error) => console.warn(error) });
120
+ // const run = await session.run("Hi");
121
+ // stop();
111
122
  ```
112
123
 
113
124
  ## Extension and configuration notes
@@ -120,6 +131,9 @@ const agent = createAgent({
120
131
  - `api.registerInputBuilder()`, `api.registerPromptBuilder()`, and `api.registerContextProvider()` contribute inert builders/providers; they do not replace defaults or run until the host passes selected entries to Phase 5 helpers.
121
132
  - `api.registerSkill()` contributes an inert `Skill` to `registries.skills`; it does not disclose instructions, activate referenced tools, or grant permissions until the host selects it.
122
133
  - `api.registerInstructionInjector()` (Phase 30) contributes an inert `InstructionInjector` to `registries.instructionInjectors`; it grants no tools, skills, or permissions and is only applied when the host selects it via `AgentConfig.instructionInjectors`/`RunOptions.instructionInjectors`. See [Instruction injection](instruction-injection.md).
134
+ - `api.registerStopHook()` contributes an inert run-end `StopHook` to `registries.stopHooks`; `activateKernel()` copies it into `stopHooks` for `createAgent({ stopHooks })`, and `LoadedExtension.dispose()` unwinds it. Hooks decide at a natural loop end only — see [Hooks](hooks.md).
135
+ - `forwardAgentEvents()` is host-invoked wiring, not a runtime default, and it observes only: the bus never transforms what the run sees. Prefer `session.subscribe()` directly when the host wants the raw stream; use the bridge when extension packages already listen on the bus.
136
+ - Session lifecycle middleware (`session_start`/`session_shutdown`) is dispatched by the agent/session runtime when the host passes its registry to `AgentConfig.middleware` — see [Middleware hooks](middleware-hooks.md).
123
137
  - `api.registerProviderPackage()`, `api.registerAuthMethod()`, `api.registerProviderRequestPolicy()`, and `api.registerSystemPromptContribution()` contribute inert provider-package data; they do not load packages, resolve credentials, mutate provider payloads, or change prompts until selected by a host/runtime helper that documents that behavior.
124
138
  - `api.registerAgent()` contributes an inert `AgentDefinition`; its `create()` can call `createAgent()`, but the runtime is not started until host code resolves the definition and creates/runs a session.
125
139
  - The kernel registers middleware only into the explicit registry returned by `createMiddlewareRegistry()` or provided by the host.
@@ -144,7 +158,10 @@ const agent = createAgent({
144
158
  - [Contribution registries](contribution-registries.md): registry bundle populated by `ExtensionAPI`.
145
159
  - [Contribution discovery (workspace)](contribution-discovery.md): filesystem-driven complement to extension registration — opt-in scan without `import()` or activation.
146
160
  - [Tools](tools.md): host activation, filtering, and dispatch for contributed tool definitions.
161
+ - [Middleware hooks](middleware-hooks.md): hook names, payloads, and the dispatched `session_start`/`session_shutdown` call sites.
162
+ - [Agent events](agent-events.md): the `AgentEvent` union the bridge forwards.
147
163
  - [Instruction injection](instruction-injection.md): package injectors that layer instructions and context blocks for `first_turn`/`every_turn`/`on_input` without granting tools.
164
+ - [Hooks](hooks.md): the hook model and event map, plus run-end stop hooks contributed through `ExtensionAPI.registerStopHook()`.
148
165
  - [Input and prompt assembly](input-and-prompt-assembly.md): host selection for contributed input/prompt builders.
149
166
  - [System prompts](system-prompts.md): host selection for contributed system prompt layers.
150
167
  - [Context and skills](context-and-skills.md): host selection and tool checks for contributed context providers and skills.
@@ -6,7 +6,7 @@ Guardrails are typed, fail-closed checks at input, completed provider output, to
6
6
 
7
7
  ## When to use it
8
8
 
9
- Use guardrails to block unsafe prompts, model responses, tool arguments, or tool results before their next boundary. Use a redactor for known secrets. Do not treat guardrails as a sandbox, secret detector, permission policy, or validation replacement.
9
+ Use guardrails to block unsafe prompts, model responses, tool arguments, or tool results before their next boundary. Use a redactor for known secrets. Do not treat guardrails as a sandbox, secret detector, permission policy, or validation replacement. For how guardrails combine with middleware, injectors, and stop hooks — and where each Claude Code / Codex hook event lands — see [Hooks](hooks.md).
10
10
 
11
11
  ## Inputs / request
12
12
 
@@ -40,7 +40,7 @@ Action outcome by stage:
40
40
  | --- | --- | --- | --- |
41
41
  | `input` | run rejected (`GuardrailError`); steered message: dropped + `steer_rejected`, run continues | run rejected; steered message: dropped + `steer_rejected`, run continues | fresh durable run: suspends for approval; otherwise fails closed |
42
42
  | `output` | run rejected | run rejected | fails closed (`ERR_PRISM_GUARDRAIL_INTERRUPT_UNAVAILABLE`) |
43
- | `tool_input` | blocked `ToolResult`, run continues | run rejected | fails closed |
43
+ | `tool_input` | blocked `ToolResult`, run continues | run rejected | fails closed for a hand-written guardrail; a compiled pack `ask` rule gates the call instead (see [Guardrail packs § `ask`](#asking-for-approval-ask-rules)); approve-with-edits decisions are revalidated at decision time, so an edit into a pack-violating state is refused with `ERR_PRISM_DECISION_INVALID` before the decision is recorded |
44
44
  | `tool_output` | blocked `ToolResult`, run continues | run rejected | fails closed |
45
45
 
46
46
  The `GuardrailError` message names the stage so unsupported `interrupt` placements are diagnosable without reading core source.
@@ -74,7 +74,9 @@ await agent.createSession().run("Draft reply", { guardrails: { toolInput: [comma
74
74
 
75
75
  ## Guardrail packs
76
76
 
77
- A pack is configuration, not code: rules compile once per session onto the existing `tool_input` / `tool_output` seams. Packs can only deny or tripwire — they never grant permissions, widen arguments, or add a stage. A rule that matches produces the standard refusal-shaped `ToolResult`; `tripwire` additionally rejects the enclosing run.
77
+ A pack is configuration, not code: rules compile once per session onto the existing `tool_input` / `tool_output` seams. Packs can only deny, tripwire, or ask for approval — they never grant permissions, widen arguments, or add a stage. A `deny` rule that matches produces the standard refusal-shaped `ToolResult`; `tripwire` additionally rejects the enclosing run.
78
+
79
+ A blocked call tells the model which rule refused it, so it stops retrying the same call: `ToolResult.error.message` is `Blocked by guardrail rule <identity>` — with `: <reason>` appended when the pack configured one, where `<identity>` is `pack:<pack>/<rule>` — bounded to 200 bytes and redacted like every other guardrail record. Tool arguments never appear in the text. A guardrail the host wrote by hand keeps the neutral `Tool call blocked by guardrail` / `Tool result blocked by guardrail` line: a pack identity is taken from the compiled rule's metadata, never synthesized from a name. Compiler-synthesized default reasons are omitted rather than echoed twice, and a reason that would push the line past the cap is truncated, so the identity always survives. The same text lands on the `tool_execution_blocked` event's `error.message` while the event's `reason` stays the machine code (`guardrail_blocked`).
78
80
 
79
81
  ```ts
80
82
  const session = agent.createSession({
@@ -100,9 +102,17 @@ Built-in pack ids are public surface and versioned:
100
102
  | `validation-respect` | `no-mutation-after-failed-validation` | Observes `options.validationTools` (default `test`, `run_tests`, `validate`, `validation`, `lint`, `typecheck`, `check`). A result carrying an error or a non-zero `exitCode` marks validation failed; the next successful validation clears it. Opt `shell` in explicitly when validations run through the shell tool. |
101
103
  | `secrets-hygiene` | `no-secret-material-in-arguments` | Scans argument strings (bounded depth and count) for credential shapes: `sk-`, `gh[pousr]_`, `AKIA…`, PEM private-key headers, JWTs, `xox[baprs]-`. Prism redaction replaces exact known values only, so these patterns ship with the pack. |
102
104
 
103
- Inline rule shape: exactly one of `pattern` (string or `RegExp`, compiled once) or `deny(args, context)` (typed predicate, host-trusted like all host code); optional `tool` (name or names; omitted matches every tool), `argPath` (dot path or paths such as `command` or `["from", "to"]`; omitted scans every argument string), `action` (`deny` default, or `tripwire`), and `reason`. Predicates receive `{ toolName, toolCallId, sessionId, runId, metadata, state }`, where `state` is pack-local and read-only. `action: "ask"` is rejected: the tool stage has no deterministic approval seam.
105
+ Inline rule shape: exactly one of `pattern` (string or `RegExp`, compiled once) or `deny(args, context)` (typed predicate, host-trusted like all host code); optional `tool` (name or names; omitted matches every tool), `argPath` (dot path or paths such as `command` or `["from", "to"]`; omitted scans every argument string), `action` (`deny` default, `tripwire`, or `ask`), and `reason`. Predicates receive `{ toolName, toolCallId, sessionId, runId, metadata, state }`, where `state` is pack-local and read-only.
106
+
107
+ ### Asking for approval (`ask` rules)
108
+
109
+ `action: "ask"` gates exactly the calls the rule matches, without the all-tools `interruptBeforeTool` gate. It requires `pattern` (an opaque `deny` predicate cannot raise an approval — it stays available for silent denials) and behaves by run shape:
110
+
111
+ - **Durable run** (`runState` set): the call suspends before dispatch, in a `tool_approval` interruption whose reason names `pack:<pack>/<rule>` and whose pending decision also carries `guardrail` and `guardrailRule: { pack, rule }`. `allow_once` dispatches once; `allow_for_run` sticks to the same decision scope (tool, argument hash, effect kind, identity); `reject_once`/`reject_for_run` continue the run with a refusal-shaped `ToolResult` and never execute the tool. The gate costs one rule evaluation per tool call at charge time, and the decision reaches the timeline as a `guardrail` step with `action: "interrupt"`.
112
+ - **Non-durable run** (no `runState`): nothing can resume a suspension, so the rule is an ordinary `block` — the run continues, the call never executes, and `ToolResult.error.message` names the rule (`Blocked by guardrail rule pack:<pack>/<rule>` plus the pack's `reason` when it configured one, bounded to 200 bytes).
113
+ An approval never widens a pack: after `allow_*` the call still runs the ordinary `tool_input` stage, so a `deny` rule that matches the same call blocks it. An approve-with-edits decision is revalidated against the packs the resumed session restored — the `deny`/`tripwire` rules plus the `ask` rules compiled as blocks — before the decision is recorded, so an edited argument set that still trips a rule is refused at decision time with `ERR_PRISM_DECISION_INVALID` naming the rule (bounded and redacted, never echoing the arguments). The rules come from the session's checkpoint-restored packs and are passed in explicitly, never merged into `agent.config.guardrails`, so no other session of that agent inherits them.
104
114
 
105
- Every evaluated rule emits a `guardrail_decision` event; the denying record's `guardrail` is `pack:<pack>/<rule>` and its `metadata` is `{ pack, rule, version }` — never tool arguments. `describeGuardrailPacks(refs)` returns the same identity rows (`pack:<pack>/<rule>`, stage, `pack@version`) that `snapshotRunBundle()` reports for the session config. Malformed config (unknown id, duplicate pack or rule id, both `pattern` and `deny`, invalid regex, `ask`) throws `GuardrailPackError` at session creation instead of silently dropping a rule.
115
+ Every evaluated rule emits a `guardrail_decision` event; the denying record's `guardrail` is `pack:<pack>/<rule>` and its `metadata` is `{ pack, rule, version }` — never tool arguments. `describeGuardrailPacks(refs)` returns the same identity rows (`pack:<pack>/<rule>`, stage, `pack@version`) that `snapshotRunBundle()` reports for the session config. Malformed config (unknown id, duplicate pack or rule id, both `pattern` and `deny`, invalid regex, `ask` with a `deny` predicate, an unknown `action` value) throws `GuardrailPackError` at session creation instead of silently dropping a rule. Under `persistSessionState` (plan 104 Task 2/3), the checkpoint also carries each pack's row — a registered pack by `{ id, version, options? }`, an inline pack by its `pattern` rules (a `deny` predicate or `RegExp` pattern cannot round-trip and refuses the save) — plus its own state-codec output (≤ 8 packs, ≤ 8 KiB per pack, ids ≤ 96 chars); a resume recompiles the rows and refuses with `AgentRunStateError` on an unknown id, a version mismatch against the installed pack, or malformed/oversized state — a suspension never silently downgrades enforcement. `session.guardrailPackRefs` exposes the refs a session (including a resumed one) actually enforces.
106
116
 
107
117
  On the observability timeline each guardrail step carries that identity in `metadata.guardrail` (with `status: "denied"` when it denied — the free-text reason stays off the step to keep metadata low-cardinality), so evals can grade enforcement without reading tool arguments: `createGuardrailPackScorer()` from `@arnilo/prism-core/governance/evals` scores a denied `pack:` rule as a failed trajectory and names it. The built-in packs are covered by violating/compliant scenario pairs in `packages/prism-core/src/governance/evals/__tests__/guardrail-pack-scenarios.test.ts` (see [Evaluations](evaluations.md#guardrail-pack-trajectory-scenarios-plan-092)).
108
118
 
@@ -141,7 +151,7 @@ With `onViolation: "block"`, the standard `GuardrailError` has `reason: "claim_u
141
151
 
142
152
  ## Extension and configuration notes
143
153
 
144
- Guardrails are callbacks supplied by the host. Prism does not discover, load, retry, or persist callback code. `createSecureAgent()` keeps configured guardrails and only appends run-level checks; it never lets a run remove secure defaults. Custom loops receive guarded `LoopContext.generate()` and `LoopContext.dispatchToolCall()`; host code that directly calls a provider or `ToolDefinition.execute()` is outside the runtime boundary. Guardrail packs follow the same rule: they are host-supplied config, compiled in memory per session, never discovered from disk or persisted by Prism.
154
+ Guardrails are callbacks supplied by the host. Prism does not discover, load, retry, or persist callback code. `createSecureAgent()` keeps configured guardrails and only appends run-level checks; it never lets a run remove secure defaults. Custom loops receive guarded `LoopContext.generate()` and `LoopContext.dispatchToolCall()`; host code that directly calls a provider or `ToolDefinition.execute()` is outside the runtime boundary. Guardrail packs follow the same rule: they are host-supplied config, compiled in memory per session, never discovered from disk. Their compiled identity and pack-owned state persist only inside an opt-in durable checkpoint (`persistSessionState`, see above) and nowhere else.
145
155
 
146
156
  ## Security and performance notes
147
157