@deepstrike/sdk 0.2.36 → 0.2.38

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.
@@ -5,12 +5,12 @@ import { peekProviderReplay, seedProviderReplayFromEvents } from "./provider-rep
5
5
  import { sanitizeReplayText } from "./replay-sanitize.js";
6
6
  import { buildLlmCompletedEvent, buildRunTerminalEvent, buildWorkflowNodeCompletedEvent, buildWorkflowNodesSubmittedEvent, recoverCompletedWorkflowNodes, recoverSubmittedWorkflowNodes, repairEventsForRecovery, } from "./session-repair.js";
7
7
  import { KernelPrimitivesDashboard } from "./kernel-primitives-dashboard.js";
8
- import { capabilityMarker, capabilitySkill, capabilityTool, capabilityCommandMount, capabilityCommandUnmount, kernelAction, kernelApply, kernelMaybeAction, messageToKernelMessage, skillMetadataToKernel, taskUpdateToKernel, toolResultToKernel, toolSchemaToKernel, } from "./kernel-step.js";
8
+ import { capabilityMarker, capabilitySkill, capabilityTool, capabilityCommandMount, capabilityCommandUnmount, entropySampleFromObservation, kernelAction, kernelApply, kernelMaybeAction, messageToKernelMessage, skillMetadataToKernel, taskUpdateToKernel, toolResultToKernel, toolSchemaToKernel, } from "./kernel-step.js";
9
9
  import { agentRunSpecToKernel, findSpawnProcessObservation, milestoneCheckPass, milestoneCheckResultToKernel, spawnObservationToManifest, subAgentResultToKernel, submitWorkflowNodesToKernel, submitWorkflowToKernel, workflowBudgetNote, workflowNodeToManifest, workflowNodeToSpec, workflowSpecToKernel, } from "../types/agent.js";
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
15
  import { kernelObservationToSessionEvent } from "./kernel-event-log.js";
16
16
  import { assertNativeProfile } from "./os-profile.js";
