bare-agent 0.9.0 → 0.10.1

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
@@ -11,7 +11,12 @@
11
11
 
12
12
  ```
13
13
 
14
- **Agent orchestration in ~2.7K lines of core. One required dep ([bareguard](https://npmjs.com/package/bareguard) ^0.2.0). Apache 2.0.**
14
+ <p align="center">
15
+ <img src="https://img.shields.io/github/package-json/v/hamr0/bareagent?label=version&color=2a4f8c" alt="version (auto from package.json)">
16
+ <img src="https://img.shields.io/badge/license-Apache%202.0-2a4f8c" alt="license: Apache 2.0">
17
+ </p>
18
+
19
+ **Agent orchestration in ~2.7K lines of core. One required dep ([bareguard](https://npmjs.com/package/bareguard) ^0.2.0).**
15
20
 
16
21
  Lightweight enough to understand completely. Complete enough to not reinvent wheels. Not a framework, not 50,000 lines of opinions — just composable building blocks for agents. Single-gate governance via bareguard: every tool call traverses one policy hook, one audit log, one budget cap.
17
22
 
@@ -72,7 +77,7 @@ Every piece works alone — take what you need, ignore the rest.
72
77
  | **Scheduler** | Cron (`0 9 * * 1-5`) or relative (`2h`, `30m`). Persisted jobs survive restarts |
73
78
  | **Stream** | Structured event emitter. Pipe as JSONL, subscribe in-process, or custom transport |
74
79
  | **Errors** | Typed hierarchy — `ProviderError`, `ToolError`, `TimeoutError`, `CircuitOpenError`, `ValidationError`. Halt decisions (turn cap, budget cap, content rules) come from bareguard, not Loop |
75
- | **bareguard adapter** | `wireGate(gate)` returns `{ policy, wrapTools }` — one-line wiring to bareguard's `Gate`. Maps gate decisions to Loop's `policy` contract; `wrapTools` decorates tools so `gate.record` fires after every execute. `require('bare-agent/bareguard')` |
80
+ | **bareguard adapter** | `wireGate(gate)` returns `{ policy, onLlmResult, onToolResult, filterTools, formatDeny }` — one-line wiring to bareguard's `Gate`. `policy` maps gate decisions to Loop's policy contract; `onLlmResult` + `onToolResult` forward every LLM and tool result to `gate.record` (so `budget.maxCostUsd` covers token-only workloads); `filterTools` drops denied tools from the catalog the LLM ever sees. Halt-severity decisions throw a typed `HaltError` and Loop exits cleanly — never leaks `[HALT: ...]` to the LLM. `require('bare-agent/bareguard')` |
76
81
  | **Browsing** | Web navigation, clicking, typing, reading via `barebrowse` (17 tools). Two modes: library tools (inline snapshots, pass to Loop) or CLI session (disk-based snapshots, token-efficient for multi-step flows). Optional `assess` tool (privacy scan) when `wearehere` is installed |
77
82
  | **Mobile** | Android + iOS device control via `baremobile`. Same two modes: library tools (`createMobileTools` — action tools auto-return snapshots) or CLI session (`baremobile` CLI — disk-based snapshots) |
78
83
  | **Shell** | Cross-platform `shell_read`, `shell_grep`, `shell_run` (argv, no shell), `shell_exec` (raw shell). Pure Node — no `grep`/`rg`/`findstr` dependency. Injection-proof `shell_run` for policy-gated use |
@@ -90,6 +95,73 @@ Every piece works alone — take what you need, ignore the rest.
90
95
 
91
96
  ---
92
97
 
98
+ ## Recipes
99
+
100
+ ### Wire bareguard into Loop
101
+
102
+ ```js
103
+ const { Gate } = require('bareguard');
104
+ const { Loop } = require('bare-agent');
105
+ const { wireGate } = require('bare-agent/bareguard');
106
+
107
+ const gate = new Gate({
108
+ budget: { maxCostUsd: 0.50 },
109
+ limits: { maxTurns: 20 },
110
+ audit: { path: './audit.jsonl' },
111
+ });
112
+ await gate.init();
113
+
114
+ const { policy, onLlmResult, onToolResult, filterTools } = wireGate(gate);
115
+ const tools = await filterTools(myTools); // drop tools denied by static policy
116
+
117
+ const loop = new Loop({ provider, policy, onLlmResult, onToolResult });
118
+ await loop.run([{ role: 'user', content: 'go' }], tools, { ctx: { userId: 42 } });
119
+ ```
120
+
121
+ `onLlmResult` + `onToolResult` are what make `budget.maxCostUsd` actually cover token-heavy workloads — without them, budget only sees tool cost. `ctx` flows through to `gate.record` as `_ctx` for per-principal accounting.
122
+
123
+ ### Per-principal bypass (owner / admin role)
124
+
125
+ Wrap the gate policy when a principal is trusted unconditionally:
126
+
127
+ ```js
128
+ const { policy: gatePolicy } = wireGate(gate);
129
+
130
+ const policy = async (toolName, args, ctx) => {
131
+ if (ctx?.role === 'owner') return true; // bypass gate entirely
132
+ return gatePolicy(toolName, args, ctx);
133
+ };
134
+
135
+ new Loop({ provider, policy, onLlmResult, onToolResult });
136
+ ```
137
+
138
+ Bypassing the gate also bypasses audit and budget — only do this for principals you trust unconditionally. For partial trust, use ctx-aware rules inside bareguard instead.
139
+
140
+ ### Custom deny strings (localize / strip prefix)
141
+
142
+ ```js
143
+ const { policy } = wireGate(gate, {
144
+ formatDeny: (decision) => `Sorry — ${decision.reason || 'not allowed'}`,
145
+ });
146
+ ```
147
+
148
+ Halt-severity decisions bypass `formatDeny` (they throw `HaltError` and exit the loop without ever reaching the LLM).
149
+
150
+ ### Catch halts in your app
151
+
152
+ ```js
153
+ const result = await loop.run(msgs, tools);
154
+ if (result.error?.startsWith('halt:')) {
155
+ // budget cap, turn cap, or gate terminated. Inspect rule:
156
+ const rule = result.error.slice('halt:'.length);
157
+ // tell the user, schedule retry, escalate, etc.
158
+ }
159
+ ```
160
+
161
+ Halts also fire `loop:error` on the stream (`source: 'halt'`) and the `onError` callback (with a `HaltError` instance).
162
+
163
+ ---
164
+
93
165
  ## Cross-language usage
94
166
 
95
167
  Not using Node.js? Spawn bare-agent as a subprocess from any language. Ready-made wrappers in [`contrib/`](contrib/README.md) for Python, Go, Rust, Ruby, and Java — copy one file, no package registry needed.
package/index.js CHANGED
@@ -10,7 +10,7 @@ const { Stream } = require('./src/stream');
10
10
  const { Retry } = require('./src/retry');
11
11
  const { runPlan } = require('./src/run-plan');
12
12
  const { CircuitBreaker } = require('./src/circuit-breaker');
13
- const { wireGate } = require('./src/bareguard-adapter');
13
+ const { wireGate, defaultActionTranslator } = require('./src/bareguard-adapter');
14
14
  const {
15
15
  BareAgentError,
16
16
  ProviderError,
@@ -18,6 +18,7 @@ const {
18
18
  TimeoutError,
19
19
  ValidationError,
20
20
  CircuitOpenError,
21
+ HaltError,
21
22
  } = require('./src/errors');
22
23
 
23
24
  module.exports = {
@@ -32,10 +33,12 @@ module.exports = {
32
33
  runPlan,
33
34
  CircuitBreaker,
34
35
  wireGate,
36
+ defaultActionTranslator,
35
37
  BareAgentError,
36
38
  ProviderError,
37
39
  ToolError,
38
40
  TimeoutError,
39
41
  ValidationError,
40
42
  CircuitOpenError,
43
+ HaltError,
41
44
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "files": [
5
5
  "index.js",
6
6
  "src/",
@@ -22,12 +22,14 @@
22
22
  },
23
23
  "exports": {
24
24
  ".": "./index.js",
25
+ "./errors": "./src/errors.js",
25
26
  "./providers": "./src/providers.js",
26
27
  "./stores": "./src/stores.js",
27
28
  "./transports": "./src/transports.js",
28
29
  "./tools": "./src/tools.js",
29
30
  "./mcp": "./src/mcp.js",
30
- "./bareguard": "./src/bareguard-adapter.js"
31
+ "./bareguard": "./src/bareguard-adapter.js",
32
+ "./package.json": "./package.json"
31
33
  },
32
34
  "engines": {
33
35
  "node": ">=18"
@@ -1,25 +1,42 @@
1
1
  'use strict';
2
2
 
3
+ const { HaltError } = require('./errors');
4
+
3
5
  /**
4
6
  * Wire a bareguard Gate into bareagent's Loop.
5
7
  *
6
8
  * Returns:
7
- * - `policy` — async (toolName, args, ctx) closure for `new Loop({ policy })`.
8
- * Maps gate.check decisions to true (allow) or a deny string
9
- * (used verbatim by Loop as the LLM-visible reason).
10
- * - `wrapTool` wraps a single tool so its execute() also calls gate.record
11
- * with the result + duration (or error). Bareguard owns the
12
- * audit log and budget tracking; record() is what feeds them.
13
- * - `wrapTools` convenience: applies wrapTool to an array.
9
+ * - `policy` — async (toolName, args, ctx) closure for `new Loop({ policy })`.
10
+ * Allow true; deny tagged reason string; halt → throws HaltError.
11
+ * - `onLlmResult` — callback for `new Loop({ onLlmResult })`. Forwards every
12
+ * provider.generate result to gate.record as a `{type:'llm'}` action
13
+ * so `budget.maxCostUsd` covers token-only workloads.
14
+ * - `onToolResult` — callback for `new Loop({ onToolResult })`. Forwards every
15
+ * tool.execute result to gate.record with ctx in scope.
16
+ * - `filterTools` — async (tools) => filtered. Drops tools denied by gate.allows
17
+ * so the LLM never sees them. No audit, no record.
18
+ * - `wrapTool` / `wrapTools` — DEPRECATED. Pre-BA1 shim that wraps execute() to
19
+ * call gate.record post-hoc. Loses _ctx and never sees LLM cost.
20
+ * Prefer `onToolResult` (and `onLlmResult` for budget correctness).
14
21
  *
15
- * Halt-severity decisions (budget exhausted, limits.maxTurns hit, etc.) come
16
- * back as deny strings tagged `[HALT: <rule>]`. Subsequent rounds halt the
17
- * same way; the LLM typically gives up and the loop exits naturally. For
18
- * earlier exit, watch the loop:error stream (the closure also calls onError
19
- * via Loop's policy-deny path) or wire `onError` to detect halt strings.
22
+ * Halt-severity decisions (budget exhausted, limits.maxTurns hit, gate terminated)
23
+ * throw HaltError from the policy closure; Loop catches it and exits cleanly with
24
+ * loop:error{source:'halt'} + loop:done — the deny is NOT fed back to the LLM.
20
25
  *
21
- * @param {object} gate - A bareguard Gate instance (must have .check and .record).
22
- * @returns {{policy: Function, wrapTool: Function, wrapTools: Function}}
26
+ * @param {object} gate - A bareguard Gate instance (must have .check, .record, .allows).
27
+ * @param {object} [options]
28
+ * @param {Function} [options.formatDeny] - (decision) => string. Transforms the deny
29
+ * string fed to the LLM. Default: "[deny: <rule>] <reason>". Halt bypasses this
30
+ * (HaltError doesn't reach the LLM).
31
+ * @param {Function} [options.actionTranslator] - (toolName, args, ctx) => action.
32
+ * Builds the action object passed to `gate.check` and `gate.record`. Default:
33
+ * `{ type: toolName, args, _ctx: ctx }`. Override when bareguard's primitives
34
+ * need a specific shape — e.g. `bashCheck` requires `{type:'bash', cmd:...}`,
35
+ * `fsCheck` requires `{type:'read'|'write'|'edit', path:...}`. The default shape
36
+ * matches `tools.denylist` / `tools.allowlist` (which read `action.type`) but
37
+ * does NOT activate `bash`/`fs`/`net` primitives — those need their own
38
+ * `action.type` value. Adopters using those primitives must translate.
39
+ * @returns {{policy: Function, onLlmResult: Function, onToolResult: Function, filterTools: Function, wrapTool: Function, wrapTools: Function}}
23
40
  *
24
41
  * @example
25
42
  * const { Gate } = require('bareguard');
@@ -30,29 +47,90 @@
30
47
  * budget: { maxCostUsd: 0.50 },
31
48
  * limits: { maxTurns: 20 },
32
49
  * audit: { path: './audit.jsonl' },
33
- * humanChannel: async (ev) => ({ decision: 'deny' }),
34
50
  * });
35
51
  * await gate.init();
36
52
  *
37
- * const { policy, wrapTools } = wireGate(gate);
38
- * const loop = new Loop({ provider, policy });
39
- * await loop.run(messages, wrapTools(myTools));
53
+ * const { policy, onLlmResult, onToolResult, filterTools } = wireGate(gate);
54
+ * const loop = new Loop({ provider, policy, onLlmResult, onToolResult });
55
+ * const tools = await filterTools(myTools);
56
+ * await loop.run(messages, tools);
40
57
  */
41
- function wireGate(gate) {
58
+ function wireGate(gate, options = {}) {
42
59
  if (!gate || typeof gate.check !== 'function' || typeof gate.record !== 'function') {
43
60
  throw new Error('[wireGate] expects a bareguard Gate instance (must have .check and .record).');
44
61
  }
62
+ if (options.formatDeny != null && typeof options.formatDeny !== 'function') {
63
+ throw new Error('[wireGate] options.formatDeny must be a function (decision) => string');
64
+ }
65
+ if (options.actionTranslator != null && typeof options.actionTranslator !== 'function') {
66
+ throw new Error('[wireGate] options.actionTranslator must be a function (toolName, args, ctx) => action');
67
+ }
68
+ const formatDeny = options.formatDeny || defaultFormatDeny;
69
+ const translate = options.actionTranslator || defaultActionTranslator;
45
70
 
46
71
  const policy = async (toolName, args, ctx) => {
47
- const decision = await gate.check({ type: toolName, args, _ctx: ctx });
72
+ const decision = await gate.check(translate(toolName, args, ctx));
48
73
  if (decision.outcome === 'allow') return true;
49
- const tag = decision.severity === 'halt'
50
- ? `[HALT: ${decision.rule}]`
51
- : `[deny: ${decision.rule}]`;
52
- return decision.reason ? `${tag} ${decision.reason}` : `${tag} ${toolName} denied`;
74
+ if (decision.severity === 'halt') {
75
+ throw new HaltError(decision.reason || `${toolName} halted by ${decision.rule}`, {
76
+ rule: decision.rule,
77
+ decision,
78
+ });
79
+ }
80
+ return formatDeny(decision, toolName);
53
81
  };
54
82
 
83
+ const onLlmResult = async ({ model, provider, usage, costUsd, durationMs, ctx }) => {
84
+ // LLM rounds bypass actionTranslator — they always use the canonical
85
+ // {type:'llm'} action so budget rules can match without translator collusion.
86
+ await gate.record(
87
+ { type: 'llm', args: { model: model || null, provider: provider || null }, _ctx: ctx ?? null },
88
+ {
89
+ costUsd: typeof costUsd === 'number' ? costUsd : 0,
90
+ tokens: (usage?.inputTokens || 0) + (usage?.outputTokens || 0),
91
+ durationMs: durationMs ?? null,
92
+ },
93
+ );
94
+ };
95
+
96
+ const onToolResult = async ({ name, args, result, error, durationMs, ctx }) => {
97
+ const action = translate(name, args, ctx);
98
+ if (error) {
99
+ await gate.record(action, {
100
+ error: error?.message || String(error),
101
+ durationMs: durationMs ?? null,
102
+ });
103
+ } else {
104
+ await gate.record(action, {
105
+ result: typeof result === 'string' ? result : JSON.stringify(result),
106
+ durationMs: durationMs ?? null,
107
+ });
108
+ }
109
+ };
110
+
111
+ const filterTools = async (tools) => {
112
+ if (!Array.isArray(tools)) {
113
+ throw new Error('[wireGate.filterTools] expects an array of tools');
114
+ }
115
+ if (typeof gate.allows !== 'function') {
116
+ throw new Error('[wireGate.filterTools] gate must have .allows (bareguard >= 0.2)');
117
+ }
118
+ const out = [];
119
+ for (const t of tools) {
120
+ if (await gate.allows(t.name)) out.push(t);
121
+ }
122
+ return out;
123
+ };
124
+
125
+ let warnedWrap = false;
55
126
  function wrapTool(tool) {
127
+ if (!warnedWrap) {
128
+ warnedWrap = true;
129
+ console.warn(
130
+ '[wireGate] wrapTool/wrapTools is deprecated — use new Loop({ policy, onLlmResult, onToolResult }) ' +
131
+ 'so budget covers LLM cost and ctx reaches gate.record. wrap* will be removed in 1.0.',
132
+ );
133
+ }
56
134
  if (!tool || typeof tool.execute !== 'function') {
57
135
  throw new Error('[wireGate.wrapTool] tool must have an execute() function');
58
136
  }
@@ -87,7 +165,21 @@ function wireGate(gate) {
87
165
  return tools.map(wrapTool);
88
166
  }
89
167
 
90
- return { policy, wrapTool, wrapTools };
168
+ return { policy, onLlmResult, onToolResult, filterTools, wrapTool, wrapTools };
169
+ }
170
+
171
+ function defaultFormatDeny(decision, toolName) {
172
+ const tag = `[deny: ${decision.rule}]`;
173
+ return decision.reason ? `${tag} ${decision.reason}` : `${tag} ${toolName} denied`;
174
+ }
175
+
176
+ // Canonical action shape: tool name as type, args nested, ctx tagged. Matches
177
+ // bareguard's `tools.denylist`/`tools.allowlist` (which read `action.type`) but
178
+ // does NOT activate `bash`/`fs`/`net` primitives — those require `action.type`
179
+ // to be `bash`/`read`/`write`/etc. and read fields like `action.cmd` /
180
+ // `action.path` at the top level. Override via `wireGate(gate, { actionTranslator })`.
181
+ function defaultActionTranslator(toolName, args, ctx) {
182
+ return { type: toolName, args, _ctx: ctx ?? null };
91
183
  }
92
184
 
93
- module.exports = { wireGate };
185
+ module.exports = { wireGate, defaultActionTranslator };
package/src/errors.js CHANGED
@@ -43,6 +43,22 @@ class CircuitOpenError extends BareAgentError {
43
43
  }
44
44
  }
45
45
 
46
+ // Signals a halt-severity governance decision (budget exhausted, turn cap hit,
47
+ // gate terminated, etc.). Thrown by wireGate's policy closure and caught by
48
+ // Loop's outer handler — does NOT propagate to the LLM as a tool result.
49
+ // Loop exits cleanly: emits loop:error{source:'halt'} + loop:done, calls onError.
50
+ class HaltError extends BareAgentError {
51
+ constructor(message, { rule, decision, context = {} } = {}) {
52
+ super(message || `[HALT: ${rule || 'unknown'}]`, {
53
+ code: 'HALT',
54
+ retryable: false,
55
+ context: { ...context, rule, decision },
56
+ });
57
+ this.rule = rule || null;
58
+ this.decision = decision || null;
59
+ }
60
+ }
61
+
46
62
  module.exports = {
47
63
  BareAgentError,
48
64
  ProviderError,
@@ -50,4 +66,5 @@ module.exports = {
50
66
  TimeoutError,
51
67
  ValidationError,
52
68
  CircuitOpenError,
69
+ HaltError,
53
70
  };
package/src/loop.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { ToolError } = require('./errors');
3
+ const { ToolError, HaltError } = require('./errors');
4
4
 
5
5
  // Average pricing per 1K tokens (USD). Adjust these to match your provider's rates.
6
6
  // Last updated: 2026-03-18. Source: public provider pricing pages.
@@ -43,11 +43,21 @@ class Loop {
43
43
  * @param {object} [options.retry] - Retry instance for backoff on failures.
44
44
  * @param {object} [options.stream] - Stream instance for event emission.
45
45
  * @param {object} [options.store] - Store instance for validate() health check.
46
- * @param {Function} [options.policy] - Async (toolName, args, ctx) => true | string. Recommended wiring: closure that delegates to a bareguard Gate (`require('bare-agent/bareguard').wireGate(gate).policy`). Anything other than `true` denies; a string is fed to the LLM verbatim as the deny reason. All policy/budget/audit decisions live in bareguard — Loop just calls the closure and respects the verdict.
46
+ * @param {Function} [options.policy] - Async (toolName, args, ctx) => true | string. Recommended wiring: closure that delegates to a bareguard Gate (`require('bare-agent/bareguard').wireGate(gate).policy`). Anything other than `true` denies; a string is fed to the LLM verbatim as the deny reason. A throw of `HaltError` exits the loop cleanly. All policy/budget/audit decisions live in bareguard — Loop just calls the closure and respects the verdict.
47
+ * @param {Function} [options.onLlmResult] - Async ({model, provider, usage, costUsd, durationMs, ctx}) called after every successful provider.generate. Wire via `wireGate(gate).onLlmResult` so `budget.maxCostUsd` covers token-only workloads. Errors route through `_reportError` but never kill the loop.
48
+ * @param {Function} [options.onToolResult] - Async ({name, args, result, error, durationMs, ctx}) called after every tool.execute (success and failure). Wire via `wireGate(gate).onToolResult` so `gate.record` sees `ctx`. Errors route through `_reportError` but never kill the loop.
47
49
  * @throws {Error} `[Loop] requires a provider` — when options.provider is missing.
48
50
  */
49
51
  constructor(options = {}) {
50
52
  if (!options.provider) throw new Error('[Loop] requires a provider');
53
+ if (options.maxRounds !== undefined) {
54
+ throw new Error(
55
+ '[Loop] options.maxRounds was removed in v0.8 when single-gate governance landed. ' +
56
+ 'Bound iteration via bareguard `new Gate({ limits: { maxTurns: N } })` and wire it with ' +
57
+ '`new Loop({ policy: wireGate(gate).policy })`. Loop\'s internal HARD_ROUND_LIMIT (100) is ' +
58
+ 'a safety net only and not configurable.',
59
+ );
60
+ }
51
61
  this.provider = options.provider;
52
62
  this.system = options.system || null;
53
63
  this.checkpoint = options.checkpoint || null;
@@ -62,6 +72,14 @@ class Loop {
62
72
  throw new Error('[Loop] options.policy must be a function (toolName, args, ctx) => true | string');
63
73
  }
64
74
  this.policy = options.policy || null;
75
+ if (options.onLlmResult != null && typeof options.onLlmResult !== 'function') {
76
+ throw new Error('[Loop] options.onLlmResult must be a function');
77
+ }
78
+ if (options.onToolResult != null && typeof options.onToolResult !== 'function') {
79
+ throw new Error('[Loop] options.onToolResult must be a function');
80
+ }
81
+ this.onLlmResult = options.onLlmResult || null;
82
+ this.onToolResult = options.onToolResult || null;
65
83
  this._stopped = false;
66
84
  this._history = []; // for chat() stateful mode
67
85
  }
@@ -144,17 +162,19 @@ class Loop {
144
162
  let lastUsage = { inputTokens: 0, outputTokens: 0 };
145
163
  let totalCost = 0;
146
164
 
165
+ try {
147
166
  for (let round = 0; round < HARD_ROUND_LIMIT; round++) {
148
167
  if (this._stopped) break;
149
168
 
150
169
  let result;
170
+ const llmStartedAt = Date.now();
151
171
  try {
152
172
  const generate = () => this.provider.generate(msgs, tools, options);
153
173
  result = this.retry ? await this.retry.call(generate) : await generate();
154
174
  } catch (err) {
155
175
  this._reportError('provider', err, { round });
156
176
  if (this.throwOnError) throw err;
157
- return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message };
177
+ return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, msgs };
158
178
  }
159
179
 
160
180
  lastUsage = result.usage || lastUsage;
@@ -162,12 +182,32 @@ class Loop {
162
182
  const roundCost = estimateCost(model, lastUsage);
163
183
  if (roundCost !== null) totalCost += roundCost;
164
184
 
185
+ // BA1: forward LLM usage to gate.record (via wireGate) so budget.maxCostUsd
186
+ // covers token-heavy / tool-light workloads. Callback errors route through
187
+ // _reportError but never kill the loop — governance failure ≠ run failure.
188
+ if (this.onLlmResult) {
189
+ try {
190
+ await this.onLlmResult({
191
+ model,
192
+ provider: this.provider.name || null,
193
+ usage: result.usage || null,
194
+ costUsd: roundCost,
195
+ durationMs: Date.now() - llmStartedAt,
196
+ ctx,
197
+ });
198
+ } catch (err) {
199
+ if (err instanceof HaltError) throw err;
200
+ this._reportError('onLlmResult', err, { round });
201
+ }
202
+ }
203
+
165
204
  // No tool calls — LLM gave a final text response
166
205
  if (!result.toolCalls || result.toolCalls.length === 0) {
167
206
  this._safeEmit({ type: 'loop:text', data: { text: result.text } });
168
207
  this._safeCall('onText', this.onText, result.text);
169
208
  this._safeEmit({ type: 'loop:done', data: { text: result.text, usage: lastUsage, cost: totalCost } });
170
- return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null };
209
+ msgs.push({ role: 'assistant', content: result.text });
210
+ return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, msgs };
171
211
  }
172
212
 
173
213
  // Execute tool calls
@@ -229,6 +269,9 @@ class Loop {
229
269
  try {
230
270
  verdict = await this.policy(tc.name, tc.arguments, ctx);
231
271
  } catch (err) {
272
+ // BA2: HaltError bubbles past the per-tool try/catch to the outer
273
+ // handler so halt exits cleanly without ever reaching the LLM.
274
+ if (err instanceof HaltError) throw err;
232
275
  verdict = `[Loop] policy error: ${err.message}`;
233
276
  }
234
277
  if (verdict !== true) {
@@ -241,26 +284,57 @@ class Loop {
241
284
  }
242
285
  }
243
286
 
287
+ const toolStartedAt = Date.now();
288
+ let toolResult;
289
+ let toolError;
244
290
  try {
245
291
  const execute = () => tool.execute(tc.arguments);
246
- const toolResult = this.retry ? await this.retry.call(execute) : await execute();
292
+ toolResult = this.retry ? await this.retry.call(execute) : await execute();
247
293
  const content = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
248
294
  msgs.push({ role: 'tool', tool_call_id: tc.id, content });
249
295
  this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, result: content } });
250
296
  } catch (err) {
251
- const toolErr = err instanceof ToolError ? err : new ToolError(err.message, { context: { tool: tc.name } });
252
- const errMsg = `[Loop] Tool error: ${toolErr.message}`;
297
+ toolError = err instanceof ToolError ? err : new ToolError(err.message, { context: { tool: tc.name } });
298
+ const errMsg = `[Loop] Tool error: ${toolError.message}`;
253
299
  msgs.push({ role: 'tool', tool_call_id: tc.id, content: errMsg });
254
300
  this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, error: errMsg } });
255
301
  }
302
+
303
+ // BA1: forward tool result/error to gate.record (via wireGate) with ctx in
304
+ // scope — fixes the lost-_ctx issue that wrapTool can't solve.
305
+ if (this.onToolResult) {
306
+ try {
307
+ await this.onToolResult({
308
+ name: tc.name,
309
+ args: tc.arguments,
310
+ result: toolResult,
311
+ error: toolError || null,
312
+ durationMs: Date.now() - toolStartedAt,
313
+ ctx,
314
+ });
315
+ } catch (err) {
316
+ if (err instanceof HaltError) throw err;
317
+ this._reportError('onToolResult', err, { tool: tc.name });
318
+ }
319
+ }
256
320
  }
257
321
  }
322
+ } catch (err) {
323
+ // BA2: HaltError is a clean governance exit, not a runtime failure.
324
+ // No throw even when throwOnError:true — the gate halted us deliberately.
325
+ if (err instanceof HaltError) {
326
+ this._reportError('halt', err, { rule: err.rule, reason: err.decision?.reason ?? null });
327
+ this._safeEmit({ type: 'loop:done', data: { text: '', halted: true, rule: err.rule, cost: totalCost } });
328
+ return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${err.rule}`, msgs };
329
+ }
330
+ throw err;
331
+ }
258
332
 
