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 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
 
@@ -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.24.0 | Node.js >= 18 | zero required deps (`bareguard ^0.9.0` optional peer for governance) | Apache 2.0
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
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
  }