@deepstrike/wasm 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.
@@ -10,8 +10,8 @@ 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";
14
- import { kernelObservationToSessionEvent, withCategory } from "./kernel-event-log.js";
13
+ import { loopInstruction, classifyInstruction, judgeGoal, dependencyOutputsNote, extractLoopContinue, extractClassifyBranch, extractJudgeWinner, } from "./workflow-control-flow.js";
14
+ import { kernelObservationToSessionEvent } from "./kernel-event-log.js";
15
15
  import { assertNativeProfile } from "./os-profile.js";
16
16
  import { LargeResultSpool } from "./large-result-spool.js";
17
17
  export class RuntimeRunner {
@@ -22,8 +22,15 @@ export class RuntimeRunner {
22
22
  pendingObservations = [];
23
23
  activeKernel = null;
24
24
  currentSessionId = null;
25
+ /** O2 (system-reminder channel): host-pushed notes awaiting the next turn-boundary drain. */
26
+ injectedSignals = [];
27
+ /** Skill names whose content has already been pushed into the durable `knowledge` slot this
28
+ * run — guards against re-pushing a duplicate entry if the model calls `skill(name)` again for
29
+ * an already-active skill (loading is idempotent; the knowledge push should be too). */
30
+ knowledgePushedSkills = new Set();
31
+ /** K4: the active run's goal, kept for the renewal-boundary memory re-query. */
32
+ currentGoal = "";
25
33
  nextArchiveStart = 0;
26
- localPageOutCache = [];
27
34
  /** M5 v2.1: sub-workflow specs a top-level agent authored via `start_workflow`, awaiting auto-drive
28
35
  * at the next safe point (after the tool turn resolves, kernel back in Reason). */
29
36
  pendingAuthoredWorkflows = [];
@@ -33,6 +40,24 @@ export class RuntimeRunner {
33
40
  }
34
41
  get hostOptions() { return this.opts; }
35
42
  interrupt() { this.interrupted = true; this.abortController?.abort(); }
43
+ /** Push a contextual note into the run's signal stream (the system-reminder channel): it drains at
44
+ * the next turn boundary, routes through the kernel attention policy, and — once acted on — renders
45
+ * as a `[SIGNAL] <text>` line in the volatile state turn plus a durable directive. `urgency` maps to
46
+ * the kernel disposition ladder: `"normal"` queues (default), `"high"` soft-interrupts, `"critical"`
47
+ * preempts. */
48
+ injectNote(text, urgency = "normal") {
49
+ this.injectedSignals.push({ source: "custom", signalType: "event", urgency, payload: { goal: text } });
50
+ }
51
+ /** Injected-note drain shared with the main loop's per-turn poll: injected notes first (FIFO), then
52
+ * the configured `signalSource` — one code path so the two inbound channels never drift. */
53
+ async nextInboundSignal() {
54
+ const injected = this.injectedSignals.shift();
55
+ if (injected)
56
+ return injected;
57
+ if (!this.opts.signalSource)
58
+ return null;
59
+ return this.opts.signalSource.nextSignal();
60
+ }
36
61
  async *run(req) {
37
62
  const prior = req.inheritEvents ?? await this.opts.sessionLog.read(req.sessionId);
38
63
  const midRun = isMidRun(prior);
@@ -58,59 +83,35 @@ export class RuntimeRunner {
58
83
  const start = startEntry.event;
59
84
  yield* this.execute(sessionId, start.goal, start.criteria, extensions, events, true);
60
85
  }
61
- /** Push content into Slot 2 (system_knowledge) via add_knowledge_message. */
62
- pushKnowledge(message, tokens) {
86
+ /** Push content into Slot 2 (system_knowledge) via add_knowledge_message.
87
+ * K1: `opts.key` gives the entry identity — a same-key push upserts (applied at the next
88
+ * compaction/renewal boundary) instead of appending a duplicate. `opts.pinned` exempts the
89
+ * entry from the knowledge-budget sweep. */
90
+ pushKnowledge(message, tokens, opts) {
63
91
  if (!this.activeKernel)
64
92
  return;
65
93
  kernelApply(this.activeKernel, this.pendingObservations, {
66
94
  kind: "add_knowledge_message",
67
95
  content: message.content ?? "",
68
96
  tokens: tokens ?? Math.max(1, Math.ceil((message.content?.length ?? 0) / 4)),
97
+ ...(opts?.key !== undefined ? { key: opts.key } : {}),
98
+ ...(opts?.pinned ? { pinned: true } : {}),
69
99
  });
70
100
  }
71
- /** Phase 4: satisfy kernel page-in requests before meta-tool execution. */
72
- async applyKernelPageIn(runtime, sessionId) {
73
- const requests = this.pendingObservations.filter((o) => o.kind === "page_in_requested" && typeof o.tool === "string");
74
- if (requests.length === 0)
101
+ /** K1: mark a keyed knowledge entry for removal at the next compaction/renewal boundary.
102
+ * Errs-open: an unknown key is a kernel-side no-op. */
103
+ removeKnowledge(key) {
104
+ if (!this.activeKernel)
75
105
  return;
76
- const entries = [];
77
- for (const req of requests) {
78
- const query = typeof req.query === "string" ? req.query : "";
79
- const topK = typeof req.top_k === "number" ? req.top_k : 5;
80
- if (req.tool === "memory") {
81
- const localHits = this.localPageOutCache.filter(m => typeof m.content === "string" && m.content.toLowerCase().includes(query.toLowerCase())).slice(0, topK);
82
- for (const hit of localHits) {
83
- entries.push({
84
- content: `[local semantic cache] ${hit.role}: ${hit.content}`,
85
- source: "semantic_cache",
86
- });
87
- }
88
- const remainingK = topK - entries.length;
89
- if (remainingK > 0 && this.opts.dreamStore && this.opts.agentId) {
90
- const hits = await this.opts.dreamStore.search(this.opts.agentId, query, remainingK);
91
- for (const hit of hits) {
92
- entries.push({
93
- content: `[memory score=${hit.score.toFixed(3)}] ${hit.text}`,
94
- source: "memory",
95
- });
96
- }
97
- }
98
- }
99
- else if (req.tool === "knowledge" && this.opts.knowledgeSource) {
100
- const snippets = await this.opts.knowledgeSource.retrieve(query, topK);
101
- for (const snippet of snippets) {
102
- entries.push({ content: snippet, source: "knowledge" });
103
- }
104
- }
105
- }
106
- if (entries.length === 0)
106
+ kernelApply(this.activeKernel, this.pendingObservations, { kind: "remove_knowledge", key });
107
+ }
108
+ /** K3: host-driven skill deactivation toolset re-widens at the next provider call, the
109
+ * skill's knowledge pin drops at the next boundary. Errs-open: not-active is a no-op. */
110
+ deactivateSkill(name) {
111
+ if (!this.activeKernel)
107
112
  return;
108
- kernelApply(runtime, this.pendingObservations, { kind: "page_in", entries });
109
- await this.opts.sessionLog.append(sessionId, withCategory({
110
- kind: "page_in",
111
- turn: runtime.turn(),
112
- entry_count: entries.length,
113
- }));
113
+ kernelApply(this.activeKernel, this.pendingObservations, { kind: "skill_deactivated", name });
114
+ this.knowledgePushedSkills.delete(name);
114
115
  }
115
116
  async resolveKernelSuspend(runtime, sessionId) {
116
117
  const gated = this.pendingObservations.filter((o) => o.kind === "tool_gated" && typeof o.call_id === "string" && typeof o.tool === "string");
@@ -190,6 +191,63 @@ export class RuntimeRunner {
190
191
  }
191
192
  return { approved, denied, events };
192
193
  }
194
+ /**
195
+ * O7: resolve a `read_result` meta-tool call to the full text of a previously-evicted tool
196
+ * output. Resolution order: (a) this turn's in-memory `pendingSpoolOutputs` map, (b) the result
197
+ * spool (persisted once the kernel observation `large_result_spooled` was processed), (c) a
198
+ * session-log scan for the original `tool_completed` event carrying that `call_id`. Slices the
199
+ * resolved text by `[offset, offset + maxBytes)` (plain string slice — "bytes-ish").
200
+ */
201
+ async resolveReadResult(sessionId, argsJson) {
202
+ let callId = "";
203
+ let offset = 0;
204
+ let maxBytes = 4000;
205
+ try {
206
+ const args = JSON.parse(argsJson || "{}");
207
+ callId = typeof args.call_id === "string" ? args.call_id : "";
208
+ if (typeof args.offset === "number" && Number.isFinite(args.offset))
209
+ offset = args.offset;
210
+ if (typeof args.max_bytes === "number" && Number.isFinite(args.max_bytes))
211
+ maxBytes = args.max_bytes;
212
+ }
213
+ catch {
214
+ // malformed arguments — callId stays empty, falls through to "not found" below
215
+ }
216
+ let full = this.pendingSpoolOutputs.get(callId)?.output;
217
+ if (full === undefined && this.opts.resultSpool) {
218
+ try {
219
+ full = await this.opts.resultSpool.findByCallId(callId);
220
+ }
221
+ catch {
222
+ full = undefined;
223
+ }
224
+ }
225
+ if (full === undefined) {
226
+ try {
227
+ const events = await this.opts.sessionLog.read(sessionId);
228
+ for (const { event } of events) {
229
+ if (event.kind !== "tool_completed")
230
+ continue;
231
+ const match = event.results.find(r => r.call_id === callId);
232
+ if (match)
233
+ full = match.output;
234
+ }
235
+ }
236
+ catch {
237
+ full = undefined;
238
+ }
239
+ }
240
+ if (full === undefined) {
241
+ return { text: `no stored output for call_id "${callId}"`, isError: true };
242
+ }
243
+ const start = Math.max(0, offset);
244
+ const end = Math.min(full.length, start + Math.max(0, maxBytes));
245
+ const slice = full.slice(start, end);
246
+ return {
247
+ text: `[read_result ${callId}: chars ${start}–${end} of ${full.length}]\n${slice}`,
248
+ isError: false,
249
+ };
250
+ }
193
251
  async dream(agentId, nowMs = Date.now()) {
194
252
  if (!this.opts.dreamStore)
195
253
  throw new Error("dreamStore not configured");
@@ -351,14 +409,35 @@ export class RuntimeRunner {
351
409
  messages: replayed.map(messageToKernelMessage),
352
410
  });
353
411
  // P1-B B3: rebuild active-skill gating after a wake (active_skills is not snapshotted).
412
+ // `knowledge` isn't snapshotted either (same graceful-reset philosophy) — best-effort re-push
413
+ // the skill's content from its replayed tool_result so the durable copy survives a wake too.
414
+ const toolResultByCallId = new Map();
415
+ for (const m of replayed) {
416
+ for (const part of m.contentParts ?? []) {
417
+ if (part.type === "tool_result" && part.callId && part.output !== undefined) {
418
+ toolResultByCallId.set(part.callId, part.output);
419
+ }
420
+ }
421
+ }
354
422
  for (const m of replayed) {
355
423
  for (const tc of m.toolCalls ?? []) {
356
424
  if (tc.name !== "skill")
357
425
  continue;
358
426
  try {
359
427
  const name = JSON.parse(tc.arguments || "{}").name;
360
- if (name)
361
- kernelApply(runtime, this.pendingObservations, { kind: "skill_activated", name });
428
+ if (!name)
429
+ continue;
430
+ kernelApply(runtime, this.pendingObservations, {
431
+ kind: "skill_activated",
432
+ name,
433
+ ...(this.opts.skillLeaseTurns !== undefined ? { lease_turns: this.opts.skillLeaseTurns } : {}),
434
+ });
435
+ const output = toolResultByCallId.get(tc.id);
436
+ if (output && !this.knowledgePushedSkills.has(name)) {
437
+ this.knowledgePushedSkills.add(name);
438
+ // K1: keyed — the kernel-side upsert is the authoritative dedup across wake replays.
439
+ this.pushKnowledge({ role: "system", content: output }, undefined, { key: `skill:${name}` });
440
+ }
362
441
  }
363
442
  catch { /* skip */ }
364
443
  }
@@ -386,24 +465,13 @@ export class RuntimeRunner {
386
465
  startPayload.run_spec = agentRunSpecToKernel(spec);
387
466
  }
388
467
  this.applyKernelPolicies(runtime);
389
- // I4: pre-fetch memory into the knowledge partition before the first LLM turn (mirrors Node).
390
- if (!resumeMidRun && this.opts.preQueryMemory && this.opts.dreamStore && this.opts.agentId) {
391
- try {
392
- const queries = await this.opts.preQueryMemory({ goal });
393
- const entries = [];
394
- for (const q of queries ?? []) {
395
- if (typeof q !== "string" || !q.trim())
396
- continue;
397
- const hits = await this.opts.dreamStore.search(this.opts.agentId, q, 5);
398
- for (const hit of hits) {
399
- entries.push({ content: `[memory score=${hit.score.toFixed(3)}] ${hit.text}`, source: "memory" });
400
- }
401
- }
402
- if (entries.length > 0) {
403
- kernelApply(runtime, this.pendingObservations, { kind: "page_in", entries });
404
- }
405
- }
406
- catch { /* errs-open */ }
468
+ // I4: pre-fetch memory before the first LLM turn (mirrors Node). Strict dynamic context
469
+ // control: single-use retrieval content, not a stable skill — lands in `history` like an
470
+ // ordinary `memory` tool result, so it decays with the compression pyramid instead of
471
+ // pinning itself in `knowledge` forever.
472
+ this.currentGoal = goal;
473
+ if (!resumeMidRun) {
474
+ await this.prefetchMemoryIntoHistory(runtime, "initial");
407
475
  }
408
476
  let action = resumeMidRun
409
477
  ? kernelAction(runtime, this.pendingObservations, { kind: "resume" })
@@ -413,16 +481,13 @@ export class RuntimeRunner {
413
481
  // I0b: kernel-throw safety net — see Node runner for full rationale.
414
482
  try {
415
483
  while (!runtime.isTerminal()) {
416
- if (action.kind === "execute_tool") {
417
- await this.applyKernelPageIn(runtime, sessionId);
418
- }
419
484
  nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart);
420
485
  if (this.interrupted) {
421
486
  action = kernelAction(runtime, this.pendingObservations, { kind: "timeout" });
422
487
  break;
423
488
  }
424
- if (this.opts.signalSource) {
425
- const sig = await this.opts.signalSource.nextSignal();
489
+ if (this.opts.signalSource || this.injectedSignals.length > 0) {
490
+ const sig = await this.nextInboundSignal();
426
491
  if (sig) {
427
492
  const sigAction = kernelMaybeAction(runtime, this.pendingObservations, signalToKernelEvent(sig));
428
493
  if (sigAction)
@@ -608,7 +673,16 @@ export class RuntimeRunner {
608
673
  // the orchestrator collects them and `runWorkflow` sends them to the parent kernel.
609
674
  // M5 v1: `start_workflow` (author a sub-workflow) flattens to the same append path.
610
675
  const submitCalls = allCalls.filter(c => c.name === "submit_workflow_nodes" || c.name === "start_workflow");
611
- const normalCalls = allCalls.filter(c => c.name !== "submit_workflow_nodes" && c.name !== "start_workflow");
676
+ // O7: `read_result` re-fetches a tool output the kernel evicted from context. Content is
677
+ // host-resolved: (a) this turn's in-memory pending spool map, (b) the on-disk result spool,
678
+ // (c) a session-log scan for the original `tool_completed` event.
679
+ const readResultCalls = allCalls.filter(c => c.name === "read_result");
680
+ const normalCalls = allCalls.filter(c => c.name !== "submit_workflow_nodes" && c.name !== "start_workflow" && c.name !== "read_result");
681
+ for (const call of readResultCalls) {
682
+ const out = await this.resolveReadResult(sessionId, call.arguments);
683
+ toolResults.push({ callId: call.id, output: out.text, isError: out.isError });
684
+ yield { type: "tool_result", callId: call.id, content: out.text, isError: out.isError };
685
+ }
612
686
  for (const call of submitCalls) {
613
687
  // M5 v2.1: a TOP-LEVEL agent authoring a whole sub-workflow via `start_workflow` — record the
614
688
  // spec and AUTO-PIVOT once this tool turn resolves. A workflow-NODE's `start_workflow` (and
@@ -630,7 +704,35 @@ export class RuntimeRunner {
630
704
  toolResults.push({ callId: call.id, output: "submitted", isError: false });
631
705
  yield { type: "tool_result", callId: call.id, content: "submitted", isError: false };
632
706
  }
633
- for await (const evt of this.opts.executionPlane.executeAll(normalCalls, runCtx)) {
707
+ // O5 (PreToolUse-hook analog): stateful host veto over each kernel-approved call.
708
+ // A blocked call never executes; its reason reaches the model as a denied result.
709
+ let executableCalls = normalCalls;
710
+ if (this.opts.onToolCall) {
711
+ const allowed = [];
712
+ for (const call of normalCalls) {
713
+ let decision;
714
+ try {
715
+ decision = await this.opts.onToolCall({ callId: call.id, name: call.name, arguments: call.arguments });
716
+ }
717
+ catch {
718
+ decision = undefined;
719
+ }
720
+ if (decision?.block) {
721
+ const reason = decision.reason ?? "blocked by host onToolCall hook";
722
+ yield { type: "tool_denied", callId: call.id, toolName: call.name, reason };
723
+ await this.opts.sessionLog.append(sessionId, {
724
+ kind: "tool_denied", turn: runtime.turn(), call_id: call.id, tool_name: call.name, reason,
725
+ });
726
+ const out = `blocked by host hook: ${reason}`;
727
+ toolResults.push({ callId: call.id, output: out, isError: true, errorKind: "governance_denied" });
728
+ yield { type: "tool_result", callId: call.id, name: call.name, content: out, isError: true };
729
+ continue;
730
+ }
731
+ allowed.push(call);
732
+ }
733
+ executableCalls = allowed;
734
+ }
735
+ for await (const evt of this.opts.executionPlane.executeAll(executableCalls, runCtx)) {
634
736
  yield evt;
635
737
  if (evt.type === "tool_result") {
636
738
  const tre = evt;
@@ -684,6 +786,31 @@ export class RuntimeRunner {
684
786
  });
685
787
  }
686
788
  }
789
+ // O5 (PostToolUse-hook analog): host inspection of each executed result before it reaches
790
+ // the kernel/session-log — replace the output and/or inject a signal note. Errs-open.
791
+ if (this.opts.onToolResult) {
792
+ for (const r of toolResults) {
793
+ const call = executableCalls.find(c => c.id === r.callId);
794
+ if (!call)
795
+ continue;
796
+ let decision;
797
+ try {
798
+ decision = await this.opts.onToolResult({
799
+ callId: r.callId, name: call.name, arguments: call.arguments,
800
+ output: r.output, isError: r.isError,
801
+ });
802
+ }
803
+ catch {
804
+ decision = undefined;
805
+ }
806
+ if (!decision)
807
+ continue;
808
+ if (typeof decision.replaceOutput === "string")
809
+ r.output = decision.replaceOutput;
810
+ if (decision.note)
811
+ this.injectNote(decision.note);
812
+ }
813
+ }
687
814
  await this.opts.sessionLog.append(sessionId, {
688
815
  kind: "tool_completed",
689
816
  turn: runtime.turn(),
@@ -701,6 +828,12 @@ export class RuntimeRunner {
701
828
  }
702
829
  }
703
830
  // P1-B B3: a successfully-resolved `skill` call activates that skill for the next turn.
831
+ //
832
+ // Strict dynamic context control: a skill is METHOD content — how to do something — reused
833
+ // for the rest of the run, unlike a one-off memory/knowledge lookup (fact content, relevant
834
+ // for the moment it's used). So its text ALSO goes into the durable `knowledge` slot here
835
+ // (in addition to the ordinary tool_result already headed for `history`, where it will decay
836
+ // with the compression pyramid like any other tool output). First activation only.
704
837
  for (const call of allCalls) {
705
838
  if (call.name !== "skill")
706
839
  continue;
@@ -709,8 +842,20 @@ export class RuntimeRunner {
709
842
  continue;
710
843
  try {
711
844
  const name = JSON.parse(call.arguments || "{}").name;
712
- if (name)
713
- kernelApply(runtime, this.pendingObservations, { kind: "skill_activated", name });
845
+ if (!name)
846
+ continue;
847
+ kernelApply(runtime, this.pendingObservations, {
848
+ kind: "skill_activated",
849
+ name,
850
+ ...(this.opts.skillLeaseTurns !== undefined ? { lease_turns: this.opts.skillLeaseTurns } : {}),
851
+ });
852
+ // With a lease configured, skip the Set optimization: an expired-then-reloaded skill
853
+ // must re-pin — only the kernel knows the lease state; its upsert dedupes anyway.
854
+ if (this.opts.skillLeaseTurns !== undefined || !this.knowledgePushedSkills.has(name)) {
855
+ this.knowledgePushedSkills.add(name);
856
+ // K1: keyed `skill:<name>` — the kernel-side upsert dedupes across runner instances.
857
+ this.pushKnowledge({ role: "system", content: res.output }, undefined, { key: `skill:${name}` });
858
+ }
714
859
  }
715
860
  catch { /* skip */ }
716
861
  }
@@ -814,7 +959,14 @@ export class RuntimeRunner {
814
959
  catch { /* non-fatal */ }
815
960
  }
816
961
  }
817
- yield { type: "done", iterations: turnsUsed, totalTokens, status };
962
+ yield {
963
+ type: "done",
964
+ iterations: turnsUsed,
965
+ totalTokens,
966
+ status,
967
+ // ③ loop-agent: surface the kernel-adjudicated after-round decision to the driver.
968
+ ...(result?.paceDecision ? { paceDecision: result.paceDecision } : {}),
969
+ };
818
970
  this.activeKernel = null;
819
971
  this.currentSessionId = null;
820
972
  }
@@ -864,7 +1016,10 @@ export class RuntimeRunner {
864
1016
  const manifest = workflowNodeToManifest(node, parentSessionId);
865
1017
  // G4: surface remaining workflow budget so a coordinator node can size its submission.
866
1018
  const budgetNote = workflowBudgetNote(budget);
867
- const withBudget = (goal) => (budgetNote ? `${goal}\n\n${budgetNote}` : goal);
1019
+ // W-N2: a DAG edge carries data every dependent node sees its dependencies' outputs (the
1020
+ // kernel sends `input_agent_ids` for all dependents; judges/reduce keep their special paths).
1021
+ const depsNote = dependencyOutputsNote(node.input_agent_ids, outputs);
1022
+ const withBudget = (goal) => [goal, depsNote, budgetNote].filter(Boolean).join("\n\n");
868
1023
  const mkCtx = (goal) => ({
869
1024
  parentOpts: this.opts,
870
1025
  parentSessionId,
@@ -873,6 +1028,10 @@ export class RuntimeRunner {
873
1028
  sessionLog: this.opts.sessionLog,
874
1029
  // M5 v2.1: this child IS a workflow node — its `start_workflow` flattens to this kernel.
875
1030
  isWorkflowNode: true,
1031
+ // W-N1: trusted workflow nodes run on the parent's execution plane (they carry no grant list
1032
+ // by design — filtering on the missing list ran every DAG node TOOL-LESS); quarantined nodes
1033
+ // stay deny-all filtered (they read untrusted content).
1034
+ toolAccess: (node.trust === "quarantined" ? "filtered" : "inherit"),
876
1035
  // #2-B-ii: the per-node abort signal the driver fires when the kernel preempts this node.
877
1036
  ...(abortSignal ? { abortSignal } : {}),
878
1037
  });
@@ -892,9 +1051,18 @@ export class RuntimeRunner {
892
1051
  const winnerId = winner === "right" ? node.judge_match.right : node.judge_match.left;
893
1052
  return withSignal(result, { tournamentWinner: winnerId });
894
1053
  }
895
- // A#2 v2 loop iteration: run the increment, then extract a stop signal. No signal ⇒ run to cap.
1054
+ // A#2 v2 loop iteration: run the increment under the armed pacing trap (workflowNodeToSpec set
1055
+ // `loopRound`, and the iteration resumes the loop's stable session — transcript-as-carry).
1056
+ // DW-3 one vocabulary: the kernel-adjudicated `pace` verb IS the continuation signal
1057
+ // (stop → loopContinue=false); the legacy text-sniffed JSON blob survives only as the fallback
1058
+ // when no pace decision arrives (stub orchestrators, harness children), where no signal still
1059
+ // means "run to max_iters" (v1).
896
1060
  if (node.loop_max_iters != null) {
897
- const result = await orchestrator.run(mkCtx(`${baseSpec.goal}\n\n${loopInstruction(node.loop_max_iters)}`));
1061
+ const iteration = Number(/-i(\d+)$/.exec(node.agent_id)?.[1] ?? "0");
1062
+ const result = await orchestrator.run(mkCtx(`${baseSpec.goal}\n\n${loopInstruction(node.loop_max_iters, iteration)}`));
1063
+ const pace = result.result.paceDecision;
1064
+ if (pace)
1065
+ return withSignal(result, { loopContinue: pace.action !== "stop" });
898
1066
  const cont = extractLoopContinue(textOf(result));
899
1067
  return cont === undefined ? result : withSignal(result, { loopContinue: cont });
900
1068
  }
@@ -991,6 +1159,21 @@ export class RuntimeRunner {
991
1159
  : {}),
992
1160
  };
993
1161
  }
1162
+ // O6: tune/disable the in-kernel repeat fuse (absent ⇒ kernel defaults: enabled, 5/8).
1163
+ if (this.opts.repeatFuse !== undefined) {
1164
+ const rf = this.opts.repeatFuse;
1165
+ config.repeat_fuse = rf === false
1166
+ ? { enabled: false, deny_after: 0, terminate_after: 0 }
1167
+ : { enabled: true, deny_after: rf.denyAfter ?? 5, terminate_after: rf.terminateAfter ?? 8 };
1168
+ }
1169
+ // O4: turn-end criteria gate toggle (absent ⇒ kernel default: enabled).
1170
+ if (this.opts.criteriaGate !== undefined) {
1171
+ config.criteria_gate = this.opts.criteriaGate;
1172
+ }
1173
+ // K2: knowledge budget ratio (absent ⇒ kernel default 0.25; 0 disables).
1174
+ if (this.opts.knowledgeBudgetRatio !== undefined) {
1175
+ config.knowledge_budget_ratio = this.opts.knowledgeBudgetRatio;
1176
+ }
994
1177
  kernelApply(runtime, this.pendingObservations, { kind: "configure_run", config });
995
1178
  if (this.opts.memoryPolicy) {
996
1179
  const m = this.opts.memoryPolicy;
@@ -1055,10 +1238,22 @@ export class RuntimeRunner {
1055
1238
  parent_session_id: parentSessionId,
1056
1239
  // W0-ABI resume: skip nodes already completed before an interruption.
1057
1240
  ...(opts?.resumedCompleted && opts.resumedCompleted.length ? { resumed_completed: opts.resumedCompleted } : {}),
1241
+ // W-1: signal-carrying completion records (classify branch / loop stop replay).
1242
+ ...(opts?.resumedResults?.length
1243
+ ? {
1244
+ resumed_results: opts.resumedResults.map(r => ({
1245
+ agent_id: r.agentId,
1246
+ ...(r.classifyBranch !== undefined ? { classify_branch: r.classifyBranch } : {}),
1247
+ ...(r.tournamentWinner !== undefined ? { tournament_winner: r.tournamentWinner } : {}),
1248
+ ...(r.loopContinue !== undefined ? { loop_continue: r.loopContinue } : {}),
1249
+ })),
1250
+ }
1251
+ : {}),
1058
1252
  // R3-1: re-apply recorded runtime submissions so dynamically-appended nodes are reconstructed.
1059
1253
  ...(opts?.resumedSubmissions && opts.resumedSubmissions.length ? { resumed_submissions: opts.resumedSubmissions } : {}),
1254
+ ...(opts?.resumedSubmissionBases && opts.resumedSubmissionBases.length ? { resumed_submission_bases: opts.resumedSubmissionBases } : {}),
1060
1255
  });
1061
- return await this.driveWorkflow(observations, parentSessionId, runtime);
1256
+ return await this.driveWorkflow(observations, parentSessionId, runtime, opts?.resumedOutputs);
1062
1257
  }
1063
1258
  finally {
1064
1259
  if (bootstrapped) {
@@ -1081,6 +1276,18 @@ export class RuntimeRunner {
1081
1276
  const parentSessionId = this.currentSessionId;
1082
1277
  const runtime = this.activeKernel;
1083
1278
  const observations = kernelApply(runtime, this.pendingObservations, submitWorkflowToKernel(spec, parentSessionId, opts?.submitterAgentId));
1279
+ // W-3: persist the agent-authored batch (bootstrap base 0 / flatten base N — the kernel now
1280
+ // announces BOTH) so an interrupted authored workflow reconstructs on resume; the host never
1281
+ // had this spec, unlike the `runWorkflow` path.
1282
+ const submitted = observations.find(o => o.kind === "workflow_nodes_submitted");
1283
+ if (submitted) {
1284
+ await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
1285
+ turn: runtime.turn(),
1286
+ nodes: workflowSpecToKernel(spec).nodes ?? [],
1287
+ baseIndex: submitted.base,
1288
+ submitterAgentId: opts?.submitterAgentId,
1289
+ }));
1290
+ }
1084
1291
  return this.driveWorkflow(observations, parentSessionId, runtime);
1085
1292
  }
1086
1293
  /**
@@ -1113,7 +1320,8 @@ export class RuntimeRunner {
1113
1320
  if (!source)
1114
1321
  return null;
1115
1322
  while (!batchState.settled) {
1116
- const sig = await source.nextSignal();
1323
+ // O2: injected notes participate in the monitor too (drain order matches nextInboundSignal).
1324
+ const sig = this.injectedSignals.shift() ?? await source.nextSignal();
1117
1325
  if (batchState.settled)
1118
1326
  break;
1119
1327
  if (!sig) {
@@ -1136,7 +1344,7 @@ export class RuntimeRunner {
1136
1344
  * `submit_workflow`): run each kernel-emitted batch in parallel, feed completions back (appending any
1137
1345
  * agent-submitted nodes first), and loop until the kernel reports the workflow complete.
1138
1346
  */
1139
- async driveWorkflow(initial, parentSessionId, runtime) {
1347
+ async driveWorkflow(initial, parentSessionId, runtime, seedOutputs) {
1140
1348
  const observations = initial;
1141
1349
  const orchestrator = this.opts.subAgentOrchestrator ?? defaultSubAgentOrchestrator;
1142
1350
  const collectNodes = (obs) => obs.find(o => o.kind === "workflow_batch_spawned")?.nodes ?? [];
@@ -1149,7 +1357,9 @@ export class RuntimeRunner {
1149
1357
  let nodes = collectNodes(observations);
1150
1358
  let budget = collectBudget(observations);
1151
1359
  // G2: each completed node's output, keyed by agent id — a reduce node reads its deps' outputs.
1152
- const outputs = new Map();
1360
+ // W-1: on resume it is pre-seeded from the persisted node outputs, so post-resume dependents
1361
+ // still see their (pre-crash) dependencies' outputs.
1362
+ const outputs = new Map(seedOutputs ?? []);
1153
1363
  for (;;) {
1154
1364
  if (nodes.length === 0)
1155
1365
  return { completed: [], failed: [], outputs: Object.fromEntries(outputs) };
@@ -1169,7 +1379,13 @@ export class RuntimeRunner {
1169
1379
  for (const result of results) {
1170
1380
  // G2: record this node's output so a downstream reduce node can consume it.
1171
1381
  const outContent = result.result.finalMessage?.content;
1172
- outputs.set(result.agentId, typeof outContent === "string" ? outContent : outContent != null ? JSON.stringify(outContent) : "");
1382
+ const outText = typeof outContent === "string" ? outContent : outContent != null ? JSON.stringify(outContent) : "";
1383
+ outputs.set(result.agentId, outText);
1384
+ // A loop iteration completes under `wf-node{N}-i{k}` but its dependents consume the STABLE
1385
+ // node id `wf-node{N}` — alias it so the LAST iteration's output is what dependents see.
1386
+ const stableId = result.agentId.replace(/-i\d+$/, "");
1387
+ if (stableId !== result.agentId)
1388
+ outputs.set(stableId, outText);
1173
1389
  // R3-1: if this node's agent submitted more nodes, append them to the parent DAG BEFORE
1174
1390
  // reporting the node's completion — the workflow is still active, so even a last-node
1175
1391
  // submission keeps the DAG alive.
@@ -1180,10 +1396,15 @@ export class RuntimeRunner {
1180
1396
  const subObs = kernelApply(runtime, this.pendingObservations, submitEvent);
1181
1397
  nextNodes.push(...collectNodes(subObs));
1182
1398
  budget = collectBudget(subObs) ?? budget;
1183
- // R3-1: persist the submission (kernel-shape nodes) so resume can re-apply it.
1399
+ // R3-1: persist the submission (kernel-shape nodes) + its kernel-reported base index
1400
+ // so resume can re-apply the batch at the exact original graph position. W-N3: also the
1401
+ // submitter, so resume drops batches whose submitter re-runs (it will re-submit).
1402
+ const submitted = subObs.find(o => o.kind === "workflow_nodes_submitted");
1184
1403
  await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
1185
1404
  turn: runtime.turn(),
1186
1405
  nodes: submitEvent.nodes ?? [],
1406
+ baseIndex: submitted?.base,
1407
+ submitterAgentId: result.agentId,
1187
1408
  }));
1188
1409
  }
1189
1410
  const obs = kernelApply(runtime, this.pendingObservations, {
@@ -1195,11 +1416,17 @@ export class RuntimeRunner {
1195
1416
  const d = findDone(obs);
1196
1417
  if (d)
1197
1418
  done = d;
1198
- // Persist node completion for resume recovery.
1419
+ // Persist node completion for resume recovery. W-1: the result-borne control signals ride
1420
+ // along (a resumed classifier re-prunes; a recorded loop stop is honored) plus the output
1421
+ // text (post-resume dependents/reduce still see this node's output).
1199
1422
  await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodeCompletedEvent({
1200
1423
  turn: runtime.turn(),
1201
1424
  agentId: result.agentId,
1202
1425
  termination: result.result.termination,
1426
+ classifyBranch: result.result.classifyBranch,
1427
+ tournamentWinner: result.result.tournamentWinner,
1428
+ loopContinue: result.result.loopContinue,
1429
+ ...(result.result.termination === "completed" && outText ? { output: outText } : {}),
1203
1430
  }));
1204
1431
  }
1205
1432
  if (done && nextNodes.length === 0) {
@@ -1210,8 +1437,9 @@ export class RuntimeRunner {
1210
1437
  }
1211
1438
  /**
1212
1439
  * Resume a workflow from the parent session's completed nodes.
1213
- * Reads the session log, extracts completed workflow node agent_ids, and
1214
- * calls runWorkflow with resumedCompleted so the kernel skips those nodes.
1440
+ * Reads the session log, extracts completed workflow node records (with their W-1 control
1441
+ * signals + outputs), and calls runWorkflow so the kernel skips those nodes, replays control
1442
+ * flow (classify prune / loop stop), and the driver re-seeds its outputs map.
1215
1443
  */
1216
1444
  async resumeWorkflow(spec, opts) {
1217
1445
  // Standalone resume: a stateless handler passes the prior `sessionId`; mid-run callers omit it.
@@ -1220,9 +1448,34 @@ export class RuntimeRunner {
1220
1448
  throw new Error("resumeWorkflow requires an active parent run or an explicit sessionId");
1221
1449
  }
1222
1450
  const events = await this.opts.sessionLog.read(sessionId);
1223
- const resumedCompleted = recoverCompletedWorkflowNodes(events);
1224
- const resumedSubmissions = recoverSubmittedWorkflowNodes(events);
1225
- return this.runWorkflow(spec, { resumedCompleted, resumedSubmissions, sessionId });
1451
+ const resumedResults = recoverCompletedWorkflowNodes(events);
1452
+ const completedIds = new Set(resumedResults.map(r => r.agentId));
1453
+ const recovered = recoverSubmittedWorkflowNodes(events);
1454
+ // W-N3: DROP batches whose submitter did NOT complete — that node re-runs on resume and will
1455
+ // re-submit its batch; replaying the logged copy too would duplicate its nodes in the DAG.
1456
+ // Only safe with exact bases (the dropped batch's slots become inert placeholders); a legacy
1457
+ // order-only log keeps every batch, since dropping would shift all later indices.
1458
+ let { submissions, bases } = recovered;
1459
+ if (bases.length === submissions.length && submissions.length > 0) {
1460
+ const keep = recovered.submitters.map(s => s === undefined || completedIds.has(s));
1461
+ submissions = submissions.filter((_, i) => keep[i]);
1462
+ bases = bases.filter((_, i) => keep[i]);
1463
+ }
1464
+ const resumedOutputs = new Map(resumedResults.filter(r => r.output).map(r => [r.agentId, r.output]));
1465
+ // Alias loop iterations onto their stable node id (last iteration wins) — dependents consume
1466
+ // `wf-node{N}`, not `wf-node{N}-i{k}`.
1467
+ for (const r of resumedResults) {
1468
+ const stableId = r.agentId.replace(/-i\d+$/, "");
1469
+ if (stableId !== r.agentId && r.output)
1470
+ resumedOutputs.set(stableId, r.output);
1471
+ }
1472
+ return this.runWorkflow(spec, {
1473
+ resumedResults,
1474
+ resumedSubmissions: submissions,
1475
+ resumedSubmissionBases: bases,
1476
+ resumedOutputs,
1477
+ sessionId,
1478
+ });
1226
1479
  }
1227
1480
  async appendObservations(sessionId, runtime, nextArchiveStart) {
1228
1481
  const turn = runtime.turn();
@@ -1258,22 +1511,67 @@ export class RuntimeRunner {
1258
1511
  });
1259
1512
  if (!event)
1260
1513
  continue;
1261
- if (obs.kind === "page_out" && obs.archived) {
1262
- this.localPageOutCache.push(...obs.archived);
1263
- }
1264
1514
  const compressedSeq = await this.opts.sessionLog.append(sessionId, event);
1265
1515
  if (event.kind === "compressed") {
1266
1516
  nextArchiveStart = compressedSeq + 1;
1517
+ // One compaction = one kernel observation: the page_out session record (and the
1518
+ // semantic-archive branch) is DERIVED from Compressed.tier_hint, preserving the
1519
+ // session-log format and OsSnapshot page_out_count.
1520
+ const archived = obs.archived;
1521
+ if (obs.tier_hint && Array.isArray(archived) && archived.length > 0) {
1522
+ await this.opts.sessionLog.append(sessionId, {
1523
+ kind: "page_out",
1524
+ turn: obs.turn ?? turn,
1525
+ action: compressionAction(obs.action),
1526
+ summary: obs.summary,
1527
+ tier_hint: obs.tier_hint ?? "durable",
1528
+ message_count: archived.length,
1529
+ });
1530
+ if (obs.tier_hint === "semantic") {
1531
+ void this.archiveSemanticPageOut(archived, compressionAction(obs.action));
1532
+ }
1533
+ }
1267
1534
  }
1268
- if (obs.kind === "page_out"
1269
- && obs.tier_hint === "semantic"
1270
- && Array.isArray(obs.archived)
1271
- && obs.archived.length > 0) {
1272
- void this.archiveSemanticPageOut(obs.archived, compressionAction(obs.action));
1535
+ // K4: a sprint renewal dropped the old history — including any earlier memory hits — so
1536
+ // re-run the preQueryMemory prefetch for the new sprint (live observations only).
1537
+ if (obs.kind === "renewed") {
1538
+ await this.prefetchMemoryIntoHistory(runtime, "renewal");
1273
1539
  }
1274
1540
  }
1275
1541
  return nextArchiveStart;
1276
1542
  }
1543
+ /** I4 + K4: fetch long-term memory hits for the current goal and land them in `history` as an
1544
+ * ordinary user turn — single-use retrieval content that decays with the compression pyramid,
1545
+ * never pinned into `knowledge`. `phase: "initial"` = once before turn 1; `phase: "renewal"` =
1546
+ * re-fired after each sprint renewal (renewal drops the old history INCLUDING earlier memory
1547
+ * hits). Errs-open throughout. */
1548
+ async prefetchMemoryIntoHistory(runtime, phase) {
1549
+ if (!this.opts.dreamStore || !this.opts.agentId)
1550
+ return;
1551
+ // P10: recall is default-on (CC session-start recall) — with no hook configured,
1552
+ // the goal itself is the query. preQueryMemory stays as the targeting override.
1553
+ const preQuery = this.opts.preQueryMemory
1554
+ ?? ((ctx) => [ctx.goal]);
1555
+ try {
1556
+ const queries = await preQuery({ goal: this.currentGoal, phase });
1557
+ const lines = [];
1558
+ for (const q of queries ?? []) {
1559
+ if (typeof q !== "string" || !q.trim())
1560
+ continue;
1561
+ const hits = await this.opts.dreamStore.search(this.opts.agentId, q, 5);
1562
+ for (const hit of hits) {
1563
+ lines.push(`[memory score=${hit.score.toFixed(3)}] ${hit.text}`);
1564
+ }
1565
+ }
1566
+ if (lines.length > 0) {
1567
+ kernelApply(runtime, this.pendingObservations, {
1568
+ kind: "add_history_message",
1569
+ message: { role: "user", content: lines.join("\n") },
1570
+ });
1571
+ }
1572
+ }
1573
+ catch { /* errs-open */ }
1574
+ }
1277
1575
  async archiveSemanticPageOut(archived, action) {
1278
1576
  if (!this.opts.dreamStore || !this.opts.agentId)
1279
1577
  return;
@@ -1282,8 +1580,12 @@ export class RuntimeRunner {
1282
1580
  ? await this.opts.dreamSummarizer.summarize(archived, { action })
1283
1581
  : await summarizeForLongTermMemory(this.opts.dreamProvider ?? this.opts.provider, archived, this.opts.dreamSystemPrompt);
1284
1582
  const existing = await this.opts.dreamStore.loadMemories(this.opts.agentId);
1583
+ // P2 write-funnel (wasm surface has no kernel write gate yet): jaccard dedup +
1584
+ // advisory 0.6 score — an automatic summary must never outrank curated content.
1585
+ if (existing.some(e => jaccardSimilarity(e.text, summary) >= 0.9))
1586
+ return;
1285
1587
  await this.opts.dreamStore.commit(this.opts.agentId, {
1286
- toAdd: [{ text: summary, score: 1.0, metadata: { source: "semantic_page_out", action } }],
1588
+ toAdd: [{ text: summary, score: 0.6, metadata: { source: "semantic_page_out", action } }],
1287
1589
  toRemoveIndices: [],
1288
1590
  stats: {
1289
1591
  insightsProcessed: 1,
@@ -1298,6 +1600,19 @@ export class RuntimeRunner {
1298
1600
  }
1299
1601
  }
1300
1602
  }
1603
+ /** Word-set jaccard similarity — the curator's dedup rule at the write funnel. */
1604
+ function jaccardSimilarity(a, b) {
1605
+ const sa = new Set(a.split(/\s+/).filter(Boolean));
1606
+ const sb = new Set(b.split(/\s+/).filter(Boolean));
1607
+ if (sa.size === 0 && sb.size === 0)
1608
+ return 1;
1609
+ let inter = 0;
1610
+ for (const w of sa)
1611
+ if (sb.has(w))
1612
+ inter++;
1613
+ const union = sa.size + sb.size - inter;
1614
+ return union === 0 ? 0 : inter / union;
1615
+ }
1301
1616
  async function summarizeForLongTermMemory(provider, archived, systemPrompt) {
1302
1617
  const transcript = archived
1303
1618
  .map(m => `${m.role}: ${m.content}`)