@arnilo/prism 0.0.96 → 0.1.1
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 +290 -2
- package/README.md +17 -3
- package/dist/agent-definitions.js +2 -3
- package/dist/agent-event-source.d.ts +11 -0
- package/dist/agent-event-source.js +512 -0
- package/dist/agent-loops.d.ts +5 -0
- package/dist/agent-loops.js +99 -14
- package/dist/agent-run-lifecycle.d.ts +5 -2
- package/dist/agent-run-lifecycle.js +18 -2
- package/dist/agent-run-state.d.ts +27 -1
- package/dist/agent-run-state.js +113 -7
- package/dist/agents.d.ts +3 -1
- package/dist/agents.js +1255 -129
- package/dist/artifacts.d.ts +132 -0
- package/dist/artifacts.js +44 -0
- package/dist/cache-helpers.js +18 -9
- package/dist/checkpoints.d.ts +4 -0
- package/dist/checkpoints.js +17 -9
- package/dist/cli-init.js +3 -7
- package/dist/cli-runner.d.ts +2 -6
- package/dist/cli-runner.js +71 -33
- package/dist/compaction.js +5 -4
- package/dist/config.js +7 -4
- package/dist/content.js +26 -24
- package/dist/context-budget.d.ts +67 -0
- package/dist/context-budget.js +288 -0
- package/dist/contracts.d.ts +590 -8
- package/dist/contracts.js +142 -1
- package/dist/contribution-parsing.js +6 -2
- package/dist/contributions.d.ts +2 -0
- package/dist/contributions.js +3 -0
- package/dist/conversations.d.ts +50 -0
- package/dist/conversations.js +98 -0
- package/dist/credentials.d.ts +22 -2
- package/dist/credentials.js +18 -3
- package/dist/devices.d.ts +94 -0
- package/dist/devices.js +138 -0
- package/dist/event-multiplexer.js +18 -4
- package/dist/extensions.d.ts +18 -1
- package/dist/extensions.js +79 -6
- package/dist/feedback.js +12 -10
- package/dist/guardrails.d.ts +1 -1
- package/dist/guardrails.js +26 -17
- package/dist/identity.d.ts +92 -0
- package/dist/identity.js +265 -0
- package/dist/index.d.ts +94 -72
- package/dist/index.js +48 -36
- package/dist/input.d.ts +10 -1
- package/dist/input.js +152 -52
- package/dist/instruction-injection.d.ts +1 -1
- package/dist/middleware.js +9 -1
- package/dist/models.d.ts +2 -0
- package/dist/models.js +3 -0
- package/dist/node/agent-definitions.js +16 -8
- package/dist/node/contribution-discovery.d.ts +1 -2
- package/dist/node/contribution-discovery.js +3 -3
- package/dist/node/session-store-jsonl.js +13 -7
- package/dist/node/settings.d.ts +1 -1
- package/dist/node/settings.js +1 -1
- package/dist/node/system-project-prompts.js +2 -4
- package/dist/node/trust.js +1 -1
- package/dist/persistence-lifecycle.d.ts +103 -0
- package/dist/persistence-lifecycle.js +202 -0
- package/dist/provider-events.d.ts +1 -0
- package/dist/provider-events.js +6 -1
- package/dist/provider-request-policy.js +3 -4
- package/dist/providers/media.d.ts +1 -1
- package/dist/providers/openai-compatible.d.ts +46 -1
- package/dist/providers/openai-compatible.js +123 -53
- package/dist/providers/openai-primitives.js +10 -7
- package/dist/providers/transport.d.ts +6 -0
- package/dist/providers/transport.js +21 -0
- package/dist/providers.d.ts +2 -0
- package/dist/providers.js +3 -0
- package/dist/redaction.d.ts +1 -0
- package/dist/redaction.js +26 -9
- package/dist/resources.d.ts +2 -2
- package/dist/resources.js +2 -2
- package/dist/retry.d.ts +5 -0
- package/dist/retry.js +8 -1
- package/dist/rpc.js +55 -11
- package/dist/run-ledger.d.ts +6 -0
- package/dist/run-ledger.js +16 -13
- package/dist/run-limits.js +49 -10
- package/dist/secure-agent.js +8 -2
- package/dist/security.js +7 -2
- package/dist/session-stores.d.ts +7 -2
- package/dist/session-stores.js +195 -21
- package/dist/skill-disclosure.d.ts +35 -0
- package/dist/skill-disclosure.js +101 -0
- package/dist/skill-load.d.ts +25 -0
- package/dist/skill-load.js +112 -0
- package/dist/structured-output.d.ts +5 -1
- package/dist/structured-output.js +20 -2
- package/dist/system-prompts.js +7 -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/compaction-conformance.js +5 -1
- package/dist/testing/extension-conformance.js +15 -3
- package/dist/testing/feedback.d.ts +1 -3
- package/dist/testing/feedback.js +1 -1
- package/dist/testing/persistence-schema.d.ts +2 -2
- package/dist/testing/persistence-schema.js +280 -35
- package/dist/testing/provider-conformance.js +3 -3
- package/dist/testing/run-ledger-conformance.js +1 -1
- package/dist/testing/session-store-conformance.d.ts +6 -0
- package/dist/testing/session-store-conformance.js +37 -2
- package/dist/testing/tool-conformance.js +30 -5
- package/dist/testing/tool-effect-store-conformance.d.ts +9 -0
- package/dist/testing/tool-effect-store-conformance.js +85 -0
- package/dist/thinking.js +4 -1
- package/dist/tool-effects.d.ts +15 -0
- package/dist/tool-effects.js +352 -0
- package/dist/tool-result-fold.d.ts +40 -0
- package/dist/tool-result-fold.js +176 -0
- package/dist/tools.d.ts +8 -3
- package/dist/tools.js +248 -13
- package/docs/0.1.0-readiness.md +215 -0
- package/docs/a2a.md +33 -2
- package/docs/acp.md +152 -0
- package/docs/ag-ui-adoption.md +77 -0
- package/docs/ag-ui.md +225 -0
- package/docs/agent-events.md +34 -3
- package/docs/agent-identity.md +144 -0
- package/docs/agent-loops.md +17 -2
- package/docs/agent-session-runtime.md +21 -4
- package/docs/browser-automation.md +5 -0
- package/docs/caveman.md +129 -0
- package/docs/cli-rpc.md +3 -6
- package/docs/coding-agent-tools.md +229 -25
- package/docs/coding-security.md +77 -11
- package/docs/compaction-and-retry.md +5 -2
- package/docs/compaction-llm.md +20 -1
- package/docs/compaction-observational-memory.md +52 -8
- package/docs/context-and-skills.md +94 -7
- package/docs/contribution-registries.md +1 -0
- package/docs/conversations.md +135 -0
- package/docs/credential-storage.md +34 -1
- package/docs/credentials-and-redaction.md +11 -1
- package/docs/database-persistence.md +27 -7
- package/docs/device-adapters.md +97 -0
- package/docs/enterprise-postgres-state.md +178 -0
- package/docs/evaluations.md +14 -1
- package/docs/extensions.md +4 -1
- package/docs/forge-integration.md +113 -0
- package/docs/guardrails.md +16 -2
- package/docs/host-security.md +35 -4
- package/docs/index.md +69 -37
- package/docs/input-and-prompt-assembly.md +8 -7
- package/docs/language-intelligence.md +162 -0
- package/docs/mcp-tools.md +62 -5
- package/docs/middleware-hooks.md +2 -2
- package/docs/migration.md +427 -2
- package/docs/model-routing.md +111 -0
- package/docs/multimodal-content.md +8 -5
- package/docs/node-jsonl-session-store.md +1 -1
- package/docs/observability.md +2 -0
- package/docs/openapi-tools.md +56 -0
- package/docs/performance.md +282 -0
- package/docs/policy-and-audit.md +171 -0
- package/docs/ponytail.md +127 -0
- package/docs/postgres-persistence.md +8 -4
- package/docs/process-sessions.md +147 -0
- package/docs/provider-caching.md +13 -1
- package/docs/provider-conformance.md +29 -5
- package/docs/provider-packages.md +43 -2
- package/docs/provider-request-policies.md +2 -0
- package/docs/providers/ai-sdk.md +24 -7
- package/docs/providers/alibaba.md +179 -0
- package/docs/providers/anthropic.md +93 -0
- package/docs/providers/azure.md +74 -0
- package/docs/providers/bedrock.md +72 -0
- package/docs/providers/google.md +89 -0
- package/docs/providers/ollama.md +166 -0
- package/docs/providers/openai-compatible.md +31 -2
- package/docs/providers/openai.md +24 -5
- package/docs/providers/openrouter.md +2 -0
- package/docs/providers/vertex.md +71 -0
- package/docs/public-contracts.md +68 -4
- package/docs/rag.md +41 -12
- package/docs/release-and-install.md +362 -208
- package/docs/resource-loading.md +3 -0
- package/docs/runs-and-usage.md +3 -0
- package/docs/server.md +44 -6
- package/docs/session-store-conformance.md +2 -0
- package/docs/session-stores.md +41 -2
- package/docs/sqlite-persistence.md +11 -3
- package/docs/structured-output.md +7 -1
- package/docs/supervisors.md +8 -0
- package/docs/tool-effects.md +95 -0
- package/docs/tools.md +5 -0
- package/docs/work-artifacts-and-review.md +102 -0
- package/docs/work-connectors.md +32 -0
- package/docs/work-tools.md +137 -0
- package/docs/workflows.md +6 -0
- package/docs/working-and-semantic-memory.md +40 -7
- package/package.json +30 -7
- package/templates/init/providers.json +22 -0
- package/docs/review-coverage-2026-07-14.md +0 -260
- package/docs/review-coverage-2026-07-15.md +0 -193
- package/docs/review-coverage-2026-07-17-provider-validation.md +0 -192
- package/docs/review-coverage-2026-07-19-phase-3.md +0 -174
- package/docs/review-coverage-2026-07-20-phase-4.md +0 -175
package/dist/agents.js
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { resolveLoop, resolveToolConcurrency } from "./agent-loops.js";
|
|
3
|
-
import {
|
|
4
|
-
import { assertGuardrailsAllowed, GuardrailError, runGuardrails } from "./guardrails.js";
|
|
5
|
-
import { createProviderTurnMetadata, readProviderHttpStatus } from "./observability.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";
|
|
6
4
|
import { createDefaultCompactionStrategy, isCompactionEntryData } from "./compaction.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";
|
|
6
|
+
import { assertGuardrailsAllowed, GuardrailError, runGuardrails } from "./guardrails.js";
|
|
7
|
+
import { identityTelemetryAttributes, ownershipFromIdentity, resolveRunIdentity } from "./identity.js";
|
|
8
|
+
import { createId } from "./ids.js";
|
|
7
9
|
import { assembleProviderInput } from "./input.js";
|
|
10
|
+
import { createProviderTurnMetadata, readProviderHttpStatus } from "./observability.js";
|
|
8
11
|
import { providerToolCallDeltaContent, reconstructToolCallDeltas } from "./provider-events.js";
|
|
9
12
|
import { createProviderRequestPolicyChain, normalizeProviderRequestPolicyResult } from "./provider-request-policy.js";
|
|
10
|
-
import {
|
|
11
|
-
import { errorToErrorInfo, redactAgentEvent, redactProviderRequest, redactRunLedgerRecord, redactSecrets, redactSessionEntry } from "./redaction.js";
|
|
12
|
-
import { composeSystemPrompt, mergeSystemPromptConfig } from "./system-prompts.js";
|
|
13
|
+
import { errorToErrorInfo, redactAgentEvent, redactProviderRequest, redactRunLedgerRecord, redactSecrets, redactSessionEntry, } from "./redaction.js";
|
|
13
14
|
import { createDefaultRetryPolicy, waitForRetry } from "./retry.js";
|
|
14
|
-
import { createMemorySessionStore, createSessionEntry, getSessionBranchEntries, rebuildSessionContext } from "./session-stores.js";
|
|
15
15
|
import { isFlushableRunLedger } from "./run-ledger.js";
|
|
16
|
-
import { createToolRegistry, dispatchToolCall } from "./tools.js";
|
|
17
16
|
import { RunLimitError, RunLimitTracker, resolveRunLimits } from "./run-limits.js";
|
|
18
|
-
import {
|
|
17
|
+
import { createMemorySessionStore, createSessionEntry, getSessionBranchEntries, rebuildSessionContext, } from "./session-stores.js";
|
|
19
18
|
import { resolveActiveSkills } from "./skills.js";
|
|
19
|
+
import { createLoadedSkillSet, resolveSkillsDisclosure } from "./skill-disclosure.js";
|
|
20
|
+
import { resolveToolResultFold } from "./tool-result-fold.js";
|
|
21
|
+
import { assertStructuredOutputRequestSupported, resolveRunProviderOptions } from "./structured-output.js";
|
|
22
|
+
import { composeSystemPrompt, mergeSystemPromptConfig } from "./system-prompts.js";
|
|
23
|
+
import { createToolRegistry, dispatchToolCall, resolveToolEffectDeclaration } from "./tools.js";
|
|
24
|
+
import { canonicalToolEffectJson, toolEffectArgumentsHash } from "./tool-effects.js";
|
|
20
25
|
export function createAgent(config) {
|
|
21
26
|
return {
|
|
22
27
|
config,
|
|
@@ -30,46 +35,411 @@ export function createAgentSession(config) {
|
|
|
30
35
|
}
|
|
31
36
|
/** Resume a persisted built-in run. A claimed/dispatched tool is never replayed automatically. */
|
|
32
37
|
export async function resumeAgentRun(agent, ref, resume, options) {
|
|
38
|
+
return executePreparedAgentRunResume(await prepareAgentRunResume(agent, ref, resume, options));
|
|
39
|
+
}
|
|
40
|
+
/** Subscribe before resuming one durable run. Early consumer return aborts that resumed execution. */
|
|
41
|
+
export async function* resumeAgentRunStream(agent, ref, resume, options) {
|
|
42
|
+
throwIfAbortedSignal(options.signal);
|
|
43
|
+
const prepared = await prepareAgentRunResume(agent, ref, resume, options, options.signal);
|
|
44
|
+
const subscription = prepared.session.subscribe(options);
|
|
45
|
+
let settled = false;
|
|
46
|
+
const runPromise = executePreparedAgentRunResume(prepared, options.signal).finally(() => {
|
|
47
|
+
settled = true;
|
|
48
|
+
});
|
|
49
|
+
try {
|
|
50
|
+
for await (const event of subscription) {
|
|
51
|
+
if ("runId" in event && event.runId !== ref.runId)
|
|
52
|
+
continue;
|
|
53
|
+
yield event;
|
|
54
|
+
}
|
|
55
|
+
await runPromise;
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
if (!settled) {
|
|
59
|
+
prepared.session.abort(new Error("resume stream consumer closed"));
|
|
60
|
+
await runPromise.catch(() => undefined);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function prepareAgentRunResume(agent, ref, resume, options, signal) {
|
|
65
|
+
throwIfAbortedSignal(signal);
|
|
33
66
|
const { record, state } = await loadAgentRunState(options.checkpoints, ref, options.ownership);
|
|
34
|
-
if (state.definitionRevision !== options.definitionRevision ||
|
|
67
|
+
if (state.definitionRevision !== options.definitionRevision ||
|
|
68
|
+
state.agentId !== (agent.config.id ?? agent.config.name) ||
|
|
69
|
+
state.fingerprint !== agentFingerprint(agent, options.definitionRevision)) {
|
|
35
70
|
throw new AgentRunStateError("Agent definition revision or fingerprint mismatch on resume");
|
|
36
71
|
}
|
|
37
72
|
if (record.version !== resume.expectedVersion || state.status !== "suspended") {
|
|
38
73
|
throw new AgentRunStateError("Stale or non-suspended agent run resume");
|
|
39
74
|
}
|
|
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
|
+
}
|
|
40
156
|
if (resume.decision === "deny") {
|
|
157
|
+
throwIfAbortedSignal(signal);
|
|
41
158
|
const denied = await saveAgentRunState({
|
|
42
159
|
checkpoints: options.checkpoints,
|
|
43
|
-
state: {
|
|
160
|
+
state: {
|
|
161
|
+
...state,
|
|
162
|
+
status: "denied",
|
|
163
|
+
loopState: undefined,
|
|
164
|
+
pendingCalls: undefined,
|
|
165
|
+
nestedRuns: undefined,
|
|
166
|
+
stickyDecisions: undefined,
|
|
167
|
+
},
|
|
44
168
|
expectedVersion: record.version,
|
|
45
169
|
ownership: options.ownership,
|
|
46
170
|
fencingToken: options.fencingToken,
|
|
47
171
|
});
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
172
|
+
return {
|
|
173
|
+
kind: "deny",
|
|
174
|
+
session,
|
|
175
|
+
interruption: state.interruption,
|
|
176
|
+
version: denied.record.version,
|
|
177
|
+
ownership: options.ownership,
|
|
178
|
+
result: {
|
|
179
|
+
sessionId: state.sessionId,
|
|
180
|
+
runId: state.runId,
|
|
181
|
+
status: "denied",
|
|
182
|
+
leafId: state.leafId,
|
|
183
|
+
text: "",
|
|
184
|
+
content: [],
|
|
185
|
+
runState: publicState(denied.state),
|
|
186
|
+
interruption: state.interruption,
|
|
187
|
+
},
|
|
188
|
+
};
|
|
52
189
|
}
|
|
53
|
-
if (state.pending?.status === "dispatched")
|
|
190
|
+
if (state.pending?.status === "dispatched" || state.pendingCalls?.some((entry) => entry.status === "dispatched")) {
|
|
54
191
|
throw new AgentRunStateError("Ambiguous dispatched tool requires operator resolution");
|
|
192
|
+
}
|
|
55
193
|
const configured = agent.config.runState;
|
|
56
194
|
if (configured && (configured.checkpoints !== options.checkpoints || configured.definitionRevision !== options.definitionRevision)) {
|
|
57
195
|
throw new AgentRunStateError("Agent durable run-state configuration mismatch on resume");
|
|
58
196
|
}
|
|
197
|
+
throwIfAbortedSignal(signal);
|
|
59
198
|
const claimed = await saveAgentRunState({
|
|
60
199
|
checkpoints: options.checkpoints,
|
|
61
|
-
state: {
|
|
200
|
+
state: {
|
|
201
|
+
...state,
|
|
202
|
+
status: "running",
|
|
203
|
+
interruption: undefined,
|
|
204
|
+
stickyDecisions: resolved?.stickyDecisions ?? state.stickyDecisions,
|
|
205
|
+
},
|
|
62
206
|
expectedVersion: record.version,
|
|
63
207
|
ownership: options.ownership,
|
|
64
208
|
fencingToken: options.fencingToken,
|
|
65
209
|
});
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
210
|
+
return {
|
|
211
|
+
kind: "approve",
|
|
212
|
+
session,
|
|
213
|
+
state: claimed.state,
|
|
214
|
+
decisions: resolved?.decisionsById,
|
|
215
|
+
ownership: options.ownership,
|
|
216
|
+
runState: configured ?? {
|
|
217
|
+
checkpoints: options.checkpoints,
|
|
218
|
+
definitionRevision: options.definitionRevision,
|
|
219
|
+
interruptBeforeTool: state.interruptBeforeTool,
|
|
220
|
+
fencingToken: options.fencingToken,
|
|
221
|
+
resumeNestedRun: options.resumeNestedRun,
|
|
222
|
+
},
|
|
223
|
+
};
|
|
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
|
+
}
|
|
433
|
+
async function executePreparedAgentRunResume(prepared, signal) {
|
|
434
|
+
if (prepared.kind === "deny") {
|
|
435
|
+
await prepared.session.recordDurableDenial(prepared.result.runId, prepared.interruption, prepared.version, prepared.ownership);
|
|
436
|
+
return prepared.result;
|
|
437
|
+
}
|
|
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);
|
|
73
443
|
}
|
|
74
444
|
class AgentRunSuspended extends Error {
|
|
75
445
|
state;
|
|
@@ -82,6 +452,30 @@ class AgentRunSuspended extends Error {
|
|
|
82
452
|
this.name = "AgentRunSuspended";
|
|
83
453
|
}
|
|
84
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
|
+
}
|
|
85
479
|
class RuntimeAgentSession {
|
|
86
480
|
id;
|
|
87
481
|
agent;
|
|
@@ -91,17 +485,28 @@ class RuntimeAgentSession {
|
|
|
91
485
|
currentLeafId;
|
|
92
486
|
history = [];
|
|
93
487
|
activeRun;
|
|
488
|
+
activeRunId;
|
|
489
|
+
activeProviderTurnAbort;
|
|
490
|
+
pendingSoftInterrupt = false;
|
|
491
|
+
pendingSteers = [];
|
|
492
|
+
pendingSteerBytes = 0;
|
|
94
493
|
activeRedactor;
|
|
95
494
|
activeProvider;
|
|
96
495
|
activeLedger;
|
|
496
|
+
activeEffectStore;
|
|
97
497
|
activeOwnership;
|
|
498
|
+
activeIdentity;
|
|
98
499
|
activeIdempotencyKey;
|
|
99
500
|
activeGuardrails;
|
|
100
501
|
activeMetadata;
|
|
101
502
|
activeLimits;
|
|
102
503
|
activeLimitOutputBuffer = false;
|
|
103
504
|
activeDurable;
|
|
505
|
+
activeLoop;
|
|
506
|
+
/** Gated calls of the current tool round awaiting one collected suspension. */
|
|
507
|
+
activeGatedRound;
|
|
104
508
|
activeLoopTurn = 1;
|
|
509
|
+
loadedSkills = createLoadedSkillSet();
|
|
105
510
|
ledgerChain = Promise.resolve();
|
|
106
511
|
ledgerFailure;
|
|
107
512
|
snapshotGeneration = 0;
|
|
@@ -124,21 +529,76 @@ class RuntimeAgentSession {
|
|
|
124
529
|
async run(input, options = {}) {
|
|
125
530
|
return this.runInternal(input, options, randomId("run"));
|
|
126
531
|
}
|
|
127
|
-
|
|
128
|
-
|
|
532
|
+
steer(input, options = {}) {
|
|
533
|
+
if (!this.activeRun || !this.activeRunId)
|
|
534
|
+
throw new Error("Agent session has no active run to steer");
|
|
535
|
+
const messages = inputToMessages(input).map((message) => this.redact(message));
|
|
536
|
+
if (messages.length === 0)
|
|
537
|
+
throw new Error("steer requires non-empty input");
|
|
538
|
+
let addBytes = 0;
|
|
539
|
+
for (const message of messages)
|
|
540
|
+
addBytes += messageTextBytes(message);
|
|
541
|
+
if (this.pendingSteers.length + messages.length > DEFAULT_MAX_PENDING_STEERS) {
|
|
542
|
+
throw new Error(`steer queue exceeds max pending messages (${DEFAULT_MAX_PENDING_STEERS})`);
|
|
543
|
+
}
|
|
544
|
+
if (this.pendingSteerBytes + addBytes > DEFAULT_MAX_PENDING_STEER_BYTES) {
|
|
545
|
+
throw new Error(`steer queue exceeds max pending bytes (${DEFAULT_MAX_PENDING_STEER_BYTES})`);
|
|
546
|
+
}
|
|
547
|
+
this.pendingSteers.push(...messages);
|
|
548
|
+
this.pendingSteerBytes += addBytes;
|
|
549
|
+
this.emit({ type: "queue_updated", sessionId: this.id, runId: this.activeRunId, size: this.pendingSteers.length });
|
|
550
|
+
if (options.softInterrupt) {
|
|
551
|
+
if (this.activeProviderTurnAbort)
|
|
552
|
+
this.activeProviderTurnAbort.abort(new SteerSoftInterrupt());
|
|
553
|
+
else
|
|
554
|
+
this.pendingSoftInterrupt = true;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
async resumeDurable(state, runState, ownership, signal, decisions) {
|
|
558
|
+
return this.runInternal(state.input ?? [], { runState, ownership, signal }, state.runId, {
|
|
559
|
+
options: runState,
|
|
560
|
+
state,
|
|
561
|
+
version: state.version,
|
|
562
|
+
decisions,
|
|
563
|
+
});
|
|
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
|
+
}
|
|
129
579
|
}
|
|
130
580
|
async recordDurableDenial(runId, interruption, version, ownership) {
|
|
131
581
|
this.activeLedger = this.agent.config.runLedger;
|
|
132
582
|
this.activeOwnership = ownership ?? this.agent.config.ownership;
|
|
133
583
|
this.activeRedactor = this.agent.config.redactor;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
584
|
+
try {
|
|
585
|
+
this.emit({ type: "agent_denied", sessionId: this.id, runId, interruption, version });
|
|
586
|
+
await this.drainLedger();
|
|
587
|
+
}
|
|
588
|
+
finally {
|
|
589
|
+
this.activeLedger = undefined;
|
|
590
|
+
this.activeOwnership = undefined;
|
|
591
|
+
this.activeRedactor = undefined;
|
|
592
|
+
this.closeSubscribers();
|
|
593
|
+
}
|
|
139
594
|
}
|
|
140
595
|
async runInternal(input, options, runId, resumed) {
|
|
141
|
-
if (this.agent.config.secure &&
|
|
596
|
+
if (this.agent.config.secure &&
|
|
597
|
+
(options.redactor !== undefined ||
|
|
598
|
+
options.ownership !== undefined ||
|
|
599
|
+
options.validate !== undefined ||
|
|
600
|
+
options.effectStore !== undefined ||
|
|
601
|
+
options.runState !== undefined)) {
|
|
142
602
|
throw new AgentRunStateError("Secure agent defaults cannot be replaced per run");
|
|
143
603
|
}
|
|
144
604
|
const requestedLimits = options.maxToolRounds === undefined
|
|
@@ -151,11 +611,12 @@ class RuntimeAgentSession {
|
|
|
151
611
|
}
|
|
152
612
|
if (durableOptions) {
|
|
153
613
|
validateRunStateOptions(durableOptions);
|
|
154
|
-
if (options.model || options.guardrails || options.loop)
|
|
155
|
-
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");
|
|
156
616
|
const configuredLoop = this.agent.config.loop;
|
|
157
|
-
if (configuredLoop && !
|
|
158
|
-
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
|
+
}
|
|
159
620
|
}
|
|
160
621
|
if (this.activeRun) {
|
|
161
622
|
const error = new Error("Agent session already has an active run");
|
|
@@ -165,12 +626,21 @@ class RuntimeAgentSession {
|
|
|
165
626
|
const controller = new AbortController();
|
|
166
627
|
const cleanupSignal = bridgeAbort(options.signal, controller);
|
|
167
628
|
this.activeRun = controller;
|
|
629
|
+
this.activeRunId = runId;
|
|
630
|
+
this.pendingSteers = [];
|
|
631
|
+
this.pendingSteerBytes = 0;
|
|
632
|
+
this.pendingSoftInterrupt = false;
|
|
168
633
|
this.activeRedactor = options.redactor ?? this.agent.config.redactor;
|
|
169
634
|
this.activeLedger = options.runLedger ?? this.agent.config.runLedger;
|
|
635
|
+
this.activeEffectStore = options.effectStore ?? this.agent.config.effectStore;
|
|
170
636
|
this.activeOwnership = options.ownership ?? this.agent.config.ownership;
|
|
637
|
+
this.activeIdentity = resolveRunIdentity(options.identity, this.agent.config.identity, this.activeOwnership);
|
|
638
|
+
if (this.activeIdentity && !this.activeOwnership)
|
|
639
|
+
this.activeOwnership = ownershipFromIdentity(this.activeIdentity);
|
|
171
640
|
this.activeIdempotencyKey = options.idempotencyKey ?? this.agent.config.idempotencyKey;
|
|
172
641
|
this.activeGuardrails = mergeGuardrails(this.agent.config.guardrails, options.guardrails);
|
|
173
642
|
this.activeDurable = resumed ?? (durableOptions ? { options: durableOptions, version: 0 } : undefined);
|
|
643
|
+
this.activeGatedRound = undefined;
|
|
174
644
|
if (resumed)
|
|
175
645
|
this.invalidateSnapshot();
|
|
176
646
|
const model = options.model ?? this.agent.config.model;
|
|
@@ -179,7 +649,12 @@ class RuntimeAgentSession {
|
|
|
179
649
|
let runStatus = "succeeded";
|
|
180
650
|
const runUsage = createUsageAccumulator();
|
|
181
651
|
let usage;
|
|
182
|
-
const metadata = {
|
|
652
|
+
const metadata = {
|
|
653
|
+
...this.agent.config.metadata,
|
|
654
|
+
...this.metadata,
|
|
655
|
+
...options.metadata,
|
|
656
|
+
...(this.activeIdentity ? identityTelemetryAttributes(this.activeIdentity) : {}),
|
|
657
|
+
};
|
|
183
658
|
this.activeMetadata = metadata;
|
|
184
659
|
const limits = new RunLimitTracker(resolvedLimits, {
|
|
185
660
|
onExceeded: (breach) => {
|
|
@@ -213,7 +688,14 @@ class RuntimeAgentSession {
|
|
|
213
688
|
const { registry, tools } = activeTools(this.agent.config.tools);
|
|
214
689
|
const activeSkills = this.resolveRunSkills(options, tools);
|
|
215
690
|
if (options.model && JSON.stringify(options.model) !== JSON.stringify(this.agent.config.model)) {
|
|
216
|
-
await this.appendEntry(createSessionEntry({
|
|
691
|
+
await this.appendEntry(createSessionEntry({
|
|
692
|
+
sessionId: this.id,
|
|
693
|
+
parentId: this.currentLeafId,
|
|
694
|
+
runId,
|
|
695
|
+
kind: "model_change",
|
|
696
|
+
previousModel: this.agent.config.model,
|
|
697
|
+
model: options.model,
|
|
698
|
+
}));
|
|
217
699
|
}
|
|
218
700
|
const inputMessages = inputToMessages(input).map((message) => this.redact(message));
|
|
219
701
|
const inputGuardrails = await runGuardrails({
|
|
@@ -224,20 +706,24 @@ class RuntimeAgentSession {
|
|
|
224
706
|
redactor: this.activeRedactor,
|
|
225
707
|
emit: (event) => this.emit(event),
|
|
226
708
|
});
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
709
|
+
// Input-guardrail decision table:
|
|
710
|
+
// - interrupt + durable + fresh run → suspend for approval.
|
|
711
|
+
// - interrupt + durable + resumed run → proceed: resuming IS the operator approval.
|
|
712
|
+
// - interrupt without durable, or block/tripwire → fail via assertGuardrailsAllowed.
|
|
713
|
+
const approvedByResume = resumed !== undefined && inputGuardrails.terminal?.action === "interrupt" && this.activeDurable !== undefined;
|
|
714
|
+
if (inputGuardrails.terminal?.action === "interrupt" && this.activeDurable && !approvedByResume) {
|
|
715
|
+
const interruption = { kind: "input_guardrail", reason: inputGuardrails.terminal.reason ?? "Input requires approval" };
|
|
716
|
+
throw new AgentRunSuspended(await this.suspendDurable({ runId, model, limits, interruption, messages: inputMessages }), interruption);
|
|
232
717
|
}
|
|
233
|
-
|
|
718
|
+
if (inputGuardrails.terminal && !approvedByResume)
|
|
234
719
|
assertGuardrailsAllowed(inputGuardrails);
|
|
235
|
-
}
|
|
236
720
|
for (const message of inputMessages)
|
|
237
721
|
await this.appendMessage(message, runId);
|
|
238
722
|
await this.autoCompact(runId, options, controller.signal, inputMessages);
|
|
239
723
|
const maxToolRounds = resolvedLimits.maxToolRounds;
|
|
240
|
-
const systemInstructions = composeSystemPrompt(mergeSystemPromptConfig(this.agent.config.systemPrompt, options.systemPrompt), {
|
|
724
|
+
const systemInstructions = composeSystemPrompt(mergeSystemPromptConfig(this.agent.config.systemPrompt, options.systemPrompt), {
|
|
725
|
+
base: this.agent.config.instructions,
|
|
726
|
+
});
|
|
241
727
|
const contextProviders = [
|
|
242
728
|
...(this.agent.config.context ?? []),
|
|
243
729
|
// ponytail: skill context after host context; no per-skill token budget yet.
|
|
@@ -250,6 +736,7 @@ class RuntimeAgentSession {
|
|
|
250
736
|
const instructionInjectors = options.instructionInjectors ?? this.agent.config.instructionInjectors ?? [];
|
|
251
737
|
const inputLayout = options.inputLayout ?? this.agent.config.inputLayout;
|
|
252
738
|
const loop = resolveLoop(options, this.agent.config);
|
|
739
|
+
this.activeLoop = loop;
|
|
253
740
|
const toolConcurrency = resolveToolConcurrency(options, this.agent.config);
|
|
254
741
|
this.activeLoopTurn = 1;
|
|
255
742
|
const recordProviderUsage = async (turnUsage, turn, attempt) => {
|
|
@@ -272,6 +759,113 @@ class RuntimeAgentSession {
|
|
|
272
759
|
};
|
|
273
760
|
await this.activeLedger.appendUsage(redactRunLedgerRecord(usageRecord, this.activeRedactor));
|
|
274
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
|
+
};
|
|
275
869
|
// ponytail: LoopContext binds existing private helpers; loop orchestrates only.
|
|
276
870
|
let assembledTurn = false;
|
|
277
871
|
let artifactFinished = false;
|
|
@@ -286,6 +880,7 @@ class RuntimeAgentSession {
|
|
|
286
880
|
inputMessages,
|
|
287
881
|
maxToolRounds,
|
|
288
882
|
toolConcurrency,
|
|
883
|
+
restoredLoopState: resumed?.state?.loopState?.snapshot,
|
|
289
884
|
assemble: async (nextInput, toolResults, turn) => {
|
|
290
885
|
limits.charge("maxTurns");
|
|
291
886
|
const request = await assembleProviderInput({
|
|
@@ -302,6 +897,9 @@ class RuntimeAgentSession {
|
|
|
302
897
|
promptBuilder: this.agent.config.promptBuilder,
|
|
303
898
|
contextProviders,
|
|
304
899
|
skills: activeSkills,
|
|
900
|
+
skillsDisclosure: resolveSkillsDisclosure(options.skillsDisclosure, this.agent.config.skillsDisclosure),
|
|
901
|
+
toolResultFold: resolveToolResultFold(options.toolResultFold, this.agent.config.toolResultFold),
|
|
902
|
+
loadedSkills: this.loadedSkills,
|
|
305
903
|
tools,
|
|
306
904
|
resourceLoader: this.agent.config.resourceLoader,
|
|
307
905
|
permission: this.agent.config.permission,
|
|
@@ -320,53 +918,151 @@ class RuntimeAgentSession {
|
|
|
320
918
|
chargeToolRound: (calls) => {
|
|
321
919
|
if (calls.length > 0)
|
|
322
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
|
+
}
|
|
323
941
|
},
|
|
324
942
|
generate: async (request) => {
|
|
943
|
+
await suspendGatedRound();
|
|
325
944
|
if (!assembledTurn)
|
|
326
945
|
limits.charge("maxTurns");
|
|
327
946
|
assembledTurn = false;
|
|
328
947
|
const policyResult = await this.applyProviderRequestPolicies(request, runId, options, metadata, controller.signal);
|
|
329
|
-
const middlewareRequest = await this.agent.config.middleware?.run("provider_request", policyResult.request) ?? policyResult.request;
|
|
330
|
-
|
|
948
|
+
const middlewareRequest = (await this.agent.config.middleware?.run("provider_request", policyResult.request)) ?? policyResult.request;
|
|
949
|
+
try {
|
|
950
|
+
return await this.generateWithRetry(this.redactProviderRequest(middlewareRequest), runId, options, controller.signal, policyResult.secrets, this.activeLoopTurn, recordProviderUsage);
|
|
951
|
+
}
|
|
952
|
+
catch (error) {
|
|
953
|
+
if (isSteerSoftInterrupt(error)) {
|
|
954
|
+
return { content: [], calls: [], started: false, usage: undefined };
|
|
955
|
+
}
|
|
956
|
+
throw error;
|
|
957
|
+
}
|
|
331
958
|
},
|
|
332
959
|
isToolCallExclusive: (call) => registry.get(call.name)?.exclusive === true,
|
|
333
|
-
dispatchToolCall: (call) =>
|
|
334
|
-
call,
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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" },
|
|
967
|
+
};
|
|
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
|
+
},
|
|
369
1063
|
appendMessage: (message) => this.appendMessage(message, runId),
|
|
1064
|
+
hasPendingSteers: () => this.pendingSteers.length > 0,
|
|
1065
|
+
applyPendingSteers: () => this.applyPendingSteers(runId, metadata, controller.signal),
|
|
370
1066
|
emit: (event) => {
|
|
371
1067
|
if (event.type === "turn_started")
|
|
372
1068
|
this.activeLoopTurn = event.turn;
|
|
@@ -383,17 +1079,178 @@ class RuntimeAgentSession {
|
|
|
383
1079
|
this.emit(event);
|
|
384
1080
|
},
|
|
385
1081
|
};
|
|
386
|
-
|
|
387
|
-
const result = await ctx.dispatchToolCall(resumed.state.pending.call);
|
|
1082
|
+
const replayToolResult = async (result) => {
|
|
388
1083
|
await ctx.appendMessage({
|
|
389
1084
|
role: "tool",
|
|
390
|
-
content: [
|
|
1085
|
+
content: [
|
|
1086
|
+
{ type: "tool_result", toolCallId: result.toolCallId, name: result.name, result: result.value, error: result.error },
|
|
1087
|
+
...(result.content ?? []),
|
|
1088
|
+
],
|
|
391
1089
|
metadata: result.metadata,
|
|
392
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
|
+
}
|
|
393
1248
|
}
|
|
394
|
-
const loopUsage = await loop.run(ctx);
|
|
395
1249
|
if (loop.name === "generate-validate-revise" && !artifactFinished) {
|
|
396
|
-
throw Object.assign(new Error(artifactFailedInfo?.message ?? "artifact loop ended without a validated artifact"), {
|
|
1250
|
+
throw Object.assign(new Error(artifactFailedInfo?.message ?? "artifact loop ended without a validated artifact"), {
|
|
1251
|
+
name: "ArtifactFailed",
|
|
1252
|
+
code: artifactFailedInfo?.code ?? "artifact_failed",
|
|
1253
|
+
});
|
|
397
1254
|
}
|
|
398
1255
|
usage = runUsage.value() ?? loopUsage;
|
|
399
1256
|
if (usage && this.activeLedger) {
|
|
@@ -410,7 +1267,16 @@ class RuntimeAgentSession {
|
|
|
410
1267
|
}
|
|
411
1268
|
await this.drainLedger();
|
|
412
1269
|
const runState = this.activeDurable?.state
|
|
413
|
-
? 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
|
+
})
|
|
414
1280
|
: undefined;
|
|
415
1281
|
this.emit({ type: "agent_finished", sessionId: this.id, runId, usage });
|
|
416
1282
|
return this.buildRunResult({ runId, status: "succeeded", usage, runState });
|
|
@@ -427,7 +1293,15 @@ class RuntimeAgentSession {
|
|
|
427
1293
|
const breach = error instanceof RunLimitError ? error.breach : limits.breach;
|
|
428
1294
|
runStatus = breach ? "failed" : controller.signal.aborted ? "aborted" : "failed";
|
|
429
1295
|
const runState = this.activeDurable?.state
|
|
430
|
-
? 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
|
+
})
|
|
431
1305
|
: undefined;
|
|
432
1306
|
const result = this.buildRunResult({
|
|
433
1307
|
runId,
|
|
@@ -443,6 +1317,13 @@ class RuntimeAgentSession {
|
|
|
443
1317
|
finally {
|
|
444
1318
|
if (this.activeRun === controller)
|
|
445
1319
|
this.activeRun = undefined;
|
|
1320
|
+
this.activeRunId = undefined;
|
|
1321
|
+
this.activeLoop = undefined;
|
|
1322
|
+
this.activeGatedRound = undefined;
|
|
1323
|
+
this.activeProviderTurnAbort = undefined;
|
|
1324
|
+
this.pendingSoftInterrupt = false;
|
|
1325
|
+
this.pendingSteers = [];
|
|
1326
|
+
this.pendingSteerBytes = 0;
|
|
446
1327
|
try {
|
|
447
1328
|
await this.drainLedger();
|
|
448
1329
|
if (this.activeLedger) {
|
|
@@ -468,7 +1349,9 @@ class RuntimeAgentSession {
|
|
|
468
1349
|
}
|
|
469
1350
|
finally {
|
|
470
1351
|
this.activeLedger = undefined;
|
|
1352
|
+
this.activeEffectStore = undefined;
|
|
471
1353
|
this.activeOwnership = undefined;
|
|
1354
|
+
this.activeIdentity = undefined;
|
|
472
1355
|
this.activeIdempotencyKey = undefined;
|
|
473
1356
|
this.activeGuardrails = undefined;
|
|
474
1357
|
this.activeMetadata = undefined;
|
|
@@ -534,21 +1417,27 @@ class RuntimeAgentSession {
|
|
|
534
1417
|
const durable = this.activeDurable;
|
|
535
1418
|
if (!durable)
|
|
536
1419
|
throw new AgentRunStateError("Durable interruption is not configured");
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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;
|
|
1424
|
+
const state = durable.state ??
|
|
1425
|
+
initialAgentRunState({
|
|
1426
|
+
agent: this.agent,
|
|
1427
|
+
options: durable.options,
|
|
1428
|
+
runId: input.runId,
|
|
1429
|
+
sessionId: this.id,
|
|
1430
|
+
leafId: this.currentLeafId,
|
|
1431
|
+
model: input.model,
|
|
1432
|
+
counters: input.limits.snapshot(),
|
|
1433
|
+
deadlineAt: input.limits.deadlineAt,
|
|
1434
|
+
status: "suspended",
|
|
1435
|
+
interruption: input.interruption,
|
|
1436
|
+
messages: input.messages,
|
|
1437
|
+
pending: input.pending,
|
|
1438
|
+
pendingCalls: input.pendingCalls,
|
|
1439
|
+
interruptBeforeTool: durable.options.interruptBeforeTool,
|
|
1440
|
+
});
|
|
552
1441
|
return this.persistDurable({
|
|
553
1442
|
...state,
|
|
554
1443
|
leafId: this.currentLeafId,
|
|
@@ -556,9 +1445,96 @@ class RuntimeAgentSession {
|
|
|
556
1445
|
interruption: input.interruption,
|
|
557
1446
|
...(input.messages ? { input: input.messages } : {}),
|
|
558
1447
|
...(input.pending ? { pending: input.pending } : {}),
|
|
1448
|
+
...(input.pendingCalls ? { pendingCalls: input.pendingCalls } : {}),
|
|
1449
|
+
nestedRuns: input.nestedRuns ?? state.nestedRuns,
|
|
1450
|
+
...(loopState ? { loopState } : {}),
|
|
559
1451
|
counters: input.limits.snapshot(),
|
|
560
1452
|
});
|
|
561
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
|
+
}
|
|
562
1538
|
async persistDurable(state) {
|
|
563
1539
|
const durable = this.activeDurable;
|
|
564
1540
|
if (!durable)
|
|
@@ -596,7 +1572,13 @@ class RuntimeAgentSession {
|
|
|
596
1572
|
await this.rebuildHistory();
|
|
597
1573
|
}
|
|
598
1574
|
fork(options = {}) {
|
|
599
|
-
return createAgentSession({
|
|
1575
|
+
return createAgentSession({
|
|
1576
|
+
agent: this.agent,
|
|
1577
|
+
id: this.id,
|
|
1578
|
+
store: this.store,
|
|
1579
|
+
leafId: options.leafId ?? this.currentLeafId,
|
|
1580
|
+
metadata: this.metadata,
|
|
1581
|
+
});
|
|
600
1582
|
}
|
|
601
1583
|
async clone(options = {}) {
|
|
602
1584
|
const id = options.id ?? randomId("session");
|
|
@@ -612,7 +1594,13 @@ class RuntimeAgentSession {
|
|
|
612
1594
|
const { id: _oldId, parentId: _oldParentId, sessionId: _oldSessionId, ...rest } = entry;
|
|
613
1595
|
await this.store.append({ ...rest, id: nextId, parentId: entry.parentId ? remap.get(entry.parentId) : undefined, sessionId: id });
|
|
614
1596
|
}
|
|
615
|
-
return createAgentSession({
|
|
1597
|
+
return createAgentSession({
|
|
1598
|
+
agent: this.agent,
|
|
1599
|
+
id,
|
|
1600
|
+
store: this.store,
|
|
1601
|
+
leafId: branch.length ? remap.get(branch[branch.length - 1].id) : undefined,
|
|
1602
|
+
metadata: this.metadata,
|
|
1603
|
+
});
|
|
616
1604
|
}
|
|
617
1605
|
branchReader() {
|
|
618
1606
|
// ponytail: prefer the store's readBranchPath (one ancestor-chain query) when present so a
|
|
@@ -626,9 +1614,7 @@ class RuntimeAgentSession {
|
|
|
626
1614
|
// the resolver entirely; otherwise `RunOptions.providerSource` overrides
|
|
627
1615
|
// `AgentConfig.providerSource` for this run. A miss on every source fails
|
|
628
1616
|
// closed with `Unknown provider: ${model.provider}` before any provider turn.
|
|
629
|
-
const provider = this.agent.config.provider ??
|
|
630
|
-
options.providerSource?.(model) ??
|
|
631
|
-
this.agent.config.providerSource?.(model);
|
|
1617
|
+
const provider = this.agent.config.provider ?? options.providerSource?.(model) ?? this.agent.config.providerSource?.(model);
|
|
632
1618
|
if (!provider)
|
|
633
1619
|
throw new Error(`Unknown provider: ${model.provider}`);
|
|
634
1620
|
this.activeProvider = provider;
|
|
@@ -638,7 +1624,11 @@ class RuntimeAgentSession {
|
|
|
638
1624
|
if (configured && typeof configured === "object" && "list" in configured) {
|
|
639
1625
|
if (options.activeSkills)
|
|
640
1626
|
return resolveActiveSkills({ registry: configured, names: options.activeSkills, tools });
|
|
641
|
-
|
|
1627
|
+
if (options.skills !== undefined)
|
|
1628
|
+
return options.skills;
|
|
1629
|
+
if (options.activateAllSkills ?? this.agent.config.activateAllSkills)
|
|
1630
|
+
return configured.list();
|
|
1631
|
+
return [];
|
|
642
1632
|
}
|
|
643
1633
|
const arr = options.skills ?? (Array.isArray(configured) ? configured : []);
|
|
644
1634
|
return arr;
|
|
@@ -693,7 +1683,7 @@ class RuntimeAgentSession {
|
|
|
693
1683
|
return await this.generateProviderTurn(request, runId, signal, secrets, turn, attempt, recordUsage);
|
|
694
1684
|
}
|
|
695
1685
|
catch (error) {
|
|
696
|
-
if (error instanceof GuardrailError)
|
|
1686
|
+
if (error instanceof GuardrailError || isSteerSoftInterrupt(error))
|
|
697
1687
|
throw error;
|
|
698
1688
|
const failure = error instanceof ProviderTurnFailure ? error : undefined;
|
|
699
1689
|
const info = failure ? redactSecrets(failure.info, secrets) : errorToErrorInfo(error, secrets);
|
|
@@ -701,7 +1691,10 @@ class RuntimeAgentSession {
|
|
|
701
1691
|
throw errorFromInfo(info);
|
|
702
1692
|
const context = { sessionId: this.id, runId, attempt, error: info, metadata: retry?.metadata, signal };
|
|
703
1693
|
let decision = await policy.decide(context);
|
|
704
|
-
const payload = await this.agent.config.middleware?.run("retry", { context, decision }) ?? {
|
|
1694
|
+
const payload = (await this.agent.config.middleware?.run("retry", { context, decision })) ?? {
|
|
1695
|
+
context,
|
|
1696
|
+
decision,
|
|
1697
|
+
};
|
|
705
1698
|
decision = payload.decision;
|
|
706
1699
|
if (!decision.retry)
|
|
707
1700
|
throw errorFromInfo(info);
|
|
@@ -745,9 +1738,18 @@ class RuntimeAgentSession {
|
|
|
745
1738
|
usageRecorded = true;
|
|
746
1739
|
await recordUsage?.(usage, turn, attempt);
|
|
747
1740
|
};
|
|
1741
|
+
const turnAbort = new AbortController();
|
|
1742
|
+
const cleanupTurn = bridgeAbort(signal, turnAbort);
|
|
1743
|
+
this.activeProviderTurnAbort = turnAbort;
|
|
1744
|
+
if (this.pendingSoftInterrupt) {
|
|
1745
|
+
this.pendingSoftInterrupt = false;
|
|
1746
|
+
turnAbort.abort(new SteerSoftInterrupt());
|
|
1747
|
+
}
|
|
1748
|
+
const turnRequest = { ...request, signal: turnAbort.signal };
|
|
748
1749
|
try {
|
|
749
|
-
|
|
750
|
-
|
|
1750
|
+
throwIfAborted(turnAbort.signal);
|
|
1751
|
+
for await (const event of this.activeProvider.generate(turnRequest)) {
|
|
1752
|
+
throwIfAborted(turnAbort.signal);
|
|
751
1753
|
this.activeLimits.charge("maxResponseBytes", jsonBytes(event));
|
|
752
1754
|
if (event.type === "error")
|
|
753
1755
|
throw new ProviderTurnFailure(event.error, started);
|
|
@@ -791,7 +1793,7 @@ class RuntimeAgentSession {
|
|
|
791
1793
|
stage: "output",
|
|
792
1794
|
guardrails: this.activeGuardrails,
|
|
793
1795
|
value: { content, calls, messageId, started, usage },
|
|
794
|
-
context: { sessionId: this.id, runId, metadata: this.activeMetadata ?? {}, signal },
|
|
1796
|
+
context: { sessionId: this.id, runId, metadata: this.activeMetadata ?? {}, signal: turnAbort.signal },
|
|
795
1797
|
redactor: this.activeRedactor,
|
|
796
1798
|
emit: (event) => this.emit(event),
|
|
797
1799
|
}));
|
|
@@ -811,6 +1813,19 @@ class RuntimeAgentSession {
|
|
|
811
1813
|
return { content, calls, messageId, started, usage };
|
|
812
1814
|
}
|
|
813
1815
|
catch (error) {
|
|
1816
|
+
if (isSteerSoftInterrupt(error) || isSteerSoftInterrupt(turnAbort.signal.reason)) {
|
|
1817
|
+
await recordTurnUsage();
|
|
1818
|
+
const latencyMs = Math.round(performance.now() - startedAt);
|
|
1819
|
+
this.emit({
|
|
1820
|
+
type: "provider_turn_finished",
|
|
1821
|
+
sessionId: this.id,
|
|
1822
|
+
runId,
|
|
1823
|
+
turn,
|
|
1824
|
+
metadata: buildMetadata({ latencyMs }),
|
|
1825
|
+
usage,
|
|
1826
|
+
});
|
|
1827
|
+
throw new SteerSoftInterrupt();
|
|
1828
|
+
}
|
|
814
1829
|
const latencyMs = Math.round(performance.now() - startedAt);
|
|
815
1830
|
const info = error instanceof ProviderTurnFailure ? redactSecrets(error.info, secrets) : errorToErrorInfo(error, secrets);
|
|
816
1831
|
await recordTurnUsage();
|
|
@@ -827,6 +1842,48 @@ class RuntimeAgentSession {
|
|
|
827
1842
|
throw error;
|
|
828
1843
|
throw new ProviderTurnFailure(info, started);
|
|
829
1844
|
}
|
|
1845
|
+
finally {
|
|
1846
|
+
cleanupTurn();
|
|
1847
|
+
if (this.activeProviderTurnAbort === turnAbort)
|
|
1848
|
+
this.activeProviderTurnAbort = undefined;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
async applyPendingSteers(runId, metadata, signal) {
|
|
1852
|
+
if (this.pendingSteers.length === 0)
|
|
1853
|
+
return false;
|
|
1854
|
+
const drained = this.pendingSteers.splice(0);
|
|
1855
|
+
this.pendingSteerBytes = 0;
|
|
1856
|
+
this.emit({ type: "queue_updated", sessionId: this.id, runId, size: 0 });
|
|
1857
|
+
for (const message of drained) {
|
|
1858
|
+
throwIfAborted(signal);
|
|
1859
|
+
const inputGuardrails = await runGuardrails({
|
|
1860
|
+
stage: "input",
|
|
1861
|
+
guardrails: this.activeGuardrails,
|
|
1862
|
+
value: [message],
|
|
1863
|
+
context: { sessionId: this.id, runId, metadata, signal },
|
|
1864
|
+
redactor: this.activeRedactor,
|
|
1865
|
+
emit: (event) => this.emit(event),
|
|
1866
|
+
});
|
|
1867
|
+
// Mid-run steer: a terminal decision drops the message (never enters history or
|
|
1868
|
+
// the session store) and the run continues. Run-start input blocking still fails
|
|
1869
|
+
// the run — only the blast radius of steered input is narrowed.
|
|
1870
|
+
const terminal = inputGuardrails.terminal;
|
|
1871
|
+
if (terminal) {
|
|
1872
|
+
if (terminal.action === "interrupt")
|
|
1873
|
+
throw new GuardrailError(terminal);
|
|
1874
|
+
this.emit({
|
|
1875
|
+
type: "steer_rejected",
|
|
1876
|
+
sessionId: this.id,
|
|
1877
|
+
runId,
|
|
1878
|
+
message: this.activeRedactor ? this.activeRedactor.redact(message) : message,
|
|
1879
|
+
record: terminal,
|
|
1880
|
+
});
|
|
1881
|
+
continue;
|
|
1882
|
+
}
|
|
1883
|
+
this.history.push(message);
|
|
1884
|
+
await this.appendMessage(message, runId);
|
|
1885
|
+
}
|
|
1886
|
+
return true;
|
|
830
1887
|
}
|
|
831
1888
|
async applyProviderRequestPolicies(request, runId, options, metadata, signal) {
|
|
832
1889
|
const policies = [...policyList(this.agent.config.providerRequestPolicies), ...policyList(options.providerRequestPolicies)];
|
|
@@ -853,16 +1910,35 @@ class RuntimeAgentSession {
|
|
|
853
1910
|
throwIfAbortedSignal(signal);
|
|
854
1911
|
const entries = await this.entries();
|
|
855
1912
|
const secrets = options.secrets ?? [];
|
|
856
|
-
const strategy = options.strategy ??
|
|
857
|
-
|
|
1913
|
+
const strategy = options.strategy ??
|
|
1914
|
+
createDefaultCompactionStrategy({ keepRecentEntries: options.keepRecentEntries, maxSummaryChars: options.maxSummaryChars, secrets });
|
|
1915
|
+
const context = {
|
|
1916
|
+
sessionId: this.id,
|
|
1917
|
+
entries,
|
|
1918
|
+
keepRecentEntries: options.keepRecentEntries,
|
|
1919
|
+
trigger,
|
|
1920
|
+
secrets,
|
|
1921
|
+
metadata: options.metadata,
|
|
1922
|
+
signal,
|
|
1923
|
+
};
|
|
858
1924
|
this.emit({ type: "compaction_started", sessionId: this.id, runId });
|
|
859
1925
|
let result = await strategy.compact(context);
|
|
860
1926
|
result = { ...result, summary: redactSecrets(result.summary, secrets) };
|
|
861
|
-
const payload = await this.agent.config.middleware?.run("compaction", { context, result }) ?? {
|
|
1927
|
+
const payload = (await this.agent.config.middleware?.run("compaction", { context, result })) ?? {
|
|
1928
|
+
context,
|
|
1929
|
+
result,
|
|
1930
|
+
};
|
|
862
1931
|
result = { ...payload.result, summary: redactSecrets(payload.result.summary, secrets) };
|
|
863
1932
|
const source = result.entries?.find((entry) => entry.kind === "compaction");
|
|
864
1933
|
const data = isCompactionEntryData(source?.data) ? source.data : undefined;
|
|
865
|
-
const entry = createSessionEntry({
|
|
1934
|
+
const entry = createSessionEntry({
|
|
1935
|
+
sessionId: this.id,
|
|
1936
|
+
parentId: this.currentLeafId,
|
|
1937
|
+
runId,
|
|
1938
|
+
kind: "compaction",
|
|
1939
|
+
summary: result.summary,
|
|
1940
|
+
data,
|
|
1941
|
+
});
|
|
866
1942
|
await this.appendEntry(entry);
|
|
867
1943
|
const finalResult = { ...result, entries: [entry] };
|
|
868
1944
|
this.emit({ type: "compaction_finished", sessionId: this.id, runId, summary: finalResult.summary });
|
|
@@ -987,6 +2063,27 @@ function inputToMessages(input) {
|
|
|
987
2063
|
return [input];
|
|
988
2064
|
return [...input];
|
|
989
2065
|
}
|
|
2066
|
+
const steerTextEncoder = new TextEncoder();
|
|
2067
|
+
function messageTextBytes(message) {
|
|
2068
|
+
let total = 0;
|
|
2069
|
+
for (const block of message.content) {
|
|
2070
|
+
if (block.type === "text")
|
|
2071
|
+
total += steerTextEncoder.encode(block.text).byteLength;
|
|
2072
|
+
}
|
|
2073
|
+
return total;
|
|
2074
|
+
}
|
|
2075
|
+
const STEER_SOFT_INTERRUPT_CODE = "steer_soft_interrupt";
|
|
2076
|
+
class SteerSoftInterrupt extends Error {
|
|
2077
|
+
code = STEER_SOFT_INTERRUPT_CODE;
|
|
2078
|
+
constructor() {
|
|
2079
|
+
super("Provider turn soft-interrupted by steer");
|
|
2080
|
+
this.name = "SteerSoftInterrupt";
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
function isSteerSoftInterrupt(error) {
|
|
2084
|
+
return (error instanceof SteerSoftInterrupt ||
|
|
2085
|
+
(typeof error === "object" && error !== null && error.code === STEER_SOFT_INTERRUPT_CODE));
|
|
2086
|
+
}
|
|
990
2087
|
function finalAssistantMessage(history) {
|
|
991
2088
|
for (let index = history.length - 1; index >= 0; index -= 1) {
|
|
992
2089
|
const message = history[index];
|
|
@@ -1039,8 +2136,22 @@ function mergeCompaction(agent, run) {
|
|
|
1039
2136
|
return { ...(agent || {}), ...run };
|
|
1040
2137
|
return agent || undefined;
|
|
1041
2138
|
}
|
|
1042
|
-
|
|
1043
|
-
|
|
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";
|
|
1044
2155
|
}
|
|
1045
2156
|
function mergeGuardrails(agent, run) {
|
|
1046
2157
|
if (!agent && !run)
|
|
@@ -1057,11 +2168,25 @@ function withoutTrailingInput(messages, input) {
|
|
|
1057
2168
|
const next = [...messages];
|
|
1058
2169
|
for (let i = input.length - 1; i >= 0; i -= 1) {
|
|
1059
2170
|
const last = next.at(-1);
|
|
1060
|
-
if (last &&
|
|
2171
|
+
if (last && stableMessageKey(last) === stableMessageKey(input[i]))
|
|
1061
2172
|
next.pop();
|
|
1062
2173
|
}
|
|
1063
2174
|
return next;
|
|
1064
2175
|
}
|
|
2176
|
+
// Key-order-insensitive comparison: a redacted-then-reassembled message with reordered
|
|
2177
|
+
// keys must still dedupe against the trailing input, or auto-compaction duplicates it.
|
|
2178
|
+
function stableMessageKey(value) {
|
|
2179
|
+
if (Array.isArray(value))
|
|
2180
|
+
return `[${value.map(stableMessageKey).join(",")}]`;
|
|
2181
|
+
if (value !== null && typeof value === "object") {
|
|
2182
|
+
const record = value;
|
|
2183
|
+
return `{${Object.keys(record)
|
|
2184
|
+
.sort()
|
|
2185
|
+
.map((key) => `${JSON.stringify(key)}:${stableMessageKey(record[key])}`)
|
|
2186
|
+
.join(",")}}`;
|
|
2187
|
+
}
|
|
2188
|
+
return JSON.stringify(value) ?? "null";
|
|
2189
|
+
}
|
|
1065
2190
|
function bridgeAbort(signal, controller) {
|
|
1066
2191
|
if (!signal)
|
|
1067
2192
|
return () => undefined;
|
|
@@ -1079,9 +2204,10 @@ function throwIfAbortedSignal(signal) {
|
|
|
1079
2204
|
if (signal?.aborted)
|
|
1080
2205
|
throw signal.reason instanceof Error ? signal.reason : new Error("Agent run aborted");
|
|
1081
2206
|
}
|
|
2207
|
+
const jsonTextEncoder = new TextEncoder();
|
|
1082
2208
|
function jsonBytes(value) {
|
|
1083
2209
|
try {
|
|
1084
|
-
return
|
|
2210
|
+
return jsonTextEncoder.encode(JSON.stringify(value)).byteLength;
|
|
1085
2211
|
}
|
|
1086
2212
|
catch {
|
|
1087
2213
|
throw new TypeError("Provider request or event must be JSON-serializable for run limits");
|
|
@@ -1098,8 +2224,8 @@ function createUsageAccumulator() {
|
|
|
1098
2224
|
if (value !== undefined)
|
|
1099
2225
|
sums.set(key, (sums.get(key) ?? 0) + value);
|
|
1100
2226
|
}
|
|
1101
|
-
const total = usage.totalTokens
|
|
1102
|
-
|
|
2227
|
+
const total = usage.totalTokens ??
|
|
2228
|
+
(usage.inputTokens !== undefined || usage.outputTokens !== undefined
|
|
1103
2229
|
? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
|
|
1104
2230
|
: undefined);
|
|
1105
2231
|
if (total !== undefined)
|