bare-agent 0.19.0 → 0.20.0

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/src/recurse.js ADDED
@@ -0,0 +1,886 @@
1
+ 'use strict';
2
+
3
+ // RLM_PRD — the `recurse()` primitive (NB-1 glue + NB-4 spawn A-tool / capability-scrub + NB-5 prompt).
4
+ // One standalone import that COMPOSES the primitives bareagent already ships (Loop, assessComplexity,
5
+ // Evaluator, bareguard via ctx.policy) into one decompose→fan-out→verify→synthesize entry point. It is glue:
6
+ // it imports `loop.js`, never the reverse — the same stance as Evaluator/refine/remember (§4.6).
7
+ //
8
+ // Shape (§0, §4.2): "B-shell with an A-tool." A deterministic shell owns control flow; the model is OFFERED
9
+ // a `spawn_child` tool it MAY use to delegate a sub-task to a fresh context window. Default control is
10
+ // Family A (the model decides whether/how to decompose, bounded by depth + bareguard). assessComplexity is a
11
+ // HINT, not a gate: it only routes `simple → single-shot` and flags `critical → force adversarial verify`.
12
+ //
13
+ // The recursion mechanism is the spike-2 default (§4.5, POC-resolved): an IN-PROCESS self-call — `spawn_child`
14
+ // runs `recurse(subtask, {...ctx, depth: depth+1})` with a fresh Loop / fresh message array (true fresh
15
+ // window) — ~0 ms/node vs ≥90 ms/node for a process fork. Termination is bareguard's (the gate), reached via
16
+ // `ctx.depth` threaded into the `policy` check; `opts.maxDepth` is only the topology knob that stops OFFERING
17
+ // the spawn tool (NB-4 tool-shaping), never the safety halt (§6). Forced fan-out (Family B / NB-2) and the
18
+ // code-reduce default (NB-3) are later build steps; the seams (`opts.count`/`mode`, `opts.synthesize`) are
19
+ // present here so they slot in without a rewrite.
20
+
21
+ /** @typedef {import('../types').Provider} Provider */
22
+ /** @typedef {import('../types').ToolDef} ToolDef */
23
+ /** @typedef {import('./evaluator').Verdict} Verdict */
24
+ /** @typedef {{id: string, text: string}} Slice */
25
+
26
+ const { Loop } = require('./loop');
27
+ const { Evaluator } = require('./evaluator');
28
+ const { Planner } = require('./planner');
29
+ const { runPlan } = require('./run-plan');
30
+ const { assessComplexity, isCritical } = require('./complexity');
31
+ const { HaltError } = require('./errors');
32
+ const { DECOMPOSITION_POLICY, capabilityScrub } = require('./recurse-prompts');
33
+ const { synthesize } = require('./recurse-synthesize');
34
+ const {
35
+ scanCount,
36
+ impliesCompleteness,
37
+ normalizeCorpus,
38
+ buildSearchTool,
39
+ buildExactTool,
40
+ buildScanTool,
41
+ } = require('./recurse-retrieval');
42
+
43
+ // NB-2 forced-fan-out tier→count map (Family B). CALIBRATED live (poc/rlm-nb2-calibrate.mjs, gpt-4o-mini):
44
+ // the measured coverage knees {2,4,6} == predicted ⌈corpus/worker-budget⌉ for medium/complex/critical. These
45
+ // are OVERRIDABLE DEFAULTS, not discovered constants — the right count is task-specific (which is why
46
+ // `opts.count` overrides this and Family A is the adaptive default). `simple` → 1 (a single forced worker).
47
+ const TIER_COUNT = { simple: 1, medium: 2, complex: 4, critical: 6 };
48
+ // Cap on workers run at once within a fan-out wave (in-process; bareguard caps the family rate too). Overridable.
49
+ const DEFAULT_FANOUT_CONCURRENCY = 4;
50
+ // Data-driven width (NB-2 / §11): default items-per-worker for the PARTITION path. width = ⌈size/budget⌉ — how
51
+ // many parallel scan-workers a measured corpus needs. A calibratable knob (the §9.1 algorithm; corpus-specific),
52
+ // not a discovered constant — overridable via `opts.workerBudget`. 100 items ≈ a worker doing ~25 scan windows.
53
+ const DEFAULT_WORKER_BUDGET = 100;
54
+
55
+ /**
56
+ * Split an array into EXACTLY `n` contiguous, near-equal chunks (the data-partition for the §11 width path).
57
+ * Deterministic; every chunk non-empty when `n <= arr.length` (the caller caps `n` at the size).
58
+ * @template T @param {T[]} arr @param {number} n @returns {T[][]}
59
+ */
60
+ function partitionInto(arr, n) {
61
+ /** @type {T[][]} */
62
+ const chunks = [];
63
+ let start = 0;
64
+ for (let i = 0; i < n; i++) {
65
+ const take = Math.ceil((arr.length - start) / (n - i)); // even spread of the remainder
66
+ chunks.push(arr.slice(start, start + take));
67
+ start += take;
68
+ }
69
+ return chunks;
70
+ }
71
+
72
+ /**
73
+ * The opts a delegated child inherits. Strips the parent's TOP-LEVEL SETPOINT — `contract`/`evaluate` grade
74
+ * the WHOLE task's final answer; a child grading its own slice against the whole definition-of-done is wasted
75
+ * (the verdict is never read by the parent) AND misapplied (a slice isn't expected to satisfy the whole DoD).
76
+ * Also strips the forced-fan-out knobs (`count`/`mode`) so a child runs Family A, not another forced wave, and
77
+ * the TOP-LEVEL retrieval knobs (`retrieval`/`corpus`/`window`/`passes`) — those describe how the WHOLE task is
78
+ * answered over the parent's corpus; a child has its own subtask and must not re-scan the parent's full corpus
79
+ * (that would fan a whole-corpus count out under every child). The `critical → force-verify` SAFETY FLOOR is
80
+ * unaffected — it keys on the task text via `isCritical`, not the contract, so a critical child still
81
+ * self-verifies. Handle tools (`opts.tools`), `synthesize`, and `maxDepth` carry down.
82
+ * @param {RecurseOptions} opts
83
+ * @returns {RecurseOptions}
84
+ */
85
+ function forChild(opts) {
86
+ return {
87
+ ...opts,
88
+ count: undefined,
89
+ mode: undefined,
90
+ contract: undefined,
91
+ evaluate: undefined,
92
+ retrieval: undefined,
93
+ corpus: undefined,
94
+ window: undefined,
95
+ passes: undefined,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * @typedef {object} RecurseCtx
101
+ * The per-run runtime blob — the wiring, threaded down the whole recursion tree (and forwarded to the worker
102
+ * Loop's `policy`/governance via `options.ctx`). Distinct from `opts` (the policy knobs).
103
+ * @property {Provider} [provider] - The model the workers call. Required (here or on `opts.provider`).
104
+ * @property {Function} [policy] - bareguard `policy(tool, args, ctx)` — the gate. Sees `ctx.depth` so it can
105
+ * enforce `limits.maxDepth`/budget/calls. recurse adds NO second guard layer (§6).
106
+ * @property {Function} [onLlmResult] - Budget hook forwarded to every worker Loop AND the verifier — judge
107
+ * and worker tokens are all real spend (BA1: never invisible).
108
+ * @property {number} [depth] - The current recursion depth (0 at the top). Incremented on each self-call;
109
+ * threaded into `policy`. Callers normally omit it (defaults to 0).
110
+ * @property {object} [stream] - Optional event stream forwarded to each worker Loop (receipts substrate).
111
+ * @property {{recall: Function}} [litectx] - Optional litectx handle (RC-5, §10 step 7). Backs the `search`
112
+ * retrieval mode (`recall`). NOT used by `scan` — litectx has no exhaustive enumerate verb today; scan reads
113
+ * the generic array slice-source `opts.corpus` instead (the litectx-resident scan case waits on the litectx
114
+ * `enumerate` verb and drops in behind the same socket).
115
+ */
116
+
117
+ /**
118
+ * @typedef {object} RecurseOptions
119
+ * @property {Provider} [provider] - Fallback provider if `ctx.provider` is absent.
120
+ * @property {number} [maxDepth=3] - Open topology ceiling (§1): the depth past which the `spawn_child` tool is
121
+ * no longer offered (`maxDepth=1` ⇒ flat fan-out, no nesting). NOT the safety halt — that is bareguard's,
122
+ * and actual depth is always ≤ `limits.maxDepth`.
123
+ * @property {ToolDef[]} [tools] - Handle tools offered to EVERY worker (RC-5 pull-default: litectx
124
+ * `recall`/`get`, wired at build step 7). Workers query on demand; never the whole corpus.
125
+ * @property {string} [contract] - Definition of done (A3). When present, the verifier grades against THIS,
126
+ * not the loose task, and verification always runs.
127
+ * @property {(result: any, ctx: {contract: string|null, task: string}) => (Verdict|Promise<Verdict>)} [evaluate]
128
+ * Override the verifier (fills `recurse()`'s verify slot, §7.1). Default = an `Evaluator` rubric pass.
129
+ * @property {((args: {task: string, text: string|null, results: any[], children: object[], ctx: RecurseCtx}) => any) | 'concat' | 'merge'} [synthesize]
130
+ * Override synthesis/reduce (NB-3). A FUNCTION is a deterministic code-reduce over the child `results` — the
131
+ * §9.1 aggregation path (LLM arithmetic over partials carried ~10–15% error). A STRATEGY string runs the
132
+ * built-in reducer: `'concat'` (lossless no-LLM join) or `'merge'` (an isolated Loop-driven subjective
133
+ * merge); a string is ignored when no child ran. Default (unset) = the worker's own final text (Family A:
134
+ * the parent model already combined the children's results in its closing turn).
135
+ * @property {number} [count] - (Opt-in, NB-2 / Family B) FORCED fan-out: decompose into exactly this many
136
+ * independent parallel workers via `Planner`→`runPlan`, then reduce. A positive integer here is the count;
137
+ * it OVERRIDES the tier→count map. Setting it (or `mode:'fanout'`) takes the deterministic-parallelism path
138
+ * instead of the model-driven Family-A default. For known-parallel tasks where the caller wants guaranteed
139
+ * fan-out, not the model's adaptive choice.
140
+ * @property {'fanout'|'partition'} [mode] - (Opt-in, NB-2 / Family B) `'fanout'` = forced semantic fan-out
141
+ * WITHOUT a fixed count — derived from `assessComplexity`'s tier via the calibrated map (medium/complex/
142
+ * critical → 2/4/6; simple → 1); `opts.count` takes precedence. `'partition'` = the DATA-DRIVEN WIDTH path
143
+ * (§11): measure `opts.corpus` and partition it into `max(opts.count floor, ⌈size/workerBudget⌉)` parallel
144
+ * scan-workers (capped by the guards), CODE-reducing the per-chunk counts. Distinct from `'fanout'`: a data
145
+ * partition, not a `Planner` semantic split.
146
+ * @property {number} [workerBudget] - (`mode:'partition'`) items per worker; width = `⌈corpus.length /
147
+ * workerBudget⌉` (default 100). A calibratable knob (the §9.1 algorithm), not a discovered constant.
148
+ * @property {number} [concurrency] - (Family B) max workers run at once per wave (default 4). The wave
149
+ * structure is `runPlan`'s; bareguard still bounds the family rate independently.
150
+ * @property {'scan'|'search'|'exact'|'tools'} [retrieval] - (§10 step 7) the retrieval shape for a task OVER A
151
+ * CORPUS, routed by question shape (§9.2.1). `'scan'` (the default WHEN `opts.corpus` is present) = process
152
+ * every slice + LLM-judge + CODE-count — the only COMPLETE path (for "how many / all"). `'search'` = litectx
153
+ * `recall` handle tool offered to the worker (needle; CANNOT count; requires `ctx.litectx`). `'exact'` = a
154
+ * deterministic code-side AND-term filter tool over `opts.corpus`. `'tools'` = the PER-QUERY Family-A face:
155
+ * offer the worker `scan_count` (over `opts.corpus`) + `search_memory` (when `ctx.litectx`) + `exact_match`
156
+ * (array corpus) ALL AT ONCE, and let it pick the shape PER SUB-QUERY — the routing lives in the tool
157
+ * descriptions (scan says "use for how many / all / count"; search says "never count"), so a mixed task gets
158
+ * needle-search AND complete-count without per-sub-query adopter declaration. The completeness guard upgrades a
159
+ * `'search'` on a "how many / all" ask to `'scan'` (UPGRADE-only, never a silent downgrade); it does NOT fire
160
+ * for `'tools'` (the complete `scan_count` is always offered there, so a mixed task keeps its search tool).
161
+ * Absent `corpus` AND `retrieval`, behaviour is unchanged (Family A / single-shot) — fully backward-compatible.
162
+ * @property {Slice[] | (() => Promise<Slice[]>)} [corpus] - (§10 step 7) the generic slice-source scan/partition
163
+ * reads: an in-hand `{id, text}[]` array, OR an async `() => Promise<Slice[]>` (e.g. `litectxCorpus(litectx,
164
+ * {kind})` materializing a litectx-resident corpus via `enumerate`). recurse depends on this SHAPE, never on
165
+ * litectx. Malformed entries are dropped, never miscounted.
166
+ * @property {number} [window] - (scan) items per judge window. Default 8 (§9.2.1 recall knee — the one
167
+ * calibrated number; per-model).
168
+ * @property {number} [passes] - (scan) shuffled-boundary passes unioned for recall. Default 2 (~0.91 recall).
169
+ */
170
+
171
+ /**
172
+ * @typedef {object} RecurseNode
173
+ * One audit/receipts node (RC-10) — the recursion tree reconstructs from these alone: parent→child lineage
174
+ * (`spawned`), each subgoal (`task`), each gap report (`verdict`), cost per node (`tokens`).
175
+ * @property {string} task
176
+ * @property {number} depth
177
+ * @property {{level: string, score: number}} complexity
178
+ * @property {boolean} critical
179
+ * @property {RecurseNode[]} spawned - Child nodes (lineage).
180
+ * @property {Verdict|null} verdict
181
+ * @property {boolean} incomplete
182
+ * @property {boolean} halted
183
+ * @property {object|null} tokens - The worker Loop's `metrics.tokens`.
184
+ * @property {string|null} model
185
+ * @property {string|null} [retrieval] - (§10 step 7) the retrieval mode this node ran (`scan`/`search`/`exact`),
186
+ * or null/absent for a plain reasoning node.
187
+ * @property {string} [retrievalUpgraded] - set when the completeness guard upgraded the mode (e.g.
188
+ * `'search→scan (completeness)'`) — the audit trail for RC-9-applied-to-retrieval.
189
+ * @property {{window: number, passes: number, scanned: number, matched: number}} [scan] - (scan) the scan
190
+ * shape: window/passes used, slices scanned, ids matched (CODE-counted).
191
+ * @property {{size: number, workerBudget: number, floor: number, dataWidth: number, width: number, matched?: number}} [partition]
192
+ * - (`mode:'partition'`) the data-driven width audit: corpus size, the budget knob, the count floor, the
193
+ * data-derived width `⌈size/budget⌉`, the chosen `width = max(floor, dataWidth)`, and matched count.
194
+ */
195
+
196
+ /**
197
+ * @typedef {object} RecurseResult
198
+ * @property {any} [result] - The synthesized answer (on convergence).
199
+ * @property {Verdict|null} [verdict] - The verifier's gap report (null when verification did not run).
200
+ * @property {boolean} [incomplete] - true on guard exhaustion / a dead worker / an incomplete child (RC-9) —
201
+ * never a faked pass.
202
+ * @property {any} [best] - The best partial answer when `incomplete` (RC-9).
203
+ * @property {string[]} [missingSlices] - When `incomplete` because a child failed: the sub-task(s) that came
204
+ * back incomplete (§9 scenario 1) — the anti-survivor-sum signal, not a quiet undercount.
205
+ * @property {RecurseNode} receipts - The audit node for this call (RC-10).
206
+ */
207
+
208
+ /**
209
+ * Decompose a task into fresh-context workers, verify against a setpoint, and synthesize one result —
210
+ * assembled from existing primitives, not reimplemented (G1/G6).
211
+ *
212
+ * ⚠️ RESOURCE BOUNDS ARE bareguard's, not recurse()'s — OPEN BY DESIGN (§6, "no second guard layer"), and the
213
+ * one thing to know before running it. `recurse()` adds NO intrinsic total-work cap. The **Family-A default**
214
+ * (model-driven `spawn_child`) lets a node spawn UP TO each Loop's `HARD_ROUND_LIMIT` (100) children PER LEVEL,
215
+ * each recursing to `opts.maxDepth` (default 3) — so node count, and therefore TOKEN + $ SPEND, compounds
216
+ * multiplicatively and is **not capped by recurse itself**. (The forced paths — `mode:'fanout'`/`'partition'` —
217
+ * ARE bounded: a deterministic `count` + a `concurrency` cap. The uncapped path is the model-driven default.)
218
+ * This is real, not theoretical: a live POC (`poc/rlm-defer2-history-overflow.mjs`) showed a weak model
219
+ * over-decomposing into 40–117 calls on a single run. **So: running WITHOUT bareguard — or without ANY
220
+ * token/cost cap — CAN BURN TOKENS / $ unboundedly (up to ~100×depth nodes).** **WIRE bareguard**
221
+ * (`ctx.policy` via `wireGate`) for any non-trivial or untrusted run — it enforces depth/budget/call caps and
222
+ * the pre-wave fan-out checkpoint, turning a runaway into a clean `{incomplete}`. With no gate available, the
223
+ * only local brakes are `opts.maxDepth: 1` (flat — no nesting) and the provider/key's own usage limits.
224
+ *
225
+ * @param {string} task - The goal.
226
+ * @param {RecurseCtx} [ctx] - The runtime wiring (provider, policy, depth, …). Threaded down the tree.
227
+ * @param {RecurseOptions} [opts] - The policy knobs.
228
+ * @returns {Promise<RecurseResult>} `{ result, verdict, receipts }` on convergence; `{ incomplete, best,
229
+ * receipts }` on guard exhaustion. NEVER a fabricated success (RC-9).
230
+ * @throws {Error} no provider supplied (on neither `ctx.provider` nor `opts.provider`).
231
+ */
232
+ async function recurse(task, ctx = {}, opts = {}) {
233
+ if (typeof task !== 'string' || task.length === 0) {
234
+ throw new Error('[recurse] task must be a non-empty string');
235
+ }
236
+ const provider = ctx.provider || opts.provider;
237
+ if (!provider) {
238
+ throw new Error('[recurse] requires a provider on ctx.provider (or opts.provider)');
239
+ }
240
+
241
+ const depth = Number.isInteger(ctx.depth) ? /** @type {number} */ (ctx.depth) : 0;
242
+ const maxDepth = Number.isInteger(opts.maxDepth) ? /** @type {number} */ (opts.maxDepth) : 3;
243
+
244
+ // The classifier ALWAYS runs — as a hint, not a gate (§4.2). It decides only the two low-regret rails:
245
+ // `simple → single-shot` (no spawn tool offered; the depth-0 baseline that already beats most, §10F) and
246
+ // `critical → force adversarial verify` (the non-overridable safety floor, isCritical). It NEVER gates the
247
+ // high-regret decomposition structure — that stays the model's (Family A).
248
+ const assessment = assessComplexity(task);
249
+ const critical = isCritical(task);
250
+
251
+ /** @type {RecurseNode} */
252
+ const node = {
253
+ task,
254
+ depth,
255
+ complexity: { level: assessment.level, score: assessment.score },
256
+ critical,
257
+ spawned: [],
258
+ verdict: null,
259
+ incomplete: false,
260
+ halted: false,
261
+ tokens: null,
262
+ model: null,
263
+ // Always-defined so the audit trail is consistent across ALL dispatch paths (the partition/fanout branches
264
+ // early-return before the Family-A retrieval routing below, where this used to be the only assignment).
265
+ // null = no corpus retrieval (Family A/B single-shot or semantic fan-out); a mode string when one ran.
266
+ retrieval: null,
267
+ };
268
+
269
+ // Family B (NB-2) — FORCED fan-out, opt-in. The caller asked for guaranteed deterministic parallelism, so
270
+ // this path does NOT offer the model the spawn tool; a deterministic count → Planner → runPlan waves →
271
+ // NB-3 reduce → verify. assessComplexity is still only a hint here (it sets the count when no explicit
272
+ // `opts.count`); `critical` still forces verify. Branches before the Family-A spawn-tool setup below.
273
+ // Data-driven width PARTITION (NB-2 / §11) — opt-in, checked BEFORE the fanout branch so `opts.count` acts as
274
+ // the width FLOOR here (not a fanout trigger). Distinct from Family B's semantic decomposition: it PARTITIONS
275
+ // a measured corpus into ⌈size/workerBudget⌉ parallel scan-workers (capped by guards), never a Planner split.
276
+ if (opts.mode === 'partition') {
277
+ return recursePartition(task, ctx, opts, { provider, depth, critical, node });
278
+ }
279
+
280
+ // PRECEDENCE: an explicit forced fan-out (`mode:'fanout'`/`count`) is the stronger, deterministic intent and
281
+ // wins over `retrieval:'tools'` — this branch returns BEFORE the retrieval routing below, so a worker-level
282
+ // per-query tool face is NOT attached on a forced fan-out (each fan-out slice is its own fresh-window recurse
283
+ // and may route retrieval for ITS subtask). Pair forced fan-out with a per-slice `corpus`, not `retrieval:'tools'`.
284
+ if (opts.mode === 'fanout' || opts.count != null) {
285
+ return recurseFanout(task, ctx, opts, { provider, depth, maxDepth, assessment, critical, node });
286
+ }
287
+
288
+ // Retrieval routing (§10 step 7, §9.2.1 task-shape model). A task OVER A CORPUS gets context as a HANDLE
289
+ // chosen by the question's shape. `scan` is the default WHEN a corpus is present (the only complete path);
290
+ // `search`/`exact` are opt-in handle TOOLS for a Family-A worker. Absent both, behaviour is unchanged.
291
+ // A corpus may be an in-hand array OR an async slice-source `() => Promise<Slice[]>` (e.g. `litectxCorpus`
292
+ // over a litectx-resident corpus). Either form defaults retrieval to scan.
293
+ const hasCorpus = Array.isArray(opts.corpus) || typeof opts.corpus === 'function';
294
+ let retrieval = opts.retrieval || (hasCorpus ? 'scan' : null);
295
+ // Completeness-contract GUARD (RC-9 applied to retrieval): a "how many / all" ask must not be answered by a
296
+ // capped `search` (which cannot count). UPGRADE-only — never silently downgrade a scan to a search.
297
+ if (retrieval === 'search' && (impliesCompleteness(task) || impliesCompleteness(opts.contract))) {
298
+ retrieval = 'scan';
299
+ node.retrievalUpgraded = 'search→scan (completeness)';
300
+ }
301
+ node.retrieval = retrieval;
302
+
303
+ // `scan` is a deterministic ORCHESTRATION (code-driven judge-per-window + code-count), not a worker model
304
+ // call — it branches to its own path. `search`/`exact` fall through to the Family-A worker below with their
305
+ // handle tool injected (the worker decides per sub-query — the §10 step-7 "offered as tools" shape).
306
+ if (retrieval === 'scan') {
307
+ return recurseScan(task, ctx, opts, { provider, depth, critical, node });
308
+ }
309
+
310
+ // Offer the spawn A-tool only below the cap AND only when decomposition is plausibly useful (`simple`
311
+ // routes to single-shot). At `depth >= maxDepth` the tool is withheld — the NB-4 tool half of the scrub,
312
+ // and what makes `maxDepth=1` flat (RC-11): top spawns, children cannot (no nesting).
313
+ const canSpawn = depth < maxDepth && assessment.level !== 'simple';
314
+
315
+ // Capability-scrub (NB-4 / RC-12): the worker system prompt is the decomposition policy (NB-5) + a
316
+ // depth-conservative suffix that nudges deeper workers toward direct action. Tool set is monotone: a
317
+ // child's tools ⊆ its parent's (same handle tools, spawn dropped at the cap).
318
+ const system = DECOMPOSITION_POLICY + capabilityScrub(depth, maxDepth);
319
+
320
+ // Handle tools (RC-5 pull-default) = caller-supplied `opts.tools` + the retrieval handle for `search`/`exact`/
321
+ // `tools` (offered so the Family-A worker pulls context per sub-query, never the whole corpus). `search` needs
322
+ // `ctx.litectx`; `exact` is a code-side filter over the corpus. A mode whose backend is absent contributes
323
+ // no tool (the worker just answers directly) rather than erroring.
324
+ const retrievalTools = [];
325
+ if (retrieval === 'search' && ctx.litectx) retrievalTools.push(buildSearchTool(ctx.litectx, {}));
326
+ if (retrieval === 'exact') retrievalTools.push(buildExactTool(normalizeCorpus(opts.corpus)));
327
+ // `tools` (§10 step-7 follow-on, per-query face) — offer ALL applicable handles at once; the worker routes by
328
+ // their descriptions per sub-query. `scan_count` is the COMPLETE path (so the completeness guard need not fire
329
+ // for `tools`); `search_memory`/`exact_match` are the cheap needle/rule paths. Each is offered only when its
330
+ // backend is present (a corpus for scan/exact, `ctx.litectx` for search), else simply absent.
331
+ if (retrieval === 'tools') {
332
+ if (hasCorpus) {
333
+ retrievalTools.push(buildScanTool(/** @type {Slice[] | (() => Promise<Slice[]>)} */ (opts.corpus), {
334
+ provider,
335
+ window: opts.window,
336
+ passes: opts.passes,
337
+ ctx: { ...ctx, depth },
338
+ onLlmResult: ctx.onLlmResult,
339
+ policy: ctx.policy,
340
+ }));
341
+ }
342
+ if (ctx.litectx) retrievalTools.push(buildSearchTool(ctx.litectx, {}));
343
+ if (Array.isArray(opts.corpus)) retrievalTools.push(buildExactTool(normalizeCorpus(opts.corpus)));
344
+ }
345
+ const handleTools = [...(Array.isArray(opts.tools) ? opts.tools : []), ...retrievalTools];
346
+ // NB-3: collect each child's declared RESULT value (copy-on-return: the value, never its transcript) so the
347
+ // reducer can aggregate them. Step-3's seam handed the receipts only, so a code-reduce could not see what to
348
+ // combine — this closes that gap and is what Family B (step 5) will reduce over `runPlan` results[].
349
+ const childResults = [];
350
+ const tools = canSpawn
351
+ ? [...handleTools, buildSpawnTool(ctx, opts, depth, maxDepth, node, childResults)]
352
+ : handleTools;
353
+
354
+ const loop = new Loop({
355
+ provider,
356
+ system,
357
+ policy: ctx.policy || undefined,
358
+ onLlmResult: ctx.onLlmResult || undefined,
359
+ stream: ctx.stream || undefined,
360
+ throwOnError: false, // a worker fault surfaces as out.error → honest incomplete, never a thrown run
361
+ });
362
+
363
+ // Fresh message array = a true fresh window (RC-2 copy-on-return, IN side): the worker sees ONLY its task,
364
+ // never a parent transcript. `ctx.depth` is threaded so bareguard's policy can enforce the depth cap (§6).
365
+ const out = await loop.run(
366
+ [{ role: 'user', content: task }],
367
+ tools,
368
+ { ctx: { ...ctx, depth } },
369
+ );
370
+
371
+ node.tokens = out.metrics ? out.metrics.tokens : null;
372
+ node.model = provider.model || null;
373
+
374
+ // Guard exhaustion during generation → honest non-convergence (RC-9 / §9 scenario 3). The Loop already
375
+ // converted the HaltError to a clean `halt:<rule>` return (BA2) — recurse just honors it.
376
+ if (typeof out.error === 'string' && out.error.startsWith('halt:')) {
377
+ node.halted = true;
378
+ node.incomplete = true;
379
+ return { incomplete: true, best: out.text || null, receipts: node };
380
+ }
381
+ if (out.error) {
382
+ node.incomplete = true;
383
+ return { incomplete: true, best: out.text || null, receipts: node };
384
+ }
385
+
386
+ // Synthesis / reduce (NB-3, build step 4) + verify (RC-7), under one HaltError guard: a governance cap that
387
+ // trips mid-synthesis or mid-verify is a clean exit returning the partial `best` (RC-6), never a thrown run.
388
+ let result = out.text;
389
+ try {
390
+ // Default (Family A) = the worker's own final text — the parent model already combined the children's
391
+ // returned results in its closing turn. `opts.synthesize` OVERRIDES that (§9.1): a FUNCTION is a
392
+ // deterministic code-reduce over the child `results` (the aggregation path — LLM arithmetic is the weak
393
+ // link); a STRATEGY string ('concat'|'merge') runs the built-in reducer. Either form is a REDUCE over
394
+ // children, so it only fires when this node actually spawned some — a leaf (incl. a single-shot worker, or
395
+ // a deep child with no grandchildren) has nothing to reduce, so its own direct answer stands. This is also
396
+ // why threading `synthesize` down the tree is correct: each level reduces ITS children, leaves don't.
397
+ if (childResults.length > 0 && opts.synthesize != null) {
398
+ if (typeof opts.synthesize === 'function') {
399
+ result = await opts.synthesize({ task, text: out.text, results: childResults, children: node.spawned, ctx });
400
+ } else if (typeof opts.synthesize === 'string') {
401
+ result = await synthesize(task, childResults, {
402
+ strategy: /** @type {any} */ (opts.synthesize),
403
+ provider,
404
+ contract: typeof opts.contract === 'string' ? opts.contract : null,
405
+ onLlmResult: ctx.onLlmResult,
406
+ policy: ctx.policy,
407
+ text: out.text,
408
+ children: node.spawned,
409
+ ctx,
410
+ });
411
+ }
412
+ }
413
+
414
+ // Honest completeness (RC-9 / §9 negative scenario 1): if ANY child came back incomplete, THIS node is
415
+ // incomplete — never a silent survivor-sum over partial data (the §9.1 undercount: 99 vs 151, no signal).
416
+ // Mirrors spike-2's `incomplete: parts.some(p => p.incomplete)`, which the shipped glue had dropped. The
417
+ // reduce still runs first, so `best` carries the partial answer; we just refuse to call it a clean success.
418
+ // Propagates up the tree: a dead grandchild → incomplete child → incomplete parent. (A dead *worker at this
419
+ // level* is already handled above via `out.error`; this covers a dead *child* surfacing through the reduce.)
420
+ const missingSlices = node.spawned.filter(c => c.incomplete).map(c => c.task);
421
+ if (missingSlices.length > 0) {
422
+ node.incomplete = true;
423
+ return { incomplete: true, best: result, missingSlices, receipts: node };
424
+ }
425
+
426
+ // Verify: a SEPARATE-context judge, never the generator grading itself. Runs when a contract is given, the
427
+ // caller supplied a verifier, OR the task is critical (the forced-verify safety rail).
428
+ const wantVerify = critical || typeof opts.contract === 'string' || typeof opts.evaluate === 'function';
429
+ if (wantVerify) {
430
+ const verdict = await verify(task, result, ctx, opts);
431
+ node.verdict = verdict;
432
+ return { result, verdict, receipts: node };
433
+ }
434
+
435
+ return { result, verdict: null, receipts: node };
436
+ } catch (err) {
437
+ if (err instanceof HaltError) {
438
+ node.halted = true;
439
+ node.incomplete = true;
440
+ return { incomplete: true, best: result, receipts: node };
441
+ }
442
+ throw err;
443
+ }
444
+ }
445
+
446
+ /**
447
+ * SCAN (§10 step 7 / §9.2.1) — the default retrieval mode for a "how many / all" task over a corpus. A
448
+ * deterministic ORCHESTRATION, not a worker model call: every slice is processed, an isolated Loop LLM-judges
449
+ * each window, and the matching ids are unioned + CODE-counted (the aggregation is CODE, never a model
450
+ * Finish/count — RC-5 / §9.1 flaw #2, the path that does not silently undercount). The result is structured
451
+ * (`{count, matchedIds}`), never a model-stated number. RC-9: a dead window → `{incomplete, missingSlices}`,
452
+ * never folded into the count as a zero; a governance HaltError mid-scan → clean incomplete.
453
+ *
454
+ * The corpus is the generic array slice-source `opts.corpus`. Absent it, scan has nothing to read — litectx's
455
+ * resident-corpus enumerate path is deferred (docs/01-product/litectx-enumerate-spec.md) — so we return an
456
+ * honest incomplete, never a fabricated zero.
457
+ * @param {string} task
458
+ * @param {RecurseCtx} ctx
459
+ * @param {RecurseOptions} opts
460
+ * @param {{provider: Provider, depth: number, critical: boolean, node: RecurseNode}} state
461
+ * @returns {Promise<RecurseResult>}
462
+ */
463
+ async function recurseScan(task, ctx, opts, state) {
464
+ const { provider, critical, node } = state;
465
+ node.model = provider.model || null;
466
+
467
+ // Resolve the slice-source: an in-hand array, or an async `() => Promise<Slice[]>` (e.g. `litectxCorpus`
468
+ // materializing a litectx-resident corpus via enumerate). A source fault is an honest incomplete, not a
469
+ // fabricated empty scan; a governance HaltError during materialization is a clean halt.
470
+ let corpus;
471
+ try {
472
+ const raw = typeof opts.corpus === 'function' ? await opts.corpus() : opts.corpus;
473
+ corpus = normalizeCorpus(raw);
474
+ } catch (err) {
475
+ if (err instanceof HaltError) {
476
+ node.halted = true;
477
+ node.incomplete = true;
478
+ return { incomplete: true, best: null, receipts: node };
479
+ }
480
+ node.incomplete = true;
481
+ return { incomplete: true, best: null, missingSlices: [`scan corpus source failed: ${err.message}`], receipts: node };
482
+ }
483
+ if (corpus.length === 0) {
484
+ node.incomplete = true;
485
+ return {
486
+ incomplete: true,
487
+ best: null,
488
+ missingSlices: ['scan requires a non-empty corpus (array or an async slice-source like litectxCorpus)'],
489
+ receipts: node,
490
+ };
491
+ }
492
+
493
+ try {
494
+ const scan = await scanCount(task, corpus, {
495
+ provider,
496
+ window: opts.window,
497
+ passes: opts.passes,
498
+ ctx: { ...ctx, depth: state.depth },
499
+ onLlmResult: ctx.onLlmResult,
500
+ policy: ctx.policy,
501
+ });
502
+ node.scan = { window: scan.window, passes: scan.passes, scanned: scan.scanned, matched: scan.count };
503
+ // Structured, CODE-counted result — the count is authoritative; matchedIds carry the evidence (RC-10).
504
+ const result = { count: scan.count, matchedIds: scan.matchedIds };
505
+
506
+ // RC-9: a dead window means we did NOT see every slice → the count is a floor, not the answer. Report it
507
+ // incomplete with the partial as `best`, never a clean pass over a hole.
508
+ if (scan.missingSlices.length > 0) {
509
+ node.incomplete = true;
510
+ return { incomplete: true, best: result, missingSlices: scan.missingSlices, receipts: node };
511
+ }
512
+
513
+ // Verify (RC-7): forced for critical, or when a contract/override is supplied. The judge grades the
514
+ // structured count against the goal/contract (an isolated grader, never the scanner itself).
515
+ const wantVerify = critical || typeof opts.contract === 'string' || typeof opts.evaluate === 'function';
516
+ if (wantVerify) {
517
+ const verdict = await verify(task, result, ctx, opts);
518
+ node.verdict = verdict;
519
+ return { result, verdict, receipts: node };
520
+ }
521
+ return { result, verdict: null, receipts: node };
522
+ } catch (err) {
523
+ if (err instanceof HaltError) {
524
+ node.halted = true;
525
+ node.incomplete = true;
526
+ return { incomplete: true, best: null, receipts: node };
527
+ }
528
+ throw err;
529
+ }
530
+ }
531
+
532
+ /**
533
+ * DATA-DRIVEN WIDTH PARTITION (NB-2 / §11) — the *width* dial that stacks above the fixed/semantic count floor.
534
+ * Distinct from Family B's `recurseFanout` (a `Planner` SEMANTIC decomposition): this MEASURES a real corpus and
535
+ * PARTITIONS it into `width = max(floor, ⌈size / workerBudget⌉)` contiguous chunks (capped by the guards and by
536
+ * the size), each scanned by a fresh-window `recurse({retrieval:'scan'})` worker, then CODE-reduced (union the
537
+ * matched ids → count; the §9.1 aggregation, never a model count). `opts.count` is the width FLOOR (never
538
+ * lowered); the data may RAISE it. Like `recurseFanout`: a pre-wave `ctx.policy('recurse_partition', …)`
539
+ * checkpoint runs once `width` is known (a budget HaltError → clean incomplete before any worker spends); RC-9
540
+ * holds (a dead/incomplete chunk → `{incomplete, missingSlices}`, never a survivor-sum).
541
+ *
542
+ * The corpus is the generic slice-source (`opts.corpus` array or async fn, e.g. `litectxCorpus`) — materialized
543
+ * once in the parent (cheap: data in an array), then each worker's LLM context sees only its chunk's windows.
544
+ * @param {string} task
545
+ * @param {RecurseCtx} ctx
546
+ * @param {RecurseOptions} opts
547
+ * @param {{provider: Provider, depth: number, critical: boolean, node: RecurseNode}} state
548
+ * @returns {Promise<RecurseResult>}
549
+ */
550
+ async function recursePartition(task, ctx, opts, state) {
551
+ const { provider, depth, critical, node } = state;
552
+ node.model = provider.model || null;
553
+
554
+ // 1) Materialize the slice-source (array or async fn). A fault is an honest incomplete; a Halt is clean.
555
+ let corpus;
556
+ try {
557
+ const raw = typeof opts.corpus === 'function' ? await opts.corpus() : opts.corpus;
558
+ corpus = normalizeCorpus(raw);
559
+ } catch (err) {
560
+ if (err instanceof HaltError) { node.halted = true; node.incomplete = true; return { incomplete: true, best: null, receipts: node }; }
561
+ node.incomplete = true;
562
+ return { incomplete: true, best: null, missingSlices: [`partition corpus source failed: ${err.message}`], receipts: node };
563
+ }
564
+ if (corpus.length === 0) {
565
+ node.incomplete = true;
566
+ return { incomplete: true, best: null, missingSlices: ['partition requires a non-empty corpus (array or async slice-source)'], receipts: node };
567
+ }
568
+
569
+ // 2) Width = max(floor, ⌈size / workerBudget⌉), never below the floor, capped at the corpus size (no empty
570
+ // workers). `opts.count` is the floor; the data raises it. The guards (the checkpoint below) are the ceiling.
571
+ const size = corpus.length;
572
+ const floor = Number.isInteger(opts.count) && /** @type {number} */ (opts.count) > 0 ? /** @type {number} */ (opts.count) : 1;
573
+ const workerBudget = Number.isInteger(opts.workerBudget) && /** @type {number} */ (opts.workerBudget) > 0 ? /** @type {number} */ (opts.workerBudget) : DEFAULT_WORKER_BUDGET;
574
+ const dataWidth = Math.ceil(size / workerBudget);
575
+ const width = Math.min(Math.max(floor, dataWidth), size);
576
+ const concurrency = Number.isInteger(opts.concurrency) && /** @type {number} */ (opts.concurrency) > 0 ? /** @type {number} */ (opts.concurrency) : DEFAULT_FANOUT_CONCURRENCY;
577
+ node.partition = { size, workerBudget, floor, dataWidth, width };
578
+ // NB: `node.retrieval` stays null here (its default) — the partition orchestrator is its OWN dispatch path, not
579
+ // the Family-A scan dispatch; its audit record is `node.partition`. The per-chunk WORKERS run scan and record it
580
+ // on THEIR nodes (`receipts.spawned`). The node-literal null default is what makes this consistently defined
581
+ // (never `undefined`) across every dispatch path.
582
+
583
+ const childResults = [];
584
+ try {
585
+ // 2b) Pre-wave checkpoint — width (the cost) is now known. A governance HaltError halts BEFORE any worker
586
+ // spends (bounds the burst to zero); a plain deny is advisory (allowlist-safe), same contract as fanout.
587
+ if (typeof ctx.policy === 'function') {
588
+ try {
589
+ await ctx.policy('recurse_partition', { width, size, depth }, { ...ctx, depth });
590
+ } catch (err) {
591
+ if (err instanceof HaltError) throw err;
592
+ }
593
+ }
594
+
595
+ // 3) Partition into `width` chunks; each chunk → a fresh-window scan worker. The chunk rides on the step so
596
+ // `runPlan` (waves + concurrency cap) can route it (executeFn gets only the step); results align by index.
597
+ const chunks = partitionInto(corpus, width);
598
+ const childOpts = { ...forChild(opts), retrieval: /** @type {'scan'} */ ('scan'), window: opts.window, passes: opts.passes };
599
+ const steps = chunks.map((chunk, i) => ({ id: `p${i}`, action: `partition ${i} (${chunk.length} items)`, dependsOn: [], chunk }));
600
+ const results = await runPlan(
601
+ steps,
602
+ (step) => recurse(task, { ...ctx, depth: depth + 1 }, { ...childOpts, corpus: /** @type {any} */ (step).chunk }),
603
+ { concurrency },
604
+ );
605
+
606
+ // 4) CODE-reduce: union the matched ids across chunks (chunks are disjoint, so union size == Σ counts, and
607
+ // union is robust to any overlap). RC-9: a dead/incomplete chunk is a MISSING slice, never survivor-summed.
608
+ /** @type {Set<string>} */
609
+ const matched = new Set();
610
+ /** @type {string[]} */
611
+ const missingSlices = [];
612
+ for (let i = 0; i < results.length; i++) {
613
+ const r = results[i];
614
+ const label = steps[i] ? steps[i].action : `partition ${i}`;
615
+ if (r.status !== 'done' || !r.result) {
616
+ node.spawned.push(makeDeadNode(label, depth + 1));
617
+ missingSlices.push(label);
618
+ continue;
619
+ }
620
+ const child = /** @type {RecurseResult} */ (r.result);
621
+ node.spawned.push(child.receipts);
622
+ const val = child.incomplete ? child.best : child.result;
623
+ if (val && Array.isArray(val.matchedIds)) for (const id of val.matchedIds) matched.add(id);
624
+ childResults.push(val);
625
+ if (child.incomplete) missingSlices.push(label);
626
+ }
627
+ const result = { count: matched.size, matchedIds: [...matched] };
628
+ node.partition.matched = matched.size;
629
+
630
+ if (missingSlices.length > 0) {
631
+ node.incomplete = true;
632
+ return { incomplete: true, best: result, missingSlices, receipts: node };
633
+ }
634
+ const wantVerify = critical || typeof opts.contract === 'string' || typeof opts.evaluate === 'function';
635
+ if (wantVerify) {
636
+ const verdict = await verify(task, result, ctx, opts);
637
+ node.verdict = verdict;
638
+ return { result, verdict, receipts: node };
639
+ }
640
+ return { result, verdict: null, receipts: node };
641
+ } catch (err) {
642
+ if (err instanceof HaltError) {
643
+ node.halted = true;
644
+ node.incomplete = true;
645
+ const best = childResults.length ? { count: new Set(childResults.flatMap((v) => (v && Array.isArray(v.matchedIds) ? v.matchedIds : []))).size } : null;
646
+ return { incomplete: true, best, receipts: node };
647
+ }
648
+ throw err;
649
+ }
650
+ }
651
+
652
+ /**
653
+ * Family B (NB-2) — the forced-fan-out path. Deterministic count → `Planner` (the NB-2 `count` seam forces
654
+ * exactly N independent parallel steps) → `runPlan` (wave parallelism, concurrency cap) → NB-3 reduce →
655
+ * verify. Each step runs as a fresh-window `recurse()` child (so copy-on-return / honest-incomplete / the
656
+ * capability-scrub all come for free, and a child MAY itself decompose under Family A); forced fan-out is NOT
657
+ * re-applied to children (their `count`/`mode` are stripped). Reduce default is `'concat'` (lossless) since
658
+ * there is no parent closing turn to combine the slices the way Family A's does. RC-9 holds: any dead/halted/
659
+ * incomplete slice → `{incomplete, missingSlices}`, never a survivor-sum. A governance HaltError (planner,
660
+ * a child, the reduce, or verify) is a clean `incomplete` exit, never a thrown run.
661
+ * @param {string} task
662
+ * @param {RecurseCtx} ctx
663
+ * @param {RecurseOptions} opts
664
+ * @param {{provider: Provider, depth: number, maxDepth: number, assessment: {level: string, score: number}, critical: boolean, node: RecurseNode}} state
665
+ * @returns {Promise<RecurseResult>}
666
+ */
667
+ async function recurseFanout(task, ctx, opts, state) {
668
+ const { provider, depth, assessment, critical, node } = state; // maxDepth rides in opts → children
669
+ node.model = provider.model || null; // the orchestration is code; per-worker tokens live in node.spawned[]
670
+
671
+ // Count: an explicit positive-integer `opts.count` wins; otherwise the calibrated tier→count map. Floor at 1
672
+ // (a 0/NaN/negative `count` is meaningless for "guaranteed parallelism" — fall back to the tier default).
673
+ const explicit = Number.isInteger(opts.count) && /** @type {number} */ (opts.count) > 0 ? opts.count : null;
674
+ const count = explicit != null ? /** @type {number} */ (explicit) : (TIER_COUNT[assessment.level] || 1);
675
+ const concurrency = Number.isInteger(opts.concurrency) && /** @type {number} */ (opts.concurrency) > 0
676
+ ? opts.concurrency : DEFAULT_FANOUT_CONCURRENCY;
677
+
678
+ const childResults = [];
679
+ const contract = typeof opts.contract === 'string' ? opts.contract : null;
680
+
681
+ try {
682
+ // 1) Decompose into exactly `count` independent parallel steps (the NB-2 Planner seam). A non-Halt planner
683
+ // failure (e.g. unparseable plan) is an honest incomplete — we cannot fan out, so we do not pretend to.
684
+ // The plan call forwards its usage to the gate (`onLlmResult`) so decomposition spend is metered, not
685
+ // invisible — and it is the CHEAP call that RESOLVES the unknown fan-out cost into a known width.
686
+ const planner = new Planner({ provider, onLlmResult: /** @type {any} */ (ctx.onLlmResult) || undefined });
687
+ let steps;
688
+ try {
689
+ steps = await planner.plan(task, { count });
690
+ } catch (err) {
691
+ if (err instanceof HaltError) throw err;
692
+ node.incomplete = true;
693
+ return { incomplete: true, best: null, missingSlices: [task], receipts: node };
694
+ }
695
+
696
+ // 1b) Pre-wave gate checkpoint (the cost-commitment point). Decomposition just turned an UNKNOWN cost into
697
+ // a KNOWN width — so before committing the worker wave, give the gate a chance to act on it. A governance
698
+ // HaltError (e.g. bareguard's budget cap, or a near-threshold HITL pause surfaced as a halt) propagates
699
+ // to the outer catch → clean incomplete, BEFORE any worker spends — this is what bounds the concurrent
700
+ // burst (N workers can't each overshoot between post-round meters if the wave never launches). A plain
701
+ // deny is advisory only: it must NOT break an allowlist policy that doesn't know this internal
702
+ // descriptor — the load-bearing budget signal is the HaltError, on bareguard's existing contract.
703
+ if (typeof ctx.policy === 'function') {
704
+ try {
705
+ await ctx.policy('recurse_fanout', { count: steps.length, depth }, { ...ctx, depth });
706
+ } catch (err) {
707
+ if (err instanceof HaltError) throw err;
708
+ // non-halt policy error/deny → advisory; proceed (per-worker policy still gates each child below)
709
+ }
710
+ }
711
+
712
+ // 2) Fan out: each step is a fresh-window recurse() child. Forced fan-out is NOT re-applied to children, and
713
+ // the top-level contract/verifier is the TOP's job — `forChild` strips both (the slices run Family A, or
714
+ // single-shot; `maxDepth` is preserved so a genuinely oversized slice may still self-decompose).
715
+ const childOpts = forChild(opts);
716
+ const results = await runPlan(
717
+ steps,
718
+ (step) => recurse(step.action, { ...ctx, depth: depth + 1 }, childOpts),
719
+ { concurrency },
720
+ );
721
+
722
+ // 3) Collect copy-on-return values + lineage, in plan order. A failed step (executeFn threw) or a child
723
+ // that came back incomplete/halted is a MISSING slice — recorded, never survivor-summed (RC-9).
724
+ /** @type {string[]} */
725
+ const missingSlices = [];
726
+ for (let i = 0; i < results.length; i++) {
727
+ const r = results[i];
728
+ const slice = steps[i] ? steps[i].action : `slice ${i}`;
729
+ if (r.status !== 'done' || !r.result) {
730
+ node.spawned.push(makeDeadNode(slice, depth + 1));
731
+ childResults.push('');
732
+ missingSlices.push(slice);
733
+ continue;
734
+ }
735
+ const child = /** @type {RecurseResult} */ (r.result);
736
+ node.spawned.push(child.receipts);
737
+ const value = child.incomplete
738
+ ? (child.best == null ? '' : child.best)
739
+ : (child.result == null ? '' : child.result);
740
+ childResults.push(value);
741
+ if (child.incomplete) missingSlices.push(slice);
742
+ }
743
+
744
+ // 4) NB-3 reduce over the slice results. Unlike Family A there is no parent closing turn, so we ALWAYS
745
+ // reduce: a `synthesize` FUNCTION is the deterministic code-reduce (§9.1); a string runs the built-in
746
+ // reducer; unset defaults to lossless `'concat'`. (`childResults` always has `count` entries.)
747
+ let result;
748
+ if (typeof opts.synthesize === 'function') {
749
+ result = await opts.synthesize({ task, text: null, results: childResults, children: node.spawned, ctx });
750
+ } else {
751
+ const strategy = typeof opts.synthesize === 'string' ? opts.synthesize : 'concat';
752
+ result = await synthesize(task, childResults, {
753
+ strategy: /** @type {any} */ (strategy),
754
+ provider,
755
+ contract,
756
+ onLlmResult: ctx.onLlmResult,
757
+ policy: ctx.policy,
758
+ text: null,
759
+ children: node.spawned,
760
+ ctx,
761
+ });
762
+ }
763
+
764
+ // 5) Honest completeness (RC-9): any missing slice → incomplete, with the partial reduce as `best`.
765
+ if (missingSlices.length > 0) {
766
+ node.incomplete = true;
767
+ return { incomplete: true, best: result, missingSlices, receipts: node };
768
+ }
769
+
770
+ // 6) Verify (RC-7): forced for critical, or when a contract/override is supplied.
771
+ const wantVerify = critical || contract != null || typeof opts.evaluate === 'function';
772
+ if (wantVerify) {
773
+ const verdict = await verify(task, result, ctx, opts);
774
+ node.verdict = verdict;
775
+ return { result, verdict, receipts: node };
776
+ }
777
+ return { result, verdict: null, receipts: node };
778
+ } catch (err) {
779
+ if (err instanceof HaltError) {
780
+ node.halted = true;
781
+ node.incomplete = true;
782
+ // best-effort partial: whatever slices we did collect, losslessly joined (no LLM — the gate already tripped)
783
+ const best = childResults.length ? childResults.filter(v => v !== '').join('\n\n') : null;
784
+ return { incomplete: true, best: best || null, receipts: node };
785
+ }
786
+ throw err;
787
+ }
788
+ }
789
+
790
+ /**
791
+ * A receipts node for a slice that never produced a result (the worker threw / runPlan marked it failed) — so
792
+ * the audit tree still shows the lineage and the dead branch, rather than a silent gap.
793
+ * @param {string} task
794
+ * @param {number} depth
795
+ * @returns {RecurseNode}
796
+ */
797
+ function makeDeadNode(task, depth) {
798
+ const a = assessComplexity(task);
799
+ return {
800
+ task, depth,
801
+ complexity: { level: a.level, score: a.score },
802
+ critical: isCritical(task),
803
+ spawned: [], verdict: null, incomplete: true, halted: false, tokens: null, model: null,
804
+ };
805
+ }
806
+
807
+ /**
808
+ * NB-4 — the `spawn_child` A-tool. The in-process self-call (§4.5 candidate (a), POC-resolved as default):
809
+ * `execute` runs a full `recurse(subtask, {...ctx, depth: depth+1})`, so a delegated sub-task gets its own
810
+ * fresh Loop / fresh window and may itself decompose, bounded by the same `maxDepth` + bareguard. Copy-on-
811
+ * return (RC-2): only the child's declared RESULT string crosses back into the parent transcript — never the
812
+ * child's scratch/transcript (the child receipts node is filed under `node.spawned` for audit, separate from
813
+ * the transcript). A child `HaltError` is intentionally NOT caught here: it throws out of `execute`, the
814
+ * Loop re-throws it (loop.js), and the parent's run halts cleanly — propagating the guard trip up the tree.
815
+ * @param {RecurseCtx} ctx
816
+ * @param {RecurseOptions} opts
817
+ * @param {number} depth - The PARENT's depth; the child runs at `depth + 1`.
818
+ * @param {number} maxDepth
819
+ * @param {RecurseNode} node - The parent's receipts node; children append to `node.spawned`.
820
+ * @param {any[]} childResults - Sink for each child's declared RESULT value (NB-3 reduce input).
821
+ * @returns {ToolDef}
822
+ */
823
+ function buildSpawnTool(ctx, opts, depth, maxDepth, node, childResults) {
824
+ return {
825
+ name: 'spawn_child',
826
+ description:
827
+ 'Delegate a sub-task to a fresh worker with its own clean context window. Use ONLY when a sub-task is ' +
828
+ 'too large or too independent to handle directly in this pass. The worker returns ONLY its result — ' +
829
+ 'not its working notes. Do the small/glue parts yourself and combine the results into your final answer.',
830
+ parameters: {
831
+ type: 'object',
832
+ properties: {
833
+ subtask: {
834
+ type: 'string',
835
+ description: 'A self-contained sub-task, with all the context the fresh worker needs to do it (it cannot see this conversation).',
836
+ },
837
+ },
838
+ required: ['subtask'],
839
+ },
840
+ /** @param {{subtask?: string}} args */
841
+ execute: async (args) => {
842
+ const subtask = typeof args?.subtask === 'string' ? args.subtask : '';
843
+ if (!subtask) return '[error] spawn_child requires a non-empty subtask string';
844
+ // A delegated child grades only ITS slice; the parent's contract/verifier is the top's job (see forChild).
845
+ const child = await recurse(subtask, { ...ctx, depth: depth + 1 }, forChild(opts));
846
+ node.spawned.push(child.receipts); // audit lineage (RC-10) — NOT the parent transcript
847
+ // Only the declared result crosses the boundary (RC-2). An incomplete child is reported honestly, not
848
+ // silently dropped or faked. The same declared value is collected for the NB-3 reducer.
849
+ const value = child.incomplete ? (child.best == null ? '' : child.best) : (child.result == null ? '' : child.result);
850
+ childResults.push(value);
851
+ if (child.incomplete) return `[incomplete] ${String(value)}`.trim();
852
+ return String(value);
853
+ },
854
+ };
855
+ }
856
+
857
+ /**
858
+ * The verify slot (§7.1) — the Evaluator fills it by default. `opts.evaluate` overrides. The default path
859
+ * runs an isolated adversarial rubric grader (separate context window): when a `contract` is present it
860
+ * grades against THAT (A3); otherwise it grades full-and-correct against the goal — which is exactly the
861
+ * `critical → force verify` rail (a critical task with no contract still gets an independent grader).
862
+ * @param {string} task
863
+ * @param {any} result
864
+ * @param {RecurseCtx} ctx
865
+ * @param {RecurseOptions} opts
866
+ * @returns {Promise<Verdict>}
867
+ */
868
+ function verify(task, result, ctx, opts) {
869
+ const contract = typeof opts.contract === 'string' ? opts.contract : null;
870
+ if (typeof opts.evaluate === 'function') {
871
+ return Promise.resolve(opts.evaluate(result, { contract, task }));
872
+ }
873
+ const provider = ctx.provider || opts.provider;
874
+ const evaluator = new Evaluator({ provider });
875
+ const rubric = contract
876
+ ? 'Judge whether the result satisfies the definition of done. Be strict and adversarial; cite the specific gap on any shortfall.'
877
+ : 'Judge whether the result fully and correctly answers the goal. Be strict and adversarial; cite the specific gap on any shortfall.';
878
+ return evaluator.evaluate(
879
+ task,
880
+ result,
881
+ { rubric, contract: contract || undefined },
882
+ { onLlmResult: /** @type {any} */ (ctx.onLlmResult), policy: ctx.policy },
883
+ );
884
+ }
885
+
886
+ module.exports = { recurse };