bare-agent 0.21.1 → 0.23.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 +23 -7
- package/examples/litectx-as-store.mjs +11 -4
- package/examples/with-bareguard.mjs +40 -10
- package/package.json +1 -1
- package/src/recurse.d.ts +92 -2
- package/src/recurse.js +204 -12
- package/tools/shell.d.ts +29 -6
- package/tools/shell.js +64 -4
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). 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), feeding the gap back with escalating temperature. 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
|
|
|
@@ -154,6 +154,7 @@ const { policy, onLlmResult, onToolResult, filterTools } = wireGate(gate, {
|
|
|
154
154
|
actionTranslator: (toolName, args, ctx) => {
|
|
155
155
|
if (toolName === 'shell_exec') return { type: 'bash', args, _ctx: ctx };
|
|
156
156
|
if (toolName === 'shell_read') return { type: 'read', args, _ctx: ctx };
|
|
157
|
+
if (toolName === 'shell_write') return { type: 'write', args, _ctx: ctx }; // gate by fs.writeScope
|
|
157
158
|
return defaultActionTranslator(toolName, args, ctx);
|
|
158
159
|
},
|
|
159
160
|
});
|
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.23.0 | Node.js >= 18 | zero required deps (`bareguard ^0.9.0` optional peer for governance) | Apache 2.0
|
|
5
5
|
>
|
|
6
6
|
> Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)
|
|
7
7
|
|
|
@@ -37,6 +37,8 @@ Eight entry points:
|
|
|
37
37
|
| Decompose a hard task into a verified tree (RLM) | recurse — decompose → fan-out → verify → synthesize in one call (**wire a gate**, cost is open by design) |
|
|
38
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
|
+
| 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 |
|
|
40
42
|
| Track task state (pending/running/done/failed) | StateMachine |
|
|
41
43
|
| Run agent turns on a schedule (cron, timers) | Scheduler |
|
|
42
44
|
| Require human approval before dangerous actions | Checkpoint |
|
|
@@ -62,7 +64,7 @@ Eight entry points:
|
|
|
62
64
|
| Assess website privacy risk | createBrowsingTools + Loop (requires `npm install wearehere`) |
|
|
63
65
|
| Control Android/iOS devices | createMobileTools + Loop |
|
|
64
66
|
| Control mobile (token-efficient, disk-based) | `baremobile` CLI session — snapshots to `.baremobile/*.yml` |
|
|
65
|
-
| Read files, list directories, run shell commands, grep | createShellTools + Loop({ policy }) |
|
|
67
|
+
| 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 |
|
|
66
68
|
| Auto-discover MCP servers from IDE configs | createMCPBridge |
|
|
67
69
|
| Gate MCP tools with allow/deny lists | createMCPBridge + `.mcp-bridge.json` |
|
|
68
70
|
| Gate every tool call with one policy hook | `wireGate(gate).policy` → `Loop({ policy })` |
|
|
@@ -384,15 +386,16 @@ Legacy `wrapTool` / `wrapTools` are retained as deprecation shims (one-shot cons
|
|
|
384
386
|
```javascript
|
|
385
387
|
const { policy, onToolResult } = wireGate(gate, {
|
|
386
388
|
actionTranslator: (toolName, args, ctx) => {
|
|
387
|
-
if (toolName === 'shell_exec')
|
|
388
|
-
if (toolName === 'shell_run')
|
|
389
|
-
if (toolName === 'shell_read')
|
|
389
|
+
if (toolName === 'shell_exec') return { type: 'bash', args, _ctx: ctx }; // bareguard 0.4.1+ reads args.command
|
|
390
|
+
if (toolName === 'shell_run') return { type: 'bash', args, _ctx: ctx }; // reads args.argv → joins to cmd
|
|
391
|
+
if (toolName === 'shell_read') return { type: 'read', args, _ctx: ctx }; // reads args.path
|
|
392
|
+
if (toolName === 'shell_write') return { type: 'write', args, _ctx: ctx }; // gate by fs.writeScope (reads args.path)
|
|
390
393
|
return { type: toolName, args, _ctx: ctx }; // fall through to defaultActionTranslator
|
|
391
394
|
},
|
|
392
395
|
});
|
|
393
396
|
```
|
|
394
397
|
|
|
395
|
-
`onLlmResult` always uses `{type:'llm'}` regardless of the translator (so budget rules match without translator collusion). `defaultActionTranslator` is exported for composition.
|
|
398
|
+
`onLlmResult` always uses `{type:'llm'}` regardless of the translator (so budget rules match without translator collusion). `defaultActionTranslator` is exported for composition. **A tool is NOT auto-gated by the fs/bash primitives without this translator** — e.g. `shell_write` runs the write but `fs.writeScope` only enforces once `shell_write` → `{type:'write', path}`; the default `{type:'shell_write'}` matches `tools.allow/denylist` only. Verified live: with the translator, `gate.check` ALLOWs `shell_run ["ls","/tmp"]` and DENYs `shell_read /etc/passwd` (`[deny: fs.readScope]`), and an out-of-scope `shell_write` is denied **before** `execute` (nothing touches disk).
|
|
396
399
|
|
|
397
400
|
**Bounding tool rounds — use `limits.maxToolRounds` (bareguard 0.4.2+), not doubled `maxTurns`.** `limits.maxTurns` ticks on every `gate.record` (LLM + tool), so an "N LLM-tool round" cap is `maxTurns: N*2`. `limits.maxToolRounds: N` ticks only on non-`llm` records and gives the natural semantic — pairs cleanly with our split `onLlmResult` / `onToolResult` (the LLM side writes `{type:'llm'}` records which the counter skips). Halt severity, same shape as `maxTurns`, rebuilt from audit on cold-start.
|
|
398
401
|
|
|
@@ -629,6 +632,8 @@ if (out.incomplete) {
|
|
|
629
632
|
console.log(out.receipts.spawned.length); // RC-10 audit tree: parent→child lineage, per-node tokens/verdict
|
|
630
633
|
```
|
|
631
634
|
|
|
635
|
+
> **Audit-safe by construction (since Unreleased).** You pass `provider` on `ctx`, and a wired gate records the per-run ctx VERBATIM as `action._ctx`. `recurse()` **strips the live provider** (and thus its `apiKey`) from the ctx at every governance boundary before it reaches `gate.record`/`gate.check`, so the key never lands in the audit JSONL — only the provider *name* does (identity, not secret). The provider still reaches the worker (it runs); only the audited copy is cleaned. (bareguard's own secret-redaction is opt-in and value/pattern-based, so do not rely on it to catch a key you put on `ctx` — but DO scrub any *other* secret-bearing field you thread on `ctx` yourself, or configure `gate` `secrets`.)
|
|
636
|
+
|
|
632
637
|
**Control families (how the tree is shaped):**
|
|
633
638
|
|
|
634
639
|
- **Family A — model-driven (default).** The worker is handed an in-process `spawn_child` tool and *decides* whether to split. `assessComplexity` is a **hint, not a gate** (only `simple → single-shot`, and the non-overridable `critical → force adversarial verify` safety floor). Nothing extra to set.
|
|
@@ -666,7 +671,18 @@ const out = await recurse('Audit auth.js, billing.js, gateway.js for authz bugs'
|
|
|
666
671
|
});
|
|
667
672
|
```
|
|
668
673
|
|
|
669
|
-
**
|
|
674
|
+
**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.
|
|
675
|
+
|
|
676
|
+
**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 the **retry temperature ESCALATES** (default `[0.2, 0.7, 1.0]` — load-bearing: a weak model at a flat temperature regenerates identical wrong code and ignores even crisp feedback). 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.
|
|
677
|
+
|
|
678
|
+
```javascript
|
|
679
|
+
const out = await recurse('Fix the failing function in calc.js', ctx, {
|
|
680
|
+
context: `project root: ${process.cwd()}\nresolve relative paths against it`,
|
|
681
|
+
refineLeaf: { sensor: (code) => runTestsAndGrade(code) }, // your deterministic test/compile close
|
|
682
|
+
});
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
**What a delegated child inherits (important — the setpoint is the TOP node's job):** when a worker delegates with `spawn_child`, the child runs a **fresh `recurse`** that inherits `tools`, `synthesize`, `maxDepth`, `persona`, `context`, and `refineLeaf` — but the parent's **`contract`/`evaluate` are stripped** (and the forced `count`/`mode` + the corpus `retrieval` knobs). A slice is not graded against the *whole*-task definition-of-done (that verdict is the top node's, and a slice satisfying the whole DoD is the wrong question); only the top `recurse` verifies the synthesized result. The non-overridable `critical → force-verify` safety floor still fires per node (it keys on the task text, not the contract). So: set `contract`/`evaluate` once at the top; they do not — and should not — re-run per intermediate node.
|
|
670
686
|
|
|
671
687
|
## Wiring with Evaluator + refine (output-side verification)
|
|
672
688
|
|
|
@@ -63,16 +63,23 @@ async function main() {
|
|
|
63
63
|
} catch {
|
|
64
64
|
console.log('\n[litectx] not installed — the swap is one line:');
|
|
65
65
|
console.log(" import { LiteCtx, liteCtxAsStore } from 'litectx';");
|
|
66
|
-
console.log(' const lc = new LiteCtx({
|
|
66
|
+
console.log(' const lc = new LiteCtx({ root: \'./agent-ctx\' }); await lc.ready();');
|
|
67
67
|
console.log(' const memory = new Memory({ store: liteCtxAsStore(lc) }); // ← only this line changes');
|
|
68
68
|
console.log('\n Install it (`npm install litectx`) to run the litectx half of this example.');
|
|
69
69
|
return;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
// litectx >= 0.21 takes a `root` DIRECTORY (it manages its own files under it), not a `dbPath` file —
|
|
73
|
+
// passing `{ dbPath }` throws on construction. Use a fresh temp dir per run.
|
|
74
|
+
const root = mkdtempSync(join(tmpdir(), `litectx-as-store-${process.pid}-`));
|
|
75
|
+
const lc = new LiteCtx({ root });
|
|
73
76
|
if (typeof lc.ready === 'function') await lc.ready();
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
try {
|
|
78
|
+
await hostWorkflow(new Memory({ store: liteCtxAsStore(lc) }), 'litectx (ranked, graph-aware)');
|
|
79
|
+
} finally {
|
|
80
|
+
if (typeof lc.close === 'function') lc.close();
|
|
81
|
+
rmSync(root, { recursive: true, force: true });
|
|
82
|
+
}
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
main().catch((err) => { console.error(err); process.exit(1); });
|
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
// Run: OPENAI_API_KEY=... node examples/with-bareguard.mjs
|
|
7
7
|
//
|
|
8
8
|
// What this demonstrates:
|
|
9
|
-
// - Single-gate governance: every tool call traverses gate.check; every
|
|
10
|
-
// result reaches gate.record (via wrapTools).
|
|
11
|
-
// -
|
|
9
|
+
// - Single-gate governance: every tool call traverses gate.check (policy); every
|
|
10
|
+
// result reaches gate.record (via onToolResult + onLlmResult — wrapTools is deprecated).
|
|
11
|
+
// - Primitive enforcement: a shell→primitive actionTranslator makes bash.allow + fs.readScope
|
|
12
|
+
// actually fire (the default translator leaves them dead — relayfact F7/BA-3).
|
|
13
|
+
// - Budget halt: if accumulated cost exceeds maxCostUsd, gate halts the loop (a HaltError,
|
|
14
|
+
// caught by the Loop as a clean exit — distinct from a per-action deny; see humanChannel below).
|
|
12
15
|
// - Audit log: one JSONL line per gated event at ./bareagent-audit.jsonl.
|
|
13
16
|
// - humanChannel: required by bareguard. Here we auto-deny asks; in real use
|
|
14
17
|
// wire it to a chat platform, terminal prompt, etc.
|
|
@@ -29,16 +32,40 @@ const gate = new Gate({
|
|
|
29
32
|
audit: { path: './bareagent-audit.jsonl' },
|
|
30
33
|
// Required by bareguard: any ask/halt event flows through here.
|
|
31
34
|
// Auto-deny is the safest default for headless use; in real apps, wire to
|
|
32
|
-
// a Telegram/Slack/terminal prompt and return
|
|
35
|
+
// a Telegram/Slack/terminal prompt and return a decision.
|
|
36
|
+
// • { decision: 'deny' } → denies THIS ONE action only; the loop keeps running and the
|
|
37
|
+
// model may try something else. deny does NOT stop the loop (relayfact F11/BA-6) — under a
|
|
38
|
+
// retry wrapper like `refine` a denied-but-not-stopped loop can keep spending.
|
|
39
|
+
// • { decision: 'terminate' } → the clean-halt path: surfaces as a HaltError the Loop catches
|
|
40
|
+
// and exits on. Use this (or a budget/turn cap) when you mean "stop", not "skip this action".
|
|
33
41
|
humanChannel: async (event) => {
|
|
34
|
-
console.warn(`[humanChannel] ${event.kind}: ${event.rule} — auto-denying`);
|
|
42
|
+
console.warn(`[humanChannel] ${event.kind}: ${event.rule} — auto-denying (this action only)`);
|
|
35
43
|
return { decision: 'deny' };
|
|
36
44
|
},
|
|
37
45
|
});
|
|
38
46
|
await gate.init();
|
|
39
47
|
|
|
40
|
-
// 2. Wire the gate
|
|
41
|
-
|
|
48
|
+
// 2. Wire the gate. The DEFAULT translator emits `{ type: <toolName> }` — which matches bareguard's
|
|
49
|
+
// `tools.allowlist`/`tools.denylist` (they read `action.type`) but does NOT activate the `bash`/`fs`/`net`
|
|
50
|
+
// primitives: those fire only on `action.type ∈ {bash, read, write, edit}` and read `action.cmd`/`action.path`.
|
|
51
|
+
// So to make the `bash.allow` + `fs.readScope` config above actually enforce, we MUST translate the shell
|
|
52
|
+
// tools into those primitive shapes — otherwise the caps are silently dead (relayfact F7/BA-3).
|
|
53
|
+
const actionTranslator = (toolName, args, ctx) => {
|
|
54
|
+
switch (toolName) {
|
|
55
|
+
// shell_run is argv (no shell); bareguard's bash.allow matches `cmd.startsWith(prefix)`, so join argv[0..].
|
|
56
|
+
case 'shell_run': return { type: 'bash', cmd: (args?.argv || []).join(' '), args, _ctx: ctx ?? null };
|
|
57
|
+
case 'shell_exec': return { type: 'bash', cmd: args?.command, args, _ctx: ctx ?? null };
|
|
58
|
+
// shell_read / shell_grep are reads — gate them through fs.readScope.
|
|
59
|
+
case 'shell_read':
|
|
60
|
+
case 'shell_grep': return { type: 'read', path: args?.path, args, _ctx: ctx ?? null };
|
|
61
|
+
// shell_write is a write — gate it through fs.writeScope (add writeScope to the Gate config to enforce).
|
|
62
|
+
case 'shell_write': return { type: 'write', path: args?.path, args, _ctx: ctx ?? null };
|
|
63
|
+
default: return { type: toolName, args, _ctx: ctx ?? null };
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
// onToolResult + onLlmResult are the current wiring (wrapTools is deprecated — it loses _ctx and never sees
|
|
67
|
+
// LLM cost, so the budget cap can't cover token-only rounds). policy gates pre-call; the result hooks record.
|
|
68
|
+
const { policy, onToolResult, onLlmResult } = wireGate(gate, { actionTranslator });
|
|
42
69
|
|
|
43
70
|
// 3. Standard bareagent setup.
|
|
44
71
|
const provider = new OpenAI({
|
|
@@ -50,16 +77,19 @@ const { tools } = createShellTools();
|
|
|
50
77
|
const loop = new Loop({
|
|
51
78
|
provider,
|
|
52
79
|
policy,
|
|
80
|
+
onToolResult, // every tool result → gate.record (with _ctx in scope)
|
|
81
|
+
onLlmResult, // every LLM round → gate.record so budget.maxCostUsd covers token-only spend
|
|
53
82
|
onError: (err, meta) => console.error(`[onError ${meta.source}]`, err.message),
|
|
54
83
|
});
|
|
55
84
|
|
|
56
|
-
// 4. Run.
|
|
85
|
+
// 4. Run. Pass the tools as-is — gating is via policy/onToolResult, not by wrapping execute().
|
|
57
86
|
const result = await loop.run(
|
|
58
87
|
[{ role: 'user', content: 'List the contents of /tmp using shell_run with argv ["ls", "/tmp"].' }],
|
|
59
|
-
|
|
88
|
+
tools,
|
|
60
89
|
);
|
|
61
90
|
|
|
62
91
|
console.log('---');
|
|
63
92
|
console.log('text:', result.text);
|
|
64
|
-
|
|
93
|
+
// Loop returns the meter under result.metrics (result.cost was removed); costUsd is null when unpriced.
|
|
94
|
+
console.log('cost:', result.metrics?.costUsd != null ? result.metrics.costUsd.toFixed(6) : 'n/a (unpriced)');
|
|
65
95
|
console.log('audit log → ./bareagent-audit.jsonl');
|
package/package.json
CHANGED
package/src/recurse.d.ts
CHANGED
|
@@ -23,7 +23,12 @@ export type RecurseCtx = {
|
|
|
23
23
|
*/
|
|
24
24
|
depth?: number | undefined;
|
|
25
25
|
/**
|
|
26
|
-
* - Optional event stream forwarded to each worker Loop (receipts substrate).
|
|
26
|
+
* - Optional event stream forwarded to each worker Loop (receipts substrate). This
|
|
27
|
+
* is the observability channel for worker activity (relayfact F15/BA-5): recurse intentionally does NOT take
|
|
28
|
+
* `onToolCall`/`onText` Loop callbacks — instead every worker Loop emits `loop:tool_call` / `loop:tool_result`
|
|
29
|
+
* (and `loop:text`/`loop:done`) to THIS stream (loop.js), so a consumer observes worker tool calls by reading
|
|
30
|
+
* the stream, not via per-call callbacks. The full audit trail is stream + the RC-10 receipts tree + (if a
|
|
31
|
+
* gate is wired) the bareguard audit.
|
|
27
32
|
*/
|
|
28
33
|
stream?: object;
|
|
29
34
|
/**
|
|
@@ -59,11 +64,51 @@ export type RecurseOptions = {
|
|
|
59
64
|
* and can override it (and any safety framing) for every worker in the tree. Caller-trusted input only.
|
|
60
65
|
*/
|
|
61
66
|
persona?: string | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* - (BA-9 / relayfact F19) An optional caller-supplied READ-ONLY working-context
|
|
69
|
+
* blob (e.g. "project root: /abs/path\nfiles are relative to it") prepended to EVERY worker's task message as
|
|
70
|
+
* a `Working context:` block, so a sliced child can LOCATE its artifact — the concrete context (absolute
|
|
71
|
+
* paths / cwd) the Planner strips when it paraphrases the parent goal into child subtasks. CARRIES DOWN the
|
|
72
|
+
* tree (preserved by `forChild`, like `persona`) and, when forced fan-out plans, is forwarded as the Planner's
|
|
73
|
+
* `info` so the slices themselves are path-aware. Also shown to the verifier (neutral FACTS, not a stance, so
|
|
74
|
+
* no anti-sycophancy concern — and an agentic critic needs the path to exercise the artifact). Distinct from
|
|
75
|
+
* `persona`: persona is a privileged SYSTEM-prompt stance; context is run-state facts on the USER message.
|
|
76
|
+
* Absent/blank ⇒ byte-identical to pre-BA-9 (backward-compatible). Validated live (`poc/ba9-context-thread.mjs`:
|
|
77
|
+
* a weak model went 0/3 → 3/3 at locating an unguessable file once the root was threaded).
|
|
78
|
+
* **SECURITY:** this becomes part of every worker's prompt (and the verifier's) — lower-privilege than
|
|
79
|
+
* `persona` (the USER message, not the SYSTEM prompt) but still a prompt-injection surface. Intended for
|
|
80
|
+
* TRUSTED run-state (paths/cwd); do NOT pass untrusted / end-user-controlled text here.
|
|
81
|
+
*/
|
|
82
|
+
context?: string | undefined;
|
|
62
83
|
/**
|
|
63
84
|
* - Handle tools offered to EVERY worker (RC-5 pull-default: litectx
|
|
64
85
|
* `recall`/`get`, wired at build step 7). Workers query on demand; never the whole corpus.
|
|
65
86
|
*/
|
|
66
87
|
tools?: import("../types").ToolDef[] | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* (Opt-in, BA-8 / relayfact F17) Turn a DEFINITE LEAF (a node that is offered no `spawn_child` — `simple`
|
|
90
|
+
* tier or at `maxDepth`) into a bounded generate→sense→regenerate loop instead of a single pass, so a failed
|
|
91
|
+
* slice can self-correct. `sensor` is a DETERMINISTIC close (test/compile/lint — NOT a model judge, R-S8) that
|
|
92
|
+
* returns a `Verdict`; on a non-pass its `critique` (the GAP, not the transcript) is fed FRESH into the next
|
|
93
|
+
* attempt (D6/A1 anti-anchoring) and the **retry temperature ESCALATES** (`temperatures`, default
|
|
94
|
+
* `[0.2,0.7,1.0]`) — the live-validated requirement that lets a weak model escape a repeat-the-same-mistake rut
|
|
95
|
+
* (`poc/ba8-leaf-refine.mjs`: 0/5 → 2-3/5; flat temp recovers 0/5). `maxIterations` defaults to
|
|
96
|
+
* `temperatures.length`; the REAL bound is bareguard (each attempt is gate-checked + metered). CARRIES DOWN the
|
|
97
|
+
* tree (preserved by `forChild`), so it engages at the leaves of a Family-A decomposition. Recovery is PARTIAL
|
|
98
|
+
* (a stubborn blind spot may persist) — `receipts.refineLeaf.passed` reports honestly. Does NOT apply to a node
|
|
99
|
+
* that delegates (its children + the tree verify own quality), nor to the scan/fanout/partition dispatch paths.
|
|
100
|
+
* Absent ⇒ a leaf is a single pass (byte-identical to pre-BA-8). An error-keyed `recall` is the CALLER's tool
|
|
101
|
+
* (`opts.tools`) keyed off the fed-back critique — bareagent stays litectx-agnostic.
|
|
102
|
+
*/
|
|
103
|
+
refineLeaf?: {
|
|
104
|
+
sensor: (result: any, ctx: {
|
|
105
|
+
task: string;
|
|
106
|
+
context: string | undefined;
|
|
107
|
+
contract: string | null;
|
|
108
|
+
}) => (Verdict | Promise<Verdict>);
|
|
109
|
+
maxIterations?: number;
|
|
110
|
+
temperatures?: number[];
|
|
111
|
+
} | undefined;
|
|
67
112
|
/**
|
|
68
113
|
* - Definition of done (A3). When present, the verifier grades against THIS,
|
|
69
114
|
* not the loose task, and verification always runs.
|
|
@@ -172,6 +217,16 @@ export type RecurseNode = {
|
|
|
172
217
|
* - The worker Loop's `metrics.tokens`.
|
|
173
218
|
*/
|
|
174
219
|
tokens: object | null;
|
|
220
|
+
/**
|
|
221
|
+
* - (BA-8) when this leaf
|
|
222
|
+
* ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally passed
|
|
223
|
+
* (false = honest non-recovery, not a faked success).
|
|
224
|
+
*/
|
|
225
|
+
refineLeaf?: {
|
|
226
|
+
iterations: number;
|
|
227
|
+
passed: boolean;
|
|
228
|
+
temperatures: number[];
|
|
229
|
+
} | undefined;
|
|
175
230
|
model: string | null;
|
|
176
231
|
/**
|
|
177
232
|
* - (§10 step 7) the retrieval mode this node ran (`scan`/`search`/`exact`),
|
|
@@ -252,7 +307,12 @@ export type Slice = {
|
|
|
252
307
|
* and worker tokens are all real spend (BA1: never invisible).
|
|
253
308
|
* @property {number} [depth] - The current recursion depth (0 at the top). Incremented on each self-call;
|
|
254
309
|
* threaded into `policy`. Callers normally omit it (defaults to 0).
|
|
255
|
-
* @property {object} [stream] - Optional event stream forwarded to each worker Loop (receipts substrate).
|
|
310
|
+
* @property {object} [stream] - Optional event stream forwarded to each worker Loop (receipts substrate). This
|
|
311
|
+
* is the observability channel for worker activity (relayfact F15/BA-5): recurse intentionally does NOT take
|
|
312
|
+
* `onToolCall`/`onText` Loop callbacks — instead every worker Loop emits `loop:tool_call` / `loop:tool_result`
|
|
313
|
+
* (and `loop:text`/`loop:done`) to THIS stream (loop.js), so a consumer observes worker tool calls by reading
|
|
314
|
+
* the stream, not via per-call callbacks. The full audit trail is stream + the RC-10 receipts tree + (if a
|
|
315
|
+
* gate is wired) the bareguard audit.
|
|
256
316
|
* @property {{recall: Function}} [litectx] - Optional litectx handle (RC-5, §10 step 7). Backs the `search`
|
|
257
317
|
* retrieval mode (`recall`). NOT used by `scan` — litectx has no exhaustive enumerate verb today; scan reads
|
|
258
318
|
* the generic array slice-source `opts.corpus` instead (the litectx-resident scan case waits on the litectx
|
|
@@ -273,8 +333,35 @@ export type Slice = {
|
|
|
273
333
|
* **SECURITY:** this is a PRIVILEGED system-prompt seam — treat `persona` like a system prompt. Do NOT pass
|
|
274
334
|
* untrusted / end-user-controlled text here; a hostile persona is prepended ahead of the decomposition policy
|
|
275
335
|
* and can override it (and any safety framing) for every worker in the tree. Caller-trusted input only.
|
|
336
|
+
* @property {string} [context] - (BA-9 / relayfact F19) An optional caller-supplied READ-ONLY working-context
|
|
337
|
+
* blob (e.g. "project root: /abs/path\nfiles are relative to it") prepended to EVERY worker's task message as
|
|
338
|
+
* a `Working context:` block, so a sliced child can LOCATE its artifact — the concrete context (absolute
|
|
339
|
+
* paths / cwd) the Planner strips when it paraphrases the parent goal into child subtasks. CARRIES DOWN the
|
|
340
|
+
* tree (preserved by `forChild`, like `persona`) and, when forced fan-out plans, is forwarded as the Planner's
|
|
341
|
+
* `info` so the slices themselves are path-aware. Also shown to the verifier (neutral FACTS, not a stance, so
|
|
342
|
+
* no anti-sycophancy concern — and an agentic critic needs the path to exercise the artifact). Distinct from
|
|
343
|
+
* `persona`: persona is a privileged SYSTEM-prompt stance; context is run-state facts on the USER message.
|
|
344
|
+
* Absent/blank ⇒ byte-identical to pre-BA-9 (backward-compatible). Validated live (`poc/ba9-context-thread.mjs`:
|
|
345
|
+
* a weak model went 0/3 → 3/3 at locating an unguessable file once the root was threaded).
|
|
346
|
+
* **SECURITY:** this becomes part of every worker's prompt (and the verifier's) — lower-privilege than
|
|
347
|
+
* `persona` (the USER message, not the SYSTEM prompt) but still a prompt-injection surface. Intended for
|
|
348
|
+
* TRUSTED run-state (paths/cwd); do NOT pass untrusted / end-user-controlled text here.
|
|
276
349
|
* @property {ToolDef[]} [tools] - Handle tools offered to EVERY worker (RC-5 pull-default: litectx
|
|
277
350
|
* `recall`/`get`, wired at build step 7). Workers query on demand; never the whole corpus.
|
|
351
|
+
* @property {{sensor: (result: any, ctx: {task: string, context: string|undefined, contract: string|null}) => (Verdict|Promise<Verdict>), maxIterations?: number, temperatures?: number[]}} [refineLeaf]
|
|
352
|
+
* (Opt-in, BA-8 / relayfact F17) Turn a DEFINITE LEAF (a node that is offered no `spawn_child` — `simple`
|
|
353
|
+
* tier or at `maxDepth`) into a bounded generate→sense→regenerate loop instead of a single pass, so a failed
|
|
354
|
+
* slice can self-correct. `sensor` is a DETERMINISTIC close (test/compile/lint — NOT a model judge, R-S8) that
|
|
355
|
+
* returns a `Verdict`; on a non-pass its `critique` (the GAP, not the transcript) is fed FRESH into the next
|
|
356
|
+
* attempt (D6/A1 anti-anchoring) and the **retry temperature ESCALATES** (`temperatures`, default
|
|
357
|
+
* `[0.2,0.7,1.0]`) — the live-validated requirement that lets a weak model escape a repeat-the-same-mistake rut
|
|
358
|
+
* (`poc/ba8-leaf-refine.mjs`: 0/5 → 2-3/5; flat temp recovers 0/5). `maxIterations` defaults to
|
|
359
|
+
* `temperatures.length`; the REAL bound is bareguard (each attempt is gate-checked + metered). CARRIES DOWN the
|
|
360
|
+
* tree (preserved by `forChild`), so it engages at the leaves of a Family-A decomposition. Recovery is PARTIAL
|
|
361
|
+
* (a stubborn blind spot may persist) — `receipts.refineLeaf.passed` reports honestly. Does NOT apply to a node
|
|
362
|
+
* that delegates (its children + the tree verify own quality), nor to the scan/fanout/partition dispatch paths.
|
|
363
|
+
* Absent ⇒ a leaf is a single pass (byte-identical to pre-BA-8). An error-keyed `recall` is the CALLER's tool
|
|
364
|
+
* (`opts.tools`) keyed off the fed-back critique — bareagent stays litectx-agnostic.
|
|
278
365
|
* @property {string} [contract] - Definition of done (A3). When present, the verifier grades against THIS,
|
|
279
366
|
* not the loose task, and verification always runs.
|
|
280
367
|
* @property {(result: any, ctx: {contract: string|null, task: string}) => (Verdict|Promise<Verdict>)} [evaluate]
|
|
@@ -333,6 +420,9 @@ export type Slice = {
|
|
|
333
420
|
* @property {boolean} incomplete
|
|
334
421
|
* @property {boolean} halted
|
|
335
422
|
* @property {object|null} tokens - The worker Loop's `metrics.tokens`.
|
|
423
|
+
* @property {{iterations: number, passed: boolean, temperatures: number[]}} [refineLeaf] - (BA-8) when this leaf
|
|
424
|
+
* ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally passed
|
|
425
|
+
* (false = honest non-recovery, not a faked success).
|
|
336
426
|
* @property {string|null} model
|
|
337
427
|
* @property {string|null} [retrieval] - (§10 step 7) the retrieval mode this node ran (`scan`/`search`/`exact`),
|
|
338
428
|
* or null/absent for a plain reasoning node.
|
package/src/recurse.js
CHANGED
|
@@ -28,6 +28,7 @@ const { Evaluator } = require('./evaluator');
|
|
|
28
28
|
const { Planner } = require('./planner');
|
|
29
29
|
const { runPlan } = require('./run-plan');
|
|
30
30
|
const { assessComplexity, isCritical } = require('./complexity');
|
|
31
|
+
const { refine } = require('./refine');
|
|
31
32
|
const { HaltError } = require('./errors');
|
|
32
33
|
const { DECOMPOSITION_POLICY, capabilityScrub } = require('./recurse-prompts');
|
|
33
34
|
const { synthesize } = require('./recurse-synthesize');
|
|
@@ -51,6 +52,11 @@ const DEFAULT_FANOUT_CONCURRENCY = 4;
|
|
|
51
52
|
// many parallel scan-workers a measured corpus needs. A calibratable knob (the §9.1 algorithm; corpus-specific),
|
|
52
53
|
// not a discovered constant — overridable via `opts.workerBudget`. 100 items ≈ a worker doing ~25 scan windows.
|
|
53
54
|
const DEFAULT_WORKER_BUDGET = 100;
|
|
55
|
+
// BA-8 leaf-refine: temperature ESCALATES per retry. The live POC (poc/ba8-leaf-refine.mjs) found that at a flat
|
|
56
|
+
// low temperature a weak model regenerates byte-identical wrong code and IGNORES even crisp deterministic
|
|
57
|
+
// feedback (0/5 recovery); recovery only appears once retries are given room to vary (0/5 → 2-3/5). So escalation
|
|
58
|
+
// is a DESIGN REQUIREMENT of the seam, not a tuning nicety. Overridable via `opts.refineLeaf.temperatures`.
|
|
59
|
+
const DEFAULT_REFINE_TEMPS = [0.2, 0.7, 1.0];
|
|
54
60
|
|
|
55
61
|
/**
|
|
56
62
|
* Split an array into EXACTLY `n` contiguous, near-equal chunks (the data-partition for the §11 width path).
|
|
@@ -82,6 +88,23 @@ function workerPersonaPrefix(persona) {
|
|
|
82
88
|
return p ? p + '\n\n' : '';
|
|
83
89
|
}
|
|
84
90
|
|
|
91
|
+
/**
|
|
92
|
+
* BA-9 (relayfact F19): prepend the caller's read-only working-context blob to a worker's task message, so a
|
|
93
|
+
* sliced child can LOCATE its artifact (absolute paths / cwd) — the concrete context the Planner otherwise
|
|
94
|
+
* strips when it paraphrases the parent goal into child subtasks. Distinct from `persona`: persona is a STANCE
|
|
95
|
+
* on the SYSTEM prompt (a privileged seam); context is neutral run-state FACTS on the USER message (the form
|
|
96
|
+
* the live POC `poc/ba9-context-thread.mjs` validated: no-context 0/3 → context 3/3 on a weak model). Absent/
|
|
97
|
+
* blank ⇒ the task message is byte-identical to pre-BA-9 (backward-compatible). Carries down the tree via
|
|
98
|
+
* `forChild` (a child of a worker rooted at `/proj` is still rooted at `/proj`), like `persona`.
|
|
99
|
+
* @param {string} task
|
|
100
|
+
* @param {unknown} context
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
function withContext(task, context) {
|
|
104
|
+
const c = typeof context === 'string' ? context.trim() : '';
|
|
105
|
+
return c ? `Working context (read-only):\n${c}\n\n${task}` : task;
|
|
106
|
+
}
|
|
107
|
+
|
|
85
108
|
/**
|
|
86
109
|
* The opts a delegated child inherits. Strips the parent's TOP-LEVEL SETPOINT — `contract`/`evaluate` grade
|
|
87
110
|
* the WHOLE task's final answer; a child grading its own slice against the whole definition-of-done is wasted
|
|
@@ -91,9 +114,10 @@ function workerPersonaPrefix(persona) {
|
|
|
91
114
|
* answered over the parent's corpus; a child has its own subtask and must not re-scan the parent's full corpus
|
|
92
115
|
* (that would fan a whole-corpus count out under every child). The `critical → force-verify` SAFETY FLOOR is
|
|
93
116
|
* unaffected — it keys on the task text via `isCritical`, not the contract, so a critical child still
|
|
94
|
-
* self-verifies. Handle tools (`opts.tools`), `synthesize`, `maxDepth`, and **`
|
|
95
|
-
* persona is a DURABLE worker stance (a child of a "senior security engineer" is still one)
|
|
96
|
-
*
|
|
117
|
+
* self-verifies. Handle tools (`opts.tools`), `synthesize`, `maxDepth`, **`persona`**, and **`context`** (BA-9)
|
|
118
|
+
* carry down — the persona is a DURABLE worker stance (a child of a "senior security engineer" is still one)
|
|
119
|
+
* and the context is durable run-state (a child rooted at `/proj` is still rooted at `/proj`), unlike the
|
|
120
|
+
* top-only `contract`/`evaluate` setpoint. They ride through the `...opts` spread (not in the strip list).
|
|
97
121
|
* @param {RecurseOptions} opts
|
|
98
122
|
* @returns {RecurseOptions}
|
|
99
123
|
*/
|
|
@@ -111,6 +135,30 @@ function forChild(opts) {
|
|
|
111
135
|
};
|
|
112
136
|
}
|
|
113
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The ctx handed to a worker `Loop.run({ ctx })` or to a direct `ctx.policy(...)` checkpoint — i.e. the ctx
|
|
140
|
+
* that a wired gate records VERBATIM into the audit as `action._ctx` (see `defaultActionTranslator` in
|
|
141
|
+
* src/bareguard-adapter.js). It STRIPS the live `provider` instance, because that object carries the API key
|
|
142
|
+
* (`provider.apiKey`) and bareguard serializes `_ctx` to disk — so an un-stripped ctx writes the raw
|
|
143
|
+
* `sk-…` key into the plaintext audit log (F16/BA-1, confirmed by relayfact probe-03).
|
|
144
|
+
*
|
|
145
|
+
* Only the AUDITED copy is cleaned: the provider still rides in the recurse-internal ctx that is threaded into
|
|
146
|
+
* each child `recurse()` self-call (children need `ctx.provider` to run), and the worker Loop already receives
|
|
147
|
+
* the provider as a constructor option — `Loop.run` never reads `ctx.provider`. The provider's IDENTITY is not
|
|
148
|
+
* lost from the audit either: the meter records the provider NAME on the `{type:'llm'}` action's args.
|
|
149
|
+
*
|
|
150
|
+
* NB: this strips the provider only — the leak that was grounded. A caller that threads its OWN secret-bearing
|
|
151
|
+
* fields onto ctx is backstopped by bareguard-side redaction (BG-1), the defense-in-depth pair to this fix.
|
|
152
|
+
* @param {RecurseCtx} ctx
|
|
153
|
+
* @param {object} [overrides] - extra fields to set on the audited copy (e.g. `{ depth }`).
|
|
154
|
+
* @returns {object}
|
|
155
|
+
*/
|
|
156
|
+
function auditSafeCtx(ctx, overrides = {}) {
|
|
157
|
+
const safe = { ...(ctx || {}) };
|
|
158
|
+
delete (/** @type {any} */ (safe)).provider;
|
|
159
|
+
return { ...safe, ...overrides };
|
|
160
|
+
}
|
|
161
|
+
|
|
114
162
|
/**
|
|
115
163
|
* @typedef {object} RecurseCtx
|
|
116
164
|
* The per-run runtime blob — the wiring, threaded down the whole recursion tree (and forwarded to the worker
|
|
@@ -122,7 +170,12 @@ function forChild(opts) {
|
|
|
122
170
|
* and worker tokens are all real spend (BA1: never invisible).
|
|
123
171
|
* @property {number} [depth] - The current recursion depth (0 at the top). Incremented on each self-call;
|
|
124
172
|
* threaded into `policy`. Callers normally omit it (defaults to 0).
|
|
125
|
-
* @property {object} [stream] - Optional event stream forwarded to each worker Loop (receipts substrate).
|
|
173
|
+
* @property {object} [stream] - Optional event stream forwarded to each worker Loop (receipts substrate). This
|
|
174
|
+
* is the observability channel for worker activity (relayfact F15/BA-5): recurse intentionally does NOT take
|
|
175
|
+
* `onToolCall`/`onText` Loop callbacks — instead every worker Loop emits `loop:tool_call` / `loop:tool_result`
|
|
176
|
+
* (and `loop:text`/`loop:done`) to THIS stream (loop.js), so a consumer observes worker tool calls by reading
|
|
177
|
+
* the stream, not via per-call callbacks. The full audit trail is stream + the RC-10 receipts tree + (if a
|
|
178
|
+
* gate is wired) the bareguard audit.
|
|
126
179
|
* @property {{recall: Function}} [litectx] - Optional litectx handle (RC-5, §10 step 7). Backs the `search`
|
|
127
180
|
* retrieval mode (`recall`). NOT used by `scan` — litectx has no exhaustive enumerate verb today; scan reads
|
|
128
181
|
* the generic array slice-source `opts.corpus` instead (the litectx-resident scan case waits on the litectx
|
|
@@ -144,8 +197,35 @@ function forChild(opts) {
|
|
|
144
197
|
* **SECURITY:** this is a PRIVILEGED system-prompt seam — treat `persona` like a system prompt. Do NOT pass
|
|
145
198
|
* untrusted / end-user-controlled text here; a hostile persona is prepended ahead of the decomposition policy
|
|
146
199
|
* and can override it (and any safety framing) for every worker in the tree. Caller-trusted input only.
|
|
200
|
+
* @property {string} [context] - (BA-9 / relayfact F19) An optional caller-supplied READ-ONLY working-context
|
|
201
|
+
* blob (e.g. "project root: /abs/path\nfiles are relative to it") prepended to EVERY worker's task message as
|
|
202
|
+
* a `Working context:` block, so a sliced child can LOCATE its artifact — the concrete context (absolute
|
|
203
|
+
* paths / cwd) the Planner strips when it paraphrases the parent goal into child subtasks. CARRIES DOWN the
|
|
204
|
+
* tree (preserved by `forChild`, like `persona`) and, when forced fan-out plans, is forwarded as the Planner's
|
|
205
|
+
* `info` so the slices themselves are path-aware. Also shown to the verifier (neutral FACTS, not a stance, so
|
|
206
|
+
* no anti-sycophancy concern — and an agentic critic needs the path to exercise the artifact). Distinct from
|
|
207
|
+
* `persona`: persona is a privileged SYSTEM-prompt stance; context is run-state facts on the USER message.
|
|
208
|
+
* Absent/blank ⇒ byte-identical to pre-BA-9 (backward-compatible). Validated live (`poc/ba9-context-thread.mjs`:
|
|
209
|
+
* a weak model went 0/3 → 3/3 at locating an unguessable file once the root was threaded).
|
|
210
|
+
* **SECURITY:** this becomes part of every worker's prompt (and the verifier's) — lower-privilege than
|
|
211
|
+
* `persona` (the USER message, not the SYSTEM prompt) but still a prompt-injection surface. Intended for
|
|
212
|
+
* TRUSTED run-state (paths/cwd); do NOT pass untrusted / end-user-controlled text here.
|
|
147
213
|
* @property {ToolDef[]} [tools] - Handle tools offered to EVERY worker (RC-5 pull-default: litectx
|
|
148
214
|
* `recall`/`get`, wired at build step 7). Workers query on demand; never the whole corpus.
|
|
215
|
+
* @property {{sensor: (result: any, ctx: {task: string, context: string|undefined, contract: string|null}) => (Verdict|Promise<Verdict>), maxIterations?: number, temperatures?: number[]}} [refineLeaf]
|
|
216
|
+
* (Opt-in, BA-8 / relayfact F17) Turn a DEFINITE LEAF (a node that is offered no `spawn_child` — `simple`
|
|
217
|
+
* tier or at `maxDepth`) into a bounded generate→sense→regenerate loop instead of a single pass, so a failed
|
|
218
|
+
* slice can self-correct. `sensor` is a DETERMINISTIC close (test/compile/lint — NOT a model judge, R-S8) that
|
|
219
|
+
* returns a `Verdict`; on a non-pass its `critique` (the GAP, not the transcript) is fed FRESH into the next
|
|
220
|
+
* attempt (D6/A1 anti-anchoring) and the **retry temperature ESCALATES** (`temperatures`, default
|
|
221
|
+
* `[0.2,0.7,1.0]`) — the live-validated requirement that lets a weak model escape a repeat-the-same-mistake rut
|
|
222
|
+
* (`poc/ba8-leaf-refine.mjs`: 0/5 → 2-3/5; flat temp recovers 0/5). `maxIterations` defaults to
|
|
223
|
+
* `temperatures.length`; the REAL bound is bareguard (each attempt is gate-checked + metered). CARRIES DOWN the
|
|
224
|
+
* tree (preserved by `forChild`), so it engages at the leaves of a Family-A decomposition. Recovery is PARTIAL
|
|
225
|
+
* (a stubborn blind spot may persist) — `receipts.refineLeaf.passed` reports honestly. Does NOT apply to a node
|
|
226
|
+
* that delegates (its children + the tree verify own quality), nor to the scan/fanout/partition dispatch paths.
|
|
227
|
+
* Absent ⇒ a leaf is a single pass (byte-identical to pre-BA-8). An error-keyed `recall` is the CALLER's tool
|
|
228
|
+
* (`opts.tools`) keyed off the fed-back critique — bareagent stays litectx-agnostic.
|
|
149
229
|
* @property {string} [contract] - Definition of done (A3). When present, the verifier grades against THIS,
|
|
150
230
|
* not the loose task, and verification always runs.
|
|
151
231
|
* @property {(result: any, ctx: {contract: string|null, task: string}) => (Verdict|Promise<Verdict>)} [evaluate]
|
|
@@ -205,6 +285,9 @@ function forChild(opts) {
|
|
|
205
285
|
* @property {boolean} incomplete
|
|
206
286
|
* @property {boolean} halted
|
|
207
287
|
* @property {object|null} tokens - The worker Loop's `metrics.tokens`.
|
|
288
|
+
* @property {{iterations: number, passed: boolean, temperatures: number[]}} [refineLeaf] - (BA-8) when this leaf
|
|
289
|
+
* ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally passed
|
|
290
|
+
* (false = honest non-recovery, not a faked success).
|
|
208
291
|
* @property {string|null} model
|
|
209
292
|
* @property {string|null} [retrieval] - (§10 step 7) the retrieval mode this node ran (`scan`/`search`/`exact`),
|
|
210
293
|
* or null/absent for a plain reasoning node.
|
|
@@ -363,7 +446,7 @@ async function recurse(task, ctx = {}, opts = {}) {
|
|
|
363
446
|
provider,
|
|
364
447
|
window: opts.window,
|
|
365
448
|
passes: opts.passes,
|
|
366
|
-
ctx:
|
|
449
|
+
ctx: auditSafeCtx(ctx, { depth }), // scan's Loop run ctx reaches the gate — strip provider (F16/BA-1)
|
|
367
450
|
onLlmResult: ctx.onLlmResult,
|
|
368
451
|
policy: ctx.policy,
|
|
369
452
|
}));
|
|
@@ -372,6 +455,15 @@ async function recurse(task, ctx = {}, opts = {}) {
|
|
|
372
455
|
if (Array.isArray(opts.corpus)) retrievalTools.push(buildExactTool(normalizeCorpus(opts.corpus)));
|
|
373
456
|
}
|
|
374
457
|
const handleTools = [...(Array.isArray(opts.tools) ? opts.tools : []), ...retrievalTools];
|
|
458
|
+
|
|
459
|
+
// BA-8 (opt-in): a DEFINITE leaf (no spawn offered — `simple` tier or at `maxDepth`) with a caller sensor runs
|
|
460
|
+
// as a bounded refine-with-escalation loop instead of a single pass, so a failed slice self-corrects. Gating on
|
|
461
|
+
// `!canSpawn` keeps it predictable (a node that may delegate is an orchestrator, not a leaf) and means the seam
|
|
462
|
+
// engages exactly at the leaves of a Family-A tree (it carries down via forChild). A no-op when unset.
|
|
463
|
+
if (!canSpawn && opts.refineLeaf && typeof opts.refineLeaf.sensor === 'function') {
|
|
464
|
+
return recurseRefineLeaf(task, ctx, opts, { provider, system, handleTools, depth, critical, node, sensor: opts.refineLeaf.sensor });
|
|
465
|
+
}
|
|
466
|
+
|
|
375
467
|
// NB-3: collect each child's declared RESULT value (copy-on-return: the value, never its transcript) so the
|
|
376
468
|
// reducer can aggregate them. Step-3's seam handed the receipts only, so a code-reduce could not see what to
|
|
377
469
|
// combine — this closes that gap and is what Family B (step 5) will reduce over `runPlan` results[].
|
|
@@ -392,9 +484,13 @@ async function recurse(task, ctx = {}, opts = {}) {
|
|
|
392
484
|
// Fresh message array = a true fresh window (RC-2 copy-on-return, IN side): the worker sees ONLY its task,
|
|
393
485
|
// never a parent transcript. `ctx.depth` is threaded so bareguard's policy can enforce the depth cap (§6).
|
|
394
486
|
const out = await loop.run(
|
|
395
|
-
|
|
487
|
+
// BA-9: prepend the caller's read-only working-context (paths/cwd) so this worker can locate its artifact.
|
|
488
|
+
[{ role: 'user', content: withContext(task, opts.context) }],
|
|
396
489
|
tools,
|
|
397
|
-
|
|
490
|
+
// auditSafeCtx: the run ctx reaches the gate as `_ctx`; strip the key-bearing provider (F16/BA-1). The
|
|
491
|
+
// worker Loop already has `provider` as a constructor option, so stripping it from the run ctx is invisible
|
|
492
|
+
// to the worker and only cleans the audited copy.
|
|
493
|
+
{ ctx: auditSafeCtx(ctx, { depth }) },
|
|
398
494
|
);
|
|
399
495
|
|
|
400
496
|
node.tokens = out.metrics ? out.metrics.tokens : null;
|
|
@@ -472,6 +568,96 @@ async function recurse(task, ctx = {}, opts = {}) {
|
|
|
472
568
|
}
|
|
473
569
|
}
|
|
474
570
|
|
|
571
|
+
/**
|
|
572
|
+
* BA-8 leaf-refine — run a DEFINITE leaf as a bounded generate→sense→regenerate loop (relayfact F17). Reuses the
|
|
573
|
+
* existing `refine.js` primitive (the Outcomes iterate→grade→revise port): each attempt is a FRESH leaf Loop
|
|
574
|
+
* (fresh window = fresh-feedback, D6/A1) seeded with the working-context'd task + (on a retry) the prior GAP, run
|
|
575
|
+
* at an ESCALATING temperature — the live-validated requirement that lets a weak model escape a repeat-the-same-
|
|
576
|
+
* mistake rut (a flat temperature recovered 0/5 in `poc/ba8-leaf-refine.mjs`). The `sensor` is the caller's
|
|
577
|
+
* DETERMINISTIC close (test/compile/lint, not a model judge). Governance is bareguard's: every attempt is gate-
|
|
578
|
+
* checked (`ctx.policy`) and metered (`onLlmResult`); a HaltError mid-loop is a clean `{incomplete}`. Honest
|
|
579
|
+
* non-recovery is reported (`receipts.refineLeaf.passed=false`), never a faked pass. An optional rubric `verify`
|
|
580
|
+
* still runs on top when a `contract`/`evaluate`/critical applies (the sensor gates retries; the rubric grades).
|
|
581
|
+
* @param {string} task
|
|
582
|
+
* @param {RecurseCtx} ctx
|
|
583
|
+
* @param {RecurseOptions} opts
|
|
584
|
+
* @param {{provider: Provider, system: string, handleTools: ToolDef[], depth: number, critical: boolean, node: RecurseNode, sensor: Function}} state
|
|
585
|
+
* @returns {Promise<RecurseResult>}
|
|
586
|
+
*/
|
|
587
|
+
async function recurseRefineLeaf(task, ctx, opts, state) {
|
|
588
|
+
const { provider, system, handleTools, depth, critical, node, sensor } = state;
|
|
589
|
+
node.model = provider.model || null;
|
|
590
|
+
const cfg = /** @type {{maxIterations?: number, temperatures?: number[]}} */ (opts.refineLeaf || {});
|
|
591
|
+
const temps = Array.isArray(cfg.temperatures) && cfg.temperatures.length ? cfg.temperatures : DEFAULT_REFINE_TEMPS;
|
|
592
|
+
const maxIterations = Number.isInteger(cfg.maxIterations) && /** @type {number} */ (cfg.maxIterations) > 0
|
|
593
|
+
? /** @type {number} */ (cfg.maxIterations) : temps.length;
|
|
594
|
+
|
|
595
|
+
// A refine leaf runs N Loops, so its receipts.tokens SUMS every attempt's spend (not just the last) — the
|
|
596
|
+
// honest cost of the node. The 4-tier tokens object (`{input,output,cacheCreation,cacheRead}`, loop.js) is flat
|
|
597
|
+
// numeric, so we accrue field-wise (robust to extra/renamed numeric fields). The gate already sees each attempt
|
|
598
|
+
// via onLlmResult independently; this is the receipts mirror. Stays null until an attempt produces metrics.
|
|
599
|
+
/** @type {Record<string, number>|null} */
|
|
600
|
+
let tokensSum = null;
|
|
601
|
+
const accrueTokens = (/** @type {any} */ t) => {
|
|
602
|
+
if (!t || typeof t !== 'object') return;
|
|
603
|
+
tokensSum = tokensSum || {};
|
|
604
|
+
for (const [k, v] of Object.entries(t)) if (typeof v === 'number') tokensSum[k] = (tokensSum[k] || 0) + v;
|
|
605
|
+
};
|
|
606
|
+
// One attempt = a fresh leaf Loop (no spawn tool: a retry is a direct correction, not a re-decomposition) at the
|
|
607
|
+
// iteration's temperature, with the GAP fed forward as fresh feedback. A governance halt → throw so refine stops.
|
|
608
|
+
const attempt = async ({ iteration, critique }) => {
|
|
609
|
+
const temperature = temps[Math.min(iteration, temps.length - 1)];
|
|
610
|
+
const loop = new Loop({
|
|
611
|
+
provider, system,
|
|
612
|
+
policy: ctx.policy || undefined,
|
|
613
|
+
onLlmResult: ctx.onLlmResult || undefined,
|
|
614
|
+
stream: ctx.stream || undefined,
|
|
615
|
+
throwOnError: false,
|
|
616
|
+
});
|
|
617
|
+
const base = withContext(task, opts.context);
|
|
618
|
+
const userText = critique
|
|
619
|
+
? `${base}\n\nYour previous attempt FAILED these checks:\n${critique}\n\nReturn a corrected result that passes ALL of them.`
|
|
620
|
+
: base;
|
|
621
|
+
const out = await loop.run([{ role: 'user', content: userText }], handleTools, { ctx: auditSafeCtx(ctx, { depth }), temperature });
|
|
622
|
+
accrueTokens(out.metrics ? out.metrics.tokens : null);
|
|
623
|
+
if (typeof out.error === 'string' && out.error.startsWith('halt:')) throw new HaltError('refine-leaf attempt halted', { rule: out.error.slice('halt:'.length) });
|
|
624
|
+
if (out.error) throw new Error(out.error); // a non-halt worker fault → honest incomplete
|
|
625
|
+
return out.text;
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
try {
|
|
629
|
+
const outcome = await refine({
|
|
630
|
+
attempt,
|
|
631
|
+
evaluate: (result, c) => sensor(result, { task, context: opts.context, contract: c.contract }),
|
|
632
|
+
contract: typeof opts.contract === 'string' ? opts.contract : undefined,
|
|
633
|
+
maxIterations,
|
|
634
|
+
});
|
|
635
|
+
node.tokens = tokensSum;
|
|
636
|
+
node.refineLeaf = { iterations: outcome.iterations, passed: !!(outcome.verdict && outcome.verdict.pass), temperatures: temps.slice(0, outcome.iterations) };
|
|
637
|
+
const result = outcome.result;
|
|
638
|
+
|
|
639
|
+
// Optional rubric layer on top of the deterministic sensor (RC-7): forced for critical, or a contract/override.
|
|
640
|
+
const wantVerify = critical || typeof opts.contract === 'string' || typeof opts.evaluate === 'function';
|
|
641
|
+
if (wantVerify) {
|
|
642
|
+
const verdict = await verify(task, result, ctx, opts);
|
|
643
|
+
node.verdict = verdict;
|
|
644
|
+
return { result, verdict, receipts: node };
|
|
645
|
+
}
|
|
646
|
+
// No rubric layer ⇒ the sensor's final verdict IS the node verdict (a non-pass is surfaced, not hidden).
|
|
647
|
+
node.verdict = outcome.verdict || null;
|
|
648
|
+
return { result, verdict: outcome.verdict || null, receipts: node };
|
|
649
|
+
} catch (err) {
|
|
650
|
+
node.tokens = tokensSum; // record whatever attempts DID spend, on both the halt and fault paths
|
|
651
|
+
if (err instanceof HaltError) {
|
|
652
|
+
node.halted = true;
|
|
653
|
+
node.incomplete = true;
|
|
654
|
+
return { incomplete: true, best: null, receipts: node };
|
|
655
|
+
}
|
|
656
|
+
node.incomplete = true;
|
|
657
|
+
return { incomplete: true, best: null, receipts: node };
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
475
661
|
/**
|
|
476
662
|
* SCAN (§10 step 7 / §9.2.1) — the default retrieval mode for a "how many / all" task over a corpus. A
|
|
477
663
|
* deterministic ORCHESTRATION, not a worker model call: every slice is processed, an isolated Loop LLM-judges
|
|
@@ -524,7 +710,7 @@ async function recurseScan(task, ctx, opts, state) {
|
|
|
524
710
|
provider,
|
|
525
711
|
window: opts.window,
|
|
526
712
|
passes: opts.passes,
|
|
527
|
-
ctx:
|
|
713
|
+
ctx: auditSafeCtx(ctx, { depth: state.depth }), // scan's Loop run ctx reaches the gate — strip provider (F16/BA-1)
|
|
528
714
|
onLlmResult: ctx.onLlmResult,
|
|
529
715
|
policy: ctx.policy,
|
|
530
716
|
});
|
|
@@ -615,7 +801,7 @@ async function recursePartition(task, ctx, opts, state) {
|
|
|
615
801
|
// spends (bounds the burst to zero); a plain deny is advisory (allowlist-safe), same contract as fanout.
|
|
616
802
|
if (typeof ctx.policy === 'function') {
|
|
617
803
|
try {
|
|
618
|
-
await ctx.policy('recurse_partition', { width, size, depth },
|
|
804
|
+
await ctx.policy('recurse_partition', { width, size, depth }, auditSafeCtx(ctx, { depth }));
|
|
619
805
|
} catch (err) {
|
|
620
806
|
if (err instanceof HaltError) throw err;
|
|
621
807
|
}
|
|
@@ -715,7 +901,10 @@ async function recurseFanout(task, ctx, opts, state) {
|
|
|
715
901
|
const planner = new Planner({ provider, onLlmResult: /** @type {any} */ (ctx.onLlmResult) || undefined });
|
|
716
902
|
let steps;
|
|
717
903
|
try {
|
|
718
|
-
|
|
904
|
+
// BA-9: forward the working-context as the Planner's `info` so the slices it writes are path-aware (a
|
|
905
|
+
// child still also receives `opts.context` directly via `forChild` — this just improves the split).
|
|
906
|
+
const planContext = typeof opts.context === 'string' && opts.context.trim() ? { count, info: opts.context } : { count };
|
|
907
|
+
steps = await planner.plan(task, planContext);
|
|
719
908
|
} catch (err) {
|
|
720
909
|
if (err instanceof HaltError) throw err;
|
|
721
910
|
node.incomplete = true;
|
|
@@ -731,7 +920,7 @@ async function recurseFanout(task, ctx, opts, state) {
|
|
|
731
920
|
// descriptor — the load-bearing budget signal is the HaltError, on bareguard's existing contract.
|
|
732
921
|
if (typeof ctx.policy === 'function') {
|
|
733
922
|
try {
|
|
734
|
-
await ctx.policy('recurse_fanout', { count: steps.length, depth },
|
|
923
|
+
await ctx.policy('recurse_fanout', { count: steps.length, depth }, auditSafeCtx(ctx, { depth }));
|
|
735
924
|
} catch (err) {
|
|
736
925
|
if (err instanceof HaltError) throw err;
|
|
737
926
|
// non-halt policy error/deny → advisory; proceed (per-worker policy still gates each child below)
|
|
@@ -896,6 +1085,9 @@ function buildSpawnTool(ctx, opts, depth, maxDepth, node, childResults) {
|
|
|
896
1085
|
*/
|
|
897
1086
|
function verify(task, result, ctx, opts) {
|
|
898
1087
|
const contract = typeof opts.contract === 'string' ? opts.contract : null;
|
|
1088
|
+
// BA-9: the verifier sees the working-context too — neutral facts (not a stance, so no anti-sycophancy risk),
|
|
1089
|
+
// and an agentic critic needs the path to exercise the artifact. A caller `evaluate` gets the RAW task (it owns
|
|
1090
|
+
// its own context); only the default isolated grader is contextualized.
|
|
899
1091
|
if (typeof opts.evaluate === 'function') {
|
|
900
1092
|
return Promise.resolve(opts.evaluate(result, { contract, task }));
|
|
901
1093
|
}
|
|
@@ -905,7 +1097,7 @@ function verify(task, result, ctx, opts) {
|
|
|
905
1097
|
? 'Judge whether the result satisfies the definition of done. Be strict and adversarial; cite the specific gap on any shortfall.'
|
|
906
1098
|
: 'Judge whether the result fully and correctly answers the goal. Be strict and adversarial; cite the specific gap on any shortfall.';
|
|
907
1099
|
return evaluator.evaluate(
|
|
908
|
-
task,
|
|
1100
|
+
withContext(task, opts.context),
|
|
909
1101
|
result,
|
|
910
1102
|
{ rubric, contract: contract || undefined },
|
|
911
1103
|
{ onLlmResult: /** @type {any} */ (ctx.onLlmResult), policy: ctx.policy },
|
package/tools/shell.d.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
declare namespace _exports {
|
|
2
|
+
export { GrepArgs, RunArgvArgs, ExecCommandArgs, ToolDef };
|
|
3
|
+
}
|
|
4
|
+
declare namespace _exports {
|
|
5
|
+
export { createShellTools };
|
|
6
|
+
export { _grepCore };
|
|
7
|
+
export { writeFile as _writeFile };
|
|
8
|
+
}
|
|
9
|
+
export = _exports;
|
|
10
|
+
type GrepArgs = {
|
|
2
11
|
pattern: string;
|
|
3
12
|
path: string;
|
|
4
13
|
recursive?: boolean | undefined;
|
|
@@ -11,28 +20,28 @@ export type GrepArgs = {
|
|
|
11
20
|
*/
|
|
12
21
|
timeout?: number | undefined;
|
|
13
22
|
};
|
|
14
|
-
|
|
23
|
+
type RunArgvArgs = {
|
|
15
24
|
argv: string[];
|
|
16
25
|
cwd?: string | undefined;
|
|
17
26
|
timeout?: number | undefined;
|
|
18
27
|
maxBuffer?: number | undefined;
|
|
19
28
|
env?: Record<string, string> | undefined;
|
|
20
29
|
};
|
|
21
|
-
|
|
30
|
+
type ExecCommandArgs = {
|
|
22
31
|
command: string;
|
|
23
32
|
cwd?: string | undefined;
|
|
24
33
|
timeout?: number | undefined;
|
|
25
34
|
maxBuffer?: number | undefined;
|
|
26
35
|
env?: Record<string, string> | undefined;
|
|
27
36
|
};
|
|
28
|
-
|
|
37
|
+
type ToolDef = import("../types").ToolDef;
|
|
29
38
|
/**
|
|
30
39
|
* Create the three shell tools. No options — configuration is per-call via tool args,
|
|
31
40
|
* gating is the caller's responsibility via `new Loop({ policy })`.
|
|
32
41
|
*
|
|
33
42
|
* @returns {{tools: ToolDef[]}}
|
|
34
43
|
*/
|
|
35
|
-
|
|
44
|
+
declare function createShellTools(): {
|
|
36
45
|
tools: ToolDef[];
|
|
37
46
|
};
|
|
38
47
|
/**
|
|
@@ -54,7 +63,7 @@ export function createShellTools(): {
|
|
|
54
63
|
* guarantee; a grounded bypass like `(a|a|a)*` passes it yet backtracks exponentially).
|
|
55
64
|
* @param {GrepArgs} args
|
|
56
65
|
*/
|
|
57
|
-
|
|
66
|
+
declare function _grepCore({ pattern, path: rawPath, recursive, maxMatches, flags }: GrepArgs): Promise<{
|
|
58
67
|
hits: {
|
|
59
68
|
file: string;
|
|
60
69
|
line: number;
|
|
@@ -63,3 +72,17 @@ export function _grepCore({ pattern, path: rawPath, recursive, maxMatches, flags
|
|
|
63
72
|
truncated: boolean;
|
|
64
73
|
fileCount: number;
|
|
65
74
|
}>;
|
|
75
|
+
/**
|
|
76
|
+
* Write text to a file (the BA-2 first-class write primitive — a coding agent must edit files, and routing
|
|
77
|
+
* writes through the shell is impractical: redirection is a shell metachar that an argv/bash allowlist denies).
|
|
78
|
+
* Creates parent directories. Caps size as a sanity ceiling. NO shell — so it gates cleanly through bareguard's
|
|
79
|
+
* fs primitive when the adopter translates `shell_write` → `{ type:'write', path }` (see createShellTools doc).
|
|
80
|
+
* @param {{path: string, content?: string, append?: boolean, maxBytes?: number}} args
|
|
81
|
+
* @returns {Promise<string>}
|
|
82
|
+
*/
|
|
83
|
+
declare function writeFile({ path: rawPath, content, append, maxBytes }: {
|
|
84
|
+
path: string;
|
|
85
|
+
content?: string;
|
|
86
|
+
append?: boolean;
|
|
87
|
+
maxBytes?: number;
|
|
88
|
+
}): Promise<string>;
|
package/tools/shell.js
CHANGED
|
@@ -3,13 +3,30 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Pure-Node shell tools — cross-platform (linux, macOS, Windows), no external binaries.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* Primitives:
|
|
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_run — run a command via an argv array (no shell, allowlist-friendly on argv[0])
|
|
11
|
+
* shell_exec — run a raw shell command with timeout + max buffer
|
|
10
12
|
*
|
|
11
|
-
* All
|
|
13
|
+
* All run through Loop's policy hook when wired via `new Loop({ policy })`.
|
|
12
14
|
* Library ships zero baked-in allowlist — gating is the agent author's responsibility.
|
|
15
|
+
*
|
|
16
|
+
* GATING WITH bareguard's fs/bash PRIMITIVES: these tools carry tool-named actions by default
|
|
17
|
+
* (`{ type:'shell_write' }`), which match `tools.allowlist`/`tools.denylist` but do NOT activate the
|
|
18
|
+
* `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 touches
|
|
20
|
+
* disk), translate it at the gate — see `examples/with-bareguard.mjs` for the `wireGate(gate, { actionTranslator })`
|
|
21
|
+
* mapping (`shell_write` → `{ type:'write', path }`, `shell_read`/`shell_grep` → `{ type:'read', path }`,
|
|
22
|
+
* `shell_run`/`shell_exec` → `{ type:'bash', cmd }`). A write tool alone is NOT auto-gated — validated by
|
|
23
|
+
* poc/ba2-write-tool-gate.mjs (without the translator the out-of-scope write leaks).
|
|
24
|
+
*
|
|
25
|
+
* CAVEAT (applies to read AND write scopes): bareguard's `fs` primitive matches paths LEXICALLY (no
|
|
26
|
+
* `realpath`/symlink resolution), so a symlink that lives INSIDE the allowed scope but points OUTSIDE it is
|
|
27
|
+
* not caught — a `shell_write` through such a link can escape the scope. If untrusted input can create
|
|
28
|
+
* symlinks under your scope, canonicalize (`fs.realpath`) before the gate, or keep the scope on a root with
|
|
29
|
+
* no attacker-writable symlinks. This is bareguard's documented lexical-match contract, not specific to this tool.
|
|
13
30
|
*/
|
|
14
31
|
|
|
15
32
|
/** @typedef {import('../types').ToolDef} ToolDef */
|
|
@@ -20,6 +37,7 @@ const { exec, execFile } = require('node:child_process');
|
|
|
20
37
|
const { Worker } = require('node:worker_threads');
|
|
21
38
|
|
|
22
39
|
const DEFAULT_READ_MAX_BYTES = 256 * 1024; // 256 KB
|
|
40
|
+
const DEFAULT_WRITE_MAX_BYTES = 5 * 1024 * 1024; // 5 MB — a sanity ceiling on a single write (LLM-authored)
|
|
23
41
|
const DEFAULT_GREP_MAX_MATCHES = 200;
|
|
24
42
|
const DEFAULT_GREP_TIMEOUT_MS = 5_000; // hard ceiling on a single grep — bounds ReDoS
|
|
25
43
|
const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
|
|
@@ -67,6 +85,31 @@ async function readEntry(rawPath, maxBytes) {
|
|
|
67
85
|
return fs.readFile(resolved, 'utf8');
|
|
68
86
|
}
|
|
69
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Write text to a file (the BA-2 first-class write primitive — a coding agent must edit files, and routing
|
|
90
|
+
* writes through the shell is impractical: redirection is a shell metachar that an argv/bash allowlist denies).
|
|
91
|
+
* Creates parent directories. Caps size as a sanity ceiling. NO shell — so it gates cleanly through bareguard's
|
|
92
|
+
* fs primitive when the adopter translates `shell_write` → `{ type:'write', path }` (see createShellTools doc).
|
|
93
|
+
* @param {{path: string, content?: string, append?: boolean, maxBytes?: number}} args
|
|
94
|
+
* @returns {Promise<string>}
|
|
95
|
+
*/
|
|
96
|
+
async function writeFile({ path: rawPath, content = '', append = false, maxBytes }) {
|
|
97
|
+
if (typeof rawPath !== 'string' || rawPath.length === 0) {
|
|
98
|
+
throw new Error('shell_write requires a non-empty "path" string');
|
|
99
|
+
}
|
|
100
|
+
const text = content == null ? '' : String(content);
|
|
101
|
+
const cap = maxBytes || DEFAULT_WRITE_MAX_BYTES;
|
|
102
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
103
|
+
if (bytes > cap) {
|
|
104
|
+
throw new Error(`shell_write content is ${bytes} bytes, over the ${cap}-byte cap (pass maxBytes to raise it)`);
|
|
105
|
+
}
|
|
106
|
+
const resolved = path.resolve(expandHome(rawPath));
|
|
107
|
+
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
108
|
+
if (append) await fs.appendFile(resolved, text, 'utf8');
|
|
109
|
+
else await fs.writeFile(resolved, text, 'utf8');
|
|
110
|
+
return `${append ? 'appended' : 'wrote'} ${bytes} bytes to ${resolved}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
70
113
|
// Probe the first 1KB for NUL bytes to skip binary files in grep walks.
|
|
71
114
|
/** @param {string} filePath */
|
|
72
115
|
async function isProbablyText(filePath) {
|
|
@@ -373,6 +416,23 @@ function createShellTools() {
|
|
|
373
416
|
},
|
|
374
417
|
execute: async (/** @type {GrepArgs} */ args) => grepPath(args),
|
|
375
418
|
},
|
|
419
|
+
{
|
|
420
|
+
name: 'shell_write',
|
|
421
|
+
description: 'Write text to a file (overwriting it), creating parent directories as needed. No shell — so an ' +
|
|
422
|
+
'fs.writeScope policy can gate it by path (translate to {type:"write"}). Use append:true to add to the end ' +
|
|
423
|
+
'instead of overwriting. Returns a "wrote N bytes to <path>" summary. Max 5MB per write by default.',
|
|
424
|
+
parameters: {
|
|
425
|
+
type: 'object',
|
|
426
|
+
properties: {
|
|
427
|
+
path: { type: 'string', description: 'Target file path. ~ expands to home. Parent dirs are created.' },
|
|
428
|
+
content: { type: 'string', description: 'The full text to write (UTF-8).' },
|
|
429
|
+
append: { type: 'boolean', description: 'Append to the file instead of overwriting it (default false).' },
|
|
430
|
+
maxBytes: { type: 'integer', description: 'Reject a write larger than this many bytes (default 5242880).' },
|
|
431
|
+
},
|
|
432
|
+
required: ['path', 'content'],
|
|
433
|
+
},
|
|
434
|
+
execute: async (/** @type {{path: string, content?: string, append?: boolean, maxBytes?: number}} */ args) => writeFile(args),
|
|
435
|
+
},
|
|
376
436
|
{
|
|
377
437
|
name: 'shell_run',
|
|
378
438
|
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.',
|
|
@@ -413,4 +473,4 @@ function createShellTools() {
|
|
|
413
473
|
return { tools };
|
|
414
474
|
}
|
|
415
475
|
|
|
416
|
-
module.exports = { createShellTools, _grepCore };
|
|
476
|
+
module.exports = { createShellTools, _grepCore, _writeFile: writeFile };
|