bare-agent 0.19.0 → 0.21.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 CHANGED
@@ -62,7 +62,7 @@ and show me the wiring code.
62
62
 
63
63
  ## What's inside
64
64
 
65
- Every piece works alone — take what you need, ignore the rest. Two axes: **Act** (get work done) and **Verify** (check it, keep context clean), with **one gate** over both.
65
+ Every piece works alone — take what you need, ignore the rest. Two axes: **Act** (get work done) and **Verify** (check it, keep context clean), with **one gate** over both — plus **`recurse`**, an Act-side primitive big enough to earn its own spotlight below.
66
66
 
67
67
  ### Act — get work done
68
68
 
@@ -72,6 +72,7 @@ Every piece works alone — take what you need, ignore the rest. Two axes: **Act
72
72
  | **Planner** | Break a goal into a step DAG. Cached |
73
73
  | **assessComplexity** | Rate a goal `simple`→`critical` from its text — no LLM. Gates whether to plan |
74
74
  | **runPlan** | Run plan steps in parallel waves. Dependency-aware, per-step retry |
75
+ | **recurse** | RLM decompose→fan-out→verify→synthesize in one call. Model-driven (`spawn_child`) or forced fan-out (`count`); `retrieval:'scan'` answers "how many / all" over a corpus by scanning every slice and code-counting — never a faked pass (honest `{incomplete, missingSlices}`) |
75
76
  | **Memory** | Persist + recall across sessions via a swappable `Store` — zero-dep JSON, SQLite, or [litectx](https://npmjs.com/package/litectx) in a one-line swap |
76
77
  | **StateMachine** | Task lifecycle: `pending → running → done / failed / waiting / cancelled` |
77
78
  | **Scheduler** | Cron or relative triggers. Jobs survive restarts |
@@ -91,6 +92,33 @@ Every piece works alone — take what you need, ignore the rest. Two axes: **Act
91
92
  | **SkillRegistry** | Surface skills on demand: one meta-tool catalog; activating a skill injects its instructions and unlocks its tools |
92
93
  | **stash** | Compact finished work out of the live window (restorable), or auto-fold the middle under token pressure |
93
94
 
95
+ ### Recurse — break a hard task into a tree *(the RLM primitive)*
96
+
97
+ `recurse(task, ctx, opts)` does **decompose → fan-out → verify → synthesize** in one call — Recursive Language Models as a single import, composed *around* the Loop (never a new engine). The default is **model-driven**: the worker is handed a `spawn_child` tool and decides whether to split, bounded by depth + bareguard (no second guard layer). Forced fan-out (`count` / `mode:'fanout'`) and data-driven width (`mode:'partition'`, measured from a corpus) are opt-in. The headline guarantee: **aggregation is code, never a model-stated number**, and a dead worker or exhausted guard returns an honest `{ incomplete, missingSlices }` — never a faked pass.
98
+
99
+ Over a corpus, context reaches a worker as a **handle routed by question shape** (`opts.retrieval`):
100
+
101
+ | `retrieval` | Use it for | How |
102
+ |---|---|---|
103
+ | `'scan'` *(default over a corpus)* | "how many / all / count" | scans every slice, LLM-judges each, **code-counts** the union — the only path that can't silently undercount |
104
+ | `'search'` | find a needle (few matches) | litectx `recall` handle tool, embeddings on — **cannot count** |
105
+ | `'exact'` | rule / exact-term match | code-side AND-filter, embeddings off |
106
+ | `'tools'` | mixed task (needle *and* count) | offers all three; the worker picks per sub-query by tool description |
107
+
108
+ ```js
109
+ const { recurse } = require('bare-agent');
110
+
111
+ // Honest count over a corpus: scans every slice, LLM-judges each, CODE-counts the union.
112
+ const { result } = await recurse(
113
+ 'How many of these support tickets are billing disputes?',
114
+ { provider },
115
+ { corpus: tickets /* {id,text}[] */, retrieval: 'scan' },
116
+ );
117
+ console.log(result.count, result.matchedIds); // a code-derived count + the ids that back it
118
+ ```
119
+
120
+ > **⚠️ Cost is open by design — wire a cap.** `recurse()` adds no intrinsic total-work limit. On the model-driven default a node can spawn up to ~100 children per level, each recursing to `maxDepth` (default 3), so **token / $ spend compounds and is bounded only by your gate** — not by recurse. Run it **with bareguard** (`ctx.policy`, which enforces depth/budget/call caps) **or with some token/USD cap** for any non-trivial or untrusted task; ungoverned, a weak model that over-decomposes *will* burn tokens. For a hard local brake without a gate, set `maxDepth: 1` (flat, no nesting). The forced modes (`mode:'fanout'` / `'partition'`) are bounded by a deterministic count + concurrency cap; the open path is the model-driven default.
121
+
94
122
  **Govern — one gate over both axes.** `wireGate(gate)` routes every LLM + tool call through one bareguard policy + audit + budget. Denied tools never reach the model; halts (turn / budget / content caps) exit cleanly. `require('bare-agent/bareguard')`
95
123
 
96
124
  **Providers:** OpenAI-compatible (OpenAI, OpenRouter, Groq, vLLM, LM Studio), Anthropic, Gemini (native), Ollama, CLIPipe, Fallback — or bring your own (one `generate` method). All return the same shape; swap freely. Usage including prompt-cache tiers is normalized, so `result.metrics` reports honest cumulative tokens + cost — and `null`, never a silent `0`, for a model it couldn't price.
@@ -1,7 +1,7 @@
1
1
  # bareagent — Integration Guide
2
2
 
3
3
  > For AI assistants and developers wiring bareagent into a project.
4
- > v0.19.0 | Node.js >= 18 | zero required deps (`bareguard ^0.9.0` optional peer for governance) | Apache 2.0
4
+ > v0.21.0 | Node.js >= 18 | zero required deps (`bareguard ^0.9.0` optional peer for governance) | Apache 2.0
5
5
  >
6
6
  > Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)
