bare-agent 0.29.0 → 0.30.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
@@ -94,7 +94,7 @@ Every piece works alone — take what you need, ignore the rest. Two axes: **Act
94
94
 
95
95
  ### Recurse — break a hard task into a tree *(the RLM primitive)*
96
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. Give workers a stance with `opts.persona` (prepended to every worker, carries down the tree, deliberately kept out of the isolated verifier), and tell them *where they are* with `opts.context` (a read-only paths/cwd blob threaded to every worker so a sliced child can locate its artifact — facts, not a stance). For a leaf that should self-correct, pass `opts.refineLeaf` (opt-in): a definite leaf becomes a bounded generate→sense→regenerate loop driven by *your* deterministic sensor (test/compile/lint), feeding the gap back (with escalating temperature on models that accept it; on a temperature-fixed model like `claude-sonnet-5` the gap critique carries recovery, and the receipt records the effective temps). 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.
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. Give workers a stance with `opts.persona` (prepended to every worker, carries down the tree, deliberately kept out of the isolated verifier), and tell them *where they are* with `opts.context` (a read-only paths/cwd blob threaded to every worker so a sliced child can locate its artifact — facts, not a stance). For a leaf that should self-correct, pass `opts.refineLeaf` (opt-in): a definite leaf becomes a bounded generate→sense→regenerate loop driven by *your* deterministic sensor (test/compile/lint — one that judges the *returned* result, never a worker side-effect it could game), feeding the gap back (with escalating temperature on models that accept it; on a temperature-fixed model like `claude-sonnet-5` the gap critique carries recovery, and the receipt records the effective temps). On a temperature-fixed model, `refineLeaf.rejectedBuffer` adds a second lever — it feeds the model's own prior *failed attempts* back verbatim ("write something structurally different"), the directed-diversity complement to temperature's random diversity (adaptive by default; the two are antagonistic, so it holds temperature flat when it engages). 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
98
 
99
99
  Over a corpus, context reaches a worker as a **handle routed by question shape** (`opts.retrieval`):
100
100
 
@@ -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.29.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
4
+ > v0.30.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.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
 
@@ -38,7 +38,7 @@ Eight entry points:
38
38
  | Count / answer "how many / all" over a corpus, honestly | recurse(task, ctx, `{ corpus, retrieval: 'scan' }`) — scans every slice, CODE-counts |
39
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 |
40
40
  | Tell recurse workers WHERE they are (paths/cwd) so a slice can find its file | recurse(task, ctx, `{ context }`) — read-only blob on every worker's task message + the Planner + verifier; carries down (facts, not a stance) |
41
- | Let a recurse LEAF retry its own failure with a deterministic check | recurse(task, ctx, `{ refineLeaf: { sensor } }`) — leaf becomes a bounded generate→sense→regenerate loop; your sensor (test/compile/lint), gap fed back, escalating temperature |
41
+ | Let a recurse LEAF retry its own failure with a deterministic check | recurse(task, ctx, `{ refineLeaf: { sensor } }`) — leaf becomes a bounded generate→sense→regenerate loop; your sensor (test/compile/lint) judges the RETURNED result, gap fed back, escalating temperature; `rejectedBuffer` feeds prior failed attempts back on a temp-fixed model |
42
42
  | Track task state (pending/running/done/failed) | StateMachine |
43
43
  | Run agent turns on a schedule (cron, timers) | Scheduler |
44
44
  | Require human approval before dangerous actions | Checkpoint |
