bare-agent 0.9.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 +74 -2
- package/package.json +1 -1
- package/src/bareguard-adapter.js +93 -24
- package/src/errors.js +17 -0
- package/src/loop.js +78 -11
- package/src/mcp-bridge.js +43 -33
- package/src/planner.js +2 -2
- package/src/provider-clipipe.js +1 -1
- package/src/retry.js +4 -4
- package/src/scheduler.js +10 -9
- package/src/store-jsonfile.js +1 -1
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,7 +77,7 @@ 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 |
|
|
@@ -90,6 +95,73 @@ Every piece works alone — take what you need, ignore the rest.
|
|
|
90
95
|
|
|
91
96
|
---
|
|
92
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).
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
93
165
|
## Cross-language usage
|
|
94
166
|
|
|
95
167
|
Not using Node.js? Spawn bare-agent as a subprocess from any language. Ready-made wrappers in [`contrib/`](contrib/README.md) for Python, Go, Rust, Ruby, and Java — copy one file, no package registry needed.
|
package/package.json
CHANGED
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
|
};
|
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
|
@@ -284,9 +284,10 @@ function buildSystemContext(servers, tools, denied) {
|
|
|
284
284
|
|
|
285
285
|
const byServer = {};
|
|
286
286
|
for (const t of tools) {
|
|
287
|
-
const
|
|
288
|
-
const server =
|
|
289
|
-
|
|
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);
|
|
290
291
|
}
|
|
291
292
|
for (const [server, toolNames] of Object.entries(byServer)) {
|
|
292
293
|
lines.push(` ${server}: ${toolNames.join(', ')}`);
|
|
@@ -449,43 +450,52 @@ async function createMCPBridge(opts = {}) {
|
|
|
449
450
|
if (needsRefresh) {
|
|
450
451
|
// Discover from IDE configs
|
|
451
452
|
const discovered = discoverServers(opts.configPaths);
|
|
453
|
+
|
|
452
454
|
if (discovered.size === 0 && !config) {
|
|
453
455
|
return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
|
|
454
456
|
}
|
|
455
457
|
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
}
|
|
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
|
+
}));
|
|
478
479
|
|
|
479
|
-
|
|
480
|
-
|
|
480
|
+
if (errors.length > 0) {
|
|
481
|
+
console.warn('[MCP Bridge] Some servers failed to connect:', errors);
|
|
482
|
+
}
|
|
481
483
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
+
}
|
|
485
494
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
+
}
|
|
489
499
|
}
|
|
490
500
|
}
|
|
491
501
|
|
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,9 +30,9 @@ 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
38
|
let timeoutId;
|
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