bare-agent 0.28.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 +2 -1
- package/bareagent.context.md +13 -4
- package/examples/with-bareguard.mjs +2 -0
- package/package.json +1 -1
- package/src/loop.js +5 -0
- package/src/recurse.d.ts +49 -7
- package/src/recurse.js +65 -11
- package/src/refine.d.ts +23 -7
- package/src/refine.js +11 -5
- package/tools/shell.d.ts +39 -0
- package/tools/shell.js +129 -6
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
|
|
|
@@ -161,6 +161,7 @@ const { policy, onLlmResult, onToolResult, filterTools } = wireGate(gate, {
|
|
|
161
161
|
if (toolName === 'shell_exec') return { type: 'bash', args, _ctx: ctx };
|
|
162
162
|
if (toolName === 'shell_read') return { type: 'read', args, _ctx: ctx };
|
|
163
163
|
if (toolName === 'shell_write') return { type: 'write', args, _ctx: ctx }; // gate by fs.writeScope
|
|
164
|
+
if (toolName === 'shell_edit') return { type: 'edit', args, _ctx: ctx }; // same fs.writeScope as write
|
|
164
165
|
return defaultActionTranslator(toolName, args, ctx);
|
|
165
166
|
},
|
|
166
167
|
});
|
package/bareagent.context.md
CHANGED
|
@@ -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.
|
|
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 |
|
|
@@ -65,7 +65,8 @@ Eight entry points:
|
|
|
65
65
|
| Assess website privacy risk | createBrowsingTools + Loop (requires `npm install wearehere`) |
|
|
66
66
|
| Control Android/iOS devices | createMobileTools + Loop |
|
|
67
67
|
| Control mobile (token-efficient, disk-based) | `baremobile` CLI session — snapshots to `.baremobile/*.yml` |
|
|
68
|
-
| Read/write files, list directories, run shell commands, grep | createShellTools (shell_read/grep/**write**/run/exec) + Loop({ policy }) — gate `shell_write` via `fs.writeScope` with an actionTranslator |
|
|
68
|
+
| Read/write files, list directories, run shell commands, grep | createShellTools (shell_read/grep/**write**/**edit**/run/exec) + Loop({ policy }) — gate `shell_write`/`shell_edit` via `fs.writeScope` with an actionTranslator |
|
|
69
|
+
| Change one span of a big file without re-emitting the whole thing | `shell_edit({path, oldText, newText})` — anchored exact-once replace; the surgical alternative to whole-file `shell_write` (BA-13). Gate it as `{type:'edit'}` |
|
|
69
70
|
| Auto-discover MCP servers from IDE configs | createMCPBridge |
|
|
70
71
|
| Gate MCP tools with allow/deny lists | createMCPBridge + `.mcp-bridge.json` |
|
|
71
72
|
| Gate every tool call with one policy hook | `wireGate(gate).policy` → `Loop({ policy })` |
|
|
@@ -423,6 +424,7 @@ const { policy, onToolResult } = wireGate(gate, {
|
|
|
423
424
|
if (toolName === 'shell_run') return { type: 'bash', args, _ctx: ctx }; // reads args.argv → joins to cmd
|
|
424
425
|
if (toolName === 'shell_read') return { type: 'read', args, _ctx: ctx }; // reads args.path
|
|
425
426
|
if (toolName === 'shell_write') return { type: 'write', args, _ctx: ctx }; // gate by fs.writeScope (reads args.path)
|
|
427
|
+
if (toolName === 'shell_edit') return { type: 'edit', args, _ctx: ctx }; // same fs.writeScope as write (reads args.path)
|
|
426
428
|
return { type: toolName, args, _ctx: ctx }; // fall through to defaultActionTranslator
|
|
427
429
|
},
|
|
428
430
|
});
|
|
@@ -706,7 +708,11 @@ const out = await recurse('Audit auth.js, billing.js, gateway.js for authz bugs'
|
|
|
706
708
|
|
|
707
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.
|
|
708
710
|
|
|
709
|
-
**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).
|
|
710
716
|
|
|
711
717
|
```javascript
|
|
712
718
|
const out = await recurse('Fix the failing function in calc.js', ctx, {
|
|
@@ -1247,6 +1253,7 @@ Mobile tools follow the observe-act pattern: action tools auto-return a fresh sn
|
|
|
1247
1253
|
|---|---|
|
|
1248
1254
|
| `shell_read` | Read a file (utf8, 256KB cap) or list a directory (tab-separated). `~` expands to home. |
|
|
1249
1255
|
| `shell_write` | Write (or `append:true`) UTF-8 text to a file, creating parent dirs. 5MB cap. No shell, so it gates cleanly through `fs.writeScope` once translated to `{type:'write'}`. **`content` is REQUIRED** — see the truncation guard below. |
|
|
1256
|
+
| `shell_edit` | Anchored exact-string replace — the **surgical** alternative to whole-file `shell_write` (BA-13). `{path, oldText, newText}`: `oldText` must occur **exactly once** (quote surrounding lines to be unique); `newText` is spliced in **verbatim** (`""` deletes). Returns a compact `edited <path>: 1 replacement` receipt, never the body. 0 or 2+ matches → a refusal **returned as the tool result** (the model re-anchors; file untouched). Gates through `fs.writeScope` translated to `{type:'edit'}` — see the note below. |
|
|
1250
1257
|
| `shell_grep` | JavaScript regex search across files. Walks directories, skips binary files, returns `{hits: [{file, line, text}], truncated, fileCount}`. |
|
|
1251
1258
|
| `shell_run` | Run a command with an **argv array** via `child_process.execFile` (no shell, no metacharacter interpretation). Returns `{stdout, stderr, code, timedOut}`. **Use this when you need a policy allowlist.** |
|
|
1252
1259
|
| `shell_exec` | Run a raw shell command string via `/bin/sh -c` (or `cmd.exe`). Returns the same shape. **Shell metacharacters are interpreted — naive allowlists are bypassable.** Use only when you genuinely need shell features (pipes, redirects, globs). |
|
|
@@ -1255,6 +1262,8 @@ Mobile tools follow the observe-act pattern: action tools auto-return a fresh sn
|
|
|
1255
1262
|
|
|
1256
1263
|
> **⚠️ `shell_write` requires `content` — and a gate cannot cover for it (v0.27+).** `content` used to default to `''`, so a tool call that OMITTED it silently overwrote the target with **zero bytes** and returned `"wrote 0 bytes to <path>"` as success. That is the ordinary shape of a model hitting its **output-token cap** mid-generation on a long file — observed live emptying a 1789-line source file. **No policy can catch it:** a 0-byte write is a *legal* write, and bareguard's `fs` primitive judges `{type:'write', path}` without inspecting the body (the gate correctly `allow`s it). `shell_write` now **rejects** an absent, `null`, or non-string `content` and leaves the file byte-identical; the error tells the model to retry with the full content. An explicit `content: ""` still empties the file — that one is deliberate.
|
|
1257
1264
|
|
|
1265
|
+
> **`shell_edit` — the surgical write (BA-13).** Changing one line of an 800-line file with `shell_write` forces the model to re-emit **all 800 lines** as tool-call JSON: an output-token tax ∝ file size (output is the expensive token class), paid on every revision, and the maximal broken-tree surface (a truncated rewrite mangles the lines it never meant to touch — the BA-4/BA-6 class). `shell_edit({path, oldText, newText})` emits only the anchor + replacement. Semantics worth knowing: `oldText` must match **exactly once** (a 0/2+ match is a refusal *returned as the tool result*, so the loop continues and the model widens the anchor — not a throw, so a repeated-identical miss is bounded by maxTurns/budget, not the spin guard); missing/empty `oldText` or missing/non-string `newText` **throw** (BA-4 guards; `newText:""` is a legal deletion); the write is **atomic** (sibling temp + rename, mode preserved) so an fs failure never leaves a partial file; and `newText` is a **literal splice** (a `$&`/`$1` in it lands verbatim, unlike `String.replace`). Gate it exactly like `shell_write` but as `{type:'edit'}` — **bareguard gates `edit` by the same `fs.writeScope` as `write` with zero config** (its FS primitive's `FS_TYPES` already includes `edit`).
|
|
1266
|
+
|
|
1258
1267
|
> **⚠️ `shell_exec` injection caveat.** `"ls"` passes a base-command allowlist like `args.command.split(/\s+/)[0]`, but so does `"ls;rm -rf /tmp/x"` — the shell runs both. **A base-command allowlist is NOT safe for `shell_exec`.** For policy-gated use, prefer `shell_run({argv})` and allow-list on `args.argv[0]` — there is no shell in that path, so metacharacters are just literal argument bytes. Use `shell_exec` only when the agent needs pipes/redirects/globs, and gate it at a higher level (human approval, narrow intent).
|
|
1259
1268
|
|
|
1260
1269
|
```javascript
|
|
@@ -60,6 +60,8 @@ const actionTranslator = (toolName, args, ctx) => {
|
|
|
60
60
|
case 'shell_grep': return { type: 'read', path: args?.path, args, _ctx: ctx ?? null };
|
|
61
61
|
// shell_write is a write — gate it through fs.writeScope (add writeScope to the Gate config to enforce).
|
|
62
62
|
case 'shell_write': return { type: 'write', path: args?.path, args, _ctx: ctx ?? null };
|
|
63
|
+
// shell_edit is an anchored edit — bareguard gates {type:'edit'} by the SAME fs.writeScope as write.
|
|
64
|
+
case 'shell_edit': return { type: 'edit', path: args?.path, args, _ctx: ctx ?? null };
|
|
63
65
|
default: return { type: toolName, args, _ctx: ctx ?? null };
|
|
64
66
|
}
|
|
65
67
|
};
|
package/package.json
CHANGED
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
|
5
|
-
* consumer's to realize here: seed a NEW Loop with `{goal + critique}` rather than continuing the
|
|
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
|
|
61
|
-
* consumer's to realize here: seed a NEW Loop with `{goal + critique}` rather than continuing the
|
|
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
|
|
9
|
-
* consumer's to realize here: seed a NEW Loop with `{goal + critique}` rather than continuing the
|
|
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;
|
package/tools/shell.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ declare namespace _exports {
|
|
|
5
5
|
export { createShellTools };
|
|
6
6
|
export { _grepCore };
|
|
7
7
|
export { writeFile as _writeFile };
|
|
8
|
+
export { editFile as _editFile };
|
|
8
9
|
}
|
|
9
10
|
export = _exports;
|
|
10
11
|
type GrepArgs = {
|
|
@@ -97,3 +98,41 @@ declare function writeFile({ path: rawPath, content, append, maxBytes }: {
|
|
|
97
98
|
append?: boolean;
|
|
98
99
|
maxBytes?: number;
|
|
99
100
|
}): Promise<string>;
|
|
101
|
+
/**
|
|
102
|
+
* Anchored exact-string replace (BA-13) — the surgical counterpart to the whole-file `shell_write`.
|
|
103
|
+
* Changing one line of an 800-line file with `shell_write` forces the model to EMIT all 800 lines as
|
|
104
|
+
* tool-call JSON: an output-token tax ∝ file size (output is the expensive token class), paid on every
|
|
105
|
+
* revision, and the maximal broken-tree surface (a truncated rewrite mangles the 799 lines it never meant
|
|
106
|
+
* to touch — the BA-4/BA-6 truncation class). `shell_edit` emits only the anchor and its replacement.
|
|
107
|
+
*
|
|
108
|
+
* TWO error classes, deliberately split:
|
|
109
|
+
* - ANCHOR failures (`oldText` matches 0 or 2+ times) RETURN a refusal string as a normal tool RESULT —
|
|
110
|
+
* the loop continues and the model re-anchors, and the refusal names the count so the retry is a DISTINCT
|
|
111
|
+
* call. (Tradeoff, chosen with eyes open: a result does NOT feed the Loop's `maxIdenticalToolErrors` spin
|
|
112
|
+
* guard, so a model that repeats the byte-identical wrong anchor is bounded only by maxTurns/budget, not
|
|
113
|
+
* short-circuited. A widened anchor is a different call and recovers naturally; the exact-repeat spin is
|
|
114
|
+
* the rare degenerate case. This matches the ask's "refusal, not a throw" contract.)
|
|
115
|
+
* - fs-layer errors (missing file, a directory) and BA-4 param-guard violations THROW at the tool boundary.
|
|
116
|
+
*
|
|
117
|
+
* BA-4 param guards (guarded from birth this time — cf. `shell_write` zeroing files on an absent arg):
|
|
118
|
+
* `oldText` a required NON-EMPTY string, `newText` a required string — both THROW when absent/wrong-type (an
|
|
119
|
+
* absent param is the truncated-call signature, never a silent default). Explicit `newText:""` is a legal
|
|
120
|
+
* deletion; an absent `newText` is not.
|
|
121
|
+
*
|
|
122
|
+
* ATOMIC: read → splice in memory → write a sibling temp (same filesystem, so `rename` is atomic) carrying
|
|
123
|
+
* the original's mode → rename over the original. Any throw before the rename leaves the original
|
|
124
|
+
* byte-identical and cleans the temp up, so a reader never sees a partial file, and an edit can't silently
|
|
125
|
+
* drop the executable bit.
|
|
126
|
+
*
|
|
127
|
+
* LITERAL splice, NOT `String.replace`: `.replace(oldText, newText)` interprets `$&`/`$1`/`` $` `` patterns in
|
|
128
|
+
* `newText` and would corrupt any edit whose replacement contains a `$`. We index + slice, so every byte of
|
|
129
|
+
* `newText` lands verbatim.
|
|
130
|
+
* @param {{path: string, oldText: string, newText: string, maxBytes?: number}} args
|
|
131
|
+
* @returns {Promise<string>}
|
|
132
|
+
*/
|
|
133
|
+
declare function editFile({ path: rawPath, oldText, newText, maxBytes }: {
|
|
134
|
+
path: string;
|
|
135
|
+
oldText: string;
|
|
136
|
+
newText: string;
|
|
137
|
+
maxBytes?: number;
|
|
138
|
+
}): Promise<string>;
|
package/tools/shell.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* shell_read — read a file or list a directory
|
|
8
8
|
* shell_grep — regex search across files (JS regex, no grep/rg/findstr)
|
|
9
9
|
* shell_write — write/overwrite (or append to) a file, creating parent dirs (no shell)
|
|
10
|
+
* shell_edit — anchored exact-string replace: change one span without rewriting the whole file
|
|
10
11
|
* shell_run — run a command via an argv array (no shell, allowlist-friendly on argv[0])
|
|
11
12
|
* shell_exec — run a raw shell command with timeout + max buffer
|
|
12
13
|
*
|
|
@@ -16,11 +17,13 @@
|
|
|
16
17
|
* GATING WITH bareguard's fs/bash PRIMITIVES: these tools carry tool-named actions by default
|
|
17
18
|
* (`{ type:'shell_write' }`), which match `tools.allowlist`/`tools.denylist` but do NOT activate the
|
|
18
19
|
* `fs`/`bash` primitives — those need `action.type ∈ {read,write,edit,bash}` with `action.path`/`action.cmd`.
|
|
19
|
-
* To gate `shell_write` by `fs.writeScope` (so a write outside the allowed root is denied BEFORE it
|
|
20
|
-
* disk), translate it at the gate — see `examples/with-bareguard.mjs` for the `wireGate(gate, { actionTranslator })`
|
|
21
|
-
* mapping (`shell_write` → `{ type:'write', path }`, `
|
|
22
|
-
* `shell_run`/`shell_exec` → `{ type:'bash', cmd }`).
|
|
23
|
-
*
|
|
20
|
+
* To gate `shell_write`/`shell_edit` by `fs.writeScope` (so a write outside the allowed root is denied BEFORE it
|
|
21
|
+
* touches disk), translate it at the gate — see `examples/with-bareguard.mjs` for the `wireGate(gate, { actionTranslator })`
|
|
22
|
+
* mapping (`shell_write` → `{ type:'write', path }`, `shell_edit` → `{ type:'edit', path }`, `shell_read`/`shell_grep`
|
|
23
|
+
* → `{ type:'read', path }`, `shell_run`/`shell_exec` → `{ type:'bash', cmd }`). bareguard gates `edit` by
|
|
24
|
+
* `fs.writeScope` identically to `write` (its FS primitive's `FS_TYPES` includes `edit`), so a consumer that
|
|
25
|
+
* fences `write` gets `edit` fenced by the same scope with ZERO extra config. A write/edit tool alone is NOT
|
|
26
|
+
* auto-gated — validated by poc/ba2-write-tool-gate.mjs (without the translator the out-of-scope write leaks).
|
|
24
27
|
*
|
|
25
28
|
* CAVEAT (applies to read AND write scopes): bareguard's `fs` primitive matches paths LEXICALLY (no
|
|
26
29
|
* `realpath`/symlink resolution), so a symlink that lives INSIDE the allowed scope but points OUTSIDE it is
|
|
@@ -33,6 +36,7 @@
|
|
|
33
36
|
|
|
34
37
|
const fs = require('node:fs/promises');
|
|
35
38
|
const path = require('node:path');
|
|
39
|
+
const crypto = require('node:crypto');
|
|
36
40
|
const { exec, execFile } = require('node:child_process');
|
|
37
41
|
const { Worker } = require('node:worker_threads');
|
|
38
42
|
|
|
@@ -127,6 +131,101 @@ async function writeFile({ path: rawPath, content, append = false, maxBytes }) {
|
|
|
127
131
|
return `${append ? 'appended' : 'wrote'} ${bytes} bytes to ${resolved}`;
|
|
128
132
|
}
|
|
129
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Anchored exact-string replace (BA-13) — the surgical counterpart to the whole-file `shell_write`.
|
|
136
|
+
* Changing one line of an 800-line file with `shell_write` forces the model to EMIT all 800 lines as
|
|
137
|
+
* tool-call JSON: an output-token tax ∝ file size (output is the expensive token class), paid on every
|
|
138
|
+
* revision, and the maximal broken-tree surface (a truncated rewrite mangles the 799 lines it never meant
|
|
139
|
+
* to touch — the BA-4/BA-6 truncation class). `shell_edit` emits only the anchor and its replacement.
|
|
140
|
+
*
|
|
141
|
+
* TWO error classes, deliberately split:
|
|
142
|
+
* - ANCHOR failures (`oldText` matches 0 or 2+ times) RETURN a refusal string as a normal tool RESULT —
|
|
143
|
+
* the loop continues and the model re-anchors, and the refusal names the count so the retry is a DISTINCT
|
|
144
|
+
* call. (Tradeoff, chosen with eyes open: a result does NOT feed the Loop's `maxIdenticalToolErrors` spin
|
|
145
|
+
* guard, so a model that repeats the byte-identical wrong anchor is bounded only by maxTurns/budget, not
|
|
146
|
+
* short-circuited. A widened anchor is a different call and recovers naturally; the exact-repeat spin is
|
|
147
|
+
* the rare degenerate case. This matches the ask's "refusal, not a throw" contract.)
|
|
148
|
+
* - fs-layer errors (missing file, a directory) and BA-4 param-guard violations THROW at the tool boundary.
|
|
149
|
+
*
|
|
150
|
+
* BA-4 param guards (guarded from birth this time — cf. `shell_write` zeroing files on an absent arg):
|
|
151
|
+
* `oldText` a required NON-EMPTY string, `newText` a required string — both THROW when absent/wrong-type (an
|
|
152
|
+
* absent param is the truncated-call signature, never a silent default). Explicit `newText:""` is a legal
|
|
153
|
+
* deletion; an absent `newText` is not.
|
|
154
|
+
*
|
|
155
|
+
* ATOMIC: read → splice in memory → write a sibling temp (same filesystem, so `rename` is atomic) carrying
|
|
156
|
+
* the original's mode → rename over the original. Any throw before the rename leaves the original
|
|
157
|
+
* byte-identical and cleans the temp up, so a reader never sees a partial file, and an edit can't silently
|
|
158
|
+
* drop the executable bit.
|
|
159
|
+
*
|
|
160
|
+
* LITERAL splice, NOT `String.replace`: `.replace(oldText, newText)` interprets `$&`/`$1`/`` $` `` patterns in
|
|
161
|
+
* `newText` and would corrupt any edit whose replacement contains a `$`. We index + slice, so every byte of
|
|
162
|
+
* `newText` lands verbatim.
|
|
163
|
+
* @param {{path: string, oldText: string, newText: string, maxBytes?: number}} args
|
|
164
|
+
* @returns {Promise<string>}
|
|
165
|
+
*/
|
|
166
|
+
async function editFile({ path: rawPath, oldText, newText, maxBytes }) {
|
|
167
|
+
if (typeof rawPath !== 'string' || rawPath.length === 0) {
|
|
168
|
+
throw new Error('shell_edit requires a non-empty "path" string');
|
|
169
|
+
}
|
|
170
|
+
if (typeof oldText !== 'string' || oldText.length === 0) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
'shell_edit requires a non-empty "oldText" string to anchor the edit — refusing to edit, the file is unchanged. '
|
|
173
|
+
+ `Got ${oldText === undefined ? 'no oldText argument' : oldText === '' ? 'an empty string' : `oldText of type ${oldText === null ? 'null' : typeof oldText}`}.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (typeof newText !== 'string') {
|
|
177
|
+
throw new Error(
|
|
178
|
+
'shell_edit requires a "newText" string (pass newText:"" to delete the anchored text) — refusing to edit, the file is unchanged. '
|
|
179
|
+
+ `Got ${newText === undefined ? 'no newText argument' : `newText of type ${newText === null ? 'null' : typeof newText}`}`
|
|
180
|
+
+ '. If your output was cut short, retry with the full newText.',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const resolved = path.resolve(expandHome(rawPath));
|
|
185
|
+
// fs-layer errors (ENOENT for a missing file, EISDIR for a directory) throw — same surface as shell_read.
|
|
186
|
+
const content = await fs.readFile(resolved, 'utf8');
|
|
187
|
+
|
|
188
|
+
// Literal, non-overlapping occurrence count (split on a string does no regex interpretation).
|
|
189
|
+
const occurrences = content.split(oldText).length - 1;
|
|
190
|
+
if (occurrences === 0) {
|
|
191
|
+
return `shell_edit: oldText not found in ${resolved} — no change made. Quote the exact text to replace `
|
|
192
|
+
+ `(check whitespace and indentation), or read the file to re-anchor.`;
|
|
193
|
+
}
|
|
194
|
+
if (occurrences > 1) {
|
|
195
|
+
return `shell_edit: oldText occurs ${occurrences}× in ${resolved} — the anchor must match exactly once. `
|
|
196
|
+
+ `Widen it with surrounding lines so it is unique. No change made.`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const idx = content.indexOf(oldText);
|
|
200
|
+
const patched = content.slice(0, idx) + newText + content.slice(idx + oldText.length);
|
|
201
|
+
|
|
202
|
+
const cap = maxBytes || DEFAULT_WRITE_MAX_BYTES;
|
|
203
|
+
const bytes = Buffer.byteLength(patched, 'utf8');
|
|
204
|
+
if (bytes > cap) {
|
|
205
|
+
throw new Error(`shell_edit result is ${bytes} bytes, over the ${cap}-byte cap (pass maxBytes to raise it)`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Atomic replace: a sibling temp (same dir → same filesystem → rename is atomic) with the original's mode.
|
|
209
|
+
const stat = await fs.stat(resolved);
|
|
210
|
+
const tmp = `${resolved}.shell_edit-${crypto.randomBytes(9).toString('hex')}.tmp`;
|
|
211
|
+
try {
|
|
212
|
+
// flag 'wx' (O_CREAT|O_EXCL) — never follow or clobber a pre-planted file/symlink at the temp path; a
|
|
213
|
+
// colliding name fails the write instead. Create owner-only (0o600) so the patched body — which may hold
|
|
214
|
+
// a secret from a sensitive source file — is never briefly world-readable in the window before chmod sets
|
|
215
|
+
// the original's real mode. (This temp pattern is new to shell_edit, so it carries its own hardening.)
|
|
216
|
+
await fs.writeFile(tmp, patched, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
|
217
|
+
await fs.chmod(tmp, stat.mode & 0o777);
|
|
218
|
+
await fs.rename(tmp, resolved);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const removed = oldText.split('\n').length - 1;
|
|
225
|
+
const added = newText.split('\n').length - 1;
|
|
226
|
+
return `edited ${resolved}: 1 replacement (-${removed}/+${added} lines)`;
|
|
227
|
+
}
|
|
228
|
+
|
|
130
229
|
// Probe the first 1KB for NUL bytes to skip binary files in grep walks.
|
|
131
230
|
/** @param {string} filePath */
|
|
132
231
|
async function isProbablyText(filePath) {
|
|
@@ -453,6 +552,30 @@ function createShellTools() {
|
|
|
453
552
|
execute: async (/** @type {{path: string, content?: string, append?: boolean, maxBytes?: number}} */ args) =>
|
|
454
553
|
writeFile(/** @type {any} */ (args)),
|
|
455
554
|
},
|
|
555
|
+
{
|
|
556
|
+
name: 'shell_edit',
|
|
557
|
+
description: 'Replace an exact, unique text span in a file — the surgical alternative to shell_write, which ' +
|
|
558
|
+
'rewrites the ENTIRE file. Give oldText (the exact text to replace — it must occur EXACTLY ONCE, so quote ' +
|
|
559
|
+
'enough surrounding lines to be unique) and newText (its replacement; pass "" to delete). Matched literally ' +
|
|
560
|
+
'(no regex; whitespace and indentation are significant). Returns a compact "edited <path>: 1 replacement" ' +
|
|
561
|
+
'receipt, never the file body. If oldText matches 0 or 2+ times the file is left unchanged and the reason is ' +
|
|
562
|
+
'returned so you can re-anchor. The file must already exist. No shell — gate by path with an fs.writeScope ' +
|
|
563
|
+
'policy (translate to {type:"edit"}).',
|
|
564
|
+
parameters: {
|
|
565
|
+
type: 'object',
|
|
566
|
+
properties: {
|
|
567
|
+
path: { type: 'string', description: 'File to edit. ~ expands to home. The file must already exist.' },
|
|
568
|
+
oldText: { type: 'string', description: 'Exact text to find — must occur exactly once. Quote surrounding lines to disambiguate. Matched literally (no regex); whitespace and indentation are significant.' },
|
|
569
|
+
newText: { type: 'string', description: 'Replacement text, inserted verbatim (a literal splice — $ is not special). Pass "" to delete the anchored text. Required — a call without it is REJECTED, not treated as a deletion.' },
|
|
570
|
+
maxBytes: { type: 'integer', description: 'Reject if the resulting file would exceed this many bytes (default 5242880).' },
|
|
571
|
+
},
|
|
572
|
+
required: ['path', 'oldText', 'newText'],
|
|
573
|
+
},
|
|
574
|
+
// The args are model-authored and UNTRUSTED — oldText/newText may be absent (an output-token-capped
|
|
575
|
+
// generation), so the boundary type stays loose and editFile enforces the BA-4 contract at runtime.
|
|
576
|
+
execute: async (/** @type {{path: string, oldText?: string, newText?: string, maxBytes?: number}} */ args) =>
|
|
577
|
+
editFile(/** @type {any} */ (args)),
|
|
578
|
+
},
|
|
456
579
|
{
|
|
457
580
|
name: 'shell_run',
|
|
458
581
|
description: 'Run a command with an argv array (no shell, no interpolation) and return {stdout, stderr, code, timedOut}. Use this when a policy allowlist needs to match on argv[0] — no shell metacharacter injection is possible. Default timeout 30s, max output 1MB.',
|
|
@@ -493,4 +616,4 @@ function createShellTools() {
|
|
|
493
616
|
return { tools };
|
|
494
617
|
}
|
|
495
618
|
|
|
496
|
-
module.exports = { createShellTools, _grepCore, _writeFile: writeFile };
|
|
619
|
+
module.exports = { createShellTools, _grepCore, _writeFile: writeFile, _editFile: editFile };
|