bare-agent 0.7.0 → 0.9.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/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +20 -16
- package/bin/cli.js +225 -36
- package/index.js +2 -4
- package/package.json +14 -7
- package/src/bareguard-adapter.js +93 -0
- package/src/errors.js +0 -14
- package/src/loop.js +17 -78
- package/src/mcp-bridge.js +130 -19
- package/src/mcp.js +2 -2
- package/src/retry.js +9 -1
- package/src/tools.js +11 -1
- package/tools/defer.js +203 -0
- package/tools/spawn.js +242 -0
- package/src/policy.js +0 -132
package/package.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bare-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"files": [
|
|
5
5
|
"index.js",
|
|
6
6
|
"src/",
|
|
7
7
|
"bin/",
|
|
8
|
-
"tools/"
|
|
8
|
+
"tools/",
|
|
9
|
+
"LICENSE",
|
|
10
|
+
"NOTICE"
|
|
9
11
|
],
|
|
10
|
-
"description": "Lightweight, composable agent orchestration. ~
|
|
11
|
-
"license": "
|
|
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
|
+
"license": "Apache-2.0",
|
|
12
14
|
"author": "hamr0",
|
|
13
15
|
"repository": {
|
|
14
16
|
"type": "git",
|
|
@@ -25,7 +27,7 @@
|
|
|
25
27
|
"./transports": "./src/transports.js",
|
|
26
28
|
"./tools": "./src/tools.js",
|
|
27
29
|
"./mcp": "./src/mcp.js",
|
|
28
|
-
"./
|
|
30
|
+
"./bareguard": "./src/bareguard-adapter.js"
|
|
29
31
|
},
|
|
30
32
|
"engines": {
|
|
31
33
|
"node": ">=18"
|
|
@@ -37,8 +39,13 @@
|
|
|
37
39
|
"ai",
|
|
38
40
|
"tool-calling",
|
|
39
41
|
"planner",
|
|
40
|
-
"lightweight"
|
|
42
|
+
"lightweight",
|
|
43
|
+
"bareguard",
|
|
44
|
+
"governance"
|
|
41
45
|
],
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"bareguard": "^0.2.0"
|
|
48
|
+
},
|
|
42
49
|
"optionalDependencies": {
|
|
43
50
|
"barebrowse": "^0.5.0",
|
|
44
51
|
"baremobile": "^0.7.0",
|
|
@@ -53,6 +60,6 @@
|
|
|
53
60
|
}
|
|
54
61
|
},
|
|
55
62
|
"scripts": {
|
|
56
|
-
"test": "node --test test/**/*.test.js"
|
|
63
|
+
"test": "node --test --test-force-exit test/**/*.test.js"
|
|
57
64
|
}
|
|
58
65
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wire a bareguard Gate into bareagent's Loop.
|
|
5
|
+
*
|
|
6
|
+
* 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.
|
|
14
|
+
*
|
|
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.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} gate - A bareguard Gate instance (must have .check and .record).
|
|
22
|
+
* @returns {{policy: Function, wrapTool: Function, wrapTools: Function}}
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* const { Gate } = require('bareguard');
|
|
26
|
+
* const { Loop } = require('bare-agent');
|
|
27
|
+
* const { wireGate } = require('bare-agent/bareguard');
|
|
28
|
+
*
|
|
29
|
+
* const gate = new Gate({
|
|
30
|
+
* budget: { maxCostUsd: 0.50 },
|
|
31
|
+
* limits: { maxTurns: 20 },
|
|
32
|
+
* audit: { path: './audit.jsonl' },
|
|
33
|
+
* humanChannel: async (ev) => ({ decision: 'deny' }),
|
|
34
|
+
* });
|
|
35
|
+
* await gate.init();
|
|
36
|
+
*
|
|
37
|
+
* const { policy, wrapTools } = wireGate(gate);
|
|
38
|
+
* const loop = new Loop({ provider, policy });
|
|
39
|
+
* await loop.run(messages, wrapTools(myTools));
|
|
40
|
+
*/
|
|
41
|
+
function wireGate(gate) {
|
|
42
|
+
if (!gate || typeof gate.check !== 'function' || typeof gate.record !== 'function') {
|
|
43
|
+
throw new Error('[wireGate] expects a bareguard Gate instance (must have .check and .record).');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const policy = async (toolName, args, ctx) => {
|
|
47
|
+
const decision = await gate.check({ type: toolName, args, _ctx: ctx });
|
|
48
|
+
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`;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function wrapTool(tool) {
|
|
56
|
+
if (!tool || typeof tool.execute !== 'function') {
|
|
57
|
+
throw new Error('[wireGate.wrapTool] tool must have an execute() function');
|
|
58
|
+
}
|
|
59
|
+
const original = tool.execute;
|
|
60
|
+
return {
|
|
61
|
+
...tool,
|
|
62
|
+
execute: async (args) => {
|
|
63
|
+
const action = { type: tool.name, args };
|
|
64
|
+
const startedAt = Date.now();
|
|
65
|
+
try {
|
|
66
|
+
const result = await original(args);
|
|
67
|
+
await gate.record(action, {
|
|
68
|
+
result: typeof result === 'string' ? result : JSON.stringify(result),
|
|
69
|
+
durationMs: Date.now() - startedAt,
|
|
70
|
+
});
|
|
71
|
+
return result;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
await gate.record(action, {
|
|
74
|
+
error: err?.message || String(err),
|
|
75
|
+
durationMs: Date.now() - startedAt,
|
|
76
|
+
});
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function wrapTools(tools) {
|
|
84
|
+
if (!Array.isArray(tools)) {
|
|
85
|
+
throw new Error('[wireGate.wrapTools] expects an array of tools');
|
|
86
|
+
}
|
|
87
|
+
return tools.map(wrapTool);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { policy, wrapTool, wrapTools };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { wireGate };
|
package/src/errors.js
CHANGED
|
@@ -43,18 +43,6 @@ class CircuitOpenError extends BareAgentError {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
class MaxRoundsError extends BareAgentError {
|
|
47
|
-
constructor(message, opts = {}) {
|
|
48
|
-
super(message || 'Loop exceeded maximum rounds', { code: 'MAX_ROUNDS', retryable: false, ...opts });
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
class MaxCostError extends BareAgentError {
|
|
53
|
-
constructor(message, opts = {}) {
|
|
54
|
-
super(message || 'Loop exceeded maximum cost cap', { code: 'MAX_COST', retryable: false, ...opts });
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
46
|
module.exports = {
|
|
59
47
|
BareAgentError,
|
|
60
48
|
ProviderError,
|
|
@@ -62,6 +50,4 @@ module.exports = {
|
|
|
62
50
|
TimeoutError,
|
|
63
51
|
ValidationError,
|
|
64
52
|
CircuitOpenError,
|
|
65
|
-
MaxRoundsError,
|
|
66
|
-
MaxCostError,
|
|
67
53
|
};
|
package/src/loop.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
const { ToolError, MaxRoundsError, MaxCostError } = require('./errors');
|
|
3
|
+
const { ToolError } = require('./errors');
|
|
5
4
|
|
|
6
5
|
// Average pricing per 1K tokens (USD). Adjust these to match your provider's rates.
|
|
7
6
|
// Last updated: 2026-03-18. Source: public provider pricing pages.
|
|
@@ -21,6 +20,11 @@ const COST_PER_1K = {
|
|
|
21
20
|
'_default': { in: 0.002, out: 0.008 },
|
|
22
21
|
};
|
|
23
22
|
|
|
23
|
+
// Internal safety net only — real iteration bounds come from a wired bareguard
|
|
24
|
+
// Gate via `limits.maxTurns`. If you hit this without bareguard wired, you have
|
|
25
|
+
// no governance and the LLM loop is unbounded by design — wire bareguard.
|
|
26
|
+
const HARD_ROUND_LIMIT = 100;
|
|
27
|
+
|
|
24
28
|
function estimateCost(model, usage) {
|
|
25
29
|
if (!usage || !model) return null;
|
|
26
30
|
const rates = COST_PER_1K[model] || COST_PER_1K['_default'];
|
|
@@ -34,20 +38,17 @@ class Loop {
|
|
|
34
38
|
/**
|
|
35
39
|
* @param {object} options
|
|
36
40
|
* @param {object} options.provider - LLM provider (must implement generate()).
|
|
37
|
-
* @param {number} [options.maxRounds=5] - Maximum think/act/observe cycles.
|
|
38
41
|
* @param {string} [options.system] - System prompt prepended to messages.
|
|
39
42
|
* @param {object} [options.checkpoint] - Checkpoint instance for human-in-the-loop.
|
|
40
43
|
* @param {object} [options.retry] - Retry instance for backoff on failures.
|
|
41
44
|
* @param {object} [options.stream] - Stream instance for event emission.
|
|
42
45
|
* @param {object} [options.store] - Store instance for validate() health check.
|
|
43
|
-
* @param {Function} [options.policy] - Async (toolName, args) => true|
|
|
44
|
-
* @param {string} [options.audit] - File path for JSONL audit log. Each tool call appends one line: {ts, tool, args, decision, result|reason|error, durationMs}.
|
|
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.
|
|
45
47
|
* @throws {Error} `[Loop] requires a provider` — when options.provider is missing.
|
|
46
48
|
*/
|
|
47
49
|
constructor(options = {}) {
|
|
48
50
|
if (!options.provider) throw new Error('[Loop] requires a provider');
|
|
49
51
|
this.provider = options.provider;
|
|
50
|
-
this.maxRounds = options.maxRounds || 5;
|
|
51
52
|
this.system = options.system || null;
|
|
52
53
|
this.checkpoint = options.checkpoint || null;
|
|
53
54
|
this.retry = options.retry || null;
|
|
@@ -58,19 +59,16 @@ class Loop {
|
|
|
58
59
|
this.throwOnError = options.throwOnError !== undefined ? options.throwOnError : true;
|
|
59
60
|
this.store = options.store || null;
|
|
60
61
|
if (options.policy != null && typeof options.policy !== 'function') {
|
|
61
|
-
throw new Error('[Loop] options.policy must be a function (toolName, args) => true |
|
|
62
|
+
throw new Error('[Loop] options.policy must be a function (toolName, args, ctx) => true | string');
|
|
62
63
|
}
|
|
63
64
|
this.policy = options.policy || null;
|
|
64
|
-
this.audit = options.audit || null;
|
|
65
|
-
this.maxCost = typeof options.maxCost === 'number' && options.maxCost > 0 ? options.maxCost : null;
|
|
66
65
|
this._stopped = false;
|
|
67
66
|
this._history = []; // for chat() stateful mode
|
|
68
|
-
this._auditInFlight = new Set();
|
|
69
67
|
}
|
|
70
68
|
|
|
71
69
|
// Unified error emitter — every silent-ish failure path routes through here so
|
|
72
|
-
// operators see
|
|
73
|
-
//
|
|
70
|
+
// operators see callback throws, checkpoint timeouts, stream listener errors
|
|
71
|
+
// in one place: loop:error stream event + onError callback.
|
|
74
72
|
_reportError(source, err, extra = {}) {
|
|
75
73
|
const message = err?.message || String(err);
|
|
76
74
|
this._safeEmit({ type: 'loop:error', data: { source, error: message, ...extra } });
|
|
@@ -106,35 +104,11 @@ class Loop {
|
|
|
106
104
|
}
|
|
107
105
|
}
|
|
108
106
|
|
|
109
|
-
// Append one JSONL record. Returns nothing (fire-and-forget for callers)
|
|
110
|
-
// but tracks the in-flight promise so `flush()` and the end of `run()` can await it.
|
|
111
|
-
_writeAudit(record) {
|
|
112
|
-
if (!this.audit) return;
|
|
113
|
-
let line;
|
|
114
|
-
try {
|
|
115
|
-
line = JSON.stringify(record) + '\n';
|
|
116
|
-
} catch (err) {
|
|
117
|
-
this._reportError('audit:serialize', err, { tool: record?.tool });
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
const p = fs.promises.appendFile(this.audit, line)
|
|
121
|
-
.catch(err => this._reportError('audit:write', err, { tool: record?.tool }))
|
|
122
|
-
.finally(() => this._auditInFlight.delete(p));
|
|
123
|
-
this._auditInFlight.add(p);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Await any in-flight audit writes. Safe to call multiple times; resolves immediately
|
|
127
|
-
// when no writes are pending. Called automatically at the end of each `run()`.
|
|
128
|
-
async flush() {
|
|
129
|
-
if (this._auditInFlight.size === 0) return;
|
|
130
|
-
await Promise.all([...this._auditInFlight]);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
107
|
/**
|
|
134
108
|
* Run the think/act/observe loop.
|
|
135
109
|
* @param {Array<object>} messages - Conversation messages in OpenAI format.
|
|
136
110
|
* @param {Array<object>} [tools=[]] - Tool definitions with name, execute, description, parameters.
|
|
137
|
-
* @param {object} [options={}] - Per-run overrides (system, temperature, etc.).
|
|
111
|
+
* @param {object} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
|
|
138
112
|
* @returns {Promise<{text: string, toolCalls: Array, usage: object, error: string|null}>}
|
|
139
113
|
* @throws {Error} `[Loop] Tool is missing a name` — when a tool has no name or a non-string name.
|
|
140
114
|
* @throws {Error} `[Loop] Tool "X" is missing an execute() function` — when execute is not a function.
|
|
@@ -170,7 +144,7 @@ class Loop {
|
|
|
170
144
|
let lastUsage = { inputTokens: 0, outputTokens: 0 };
|
|
171
145
|
let totalCost = 0;
|
|
172
146
|
|
|
173
|
-
for (let round = 0; round <
|
|
147
|
+
for (let round = 0; round < HARD_ROUND_LIMIT; round++) {
|
|
174
148
|
if (this._stopped) break;
|
|
175
149
|
|
|
176
150
|
let result;
|
|
@@ -179,7 +153,6 @@ class Loop {
|
|
|
179
153
|
result = this.retry ? await this.retry.call(generate) : await generate();
|
|
180
154
|
} catch (err) {
|
|
181
155
|
this._reportError('provider', err, { round });
|
|
182
|
-
await this.flush();
|
|
183
156
|
if (this.throwOnError) throw err;
|
|
184
157
|
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message };
|
|
185
158
|
}
|
|
@@ -189,21 +162,10 @@ class Loop {
|
|
|
189
162
|
const roundCost = estimateCost(model, lastUsage);
|
|
190
163
|
if (roundCost !== null) totalCost += roundCost;
|
|
191
164
|
|
|
192
|
-
// Cost cap — fail fast before the next round costs more money.
|
|
193
|
-
if (this.maxCost !== null && totalCost > this.maxCost) {
|
|
194
|
-
const msg = `[Loop] cost ${totalCost.toFixed(4)} exceeded cap ${this.maxCost.toFixed(4)} after round ${round + 1}`;
|
|
195
|
-
const err = new MaxCostError(msg, { context: { cost: totalCost, maxCost: this.maxCost, round } });
|
|
196
|
-
this._reportError('cost-cap', err, { cost: totalCost, maxCost: this.maxCost });
|
|
197
|
-
await this.flush();
|
|
198
|
-
if (this.throwOnError) throw err;
|
|
199
|
-
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: msg };
|
|
200
|
-
}
|
|
201
|
-
|
|
202
165
|
// No tool calls — LLM gave a final text response
|
|
203
166
|
if (!result.toolCalls || result.toolCalls.length === 0) {
|
|
204
167
|
this._safeEmit({ type: 'loop:text', data: { text: result.text } });
|
|
205
168
|
this._safeCall('onText', this.onText, result.text);
|
|
206
|
-
await this.flush();
|
|
207
169
|
this._safeEmit({ type: 'loop:done', data: { text: result.text, usage: lastUsage, cost: totalCost } });
|
|
208
170
|
return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null };
|
|
209
171
|
}
|
|
@@ -260,6 +222,8 @@ class Loop {
|
|
|
260
222
|
// anything else (false, string, undefined, object, throw) denies. A string verdict
|
|
261
223
|
// is used verbatim as the deny reason. `ctx` (opaque blob passed via
|
|
262
224
|
// loop.run(msgs, tools, { ctx })) is forwarded as the third arg for per-caller gating.
|
|
225
|
+
// Recommended wiring: bareguard's Gate via `wireGate(gate).policy` — bareguard
|
|
226
|
+
// owns budget, audit, and halt decisions; Loop just respects the verdict.
|
|
263
227
|
if (this.policy) {
|
|
264
228
|
let verdict;
|
|
265
229
|
try {
|
|
@@ -273,54 +237,29 @@ class Loop {
|
|
|
273
237
|
: `[Loop] Tool "${tc.name}" denied by policy`;
|
|
274
238
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content: reason });
|
|
275
239
|
this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, denied: true, reason } });
|
|
276
|
-
this._writeAudit({
|
|
277
|
-
ts: new Date().toISOString(),
|
|
278
|
-
tool: tc.name,
|
|
279
|
-
args: tc.arguments,
|
|
280
|
-
decision: 'deny',
|
|
281
|
-
reason,
|
|
282
|
-
});
|
|
283
240
|
continue;
|
|
284
241
|
}
|
|
285
242
|
}
|
|
286
243
|
|
|
287
|
-
const startedAt = Date.now();
|
|
288
244
|
try {
|
|
289
245
|
const execute = () => tool.execute(tc.arguments);
|
|
290
246
|
const toolResult = this.retry ? await this.retry.call(execute) : await execute();
|
|
291
247
|
const content = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
|
|
292
248
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content });
|
|
293
249
|
this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, result: content } });
|
|
294
|
-
this._writeAudit({
|
|
295
|
-
ts: new Date().toISOString(),
|
|
296
|
-
tool: tc.name,
|
|
297
|
-
args: tc.arguments,
|
|
298
|
-
decision: 'allow',
|
|
299
|
-
result: content,
|
|
300
|
-
durationMs: Date.now() - startedAt,
|
|
301
|
-
});
|
|
302
250
|
} catch (err) {
|
|
303
251
|
const toolErr = err instanceof ToolError ? err : new ToolError(err.message, { context: { tool: tc.name } });
|
|
304
252
|
const errMsg = `[Loop] Tool error: ${toolErr.message}`;
|
|
305
253
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content: errMsg });
|
|
306
254
|
this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, error: errMsg } });
|
|
307
|
-
this._writeAudit({
|
|
308
|
-
ts: new Date().toISOString(),
|
|
309
|
-
tool: tc.name,
|
|
310
|
-
args: tc.arguments,
|
|
311
|
-
decision: 'allow',
|
|
312
|
-
error: toolErr.message,
|
|
313
|
-
durationMs: Date.now() - startedAt,
|
|
314
|
-
});
|
|
315
255
|
}
|
|
316
256
|
}
|
|
317
257
|
}
|
|
318
258
|
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
259
|
+
// Hard safety limit — should never fire under normal usage; bareguard's
|
|
260
|
+
// limits.maxTurns (or the LLM's natural completion) ends the loop first.
|
|
261
|
+
const warning = `[Loop] hit internal safety limit of ${HARD_ROUND_LIMIT} rounds. Wire bareguard for proper governance — see bare-agent/bareguard.`;
|
|
322
262
|
this._safeEmit({ type: 'loop:done', data: { text: '', warning, cost: totalCost } });
|
|
323
|
-
if (this.throwOnError) throw new MaxRoundsError(warning);
|
|
324
263
|
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: warning };
|
|
325
264
|
}
|
|
326
265
|
|
package/src/mcp-bridge.js
CHANGED
|
@@ -219,29 +219,30 @@ function wrapTools(serverName, mcpTools, rpc) {
|
|
|
219
219
|
async function killServer(child) {
|
|
220
220
|
if (child.exitCode !== null) return;
|
|
221
221
|
|
|
222
|
-
child
|
|
222
|
+
// end() sends FIN so the child sees stdin EOF and can exit cleanly;
|
|
223
|
+
// destroy() alone does not always propagate.
|
|
224
|
+
try { child.stdin?.end(); } catch { /* already closed */ }
|
|
223
225
|
child.stdout?.destroy();
|
|
224
226
|
child.stderr?.destroy();
|
|
225
227
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
+
// Short grace, then SIGTERM, then SIGKILL. Each wait clears its timer
|
|
229
|
+
// promptly when the child closes so we don't block the event loop after
|
|
230
|
+
// exit (which kept node:test's file-level wrapper hanging).
|
|
231
|
+
const waitClose = (ms) => new Promise(resolve => {
|
|
232
|
+
let timer;
|
|
233
|
+
const onClose = () => { clearTimeout(timer); resolve(); };
|
|
228
234
|
child.once('close', onClose);
|
|
229
|
-
setTimeout(() => {
|
|
235
|
+
timer = setTimeout(() => {
|
|
230
236
|
child.removeListener('close', onClose);
|
|
231
237
|
resolve();
|
|
232
|
-
},
|
|
238
|
+
}, ms);
|
|
233
239
|
});
|
|
234
240
|
|
|
241
|
+
await waitClose(150);
|
|
242
|
+
|
|
235
243
|
if (child.exitCode === null) {
|
|
236
244
|
child.kill('SIGTERM');
|
|
237
|
-
await
|
|
238
|
-
const onClose = () => resolve();
|
|
239
|
-
child.once('close', onClose);
|
|
240
|
-
setTimeout(() => {
|
|
241
|
-
child.removeListener('close', onClose);
|
|
242
|
-
resolve();
|
|
243
|
-
}, 700);
|
|
244
|
-
});
|
|
245
|
+
await waitClose(300);
|
|
245
246
|
}
|
|
246
247
|
|
|
247
248
|
if (child.exitCode === null) {
|
|
@@ -262,11 +263,12 @@ async function connectAndListTools(name, def, timeout = 15000) {
|
|
|
262
263
|
clientInfo: { name: 'bare-agent', version: '0.5.0' },
|
|
263
264
|
});
|
|
264
265
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
266
|
+
let timerId;
|
|
267
|
+
const timer = new Promise((_, reject) => {
|
|
268
|
+
timerId = setTimeout(() => reject(new ToolError(`MCP server "${name}" init timed out after ${timeout}ms`)), timeout);
|
|
269
|
+
});
|
|
268
270
|
|
|
269
|
-
await Promise.race([init, timer]);
|
|
271
|
+
try { await Promise.race([init, timer]); } finally { clearTimeout(timerId); }
|
|
270
272
|
client.notify('notifications/initialized');
|
|
271
273
|
|
|
272
274
|
const { tools: mcpTools } = await client.rpc('tools/list');
|
|
@@ -308,6 +310,103 @@ function buildSystemContext(servers, tools, denied) {
|
|
|
308
310
|
return lines.join('\n');
|
|
309
311
|
}
|
|
310
312
|
|
|
313
|
+
// --- Meta-tools: mcp_discover + mcp_invoke (v0.9) ---
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Build the LLM-callable meta-tool surface from a fully-connected bridge.
|
|
317
|
+
* Shares the underlying tool array and RPC clients with the bulk surface —
|
|
318
|
+
* one set of connections, one factory, two output forms. The user picks
|
|
319
|
+
* `bridge.tools` (bulk) for small catalogs the LLM should see upfront, or
|
|
320
|
+
* `bridge.metaTools` for large catalogs the LLM should discover on demand.
|
|
321
|
+
*
|
|
322
|
+
* Gov shape: when the LLM calls mcp_invoke, the action sent to gate.check
|
|
323
|
+
* is `{ type: 'mcp_invoke', args: { name, args }, _ctx }` — bareguard sees
|
|
324
|
+
* `mcp_invoke` as the type. To deny specific MCP tools, use bareguard's
|
|
325
|
+
* `tools.denyArgPatterns: { mcp_invoke: [/"name":"linear_admin_.*"/] }`
|
|
326
|
+
* or `content.denyPatterns` over the JSON-serialized form. The inner MCP
|
|
327
|
+
* tool name doesn't travel as `action.type` — that's a deliberate v0.9
|
|
328
|
+
* trade for one consistent gate-check call per LLM tool invocation.
|
|
329
|
+
*
|
|
330
|
+
* @param {Array} tools - The bulk-loaded, name-prefixed tools array.
|
|
331
|
+
* @param {string} discoveredAt - ISO timestamp from .mcp-bridge.json.
|
|
332
|
+
* @returns {Array} [mcp_discover, mcp_invoke]
|
|
333
|
+
*/
|
|
334
|
+
function buildMetaTools(tools, discoveredAt) {
|
|
335
|
+
// Catalog descriptors: same info the LLM would see for bulk-loaded tools,
|
|
336
|
+
// but exposed via mcp_discover instead of taking up tool-array slots upfront.
|
|
337
|
+
const catalog = tools.map(t => {
|
|
338
|
+
const sep = t.name.indexOf('_');
|
|
339
|
+
return {
|
|
340
|
+
name: t.name,
|
|
341
|
+
description: t.description || '',
|
|
342
|
+
schema: t.parameters || { type: 'object', properties: {} },
|
|
343
|
+
server: sep > 0 ? t.name.slice(0, sep) : t.name,
|
|
344
|
+
tool: sep > 0 ? t.name.slice(sep + 1) : '',
|
|
345
|
+
};
|
|
346
|
+
});
|
|
347
|
+
const byName = new Map(tools.map(t => [t.name, t]));
|
|
348
|
+
|
|
349
|
+
const mcpDiscover = {
|
|
350
|
+
name: 'mcp_discover',
|
|
351
|
+
description:
|
|
352
|
+
'List MCP tools currently available across all configured servers. Returns descriptors with name, description, schema, server, and tool. Pass refresh:true to force a fresh discovery (otherwise the catalog is the one loaded at agent startup). Discovery itself is ungated — read-only catalog access. Gov decisions still happen at invoke time via mcp_invoke.',
|
|
353
|
+
parameters: {
|
|
354
|
+
type: 'object',
|
|
355
|
+
properties: {
|
|
356
|
+
refresh: {
|
|
357
|
+
type: 'boolean',
|
|
358
|
+
description: 'Currently a no-op flag in v0.9 — the catalog is loaded once at bridge construction. Set true to signal intent; behavior may change in a later version.',
|
|
359
|
+
},
|
|
360
|
+
server: {
|
|
361
|
+
type: 'string',
|
|
362
|
+
description: 'Optional: filter the catalog to one server name.',
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
execute: async ({ server } = {}) => {
|
|
367
|
+
const filtered = server
|
|
368
|
+
? catalog.filter(t => t.server === server)
|
|
369
|
+
: catalog;
|
|
370
|
+
return {
|
|
371
|
+
tools: filtered,
|
|
372
|
+
cachedAt: discoveredAt || new Date().toISOString(),
|
|
373
|
+
count: filtered.length,
|
|
374
|
+
};
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const mcpInvoke = {
|
|
379
|
+
name: 'mcp_invoke',
|
|
380
|
+
description:
|
|
381
|
+
'Invoke an MCP tool by its canonical bareagent name (the `name` field returned by mcp_discover, e.g. "linear_list_issues"). Args are passed through to the underlying MCP server. Returns the tool result. Bareguard governs every invocation — denies fed back as deny strings, halts as [HALT] strings.',
|
|
382
|
+
parameters: {
|
|
383
|
+
type: 'object',
|
|
384
|
+
properties: {
|
|
385
|
+
name: {
|
|
386
|
+
type: 'string',
|
|
387
|
+
description: 'Canonical MCP tool name (from mcp_discover). Format: <server>_<tool>.',
|
|
388
|
+
},
|
|
389
|
+
args: {
|
|
390
|
+
type: 'object',
|
|
391
|
+
description: 'Arguments for the MCP tool, matching its schema (also from mcp_discover).',
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
required: ['name'],
|
|
395
|
+
},
|
|
396
|
+
execute: async ({ name, args }) => {
|
|
397
|
+
const tool = byName.get(name);
|
|
398
|
+
if (!tool) {
|
|
399
|
+
throw new ToolError(`mcp_invoke: unknown tool "${name}". Call mcp_discover for the current catalog.`, {
|
|
400
|
+
context: { name, knownNames: [...byName.keys()] },
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
return await tool.execute(args || {});
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
return [mcpDiscover, mcpInvoke];
|
|
408
|
+
}
|
|
409
|
+
|
|
311
410
|
// --- Main entry point ---
|
|
312
411
|
|
|
313
412
|
/**
|
|
@@ -316,13 +415,22 @@ function buildSystemContext(servers, tools, denied) {
|
|
|
316
415
|
* On subsequent runs, reads .mcp-bridge.json and respects allow/deny per tool.
|
|
317
416
|
* Re-discovers when TTL expires (default: 24h).
|
|
318
417
|
*
|
|
418
|
+
* Returns BOTH surfaces (v0.9+):
|
|
419
|
+
* - `tools` — bulk-loaded array of name-prefixed tools (small catalogs;
|
|
420
|
+
* LLM sees them upfront).
|
|
421
|
+
* - `metaTools` — [mcp_discover, mcp_invoke] LLM-callable pair (large catalogs;
|
|
422
|
+
* LLM picks tools dynamically). Shares the same RPC connections.
|
|
423
|
+
*
|
|
424
|
+
* Wire one or the other into Loop's tool array; never both (the LLM would see
|
|
425
|
+
* the same MCP tool twice). Pick by catalog size and token budget.
|
|
426
|
+
*
|
|
319
427
|
* @param {object} [opts]
|
|
320
428
|
* @param {string} [opts.bridgePath] - Path to .mcp-bridge.json. Default: .mcp-bridge.json in cwd.
|
|
321
429
|
* @param {string[]} [opts.configPaths] - IDE config paths for discovery.
|
|
322
430
|
* @param {string[]} [opts.servers] - Limit to these server names.
|
|
323
431
|
* @param {number} [opts.timeout=15000] - Per-server init timeout in ms.
|
|
324
432
|
* @param {boolean} [opts.refresh=false] - Force re-discovery regardless of TTL.
|
|
325
|
-
* @returns {Promise<{tools: Array, servers: string[], systemContext: string, denied: Array, close: Function}>}
|
|
433
|
+
* @returns {Promise<{tools: Array, metaTools: Array, servers: string[], systemContext: string, denied: Array, close: Function}>}
|
|
326
434
|
*/
|
|
327
435
|
async function createMCPBridge(opts = {}) {
|
|
328
436
|
if ('policy' in opts) {
|
|
@@ -435,8 +543,11 @@ async function createMCPBridge(opts = {}) {
|
|
|
435
543
|
const systemContext = buildSystemContext(connected, tools, denied);
|
|
436
544
|
if (connected.length > 0) console.log(systemContext);
|
|
437
545
|
|
|
546
|
+
const metaTools = buildMetaTools(tools, config?.discovered);
|
|
547
|
+
|
|
438
548
|
return {
|
|
439
549
|
tools,
|
|
550
|
+
metaTools,
|
|
440
551
|
servers: connected,
|
|
441
552
|
denied,
|
|
442
553
|
systemContext,
|
|
@@ -447,4 +558,4 @@ async function createMCPBridge(opts = {}) {
|
|
|
447
558
|
};
|
|
448
559
|
}
|
|
449
560
|
|
|
450
|
-
module.exports = { createMCPBridge, discoverServers };
|
|
561
|
+
module.exports = { createMCPBridge, discoverServers, buildMetaTools };
|
package/src/mcp.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { createMCPBridge, discoverServers } = require('./mcp-bridge');
|
|
3
|
+
const { createMCPBridge, discoverServers, buildMetaTools } = require('./mcp-bridge');
|
|
4
4
|
|
|
5
|
-
module.exports = { createMCPBridge, discoverServers };
|
|
5
|
+
module.exports = { createMCPBridge, discoverServers, buildMetaTools };
|
package/src/retry.js
CHANGED
|
@@ -35,15 +35,23 @@ class Retry {
|
|
|
35
35
|
const timeout = options.timeout || this.timeout;
|
|
36
36
|
|
|
37
37
|
for (let attempt = 1; attempt <= max; attempt++) {
|
|
38
|
+
let timeoutId;
|
|
38
39
|
try {
|
|
39
40
|
const result = await (timeout
|
|
40
|
-
? Promise.race([
|
|
41
|
+
? Promise.race([
|
|
42
|
+
fn(),
|
|
43
|
+
new Promise((_, rej) => {
|
|
44
|
+
timeoutId = setTimeout(() => rej(new TimeoutError('[Retry] Timeout')), timeout);
|
|
45
|
+
}),
|
|
46
|
+
])
|
|
41
47
|
: fn());
|
|
42
48
|
return result;
|
|
43
49
|
} catch (err) {
|
|
44
50
|
if (attempt === max || !retryOn(err)) throw err;
|
|
45
51
|
const delay = this._delay(attempt);
|
|
46
52
|
await new Promise(r => setTimeout(r, delay));
|
|
53
|
+
} finally {
|
|
54
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
47
55
|
}
|
|
48
56
|
}
|
|
49
57
|
}
|
package/src/tools.js
CHANGED
|
@@ -3,5 +3,15 @@
|
|
|
3
3
|
const { createBrowsingTools } = require('../tools/browse');
|
|
4
4
|
const { createMobileTools } = require('../tools/mobile');
|
|
5
5
|
const { createShellTools } = require('../tools/shell');
|
|
6
|
+
const { createSpawnTool, spawnChild } = require('../tools/spawn');
|
|
7
|
+
const { createDeferTool, readQueue: readDeferQueue } = require('../tools/defer');
|
|
6
8
|
|
|
7
|
-
module.exports = {
|
|
9
|
+
module.exports = {
|
|
10
|
+
createBrowsingTools,
|
|
11
|
+
createMobileTools,
|
|
12
|
+
createShellTools,
|
|
13
|
+
createSpawnTool,
|
|
14
|
+
spawnChild,
|
|
15
|
+
createDeferTool,
|
|
16
|
+
readDeferQueue,
|
|
17
|
+
};
|