@deepstrike/sdk 0.2.39 → 0.2.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +61 -31
  2. package/dist/collaboration/contract.d.ts +2 -2
  3. package/dist/collaboration/contract.js +2 -2
  4. package/dist/collaboration/handoff.d.ts +2 -14
  5. package/dist/collaboration/handoff.js +1 -17
  6. package/dist/collaboration/harness.d.ts +16 -47
  7. package/dist/collaboration/harness.js +57 -158
  8. package/dist/collaboration/index.d.ts +2 -2
  9. package/dist/collaboration/index.js +1 -2
  10. package/dist/collaboration/modes/creator-verifier.js +42 -6
  11. package/dist/collaboration/pool.d.ts +8 -0
  12. package/dist/collaboration/pool.js +56 -3
  13. package/dist/harness/harness.d.ts +97 -97
  14. package/dist/harness/harness.js +190 -144
  15. package/dist/harness/judge.d.ts +3 -2
  16. package/dist/harness/judge.js +7 -3
  17. package/dist/harness/public.d.ts +5 -3
  18. package/dist/harness/public.js +3 -2
  19. package/dist/index.d.ts +22 -7
  20. package/dist/index.js +8 -1
  21. package/dist/kernel.d.ts +12 -39
  22. package/dist/memory/agent.d.ts +8 -41
  23. package/dist/memory/agent.js +34 -117
  24. package/dist/memory/extraction.d.ts +4 -0
  25. package/dist/memory/extraction.js +79 -0
  26. package/dist/memory/in-memory-store.d.ts +28 -15
  27. package/dist/memory/in-memory-store.js +74 -25
  28. package/dist/memory/protocols.d.ts +56 -56
  29. package/dist/memory/protocols.js +1 -0
  30. package/dist/memory/public.d.ts +3 -1
  31. package/dist/memory/public.js +1 -0
  32. package/dist/memory/ranking.d.ts +33 -0
  33. package/dist/memory/ranking.js +77 -0
  34. package/dist/memory/retention.d.ts +17 -0
  35. package/dist/memory/retention.js +54 -0
  36. package/dist/os/public.d.ts +3 -3
  37. package/dist/os/public.js +1 -1
  38. package/dist/providers/base.d.ts +8 -0
  39. package/dist/providers/base.js +0 -0
  40. package/dist/providers/gemini.js +12 -0
  41. package/dist/providers/ollama.js +3 -1
  42. package/dist/providers/openai-responses.js +9 -2
  43. package/dist/providers/profiles.d.ts +5 -3
  44. package/dist/providers/profiles.js +3 -3
  45. package/dist/runtime/context-policy.d.ts +35 -0
  46. package/dist/runtime/context-policy.js +66 -0
  47. package/dist/runtime/eval.d.ts +6 -2
  48. package/dist/runtime/eval.js +2 -2
  49. package/dist/runtime/event-stream.d.ts +9 -0
  50. package/dist/runtime/event-stream.js +25 -11
  51. package/dist/runtime/execution-plane.d.ts +5 -1
  52. package/dist/runtime/execution-plane.js +9 -3
  53. package/dist/runtime/facade.js +2 -1
  54. package/dist/runtime/kernel-event-log.d.ts +0 -2
  55. package/dist/runtime/kernel-event-log.js +61 -13
  56. package/dist/runtime/kernel-primitives-dashboard.js +1 -1
  57. package/dist/runtime/kernel-rebuild.d.ts +13 -0
  58. package/dist/runtime/kernel-rebuild.js +75 -0
  59. package/dist/runtime/kernel-step.d.ts +157 -8
  60. package/dist/runtime/kernel-step.js +220 -7
  61. package/dist/runtime/kernel-transaction-log.d.ts +61 -0
  62. package/dist/runtime/kernel-transaction-log.js +149 -0
  63. package/dist/runtime/large-result-spool.d.ts +3 -1
  64. package/dist/runtime/large-result-spool.js +24 -5
  65. package/dist/runtime/loop-driver.d.ts +1 -1
  66. package/dist/runtime/loop-driver.js +2 -7
  67. package/dist/runtime/mcp-proxy-plane.d.ts +1 -0
  68. package/dist/runtime/mcp-proxy-plane.js +23 -6
  69. package/dist/runtime/os-profile.d.ts +9 -10
  70. package/dist/runtime/os-profile.js +14 -10
  71. package/dist/runtime/os-snapshot.d.ts +19 -0
  72. package/dist/runtime/os-snapshot.js +33 -3
  73. package/dist/runtime/process-sandbox-plane.js +16 -11
  74. package/dist/runtime/reaction-checkpoint.d.ts +51 -0
  75. package/dist/runtime/reaction-checkpoint.js +83 -0
  76. package/dist/runtime/reactive-session.d.ts +9 -3
  77. package/dist/runtime/reactive-session.js +44 -14
  78. package/dist/runtime/reliability.d.ts +48 -0
  79. package/dist/runtime/reliability.js +86 -0
  80. package/dist/runtime/remote-vpc-plane.js +4 -3
  81. package/dist/runtime/run-group.d.ts +38 -35
  82. package/dist/runtime/run-group.js +97 -54
  83. package/dist/runtime/runner.d.ts +117 -78
  84. package/dist/runtime/runner.js +1516 -1197
  85. package/dist/runtime/session-log.d.ts +71 -12
  86. package/dist/runtime/session-log.js +192 -32
  87. package/dist/runtime/session-repair.d.ts +11 -7
  88. package/dist/runtime/session-repair.js +11 -8
  89. package/dist/runtime/sub-agent-orchestrator.d.ts +6 -2
  90. package/dist/runtime/sub-agent-orchestrator.js +45 -18
  91. package/dist/signals/gateway.d.ts +23 -10
  92. package/dist/signals/gateway.js +81 -17
  93. package/dist/signals/scheduled.js +0 -1
  94. package/dist/signals/types.d.ts +21 -13
  95. package/dist/skills/loader.js +12 -2
  96. package/dist/tools/index.d.ts +2 -0
  97. package/dist/types/agent.d.ts +40 -1
  98. package/dist/types/agent.js +61 -1
  99. package/dist/types.d.ts +6 -1
  100. package/dist/workflow/public.d.ts +2 -3
  101. package/dist/workflow/public.js +0 -1
  102. package/package.json +2 -2
@@ -1,12 +1,13 @@
1
- import { memoriesToIndex, selectMemories } from "../memory/agent.js";
1
+ import { extractSessionMemories } from "../memory/extraction.js";
2
2
  import { resolvePermissionRequest } from "./execution-plane.js";
3
+ import { GroupBudgetScope } from "./run-group.js";
3
4
  import { getKernel } from "../kernel.js";
4
5
  import { peekProviderReplay, seedProviderReplayFromEvents } from "./provider-replay.js";
5
6
  import { sanitizeReplayText } from "./replay-sanitize.js";
6
- import { buildLlmCompletedEvent, buildRunTerminalEvent, buildWorkflowNodeCompletedEvent, buildWorkflowNodesSubmittedEvent, recoverCompletedWorkflowNodes, recoverSubmittedWorkflowNodes, repairEventsForRecovery, } from "./session-repair.js";
7
+ import { buildLlmCompletedEvent, buildRunTerminalEvent, buildWorkflowNodeCompletedEvent, buildWorkflowNodesSubmittedEvent, recoverWorkflowNodeOutcomes, recoverSubmittedWorkflowNodes, repairEventsForRecovery, } from "./session-repair.js";
7
8
  import { KernelPrimitivesDashboard } from "./kernel-primitives-dashboard.js";
8
- import { capabilityMarker, capabilitySkill, capabilityTool, capabilityCommandMount, capabilityCommandUnmount, entropySampleFromObservation, kernelAction, kernelApply, kernelMaybeAction, messageToKernelMessage, skillMetadataToKernel, taskUpdateToKernel, toolResultToKernel, toolSchemaToKernel, } from "./kernel-step.js";
9
- import { agentRunSpecToKernel, findSpawnProcessObservation, milestoneCheckPass, milestoneCheckResultToKernel, spawnObservationToManifest, subAgentResultToKernel, submitWorkflowNodesToKernel, submitWorkflowToKernel, workflowBudgetNote, workflowNodeToManifest, workflowNodeToSpec, workflowSpecToKernel, } from "../types/agent.js";
9
+ import { capabilityMarker, capabilitySkill, capabilityTool, capabilityCommandMount, capabilityCommandUnmount, entropySampleFromObservation, durableKernelAction, durableKernelApply, durableKernelMaybeAction, messageToKernelMessage, skillMetadataToKernel, taskUpdateToKernel, toolResultToKernel, toolSchemaToKernel, } from "./kernel-step.js";
10
+ import { agentRunSpecToKernel, findSpawnProcessObservation, milestoneCheckPass, milestoneCheckResultToKernel, spawnObservationToManifest, subAgentResultToKernel, submitWorkflowNodesToKernel, submitWorkflowToKernel, workflowBudgetNote, workflowNodeOutcomeFromKernel, workflowNodeStatusFromTermination, workflowNodeToManifest, workflowNodeToSpec, workflowSpecToKernel, } from "../types/agent.js";
10
11
  import { defaultSubAgentOrchestrator } from "./sub-agent-orchestrator.js";
11
12
  import { extractJsonValue, schemaInstruction, schemaRetryInstruction, validateAgainstSchema, } from "./output-schema.js";
12
13
  import { resolveReducer } from "./reducers.js";
@@ -16,13 +17,47 @@ import { kernelObservationToSessionEvent } from "./kernel-event-log.js";
16
17
  import { assertNativeProfile } from "./os-profile.js";
17
18
  import { LargeResultSpool } from "./large-result-spool.js";
18
19
  import { formatToolError } from "../tools/errors.js";