7
7
 
@@ -34,6 +34,9 @@ Eight entry points:
34
34
  | Size a goal before planning (no LLM) | assessComplexity — `needsPlanning` gates a Planner pass |
35
35
  | Kill a spawned child that hangs silently | createSpawnTool / spawnChild `{ idleTimeoutMs }` |
36
36
  | Execute a step DAG with parallelism | runPlan + executeFn |
37
+ | Decompose a hard task into a verified tree (RLM) | recurse — decompose → fan-out → verify → synthesize in one call (**wire a gate**, cost is open by design) |
38
+ | Count / answer "how many / all" over a corpus, honestly | recurse(task, ctx, `{ corpus, retrieval: 'scan' }`) — scans every slice, CODE-counts |
39
+ | Give recurse workers a persona/role (senior-dev stance) | recurse(task, ctx, `{ persona }`) — prepended to every worker, carries down the tree; not applied to the verifier |
37
40
  | Track task state (pending/running/done/failed) | StateMachine |
38
41
  | Run agent turns on a schedule (cron, timers) | Scheduler |
39
42
  | Require human approval before dangerous actions | Checkpoint |
@@ -599,6 +602,72 @@ const results = await runPlan(steps, async (step) => {
599
602
  // results: [{ id: 's1', status: 'done', result: '...' }, { id: 's2', status: 'failed', error: '...' }, ...]
600
603
  ```
601
604
 
605
+ ## Wiring with recurse (RLM — decompose → fan-out → verify → synthesize)
606
+
607
+ `recurse(task, ctx, opts)` is the **Recursive Language Models** primitive (v0.20.0): one import that decomposes a hard task into fresh-context workers, verifies against a setpoint, and synthesizes one result. It is **thin glue composed around `Loop`/`Planner`/`runPlan`/`Evaluator`/`spawn`** — not a new engine, never imported by `loop.js`. Returns `{ result, verdict, receipts }` on convergence, or `{ incomplete, best, missingSlices, receipts }` on guard exhaustion / a dead worker — **never a fabricated success** (RC-9).
608
+
609
+ > **⚠️ Cost is open by DESIGN — wire a gate.** `recurse()` adds NO intrinsic total-work cap. On the **Family-A default** (model-driven) a node can spawn up to ~100 children per level, each recursing to `opts.maxDepth` (default 3) — so token/$ spend compounds and is bounded **only by your gate**, not by recurse. A live POC saw a weak model do 40–117 calls in one run. **Always wire bareguard** (`ctx.policy` via `wireGate`) for any non-trivial or untrusted run — it enforces depth/budget/call caps and turns a runaway into a clean `{ incomplete }` (proven: a wired `Gate` cut a 43–117-call runaway to 4–5 calls). The local brake without a gate is `opts.maxDepth: 1` (flat, no nesting). The forced modes (`mode:'fanout'`/`'partition'`) ARE bounded (deterministic count + concurrency cap).
610
+
611
+ ```javascript
612
+ const { recurse, wireGate } = require('bare-agent');
613
+ const { Gate } = require('bareguard');
614
+
615
+ // ALWAYS run governed for real work — the gate is the total-work bound.
616
+ const gate = new Gate({ budget: { maxCostUsd: 0.50 }, limits: { maxTurns: 30, maxDepth: 3 }, humanChannel: async () => ({ decision: 'deny' }) });
617
+ await gate.init();
618
+ const { policy, onLlmResult } = wireGate(gate);
619
+
620
+ // ctx = runtime wiring threaded down the whole tree; opts = policy knobs.
621
+ const ctx = { provider, policy, onLlmResult }; // policy + onLlmResult = the gate over every node + the verifier
622
+ const out = await recurse('Audit this 2000-line module for security bugs and rank them', ctx, { maxDepth: 3 });
623
+
624
+ if (out.incomplete) {
625
+ console.warn('did not converge:', out.missingSlices, '— best partial:', out.best); // honest, never a faked pass
626
+ } else {
627
+ console.log(out.result, out.verdict); // the synthesized answer + the verifier's gap report
628
+ }
629
+ console.log(out.receipts.spawned.length); // RC-10 audit tree: parent→child lineage, per-node tokens/verdict
630
+ ```
631
+
632
+ **Control families (how the tree is shaped):**
633
+
634
+ - **Family A — model-driven (default).** The worker is handed an in-process `spawn_child` tool and *decides* whether to split. `assessComplexity` is a **hint, not a gate** (only `simple → single-shot`, and the non-overridable `critical → force adversarial verify` safety floor). Nothing extra to set.
635
+ - **Family B — forced fan-out (opt-in).** `{ count: N }` → exactly N independent parallel workers via `Planner`→`runPlan`; or `{ mode: 'fanout' }` → count derived from the complexity tier (medium/complex/critical → 2/4/6). Deterministic + concurrency-capped.
636
+ - **`{ mode: 'partition', corpus, workerBudget }`** — data-driven WIDTH: measure the corpus, split into `max(count floor, ⌈size/workerBudget⌉)` parallel scan-workers, union-count. A pre-wave `recurse_partition` policy checkpoint fires before any worker spends.
637
+
638
+ **Retrieval over a corpus** (`opts.corpus = {id,text}[]` or an async `() => Promise<Slice[]>`, e.g. `litectxCorpus(litectx, {kind})`). Context reaches a worker as a HANDLE routed by question shape:
639
+
640
+ | `retrieval` | For | Note |
641
+ |---|---|---|
642
+ | `'scan'` *(default when `corpus` present)* | "how many / all / count" | scans every slice, LLM-judges, **CODE-counts** the union — the only path that can't undercount; `window` 8, `passes` 2 |
643
+ | `'search'` | find a needle | litectx `recall` tool (needs `ctx.litectx`), embeddings on — **cannot count** |
644
+ | `'exact'` | rule / exact-term match | code-side AND-filter, embeddings off |
645
+ | `'tools'` | mixed task (needle *and* count) | offers `scan_count` + `search_memory` + `exact_match`; worker picks per sub-query by tool description |
646
+
647
+ A completeness guard **upgrades** a `'search'` on a "how many / all" ask to `'scan'` (upgrade-only, never a silent downgrade). Aggregation is **always code**, never a model-stated number.
648
+
649
+ ```javascript
650
+ // Honest count over a corpus — scans every slice, code-counts the matches.
651
+ const { result } = await recurse(
652
+ 'How many of these support tickets are billing disputes?',
653
+ { provider, policy }, // still wire the gate
654
+ { corpus: tickets /* {id,text}[] */, retrieval: 'scan' },
655
+ );
656
+ console.log(result.count, result.matchedIds); // a code-derived count + the ids that back it
657
+ ```
658
+
659
+ **Synthesis (`opts.synthesize`):** a **function** (deterministic code-reduce over child `results` — use for arithmetic/aggregation; LLM arithmetic over partials carried ~10–15% error), or `'concat'` (lossless no-LLM join), or `'merge'` (isolated Loop-driven subjective merge). Default = the parent model's own closing-turn synthesis. **`opts.contract`** = a definition-of-done the verifier grades against (instead of the loose task); **`opts.evaluate`** overrides the verifier. Exported helpers for the per-query face: `buildScanTool`, `buildSearchTool`, `buildExactTool`, `litectxCorpus`.
660
+
661
+ **Worker persona (`opts.persona`, v0.21.0):** a string PREPENDED to every Family-A worker's system prompt — give workers a stance (`persona: 'You are a senior security engineer; be specific and cite the exact file:line'`). It **augments**, never replaces, the built-in decomposition policy + depth-scrub (those drive the spawn mechanics), and **carries down the whole tree** (a child of a "senior security engineer" is still one). It is deliberately **not** applied to the isolated verifier (that isolation is what defeats self-grading sycophancy) nor the deterministic scan judge. Absent ⇒ the worker prompt is unchanged from pre-0.21.
662
+
663
+ ```javascript
664
+ const out = await recurse('Audit auth.js, billing.js, gateway.js for authz bugs', ctx, {
665
+ persona: 'You are a blunt senior application-security engineer. Report each finding as file:line + impact + fix.',
666
+ });
667
+ ```
668
+
669
+ **What a delegated child inherits (important — the setpoint is the TOP node's job):** when a worker delegates with `spawn_child`, the child runs a **fresh `recurse`** that inherits `tools`, `synthesize`, `maxDepth`, and `persona` — but the parent's **`contract`/`evaluate` are stripped** (and the forced `count`/`mode` + the corpus `retrieval` knobs). A slice is not graded against the *whole*-task definition-of-done (that verdict is the top node's, and a slice satisfying the whole DoD is the wrong question); only the top `recurse` verifies the synthesized result. The non-overridable `critical → force-verify` safety floor still fires per node (it keys on the task text, not the contract). So: set `contract`/`evaluate` once at the top; they do not — and should not — re-run per intermediate node.
670
+
602
671
  ## Provider options
603
672
 
604
673
  ```javascript
package/index.d.ts CHANGED
@@ -2,6 +2,11 @@ import { Loop } from "./src/loop";
2
2
  import { Planner } from "./src/planner";
3
3
  import { Evaluator } from "./src/evaluator";
4
4
  import { refine } from "./src/refine";
5
+ import { recurse } from "./src/recurse";
6
+ import { buildSearchTool } from "./src/recurse-retrieval";
7
+ import { buildExactTool } from "./src/recurse-retrieval";
8
+ import { buildScanTool } from "./src/recurse-retrieval";
9
+ import { litectxCorpus } from "./src/recurse-retrieval";
5
10
  import { remember } from "./src/remember";
6
11
  import { assessComplexity } from "./src/complexity";
7
12
  import { isCritical } from "./src/complexity";
@@ -29,4 +34,4 @@ import { TimeoutError } from "./src/errors";
29
34
  import { ValidationError } from "./src/errors";
30
35
  import { CircuitOpenError } from "./src/errors";
31
36
  import { HaltError } from "./src/errors";
32
- export { Loop, Planner, Evaluator, refine, remember, assessComplexity, isCritical, SkillRegistry, createStashSkill, StateMachine, Scheduler, Checkpoint, Memory, Stream, Retry, runPlan, CircuitBreaker, wireGate, defaultActionTranslator, toUnits, fromUnits, unitAssembler, unitTrimmer, harvestKey, BareAgentError, ProviderError, ToolError, TimeoutError, ValidationError, CircuitOpenError, HaltError };
37
+ export { Loop, Planner, Evaluator, refine, recurse, buildSearchTool, buildExactTool, buildScanTool, litectxCorpus, remember, assessComplexity, isCritical, SkillRegistry, createStashSkill, StateMachine, Scheduler, Checkpoint, Memory, Stream, Retry, runPlan, CircuitBreaker, wireGate, defaultActionTranslator, toUnits, fromUnits, unitAssembler, unitTrimmer, harvestKey, BareAgentError, ProviderError, ToolError, TimeoutError, ValidationError, CircuitOpenError, HaltError };
package/index.js CHANGED
@@ -4,6 +4,8 @@ const { Loop } = require('./src/loop');
4
4
  const { Planner } = require('./src/planner');
5
5
  const { Evaluator } = require('./src/evaluator');
6
6
  const { refine } = require('./src/refine');
7
+ const { recurse } = require('./src/recurse');
8
+ const { buildSearchTool, buildExactTool, buildScanTool, litectxCorpus } = require('./src/recurse-retrieval');
7
9
  const { remember } = require('./src/remember');
8
10
  const { assessComplexity, isCritical } = require('./src/complexity');
9
11
  const { SkillRegistry } = require('./src/skills');
@@ -33,6 +35,11 @@ module.exports = {
33
35
  Planner,
34
36
  Evaluator,
35
37
  refine,
38
+ recurse,
39
+ buildSearchTool,
40
+ buildExactTool,
41
+ buildScanTool,
42
+ litectxCorpus,
36
43
  remember,
37
44
  assessComplexity,
38
45
  isCritical,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
@@ -92,8 +92,8 @@
92
92
  }
93
93
  },
94
94
  "scripts": {
95
- "test": "node --test --test-force-exit test/**/*.test.js",
96
- "test:unit": "node --test --test-force-exit \"test/!(integration*|*mcp*|spawn*).test.js\"",
95
+ "test": "node --test test/**/*.test.js",
96
+ "test:unit": "node --test \"test/!(integration*|*mcp*|spawn*).test.js\"",
97
97
  "typecheck": "tsc --noEmit",
98
98
  "prebuild:types": "node scripts/clean-types.js",
99
99
  "build:types": "tsc",
@@ -102,7 +102,7 @@
102
102
  "devDependencies": {
103
103
  "@types/node": "^22.19.19",
104
104
  "bareguard": "^0.9.0",
105
- "litectx": "^0.16.0",
105
+ "litectx": "^0.26.0",
106
106
  "typescript": "^5.7.0"
107
107
  }
108
108
  }
package/src/planner.d.ts CHANGED
@@ -30,6 +30,17 @@ export type PlannerOptions = {
30
30
  * - Cache time-to-live in ms. 0 disables caching.
31
31
  */
32
32
  cacheTTL?: number | undefined;
33
+ /**
34
+ * - Budget hook
35
+ * (mirror of Evaluator's). Forwards the planning call's `usage` to the gate so decomposition spend is
36
+ * visible — without it the plan call is invisible to bareguard's budget (the RLM Family-B meter gap). A
37
+ * cache hit does NOT forward (no LLM call happened).
38
+ */
39
+ onLlmResult?: ((payload: {
40
+ usage: any;
41
+ model: string | null;
42
+ kind: "plan";
43
+ }) => any) | undefined;
33
44
  };
34
45
  export class Planner {
35
46
  /**
@@ -41,10 +52,18 @@ export class Planner {
41
52
  prompt: string;
42
53
  _cacheTTL: number;
43
54
  _cache: Map<any, any>;
55
+ onLlmResult: ((payload: {
56
+ usage: any;
57
+ model: string | null;
58
+ kind: "plan";
59
+ }) => any) | null;
44
60
  /**
45
61
  * Generate a step DAG from a goal.
46
62
  * @param {string} goal - The user's goal to decompose.
47
- * @param {{info?: string}} [context={}] - Optional context with info field.
63
+ * @param {{info?: string, count?: number}} [context={}] - Optional context. `info` is prior
64
+ * context to factor in. `count` (RLM NB-2 seam): when a positive integer, forces the plan to
65
+ * exactly that many INDEPENDENT, parallelizable steps (all `dependsOn: []`) instead of the
66
+ * model's free 2–7 — lets `recurse()` impose the deterministic tier→count for forced fan-out.
48
67
  * @returns {Promise<Step[]>}
49
68
  * @throws {Error} `[Planner] could not parse plan` — when LLM output is not parseable JSON.
50
69
  * @throws {Error} `[Planner] expected JSON array` — when parsed result is not an array.
@@ -52,6 +71,7 @@ export class Planner {
52
71
  */
53
72
  plan(goal: string, context?: {
54
73
  info?: string;
74
+ count?: number;
55
75
  }): Promise<Step[]>;
56
76
  clearCache(): void;
57
77
  /**
package/src/planner.js CHANGED
@@ -15,6 +15,10 @@
15
15
  * @property {Provider} provider - LLM provider (must implement generate()).
16
16
  * @property {string} [prompt] - Custom planning prompt override.
17
17
  * @property {number} [cacheTTL] - Cache time-to-live in ms. 0 disables caching.
18
+ * @property {(payload: {usage: any, model: string|null, kind: 'plan'}) => any} [onLlmResult] - Budget hook
19
+ * (mirror of Evaluator's). Forwards the planning call's `usage` to the gate so decomposition spend is
20
+ * visible — without it the plan call is invisible to bareguard's budget (the RLM Family-B meter gap). A
21
+ * cache hit does NOT forward (no LLM call happened).
18
22
  */
19
23
 
20
24
  const PLAN_PROMPT = `You are a planning agent. Break the user's goal into concrete steps.
@@ -42,28 +46,38 @@ class Planner {
42
46
  this.prompt = options.prompt || PLAN_PROMPT;
43
47
  this._cacheTTL = options.cacheTTL || 0;
44
48
  this._cache = new Map();
49
+ this.onLlmResult = options.onLlmResult || null;
45
50
  }
46
51
 
47
52
  /**
48
53
  * Generate a step DAG from a goal.
49
54
  * @param {string} goal - The user's goal to decompose.
50
- * @param {{info?: string}} [context={}] - Optional context with info field.
55
+ * @param {{info?: string, count?: number}} [context={}] - Optional context. `info` is prior
56
+ * context to factor in. `count` (RLM NB-2 seam): when a positive integer, forces the plan to
57
+ * exactly that many INDEPENDENT, parallelizable steps (all `dependsOn: []`) instead of the
58
+ * model's free 2–7 — lets `recurse()` impose the deterministic tier→count for forced fan-out.
51
59
  * @returns {Promise<Step[]>}
52
60
  * @throws {Error} `[Planner] could not parse plan` — when LLM output is not parseable JSON.
53
61
  * @throws {Error} `[Planner] expected JSON array` — when parsed result is not an array.
54
62
  * @throws {Error} `[Planner] step missing id or action` — when a step lacks required fields.
55
63
  */
56
64
  async plan(goal, context = {}) {
65
+ // NB-2: a forced fan-out count (positive integer only — a 0/NaN/negative falls back to free planning).
66
+ const count = Number.isInteger(context.count) && /** @type {number} */ (context.count) > 0
67
+ ? /** @type {number} */ (context.count) : null;
57
68
  if (this._cacheTTL > 0) {
58
- const cacheKey = JSON.stringify({ goal, info: context.info || '' });
69
+ const cacheKey = JSON.stringify({ goal, info: context.info || '', count });
59
70
  const cached = this._cache.get(cacheKey);
60
71
  if (cached && Date.now() < cached.expiresAt) {
61
72
  return cached.result;
62
73
  }
63
74
  }
64
75
 
76
+ const system = count
77
+ ? `${this.prompt}\n\nOVERRIDE: ignore the "2-7 steps" guidance. Decompose into EXACTLY ${count} independent, parallelizable steps, each with "dependsOn": []. Split the goal into ${count} disjoint slices of comparable size that together cover it with no overlap.`
78
+ : this.prompt;
65
79
  const messages = [
66
- { role: 'system', content: this.prompt },
80
+ { role: 'system', content: system },
67
81
  ];
68
82
  if (context.info) {
69
83
  messages.push({ role: 'user', content: `Context: ${context.info}` });
@@ -75,10 +89,16 @@ class Planner {
75
89
  temperature: 0,
76
90
  });
77
91
 
92
+ // Budget visibility: forward the planning call's usage to the gate (mirror of Evaluator). Only on a real
93
+ // LLM call — a cache hit returned earlier without reaching here, so it never double-counts.
94
+ if (this.onLlmResult) {
95
+ await this.onLlmResult({ usage: result.usage || null, model: result.model || this.provider.model || null, kind: 'plan' });
96
+ }
97
+
78
98
  const steps = this._parse(result.text);
79
99
 
80
100
  if (this._cacheTTL > 0) {
81
- const cacheKey = JSON.stringify({ goal, info: context.info || '' });
101
+ const cacheKey = JSON.stringify({ goal, info: context.info || '', count });
82
102
  this._cache.set(cacheKey, { result: steps, expiresAt: Date.now() + this._cacheTTL });
83
103
  }
84
104
 
@@ -0,0 +1,22 @@
1
+ /**
2
+ * NB-5 — the decomposition-policy system blurb + few-shot. Prepended to a Family-A worker's system prompt so
3
+ * the model has an in-context example of HOW to split before it is offered the `spawn_child` A-tool. Flat-first
4
+ * (§4.2/§8): the model fans out flat over a window-sized batch and only escalates to a nested `spawn_child`
5
+ * when a single sub-task is genuinely too large to handle directly. Worked splits are deliberately small —
6
+ * the lift is from showing the SHAPE of a good split, not from volume.
7
+ * @type {string}
8
+ */
9
+ export const DECOMPOSITION_POLICY: string;
10
+ /**
11
+ * NB-4 / RC-12 — the depth-aware capability-scrub suffix. Deeper workers get a more conservative prompt:
12
+ * "prefer direct action, only delegate if truly necessary." Combined with the inline `canSpawn` check (the
13
+ * tool half of the scrub: `depth < maxDepth` withholds `spawn_child` at the cap) in recurse.js, this realizes
14
+ * guard #5's prompt+tool-shaping half (the part bareguard's blind
15
+ * `policy` cap cannot express). At depth 0 there is no suffix (the top-level worker decomposes freely);
16
+ * from depth 1 on, each level nudges harder toward answering directly so recursion contracts toward its base
17
+ * case rather than fanning out without bound.
18
+ * @param {number} depth - The worker's depth (0 = top level).
19
+ * @param {number} maxDepth - The topology ceiling; at `depth >= maxDepth` the `spawn_child` tool is withheld.
20
+ * @returns {string} A suffix to append to the worker system prompt ('' at depth 0).
21
+ */
22
+ export function capabilityScrub(depth: number, maxDepth: number): string;
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ // NB-5 + NB-4 prompt assets for src/recurse.js (RLM_PRD §4.3). Pure text, ZERO runtime — kept in their own
4
+ // file so the decomposition policy (which the RLM paper Fig 4 shows directly lifts accuracy and the
5
+ // first-split-correct rate) is inspectable and editable without touching the glue. Two assets:
6
+ // - DECOMPOSITION_POLICY (NB-5): the worker's system-prompt blurb + 1-2 worked splits.
7
+ // - capabilityScrub (NB-4 / RC-12): the depth-conservative suffix deeper workers receive.
8
+
9
+ /**
10
+ * NB-5 — the decomposition-policy system blurb + few-shot. Prepended to a Family-A worker's system prompt so
11
+ * the model has an in-context example of HOW to split before it is offered the `spawn_child` A-tool. Flat-first
12
+ * (§4.2/§8): the model fans out flat over a window-sized batch and only escalates to a nested `spawn_child`
13
+ * when a single sub-task is genuinely too large to handle directly. Worked splits are deliberately small —
14
+ * the lift is from showing the SHAPE of a good split, not from volume.
15
+ * @type {string}
16
+ */
17
+ const DECOMPOSITION_POLICY = [
18
+ 'You solve a task by DECOMPOSING it, not by swallowing everything at once.',
19
+ '',
20
+ 'How to decompose:',
21
+ '- Break the task into independent sub-tasks, each small enough to handle in one focused pass.',
22
+ '- Prefer a FLAT split (several sibling sub-tasks) over a deep one. Only nest — split a sub-task again —',
23
+ ' when that single sub-task is itself too large to handle directly.',
24
+ '- When you have a sub-task that is large or independent, delegate it with the `spawn_child` tool: it runs',
25
+ ' in a FRESH context window and returns only its result. Do the small/glue parts yourself.',
26
+ '- After the sub-results come back, COMBINE them into one final answer for the original task.',
27
+ '- If you can answer directly without splitting, just do so — decomposition is for tasks too big for one pass.',
28
+ '',
29
+ 'Worked example 1 (flat split):',
30
+ ' Task: "Summarize the security posture of services A, B, and C."',
31
+ ' Good split: spawn_child("Summarize the security posture of service A"), same for B, same for C,',
32
+ ' then combine the three summaries into one posture report.',
33
+ '',
34
+ 'Worked example 2 (nest only on overflow):',
35
+ ' Task: "Count matching records across a 10,000-line log."',
36
+ ' The log is too large for one pass, so split it into chunks and spawn_child a count for each chunk;',
37
+ ' if a chunk is STILL too large, that child splits again. Sum the per-chunk counts in your final answer.',
38
+ ].join('\n');
39
+
40
+ /**
41
+ * NB-4 / RC-12 — the depth-aware capability-scrub suffix. Deeper workers get a more conservative prompt:
42
+ * "prefer direct action, only delegate if truly necessary." Combined with the inline `canSpawn` check (the
43
+ * tool half of the scrub: `depth < maxDepth` withholds `spawn_child` at the cap) in recurse.js, this realizes
44
+ * guard #5's prompt+tool-shaping half (the part bareguard's blind
45
+ * `policy` cap cannot express). At depth 0 there is no suffix (the top-level worker decomposes freely);
46
+ * from depth 1 on, each level nudges harder toward answering directly so recursion contracts toward its base
47
+ * case rather than fanning out without bound.
48
+ * @param {number} depth - The worker's depth (0 = top level).
49
+ * @param {number} maxDepth - The topology ceiling; at `depth >= maxDepth` the `spawn_child` tool is withheld.
50
+ * @returns {string} A suffix to append to the worker system prompt ('' at depth 0).
51
+ */
52
+ function capabilityScrub(depth, maxDepth) {
53
+ if (depth <= 0) return '';
54
+ if (depth >= maxDepth) {
55
+ return [
56
+ '',
57
+ `DEPTH ${depth} of ${maxDepth} — this is the deepest level. You CANNOT delegate further; there is no`,
58
+ 'spawn tool here. Answer this sub-task DIRECTLY and concisely from what you have. If the sub-task is',
59
+ 'still too large to answer faithfully, say so explicitly rather than guessing — an honest "incomplete"',
60
+ 'is correct; a fabricated answer is not.',
61
+ ].join('\n');
62
+ }
63
+ return [
64
+ '',
65
+ `DEPTH ${depth} of ${maxDepth} — you are already inside a delegated sub-task. PREFER DIRECT ACTION:`,
66
+ 'answer this sub-task yourself if you reasonably can. Only delegate further (spawn_child) when a part of',
67
+ 'it is genuinely too large to handle in one pass. Keep the recursion shallow.',
68
+ ].join('\n');
69
+ }
70
+
71
+ module.exports = { DECOMPOSITION_POLICY, capabilityScrub };
@@ -0,0 +1,174 @@
1
+ export type Slice = {
2
+ /**
3
+ * - Stable, word-like id (no whitespace/commas — it is echoed back by the judge).
4
+ */
5
+ id: string;
6
+ /**
7
+ * - The item content shown to the judge.
8
+ */
9
+ text: string;
10
+ };
11
+ export type Provider = import("../types").Provider;
12
+ export type ToolDef = import("../types").ToolDef;
13
+ /**
14
+ * SCAN — process every slice, LLM-judge each window, union matching ids across windows AND passes, CODE-count
15
+ * the union. The default reliability mechanism (§9.2.1): the only path that does not silently undercount.
16
+ * RC-9: a dead window is recorded in `missingSlices`, never folded into the count as a zero.
17
+ * @param {string} predicate - The task the slices are judged against.
18
+ * @param {Slice[]} corpus - The array slice-source (already validated/normalized by the caller).
19
+ * @param {object} opts
20
+ * @param {Provider} opts.provider
21
+ * @param {number} [opts.window]
22
+ * @param {number} [opts.passes]
23
+ * @param {object} [opts.ctx]
24
+ * @param {Function} [opts.onLlmResult]
25
+ * @param {Function} [opts.policy]
26
+ * @returns {Promise<{matchedIds: string[], count: number, missingSlices: string[], window: number, passes: number, scanned: number}>}
27
+ * @throws {HaltError} a governance cap halted a window judge.
28
+ */
29
+ export function scanCount(predicate: string, corpus: Slice[], opts: {
30
+ provider: Provider;
31
+ window?: number | undefined;
32
+ passes?: number | undefined;
33
+ ctx?: object;
34
+ onLlmResult?: Function | undefined;
35
+ policy?: Function | undefined;
36
+ }): Promise<{
37
+ matchedIds: string[];
38
+ count: number;
39
+ missingSlices: string[];
40
+ window: number;
41
+ passes: number;
42
+ scanned: number;
43
+ }>;
44
+ /**
45
+ * @typedef {object} Slice
46
+ * @property {string} id - Stable, word-like id (no whitespace/commas — it is echoed back by the judge).
47
+ * @property {string} text - The item content shown to the judge.
48
+ */
49
+ /**
50
+ * Judge ONE window: run an isolated Loop with the classify prompt over the window's items, and intersect the
51
+ * returned ids with the ids actually SHOWN this window (RC-2 — a window's judge can only "match" what it was
52
+ * given; a hallucinated id from another window is dropped). A governance HaltError propagates (the caller turns
53
+ * it into a clean incomplete); any other Loop fault marks the window DEAD (→ RC-9 missingSlices), never a
54
+ * silent zero that would undercount.
55
+ * @param {string} predicate
56
+ * @param {Slice[]} window
57
+ * @param {{provider: Provider, ctx?: object, onLlmResult?: Function, policy?: Function, nonce: number}} opts
58
+ * @returns {Promise<{ids: string[]|null, dead: boolean}>}
59
+ * @throws {HaltError}
60
+ */
61
+ export function judgeWindow(predicate: string, window: Slice[], opts: {
62
+ provider: Provider;
63
+ ctx?: object;
64
+ onLlmResult?: Function;
65
+ policy?: Function;
66
+ nonce: number;
67
+ }): Promise<{
68
+ ids: string[] | null;
69
+ dead: boolean;
70
+ }>;
71
+ /**
72
+ * The §9.2-validated classify system prompt, GENERALIZED from the POC's hardcoded "SPORTS news" predicate to
73
+ * an arbitrary one. The load-bearing wording is verbatim ("Examine EACH item individually", "Output ONLY the
74
+ * IDs", "Comma-separated. If none, output 'none'. No count, no prose.") — the predicate is the only variable.
75
+ * It MUST return ids, not a count: counting is CODE's job (RC-5 / §9.1), so the judge never does arithmetic.
76
+ * @param {string} predicate - The task/goal the items are judged against.
77
+ * @returns {string}
78
+ */
79
+ export function classifySystem(predicate: string): string;
80
+ /**
81
+ * Does this text imply a completeness ("all / every / count / how many") ask? Used to upgrade a capped search
82
+ * to a scan (the only complete path). Conservative by design — a false positive only costs a thorough scan.
83
+ * @param {unknown} text
84
+ * @returns {boolean}
85
+ */
86
+ export function impliesCompleteness(text: unknown): boolean;
87
+ /**
88
+ * Normalize a raw slice-source into validated `{id, text}` slices (drops malformed entries — a slice with no
89
+ * string id/text cannot be judged or counted, so it is excluded rather than silently miscounted).
90
+ * @param {unknown} corpus
91
+ * @returns {Slice[]}
92
+ */
93
+ export function normalizeCorpus(corpus: unknown): Slice[];
94
+ /**
95
+ * The `search` handle tool (RC-5 needle path): litectx `recall` — embeddings ON, `fact`/`episode` (the
96
+ * KNN-nominate kinds), capped at `KNN_K`. For FINDING the relevant few; the description tells the worker it
97
+ * CANNOT count (the completeness guard catches a "how many" ask before this tool is ever offered). Returns the
98
+ * matched items' bodies as text the worker reads; never the whole corpus.
99
+ * @param {{recall: Function}} litectx
100
+ * @param {{kinds?: string[], n?: number}} [opts]
101
+ * @returns {ToolDef}
102
+ */
103
+ export function buildSearchTool(litectx: {
104
+ recall: Function;
105
+ }, opts?: {
106
+ kinds?: string[];
107
+ n?: number;
108
+ }): ToolDef;
109
+ /**
110
+ * The `exact` handle tool (RC-5 rule path): a deterministic, embeddings-free code-side predicate filter over
111
+ * the slice-source — returns every record whose text contains ALL given terms (case-insensitive AND). This is
112
+ * the "code-side predicate filter" half of §9.2.1; it is complete over the slices it is given (no recall cap)
113
+ * but only as good as a lexical rule. (FTS-AND over a litectx instance is the alternative, but needs embeddings
114
+ * OFF to stay exact — deferred; the code-side filter is the embeddings-free path shipped now.)
115
+ * @param {Slice[]} corpus - The validated slice-source.
116
+ * @returns {ToolDef}
117
+ */
118
+ export function buildExactTool(corpus: Slice[]): ToolDef;
119
+ /**
120
+ * The `scan` handle tool (RC-5 COMPLETE path, the per-query Family-A face of §10 step 7) — the deterministic
121
+ * counterpart to `search`/`exact` as a TOOL a worker may call per sub-query. Where `search_memory` returns the
122
+ * top FEW (capped, cannot count) and `exact_match` is a lexical rule, `scan_count` runs the full §9.2.1 scan
123
+ * (`scanCount`) over EVERY record and returns an exact, CODE-counted total — the only tool that does not silently
124
+ * undercount. The completeness routing lives in the DESCRIPTIONS, not a code-guard: this tool says "use for how
125
+ * many / all / count"; `search_memory` says "never use to count" — so a worker picks the complete path per
126
+ * sub-query (the shape can differ per sub-query with no adopter declaration). RC-9 honesty is preserved at the
127
+ * tool boundary: a dead window surfaces as an explicit `INCOMPLETE — the count is a floor`, never a clean number
128
+ * over a hole. A governance `HaltError` from the inner scan PROPAGATES (the Loop turns it into a clean halt —
129
+ * never wrapped to a `ToolError`).
130
+ * @param {Slice[] | (() => Promise<Slice[]>)} corpus - The slice-source (array or async, like `litectxCorpus`);
131
+ * materialized lazily on first call and cached for the tool's lifetime.
132
+ * @param {{provider: Provider, window?: number, passes?: number, ctx?: object, onLlmResult?: Function, policy?: Function}} opts
133
+ * @returns {ToolDef}
134
+ */
135
+ export function buildScanTool(corpus: Slice[] | (() => Promise<Slice[]>), opts: {
136
+ provider: Provider;
137
+ window?: number;
138
+ passes?: number;
139
+ ctx?: object;
140
+ onLlmResult?: Function;
141
+ policy?: Function;
142
+ }): ToolDef;
143
+ /**
144
+ * Build a SCAN slice-source backed by a litectx-RESIDENT corpus (facts/episodes the agent already accrued) —
145
+ * the §10-step-7 deferral un-blocked by litectx 0.26's `enumerate` verb (spec:
146
+ * docs/01-product/litectx-enumerate-spec.md). Returns the generic async slice-source recurse's scan reads: a
147
+ * `() => Promise<Slice[]>` that pages through EVERY row of `kind` via `enumerate` (exhaustive — the rank-free
148
+ * read `recall` structurally cannot do) and maps each to `{id: item.path, text: item.body}`. recurse stays
149
+ * litectx-agnostic: it depends on this source SHAPE, never on litectx (same stance as `remember`'s Store
150
+ * socket) — an adopter can hand any `() => Promise<Slice[]>` (a DB, a file, an API) instead.
151
+ *
152
+ * Only for a corpus ALREADY in litectx for its own reasons — never ingest a fresh corpus just to enumerate it
153
+ * back (strictly worse than scanning the in-hand array; spec §1.1).
154
+ * @param {{enumerate: Function}} litectx
155
+ * @param {{kind?: 'fact'|'episode', pageSize?: number}} [opts]
156
+ * @returns {() => Promise<Slice[]>}
157
+ */
158
+ export function litectxCorpus(litectx: {
159
+ enumerate: Function;
160
+ }, opts?: {
161
+ kind?: "fact" | "episode";
162
+ pageSize?: number;
163
+ }): () => Promise<Slice[]>;
164
+ /**
165
+ * Deterministic rotation by `k` — the shuffled-boundary mechanism for multi-pass union WITHOUT an RNG (keeps
166
+ * RC-3 determinism: same corpus + same passes ⇒ identical scan). Rotating the array changes which items share a
167
+ * window (and each item's within-window position), so an item under-recalled at one window's tail in pass 0
168
+ * lands mid-window in pass 1 — the §9.2.1 mechanism that lifts recall ~0.85 → ~0.93.
169
+ * @template T @param {T[]} arr @param {number} k @returns {T[]}
170
+ */
171
+ export function rotate<T>(arr: T[], k: number): T[];
172
+ export const SCAN_WINDOW: 8;
173
+ export const SCAN_PASSES: 2;
174
+ export const KNN_K: 8;