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