@deepstrike/sdk 0.2.36 → 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,7 +10,7 @@ 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
15
  import { kernelObservationToSessionEvent } from "./kernel-event-log.js";
16
16
  import { assertNativeProfile } from "./os-profile.js";
@@ -72,23 +72,30 @@ export class RuntimeRunner {
72
72
  return;
73
73
  }
74
74
  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);
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
+ }
92
99
  await this.appendMemorySyscallObservations(sessionId, observations);
93
100
  }
94
101
  async queryMemory(query, opts = {}) {
@@ -116,24 +123,19 @@ export class RuntimeRunner {
116
123
  }
117
124
  }
118
125
  await this.appendMemorySyscallObservations(sessionId, observations);
119
- await this.logMemoryRetrievalResult(sessionId, runtime, retrieval);
126
+ await this.logMemoryRetrievalResult(sessionId, retrieval);
120
127
  return hits;
121
128
  }
122
- async logMemoryRetrievalResult(sessionId, runtime, retrieval) {
129
+ async logMemoryRetrievalResult(sessionId, retrieval) {
123
130
  if (!sessionId)
124
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).
125
134
  await this.opts.sessionLog.append(sessionId, {
126
135
  kind: "memory_retrieval_result",
127
136
  selected_memory_ids: retrieval.selected_memory_ids,
128
137
  selection_rationale: retrieval.selection_rationale,
129
138
  });
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
139
  }
