@arnilo/prism 0.7.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 (182) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +12 -11
  3. package/dist/agent-approval.d.ts +15 -2
  4. package/dist/agent-approval.js +5 -1
  5. package/dist/agent-event-source.d.ts +9 -1
  6. package/dist/agent-event-source.js +10 -3
  7. package/dist/agent-loops.js +7 -4
  8. package/dist/agent-run-lifecycle.d.ts +15 -1
  9. package/dist/agent-run-lifecycle.js +91 -10
  10. package/dist/agent-run-state.d.ts +34 -2
  11. package/dist/agent-run-state.js +68 -6
  12. package/dist/agent-session/helpers.js +20 -1
  13. package/dist/agent-session/session/assemble.js +250 -27
  14. package/dist/agent-session/session/persist.d.ts +27 -0
  15. package/dist/agent-session/session/persist.js +94 -12
  16. package/dist/agent-session/session/provider-round.d.ts +14 -4
  17. package/dist/agent-session/session/provider-round.js +197 -25
  18. package/dist/agent-session/session/tool-round.js +24 -2
  19. package/dist/agent-session/session/types.d.ts +36 -2
  20. package/dist/agent-session/session.d.ts +40 -4
  21. package/dist/agent-session/session.js +78 -5
  22. package/dist/attention-compiler.d.ts +51 -2
  23. package/dist/attention-compiler.js +282 -21
  24. package/dist/cache-helpers.d.ts +4 -2
  25. package/dist/cache-helpers.js +8 -6
  26. package/dist/checkpoint-restore.d.ts +45 -0
  27. package/dist/checkpoint-restore.js +54 -0
  28. package/dist/checkpoints.js +7 -11
  29. package/dist/context-budget.d.ts +2 -1
  30. package/dist/context-budget.js +24 -2
  31. package/dist/contracts-core/agent.d.ts +30 -0
  32. package/dist/contracts-core/attention.d.ts +95 -0
  33. package/dist/contracts-core/content.d.ts +15 -0
  34. package/dist/contracts-core/guardrail-packs.d.ts +41 -0
  35. package/dist/contracts-core/guardrail-packs.js +2 -0
  36. package/dist/contracts-core/loop.d.ts +42 -0
  37. package/dist/contracts-core/provider.d.ts +25 -0
  38. package/dist/contracts-core/run-limits.d.ts +21 -0
  39. package/dist/contracts-core/session.d.ts +23 -5
  40. package/dist/contracts-core/session.js +21 -2
  41. package/dist/contracts-core/usage.d.ts +40 -0
  42. package/dist/contracts-core/usage.js +8 -0
  43. package/dist/contracts-core.d.ts +2 -0
  44. package/dist/contracts-core.js +2 -0
  45. package/dist/contracts-protocol.d.ts +90 -4
  46. package/dist/contracts-run-state.d.ts +82 -6
  47. package/dist/evidence-grounding.d.ts +29 -0
  48. package/dist/evidence-grounding.js +162 -0
  49. package/dist/guardrail-packs/coding-standard.d.ts +3 -0
  50. package/dist/guardrail-packs/coding-standard.js +63 -0
  51. package/dist/guardrail-packs/destructive-commands.d.ts +3 -0
  52. package/dist/guardrail-packs/destructive-commands.js +46 -0
  53. package/dist/guardrail-packs/errors.d.ts +7 -0
  54. package/dist/guardrail-packs/errors.js +9 -0
  55. package/dist/guardrail-packs/index.d.ts +4 -0
  56. package/dist/guardrail-packs/index.js +15 -0
  57. package/dist/guardrail-packs/secrets-hygiene.d.ts +3 -0
  58. package/dist/guardrail-packs/secrets-hygiene.js +23 -0
  59. package/dist/guardrail-packs/types.d.ts +16 -0
  60. package/dist/guardrail-packs/types.js +2 -0
  61. package/dist/guardrail-packs/validation-respect.d.ts +3 -0
  62. package/dist/guardrail-packs/validation-respect.js +53 -0
  63. package/dist/guardrails.d.ts +20 -1
  64. package/dist/guardrails.js +268 -0
  65. package/dist/host-composition.d.ts +13 -0
  66. package/dist/host-composition.js +33 -2
  67. package/dist/index.d.ts +19 -10
  68. package/dist/index.js +11 -6
  69. package/dist/input.d.ts +8 -1
  70. package/dist/input.js +68 -6
  71. package/dist/middleware.d.ts +37 -2
  72. package/dist/middleware.js +41 -0
  73. package/dist/node/session-store-jsonl.js +18 -3
  74. package/dist/observability.js +6 -0
  75. package/dist/provider-events.d.ts +11 -3
  76. package/dist/provider-events.js +62 -4
  77. package/dist/providers/openai-compatible.js +6 -3
  78. package/dist/providers/transport.d.ts +3 -1
  79. package/dist/providers/transport.js +36 -0
  80. package/dist/redaction.js +18 -2
  81. package/dist/run-bundle.d.ts +89 -0
  82. package/dist/run-bundle.js +150 -0
  83. package/dist/run-limits.d.ts +11 -1
  84. package/dist/run-limits.js +46 -0
  85. package/dist/session-stores.d.ts +12 -1
  86. package/dist/session-stores.js +21 -4
  87. package/dist/testing/agent-event-source-conformance.js +41 -2
  88. package/dist/testing/prefix-stability-conformance.d.ts +30 -0
  89. package/dist/testing/prefix-stability-conformance.js +104 -0
  90. package/dist/testing/session-store-conformance.d.ts +3 -2
  91. package/dist/testing/session-store-conformance.js +48 -0
  92. package/dist/testing/state-concurrency-conformance.js +5 -12
  93. package/dist/tools.d.ts +5 -0
  94. package/dist/tools.js +11 -3
  95. package/dist/usage-estimation.d.ts +29 -0
  96. package/dist/usage-estimation.js +79 -0
  97. package/docs/ag-ui.md +5 -0
  98. package/docs/agent-events.md +68 -1
  99. package/docs/agent-loops.md +33 -0
  100. package/docs/agent-session-runtime.md +5 -3
  101. package/docs/attention-compiler.md +89 -8
  102. package/docs/coding-agent-tools.md +1 -1
  103. package/docs/coding-security.md +1 -0
  104. package/docs/coding-tools.md +0 -1
  105. package/docs/compaction-and-retry.md +1 -1
  106. package/docs/compaction-observational-memory.md +34 -7
  107. package/docs/connected-apps.md +116 -0
  108. package/docs/context-and-skills.md +13 -0
  109. package/docs/core.md +1 -1
  110. package/docs/diagrams.md +6 -6
  111. package/docs/document-reader.md +9 -9
  112. package/docs/documents.md +32 -11
  113. package/docs/durable-runs.md +129 -0
  114. package/docs/embeddings.md +5 -0
  115. package/docs/enterprise-postgres-state.md +4 -0
  116. package/docs/evaluations.md +5 -0
  117. package/docs/execution-timeline.md +84 -1
  118. package/docs/guardrails.md +71 -2
  119. package/docs/history/079-messaging-primitive-review.md +391 -0
  120. package/docs/history/080-messaging-followon-primitive-review.md +234 -0
  121. package/docs/history/081-connected-apps-primitive-review.md +74 -0
  122. package/docs/history/083-prism-work-primitive-review.md +84 -0
  123. package/docs/history/084-primitive-review.md +96 -0
  124. package/docs/history/085-honesty-and-cut-primitive-review.md +91 -0
  125. package/docs/history/README.md +5 -0
  126. package/docs/history/release-handoffs.md +38 -0
  127. package/docs/host-compositions.md +8 -6
  128. package/docs/host-security.md +2 -2
  129. package/docs/index.md +66 -29
  130. package/docs/input-and-prompt-assembly.md +3 -3
  131. package/docs/knowledge-sync.md +4 -0
  132. package/docs/live-testing.md +5 -3
  133. package/docs/mcp-tools.md +1 -0
  134. package/docs/messaging-channel-operations.md +166 -0
  135. package/docs/messaging-channels.md +150 -0
  136. package/docs/middleware-hooks.md +38 -2
  137. package/docs/migrate-to-0.8.md +124 -0
  138. package/docs/migrate-to-0.9.md +210 -0
  139. package/docs/migration.md +43 -0
  140. package/docs/model-registry.md +12 -2
  141. package/docs/multi-agent-patterns.md +25 -2
  142. package/docs/node-jsonl-session-store.md +7 -1
  143. package/docs/observability.md +7 -3
  144. package/docs/openapi-tools.md +1 -1
  145. package/docs/operations.md +1 -3
  146. package/docs/options-index.md +36 -3
  147. package/docs/peer-dependencies.md +6 -6
  148. package/docs/policy-and-audit.md +13 -1
  149. package/docs/postgres-persistence.md +1 -1
  150. package/docs/prefix-stability-conformance.md +93 -0
  151. package/docs/provider-caching.md +4 -4
  152. package/docs/provider-conformance.md +16 -0
  153. package/docs/provider-layer.md +2 -2
  154. package/docs/provider-packages.md +20 -20
  155. package/docs/providers/neuralwatt.md +5 -1
  156. package/docs/public-contracts.md +2 -2
  157. package/docs/rag.md +102 -4
  158. package/docs/release-and-install.md +55 -47
  159. package/docs/run-bundle.md +92 -0
  160. package/docs/runs-and-usage.md +57 -6
  161. package/docs/scoped-agent-memory.md +262 -0
  162. package/docs/server.md +2 -0
  163. package/docs/session-store-conformance.md +1 -2
  164. package/docs/session-stores.md +17 -17
  165. package/docs/sheets.md +9 -9
  166. package/docs/signal-channel.md +112 -0
  167. package/docs/speech.md +5 -1
  168. package/docs/sqlite-persistence.md +1 -1
  169. package/docs/supervisors.md +32 -12
  170. package/docs/telegram-channel.md +157 -0
  171. package/docs/testing.md +2 -2
  172. package/docs/tools.md +17 -0
  173. package/docs/wiki.md +1 -1
  174. package/docs/work-artifacts-and-review.md +1 -1
  175. package/docs/work-connectors.md +9 -9
  176. package/docs/work-sandbox.md +115 -0
  177. package/docs/work-tools.md +38 -16
  178. package/docs/workflows.md +5 -0
  179. package/package.json +9 -3
  180. package/templates/business-worker/manifest.json +2 -1
  181. package/templates/business-worker/src/agent.ts.tmpl +1 -1
  182. package/templates/business-worker/src/tests/agent.test.ts.tmpl +1 -1
