@arnilo/prism 0.9.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 (75) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/README.md +13 -12
  3. package/dist/agent-approval.d.ts +7 -1
  4. package/dist/agent-approval.js +15 -6
  5. package/dist/agent-run-lifecycle.js +19 -5
  6. package/dist/agent-run-state.d.ts +26 -5
  7. package/dist/agent-run-state.js +97 -1
  8. package/dist/agent-session/event-subscriber.d.ts +2 -0
  9. package/dist/agent-session/event-subscriber.js +3 -0
  10. package/dist/agent-session/session/assemble.js +156 -9
  11. package/dist/agent-session/session/persist.js +11 -5
  12. package/dist/agent-session/session/provider-round.js +54 -13
  13. package/dist/agent-session/session/tool-round.d.ts +2 -2
  14. package/dist/agent-session/session/tool-round.js +58 -5
  15. package/dist/agent-session/session/types.d.ts +20 -2
  16. package/dist/agent-session/session.d.ts +65 -4
  17. package/dist/agent-session/session.js +156 -16
  18. package/dist/context-budget.d.ts +11 -0
  19. package/dist/context-budget.js +33 -2
  20. package/dist/contracts-core/agent.d.ts +26 -5
  21. package/dist/contracts-core/extensions.d.ts +3 -0
  22. package/dist/contracts-core/guardrail-packs.d.ts +8 -3
  23. package/dist/contracts-core/loop.d.ts +36 -0
  24. package/dist/contracts-core/provider.d.ts +6 -1
  25. package/dist/contracts-core/run-limits.d.ts +10 -1
  26. package/dist/contracts-protocol.d.ts +6 -4
  27. package/dist/contracts-run-state.d.ts +37 -3
  28. package/dist/contributions.d.ts +2 -1
  29. package/dist/contributions.js +1 -0
  30. package/dist/extensions.d.ts +15 -1
  31. package/dist/extensions.js +68 -0
  32. package/dist/guardrail-packs/types.d.ts +10 -0
  33. package/dist/guardrail-packs/validation-respect.js +16 -0
  34. package/dist/guardrails.d.ts +42 -1
  35. package/dist/guardrails.js +124 -15
  36. package/dist/index.d.ts +6 -6
  37. package/dist/index.js +4 -4
  38. package/dist/middleware.d.ts +1 -1
  39. package/dist/run-bundle.d.ts +6 -1
  40. package/dist/run-bundle.js +4 -1
  41. package/dist/run-limits.js +13 -0
  42. package/dist/testing/prefix-stability-conformance.d.ts +29 -0
  43. package/dist/testing/prefix-stability-conformance.js +91 -23
  44. package/dist/tools.js +10 -3
  45. package/docs/agent-events.md +12 -8
  46. package/docs/agent-session-runtime.md +9 -6
  47. package/docs/caveman.md +1 -1
  48. package/docs/compaction-llm.md +2 -0
  49. package/docs/compaction-observational-memory.md +21 -1
  50. package/docs/durable-runs.md +4 -3
  51. package/docs/embeddings.md +5 -1
  52. package/docs/execution-timeline.md +3 -2
  53. package/docs/extensions.md +20 -3
  54. package/docs/guardrails.md +16 -6
  55. package/docs/hooks.md +282 -0
  56. package/docs/index.md +18 -15
  57. package/docs/input-and-prompt-assembly.md +1 -1
  58. package/docs/instruction-injection.md +1 -0
  59. package/docs/live-testing.md +3 -1
  60. package/docs/memory-fabric.md +28 -0
  61. package/docs/middleware-hooks.md +54 -4
  62. package/docs/migration.md +13 -0
  63. package/docs/options-index.md +3 -1
  64. package/docs/policy-and-audit.md +14 -1
  65. package/docs/prefix-stability-conformance.md +57 -7
  66. package/docs/provider-packages.md +20 -20
  67. package/docs/public-contracts.md +1 -0
  68. package/docs/rag.md +93 -6
  69. package/docs/release-and-install.md +42 -39
  70. package/docs/runs-and-usage.md +17 -8
  71. package/docs/scoped-agent-memory.md +17 -9
  72. package/docs/scoped-memory.md +138 -0
  73. package/docs/tools.md +1 -1
  74. package/docs/wiki.md +4 -2
  75. package/package.json +4 -2
