@arnilo/prism 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/README.md +19 -16
  3. package/dist/agent-approval.d.ts +7 -1
  4. package/dist/agent-approval.js +15 -6
  5. package/dist/agent-run-lifecycle.d.ts +2 -1
  6. package/dist/agent-run-lifecycle.js +20 -6
  7. package/dist/agent-run-state.d.ts +26 -5
  8. package/dist/agent-run-state.js +97 -1
  9. package/dist/agent-session/event-subscriber.d.ts +2 -0
  10. package/dist/agent-session/event-subscriber.js +3 -0
  11. package/dist/agent-session/session/assemble.js +165 -16
  12. package/dist/agent-session/session/persist.js +11 -5
  13. package/dist/agent-session/session/provider-round.js +54 -13
  14. package/dist/agent-session/session/tool-round.d.ts +2 -2
  15. package/dist/agent-session/session/tool-round.js +86 -23
  16. package/dist/agent-session/session/types.d.ts +21 -2
  17. package/dist/agent-session/session.d.ts +66 -4
  18. package/dist/agent-session/session.js +159 -18
  19. package/dist/checkpoint-restore.d.ts +50 -14
  20. package/dist/checkpoint-restore.js +104 -28
  21. package/dist/context-budget.d.ts +11 -0
  22. package/dist/context-budget.js +33 -2
  23. package/dist/contracts-core/agent.d.ts +26 -5
  24. package/dist/contracts-core/extensions.d.ts +3 -0
  25. package/dist/contracts-core/guardrail-packs.d.ts +8 -3
  26. package/dist/contracts-core/loop.d.ts +36 -0
  27. package/dist/contracts-core/provider.d.ts +6 -1
  28. package/dist/contracts-core/run-limits.d.ts +10 -1
  29. package/dist/contracts-core/session.d.ts +2 -1
  30. package/dist/contracts-protocol.d.ts +6 -4
  31. package/dist/contracts-run-state.d.ts +48 -6
  32. package/dist/contributions.d.ts +2 -1
  33. package/dist/contributions.js +1 -0
  34. package/dist/extensions.d.ts +15 -1
  35. package/dist/extensions.js +68 -0
  36. package/dist/guardrail-packs/types.d.ts +10 -0
  37. package/dist/guardrail-packs/validation-respect.js +16 -0
  38. package/dist/guardrails.d.ts +42 -1
  39. package/dist/guardrails.js +124 -15
  40. package/dist/index.d.ts +7 -7
  41. package/dist/index.js +4 -4
  42. package/dist/leases.js +32 -6
  43. package/dist/middleware.d.ts +1 -1
  44. package/dist/node/contribution-discovery.d.ts +16 -1
  45. package/dist/node/contribution-discovery.js +47 -0
  46. package/dist/node/session-store-jsonl.js +67 -17
  47. package/dist/run-bundle.d.ts +6 -1
  48. package/dist/run-bundle.js +4 -1
  49. package/dist/run-limits.d.ts +11 -5
  50. package/dist/run-limits.js +13 -0
  51. package/dist/session-stores.js +61 -12
  52. package/dist/testing/prefix-stability-conformance.d.ts +73 -1
  53. package/dist/testing/prefix-stability-conformance.js +158 -27
  54. package/dist/tools.js +10 -3
  55. package/dist/usage-estimation.d.ts +7 -1
  56. package/dist/usage-estimation.js +16 -10
  57. package/docs/acp.md +2 -2
  58. package/docs/agent-events.md +15 -10
  59. package/docs/agent-session-runtime.md +10 -7
  60. package/docs/coding-agent-tools.md +1 -1
  61. package/docs/coding-tools.md +7 -11
  62. package/docs/compaction-llm.md +2 -0
  63. package/docs/compaction-observational-memory.md +21 -1
  64. package/docs/context-and-skills.md +6 -7
  65. package/docs/contribution-discovery.md +13 -0
  66. package/docs/durable-runs.md +14 -6
  67. package/docs/embeddings.md +7 -1
  68. package/docs/execution-timeline.md +9 -2
  69. package/docs/extensions.md +21 -5
  70. package/docs/guardrails.md +16 -6
  71. package/docs/hooks.md +282 -0
  72. package/docs/impeccable.md +1 -2
  73. package/docs/index.md +28 -21
  74. package/docs/input-and-prompt-assembly.md +1 -1
  75. package/docs/instruction-injection.md +1 -0
  76. package/docs/live-testing.md +3 -2
  77. package/docs/memory-fabric.md +29 -0
  78. package/docs/middleware-hooks.md +54 -4
  79. package/docs/migrate-to-0.11.md +65 -0
  80. package/docs/migration.md +24 -0
  81. package/docs/node-jsonl-session-store.md +4 -3
  82. package/docs/operations.md +1 -1
  83. package/docs/options-index.md +3 -1
  84. package/docs/peer-dependencies.md +3 -5
  85. package/docs/policy-and-audit.md +15 -2
  86. package/docs/prefix-stability-conformance.md +82 -9
  87. package/docs/provider-packages.md +20 -20
  88. package/docs/public-contracts.md +2 -1
  89. package/docs/rag.md +94 -7
  90. package/docs/release-and-install.md +62 -59
  91. package/docs/runs-and-usage.md +21 -10
  92. package/docs/scoped-agent-memory.md +17 -9
  93. package/docs/scoped-memory.md +138 -0
  94. package/docs/session-stores.md +2 -2
  95. package/docs/supervisors.md +14 -6
  96. package/docs/testing.md +17 -9
  97. package/docs/tools.md +1 -1
  98. package/docs/wiki.md +4 -2
  99. package/docs/workflows.md +2 -2
  100. package/package.json +8 -5
  101. package/docs/caveman.md +0 -130
  102. package/docs/graft.md +0 -149
  103. package/docs/ponytail.md +0 -129
