@arnilo/prism 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/CHANGELOG.md +62 -1
  2. package/README.md +13 -12
  3. package/dist/agent-approval.d.ts +17 -2
  4. package/dist/agent-approval.js +15 -6
  5. package/dist/agent-event-source.d.ts +9 -1
  6. package/dist/agent-event-source.js +10 -3
  7. package/dist/agent-loops.js +7 -4
  8. package/dist/agent-run-lifecycle.d.ts +15 -1
  9. package/dist/agent-run-lifecycle.js +82 -11
  10. package/dist/agent-run-state.d.ts +47 -6
  11. package/dist/agent-run-state.js +154 -6
  12. package/dist/agent-session/event-subscriber.d.ts +2 -0
  13. package/dist/agent-session/event-subscriber.js +3 -0
  14. package/dist/agent-session/helpers.js +14 -0
  15. package/dist/agent-session/session/assemble.js +281 -32
  16. package/dist/agent-session/session/persist.d.ts +11 -0
  17. package/dist/agent-session/session/persist.js +48 -16
  18. package/dist/agent-session/session/provider-round.d.ts +14 -4
  19. package/dist/agent-session/session/provider-round.js +226 -19
  20. package/dist/agent-session/session/tool-round.d.ts +2 -2
  21. package/dist/agent-session/session/tool-round.js +78 -6
  22. package/dist/agent-session/session/types.d.ts +44 -3
  23. package/dist/agent-session/session.d.ts +100 -5
  24. package/dist/agent-session/session.js +224 -13
  25. package/dist/attention-compiler.d.ts +51 -2
  26. package/dist/attention-compiler.js +282 -21
  27. package/dist/cache-helpers.d.ts +4 -2
  28. package/dist/cache-helpers.js +8 -6
  29. package/dist/checkpoint-restore.d.ts +45 -0
  30. package/dist/checkpoint-restore.js +54 -0
  31. package/dist/context-budget.d.ts +13 -1
  32. package/dist/context-budget.js +57 -4
  33. package/dist/contracts-core/agent.d.ts +52 -1
  34. package/dist/contracts-core/attention.d.ts +95 -0
  35. package/dist/contracts-core/content.d.ts +10 -0
  36. package/dist/contracts-core/extensions.d.ts +3 -0
  37. package/dist/contracts-core/guardrail-packs.d.ts +46 -0
  38. package/dist/contracts-core/guardrail-packs.js +2 -0
  39. package/dist/contracts-core/loop.d.ts +36 -0
  40. package/dist/contracts-core/provider.d.ts +30 -0
  41. package/dist/contracts-core/run-limits.d.ts +29 -1
  42. package/dist/contracts-core/session.d.ts +23 -5
  43. package/dist/contracts-core/session.js +21 -2
  44. package/dist/contracts-core/usage.d.ts +40 -0
  45. package/dist/contracts-core/usage.js +8 -0
  46. package/dist/contracts-core.d.ts +2 -0
  47. package/dist/contracts-core.js +2 -0
  48. package/dist/contracts-protocol.d.ts +81 -5
  49. package/dist/contracts-run-state.d.ts +91 -2
  50. package/dist/contributions.d.ts +2 -1
  51. package/dist/contributions.js +1 -0
  52. package/dist/extensions.d.ts +15 -1
  53. package/dist/extensions.js +68 -0
  54. package/dist/guardrail-packs/coding-standard.d.ts +3 -0
  55. package/dist/guardrail-packs/coding-standard.js +63 -0
  56. package/dist/guardrail-packs/destructive-commands.d.ts +3 -0
  57. package/dist/guardrail-packs/destructive-commands.js +46 -0
  58. package/dist/guardrail-packs/errors.d.ts +7 -0
  59. package/dist/guardrail-packs/errors.js +9 -0
  60. package/dist/guardrail-packs/index.d.ts +4 -0
  61. package/dist/guardrail-packs/index.js +15 -0
  62. package/dist/guardrail-packs/secrets-hygiene.d.ts +3 -0
  63. package/dist/guardrail-packs/secrets-hygiene.js +23 -0
  64. package/dist/guardrail-packs/types.d.ts +26 -0
  65. package/dist/guardrail-packs/types.js +2 -0
  66. package/dist/guardrail-packs/validation-respect.d.ts +3 -0
  67. package/dist/guardrail-packs/validation-respect.js +69 -0
  68. package/dist/guardrails.d.ts +61 -1
  69. package/dist/guardrails.js +377 -0
  70. package/dist/index.d.ts +16 -11
  71. package/dist/index.js +10 -7
  72. package/dist/input.d.ts +8 -1
  73. package/dist/input.js +68 -6
  74. package/dist/middleware.d.ts +37 -2
  75. package/dist/middleware.js +41 -0
  76. package/dist/node/session-store-jsonl.js +18 -3
  77. package/dist/observability.js +6 -0
  78. package/dist/provider-events.d.ts +8 -2
  79. package/dist/provider-events.js +60 -2
  80. package/dist/providers/openai-compatible.js +6 -3
  81. package/dist/run-bundle.d.ts +6 -1
  82. package/dist/run-bundle.js +5 -1
  83. package/dist/run-limits.d.ts +11 -1
  84. package/dist/run-limits.js +59 -0
  85. package/dist/session-stores.d.ts +12 -1
  86. package/dist/session-stores.js +21 -4
  87. package/dist/testing/agent-event-source-conformance.js +41 -2
  88. package/dist/testing/prefix-stability-conformance.d.ts +59 -0
  89. package/dist/testing/prefix-stability-conformance.js +172 -0
  90. package/dist/testing/session-store-conformance.d.ts +3 -2
  91. package/dist/testing/session-store-conformance.js +48 -0
  92. package/dist/tools.d.ts +5 -0
  93. package/dist/tools.js +21 -6
  94. package/dist/usage-estimation.d.ts +29 -0
  95. package/dist/usage-estimation.js +79 -0
  96. package/docs/agent-events.md +75 -4
  97. package/docs/agent-session-runtime.md +10 -6
  98. package/docs/attention-compiler.md +89 -8
  99. package/docs/caveman.md +1 -1
  100. package/docs/coding-agent-tools.md +1 -1
  101. package/docs/compaction-and-retry.md +1 -1
  102. package/docs/compaction-llm.md +2 -0
  103. package/docs/compaction-observational-memory.md +54 -7
  104. package/docs/durable-runs.md +46 -3
  105. package/docs/embeddings.md +9 -0
  106. package/docs/evaluations.md +5 -0
  107. package/docs/execution-timeline.md +79 -1
  108. package/docs/extensions.md +20 -3
  109. package/docs/guardrails.md +50 -4
  110. package/docs/hooks.md +282 -0
  111. package/docs/index.md +37 -15
  112. package/docs/input-and-prompt-assembly.md +4 -4
  113. package/docs/instruction-injection.md +1 -0
  114. package/docs/knowledge-sync.md +4 -0
  115. package/docs/live-testing.md +3 -1
  116. package/docs/memory-fabric.md +28 -0
  117. package/docs/middleware-hooks.md +90 -4
  118. package/docs/migrate-to-0.9.md +210 -0
  119. package/docs/migration.md +26 -0
  120. package/docs/multi-agent-patterns.md +25 -2
  121. package/docs/node-jsonl-session-store.md +7 -1
  122. package/docs/observability.md +7 -3
  123. package/docs/options-index.md +4 -1
  124. package/docs/policy-and-audit.md +26 -1
  125. package/docs/prefix-stability-conformance.md +143 -0
  126. package/docs/provider-caching.md +4 -4
  127. package/docs/provider-conformance.md +16 -0
  128. package/docs/provider-packages.md +20 -20
  129. package/docs/public-contracts.md +3 -2
  130. package/docs/rag.md +188 -3
  131. package/docs/release-and-install.md +45 -40
  132. package/docs/runs-and-usage.md +56 -10
  133. package/docs/scoped-agent-memory.md +270 -0
  134. package/docs/scoped-memory.md +138 -0
  135. package/docs/session-store-conformance.md +1 -2
  136. package/docs/session-stores.md +17 -17
  137. package/docs/supervisors.md +32 -12
  138. package/docs/tools.md +18 -1
  139. package/docs/wiki.md +4 -2
  140. package/docs/workflows.md +5 -0
  141. package/package.json +8 -2
