@arnilo/prism 0.0.24 → 0.0.25

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.
package/dist/agents.js CHANGED
@@ -1,7 +1,8 @@
1
- import { resolveLoop, resolveToolConcurrency } from "./agent-loops.js";
2
- import { agentFingerprint, initialAgentRunState, loadAgentRunState, publicState, saveAgentRunState, validateRunStateOptions, } from "./agent-run-state.js";
1
+ import { createHash } from "node:crypto";
2
+ import { resolveLoop, resolveToolConcurrency, singleShotLoop } from "./agent-loops.js";
3
+ import { agentFingerprint, boundedLoopSnapshot, initialAgentRunState, loadAgentRunState, publicState, saveAgentRunState, validateRunStateOptions, } from "./agent-run-state.js";
3
4
  import { createDefaultCompactionStrategy, isCompactionEntryData } from "./compaction.js";
4
- import { AgentRunError, AgentRunStateError, DEFAULT_MAX_PENDING_STEER_BYTES, DEFAULT_MAX_PENDING_STEERS } from "./contracts.js";
5
+ import { AgentDecisionError, AgentDelegationSuspendedError, AgentLoopStateError, AgentRunError, AgentRunStateError, DEFAULT_MAX_PENDING_DECISIONS, HARD_MAX_ELICITATION_BYTES, HARD_MAX_PENDING_DECISIONS, MAX_ATTRIBUTION_DEPTH, DEFAULT_MAX_PENDING_STEER_BYTES, DEFAULT_MAX_PENDING_STEERS, DEFAULT_MAX_STICKY_DECISIONS, MAX_DECISION_REASON_BYTES, MAX_ELICITATION_BYTES, } from "./contracts.js";
5
6
  import { assertGuardrailsAllowed, GuardrailError, runGuardrails } from "./guardrails.js";
6
7
  import { identityTelemetryAttributes, ownershipFromIdentity, resolveRunIdentity } from "./identity.js";
7
8
  import { createId } from "./ids.js";
@@ -19,7 +20,8 @@ import { createLoadedSkillSet, resolveSkillsDisclosure } from "./skill-disclosur
19
20
  import { resolveToolResultFold } from "./tool-result-fold.js";
20
21
  import { assertStructuredOutputRequestSupported, resolveRunProviderOptions } from "./structured-output.js";
21
22
  import { composeSystemPrompt, mergeSystemPromptConfig } from "./system-prompts.js";
