@deepstrike/sdk 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 (39) hide show
  1. package/README.md +83 -60
  2. package/dist/index.d.ts +5 -7
  3. package/dist/index.js +3 -3
  4. package/dist/kernel.d.ts +61 -31
  5. package/dist/runtime/canonical-kernel-step.d.ts +143 -0
  6. package/dist/runtime/canonical-kernel-step.js +1444 -0
  7. package/dist/runtime/execution-plane.d.ts +0 -3
  8. package/dist/runtime/execution-plane.js +0 -24
  9. package/dist/runtime/facade.js +3 -0
  10. package/dist/runtime/kernel-event-log.js +7 -13
  11. package/dist/runtime/kernel-journal.d.ts +264 -0
  12. package/dist/runtime/kernel-journal.js +741 -0
  13. package/dist/runtime/kernel-primitives-dashboard.d.ts +0 -2
  14. package/dist/runtime/kernel-primitives-dashboard.js +1 -8
  15. package/dist/runtime/kernel-step.d.ts +29 -109
  16. package/dist/runtime/kernel-step.js +47 -317
  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 +16 -0
  20. package/dist/runtime/payload-store.js +80 -0
  21. package/dist/runtime/runner.d.ts +31 -114
  22. package/dist/runtime/runner.js +689 -774
  23. package/dist/runtime/session-log.d.ts +34 -32
  24. package/dist/runtime/session-log.js +21 -131
  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 +1 -1
  28. package/dist/runtime/sub-agent-orchestrator.js +42 -40
  29. package/dist/types/agent.d.ts +22 -19
  30. package/dist/types/agent.js +26 -42
  31. package/dist/workflow/public.d.ts +1 -1
  32. package/dist/workflow/public.js +1 -1
  33. package/package.json +2 -2
  34. package/dist/runtime/kernel-rebuild.d.ts +0 -13
  35. package/dist/runtime/kernel-rebuild.js +0 -75
  36. package/dist/runtime/kernel-transaction-log.d.ts +0 -61
  37. package/dist/runtime/kernel-transaction-log.js +0 -149
  38. package/dist/runtime/large-result-spool.d.ts +0 -93
  39. package/dist/runtime/large-result-spool.js +0 -214
@@ -1,13 +1,15 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { extractSessionMemories } from "../memory/extraction.js";
2
3
  import { resolvePermissionRequest } from "./execution-plane.js";
3
4
  import { GroupBudgetScope } from "./run-group.js";
4
5
  import { getKernel } from "../kernel.js";
5
6
  import { peekProviderReplay, seedProviderReplayFromEvents } from "./provider-replay.js";
6
7
  import { sanitizeReplayText } from "./replay-sanitize.js";
7
- import { buildLlmCompletedEvent, buildRunTerminalEvent, buildWorkflowNodeCompletedEvent, buildWorkflowNodesSubmittedEvent, recoverWorkflowNodeOutcomes, recoverSubmittedWorkflowNodes, repairEventsForRecovery, } from "./session-repair.js";
8
+ import { buildLlmCompletedEvent, buildRunTerminalEvent, buildWorkflowNodeCompletedEvent, buildWorkflowNodesSubmittedEvent, } from "./session-repair.js";
8
9
  import { KernelPrimitivesDashboard } from "./kernel-primitives-dashboard.js";
9
- import { capabilityMarker, capabilitySkill, capabilityTool, capabilityCommandMount, capabilityCommandUnmount, entropySampleFromObservation, durableKernelAction, durableKernelApply, durableKernelMaybeAction, messageToKernelMessage, skillMetadataToKernel, taskUpdateToKernel, toolResultToKernel, toolSchemaToKernel, } from "./kernel-step.js";
10
- import { agentRunSpecToKernel, findSpawnProcessObservation, milestoneCheckPass, milestoneCheckResultToKernel, spawnObservationToManifest, subAgentResultToKernel, submitWorkflowNodesToKernel, submitWorkflowToKernel, workflowBudgetNote, workflowNodeOutcomeFromKernel, workflowNodeStatusFromTermination, workflowNodeToManifest, workflowNodeToSpec, workflowSpecToKernel, } from "../types/agent.js";
10
+ import { capabilityMarker, capabilitySkill, capabilityTool, capabilityCommandMount, capabilityCommandUnmount, entropySampleFromObservation, messageToKernelMessage, skillMetadataToKernel, taskUpdateToKernel, toolResultToKernel, toolSchemaToKernel, } from "./kernel-step.js";
11
+ import { CanonicalKernelRejectedError, CanonicalRunnerRuntime, canonicalKernelAction, canonicalKernelApply, canonicalKernelMaybeAction, canonicalStartAgent, canonicalStartWorkflow, } from "./canonical-kernel-step.js";
12
+ import { agentRunSpecToKernel, MILESTONE_UNVERIFIED_REASON, milestoneCheckFail, milestoneCheckPass, milestoneCheckResultToKernel, subAgentResultToKernel, workflowBudgetNote, workflowNodeSpecToKernel, workflowNodeOutcomeFromKernel, workflowNodeStatusFromTermination, workflowNodeToManifest, workflowNodeToSpec, workflowSpecToKernel, } from "../types/agent.js";
11
13
  import { defaultSubAgentOrchestrator } from "./sub-agent-orchestrator.js";
12
14
  import { extractJsonValue, schemaInstruction, schemaRetryInstruction, validateAgainstSchema, } from "./output-schema.js";
13
15
  import { resolveReducer } from "./reducers.js";
@@ -15,7 +17,7 @@ import { loopInstruction, classifyInstruction, judgeGoal, dependencyOutputsNote,
15
17
  import { governancePolicyToKernelEvent, governanceFilterSchema } from "../governance.js";
16
18
  import { kernelObservationToSessionEvent } from "./kernel-event-log.js";
17
19
  import { assertNativeProfile } from "./os-profile.js";
18
- import { LargeResultSpool } from "./large-result-spool.js";
20
+ import { PayloadStore } from "./payload-store.js";
19
21
  import { formatToolError } from "../tools/errors.js";
20
22
  import { ManagedTaskScope } from "./reliability.js";
21
23
  import { contextPolicyV1, normalizeContextPolicyV1, } from "./context-policy.js";
@@ -36,13 +38,45 @@ export function schedulerPolicyToKernel(policy) {
36
38
  token_cost_weight: policy.tokenCostWeight,
37
39
  };
38
40
  }