@@ -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,15 +17,26 @@ 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 { checkpointDurableTurn, 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
27
  /** Cap on the host stop detail that reaches the result, ledger, and timeline (plan 084 Task 2). */
28
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
+ }
29
40
  /**
30
41
  * `RunOptions.turnPolicy` stopped the run at a turn boundary (plan 084 Task 2). Internal control
31
42
  * signal: it unwinds any loop shape and `executeRun` turns it into a clean terminal success with
@@ -45,6 +56,104 @@ class TurnPolicyError extends Error {
45
56
  this.name = "TurnPolicyError";
46
57
  }
47
58
  }
59
+ /** Stop-hook misuse: a throwing or malformed hook fails the run closed (plan 106 R1). */
60
+ class StopHookError extends Error {
61
+ code = "ERR_PRISM_STOP_HOOK";
62
+ constructor(message, options) {
63
+ super(message, options);
64
+ this.name = "StopHookError";
65
+ }
66
+ }
67
+ /** Validate the merged stop-hook list once per run, before any provider turn (plan 106 R1). */
68
+ function assertStopHooks(hooks) {
69
+ for (const hook of hooks) {
70
+ if (typeof hook !== "object" ||
71
+ hook === null ||
72
+ typeof hook.name !== "string" ||
73
+ hook.name.length === 0 ||
74
+ typeof hook.decide !== "function") {
75
+ throw new TypeError("stopHooks entries must be StopHook objects with a non-empty name and a decide function");
76
+ }
77
+ }
78
+ }
79
+ /**
80
+ * Run stop hooks in order at a natural loop end (plan 106 R1). The first `continue` wins; every
81
+ * `stop` (or no hook continuing) leaves the run finished. Hook context is metadata plus the live
82
+ * transcript — tool arguments, prompts, and results are never reshaped by core.
83
+ */
84
+ async function evaluateStopHooks(ctx, stopHookActive) {
85
+ const context = {
86
+ sessionId: ctx.session.id,
87
+ runId: ctx.runId,
88
+ turn: ctx.limits.snapshot().turns,
89
+ history: ctx.loopCtx.history,
90
+ metadata: ctx.metadata,
91
+ signal: ctx.controller.signal,
92
+ stopHookActive,
93
+ };
94
+ for (const hook of ctx.stopHooks) {
95
+ let decision;
96
+ try {
97
+ decision = await hook.decide(context);
98
+ }
99
+ catch (error) {
100
+ throw new StopHookError(`Stop hook "${hook.name}" threw`, { cause: error });
101
+ }
102
+ if (decision === null || typeof decision !== "object") {
103
+ throw new StopHookError(`Stop hook "${hook.name}" must return a StopHookDecision`);
104
+ }
105
+ const action = decision.action;
106
+ if (action === "stop")
107
+ continue;
108
+ if (action !== "continue") {
109
+ throw new StopHookError(`Stop hook "${hook.name}" decision action must be "stop" or "continue"`);
110
+ }
111
+ const reason = decision.reason;
112
+ if (typeof reason !== "string" || reason.length === 0) {
113
+ throw new StopHookError(`Stop hook "${hook.name}" continue decision requires a non-empty reason string`);
114
+ }
115
+ const steer = decision.steer;
116
+ if (!isStopHookSteer(steer)) {
117
+ throw new StopHookError(`Stop hook "${hook.name}" steer must be a string or Message`);
118
+ }
119
+ return steer === undefined ? { reason } : { reason, steer };
120
+ }
121
+ return undefined;
122
+ }
123
+ function isStopHookSteer(value) {
124
+ if (value === undefined || typeof value === "string")
125
+ return true;
126
+ if (typeof value !== "object" || value === null)
127
+ return false;
128
+ const message = value;
129
+ return typeof message.role === "string" && Array.isArray(message.content);
130
+ }
131
+ /**
132
+ * Queue a continuation through the host steer path (plan 106 R1): same redaction, same 8-message /
133
+ * 64 KiB caps, and the same input-guardrail re-check when the loop drains it. A queue failure fails
134
+ * the run closed — the hook asked for something the run cannot deliver.
135
+ */
136
+ function queueStopHookContinuation(ctx, decision) {
137
+ const messages = [{ role: "user", content: [{ type: "text", text: decision.reason }] }];
138
+ if (decision.steer !== undefined) {
139
+ messages.push(typeof decision.steer === "string" ? { role: "user", content: [{ type: "text", text: decision.steer }] } : decision.steer);
140
+ }
141
+ try {
142
+ ctx.session.steer(messages);
143
+ }
144
+ catch (error) {
145
+ throw new StopHookError("Stop hook continuation could not be queued", { cause: error });
146
+ }
147
+ }
148
+ /** The generate-validate-revise loop promises a validated artifact; a bare return is a failure. */
149
+ function assertArtifactOutcome(ctx) {
150
+ if (ctx.loop.name === "generate-validate-revise" && !ctx.artifactFinished) {
151
+ throw Object.assign(new Error(ctx.artifactFailedInfo?.message ?? "artifact loop ended without a validated artifact"), {
152
+ name: "ArtifactFailed",
153
+ code: ctx.artifactFailedInfo?.code ?? "artifact_failed",
154
+ });
155
+ }
156
+ }
48
157
  /** Validate `RunOptions.turnPolicy` once, before any provider turn (plan 084 Task 2). */
