@arnilo/prism 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/CHANGELOG.md +62 -1
  2. package/README.md +13 -12
  3. package/dist/agent-approval.d.ts +17 -2
  4. package/dist/agent-approval.js +15 -6
  5. package/dist/agent-event-source.d.ts +9 -1
  6. package/dist/agent-event-source.js +10 -3
  7. package/dist/agent-loops.js +7 -4
  8. package/dist/agent-run-lifecycle.d.ts +15 -1
  9. package/dist/agent-run-lifecycle.js +82 -11
  10. package/dist/agent-run-state.d.ts +47 -6
  11. package/dist/agent-run-state.js +154 -6
  12. package/dist/agent-session/event-subscriber.d.ts +2 -0
  13. package/dist/agent-session/event-subscriber.js +3 -0
  14. package/dist/agent-session/helpers.js +14 -0
  15. package/dist/agent-session/session/assemble.js +281 -32
  16. package/dist/agent-session/session/persist.d.ts +11 -0
  17. package/dist/agent-session/session/persist.js +48 -16
  18. package/dist/agent-session/session/provider-round.d.ts +14 -4
  19. package/dist/agent-session/session/provider-round.js +226 -19
  20. package/dist/agent-session/session/tool-round.d.ts +2 -2
  21. package/dist/agent-session/session/tool-round.js +78 -6
  22. package/dist/agent-session/session/types.d.ts +44 -3
  23. package/dist/agent-session/session.d.ts +100 -5
  24. package/dist/agent-session/session.js +224 -13
  25. package/dist/attention-compiler.d.ts +51 -2
  26. package/dist/attention-compiler.js +282 -21
  27. package/dist/cache-helpers.d.ts +4 -2
  28. package/dist/cache-helpers.js +8 -6
  29. package/dist/checkpoint-restore.d.ts +45 -0
  30. package/dist/checkpoint-restore.js +54 -0
  31. package/dist/context-budget.d.ts +13 -1
  32. package/dist/context-budget.js +57 -4
  33. package/dist/contracts-core/agent.d.ts +52 -1
  34. package/dist/contracts-core/attention.d.ts +95 -0
  35. package/dist/contracts-core/content.d.ts +10 -0
  36. package/dist/contracts-core/extensions.d.ts +3 -0
  37. package/dist/contracts-core/guardrail-packs.d.ts +46 -0
  38. package/dist/contracts-core/guardrail-packs.js +2 -0
  39. package/dist/contracts-core/loop.d.ts +36 -0
  40. package/dist/contracts-core/provider.d.ts +30 -0
  41. package/dist/contracts-core/run-limits.d.ts +29 -1
  42. package/dist/contracts-core/session.d.ts +23 -5
  43. package/dist/contracts-core/session.js +21 -2
  44. package/dist/contracts-core/usage.d.ts +40 -0
  45. package/dist/contracts-core/usage.js +8 -0
  46. package/dist/contracts-core.d.ts +2 -0
  47. package/dist/contracts-core.js +2 -0
  48. package/dist/contracts-protocol.d.ts +81 -5
  49. package/dist/contracts-run-state.d.ts +91 -2
  50. package/dist/contributions.d.ts +2 -1
  51. package/dist/contributions.js +1 -0
  52. package/dist/extensions.d.ts +15 -1
  53. package/dist/extensions.js +68 -0
  54. package/dist/guardrail-packs/coding-standard.d.ts +3 -0
  55. package/dist/guardrail-packs/coding-standard.js +63 -0
  56. package/dist/guardrail-packs/destructive-commands.d.ts +3 -0
  57. package/dist/guardrail-packs/destructive-commands.js +46 -0
  58. package/dist/guardrail-packs/errors.d.ts +7 -0
  59. package/dist/guardrail-packs/errors.js +9 -0
  60. package/dist/guardrail-packs/index.d.ts +4 -0
  61. package/dist/guardrail-packs/index.js +15 -0
  62. package/dist/guardrail-packs/secrets-hygiene.d.ts +3 -0
  63. package/dist/guardrail-packs/secrets-hygiene.js +23 -0
  64. package/dist/guardrail-packs/types.d.ts +26 -0
  65. package/dist/guardrail-packs/types.js +2 -0
  66. package/dist/guardrail-packs/validation-respect.d.ts +3 -0
  67. package/dist/guardrail-packs/validation-respect.js +69 -0
  68. package/dist/guardrails.d.ts +61 -1
  69. package/dist/guardrails.js +377 -0
  70. package/dist/index.d.ts +16 -11
  71. package/dist/index.js +10 -7
  72. package/dist/input.d.ts +8 -1
  73. package/dist/input.js +68 -6
  74. package/dist/middleware.d.ts +37 -2
  75. package/dist/middleware.js +41 -0
  76. package/dist/node/session-store-jsonl.js +18 -3
  77. package/dist/observability.js +6 -0
  78. package/dist/provider-events.d.ts +8 -2
  79. package/dist/provider-events.js +60 -2
  80. package/dist/providers/openai-compatible.js +6 -3
  81. package/dist/run-bundle.d.ts +6 -1
  82. package/dist/run-bundle.js +5 -1
  83. package/dist/run-limits.d.ts +11 -1
  84. package/dist/run-limits.js +59 -0
  85. package/dist/session-stores.d.ts +12 -1
  86. package/dist/session-stores.js +21 -4
  87. package/dist/testing/agent-event-source-conformance.js +41 -2
  88. package/dist/testing/prefix-stability-conformance.d.ts +59 -0
  89. package/dist/testing/prefix-stability-conformance.js +172 -0
  90. package/dist/testing/session-store-conformance.d.ts +3 -2
  91. package/dist/testing/session-store-conformance.js +48 -0
  92. package/dist/tools.d.ts +5 -0
  93. package/dist/tools.js +21 -6
  94. package/dist/usage-estimation.d.ts +29 -0
  95. package/dist/usage-estimation.js +79 -0
  96. package/docs/agent-events.md +75 -4
  97. package/docs/agent-session-runtime.md +10 -6
  98. package/docs/attention-compiler.md +89 -8
  99. package/docs/caveman.md +1 -1
  100. package/docs/coding-agent-tools.md +1 -1
  101. package/docs/compaction-and-retry.md +1 -1
  102. package/docs/compaction-llm.md +2 -0
  103. package/docs/compaction-observational-memory.md +54 -7
  104. package/docs/durable-runs.md +46 -3
  105. package/docs/embeddings.md +9 -0
  106. package/docs/evaluations.md +5 -0
  107. package/docs/execution-timeline.md +79 -1
  108. package/docs/extensions.md +20 -3
  109. package/docs/guardrails.md +50 -4
  110. package/docs/hooks.md +282 -0
  111. package/docs/index.md +37 -15
  112. package/docs/input-and-prompt-assembly.md +4 -4
  113. package/docs/instruction-injection.md +1 -0
  114. package/docs/knowledge-sync.md +4 -0
  115. package/docs/live-testing.md +3 -1
  116. package/docs/memory-fabric.md +28 -0
  117. package/docs/middleware-hooks.md +90 -4
  118. package/docs/migrate-to-0.9.md +210 -0
  119. package/docs/migration.md +26 -0
  120. package/docs/multi-agent-patterns.md +25 -2
  121. package/docs/node-jsonl-session-store.md +7 -1
  122. package/docs/observability.md +7 -3
  123. package/docs/options-index.md +4 -1
  124. package/docs/policy-and-audit.md +26 -1
  125. package/docs/prefix-stability-conformance.md +143 -0
  126. package/docs/provider-caching.md +4 -4
  127. package/docs/provider-conformance.md +16 -0
  128. package/docs/provider-packages.md +20 -20
  129. package/docs/public-contracts.md +3 -2
  130. package/docs/rag.md +188 -3
  131. package/docs/release-and-install.md +45 -40
  132. package/docs/runs-and-usage.md +56 -10
  133. package/docs/scoped-agent-memory.md +270 -0
  134. package/docs/scoped-memory.md +138 -0
  135. package/docs/session-store-conformance.md +1 -2
  136. package/docs/session-stores.md +17 -17
  137. package/docs/supervisors.md +32 -12
  138. package/docs/tools.md +18 -1
  139. package/docs/wiki.md +4 -2
  140. package/docs/workflows.md +5 -0
  141. package/package.json +8 -2