20
+ import { ManagedTaskScope } from "./reliability.js";
21
+ import { contextPolicyV1, normalizeContextPolicyV1, } from "./context-policy.js";
22
+ export function schedulerPolicyToKernel(policy) {
23
+ const allowed = new Set([
24
+ "version", "criticalPathWeight", "fanoutWeight", "ageWeight", "tokenCostWeight",
25
+ ]);
26
+ const unknown = Object.keys(policy).filter(key => !allowed.has(key));
27
+ if (unknown.length > 0)
28
+ throw new TypeError(`unknown scheduler policy field(s): ${unknown.join(", ")}`);
29
+ return {
30
+ version: policy.version,
31
+ critical_path_weight: policy.criticalPathWeight,
32
+ fanout_weight: policy.fanoutWeight,
33
+ age_weight: policy.ageWeight,
34
+ token_cost_weight: policy.tokenCostWeight,
35
+ };
36
+ }
37
+ function pendingCallIds(action) {
38
+ switch (action.kind) {
39
+ case "call_provider":
40
+ return [action.effectId];
41
+ case "execute_tool":
42
+ return action.calls.map(call => call.id);
43
+ case "request_approval":
44
+ return action.requests.map(request => request.callId);
45
+ case "spawn_workflow":
46
+ return action.nodes.map(node => String(node.agent_id ?? "")).filter(Boolean);
47
+ case "preempt_sub_agents":
48
+ return action.agentIds;
49
+ default:
50
+ return "effectId" in action ? [action.effectId] : [];
51
+ }
52
+ }
19
53
  export class RuntimeRunner {
20
54
  opts;
21
55
  interrupted = false;
22
- /** #2-B-ii: aborts the in-flight provider stream when the run is interrupted/preempted. Recreated
23
- * per `execute`; `interrupt()` fires it so a Critical `InterruptNow` cancels the live LLM call. */
56
+ cancellationReason;
57
+ /** Aborts host-owned provider I/O before `cancel_operation` commits the kernel terminal fact. */
24
58
  abortController = null;
25
59
  activeKernel = null;
60
+ activeGroupBudgetScope;
26
61
  pendingObservations = [];
27
62
  currentSessionId = null;
28
63
  /** O2 (system-reminder channel): host-pushed notes awaiting the next turn-boundary drain. */
@@ -32,18 +67,23 @@ export class RuntimeRunner {
32
67
  * an already-active skill (loading is idempotent; the knowledge push should be too). */
33
68
  knowledgePushedSkills = new Set();
34
69
  nextArchiveStart = 0;
70
+ pendingPageOutArchives = [];
71
+ activePageOutArchive;
35
72
  /** K4: the active run's goal, kept for the renewal-boundary memory re-query. */
36
73
  currentGoal = "";
37
- /** Full tool outputs keyed by call_id until Layer-1 spool observations are logged. */
38
- pendingSpoolOutputs = new Map();
39
74
  /** M5 v2.1: sub-workflow specs a top-level agent authored via `start_workflow`, awaiting auto-drive
40
75
  * at the next safe point (after the tool turn resolves, kernel back in Reason — not suspended). */
41
76
  pendingAuthoredWorkflows = [];
77
+ workflowContinuation = null;
42
78
  dashboard = null;
43
79
  /** Most recent kernel entropy sample of the active/last run (see `latestEntropy`). */
44
80
  lastEntropySample = null;
45
81
  constructor(opts) {
46
82
  this.opts = opts;
83
+ const schemaAttempts = opts.workflowSchemaValidationAttempts ?? 2;
84
+ if (!Number.isInteger(schemaAttempts) || schemaAttempts < 1 || schemaAttempts > 16) {
85
+ throw new RangeError("workflowSchemaValidationAttempts must be an integer between 1 and 16");
86
+ }
47
87
  if (opts.enableDiagnosticsDashboard) {
48
88
  const originalAppend = opts.sessionLog.append.bind(opts.sessionLog);
49
89
  opts.sessionLog.append = async (sessionId, event) => {
@@ -60,83 +100,104 @@ export class RuntimeRunner {
60
100
  get hostOptions() {
61
101
  return this.opts;
62
102
  }
103
+ durableSessionId(sessionId) {
104
+ const resolved = sessionId ?? this.currentSessionId;
105
+ if (!resolved)
106
+ throw new Error("durable kernel transitions require a session id");
107
+ return resolved;
108
+ }
109
+ async commitKernelApply(runtime, pending, event, sessionId) {
110
+ return durableKernelApply(runtime, this.opts.sessionLog, this.durableSessionId(sessionId), pending, event);
111
+ }
112
+ async commitKernelMaybeAction(runtime, pending, event, sessionId) {
113
+ return durableKernelMaybeAction(runtime, this.opts.sessionLog, this.durableSessionId(sessionId), pending, event);
114
+ }
115
+ async commitKernelAction(runtime, pending, event, sessionId) {
116
+ return durableKernelAction(runtime, this.opts.sessionLog, this.durableSessionId(sessionId), pending, event);
117
+ }
118
+ async persistMemoryToStore(memory, agentId) {
119
+ if (!this.opts.dreamStore)
120
+ throw new Error("memory persistence requires dreamStore");
121
+ await this.opts.dreamStore.upsert(agentId, memory);
122
+ }
123
+ async retrieveMemoryFromStore(query, requestedK, agentId) {
124
+ if (!this.opts.dreamStore)
125
+ throw new Error("memory queries require dreamStore");
126
+ return (await this.opts.dreamStore.search(agentId, { ...query, top_k: requestedK }))
127
+ .slice(0, requestedK);
128
+ }
63
129
  async writeMemory(memory, opts = {}) {
64
130
  const sessionId = opts.sessionId ?? this.currentSessionId;
65
131
  const agentId = opts.agentId ?? this.opts.agentId;
66
132
  if (!this.opts.dreamStore || !agentId)
67
133
  return;
134
+ const durableSessionId = this.durableSessionId(sessionId);
68
135
  const observations = [];
69
- const runtime = this.activeKernel ?? this.createSyscallRuntime();
70
- kernelApply(runtime, observations, { kind: "write_memory", memory });
71
- const event = observations.find(o => o.kind === "memory_written");
72
- if (!event) {
136
+ const runtime = this.createSyscallRuntime();
137
+ const action = await this.commitKernelMaybeAction(runtime, observations, { kind: "write_memory", memory }, durableSessionId);
138
+ if (!action) {
73
139
  await this.appendMemorySyscallObservations(sessionId, observations);
74
140
  return;
75
141
  }
76
- const existing = await this.opts.dreamStore.loadMemories(agentId);
77
- // Curator-style jaccard dedup at the single write path: a near-duplicate of an
78
- // existing entry is dropped (the observation is still logged for audit).
79
- const isDuplicate = existing.some(e => jaccardSimilarity(e.text, memory.content) >= 0.9);
80
- if (!isDuplicate) {
81
- const meta = memory.metadata;
82
- const score = typeof meta?.score === "number" ? meta.score : 1.0;
83
- await this.opts.dreamStore.commit(agentId, {
84
- toAdd: [{
85
- text: memory.content,
86
- score,
87
- metadata: {
88
- ...memory.metadata,
89
- source: meta?.source ?? "write_memory_syscall",
90
- },
91
- }],
92
- toRemoveIndices: [],
93
- stats: {
94
- insightsProcessed: 1,
95
- duplicatesRemoved: 0,
96
- conflictsResolved: 0,
97
- entriesAdded: 1,
98
- },
99
- }, existing);
142
+ if (action.kind !== "persist_memory") {
143
+ throw new Error(`write_memory returned unexpected kernel effect: ${action.kind}`);
100
144
  }
101
- await this.appendMemorySyscallObservations(sessionId, observations);
145
+ let ioError;
146
+ try {
147
+ await this.persistMemoryToStore(action.memory, agentId);
148
+ }
149
+ catch (cause) {
150
+ ioError = cause;
151
+ }
152
+ await this.commitKernelApply(runtime, observations, {
153
+ kind: "memory_persist_result",
154
+ effect_id: action.effectId,
155
+ ...(ioError ? { error: formatToolError(ioError) } : {}),
156
+ }, durableSessionId);
157
+ await this.appendMemorySyscallObservations(durableSessionId, observations);
158
+ if (ioError)
159
+ throw ioError;
102
160
  }
103
161
  async queryMemory(query, opts = {}) {
104
162
  const sessionId = opts.sessionId ?? this.currentSessionId;
105
163
  const agentId = opts.agentId ?? this.opts.agentId;
106
164
  if (!this.opts.dreamStore || !agentId)
107
165
  return [];
166
+ const durableSessionId = this.durableSessionId(sessionId);
108
167
  const observations = [];
109
- const runtime = this.activeKernel ?? this.createSyscallRuntime();
110
- kernelApply(runtime, observations, { kind: "query_memory", query });
111
- const allMemories = await this.opts.dreamStore.loadMemories(agentId);
112
- const retrieval = await selectMemories(query, memoriesToIndex(allMemories));
113
- let hits;
114
- if (retrieval.selected_memory_ids.length > 0) {
115
- const selected = new Set(retrieval.selected_memory_ids);
116
- hits = allMemories
117
- .filter(m => selected.has(String(m.metadata?.name ?? "")))
118
- .slice(0, query.top_k);
119
- }
120
- else {
121
- hits = await this.opts.dreamStore.search(agentId, query.current_context, query.top_k);
122
- if (hits.length > 0 && retrieval.selection_rationale === "No candidates after filtering") {
123
- retrieval.selected_memory_ids = hits.map(h => String(h.metadata?.name ?? h.text.slice(0, 32)));
124
- retrieval.selection_rationale = `DreamStore.search returned ${hits.length} hit(s)`;
125
- }
168
+ const runtime = this.createSyscallRuntime();
169
+ const action = await this.commitKernelAction(runtime, observations, { kind: "query_memory", query }, durableSessionId);
170
+ if (action.kind !== "query_memory") {
171
+ throw new Error(`query_memory returned unexpected kernel effect: ${action.kind}`);
126
172
  }
127
- await this.appendMemorySyscallObservations(sessionId, observations);
128
- await this.logMemoryRetrievalResult(sessionId, retrieval);
173
+ let hits = [];
174
+ let ioError;
175
+ try {
176
+ hits = await this.retrieveMemoryFromStore(query, action.requestedK, agentId);
177
+ }
178
+ catch (cause) {
179
+ ioError = cause;
180
+ }
181
+ await this.commitKernelApply(runtime, observations, {
182
+ kind: "memory_query_result",
183
+ effect_id: action.effectId,
184
+ hits,
185
+ ...(ioError ? { error: formatToolError(ioError) } : {}),
186
+ }, durableSessionId);
187
+ await this.appendMemorySyscallObservations(durableSessionId, observations);
188
+ if (ioError)
189
+ throw ioError;
190
+ await this.logMemoryRetrievalResult(durableSessionId, hits);
129
191
  return hits;
130
192
  }
131
- async logMemoryRetrievalResult(sessionId, retrieval) {
193
+ async logMemoryRetrievalResult(sessionId, hits) {
132
194
  if (!sessionId)
133
195
  return;
134
196
  // The session-log record is the durable audit artifact; the kernel needs no
135
197
  // acknowledgment (the former kernel event was a no-op and was removed).
136
198
  await this.opts.sessionLog.append(sessionId, {
137
199
  kind: "memory_retrieval_result",
138
- selected_memory_ids: retrieval.selected_memory_ids,
139
- selection_rationale: retrieval.selection_rationale,
200
+ hits,
140
201
  });
141
202
  }
142
203
  createSyscallRuntime() {
@@ -149,6 +210,37 @@ export class RuntimeRunner {
149
210
  timeoutMs: this.opts.timeoutMs !== undefined ? BigInt(this.opts.timeoutMs) : undefined,
150
211
  });
151
212
  }
213
+ groupBudgetRequest(includeTokens = true) {
214
+ const tokens = includeTokens ? this.opts.maxTotalTokens : undefined;
215
+ const subagents = this.opts.resourceQuota?.maxTotalSubagents;
216
+ const rounds = this.opts.runSpec?.loopRound ? 1 : undefined;
217
+ const roundLimit = this.opts.runSpec?.loopRound?.maxRounds;
218
+ return {
219
+ limits: {
220
+ ...(tokens !== undefined ? { tokens } : {}),
221
+ ...(subagents !== undefined ? { subagents } : {}),
222
+ ...(roundLimit !== undefined ? { rounds: roundLimit } : {}),
223
+ },
224
+ requested: {
225
+ ...(tokens !== undefined ? { tokens } : {}),
226
+ ...(subagents !== undefined ? { subagents } : {}),
227
+ ...(rounds !== undefined ? { rounds } : {}),
228
+ },
229
+ };
230
+ }
231
+ async settleGroupBudget(scope, actual) {
232
+ const retries = this.opts.kernelReliability?.hostEffectRetryAttempts ?? 3;
233
+ for (let attempt = 0;; attempt += 1) {
234
+ try {
235
+ await scope.settle(actual);
236
+ return;
237
+ }
238
+ catch (error) {
239
+ if (attempt >= retries)
240
+ throw error;
241
+ }
242
+ }
243
+ }
152
244
  /**
153
245
  * Lower the declarative governance / attention / scheduler-budget / resource-quota policies into a
154
246
  * freshly-created kernel. Shared by `execute()` (full agent run) and `bootstrapWorkflowKernel()`
@@ -156,23 +248,73 @@ export class RuntimeRunner {
156
248
  * exactly as a mid-run spawn would be. Must run BEFORE `start_run` so the in-kernel gate enforces
157
249
  * every policy from the first spawn. No config ⇒ the native-profile defaults (铁律: defaults only).
158
250
  */
159
- applyKernelPolicies(runtime, groupTokensBase, groupSpawnsBase, groupRoundsBase) {
251
+ async applyKernelPolicies(runtime, groupBudgetScope) {
160
252
  // K2: lower governance / attention / scheduler / quota in ONE `configure_run` event instead of
161
253
  // the previous 2–4 separate `set_*` / `load_governance_policy` events. The kernel applies each
162
254
  // present field via the same path its granular event uses; absent fields are left untouched.
163
255
  // (Requires the 0.2.30 core that ships `configure_run`.)
164
256
  const osProfile = assertNativeProfile(this.opts.osProfile ?? "native");
165
- const attentionPolicy = this.opts.attentionPolicy ?? osProfile.attentionPolicy;
257
+ const signalPolicy = this.opts.signalPolicy ?? osProfile.signalPolicy;
166
258
  const governancePolicy = this.opts.governancePolicy ?? osProfile.governancePolicy;
167
259
  // Strip the event `kind` off the governance event — `configure_run.config.governance` carries the
168
260
  // bare policy fields (default_action / rules / vetoed_tools / rate_limits / constraints).
169
261
  const { kind: _govKind, ...governance } = governancePolicyToKernelEvent(governancePolicy);
170
262
  const config = { governance };
171
- if (attentionPolicy.maxQueueSize !== undefined) {
172
- config.attention_max_queue_size = attentionPolicy.maxQueueSize;
263
+ if (this.opts.contextPolicy) {
264
+ config.context_policy = normalizeContextPolicyV1(contextPolicyV1(this.opts.contextPolicy));
265
+ }
266
+ if (this.opts.kernelReliability) {
267
+ const reliability = this.opts.kernelReliability;
268
+ config.reliability = {
269
+ ...(reliability.eventReplayCapacity !== undefined
270
+ ? { event_replay_capacity: reliability.eventReplayCapacity }
271
+ : {}),
272
+ ...(reliability.completedEffectReplayCapacity !== undefined
273
+ ? { completed_effect_replay_capacity: reliability.completedEffectReplayCapacity }
274
+ : {}),
275
+ ...(reliability.providerRecoveryAttempts !== undefined
276
+ ? { provider_recovery_attempts: reliability.providerRecoveryAttempts }
277
+ : {}),
278
+ ...(reliability.outputRecoveryAttempts !== undefined
279
+ ? { output_recovery_attempts: reliability.outputRecoveryAttempts }
280
+ : {}),
281
+ ...(reliability.hostEffectRetryAttempts !== undefined
282
+ ? { host_effect_retry_attempts: reliability.hostEffectRetryAttempts }
283
+ : {}),
284
+ ...(reliability.spoolThresholdBytes !== undefined
285
+ ? { spool_threshold_bytes: reliability.spoolThresholdBytes }
286
+ : {}),
287
+ ...(reliability.spoolPreviewBytes !== undefined
288
+ ? { spool_preview_bytes: reliability.spoolPreviewBytes }
289
+ : {}),
290
+ ...(reliability.snapshotInputLimit !== undefined
291
+ ? { snapshot_input_limit: reliability.snapshotInputLimit }
292
+ : {}),
293
+ ...(reliability.maxInputBytes !== undefined
294
+ ? { max_input_bytes: reliability.maxInputBytes }
295
+ : {}),
296
+ ...(reliability.snapshotJournalBytesLimit !== undefined
297
+ ? { snapshot_journal_bytes_limit: reliability.snapshotJournalBytesLimit }
298
+ : {}),
299
+ };
300
+ }
301
+ config.signal_policy = {
302
+ version: 1,
303
+ queue_max: signalPolicy.queueMax,
304
+ ...(signalPolicy.ttlMs !== undefined ? { ttl_ms: signalPolicy.ttlMs } : {}),
305
+ ...(signalPolicy.deadlineEscalation !== undefined
306
+ ? { deadline_escalation: signalPolicy.deadlineEscalation }
307
+ : {}),
308
+ };
309
+ if (this.opts.promptBudget) {
310
+ config.prompt_budget = {
311
+ prompt_overhead_tokens: this.opts.promptBudget.promptOverheadTokens,
312
+ output_reserve_tokens: this.opts.promptBudget.outputReserveTokens,
313
+ safety_margin_tokens: this.opts.promptBudget.safetyMarginTokens,
314
+ };
173
315
  }
174
- if (this.opts.schedulerBudget?.maxWallMs !== undefined) {
175
- config.scheduler_max_wall_ms = this.opts.schedulerBudget.maxWallMs;
316
+ if (this.opts.schedulerPolicy) {
317
+ config.scheduler_policy = schedulerPolicyToKernel(this.opts.schedulerPolicy);
176
318
  }
177
319
  if (this.opts.resourceQuota) {
178
320
  const q = this.opts.resourceQuota;
@@ -185,15 +327,19 @@ export class RuntimeRunner {
185
327
  : {}),
186
328
  };
187
329
  }
188
- if (groupTokensBase !== undefined && groupTokensBase > 0) {
189
- config.group_tokens_base = groupTokensBase;
190
- }
191
- if (groupSpawnsBase !== undefined && groupSpawnsBase > 0) {
192
- config.group_spawns_base = groupSpawnsBase;
193
- }
194
- if (groupRoundsBase !== undefined && groupRoundsBase > 0) {
195
- // loop-agent: completed-round count seeds the pacing trap's max_rounds coercion.
196
- config.group_rounds_base = groupRoundsBase;
330
+ if (groupBudgetScope) {
331
+ config.budget_grant = {
332
+ reservation_id: groupBudgetScope.reservationId,
333
+ ...(groupBudgetScope.granted.tokens !== undefined
334
+ ? { tokens: groupBudgetScope.granted.tokens }
335
+ : {}),
336
+ ...(groupBudgetScope.granted.subagents !== undefined
337
+ ? { subagents: groupBudgetScope.granted.subagents }
338
+ : {}),
339
+ ...(groupBudgetScope.granted.rounds !== undefined
340
+ ? { rounds: groupBudgetScope.granted.rounds }
341
+ : {}),
342
+ };
197
343
  }
198
344
  // O6: tune/disable the in-kernel repeat fuse. `false` disables; an object overrides thresholds.
199
345
  // Absent ⇒ kernel defaults (enabled, deny_after=5, terminate_after=8).
@@ -223,7 +369,7 @@ export class RuntimeRunner {
223
369
  ...(ew.notifyModel !== undefined ? { notify_model: ew.notifyModel } : {}),
224
370
  };
225
371
  }
226
- kernelApply(runtime, this.pendingObservations, { kind: "configure_run", config });
372
+ await this.commitKernelApply(runtime, this.pendingObservations, { kind: "configure_run", config });
227
373
  }
228
374
  async appendMemorySyscallObservations(sessionId, observations) {
229
375
  if (!sessionId)
@@ -240,37 +386,37 @@ export class RuntimeRunner {
240
386
  }
241
387
  }
242
388
  /** Mount a tool capability on the currently-running kernel runtime. No-op if not running. */
243
- mountTool(schema) {
389
+ async mountTool(schema) {
244
390
  if (!this.activeKernel)
245
391
  return;
246
- kernelApply(this.activeKernel, this.pendingObservations, capabilityCommandMount(capabilityTool(schema)));
392
+ await this.commitKernelApply(this.activeKernel, this.pendingObservations, capabilityCommandMount(capabilityTool(schema)));
247
393
  }
248
394
  /** Mount a skill capability on the currently-running kernel runtime. No-op if not running. */
249
- mountSkill(name, description) {
395
+ async mountSkill(name, description) {
250
396
  if (!this.activeKernel)
251
397
  return;
252
- kernelApply(this.activeKernel, this.pendingObservations, capabilityCommandMount(capabilitySkill({ name, description, estimatedTokens: 0 })));
398
+ await this.commitKernelApply(this.activeKernel, this.pendingObservations, capabilityCommandMount(capabilitySkill({ name, description, estimatedTokens: 0 })));
253
399
  }
254
400
  /** Mount a generic marker capability (e.g. MCP server, agent) on the active run. No-op if not running. */
255
- mountMarker(kind, id, description) {
401
+ async mountMarker(kind, id, description) {
256
402
  if (!this.activeKernel)
257
403
  return;
258
- kernelApply(this.activeKernel, this.pendingObservations, capabilityCommandMount(capabilityMarker(kind, id, description)));
404
+ await this.commitKernelApply(this.activeKernel, this.pendingObservations, capabilityCommandMount(capabilityMarker(kind, id, description)));
259
405
  }
260
406
  /** Unmount a capability by kind + id from the active run. No-op if not running. */
261
- unmountCapability(kind, id) {
407
+ async unmountCapability(kind, id) {
262
408
  if (!this.activeKernel)
263
409
  return;
264
- kernelApply(this.activeKernel, this.pendingObservations, capabilityCommandUnmount(kind, id));
410
+ await this.commitKernelApply(this.activeKernel, this.pendingObservations, capabilityCommandUnmount(kind, id));
265
411
  }
266
412
  /** Push content into the Knowledge slot (memory retrievals, skill definitions, artifacts).
267
413
  * K1: `opts.key` gives the entry identity — a same-key push upserts (applied at the next
268
414
  * compaction/renewal boundary, where the cached system[1] block is rewritten anyway) instead
269
415
  * of appending a duplicate. `opts.pinned` exempts the entry from the knowledge-budget sweep. */
270
- pushKnowledge(message, tokens, opts) {
416
+ async pushKnowledge(message, tokens, opts) {
271
417
  if (!this.activeKernel)
272
418
  return;
273
- kernelApply(this.activeKernel, this.pendingObservations, {
419
+ await this.commitKernelApply(this.activeKernel, this.pendingObservations, {
274
420
  kind: "add_knowledge_message",
275
421
  content: message.content ?? "",
276
422
  tokens: tokens ?? Math.max(1, Math.ceil((message.content?.length ?? 0) / 4)),
@@ -280,19 +426,19 @@ export class RuntimeRunner {
280
426
  }
281
427
  /** K1: mark a keyed knowledge entry for removal at the next compaction/renewal boundary.
282
428
  * Errs-open: an unknown key is a kernel-side no-op. */
283
- removeKnowledge(key) {
429
+ async removeKnowledge(key) {
284
430
  if (!this.activeKernel)
285
431
  return;
286
- kernelApply(this.activeKernel, this.pendingObservations, { kind: "remove_knowledge", key });
432
+ await this.commitKernelApply(this.activeKernel, this.pendingObservations, { kind: "remove_knowledge", key });
287
433
  }
288
434
  /** K3: host-driven skill deactivation (there is deliberately no model-facing unload — it
289
435
  * invites thrash). The toolset re-widens at the next provider call; the skill's knowledge pin
290
436
  * drops at the next compaction/renewal boundary. A later `skill(name)` call re-activates and
291
437
  * re-pins fresh content. Errs-open: not-active is a kernel-side no-op. */
292
- deactivateSkill(name) {
438
+ async deactivateSkill(name) {
293
439
  if (!this.activeKernel)
294
440
  return;
295
- kernelApply(this.activeKernel, this.pendingObservations, { kind: "skill_deactivated", name });
441
+ await this.commitKernelApply(this.activeKernel, this.pendingObservations, { kind: "skill_deactivated", name });
296
442
  // Re-arm the SDK-side push guard so a re-activation re-pins the content.
297
443
  this.knowledgePushedSkills.delete(name);
298
444
  }
@@ -306,7 +452,7 @@ export class RuntimeRunner {
306
452
  }
307
453
  const parentSessionId = this.currentSessionId;
308
454
  const runtime = this.activeKernel;
309
- const observations = kernelApply(runtime, this.pendingObservations, {
455
+ const observations = await this.commitKernelApply(runtime, this.pendingObservations, {
310
456
  kind: "spawn_sub_agent",
311
457
  spec: agentRunSpecToKernel(spec),
312
458
  parent_session_id: parentSessionId,
@@ -325,7 +471,7 @@ export class RuntimeRunner {
325
471
  sessionLog: this.opts.sessionLog,
326
472
  ...(this.opts.subAgentHarness ? { harness: this.opts.subAgentHarness } : {}),
327
473
  });
328
- kernelApply(runtime, this.pendingObservations, {
474
+ await this.commitKernelApply(runtime, this.pendingObservations, {
329
475
  kind: "sub_agent_completed",
330
476
  result: subAgentResultToKernel(result),
331
477
  });
@@ -414,10 +560,10 @@ export class RuntimeRunner {
414
560
  const schema = node.output_schema;
415
561
  if (!schema)
416
562
  return orchestrator.run(mkCtx(baseSpec.goal));
417
- const MAX_ATTEMPTS = 2;
563
+ const maxAttempts = this.opts.workflowSchemaValidationAttempts ?? 2;
418
564
  let last;
419
565
  let lastErrors = [];
420
- for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
566
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
421
567
  const goal = attempt === 1
422
568
  ? `${baseSpec.goal}\n\n${schemaInstruction(schema)}`
423
569
  : `${baseSpec.goal}\n\n${schemaRetryInstruction(schema, lastErrors)}`;
@@ -430,7 +576,7 @@ export class RuntimeRunner {
430
576
  last = result;
431
577
  lastErrors = v.errors;
432
578
  }
433
- const reason = `output_schema validation failed after ${MAX_ATTEMPTS} attempts: ${lastErrors.join("; ")}`;
579
+ const reason = `output_schema validation failed after ${maxAttempts} attempts: ${lastErrors.join("; ")}`;
434
580
  const fallback = last;
435
581
  return {
436
582
  ...fallback,
@@ -467,7 +613,7 @@ export class RuntimeRunner {
467
613
  * W0-ABI: run a declarative workflow DAG. The kernel owns the DAG and gates every node spawn
468
614
  * through the syscall trap; this driver runs each kernel-emitted batch of nodes in parallel,
469
615
  * feeds their results back, and loops until the kernel reports the workflow complete.
470
- * Returns the completed / failed node agent-ids.
616
+ * Returns one typed terminal outcome for every node in the DAG.
471
617
  */
472
618
  async runWorkflow(spec, opts) {
473
619
  // Standalone entry: with no active parent run (e.g. a stateless HTTP handler), auto-bootstrap a
@@ -475,34 +621,42 @@ export class RuntimeRunner {
475
621
  // gets — then tear it down on completion so the runner is reusable. Mid-run callers (activeKernel
476
622
  // already set by an in-flight `run()`) keep the original in-place behavior with no teardown.
477
623
  const bootstrapped = !this.activeKernel || !this.currentSessionId;
478
- if (bootstrapped) {
479
- const sessionId = opts?.sessionId ?? `wf-${crypto.randomUUID()}`;
480
- // L1: a standalone workflow is a member of its runner's governance domain too. Seed the
481
- // bootstrap kernel with the group's cumulative spend (so the cumulative spawn/token cap bites
482
- // while scheduling DAG nodes) and register membership — mirroring `execute()`. Mid-run callers
483
- // skip this: their parent `run()` already seeds + counts the nodes via `localSubagentsSpawned()`.
484
- let groupLedger;
485
- if (this.opts.runGroup) {
486
- const g = this.opts.runGroup;
487
- groupLedger = await g.budgetStore.read(g.id);
488
- await g.budgetStore.join(g.id, { sessionId, role: this.opts.agentId, kind: "vehicle" });
489
- }
490
- this.bootstrapWorkflowKernel(sessionId, spec, groupLedger?.tokensSpent, groupLedger?.subagentsSpawned);
491
- }
492
- const parentSessionId = this.currentSessionId;
493
- const runtime = this.activeKernel;
624
+ let groupBudgetScope;
494
625
  try {
495
- const observations = kernelApply(runtime, this.pendingObservations, {
626
+ if (bootstrapped) {
627
+ const sessionId = opts?.sessionId ?? `wf-${crypto.randomUUID()}`;
628
+ // A standalone workflow reserves a bounded slice before its kernel schedules any node.
629
+ // Mid-run callers reuse their parent run's already-active reservation.
630
+ if (this.opts.runGroup) {
631
+ const g = this.opts.runGroup;
632
+ groupBudgetScope = await GroupBudgetScope.open(g, { sessionId, role: this.opts.agentId, kind: "vehicle" }, this.groupBudgetRequest(false));
633
+ this.activeGroupBudgetScope = groupBudgetScope;
634
+ }
635
+ // Resume depends on this fact. Do not dispatch any node until it is durable.
636
+ await this.opts.sessionLog.append(sessionId, {
637
+ kind: "run_started",
638
+ run_id: crypto.randomUUID(),
639
+ goal: `workflow:${spec.nodes.length} nodes`,
640
+ criteria: [],
641
+ agent_id: this.opts.agentId,
642
+ });
643
+ await this.bootstrapWorkflowKernel(sessionId, groupBudgetScope);
644
+ }
645
+ const parentSessionId = this.currentSessionId;
646
+ const runtime = this.activeKernel;
647
+ const observationStart = this.pendingObservations.length;
648
+ const initialAction = await this.commitKernelMaybeAction(runtime, this.pendingObservations, {
496
649
  kind: "load_workflow",
497
650
  spec: workflowSpecToKernel(spec),
498
651
  parent_session_id: parentSessionId,
499
- // W0-ABI resume: skip nodes already completed before an interruption.
500
- ...(opts?.resumedCompleted?.length ? { resumed_completed: opts.resumedCompleted } : {}),
501
- // W-1: signal-carrying completion records (classify branch / loop stop replay).
502
- ...(opts?.resumedResults?.length
652
+ // Exact typed terminal outcomes plus control-flow signals recovered from the journal.
653
+ ...(opts?.resumedOutcomes?.length
503
654
  ? {
504
- resumed_results: opts.resumedResults.map(r => ({
655
+ resumed_outcomes: opts.resumedOutcomes.map(r => ({
505
656
  agent_id: r.agentId,
657
+ status: r.status,
658
+ termination: r.termination,
659
+ ...(r.output ? { output: messageToKernelMessage(r.output) } : {}),
506
660
  ...(r.classifyBranch !== undefined ? { classify_branch: r.classifyBranch } : {}),
507
661
  ...(r.tournamentWinner !== undefined ? { tournament_winner: r.tournamentWinner } : {}),
508
662
  ...(r.loopContinue !== undefined ? { loop_continue: r.loopContinue } : {}),
@@ -513,24 +667,30 @@ export class RuntimeRunner {
513
667
  ...(opts?.resumedSubmissions?.length ? { resumed_submissions: opts.resumedSubmissions } : {}),
514
668
  ...(opts?.resumedSubmissionBases?.length ? { resumed_submission_bases: opts.resumedSubmissionBases } : {}),
515
669
  });
516
- return await this.driveWorkflow(observations, parentSessionId, runtime, opts?.resumedOutputs);
670
+ const observations = this.pendingObservations.slice(observationStart);
671
+ const outcome = await this.driveWorkflow(initialAction, observations, parentSessionId, runtime, recoveredOutputs(opts?.resumedOutcomes));
672
+ if (bootstrapped) {
673
+ const terminal = await this.commitKernelAction(runtime, this.pendingObservations, { kind: "complete_run" });
674
+ if (terminal.kind !== "done") {
675
+ throw new Error("complete_run did not produce a terminal kernel action");
676
+ }
677
+ await this.appendObservations(parentSessionId, runtime, 0);
678
+ }
679
+ return outcome;
517
680
  }
518
681
  finally {
519
682
  if (bootstrapped) {
520
- // L1: charge the standalone workflow's node spawns back to the group so the cumulative spawn
521
- // cap (`maxTotalSubagents`) counts workflow nodes — they are member runs whose own
522
- // `execute()` charge contributes 0 spawns, so without this the node count is invisible to the
523
- // group. The envelope kernel's TaskTable holds one proc per scheduled node, so
524
- // `localSubagentsSpawned()` is exactly that node count (the envelope itself burns no tokens).
525
- if (this.opts.runGroup) {
526
- const subagents = runtime.localSubagentsSpawned?.() ?? 0;
527
- if (subagents > 0) {
528
- await this.opts.runGroup.budgetStore.charge(this.opts.runGroup.id, { subagents });
683
+ try {
684
+ if (groupBudgetScope && !groupBudgetScope.isClosed) {
685
+ await groupBudgetScope.release();
529
686
  }
530
687
  }
531
- this.activeKernel = null;
532
- this.currentSessionId = null;
533
- this.pendingObservations = [];
688
+ finally {
689
+ this.activeKernel = null;
690
+ this.currentSessionId = null;
691
+ this.pendingObservations = [];
692
+ this.activeGroupBudgetScope = undefined;
693
+ }
534
694
  }
535
695
  }
536
696
  }
@@ -538,31 +698,25 @@ export class RuntimeRunner {
538
698
  * Bootstrap a standalone kernel for a host-driven workflow with NO active parent run — the path a
539
699
  * stateless request handler takes when it calls `runWorkflow(spec)` directly. Mirrors `execute()`'s
540
700
  * pre-run kernel setup (governance / attention / quota via `applyKernelPolicies`, then `start_run`)
541
- * and records a `run_started` event so the standalone run is resumable from the session log. Sets
542
- * `activeKernel` / `currentSessionId`; `runWorkflow` is responsible for tearing them down.
701
+ * after `runWorkflow` has durably recorded `run_started`. Sets `activeKernel` / `currentSessionId`;
702
+ * `runWorkflow` is responsible for tearing them down.
543
703
  */
544
- bootstrapWorkflowKernel(sessionId, spec, groupTokensBase, groupSpawnsBase) {
704
+ async bootstrapWorkflowKernel(sessionId, groupBudgetScope) {
545
705
  this.interrupted = false;
546
706
  this.abortController = new AbortController();
547
707
  this.pendingObservations = [];
548
- this.pendingSpoolOutputs.clear();
708
+ this.pendingPageOutArchives = [];
709
+ this.activePageOutArchive = undefined;
549
710
  this.currentSessionId = sessionId;
550
711
  const runtime = this.createSyscallRuntime();
551
712
  this.activeKernel = runtime;
552
- const goal = `workflow:${spec.nodes.length} nodes`;
553
- // Best-effort run_started log so a standalone workflow can be resumed via `resumeWorkflow`. The
554
- // session log is fire-and-forget here (the kernel state, not the log, drives the DAG); a logless
555
- // store simply means no resume.
556
- void this.opts.sessionLog.append(sessionId, {
557
- kind: "run_started",
558
- run_id: crypto.randomUUID(),
559
- goal,
560
- criteria: [],
561
- agent_id: this.opts.agentId,
562
- }).catch(() => { });
563
- this.applyKernelPolicies(runtime, groupTokensBase, groupSpawnsBase);
564
- // K1: no explicit `start_run` — the host `load_workflow` (fired next by `runWorkflow`) self-bootstraps
565
- // the run on the 0.2.30 core, matching the agent-reachable `submit_workflow` path.
713
+ await this.applyKernelPolicies(runtime, groupBudgetScope);
714
+ // ABI v2 has one lifecycle: standalone workflows start a real run before loading their DAG.
715
+ // The initial provider effect is superseded by the workflow load; no self-bootstrap escape hatch.
716
+ await this.commitKernelAction(runtime, this.pendingObservations, {
717
+ kind: "start_run",
718
+ task: { goal: `workflow session ${sessionId}`, criteria: [] },
719
+ });
566
720
  return runtime;
567
721
  }
568
722
  /**
@@ -580,7 +734,9 @@ export class RuntimeRunner {
580
734
  }
581
735
  const parentSessionId = this.currentSessionId;
582
736
  const runtime = this.activeKernel;
583
- const observations = kernelApply(runtime, this.pendingObservations, submitWorkflowToKernel(spec, parentSessionId, opts?.submitterAgentId));
737
+ const observationStart = this.pendingObservations.length;
738
+ const initialAction = await this.commitKernelMaybeAction(runtime, this.pendingObservations, submitWorkflowToKernel(spec, parentSessionId, opts?.submitterAgentId));
739
+ const observations = this.pendingObservations.slice(observationStart);
584
740
  // W-3: persist the agent-authored batch (bootstrap base 0 / flatten base N — the kernel now
585
741
  // announces BOTH) so an interrupted authored workflow reconstructs on resume; the host never
586
742
  // had this spec, unlike the `runWorkflow` path.
@@ -593,7 +749,7 @@ export class RuntimeRunner {
593
749
  submitterAgentId: opts?.submitterAgentId,
594
750
  }));
595
751
  }
596
- return this.driveWorkflow(observations, parentSessionId, runtime);
752
+ return this.driveWorkflow(initialAction, observations, parentSessionId, runtime);
597
753
  }
598
754
  /**
599
755
  * M5 v2.1: drive the sub-workflow(s) a top-level agent authored via `start_workflow`. Called at the
@@ -607,14 +763,14 @@ export class RuntimeRunner {
607
763
  async driveAuthoredWorkflows(runtime, action) {
608
764
  const specs = this.pendingAuthoredWorkflows;
609
765
  this.pendingAuthoredWorkflows = [];
766
+ this.workflowContinuation = null;
610
767
  for (const spec of specs) {
611
- const outcome = await this.bootstrapWorkflow(spec);
612
- kernelApply(runtime, this.pendingObservations, {
613
- kind: "add_history_message",
614
- message: messageToKernelMessage({ role: "user", content: authoredWorkflowOutcomeNote(outcome) }),
615
- });
768
+ await this.bootstrapWorkflow(spec);
616
769
  }
617
- return { kind: "call_provider", context: runtime.render(), tools: action.tools };
770
+ const continuation = this.workflowContinuation;
771
+ if (!continuation)
772
+ throw new Error("authored workflow completed without a provider continuation");
773
+ return continuation;
618
774
  }
619
775
  /**
620
776
  * #2-B-ii: while a workflow batch is in flight, poll the signal source. A Critical `InterruptNow`
@@ -631,21 +787,40 @@ export class RuntimeRunner {
631
787
  while (!batchState.settled) {
632
788
  // O2: injected notes participate in the monitor too, so a host `injectNote` mid-batch is not
633
789
  // stranded until the batch settles (the drain order matches `nextInboundSignal`).
634
- const sig = this.injectedSignals.shift()
635
- ?? await source.nextSignal(this.currentSessionId ?? undefined);
636
- if (batchState.settled)
790
+ const delivery = await this.nextInboundSignal();
791
+ if (batchState.settled) {
792
+ await delivery?.nack();
637
793
  break;
638
- if (!sig) {
794
+ }
795
+ if (!delivery) {
639
796
  await new Promise(resolve => setTimeout(resolve, 5));
640
797
  continue;
641
798
  }
642
- const obs = kernelApply(runtime, this.pendingObservations, signalToKernelEvent(sig));
643
- const preempted = obs.find(o => o.kind === "agent_preempted");
799
+ const observationStart = this.pendingObservations.length;
800
+ const signalAction = await this.consumeInboundSignal(delivery, sig => this.commitKernelMaybeAction(runtime, this.pendingObservations, signalToKernelEvent(sig)));
801
+ let observations = this.pendingObservations.slice(observationStart);
802
+ if (signalAction?.kind === "preempt_sub_agents") {
803
+ for (const id of signalAction.agentIds)
804
+ controllers.get(id)?.abort();
805
+ const resultStart = this.pendingObservations.length;
806
+ const continuation = await this.commitKernelMaybeAction(runtime, this.pendingObservations, {
807
+ kind: "preempt_result",
808
+ effect_id: signalAction.effectId,
809
+ });
810
+ if (continuation && continuation.kind !== "call_provider" && continuation.kind !== "done") {
811
+ throw new Error(`workflow preemption returned unexpected effect: ${continuation.kind}`);
812
+ }
813
+ observations = [...observations, ...this.pendingObservations.slice(resultStart)];
814
+ }
815
+ else if (signalAction) {
816
+ throw new Error(`workflow signal returned unexpected effect: ${signalAction.kind}`);
817
+ }
818
+ const preempted = observations.find(o => o.kind === "agent_preempted");
644
819
  if (preempted) {
645
820
  for (const id of preempted.agent_ids ?? [])
646
821
  controllers.get(id)?.abort();
647
- const wc = obs.find(o => o.kind === "workflow_completed");
648
- return { completed: wc?.completed ?? [], failed: wc?.failed ?? [] };
822
+ const wc = observations.find(o => o.kind === "workflow_completed");
823
+ return (wc?.node_outcomes ?? []).map(workflowNodeOutcomeFromKernel);
649
824
  }
650
825
  }
651
826
  return null;
@@ -654,22 +829,40 @@ export class RuntimeRunner {
654
829
  * Shared workflow driver for `runWorkflow` (host `load_workflow`) and `bootstrapWorkflow` (agent
655
830
  * `submit_workflow`): given the observations from the initial load/bootstrap, run each kernel-emitted
656
831
  * batch in parallel, feed completions back (appending any agent-submitted nodes first), and loop
657
- * until the kernel reports the workflow complete. Returns the completed / failed node agent-ids.
832
+ * until the kernel reports the workflow complete. Returns typed terminal node outcomes.
658
833
  */
659
- async driveWorkflow(initial, parentSessionId, runtime, seedOutputs) {
834
+ async driveWorkflow(initialAction, initial, parentSessionId, runtime, seedOutputs) {
660
835
  let observations = initial;
661
836
  const orchestrator = this.opts.subAgentOrchestrator ?? defaultSubAgentOrchestrator;
662
- const collectNodes = (obs) => obs.find(o => o.kind === "workflow_batch_spawned")
663
- ?.nodes ?? [];
664
- // G4: the batch observation also carries the workflow's remaining budget; track the latest so a
665
- // coordinator node's prompt reflects current headroom when it decides how much to submit.
666
- const collectBudget = (obs) => obs.find(o => o.kind === "workflow_batch_spawned")?.budget;
667
837
  const findDone = (obs) => obs.find(o => o.kind === "workflow_completed");
838
+ const acceptSpawn = async (spawn) => {
839
+ const observationStart = this.pendingObservations.length;
840
+ const continuation = await this.commitKernelMaybeAction(runtime, this.pendingObservations, {
841
+ kind: "workflow_spawn_result",
842
+ effect_id: spawn.effectId,
843
+ started_agent_ids: spawn.nodes.map(node => String(node.agent_id ?? "")),
844
+ failures: [],
845
+ });
846
+ if (continuation) {
847
+ throw new Error(`workflow spawn acknowledgement returned unexpected effect: ${continuation.kind}`);
848
+ }
849
+ return this.pendingObservations.slice(observationStart);
850
+ };
668
851
  let done = findDone(observations);
669
- if (done)
670
- return { completed: done.completed ?? [], failed: done.failed ?? [], outputs: {} };
671
- let nodes = collectNodes(observations);
672
- let budget = collectBudget(observations);
852
+ if (done) {
853
+ if (initialAction?.kind === "call_provider")
854
+ this.workflowContinuation = initialAction;
855
+ return { nodeOutcomes: (done.node_outcomes ?? []).map(workflowNodeOutcomeFromKernel), outputs: {} };
856
+ }
857
+ if (!initialAction)
858
+ return { nodeOutcomes: [], outputs: {} };
859
+ if (initialAction.kind !== "spawn_workflow") {
860
+ throw new Error(`workflow load returned unexpected kernel effect: ${initialAction.kind}`);
861
+ }
862
+ let nodes = initialAction.nodes;
863
+ let budget = initialAction.budget;
864
+ observations = await acceptSpawn(initialAction);
865
+ done = findDone(observations);
673
866
  // G2: each completed node's output, keyed by agent id — a reduce node reads its dependencies'
674
867
  // outputs from here. Deps always complete in an earlier round than the reduce node that needs
675
868
  // them (the kernel keeps the reduce node un-ready until its deps finish), so this is populated.
@@ -678,7 +871,7 @@ export class RuntimeRunner {
678
871
  const outputs = new Map(seedOutputs ?? []);
679
872
  for (;;) {
680
873
  if (nodes.length === 0)
681
- return { completed: [], failed: [], outputs: Object.fromEntries(outputs) }; // nothing to run (e.g. all gated)
874
+ return { nodeOutcomes: [], outputs: Object.fromEntries(outputs) }; // nothing to run (e.g. all gated)
682
875
  // Run the currently-runnable nodes in parallel — each is independent within a round.
683
876
  const roundBudget = budget;
684
877
  // #2-B-ii: per-node abort controllers + a concurrent preemption monitor. While the batch is in
@@ -692,7 +885,7 @@ export class RuntimeRunner {
692
885
  batchState.settled = true;
693
886
  const preempted = await monitor;
694
887
  if (preempted)
695
- return { ...preempted, outputs: Object.fromEntries(outputs) };
888
+ return { nodeOutcomes: preempted, outputs: Object.fromEntries(outputs) };
696
889
  // Feed completions back one at a time. The kernel's run-queue executor may spawn a node's
697
890
  // dependents the moment *that* node completes (per-node unblock), so each feed can emit its
698
891
  // own `workflow_batch_spawned`; ACCUMULATE them across the round rather than keeping only the
@@ -719,26 +912,50 @@ export class RuntimeRunner {
719
912
  // G1: stamp the submitting node's agent id so the kernel can coerce a quarantined
720
913
  // submitter's nodes to quarantined (no topological privilege escalation).
721
914
  const submitEvent = submitWorkflowNodesToKernel(result.submittedNodes, result.agentId);
722
- const subObs = kernelApply(runtime, this.pendingObservations, submitEvent);
723
- nextNodes.push(...collectNodes(subObs));
724
- budget = collectBudget(subObs) ?? budget;
915
+ const observationStart = this.pendingObservations.length;
916
+ const submitAction = await this.commitKernelMaybeAction(runtime, this.pendingObservations, submitEvent);
917
+ const subObs = this.pendingObservations.slice(observationStart);
918
+ if (submitAction?.kind === "spawn_workflow") {
919
+ nextNodes.push(...submitAction.nodes);
920
+ budget = submitAction.budget ?? budget;
921
+ const accepted = await acceptSpawn(submitAction);
922
+ const submittedDone = findDone([...subObs, ...accepted]);
923
+ if (submittedDone)
924
+ done = submittedDone;
925
+ }
926
+ else if (submitAction) {
927
+ throw new Error(`workflow node submission returned unexpected effect: ${submitAction.kind}`);
928
+ }
725
929
  // R3-1: persist the submission (kernel-shape nodes) + its kernel-reported base index
726
930
  // so resume can re-apply the batch at the exact original graph position. W-N3: also the
727
931
  // submitter, so resume drops batches whose submitter re-runs (it will re-submit).
728
932
  const submitted = subObs.find(o => o.kind === "workflow_nodes_submitted");
729
- await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
730
- turn: runtime.turn(),
731
- nodes: submitEvent.nodes ?? [],
732
- baseIndex: submitted?.base,
733
- submitterAgentId: result.agentId,
734
- }));
933
+ if (submitted) {
934
+ await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
935
+ turn: runtime.turn(),
936
+ nodes: submitEvent.nodes ?? [],
937
+ baseIndex: submitted.base,
938
+ submitterAgentId: result.agentId,
939
+ }));
940
+ }
735
941
  }
736
- const obs = kernelApply(runtime, this.pendingObservations, {
942
+ const observationStart = this.pendingObservations.length;
943
+ const completionAction = await this.commitKernelMaybeAction(runtime, this.pendingObservations, {
737
944
  kind: "sub_agent_completed",
738
945
  result: subAgentResultToKernel(result),
739
946
  });
740
- nextNodes.push(...collectNodes(obs));
741
- budget = collectBudget(obs) ?? budget;
947
+ let obs = this.pendingObservations.slice(observationStart);
948
+ if (completionAction?.kind === "spawn_workflow") {
949
+ nextNodes.push(...completionAction.nodes);
950
+ budget = completionAction.budget ?? budget;
951
+ obs = [...obs, ...await acceptSpawn(completionAction)];
952
+ }
953
+ else if (completionAction?.kind === "call_provider") {
954
+ this.workflowContinuation = completionAction;
955
+ }
956
+ else if (completionAction) {
957
+ throw new Error(`workflow completion returned unexpected effect: ${completionAction.kind}`);
958
+ }
742
959
  const d = findDone(obs);
743
960
  if (d)
744
961
  done = d;
@@ -748,15 +965,19 @@ export class RuntimeRunner {
748
965
  await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodeCompletedEvent({
749
966
  turn: runtime.turn(),
750
967
  agentId: result.agentId,
968
+ status: workflowNodeStatusFromTermination(result.result.termination),
751
969
  termination: result.result.termination,
752
970
  classifyBranch: result.result.classifyBranch,
753
971
  tournamentWinner: result.result.tournamentWinner,
754
972
  loopContinue: result.result.loopContinue,
755
- ...(result.result.termination === "completed" && outText ? { output: outText } : {}),
973
+ ...(result.result.finalMessage ? { output: result.result.finalMessage } : {}),
756
974
  }));
757
975
  }
758
976
  if (done && nextNodes.length === 0) {
759
- return { completed: done.completed ?? [], failed: done.failed ?? [], outputs: Object.fromEntries(outputs) };
977
+ return {
978
+ nodeOutcomes: (done.node_outcomes ?? []).map(workflowNodeOutcomeFromKernel),
979
+ outputs: Object.fromEntries(outputs),
980
+ };
760
981
  }
761
982
  nodes = nextNodes;
762
983
  }
@@ -775,39 +996,33 @@ export class RuntimeRunner {
775
996
  throw new Error("resumeWorkflow requires an active parent run or an explicit sessionId");
776
997
  }
777
998
  const events = await this.opts.sessionLog.read(sessionId);
778
- const resumedResults = recoverCompletedWorkflowNodes(events);
779
- const completedIds = new Set(resumedResults.map(r => r.agentId));
999
+ const resumedOutcomes = recoverWorkflowNodeOutcomes(events);
1000
+ const completedIds = new Set(resumedOutcomes.map(r => r.agentId));
780
1001
  const recovered = recoverSubmittedWorkflowNodes(events);
781
1002
  // W-N3: DROP batches whose submitter did NOT complete — that node re-runs on resume and will
782
1003
  // re-submit its batch; replaying the logged copy too would duplicate its nodes in the DAG.
783
- // Only safe with exact bases (the dropped batch's slots become inert placeholders); a legacy
784
- // order-only log keeps every batch, since dropping would shift all later indices.
1004
+ // Exact bases keep later graph indices stable while dropped slots remain inert placeholders.
785
1005
  let { submissions, bases } = recovered;
786
- if (bases.length === submissions.length && submissions.length > 0) {
1006
+ if (submissions.length > 0) {
787
1007
  const keep = recovered.submitters.map(s => s === undefined || completedIds.has(s));
788
1008
  submissions = submissions.filter((_, i) => keep[i]);
789
1009
  bases = bases.filter((_, i) => keep[i]);
790
1010
  }
791
- const resumedOutputs = new Map(resumedResults.filter(r => r.output).map(r => [r.agentId, r.output]));
792
- // Alias loop iterations onto their stable node id (last iteration wins) — dependents consume
793
- // `wf-node{N}`, not `wf-node{N}-i{k}`.
794
- for (const r of resumedResults) {
795
- const stableId = r.agentId.replace(/-i\d+$/, "");
796
- if (stableId !== r.agentId && r.output)
797
- resumedOutputs.set(stableId, r.output);
798
- }
799
1011
  return this.runWorkflow(spec, {
800
- resumedResults,
1012
+ resumedOutcomes,
801
1013
  resumedSubmissions: submissions,
802
1014
  resumedSubmissionBases: bases,
803
- resumedOutputs,
804
1015
  sessionId,
805
1016
  });
806
1017
  }
807
- interrupt() { this.interrupted = true; this.abortController?.abort(); }
1018
+ interrupt(reason = "user") {
1019
+ this.interrupted = true;
1020
+ this.cancellationReason = reason;
1021
+ this.abortController?.abort(reason);
1022
+ }
808
1023
  /** Push a contextual note into the run's signal stream (the system-reminder channel): it drains at
809
- * the next turn boundary, routes through the kernel attention policy, and once acted on — renders
810
- * as a `[SIGNAL] <text>` line in the volatile state turn plus a durable directive. Use it to feed
1024
+ * the next turn boundary, routes through the kernel attention policy, and renders once as a
1025
+ * `[SIGNAL] <text>` line in the volatile state turn. Use it to feed
811
1026
  * host-detected events back to the model mid-run (e.g. "that write was a no-op — stop repeating it")
812
1027
  * without wiring a full `SignalSource`. `urgency` maps to the kernel disposition ladder: `"normal"`
813
1028
  * queues for the next boundary (default), `"high"` soft-interrupts, `"critical"` preempts. */
@@ -830,18 +1045,63 @@ export class RuntimeRunner {
830
1045
  async nextInboundSignal() {
831
1046
  const injected = this.injectedSignals.shift();
832
1047
  if (injected)
833
- return injected;
1048
+ return {
1049
+ signalId: crypto.randomUUID(),
1050
+ deliveryId: `injected-${crypto.randomUUID()}`,
1051
+ deliveryAttempt: 1,
1052
+ signal: injected,
1053
+ ack: async () => true,
1054
+ nack: async () => true,
1055
+ };
834
1056
  if (!this.opts.signalSource)
835
1057
  return null;
836
- return this.opts.signalSource.nextSignal(this.currentSessionId ?? undefined);
1058
+ const source = this.opts.signalSource;
1059
+ const claim = await source.claimSignal(this.currentSessionId ?? undefined);
1060
+ if (!claim)
1061
+ return null;
1062
+ const receipt = {
1063
+ deliveryId: claim.deliveryId,
1064
+ leaseToken: claim.leaseToken,
1065
+ };
1066
+ return {
1067
+ signalId: claim.signalId,
1068
+ deliveryId: claim.deliveryId,
1069
+ deliveryAttempt: claim.deliveryAttempt,
1070
+ signal: claim.signal,
1071
+ ack: () => source.ackSignal(receipt),
1072
+ nack: () => source.nackSignal(receipt),
1073
+ };
1074
+ }
1075
+ async consumeInboundSignal(delivery, consume) {
1076
+ try {
1077
+ const observationStart = this.pendingObservations.length;
1078
+ const result = await consume(delivery);
1079
+ const dispositions = this.pendingObservations.slice(observationStart).filter(observation => observation.kind === "signal_delivery_disposed"
1080
+ && observation.delivery_id === delivery.deliveryId
1081
+ && observation.attempt === delivery.deliveryAttempt);
1082
+ if (dispositions.length !== 1) {
1083
+ throw new Error("kernel did not return the matching signal delivery disposition");
1084
+ }
1085
+ if (!await delivery.ack())
1086
+ throw new Error("signal lease was lost before acknowledgement");
1087
+ return result;
1088
+ }
1089
+ catch (cause) {
1090
+ await delivery.nack();
1091
+ throw cause;
1092
+ }
837
1093
  }
838
1094
  async *run(req) {
839
1095
  const prior = req.inheritEvents ?? await this.opts.sessionLog.read(req.sessionId);
840
1096
  const midRun = isMidRun(prior);
1097
+ const resumedStart = [...prior].reverse().find(entry => entry.event.kind === "run_started");
1098
+ const runId = midRun && resumedStart?.event.kind === "run_started"
1099
+ ? resumedStart.event.run_id
1100
+ : crypto.randomUUID();
841
1101
  if (!midRun) {
842
1102
  await this.opts.sessionLog.append(req.sessionId, {
843
1103
  kind: "run_started",
844
- run_id: crypto.randomUUID(),
1104
+ run_id: runId,
845
1105
  goal: req.goal,
846
1106
  criteria: req.criteria ?? [],
847
1107
  agent_id: this.opts.agentId,
@@ -849,7 +1109,7 @@ export class RuntimeRunner {
849
1109
  ...(req.attachments?.length ? { attachments: req.attachments } : {}),
850
1110
  });
851
1111
  }
852
- yield* this.execute(req.sessionId, req.goal, req.criteria ?? [], req.extensions, prior.length > 0 ? prior : undefined, midRun, req.attachments);
1112
+ yield* this.execute(req.sessionId, req.goal, req.criteria ?? [], req.extensions, prior.length > 0 ? prior : undefined, midRun, req.attachments, runId);
853
1113
  }
854
1114
  async *wake(sessionId, extensions) {
855
1115
  const events = await this.opts.sessionLog.read(sessionId);
@@ -859,101 +1119,28 @@ export class RuntimeRunner {
859
1119
  if (!startEntry)
860
1120
  throw new Error(`No run_started event for session: ${sessionId}`);
861
1121
  const start = startEntry.event;
862
- yield* this.execute(sessionId, start.goal, start.criteria, extensions, events, true, start.attachments);
1122
+ yield* this.execute(sessionId, start.goal, start.criteria, extensions, events, true, start.attachments, start.run_id);
863
1123
  }
864
- async *dream(agentId, nowMs = Date.now()) {
865
- if (!this.opts.dreamStore)
866
- throw new Error("dreamStore not configured");
867
- const kernel = getKernel();
868
- const sessions = await this.opts.dreamStore.loadSessions(agentId);
869
- const existingMemories = await this.opts.dreamStore.loadMemories(agentId);
870
- if (!sessions.length) {
871
- yield { type: "done", iterations: 0, totalTokens: 0, status: "completed", dreamResult: { sessionsProcessed: 0, insightsExtracted: 0, entriesAdded: 0, entriesRemoved: 0 } };
872
- return;
873
- }
874
- const pipeline = new kernel.IdlePipeline(agentId);
875
- const action1 = pipeline.feedTrigger(sessions.map(s => ({
876
- sessionId: s.sessionId, agentId: s.agentId,
877
- messages: s.messages.map(m => ({
878
- role: m.role, content: m.content, tokenCount: m.tokenCount,
879
- toolCalls: (m.toolCalls ?? []).map(tc => ({ id: tc.id, name: tc.name, arguments: tc.arguments })),
880
- })),
881
- metadata: JSON.stringify(s.metadata ?? null),
882
- createdAtMs: s.createdAtMs, updatedAtMs: s.updatedAtMs,
883
- })), existingMemories.map(e => ({ text: e.text, score: e.score, metadata: JSON.stringify(e.metadata ?? null) })), nowMs);
884
- if (action1.kind === "noop" || action1.kind === "aborted") {
885
- yield { type: "done", iterations: 0, totalTokens: 0, status: "completed", dreamResult: { sessionsProcessed: 0, insightsExtracted: 0, entriesAdded: 0, entriesRemoved: 0 } };
886
- return;
887
- }
888
- if (action1.kind !== "synthesize_insights")
889
- throw new Error(`unexpected: ${action1.kind}`);
890
- let synthesisText = "";
891
- const dreamProvider = this.opts.dreamProvider ?? this.opts.provider;
892
- const providerState = dreamProvider.createRunState?.();
893
- const synthMsgs = (action1.messages ?? []);
894
- const kernelSystemText = synthMsgs.filter(m => m.role === "system").map(m => m.content).join("\n\n");
895
- const synthContext = {
896
- systemText: [kernelSystemText, this.opts.dreamSystemPrompt].filter(Boolean).join("\n\n"),
897
- turns: synthMsgs.filter(m => m.role !== "system"),
898
- };
899
- let totalTokens = 0;
900
- for await (const evt of dreamProvider.stream(synthContext, [], undefined, providerState)) {
901
- if (evt.type === "text_delta") {
902
- synthesisText += evt.delta;
903
- yield evt;
904
- }
905
- else if (evt.type === "usage")
906
- totalTokens = evt.totalTokens;
907
- }
908
- const action2 = pipeline.feedSynthesisResult(synthesisText);
909
- if (action2.kind !== "commit_memories")
910
- throw new Error(`unexpected: ${action2.kind}`);
911
- const cr = action2.curationResult;
912
- const rr = action2.runResult;
913
- const dsResult = {
914
- toAdd: (cr.toAdd ?? []).map((e) => ({
915
- text: e.text, score: e.score, metadata: tryParseJson(e.metadata),
916
- })),
917
- toRemoveIndices: (cr.toRemoveIndices ?? []).map(Number),
918
- stats: {
919
- insightsProcessed: cr.stats?.insightsProcessed ?? 0,
920
- duplicatesRemoved: cr.stats?.duplicatesRemoved ?? 0,
921
- conflictsResolved: cr.stats?.conflictsResolved ?? 0,
922
- entriesAdded: cr.stats?.entriesAdded ?? 0,
923
- },
924
- };
925
- await this.opts.dreamStore.commit(agentId, dsResult, existingMemories);
926
- yield {
927
- type: "done", iterations: 1, totalTokens, status: "completed",
928
- dreamResult: {
929
- sessionsProcessed: rr.sessionsProcessed,
930
- insightsExtracted: rr.insightsExtracted,
931
- entriesAdded: cr.stats?.entriesAdded ?? 0,
932
- entriesRemoved: (cr.toRemoveIndices ?? []).length,
933
- },
934
- };
935
- }
936
- /** Resolve in-kernel AskUser suspend; returns resume lists and stream events to yield. */
937
- async resolveKernelSuspend(runtime, sessionId) {
938
- const gated = this.pendingObservations.filter((o) => o.kind === "tool_gated" && typeof o.call_id === "string" && typeof o.tool === "string");
1124
+ /** Execute a kernel-owned approval effect and return the correlated decision lists. */
1125
+ async resolveApprovalRequests(requests, runtime, sessionId) {
939
1126
  const approved = [];
940
1127
  const denied = [];
941
1128
  const events = [];
942
1129
  const runCtx = { onPermissionRequest: this.opts.onPermissionRequest };
943
- for (const g of gated) {
1130
+ for (const approval of requests) {
944
1131
  const request = {
945
1132
  type: "permission_request",
946
- callId: g.call_id,
947
- toolName: g.tool,
948
- arguments: "{}",
949
- reason: typeof g.reason === "string" ? g.reason : "",
1133
+ callId: approval.callId,
1134
+ toolName: approval.tool,
1135
+ arguments: approval.arguments,
1136
+ reason: approval.reason,
950
1137
  };
951
1138
  events.push(request);
952
1139
  const decision = await resolvePermissionRequest(request, runCtx);
953
1140
  events.push({
954
1141
  type: "permission_resolved",
955
- callId: g.call_id,
956
- toolName: g.tool,
1142
+ callId: approval.callId,
1143
+ toolName: approval.tool,
957
1144
  approved: decision.approved,
958
1145
  responder: decision.responder ?? "host",
959
1146
  ...(decision.reason ? { reason: decision.reason } : {}),
@@ -961,8 +1148,8 @@ export class RuntimeRunner {
961
1148
  await this.opts.sessionLog.append(sessionId, {
962
1149
  kind: "permission_requested",
963
1150
  turn: runtime.turn(),
964
- tool: g.tool,
965
- arguments: "{}",
1151
+ tool: approval.tool,
1152
+ arguments: approval.arguments,
966
1153
  reason: request.reason,
967
1154
  });
968
1155
  await this.opts.sessionLog.append(sessionId, {
@@ -972,21 +1159,21 @@ export class RuntimeRunner {
972
1159
  responder: decision.responder ?? "host",
973
1160
  });
974
1161
  if (decision.approved) {
975
- approved.push(g.call_id);
1162
+ approved.push(approval.callId);
976
1163
  }
977
1164
  else {
978
- denied.push(g.call_id);
1165
+ denied.push(approval.callId);
979
1166
  const denyReason = decision.reason ?? "permission denied";
980
1167
  events.push({
981
1168
  type: "tool_denied",
982
- callId: g.call_id,
983
- toolName: g.tool,
1169
+ callId: approval.callId,
1170
+ toolName: approval.tool,
984
1171
  reason: denyReason,
985
1172
  });
986
1173
  events.push({
987
1174
  type: "tool_result",
988
- callId: g.call_id,
989
- name: g.tool,
1175
+ callId: approval.callId,
1176
+ name: approval.tool,
990
1177
  content: `permission denied: ${denyReason}`,
991
1178
  isError: true,
992
1179
  errorKind: "governance_denied",
@@ -994,15 +1181,15 @@ export class RuntimeRunner {
994
1181
  await this.opts.sessionLog.append(sessionId, {
995
1182
  kind: "tool_denied",
996
1183
  turn: runtime.turn(),
997
- call_id: g.call_id,
998
- tool_name: g.tool,
1184
+ call_id: approval.callId,
1185
+ tool_name: approval.tool,
999
1186
  reason: denyReason,
1000
1187
  });
1001
1188
  await this.opts.sessionLog.append(sessionId, {
1002
1189
  kind: "tool_completed",
1003
1190
  turn: runtime.turn(),
1004
1191
  results: [{
1005
- call_id: g.call_id,
1192
+ call_id: approval.callId,
1006
1193
  output: `permission denied: ${denyReason}`,
1007
1194
  is_error: true,
1008
1195
  error_kind: "governance_denied",
@@ -1014,10 +1201,9 @@ export class RuntimeRunner {
1014
1201
  }
1015
1202
  /**
1016
1203
  * O7: resolve a `read_result` meta-tool call to the full text of a previously-evicted tool
1017
- * output. Resolution order: (a) this turn's in-memory `pendingSpoolOutputs` map (a call spooled
1018
- * earlier in the SAME tool-turn, before the session-log write lands), (b) the on-disk result
1019
- * spool (persisted once the kernel observation `large_result_spooled` was processed), (c) a
1020
- * session-log scan for the original `tool_completed` event carrying that `call_id`. Slices the
1204
+ * output. Resolution order: (a) the on-disk result spool committed by the explicit
1205
+ * `spool_large_result` host effect, then (b) a session-log scan for the original
1206
+ * `tool_completed` event carrying that `call_id`. Slices the
1021
1207
  * resolved text by `[offset, offset + maxBytes)` (plain string slice — "bytes-ish").
1022
1208
  */
1023
1209
  async resolveReadResult(sessionId, argsJson) {
@@ -1035,15 +1221,13 @@ export class RuntimeRunner {
1035
1221
  catch {
1036
1222
  // malformed arguments — callId stays empty, falls through to "not found" below
1037
1223
  }
1038
- let full = this.pendingSpoolOutputs.get(callId)?.output;
1039
- if (full === undefined) {
1040
- const spool = this.opts.resultSpool ?? new LargeResultSpool();
1041
- try {
1042
- full = await spool.findByCallId(callId);
1043
- }
1044
- catch {
1045
- full = undefined;
1046
- }
1224
+ let full;
1225
+ const spool = this.opts.resultSpool ?? new LargeResultSpool();
1226
+ try {
1227
+ full = await spool.findByCallId(callId);
1228
+ }
1229
+ catch {
1230
+ full = undefined;
1047
1231
  }
1048
1232
  if (full === undefined) {
1049
1233
  try {
@@ -1071,11 +1255,13 @@ export class RuntimeRunner {
1071
1255
  isError: false,
1072
1256
  };
1073
1257
  }
1074
- async *execute(sessionId, goal, criteria, extensions, priorEvents, resumeMidRun = false, attachments) {
1258
+ async *execute(sessionId, goal, criteria, extensions, priorEvents, resumeMidRun = false, attachments, runId = crypto.randomUUID()) {
1075
1259
  this.interrupted = false;
1260
+ this.cancellationReason = undefined;
1076
1261
  this.abortController = new AbortController();
1077
1262
  this.pendingObservations = [];
1078
- this.pendingSpoolOutputs.clear();
1263
+ this.pendingPageOutArchives = [];
1264
+ this.activePageOutArchive = undefined;
1079
1265
  this.currentSessionId = sessionId;
1080
1266
  if (this.opts.enableDiagnosticsDashboard) {
1081
1267
  this.dashboard = new KernelPrimitivesDashboard(sessionId);
@@ -1087,795 +1273,920 @@ export class RuntimeRunner {
1087
1273
  const providerPolicy = this.opts.provider.runtimePolicy?.() ?? {};
1088
1274
  const effectiveMaxTurns = this.opts.maxTurns ?? providerPolicy.maxTurns ?? 25;
1089
1275
  const effectiveTimeoutMs = this.opts.timeoutMs ?? providerPolicy.timeoutMs;
1090
- const runtime = new kernel.KernelRuntime({
1091
- maxTokens: this.opts.maxTokens,
1092
- maxTurns: effectiveMaxTurns,
1093
- timeoutMs: effectiveTimeoutMs !== undefined ? BigInt(effectiveTimeoutMs) : undefined,
1094
- maxTotalTokens: this.opts.maxTotalTokens !== undefined ? BigInt(this.opts.maxTotalTokens) : undefined,
1095
- });
1096
- this.activeKernel = runtime;
1097
- this.nextArchiveStart = nextCompressedArchiveStart;
1098
- if (this.opts.tokenizer) {
1099
- kernelApply(runtime, this.pendingObservations, {
1100
- kind: "set_tokenizer",
1101
- name: this.opts.tokenizer,
1102
- });
1103
- }
1104
- if (this.opts.enablePlanTool !== undefined) {
1105
- kernelApply(runtime, this.pendingObservations, {
1106
- kind: "set_plan_tool_enabled",
1107
- enabled: this.opts.enablePlanTool,
1108
- });
1109
- }
1110
- kernelApply(runtime, this.pendingObservations, {
1111
- kind: "set_tools",
1112
- tools: this.opts.executionPlane.schemas().map(toolSchemaToKernel),
1113
- });
1114
- if (this.opts.systemPrompt) {
1115
- kernelApply(runtime, this.pendingObservations, {
1116
- kind: "add_system_message",
1117
- content: this.opts.systemPrompt,
1118
- tokens: Math.max(1, Math.ceil(this.opts.systemPrompt.length / 4)),
1276
+ const operation = {
1277
+ runId,
1278
+ sessionId,
1279
+ agentId: this.opts.agentId,
1280
+ signal: this.abortController.signal,
1281
+ ...(effectiveTimeoutMs !== undefined ? { deadlineMs: Date.now() + effectiveTimeoutMs } : {}),
1282
+ };
1283
+ const taskScope = new ManagedTaskScope(operation, this.opts.onBackgroundTaskError);
1284
+ let groupBudgetScope;
1285
+ try {
1286
+ const runtime = new kernel.KernelRuntime({
1287
+ maxTokens: this.opts.maxTokens,
1288
+ maxTurns: effectiveMaxTurns,
1289
+ timeoutMs: effectiveTimeoutMs !== undefined ? BigInt(effectiveTimeoutMs) : undefined,
1290
+ maxTotalTokens: this.opts.maxTotalTokens !== undefined ? BigInt(this.opts.maxTotalTokens) : undefined,
1119
1291
  });
1120
- }
1121
- if (this.opts.initialMemory) {
1122
- for (const mem of this.opts.initialMemory) {
1123
- kernelApply(runtime, this.pendingObservations, {
1124
- kind: "add_knowledge_message",
1125
- content: mem,
1126
- tokens: Math.max(1, Math.ceil(mem.length / 4)),
1292
+ this.activeKernel = runtime;
1293
+ this.nextArchiveStart = nextCompressedArchiveStart;
1294
+ if (this.opts.tokenizer) {
1295
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1296
+ kind: "set_tokenizer",
1297
+ name: this.opts.tokenizer,
1127
1298
  });
1128
1299
  }
1129
- }
1130
- if (this.opts.skillDir) {
1131
- const { scanSkillDir } = await import("../skills/loader.js");
1132
- const metas = await scanSkillDir(this.opts.skillDir);
1133
- // P1-B: pass the full SkillMetadata (incl. `allowedTools`) straight through — re-mapping it
1134
- // field-by-field previously dropped `allowedTools`.
1135
- kernelApply(runtime, this.pendingObservations, {
1136
- kind: "set_available_skills",
1137
- skills: metas.map(m => skillMetadataToKernel(m)),
1138
- });
1139
- }
1140
- // P1-B/D: configure the stable-core tool ids (always exposed under skill gating). Empty/absent
1141
- // ⇒ skills narrow to exactly their declared tools + meta-tools.
1142
- if (this.opts.stableCoreToolIds?.length) {
1143
- kernelApply(runtime, this.pendingObservations, {
1144
- kind: "set_stable_core_tools",
1145
- tool_ids: this.opts.stableCoreToolIds,
1146
- });
1147
- }
1148
- if (this.opts.dreamStore && this.opts.agentId) {
1149
- kernelApply(runtime, this.pendingObservations, { kind: "set_memory_enabled", enabled: true });
1150
- }
1151
- // Install optional memory policy. Maps the ergonomic camelCase option onto the kernel's
1152
- // snake_case `set_memory_policy` event; omitted fields fall back to kernel defaults.
1153
- if (this.opts.memoryPolicy) {
1154
- const m = this.opts.memoryPolicy;
1155
- kernelApply(runtime, this.pendingObservations, {
1156
- kind: "set_memory_policy",
1157
- ...(m.memoryPath !== undefined ? { memory_path: m.memoryPath } : {}),
1158
- ...(m.staleWarningDays !== undefined ? { stale_warning_days: m.staleWarningDays } : {}),
1159
- ...(m.retrievalTopK !== undefined ? { retrieval_top_k: m.retrievalTopK } : {}),
1160
- ...(m.validationEnabled !== undefined ? { validation_enabled: m.validationEnabled } : {}),
1161
- ...(m.maxContentBytes !== undefined ? { max_content_bytes: m.maxContentBytes } : {}),
1162
- ...(m.maxNameLength !== undefined ? { max_name_length: m.maxNameLength } : {}),
1163
- });
1164
- }
1165
- if (this.opts.knowledgeSource) {
1166
- kernelApply(runtime, this.pendingObservations, { kind: "set_knowledge_enabled", enabled: true });
1167
- }
1168
- if (this.opts.milestoneContract) {
1169
- kernelApply(runtime, this.pendingObservations, {
1170
- kind: "load_milestone_contract",
1171
- contract: {
1172
- phases: this.opts.milestoneContract.phases.map(p => ({
1173
- id: p.id,
1174
- criteria: p.criteria ?? [],
1175
- unlocks: p.unlocks ?? [],
1176
- required_evidence: p.requiredEvidence ?? [],
1177
- ...(p.verifier ? { verifier: p.verifier } : {}),
1178
- })),
1179
- },
1180
- });
1181
- }
1182
- const maxBytes = runtime.recoveryContentBytes();
1183
- if (priorEvents && priorEvents.length > 0) {
1184
- const repaired = repairEventsForRecovery(priorEvents, maxBytes);
1185
- seedProviderReplayFromEvents(this.opts.provider, repaired);
1186
- const loadArchive = this.opts.compressionStore
1187
- ? (ref) => this.opts.compressionStore.read(ref)
1188
- : undefined;
1189
- const replayed = await replayMessagesAsync(repaired, maxBytes, loadArchive);
1190
- kernelApply(runtime, this.pendingObservations, {
1191
- kind: "preload_history",
1192
- messages: replayed.map(messageToKernelMessage),
1300
+ if (this.opts.enablePlanTool !== undefined) {
1301
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1302
+ kind: "set_plan_tool_enabled",
1303
+ enabled: this.opts.enablePlanTool,
1304
+ });
1305
+ }
1306
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1307
+ kind: "set_tools",
1308
+ tools: this.opts.executionPlane.schemas().map(toolSchemaToKernel),
1193
1309
  });
1194
- // P1-B B3: rebuild active-skill gating after a wake by re-emitting SkillActivated for each
1195
- // `skill` tool call in the replayed history (active_skills is not snapshotted — graceful).
1196
- // The catalog (set_available_skills) was already fed above, so allowed_tools resolves.
1197
- // `knowledge` isn't snapshotted either (same graceful-reset philosophy) — best-effort re-push
1198
- // the skill's content from its replayed tool_result so the durable copy survives a wake too.
1199
- const toolResultByCallId = new Map();
1200
- for (const m of replayed) {
1201
- for (const part of m.contentParts ?? []) {
1202
- if (part.type === "tool_result")
1203
- toolResultByCallId.set(part.callId, part.output);
1310
+ if (this.opts.systemPrompt) {
1311
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1312
+ kind: "add_system_message",
1313
+ content: this.opts.systemPrompt,
1314
+ tokens: Math.max(1, Math.ceil(this.opts.systemPrompt.length / 4)),
1315
+ });
1316
+ }
1317
+ if (this.opts.initialMemory) {
1318
+ for (const mem of this.opts.initialMemory) {
1319
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1320
+ kind: "add_knowledge_message",
1321
+ content: mem,
1322
+ tokens: Math.max(1, Math.ceil(mem.length / 4)),
1323
+ });
1204
1324
  }
1205
1325
  }
1206
- for (const m of replayed) {
1207
- for (const tc of m.toolCalls ?? []) {
1208
- if (tc.name !== "skill")
1209
- continue;
1210
- try {
1211
- const name = JSON.parse(tc.arguments || "{}").name;
1212
- if (!name)
1326
+ if (this.opts.skillDir) {
1327
+ const { scanSkillDir } = await import("../skills/loader.js");
1328
+ const metas = await scanSkillDir(this.opts.skillDir);
1329
+ // P1-B: pass the full SkillMetadata (incl. `allowedTools`) straight through — re-mapping it
1330
+ // field-by-field previously dropped `allowedTools`.
1331
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1332
+ kind: "set_available_skills",
1333
+ skills: metas.map(m => skillMetadataToKernel(m)),
1334
+ });
1335
+ }
1336
+ // P1-B/D: configure the stable-core tool ids (always exposed under skill gating). Empty/absent
1337
+ // ⇒ skills narrow to exactly their declared tools + meta-tools.
1338
+ if (this.opts.stableCoreToolIds?.length) {
1339
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1340
+ kind: "set_stable_core_tools",
1341
+ tool_ids: this.opts.stableCoreToolIds,
1342
+ });
1343
+ }
1344
+ if (this.opts.dreamStore && this.opts.agentId) {
1345
+ await this.commitKernelApply(runtime, this.pendingObservations, { kind: "set_memory_enabled", enabled: true });
1346
+ }
1347
+ // Install optional memory policy. Maps the ergonomic camelCase option onto the kernel's
1348
+ // snake_case `set_memory_policy` event; omitted fields fall back to kernel defaults.
1349
+ if (this.opts.memoryPolicy) {
1350
+ const m = this.opts.memoryPolicy;
1351
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1352
+ kind: "set_memory_policy",
1353
+ ...(m.memoryPath !== undefined ? { memory_path: m.memoryPath } : {}),
1354
+ ...(m.staleWarningDays !== undefined ? { stale_warning_days: m.staleWarningDays } : {}),
1355
+ ...(m.retrievalTopK !== undefined ? { retrieval_top_k: m.retrievalTopK } : {}),
1356
+ ...(m.validationEnabled !== undefined ? { validation_enabled: m.validationEnabled } : {}),
1357
+ ...(m.maxContentBytes !== undefined ? { max_content_bytes: m.maxContentBytes } : {}),
1358
+ ...(m.maxNameLength !== undefined ? { max_name_length: m.maxNameLength } : {}),
1359
+ });
1360
+ }
1361
+ if (this.opts.knowledgeSource) {
1362
+ await this.commitKernelApply(runtime, this.pendingObservations, { kind: "set_knowledge_enabled", enabled: true });
1363
+ }
1364
+ if (this.opts.milestoneContract) {
1365
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1366
+ kind: "load_milestone_contract",
1367
+ contract: {
1368
+ phases: this.opts.milestoneContract.phases.map(p => ({
1369
+ id: p.id,
1370
+ criteria: p.criteria ?? [],
1371
+ unlocks: p.unlocks ?? [],
1372
+ required_evidence: p.requiredEvidence ?? [],
1373
+ ...(p.verifier ? { verifier: p.verifier } : {}),
1374
+ })),
1375
+ },
1376
+ });
1377
+ }
1378
+ const maxBytes = runtime.recoveryContentBytes();
1379
+ if (priorEvents && priorEvents.length > 0) {
1380
+ const repaired = repairEventsForRecovery(priorEvents, maxBytes);
1381
+ seedProviderReplayFromEvents(this.opts.provider, repaired);
1382
+ const loadArchive = this.opts.compressionStore
1383
+ ? (ref) => this.opts.compressionStore.read(ref)
1384
+ : undefined;
1385
+ const replayed = await replayMessagesAsync(repaired, maxBytes, loadArchive);
1386
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1387
+ kind: "preload_history",
1388
+ messages: replayed.map(messageToKernelMessage),
1389
+ });
1390
+ // P1-B B3: rebuild active-skill gating after a wake by re-emitting SkillActivated for each
1391
+ // `skill` tool call in the replayed history (active_skills is not snapshotted — graceful).
1392
+ // The catalog (set_available_skills) was already fed above, so allowed_tools resolves.
1393
+ // `knowledge` isn't snapshotted either (same graceful-reset philosophy) — best-effort re-push
1394
+ // the skill's content from its replayed tool_result so the durable copy survives a wake too.
1395
+ const toolResultByCallId = new Map();
1396
+ for (const m of replayed) {
1397
+ for (const part of m.contentParts ?? []) {
1398
+ if (part.type === "tool_result")
1399
+ toolResultByCallId.set(part.callId, part.output);
1400
+ }
1401
+ }
1402
+ for (const m of replayed) {
1403
+ for (const tc of m.toolCalls ?? []) {
1404
+ if (tc.name !== "skill")
1213
1405
  continue;
1214
- kernelApply(runtime, this.pendingObservations, {
1215
- kind: "skill_activated",
1216
- name,
1217
- ...(this.opts.skillLeaseTurns !== undefined ? { lease_turns: this.opts.skillLeaseTurns } : {}),
1218
- });
1219
- const output = toolResultByCallId.get(tc.id);
1220
- if (output && !this.knowledgePushedSkills.has(name)) {
1221
- this.knowledgePushedSkills.add(name);
1222
- // K1: keyed — the kernel-side upsert is the authoritative dedup, so a wake re-push
1223
- // of a skill already pinned live can never double-pin (the in-run Set resets with
1224
- // each runner instance; the key does not).
1225
- this.pushKnowledge({ role: "system", content: output, toolCalls: [] }, undefined, { key: `skill:${name}` });
1406
+ try {
1407
+ const name = JSON.parse(tc.arguments || "{}").name;
1408
+ if (!name)
1409
+ continue;
1410
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1411
+ kind: "skill_activated",
1412
+ name,
1413
+ ...(this.opts.skillLeaseTurns !== undefined ? { lease_turns: this.opts.skillLeaseTurns } : {}),
1414
+ });
1415
+ const output = toolResultByCallId.get(tc.id);
1416
+ if (output && !this.knowledgePushedSkills.has(name)) {
1417
+ this.knowledgePushedSkills.add(name);
1418
+ // K1: keyed — the kernel-side upsert is the authoritative dedup, so a wake re-push
1419
+ // of a skill already pinned live can never double-pin (the in-run Set resets with
1420
+ // each runner instance; the key does not).
1421
+ await this.pushKnowledge({ role: "system", content: output, toolCalls: [] }, undefined, { key: `skill:${name}` });
1422
+ }
1226
1423
  }
1424
+ catch { /* malformed skill args — skip */ }
1227
1425
  }
1228
- catch { /* malformed skill args — skip */ }
1229
1426
  }
1230
1427
  }
1231
- }
1232
- const sessionStart = Date.now();
1233
- const startPayload = {
1234
- kind: "start_run",
1235
- task: { goal, criteria },
1236
- };
1237
- // P0-A: lower an explicit `runSpec` and/or the `allowedToolIds` profile to the kernel's
1238
- // `capability_filter`. `allowedToolIds` augments an explicit spec's filter, else synthesizes
1239
- // a minimal top-level spec carrying just the filter (reuses the existing run_spec wire — no
1240
- // new ABI). Unset on both ⇒ no run_spec ⇒ no gating (铁律: no config = old behavior).
1241
- const allowedToolIds = this.opts.allowedToolIds;
1242
- const hasProfile = allowedToolIds !== undefined && allowedToolIds.length > 0;
1243
- if (this.opts.runSpec || hasProfile) {
1244
- const baseSpec = this.opts.runSpec ?? {
1245
- identity: { agentId: this.opts.agentId ?? "root", sessionId, isSubAgent: false },
1246
- role: "custom",
1247
- goal,
1428
+ const sessionStart = Date.now();
1429
+ const startPayload = {
1430
+ kind: "start_run",
1431
+ task: { goal, criteria },
1248
1432
  };
1249
- const spec = hasProfile
1250
- ? { ...baseSpec, capabilityFilter: { ...baseSpec.capabilityFilter, allowedIds: allowedToolIds } }
1251
- : baseSpec;
1252
- startPayload.run_spec = agentRunSpecToKernel(spec);
1253
- }
1254
- // L1: seed the kernel with the group's cumulative spend so the run-level token cap + cumulative
1255
- // spawn cap span the whole governance domain (other members' prior spend). No group ⇒ per-run.
1256
- // Also register this session as a member so the run's lineage (R2) spans personas/invocations.
1257
- let groupLedger;
1258
- if (this.opts.runGroup) {
1259
- const g = this.opts.runGroup;
1260
- groupLedger = await g.budgetStore.read(g.id);
1261
- await g.budgetStore.join(g.id, { sessionId, role: this.opts.agentId, kind: "vehicle" });
1262
- }
1263
- this.applyKernelPolicies(runtime, groupLedger?.tokensSpent, groupLedger?.subagentsSpawned, groupLedger?.roundsCompleted);
1264
- // Multimodal upload: seed the user's attachments (images/audio) as a history
1265
- // message before start_run pushes the "[TASK STATE]" anchor. init_task does not
1266
- // clear history, so order becomes [attachment user msg, "Proceed…"] both land
1267
- // in the first render. On resume the message is already in the replayed history.
1268
- if (!resumeMidRun && attachments?.length) {
1269
- kernelApply(runtime, this.pendingObservations, {
1270
- kind: "add_history_message",
1271
- message: attachmentsToKernelMessage(attachments),
1272
- });
1273
- }
1274
- // I4: pre-fetch memory before the first LLM turn so the model sees it on turn 1 instead of
1275
- // discovering it via the `memory` tool on turn 3+. Skipped on resumes (already in prior
1276
- // context) and when dreamStore/agentId is absent.
1277
- //
1278
- // Strict dynamic context control: this is single-use retrieval content (facts relevant to
1279
- // THIS run's goal right now), not a stable method/skill — so it lands in `history` as an
1280
- // ordinary turn, exactly like a real `memory` tool result would, and decays with the
1281
- // compression pyramid over subsequent turns instead of pinning itself in `knowledge` forever.
1282
- this.currentGoal = goal;
1283
- if (!resumeMidRun) {
1284
- await this.prefetchMemoryIntoHistory(runtime, "initial");
1285
- }
1286
- let action = resumeMidRun
1287
- ? kernelAction(runtime, this.pendingObservations, { kind: "resume" })
1288
- : kernelAction(runtime, this.pendingObservations, startPayload);
1289
- // P0-C: the skill loaded and in effect going into the current turn (updated when the model's
1290
- // `skill` tool call resolves). Drives the per-turn `activeSkill` metric dwell measurement.
1291
- let activeSkill;
1292
- // I0b: wrap the main loop so any uncaught kernel exception (typically a NAPI
1293
- // Status::InvalidArg from a malformed input — e.g. RuntimeSignal.source with a wrong shape,
1294
- // or an unrecognized event kind) is observable rather than silently propagating out of the
1295
- // async generator. Without this wrap the runner emits no `run_terminal` event, so downstream
1296
- // observability (session log, bench mechanism hooks) can't distinguish "the kernel rejected
1297
- // an input" from "the run is still in progress."
1298
- try {
1299
- while (!runtime.isTerminal()) {
1300
- nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart);
1301
- this.nextArchiveStart = nextCompressedArchiveStart;
1302
- if (this.interrupted) {
1303
- action = kernelAction(runtime, this.pendingObservations, { kind: "timeout" });
1304
- break;
1305
- }
1306
- if (this.opts.signalSource || this.injectedSignals.length > 0) {
1307
- const sig = await this.nextInboundSignal();
1308
- if (sig) {
1309
- // Kernel-routed: the kernel decides disposition (dedup/queue/interrupt) and emits
1310
- // `signal_disposed`. An actionable disposition yields a new action to adopt; queued/observed/
1311
- // ignored yields none (kernel buffers).
1312
- const sigAction = kernelMaybeAction(runtime, this.pendingObservations, signalToKernelEvent(sig));
1313
- if (sigAction)
1314
- action = sigAction;
1315
- // I0a: a Critical-urgency signal carries user_abort intent. The kernel disposes it as
1316
- // InterruptNow (forces a Reason turn) but does NOT call abortController.abort() unless
1317
- // sub-agents are suspended — so the no-sub-agent path (e.g. the signal-injection bench
1318
- // scenario) wouldn't otherwise set `this.interrupted`, and the eventual run_terminal would
1319
- // report `reason: "error"` indistinguishable from a crash. Mark it here so the final
1320
- // classification in the run_terminal emit picks `user_abort`.
1321
- if (sig.urgency === "critical")
1322
- this.interrupted = true;
1323
- }
1324
- }
1325
- if (runtime.isTerminal())
1326
- break;
1327
- if (action.kind === "call_provider") {
1328
- // M5 v2.1: top-level auto-pivot at the safe point. If the agent authored sub-workflow(s) via
1329
- // `start_workflow`, drive each in THIS kernel now (the kernel is in Reason / `call_provider`,
1330
- // NOT suspended — driving mid-suspend would clobber the single-slot suspend state), inject the
1331
- // outcome into context, and re-render. Loop-top placement (vs only after `tool_results`) catches
1332
- // EVERY path to `call_provider` — including resuming after an approval gate — so a queued spec
1333
- // is never stranded. Drains the queue; fires once per authored batch.
1334
- if (this.pendingAuthoredWorkflows.length > 0) {
1335
- action = await this.driveAuthoredWorkflows(runtime, action);
1433
+ // P0-A: lower an explicit `runSpec` and/or the `allowedToolIds` profile to the kernel's
1434
+ // `capability_filter`. `allowedToolIds` augments an explicit spec's filter, else synthesizes
1435
+ // a minimal top-level spec carrying just the filter (reuses the existing run_spec wire — no
1436
+ // new ABI). Unset on both ⇒ no run_spec ⇒ no gating (铁律: no config = old behavior).
1437
+ const allowedToolIds = this.opts.allowedToolIds;
1438
+ const hasProfile = allowedToolIds !== undefined && allowedToolIds.length > 0;
1439
+ if (this.opts.runSpec || hasProfile) {
1440
+ const baseSpec = this.opts.runSpec ?? {
1441
+ identity: { agentId: this.opts.agentId ?? "root", sessionId, isSubAgent: false },
1442
+ role: "custom",
1443
+ goal,
1444
+ };
1445
+ const spec = hasProfile
1446
+ ? { ...baseSpec, capabilityFilter: { ...baseSpec.capabilityFilter, allowedIds: allowedToolIds } }
1447
+ : baseSpec;
1448
+ startPayload.run_spec = agentRunSpecToKernel(spec);
1449
+ }
1450
+ // Reserve capacity before start_run. The kernel enforces only this vehicle's grant and reports
1451
+ // exact terminal usage against the same opaque reservation identity.
1452
+ if (this.opts.runGroup) {
1453
+ const g = this.opts.runGroup;
1454
+ groupBudgetScope = await GroupBudgetScope.open(g, { sessionId, role: this.opts.agentId, kind: "vehicle" }, this.groupBudgetRequest());
1455
+ this.activeGroupBudgetScope = groupBudgetScope;
1456
+ }
1457
+ await this.applyKernelPolicies(runtime, groupBudgetScope);
1458
+ // Multimodal upload: seed the user's attachments (images/audio) as a history
1459
+ // message before start_run pushes the "[TASK STATE]" anchor. init_task does not
1460
+ // clear history, so order becomes [attachment user msg, "Proceed…"] — both land
1461
+ // in the first render. On resume the message is already in the replayed history.
1462
+ if (!resumeMidRun && attachments?.length) {
1463
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1464
+ kind: "add_history_message",
1465
+ message: attachmentsToKernelMessage(attachments),
1466
+ });
1467
+ }
1468
+ // I4: pre-fetch memory before the first LLM turn so the model sees it on turn 1 instead of
1469
+ // discovering it via the `memory` tool on turn 3+. Skipped on resumes (already in prior
1470
+ // context) and when dreamStore/agentId is absent.
1471
+ //
1472
+ // Strict dynamic context control: this is single-use retrieval content (facts relevant to
1473
+ // THIS run's goal right now), not a stable method/skill so it lands in `history` as an
1474
+ // ordinary turn, exactly like a real `memory` tool result would, and decays with the
1475
+ // compression pyramid over subsequent turns instead of pinning itself in `knowledge` forever.
1476
+ this.currentGoal = goal;
1477
+ if (!resumeMidRun) {
1478
+ await this.prefetchMemoryIntoHistory(runtime, "initial");
1479
+ }
1480
+ let action = resumeMidRun
1481
+ ? await this.commitKernelAction(runtime, this.pendingObservations, { kind: "resume" })
1482
+ : await this.commitKernelAction(runtime, this.pendingObservations, startPayload);
1483
+ // P0-C: the skill loaded and in effect going into the current turn (updated when the model's
1484
+ // `skill` tool call resolves). Drives the per-turn `activeSkill` metric → dwell measurement.
1485
+ let activeSkill;
1486
+ // I0b: wrap the main loop so any uncaught kernel exception (typically a NAPI
1487
+ // Status::InvalidArg from a malformed input — e.g. RuntimeSignal.source with a wrong shape,
1488
+ // or an unrecognized event kind) is observable rather than silently propagating out of the
1489
+ // async generator. Without this wrap the runner emits no `run_terminal` event, so downstream
1490
+ // observability (session log, bench mechanism hooks) can't distinguish "the kernel rejected
1491
+ // an input" from "the run is still in progress."
1492
+ try {
1493
+ while (!runtime.isTerminal()) {
1494
+ nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart, taskScope);
1495
+ this.nextArchiveStart = nextCompressedArchiveStart;
1496
+ if (this.interrupted) {
1497
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
1498
+ kind: "cancel_operation",
1499
+ reason: this.cancellationReason ?? "user",
1500
+ pending_call_ids: pendingCallIds(action),
1501
+ });
1502
+ break;
1336
1503
  }
1337
- const finalToolCalls = [];
1338
- let finalText = "";
1339
- // I5: governance schema-level pre-filter. When a declarative GovernancePolicy is loaded
1340
- // and `surfaceDeniedInSystem !== false`, drop denied tools from the schema BEFORE the
1341
- // model sees them the model can't plan a call it doesn't know about, so the rollback
1342
- // overhead disappears. The list of denied names is appended to systemKnowledge so the
1343
- // model knows not to plan around them.
1344
- let context = action.context;
1345
- let tools = action.tools;
1346
- if (this.opts.governancePolicy && this.opts.governancePolicy.surfaceDeniedInSystem !== false) {
1347
- const { allowed, denied } = governanceFilterSchema(tools, this.opts.governancePolicy);
1348
- if (denied.length > 0) {
1349
- tools = allowed;
1350
- const note = `[governance] the following tools are denied for this run and will fail if called: ${denied.join(", ")}.`;
1351
- context = {
1352
- ...context,
1353
- systemKnowledge: context.systemKnowledge
1354
- ? `${context.systemKnowledge}\n\n${note}`
1355
- : note,
1356
- };
1504
+ if (this.opts.signalSource || this.injectedSignals.length > 0) {
1505
+ const delivery = await this.nextInboundSignal();
1506
+ if (delivery) {
1507
+ // Kernel-routed: the kernel decides disposition (dedup/queue/interrupt) and emits
1508
+ // `signal_delivery_disposed`. An actionable disposition yields a new action to adopt; queued/observed/
1509
+ // ignored yields none (kernel buffers).
1510
+ const sigAction = await this.consumeInboundSignal(delivery, sig => this.commitKernelMaybeAction(runtime, this.pendingObservations, signalToKernelEvent(sig)));
1511
+ if (sigAction)
1512
+ action = sigAction;
1513
+ // A critical signal is a kernel attention/preemption decision, not operation cancellation.
1357
1514
  }
1358
1515
  }
1359
- let turnTokens = 0;
1360
- let turnInputTokens = 0;
1361
- let turnOutputTokens = 0;
1362
- let turnCacheReadTokens = 0;
1363
- let turnCacheCreationTokens = 0;
1364
- let turnCacheReadBySlot;
1365
- let turnStopReason;
1366
- const abortSignal = this.abortController?.signal;
1367
- try {
1368
- for await (const evt of this.opts.provider.stream(context, tools, Object.keys(ext).length ? ext : undefined, providerState, abortSignal)) {
1369
- // #2-B-ii: a preempting `interrupt()` fires `abortController` — stop consuming the live
1370
- // stream immediately (providers that forward `signal` also abort the socket; the rest at
1371
- // least stop here at the next event). The loop-top `interrupted` check then ends the run.
1372
- if (abortSignal?.aborted)
1373
- break;
1374
- if (evt.type === "usage") {
1375
- const usageEvt = evt;
1376
- turnTokens = usageEvt.totalTokens;
1377
- turnInputTokens = usageEvt.inputTokens ?? 0;
1378
- turnOutputTokens = usageEvt.outputTokens ?? 0;
1379
- // P0-C: capture the prompt-cache split for the tool-gating hit-rate baseline.
1380
- turnCacheReadTokens = usageEvt.cacheReadInputTokens ?? 0;
1381
- turnCacheCreationTokens = usageEvt.cacheCreationInputTokens ?? 0;
1382
- // I1: per-slot attribution forwarded into TurnMetrics. Undefined when the provider
1383
- // doesn't honor cache_control (OpenAI-family auto-cache).
1384
- turnCacheReadBySlot = usageEvt.cacheReadInputTokensBySlot;
1385
- // Phase 4: stop_reason drives the kernel's max-output-tokens recovery. The closing
1386
- // usage frame carries it; keep the last non-empty value seen this turn.
1387
- if (usageEvt.stopReason)
1388
- turnStopReason = usageEvt.stopReason;
1389
- continue;
1516
+ if (runtime.isTerminal())
1517
+ break;
1518
+ if (action.kind === "call_provider") {
1519
+ // M5 v2.1: top-level auto-pivot at the safe point. If the agent authored sub-workflow(s) via
1520
+ // `start_workflow`, drive each in THIS kernel now (the kernel is in Reason / `call_provider`,
1521
+ // NOT suspended — driving mid-suspend would clobber the single-slot suspend state), inject the
1522
+ // outcome into context, and re-render. Loop-top placement (vs only after `tool_results`) catches
1523
+ // EVERY path to `call_provider` — including resuming after an approval gate — so a queued spec
1524
+ // is never stranded. Drains the queue; fires once per authored batch.
1525
+ if (this.pendingAuthoredWorkflows.length > 0) {
1526
+ action = await this.driveAuthoredWorkflows(runtime, action);
1527
+ }
1528
+ const providerEffectId = action.effectId;
1529
+ const finalToolCalls = [];
1530
+ let finalText = "";
1531
+ // I5: governance schema-level pre-filter. When a declarative GovernancePolicy is loaded
1532
+ // and `surfaceDeniedInSystem !== false`, drop denied tools from the schema BEFORE the
1533
+ // model sees them — the model can't plan a call it doesn't know about, so the rollback
1534
+ // overhead disappears. The list of denied names is appended to systemKnowledge so the
1535
+ // model knows not to plan around them.
1536
+ let context = action.context;
1537
+ let tools = action.tools;
1538
+ if (this.opts.governancePolicy && this.opts.governancePolicy.surfaceDeniedInSystem !== false) {
1539
+ const { allowed, denied } = governanceFilterSchema(tools, this.opts.governancePolicy);
1540
+ if (denied.length > 0) {
1541
+ tools = allowed;
1542
+ const note = `[governance] the following tools are denied for this run and will fail if called: ${denied.join(", ")}.`;
1543
+ context = {
1544
+ ...context,
1545
+ systemKnowledge: context.systemKnowledge
1546
+ ? `${context.systemKnowledge}\n\n${note}`
1547
+ : note,
1548
+ };
1390
1549
  }
1391
- yield evt;
1392
- if (evt.type === "text_delta")
1393
- finalText += evt.delta;
1394
- else if (evt.type === "tool_call") {
1395
- const tc = evt;
1396
- finalToolCalls.push({ id: tc.id, name: tc.name, arguments: JSON.stringify(tc.arguments) });
1550
+ }
1551
+ let turnTokens = 0;
1552
+ let turnInputTokens = 0;
1553
+ let turnOutputTokens = 0;
1554
+ let turnCacheReadTokens = 0;
1555
+ let turnCacheCreationTokens = 0;
1556
+ let turnCacheReadBySlot;
1557
+ let turnStopReason;
1558
+ const abortSignal = this.abortController?.signal;
1559
+ try {
1560
+ for await (const evt of this.opts.provider.stream(context, tools, Object.keys(ext).length ? ext : undefined, providerState, abortSignal)) {
1561
+ // #2-B-ii: a preempting `interrupt()` fires `abortController` — stop consuming the live
1562
+ // stream immediately (providers that forward `signal` also abort the socket; the rest at
1563
+ // least stop here at the next event). The loop-top `interrupted` check then ends the run.
1564
+ if (abortSignal?.aborted)
1565
+ break;
1566
+ if (evt.type === "usage") {
1567
+ const usageEvt = evt;
1568
+ turnTokens = usageEvt.totalTokens;
1569
+ turnInputTokens = usageEvt.inputTokens ?? 0;
1570
+ turnOutputTokens = usageEvt.outputTokens ?? 0;
1571
+ // P0-C: capture the prompt-cache split for the tool-gating hit-rate baseline.
1572
+ turnCacheReadTokens = usageEvt.cacheReadInputTokens ?? 0;
1573
+ turnCacheCreationTokens = usageEvt.cacheCreationInputTokens ?? 0;
1574
+ // I1: per-slot attribution forwarded into TurnMetrics. Undefined when the provider
1575
+ // doesn't honor cache_control (OpenAI-family auto-cache).
1576
+ turnCacheReadBySlot = usageEvt.cacheReadInputTokensBySlot;
1577
+ // Phase 4: stop_reason drives the kernel's max-output-tokens recovery. The closing
1578
+ // usage frame carries it; keep the last non-empty value seen this turn.
1579
+ if (usageEvt.stopReason)
1580
+ turnStopReason = usageEvt.stopReason;
1581
+ continue;
1582
+ }
1583
+ yield evt;
1584
+ if (evt.type === "text_delta")
1585
+ finalText += evt.delta;
1586
+ else if (evt.type === "tool_call") {
1587
+ const tc = evt;
1588
+ finalToolCalls.push({ id: tc.id, name: tc.name, arguments: JSON.stringify(tc.arguments) });
1589
+ }
1397
1590
  }
1398
1591
  }
1399
- }
1400
- catch (err) {
1401
- if (abortSignal?.aborted) {
1402
- // #2-B-ii: an aborted in-flight request surfaces as an AbortError — treat it as an
1403
- // interrupt (the post-stream `aborted` check below converts it to a clean
1404
- // timeout/UserAbort), not a crash or a provider error.
1405
- this.interrupted = true;
1592
+ catch (err) {
1593
+ if (abortSignal?.aborted) {
1594
+ // External I/O is already stopped; the post-stream branch commits cancellation.
1595
+ this.interrupted = true;
1596
+ this.cancellationReason ??= "user";
1597
+ }
1598
+ else {
1599
+ // Reactive recovery is now a kernel decision. Forward the raw provider error and
1600
+ // dispatch whatever the kernel returns: `call_provider` to retry with a freshly
1601
+ // compacted context, or `done` to terminate with an honest `ContextOverflow`. The
1602
+ // classify + compact + retry + give-up policy lives in the kernel (one place), not
1603
+ // duplicated across the four SDK runners. `continue` re-enters the loop: a recovered
1604
+ // turn persists its compaction archive via the loop-top appendObservations, and a
1605
+ // terminal `done` exits through `isTerminal()` into the run_terminal emit below.
1606
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
1607
+ kind: "provider_error",
1608
+ effect_id: providerEffectId,
1609
+ message: formatToolError(err),
1610
+ });
1611
+ // Withholding (query.ts parity): surface the raw provider error only when the kernel
1612
+ // could NOT recover (it returned a terminal). On a recovered retry (`call_provider`)
1613
+ // the error stays hidden, so embedders that terminate on `error` events don't see a
1614
+ // phantom failure mid-recovery.
1615
+ if (action.kind === "done") {
1616
+ yield { type: "error", message: formatToolError(err) };
1617
+ }
1618
+ continue;
1619
+ }
1406
1620
  }
1407
- else {
1408
- // Reactive recovery is now a kernel decision. Forward the raw provider error and
1409
- // dispatch whatever the kernel returns: `call_provider` to retry with a freshly
1410
- // compacted context, or `done` to terminate with an honest `ContextOverflow`. The
1411
- // classify + compact + retry + give-up policy lives in the kernel (one place), not
1412
- // duplicated across the four SDK runners. `continue` re-enters the loop: a recovered
1413
- // turn persists its compaction archive via the loop-top appendObservations, and a
1414
- // terminal `done` exits through `isTerminal()` into the run_terminal emit below.
1415
- action = kernelAction(runtime, this.pendingObservations, {
1416
- kind: "provider_error",
1417
- message: formatToolError(err),
1621
+ // Do not commit partial provider output after host cancellation.
1622
+ if (abortSignal?.aborted) {
1623
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
1624
+ kind: "cancel_operation",
1625
+ reason: this.cancellationReason ?? "user",
1626
+ pending_call_ids: [providerEffectId],
1418
1627
  });
1419
- // Withholding (query.ts parity): surface the raw provider error only when the kernel
1420
- // could NOT recover (it returned a terminal). On a recovered retry (`call_provider`)
1421
- // the error stays hidden, so embedders that terminate on `error` events don't see a
1422
- // phantom failure mid-recovery.
1423
- if (action.kind === "done") {
1424
- yield { type: "error", message: formatToolError(err) };
1628
+ break;
1629
+ }
1630
+ const assistantMessage = {
1631
+ role: "assistant",
1632
+ content: finalText,
1633
+ toolCalls: finalToolCalls,
1634
+ tokenCount: turnOutputTokens || turnTokens || undefined,
1635
+ };
1636
+ const providerEvent = {
1637
+ kind: "provider_result",
1638
+ effect_id: providerEffectId,
1639
+ message: messageToKernelMessage(assistantMessage),
1640
+ ...(turnInputTokens > 0 ? { observed_input_tokens: turnInputTokens } : {}),
1641
+ ...(turnOutputTokens > 0 ? { observed_output_tokens: turnOutputTokens } : {}),
1642
+ now_ms: Date.now(),
1643
+ ...(turnStopReason ? { stop_reason: turnStopReason } : {}),
1644
+ };
1645
+ action = await this.commitKernelAction(runtime, this.pendingObservations, providerEvent);
1646
+ const providerReplay = peekProviderReplay(this.opts.provider, finalText, finalToolCalls);
1647
+ await this.opts.sessionLog.append(sessionId, buildLlmCompletedEvent({
1648
+ turn: runtime.turn(),
1649
+ content: finalText,
1650
+ tokenCount: turnOutputTokens || turnTokens || undefined,
1651
+ toolCalls: finalToolCalls,
1652
+ providerReplay,
1653
+ }));
1654
+ // P0-C: emit per-turn tool-gating telemetry. `activeSkill` reflects the skill in effect
1655
+ // GOING INTO this turn; a `skill` call here only takes effect next turn, so emit first, then
1656
+ // advance. Wrapped so a faulty sink can never break the run (pure observation).
1657
+ if (this.opts.onTurnMetrics) {
1658
+ try {
1659
+ this.opts.onTurnMetrics({
1660
+ turn: runtime.turn(),
1661
+ toolsExposed: tools.length,
1662
+ toolsCalled: finalToolCalls.length,
1663
+ activeSkill,
1664
+ inputTokens: turnInputTokens,
1665
+ cacheReadTokens: turnCacheReadTokens,
1666
+ cacheCreationTokens: turnCacheCreationTokens,
1667
+ ...(turnCacheReadBySlot ? { cacheReadTokensBySlot: turnCacheReadBySlot } : {}),
1668
+ });
1425
1669
  }
1426
- continue;
1670
+ catch { /* metrics must never break the run */ }
1671
+ }
1672
+ const skillCall = finalToolCalls.find(c => c.name === "skill");
1673
+ if (skillCall) {
1674
+ try {
1675
+ const name = JSON.parse(skillCall.arguments || "{}").name;
1676
+ if (name)
1677
+ activeSkill = name;
1678
+ }
1679
+ catch { /* malformed skill args — leave activeSkill unchanged */ }
1427
1680
  }
1428
1681
  }
1429
- // #2-B-ii: stream aborted (preempt/interrupt) via the break path (provider yielded no error)
1430
- // end the turn now with a timeout so the kernel terminates the run, rather than feeding the
1431
- // partial assistant output as a normal turn.
1432
- if (abortSignal?.aborted) {
1433
- action = kernelAction(runtime, this.pendingObservations, { kind: "timeout" });
1434
- break;
1435
- }
1436
- const assistantMessage = {
1437
- role: "assistant",
1438
- content: finalText,
1439
- toolCalls: finalToolCalls,
1440
- tokenCount: turnOutputTokens || turnTokens || undefined,
1441
- };
1442
- const providerEvent = {
1443
- kind: "provider_result",
1444
- message: messageToKernelMessage(assistantMessage),
1445
- ...(turnInputTokens > 0 ? { observed_input_tokens: turnInputTokens } : {}),
1446
- ...(turnOutputTokens > 0 ? { observed_output_tokens: turnOutputTokens } : {}),
1447
- now_ms: Date.now(),
1448
- ...(turnStopReason ? { stop_reason: turnStopReason } : {}),
1449
- };
1450
- let nextAction = kernelMaybeAction(runtime, this.pendingObservations, providerEvent);
1451
- if (!nextAction && this.pendingObservations.some(o => o.kind === "suspended")) {
1452
- const resolved = await this.resolveKernelSuspend(runtime, sessionId);
1453
- for (const evt of resolved.events)
1454
- yield evt;
1455
- nextAction = kernelAction(runtime, this.pendingObservations, {
1456
- kind: "resume",
1682
+ else if (action.kind === "request_approval") {
1683
+ const resolved = await this.resolveApprovalRequests(action.requests, runtime, sessionId);
1684
+ for (const event of resolved.events)
1685
+ yield event;
1686
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
1687
+ kind: "approval_result",
1688
+ effect_id: action.effectId,
1457
1689
  approved_calls: resolved.approved,
1458
1690
  denied_calls: resolved.denied,
1459
1691
  });
1460
1692
  }
1461
- action = nextAction ?? kernelAction(runtime, this.pendingObservations, providerEvent);
1462
- const providerReplay = peekProviderReplay(this.opts.provider, finalText, finalToolCalls);
1463
- await this.opts.sessionLog.append(sessionId, buildLlmCompletedEvent({
1464
- turn: runtime.turn(),
1465
- content: finalText,
1466
- tokenCount: turnOutputTokens || turnTokens || undefined,
1467
- toolCalls: finalToolCalls,
1468
- providerReplay,
1469
- }));
1470
- // P0-C: emit per-turn tool-gating telemetry. `activeSkill` reflects the skill in effect
1471
- // GOING INTO this turn; a `skill` call here only takes effect next turn, so emit first, then
1472
- // advance. Wrapped so a faulty sink can never break the run (pure observation).
1473
- if (this.opts.onTurnMetrics) {
1693
+ else if (action.kind === "persist_memory") {
1694
+ let error;
1695
+ const agentId = this.opts.agentId;
1474
1696
  try {
1475
- this.opts.onTurnMetrics({
1476
- turn: runtime.turn(),
1477
- toolsExposed: tools.length,
1478
- toolsCalled: finalToolCalls.length,
1479
- activeSkill,
1480
- inputTokens: turnInputTokens,
1481
- cacheReadTokens: turnCacheReadTokens,
1482
- cacheCreationTokens: turnCacheCreationTokens,
1483
- ...(turnCacheReadBySlot ? { cacheReadTokensBySlot: turnCacheReadBySlot } : {}),
1484
- });
1697
+ if (!agentId)
1698
+ throw new Error("memory persistence requires RuntimeOptions.agentId");
1699
+ await this.persistMemoryToStore(action.memory, agentId);
1700
+ }
1701
+ catch (cause) {
1702
+ error = formatToolError(cause);
1485
1703
  }
1486
- catch { /* metrics must never break the run */ }
1704
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
1705
+ kind: "memory_persist_result",
1706
+ effect_id: action.effectId,
1707
+ ...(error ? { error } : {}),
1708
+ });
1487
1709
  }
1488
- const skillCall = finalToolCalls.find(c => c.name === "skill");
1489
- if (skillCall) {
1710
+ else if (action.kind === "query_memory") {
1711
+ const query = action.query;
1712
+ let hits = [];
1713
+ let error;
1714
+ const agentId = this.opts.agentId;
1490
1715
  try {
1491
- const name = JSON.parse(skillCall.arguments || "{}").name;
1492
- if (name)
1493
- activeSkill = name;
1716
+ if (!agentId)
1717
+ throw new Error("memory queries require RuntimeOptions.agentId");
1718
+ hits = await this.retrieveMemoryFromStore(query, action.requestedK, agentId);
1494
1719
  }
1495
- catch { /* malformed skill args — leave activeSkill unchanged */ }
1496
- }
1497
- }
1498
- else if (action.kind === "execute_tool") {
1499
- const allCalls = action.calls;
1500
- await this.opts.sessionLog.append(sessionId, { kind: "tool_requested", turn: runtime.turn(), calls: allCalls });
1501
- const runCtx = {
1502
- agentId: this.opts.agentId,
1503
- skillDir: this.opts.skillDir,
1504
- dreamStore: this.opts.dreamStore,
1505
- knowledgeSource: this.opts.knowledgeSource,
1506
- onToolSuspend: this.opts.onToolSuspend,
1507
- onPermissionRequest: this.opts.onPermissionRequest,
1508
- resultSpool: this.opts.resultSpool ?? new LargeResultSpool(),
1509
- };
1510
- const toolResults = [];
1511
- const normalCalls = allCalls.filter(c => c.name !== "update_plan" && c.name !== "submit_workflow_nodes" && c.name !== "start_workflow"
1512
- && c.name !== "read_result");
1513
- const planCalls = allCalls.filter(c => c.name === "update_plan");
1514
- // M5 v1: `start_workflow` (author a sub-workflow) flattens to the same append path as
1515
- // `submit_workflow_nodes` — a `WorkflowSpec` is a node batch. (v2 adds top-level bootstrap.)
1516
- const submitCalls = allCalls.filter(c => c.name === "submit_workflow_nodes" || c.name === "start_workflow");
1517
- // O7: `read_result` re-fetches a tool output the kernel evicted from context. Content is
1518
- // host-resolved: (a) this turn's in-memory pending spool map, (b) the on-disk result spool
1519
- // (persisted once the kernel observes `large_result_spooled`), (c) a session-log scan for
1520
- // the original `tool_completed` event. The kernel only advertises the capability.
1521
- const readResultCalls = allCalls.filter(c => c.name === "read_result");
1522
- for (const call of planCalls) {
1523
- const update = parseUpdatePlanArgs(call.arguments);
1524
- kernelApply(runtime, this.pendingObservations, {
1525
- kind: "update_task",
1526
- update: taskUpdateToKernel(update),
1720
+ catch (cause) {
1721
+ error = formatToolError(cause);
1722
+ }
1723
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
1724
+ kind: "memory_query_result",
1725
+ effect_id: action.effectId,
1726
+ hits,
1727
+ ...(error ? { error } : {}),
1527
1728
  });
1528
- const result = { callId: call.id, output: "success", isError: false };
1529
- toolResults.push(result);
1530
- yield { type: "tool_result", callId: call.id, content: "success", isError: false };
1729
+ if (!error)
1730
+ await this.logMemoryRetrievalResult(sessionId, hits);
1531
1731
  }
1532
- for (const call of readResultCalls) {
1533
- const out = await this.resolveReadResult(sessionId, call.arguments);
1534
- toolResults.push({ callId: call.id, output: out.text, isError: out.isError });
1535
- yield { type: "tool_result", callId: call.id, content: out.text, isError: out.isError };
1536
- }
1537
- // R3-1: `submit_workflow_nodes` cannot be applied to this runner's kernel — when this runner
1538
- // is a workflow node, the workflow lives in the *parent* kernel. Surface the requested nodes
1539
- // as a stream event; the orchestrator collects them onto the node's result and `runWorkflow`
1540
- // sends `submit_workflow_nodes` to the parent kernel. (When not a workflow node, the event is
1541
- // simply unconsumed — a no-op.)
1542
- for (const call of submitCalls) {
1543
- // M5 v2.1: a TOP-LEVEL agent authoring a whole sub-workflow via `start_workflow` — record the
1544
- // full spec and AUTO-PIVOT once this tool turn resolves (the loop drives it in this kernel and
1545
- // injects the outcome). A workflow-NODE's `start_workflow` (and every `submit_workflow_nodes`)
1546
- // instead FLATTENS: the batch is surfaced for the parent `runWorkflow` to append.
1547
- if (call.name === "start_workflow" && !this.opts.isWorkflowNode) {
1548
- const spec = parseStartWorkflowSpec(call.arguments);
1549
- if (spec) {
1550
- this.pendingAuthoredWorkflows.push(spec);
1551
- const out = "workflow authored; executing now";
1552
- toolResults.push({ callId: call.id, output: out, isError: false });
1553
- yield { type: "tool_result", callId: call.id, content: out, isError: false };
1554
- continue;
1555
- }
1732
+ else if (action.kind === "spool_large_result") {
1733
+ const spool = this.opts.resultSpool ?? new LargeResultSpool();
1734
+ let spoolRef;
1735
+ let error;
1736
+ try {
1737
+ spoolRef = await spool.persistOutput(action.callId, action.output);
1738
+ }
1739
+ catch (cause) {
1740
+ error = formatToolError(cause);
1556
1741
  }
1557
- // `start_workflow` wraps the batch as `{ spec: { nodes } }`; `submit_workflow_nodes` is `{ nodes }`.
1558
- const nodes = call.name === "start_workflow"
1559
- ? parseStartWorkflowArgs(call.arguments)
1560
- : parseSubmitWorkflowNodesArgs(call.arguments);
1561
- yield { type: "workflow_nodes_submitted", nodes };
1562
- const result = { callId: call.id, output: "submitted", isError: false };
1563
- toolResults.push(result);
1564
- yield { type: "tool_result", callId: call.id, content: "submitted", isError: false };
1742
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
1743
+ kind: "large_result_spool_result",
1744
+ effect_id: action.effectId,
1745
+ ...(spoolRef ? { spool_ref: spoolRef } : {}),
1746
+ ...(error ? { error } : {}),
1747
+ });
1565
1748
  }
1566
- // O5 (PreToolUse-hook analog): give the host a STATEFUL veto over each kernel-approved
1567
- // call. A blocked call never executes; its reason reaches the model as a governance-denied
1568
- // tool result (the kernel rolls the turn back with the note). Errs-open on hook throw.
1569
- let executableCalls = normalCalls;
1570
- if (this.opts.onToolCall) {
1571
- const allowed = [];
1572
- for (const call of normalCalls) {
1573
- let decision;
1574
- try {
1575
- decision = await this.opts.onToolCall({ callId: call.id, name: call.name, arguments: call.arguments });
1749
+ else if (action.kind === "archive_page_out") {
1750
+ const archiveMeta = this.activePageOutArchive
1751
+ ?? this.pendingPageOutArchives.shift()
1752
+ ?? {
1753
+ archiveStart: this.nextArchiveStart,
1754
+ compressedSeq: await this.opts.sessionLog.latestSeq(sessionId),
1755
+ };
1756
+ this.activePageOutArchive = archiveMeta;
1757
+ let archiveRef;
1758
+ let error;
1759
+ try {
1760
+ if (this.opts.compressionStore) {
1761
+ const ref = await this.opts.compressionStore.write(sessionId, archiveMeta.archiveStart, action.archived);
1762
+ if (ref)
1763
+ archiveRef = ref;
1576
1764
  }
1577
- catch {
1578
- decision = undefined;
1765
+ }
1766
+ catch (cause) {
1767
+ error = formatToolError(cause);
1768
+ }
1769
+ const archived = action.archived;
1770
+ const archiveAction = compressionAction(action.action) ?? "auto_compact";
1771
+ const archiveTier = action.tier;
1772
+ const compressedSeq = archiveMeta.compressedSeq;
1773
+ if (!error)
1774
+ this.activePageOutArchive = undefined;
1775
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
1776
+ kind: "page_out_archive_result",
1777
+ effect_id: action.effectId,
1778
+ ...(archiveRef ? { archive_ref: archiveRef } : {}),
1779
+ ...(error ? { error } : {}),
1780
+ });
1781
+ if (!error) {
1782
+ if (this.opts.asyncSummarizer && archived.length > 0) {
1783
+ const upgrade = () => this.upgradeCompressedSummary(sessionId, compressedSeq, archived, archiveAction);
1784
+ taskScope.spawn("compressed-summary-upgrade", upgrade);
1579
1785
  }
1580
- if (decision?.block) {
1581
- const reason = decision.reason ?? "blocked by host onToolCall hook";
1582
- yield { type: "tool_denied", callId: call.id, toolName: call.name, reason };
1583
- await this.opts.sessionLog.append(sessionId, {
1584
- kind: "tool_denied", turn: runtime.turn(), call_id: call.id, tool_name: call.name, reason,
1585
- });
1586
- const out = `blocked by host hook: ${reason}`;
1587
- toolResults.push({ callId: call.id, output: out, isError: true, errorKind: "governance_denied" });
1588
- yield { type: "tool_result", callId: call.id, name: call.name, content: out, isError: true };
1589
- continue;
1786
+ if (archiveTier === "semantic" && archived.length > 0) {
1787
+ taskScope.spawn("semantic-page-out", () => this.archiveSemanticPageOut(archived, archiveAction, sessionId));
1590
1788
  }
1591
- allowed.push(call);
1592
1789
  }
1593
- executableCalls = allowed;
1594
1790
  }
1595
- if (executableCalls.length > 0) {
1596
- for await (const evt of this.opts.executionPlane.executeAll(executableCalls, runCtx)) {
1597
- yield evt;
1598
- if (evt.type === "tool_result") {
1599
- const tre = evt;
1600
- toolResults.push({
1601
- callId: tre.callId,
1602
- output: tre.content,
1603
- isError: tre.isError,
1604
- isFatal: tre.isFatal,
1605
- errorKind: tre.errorKind,
1606
- });
1607
- }
1608
- else if (evt.type === "tool_argument_repaired") {
1609
- const tare = evt;
1610
- await this.opts.sessionLog.append(sessionId, {
1611
- kind: "tool_argument_repaired",
1612
- turn: runtime.turn(),
1613
- tool: tare.name,
1614
- original_arguments: tare.originalArguments,
1615
- repaired_arguments: tare.repairedArguments,
1616
- });
1791
+ else if (action.kind === "execute_tool") {
1792
+ const toolEffectId = action.effectId;
1793
+ const allCalls = action.calls;
1794
+ await this.opts.sessionLog.append(sessionId, { kind: "tool_requested", turn: runtime.turn(), calls: allCalls });
1795
+ const runCtx = {
1796
+ operation,
1797
+ agentId: this.opts.agentId,
1798
+ memoryScope: this.opts.memoryScope,
1799
+ skillDir: this.opts.skillDir,
1800
+ dreamStore: this.opts.dreamStore,
1801
+ knowledgeSource: this.opts.knowledgeSource,
1802
+ onToolSuspend: this.opts.onToolSuspend,
1803
+ onPermissionRequest: this.opts.onPermissionRequest,
1804
+ resultSpool: this.opts.resultSpool ?? new LargeResultSpool(),
1805
+ };
1806
+ const toolResults = [];
1807
+ const normalCalls = allCalls.filter(c => c.name !== "update_plan" && c.name !== "submit_workflow_nodes" && c.name !== "start_workflow"
1808
+ && c.name !== "read_result");
1809
+ const planCalls = allCalls.filter(c => c.name === "update_plan");
1810
+ // M5 v1: `start_workflow` (author a sub-workflow) flattens to the same append path as
1811
+ // `submit_workflow_nodes` — a `WorkflowSpec` is a node batch. (v2 adds top-level bootstrap.)
1812
+ const submitCalls = allCalls.filter(c => c.name === "submit_workflow_nodes" || c.name === "start_workflow");
1813
+ // O7: `read_result` re-fetches a tool output the kernel evicted from context. Content is
1814
+ // host-resolved from the effect-committed spool, then from the durable session log.
1815
+ const readResultCalls = allCalls.filter(c => c.name === "read_result");
1816
+ for (const call of planCalls) {
1817
+ const update = parseUpdatePlanArgs(call.arguments);
1818
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1819
+ kind: "update_task",
1820
+ update: taskUpdateToKernel(update),
1821
+ });
1822
+ const result = { callId: call.id, output: "success", isError: false };
1823
+ toolResults.push(result);
1824
+ yield { type: "tool_result", callId: call.id, content: "success", isError: false };
1825
+ }
1826
+ for (const call of readResultCalls) {
1827
+ const out = await this.resolveReadResult(sessionId, call.arguments);
1828
+ toolResults.push({ callId: call.id, output: out.text, isError: out.isError });
1829
+ yield { type: "tool_result", callId: call.id, content: out.text, isError: out.isError };
1830
+ }
1831
+ // R3-1: `submit_workflow_nodes` cannot be applied to this runner's kernel — when this runner
1832
+ // is a workflow node, the workflow lives in the *parent* kernel. Surface the requested nodes
1833
+ // as a stream event; the orchestrator collects them onto the node's result and `runWorkflow`
1834
+ // sends `submit_workflow_nodes` to the parent kernel. (When not a workflow node, the event is
1835
+ // simply unconsumed — a no-op.)
1836
+ for (const call of submitCalls) {
1837
+ // M5 v2.1: a TOP-LEVEL agent authoring a whole sub-workflow via `start_workflow` — record the
1838
+ // full spec and AUTO-PIVOT once this tool turn resolves (the loop drives it in this kernel and
1839
+ // injects the outcome). A workflow-NODE's `start_workflow` (and every `submit_workflow_nodes`)
1840
+ // instead FLATTENS: the batch is surfaced for the parent `runWorkflow` to append.
1841
+ if (call.name === "start_workflow" && !this.opts.isWorkflowNode) {
1842
+ const spec = parseStartWorkflowSpec(call.arguments);
1843
+ if (spec) {
1844
+ this.pendingAuthoredWorkflows.push(spec);
1845
+ const out = "workflow authored; executing now";
1846
+ toolResults.push({ callId: call.id, output: out, isError: false });
1847
+ yield { type: "tool_result", callId: call.id, content: out, isError: false };
1848
+ continue;
1849
+ }
1617
1850
  }
1618
- else if (evt.type === "tool_denied") {
1619
- const tde = evt;
1620
- await this.opts.sessionLog.append(sessionId, {
1621
- kind: "tool_denied",
1622
- turn: runtime.turn(),
1623
- call_id: tde.callId,
1624
- tool_name: tde.toolName,
1625
- reason: tde.reason,
1626
- });
1851
+ // `start_workflow` wraps the batch as `{ spec: { nodes } }`; `submit_workflow_nodes` is `{ nodes }`.
1852
+ const nodes = call.name === "start_workflow"
1853
+ ? parseStartWorkflowArgs(call.arguments)
1854
+ : parseSubmitWorkflowNodesArgs(call.arguments);
1855
+ yield { type: "workflow_nodes_submitted", nodes };
1856
+ const result = { callId: call.id, output: "submitted", isError: false };
1857
+ toolResults.push(result);
1858
+ yield { type: "tool_result", callId: call.id, content: "submitted", isError: false };
1859
+ }
1860
+ // O5 (PreToolUse-hook analog): give the host a STATEFUL veto over each kernel-approved
1861
+ // call. A blocked call never executes; its reason reaches the model as a governance-denied
1862
+ // tool result (the kernel rolls the turn back with the note). Decision failures are closed
1863
+ // unless the host explicitly marks this hook advisory with `onToolCallFailure: "open"`.
1864
+ let executableCalls = normalCalls;
1865
+ if (this.opts.onToolCall) {
1866
+ const allowed = [];
1867
+ for (const call of normalCalls) {
1868
+ let decision;
1869
+ try {
1870
+ decision = await this.opts.onToolCall({ callId: call.id, name: call.name, arguments: call.arguments });
1871
+ }
1872
+ catch (cause) {
1873
+ decision = this.opts.onToolCallFailure === "open"
1874
+ ? undefined
1875
+ : { block: true, reason: `onToolCall hook failed: ${formatToolError(cause)}` };
1876
+ }
1877
+ if (decision?.block) {
1878
+ const reason = decision.reason ?? "blocked by host onToolCall hook";
1879
+ yield { type: "tool_denied", callId: call.id, toolName: call.name, reason };
1880
+ await this.opts.sessionLog.append(sessionId, {
1881
+ kind: "tool_denied", turn: runtime.turn(), call_id: call.id, tool_name: call.name, reason,
1882
+ });
1883
+ const out = `blocked by host hook: ${reason}`;
1884
+ toolResults.push({ callId: call.id, output: out, isError: true, errorKind: "governance_denied" });
1885
+ yield { type: "tool_result", callId: call.id, name: call.name, content: out, isError: true };
1886
+ continue;
1887
+ }
1888
+ allowed.push(call);
1627
1889
  }
1628
- else if (evt.type === "permission_request") {
1629
- const pre = evt;
1630
- const turn = runtime.turn();
1631
- await this.opts.sessionLog.append(sessionId, {
1632
- kind: "permission_requested",
1633
- turn,
1634
- tool: pre.toolName,
1635
- arguments: pre.arguments,
1636
- reason: pre.reason,
1637
- });
1890
+ executableCalls = allowed;
1891
+ }
1892
+ if (executableCalls.length > 0) {
1893
+ for await (const evt of this.opts.executionPlane.executeAll(executableCalls, runCtx)) {
1894
+ yield evt;
1895
+ if (evt.type === "tool_result") {
1896
+ const tre = evt;
1897
+ toolResults.push({
1898
+ callId: tre.callId,
1899
+ output: tre.content,
1900
+ isError: tre.isError,
1901
+ isFatal: tre.isFatal,
1902
+ errorKind: tre.errorKind,
1903
+ });
1904
+ }
1905
+ else if (evt.type === "tool_argument_repaired") {
1906
+ const tare = evt;
1907
+ await this.opts.sessionLog.append(sessionId, {
1908
+ kind: "tool_argument_repaired",
1909
+ turn: runtime.turn(),
1910
+ tool: tare.name,
1911
+ original_arguments: tare.originalArguments,
1912
+ repaired_arguments: tare.repairedArguments,
1913
+ });
1914
+ }
1915
+ else if (evt.type === "tool_denied") {
1916
+ const tde = evt;
1917
+ await this.opts.sessionLog.append(sessionId, {
1918
+ kind: "tool_denied",
1919
+ turn: runtime.turn(),
1920
+ call_id: tde.callId,
1921
+ tool_name: tde.toolName,
1922
+ reason: tde.reason,
1923
+ });
1924
+ }
1925
+ else if (evt.type === "permission_request") {
1926
+ const pre = evt;
1927
+ const turn = runtime.turn();
1928
+ await this.opts.sessionLog.append(sessionId, {
1929
+ kind: "permission_requested",
1930
+ turn,
1931
+ tool: pre.toolName,
1932
+ arguments: pre.arguments,
1933
+ reason: pre.reason,
1934
+ });
1935
+ }
1936
+ else if (evt.type === "permission_resolved") {
1937
+ const resolved = evt;
1938
+ const turn = runtime.turn();
1939
+ await this.opts.sessionLog.append(sessionId, {
1940
+ kind: "permission_resolved",
1941
+ turn,
1942
+ approved: resolved.approved,
1943
+ responder: resolved.responder,
1944
+ });
1945
+ }
1638
1946
  }
1639
- else if (evt.type === "permission_resolved") {
1640
- const resolved = evt;
1641
- const turn = runtime.turn();
1642
- await this.opts.sessionLog.append(sessionId, {
1643
- kind: "permission_resolved",
1644
- turn,
1645
- approved: resolved.approved,
1646
- responder: resolved.responder,
1647
- });
1947
+ const names = executableCalls.map(c => c.name).join(", ");
1948
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1949
+ kind: "update_task",
1950
+ update: taskUpdateToKernel({ progress: `Executed tools: ${names}` }),
1951
+ });
1952
+ }
1953
+ // O5 (PostToolUse-hook analog): let the host inspect each executed result BEFORE it
1954
+ // reaches the kernel/session-log — replace the output (redact/annotate) and/or push a
1955
+ // contextual note into the signal stream. Errs-open on hook throw.
1956
+ if (this.opts.onToolResult) {
1957
+ for (const r of toolResults) {
1958
+ const call = executableCalls.find(c => c.id === r.callId);
1959
+ if (!call)
1960
+ continue; // plan/submit synthetics and hook-blocked calls are not host results
1961
+ let decision;
1962
+ try {
1963
+ decision = await this.opts.onToolResult({
1964
+ callId: r.callId, name: call.name, arguments: call.arguments,
1965
+ output: r.output, isError: r.isError,
1966
+ });
1967
+ }
1968
+ catch {
1969
+ decision = undefined;
1970
+ }
1971
+ if (!decision)
1972
+ continue;
1973
+ if (typeof decision.replaceOutput === "string")
1974
+ r.output = decision.replaceOutput;
1975
+ if (decision.note)
1976
+ this.injectNote(decision.note);
1648
1977
  }
1649
1978
  }
1650
- const names = executableCalls.map(c => c.name).join(", ");
1651
- kernelApply(runtime, this.pendingObservations, {
1652
- kind: "update_task",
1653
- update: taskUpdateToKernel({ progress: `Executed tools: ${names}` }),
1979
+ await this.opts.sessionLog.append(sessionId, {
1980
+ kind: "tool_completed",
1981
+ turn: runtime.turn(),
1982
+ results: toolResults.map(r => ({
1983
+ call_id: r.callId,
1984
+ output: r.output,
1985
+ is_error: r.isError,
1986
+ token_count: r.tokenCount,
1987
+ })),
1654
1988
  });
1655
- }
1656
- // O5 (PostToolUse-hook analog): let the host inspect each executed result BEFORE it
1657
- // reaches the kernel/session-log replace the output (redact/annotate) and/or push a
1658
- // contextual note into the signal stream. Errs-open on hook throw.
1659
- if (this.opts.onToolResult) {
1660
- for (const r of toolResults) {
1661
- const call = executableCalls.find(c => c.id === r.callId);
1662
- if (!call)
1663
- continue; // plan/submit synthetics and hook-blocked calls are not host results
1664
- let decision;
1989
+ // P1-B B3: a `skill` call that resolved successfully activates that skill in the kernel, so
1990
+ // the next `call_provider` narrows the toolset to its declared tools. Fed before `tool_results`
1991
+ // (which computes the next action). Errs-open: a failed/missing skill load doesn't activate.
1992
+ //
1993
+ // Strict dynamic context control: a skill is METHOD content — how to do something — reused
1994
+ // for the rest of the run, unlike a one-off memory/knowledge lookup (fact content, relevant
1995
+ // for the moment it's used). So its text ALSO goes into the durable `knowledge` slot here
1996
+ // (in addition to the ordinary tool_result already headed for `history`, where it will decay
1997
+ // with the compression pyramid like any other tool output — that's fine, the permanent copy
1998
+ // now lives in `knowledge`). First activation only (see `knowledgePushedSkills`).
1999
+ for (const call of allCalls) {
2000
+ if (call.name !== "skill")
2001
+ continue;
2002
+ const res = toolResults.find(r => r.callId === call.id);
2003
+ if (!res || res.isError)
2004
+ continue;
1665
2005
  try {
1666
- decision = await this.opts.onToolResult({
1667
- callId: r.callId, name: call.name, arguments: call.arguments,
1668
- output: r.output, isError: r.isError,
2006
+ const name = JSON.parse(call.arguments || "{}").name;
2007
+ if (!name)
2008
+ continue;
2009
+ await this.commitKernelApply(runtime, this.pendingObservations, {
2010
+ kind: "skill_activated",
2011
+ name,
2012
+ ...(this.opts.skillLeaseTurns !== undefined ? { lease_turns: this.opts.skillLeaseTurns } : {}),
1669
2013
  });
2014
+ // K1: keyed `skill:<name>` — the kernel-side upsert dedupes across runner instances
2015
+ // (wake re-push of an already-pinned skill upserts instead of duplicating). With a
2016
+ // lease configured, the Set optimization is skipped: an expired-then-reloaded skill
2017
+ // must re-pin, and only the kernel knows the lease state — its upsert dedupes anyway.
2018
+ if (this.opts.skillLeaseTurns !== undefined || !this.knowledgePushedSkills.has(name)) {
2019
+ this.knowledgePushedSkills.add(name);
2020
+ await this.pushKnowledge({ role: "system", content: res.output, toolCalls: [] }, undefined, { key: `skill:${name}` });
2021
+ }
1670
2022
  }
1671
- catch {
1672
- decision = undefined;
1673
- }
1674
- if (!decision)
1675
- continue;
1676
- if (typeof decision.replaceOutput === "string")
1677
- r.output = decision.replaceOutput;
1678
- if (decision.note)
1679
- this.injectNote(decision.note);
2023
+ catch { /* malformed skill args — skip activation */ }
1680
2024
  }
1681
- }
1682
- await this.opts.sessionLog.append(sessionId, {
1683
- kind: "tool_completed",
1684
- turn: runtime.turn(),
1685
- results: toolResults.map(r => ({
1686
- call_id: r.callId,
1687
- output: r.output,
1688
- is_error: r.isError,
1689
- token_count: r.tokenCount,
1690
- })),
1691
- });
1692
- for (const call of normalCalls) {
1693
- const result = toolResults.find(r => r.callId === call.id);
1694
- if (result) {
1695
- this.pendingSpoolOutputs.set(call.id, { tool: call.name, output: result.output });
2025
+ const entropyObsStart = this.pendingObservations.length;
2026
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
2027
+ kind: "tool_results",
2028
+ effect_id: toolEffectId,
2029
+ results: toolResults.map(toolResultToKernel),
2030
+ });
2031
+ // Surface the boundary's entropy measurement live (the heartbeat watch source) —
2032
+ // the session-log record lands via the normal appendObservations path.
2033
+ for (const obs of this.pendingObservations.slice(entropyObsStart)) {
2034
+ if (obs.kind === "entropy_sample") {
2035
+ this.lastEntropySample = entropySampleFromObservation(obs);
2036
+ yield { type: "entropy_sample", sample: this.lastEntropySample };
2037
+ }
2038
+ else if (obs.kind === "entropy_alert") {
2039
+ yield {
2040
+ type: "entropy_alert",
2041
+ turn: obs.turn ?? 0,
2042
+ score: obs.score ?? 0,
2043
+ threshold: obs.threshold ?? 0,
2044
+ };
2045
+ }
1696
2046
  }
1697
2047
  }
1698
- // P1-B B3: a `skill` call that resolved successfully activates that skill in the kernel, so
1699
- // the next `call_provider` narrows the toolset to its declared tools. Fed before `tool_results`
1700
- // (which computes the next action). Errs-open: a failed/missing skill load doesn't activate.
1701
- //
1702
- // Strict dynamic context control: a skill is METHOD content — how to do something — reused
1703
- // for the rest of the run, unlike a one-off memory/knowledge lookup (fact content, relevant
1704
- // for the moment it's used). So its text ALSO goes into the durable `knowledge` slot here
1705
- // (in addition to the ordinary tool_result already headed for `history`, where it will decay
1706
- // with the compression pyramid like any other tool output — that's fine, the permanent copy
1707
- // now lives in `knowledge`). First activation only (see `knowledgePushedSkills`).
1708
- for (const call of allCalls) {
1709
- if (call.name !== "skill")
1710
- continue;
1711
- const res = toolResults.find(r => r.callId === call.id);
1712
- if (!res || res.isError)
1713
- continue;
1714
- try {
1715
- const name = JSON.parse(call.arguments || "{}").name;
1716
- if (!name)
1717
- continue;
1718
- kernelApply(runtime, this.pendingObservations, {
1719
- kind: "skill_activated",
1720
- name,
1721
- ...(this.opts.skillLeaseTurns !== undefined ? { lease_turns: this.opts.skillLeaseTurns } : {}),
2048
+ else if (action.kind === "evaluate_milestone") {
2049
+ const milestoneEffectId = action.effectId;
2050
+ const milestonePolicy = this.opts.milestonePolicy ?? "require_verifier";
2051
+ if (milestonePolicy === "auto_pass") {
2052
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
2053
+ kind: "milestone_result",
2054
+ effect_id: milestoneEffectId,
2055
+ result: milestoneCheckResultToKernel(milestoneCheckPass(action.phaseId)),
1722
2056
  });
1723
- // K1: keyed `skill:<name>` the kernel-side upsert dedupes across runner instances
1724
- // (wake re-push of an already-pinned skill upserts instead of duplicating). With a
1725
- // lease configured, the Set optimization is skipped: an expired-then-reloaded skill
1726
- // must re-pin, and only the kernel knows the lease state — its upsert dedupes anyway.
1727
- if (this.opts.skillLeaseTurns !== undefined || !this.knowledgePushedSkills.has(name)) {
1728
- this.knowledgePushedSkills.add(name);
1729
- this.pushKnowledge({ role: "system", content: res.output, toolCalls: [] }, undefined, { key: `skill:${name}` });
1730
- }
2057
+ this.nextArchiveStart = await this.appendObservations(sessionId, runtime, this.nextArchiveStart, taskScope);
1731
2058
  }
1732
- catch { /* malformed skill args — skip activation */ }
1733
- }
1734
- const entropyObsStart = this.pendingObservations.length;
1735
- action = kernelAction(runtime, this.pendingObservations, {
1736
- kind: "tool_results",
1737
- results: toolResults.map(toolResultToKernel),
1738
- });
1739
- // Surface the boundary's entropy measurement live (the heartbeat watch source) —
1740
- // the session-log record lands via the normal appendObservations path.
1741
- for (const obs of this.pendingObservations.slice(entropyObsStart)) {
1742
- if (obs.kind === "entropy_sample") {
1743
- this.lastEntropySample = entropySampleFromObservation(obs);
1744
- yield { type: "entropy_sample", sample: this.lastEntropySample };
2059
+ else if (this.opts.onMilestoneEvaluate) {
2060
+ const check = await this.opts.onMilestoneEvaluate({
2061
+ phaseId: action.phaseId,
2062
+ criteria: action.criteria,
2063
+ requiredEvidence: action.requiredEvidence,
2064
+ });
2065
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
2066
+ kind: "milestone_result",
2067
+ effect_id: milestoneEffectId,
2068
+ result: milestoneCheckResultToKernel(check),
2069
+ });
2070
+ this.nextArchiveStart = await this.appendObservations(sessionId, runtime, this.nextArchiveStart, taskScope);
1745
2071
  }
1746
- else if (obs.kind === "entropy_alert") {
1747
- yield {
1748
- type: "entropy_alert",
1749
- turn: obs.turn ?? 0,
1750
- score: obs.score ?? 0,
1751
- threshold: obs.threshold ?? 0,
1752
- };
2072
+ else {
2073
+ this.nextArchiveStart = await this.appendObservations(sessionId, runtime, this.nextArchiveStart, taskScope);
2074
+ const turnsUsed = Math.max(1, runtime.turn());
2075
+ await this.opts.sessionLog.append(sessionId, buildRunTerminalEvent({
2076
+ reason: "milestone_pending",
2077
+ turnsUsed,
2078
+ totalTokens: 0,
2079
+ }));
2080
+ await groupBudgetScope?.release();
2081
+ await taskScope.drain();
2082
+ yield { type: "done", iterations: turnsUsed, totalTokens: 0, status: "milestone_pending" };
2083
+ this.activeKernel = null;
2084
+ this.currentSessionId = null;
2085
+ return;
1753
2086
  }
1754
2087
  }
1755
- }
1756
- else if (action.kind === "evaluate_milestone") {
1757
- const milestonePolicy = this.opts.milestonePolicy ?? "require_verifier";
1758
- if (milestonePolicy === "auto_pass") {
1759
- action = kernelAction(runtime, this.pendingObservations, {
1760
- kind: "milestone_result",
1761
- result: milestoneCheckResultToKernel(milestoneCheckPass(action.phaseId)),
1762
- });
1763
- this.nextArchiveStart = await this.appendObservations(sessionId, runtime, this.nextArchiveStart);
1764
- }
1765
- else if (this.opts.onMilestoneEvaluate) {
1766
- const check = await this.opts.onMilestoneEvaluate({
1767
- phaseId: action.phaseId,
1768
- criteria: action.criteria,
1769
- requiredEvidence: action.requiredEvidence,
1770
- });
1771
- action = kernelAction(runtime, this.pendingObservations, {
1772
- kind: "milestone_result",
1773
- result: milestoneCheckResultToKernel(check),
1774
- });
1775
- this.nextArchiveStart = await this.appendObservations(sessionId, runtime, this.nextArchiveStart);
1776
- }
1777
- else {
1778
- this.nextArchiveStart = await this.appendObservations(sessionId, runtime, this.nextArchiveStart);
1779
- const turnsUsed = Math.max(1, runtime.turn());
1780
- await this.opts.sessionLog.append(sessionId, buildRunTerminalEvent({
1781
- reason: "milestone_pending",
1782
- turnsUsed,
1783
- totalTokens: 0,
1784
- }));
1785
- yield { type: "done", iterations: turnsUsed, totalTokens: 0, status: "milestone_pending" };
1786
- this.activeKernel = null;
1787
- this.currentSessionId = null;
1788
- return;
2088
+ else if (action.kind === "done") {
2089
+ break;
1789
2090
  }
1790
2091
  }
1791
- else if (action.kind === "done") {
1792
- break;
2092
+ }
2093
+ catch (err) {
2094
+ // I0b: kernel rejection (or any other thrown error inside the loop) reaches us here.
2095
+ // Classify by NAPI status code or message pattern — `invalid_arg` for surface-shape rejects,
2096
+ // `error` for everything else — then emit run_terminal so observability sees a clean end.
2097
+ // The yield-error path mirrors what the in-flight provider-stream catch does.
2098
+ const errMsg = formatToolError(err);
2099
+ const code = err.code;
2100
+ const isInvalidArg = code === "InvalidArg" ||
2101
+ errMsg.toLowerCase().includes("invalidarg") ||
2102
+ errMsg.toLowerCase().includes("invalid argument");
2103
+ const reason = isInvalidArg ? "invalid_arg" : "error";
2104
+ yield { type: "error", message: errMsg };
2105
+ try {
2106
+ await this.opts.sessionLog.append(sessionId, buildRunTerminalEvent({
2107
+ reason,
2108
+ turnsUsed: runtime.turn() || 0,
2109
+ totalTokens: 0,
2110
+ }));
1793
2111
  }
2112
+ catch { /* session log failure must not mask the original error */ }
2113
+ await groupBudgetScope?.release();
2114
+ await taskScope.drain();
2115
+ yield { type: "done", iterations: runtime.turn() || 0, totalTokens: 0, status: reason };
2116
+ this.activeKernel = null;
2117
+ this.currentSessionId = null;
2118
+ this.dashboard = null;
2119
+ return;
1794
2120
  }
1795
- }
1796
- catch (err) {
1797
- // I0b: kernel rejection (or any other thrown error inside the loop) reaches us here.
1798
- // Classify by NAPI status code or message pattern`invalid_arg` for surface-shape rejects,
1799
- // `error` for everything else then emit run_terminal so observability sees a clean end.
1800
- // The yield-error path mirrors what the in-flight provider-stream catch does.
1801
- const errMsg = formatToolError(err);
1802
- const code = err.code;
1803
- const isInvalidArg = code === "InvalidArg" ||
1804
- errMsg.toLowerCase().includes("invalidarg") ||
1805
- errMsg.toLowerCase().includes("invalid argument");
1806
- const reason = isInvalidArg ? "invalid_arg" : "error";
1807
- yield { type: "error", message: errMsg };
1808
- try {
1809
- await this.opts.sessionLog.append(sessionId, buildRunTerminalEvent({
1810
- reason,
1811
- turnsUsed: runtime.turn() || 0,
1812
- totalTokens: 0,
2121
+ const result = action.kind === "done" ? action.result : undefined;
2122
+ // I0a: when the loop exits without a clean kernel-done — typically because a hard interrupt
2123
+ // aborted the in-flight LLM stream and the catch path sent `timeout` (which the kernel handles
2124
+ // by injecting a rollback note and continuing, not by terminating) preserve the preempt
2125
+ // intent in the run_terminal reason. Without this, every interrupt-curtailed run reports
2126
+ // `reason: "error"` and the bench / observability layer can't distinguish preemption from a
2127
+ // genuine crash. Mirrors WASM/Python/Rust.
2128
+ const status = result?.termination ?? "error";
2129
+ const turnsUsed = result ? Math.max(1, result.turnsUsed) : runtime.turn() || 0;
2130
+ const totalTokens = result?.totalTokensUsed ?? 0;
2131
+ nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart, taskScope);
2132
+ await this.opts.sessionLog.append(sessionId, buildRunTerminalEvent({
2133
+ reason: status,
2134
+ turnsUsed,
2135
+ totalTokens,
2136
+ }));
2137
+ if (groupBudgetScope && !groupBudgetScope.isClosed) {
2138
+ throw new Error("kernel terminated without a correlated budget_usage_reported observation");
2139
+ }
2140
+ if (this.opts.dreamStore && this.opts.agentId) {
2141
+ const newMsgs = runtime.drainNewMessages().map(m => ({
2142
+ role: m.role,
2143
+ content: m.content,
2144
+ contentParts: m.contentParts,
2145
+ tokenCount: m.tokenCount,
2146
+ toolCalls: m.toolCalls?.length ? m.toolCalls : undefined,
1813
2147
  }));
2148
+ if (newMsgs.length > 0) {
2149
+ try {
2150
+ const completedSession = {
2151
+ sessionId,
2152
+ agentId: this.opts.agentId,
2153
+ messages: newMsgs,
2154
+ metadata: null,
2155
+ createdAtMs: sessionStart,
2156
+ updatedAtMs: Date.now(),
2157
+ };
2158
+ await this.opts.dreamStore.saveSession(completedSession);
2159
+ if (this.opts.memoryScope) {
2160
+ const extracted = await extractSessionMemories(this.opts.dreamProvider ?? this.opts.provider, completedSession, this.opts.memoryScope, this.opts.dreamSystemPrompt);
2161
+ for (const memory of extracted) {
2162
+ await this.writeMemory(memory, { sessionId, agentId: this.opts.agentId });
2163
+ }
2164
+ }
2165
+ }
2166
+ catch { /* non-fatal */ }
2167
+ }
1814
2168
  }
1815
- catch { /* session log failure must not mask the original error */ }
1816
- yield { type: "done", iterations: runtime.turn() || 0, totalTokens: 0, status: reason };
2169
+ await taskScope.drain();
2170
+ yield {
2171
+ type: "done",
2172
+ iterations: turnsUsed,
2173
+ totalTokens,
2174
+ status,
2175
+ // ③ loop-agent: surface the kernel-adjudicated after-round decision to the driver.
2176
+ ...(result?.paceDecision ? { paceDecision: result.paceDecision } : {}),
2177
+ };
1817
2178
  this.activeKernel = null;
1818
2179
  this.currentSessionId = null;
1819
2180
  this.dashboard = null;
1820
- return;
1821
- }
1822
- const result = action.kind === "done" ? action.result : undefined;
1823
- // I0a: when the loop exits without a clean kernel-done — typically because a hard interrupt
1824
- // aborted the in-flight LLM stream and the catch path sent `timeout` (which the kernel handles
1825
- // by injecting a rollback note and continuing, not by terminating) — preserve the preempt
1826
- // intent in the run_terminal reason. Without this, every interrupt-curtailed run reports
1827
- // `reason: "error"` and the bench / observability layer can't distinguish preemption from a
1828
- // genuine crash. Mirrors WASM/Python/Rust.
1829
- const status = result?.termination ?? (this.interrupted ? "user_abort" : "error");
1830
- const turnsUsed = result ? Math.max(1, result.turnsUsed) : runtime.turn() || 0;
1831
- const totalTokens = result?.totalTokensUsed ?? 0;
1832
- nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart);
1833
- await this.opts.sessionLog.append(sessionId, buildRunTerminalEvent({
1834
- reason: status,
1835
- turnsUsed,
1836
- totalTokens,
1837
- }));
1838
- // L1: charge this vehicle's local spend (tokens + sub-agent spawns) back to the governance domain
1839
- // so the next member is seeded with the updated cumulative totals.
1840
- if (this.opts.runGroup) {
1841
- const subagents = runtime.localSubagentsSpawned?.() ?? 0;
1842
- if (totalTokens > 0 || subagents > 0) {
1843
- await this.opts.runGroup.budgetStore.charge(this.opts.runGroup.id, { tokens: totalTokens, subagents });
1844
- }
1845
2181
  }
1846
- if (this.opts.dreamStore && this.opts.agentId) {
1847
- const newMsgs = runtime.drainNewMessages().map(m => ({
1848
- role: m.role,
1849
- content: m.content,
1850
- contentParts: m.contentParts,
1851
- tokenCount: m.tokenCount,
1852
- toolCalls: m.toolCalls?.length ? m.toolCalls : undefined,
1853
- }));
1854
- if (newMsgs.length > 0) {
1855
- try {
1856
- await this.opts.dreamStore.saveSession({
1857
- sessionId: crypto.randomUUID(),
1858
- agentId: this.opts.agentId,
1859
- messages: newMsgs,
1860
- metadata: null,
1861
- createdAtMs: sessionStart,
1862
- updatedAtMs: Date.now(),
1863
- });
1864
- }
1865
- catch { /* non-fatal */ }
1866
- }
2182
+ finally {
2183
+ await groupBudgetScope?.release();
2184
+ if (taskScope.pending > 0)
2185
+ await taskScope.cancel("run scope closed");
2186
+ this.activeKernel = null;
2187
+ this.currentSessionId = null;
2188
+ this.dashboard = null;
1867
2189
  }
1868
- yield {
1869
- type: "done",
1870
- iterations: turnsUsed,
1871
- totalTokens,
1872
- status,
1873
- // ③ loop-agent: surface the kernel-adjudicated after-round decision to the driver.
1874
- ...(result?.paceDecision ? { paceDecision: result.paceDecision } : {}),
1875
- };
1876
- this.activeKernel = null;
1877
- this.currentSessionId = null;
1878
- this.dashboard = null;
1879
2190
  }
1880
2191
  /** I4 + K4: fetch long-term memory hits for the current goal and land them in `history` as an
1881
2192
  * ordinary user turn — single-use retrieval content that decays with the compression pyramid,
@@ -1883,12 +2194,17 @@ export class RuntimeRunner {
1883
2194
  * after each sprint renewal (`phase: "renewal"`): renewal drops the old history INCLUDING the
1884
2195
  * earlier memory hits, so the new sprint gets a fresh recall pass. Errs-open throughout. */
1885
2196
  async prefetchMemoryIntoHistory(runtime, phase) {
1886
- if (!this.opts.dreamStore || !this.opts.agentId)
2197
+ if (!this.opts.dreamStore || !this.opts.agentId || !this.opts.memoryScope)
1887
2198
  return;
1888
2199
  // P10: recall is default-on (CC session-start recall) — with no hook configured,
1889
2200
  // the goal itself is the query. preQueryMemory stays as the targeting override.
1890
2201
  const preQuery = this.opts.preQueryMemory
1891
- ?? ((ctx) => [ctx.goal]);
2202
+ ?? ((ctx) => [{
2203
+ scope: this.opts.memoryScope,
2204
+ query: ctx.goal,
2205
+ top_k: 5,
2206
+ kinds: [],
2207
+ }]);
1892
2208
  try {
1893
2209
  const queries = await preQuery({
1894
2210
  goal: this.currentGoal,
@@ -1897,15 +2213,15 @@ export class RuntimeRunner {
1897
2213
  });
1898
2214
  const lines = [];
1899
2215
  for (const q of queries ?? []) {
1900
- if (typeof q !== "string" || !q.trim())
2216
+ if (!q.query.trim())
1901
2217
  continue;
1902
- const hits = await this.opts.dreamStore.search(this.opts.agentId, q, 5);
2218
+ const hits = await this.opts.dreamStore.search(this.opts.agentId, q);
1903
2219
  for (const hit of hits) {
1904
- lines.push(`[memory score=${hit.score.toFixed(3)}] ${hit.text}`);
2220
+ lines.push(`[memory record_id=${hit.record.record_id} trust=${hit.record.provenance.trust} score=${hit.score.toFixed(3)}] ${hit.record.content}`);
1905
2221
  }
1906
2222
  }
1907
2223
  if (lines.length > 0) {
1908
- kernelApply(runtime, this.pendingObservations, {
2224
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1909
2225
  kind: "add_history_message",
1910
2226
  message: { role: "user", content: lines.join("\n") },
1911
2227
  });
@@ -1913,50 +2229,45 @@ export class RuntimeRunner {
1913
2229
  }
1914
2230
  catch { /* errs-open — a faulty pre-fetch never breaks the run */ }
1915
2231
  }
1916
- async appendObservations(sessionId, runtime, nextArchiveStart) {
2232
+ async appendObservations(sessionId, runtime, nextArchiveStart, _taskScope) {
1917
2233
  const turn = runtime.turn();
1918
2234
  const preservedRefs = runtime.preservedRefs();
1919
2235
  const observations = this.pendingObservations.splice(0);
1920
- for (let obs of observations) {
2236
+ for (const obs of observations) {
1921
2237
  if (obs.kind === "page_in_requested")
1922
2238
  continue;
1923
- let archiveRef;
1924
- let spoolRef;
1925
- if (obs.kind === "compressed") {
1926
- const archived = obs.archived;
1927
- if (this.opts.compressionStore && archived && archived.length > 0) {
1928
- try {
1929
- const pathRef = await this.opts.compressionStore.write(sessionId, nextArchiveStart, archived);
1930
- if (pathRef)
1931
- archiveRef = pathRef;
1932
- }
1933
- catch {
1934
- // non-fatal
1935
- }
2239
+ if (obs.kind === "budget_usage_reported") {
2240
+ const scope = this.activeGroupBudgetScope;
2241
+ if (!scope || obs.reservation_id !== scope.reservationId) {
2242
+ throw new Error("budget usage report does not match the active reservation");
1936
2243
  }
2244
+ await this.settleGroupBudget(scope, {
2245
+ tokens: obs.tokens ?? 0,
2246
+ subagents: obs.subagents ?? 0,
2247
+ rounds: obs.rounds ?? 0,
2248
+ });
2249
+ this.activeGroupBudgetScope = undefined;
1937
2250
  }
1938
- if (obs.kind === "large_result_spooled") {
1939
- const pending = this.pendingSpoolOutputs.get(obs.call_id ?? "");
1940
- if (pending) {
1941
- const spool = this.opts.resultSpool ?? new LargeResultSpool();
1942
- try {
1943
- spoolRef = await spool.persistOutput(obs.call_id ?? "", pending.output);
1944
- }
1945
- catch {
1946
- // non-fatal: preview remains in kernel context; full output still in tool_completed log
1947
- }
1948
- if (!obs.tool && pending.tool) {
1949
- obs = { ...obs, tool: pending.tool };
1950
- }
1951
- this.pendingSpoolOutputs.delete(obs.call_id ?? "");
2251
+ // M3: mirror the kernel's journaled recall lifecycle into the durable store so recall
2252
+ // history (count + last-recalled turn) survives across sessions.
2253
+ if (obs.kind === "memory_recalled" && obs.recalls?.length) {
2254
+ const agentId = this.opts.agentId;
2255
+ if (agentId && this.opts.dreamStore?.recordRecall) {
2256
+ await this.opts.dreamStore.recordRecall(agentId, obs.recalls);
1952
2257
  }
1953
2258
  }
2259
+ // M4: a recall crossed the promotion threshold. Advisory — surface it for the host/model to
2260
+ // act on (pin or promote to knowledge); the runner does not auto-pin.
2261
+ if (obs.kind === "promotion_suggested" && obs.record_id) {
2262
+ this.opts.onPromotionSuggested?.({
2263
+ recordId: obs.record_id,
2264
+ recallCount: obs.recall_count ?? 0,
2265
+ });
2266
+ }
1954
2267
  const latest = obs.kind === "compressed" ? await this.opts.sessionLog.latestSeq(sessionId) : undefined;
1955
2268
  const event = kernelObservationToSessionEvent(obs, turn, {
1956
2269
  nextArchiveStart,
1957
2270
  latestSeq: latest,
1958
- archiveRef,
1959
- spoolRef,
1960
2271
  preservedRefs,
1961
2272
  compressionAction,
1962
2273
  });
@@ -1964,27 +2275,10 @@ export class RuntimeRunner {
1964
2275
  continue;
1965
2276
  const compressedSeq = await this.opts.sessionLog.append(sessionId, event);
1966
2277
  if (event.kind === "compressed") {
1967
- nextArchiveStart = compressedSeq + 1;
1968
- const archived = obs.kind === "compressed" ? obs.archived : undefined;
1969
- if (this.opts.asyncSummarizer && archived && archived.length > 0) {
1970
- void this.upgradeCompressedSummary(sessionId, compressedSeq, archived, compressionAction(obs.action) ?? "auto_compact", runtime);
1971
- }
1972
- // One compaction = one kernel observation: the page_out session record (and the
1973
- // semantic-archive branch) is DERIVED here from Compressed.tier_hint, preserving the
1974
- // session-log format and OsSnapshot page_out_count.
1975
- if (obs.tier_hint && Array.isArray(archived) && archived.length > 0) {
1976
- await this.opts.sessionLog.append(sessionId, {
1977
- kind: "page_out",
1978
- turn: obs.turn ?? turn,
1979
- action: compressionAction(obs.action),
1980
- summary: obs.summary,
1981
- tier_hint: obs.tier_hint ?? "durable",
1982
- message_count: archived.length,
1983
- });
1984
- if (obs.tier_hint === "semantic") {
1985
- void this.archiveSemanticPageOut(archived, compressionAction(obs.action));
1986
- }
2278
+ if ((obs.archived_count ?? 0) > 0) {
2279
+ this.pendingPageOutArchives.push({ archiveStart: nextArchiveStart, compressedSeq });
1987
2280
  }
2281
+ nextArchiveStart = compressedSeq + 1;
1988
2282
  }
1989
2283
  // K4: a sprint renewal dropped the old history — including any earlier memory hits — so
1990
2284
  // re-run the preQueryMemory prefetch for the new sprint (live observations only: this
@@ -1995,56 +2289,58 @@ export class RuntimeRunner {
1995
2289
  }
1996
2290
  return nextArchiveStart;
1997
2291
  }
1998
- async archiveSemanticPageOut(archived, action) {
1999
- if (!this.opts.dreamStore || !this.opts.agentId)
2292
+ async archiveSemanticPageOut(archived, action, sessionId) {
2293
+ if (!this.opts.dreamStore || !this.opts.agentId || !this.opts.memoryScope)
2000
2294
  return;
2001
- try {
2002
- const summary = this.opts.dreamSummarizer
2003
- ? await this.opts.dreamSummarizer.summarize(archived, { action })
2004
- : await summarizeForLongTermMemory(this.opts.dreamProvider ?? this.opts.provider, archived, this.opts.dreamSystemPrompt);
2005
- // P2 write-funnel: route through the ONE gated WriteMemory syscall so validation,
2006
- // the rolling write quota, dedup, and the memory_written audit all apply. Score is
2007
- // advisory (0.6) — an automatic summary must never outrank curated content.
2008
- await this.writeMemory({
2009
- content: summary,
2010
- metadata: {
2011
- name: `page-out-${Date.now()}`,
2012
- description: `auto summary of ${action ?? "compaction"} archive`,
2013
- source: "semantic_page_out",
2014
- action,
2015
- score: 0.6,
2016
- },
2017
- });
2018
- }
2019
- catch {
2020
- // non-fatal: in-context compression summary remains; long-term layer is best-effort
2021
- }
2295
+ const summary = this.opts.dreamSummarizer
2296
+ ? await this.opts.dreamSummarizer.summarize(archived, { action })
2297
+ : await summarizeForLongTermMemory(this.opts.dreamProvider ?? this.opts.provider, archived, this.opts.dreamSystemPrompt);
2298
+ // P2 write-funnel: route through the ONE gated WriteMemory syscall so validation,
2299
+ // the rolling write quota, dedup, and the memory_written audit all apply. Score is
2300
+ // advisory (0.6) an automatic summary must never outrank curated content.
2301
+ const now = Date.now();
2302
+ const name = `page-out-${now}`;
2303
+ await this.writeMemory({
2304
+ record_id: `${this.opts.memoryScope.tenant_id}:${this.opts.memoryScope.namespace}:project:${name}`,
2305
+ scope: this.opts.memoryScope,
2306
+ name,
2307
+ kind: "project",
2308
+ content: summary,
2309
+ description: `auto summary of ${action ?? "compaction"} archive`,
2310
+ provenance: {
2311
+ session_id: sessionId,
2312
+ author: "extraction",
2313
+ trust: "untrusted",
2314
+ evidence_refs: [],
2315
+ },
2316
+ created_at: now,
2317
+ updated_at: now,
2318
+ recall_count: 0,
2319
+ confidence: 0.6,
2320
+ links: [],
2321
+ pinned: false,
2322
+ }, { sessionId, agentId: this.opts.agentId });
2022
2323
  }
2023
2324
  async upgradeCompressedSummary(sessionId, compressedSeq, archived, action, runtime) {
2024
- try {
2025
- const summary = await this.opts.asyncSummarizer.summarize(archived, action);
2026
- await this.opts.sessionLog.append(sessionId, {
2027
- kind: "summary_upgraded",
2028
- compressed_seq: compressedSeq,
2029
- summary,
2325
+ const summary = await this.opts.asyncSummarizer.summarize(archived, action);
2326
+ await this.opts.sessionLog.append(sessionId, {
2327
+ kind: "summary_upgraded",
2328
+ compressed_seq: compressedSeq,
2329
+ summary,
2330
+ });
2331
+ // P4: the LLM summary also re-enters the LIVE session as a keyed page-in entry —
2332
+ // K1 boundary-deferred upsert lands it with zero mid-generation cache churn and the
2333
+ // K2 budget governs its size. The RuleSummarizer text remains the synchronous label.
2334
+ if (runtime) {
2335
+ await this.commitKernelApply(runtime, this.pendingObservations, {
2336
+ kind: "page_in",
2337
+ entries: [{
2338
+ content: `[ARCHIVE SUMMARY] ${summary}`,
2339
+ key: `summary:seq-${compressedSeq}`,
2340
+ pinned: false,
2341
+ source: "async_summarizer",
2342
+ }],
2030
2343
  });
2031
- // P4: the LLM summary also re-enters the LIVE session as a keyed page-in entry —
2032
- // K1 boundary-deferred upsert lands it with zero mid-generation cache churn and the
2033
- // K2 budget governs its size. The RuleSummarizer text remains the synchronous label.
2034
- if (runtime) {
2035
- kernelApply(runtime, this.pendingObservations, {
2036
- kind: "page_in",
2037
- entries: [{
2038
- content: `[ARCHIVE SUMMARY] ${summary}`,
2039
- key: `summary:seq-${compressedSeq}`,
2040
- pinned: false,
2041
- source: "async_summarizer",
2042
- }],
2043
- });
2044
- }
2045
- }
2046
- catch {
2047
- // non-fatal: rule-based summary stays in place
2048
2344
  }
2049
2345
  }
2050
2346
  }
@@ -2169,14 +2465,22 @@ export function replayMessages(events, maxBytes) {
2169
2465
  upgradedSummaries.set(e.compressed_seq, e.summary);
2170
2466
  }
2171
2467
  const messages = [];
2172
- for (const { seq, event: e } of events) {
2468
+ for (let eventIndex = 0; eventIndex < events.length; eventIndex++) {
2469
+ const { seq, event: e } = events[eventIndex];
2173
2470
  if (e.kind === "run_started") {
2174
2471
  const userText = e.criteria.length
2175
2472
  ? `${e.goal}\n\nCriteria:\n${e.criteria.map((c, i) => `${i + 1}. ${c}`).join("\n")}`
2176
2473
  : e.goal;
2474
+ // Multimodal parity: the live seed of `attachments` is gated behind `!resumeMidRun`, so on
2475
+ // resume the image/audio must be recovered from the persisted run_started event or it is lost.
2476
+ const attachments = (e.attachments ?? []);
2477
+ const contentParts = attachments.length
2478
+ ? [...(userText ? [{ type: "text", text: userText }] : []), ...attachments]
2479
+ : undefined;
2177
2480
  messages.push({
2178
2481
  role: "user",
2179
2482
  content: userText,
2483
+ ...(contentParts ? { contentParts } : {}),
2180
2484
  toolCalls: [],
2181
2485
  tokenCount: Math.max(1, Math.ceil(userText.length / 4)),
2182
2486
  });
@@ -2229,38 +2533,32 @@ export async function replayMessagesAsync(events, maxBytes, loadArchive) {
2229
2533
  upgradedSummaries.set(e.compressed_seq, e.summary);
2230
2534
  }
2231
2535
  const messages = [];
2232
- for (const { seq, event: e } of events) {
2536
+ for (let eventIndex = 0; eventIndex < events.length; eventIndex++) {
2537
+ const { seq, event: e } = events[eventIndex];
2233
2538
  if (e.kind === "run_started") {
2234
2539
  const userText = e.criteria.length
2235
2540
  ? `${e.goal}\n\nCriteria:\n${e.criteria.map((c, i) => `${i + 1}. ${c}`).join("\n")}`
2236
2541
  : e.goal;
2542
+ // Multimodal parity: the live seed of `attachments` is gated behind `!resumeMidRun`, so on
2543
+ // resume the image/audio must be recovered from the persisted run_started event or it is lost.
2544
+ const attachments = (e.attachments ?? []);
2545
+ const contentParts = attachments.length
2546
+ ? [...(userText ? [{ type: "text", text: userText }] : []), ...attachments]
2547
+ : undefined;
2237
2548
  messages.push({
2238
2549
  role: "user",
2239
2550
  content: userText,
2551
+ ...(contentParts ? { contentParts } : {}),
2240
2552
  toolCalls: [],
2241
2553
  tokenCount: Math.max(1, Math.ceil(userText.length / 4)),
2242
2554
  });
2243
2555
  }
2244
2556
  else if (e.kind === "compressed") {
2245
- let loadedSuccessfully = false;
2246
- if (e.archive_ref && loadArchive) {
2247
- try {
2248
- const archivedMsgs = await loadArchive(e.archive_ref);
2249
- for (const msg of archivedMsgs) {
2250
- messages.push({
2251
- role: msg.role,
2252
- content: sanitizeReplayText(msg.content, maxBytes),
2253
- toolCalls: msg.toolCalls ?? [],
2254
- tokenCount: msg.tokenCount,
2255
- });
2256
- }
2257
- loadedSuccessfully = true;
2258
- }
2259
- catch (err) {
2260
- // Loader failed (e.g. MissingArchive). We degrade and fallback.
2261
- }
2262
- }
2263
- if (!loadedSuccessfully) {
2557
+ const pageOutWillSupplyArchive = events.slice(eventIndex + 1).some(({ event }) => event.kind === "page_out"
2558
+ && event.turn === e.turn
2559
+ && typeof event.archive_ref === "string"
2560
+ && event.archive_ref.length > 0);
2561
+ if (!pageOutWillSupplyArchive) {
2264
2562
  const summary = upgradedSummaries.get(seq) ?? e.summary;
2265
2563
  if (summary) {
2266
2564
  const systemText = `[Compressed context: turn ${e.turn}]\n${summary}`;
@@ -2273,6 +2571,30 @@ export async function replayMessagesAsync(events, maxBytes, loadArchive) {
2273
2571
  }
2274
2572
  }
2275
2573
  }
2574
+ else if (e.kind === "page_out" && e.archive_ref && loadArchive) {
2575
+ try {
2576
+ const archivedMsgs = await loadArchive(e.archive_ref);
2577
+ for (const msg of archivedMsgs) {
2578
+ messages.push({
2579
+ role: msg.role,
2580
+ content: sanitizeReplayText(msg.content, maxBytes),
2581
+ toolCalls: msg.toolCalls ?? [],
2582
+ tokenCount: msg.tokenCount,
2583
+ });
2584
+ }
2585
+ }
2586
+ catch {
2587
+ if (e.summary) {
2588
+ const systemText = `[Compressed context: turn ${e.turn}]\n${e.summary}`;
2589
+ messages.push({
2590
+ role: "system",
2591
+ content: systemText,
2592
+ toolCalls: [],
2593
+ tokenCount: Math.max(1, Math.ceil(systemText.length / 4)),
2594
+ });
2595
+ }
2596
+ }
2597
+ }
2276
2598
  else if (e.kind === "llm_completed") {
2277
2599
  messages.push({
2278
2600
  role: "assistant",
@@ -2309,14 +2631,6 @@ function nextArchivedSeqStart(events) {
2309
2631
  }
2310
2632
  return next;
2311
2633
  }
2312
- function tryParseJson(s) {
2313
- try {
2314
- return JSON.parse(s);
2315
- }
2316
- catch {
2317
- return null;
2318
- }
2319
- }
2320
2634
  /** Collect all text_delta events from a run into a single string. */
2321
2635
  export async function collectText(stream) {
2322
2636
  let text = "";
@@ -2388,46 +2702,51 @@ function parseStartWorkflowSpec(argsStr) {
2388
2702
  }
2389
2703
  /** M5 v2.1: render an authored-workflow outcome into a user-message note injected back into the
2390
2704
  * agent's context, so the agent's next turn continues with the sub-workflow's results in view. */
2705
+ function recoveredOutputs(outcomes) {
2706
+ const outputs = new Map();
2707
+ for (const outcome of outcomes ?? []) {
2708
+ if (!outcome.output)
2709
+ continue;
2710
+ outputs.set(outcome.agentId, outcome.output.content);
2711
+ outputs.set(outcome.agentId.replace(/-i\d+$/, ""), outcome.output.content);
2712
+ }
2713
+ return outputs;
2714
+ }
2391
2715
  function authoredWorkflowOutcomeNote(outcome) {
2716
+ const counts = new Map();
2717
+ for (const node of outcome.nodeOutcomes)
2718
+ counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
2392
2719
  const lines = [
2393
- `[authored workflow result] ${outcome.completed.length} node(s) completed` +
2394
- (outcome.failed.length ? `, ${outcome.failed.length} failed` : "") + ".",
2720
+ `[authored workflow result] ${outcome.nodeOutcomes.length} terminal node(s): ` +
2721
+ [...counts.entries()].map(([status, count]) => `${count} ${status}`).join(", ") + ".",
2395
2722
  ];
2396
- for (const id of outcome.completed) {
2397
- const out = outcome.outputs[id];
2723
+ for (const node of outcome.nodeOutcomes) {
2724
+ const out = outcome.outputs[node.nodeId] ?? node.output?.content;
2398
2725
  if (out)
2399
- lines.push(`- ${id}: ${out.length > 500 ? out.slice(0, 500) + "…" : out}`);
2726
+ lines.push(`- ${node.nodeId} (${node.status}): ${out.length > 500 ? out.slice(0, 500) + "…" : out}`);
2400
2727
  }
2401
2728
  return lines.join("\n");
2402
2729
  }
2403
2730
  /** Lower a host `RuntimeSignal` to the kernel's snake_case `signal` input event. Shared by the main
2404
2731
  * loop's per-turn poll and #2-B-ii's workflow-batch preemption monitor (so the two never drift). */
2405
- /** Word-set jaccard similarity — the curator's dedup rule as a pure helper at the write funnel. */
2406
- function jaccardSimilarity(a, b) {
2407
- const sa = new Set(a.split(/\s+/).filter(Boolean));
2408
- const sb = new Set(b.split(/\s+/).filter(Boolean));
2409
- if (sa.size === 0 && sb.size === 0)
2410
- return 1;
2411
- let inter = 0;
2412
- for (const w of sa)
2413
- if (sb.has(w))
2414
- inter++;
2415
- const union = sa.size + sb.size - inter;
2416
- return union === 0 ? 0 : inter / union;
2417
- }
2418
- function signalToKernelEvent(sig) {
2732
+ function signalToKernelEvent(delivery) {
2733
+ const sig = delivery.signal;
2419
2734
  return {
2420
- kind: "signal",
2735
+ kind: "deliver_signal",
2736
+ delivery_id: delivery.deliveryId,
2737
+ attempt: delivery.deliveryAttempt,
2421
2738
  signal: {
2422
- id: crypto.randomUUID(),
2739
+ id: delivery.signalId,
2423
2740
  source: sig.source ?? "custom",
2424
2741
  signal_type: sig.signalType ?? "event",
2425
2742
  urgency: sig.urgency ?? "normal",
2426
- summary: String(sig.payload?.goal ?? sig.kind ?? "signal"),
2743
+ summary: String(sig.payload?.goal ?? "signal"),
2427
2744
  payload: sig.payload ?? {},
2428
2745
  ...(sig.dedupeKey ? { dedupe_key: sig.dedupeKey } : {}),
2429
2746
  ...(sig.recipient ? { recipient: sig.recipient } : {}),
2430
- ...(sig.topic ? { topic: sig.topic } : {}),
2747
+ ...(sig.deadlineMs !== undefined ? { deadline_ms: sig.deadlineMs } : {}),
2748
+ ...(sig.coalesceKey ? { coalesce_key: sig.coalesceKey } : {}),
2749
+ coalesced_count: Math.max(1, sig.coalescedCount ?? 1),
2431
2750
  timestamp_ms: Date.now(),
2432
2751
  },
2433
2752
  };