bare-agent 0.24.0 → 0.25.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 +1 -1
- package/bareagent.context.md +3 -1
- package/package.json +1 -1
- package/src/loop.d.ts +18 -0
- package/src/loop.js +37 -0
- package/src/recurse.d.ts +16 -0
- package/src/recurse.js +21 -0
package/README.md
CHANGED
|
@@ -119,7 +119,7 @@ console.log(result.count, result.matchedIds); // a code-derived count + the id
|
|
|
119
119
|
|
|
120
120
|
> **⚠️ Cost is open by design — wire a cap.** `recurse()` adds no intrinsic total-work limit. On the model-driven default a node can spawn up to ~100 children per level, each recursing to `maxDepth` (default 3), so **token / $ spend compounds and is bounded only by your gate** — not by recurse. Run it **with bareguard** (`ctx.policy`, which enforces depth/budget/call caps) **or with some token/USD cap** for any non-trivial or untrusted task; ungoverned, a weak model that over-decomposes *will* burn tokens. For a hard local brake without a gate, set `maxDepth: 1` (flat, no nesting). The forced modes (`mode:'fanout'` / `'partition'`) are bounded by a deterministic count + concurrency cap; the open path is the model-driven default.
|
|
121
121
|
|
|
122
|
-
**Govern — one gate over both axes.** `wireGate(gate)` routes every LLM + tool call through one bareguard policy + audit + budget. Denied tools never reach the model; halts (turn / budget / content caps) exit cleanly. `require('bare-agent/bareguard')`
|
|
122
|
+
**Govern — one gate over both axes.** `wireGate(gate)` routes every LLM + tool call through one bareguard policy + audit + budget. Denied tools never reach the model; halts (turn / budget / content caps) exit cleanly. A plain deny stays advisory (the model can pivot to an allowed tool), but the Loop short-circuits a *spin* — `maxConsecutiveDenials` consecutive denials of the same action (default 3) stop the run with `error:'denied:<tool>'` instead of burning the budget to the cap; under `recurse` that surfaces as `{ incomplete, blocker:'governance-deny' }`. `require('bare-agent/bareguard')`
|
|
123
123
|
|
|
124
124
|
**Providers:** OpenAI-compatible (OpenAI, OpenRouter, Groq, vLLM, LM Studio), Anthropic, Gemini (native), Ollama, CLIPipe, Fallback — or bring your own (one `generate` method). All return the same shape; swap freely. Usage including prompt-cache tiers is normalized, so `result.metrics` reports honest cumulative tokens + cost — and `null`, never a silent `0`, for a model it couldn't price. A model that rejects a non-default `temperature` (e.g. `claude-sonnet-5`, OpenAI o1/gpt-5-class return a `400`) is handled gracefully — the provider drops the param and retries once rather than failing the call, surfacing `temperatureDropped` so a caller can report the effective value.
|
|
125
125
|
|
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.25.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
|
|
|
@@ -379,6 +379,8 @@ if (result.error?.startsWith('halt:')) {
|
|
|
379
379
|
|
|
380
380
|
Halt-severity decisions exit the loop cleanly via a typed `HaltError` — full mechanics (sealed `msgs`, `halt:<rule>` error token, `loop:done{halted:true}` event, `throwOnError:true` interaction, `halt:unknown` coalesce) are in the **Halt decisions throw `HaltError`** paragraph below. Short version: check `result.error?.startsWith('halt:')` after the run.
|
|
381
381
|
|
|
382
|
+
**Deny-spin short-circuit (`maxConsecutiveDenials`, default 3, v0.25+).** A *non-halt* deny (a `policy` verdict that isn't `true` — e.g. a `humanChannel: deny`, an allowlist miss, a `content`/`fs.writeScope` block) is **advisory**: it's fed back to the model as a tool result so the model can pivot to a different allowed tool. But a model that keeps retrying the *same* denied action would otherwise spin every round until your `budget.maxCostUsd` finally halts it — burning the whole cap with no progress (this bit a coding agent whose write kept tripping `content.askPatterns`). The Loop now counts **consecutive** denials (any allowed call resets the streak, preserving the pivot) and short-circuits at `maxConsecutiveDenials` with `result.error === 'denied:<tool>'` (a clean return, transcript sealed — never a throw). Check `result.error?.startsWith('denied:')` to distinguish a governance block from a completed run; set `maxConsecutiveDenials: 0` (or `Infinity`) on `new Loop({...})` to restore the pure-advisory behavior. Under `recurse`, a short-circuited worker returns a **labeled** `{ incomplete: true, blocker: 'governance-deny' }` (and `receipts.blocker`) so you can widen scope / re-gate / escalate rather than read it as a model failure.
|
|
383
|
+
|
|
382
384
|
Legacy `wrapTool` / `wrapTools` are retained as deprecation shims (one-shot console warning, removal in 1.0). Migration: replace `wrapTools(tools)` at `loop.run()` with `filterTools(tools)` once upfront + `onLlmResult` / `onToolResult` on `new Loop({...})` to pick up LLM-cost recording and `_ctx` threading.
|
|
383
385
|
|
|
384
386
|
**`actionTranslator` for bash/fs primitive activation (v0.10.1+).** Bareguard's `bashCheck` / `fsCheck` / `netCheck` only fire when `action.type === 'bash'` / `'read'` / `'write'` / `'fetch'`. The default action shape is `{type: toolName, args, _ctx}` which matches `tools.denylist` / `tools.allowlist` but does NOT activate those primitives. Adopters who want both pass `wireGate(gate, { actionTranslator })`. Since bareguard 0.4.1+, the primitives read fields from either flat (`action.cmd`) or nested (`action.args.cmd` / `.command`) shapes, so you can pass args through verbatim:
|
package/package.json
CHANGED
package/src/loop.d.ts
CHANGED
|
@@ -52,6 +52,16 @@ export type LoopOptions = {
|
|
|
52
52
|
*/
|
|
53
53
|
onLlmResult?: Function | undefined;
|
|
54
54
|
onToolResult?: Function | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* - BA-11 safety net (default 3). Short-circuit the run when
|
|
57
|
+
* `policy` denies this many tool calls IN A ROW with no allowed call in between — a governance deny is
|
|
58
|
+
* not a recoverable tool error, so a model that keeps retrying variants of a denied action would
|
|
59
|
+
* otherwise burn the budget to the cap without progress (probe-16: 16 calls, sensor never reached). Any
|
|
60
|
+
* tool call that PASSES policy resets the streak, preserving allowlist-safe pivoting (deny X → allow Y).
|
|
61
|
+
* The run returns cleanly with `error: 'denied:<tool>'` (mirrors the halt return; never throws even under
|
|
62
|
+
* throwOnError). Set `0` or `Infinity` to disable (restores pre-BA-11 advisory-deny behavior).
|
|
63
|
+
*/
|
|
64
|
+
maxConsecutiveDenials?: number | undefined;
|
|
55
65
|
/**
|
|
56
66
|
* - Removed in v0.8; presence throws a migration error.
|
|
57
67
|
*/
|
|
@@ -79,6 +89,7 @@ export class Loop {
|
|
|
79
89
|
throwOnError: boolean;
|
|
80
90
|
store: import("../types").Store | null;
|
|
81
91
|
policy: Function | null;
|
|
92
|
+
maxConsecutiveDenials: number;
|
|
82
93
|
assemble: Function | null;
|
|
83
94
|
trim: Function | null;
|
|
84
95
|
onLlmResult: Function | null;
|
|
@@ -228,6 +239,13 @@ export function estimateCost(model: string | null, usage: Usage | null): number
|
|
|
228
239
|
* gate.record (via wireGate). `event.kind` discriminates the source: `'turn'` for a main-loop round,
|
|
229
240
|
* `'summarize'` for an out-of-band `ctx.summarize` call (R-C6). Both count against the budget.
|
|
230
241
|
* @property {Function} [onToolResult]
|
|
242
|
+
* @property {number} [maxConsecutiveDenials] - BA-11 safety net (default 3). Short-circuit the run when
|
|
243
|
+
* `policy` denies this many tool calls IN A ROW with no allowed call in between — a governance deny is
|
|
244
|
+
* not a recoverable tool error, so a model that keeps retrying variants of a denied action would
|
|
245
|
+
* otherwise burn the budget to the cap without progress (probe-16: 16 calls, sensor never reached). Any
|
|
246
|
+
* tool call that PASSES policy resets the streak, preserving allowlist-safe pivoting (deny X → allow Y).
|
|
247
|
+
* The run returns cleanly with `error: 'denied:<tool>'` (mirrors the halt return; never throws even under
|
|
248
|
+
* throwOnError). Set `0` or `Infinity` to disable (restores pre-BA-11 advisory-deny behavior).
|
|
231
249
|
* @property {number} [maxRounds] - Removed in v0.8; presence throws a migration error.
|
|
232
250
|
*/
|
|
233
251
|
/** @type {Record<string, {in: number, out: number, cacheReadMult?: number, cacheWriteMult?: number}>} */
|
package/src/loop.js
CHANGED
|
@@ -49,6 +49,13 @@ const { ToolError, HaltError } = require('./errors');
|
|
|
49
49
|
* gate.record (via wireGate). `event.kind` discriminates the source: `'turn'` for a main-loop round,
|
|
50
50
|
* `'summarize'` for an out-of-band `ctx.summarize` call (R-C6). Both count against the budget.
|
|
51
51
|
* @property {Function} [onToolResult]
|
|
52
|
+
* @property {number} [maxConsecutiveDenials] - BA-11 safety net (default 3). Short-circuit the run when
|
|
53
|
+
* `policy` denies this many tool calls IN A ROW with no allowed call in between — a governance deny is
|
|
54
|
+
* not a recoverable tool error, so a model that keeps retrying variants of a denied action would
|
|
55
|
+
* otherwise burn the budget to the cap without progress (probe-16: 16 calls, sensor never reached). Any
|
|
56
|
+
* tool call that PASSES policy resets the streak, preserving allowlist-safe pivoting (deny X → allow Y).
|
|
57
|
+
* The run returns cleanly with `error: 'denied:<tool>'` (mirrors the halt return; never throws even under
|
|
58
|
+
* throwOnError). Set `0` or `Infinity` to disable (restores pre-BA-11 advisory-deny behavior).
|
|
52
59
|
* @property {number} [maxRounds] - Removed in v0.8; presence throws a migration error.
|
|
53
60
|
*/
|
|
54
61
|
|
|
@@ -212,6 +219,13 @@ class Loop {
|
|
|
212
219
|
throw new Error('[Loop] options.policy must be a function (toolName, args, ctx) => true | string');
|
|
213
220
|
}
|
|
214
221
|
this.policy = options.policy || null;
|
|
222
|
+
// BA-11 deny-spin guard. Default 3; 0/Infinity/non-finite disables (restores advisory-deny behavior).
|
|
223
|
+
// Validated only if provided so an explicit 0 is honored as "off" (Infinity also disables).
|
|
224
|
+
if (options.maxConsecutiveDenials != null
|
|
225
|
+
&& (typeof options.maxConsecutiveDenials !== 'number' || options.maxConsecutiveDenials < 0 || Number.isNaN(options.maxConsecutiveDenials))) {
|
|
226
|
+
throw new Error('[Loop] options.maxConsecutiveDenials must be a non-negative number (0 or Infinity disables)');
|
|
227
|
+
}
|
|
228
|
+
this.maxConsecutiveDenials = options.maxConsecutiveDenials != null ? options.maxConsecutiveDenials : 3;
|
|
215
229
|
if (options.assemble != null && typeof options.assemble !== 'function') {
|
|
216
230
|
throw new Error('[Loop] options.assemble must be a function (msgs, info) => msgs');
|
|
217
231
|
}
|
|
@@ -360,6 +374,9 @@ class Loop {
|
|
|
360
374
|
// unsupported/deprecated) and retried without it. Surfaced on the result so an upstream receipt
|
|
361
375
|
// (recurse's refineLeaf) can report the EFFECTIVE temperature rather than the ignored request.
|
|
362
376
|
let temperatureDropped = false;
|
|
377
|
+
// BA-11: consecutive policy-deny counter (reset by any tool call that PASSES policy). When it reaches
|
|
378
|
+
// this.maxConsecutiveDenials the run short-circuits cleanly — see the deny block below.
|
|
379
|
+
let consecutiveDenials = 0;
|
|
363
380
|
|
|
364
381
|
// The meter (Feature 3): bareagent is the canonical run counter. Accumulates across rounds and is
|
|
365
382
|
// returned as `result.metrics`. `tokens` is CUMULATIVE over all four tiers (fixes the last-round-only
|
|
@@ -731,10 +748,30 @@ class Loop {
|
|
|
731
748
|
: `[Loop] Tool "${tc.name}" denied by policy`;
|
|
732
749
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content: reason });
|
|
733
750
|
this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, denied: true, reason } });
|
|
751
|
+
// BA-11: a governance deny is not a recoverable tool error. Count consecutive denials; when the
|
|
752
|
+
// model keeps retrying denied actions (proven live: 8 in a row before giving up) short-circuit the
|
|
753
|
+
// run rather than let it burn the budget to the cap. The streak resets on any ALLOWED tool call
|
|
754
|
+
// (below), so a legit deny-then-pivot (deny X → allow Y) never trips this. 0/Infinity disables.
|
|
755
|
+
consecutiveDenials += 1;
|
|
756
|
+
if (this.maxConsecutiveDenials > 0 && Number.isFinite(this.maxConsecutiveDenials)
|
|
757
|
+
&& consecutiveDenials >= this.maxConsecutiveDenials) {
|
|
758
|
+
const denyTag = `denied:${tc.name}`;
|
|
759
|
+
// Pair any still-dangling tool_calls from this round so the returned transcript stays
|
|
760
|
+
// provider-valid (same seal the halt path uses), then exit cleanly — no throw even under
|
|
761
|
+
// throwOnError, mirroring the governance-halt contract.
|
|
762
|
+
sealDanglingToolCalls(msgs, denyTag);
|
|
763
|
+
this._reportError('denied', new Error(`policy denied ${consecutiveDenials} consecutive tool calls (${tc.name})`), { rule: denyTag, denials: consecutiveDenials });
|
|
764
|
+
this._safeEmit({ type: 'loop:done', data: { text: '', denied: true, rule: denyTag, cost: totalCost } });
|
|
765
|
+
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, msgs, metrics: finalizeMetrics() };
|
|
766
|
+
}
|
|
734
767
|
continue;
|
|
735
768
|
}
|
|
736
769
|
}
|
|
737
770
|
|
|
771
|
+
// BA-11: reaching here means this tool call PASSED policy (or there is no policy) — progress, so the
|
|
772
|
+
// consecutive-deny streak resets. A single deny followed by an allowed call never trips the guard.
|
|
773
|
+
consecutiveDenials = 0;
|
|
774
|
+
|
|
738
775
|
const toolStartedAt = Date.now();
|
|
739
776
|
let toolResult;
|
|
740
777
|
let toolError;
|
package/src/recurse.d.ts
CHANGED
|
@@ -215,6 +215,11 @@ export type RecurseNode = {
|
|
|
215
215
|
verdict: Verdict | null;
|
|
216
216
|
incomplete: boolean;
|
|
217
217
|
halted: boolean;
|
|
218
|
+
/**
|
|
219
|
+
* - (BA-11) set to `'governance-deny'` when this node stopped because its Loop
|
|
220
|
+
* short-circuited a consecutive-policy-deny spin (not a model failure). Mirrors `RecurseResult.blocker`.
|
|
221
|
+
*/
|
|
222
|
+
blocker?: string | undefined;
|
|
218
223
|
/**
|
|
219
224
|
* - The worker Loop's `metrics.tokens`.
|
|
220
225
|
*/
|
|
@@ -287,6 +292,12 @@ export type RecurseResult = {
|
|
|
287
292
|
* back incomplete (§9 scenario 1) — the anti-survivor-sum signal, not a quiet undercount.
|
|
288
293
|
*/
|
|
289
294
|
missingSlices?: string[] | undefined;
|
|
295
|
+
/**
|
|
296
|
+
* - Present when `incomplete` for a specific, actionable reason. `'governance-deny'`
|
|
297
|
+
* (BA-11): the worker's Loop short-circuited after N consecutive policy denials rather than burn to the
|
|
298
|
+
* budget cap — the caller can widen scope / re-gate / escalate instead of reading it as a model failure.
|
|
299
|
+
*/
|
|
300
|
+
blocker?: string | undefined;
|
|
290
301
|
/**
|
|
291
302
|
* - The audit node for this call (RC-10).
|
|
292
303
|
*/
|
|
@@ -424,6 +435,8 @@ export type Slice = {
|
|
|
424
435
|
* @property {Verdict|null} verdict
|
|
425
436
|
* @property {boolean} incomplete
|
|
426
437
|
* @property {boolean} halted
|
|
438
|
+
* @property {string} [blocker] - (BA-11) set to `'governance-deny'` when this node stopped because its Loop
|
|
439
|
+
* short-circuited a consecutive-policy-deny spin (not a model failure). Mirrors `RecurseResult.blocker`.
|
|
427
440
|
* @property {object|null} tokens - The worker Loop's `metrics.tokens`.
|
|
428
441
|
* @property {{iterations: number, passed: boolean, temperatures: (number|null)[]}} [refineLeaf] - (BA-8) when this
|
|
429
442
|
* leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
|
|
@@ -449,6 +462,9 @@ export type Slice = {
|
|
|
449
462
|
* @property {any} [best] - The best partial answer when `incomplete` (RC-9).
|
|
450
463
|
* @property {string[]} [missingSlices] - When `incomplete` because a child failed: the sub-task(s) that came
|
|
451
464
|
* back incomplete (§9 scenario 1) — the anti-survivor-sum signal, not a quiet undercount.
|
|
465
|
+
* @property {string} [blocker] - Present when `incomplete` for a specific, actionable reason. `'governance-deny'`
|
|
466
|
+
* (BA-11): the worker's Loop short-circuited after N consecutive policy denials rather than burn to the
|
|
467
|
+
* budget cap — the caller can widen scope / re-gate / escalate instead of reading it as a model failure.
|
|
452
468
|
* @property {RecurseNode} receipts - The audit node for this call (RC-10).
|
|
453
469
|
*/
|
|
454
470
|
/**
|
package/src/recurse.js
CHANGED
|
@@ -290,6 +290,8 @@ function auditSafeCtx(ctx, overrides = {}) {
|
|
|
290
290
|
* @property {Verdict|null} verdict
|
|
291
291
|
* @property {boolean} incomplete
|
|
292
292
|
* @property {boolean} halted
|
|
293
|
+
* @property {string} [blocker] - (BA-11) set to `'governance-deny'` when this node stopped because its Loop
|
|
294
|
+
* short-circuited a consecutive-policy-deny spin (not a model failure). Mirrors `RecurseResult.blocker`.
|
|
293
295
|
* @property {object|null} tokens - The worker Loop's `metrics.tokens`.
|
|
294
296
|
* @property {{iterations: number, passed: boolean, temperatures: (number|null)[]}} [refineLeaf] - (BA-8) when this
|
|
295
297
|
* leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
|
|
@@ -316,6 +318,9 @@ function auditSafeCtx(ctx, overrides = {}) {
|
|
|
316
318
|
* @property {any} [best] - The best partial answer when `incomplete` (RC-9).
|
|
317
319
|
* @property {string[]} [missingSlices] - When `incomplete` because a child failed: the sub-task(s) that came
|
|
318
320
|
* back incomplete (§9 scenario 1) — the anti-survivor-sum signal, not a quiet undercount.
|
|
321
|
+
* @property {string} [blocker] - Present when `incomplete` for a specific, actionable reason. `'governance-deny'`
|
|
322
|
+
* (BA-11): the worker's Loop short-circuited after N consecutive policy denials rather than burn to the
|
|
323
|
+
* budget cap — the caller can widen scope / re-gate / escalate instead of reading it as a model failure.
|
|
319
324
|
* @property {RecurseNode} receipts - The audit node for this call (RC-10).
|
|
320
325
|
*/
|
|
321
326
|
|
|
@@ -510,6 +515,15 @@ async function recurse(task, ctx = {}, opts = {}) {
|
|
|
510
515
|
node.incomplete = true;
|
|
511
516
|
return { incomplete: true, best: out.text || null, receipts: node };
|
|
512
517
|
}
|
|
518
|
+
// BA-11: a deny-spin short-circuit. The Loop stopped the worker after N consecutive governance denials
|
|
519
|
+
// (a governance deny is not a recoverable tool error — retrying variants would burn to the budget cap;
|
|
520
|
+
// probe-16: 16 calls, sensor never reached → incomplete). Surface it as a clean, LABELED incomplete so a
|
|
521
|
+
// caller can tell a governance block apart from a model failure and act (widen scope, re-gate, escalate).
|
|
522
|
+
if (typeof out.error === 'string' && out.error.startsWith('denied:')) {
|
|
523
|
+
node.incomplete = true;
|
|
524
|
+
node.blocker = 'governance-deny';
|
|
525
|
+
return { incomplete: true, best: out.text || null, blocker: 'governance-deny', receipts: node };
|
|
526
|
+
}
|
|
513
527
|
if (out.error) {
|
|
514
528
|
node.incomplete = true;
|
|
515
529
|
return { incomplete: true, best: out.text || null, receipts: node };
|
|
@@ -673,6 +687,13 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
|
|
|
673
687
|
node.incomplete = true;
|
|
674
688
|
return { incomplete: true, best: null, receipts: node };
|
|
675
689
|
}
|
|
690
|
+
// BA-11: a deny-spin inside a refine attempt (the Loop short-circuited after N consecutive governance
|
|
691
|
+
// denials, rethrown at recurse.js as `denied:<tool>`) is a LABELED governance block, not a model fault.
|
|
692
|
+
if (typeof err?.message === 'string' && err.message.startsWith('denied:')) {
|
|
693
|
+
node.incomplete = true;
|
|
694
|
+
node.blocker = 'governance-deny';
|
|
695
|
+
return { incomplete: true, best: null, blocker: 'governance-deny', receipts: node };
|
|
696
|
+
}
|
|
676
697
|
node.incomplete = true;
|
|
677
698
|
return { incomplete: true, best: null, receipts: node };
|
|
678
699
|
}
|