@@ -1,9 +1,14 @@
1
1
  /** Provider-round phase of runInternal (plan 059). */
2
+ import { resolveInputCap } from "../../attention-compiler.js";
3
+ import { cacheUsageReport } from "../../cache-helpers.js";
4
+ import { estimateMessageTokens, estimateRequestExtrasTokens, getContextBudgetReport, resolveHostTokenEstimator, } from "../../context-budget.js";
2
5
  import { assertGuardrailsAllowed, GuardrailError, runGuardrails } from "../../guardrails.js";
6
+ import { validateDeterministicTurnAnswer } from "../../middleware.js";
3
7
  import { createProviderTurnMetadata, readProviderHttpStatus } from "../../observability.js";
4
8
  import { providerError, providerToolCallDeltaContent } from "../../provider-events.js";
5
9
  import { errorToErrorInfo, redactRunLedgerRecord, redactSecrets } from "../../redaction.js";
6
10
  import { createDefaultRetryPolicy, waitForRetry } from "../../retry.js";
11
+ import { estimateTextTokensForFamily } from "../../usage-estimation.js";
7
12
  import { bridgeAbort, errorFromInfo, isSteerSoftInterrupt, jsonBytes, mergeRetry, ProviderTurnFailure, providerContent, randomId, reconstructMissingToolCalls, SteerSoftInterrupt, throwIfAborted, } from "../helpers.js";