@@ -40,6 +40,8 @@ export class RuntimeRunner {
40
40
  * at the next safe point (after the tool turn resolves, kernel back in Reason — not suspended). */
41
41
  pendingAuthoredWorkflows = [];
42
42
  dashboard = null;
43
+ /** Most recent kernel entropy sample of the active/last run (see `latestEntropy`). */
44
+ lastEntropySample = null;
43
45
  constructor(opts) {
44
46
  this.opts = opts;
45
47
  if (opts.enableDiagnosticsDashboard) {
@@ -72,23 +74,30 @@ export class RuntimeRunner {
72
74
  return;
73
75
  }
74
76
  const existing = await this.opts.dreamStore.loadMemories(agentId);
75
- await this.opts.dreamStore.commit(agentId, {
76
- toAdd: [{
77
- text: memory.content,
78
- score: 1.0,
79
- metadata: {
80
- ...memory.metadata,
81
- source: "write_memory_syscall",
82
- },
83
- }],
84
- toRemoveIndices: [],
85
- stats: {
86
- insightsProcessed: 1,
87
- duplicatesRemoved: 0,
88
- conflictsResolved: 0,
89
- entriesAdded: 1,
90
- },
91
- }, existing);
77
+ // Curator-style jaccard dedup at the single write path: a near-duplicate of an
78
+ // existing entry is dropped (the observation is still logged for audit).
79
+ const isDuplicate = existing.some(e => jaccardSimilarity(e.text, memory.content) >= 0.9);
80
+ if (!isDuplicate) {
81
+ const meta = memory.metadata;
82
+ const score = typeof meta?.score === "number" ? meta.score : 1.0;
83
+ await this.opts.dreamStore.commit(agentId, {
84
+ toAdd: [{
85
+ text: memory.content,
86
+ score,
87
+ metadata: {
88
+ ...memory.metadata,
89
+ source: meta?.source ?? "write_memory_syscall",
90
+ },
91
+ }],
92
+ toRemoveIndices: [],
93
+ stats: {
94
+ insightsProcessed: 1,
95
+ duplicatesRemoved: 0,
96
+ conflictsResolved: 0,
97
+ entriesAdded: 1,
98
+ },
99
+ }, existing);
100
+ }
92
101
  await this.appendMemorySyscallObservations(sessionId, observations);
93
102
  }
94
103
  async queryMemory(query, opts = {}) {
@@ -116,24 +125,19 @@ export class RuntimeRunner {
116
125
  }
117
126
  }
118
127
  await this.appendMemorySyscallObservations(sessionId, observations);
119
- await this.logMemoryRetrievalResult(sessionId, runtime, retrieval);
128
+ await this.logMemoryRetrievalResult(sessionId, retrieval);
120
129
  return hits;
121
130
  }
122
- async logMemoryRetrievalResult(sessionId, runtime, retrieval) {
131
+ async logMemoryRetrievalResult(sessionId, retrieval) {
123
132
  if (!sessionId)
124
133
  return;
134
+ // The session-log record is the durable audit artifact; the kernel needs no
135
+ // acknowledgment (the former kernel event was a no-op and was removed).
125
136
  await this.opts.sessionLog.append(sessionId, {
126
137
  kind: "memory_retrieval_result",
127
138
  selected_memory_ids: retrieval.selected_memory_ids,
128
139
  selection_rationale: retrieval.selection_rationale,
129
140
  });
130
- kernelApply(runtime, [], {
131
- kind: "memory_retrieval_result",
132
- retrieval: {
133
- selected_memory_ids: retrieval.selected_memory_ids,
134
- selection_rationale: retrieval.selection_rationale,
135
- },
136
- });
137
141
  }
138
142
  createSyscallRuntime() {
139
143
  const { KernelRuntime } = getKernel();
@@ -152,7 +156,7 @@ export class RuntimeRunner {
152
156
  * exactly as a mid-run spawn would be. Must run BEFORE `start_run` so the in-kernel gate enforces
153
157
  * every policy from the first spawn. No config ⇒ the native-profile defaults (铁律: defaults only).
154
158
  */
155
- applyKernelPolicies(runtime, groupTokensBase, groupSpawnsBase) {
159
+ applyKernelPolicies(runtime, groupTokensBase, groupSpawnsBase, groupRoundsBase) {
156
160
  // K2: lower governance / attention / scheduler / quota in ONE `configure_run` event instead of
157
161
  // the previous 2–4 separate `set_*` / `load_governance_policy` events. The kernel applies each
158
162
  // present field via the same path its granular event uses; absent fields are left untouched.
@@ -187,6 +191,10 @@ export class RuntimeRunner {
187
191
  if (groupSpawnsBase !== undefined && groupSpawnsBase > 0) {
188
192
  config.group_spawns_base = groupSpawnsBase;
189
193
  }
194
+ if (groupRoundsBase !== undefined && groupRoundsBase > 0) {
195
+ // ③ loop-agent: completed-round count seeds the pacing trap's max_rounds coercion.
196
+ config.group_rounds_base = groupRoundsBase;
197
+ }
190
198
  // O6: tune/disable the in-kernel repeat fuse. `false` disables; an object overrides thresholds.
191
199
  // Absent ⇒ kernel defaults (enabled, deny_after=5, terminate_after=8).
192
200
  if (this.opts.repeatFuse !== undefined) {
@@ -203,6 +211,18 @@ export class RuntimeRunner {
203
211
  if (this.opts.knowledgeBudgetRatio !== undefined) {
204
212
  config.knowledge_budget_ratio = this.opts.knowledgeBudgetRatio;
205
213
  }
214
+ // Entropy watch (opt-in): threshold alerting over the per-turn session-entropy score.
215
+ // Absent fields keep kernel defaults (threshold 0.65 / hysteresis 0.1 / cooldown 4).
216
+ if (this.opts.entropyWatch !== undefined) {
217
+ const ew = this.opts.entropyWatch;
218
+ config.entropy_watch = {
219
+ enabled: ew.enabled ?? true,
220
+ ...(ew.threshold !== undefined ? { threshold: ew.threshold } : {}),
221
+ ...(ew.hysteresis !== undefined ? { hysteresis: ew.hysteresis } : {}),
222
+ ...(ew.cooldownTurns !== undefined ? { cooldown_turns: ew.cooldownTurns } : {}),
223
+ ...(ew.notifyModel !== undefined ? { notify_model: ew.notifyModel } : {}),
224
+ };
225
+ }
206
226
  kernelApply(runtime, this.pendingObservations, { kind: "configure_run", config });
207
227
  }
208
228
  async appendMemorySyscallObservations(sessionId, observations) {
@@ -330,7 +350,10 @@ export class RuntimeRunner {
330
350
  // G4: surface the workflow's remaining budget to the node's agent so a coordinator can size its
331
351
  // `submit_workflow_nodes` batch to what is available (empty string ⇒ unbounded, no note).
332
352
  const budgetNote = workflowBudgetNote(budget);
333
- const withBudget = (goal) => (budgetNote ? `${goal}\n\n${budgetNote}` : goal);
353
+ // W-N2: a DAG edge carries data every dependent node sees its dependencies' outputs (the
354
+ // kernel sends `input_agent_ids` for all dependents; judges/reduce keep their special paths).
355
+ const depsNote = dependencyOutputsNote(node.input_agent_ids, outputs);
356
+ const withBudget = (goal) => [goal, depsNote, budgetNote].filter(Boolean).join("\n\n");
334
357
  const mkCtx = (goal) => ({
335
358
  parentOpts: this.opts,
336
359
  parentSessionId,
@@ -340,6 +363,10 @@ export class RuntimeRunner {
340
363
  // M5 v2.1: this child IS a workflow node — its `start_workflow` flattens to this kernel (the
341
364
  // workflow it would author joins the running DAG) rather than bootstrapping a nested pivot.
342
365
  isWorkflowNode: true,
366
+ // W-N1: trusted workflow nodes run on the parent's execution plane (they carry no grant list
367
+ // by design — filtering on the missing list ran every DAG node TOOL-LESS); quarantined nodes
368
+ // stay deny-all filtered (they read untrusted content).
369
+ toolAccess: (node.trust === "quarantined" ? "filtered" : "inherit"),
343
370
  // #2-B-ii: the per-node abort signal the driver fires when the kernel preempts this node.
344
371
  ...(abortSignal ? { abortSignal } : {}),
345
372
  ...(this.opts.subAgentHarness ? { harness: this.opts.subAgentHarness } : {}),
@@ -361,10 +388,18 @@ export class RuntimeRunner {
361
388
  const winnerId = winner === "right" ? node.judge_match.right : node.judge_match.left;
362
389
  return withSignal(result, { tournamentWinner: winnerId });
363
390
  }
364
- // A#2 v2 loop iteration: run the increment, then extract a stop signal so the kernel can end the
365
- // loop early (`loopContinue: false`). No signal run to `max_iters`.
391
+ // A#2 v2 loop iteration: run the increment under the armed pacing trap (workflowNodeToSpec set
392
+ // `loopRound`, and the iteration resumes the loop's stable session — transcript-as-carry).
393
+ // DW-3 one vocabulary: the kernel-adjudicated `pace` verb IS the continuation signal
394
+ // (stop → loopContinue=false); the legacy text-sniffed JSON blob survives only as the fallback
395
+ // when no pace decision arrives (stub orchestrators, harness children), where no signal still
396
+ // means "run to max_iters" (v1).
366
397
  if (node.loop_max_iters != null) {
367
- const result = await orchestrator.run(mkCtx(`${baseSpec.goal}\n\n${loopInstruction(node.loop_max_iters)}`));
398
+ const iteration = Number(/-i(\d+)$/.exec(node.agent_id)?.[1] ?? "0");
399
+ const result = await orchestrator.run(mkCtx(`${baseSpec.goal}\n\n${loopInstruction(node.loop_max_iters, iteration)}`));
400
+ const pace = result.result.paceDecision;
401
+ if (pace)
402
+ return withSignal(result, { loopContinue: pace.action !== "stop" });
368
403
  const cont = extractLoopContinue(textOf(result));
369
404
  return cont === undefined ? result : withSignal(result, { loopContinue: cont });
370
405
  }
@@ -450,7 +485,7 @@ export class RuntimeRunner {
450
485
  if (this.opts.runGroup) {
451
486
  const g = this.opts.runGroup;
452
487
  groupLedger = await g.budgetStore.read(g.id);
453
- await g.budgetStore.join(g.id, { sessionId, role: this.opts.agentId });
488
+ await g.budgetStore.join(g.id, { sessionId, role: this.opts.agentId, kind: "vehicle" });
454
489
  }
455
490
  this.bootstrapWorkflowKernel(sessionId, spec, groupLedger?.tokensSpent, groupLedger?.subagentsSpawned);
456
491
  }
@@ -463,10 +498,22 @@ export class RuntimeRunner {
463
498
  parent_session_id: parentSessionId,
464
499
  // W0-ABI resume: skip nodes already completed before an interruption.
465
500
  ...(opts?.resumedCompleted?.length ? { resumed_completed: opts.resumedCompleted } : {}),
501
+ // W-1: signal-carrying completion records (classify branch / loop stop replay).
502
+ ...(opts?.resumedResults?.length
503
+ ? {
504
+ resumed_results: opts.resumedResults.map(r => ({
505
+ agent_id: r.agentId,
506
+ ...(r.classifyBranch !== undefined ? { classify_branch: r.classifyBranch } : {}),
507
+ ...(r.tournamentWinner !== undefined ? { tournament_winner: r.tournamentWinner } : {}),
508
+ ...(r.loopContinue !== undefined ? { loop_continue: r.loopContinue } : {}),
509
+ })),
510
+ }
511
+ : {}),
466
512
  // R3-1: re-apply recorded runtime submissions so dynamically-appended nodes are reconstructed.
467
513
  ...(opts?.resumedSubmissions?.length ? { resumed_submissions: opts.resumedSubmissions } : {}),
514
+ ...(opts?.resumedSubmissionBases?.length ? { resumed_submission_bases: opts.resumedSubmissionBases } : {}),
468
515
  });
469
- return await this.driveWorkflow(observations, parentSessionId, runtime);
516
+ return await this.driveWorkflow(observations, parentSessionId, runtime, opts?.resumedOutputs);
470
517
  }
471
518
  finally {
472
519
  if (bootstrapped) {
@@ -534,6 +581,18 @@ export class RuntimeRunner {
534
581
  const parentSessionId = this.currentSessionId;
535
582
  const runtime = this.activeKernel;
536
583
  const observations = kernelApply(runtime, this.pendingObservations, submitWorkflowToKernel(spec, parentSessionId, opts?.submitterAgentId));
584
+ // W-3: persist the agent-authored batch (bootstrap base 0 / flatten base N — the kernel now
585
+ // announces BOTH) so an interrupted authored workflow reconstructs on resume; the host never
586
+ // had this spec, unlike the `runWorkflow` path.
587
+ const submitted = observations.find(o => o.kind === "workflow_nodes_submitted");
588
+ if (submitted) {
589
+ await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
590
+ turn: runtime.turn(),
591
+ nodes: workflowSpecToKernel(spec).nodes ?? [],
592
+ baseIndex: submitted.base,
593
+ submitterAgentId: opts?.submitterAgentId,
594
+ }));
595
+ }
537
596
  return this.driveWorkflow(observations, parentSessionId, runtime);
538
597
  }
539
598
  /**
@@ -597,7 +656,7 @@ export class RuntimeRunner {
597
656
  * batch in parallel, feed completions back (appending any agent-submitted nodes first), and loop
598
657
  * until the kernel reports the workflow complete. Returns the completed / failed node agent-ids.
599
658
  */
600
- async driveWorkflow(initial, parentSessionId, runtime) {
659
+ async driveWorkflow(initial, parentSessionId, runtime, seedOutputs) {
601
660
  let observations = initial;
602
661
  const orchestrator = this.opts.subAgentOrchestrator ?? defaultSubAgentOrchestrator;
603
662
  const collectNodes = (obs) => obs.find(o => o.kind === "workflow_batch_spawned")
@@ -614,7 +673,9 @@ export class RuntimeRunner {
614
673
  // G2: each completed node's output, keyed by agent id — a reduce node reads its dependencies'
615
674
  // outputs from here. Deps always complete in an earlier round than the reduce node that needs
616
675
  // them (the kernel keeps the reduce node un-ready until its deps finish), so this is populated.
617
- const outputs = new Map();
676
+ // W-1: on resume it is pre-seeded from the persisted node outputs, so post-resume dependents
677
+ // still see their (pre-crash) dependencies' outputs.
678
+ const outputs = new Map(seedOutputs ?? []);
618
679
  for (;;) {
619
680
  if (nodes.length === 0)
620
681
  return { completed: [], failed: [], outputs: Object.fromEntries(outputs) }; // nothing to run (e.g. all gated)
@@ -643,7 +704,13 @@ export class RuntimeRunner {
643
704
  for (const result of results) {
644
705
  // G2: record this node's output so a downstream reduce node can consume it.
645
706
  const outContent = result.result.finalMessage?.content;
646
- outputs.set(result.agentId, typeof outContent === "string" ? outContent : outContent != null ? JSON.stringify(outContent) : "");
707
+ const outText = typeof outContent === "string" ? outContent : outContent != null ? JSON.stringify(outContent) : "";
708
+ outputs.set(result.agentId, outText);
709
+ // A loop iteration completes under `wf-node{N}-i{k}` but its dependents consume the STABLE
710
+ // node id `wf-node{N}` — alias it so the LAST iteration's output is what dependents see.
711
+ const stableId = result.agentId.replace(/-i\d+$/, "");
712
+ if (stableId !== result.agentId)
713
+ outputs.set(stableId, outText);
647
714
  // R3-1: if this node's agent submitted more nodes, append them to the parent DAG BEFORE
648
715
  // reporting the node's completion — the workflow is still active (the kernel hasn't seen this
649
716
  // node finish), so even a submission from the last running node keeps the DAG alive. The
@@ -655,10 +722,15 @@ export class RuntimeRunner {
655
722
  const subObs = kernelApply(runtime, this.pendingObservations, submitEvent);
656
723
  nextNodes.push(...collectNodes(subObs));
657
724
  budget = collectBudget(subObs) ?? budget;
658
- // R3-1: persist the submission (kernel-shape nodes) so resume can re-apply it.
725
+ // R3-1: persist the submission (kernel-shape nodes) + its kernel-reported base index
726
+ // so resume can re-apply the batch at the exact original graph position. W-N3: also the
727
+ // submitter, so resume drops batches whose submitter re-runs (it will re-submit).
728
+ const submitted = subObs.find(o => o.kind === "workflow_nodes_submitted");
659
729
  await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
660
730
  turn: runtime.turn(),
661
731
  nodes: submitEvent.nodes ?? [],
732
+ baseIndex: submitted?.base,
733
+ submitterAgentId: result.agentId,
662
734
  }));
663
735
  }
664
736
  const obs = kernelApply(runtime, this.pendingObservations, {
@@ -670,11 +742,17 @@ export class RuntimeRunner {
670
742
  const d = findDone(obs);
671
743
  if (d)
672
744
  done = d;
673
- // Persist node completion for resume recovery.
745
+ // Persist node completion for resume recovery. W-1: the result-borne control signals ride
746
+ // along (a resumed classifier re-prunes; a recorded loop stop is honored) plus the output
747
+ // text (post-resume dependents/reduce still see this node's output).
674
748
  await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodeCompletedEvent({
675
749
  turn: runtime.turn(),
676
750
  agentId: result.agentId,
677
751
  termination: result.result.termination,
752
+ classifyBranch: result.result.classifyBranch,
753
+ tournamentWinner: result.result.tournamentWinner,
754
+ loopContinue: result.result.loopContinue,
755
+ ...(result.result.termination === "completed" && outText ? { output: outText } : {}),
678
756
  }));
679
757
  }
680
758
  if (done && nextNodes.length === 0) {
@@ -685,8 +763,9 @@ export class RuntimeRunner {
685
763
  }
686
764
  /**
687
765
  * Resume a workflow from the parent session's completed nodes.
688
- * Reads the session log, extracts completed workflow node agent_ids, and
689
- * calls runWorkflow with resumedCompleted so the kernel skips those nodes.
766
+ * Reads the session log, extracts completed workflow node records (with their W-1 control
767
+ * signals + outputs), and calls runWorkflow so the kernel skips those nodes, replays control
768
+ * flow (classify prune / loop stop), and the driver re-seeds its outputs map.
690
769
  */
691
770
  async resumeWorkflow(spec, opts) {
692
771
  // Standalone resume: a stateless handler passes the prior `sessionId` to pick up an interrupted
@@ -696,9 +775,34 @@ export class RuntimeRunner {
696
775
  throw new Error("resumeWorkflow requires an active parent run or an explicit sessionId");
697
776
  }
698
777
  const events = await this.opts.sessionLog.read(sessionId);
699
- const resumedCompleted = recoverCompletedWorkflowNodes(events);
700
- const resumedSubmissions = recoverSubmittedWorkflowNodes(events);
701
- return this.runWorkflow(spec, { resumedCompleted, resumedSubmissions, sessionId });
778
+ const resumedResults = recoverCompletedWorkflowNodes(events);
779
+ const completedIds = new Set(resumedResults.map(r => r.agentId));
780
+ const recovered = recoverSubmittedWorkflowNodes(events);
781
+ // W-N3: DROP batches whose submitter did NOT complete — that node re-runs on resume and will
782
+ // re-submit its batch; replaying the logged copy too would duplicate its nodes in the DAG.
783
+ // Only safe with exact bases (the dropped batch's slots become inert placeholders); a legacy
784
+ // order-only log keeps every batch, since dropping would shift all later indices.
785
+ let { submissions, bases } = recovered;
786
+ if (bases.length === submissions.length && submissions.length > 0) {
787
+ const keep = recovered.submitters.map(s => s === undefined || completedIds.has(s));
788
+ submissions = submissions.filter((_, i) => keep[i]);
789
+ bases = bases.filter((_, i) => keep[i]);
790
+ }
791
+ const resumedOutputs = new Map(resumedResults.filter(r => r.output).map(r => [r.agentId, r.output]));
792
+ // Alias loop iterations onto their stable node id (last iteration wins) — dependents consume
793
+ // `wf-node{N}`, not `wf-node{N}-i{k}`.
794
+ for (const r of resumedResults) {
795
+ const stableId = r.agentId.replace(/-i\d+$/, "");
796
+ if (stableId !== r.agentId && r.output)
797
+ resumedOutputs.set(stableId, r.output);
798
+ }
799
+ return this.runWorkflow(spec, {
800
+ resumedResults,
801
+ resumedSubmissions: submissions,
802
+ resumedSubmissionBases: bases,
803
+ resumedOutputs,
804
+ sessionId,
805
+ });
702
806
  }
703
807
  interrupt() { this.interrupted = true; this.abortController?.abort(); }
704
808
  /** Push a contextual note into the run's signal stream (the system-reminder channel): it drains at
@@ -715,6 +819,12 @@ export class RuntimeRunner {
715
819
  payload: { goal: text },
716
820
  });
717
821
  }
822
+ /** The most recent kernel session-entropy sample (one per completed turn), or `null` before the
823
+ * first boundary. A pull companion to the streamed `entropy_sample` events — hosts polling from
824
+ * outside the stream (e.g. a heartbeat supervisor) read the latest measurement here. */
825
+ latestEntropy() {
826
+ return this.lastEntropySample;
827
+ }
718
828
  /** Injected-note drain shared by the main loop's per-turn poll: injected notes first (FIFO), then
719
829
  * the configured `signalSource`. Keeps the two inbound channels on one code path so they never drift. */
720
830
  async nextInboundSignal() {
@@ -1148,9 +1258,9 @@ export class RuntimeRunner {
1148
1258
  if (this.opts.runGroup) {
1149
1259
  const g = this.opts.runGroup;
1150
1260
  groupLedger = await g.budgetStore.read(g.id);
1151
- await g.budgetStore.join(g.id, { sessionId, role: this.opts.agentId });
1261
+ await g.budgetStore.join(g.id, { sessionId, role: this.opts.agentId, kind: "vehicle" });
1152
1262
  }
1153
- this.applyKernelPolicies(runtime, groupLedger?.tokensSpent, groupLedger?.subagentsSpawned);
1263
+ this.applyKernelPolicies(runtime, groupLedger?.tokensSpent, groupLedger?.subagentsSpawned, groupLedger?.roundsCompleted);
1154
1264
  // Multimodal upload: seed the user's attachments (images/audio) as a history
1155
1265
  // message before start_run pushes the "[TASK STATE]" anchor. init_task does not
1156
1266
  // clear history, so order becomes [attachment user msg, "Proceed…"] — both land
@@ -1621,10 +1731,27 @@ export class RuntimeRunner {
1621
1731
  }
1622
1732
  catch { /* malformed skill args — skip activation */ }
1623
1733
  }
1734
+ const entropyObsStart = this.pendingObservations.length;
1624
1735
  action = kernelAction(runtime, this.pendingObservations, {
1625
1736
  kind: "tool_results",
1626
1737
  results: toolResults.map(toolResultToKernel),
1627
1738
  });
1739
+ // Surface the boundary's entropy measurement live (the heartbeat watch source) —
1740
+ // the session-log record lands via the normal appendObservations path.
1741
+ for (const obs of this.pendingObservations.slice(entropyObsStart)) {
1742
+ if (obs.kind === "entropy_sample") {
1743
+ this.lastEntropySample = entropySampleFromObservation(obs);
1744
+ yield { type: "entropy_sample", sample: this.lastEntropySample };
1745
+ }
1746
+ else if (obs.kind === "entropy_alert") {
1747
+ yield {
1748
+ type: "entropy_alert",
1749
+ turn: obs.turn ?? 0,
1750
+ score: obs.score ?? 0,
1751
+ threshold: obs.threshold ?? 0,
1752
+ };
1753
+ }
1754
+ }
1628
1755
  }
1629
1756
  else if (action.kind === "evaluate_milestone") {
1630
1757
  const milestonePolicy = this.opts.milestonePolicy ?? "require_verifier";
@@ -1738,7 +1865,14 @@ export class RuntimeRunner {
1738
1865
  catch { /* non-fatal */ }
1739
1866
  }
1740
1867
  }
1741
- yield { type: "done", iterations: turnsUsed, totalTokens, status };
1868
+ yield {
1869
+ type: "done",
1870
+ iterations: turnsUsed,
1871
+ totalTokens,
1872
+ status,
1873
+ // ③ loop-agent: surface the kernel-adjudicated after-round decision to the driver.
1874
+ ...(result?.paceDecision ? { paceDecision: result.paceDecision } : {}),
1875
+ };
1742
1876
  this.activeKernel = null;
1743
1877
  this.currentSessionId = null;
1744
1878
  this.dashboard = null;
@@ -1749,10 +1883,14 @@ export class RuntimeRunner {
1749
1883
  * after each sprint renewal (`phase: "renewal"`): renewal drops the old history INCLUDING the
1750
1884
  * earlier memory hits, so the new sprint gets a fresh recall pass. Errs-open throughout. */
1751
1885
  async prefetchMemoryIntoHistory(runtime, phase) {
1752
- if (!this.opts.preQueryMemory || !this.opts.dreamStore || !this.opts.agentId)
1886
+ if (!this.opts.dreamStore || !this.opts.agentId)
1753
1887
  return;
1888
+ // P10: recall is default-on (CC session-start recall) — with no hook configured,
1889
+ // the goal itself is the query. preQueryMemory stays as the targeting override.
1890
+ const preQuery = this.opts.preQueryMemory
1891
+ ?? ((ctx) => [ctx.goal]);
1754
1892
  try {
1755
- const queries = await this.opts.preQueryMemory({
1893
+ const queries = await preQuery({
1756
1894
  goal: this.currentGoal,
1757
1895
  runSpec: this.opts.runSpec,
1758
1896
  phase,
@@ -1829,14 +1967,24 @@ export class RuntimeRunner {
1829
1967
  nextArchiveStart = compressedSeq + 1;
1830
1968
  const archived = obs.kind === "compressed" ? obs.archived : undefined;
1831
1969
  if (this.opts.asyncSummarizer && archived && archived.length > 0) {
1832
- void this.upgradeCompressedSummary(sessionId, compressedSeq, archived, compressionAction(obs.action) ?? "auto_compact");
1970
+ void this.upgradeCompressedSummary(sessionId, compressedSeq, archived, compressionAction(obs.action) ?? "auto_compact", runtime);
1971
+ }
1972
+ // One compaction = one kernel observation: the page_out session record (and the
1973
+ // semantic-archive branch) is DERIVED here from Compressed.tier_hint, preserving the
1974
+ // session-log format and OsSnapshot page_out_count.
1975
+ if (obs.tier_hint && Array.isArray(archived) && archived.length > 0) {
1976
+ await this.opts.sessionLog.append(sessionId, {
1977
+ kind: "page_out",
1978
+ turn: obs.turn ?? turn,
1979
+ action: compressionAction(obs.action),
1980
+ summary: obs.summary,
1981
+ tier_hint: obs.tier_hint ?? "durable",
1982
+ message_count: archived.length,
1983
+ });
1984
+ if (obs.tier_hint === "semantic") {
1985
+ void this.archiveSemanticPageOut(archived, compressionAction(obs.action));
1986
+ }
1833
1987
  }
1834
- }
1835
- if (obs.kind === "page_out"
1836
- && obs.tier_hint === "semantic"
1837
- && Array.isArray(obs.archived)
1838
- && obs.archived.length > 0) {
1839
- void this.archiveSemanticPageOut(obs.archived, compressionAction(obs.action));
1840
1988
  }
1841
1989
  // K4: a sprint renewal dropped the old history — including any earlier memory hits — so
1842
1990
  // re-run the preQueryMemory prefetch for the new sprint (live observations only: this
@@ -1854,23 +2002,25 @@ export class RuntimeRunner {
1854
2002
  const summary = this.opts.dreamSummarizer
1855
2003
  ? await this.opts.dreamSummarizer.summarize(archived, { action })
1856
2004
  : await summarizeForLongTermMemory(this.opts.dreamProvider ?? this.opts.provider, archived, this.opts.dreamSystemPrompt);
1857
- const existing = await this.opts.dreamStore.loadMemories(this.opts.agentId);
1858
- await this.opts.dreamStore.commit(this.opts.agentId, {
1859
- toAdd: [{ text: summary, score: 1.0, metadata: { source: "semantic_page_out", action } }],
1860
- toRemoveIndices: [],
1861
- stats: {
1862
- insightsProcessed: 1,
1863
- duplicatesRemoved: 0,
1864
- conflictsResolved: 0,
1865
- entriesAdded: 1,
2005
+ // P2 write-funnel: route through the ONE gated WriteMemory syscall so validation,
2006
+ // the rolling write quota, dedup, and the memory_written audit all apply. Score is
2007
+ // advisory (0.6) an automatic summary must never outrank curated content.
2008
+ await this.writeMemory({
2009
+ content: summary,
2010
+ metadata: {
2011
+ name: `page-out-${Date.now()}`,
2012
+ description: `auto summary of ${action ?? "compaction"} archive`,
2013
+ source: "semantic_page_out",
2014
+ action,
2015
+ score: 0.6,
1866
2016
  },
1867
- }, existing);
2017
+ });
1868
2018
  }
1869
2019
  catch {
1870
2020
  // non-fatal: in-context compression summary remains; long-term layer is best-effort
1871
2021
  }
1872
2022
  }
1873
- async upgradeCompressedSummary(sessionId, compressedSeq, archived, action) {
2023
+ async upgradeCompressedSummary(sessionId, compressedSeq, archived, action, runtime) {
1874
2024
  try {
1875
2025
  const summary = await this.opts.asyncSummarizer.summarize(archived, action);
1876
2026
  await this.opts.sessionLog.append(sessionId, {
@@ -1878,6 +2028,20 @@ export class RuntimeRunner {
1878
2028
  compressed_seq: compressedSeq,
1879
2029
  summary,
1880
2030
  });
2031
+ // P4: the LLM summary also re-enters the LIVE session as a keyed page-in entry —
2032
+ // K1 boundary-deferred upsert lands it with zero mid-generation cache churn and the
2033
+ // K2 budget governs its size. The RuleSummarizer text remains the synchronous label.
2034
+ if (runtime) {
2035
+ kernelApply(runtime, this.pendingObservations, {
2036
+ kind: "page_in",
2037
+ entries: [{
2038
+ content: `[ARCHIVE SUMMARY] ${summary}`,
2039
+ key: `summary:seq-${compressedSeq}`,
2040
+ pinned: false,
2041
+ source: "async_summarizer",
2042
+ }],
2043
+ });
2044
+ }
1881
2045
  }
1882
2046
  catch {
1883
2047
  // non-fatal: rule-based summary stays in place
@@ -1885,7 +2049,20 @@ export class RuntimeRunner {
1885
2049
  }
1886
2050
  }
1887
2051
  function isMidRun(events) {
1888
- return events.length > 0 && !events.some(e => e.event.kind === "run_terminal");
2052
+ // Mid-run the LAST run_started has no run_terminal after it. Pairing (not mere
2053
+ // presence) matters on multi-round loop sessions: round 1's terminal must not make a
2054
+ // crashed round 2 look fresh, and driver-level round_* records must not make a fresh
2055
+ // round look interrupted.
2056
+ let lastStarted = -1;
2057
+ let lastTerminal = -1;
2058
+ for (let i = 0; i < events.length; i++) {
2059
+ const k = events[i].event.kind;
2060
+ if (k === "run_started")
2061
+ lastStarted = i;
2062
+ else if (k === "run_terminal")
2063
+ lastTerminal = i;
2064
+ }
2065
+ return lastStarted >= 0 && lastStarted > lastTerminal;
1889
2066
  }
1890
2067
  /**
1891
2068
  * Build a kernel `add_history_message` payload from user attachments: a `user`
@@ -1940,6 +2117,50 @@ async function summarizeForLongTermMemory(provider, archived, systemPrompt) {
1940
2117
  }
1941
2118
  return text.trim() || transcript.slice(0, 2000);
1942
2119
  }
2120
+ /** Kernel-consumed meta-tools (e.g. `pace`) are answered by a synthetic tool result the kernel keeps
2121
+ * in its OWN history but never emits as a `tool_completed` session event (they never reach the
2122
+ * execution plane). On replay that leaves an assistant `tool_call` with no following tool result —
2123
+ * which strict OpenAI-compatible providers reject ("every tool_call must be answered by a tool
2124
+ * message"). This pass re-pairs any such orphan by inserting a synthetic tool-result message right
2125
+ * after its assistant message, reproducing the pair the kernel had all along.
2126
+ *
2127
+ * Discriminator: only pair an orphan when the run **continued past it** — i.e. a later non-tool
2128
+ * message exists. A tail assistant tool_call with nothing after it is a genuinely PENDING tool the
2129
+ * run stopped in front of (the wake/recovery case), which must stay unpaired so wake executes it.
2130
+ * Pure. */
2131
+ export function pairOrphanToolCalls(messages) {
2132
+ const out = [];
2133
+ for (let i = 0; i < messages.length; i++) {
2134
+ const m = messages[i];
2135
+ out.push(m);
2136
+ if (m.role !== "assistant" || !m.toolCalls?.length)
2137
+ continue;
2138
+ // Collect ids answered by the immediately-following run of tool messages; note where it ends.
2139
+ const answered = new Set();
2140
+ let j = i + 1;
2141
+ for (; j < messages.length && messages[j].role === "tool"; j++) {
2142
+ for (const p of messages[j].contentParts ?? []) {
2143
+ if (p.type === "tool_result")
2144
+ answered.add(p.callId);
2145
+ }
2146
+ }
2147
+ // If nothing follows the tool run, this tool_call is a pending tail (wake case) — leave it.
2148
+ if (j >= messages.length)
2149
+ continue;
2150
+ for (const c of m.toolCalls) {
2151
+ if (answered.has(c.id))
2152
+ continue;
2153
+ out.push({
2154
+ role: "tool",
2155
+ content: "",
2156
+ toolCalls: [],
2157
+ contentParts: [{ type: "tool_result", callId: c.id, output: `[${c.name} handled by kernel]`, isError: false }],
2158
+ tokenCount: 1,
2159
+ });
2160
+ }
2161
+ }
2162
+ return out;
2163
+ }
1943
2164
  export function replayMessages(events, maxBytes) {
1944
2165
  // Build upgraded-summary index: compressed_seq -> upgraded summary
1945
2166
  const upgradedSummaries = new Map();
@@ -1998,7 +2219,7 @@ export function replayMessages(events, maxBytes) {
1998
2219
  }
1999
2220
  }
2000
2221
  }
2001
- return messages;
2222
+ return pairOrphanToolCalls(messages);
2002
2223
  }
2003
2224
  export async function replayMessagesAsync(events, maxBytes, loadArchive) {
2004
2225
  // Build upgraded-summary index: compressed_seq -> upgraded summary
@@ -2078,7 +2299,7 @@ export async function replayMessagesAsync(events, maxBytes, loadArchive) {
2078
2299
  }
2079
2300
  }
2080
2301
  }
2081
- return messages;
2302
+ return pairOrphanToolCalls(messages);
2082
2303
  }
2083
2304
  function nextArchivedSeqStart(events) {
2084
2305
  let next = 0;
@@ -2181,6 +2402,19 @@ function authoredWorkflowOutcomeNote(outcome) {
2181
2402
  }
2182
2403
  /** Lower a host `RuntimeSignal` to the kernel's snake_case `signal` input event. Shared by the main
2183
2404
  * loop's per-turn poll and #2-B-ii's workflow-batch preemption monitor (so the two never drift). */
2405
+ /** Word-set jaccard similarity — the curator's dedup rule as a pure helper at the write funnel. */
2406
+ function jaccardSimilarity(a, b) {
2407
+ const sa = new Set(a.split(/\s+/).filter(Boolean));
2408
+ const sb = new Set(b.split(/\s+/).filter(Boolean));
2409
+ if (sa.size === 0 && sb.size === 0)
2410
+ return 1;
2411
+ let inter = 0;
2412
+ for (const w of sa)
2413
+ if (sb.has(w))
2414
+ inter++;
2415
+ const union = sa.size + sb.size - inter;
2416
+ return union === 0 ? 0 : inter / union;
2417
+ }
2184
2418
  function signalToKernelEvent(sig) {
2185
2419
  return {
2186
2420
  kind: "signal",