@@ -1,8 +1,8 @@
1
1
  /** session (0.2.5 plan 025 Task 1 split). Moved verbatim from agent-session.ts; public surface unchanged behind the barrel. */
2
2
  import { ActiveDurableRun, ActiveDurableRunExtras } from "../agent-approval.js";
3
- import type { PendingToolCall, StoredAgentRunState } from "../agent-run-state.js";
3
+ import type { PendingToolCall, PersistedGuardrailPacks, StoredAgentRunState } from "../agent-run-state.js";
4
4
  import { type AttentionFoldLedger, type AttentionStickyFrontier, type PersistedAttentionFoldLedger, type PersistedAttentionStickyFrontier } from "../attention-compiler.js";
5
- import type { Agent, AgentEvent, AgentRunResult, AgentRunState, AgentRunStateOptions, AgentSession, AgentSessionConfig, AIProvider, CompactionOptions, CompactionResult, ContextMeter, ErrorInfo, Guardrails, Message, OwnershipScope, PendingDecision, PromptVersionRef, ProviderRequest, RunDecision, RunOptions, SessionEntry, Skill, SteerOptions, SubscribeOptions, ToolCallSummary, ToolDefinition, ToolEffectStore, Usage } from "../contracts.js";
5
+ import type { Agent, AgentEvent, AgentRunResult, AgentRunState, AgentRunStateOptions, AgentSession, AgentSessionConfig, AIProvider, CompactionOptions, CompactionResult, ContextMeter, ErrorInfo, GuardrailPackRef, Guardrails, Message, OwnershipScope, PendingDecision, PromptVersionRef, ProviderRequest, RunDecision, RunOptions, SessionEntry, Skill, SteerOptions, SubscribeOptions, ToolCallSummary, ToolDefinition, ToolEffectStore, Usage } from "../contracts.js";
6
6
  import type { AgentIdentity } from "../identity.js";
7
7
  import type { AgentInput } from "../input.js";
8
8
  import type { RunLimitTracker } from "../run-limits.js";