@@ -1,12 +1,14 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { parseAttentionStickyFrontier, restoreAttentionFoldLedger, } from "./attention-compiler.js";
2
3
  import { AgentLoopStateError, AgentRunStateError } from "./contracts.js";
3
- import { parseAttentionStickyFrontier } from "./attention-compiler.js";
4
4
  import { validateLoadedSkillBodies } from "./skill-load.js";
5
5
  import { HARD_RUN_TOOL_NAMES } from "./tools.js";
6
6
  export const AGENT_RUN_STATE_NAMESPACE = "prism.agent-run";
7
7
  export const AGENT_RUN_STATE_SCHEMA_VERSION = 1;
8
8
  export const DEFAULT_MAX_AGENT_RUN_STATE_BYTES = 256 * 1024;
9
9
  export const HARD_MAX_AGENT_RUN_STATE_BYTES = 1024 * 1024;
10
+ /** Sidecar metadata ceiling per checkpoint record (not the run-state value). */
11
+ export const MAX_AGENT_RUN_METADATA_BYTES = 4 * 1024;
10
12
  const MAX_DEPTH = 32;
11
13
  const MAX_PROPERTIES = 256;
12
14
  /** Session-state caps (plan 015 Task 4): bounded names charged against the run-state byte budget. */
@@ -102,6 +104,9 @@ export function validateRunStateOptions(options) {
102
104
  if (!Number.isSafeInteger(bytes) || bytes < 1 || bytes > HARD_MAX_AGENT_RUN_STATE_BYTES) {
103
105
  throw new AgentRunStateError(`maxStateBytes must be a positive safe integer at most ${HARD_MAX_AGENT_RUN_STATE_BYTES}`);
104
106
  }
107
+ if (options.checkpointPolicy !== undefined && options.checkpointPolicy !== "decision" && options.checkpointPolicy !== "every-turn") {
108
+ throw new AgentRunStateError('checkpointPolicy must be "decision" or "every-turn"');
109
+ }
105
110
  }
