@deepstrike/sdk 0.2.49 → 0.2.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +83 -60
  2. package/dist/harness/manifest.d.ts +1 -1
  3. package/dist/harness/manifest.js +43 -29
  4. package/dist/index.d.ts +5 -7
  5. package/dist/index.js +3 -3
  6. package/dist/kernel.d.ts +61 -31
  7. package/dist/runtime/canonical-kernel-step.d.ts +143 -0
  8. package/dist/runtime/canonical-kernel-step.js +1444 -0
  9. package/dist/runtime/execution-plane.d.ts +0 -3
  10. package/dist/runtime/execution-plane.js +0 -24
  11. package/dist/runtime/facade.js +3 -0
  12. package/dist/runtime/kernel-event-log.js +7 -13
  13. package/dist/runtime/kernel-journal.d.ts +264 -0
  14. package/dist/runtime/kernel-journal.js +741 -0
  15. package/dist/runtime/kernel-primitives-dashboard.d.ts +0 -2
  16. package/dist/runtime/kernel-primitives-dashboard.js +1 -8
  17. package/dist/runtime/kernel-step.d.ts +29 -109
  18. package/dist/runtime/kernel-step.js +47 -317
  19. package/dist/runtime/os-snapshot.d.ts +2 -2
  20. package/dist/runtime/os-snapshot.js +2 -6
  21. package/dist/runtime/payload-store.d.ts +16 -0
  22. package/dist/runtime/payload-store.js +80 -0
  23. package/dist/runtime/runner.d.ts +80 -119
  24. package/dist/runtime/runner.js +706 -779
  25. package/dist/runtime/session-log.d.ts +34 -32
  26. package/dist/runtime/session-log.js +21 -131
  27. package/dist/runtime/session-repair.d.ts +2 -36
  28. package/dist/runtime/session-repair.js +2 -47
  29. package/dist/runtime/sub-agent-orchestrator.d.ts +1 -1
  30. package/dist/runtime/sub-agent-orchestrator.js +42 -40
  31. package/dist/types/agent.d.ts +31 -19
  32. package/dist/types/agent.js +31 -42
  33. package/dist/workflow/public.d.ts +1 -1
  34. package/dist/workflow/public.js +1 -1
  35. package/package.json +2 -2
  36. package/dist/runtime/kernel-rebuild.d.ts +0 -13
  37. package/dist/runtime/kernel-rebuild.js +0 -75
  38. package/dist/runtime/kernel-transaction-log.d.ts +0 -61
  39. package/dist/runtime/kernel-transaction-log.js +0 -149
  40. package/dist/runtime/large-result-spool.d.ts +0 -93
  41. 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,
