bare-agent 0.8.0 → 0.10.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
@@ -11,7 +11,12 @@
11
11
 
12
12
  ```
13
13
 
14
- **Agent orchestration in ~2.4K lines of core. One required dep ([bareguard](https://npmjs.com/package/bareguard)). 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,11 +77,13 @@ 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 |
79
- | **MCP Bridge** | Auto-discover MCP servers from IDE configs (Claude Code, Cursor, etc.), expose as bareagent tools. Static allow/deny via `.mcp-bridge.json`, `systemContext` for LLM awareness. Runtime policy lives in `Loop({ policy })` — one hook for MCP + native tools alike. Zero deps |
84
+ | **MCP Bridge** | Auto-discover MCP servers from IDE configs (Claude Code, Cursor, etc.), expose as bareagent tools. Static allow/deny via `.mcp-bridge.json`, `systemContext` for LLM awareness. Runtime policy lives in `Loop({ policy })` — one hook for MCP + native tools alike. Returns both bulk `tools` (one per MCP tool) and `metaTools` (`mcp_discover` + `mcp_invoke` for token-thrifty access to large catalogs). Zero deps |
85
+ | **Spawn** | Fork a child bareagent process as a specialist agent. LLM-callable form blocks until child exits; library form returns a handle (`wait`, `onLine`, `kill`). One JSONL channel per child — child stderr captured and re-emitted as `child:stderr` events on the parent stream. Threads `BAREGUARD_AUDIT_PATH` / `BAREGUARD_PARENT_RUN_ID` / `BAREGUARD_BUDGET_FILE` / `BAREGUARD_SPAWN_DEPTH` so the family stitches into one audit + budget. `bareguard ^0.2.0` adds `spawn.ratePerMinute` + `limits.maxDepth` per-family caps |
86
+ | **Defer** | Append a `{action, when}` record to a JSONL queue for a separate waker (cron / systemd timer / `examples/wake.sh`) to fire later. Two-phase governance: emit-time `gate.check` on the `defer` action; fire-time `gate.check` on the inner action when the waker re-invokes. `bareguard ^0.2.0` adds `defer.ratePerMinute` family-wide cap |
80
87
 
81
88
  **Providers:** OpenAI-compatible (OpenAI, OpenRouter, Groq, vLLM, LM Studio), Anthropic, Ollama, CLIPipe (any CLI tool via stdin/stdout with real-time streaming), Fallback, or bring your own (one method: `generate`). All return the same shape — swap freely.
82
89
 
@@ -84,7 +91,74 @@ Every piece works alone — take what you need, ignore the rest.
84
91
 
85
92
  **Cross-language:** Runs as a subprocess. Communicate via JSONL on stdin/stdout from Python, Go, Rust, Ruby, Java, or anything that can spawn a process. Ready-made wrappers in [`contrib/`](contrib/README.md).
86
93
 
87
- **Deps:** 1 required (`bareguard` for governance — single-gate policy + audit + budget). Optional: `cron-parser` (cron expressions), `better-sqlite3` (SQLite store), `barebrowse` (web browsing), `baremobile` (Android + iOS device control), `wearehere` (privacy assessment via barebrowse).
94
+ **Deps:** 1 required (`bareguard ^0.2.0` for governance — single-gate policy + audit + budget + per-family rate caps). Optional: `cron-parser` (cron expressions), `better-sqlite3` (SQLite store), `barebrowse` (web browsing), `baremobile` (Android + iOS device control), `wearehere` (privacy assessment via barebrowse).
95
+
96
+ ---
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).
88
162
 
89
163
  ---
90
164
 
package/bin/cli.js CHANGED
@@ -1,7 +1,35 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
+ /**
5
+ * bin/cli.js — bareagent subprocess entry point.
6
+ *
7
+ * Two modes (auto-detected by flag presence):
8
+ *
9
+ * 1. Stdio JSONL mode (no --config):
10
+ * Reads JSONL requests `{ method, params: { goal | messages } }` from stdin,
11
+ * runs Loop with no special tools, emits JSONL events on stdout. Used by
12
+ * contrib/ subprocess wrappers and ad-hoc invocations.
13
+ *
14
+ * 2. Config-driven agent mode (--config <path>):
15
+ * Loads a JSON specialist/orchestrator config, wires the configured tools
16
+ * and bareguard Gate, reads ONE input record from stdin, runs Loop, emits
17
+ * JSONL events on stdout, exits when loop:done fires. This is what the
18
+ * `spawn` tool uses to fork child agents (PRD §10.6).
19
+ *
20
+ * Config schema (v0.9):
21
+ * {
22
+ * "systemPrompt": "string",
23
+ * "provider": "openai" | "anthropic" | "ollama",
24
+ * "model": "gpt-4o-mini" (etc),
25
+ * "tools": ["shell_read", "shell_grep", "spawn", "defer", ...],
26
+ * "gate": { ...bareguard config; humanChannel headless-defaults to deny }
27
+ * }
28
+ */
29
+
4
30
  const { createInterface } = require('node:readline');
31
+ const fs = require('node:fs');
32
+ const path = require('node:path');
5
33
  const { Loop } = require('../src/loop');
6
34
  const { Stream } = require('../src/stream');
7
35
  const { JsonlTransport } = require('../src/transport-jsonl');
@@ -12,60 +40,221 @@ const flag = (name) => {
12
40
  return i >= 0 ? args[i + 1] : undefined;
13
41
  };
14
42
 
15
- const providerName = flag('provider') || 'openai';
16
- const model = flag('model');
43
+ const configPath = flag('config');
44
+
45
+ if (configPath) {
46
+ runConfigMode(configPath).catch((err) => {
47
+ process.stdout.write(JSON.stringify({ type: 'loop:error', data: { source: 'cli', error: err.message } }) + '\n');
48
+ process.exit(1);
49
+ });
50
+ } else {
51
+ runStdioMode();
52
+ }
53
+
54
+ // ─── Mode 2: config-driven ────────────────────────────────────────────────
55
+
56
+ async function runConfigMode(cfgPath) {
57
+ const cfg = readConfig(cfgPath);
58
+ const stream = new Stream({ transport: new JsonlTransport() });
59
+
60
+ // Provider
61
+ const provider = createProvider(cfg.provider || 'openai', cfg.model);
62
+
63
+ // Tools — registry resolved by name from a curated set of built-ins.
64
+ const tools = await resolveTools(cfg.tools || [], { stream });
65
+
66
+ // Bareguard Gate (optional but strongly recommended for spawn children)
67
+ let policy = null;
68
+ let wrapToolsFn = (t) => t;
69
+ if (cfg.gate) {
70
+ try {
71
+ const { Gate } = require('bareguard');
72
+ const { wireGate } = require('../src/bareguard-adapter');
73
+
74
+ // Headless humanChannel default: warn once, deny safely. Overridden if
75
+ // the config explicitly sets humanChannel (rare in JSON, but supported
76
+ // via a require path).
77
+ let humanChannel = cfg.gate.humanChannel;
78
+ if (typeof humanChannel === 'string') {
79
+ // Allow `humanChannel: "./my-channel.js"` — load from a file relative to config.
80
+ const fnPath = path.resolve(path.dirname(cfgPath), humanChannel);
81
+ humanChannel = require(fnPath);
82
+ }
83
+ if (typeof humanChannel !== 'function') {
84
+ let warned = false;
85
+ humanChannel = async (event) => {
86
+ if (!warned) {
87
+ process.stderr.write(`[cli] no humanChannel configured — ${event.kind} on ${event.rule} auto-denying.\n`);
88
+ warned = true;
89
+ }
90
+ return { decision: 'deny' };
91
+ };
92
+ }
93
+
94
+ const gate = new Gate({ ...cfg.gate, humanChannel });
95
+ await gate.init();
96
+ const wired = wireGate(gate);
97
+ policy = wired.policy;
98
+ wrapToolsFn = wired.wrapTools;
99
+ } catch (err) {
100
+ process.stderr.write(`[cli] failed to wire bareguard: ${err.message}. Continuing without policy gate.\n`);
101
+ }
102
+ }
103
+
104
+ // Read ONE input record from stdin (JSON or raw string). Treat blank stdin
105
+ // as no input — let the systemPrompt drive the loop alone.
106
+ const stdin = await readStdin();
107
+ const initialMessage = buildInitialMessage(cfg, stdin);
108
+
109
+ const loop = new Loop({
110
+ provider,
111
+ system: cfg.systemPrompt || null,
112
+ stream,
113
+ policy,
114
+ onError: (err, meta) => {
115
+ process.stderr.write(`[loop:error ${meta.source}] ${err.message}\n`);
116
+ },
117
+ });
118
+
119
+ const wrapped = wrapToolsFn(tools);
120
+ await loop.run([initialMessage], wrapped);
121
+ // Stream's loop:done event has already been emitted; exit clean.
122
+ process.exit(0);
123
+ }
17
124
 
18
- function createProvider() {
19
- if (providerName === 'openai') {
125
+ function readConfig(cfgPath) {
126
+ const abs = path.resolve(cfgPath);
127
+ let raw;
128
+ try { raw = fs.readFileSync(abs, 'utf8'); }
129
+ catch (err) { throw new Error(`[cli] cannot read config at ${abs}: ${err.message}`); }
130
+ try { return JSON.parse(raw); }
131
+ catch (err) { throw new Error(`[cli] config at ${abs} is not valid JSON: ${err.message}`); }
132
+ }
133
+
134
+ function readStdin() {
135
+ return new Promise((resolve) => {
136
+ let buf = '';
137
+ if (process.stdin.isTTY) return resolve('');
138
+ process.stdin.setEncoding('utf8');
139
+ process.stdin.on('data', (chunk) => { buf += chunk; });
140
+ process.stdin.on('end', () => resolve(buf.trim()));
141
+ // Safety: don't hang forever if stdin never closes.
142
+ setTimeout(() => resolve(buf.trim()), 100).unref();
143
+ });
144
+ }
145
+
146
+ function buildInitialMessage(cfg, stdin) {
147
+ if (!stdin) {
148
+ return { role: 'user', content: cfg.defaultPrompt || 'Begin.' };
149
+ }
150
+ // Try to parse as JSON; fall back to raw string.
151
+ let parsed;
152
+ try { parsed = JSON.parse(stdin); } catch { /* fine */ }
153
+ if (parsed && typeof parsed === 'object') {
154
+ if (typeof parsed.content === 'string') {
155
+ return { role: 'user', content: parsed.content };
156
+ }
157
+ return { role: 'user', content: JSON.stringify(parsed) };
158
+ }
159
+ return { role: 'user', content: stdin };
160
+ }
161
+
162
+ async function resolveTools(names, ctx) {
163
+ const tools = [];
164
+ for (const name of names) {
165
+ const resolved = await resolveOneTool(name, ctx);
166
+ if (resolved) tools.push(...(Array.isArray(resolved) ? resolved : [resolved]));
167
+ }
168
+ return tools;
169
+ }
170
+
171
+ async function resolveOneTool(name, ctx) {
172
+ switch (name) {
173
+ case 'shell_read':
174
+ case 'shell_grep':
175
+ case 'shell_run':
176
+ case 'shell_exec': {
177
+ const { createShellTools } = require('../tools/shell');
178
+ const { tools } = createShellTools();
179
+ return tools.find(t => t.name === name) || null;
180
+ }
181
+ case 'shell_*': {
182
+ const { createShellTools } = require('../tools/shell');
183
+ return createShellTools().tools;
184
+ }
185
+ case 'spawn': {
186
+ const { createSpawnTool } = require('../tools/spawn');
187
+ return createSpawnTool({ stream: ctx.stream }).tool;
188
+ }
189
+ case 'defer': {
190
+ const { createDeferTool } = require('../tools/defer');
191
+ return createDeferTool().tool;
192
+ }
193
+ default:
194
+ process.stderr.write(`[cli] unknown tool name in config: ${name}\n`);
195
+ return null;
196
+ }
197
+ }
198
+
199
+ // ─── Mode 1: stdio JSONL (legacy) ─────────────────────────────────────────
200
+
201
+ function runStdioMode() {
202
+ const providerName = flag('provider') || 'openai';
203
+ const model = flag('model');
204
+ const stream = new Stream({ transport: new JsonlTransport() });
205
+ const loop = new Loop({ provider: createProvider(providerName, model), stream });
206
+
207
+ let pending = 0;
208
+ let closing = false;
209
+
210
+ const rl = createInterface({ input: process.stdin });
211
+ rl.on('line', async (line) => {
212
+ pending++;
213
+ try {
214
+ const req = JSON.parse(line);
215
+ const messages = req.params?.messages || [
216
+ { role: 'user', content: req.params?.goal || '' },
217
+ ];
218
+ const result = await loop.run(messages, []);
219
+ stream.emit({ type: 'result', data: result });
220
+ } catch (err) {
221
+ stream.emit({ type: 'error', data: { error: err.message } });
222
+ } finally {
223
+ pending--;
224
+ if (closing && pending === 0) process.exit(0);
225
+ }
226
+ });
227
+
228
+ rl.on('close', () => {
229
+ closing = true;
230
+ if (pending === 0) process.exit(0);
231
+ });
232
+ }
233
+
234
+ // ─── Shared: provider construction ────────────────────────────────────────
235
+
236
+ function createProvider(name, model) {
237
+ if (name === 'openai') {
20
238
  const { OpenAIProvider } = require('../src/provider-openai');
21
239
  return new OpenAIProvider({
22
240
  apiKey: process.env.OPENAI_API_KEY,
23
241
  ...(model && { model }),
24
242
  });
25
243
  }
26
- if (providerName === 'anthropic') {
244
+ if (name === 'anthropic') {
27
245
  const { AnthropicProvider } = require('../src/provider-anthropic');
28
246
  return new AnthropicProvider({
29
247
  apiKey: process.env.ANTHROPIC_API_KEY,
30
248
  ...(model && { model }),
31
249
  });
32
250
  }
33
- if (providerName === 'ollama') {
251
+ if (name === 'ollama') {
34
252
  const { OllamaProvider } = require('../src/provider-ollama');
35
253
  return new OllamaProvider({
36
254
  ...(model && { model }),
37
255
  ...(flag('url') && { url: flag('url') }),
38
256
  });
39
257
  }
40
- process.stderr.write(`Unknown provider: ${providerName}\n`);
258
+ process.stderr.write(`Unknown provider: ${name}\n`);
41
259
  process.exit(1);
42
260
  }
43
-
44
- const stream = new Stream({ transport: new JsonlTransport() });
45
- const loop = new Loop({ provider: createProvider(), stream });
46
-
47
- let pending = 0;
48
- let closing = false;
49
-
50
- const rl = createInterface({ input: process.stdin });
51
- rl.on('line', async (line) => {
52
- pending++;
53
- try {
54
- const req = JSON.parse(line);
55
- const messages = req.params?.messages || [
56
- { role: 'user', content: req.params?.goal || '' },
57
- ];
58
- const result = await loop.run(messages, []);
59
- stream.emit({ type: 'result', data: result });
60
- } catch (err) {
61
- stream.emit({ type: 'error', data: { error: err.message } });
62
- } finally {
63
- pending--;
64
- if (closing && pending === 0) process.exit(0);
65
- }
66
- });
67
-
68
- rl.on('close', () => {
69
- closing = true;
70
- if (pending === 0) process.exit(0);
71
- });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "src/",
@@ -9,7 +9,7 @@
9
9
  "LICENSE",
10
10
  "NOTICE"
11
11
  ],
12
- "description": "Lightweight, composable agent orchestration for autonomous agents. Single-gate governance via bareguard, cross-platform shell tools, MCP bridge. ~2.4K lines core, one required dep.",
12
+ "description": "Lightweight, composable agent orchestration for autonomous agents. Multi-agent primitives (spawn, defer, MCP meta-tools), single-gate governance via bareguard, cross-platform shell tools, MCP bridge. ~2.7K lines core, one required dep.",
13
13
  "license": "Apache-2.0",
14
14
  "author": "hamr0",
15
15
  "repository": {
@@ -44,7 +44,7 @@
44
44
  "governance"
45
45
  ],
46
46
  "dependencies": {
47
- "bareguard": "^0.1.1"
47
+ "bareguard": "^0.2.0"
48
48
  },
49
49
  "optionalDependencies": {
50
50
  "barebrowse": "^0.5.0",
@@ -60,6 +60,6 @@
60
60
  }
61
61
  },
62
62
  "scripts": {
63
- "test": "node --test test/**/*.test.js"
63
+ "test": "node --test --test-force-exit test/**/*.test.js"
64
64
  }
65
65
  }
@@ -1,25 +1,34 @@
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
+ * @returns {{policy: Function, onLlmResult: Function, onToolResult: Function, filterTools: Function, wrapTool: Function, wrapTools: Function}}
23
32
  *
24
33
  * @example
25
34
  * const { Gate } = require('bareguard');
@@ -30,29 +39,84 @@
30
39
  * budget: { maxCostUsd: 0.50 },
31
40
  * limits: { maxTurns: 20 },
32
41
  * audit: { path: './audit.jsonl' },
33
- * humanChannel: async (ev) => ({ decision: 'deny' }),
34
42
  * });
35
43
  * await gate.init();
36
44
  *
37
- * const { policy, wrapTools } = wireGate(gate);
38
- * const loop = new Loop({ provider, policy });
39
- * await loop.run(messages, wrapTools(myTools));
45
+ * const { policy, onLlmResult, onToolResult, filterTools } = wireGate(gate);
46
+ * const loop = new Loop({ provider, policy, onLlmResult, onToolResult });
47
+ * const tools = await filterTools(myTools);
48
+ * await loop.run(messages, tools);
40
49
  */
41
- function wireGate(gate) {
50
+ function wireGate(gate, options = {}) {
42
51
  if (!gate || typeof gate.check !== 'function' || typeof gate.record !== 'function') {
43
52
  throw new Error('[wireGate] expects a bareguard Gate instance (must have .check and .record).');
44
53
  }
54
+ if (options.formatDeny != null && typeof options.formatDeny !== 'function') {
55
+ throw new Error('[wireGate] options.formatDeny must be a function (decision) => string');
56
+ }
57
+ const formatDeny = options.formatDeny || defaultFormatDeny;
45
58
 
46
59
  const policy = async (toolName, args, ctx) => {
47
60
  const decision = await gate.check({ type: toolName, args, _ctx: ctx });
48
61
  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`;