106
111
  export async function loadAgentRunState(checkpoints, ref, ownership) {
107
112
  const record = await checkpoints.loadCheckpoint({ namespace: AGENT_RUN_STATE_NAMESPACE, key: ref.runId, ...ownership });
@@ -113,10 +118,55 @@ export async function loadAgentRunState(checkpoints, ref, ownership) {
113
118
  record.value.sessionId !== ref.sessionId) {
114
119
  throw new AgentRunStateError("Agent run session mismatch");
115
120
  }
116
- return { record, state: parseAgentRunState(record.value, record.version) };
121
+ const metadata = readCheckpointMetadata(record.metadata);
122
+ return { record, state: parseAgentRunState(record.value, record.version), ...(metadata ? { metadata } : {}) };
123
+ }
124
+ /** Resolve a host metadata source. A throwing provider fails the checkpoint write (fail closed). */
125
+ export function resolveCheckpointMetadata(source) {
126
+ return typeof source === "function" ? source() : source;
127
+ }
128
+ function checkpointMetadataBytes(metadata) {
129
+ return Buffer.byteLength(JSON.stringify(metadata), "utf8");
130
+ }
131
+ /**
132
+ * Redact + bound a sidecar metadata map for a checkpoint write. Values must be strings;
133
+ * redaction runs first so a replacement marker is still charged against the 4 KiB ceiling.
134
+ */
135
+ export function boundCheckpointMetadata(metadata, redactor) {
136
+ const redacted = redactor?.redact(metadata) ?? metadata;
137
+ if (!redacted || typeof redacted !== "object" || Array.isArray(redacted)) {
138
+ throw new AgentRunStateError("Checkpoint metadata must be an object");
139
+ }
140
+ const bounded = {};
141
+ for (const [key, value] of Object.entries(redacted)) {
142
+ if (typeof value !== "string")
143
+ throw new AgentRunStateError(`Checkpoint metadata value for ${key} must be a string`);
144
+ bounded[key] = value;
145
+ }
146
+ if (checkpointMetadataBytes(bounded) > MAX_AGENT_RUN_METADATA_BYTES) {
147
+ throw new AgentRunStateError(`Checkpoint metadata exceeds ${MAX_AGENT_RUN_METADATA_BYTES} bytes`);
148
+ }
149
+ return Object.freeze(bounded);
150
+ }
151
+ /**
152
+ * Read-side normalization (legacy tolerance): absent, oversize, or non-string entries are
153
+ * dropped, never thrown — a malformed sidecar must not block a resume.
154
+ */
155
+ export function readCheckpointMetadata(metadata) {
156
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata))
157
+ return undefined;
158
+ const bounded = {};
159
+ for (const [key, value] of Object.entries(metadata)) {
160
+ if (typeof value === "string")
161
+ bounded[key] = value;
162
+ }
163
+ if (Object.keys(bounded).length === 0 || checkpointMetadataBytes(bounded) > MAX_AGENT_RUN_METADATA_BYTES)
164
+ return undefined;
165
+ return Object.freeze(bounded);
117
166
  }
118
167
  export async function saveAgentRunState(input) {
119
168
  const bounded = boundState(input.redactor?.redact(input.state) ?? input.state, input.maxStateBytes ?? DEFAULT_MAX_AGENT_RUN_STATE_BYTES);
169
+ const metadata = input.metadata === undefined ? undefined : boundCheckpointMetadata(input.metadata, input.redactor);
120
170
  const record = await input.checkpoints.saveCheckpoint({
121
171
  namespace: AGENT_RUN_STATE_NAMESPACE,
122
172
  key: bounded.runId,
@@ -125,12 +175,13 @@ export async function saveAgentRunState(input) {
125
175
  fencingToken: input.fencingToken,
126
176
  value: bounded,
127
177
  category: "agent-run",
178
+ ...(metadata ? { metadata } : {}),
128
179
  ...input.ownership,
129
180
  });
130
181
  return { record, state: { ...bounded, version: record.version } };
131
182
  }
132
183
  export function publicState(state) {
133
- const { input: _input, pending: _pending, pendingCalls: _pendingCalls, nestedRuns: _nestedRuns, interruptBeforeTool: _interruptBeforeTool, counters: _counters, deadlineAt: _deadlineAt, toolNames: _toolNames, ...publicValue } = state;
184
+ const { input: _input, pending: _pending, pendingCalls: _pendingCalls, nestedRuns: _nestedRuns, interruptBeforeTool: _interruptBeforeTool, counters: _counters, deadlineAt: _deadlineAt, toolNames: _toolNames, checkpointPolicy: _checkpointPolicy, stopReason: _stopReason, ...publicValue } = state;
134
185
  return publicValue;
135
186
  }
136
187
  export function initialAgentRunState(input) {
@@ -152,6 +203,7 @@ export function initialAgentRunState(input) {
152
203
  interruptBeforeTool: input.interruptBeforeTool,
153
204
  counters: input.counters,
154
205
  deadlineAt: input.deadlineAt,
206
+ ...(input.options.checkpointPolicy === "every-turn" ? { checkpointPolicy: "every-turn" } : {}),
155
207
  };
156
208
  }
157
209
  export function parseAgentRunState(value, version) {
@@ -215,6 +267,12 @@ export function parseAgentRunState(value, version) {
215
267
  }
216
268
  }
217
269
  }
270
+ if (state.checkpointPolicy !== undefined && state.checkpointPolicy !== "every-turn") {
271
+ throw new AgentRunStateError("Malformed agent run checkpoint policy");
272
+ }
273
+ if (state.stopReason !== undefined && state.stopReason !== "host_policy") {
274
+ throw new AgentRunStateError("Malformed agent run stop reason");
275
+ }
218
276
  // Load bounds against the hard cap, not the default: the configured maxStateBytes is a
219
277
  // save-side policy knob, while the load-side bound is only a DoS ceiling. States saved
220
278
  // with a raised maxStateBytes must remain resumable.
