@arnilo/prism 0.8.0 → 0.9.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 (121) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +11 -11
  3. package/dist/agent-approval.d.ts +11 -2
  4. package/dist/agent-event-source.d.ts +9 -1
  5. package/dist/agent-event-source.js +10 -3
  6. package/dist/agent-loops.js +7 -4
  7. package/dist/agent-run-lifecycle.d.ts +15 -1
  8. package/dist/agent-run-lifecycle.js +63 -6
  9. package/dist/agent-run-state.d.ts +22 -2
  10. package/dist/agent-run-state.js +57 -5
  11. package/dist/agent-session/helpers.js +14 -0
  12. package/dist/agent-session/session/assemble.js +126 -24
  13. package/dist/agent-session/session/persist.d.ts +11 -0
  14. package/dist/agent-session/session/persist.js +37 -11
  15. package/dist/agent-session/session/provider-round.d.ts +14 -4
  16. package/dist/agent-session/session/provider-round.js +185 -19
  17. package/dist/agent-session/session/tool-round.js +20 -1
  18. package/dist/agent-session/session/types.d.ts +25 -2
  19. package/dist/agent-session/session.d.ts +38 -4
  20. package/dist/agent-session/session.js +76 -5
  21. package/dist/attention-compiler.d.ts +51 -2
  22. package/dist/attention-compiler.js +282 -21
  23. package/dist/cache-helpers.d.ts +4 -2
  24. package/dist/cache-helpers.js +8 -6
  25. package/dist/checkpoint-restore.d.ts +45 -0
  26. package/dist/checkpoint-restore.js +54 -0
  27. package/dist/context-budget.d.ts +2 -1
  28. package/dist/context-budget.js +24 -2
  29. package/dist/contracts-core/agent.d.ts +30 -0
  30. package/dist/contracts-core/attention.d.ts +95 -0
  31. package/dist/contracts-core/content.d.ts +10 -0
  32. package/dist/contracts-core/guardrail-packs.d.ts +41 -0
  33. package/dist/contracts-core/guardrail-packs.js +2 -0
  34. package/dist/contracts-core/provider.d.ts +25 -0
  35. package/dist/contracts-core/run-limits.d.ts +19 -0
  36. package/dist/contracts-core/session.d.ts +23 -5
  37. package/dist/contracts-core/session.js +21 -2
  38. package/dist/contracts-core/usage.d.ts +40 -0
  39. package/dist/contracts-core/usage.js +8 -0
  40. package/dist/contracts-core.d.ts +2 -0
  41. package/dist/contracts-core.js +2 -0
  42. package/dist/contracts-protocol.d.ts +76 -2
  43. package/dist/contracts-run-state.d.ts +56 -1
  44. package/dist/guardrail-packs/coding-standard.d.ts +3 -0
  45. package/dist/guardrail-packs/coding-standard.js +63 -0
  46. package/dist/guardrail-packs/destructive-commands.d.ts +3 -0
  47. package/dist/guardrail-packs/destructive-commands.js +46 -0
  48. package/dist/guardrail-packs/errors.d.ts +7 -0
  49. package/dist/guardrail-packs/errors.js +9 -0
  50. package/dist/guardrail-packs/index.d.ts +4 -0
  51. package/dist/guardrail-packs/index.js +15 -0
  52. package/dist/guardrail-packs/secrets-hygiene.d.ts +3 -0
  53. package/dist/guardrail-packs/secrets-hygiene.js +23 -0
  54. package/dist/guardrail-packs/types.d.ts +16 -0
  55. package/dist/guardrail-packs/types.js +2 -0
  56. package/dist/guardrail-packs/validation-respect.d.ts +3 -0
  57. package/dist/guardrail-packs/validation-respect.js +53 -0
  58. package/dist/guardrails.d.ts +20 -1
  59. package/dist/guardrails.js +268 -0
  60. package/dist/index.d.ts +14 -9
  61. package/dist/index.js +9 -6
  62. package/dist/input.d.ts +8 -1
  63. package/dist/input.js +68 -6
  64. package/dist/middleware.d.ts +37 -2
  65. package/dist/middleware.js +41 -0
  66. package/dist/node/session-store-jsonl.js +18 -3
  67. package/dist/observability.js +6 -0
  68. package/dist/provider-events.d.ts +8 -2
  69. package/dist/provider-events.js +60 -2
  70. package/dist/providers/openai-compatible.js +6 -3
  71. package/dist/run-bundle.js +2 -1
  72. package/dist/run-limits.d.ts +11 -1
  73. package/dist/run-limits.js +46 -0
  74. package/dist/session-stores.d.ts +12 -1
  75. package/dist/session-stores.js +21 -4
  76. package/dist/testing/agent-event-source-conformance.js +41 -2
  77. package/dist/testing/prefix-stability-conformance.d.ts +30 -0
  78. package/dist/testing/prefix-stability-conformance.js +104 -0
  79. package/dist/testing/session-store-conformance.d.ts +3 -2
  80. package/dist/testing/session-store-conformance.js +48 -0
  81. package/dist/tools.d.ts +5 -0
  82. package/dist/tools.js +11 -3
  83. package/dist/usage-estimation.d.ts +29 -0
  84. package/dist/usage-estimation.js +79 -0
  85. package/docs/agent-events.md +68 -1
  86. package/docs/agent-session-runtime.md +1 -0
  87. package/docs/attention-compiler.md +89 -8
  88. package/docs/coding-agent-tools.md +1 -1
  89. package/docs/compaction-and-retry.md +1 -1
  90. package/docs/compaction-observational-memory.md +33 -6
  91. package/docs/durable-runs.md +42 -0
  92. package/docs/embeddings.md +5 -0
  93. package/docs/evaluations.md +5 -0
  94. package/docs/execution-timeline.md +78 -1
  95. package/docs/guardrails.md +38 -2
  96. package/docs/index.md +32 -13
  97. package/docs/input-and-prompt-assembly.md +3 -3
  98. package/docs/knowledge-sync.md +4 -0
  99. package/docs/middleware-hooks.md +38 -2
  100. package/docs/migrate-to-0.9.md +210 -0
  101. package/docs/migration.md +13 -0
  102. package/docs/multi-agent-patterns.md +25 -2
  103. package/docs/node-jsonl-session-store.md +7 -1
  104. package/docs/observability.md +7 -3
  105. package/docs/options-index.md +2 -1
  106. package/docs/policy-and-audit.md +13 -1
  107. package/docs/prefix-stability-conformance.md +93 -0
  108. package/docs/provider-caching.md +4 -4
  109. package/docs/provider-conformance.md +16 -0
  110. package/docs/provider-packages.md +20 -20
  111. package/docs/public-contracts.md +2 -2
  112. package/docs/rag.md +101 -3
  113. package/docs/release-and-install.md +39 -37
  114. package/docs/runs-and-usage.md +43 -6
  115. package/docs/scoped-agent-memory.md +262 -0
  116. package/docs/session-store-conformance.md +1 -2
  117. package/docs/session-stores.md +17 -17
  118. package/docs/supervisors.md +32 -12
  119. package/docs/tools.md +17 -0
  120. package/docs/workflows.md +5 -0
  121. package/package.json +5 -1