259
333
  // Hard safety limit — should never fire under normal usage; bareguard's
260
334
  // limits.maxTurns (or the LLM's natural completion) ends the loop first.
261
335
  const warning = `[Loop] hit internal safety limit of ${HARD_ROUND_LIMIT} rounds. Wire bareguard for proper governance — see bare-agent/bareguard.`;
262
336
  this._safeEmit({ type: 'loop:done', data: { text: '', warning, cost: totalCost } });
263
- return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: warning };
337
+ return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, msgs };
264
338
  }
265
339
 
266
340
  /**
@@ -329,9 +403,10 @@ class Loop {
329
403
  async chat(text, tools = [], options = {}) {
330
404
  this._history.push({ role: 'user', content: text });
331
405
  const result = await this.run(this._history, tools, options);
332
- if (result.text) {
333
- this._history.push({ role: 'assistant', content: result.text });
334
- }
406
+ // Sync _history from the full msgs run() built (tool-call messages, tool results,
407
+ // and final assistant text). Strip the leading system message if one was prepended.
408
+ const effectiveSystem = options.system || this.system;
409
+ this._history = effectiveSystem ? result.msgs.slice(1) : result.msgs.slice();
335
410
  return result;
336
411
  }
337
412
 
package/src/mcp-bridge.js CHANGED
@@ -284,9 +284,10 @@ function buildSystemContext(servers, tools, denied) {
284
284
 
285
285
  const byServer = {};
286
286
  for (const t of tools) {
287
- const parts = t.name.split('_');
288
- const server = parts[0];
289
- (byServer[server] = byServer[server] || []).push(t.name.replace(`${server}_`, ''));
287
+ const sep = t.name.indexOf('_');
288
+ const server = sep > 0 ? t.name.slice(0, sep) : t.name;
289
+ const tool = sep > 0 ? t.name.slice(sep + 1) : '';
290
+ (byServer[server] = byServer[server] || []).push(tool);
290
291
  }
291
292
  for (const [server, toolNames] of Object.entries(byServer)) {
292
293
  lines.push(` ${server}: ${toolNames.join(', ')}`);
@@ -449,43 +450,52 @@ async function createMCPBridge(opts = {}) {
449
450
  if (needsRefresh) {
450
451
  // Discover from IDE configs
451
452
  const discovered = discoverServers(opts.configPaths);
453
+
452
454
  if (discovered.size === 0 && !config) {
453
455
  return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
454
456
  }
455
457
 
456
- // Connect to all discovered servers and list their tools
457
- const freshTools = new Map();
458
- const connectResults = new Map();
459
- const errors = [];
460
-
461
- const toDiscover = opts.servers
462
- ? [...discovered.entries()].filter(([n]) => opts.servers.includes(n))
463
- : [...discovered.entries()];
464
-
465
- await Promise.all(toDiscover.map(async ([name, def]) => {
466
- try {
467
- const result = await connectAndListTools(name, def, timeout);
468
- freshTools.set(name, result.mcpTools);
469
- connectResults.set(name, result.client);
470
- } catch (err) {
471
- errors.push({ server: name, error: err.message });
472
- }
473
- }));
474
-
475
- if (errors.length > 0) {
476
- console.warn('[MCP Bridge] Some servers failed to connect:', errors);
477
- }
458
+ // Only attempt connection when discovery found something.
459
+ // If discovered.size === 0 but config exists, fall through and use the existing config
460
+ // rather than wiping it on a transient discovery failure.
461
+ if (discovered.size > 0) {
462
+ const freshTools = new Map();
463
+ const connectResults = new Map();
464
+ const errors = [];
465
+
466
+ const toDiscover = opts.servers
467
+ ? [...discovered.entries()].filter(([n]) => opts.servers.includes(n))
468
+ : [...discovered.entries()];
469
+
470
+ await Promise.all(toDiscover.map(async ([name, def]) => {
471
+ try {
472
+ const result = await connectAndListTools(name, def, timeout);
473
+ freshTools.set(name, result.mcpTools);
474
+ connectResults.set(name, result.client);
475
+ } catch (err) {
476
+ errors.push({ server: name, error: err.message });
477
+ }
478
+ }));
478
479
 
479
- // Merge with existing config (preserves user's allow/deny)
480
- config = mergeBridgeConfig(config, new Map(toDiscover), freshTools);
480
+ if (errors.length > 0) {
481
+ console.warn('[MCP Bridge] Some servers failed to connect:', errors);
482
+ }
481
483
 
482
- // Write the config file
483
- writeBridgeConfig(bridgePath, config);
484
- console.log(`[MCP Bridge] Wrote ${bridgePath}`);
484
+ // Only write config when at least one server connected successfully.
485
+ // If all servers failed, retain the existing config unchanged so
486
+ // user-curated allow/deny settings are not destroyed on transient failures.
487
+ if (freshTools.size > 0) {
488
+ config = mergeBridgeConfig(config, new Map(toDiscover), freshTools);
489
+ writeBridgeConfig(bridgePath, config);
490
+ console.log(`[MCP Bridge] Wrote ${bridgePath}`);
491
+ } else if (!config) {
492
+ return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
493
+ }
485
494
 
486
- // Close the discovery connections — we'll reconnect below using the config
487
- for (const client of connectResults.values()) {
488
- await killServer(client.child);
495
+ // Close the discovery connections — we'll reconnect below using the config
496
+ for (const client of connectResults.values()) {
497
+ await killServer(client.child);
498
+ }
489
499
  }
490
500
  }
491
501
 
package/src/planner.js CHANGED
@@ -40,7 +40,7 @@ class Planner {
40
40
  */