49
158
  function assertTurnPolicy(policy, resolvedLimits) {
50
159
  if (policy === undefined)
@@ -135,12 +244,21 @@ function assertPromptVersionRef(ref) {
135
244
  return ref;
136
245
  }
137
246
  async function assembleRoundContext(params) {
138
- const { session, input, options, runId, resumed, controller, model, startedAt, promptVersion, metadata, limits, runUsage } = params;
247
+ const { session, input, options, runId, resumed, controller, model, startedAt, promptVersion, metadata, limits, runUsage, stopHooks } = params;
139
248
  session.resolveRunProvider(options);
140
249
  throwIfAborted(controller.signal);
141
250
  session.emit({ type: "agent_started", sessionId: session.id, runId });
142
251
  if (resumed)
143
- session.emit({ type: "agent_resumed", sessionId: session.id, runId, version: resumed.version });
252
+ session.emit({
253
+ type: "agent_resumed",
254
+ sessionId: session.id,
255
+ runId,
256
+ version: resumed.version,
257
+ ...(resumed.restore ? { restore: resumed.restore } : {}),
258
+ });
259
+ // Plan 106 R2: first run start of this session opens it. Awaited after the two emits above so the
260
+ // run's synchronous announce burst stays intact; middleware error policy owns failures.
261
+ await session.openSession(runId);
144
262
  const startRecord = {
145
263
  id: runId,
146
264
  sessionId: session.id,
@@ -176,6 +294,7 @@ async function assembleRoundContext(params) {
176
294
  const tools = searchTool ? [...activeToolList, searchTool] : activeToolList;
177
295
  const activeSkills = session.resolveRunSkills(options, tools);
178
296
  session.activeRunSkills = activeSkills;
297
+ session.tailSegments.clear();
179
298
  if (options.model && JSON.stringify(options.model) !== JSON.stringify(session.agent.config.model)) {
180
299
  await session.appendEntry(createSessionEntry({
181
300
  sessionId: session.id,
@@ -215,24 +334,41 @@ async function assembleRoundContext(params) {
215
334
  assertStructuredOutputRequestSupported(options.model ?? session.agent.config.model, providerOptions);
216
335
  const validate = options.validate ?? session.agent.config.validator;
217
336
  // Resolved once per run, before any provider turn: a bad setting or a widening run overlay
218
- // fails here rather than on the turn that happens to cross the ratio (plan 074 C12).
219
- const attentionCompiler = resolveRunAttentionCompiler(session.agent.config.attentionCompiler, options.attentionCompiler, options.model ?? session.agent.config.model);
337
+ // fails here rather than on the turn that happens to cross the ratio (plan 074 C12). The
338
+ // resolved run input budget rides the handle so `run_input_ratio` folds against the same cap
339
+ // the run limit enforces (plan 086 T2); `null` (disabled) leaves that axis on the input cap.
340
+ const attentionCompiler = resolveRunAttentionCompiler(session.agent.config.attentionCompiler, options.attentionCompiler, options.model ?? session.agent.config.model, limits.limits.maxInputTokens);
341
+ // Plan 086 T3: durable folding writes the fold ledger to the run checkpoint, so it needs a
342
+ // durable run (the session's durable state is set before this call). Fail at run start, before
343
+ // any provider turn, rather than folding into memory only. The fold state rides that
344
+ // checkpoint independently of `persistSessionState`.
345
+ if (attentionCompiler?.durable && !session.activeDurable) {
346
+ throw new AgentRunStateError("attentionCompiler.durable requires a durable run: set AgentConfig or RunOptions runState with a checkpoint store");
347
+ }
348
+ session.attentionDurable = attentionCompiler?.durable === true;
220
349
  // Telemetry seam (plan 074 T6): one `attention_compiled` per mutated turn, counts and the
221
350
  // measured ratio inputs only. Under-ratio turns and compiler-off runs emit nothing.
351
+ // Plan 086 T3: a turn that folded new bodies is the fold-boundary durability signal, so it is
352
+ // remembered here (the callback is synchronous) and checkpointed by the assembler below.
353
+ let foldCheckpointPending = false;
222
354
  const onAttentionReport = attentionCompiler
223
- ? (report) => session.emit({
224
- type: "attention_compiled",
225
- sessionId: session.id,
226
- runId,
227
- used: report.used,
228
- usedAfter: report.usedAfter,
229
- inputCap: report.inputCap,
230
- triggerRatio: report.triggerRatio,
231
- droppedThinkingTurns: report.droppedThinkingTurns,
232
- stubbedToolResults: report.stubbedToolResults,
233
- stubbedBytes: report.stubbedBytes,
234
- truncated: report.truncated,
235
- })
355
+ ? (report) => {
356
+ if (report.newFoldedBodies > 0)
357
+ foldCheckpointPending = true;
358
+ session.emit({
359
+ type: "attention_compiled",
360
+ sessionId: session.id,
361
+ runId,
362
+ used: report.used,
363
+ usedAfter: report.usedAfter,
364
+ inputCap: report.inputCap,
365
+ triggerRatio: report.triggerRatio,
366
+ droppedThinkingTurns: report.droppedThinkingTurns,
367
+ stubbedToolResults: report.stubbedToolResults,
368
+ stubbedBytes: report.stubbedBytes,
369
+ truncated: report.truncated,
370
+ });
371
+ }
236
372
  : undefined;
237
373
  const instructionInjectors = options.instructionInjectors ?? session.agent.config.instructionInjectors ?? [];
238
374
  const inputLayout = options.inputLayout ?? session.agent.config.inputLayout;
@@ -269,9 +405,12 @@ async function assembleRoundContext(params) {
269
405
  artifactFailedInfo: undefined,
270
406
  toolCalls: 0,
271
407
  toolResults: [],
408
+ stopHooks,
272
409
  runUsage,
273
410
  loopCtx: undefined,
274
411
  };
412
+ const toolNarrowing = options.toolNarrowing ?? session.agent.config.toolNarrowing;
413
+ let narrowedForTurn;
275
414
  const loopCtx = {
276
415
  sessionId: session.id,
277
416
  runId,
@@ -285,6 +424,38 @@ async function assembleRoundContext(params) {
285
424
  restoredLoopState: resumed?.state?.loopState?.snapshot,
286
425
  assemble: async (nextInput, toolResults, turn) => {
287
426
  limits.charge("maxTurns");
427
+ const turnIndex = turn ?? 1;
428
+ let turnTools = tools;
429
+ if (toolNarrowing) {
430
+ if (typeof toolNarrowing !== "function")
431
+ throw new TypeError("toolNarrowing must be a function");
432
+ if (narrowedForTurn?.turn === turnIndex) {
433
+ turnTools = narrowedForTurn.tools;
434
+ }
435
+ else {
436
+ const assistant = lastAssistantText(session.history);
437
+ const requested = await toolNarrowing({
438
+ turn: turnIndex,
439
+ toolIds: tools.map((tool) => tool.name),
440
+ ...(assistant !== undefined ? { lastAssistantText: assistant } : {}),
441
+ });
442
+ if (!Array.isArray(requested))
443
+ throw new TypeError("toolNarrowing must return a string array");
444
+ const clamped = clampTurnToolNames(tools, requested);
445
+ if (clamped.dropped.length > 0) {
446
+ session.emit({
447
+ type: "tool_narrowing_clamped",
448
+ sessionId: session.id,
449
+ runId,
450
+ turn: turnIndex,
451
+ dropped: clamped.dropped,
452
+ });
453
+ }
454
+ turnTools = clamped.tools;
455
+ narrowedForTurn = { turn: turnIndex, tools: turnTools };
456
+ }
457
+ ctx.turnAllow = turnTools.map((tool) => tool.name);
458
+ }
288
459
  const request = await assembleProviderInput({
289
460
  model: options.model ?? session.agent.config.model,
290
461
  input: nextInput,
@@ -304,13 +475,21 @@ async function assembleRoundContext(params) {
304
475
  toolsSearch: session.agent.config.toolsSearch,
305
476
  activatedTools: session.activatedTools,
306
477
  toolResultFold: resolveToolResultFold(options.toolResultFold, session.agent.config.toolResultFold),
478
+ contextBudget: session.agent.config.contextBudget,
307
479
  attentionCompiler,
308
480
  // Session-owned: a stub made earlier stays applied even on a later under-ratio turn, so
309
481
  // the prompt-cache prefix is not rewritten (C10). Undefined when the compiler is off.
310
482
  attentionSticky: attentionCompiler ? session.attentionStickyFor() : undefined,
483
+ // Folded bodies (plan 086 T3): a row summarized once is re-applied, never re-summarized,
484
+ // so sticky rows stay byte-identical and a resumed run reuses the persisted bodies.
485
+ attentionFold: attentionCompiler ? session.attentionFoldFor() : undefined,
486
+ // Charge-so-far for the `run_input_ratio` axis: the counter only holds completed turns,
487
+ // so the axis projects this turn's estimate onto it.
488
+ runInputTokens: limits.snapshot().inputTokens,
311
489
  onAttentionReport,
312
490
  loadedSkills: session.loadedSkills,
313
- tools,
491
+ tailSegments: session.tailSegments,
492
+ tools: turnTools,
314
493
  resourceLoader: session.agent.config.resourceLoader,
315
494
  permission: session.agent.config.permission,
316
495
  trust: session.agent.config.trust,
@@ -323,6 +502,13 @@ async function assembleRoundContext(params) {
323
502
  signal: controller.signal,
324
503
  });
325
504
  ctx.assembledTurn = true;
505
+ if (foldCheckpointPending) {
506
+ foldCheckpointPending = false;
507
+ // Fold-boundary durability (plan 086 T3): one write per turn that added folded bodies,
508
+ // after the request is assembled and before the provider sees it, so a crash during this
509
+ // turn resumes with the same ledger. No-op unless the compiler is durable.
510
+ await checkpointDurableFold(session, { runId, model, limits });
511
+ }
326
512
  return request;
327
513
  },
328
514
  chargeToolRound: bindChargeToolRound(ctx),
@@ -345,10 +531,15 @@ async function assembleRoundContext(params) {
345
531
  if (session.activeDurable?.options.checkpointPolicy === "every-turn") {
346
532
  await checkpointDurableTurn(session, { runId, model, limits });
347
533
  }
534
+ // Deterministic no-model turn (plan 096): host middleware answers at the provider boundary,
535
+ // before any provider-round work. No answer → provider path unchanged.
536
+ const deterministic = await resolveDeterministicTurn(session, request, runId, session.activeLoopTurn, controller.signal, ctx.toolResults);
537
+ if (deterministic)
538
+ return deterministic;
348
539
  const policyResult = await session.applyProviderRequestPolicies(request, runId, options, metadata, controller.signal);
349
540
  const middlewareRequest = (await session.agent.config.middleware?.run("provider_request", policyResult.request)) ?? policyResult.request;
350
541
  try {
351
- return await generateWithRetry(session, session.redactProviderRequest(middlewareRequest), runId, options, controller.signal, policyResult.secrets, session.activeLoopTurn, (turnUsage, turn, attempt) => recordProviderUsage(ctx, turnUsage, turn, attempt), ctx.toolResults);
542
+ 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);
352
543
  }
353
544
  catch (error) {
354
545
  if (isSteerSoftInterrupt(error)) {
@@ -381,6 +572,50 @@ async function assembleRoundContext(params) {
381
572
  ctx.loopCtx = loopCtx;
382
573
  return ctx;
383
574
  }
575
+ /**
576
+ * Run the loop to settlement, then apply stop hooks at the natural loop end (plan 106 R1). Each
577
+ * `continue` queues its reason through the steer path and re-enters the loop with a continuation
578
+ * context whose `input`/`inputMessages` are empty — the continuation message is already in
579
+ * `history`, and replaying run-start input would duplicate it. A loop ceiling, a host turn-policy
580
+ * stop, or an artifact failure is not a natural end: hooks never run there, and a continuation leg
581
+ * that hits a ceiling ends the run instead of asking again. `limits.maxStopContinuations`
582
+ * (default 3; `0` observes only; `null` uncapped) bounds continuations as a clean `hook_limit` stop.
583
+ */
584
+ async function runLoopWithStopHooks(ctx) {
585
+ let usage = await runLoopUntilSettled(ctx);
586
+ assertArtifactOutcome(ctx);
587
+ // Zero overhead when nothing is configured: no wrapper state, no reads.
588
+ if (ctx.stopHooks.length === 0)
589
+ return usage;
590
+ const cap = ctx.limits.limits.maxStopContinuations;
591
+ let continuations = 0;
592
+ let stopHookActive = false;
593
+ for (;;) {
594
+ if (ctx.runStop !== undefined || ctx.loopCtx.finishReason !== undefined)
595
+ return usage;
596
+ const decision = await evaluateStopHooks(ctx, stopHookActive);
597
+ if (!decision)
598
+ return usage;
599
+ if (cap !== null && continuations >= cap) {
600
+ ctx.loopCtx.finishReason = "hook_limit";
601
+ return usage;
602
+ }
603
+ continuations += 1;
604
+ stopHookActive = true;
605
+ queueStopHookContinuation(ctx, decision);
606
+ const continuationCtx = { ...ctx.loopCtx, input: [], inputMessages: [], continuation: true };
607
+ try {
608
+ usage = await runLoopUntilSettled({ ...ctx, loopCtx: continuationCtx });
609
+ }
610
+ finally {
611
+ // The loops set `finishReason` on the context they receive; carry it back so the ceiling
612
+ // survives onto the result, the finish record, and `persistSucceeded`.
613
+ if (continuationCtx.finishReason !== undefined)
614
+ ctx.loopCtx.finishReason = continuationCtx.finishReason;
615
+ }
616
+ assertArtifactOutcome(ctx);
617
+ }
618
+ }
384
619
  export async function executeRun(session, input, options, runId, resumed) {
385
620
  const legacyMaxToolRounds = options.maxToolRounds;
386
621
  if (legacyMaxToolRounds !== undefined) {
@@ -398,6 +633,8 @@ export async function executeRun(session, input, options, runId, resumed) {
398
633
  const requestedLimits = options.limits;
399
634
  const resolvedLimits = resolveRunLimits(session.agent.config.limits, requestedLimits);
400
635
  assertTurnPolicy(options.turnPolicy, resolvedLimits);
636
+ const stopHooks = [...(session.agent.config.stopHooks ?? []), ...(options.stopHooks ?? [])];
637
+ assertStopHooks(stopHooks);
401
638
  const durableOptions = options.runState ?? session.agent.config.runState;
402
639
  if (session.agent.config.runState && options.runState && session.agent.config.runState !== options.runState) {
403
640
  throw new AgentRunStateError("RunOptions cannot replace agent durable run-state configuration");
@@ -431,8 +668,14 @@ export async function executeRun(session, input, options, runId, resumed) {
431
668
  if (session.activeIdentity && !session.activeOwnership)
432
669
  session.activeOwnership = ownershipFromIdentity(session.activeIdentity);
433
670
  session.activeIdempotencyKey = options.idempotencyKey ?? session.agent.config.idempotencyKey;
434
- session.activeGuardrails = mergeGuardrails(session.agent.config.guardrails, options.guardrails);
435
671
  session.activeDurable = resumed ?? (durableOptions ? { options: durableOptions, version: 0 } : undefined);
672
+ // Plan 104 T3: an `ask` rule is gated at charge time when the run can suspend; a run that cannot
673
+ // suspend enforces the same rule as a plain block, so it joins the ordinary stage guardrails.
674
+ const packGuardrails = session.activeDurable ? session.packGuardrails : mergeGuardrails(session.packGuardrails, session.packAskBlocks);
675
+ session.activeGuardrails = mergeGuardrails(mergeGuardrails(session.agent.config.guardrails, packGuardrails), options.guardrails);
676
+ // Plan 086 T3: reset here, so a suspension before the compiler is resolved (input guardrail)
677
+ // cannot inherit the previous run's durable-folding flag. `assembleRoundContext` sets it true.
678
+ session.attentionDurable = false;
436
679
  session.activeGatedRound = undefined;
437
680
  if (resumed)
438
681
  session.invalidateSnapshot();
@@ -462,6 +705,7 @@ export async function executeRun(session, input, options, runId, resumed) {
462
705
  deadlineAt: resumed?.state?.deadlineAt,
463
706
  });
464
707
  session.activeLimits = limits;
708
+ session.activeRecentToolCalls = [];
465
709
  const hasFiniteTokenCap = (value) => typeof value === "number" && Number.isFinite(value);
466
710
  session.activeLimitOutputBuffer = [session.agent.config.limits, requestedLimits].some((value) => hasFiniteTokenCap(value?.maxOutputTokens) || hasFiniteTokenCap(value?.maxTotalTokens) || value?.maxCost !== undefined);
467
711
  try {
@@ -478,6 +722,7 @@ export async function executeRun(session, input, options, runId, resumed) {
478
722
  metadata,
479
723
  limits,
480
724
  runUsage,
725
+ stopHooks,
481
726
  });
482
727
  await replayDurableNestedAndPending(ctx);
483
728
  const resumedLoopState = resumed?.state?.loopState;
@@ -487,7 +732,7 @@ export async function executeRun(session, input, options, runId, resumed) {
487
732
  }
488
733
  ctx.loop.restore?.(resumedLoopState.snapshot);
489
734
  }
490
- const loopUsage = await runLoopUntilSettled(ctx).catch((error) => {
735
+ const loopUsage = await runLoopWithStopHooks(ctx).catch((error) => {
491
736
  // Host turn-policy stop (plan 084 Task 2): the loop was unwound on purpose at a turn
492
737
  // boundary. Not an error — the run settles cleanly and stays resumable.
493
738
  if (error instanceof AgentRunStopped)
@@ -496,12 +741,6 @@ export async function executeRun(session, input, options, runId, resumed) {
496
741
  });
497
742
  stopReason = ctx.runStop?.reason ?? ctx.loopCtx.finishReason;
498
743
  stopDetail = ctx.runStop?.detail;
499
- if (!ctx.runStop && ctx.loop.name === "generate-validate-revise" && !ctx.artifactFinished) {
500
- throw Object.assign(new Error(ctx.artifactFailedInfo?.message ?? "artifact loop ended without a validated artifact"), {
501
- name: "ArtifactFailed",
502
- code: ctx.artifactFailedInfo?.code ?? "artifact_failed",
503
- });
504
- }
505
744
  usage = runUsage.value() ?? loopUsage;
506
745
  return await persistSucceeded(ctx, loopUsage);
507
746
  }
@@ -513,8 +752,18 @@ export async function executeRun(session, input, options, runId, resumed) {
513
752
  return session.buildRunResult({ runId, status: "suspended", runState: error.state, interruption: error.interruption });
514
753
  }
515
754
  runError = errorToErrorInfo(error);
516
- session.emit({ type: "error", sessionId: session.id, runId, error: runError });
517
755
  const breach = error instanceof RunLimitError ? error.breach : limits.breach;
756
+ // Terminal attribution before the terminal `error`/finish records, so a subscriber that stops
757
+ // at the first terminal event still sees why the run died (plan 087 T2).
758
+ if (breach) {
759
+ session.emit({
760
+ type: "budget_exhausted",
761
+ sessionId: session.id,
762
+ runId,
763
+ ...describeBudgetExhaustion(limits, breach, session.activeRecentToolCalls ?? []),
764
+ });
765
+ }
766
+ session.emit({ type: "error", sessionId: session.id, runId, error: runError });
518
767
  runStatus = breach ? "failed" : controller.signal.aborted ? "aborted" : "failed";
519
768
  const runState = session.activeDurable?.state
520
769
  ? await persistDurable(session, {
@@ -28,6 +28,17 @@ export declare function checkpointDurableTurn(session: SessionHost, input: {
28
28
  readonly model: ModelConfig;
29
29
  readonly limits: RunLimitTracker;
30
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>;
31
42
  export declare function persistSucceeded(ctx: RoundContext, loopUsage: Usage | undefined): Promise<AgentRunResult>;
32
43
  export declare function cleanupRun(input: {
33
44
  session: SessionHost;
@@ -1,5 +1,5 @@
1
1
  /** Finalize/persist phase of runInternal (plan 059). */
2
- import { boundedLoopSnapshot, initialAgentRunState, publicState, saveAgentRunState } from "../../agent-run-state.js";
2
+ import { boundedLoopSnapshot, initialAgentRunState, publicState, resolveCheckpointMetadata, saveAgentRunState, } from "../../agent-run-state.js";
3
3
  import { AgentRunStateError } from "../../contracts.js";
4
4
  import { redactRunLedgerRecord } from "../../redaction.js";
5
5
  import { isFlushableRunLedger } from "../../run-ledger.js";
@@ -10,13 +10,20 @@ export async function persistDurable(session, state) {
10
10
  if (!durable)
11
11
  throw new AgentRunStateError("Durable run state is not configured");
12
12
  const withGrant = session.activeToolNames !== undefined ? { ...state, toolNames: session.activeToolNames } : state;
13
- const attentionSticky = session.serializedAttentionSticky();
14
- const persisted = durable.options.persistSessionState
15
- ? {
16
- ...withGrant,
17
- sessionState: {
13
+ const persistSessionState = durable.options.persistSessionState === true;
14
+ // Plan 086 T3: durable folding owns its two keys. A run that opted into `durable` writes the
15
+ // fold ledger and its frontier even when the broader session-state bag stays off; a run that
16
+ // did not keeps exactly today's bytes, where the frontier rides `persistSessionState`.
17
+ const attentionSticky = persistSessionState || session.attentionDurable ? session.serializedAttentionSticky() : undefined;
18
+ const attentionFold = session.attentionDurable ? session.serializedAttentionFold() : undefined;
19
+ // Plan 104 T2: pack refs and pack-owned state; the key only exists under the same opt-in, so a
20
+ // default checkpoint keeps exactly today's bytes.
21
+ const guardrailPacks = persistSessionState ? session.serializedGuardrailPackState() : undefined;
22
+ const sessionState = {
23
+ ...(persistSessionState
24
+ ? {
18
25
  loadedSkillNames: session.loadedSkills.list(),
19
- ...(attentionSticky ? { attentionSticky } : {}),
26
+ ...(guardrailPacks ? { guardrailPacks } : {}),
20
27
  ...(session.activatedTools.list().length ? { activatedToolNames: session.activatedTools.list() } : {}),
21
28
  ...(durable.options.includeSkillBodies
22
29
  ? {
@@ -25,9 +32,13 @@ export async function persistDurable(session, state) {
25
32
  : undefined),
26
33
  }
27
34
  : {}),
28
- },
29
- }
30
- : withGrant;
35
+ }
36
+ : {}),
37
+ ...(attentionSticky ? { attentionSticky } : {}),
38
+ ...(attentionFold ? { attentionFold } : {}),
39
+ };
40
+ const persisted = Object.keys(sessionState).length > 0 ? { ...withGrant, sessionState } : withGrant;
41
+ const metadata = resolveCheckpointMetadata(durable.options.checkpointMetadata) ?? durable.checkpointMetadata;
31
42
  const saved = await saveAgentRunState({
32
43
  checkpoints: durable.options.checkpoints,
33
44
  state: persisted,
@@ -36,6 +47,7 @@ export async function persistDurable(session, state) {
36
47
  fencingToken: durable.options.fencingToken,
37
48
  redactor: session.activeRedactor,
38
49
  maxStateBytes: durable.options.maxStateBytes,
50
+ ...(metadata ? { metadata } : {}),
39
51
  });
40
52
  durable.state = saved.state;
41
53
  durable.version = saved.record.version;
@@ -86,8 +98,25 @@ export async function suspendDurable(session, input) {
86
98
  * into a resumed run, and loop-local state rides along exactly as it does at suspension.
87
99
  */
88
100
  export async function checkpointDurableTurn(session, input) {
101
+ if (session.activeDurable?.options.checkpointPolicy !== "every-turn")
102
+ return;
103
+ await writeRunningCheckpoint(session, input);
104
+ }
105
+ /**
106
+ * Fold-boundary checkpoint (plan 086 T3). Called once per turn that added folded bodies — never
107
+ * per turn — when the resolved compiler is durable, so a crash after a fold resumes with the
108
+ * ledger and frontier already on disk. Independent of `checkpointPolicy`: the fold is the
109
+ * durability point that matters for a long single run, not the turn boundary.
110
+ */
111
+ export async function checkpointDurableFold(session, input) {
112
+ if (!session.attentionDurable)
113
+ return;
114
+ await writeRunningCheckpoint(session, input);
115
+ }
116
+ /** Shared running-checkpoint write for the turn-boundary and fold-boundary triggers. */
117
+ async function writeRunningCheckpoint(session, input) {
89
118
  const durable = session.activeDurable;
90
- if (durable?.options.checkpointPolicy !== "every-turn")
119
+ if (!durable)
91
120
  return;
92
121
  const loop = session.activeLoop;
93
122
  const loopState = loop?.snapshot ? boundedLoopSnapshot(loop.name, loop.revision ?? "1", loop.snapshot()) : undefined;
@@ -135,19 +164,21 @@ export async function persistSucceeded(ctx, loopUsage) {
135
164
  await session.activeLedger.appendUsage(redactRunLedgerRecord(usageRecord, session.activeRedactor));
136
165
  }
137
166
  await session.drainLedger();
167
+ // Plan 084 Task 2 / plan 106 R1: a clean run-end stop keeps the frontier and marks the state
168
+ // continuable — a host turn-policy stop (`host_policy`) or a stop-hook continuation cap
169
+ // (`hook_limit`). Every other succeeded state drops its loop state and is final.
170
+ const continuableStop = stop ? "host_policy" : ctx.loopCtx.finishReason === "hook_limit" ? "hook_limit" : undefined;
138
171
  const runState = session.activeDurable?.state
139
172
  ? await persistDurable(session, {
140
173
  ...session.activeDurable.state,
141
174
  status: "succeeded",
142
- // Plan 084 Task 2: a host-policy stop is terminal for the run but leaves the frontier
143
- // intact — the loop state is kept and the state is marked continuable.
144
- ...(stop ? { stopReason: "host_policy", leafId: session.currentLeafId } : {}),
175
+ ...(continuableStop ? { stopReason: continuableStop, leafId: session.currentLeafId } : {}),
145
176
  pending: undefined,
146
177
  pendingCalls: undefined,
147
178
  nestedRuns: undefined,
148
179
  stickyDecisions: undefined,
149
180
  interruption: undefined,
150
- ...(stop ? {} : { loopState: undefined }),
181
+ ...(continuableStop ? {} : { loopState: undefined }),
151
182
  })
152
183
  : undefined;
153
184
  session.emit({
@@ -216,11 +247,12 @@ export async function cleanupRun(input) {
216
247
  session.activeLimits?.dispose();
217
248
  session.activeToolNames = undefined;
218
249
  session.activeLimits = undefined;
250
+ session.activeRecentToolCalls = undefined;
219
251
  session.activeLimitOutputBuffer = false;
220
252
  session.activeRedactor = undefined;
221
253
  session.activeProvider = undefined;
222
254
  cleanupSignal();
223
- session.closeSubscribers();
255
+ session.closeRunSubscribers();
224
256
  }
225
257
  }
226
258
  //# sourceMappingURL=persist.js.map
@@ -1,6 +1,16 @@
1
1
  /** Provider-round phase of runInternal (plan 059). */
2
- import type { ProviderRequest, ProviderTurnResult, RunOptions, ToolResult, Usage } from "../../contracts.js";
2
+ import type { ModelConfig, ProviderRequest, ProviderTurnResult, RunOptions, ToolResult, Usage } from "../../contracts.js";
3
3
  import type { RoundContext, SessionHost } from "./types.js";
4
- export declare function recordProviderUsage(ctx: RoundContext, turnUsage: Usage | undefined, turn: number, attempt: number): Promise<void>;
5
- export declare function generateWithRetry(session: SessionHost, request: ProviderRequest, runId: string, options: RunOptions, signal: AbortSignal, requestSecrets?: readonly (string | undefined)[], turn?: number, recordUsage?: (usage: Usage | undefined, turn: number, attempt: number) => Promise<void>, toolResults?: readonly ToolResult[]): Promise<ProviderTurnResult>;
6
- export declare function generateProviderTurn(session: SessionHost, request: ProviderRequest, runId: string, signal: AbortSignal, secrets?: readonly (string | undefined)[], turn?: number, attempt?: number, recordUsage?: (usage: Usage | undefined, turn: number, attempt: number) => Promise<void>, toolResults?: readonly ToolResult[]): Promise<ProviderTurnResult>;
4
+ /** Resolve the per-request input cap for turn-budget metadata (plan 087 T1). A model without a
5
+ * derivable cap (or a bad attention setting on an unrelated run) omits the field instead of
6
+ * failing an emitting turn; the attention compiler, when enabled, is the cap authority. */
7
+ export declare function resolveTurnInputCap(session: SessionHost, model: ModelConfig): number | undefined;
8
+ export declare function recordProviderUsage(ctx: RoundContext, turnUsage: Usage | undefined, turn: number, attempt: number, request?: ProviderRequest): Promise<Usage | undefined>;
9
+ /**
10
+ * Plan 096: host middleware may answer the turn deterministically at the `beforeProviderTurn` seam —
11
+ * no provider request, no usage, mandatory provenance. `undefined` sends the turn to the provider
12
+ * unchanged; a malformed answer fails the run closed instead of falling through to the provider.
13
+ */
14
+ export declare function resolveDeterministicTurn(session: SessionHost, request: ProviderRequest, runId: string, turn: number, signal: AbortSignal, toolResults?: readonly ToolResult[]): Promise<ProviderTurnResult | undefined>;
15
+ export declare function generateWithRetry(session: SessionHost, request: ProviderRequest, runId: string, options: RunOptions, signal: AbortSignal, requestSecrets?: readonly (string | undefined)[], turn?: number, recordUsage?: (usage: Usage | undefined, turn: number, attempt: number) => Promise<Usage | undefined>, toolResults?: readonly ToolResult[]): Promise<ProviderTurnResult>;
16
+ export declare function generateProviderTurn(session: SessionHost, request: ProviderRequest, runId: string, signal: AbortSignal, secrets?: readonly (string | undefined)[], turn?: number, attempt?: number, recordUsage?: (usage: Usage | undefined, turn: number, attempt: number) => Promise<Usage | undefined>, toolResults?: readonly ToolResult[]): Promise<ProviderTurnResult>;