@@ -5,7 +5,7 @@
5
5
  * frozen prefix. The assembly branch lives in `input.ts`; opt-in agent wiring (Task 5) is not
6
6
  * part of this module. */
7
7
  import { type ContextBudgetMessageGroups } from "./context-budget.js";
8
- import type { AttentionCompiler, AttentionCompilerContext, AttentionCompilerOptions, AttentionCompilerSetting, AttentionInputCapOptions, AttentionReport, ContextBlock, Skill, ToolDefinition } from "./contracts.js";
8
+ import type { AttentionCompiler, AttentionCompilerContext, AttentionCompilerOptions, AttentionCompilerSetting, AttentionInputCapOptions, AttentionReport, AttentionTrigger, AttentionTriggerDecision, AttentionTriggerState, ContextBlock, Skill, ToolDefinition } from "./contracts.js";
9
9
  import { type CompactionTrigger } from "./contracts-core/compaction.js";
10
10
  import type { SecretRedactor } from "./redaction.js";
11
11
  import { type ResolvedToolResultFoldOptions } from "./tool-result-fold.js";
@@ -25,13 +25,29 @@ export declare function resolveAttentionReserveTokens(value: unknown): number;
25
25
  /** C2: host `maxInputTokens` wins; otherwise `contextWindow - (maxOutputTokens ?? 0) - reserve`.
26
26
  * Shared with the `input_ratio` compaction trigger (Task 4). Throws when no cap can be derived. */
27
27
  export declare function resolveInputCap(options?: AttentionInputCapOptions, model?: AttentionCompilerContext["model"]): number;
28
+ /**
29
+ * Build the frozen per-turn state the axes read: the compiler supplies the cap and the run budget
30
+ * (so a caller cannot silently drop the budget and fall back to the input cap), the caller
31
+ * supplies what only the turn knows.
32
+ */
33
+ export declare function attentionTriggerState(compiler: Pick<AttentionCompiler, "inputCap" | "runInputBudget">, turn: {
34
+ readonly estimatedInputTokens: number;
35
+ readonly runInputTokens?: number;
36
+ readonly turn?: number;
37
+ }): AttentionTriggerState;
38
+ /**
39
+ * Evaluate the fold axes in order against one turn's state; the first axis that fires wins, and
40
+ * its `targetTokens` (when it has one) is what the stages fold to. Called at most twice per turn —
41
+ * once at turn start, once after the stages — so a host predicate never runs per row.
42
+ */
43
+ export declare function evaluateAttentionTrigger(axes: readonly AttentionTrigger[], state: AttentionTriggerState): AttentionTriggerDecision;
28
44
  /** Validate compiler options + resolve the input cap. No provider I/O; unknown or
29
45
  * unresolvable input throws here rather than on the first turn. */
30
46
  export declare function createAttentionCompiler(options?: AttentionCompilerOptions, context?: AttentionCompilerContext): AttentionCompiler;
31
47
  /** Resolve the run's compiler from the agent setting plus an optional run overlay, validating
32
48
  * both eagerly (no provider I/O) so a typo fails at run start, not on some later turn (C12).
33
49
  * Returns `undefined` when the compiler is off — the assembly path then allocates nothing. */
34
- export declare function resolveRunAttentionCompiler(agent: AttentionCompilerSetting | undefined, run: AttentionCompilerSetting | undefined, model: AttentionCompilerContext["model"]): AttentionCompiler | undefined;
50
+ export declare function resolveRunAttentionCompiler(agent: AttentionCompilerSetting | undefined, run: AttentionCompilerSetting | undefined, model: AttentionCompilerContext["model"], runInputBudget?: number | null): AttentionCompiler | undefined;
35
51
  /** Caller-owned sticky frontier: what this session leaf already mutated (C10). Mutations are
36
52
  * monotonic, so a stubbed call stays stubbed and stripped thinking stays stripped even on a
37
53
  * later under-ratio turn — restoring either would rewrite the prompt-cache prefix. */
@@ -53,9 +69,15 @@ export interface AttentionCompileOptions {
53
69
  * decide fold-eligibility. Omitted → the deterministic stub and `keepLast` alone. */
54
70
  readonly fold?: ResolvedToolResultFoldOptions;
55
71
  readonly frontier?: AttentionStickyFrontier;
72
+ /** Session-owned folded bodies (plan 086 T3): a body stored here is re-applied instead of
73
+ * re-summarized, so a sticky row stays byte-identical and the host `summarize` runs once per
74
+ * row instead of once per turn. Omit for one-shot assemblies. */
75
+ readonly attentionFold?: AttentionFoldLedger;
56
76
  readonly redactor?: SecretRedactor;
57
77
  readonly signal?: AbortSignal;
58
78
  readonly turn?: number;
79
+ /** Run input tokens already charged this run; the `run_input_ratio` axis projects onto it. */
80
+ readonly runInputTokens?: number;
59
81
  readonly sessionId?: string;
60
82
  readonly runId?: string;
61
83
  }
