@arnilo/prism 0.9.0 → 0.11.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.
- package/CHANGELOG.md +50 -1
- package/README.md +19 -16
- package/dist/agent-approval.d.ts +7 -1
- package/dist/agent-approval.js +15 -6
- package/dist/agent-run-lifecycle.d.ts +2 -1
- package/dist/agent-run-lifecycle.js +20 -6
- package/dist/agent-run-state.d.ts +26 -5
- package/dist/agent-run-state.js +97 -1
- package/dist/agent-session/event-subscriber.d.ts +2 -0
- package/dist/agent-session/event-subscriber.js +3 -0
- package/dist/agent-session/session/assemble.js +165 -16
- package/dist/agent-session/session/persist.js +11 -5
- package/dist/agent-session/session/provider-round.js +54 -13
- package/dist/agent-session/session/tool-round.d.ts +2 -2
- package/dist/agent-session/session/tool-round.js +86 -23
- package/dist/agent-session/session/types.d.ts +21 -2
- package/dist/agent-session/session.d.ts +66 -4
- package/dist/agent-session/session.js +159 -18
- package/dist/checkpoint-restore.d.ts +50 -14
- package/dist/checkpoint-restore.js +104 -28
- package/dist/context-budget.d.ts +11 -0
- package/dist/context-budget.js +33 -2
- package/dist/contracts-core/agent.d.ts +26 -5
- package/dist/contracts-core/extensions.d.ts +3 -0
- package/dist/contracts-core/guardrail-packs.d.ts +8 -3
- package/dist/contracts-core/loop.d.ts +36 -0
- package/dist/contracts-core/provider.d.ts +6 -1
- package/dist/contracts-core/run-limits.d.ts +10 -1
- package/dist/contracts-core/session.d.ts +2 -1
- package/dist/contracts-protocol.d.ts +6 -4
- package/dist/contracts-run-state.d.ts +48 -6
- package/dist/contributions.d.ts +2 -1
- package/dist/contributions.js +1 -0
- package/dist/extensions.d.ts +15 -1
- package/dist/extensions.js +68 -0
- package/dist/guardrail-packs/types.d.ts +10 -0
- package/dist/guardrail-packs/validation-respect.js +16 -0
- package/dist/guardrails.d.ts +42 -1
- package/dist/guardrails.js +124 -15
- package/dist/index.d.ts +7 -7
- package/dist/index.js +4 -4
- package/dist/leases.js +32 -6
- package/dist/middleware.d.ts +1 -1
- package/dist/node/contribution-discovery.d.ts +16 -1
- package/dist/node/contribution-discovery.js +47 -0
- package/dist/node/session-store-jsonl.js +67 -17
- package/dist/run-bundle.d.ts +6 -1
- package/dist/run-bundle.js +4 -1
- package/dist/run-limits.d.ts +11 -5
- package/dist/run-limits.js +13 -0
- package/dist/session-stores.js +61 -12
- package/dist/testing/prefix-stability-conformance.d.ts +73 -1
- package/dist/testing/prefix-stability-conformance.js +158 -27
- package/dist/tools.js +10 -3
- package/dist/usage-estimation.d.ts +7 -1
- package/dist/usage-estimation.js +16 -10
- package/docs/acp.md +2 -2
- package/docs/agent-events.md +15 -10
- package/docs/agent-session-runtime.md +10 -7
- package/docs/coding-agent-tools.md +1 -1
- package/docs/coding-tools.md +7 -11
- package/docs/compaction-llm.md +2 -0
- package/docs/compaction-observational-memory.md +21 -1
- package/docs/context-and-skills.md +6 -7
- package/docs/contribution-discovery.md +13 -0
- package/docs/durable-runs.md +14 -6
- package/docs/embeddings.md +7 -1
- package/docs/execution-timeline.md +9 -2
- package/docs/extensions.md +21 -5
- package/docs/guardrails.md +16 -6
- package/docs/hooks.md +282 -0
- package/docs/impeccable.md +1 -2
- package/docs/index.md +28 -21
- package/docs/input-and-prompt-assembly.md +1 -1
- package/docs/instruction-injection.md +1 -0
- package/docs/live-testing.md +3 -2
- package/docs/memory-fabric.md +29 -0
- package/docs/middleware-hooks.md +54 -4
- package/docs/migrate-to-0.11.md +65 -0
- package/docs/migration.md +24 -0
- package/docs/node-jsonl-session-store.md +4 -3
- package/docs/operations.md +1 -1
- package/docs/options-index.md +3 -1
- package/docs/peer-dependencies.md +3 -5
- package/docs/policy-and-audit.md +15 -2
- package/docs/prefix-stability-conformance.md +82 -9
- package/docs/provider-packages.md +20 -20
- package/docs/public-contracts.md +2 -1
- package/docs/rag.md +94 -7
- package/docs/release-and-install.md +62 -59
- package/docs/runs-and-usage.md +21 -10
- package/docs/scoped-agent-memory.md +17 -9
- package/docs/scoped-memory.md +138 -0
- package/docs/session-stores.md +2 -2
- package/docs/supervisors.md +14 -6
- package/docs/testing.md +17 -9
- package/docs/tools.md +1 -1
- package/docs/wiki.md +4 -2
- package/docs/workflows.md +2 -2
- package/package.json +8 -5
- package/docs/caveman.md +0 -130
- package/docs/graft.md +0 -149
- package/docs/ponytail.md +0 -129
package/docs/middleware-hooks.md
CHANGED
|
@@ -14,7 +14,7 @@ APIs:
|
|
|
14
14
|
|
|
15
15
|
Use middleware hooks when a host wants extension/package code to observe or transform a value at a named runtime boundary.
|
|
16
16
|
|
|
17
|
-
Do not use middleware hooks as a provider adapter, prompt builder, retry policy, compaction strategy, tool dispatcher, permission system, or agent/session runtime. Per-turn tool menus use `AgentConfig.toolNarrowing` / `RunOptions.toolNarrowing`, not a middleware hook — see [Tools](tools.md).
|
|
17
|
+
Do not use middleware hooks as a provider adapter, prompt builder, retry policy, compaction strategy, tool dispatcher, permission system, or agent/session runtime. Per-turn tool menus use `AgentConfig.toolNarrowing` / `RunOptions.toolNarrowing`, not a middleware hook — see [Tools](tools.md). Run-end decisions use stop hooks (`AgentConfig.stopHooks` / `RunOptions.stopHooks`) — see [Hooks](hooks.md) — not a middleware hook.
|
|
18
18
|
|
|
19
19
|
## Inputs / request
|
|
20
20
|
|
|
@@ -32,6 +32,7 @@ Built-in hook names:
|
|
|
32
32
|
- `tool_call`
|
|
33
33
|
- `tool_result`
|
|
34
34
|
- `retry`
|
|
35
|
+
- `compaction_request`
|
|
35
36
|
- `compaction`
|
|
36
37
|
- `session_start`
|
|
37
38
|
- `session_shutdown`
|
|
@@ -48,7 +49,16 @@ Built-in hook names:
|
|
|
48
49
|
|
|
49
50
|
## Outputs / response / events
|
|
50
51
|
|
|
51
|
-
`run()` returns the transformed value. If no middleware is registered for a hook, `run()` returns the original value. `assembleProviderInput()` calls Phase 5 hooks in this order when middleware is supplied: `input_assembly`, then `context`, then `prompt_build`. The `input_assembly` call is unconditional — it runs after whatever `InputBuilder` produced the messages, so host middleware at that hook cannot be skipped by a custom builder. The agent/session runtime runs `beforeProviderTurn` once per turn after the request is assembled and before any provider-round work, then applies configured provider request policies, then invokes `provider_request` once with the `ProviderRequest` before `AIProvider.generate()`, invokes `tool_call` and `tool_result` through `dispatchToolCall()` for complete provider tool calls, invokes `compaction` with `{ context, result }` after
|
|
52
|
+
`run()` returns the transformed value. If no middleware is registered for a hook, `run()` returns the original value. `assembleProviderInput()` calls Phase 5 hooks in this order when middleware is supplied: `input_assembly`, then `context`, then `prompt_build`. The `input_assembly` call is unconditional — it runs after whatever `InputBuilder` produced the messages, so host middleware at that hook cannot be skipped by a custom builder. The agent/session runtime runs `beforeProviderTurn` once per turn after the request is assembled and before any provider-round work, then applies configured provider request policies, then invokes `provider_request` once with the `ProviderRequest` before `AIProvider.generate()`, invokes `tool_call` and `tool_result` through `dispatchToolCall()` for complete provider tool calls, invokes `compaction_request` with the strategy's `CompactionContext` before the strategy runs — only when compaction triggers (manual `session.compact()` and auto-compaction both route through it), so a handler rewrites `entries`, `keepRecentEntries`, `metadata`, or `secrets` for the strategy — then invokes `compaction` with `{ context, result }` after the strategy returns and before the runtime appends its standard compaction entry, and invokes `retry` with `{ context, decision }` before scheduling a provider-turn retry. There is no `provider_response` hook; observing provider output belongs to the provider adapter or subscriber events.
|
|
53
|
+
|
|
54
|
+
Session lifecycle hooks are dispatched by the agent/session runtime, once each:
|
|
55
|
+
|
|
56
|
+
| Hook | When | Payload |
|
|
57
|
+
| --- | --- | --- |
|
|
58
|
+
| `session_start` | First run start of a session, after `agent_started`/`agent_resumed` and before the first provider turn. A session rebuilt from a durable checkpoint is a new runtime session, so it opens again. | `{ sessionId, runId }` |
|
|
59
|
+
| `session_shutdown` | `session.close()`, before every subscriber is closed. Idempotent — calling `close()` twice dispatches once. | `{ sessionId }` |
|
|
60
|
+
|
|
61
|
+
Both are one dispatch per session, never per turn, and both honor the registry `errorPolicy` exactly like every other hook: with `"event"` a throw becomes an `extension_error` event and the run continues, with `"throw"` it surfaces (for `session_start`, `session.run()` rejects; for `session_shutdown`, `close()` rejects after closing subscribers).
|
|
52
62
|
|
|
53
63
|
With default `errorPolicy: "event"`, middleware errors become `extension_error` events when `onError` is provided, and later middleware still runs with the current value. With `errorPolicy: "throw"`, `run()` rejects on the first middleware error.
|
|
54
64
|
|
|
@@ -88,11 +98,48 @@ import type { Extension } from "@arnilo/prism";
|
|
|
88
98
|
export const extension: Extension = {
|
|
89
99
|
name: "demo-middleware",
|
|
90
100
|
setup(api) {
|
|
91
|
-
api.use("session_start", (event) =>
|
|
101
|
+
api.use("session_start", (event) => {
|
|
102
|
+
// Once per session: provision session-scoped state here.
|
|
103
|
+
return event;
|
|
104
|
+
});
|
|
92
105
|
},
|
|
93
106
|
};
|
|
94
107
|
```
|
|
95
108
|
|
|
109
|
+
`session_shutdown` runs on `await session.close()`, which then closes every subscriber:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const session = createAgent({ model, provider, middleware }).createSession();
|
|
113
|
+
await session.run("hello");
|
|
114
|
+
await session.close(); // session_shutdown middleware once, then every subscriber closes
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Pre-compaction rewrite (`compaction_request`)
|
|
118
|
+
|
|
119
|
+
`compaction_request` is the input side of the compaction pair: it runs once per compaction event, right
|
|
120
|
+
after `compaction_started` and before the strategy's `compact()`, and its return value **is** the
|
|
121
|
+
strategy's input. The payload is the same `CompactionContext` the strategy would have received
|
|
122
|
+
(`sessionId`, `entries`, `keepRecentEntries`, `trigger`, `secrets`, `metadata`, `signal`), and the
|
|
123
|
+
post-strategy `compaction` hook then observes that rewritten context — so a subscriber always sees
|
|
124
|
+
what actually compacted.
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import { createMiddlewareRegistry, type CompactionContext } from "@arnilo/prism";
|
|
128
|
+
|
|
129
|
+
const middleware = createMiddlewareRegistry();
|
|
130
|
+
middleware.use<CompactionContext>("compaction_request", (context, next) =>
|
|
131
|
+
next({ ...context, entries: pinCriticalFacts(context.entries) }),
|
|
132
|
+
);
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Contract:
|
|
136
|
+
|
|
137
|
+
- Only compaction events dispatch it: ordinary turns no-op, even with auto-compaction configured but not triggered.
|
|
138
|
+
- Returning `undefined` without `next()` leaves the original context in place; the registry's `next()`-shape rules apply as everywhere else.
|
|
139
|
+
- A throw follows the registry `errorPolicy`: with `"event"` the error is reported and compaction proceeds on the last committed context; with `"throw"` `session.compact()` (or the run, on the auto path) rejects and no compaction entry is appended.
|
|
140
|
+
- The seam rewrites input, it cannot skip compaction: an empty or invalid entry set is the strategy's own error, not `"do nothing"`.
|
|
141
|
+
- Validation stays where it already was. Entries are redacted on append and the strategy owns its input expectations; the hook is host code inside the same trust boundary as `AgentConfig.compaction`, not a remote endpoint.
|
|
142
|
+
|
|
96
143
|
## No-model turns (`beforeProviderTurn`)
|
|
97
144
|
|
|
98
145
|
`beforeProviderTurn` lets the host answer a turn from data it already has — teaching empty states, canned flows, deterministic lookups — without any provider request. The payload is `BeforeProviderTurnPayload` (`sessionId`, `runId`, `turn`, `userText`) and middleware returns it unchanged or with `answer: DeterministicTurnAnswer` set:
|
|
@@ -131,8 +178,10 @@ Contract:
|
|
|
131
178
|
- Middleware registration is explicit through `createMiddlewareRegistry()` or `ExtensionAPI.use()`.
|
|
132
179
|
- `provider_request` middleware sees generic `ProviderRequest.options` after request policies have run; do not add secrets unless a redactor/policy secret list covers that boundary.
|
|
133
180
|
- Middleware runs only when the host/runtime calls `run()` or passes the registry to a helper that documents a call site.
|
|
181
|
+
- `session_start`/`session_shutdown` dispatch only when the host passes its registry to `AgentConfig.middleware`; a session that never runs never starts, and one the host never closes never shuts down. Closing an idle session is fine — the hook still does not fire twice. There is no `session_start` for a session that only calls `compact()` or `contextMeter()`.
|
|
134
182
|
- `beforeProviderTurn` runs only for turns that reach the provider boundary; a turn already ended by a run limit, host turn policy, or durable suspension never reaches it, and host middleware is trusted code — it must not use the hook to bypass `RunLimits` or guardrails.
|
|
135
183
|
- `compaction` middleware may adjust the compaction result summary/data, but runtime still owns session store append ordering and branch parent ids.
|
|
184
|
+
- `compaction_request` runs once per compaction event with the strategy's context as payload; the runtime still redacts the summary, appends the compaction entry, and rebuilds history. Neither hook can skip compaction (an empty entry set is the strategy's error), and neither runs on ordinary turns.
|
|
136
185
|
- `retry` middleware may stop retrying or adjust delay, but runtime still owns retry event emission, abort-aware waiting, and provider-turn boundaries.
|
|
137
186
|
- The registry does not discover packages, read manifests, load config, call providers, execute tools, read resources, or start sessions.
|
|
138
187
|
- Hosts may pass a middleware registry into `createExtensionKernel({ middleware })` to share it with direct host code.
|
|
@@ -152,8 +201,9 @@ Contract:
|
|
|
152
201
|
- [Contribution registries](contribution-registries.md): direct contribution registration separate from middleware.
|
|
153
202
|
- [Agent/session runtime](agent-session-runtime.md): provider request policy/middleware timing, bounded tool loop call site for `tool_call`/`tool_result` hooks, and runtime call sites for `compaction` and `retry`.
|
|
154
203
|
- [Tools](tools.md): tool dispatch behavior that runs `tool_call` and `tool_result` hooks.
|
|
204
|
+
- [Hooks](hooks.md): the hook model, the Claude Code / Codex event map, the `hooks.json` adapter, and the run-end stop hooks that are separate from payload-transforming middleware.
|
|
155
205
|
- [Input and prompt assembly](input-and-prompt-assembly.md): `input_assembly` and `prompt_build` helper call sites.
|
|
156
|
-
- [Compaction and retry policies](compaction-and-retry.md): compaction/retry middleware payloads and runtime timing.
|
|
206
|
+
- [Compaction and retry policies](compaction-and-retry.md): compaction/retry middleware payloads and runtime timing, including the pre-strategy `compaction_request` seam.
|
|
157
207
|
- [Context and skills](context-and-skills.md): `context` helper call site.
|
|
158
208
|
- [Observability](observability.md): optional OpenTelemetry adapter over `AgentEvent` streams.
|
|
159
209
|
- [Public contracts](public-contracts.md): provider, tool, context, session, and extension contracts that runtimes can pass through hooks.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Migrate Prism 0.10 to 0.11
|
|
2
|
+
|
|
3
|
+
> **Status: 0.11.0** (store bounds, in-memory lease fence reset, persona and graft subpath removals).
|
|
4
|
+
|
|
5
|
+
This document is the host checklist for upgrading from Prism 0.10.0 to 0.11.0.
|
|
6
|
+
|
|
7
|
+
0.11.0 is a lockstep minor for all **twelve** publishable packages. Node `>=22` stays the floor. No new public export was added by the review remediation. Two store contracts change for every host that uses the built-in memory or JSONL stores, or the in-memory lease store. Three subpaths that the published 0.10.0 tarball still shipped are removed.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Behavior changes
|
|
12
|
+
|
|
13
|
+
### 1. Idempotency dedup remembers 4,096 keys
|
|
14
|
+
|
|
15
|
+
The memory session store and the JSONL session store keep the latest 4,096 dedup keys (`sessionId`, `idempotencyKey`, `expectedParentId`). A replay inside that window is still rejected. A replay of a key that has fallen out of the window appends a new entry.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// before — every dedup key was kept for the life of the store
|
|
19
|
+
await store.append(entry); // same idempotencyKey always rejected
|
|
20
|
+
|
|
21
|
+
// after — only the latest 4,096 keys are remembered
|
|
22
|
+
await store.append(older); // appends again once 4,096 newer keys have been seen
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Migration actions**
|
|
26
|
+
- Do not treat idempotency rejection as permanent across a long-lived process. Persist the business result yourself if a replay after 4,096 later keys must stay a no-op.
|
|
27
|
+
- Durable SQLite and Postgres stores are unchanged by this window.
|
|
28
|
+
|
|
29
|
+
### 2. An evicted in-memory lease starts at fencing 1
|
|
30
|
+
|
|
31
|
+
`docs/operations.md` used to say every expired row keeps its fencing counter. That remains true for the SQLite and Postgres lease adapters. The in-memory lease store now deletes expired rows once the map reaches 1,024, on write paths only.
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
// before — a released key always inherited fencingToken + 1
|
|
35
|
+
const again = await leases.tryAcquireLease(sameKey); // fencingToken === previous + 1
|
|
36
|
+
|
|
37
|
+
// after — a key still in the map inherits + 1; a swept key starts at 1
|
|
38
|
+
const swept = await leases.tryAcquireLease(evictedKey); // fencingToken === 1
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Migration actions**
|
|
42
|
+
- Do not assume an in-memory fencing token grows for the life of the process. A token of 1 can mean "first owner" or "owner after a sweep".
|
|
43
|
+
- A released key that has not been swept still inherits `fencingToken + 1`.
|
|
44
|
+
- Hosts on SQLite or Postgres need no change.
|
|
45
|
+
|
|
46
|
+
### 3. Persona and graft subpaths are gone
|
|
47
|
+
|
|
48
|
+
`@arnilo/prism-coding-tools/caveman`, `@arnilo/prism-coding-tools/ponytail`, and `@arnilo/prism-memory/graft` are not in this line. The published 0.10.0 tarball still had them. Load persona skills with `loadSkillDirectory` from a host extension. Reach graft through a host MCP server or `registerTool` / `registerCommand`. `@arnilo/prism-coding-tools/impeccable` and the memory `/rag`, `/compaction/*`, `/fabric`, `/wiki`, and `/scoped` subpaths are unchanged. The name-level removal list is in [migration.md](migration.md).
|
|
49
|
+
|
|
50
|
+
### Unchanged for hosts that only call the runtime
|
|
51
|
+
|
|
52
|
+
`readBranchPath` on the memory store and the JSONL parse cache change cost, not results, except for the same-size/same-mtime JSONL residual named above. Plain-text token estimates share one `ceil(length/4)` helper. Message and entry estimates are unchanged and are not billing numbers.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Upgrade steps
|
|
57
|
+
|
|
58
|
+
1. Move every `@arnilo/*` dependency and peer to `^0.11.0`.
|
|
59
|
+
2. If you import `/caveman`, `/ponytail`, or `/graft`, switch to the host-owned seams before installing.
|
|
60
|
+
3. If you persist idempotency by relying on an unbounded in-memory or JSONL dedup set, store the result yourself.
|
|
61
|
+
4. If you compare in-memory lease fencing tokens for "never reused", treat a swept key's `1` as a new counter.
|
|
62
|
+
|
|
63
|
+
## Rollback
|
|
64
|
+
|
|
65
|
+
Install the published `0.10.0` tarballs. That line still has the three subpaths, an unbounded idempotency set, and no in-memory lease sweep. Do not point `^0.11.0` peers at a `0.10.0` install.
|
package/docs/migration.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Migration guide
|
|
2
2
|
|
|
3
|
+
## 0.10.0 → 0.11.0 (behavior persona and graft subpath removals)
|
|
4
|
+
|
|
5
|
+
**Prism 0.11.0 removes three public subpaths** that shipped through 0.10.0. Both capabilities stay supported on public seams — only the vendored convenience subpaths are gone. Compat baselines are regenerated for this line: 106 names removed (58 in `@arnilo/prism-memory`, 48 in `@arnilo/prism-coding-tools`) with no consumer-visible signature break; the only non-barrel signature changes are the widened `runCheckpointRestoreHooks` parameter and `describeBudgetExhaustion` return, plus two internal modules that no `exports` entry reaches. The 0.11.0 cut's own declaration change is `version` `"0.10.0"` → `"0.11.0"`. Plan 120 added no public name.
|
|
6
|
+
|
|
7
|
+
What a 0.10.0 host must check before upgrading:
|
|
8
|
+
|
|
9
|
+
- **`@arnilo/prism-coding-tools/caveman` and `/ponytail` are gone**, with their vendored upstream fixtures and the `@dietrichgebert/ponytail` optional peer. Load the upstream skill tree with the new `loadSkillDirectory(directory, { maxSkillBytes? })` on `@arnilo/prism/node/contribution-discovery`, then register the skills, a `/caveman`-style command, an every-turn injector, and session-entry mode persistence from a host extension — [examples/caveman-ponytail.ts](../examples/caveman-ponytail.ts) is the ported reference. `@arnilo/prism-coding-tools/impeccable` is unchanged.
|
|
10
|
+
- **`@arnilo/prism-memory/graft` is gone**, with its `@nanonets/graft` optional peer, the `prism-graft` fixture CLI, and the `/graft-init` / `/graft-build` / `/graft-build-deep` commands; `@arnilo/prism-memory` now declares only its required `@arnilo/prism` peer. Integrate graft as a host-exposed graft **MCP server**, or author the tools and commands against `registerTool` / `registerCommand` — the deleted extension was a subprocess CLI plus a retrieval-pack context provider plus blast-radius middleware, all host-composable. `/rag`, `/compaction/*`, `/fabric`, `/wiki`, and `/scoped` are unchanged.
|
|
11
|
+
- **Additive in the same line:** `CheckpointRestoreHandler` / `CheckpointRestoreCompensation` (a restore hook may now compensate) and the `BudgetExhaustionAttribution` on `describeBudgetExhaustion`'s return, `SubagentFailure` / `SubagentRecovery` / `SubagentRecoveryOutcome` lifecycle events, and `scorePrefixStability`. A host passing a plain restore hook to `runCheckpointRestoreHooks` needs no change — the new parameter type is that hook, widened.
|
|
12
|
+
- **Idempotency window and lease fence.** Memory and JSONL stores remember the latest 4,096 dedup keys; replaying an older key appends a new entry. The in-memory lease store deletes expired rows once the map reaches 1,024, and a swept key's next acquire starts at fencing 1. Durable SQLite and Postgres adapters still keep the counter. Full steps: [migrate-to-0.11.md](migrate-to-0.11.md).
|
|
13
|
+
|
|
14
|
+
## 0.9.0 → 0.10.0 (hook lifecycle completion, scoped agent memory)
|
|
15
|
+
|
|
16
|
+
**Prism 0.10.0 is a lockstep minor for all twelve publishable packages** — `@arnilo/prism-hooks` is new. Node `>=22` stays the floor. Nothing was removed in 0.10.0 itself: no import path moved and no export was dropped (compat baseline: +47 names, zero removals, zero renames); the persona and graft subpath removals recorded for this line land in **0.11.0** — see the section above. Scoped memory is a new opt-in subpath that stays inert until a host constructs a policy.
|
|
17
|
+
|
|
18
|
+
What a 0.9.0 host must check before upgrading:
|
|
19
|
+
|
|
20
|
+
- **One type-level addition: `AgentSession.close(): Promise<void>`.** Hosts that implement or proxy the interface (not only consume it) must add a `close()`; `async close() {}` satisfies the type, and a proxy should forward to the wrapped session so `session_shutdown` fires once. The in-repo precedent is the observational-memory proxy in `@arnilo/prism-memory`, which forwards it.
|
|
21
|
+
- **`session_start` and `session_shutdown` are now emitted.** Both names were declared in 0.9.0 but had no call site, so extension handlers registered against them never ran. `session_start` fires once per session at the first turn of the first run (durable resumes included); `session_shutdown` fires from `session.close()` and is idempotent — a host that never calls `close()` never sees it.
|
|
22
|
+
- **`hook_limit` is a new `AgentFinishReason`** (and a `StoredAgentRunState.stopReason`). Exhaustive switches over finish reasons must handle it; a `hook_limit` stop stays resumable only under `checkpointPolicy: "every-turn"`.
|
|
23
|
+
- **Stop hooks are bounded by default.** `RunLimits.maxStopContinuations` defaults to 3 (`0` disables continuation, `null` uncaps), and only a run that registers stop hooks can reach the limit.
|
|
24
|
+
- **`compaction_request` runs before the compaction strategy** when a handler is registered (entries and budget are rewritable); with no handler the compaction path is unchanged.
|
|
25
|
+
- **Additive, inert by default:** `AgentConfig.stopHooks` / `RunOptions.stopHooks` and `ExtensionAPI.registerStopHook()`, `forwardAgentEvents()`, the `@arnilo/prism-hooks` adapter, and the whole `@arnilo/prism-memory/scoped` surface (reviewer, promotion ladder, GC proposals, bounded facts block, approval gate, audit mirror, eval harness).
|
|
26
|
+
|
|
3
27
|
## 0.8.0 → 0.9.0 (attention budget axes, turn traces, tool narrowing, guardrail packs, background agents, session search, deterministic turns, shared scopes)
|
|
4
28
|
|
|
5
29
|
**Prism 0.9.0 is a lockstep minor for all eleven publishable packages.** Node `>=22` stays the floor. Nothing was removed: no import path moved, no export was dropped, and every new surface defaults to 0.8 behavior. The full guide — the four deltas inside existing surfaces, every new option with its sizing line, upgrade steps, and rollback — is [migrate-to-0.9.md](migrate-to-0.9.md).
|
|
@@ -32,7 +32,7 @@ import { createJsonlSessionStore } from "@arnilo/prism/node/session-store-jsonl"
|
|
|
32
32
|
|
|
33
33
|
`createJsonlSessionStore()` returns a `SessionStore`:
|
|
34
34
|
|
|
35
|
-
- `append(entry, options?)` appends one JSON line, rejects duplicate entry ids, honors `expectedParentId` existence checks, and deduplicates exact idempotency retries within this store instance. Append **fails closed** when the file already contains any corrupt or shape-invalid line (`Invalid JSONL at line N: …`) so writers cannot extend a damaged log.
|
|
35
|
+
- `append(entry, options?)` appends one JSON line, rejects duplicate entry ids, honors `expectedParentId` existence checks, and deduplicates exact idempotency retries within this store instance (latest 4,096 keys; an older replay appends as a new entry). Append **fails closed** when the file already contains any corrupt or shape-invalid line (`Invalid JSONL at line N: …`) so writers cannot extend a damaged log.
|
|
36
36
|
- `list(sessionId)` reads the file and returns valid entries for that session id. Corrupt or shape-invalid lines are skipped; they do not poison the whole file.
|
|
37
37
|
- `get(id)` reads the file and returns the matching valid entry, if any.
|
|
38
38
|
- `searchSessions(query)` reads the file and runs the shared linear session matcher. Corrupt or shape-invalid lines are quarantined exactly as in `list()`/`get()`, the contract linear caps bound sessions/entries/bytes scanned, and hits carry the same shape as the indexed adapters (`sessionId`, `leafId`, `entryId`, `runId`, `turn`, `snippet`) — without `score`, since a linear scan has no index relevance.
|
|
@@ -77,8 +77,9 @@ Use `createMemorySessionStore()` for tests or throwaway sessions; use the JSONL
|
|
|
77
77
|
- Reads and writes use only the caller-provided path.
|
|
78
78
|
- Errors include path/reason or line number, not file contents.
|
|
79
79
|
- Do not put secrets in messages, metadata, summaries, labels, or custom entries.
|
|
80
|
-
- Reads are linear in file size
|
|
81
|
-
-
|
|
80
|
+
- Each store instance caches the parsed array keyed by `(size, mtimeMs)`. A stat match reuses it; a stat miss re-reads. Reads are linear in file size on a miss. `append()` refreshes the cache after a successful write so the next `list()` / `snapshot()` does not re-parse. A same-size rewrite inside one filesystem timestamp tick is not detected. `readJsonlSessionEntries()` does not use this cache.
|
|
81
|
+
- Appends still re-read and re-parse the whole file for duplicate/parent/corruption checks before writing one line, and are serialized per store instance. A rejected append does not poison later appends; the rejected line is not written.
|
|
82
|
+
- `searchSessions` has no index. A cache hit reuses the parsed array; a miss reads and parses the whole file before the capped scan, so latency and peak memory still grow with the corpus. Use a SQLite/Postgres `SessionStore` when search latency matters, and treat search here as resume/filter tooling on small stores.
|
|
82
83
|
- There is no cross-process lock or durable idempotency table; two processes writing the same file can race. Add a database or external lock if multiple processes write the same file.
|
|
83
84
|
- Treat this adapter as development/single-process storage. Production multi-writer hosts should use an indexed database `SessionStore` adapter.
|
|
84
85
|
|
package/docs/operations.md
CHANGED
|
@@ -20,7 +20,7 @@ This page is the operator runbook for the high-availability story proven by plan
|
|
|
20
20
|
|
|
21
21
|
## Outputs / response / events
|
|
22
22
|
|
|
23
|
-
- A lease record: `{ namespace, key, ownerId, token, fencingToken, acquiredAt, expiresAt, updatedAt }`. Expired rows retain their fencing counter; the next owner inherits `fencingToken + 1`.
|
|
23
|
+
- A lease record: `{ namespace, key, ownerId, token, fencingToken, acquiredAt, expiresAt, updatedAt }`. Expired rows retain their fencing counter; the next owner inherits `fencingToken + 1`. The in-memory store deletes expired rows once it holds 1,024 of them; an evicted key's next owner starts at fencing 1. Durable adapters keep the counter.
|
|
24
24
|
- A checkpoint record: `{ namespace, key, version, fencingToken?, value, createdAt, updatedAt }`. Cursor/value changes are CAS-committed; a peer can replay an unfinished step but can never skip ahead or move the cursor backward.
|
|
25
25
|
- Failover timing: the drill reports `failoverMs` (wall time between the owner's death and the peer's acquisition) and asserts it against the frozen ceiling.
|
|
26
26
|
|
package/docs/options-index.md
CHANGED
|
@@ -42,10 +42,12 @@ Field-level detail (defaults, bounds, failure modes) lives on the owning page
|
|
|
42
42
|
| --- | --- | --- |
|
|
43
43
|
| `RunOptions.turnPolicy` (`TurnPolicyOptions`) | Synchronous host stop at a turn boundary; a stop lands as `stopReason: "host_policy"` and stays resumable | [Agent loops](agent-loops.md) |
|
|
44
44
|
| `CreateAgUiHandlerOptions.inputPolicy` (`AgUiInputPolicyOptions`) | `clientState: "honor"` \| `"ignore"` — whether the server honors client-supplied AG-UI state and tools | [Frontend interoperability](ag-ui.md) |
|
|
45
|
+
| `SubscribeOptions.acrossRuns` | `true` keeps one live subscriber open across runs of the same session until the host ends it, session teardown, or an overflow (bounded queue, default 1024, no background work); default `false` closes it at run end | [Agent events](agent-events.md) |
|
|
45
46
|
| `snapshotRunBundle(...)` → `RunBundleSnapshot` | Inspectable digest projection of the effective run bundle (prompt/skill/tool/guardrail digests, limits, storage kinds) | [Run bundle](run-bundle.md) |
|
|
46
47
|
| `createClaimGroundingGuardrail` (`ClaimGroundingGuardrailOptions`) | `"output"`-stage guardrail that blocks or flags numeric claims no tool result or host evidence supports | [Guardrails](guardrails.md) |
|
|
47
48
|
| `ErrorInfo.failureClass` (`ProviderFailureClass`) | Typed provider failure on run outcomes, ledger rows, and tool results (`quota`, `rate_limited`, `auth`, `transient`, `permanent`) | [Runs and usage](runs-and-usage.md) |
|
|
48
|
-
| `AgentConfig.usageEstimation` | `"fallback"` (default) records a labeled estimate when a provider reports no usage; `"off"` leaves usage absent; estimates are never priced | [Runs and usage](runs-and-usage.md#automatic-fallback-agentconfigusageestimation) |
|
|
49
|
+
| `AgentConfig.usageEstimation` | `"fallback"` (default) records a labeled estimate when a provider reports no usage; `"off"` leaves usage absent; `"strict"` refuses the turn instead (`code: "usage_missing"`); estimates are never priced | [Runs and usage](runs-and-usage.md#automatic-fallback-agentconfigusageestimation) |
|
|
50
|
+
| `AgentConfig.contextBudget` | Session-turn eviction budget forwarded to every assembly (`maxInputTokens`/`maxInputBytes`, `reportOmissions`, `tokenEstimator`); the usage fallback prefers its measurement | [Input and prompt assembly](input-and-prompt-assembly.md) |
|
|
49
51
|
| `ModelConfig.capabilities.toolCallStrictness` | Advisory tool-call reliability (`"strict"` \| `"lenient"` \| `"legacy"`); catalog conformance, not a promise | [Model registry](model-registry.md) |
|
|
50
52
|
|
|
51
53
|
## Agent/session runtime
|
|
@@ -20,8 +20,6 @@ One row per declaration. `Unlocks` names the subpath whose import reaches the pe
|
|
|
20
20
|
| Peer | Declared range | Optional | Declared by | Unlocks | Install | Network |
|
|
21
21
|
| --- | --- | --- | --- | --- | --- | --- |
|
|
22
22
|
| `zod` | `^3.25.0 \|\| ^4.0.0` | no | `@arnilo/prism-ag-ui` | `./acp` | `npm i zod` | no |
|
|
23
|
-
| `@nanonets/graft` | `^0.16.0 \|\| ^0.18.0` | yes | `@arnilo/prism-memory` | `./graft` | `npm i @nanonets/graft` | no |
|
|
24
|
-
| `@dietrichgebert/ponytail` | `^4.9.0` | yes | `@arnilo/prism-coding-tools` | `./ponytail` | `npm i @dietrichgebert/ponytail` | no |
|
|
25
23
|
| `mammoth` | `^1.8.0` | yes | `@arnilo/prism-work` | `./document-reader` | `npm i mammoth` | no |
|
|
26
24
|
| `pdf-parse` | `^2.4.5` | yes | `@arnilo/prism-work` | `./document-reader` | `npm i pdf-parse` | no |
|
|
27
25
|
| `e2b` | `2.49.1` | yes | `@arnilo/prism-coding-tools` | `./security` | `npm i e2b@2.49.1` | yes |
|
|
@@ -47,7 +45,7 @@ Two peers are pinned to an exact version instead of a range, because the pin is
|
|
|
47
45
|
`pg`, `@nats-io/jetstream`, `@nats-io/transport-node`, `playwright-core`, and `e2b` open sockets. For a supply-chain review of those five:
|
|
48
46
|
|
|
49
47
|
- **Connection targets are host-owned.** Every one of them is passed a host-supplied connection string, endpoint list, browser instance, API key, or service URL. Prism holds no default endpoint, and no peer is reachable from the root import.
|
|
50
|
-
- **Bytes stay local otherwise.** `better-sqlite3`, `mammoth`, `pdf-parse
|
|
48
|
+
- **Bytes stay local otherwise.** `better-sqlite3`, `mammoth`, and `pdf-parse` are filesystem/process peers; the remaining two (`zod`, `@ai-sdk/provider`) are pure types/schemas.
|
|
51
49
|
- **No secrets are read by the peers.** Prism resolves credentials through host providers and redacts them at the boundary; peers only ever receive a resolved connection string or model object. See [Credentials and redaction](credentials-and-redaction.md) and [Host security guide](host-security.md).
|
|
52
50
|
- **Nothing is installed implicitly.** Optional peers are never auto-installed by npm; a missing one fails closed at the call site with a typed error naming the peer and the subpath. Required peers (today only `zod`) are installed by npm with the package.
|
|
53
51
|
|
|
@@ -79,7 +77,7 @@ const tools = await createBrowserTools({ browser });
|
|
|
79
77
|
|
|
80
78
|
## Extension and configuration notes
|
|
81
79
|
|
|
82
|
-
- A peer is an *implementation the host owns*. When a peer's default wiring is not what you want, pass your own implementation instead of installing theirs: the document reader accepts host parsers (`createReadTool({ documentReader })`),
|
|
80
|
+
- A peer is an *implementation the host owns*. When a peer's default wiring is not what you want, pass your own implementation instead of installing theirs: the document reader accepts host parsers (`createReadTool({ documentReader })`), and the browser surfaces accept a host `Browser`.
|
|
83
81
|
- Subpaths that need a peer isolate that import, so importing another subpath of the same package never evaluates it. The office family is the extreme case: zero peers, because it takes structural inputs. `@arnilo/prism-channels` also has no third-party peers: Telegram uses native `fetch`, and signal-cli is a host-operated binary rather than an npm peer.
|
|
84
82
|
- Adding a peer to a Prism package is a release-gated change: the declaration must be optional unless a hard dependency's own peer forces it (the `zod` case), and exact pins must come with a version-gate or compatibility rationale.
|
|
85
83
|
|
|
@@ -93,4 +91,4 @@ const tools = await createBrowserTools({ browser });
|
|
|
93
91
|
|
|
94
92
|
- [Release and install](release-and-install.md): install profiles that pair with each peer.
|
|
95
93
|
- [Configuration options index](options-index.md): the option surfaces each peer unlocks.
|
|
96
|
-
- Package-level detail: [Coding tools](coding-tools.md), [Core runtime](core.md), [Session stores](session-stores.md), [Browser automation](browser-automation.md), [Document reader](document-reader.md), [
|
|
94
|
+
- Package-level detail: [Coding tools](coding-tools.md), [Core runtime](core.md), [Session stores](session-stores.md), [Browser automation](browser-automation.md), [Document reader](document-reader.md), [Provider packages](provider-packages.md), [Messaging channels](messaging-channels.md).
|
package/docs/policy-and-audit.md
CHANGED
|
@@ -115,12 +115,25 @@ Memory retrieval keeps its own audit events next to policy decisions; hosts forw
|
|
|
115
115
|
|
|
116
116
|
| Event | Shape | When |
|
|
117
117
|
| --- | --- | --- |
|
|
118
|
-
| `rag.acl_denied` | `{ sourceId, scope: { tenantId, resourceId, threadId }, reason: "no_grant" \| "check_failed", hits, error? }` via `retrieveContext({ onAccessDenied })` | A source was withheld
|
|
118
|
+
| `rag.acl_denied` | `{ sourceId, scope: { tenantId, resourceId, threadId }, reason: "no_grant" \| "check_failed", hits, error? }` via `retrieveContext({ onAccessDenied })` | A source was withheld: revoked/absent/version-mismatched grant, the grant lookup threw (`error` is redacted, capped at 256 chars), or the store's own predicate filtered it before ranking (`hits: 0`, reported only when the host wires `onDeniedSources` through `retrieveContext`, plan 102 Task 6) |
|
|
119
119
|
| `Repointed` log line + result | `repointSource()` → `{ from, to, movedChunks, rewrittenEdges, layers, batched }` | A source's grant identity moved and derived artifacts followed |
|
|
120
|
-
|
|
|
120
|
+
| `rag.repointed` (rename audit) | `applySourceRenames({ onRenamed })` → `{ from, to, outcome: "moved", movedChunks, rewrittenEdges, layers }` \| `{ from, to, outcome: "failed", error }` | A batch of identity moves ran: one event per rename that settled, successes and failures alike (`error` redacted and capped at 256 chars) — plan 102 Task 7 |
|
|
121
|
+
| Invalidation rows | `store.invalidate()` rows (`{ id, reason: "corrected" \| "revoked" \| "forgotten" \| "legal_hold", at }`) read back by `listInvalidatedIds()` | A source was revoked/forgotten/held; tombstones stay for explainability. Handler tombstones (e.g. fabric notes) mirror the propagation's resolved reason — a `legal_hold` walk never lands as `forgotten` |
|
|
121
122
|
|
|
122
123
|
Events are per *source*, not per hit, and are emitted once per query. They never contain document text, grant contents, or credentials; `check_failed` messages pass through the same redactor as retrieved content. Denials are fail-closed: a source is excluded whether the grant is absent, revoked, or the lookup failed, and the query returns the remaining hits. Aborts are not denials and are never recorded as such.
|
|
123
124
|
|
|
125
|
+
The re-point rows are the same kind of evidence for identity moves: `repointSource()` returns its counts to the caller, and `applySourceRenames()` (plan 102 Task 7) writes a batch into a host sink through `onRenamed` — one event per rename that settled, carrying `from`/`to`, the outcome, and either the moved counts per layer or the redacted error. A failed rename is audited before the batch stops (fail-fast) or continues, and a rename that never started is not audited.
|
|
126
|
+
|
|
127
|
+
Since plan 102 Task 6 the table also covers what the **store's own predicate** withheld, which plan 089 recorded as unauditable. A store that declares `authorization: "acl"` reports the sources its `query`/`lexicalQuery` predicate filtered out when the caller opts in, and `retrieveContext()` forwards that report into the same `onAccessDenied` path (one event per source per query, `hits: 0` because no hit ever existed — the finer per-source rule stays on the query-level callback):
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
// store-level: what this query's own predicate withheld, and why
|
|
131
|
+
await store.query({ ...scope, embedding, topK, authorization, onDeniedSources: (d) => audit.write(d) });
|
|
132
|
+
// → [{ sourceId: "doc:payroll", reason: "no_grant" }, { sourceId: "doc:hr", reason: "version_mismatch" }]
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Without the callback the store issues no extra statement and its SQL is unchanged; with it, PostgreSQL/pgvector adds one grouped anti-join (`GROUP BY source_id` over the rows the predicate refused) that measured **1.2–1.4ms** on the 23-row fixture on an AMD Ryzen 9 PRO 7940HS. Reports carry source ids and reasons only — never rows, text, grant contents, or principal ids — and never widen the predicate: a withheld source stays withheld with or without the callback.
|
|
136
|
+
|
|
124
137
|
## Security and performance notes
|
|
125
138
|
|
|
126
139
|
- Approvals require verified `AgentIdentity`; actor fields are refs only.
|
|
@@ -7,16 +7,21 @@ Prefix stability conformance drives a real agent session through two staggered s
|
|
|
7
7
|
Exported from `@arnilo/prism/testing/prefix-stability-conformance`:
|
|
8
8
|
|
|
9
9
|
- `runPrefixStabilityConformance(options)`
|
|
10
|
+
- `scorePrefixStability(requests, options?)` — score a capture the host already holds; no session, no provider call
|
|
10
11
|
- `PrefixStabilityConformanceOptions`
|
|
11
12
|
- `PrefixStabilityConformanceResult`
|
|
13
|
+
- `ScorePrefixStabilityOptions`
|
|
14
|
+
- `PrefixStabilitySample`
|
|
15
|
+
- `PrefixStabilityResetDetail`
|
|
12
16
|
|
|
13
17
|
## When to use it
|
|
14
18
|
|
|
15
|
-
Use it when a host owns any part of prompt assembly — custom `inputBuilder`, `promptBuilder`, context providers, instruction injectors, input/prompt middleware, or an explicit `inputLayout` — and wants to prove that progressive disclosure still holds the cache prefix. The runner:
|
|
19
|
+
Use it when a host owns any part of prompt assembly — custom `inputBuilder`, `promptBuilder`, context providers, instruction injectors, input/prompt middleware, or an explicit `inputLayout` — and wants to prove that progressive disclosure still holds the cache prefix. A host that already holds captured `ProviderRequest`s scores them with `scorePrefixStability` instead of re-running the fixture; pass the session's `tailSegments` map if you have it. A wrong or empty map yields a wrong number, not an error, and never a fabricated `1`. The runner:
|
|
16
20
|
|
|
17
|
-
- installs a fixture provider (no network) that loads `skills[0]` on the first turn and `skills[1]` on the second, two provider requests per turn;
|
|
21
|
+
- installs a fixture provider (no network) that loads `skills[0]` on the first turn and `skills[1]` on the second, two provider requests per turn — and, when the host runs an attention compiler, carries a deterministic reasoning block per skill-load round so the compiler's thinking stage has real content to fold;
|
|
18
22
|
- keeps everything else in `host` exactly as production: system prompt, context providers, builders, middleware, disclosure settings;
|
|
19
23
|
- measures, for each consecutive captured request, the byte-shared prefix as a fraction of the previous request and fails below `minContinuity` (default `0.95`);
|
|
24
|
+
- reports that fraction twice: `minContinuity` (provider-visible prefix, what the prompt cache pays for) and `cacheableContinuity` (the same measurement with the session's tail segments removed), and asserts whichever `assertOn` selects (default `providerPrefix`);
|
|
20
25
|
- fails when a loaded body never reaches a provider request, so a builder that drops the tail cannot pass vacuously.
|
|
21
26
|
|
|
22
27
|
## Inputs / request
|
|
@@ -31,36 +36,53 @@ const result = await runPrefixStabilityConformance({
|
|
|
31
36
|
context: [projectContextProvider],
|
|
32
37
|
},
|
|
33
38
|
skills: [skillA, skillB],
|
|
39
|
+
assertOn: "cacheablePrefix",
|
|
34
40
|
});
|
|
35
41
|
```
|
|
36
42
|
|
|
37
43
|
`PrefixStabilityConformanceOptions`:
|
|
38
44
|
- `host` — the host's `AgentConfig` minus `provider`, `providerSource`, and `skills`; the runner supplies the fixture provider and fixture skill registry
|
|
39
45
|
- `skills` — exactly two distinct `Skill` values with non-empty `instructions`, loaded in turn order
|
|
40
|
-
- `minContinuity?` — minimum shared-prefix fraction between consecutive requests (default `0.95`)
|
|
46
|
+
- `minContinuity?` — minimum shared-prefix fraction between consecutive requests (default `0.95`); always measured against the provider-visible prefix
|
|
47
|
+
- `assertOn?` — `"providerPrefix"` (default) gates the run on the provider-visible prefix; `"cacheablePrefix"` gates it on the tail-aware measurement instead, for an eager or body-heavy host that deliberately re-sends bodies after the stable prefix
|
|
48
|
+
- `allowedResets?` — how many request pairs may break below `minContinuity` (default `0`, today's behavior). Set `1` for an assembly that folds, compacts, or evicts exactly one boundary; more resets than declared fail, and fewer fail too, because a fixture that was supposed to invalidate the prefix and never did cannot pass vacuously. Each allowed gap is also a `resetDetails` row
|
|
49
|
+
- `foldableToolResultBytes?` — installs a runner-owned `prefix_stability_bulk` fixture tool that returns exactly this many bytes of generated text and adds a second tool call for it beside `load_skill` in the same round, so the attention compiler's tool-result stage has a foldable row. Unset, the capture is byte-identical to today (four requests, one `load_skill` call each)
|
|
41
50
|
- `inputs?` — the two turn inputs (default fixed strings, so runs stay comparable across hosts)
|
|
42
51
|
|
|
43
52
|
## Outputs / response / events
|
|
44
53
|
|
|
45
|
-
Returns `Promise<{ requests: number; minContinuity: number }>`: the captured request count (four)
|
|
54
|
+
Returns `Promise<{ requests: number; minContinuity: number; cacheableContinuity: number; resets: readonly number[]; resetDetails: readonly PrefixStabilityResetDetail[] }>`: the captured request count (four), the two lowest shared-prefix fractions, and one detail row per reset. Throws a plain `Error` naming the offending request pair and the measured percentage on the first violation. No events, no test runner, no network. Detail rows are indexes and fractions only — no message text.
|
|
55
|
+
|
|
56
|
+
A gap below `minContinuity` **on the metric `assertOn` selects** is collected as a reset instead of failing inside the loop: `resets` holds the 1-based index of the request that broke (the later request of the pair, ascending). `resetDetails[i].request` equals `resets[i]`; `fraction` is that selected metric and `cacheableFraction` is the tail-excluded fraction for the same pair. A run with no gap returns `[]` for both. With the default `allowedResets: 0` the first reset fails the run exactly as before, now adding the observed reset list to the message; `allowedResets: 1` lets a single documented boundary (an attention fold, a compaction, a budget eviction) pass while every other pair must stay byte-stable, and the result carries exactly that one detail row.
|
|
57
|
+
|
|
58
|
+
Both numbers measure the same consecutive request pairs, byte for byte:
|
|
59
|
+
|
|
60
|
+
- **`minContinuity`** — the provider-visible prefix, messages **and** tool schemas as sent on the wire. This is what the prompt cache can keep paying for, and its meaning is frozen: `0.95` default, unchanged by `assertOn`.
|
|
61
|
+
- **`cacheableContinuity`** — the same fraction recomputed after removing this session's tail segments (loaded skill bodies, resources moved to the tail) from **both** requests of each pair. A host whose provider-visible fraction dips only because of tail bodies reads `1` here.
|
|
62
|
+
|
|
63
|
+
Tail segments are read from the session's own `tailSegments` map — the exact `Message` objects assembly appended — matched by object identity first and by serialized-value equality for a `promptBuilder` that clones messages. Nothing is added to the provider payload and no host content is pattern-matched. When no captured request carried a tail segment (a builder that renders bodies elsewhere), `cacheableContinuity` equals `minContinuity`; it is not reported as `1`. `scorePrefixStability` uses the same rule: omit `tailSegments`, or pass a map that matches nothing, and the two fractions stay equal.
|
|
64
|
+
|
|
65
|
+
`scorePrefixStability(requests, options?)` returns `{ minContinuity, cacheableContinuity, resets, resetDetails }` for a list the host already captured. `resets` follows the provider-visible prefix unless `options.assertOn` is `"cacheablePrefix"` (the runner passes its own `assertOn` so the two surfaces share one measurement). `resetDetails[i].request` equals `resets[i]`; each row is `{ request, fraction, cacheableFraction }` — indexes and fractions only, never message text. Fewer than two requests throws a plain `Error` naming the count.
|
|
46
66
|
|
|
47
67
|
## Request/response example
|
|
48
68
|
|
|
49
69
|
```ts
|
|
50
70
|
import { runPrefixStabilityConformance } from "@arnilo/prism/testing/prefix-stability-conformance";
|
|
51
71
|
|
|
52
|
-
const { minContinuity } = await runPrefixStabilityConformance({
|
|
72
|
+
const { minContinuity, cacheableContinuity, resets } = await runPrefixStabilityConformance({
|
|
53
73
|
host: myAgentAssembly,
|
|
54
74
|
skills: [alphaSkill, betaSkill],
|
|
55
75
|
});
|
|
56
|
-
// throws: "request 2 → 3 kept 41.2% of the previous provider prefix (minimum 95.0%)
|
|
76
|
+
// throws: "request 2 → 3 kept 41.2% of the previous provider prefix (minimum 95.0%), and 1 pair(s)
|
|
77
|
+
// broke below it (resets [3] of 3 request pairs, allowedResets 0)"
|
|
57
78
|
// when a context block or the skill catalog is recomposed in place.
|
|
79
|
+
// minContinuity: 0.98 (provider-visible) · cacheableContinuity: 1 (tail bodies excluded) · resets: []
|
|
58
80
|
```
|
|
59
81
|
|
|
60
82
|
## Implementation example
|
|
61
83
|
|
|
62
84
|
```ts
|
|
63
|
-
import { runPrefixStabilityConformance } from "@arnilo/prism/testing/prefix-stability-conformance";
|
|
85
|
+
import { runPrefixStabilityConformance, scorePrefixStability } from "@arnilo/prism/testing/prefix-stability-conformance";
|
|
64
86
|
|
|
65
87
|
// The runner owns the provider and skills, so the same helper is the negative control too:
|
|
66
88
|
// add a deliberately volatile context provider to prove the assertion can fail.
|
|
@@ -71,13 +93,64 @@ await runPrefixStabilityConformance({
|
|
|
71
93
|
},
|
|
72
94
|
skills: [alphaSkill, betaSkill],
|
|
73
95
|
});
|
|
96
|
+
|
|
97
|
+
// A host that captures its own provider requests (middleware, proxy, or an adapter tap):
|
|
98
|
+
const sample = scorePrefixStability(captured, { tailSegments: sessionTails, minContinuity: 0.95 });
|
|
99
|
+
// → { minContinuity: 0.98, cacheableContinuity: 1, resets: [], resetDetails: [] }
|
|
100
|
+
// With no tail map a deliberate tail re-send counts against the score:
|
|
101
|
+
// → { minContinuity: 0.71, cacheableContinuity: 0.71, resets: [2], resetDetails: [{ request: 2, fraction: 0.71, cacheableFraction: 0.71 }] }
|
|
74
102
|
```
|
|
75
103
|
|
|
76
104
|
## Extension and configuration notes
|
|
77
105
|
|
|
78
|
-
- Loaded skill bodies and URI resources are re-sent after new transcript content by design (the tail is append-only, not immutable); a body larger than `1 - minContinuity` of the whole prompt lowers the fraction without indicating a prefix regression.
|
|
106
|
+
- Loaded skill bodies and URI resources are re-sent after new transcript content by design (the tail is append-only, not immutable); a body larger than `1 - minContinuity` of the whole prompt lowers the provider-visible fraction without indicating a prefix regression. For such a host assert `assertOn: "cacheablePrefix"` (the tail-aware number stays `1`), or raise the fixture's stable prefix or lower `minContinuity`.
|
|
107
|
+
- A volatile leading context provider lowers **both** fractions: context is not a tail segment, so `cacheablePrefix` cannot mask a real prefix regression.
|
|
79
108
|
- Prompt builders that render skill bodies outside the tail are welcome — the check measures the provider-visible prefix, not where the body sits.
|
|
80
|
-
- Attention/tool-result folding and context-budget eviction are explicit invalidation boundaries:
|
|
109
|
+
- Attention/tool-result folding and context-budget eviction are explicit invalidation boundaries: with the default `allowedResets: 0` the run fails at that turn. Declare the boundary instead of loosening `minContinuity` — one fold is one reset, so a second reset, a post-fold rewrite, or a fold that never happened still fails:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const result = await runPrefixStabilityConformance({
|
|
113
|
+
host: {
|
|
114
|
+
...myAssembly,
|
|
115
|
+
// A predicate gate must settle under the stages it triggers: one that still fires after folding
|
|
116
|
+
// fails closed with `AttentionBudgetError` instead of reporting a reset. This one opens on a
|
|
117
|
+
// run's opening round while the carried request is over the floor, and the fold drops it back.
|
|
118
|
+
attentionCompiler: {
|
|
119
|
+
maxInputTokens: 4_000,
|
|
120
|
+
keepLast: 0,
|
|
121
|
+
thinkingKeepTurns: 0,
|
|
122
|
+
trigger: { kind: "predicate", shouldFold: (state) => state.turn === 1 && state.estimatedInputTokens >= 1_350 },
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
skills: [alphaSkill, betaSkill],
|
|
126
|
+
allowedResets: 1, // add `assertOn: "cacheablePrefix"` when tail re-sends should not count either
|
|
127
|
+
});
|
|
128
|
+
result.resets; // [3] — the request after the fold; every other pair stayed append-only
|
|
129
|
+
result.resetDetails; // [{ request: 3, fraction, cacheableFraction }] — same gap, both fractions
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- Both compiler stages are proven by the runner. The deterministic reasoning block proves the thinking stage; `foldableToolResultBytes` proves the **tool-result** stage — the bulk row becomes the stub at the reset while the sibling `load_skill` confirmation stays byte-identical (the shrink guard leaves the small row alone). The fixture tool is installed only when the option is set, and its payload is generated text, never host content.
|
|
133
|
+
|
|
134
|
+
- Documented invalidation boundaries — what `resets` is expected to name. Each row is pinned by a fixture in [`src/__tests__/invalidation-inventory.test.ts`](../src/__tests__/invalidation-inventory.test.ts) that asserts the boundary *position* (message index, and tool index for schemas), so a reordering of the cache-aware layout fails that suite instead of silently relocating a boundary:
|
|
135
|
+
|
|
136
|
+
| Segment | Boundary in the default `cache_aware` layout | Owner |
|
|
137
|
+
| --- | --- | --- |
|
|
138
|
+
| Per-turn instruction-injector text (`on_input`) | Message 0: merged into the leading system prompt — never moved behind the transcript | [Input and prompt assembly](input-and-prompt-assembly.md) |
|
|
139
|
+
| Host / injector context blocks | The context slot: after the hoisted leading system messages, before skills | [Context and skills](context-and-skills.md) |
|
|
140
|
+
| Observational-memory blocks (`observational-memory`, `recent-messages`) | The same context slot; re-rendering identical blocks keeps the prefix byte-identical | [Context and skills](context-and-skills.md) |
|
|
141
|
+
| Compaction summaries | Right after the leading system prompt while nothing user-role precedes it; a leading attachment moves the summary behind the context and skill slots | [Input and prompt assembly](input-and-prompt-assembly.md) |
|
|
142
|
+
| Pending tool results / current input | The suffix: a result inserts immediately before the current input, so that input is the round's boundary | [Input and prompt assembly](input-and-prompt-assembly.md) |
|
|
143
|
+
| `contextBudget` eviction | The first evicted group in the documented drop order (tool results → history → summaries → context → skills → attachments). Tool results, history, the lowest-priority context block, skill-body demotion, and the newest attachment are pinned at their message index in [`src/__tests__/invalidation-inventory.test.ts`](../src/__tests__/invalidation-inventory.test.ts); the full drop order stays behavioral in [`src/__tests__/context-budget.test.ts`](../src/__tests__/context-budget.test.ts) | [Input and prompt assembly](input-and-prompt-assembly.md) |
|
|
144
|
+
| Tool-schema selection | `request.tools` only: gaining or losing a schema leaves every message byte-identical, while a changed description is a boundary at that schema | [Provider caching](provider-caching.md) |
|
|
145
|
+
| Attention-compiler / tool-result fold | In place at the fold frontier: the oldest stripped or stubbed row is the boundary — one reset, declared with `allowedResets` | [Attention compiler](attention-compiler.md) |
|
|
146
|
+
| Skill bodies and URI resources (tail) | After the transcript, append-only: a newly loaded body never invalidates the prefix and `cacheableContinuity` stays `1` | [Input and prompt assembly](input-and-prompt-assembly.md) |
|
|
147
|
+
|
|
148
|
+
- Switching `inputLayout` is an explicit, documented invalidation, not a stability claim — the whole
|
|
149
|
+
order moves ([`legacy` includes the resolved context and skill slots ahead of the instruction
|
|
150
|
+
groups](input-and-prompt-assembly.md)). The same fixtures are pinned under both `cache_aware` and
|
|
151
|
+
`legacy` in [`src/__tests__/invalidation-inventory.test.ts`](../src/__tests__/invalidation-inventory.test.ts):
|
|
152
|
+
the injector-context boundary is message 2 under `cache_aware` and message 1 under `legacy`, and the
|
|
153
|
+
summary boundary is 1 and 3. A layout change that relocates either boundary fails that suite.
|
|
81
154
|
|
|
82
155
|
## Security and performance notes
|
|
83
156
|
|
|
@@ -25,26 +25,26 @@ Do not use provider packages as a package manager, credential store, env loader,
|
|
|
25
25
|
|
|
26
26
|
| adapter package | version |
|
|
27
27
|
| --- | --- |
|
|
28
|
-
| `@arnilo/prism-providers/ai-sdk` | 0.
|
|
29
|
-
| `@arnilo/prism-providers/alibaba` | 0.
|
|
30
|
-
| `@arnilo/prism-providers/anthropic` | 0.
|
|
31
|
-
| `@arnilo/prism-providers/azure` | 0.
|
|
32
|
-
| `@arnilo/prism-providers/bedrock` | 0.
|
|
33
|
-
| `@arnilo/prism-providers/clinepass` | 0.
|
|
34
|
-
| `@arnilo/prism-providers/commandcode` | 0.
|
|
35
|
-
| `@arnilo/prism-providers/deepseek` | 0.
|
|
36
|
-
| `@arnilo/prism-providers/google` | 0.
|
|
37
|
-
| `@arnilo/prism-providers/hyper` | 0.
|
|
38
|
-
| `@arnilo/prism-providers/kimi` | 0.
|
|
39
|
-
| `@arnilo/prism-providers/model-discovery` | 0.
|
|
40
|
-
| `@arnilo/prism-providers/neuralwatt` | 0.
|
|
41
|
-
| `@arnilo/prism-providers/ollama` | 0.
|
|
42
|
-
| `@arnilo/prism-providers/openai` | 0.
|
|
43
|
-
| `@arnilo/prism-providers/opencode-go` | 0.
|
|
44
|
-
| `@arnilo/prism-providers/openrouter` | 0.
|
|
45
|
-
| `@arnilo/prism-providers/vertex` | 0.
|
|
46
|
-
| `@arnilo/prism-providers/xai` | 0.
|
|
47
|
-
| `@arnilo/prism-providers/zai` | 0.
|
|
28
|
+
| `@arnilo/prism-providers/ai-sdk` | 0.11.0 |
|
|
29
|
+
| `@arnilo/prism-providers/alibaba` | 0.11.0 |
|
|
30
|
+
| `@arnilo/prism-providers/anthropic` | 0.11.0 |
|
|
31
|
+
| `@arnilo/prism-providers/azure` | 0.11.0 |
|
|
32
|
+
| `@arnilo/prism-providers/bedrock` | 0.11.0 |
|
|
33
|
+
| `@arnilo/prism-providers/clinepass` | 0.11.0 |
|
|
34
|
+
| `@arnilo/prism-providers/commandcode` | 0.11.0 |
|
|
35
|
+
| `@arnilo/prism-providers/deepseek` | 0.11.0 |
|
|
36
|
+
| `@arnilo/prism-providers/google` | 0.11.0 |
|
|
37
|
+
| `@arnilo/prism-providers/hyper` | 0.11.0 |
|
|
38
|
+
| `@arnilo/prism-providers/kimi` | 0.11.0 |
|
|
39
|
+
| `@arnilo/prism-providers/model-discovery` | 0.11.0 |
|
|
40
|
+
| `@arnilo/prism-providers/neuralwatt` | 0.11.0 |
|
|
41
|
+
| `@arnilo/prism-providers/ollama` | 0.11.0 |
|
|
42
|
+
| `@arnilo/prism-providers/openai` | 0.11.0 |
|
|
43
|
+
| `@arnilo/prism-providers/opencode-go` | 0.11.0 |
|
|
44
|
+
| `@arnilo/prism-providers/openrouter` | 0.11.0 |
|
|
45
|
+
| `@arnilo/prism-providers/vertex` | 0.11.0 |
|
|
46
|
+
| `@arnilo/prism-providers/xai` | 0.11.0 |
|
|
47
|
+
| `@arnilo/prism-providers/zai` | 0.11.0 |
|
|
48
48
|
<!-- generated:package-truth:providers end -->
|
|
49
49
|
|
|
50
50
|
|
package/docs/public-contracts.md
CHANGED
|
@@ -147,7 +147,7 @@ Important request shapes:
|
|
|
147
147
|
| `PrismManifest` | Data-only package manifest with config defaults, contribution declarations, and resource declarations. |
|
|
148
148
|
| `ProductionPersistenceStore` | Adapter-facing interface for durable, paginated, multi-tenant storage plus optional `checkpoints?: CheckpointStore`, `leases?: LeaseStore`, and `feedback?: RunFeedbackStore`. No SQL/ORM/host file storage/network dependency. |
|
|
149
149
|
| `CheckpointStore` | Generic versioned checkpoint capability: save/load/bounded-list/delete by namespace and key, with ownership, exact-version CAS, and lease fencing. `createMemoryCheckpointStore()` is the reference implementation; it is bounded — `maxRecords` (default 10,000, evicts least-recently-saved) and `maxValueBytes` (default 1 MiB per JSON value). |
|
|
150
|
-
| `LeaseStore` | Atomic acquire/renew/release/get by namespace and key, with opaque claim tokens, expiry, ownership scope, and
|
|
150
|
+
| `LeaseStore` | Atomic acquire/renew/release/get by namespace and key, with opaque claim tokens, expiry, ownership scope, and takeover fences that increase while the row remains. `createMemoryLeaseStore()` sweeps expired rows at 1,024 and an evicted key restarts at fencing 1; durable adapters keep the counter. |
|
|
151
151
|
| `RunFeedbackStore` | Immutable append, bounded owned query, and owned deletion for ratings/comments/tags linked to existing run/trace/evaluation IDs. `createMemoryRunFeedbackStore()` is the reference implementation. |
|
|
152
152
|
| `EventMultiplexer<T>` | Generic bounded fan-in from async sources. `createEventMultiplexer()` owns queue limits, overflow policy, abort, source teardown, and close behavior. Graceful `close()` stops publishes/sources and drains already-queued events before the subscriber completes; overflow `close` still emits one notice and terminates. Single-consumer contract: a second concurrent `subscribe()` throws `EventMultiplexerError` (`ERR_PRISM_EVENT_MULTIPLEXER_SINGLE_CONSUMER`); the slot frees when the active consumer completes or is `return()`ed at a yield. `observe` fan-in is unchanged (broadcast happens at the source). |
|
|
153
153
|
| `PersistencePage<T>` | Cursor-paginated result page: `items`, optional `nextCursor`, optional `total`. |
|
|
@@ -157,6 +157,7 @@ Important request shapes:
|
|
|
157
157
|
| `SessionIndex` / `SessionSearchQuery` / `SessionSearchHit` | Bounded optional session search seam (`search` / `SessionStore.searchSessions?`). Filters: workspace (`metadata.workspaceRoot`), time, provider/model, label/summary, entry-kind (`kind`, e.g. annotation search), optional FTS `query`, ownership. Hits return `sessionId` + optional `leafId` for resume, and on a text match the matched entry pointer (`entryId`, `runId`, 1-based `turn`, store `score`) with a bounded matched-text `snippet`; never credentials. Caps via `resolveSessionSearchQuery` / `DEFAULT_*` / `HARD_MAX_*` session-search constants. SQLite/Postgres index (FTS5 / `tsvector`); memory and JSONL scan linearly. |
|
|
158
158
|
| `contextBudget` / `getContextBudgetReport` / `ContextBudgetError` | Opt-in assembler budget on `AssembleProviderInputOptions`; deterministic eviction; omission report in `ProviderRequest.metadata` (kinds/ids/sizes only). |
|
|
159
159
|
| `AgentSession.steer` / `SteerOptions` / pending-steer caps | Mid-run enqueue into active run; optional `softInterrupt`; default 8 msgs / 64 KiB UTF-8. |
|
|
160
|
+
| `AgentSession.close()` | Session teardown: dispatches `session_shutdown` middleware once (idempotent), then closes every subscriber (`acrossRuns` included). `session_start` is its mirror at the first run start; both are per-session, never per-turn. See [Middleware hooks](middleware-hooks.md). |
|
|
160
161
|
| `SessionSearchUnsupportedError` / `sessionSearchMode` | Memory opt-out (`sessionSearchMode: "unsupported"`); typed throw (not empty success). Memory linear caps are host-overridable via `CreateMemorySessionStoreOptions.search`; the JSONL store searches linearly with the contract default caps. |
|
|
161
162
|
| `BranchRecord` / `BranchQuery` | Branch handle/leaf pointer and query filters (session, name, parent branch, leaf presence). |
|
|
162
163
|
| `SessionEntryQuery` | Paginated entry filters: `sessionId`, `runId`, `parentId`, `leafId`, `kind`, timestamp range, ownership. |
|