@@ -708,7 +708,11 @@ const out = await recurse('Audit auth.js, billing.js, gateway.js for authz bugs'
708
708
 
709
709
  **Worker context (`opts.context`, v0.23.0):** a read-only working-context string (paths/cwd) PREPENDED to every worker's TASK message as a `Working context:` block — so a sliced child can **locate its artifact** (the Planner paraphrases the goal into subtasks and drops absolute paths; without this, workers guess `.`/`~`/`/tmp` and get denied). Forwarded to the Planner as `info` (path-aware slices) and shown to the verifier too (neutral FACTS, not a stance — distinct from `persona`, which is a privileged SYSTEM-prompt stance). Carries down the tree. **Security:** it still becomes part of the prompt, so pass caller-trusted run-state only, never untrusted/end-user text (lower-privilege than `persona` — user message, not system — but still an injection surface). Absent ⇒ the task message is unchanged.
710
710
 
711
- **Leaf self-correction (`opts.refineLeaf`, v0.23.0, opt-in):** turn a **definite leaf** (a node offered no `spawn_child` — `simple` tier or at `maxDepth`) into a bounded generate→sense→regenerate loop instead of a single pass: `{ sensor, maxIterations?, temperatures? }`. `sensor(result, { task, context, contract }) → Verdict` is YOUR **deterministic** close (test/compile/lint — not a model judge); on a non-pass its `critique` (the gap, not the transcript) is fed FRESH into the next attempt and — **on models that accept `temperature`** — the **retry temperature ESCALATES** (default `[0.2, 0.7, 1.0]` — load-bearing there: a weak model at a flat temperature regenerates identical wrong code and ignores even crisp feedback). On a **temperature-fixed model** (e.g. `claude-sonnet-5`, which 400s any non-default temperature) the provider silently drops the param (see below), the escalation lever is inert, and the fed-back gap critique carries recovery alone; `receipts.refineLeaf.temperatures` then records the EFFECTIVE temps — a `null` marks an attempt that ran at the model's default (never the ignored requested value). Each attempt is gate-checked + metered; a HaltError mid-loop → clean `{ incomplete }`; honest non-recovery → `receipts.refineLeaf.passed === false` (never a faked pass); `receipts.tokens` sums all attempts. The error-keyed `recall` stays YOUR tool (`opts.tools`), keyed off the fed-back critique — bareagent stays litectx-agnostic. Carries down (engages at the leaves). Absent ⇒ a leaf is a single pass.
711
+ **Leaf self-correction (`opts.refineLeaf`, v0.23.0, opt-in):** turn a **definite leaf** (a node offered no `spawn_child` — `simple` tier or at `maxDepth`) into a bounded generate→sense→regenerate loop instead of a single pass: `{ sensor, maxIterations?, temperatures?, rejectedBuffer? }`. `sensor(result, { task, context, contract }) → Verdict` is YOUR **deterministic** close (test/compile/lint — not a model judge); on a non-pass its `critique` (the gap, not the transcript) is fed FRESH into the next attempt and — **on models that accept `temperature`** — the **retry temperature ESCALATES** (default `[0.2, 0.7, 1.0]` — load-bearing there: a weak model at a flat temperature regenerates identical wrong code and ignores even crisp feedback). On a **temperature-fixed model** (e.g. `claude-sonnet-5`, which 400s any non-default temperature) the provider silently drops the param (see below), the escalation lever is inert, and the fed-back gap critique carries recovery alone; `receipts.refineLeaf.temperatures` then records the EFFECTIVE temps — a `null` marks an attempt that ran at the model's default (never the ignored requested value). Each attempt is gate-checked + metered; a HaltError mid-loop → clean `{ incomplete }`; honest non-recovery → `receipts.refineLeaf.passed === false` (never a faked pass); `receipts.tokens` sums all attempts. The error-keyed `recall` stays YOUR tool (`opts.tools`), keyed off the fed-back critique — bareagent stays litectx-agnostic. Carries down (engages at the leaves). Absent ⇒ a leaf is a single pass.
712
+
713
+ > **Sensor integrity (`refineLeaf.sensor`):** the sensor must judge the **returned result** (tamper-proof — build/run the returned string in isolation), never a worker **side-effect** a worker with edit tools could game (writing a passing file then returning junk, or editing the failing test itself). The loop optimizes against whatever the sensor reads — keep the close outside what the worker can write. (RSI field lesson: reward-hacking appeared in every optimization loop with a gameable close.)
714
+
715
+ > **`rejectedBuffer` (BA-14, v0.30.0):** a second lever for a **temperature-fixed** model, where escalation is inert. Instead of only the latest critique, it surfaces the model's OWN prior failed attempts VERBATIM — *"you wrote these, they failed X — write something STRUCTURALLY DIFFERENT."* This is **directed** diversity (attack the specific repeated mistake); escalation is **random** diversity, and the two are **antagonistic** — temperature monotonically degrades the buffer (`poc/ba14b`: flat-0.2 100% → 0.7 70% → 1.0 50%), so when the buffer engages the retry temperature is **held flat** at `temperatures[0]`, never escalated. Trigger: `true` = force on (also on temperature-accepting models); `false` = force off (pure BA-8 escalation); **unset = adaptive** — engage only once a prior attempt's temperature was dropped (i.e. a temp-fixed model where escalation is inert and the buffer is the sole lever). On a temperature-accepting model the default leaves behavior byte-identical. `receipts.refineLeaf.rejectedBuffer` reports whether it engaged. Efficacy is a **weak-model / fixation** phenomenon (live on `claude-sonnet-5` it engaged 6/6 but recovered no better than critique-only — cost-neutral, hence adaptive-not-always-on).
712
716
 
713
717
  ```javascript
714
718
  const out = await recurse('Fix the failing function in calc.js', ctx, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
package/src/loop.js CHANGED
@@ -701,6 +701,11 @@ class Loop {
701
701
  const generate = () => this.provider.generate(toSend, activeTools, options);
702
702
  result = this.retry ? await this.retry.call(generate) : await generate();
703
703
  } catch (err) {
704
+ // A HaltError is a governance exit, not a provider failure — re-throw it (like every other seam) so it
705
+ // reaches the outer handler, which seals dangling tool_calls and returns error:`halt:<rule>`. Without
706
+ // this, throwOnError:false laundered a provider-surfaced HaltError into a generic `error:<message>`,
707
+ // indistinguishable from a real fault (the retry never retries it — DEFAULT_RETRY_ON is false for it).
708
+ if (err instanceof HaltError) throw err;
704
709
  this._reportError('provider', err, { round });
705
710
  if (this.throwOnError) throw err;
706
711
  // BA-5: a mid-run provider failure must not erase the work of the rounds that succeeded.
package/src/recurse.d.ts CHANGED
@@ -94,13 +94,29 @@ export type RecurseOptions = {
94
94
  * (`temperatures`, default `[0.2,0.7,1.0]`) — the live-validated lever that lets a weak model escape a
95
95
  * repeat-the-same-mistake rut (`poc/ba8-leaf-refine.mjs`: 0/5 → 2-3/5; flat temp recovers 0/5). On a
96
96
  * temperature-fixed model (BA-10) the provider drops the param, `receipts.refineLeaf.temperatures` records
97
- * `null`, and the fed-back gap critique carries recovery alone. `maxIterations` defaults to
97
+ * `null`, and the fed-back gap critique carries recovery alone UNLESS the rejected-attempt buffer engages
98
+ * (below). `maxIterations` defaults to
98
99
  * `temperatures.length`; the REAL bound is bareguard (each attempt is gate-checked + metered). CARRIES DOWN the
99
100
  * tree (preserved by `forChild`), so it engages at the leaves of a Family-A decomposition. Recovery is PARTIAL
100
101
  * (a stubborn blind spot may persist) — `receipts.refineLeaf.passed` reports honestly. Does NOT apply to a node
101
102
  * that delegates (its children + the tree verify own quality), nor to the scan/fanout/partition dispatch paths.
102
103
  * Absent ⇒ a leaf is a single pass (byte-identical to pre-BA-8). An error-keyed `recall` is the CALLER's tool
103
104
  * (`opts.tools`) keyed off the fed-back critique — bareagent stays litectx-agnostic.
105
+ * **Sensor integrity (RSI-learnings #1/#5, "audit the close"):** the `sensor` MUST judge the RETURNED result
106
+ * (tamper-proof — e.g. build/run the returned string in an isolated context, as `poc/ba8-leaf-refine.mjs` does),
107
+ * NEVER a worker side-effect a worker with edit tools could GAME (writing a passing file then returning junk, or
108
+ * editing the failing test itself). A gameable close is the reward-hacking surface every RSI system in the field
109
+ * got bitten by; the loop optimizes against WHATEVER the sensor reads, so keep it outside what the worker can write.
110
+ * **`rejectedBuffer` (BA-14):** a SkillOpt-shaped rejected-attempt buffer — instead of only the LATEST critique,
111
+ * surface the model's OWN prior failed attempts verbatim ("you wrote these, they failed X — write something
112
+ * STRUCTURALLY DIFFERENT"). This is DIRECTED diversity (attack the specific repeated mistake), where escalation
113
+ * is RANDOM diversity; the two are ANTAGONISTIC (`poc/ba14b-temp-with-buffer.mjs`: temperature monotonically
114
+ * degrades the buffer, 100%→70%→50% across 0.2/0.7/1.0), so when the buffer engages the retry temperature is
115
+ * HELD at `temperatures[0]` (flat-low), never escalated. `true` = force on (also on temperature-accepting
116
+ * models); `false` = force off (pure BA-8 escalation); UNSET = ADAPTIVE — engage only once a prior attempt's
117
+ * temperature was dropped (a temperature-fixed model, BA-10, where escalation is inert and the buffer is the
118
+ * sole lever — `poc/ba14-rejected-buffer.mjs`: flat-temp 50%→100%). `receipts.refineLeaf.rejectedBuffer`
119
+ * reports whether any iteration injected it. Bounded by `maxIterations` (the buffer never outgrows it).
104
120
  */
105
121
  refineLeaf?: {
106
122
  sensor: (result: any, ctx: {
@@ -110,6 +126,7 @@ export type RecurseOptions = {
110
126
  }) => (Verdict | Promise<Verdict>);
111
127
  maxIterations?: number;
112
128
  temperatures?: number[];
129
+ rejectedBuffer?: boolean;
113
130
  } | undefined;
114
131
  /**
115
132
  * - Definition of done (A3). When present, the verifier grades against THIS,
@@ -225,15 +242,20 @@ export type RecurseNode = {
225
242
  */
226
243
  tokens: object | null;
227
244
  /**
228
- * - (BA-8) when this
229
- * leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
245
+ * - (BA-8) when
246
+ * this leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
230
247
  * passed (false = honest non-recovery, not a faked success). `temperatures` are the EFFECTIVE per-attempt temps
231
248
  * (BA-10): a `null` marks an attempt the model ran at its DEFAULT because it rejected the requested temperature.
249
+ * `rejectedBuffer` (BA-14): whether any iteration injected the rejected-attempt buffer (prior failed attempts
250
+ * surfaced verbatim); when it engages the REQUESTED retry temperature is held flat at `temperatures[0]`, never
251
+ * escalated (ba14b antagonism) — but a temperature-fixed model still drops it, so the EFFECTIVE temp recorded
252
+ * above is `null`, not `temperatures[0]`.
232
253
  */
233
254
  refineLeaf?: {
234
255
  iterations: number;
235
256
  passed: boolean;
236
257
  temperatures: (number | null)[];
258
+ rejectedBuffer: boolean;
237
259
  } | undefined;
238
260
  model: string | null;
239
261
  /**
@@ -362,7 +384,7 @@ export type Slice = {
362
384
  * TRUSTED run-state (paths/cwd); do NOT pass untrusted / end-user-controlled text here.
363
385
  * @property {ToolDef[]} [tools] - Handle tools offered to EVERY worker (RC-5 pull-default: litectx
364
386
  * `recall`/`get`, wired at build step 7). Workers query on demand; never the whole corpus.
365
- * @property {{sensor: (result: any, ctx: {task: string, context: string|undefined, contract: string|null}) => (Verdict|Promise<Verdict>), maxIterations?: number, temperatures?: number[]}} [refineLeaf]
387
+ * @property {{sensor: (result: any, ctx: {task: string, context: string|undefined, contract: string|null}) => (Verdict|Promise<Verdict>), maxIterations?: number, temperatures?: number[], rejectedBuffer?: boolean}} [refineLeaf]
366
388
  * (Opt-in, BA-8 / relayfact F17) Turn a DEFINITE LEAF (a node that is offered no `spawn_child` — `simple`
367
389
  * tier or at `maxDepth`) into a bounded generate→sense→regenerate loop instead of a single pass, so a failed
368
390
  * slice can self-correct. `sensor` is a DETERMINISTIC close (test/compile/lint — NOT a model judge, R-S8) that
@@ -371,13 +393,29 @@ export type Slice = {
371
393
  * (`temperatures`, default `[0.2,0.7,1.0]`) — the live-validated lever that lets a weak model escape a
372
394
  * repeat-the-same-mistake rut (`poc/ba8-leaf-refine.mjs`: 0/5 → 2-3/5; flat temp recovers 0/5). On a
373
395
  * temperature-fixed model (BA-10) the provider drops the param, `receipts.refineLeaf.temperatures` records
374
- * `null`, and the fed-back gap critique carries recovery alone. `maxIterations` defaults to
396
+ * `null`, and the fed-back gap critique carries recovery alone UNLESS the rejected-attempt buffer engages
397
+ * (below). `maxIterations` defaults to
375
398
  * `temperatures.length`; the REAL bound is bareguard (each attempt is gate-checked + metered). CARRIES DOWN the
376
399
  * tree (preserved by `forChild`), so it engages at the leaves of a Family-A decomposition. Recovery is PARTIAL
377
400
  * (a stubborn blind spot may persist) — `receipts.refineLeaf.passed` reports honestly. Does NOT apply to a node
378
401
  * that delegates (its children + the tree verify own quality), nor to the scan/fanout/partition dispatch paths.
379
402
  * Absent ⇒ a leaf is a single pass (byte-identical to pre-BA-8). An error-keyed `recall` is the CALLER's tool
380
403
  * (`opts.tools`) keyed off the fed-back critique — bareagent stays litectx-agnostic.
404
+ * **Sensor integrity (RSI-learnings #1/#5, "audit the close"):** the `sensor` MUST judge the RETURNED result
405
+ * (tamper-proof — e.g. build/run the returned string in an isolated context, as `poc/ba8-leaf-refine.mjs` does),
406
+ * NEVER a worker side-effect a worker with edit tools could GAME (writing a passing file then returning junk, or
407
+ * editing the failing test itself). A gameable close is the reward-hacking surface every RSI system in the field
408
+ * got bitten by; the loop optimizes against WHATEVER the sensor reads, so keep it outside what the worker can write.
409
+ * **`rejectedBuffer` (BA-14):** a SkillOpt-shaped rejected-attempt buffer — instead of only the LATEST critique,
410
+ * surface the model's OWN prior failed attempts verbatim ("you wrote these, they failed X — write something
411
+ * STRUCTURALLY DIFFERENT"). This is DIRECTED diversity (attack the specific repeated mistake), where escalation
412
+ * is RANDOM diversity; the two are ANTAGONISTIC (`poc/ba14b-temp-with-buffer.mjs`: temperature monotonically
413
+ * degrades the buffer, 100%→70%→50% across 0.2/0.7/1.0), so when the buffer engages the retry temperature is
414
+ * HELD at `temperatures[0]` (flat-low), never escalated. `true` = force on (also on temperature-accepting
415
+ * models); `false` = force off (pure BA-8 escalation); UNSET = ADAPTIVE — engage only once a prior attempt's
416
+ * temperature was dropped (a temperature-fixed model, BA-10, where escalation is inert and the buffer is the
417
+ * sole lever — `poc/ba14-rejected-buffer.mjs`: flat-temp 50%→100%). `receipts.refineLeaf.rejectedBuffer`
418
+ * reports whether any iteration injected it. Bounded by `maxIterations` (the buffer never outgrows it).
381
419
  * @property {string} [contract] - Definition of done (A3). When present, the verifier grades against THIS,
382
420
  * not the loose task, and verification always runs.
383
421
  * @property {(result: any, ctx: {contract: string|null, task: string}) => (Verdict|Promise<Verdict>)} [evaluate]
@@ -438,10 +476,14 @@ export type Slice = {
438
476
  * @property {string} [blocker] - (BA-11) set to `'governance-deny'` when this node stopped because its Loop
439
477
  * short-circuited a consecutive-policy-deny spin (not a model failure). Mirrors `RecurseResult.blocker`.
440
478
  * @property {object|null} tokens - The worker Loop's `metrics.tokens`.
441
- * @property {{iterations: number, passed: boolean, temperatures: (number|null)[]}} [refineLeaf] - (BA-8) when this
442
- * leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
479
+ * @property {{iterations: number, passed: boolean, temperatures: (number|null)[], rejectedBuffer: boolean}} [refineLeaf] - (BA-8) when
480
+ * this leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
443
481
  * passed (false = honest non-recovery, not a faked success). `temperatures` are the EFFECTIVE per-attempt temps
444
482
  * (BA-10): a `null` marks an attempt the model ran at its DEFAULT because it rejected the requested temperature.
483
+ * `rejectedBuffer` (BA-14): whether any iteration injected the rejected-attempt buffer (prior failed attempts
484
+ * surfaced verbatim); when it engages the REQUESTED retry temperature is held flat at `temperatures[0]`, never
485
+ * escalated (ba14b antagonism) — but a temperature-fixed model still drops it, so the EFFECTIVE temp recorded
486
+ * above is `null`, not `temperatures[0]`.
445
487
  * @property {string|null} model
446
488
  * @property {string|null} [retrieval] - (§10 step 7) the retrieval mode this node ran (`scan`/`search`/`exact`),
447
489
  * or null/absent for a plain reasoning node.
package/src/recurse.js CHANGED
@@ -216,7 +216,7 @@ function auditSafeCtx(ctx, overrides = {}) {
216
216
  * TRUSTED run-state (paths/cwd); do NOT pass untrusted / end-user-controlled text here.
217
217
  * @property {ToolDef[]} [tools] - Handle tools offered to EVERY worker (RC-5 pull-default: litectx
218
218
  * `recall`/`get`, wired at build step 7). Workers query on demand; never the whole corpus.
219
- * @property {{sensor: (result: any, ctx: {task: string, context: string|undefined, contract: string|null}) => (Verdict|Promise<Verdict>), maxIterations?: number, temperatures?: number[]}} [refineLeaf]
219
+ * @property {{sensor: (result: any, ctx: {task: string, context: string|undefined, contract: string|null}) => (Verdict|Promise<Verdict>), maxIterations?: number, temperatures?: number[], rejectedBuffer?: boolean}} [refineLeaf]
220
220
  * (Opt-in, BA-8 / relayfact F17) Turn a DEFINITE LEAF (a node that is offered no `spawn_child` — `simple`
221
221
  * tier or at `maxDepth`) into a bounded generate→sense→regenerate loop instead of a single pass, so a failed
222
222
  * slice can self-correct. `sensor` is a DETERMINISTIC close (test/compile/lint — NOT a model judge, R-S8) that
@@ -225,13 +225,29 @@ function auditSafeCtx(ctx, overrides = {}) {
225
225
  * (`temperatures`, default `[0.2,0.7,1.0]`) — the live-validated lever that lets a weak model escape a
226
226
  * repeat-the-same-mistake rut (`poc/ba8-leaf-refine.mjs`: 0/5 → 2-3/5; flat temp recovers 0/5). On a
227
227
  * temperature-fixed model (BA-10) the provider drops the param, `receipts.refineLeaf.temperatures` records
228
- * `null`, and the fed-back gap critique carries recovery alone. `maxIterations` defaults to
228
+ * `null`, and the fed-back gap critique carries recovery alone UNLESS the rejected-attempt buffer engages
229
+ * (below). `maxIterations` defaults to
229
230
  * `temperatures.length`; the REAL bound is bareguard (each attempt is gate-checked + metered). CARRIES DOWN the
230
231
  * tree (preserved by `forChild`), so it engages at the leaves of a Family-A decomposition. Recovery is PARTIAL
231
232
  * (a stubborn blind spot may persist) — `receipts.refineLeaf.passed` reports honestly. Does NOT apply to a node
232
233
  * that delegates (its children + the tree verify own quality), nor to the scan/fanout/partition dispatch paths.
233
234
  * Absent ⇒ a leaf is a single pass (byte-identical to pre-BA-8). An error-keyed `recall` is the CALLER's tool
234
235
  * (`opts.tools`) keyed off the fed-back critique — bareagent stays litectx-agnostic.
236
+ * **Sensor integrity (RSI-learnings #1/#5, "audit the close"):** the `sensor` MUST judge the RETURNED result
237
+ * (tamper-proof — e.g. build/run the returned string in an isolated context, as `poc/ba8-leaf-refine.mjs` does),
238
+ * NEVER a worker side-effect a worker with edit tools could GAME (writing a passing file then returning junk, or
239
+ * editing the failing test itself). A gameable close is the reward-hacking surface every RSI system in the field
240
+ * got bitten by; the loop optimizes against WHATEVER the sensor reads, so keep it outside what the worker can write.
241
+ * **`rejectedBuffer` (BA-14):** a SkillOpt-shaped rejected-attempt buffer — instead of only the LATEST critique,
242
+ * surface the model's OWN prior failed attempts verbatim ("you wrote these, they failed X — write something
243
+ * STRUCTURALLY DIFFERENT"). This is DIRECTED diversity (attack the specific repeated mistake), where escalation
244
+ * is RANDOM diversity; the two are ANTAGONISTIC (`poc/ba14b-temp-with-buffer.mjs`: temperature monotonically
245
+ * degrades the buffer, 100%→70%→50% across 0.2/0.7/1.0), so when the buffer engages the retry temperature is
246
+ * HELD at `temperatures[0]` (flat-low), never escalated. `true` = force on (also on temperature-accepting
247
+ * models); `false` = force off (pure BA-8 escalation); UNSET = ADAPTIVE — engage only once a prior attempt's
248
+ * temperature was dropped (a temperature-fixed model, BA-10, where escalation is inert and the buffer is the
249
+ * sole lever — `poc/ba14-rejected-buffer.mjs`: flat-temp 50%→100%). `receipts.refineLeaf.rejectedBuffer`
250
+ * reports whether any iteration injected it. Bounded by `maxIterations` (the buffer never outgrows it).
235
251
  * @property {string} [contract] - Definition of done (A3). When present, the verifier grades against THIS,
236
252
  * not the loose task, and verification always runs.
237
253
  * @property {(result: any, ctx: {contract: string|null, task: string}) => (Verdict|Promise<Verdict>)} [evaluate]
@@ -293,10 +309,14 @@ function auditSafeCtx(ctx, overrides = {}) {
293
309
  * @property {string} [blocker] - (BA-11) set to `'governance-deny'` when this node stopped because its Loop
294
310
  * short-circuited a consecutive-policy-deny spin (not a model failure). Mirrors `RecurseResult.blocker`.
295
311
  * @property {object|null} tokens - The worker Loop's `metrics.tokens`.
296
- * @property {{iterations: number, passed: boolean, temperatures: (number|null)[]}} [refineLeaf] - (BA-8) when this
297
- * leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
312
+ * @property {{iterations: number, passed: boolean, temperatures: (number|null)[], rejectedBuffer: boolean}} [refineLeaf] - (BA-8) when
313
+ * this leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
298
314
  * passed (false = honest non-recovery, not a faked success). `temperatures` are the EFFECTIVE per-attempt temps
299
315
  * (BA-10): a `null` marks an attempt the model ran at its DEFAULT because it rejected the requested temperature.
316
+ * `rejectedBuffer` (BA-14): whether any iteration injected the rejected-attempt buffer (prior failed attempts
317
+ * surfaced verbatim); when it engages the REQUESTED retry temperature is held flat at `temperatures[0]`, never
318
+ * escalated (ba14b antagonism) — but a temperature-fixed model still drops it, so the EFFECTIVE temp recorded
319
+ * above is `null`, not `temperatures[0]`.
300
320
  * @property {string|null} model
301
321
  * @property {string|null} [retrieval] - (§10 step 7) the retrieval mode this node ran (`scan`/`search`/`exact`),
302
322
  * or null/absent for a plain reasoning node.
@@ -608,10 +628,25 @@ async function recurse(task, ctx = {}, opts = {}) {
608
628
  async function recurseRefineLeaf(task, ctx, opts, state) {
609
629
  const { provider, system, handleTools, depth, critical, node, sensor } = state;
610
630
  node.model = provider.model || null;
611
- const cfg = /** @type {{maxIterations?: number, temperatures?: number[]}} */ (opts.refineLeaf || {});
631
+ const cfg = /** @type {{maxIterations?: number, temperatures?: number[], rejectedBuffer?: boolean}} */ (opts.refineLeaf || {});
612
632
  const temps = Array.isArray(cfg.temperatures) && cfg.temperatures.length ? cfg.temperatures : DEFAULT_REFINE_TEMPS;
613
633
  const maxIterations = Number.isInteger(cfg.maxIterations) && /** @type {number} */ (cfg.maxIterations) > 0
614
634
  ? /** @type {number} */ (cfg.maxIterations) : temps.length;
635
+ // BA-14 rejected-attempt buffer: surface the model's OWN prior failed attempts verbatim ("you wrote these,
636
+ // they failed X — write something DIFFERENT") — a SkillOpt-shaped directed-diversity lever. `rejectedBuffer`:
637
+ // `true` = force on (also on temperature-accepting models); `false` = force off; unset = ADAPTIVE (engage only
638
+ // once a prior attempt's temperature was DROPPED, i.e. a temperature-fixed model where BA-8 escalation is inert
639
+ // and the buffer is the only lever — ba14 D>C). Escalation and the buffer are ANTAGONISTIC (ba14b: temp
640
+ // monotonically degrades the buffer 100→70→50% across 0.2/0.7/1.0), so when the buffer engages we HOLD temps[0].
641
+ const bufferForced = cfg.rejectedBuffer === true;
642
+ const bufferDisabled = cfg.rejectedBuffer === false;
643
+ let bufferUsed = false; // receipt: did any iteration actually inject the ledger?
644
+ const LEDGER_ENTRY_CAP = 600, LEDGER_WHY_CAP = 400;
645
+ const formatLedger = (/** @type {Array<{result: any, verdict: any}>} */ history) => history.map((h, i) => {
646
+ const code = String(h.result == null ? '' : h.result).replace(/```[a-zA-Z]*\n?/g, '').trim().slice(0, LEDGER_ENTRY_CAP);
647
+ const why = h.verdict && typeof h.verdict.critique === 'string' ? h.verdict.critique.slice(0, LEDGER_WHY_CAP) : '';
648
+ return `--- Rejected attempt ${i + 1} (already failed — do NOT reproduce) ---\n${code}${why ? `\nFailed: ${why}` : ''}`;
649
+ }).join('\n\n');
615
650
 