@@ -90,6 +112,33 @@ export declare function serializeAttentionStickyFrontier(frontier: AttentionStic
90
112
  export declare function parseAttentionStickyFrontier(value: unknown): PersistedAttentionStickyFrontier | undefined;
91
113
  /** Rebuild a frontier from a restored snapshot; callers hold the session that owns it. */
92
114
  export declare function restoreAttentionStickyFrontier(persisted: PersistedAttentionStickyFrontier): AttentionStickyFrontier;
115
+ /** Session-owned folded bodies, keyed by tool call id. A row is summarized once: every later
116
+ * turn re-applies the stored body, so a sticky row stays byte-identical for the provider cache
117
+ * and the host `summarize` is not called again for it. Bodies are already redacted and capped by
118
+ * the fold that produced them, so nothing here needs redaction at persistence time. */
119
+ export interface AttentionFoldLedger {
120
+ /** toolCallId → stub body (never the payload); insertion order is fold order, oldest first. */
121
+ readonly bodies: Map<string, string>;
122
+ }
123
+ export declare function createAttentionFoldLedger(): AttentionFoldLedger;
124
+ /** Serialized fold ledger (plan 086 T3). Stub bodies only — no payload, and every entry was
125
+ * already capped and redacted when it was folded, so a durable resume restores it verbatim. */
126
+ export interface PersistedAttentionFoldLedger {
127
+ readonly v: 1;
128
+ /** Oldest-first, so a restored ledger is the tail of the folds the session made. */
129
+ readonly bodies: readonly {
130
+ readonly id: string;
131
+ readonly body: string;
132
+ }[];
133
+ }
134
+ /** Bounded snapshot of a live ledger; caller-owned (the runtime persists it, the compiler never
135
+ * writes anywhere). */
136
+ export declare function serializeAttentionFoldLedger(ledger: AttentionFoldLedger): PersistedAttentionFoldLedger;
137
+ /** Validate a persisted ledger from an untrusted store (plan 086 T3). Malformed *entries* are
138
+ * dropped one by one — a body the compiler cannot trust simply re-summarizes on the next turn —
139
+ * while a malformed *shape* yields `undefined` so the caller starts from an empty ledger.
140
+ * Never throws: a resume must not fail because a checkpoint was hand-edited. */
141
+ export declare function restoreAttentionFoldLedger(value: unknown): AttentionFoldLedger | undefined;
93
142
  /** Consecutive `truncated` turns that arm compaction by default. */
94
143
  export declare const DEFAULT_ATTENTION_TRUNCATION_THRESHOLD = 2;
95
144
  export interface AttentionTruncationTriggerOptions {
@@ -7,7 +7,7 @@
7
7
  import { createHash } from "node:crypto";
8
8
  import { estimateMessageBytes, estimateMessageTokens, estimateTextBytes, measureInputCost, } from "./context-budget.js";
9
9
  import { assertCompactionTrigger } from "./contracts-core/compaction.js";
10
- import { capToolResultSummary, foldedToolResultHeader, inferToolResultTurns, toolResultFoldText, } from "./tool-result-fold.js";
10
+ import { capToolResultSummary, foldedToolResultHeader, HARD_TOOL_RESULT_FOLD_MAX_SUMMARY_BYTES, inferToolResultTurns, toolResultFoldText, } from "./tool-result-fold.js";
11
11
  export const ATTENTION_BUDGET_ERROR_CODE = "attention_budget_exceeded";
12
12
  /** C9: still over `triggerRatio` after every eligible stage — host should compact, not delete. */
13
13
  export class AttentionBudgetError extends Error {
@@ -44,6 +44,19 @@ function resolveCount(value, fallback, name) {
44
44
  }
45
45
  return value;
46
46
  }
47
+ /** `resolveRatio` for a value with no default: an axis ratio is required. */
48
+ function requiredRatio(value, name) {
49
+ if (value === undefined)
50
+ throw new TypeError(`${name} must be a number in (0, 1)`);
51
+ return resolveRatio(value, DEFAULT_ATTENTION_TRIGGER_RATIO, name);
52
+ }
53
+ function resolveBoolean(value, name) {
54
+ if (value === undefined)
55
+ return false;
56
+ if (typeof value !== "boolean")
57
+ throw new TypeError(`${name} must be a boolean`);
58
+ return value;
59
+ }
47
60
  export function resolveAttentionReserveTokens(value) {
48
61
  return resolveCount(value, DEFAULT_ATTENTION_RESERVE_TOKENS, "attentionCompiler.reserveTokens");
49
62
  }
@@ -92,29 +105,170 @@ function resolveExcludeTools(value) {
92
105
  }
93
106
  return Object.freeze(out);
94
107
  }
108
+ /** Normalize one configured axis, freezing it. Unnamed predicates are wrapped so the evaluator
109
+ * sees one shape; every failure is a config-time `TypeError` naming the option. */
110
+ function normalizeAttentionTrigger(value, name) {
111
+ if (typeof value === "function")
112
+ return Object.freeze({ kind: "predicate", shouldFold: value });
113
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
114
+ throw new TypeError(`${name} must be a trigger object, a predicate function, or an array of them`);
115
+ }
116
+ const kind = value.kind;
117
+ switch (kind) {
118
+ case "input_ratio":
119
+ case "run_input_ratio":
120
+ return Object.freeze({ kind, ratio: requiredRatio(value.ratio, `${name}.ratio`) });
121
+ case "token_floor": {
122
+ const tokens = value.tokens;
123
+ if (!Number.isSafeInteger(tokens) || tokens < 1) {
124
+ throw new TypeError(`${name}.tokens must be a positive safe integer`);
125
+ }
126
+ return Object.freeze({ kind, tokens: tokens });
127
+ }
128
+ case "predicate": {
129
+ const shouldFold = value.shouldFold;
130
+ if (typeof shouldFold !== "function")
131
+ throw new TypeError(`${name}.shouldFold must be a function`);
132
+ return Object.freeze({ kind, shouldFold: shouldFold });
133
+ }
134
+ default:
135
+ throw new TypeError(`unknown ${name} kind: ${String(kind)} (expected input_ratio, run_input_ratio, token_floor, or predicate)`);
136
+ }
137
+ }
138
+ /** Fold axes for a compiler: exactly the configured `trigger`, or the `triggerRatio` axis alone
139
+ * (plan 074 behavior) when none is. Frozen, in evaluation order. */
140
+ function resolveAttentionTriggerAxes(triggerRatio, value) {
141
+ if (value === undefined)
142
+ return Object.freeze([Object.freeze({ kind: "input_ratio", ratio: triggerRatio })]);
143
+ const entries = Array.isArray(value) ? value : [value];
144
+ if (entries.length === 0) {
145
+ throw new TypeError("attentionCompiler.trigger must be a trigger object, a predicate function, or a non-empty array of them");
146
+ }
147
+ const single = entries.length === 1;
148
+ return Object.freeze(entries.map((entry, index) => normalizeAttentionTrigger(entry, single ? "attentionCompiler.trigger" : `attentionCompiler.trigger[${index}]`)));
149
+ }
150
+ /** Resolved `RunLimits.maxInputTokens`; `null` disables the cap, so it is no budget either. */
151
+ function resolveAttentionRunInputBudget(value) {
152
+ if (value === undefined || value === null)
153
+ return undefined;
154
+ if (!Number.isSafeInteger(value) || value < 1) {
155
+ throw new TypeError("attentionCompiler.runInputBudget must be a positive safe integer");
156
+ }
157
+ return value;
158
+ }
159
+ /**
160
+ * Build the frozen per-turn state the axes read: the compiler supplies the cap and the run budget
161
+ * (so a caller cannot silently drop the budget and fall back to the input cap), the caller
162
+ * supplies what only the turn knows.
163
+ */
164
+ export function attentionTriggerState(compiler, turn) {
165
+ return Object.freeze({
166
+ estimatedInputTokens: turn.estimatedInputTokens,
167
+ inputCapTokens: compiler.inputCap,
168
+ ...(compiler.runInputBudget === undefined ? {} : { runInputBudgetTokens: compiler.runInputBudget }),
169
+ runInputTokens: turn.runInputTokens ?? 0,
170
+ turn: turn.turn ?? 1,
171
+ });
172
+ }
173
+ /**
174
+ * Evaluate the fold axes in order against one turn's state; the first axis that fires wins, and
175
+ * its `targetTokens` (when it has one) is what the stages fold to. Called at most twice per turn —
176
+ * once at turn start, once after the stages — so a host predicate never runs per row.
177
+ */
178
+ export function evaluateAttentionTrigger(axes, state) {
179
+ for (const axis of axes) {
180
+ switch (axis.kind) {
181
+ case "input_ratio": {
182
+ const targetTokens = axis.ratio * state.inputCapTokens;
183
+ if (state.estimatedInputTokens >= targetTokens) {
184
+ return { shouldFold: true, firedAxis: "input_ratio", targetTokens, failsClosed: true };
185
+ }
186
+ continue;
187
+ }
188
+ case "token_floor":
189
+ if (state.estimatedInputTokens >= axis.tokens) {
190
+ return { shouldFold: true, firedAxis: "token_floor", targetTokens: axis.tokens, failsClosed: true };
191
+ }
192
+ continue;
193
+ case "run_input_ratio": {
194
+ const budget = state.runInputBudgetTokens;
195
+ if (budget === undefined) {
196
+ // No run budget declared: the axis is the per-request ratio comparison.
197
+ const targetTokens = axis.ratio * state.inputCapTokens;
198
+ if (state.estimatedInputTokens >= targetTokens) {
199
+ return { shouldFold: true, firedAxis: "run_input_ratio", targetTokens, failsClosed: true };
200
+ }
201
+ continue;
202
+ }
203
+ if (state.runInputTokens + state.estimatedInputTokens < axis.ratio * budget)
204
+ continue;
205
+ // Cumulative: the spend is already booked, so folding cannot settle the axis. No
206
+ // per-request target (fold every eligible row) and no fail-closed throw — the run limit
207
+ // owns the cap, and folding is what keeps the run under it.
208
+ return { shouldFold: true, firedAxis: "run_input_ratio", failsClosed: false };
209
+ }
210
+ case "predicate": {
211
+ const fired = axis.shouldFold(state);
212
+ if (typeof fired !== "boolean") {
213
+ throw new TypeError("attentionCompiler.trigger predicate must return a boolean (use a synchronous function)");
214
+ }
215
+ if (fired)
216
+ return { shouldFold: true, firedAxis: "predicate", failsClosed: true };
217
+ continue;
218
+ }
219
+ }
220
+ }
221
+ return { shouldFold: false, failsClosed: false };
222
+ }
95
223
  /** Validate compiler options + resolve the input cap. No provider I/O; unknown or
96
224
  * unresolvable input throws here rather than on the first turn. */