39
- /** Kernel observation kinds owned by the memory lifecycle consumer (journal + store mirror). */
40
- function isMemoryLifecycleObservation(obs) {
41
- return obs.kind === "memory_written"
42
- || obs.kind === "memory_queried"
43
- || obs.kind === "memory_validation_failed"
44
- || obs.kind === "memory_recalled"
45
- || obs.kind === "promotion_suggested";
41
+ function kernelReliabilityToKernel(policy) {
42
+ const allowed = new Set(["providerRecoveryAttempts", "outputRecoveryAttempts", "maxInputBytes"]);
43
+ const unknown = Object.keys(policy).filter(key => !allowed.has(key));
44
+ if (unknown.length > 0) {
45
+ throw new TypeError(`unknown kernel reliability field(s): ${unknown.join(", ")}`);
46
+ }
47
+ return {
48
+ ...(policy.providerRecoveryAttempts !== undefined
49
+ ? { provider_recovery_attempts: policy.providerRecoveryAttempts }
50
+ : {}),
51
+ ...(policy.outputRecoveryAttempts !== undefined
52
+ ? { output_recovery_attempts: policy.outputRecoveryAttempts }
53
+ : {}),
54
+ ...(policy.maxInputBytes !== undefined ? { max_input_bytes: policy.maxInputBytes } : {}),
55
+ };
56
+ }
57
+ function memoryPolicyToKernel(policy) {
58
+ const allowed = new Set([
59
+ "staleWarningDays",
60
+ "retrievalTopK",
61
+ "validationEnabled",
62
+ "maxContentBytes",
63
+ "maxNameLength",
64
+ "promotionRecallThreshold",
65
+ ]);
66
+ const unknown = Object.keys(policy).filter(key => !allowed.has(key));
67
+ if (unknown.length > 0) {
68
+ throw new TypeError(`unknown memory policy field(s): ${unknown.join(", ")}`);
69
+ }
70
+ return {
71
+ ...(policy.staleWarningDays !== undefined ? { stale_warning_days: policy.staleWarningDays } : {}),
72
+ ...(policy.retrievalTopK !== undefined ? { retrieval_top_k: policy.retrievalTopK } : {}),
73
+ ...(policy.validationEnabled !== undefined ? { validation_enabled: policy.validationEnabled } : {}),
74
+ ...(policy.maxContentBytes !== undefined ? { max_content_bytes: policy.maxContentBytes } : {}),
75
+ ...(policy.maxNameLength !== undefined ? { max_name_length: policy.maxNameLength } : {}),
76
+ ...(policy.promotionRecallThreshold !== undefined
77
+ ? { promotion_recall_threshold: policy.promotionRecallThreshold }
78
+ : {}),
79
+ };
46
80
  }
47
81
  function controlRequestRejection(observations, operation) {
48
82
  const rejected = observations.find(observation => observation.kind === "control_request_rejected"
@@ -71,6 +105,17 @@ function pendingCallIds(action) {
71
105
  return "effectId" in action ? [action.effectId] : [];
72
106
  }
73
107
  }
108
+ function utf8Prefix(value, maxBytes) {
109
+ if (maxBytes <= 0)
110
+ return "";
111
+ const bytes = Buffer.from(value, "utf8");
112
+ if (bytes.byteLength <= maxBytes)
113
+ return value;
114
+ let end = maxBytes;
115
+ while (end > 0 && (bytes[end] & 0xC0) === 0x80)
116
+ end -= 1;
117
+ return bytes.subarray(0, end).toString("utf8");
118
+ }
74
119
  export class RuntimeRunner {
75
120
  opts;
76
121
  interrupted = false;
@@ -81,6 +126,7 @@ export class RuntimeRunner {
81
126
  activeGroupBudgetScope;
82
127
  pendingObservations = [];
83
128
  currentSessionId = null;
129
+ fallbackPayloadStore = null;
84
130
  /** O2 (system-reminder channel): host-pushed notes awaiting the next turn-boundary drain. */
85
131
  injectedSignals = [];
86
132
  /** Skill names whose content has already been pushed into the durable `knowledge` slot this
@@ -92,9 +138,6 @@ export class RuntimeRunner {
92
138
  activePageOutArchive;
93
139
  /** K4: the active run's goal, kept for the renewal-boundary memory re-query. */
94
140
  currentGoal = "";
95
- /** M5 v2.1: sub-workflow specs a top-level agent authored via `start_workflow`, awaiting auto-drive
96
- * at the next safe point (after the tool turn resolves, kernel back in Reason — not suspended). */
97
- pendingAuthoredWorkflows = [];
98
141
  workflowContinuation = null;
99
142
  dashboard = null;
100
143
  /** Most recent kernel entropy sample of the active/last run (see `latestEntropy`). */
@@ -110,6 +153,10 @@ export class RuntimeRunner {
110
153
  if (!Number.isInteger(schemaAttempts) || schemaAttempts < 1 || schemaAttempts > 16) {
111
154
  throw new RangeError("workflowSchemaValidationAttempts must be an integer between 1 and 16");
112
155
  }
156
+ if (opts.kernelReliability)
157
+ kernelReliabilityToKernel(opts.kernelReliability);
158
+ if (opts.memoryPolicy)
159
+ memoryPolicyToKernel(opts.memoryPolicy);
113
160
  this.composedSystemPrompt = composeSystemPrompt(opts.systemPrompt, opts.instructions);
114
161
  if (opts.enableDiagnosticsDashboard) {
115
162
  const originalAppend = opts.sessionLog.append.bind(opts.sessionLog);
@@ -148,14 +195,23 @@ export class RuntimeRunner {
148
195
  throw new Error("durable kernel transitions require a session id");
149
196
  return resolved;
150
197
  }
151
- async commitKernelApply(runtime, pending, event, sessionId) {
152
- return durableKernelApply(runtime, this.opts.sessionLog, this.durableSessionId(sessionId), pending, event);
198
+ async commitKernelApply(runtime, pending, event, _sessionId) {
199
+ return canonicalKernelApply(runtime, pending, event);
153
200
  }
154
- async commitKernelMaybeAction(runtime, pending, event, sessionId) {
155
- return durableKernelMaybeAction(runtime, this.opts.sessionLog, this.durableSessionId(sessionId), pending, event);
201
+ async commitKernelMaybeAction(runtime, pending, event, _sessionId) {
202
+ return canonicalKernelMaybeAction(runtime, pending, event);
156
203
  }
157
- async commitKernelAction(runtime, pending, event, sessionId) {
158
- return durableKernelAction(runtime, this.opts.sessionLog, this.durableSessionId(sessionId), pending, event);
204
+ async commitKernelAction(runtime, pending, event, _sessionId) {
205
+ return canonicalKernelAction(runtime, pending, event);
206
+ }
207
+ async startKernelAgent(runtime, pending, task, runSpec) {
208
+ return canonicalStartAgent(runtime, pending, task, runSpec);
209
+ }
210
+ payloadStore() {
211
+ if (this.opts.payloadStore)
212
+ return this.opts.payloadStore;
213
+ this.fallbackPayloadStore ??= new PayloadStore();
214
+ return this.fallbackPayloadStore;
159
215
  }
160
216
  async persistMemoryToStore(memory, agentId) {
161
217
  if (!this.opts.dreamStore)
@@ -169,31 +225,14 @@ export class RuntimeRunner {
169
225
  .slice(0, requestedK);
170
226
  }
171
227
  /**
172
- * T5: run one memory query through the kernel's `query_memory memory_query_result`
173
- * effect lifecycle on the given runtime. The kernel injects each routed hit into history
174
- * itself and derives the recall lifecycle (`memory_recalled`, edge-triggered
175
- * `promotion_suggested`) from the routed hits — the store stays a pure query.
176
- *
177
- * `seenRecordIds` is one dedupe horizon: a record hit by several queries of the same
178
- * prefetch is routed (recalled, injected) once. The kernel derives counts statelessly from
179
- * each hit's payload, so host-side pre-filtering is the only place duplicates can be stopped.
180
- *
181
- * The recall lifecycle is consumed immediately rather than via the per-turn drain: the store
182
- * must be up to date before any same-turn re-query, and a renewal prefetch fires inside the
183
- * drain loop where queued observations would not be consumed until the next boundary.
184
- * Non-memory observations are forwarded to `leftovers` (the run's pending queue) when given,
185
- * and discarded for detached syscall runtimes (their kernel is throwaway).
228
+ * Route a host-originated renewal prefetch into canonical knowledge commands. This is not an
229
+ * agent syscall: the host selects records from its store, then the kernel owns the only mutation
230
+ * of live semantic context. `seenRecordIds` is the prefetch's dedupe horizon.
186
231
  */
187
- async queryMemoryThroughKernel(runtime, query, agentId, sessionId, seenRecordIds, leftovers) {
188
- const observations = [];
189
- const action = await this.commitKernelAction(runtime, observations, { kind: "query_memory", query }, sessionId);
190
- if (action.kind !== "query_memory") {
191
- throw new Error(`query_memory returned unexpected kernel effect: ${action.kind}`);
192
- }
232
+ async prefetchMemoryIntoKnowledge(runtime, query, agentId, sessionId, seenRecordIds, leftovers) {
193
233
  let hits = [];
194
- let ioError;
195
234
  try {
196
- hits = await this.retrieveMemoryFromStore(query, action.requestedK, agentId);
235
+ hits = await this.retrieveMemoryFromStore(query, query.top_k, agentId);
197
236
  if (seenRecordIds) {
198
237
  hits = hits.filter(hit => {
199
238
  const id = hit.record.record_id;
@@ -203,77 +242,94 @@ export class RuntimeRunner {
203
242
  return true;
204
243
  });
205
244
  }
206
- }
207
- catch (cause) {
208
- ioError = cause;
209
- }
210
- // Close the kernel effect even when the store failed — never leave a dangling query.
211
- // The result commit resumes the kernel's reasoning path (`resume_after_preload`), which
212
- // re-emits the next loop action with the routed hits now in history — callers on a live
213
- // run kernel must adopt it (a query is a preload, not a detached side-channel).
214
- const resumed = await this.commitKernelMaybeAction(runtime, observations, {
215
- kind: "memory_query_result",
216
- effect_id: action.effectId,
217
- hits,
218
- ...(ioError ? { error: formatToolError(ioError) } : {}),
219
- }, sessionId);
220
- await this.consumeMemoryLifecycleObservations(sessionId, observations);
221
- if (leftovers) {
222
- for (const obs of observations) {
223
- if (!isMemoryLifecycleObservation(obs))
224
- leftovers.push(obs);
245
+ for (const hit of hits) {
246
+ await this.commitKernelApply(runtime, leftovers ?? [], {
247
+ kind: "add_knowledge_message",
248
+ key: `memory:${hit.record.record_id}`,
249
+ content: hit.record.content,
250
+ tokens: Math.max(1, Math.ceil(hit.record.content.length / 4)),
251
+ }, sessionId);
225
252
  }
253
+ await this.applyHostMemoryRecallLifecycle(hits, agentId);
254
+ await this.logMemoryRetrievalResult(sessionId, hits);
226
255
  }
227
- if (ioError)
228
- throw ioError;
229
- return { hits, action: resumed };
256
+ catch {
257
+ return { hits: [], action: runtime.resumeAction() };
258
+ }
259
+ return { hits, action: runtime.resumeAction() };
230
260
  }
231
261
  async writeMemory(memory, opts = {}) {
232
262
  const sessionId = opts.sessionId ?? this.currentSessionId;
233
263
  const agentId = opts.agentId ?? this.opts.agentId;
234
264
  if (!this.opts.dreamStore || !agentId)
235
265
  return;
236
- const durableSessionId = this.durableSessionId(sessionId);
237
- const observations = [];
238
- const runtime = this.createSyscallRuntime();
239
- const action = await this.commitKernelMaybeAction(runtime, observations, { kind: "write_memory", memory }, durableSessionId);
240
- if (!action) {
241
- await this.consumeMemoryLifecycleObservations(sessionId, observations);
242
- return;
243
- }
244
- if (action.kind !== "persist_memory") {
245
- throw new Error(`write_memory returned unexpected kernel effect: ${action.kind}`);
266
+ const policy = this.opts.memoryPolicy;
267
+ if (policy?.validationEnabled !== false) {
268
+ const error = !memory.name.trim()
269
+ ? "memory name must not be empty"
270
+ : memory.name.length > (policy?.maxNameLength ?? 100)
271
+ ? `memory name exceeds ${policy?.maxNameLength ?? 100} characters`
272
+ : Buffer.byteLength(memory.content, "utf8") > (policy?.maxContentBytes ?? 10_000)
273
+ ? `memory content exceeds ${policy?.maxContentBytes ?? 10_000} bytes`
274
+ : undefined;
275
+ if (error) {
276
+ if (sessionId) {
277
+ await this.opts.sessionLog.append(sessionId, {
278
+ kind: "memory_validation_failed",
279
+ turn: this.activeKernel?.turn() ?? 0,
280
+ record_id: memory.record_id,
281
+ error,
282
+ });
283
+ }
284
+ return;
285
+ }
246
286
  }
247
- let ioError;
248
- try {
249
- await this.persistMemoryToStore(action.memory, agentId);
287
+ await this.persistMemoryToStore(memory, agentId);
288
+ if (sessionId) {
289
+ await this.opts.sessionLog.append(sessionId, {
290
+ kind: "memory_written",
291
+ turn: this.activeKernel?.turn() ?? 0,
292
+ record_id: memory.record_id,
293
+ scope: memory.scope,
294
+ memory_kind: memory.kind,
295
+ name: memory.name,
296
+ size_bytes: Buffer.byteLength(memory.content, "utf8"),
297
+ });
250
298
  }
251
- catch (cause) {
252
- ioError = cause;
253
- }
254
- await this.commitKernelApply(runtime, observations, {
255
- kind: "memory_persist_result",
256
- effect_id: action.effectId,
257
- ...(ioError ? { error: formatToolError(ioError) } : {}),
258
- }, durableSessionId);
259
- await this.consumeMemoryLifecycleObservations(durableSessionId, observations);
260
- if (ioError)
261
- throw ioError;
262
299
  }
263
300
  async queryMemory(query, opts = {}) {
264
301
  const sessionId = opts.sessionId ?? this.currentSessionId;
265
302
  const agentId = opts.agentId ?? this.opts.agentId;
266
303
  if (!this.opts.dreamStore || !agentId)
267
304
  return [];
268
- const durableSessionId = this.durableSessionId(sessionId);
269
- // Detached syscall runtime: the kernel's history injection and resumed action are inert
270
- // (the kernel is throwaway), but the recall lifecycle is identical to an in-run query
271
- // (T5 parity).
272
- const runtime = this.createSyscallRuntime();
273
- const { hits } = await this.queryMemoryThroughKernel(runtime, query, agentId, durableSessionId);
274
- await this.logMemoryRetrievalResult(durableSessionId, hits);
305
+ const hits = await this.retrieveMemoryFromStore(query, query.top_k, agentId);
306
+ await this.applyHostMemoryRecallLifecycle(hits, agentId);
307
+ await this.logMemoryRetrievalResult(sessionId, hits);
275
308
  return hits;
276
309
  }
310
+ async applyHostMemoryRecallLifecycle(hits, agentId) {
311
+ if (hits.length === 0)
312
+ return;
313
+ const recalls = hits.map(hit => ({
314
+ record_id: hit.record.record_id,
315
+ recall_count: hit.record.recall_count + 1,
316
+ last_recalled_at: Date.now(),
317
+ }));
318
+ await this.opts.dreamStore?.recordRecall?.(agentId, recalls);
319
+ const threshold = this.opts.memoryPolicy?.promotionRecallThreshold;
320
+ if (threshold === undefined)
321
+ return;
322
+ for (let index = 0; index < hits.length; index += 1) {
323
+ const before = hits[index].record.recall_count;
324
+ const after = recalls[index].recall_count;
325
+ if (before < threshold && after >= threshold) {
326
+ this.opts.onPromotionSuggested?.({
327
+ recordId: recalls[index].record_id,
328
+ recallCount: after,
329
+ });
330
+ }
331
+ }
332
+ }
277
333
  async logMemoryRetrievalResult(sessionId, hits) {
278
334
  if (!sessionId)
279
335
  return;
@@ -284,16 +340,35 @@ export class RuntimeRunner {
284
340
  hits,
285
341
  });
286
342
  }
287
- createSyscallRuntime() {
288
- const { KernelRuntime } = getKernel();
289
- return new KernelRuntime({
290
- maxTokens: this.opts.maxTokens,
343
+ createCanonicalRuntime(runId = crypto.randomUUID(), sessionId = this.durableSessionId()) {
344
+ const { CanonicalKernel } = getKernel();
345
+ return new CanonicalRunnerRuntime(new CanonicalKernel(), this.resolveKernelJournal(), `node-operation-${runId}`, {
346
+ maxContextTokens: this.opts.maxTokens,
291
347
  maxTurns: this.opts.maxTurns,
292
- // M4/G5: per-node token cap → child run's cumulative token budget.
293
- maxTotalTokens: this.opts.maxTotalTokens !== undefined ? BigInt(this.opts.maxTotalTokens) : undefined,
294
- timeoutMs: this.opts.timeoutMs !== undefined ? BigInt(this.opts.timeoutMs) : undefined,
348
+ maxTotalTokens: this.opts.maxTotalTokens,
349
+ maxWallMs: this.opts.timeoutMs,
350
+ memoryBindingId: `node-memory-${this.opts.agentId ?? "root"}`,
351
+ persistPayload: async (callId, content, previewBytes) => {
352
+ const digest = `sha256:${createHash("sha256").update(content).digest("hex")}`;
353
+ const payloadRef = `payload:${digest.slice("sha256:".length, "sha256:".length + 32)}`;
354
+ await this.payloadStore().persistPayload(sessionId, payloadRef, content);
355
+ return {
356
+ payloadRef,
357
+ digest,
358
+ originalSize: String(Buffer.byteLength(content, "utf8")),
359
+ preview: utf8Prefix(content, previewBytes),
360
+ };
361
+ },
295
362
  });
296
363
  }
364
+ resolveKernelJournal() {
365
+ const embedded = this.opts.sessionLog.kernelJournal;
366
+ const journal = this.opts.kernelJournal ?? embedded;
367
+ if (!journal) {
368
+ throw new Error("RuntimeOptions.kernelJournal is required when SessionLog has no canonical journal");
369
+ }
370
+ return journal;
371
+ }
297
372
  groupBudgetRequest(includeTokens = true) {
298
373
  const tokens = includeTokens ? this.opts.maxTotalTokens : undefined;
299
374
  const subagents = this.opts.resourceQuota?.maxTotalSubagents;
@@ -313,7 +388,10 @@ export class RuntimeRunner {
313
388
  };
314
389
  }
315
390
  async settleGroupBudget(scope, actual) {
316
- const retries = this.opts.kernelReliability?.hostEffectRetryAttempts ?? 3;
391
+ const retries = this.opts.groupBudgetSettlementRetries ?? 3;
392
+ if (!Number.isInteger(retries) || retries < 0 || retries > 16) {
393
+ throw new RangeError("groupBudgetSettlementRetries must be an integer in 0..16");
394
+ }
317
395
  for (let attempt = 0;; attempt += 1) {
318
396
  try {
319
397
  await scope.settle(actual);
@@ -327,9 +405,9 @@ export class RuntimeRunner {
327
405
  }
328
406
  /**
329
407
  * Lower the declarative governance / attention / scheduler-budget / resource-quota policies into a
330
- * freshly-created kernel. Shared by `execute()` (full agent run) and `bootstrapWorkflowKernel()`
408
+ * freshly-created kernel. Shared by `execute()` (full agent run) and `initializeWorkflowKernel()`
331
409
  * (standalone host-driven workflow) so a workflow's DAG-node spawns are gated, queued, and quota'd
332
- * exactly as a mid-run spawn would be. Must run BEFORE `start_run` so the in-kernel gate enforces
410
+ * exactly as a mid-run spawn would be. Must run before the canonical root start so the gate enforces
333
411
  * every policy from the first spawn. No config ⇒ the native-profile defaults (铁律: defaults only).
334
412
  */
335
413
  async applyKernelPolicies(runtime, groupBudgetScope) {
@@ -348,39 +426,7 @@ export class RuntimeRunner {
348
426
  config.context_policy = normalizeContextPolicyV1(contextPolicyV1(this.opts.contextPolicy));
349
427
  }
350
428
  if (this.opts.kernelReliability) {
351
- const reliability = this.opts.kernelReliability;
352
- config.reliability = {
353
- ...(reliability.eventReplayCapacity !== undefined
354
- ? { event_replay_capacity: reliability.eventReplayCapacity }
355
- : {}),
356
- ...(reliability.completedEffectReplayCapacity !== undefined
357
- ? { completed_effect_replay_capacity: reliability.completedEffectReplayCapacity }
358
- : {}),
359
- ...(reliability.providerRecoveryAttempts !== undefined
360
- ? { provider_recovery_attempts: reliability.providerRecoveryAttempts }
361
- : {}),
362
- ...(reliability.outputRecoveryAttempts !== undefined
363
- ? { output_recovery_attempts: reliability.outputRecoveryAttempts }
364
- : {}),
365
- ...(reliability.hostEffectRetryAttempts !== undefined
366
- ? { host_effect_retry_attempts: reliability.hostEffectRetryAttempts }
367
- : {}),
368
- ...(reliability.spoolThresholdBytes !== undefined
369
- ? { spool_threshold_bytes: reliability.spoolThresholdBytes }
370
- : {}),
371
- ...(reliability.spoolPreviewBytes !== undefined
372
- ? { spool_preview_bytes: reliability.spoolPreviewBytes }
373
- : {}),
374
- ...(reliability.snapshotInputLimit !== undefined
375
- ? { snapshot_input_limit: reliability.snapshotInputLimit }
376
- : {}),
377
- ...(reliability.maxInputBytes !== undefined
378
- ? { max_input_bytes: reliability.maxInputBytes }
379
- : {}),
380
- ...(reliability.snapshotJournalBytesLimit !== undefined
381
- ? { snapshot_journal_bytes_limit: reliability.snapshotJournalBytesLimit }
382
- : {}),
383
- };
429
+ config.reliability = kernelReliabilityToKernel(this.opts.kernelReliability);
384
430
  }
385
431
  config.signal_policy = {
386
432
  version: 1,
@@ -462,9 +508,7 @@ export class RuntimeRunner {
462
508
  await this.commitKernelApply(runtime, this.pendingObservations, { kind: "configure_run", config });
463
509
  }
464
510
  /**
465
- * Mirror one kernel memory-lifecycle observation into the durable store / host callbacks.
466
- * Shared by the main run drain, the prefetch path, and the host memory syscalls so every
467
- * query route has identical recall + promotion semantics (T5).
511
+ * Mirror one agent-syscall memory-lifecycle observation into the durable store / host callbacks.
468
512
  *
469
513
  * M3: `memory_recalled` carries the kernel-derived count — the runner never computes
470
514
  * `recall_count + 1` itself. M4: `promotion_suggested` is advisory and already
@@ -484,19 +528,6 @@ export class RuntimeRunner {
484
528
  });
485
529
  }
486
530
  }
487
- async consumeMemoryLifecycleObservations(sessionId, observations) {
488
- const turn = this.activeKernel?.turn() ?? 0;
489
- for (const obs of observations) {
490
- if (!isMemoryLifecycleObservation(obs))
491
- continue;
492
- await this.mirrorMemoryLifecycle(obs);
493
- if (!sessionId)
494
- continue;
495
- const event = kernelObservationToSessionEvent(obs, turn);
496
- if (event)
497
- await this.opts.sessionLog.append(sessionId, event);
498
- }
499
- }
500
531
  /** Mount a tool capability on the currently-running kernel runtime. No-op if not running. */
501
532
  async mountTool(schema) {
502
533
  if (!this.activeKernel)
@@ -554,48 +585,6 @@ export class RuntimeRunner {
554
585
  // Re-arm the SDK-side push guard so a re-activation re-pins the content.
555
586
  this.knowledgePushedSkills.delete(name);
556
587
  }
557
- /**
558
- * Spawn an isolated sub-agent via the kernel, run it on the host, and feed the result back.
559
- * Requires an active parent run (`run()` / `wake()` in progress or paused at milestone).
560
- */
561
- async *spawnSubAgent(spec) {
562
- if (!this.activeKernel || !this.currentSessionId) {
563
- throw new Error("spawnSubAgent requires an active parent run");
564
- }
565
- const parentSessionId = this.currentSessionId;
566
- const runtime = this.activeKernel;
567
- const observations = await this.commitKernelApply(runtime, this.pendingObservations, {
568
- kind: "spawn_sub_agent",
569
- spec: agentRunSpecToKernel(spec),
570
- parent_session_id: parentSessionId,
571
- });
572
- this.nextArchiveStart = await this.appendObservations(parentSessionId, runtime, this.nextArchiveStart);
573
- const spawned = findSpawnProcessObservation(observations);
574
- if (!spawned) {
575
- const rejected = controlRequestRejection(observations, "spawn_sub_agent");
576
- if (rejected) {
577
- yield { type: "error", message: `spawn_sub_agent denied: ${rejected.reason}` };
578
- return;
579
- }
580
- throw new Error("spawn_sub_agent did not emit agent_process_changed");
581
- }
582
- const manifest = spawnObservationToManifest(spawned, spec, parentSessionId);
583
- const orchestrator = this.opts.subAgentOrchestrator ?? defaultSubAgentOrchestrator;
584
- const result = await orchestrator.run({
585
- parentOpts: this.opts,
586
- parentSessionId,
587
- spec,
588
- manifest,
589
- sessionLog: this.opts.sessionLog,
590
- toolAccess: spec.toolAccess,
591
- ...(this.opts.subAgentHarness ? { harness: this.opts.subAgentHarness } : {}),
592
- });
593
- await this.commitKernelApply(runtime, this.pendingObservations, {
594
- kind: "sub_agent_completed",
595
- result: subAgentResultToKernel(result),
596
- });
597
- yield { type: "done", iterations: result.result.turnsUsed, totalTokens: result.result.totalTokensUsed, status: result.result.termination };
598
- }
599
588
  /**
600
589
  * G3: run one workflow node, enforcing its `output_schema` (if any). Without a schema this is a
601
590
  * plain `orchestrator.run`. With one, the node's agent is instructed to emit conforming JSON, its
@@ -625,8 +614,8 @@ export class RuntimeRunner {
625
614
  spec: { ...baseSpec, goal: withBudget(goal) },
626
615
  manifest,
627
616
  sessionLog: this.opts.sessionLog,
628
- // M5 v2.1: this child IS a workflow node its `start_workflow` flattens to this kernel (the
629
- // workflow it would author joins the running DAG) rather than bootstrapping a nested pivot.
617
+ // This child is a workflow node, so capability resolution applies workflow-node quarantine
618
+ // semantics instead of treating it as an independently spawned agent.
630
619
  isWorkflowNode: true,
631
620
  // W-N1: trusted workflow nodes run on the parent's execution plane (they carry no grant list
632
621
  // by design — filtering on the missing list ran every DAG node TOOL-LESS); quarantined nodes
@@ -736,7 +725,7 @@ export class RuntimeRunner {
736
725
  */
737
726
  async runWorkflow(spec, opts) {
738
727
  // Standalone entry: with no active parent run (e.g. a stateless HTTP handler), auto-bootstrap a
739
- // kernel that owns the DAG — start_run + the same governance/quota/attention policies a full run
728
+ // kernel that owns the DAG — canonical configure + root start with the same policies a full run
740
729
  // gets — then tear it down on completion so the runner is reusable. Mid-run callers (activeKernel
741
730
  // already set by an in-flight `run()`) keep the original in-place behavior with no teardown.
742
731
  const bootstrapped = !this.activeKernel || !this.currentSessionId;
@@ -744,6 +733,7 @@ export class RuntimeRunner {
744
733
  try {
745
734
  if (bootstrapped) {
746
735
  const sessionId = opts?.sessionId ?? `wf-${crypto.randomUUID()}`;
736
+ const runId = crypto.randomUUID();
747
737
  // A standalone workflow reserves a bounded slice before its kernel schedules any node.
748
738
  // Mid-run callers reuse their parent run's already-active reservation.
749
739
  if (this.opts.runGroup) {
@@ -754,46 +744,56 @@ export class RuntimeRunner {
754
744
  // Resume depends on this fact. Do not dispatch any node until it is durable.
755
745
  await this.opts.sessionLog.append(sessionId, {
756
746
  kind: "run_started",
757
- run_id: crypto.randomUUID(),
747
+ run_id: runId,
758
748
  goal: `workflow:${spec.nodes.length} nodes`,
759
749
  criteria: [],
760
750
  agent_id: this.opts.agentId,
761
751
  });
762
- await this.bootstrapWorkflowKernel(sessionId, groupBudgetScope);
752
+ await this.initializeWorkflowKernel(sessionId, runId, groupBudgetScope);
763
753
  }
764
754
  const parentSessionId = this.currentSessionId;
765
755
  const runtime = this.activeKernel;
766
756
  const observationStart = this.pendingObservations.length;
767
- const initialAction = await this.commitKernelMaybeAction(runtime, this.pendingObservations, {
768
- kind: "load_workflow",
769
- spec: workflowSpecToKernel(spec),
770
- parent_session_id: parentSessionId,
771
- // Exact typed terminal outcomes plus control-flow signals recovered from the journal.
772
- ...(opts?.resumedOutcomes?.length
773
- ? {
774
- resumed_outcomes: opts.resumedOutcomes.map(r => ({
775
- agent_id: r.agentId,
776
- status: r.status,
777
- termination: r.termination,
778
- ...(r.output ? { output: messageToKernelMessage(r.output) } : {}),
779
- ...(r.classifyBranch !== undefined ? { classify_branch: r.classifyBranch } : {}),
780
- ...(r.tournamentWinner !== undefined ? { tournament_winner: r.tournamentWinner } : {}),
781
- ...(r.loopContinue !== undefined ? { loop_continue: r.loopContinue } : {}),
782
- })),
783
- }
784
- : {}),
785
- // R3-1: re-apply recorded runtime submissions so dynamically-appended nodes are reconstructed.
786
- ...(opts?.resumedSubmissions?.length ? { resumed_submissions: opts.resumedSubmissions } : {}),
787
- ...(opts?.resumedSubmissionBases?.length ? { resumed_submission_bases: opts.resumedSubmissionBases } : {}),
788
- });
757
+ let initialAction;
758
+ try {
759
+ initialAction = await canonicalStartWorkflow(runtime, this.pendingObservations, workflowSpecToKernel(spec));
760
+ }
761
+ catch (error) {
762
+ if (!(error instanceof CanonicalKernelRejectedError))
763
+ throw error;
764
+ return {
765
+ nodeOutcomes: [],
766
+ outputs: {},
767
+ rejection: {
768
+ operation: "start_workflow",
769
+ reason: String(error.fault.message ?? error.message),
770
+ },
771
+ };
772
+ }
789
773
  const observations = this.pendingObservations.slice(observationStart);
790
- const outcome = await this.driveWorkflow(initialAction, observations, parentSessionId, runtime, recoveredOutputs(opts?.resumedOutcomes));
774
+ const outcome = await this.driveWorkflow(initialAction, observations, parentSessionId, runtime, new Map());
791
775
  if (bootstrapped) {
792
- const terminal = await this.commitKernelAction(runtime, this.pendingObservations, { kind: "complete_run" });
776
+ let terminal = runtime.resumeAction();
777
+ if (!terminal)
778
+ throw new Error("completed canonical workflow has no terminal action");
779
+ if (terminal.kind !== "done" && this.interrupted) {
780
+ terminal = await this.commitKernelAction(runtime, this.pendingObservations, {
781
+ kind: "cancel_operation",
782
+ effect_id: terminal.effectId,
783
+ reason: this.cancellationReason ?? "user",
784
+ });
785
+ }
793
786
  if (terminal.kind !== "done") {
794
- throw new Error("complete_run did not produce a terminal kernel action");
787
+ throw new Error("canonical workflow did not produce a terminal kernel action");
795
788
  }
796
789
  await this.appendObservations(parentSessionId, runtime, 0);
790
+ if (groupBudgetScope && !groupBudgetScope.isClosed) {
791
+ await this.settleGroupBudget(groupBudgetScope, {
792
+ tokens: terminal.result.totalTokensUsed,
793
+ subagents: runtime.localSubagentsSpawned(),
794
+ });
795
+ this.activeGroupBudgetScope = undefined;
796
+ }
797
797
  }
798
798
  return outcome;
799
799
  }
@@ -816,81 +816,22 @@ export class RuntimeRunner {
816
816
  /**
817
817
  * Bootstrap a standalone kernel for a host-driven workflow with NO active parent run — the path a
818
818
  * stateless request handler takes when it calls `runWorkflow(spec)` directly. Mirrors `execute()`'s
819
- * pre-run kernel setup (governance / attention / quota via `applyKernelPolicies`, then `start_run`)
819
+ * pre-run kernel setup (governance / attention / quota via `applyKernelPolicies`, then root start)
820
820
  * after `runWorkflow` has durably recorded `run_started`. Sets `activeKernel` / `currentSessionId`;
821
821
  * `runWorkflow` is responsible for tearing them down.
822
822
  */
823
- async bootstrapWorkflowKernel(sessionId, groupBudgetScope) {
823
+ async initializeWorkflowKernel(sessionId, runId, groupBudgetScope) {
824
824
  this.interrupted = false;
825
825
  this.abortController = new AbortController();
826
826
  this.pendingObservations = [];
827
827
  this.pendingPageOutArchives = [];
828
828
  this.activePageOutArchive = undefined;
829
829
  this.currentSessionId = sessionId;
830
- const runtime = this.createSyscallRuntime();
830
+ const runtime = this.createCanonicalRuntime(runId, sessionId);
831
831
  this.activeKernel = runtime;
832
832
  await this.applyKernelPolicies(runtime, groupBudgetScope);
833
- // ABI v2 has one lifecycle: standalone workflows start a real run before loading their DAG.
834
- // The initial provider effect is superseded by the workflow load; no self-bootstrap escape hatch.
835
- await this.commitKernelAction(runtime, this.pendingObservations, {
836
- kind: "start_run",
837
- task: { goal: `workflow session ${sessionId}`, criteria: [] },
838
- });
839
833
  return runtime;
840
834
  }
841
- /**
842
- * M5/G1: bootstrap an **agent-authored** workflow ("the model writes its own harness"). Unlike
843
- * `runWorkflow` (the host fires the privileged `load_workflow`), this routes the spec through the
844
- * agent-reachable `Syscall::LoadWorkflow` (the `submit_workflow` event): with no workflow active the
845
- * kernel **bootstraps** the DAG; if one is already active it **flattens** the spec's nodes onto it
846
- * (bootstrap-or-flatten — one kernel, one quota, never a workflow stack). Gated by the same
847
- * `max_workflow_nodes` backstop as runtime submission, so an authored harness can't overgrow the run.
848
- * The resulting batches are driven by the same shared driver as `runWorkflow`.
849
- */
850
- async bootstrapWorkflow(spec, opts) {
851
- if (!this.activeKernel || !this.currentSessionId) {
852
- throw new Error("bootstrapWorkflow requires an active parent run");
853
- }
854
- const parentSessionId = this.currentSessionId;
855
- const runtime = this.activeKernel;
856
- const observationStart = this.pendingObservations.length;
857
- const initialAction = await this.commitKernelMaybeAction(runtime, this.pendingObservations, submitWorkflowToKernel(spec, parentSessionId, opts?.submitterAgentId));
858
- const observations = this.pendingObservations.slice(observationStart);
859
- // W-3: persist the agent-authored batch (bootstrap base 0 / flatten base N — the kernel now
860
- // announces BOTH) so an interrupted authored workflow reconstructs on resume; the host never
861
- // had this spec, unlike the `runWorkflow` path.
862
- const submitted = observations.find(o => o.kind === "workflow_nodes_submitted");
863
- if (submitted) {
864
- await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
865
- turn: runtime.turn(),
866
- nodes: workflowSpecToKernel(spec).nodes ?? [],
867
- baseIndex: submitted.base,
868
- submitterAgentId: opts?.submitterAgentId,
869
- }));
870
- }
871
- return this.driveWorkflow(initialAction, observations, parentSessionId, runtime);
872
- }
873
- /**
874
- * M5 v2.1: drive the sub-workflow(s) a top-level agent authored via `start_workflow`. Called at the
875
- * verified-safe point (right after the tool turn resolved to `call_provider` — kernel in Reason, not
876
- * suspended). For each authored spec: `bootstrapWorkflow` runs it in THIS kernel (the kernel resumes
877
- * the agent reason loop on `workflow_completed` — `finish_workflow` sets phase=Reason), then the
878
- * outcome is injected as a user message so the agent's next turn sees the result. Returns a fresh
879
- * `call_provider` synthesized from the updated context (the workflow drive consumed its own kernel
880
- * actions, so we re-render — the same pattern as the reactive-compact retry path).
881
- */
882
- async driveAuthoredWorkflows(runtime, action) {
883
- const specs = this.pendingAuthoredWorkflows;
884
- this.pendingAuthoredWorkflows = [];
885
- this.workflowContinuation = null;
886
- for (const spec of specs) {
887
- await this.bootstrapWorkflow(spec);
888
- }
889
- const continuation = this.workflowContinuation;
890
- if (!continuation)
891
- throw new Error("authored workflow completed without a provider continuation");
892
- return continuation;
893
- }
894
835
  /**
895
836
  * #2-B-ii: while a workflow batch is in flight, poll the signal source. A Critical `InterruptNow`
896
837
  * routes through the kernel (which, with the root suspended in `SubAgentAwait`, preempts — marks the
@@ -901,9 +842,48 @@ export class RuntimeRunner {
901
842
  */
902
843
  async monitorWorkflowPreemption(runtime, controllers, batchState) {
903
844
  const source = this.opts.signalSource;
904
- if (!source)
905
- return null;
906
845
  while (!batchState.settled) {
846
+ if (this.interrupted) {
847
+ const observationStart = this.pendingObservations.length;
848
+ let cancellation = await this.commitKernelAction(runtime, this.pendingObservations, {
849
+ kind: "cancel_operation",
850
+ reason: this.cancellationReason ?? "user",
851
+ });
852
+ let preemptedAgentIds = [...controllers.keys()];
853
+ if (cancellation.kind === "preempt_sub_agents") {
854
+ preemptedAgentIds = cancellation.agentIds;
855
+ for (const id of cancellation.agentIds) {
856
+ controllers.get(id)?.abort(this.cancellationReason ?? "user");
857
+ }
858
+ cancellation = await this.commitKernelAction(runtime, this.pendingObservations, {
859
+ kind: "preempt_result",
860
+ effect_id: cancellation.effectId,
861
+ });
862
+ }
863
+ else {
864
+ for (const controller of controllers.values()) {
865
+ controller.abort(this.cancellationReason ?? "user");
866
+ }
867
+ }
868
+ if (cancellation.kind !== "done") {
869
+ throw new Error(`workflow cancellation returned unexpected effect: ${cancellation.kind}`);
870
+ }
871
+ const observations = this.pendingObservations.slice(observationStart);
872
+ const completed = observations.find(o => o.kind === "workflow_completed");
873
+ if (completed) {
874
+ return (completed.node_outcomes ?? []).map(workflowNodeOutcomeFromKernel);
875
+ }
876
+ const preempted = observations.find(o => o.kind === "agent_preempted");
877
+ return (preempted?.agent_ids ?? preemptedAgentIds).map(nodeId => ({
878
+ nodeId,
879
+ status: "failed",
880
+ termination: "user_abort",
881
+ }));
882
+ }
883
+ if (!source) {
884
+ await new Promise(resolve => setTimeout(resolve, 5));
885
+ continue;
886
+ }
907
887
  // O2: injected notes participate in the monitor too, so a host `injectNote` mid-batch is not
908
888
  // stranded until the batch settles (the drain order matches `nextInboundSignal`).
909
889
  const delivery = await this.nextInboundSignal();
@@ -936,6 +916,8 @@ export class RuntimeRunner {
936
916
  }
937
917
  const preempted = observations.find(o => o.kind === "agent_preempted");
938
918
  if (preempted) {
919
+ this.interrupted = true;
920
+ this.cancellationReason ??= "user";
939
921
  for (const id of preempted.agent_ids ?? [])
940
922
  controllers.get(id)?.abort();
941
923
  const wc = observations.find(o => o.kind === "workflow_completed");
@@ -945,10 +927,8 @@ export class RuntimeRunner {
945
927
  return null;
946
928
  }
947
929
  /**
948
- * Shared workflow driver for `runWorkflow` (host `load_workflow`) and `bootstrapWorkflow` (agent
949
- * `submit_workflow`): given the observations from the initial load/bootstrap, run each kernel-emitted
950
- * batch in parallel, feed completions back (appending any agent-submitted nodes first), and loop
951
- * until the kernel reports the workflow complete. Returns typed terminal node outcomes.
930
+ * Drive a canonical root or provider-authored workflow from kernel effects only: run each
931
+ * emitted batch, resolve its launch/completion/preemption effects, and stop at the kernel terminal.
952
932
  */
953
933
  async driveWorkflow(initialAction, initial, parentSessionId, runtime, seedOutputs) {
954
934
  let observations = initial;
@@ -993,9 +973,16 @@ export class RuntimeRunner {
993
973
  // W-1: on resume it is pre-seeded from the persisted node outputs, so post-resume dependents
994
974
  // still see their (pre-crash) dependencies' outputs.
995
975
  const outputs = new Map(seedOutputs ?? []);
976
+ const completedNodeOutcomes = [];
996
977
  for (;;) {
997
978
  if (nodes.length === 0)
998
979
  return { nodeOutcomes: [], outputs: Object.fromEntries(outputs) }; // nothing to run (e.g. all gated)
980
+ for (const node of nodes) {
981
+ for (const [agentId, output] of Object.entries(node.dependency_outputs ?? {})) {
982
+ if (!outputs.has(agentId))
983
+ outputs.set(agentId, output);
984
+ }
985
+ }
999
986
  // Run the currently-runnable nodes in parallel — each is independent within a round.
1000
987
  const roundBudget = budget;
1001
988
  // #2-B-ii: per-node abort controllers + a concurrent preemption monitor. While the batch is in
@@ -1008,8 +995,9 @@ export class RuntimeRunner {
1008
995
  const results = await Promise.all(nodes.map(node => this.runWorkflowNode(node, parentSessionId, orchestrator, roundBudget, outputs, controllers.get(node.agent_id)?.signal)));
1009
996
  batchState.settled = true;
1010
997
  const preempted = await monitor;
1011
- if (preempted)
998
+ if (preempted !== null) {
1012
999
  return { nodeOutcomes: preempted, outputs: Object.fromEntries(outputs) };
1000
+ }
1013
1001
  // Feed completions back one at a time. The kernel's run-queue executor may spawn a node's
1014
1002
  // dependents the moment *that* node completes (per-node unblock), so each feed can emit its
1015
1003
  // own `workflow_batch_spawned`; ACCUMULATE them across the round rather than keeping only the
@@ -1023,61 +1011,17 @@ export class RuntimeRunner {
1023
1011
  const outContent = result.result.finalMessage?.content;
1024
1012
  const outText = typeof outContent === "string" ? outContent : outContent != null ? JSON.stringify(outContent) : "";
1025
1013
  outputs.set(result.agentId, outText);
1014
+ completedNodeOutcomes.push({
1015
+ nodeId: result.agentId,
1016
+ status: workflowNodeStatusFromTermination(result.result.termination),
1017
+ termination: result.result.termination,
1018
+ ...(result.result.finalMessage ? { output: result.result.finalMessage } : {}),
1019
+ });
1026
1020
  // A loop iteration completes under `wf-node{N}-i{k}` but its dependents consume the STABLE
1027
1021
  // node id `wf-node{N}` — alias it so the LAST iteration's output is what dependents see.
1028
1022
  const stableId = result.agentId.replace(/-i\d+$/, "");
1029
1023
  if (stableId !== result.agentId)
1030
1024
  outputs.set(stableId, outText);
1031
- // R3-1: if this node's agent submitted more nodes, append them to the parent DAG BEFORE
1032
- // reporting the node's completion — the workflow is still active (the kernel hasn't seen this
1033
- // node finish), so even a submission from the last running node keeps the DAG alive. The
1034
- // appended nodes' `workflow_batch_spawned` is collected into this round like any other.
1035
- if (result.submittedNodes?.length) {
1036
- // G1: stamp the submitting node's agent id so the kernel can coerce a quarantined
1037
- // submitter's nodes to quarantined (no topological privilege escalation).
1038
- const submitEvent = submitWorkflowNodesToKernel(result.submittedNodes, result.agentId);
1039
- const observationStart = this.pendingObservations.length;
1040
- const submitAction = await this.commitKernelMaybeAction(runtime, this.pendingObservations, submitEvent);
1041
- const subObs = this.pendingObservations.slice(observationStart);
1042
- const rejected = controlRequestRejection(subObs, "submit_workflow_nodes")
1043
- ?? (subObs.find(o => o.kind === "nodes_rejected")
1044
- ? { operation: "submit_workflow_nodes", reason: String(subObs.find(o => o.kind === "nodes_rejected")?.reason ?? "request denied") }
1045
- : undefined);
1046
- if (rejected) {
1047
- const denial = `workflow node submission denied: ${rejected.reason}`;
1048
- result.result = {
1049
- ...result.result,
1050
- termination: "error",
1051
- finalMessage: { role: "assistant", content: denial, toolCalls: [] },
1052
- };
1053
- outputs.set(result.agentId, denial);
1054
- if (stableId !== result.agentId)
1055
- outputs.set(stableId, denial);
1056
- }
1057
- if (submitAction?.kind === "spawn_workflow") {
1058
- nextNodes.push(...submitAction.nodes);
1059
- budget = submitAction.budget ?? budget;
1060
- const accepted = await acceptSpawn(submitAction);
1061
- const submittedDone = findDone([...subObs, ...accepted]);
1062
- if (submittedDone)
1063
- done = submittedDone;
1064
- }
1065
- else if (submitAction) {
1066
- throw new Error(`workflow node submission returned unexpected effect: ${submitAction.kind}`);
1067
- }
1068
- // R3-1: persist the submission (kernel-shape nodes) + its kernel-reported base index
1069
- // so resume can re-apply the batch at the exact original graph position. W-N3: also the
1070
- // submitter, so resume drops batches whose submitter re-runs (it will re-submit).
1071
- const submitted = subObs.find(o => o.kind === "workflow_nodes_submitted");
1072
- if (submitted) {
1073
- await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
1074
- turn: runtime.turn(),
1075
- nodes: submitEvent.nodes ?? [],
1076
- baseIndex: submitted.base,
1077
- submitterAgentId: result.agentId,
1078
- }));
1079
- }
1080
- }
1081
1025
  const observationStart = this.pendingObservations.length;
1082
1026
  const completionAction = await this.commitKernelMaybeAction(runtime, this.pendingObservations, {
1083
1027
  kind: "sub_agent_completed",
@@ -1092,9 +1036,28 @@ export class RuntimeRunner {
1092
1036
  else if (completionAction?.kind === "call_provider") {
1093
1037
  this.workflowContinuation = completionAction;
1094
1038
  }
1039
+ else if (completionAction?.kind === "done") {
1040
+ return {
1041
+ nodeOutcomes: completedNodeOutcomes,
1042
+ outputs: Object.fromEntries(outputs),
1043
+ };
1044
+ }
1095
1045
  else if (completionAction) {
1096
1046
  throw new Error(`workflow completion returned unexpected effect: ${completionAction.kind}`);
1097
1047
  }
1048
+ // ABI v3: child-authored DAG additions ride on ChildCompleted.parent_requests. Admission is
1049
+ // independent of the completion fact; only an admitted request emits this observation.
1050
+ if (result.submittedNodes?.length) {
1051
+ const submitted = obs.find(o => o.kind === "workflow_nodes_submitted");
1052
+ if (submitted) {
1053
+ await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
1054
+ turn: runtime.turn(),
1055
+ nodes: result.submittedNodes.map(workflowNodeSpecToKernel),
1056
+ baseIndex: submitted.base,
1057
+ submitterAgentId: result.agentId,
1058
+ }));
1059
+ }
1060
+ }
1098
1061
  const d = findDone(obs);
1099
1062
  if (d)
1100
1063
  done = d;
@@ -1121,39 +1084,6 @@ export class RuntimeRunner {
1121
1084
  nodes = nextNodes;
1122
1085
  }
1123
1086
  }
1124
- /**
1125
- * Resume a workflow from the parent session's completed nodes.
1126
- * Reads the session log, extracts completed workflow node records (with their W-1 control
1127
- * signals + outputs), and calls runWorkflow so the kernel skips those nodes, replays control
1128
- * flow (classify prune / loop stop), and the driver re-seeds its outputs map.
1129
- */
1130
- async resumeWorkflow(spec, opts) {
1131
- // Standalone resume: a stateless handler passes the prior `sessionId` to pick up an interrupted
1132
- // workflow from the session log. Mid-run callers omit it and resume the active session.
1133
- const sessionId = opts?.sessionId ?? this.currentSessionId;
1134
- if (!sessionId) {
1135
- throw new Error("resumeWorkflow requires an active parent run or an explicit sessionId");
1136
- }
1137
- const events = await this.opts.sessionLog.read(sessionId);
1138
- const resumedOutcomes = recoverWorkflowNodeOutcomes(events);
1139
- const completedIds = new Set(resumedOutcomes.map(r => r.agentId));
1140
- const recovered = recoverSubmittedWorkflowNodes(events);
1141
- // W-N3: DROP batches whose submitter did NOT complete — that node re-runs on resume and will
1142
- // re-submit its batch; replaying the logged copy too would duplicate its nodes in the DAG.
1143
- // Exact bases keep later graph indices stable while dropped slots remain inert placeholders.
1144
- let { submissions, bases } = recovered;
1145
- if (submissions.length > 0) {
1146
- const keep = recovered.submitters.map(s => s === undefined || completedIds.has(s));
1147
- submissions = submissions.filter((_, i) => keep[i]);
1148
- bases = bases.filter((_, i) => keep[i]);
1149
- }
1150
- return this.runWorkflow(spec, {
1151
- resumedOutcomes,
1152
- resumedSubmissions: submissions,
1153
- resumedSubmissionBases: bases,
1154
- sessionId,
1155
- });
1156
- }
1157
1087
  interrupt(reason = "user") {
1158
1088
  this.interrupted = true;
1159
1089
  this.cancellationReason = reason;
@@ -1218,7 +1148,7 @@ export class RuntimeRunner {
1218
1148
  const dispositions = this.pendingObservations.slice(observationStart).filter(observation => observation.kind === "signal_delivery_disposed"
1219
1149
  && observation.delivery_id === delivery.deliveryId
1220
1150
  && observation.attempt === delivery.deliveryAttempt);
1221
- if (dispositions.length !== 1) {
1151
+ if (dispositions.length > 1) {
1222
1152
  throw new Error("kernel did not return the matching signal delivery disposition");
1223
1153
  }
1224
1154
  if (!await delivery.ack())
@@ -1232,8 +1162,24 @@ export class RuntimeRunner {
1232
1162
  }
1233
1163
  async *run(req) {
1234
1164
  const prior = req.inheritEvents ?? await this.opts.sessionLog.read(req.sessionId);
1235
- const midRun = isMidRun(prior);
1236
1165
  const resumedStart = [...prior].reverse().find(entry => entry.event.kind === "run_started");
1166
+ // SessionLog is an audit projection. A forged/stale run_terminal must not mint a new
1167
+ // operation while the canonical journal still has a live chain — same authority as wake().
1168
+ // Inherited parent events are transcript input for a fresh child operation, never recovery
1169
+ // evidence for the child's own canonical journal.
1170
+ let midRun = req.inheritEvents ? false : isMidRun(prior);
1171
+ if (!midRun
1172
+ && resumedStart?.event.kind === "run_started"
1173
+ && !req.inheritEvents) {
1174
+ const operationId = `node-operation-${resumedStart.event.run_id}`;
1175
+ const head = await this.resolveKernelJournal().head(operationId);
1176
+ if (head) {
1177
+ const authoritative = this.createCanonicalRuntime(resumedStart.event.run_id, req.sessionId);
1178
+ await authoritative.restore();
1179
+ if (!authoritative.isTerminal())
1180
+ midRun = true;
1181
+ }
1182
+ }
1237
1183
  const runId = midRun && resumedStart?.event.kind === "run_started"
1238
1184
  ? resumedStart.event.run_id
1239
1185
  : crypto.randomUUID();
@@ -1258,12 +1204,26 @@ export class RuntimeRunner {
1258
1204
  }
1259
1205
  async *wake(sessionId, extensions) {
1260
1206
  const events = await this.opts.sessionLog.read(sessionId);
1261
- if (events.some(e => e.event.kind === "run_terminal"))
1262
- return;
1263
- const startEntry = [...events].reverse().find(e => e.event.kind === "run_started");
1207
+ const startIndex = events.reduce((latest, entry, index) => entry.event.kind === "run_started" ? index : latest, -1);
1208
+ const startEntry = startIndex >= 0 ? events[startIndex] : undefined;
1264
1209
  if (!startEntry)
1265
1210
  throw new Error(`No run_started event for session: ${sessionId}`);
1266
1211
  const start = startEntry.event;
1212
+ const projectedTerminal = events
1213
+ .slice(startIndex + 1)
1214
+ .some(e => e.event.kind === "run_terminal");
1215
+ if (projectedTerminal) {
1216
+ const operationId = `node-operation-${start.run_id}`;
1217
+ const journal = this.resolveKernelJournal();
1218
+ const head = await journal.head(operationId);
1219
+ if (!head) {
1220
+ throw new Error("run_terminal projection has no canonical journal");
1221
+ }
1222
+ const authoritative = this.createCanonicalRuntime(start.run_id, sessionId);
1223
+ await authoritative.restore();
1224
+ if (authoritative.isTerminal())
1225
+ return;
1226
+ }
1267
1227
  yield* this.execute(sessionId, start.goal, start.criteria, extensions, events, true, start.attachments, start.run_id);
1268
1228
  }
1269
1229
  /** Execute a kernel-owned approval effect and return the correlated decision lists. */
@@ -1344,62 +1304,6 @@ export class RuntimeRunner {
1344
1304
  }
1345
1305
  return { approved, denied, events };
1346
1306
  }
1347
- /**
1348
- * O7: resolve a `read_result` meta-tool call to the full text of a previously-evicted tool
1349
- * output. Resolution order: (a) the on-disk result spool committed by the explicit
1350
- * `spool_large_result` host effect, then (b) a session-log scan for the original
1351
- * `tool_completed` event carrying that `call_id`. Slices the
1352
- * resolved text by `[offset, offset + maxBytes)` (plain string slice — "bytes-ish").
1353
- */
1354
- async resolveReadResult(sessionId, argsJson) {
1355
- let callId = "";
1356
- let offset = 0;
1357
- let maxBytes = 4000;
1358
- try {
1359
- const args = JSON.parse(argsJson || "{}");
1360
- callId = typeof args.call_id === "string" ? args.call_id : "";
1361
- if (typeof args.offset === "number" && Number.isFinite(args.offset))
1362
- offset = args.offset;
1363
- if (typeof args.max_bytes === "number" && Number.isFinite(args.max_bytes))
1364
- maxBytes = args.max_bytes;
1365
- }
1366
- catch {
1367
- // malformed arguments — callId stays empty, falls through to "not found" below
1368
- }
1369
- let full;
1370
- const spool = this.opts.resultSpool ?? new LargeResultSpool();
1371
- try {
1372
- full = await spool.findByCallId(sessionId, callId);
1373
- }
1374
- catch {
1375
- full = undefined;
1376
- }
1377
- if (full === undefined) {
1378
- try {
1379
- const events = await this.opts.sessionLog.read(sessionId);
1380
- for (const { event } of events) {
1381
- if (event.kind !== "tool_completed")
1382
- continue;
1383
- const match = event.results.find(r => r.call_id === callId);
1384
- if (match)
1385
- full = match.output;
1386
- }
1387
- }
1388
- catch {
1389
- full = undefined;
1390
- }
1391
- }
1392
- if (full === undefined) {
1393
- return { text: `no stored output for call_id "${callId}"`, isError: true };
1394
- }
1395
- const start = Math.max(0, offset);
1396
- const end = Math.min(full.length, start + Math.max(0, maxBytes));
1397
- const slice = full.slice(start, end);
1398
- return {
1399
- text: `[read_result ${callId}: chars ${start}–${end} of ${full.length}]\n${slice}`,
1400
- isError: false,
1401
- };
1402
- }
1403
1307
  async *execute(sessionId, goal, criteria, extensions, priorEvents, resumeMidRun = false, attachments, runId = crypto.randomUUID()) {
1404
1308
  this.interrupted = false;
1405
1309
  this.cancellationReason = undefined;
@@ -1411,7 +1315,6 @@ export class RuntimeRunner {
1411
1315
  if (this.opts.enableDiagnosticsDashboard) {
1412
1316
  this.dashboard = new KernelPrimitivesDashboard(sessionId);
1413
1317
  }
1414
- const kernel = getKernel();
1415
1318
  const ext = { ...this.opts.extensions, ...(extensions ?? {}) };
1416
1319
  const providerState = this.opts.provider.createRunState?.();
1417
1320
  let nextCompressedArchiveStart = nextArchivedSeqStart(priorEvents);
@@ -1428,162 +1331,113 @@ export class RuntimeRunner {
1428
1331
  const taskScope = new ManagedTaskScope(operation, this.opts.onBackgroundTaskError);
1429
1332
  let groupBudgetScope;
1430
1333
  try {
1431
- const runtime = new kernel.KernelRuntime({
1432
- maxTokens: this.opts.maxTokens,
1433
- maxTurns: effectiveMaxTurns,
1434
- timeoutMs: effectiveTimeoutMs !== undefined ? BigInt(effectiveTimeoutMs) : undefined,
1435
- maxTotalTokens: this.opts.maxTotalTokens !== undefined ? BigInt(this.opts.maxTotalTokens) : undefined,
1436
- });
1334
+ const runtime = this.createCanonicalRuntime(runId, sessionId);
1335
+ if (resumeMidRun)
1336
+ await runtime.restore();
1437
1337
  this.activeKernel = runtime;
1438
1338
  this.nextArchiveStart = nextCompressedArchiveStart;
1439
- if (this.opts.tokenizer) {
1440
- await this.commitKernelApply(runtime, this.pendingObservations, {
1441
- kind: "set_tokenizer",
1442
- name: this.opts.tokenizer,
1443
- });
1444
- }
1445
- if (this.opts.enablePlanTool !== undefined) {
1446
- await this.commitKernelApply(runtime, this.pendingObservations, {
1447
- kind: "set_plan_tool_enabled",
1448
- enabled: this.opts.enablePlanTool,
1449
- });
1450
- }
1451
- await this.commitKernelApply(runtime, this.pendingObservations, {
1452
- kind: "set_tools",
1453
- tools: this.opts.executionPlane.schemas().map(toolSchemaToKernel),
1454
- });
1455
- if (this.composedSystemPrompt) {
1456
- await this.commitKernelApply(runtime, this.pendingObservations, {
1457
- kind: "add_system_message",
1458
- content: this.composedSystemPrompt,
1459
- tokens: Math.max(1, Math.ceil(this.composedSystemPrompt.length / 4)),
1460
- });
1461
- }
1462
- if (this.opts.initialMemory) {
1463
- for (const mem of this.opts.initialMemory) {
1339
+ if (!resumeMidRun) {
1340
+ if (this.opts.tokenizer) {
1464
1341
  await this.commitKernelApply(runtime, this.pendingObservations, {
1465
- kind: "add_knowledge_message",
1466
- content: mem,
1467
- tokens: Math.max(1, Math.ceil(mem.length / 4)),
1342
+ kind: "set_tokenizer",
1343
+ name: this.opts.tokenizer,
1344
+ });
1345
+ }
1346
+ if (this.opts.enablePlanTool !== undefined) {
1347
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1348
+ kind: "set_plan_tool_enabled",
1349
+ enabled: this.opts.enablePlanTool,
1468
1350
  });
1469
1351
  }
1470
- }
1471
- if (this.opts.skillDir) {
1472
- const { scanSkillDir } = await import("../skills/loader.js");
1473
- const metas = await scanSkillDir(this.opts.skillDir);
1474
- // S2 host-layer skill allowlist: keep only scanned skills named in `skillFilter` before feeding
1475
- // the catalog. Absent ⇒ feed all (identical to the pre-feature message); empty ⇒ feed none. The
1476
- // `set_available_skills` message is ALWAYS sent when a skillDir exists (shape preserved) — only
1477
- // the list narrows; the no-skillDir path stays untouched.
1478
- const filter = this.opts.skillFilter;
1479
- const selected = filter === undefined ? metas : metas.filter(m => filter.includes(m.name));
1480
- // P1-B: pass the full SkillMetadata (incl. `allowedTools`) straight through — re-mapping it
1481
- // field-by-field previously dropped `allowedTools`.
1482
- await this.commitKernelApply(runtime, this.pendingObservations, {
1483
- kind: "set_available_skills",
1484
- skills: selected.map(m => skillMetadataToKernel(m)),
1485
- });
1486
- }
1487
- // P1-B/D: configure the stable-core tool ids (always exposed under skill gating). Empty/absent
1488
- // ⇒ skills narrow to exactly their declared tools + meta-tools.
1489
- if (this.opts.stableCoreToolIds?.length) {
1490
- await this.commitKernelApply(runtime, this.pendingObservations, {
1491
- kind: "set_stable_core_tools",
1492
- tool_ids: this.opts.stableCoreToolIds,
1493
- });
1494
- }
1495
- if (this.opts.dreamStore && this.opts.agentId) {
1496
- await this.commitKernelApply(runtime, this.pendingObservations, { kind: "set_memory_enabled", enabled: true });
1497
- }
1498
- // Install optional memory policy. Maps the ergonomic camelCase option onto the kernel's
1499
- // snake_case `set_memory_policy` event; omitted fields fall back to kernel defaults.
1500
- if (this.opts.memoryPolicy) {
1501
- const m = this.opts.memoryPolicy;
1502
- await this.commitKernelApply(runtime, this.pendingObservations, {
1503
- kind: "set_memory_policy",
1504
- ...(m.memoryPath !== undefined ? { memory_path: m.memoryPath } : {}),
1505
- ...(m.staleWarningDays !== undefined ? { stale_warning_days: m.staleWarningDays } : {}),
1506
- ...(m.retrievalTopK !== undefined ? { retrieval_top_k: m.retrievalTopK } : {}),
1507
- ...(m.validationEnabled !== undefined ? { validation_enabled: m.validationEnabled } : {}),
1508
- ...(m.maxContentBytes !== undefined ? { max_content_bytes: m.maxContentBytes } : {}),
1509
- ...(m.maxNameLength !== undefined ? { max_name_length: m.maxNameLength } : {}),
1510
- ...(m.promotionRecallThreshold !== undefined
1511
- ? { promotion_recall_threshold: m.promotionRecallThreshold }
1512
- : {}),
1513
- });
1514
- }
1515
- if (this.opts.knowledgeSource) {
1516
- await this.commitKernelApply(runtime, this.pendingObservations, { kind: "set_knowledge_enabled", enabled: true });
1517
- }
1518
- if (this.opts.milestoneContract) {
1519
- await this.commitKernelApply(runtime, this.pendingObservations, {
1520
- kind: "load_milestone_contract",
1521
- contract: {
1522
- phases: this.opts.milestoneContract.phases.map(p => ({
1523
- id: p.id,
1524
- criteria: p.criteria ?? [],
1525
- unlocks: p.unlocks ?? [],
1526
- required_evidence: p.requiredEvidence ?? [],
1527
- ...(p.verifier ? { verifier: p.verifier } : {}),
1528
- })),
1529
- },
1530
- });
1531
- }
1532
- const maxBytes = runtime.recoveryContentBytes();
1533
- if (priorEvents && priorEvents.length > 0) {
1534
- const repaired = repairEventsForRecovery(priorEvents, maxBytes);
1535
- seedProviderReplayFromEvents(this.opts.provider, repaired);
1536
- const loadArchive = this.opts.compressionStore
1537
- ? (ref) => this.opts.compressionStore.read(ref)
1538
- : undefined;
1539
- const replayed = await replayMessagesAsync(repaired, maxBytes, loadArchive);
1540
1352
  await this.commitKernelApply(runtime, this.pendingObservations, {
1541
- kind: "preload_history",
1542
- messages: replayed.map(messageToKernelMessage),
1353
+ kind: "set_tools",
1354
+ tools: this.opts.executionPlane.schemas().map(toolSchemaToKernel),
1543
1355
  });
1544
- // P1-B B3: rebuild active-skill gating after a wake by re-emitting SkillActivated for each
1545
- // `skill` tool call in the replayed history (active_skills is not snapshotted — graceful).
1546
- // The catalog (set_available_skills) was already fed above, so allowed_tools resolves.
1547
- // `knowledge` isn't snapshotted either (same graceful-reset philosophy) — best-effort re-push
1548
- // the skill's content from its replayed tool_result so the durable copy survives a wake too.
1549
- const toolResultByCallId = new Map();
1550
- for (const m of replayed) {
1551
- for (const part of m.contentParts ?? []) {
1552
- if (part.type === "tool_result")
1553
- toolResultByCallId.set(part.callId, part.output);
1554
- }
1356
+ if (this.composedSystemPrompt) {
1357
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1358
+ kind: "add_system_message",
1359
+ content: this.composedSystemPrompt,
1360
+ tokens: Math.max(1, Math.ceil(this.composedSystemPrompt.length / 4)),
1361
+ });
1555
1362
  }
1556
- for (const m of replayed) {
1557
- for (const tc of m.toolCalls ?? []) {
1558
- if (tc.name !== "skill")
1559
- continue;
1560
- try {
1561
- const name = JSON.parse(tc.arguments || "{}").name;
1562
- if (!name)
1563
- continue;
1564
- await this.commitKernelApply(runtime, this.pendingObservations, {
1565
- kind: "skill_activated",
1566
- name,
1567
- ...(this.opts.skillLeaseTurns !== undefined ? { lease_turns: this.opts.skillLeaseTurns } : {}),
1568
- });
1569
- const output = toolResultByCallId.get(tc.id);
1570
- if (output && !this.knowledgePushedSkills.has(name)) {
1571
- this.knowledgePushedSkills.add(name);
1572
- // K1: keyed — the kernel-side upsert is the authoritative dedup, so a wake re-push
1573
- // of a skill already pinned live can never double-pin (the in-run Set resets with
1574
- // each runner instance; the key does not).
1575
- await this.pushKnowledge({ role: "system", content: output, toolCalls: [] }, undefined, { key: `skill:${name}` });
1576
- }
1577
- }
1578
- catch { /* malformed skill args — skip */ }
1363
+ if (this.opts.initialMemory) {
1364
+ for (const mem of this.opts.initialMemory) {
1365
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1366
+ kind: "add_knowledge_message",
1367
+ content: mem,
1368
+ tokens: Math.max(1, Math.ceil(mem.length / 4)),
1369
+ });
1579
1370
  }
1580
1371
  }
1372
+ if (this.opts.skillDir) {
1373
+ const { scanSkillDir } = await import("../skills/loader.js");
1374
+ const metas = await scanSkillDir(this.opts.skillDir);
1375
+ // S2 host-layer skill allowlist: keep only scanned skills named in `skillFilter` before feeding
1376
+ // the catalog. Absent ⇒ feed all (identical to the pre-feature message); empty ⇒ feed none. The
1377
+ // `set_available_skills` message is ALWAYS sent when a skillDir exists (shape preserved) — only
1378
+ // the list narrows; the no-skillDir path stays untouched.
1379
+ const filter = this.opts.skillFilter;
1380
+ const selected = filter === undefined ? metas : metas.filter(m => filter.includes(m.name));
1381
+ // P1-B: pass the full SkillMetadata (incl. `allowedTools`) straight through — re-mapping it
1382
+ // field-by-field previously dropped `allowedTools`.
1383
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1384
+ kind: "set_available_skills",
1385
+ skills: selected.map(m => skillMetadataToKernel(m)),
1386
+ });
1387
+ }
1388
+ // P1-B/D: configure the stable-core tool ids (always exposed under skill gating). Empty/absent
1389
+ // ⇒ skills narrow to exactly their declared tools + meta-tools.
1390
+ if (this.opts.stableCoreToolIds?.length) {
1391
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1392
+ kind: "set_stable_core_tools",
1393
+ tool_ids: this.opts.stableCoreToolIds,
1394
+ });
1395
+ }
1396
+ if (this.opts.dreamStore && this.opts.agentId) {
1397
+ await this.commitKernelApply(runtime, this.pendingObservations, { kind: "set_memory_enabled", enabled: true });
1398
+ }
1399
+ // Install optional memory policy. Maps the ergonomic camelCase option onto the kernel's
1400
+ // snake_case `set_memory_policy` event; omitted fields fall back to kernel defaults.
1401
+ if (this.opts.memoryPolicy) {
1402
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1403
+ kind: "set_memory_policy",
1404
+ ...memoryPolicyToKernel(this.opts.memoryPolicy),
1405
+ });
1406
+ }
1407
+ if (this.opts.knowledgeSource) {
1408
+ await this.commitKernelApply(runtime, this.pendingObservations, { kind: "set_knowledge_enabled", enabled: true });
1409
+ }
1410
+ if (this.opts.milestoneContract) {
1411
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1412
+ kind: "load_milestone_contract",
1413
+ contract: {
1414
+ phases: this.opts.milestoneContract.phases.map(p => ({
1415
+ id: p.id,
1416
+ criteria: p.criteria ?? [],
1417
+ unlocks: p.unlocks ?? [],
1418
+ required_evidence: p.requiredEvidence ?? [],
1419
+ ...(p.verifier ? { verifier: p.verifier } : {}),
1420
+ })),
1421
+ },
1422
+ });
1423
+ }
1424
+ }
1425
+ if (priorEvents && priorEvents.length > 0) {
1426
+ seedProviderReplayFromEvents(this.opts.provider, priorEvents);
1427
+ if (!resumeMidRun) {
1428
+ const loadArchive = this.opts.compressionStore
1429
+ ? (ref) => this.opts.compressionStore.read(ref)
1430
+ : undefined;
1431
+ const replayed = await replayMessagesAsync(priorEvents, runtime.recoveryContentBytes(), loadArchive);
1432
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1433
+ kind: "preload_history",
1434
+ messages: replayed.map(messageToKernelMessage),
1435
+ });
1436
+ }
1581
1437
  }
1582
1438
  const sessionStart = Date.now();
1583
- const startPayload = {
1584
- kind: "start_run",
1585
- task: { goal, criteria },
1586
- };
1439
+ const startTask = { goal, criteria };
1440
+ let startRunSpec;
1587
1441
  // P0-A: lower an explicit `runSpec`, the `allowedToolIds` ceiling, and/or the `baselineToolIds`
1588
1442
  // pre-activation surface to the kernel run spec. Each augments an explicit spec, else
1589
1443
  // synthesizes a minimal top-level spec carrying just the exposure config (reuses the existing
@@ -1595,7 +1449,8 @@ export class RuntimeRunner {
1595
1449
  // (meta + stable-core only), so mere presence triggers the lowering.
1596
1450
  const baselineToolIds = this.opts.baselineToolIds;
1597
1451
  const hasBaseline = baselineToolIds !== undefined;
1598
- if (this.opts.runSpec || hasProfile || hasBaseline) {
1452
+ const hasMilestoneContract = this.opts.milestoneContract !== undefined;
1453
+ if (this.opts.runSpec || hasProfile || hasBaseline || hasMilestoneContract) {
1599
1454
  const baseSpec = this.opts.runSpec ?? {
1600
1455
  identity: { agentId: this.opts.agentId ?? "root", sessionId, isSubAgent: false },
1601
1456
  role: "custom",
@@ -1606,29 +1461,37 @@ export class RuntimeRunner {
1606
1461
  : baseSpec;
1607
1462
  if (hasBaseline)
1608
1463
  spec = { ...spec, exposureBaseline: baselineToolIds };
1609
- startPayload.run_spec = agentRunSpecToKernel(spec);
1464
+ if (hasMilestoneContract && !spec.verificationContractId) {
1465
+ spec = { ...spec, verificationContractId: "node-default" };
1466
+ }
1467
+ startRunSpec = agentRunSpecToKernel(spec);
1610
1468
  }
1611
- // Reserve capacity before start_run. The kernel enforces only this vehicle's grant and reports
1469
+ // Reserve capacity before the canonical root start. The kernel enforces only this vehicle's grant and reports
1612
1470
  // exact terminal usage against the same opaque reservation identity. A nested vehicle joins for
1613
1471
  // lineage/settlement only: it reserves no budget axes (group admission governs peer vehicles),
1614
1472
  // so the parent's held reservation cannot squeeze the child's grant to zero.
1615
- if (this.opts.runGroup) {
1473
+ if (!resumeMidRun && this.opts.runGroup) {
1616
1474
  const g = this.opts.runGroup;
1617
1475
  groupBudgetScope = await GroupBudgetScope.open(g, { sessionId, role: this.opts.agentId, kind: "vehicle" }, this.opts.nestedGroupVehicle ? { limits: {}, requested: {} } : this.groupBudgetRequest());
1618
1476
  this.activeGroupBudgetScope = groupBudgetScope;
1619
1477
  }
1620
- try {
1621
- await this.applyKernelPolicies(runtime, groupBudgetScope);
1622
- }
1623
- catch (err) {
1624
- // Admission failure (e.g. the kernel rejecting a zero-capacity grant): release the
1625
- // reservation so it cannot linger in the group ledger, then surface the error.
1626
- await groupBudgetScope?.release();
1627
- this.activeGroupBudgetScope = undefined;
1628
- throw err;
1478
+ if (!resumeMidRun) {
1479
+ try {
1480
+ await this.applyKernelPolicies(runtime, groupBudgetScope);
1481
+ }
1482
+ catch (err) {
1483
+ // Admission failure (e.g. the kernel rejecting a zero-capacity grant): release the
1484
+ // reservation so it cannot linger in the group ledger, then surface the error.
1485
+ await groupBudgetScope?.release();
1486
+ this.activeGroupBudgetScope = undefined;
1487
+ throw err;
1488
+ }
1629
1489
  }
1490
+ this.currentGoal = goal;
1491
+ if (!resumeMidRun)
1492
+ await this.prefetchMemoryIntoInitialContext(runtime);
1630
1493
  // Multimodal upload: seed the user's attachments (images/audio) as a history
1631
- // message before start_run pushes the "[TASK STATE]" anchor. init_task does not
1494
+ // message before root start pushes the "[TASK STATE]" anchor. init_task does not
1632
1495
  // clear history, so order becomes [attachment user msg, "Proceed…"] — both land
1633
1496
  // in the first render. On resume the message is already in the replayed history.
1634
1497
  if (!resumeMidRun && attachments?.length) {
@@ -1637,24 +1500,14 @@ export class RuntimeRunner {
1637
1500
  message: attachmentsToKernelMessage(attachments),
1638
1501
  });
1639
1502
  }
1640
- this.currentGoal = goal;
1503
+ const resumedAction = resumeMidRun ? runtime.resumeAction() : null;
1641
1504
  let action = resumeMidRun
1642
- ? await this.commitKernelAction(runtime, this.pendingObservations, { kind: "resume" })
1643
- : await this.commitKernelAction(runtime, this.pendingObservations, startPayload);
1644
- // I4/T5: pre-fetch memory before the first LLM turn so the model sees it on turn 1 instead
1645
- // of discovering it via the `memory` tool on turn 3+. It routes through the kernel's
1646
- // query_memory lifecycle and therefore runs AFTER start_run a memory query is a kernel
1647
- // preload whose result resumes the reasoning path, so issuing it pre-run would leave the
1648
- // kernel Running and start_run would fault. The resumed action from the last query
1649
- // supersedes start_run's (same pull contract; it renders the injected hits). Hits land in
1650
- // `history` as ordinary turns — single-use retrieval content that decays with the
1651
- // compression pyramid, never pinned into `knowledge`. Skipped on resumes (already in
1652
- // prior context) and when dreamStore/agentId is absent.
1653
- if (!resumeMidRun) {
1654
- const resumed = await this.prefetchMemoryIntoHistory(runtime, "initial");
1655
- if (resumed)
1656
- action = resumed;
1657
- }
1505
+ ? resumedAction ?? (() => { throw new Error("restored canonical operation has no pending effect or terminal"); })()
1506
+ : await this.startKernelAgent(runtime, this.pendingObservations, startTask, startRunSpec);
1507
+ // I4/T5: pre-fetch memory before root start so the model sees it on turn 1 instead of
1508
+ // discovering it via the `memory` tool later. Accepted hits enter `initial_context.messages`
1509
+ // and are therefore frozen into the canonical start record. Skipped on restore (the checkpoint
1510
+ // or journal already owns them) and when dreamStore/agentId is absent.
1658
1511
  // P0-C: the skill loaded and in effect going into the current turn (updated when the model's
1659
1512
  // `skill` tool call resolves). Drives the per-turn `activeSkill` metric → dwell measurement.
1660
1513
  let activeSkill;
@@ -1691,15 +1544,6 @@ export class RuntimeRunner {
1691
1544
  if (runtime.isTerminal())
1692
1545
  break;
1693
1546
  if (action.kind === "call_provider") {
1694
- // M5 v2.1: top-level auto-pivot at the safe point. If the agent authored sub-workflow(s) via
1695
- // `start_workflow`, drive each in THIS kernel now (the kernel is in Reason / `call_provider`,
1696
- // NOT suspended — driving mid-suspend would clobber the single-slot suspend state), inject the
1697
- // outcome into context, and re-render. Loop-top placement (vs only after `tool_results`) catches
1698
- // EVERY path to `call_provider` — including resuming after an approval gate — so a queued spec
1699
- // is never stranded. Drains the queue; fires once per authored batch.
1700
- if (this.pendingAuthoredWorkflows.length > 0) {
1701
- action = await this.driveAuthoredWorkflows(runtime, action);
1702
- }
1703
1547
  const providerEffectId = action.effectId;
1704
1548
  const finalToolCalls = [];
1705
1549
  let finalText = "";
@@ -1802,10 +1646,28 @@ export class RuntimeRunner {
1802
1646
  });
1803
1647
  break;
1804
1648
  }
1649
+ const canonicalToolCalls = this.opts.skillLeaseTurns === undefined
1650
+ ? finalToolCalls
1651
+ : finalToolCalls.map(call => {
1652
+ if (call.name !== "skill")
1653
+ return call;
1654
+ try {
1655
+ return {
1656
+ ...call,
1657
+ arguments: JSON.stringify({
1658
+ ...JSON.parse(call.arguments || "{}"),
1659
+ lease_turns: this.opts.skillLeaseTurns,
1660
+ }),
1661
+ };
1662
+ }
1663
+ catch {
1664
+ return call;
1665
+ }
1666
+ });
1805
1667
  const assistantMessage = {
1806
1668
  role: "assistant",
1807
1669
  content: finalText,
1808
- toolCalls: finalToolCalls,
1670
+ toolCalls: canonicalToolCalls,
1809
1671
  tokenCount: turnOutputTokens || turnTokens || undefined,
1810
1672
  };
1811
1673
  const providerEvent = {
@@ -1814,9 +1676,36 @@ export class RuntimeRunner {
1814
1676
  message: messageToKernelMessage(assistantMessage),
1815
1677
  ...(turnInputTokens > 0 ? { observed_input_tokens: turnInputTokens } : {}),
1816
1678
  ...(turnOutputTokens > 0 ? { observed_output_tokens: turnOutputTokens } : {}),
1817
- now_ms: Date.now(),
1818
1679
  ...(turnStopReason ? { stop_reason: turnStopReason } : {}),
1819
1680
  };
1681
+ if (this.opts.skillDir) {
1682
+ const skillCalls = finalToolCalls.filter(call => call.name === "skill");
1683
+ if (skillCalls.length > 0) {
1684
+ const { readSkillFile } = await import("../skills/loader.js");
1685
+ for (const call of skillCalls) {
1686
+ try {
1687
+ const name = String(JSON.parse(call.arguments || "{}").name ?? "");
1688
+ if (!name)
1689
+ continue;
1690
+ if (this.opts.skillFilter && !this.opts.skillFilter.includes(name))
1691
+ continue;
1692
+ const content = await readSkillFile(this.opts.skillDir, name);
1693
+ if (!content)
1694
+ continue;
1695
+ await this.commitKernelApply(runtime, this.pendingObservations, {
1696
+ kind: "add_knowledge_message",
1697
+ key: `skill:${name}`,
1698
+ content,
1699
+ tokens: Math.max(1, Math.ceil(content.length / 4)),
1700
+ pinned: true,
1701
+ });
1702
+ }
1703
+ catch {
1704
+ // A missing or malformed skill stays a model-visible syscall rejection.
1705
+ }
1706
+ }
1707
+ }
1708
+ }
1820
1709
  action = await this.commitKernelAction(runtime, this.pendingObservations, providerEvent);
1821
1710
  const providerReplay = peekProviderReplay(this.opts.provider, finalText, finalToolCalls);
1822
1711
  await this.opts.sessionLog.append(sessionId, buildLlmCompletedEvent({
@@ -1868,10 +1757,32 @@ export class RuntimeRunner {
1868
1757
  else if (action.kind === "persist_memory") {
1869
1758
  let error;
1870
1759
  const agentId = this.opts.agentId;
1760
+ const canonicalMemory = action.memory;
1761
+ const record = {
1762
+ record_id: `memory:${crypto.randomUUID()}`,
1763
+ scope: this.opts.memoryScope ?? { tenant_id: "default", namespace: agentId ?? "default" },
1764
+ name: String(canonicalMemory.name ?? ""),
1765
+ kind: String(canonicalMemory.kind ?? "reference"),
1766
+ content: String(canonicalMemory.content ?? ""),
1767
+ description: String(canonicalMemory.description ?? ""),
1768
+ provenance: {
1769
+ author: "model",
1770
+ trust: "untrusted",
1771
+ evidence_refs: Array.isArray(canonicalMemory.evidence_refs)
1772
+ ? canonicalMemory.evidence_refs.map(String)
1773
+ : [],
1774
+ },
1775
+ created_at: Number(canonicalMemory.accepted_at_ms ?? Date.now()),
1776
+ updated_at: Number(canonicalMemory.accepted_at_ms ?? Date.now()),
1777
+ recall_count: 0,
1778
+ confidence: 1,
1779
+ links: [],
1780
+ pinned: false,
1781
+ };
1871
1782
  try {
1872
1783
  if (!agentId)
1873
1784
  throw new Error("memory persistence requires RuntimeOptions.agentId");
1874
- await this.persistMemoryToStore(action.memory, agentId);
1785
+ await this.persistMemoryToStore(record, agentId);
1875
1786
  }
1876
1787
  catch (cause) {
1877
1788
  error = formatToolError(cause);
@@ -1879,11 +1790,22 @@ export class RuntimeRunner {
1879
1790
  action = await this.commitKernelAction(runtime, this.pendingObservations, {
1880
1791
  kind: "memory_persist_result",
1881
1792
  effect_id: action.effectId,
1793
+ record_ref: record.record_id,
1882
1794
  ...(error ? { error } : {}),
1883
1795
  });
1884
1796
  }
1885
1797
  else if (action.kind === "query_memory") {
1886
- const query = action.query;
1798
+ const query = {
1799
+ scope: this.opts.memoryScope ?? {
1800
+ tenant_id: "default",
1801
+ namespace: this.opts.agentId ?? "default",
1802
+ },
1803
+ query: String(action.query.text ?? ""),
1804
+ top_k: action.requestedK,
1805
+ kinds: Array.isArray(action.query.kinds)
1806
+ ? action.query.kinds.map(String)
1807
+ : [],
1808
+ };
1887
1809
  let hits = [];
1888
1810
  let error;
1889
1811
  const agentId = this.opts.agentId;
@@ -1904,23 +1826,6 @@ export class RuntimeRunner {
1904
1826
  if (!error)
1905
1827
  await this.logMemoryRetrievalResult(sessionId, hits);
1906
1828
  }
1907
- else if (action.kind === "spool_large_result") {
1908
- const spool = this.opts.resultSpool ?? new LargeResultSpool();
1909
- let spoolRef;
1910
- let error;
1911
- try {
1912
- spoolRef = await spool.persistOutput(sessionId, action.callId, action.output);
1913
- }
1914
- catch (cause) {
1915
- error = formatToolError(cause);
1916
- }
1917
- action = await this.commitKernelAction(runtime, this.pendingObservations, {
1918
- kind: "large_result_spool_result",
1919
- effect_id: action.effectId,
1920
- ...(spoolRef ? { spool_ref: spoolRef } : {}),
1921
- ...(error ? { error } : {}),
1922
- });
1923
- }
1924
1829
  else if (action.kind === "archive_page_out") {
1925
1830
  const archiveMeta = this.activePageOutArchive
1926
1831
  ?? this.pendingPageOutArchives.shift()
@@ -1932,8 +1837,15 @@ export class RuntimeRunner {
1932
1837
  let archiveRef;
1933
1838
  let error;
1934
1839
  try {
1935
- if (this.opts.compressionStore) {
1936
- const ref = await this.opts.compressionStore.write(sessionId, archiveMeta.archiveStart, action.archived);
1840
+ if (action.payload && action.handleId) {
1841
+ archiveRef = this.opts.compressionStore
1842
+ ? await this.opts.compressionStore.write(sessionId, archiveMeta.archiveStart, action.archived ?? [])
1843
+ : undefined;
1844
+ archiveRef ??= `payload:${String(action.payload.digest).replace(/^sha256:/, "").slice(0, 32)}`;
1845
+ await this.payloadStore().persistPayload(sessionId, archiveRef, action.payload.content);
1846
+ }
1847
+ else if (this.opts.compressionStore) {
1848
+ const ref = await this.opts.compressionStore.write(sessionId, archiveMeta.archiveStart, action.archived ?? []);
1937
1849
  if (ref)
1938
1850
  archiveRef = ref;
1939
1851
  }
@@ -1941,7 +1853,7 @@ export class RuntimeRunner {
1941
1853
  catch (cause) {
1942
1854
  error = formatToolError(cause);
1943
1855
  }
1944
- const archived = action.archived;
1856
+ const archived = action.archived ?? [];
1945
1857
  const archiveAction = compressionAction(action.action) ?? "auto_compact";
1946
1858
  const archiveTier = action.tier;
1947
1859
  const compressedSeq = archiveMeta.compressedSeq;
@@ -1950,6 +1862,7 @@ export class RuntimeRunner {
1950
1862
  action = await this.commitKernelAction(runtime, this.pendingObservations, {
1951
1863
  kind: "page_out_archive_result",
1952
1864
  effect_id: action.effectId,
1865
+ ...(archiveRef ? { payload_ref: archiveRef } : {}),
1953
1866
  ...(archiveRef ? { archive_ref: archiveRef } : {}),
1954
1867
  ...(error ? { error } : {}),
1955
1868
  });
@@ -1963,6 +1876,39 @@ export class RuntimeRunner {
1963
1876
  }
1964
1877
  }
1965
1878
  }
1879
+ else if (action.kind === "load_payload") {
1880
+ let content;
1881
+ let error;
1882
+ try {
1883
+ content = await this.payloadStore().loadPayload(sessionId, action.payloadRef);
1884
+ if (content === undefined) {
1885
+ throw new Error(`payload not found for opaque locator ${action.payloadRef}`);
1886
+ }
1887
+ }
1888
+ catch (cause) {
1889
+ error = formatToolError(cause);
1890
+ }
1891
+ if (error || content === undefined) {
1892
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
1893
+ kind: "payload_load_failed",
1894
+ effect_id: action.effectId,
1895
+ error,
1896
+ });
1897
+ }
1898
+ else {
1899
+ const digestBytes = await crypto.subtle.digest("SHA-256", Buffer.from(content));
1900
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
1901
+ kind: "payload_loaded",
1902
+ effect_id: action.effectId,
1903
+ handle_id: action.handleId,
1904
+ payload: {
1905
+ content,
1906
+ digest: `sha256:${Buffer.from(digestBytes).toString("hex")}`,
1907
+ original_size: String(Buffer.byteLength(content, "utf8")),
1908
+ },
1909
+ });
1910
+ }
1911
+ }
1966
1912
  else if (action.kind === "execute_tool") {
1967
1913
  const toolEffectId = action.effectId;
1968
1914
  const allCalls = action.calls;
@@ -1976,18 +1922,13 @@ export class RuntimeRunner {
1976
1922
  knowledgeSource: this.opts.knowledgeSource,
1977
1923
  onToolSuspend: this.opts.onToolSuspend,
1978
1924
  onPermissionRequest: this.opts.onPermissionRequest,
1979
- resultSpool: this.opts.resultSpool ?? new LargeResultSpool(),
1980
1925
  };
1981
1926
  const toolResults = [];
1982
- const normalCalls = allCalls.filter(c => c.name !== "update_plan" && c.name !== "submit_workflow_nodes" && c.name !== "start_workflow"
1983
- && c.name !== "read_result");
1927
+ const normalCalls = allCalls.filter(c => c.name !== "update_plan" && c.name !== "submit_workflow_nodes" && c.name !== "start_workflow");
1984
1928
  const planCalls = allCalls.filter(c => c.name === "update_plan");
1985
- // M5 v1: `start_workflow` (author a sub-workflow) flattens to the same append path as
1986
- // `submit_workflow_nodes` a `WorkflowSpec` is a node batch. (v2 adds top-level bootstrap.)
1929
+ // Syscall tools are consumed by core from the provider result. If one reaches this host
1930
+ // effect projection, the canonical boundary has drifted and must fail closed.
1987
1931
  const submitCalls = allCalls.filter(c => c.name === "submit_workflow_nodes" || c.name === "start_workflow");
1988
- // O7: `read_result` re-fetches a tool output the kernel evicted from context. Content is
1989
- // host-resolved from the effect-committed spool, then from the durable session log.
1990
- const readResultCalls = allCalls.filter(c => c.name === "read_result");
1991
1932
  for (const call of planCalls) {
1992
1933
  const update = parseUpdatePlanArgs(call.arguments);
1993
1934
  await this.commitKernelApply(runtime, this.pendingObservations, {
@@ -1998,39 +1939,8 @@ export class RuntimeRunner {
1998
1939
  toolResults.push(result);
1999
1940
  yield { type: "tool_result", callId: call.id, content: "success", isError: false };
2000
1941
  }
2001
- for (const call of readResultCalls) {
2002
- const out = await this.resolveReadResult(sessionId, call.arguments);
2003
- toolResults.push({ callId: call.id, output: out.text, isError: out.isError });
2004
- yield { type: "tool_result", callId: call.id, content: out.text, isError: out.isError };
2005
- }
2006
- // R3-1: `submit_workflow_nodes` cannot be applied to this runner's kernel — when this runner
2007
- // is a workflow node, the workflow lives in the *parent* kernel. Surface the requested nodes
2008
- // as a stream event; the orchestrator collects them onto the node's result and `runWorkflow`
2009
- // sends `submit_workflow_nodes` to the parent kernel. (When not a workflow node, the event is
2010
- // simply unconsumed — a no-op.)
2011
1942
  for (const call of submitCalls) {
2012
- // M5 v2.1: a TOP-LEVEL agent authoring a whole sub-workflow via `start_workflow` — record the
2013
- // full spec and AUTO-PIVOT once this tool turn resolves (the loop drives it in this kernel and
2014
- // injects the outcome). A workflow-NODE's `start_workflow` (and every `submit_workflow_nodes`)
2015
- // instead FLATTENS: the batch is surfaced for the parent `runWorkflow` to append.
2016
- if (call.name === "start_workflow" && !this.opts.isWorkflowNode) {
2017
- const spec = parseStartWorkflowSpec(call.arguments);
2018
- if (spec) {
2019
- this.pendingAuthoredWorkflows.push(spec);
2020
- const out = "workflow submitted for governance adjudication";
2021
- toolResults.push({ callId: call.id, output: out, isError: false });
2022
- yield { type: "tool_result", callId: call.id, content: out, isError: false };
2023
- continue;
2024
- }
2025
- }
2026
- // `start_workflow` wraps the batch as `{ spec: { nodes } }`; `submit_workflow_nodes` is `{ nodes }`.
2027
- const nodes = call.name === "start_workflow"
2028
- ? parseStartWorkflowArgs(call.arguments)
2029
- : parseSubmitWorkflowNodesArgs(call.arguments);
2030
- yield { type: "workflow_nodes_submitted", nodes };
2031
- const result = { callId: call.id, output: "workflow nodes submitted for parent governance adjudication", isError: false };
2032
- toolResults.push(result);
2033
- yield { type: "tool_result", callId: call.id, content: result.output, isError: false };
1943
+ throw new Error(`canonical kernel published model syscall ${call.name} as a host tool effect`);
2034
1944
  }
2035
1945
  // O5 (PreToolUse-hook analog): give the host a STATEFUL veto over each kernel-approved
2036
1946
  // call. A blocked call never executes; its reason reaches the model as a committed
@@ -2161,11 +2071,11 @@ export class RuntimeRunner {
2161
2071
  token_count: r.tokenCount,
2162
2072
  })),
2163
2073
  });
2164
- // P1-B B3: a `skill` call that resolved successfully activates that skill in the kernel, so
2165
- // the next `call_provider` narrows the toolset to its declared tools. Fed before `tool_results`
2166
- // (which computes the next action). Errs-open: a failed/missing skill load doesn't activate.
2074
+ // The canonical provider resolution already activates a successfully resolved `skill` call.
2075
+ // The host's remaining responsibility is to pin the resolved METHOD content how to do
2076
+ // something for reuse throughout the run, unlike a one-off memory/knowledge lookup.
2167
2077
  //
2168
- // Strict dynamic context control: a skill is METHOD content — how to do something — reused
2078
+ // Strict dynamic context control: the skill text
2169
2079
  // for the rest of the run, unlike a one-off memory/knowledge lookup (fact content, relevant
2170
2080
  // for the moment it's used). So its text ALSO goes into the durable `knowledge` slot here
2171
2081
  // (in addition to the ordinary tool_result already headed for `history`, where it will decay
@@ -2181,11 +2091,6 @@ export class RuntimeRunner {
2181
2091
  const name = JSON.parse(call.arguments || "{}").name;
2182
2092
  if (!name)
2183
2093
  continue;
2184
- await this.commitKernelApply(runtime, this.pendingObservations, {
2185
- kind: "skill_activated",
2186
- name,
2187
- ...(this.opts.skillLeaseTurns !== undefined ? { lease_turns: this.opts.skillLeaseTurns } : {}),
2188
- });
2189
2094
  // K1: keyed `skill:<name>` — the kernel-side upsert dedupes across runner instances
2190
2095
  // (wake re-push of an already-pinned skill upserts instead of duplicating). With a
2191
2096
  // lease configured, the Set optimization is skipped: an expired-then-reloaded skill
@@ -2195,7 +2100,7 @@ export class RuntimeRunner {
2195
2100
  await this.pushKnowledge({ role: "system", content: res.output, toolCalls: [] }, undefined, { key: `skill:${name}` });
2196
2101
  }
2197
2102
  }
2198
- catch { /* malformed skill args — skip activation */ }
2103
+ catch { /* malformed skill args — skip the knowledge pin */ }
2199
2104
  }
2200
2105
  const entropyObsStart = this.pendingObservations.length;
2201
2106
  action = await this.commitKernelAction(runtime, this.pendingObservations, {
@@ -2222,6 +2127,7 @@ export class RuntimeRunner {
2222
2127
  }
2223
2128
  else if (action.kind === "evaluate_milestone") {
2224
2129
  const milestoneEffectId = action.effectId;
2130
+ const milestonePhaseId = action.phaseId;
2225
2131
  const milestonePolicy = this.opts.milestonePolicy ?? "require_verifier";
2226
2132
  if (milestonePolicy === "auto_pass") {
2227
2133
  action = await this.commitKernelAction(runtime, this.pendingObservations, {
@@ -2245,6 +2151,17 @@ export class RuntimeRunner {
2245
2151
  this.nextArchiveStart = await this.appendObservations(sessionId, runtime, this.nextArchiveStart, taskScope);
2246
2152
  }
2247
2153
  else {
2154
+ // R-B27: resolve the effect before ending the run. Nothing here can attest the phase, but
2155
+ // the kernel is holding this milestone in its pending-effect table and only a matching
2156
+ // result removes it — a bare `return` leaves a dangling effect that a logical-checkpoint
2157
+ // recovery cannot resolve. Feed back the conservative "unverified" resolution: the wire
2158
+ // has no error field yet, so it rides as `passed: false`, which keeps the phase where it
2159
+ // is (fail-closed, no unlocks mounted). The run still ends as `milestone_pending`.
2160
+ await this.commitKernelAction(runtime, this.pendingObservations, {
2161
+ kind: "milestone_result",
2162
+ effect_id: milestoneEffectId,
2163
+ result: milestoneCheckResultToKernel(milestoneCheckFail(milestonePhaseId, MILESTONE_UNVERIFIED_REASON)),
2164
+ });
2248
2165
  this.nextArchiveStart = await this.appendObservations(sessionId, runtime, this.nextArchiveStart, taskScope);
2249
2166
  const turnsUsed = Math.max(1, runtime.turn());
2250
2167
  await this.opts.sessionLog.append(sessionId, buildRunTerminalEvent({
@@ -2260,9 +2177,31 @@ export class RuntimeRunner {
2260
2177
  return;
2261
2178
  }
2262
2179
  }
2180
+ else if (action.kind === "spawn_workflow") {
2181
+ await this.driveWorkflow(action, [], sessionId, runtime, new Map());
2182
+ action = runtime.resumeAction()
2183
+ ?? (() => { throw new Error("canonical workflow completed without a terminal or continuation"); })();
2184
+ }
2185
+ else if (action.kind === "unsupported_effect") {
2186
+ action = await this.commitKernelAction(runtime, this.pendingObservations, {
2187
+ kind: "unsupported_effect",
2188
+ effect_id: action.effectId,
2189
+ effect_kind: action.effectKind,
2190
+ });
2191
+ }
2263
2192
  else if (action.kind === "done") {
2264
2193
  break;
2265
2194
  }
2195
+ else {
2196
+ // R-B28: fail-closed backstop. Without it an effect that reaches this position but has no
2197
+ // branch here (`spawn_workflow` / `preempt_sub_agents` are only driven inside the workflow
2198
+ // driver, and any effect kind a newer kernel adds) leaves `action` unreplaced and no event
2199
+ // in flight — `while (!runtime.isTerminal())` re-enters immediately and the run pins a core
2200
+ // at 100% forever while the kernel waits for a result that will never come. Terminating
2201
+ // through the loop's existing throw path makes the protocol mismatch visible instead
2202
+ // (run_terminal `error` + an `error` event) and cannot busy-wait.
2203
+ throw new Error(`unhandled kernel effect ${action.kind} in the main run loop`);
2204
+ }
2266
2205
  }
2267
2206
  }
2268
2207
  catch (err) {
@@ -2310,7 +2249,12 @@ export class RuntimeRunner {
2310
2249
  totalTokens,
2311
2250
  }));
2312
2251
  if (groupBudgetScope && !groupBudgetScope.isClosed) {
2313
- throw new Error("kernel terminated without a correlated budget_usage_reported observation");
2252
+ await this.settleGroupBudget(groupBudgetScope, {
2253
+ tokens: totalTokens,
2254
+ subagents: runtime.localSubagentsSpawned(),
2255
+ ...(this.opts.runSpec?.loopRound ? { rounds: 1 } : {}),
2256
+ });
2257
+ this.activeGroupBudgetScope = undefined;
2314
2258
  }
2315
2259
  if (this.opts.dreamStore && this.opts.agentId) {
2316
2260
  const newMsgs = runtime.drainNewMessages().map(m => ({
@@ -2386,9 +2330,8 @@ export class RuntimeRunner {
2386
2330
  runSpec: this.opts.runSpec,
2387
2331
  phase,
2388
2332
  });
2389
- // T5: route each query through the kernel's query_memory lifecycle instead of calling
2390
- // the store directly the kernel injects each routed hit into history itself and the
2391
- // recall lifecycle (recordRecall / promotion) fires exactly like an in-run query.
2333
+ // Renewal prefetch is a host policy, not an agent syscall. Store selection stays host-side;
2334
+ // each accepted hit enters the live run through a canonical seed-knowledge command.
2392
2335
  //
2393
2336
  // One prefetch = one dedupe horizon: a record hit by several short queries recalls and
2394
2337
  // injects once. A renewal prefetch starts a fresh horizon — renewal dropped the earlier
@@ -2398,16 +2341,57 @@ export class RuntimeRunner {
2398
2341
  for (const q of queries ?? []) {
2399
2342
  if (!q.query.trim())
2400
2343
  continue;
2401
- const { action } = await this.queryMemoryThroughKernel(runtime, q, this.opts.agentId, this.durableSessionId(this.currentSessionId), seenRecordIds, this.pendingObservations);
2344
+ const { action } = await this.prefetchMemoryIntoKnowledge(runtime, q, this.opts.agentId, this.durableSessionId(this.currentSessionId), seenRecordIds, this.pendingObservations);
2402
2345
  resumed = action ?? resumed;
2403
2346
  }
2404
- // Each memory_query_result resumes the reasoning path and re-emits the pending loop
2405
- // action; the caller must continue from the LAST one (it renders the injected hits).
2347
+ // Every seed command leaves the pending provider action authoritative; use the last view.
2406
2348
  return resumed;
2407
2349
  }
2408
2350
  catch { /* errs-open — a faulty pre-fetch never breaks the run */ }
2409
2351
  return undefined;
2410
2352
  }
2353
+ async prefetchMemoryIntoInitialContext(runtime) {
2354
+ if (!this.opts.dreamStore || !this.opts.agentId || !this.opts.memoryScope)
2355
+ return;
2356
+ const preQuery = this.opts.preQueryMemory
2357
+ ?? ((ctx) => [{
2358
+ scope: this.opts.memoryScope,
2359
+ query: ctx.goal,
2360
+ top_k: 5,
2361
+ kinds: [],
2362
+ }]);
2363
+ try {
2364
+ const queries = await preQuery({
2365
+ goal: this.currentGoal,
2366
+ runSpec: this.opts.runSpec,
2367
+ phase: "initial",
2368
+ });
2369
+ const seen = new Set();
2370
+ const accepted = [];
2371
+ for (const query of queries ?? []) {
2372
+ if (!query.query.trim())
2373
+ continue;
2374
+ const hits = await this.retrieveMemoryFromStore(query, query.top_k, this.opts.agentId);
2375
+ for (const hit of hits) {
2376
+ if (seen.has(hit.record.record_id))
2377
+ continue;
2378
+ seen.add(hit.record.record_id);
2379
+ accepted.push(hit);
2380
+ await this.commitKernelApply(runtime, this.pendingObservations, {
2381
+ kind: "add_history_message",
2382
+ message: {
2383
+ role: "user",
2384
+ content: `[MEMORY record_id=${hit.record.record_id} kind=${hit.record.kind}] ${hit.record.content}`,
2385
+ },
2386
+ });
2387
+ }
2388
+ }
2389
+ await this.applyHostMemoryRecallLifecycle(accepted, this.opts.agentId);
2390
+ }
2391
+ catch {
2392
+ // Prefetch is advisory; store/config failure never blocks the operation.
2393
+ }
2394
+ }
2411
2395
  async appendObservations(sessionId, runtime, nextArchiveStart, _taskScope) {
2412
2396
  const turn = runtime.turn();
2413
2397
  const preservedRefs = runtime.preservedRefs();
@@ -2833,75 +2817,6 @@ function parseUpdatePlanArgs(argsStr) {
2833
2817
  : parsed.blocked_on,
2834
2818
  };
2835
2819
  }
2836
- /** R3-1: parse the `submit_workflow_nodes` tool arguments (`{ nodes: WorkflowNodeSpec[] }`). Node
2837
- * shapes are trusted structurally here; the kernel validates them (dep range, quarantine, quota) on
2838
- * append. A malformed payload yields no nodes rather than throwing. */
2839
- function parseSubmitWorkflowNodesArgs(argsStr) {
2840
- let parsed = {};
2841
- try {
2842
- parsed = JSON.parse(argsStr);
2843
- }
2844
- catch {
2845
- // Ignore parse error → no nodes submitted.
2846
- }
2847
- return Array.isArray(parsed.nodes) ? parsed.nodes : [];
2848
- }
2849
- /** M5 v1: parse the `start_workflow` tool arguments (`{ spec: { nodes: WorkflowNodeSpec[] } }`) into
2850
- * the spec's node batch — flattened onto the running workflow via the same append path. A malformed
2851
- * payload yields no nodes rather than throwing. */
2852
- function parseStartWorkflowArgs(argsStr) {
2853
- let parsed = {};
2854
- try {
2855
- parsed = JSON.parse(argsStr);
2856
- }
2857
- catch {
2858
- // Ignore parse error → no nodes.
2859
- }
2860
- const spec = parsed.spec;
2861
- return Array.isArray(spec?.nodes) ? spec.nodes : [];
2862
- }
2863
- /** M5 v2.1: parse the full `WorkflowSpec` from a top-level `start_workflow` call, for auto-pivot drive
2864
- * (vs `parseStartWorkflowArgs`, which returns only the node batch for the flatten path). Returns
2865
- * `undefined` on a malformed / empty payload so the caller falls back to the flatten path. */
2866
- function parseStartWorkflowSpec(argsStr) {
2867
- try {
2868
- const parsed = JSON.parse(argsStr);
2869
- if (Array.isArray(parsed.spec?.nodes) && parsed.spec.nodes.length > 0) {
2870
- return { nodes: parsed.spec.nodes };
2871
- }
2872
- }
2873
- catch {
2874
- // Ignore parse error → undefined (fall back to flatten).
2875
- }
2876
- return undefined;
2877
- }
2878
- /** M5 v2.1: render an authored-workflow outcome into a user-message note injected back into the
2879
- * agent's context, so the agent's next turn continues with the sub-workflow's results in view. */
2880
- function recoveredOutputs(outcomes) {
2881
- const outputs = new Map();
2882
- for (const outcome of outcomes ?? []) {
2883
- if (!outcome.output)
2884
- continue;
2885
- outputs.set(outcome.agentId, outcome.output.content);
2886
- outputs.set(outcome.agentId.replace(/-i\d+$/, ""), outcome.output.content);
2887
- }
2888
- return outputs;
2889
- }
2890
- function authoredWorkflowOutcomeNote(outcome) {
2891
- const counts = new Map();
2892
- for (const node of outcome.nodeOutcomes)
2893
- counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
2894
- const lines = [
2895
- `[authored workflow result] ${outcome.nodeOutcomes.length} terminal node(s): ` +
2896
- [...counts.entries()].map(([status, count]) => `${count} ${status}`).join(", ") + ".",
2897
- ];
2898
- for (const node of outcome.nodeOutcomes) {
2899
- const out = outcome.outputs[node.nodeId] ?? node.output?.content;
2900
- if (out)
2901
- lines.push(`- ${node.nodeId} (${node.status}): ${out.length > 500 ? out.slice(0, 500) + "…" : out}`);
2902
- }
2903
- return lines.join("\n");
2904
- }
2905
2820
  /** Lower a host `RuntimeSignal` to the kernel's snake_case `signal` input event. Shared by the main
2906
2821
  * loop's per-turn poll and #2-B-ii's workflow-batch preemption monitor (so the two never drift). */
2907
2822
  function signalToKernelEvent(delivery) {