@deepstrike/wasm 0.2.49 → 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 +72 -79
  22. package/dist/runtime/runner.js +424 -637
  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 +21 -16
  30. package/dist/runtime/types/agent.js +21 -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,164 +433,161 @@ 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
- };
542
- // P0-A: lower an explicit `runSpec` and/or the `allowedToolIds` profile to the kernel's
543
- // `capability_filter` (reuses the existing run_spec wire — no new ABI). Unset on both ⇒ no
544
- // gating (铁律: no config = old behavior).
565
+ const startTask = { goal, criteria };
566
+ let startRunSpec;
567
+ // P0-A: lower an explicit `runSpec`, the `allowedToolIds` ceiling, and/or the `baselineToolIds`
568
+ // pre-activation surface to the kernel run spec (reuses the existing run_spec wire — no new
569
+ // ABI). Unset on all ⇒ no gating (铁律: no config = old behavior).
545
570
  const allowedToolIds = this.opts.allowedToolIds;
546
571
  const hasProfile = allowedToolIds !== undefined && allowedToolIds.length > 0;
547
- if (this.opts.runSpec || hasProfile) {
572
+ // NOT the `length > 0` idiom above: `baselineToolIds: []` is the legitimate minimal surface
573
+ // (meta + stable-core only), so mere presence triggers the lowering.
574
+ const baselineToolIds = this.opts.baselineToolIds;
575
+ const hasBaseline = baselineToolIds !== undefined;
576
+ if (this.opts.runSpec || hasProfile || hasBaseline) {
548
577
  const baseSpec = this.opts.runSpec ?? {
549
578
  identity: { agentId: this.opts.agentId ?? "root", sessionId, isSubAgent: false },
550
579
  role: "custom",
551
580
  goal,
552
581
  };
553
- const spec = hasProfile
582
+ let spec = hasProfile
554
583
  ? { ...baseSpec, capabilityFilter: { ...baseSpec.capabilityFilter, allowedIds: allowedToolIds } }
555
584
  : baseSpec;
556
- startPayload.run_spec = agentRunSpecToKernel(spec);
585
+ if (hasBaseline)
586
+ spec = { ...spec, exposureBaseline: baselineToolIds };
587
+ startRunSpec = agentRunSpecToKernel(spec);
557
588
  }
558
- this.applyKernelPolicies(runtime);
589
+ if (!resumeMidRun)
590
+ await this.applyKernelPolicies(runtime);
559
591
  // I4: pre-fetch memory before the first LLM turn (mirrors Node). Strict dynamic context
560
592
  // control: single-use retrieval content, not a stable skill — lands in `history` like an
561
593
  // ordinary `memory` tool result, so it decays with the compression pyramid instead of
@@ -565,8 +597,8 @@ export class RuntimeRunner {
565
597
  await this.prefetchMemoryIntoHistory(runtime, "initial");
566
598
  }
567
599
  let action = resumeMidRun
568
- ? kernelAction(runtime, this.pendingObservations, { kind: "resume" })
569
- : 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);
570
602
  // P0-C: the skill loaded and in effect going into the current turn → per-turn `activeSkill` metric.
571
603
  let activeSkill;
572
604
  // I0b: kernel-throw safety net — see Node runner for full rationale.