97
225
  export function createAttentionCompiler(options = {}, context = {}) {
98
226
  if (typeof options !== "object" || options === null)
99
227
  throw new TypeError("attentionCompiler options must be an object");
100
228
  const triggerRatio = resolveRatio(options.triggerRatio, DEFAULT_ATTENTION_TRIGGER_RATIO, "attentionCompiler.triggerRatio");
229
+ const triggerAxes = resolveAttentionTriggerAxes(triggerRatio, options.trigger);
230
+ // Reference ratio the handle reports and `compactRatio` / the compaction trigger are checked
231
+ // against: the explicit `triggerRatio`, else the first `input_ratio` axis, so a `trigger` that
232
+ // replaces the gate still reports the ratio that gate actually folds at.
233
+ let referenceRatio = triggerRatio;
234
+ if (options.triggerRatio === undefined) {
235
+ for (const axis of triggerAxes) {
236
+ if (axis.kind === "input_ratio") {
237
+ referenceRatio = axis.ratio;
238
+ break;
239
+ }
240
+ }
241
+ }
101
242
  const compactRatio = resolveRatio(options.compactRatio, DEFAULT_ATTENTION_COMPACT_RATIO, "attentionCompiler.compactRatio");
102
- if (compactRatio <= triggerRatio) {
103
- throw new TypeError(`attentionCompiler.compactRatio (${compactRatio}) must exceed triggerRatio (${triggerRatio})`);
243
+ if (compactRatio <= referenceRatio) {
244
+ throw new TypeError(`attentionCompiler.compactRatio (${compactRatio}) must exceed triggerRatio (${referenceRatio})`);
104
245
  }
105
246
  const thinkingKeepTurns = resolveCount(options.thinkingKeepTurns, DEFAULT_ATTENTION_THINKING_KEEP_TURNS, "attentionCompiler.thinkingKeepTurns");
106
247
  const keepLast = resolveCount(options.keepLast, DEFAULT_ATTENTION_KEEP_LAST, "attentionCompiler.keepLast");
107
248
  const inputCap = resolveInputCap(options, context.model);
108
249
  const reserveTokens = resolveAttentionReserveTokens(options.reserveTokens);
109
250
  const excludeTools = resolveExcludeTools(options.excludeTools);
251
+ const runInputBudget = resolveAttentionRunInputBudget(context.runInputBudget);
252
+ const durable = resolveBoolean(options.durable, "attentionCompiler.durable");
110
253
  const trigger = context.compactionTrigger;
111
254
  if (trigger !== undefined) {
112
255
  assertCompactionTrigger(trigger);
113
- if (trigger.type === "input_ratio" && trigger.ratio <= triggerRatio) {
114
- throw new TypeError(`compaction input_ratio (${trigger.ratio}) must exceed attentionCompiler.triggerRatio (${triggerRatio})`);
256
+ if (trigger.type === "input_ratio" && trigger.ratio <= referenceRatio) {
257
+ throw new TypeError(`compaction input_ratio (${trigger.ratio}) must exceed attentionCompiler.triggerRatio (${referenceRatio})`);
115
258
  }
116
259
  }
117
- return Object.freeze({ inputCap, reserveTokens, triggerRatio, compactRatio, thinkingKeepTurns, keepLast, excludeTools });
260
+ return Object.freeze({
261
+ inputCap,
262
+ reserveTokens,
263
+ triggerRatio: referenceRatio,
264
+ compactRatio,
265
+ thinkingKeepTurns,
266
+ keepLast,
267
+ excludeTools,
268
+ trigger: triggerAxes,
269
+ durable,
270
+ ...(runInputBudget === undefined ? {} : { runInputBudget }),
271
+ });
118
272
  }
119
273
  /* ------------------------------------------------------------------------------------------------
120
274
  * Opt-in wiring (Task 5)
@@ -154,8 +308,11 @@ function mergeAttentionRunOverlay(base, overlay) {
154
308
  }
155
309
  return value;
156
310
  };
157
- if (overlay.maxInputTokens !== undefined || overlay.reserveTokens !== undefined) {
158
- throw new TypeError("RunOptions.attentionCompiler must not set maxInputTokens or reserveTokens: the input cap is agent-config only");
311
+ if (overlay.maxInputTokens !== undefined ||
312
+ overlay.reserveTokens !== undefined ||
313
+ overlay.trigger !== undefined ||
314
+ overlay.durable !== undefined) {
315
+ throw new TypeError("RunOptions.attentionCompiler must not set maxInputTokens, reserveTokens, trigger, or durable: the input cap, the gate, and fold durability are agent-config only");
159
316
  }
160
317
  const triggerRatio = gate("triggerRatio", DEFAULT_ATTENTION_TRIGGER_RATIO);
161
318
  const compactRatio = gate("compactRatio", DEFAULT_ATTENTION_COMPACT_RATIO);
@@ -174,7 +331,11 @@ function mergeAttentionRunOverlay(base, overlay) {
174
331
  /** Resolve the run's compiler from the agent setting plus an optional run overlay, validating
175
332
  * both eagerly (no provider I/O) so a typo fails at run start, not on some later turn (C12).
176
333
  * Returns `undefined` when the compiler is off — the assembly path then allocates nothing. */
177
- export function resolveRunAttentionCompiler(agent, run, model) {
334
+ export function resolveRunAttentionCompiler(agent, run, model, runInputBudget) {
335
+ const context = {
336
+ model,
337
+ ...(runInputBudget === undefined || runInputBudget === null ? {} : { runInputBudget }),
338
+ };
178
339
  const enabled = enabledAttentionSetting(agent);
179
340
  if (enabled === undefined) {
180
341
  if (run !== undefined && run !== false) {
@@ -185,11 +346,11 @@ export function resolveRunAttentionCompiler(agent, run, model) {
185
346
  if (run === false)
186
347
  return undefined;
187
348
  if (run === undefined || run === true)
188
- return createAttentionCompiler(enabled, { model });
349
+ return createAttentionCompiler(enabled, context);
189
350
  if (typeof run !== "object" || run === null || Array.isArray(run)) {
190
351
  throw new TypeError("RunOptions.attentionCompiler must be false or an options object");
191
352
  }
192
- return createAttentionCompiler(mergeAttentionRunOverlay(enabled, run), { model });
353
+ return createAttentionCompiler(mergeAttentionRunOverlay(enabled, run), context);
193
354
  }
194
355
  export function createAttentionStickyFrontier() {
195
356
  return { thinking: new Set(), toolCallIds: new Set() };
@@ -215,15 +376,30 @@ export async function compileAttention(options) {
215
376
  const { compiler, groups } = options;
216
377
  const turn = options.turn ?? 1;
217
378
  const frontier = options.frontier;
218
- const triggerTokens = compiler.triggerRatio * compiler.inputCap;
379
+ const runInputTokens = options.runInputTokens ?? 0;
380
+ // One frozen state per evaluation: predicates never see a live object they could mutate, and
381
+ // the axes are evaluated at most twice a turn (turn start, then after the stages).
382
+ const triggerState = (estimatedInputTokens) => attentionTriggerState(compiler, { estimatedInputTokens, runInputTokens, turn });
219
383
  const usedAtStart = measureInputCost({ groups, context: options.context, skills: options.skills, tools: options.tools }).tokens;
384
+ const decision = evaluateAttentionTrigger(compiler.trigger, triggerState(usedAtStart));
385
+ const targetTokens = decision.targetTokens;
386
+ const gateOpen = decision.shouldFold;
220
387
  let used = usedAtStart;
221
- const over = () => used >= triggerTokens;
388
+ // The gate is decided once per turn against the assembled estimate; the per-row loop then only
389
+ // compares numbers, so a predicate axis costs one call, not one per eligible row. A target-less
390
+ // axis (cumulative run budget, predicate) folds every eligible row.
391
+ const needsMore = () => gateOpen && (targetTokens === undefined || used >= targetTokens);
392
+ /** Post-stage verdict: a per-request axis still over its target, or a target-less axis whose
393
+ * predicate still fires, fails closed. A cumulative run-budget axis never does. */
394
+ const stillFired = () => targetTokens !== undefined
395
+ ? used >= targetTokens
396
+ : decision.failsClosed && evaluateAttentionTrigger(compiler.trigger, triggerState(Math.max(0, used))).shouldFold;
222
397
  const history = [...groups.history];
223
398
  const toolResults = [...groups.toolResults];
224
399
  let droppedThinkingTurns = 0;
225
400
  let stubbedToolResults = 0;
226
401
  let stubbedBytes = 0;
402
+ let newFoldedBodies = 0;
227
403
  let leftEligible = 0;
228
404
  // Stage 1 — strip thinking from every assistant turn except the newest `thinkingKeepTurns`.
229
405
  const rowTurns = inferToolResultTurns(history);
@@ -233,7 +409,7 @@ export async function compileAttention(options) {
233
409
  continue;
234
410
  const key = thinkingKey(message, options.redactor);
235
411
  const sticky = frontier?.thinking.has(key) === true;
236
- if (!sticky && !over()) {
412
+ if (!sticky && !needsMore()) {
237
413
  leftEligible += 1;
238
414
  continue;
239
415
  }
@@ -247,11 +423,12 @@ export async function compileAttention(options) {
247
423
  // and the in-flight group (which is always newer than history).
248
424
  for (const target of toolResultTargets({ history, toolResults, rowTurns, turn, compiler, fold: options.fold })) {
249
425
  const sticky = frontier?.toolCallIds.has(target.toolCallId) === true;
250
- if (!sticky && !over()) {
426
+ if (!sticky && !needsMore()) {
251
427
  leftEligible += 1;
252
428
  continue;
253
429
  }
254
- const stubbed = await stubToolResultMessage(target, options);
430
+ const folded = await foldedBody(target, options);
431
+ const stubbed = stubToolResultMessage(target, folded.body);
255
432
  const before = estimateMessageTokens(target.message);
256
433
  const after = estimateMessageTokens(stubbed);
257
434
  if (after >= before && !sticky) {
@@ -259,6 +436,8 @@ export async function compileAttention(options) {
259
436
  leftEligible += 1;
260
437
  continue;
261
438
  }
439
+ if (!folded.cached && rememberFold(options.attentionFold, target.toolCallId, folded.body))
440
+ newFoldedBodies += 1;
262
441
  const beforeBytes = estimateMessageBytes(target.message);
263
442
  target.update(stubbed);
264
443
  used -= before - after;
@@ -266,8 +445,9 @@ export async function compileAttention(options) {
266
445
  stubbedBytes += Math.max(0, beforeBytes - estimateMessageBytes(stubbed));
267
446
  rememberToolCall(frontier, target.toolCallId);
268
447
  }
269
- if (over()) {
270
- throw new AttentionBudgetError(`attention budget exceeded: estimated ${used} tokens >= ${Math.ceil(triggerTokens)} (triggerRatio ${compiler.triggerRatio} of inputCap ${compiler.inputCap}) after dropping ${droppedThinkingTurns} thinking turns and stubbing ${stubbedToolResults} tool results`);
448
+ if (gateOpen && stillFired()) {
449
+ const threshold = targetTokens === undefined ? String(decision.firedAxis) : Math.ceil(targetTokens);
450
+ throw new AttentionBudgetError(`attention budget exceeded: estimated ${used} tokens >= ${threshold} (${gateDetail(compiler, decision)}) after dropping ${droppedThinkingTurns} thinking turns and stubbing ${stubbedToolResults} tool results`);
271
451
  }
272
452
  const mutated = droppedThinkingTurns > 0 || stubbedToolResults > 0;
273
453
  return {
@@ -278,15 +458,26 @@ export async function compileAttention(options) {
278
458
  usedAfter: Math.max(0, used),
279
459
  inputCap: compiler.inputCap,
280
460
  triggerRatio: compiler.triggerRatio,
461
+ ...(decision.firedAxis === undefined ? {} : { firedAxis: decision.firedAxis }),
281
462
  droppedThinkingTurns,
282
463
  stubbedToolResults,
283
464
  stubbedBytes,
465
+ newFoldedBodies,
284
466
  truncated: leftEligible > 0,
285
467
  ...(options.runId === undefined ? {} : { runId: options.runId }),
286
468
  ...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
287
469
  },
288
470
  };
289
471
  }
472
+ /** Error detail for the fail-closed throw: the legacy wording for the plain `triggerRatio` axis,
473
+ * the axis name (and run budget, when one is in play) for every other axis. */
474
+ function gateDetail(compiler, decision) {
475
+ if (decision.firedAxis === "input_ratio" && decision.targetTokens === compiler.triggerRatio * compiler.inputCap) {
476
+ return `triggerRatio ${compiler.triggerRatio} of inputCap ${compiler.inputCap}`;
477
+ }
478
+ const budget = compiler.runInputBudget === undefined ? "" : ` with run input budget ${compiler.runInputBudget}`;
479
+ return `${String(decision.firedAxis)} gate of inputCap ${compiler.inputCap}${budget}`;
480
+ }
290
481
  /** Thinking-bearing assistant-message indexes, oldest first, excluding the newest `keepTurns`.
291
482
  * Counts thinking turns, not assistant messages, so a thinking-free reply does not push an
292
483
  * older reasoning block out of the keep window. */
@@ -338,11 +529,17 @@ function toolResultTargets(options) {
338
529
  return estimateTextBytes(toolResultFoldText(block.result, block.error, message.content)) >= fold.minBytes;
339
530
  });
340
531
  }
341
- async function stubToolResultMessage(target, options) {
532
+ /** The stub body for one row: the ledger's copy when it has one, the host `summarize`
533
+ * otherwise. `cached` is the turn's summarize saving — a miss is the only case that costs a
534
+ * host call, and the only case a durable run has new fold state to checkpoint. */
535
+ async function foldedBody(target, options) {
536
+ const cached = options.attentionFold?.bodies.get(target.toolCallId);
537
+ if (cached !== undefined)
538
+ return { body: cached, cached: true };
342
539
  const { block, message } = target;
343
540
  const text = toolResultFoldText(block.result, block.error, message.content);
344
541
  const fold = options.fold;
345
- const summary = fold
542
+ const body = fold
346
543
  ? capToolResultSummary(String(await fold.summarize({
347
544
  sessionId: options.sessionId ?? "",
348
545
  runId: options.runId ?? "",
@@ -352,9 +549,13 @@ async function stubToolResultMessage(target, options) {
352
549
  text,
353
550
  })), fold.maxSummaryBytes)
354
551
  : attentionStubText(options.redactor?.redact(text) ?? text);
552
+ return { body, cached: false };
553
+ }
554
+ function stubToolResultMessage(target, body) {
555
+ const { message } = target;
355
556
  return {
356
557
  ...message,
357
- content: message.content.map((part) => part.type === "tool_result" ? { ...part, result: foldedToolResultHeader(target.toolName, target.toolCallId, summary) } : part),
558
+ content: message.content.map((part) => part.type === "tool_result" ? { ...part, result: foldedToolResultHeader(target.toolName, target.toolCallId, body) } : part),
358
559
  metadata: { ...message.metadata, prismFolded: true },
359
560
  };
360
561
  }
@@ -448,6 +649,66 @@ export function restoreAttentionStickyFrontier(persisted) {
448
649
  frontier.toolCallIds.add(id);
449
650
  return frontier;
450
651
  }
652
+ export function createAttentionFoldLedger() {
653
+ return { bodies: new Map() };
654
+ }
655
+ /** Live and persisted ledger caps. One body is bounded by the fold's own `maxSummaryBytes`
656
+ * (hard cap 4 KiB), so the entry count is what bounds a durable checkpoint. */
657
+ const MAX_ATTENTION_FOLD_BODIES = 64;
658
+ const ATTENTION_FOLD_SCHEMA_VERSION = 1;
659
+ /** Keep the newest `max` entries: a session that folds more rows than the cap keeps the recent
660
+ * bodies and re-summarizes the oldest if their rows are ever folded again. */
661
+ function trimOldest(bodies, max) {
662
+ while (bodies.size > max) {
663
+ const oldest = bodies.keys().next().value;
664
+ if (oldest === undefined)
665
+ break;
666
+ bodies.delete(oldest);
667
+ }
668
+ }
669
+ /** Record one folded body, keeping the live ledger inside its caps. Returns `false` for a body
670
+ * too large to be worth a checkpoint byte (the fold's own cap makes this host-only in practice). */
671
+ function rememberFold(ledger, toolCallId, body) {
672
+ if (!ledger || estimateTextBytes(body) > HARD_TOOL_RESULT_FOLD_MAX_SUMMARY_BYTES)
673
+ return false;
674
+ ledger.bodies.delete(toolCallId);
675
+ ledger.bodies.set(toolCallId, body);
676
+ trimOldest(ledger.bodies, MAX_ATTENTION_FOLD_BODIES);
677
+ return true;
678
+ }
679
+ /** Bounded snapshot of a live ledger; caller-owned (the runtime persists it, the compiler never
680
+ * writes anywhere). */
681
+ export function serializeAttentionFoldLedger(ledger) {
682
+ return {
683
+ v: ATTENTION_FOLD_SCHEMA_VERSION,
684
+ bodies: [...ledger.bodies].slice(-MAX_ATTENTION_FOLD_BODIES).map(([id, body]) => ({ id, body })),
685
+ };
686
+ }
687
+ /** Validate a persisted ledger from an untrusted store (plan 086 T3). Malformed *entries* are
688
+ * dropped one by one — a body the compiler cannot trust simply re-summarizes on the next turn —
689
+ * while a malformed *shape* yields `undefined` so the caller starts from an empty ledger.
690
+ * Never throws: a resume must not fail because a checkpoint was hand-edited. */
691
+ export function restoreAttentionFoldLedger(value) {
692
+ if (typeof value !== "object" || value === null || Array.isArray(value))
693
+ return undefined;
694
+ const bodies = value.bodies;
695
+ if (!Array.isArray(bodies))
696
+ return undefined;
697
+ const ledger = createAttentionFoldLedger();
698
+ for (const entry of bodies) {
699
+ if (typeof entry !== "object" || entry === null)
700
+ continue;
701
+ const { id, body } = entry;
702
+ if (typeof id !== "string" || id.length === 0 || id.length > MAX_ATTENTION_TOOL_CALL_ID_CHARS || id.includes("\u0000"))
703
+ continue;
704
+ if (typeof body !== "string" || body.length === 0 || estimateTextBytes(body) > HARD_TOOL_RESULT_FOLD_MAX_SUMMARY_BYTES)
705
+ continue;
706
+ ledger.bodies.delete(id);
707
+ ledger.bodies.set(id, body);
708
+ }
709
+ trimOldest(ledger.bodies, MAX_ATTENTION_FOLD_BODIES);
710
+ return ledger;
711
+ }
451
712
  /** Consecutive `truncated` turns that arm compaction by default. */
452
713
  export const DEFAULT_ATTENTION_TRUNCATION_THRESHOLD = 2;
453
714
  export function createAttentionTruncationTrigger(options = {}) {
@@ -14,8 +14,10 @@ export type CacheControlledMessage = Omit<Message, "content"> & {
14
14
  readonly content: readonly CacheControlledContentBlock[];
15
15
  };
16
16
  export interface CacheUsageReport {
17
- readonly cacheReadTokens: number;
18
- readonly cacheWriteTokens: number;
17
+ /** Present only when the provider reported cache-read usage; never fabricated as zero. */
18
+ readonly cacheReadTokens?: number;
19
+ /** Present only when the provider reported cache-write usage; never fabricated as zero. */
20
+ readonly cacheWriteTokens?: number;
19
21
  readonly hitRate?: number;
20
22
  readonly estimatedSavings?: number;
21
23
  readonly currency?: string;
@@ -67,15 +67,17 @@ export function cacheSavings(usage, model) {
67
67
  return (read * Math.max(0, input - cacheRead)) / costUnitDivisor(model.cost?.unit);
68
68
  }
69
69
  export function cacheUsageReport(usage, model) {
70
- if (!usage)
70
+ if (!usage || (usage.cacheReadTokens === undefined && usage.cacheWriteTokens === undefined))
71
71
  return undefined;
72
72
  const estimatedSavings = model ? cacheSavings(usage, model) : undefined;
73
+ const currency = estimatedSavings === undefined ? usage.currency : (model?.cost?.currency ?? usage.currency);
74
+ const hitRate = cacheHitRate(usage);
73
75
  return {
74
- cacheReadTokens: usage.cacheReadTokens ?? 0,
75
- cacheWriteTokens: usage.cacheWriteTokens ?? 0,
76
- hitRate: cacheHitRate(usage),
77
- estimatedSavings,
78
- currency: estimatedSavings === undefined ? usage.currency : (model?.cost?.currency ?? usage.currency),
76
+ ...(usage.cacheReadTokens === undefined ? {} : { cacheReadTokens: usage.cacheReadTokens }),
77
+ ...(usage.cacheWriteTokens === undefined ? {} : { cacheWriteTokens: usage.cacheWriteTokens }),
78
+ ...(hitRate === undefined ? {} : { hitRate }),
79
+ ...(estimatedSavings === undefined ? {} : { estimatedSavings }),
80
+ ...(currency === undefined ? {} : { currency }),
79
81
  };
80
82
  }
81
83
  export function resolveBreakpoint(messages, breakpoint) {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Checkpoint restore hooks (plan 094 Task 3). A hook restores one external layer (git commit,
3
+ * document version, workspace fingerprint) recorded in a checkpoint's sidecar metadata.
4
+ *
5
+ * All-or-nothing: hooks run before the resume claims the checkpoint. The first hook that fails
6
+ * or times out aborts the resume with `CheckpointRestoreError` naming that hook, so the
7
+ * conversation restore never applies on top of a half-restored external world. Hosts that need
8
+ * every layer back where they were re-run the whole restore after fixing the failing layer.
9
+ */
10
+ /** Per-hook ceiling for a restore (plan 094 Task 3 default). */
11
+ export declare const DEFAULT_CHECKPOINT_RESTORE_TIMEOUT_MS = 10000;
12
+ /** One hook that completed during a restore. */
13
+ export interface CheckpointRestoreAuditEntry {
14
+ readonly hook: string;
15
+ readonly durationMs: number;
16
+ }
17
+ /** Audit of a completed restore: every hook that ran, in order. */
18
+ export interface CheckpointRestoreAudit {
19
+ readonly hooks: readonly CheckpointRestoreAuditEntry[];
20
+ readonly durationMs: number;
21
+ }
22
+ /**
23
+ * Host code restoring one external layer. `signal` aborts on the per-hook timeout and on the
24
+ * caller's abort, so a hook that talks to a remote system can cancel instead of dangling.
25
+ */
26
+ export type CheckpointRestoreHook<Context> = (checkpoint: Context, signal: AbortSignal) => void | Promise<void>;
27
+ /** Thrown when a restore hook fails or times out; the checkpoint and conversation are untouched. */
28
+ export declare class CheckpointRestoreError extends Error {
29
+ readonly code = "ERR_PRISM_CHECKPOINT_RESTORE";
30
+ /** Name of the failing hook (`fn.name` or `hook[i]`). */
31
+ readonly hook: string;
32
+ constructor(hook: string, cause: unknown);
33
+ }
34
+ export interface RunCheckpointRestoreHooksOptions {
35
+ /** Per-hook timeout; defaults to `DEFAULT_CHECKPOINT_RESTORE_TIMEOUT_MS`. */
36
+ readonly timeoutMs?: number;
37
+ /** Caller abort: checked between hooks and combined into each hook's signal. */
38
+ readonly signal?: AbortSignal;
39
+ }
40
+ /**
41
+ * Run restore hooks sequentially and report the audit. A hook failure throws
42
+ * `CheckpointRestoreError` immediately (later hooks do not run); an already-aborted caller signal
43
+ * throws its own abort reason so the resume reads as cancelled rather than as a restore failure.
44
+ */
45
+ export declare function runCheckpointRestoreHooks<Context>(hooks: readonly CheckpointRestoreHook<Context>[], context: Context, options?: RunCheckpointRestoreHooksOptions): Promise<CheckpointRestoreAudit>;