@deepstrike/wasm 0.2.50 → 0.2.51

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 (37) hide show
  1. package/README.md +2 -1
  2. package/dist/index.d.ts +4 -3
  3. package/dist/index.js +3 -2
  4. package/dist/runtime/canonical-kernel-step.d.ts +146 -0
  5. package/dist/runtime/canonical-kernel-step.js +1484 -0
  6. package/dist/runtime/execution-plane.d.ts +0 -3
  7. package/dist/runtime/execution-plane.js +0 -25
  8. package/dist/runtime/facade.js +3 -0
  9. package/dist/runtime/index.d.ts +7 -7
  10. package/dist/runtime/index.js +4 -3
  11. package/dist/runtime/kernel-event-log.d.ts +0 -1
  12. package/dist/runtime/kernel-event-log.js +7 -13
  13. package/dist/runtime/kernel-journal.d.ts +332 -0
  14. package/dist/runtime/kernel-journal.js +681 -0
  15. package/dist/runtime/kernel-step.d.ts +34 -89
  16. package/dist/runtime/kernel-step.js +67 -167
  17. package/dist/runtime/os-snapshot.d.ts +2 -2
  18. package/dist/runtime/os-snapshot.js +2 -6
  19. package/dist/runtime/payload-store.d.ts +30 -0
  20. package/dist/runtime/payload-store.js +66 -0
  21. package/dist/runtime/runner.d.ts +27 -75
  22. package/dist/runtime/runner.js +408 -632
  23. package/dist/runtime/session-log.d.ts +24 -27
  24. package/dist/runtime/session-log.js +11 -52
  25. package/dist/runtime/session-repair.d.ts +2 -36
  26. package/dist/runtime/session-repair.js +2 -47
  27. package/dist/runtime/sub-agent-orchestrator.d.ts +2 -2
  28. package/dist/runtime/sub-agent-orchestrator.js +36 -40
  29. package/dist/runtime/types/agent.d.ts +13 -16
  30. package/dist/runtime/types/agent.js +16 -41
  31. package/package.json +4 -3
  32. package/dist/runtime/kernel-rebuild.d.ts +0 -8
  33. package/dist/runtime/kernel-rebuild.js +0 -65
  34. package/dist/runtime/kernel-transaction-log.d.ts +0 -61
  35. package/dist/runtime/kernel-transaction-log.js +0 -140
  36. package/dist/runtime/large-result-spool.d.ts +0 -57
  37. package/dist/runtime/large-result-spool.js +0 -169
@@ -2,19 +2,20 @@ import { extractSessionMemories } from "../memory/extraction.js";
2
2
  import { resolvePermissionRequest } from "./execution-plane.js";
3
3
  import { governancePolicyToKernelEvent, governanceFilterSchema } from "../governance.js";
4
4
  import { getKernel } from "./kernel.js";
5
+ import { CanonicalRunnerRuntime, canonicalKernelAction, canonicalKernelApply, canonicalKernelMaybeAction, canonicalStartAgent, canonicalStartWorkflow, sha256 as canonicalSha256, } from "./canonical-kernel-step.js";
5
6
  import { peekProviderReplay, seedProviderReplayFromEvents } from "./provider-replay.js";
6
7
  import { sanitizeReplayText } from "./replay-sanitize.js";
7
8
  import { formatToolError } from "../tools/errors.js";
