@deepstrike/sdk 0.2.35 → 0.2.37

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 (35) hide show
  1. package/README.md +6 -6
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.js +2 -0
  4. package/dist/os/public.d.ts +1 -1
  5. package/dist/os/public.js +1 -1
  6. package/dist/runtime/facade.js +11 -11
  7. package/dist/runtime/kernel-event-log.d.ts +0 -6
  8. package/dist/runtime/kernel-event-log.js +38 -62
  9. package/dist/runtime/kernel-step.d.ts +11 -0
  10. package/dist/runtime/kernel-step.js +12 -0
  11. package/dist/runtime/large-result-spool.d.ts +7 -0
  12. package/dist/runtime/large-result-spool.js +25 -0
  13. package/dist/runtime/loop-driver.d.ts +108 -0
  14. package/dist/runtime/loop-driver.js +198 -0
  15. package/dist/runtime/os-snapshot.d.ts +0 -1
  16. package/dist/runtime/os-snapshot.js +0 -19
  17. package/dist/runtime/reactive-session.d.ts +5 -2
  18. package/dist/runtime/reactive-session.js +17 -4
  19. package/dist/runtime/run-group.d.ts +9 -0
  20. package/dist/runtime/run-group.js +18 -4
  21. package/dist/runtime/runner.d.ts +151 -12
  22. package/dist/runtime/runner.js +568 -155
  23. package/dist/runtime/session-log.d.ts +31 -54
  24. package/dist/runtime/session-repair.d.ts +29 -7
  25. package/dist/runtime/session-repair.js +37 -9
  26. package/dist/runtime/sub-agent-orchestrator.d.ts +12 -0
  27. package/dist/runtime/sub-agent-orchestrator.js +54 -30
  28. package/dist/runtime/workflow-control-flow.d.ts +10 -2
  29. package/dist/runtime/workflow-control-flow.js +27 -6
  30. package/dist/signals/gateway.d.ts +4 -2
  31. package/dist/signals/gateway.js +8 -1
  32. package/dist/types/agent.d.ts +40 -2
  33. package/dist/types/agent.js +25 -1
  34. package/dist/types.d.ts +2 -0
  35. package/package.json +2 -2
@@ -10,9 +10,9 @@ import { agentRunSpecToKernel, findSpawnProcessObservation, milestoneCheckPass,
10
10
  import { defaultSubAgentOrchestrator } from "./sub-agent-orchestrator.js";
11
11
  import { extractJsonValue, schemaInstruction, schemaRetryInstruction, validateAgainstSchema, } from "./output-schema.js";
12
12
  import { resolveReducer } from "./reducers.js";
13
- import { loopInstruction, classifyInstruction, judgeGoal, extractLoopContinue, extractClassifyBranch, extractJudgeWinner, } from "./workflow-control-flow.js";
13
+ import { loopInstruction, classifyInstruction, judgeGoal, dependencyOutputsNote, extractLoopContinue, extractClassifyBranch, extractJudgeWinner, } from "./workflow-control-flow.js";
14
14
  import { governancePolicyToKernelEvent, governanceFilterSchema } from "../governance.js";
15
- import { kernelObservationToSessionEvent, withCategory } from "./kernel-event-log.js";
15
+ import { kernelObservationToSessionEvent } from "./kernel-event-log.js";
16
16
  import { assertNativeProfile } from "./os-profile.js";
17
17
  import { LargeResultSpool } from "./large-result-spool.js";
18
18
  import { formatToolError } from "../tools/errors.js";
@@ -25,11 +25,17 @@ export class RuntimeRunner {
25
25
  activeKernel = null;
26
26
  pendingObservations = [];
27
27
  currentSessionId = null;
28
+ /** O2 (system-reminder channel): host-pushed notes awaiting the next turn-boundary drain. */
29
+ injectedSignals = [];
30
+ /** Skill names whose content has already been pushed into the durable `knowledge` slot this
31
+ * run — guards against re-pushing a duplicate entry if the model calls `skill(name)` again for
32
+ * an already-active skill (loading is idempotent; the knowledge push should be too). */
33
+ knowledgePushedSkills = new Set();
28
34
  nextArchiveStart = 0;
35
+ /** K4: the active run's goal, kept for the renewal-boundary memory re-query. */
36
+ currentGoal = "";
29
37
  /** Full tool outputs keyed by call_id until Layer-1 spool observations are logged. */
30
38
  pendingSpoolOutputs = new Map();
31
- /** Local cache of paged-out/archived messages for priority memory retrieval. */
32
- localPageOutCache = [];
33
39
  /** M5 v2.1: sub-workflow specs a top-level agent authored via `start_workflow`, awaiting auto-drive
34
40
  * at the next safe point (after the tool turn resolves, kernel back in Reason — not suspended). */
35
41
  pendingAuthoredWorkflows = [];
@@ -66,23 +72,30 @@ export class RuntimeRunner {
66
72
  return;
67
73
  }
68
74
  const existing = await this.opts.dreamStore.loadMemories(agentId);
69
- await this.opts.dreamStore.commit(agentId, {
70
- toAdd: [{
71
- text: memory.content,
72
- score: 1.0,
73
- metadata: {
74
- ...memory.metadata,
75
- source: "write_memory_syscall",
76
- },
77
- }],
78
- toRemoveIndices: [],
79
- stats: {
80
- insightsProcessed: 1,
81
- duplicatesRemoved: 0,
82
- conflictsResolved: 0,
83
- entriesAdded: 1,
84
- },
85
- }, existing);
75
+ // Curator-style jaccard dedup at the single write path: a near-duplicate of an
76
+ // existing entry is dropped (the observation is still logged for audit).
77
+ const isDuplicate = existing.some(e => jaccardSimilarity(e.text, memory.content) >= 0.9);
78
+ if (!isDuplicate) {
79
+ const meta = memory.metadata;
80
+ const score = typeof meta?.score === "number" ? meta.score : 1.0;
81
+ await this.opts.dreamStore.commit(agentId, {
82
+ toAdd: [{
83
+ text: memory.content,
84
+ score,
85
+ metadata: {
86
+ ...memory.metadata,
87
+ source: meta?.source ?? "write_memory_syscall",
88
+ },
89
+ }],
90
+ toRemoveIndices: [],
91
+ stats: {
92
+ insightsProcessed: 1,
93
+ duplicatesRemoved: 0,
94
+ conflictsResolved: 0,
95
+ entriesAdded: 1,
96
+ },
97
+ }, existing);
98
+ }
86
99
  await this.appendMemorySyscallObservations(sessionId, observations);
87
100
  }
88
101
  async queryMemory(query, opts = {}) {
@@ -110,24 +123,19 @@ export class RuntimeRunner {
110
123
  }
111
124
  }
112
125
  await this.appendMemorySyscallObservations(sessionId, observations);
113
- await this.logMemoryRetrievalResult(sessionId, runtime, retrieval);
126
+ await this.logMemoryRetrievalResult(sessionId, retrieval);
114
127
  return hits;
115
128
  }
