@arnilo/prism 0.0.23 → 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/CHANGELOG.md +39 -3
- package/dist/agent-event-source.d.ts +11 -0
- package/dist/agent-event-source.js +512 -0
- package/dist/agent-loops.js +37 -5
- package/dist/agent-run-state.d.ts +27 -1
- package/dist/agent-run-state.js +86 -5
- package/dist/agents.js +890 -78
- package/dist/contracts.d.ts +338 -4
- package/dist/contracts.js +53 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.js +5 -2
- package/dist/testing/agent-event-source-conformance.d.ts +4 -0
- package/dist/testing/agent-event-source-conformance.js +54 -0
- package/dist/testing/persistence-schema.d.ts +2 -2
- package/dist/testing/persistence-schema.js +58 -21
- package/dist/testing/tool-effect-store-conformance.d.ts +9 -0
- package/dist/testing/tool-effect-store-conformance.js +85 -0
- package/dist/tool-effects.d.ts +15 -0
- package/dist/tool-effects.js +338 -0
- package/dist/tools.d.ts +4 -1
- package/dist/tools.js +219 -9
- package/docs/0.1.0-readiness.md +10 -9
- package/docs/a2a.md +6 -2
- package/docs/ag-ui-adoption.md +77 -0
- package/docs/ag-ui.md +77 -42
- package/docs/agent-events.md +5 -1
- package/docs/agent-loops.md +9 -1
- package/docs/agent-session-runtime.md +9 -2
- package/docs/browser-automation.md +2 -0
- package/docs/coding-agent-tools.md +2 -0
- package/docs/coding-security.md +1 -1
- package/docs/database-persistence.md +2 -0
- package/docs/enterprise-postgres-state.md +5 -1
- package/docs/host-security.md +8 -1
- package/docs/index.md +13 -11
- package/docs/mcp-tools.md +19 -2
- package/docs/migration.md +46 -0
- package/docs/performance.md +25 -0
- package/docs/postgres-persistence.md +5 -2
- package/docs/public-contracts.md +2 -0
- package/docs/release-and-install.md +70 -690
- package/docs/server.md +10 -6
- package/docs/sqlite-persistence.md +10 -2
- package/docs/supervisors.md +6 -0
- package/docs/tool-effects.md +95 -0
- package/docs/tools.md +4 -0
- package/docs/work-tools.md +4 -0
- package/docs/workflows.md +1 -1
- package/package.json +11 -3
package/dist/agents.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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: {
|
|
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: {
|
|
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
|
-
|
|
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;
|
|
@@ -160,6 +493,7 @@ class RuntimeAgentSession {
|
|
|
160
493
|
activeRedactor;
|
|
161
494
|
activeProvider;
|
|
162
495
|
activeLedger;
|
|
496
|
+
activeEffectStore;
|
|
163
497
|
activeOwnership;
|
|
164
498
|
activeIdentity;
|
|
165
499
|
activeIdempotencyKey;
|
|
@@ -168,6 +502,9 @@ class RuntimeAgentSession {
|
|
|
168
502
|
activeLimits;
|
|
169
503
|
activeLimitOutputBuffer = false;
|
|
170
504
|
activeDurable;
|
|
505
|
+
activeLoop;
|
|
506
|
+
/** Gated calls of the current tool round awaiting one collected suspension. */
|
|
507
|
+
activeGatedRound;
|
|
171
508
|
activeLoopTurn = 1;
|
|
172
509
|
loadedSkills = createLoadedSkillSet();
|
|
173
510
|
ledgerChain = Promise.resolve();
|
|
@@ -217,13 +554,29 @@ class RuntimeAgentSession {
|
|
|
217
554
|
this.pendingSoftInterrupt = true;
|
|
218
555
|
}
|
|
219
556
|
}
|
|
220
|
-
async resumeDurable(state, runState, ownership, signal) {
|
|
557
|
+
async resumeDurable(state, runState, ownership, signal, decisions) {
|
|
221
558
|
return this.runInternal(state.input ?? [], { runState, ownership, signal }, state.runId, {
|
|
222
559
|
options: runState,
|
|
223
560
|
state,
|
|
224
561
|
version: state.version,
|
|
562
|
+
decisions,
|
|
225
563
|
});
|
|
226
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
|
+
}
|
|
227
580
|
async recordDurableDenial(runId, interruption, version, ownership) {
|
|
228
581
|
this.activeLedger = this.agent.config.runLedger;
|
|
229
582
|
this.activeOwnership = ownership ?? this.agent.config.ownership;
|
|
@@ -244,6 +597,7 @@ class RuntimeAgentSession {
|
|
|
244
597
|
(options.redactor !== undefined ||
|
|
245
598
|
options.ownership !== undefined ||
|
|
246
599
|
options.validate !== undefined ||
|
|
600
|
+
options.effectStore !== undefined ||
|
|
247
601
|
options.runState !== undefined)) {
|
|
248
602
|
throw new AgentRunStateError("Secure agent defaults cannot be replaced per run");
|
|
249
603
|
}
|
|
@@ -257,11 +611,12 @@ class RuntimeAgentSession {
|
|
|
257
611
|
}
|
|
258
612
|
if (durableOptions) {
|
|
259
613
|
validateRunStateOptions(durableOptions);
|
|
260
|
-
if (options.model || options.guardrails || options.loop)
|
|
261
|
-
throw new AgentRunStateError("Durable runs require model, guardrails, and
|
|
614
|
+
if (options.model || options.guardrails || options.loop || options.effectStore)
|
|
615
|
+
throw new AgentRunStateError("Durable runs require model, guardrails, loop, and effect store on AgentConfig for fingerprinting");
|
|
262
616
|
const configuredLoop = this.agent.config.loop;
|
|
263
|
-
if (configuredLoop && !
|
|
264
|
-
throw new
|
|
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
|
+
}
|
|
265
620
|
}
|
|
266
621
|
if (this.activeRun) {
|
|
267
622
|
const error = new Error("Agent session already has an active run");
|
|
@@ -277,6 +632,7 @@ class RuntimeAgentSession {
|
|
|
277
632
|
this.pendingSoftInterrupt = false;
|
|
278
633
|
this.activeRedactor = options.redactor ?? this.agent.config.redactor;
|
|
279
634
|
this.activeLedger = options.runLedger ?? this.agent.config.runLedger;
|
|
635
|
+
this.activeEffectStore = options.effectStore ?? this.agent.config.effectStore;
|
|
280
636
|
this.activeOwnership = options.ownership ?? this.agent.config.ownership;
|
|
281
637
|
this.activeIdentity = resolveRunIdentity(options.identity, this.agent.config.identity, this.activeOwnership);
|
|
282
638
|
if (this.activeIdentity && !this.activeOwnership)
|
|
@@ -284,6 +640,7 @@ class RuntimeAgentSession {
|
|
|
284
640
|
this.activeIdempotencyKey = options.idempotencyKey ?? this.agent.config.idempotencyKey;
|
|
285
641
|
this.activeGuardrails = mergeGuardrails(this.agent.config.guardrails, options.guardrails);
|
|
286
642
|
this.activeDurable = resumed ?? (durableOptions ? { options: durableOptions, version: 0 } : undefined);
|
|
643
|
+
this.activeGatedRound = undefined;
|
|
287
644
|
if (resumed)
|
|
288
645
|
this.invalidateSnapshot();
|
|
289
646
|
const model = options.model ?? this.agent.config.model;
|
|
@@ -379,6 +736,7 @@ class RuntimeAgentSession {
|
|
|
379
736
|
const instructionInjectors = options.instructionInjectors ?? this.agent.config.instructionInjectors ?? [];
|
|
380
737
|
const inputLayout = options.inputLayout ?? this.agent.config.inputLayout;
|
|
381
738
|
const loop = resolveLoop(options, this.agent.config);
|
|
739
|
+
this.activeLoop = loop;
|
|
382
740
|
const toolConcurrency = resolveToolConcurrency(options, this.agent.config);
|
|
383
741
|
this.activeLoopTurn = 1;
|
|
384
742
|
const recordProviderUsage = async (turnUsage, turn, attempt) => {
|
|
@@ -401,6 +759,113 @@ class RuntimeAgentSession {
|
|
|
401
759
|
};
|
|
402
760
|
await this.activeLedger.appendUsage(redactRunLedgerRecord(usageRecord, this.activeRedactor));
|
|
403
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
|
+
};
|
|
404
869
|
// ponytail: LoopContext binds existing private helpers; loop orchestrates only.
|
|
405
870
|
let assembledTurn = false;
|
|
406
871
|
let artifactFinished = false;
|
|
@@ -415,6 +880,7 @@ class RuntimeAgentSession {
|
|
|
415
880
|
inputMessages,
|
|
416
881
|
maxToolRounds,
|
|
417
882
|
toolConcurrency,
|
|
883
|
+
restoredLoopState: resumed?.state?.loopState?.snapshot,
|
|
418
884
|
assemble: async (nextInput, toolResults, turn) => {
|
|
419
885
|
limits.charge("maxTurns");
|
|
420
886
|
const request = await assembleProviderInput({
|
|
@@ -452,8 +918,29 @@ class RuntimeAgentSession {
|
|
|
452
918
|
chargeToolRound: (calls) => {
|
|
453
919
|
if (calls.length > 0)
|
|
454
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
|
+
}
|
|
455
941
|
},
|
|
456
942
|
generate: async (request) => {
|
|
943
|
+
await suspendGatedRound();
|
|
457
944
|
if (!assembledTurn)
|
|
458
945
|
limits.charge("maxTurns");
|
|
459
946
|
assembledTurn = false;
|
|
@@ -470,65 +957,109 @@ class RuntimeAgentSession {
|
|
|
470
957
|
}
|
|
471
958
|
},
|
|
472
959
|
isToolCallExclusive: (call) => registry.get(call.name)?.exclusive === true,
|
|
473
|
-
dispatchToolCall: (call) =>
|
|
474
|
-
call,
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
signal: controller.signal,
|
|
481
|
-
metadata: {
|
|
482
|
-
...metadata,
|
|
483
|
-
loadedSkills: this.loadedSkills,
|
|
484
|
-
activeTools: tools,
|
|
485
|
-
activeSkillNames: activeSkills.map((skill) => skill.name),
|
|
486
|
-
},
|
|
487
|
-
identity: this.activeIdentity,
|
|
488
|
-
},
|
|
489
|
-
middleware: this.agent.config.middleware,
|
|
490
|
-
emit: (event) => this.emit(event),
|
|
491
|
-
permission: this.agent.config.permission,
|
|
492
|
-
trust: this.agent.config.trust,
|
|
493
|
-
redactor: this.activeRedactor,
|
|
494
|
-
ledger: this.activeLedger,
|
|
495
|
-
ownership: this.activeOwnership,
|
|
496
|
-
identity: this.activeIdentity,
|
|
497
|
-
guardrails: this.activeGuardrails,
|
|
498
|
-
limitTracker: limits,
|
|
499
|
-
beforeExecute: async (mediatedCall) => {
|
|
500
|
-
const durable = this.activeDurable;
|
|
501
|
-
if (!durable)
|
|
502
|
-
return;
|
|
503
|
-
const pending = durable.state?.pending;
|
|
504
|
-
if (pending?.call.id === mediatedCall.id && pending.status === "ready") {
|
|
505
|
-
await this.persistDurable({
|
|
506
|
-
...durable.state,
|
|
507
|
-
status: "running",
|
|
508
|
-
pending: { ...pending, status: "dispatched" },
|
|
509
|
-
interruption: undefined,
|
|
510
|
-
});
|
|
511
|
-
return;
|
|
512
|
-
}
|
|
513
|
-
if (!durable.options.interruptBeforeTool)
|
|
514
|
-
return;
|
|
515
|
-
const interruption = {
|
|
516
|
-
kind: "tool_approval",
|
|
517
|
-
reason: "Tool side effect requires approval",
|
|
518
|
-
toolCallId: mediatedCall.id,
|
|
519
|
-
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" },
|
|
520
967
|
};
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
+
},
|
|
532
1063
|
appendMessage: (message) => this.appendMessage(message, runId),
|
|
533
1064
|
hasPendingSteers: () => this.pendingSteers.length > 0,
|
|
534
1065
|
applyPendingSteers: () => this.applyPendingSteers(runId, metadata, controller.signal),
|
|
@@ -548,8 +1079,7 @@ class RuntimeAgentSession {
|
|
|
548
1079
|
this.emit(event);
|
|
549
1080
|
},
|
|
550
1081
|
};
|
|
551
|
-
|
|
552
|
-
const result = await ctx.dispatchToolCall(resumed.state.pending.call);
|
|
1082
|
+
const replayToolResult = async (result) => {
|
|
553
1083
|
await ctx.appendMessage({
|
|
554
1084
|
role: "tool",
|
|
555
1085
|
content: [
|
|
@@ -558,8 +1088,164 @@ class RuntimeAgentSession {
|
|
|
558
1088
|
],
|
|
559
1089
|
metadata: result.metadata,
|
|
560
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
|
+
}
|
|
561
1248
|
}
|
|
562
|
-
const loopUsage = await loop.run(ctx);
|
|
563
1249
|
if (loop.name === "generate-validate-revise" && !artifactFinished) {
|
|
564
1250
|
throw Object.assign(new Error(artifactFailedInfo?.message ?? "artifact loop ended without a validated artifact"), {
|
|
565
1251
|
name: "ArtifactFailed",
|
|
@@ -581,7 +1267,16 @@ class RuntimeAgentSession {
|
|
|
581
1267
|
}
|
|
582
1268
|
await this.drainLedger();
|
|
583
1269
|
const runState = this.activeDurable?.state
|
|
584
|
-
? await this.persistDurable({
|
|
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
|
+
})
|
|
585
1280
|
: undefined;
|
|
586
1281
|
this.emit({ type: "agent_finished", sessionId: this.id, runId, usage });
|
|
587
1282
|
return this.buildRunResult({ runId, status: "succeeded", usage, runState });
|
|
@@ -598,7 +1293,15 @@ class RuntimeAgentSession {
|
|
|
598
1293
|
const breach = error instanceof RunLimitError ? error.breach : limits.breach;
|
|
599
1294
|
runStatus = breach ? "failed" : controller.signal.aborted ? "aborted" : "failed";
|
|
600
1295
|
const runState = this.activeDurable?.state
|
|
601
|
-
? await this.persistDurable({
|
|
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
|
+
})
|
|
602
1305
|
: undefined;
|
|
603
1306
|
const result = this.buildRunResult({
|
|
604
1307
|
runId,
|
|
@@ -615,6 +1318,8 @@ class RuntimeAgentSession {
|
|
|
615
1318
|
if (this.activeRun === controller)
|
|
616
1319
|
this.activeRun = undefined;
|
|
617
1320
|
this.activeRunId = undefined;
|
|
1321
|
+
this.activeLoop = undefined;
|
|
1322
|
+
this.activeGatedRound = undefined;
|
|
618
1323
|
this.activeProviderTurnAbort = undefined;
|
|
619
1324
|
this.pendingSoftInterrupt = false;
|
|
620
1325
|
this.pendingSteers = [];
|
|
@@ -644,6 +1349,7 @@ class RuntimeAgentSession {
|
|
|
644
1349
|
}
|
|
645
1350
|
finally {
|
|
646
1351
|
this.activeLedger = undefined;
|
|
1352
|
+
this.activeEffectStore = undefined;
|
|
647
1353
|
this.activeOwnership = undefined;
|
|
648
1354
|
this.activeIdentity = undefined;
|
|
649
1355
|
this.activeIdempotencyKey = undefined;
|
|
@@ -711,6 +1417,10 @@ class RuntimeAgentSession {
|
|
|
711
1417
|
const durable = this.activeDurable;
|
|
712
1418
|
if (!durable)
|
|
713
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;
|
|
714
1424
|
const state = durable.state ??
|
|
715
1425
|
initialAgentRunState({
|
|
716
1426
|
agent: this.agent,
|
|
@@ -725,6 +1435,7 @@ class RuntimeAgentSession {
|
|
|
725
1435
|
interruption: input.interruption,
|
|
726
1436
|
messages: input.messages,
|
|
727
1437
|
pending: input.pending,
|
|
1438
|
+
pendingCalls: input.pendingCalls,
|
|
728
1439
|
interruptBeforeTool: durable.options.interruptBeforeTool,
|
|
729
1440
|
});
|
|
730
1441
|
return this.persistDurable({
|
|
@@ -734,9 +1445,96 @@ class RuntimeAgentSession {
|
|
|
734
1445
|
interruption: input.interruption,
|
|
735
1446
|
...(input.messages ? { input: input.messages } : {}),
|
|
736
1447
|
...(input.pending ? { pending: input.pending } : {}),
|
|
1448
|
+
...(input.pendingCalls ? { pendingCalls: input.pendingCalls } : {}),
|
|
1449
|
+
nestedRuns: input.nestedRuns ?? state.nestedRuns,
|
|
1450
|
+
...(loopState ? { loopState } : {}),
|
|
737
1451
|
counters: input.limits.snapshot(),
|
|
738
1452
|
});
|
|
739
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
|
+
}
|
|
740
1538
|
async persistDurable(state) {
|
|
741
1539
|
const durable = this.activeDurable;
|
|
742
1540
|
if (!durable)
|
|
@@ -1338,8 +2136,22 @@ function mergeCompaction(agent, run) {
|
|
|
1338
2136
|
return { ...(agent || {}), ...run };
|
|
1339
2137
|
return agent || undefined;
|
|
1340
2138
|
}
|
|
1341
|
-
|
|
1342
|
-
|
|
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";
|
|
1343
2155
|
}
|
|
1344
2156
|
function mergeGuardrails(agent, run) {
|
|
1345
2157
|
if (!agent && !run)
|