62
+ if (decision.severity === 'halt') {
63
+ throw new HaltError(decision.reason || `${toolName} halted by ${decision.rule}`, {
64
+ rule: decision.rule,
65
+ decision,
66
+ });
67
+ }
68
+ return formatDeny(decision, toolName);
69
+ };
70
+
71
+ const onLlmResult = async ({ model, provider, usage, costUsd, durationMs, ctx }) => {
72
+ await gate.record(
73
+ { type: 'llm', args: { model: model || null, provider: provider || null }, _ctx: ctx ?? null },
74
+ {
75
+ costUsd: typeof costUsd === 'number' ? costUsd : 0,
76
+ tokens: (usage?.inputTokens || 0) + (usage?.outputTokens || 0),
77
+ durationMs: durationMs ?? null,
78
+ },
79
+ );
80
+ };
81
+
82
+ const onToolResult = async ({ name, args, result, error, durationMs, ctx }) => {
83
+ const action = { type: name, args, _ctx: ctx ?? null };
84
+ if (error) {
85
+ await gate.record(action, {
86
+ error: error?.message || String(error),
87
+ durationMs: durationMs ?? null,
88
+ });
89
+ } else {
90
+ await gate.record(action, {
91
+ result: typeof result === 'string' ? result : JSON.stringify(result),
92
+ durationMs: durationMs ?? null,
93
+ });
94
+ }
53
95
  };