8
13
  function pushCoalescedContent(content, block) {
9
14
  const last = content.at(-1);
@@ -21,6 +26,51 @@ function pushCoalescedContent(content, block) {
21
26
  }
22
27
  content.push(block);
23
28
  }
29
+ /** Resolve the per-request input cap for turn-budget metadata (plan 087 T1). A model without a
30
+ * derivable cap (or a bad attention setting on an unrelated run) omits the field instead of
31
+ * failing an emitting turn; the attention compiler, when enabled, is the cap authority. */
32
+ export function resolveTurnInputCap(session, model) {
33
+ const setting = session.agent.config.attentionCompiler;
34
+ const options = typeof setting === "object" && setting !== null ? setting : undefined;
35
+ try {
36
+ return resolveInputCap(options ? { maxInputTokens: options.maxInputTokens, reserveTokens: options.reserveTokens } : {}, model);
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ /** Effective budget snapshot at turn end (plan 087 T1): O(1) from the run limit tracker. */
43
+ function turnBudgets(session, model, usage) {
44
+ const tracker = session.activeLimits;
45
+ if (!tracker)
46
+ return undefined;
47
+ const snapshot = tracker.snapshot();
48
+ const inputCap = resolveTurnInputCap(session, model);
49
+ const runInputBudget = tracker.limits.maxInputTokens;
50
+ return {
51
+ ...(usage?.inputTokens === undefined
52
+ ? {}
53
+ : { inputTokens: usage.inputTokens, inputTokensSource: usage.estimated === true ? "estimated" : "reported" }),
54
+ ...(inputCap === undefined ? {} : { inputCap }),
55
+ ...(runInputBudget === null ? {} : { runInputBudget }),
56
+ runInputUsed: snapshot.inputTokens,
57
+ turns: snapshot.turns,
58
+ maxTurns: tracker.limits.maxTurns,
59
+ };
60
+ }
61
+ function cacheMetadata(usage) {
62
+ const cache = cacheUsageReport(usage);
63
+ return cache === undefined ? {} : { cache };
64
+ }
65
+ /** Native reason wins, except a generic `end_turn` on a turn that produced tool calls: protocols
66
+ * with one generic completion value (Google `STOP`) are tool-call turns by content (plan 087 T1). */
67
+ function normalizeTurnStopReason(native, calls) {
68
+ if (native === undefined)
69
+ return calls.length > 0 ? "tool_calls" : "end_turn";
70
+ if (native === "end_turn" && calls.length > 0)
71
+ return "tool_calls";
72
+ return native;
73
+ }
24
74
  /**
25
75
  * Plan 062: price usage through the host's {@link CostCatalog} when the provider
26
76
  * did not report a cost itself. Stale/unknown quotes, catalog failures, or
@@ -46,17 +96,22 @@ async function withCatalogCost(catalog, model, usage, signal) {
46
96
  return usage; // catalog failure degrades to usage-only
47
97
  }
48
98
  }
49
- export async function recordProviderUsage(ctx, turnUsage, turn, attempt) {
99
+ export async function recordProviderUsage(ctx, turnUsage, turn, attempt, request) {
50
100
  const { session, limits, runUsage, runId } = ctx;
51
- const usage = turnUsage
52
- ? await withCatalogCost(session.agent.config.costCatalog, ctx.model, turnUsage, ctx.controller.signal)
53
- : undefined;
54
- limits.recordUsage(usage);
55
- if (!usage)
56
- return;
57
- runUsage.add(usage);
101
+ const usage = turnUsage ?? estimateTurnUsage(session, ctx.model, request);
102
+ // An estimate is never priced: a catalog quote on estimated tokens would invent billing.
103
+ const effective = usage && usage.estimated !== true
104
+ ? await withCatalogCost(session.agent.config.costCatalog, ctx.model, usage, ctx.controller.signal)
105
+ : usage;
106
+ limits.recordUsage(effective);
107
+ if (!effective)
108
+ return undefined;
109
+ if (effective.inputTokens !== undefined) {
110
+ session.activeInputMeter = { tokens: effective.inputTokens, source: effective.estimated === true ? "estimated" : "reported" };
111
+ }
112
+ runUsage.add(effective);
58
113
  if (!session.activeLedger)
59
- return;
114
+ return effective;
60
115
  const usageRecord = {
61
116
  id: randomId("usage"),
62
117
  sessionId: session.id,
@@ -64,11 +119,134 @@ export async function recordProviderUsage(ctx, turnUsage, turn, attempt) {
64
119
  scope: "provider_turn",
65
120
  turn,
66
121
  attempt,
67
- usage,
122
+ usage: effective,
68
123
  recordedAt: new Date().toISOString(),
69
124
  ...session.activeOwnership,
70
125
  };
71
126
  await session.activeLedger.appendUsage(redactRunLedgerRecord(usageRecord, session.activeRedactor));
127
+ return effective;
128
+ }
129
+ /**
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.
143
+ */
144
+ function estimateTurnUsage(session, model, request) {
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")
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
+ }
160
+ const estimate = estimateMessageTokens(request.messages, model.model);
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);
176
+ }
177
+ /** Latest user-role text in the assembled request; steered messages included. */
178
+ function lastUserText(messages) {
179
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
180
+ const message = messages[i];
181
+ if (message?.role !== "user")
182
+ continue;
183
+ return message.content.map((block) => (block.type === "text" ? block.text : "")).join("");
184
+ }
185
+ return "";
186
+ }
187
+ /**
188
+ * Plan 096: host middleware may answer the turn deterministically at the `beforeProviderTurn` seam —
189
+ * no provider request, no usage, mandatory provenance. `undefined` sends the turn to the provider
190
+ * unchanged; a malformed answer fails the run closed instead of falling through to the provider.
191
+ */
192
+ export async function resolveDeterministicTurn(session, request, runId, turn, signal, toolResults = []) {
193
+ const middleware = session.agent.config.middleware;
194
+ if (!middleware)
195
+ return undefined;
196
+ const payload = await middleware.run("beforeProviderTurn", {
197
+ sessionId: session.id,
198
+ runId,
199
+ turn,
200
+ userText: lastUserText(request.messages),
201
+ });
202
+ const answer = payload?.answer;
203
+ if (answer === undefined)
204
+ return undefined;
205
+ const validated = validateDeterministicTurnAnswer(answer);
206
+ throwIfAborted(signal);
207
+ const messageId = randomId("msg");
208
+ // Same response-byte axis as provider output: a host answer must not bypass a run ceiling.
209
+ session.activeLimits?.charge("maxResponseBytes", jsonBytes(validated.content));
210
+ if (session.activeGuardrails?.output?.length) {
211
+ assertGuardrailsAllowed(await runGuardrails({
212
+ stage: "output",
213
+ guardrails: session.activeGuardrails,
214
+ value: { content: validated.content, calls: [], messageId, started: true, usage: undefined },
215
+ context: {
216
+ sessionId: session.id,
217
+ runId,
218
+ metadata: session.activeMetadata ?? {},
219
+ signal,
220
+ toolResults,
221
+ },
222
+ redactor: session.activeRedactor,
223
+ emit: (event) => session.emit(event),
224
+ }));
225
+ }
226
+ session.emit({
227
+ type: "deterministic_turn",
228
+ sessionId: session.id,
229
+ runId,
230
+ turn,
231
+ middleware: validated.provenance.middleware,
232
+ });
233
+ session.emit({
234
+ type: "message_started",
235
+ sessionId: session.id,
236
+ runId,
237
+ message: { id: messageId, role: "assistant", content: [] },
238
+ });
239
+ for (const block of validated.content)
240
+ session.emit({ type: "message_delta", sessionId: session.id, runId, content: block });
241
+ // Provenance rides the message into the store (plan 096 Task 2): the transcript alone proves no model ran.
242
+ return {
243
+ content: validated.content,
244
+ calls: [],
245
+ messageId,
246
+ started: true,
247
+ usage: undefined,
248
+ metadata: { deterministic: validated.provenance },
249
+ };
72
250
  }