22
- import { createToolRegistry, dispatchToolCall } from "./tools.js";
23
+ import { createToolRegistry, dispatchToolCall, resolveToolEffectDeclaration } from "./tools.js";
24
+ import { canonicalToolEffectJson, toolEffectArgumentsHash } from "./tool-effects.js";
23
25
  export function createAgent(config) {
24
26
  return {
25
27
  config,
@@ -71,11 +73,98 @@ async function prepareAgentRunResume(agent, ref, resume, options, signal) {
71
73
  throw new AgentRunStateError("Stale or non-suspended agent run resume");
72
74
  }
73
75
  const session = new RuntimeAgentSession({ agent, id: state.sessionId, leafId: state.leafId });
76
+ if (resume.decision !== undefined && resume.decisions !== undefined) {
77
+ throw new AgentDecisionError("ERR_PRISM_DECISION_INVALID", "Resume accepts exactly one of decision or decisions");
78
+ }
79
+ const pendingDecisions = pendingDecisionsOf(state);
80
+ // Legacy approve maps to allow-once on every pending decision; legacy deny keeps its
81
+ // terminal-denied behavior. Batch decisions are validated and applied atomically below.
82
+ const resolved = resume.decisions !== undefined
83
+ ? await resolveRunDecisions({ agent, state, decisions: resume.decisions, signal })
84
+ : resume.decision === "approve" && pendingDecisions
85
+ ? await resolveRunDecisions({
86
+ agent,
87
+ state,
88
+ decisions: pendingDecisions.map((pending) => ({ approvalId: pending.approvalId, outcome: "allow_once" })),
89
+ signal,
90
+ })
91
+ : undefined;
92
+ if (resume.decision === undefined && resume.decisions === undefined) {
93
+ throw new AgentDecisionError("ERR_PRISM_DECISION_INVALID", "Resume requires a decision or decisions");
94
+ }
95
+ if (resolved && resolved.remaining.length > 0) {
96
+ throwIfAbortedSignal(signal);
97
+ const single = resolved.remaining.length === 1 ? resolved.remaining[0] : undefined;
98
+ const interruption = {
99
+ kind: state.interruption?.kind ?? "tool_approval",
100
+ reason: `${resolved.remaining.length} approval request(s) remain`,
101
+ ...(single?.toolCallId ? { toolCallId: single.toolCallId } : {}),
102
+ ...(single?.scope.toolName ? { toolName: single.scope.toolName } : {}),
103
+ pendingDecisions: resolved.remaining,
104
+ };
105
+ const resuspended = await saveAgentRunState({
106
+ checkpoints: options.checkpoints,
107
+ state: {
108
+ ...state,
109
+ status: "suspended",
110
+ interruption,
111
+ pending: undefined,
112
+ // Decided approvals persist on their entries so a partial batch never loses them;
113
+ // they dispatch (or synthesize their result) when the run finally resumes.
114
+ pendingCalls: state.pendingCalls?.map((entry) => {
115
+ const decision = resolved.decisionsById.get(entry.approvalId);
116
+ return decision ? { ...entry, decision } : entry;
117
+ }),
118
+ // Decided nested approvals persist on their nested-run entries, keyed by
119
+ // root-visible approval id, so a partial batch never loses them either.
120
+ nestedRuns: state.nestedRuns?.map((entry) => {
121
+ const decided = entry.approvals.filter((approval) => resolved.decisionsById.has(approval.id));
122
+ if (decided.length === 0)
123
+ return entry;
124
+ return {
125
+ ...entry,
126
+ decisions: {
127
+ ...entry.decisions,
128
+ ...Object.fromEntries(decided.map((approval) => [approval.id, resolved.decisionsById.get(approval.id)])),
129
+ },
130
+ };
131
+ }),
132
+ stickyDecisions: resolved.stickyDecisions,
133
+ },
134
+ expectedVersion: record.version,
135
+ ownership: options.ownership,
136
+ fencingToken: options.fencingToken,
137
+ });
138
+ return {
139
+ kind: "resuspend",
140
+ session,
141
+ interruption,
142
+ version: resuspended.record.version,
143
+ ownership: options.ownership,
144
+ result: {
145
+ sessionId: state.sessionId,
146
+ runId: state.runId,
147
+ status: "suspended",
148
+ leafId: state.leafId,
149
+ text: "",
150
+ content: [],
151
+ runState: publicState(resuspended.state),
152
+ interruption,
153
+ },
154
+ };
155
+ }
74
156
  if (resume.decision === "deny") {
75
157
  throwIfAbortedSignal(signal);
76
158
  const denied = await saveAgentRunState({
77
159
  checkpoints: options.checkpoints,
78
- state: { ...state, status: "denied" },
160
+ state: {
161
+ ...state,
162
+ status: "denied",
163
+ loopState: undefined,
164
+ pendingCalls: undefined,
165
+ nestedRuns: undefined,
166
+ stickyDecisions: undefined,
167
+ },
79
168
  expectedVersion: record.version,
80
169
  ownership: options.ownership,
81
170
  fencingToken: options.fencingToken,
@@ -98,8 +187,9 @@ async function prepareAgentRunResume(agent, ref, resume, options, signal) {
98
187
  },
99
188
  };
100
189
  }
101
- if (state.pending?.status === "dispatched")
190
+ if (state.pending?.status === "dispatched" || state.pendingCalls?.some((entry) => entry.status === "dispatched")) {
102
191
  throw new AgentRunStateError("Ambiguous dispatched tool requires operator resolution");
192
+ }
103
193
  const configured = agent.config.runState;
104
194
  if (configured && (configured.checkpoints !== options.checkpoints || configured.definitionRevision !== options.definitionRevision)) {
105
195
  throw new AgentRunStateError("Agent durable run-state configuration mismatch on resume");
@@ -107,7 +197,12 @@ async function prepareAgentRunResume(agent, ref, resume, options, signal) {
107
197
  throwIfAbortedSignal(signal);
108
198
  const claimed = await saveAgentRunState({
109
199
  checkpoints: options.checkpoints,
110
- state: { ...state, status: "running", interruption: undefined },
200
+ state: {
201
+ ...state,
202
+ status: "running",
203
+ interruption: undefined,
204
+ stickyDecisions: resolved?.stickyDecisions ?? state.stickyDecisions,
205
+ },
111
206
  expectedVersion: record.version,
112
207
  ownership: options.ownership,
113
208
  fencingToken: options.fencingToken,
@@ -116,21 +211,235 @@ async function prepareAgentRunResume(agent, ref, resume, options, signal) {
116
211
  kind: "approve",
117
212
  session,
118
213
  state: claimed.state,
214
+ decisions: resolved?.decisionsById,
119
215
  ownership: options.ownership,
120
216
  runState: configured ?? {
121
217
  checkpoints: options.checkpoints,
122
218
  definitionRevision: options.definitionRevision,
123
219
  interruptBeforeTool: state.interruptBeforeTool,
124
220
  fencingToken: options.fencingToken,
221
+ resumeNestedRun: options.resumeNestedRun,
125
222
  },
126
223
  };
127
224
  }
225
+ /** Pending decisions of a suspended state, synthesizing the legacy single-approval shape. */
226
+ function pendingDecisionsOf(state) {
227
+ if (state.interruption?.pendingDecisions)
228
+ return state.interruption.pendingDecisions;
229
+ if (state.pending) {
230
+ return [
231
+ {
232
+ approvalId: state.pending.call.id,
233
+ kind: "tool_approval",
234
+ toolCallId: state.pending.call.id,
235
+ scope: { toolName: state.pending.call.name },
236
+ reason: state.interruption?.reason ?? "Tool side effect requires approval",
237
+ },
238
+ ];
239
+ }
240
+ return undefined;
241
+ }
242
+ /**
243
+ * Validate one decision batch against the suspended state. Fail-closed and atomic: any
244
+ * invalid entry rejects the whole batch before any CAS, leaving state and version untouched.
245
+ * Unknown and foreign approval ids share one non-enumerating error.
246
+ */
247
+ async function resolveRunDecisions(input) {
248
+ const { agent, state, decisions } = input;
249
+ if (decisions.length === 0)
250
+ throw new AgentDecisionError("ERR_PRISM_DECISION_INVALID", "Decision batch must not be empty");
251
+ if (decisions.length > HARD_MAX_PENDING_DECISIONS) {
252
+ throw new AgentDecisionError("ERR_PRISM_DECISION_LIMIT", `Decision batch exceeds ${HARD_MAX_PENDING_DECISIONS} entries`);
253
+ }
254
+ const pending = pendingDecisionsOf(state);
255
+ if (!pending?.length)
256
+ throw new AgentDecisionError("ERR_PRISM_DECISION_UNKNOWN", "No pending approval decisions for this run");
257
+ const byId = new Map(pending.map((entry) => [entry.approvalId, entry]));
258
+ const seen = new Set();
259
+ const decisionsById = new Map();
260
+ const stickies = [];
261
+ const decidedAt = new Date().toISOString();
262
+ const { registry } = activeTools(agent.config.tools);
263
+ for (const decision of decisions) {
264
+ if (seen.has(decision.approvalId)) {
265
+ throw new AgentDecisionError("ERR_PRISM_DECISION_DUPLICATE", "Duplicate approval decision in batch");
266
+ }
267
+ seen.add(decision.approvalId);
268
+ const target = byId.get(decision.approvalId);
269
+ if (!target)
270
+ throw new AgentDecisionError("ERR_PRISM_DECISION_UNKNOWN", "Unknown approval decision");
271
+ if (decision.reason !== undefined && Buffer.byteLength(decision.reason, "utf8") > MAX_DECISION_REASON_BYTES) {
272
+ throw new AgentDecisionError("ERR_PRISM_DECISION_LIMIT", `Decision reason exceeds ${MAX_DECISION_REASON_BYTES} bytes`);
273
+ }
274
+ if (decision.outcome !== "allow_once" &&
275
+ decision.outcome !== "allow_for_run" &&
276
+ decision.outcome !== "reject_once" &&
277
+ decision.outcome !== "reject_for_run") {
278
+ throw new AgentDecisionError("ERR_PRISM_DECISION_INVALID", "Unknown approval outcome");
279
+ }
280
+ if (decision.modifiedArguments !== undefined) {
281
+ if (target.kind !== "tool_approval" || !target.toolCallId) {
282
+ throw new AgentDecisionError("ERR_PRISM_DECISION_SCOPE", "Modified arguments apply only to tool approvals");
283
+ }
284
+ await validateModifiedArguments(agent, registry, state, target, decision.modifiedArguments, input.signal);
285
+ }
286
+ if (decision.elicitation !== undefined) {
287
+ if (target.kind !== "elicitation") {
288
+ throw new AgentDecisionError("ERR_PRISM_DECISION_SCOPE", "Elicitation payload applies only to elicitation decisions");
289
+ }
290
+ await validateElicitationPayload(agent, state, target, decision.elicitation, input.signal);
291
+ }
292
+ decisionsById.set(decision.approvalId, decision);
293
+ if (decision.outcome === "allow_for_run" || decision.outcome === "reject_for_run") {
294
+ stickies.push({
295
+ // A decision with modified arguments must not stick to the original arguments hash:
296
+ // the modification is one-off, so the sticky scope matches by name/effect/identity only.
297
+ scope: decision.modifiedArguments !== undefined ? { ...target.scope, argumentsHash: undefined } : target.scope,
298
+ outcome: decision.outcome,
299
+ ...(decision.reason !== undefined ? { reason: decision.reason } : {}),
300
+ // Root-owned sticky scope includes the delegation path for nested decisions.
301
+ ...(target.attribution ? { attribution: target.attribution } : {}),
302
+ decidedAt,
303
+ });
304
+ }
305
+ }
306
+ const stickyDecisions = [...(state.stickyDecisions ?? []), ...stickies];
307
+ if (stickyDecisions.length > DEFAULT_MAX_STICKY_DECISIONS) {
308
+ throw new AgentDecisionError("ERR_PRISM_DECISION_LIMIT", `Sticky decisions exceed ${DEFAULT_MAX_STICKY_DECISIONS} per run`);
309
+ }
310
+ return {
311
+ decisionsById,
312
+ stickyDecisions,
313
+ remaining: pending.filter((entry) => !seen.has(entry.approvalId)),
314
+ };
315
+ }
316
+ /** Decision-time revalidation of modified arguments: schema, then input guardrails. Policy and trust re-run at dispatch. */
317
+ async function validateModifiedArguments(agent, registry, state, target, modified, signal) {
318
+ const invalid = (message, cause) => new AgentDecisionError("ERR_PRISM_DECISION_INVALID", message, { cause });
319
+ if (JSON.stringify(modified) === undefined || Buffer.byteLength(JSON.stringify(modified), "utf8") > MAX_ELICITATION_BYTES) {
320
+ throw invalid("Modified arguments must be a bounded JSON object");
321
+ }
322
+ const call = state.pendingCalls?.find((entry) => entry.approvalId === target.approvalId)?.call ??
323
+ (state.pending && state.pending.call.id === target.toolCallId ? state.pending.call : undefined);
324
+ const toolName = target.scope.toolName ?? call?.name ?? "";
325
+ const tool = registry.get(toolName);
326
+ const context = { sessionId: state.sessionId, runId: state.runId, toolCallId: target.toolCallId ?? "", signal };
327
+ if (agent.config.validator && tool) {
328
+ const validation = await agent.config.validator(tool, modified, context);
329
+ if (validation)
330
+ throw invalid("Modified arguments failed schema validation");
331
+ }
332
+ const value = call
333
+ ? { ...call, arguments: modified }
334
+ : { type: "tool_call", id: target.toolCallId ?? "", name: toolName, arguments: modified };
335
+ const guarded = await runGuardrails({
336
+ stage: "tool_input",
337
+ guardrails: agent.config.guardrails,
338
+ value,
339
+ context: {
340
+ sessionId: state.sessionId,
341
+ runId: state.runId,
342
+ toolCallId: target.toolCallId,
343
+ toolName,
344
+ metadata: {},
345
+ signal,
346
+ },
347
+ redactor: agent.config.redactor,
348
+ });
349
+ if (guarded.terminal)
350
+ throw invalid("Modified arguments blocked by guardrail");
351
+ }
352
+ /**
353
+ * Resolve a tool's declared elicitation contract for a gated call. A throwing hook falls back to
354
+ * plain tool approval: malformed model args then surface as a tool error after approval, never
355
+ * as a run failure at the gate. Output is bounded before it enters the pending-decision record.
356
+ */
357
+ function toolElicitationRequest(tool, args, context) {
358
+ if (!tool?.elicitation)
359
+ return undefined;
360
+ let request;
361
+ try {
362
+ request = tool.elicitation(args, context);
363
+ }
364
+ catch {
365
+ return undefined;
366
+ }
367
+ if (!request)
368
+ return undefined;
369
+ const schemaText = JSON.stringify(request.schema);
370
+ if (schemaText === undefined || Buffer.byteLength(schemaText, "utf8") > HARD_MAX_ELICITATION_BYTES)
371
+ return undefined;
372
+ const reason = request.reason;
373
+ if (reason !== undefined && Buffer.byteLength(reason, "utf8") > MAX_DECISION_REASON_BYTES)
374
+ return { schema: request.schema };
375
+ return { schema: request.schema, ...(reason !== undefined ? { reason } : {}) };
376
+ }
377
+ /** Elicitation payload check: bounded JSON object, schema-required keys, host validator when configured. */
378
+ async function validateElicitationPayload(agent, state, target, payload, signal) {
379
+ const invalid = (message) => new AgentDecisionError("ERR_PRISM_DECISION_INVALID", message);
380
+ const text = JSON.stringify(payload);
381
+ if (text === undefined || Buffer.byteLength(text, "utf8") > MAX_ELICITATION_BYTES) {
382
+ throw new AgentDecisionError("ERR_PRISM_DECISION_LIMIT", `Elicitation payload exceeds ${MAX_ELICITATION_BYTES} bytes`);
383
+ }
384
+ const schema = target.elicitationSchema;
385
+ if (schema) {
386
+ const required = schema.required;
387
+ if (Array.isArray(required)) {
388
+ for (const key of required) {
389
+ if (typeof key === "string" && !Object.hasOwn(payload, key))
390
+ throw invalid(`Elicitation payload missing required key ${key}`);
391
+ }
392
+ }
393
+ if (agent.config.validator) {
394
+ const tool = {
395
+ name: target.scope.toolName ?? "elicitation",
396
+ parameters: schema,
397
+ execute: () => ({ toolCallId: "", name: "elicitation" }),
398
+ };
399
+ const context = { sessionId: state.sessionId, runId: state.runId, toolCallId: target.toolCallId ?? "elicitation", signal };
400
+ const validation = await agent.config.validator(tool, payload, context);
401
+ if (validation)
402
+ throw invalid("Elicitation payload failed schema validation");
403
+ }
404
+ }
405
+ // Tool-declared answer-shape validation, re-derived from the current registry (never persisted).
406
+ const call = state.pendingCalls?.find((entry) => entry.call.id === target.toolCallId)?.call;
407
+ const tool = call ? activeTools(agent.config.tools).registry.get(call.name) : undefined;
408
+ const validate = tool?.elicitation && call
409
+ ? safeToolElicitationValidate(tool, call.arguments, {
410
+ sessionId: state.sessionId,
411
+ runId: state.runId,
412
+ toolCallId: target.toolCallId ?? "elicitation",
413
+ signal,
414
+ })
415
+ : undefined;
416
+ if (validate) {
417
+ try {
418
+ validate(payload);
419
+ }
420
+ catch (error) {
421
+ throw invalid(error instanceof Error ? error.message : "Elicitation payload rejected by tool validation");
422
+ }
423
+ }
424
+ }
425
+ function safeToolElicitationValidate(tool, args, context) {
426
+ try {
427
+ return tool.elicitation(args, context)?.validate;
428
+ }
429
+ catch {
430
+ return undefined;
431
+ }
432
+ }
128
433
  async function executePreparedAgentRunResume(prepared, signal) {
129
434
  if (prepared.kind === "deny") {
130
435
  await prepared.session.recordDurableDenial(prepared.result.runId, prepared.interruption, prepared.version, prepared.ownership);
131
436
  return prepared.result;
132
437
  }
133
- return prepared.session.resumeDurable(prepared.state, prepared.runState, prepared.ownership, signal);
438
+ if (prepared.kind === "resuspend") {
439
+ await prepared.session.recordDurableResumption(prepared.result.runId, prepared.interruption, prepared.version, prepared.ownership);
440
+ return prepared.result;
441
+ }
442
+ return prepared.session.resumeDurable(prepared.state, prepared.runState, prepared.ownership, signal, prepared.decisions);
134
443
  }
135
444
  class AgentRunSuspended extends Error {
136
445
  state;
@@ -143,6 +452,30 @@ class AgentRunSuspended extends Error {
143
452
  this.name = "AgentRunSuspended";
144
453
  }
145
454
  }
455
+ /** Root-visible nested approval id: hashed so it stays bounded and non-enumerating at any depth. */
456
+ function nestedApprovalId(runId, childApprovalId) {
457
+ return `sub_${createHash("sha256").update(`${runId}:${childApprovalId}`).digest("hex")}`;
458
+ }
459
+ function pathsEqual(a, b) {
460
+ if (a === undefined || b === undefined)
461
+ return a === b;
462
+ return a.length === b.length && a.every((value, index) => value === b[index]);
463
+ }
464
+ function decisionScopesEqual(a, b) {
465
+ if (a.toolName !== b.toolName || a.argumentsHash !== b.argumentsHash || a.effectKind !== b.effectKind || a.identity !== b.identity)
466
+ return false;
467
+ if (a.actionConstraints === undefined || b.actionConstraints === undefined)
468
+ return a.actionConstraints === b.actionConstraints;
469
+ const keys = Object.keys(a.actionConstraints);
470
+ return (keys.length === Object.keys(b.actionConstraints).length &&
471
+ keys.every((key) => key in b.actionConstraints &&
472
+ canonicalToolEffectJson(a.actionConstraints[key]) === canonicalToolEffectJson(b.actionConstraints[key])));
473
+ }
474
+ function nestedOutcomeToolResult(outcome, toolCallId, name) {
475
+ return outcome.status === "completed"
476
+ ? { toolCallId, name, ...(outcome.value !== undefined ? { value: outcome.value } : {}) }
477
+ : { toolCallId, name, error: { code: outcome.code, message: outcome.message } };
478
+ }
146
479
  class RuntimeAgentSession {
147
480
  id;
148
481
  agent;
@@ -169,6 +502,9 @@ class RuntimeAgentSession {
169
502
  activeLimits;
170
503
  activeLimitOutputBuffer = false;
171
504
  activeDurable;
505
+ activeLoop;
506
+ /** Gated calls of the current tool round awaiting one collected suspension. */
507
+ activeGatedRound;
172
508
  activeLoopTurn = 1;
173
509
  loadedSkills = createLoadedSkillSet();
174
510
  ledgerChain = Promise.resolve();
@@ -218,13 +554,29 @@ class RuntimeAgentSession {
218
554
  this.pendingSoftInterrupt = true;
219
555
  }
220
556
  }
221
- async resumeDurable(state, runState, ownership, signal) {
557
+ async resumeDurable(state, runState, ownership, signal, decisions) {
222
558
  return this.runInternal(state.input ?? [], { runState, ownership, signal }, state.runId, {
223
559
  options: runState,
224
560
  state,
225
561
  version: state.version,
562
+ decisions,
226
563
  });
227
564
  }
565
+ async recordDurableResumption(runId, interruption, version, ownership) {
566
+ this.activeLedger = this.agent.config.runLedger;
567
+ this.activeOwnership = ownership ?? this.agent.config.ownership;
568
+ this.activeRedactor = this.agent.config.redactor;
569
+ try {
570
+ this.emit({ type: "agent_suspended", sessionId: this.id, runId, interruption, version });
571
+ await this.drainLedger();
572
+ }
573
+ finally {
574
+ this.activeLedger = undefined;
575
+ this.activeOwnership = undefined;
576
+ this.activeRedactor = undefined;
577
+ this.closeSubscribers();
578
+ }
579
+ }
228
580
  async recordDurableDenial(runId, interruption, version, ownership) {
229
581
  this.activeLedger = this.agent.config.runLedger;
230
582
  this.activeOwnership = ownership ?? this.agent.config.ownership;
@@ -262,8 +614,9 @@ class RuntimeAgentSession {
262
614
  if (options.model || options.guardrails || options.loop || options.effectStore)
263
615
  throw new AgentRunStateError("Durable runs require model, guardrails, loop, and effect store on AgentConfig for fingerprinting");
264
616
  const configuredLoop = this.agent.config.loop;
265
- if (configuredLoop && !isBuiltInLoop(configuredLoop))
266
- throw new AgentRunStateError("Custom AgentLoopStrategy is not durable");
617
+ if (configuredLoop && !isDurableLoop(configuredLoop)) {
618
+ throw new AgentLoopStateError("ERR_PRISM_LOOP_NOT_DURABLE", "Custom AgentLoopStrategy on a durable run requires snapshot and restore hooks");
619
+ }
267
620
  }
268
621
  if (this.activeRun) {
269
622
  const error = new Error("Agent session already has an active run");
@@ -287,6 +640,7 @@ class RuntimeAgentSession {
287
640
  this.activeIdempotencyKey = options.idempotencyKey ?? this.agent.config.idempotencyKey;
288
641
  this.activeGuardrails = mergeGuardrails(this.agent.config.guardrails, options.guardrails);
289
642
  this.activeDurable = resumed ?? (durableOptions ? { options: durableOptions, version: 0 } : undefined);
643
+ this.activeGatedRound = undefined;
290
644
  if (resumed)
291
645
  this.invalidateSnapshot();
292
646
  const model = options.model ?? this.agent.config.model;
@@ -382,6 +736,7 @@ class RuntimeAgentSession {
382
736
  const instructionInjectors = options.instructionInjectors ?? this.agent.config.instructionInjectors ?? [];
383
737
  const inputLayout = options.inputLayout ?? this.agent.config.inputLayout;
384
738
  const loop = resolveLoop(options, this.agent.config);
739
+ this.activeLoop = loop;
385
740
  const toolConcurrency = resolveToolConcurrency(options, this.agent.config);
386
741
  this.activeLoopTurn = 1;
387
742
  const recordProviderUsage = async (turnUsage, turn, attempt) => {
@@ -404,6 +759,113 @@ class RuntimeAgentSession {
404
759
  };
405
760
  await this.activeLedger.appendUsage(redactRunLedgerRecord(usageRecord, this.activeRedactor));
406
761
  };
762
+ // Suspends the run when a round recorded gated calls. Fires at the next provider turn
763
+ // (generate) or after the loop ends, so ungated round siblings dispatch first.
764
+ const suspendGatedRound = async () => {
765
+ const gated = this.activeGatedRound;
766
+ if (!gated?.size)
767
+ return;
768
+ const entries = [...gated.values()];
769
+ const decisions = entries.map((gatedCall) => gatedCall.decision);
770
+ const single = decisions.length === 1 ? decisions[0] : undefined;
771
+ const interruption = {
772
+ kind: single?.kind === "elicitation" ? "elicitation" : "tool_approval",
773
+ reason: single ? single.reason : `${decisions.length} tool side effects require approval`,
774
+ ...(single?.toolCallId ? { toolCallId: single.toolCallId } : {}),
775
+ ...(single?.scope.toolName ? { toolName: single.scope.toolName } : {}),
776
+ pendingDecisions: decisions,
777
+ };
778
+ throw new AgentRunSuspended(await this.suspendDurable({ runId, model, limits, interruption, pendingCalls: entries.map((gatedCall) => gatedCall.entry) }), interruption);
779
+ };
780
+ // Suspends on a nested run's pending decisions, merging any still-ready round entries
781
+ // (with their decisions attached) so a nested signal mid-replay never drops own work.
782
+ const suspendNested = async (nested) => {
783
+ const state = this.activeDurable?.state;
784
+ const kept = (state?.pendingCalls ?? [])
785
+ .filter((entry) => entry.status === "ready")
786
+ .map((entry) => {
787
+ const decision = entry.decision ?? resumed?.decisions?.get(entry.approvalId);
788
+ return decision ? { ...entry, decision } : entry;
789
+ });
790
+ const gated = [...(this.activeGatedRound?.values() ?? [])];
791
+ const pendingCalls = [
792
+ ...kept,
793
+ ...gated.map((gatedCall) => gatedCall.entry),
794
+ { call: nested.toolCall, status: "ready", approvalId: `nr_${nested.entry.runId}` },
795
+ ];
796
+ const keptIds = new Set(kept.map((entry) => entry.approvalId));
797
+ const decisions = [
798
+ ...(state?.interruption?.pendingDecisions ?? []).filter((pending) => keptIds.has(pending.approvalId)),
799
+ ...gated.map((gatedCall) => gatedCall.decision),
800
+ ...nested.pending,
801
+ ];
802
+ if (decisions.length > HARD_MAX_PENDING_DECISIONS) {
803
+ throw new AgentDecisionError("ERR_PRISM_DECISION_LIMIT", `Pending decisions exceed ${HARD_MAX_PENDING_DECISIONS} per run`);
804
+ }
805
+ const single = decisions.length === 1 ? decisions[0] : undefined;
806
+ const interruption = {
807
+ kind: single?.kind ?? "tool_approval",
808
+ reason: single ? single.reason : `${decisions.length} approval request(s) need a decision`,
809
+ ...(single?.toolCallId ? { toolCallId: single.toolCallId } : {}),
810
+ ...(single?.scope.toolName ? { toolName: single.scope.toolName } : {}),
811
+ pendingDecisions: decisions,
812
+ };
813
+ throw new AgentRunSuspended(await this.suspendDurable({
814
+ runId,
815
+ model,
816
+ limits,
817
+ interruption,
818
+ pendingCalls,
819
+ nestedRuns: [...(state?.nestedRuns ?? []), nested.entry],
820
+ }), interruption);
821
+ };
822
+ // Converts a nested-run suspension into either root-visible pending decisions (hashed,
823
+ // attributed approval ids) or — when a root sticky covers every surfaced decision and a
824
+ // hook is available — an immediate child resume loop ending in a synthesized tool result.
825
+ const applyNestedRun = async (input) => {
826
+ let current = input.pending;
827
+ // ponytail: sticky auto-apply only when the whole surfaced set is covered; mixed sets
828
+ // surface to the host. Hook round-trips capped at 4 per suspension event.
829
+ for (let depth = 0;; depth += 1) {
830
+ const attributed = current.map((decision) => {
831
+ const id = nestedApprovalId(input.ref.runId, decision.approvalId);
832
+ return {
833
+ id,
834
+ childApprovalId: decision.approvalId,
835
+ decision: { ...decision, approvalId: id, attribution: decision.attribution ?? { path: input.path } },
836
+ };
837
+ });
838
+ if (attributed.some(({ decision }) => (decision.attribution?.path.length ?? 0) > MAX_ATTRIBUTION_DEPTH)) {
839
+ throw new AgentDecisionError("ERR_PRISM_DECISION_LIMIT", `Attribution path exceeds ${MAX_ATTRIBUTION_DEPTH} entries`);
840
+ }
841
+ const allSticky = attributed.length > 0 && attributed.every(({ decision }) => this.matchNestedSticky(decision) !== undefined);
842
+ if (!input.hook || !allSticky || depth >= 4) {
843
+ return {
844
+ entry: {
845
+ runId: input.ref.runId,
846
+ ...(input.ref.sessionId ? { sessionId: input.ref.sessionId } : {}),
847
+ toolCallId: input.toolCall.id,
848
+ path: attributed[0]?.decision.attribution?.path ?? input.path,
849
+ approvals: attributed.map(({ id, childApprovalId }) => ({ id, childApprovalId })),
850
+ },
851
+ pending: attributed.map(({ decision }) => decision),
852
+ };
853
+ }
854
+ const outcome = await input.hook({ ref: input.ref, toolCallId: input.toolCall.id, path: input.path }, attributed.map(({ childApprovalId, decision }) => {
855
+ const sticky = this.matchNestedSticky(decision);
856
+ return {
857
+ approvalId: childApprovalId,
858
+ outcome: sticky.outcome,
859
+ ...(sticky.reason !== undefined ? { reason: sticky.reason } : {}),
860
+ };
861
+ }));
862
+ if (outcome.status === "suspended") {
863
+ current = outcome.pendingDecisions;
864
+ continue;
865
+ }
866
+ return { toolResult: nestedOutcomeToolResult(outcome, input.toolCall.id, input.toolCall.name) };
867
+ }
868
+ };
407
869
  // ponytail: LoopContext binds existing private helpers; loop orchestrates only.
408
870
  let assembledTurn = false;
409
871
  let artifactFinished = false;
@@ -418,6 +880,7 @@ class RuntimeAgentSession {
418
880
  inputMessages,
419
881
  maxToolRounds,
420
882
  toolConcurrency,
883
+ restoredLoopState: resumed?.state?.loopState?.snapshot,
421
884
  assemble: async (nextInput, toolResults, turn) => {
422
885
  limits.charge("maxTurns");
423
886
  const request = await assembleProviderInput({
@@ -455,8 +918,29 @@ class RuntimeAgentSession {
455
918
  chargeToolRound: (calls) => {
456
919
  if (calls.length > 0)
457
920
  limits.charge("maxToolRounds");
921
+ const durable = this.activeDurable;
922
+ if (!durable || !durable.options.interruptBeforeTool || calls.length === 0)
923
+ return;
924
+ // Round-level gate: record one pending decision per uncovered gated call. Ungated
925
+ // and sticky-allowed calls still dispatch; the suspension fires at the next provider
926
+ // turn (or run end) via suspendGatedRound. Per-call beforeExecute is the backstop
927
+ // for loops that dispatch without charging a round.
928
+ for (const call of calls) {
929
+ if (this.matchStickyDecision(call, registry))
930
+ continue;
931
+ const approvalId = randomId("approval");
932
+ this.activeGatedRound ??= new Map();
933
+ this.activeGatedRound.set(call.id, {
934
+ entry: { call, status: "ready", approvalId },
935
+ decision: this.buildPendingDecision(call, approvalId, registry, runId, metadata, controller.signal),
936
+ });
937
+ }
938
+ if (this.activeGatedRound && this.activeGatedRound.size > DEFAULT_MAX_PENDING_DECISIONS) {
939
+ throw new AgentDecisionError("ERR_PRISM_DECISION_LIMIT", `Pending decisions exceed ${DEFAULT_MAX_PENDING_DECISIONS} per run`);
940
+ }
458
941
  },
459
942
  generate: async (request) => {
943
+ await suspendGatedRound();
460
944
  if (!assembledTurn)
461
945
  limits.charge("maxTurns");
462
946
  assembledTurn = false;
@@ -473,66 +957,109 @@ class RuntimeAgentSession {
473
957
  }
474
958
  },
475
959
  isToolCallExclusive: (call) => registry.get(call.name)?.exclusive === true,
476
- dispatchToolCall: (call) => dispatchToolCall({
477
- call,
478
- registry,
479
- context: {
480
- sessionId: this.id,
481
- runId,
482
- toolCallId: call.id,
483
- signal: controller.signal,
484
- metadata: {
485
- ...metadata,
486
- loadedSkills: this.loadedSkills,
487
- activeTools: tools,
488
- activeSkillNames: activeSkills.map((skill) => skill.name),
489
- },
490
- identity: this.activeIdentity,
491
- },
492
- middleware: this.agent.config.middleware,
493
- emit: (event) => this.emit(event),
494
- permission: this.agent.config.permission,
495
- trust: this.agent.config.trust,
496
- redactor: this.activeRedactor,
497
- ledger: this.activeLedger,
498
- effectStore: this.activeEffectStore,
499
- ownership: this.activeOwnership,
500
- identity: this.activeIdentity,
501
- guardrails: this.activeGuardrails,
502
- limitTracker: limits,
503
- beforeExecute: async (mediatedCall) => {
504
- const durable = this.activeDurable;
505
- if (!durable)
506
- return;
507
- const pending = durable.state?.pending;
508
- if (pending?.call.id === mediatedCall.id && pending.status === "ready") {
509
- await this.persistDurable({
510
- ...durable.state,
511
- status: "running",
512
- pending: { ...pending, status: "dispatched" },
513
- interruption: undefined,
514
- });
515
- return;
516
- }
517
- if (!durable.options.interruptBeforeTool)
518
- return;
519
- const interruption = {
520
- kind: "tool_approval",
521
- reason: "Tool side effect requires approval",
522
- toolCallId: mediatedCall.id,
523
- toolName: mediatedCall.name,
960
+ dispatchToolCall: async (call) => {
961
+ const sticky = this.matchStickyDecision(call, registry);
962
+ if (sticky?.outcome === "reject_for_run") {
963
+ return {
964
+ toolCallId: call.id,
965
+ name: call.name,
966
+ error: { code: "approval_rejected", message: sticky.reason ?? "Rejected for this run" },
524
967
  };
525
- throw new AgentRunSuspended(await this.suspendDurable({
526
- runId,
527
- model,
528
- limits,
529
- interruption,
530
- pending: { call: mediatedCall, status: "ready" },
531
- }), interruption);
532
- },
533
- // ponytail: RunOptions wins; array-compose deferred (roadmap: compose-later).
534
- validate,
535
- }),
968
+ }
969
+ if (this.activeGatedRound?.has(call.id)) {
970
+ // Gated this round: never dispatched. The marker is skipped by
971
+ // dispatchToolCallsInOrder so the transcript stays free of phantom results.
972
+ return { toolCallId: call.id, name: call.name, metadata: { approvalPending: true } };
973
+ }
974
+ try {
975
+ return await dispatchToolCall({
976
+ call,
977
+ registry,
978
+ context: {
979
+ sessionId: this.id,
980
+ runId,
981
+ toolCallId: call.id,
982
+ signal: controller.signal,
983
+ metadata: {
984
+ ...metadata,
985
+ loadedSkills: this.loadedSkills,
986
+ activeTools: tools,
987
+ activeSkillNames: activeSkills.map((skill) => skill.name),
988
+ },
989
+ identity: this.activeIdentity,
990
+ },
991
+ middleware: this.agent.config.middleware,
992
+ emit: (event) => this.emit(event),
993
+ permission: this.agent.config.permission,
994
+ trust: this.agent.config.trust,
995
+ redactor: this.activeRedactor,
996
+ ledger: this.activeLedger,
997
+ effectStore: this.activeEffectStore,
998
+ ownership: this.activeOwnership,
999
+ identity: this.activeIdentity,
1000
+ guardrails: this.activeGuardrails,
1001
+ limitTracker: limits,
1002
+ beforeExecute: async (mediatedCall) => {
1003
+ const durable = this.activeDurable;
1004
+ if (!durable)
1005
+ return;
1006
+ const pendingCalls = durable.state?.pendingCalls;
1007
+ const matched = pendingCalls?.find((entry) => entry.call.id === mediatedCall.id && entry.status === "ready");
1008
+ if (matched) {
1009
+ await this.persistDurable({
1010
+ ...durable.state,
1011
+ status: "running",
1012
+ pendingCalls: pendingCalls.map((entry) => (entry === matched ? { ...entry, status: "dispatched" } : entry)),
1013
+ interruption: undefined,
1014
+ });
1015
+ return;
1016
+ }
1017
+ const pending = durable.state?.pending;
1018
+ if (pending?.call.id === mediatedCall.id && pending.status === "ready") {
1019
+ await this.persistDurable({
1020
+ ...durable.state,
1021
+ status: "running",
1022
+ pending: { ...pending, status: "dispatched" },
1023
+ interruption: undefined,
1024
+ });
1025
+ return;
1026
+ }
1027
+ if (!durable.options.interruptBeforeTool)
1028
+ return;
1029
+ if (this.matchStickyDecision(mediatedCall, registry)?.outcome === "allow_for_run")
1030
+ return;
1031
+ // Backstop for loops that dispatch without awaiting chargeToolRound: suspend on
1032
+ // the first uncovered gated call with a single pending decision.
1033
+ const approvalId = randomId("approval");
1034
+ const decision = this.buildPendingDecision(mediatedCall, approvalId, registry, runId, metadata, controller.signal);
1035
+ const interruption = {
1036
+ kind: "tool_approval",
1037
+ reason: decision.reason,
1038
+ toolCallId: mediatedCall.id,
1039
+ toolName: mediatedCall.name,
1040
+ pendingDecisions: [decision],
1041
+ };
1042
+ throw new AgentRunSuspended(await this.suspendDurable({
1043
+ runId,
1044
+ model,
1045
+ limits,
1046
+ interruption,
1047
+ pending: { call: mediatedCall, status: "ready" },
1048
+ pendingCalls: [{ call: mediatedCall, status: "ready", approvalId }],
1049
+ }), interruption);
1050
+ },
1051
+ // ponytail: RunOptions wins; array-compose deferred (roadmap: compose-later).
1052
+ validate,
1053
+ });
1054
+ }
1055
+ catch (error) {
1056
+ // Link the suspension signal to the hosting call so the root suspension can
1057
+ // synthesize this call's tool_result when the nested run later terminates.
1058
+ if (error instanceof AgentDelegationSuspendedError && !error.toolCall)
1059
+ error.toolCall = call;
1060
+ throw error;
1061
+ }
1062
+ },
536
1063
  appendMessage: (message) => this.appendMessage(message, runId),
537
1064
  hasPendingSteers: () => this.pendingSteers.length > 0,
538
1065
  applyPendingSteers: () => this.applyPendingSteers(runId, metadata, controller.signal),
@@ -552,8 +1079,7 @@ class RuntimeAgentSession {
552
1079
  this.emit(event);
553
1080
  },
554
1081
  };
555
- if (resumed?.state?.pending?.status === "ready") {
556
- const result = await ctx.dispatchToolCall(resumed.state.pending.call);
1082
+ const replayToolResult = async (result) => {
557
1083
  await ctx.appendMessage({
558
1084
  role: "tool",
559
1085
  content: [
@@ -562,8 +1088,164 @@ class RuntimeAgentSession {
562
1088
  ],
563
1089
  metadata: result.metadata,
564
1090
  });
1091
+ };
1092
+ // Handles a nested-run suspension signal: sticky auto-apply appends a synthesized
1093
+ // tool_result and returns; anything else re-suspends the root (throws AgentRunSuspended).
1094
+ const handleNestedSignal = async (error) => {
1095
+ const durableOptions = this.activeDurable?.options;
1096
+ if (!durableOptions || !error.toolCall) {
1097
+ throw new AgentDecisionError("ERR_PRISM_DECISION_INVALID", "Nested-run suspension requires durable run state and a hosting tool call");
1098
+ }
1099
+ if (error.pendingDecisions.length === 0) {
1100
+ throw new AgentDecisionError("ERR_PRISM_DECISION_INVALID", "Nested-run suspension carried no pending decisions");
1101
+ }
1102
+ const applied = await applyNestedRun({
1103
+ ref: error.ref,
1104
+ toolCall: error.toolCall,
1105
+ path: error.path ?? [],
1106
+ pending: error.pendingDecisions,
1107
+ hook: durableOptions.resumeNestedRun,
1108
+ });
1109
+ if ("toolResult" in applied) {
1110
+ await replayToolResult(applied.toolResult);
1111
+ return;
1112
+ }
1113
+ await suspendNested({ entry: applied.entry, toolCall: error.toolCall, pending: applied.pending });
1114
+ };
1115
+ // Route decided nested-run approvals back to their children before replaying own calls.
1116
+ // Undecided or re-suspended children re-suspend the root with the surfaced remainder.
1117
+ let resumePendingCalls = resumed?.state?.pendingCalls;
1118
+ if (resumed?.state?.nestedRuns?.length) {
1119
+ const nestedRuns = resumed.state.nestedRuns;
1120
+ const hook = this.activeDurable?.options.resumeNestedRun;
1121
+ const oldPending = resumed.state.interruption?.pendingDecisions ?? [];
1122
+ const nestedApprovalIds = new Set(nestedRuns.flatMap((entry) => entry.approvals.map((approval) => approval.id)));
1123
+ const remainingNested = [];
1124
+ const surfacedPending = [];
1125
+ const resolvedToolCallIds = new Set();
1126
+ for (const entry of nestedRuns) {
1127
+ const grouped = [];
1128
+ for (const approval of entry.approvals) {
1129
+ const decision = entry.decisions?.[approval.id] ?? resumed.decisions?.get(approval.id);
1130
+ if (decision)
1131
+ grouped.push({ ...decision, approvalId: approval.childApprovalId });
1132
+ }
1133
+ if (grouped.length === 0) {
1134
+ remainingNested.push(entry);
1135
+ surfacedPending.push(...oldPending.filter((pending) => entry.approvals.some((approval) => approval.id === pending.approvalId)));
1136
+ continue;
1137
+ }
1138
+ if (!hook) {
1139
+ throw new AgentDecisionError("ERR_PRISM_DECISION_INVALID", "Nested-run decisions require a resumeNestedRun hook");
1140
+ }
1141
+ const toolCall = resumePendingCalls?.find((pending) => pending.call.id === entry.toolCallId)?.call;
1142
+ if (!toolCall)
1143
+ throw new AgentRunStateError("Nested run link is missing its tool call");
1144
+ const ref = { runId: entry.runId, ...(entry.sessionId ? { sessionId: entry.sessionId } : {}) };
1145
+ const outcome = await hook({ ref, toolCallId: entry.toolCallId, path: entry.path }, grouped);
1146
+ if (outcome.status !== "suspended") {
1147
+ await replayToolResult(nestedOutcomeToolResult(outcome, entry.toolCallId, toolCall.name));
1148
+ resolvedToolCallIds.add(entry.toolCallId);
1149
+ continue;
1150
+ }
1151
+ const applied = await applyNestedRun({ ref, toolCall, path: entry.path, pending: outcome.pendingDecisions, hook });
1152
+ if ("toolResult" in applied) {
1153
+ await replayToolResult(applied.toolResult);
1154
+ resolvedToolCallIds.add(entry.toolCallId);
1155
+ }
1156
+ else {
1157
+ remainingNested.push(applied.entry);
1158
+ surfacedPending.push(...applied.pending);
1159
+ }
1160
+ }
1161
+ resumePendingCalls = resumePendingCalls
1162
+ ?.filter((entry) => !resolvedToolCallIds.has(entry.call.id))
1163
+ .map((entry) => {
1164
+ const decision = resumed.decisions?.get(entry.approvalId);
1165
+ return decision && !entry.decision ? { ...entry, decision } : entry;
1166
+ });
1167
+ if (this.activeDurable?.state) {
1168
+ this.activeDurable.state = {
1169
+ ...this.activeDurable.state,
1170
+ pendingCalls: resumePendingCalls?.length ? resumePendingCalls : undefined,
1171
+ nestedRuns: remainingNested.length ? remainingNested : undefined,
1172
+ };
1173
+ }
1174
+ const remainingOwn = oldPending.filter((pending) => !nestedApprovalIds.has(pending.approvalId) &&
1175
+ !resumed.decisions?.has(pending.approvalId) &&
1176
+ !resumePendingCalls?.some((entry) => entry.approvalId === pending.approvalId && entry.decision !== undefined));
1177
+ if (remainingOwn.length > 0 || surfacedPending.length > 0) {
1178
+ const pendingDecisions = [...remainingOwn, ...surfacedPending];
1179
+ const single = pendingDecisions.length === 1 ? pendingDecisions[0] : undefined;
1180
+ const interruption = {
1181
+ kind: single?.kind ?? "tool_approval",
1182
+ reason: `${pendingDecisions.length} approval request(s) remain`,
1183
+ ...(single?.toolCallId ? { toolCallId: single.toolCallId } : {}),
1184
+ ...(single?.scope.toolName ? { toolName: single.scope.toolName } : {}),
1185
+ pendingDecisions,
1186
+ };
1187
+ throw new AgentRunSuspended(await this.suspendDurable({
1188
+ runId,
1189
+ model,
1190
+ limits,
1191
+ interruption,
1192
+ pendingCalls: resumePendingCalls,
1193
+ nestedRuns: remainingNested,
1194
+ }), interruption);
1195
+ }
1196
+ }
1197
+ if (resumePendingCalls?.length) {
1198
+ for (const entry of resumePendingCalls) {
1199
+ if (entry.status !== "ready")
1200
+ continue;
1201
+ const decision = entry.decision ?? resumed?.decisions?.get(entry.approvalId);
1202
+ if (decision && (decision.outcome === "reject_once" || decision.outcome === "reject_for_run")) {
1203
+ await replayToolResult({
1204
+ toolCallId: entry.call.id,
1205
+ name: entry.call.name,
1206
+ error: { code: "approval_rejected", message: decision.reason ?? "Approval rejected" },
1207
+ });
1208
+ continue;
1209
+ }
1210
+ if (decision?.elicitation !== undefined) {
1211
+ // Elicitation acceptance resolves the suspended call with the validated payload.
1212
+ await replayToolResult({ toolCallId: entry.call.id, name: entry.call.name, value: decision.elicitation });
1213
+ continue;
1214
+ }
1215
+ const call = decision?.modifiedArguments ? { ...entry.call, arguments: decision.modifiedArguments } : entry.call;
1216
+ try {
1217
+ await replayToolResult(await ctx.dispatchToolCall(call));
1218
+ }
1219
+ catch (error) {
1220
+ if (!(error instanceof AgentDelegationSuspendedError))
1221
+ throw error;
1222
+ await handleNestedSignal(error);
1223
+ }
1224
+ }
1225
+ }
1226
+ else if (resumed?.state?.pending?.status === "ready") {
1227
+ await replayToolResult(await ctx.dispatchToolCall(resumed.state.pending.call));
1228
+ }
1229
+ const resumedLoopState = resumed?.state?.loopState;
1230
+ if (resumedLoopState) {
1231
+ if (loop.name !== resumedLoopState.name || (loop.revision ?? "1") !== resumedLoopState.revision) {
1232
+ throw new AgentLoopStateError("ERR_PRISM_LOOP_REVISION", `Loop ${resumedLoopState.name} revision ${resumedLoopState.revision} does not match the resumed durable run`);
1233
+ }
1234
+ loop.restore?.(resumedLoopState.snapshot);
1235
+ }
1236
+ let loopUsage;
1237
+ while (true) {
1238
+ try {
1239
+ loopUsage = await loop.run(ctx);
1240
+ await suspendGatedRound();
1241
+ break;
1242
+ }
1243
+ catch (error) {
1244
+ if (!(error instanceof AgentDelegationSuspendedError))
1245
+ throw error;
1246
+ await handleNestedSignal(error);
1247
+ }
565
1248
  }
566
- const loopUsage = await loop.run(ctx);
567
1249
  if (loop.name === "generate-validate-revise" && !artifactFinished) {
568
1250
  throw Object.assign(new Error(artifactFailedInfo?.message ?? "artifact loop ended without a validated artifact"), {
569
1251
  name: "ArtifactFailed",
@@ -585,7 +1267,16 @@ class RuntimeAgentSession {
585
1267
  }
586
1268
  await this.drainLedger();
587
1269
  const runState = this.activeDurable?.state
588
- ? await this.persistDurable({ ...this.activeDurable.state, status: "succeeded", pending: undefined, interruption: undefined })
1270
+ ? await this.persistDurable({
1271
+ ...this.activeDurable.state,
1272
+ status: "succeeded",
1273
+ pending: undefined,
1274
+ pendingCalls: undefined,
1275
+ nestedRuns: undefined,
1276
+ stickyDecisions: undefined,
1277
+ interruption: undefined,
1278
+ loopState: undefined,
1279
+ })
589
1280
  : undefined;
590
1281
  this.emit({ type: "agent_finished", sessionId: this.id, runId, usage });
591
1282
  return this.buildRunResult({ runId, status: "succeeded", usage, runState });
@@ -602,7 +1293,15 @@ class RuntimeAgentSession {
602
1293
  const breach = error instanceof RunLimitError ? error.breach : limits.breach;
603
1294
  runStatus = breach ? "failed" : controller.signal.aborted ? "aborted" : "failed";
604
1295
  const runState = this.activeDurable?.state
605
- ? await this.persistDurable({ ...this.activeDurable.state, status: runStatus, interruption: undefined })
1296
+ ? await this.persistDurable({
1297
+ ...this.activeDurable.state,
1298
+ status: runStatus,
1299
+ interruption: undefined,
1300
+ loopState: undefined,
1301
+ pendingCalls: undefined,
1302
+ nestedRuns: undefined,
1303
+ stickyDecisions: undefined,
1304
+ })
606
1305
  : undefined;
607
1306
  const result = this.buildRunResult({
608
1307
  runId,
@@ -619,6 +1318,8 @@ class RuntimeAgentSession {
619
1318
  if (this.activeRun === controller)
620
1319
  this.activeRun = undefined;
621
1320
  this.activeRunId = undefined;
1321
+ this.activeLoop = undefined;
1322
+ this.activeGatedRound = undefined;
622
1323
  this.activeProviderTurnAbort = undefined;
623
1324
  this.pendingSoftInterrupt = false;
624
1325
  this.pendingSteers = [];
@@ -716,6 +1417,10 @@ class RuntimeAgentSession {
716
1417
  const durable = this.activeDurable;
717
1418
  if (!durable)
718
1419
  throw new AgentRunStateError("Durable interruption is not configured");
1420
+ // Capture loop-local state before persisting the suspension. Undefined before the loop
1421
+ // starts (input-guardrail suspensions) and for snapshot-less built-ins.
1422
+ const loop = this.activeLoop;
1423
+ const loopState = loop?.snapshot ? boundedLoopSnapshot(loop.name, loop.revision ?? "1", loop.snapshot()) : undefined;
719
1424
  const state = durable.state ??
720
1425
  initialAgentRunState({
721
1426
  agent: this.agent,
@@ -730,6 +1435,7 @@ class RuntimeAgentSession {
730
1435
  interruption: input.interruption,
731
1436
  messages: input.messages,
732
1437
  pending: input.pending,
1438
+ pendingCalls: input.pendingCalls,
733
1439
  interruptBeforeTool: durable.options.interruptBeforeTool,
734
1440
  });
735
1441
  return this.persistDurable({
@@ -739,9 +1445,96 @@ class RuntimeAgentSession {
739
1445
  interruption: input.interruption,
740
1446
  ...(input.messages ? { input: input.messages } : {}),
741
1447
  ...(input.pending ? { pending: input.pending } : {}),
1448
+ ...(input.pendingCalls ? { pendingCalls: input.pendingCalls } : {}),
1449
+ nestedRuns: input.nestedRuns ?? state.nestedRuns,
1450
+ ...(loopState ? { loopState } : {}),
742
1451
  counters: input.limits.snapshot(),
743
1452
  });
744
1453
  }
1454
+ /** First attributed sticky whose scope and delegation path exactly match a nested decision. */
1455
+ matchNestedSticky(decision) {
1456
+ const stickies = this.activeDurable?.state?.stickyDecisions;
1457
+ return stickies?.find((sticky) => sticky.attribution !== undefined &&
1458
+ pathsEqual(sticky.attribution.path, decision.attribution?.path) &&
1459
+ decisionScopesEqual(sticky.scope, decision.scope));
1460
+ }
1461
+ /** First sticky decision whose scope exactly matches this call, if any. */
1462
+ matchStickyDecision(call, registry) {
1463
+ const stickies = this.activeDurable?.state?.stickyDecisions;
1464
+ if (!stickies?.length)
1465
+ return undefined;
1466
+ const identityRef = decisionIdentityRef(this.activeIdentity);
1467
+ let argumentsHash;
1468
+ let effectKind;
1469
+ let effectResolved = false;
1470
+ return stickies.find((sticky) => {
1471
+ if (sticky.attribution !== undefined)
1472
+ return false; // nested-run stickies match decisions, not calls
1473
+ const scope = sticky.scope;
1474
+ if (scope.toolName !== undefined && scope.toolName !== call.name)
1475
+ return false;
1476
+ if (scope.identity !== undefined && scope.identity !== identityRef)
1477
+ return false;
1478
+ if (scope.argumentsHash !== undefined) {
1479
+ argumentsHash ??= toolEffectArgumentsHash(call.arguments);
1480
+ if (scope.argumentsHash !== argumentsHash)
1481
+ return false;
1482
+ }
1483
+ if (scope.effectKind !== undefined) {
1484
+ if (!effectResolved) {
1485
+ effectResolved = true;
1486
+ const tool = registry.get(call.name);
1487
+ effectKind = tool?.effect
1488
+ ? resolveToolEffectDeclaration(tool, call.arguments, { sessionId: this.id, runId: "", toolCallId: call.id })?.kind
1489
+ : undefined;
1490
+ }
1491
+ if (scope.effectKind !== effectKind)
1492
+ return false;
1493
+ }
1494
+ if (scope.actionConstraints) {
1495
+ for (const [key, value] of Object.entries(scope.actionConstraints)) {
1496
+ const actual = call.arguments[key];
1497
+ if (canonicalToolEffectJson(actual === undefined ? null : actual) !== canonicalToolEffectJson(value))
1498
+ return false;
1499
+ }
1500
+ }
1501
+ return true;
1502
+ });
1503
+ }
1504
+ /** Redacted pending-decision descriptor for one gated call; never carries raw arguments. */
1505
+ buildPendingDecision(call, approvalId, registry, runId, metadata, signal) {
1506
+ const tool = registry.get(call.name);
1507
+ const declaration = tool?.effect
1508
+ ? resolveToolEffectDeclaration(tool, call.arguments, {
1509
+ sessionId: this.id,
1510
+ runId,
1511
+ toolCallId: call.id,
1512
+ signal,
1513
+ metadata,
1514
+ })
1515
+ : undefined;
1516
+ const identityRef = decisionIdentityRef(this.activeIdentity);
1517
+ const elicitation = toolElicitationRequest(tool, call.arguments, {
1518
+ sessionId: this.id,
1519
+ runId,
1520
+ toolCallId: call.id,
1521
+ signal,
1522
+ metadata,
1523
+ });
1524
+ return {
1525
+ approvalId,
1526
+ kind: elicitation ? "elicitation" : "tool_approval",
1527
+ toolCallId: call.id,
1528
+ scope: {
1529
+ toolName: call.name,
1530
+ argumentsHash: toolEffectArgumentsHash(call.arguments),
1531
+ ...(declaration && declaration.kind !== "none" ? { effectKind: declaration.kind } : {}),
1532
+ ...(identityRef ? { identity: identityRef } : {}),
1533
+ },
1534
+ reason: elicitation?.reason ?? "Tool side effect requires approval",
1535
+ ...(elicitation ? { elicitationSchema: elicitation.schema } : {}),
1536
+ };
1537
+ }
745
1538
  async persistDurable(state) {
746
1539
  const durable = this.activeDurable;
747
1540
  if (!durable)
@@ -1343,8 +2136,22 @@ function mergeCompaction(agent, run) {
1343
2136
  return { ...(agent || {}), ...run };
1344
2137
  return agent || undefined;
1345
2138
  }
1346
- function isBuiltInLoop(loop) {
1347
- return typeof loop === "object" && loop !== null && "strategy" in loop;
2139
+ /** Compact redacted principal reference used in decision scopes; never a credential. */
2140
+ function decisionIdentityRef(identity) {
2141
+ return identity ? `${identity.tenantId}:${identity.principal.kind}:${identity.principal.id}` : undefined;
2142
+ }
2143
+ /**
2144
+ * Durable-run gate: built-in option forms and the single-shot singleton are durable via the
2145
+ * pending-call mechanism; a custom strategy must declare both snapshot and restore hooks.
2146
+ */
2147
+ function isDurableLoop(loop) {
2148
+ if (typeof loop !== "object" || loop === null)
2149
+ return true;
2150
+ if ("strategy" in loop)
2151
+ return true;
2152
+ if (loop === singleShotLoop)
2153
+ return true;
2154
+ return typeof loop.snapshot === "function" && typeof loop.restore === "function";
1348
2155
  }
1349
2156
  function mergeGuardrails(agent, run) {
1350
2157
  if (!agent && !run)