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/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.7.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. ~1800 lines, 0 required deps.",
11
- "license": "MIT",
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
- "./policy": "./src/policy.js"
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 fs = require('node:fs');
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|false|string. Deny returns the string (or a generic message) to the LLM as tool result.
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 | false | string');
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 audit writes, callback throws, checkpoint timeouts, stream listener
73
- // errors in one place: loop:error stream event + onError callback.
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 < this.maxRounds; 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
- // maxRounds exceeded
320
- const warning = `[Loop] ended after ${this.maxRounds} rounds without final response`;
321
- await this.flush();
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.stdin?.destroy();
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
- await new Promise(resolve => {
227
- const onClose = () => resolve();
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
- }, 700);
238
+ }, ms);
233
239
  });
234
240
 
241
+ await waitClose(150);
242
+
235
243
  if (child.exitCode === null) {
236
244
  child.kill('SIGTERM');
237
- await new Promise(resolve => {
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
- const timer = new Promise((_, reject) =>
266
- setTimeout(() => reject(new ToolError(`MCP server "${name}" init timed out after ${timeout}ms`)), timeout)
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([fn(), new Promise((_, rej) => setTimeout(() => rej(new TimeoutError('[Retry] Timeout')), timeout))])
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 = { createBrowsingTools, createMobileTools, createShellTools };
9
+ module.exports = {
10
+ createBrowsingTools,
11
+ createMobileTools,
12
+ createShellTools,
13
+ createSpawnTool,
14
+ spawnChild,
15
+ createDeferTool,
16
+ readDeferQueue,
17
+ };