@@ -1,53 +1,129 @@
1
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.
2
+ * Checkpoint restore hooks (plan 094 Task 3; reverse compensation plan 109 Task 2). A hook restores
3
+ * one external layer (git commit, document version, workspace fingerprint) recorded in a
4
+ * checkpoint's sidecar metadata.
4
5
  *
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.
6
+ * All-or-nothing: hooks run before the resume claims the checkpoint. The first hook that fails or
7
+ * times out aborts the resume with `CheckpointRestoreError` naming that hook, so the conversation
8
+ * restore never applies on top of a half-restored external world. A handler may also declare a
9
+ * `compensate` direction: on failure the applied layers — including the failing one, which may be
10
+ * half-applied — are undone in reverse order and the error carries a best-effort
11
+ * `CheckpointRestoreCompensation` report. Compensation never runs after the caller aborted, never
12
+ * masks the original failure, and the checkpoint stays resumable either way.
9
13
  */
10
14
  /** Per-hook ceiling for a restore (plan 094 Task 3 default). */
11
15
  export const DEFAULT_CHECKPOINT_RESTORE_TIMEOUT_MS = 10_000;
12
16
  /** Thrown when a restore hook fails or times out; the checkpoint and conversation are untouched. */
13
17
  export class CheckpointRestoreError extends Error {
14
18
  code = "ERR_PRISM_CHECKPOINT_RESTORE";
15
- /** Name of the failing hook (`fn.name` or `hook[i]`). */
19
+ /** Name of the failing hook (`id`, `hook.name`, or `hook[i]`). */
16
20
  hook;
17
- constructor(hook, cause) {
21
+ constructor(hook, cause, compensation) {
18
22
  super(`Checkpoint restore hook ${hook} failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
19
23
  this.name = "CheckpointRestoreError";
20
24
  this.hook = hook;
25
+ if (compensation)
26
+ this.compensation = compensation;
21
27
  }
22
28
  }
29
+ /** Compensation reasons are operator hints, not log lines: capped at this many UTF-8 bytes. */
30
+ const MAX_COMPENSATION_REASON_BYTES = 1_024;
31
+ /** One handler identity per restore, resolved once at the executor boundary (plan 109 Task 2). */
32
+ function normalizeRestoreHandler(handler, index) {
33
+ if (typeof handler === "function")
34
+ return { id: handler.name || `hook[${index}]`, restore: handler };
35
+ return {
36
+ id: handler.id ?? handler.restore.name ?? `hook[${index}]`,
37
+ restore: handler.restore,
38
+ ...(handler.compensate ? { compensate: handler.compensate } : {}),
39
+ };
40
+ }
41
+ /** Run one direction of one handler under the per-hook timeout, reporting failure instead of throwing. */
42
+ async function invokeRestoreHandler(handler, context, timeoutMs, callerSignal) {
43
+ const controller = new AbortController();
44
+ const timer = setTimeout(() => controller.abort(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
45
+ const signal = callerSignal ? AbortSignal.any([callerSignal, controller.signal]) : controller.signal;
46
+ const started = Date.now();
47
+ try {
48
+ await handler(context, signal);
49
+ return { ok: true, durationMs: Date.now() - started };
50
+ }
51
+ catch (error) {
52
+ // Our own abort means the hook either ignored the signal or lost the race; name the timeout.
53
+ const timedOut = controller.signal.aborted && !callerSignal?.aborted;
54
+ return { ok: false, cause: timedOut ? controller.signal.reason : error };
55
+ }
56
+ finally {
57
+ clearTimeout(timer);
58
+ }
59
+ }
60
+ /** UTF-8-safe truncation; no suffix, so the cap is exact. */
61
+ function truncateUtf8Bytes(text, maxBytes) {
62
+ if (Buffer.byteLength(text, "utf8") <= maxBytes)
63
+ return text;
64
+ let bytes = 0;
65
+ let end = 0;
66
+ for (const char of text) {
67
+ const size = Buffer.byteLength(char, "utf8");
68
+ if (bytes + size > maxBytes)
69
+ break;
70
+ bytes += size;
71
+ end += char.length;
72
+ }
73
+ return text.slice(0, end);
74
+ }
75
+ /** Redacted, byte-bounded operator hint; hook arguments are never copied into it. */
76
+ function compensationReason(error, redactor) {
77
+ const message = error instanceof Error ? error.message : String(error);
78
+ return truncateUtf8Bytes(redactor ? redactor.redact(message) : message, MAX_COMPENSATION_REASON_BYTES);
79
+ }
23
80
  /**
24
- * Run restore hooks sequentially and report the audit. A hook failure throws
25
- * `CheckpointRestoreError` immediately (later hooks do not run); an already-aborted caller signal
26
- * throws its own abort reason so the resume reads as cancelled rather than as a restore failure.
81
+ * Reverse pass over the applied layers (`failedIndex`…0, failing hook first). Best-effort: a
82
+ * failing compensation is recorded once and the pass continues; a caller abort stops the pass and
83
+ * rethrows the abort reason unchanged, so a cancel still reads as cancelled.
84
+ */
85
+ async function compensateRestoreHandlers(handlers, failedIndex, context, timeoutMs, options) {
86
+ const ran = [];
87
+ let failed;
88
+ for (let index = failedIndex; index >= 0; index -= 1) {
89
+ const handler = handlers[index];
90
+ if (!handler?.compensate)
91
+ continue;
92
+ options.signal?.throwIfAborted();
93
+ const outcome = await invokeRestoreHandler(handler.compensate, context, timeoutMs, options.signal);
94
+ options.signal?.throwIfAborted();
95
+ ran.push(handler.id);
96
+ if (!outcome.ok && failed === undefined)
97
+ failed = { hook: handler.id, reason: compensationReason(outcome.cause, options.redactor) };
98
+ }
99
+ if (ran.length === 0)
100
+ return undefined;
101
+ return { ran, ...(failed ? { failed } : {}) };
102
+ }
103
+ /**
104
+ * Run restore handlers sequentially and report the audit. A handler failure throws
105
+ * `CheckpointRestoreError` immediately (later handlers do not run) after a best-effort reverse
106
+ * compensation pass over the applied layers; with no `compensate` declared anywhere, the failure
107
+ * shape is exactly plan 094's. An already-aborted caller signal throws its own abort reason so the
108
+ * resume reads as cancelled rather than as a restore failure, and a caller abort during the pass
109
+ * stops it before the next handler runs.
27
110
  */
28
111
  export async function runCheckpointRestoreHooks(hooks, context, options = {}) {
29
112
  const timeoutMs = options.timeoutMs ?? DEFAULT_CHECKPOINT_RESTORE_TIMEOUT_MS;
30
113
  const started = Date.now();
31
114
  const entries = [];
32
- for (const [index, hook] of hooks.entries()) {
115
+ const handlers = hooks.map((handler, index) => normalizeRestoreHandler(handler, index));
116
+ for (const [index, handler] of handlers.entries()) {
33
117
  options.signal?.throwIfAborted();
34
- const name = hook.name || `hook[${index}]`;
35
- const controller = new AbortController();
36
- const timer = setTimeout(() => controller.abort(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
37
- const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
38
- const hookStarted = Date.now();
39
- try {
40
- await hook(context, signal);
41
- }
42
- catch (error) {
43
- // Our own abort means the hook either ignored the signal or lost the race; name the timeout.
44
- const timedOut = controller.signal.aborted && !options.signal?.aborted;
45
- throw new CheckpointRestoreError(name, timedOut ? controller.signal.reason : error);
46
- }
47
- finally {
48
- clearTimeout(timer);
118
+ const outcome = await invokeRestoreHandler(handler.restore, context, timeoutMs, options.signal);
119
+ if (!outcome.ok) {
120
+ // Security: never run host compensation code once the caller has aborted.
121
+ const compensation = options.signal?.aborted
122
+ ? undefined
123
+ : await compensateRestoreHandlers(handlers, index, context, timeoutMs, options);
124
+ throw new CheckpointRestoreError(handler.id, outcome.cause, compensation);
49
125
  }
50
- entries.push({ hook: name, durationMs: Date.now() - hookStarted });
126
+ entries.push({ hook: handler.id, durationMs: outcome.durationMs });
51
127
  }
52
128
  return { hooks: entries, durationMs: Date.now() - started };
53
129
  }
@@ -96,3 +96,14 @@ export declare function measureInputCost(options: MeasureInputCostOptions): {
96
96
  tokens: number;
97
97
  bytes: number;
98
98
  };
99
+ /** Plan 103 T6: the host's `contextBudget.tokenEstimator`, validated exactly like the budget pass
100
+ * validates it (a non-function, or a non-finite/negative count, fails closed with `TypeError`).
101
+ * `undefined` when no host estimator is configured, so callers can fall through to the built-in
102
+ * heuristic. Exported for the usage seam (`provider-round.ts`) — deliberately not re-exported by
103
+ * `src/index.ts`, so the public surface is unchanged. */
104
+ export declare function resolveHostTokenEstimator(budget: ContextBudget | undefined): TokenEstimator | undefined;
105
+ /** Plan 103 T6: tool declarations and context blocks projected with the assembler's own
106
+ * `measureAll` text shapes, so the usage-fallback estimate and the budget pass cannot drift
107
+ * (never `JSON.stringify` of the raw schemas). Exported for the usage seam — deliberately not
108
+ * re-exported by `src/index.ts`. */
109
+ export declare function estimateRequestExtrasTokens(tools: readonly ToolDefinition[] | undefined, context: readonly ContextBlock[] | undefined, estimateTokens: TokenEstimator): number;
@@ -246,7 +246,7 @@ function measureAll(groups, context, skills, tools, skillContext, demotedBodies,
246
246
  for (const message of groups.toolResults)
247
247
  addMessage(message);
248
248
  for (const block of context) {
249
- const text = `${block.title ? `${block.title}:\n` : "Context:\n"}${contextBlockText(block)}`;
249
+ const text = contextBlockMeasureText(block);
250
250
  tokens += estimateTokens(text);
251
251
  bytes += estimateTextBytes(text);
252
252
  }
@@ -256,12 +256,35 @@ function measureAll(groups, context, skills, tools, skillContext, demotedBodies,
256
256
  bytes += estimateTextBytes(text);
257
257
  }
258
258
  if (tools?.length) {
259
- const text = `Available tools:\n${tools.map((tool) => `- ${tool.name}${tool.description ? `: ${tool.description}` : ""}`).join("\n")}`;
259
+ const text = toolsMeasureText(tools);
260
260
  tokens += estimateTokens(text);
261
261
  bytes += estimateTextBytes(text);
262
262
  }
263
263
  return { tokens, bytes };
264
264
  }
265
+ /** Plan 103 T6: the host's `contextBudget.tokenEstimator`, validated exactly like the budget pass
266
+ * validates it (a non-function, or a non-finite/negative count, fails closed with `TypeError`).
267
+ * `undefined` when no host estimator is configured, so callers can fall through to the built-in
268
+ * heuristic. Exported for the usage seam (`provider-round.ts`) — deliberately not re-exported by
269
+ * `src/index.ts`, so the public surface is unchanged. */
270
+ export function resolveHostTokenEstimator(budget) {
271
+ if (budget?.tokenEstimator === undefined)
272
+ return undefined;
273
+ return resolveTokenEstimator(budget);
274
+ }
275
+ /** Plan 103 T6: tool declarations and context blocks projected with the assembler's own
276
+ * `measureAll` text shapes, so the usage-fallback estimate and the budget pass cannot drift
277
+ * (never `JSON.stringify` of the raw schemas). Exported for the usage seam — deliberately not
278
+ * re-exported by `src/index.ts`. */
279
+ export function estimateRequestExtrasTokens(tools, context, estimateTokens) {
280
+ let tokens = 0;
281
+ if (context?.length)
282
+ for (const block of context)
283
+ tokens += estimateTokens(contextBlockMeasureText(block));
284
+ if (tools?.length)
285
+ tokens += estimateTokens(toolsMeasureText(tools));
286
+ return tokens;
287
+ }
265
288
  function overBudget(cost, budget) {
266
289
  if (budget.maxInputTokens !== undefined && cost.tokens > budget.maxInputTokens)
267
290
  return true;
@@ -342,4 +365,12 @@ function contextBlockText(block) {
342
365
  })
343
366
  .join("\n");
344
367
  }
368
+ /** The context block exactly as `measureAll` measures it (plan 103 T6 shares this shape with the usage seam). */
369
+ function contextBlockMeasureText(block) {
370
+ return `${block.title ? `${block.title}:\n` : "Context:\n"}${contextBlockText(block)}`;
371
+ }
372
+ /** The tool list exactly as `measureAll` measures it (plan 103 T6 shares this shape with the usage seam). */
373
+ function toolsMeasureText(tools) {
374
+ return `Available tools:\n${tools.map((tool) => `- ${tool.name}${tool.description ? `: ${tool.description}` : ""}`).join("\n")}`;
375
+ }
345
376
  //# sourceMappingURL=context-budget.js.map
@@ -11,7 +11,7 @@ import type { CompactionOptions, RetryOptions } from "./compaction.js";
11
11
  import type { ContentBlock, ErrorInfo, JsonObject, Message, ModelConfig } from "./content.js";
12
12
  import type { ProviderRequestPolicy, SystemPromptConfig } from "./extensions.js";
13
13
  import type { GuardrailPackRef } from "./guardrail-packs.js";
14
- import type { AgentLoopOptions, AgentLoopStrategy } from "./loop.js";
14
+ import type { AgentLoopOptions, AgentLoopStrategy, StopHook } from "./loop.js";
15
15
  import type { OwnershipScope } from "./persistence.js";
16
16
  import type { AIProvider, ProviderRequestOptions, ProviderResolver } from "./provider.js";
17
17
  import type { ResourceLoader } from "./resources.js";
@@ -86,11 +86,24 @@ export interface AgentConfig {
86
86
  * Omitted keeps today's request bytes; per-run options may only relax this setting. */
87
87
  readonly attentionCompiler?: import("./attention.js").AttentionCompilerSetting;
88
88
  /**
89
- * Missing-usage fallback (plan 091 T2): `"fallback"` (default) records a labeled
90
- * estimate when a provider turn reports no usage; `"off"` leaves usage absent —
91
- * never zero. Estimates are marked `Usage.estimated` and are never priced.
89
+ * Missing-usage handling (plan 091 T2, plan 103 T5): `"fallback"` (default) records a labeled
90
+ * estimate when a provider turn reports no usage; `"off"` leaves usage absent — never zero;
91
+ * `"strict"` refuses a usage-less turn instead, failing the run with `code: "usage_missing"`
92
+ * (`name: "UsageMissingError"`) so a host whose cost gates cannot tolerate approximations never
93
+ * runs on an estimate. A refusal is a harness decision, not a provider failure, so it carries no
94
+ * `failureClass` and is never retried. Estimates are marked `Usage.estimated` and never priced.
92
95
  */
93
- readonly usageEstimation?: "fallback" | "off";
96
+ readonly usageEstimation?: "fallback" | "off" | "strict";
97
+ /**
98
+ * Session-turn context budget (plan 103 T6): forwarded to every `assembleProviderInput`
99
+ * call this agent's sessions make, so a session gets the same eviction, `tokenEstimator`,
100
+ * and `reportOmissions` semantics as a direct assembler caller. Mutually exclusive with
101
+ * `attentionCompiler` (rejected at assembly). With `usageEstimation: "fallback"`, the
102
+ * missing-usage estimate prefers this budget's own measurement: the request's
103
+ * `ContextBudgetReport.keptTokens` when `reportOmissions` is on, else the `tokenEstimator`
104
+ * projection — see [Runs and usage](../../docs/runs-and-usage.md).
105
+ */
106
+ readonly contextBudget?: import("../context-budget.js").ContextBudget;
94
107
  readonly inputBuilder?: InputBuilder;
95
108
  readonly promptBuilder?: PromptBuilder;
96
109
  readonly middleware?: MiddlewareRegistry;
@@ -124,6 +137,8 @@ export interface AgentConfig {
124
137
  readonly inputLayout?: InputAssemblyLayout;
125
138
  readonly loop?: AgentLoopStrategy | AgentLoopOptions;
126
139
  readonly guardrails?: Guardrails;
140
+ /** Run-end stop hooks (plan 106 R1); `RunOptions.stopHooks` appends to this list. */
141
+ readonly stopHooks?: readonly StopHook[];
127
142
  /** Opt-in durable interruption/checkpointing default for this agent. */
128
143
  readonly runState?: AgentRunStateOptions;
129
144
  /** Internal marker set by createSecureAgent(); makes security defaults immutable per run. */
@@ -183,6 +198,12 @@ export interface AgentSessionCloneOptions {
183
198
  }
184
199
  export type SubscriberOverflowPolicy = "close" | "drop_oldest" | "drop_newest";
185
200
  export interface SubscribeOptions {
201
+ /**
202
+ * Plan 104 T5: `true` keeps this subscriber open across runs of the same session; it is then
203
+ * closed only by the host (`subscription.close()` / `session.closeSubscribers()`) or by an
204
+ * overflow under the default `close` policy. Default `false` (closed at run end).
205
+ */
206
+ readonly acrossRuns?: boolean;
186
207
  /** Maximum queued events for a subscriber that is not actively awaiting `next()`. Defaults to 1024. */
187
208
  readonly maxQueuedEvents?: number;
188
209
  /** What to do when `maxQueuedEvents` is reached. Defaults to `close`. */
@@ -7,6 +7,7 @@ import type { Middleware, MiddlewareHookName, MiddlewareRegistry } from "../midd
7
7
  import type { AgentDefinition, CommandDefinition, ContextProvider, InputBuilder, InstructionInjector, PromptBuilder, Skill } from "./agent.js";
8
8
  import type { CompactionStrategy, RetryPolicy } from "./compaction.js";
9
9
  import type { ErrorInfo, ModelConfig } from "./content.js";
10
+ import type { StopHook } from "./loop.js";
10
11
  import type { StoreFactory } from "./persistence.js";
11
12
  import type { AIProvider, ProviderRequest } from "./provider.js";
12
13
  import type { Credential, CredentialResolver, ResourceLoader, SettingsProvider } from "./resources.js";
@@ -160,4 +161,6 @@ export interface ExtensionAPI {
160
161
  registerProviderRequestPolicy(policy: ProviderRequestPolicy): void;
161
162
  registerSystemPromptContribution(contribution: SystemPromptContribution): void;
162
163
  registerInstructionInjector(injector: InstructionInjector): void;
164
+ /** Contributes an inert run-end stop hook; activate it via `activateKernel()` → `AgentConfig.stopHooks` (plan 106 R1). */
165
+ registerStopHook(hook: StopHook): void;
163
166
  }
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Guardrail packs (plan 092 Task 2): config-declared, restrictive-only rule sets compiled once per
3
3
  * session onto the existing tool interception seams (`tool_input` / `tool_output`). Packs can only
4
- * deny or tripwire — they never grant permissions, widen arguments, or add a stage.
4
+ * deny, tripwire, or ask for approval — they never grant permissions, widen arguments, or add a stage.
5
5
  */
6
6
  import type { JsonObject } from "./content.js";
7
- export type GuardrailRuleAction = "deny" | "tripwire";
7
+ export type GuardrailRuleAction = "deny" | "tripwire" | "ask";
8
8
  /** Read-only identity view handed to a pack rule predicate (never carries a raw argument echo). */
9
9
  export interface GuardrailRuleContext {
10
10
  readonly toolName: string;
@@ -25,7 +25,12 @@ export interface GuardrailRule {
25
25
  readonly argPath?: string | readonly string[];
26
26
  /** Typed predicate escape hatch (host-trusted like all host code); deny when it returns true. Exactly one of `pattern` / `deny`. */
27
27
  readonly deny?: (args: JsonObject, context: GuardrailRuleContext) => boolean;
28
- /** Defaults to `deny`; `tripwire` also rejects the enclosing run. `ask` has no deterministic seam (plan 092 Task 1). */
28
+ /**
29
+ * Defaults to `deny`. `tripwire` also rejects the enclosing run. `ask` suspends a durable run
30
+ * before the call dispatches (the pending decision names this rule) and blocks the call in a run
31
+ * that cannot suspend; it requires `pattern` — an opaque predicate cannot raise an approval
32
+ * (plan 104 Task 3).
33
+ */
29
34
  readonly action?: GuardrailRuleAction;
30
35
  /** Bounded, redacted record reason; defaults to the pack/rule id. */
31
36
  readonly reason?: string;
@@ -46,6 +46,36 @@ export interface TurnPolicyOptions {
46
46
  */
47
47
  readonly stop?: (context: TurnBoundaryContext) => TurnStopDecision;
48
48
  }
49
+ /**
50
+ * Run-end stop-hook contract (plan 106 R1). Stop hooks run at a natural loop end — never after a
51
+ * loop ceiling, a host turn-policy stop, or an artifact failure — and decide whether the run is
52
+ * done. The first `continue` queues `reason` (plus optional `steer`) through the same steer path a
53
+ * host would use and re-enters the loop; `stop` (or no hook continuing) ends the run normally.
54
+ */
55
+ export interface StopHookContext {
56
+ readonly sessionId: string;
57
+ readonly runId: string;
58
+ /** Provider turns already assembled in this run (resumption continues the run's counter). */
59
+ readonly turn: number;
60
+ /** Live transcript at loop end; hooks read it, never mutate it. */
61
+ readonly history: readonly Message[];
62
+ readonly metadata: Readonly<Record<string, unknown>>;
63
+ readonly signal: AbortSignal;
64
+ /** True on every invocation after the first continuation in this run (Claude Code `stop_hook_active`). */
65
+ readonly stopHookActive: boolean;
66
+ }
67
+ /** `continue` re-enters the loop with `reason` queued as a steer (optional extra `steer` message follows it). */
68
+ export type StopHookDecision = {
69
+ readonly action: "stop";
70
+ } | {
71
+ readonly action: "continue";
72
+ readonly reason: string;
73
+ readonly steer?: string | Message;
74
+ };
75
+ export interface StopHook {
76
+ readonly name: string;
77
+ decide(context: StopHookContext): StopHookDecision | Promise<StopHookDecision>;
78
+ }
49
79
  export interface LoopContext {
50
80
  readonly sessionId: string;
51
81
  readonly runId: string;
@@ -54,6 +84,12 @@ export interface LoopContext {
54
84
  readonly history: Message[];
55
85
  readonly input: AgentInput;
56
86
  readonly inputMessages: readonly Message[];
87
+ /**
88
+ * True when this `run()` call is a stop-hook continuation re-entry (plan 106 R1): `input` and
89
+ * `inputMessages` are empty because the continuation message is already in `history`. Custom
90
+ * strategies must not replay run-start input when this is set.
91
+ */
92
+ readonly continuation?: boolean;
57
93
  readonly maxToolRounds: number;
58
94
  /**
59
95
  * Why the loop stopped, when a limit/ceiling ends the run cleanly (F4). Strategies set
@@ -74,8 +74,13 @@ export type ProviderStopReason = "end_turn" | "tool_calls" | "max_output_tokens"
74
74
  * axis, so a host can see which limit was closest without instrumenting the session.
75
75
  */
76
76
  export interface TurnBudgets {
77
- /** Provider-reported input tokens for this turn; absent when the provider reported none. */
77
+ /** Input tokens charged for this turn: provider-reported, or a labeled fallback estimate when the
78
+ * provider reported none; absent when neither exists. See `inputTokensSource`. */
78
79
  readonly inputTokens?: number;
80
+ /** Provenance of `inputTokens`: `"reported"` from the provider, `"estimated"` from the
81
+ * `usageEstimation: "fallback"` seam. Absent together with `inputTokens`; `usage.estimated` stays
82
+ * the authoritative label for the turn's usage. */
83
+ readonly inputTokensSource?: "reported" | "estimated";
79
84
  /** Resolved per-request input cap (attention compiler when enabled); absent when no cap derivable. */
80
85
  readonly inputCap?: number;
81
86
  /** Cumulative run input budget (`RunLimits.maxInputTokens`); absent when the axis is disabled. */
@@ -22,6 +22,12 @@ export interface RunLimits {
22
22
  readonly amount: number;
23
23
  readonly currency: string;
24
24
  };
25
+ /**
26
+ * Clean cap on stop-hook continuations in one run (plan 106 R1). Default 3; `0` observes stop
27
+ * hooks but never continues; `null` disables the cap. Layers narrow by min like every other
28
+ * policy axis, and the cap ends the run with `stopReason: "hook_limit"` (no limit breach).
29
+ */
30
+ readonly maxStopContinuations?: number | null;
25
31
  }
26
32
  /** Fully resolved limits after `resolveRunLimits`: every policy axis is a finite cap or `null` (disabled). */
27
33
  export interface ResolvedRunLimits {
@@ -39,8 +45,11 @@ export interface ResolvedRunLimits {
39
45
  readonly amount: number;
40
46
  readonly currency: string;
41
47
  };
48
+ /** Stop-hook continuation cap (plan 106 R1); not a run-limit counter axis. */
49
+ readonly maxStopContinuations: number | null;
42
50
  }
43
- export type RunLimitName = keyof Required<RunLimits>;
51
+ /** Counter-backed limit axes; `maxStopContinuations` caps a clean stop instead of a breach. */
52
+ export type RunLimitName = Exclude<keyof Required<RunLimits>, "maxStopContinuations">;
44
53
  export interface RunLimitCounters {
45
54
  readonly turns: number;
46
55
  readonly providerAttempts: number;
@@ -30,7 +30,8 @@ export interface SessionStore {
30
30
  get?(id: string): Promise<SessionEntry | undefined>;
31
31
  /** DB-friendly branch read: return one branch's ancestor chain as a page so adapters
32
32
  * avoid `list(sessionId)` (full-session scan) + in-memory rebuild. Optional — the
33
- * built-in memory/JSONL stores omit it and the runtime falls back to `list()`. */
33
+ * built-in JSONL store omits it and the runtime falls back to `list()`. The memory
34
+ * store implements it. */
34
35
  readBranchPath?(query: SessionBranchRead): Promise<PersistencePage<SessionEntry>>;
35
36
  /**
36
37
  * Optional bounded session search. Prefer implementing this **or** returning a companion
@@ -1,6 +1,6 @@
1
- import type { AgentLoopOptions, AgentLoopStrategy, ArtifactValidation, BudgetAxisUsage, BudgetConsumedCounters, CompactionOptions, ContentBlock, ErrorInfo, GuardrailRecord, Guardrails, InstructionInjector, JsonObject, Message, ModelConfig, OwnershipScope, ProviderRequestOptions, ProviderRequestPolicy, ProviderResolver, ProviderStopReason, RetryOptions, RunLimitBreach, RunLimitName, RunLimits, Skill, SubscriberOverflowPolicy, SystemPromptConfig, ToolCallAuthority, ToolCallContent, ToolCallSummary, TurnBudgets, TurnPolicyOptions, Usage } from "./contracts-core.js";
2
1
  import type { CacheUsageReport } from "./cache-helpers.js";
3
2
  import type { CheckpointRestoreAudit } from "./checkpoint-restore.js";
3
+ import type { AgentLoopOptions, AgentLoopStrategy, ArtifactValidation, BudgetAxisUsage, BudgetConsumedCounters, CompactionOptions, ContentBlock, ErrorInfo, GuardrailRecord, Guardrails, InstructionInjector, JsonObject, Message, ModelConfig, OwnershipScope, ProviderRequestOptions, ProviderRequestPolicy, ProviderResolver, ProviderStopReason, RetryOptions, RunLimitBreach, RunLimitName, RunLimits, Skill, StopHook, SubscriberOverflowPolicy, SystemPromptConfig, ToolCallAuthority, ToolCallContent, ToolCallSummary, TurnBudgets, TurnPolicyOptions, Usage } from "./contracts-core.js";
4
4
  import type { AgentRunInterruption, AgentRunStateOptions } from "./contracts-run-state.js";
5
5
  import type { SecretRedactor } from "./redaction.js";
6
6
  import type { ToolValidator } from "./tools.js";
@@ -137,6 +137,8 @@ export interface RunOptions {
137
137
  * evaluated before every provider request. Omitted → no callback runs.
138
138
  */
139
139
  readonly turnPolicy?: TurnPolicyOptions;
140
+ /** Appended to agent-level stop hooks for this run (plan 106 R1). */
141
+ readonly stopHooks?: readonly StopHook[];
140
142
  }
141
143
  export interface ProviderTurnMetadata {
142
144
  readonly providerId: string;
@@ -193,8 +195,8 @@ export interface DelegatedAgentStep {
193
195
  readonly label?: string;
194
196
  };
195
197
  }
196
- /** Why a run stopped cleanly. `host_policy` is a `RunOptions.turnPolicy` stop; the rest are loop ceilings (F4). */
197
- export type AgentFinishReason = "turn_limit" | "token_limit" | "refusal" | "host_policy";
198
+ /** Why a run stopped cleanly. `host_policy` is a `RunOptions.turnPolicy` stop, `hook_limit` a stop-hook continuation cap; the rest are loop ceilings (F4). */
199
+ export type AgentFinishReason = "turn_limit" | "token_limit" | "refusal" | "host_policy" | "hook_limit";
198
200
  /**
199
201
  * Origin of an agent event forwarded from a delegated child (supervisor child-event passthrough).
200
202
  * Present only on child events routed onto a parent stream; absent on a session's own events.
@@ -215,7 +217,7 @@ type AgentEventPayload = {
215
217
  readonly sessionId: string;
216
218
  readonly runId: string;
217
219
  readonly usage?: Usage;
218
- /** Why the loop stopped, when a limit/ceiling or a host turn policy ended the run cleanly (F4). Absent = natural end. */
220
+ /** Why the loop stopped, when a limit/ceiling, a host turn policy, or a stop-hook continuation cap ended the run cleanly (F4). Absent = natural end. */
219
221
  readonly finishReason?: AgentFinishReason;
220
222
  /** Host stop detail from `TurnPolicyOptions.stop` (≤256 bytes, redacted). Present only with `finishReason: "host_policy"`. */
221
223
  readonly stopDetail?: string;
@@ -1,6 +1,7 @@
1
- import type { AgentSessionCloneOptions, AgentSessionForkOptions, CheckpointRecord, CheckpointStore, CompactionOptions, CompactionResult, ContentBlock, ContextMeter, ErrorInfo, JsonObject, JsonValue, Message, ModelConfig, OwnershipScope, RunLimitBreach, SessionEntry, SubscribeOptions, ToolCallContent, Usage } from "./contracts-core.js";
1
+ import type { CheckpointRestoreHandler } from "./checkpoint-restore.js";
2
+ import type { AgentSessionCloneOptions, AgentSessionForkOptions, CheckpointRecord, CheckpointStore, CompactionOptions, CompactionResult, ContentBlock, ContextMeter, ErrorInfo, GuardrailPackRef, JsonObject, JsonValue, Message, ModelConfig, OwnershipScope, RunLimitBreach, SessionEntry, SubscribeOptions, ToolCallContent, Usage } from "./contracts-core.js";
2
3
  import type { AgentEvent, AgentFinishReason, RunOptions, ToolEffectKind } from "./contracts-protocol.js";
3
- import type { CheckpointRestoreHook } from "./checkpoint-restore.js";
4
+ import type { BudgetExhaustionAttribution } from "./run-limits.js";
4
5
  export type AgentRunStatus = "succeeded" | "failed" | "aborted" | "suspended" | "denied";
5
6
  export type AgentRunInterruptionKind = "input_guardrail" | "tool_approval" | "elicitation";
6
7
  export type ApprovalOutcome = "allow_once" | "allow_for_run" | "reject_once" | "reject_for_run";
@@ -27,6 +28,16 @@ export interface PendingDecision {
27
28
  readonly reason: string;
28
29
  /** Typed payload contract for elicitation decisions. */
29
30
  readonly elicitationSchema?: JsonObject;
31
+ /**
32
+ * Plan 104 T3: pack `ask` rule that gated this call as `pack:<pack>/<rule>` (bounded by the
33
+ * compile-time id limits), present only when a pack rule raised the decision.
34
+ */
35
+ readonly guardrail?: string;
36
+ /** Plan 104 T3: machine-readable ids behind `guardrail`, so a host never parses the name. */
37
+ readonly guardrailRule?: {
38
+ readonly pack: string;
39
+ readonly rule: string;
40
+ };
30
41
  /** Delegation chain, root-first; core-written, never client-supplied. */
31
42
  readonly attribution?: {
32
43
  readonly path: readonly string[];
@@ -38,6 +49,8 @@ export interface AgentRunInterruption {
38
49
  readonly reason: string;
39
50
  readonly toolCallId?: string;
40
51
  readonly toolName?: string;
52
+ /** Plan 104 T3: the pack rule that raised this suspension (`pack:<pack>/<rule>`), when one did. */
53
+ readonly guardrail?: string;
41
54
  /** All unresolved approval requests of this suspension; absent for legacy single approvals. */
42
55
  readonly pendingDecisions?: readonly PendingDecision[];
43
56
  }
@@ -235,8 +248,8 @@ export interface AgentCheckpointRestoreContext {
235
248
  readonly metadata?: AgentRunCheckpointMetadata;
236
249
  readonly checkpoint: CheckpointRecord;
237
250
  }
238
- /** Host code restoring one external layer before a durable resume applies. */
239
- export type AgentCheckpointRestoreHook = CheckpointRestoreHook<AgentCheckpointRestoreContext>;
251
+ /** Host code restoring or compensating one external layer before a durable resume applies. */
252
+ export type AgentCheckpointRestoreHook = CheckpointRestoreHandler<AgentCheckpointRestoreContext>;
240
253
  export interface AgentRunResumeOptions {
241
254
  readonly checkpoints: CheckpointStore;
242
255
  /** Current host-authored revision; must exactly match the checkpoint. */
@@ -266,7 +279,9 @@ export interface AgentRunResumeOptions {
266
279
  * Plan 094 Task 3: external-state restore hooks. Every hook must succeed (sequentially, each
267
280
  * within `restoreHookTimeoutMs`) before the claim write and the conversation restore apply;
268
281
  * the first failure throws `CheckpointRestoreError` naming the hook and leaves the checkpoint
269
- * suspended. Hosts that register hooks on the lifecycle instead pass them once there.
282
+ * suspended. Plan 109 Task 2: an entry may be `{ id?, restore, compensate? }`, and on failure the
283
+ * applied layers are compensated in reverse order (best-effort) before the throw. Hosts that
284
+ * register hooks on the lifecycle instead pass them once there.
270
285
  */
271
286
  readonly restoreHooks?: readonly AgentCheckpointRestoreHook[];
272
287
  /** Per-hook restore ceiling in ms; defaults to `DEFAULT_CHECKPOINT_RESTORE_TIMEOUT_MS`. */
@@ -313,6 +328,11 @@ export interface AgentRunResult {
313
328
  readonly usage?: Usage;
314
329
  /** Present when the run hit a configured resource ceiling. */
315
330
  readonly limit?: RunLimitBreach;
331
+ /**
332
+ * Present with `limit` when the run died on a ceiling: the same plan-087 attribution payload the
333
+ * `budget_exhausted` event carries (`limit` stays on {@link AgentRunResult.limit}).
334
+ */
335
+ readonly attribution?: BudgetExhaustionAttribution;
316
336
  /** Present when `status` is `"failed"` or when a failed attempt still produced partial output. */
317
337
  readonly error?: ErrorInfo;
318
338
  /** String form of the abort reason when `status` is `"aborted"`. */
@@ -350,6 +370,12 @@ export interface SteerOptions {
350
370
  }
351
371
  export interface AgentSession {
352
372
  readonly id: string;
373
+ /**
374
+ * Plan 104 Task 2: guardrail pack refs this session enforces (the restored rows after a durable
375
+ * resume); `undefined` when it enforces none. Pass to `snapshotRunBundle({ packs })` for the
376
+ * recorded identity that matches enforcement.
377
+ */
378
+ readonly guardrailPackRefs?: readonly GuardrailPackRef[];
353
379
  /** Current branch leaf entry id; advances on every append/run and is re-pointed by `checkout`.
354
380
  * Undefined until the first entry lands (a fresh session with no history). */
355
381
  readonly leafId: string | undefined;
@@ -361,10 +387,26 @@ export interface AgentSession {
361
387
  * Fails closed when no run is active or the pending queue exceeds caps.
362
388
  */
363
389
  steer(input: string | Message | readonly Message[], options?: SteerOptions): void;
364
- /** Subscribe first, then start exactly one run and yield only that run's events until it terminates. */
390
+ /**
391
+ * Subscribe first, then start exactly one run and yield only that run's events until it terminates.
392
+ * This subscription belongs to `stream()`: it is closed when the owned run settles (so a pre-flight
393
+ * rejection unblocks the consumer instead of parking it behind a run that never emits).
394
+ */
365
395
  stream(input: string | Message | readonly Message[], options?: RunOptions & SubscribeOptions): AsyncIterable<AgentEvent>;
366
396
  compact(options?: CompactionOptions): Promise<CompactionResult>;
397
+ /**
398
+ * Subscribe to this session's live events. A run-scoped subscriber (default) is closed when the run
399
+ * ends, suspends, or is denied; `SubscribeOptions.acrossRuns: true` keeps it open across runs until
400
+ * `subscription.close()`, `closeSubscribers()`, or a queue overflow closes it.
401
+ */
367
402
  subscribe(options?: SubscribeOptions): AsyncIterable<AgentEvent>;
403
+ /**
404
+ * Plan 106 R2: session teardown. Dispatches `session_shutdown` middleware once (idempotent), then
405
+ * closes every subscriber (`acrossRuns` included); calling it twice is a no-op. Call after the
406
+ * active run settles. Subscriber-only teardown stays `break`ing the `for await` (or calling the
407
+ * iterator's `return()`). See [Agent/session runtime](agent-session-runtime.md).
408
+ */
409
+ close(): Promise<void>;
368
410
  abort(reason?: unknown): void;
369
411
  entries(): Promise<readonly SessionEntry[]>;
370
412
  checkout(leafId?: string): Promise<void>;
@@ -1,4 +1,4 @@
1
- import type { AgentDefinition, AuthMethod, CommandDefinition, CompactionStrategy, ContextProvider, CredentialResolver, DiscoveredContribution, InputBuilder, InstructionInjector, PromptBuilder, ProviderPackage, ProviderRequestPolicy, ResourceLoader, RetryPolicy, SettingsProvider, Skill, StoreFactory, SystemPromptContribution, ToolDefinition } from "./contracts.js";
1
+ import type { AgentDefinition, AuthMethod, CommandDefinition, CompactionStrategy, ContextProvider, CredentialResolver, DiscoveredContribution, InputBuilder, InstructionInjector, PromptBuilder, ProviderPackage, ProviderRequestPolicy, ResourceLoader, RetryPolicy, SettingsProvider, Skill, StopHook, StoreFactory, SystemPromptContribution, ToolDefinition } from "./contracts.js";
2
2
  import { type ModelRegistry } from "./models.js";
3
3
  import { type ProviderRegistry } from "./providers.js";
4
4
  import { type DuplicateRegistrationOptions } from "./registry-options.js";
@@ -35,6 +35,7 @@ export interface ContributionRegistries {
35
35
  readonly providerRequestPolicies: ContributionRegistry<ProviderRequestPolicy>;
36
36
  readonly systemPromptContributions: ContributionRegistry<SystemPromptContribution>;
37
37
  readonly instructionInjectors: ContributionRegistry<InstructionInjector>;
38
+ readonly stopHooks: ContributionRegistry<StopHook>;
38
39
  }
39
40
  export interface ContributionRegistriesOptions extends DuplicateRegistrationOptions {
40
41
  }
@@ -49,6 +49,7 @@ export function createContributionRegistries(options = {}) {
49
49
  providerRequestPolicies: createContributionRegistry(registryOptions("provider request policy")),
50
50
  systemPromptContributions: createContributionRegistry(registryOptions("system prompt contribution")),
51
51
  instructionInjectors: createContributionRegistry(registryOptions("instruction injector")),
52
+ stopHooks: createContributionRegistry(registryOptions("stop hook")),
52
53
  };
53
54
  }
54
55
  /** Register discovered contributions into the given registries. Inert: skill