41
41
  async plan(goal, context = {}) {
42
42
  if (this._cacheTTL > 0) {
43
- const cacheKey = goal + '|' + (context.info || '');
43
+ const cacheKey = JSON.stringify({ goal, info: context.info || '' });
44
44
  const cached = this._cache.get(cacheKey);
45
45
  if (cached && Date.now() < cached.expiresAt) {
46
46
  return cached.result;
@@ -63,7 +63,7 @@ class Planner {
63
63
  const steps = this._parse(result.text);
64
64
 
65
65
  if (this._cacheTTL > 0) {
66
- const cacheKey = goal + '|' + (context.info || '');
66
+ const cacheKey = JSON.stringify({ goal, info: context.info || '' });
67
67
  this._cache.set(cacheKey, { result: steps, expiresAt: Date.now() + this._cacheTTL });
68
68
  }
69
69
 
@@ -74,7 +74,7 @@ class CLIPipeProvider {
74
74
  /**
75
75
  * Spawn the CLI process, pipe prompt to stdin, collect stdout.
76
76
  * @param {string} prompt
77
- * @param {string[]} [extraArgs=[]] - Additional args prepended to this.args.
77
+ * @param {string[]} [extraArgs=[]] - Additional args appended after this.args.
78
78
  * @returns {Promise<string>}
79
79
  */
80
80
  _spawn(prompt, extraArgs = []) {
package/src/retry.js CHANGED
@@ -14,9 +14,9 @@ const DEFAULT_RETRY_ON = (err) => {
14
14
 
15
15
  class Retry {
16
16
  constructor(options = {}) {
17
- this.maxAttempts = options.maxAttempts || 3;
17
+ this.maxAttempts = options.maxAttempts !== undefined ? options.maxAttempts : 3;
18
18
  this.backoff = options.backoff || 'exponential';
19
- this.timeout = options.timeout || 60000;
19
+ this.timeout = options.timeout !== undefined ? options.timeout : 60000;
20
20
  this.retryOn = options.retryOn || DEFAULT_RETRY_ON;
21
21
  this.jitter = options.jitter !== undefined ? options.jitter : false;
22
22
  }
@@ -30,9 +30,9 @@ class Retry {
30
30
  * @throws {Error} Rethrows the last error when maxAttempts is exhausted or error is not retryable.
31
31
  */
32
32
  async call(fn, options = {}) {
33
- const max = options.maxAttempts || this.maxAttempts;
33
+ const max = options.maxAttempts !== undefined ? options.maxAttempts : this.maxAttempts;
34
34
  const retryOn = options.retryOn || this.retryOn;
35
- const timeout = options.timeout || this.timeout;
35
+ const timeout = options.timeout !== undefined ? options.timeout : this.timeout;
36
36
 
37
37
  for (let attempt = 1; attempt <= max; attempt++) {
38
38
  let timeoutId;
package/src/scheduler.js CHANGED
@@ -22,7 +22,7 @@ class Scheduler {
22
22
  : [];
23
23
  this._timer = null;
24
24
  this._nextId = this._jobs.length
25
- ? Math.max(...this._jobs.map(j => j.id)) + 1
25
+ ? this._jobs.reduce((max, j) => Math.max(max, j.id), 0) + 1
26
26
  : 1;
27
27
  }
28
28
 
@@ -80,15 +80,16 @@ class Scheduler {
80
80
  try {
81
81
  await handler(job);
82
82
  } catch (err) {
83
- this.onError?.(err, job);
83
+ try { this.onError?.(err, job); } catch { /* swallow onError throws */ }
84
+ } finally {
85
+ this._running.delete(job.id);
86
+ if (job.type === 'once') {
87
+ job.status = 'done';
88
+ } else {
89
+ job.nextRun = this._parseSchedule(job.schedule).toISOString();
90
+ }
91
+ this._save();
84
92
  }
85
- this._running.delete(job.id);
86
- if (job.type === 'once') {
87
- job.status = 'done';
88
- } else {
89
- job.nextRun = this._parseSchedule(job.schedule).toISOString();
90
- }
91
- this._save();
92
93
  }
93
94
  };
94
95
  tick();
@@ -24,7 +24,7 @@ class JsonFileStore {
24
24
  ? JSON.parse(readFileSync(this._path, 'utf8'))
25
25
  : [];
26
26
  this._nextId = this._data.length
27
- ? Math.max(...this._data.map(d => d.id)) + 1
27
+ ? this._data.reduce((max, d) => Math.max(max, d.id), 0) + 1
28
28
  : 1;
29
29
  }
30
30