@@ -284,12 +342,16 @@ function validateSessionState(sessionState) {
284
342
  }
285
343
  }
286
344
  const attention = sessionState.attentionSticky;
287
- if (attention === undefined)
288
- return;
289
345
  // Both arrays are capped by the parser, and a malformed frontier is dropped rather than
290
346
  // failing the resume: re-deciding a mutation is safe, refusing to resume is not.
291
- if (parseAttentionStickyFrontier(attention) === undefined) {
347
+ if (attention !== undefined && parseAttentionStickyFrontier(attention) === undefined) {
292
348
  throw new AgentRunStateError("Malformed agent run attention frontier");
293
349
  }
350
+ // Plan 086 T3: the fold ledger gets the same treatment — malformed entries are dropped by the
351
+ // parser, a malformed shape fails the load rather than the first provider turn.
352
+ const fold = sessionState.attentionFold;
353
+ if (fold !== undefined && restoreAttentionFoldLedger(fold) === undefined) {
354
+ throw new AgentRunStateError("Malformed agent run attention fold ledger");
355
+ }
294
356
  }
295
357
  //# sourceMappingURL=agent-run-state.js.map
@@ -53,7 +53,12 @@ export function finalAssistantMessage(history) {
53
53
  return { content: [], text: "" };
54
54
  }
55
55
  export function errorFromInfo(error) {
56
- return Object.assign(new Error(error.message), { name: error.name ?? "Error", cause: error.cause, code: error.code });
56
+ return Object.assign(new Error(error.message), {
57
+ name: error.name ?? "Error",
58
+ cause: error.cause,
59
+ code: error.code,
60
+ failureClass: error.failureClass,
61
+ });
57
62
  }
