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 +78 -4
- package/bin/cli.js +225 -36
- package/package.json +4 -4
- package/src/bareguard-adapter.js +93 -24
- package/src/errors.js +17 -0
- package/src/loop.js +78 -11
- package/src/mcp-bridge.js +173 -52
- package/src/mcp.js +2 -2
- package/src/planner.js +2 -2
- package/src/provider-clipipe.js +1 -1
- package/src/retry.js +13 -5
- package/src/scheduler.js +10 -9
- package/src/store-jsonfile.js +1 -1
- package/src/tools.js +11 -1
- package/tools/defer.js +203 -0
- package/tools/spawn.js +242 -0
package/README.md
CHANGED
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
|
|
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,
|
|
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
|
|
16
|
-
|
|
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
|
|
19
|
-
|
|
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 (
|
|
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 (
|
|
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: ${
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
}
|
package/src/bareguard-adapter.js
CHANGED
|
@@ -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`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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,
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
22
|
-
* @
|
|
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,
|
|
38
|
-
* const loop = new Loop({ provider, policy });
|
|
39
|
-
* await
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
};
|