616
651
  // A refine leaf runs N Loops, so its receipts.tokens SUMS every attempt's spend (not just the last) — the
617
652
  // honest cost of the node. The 4-tier tokens object (`{input,output,cacheCreation,cacheRead}`, loop.js) is flat
@@ -632,8 +667,16 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
632
667
  const effectiveTemps = [];
633
668
  // One attempt = a fresh leaf Loop (no spawn tool: a retry is a direct correction, not a re-decomposition) at the
634
669
  // iteration's temperature, with the GAP fed forward as fresh feedback. A governance halt → throw so refine stops.
635
- const attempt = async ({ iteration, critique }) => {
636
- const temperature = temps[Math.min(iteration, temps.length - 1)];
670
+ const attempt = async ({ iteration, critique, history }) => {
671
+ const hist = Array.isArray(history) ? history : [];
672
+ // ADAPTIVE trigger: a prior attempt whose temperature the model rejected (BA-10) records `null` in
673
+ // effectiveTemps → escalation is inert on this (temperature-fixed) model, so engage the buffer. Forced-on
674
+ // engages regardless (incl. temperature-accepting models). Needs ≥1 prior attempt to have something to buffer.
675
+ const tempDropped = effectiveTemps.some((t) => t === null);
676
+ const useBuffer = hist.length > 0 && !bufferDisabled && (bufferForced || tempDropped);
677
+ // ba14b: temperature is antagonistic to the buffer's directed diversity — HOLD temps[0] when it engages;
678
+ // otherwise escalate (BA-8, the no-memory lever). On a temperature-fixed model both collapse to the default.
679
+ const temperature = useBuffer ? temps[0] : temps[Math.min(iteration, temps.length - 1)];
637
680
  const loop = new Loop({
638
681
  provider, system,
639
682
  policy: ctx.policy || undefined,
@@ -642,9 +685,15 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
642
685
  throwOnError: false,
643
686
  });
644
687
  const base = withContext(task, opts.context);
645
- const userText = critique
646
- ? `${base}\n\nYour previous attempt FAILED these checks:\n${critique}\n\nReturn a corrected result that passes ALL of them.`
647
- : base;
688
+ let userText;
689
+ if (useBuffer) {
690
+ bufferUsed = true;
691
+ userText = `${base}\n\nYou have already tried the following and each FAILED. Do NOT reproduce them — write a STRUCTURALLY DIFFERENT result that passes ALL checks:\n\n${formatLedger(hist)}`;
692
+ } else if (critique) {
693
+ userText = `${base}\n\nYour previous attempt FAILED these checks:\n${critique}\n\nReturn a corrected result that passes ALL of them.`;
694
+ } else {
695
+ userText = base;
696
+ }
648
697
  const out = await loop.run([{ role: 'user', content: userText }], handleTools, { ctx: auditSafeCtx(ctx, { depth }), temperature });
649
698
  // `temperatureDropped` is set on the Loop result only when the model rejected the requested temperature
650
699
  // (BA-10); it's absent on the error/halt return shapes, so read it through a narrow cast.
@@ -667,7 +716,7 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
667
716
  // `temperatures` = the EFFECTIVE temps (BA-10): a `null` marks an attempt whose requested temperature the
668
717
  // model rejected and ran at its default — so the receipt never claims a value the model ignored. On a
669
718
  // temperature-accepting model this equals the requested `temps.slice(0, iterations)` (byte-identical receipt).
670
- node.refineLeaf = { iterations: outcome.iterations, passed: !!(outcome.verdict && outcome.verdict.pass), temperatures: effectiveTemps.slice(0, outcome.iterations) };
719
+ node.refineLeaf = { iterations: outcome.iterations, passed: !!(outcome.verdict && outcome.verdict.pass), temperatures: effectiveTemps.slice(0, outcome.iterations), rejectedBuffer: bufferUsed };
671
720
  const result = outcome.result;
672
721
 
673
722
  // Optional rubric layer on top of the deterministic sensor (RC-7): forced for critical, or a contract/override.
@@ -682,6 +731,11 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
682
731
  return { result, verdict: outcome.verdict || null, receipts: node };
683
732
  } catch (err) {
684
733
  node.tokens = tokensSum; // record whatever attempts DID spend, on both the halt and fault paths
734
+ // The refineLeaf receipt must ride EVERY terminating path, not just the clean one (same invariant as BA-10's
735
+ // `temperatureDropped`): a leaf that ran attempts then halted/faulted still spent tokens and may have engaged
736
+ // the buffer. `effectiveTemps[iteration]` is set BEFORE each attempt's throw, so it reflects every attempt
737
+ // made; `passed:false` because the catch is only reached on a throw (a pass returns from the try above).
738
+ node.refineLeaf = { iterations: effectiveTemps.length, passed: false, temperatures: effectiveTemps.slice(), rejectedBuffer: bufferUsed };
685
739
  if (err instanceof HaltError) {
686
740
  node.halted = true;
687
741
  node.incomplete = true;
package/src/refine.d.ts CHANGED
@@ -1,15 +1,25 @@
1
1
  export type Verdict = import("./evaluator").Verdict;
2
2
  export type RefineOptions = {
3
3
  /**
4
- * Build one generation. On iteration 0, `lastResult`/`critique` are null. Fresh-feedback (D6/A1) is the
5
- * consumer's to realize here: seed a NEW Loop with `{goal + critique}` rather than continuing the failed
6
- * transcript — anchoring on a wrong answer defeats the independent verifier.
4
+ * Build one generation. On iteration 0, `lastResult`/`critique` are null and `history` is empty. Fresh-feedback
5
+ * (D6/A1) is the consumer's to realize here: seed a NEW Loop with `{goal + critique}` rather than continuing the
6
+ * failed transcript — anchoring on a wrong answer defeats the independent verifier. `history` is a fresh
7
+ * shallow COPY of every prior `{result, verdict}` in order (a SkillOpt rejected-attempt buffer, BA-14): a
8
+ * consumer MAY surface the failed attempts verbatim ("you already wrote these, they failed X — write something
9
+ * different"), the lever that recovers a temperature-fixed model's fixation rut where escalation is inert
10
+ * (`poc/ba14-rejected-buffer.mjs`: flat-temp 50%→100%). *Structurally* mutating the copy (push/splice/reorder)
11
+ * is safe; the `{result, verdict}` entries are SHARED references into refine's internal history (and the returned
12
+ * `outcome.history`), so treat them as READ-ONLY — deep-mutating an entry corrupts the outcome.
7
13
  */
8
14
  attempt: (args: {
9
15
  iteration: number;
10
16
  lastResult: any;
11
17
  critique: string | null;
12
18
  contract: string | null;
19
+ history: Array<{
20
+ result: any;
21
+ verdict: Verdict;
22
+ }>;
13
23
  }) => any;
14
24
  /**
15
25
  * Judge a result — typically `evaluator.evaluate(goal, result, { rubric, contract })`.
@@ -56,10 +66,16 @@ export type RefineOutcome = {
56
66
  /** @typedef {import('./evaluator').Verdict} Verdict */
57
67
  /**
58
68
  * @typedef {object} RefineOptions
59
- * @property {(args: {iteration: number, lastResult: any, critique: string|null, contract: string|null}) => any} attempt
60
- * Build one generation. On iteration 0, `lastResult`/`critique` are null. Fresh-feedback (D6/A1) is the
61
- * consumer's to realize here: seed a NEW Loop with `{goal + critique}` rather than continuing the failed
62
- * transcript — anchoring on a wrong answer defeats the independent verifier.
69
+ * @property {(args: {iteration: number, lastResult: any, critique: string|null, contract: string|null, history: Array<{result: any, verdict: Verdict}>}) => any} attempt
70
+ * Build one generation. On iteration 0, `lastResult`/`critique` are null and `history` is empty. Fresh-feedback
71
+ * (D6/A1) is the consumer's to realize here: seed a NEW Loop with `{goal + critique}` rather than continuing the
72
+ * failed transcript — anchoring on a wrong answer defeats the independent verifier. `history` is a fresh
73
+ * shallow COPY of every prior `{result, verdict}` in order (a SkillOpt rejected-attempt buffer, BA-14): a
74
+ * consumer MAY surface the failed attempts verbatim ("you already wrote these, they failed X — write something
75
+ * different"), the lever that recovers a temperature-fixed model's fixation rut where escalation is inert
76
+ * (`poc/ba14-rejected-buffer.mjs`: flat-temp 50%→100%). *Structurally* mutating the copy (push/splice/reorder)
77
+ * is safe; the `{result, verdict}` entries are SHARED references into refine's internal history (and the returned
78
+ * `outcome.history`), so treat them as READ-ONLY — deep-mutating an entry corrupts the outcome.
63
79
  * @property {(result: any, ctx: {iteration: number, contract: string|null}) => (Verdict | Promise<Verdict>)} evaluate
64
80
  * Judge a result — typically `evaluator.evaluate(goal, result, { rubric, contract })`.
65
81
  * @property {string} [contract] - The shared definition of done (A3/D10), forwarded to BOTH `attempt` and
package/src/refine.js CHANGED
@@ -4,10 +4,16 @@
4
4
 
5
5
  /**
6
6
  * @typedef {object} RefineOptions
7
- * @property {(args: {iteration: number, lastResult: any, critique: string|null, contract: string|null}) => any} attempt
8
- * Build one generation. On iteration 0, `lastResult`/`critique` are null. Fresh-feedback (D6/A1) is the
9
- * consumer's to realize here: seed a NEW Loop with `{goal + critique}` rather than continuing the failed
10
- * transcript — anchoring on a wrong answer defeats the independent verifier.
7
+ * @property {(args: {iteration: number, lastResult: any, critique: string|null, contract: string|null, history: Array<{result: any, verdict: Verdict}>}) => any} attempt
8
+ * Build one generation. On iteration 0, `lastResult`/`critique` are null and `history` is empty. Fresh-feedback
9
+ * (D6/A1) is the consumer's to realize here: seed a NEW Loop with `{goal + critique}` rather than continuing the
10
+ * failed transcript — anchoring on a wrong answer defeats the independent verifier. `history` is a fresh
11
+ * shallow COPY of every prior `{result, verdict}` in order (a SkillOpt rejected-attempt buffer, BA-14): a
12
+ * consumer MAY surface the failed attempts verbatim ("you already wrote these, they failed X — write something
13
+ * different"), the lever that recovers a temperature-fixed model's fixation rut where escalation is inert
14
+ * (`poc/ba14-rejected-buffer.mjs`: flat-temp 50%→100%). *Structurally* mutating the copy (push/splice/reorder)
15
+ * is safe; the `{result, verdict}` entries are SHARED references into refine's internal history (and the returned
16
+ * `outcome.history`), so treat them as READ-ONLY — deep-mutating an entry corrupts the outcome.
11
17
  * @property {(result: any, ctx: {iteration: number, contract: string|null}) => (Verdict | Promise<Verdict>)} evaluate
12
18
  * Judge a result — typically `evaluator.evaluate(goal, result, { rubric, contract })`.
13
19
  * @property {string} [contract] - The shared definition of done (A3/D10), forwarded to BOTH `attempt` and
@@ -49,7 +55,7 @@ async function refine(options) {
49
55
  let lastVerdict = null;
50
56
 
51
57
  for (let iteration = 0; iteration < maxIterations; iteration++) {
52
- const result = await attempt({ iteration, lastResult, critique: lastVerdict ? lastVerdict.critique : null, contract });
58
+ const result = await attempt({ iteration, lastResult, critique: lastVerdict ? lastVerdict.critique : null, contract, history: history.slice() });
53
59
  const verdict = await evaluate(result, { iteration, contract });
54
60
  history.push({ result, verdict });
55
61
  lastResult = result;