58
63
  export class ProviderTurnFailure extends Error {
59
64
  info;
@@ -155,6 +160,8 @@ export function createUsageAccumulator() {
155
160
  const sums = new Map();
156
161
  let costCurrency;
157
162
  let costCompatible = true;
163
+ let estimated = false;
164
+ let confidence;
158
165
  return {
159
166
  add(usage) {
160
167
  for (const key of ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"]) {
@@ -176,6 +183,13 @@ export function createUsageAccumulator() {
176
183
  if (costCompatible)
177
184
  sums.set("cost", (sums.get("cost") ?? 0) + usage.cost);
178
185
  }
186
+ // Run totals keep estimate provenance (plan 091 T2): one estimated turn
187
+ // labels the total, and the weakest confidence wins.
188
+ if (usage.estimated === true) {
189
+ estimated = true;
190
+ if (usage.confidence !== undefined && (confidence === undefined || usage.confidence === "low"))
191
+ confidence = usage.confidence;
192
+ }
179
193
  },
180
194
  value() {
181
195
  if (sums.size === 0)
@@ -187,6 +201,11 @@ export function createUsageAccumulator() {
187
201
  }
188
202
  if (costCompatible && sums.has("cost") && costCurrency !== undefined)
189
203
  usage.currency = costCurrency;
204
+ if (estimated) {
205
+ usage.estimated = true;
206
+ if (confidence !== undefined)
207
+ usage.confidence = confidence;
208
+ }
190
209
  return Object.keys(usage).length > 0 ? usage : undefined;
191
210
  },
192
211
  };
@@ -9,7 +9,7 @@ import { assertGuardrailsAllowed, runGuardrails } from "../../guardrails.js";
9
9
  import { identityTelemetryAttributes, ownershipFromIdentity, resolveRunIdentity } from "../../identity.js";
10
10
  import { assembleProviderInput } from "../../input.js";
11
11
  import { errorToErrorInfo, redactRunLedgerRecord } from "../../redaction.js";
12
- import { RunLimitError, RunLimitTracker, resolveRunLimits } from "../../run-limits.js";
12
+ import { describeBudgetExhaustion, RunLimitError, RunLimitTracker, resolveRunLimits } from "../../run-limits.js";
13
13
  import { createSessionEntry } from "../../session-stores.js";
14
14
  import { resolveSkillsDisclosure } from "../../skill-disclosure.js";
15
15
  import { applyRestoredSkillBodies } from "../../skill-load.js";
@@ -17,13 +17,117 @@ import { assertStructuredOutputRequestSupported, resolveRunProviderOptions } fro
17
17
  import { composeSystemPrompt, mergeSystemPromptConfig } from "../../system-prompts.js";
18
18
  import { resolveToolResultFold } from "../../tool-result-fold.js";
19
19
  import { createSearchToolsTool, createToolSearchState, resolveToolsDisclosure } from "../../tool-search.js";
20
- import { createToolRegistry, selectRunTools } from "../../tools.js";
20
+ import { clampTurnToolNames, createToolRegistry, selectRunTools } from "../../tools.js";
21
21
  import { bridgeAbort, createUsageAccumulator, inputToMessages, isDurableLoop, isSteerSoftInterrupt, mergeGuardrails, throwIfAborted, } from "../helpers.js";
22
- import { cleanupRun, persistDurable, persistSucceeded, suspendDurable } from "./persist.js";
23
- import { generateWithRetry, recordProviderUsage } from "./provider-round.js";
22
+ import { checkpointDurableFold, checkpointDurableTurn, cleanupRun, persistDurable, persistSucceeded, suspendDurable } from "./persist.js";
23
+ import { generateWithRetry, recordProviderUsage, resolveDeterministicTurn } from "./provider-round.js";
24
24
  import { bindChargeToolRound, bindDispatchToolCall, replayDurableNestedAndPending, runLoopUntilSettled, suspendGatedRound, } from "./tool-round.js";
25
25
  const PROMPT_VERSION_MAX_NAME_BYTES = 256;
26
26
  const PROMPT_VERSION_HASH_PATTERN = /^sha256:[0-9a-f]{64}$/;
27
+ /** Cap on the host stop detail that reaches the result, ledger, and timeline (plan 084 Task 2). */
28
+ const TURN_STOP_DETAIL_MAX_BYTES = 256;
29
+ function lastAssistantText(history) {
30
+ for (let i = history.length - 1; i >= 0; i -= 1) {
31
+ const message = history[i];
32
+ if (message?.role !== "assistant")
33
+ continue;
34
+ const text = message.content.map((block) => (block.type === "text" ? block.text : "")).join("");
35
+ if (text)
36
+ return text;
37
+ }
38
+ return undefined;
39
+ }
40
+ /**
41
+ * `RunOptions.turnPolicy` stopped the run at a turn boundary (plan 084 Task 2). Internal control
42
+ * signal: it unwinds any loop shape and `executeRun` turns it into a clean terminal success with
43
+ * `stopReason: "host_policy"` — never a run error.
44
+ */
45
+ class AgentRunStopped extends Error {
46
+ constructor() {
47
+ super("Agent run stopped by host turn policy");
48
+ this.name = "AgentRunStopped";
49
+ }
50
+ }
51
+ /** Host turn-policy misuse: a throwing or malformed callback fails the run closed. */
52
+ class TurnPolicyError extends Error {
53
+ code = "ERR_PRISM_TURN_POLICY";
54
+ constructor(message, options) {
55
+ super(message, options);
56
+ this.name = "TurnPolicyError";
57
+ }
58
+ }
59
+ /** Validate `RunOptions.turnPolicy` once, before any provider turn (plan 084 Task 2). */
60
+ function assertTurnPolicy(policy, resolvedLimits) {
61
+ if (policy === undefined)
62
+ return;
63
+ if (typeof policy !== "object" || policy === null)
64
+ throw new TypeError("RunOptions.turnPolicy must be an object");
65
+ if (policy.stop !== undefined && typeof policy.stop !== "function") {
66
+ throw new TypeError("RunOptions.turnPolicy.stop must be a function");
67
+ }
68
+ const maxTurns = policy.maxTurns;
69
+ if (maxTurns === undefined)
70
+ return;
71
+ if (!Number.isSafeInteger(maxTurns) || maxTurns < 1) {
72
+ throw new TypeError("RunOptions.turnPolicy.maxTurns must be a positive safe integer");
73
+ }
74
+ // Same narrowing law as `limits`: a run overlay may tighten the agent's cap, never widen it.
75
+ const configured = resolvedLimits.maxTurns;
76
+ if (configured !== null && maxTurns > configured) {
77
+ throw new TypeError(`RunOptions.turnPolicy.maxTurns (${maxTurns}) cannot widen limits.maxTurns (${configured})`);
78
+ }
79
+ }
80
+ /** Redact and bound a host stop reason; anything unusable fails the run closed. */
81
+ function boundedStopDetail(session, reason) {
82
+ if (typeof reason !== "string" || reason.length === 0) {
83
+ throw new TurnPolicyError("RunOptions.turnPolicy.stop must return a non-empty reason string");
84
+ }
85
+ const redacted = session.redact(reason);
86
+ if (Buffer.byteLength(redacted, "utf8") > TURN_STOP_DETAIL_MAX_BYTES) {
87
+ throw new TurnPolicyError(`RunOptions.turnPolicy.stop reason must be at most ${TURN_STOP_DETAIL_MAX_BYTES} UTF-8 bytes`);
88
+ }
89
+ return redacted;
90
+ }
91
+ /**
92
+ * Evaluate the host turn policy at the current provider-turn boundary (plan 084 Task 2). Returns
93
+ * the stop to record, or `undefined` to run the turn. Omitted policy → nothing is read or called.
94
+ */
95
+ function evaluateTurnStop(ctx) {
96
+ const policy = ctx.options.turnPolicy;
97
+ if (!policy)
98
+ return undefined;
99
+ const turn = Math.max(1, ctx.session.activeLoopTurn);
100
+ const turns = turn - 1;
101
+ if (policy.maxTurns !== undefined && turns >= policy.maxTurns)
102
+ return { reason: "turn_limit", detail: "maxTurns" };
103
+ if (!policy.stop)
104
+ return undefined;
105
+ const context = {
106
+ sessionId: ctx.session.id,
107
+ runId: ctx.runId,
108
+ turn,
109
+ turns,
110
+ toolCalls: ctx.toolCalls,
111
+ ...(ctx.runUsage.value() ? { usage: ctx.runUsage.value() } : {}),
112
+ metadata: ctx.metadata,
113
+ };
114
+ let decision;
115
+ try {
116
+ decision = policy.stop(context);
117
+ }
118
+ catch (error) {
119
+ throw new TurnPolicyError("RunOptions.turnPolicy.stop threw", { cause: error });
120
+ }
121
+ if (decision === null || typeof decision !== "object" || typeof decision.then === "function") {
122
+ throw new TurnPolicyError("RunOptions.turnPolicy.stop must synchronously return a TurnStopDecision");
123
+ }
124
+ const action = decision.action;
125
+ if (action === "continue")
126
+ return undefined;
127
+ if (action !== "stop")
128
+ throw new TurnPolicyError('RunOptions.turnPolicy.stop decision action must be "continue" or "stop"');
129
+ return { reason: "host_policy", detail: boundedStopDetail(ctx.session, decision.reason) };
130
+ }
27
131
  function assertPromptVersionRef(ref) {
28
132
  if (ref === undefined)
29
133
  return undefined;
@@ -47,7 +151,13 @@ async function assembleRoundContext(params) {
47
151
  throwIfAborted(controller.signal);
48
152
  session.emit({ type: "agent_started", sessionId: session.id, runId });
49
153
  if (resumed)
50
- session.emit({ type: "agent_resumed", sessionId: session.id, runId, version: resumed.version });
154
+ session.emit({
155
+ type: "agent_resumed",
156
+ sessionId: session.id,
157
+ runId,
158
+ version: resumed.version,
159
+ ...(resumed.restore ? { restore: resumed.restore } : {}),
160
+ });
51
161
  const startRecord = {
52
162
  id: runId,
53
163
  sessionId: session.id,
@@ -83,6 +193,7 @@ async function assembleRoundContext(params) {
83
193
  const tools = searchTool ? [...activeToolList, searchTool] : activeToolList;
84
194
  const activeSkills = session.resolveRunSkills(options, tools);
85
195
  session.activeRunSkills = activeSkills;
196
+ session.tailSegments.clear();
86
197
  if (options.model && JSON.stringify(options.model) !== JSON.stringify(session.agent.config.model)) {
87
198
  await session.appendEntry(createSessionEntry({
88
199
  sessionId: session.id,
@@ -122,24 +233,41 @@ async function assembleRoundContext(params) {
122
233
  assertStructuredOutputRequestSupported(options.model ?? session.agent.config.model, providerOptions);
123
234
  const validate = options.validate ?? session.agent.config.validator;
124
235
  // Resolved once per run, before any provider turn: a bad setting or a widening run overlay
125
- // fails here rather than on the turn that happens to cross the ratio (plan 074 C12).
126
- const attentionCompiler = resolveRunAttentionCompiler(session.agent.config.attentionCompiler, options.attentionCompiler, options.model ?? session.agent.config.model);
236
+ // fails here rather than on the turn that happens to cross the ratio (plan 074 C12). The
237
+ // resolved run input budget rides the handle so `run_input_ratio` folds against the same cap
238
+ // the run limit enforces (plan 086 T2); `null` (disabled) leaves that axis on the input cap.
239
+ const attentionCompiler = resolveRunAttentionCompiler(session.agent.config.attentionCompiler, options.attentionCompiler, options.model ?? session.agent.config.model, limits.limits.maxInputTokens);
240
+ // Plan 086 T3: durable folding writes the fold ledger to the run checkpoint, so it needs a
241
+ // durable run (the session's durable state is set before this call). Fail at run start, before
242
+ // any provider turn, rather than folding into memory only. The fold state rides that
243
+ // checkpoint independently of `persistSessionState`.
244
+ if (attentionCompiler?.durable && !session.activeDurable) {
245
+ throw new AgentRunStateError("attentionCompiler.durable requires a durable run: set AgentConfig or RunOptions runState with a checkpoint store");
246
+ }
247
+ session.attentionDurable = attentionCompiler?.durable === true;
127
248
  // Telemetry seam (plan 074 T6): one `attention_compiled` per mutated turn, counts and the
128
249
  // measured ratio inputs only. Under-ratio turns and compiler-off runs emit nothing.
250
+ // Plan 086 T3: a turn that folded new bodies is the fold-boundary durability signal, so it is
251
+ // remembered here (the callback is synchronous) and checkpointed by the assembler below.
252
+ let foldCheckpointPending = false;
129
253
  const onAttentionReport = attentionCompiler
130
- ? (report) => session.emit({
131
- type: "attention_compiled",
132
- sessionId: session.id,
133
- runId,
134
- used: report.used,
135
- usedAfter: report.usedAfter,
136
- inputCap: report.inputCap,
137
- triggerRatio: report.triggerRatio,
138
- droppedThinkingTurns: report.droppedThinkingTurns,
139
- stubbedToolResults: report.stubbedToolResults,
140
- stubbedBytes: report.stubbedBytes,
141
- truncated: report.truncated,
142
- })
254
+ ? (report) => {
255
+ if (report.newFoldedBodies > 0)
256
+ foldCheckpointPending = true;
257
+ session.emit({
258
+ type: "attention_compiled",
259
+ sessionId: session.id,
260
+ runId,
261
+ used: report.used,
262
+ usedAfter: report.usedAfter,
263
+ inputCap: report.inputCap,
264
+ triggerRatio: report.triggerRatio,
265
+ droppedThinkingTurns: report.droppedThinkingTurns,
266
+ stubbedToolResults: report.stubbedToolResults,
267
+ stubbedBytes: report.stubbedBytes,
268
+ truncated: report.truncated,
269
+ });
270
+ }
143
271
  : undefined;
144
272
  const instructionInjectors = options.instructionInjectors ?? session.agent.config.instructionInjectors ?? [];
145
273
  const inputLayout = options.inputLayout ?? session.agent.config.inputLayout;
@@ -174,9 +302,13 @@ async function assembleRoundContext(params) {
174
302
  assembledTurn: false,
175
303
  artifactFinished: false,
176
304
  artifactFailedInfo: undefined,
305
+ toolCalls: 0,
306
+ toolResults: [],
177
307
  runUsage,
178
308
  loopCtx: undefined,
179
309
  };
310
+ const toolNarrowing = options.toolNarrowing ?? session.agent.config.toolNarrowing;
311
+ let narrowedForTurn;
180
312
  const loopCtx = {
181
313
  sessionId: session.id,
182
314
  runId,
@@ -190,6 +322,38 @@ async function assembleRoundContext(params) {
190
322
  restoredLoopState: resumed?.state?.loopState?.snapshot,
191
323
  assemble: async (nextInput, toolResults, turn) => {
192
324
  limits.charge("maxTurns");
325
+ const turnIndex = turn ?? 1;
326
+ let turnTools = tools;
327
+ if (toolNarrowing) {
328
+ if (typeof toolNarrowing !== "function")
329
+ throw new TypeError("toolNarrowing must be a function");
330
+ if (narrowedForTurn?.turn === turnIndex) {
331
+ turnTools = narrowedForTurn.tools;
332
+ }
333
+ else {
334
+ const assistant = lastAssistantText(session.history);
335
+ const requested = await toolNarrowing({
336
+ turn: turnIndex,
337
+ toolIds: tools.map((tool) => tool.name),
338
+ ...(assistant !== undefined ? { lastAssistantText: assistant } : {}),
339
+ });
340
+ if (!Array.isArray(requested))
341
+ throw new TypeError("toolNarrowing must return a string array");
342
+ const clamped = clampTurnToolNames(tools, requested);
343
+ if (clamped.dropped.length > 0) {
344
+ session.emit({
345
+ type: "tool_narrowing_clamped",
346
+ sessionId: session.id,
347
+ runId,
348
+ turn: turnIndex,
349
+ dropped: clamped.dropped,
350
+ });
351
+ }
352
+ turnTools = clamped.tools;
353
+ narrowedForTurn = { turn: turnIndex, tools: turnTools };
354
+ }
355
+ ctx.turnAllow = turnTools.map((tool) => tool.name);
356
+ }
193
357
  const request = await assembleProviderInput({
194
358
  model: options.model ?? session.agent.config.model,
195
359
  input: nextInput,
@@ -213,9 +377,16 @@ async function assembleRoundContext(params) {
213
377
  // Session-owned: a stub made earlier stays applied even on a later under-ratio turn, so
214
378
  // the prompt-cache prefix is not rewritten (C10). Undefined when the compiler is off.
215
379
  attentionSticky: attentionCompiler ? session.attentionStickyFor() : undefined,
380
+ // Folded bodies (plan 086 T3): a row summarized once is re-applied, never re-summarized,
381
+ // so sticky rows stay byte-identical and a resumed run reuses the persisted bodies.
382
+ attentionFold: attentionCompiler ? session.attentionFoldFor() : undefined,
383
+ // Charge-so-far for the `run_input_ratio` axis: the counter only holds completed turns,
384
+ // so the axis projects this turn's estimate onto it.
385
+ runInputTokens: limits.snapshot().inputTokens,
216
386
  onAttentionReport,
217
387
  loadedSkills: session.loadedSkills,
218
- tools,
388
+ tailSegments: session.tailSegments,
389
+ tools: turnTools,
219
390
  resourceLoader: session.agent.config.resourceLoader,
220
391
  permission: session.agent.config.permission,
221
392
  trust: session.agent.config.trust,
@@ -228,6 +399,13 @@ async function assembleRoundContext(params) {
228
399
  signal: controller.signal,
229
400
  });
230
401
  ctx.assembledTurn = true;
402
+ if (foldCheckpointPending) {
403
+ foldCheckpointPending = false;
404
+ // Fold-boundary durability (plan 086 T3): one write per turn that added folded bodies,
405
+ // after the request is assembled and before the provider sees it, so a crash during this
406
+ // turn resumes with the same ledger. No-op unless the compiler is durable.
407
+ await checkpointDurableFold(session, { runId, model, limits });
408
+ }
231
409
  return request;
232
410
  },
233
411
  chargeToolRound: bindChargeToolRound(ctx),
@@ -236,10 +414,29 @@ async function assembleRoundContext(params) {
236
414
  if (!ctx.assembledTurn)
237
415
  limits.charge("maxTurns");
238
416
  ctx.assembledTurn = false;
417
+ // Host turn policy (plan 084 Task 2): evaluated at the same turn boundary as the
418
+ // crash-recovery checkpoint below, before any provider work. Throwing unwinds any loop
419
+ // shape; `executeRun` converts it into a clean terminal success with `stopReason`.
420
+ const stop = evaluateTurnStop(ctx);
421
+ if (stop) {
422
+ ctx.runStop = stop;
423
+ ctx.loopCtx.finishReason = stop.reason;
424
+ throw new AgentRunStopped();
425
+ }
426
+ // Crash-recovery boundary (plan 084 Task 1): after the previous turn's tool results are in
427
+ // the store and before this provider request. No-op unless `checkpointPolicy: "every-turn"`.
428
+ if (session.activeDurable?.options.checkpointPolicy === "every-turn") {
429
+ await checkpointDurableTurn(session, { runId, model, limits });
430
+ }
431
+ // Deterministic no-model turn (plan 096): host middleware answers at the provider boundary,
432
+ // before any provider-round work. No answer → provider path unchanged.
433
+ const deterministic = await resolveDeterministicTurn(session, request, runId, session.activeLoopTurn, controller.signal, ctx.toolResults);
434
+ if (deterministic)
435
+ return deterministic;
239
436
  const policyResult = await session.applyProviderRequestPolicies(request, runId, options, metadata, controller.signal);
240
437
  const middlewareRequest = (await session.agent.config.middleware?.run("provider_request", policyResult.request)) ?? policyResult.request;
241
438
  try {
242
- return await generateWithRetry(session, session.redactProviderRequest(middlewareRequest), runId, options, controller.signal, policyResult.secrets, session.activeLoopTurn, (turnUsage, turn, attempt) => recordProviderUsage(ctx, turnUsage, turn, attempt));
439
+ return await generateWithRetry(session, session.redactProviderRequest(middlewareRequest), runId, options, controller.signal, policyResult.secrets, session.activeLoopTurn, (turnUsage, turn, attempt) => recordProviderUsage(ctx, turnUsage, turn, attempt, middlewareRequest), ctx.toolResults);
243
440
  }
244
441
  catch (error) {
245
442
  if (isSteerSoftInterrupt(error)) {
@@ -288,6 +485,7 @@ export async function executeRun(session, input, options, runId, resumed) {
288
485
  }
289
486
  const requestedLimits = options.limits;
290
487
  const resolvedLimits = resolveRunLimits(session.agent.config.limits, requestedLimits);
488
+ assertTurnPolicy(options.turnPolicy, resolvedLimits);
291
489
  const durableOptions = options.runState ?? session.agent.config.runState;
292
490
  if (session.agent.config.runState && options.runState && session.agent.config.runState !== options.runState) {
293
491
  throw new AgentRunStateError("RunOptions cannot replace agent durable run-state configuration");
@@ -321,8 +519,11 @@ export async function executeRun(session, input, options, runId, resumed) {
321
519
  if (session.activeIdentity && !session.activeOwnership)
322
520
  session.activeOwnership = ownershipFromIdentity(session.activeIdentity);
323
521
  session.activeIdempotencyKey = options.idempotencyKey ?? session.agent.config.idempotencyKey;
324
- session.activeGuardrails = mergeGuardrails(session.agent.config.guardrails, options.guardrails);
522
+ session.activeGuardrails = mergeGuardrails(mergeGuardrails(session.agent.config.guardrails, session.packGuardrails), options.guardrails);
325
523
  session.activeDurable = resumed ?? (durableOptions ? { options: durableOptions, version: 0 } : undefined);
524
+ // Plan 086 T3: reset here, so a suspension before the compiler is resolved (input guardrail)
525
+ // cannot inherit the previous run's durable-folding flag. `assembleRoundContext` sets it true.
526
+ session.attentionDurable = false;
326
527
  session.activeGatedRound = undefined;
327
528
  if (resumed)
328
529
  session.invalidateSnapshot();
@@ -330,6 +531,9 @@ export async function executeRun(session, input, options, runId, resumed) {
330
531
  const startedAt = new Date().toISOString();
331
532
  let runError;
332
533
  let runStatus = "succeeded";
534
+ // Set only on the clean-success path; the finish ledger record carries them (plan 084 Task 2).
535
+ let stopReason;
536
+ let stopDetail;
333
537
  const runUsage = createUsageAccumulator();
334
538
  let usage;
335
539
  const metadata = {
@@ -349,6 +553,7 @@ export async function executeRun(session, input, options, runId, resumed) {
349
553
  deadlineAt: resumed?.state?.deadlineAt,
350
554
  });
351
555
  session.activeLimits = limits;
556
+ session.activeRecentToolCalls = [];
352
557
  const hasFiniteTokenCap = (value) => typeof value === "number" && Number.isFinite(value);
353
558
  session.activeLimitOutputBuffer = [session.agent.config.limits, requestedLimits].some((value) => hasFiniteTokenCap(value?.maxOutputTokens) || hasFiniteTokenCap(value?.maxTotalTokens) || value?.maxCost !== undefined);
354
559
  try {
@@ -374,8 +579,16 @@ export async function executeRun(session, input, options, runId, resumed) {
374
579
  }
375
580
  ctx.loop.restore?.(resumedLoopState.snapshot);
376
581
  }
377
- const loopUsage = await runLoopUntilSettled(ctx);
378
- if (ctx.loop.name === "generate-validate-revise" && !ctx.artifactFinished) {
582
+ const loopUsage = await runLoopUntilSettled(ctx).catch((error) => {
583
+ // Host turn-policy stop (plan 084 Task 2): the loop was unwound on purpose at a turn
584
+ // boundary. Not an error — the run settles cleanly and stays resumable.
585
+ if (error instanceof AgentRunStopped)
586
+ return undefined;
587
+ throw error;
588
+ });
589
+ stopReason = ctx.runStop?.reason ?? ctx.loopCtx.finishReason;
590
+ stopDetail = ctx.runStop?.detail;
591
+ if (!ctx.runStop && ctx.loop.name === "generate-validate-revise" && !ctx.artifactFinished) {
379
592
  throw Object.assign(new Error(ctx.artifactFailedInfo?.message ?? "artifact loop ended without a validated artifact"), {
380
593
  name: "ArtifactFailed",
381
594
  code: ctx.artifactFailedInfo?.code ?? "artifact_failed",
@@ -392,8 +605,18 @@ export async function executeRun(session, input, options, runId, resumed) {
392
605
  return session.buildRunResult({ runId, status: "suspended", runState: error.state, interruption: error.interruption });
393
606
  }
394
607
  runError = errorToErrorInfo(error);
395
- session.emit({ type: "error", sessionId: session.id, runId, error: runError });
396
608
  const breach = error instanceof RunLimitError ? error.breach : limits.breach;
609
+ // Terminal attribution before the terminal `error`/finish records, so a subscriber that stops
610
+ // at the first terminal event still sees why the run died (plan 087 T2).
611
+ if (breach) {
612
+ session.emit({
613
+ type: "budget_exhausted",
614
+ sessionId: session.id,
615
+ runId,
616
+ ...describeBudgetExhaustion(limits, breach, session.activeRecentToolCalls ?? []),
617
+ });
618
+ }
619
+ session.emit({ type: "error", sessionId: session.id, runId, error: runError });
397
620
  runStatus = breach ? "failed" : controller.signal.aborted ? "aborted" : "failed";
398
621
  const runState = session.activeDurable?.state
399
622
  ? await persistDurable(session, {
@@ -418,7 +641,7 @@ export async function executeRun(session, input, options, runId, resumed) {
418
641
  throw new AgentRunError(result, { cause: error });
419
642
  }
420
643
  finally {
421
- await cleanupRun({ session, controller, cleanupSignal, runId, model, startedAt, runStatus, runError });
644
+ await cleanupRun({ session, controller, cleanupSignal, runId, model, startedAt, runStatus, runError, stopReason, stopDetail });
422
645
  }
423
646
  }
424
647
  //# sourceMappingURL=assemble.js.map
@@ -15,6 +15,30 @@ export declare function suspendDurable(session: SessionHost, input: {
15
15
  /** Full replacement when provided; otherwise the recorded nested runs are preserved. */
16
16
  readonly nestedRuns?: readonly NestedRunRef[];
17
17
  }): Promise<AgentRunState>;
18
+ /**
19
+ * Turn-boundary crash-recovery checkpoint (plan 084 Task 1). Called before each provider
20
+ * request when `checkpointPolicy: "every-turn"`; a no-op otherwise, so default-policy runs keep
21
+ * the 0.8.x checkpoint shape and write count unchanged. Pending-decision markers are dropped:
22
+ * at a turn boundary every gated call has been resolved or the run already suspended, and a
23
+ * stale marker must never replay. The recorded `checkpointPolicy` makes the cadence survive
24
+ * into a resumed run, and loop-local state rides along exactly as it does at suspension.
25
+ */
26
+ export declare function checkpointDurableTurn(session: SessionHost, input: {
27
+ readonly runId: string;
28
+ readonly model: ModelConfig;
29
+ readonly limits: RunLimitTracker;
30
+ }): Promise<void>;
31
+ /**
32
+ * Fold-boundary checkpoint (plan 086 T3). Called once per turn that added folded bodies — never
33
+ * per turn — when the resolved compiler is durable, so a crash after a fold resumes with the
34
+ * ledger and frontier already on disk. Independent of `checkpointPolicy`: the fold is the
35
+ * durability point that matters for a long single run, not the turn boundary.
36
+ */
37
+ export declare function checkpointDurableFold(session: SessionHost, input: {
38
+ readonly runId: string;
39
+ readonly model: ModelConfig;
40
+ readonly limits: RunLimitTracker;
41
+ }): Promise<void>;
18
42
  export declare function persistSucceeded(ctx: RoundContext, loopUsage: Usage | undefined): Promise<AgentRunResult>;
19
43
  export declare function cleanupRun(input: {
20
44
  session: SessionHost;
@@ -25,4 +49,7 @@ export declare function cleanupRun(input: {
25
49
  startedAt: string;
26
50
  runStatus: AgentRunResult["status"];
27
51
  runError: ErrorInfo | undefined;
52
+ /** Clean stop taxonomy for the finish record; only written for a succeeded run (plan 084 Task 2). */
53
+ stopReason?: import("../../contracts.js").AgentFinishReason;
54
+ stopDetail?: string;
28
55
  }): Promise<void>;