73
251
  export async function generateWithRetry(session, request, runId, options, signal, requestSecrets = [], turn = 1, recordUsage, toolResults = []) {
74
252
  const retry = mergeRetry(session.agent.config.retry, options.retry);
@@ -119,7 +297,9 @@ export async function generateProviderTurn(session, request, runId, signal, secr
119
297
  let messageId;
120
298
  let started = false;
121
299
  let usage;
300
+ let nativeStopReason;
122
301
  let usageRecorded = false;
302
+ let effectiveUsage;
123
303
  const bufferedOutput = [];
124
304
  const bufferOutput = Boolean(session.activeGuardrails?.output?.length || session.activeLimitOutputBuffer);
125
305
  const emitOutput = (event) => {
@@ -130,9 +310,11 @@ export async function generateProviderTurn(session, request, runId, signal, secr
130
310
  };
131
311
  const recordTurnUsage = async () => {
132
312
  if (usageRecorded)
133
- return;
313
+ return effectiveUsage;
134
314
  usageRecorded = true;
135
- await recordUsage?.(usage, turn, attempt);
315
+ // The seam may return a labeled estimate (plan 091 T2); without a callback the reported value stands.
316
+ effectiveUsage = (await recordUsage?.(usage, turn, attempt)) ?? usage;
317
+ return effectiveUsage;
136
318
  };
137
319
  const turnAbort = new AbortController();
138
320
  const cleanupTurn = bridgeAbort(signal, turnAbort);
@@ -153,6 +335,7 @@ export async function generateProviderTurn(session, request, runId, signal, secr
153
335
  usage = event.usage;
154
336
  if (event.type === "done") {
155
337
  usage = event.usage ?? usage;
338
+ nativeStopReason = event.stopReason;
156
339
  break;
157
340
  }
158
341
  if (event.type === "message_start") {
@@ -183,6 +366,14 @@ export async function generateProviderTurn(session, request, runId, signal, secr
183
366
  calls.push(call);
184
367
  emitOutput({ type: "message_delta", sessionId: session.id, runId, content: call });
185
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
+ }
186
377
  await recordTurnUsage();
187
378
  if (session.activeGuardrails?.output?.length) {
188
379
  assertGuardrailsAllowed(await runGuardrails({
@@ -209,10 +400,15 @@ export async function generateProviderTurn(session, request, runId, signal, secr
209
400
  sessionId: session.id,
210
401
  runId,
211
402
  turn,
212
- metadata: buildMetadata({ latencyMs }),
213
- usage,
403
+ metadata: buildMetadata({
404
+ latencyMs,
405
+ stopReason: normalizeTurnStopReason(nativeStopReason, calls),
406
+ budgets: turnBudgets(session, request.model, effectiveUsage),
407
+ ...cacheMetadata(effectiveUsage),
408
+ }),
409
+ usage: effectiveUsage,
214
410
  });
215
- return { content, calls, messageId, started, usage };
411
+ return { content, calls, messageId, started, usage: effectiveUsage };
216
412
  }
217
413
  catch (error) {
218
414
  if (isSteerSoftInterrupt(error) || isSteerSoftInterrupt(turnAbort.signal.reason)) {
@@ -223,8 +419,13 @@ export async function generateProviderTurn(session, request, runId, signal, secr
223
419
  sessionId: session.id,
224
420
  runId,
225
421
  turn,
226
- metadata: buildMetadata({ latencyMs }),
227
- usage,
422
+ metadata: buildMetadata({
423
+ latencyMs,
424
+ stopReason: "abort",
425
+ budgets: turnBudgets(session, request.model, effectiveUsage),
426
+ ...cacheMetadata(effectiveUsage),
427
+ }),
428
+ usage: effectiveUsage,
228
429
  });
229
430
  throw new SteerSoftInterrupt();
230
431
  }
@@ -236,8 +437,14 @@ export async function generateProviderTurn(session, request, runId, signal, secr
236
437
  sessionId: session.id,
237
438
  runId,
238
439
  turn,
239
- metadata: buildMetadata({ latencyMs, httpStatus: readProviderHttpStatus(info) }),
240
- usage,
440
+ metadata: buildMetadata({
441
+ latencyMs,
442
+ httpStatus: readProviderHttpStatus(info),
443
+ stopReason: signal.aborted || turnAbort.signal.aborted ? "abort" : "provider_error",
444
+ budgets: turnBudgets(session, request.model, effectiveUsage),
445
+ ...cacheMetadata(effectiveUsage),
446
+ }),
447
+ usage: effectiveUsage,
241
448
  error: info,
242
449
  });
243
450
  if (error instanceof GuardrailError || error instanceof ProviderTurnFailure)
@@ -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,51 @@ 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
+ }
294
+ /** Last-N dispatched tool calls kept for `budget_exhausted` attribution (plan 087 T2); the hash
295
+ * is the same canonical arguments hash the effect store uses, so raw args never enter events. */
296
+ const RECENT_TOOL_CALL_LIMIT = 10;
297
+ function recordRecentToolCall(session, call) {
298
+ const recent = (session.activeRecentToolCalls ??= []);
299
+ recent.push({ id: call.id, name: call.name, argHash: `sha256:${toolEffectArgumentsHash(call.arguments)}` });
300
+ if (recent.length > RECENT_TOOL_CALL_LIMIT)
301
+ recent.shift();
302
+ }
303
+ function dispatchFilter(ctx) {
304
+ const hiddenOk = ctx.options.allowHiddenToolCalls ?? ctx.session.agent.config.allowHiddenToolCalls;
305
+ if (hiddenOk || ctx.turnAllow === undefined) {
306
+ return ctx.tools.length > 0 ? { filter: { allow: ctx.tools.map((tool) => tool.name) } } : {};
307
+ }
308
+ if (ctx.turnAllow.length > 0)
309
+ return { filter: { allow: ctx.turnAllow } };
310
+ return ctx.tools.length > 0 ? { filter: { deny: ctx.tools.map((tool) => tool.name) } } : {};
311
+ }
241
312
  export function bindDispatchToolCall(ctx) {
242
313
  return async (call) => {
243
314
  const sticky = matchStickyDecision(ctx.session, call, ctx.registry);
@@ -252,6 +323,7 @@ export function bindDispatchToolCall(ctx) {
252
323
  return { toolCallId: call.id, name: call.name, metadata: { approvalPending: true } };
253
324
  }
254
325
  ctx.toolCalls += 1;
326
+ recordRecentToolCall(ctx.session, call);
255
327
  try {
256
328
  const result = await dispatchToolCall({
257
329
  call,
@@ -279,7 +351,7 @@ export function bindDispatchToolCall(ctx) {
279
351
  ownership: ctx.session.activeOwnership,
280
352
  identity: ctx.session.activeIdentity,
281
353
  guardrails: ctx.session.activeGuardrails,
282
- ...(ctx.tools.length > 0 ? { filter: { allow: ctx.tools.map((tool) => tool.name) } } : {}),
354
+ ...dispatchFilter(ctx),
283
355
  limitTracker: ctx.limits,
284
356
  beforeExecute: async (mediatedCall) => {
285
357
  const durable = ctx.session.activeDurable;
@@ -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";
4
- import type { AttentionStickyFrontier, 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, ToolDefinition, ToolEffectStore, ToolRegistry, ToolResult, Usage } from "../../contracts.js";
3
+ import type { PendingToolCall, PersistedGuardrailPacks } from "../../agent-run-state.js";
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, 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";
@@ -33,9 +33,25 @@ export type SessionHost = {
33
33
  activeIdentity?: AgentIdentity;
34
34
  activeIdempotencyKey?: string;
35
35
  activeGuardrails?: Guardrails;
36
+ /** Plan 092 Task 2: packs compiled once at session construction; read-only for phases. */
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;
36
44
  activeMetadata?: Readonly<Record<string, unknown>>;
37
45
  activePromptVersion?: PromptVersionRef;
38
46
  activeLimits?: RunLimitTracker;
47
+ /** Plan 091 T2: input tokens of the latest provider turn plus whether the
48
+ * provider reported them. Set by the usage seam; read by `contextMeter()`. */
49
+ activeInputMeter?: {
50
+ readonly tokens: number;
51
+ readonly source: "reported" | "estimated";
52
+ };
53
+ /** Bounded last-N tool-call summaries of the active run (plan 087 T2): ids, names, arg hashes. */
54
+ activeRecentToolCalls?: ToolCallSummary[];
39
55
  activeLimitOutputBuffer: boolean;
40
56
  activeDurable?: ActiveDurableRun;
41
57
  activeLoop?: AgentLoopStrategy;
@@ -45,6 +61,8 @@ export type SessionHost = {
45
61
  }>;
46
62
  activeLoopTurn: number;
47
63
  readonly loadedSkills: LoadedSkillSet;
64
+ /** Run-owned monotonic prompt tail; cleared before each new run. */
65
+ readonly tailSegments: Map<string, Message>;
48
66
  readonly activatedTools: ActiveToolSet;
49
67
  restoredSkillBodies: readonly LoadedSkillBodiesEntry[];
50
68
  activeRunSkills: readonly Skill[];
@@ -57,11 +75,22 @@ export type SessionHost = {
57
75
  serializedAttentionSticky(): PersistedAttentionStickyFrontier | undefined;
58
76
  /** Plan 074 P3: restore a frontier that was validated when the checkpoint was loaded. */
59
77
  restoreAttentionSticky(persisted: PersistedAttentionStickyFrontier): void;
78
+ /** Folded bodies for this session (plan 086 T3); session-owned so a resumed fold re-applies
79
+ * the same stub bytes instead of calling the host `summarize` again. Lazily created. */
80
+ attentionFoldFor(): AttentionFoldLedger;
81
+ /** Plan 086 T3: bounded ledger snapshot for a durable checkpoint (undefined before any fold). */
82
+ serializedAttentionFold(): PersistedAttentionFoldLedger | undefined;
83
+ /** Plan 086 T3: adopt a ledger validated when the checkpoint was loaded. */
84
+ restoreAttentionFold(ledger: AttentionFoldLedger): void;
85
+ /** Plan 086 T3: `attention.compiler.durable` for the current run; set by the run assembler. */
86
+ attentionDurable: boolean;
60
87
  invalidateSnapshot(): void;
61
88
  resolveRunProvider(options: RunOptions): void;
62
89
  emit(event: AgentEvent): void;
63
90
  rebuildHistory(): Promise<void>;
64
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;
65
94
  appendEntry(entry: SessionEntry): Promise<void>;
66
95
  redact<T>(value: T): T;
67
96
  appendMessage(message: Message, runId: string): Promise<void>;
@@ -83,7 +112,15 @@ export type SessionHost = {
83
112
  readonly runState?: import("../../contracts.js").AgentRunState;
84
113
  readonly interruption?: import("../../contracts.js").AgentRunInterruption;
85
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. */
86
121
  closeSubscribers(): void;
122
+ /** Run end (finish, suspend, or deny): close only the subscribers that do not opt into `acrossRuns`. */
123
+ closeRunSubscribers(): void;
87
124
  snapshot(): Promise<SessionContextSnapshot>;
88
125
  };
89
126
  export declare function asSessionHost(session: unknown): SessionHost;
@@ -116,6 +153,8 @@ export type RoundContext = {
116
153
  loop: AgentLoopStrategy;
117
154
  toolConcurrency: number;
118
155
  toolsDisclosure: import("../../tool-search.js").ToolsDisclosure;
156
+ /** Per-turn dispatch overlay; undefined when `toolNarrowing` is unset. */
157
+ turnAllow?: readonly string[];
119
158
  assembledTurn: boolean;
120
159
  artifactFinished: boolean;
121
160
  artifactFailedInfo: {
@@ -128,6 +167,8 @@ export type RoundContext = {
128
167
  toolResults: ToolResult[];
129
168
  /** Set when a `RunOptions.turnPolicy` stop ended the loop (plan 084 Task 2). */
130
169
  runStop?: RunStopInfo;
170
+ /** Merged agent + run stop hooks, in invocation order (plan 106 R1). Empty = wrapper skipped. */
171
+ stopHooks: readonly StopHook[];
131
172
  runUsage: {
132
173
  add(usage: Usage): void;
133
174
  value(): Usage | undefined;