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/README.md +22 -1
- package/bareagent.context.md +1 -1
- package/index.d.ts +6 -1
- package/index.js +7 -0
- package/package.json +4 -4
- package/src/planner.d.ts +21 -1
- package/src/planner.js +24 -4
- package/src/recurse-prompts.d.ts +22 -0
- package/src/recurse-prompts.js +71 -0
- package/src/recurse-retrieval.d.ts +174 -0
- package/src/recurse-retrieval.js +372 -0
- package/src/recurse-synthesize.d.ts +53 -0
- package/src/recurse-synthesize.js +97 -0
- package/src/recurse.d.ts +361 -0
- package/src/recurse.js +886 -0
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 };
|