@@ -14,6 +14,10 @@ export declare class RuntimeAgentSession implements AgentSession {
14
14
  private readonly metadata?;
15
15
  private readonly store;
16
16
  private readonly subscribers;
17
+ /** Plan 106 R2: `session_start` is dispatched at the first run start, once per runtime session. */
18
+ private sessionOpened;
19
+ /** Plan 106 R2: `close()` dispatches `session_shutdown` and closes subscribers exactly once. */
20
+ private closed;
17
21
  private currentLeafId?;
18
22
  private history;
19
23
  private activeRun?;
@@ -31,9 +35,15 @@ export declare class RuntimeAgentSession implements AgentSession {
31
35
  private activeIdempotencyKey?;
32
36
  private activeGuardrails?;
33
37
  /** Plan 092 Task 2: guardrail packs compiled once in the constructor; merged into every run's `activeGuardrails`. */
34
- readonly packGuardrails?: Guardrails;
38
+ packGuardrails?: Guardrails;
39
+ /** Plan 104 Task 3: `ask` rules as the durable charge-time gate (a match records `interrupt`). */
40
+ packAskGate?: Guardrails;
41
+ /** Plan 104 Task 3: the same `ask` rules as plain blocks for a run that cannot suspend. */
42
+ packAskBlocks?: Guardrails;
35
43
  /** Original pack refs, carried into `fork()`/`clone()` so a branch cannot silently lose its policy. */
36
- private readonly guardrailPackRefs?;
44
+ private packRefs?;
45
+ /** Plan 104 Task 2: compiled refs + live pack state, replaced by `restoreGuardrailPacks` on resume. */
46
+ private compiledGuardrailPacks?;
37
47
  activeMetadata?: Readonly<Record<string, unknown>>;
38
48
  activePromptVersion?: PromptVersionRef;
39
49
  activeLimits?: RunLimitTracker;
@@ -95,11 +105,28 @@ export declare class RuntimeAgentSession implements AgentSession {
95
105
  clearActivatedTools(): void;
96
106
  /** Plan 018 Task 6: restore persisted loaded-skill bodies (already validated fail-closed at load). */
97
107
  restoreLoadedSkillBodies(bodies: readonly LoadedSkillBodiesEntry[]): void;
108
+ /** Plan 104 T2: the refs this session actually enforces (restored ones after a resume). */
109
+ get guardrailPackRefs(): readonly GuardrailPackRef[] | undefined;
110
+ /** Plan 104 T2: pack refs + live pack-owned state for a durable checkpoint (opt-in with `persistSessionState`). */
111
+ serializedGuardrailPackState(): PersistedGuardrailPacks | undefined;
112
+ /**
113
+ * Plan 104 T2: recompile checkpoint packs before the resumed run's first turn. `state` present
114
+ * (even empty) marks a restore, so unknown ids, version mismatches, and codec-less state fail
115
+ * closed as `AgentRunStateError` — never a session that silently enforces less than it did.
116
+ */
117
+ restoreGuardrailPacks(refs: readonly GuardrailPackRef[], state?: Readonly<Record<string, unknown>>): void;
98
118
  private ledgerChain;
99
119
  private ledgerFailure;
100
120
  private snapshotGeneration;
101
121
  private snapshotCache?;
102
122
  private readonly snapshotCacheTtlMs;
123
+ /**
124
+ * Plan 103 T4: identity-keyed meter cache. Holds only the last public meter value
125
+ * plus the identity of everything the cold read consumed (`snapshotGeneration`,
126
+ * leaf, active meter/limits, and the history array reference + length, which catches
127
+ * in-place `history.push` during a run) — never history content, never an estimator.
128
+ */
129
+ private meterCache?;
103
130
  constructor(config: AgentSessionConfig & {
104
131
  readonly agent: Agent;
105
132
  });
@@ -111,15 +138,44 @@ export declare class RuntimeAgentSession implements AgentSession {
111
138
  * `provider_turn_finished.budgets` resolves them. Before any provider turn in
112
139
  * this session it estimates stored history, so a non-reporting model still
113
140
  * shows a working meter instead of zero. Never billing; estimates are labeled.
141
+ *
142
+ * Plan 103 T4: reads are cached until the history generation, leaf, history
143
+ * length, or active-run identity changes, so a per-frame poll pays one estimate
144
+ * per mutation instead of one per read. The cached value is frozen and is
145
+ * identical (`===`) to the previous read while nothing changed.
114
146
  */
115
147
  contextMeter(): ContextMeter;
148
+ /** Cold path of `contextMeter()`: one estimate over stored history plus cap/budget resolution. */
149
+ private measureContextMeter;
150
+ /**
151
+ * Live events for this session. A run-scoped subscriber (the default) is closed when the run ends,
152
+ * suspends, or is denied; `SubscribeOptions.acrossRuns: true` keeps one subscriber open across runs
153
+ * of the same session until the host closes it, the session tears it down, or its bounded queue
154
+ * overflows under the default policy. Subscribe before `run()`; the consumer loop and `run()` must
155
+ * run concurrently, since events are only emitted during a live run.
156
+ */
116
157
  subscribe(options?: SubscribeOptions): AsyncIterable<AgentEvent>;
158
+ private createSubscriber;
117
159
  run(input: AgentInput, options?: RunOptions): Promise<AgentRunResult>;
118
160
  steer(input: AgentInput, options?: SteerOptions): void;
119
161
  resumeDurable(state: StoredAgentRunState, runState: AgentRunStateOptions, ownership?: OwnershipScope, signal?: AbortSignal, decisions?: ReadonlyMap<string, RunDecision>, extras?: ActiveDurableRunExtras): Promise<AgentRunResult>;
120
162
  recordDurableResumption(runId: string, interruption: import("../contracts.js").AgentRunInterruption, version: number, ownership?: OwnershipScope): Promise<void>;
121
163
  recordDurableDenial(runId: string, interruption: import("../contracts.js").AgentRunInterruption, version: number, ownership?: OwnershipScope): Promise<void>;
122
164
  private runInternal;
165
+ /**
166
+ * Plan 106 R2: dispatch `session_start` once per session, at its first run start (including the
167
+ * first run of a session rebuilt from a durable checkpoint). The run assembler awaits it right
168
+ * after `agent_started`/`agent_resumed`, so session-scoped provisioning is done before the first
169
+ * turn while the runtime's synchronous emit burst stays intact. Middleware error policy decides
170
+ * whether a failure surfaces or becomes an `extension_error` event.
171
+ */
172
+ openSession(runId: string): Promise<void>;
173
+ /**
174
+ * Plan 106 R2: session teardown. Dispatches `session_shutdown` middleware once (idempotent) and
175
+ * then closes every subscriber, run-scoped and `acrossRuns` alike. Call it after the active run
176
+ * settles; `closeSubscribers()` remains the subscriber-only seam.
177
+ */
178
+ close(): Promise<void>;
123
179
  prompt(input: string, options?: RunOptions): Promise<AgentRunResult>;
124
180
  stream(input: AgentInput, options?: RunOptions & SubscribeOptions): AsyncGenerator<AgentEvent>;
125
181
  buildRunResult(input: {
@@ -149,6 +205,11 @@ export declare class RuntimeAgentSession implements AgentSession {
149
205
  resolveRunProvider(options: RunOptions): void;
150
206
  resolveRunSkills(options: RunOptions, tools: readonly ToolDefinition[]): readonly Skill[];
151
207
  emit(event: AgentEvent): void;
208
+ /**
209
+ * Run end (finish, suspension, or denial): closes the run-scoped subscribers only. Subscribers that
210
+ * opted into `SubscribeOptions.acrossRuns` stay open for the next run of this session.
211
+ */
212
+ closeRunSubscribers(): void;
152
213
  closeSubscribers(): void;
153
214
  drainLedger(): Promise<void>;
154
215
  applyPendingSteers(runId: string, metadata: Readonly<Record<string, unknown>>, signal: AbortSignal): Promise<boolean>;
@@ -3,8 +3,8 @@ import { policyList } from "../agent-tool-dispatch.js";
3
3
  import { createAttentionFoldLedger, createAttentionStickyFrontier, resolveInputCap, restoreAttentionStickyFrontier, serializeAttentionFoldLedger, serializeAttentionStickyFrontier, } from "../attention-compiler.js";
4
4
  import { createDefaultCompactionStrategy, isCompactionEntryData } from "../compaction.js";
5
5
  import { estimateAssemblyTokens, estimateMessageTokens, estimateTextTokens } from "../context-budget.js";
6
- import { DEFAULT_MAX_PENDING_STEER_BYTES, DEFAULT_MAX_PENDING_STEERS, DEFAULT_SNAPSHOT_CACHE_TTL_MS, HARD_MAX_SNAPSHOT_CACHE_TTL_MS, resolveShouldCompact, } from "../contracts.js";
7
- import { compileGuardrailPacks, GuardrailError, runGuardrails } from "../guardrails.js";
6
+ import { AgentRunStateError, DEFAULT_MAX_PENDING_STEER_BYTES, DEFAULT_MAX_PENDING_STEERS, DEFAULT_SNAPSHOT_CACHE_TTL_MS, HARD_MAX_SNAPSHOT_CACHE_TTL_MS, resolveShouldCompact, } from "../contracts.js";
7
+ import { compileGuardrailPacksWithState, GuardrailError, GuardrailPackError, runGuardrails } from "../guardrails.js";
8
8
  import { applyDefaultProviderRequestOptions, createProviderRequestPolicyChain, normalizeProviderRequestPolicyResult, } from "../provider-request-policy.js";
9
9
  import { redactAgentEvent, redactProviderRequest, redactRunLedgerRecord, redactSecrets, redactSessionEntry } from "../redaction.js";
10
10
  import { createMemorySessionStore, createSessionEntry, getSessionBranchEntries, rebuildSessionContext } from "../session-stores.js";
@@ -23,6 +23,10 @@ export class RuntimeAgentSession {
23
23
  metadata;
24
24
  store;
25
25
  subscribers = new Set();
26
+ /** Plan 106 R2: `session_start` is dispatched at the first run start, once per runtime session. */
27
+ sessionOpened = false;
28
+ /** Plan 106 R2: `close()` dispatches `session_shutdown` and closes subscribers exactly once. */
29
+ closed = false;
26
30
  currentLeafId;
27
31
  history = [];
28
32
  activeRun;
@@ -41,8 +45,14 @@ export class RuntimeAgentSession {
41
45
  activeGuardrails;
42
46
  /** Plan 092 Task 2: guardrail packs compiled once in the constructor; merged into every run's `activeGuardrails`. */
43
47
  packGuardrails;
48
+ /** Plan 104 Task 3: `ask` rules as the durable charge-time gate (a match records `interrupt`). */
49
+ packAskGate;
50
+ /** Plan 104 Task 3: the same `ask` rules as plain blocks for a run that cannot suspend. */
51
+ packAskBlocks;
44
52
  /** Original pack refs, carried into `fork()`/`clone()` so a branch cannot silently lose its policy. */
45
- guardrailPackRefs;
53
+ packRefs;
54
+ /** Plan 104 Task 2: compiled refs + live pack state, replaced by `restoreGuardrailPacks` on resume. */
55
+ compiledGuardrailPacks;
46
56
  activeMetadata;
47
57
  activePromptVersion;
48
58
  activeLimits;
@@ -127,11 +137,51 @@ export class RuntimeAgentSession {
127
137
  for (const entry of bodies)
128
138
  this.loadedSkills.add(entry.name);
129
139
  }
140
+ /** Plan 104 T2: the refs this session actually enforces (restored ones after a resume). */
141
+ get guardrailPackRefs() {
142
+ return this.packRefs;
143
+ }
144
+ /** Plan 104 T2: pack refs + live pack-owned state for a durable checkpoint (opt-in with `persistSessionState`). */
145
+ serializedGuardrailPackState() {
146
+ const compiled = this.compiledGuardrailPacks;
147
+ if (!compiled || compiled.packs.length === 0)
148
+ return undefined;
149
+ const state = compiled.snapshotState();
150
+ return { packs: compiled.packs, ...(state ? { state } : {}) };
151
+ }
152
+ /**
153
+ * Plan 104 T2: recompile checkpoint packs before the resumed run's first turn. `state` present
154
+ * (even empty) marks a restore, so unknown ids, version mismatches, and codec-less state fail
155
+ * closed as `AgentRunStateError` — never a session that silently enforces less than it did.
156
+ */
157
+ restoreGuardrailPacks(refs, state) {
158
+ let compiled;
159
+ try {
160
+ compiled = compileGuardrailPacksWithState(refs, undefined, state ?? {});
161
+ }
162
+ catch (error) {
163
+ if (error instanceof GuardrailPackError)
164
+ throw new AgentRunStateError(`Cannot restore guardrail packs: ${error.message}`);
165
+ throw error;
166
+ }
167
+ this.compiledGuardrailPacks = compiled;
168
+ this.packGuardrails = compiled.guardrails;
169
+ this.packAskGate = compiled.askGate;
170
+ this.packAskBlocks = compiled.askBlocks;
171
+ this.packRefs = refs;
172
+ }
130
173
  ledgerChain = Promise.resolve();
131
174
  ledgerFailure;
132
175
  snapshotGeneration = 0;
133
176
  snapshotCache;
134
177
  snapshotCacheTtlMs;
178
+ /**
179
+ * Plan 103 T4: identity-keyed meter cache. Holds only the last public meter value
180
+ * plus the identity of everything the cold read consumed (`snapshotGeneration`,
181
+ * leaf, active meter/limits, and the history array reference + length, which catches
182
+ * in-place `history.push` during a run) — never history content, never an estimator.
183
+ */
184
+ meterCache;
135
185
  constructor(config) {
136
186
  this.id = config.id ?? randomId("session");
137
187
  this.agent = config.agent;
@@ -139,11 +189,14 @@ export class RuntimeAgentSession {
139
189
  this.store = config.store ?? config.agent.config.store ?? createMemorySessionStore();
140
190
  this.currentLeafId = config.leafId;
141
191
  this.snapshotCacheTtlMs = resolveSnapshotCacheTtlMs(config.snapshotCacheTtlMs);
142
- this.packGuardrails = compileGuardrailPacks(config.guardrailPacks);
143
- this.guardrailPackRefs = config.guardrailPacks;
192
+ this.compiledGuardrailPacks = compileGuardrailPacksWithState(config.guardrailPacks);
193
+ this.packGuardrails = this.compiledGuardrailPacks.guardrails;
194
+ this.packAskGate = this.compiledGuardrailPacks.askGate;
195
+ this.packAskBlocks = this.compiledGuardrailPacks.askBlocks;
196
+ this.packRefs = config.guardrailPacks;
144
197
  const usageEstimation = config.agent.config.usageEstimation;
145
- if (usageEstimation !== undefined && usageEstimation !== "fallback" && usageEstimation !== "off") {
146
- throw new TypeError('usageEstimation must be "fallback" or "off"');
198
+ if (usageEstimation !== undefined && usageEstimation !== "fallback" && usageEstimation !== "off" && usageEstimation !== "strict") {
199
+ throw new TypeError('usageEstimation must be "fallback", "off", or "strict"');
147
200
  }
148
201
  }
149
202
  get leafId() {
@@ -156,8 +209,37 @@ export class RuntimeAgentSession {
156
209
  * `provider_turn_finished.budgets` resolves them. Before any provider turn in
157
210
  * this session it estimates stored history, so a non-reporting model still
158
211
  * shows a working meter instead of zero. Never billing; estimates are labeled.
212
+ *
213
+ * Plan 103 T4: reads are cached until the history generation, leaf, history
214
+ * length, or active-run identity changes, so a per-frame poll pays one estimate
215
+ * per mutation instead of one per read. The cached value is frozen and is
216
+ * identical (`===`) to the previous read while nothing changed.
159
217
  */
160
218
  contextMeter() {
219
+ const cached = this.meterCache;
220
+ if (cached &&
221
+ cached.leafId === this.currentLeafId &&
222
+ cached.generation === this.snapshotGeneration &&
223
+ cached.meter === this.activeInputMeter &&
224
+ cached.limits === this.activeLimits &&
225
+ cached.history === this.history &&
226
+ cached.historyLength === this.history.length) {
227
+ return cached.value;
228
+ }
229
+ const value = Object.freeze(this.measureContextMeter());
230
+ this.meterCache = {
231
+ leafId: this.currentLeafId,
232
+ generation: this.snapshotGeneration,
233
+ meter: this.activeInputMeter,
234
+ limits: this.activeLimits,
235
+ history: this.history,
236
+ historyLength: this.history.length,
237
+ value,
238
+ };
239
+ return value;
240
+ }
241
+ /** Cold path of `contextMeter()`: one estimate over stored history plus cap/budget resolution. */
242
+ measureContextMeter() {
161
243
  const model = this.agent.config.model;
162
244
  const inputTokens = this.activeInputMeter?.tokens ?? estimateMessageTokens(this.history, model.model).tokens;
163
245
  const source = this.activeInputMeter?.source ?? "estimated";
@@ -179,7 +261,17 @@ export class RuntimeAgentSession {
179
261
  ...(inputCap === undefined ? {} : { usedRatio: inputTokens / inputCap }),
180
262
  };
181
263
  }
264
+ /**
265
+ * Live events for this session. A run-scoped subscriber (the default) is closed when the run ends,
266
+ * suspends, or is denied; `SubscribeOptions.acrossRuns: true` keeps one subscriber open across runs
267
+ * of the same session until the host closes it, the session tears it down, or its bounded queue
268
+ * overflows under the default policy. Subscribe before `run()`; the consumer loop and `run()` must
269
+ * run concurrently, since events are only emitted during a live run.
270
+ */
182
271
  subscribe(options = {}) {
272
+ return this.createSubscriber(options);
273
+ }
274
+ createSubscriber(options) {
183
275
  const subscriber = new EventSubscriber(this.id, options, () => this.subscribers.delete(subscriber));
184
276
  this.subscribers.add(subscriber);
185
277
  return subscriber;
@@ -233,7 +325,7 @@ export class RuntimeAgentSession {
233
325
  this.activeLedger = undefined;
234
326
  this.activeOwnership = undefined;
235
327
  this.activeRedactor = undefined;
236
- this.closeSubscribers();
328
+ this.closeRunSubscribers();
237
329
  }
238
330
  }
239
331
  async recordDurableDenial(runId, interruption, version, ownership) {
@@ -248,25 +340,58 @@ export class RuntimeAgentSession {
248
340
  this.activeLedger = undefined;
249
341
  this.activeOwnership = undefined;
250
342
  this.activeRedactor = undefined;
251
- this.closeSubscribers();
343
+ this.closeRunSubscribers();
252
344
  }
253
345
  }
254
346
  async runInternal(input, options, runId, resumed) {
255
347
  return executeRun(asSessionHost(this), input, options, runId, resumed);
256
348
  }
349
+ /**
350
+ * Plan 106 R2: dispatch `session_start` once per session, at its first run start (including the
351
+ * first run of a session rebuilt from a durable checkpoint). The run assembler awaits it right
352
+ * after `agent_started`/`agent_resumed`, so session-scoped provisioning is done before the first
353
+ * turn while the runtime's synchronous emit burst stays intact. Middleware error policy decides
354
+ * whether a failure surfaces or becomes an `extension_error` event.
355
+ */
356
+ async openSession(runId) {
357
+ if (this.sessionOpened)
358
+ return;
359
+ this.sessionOpened = true;
360
+ await this.agent.config.middleware?.run("session_start", { sessionId: this.id, runId });
361
+ }
362
+ /**
363
+ * Plan 106 R2: session teardown. Dispatches `session_shutdown` middleware once (idempotent) and
364
+ * then closes every subscriber, run-scoped and `acrossRuns` alike. Call it after the active run
365
+ * settles; `closeSubscribers()` remains the subscriber-only seam.
366
+ */
367
+ async close() {
368
+ if (this.closed)
369
+ return;
370
+ this.closed = true;
371
+ try {
372
+ await this.agent.config.middleware?.run("session_shutdown", { sessionId: this.id });
373
+ }
374
+ finally {
375
+ this.closeSubscribers();
376
+ }
377
+ }
257
378
  prompt(input, options) {
258
379
  return this.run(input, options);
259
380
  }
260
381
  async *stream(input, options = {}) {
261
382
  const { maxQueuedEvents, overflow, ...runOptions } = options;
262
- const subscription = this.subscribe({ maxQueuedEvents, overflow });
383
+ const subscriber = this.createSubscriber({ maxQueuedEvents, overflow });
263
384
  let runOwnedId;
264
385
  let settled = false;
386
+ // This subscription is stream()'s own, so it does not depend on the run-end close: settling the
387
+ // run closes it too, which also unblocks the consumer loop when the run fails before it ever
388
+ // emits (a pre-flight validation rejection returns before run-end cleanup).
265
389
  const runPromise = this.run(input, runOptions).finally(() => {
266
390
  settled = true;
391
+ subscriber.close();
267
392
  });
268
393
  try {
269
- for await (const event of subscription) {
394
+ for await (const event of subscriber) {
270
395
  if ("runId" in event && typeof event.runId === "string") {
271
396
  if (runOwnedId === undefined && event.type === "agent_started")
272
397
  runOwnedId = event.runId;
@@ -278,6 +403,7 @@ export class RuntimeAgentSession {
278
403
  await runPromise;
279
404
  }
280
405
  finally {
406
+ subscriber.close();
281
407
  if (!settled) {
282
408
  this.abort(new Error("stream consumer closed"));
283
409
  await runPromise.catch(() => undefined);
@@ -333,7 +459,7 @@ export class RuntimeAgentSession {
333
459
  store: this.store,
334
460
  leafId: options.leafId ?? this.currentLeafId,
335
461
  metadata: this.metadata,
336
- ...(this.guardrailPackRefs ? { guardrailPacks: this.guardrailPackRefs } : {}),
462
+ ...(this.packRefs ? { guardrailPacks: this.packRefs } : {}),
337
463
  });
338
464
  }
339
465
  async clone(options = {}) {
@@ -356,7 +482,7 @@ export class RuntimeAgentSession {
356
482
  store: this.store,
357
483
  leafId: branch.length ? remap.get(branch[branch.length - 1].id) : undefined,
358
484
  metadata: this.metadata,
359
- ...(this.guardrailPackRefs ? { guardrailPacks: this.guardrailPackRefs } : {}),
485
+ ...(this.packRefs ? { guardrailPacks: this.packRefs } : {}),
360
486
  });
361
487
  }
362
488
  branchReader() {
@@ -418,6 +544,15 @@ export class RuntimeAgentSession {
418
544
  });
419
545
  }
420
546
  }
547
+ /**
548
+ * Run end (finish, suspension, or denial): closes the run-scoped subscribers only. Subscribers that
549
+ * opted into `SubscribeOptions.acrossRuns` stay open for the next run of this session.
550
+ */
551
+ closeRunSubscribers() {
552
+ for (const subscriber of this.subscribers)
553
+ if (!subscriber.acrossRuns)
554
+ subscriber.close();
555
+ }
421
556
  closeSubscribers() {
422
557
  for (const subscriber of this.subscribers)
423
558
  subscriber.close();
@@ -528,10 +663,15 @@ export class RuntimeAgentSession {
528
663
  signal,
529
664
  };
530
665
  this.emit({ type: "compaction_started", sessionId: this.id, runId });
531
- let result = await strategy.compact(context);
666
+ // Plan 106 R3: pre-compaction seam — the strategy compacts exactly the context this returns.
667
+ const requested = (await this.agent.config.middleware?.run("compaction_request", context)) ?? context;
668
+ let result = await strategy.compact(requested);
532
669
  result = { ...result, summary: redactSecrets(result.summary, secrets) };
533
- const payload = (await this.agent.config.middleware?.run("compaction", { context, result })) ?? {
534
- context,
670
+ const payload = (await this.agent.config.middleware?.run("compaction", {
671
+ context: requested,
672
+ result,
673
+ })) ?? {
674
+ context: requested,
535
675
  result,
536
676
  };
537
677
  result = { ...payload.result, summary: redactSecrets(payload.result.summary, secrets) };
@@ -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