@@ -574,7 +606,7 @@ export class RuntimeRunner {
574
606
  while (!runtime.isTerminal()) {
575
607
  nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart);
576
608
  if (this.interrupted) {
577
- action = kernelAction(runtime, this.pendingObservations, {
609
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
578
610
  kind: "cancel_operation",
579
611
  reason: this.cancellationReason ?? "user",
580
612
  pending_call_ids: pendingCallIds(action),
@@ -584,7 +616,7 @@ export class RuntimeRunner {
584
616
  if (this.opts.signalSource || this.injectedSignals.length > 0) {
585
617
  const delivery = await this.nextInboundSignal();
586
618
  if (delivery) {
587
- 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)));
588
620
  if (sigAction)
589
621
  action = sigAction;
590
622
  // Critical attention/preemption is distinct from operation cancellation.
@@ -593,12 +625,6 @@ export class RuntimeRunner {
593
625
  if (runtime.isTerminal())
594
626
  break;
595
627
  if (action.kind === "call_provider") {
596
- // M5 v2.1: top-level auto-pivot at the safe point (kernel in Reason, not suspended). Loop-top
597
- // placement catches every path to `call_provider` (incl. post-approval-resume), so a queued
598
- // authored spec is never stranded. Drains the queue; fires once per authored batch.
599
- if (this.pendingAuthoredWorkflows.length > 0) {
600
- action = await this.driveAuthoredWorkflows(runtime, action);
601
- }
602
628
  const providerEffectId = action.effectId;
603
629
  const finalToolCalls = [];
604
630
  let finalText = "";
@@ -669,7 +695,7 @@ export class RuntimeRunner {
669
695
  // compacted context, or `done` to terminate with an honest `ContextOverflow`. The
670
696
  // classify + compact + retry + give-up policy lives in the kernel (one place), not
671
697
  // duplicated across the four SDK runners.
672
- action = kernelAction(runtime, this.pendingObservations, {
698
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
673
699
  kind: "provider_error",
674
700
  effect_id: providerEffectId,
675
701
  message: formatToolError(err),
@@ -687,7 +713,7 @@ export class RuntimeRunner {
687
713
  }
688
714
  // #2-B-ii: stream aborted (preempt/interrupt) via the break path — end the turn now.
689
715
  if (abortSignal?.aborted) {
690
- action = kernelAction(runtime, this.pendingObservations, {
716
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
691
717
  kind: "cancel_operation",
692
718
  reason: this.cancellationReason ?? "user",
693
719
  pending_call_ids: [providerEffectId],
@@ -706,10 +732,9 @@ export class RuntimeRunner {
706
732
  message: messageToKernelMessage(assistantMessage),
707
733
  ...(turnInputTokens > 0 ? { observed_input_tokens: turnInputTokens } : {}),
708
734
  ...(turnOutputTokens > 0 ? { observed_output_tokens: turnOutputTokens } : {}),
709
- now_ms: Date.now(),
710
735
  ...(turnStopReason ? { stop_reason: turnStopReason } : {}),
711
736
  };
712
- action = kernelAction(runtime, this.pendingObservations, providerEvent);
737
+ action = await this.commitKernelAction(runtime, this.pendingObservations, providerEvent);
713
738
  const providerReplay = peekProviderReplay(this.opts.provider, finalText, finalToolCalls);
714
739
  await this.opts.sessionLog.append(sessionId, buildLlmCompletedEvent({
715
740
  turn: runtime.turn(),
@@ -749,7 +774,7 @@ export class RuntimeRunner {
749
774
  const resolved = await this.resolveKernelSuspend(action.requests, runtime, sessionId);
750
775
  for (const evt of resolved.events)
751
776
  yield evt;
752
- action = kernelAction(runtime, this.pendingObservations, {
777
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
753
778
  kind: "approval_result",
754
779
  effect_id: action.effectId,
755
780
  approved_calls: resolved.approved,
@@ -766,7 +791,7 @@ export class RuntimeRunner {
766
791
  catch (cause) {
767
792
  error = formatToolError(cause);
768
793
  }
769
- action = kernelAction(runtime, this.pendingObservations, {
794
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
770
795
  kind: "memory_persist_result",
771
796
  effect_id: action.effectId,
772
797
  ...(error ? { error } : {}),
@@ -786,7 +811,7 @@ export class RuntimeRunner {
786
811
  error = formatToolError(cause);
787
812
  }
788
813
  const obsStart = this.pendingObservations.length;
789
- action = kernelAction(runtime, this.pendingObservations, {
814
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
790
815
  kind: "memory_query_result",
791
816
  effect_id: action.effectId,
792
817
  hits,
@@ -802,23 +827,6 @@ export class RuntimeRunner {
802
827
  }
803
828
  }
804
829
  }
805
- else if (action.kind === "spool_large_result") {
806
- const spool = this.opts.resultSpool ?? new LargeResultSpool();
807
- let spoolRef;
808
- let error;
809
- try {
810
- spoolRef = await spool.persistOutput(sessionId, action.callId, action.output);
811
- }
812
- catch (cause) {
813
- error = formatToolError(cause);
814
- }
815
- action = kernelAction(runtime, this.pendingObservations, {
816
- kind: "large_result_spool_result",
817
- effect_id: action.effectId,
818
- ...(spoolRef ? { spool_ref: spoolRef } : {}),
819
- ...(error ? { error } : {}),
820
- });
821
- }
822
830
  else if (action.kind === "archive_page_out") {
823
831
  const archiveMeta = this.activePageOutArchive
824
832
  ?? this.pendingPageOutArchives.shift()
@@ -830,6 +838,10 @@ export class RuntimeRunner {
830
838
  if (this.opts.compressionStore) {
831
839
  archiveRef = await this.opts.compressionStore.write(sessionId, archiveMeta.archiveStart, action.archived);
832
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
+ }
833
845
  }
834
846
  catch (cause) {
835
847
  error = formatToolError(cause);
@@ -839,7 +851,7 @@ export class RuntimeRunner {
839
851
  const archiveTier = action.tier;
840
852
  if (!error)
841
853
  this.activePageOutArchive = undefined;
842
- action = kernelAction(runtime, this.pendingObservations, {
854
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
843
855
  kind: "page_out_archive_result",
844
856
  effect_id: action.effectId,
845
857
  ...(archiveRef ? { archive_ref: archiveRef } : {}),
@@ -849,6 +861,25 @@ export class RuntimeRunner {
849
861
  void this.archiveSemanticPageOut(archived, archiveAction);
850
862
  }
851
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
+ }
852
883
  else if (action.kind === "execute_tool") {
853
884
  const toolEffectId = action.effectId;
854
885
  const allCalls = action.calls;
@@ -863,55 +894,25 @@ export class RuntimeRunner {
863
894
  onPermissionRequest: this.opts.onPermissionRequest,
864
895
  };
865
896
  const toolResults = [];
866
- // R3-1: intercept `submit_workflow_nodes` — it can't apply to this runner's kernel (when this
867
- // runner is a workflow node, the workflow lives in the parent). Surface the nodes as an event;
868
- // the orchestrator collects them and `runWorkflow` sends them to the parent kernel.
869
- // 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.
870
899
  const submitCalls = allCalls.filter(c => c.name === "submit_workflow_nodes" || c.name === "start_workflow");
871
- // O7: `read_result` re-fetches a tool output the kernel evicted from context. Content is
872
- // host-resolved: (a) this turn's in-memory pending spool map, (b) the on-disk result spool,
873
- // (c) a session-log scan for the original `tool_completed` event.
874
- const readResultCalls = allCalls.filter(c => c.name === "read_result");
875
900
  const planCalls = allCalls.filter(c => c.name === "update_plan");
876
- 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"
877
902
  && c.name !== "update_plan");
878
903
  // `update_plan` is a kernel meta-tool (exposed via `enablePlanTool`), not a registered
879
904
  // plane tool — resolve it here as an `update_task` apply, mirroring the node/python runners.
880
905
  for (const call of planCalls) {
881
906
  const update = parseUpdatePlanArgs(call.arguments);
882
- kernelApply(runtime, this.pendingObservations, {
907
+ await this.commitKernelApply(runtime, this.pendingObservations, {
883
908
  kind: "update_task",
884
909
  update: taskUpdateToKernel(update),
885
910
  });
886
911
  toolResults.push({ callId: call.id, output: "success", isError: false });
887
912
  yield { type: "tool_result", callId: call.id, content: "success", isError: false };
888
913
  }
889
- for (const call of readResultCalls) {
890
- const out = await this.resolveReadResult(sessionId, call.arguments);
891
- toolResults.push({ callId: call.id, output: out.text, isError: out.isError });
892
- yield { type: "tool_result", callId: call.id, content: out.text, isError: out.isError };
893
- }
894
914
  for (const call of submitCalls) {
895
- // M5 v2.1: a TOP-LEVEL agent authoring a whole sub-workflow via `start_workflow` — record the
896
- // spec and AUTO-PIVOT once this tool turn resolves. A workflow-NODE's `start_workflow` (and
897
- // every `submit_workflow_nodes`) instead FLATTENS for the parent `runWorkflow` to append.
898
- if (call.name === "start_workflow" && !this.opts.isWorkflowNode) {
899
- const spec = parseStartWorkflowSpec(call.arguments);
900
- if (spec) {
901
- this.pendingAuthoredWorkflows.push(spec);
902
- const out = "workflow submitted for governance adjudication";
903
- toolResults.push({ callId: call.id, output: out, isError: false });
904
- yield { type: "tool_result", callId: call.id, content: out, isError: false };
905
- continue;
906
- }
907
- }
908
- const nodes = call.name === "start_workflow"
909
- ? parseStartWorkflowArgs(call.arguments)
910
- : parseSubmitWorkflowNodesArgs(call.arguments);
911
- yield { type: "workflow_nodes_submitted", nodes };
912
- const out = "workflow nodes submitted for parent governance adjudication";
913
- toolResults.push({ callId: call.id, output: out, isError: false });
914
- 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`);
915
916
  }
916
917
  // O5 (PreToolUse-hook analog): stateful host veto over each kernel-approved call.
917
918
  // A blocked call never executes; its reason reaches the model as a denied result.
@@ -1030,9 +1031,10 @@ export class RuntimeRunner {
1030
1031
  token_count: r.tokenCount,
1031
1032
  })),
1032
1033
  });
1033
- // 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.
1034
1036
  //
1035
- // Strict dynamic context control: a skill is METHOD content — how to do something — reused
1037
+ // Strict dynamic context control: the skill text
1036
1038
  // for the rest of the run, unlike a one-off memory/knowledge lookup (fact content, relevant
1037
1039
  // for the moment it's used). So its text ALSO goes into the durable `knowledge` slot here
1038
1040
  // (in addition to the ordinary tool_result already headed for `history`, where it will decay
@@ -1047,11 +1049,6 @@ export class RuntimeRunner {
1047
1049
  const name = JSON.parse(call.arguments || "{}").name;
1048
1050
  if (!name)
1049
1051
  continue;
1050
- kernelApply(runtime, this.pendingObservations, {
1051
- kind: "skill_activated",
1052
- name,
1053
- ...(this.opts.skillLeaseTurns !== undefined ? { lease_turns: this.opts.skillLeaseTurns } : {}),
1054
- });
1055
1052
  // With a lease configured, skip the Set optimization: an expired-then-reloaded skill
1056
1053
  // must re-pin — only the kernel knows the lease state; its upsert dedupes anyway.
1057
1054
  if (this.opts.skillLeaseTurns !== undefined || !this.knowledgePushedSkills.has(name)) {
@@ -1063,7 +1060,7 @@ export class RuntimeRunner {
1063
1060
  catch { /* skip */ }
1064
1061
  }
1065
1062
  const entropyObsStart = this.pendingObservations.length;
1066
- action = kernelAction(runtime, this.pendingObservations, {
1063
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
1067
1064
  kind: "tool_results",
1068
1065
  effect_id: toolEffectId,
1069
1066
  results: toolResults.map(toolResultToKernel),
@@ -1094,9 +1091,11 @@ export class RuntimeRunner {
1094
1091
  }
1095
1092
  }
1096
1093
  else if (action.kind === "evaluate_milestone") {
1094
+ const milestoneEffectId = action.effectId;
1095
+ const milestonePhaseId = action.phaseId;
1097
1096
  const milestonePolicy = this.opts.milestonePolicy ?? "require_verifier";
1098
1097
  if (milestonePolicy === "auto_pass") {
1099
- action = kernelAction(runtime, this.pendingObservations, {
1098
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
1100
1099
  kind: "milestone_result",
1101
1100
  effect_id: action.effectId,
1102
1101
  result: milestoneCheckResultToKernel(milestoneCheckPass(action.phaseId)),
@@ -1109,7 +1108,7 @@ export class RuntimeRunner {
1109
1108
  criteria: action.criteria ?? [],
1110
1109
  requiredEvidence: action.requiredEvidence ?? [],
1111
1110
  });
1112
- action = kernelAction(runtime, this.pendingObservations, {
1111
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
1113
1112
  kind: "milestone_result",
1114
1113
  effect_id: action.effectId,
1115
1114
  result: milestoneCheckResultToKernel(check),
@@ -1117,6 +1116,17 @@ export class RuntimeRunner {
1117
1116
  nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart);
1118
1117
  }
1119
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
+ });
1120
1130
  nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart);
1121
1131
  const turnsUsed = Math.max(1, runtime.turn());
1122
1132
  await this.opts.sessionLog.append(sessionId, buildRunTerminalEvent({
@@ -1130,9 +1140,31 @@ export class RuntimeRunner {
1130
1140
  return;
1131
1141
  }
1132
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
+ }
1133
1155
  else if (action.kind === "done") {
1134
1156
  break;
1135
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
+ }
1136
1168
  }
1137
1169
  }
1138
1170
  catch (err) {
@@ -1207,54 +1239,6 @@ export class RuntimeRunner {
1207
1239
  this.activeKernel = null;
1208
1240
  this.currentSessionId = null;
1209
1241
  }
1210
- async spawnSubAgent(spec) {
1211
- if (!this.activeKernel || !this.currentSessionId) {
1212
- throw new Error("spawnSubAgent requires an active parent run");
1213
- }
1214
- const parentSessionId = this.currentSessionId;
1215
- const runtime = this.activeKernel;
1216
- const observations = kernelApply(runtime, this.pendingObservations, {
1217
- kind: "spawn_sub_agent",
1218
- spec: agentRunSpecToKernel(spec),
1219
- parent_session_id: parentSessionId,
1220
- });
1221
- this.nextArchiveStart = await this.appendObservations(parentSessionId, runtime, this.nextArchiveStart);
1222
- const spawned = findSpawnProcessObservation(observations);
1223
- if (!spawned) {
1224
- const rejected = controlRequestRejection(observations, "spawn_sub_agent");
1225
- if (rejected) {
1226
- return {
1227
- agentId: rejected.subject ?? spec.identity.agentId,
1228
- result: {
1229
- termination: "error",
1230
- finalMessage: {
1231
- role: "assistant",
1232
- content: `spawn_sub_agent denied: ${rejected.reason}`,
1233
- toolCalls: [],
1234
- },
1235
- turnsUsed: 0,
1236
- totalTokensUsed: 0,
1237
- },
1238
- };
1239
- }
1240
- throw new Error("spawn_sub_agent did not emit agent_process_changed");
1241
- }
1242
- const manifest = spawnObservationToManifest(spawned, spec, parentSessionId);
1243
- const orchestrator = this.opts.subAgentOrchestrator ?? defaultSubAgentOrchestrator;
1244
- const result = await orchestrator.run({
1245
- parentOpts: this.opts,
1246
- parentSessionId,
1247
- spec,
1248
- manifest,
1249
- sessionLog: this.opts.sessionLog,
1250
- toolAccess: spec.toolAccess,
1251
- });
1252
- kernelApply(runtime, this.pendingObservations, {
1253
- kind: "sub_agent_completed",
1254
- result: subAgentResultToKernel(result),
1255
- });
1256
- return result;
1257
- }
1258
1242
  /**
1259
1243
  * G3: run one workflow node, enforcing its `output_schema` (if any) by instructing the agent,
1260
1244
  * validating its output (the supported JSON-Schema subset), and re-running once with the errors
@@ -1281,7 +1265,8 @@ export class RuntimeRunner {
1281
1265
  spec: { ...baseSpec, goal: withBudget(goal) },
1282
1266
  manifest,
1283
1267
  sessionLog: this.opts.sessionLog,
1284
- // 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.
1285
1270
  isWorkflowNode: true,
1286
1271
  // W-N1: trusted workflow nodes run on the parent's execution plane (they carry no grant list
1287
1272
  // by design — filtering on the missing list ran every DAG node TOOL-LESS); quarantined nodes
@@ -1386,11 +1371,11 @@ export class RuntimeRunner {
1386
1371
  */
1387
1372
  /**
1388
1373
  * Lower the declarative governance / attention / scheduler-budget / resource-quota / memory policies
1389
- * 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()`
1390
1375
  * (standalone workflow) so a DAG's node spawns are gated and quota'd exactly as a mid-run spawn.
1391
- * Must run BEFORE `start_run`. No config ⇒ native-profile defaults.
1376
+ * Must run before the canonical root start. No config ⇒ native-profile defaults.
1392
1377
  */
1393
- applyKernelPolicies(runtime) {
1378
+ async applyKernelPolicies(runtime) {
1394
1379
  const osProfile = assertNativeProfile(this.opts.osProfile ?? "native");
1395
1380
  const signalPolicy = this.opts.signalPolicy ?? osProfile.signalPolicy;
1396
1381
  const governancePolicy = this.opts.governancePolicy ?? osProfile.governancePolicy;
@@ -1420,21 +1405,7 @@ export class RuntimeRunner {
1420
1405
  config.scheduler_policy = schedulerPolicyToKernel(this.opts.schedulerPolicy);
1421
1406
  }
1422
1407
  if (this.opts.kernelReliability) {
1423
- const r = this.opts.kernelReliability;
1424
- config.reliability = {
1425
- ...(r.eventReplayCapacity !== undefined ? { event_replay_capacity: r.eventReplayCapacity } : {}),
1426
- ...(r.completedEffectReplayCapacity !== undefined ? { completed_effect_replay_capacity: r.completedEffectReplayCapacity } : {}),
1427
- ...(r.providerRecoveryAttempts !== undefined ? { provider_recovery_attempts: r.providerRecoveryAttempts } : {}),
1428
- ...(r.outputRecoveryAttempts !== undefined ? { output_recovery_attempts: r.outputRecoveryAttempts } : {}),
1429
- ...(r.hostEffectRetryAttempts !== undefined ? { host_effect_retry_attempts: r.hostEffectRetryAttempts } : {}),
1430
- ...(r.spoolThresholdBytes !== undefined ? { spool_threshold_bytes: r.spoolThresholdBytes } : {}),
1431
- ...(r.spoolPreviewBytes !== undefined ? { spool_preview_bytes: r.spoolPreviewBytes } : {}),
1432
- ...(r.snapshotInputLimit !== undefined ? { snapshot_input_limit: r.snapshotInputLimit } : {}),
1433
- ...(r.maxInputBytes !== undefined ? { max_input_bytes: r.maxInputBytes } : {}),
1434
- ...(r.snapshotJournalBytesLimit !== undefined
1435
- ? { snapshot_journal_bytes_limit: r.snapshotJournalBytesLimit }
1436
- : {}),
1437
- };
1408
+ config.reliability = kernelReliabilityToKernel(this.opts.kernelReliability);
1438
1409
  }
1439
1410
  if (this.opts.resourceQuota) {
1440
1411
  const q = this.opts.resourceQuota;
@@ -1458,6 +1429,11 @@ export class RuntimeRunner {
1458
1429
  if (this.opts.criteriaGate !== undefined) {
1459
1430
  config.criteria_gate = this.opts.criteriaGate;
1460
1431
  }
1432
+ // P1: fail-closed dispatch selector (absent ⇒ kernel default "exposed"). "registered" is the
1433
+ // escape hatch back to permissive dispatch; the kernel rejects any other value.
1434
+ if (this.opts.toolDispatchGate !== undefined) {
1435
+ config.tool_dispatch_gate = this.opts.toolDispatchGate;
1436
+ }
1461
1437
  // K2: knowledge budget ratio (absent ⇒ kernel default 0.25; 0 disables).
1462
1438
  if (this.opts.knowledgeBudgetRatio !== undefined) {
1463
1439
  config.knowledge_budget_ratio = this.opts.knowledgeBudgetRatio;
@@ -1473,54 +1449,30 @@ export class RuntimeRunner {
1473
1449
  ...(ew.notifyModel !== undefined ? { notify_model: ew.notifyModel } : {}),
1474
1450
  };
1475
1451
  }
1476
- kernelApply(runtime, this.pendingObservations, { kind: "configure_run", config });
1452
+ await this.commitKernelApply(runtime, this.pendingObservations, { kind: "configure_run", config });
1477
1453
  if (this.opts.memoryPolicy) {
1478
- const m = this.opts.memoryPolicy;
1479
- kernelApply(runtime, this.pendingObservations, {
1454
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1480
1455
  kind: "set_memory_policy",
1481
- ...(m.memoryPath !== undefined ? { memory_path: m.memoryPath } : {}),
1482
- ...(m.staleWarningDays !== undefined ? { stale_warning_days: m.staleWarningDays } : {}),
1483
- ...(m.retrievalTopK !== undefined ? { retrieval_top_k: m.retrievalTopK } : {}),
1484
- ...(m.validationEnabled !== undefined ? { validation_enabled: m.validationEnabled } : {}),
1485
- ...(m.maxContentBytes !== undefined ? { max_content_bytes: m.maxContentBytes } : {}),
1486
- ...(m.maxNameLength !== undefined ? { max_name_length: m.maxNameLength } : {}),
1456
+ ...memoryPolicyToKernel(this.opts.memoryPolicy),
1487
1457
  });
1488
1458
  }
1489
1459
  }
1490
1460
  /**
1491
- * Bootstrap a standalone kernel for a host-driven workflow with no active parent run — the path a
1492
- * stateless handler (browser/edge worker) takes when it calls `runWorkflow(spec)` directly. Mirrors
1493
- * `execute()`'s pre-run setup (policies via `applyKernelPolicies`, then `start_run`) and records a
1494
- * 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.
1495
1464
  */
1496
- async bootstrapWorkflowKernel(sessionId, spec) {
1465
+ async initializeWorkflowKernel(sessionId, runId) {
1497
1466
  this.interrupted = false;
1498
1467
  this.cancellationReason = undefined;
1468
+ this.abortController = new AbortController();
1499
1469
  this.pendingObservations = [];
1500
1470
  this.pendingPageOutArchives = [];
1501
1471
  this.activePageOutArchive = undefined;
1502
1472
  this.currentSessionId = sessionId;
1503
- const kernel = await getKernel();
1504
- const runtime = new kernel.KernelRuntime({
1505
- maxTokens: this.opts.maxTokens,
1506
- maxTurns: this.opts.maxTurns ?? 25,
1507
- ...(this.opts.maxTotalTokens !== undefined ? { maxTotalTokens: this.opts.maxTotalTokens } : {}),
1508
- timeoutMs: this.opts.timeoutMs,
1509
- });
1473
+ const runtime = await this.createCanonicalRuntime(runId, sessionId);
1510
1474
  this.activeKernel = runtime;
1511
- const goal = `workflow:${spec.nodes.length} nodes`;
1512
- void Promise.resolve(this.opts.sessionLog.append(sessionId, {
1513
- kind: "run_started",
1514
- run_id: crypto.randomUUID(),
1515
- goal,
1516
- criteria: [],
1517
- ...(this.opts.agentId ? { agent_id: this.opts.agentId } : {}),
1518
- })).catch(() => { });
1519
- this.applyKernelPolicies(runtime);
1520
- kernelAction(runtime, this.pendingObservations, {
1521
- kind: "start_run",
1522
- task: { goal: `workflow session ${sessionId}`, criteria: [] },
1523
- });
1475
+ await this.applyKernelPolicies(runtime);
1524
1476
  return runtime;
1525
1477
  }
1526
1478
  async runWorkflow(spec, opts) {
@@ -1529,94 +1481,51 @@ export class RuntimeRunner {
1529
1481
  // Mid-run callers keep the original in-place behavior with no teardown.
1530
1482
  const bootstrapped = !this.activeKernel || !this.currentSessionId;
1531
1483
  if (bootstrapped) {
1532
- 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);
1533
1494
  }
1534
1495
  const parentSessionId = this.currentSessionId;
1535
1496
  const runtime = this.activeKernel;
1536
1497
  try {
1537
1498
  const observationStart = this.pendingObservations.length;
1538
- const initialAction = kernelMaybeAction(runtime, this.pendingObservations, {
1539
- kind: "load_workflow",
1540
- spec: workflowSpecToKernel(spec),
1541
- parent_session_id: parentSessionId,
1542
- // Exact typed terminal outcomes plus control-flow signals recovered from the journal.
1543
- ...(opts?.resumedOutcomes?.length
1544
- ? {
1545
- resumed_outcomes: opts.resumedOutcomes.map(r => ({
1546
- agent_id: r.agentId,
1547
- status: r.status,
1548
- termination: r.termination,
1549
- ...(r.output ? { output: messageToKernelMessage(r.output) } : {}),
1550
- ...(r.classifyBranch !== undefined ? { classify_branch: r.classifyBranch } : {}),
1551
- ...(r.tournamentWinner !== undefined ? { tournament_winner: r.tournamentWinner } : {}),
1552
- ...(r.loopContinue !== undefined ? { loop_continue: r.loopContinue } : {}),
1553
- })),
1554
- }
1555
- : {}),
1556
- // R3-1: re-apply recorded runtime submissions so dynamically-appended nodes are reconstructed.
1557
- ...(opts?.resumedSubmissions && opts.resumedSubmissions.length ? { resumed_submissions: opts.resumedSubmissions } : {}),
1558
- ...(opts?.resumedSubmissionBases && opts.resumedSubmissionBases.length ? { resumed_submission_bases: opts.resumedSubmissionBases } : {}),
1559
- });
1499
+ const initialAction = await canonicalStartWorkflow(runtime, this.pendingObservations, workflowSpecToKernel(spec));
1560
1500
  const observations = this.pendingObservations.slice(observationStart);
1561
- 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;
1562
1519
  }
1563
1520
  finally {
1564
1521
  if (bootstrapped) {
1565
1522
  this.activeKernel = null;
1566
1523
  this.currentSessionId = null;
1524
+ this.abortController = null;
1567
1525
  this.pendingObservations = [];
1568
1526
  }
1569
1527
  }
1570
1528
  }
1571
- /**
1572
- * M5/G1: bootstrap an **agent-authored** workflow ("the model writes its own harness"). Routes the
1573
- * spec through the agent-reachable `Syscall::LoadWorkflow` (`submit_workflow`): with no workflow
1574
- * active the kernel bootstraps the DAG, else it flattens onto the running one (bootstrap-or-flatten —
1575
- * one kernel, one quota). The same shared driver runs the resulting batches.
1576
- */
1577
- async bootstrapWorkflow(spec, opts) {
1578
- if (!this.activeKernel || !this.currentSessionId) {
1579
- throw new Error("bootstrapWorkflow requires an active parent run");
1580
- }
1581
- const parentSessionId = this.currentSessionId;
1582
- const runtime = this.activeKernel;
1583
- const observationStart = this.pendingObservations.length;
1584
- const initialAction = kernelMaybeAction(runtime, this.pendingObservations, submitWorkflowToKernel(spec, parentSessionId, opts?.submitterAgentId));
1585
- const observations = this.pendingObservations.slice(observationStart);
1586
- // W-3: persist the agent-authored batch (bootstrap base 0 / flatten base N — the kernel now
1587
- // announces BOTH) so an interrupted authored workflow reconstructs on resume; the host never
1588
- // had this spec, unlike the `runWorkflow` path.
1589
- const submitted = observations.find(o => o.kind === "workflow_nodes_submitted");
1590
- if (submitted) {
1591
- await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
1592
- turn: runtime.turn(),
1593
- nodes: workflowSpecToKernel(spec).nodes ?? [],
1594
- baseIndex: submitted.base,
1595
- submitterAgentId: opts?.submitterAgentId,
1596
- }));
1597
- }
1598
- return this.driveWorkflow(initialAction, observations, parentSessionId, runtime);
1599
- }
1600
- /**
1601
- * M5 v2.1: drive the sub-workflow(s) a top-level agent authored via `start_workflow`, at the safe
1602
- * point (tool turn resolved → kernel in Reason). Each runs in THIS kernel (the kernel resumes the
1603
- * reason loop on `workflow_completed`), then the outcome is injected as a user message and a fresh
1604
- * `call_provider` is synthesized from the updated context (the workflow drive consumed its own
1605
- * kernel actions — same re-render pattern as the reactive-compact retry path).
1606
- */
1607
- async driveAuthoredWorkflows(runtime, action) {
1608
- const specs = this.pendingAuthoredWorkflows;
1609
- this.pendingAuthoredWorkflows = [];
1610
- this.workflowContinuation = null;
1611
- for (const spec of specs) {
1612
- await this.bootstrapWorkflow(spec);
1613
- }
1614
- const continuation = this.workflowContinuation;
1615
- if (!continuation) {
1616
- throw new Error("authored workflow completed without a provider continuation");
1617
- }
1618
- return continuation;
1619
- }
1620
1529
  /**
1621
1530
  * #2-B-ii: while a workflow batch is in flight, poll the signal source; a Critical `InterruptNow`
1622
1531
  * routes through the kernel (root in `SubAgentAwait` → preempt → `AgentPreempted` + tears the
@@ -1637,14 +1546,14 @@ export class RuntimeRunner {
1637
1546
  continue;
1638
1547
  }
1639
1548
  const observationStart = this.pendingObservations.length;
1640
- 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)));
1641
1550
  if (signalAction) {
1642
1551
  if (signalAction.kind !== "preempt_sub_agents") {
1643
1552
  throw new Error(`workflow signal returned unexpected effect: ${signalAction.kind}`);
1644
1553
  }
1645
1554
  for (const id of signalAction.agentIds)
1646
1555
  controllers.get(id)?.abort();
1647
- const continuation = kernelMaybeAction(runtime, this.pendingObservations, {
1556
+ const continuation = await this.commitKernelMaybeAction(runtime, this.pendingObservations, {
1648
1557
  kind: "preempt_result", effect_id: signalAction.effectId,
1649
1558
  });
1650
1559
  if (continuation && continuation.kind !== "call_provider" && continuation.kind !== "done") {
@@ -1663,17 +1572,16 @@ export class RuntimeRunner {
1663
1572
  return null;
1664
1573
  }
1665
1574
  /**
1666
- * Shared workflow driver for `runWorkflow` (host `load_workflow`) and `bootstrapWorkflow` (agent
1667
- * `submit_workflow`): run each kernel-emitted batch in parallel, feed completions back (appending any
1668
- * 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.
1669
1577
  */
1670
1578
  async driveWorkflow(initialAction, initial, parentSessionId, runtime, seedOutputs) {
1671
1579
  const observations = initial;
1672
1580
  const orchestrator = this.opts.subAgentOrchestrator ?? defaultSubAgentOrchestrator;
1673
1581
  const findDone = (obs) => obs.find(o => o.kind === "workflow_completed");
1674
- const acceptSpawn = (spawn) => {
1582
+ const acceptSpawn = async (spawn) => {
1675
1583
  const observationStart = this.pendingObservations.length;
1676
- const continuation = kernelMaybeAction(runtime, this.pendingObservations, {
1584
+ const continuation = await this.commitKernelMaybeAction(runtime, this.pendingObservations, {
1677
1585
  kind: "workflow_spawn_result",
1678
1586
  effect_id: spawn.effectId,
1679
1587
  started_agent_ids: spawn.nodes.map(node => String(node.agent_id ?? "")),
@@ -1701,7 +1609,7 @@ export class RuntimeRunner {
1701
1609
  }
1702
1610
  let nodes = initialAction.nodes;
1703
1611
  let budget = initialAction.budget;
1704
- acceptSpawn(initialAction);
1612
+ await acceptSpawn(initialAction);
1705
1613
  // G2: each completed node's output, keyed by agent id — a reduce node reads its deps' outputs.
1706
1614
  // W-1: on resume it is pre-seeded from the persisted node outputs, so post-resume dependents
1707
1615
  // still see their (pre-crash) dependencies' outputs.
@@ -1709,6 +1617,13 @@ export class RuntimeRunner {
1709
1617
  for (;;) {
1710
1618
  if (nodes.length === 0)
1711
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
+ }
1712
1627
  const roundBudget = budget;
1713
1628
  // #2-B-ii: per-node abort controllers + a concurrent preemption monitor (see node runner).
1714
1629
  const controllers = new Map(nodes.map(n => [n.agent_id, new AbortController()]));
@@ -1732,55 +1647,8 @@ export class RuntimeRunner {
1732
1647
  const stableId = result.agentId.replace(/-i\d+$/, "");
1733
1648
  if (stableId !== result.agentId)
1734
1649
  outputs.set(stableId, outText);
1735
- // R3-1: if this node's agent submitted more nodes, append them to the parent DAG BEFORE
1736
- // reporting the node's completion — the workflow is still active, so even a last-node
1737
- // submission keeps the DAG alive.
1738
- if (result.submittedNodes?.length) {
1739
- // G1: stamp the submitting node's agent id so the kernel coerces a quarantined submitter's
1740
- // nodes to quarantined (no topological privilege escalation).
1741
- const submitEvent = submitWorkflowNodesToKernel(result.submittedNodes, result.agentId);
1742
- const observationStart = this.pendingObservations.length;
1743
- const submitAction = kernelMaybeAction(runtime, this.pendingObservations, submitEvent);
1744
- const subObs = this.pendingObservations.slice(observationStart);
1745
- const nodesRejected = subObs.find(observation => observation.kind === "nodes_rejected");
1746
- const rejected = controlRequestRejection(subObs, "submit_workflow_nodes")
1747
- ?? (nodesRejected
1748
- ? { operation: "submit_workflow_nodes", reason: String(nodesRejected.reason ?? "request denied") }
1749
- : undefined);
1750
- if (rejected) {
1751
- const denial = `workflow node submission denied: ${rejected.reason}`;
1752
- result.result = {
1753
- ...result.result,
1754
- termination: "error",
1755
- finalMessage: { role: "assistant", content: denial, toolCalls: [] },
1756
- };
1757
- outputs.set(result.agentId, denial);
1758
- if (stableId !== result.agentId)
1759
- outputs.set(stableId, denial);
1760
- }
1761
- if (submitAction?.kind === "spawn_workflow") {
1762
- nextNodes.push(...submitAction.nodes);
1763
- budget = submitAction.budget ?? budget;
1764
- acceptSpawn(submitAction);
1765
- }
1766
- else if (submitAction) {
1767
- throw new Error(`workflow node submission returned unexpected effect: ${submitAction.kind}`);
1768
- }
1769
- // R3-1: persist the submission (kernel-shape nodes) + its kernel-reported base index
1770
- // so resume can re-apply the batch at the exact original graph position. W-N3: also the
1771
- // submitter, so resume drops batches whose submitter re-runs (it will re-submit).
1772
- const submitted = subObs.find(o => o.kind === "workflow_nodes_submitted");
1773
- if (submitted) {
1774
- await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
1775
- turn: runtime.turn(),
1776
- nodes: submitEvent.nodes ?? [],
1777
- baseIndex: submitted.base,
1778
- submitterAgentId: result.agentId,
1779
- }));
1780
- }
1781
- }
1782
1650
  const observationStart = this.pendingObservations.length;
1783
- const completionAction = kernelMaybeAction(runtime, this.pendingObservations, {
1651
+ const completionAction = await this.commitKernelMaybeAction(runtime, this.pendingObservations, {
1784
1652
  kind: "sub_agent_completed",
1785
1653
  result: subAgentResultToKernel(result),
1786
1654
  });
@@ -1788,14 +1656,31 @@ export class RuntimeRunner {
1788
1656
  if (completionAction?.kind === "spawn_workflow") {
1789
1657
  nextNodes.push(...completionAction.nodes);
1790
1658
  budget = completionAction.budget ?? budget;
1791
- obs = [...obs, ...acceptSpawn(completionAction)];
1659
+ obs = [...obs, ...await acceptSpawn(completionAction)];
1792
1660
  }
1793
1661
  else if (completionAction?.kind === "call_provider") {
1794
1662
  this.workflowContinuation = completionAction;
1795
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
+ }
1796
1668
  else if (completionAction) {
1797
1669
  throw new Error(`workflow completion returned unexpected effect: ${completionAction.kind}`);
1798
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
+ }
1799
1684
  const d = findDone(obs);
1800
1685
  if (d)
1801
1686
  done = d;
@@ -1822,38 +1707,6 @@ export class RuntimeRunner {
1822
1707
  nodes = nextNodes;
1823
1708
  }
1824
1709
  }
1825
- /**
1826
- * Resume a workflow from the parent session's completed nodes.
1827
- * Reads the session log, extracts completed workflow node records (with their W-1 control
1828
- * signals + outputs), and calls runWorkflow so the kernel skips those nodes, replays control
1829
- * flow (classify prune / loop stop), and the driver re-seeds its outputs map.
1830
- */
1831
- async resumeWorkflow(spec, opts) {
1832
- // Standalone resume: a stateless handler passes the prior `sessionId`; mid-run callers omit it.
1833
- const sessionId = opts?.sessionId ?? this.currentSessionId;
1834
- if (!sessionId) {
1835
- throw new Error("resumeWorkflow requires an active parent run or an explicit sessionId");
1836
- }
1837
- const events = await this.opts.sessionLog.read(sessionId);
1838
- const resumedOutcomes = recoverWorkflowNodeOutcomes(events);
1839
- const completedIds = new Set(resumedOutcomes.map(r => r.agentId));
1840
- const recovered = recoverSubmittedWorkflowNodes(events);
1841
- // W-N3: DROP batches whose submitter did NOT complete — that node re-runs on resume and will
1842
- // re-submit its batch; replaying the logged copy too would duplicate its nodes in the DAG.
1843
- // Exact bases keep later graph indices stable while dropped slots remain inert placeholders.
1844
- let { submissions, bases } = recovered;
1845
- if (submissions.length > 0) {
1846
- const keep = recovered.submitters.map(s => s === undefined || completedIds.has(s));
1847
- submissions = submissions.filter((_, i) => keep[i]);
1848
- bases = bases.filter((_, i) => keep[i]);
1849
- }
1850
- return this.runWorkflow(spec, {
1851
- resumedOutcomes,
1852
- resumedSubmissions: submissions,
1853
- resumedSubmissionBases: bases,
1854
- sessionId,
1855
- });
1856
- }
1857
1710
  async appendObservations(sessionId, runtime, nextArchiveStart) {
1858
1711
  const turn = runtime.turn();
1859
1712
  const preservedRefs = runtime.preservedRefs();
@@ -1909,7 +1762,7 @@ export class RuntimeRunner {
1909
1762
  }
1910
1763
  }
1911
1764
  if (lines.length > 0) {
1912
- kernelApply(runtime, this.pendingObservations, {
1765
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1913
1766
  kind: "add_history_message",
1914
1767
  message: { role: "user", content: lines.join("\n") },
1915
1768
  });
@@ -2093,72 +1946,6 @@ function parseUpdatePlanArgs(argsStr) {
2093
1946
  : parsed.blocked_on,
2094
1947
  };
2095
1948
  }
2096
- /** R3-1: parse `submit_workflow_nodes` tool args (`{ nodes: WorkflowNodeSpec[] }`). Node shapes are
2097
- * trusted structurally; the kernel validates them on append. Malformed payload → no nodes. */
2098
- function parseSubmitWorkflowNodesArgs(argsStr) {
2099
- let parsed = {};
2100
- try {
2101
- parsed = JSON.parse(argsStr);
2102
- }
2103
- catch {
2104
- // Ignore parse error → no nodes submitted.
2105
- }
2106
- return Array.isArray(parsed.nodes) ? parsed.nodes : [];
2107
- }
2108
- /** M5 v1: parse `start_workflow` tool args (`{ spec: { nodes: WorkflowNodeSpec[] } }`) into the
2109
- * spec's node batch — flattened onto the running workflow via the same append path. */
2110
- function parseStartWorkflowArgs(argsStr) {
2111
- let parsed = {};
2112
- try {
2113
- parsed = JSON.parse(argsStr);
2114
- }
2115
- catch {
2116
- // Ignore parse error → no nodes.
2117
- }
2118
- const spec = parsed.spec;
2119
- return Array.isArray(spec?.nodes) ? spec.nodes : [];
2120
- }
2121
- /** M5 v2.1: parse the full `WorkflowSpec` from a top-level `start_workflow` call for the auto-pivot
2122
- * drive. Returns `undefined` on a malformed / empty payload (caller falls back to the flatten path). */
2123
- function parseStartWorkflowSpec(argsStr) {
2124
- try {
2125
- const parsed = JSON.parse(argsStr);
2126
- if (Array.isArray(parsed.spec?.nodes) && parsed.spec.nodes.length > 0) {
2127
- return { nodes: parsed.spec.nodes };
2128
- }
2129
- }
2130
- catch {
2131
- // Ignore parse error → undefined (fall back to flatten).
2132
- }
2133
- return undefined;
2134
- }
2135
- /** M5 v2.1: render an authored-workflow outcome into a user-message note injected back into the
2136
- * agent's context, so its next turn continues with the sub-workflow's results in view. */
2137
- function recoveredOutputs(outcomes) {
2138
- const outputs = new Map();
2139
- for (const outcome of outcomes ?? []) {
2140
- if (!outcome.output)
2141
- continue;
2142
- outputs.set(outcome.agentId, outcome.output.content);
2143
- outputs.set(outcome.agentId.replace(/-i\d+$/, ""), outcome.output.content);
2144
- }
2145
- return outputs;
2146
- }
2147
- function authoredWorkflowOutcomeNote(outcome) {
2148
- const counts = new Map();
2149
- for (const node of outcome.nodeOutcomes)
2150
- counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
2151
- const lines = [
2152
- `[authored workflow result] ${outcome.nodeOutcomes.length} terminal node(s): ` +
2153
- [...counts.entries()].map(([status, count]) => `${count} ${status}`).join(", ") + ".",
2154
- ];
2155
- for (const node of outcome.nodeOutcomes) {
2156
- const out = outcome.outputs[node.nodeId] ?? node.output?.content;
2157
- if (out)
2158
- lines.push(`- ${node.nodeId} (${node.status}): ${out.length > 500 ? out.slice(0, 500) + "…" : out}`);
2159
- }
2160
- return lines.join("\n");
2161
- }
2162
1949
  /** Lower a claimed signal delivery to the kernel's `deliver_signal` input event. Shared by the main
2163
1950
  * loop's per-turn poll and #2-B-ii's workflow-batch preemption monitor (so the two never drift). */
2164
1951
  function signalToKernelEvent(delivery) {