54
96
 
97
+ const filterTools = async (tools) => {
98
+ if (!Array.isArray(tools)) {
99
+ throw new Error('[wireGate.filterTools] expects an array of tools');
100
+ }
101
+ if (typeof gate.allows !== 'function') {
102
+ throw new Error('[wireGate.filterTools] gate must have .allows (bareguard >= 0.2)');
103
+ }
104
+ const out = [];
105
+ for (const t of tools) {
106
+ if (await gate.allows(t.name)) out.push(t);
107
+ }
108
+ return out;
109
+ };
110
+
111
+ let warnedWrap = false;
55
112
  function wrapTool(tool) {
113
+ if (!warnedWrap) {
114
+ warnedWrap = true;
115
+ console.warn(
116
+ '[wireGate] wrapTool/wrapTools is deprecated — use new Loop({ policy, onLlmResult, onToolResult }) ' +
117
+ 'so budget covers LLM cost and ctx reaches gate.record. wrap* will be removed in 1.0.',
118
+ );
119
+ }
56
120
  if (!tool || typeof tool.execute !== 'function') {
57
121
  throw new Error('[wireGate.wrapTool] tool must have an execute() function');
58
122
  }
@@ -87,7 +151,12 @@ function wireGate(gate) {
87
151
  return tools.map(wrapTool);
88
152
  }
89
153
 
90
- return { policy, wrapTool, wrapTools };
154
+ return { policy, onLlmResult, onToolResult, filterTools, wrapTool, wrapTools };
155
+ }
156
+
157
+ function defaultFormatDeny(decision, toolName) {
158
+ const tag = `[deny: ${decision.rule}]`;
159
+ return decision.reason ? `${tag} ${decision.reason}` : `${tag} ${toolName} denied`;
91
160
  }
92
161
 
93
162
  module.exports = { wireGate };
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
  };