116
- async logMemoryRetrievalResult(sessionId, runtime, retrieval) {
129
+ async logMemoryRetrievalResult(sessionId, retrieval) {
117
130
  if (!sessionId)
118
131
  return;
132
+ // The session-log record is the durable audit artifact; the kernel needs no
133
+ // acknowledgment (the former kernel event was a no-op and was removed).
119
134
  await this.opts.sessionLog.append(sessionId, {
120
135
  kind: "memory_retrieval_result",
121
136
  selected_memory_ids: retrieval.selected_memory_ids,
122
137
  selection_rationale: retrieval.selection_rationale,
123
138
  });
124
- kernelApply(runtime, [], {
125
- kind: "memory_retrieval_result",
126
- retrieval: {
127
- selected_memory_ids: retrieval.selected_memory_ids,
128
- selection_rationale: retrieval.selection_rationale,
129
- },
130
- });
131
139
  }
132
140
  createSyscallRuntime() {
133
141
  const { KernelRuntime } = getKernel();
@@ -146,7 +154,7 @@ export class RuntimeRunner {
146
154
  * exactly as a mid-run spawn would be. Must run BEFORE `start_run` so the in-kernel gate enforces
147
155
  * every policy from the first spawn. No config ⇒ the native-profile defaults (铁律: defaults only).
148
156
  */
149
- applyKernelPolicies(runtime, groupTokensBase, groupSpawnsBase) {
157
+ applyKernelPolicies(runtime, groupTokensBase, groupSpawnsBase, groupRoundsBase) {
150
158
  // K2: lower governance / attention / scheduler / quota in ONE `configure_run` event instead of
151
159
  // the previous 2–4 separate `set_*` / `load_governance_policy` events. The kernel applies each
152
160
  // present field via the same path its granular event uses; absent fields are left untouched.
@@ -181,6 +189,26 @@ export class RuntimeRunner {
181
189
  if (groupSpawnsBase !== undefined && groupSpawnsBase > 0) {
182
190
  config.group_spawns_base = groupSpawnsBase;
183
191
  }
192
+ if (groupRoundsBase !== undefined && groupRoundsBase > 0) {
193
+ // ③ loop-agent: completed-round count seeds the pacing trap's max_rounds coercion.
194
+ config.group_rounds_base = groupRoundsBase;
195
+ }
196
+ // O6: tune/disable the in-kernel repeat fuse. `false` disables; an object overrides thresholds.
197
+ // Absent ⇒ kernel defaults (enabled, deny_after=5, terminate_after=8).
198
+ if (this.opts.repeatFuse !== undefined) {
199
+ const rf = this.opts.repeatFuse;
200
+ config.repeat_fuse = rf === false
201
+ ? { enabled: false, deny_after: 0, terminate_after: 0 }
202
+ : { enabled: true, deny_after: rf.denyAfter ?? 5, terminate_after: rf.terminateAfter ?? 8 };
203
+ }
204
+ // O4: turn-end criteria gate toggle (absent ⇒ kernel default: enabled).
205
+ if (this.opts.criteriaGate !== undefined) {
206
+ config.criteria_gate = this.opts.criteriaGate;
207
+ }
208
+ // K2: knowledge budget ratio (absent ⇒ kernel default 0.25; 0 disables).
209
+ if (this.opts.knowledgeBudgetRatio !== undefined) {
210
+ config.knowledge_budget_ratio = this.opts.knowledgeBudgetRatio;
211
+ }
184
212
  kernelApply(runtime, this.pendingObservations, { kind: "configure_run", config });
185
213
  }
186
214
  async appendMemorySyscallObservations(sessionId, observations) {
@@ -221,62 +249,39 @@ export class RuntimeRunner {
221
249
  return;
222
250
  kernelApply(this.activeKernel, this.pendingObservations, capabilityCommandUnmount(kind, id));
223
251
  }
224
- /** Phase 4: satisfy kernel page-in requests before meta-tool execution. */
225
- async applyKernelPageIn(runtime, sessionId) {
226
- const requests = this.pendingObservations.filter((o) => o.kind === "page_in_requested" && typeof o.tool === "string");
227
- if (requests.length === 0)
228
- return;
229
- const entries = [];
230
- for (const req of requests) {
231
- const query = typeof req.query === "string" ? req.query : "";
232
- const topK = typeof req.top_k === "number" ? req.top_k : 5;
233
- if (req.tool === "memory") {
234
- // Priority search: Local Page-Out Cache (lexical/keyword filter)
235
- const localHits = this.localPageOutCache.filter(m => typeof m.content === "string" && m.content.toLowerCase().includes(query.toLowerCase())).slice(0, topK);
236
- for (const hit of localHits) {
237
- entries.push({
238
- content: `[local semantic cache] ${hit.role}: ${hit.content}`,
239
- source: "semantic_cache",
240
- });
241
- }
242
- // Fall back to dreamStore for the remainder if needed
243
- const remainingK = topK - entries.length;
244
- if (remainingK > 0 && this.opts.dreamStore && this.opts.agentId) {
245
- const hits = await this.opts.dreamStore.search(this.opts.agentId, query, remainingK);
246
- for (const hit of hits) {
247
- entries.push({
248
- content: `[memory score=${hit.score.toFixed(3)}] ${hit.text}`,
249
- source: "memory",
250
- });
251
- }
252
- }
253
- }
254
- else if (req.tool === "knowledge" && this.opts.knowledgeSource) {
255
- const snippets = await this.opts.knowledgeSource.retrieve(query, topK);
256
- for (const snippet of snippets) {
257
- entries.push({ content: snippet, source: "knowledge" });
258
- }
259
- }
260
- }
261
- if (entries.length === 0)
262
- return;
263
- kernelApply(runtime, this.pendingObservations, { kind: "page_in", entries });
264
- await this.opts.sessionLog.append(sessionId, withCategory({
265
- kind: "page_in",
266
- turn: runtime.turn(),
267
- entry_count: entries.length,
268
- }));
269
- }
270
- /** Push content into the Knowledge slot (memory retrievals, skill definitions, artifacts). */
271
- pushKnowledge(message, tokens) {
252
+ /** Push content into the Knowledge slot (memory retrievals, skill definitions, artifacts).
253
+ * K1: `opts.key` gives the entry identity — a same-key push upserts (applied at the next
254
+ * compaction/renewal boundary, where the cached system[1] block is rewritten anyway) instead
255
+ * of appending a duplicate. `opts.pinned` exempts the entry from the knowledge-budget sweep. */
256
+ pushKnowledge(message, tokens, opts) {
272
257
  if (!this.activeKernel)
273
258
  return;
274
259
  kernelApply(this.activeKernel, this.pendingObservations, {
275
260
  kind: "add_knowledge_message",
276
261
  content: message.content ?? "",
277
262
  tokens: tokens ?? Math.max(1, Math.ceil((message.content?.length ?? 0) / 4)),
263
+ ...(opts?.key !== undefined ? { key: opts.key } : {}),
264
+ ...(opts?.pinned ? { pinned: true } : {}),
278
265
  });
279
266
  }
267
+ /** K1: mark a keyed knowledge entry for removal at the next compaction/renewal boundary.
268
+ * Errs-open: an unknown key is a kernel-side no-op. */
269
+ removeKnowledge(key) {
270
+ if (!this.activeKernel)
271
+ return;
272
+ kernelApply(this.activeKernel, this.pendingObservations, { kind: "remove_knowledge", key });
273
+ }
274
+ /** K3: host-driven skill deactivation (there is deliberately no model-facing unload — it
275
+ * invites thrash). The toolset re-widens at the next provider call; the skill's knowledge pin
276
+ * drops at the next compaction/renewal boundary. A later `skill(name)` call re-activates and
277
+ * re-pins fresh content. Errs-open: not-active is a kernel-side no-op. */
278
+ deactivateSkill(name) {
279
+ if (!this.activeKernel)
280
+ return;
281
+ kernelApply(this.activeKernel, this.pendingObservations, { kind: "skill_deactivated", name });
282
+ // Re-arm the SDK-side push guard so a re-activation re-pins the content.
283
+ this.knowledgePushedSkills.delete(name);
284
+ }
280
285
  /**
281
286
  * Spawn an isolated sub-agent via the kernel, run it on the host, and feed the result back.
282
287
  * Requires an active parent run (`run()` / `wake()` in progress or paused at milestone).
@@ -331,7 +336,10 @@ export class RuntimeRunner {
331
336
  // G4: surface the workflow's remaining budget to the node's agent so a coordinator can size its
332
337
  // `submit_workflow_nodes` batch to what is available (empty string ⇒ unbounded, no note).
333
338
  const budgetNote = workflowBudgetNote(budget);
334
- const withBudget = (goal) => (budgetNote ? `${goal}\n\n${budgetNote}` : goal);
339
+ // W-N2: a DAG edge carries data every dependent node sees its dependencies' outputs (the
340
+ // kernel sends `input_agent_ids` for all dependents; judges/reduce keep their special paths).
341
+ const depsNote = dependencyOutputsNote(node.input_agent_ids, outputs);
342
+ const withBudget = (goal) => [goal, depsNote, budgetNote].filter(Boolean).join("\n\n");
335
343
  const mkCtx = (goal) => ({
336
344
  parentOpts: this.opts,
337
345
  parentSessionId,
@@ -341,6 +349,10 @@ export class RuntimeRunner {
341
349
  // M5 v2.1: this child IS a workflow node — its `start_workflow` flattens to this kernel (the
342
350
  // workflow it would author joins the running DAG) rather than bootstrapping a nested pivot.
343
351
  isWorkflowNode: true,
352
+ // W-N1: trusted workflow nodes run on the parent's execution plane (they carry no grant list
353
+ // by design — filtering on the missing list ran every DAG node TOOL-LESS); quarantined nodes
354
+ // stay deny-all filtered (they read untrusted content).
355
+ toolAccess: (node.trust === "quarantined" ? "filtered" : "inherit"),
344
356
  // #2-B-ii: the per-node abort signal the driver fires when the kernel preempts this node.
345
357
  ...(abortSignal ? { abortSignal } : {}),
346
358
  ...(this.opts.subAgentHarness ? { harness: this.opts.subAgentHarness } : {}),
@@ -362,10 +374,18 @@ export class RuntimeRunner {
362
374
  const winnerId = winner === "right" ? node.judge_match.right : node.judge_match.left;
363
375
  return withSignal(result, { tournamentWinner: winnerId });
364
376
  }
365
- // A#2 v2 loop iteration: run the increment, then extract a stop signal so the kernel can end the
366
- // loop early (`loopContinue: false`). No signal run to `max_iters`.
377
+ // A#2 v2 loop iteration: run the increment under the armed pacing trap (workflowNodeToSpec set
378
+ // `loopRound`, and the iteration resumes the loop's stable session — transcript-as-carry).
379
+ // DW-3 one vocabulary: the kernel-adjudicated `pace` verb IS the continuation signal
380
+ // (stop → loopContinue=false); the legacy text-sniffed JSON blob survives only as the fallback
381
+ // when no pace decision arrives (stub orchestrators, harness children), where no signal still
382
+ // means "run to max_iters" (v1).
367
383
  if (node.loop_max_iters != null) {
368
- const result = await orchestrator.run(mkCtx(`${baseSpec.goal}\n\n${loopInstruction(node.loop_max_iters)}`));
384
+ const iteration = Number(/-i(\d+)$/.exec(node.agent_id)?.[1] ?? "0");
385
+ const result = await orchestrator.run(mkCtx(`${baseSpec.goal}\n\n${loopInstruction(node.loop_max_iters, iteration)}`));
386
+ const pace = result.result.paceDecision;
387
+ if (pace)
388
+ return withSignal(result, { loopContinue: pace.action !== "stop" });
369
389
  const cont = extractLoopContinue(textOf(result));
370
390
  return cont === undefined ? result : withSignal(result, { loopContinue: cont });
371
391
  }
@@ -451,7 +471,7 @@ export class RuntimeRunner {
451
471
  if (this.opts.runGroup) {
452
472
  const g = this.opts.runGroup;
453
473
  groupLedger = await g.budgetStore.read(g.id);
454
- await g.budgetStore.join(g.id, { sessionId, role: this.opts.agentId });
474
+ await g.budgetStore.join(g.id, { sessionId, role: this.opts.agentId, kind: "vehicle" });
455
475
  }
456
476
  this.bootstrapWorkflowKernel(sessionId, spec, groupLedger?.tokensSpent, groupLedger?.subagentsSpawned);
457
477
  }
@@ -464,10 +484,22 @@ export class RuntimeRunner {
464
484
  parent_session_id: parentSessionId,
465
485
  // W0-ABI resume: skip nodes already completed before an interruption.
466
486
  ...(opts?.resumedCompleted?.length ? { resumed_completed: opts.resumedCompleted } : {}),
487
+ // W-1: signal-carrying completion records (classify branch / loop stop replay).
488
+ ...(opts?.resumedResults?.length
489
+ ? {
490
+ resumed_results: opts.resumedResults.map(r => ({
491
+ agent_id: r.agentId,
492
+ ...(r.classifyBranch !== undefined ? { classify_branch: r.classifyBranch } : {}),
493
+ ...(r.tournamentWinner !== undefined ? { tournament_winner: r.tournamentWinner } : {}),
494
+ ...(r.loopContinue !== undefined ? { loop_continue: r.loopContinue } : {}),
495
+ })),
496
+ }
497
+ : {}),
467
498
  // R3-1: re-apply recorded runtime submissions so dynamically-appended nodes are reconstructed.
468
499
  ...(opts?.resumedSubmissions?.length ? { resumed_submissions: opts.resumedSubmissions } : {}),
500
+ ...(opts?.resumedSubmissionBases?.length ? { resumed_submission_bases: opts.resumedSubmissionBases } : {}),
469
501
  });
470
- return await this.driveWorkflow(observations, parentSessionId, runtime);
502
+ return await this.driveWorkflow(observations, parentSessionId, runtime, opts?.resumedOutputs);
471
503
  }
472
504
  finally {
473
505
  if (bootstrapped) {
@@ -535,6 +567,18 @@ export class RuntimeRunner {
535
567
  const parentSessionId = this.currentSessionId;
536
568
  const runtime = this.activeKernel;
537
569
  const observations = kernelApply(runtime, this.pendingObservations, submitWorkflowToKernel(spec, parentSessionId, opts?.submitterAgentId));
570
+ // W-3: persist the agent-authored batch (bootstrap base 0 / flatten base N — the kernel now
571
+ // announces BOTH) so an interrupted authored workflow reconstructs on resume; the host never
572
+ // had this spec, unlike the `runWorkflow` path.
573
+ const submitted = observations.find(o => o.kind === "workflow_nodes_submitted");
574
+ if (submitted) {
575
+ await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
576
+ turn: runtime.turn(),
577
+ nodes: workflowSpecToKernel(spec).nodes ?? [],
578
+ baseIndex: submitted.base,
579
+ submitterAgentId: opts?.submitterAgentId,
580
+ }));
581
+ }
538
582
  return this.driveWorkflow(observations, parentSessionId, runtime);
539
583
  }
540
584
  /**
@@ -571,7 +615,10 @@ export class RuntimeRunner {
571
615
  if (!source)
572
616
  return null;
573
617
  while (!batchState.settled) {
574
- const sig = await source.nextSignal(this.currentSessionId ?? undefined);
618
+ // O2: injected notes participate in the monitor too, so a host `injectNote` mid-batch is not
619
+ // stranded until the batch settles (the drain order matches `nextInboundSignal`).
620
+ const sig = this.injectedSignals.shift()
621
+ ?? await source.nextSignal(this.currentSessionId ?? undefined);
575
622
  if (batchState.settled)
576
623
  break;
577
624
  if (!sig) {
@@ -595,7 +642,7 @@ export class RuntimeRunner {
595
642
  * batch in parallel, feed completions back (appending any agent-submitted nodes first), and loop
596
643
  * until the kernel reports the workflow complete. Returns the completed / failed node agent-ids.
597
644
  */
598
- async driveWorkflow(initial, parentSessionId, runtime) {
645
+ async driveWorkflow(initial, parentSessionId, runtime, seedOutputs) {
599
646
  let observations = initial;
600
647
  const orchestrator = this.opts.subAgentOrchestrator ?? defaultSubAgentOrchestrator;
601
648
  const collectNodes = (obs) => obs.find(o => o.kind === "workflow_batch_spawned")
@@ -612,7 +659,9 @@ export class RuntimeRunner {
612
659
  // G2: each completed node's output, keyed by agent id — a reduce node reads its dependencies'
613
660
  // outputs from here. Deps always complete in an earlier round than the reduce node that needs
614
661
  // them (the kernel keeps the reduce node un-ready until its deps finish), so this is populated.
615
- const outputs = new Map();
662
+ // W-1: on resume it is pre-seeded from the persisted node outputs, so post-resume dependents
663
+ // still see their (pre-crash) dependencies' outputs.
664
+ const outputs = new Map(seedOutputs ?? []);
616
665
  for (;;) {
617
666
  if (nodes.length === 0)
618
667
  return { completed: [], failed: [], outputs: Object.fromEntries(outputs) }; // nothing to run (e.g. all gated)
@@ -641,7 +690,13 @@ export class RuntimeRunner {
641
690
  for (const result of results) {
642
691
  // G2: record this node's output so a downstream reduce node can consume it.
643
692
  const outContent = result.result.finalMessage?.content;
644
- outputs.set(result.agentId, typeof outContent === "string" ? outContent : outContent != null ? JSON.stringify(outContent) : "");
693
+ const outText = typeof outContent === "string" ? outContent : outContent != null ? JSON.stringify(outContent) : "";
694
+ outputs.set(result.agentId, outText);
695
+ // A loop iteration completes under `wf-node{N}-i{k}` but its dependents consume the STABLE
696
+ // node id `wf-node{N}` — alias it so the LAST iteration's output is what dependents see.
697
+ const stableId = result.agentId.replace(/-i\d+$/, "");
698
+ if (stableId !== result.agentId)
699
+ outputs.set(stableId, outText);
645
700
  // R3-1: if this node's agent submitted more nodes, append them to the parent DAG BEFORE
646
701
  // reporting the node's completion — the workflow is still active (the kernel hasn't seen this
647
702
  // node finish), so even a submission from the last running node keeps the DAG alive. The
@@ -653,10 +708,15 @@ export class RuntimeRunner {
653
708
  const subObs = kernelApply(runtime, this.pendingObservations, submitEvent);
654
709
  nextNodes.push(...collectNodes(subObs));
655
710
  budget = collectBudget(subObs) ?? budget;
656
- // R3-1: persist the submission (kernel-shape nodes) so resume can re-apply it.
711
+ // R3-1: persist the submission (kernel-shape nodes) + its kernel-reported base index
712
+ // so resume can re-apply the batch at the exact original graph position. W-N3: also the
713
+ // submitter, so resume drops batches whose submitter re-runs (it will re-submit).
714
+ const submitted = subObs.find(o => o.kind === "workflow_nodes_submitted");
657
715
  await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
658
716
  turn: runtime.turn(),
659
717
  nodes: submitEvent.nodes ?? [],
718
+ baseIndex: submitted?.base,
719
+ submitterAgentId: result.agentId,
660
720
  }));
661
721
  }
662
722
  const obs = kernelApply(runtime, this.pendingObservations, {
@@ -668,11 +728,17 @@ export class RuntimeRunner {
668
728
  const d = findDone(obs);
669
729
  if (d)
670
730
  done = d;
671
- // Persist node completion for resume recovery.
731
+ // Persist node completion for resume recovery. W-1: the result-borne control signals ride
732
+ // along (a resumed classifier re-prunes; a recorded loop stop is honored) plus the output
733
+ // text (post-resume dependents/reduce still see this node's output).
672
734
  await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodeCompletedEvent({
673
735
  turn: runtime.turn(),
674
736
  agentId: result.agentId,
675
737
  termination: result.result.termination,
738
+ classifyBranch: result.result.classifyBranch,
739
+ tournamentWinner: result.result.tournamentWinner,
740
+ loopContinue: result.result.loopContinue,
741
+ ...(result.result.termination === "completed" && outText ? { output: outText } : {}),
676
742
  }));
677
743
  }
678
744
  if (done && nextNodes.length === 0) {
@@ -683,8 +749,9 @@ export class RuntimeRunner {
683
749
  }
684
750
  /**
685
751
  * Resume a workflow from the parent session's completed nodes.
686
- * Reads the session log, extracts completed workflow node agent_ids, and
687
- * calls runWorkflow with resumedCompleted so the kernel skips those nodes.
752
+ * Reads the session log, extracts completed workflow node records (with their W-1 control
753
+ * signals + outputs), and calls runWorkflow so the kernel skips those nodes, replays control
754
+ * flow (classify prune / loop stop), and the driver re-seeds its outputs map.
688
755
  */
689
756
  async resumeWorkflow(spec, opts) {
690
757
  // Standalone resume: a stateless handler passes the prior `sessionId` to pick up an interrupted
@@ -694,11 +761,60 @@ export class RuntimeRunner {
694
761
  throw new Error("resumeWorkflow requires an active parent run or an explicit sessionId");
695
762
  }
696
763
  const events = await this.opts.sessionLog.read(sessionId);
697
- const resumedCompleted = recoverCompletedWorkflowNodes(events);
698
- const resumedSubmissions = recoverSubmittedWorkflowNodes(events);
699
- return this.runWorkflow(spec, { resumedCompleted, resumedSubmissions, sessionId });
764
+ const resumedResults = recoverCompletedWorkflowNodes(events);
765
+ const completedIds = new Set(resumedResults.map(r => r.agentId));
766
+ const recovered = recoverSubmittedWorkflowNodes(events);
767
+ // W-N3: DROP batches whose submitter did NOT complete — that node re-runs on resume and will
768
+ // re-submit its batch; replaying the logged copy too would duplicate its nodes in the DAG.
769
+ // Only safe with exact bases (the dropped batch's slots become inert placeholders); a legacy
770
+ // order-only log keeps every batch, since dropping would shift all later indices.
771
+ let { submissions, bases } = recovered;
772
+ if (bases.length === submissions.length && submissions.length > 0) {
773
+ const keep = recovered.submitters.map(s => s === undefined || completedIds.has(s));
774
+ submissions = submissions.filter((_, i) => keep[i]);
775
+ bases = bases.filter((_, i) => keep[i]);
776
+ }
777
+ const resumedOutputs = new Map(resumedResults.filter(r => r.output).map(r => [r.agentId, r.output]));
778
+ // Alias loop iterations onto their stable node id (last iteration wins) — dependents consume
779
+ // `wf-node{N}`, not `wf-node{N}-i{k}`.
780
+ for (const r of resumedResults) {
781
+ const stableId = r.agentId.replace(/-i\d+$/, "");
782
+ if (stableId !== r.agentId && r.output)
783
+ resumedOutputs.set(stableId, r.output);
784
+ }
785
+ return this.runWorkflow(spec, {
786
+ resumedResults,
787
+ resumedSubmissions: submissions,
788
+ resumedSubmissionBases: bases,
789
+ resumedOutputs,
790
+ sessionId,
791
+ });
700
792
  }
701
793
  interrupt() { this.interrupted = true; this.abortController?.abort(); }
794
+ /** Push a contextual note into the run's signal stream (the system-reminder channel): it drains at
795
+ * the next turn boundary, routes through the kernel attention policy, and — once acted on — renders
796
+ * as a `[SIGNAL] <text>` line in the volatile state turn plus a durable directive. Use it to feed
797
+ * host-detected events back to the model mid-run (e.g. "that write was a no-op — stop repeating it")
798
+ * without wiring a full `SignalSource`. `urgency` maps to the kernel disposition ladder: `"normal"`
799
+ * queues for the next boundary (default), `"high"` soft-interrupts, `"critical"` preempts. */
800
+ injectNote(text, urgency = "normal") {
801
+ this.injectedSignals.push({
802
+ source: "custom",
803
+ signalType: "event",
804
+ urgency,
805
+ payload: { goal: text },
806
+ });
807
+ }
808
+ /** Injected-note drain shared by the main loop's per-turn poll: injected notes first (FIFO), then
809
+ * the configured `signalSource`. Keeps the two inbound channels on one code path so they never drift. */
810
+ async nextInboundSignal() {
811
+ const injected = this.injectedSignals.shift();
812
+ if (injected)
813
+ return injected;
814
+ if (!this.opts.signalSource)
815
+ return null;
816
+ return this.opts.signalSource.nextSignal(this.currentSessionId ?? undefined);
817
+ }
702
818
  async *run(req) {
703
819
  const prior = req.inheritEvents ?? await this.opts.sessionLog.read(req.sessionId);
704
820
  const midRun = isMidRun(prior);
@@ -876,6 +992,65 @@ export class RuntimeRunner {
876
992
  }
877
993
  return { approved, denied, events };
878
994
  }
995
+ /**
996
+ * O7: resolve a `read_result` meta-tool call to the full text of a previously-evicted tool
997
+ * output. Resolution order: (a) this turn's in-memory `pendingSpoolOutputs` map (a call spooled
998
+ * earlier in the SAME tool-turn, before the session-log write lands), (b) the on-disk result
999
+ * spool (persisted once the kernel observation `large_result_spooled` was processed), (c) a
1000
+ * session-log scan for the original `tool_completed` event carrying that `call_id`. Slices the
1001
+ * resolved text by `[offset, offset + maxBytes)` (plain string slice — "bytes-ish").
1002
+ */
1003
+ async resolveReadResult(sessionId, argsJson) {
1004
+ let callId = "";
1005
+ let offset = 0;
1006
+ let maxBytes = 4000;
1007
+ try {
1008
+ const args = JSON.parse(argsJson || "{}");
1009
+ callId = typeof args.call_id === "string" ? args.call_id : "";
1010
+ if (typeof args.offset === "number" && Number.isFinite(args.offset))
1011
+ offset = args.offset;
1012
+ if (typeof args.max_bytes === "number" && Number.isFinite(args.max_bytes))
1013
+ maxBytes = args.max_bytes;
1014
+ }
1015
+ catch {
1016
+ // malformed arguments — callId stays empty, falls through to "not found" below
1017
+ }
1018
+ let full = this.pendingSpoolOutputs.get(callId)?.output;
1019
+ if (full === undefined) {
1020
+ const spool = this.opts.resultSpool ?? new LargeResultSpool();
1021
+ try {
1022
+ full = await spool.findByCallId(callId);
1023
+ }
1024
+ catch {
1025
+ full = undefined;
1026
+ }
1027
+ }
1028
+ if (full === undefined) {
1029
+ try {
1030
+ const events = await this.opts.sessionLog.read(sessionId);
1031
+ for (const { event } of events) {
1032
+ if (event.kind !== "tool_completed")
1033
+ continue;
1034
+ const match = event.results.find(r => r.call_id === callId);
1035
+ if (match)
1036
+ full = match.output;
1037
+ }
1038
+ }
1039
+ catch {
1040
+ full = undefined;
1041
+ }
1042
+ }
1043
+ if (full === undefined) {
1044
+ return { text: `no stored output for call_id "${callId}"`, isError: true };
1045
+ }
1046
+ const start = Math.max(0, offset);
1047
+ const end = Math.min(full.length, start + Math.max(0, maxBytes));
1048
+ const slice = full.slice(start, end);
1049
+ return {
1050
+ text: `[read_result ${callId}: chars ${start}–${end} of ${full.length}]\n${slice}`,
1051
+ isError: false,
1052
+ };
1053
+ }
879
1054
  async *execute(sessionId, goal, criteria, extensions, priorEvents, resumeMidRun = false, attachments) {
880
1055
  this.interrupted = false;
881
1056
  this.abortController = new AbortController();
@@ -999,14 +1174,36 @@ export class RuntimeRunner {
999
1174
  // P1-B B3: rebuild active-skill gating after a wake by re-emitting SkillActivated for each
1000
1175
  // `skill` tool call in the replayed history (active_skills is not snapshotted — graceful).
1001
1176
  // The catalog (set_available_skills) was already fed above, so allowed_tools resolves.
1177
+ // `knowledge` isn't snapshotted either (same graceful-reset philosophy) — best-effort re-push
1178
+ // the skill's content from its replayed tool_result so the durable copy survives a wake too.
1179
+ const toolResultByCallId = new Map();
1180
+ for (const m of replayed) {
1181
+ for (const part of m.contentParts ?? []) {
1182
+ if (part.type === "tool_result")
1183
+ toolResultByCallId.set(part.callId, part.output);
1184
+ }
1185
+ }
1002
1186
  for (const m of replayed) {
1003
1187
  for (const tc of m.toolCalls ?? []) {
1004
1188
  if (tc.name !== "skill")
1005
1189
  continue;
1006
1190
  try {
1007
1191
  const name = JSON.parse(tc.arguments || "{}").name;
1008
- if (name)
1009
- kernelApply(runtime, this.pendingObservations, { kind: "skill_activated", name });
1192
+ if (!name)
1193
+ continue;
1194
+ kernelApply(runtime, this.pendingObservations, {
1195
+ kind: "skill_activated",
1196
+ name,
1197
+ ...(this.opts.skillLeaseTurns !== undefined ? { lease_turns: this.opts.skillLeaseTurns } : {}),
1198
+ });
1199
+ const output = toolResultByCallId.get(tc.id);
1200
+ if (output && !this.knowledgePushedSkills.has(name)) {
1201
+ this.knowledgePushedSkills.add(name);
1202
+ // K1: keyed — the kernel-side upsert is the authoritative dedup, so a wake re-push
1203
+ // of a skill already pinned live can never double-pin (the in-run Set resets with
1204
+ // each runner instance; the key does not).
1205
+ this.pushKnowledge({ role: "system", content: output, toolCalls: [] }, undefined, { key: `skill:${name}` });
1206
+ }
1010
1207
  }
1011
1208
  catch { /* malformed skill args — skip */ }
1012
1209
  }
@@ -1041,9 +1238,9 @@ export class RuntimeRunner {
1041
1238
  if (this.opts.runGroup) {
1042
1239
  const g = this.opts.runGroup;
1043
1240
  groupLedger = await g.budgetStore.read(g.id);
1044
- await g.budgetStore.join(g.id, { sessionId, role: this.opts.agentId });
1241
+ await g.budgetStore.join(g.id, { sessionId, role: this.opts.agentId, kind: "vehicle" });
1045
1242
  }
1046
- this.applyKernelPolicies(runtime, groupLedger?.tokensSpent, groupLedger?.subagentsSpawned);
1243
+ this.applyKernelPolicies(runtime, groupLedger?.tokensSpent, groupLedger?.subagentsSpawned, groupLedger?.roundsCompleted);
1047
1244
  // Multimodal upload: seed the user's attachments (images/audio) as a history
1048
1245
  // message before start_run pushes the "[TASK STATE]" anchor. init_task does not
1049
1246
  // clear history, so order becomes [attachment user msg, "Proceed…"] — both land
@@ -1054,25 +1251,17 @@ export class RuntimeRunner {
1054
1251
  message: attachmentsToKernelMessage(attachments),
1055
1252
  });
1056
1253
  }
1057
- // I4: pre-fetch memory into the knowledge partition before the first LLM turn. Skipped on
1058
- // resumes (memory was already on the prior context) and when dreamStore/agentId is absent.
1059
- if (!resumeMidRun && this.opts.preQueryMemory && this.opts.dreamStore && this.opts.agentId) {
1060
- try {
1061
- const queries = await this.opts.preQueryMemory({ goal, runSpec: this.opts.runSpec });
1062
- const entries = [];
1063
- for (const q of queries ?? []) {
1064
- if (typeof q !== "string" || !q.trim())
1065
- continue;
1066
- const hits = await this.opts.dreamStore.search(this.opts.agentId, q, 5);
1067
- for (const hit of hits) {
1068
- entries.push({ content: `[memory score=${hit.score.toFixed(3)}] ${hit.text}`, source: "memory" });
1069
- }
1070
- }
1071
- if (entries.length > 0) {
1072
- kernelApply(runtime, this.pendingObservations, { kind: "page_in", entries });
1073
- }
1074
- }
1075
- catch { /* errs-open — a faulty pre-fetch never breaks the run */ }
1254
+ // I4: pre-fetch memory before the first LLM turn so the model sees it on turn 1 instead of
1255
+ // discovering it via the `memory` tool on turn 3+. Skipped on resumes (already in prior
1256
+ // context) and when dreamStore/agentId is absent.
1257
+ //
1258
+ // Strict dynamic context control: this is single-use retrieval content (facts relevant to
1259
+ // THIS run's goal right now), not a stable method/skill — so it lands in `history` as an
1260
+ // ordinary turn, exactly like a real `memory` tool result would, and decays with the
1261
+ // compression pyramid over subsequent turns instead of pinning itself in `knowledge` forever.
1262
+ this.currentGoal = goal;
1263
+ if (!resumeMidRun) {
1264
+ await this.prefetchMemoryIntoHistory(runtime, "initial");
1076
1265
  }
1077
1266
  let action = resumeMidRun
1078
1267
  ? kernelAction(runtime, this.pendingObservations, { kind: "resume" })
@@ -1088,18 +1277,14 @@ export class RuntimeRunner {
1088
1277
  // an input" from "the run is still in progress."
1089
1278
  try {
1090
1279
  while (!runtime.isTerminal()) {
1091
- // Page-in must run before appendObservations drains pending kernel observations.
1092
- if (action.kind === "execute_tool") {
1093
- await this.applyKernelPageIn(runtime, sessionId);
1094
- }
1095
1280
  nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart);
1096
1281
  this.nextArchiveStart = nextCompressedArchiveStart;
1097
1282
  if (this.interrupted) {
1098
1283
  action = kernelAction(runtime, this.pendingObservations, { kind: "timeout" });
1099
1284
  break;
1100
1285
  }
1101
- if (this.opts.signalSource) {
1102
- const sig = await this.opts.signalSource.nextSignal(this.currentSessionId ?? undefined);
1286
+ if (this.opts.signalSource || this.injectedSignals.length > 0) {
1287
+ const sig = await this.nextInboundSignal();
1103
1288
  if (sig) {
1104
1289
  // Kernel-routed: the kernel decides disposition (dedup/queue/interrupt) and emits
1105
1290
  // `signal_disposed`. An actionable disposition yields a new action to adopt; queued/observed/
@@ -1303,11 +1488,17 @@ export class RuntimeRunner {
1303
1488
  resultSpool: this.opts.resultSpool ?? new LargeResultSpool(),
1304
1489
  };
1305
1490
  const toolResults = [];
1306
- const normalCalls = allCalls.filter(c => c.name !== "update_plan" && c.name !== "submit_workflow_nodes" && c.name !== "start_workflow");
1491
+ const normalCalls = allCalls.filter(c => c.name !== "update_plan" && c.name !== "submit_workflow_nodes" && c.name !== "start_workflow"
1492
+ && c.name !== "read_result");
1307
1493
  const planCalls = allCalls.filter(c => c.name === "update_plan");
1308
1494
  // M5 v1: `start_workflow` (author a sub-workflow) flattens to the same append path as
1309
1495
  // `submit_workflow_nodes` — a `WorkflowSpec` is a node batch. (v2 adds top-level bootstrap.)
1310
1496
  const submitCalls = allCalls.filter(c => c.name === "submit_workflow_nodes" || c.name === "start_workflow");
1497
+ // O7: `read_result` re-fetches a tool output the kernel evicted from context. Content is
1498
+ // host-resolved: (a) this turn's in-memory pending spool map, (b) the on-disk result spool
1499
+ // (persisted once the kernel observes `large_result_spooled`), (c) a session-log scan for
1500
+ // the original `tool_completed` event. The kernel only advertises the capability.
1501
+ const readResultCalls = allCalls.filter(c => c.name === "read_result");
1311
1502
  for (const call of planCalls) {
1312
1503
  const update = parseUpdatePlanArgs(call.arguments);
1313
1504
  kernelApply(runtime, this.pendingObservations, {
@@ -1318,6 +1509,11 @@ export class RuntimeRunner {
1318
1509
  toolResults.push(result);
1319
1510
  yield { type: "tool_result", callId: call.id, content: "success", isError: false };
1320
1511
  }
1512
+ for (const call of readResultCalls) {
1513
+ const out = await this.resolveReadResult(sessionId, call.arguments);
1514
+ toolResults.push({ callId: call.id, output: out.text, isError: out.isError });
1515
+ yield { type: "tool_result", callId: call.id, content: out.text, isError: out.isError };
1516
+ }
1321
1517
  // R3-1: `submit_workflow_nodes` cannot be applied to this runner's kernel — when this runner
1322
1518
  // is a workflow node, the workflow lives in the *parent* kernel. Surface the requested nodes
1323
1519
  // as a stream event; the orchestrator collects them onto the node's result and `runWorkflow`
@@ -1347,8 +1543,37 @@ export class RuntimeRunner {
1347
1543
  toolResults.push(result);
1348
1544
  yield { type: "tool_result", callId: call.id, content: "submitted", isError: false };
1349
1545
  }
1350
- if (normalCalls.length > 0) {
1351
- for await (const evt of this.opts.executionPlane.executeAll(normalCalls, runCtx)) {
1546
+ // O5 (PreToolUse-hook analog): give the host a STATEFUL veto over each kernel-approved
1547
+ // call. A blocked call never executes; its reason reaches the model as a governance-denied
1548
+ // tool result (the kernel rolls the turn back with the note). Errs-open on hook throw.
1549
+ let executableCalls = normalCalls;
1550
+ if (this.opts.onToolCall) {
1551
+ const allowed = [];
1552
+ for (const call of normalCalls) {
1553
+ let decision;
1554
+ try {
1555
+ decision = await this.opts.onToolCall({ callId: call.id, name: call.name, arguments: call.arguments });
1556
+ }
1557
+ catch {
1558
+ decision = undefined;
1559
+ }
1560
+ if (decision?.block) {
1561
+ const reason = decision.reason ?? "blocked by host onToolCall hook";
1562
+ yield { type: "tool_denied", callId: call.id, toolName: call.name, reason };
1563
+ await this.opts.sessionLog.append(sessionId, {
1564
+ kind: "tool_denied", turn: runtime.turn(), call_id: call.id, tool_name: call.name, reason,
1565
+ });
1566
+ const out = `blocked by host hook: ${reason}`;
1567
+ toolResults.push({ callId: call.id, output: out, isError: true, errorKind: "governance_denied" });
1568
+ yield { type: "tool_result", callId: call.id, name: call.name, content: out, isError: true };
1569
+ continue;
1570
+ }
1571
+ allowed.push(call);
1572
+ }
1573
+ executableCalls = allowed;
1574
+ }
1575
+ if (executableCalls.length > 0) {
1576
+ for await (const evt of this.opts.executionPlane.executeAll(executableCalls, runCtx)) {
1352
1577
  yield evt;
1353
1578
  if (evt.type === "tool_result") {
1354
1579
  const tre = evt;
@@ -1402,12 +1627,38 @@ export class RuntimeRunner {
1402
1627
  });
1403
1628
  }
1404
1629
  }
1405
- const names = normalCalls.map(c => c.name).join(", ");
1630
+ const names = executableCalls.map(c => c.name).join(", ");
1406
1631
  kernelApply(runtime, this.pendingObservations, {
1407
1632
  kind: "update_task",
1408
1633
  update: taskUpdateToKernel({ progress: `Executed tools: ${names}` }),
1409
1634
  });
1410
1635
  }
1636
+ // O5 (PostToolUse-hook analog): let the host inspect each executed result BEFORE it
1637
+ // reaches the kernel/session-log — replace the output (redact/annotate) and/or push a
1638
+ // contextual note into the signal stream. Errs-open on hook throw.
1639
+ if (this.opts.onToolResult) {
1640
+ for (const r of toolResults) {
1641
+ const call = executableCalls.find(c => c.id === r.callId);
1642
+ if (!call)
1643
+ continue; // plan/submit synthetics and hook-blocked calls are not host results
1644
+ let decision;
1645
+ try {
1646
+ decision = await this.opts.onToolResult({
1647
+ callId: r.callId, name: call.name, arguments: call.arguments,
1648
+ output: r.output, isError: r.isError,
1649
+ });
1650
+ }
1651
+ catch {
1652
+ decision = undefined;
1653
+ }
1654
+ if (!decision)
1655
+ continue;
1656
+ if (typeof decision.replaceOutput === "string")
1657
+ r.output = decision.replaceOutput;
1658
+ if (decision.note)
1659
+ this.injectNote(decision.note);
1660
+ }
1661
+ }
1411
1662
  await this.opts.sessionLog.append(sessionId, {
1412
1663
  kind: "tool_completed",
1413
1664
  turn: runtime.turn(),
@@ -1427,6 +1678,13 @@ export class RuntimeRunner {
1427
1678
  // P1-B B3: a `skill` call that resolved successfully activates that skill in the kernel, so
1428
1679
  // the next `call_provider` narrows the toolset to its declared tools. Fed before `tool_results`
1429
1680
  // (which computes the next action). Errs-open: a failed/missing skill load doesn't activate.
1681
+ //
1682
+ // Strict dynamic context control: a skill is METHOD content — how to do something — reused
1683
+ // for the rest of the run, unlike a one-off memory/knowledge lookup (fact content, relevant
1684
+ // for the moment it's used). So its text ALSO goes into the durable `knowledge` slot here
1685
+ // (in addition to the ordinary tool_result already headed for `history`, where it will decay
1686
+ // with the compression pyramid like any other tool output — that's fine, the permanent copy
1687
+ // now lives in `knowledge`). First activation only (see `knowledgePushedSkills`).
1430
1688
  for (const call of allCalls) {
1431
1689
  if (call.name !== "skill")
1432
1690
  continue;
@@ -1435,8 +1693,21 @@ export class RuntimeRunner {
1435
1693
  continue;
1436
1694
  try {
1437
1695
  const name = JSON.parse(call.arguments || "{}").name;
1438
- if (name)
1439
- kernelApply(runtime, this.pendingObservations, { kind: "skill_activated", name });
1696
+ if (!name)
1697
+ continue;
1698
+ kernelApply(runtime, this.pendingObservations, {
1699
+ kind: "skill_activated",
1700
+ name,
1701
+ ...(this.opts.skillLeaseTurns !== undefined ? { lease_turns: this.opts.skillLeaseTurns } : {}),
1702
+ });
1703
+ // K1: keyed `skill:<name>` — the kernel-side upsert dedupes across runner instances
1704
+ // (wake re-push of an already-pinned skill upserts instead of duplicating). With a
1705
+ // lease configured, the Set optimization is skipped: an expired-then-reloaded skill
1706
+ // must re-pin, and only the kernel knows the lease state — its upsert dedupes anyway.
1707
+ if (this.opts.skillLeaseTurns !== undefined || !this.knowledgePushedSkills.has(name)) {
1708
+ this.knowledgePushedSkills.add(name);
1709
+ this.pushKnowledge({ role: "system", content: res.output, toolCalls: [] }, undefined, { key: `skill:${name}` });
1710
+ }
1440
1711
  }
1441
1712
  catch { /* malformed skill args — skip activation */ }
1442
1713
  }
@@ -1557,11 +1828,54 @@ export class RuntimeRunner {
1557
1828
  catch { /* non-fatal */ }
1558
1829
  }
1559
1830
  }
1560
- yield { type: "done", iterations: turnsUsed, totalTokens, status };
1831
+ yield {
1832
+ type: "done",
1833
+ iterations: turnsUsed,
1834
+ totalTokens,
1835
+ status,
1836
+ // ③ loop-agent: surface the kernel-adjudicated after-round decision to the driver.
1837
+ ...(result?.paceDecision ? { paceDecision: result.paceDecision } : {}),
1838
+ };
1561
1839
  this.activeKernel = null;
1562
1840
  this.currentSessionId = null;
1563
1841
  this.dashboard = null;
1564
1842
  }
1843
+ /** I4 + K4: fetch long-term memory hits for the current goal and land them in `history` as an
1844
+ * ordinary user turn — single-use retrieval content that decays with the compression pyramid,
1845
+ * never pinned into `knowledge`. Called once before turn 1 (`phase: "initial"`) and re-fired
1846
+ * after each sprint renewal (`phase: "renewal"`): renewal drops the old history INCLUDING the
1847
+ * earlier memory hits, so the new sprint gets a fresh recall pass. Errs-open throughout. */
1848
+ async prefetchMemoryIntoHistory(runtime, phase) {
1849
+ if (!this.opts.dreamStore || !this.opts.agentId)
1850
+ return;
1851
+ // P10: recall is default-on (CC session-start recall) — with no hook configured,
1852
+ // the goal itself is the query. preQueryMemory stays as the targeting override.
1853
+ const preQuery = this.opts.preQueryMemory
1854
+ ?? ((ctx) => [ctx.goal]);
1855
+ try {
1856
+ const queries = await preQuery({
1857
+ goal: this.currentGoal,
1858
+ runSpec: this.opts.runSpec,
1859
+ phase,
1860
+ });
1861
+ const lines = [];
1862
+ for (const q of queries ?? []) {
1863
+ if (typeof q !== "string" || !q.trim())
1864
+ continue;
1865
+ const hits = await this.opts.dreamStore.search(this.opts.agentId, q, 5);
1866
+ for (const hit of hits) {
1867
+ lines.push(`[memory score=${hit.score.toFixed(3)}] ${hit.text}`);
1868
+ }
1869
+ }
1870
+ if (lines.length > 0) {
1871
+ kernelApply(runtime, this.pendingObservations, {
1872
+ kind: "add_history_message",
1873
+ message: { role: "user", content: lines.join("\n") },
1874
+ });
1875
+ }
1876
+ }
1877
+ catch { /* errs-open — a faulty pre-fetch never breaks the run */ }
1878
+ }
1565
1879
  async appendObservations(sessionId, runtime, nextArchiveStart) {
1566
1880
  const turn = runtime.turn();
1567
1881
  const preservedRefs = runtime.preservedRefs();
@@ -1584,9 +1898,6 @@ export class RuntimeRunner {
1584
1898
  }
1585
1899
  }
1586
1900
  }
1587
- if (obs.kind === "page_out" && obs.archived) {
1588
- this.localPageOutCache.push(...obs.archived);
1589
- }
1590
1901
  if (obs.kind === "large_result_spooled") {
1591
1902
  const pending = this.pendingSpoolOutputs.get(obs.call_id ?? "");
1592
1903
  if (pending) {
@@ -1619,14 +1930,30 @@ export class RuntimeRunner {
1619
1930
  nextArchiveStart = compressedSeq + 1;
1620
1931
  const archived = obs.kind === "compressed" ? obs.archived : undefined;
1621
1932
  if (this.opts.asyncSummarizer && archived && archived.length > 0) {
1622
- void this.upgradeCompressedSummary(sessionId, compressedSeq, archived, compressionAction(obs.action) ?? "auto_compact");
1933
+ void this.upgradeCompressedSummary(sessionId, compressedSeq, archived, compressionAction(obs.action) ?? "auto_compact", runtime);
1934
+ }
1935
+ // One compaction = one kernel observation: the page_out session record (and the
1936
+ // semantic-archive branch) is DERIVED here from Compressed.tier_hint, preserving the
1937
+ // session-log format and OsSnapshot page_out_count.
1938
+ if (obs.tier_hint && Array.isArray(archived) && archived.length > 0) {
1939
+ await this.opts.sessionLog.append(sessionId, {
1940
+ kind: "page_out",
1941
+ turn: obs.turn ?? turn,
1942
+ action: compressionAction(obs.action),
1943
+ summary: obs.summary,
1944
+ tier_hint: obs.tier_hint ?? "durable",
1945
+ message_count: archived.length,
1946
+ });
1947
+ if (obs.tier_hint === "semantic") {
1948
+ void this.archiveSemanticPageOut(archived, compressionAction(obs.action));
1949
+ }
1623
1950
  }
1624
1951
  }
1625
- if (obs.kind === "page_out"
1626
- && obs.tier_hint === "semantic"
1627
- && Array.isArray(obs.archived)
1628
- && obs.archived.length > 0) {
1629
- void this.archiveSemanticPageOut(obs.archived, compressionAction(obs.action));
1952
+ // K4: a sprint renewal dropped the old history — including any earlier memory hits — so
1953
+ // re-run the preQueryMemory prefetch for the new sprint (live observations only: this
1954
+ // consumer sits on the live drain path, same placement as the semantic page-out archival).
1955
+ if (obs.kind === "renewed") {
1956
+ await this.prefetchMemoryIntoHistory(runtime, "renewal");
1630
1957
  }
1631
1958
  }
1632
1959
  return nextArchiveStart;
@@ -1638,23 +1965,25 @@ export class RuntimeRunner {
1638
1965
  const summary = this.opts.dreamSummarizer
1639
1966
  ? await this.opts.dreamSummarizer.summarize(archived, { action })
1640
1967
  : await summarizeForLongTermMemory(this.opts.dreamProvider ?? this.opts.provider, archived, this.opts.dreamSystemPrompt);
1641
- const existing = await this.opts.dreamStore.loadMemories(this.opts.agentId);
1642
- await this.opts.dreamStore.commit(this.opts.agentId, {
1643
- toAdd: [{ text: summary, score: 1.0, metadata: { source: "semantic_page_out", action } }],
1644
- toRemoveIndices: [],
1645
- stats: {
1646
- insightsProcessed: 1,
1647
- duplicatesRemoved: 0,
1648
- conflictsResolved: 0,
1649
- entriesAdded: 1,
1968
+ // P2 write-funnel: route through the ONE gated WriteMemory syscall so validation,
1969
+ // the rolling write quota, dedup, and the memory_written audit all apply. Score is
1970
+ // advisory (0.6) an automatic summary must never outrank curated content.
1971
+ await this.writeMemory({
1972
+ content: summary,
1973
+ metadata: {
1974
+ name: `page-out-${Date.now()}`,
1975
+ description: `auto summary of ${action ?? "compaction"} archive`,
1976
+ source: "semantic_page_out",
1977
+ action,
1978
+ score: 0.6,
1650
1979
  },
1651
- }, existing);
1980
+ });
1652
1981
  }
1653
1982
  catch {
1654
1983
  // non-fatal: in-context compression summary remains; long-term layer is best-effort
1655
1984
  }
1656
1985
  }
1657
- async upgradeCompressedSummary(sessionId, compressedSeq, archived, action) {
1986
+ async upgradeCompressedSummary(sessionId, compressedSeq, archived, action, runtime) {
1658
1987
  try {
1659
1988
  const summary = await this.opts.asyncSummarizer.summarize(archived, action);
1660
1989
  await this.opts.sessionLog.append(sessionId, {
@@ -1662,6 +1991,20 @@ export class RuntimeRunner {
1662
1991
  compressed_seq: compressedSeq,
1663
1992
  summary,
1664
1993
  });
1994
+ // P4: the LLM summary also re-enters the LIVE session as a keyed page-in entry —
1995
+ // K1 boundary-deferred upsert lands it with zero mid-generation cache churn and the
1996
+ // K2 budget governs its size. The RuleSummarizer text remains the synchronous label.
1997
+ if (runtime) {
1998
+ kernelApply(runtime, this.pendingObservations, {
1999
+ kind: "page_in",
2000
+ entries: [{
2001
+ content: `[ARCHIVE SUMMARY] ${summary}`,
2002
+ key: `summary:seq-${compressedSeq}`,
2003
+ pinned: false,
2004
+ source: "async_summarizer",
2005
+ }],
2006
+ });
2007
+ }
1665
2008
  }
1666
2009
  catch {
1667
2010
  // non-fatal: rule-based summary stays in place
@@ -1669,7 +2012,20 @@ export class RuntimeRunner {
1669
2012
  }
1670
2013
  }
1671
2014
  function isMidRun(events) {
1672
- return events.length > 0 && !events.some(e => e.event.kind === "run_terminal");
2015
+ // Mid-run the LAST run_started has no run_terminal after it. Pairing (not mere
2016
+ // presence) matters on multi-round loop sessions: round 1's terminal must not make a
2017
+ // crashed round 2 look fresh, and driver-level round_* records must not make a fresh
2018
+ // round look interrupted.
2019
+ let lastStarted = -1;
2020
+ let lastTerminal = -1;
2021
+ for (let i = 0; i < events.length; i++) {
2022
+ const k = events[i].event.kind;
2023
+ if (k === "run_started")
2024
+ lastStarted = i;
2025
+ else if (k === "run_terminal")
2026
+ lastTerminal = i;
2027
+ }
2028
+ return lastStarted >= 0 && lastStarted > lastTerminal;
1673
2029
  }
1674
2030
  /**
1675
2031
  * Build a kernel `add_history_message` payload from user attachments: a `user`
@@ -1724,6 +2080,50 @@ async function summarizeForLongTermMemory(provider, archived, systemPrompt) {
1724
2080
  }
1725
2081
  return text.trim() || transcript.slice(0, 2000);
1726
2082
  }
2083
+ /** Kernel-consumed meta-tools (e.g. `pace`) are answered by a synthetic tool result the kernel keeps
2084
+ * in its OWN history but never emits as a `tool_completed` session event (they never reach the
2085
+ * execution plane). On replay that leaves an assistant `tool_call` with no following tool result —
2086
+ * which strict OpenAI-compatible providers reject ("every tool_call must be answered by a tool
2087
+ * message"). This pass re-pairs any such orphan by inserting a synthetic tool-result message right
2088
+ * after its assistant message, reproducing the pair the kernel had all along.
2089
+ *
2090
+ * Discriminator: only pair an orphan when the run **continued past it** — i.e. a later non-tool
2091
+ * message exists. A tail assistant tool_call with nothing after it is a genuinely PENDING tool the
2092
+ * run stopped in front of (the wake/recovery case), which must stay unpaired so wake executes it.
2093
+ * Pure. */
2094
+ export function pairOrphanToolCalls(messages) {
2095
+ const out = [];
2096
+ for (let i = 0; i < messages.length; i++) {
2097
+ const m = messages[i];
2098
+ out.push(m);
2099
+ if (m.role !== "assistant" || !m.toolCalls?.length)
2100
+ continue;
2101
+ // Collect ids answered by the immediately-following run of tool messages; note where it ends.
2102
+ const answered = new Set();
2103
+ let j = i + 1;
2104
+ for (; j < messages.length && messages[j].role === "tool"; j++) {
2105
+ for (const p of messages[j].contentParts ?? []) {
2106
+ if (p.type === "tool_result")
2107
+ answered.add(p.callId);
2108
+ }
2109
+ }
2110
+ // If nothing follows the tool run, this tool_call is a pending tail (wake case) — leave it.
2111
+ if (j >= messages.length)
2112
+ continue;
2113
+ for (const c of m.toolCalls) {
2114
+ if (answered.has(c.id))
2115
+ continue;
2116
+ out.push({
2117
+ role: "tool",
2118
+ content: "",
2119
+ toolCalls: [],
2120
+ contentParts: [{ type: "tool_result", callId: c.id, output: `[${c.name} handled by kernel]`, isError: false }],
2121
+ tokenCount: 1,
2122
+ });
2123
+ }
2124
+ }
2125
+ return out;
2126
+ }
1727
2127
  export function replayMessages(events, maxBytes) {
1728
2128
  // Build upgraded-summary index: compressed_seq -> upgraded summary
1729
2129
  const upgradedSummaries = new Map();
@@ -1782,7 +2182,7 @@ export function replayMessages(events, maxBytes) {
1782
2182
  }
1783
2183
  }
1784
2184
  }
1785
- return messages;
2185
+ return pairOrphanToolCalls(messages);
1786
2186
  }
1787
2187
  export async function replayMessagesAsync(events, maxBytes, loadArchive) {
1788
2188
  // Build upgraded-summary index: compressed_seq -> upgraded summary
@@ -1862,7 +2262,7 @@ export async function replayMessagesAsync(events, maxBytes, loadArchive) {
1862
2262
  }
1863
2263
  }
1864
2264
  }
1865
- return messages;
2265
+ return pairOrphanToolCalls(messages);
1866
2266
  }
1867
2267
  function nextArchivedSeqStart(events) {
1868
2268
  let next = 0;
@@ -1965,6 +2365,19 @@ function authoredWorkflowOutcomeNote(outcome) {
1965
2365
  }
1966
2366
  /** Lower a host `RuntimeSignal` to the kernel's snake_case `signal` input event. Shared by the main
1967
2367
  * loop's per-turn poll and #2-B-ii's workflow-batch preemption monitor (so the two never drift). */
2368
+ /** Word-set jaccard similarity — the curator's dedup rule as a pure helper at the write funnel. */
2369
+ function jaccardSimilarity(a, b) {
2370
+ const sa = new Set(a.split(/\s+/).filter(Boolean));
2371
+ const sb = new Set(b.split(/\s+/).filter(Boolean));
2372
+ if (sa.size === 0 && sb.size === 0)
2373
+ return 1;
2374
+ let inter = 0;
2375
+ for (const w of sa)
2376
+ if (sb.has(w))
2377
+ inter++;
2378
+ const union = sa.size + sb.size - inter;
2379
+ return union === 0 ? 0 : inter / union;
2380
+ }
1968
2381
  function signalToKernelEvent(sig) {
1969
2382
  return {
1970
2383
  kind: "signal",