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/src/loop.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { ToolError } = require('./errors');
|
|
3
|
+
const { ToolError, HaltError } = require('./errors');
|
|
4
4
|
|
|
5
5
|
// Average pricing per 1K tokens (USD). Adjust these to match your provider's rates.
|
|
6
6
|
// Last updated: 2026-03-18. Source: public provider pricing pages.
|
|
@@ -43,7 +43,9 @@ class Loop {
|
|
|
43
43
|
* @param {object} [options.retry] - Retry instance for backoff on failures.
|
|
44
44
|
* @param {object} [options.stream] - Stream instance for event emission.
|
|
45
45
|
* @param {object} [options.store] - Store instance for validate() health check.
|
|
46
|
-
* @param {Function} [options.policy] - Async (toolName, args, ctx) => true | string. Recommended wiring: closure that delegates to a bareguard Gate (`require('bare-agent/bareguard').wireGate(gate).policy`). Anything other than `true` denies; a string is fed to the LLM verbatim as the deny reason. All policy/budget/audit decisions live in bareguard — Loop just calls the closure and respects the verdict.
|
|
46
|
+
* @param {Function} [options.policy] - Async (toolName, args, ctx) => true | string. Recommended wiring: closure that delegates to a bareguard Gate (`require('bare-agent/bareguard').wireGate(gate).policy`). Anything other than `true` denies; a string is fed to the LLM verbatim as the deny reason. A throw of `HaltError` exits the loop cleanly. All policy/budget/audit decisions live in bareguard — Loop just calls the closure and respects the verdict.
|
|
47
|
+
* @param {Function} [options.onLlmResult] - Async ({model, provider, usage, costUsd, durationMs, ctx}) called after every successful provider.generate. Wire via `wireGate(gate).onLlmResult` so `budget.maxCostUsd` covers token-only workloads. Errors route through `_reportError` but never kill the loop.
|
|
48
|
+
* @param {Function} [options.onToolResult] - Async ({name, args, result, error, durationMs, ctx}) called after every tool.execute (success and failure). Wire via `wireGate(gate).onToolResult` so `gate.record` sees `ctx`. Errors route through `_reportError` but never kill the loop.
|
|
47
49
|
* @throws {Error} `[Loop] requires a provider` — when options.provider is missing.
|
|
48
50
|
*/
|
|
49
51
|
constructor(options = {}) {
|
|
@@ -62,6 +64,14 @@ class Loop {
|
|
|
62
64
|
throw new Error('[Loop] options.policy must be a function (toolName, args, ctx) => true | string');
|
|
63
65
|
}
|
|
64
66
|
this.policy = options.policy || null;
|
|
67
|
+
if (options.onLlmResult != null && typeof options.onLlmResult !== 'function') {
|
|
68
|
+
throw new Error('[Loop] options.onLlmResult must be a function');
|
|
69
|
+
}
|
|
70
|
+
if (options.onToolResult != null && typeof options.onToolResult !== 'function') {
|
|
71
|
+
throw new Error('[Loop] options.onToolResult must be a function');
|
|
72
|
+
}
|
|
73
|
+
this.onLlmResult = options.onLlmResult || null;
|
|
74
|
+
this.onToolResult = options.onToolResult || null;
|
|
65
75
|
this._stopped = false;
|
|
66
76
|
this._history = []; // for chat() stateful mode
|
|
67
77
|
}
|
|
@@ -144,17 +154,19 @@ class Loop {
|
|
|
144
154
|
let lastUsage = { inputTokens: 0, outputTokens: 0 };
|
|
145
155
|
let totalCost = 0;
|
|
146
156
|
|
|
157
|
+
try {
|
|
147
158
|
for (let round = 0; round < HARD_ROUND_LIMIT; round++) {
|
|
148
159
|
if (this._stopped) break;
|
|
149
160
|
|
|
150
161
|
let result;
|
|
162
|
+
const llmStartedAt = Date.now();
|
|
151
163
|
try {
|
|
152
164
|
const generate = () => this.provider.generate(msgs, tools, options);
|
|
153
165
|
result = this.retry ? await this.retry.call(generate) : await generate();
|
|
154
166
|
} catch (err) {
|
|
155
167
|
this._reportError('provider', err, { round });
|
|
156
168
|
if (this.throwOnError) throw err;
|
|
157
|
-
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message };
|
|
169
|
+
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, msgs };
|
|
158
170
|
}
|
|
159
171
|
|
|
160
172
|
lastUsage = result.usage || lastUsage;
|
|
@@ -162,12 +174,32 @@ class Loop {
|
|
|
162
174
|
const roundCost = estimateCost(model, lastUsage);
|
|
163
175
|
if (roundCost !== null) totalCost += roundCost;
|
|
164
176
|
|
|
177
|
+
// BA1: forward LLM usage to gate.record (via wireGate) so budget.maxCostUsd
|
|
178
|
+
// covers token-heavy / tool-light workloads. Callback errors route through
|
|
179
|
+
// _reportError but never kill the loop — governance failure ≠ run failure.
|
|
180
|
+
if (this.onLlmResult) {
|
|
181
|
+
try {
|
|
182
|
+
await this.onLlmResult({
|
|
183
|
+
model,
|
|
184
|
+
provider: this.provider.name || null,
|
|
185
|
+
usage: result.usage || null,
|
|
186
|
+
costUsd: roundCost,
|
|
187
|
+
durationMs: Date.now() - llmStartedAt,
|
|
188
|
+
ctx,
|
|
189
|
+
});
|
|
190
|
+
} catch (err) {
|
|
191
|
+
if (err instanceof HaltError) throw err;
|
|
192
|
+
this._reportError('onLlmResult', err, { round });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
165
196
|
// No tool calls — LLM gave a final text response
|
|
166
197
|
if (!result.toolCalls || result.toolCalls.length === 0) {
|
|
167
198
|
this._safeEmit({ type: 'loop:text', data: { text: result.text } });
|
|
168
199
|
this._safeCall('onText', this.onText, result.text);
|
|
169
200
|
this._safeEmit({ type: 'loop:done', data: { text: result.text, usage: lastUsage, cost: totalCost } });
|
|
170
|
-
|
|
201
|
+
msgs.push({ role: 'assistant', content: result.text });
|
|
202
|
+
return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, msgs };
|
|
171
203
|
}
|
|
172
204
|
|
|
173
205
|
// Execute tool calls
|
|
@@ -229,6 +261,9 @@ class Loop {
|
|
|
229
261
|
try {
|
|
230
262
|
verdict = await this.policy(tc.name, tc.arguments, ctx);
|
|
231
263
|
} catch (err) {
|
|
264
|
+
// BA2: HaltError bubbles past the per-tool try/catch to the outer
|
|
265
|
+
// handler so halt exits cleanly without ever reaching the LLM.
|
|
266
|
+
if (err instanceof HaltError) throw err;
|
|
232
267
|
verdict = `[Loop] policy error: ${err.message}`;
|
|
233
268
|
}
|
|
234
269
|
if (verdict !== true) {
|
|
@@ -241,26 +276,57 @@ class Loop {
|
|
|
241
276
|
}
|
|
242
277
|
}
|
|
243
278
|
|
|
279
|
+
const toolStartedAt = Date.now();
|
|
280
|
+
let toolResult;
|
|
281
|
+
let toolError;
|
|
244
282
|
try {
|
|
245
283
|
const execute = () => tool.execute(tc.arguments);
|
|
246
|
-
|
|
284
|
+
toolResult = this.retry ? await this.retry.call(execute) : await execute();
|
|
247
285
|
const content = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
|
|
248
286
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content });
|
|
249
287
|
this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, result: content } });
|
|
250
288
|
} catch (err) {
|
|
251
|
-
|
|
252
|
-
const errMsg = `[Loop] Tool error: ${
|
|
289
|
+
toolError = err instanceof ToolError ? err : new ToolError(err.message, { context: { tool: tc.name } });
|
|
290
|
+
const errMsg = `[Loop] Tool error: ${toolError.message}`;
|
|
253
291
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content: errMsg });
|
|
254
292
|
this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, error: errMsg } });
|
|
255
293
|
}
|
|
294
|
+
|
|
295
|
+
// BA1: forward tool result/error to gate.record (via wireGate) with ctx in
|
|
296
|
+
// scope — fixes the lost-_ctx issue that wrapTool can't solve.
|
|
297
|
+
if (this.onToolResult) {
|
|
298
|
+
try {
|
|
299
|
+
await this.onToolResult({
|
|
300
|
+
name: tc.name,
|
|
301
|
+
args: tc.arguments,
|
|
302
|
+
result: toolResult,
|
|
303
|
+
error: toolError || null,
|
|
304
|
+
durationMs: Date.now() - toolStartedAt,
|
|
305
|
+
ctx,
|
|
306
|
+
});
|
|
307
|
+
} catch (err) {
|
|
308
|
+
if (err instanceof HaltError) throw err;
|
|
309
|
+
this._reportError('onToolResult', err, { tool: tc.name });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
256
312
|
}
|
|
257
313
|
}
|
|
314
|
+
} catch (err) {
|
|
315
|
+
// BA2: HaltError is a clean governance exit, not a runtime failure.
|
|
316
|
+
// No throw even when throwOnError:true — the gate halted us deliberately.
|
|
317
|
+
if (err instanceof HaltError) {
|
|
318
|
+
this._reportError('halt', err, { rule: err.rule, reason: err.decision?.reason ?? null });
|
|
319
|
+
this._safeEmit({ type: 'loop:done', data: { text: '', halted: true, rule: err.rule, cost: totalCost } });
|
|
320
|
+
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${err.rule}`, msgs };
|
|
321
|
+
}
|
|
322
|
+
throw err;
|
|
323
|
+
}
|
|
258
324
|
|
|
259
325
|
// Hard safety limit — should never fire under normal usage; bareguard's
|
|
260
326
|
// limits.maxTurns (or the LLM's natural completion) ends the loop first.
|
|
261
327
|
const warning = `[Loop] hit internal safety limit of ${HARD_ROUND_LIMIT} rounds. Wire bareguard for proper governance — see bare-agent/bareguard.`;
|
|
262
328
|
this._safeEmit({ type: 'loop:done', data: { text: '', warning, cost: totalCost } });
|
|
263
|
-
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: warning };
|
|
329
|
+
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, msgs };
|
|
264
330
|
}
|
|
265
331
|
|
|
266
332
|
/**
|
|
@@ -329,9 +395,10 @@ class Loop {
|
|
|
329
395
|
async chat(text, tools = [], options = {}) {
|
|
330
396
|
this._history.push({ role: 'user', content: text });
|
|
331
397
|
const result = await this.run(this._history, tools, options);
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
398
|
+
// Sync _history from the full msgs run() built (tool-call messages, tool results,
|
|
399
|
+
// and final assistant text). Strip the leading system message if one was prepended.
|
|
400
|
+
const effectiveSystem = options.system || this.system;
|
|
401
|
+
this._history = effectiveSystem ? result.msgs.slice(1) : result.msgs.slice();
|
|
335
402
|
return result;
|
|
336
403
|
}
|
|
337
404
|
|
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');
|
|
@@ -282,9 +284,10 @@ function buildSystemContext(servers, tools, denied) {
|
|
|
282
284
|
|
|
283
285
|
const byServer = {};
|
|
284
286
|
for (const t of tools) {
|
|
285
|
-
const
|
|
286
|
-
const server =
|
|
287
|
-
|
|
287
|
+
const sep = t.name.indexOf('_');
|
|
288
|
+
const server = sep > 0 ? t.name.slice(0, sep) : t.name;
|
|
289
|
+
const tool = sep > 0 ? t.name.slice(sep + 1) : '';
|
|
290
|
+
(byServer[server] = byServer[server] || []).push(tool);
|
|
288
291
|
}
|
|
289
292
|
for (const [server, toolNames] of Object.entries(byServer)) {
|
|
290
293
|
lines.push(` ${server}: ${toolNames.join(', ')}`);
|
|
@@ -308,6 +311,103 @@ function buildSystemContext(servers, tools, denied) {
|
|
|
308
311
|
return lines.join('\n');
|
|
309
312
|
}
|
|
310
313
|
|
|
314
|
+
// --- Meta-tools: mcp_discover + mcp_invoke (v0.9) ---
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Build the LLM-callable meta-tool surface from a fully-connected bridge.
|
|
318
|
+
* Shares the underlying tool array and RPC clients with the bulk surface —
|
|
319
|
+
* one set of connections, one factory, two output forms. The user picks
|
|
320
|
+
* `bridge.tools` (bulk) for small catalogs the LLM should see upfront, or
|
|
321
|
+
* `bridge.metaTools` for large catalogs the LLM should discover on demand.
|
|
322
|
+
*
|
|
323
|
+
* Gov shape: when the LLM calls mcp_invoke, the action sent to gate.check
|
|
324
|
+
* is `{ type: 'mcp_invoke', args: { name, args }, _ctx }` — bareguard sees
|
|
325
|
+
* `mcp_invoke` as the type. To deny specific MCP tools, use bareguard's
|
|
326
|
+
* `tools.denyArgPatterns: { mcp_invoke: [/"name":"linear_admin_.*"/] }`
|
|
327
|
+
* or `content.denyPatterns` over the JSON-serialized form. The inner MCP
|
|
328
|
+
* tool name doesn't travel as `action.type` — that's a deliberate v0.9
|
|
329
|
+
* trade for one consistent gate-check call per LLM tool invocation.
|
|
330
|
+
*
|
|
331
|
+
* @param {Array} tools - The bulk-loaded, name-prefixed tools array.
|
|
332
|
+
* @param {string} discoveredAt - ISO timestamp from .mcp-bridge.json.
|
|
333
|
+
* @returns {Array} [mcp_discover, mcp_invoke]
|
|
334
|
+
*/
|
|
335
|
+
function buildMetaTools(tools, discoveredAt) {
|
|
336
|
+
// Catalog descriptors: same info the LLM would see for bulk-loaded tools,
|
|
337
|
+
// but exposed via mcp_discover instead of taking up tool-array slots upfront.
|
|
338
|
+
const catalog = tools.map(t => {
|
|
339
|
+
const sep = t.name.indexOf('_');
|
|
340
|
+
return {
|
|
341
|
+
name: t.name,
|
|
342
|
+
description: t.description || '',
|
|
343
|
+
schema: t.parameters || { type: 'object', properties: {} },
|
|
344
|
+
server: sep > 0 ? t.name.slice(0, sep) : t.name,
|
|
345
|
+
tool: sep > 0 ? t.name.slice(sep + 1) : '',
|
|
346
|
+
};
|
|
347
|
+
});
|
|
348
|
+
const byName = new Map(tools.map(t => [t.name, t]));
|
|
349
|
+
|
|
350
|
+
const mcpDiscover = {
|
|
351
|
+
name: 'mcp_discover',
|
|
352
|
+
description:
|
|
353
|
+
'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.',
|
|
354
|
+
parameters: {
|
|
355
|
+
type: 'object',
|
|
356
|
+
properties: {
|
|
357
|
+
refresh: {
|
|
358
|
+
type: 'boolean',
|
|
359
|
+
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.',
|
|
360
|
+
},
|
|
361
|
+
server: {
|
|
362
|
+
type: 'string',
|
|
363
|
+
description: 'Optional: filter the catalog to one server name.',
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
execute: async ({ server } = {}) => {
|
|
368
|
+
const filtered = server
|
|
369
|
+
? catalog.filter(t => t.server === server)
|
|
370
|
+
: catalog;
|
|
371
|
+
return {
|
|
372
|
+
tools: filtered,
|
|
373
|
+
cachedAt: discoveredAt || new Date().toISOString(),
|
|
374
|
+
count: filtered.length,
|
|
375
|
+
};
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const mcpInvoke = {
|
|
380
|
+
name: 'mcp_invoke',
|
|
381
|
+
description:
|
|
382
|
+
'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.',
|
|
383
|
+
parameters: {
|
|
384
|
+
type: 'object',
|
|
385
|
+
properties: {
|
|
386
|
+
name: {
|
|
387
|
+
type: 'string',
|
|
388
|
+
description: 'Canonical MCP tool name (from mcp_discover). Format: <server>_<tool>.',
|
|
389
|
+
},
|
|
390
|
+
args: {
|
|
391
|
+
type: 'object',
|
|
392
|
+
description: 'Arguments for the MCP tool, matching its schema (also from mcp_discover).',
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
required: ['name'],
|
|
396
|
+
},
|
|
397
|
+
execute: async ({ name, args }) => {
|
|
398
|
+
const tool = byName.get(name);
|
|
399
|
+
if (!tool) {
|
|
400
|
+
throw new ToolError(`mcp_invoke: unknown tool "${name}". Call mcp_discover for the current catalog.`, {
|
|
401
|
+
context: { name, knownNames: [...byName.keys()] },
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
return await tool.execute(args || {});
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
return [mcpDiscover, mcpInvoke];
|
|
409
|
+
}
|
|
410
|
+
|
|
311
411
|
// --- Main entry point ---
|
|
312
412
|
|
|
313
413
|
/**
|
|
@@ -316,13 +416,22 @@ function buildSystemContext(servers, tools, denied) {
|
|
|
316
416
|
* On subsequent runs, reads .mcp-bridge.json and respects allow/deny per tool.
|
|
317
417
|
* Re-discovers when TTL expires (default: 24h).
|
|
318
418
|
*
|
|
419
|
+
* Returns BOTH surfaces (v0.9+):
|
|
420
|
+
* - `tools` — bulk-loaded array of name-prefixed tools (small catalogs;
|
|
421
|
+
* LLM sees them upfront).
|
|
422
|
+
* - `metaTools` — [mcp_discover, mcp_invoke] LLM-callable pair (large catalogs;
|
|
423
|
+
* LLM picks tools dynamically). Shares the same RPC connections.
|
|
424
|
+
*
|
|
425
|
+
* Wire one or the other into Loop's tool array; never both (the LLM would see
|
|
426
|
+
* the same MCP tool twice). Pick by catalog size and token budget.
|
|
427
|
+
*
|
|
319
428
|
* @param {object} [opts]
|
|
320
429
|
* @param {string} [opts.bridgePath] - Path to .mcp-bridge.json. Default: .mcp-bridge.json in cwd.
|
|
321
430
|
* @param {string[]} [opts.configPaths] - IDE config paths for discovery.
|
|
322
431
|
* @param {string[]} [opts.servers] - Limit to these server names.
|
|
323
432
|
* @param {number} [opts.timeout=15000] - Per-server init timeout in ms.
|
|
324
433
|
* @param {boolean} [opts.refresh=false] - Force re-discovery regardless of TTL.
|
|
325
|
-
* @returns {Promise<{tools: Array, servers: string[], systemContext: string, denied: Array, close: Function}>}
|
|
434
|
+
* @returns {Promise<{tools: Array, metaTools: Array, servers: string[], systemContext: string, denied: Array, close: Function}>}
|
|
326
435
|
*/
|
|
327
436
|
async function createMCPBridge(opts = {}) {
|
|
328
437
|
if ('policy' in opts) {
|
|
@@ -341,43 +450,52 @@ async function createMCPBridge(opts = {}) {
|
|
|
341
450
|
if (needsRefresh) {
|
|
342
451
|
// Discover from IDE configs
|
|
343
452
|
const discovered = discoverServers(opts.configPaths);
|
|
453
|
+
|
|
344
454
|
if (discovered.size === 0 && !config) {
|
|
345
455
|
return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
|
|
346
456
|
}
|
|
347
457
|
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
458
|
+
// Only attempt connection when discovery found something.
|
|
459
|
+
// If discovered.size === 0 but config exists, fall through and use the existing config
|
|
460
|
+
// rather than wiping it on a transient discovery failure.
|
|
461
|
+
if (discovered.size > 0) {
|
|
462
|
+
const freshTools = new Map();
|
|
463
|
+
const connectResults = new Map();
|
|
464
|
+
const errors = [];
|
|
465
|
+
|
|
466
|
+
const toDiscover = opts.servers
|
|
467
|
+
? [...discovered.entries()].filter(([n]) => opts.servers.includes(n))
|
|
468
|
+
: [...discovered.entries()];
|
|
469
|
+
|
|
470
|
+
await Promise.all(toDiscover.map(async ([name, def]) => {
|
|
471
|
+
try {
|
|
472
|
+
const result = await connectAndListTools(name, def, timeout);
|
|
473
|
+
freshTools.set(name, result.mcpTools);
|
|
474
|
+
connectResults.set(name, result.client);
|
|
475
|
+
} catch (err) {
|
|
476
|
+
errors.push({ server: name, error: err.message });
|
|
477
|
+
}
|
|
478
|
+
}));
|
|
370
479
|
|
|
371
|
-
|
|
372
|
-
|
|
480
|
+
if (errors.length > 0) {
|
|
481
|
+
console.warn('[MCP Bridge] Some servers failed to connect:', errors);
|
|
482
|
+
}
|
|
373
483
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
484
|
+
// Only write config when at least one server connected successfully.
|
|
485
|
+
// If all servers failed, retain the existing config unchanged so
|
|
486
|
+
// user-curated allow/deny settings are not destroyed on transient failures.
|
|
487
|
+
if (freshTools.size > 0) {
|
|
488
|
+
config = mergeBridgeConfig(config, new Map(toDiscover), freshTools);
|
|
489
|
+
writeBridgeConfig(bridgePath, config);
|
|
490
|
+
console.log(`[MCP Bridge] Wrote ${bridgePath}`);
|
|
491
|
+
} else if (!config) {
|
|
492
|
+
return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
|
|
493
|
+
}
|
|
377
494
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
495
|
+
// Close the discovery connections — we'll reconnect below using the config
|
|
496
|
+
for (const client of connectResults.values()) {
|
|
497
|
+
await killServer(client.child);
|
|
498
|
+
}
|
|
381
499
|
}
|
|
382
500
|
}
|
|
383
501
|
|
|
@@ -435,8 +553,11 @@ async function createMCPBridge(opts = {}) {
|
|
|
435
553
|
const systemContext = buildSystemContext(connected, tools, denied);
|
|
436
554
|
if (connected.length > 0) console.log(systemContext);
|
|
437
555
|
|
|
556
|
+
const metaTools = buildMetaTools(tools, config?.discovered);
|
|
557
|
+
|
|
438
558
|
return {
|
|
439
559
|
tools,
|
|
560
|
+
metaTools,
|
|
440
561
|
servers: connected,
|
|
441
562
|
denied,
|
|
442
563
|
systemContext,
|
|
@@ -447,4 +568,4 @@ async function createMCPBridge(opts = {}) {
|
|
|
447
568
|
};
|
|
448
569
|
}
|
|
449
570
|
|
|
450
|
-
module.exports = { createMCPBridge, discoverServers };
|
|
571
|
+
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/planner.js
CHANGED
|
@@ -40,7 +40,7 @@ class Planner {
|
|
|
40
40
|
*/
|
|
41
41
|
async plan(goal, context = {}) {
|
|
42
42
|
if (this._cacheTTL > 0) {
|
|
43
|
-
const cacheKey = goal
|
|
43
|
+
const cacheKey = JSON.stringify({ goal, info: context.info || '' });
|
|
44
44
|
const cached = this._cache.get(cacheKey);
|
|
45
45
|
if (cached && Date.now() < cached.expiresAt) {
|
|
46
46
|
return cached.result;
|
|
@@ -63,7 +63,7 @@ class Planner {
|
|
|
63
63
|
const steps = this._parse(result.text);
|
|
64
64
|
|
|
65
65
|
if (this._cacheTTL > 0) {
|
|
66
|
-
const cacheKey = goal
|
|
66
|
+
const cacheKey = JSON.stringify({ goal, info: context.info || '' });
|
|
67
67
|
this._cache.set(cacheKey, { result: steps, expiresAt: Date.now() + this._cacheTTL });
|
|
68
68
|
}
|
|
69
69
|
|
package/src/provider-clipipe.js
CHANGED
|
@@ -74,7 +74,7 @@ class CLIPipeProvider {
|
|
|
74
74
|
/**
|
|
75
75
|
* Spawn the CLI process, pipe prompt to stdin, collect stdout.
|
|
76
76
|
* @param {string} prompt
|
|
77
|
-
* @param {string[]} [extraArgs=[]] - Additional args
|
|
77
|
+
* @param {string[]} [extraArgs=[]] - Additional args appended after this.args.
|
|
78
78
|
* @returns {Promise<string>}
|
|
79
79
|
*/
|
|
80
80
|
_spawn(prompt, extraArgs = []) {
|
package/src/retry.js
CHANGED
|
@@ -14,9 +14,9 @@ const DEFAULT_RETRY_ON = (err) => {
|
|
|
14
14
|
|
|
15
15
|
class Retry {
|
|
16
16
|
constructor(options = {}) {
|
|
17
|
-
this.maxAttempts = options.maxAttempts
|
|
17
|
+
this.maxAttempts = options.maxAttempts !== undefined ? options.maxAttempts : 3;
|
|
18
18
|
this.backoff = options.backoff || 'exponential';
|
|
19
|
-
this.timeout = options.timeout
|
|
19
|
+
this.timeout = options.timeout !== undefined ? options.timeout : 60000;
|
|
20
20
|
this.retryOn = options.retryOn || DEFAULT_RETRY_ON;
|
|
21
21
|
this.jitter = options.jitter !== undefined ? options.jitter : false;
|
|
22
22
|
}
|
|
@@ -30,20 +30,28 @@ class Retry {
|
|
|
30
30
|
* @throws {Error} Rethrows the last error when maxAttempts is exhausted or error is not retryable.
|
|
31
31
|
*/
|
|
32
32
|
async call(fn, options = {}) {
|
|
33
|
-
const max = options.maxAttempts
|
|
33
|
+
const max = options.maxAttempts !== undefined ? options.maxAttempts : this.maxAttempts;
|
|
34
34
|
const retryOn = options.retryOn || this.retryOn;
|
|
35
|
-
const timeout = options.timeout
|
|
35
|
+
const timeout = options.timeout !== undefined ? 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/scheduler.js
CHANGED
|
@@ -22,7 +22,7 @@ class Scheduler {
|
|
|
22
22
|
: [];
|
|
23
23
|
this._timer = null;
|
|
24
24
|
this._nextId = this._jobs.length
|
|
25
|
-
?
|
|
25
|
+
? this._jobs.reduce((max, j) => Math.max(max, j.id), 0) + 1
|
|
26
26
|
: 1;
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -80,15 +80,16 @@ class Scheduler {
|
|
|
80
80
|
try {
|
|
81
81
|
await handler(job);
|
|
82
82
|
} catch (err) {
|
|
83
|
-
this.onError?.(err, job);
|
|
83
|
+
try { this.onError?.(err, job); } catch { /* swallow onError throws */ }
|
|
84
|
+
} finally {
|
|
85
|
+
this._running.delete(job.id);
|
|
86
|
+
if (job.type === 'once') {
|
|
87
|
+
job.status = 'done';
|
|
88
|
+
} else {
|
|
89
|
+
job.nextRun = this._parseSchedule(job.schedule).toISOString();
|
|
90
|
+
}
|
|
91
|
+
this._save();
|
|
84
92
|
}
|
|
85
|
-
this._running.delete(job.id);
|
|
86
|
-
if (job.type === 'once') {
|
|
87
|
-
job.status = 'done';
|
|
88
|
-
} else {
|
|
89
|
-
job.nextRun = this._parseSchedule(job.schedule).toISOString();
|
|
90
|
-
}
|
|
91
|
-
this._save();
|
|
92
93
|
}
|
|
93
94
|
};
|
|
94
95
|
tick();
|
package/src/store-jsonfile.js
CHANGED
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
|
+
};
|