8
- import { buildLlmCompletedEvent, buildRunTerminalEvent, buildWorkflowNodeCompletedEvent, buildWorkflowNodesSubmittedEvent, recoverWorkflowNodeOutcomes, recoverSubmittedWorkflowNodes, repairEventsForRecovery, } from "./session-repair.js";
9
- import { kernelAction, kernelApply, kernelMaybeAction, 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";
9
+ import { buildLlmCompletedEvent, buildRunTerminalEvent, buildWorkflowNodeCompletedEvent, buildWorkflowNodesSubmittedEvent, } from "./session-repair.js";
10
+ import { messageToKernelMessage, skillMetadataToKernel, taskUpdateToKernel, toolResultToKernel, toolSchemaToKernel, } from "./kernel-step.js";
11
+ import { agentRunSpecToKernel, MILESTONE_UNVERIFIED_REASON, milestoneCheckFail, milestoneCheckPass, milestoneCheckResultToKernel, subAgentResultToKernel, workflowBudgetNote, workflowNodeSpecToKernel, workflowNodeOutcomeFromKernel, workflowNodeStatusFromTermination, workflowNodeToManifest, workflowNodeToSpec, workflowSpecToKernel, } from "./types/agent.js";
11
12
  import { defaultSubAgentOrchestrator } from "./sub-agent-orchestrator.js";
12
13
  import { extractJsonValue, schemaInstruction, schemaRetryInstruction, validateAgainstSchema, } from "./output-schema.js";
13
14
  import { resolveReducer } from "./reducers.js";
14
15
  import { loopInstruction, classifyInstruction, judgeGoal, dependencyOutputsNote, extractLoopContinue, extractClassifyBranch, extractJudgeWinner, } from "./workflow-control-flow.js";
15
16
  import { kernelObservationToSessionEvent } from "./kernel-event-log.js";
16
17
  import { assertNativeProfile } from "./os-profile.js";
17
- import { LargeResultSpool } from "./large-result-spool.js";
18
+ import { PayloadStore } from "./payload-store.js";
18
19
  import { contextPolicyV1, normalizeContextPolicyV1, } from "./context-policy.js";
19
20
  export function schedulerPolicyToKernel(policy) {
20
21
  const allowed = new Set([
@@ -31,6 +32,42 @@ export function schedulerPolicyToKernel(policy) {
31
32
  token_cost_weight: policy.tokenCostWeight,
32
33
  };
33
34
  }
35
+ function kernelReliabilityToKernel(policy) {
36
+ const allowed = new Set(["providerRecoveryAttempts", "outputRecoveryAttempts", "maxInputBytes"]);
37
+ const unknown = Object.keys(policy).filter(key => !allowed.has(key));
38
+ if (unknown.length > 0) {
39
+ throw new TypeError(`unknown kernel reliability field(s): ${unknown.join(", ")}`);
40
+ }
41
+ return {
42
+ ...(policy.providerRecoveryAttempts !== undefined
43
+ ? { provider_recovery_attempts: policy.providerRecoveryAttempts }
44
+ : {}),
45
+ ...(policy.outputRecoveryAttempts !== undefined
46
+ ? { output_recovery_attempts: policy.outputRecoveryAttempts }
47
+ : {}),
48
+ ...(policy.maxInputBytes !== undefined ? { max_input_bytes: policy.maxInputBytes } : {}),
49
+ };
50
+ }
51
+ function memoryPolicyToKernel(policy) {
52
+ const allowed = new Set([
53
+ "staleWarningDays",
54
+ "retrievalTopK",
55
+ "validationEnabled",
56
+ "maxContentBytes",
57
+ "maxNameLength",
58
+ ]);
59
+ const unknown = Object.keys(policy).filter(key => !allowed.has(key));
60
+ if (unknown.length > 0) {
61
+ throw new TypeError(`unknown memory policy field(s): ${unknown.join(", ")}`);
62
+ }
63
+ return {
64
+ ...(policy.staleWarningDays !== undefined ? { stale_warning_days: policy.staleWarningDays } : {}),
65
+ ...(policy.retrievalTopK !== undefined ? { retrieval_top_k: policy.retrievalTopK } : {}),
66
+ ...(policy.validationEnabled !== undefined ? { validation_enabled: policy.validationEnabled } : {}),
67
+ ...(policy.maxContentBytes !== undefined ? { max_content_bytes: policy.maxContentBytes } : {}),
68
+ ...(policy.maxNameLength !== undefined ? { max_name_length: policy.maxNameLength } : {}),
69
+ };
70
+ }
34
71
  function pendingCallIds(action) {
35
72
  switch (action.kind) {
36
73
  case "call_provider": return [action.effectId];
@@ -61,6 +98,7 @@ export class RuntimeRunner {
61
98
  pendingObservations = [];
62
99
  activeKernel = null;
63
100
  currentSessionId = null;
101
+ fallbackPayloadStore = null;
64
102
  /** O2 (system-reminder channel): host-pushed notes awaiting the next turn-boundary drain. */
65
103
  injectedSignals = [];
66
104
  /** Skill names whose content has already been pushed into the durable `knowledge` slot this
@@ -74,9 +112,7 @@ export class RuntimeRunner {
74
112
  nextArchiveStart = 0;
75
113
  pendingPageOutArchives = [];
76
114
  activePageOutArchive;
77
- /** M5 v2.1: sub-workflow specs a top-level agent authored via `start_workflow`, awaiting auto-drive
78
- * at the next safe point (after the tool turn resolves, kernel back in Reason). */
79
- pendingAuthoredWorkflows = [];
115
+ /** Provider continuation emitted after a canonical nested workflow completes. */
80
116
  workflowContinuation = null;
81
117
  constructor(opts) {
82
118
  this.opts = opts;
@@ -85,6 +121,44 @@ export class RuntimeRunner {
85
121
  throw new RangeError("workflowSchemaValidationAttempts must be an integer between 1 and 16");
86
122
  }
87
123
  }
124
+ resolveKernelJournal() {
125
+ const embedded = this.opts.sessionLog;
126
+ const journal = this.opts.kernelJournal ?? embedded.kernelJournal;
127
+ if (!journal) {
128
+ throw new Error("RuntimeOptions.kernelJournal is required when SessionLog has no canonical journal");
129
+ }
130
+ return journal;
131
+ }
132
+ async createCanonicalRuntime(runId = crypto.randomUUID(), sessionId = this.currentSessionId ?? "wasm-session") {
133
+ const { CanonicalKernel } = await getKernel();
134
+ return new CanonicalRunnerRuntime(new CanonicalKernel(), this.resolveKernelJournal(), `wasm-operation-${runId}`, {
135
+ maxContextTokens: this.opts.maxTokens,
136
+ maxTurns: this.opts.maxTurns,
137
+ maxTotalTokens: this.opts.maxTotalTokens,
138
+ maxWallMs: this.opts.timeoutMs,
139
+ memoryBindingId: `wasm-memory-${this.opts.agentId ?? "root"}`,
140
+ persistPayload: async (_callId, content, previewBytes) => {
141
+ const digest = canonicalSha256(content);
142
+ const payloadRef = `payload:${digest.replace(/^sha256:/, "").slice(0, 32)}`;
143
+ await this.payloadStore().persistPayload(sessionId, payloadRef, content);
144
+ return {
145
+ payloadRef,
146
+ digest,
147
+ originalSize: String(new TextEncoder().encode(content).byteLength),
148
+ preview: new TextDecoder().decode(new TextEncoder().encode(content).slice(0, previewBytes)),
149
+ };
150
+ },
151
+ });
152
+ }
153
+ async commitKernelApply(runtime, pending, event) {
154
+ return canonicalKernelApply(runtime, pending, event);
155
+ }
156
+ async commitKernelMaybeAction(runtime, pending, event) {
157
+ return canonicalKernelMaybeAction(runtime, pending, event);
158
+ }
159
+ async commitKernelAction(runtime, pending, event) {
160
+ return canonicalKernelAction(runtime, pending, event);
161
+ }
88
162
  get hostOptions() { return this.opts; }
89
163
  interrupt(reason = "user") {
90
164
  this.interrupted = true;
@@ -139,7 +213,7 @@ export class RuntimeRunner {
139
213
  async consumeInboundSignal(delivery, consume) {
140
214
  try {
141
215
  const observationStart = this.pendingObservations.length;
142
- const result = consume(delivery);
216
+ const result = await consume(delivery);
143
217
  const dispositions = this.pendingObservations.slice(observationStart).filter(observation => observation.kind === "signal_delivery_disposed"
144
218
  && observation.delivery_id === delivery.deliveryId
145
219
  && observation.attempt === delivery.deliveryAttempt);
@@ -157,17 +231,32 @@ export class RuntimeRunner {
157
231
  }
158
232
  async *run(req) {
159
233
  const prior = req.inheritEvents ?? await this.opts.sessionLog.read(req.sessionId);
160
- const midRun = isMidRun(prior);
234
+ const resumedStart = [...prior].reverse().find(entry => entry.event.kind === "run_started");
235
+ // Inherited parent events are transcript input for a fresh child operation, never recovery
236
+ // evidence for the child's own canonical journal.
237
+ let midRun = req.inheritEvents ? false : isMidRun(prior);
238
+ if (!midRun && resumedStart?.event.kind === "run_started" && !req.inheritEvents) {
239
+ const operationId = `wasm-operation-${resumedStart.event.run_id}`;
240
+ if (await this.resolveKernelJournal().head(operationId)) {
241
+ const authoritative = await this.createCanonicalRuntime(resumedStart.event.run_id, req.sessionId);
242
+ await authoritative.restore();
243
+ if (!authoritative.isTerminal())
244
+ midRun = true;
245
+ }
246
+ }
161
247
  // Idempotent per session: an earlier run's `run_started` already carries these attachments
162
248
  // (same-session retry attempt), so replay reconstructs them — recording and seeding again
163
249
  // would double them in history. Deduping at the append keeps live and replay in agreement.
164
250
  const attachments = req.attachments?.length && !attachmentsAlreadySeeded(prior, req.attachments)
165
251
  ? req.attachments
166
252
  : undefined;
253
+ const runId = midRun && resumedStart?.event.kind === "run_started"
254
+ ? resumedStart.event.run_id
255
+ : crypto.randomUUID();
167
256
  if (!midRun) {
168
257
  await this.opts.sessionLog.append(req.sessionId, {
169
258
  kind: "run_started",
170
- run_id: crypto.randomUUID(),
259
+ run_id: runId,
171
260
  goal: req.goal,
172
261
  criteria: req.criteria ?? [],
173
262
  agent_id: this.opts.agentId,
@@ -175,46 +264,42 @@ export class RuntimeRunner {
175
264
  ...(attachments ? { attachments } : {}),
176
265
  });
177
266
  }
178
- yield* this.execute(req.sessionId, req.goal, req.criteria ?? [], req.extensions, prior.length > 0 ? prior : undefined, midRun, attachments);
267
+ yield* this.execute(req.sessionId, runId, req.goal, req.criteria ?? [], req.extensions, prior.length > 0 ? prior : undefined, midRun, attachments);
179
268
  }
180
269
  async *wake(sessionId, extensions) {
181
270
  const events = await this.opts.sessionLog.read(sessionId);
182
- if (events.some(e => e.event.kind === "run_terminal"))
183
- return;
184
- const startEntry = [...events].reverse().find(e => e.event.kind === "run_started");
271
+ const startIndex = events.reduce((latest, entry, index) => entry.event.kind === "run_started" ? index : latest, -1);
272
+ const startEntry = startIndex >= 0 ? events[startIndex] : undefined;
185
273
  if (!startEntry)
186
274
  throw new Error(`No run_started event for session: ${sessionId}`);
187
275
  const start = startEntry.event;
188
- yield* this.execute(sessionId, start.goal, start.criteria, extensions, events, true, start.attachments);
276
+ const journalHead = await this.resolveKernelJournal().head(`wasm-operation-${start.run_id}`);
277
+ if (!journalHead) {
278
+ const projectedTail = events.slice(startIndex + 1);
279
+ if (projectedTail.some(e => e.event.kind === "run_terminal")) {
280
+ throw new Error("run_terminal projection has no canonical journal");
281
+ }
282
+ if (projectedTail.some(entry => entry.event.kind === "tool_requested" || entry.event.kind === "tool_completed")) {
283
+ throw new Error("restored canonical operation has no pending effect or terminal");
284
+ }
285
+ }
286
+ else {
287
+ const authoritative = await this.createCanonicalRuntime(start.run_id, sessionId);
288
+ await authoritative.restore();
289
+ if (authoritative.isTerminal())
290
+ return;
291
+ }
292
+ yield* this.execute(sessionId, start.run_id, start.goal, start.criteria, extensions, events, true, start.attachments);
189
293
  }
190
294
  async writeMemory(memory, sessionId) {
191
295
  if (!this.opts.dreamStore || !this.opts.agentId)
192
296
  return;
193
- const kernel = await getKernel();
194
- const runtime = new kernel.KernelRuntime({ maxTokens: this.opts.maxTokens, maxTurns: this.opts.maxTurns ?? 25 });
195
- const observations = [];
196
- const action = kernelMaybeAction(runtime, observations, { kind: "write_memory", memory });
197
- if (!action) {
198
- await this.appendMemorySyscallObservations(sessionId, observations);
199
- return;
200
- }
201
- if (action.kind !== "persist_memory")
202
- throw new Error(`write_memory returned unexpected kernel effect: ${action.kind}`);
203
- let error;
204
297
  try {
205
- await this.opts.dreamStore.upsert(this.opts.agentId, action.memory);
298
+ await this.opts.dreamStore.upsert(this.opts.agentId, memory);
206
299
  }
207
300
  catch (cause) {
208
- error = formatToolError(cause);
301
+ throw new Error(formatToolError(cause));
209
302
  }
210
- kernelApply(runtime, observations, {
211
- kind: "memory_persist_result",
212
- effect_id: action.effectId,
213
- ...(error ? { error } : {}),
214
- });
215
- await this.appendMemorySyscallObservations(sessionId, observations);
216
- if (error)
217
- throw new Error(error);
218
303
  }
219
304
  async appendMemorySyscallObservations(sessionId, observations) {
220
305
  if (!sessionId)
@@ -231,10 +316,10 @@ export class RuntimeRunner {
231
316
  * K1: `opts.key` gives the entry identity — a same-key push upserts (applied at the next
232
317
  * compaction/renewal boundary) instead of appending a duplicate. `opts.pinned` exempts the
233
318
  * entry from the knowledge-budget sweep. */
234
- pushKnowledge(message, tokens, opts) {
319
+ async pushKnowledge(message, tokens, opts) {
235
320
  if (!this.activeKernel)
236
321
  return;
237
- kernelApply(this.activeKernel, this.pendingObservations, {
322
+ await this.commitKernelApply(this.activeKernel, this.pendingObservations, {
238
323
  kind: "add_knowledge_message",
239
324
  content: message.content ?? "",
240
325
  tokens: tokens ?? Math.max(1, Math.ceil((message.content?.length ?? 0) / 4)),
@@ -242,19 +327,25 @@ export class RuntimeRunner {
242
327
  ...(opts?.pinned ? { pinned: true } : {}),
243
328
  });
244
329
  }
330
+ payloadStore() {
331
+ if (this.opts.payloadStore)
332
+ return this.opts.payloadStore;
333
+ this.fallbackPayloadStore ??= new PayloadStore();
334
+ return this.fallbackPayloadStore;
335
+ }
245
336
  /** K1: mark a keyed knowledge entry for removal at the next compaction/renewal boundary.
246
337
  * Errs-open: an unknown key is a kernel-side no-op. */
247
- removeKnowledge(key) {
338
+ async removeKnowledge(key) {
248
339
  if (!this.activeKernel)
249
340
  return;
250
- kernelApply(this.activeKernel, this.pendingObservations, { kind: "remove_knowledge", key });
341
+ await this.commitKernelApply(this.activeKernel, this.pendingObservations, { kind: "remove_knowledge", key });
251
342
  }
252
343
  /** K3: host-driven skill deactivation — toolset re-widens at the next provider call, the
253
344
  * skill's knowledge pin drops at the next boundary. Errs-open: not-active is a no-op. */
254
- deactivateSkill(name) {
345
+ async deactivateSkill(name) {
255
346
  if (!this.activeKernel)
256
347
  return;
257
- kernelApply(this.activeKernel, this.pendingObservations, { kind: "skill_deactivated", name });
348
+ await this.commitKernelApply(this.activeKernel, this.pendingObservations, { kind: "skill_deactivated", name });
258
349
  this.knowledgePushedSkills.delete(name);
259
350
  }
260
351
  async resolveKernelSuspend(requests, runtime, sessionId) {
@@ -334,63 +425,7 @@ export class RuntimeRunner {
334
425
  }
335
426
  return { approved, denied, events };
336
427
  }
337
- /**
338
- * O7: resolve a `read_result` meta-tool call to the full text of a previously-evicted tool
339
- * output. Resolution order: (a) the result spool committed by `large_result_spool_result`,
340
- * (b) a session-log scan for the original `tool_completed` event carrying that `call_id`.
341
- * Slices the resolved text by `[offset, offset + maxBytes)` (plain string slice — "bytes-ish").
342
- */
343
- async resolveReadResult(sessionId, argsJson) {
344
- let callId = "";
345
- let offset = 0;
346
- let maxBytes = 4000;
347
- try {
348
- const args = JSON.parse(argsJson || "{}");
349
- callId = typeof args.call_id === "string" ? args.call_id : "";
350
- if (typeof args.offset === "number" && Number.isFinite(args.offset))
351
- offset = args.offset;
352
- if (typeof args.max_bytes === "number" && Number.isFinite(args.max_bytes))
353
- maxBytes = args.max_bytes;
354
- }
355
- catch {
356
- // malformed arguments — callId stays empty, falls through to "not found" below
357
- }
358
- let full;
359
- if (full === undefined && this.opts.resultSpool) {
360
- try {
361
- full = await this.opts.resultSpool.findByCallId(sessionId, callId);
362
- }
363
- catch {
364
- full = undefined;
365
- }
366
- }
367
- if (full === undefined) {
368
- try {
369
- const events = await this.opts.sessionLog.read(sessionId);
370
- for (const { event } of events) {
371
- if (event.kind !== "tool_completed")
372
- continue;
373
- const match = event.results.find(r => r.call_id === callId);
374
- if (match)
375
- full = match.output;
376
- }
377
- }
378
- catch {
379
- full = undefined;
380
- }
381
- }
382
- if (full === undefined) {
383
- return { text: `no stored output for call_id "${callId}"`, isError: true };
384
- }
385
- const start = Math.max(0, offset);
386
- const end = Math.min(full.length, start + Math.max(0, maxBytes));
387
- const slice = full.slice(start, end);
388
- return {
389
- text: `[read_result ${callId}: chars ${start}–${end} of ${full.length}]\n${slice}`,
390
- isError: false,
391
- };
392
- }
393
- async *execute(sessionId, goal, criteria, extensions, priorEvents, resumeMidRun = false, attachments) {
428
+ async *execute(sessionId, runId, goal, criteria, extensions, priorEvents, resumeMidRun = false, attachments) {
394
429
  this.interrupted = false;
395
430
  this.cancellationReason = undefined;
396
431
  this.abortController = new AbortController();
@@ -398,147 +433,137 @@ export class RuntimeRunner {
398
433
  this.pendingPageOutArchives = [];
399
434
  this.activePageOutArchive = undefined;
400
435
  this.currentSessionId = sessionId;
401
- const kernel = await getKernel();
402
436
  const ext = { ...this.opts.extensions, ...(extensions ?? {}) };
403
437
  const providerState = this.opts.provider.createRunState?.();
404
438
  let nextCompressedArchiveStart = nextArchivedSeqStart(priorEvents);
405
439
  const providerPolicy = this.opts.provider.runtimePolicy?.() ?? {};
406
440
  const effectiveMaxTurns = this.opts.maxTurns ?? providerPolicy.maxTurns ?? 25;
407
441
  const effectiveTimeoutMs = this.opts.timeoutMs ?? providerPolicy.timeoutMs;
408
- const runtime = new kernel.KernelRuntime({
409
- maxTokens: this.opts.maxTokens,
410
- maxTurns: effectiveMaxTurns,
411
- // M4/G5: per-node token cap → child run's cumulative token budget (wasm LoopPolicy.maxTotalTokens is f64).
412
- ...(this.opts.maxTotalTokens !== undefined ? { maxTotalTokens: this.opts.maxTotalTokens } : {}),
413
- timeoutMs: effectiveTimeoutMs,
414
- });
442
+ const runtime = await this.createCanonicalRuntime(runId, sessionId);
443
+ if (resumeMidRun)
444
+ await runtime.restore();
415
445
  this.activeKernel = runtime;
416
- if (this.opts.tokenizer) {
417
- kernelApply(runtime, this.pendingObservations, {
418
- kind: "set_tokenizer",
419
- name: this.opts.tokenizer,
420
- });
421
- }
422
- if (this.opts.enablePlanTool !== undefined) {
423
- kernelApply(runtime, this.pendingObservations, {
424
- kind: "set_plan_tool_enabled",
425
- enabled: this.opts.enablePlanTool,
426
- });
427
- }
428
- kernelApply(runtime, this.pendingObservations, {
429
- kind: "set_tools",
430
- tools: this.opts.executionPlane.schemas().map(toolSchemaToKernel),
431
- });
432
- if (this.opts.systemPrompt) {
433
- kernelApply(runtime, this.pendingObservations, {
434
- kind: "add_system_message",
435
- content: this.opts.systemPrompt,
436
- tokens: Math.max(1, Math.ceil(this.opts.systemPrompt.length / 4)),
437
- });
438
- }
439
- if (this.opts.initialMemory) {
440
- for (const mem of this.opts.initialMemory) {
441
- kernelApply(runtime, this.pendingObservations, {
442
- kind: "add_knowledge_message",
443
- content: mem,
444
- tokens: Math.max(1, Math.ceil(mem.length / 4)),
446
+ if (!resumeMidRun) {
447
+ if (this.opts.tokenizer) {
448
+ await this.commitKernelApply(runtime, this.pendingObservations, {
449
+ kind: "set_tokenizer",
450
+ name: this.opts.tokenizer,
445
451
  });
446
452
  }
447
- }
448
- if (this.opts.skillContentMap && this.opts.skillContentMap.size > 0) {
449
- const metas = [...this.opts.skillContentMap.keys()].map(name => ({
450
- name,
451
- description: "",
452
- estimatedTokens: 0,
453
- }));
454
- kernelApply(runtime, this.pendingObservations, {
455
- kind: "set_available_skills",
456
- skills: metas.map(skillMetadataToKernel),
457
- });
458
- }
459
- // P1-B/D: configure stable-core tool ids (always exposed under skill gating).
460
- if (this.opts.stableCoreToolIds?.length) {
461
- kernelApply(runtime, this.pendingObservations, {
462
- kind: "set_stable_core_tools",
463
- tool_ids: this.opts.stableCoreToolIds,
464
- });
465
- }
466
- if (this.opts.dreamStore && this.opts.agentId) {
467
- kernelApply(runtime, this.pendingObservations, { kind: "set_memory_enabled", enabled: true });
468
- }
469
- if (this.opts.knowledgeSource) {
470
- kernelApply(runtime, this.pendingObservations, { kind: "set_knowledge_enabled", enabled: true });
471
- }
472
- if (this.opts.milestoneContract) {
473
- kernelApply(runtime, this.pendingObservations, {
474
- kind: "load_milestone_contract",
475
- contract: {
476
- phases: this.opts.milestoneContract.phases.map(p => ({
477
- id: p.id,
478
- criteria: p.criteria ?? [],
479
- unlocks: p.unlocks ?? [],
480
- verifier: p.verifier ?? null,
481
- required_evidence: p.requiredEvidence ?? [],
482
- })),
483
- },
484
- });
485
- }
486
- const maxBytes = runtime.recoveryContentBytes();
487
- if (priorEvents && priorEvents.length > 0) {
488
- const repaired = repairEventsForRecovery(priorEvents, maxBytes);
489
- seedProviderReplayFromEvents(this.opts.provider, repaired);
490
- const replayed = await replayMessages(repaired, maxBytes, this.opts.compressionStore);
491
- kernelApply(runtime, this.pendingObservations, {
492
- kind: "preload_history",
493
- messages: replayed.map(messageToKernelMessage),
453
+ if (this.opts.enablePlanTool !== undefined) {
454
+ await this.commitKernelApply(runtime, this.pendingObservations, {
455
+ kind: "set_plan_tool_enabled",
456
+ enabled: this.opts.enablePlanTool,
457
+ });
458
+ }
459
+ await this.commitKernelApply(runtime, this.pendingObservations, {
460
+ kind: "set_tools",
461
+ tools: this.opts.executionPlane.schemas().map(toolSchemaToKernel),
494
462
  });
495
- // P1-B B3: rebuild active-skill gating after a wake (active_skills is not snapshotted).
496
- // `knowledge` isn't snapshotted either (same graceful-reset philosophy) — best-effort re-push
497
- // the skill's content from its replayed tool_result so the durable copy survives a wake too.
498
- const toolResultByCallId = new Map();
499
- for (const m of replayed) {
500
- for (const part of m.contentParts ?? []) {
501
- if (part.type === "tool_result" && part.callId && part.output !== undefined) {
502
- toolResultByCallId.set(part.callId, part.output);
503
- }
463
+ if (this.opts.systemPrompt) {
464
+ await this.commitKernelApply(runtime, this.pendingObservations, {
465
+ kind: "add_system_message",
466
+ content: this.opts.systemPrompt,
467
+ tokens: Math.max(1, Math.ceil(this.opts.systemPrompt.length / 4)),
468
+ });
469
+ }
470
+ if (this.opts.initialMemory) {
471
+ for (const mem of this.opts.initialMemory) {
472
+ await this.commitKernelApply(runtime, this.pendingObservations, {
473
+ kind: "add_knowledge_message",
474
+ content: mem,
475
+ tokens: Math.max(1, Math.ceil(mem.length / 4)),
476
+ });
504
477
  }
505
478
  }
506
- for (const m of replayed) {
507
- for (const tc of m.toolCalls ?? []) {
508
- if (tc.name !== "skill")
509
- continue;
510
- try {
511
- const name = JSON.parse(tc.arguments || "{}").name;
512
- if (!name)
479
+ if (this.opts.skillContentMap && this.opts.skillContentMap.size > 0) {
480
+ const metas = [...this.opts.skillContentMap.keys()].map(name => ({
481
+ name,
482
+ description: "",
483
+ estimatedTokens: 0,
484
+ }));
485
+ await this.commitKernelApply(runtime, this.pendingObservations, {
486
+ kind: "set_available_skills",
487
+ skills: metas.map(skillMetadataToKernel),
488
+ });
489
+ }
490
+ // P1-B/D: configure stable-core tool ids (always exposed under skill gating).
491
+ if (this.opts.stableCoreToolIds?.length) {
492
+ await this.commitKernelApply(runtime, this.pendingObservations, {
493
+ kind: "set_stable_core_tools",
494
+ tool_ids: this.opts.stableCoreToolIds,
495
+ });
496
+ }
497
+ if (this.opts.dreamStore && this.opts.agentId) {
498
+ await this.commitKernelApply(runtime, this.pendingObservations, { kind: "set_memory_enabled", enabled: true });
499
+ }
500
+ if (this.opts.knowledgeSource) {
501
+ await this.commitKernelApply(runtime, this.pendingObservations, { kind: "set_knowledge_enabled", enabled: true });
502
+ }
503
+ if (this.opts.milestoneContract) {
504
+ await this.commitKernelApply(runtime, this.pendingObservations, {
505
+ kind: "load_milestone_contract",
506
+ contract: {
507
+ phases: this.opts.milestoneContract.phases.map(p => ({
508
+ id: p.id,
509
+ criteria: p.criteria ?? [],
510
+ unlocks: p.unlocks ?? [],
511
+ verifier: p.verifier ?? null,
512
+ required_evidence: p.requiredEvidence ?? [],
513
+ })),
514
+ },
515
+ });
516
+ }
517
+ const maxBytes = runtime.recoveryContentBytes();
518
+ if (priorEvents && priorEvents.length > 0) {
519
+ const replayed = await replayMessages(priorEvents, maxBytes, this.opts.compressionStore);
520
+ await this.commitKernelApply(runtime, this.pendingObservations, {
521
+ kind: "preload_history",
522
+ messages: replayed.map(messageToKernelMessage),
523
+ });
524
+ // Re-pin replayed skill content into knowledge for the fresh operation. Activation itself is
525
+ // owned by canonical provider-result reduction; the host must not assert it separately.
526
+ const toolResultByCallId = new Map();
527
+ for (const m of replayed) {
528
+ for (const part of m.contentParts ?? []) {
529
+ if (part.type === "tool_result" && part.callId && part.output !== undefined) {
530
+ toolResultByCallId.set(part.callId, part.output);
531
+ }
532
+ }
533
+ }
534
+ for (const m of replayed) {
535
+ for (const tc of m.toolCalls ?? []) {
536
+ if (tc.name !== "skill")
513
537
  continue;
514
- kernelApply(runtime, this.pendingObservations, {
515
- kind: "skill_activated",
516
- name,
517
- ...(this.opts.skillLeaseTurns !== undefined ? { lease_turns: this.opts.skillLeaseTurns } : {}),
518
- });
519
- const output = toolResultByCallId.get(tc.id);
520
- if (output && !this.knowledgePushedSkills.has(name)) {
521
- this.knowledgePushedSkills.add(name);
522
- // K1: keyed — the kernel-side upsert is the authoritative dedup across wake replays.
523
- this.pushKnowledge({ role: "system", content: output }, undefined, { key: `skill:${name}` });
538
+ try {
539
+ const name = JSON.parse(tc.arguments || "{}").name;
540
+ if (!name)
541
+ continue;
542
+ const output = toolResultByCallId.get(tc.id);
543
+ if (output && !this.knowledgePushedSkills.has(name)) {
544
+ this.knowledgePushedSkills.add(name);
545
+ // K1: keyed — the kernel-side upsert is the authoritative dedup across wake replays.
546
+ this.pushKnowledge({ role: "system", content: output }, undefined, { key: `skill:${name}` });
547
+ }
524
548
  }
549
+ catch { /* skip */ }
525
550
  }
526
- catch { /* skip */ }
527
551
  }
528
552
  }
553
+ // Multimodal upload: seed attachments before the canonical root start (parity with Node/Python).
554
+ if (!resumeMidRun && attachments?.length) {
555
+ await this.commitKernelApply(runtime, this.pendingObservations, {
556
+ kind: "add_history_message",
557
+ message: attachmentsToKernelMessage(attachments),
558
+ });
559
+ }
529
560
  }
530
- // Multimodal upload: seed attachments before start_run (parity with Node/Python).
531
- if (!resumeMidRun && attachments?.length) {
532
- kernelApply(runtime, this.pendingObservations, {
533
- kind: "add_history_message",
534
- message: attachmentsToKernelMessage(attachments),
535
- });
561
+ if (priorEvents && priorEvents.length > 0) {
562
+ seedProviderReplayFromEvents(this.opts.provider, priorEvents);
536
563
  }
537
564
  const sessionStart = Date.now();
538
- const startPayload = {
539
- kind: "start_run",
540
- task: { goal, criteria },
541
- };
565
+ const startTask = { goal, criteria };
566
+ let startRunSpec;
542
567
  // P0-A: lower an explicit `runSpec`, the `allowedToolIds` ceiling, and/or the `baselineToolIds`
543
568
  // pre-activation surface to the kernel run spec (reuses the existing run_spec wire — no new
544
569
  // ABI). Unset on all ⇒ no gating (铁律: no config = old behavior).
@@ -559,9 +584,10 @@ export class RuntimeRunner {
559
584
  : baseSpec;
560
585
  if (hasBaseline)
561
586
  spec = { ...spec, exposureBaseline: baselineToolIds };
562
- startPayload.run_spec = agentRunSpecToKernel(spec);
587
+ startRunSpec = agentRunSpecToKernel(spec);
563
588
  }
564
- this.applyKernelPolicies(runtime);
589
+ if (!resumeMidRun)
590
+ await this.applyKernelPolicies(runtime);
565
591
  // I4: pre-fetch memory before the first LLM turn (mirrors Node). Strict dynamic context
566
592
  // control: single-use retrieval content, not a stable skill — lands in `history` like an
567
593
  // ordinary `memory` tool result, so it decays with the compression pyramid instead of
@@ -571,8 +597,8 @@ export class RuntimeRunner {
571
597
  await this.prefetchMemoryIntoHistory(runtime, "initial");
572
598
  }
573
599
  let action = resumeMidRun
574
- ? kernelAction(runtime, this.pendingObservations, { kind: "resume" })
575
- : kernelAction(runtime, this.pendingObservations, startPayload);
600
+ ? runtime.resumeAction() ?? (() => { throw new Error("restored canonical operation has no pending effect or terminal"); })()
601
+ : await canonicalStartAgent(runtime, this.pendingObservations, startTask, startRunSpec);
576
602
  // P0-C: the skill loaded and in effect going into the current turn → per-turn `activeSkill` metric.
577
603
  let activeSkill;
578
604
  // I0b: kernel-throw safety net — see Node runner for full rationale.
@@ -580,7 +606,7 @@ export class RuntimeRunner {
580
606
  while (!runtime.isTerminal()) {
581
607
  nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart);
582
608
  if (this.interrupted) {
583
- action = kernelAction(runtime, this.pendingObservations, {
609
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
584
610
  kind: "cancel_operation",
585
611
  reason: this.cancellationReason ?? "user",
586
612
  pending_call_ids: pendingCallIds(action),
@@ -590,7 +616,7 @@ export class RuntimeRunner {
590
616
  if (this.opts.signalSource || this.injectedSignals.length > 0) {
591
617
  const delivery = await this.nextInboundSignal();
592
618
  if (delivery) {
593
- const sigAction = await this.consumeInboundSignal(delivery, claimed => kernelMaybeAction(runtime, this.pendingObservations, signalToKernelEvent(claimed)));
619
+ const sigAction = await this.consumeInboundSignal(delivery, claimed => this.commitKernelMaybeAction(runtime, this.pendingObservations, signalToKernelEvent(claimed)));
594
620
  if (sigAction)
595
621
  action = sigAction;
596
622
  // Critical attention/preemption is distinct from operation cancellation.
@@ -599,12 +625,6 @@ export class RuntimeRunner {
599
625
  if (runtime.isTerminal())
600
626
  break;
601
627
  if (action.kind === "call_provider") {
602
- // M5 v2.1: top-level auto-pivot at the safe point (kernel in Reason, not suspended). Loop-top
603
- // placement catches every path to `call_provider` (incl. post-approval-resume), so a queued
604
- // authored spec is never stranded. Drains the queue; fires once per authored batch.
605
- if (this.pendingAuthoredWorkflows.length > 0) {
606
- action = await this.driveAuthoredWorkflows(runtime, action);
607
- }
608
628
  const providerEffectId = action.effectId;
609
629
  const finalToolCalls = [];
610
630
  let finalText = "";
@@ -675,7 +695,7 @@ export class RuntimeRunner {
675
695
  // compacted context, or `done` to terminate with an honest `ContextOverflow`. The
676
696
  // classify + compact + retry + give-up policy lives in the kernel (one place), not
677
697
  // duplicated across the four SDK runners.
678
- action = kernelAction(runtime, this.pendingObservations, {
698
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
679
699
  kind: "provider_error",
680
700
  effect_id: providerEffectId,
681
701
  message: formatToolError(err),
@@ -693,7 +713,7 @@ export class RuntimeRunner {
693
713
  }
694
714
  // #2-B-ii: stream aborted (preempt/interrupt) via the break path — end the turn now.
695
715
  if (abortSignal?.aborted) {
696
- action = kernelAction(runtime, this.pendingObservations, {
716
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
697
717
  kind: "cancel_operation",
698
718
  reason: this.cancellationReason ?? "user",
699
719
  pending_call_ids: [providerEffectId],
@@ -712,10 +732,9 @@ export class RuntimeRunner {
712
732
  message: messageToKernelMessage(assistantMessage),
713
733
  ...(turnInputTokens > 0 ? { observed_input_tokens: turnInputTokens } : {}),
714
734
  ...(turnOutputTokens > 0 ? { observed_output_tokens: turnOutputTokens } : {}),
715
- now_ms: Date.now(),
716
735
  ...(turnStopReason ? { stop_reason: turnStopReason } : {}),
717
736
  };
718
- action = kernelAction(runtime, this.pendingObservations, providerEvent);
737
+ action = await this.commitKernelAction(runtime, this.pendingObservations, providerEvent);
719
738
  const providerReplay = peekProviderReplay(this.opts.provider, finalText, finalToolCalls);
720
739
  await this.opts.sessionLog.append(sessionId, buildLlmCompletedEvent({
721
740
  turn: runtime.turn(),
@@ -755,7 +774,7 @@ export class RuntimeRunner {
755
774
  const resolved = await this.resolveKernelSuspend(action.requests, runtime, sessionId);
756
775
  for (const evt of resolved.events)
757
776
  yield evt;
758
- action = kernelAction(runtime, this.pendingObservations, {
777
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
759
778
  kind: "approval_result",
760
779
  effect_id: action.effectId,
761
780
  approved_calls: resolved.approved,
@@ -772,7 +791,7 @@ export class RuntimeRunner {
772
791
  catch (cause) {
773
792
  error = formatToolError(cause);
774
793
  }
775
- action = kernelAction(runtime, this.pendingObservations, {
794
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
776
795
  kind: "memory_persist_result",
777
796
  effect_id: action.effectId,
778
797
  ...(error ? { error } : {}),
@@ -792,7 +811,7 @@ export class RuntimeRunner {
792
811
  error = formatToolError(cause);
793
812
  }
794
813
  const obsStart = this.pendingObservations.length;
795
- action = kernelAction(runtime, this.pendingObservations, {
814
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
796
815
  kind: "memory_query_result",
797
816
  effect_id: action.effectId,
798
817
  hits,
@@ -808,23 +827,6 @@ export class RuntimeRunner {
808
827
  }
809
828
  }
810
829
  }
811
- else if (action.kind === "spool_large_result") {
812
- const spool = this.opts.resultSpool ?? new LargeResultSpool();
813
- let spoolRef;
814
- let error;
815
- try {
816
- spoolRef = await spool.persistOutput(sessionId, action.callId, action.output);
817
- }
818
- catch (cause) {
819
- error = formatToolError(cause);
820
- }
821
- action = kernelAction(runtime, this.pendingObservations, {
822
- kind: "large_result_spool_result",
823
- effect_id: action.effectId,
824
- ...(spoolRef ? { spool_ref: spoolRef } : {}),
825
- ...(error ? { error } : {}),
826
- });
827
- }
828
830
  else if (action.kind === "archive_page_out") {
829
831
  const archiveMeta = this.activePageOutArchive
830
832
  ?? this.pendingPageOutArchives.shift()
@@ -836,6 +838,10 @@ export class RuntimeRunner {
836
838
  if (this.opts.compressionStore) {
837
839
  archiveRef = await this.opts.compressionStore.write(sessionId, archiveMeta.archiveStart, action.archived);
838
840
  }
841
+ if (action.payload) {
842
+ archiveRef ??= `payload:${action.payload.digest.replace(/^sha256:/, "").slice(0, 32)}`;
843
+ await this.payloadStore().persistPayload(sessionId, archiveRef, action.payload.content);
844
+ }
839
845
  }
840
846
  catch (cause) {
841
847
  error = formatToolError(cause);
@@ -845,7 +851,7 @@ export class RuntimeRunner {
845
851
  const archiveTier = action.tier;
846
852
  if (!error)
847
853
  this.activePageOutArchive = undefined;
848
- action = kernelAction(runtime, this.pendingObservations, {
854
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
849
855
  kind: "page_out_archive_result",
850
856
  effect_id: action.effectId,
851
857
  ...(archiveRef ? { archive_ref: archiveRef } : {}),
@@ -855,6 +861,25 @@ export class RuntimeRunner {
855
861
  void this.archiveSemanticPageOut(archived, archiveAction);
856
862
  }
857
863
  }
864
+ else if (action.kind === "load_payload") {
865
+ const content = await this.payloadStore().loadPayload(sessionId, action.payloadRef);
866
+ action = content === undefined
867
+ ? await this.commitKernelAction(runtime, this.pendingObservations, {
868
+ kind: "payload_load_failed",
869
+ effect_id: action.effectId,
870
+ error: `payload not found for opaque locator ${action.payloadRef}`,
871
+ })
872
+ : await this.commitKernelAction(runtime, this.pendingObservations, {
873
+ kind: "payload_loaded",
874
+ effect_id: action.effectId,
875
+ handle_id: action.handleId,
876
+ payload: {
877
+ content,
878
+ digest: canonicalSha256(content),
879
+ original_size: String(new TextEncoder().encode(content).byteLength),
880
+ },
881
+ });
882
+ }
858
883
  else if (action.kind === "execute_tool") {
859
884
  const toolEffectId = action.effectId;
860
885
  const allCalls = action.calls;
@@ -869,55 +894,25 @@ export class RuntimeRunner {
869
894
  onPermissionRequest: this.opts.onPermissionRequest,
870
895
  };
871
896
  const toolResults = [];
872
- // R3-1: intercept `submit_workflow_nodes` — it can't apply to this runner's kernel (when this
873
- // runner is a workflow node, the workflow lives in the parent). Surface the nodes as an event;
874
- // the orchestrator collects them and `runWorkflow` sends them to the parent kernel.
875
- // M5 v1: `start_workflow` (author a sub-workflow) flattens to the same append path.
897
+ // Syscall tools are consumed by core from the provider result and must never escape as host
898
+ // tool effects. Keep an explicit invariant check below so a projection drift fails closed.
876
899
  const submitCalls = allCalls.filter(c => c.name === "submit_workflow_nodes" || c.name === "start_workflow");
877
- // O7: `read_result` re-fetches a tool output the kernel evicted from context. Content is
878
- // host-resolved: (a) this turn's in-memory pending spool map, (b) the on-disk result spool,
879
- // (c) a session-log scan for the original `tool_completed` event.
880
- const readResultCalls = allCalls.filter(c => c.name === "read_result");
881
900
  const planCalls = allCalls.filter(c => c.name === "update_plan");
882
- const normalCalls = allCalls.filter(c => c.name !== "submit_workflow_nodes" && c.name !== "start_workflow" && c.name !== "read_result"
901
+ const normalCalls = allCalls.filter(c => c.name !== "submit_workflow_nodes" && c.name !== "start_workflow"
883
902
  && c.name !== "update_plan");
884
903
  // `update_plan` is a kernel meta-tool (exposed via `enablePlanTool`), not a registered
885
904
  // plane tool — resolve it here as an `update_task` apply, mirroring the node/python runners.
886
905
  for (const call of planCalls) {
887
906
  const update = parseUpdatePlanArgs(call.arguments);
888
- kernelApply(runtime, this.pendingObservations, {
907
+ await this.commitKernelApply(runtime, this.pendingObservations, {
889
908
  kind: "update_task",
890
909
  update: taskUpdateToKernel(update),
891
910
  });
892
911
  toolResults.push({ callId: call.id, output: "success", isError: false });
893
912
  yield { type: "tool_result", callId: call.id, content: "success", isError: false };
894
913
  }
895
- for (const call of readResultCalls) {
896
- const out = await this.resolveReadResult(sessionId, call.arguments);
897
- toolResults.push({ callId: call.id, output: out.text, isError: out.isError });
898
- yield { type: "tool_result", callId: call.id, content: out.text, isError: out.isError };
899
- }
900
914
  for (const call of submitCalls) {
901
- // M5 v2.1: a TOP-LEVEL agent authoring a whole sub-workflow via `start_workflow` — record the
902
- // spec and AUTO-PIVOT once this tool turn resolves. A workflow-NODE's `start_workflow` (and
903
- // every `submit_workflow_nodes`) instead FLATTENS for the parent `runWorkflow` to append.
904
- if (call.name === "start_workflow" && !this.opts.isWorkflowNode) {
905
- const spec = parseStartWorkflowSpec(call.arguments);
906
- if (spec) {
907
- this.pendingAuthoredWorkflows.push(spec);
908
- const out = "workflow submitted for governance adjudication";
909
- toolResults.push({ callId: call.id, output: out, isError: false });
910
- yield { type: "tool_result", callId: call.id, content: out, isError: false };
911
- continue;
912
- }
913
- }
914
- const nodes = call.name === "start_workflow"
915
- ? parseStartWorkflowArgs(call.arguments)
916
- : parseSubmitWorkflowNodesArgs(call.arguments);
917
- yield { type: "workflow_nodes_submitted", nodes };
918
- const out = "workflow nodes submitted for parent governance adjudication";
919
- toolResults.push({ callId: call.id, output: out, isError: false });
920
- yield { type: "tool_result", callId: call.id, content: out, isError: false };
915
+ throw new Error(`canonical kernel published model syscall ${call.name} as a host tool effect`);
921
916
  }
922
917
  // O5 (PreToolUse-hook analog): stateful host veto over each kernel-approved call.
923
918
  // A blocked call never executes; its reason reaches the model as a denied result.
@@ -1036,9 +1031,10 @@ export class RuntimeRunner {
1036
1031
  token_count: r.tokenCount,
1037
1032
  })),
1038
1033
  });
1039
- // P1-B B3: a successfully-resolved `skill` call activates that skill for the next turn.
1034
+ // Canonical provider-result reduction activates a successfully resolved `skill` call. The
1035
+ // host only pins its METHOD content — how to do something — for later turns.
1040
1036
  //
1041
- // Strict dynamic context control: a skill is METHOD content — how to do something — reused
1037
+ // Strict dynamic context control: the skill text
1042
1038
  // for the rest of the run, unlike a one-off memory/knowledge lookup (fact content, relevant
1043
1039
  // for the moment it's used). So its text ALSO goes into the durable `knowledge` slot here
1044
1040
  // (in addition to the ordinary tool_result already headed for `history`, where it will decay
@@ -1053,11 +1049,6 @@ export class RuntimeRunner {
1053
1049
  const name = JSON.parse(call.arguments || "{}").name;
1054
1050
  if (!name)
1055
1051
  continue;
1056
- kernelApply(runtime, this.pendingObservations, {
1057
- kind: "skill_activated",
1058
- name,
1059
- ...(this.opts.skillLeaseTurns !== undefined ? { lease_turns: this.opts.skillLeaseTurns } : {}),
1060
- });
1061
1052
  // With a lease configured, skip the Set optimization: an expired-then-reloaded skill
1062
1053
  // must re-pin — only the kernel knows the lease state; its upsert dedupes anyway.
1063
1054
  if (this.opts.skillLeaseTurns !== undefined || !this.knowledgePushedSkills.has(name)) {
@@ -1069,7 +1060,7 @@ export class RuntimeRunner {
1069
1060
  catch { /* skip */ }
1070
1061
  }
1071
1062
  const entropyObsStart = this.pendingObservations.length;
1072
- action = kernelAction(runtime, this.pendingObservations, {
1063
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
1073
1064
  kind: "tool_results",
1074
1065
  effect_id: toolEffectId,
1075
1066
  results: toolResults.map(toolResultToKernel),
@@ -1100,9 +1091,11 @@ export class RuntimeRunner {
1100
1091
  }
1101
1092
  }
1102
1093
  else if (action.kind === "evaluate_milestone") {
1094
+ const milestoneEffectId = action.effectId;
1095
+ const milestonePhaseId = action.phaseId;
1103
1096
  const milestonePolicy = this.opts.milestonePolicy ?? "require_verifier";
1104
1097
  if (milestonePolicy === "auto_pass") {
1105
- action = kernelAction(runtime, this.pendingObservations, {
1098
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
1106
1099
  kind: "milestone_result",
1107
1100
  effect_id: action.effectId,
1108
1101
  result: milestoneCheckResultToKernel(milestoneCheckPass(action.phaseId)),
@@ -1115,7 +1108,7 @@ export class RuntimeRunner {
1115
1108
  criteria: action.criteria ?? [],
1116
1109
  requiredEvidence: action.requiredEvidence ?? [],
1117
1110
  });
1118
- action = kernelAction(runtime, this.pendingObservations, {
1111
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
1119
1112
  kind: "milestone_result",
1120
1113
  effect_id: action.effectId,
1121
1114
  result: milestoneCheckResultToKernel(check),
@@ -1123,6 +1116,17 @@ export class RuntimeRunner {
1123
1116
  nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart);
1124
1117
  }
1125
1118
  else {
1119
+ // R-B27: resolve the effect before ending the run. Nothing here can attest the phase, but
1120
+ // the kernel is holding this milestone in its pending-effect table and only a matching
1121
+ // result removes it — a bare `return` leaves a dangling effect that a logical-checkpoint
1122
+ // recovery cannot resolve. Feed back the conservative "unverified" resolution: the wire
1123
+ // has no error field yet, so it rides as `passed: false`, which keeps the phase where it
1124
+ // is (fail-closed, no unlocks mounted). The run still ends as `milestone_pending`.
1125
+ await this.commitKernelAction(runtime, this.pendingObservations, {
1126
+ kind: "milestone_result",
1127
+ effect_id: milestoneEffectId,
1128
+ result: milestoneCheckResultToKernel(milestoneCheckFail(milestonePhaseId, MILESTONE_UNVERIFIED_REASON)),
1129
+ });
1126
1130
  nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart);
1127
1131
  const turnsUsed = Math.max(1, runtime.turn());
1128
1132
  await this.opts.sessionLog.append(sessionId, buildRunTerminalEvent({
@@ -1136,9 +1140,31 @@ export class RuntimeRunner {
1136
1140
  return;
1137
1141
  }
1138
1142
  }
1143
+ else if (action.kind === "spawn_workflow") {
1144
+ await this.driveWorkflow(action, [], sessionId, runtime, new Map());
1145
+ action = runtime.resumeAction()
1146
+ ?? (() => { throw new Error("canonical workflow completed without a terminal or continuation"); })();
1147
+ }
1148
+ else if (action.kind === "unsupported_effect") {
1149
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
1150
+ kind: "unsupported_effect",
1151
+ effect_id: action.effectId,
1152
+ effect_kind: action.effectKind,
1153
+ });
1154
+ }
1139
1155
  else if (action.kind === "done") {
1140
1156
  break;
1141
1157
  }
1158
+ else {
1159
+ // R-B28: fail-closed backstop. Without it an effect that reaches this position but has no
1160
+ // branch here (`spawn_workflow` / `preempt_sub_agents` are only driven inside the workflow
1161
+ // driver, and any effect kind a newer kernel adds) leaves `action` unreplaced and no event
1162
+ // in flight — `while (!runtime.isTerminal())` re-enters immediately and the run pins a core
1163
+ // at 100% forever while the kernel waits for a result that will never come. Terminating
1164
+ // through the loop's existing throw path makes the protocol mismatch visible instead
1165
+ // (run_terminal `error` + an `error` event) and cannot busy-wait.
1166
+ throw new Error(`unhandled kernel effect ${action.kind} in the main run loop`);
1167
+ }
1142
1168
  }
1143
1169
  }
1144
1170
  catch (err) {
@@ -1213,54 +1239,6 @@ export class RuntimeRunner {
1213
1239
  this.activeKernel = null;
1214
1240
  this.currentSessionId = null;
1215
1241
  }
1216
- async spawnSubAgent(spec) {
1217
- if (!this.activeKernel || !this.currentSessionId) {
1218
- throw new Error("spawnSubAgent requires an active parent run");
1219
- }
1220
- const parentSessionId = this.currentSessionId;
1221
- const runtime = this.activeKernel;
1222
- const observations = kernelApply(runtime, this.pendingObservations, {
1223
- kind: "spawn_sub_agent",
1224
- spec: agentRunSpecToKernel(spec),
1225
- parent_session_id: parentSessionId,
1226
- });
1227
- this.nextArchiveStart = await this.appendObservations(parentSessionId, runtime, this.nextArchiveStart);
1228
- const spawned = findSpawnProcessObservation(observations);
1229
- if (!spawned) {
1230
- const rejected = controlRequestRejection(observations, "spawn_sub_agent");
1231
- if (rejected) {
1232
- return {
1233
- agentId: rejected.subject ?? spec.identity.agentId,
1234
- result: {
1235
- termination: "error",
1236
- finalMessage: {
1237
- role: "assistant",
1238
- content: `spawn_sub_agent denied: ${rejected.reason}`,
1239
- toolCalls: [],
1240
- },
1241
- turnsUsed: 0,
1242
- totalTokensUsed: 0,
1243
- },
1244
- };
1245
- }
1246
- throw new Error("spawn_sub_agent did not emit agent_process_changed");
1247
- }
1248
- const manifest = spawnObservationToManifest(spawned, spec, parentSessionId);
1249
- const orchestrator = this.opts.subAgentOrchestrator ?? defaultSubAgentOrchestrator;
1250
- const result = await orchestrator.run({
1251
- parentOpts: this.opts,
1252
- parentSessionId,
1253
- spec,
1254
- manifest,
1255
- sessionLog: this.opts.sessionLog,
1256
- toolAccess: spec.toolAccess,
1257
- });
1258
- kernelApply(runtime, this.pendingObservations, {
1259
- kind: "sub_agent_completed",
1260
- result: subAgentResultToKernel(result),
1261
- });
1262
- return result;
1263
- }
1264
1242
  /**
1265
1243
  * G3: run one workflow node, enforcing its `output_schema` (if any) by instructing the agent,
1266
1244
  * validating its output (the supported JSON-Schema subset), and re-running once with the errors
@@ -1287,7 +1265,8 @@ export class RuntimeRunner {
1287
1265
  spec: { ...baseSpec, goal: withBudget(goal) },
1288
1266
  manifest,
1289
1267
  sessionLog: this.opts.sessionLog,
1290
- // M5 v2.1: this child IS a workflow node — its `start_workflow` flattens to this kernel.
1268
+ // This child is a workflow node, so capability resolution applies workflow-node quarantine
1269
+ // semantics instead of treating it as an independently spawned agent.
1291
1270
  isWorkflowNode: true,
1292
1271
  // W-N1: trusted workflow nodes run on the parent's execution plane (they carry no grant list
1293
1272
  // by design — filtering on the missing list ran every DAG node TOOL-LESS); quarantined nodes
@@ -1392,11 +1371,11 @@ export class RuntimeRunner {
1392
1371
  */
1393
1372
  /**
1394
1373
  * Lower the declarative governance / attention / scheduler-budget / resource-quota / memory policies
1395
- * into a freshly-created kernel. Shared by `execute()` (full run) and `bootstrapWorkflowKernel()`
1374
+ * into a freshly-created kernel. Shared by `execute()` (full run) and `initializeWorkflowKernel()`
1396
1375
  * (standalone workflow) so a DAG's node spawns are gated and quota'd exactly as a mid-run spawn.
1397
- * Must run BEFORE `start_run`. No config ⇒ native-profile defaults.
1376
+ * Must run before the canonical root start. No config ⇒ native-profile defaults.
1398
1377
  */
1399
- applyKernelPolicies(runtime) {
1378
+ async applyKernelPolicies(runtime) {
1400
1379
  const osProfile = assertNativeProfile(this.opts.osProfile ?? "native");
1401
1380
  const signalPolicy = this.opts.signalPolicy ?? osProfile.signalPolicy;
1402
1381
  const governancePolicy = this.opts.governancePolicy ?? osProfile.governancePolicy;
@@ -1426,21 +1405,7 @@ export class RuntimeRunner {
1426
1405
  config.scheduler_policy = schedulerPolicyToKernel(this.opts.schedulerPolicy);
1427
1406
  }
1428
1407
  if (this.opts.kernelReliability) {
1429
- const r = this.opts.kernelReliability;
1430
- config.reliability = {
1431
- ...(r.eventReplayCapacity !== undefined ? { event_replay_capacity: r.eventReplayCapacity } : {}),
1432
- ...(r.completedEffectReplayCapacity !== undefined ? { completed_effect_replay_capacity: r.completedEffectReplayCapacity } : {}),
1433
- ...(r.providerRecoveryAttempts !== undefined ? { provider_recovery_attempts: r.providerRecoveryAttempts } : {}),
1434
- ...(r.outputRecoveryAttempts !== undefined ? { output_recovery_attempts: r.outputRecoveryAttempts } : {}),
1435
- ...(r.hostEffectRetryAttempts !== undefined ? { host_effect_retry_attempts: r.hostEffectRetryAttempts } : {}),
1436
- ...(r.spoolThresholdBytes !== undefined ? { spool_threshold_bytes: r.spoolThresholdBytes } : {}),
1437
- ...(r.spoolPreviewBytes !== undefined ? { spool_preview_bytes: r.spoolPreviewBytes } : {}),
1438
- ...(r.snapshotInputLimit !== undefined ? { snapshot_input_limit: r.snapshotInputLimit } : {}),
1439
- ...(r.maxInputBytes !== undefined ? { max_input_bytes: r.maxInputBytes } : {}),
1440
- ...(r.snapshotJournalBytesLimit !== undefined
1441
- ? { snapshot_journal_bytes_limit: r.snapshotJournalBytesLimit }
1442
- : {}),
1443
- };
1408
+ config.reliability = kernelReliabilityToKernel(this.opts.kernelReliability);
1444
1409
  }
1445
1410
  if (this.opts.resourceQuota) {
1446
1411
  const q = this.opts.resourceQuota;
@@ -1484,54 +1449,30 @@ export class RuntimeRunner {
1484
1449
  ...(ew.notifyModel !== undefined ? { notify_model: ew.notifyModel } : {}),
1485
1450
  };
1486
1451
  }
1487
- kernelApply(runtime, this.pendingObservations, { kind: "configure_run", config });
1452
+ await this.commitKernelApply(runtime, this.pendingObservations, { kind: "configure_run", config });
1488
1453
  if (this.opts.memoryPolicy) {
1489
- const m = this.opts.memoryPolicy;
1490
- kernelApply(runtime, this.pendingObservations, {
1454
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1491
1455
  kind: "set_memory_policy",
1492
- ...(m.memoryPath !== undefined ? { memory_path: m.memoryPath } : {}),
1493
- ...(m.staleWarningDays !== undefined ? { stale_warning_days: m.staleWarningDays } : {}),
1494
- ...(m.retrievalTopK !== undefined ? { retrieval_top_k: m.retrievalTopK } : {}),
1495
- ...(m.validationEnabled !== undefined ? { validation_enabled: m.validationEnabled } : {}),
1496
- ...(m.maxContentBytes !== undefined ? { max_content_bytes: m.maxContentBytes } : {}),
1497
- ...(m.maxNameLength !== undefined ? { max_name_length: m.maxNameLength } : {}),
1456
+ ...memoryPolicyToKernel(this.opts.memoryPolicy),
1498
1457
  });
1499
1458
  }
1500
1459
  }
1501
1460
  /**
1502
- * Bootstrap a standalone kernel for a host-driven workflow with no active parent run — the path a
1503
- * stateless handler (browser/edge worker) takes when it calls `runWorkflow(spec)` directly. Mirrors
1504
- * `execute()`'s pre-run setup (policies via `applyKernelPolicies`, then `start_run`) and records a
1505
- * best-effort `run_started` so the run is resumable. `runWorkflow` tears the kernel down afterward.
1461
+ * Bootstrap a standalone kernel for a host-driven workflow with no active parent run. The caller
1462
+ * durably records `run_started` with the same `runId` before this method can create journal state.
1463
+ * `StartOperation { Workflow }` is the sole root transition; `runWorkflow` tears the kernel down.
1506
1464
  */
1507
- async bootstrapWorkflowKernel(sessionId, spec) {
1465
+ async initializeWorkflowKernel(sessionId, runId) {
1508
1466
  this.interrupted = false;
1509
1467
  this.cancellationReason = undefined;
1468
+ this.abortController = new AbortController();
1510
1469
  this.pendingObservations = [];
1511
1470
  this.pendingPageOutArchives = [];
1512
1471
  this.activePageOutArchive = undefined;
1513
1472
  this.currentSessionId = sessionId;
1514
- const kernel = await getKernel();
1515
- const runtime = new kernel.KernelRuntime({
1516
- maxTokens: this.opts.maxTokens,
1517
- maxTurns: this.opts.maxTurns ?? 25,
1518
- ...(this.opts.maxTotalTokens !== undefined ? { maxTotalTokens: this.opts.maxTotalTokens } : {}),
1519
- timeoutMs: this.opts.timeoutMs,
1520
- });
1473
+ const runtime = await this.createCanonicalRuntime(runId, sessionId);
1521
1474
  this.activeKernel = runtime;
1522
- const goal = `workflow:${spec.nodes.length} nodes`;
1523
- void Promise.resolve(this.opts.sessionLog.append(sessionId, {
1524
- kind: "run_started",
1525
- run_id: crypto.randomUUID(),
1526
- goal,
1527
- criteria: [],
1528
- ...(this.opts.agentId ? { agent_id: this.opts.agentId } : {}),
1529
- })).catch(() => { });
1530
- this.applyKernelPolicies(runtime);
1531
- kernelAction(runtime, this.pendingObservations, {
1532
- kind: "start_run",
1533
- task: { goal: `workflow session ${sessionId}`, criteria: [] },
1534
- });
1475
+ await this.applyKernelPolicies(runtime);
1535
1476
  return runtime;
1536
1477
  }
1537
1478
  async runWorkflow(spec, opts) {
@@ -1540,94 +1481,51 @@ export class RuntimeRunner {
1540
1481
  // Mid-run callers keep the original in-place behavior with no teardown.
1541
1482
  const bootstrapped = !this.activeKernel || !this.currentSessionId;
1542
1483
  if (bootstrapped) {
1543
- await this.bootstrapWorkflowKernel(opts?.sessionId ?? `wf-${crypto.randomUUID()}`, spec);
1484
+ const sessionId = opts?.sessionId ?? `wf-${crypto.randomUUID()}`;
1485
+ const runId = crypto.randomUUID();
1486
+ await this.opts.sessionLog.append(sessionId, {
1487
+ kind: "run_started",
1488
+ run_id: runId,
1489
+ goal: `workflow:${spec.nodes.length} nodes`,
1490
+ criteria: [],
1491
+ ...(this.opts.agentId ? { agent_id: this.opts.agentId } : {}),
1492
+ });
1493
+ await this.initializeWorkflowKernel(sessionId, runId);
1544
1494
  }
1545
1495
  const parentSessionId = this.currentSessionId;
1546
1496
  const runtime = this.activeKernel;
1547
1497
  try {
1548
1498
  const observationStart = this.pendingObservations.length;
1549
- const initialAction = kernelMaybeAction(runtime, this.pendingObservations, {
1550
- kind: "load_workflow",
1551
- spec: workflowSpecToKernel(spec),
1552
- parent_session_id: parentSessionId,
1553
- // Exact typed terminal outcomes plus control-flow signals recovered from the journal.
1554
- ...(opts?.resumedOutcomes?.length
1555
- ? {
1556
- resumed_outcomes: opts.resumedOutcomes.map(r => ({
1557
- agent_id: r.agentId,
1558
- status: r.status,
1559
- termination: r.termination,
1560
- ...(r.output ? { output: messageToKernelMessage(r.output) } : {}),
1561
- ...(r.classifyBranch !== undefined ? { classify_branch: r.classifyBranch } : {}),
1562
- ...(r.tournamentWinner !== undefined ? { tournament_winner: r.tournamentWinner } : {}),
1563
- ...(r.loopContinue !== undefined ? { loop_continue: r.loopContinue } : {}),
1564
- })),
1565
- }
1566
- : {}),
1567
- // R3-1: re-apply recorded runtime submissions so dynamically-appended nodes are reconstructed.
1568
- ...(opts?.resumedSubmissions && opts.resumedSubmissions.length ? { resumed_submissions: opts.resumedSubmissions } : {}),
1569
- ...(opts?.resumedSubmissionBases && opts.resumedSubmissionBases.length ? { resumed_submission_bases: opts.resumedSubmissionBases } : {}),
1570
- });
1499
+ const initialAction = await canonicalStartWorkflow(runtime, this.pendingObservations, workflowSpecToKernel(spec));
1571
1500
  const observations = this.pendingObservations.slice(observationStart);
1572
- return await this.driveWorkflow(initialAction, observations, parentSessionId, runtime, recoveredOutputs(opts?.resumedOutcomes));
1501
+ const outcome = await this.driveWorkflow(initialAction, observations, parentSessionId, runtime, new Map());
1502
+ if (bootstrapped) {
1503
+ let terminal = runtime.resumeAction();
1504
+ if (!terminal)
1505
+ throw new Error("completed canonical workflow has no terminal action");
1506
+ if (terminal.kind !== "done" && this.interrupted) {
1507
+ terminal = await this.commitKernelAction(runtime, this.pendingObservations, {
1508
+ kind: "cancel_operation",
1509
+ effect_id: terminal.effectId,
1510
+ reason: this.cancellationReason ?? "user",
1511
+ });
1512
+ }
1513
+ if (terminal.kind !== "done") {
1514
+ throw new Error("canonical workflow did not produce a terminal kernel action");
1515
+ }
1516
+ await this.appendObservations(parentSessionId, runtime, 0);
1517
+ }
1518
+ return outcome;
1573
1519
  }
1574
1520
  finally {
1575
1521
  if (bootstrapped) {
1576
1522
  this.activeKernel = null;
1577
1523
  this.currentSessionId = null;
1524
+ this.abortController = null;
1578
1525
  this.pendingObservations = [];
1579
1526
  }
1580
1527
  }
1581
1528
  }
1582
- /**
1583
- * M5/G1: bootstrap an **agent-authored** workflow ("the model writes its own harness"). Routes the
1584
- * spec through the agent-reachable `Syscall::LoadWorkflow` (`submit_workflow`): with no workflow
1585
- * active the kernel bootstraps the DAG, else it flattens onto the running one (bootstrap-or-flatten —
1586
- * one kernel, one quota). The same shared driver runs the resulting batches.
1587
- */
1588
- async bootstrapWorkflow(spec, opts) {
1589
- if (!this.activeKernel || !this.currentSessionId) {
1590
- throw new Error("bootstrapWorkflow requires an active parent run");
1591
- }
1592
- const parentSessionId = this.currentSessionId;
1593
- const runtime = this.activeKernel;
1594
- const observationStart = this.pendingObservations.length;
1595
- const initialAction = kernelMaybeAction(runtime, this.pendingObservations, submitWorkflowToKernel(spec, parentSessionId, opts?.submitterAgentId));
1596
- const observations = this.pendingObservations.slice(observationStart);
1597
- // W-3: persist the agent-authored batch (bootstrap base 0 / flatten base N — the kernel now
1598
- // announces BOTH) so an interrupted authored workflow reconstructs on resume; the host never
1599
- // had this spec, unlike the `runWorkflow` path.
1600
- const submitted = observations.find(o => o.kind === "workflow_nodes_submitted");
1601
- if (submitted) {
1602
- await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
1603
- turn: runtime.turn(),
1604
- nodes: workflowSpecToKernel(spec).nodes ?? [],
1605
- baseIndex: submitted.base,
1606
- submitterAgentId: opts?.submitterAgentId,
1607
- }));
1608
- }
1609
- return this.driveWorkflow(initialAction, observations, parentSessionId, runtime);
1610
- }
1611
- /**
1612
- * M5 v2.1: drive the sub-workflow(s) a top-level agent authored via `start_workflow`, at the safe
1613
- * point (tool turn resolved → kernel in Reason). Each runs in THIS kernel (the kernel resumes the
1614
- * reason loop on `workflow_completed`), then the outcome is injected as a user message and a fresh
1615
- * `call_provider` is synthesized from the updated context (the workflow drive consumed its own
1616
- * kernel actions — same re-render pattern as the reactive-compact retry path).
1617
- */
1618
- async driveAuthoredWorkflows(runtime, action) {
1619
- const specs = this.pendingAuthoredWorkflows;
1620
- this.pendingAuthoredWorkflows = [];
1621
- this.workflowContinuation = null;
1622
- for (const spec of specs) {
1623
- await this.bootstrapWorkflow(spec);
1624
- }
1625
- const continuation = this.workflowContinuation;
1626
- if (!continuation) {
1627
- throw new Error("authored workflow completed without a provider continuation");
1628
- }
1629
- return continuation;
1630
- }
1631
1529
  /**
1632
1530
  * #2-B-ii: while a workflow batch is in flight, poll the signal source; a Critical `InterruptNow`
1633
1531
  * routes through the kernel (root in `SubAgentAwait` → preempt → `AgentPreempted` + tears the
@@ -1648,14 +1546,14 @@ export class RuntimeRunner {
1648
1546
  continue;
1649
1547
  }
1650
1548
  const observationStart = this.pendingObservations.length;
1651
- const signalAction = await this.consumeInboundSignal(delivery, claimed => kernelMaybeAction(runtime, this.pendingObservations, signalToKernelEvent(claimed)));
1549
+ const signalAction = await this.consumeInboundSignal(delivery, claimed => this.commitKernelMaybeAction(runtime, this.pendingObservations, signalToKernelEvent(claimed)));
1652
1550
  if (signalAction) {
1653
1551
  if (signalAction.kind !== "preempt_sub_agents") {
1654
1552
  throw new Error(`workflow signal returned unexpected effect: ${signalAction.kind}`);
1655
1553
  }
1656
1554
  for (const id of signalAction.agentIds)
1657
1555
  controllers.get(id)?.abort();
1658
- const continuation = kernelMaybeAction(runtime, this.pendingObservations, {
1556
+ const continuation = await this.commitKernelMaybeAction(runtime, this.pendingObservations, {
1659
1557
  kind: "preempt_result", effect_id: signalAction.effectId,
1660
1558
  });
1661
1559
  if (continuation && continuation.kind !== "call_provider" && continuation.kind !== "done") {
@@ -1674,17 +1572,16 @@ export class RuntimeRunner {
1674
1572
  return null;
1675
1573
  }
1676
1574
  /**
1677
- * Shared workflow driver for `runWorkflow` (host `load_workflow`) and `bootstrapWorkflow` (agent
1678
- * `submit_workflow`): run each kernel-emitted batch in parallel, feed completions back (appending any
1679
- * agent-submitted nodes first), and loop until the kernel reports the workflow complete.
1575
+ * Shared canonical workflow driver: run each kernel-emitted batch in parallel, feed completions
1576
+ * back, and loop until the kernel reports the workflow complete.
1680
1577
  */
1681
1578
  async driveWorkflow(initialAction, initial, parentSessionId, runtime, seedOutputs) {
1682
1579
  const observations = initial;
1683
1580
  const orchestrator = this.opts.subAgentOrchestrator ?? defaultSubAgentOrchestrator;
1684
1581
  const findDone = (obs) => obs.find(o => o.kind === "workflow_completed");
1685
- const acceptSpawn = (spawn) => {
1582
+ const acceptSpawn = async (spawn) => {
1686
1583
  const observationStart = this.pendingObservations.length;
1687
- const continuation = kernelMaybeAction(runtime, this.pendingObservations, {
1584
+ const continuation = await this.commitKernelMaybeAction(runtime, this.pendingObservations, {
1688
1585
  kind: "workflow_spawn_result",
1689
1586
  effect_id: spawn.effectId,
1690
1587
  started_agent_ids: spawn.nodes.map(node => String(node.agent_id ?? "")),
@@ -1712,7 +1609,7 @@ export class RuntimeRunner {
1712
1609
  }
1713
1610
  let nodes = initialAction.nodes;
1714
1611
  let budget = initialAction.budget;
1715
- acceptSpawn(initialAction);
1612
+ await acceptSpawn(initialAction);
1716
1613
  // G2: each completed node's output, keyed by agent id — a reduce node reads its deps' outputs.
1717
1614
  // W-1: on resume it is pre-seeded from the persisted node outputs, so post-resume dependents
1718
1615
  // still see their (pre-crash) dependencies' outputs.
@@ -1720,6 +1617,13 @@ export class RuntimeRunner {
1720
1617
  for (;;) {
1721
1618
  if (nodes.length === 0)
1722
1619
  return { nodeOutcomes: [], outputs: Object.fromEntries(outputs) };
1620
+ for (const node of nodes) {
1621
+ const dependencyOutputs = node.dependency_outputs ?? {};
1622
+ for (const [agentId, output] of Object.entries(dependencyOutputs)) {
1623
+ if (!outputs.has(agentId))
1624
+ outputs.set(agentId, output);
1625
+ }
1626
+ }
1723
1627
  const roundBudget = budget;
1724
1628
  // #2-B-ii: per-node abort controllers + a concurrent preemption monitor (see node runner).
1725
1629
  const controllers = new Map(nodes.map(n => [n.agent_id, new AbortController()]));
@@ -1743,55 +1647,8 @@ export class RuntimeRunner {
1743
1647
  const stableId = result.agentId.replace(/-i\d+$/, "");
1744
1648
  if (stableId !== result.agentId)
1745
1649
  outputs.set(stableId, outText);
1746
- // R3-1: if this node's agent submitted more nodes, append them to the parent DAG BEFORE
1747
- // reporting the node's completion — the workflow is still active, so even a last-node
1748
- // submission keeps the DAG alive.
1749
- if (result.submittedNodes?.length) {
1750
- // G1: stamp the submitting node's agent id so the kernel coerces a quarantined submitter's
1751
- // nodes to quarantined (no topological privilege escalation).
1752
- const submitEvent = submitWorkflowNodesToKernel(result.submittedNodes, result.agentId);
1753
- const observationStart = this.pendingObservations.length;
1754
- const submitAction = kernelMaybeAction(runtime, this.pendingObservations, submitEvent);
1755
- const subObs = this.pendingObservations.slice(observationStart);
1756
- const nodesRejected = subObs.find(observation => observation.kind === "nodes_rejected");
1757
- const rejected = controlRequestRejection(subObs, "submit_workflow_nodes")
1758
- ?? (nodesRejected
1759
- ? { operation: "submit_workflow_nodes", reason: String(nodesRejected.reason ?? "request denied") }
1760
- : undefined);
1761
- if (rejected) {
1762
- const denial = `workflow node submission denied: ${rejected.reason}`;
1763
- result.result = {
1764
- ...result.result,
1765
- termination: "error",
1766
- finalMessage: { role: "assistant", content: denial, toolCalls: [] },
1767
- };
1768
- outputs.set(result.agentId, denial);
1769
- if (stableId !== result.agentId)
1770
- outputs.set(stableId, denial);
1771
- }
1772
- if (submitAction?.kind === "spawn_workflow") {
1773
- nextNodes.push(...submitAction.nodes);
1774
- budget = submitAction.budget ?? budget;
1775
- acceptSpawn(submitAction);
1776
- }
1777
- else if (submitAction) {
1778
- throw new Error(`workflow node submission returned unexpected effect: ${submitAction.kind}`);
1779
- }
1780
- // R3-1: persist the submission (kernel-shape nodes) + its kernel-reported base index
1781
- // so resume can re-apply the batch at the exact original graph position. W-N3: also the
1782
- // submitter, so resume drops batches whose submitter re-runs (it will re-submit).
1783
- const submitted = subObs.find(o => o.kind === "workflow_nodes_submitted");
1784
- if (submitted) {
1785
- await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
1786
- turn: runtime.turn(),
1787
- nodes: submitEvent.nodes ?? [],
1788
- baseIndex: submitted.base,
1789
- submitterAgentId: result.agentId,
1790
- }));
1791
- }
1792
- }
1793
1650
  const observationStart = this.pendingObservations.length;
1794
- const completionAction = kernelMaybeAction(runtime, this.pendingObservations, {
1651
+ const completionAction = await this.commitKernelMaybeAction(runtime, this.pendingObservations, {
1795
1652
  kind: "sub_agent_completed",
1796
1653
  result: subAgentResultToKernel(result),
1797
1654
  });
@@ -1799,14 +1656,31 @@ export class RuntimeRunner {
1799
1656
  if (completionAction?.kind === "spawn_workflow") {
1800
1657
  nextNodes.push(...completionAction.nodes);
1801
1658
  budget = completionAction.budget ?? budget;
1802
- obs = [...obs, ...acceptSpawn(completionAction)];
1659
+ obs = [...obs, ...await acceptSpawn(completionAction)];
1803
1660
  }
1804
1661
  else if (completionAction?.kind === "call_provider") {
1805
1662
  this.workflowContinuation = completionAction;
1806
1663
  }
1664
+ else if (completionAction?.kind === "done") {
1665
+ // The correlated child completion may terminalize the workflow in the same canonical
1666
+ // step. Its workflow_completed observation below remains the typed outcome source.
1667
+ }
1807
1668
  else if (completionAction) {
1808
1669
  throw new Error(`workflow completion returned unexpected effect: ${completionAction.kind}`);
1809
1670
  }
1671
+ // Child-authored DAG additions are admitted only as part of the canonical child-completion
1672
+ // resolution. Persist the projection only after core reports the admitted base index.
1673
+ if (result.submittedNodes?.length) {
1674
+ const submitted = obs.find(o => o.kind === "workflow_nodes_submitted");
1675
+ if (submitted) {
1676
+ await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
1677
+ turn: runtime.turn(),
1678
+ nodes: result.submittedNodes.map(workflowNodeSpecToKernel),
1679
+ baseIndex: submitted.base,
1680
+ submitterAgentId: result.agentId,
1681
+ }));
1682
+ }
1683
+ }
1810
1684
  const d = findDone(obs);
1811
1685
  if (d)
1812
1686
  done = d;
@@ -1833,38 +1707,6 @@ export class RuntimeRunner {
1833
1707
  nodes = nextNodes;
1834
1708
  }
1835
1709
  }
1836
- /**
1837
- * Resume a workflow from the parent session's completed nodes.
1838
- * Reads the session log, extracts completed workflow node records (with their W-1 control
1839
- * signals + outputs), and calls runWorkflow so the kernel skips those nodes, replays control
1840
- * flow (classify prune / loop stop), and the driver re-seeds its outputs map.
1841
- */
1842
- async resumeWorkflow(spec, opts) {
1843
- // Standalone resume: a stateless handler passes the prior `sessionId`; mid-run callers omit it.
1844
- const sessionId = opts?.sessionId ?? this.currentSessionId;
1845
- if (!sessionId) {
1846
- throw new Error("resumeWorkflow requires an active parent run or an explicit sessionId");
1847
- }
1848
- const events = await this.opts.sessionLog.read(sessionId);
1849
- const resumedOutcomes = recoverWorkflowNodeOutcomes(events);
1850
- const completedIds = new Set(resumedOutcomes.map(r => r.agentId));
1851
- const recovered = recoverSubmittedWorkflowNodes(events);
1852
- // W-N3: DROP batches whose submitter did NOT complete — that node re-runs on resume and will
1853
- // re-submit its batch; replaying the logged copy too would duplicate its nodes in the DAG.
1854
- // Exact bases keep later graph indices stable while dropped slots remain inert placeholders.
1855
- let { submissions, bases } = recovered;
1856
- if (submissions.length > 0) {
1857
- const keep = recovered.submitters.map(s => s === undefined || completedIds.has(s));
1858
- submissions = submissions.filter((_, i) => keep[i]);
1859
- bases = bases.filter((_, i) => keep[i]);
1860
- }
1861
- return this.runWorkflow(spec, {
1862
- resumedOutcomes,
1863
- resumedSubmissions: submissions,
1864
- resumedSubmissionBases: bases,
1865
- sessionId,
1866
- });
1867
- }
1868
1710
  async appendObservations(sessionId, runtime, nextArchiveStart) {
1869
1711
  const turn = runtime.turn();
1870
1712
  const preservedRefs = runtime.preservedRefs();
@@ -1920,7 +1762,7 @@ export class RuntimeRunner {
1920
1762
  }
1921
1763
  }
1922
1764
  if (lines.length > 0) {
1923
- kernelApply(runtime, this.pendingObservations, {
1765
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1924
1766
  kind: "add_history_message",
1925
1767
  message: { role: "user", content: lines.join("\n") },
1926
1768
  });
@@ -2104,72 +1946,6 @@ function parseUpdatePlanArgs(argsStr) {
2104
1946
  : parsed.blocked_on,
2105
1947
  };
2106
1948
  }
2107
- /** R3-1: parse `submit_workflow_nodes` tool args (`{ nodes: WorkflowNodeSpec[] }`). Node shapes are
2108
- * trusted structurally; the kernel validates them on append. Malformed payload → no nodes. */
2109
- function parseSubmitWorkflowNodesArgs(argsStr) {
2110
- let parsed = {};
2111
- try {
2112
- parsed = JSON.parse(argsStr);
2113
- }
2114
- catch {
2115
- // Ignore parse error → no nodes submitted.
2116
- }
2117
- return Array.isArray(parsed.nodes) ? parsed.nodes : [];
2118
- }
2119
- /** M5 v1: parse `start_workflow` tool args (`{ spec: { nodes: WorkflowNodeSpec[] } }`) into the
2120
- * spec's node batch — flattened onto the running workflow via the same append path. */
2121
- function parseStartWorkflowArgs(argsStr) {
2122
- let parsed = {};
2123
- try {
2124
- parsed = JSON.parse(argsStr);
2125
- }
2126
- catch {
2127
- // Ignore parse error → no nodes.
2128
- }
2129
- const spec = parsed.spec;
2130
- return Array.isArray(spec?.nodes) ? spec.nodes : [];
2131
- }
2132
- /** M5 v2.1: parse the full `WorkflowSpec` from a top-level `start_workflow` call for the auto-pivot
2133
- * drive. Returns `undefined` on a malformed / empty payload (caller falls back to the flatten path). */
2134
- function parseStartWorkflowSpec(argsStr) {
2135
- try {
2136
- const parsed = JSON.parse(argsStr);
2137
- if (Array.isArray(parsed.spec?.nodes) && parsed.spec.nodes.length > 0) {
2138
- return { nodes: parsed.spec.nodes };
2139
- }
2140
- }
2141
- catch {
2142
- // Ignore parse error → undefined (fall back to flatten).
2143
- }
2144
- return undefined;
2145
- }
2146
- /** M5 v2.1: render an authored-workflow outcome into a user-message note injected back into the
2147
- * agent's context, so its next turn continues with the sub-workflow's results in view. */
2148
- function recoveredOutputs(outcomes) {
2149
- const outputs = new Map();
2150
- for (const outcome of outcomes ?? []) {
2151
- if (!outcome.output)
2152
- continue;
2153
- outputs.set(outcome.agentId, outcome.output.content);
2154
- outputs.set(outcome.agentId.replace(/-i\d+$/, ""), outcome.output.content);
2155
- }
2156
- return outputs;
2157
- }
2158
- function authoredWorkflowOutcomeNote(outcome) {
2159
- const counts = new Map();
2160
- for (const node of outcome.nodeOutcomes)
2161
- counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
2162
- const lines = [
2163
- `[authored workflow result] ${outcome.nodeOutcomes.length} terminal node(s): ` +
2164
- [...counts.entries()].map(([status, count]) => `${count} ${status}`).join(", ") + ".",
2165
- ];
2166
- for (const node of outcome.nodeOutcomes) {
2167
- const out = outcome.outputs[node.nodeId] ?? node.output?.content;
2168
- if (out)
2169
- lines.push(`- ${node.nodeId} (${node.status}): ${out.length > 500 ? out.slice(0, 500) + "…" : out}`);
2170
- }
2171
- return lines.join("\n");
2172
- }
2173
1949
  /** Lower a claimed signal delivery to the kernel's `deliver_signal` input event. Shared by the main
2174
1950
  * loop's per-turn poll and #2-B-ii's workflow-batch preemption monitor (so the two never drift). */
2175
1951
  function signalToKernelEvent(delivery) {