138
140
  createSyscallRuntime() {
139
141
  const { KernelRuntime } = getKernel();
@@ -152,7 +154,7 @@ export class RuntimeRunner {
152
154
  * exactly as a mid-run spawn would be. Must run BEFORE `start_run` so the in-kernel gate enforces
153
155
  * every policy from the first spawn. No config ⇒ the native-profile defaults (铁律: defaults only).
154
156
  */
155
- applyKernelPolicies(runtime, groupTokensBase, groupSpawnsBase) {
157
+ applyKernelPolicies(runtime, groupTokensBase, groupSpawnsBase, groupRoundsBase) {
156
158
  // K2: lower governance / attention / scheduler / quota in ONE `configure_run` event instead of
157
159
  // the previous 2–4 separate `set_*` / `load_governance_policy` events. The kernel applies each
158
160
  // present field via the same path its granular event uses; absent fields are left untouched.
@@ -187,6 +189,10 @@ export class RuntimeRunner {
187
189
  if (groupSpawnsBase !== undefined && groupSpawnsBase > 0) {
188
190
  config.group_spawns_base = groupSpawnsBase;
189
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
+ }
190
196
  // O6: tune/disable the in-kernel repeat fuse. `false` disables; an object overrides thresholds.
191
197
  // Absent ⇒ kernel defaults (enabled, deny_after=5, terminate_after=8).
192
198
  if (this.opts.repeatFuse !== undefined) {
@@ -330,7 +336,10 @@ export class RuntimeRunner {
330
336
  // G4: surface the workflow's remaining budget to the node's agent so a coordinator can size its
331
337
  // `submit_workflow_nodes` batch to what is available (empty string ⇒ unbounded, no note).
332
338
  const budgetNote = workflowBudgetNote(budget);
333
- 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");
334
343
  const mkCtx = (goal) => ({
335
344
  parentOpts: this.opts,
336
345
  parentSessionId,
@@ -340,6 +349,10 @@ export class RuntimeRunner {
340
349
  // M5 v2.1: this child IS a workflow node — its `start_workflow` flattens to this kernel (the
341
350
  // workflow it would author joins the running DAG) rather than bootstrapping a nested pivot.
342
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"),
343
356
  // #2-B-ii: the per-node abort signal the driver fires when the kernel preempts this node.
344
357
  ...(abortSignal ? { abortSignal } : {}),
345
358
  ...(this.opts.subAgentHarness ? { harness: this.opts.subAgentHarness } : {}),
@@ -361,10 +374,18 @@ export class RuntimeRunner {
361
374
  const winnerId = winner === "right" ? node.judge_match.right : node.judge_match.left;
362
375
  return withSignal(result, { tournamentWinner: winnerId });
363
376
  }
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`.
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).
366
383
  if (node.loop_max_iters != null) {
367
- 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" });
368
389
  const cont = extractLoopContinue(textOf(result));
369
390
  return cont === undefined ? result : withSignal(result, { loopContinue: cont });
370
391
  }
@@ -450,7 +471,7 @@ export class RuntimeRunner {
450
471
  if (this.opts.runGroup) {
451
472
  const g = this.opts.runGroup;
452
473
  groupLedger = await g.budgetStore.read(g.id);
453
- 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" });
454
475
  }
455
476
  this.bootstrapWorkflowKernel(sessionId, spec, groupLedger?.tokensSpent, groupLedger?.subagentsSpawned);
456
477
  }
@@ -463,10 +484,22 @@ export class RuntimeRunner {
463
484
  parent_session_id: parentSessionId,
464
485
  // W0-ABI resume: skip nodes already completed before an interruption.
465
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
+ : {}),
466
498
  // R3-1: re-apply recorded runtime submissions so dynamically-appended nodes are reconstructed.
467
499
  ...(opts?.resumedSubmissions?.length ? { resumed_submissions: opts.resumedSubmissions } : {}),
500
+ ...(opts?.resumedSubmissionBases?.length ? { resumed_submission_bases: opts.resumedSubmissionBases } : {}),
468
501
  });
469
- return await this.driveWorkflow(observations, parentSessionId, runtime);
502
+ return await this.driveWorkflow(observations, parentSessionId, runtime, opts?.resumedOutputs);
470
503
  }
471
504
  finally {
472
505
  if (bootstrapped) {
@@ -534,6 +567,18 @@ export class RuntimeRunner {
534
567
  const parentSessionId = this.currentSessionId;
535
568
  const runtime = this.activeKernel;
536
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
+ }
537
582
  return this.driveWorkflow(observations, parentSessionId, runtime);
538
583
  }
539
584
  /**
@@ -597,7 +642,7 @@ export class RuntimeRunner {
597
642
  * batch in parallel, feed completions back (appending any agent-submitted nodes first), and loop
598
643
  * until the kernel reports the workflow complete. Returns the completed / failed node agent-ids.
599
644
  */
600
- async driveWorkflow(initial, parentSessionId, runtime) {
645
+ async driveWorkflow(initial, parentSessionId, runtime, seedOutputs) {
601
646
  let observations = initial;
602
647
  const orchestrator = this.opts.subAgentOrchestrator ?? defaultSubAgentOrchestrator;
603
648
  const collectNodes = (obs) => obs.find(o => o.kind === "workflow_batch_spawned")
@@ -614,7 +659,9 @@ export class RuntimeRunner {
614
659
  // G2: each completed node's output, keyed by agent id — a reduce node reads its dependencies'
615
660
  // outputs from here. Deps always complete in an earlier round than the reduce node that needs
616
661
  // them (the kernel keeps the reduce node un-ready until its deps finish), so this is populated.
617
- 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 ?? []);
618
665
  for (;;) {
619
666
  if (nodes.length === 0)
620
667
  return { completed: [], failed: [], outputs: Object.fromEntries(outputs) }; // nothing to run (e.g. all gated)
@@ -643,7 +690,13 @@ export class RuntimeRunner {
643
690
  for (const result of results) {
644
691
  // G2: record this node's output so a downstream reduce node can consume it.
645
692
  const outContent = result.result.finalMessage?.content;
646
- 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);
647
700
  // R3-1: if this node's agent submitted more nodes, append them to the parent DAG BEFORE
648
701
  // reporting the node's completion — the workflow is still active (the kernel hasn't seen this
649
702
  // node finish), so even a submission from the last running node keeps the DAG alive. The
@@ -655,10 +708,15 @@ export class RuntimeRunner {
655
708
  const subObs = kernelApply(runtime, this.pendingObservations, submitEvent);
656
709
  nextNodes.push(...collectNodes(subObs));
657
710
  budget = collectBudget(subObs) ?? budget;
658
- // 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");
659
715
  await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
660
716
  turn: runtime.turn(),
661
717
  nodes: submitEvent.nodes ?? [],
718
+ baseIndex: submitted?.base,
719
+ submitterAgentId: result.agentId,
662
720
  }));
663
721
  }
664
722
  const obs = kernelApply(runtime, this.pendingObservations, {
@@ -670,11 +728,17 @@ export class RuntimeRunner {
670
728
  const d = findDone(obs);
671
729
  if (d)
672
730
  done = d;
673
- // 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).
674
734
  await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodeCompletedEvent({
675
735
  turn: runtime.turn(),
676
736
  agentId: result.agentId,
677
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 } : {}),
678
742
  }));
679
743
  }
680
744
  if (done && nextNodes.length === 0) {
@@ -685,8 +749,9 @@ export class RuntimeRunner {
685
749
  }
686
750
  /**
687
751
  * 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.
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.
690
755
  */
691
756
  async resumeWorkflow(spec, opts) {
692
757
  // Standalone resume: a stateless handler passes the prior `sessionId` to pick up an interrupted
@@ -696,9 +761,34 @@ export class RuntimeRunner {
696
761
  throw new Error("resumeWorkflow requires an active parent run or an explicit sessionId");
697
762
  }
698
763
  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 });
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
+ });
702
792
  }
703
793
  interrupt() { this.interrupted = true; this.abortController?.abort(); }
704
794
  /** Push a contextual note into the run's signal stream (the system-reminder channel): it drains at
@@ -1148,9 +1238,9 @@ export class RuntimeRunner {
1148
1238
  if (this.opts.runGroup) {
1149
1239
  const g = this.opts.runGroup;
1150
1240
  groupLedger = await g.budgetStore.read(g.id);
1151
- 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" });
1152
1242
  }
1153
- this.applyKernelPolicies(runtime, groupLedger?.tokensSpent, groupLedger?.subagentsSpawned);
1243
+ this.applyKernelPolicies(runtime, groupLedger?.tokensSpent, groupLedger?.subagentsSpawned, groupLedger?.roundsCompleted);
1154
1244
  // Multimodal upload: seed the user's attachments (images/audio) as a history
1155
1245
  // message before start_run pushes the "[TASK STATE]" anchor. init_task does not
1156
1246
  // clear history, so order becomes [attachment user msg, "Proceed…"] — both land
@@ -1738,7 +1828,14 @@ export class RuntimeRunner {
1738
1828
  catch { /* non-fatal */ }
1739
1829
  }
1740
1830
  }
1741
- 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
+ };
1742
1839
  this.activeKernel = null;
1743
1840
  this.currentSessionId = null;
1744
1841
  this.dashboard = null;
@@ -1749,10 +1846,14 @@ export class RuntimeRunner {
1749
1846
  * after each sprint renewal (`phase: "renewal"`): renewal drops the old history INCLUDING the
1750
1847
  * earlier memory hits, so the new sprint gets a fresh recall pass. Errs-open throughout. */
1751
1848
  async prefetchMemoryIntoHistory(runtime, phase) {
1752
- if (!this.opts.preQueryMemory || !this.opts.dreamStore || !this.opts.agentId)
1849
+ if (!this.opts.dreamStore || !this.opts.agentId)
1753
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]);
1754
1855
  try {
1755
- const queries = await this.opts.preQueryMemory({
1856
+ const queries = await preQuery({
1756
1857
  goal: this.currentGoal,
1757
1858
  runSpec: this.opts.runSpec,
1758
1859
  phase,
@@ -1829,14 +1930,24 @@ export class RuntimeRunner {
1829
1930
  nextArchiveStart = compressedSeq + 1;
1830
1931
  const archived = obs.kind === "compressed" ? obs.archived : undefined;
1831
1932
  if (this.opts.asyncSummarizer && archived && archived.length > 0) {
1832
- 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
+ }
1833
1950
  }
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
1951
  }
1841
1952
  // K4: a sprint renewal dropped the old history — including any earlier memory hits — so
1842
1953
  // re-run the preQueryMemory prefetch for the new sprint (live observations only: this
@@ -1854,23 +1965,25 @@ export class RuntimeRunner {
1854
1965
  const summary = this.opts.dreamSummarizer
1855
1966
  ? await this.opts.dreamSummarizer.summarize(archived, { action })
1856
1967
  : 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,
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,
1866
1979
  },
1867
- }, existing);
1980
+ });
1868
1981
  }
1869
1982
  catch {
1870
1983
  // non-fatal: in-context compression summary remains; long-term layer is best-effort
1871
1984
  }
1872
1985
  }
1873
- async upgradeCompressedSummary(sessionId, compressedSeq, archived, action) {
1986
+ async upgradeCompressedSummary(sessionId, compressedSeq, archived, action, runtime) {
1874
1987
  try {
1875
1988
  const summary = await this.opts.asyncSummarizer.summarize(archived, action);
1876
1989
  await this.opts.sessionLog.append(sessionId, {
@@ -1878,6 +1991,20 @@ export class RuntimeRunner {
1878
1991
  compressed_seq: compressedSeq,
1879
1992
  summary,
1880
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
+ }
1881
2008
  }
1882
2009
  catch {
1883
2010
  // non-fatal: rule-based summary stays in place
@@ -1885,7 +2012,20 @@ export class RuntimeRunner {
1885
2012
  }
1886
2013
  }
1887
2014
  function isMidRun(events) {
1888
- 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;
1889
2029
  }
1890
2030
  /**
1891
2031
  * Build a kernel `add_history_message` payload from user attachments: a `user`
@@ -1940,6 +2080,50 @@ async function summarizeForLongTermMemory(provider, archived, systemPrompt) {
1940
2080
  }
1941
2081
  return text.trim() || transcript.slice(0, 2000);
1942
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
+ }
1943
2127
  export function replayMessages(events, maxBytes) {
1944
2128
  // Build upgraded-summary index: compressed_seq -> upgraded summary
1945
2129
  const upgradedSummaries = new Map();
@@ -1998,7 +2182,7 @@ export function replayMessages(events, maxBytes) {
1998
2182
  }
1999
2183
  }
2000
2184
  }
2001
- return messages;
2185
+ return pairOrphanToolCalls(messages);
2002
2186
  }
2003
2187
  export async function replayMessagesAsync(events, maxBytes, loadArchive) {
2004
2188
  // Build upgraded-summary index: compressed_seq -> upgraded summary
@@ -2078,7 +2262,7 @@ export async function replayMessagesAsync(events, maxBytes, loadArchive) {
2078
2262
  }
2079
2263
  }
2080
2264
  }
2081
- return messages;
2265
+ return pairOrphanToolCalls(messages);
2082
2266
  }
2083
2267
  function nextArchivedSeqStart(events) {
2084
2268
  let next = 0;
@@ -2181,6 +2365,19 @@ function authoredWorkflowOutcomeNote(outcome) {
2181
2365
  }
2182
2366
  /** Lower a host `RuntimeSignal` to the kernel's snake_case `signal` input event. Shared by the main
2183
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
+ }
2184
2381
  function signalToKernelEvent(sig) {
2185
2382
  return {
2186
2383
  kind: "signal",