@@ -438,6 +484,11 @@ export class RuntimeRunner {
438
484
  if (this.opts.criteriaGate !== undefined) {
439
485
  config.criteria_gate = this.opts.criteriaGate;
440
486
  }
487
+ // P1: fail-closed dispatch selector (absent ⇒ kernel default "exposed"). "registered" is the
488
+ // escape hatch back to permissive dispatch; the kernel rejects any other value.
489
+ if (this.opts.toolDispatchGate !== undefined) {
490
+ config.tool_dispatch_gate = this.opts.toolDispatchGate;
491
+ }
441
492
  // K2: knowledge budget ratio (absent ⇒ kernel default 0.25; 0 disables).
442
493
  if (this.opts.knowledgeBudgetRatio !== undefined) {
443
494
  config.knowledge_budget_ratio = this.opts.knowledgeBudgetRatio;
@@ -457,9 +508,7 @@ export class RuntimeRunner {
457
508
  await this.commitKernelApply(runtime, this.pendingObservations, { kind: "configure_run", config });
458
509
  }
459
510
  /**
460
- * Mirror one kernel memory-lifecycle observation into the durable store / host callbacks.
461
- * Shared by the main run drain, the prefetch path, and the host memory syscalls so every
462
- * query route has identical recall + promotion semantics (T5).
511
+ * Mirror one agent-syscall memory-lifecycle observation into the durable store / host callbacks.
463
512
  *
464
513
  * M3: `memory_recalled` carries the kernel-derived count — the runner never computes
465
514
  * `recall_count + 1` itself. M4: `promotion_suggested` is advisory and already
@@ -479,19 +528,6 @@ export class RuntimeRunner {
479
528
  });
480
529
  }
481
530
  }
482
- async consumeMemoryLifecycleObservations(sessionId, observations) {
483
- const turn = this.activeKernel?.turn() ?? 0;
484
- for (const obs of observations) {
485
- if (!isMemoryLifecycleObservation(obs))
486
- continue;
487
- await this.mirrorMemoryLifecycle(obs);
488
- if (!sessionId)
489
- continue;
490
- const event = kernelObservationToSessionEvent(obs, turn);
491
- if (event)
492
- await this.opts.sessionLog.append(sessionId, event);
493
- }
494
- }
495
531
  /** Mount a tool capability on the currently-running kernel runtime. No-op if not running. */
496
532
  async mountTool(schema) {
497
533
  if (!this.activeKernel)
@@ -549,48 +585,6 @@ export class RuntimeRunner {
549
585
  // Re-arm the SDK-side push guard so a re-activation re-pins the content.
550
586
  this.knowledgePushedSkills.delete(name);
551
587
  }
552
- /**
553
- * Spawn an isolated sub-agent via the kernel, run it on the host, and feed the result back.
554
- * Requires an active parent run (`run()` / `wake()` in progress or paused at milestone).
555
- */
556
- async *spawnSubAgent(spec) {
557
- if (!this.activeKernel || !this.currentSessionId) {
558
- throw new Error("spawnSubAgent requires an active parent run");
559
- }
560
- const parentSessionId = this.currentSessionId;
561
- const runtime = this.activeKernel;
562
- const observations = await this.commitKernelApply(runtime, this.pendingObservations, {
563
- kind: "spawn_sub_agent",
564
- spec: agentRunSpecToKernel(spec),
565
- parent_session_id: parentSessionId,
566
- });
567
- this.nextArchiveStart = await this.appendObservations(parentSessionId, runtime, this.nextArchiveStart);
568
- const spawned = findSpawnProcessObservation(observations);
569
- if (!spawned) {
570
- const rejected = controlRequestRejection(observations, "spawn_sub_agent");
571
- if (rejected) {
572
- yield { type: "error", message: `spawn_sub_agent denied: ${rejected.reason}` };
573
- return;
574
- }
575
- throw new Error("spawn_sub_agent did not emit agent_process_changed");
576
- }
577
- const manifest = spawnObservationToManifest(spawned, spec, parentSessionId);
578
- const orchestrator = this.opts.subAgentOrchestrator ?? defaultSubAgentOrchestrator;
579
- const result = await orchestrator.run({
580
- parentOpts: this.opts,
581
- parentSessionId,
582
- spec,
583
- manifest,
584
- sessionLog: this.opts.sessionLog,
585
- toolAccess: spec.toolAccess,
586
- ...(this.opts.subAgentHarness ? { harness: this.opts.subAgentHarness } : {}),
587
- });
588
- await this.commitKernelApply(runtime, this.pendingObservations, {
589
- kind: "sub_agent_completed",
590
- result: subAgentResultToKernel(result),
591
- });
592
- yield { type: "done", iterations: result.result.turnsUsed, totalTokens: result.result.totalTokensUsed, status: result.result.termination };
593
- }
594
588
  /**
595
589
  * G3: run one workflow node, enforcing its `output_schema` (if any). Without a schema this is a
596
590
  * plain `orchestrator.run`. With one, the node's agent is instructed to emit conforming JSON, its
@@ -620,8 +614,8 @@ export class RuntimeRunner {
620
614
  spec: { ...baseSpec, goal: withBudget(goal) },
621
615
  manifest,
622
616
  sessionLog: this.opts.sessionLog,
623
- // M5 v2.1: this child IS a workflow node its `start_workflow` flattens to this kernel (the
624
- // 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.
625
619
  isWorkflowNode: true,
626
620
  // W-N1: trusted workflow nodes run on the parent's execution plane (they carry no grant list
627
621
  // by design — filtering on the missing list ran every DAG node TOOL-LESS); quarantined nodes
@@ -731,7 +725,7 @@ export class RuntimeRunner {
731
725
  */
732
726
  async runWorkflow(spec, opts) {
733
727
  // Standalone entry: with no active parent run (e.g. a stateless HTTP handler), auto-bootstrap a
734
- // 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
735
729
  // gets — then tear it down on completion so the runner is reusable. Mid-run callers (activeKernel
736
730
  // already set by an in-flight `run()`) keep the original in-place behavior with no teardown.
737
731
  const bootstrapped = !this.activeKernel || !this.currentSessionId;
@@ -739,6 +733,7 @@ export class RuntimeRunner {
739
733
  try {
740
734
  if (bootstrapped) {
741
735
  const sessionId = opts?.sessionId ?? `wf-${crypto.randomUUID()}`;
736
+ const runId = crypto.randomUUID();
742
737
  // A standalone workflow reserves a bounded slice before its kernel schedules any node.
743
738
  // Mid-run callers reuse their parent run's already-active reservation.
744
739
  if (this.opts.runGroup) {
@@ -749,46 +744,56 @@ export class RuntimeRunner {
749
744
  // Resume depends on this fact. Do not dispatch any node until it is durable.
750
745
  await this.opts.sessionLog.append(sessionId, {
751
746
  kind: "run_started",
752
- run_id: crypto.randomUUID(),
747
+ run_id: runId,
753
748
  goal: `workflow:${spec.nodes.length} nodes`,
754
749
  criteria: [],
755
750
  agent_id: this.opts.agentId,
756
751
  });
757
- await this.bootstrapWorkflowKernel(sessionId, groupBudgetScope);
752
+ await this.initializeWorkflowKernel(sessionId, runId, groupBudgetScope);
758
753
  }
759
754
  const parentSessionId = this.currentSessionId;
760
755
  const runtime = this.activeKernel;
761
756
  const observationStart = this.pendingObservations.length;
762
- const initialAction = await this.commitKernelMaybeAction(runtime, this.pendingObservations, {
763
- kind: "load_workflow",
764
- spec: workflowSpecToKernel(spec),
765
- parent_session_id: parentSessionId,
766
- // Exact typed terminal outcomes plus control-flow signals recovered from the journal.
767
- ...(opts?.resumedOutcomes?.length
768
- ? {
769
- resumed_outcomes: opts.resumedOutcomes.map(r => ({
770
- agent_id: r.agentId,
771
- status: r.status,
772
- termination: r.termination,
773
- ...(r.output ? { output: messageToKernelMessage(r.output) } : {}),
774
- ...(r.classifyBranch !== undefined ? { classify_branch: r.classifyBranch } : {}),
775
- ...(r.tournamentWinner !== undefined ? { tournament_winner: r.tournamentWinner } : {}),
776
- ...(r.loopContinue !== undefined ? { loop_continue: r.loopContinue } : {}),
777
- })),
778
- }
779
- : {}),
780
- // R3-1: re-apply recorded runtime submissions so dynamically-appended nodes are reconstructed.
781
- ...(opts?.resumedSubmissions?.length ? { resumed_submissions: opts.resumedSubmissions } : {}),
782
- ...(opts?.resumedSubmissionBases?.length ? { resumed_submission_bases: opts.resumedSubmissionBases } : {}),
783
- });
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
+ }
784
773
  const observations = this.pendingObservations.slice(observationStart);
785
- const outcome = await this.driveWorkflow(initialAction, observations, parentSessionId, runtime, recoveredOutputs(opts?.resumedOutcomes));
774
+ const outcome = await this.driveWorkflow(initialAction, observations, parentSessionId, runtime, new Map());
786
775
  if (bootstrapped) {
787
- 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
+ }
788
786
  if (terminal.kind !== "done") {
789
- 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");
790
788
  }
791
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
+ }
792
797
  }
793
798
  return outcome;
794
799
  }
@@ -811,81 +816,22 @@ export class RuntimeRunner {
811
816
  /**
812
817
  * Bootstrap a standalone kernel for a host-driven workflow with NO active parent run — the path a
813
818
  * stateless request handler takes when it calls `runWorkflow(spec)` directly. Mirrors `execute()`'s
814
- * 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)
815
820
  * after `runWorkflow` has durably recorded `run_started`. Sets `activeKernel` / `currentSessionId`;
816
821
  * `runWorkflow` is responsible for tearing them down.
817
822
  */
818
- async bootstrapWorkflowKernel(sessionId, groupBudgetScope) {
823
+ async initializeWorkflowKernel(sessionId, runId, groupBudgetScope) {
819
824
  this.interrupted = false;
820
825
  this.abortController = new AbortController();
821
826
  this.pendingObservations = [];
822
827
  this.pendingPageOutArchives = [];
823
828
  this.activePageOutArchive = undefined;
824
829
  this.currentSessionId = sessionId;
825
- const runtime = this.createSyscallRuntime();
830
+ const runtime = this.createCanonicalRuntime(runId, sessionId);
826
831
  this.activeKernel = runtime;
827
832
  await this.applyKernelPolicies(runtime, groupBudgetScope);
828
- // ABI v2 has one lifecycle: standalone workflows start a real run before loading their DAG.
829
- // The initial provider effect is superseded by the workflow load; no self-bootstrap escape hatch.
830
- await this.commitKernelAction(runtime, this.pendingObservations, {
831
- kind: "start_run",
832
- task: { goal: `workflow session ${sessionId}`, criteria: [] },
833
- });
834
833
  return runtime;
835
834
  }
836
- /**
837
- * M5/G1: bootstrap an **agent-authored** workflow ("the model writes its own harness"). Unlike
838
- * `runWorkflow` (the host fires the privileged `load_workflow`), this routes the spec through the
839
- * agent-reachable `Syscall::LoadWorkflow` (the `submit_workflow` event): with no workflow active the
840
- * kernel **bootstraps** the DAG; if one is already active it **flattens** the spec's nodes onto it
841
- * (bootstrap-or-flatten — one kernel, one quota, never a workflow stack). Gated by the same
842
- * `max_workflow_nodes` backstop as runtime submission, so an authored harness can't overgrow the run.
843
- * The resulting batches are driven by the same shared driver as `runWorkflow`.
844
- */
845
- async bootstrapWorkflow(spec, opts) {
846
- if (!this.activeKernel || !this.currentSessionId) {
847
- throw new Error("bootstrapWorkflow requires an active parent run");
848
- }
849
- const parentSessionId = this.currentSessionId;
850
- const runtime = this.activeKernel;
851
- const observationStart = this.pendingObservations.length;
852
- const initialAction = await this.commitKernelMaybeAction(runtime, this.pendingObservations, submitWorkflowToKernel(spec, parentSessionId, opts?.submitterAgentId));
853
- const observations = this.pendingObservations.slice(observationStart);
854
- // W-3: persist the agent-authored batch (bootstrap base 0 / flatten base N — the kernel now
855
- // announces BOTH) so an interrupted authored workflow reconstructs on resume; the host never
856
- // had this spec, unlike the `runWorkflow` path.
857
- const submitted = observations.find(o => o.kind === "workflow_nodes_submitted");
858
- if (submitted) {
859
- await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
860
- turn: runtime.turn(),
861
- nodes: workflowSpecToKernel(spec).nodes ?? [],
862
- baseIndex: submitted.base,
863
- submitterAgentId: opts?.submitterAgentId,
864
- }));
865
- }
866
- return this.driveWorkflow(initialAction, observations, parentSessionId, runtime);
867
- }
868
- /**
869
- * M5 v2.1: drive the sub-workflow(s) a top-level agent authored via `start_workflow`. Called at the
870
- * verified-safe point (right after the tool turn resolved to `call_provider` — kernel in Reason, not
871
- * suspended). For each authored spec: `bootstrapWorkflow` runs it in THIS kernel (the kernel resumes
872
- * the agent reason loop on `workflow_completed` — `finish_workflow` sets phase=Reason), then the
873
- * outcome is injected as a user message so the agent's next turn sees the result. Returns a fresh
874
- * `call_provider` synthesized from the updated context (the workflow drive consumed its own kernel
875
- * actions, so we re-render — the same pattern as the reactive-compact retry path).
876
- */
877
- async driveAuthoredWorkflows(runtime, action) {
878
- const specs = this.pendingAuthoredWorkflows;
879
- this.pendingAuthoredWorkflows = [];
880
- this.workflowContinuation = null;
881
- for (const spec of specs) {
882
- await this.bootstrapWorkflow(spec);
883
- }
884
- const continuation = this.workflowContinuation;
885
- if (!continuation)
886
- throw new Error("authored workflow completed without a provider continuation");
887
- return continuation;
888
- }
889
835
  /**
890
836
  * #2-B-ii: while a workflow batch is in flight, poll the signal source. A Critical `InterruptNow`
891
837
  * routes through the kernel (which, with the root suspended in `SubAgentAwait`, preempts — marks the
@@ -896,9 +842,48 @@ export class RuntimeRunner {
896
842
  */
897
843
  async monitorWorkflowPreemption(runtime, controllers, batchState) {
898
844
  const source = this.opts.signalSource;
899
- if (!source)
900
- return null;
901
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
+ }
902
887
  // O2: injected notes participate in the monitor too, so a host `injectNote` mid-batch is not
903
888
  // stranded until the batch settles (the drain order matches `nextInboundSignal`).
904
889
  const delivery = await this.nextInboundSignal();
@@ -931,6 +916,8 @@ export class RuntimeRunner {
931
916
  }
932
917
  const preempted = observations.find(o => o.kind === "agent_preempted");
933
918
  if (preempted) {
919
+ this.interrupted = true;
920
+ this.cancellationReason ??= "user";
934
921
  for (const id of preempted.agent_ids ?? [])
935
922
  controllers.get(id)?.abort();
936
923
  const wc = observations.find(o => o.kind === "workflow_completed");
@@ -940,10 +927,8 @@ export class RuntimeRunner {
940
927
  return null;
941
928
  }
942
929
  /**
943
- * Shared workflow driver for `runWorkflow` (host `load_workflow`) and `bootstrapWorkflow` (agent
944
- * `submit_workflow`): given the observations from the initial load/bootstrap, run each kernel-emitted
945
- * batch in parallel, feed completions back (appending any agent-submitted nodes first), and loop
946
- * 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.
947
932
  */
948
933
  async driveWorkflow(initialAction, initial, parentSessionId, runtime, seedOutputs) {
949
934
  let observations = initial;
@@ -988,9 +973,16 @@ export class RuntimeRunner {
988
973
  // W-1: on resume it is pre-seeded from the persisted node outputs, so post-resume dependents
989
974
  // still see their (pre-crash) dependencies' outputs.
990
975
  const outputs = new Map(seedOutputs ?? []);
976
+ const completedNodeOutcomes = [];
991
977
  for (;;) {
992
978
  if (nodes.length === 0)
993
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
+ }
994
986
  // Run the currently-runnable nodes in parallel — each is independent within a round.
995
987
  const roundBudget = budget;
996
988
  // #2-B-ii: per-node abort controllers + a concurrent preemption monitor. While the batch is in
@@ -1003,8 +995,9 @@ export class RuntimeRunner {
1003
995
  const results = await Promise.all(nodes.map(node => this.runWorkflowNode(node, parentSessionId, orchestrator, roundBudget, outputs, controllers.get(node.agent_id)?.signal)));
1004
996
  batchState.settled = true;
1005
997
  const preempted = await monitor;
1006
- if (preempted)
998
+ if (preempted !== null) {
1007
999
  return { nodeOutcomes: preempted, outputs: Object.fromEntries(outputs) };
1000
+ }
1008
1001
  // Feed completions back one at a time. The kernel's run-queue executor may spawn a node's
1009
1002
  // dependents the moment *that* node completes (per-node unblock), so each feed can emit its
1010
1003
  // own `workflow_batch_spawned`; ACCUMULATE them across the round rather than keeping only the
@@ -1018,61 +1011,17 @@ export class RuntimeRunner {
1018
1011
  const outContent = result.result.finalMessage?.content;
1019
1012
  const outText = typeof outContent === "string" ? outContent : outContent != null ? JSON.stringify(outContent) : "";
1020
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
+ });
1021
1020
  // A loop iteration completes under `wf-node{N}-i{k}` but its dependents consume the STABLE
1022
1021
  // node id `wf-node{N}` — alias it so the LAST iteration's output is what dependents see.
1023
1022
  const stableId = result.agentId.replace(/-i\d+$/, "");
1024
1023
  if (stableId !== result.agentId)
1025
1024
  outputs.set(stableId, outText);
1026
- // R3-1: if this node's agent submitted more nodes, append them to the parent DAG BEFORE
1027
- // reporting the node's completion — the workflow is still active (the kernel hasn't seen this
1028
- // node finish), so even a submission from the last running node keeps the DAG alive. The
1029
- // appended nodes' `workflow_batch_spawned` is collected into this round like any other.
1030
- if (result.submittedNodes?.length) {
1031
- // G1: stamp the submitting node's agent id so the kernel can coerce a quarantined
1032
- // submitter's nodes to quarantined (no topological privilege escalation).
1033
- const submitEvent = submitWorkflowNodesToKernel(result.submittedNodes, result.agentId);
1034
- const observationStart = this.pendingObservations.length;
1035
- const submitAction = await this.commitKernelMaybeAction(runtime, this.pendingObservations, submitEvent);
1036
- const subObs = this.pendingObservations.slice(observationStart);
1037
- const rejected = controlRequestRejection(subObs, "submit_workflow_nodes")
1038
- ?? (subObs.find(o => o.kind === "nodes_rejected")
1039
- ? { operation: "submit_workflow_nodes", reason: String(subObs.find(o => o.kind === "nodes_rejected")?.reason ?? "request denied") }
1040
- : undefined);
1041
- if (rejected) {
1042
- const denial = `workflow node submission denied: ${rejected.reason}`;
1043
- result.result = {
1044
- ...result.result,
1045
- termination: "error",
1046
- finalMessage: { role: "assistant", content: denial, toolCalls: [] },
1047
- };
1048
- outputs.set(result.agentId, denial);
1049
- if (stableId !== result.agentId)
1050
- outputs.set(stableId, denial);
1051
- }
1052
- if (submitAction?.kind === "spawn_workflow") {
1053
- nextNodes.push(...submitAction.nodes);
1054
- budget = submitAction.budget ?? budget;
1055
- const accepted = await acceptSpawn(submitAction);
1056
- const submittedDone = findDone([...subObs, ...accepted]);
1057
- if (submittedDone)
1058
- done = submittedDone;
1059
- }
1060
- else if (submitAction) {
1061
- throw new Error(`workflow node submission returned unexpected effect: ${submitAction.kind}`);
1062
- }
1063
- // R3-1: persist the submission (kernel-shape nodes) + its kernel-reported base index
1064
- // so resume can re-apply the batch at the exact original graph position. W-N3: also the
1065
- // submitter, so resume drops batches whose submitter re-runs (it will re-submit).
1066
- const submitted = subObs.find(o => o.kind === "workflow_nodes_submitted");
1067
- if (submitted) {
1068
- await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
1069
- turn: runtime.turn(),
1070
- nodes: submitEvent.nodes ?? [],
1071
- baseIndex: submitted.base,
1072
- submitterAgentId: result.agentId,
1073
- }));
1074
- }
1075
- }
1076
1025
  const observationStart = this.pendingObservations.length;
1077
1026
  const completionAction = await this.commitKernelMaybeAction(runtime, this.pendingObservations, {
1078
1027
  kind: "sub_agent_completed",
@@ -1087,9 +1036,28 @@ export class RuntimeRunner {
1087
1036
  else if (completionAction?.kind === "call_provider") {
1088
1037
  this.workflowContinuation = completionAction;
1089
1038
  }
1039
+ else if (completionAction?.kind === "done") {
1040
+ return {
1041
+ nodeOutcomes: completedNodeOutcomes,
1042
+ outputs: Object.fromEntries(outputs),
1043
+ };
1044
+ }
1090
1045
  else if (completionAction) {
1091
1046
  throw new Error(`workflow completion returned unexpected effect: ${completionAction.kind}`);
1092
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
+ }
1093
1061
  const d = findDone(obs);
1094
1062
  if (d)
1095
1063
  done = d;
@@ -1116,39 +1084,6 @@ export class RuntimeRunner {
1116
1084
  nodes = nextNodes;
1117
1085
  }
1118
1086
  }
1119
- /**
1120
- * Resume a workflow from the parent session's completed nodes.
1121
- * Reads the session log, extracts completed workflow node records (with their W-1 control
1122
- * signals + outputs), and calls runWorkflow so the kernel skips those nodes, replays control
1123
- * flow (classify prune / loop stop), and the driver re-seeds its outputs map.
1124
- */
1125
- async resumeWorkflow(spec, opts) {
1126
- // Standalone resume: a stateless handler passes the prior `sessionId` to pick up an interrupted
1127
- // workflow from the session log. Mid-run callers omit it and resume the active session.
1128
- const sessionId = opts?.sessionId ?? this.currentSessionId;
1129
- if (!sessionId) {
1130
- throw new Error("resumeWorkflow requires an active parent run or an explicit sessionId");
1131
- }
1132
- const events = await this.opts.sessionLog.read(sessionId);
1133
- const resumedOutcomes = recoverWorkflowNodeOutcomes(events);
1134
- const completedIds = new Set(resumedOutcomes.map(r => r.agentId));
1135
- const recovered = recoverSubmittedWorkflowNodes(events);
1136
- // W-N3: DROP batches whose submitter did NOT complete — that node re-runs on resume and will
1137
- // re-submit its batch; replaying the logged copy too would duplicate its nodes in the DAG.
1138
- // Exact bases keep later graph indices stable while dropped slots remain inert placeholders.
1139
- let { submissions, bases } = recovered;
1140
- if (submissions.length > 0) {
1141
- const keep = recovered.submitters.map(s => s === undefined || completedIds.has(s));
1142
- submissions = submissions.filter((_, i) => keep[i]);
1143
- bases = bases.filter((_, i) => keep[i]);
1144
- }
1145
- return this.runWorkflow(spec, {
1146
- resumedOutcomes,
1147
- resumedSubmissions: submissions,
1148
- resumedSubmissionBases: bases,
1149
- sessionId,
1150
- });
1151
- }
1152
1087
  interrupt(reason = "user") {
1153
1088
  this.interrupted = true;
1154
1089
  this.cancellationReason = reason;
@@ -1213,7 +1148,7 @@ export class RuntimeRunner {
1213
1148
  const dispositions = this.pendingObservations.slice(observationStart).filter(observation => observation.kind === "signal_delivery_disposed"
1214
1149
  && observation.delivery_id === delivery.deliveryId
1215
1150
  && observation.attempt === delivery.deliveryAttempt);
1216
- if (dispositions.length !== 1) {
1151
+ if (dispositions.length > 1) {
1217
1152
  throw new Error("kernel did not return the matching signal delivery disposition");
1218
1153
  }
1219
1154
  if (!await delivery.ack())
@@ -1227,8 +1162,24 @@ export class RuntimeRunner {
1227
1162
  }
1228
1163
  async *run(req) {
1229
1164
  const prior = req.inheritEvents ?? await this.opts.sessionLog.read(req.sessionId);
1230
- const midRun = isMidRun(prior);
1231
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
+ }
1232
1183
  const runId = midRun && resumedStart?.event.kind === "run_started"
1233
1184
  ? resumedStart.event.run_id
1234
1185
  : crypto.randomUUID();
@@ -1253,12 +1204,26 @@ export class RuntimeRunner {
1253
1204
  }
1254
1205
  async *wake(sessionId, extensions) {
1255
1206
  const events = await this.opts.sessionLog.read(sessionId);
1256
- if (events.some(e => e.event.kind === "run_terminal"))
1257
- return;
1258
- 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;
1259
1209
  if (!startEntry)
1260
1210
  throw new Error(`No run_started event for session: ${sessionId}`);
1261
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
+ }
1262
1227
  yield* this.execute(sessionId, start.goal, start.criteria, extensions, events, true, start.attachments, start.run_id);
1263
1228
  }
1264
1229
  /** Execute a kernel-owned approval effect and return the correlated decision lists. */
@@ -1339,62 +1304,6 @@ export class RuntimeRunner {
1339
1304
  }
1340
1305
  return { approved, denied, events };
1341
1306
  }
1342
- /**
1343
- * O7: resolve a `read_result` meta-tool call to the full text of a previously-evicted tool
1344
- * output. Resolution order: (a) the on-disk result spool committed by the explicit
1345
- * `spool_large_result` host effect, then (b) a session-log scan for the original
1346
- * `tool_completed` event carrying that `call_id`. Slices the
1347
- * resolved text by `[offset, offset + maxBytes)` (plain string slice — "bytes-ish").
1348
- */
1349
- async resolveReadResult(sessionId, argsJson) {
1350
- let callId = "";
1351
- let offset = 0;
1352
- let maxBytes = 4000;
1353
- try {
1354
- const args = JSON.parse(argsJson || "{}");
1355
- callId = typeof args.call_id === "string" ? args.call_id : "";
1356
- if (typeof args.offset === "number" && Number.isFinite(args.offset))
1357
- offset = args.offset;
1358
- if (typeof args.max_bytes === "number" && Number.isFinite(args.max_bytes))
1359
- maxBytes = args.max_bytes;
1360
- }
1361
- catch {
1362
- // malformed arguments — callId stays empty, falls through to "not found" below
1363
- }
1364
- let full;
1365
- const spool = this.opts.resultSpool ?? new LargeResultSpool();
1366
- try {
1367
- full = await spool.findByCallId(sessionId, callId);
1368
- }
1369
- catch {
1370
- full = undefined;
1371
- }
1372
- if (full === undefined) {
1373
- try {
1374
- const events = await this.opts.sessionLog.read(sessionId);
1375
- for (const { event } of events) {
1376
- if (event.kind !== "tool_completed")
1377
- continue;
1378
- const match = event.results.find(r => r.call_id === callId);
1379
- if (match)
1380
- full = match.output;
1381
- }
1382
- }
1383
- catch {
1384
- full = undefined;
1385
- }
1386
- }
1387
- if (full === undefined) {
1388
- return { text: `no stored output for call_id "${callId}"`, isError: true };
1389
- }
1390
- const start = Math.max(0, offset);
1391
- const end = Math.min(full.length, start + Math.max(0, maxBytes));
1392
- const slice = full.slice(start, end);
1393
- return {
1394
- text: `[read_result ${callId}: chars ${start}–${end} of ${full.length}]\n${slice}`,
1395
- isError: false,
1396
- };
1397
- }
1398
1307
  async *execute(sessionId, goal, criteria, extensions, priorEvents, resumeMidRun = false, attachments, runId = crypto.randomUUID()) {
1399
1308
  this.interrupted = false;
1400
1309
  this.cancellationReason = undefined;
@@ -1406,7 +1315,6 @@ export class RuntimeRunner {
1406
1315
  if (this.opts.enableDiagnosticsDashboard) {
1407
1316
  this.dashboard = new KernelPrimitivesDashboard(sessionId);
1408
1317
  }
1409
- const kernel = getKernel();
1410
1318
  const ext = { ...this.opts.extensions, ...(extensions ?? {}) };
1411
1319
  const providerState = this.opts.provider.createRunState?.();
1412
1320
  let nextCompressedArchiveStart = nextArchivedSeqStart(priorEvents);
@@ -1423,200 +1331,167 @@ export class RuntimeRunner {
1423
1331
  const taskScope = new ManagedTaskScope(operation, this.opts.onBackgroundTaskError);
1424
1332
  let groupBudgetScope;
1425
1333
  try {
1426
- const runtime = new kernel.KernelRuntime({
1427
- maxTokens: this.opts.maxTokens,
1428
- maxTurns: effectiveMaxTurns,
1429
- timeoutMs: effectiveTimeoutMs !== undefined ? BigInt(effectiveTimeoutMs) : undefined,
1430
- maxTotalTokens: this.opts.maxTotalTokens !== undefined ? BigInt(this.opts.maxTotalTokens) : undefined,
1431
- });
1334
+ const runtime = this.createCanonicalRuntime(runId, sessionId);
1335
+ if (resumeMidRun)
1336
+ await runtime.restore();
1432
1337
  this.activeKernel = runtime;
1433
1338
  this.nextArchiveStart = nextCompressedArchiveStart;
1434
- if (this.opts.tokenizer) {
1435
- await this.commitKernelApply(runtime, this.pendingObservations, {
1436
- kind: "set_tokenizer",
1437
- name: this.opts.tokenizer,
1438
- });
1439
- }
1440
- if (this.opts.enablePlanTool !== undefined) {
1441
- await this.commitKernelApply(runtime, this.pendingObservations, {
1442
- kind: "set_plan_tool_enabled",
1443
- enabled: this.opts.enablePlanTool,
1444
- });
1445
- }
1446
- await this.commitKernelApply(runtime, this.pendingObservations, {
1447
- kind: "set_tools",
1448
- tools: this.opts.executionPlane.schemas().map(toolSchemaToKernel),
1449
- });
1450
- if (this.composedSystemPrompt) {
1451
- await this.commitKernelApply(runtime, this.pendingObservations, {
1452
- kind: "add_system_message",
1453
- content: this.composedSystemPrompt,
1454
- tokens: Math.max(1, Math.ceil(this.composedSystemPrompt.length / 4)),
1455
- });
1456
- }
1457
- if (this.opts.initialMemory) {
1458
- for (const mem of this.opts.initialMemory) {
1339
+ if (!resumeMidRun) {
1340
+ if (this.opts.tokenizer) {
1459
1341
  await this.commitKernelApply(runtime, this.pendingObservations, {
1460
- kind: "add_knowledge_message",
1461
- content: mem,
1462
- 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,
1463
1350
  });
1464
1351
  }
1465
- }
1466
- if (this.opts.skillDir) {
1467
- const { scanSkillDir } = await import("../skills/loader.js");
1468
- const metas = await scanSkillDir(this.opts.skillDir);
1469
- // S2 host-layer skill allowlist: keep only scanned skills named in `skillFilter` before feeding
1470
- // the catalog. Absent ⇒ feed all (identical to the pre-feature message); empty ⇒ feed none. The
1471
- // `set_available_skills` message is ALWAYS sent when a skillDir exists (shape preserved) — only
1472
- // the list narrows; the no-skillDir path stays untouched.
1473
- const filter = this.opts.skillFilter;
1474
- const selected = filter === undefined ? metas : metas.filter(m => filter.includes(m.name));
1475
- // P1-B: pass the full SkillMetadata (incl. `allowedTools`) straight through — re-mapping it
1476
- // field-by-field previously dropped `allowedTools`.
1477
- await this.commitKernelApply(runtime, this.pendingObservations, {
1478
- kind: "set_available_skills",
1479
- skills: selected.map(m => skillMetadataToKernel(m)),
1480
- });
1481
- }
1482
- // P1-B/D: configure the stable-core tool ids (always exposed under skill gating). Empty/absent
1483
- // ⇒ skills narrow to exactly their declared tools + meta-tools.
1484
- if (this.opts.stableCoreToolIds?.length) {
1485
- await this.commitKernelApply(runtime, this.pendingObservations, {
1486
- kind: "set_stable_core_tools",
1487
- tool_ids: this.opts.stableCoreToolIds,
1488
- });
1489
- }
1490
- if (this.opts.dreamStore && this.opts.agentId) {
1491
- await this.commitKernelApply(runtime, this.pendingObservations, { kind: "set_memory_enabled", enabled: true });
1492
- }
1493
- // Install optional memory policy. Maps the ergonomic camelCase option onto the kernel's
1494
- // snake_case `set_memory_policy` event; omitted fields fall back to kernel defaults.
1495
- if (this.opts.memoryPolicy) {
1496
- const m = this.opts.memoryPolicy;
1497
- await this.commitKernelApply(runtime, this.pendingObservations, {
1498
- kind: "set_memory_policy",
1499
- ...(m.memoryPath !== undefined ? { memory_path: m.memoryPath } : {}),
1500
- ...(m.staleWarningDays !== undefined ? { stale_warning_days: m.staleWarningDays } : {}),
1501
- ...(m.retrievalTopK !== undefined ? { retrieval_top_k: m.retrievalTopK } : {}),
1502
- ...(m.validationEnabled !== undefined ? { validation_enabled: m.validationEnabled } : {}),
1503
- ...(m.maxContentBytes !== undefined ? { max_content_bytes: m.maxContentBytes } : {}),
1504
- ...(m.maxNameLength !== undefined ? { max_name_length: m.maxNameLength } : {}),
1505
- ...(m.promotionRecallThreshold !== undefined
1506
- ? { promotion_recall_threshold: m.promotionRecallThreshold }
1507
- : {}),
1508
- });
1509
- }
1510
- if (this.opts.knowledgeSource) {
1511
- await this.commitKernelApply(runtime, this.pendingObservations, { kind: "set_knowledge_enabled", enabled: true });
1512
- }
1513
- if (this.opts.milestoneContract) {
1514
- await this.commitKernelApply(runtime, this.pendingObservations, {
1515
- kind: "load_milestone_contract",
1516
- contract: {
1517
- phases: this.opts.milestoneContract.phases.map(p => ({
1518
- id: p.id,
1519
- criteria: p.criteria ?? [],
1520
- unlocks: p.unlocks ?? [],
1521
- required_evidence: p.requiredEvidence ?? [],
1522
- ...(p.verifier ? { verifier: p.verifier } : {}),
1523
- })),
1524
- },
1525
- });
1526
- }
1527
- const maxBytes = runtime.recoveryContentBytes();
1528
- if (priorEvents && priorEvents.length > 0) {
1529
- const repaired = repairEventsForRecovery(priorEvents, maxBytes);
1530
- seedProviderReplayFromEvents(this.opts.provider, repaired);
1531
- const loadArchive = this.opts.compressionStore
1532
- ? (ref) => this.opts.compressionStore.read(ref)
1533
- : undefined;
1534
- const replayed = await replayMessagesAsync(repaired, maxBytes, loadArchive);
1535
1352
  await this.commitKernelApply(runtime, this.pendingObservations, {
1536
- kind: "preload_history",
1537
- messages: replayed.map(messageToKernelMessage),
1353
+ kind: "set_tools",
1354
+ tools: this.opts.executionPlane.schemas().map(toolSchemaToKernel),
1538
1355
  });
1539
- // P1-B B3: rebuild active-skill gating after a wake by re-emitting SkillActivated for each
1540
- // `skill` tool call in the replayed history (active_skills is not snapshotted — graceful).
1541
- // The catalog (set_available_skills) was already fed above, so allowed_tools resolves.
1542
- // `knowledge` isn't snapshotted either (same graceful-reset philosophy) — best-effort re-push
1543
- // the skill's content from its replayed tool_result so the durable copy survives a wake too.
1544
- const toolResultByCallId = new Map();
1545
- for (const m of replayed) {
1546
- for (const part of m.contentParts ?? []) {
1547
- if (part.type === "tool_result")
1548
- toolResultByCallId.set(part.callId, part.output);
1549
- }
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
+ });
1550
1362
  }
1551
- for (const m of replayed) {
1552
- for (const tc of m.toolCalls ?? []) {
1553
- if (tc.name !== "skill")
1554
- continue;
1555
- try {
1556
- const name = JSON.parse(tc.arguments || "{}").name;
1557
- if (!name)
1558
- continue;
1559
- await this.commitKernelApply(runtime, this.pendingObservations, {
1560
- kind: "skill_activated",
1561
- name,
1562
- ...(this.opts.skillLeaseTurns !== undefined ? { lease_turns: this.opts.skillLeaseTurns } : {}),
1563
- });
1564
- const output = toolResultByCallId.get(tc.id);
1565
- if (output && !this.knowledgePushedSkills.has(name)) {
1566
- this.knowledgePushedSkills.add(name);
1567
- // K1: keyed — the kernel-side upsert is the authoritative dedup, so a wake re-push
1568
- // of a skill already pinned live can never double-pin (the in-run Set resets with
1569
- // each runner instance; the key does not).
1570
- await this.pushKnowledge({ role: "system", content: output, toolCalls: [] }, undefined, { key: `skill:${name}` });
1571
- }
1572
- }
1573
- 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
+ });
1574
1370
  }
1575
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
+ }
1576
1437
  }
1577
1438
  const sessionStart = Date.now();
1578
- const startPayload = {
1579
- kind: "start_run",
1580
- task: { goal, criteria },
1581
- };
1582
- // P0-A: lower an explicit `runSpec` and/or the `allowedToolIds` profile to the kernel's
1583
- // `capability_filter`. `allowedToolIds` augments an explicit spec's filter, else synthesizes
1584
- // a minimal top-level spec carrying just the filter (reuses the existing run_spec wire — no
1585
- // new ABI). Unset on both ⇒ no run_spec ⇒ no gating (铁律: no config = old behavior).
1439
+ const startTask = { goal, criteria };
1440
+ let startRunSpec;
1441
+ // P0-A: lower an explicit `runSpec`, the `allowedToolIds` ceiling, and/or the `baselineToolIds`
1442
+ // pre-activation surface to the kernel run spec. Each augments an explicit spec, else
1443
+ // synthesizes a minimal top-level spec carrying just the exposure config (reuses the existing
1444
+ // run_spec wire no new ABI). Unset on all ⇒ no run_spec ⇒ no gating (铁律: no config = old
1445
+ // behavior).
1586
1446
  const allowedToolIds = this.opts.allowedToolIds;
1587
1447
  const hasProfile = allowedToolIds !== undefined && allowedToolIds.length > 0;
1588
- if (this.opts.runSpec || hasProfile) {
1448
+ // NOT the `length > 0` idiom above: `baselineToolIds: []` is the legitimate minimal surface
1449
+ // (meta + stable-core only), so mere presence triggers the lowering.
1450
+ const baselineToolIds = this.opts.baselineToolIds;
1451
+ const hasBaseline = baselineToolIds !== undefined;
1452
+ const hasMilestoneContract = this.opts.milestoneContract !== undefined;
1453
+ if (this.opts.runSpec || hasProfile || hasBaseline || hasMilestoneContract) {
1589
1454
  const baseSpec = this.opts.runSpec ?? {
1590
1455
  identity: { agentId: this.opts.agentId ?? "root", sessionId, isSubAgent: false },
1591
1456
  role: "custom",
1592
1457
  goal,
1593
1458
  };
1594
- const spec = hasProfile
1459
+ let spec = hasProfile
1595
1460
  ? { ...baseSpec, capabilityFilter: { ...baseSpec.capabilityFilter, allowedIds: allowedToolIds } }
1596
1461
  : baseSpec;
1597
- startPayload.run_spec = agentRunSpecToKernel(spec);
1462
+ if (hasBaseline)
1463
+ spec = { ...spec, exposureBaseline: baselineToolIds };
1464
+ if (hasMilestoneContract && !spec.verificationContractId) {
1465
+ spec = { ...spec, verificationContractId: "node-default" };
1466
+ }
1467
+ startRunSpec = agentRunSpecToKernel(spec);
1598
1468
  }
1599
- // 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
1600
1470
  // exact terminal usage against the same opaque reservation identity. A nested vehicle joins for
1601
1471
  // lineage/settlement only: it reserves no budget axes (group admission governs peer vehicles),
1602
1472
  // so the parent's held reservation cannot squeeze the child's grant to zero.
1603
- if (this.opts.runGroup) {
1473
+ if (!resumeMidRun && this.opts.runGroup) {
1604
1474
  const g = this.opts.runGroup;
1605
1475
  groupBudgetScope = await GroupBudgetScope.open(g, { sessionId, role: this.opts.agentId, kind: "vehicle" }, this.opts.nestedGroupVehicle ? { limits: {}, requested: {} } : this.groupBudgetRequest());
1606
1476
  this.activeGroupBudgetScope = groupBudgetScope;
1607
1477
  }
1608
- try {
1609
- await this.applyKernelPolicies(runtime, groupBudgetScope);
1610
- }
1611
- catch (err) {
1612
- // Admission failure (e.g. the kernel rejecting a zero-capacity grant): release the
1613
- // reservation so it cannot linger in the group ledger, then surface the error.
1614
- await groupBudgetScope?.release();
1615
- this.activeGroupBudgetScope = undefined;
1616
- 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
+ }
1617
1489
  }
1490
+ this.currentGoal = goal;
1491
+ if (!resumeMidRun)
1492
+ await this.prefetchMemoryIntoInitialContext(runtime);
1618
1493
  // Multimodal upload: seed the user's attachments (images/audio) as a history
1619
- // 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
1620
1495
  // clear history, so order becomes [attachment user msg, "Proceed…"] — both land
1621
1496
  // in the first render. On resume the message is already in the replayed history.
1622
1497
  if (!resumeMidRun && attachments?.length) {
@@ -1625,24 +1500,14 @@ export class RuntimeRunner {
1625
1500
  message: attachmentsToKernelMessage(attachments),
1626
1501
  });
1627
1502
  }
1628
- this.currentGoal = goal;
1503
+ const resumedAction = resumeMidRun ? runtime.resumeAction() : null;
1629
1504
  let action = resumeMidRun
1630
- ? await this.commitKernelAction(runtime, this.pendingObservations, { kind: "resume" })
1631
- : await this.commitKernelAction(runtime, this.pendingObservations, startPayload);
1632
- // I4/T5: pre-fetch memory before the first LLM turn so the model sees it on turn 1 instead
1633
- // of discovering it via the `memory` tool on turn 3+. It routes through the kernel's
1634
- // query_memory lifecycle and therefore runs AFTER start_run a memory query is a kernel
1635
- // preload whose result resumes the reasoning path, so issuing it pre-run would leave the
1636
- // kernel Running and start_run would fault. The resumed action from the last query
1637
- // supersedes start_run's (same pull contract; it renders the injected hits). Hits land in
1638
- // `history` as ordinary turns — single-use retrieval content that decays with the
1639
- // compression pyramid, never pinned into `knowledge`. Skipped on resumes (already in
1640
- // prior context) and when dreamStore/agentId is absent.
1641
- if (!resumeMidRun) {
1642
- const resumed = await this.prefetchMemoryIntoHistory(runtime, "initial");
1643
- if (resumed)
1644
- action = resumed;
1645
- }
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.
1646
1511
  // P0-C: the skill loaded and in effect going into the current turn (updated when the model's
1647
1512
  // `skill` tool call resolves). Drives the per-turn `activeSkill` metric → dwell measurement.
1648
1513
  let activeSkill;
@@ -1679,15 +1544,6 @@ export class RuntimeRunner {
1679
1544
  if (runtime.isTerminal())
1680
1545
  break;
1681
1546
  if (action.kind === "call_provider") {
1682
- // M5 v2.1: top-level auto-pivot at the safe point. If the agent authored sub-workflow(s) via
1683
- // `start_workflow`, drive each in THIS kernel now (the kernel is in Reason / `call_provider`,
1684
- // NOT suspended — driving mid-suspend would clobber the single-slot suspend state), inject the
1685
- // outcome into context, and re-render. Loop-top placement (vs only after `tool_results`) catches
1686
- // EVERY path to `call_provider` — including resuming after an approval gate — so a queued spec
1687
- // is never stranded. Drains the queue; fires once per authored batch.
1688
- if (this.pendingAuthoredWorkflows.length > 0) {
1689
- action = await this.driveAuthoredWorkflows(runtime, action);
1690
- }
1691
1547
  const providerEffectId = action.effectId;
1692
1548
  const finalToolCalls = [];
1693
1549
  let finalText = "";
@@ -1790,10 +1646,28 @@ export class RuntimeRunner {
1790
1646
  });
1791
1647
  break;
1792
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
+ });
1793
1667
  const assistantMessage = {
1794
1668
  role: "assistant",
1795
1669
  content: finalText,
1796
- toolCalls: finalToolCalls,
1670
+ toolCalls: canonicalToolCalls,
1797
1671
  tokenCount: turnOutputTokens || turnTokens || undefined,
1798
1672
  };
1799
1673
  const providerEvent = {
@@ -1802,9 +1676,36 @@ export class RuntimeRunner {
1802
1676
  message: messageToKernelMessage(assistantMessage),
1803
1677
  ...(turnInputTokens > 0 ? { observed_input_tokens: turnInputTokens } : {}),
1804
1678
  ...(turnOutputTokens > 0 ? { observed_output_tokens: turnOutputTokens } : {}),
1805
- now_ms: Date.now(),
1806
1679
  ...(turnStopReason ? { stop_reason: turnStopReason } : {}),
1807
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
+ }
1808
1709
  action = await this.commitKernelAction(runtime, this.pendingObservations, providerEvent);
1809
1710
  const providerReplay = peekProviderReplay(this.opts.provider, finalText, finalToolCalls);
1810
1711
  await this.opts.sessionLog.append(sessionId, buildLlmCompletedEvent({
@@ -1856,10 +1757,32 @@ export class RuntimeRunner {
1856
1757
  else if (action.kind === "persist_memory") {
1857
1758
  let error;
1858
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
+ };
1859
1782
  try {
1860
1783
  if (!agentId)
1861
1784
  throw new Error("memory persistence requires RuntimeOptions.agentId");
1862
- await this.persistMemoryToStore(action.memory, agentId);
1785
+ await this.persistMemoryToStore(record, agentId);
1863
1786
  }
1864
1787
  catch (cause) {
1865
1788
  error = formatToolError(cause);
@@ -1867,11 +1790,22 @@ export class RuntimeRunner {
1867
1790
  action = await this.commitKernelAction(runtime, this.pendingObservations, {
1868
1791
  kind: "memory_persist_result",
1869
1792
  effect_id: action.effectId,
1793
+ record_ref: record.record_id,
1870
1794
  ...(error ? { error } : {}),
1871
1795
  });
1872
1796
  }
1873
1797
  else if (action.kind === "query_memory") {
1874
- 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
+ };
1875
1809
  let hits = [];
1876
1810
  let error;
1877
1811
  const agentId = this.opts.agentId;
@@ -1892,23 +1826,6 @@ export class RuntimeRunner {
1892
1826
  if (!error)
1893
1827
  await this.logMemoryRetrievalResult(sessionId, hits);
1894
1828
  }
1895
- else if (action.kind === "spool_large_result") {
1896
- const spool = this.opts.resultSpool ?? new LargeResultSpool();
1897
- let spoolRef;
1898
- let error;
1899
- try {
1900
- spoolRef = await spool.persistOutput(sessionId, action.callId, action.output);
1901
- }
1902
- catch (cause) {
1903
- error = formatToolError(cause);
1904
- }
1905
- action = await this.commitKernelAction(runtime, this.pendingObservations, {
1906
- kind: "large_result_spool_result",
1907
- effect_id: action.effectId,
1908
- ...(spoolRef ? { spool_ref: spoolRef } : {}),
1909
- ...(error ? { error } : {}),
1910
- });
1911
- }
1912
1829
  else if (action.kind === "archive_page_out") {
1913
1830
  const archiveMeta = this.activePageOutArchive
1914
1831
  ?? this.pendingPageOutArchives.shift()
@@ -1920,8 +1837,15 @@ export class RuntimeRunner {
1920
1837
  let archiveRef;
1921
1838
  let error;
1922
1839
  try {
1923
- if (this.opts.compressionStore) {
1924
- 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 ?? []);
1925
1849
  if (ref)
1926
1850
  archiveRef = ref;
1927
1851
  }
@@ -1929,7 +1853,7 @@ export class RuntimeRunner {
1929
1853
  catch (cause) {
1930
1854
  error = formatToolError(cause);
1931
1855
  }
1932
- const archived = action.archived;
1856
+ const archived = action.archived ?? [];
1933
1857
  const archiveAction = compressionAction(action.action) ?? "auto_compact";
1934
1858
  const archiveTier = action.tier;
1935
1859
  const compressedSeq = archiveMeta.compressedSeq;
@@ -1938,6 +1862,7 @@ export class RuntimeRunner {
1938
1862
  action = await this.commitKernelAction(runtime, this.pendingObservations, {
1939
1863
  kind: "page_out_archive_result",
1940
1864
  effect_id: action.effectId,
1865
+ ...(archiveRef ? { payload_ref: archiveRef } : {}),
1941
1866
  ...(archiveRef ? { archive_ref: archiveRef } : {}),
1942
1867
  ...(error ? { error } : {}),
1943
1868
  });
@@ -1951,6 +1876,39 @@ export class RuntimeRunner {
1951
1876
  }
1952
1877
  }
1953
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
+ }
1954
1912
  else if (action.kind === "execute_tool") {
1955
1913
  const toolEffectId = action.effectId;
1956
1914
  const allCalls = action.calls;
@@ -1964,18 +1922,13 @@ export class RuntimeRunner {
1964
1922
  knowledgeSource: this.opts.knowledgeSource,
1965
1923
  onToolSuspend: this.opts.onToolSuspend,
1966
1924
  onPermissionRequest: this.opts.onPermissionRequest,
1967
- resultSpool: this.opts.resultSpool ?? new LargeResultSpool(),
1968
1925
  };
1969
1926
  const toolResults = [];
1970
- const normalCalls = allCalls.filter(c => c.name !== "update_plan" && c.name !== "submit_workflow_nodes" && c.name !== "start_workflow"
1971
- && c.name !== "read_result");
1927
+ const normalCalls = allCalls.filter(c => c.name !== "update_plan" && c.name !== "submit_workflow_nodes" && c.name !== "start_workflow");
1972
1928
  const planCalls = allCalls.filter(c => c.name === "update_plan");
1973
- // M5 v1: `start_workflow` (author a sub-workflow) flattens to the same append path as
1974
- // `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.
1975
1931
  const submitCalls = allCalls.filter(c => c.name === "submit_workflow_nodes" || c.name === "start_workflow");
1976
- // O7: `read_result` re-fetches a tool output the kernel evicted from context. Content is
1977
- // host-resolved from the effect-committed spool, then from the durable session log.
1978
- const readResultCalls = allCalls.filter(c => c.name === "read_result");
1979
1932
  for (const call of planCalls) {
1980
1933
  const update = parseUpdatePlanArgs(call.arguments);
1981
1934
  await this.commitKernelApply(runtime, this.pendingObservations, {
@@ -1986,39 +1939,8 @@ export class RuntimeRunner {
1986
1939
  toolResults.push(result);
1987
1940
  yield { type: "tool_result", callId: call.id, content: "success", isError: false };
1988
1941
  }
1989
- for (const call of readResultCalls) {
1990
- const out = await this.resolveReadResult(sessionId, call.arguments);
1991
- toolResults.push({ callId: call.id, output: out.text, isError: out.isError });
1992
- yield { type: "tool_result", callId: call.id, content: out.text, isError: out.isError };
1993
- }
1994
- // R3-1: `submit_workflow_nodes` cannot be applied to this runner's kernel — when this runner
1995
- // is a workflow node, the workflow lives in the *parent* kernel. Surface the requested nodes
1996
- // as a stream event; the orchestrator collects them onto the node's result and `runWorkflow`
1997
- // sends `submit_workflow_nodes` to the parent kernel. (When not a workflow node, the event is
1998
- // simply unconsumed — a no-op.)
1999
1942
  for (const call of submitCalls) {
2000
- // M5 v2.1: a TOP-LEVEL agent authoring a whole sub-workflow via `start_workflow` — record the
2001
- // full spec and AUTO-PIVOT once this tool turn resolves (the loop drives it in this kernel and
2002
- // injects the outcome). A workflow-NODE's `start_workflow` (and every `submit_workflow_nodes`)
2003
- // instead FLATTENS: the batch is surfaced for the parent `runWorkflow` to append.
2004
- if (call.name === "start_workflow" && !this.opts.isWorkflowNode) {
2005
- const spec = parseStartWorkflowSpec(call.arguments);
2006
- if (spec) {
2007
- this.pendingAuthoredWorkflows.push(spec);
2008
- const out = "workflow submitted for governance adjudication";
2009
- toolResults.push({ callId: call.id, output: out, isError: false });
2010
- yield { type: "tool_result", callId: call.id, content: out, isError: false };
2011
- continue;
2012
- }
2013
- }
2014
- // `start_workflow` wraps the batch as `{ spec: { nodes } }`; `submit_workflow_nodes` is `{ nodes }`.
2015
- const nodes = call.name === "start_workflow"
2016
- ? parseStartWorkflowArgs(call.arguments)
2017
- : parseSubmitWorkflowNodesArgs(call.arguments);
2018
- yield { type: "workflow_nodes_submitted", nodes };
2019
- const result = { callId: call.id, output: "workflow nodes submitted for parent governance adjudication", isError: false };
2020
- toolResults.push(result);
2021
- 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`);
2022
1944
  }
2023
1945
  // O5 (PreToolUse-hook analog): give the host a STATEFUL veto over each kernel-approved
2024
1946
  // call. A blocked call never executes; its reason reaches the model as a committed
@@ -2149,11 +2071,11 @@ export class RuntimeRunner {
2149
2071
  token_count: r.tokenCount,
2150
2072
  })),
2151
2073
  });
2152
- // P1-B B3: a `skill` call that resolved successfully activates that skill in the kernel, so
2153
- // the next `call_provider` narrows the toolset to its declared tools. Fed before `tool_results`
2154
- // (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.
2155
2077
  //
2156
- // Strict dynamic context control: a skill is METHOD content — how to do something — reused
2078
+ // Strict dynamic context control: the skill text
2157
2079
  // for the rest of the run, unlike a one-off memory/knowledge lookup (fact content, relevant
2158
2080
  // for the moment it's used). So its text ALSO goes into the durable `knowledge` slot here
2159
2081
  // (in addition to the ordinary tool_result already headed for `history`, where it will decay
@@ -2169,11 +2091,6 @@ export class RuntimeRunner {
2169
2091
  const name = JSON.parse(call.arguments || "{}").name;
2170
2092
  if (!name)
2171
2093
  continue;
2172
- await this.commitKernelApply(runtime, this.pendingObservations, {
2173
- kind: "skill_activated",
2174
- name,
2175
- ...(this.opts.skillLeaseTurns !== undefined ? { lease_turns: this.opts.skillLeaseTurns } : {}),
2176
- });
2177
2094
  // K1: keyed `skill:<name>` — the kernel-side upsert dedupes across runner instances
2178
2095
  // (wake re-push of an already-pinned skill upserts instead of duplicating). With a
2179
2096
  // lease configured, the Set optimization is skipped: an expired-then-reloaded skill
@@ -2183,7 +2100,7 @@ export class RuntimeRunner {
2183
2100
  await this.pushKnowledge({ role: "system", content: res.output, toolCalls: [] }, undefined, { key: `skill:${name}` });
2184
2101
  }
2185
2102
  }
2186
- catch { /* malformed skill args — skip activation */ }
2103
+ catch { /* malformed skill args — skip the knowledge pin */ }
2187
2104
  }
2188
2105
  const entropyObsStart = this.pendingObservations.length;
2189
2106
  action = await this.commitKernelAction(runtime, this.pendingObservations, {
@@ -2210,6 +2127,7 @@ export class RuntimeRunner {
2210
2127
  }
2211
2128
  else if (action.kind === "evaluate_milestone") {
2212
2129
  const milestoneEffectId = action.effectId;
2130
+ const milestonePhaseId = action.phaseId;
2213
2131
  const milestonePolicy = this.opts.milestonePolicy ?? "require_verifier";
2214
2132
  if (milestonePolicy === "auto_pass") {
2215
2133
  action = await this.commitKernelAction(runtime, this.pendingObservations, {
@@ -2233,6 +2151,17 @@ export class RuntimeRunner {
2233
2151
  this.nextArchiveStart = await this.appendObservations(sessionId, runtime, this.nextArchiveStart, taskScope);
2234
2152
  }
2235
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
+ });
2236
2165
  this.nextArchiveStart = await this.appendObservations(sessionId, runtime, this.nextArchiveStart, taskScope);
2237
2166
  const turnsUsed = Math.max(1, runtime.turn());
2238
2167
  await this.opts.sessionLog.append(sessionId, buildRunTerminalEvent({
@@ -2248,9 +2177,31 @@ export class RuntimeRunner {
2248
2177
  return;
2249
2178
  }
2250
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
+ }
2251
2192
  else if (action.kind === "done") {
2252
2193
  break;
2253
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
+ }
2254
2205
  }
2255
2206
  }
2256
2207
  catch (err) {
@@ -2298,7 +2249,12 @@ export class RuntimeRunner {
2298
2249
  totalTokens,
2299
2250
  }));
2300
2251
  if (groupBudgetScope && !groupBudgetScope.isClosed) {
2301
- 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;
2302
2258
  }
2303
2259
  if (this.opts.dreamStore && this.opts.agentId) {
2304
2260
  const newMsgs = runtime.drainNewMessages().map(m => ({
@@ -2374,9 +2330,8 @@ export class RuntimeRunner {
2374
2330
  runSpec: this.opts.runSpec,
2375
2331
  phase,
2376
2332
  });
2377
- // T5: route each query through the kernel's query_memory lifecycle instead of calling
2378
- // the store directly the kernel injects each routed hit into history itself and the
2379
- // 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.
2380
2335
  //
2381
2336
  // One prefetch = one dedupe horizon: a record hit by several short queries recalls and
2382
2337
  // injects once. A renewal prefetch starts a fresh horizon — renewal dropped the earlier
@@ -2386,16 +2341,57 @@ export class RuntimeRunner {
2386
2341
  for (const q of queries ?? []) {
2387
2342
  if (!q.query.trim())
2388
2343
  continue;
2389
- 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);
2390
2345
  resumed = action ?? resumed;
2391
2346
  }
2392
- // Each memory_query_result resumes the reasoning path and re-emits the pending loop
2393
- // 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.
2394
2348
  return resumed;
2395
2349
  }
2396
2350
  catch { /* errs-open — a faulty pre-fetch never breaks the run */ }
2397
2351
  return undefined;
2398
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
+ }
2399
2395
  async appendObservations(sessionId, runtime, nextArchiveStart, _taskScope) {
2400
2396
  const turn = runtime.turn();
2401
2397
  const preservedRefs = runtime.preservedRefs();
@@ -2821,75 +2817,6 @@ function parseUpdatePlanArgs(argsStr) {
2821
2817
  : parsed.blocked_on,
2822
2818
  };
2823
2819
  }
2824
- /** R3-1: parse the `submit_workflow_nodes` tool arguments (`{ nodes: WorkflowNodeSpec[] }`). Node
2825
- * shapes are trusted structurally here; the kernel validates them (dep range, quarantine, quota) on
2826
- * append. A malformed payload yields no nodes rather than throwing. */
2827
- function parseSubmitWorkflowNodesArgs(argsStr) {
2828
- let parsed = {};
2829
- try {
2830
- parsed = JSON.parse(argsStr);
2831
- }
2832
- catch {
2833
- // Ignore parse error → no nodes submitted.
2834
- }
2835
- return Array.isArray(parsed.nodes) ? parsed.nodes : [];
2836
- }
2837
- /** M5 v1: parse the `start_workflow` tool arguments (`{ spec: { nodes: WorkflowNodeSpec[] } }`) into
2838
- * the spec's node batch — flattened onto the running workflow via the same append path. A malformed
2839
- * payload yields no nodes rather than throwing. */
2840
- function parseStartWorkflowArgs(argsStr) {
2841
- let parsed = {};
2842
- try {
2843
- parsed = JSON.parse(argsStr);
2844
- }
2845
- catch {
2846
- // Ignore parse error → no nodes.
2847
- }
2848
- const spec = parsed.spec;
2849
- return Array.isArray(spec?.nodes) ? spec.nodes : [];
2850
- }
2851
- /** M5 v2.1: parse the full `WorkflowSpec` from a top-level `start_workflow` call, for auto-pivot drive
2852
- * (vs `parseStartWorkflowArgs`, which returns only the node batch for the flatten path). Returns
2853
- * `undefined` on a malformed / empty payload so the caller falls back to the flatten path. */
2854
- function parseStartWorkflowSpec(argsStr) {
2855
- try {
2856
- const parsed = JSON.parse(argsStr);
2857
- if (Array.isArray(parsed.spec?.nodes) && parsed.spec.nodes.length > 0) {
2858
- return { nodes: parsed.spec.nodes };
2859
- }
2860
- }
2861
- catch {
2862
- // Ignore parse error → undefined (fall back to flatten).
2863
- }
2864
- return undefined;
2865
- }
2866
- /** M5 v2.1: render an authored-workflow outcome into a user-message note injected back into the
2867
- * agent's context, so the agent's next turn continues with the sub-workflow's results in view. */
2868
- function recoveredOutputs(outcomes) {
2869
- const outputs = new Map();
2870
- for (const outcome of outcomes ?? []) {
2871
- if (!outcome.output)
2872
- continue;
2873
- outputs.set(outcome.agentId, outcome.output.content);
2874
- outputs.set(outcome.agentId.replace(/-i\d+$/, ""), outcome.output.content);
2875
- }
2876
- return outputs;
2877
- }
2878
- function authoredWorkflowOutcomeNote(outcome) {
2879
- const counts = new Map();
2880
- for (const node of outcome.nodeOutcomes)
2881
- counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
2882
- const lines = [
2883
- `[authored workflow result] ${outcome.nodeOutcomes.length} terminal node(s): ` +
2884
- [...counts.entries()].map(([status, count]) => `${count} ${status}`).join(", ") + ".",
2885
- ];
2886
- for (const node of outcome.nodeOutcomes) {
2887
- const out = outcome.outputs[node.nodeId] ?? node.output?.content;
2888
- if (out)
2889
- lines.push(`- ${node.nodeId} (${node.status}): ${out.length > 500 ? out.slice(0, 500) + "…" : out}`);
2890
- }
2891
- return lines.join("\n");
2892
- }
2893
2820
  /** Lower a host `RuntimeSignal` to the kernel's snake_case `signal` input event. Shared by the main
2894
2821
  * loop's per-turn poll and #2-B-ii's workflow-batch preemption monitor (so the two never drift). */
2895
2822
  function signalToKernelEvent(delivery) {