@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
@@ -56,6 +56,104 @@ class TurnPolicyError extends Error {
56
56
  this.name = "TurnPolicyError";
57
57
  }
58
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
+ }
59
157
  /** Validate `RunOptions.turnPolicy` once, before any provider turn (plan 084 Task 2). */
60
158
  function assertTurnPolicy(policy, resolvedLimits) {
61
159
  if (policy === undefined)
@@ -146,7 +244,7 @@ function assertPromptVersionRef(ref) {
146
244
  return ref;
147
245
  }
148
246
  async function assembleRoundContext(params) {
149
- 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;
150
248
  session.resolveRunProvider(options);
151
249
  throwIfAborted(controller.signal);
152
250
  session.emit({ type: "agent_started", sessionId: session.id, runId });
@@ -158,6 +256,9 @@ async function assembleRoundContext(params) {
158
256
  version: resumed.version,
159
257
  ...(resumed.restore ? { restore: resumed.restore } : {}),
160
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);
161
262
  const startRecord = {
162
263
  id: runId,
163
264
  sessionId: session.id,
@@ -304,6 +405,7 @@ async function assembleRoundContext(params) {
304
405
  artifactFailedInfo: undefined,
305
406
  toolCalls: 0,
306
407
  toolResults: [],
408
+ stopHooks,
307
409
  runUsage,
308
410
  loopCtx: undefined,
309
411
  };
@@ -373,6 +475,7 @@ async function assembleRoundContext(params) {
373
475
  toolsSearch: session.agent.config.toolsSearch,
374
476
  activatedTools: session.activatedTools,
375
477
  toolResultFold: resolveToolResultFold(options.toolResultFold, session.agent.config.toolResultFold),
478
+ contextBudget: session.agent.config.contextBudget,
376
479
  attentionCompiler,
377
480
  // Session-owned: a stub made earlier stays applied even on a later under-ratio turn, so
378
481
  // the prompt-cache prefix is not rewritten (C10). Undefined when the compiler is off.
@@ -469,6 +572,50 @@ async function assembleRoundContext(params) {
469
572
  ctx.loopCtx = loopCtx;
470
573
  return ctx;
471
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
+ }
472
619
  export async function executeRun(session, input, options, runId, resumed) {
473
620
  const legacyMaxToolRounds = options.maxToolRounds;
474
621
  if (legacyMaxToolRounds !== undefined) {
@@ -486,6 +633,8 @@ export async function executeRun(session, input, options, runId, resumed) {
486
633
  const requestedLimits = options.limits;
487
634
  const resolvedLimits = resolveRunLimits(session.agent.config.limits, requestedLimits);
488
635
  assertTurnPolicy(options.turnPolicy, resolvedLimits);
636
+ const stopHooks = [...(session.agent.config.stopHooks ?? []), ...(options.stopHooks ?? [])];
637
+ assertStopHooks(stopHooks);
489
638
  const durableOptions = options.runState ?? session.agent.config.runState;
490
639
  if (session.agent.config.runState && options.runState && session.agent.config.runState !== options.runState) {
491
640
  throw new AgentRunStateError("RunOptions cannot replace agent durable run-state configuration");
@@ -519,8 +668,11 @@ export async function executeRun(session, input, options, runId, resumed) {
519
668
  if (session.activeIdentity && !session.activeOwnership)
520
669
  session.activeOwnership = ownershipFromIdentity(session.activeIdentity);
521
670
  session.activeIdempotencyKey = options.idempotencyKey ?? session.agent.config.idempotencyKey;
522
- session.activeGuardrails = mergeGuardrails(mergeGuardrails(session.agent.config.guardrails, session.packGuardrails), options.guardrails);
523
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);
524
676
  // Plan 086 T3: reset here, so a suspension before the compiler is resolved (input guardrail)
525
677
  // cannot inherit the previous run's durable-folding flag. `assembleRoundContext` sets it true.
526
678
  session.attentionDurable = false;
@@ -570,6 +722,7 @@ export async function executeRun(session, input, options, runId, resumed) {
570
722
  metadata,
571
723
  limits,
572
724
  runUsage,
725
+ stopHooks,
573
726
  });
574
727
  await replayDurableNestedAndPending(ctx);
575
728
  const resumedLoopState = resumed?.state?.loopState;
@@ -579,7 +732,7 @@ export async function executeRun(session, input, options, runId, resumed) {
579
732
  }
580
733
  ctx.loop.restore?.(resumedLoopState.snapshot);
581
734
  }
582
- const loopUsage = await runLoopUntilSettled(ctx).catch((error) => {
735
+ const loopUsage = await runLoopWithStopHooks(ctx).catch((error) => {
583
736
  // Host turn-policy stop (plan 084 Task 2): the loop was unwound on purpose at a turn
584
737
  // boundary. Not an error — the run settles cleanly and stays resumable.
585
738
  if (error instanceof AgentRunStopped)
@@ -588,12 +741,6 @@ export async function executeRun(session, input, options, runId, resumed) {
588
741
  });
589
742
  stopReason = ctx.runStop?.reason ?? ctx.loopCtx.finishReason;
590
743
  stopDetail = ctx.runStop?.detail;
591
- if (!ctx.runStop && ctx.loop.name === "generate-validate-revise" && !ctx.artifactFinished) {
592
- throw Object.assign(new Error(ctx.artifactFailedInfo?.message ?? "artifact loop ended without a validated artifact"), {
593
- name: "ArtifactFailed",
594
- code: ctx.artifactFailedInfo?.code ?? "artifact_failed",
595
- });
596
- }
597
744
  usage = runUsage.value() ?? loopUsage;
598
745
  return await persistSucceeded(ctx, loopUsage);
599
746
  }
@@ -16,10 +16,14 @@ export async function persistDurable(session, state) {
16
16
  // did not keeps exactly today's bytes, where the frontier rides `persistSessionState`.
17
17
  const attentionSticky = persistSessionState || session.attentionDurable ? session.serializedAttentionSticky() : undefined;
18
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;
19
22
  const sessionState = {
20
23
  ...(persistSessionState
21
24
  ? {
22
25
  loadedSkillNames: session.loadedSkills.list(),
26
+ ...(guardrailPacks ? { guardrailPacks } : {}),
23
27
  ...(session.activatedTools.list().length ? { activatedToolNames: session.activatedTools.list() } : {}),
24
28
  ...(durable.options.includeSkillBodies
25
29
  ? {
@@ -160,19 +164,21 @@ export async function persistSucceeded(ctx, loopUsage) {
160
164
  await session.activeLedger.appendUsage(redactRunLedgerRecord(usageRecord, session.activeRedactor));
161
165
  }
162
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;
163
171
  const runState = session.activeDurable?.state
164
172
  ? await persistDurable(session, {
165
173
  ...session.activeDurable.state,
166
174
  status: "succeeded",
167
- // Plan 084 Task 2: a host-policy stop is terminal for the run but leaves the frontier
168
- // intact — the loop state is kept and the state is marked continuable.
169
- ...(stop ? { stopReason: "host_policy", leafId: session.currentLeafId } : {}),
175
+ ...(continuableStop ? { stopReason: continuableStop, leafId: session.currentLeafId } : {}),
170
176
  pending: undefined,
171
177
  pendingCalls: undefined,
172
178
  nestedRuns: undefined,
173
179
  stickyDecisions: undefined,
174
180
  interruption: undefined,
175
- ...(stop ? {} : { loopState: undefined }),
181
+ ...(continuableStop ? {} : { loopState: undefined }),
176
182
  })
177
183
  : undefined;
178
184
  session.emit({
@@ -246,7 +252,7 @@ export async function cleanupRun(input) {
246
252
  session.activeRedactor = undefined;
247
253
  session.activeProvider = undefined;
248
254
  cleanupSignal();
249
- session.closeSubscribers();
255
+ session.closeRunSubscribers();
250
256
  }
251
257
  }
252
258
  //# sourceMappingURL=persist.js.map
@@ -1,7 +1,7 @@
1
1
  /** Provider-round phase of runInternal (plan 059). */
2
2
  import { resolveInputCap } from "../../attention-compiler.js";
3
3
  import { cacheUsageReport } from "../../cache-helpers.js";
4
- import { estimateMessageTokens } from "../../context-budget.js";
4
+ import { estimateMessageTokens, estimateRequestExtrasTokens, getContextBudgetReport, resolveHostTokenEstimator, } from "../../context-budget.js";
5
5
  import { assertGuardrailsAllowed, GuardrailError, runGuardrails } from "../../guardrails.js";
6
6
  import { validateDeterministicTurnAnswer } from "../../middleware.js";
7
7
  import { createProviderTurnMetadata, readProviderHttpStatus } from "../../observability.js";
@@ -48,7 +48,9 @@ function turnBudgets(session, model, usage) {
48
48
  const inputCap = resolveTurnInputCap(session, model);
49
49
  const runInputBudget = tracker.limits.maxInputTokens;
50
50
  return {
51
- ...(usage?.inputTokens === undefined ? {} : { inputTokens: usage.inputTokens }),
51
+ ...(usage?.inputTokens === undefined
52
+ ? {}
53
+ : { inputTokens: usage.inputTokens, inputTokensSource: usage.estimated === true ? "estimated" : "reported" }),
52
54
  ...(inputCap === undefined ? {} : { inputCap }),
53
55
  ...(runInputBudget === null ? {} : { runInputBudget }),
54
56
  runInputUsed: snapshot.inputTokens,
@@ -125,21 +127,52 @@ export async function recordProviderUsage(ctx, turnUsage, turn, attempt, request
125
127
  return effective;
126
128
  }
127
129
  /**
128
- * Plan 091 T2 missing-usage fallback: when the provider reported nothing and the
129
- * agent did not turn estimation off, label an estimate of the turn's own request
130
- * (messages + tool declarations + context blocks). Returns `undefined` when
131
- * estimation is off or the request is unavailable — absent stays absent.
130
+ * Plan 091 T2 missing-usage fallback, plan 103 T6 exact-measurement reuse: when the provider
131
+ * reported nothing and the agent did not turn estimation off, label one estimate of the turn's
132
+ * own request, preferring the most exact measurement that already exists —
133
+ * 1. the budget pass's own `ContextBudgetReport.keptTokens` (whole request, post-eviction,
134
+ * the same figure that decided evictions; excludes content added after the budget pass),
135
+ * 2. the host's `contextBudget.tokenEstimator`, projecting messages plus tool/context portions
136
+ * through the assembler's own `measureAll` text shapes,
137
+ * 3. the plan-091 family heuristic for messages plus those same assembler shapes for extras
138
+ * (no `JSON.stringify` of the schemas, so no drift from what the assembler measured).
139
+ * A host tokenizer's count is still an estimate (`confidence: "high"`, never `"reported"`);
140
+ * a report measured by the built-in ÷4 basis is honestly `"low"`. Returns `undefined` when
141
+ * estimation is not the fallback (`"off"` / `"strict"`) or the request is unavailable —
142
+ * absent stays absent.
132
143
  */
133
144
  function estimateTurnUsage(session, model, request) {
134
- if (!request || session.agent.config.usageEstimation === "off")
145
+ // Omitted is the documented default (`"fallback"`), not a reason to skip estimation.
146
+ const mode = session.agent.config.usageEstimation ?? "fallback";
147
+ if (!request || mode !== "fallback")
135
148
  return undefined;
149
+ const hostEstimator = resolveHostTokenEstimator(session.agent.config.contextBudget);
150
+ const report = getContextBudgetReport(request);
151
+ if (report) {
152
+ return { inputTokens: report.keptTokens, estimated: true, confidence: hostEstimator === undefined ? "low" : "high" };
153
+ }
154
+ if (hostEstimator) {
155
+ let tokens = estimateRequestExtrasTokens(request.tools, request.context, hostEstimator);
156
+ for (const message of request.messages)
157
+ tokens += estimateMessageTokens(message, hostEstimator);
158
+ return { inputTokens: tokens, estimated: true, confidence: "high" };
159
+ }
136
160
  const estimate = estimateMessageTokens(request.messages, model.model);
137
- const extras = request.tools?.length || request.context?.length ? JSON.stringify({ tools: request.tools, context: request.context }) : undefined;
138
- return {
139
- inputTokens: estimate.tokens + (extras === undefined ? 0 : estimateTextTokensForFamily(extras, model.model)),
140
- estimated: true,
141
- confidence: estimate.confidence,
142
- };
161
+ const extras = estimateRequestExtrasTokens(request.tools, request.context, (text) => estimateTextTokensForFamily(text, model.model));
162
+ return { inputTokens: estimate.tokens + extras, estimated: true, confidence: estimate.confidence };
163
+ }
164
+ /**
165
+ * Plan 103 T5: the refusal `usageEstimation: "strict"` gives a completed turn that reported no
166
+ * usage. It rides the existing observable-failure path (one attempt, terminal `error` event) and
167
+ * stamps no `failureClass` — a harness refusal is not a provider failure, so `name`/`code` are
168
+ * what a host matches on. The info carries the turn number and mode only, never request content.
169
+ */
170
+ function usageMissingFailure(turn) {
171
+ return new ProviderTurnFailure({
172
+ name: "UsageMissingError",
173
+ code: "usage_missing",
174
+ message: `provider reported no usage on turn ${turn} and usageEstimation is "strict"`,
175
+ }, true);
143
176
  }
144
177
  /** Latest user-role text in the assembled request; steered messages included. */
145
178
  function lastUserText(messages) {
@@ -333,6 +366,14 @@ export async function generateProviderTurn(session, request, runId, signal, secr
333
366
  calls.push(call);
334
367
  emitOutput({ type: "message_delta", sessionId: session.id, runId, content: call });
335
368
  }
369
+ // Plan 103 T5: strict refuses a completed turn that reported no usage *before* the usage seam
370
+ // runs, so no estimate is projected, the cost catalog is not consulted, and the fail-closed
371
+ // `recordUsage(undefined)` maxCost breach cannot preempt the refusal. Marking the seam
372
+ // consulted keeps the catch below from re-entering it with the same missing usage.
373
+ if (usage === undefined && session.agent.config.usageEstimation === "strict") {
374
+ usageRecorded = true;
375
+ throw usageMissingFailure(turn);
376
+ }
336
377
  await recordTurnUsage();
337
378
  if (session.activeGuardrails?.output?.length) {
338
379
  assertGuardrailsAllowed(await runGuardrails({
@@ -1,10 +1,10 @@
1
1
  /** Tool-round phase of runInternal (plan 059). */
2
- import type { AgentRunRef, LoopContext, NestedRunRef, PendingDecision, ResumeNestedRun, StickyDecision, ToolCallContent, ToolRegistry, ToolResult, Usage } from "../../contracts.js";
2
+ import type { AgentRunRef, GuardrailRecord, LoopContext, NestedRunRef, PendingDecision, ResumeNestedRun, StickyDecision, ToolCallContent, ToolRegistry, ToolResult, Usage } from "../../contracts.js";
3
3
  import { AgentDelegationSuspendedError } from "../../contracts.js";
4
4
  import type { RoundContext, SessionHost } from "./types.js";
5
5
  export declare function matchNestedSticky(session: SessionHost, decision: PendingDecision): StickyDecision | undefined;
6
6
  export declare function matchStickyDecision(session: SessionHost, call: ToolCallContent, registry: ToolRegistry): StickyDecision | undefined;
7
- export declare function buildPendingDecision(session: SessionHost, call: ToolCallContent, approvalId: string, registry: ToolRegistry, runId: string, metadata: Readonly<Record<string, unknown>>, signal: AbortSignal): PendingDecision;
7
+ export declare function buildPendingDecision(session: SessionHost, call: ToolCallContent, approvalId: string, registry: ToolRegistry, runId: string, metadata: Readonly<Record<string, unknown>>, signal: AbortSignal, ask?: GuardrailRecord): PendingDecision;
8
8
  export declare function applyNestedRun(session: SessionHost, input: {
9
9
  ref: AgentRunRef;
10
10
  toolCall: ToolCallContent;
@@ -3,6 +3,7 @@ import { AgentRunSuspended, decisionIdentityRef, decisionScopesEqual, nestedAppr
3
3
  import { toolElicitationRequest } from "../../agent-tool-dispatch.js";
4
4
  import { AgentDecisionError, AgentDelegationSuspendedError, AgentRunStateError, DEFAULT_MAX_PENDING_DECISIONS, HARD_MAX_PENDING_DECISIONS, MAX_ATTRIBUTION_DEPTH, } from "../../contracts.js";
5
5
  import { toToolResultMessage } from "../../input.js";
6
+ import { runGuardrails } from "../../guardrails.js";
6
7
  import { canonicalToolEffectJson, toolEffectArgumentsHash } from "../../tool-effects.js";
7
8
  import { dispatchToolCall, resolveToolEffectDeclaration } from "../../tools.js";
8
9
  import { randomId } from "../helpers.js";
@@ -55,7 +56,7 @@ export function matchStickyDecision(session, call, registry) {
55
56
  return true;
56
57
  });
57
58
  }
58
- export function buildPendingDecision(session, call, approvalId, registry, runId, metadata, signal) {
59
+ export function buildPendingDecision(session, call, approvalId, registry, runId, metadata, signal, ask) {
59
60
  const tool = registry.get(call.name);
60
61
  const declaration = tool?.effect
61
62
  ? resolveToolEffectDeclaration(tool, call.arguments, {
@@ -74,6 +75,11 @@ export function buildPendingDecision(session, call, approvalId, registry, runId,
74
75
  signal,
75
76
  metadata,
76
77
  });
78
+ // Plan 104 T3: the pack `ask` rule that gated this call is named in the bounded reason and carried
79
+ // machine-readably, so a host never parses the name to know which rule raised the approval.
80
+ const pack = ask?.metadata?.pack;
81
+ const rule = ask?.metadata?.rule;
82
+ const guardrailRule = typeof pack === "string" && typeof rule === "string" ? { pack, rule } : undefined;
77
83
  return {
78
84
  approvalId,
79
85
  kind: elicitation ? "elicitation" : "tool_approval",
@@ -84,10 +90,22 @@ export function buildPendingDecision(session, call, approvalId, registry, runId,
84
90
  ...(declaration && declaration.kind !== "none" ? { effectKind: declaration.kind } : {}),
85
91
  ...(identityRef ? { identity: identityRef } : {}),
86
92
  },
87
- reason: elicitation?.reason ?? "Tool side effect requires approval",
93
+ reason: elicitation?.reason ?? (ask ? askDecisionReason(ask) : "Tool side effect requires approval"),
88
94
  ...(elicitation ? { elicitationSchema: elicitation.schema } : {}),
95
+ ...(ask && guardrailRule ? { guardrail: ask.guardrail, guardrailRule } : {}),
89
96
  };
90
97
  }
98
+ const MAX_ASK_DECISION_REASON_BYTES = 200;
99
+ /** `pack:<pack>/<rule>` plus the pack's own reason, bounded like every other decision field. */
100
+ function askDecisionReason(ask) {
101
+ const pack = ask.metadata?.pack;
102
+ const rule = ask.metadata?.rule;
103
+ const defaultReason = typeof pack === "string" && typeof rule === "string" ? `guardrail pack rule ${pack}/${rule}` : undefined;
104
+ const line = `Approval required by guardrail rule ${ask.guardrail}`;
105
+ const text = ask.reason && ask.reason !== defaultReason ? `${line}: ${ask.reason}` : line;
106
+ const bytes = new TextEncoder().encode(text);
107
+ return bytes.length <= MAX_ASK_DECISION_REASON_BYTES ? text : new TextDecoder().decode(bytes.subarray(0, MAX_ASK_DECISION_REASON_BYTES));
108
+ }
91
109
  export async function applyNestedRun(session, input) {
92
110
  let current = input.pending;
93
111
  for (let depth = 0;; depth += 1) {
@@ -142,6 +160,7 @@ export async function suspendGatedRound(ctx) {
142
160
  reason: single ? single.reason : `${decisions.length} tool side effects require approval`,
143
161
  ...(single?.toolCallId ? { toolCallId: single.toolCallId } : {}),
144
162
  ...(single?.scope.toolName ? { toolName: single.scope.toolName } : {}),
163
+ ...(single?.guardrail ? { guardrail: single.guardrail } : {}),
145
164
  pendingDecisions: decisions,
146
165
  };
147
166
  throw new AgentRunSuspended(await suspendDurable(ctx.session, {
@@ -217,20 +236,27 @@ export async function handleNestedSignal(ctx, error) {
217
236
  await suspendNested(ctx, { entry: applied.entry, toolCall: error.toolCall, pending: applied.pending });
218
237
  }
219
238
  export function bindChargeToolRound(ctx) {
220
- return (calls) => {
239
+ return async (calls) => {
221
240
  if (calls.length > 0)
222
241
  ctx.limits.charge("maxToolRounds");
223
242
  const durable = ctx.session.activeDurable;
224
- if (!durable?.options.interruptBeforeTool || calls.length === 0)
243
+ // A run that cannot suspend never gates here: `activeGuardrails` already carries the pack `ask`
244
+ // rules as plain blocks (assemble.ts), so the ordinary stage path refuses the call.
245
+ if (!durable || calls.length === 0)
246
+ return;
247
+ if (!ctx.session.packAskGate && !durable.options.interruptBeforeTool)
225
248
  return;
226
249
  for (const call of calls) {
227
250
  if (matchStickyDecision(ctx.session, call, ctx.registry))
228
251
  continue;
252
+ const ask = await matchAskGate(ctx, call);
253
+ if (!ask && !durable.options.interruptBeforeTool)
254
+ continue;
229
255
  const approvalId = randomId("approval");
230
256
  ctx.session.activeGatedRound ??= new Map();
231
257
  ctx.session.activeGatedRound.set(call.id, {
232
258
  entry: { call, status: "ready", approvalId },
233
- decision: buildPendingDecision(ctx.session, call, approvalId, ctx.registry, ctx.runId, ctx.metadata, ctx.controller.signal),
259
+ decision: buildPendingDecision(ctx.session, call, approvalId, ctx.registry, ctx.runId, ctx.metadata, ctx.controller.signal, ask),
234
260
  });
235
261
  }
236
262
  if (ctx.session.activeGatedRound && ctx.session.activeGatedRound.size > DEFAULT_MAX_PENDING_DECISIONS) {
@@ -238,6 +264,33 @@ export function bindChargeToolRound(ctx) {
238
264
  }
239
265
  };
240
266
  }
267
+ /**
268
+ * Plan 104 T3: evaluate the pack `ask` rules for one call at charge time. The rules run through the
269
+ * same compiler and stage runner as every other pack rule, so matching, bounds, and redaction are
270
+ * shared; a match emits its `guardrail_decision` (`interrupt`: awaiting a decision) and gates the
271
+ * call before it can dispatch.
272
+ */
273
+ async function matchAskGate(ctx, call) {
274
+ const gate = ctx.session.packAskGate;
275
+ if (!gate)
276
+ return undefined;
277
+ const result = await runGuardrails({
278
+ stage: "tool_input",
279
+ guardrails: gate,
280
+ value: call,
281
+ context: {
282
+ sessionId: ctx.session.id,
283
+ runId: ctx.runId,
284
+ toolCallId: call.id,
285
+ toolName: call.name,
286
+ metadata: ctx.metadata,
287
+ signal: ctx.controller.signal,
288
+ },
289
+ redactor: ctx.session.activeRedactor,
290
+ emit: (event) => ctx.session.emit(event),
291
+ });
292
+ return result.terminal;
293
+ }
241
294
  /** Last-N dispatched tool calls kept for `budget_exhausted` attribution (plan 087 T2); the hash
242
295
  * is the same canonical arguments hash the effect store uses, so raw args never enter events. */
243
296
  const RECENT_TOOL_CALL_LIMIT = 10;
@@ -1,8 +1,8 @@
1
1
  /** Shared host/round types for runInternal phase split (plan 059). Internal only. */
2
2
  import type { ActiveDurableRun } from "../../agent-approval.js";
3
- import type { PendingToolCall } from "../../agent-run-state.js";
3
+ import type { PendingToolCall, PersistedGuardrailPacks } from "../../agent-run-state.js";
4
4
  import type { AttentionFoldLedger, AttentionStickyFrontier, PersistedAttentionFoldLedger, PersistedAttentionStickyFrontier } from "../../attention-compiler.js";
5
- import type { Agent, AgentEvent, AgentFinishReason, AgentLoopStrategy, AgentRunResult, AIProvider, ErrorInfo, Guardrails, LoopContext, Message, ModelConfig, OwnershipScope, PendingDecision, PromptVersionRef, ProviderRequest, RunLedger, RunOptions, SessionEntry, SessionStore, Skill, ToolCallSummary, ToolDefinition, ToolEffectStore, ToolRegistry, ToolResult, Usage } from "../../contracts.js";
5
+ import type { Agent, AgentEvent, AgentFinishReason, AgentLoopStrategy, AgentRunResult, AIProvider, ErrorInfo, Guardrails, LoopContext, Message, ModelConfig, OwnershipScope, PendingDecision, PromptVersionRef, ProviderRequest, RunLedger, RunOptions, SessionEntry, SessionStore, Skill, StopHook, ToolCallSummary, ToolDefinition, ToolEffectStore, ToolRegistry, ToolResult, Usage } from "../../contracts.js";
6
6
  import type { AgentIdentity } from "../../identity.js";
7
7
  import type { AgentInput } from "../../input.js";
8
8
  import type { SecretRedactor } from "../../redaction.js";
@@ -35,6 +35,12 @@ export type SessionHost = {
35
35
  activeGuardrails?: Guardrails;
36
36
  /** Plan 092 Task 2: packs compiled once at session construction; read-only for phases. */
37
37
  readonly packGuardrails?: Guardrails;
38
+ /** Plan 104 T3: `ask` rules as the durable charge-time gate (`interrupt` records) and as plain
39
+ * blocks for a run that cannot suspend. */
40
+ readonly packAskGate?: Guardrails;
41
+ readonly packAskBlocks?: Guardrails;
42
+ /** Plan 104 T2: pack refs + live pack-owned state for a durable checkpoint. */
43
+ serializedGuardrailPackState(): PersistedGuardrailPacks | undefined;
38
44
  activeMetadata?: Readonly<Record<string, unknown>>;
39
45
  activePromptVersion?: PromptVersionRef;
40
46
  activeLimits?: RunLimitTracker;
@@ -83,6 +89,8 @@ export type SessionHost = {
83
89
  emit(event: AgentEvent): void;
84
90
  rebuildHistory(): Promise<void>;
85
91
  resolveRunSkills(options: RunOptions, tools: readonly ToolDefinition[]): readonly Skill[];
92
+ /** Redacted, cap-checked steer queue push (plan 106 R1 uses it for stop-hook continuations). */
93
+ steer(input: AgentInput): void;
86
94
  appendEntry(entry: SessionEntry): Promise<void>;
87
95
  redact<T>(value: T): T;
88
96
  appendMessage(message: Message, runId: string): Promise<void>;
@@ -104,7 +112,15 @@ export type SessionHost = {
104
112
  readonly runState?: import("../../contracts.js").AgentRunState;
105
113
  readonly interruption?: import("../../contracts.js").AgentRunInterruption;
106
114
  }): AgentRunResult;
115
+ /**
116
+ * Plan 106 R2: dispatch `session_start` middleware once per session (first run start), awaited by
117
+ * the run assembler after the `agent_started`/`agent_resumed` emits. No-op on every later call.
118
+ */
119
+ openSession(runId: string): Promise<void>;
120
+ /** Session teardown: close every subscriber, run-scoped and `acrossRuns` alike. */
107
121
  closeSubscribers(): void;
122
+ /** Run end (finish, suspend, or deny): close only the subscribers that do not opt into `acrossRuns`. */
123
+ closeRunSubscribers(): void;
108
124
  snapshot(): Promise<SessionContextSnapshot>;
109
125
  };
110
126
  export declare function asSessionHost(session: unknown): SessionHost;
@@ -151,6 +167,8 @@ export type RoundContext = {
151
167
  toolResults: ToolResult[];
152
168
  /** Set when a `RunOptions.turnPolicy` stop ended the loop (plan 084 Task 2). */
153
169
  runStop?: RunStopInfo;
170
+ /** Merged agent + run stop hooks, in invocation order (plan 106 R1). Empty = wrapper skipped. */
171
+ stopHooks: readonly StopHook[];
154
172
  runUsage: {
155
173
  add(usage: Usage): void;
156
174
  value(): Usage | undefined;