bare-agent 0.9.0 → 0.10.1
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/index.js +4 -1
- package/package.json +4 -2
- package/src/bareguard-adapter.js +118 -26
- package/src/errors.js +17 -0
- package/src/loop.js +86 -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/index.js
CHANGED
|
@@ -10,7 +10,7 @@ const { Stream } = require('./src/stream');
|
|
|
10
10
|
const { Retry } = require('./src/retry');
|
|
11
11
|
const { runPlan } = require('./src/run-plan');
|
|
12
12
|
const { CircuitBreaker } = require('./src/circuit-breaker');
|
|
13
|
-
const { wireGate } = require('./src/bareguard-adapter');
|
|
13
|
+
const { wireGate, defaultActionTranslator } = require('./src/bareguard-adapter');
|
|
14
14
|
const {
|
|
15
15
|
BareAgentError,
|
|
16
16
|
ProviderError,
|
|
@@ -18,6 +18,7 @@ const {
|
|
|
18
18
|
TimeoutError,
|
|
19
19
|
ValidationError,
|
|
20
20
|
CircuitOpenError,
|
|
21
|
+
HaltError,
|
|
21
22
|
} = require('./src/errors');
|
|
22
23
|
|
|
23
24
|
module.exports = {
|
|
@@ -32,10 +33,12 @@ module.exports = {
|
|
|
32
33
|
runPlan,
|
|
33
34
|
CircuitBreaker,
|
|
34
35
|
wireGate,
|
|
36
|
+
defaultActionTranslator,
|
|
35
37
|
BareAgentError,
|
|
36
38
|
ProviderError,
|
|
37
39
|
ToolError,
|
|
38
40
|
TimeoutError,
|
|
39
41
|
ValidationError,
|
|
40
42
|
CircuitOpenError,
|
|
43
|
+
HaltError,
|
|
41
44
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bare-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"files": [
|
|
5
5
|
"index.js",
|
|
6
6
|
"src/",
|
|
@@ -22,12 +22,14 @@
|
|
|
22
22
|
},
|
|
23
23
|
"exports": {
|
|
24
24
|
".": "./index.js",
|
|
25
|
+
"./errors": "./src/errors.js",
|
|
25
26
|
"./providers": "./src/providers.js",
|
|
26
27
|
"./stores": "./src/stores.js",
|
|
27
28
|
"./transports": "./src/transports.js",
|
|
28
29
|
"./tools": "./src/tools.js",
|
|
29
30
|
"./mcp": "./src/mcp.js",
|
|
30
|
-
"./bareguard": "./src/bareguard-adapter.js"
|
|
31
|
+
"./bareguard": "./src/bareguard-adapter.js",
|
|
32
|
+
"./package.json": "./package.json"
|
|
31
33
|
},
|
|
32
34
|
"engines": {
|
|
33
35
|
"node": ">=18"
|
package/src/bareguard-adapter.js
CHANGED
|
@@ -1,25 +1,42 @@
|
|
|
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
|
+
* @param {Function} [options.actionTranslator] - (toolName, args, ctx) => action.
|
|
32
|
+
* Builds the action object passed to `gate.check` and `gate.record`. Default:
|
|
33
|
+
* `{ type: toolName, args, _ctx: ctx }`. Override when bareguard's primitives
|
|
34
|
+
* need a specific shape — e.g. `bashCheck` requires `{type:'bash', cmd:...}`,
|
|
35
|
+
* `fsCheck` requires `{type:'read'|'write'|'edit', path:...}`. The default shape
|
|
36
|
+
* matches `tools.denylist` / `tools.allowlist` (which read `action.type`) but
|
|
37
|
+
* does NOT activate `bash`/`fs`/`net` primitives — those need their own
|
|
38
|
+
* `action.type` value. Adopters using those primitives must translate.
|
|
39
|
+
* @returns {{policy: Function, onLlmResult: Function, onToolResult: Function, filterTools: Function, wrapTool: Function, wrapTools: Function}}
|
|
23
40
|
*
|
|
24
41
|
* @example
|
|
25
42
|
* const { Gate } = require('bareguard');
|
|
@@ -30,29 +47,90 @@
|
|
|
30
47
|
* budget: { maxCostUsd: 0.50 },
|
|
31
48
|
* limits: { maxTurns: 20 },
|
|
32
49
|
* audit: { path: './audit.jsonl' },
|
|
33
|
-
* humanChannel: async (ev) => ({ decision: 'deny' }),
|
|
34
50
|
* });
|
|
35
51
|
* await gate.init();
|
|
36
52
|
*
|
|
37
|
-
* const { policy,
|
|
38
|
-
* const loop = new Loop({ provider, policy });
|
|
39
|
-
* await
|
|
53
|
+
* const { policy, onLlmResult, onToolResult, filterTools } = wireGate(gate);
|
|
54
|
+
* const loop = new Loop({ provider, policy, onLlmResult, onToolResult });
|
|
55
|
+
* const tools = await filterTools(myTools);
|
|
56
|
+
* await loop.run(messages, tools);
|
|
40
57
|
*/
|
|
41
|
-
function wireGate(gate) {
|
|
58
|
+
function wireGate(gate, options = {}) {
|
|
42
59
|
if (!gate || typeof gate.check !== 'function' || typeof gate.record !== 'function') {
|
|
43
60
|
throw new Error('[wireGate] expects a bareguard Gate instance (must have .check and .record).');
|
|
44
61
|
}
|
|
62
|
+
if (options.formatDeny != null && typeof options.formatDeny !== 'function') {
|
|
63
|
+
throw new Error('[wireGate] options.formatDeny must be a function (decision) => string');
|
|
64
|
+
}
|
|
65
|
+
if (options.actionTranslator != null && typeof options.actionTranslator !== 'function') {
|
|
66
|
+
throw new Error('[wireGate] options.actionTranslator must be a function (toolName, args, ctx) => action');
|
|
67
|
+
}
|
|
68
|
+
const formatDeny = options.formatDeny || defaultFormatDeny;
|
|
69
|
+
const translate = options.actionTranslator || defaultActionTranslator;
|
|
45
70
|
|
|
46
71
|
const policy = async (toolName, args, ctx) => {
|
|
47
|
-
const decision = await gate.check(
|
|
72
|
+
const decision = await gate.check(translate(toolName, args, ctx));
|
|
48
73
|
if (decision.outcome === 'allow') return true;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
74
|
+
if (decision.severity === 'halt') {
|
|
75
|
+
throw new HaltError(decision.reason || `${toolName} halted by ${decision.rule}`, {
|
|
76
|
+
rule: decision.rule,
|
|
77
|
+
decision,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return formatDeny(decision, toolName);
|
|
53
81
|
};
|
|
54
82
|
|
|
83
|
+
const onLlmResult = async ({ model, provider, usage, costUsd, durationMs, ctx }) => {
|
|
84
|
+
// LLM rounds bypass actionTranslator — they always use the canonical
|
|
85
|
+
// {type:'llm'} action so budget rules can match without translator collusion.
|
|
86
|
+
await gate.record(
|
|
87
|
+
{ type: 'llm', args: { model: model || null, provider: provider || null }, _ctx: ctx ?? null },
|
|
88
|
+
{
|
|
89
|
+
costUsd: typeof costUsd === 'number' ? costUsd : 0,
|
|
90
|
+
tokens: (usage?.inputTokens || 0) + (usage?.outputTokens || 0),
|
|
91
|
+
durationMs: durationMs ?? null,
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const onToolResult = async ({ name, args, result, error, durationMs, ctx }) => {
|
|
97
|
+
const action = translate(name, args, ctx);
|
|
98
|
+
if (error) {
|
|
99
|
+
await gate.record(action, {
|
|
100
|
+
error: error?.message || String(error),
|
|
101
|
+
durationMs: durationMs ?? null,
|
|
102
|
+
});
|
|
103
|
+
} else {
|
|
104
|
+
await gate.record(action, {
|
|
105
|
+
result: typeof result === 'string' ? result : JSON.stringify(result),
|
|
106
|
+
durationMs: durationMs ?? null,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const filterTools = async (tools) => {
|
|
112
|
+
if (!Array.isArray(tools)) {
|
|
113
|
+
throw new Error('[wireGate.filterTools] expects an array of tools');
|
|
114
|
+
}
|
|
115
|
+
if (typeof gate.allows !== 'function') {
|
|
116
|
+
throw new Error('[wireGate.filterTools] gate must have .allows (bareguard >= 0.2)');
|
|
117
|
+
}
|
|
118
|
+
const out = [];
|
|
119
|
+
for (const t of tools) {
|
|
120
|
+
if (await gate.allows(t.name)) out.push(t);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
let warnedWrap = false;
|
|
55
126
|
function wrapTool(tool) {
|
|
127
|
+
if (!warnedWrap) {
|
|
128
|
+
warnedWrap = true;
|
|
129
|
+
console.warn(
|
|
130
|
+
'[wireGate] wrapTool/wrapTools is deprecated — use new Loop({ policy, onLlmResult, onToolResult }) ' +
|
|
131
|
+
'so budget covers LLM cost and ctx reaches gate.record. wrap* will be removed in 1.0.',
|
|
132
|
+
);
|
|
133
|
+
}
|
|
56
134
|
if (!tool || typeof tool.execute !== 'function') {
|
|
57
135
|
throw new Error('[wireGate.wrapTool] tool must have an execute() function');
|
|
58
136
|
}
|
|
@@ -87,7 +165,21 @@ function wireGate(gate) {
|
|
|
87
165
|
return tools.map(wrapTool);
|
|
88
166
|
}
|
|
89
167
|
|
|
90
|
-
return { policy, wrapTool, wrapTools };
|
|
168
|
+
return { policy, onLlmResult, onToolResult, filterTools, wrapTool, wrapTools };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function defaultFormatDeny(decision, toolName) {
|
|
172
|
+
const tag = `[deny: ${decision.rule}]`;
|
|
173
|
+
return decision.reason ? `${tag} ${decision.reason}` : `${tag} ${toolName} denied`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Canonical action shape: tool name as type, args nested, ctx tagged. Matches
|
|
177
|
+
// bareguard's `tools.denylist`/`tools.allowlist` (which read `action.type`) but
|
|
178
|
+
// does NOT activate `bash`/`fs`/`net` primitives — those require `action.type`
|
|
179
|
+
// to be `bash`/`read`/`write`/etc. and read fields like `action.cmd` /
|
|
180
|
+
// `action.path` at the top level. Override via `wireGate(gate, { actionTranslator })`.
|
|
181
|
+
function defaultActionTranslator(toolName, args, ctx) {
|
|
182
|
+
return { type: toolName, args, _ctx: ctx ?? null };
|
|
91
183
|
}
|
|
92
184
|
|
|
93
|
-
module.exports = { wireGate };
|
|
185
|
+
module.exports = { wireGate, defaultActionTranslator };
|
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,11 +43,21 @@ 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 = {}) {
|
|
50
52
|
if (!options.provider) throw new Error('[Loop] requires a provider');
|
|
53
|
+
if (options.maxRounds !== undefined) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
'[Loop] options.maxRounds was removed in v0.8 when single-gate governance landed. ' +
|
|
56
|
+
'Bound iteration via bareguard `new Gate({ limits: { maxTurns: N } })` and wire it with ' +
|
|
57
|
+
'`new Loop({ policy: wireGate(gate).policy })`. Loop\'s internal HARD_ROUND_LIMIT (100) is ' +
|
|
58
|
+
'a safety net only and not configurable.',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
51
61
|
this.provider = options.provider;
|
|
52
62
|
this.system = options.system || null;
|
|
53
63
|
this.checkpoint = options.checkpoint || null;
|
|
@@ -62,6 +72,14 @@ class Loop {
|
|
|
62
72
|
throw new Error('[Loop] options.policy must be a function (toolName, args, ctx) => true | string');
|
|
63
73
|
}
|
|
64
74
|
this.policy = options.policy || null;
|
|
75
|
+
if (options.onLlmResult != null && typeof options.onLlmResult !== 'function') {
|
|
76
|
+
throw new Error('[Loop] options.onLlmResult must be a function');
|
|
77
|
+
}
|
|
78
|
+
if (options.onToolResult != null && typeof options.onToolResult !== 'function') {
|
|
79
|
+
throw new Error('[Loop] options.onToolResult must be a function');
|
|
80
|
+
}
|
|
81
|
+
this.onLlmResult = options.onLlmResult || null;
|
|
82
|
+
this.onToolResult = options.onToolResult || null;
|
|
65
83
|
this._stopped = false;
|
|
66
84
|
this._history = []; // for chat() stateful mode
|
|
67
85
|
}
|
|
@@ -144,17 +162,19 @@ class Loop {
|
|
|
144
162
|
let lastUsage = { inputTokens: 0, outputTokens: 0 };
|
|
145
163
|
let totalCost = 0;
|
|
146
164
|
|
|
165
|
+
try {
|
|
147
166
|
for (let round = 0; round < HARD_ROUND_LIMIT; round++) {
|
|
148
167
|
if (this._stopped) break;
|
|
149
168
|
|
|
150
169
|
let result;
|
|
170
|
+
const llmStartedAt = Date.now();
|
|
151
171
|
try {
|
|
152
172
|
const generate = () => this.provider.generate(msgs, tools, options);
|
|
153
173
|
result = this.retry ? await this.retry.call(generate) : await generate();
|
|
154
174
|
} catch (err) {
|
|
155
175
|
this._reportError('provider', err, { round });
|
|
156
176
|
if (this.throwOnError) throw err;
|
|
157
|
-
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message };
|
|
177
|
+
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, msgs };
|
|
158
178
|
}
|
|
159
179
|
|
|
160
180
|
lastUsage = result.usage || lastUsage;
|
|
@@ -162,12 +182,32 @@ class Loop {
|
|
|
162
182
|
const roundCost = estimateCost(model, lastUsage);
|
|
163
183
|
if (roundCost !== null) totalCost += roundCost;
|
|
164
184
|
|
|
185
|
+
// BA1: forward LLM usage to gate.record (via wireGate) so budget.maxCostUsd
|
|
186
|
+
// covers token-heavy / tool-light workloads. Callback errors route through
|
|
187
|
+
// _reportError but never kill the loop — governance failure ≠ run failure.
|
|
188
|
+
if (this.onLlmResult) {
|
|
189
|
+
try {
|
|
190
|
+
await this.onLlmResult({
|
|
191
|
+
model,
|
|
192
|
+
provider: this.provider.name || null,
|
|
193
|
+
usage: result.usage || null,
|
|
194
|
+
costUsd: roundCost,
|
|
195
|
+
durationMs: Date.now() - llmStartedAt,
|
|
196
|
+
ctx,
|
|
197
|
+
});
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (err instanceof HaltError) throw err;
|
|
200
|
+
this._reportError('onLlmResult', err, { round });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
165
204
|
// No tool calls — LLM gave a final text response
|
|
166
205
|
if (!result.toolCalls || result.toolCalls.length === 0) {
|
|
167
206
|
this._safeEmit({ type: 'loop:text', data: { text: result.text } });
|
|
168
207
|
this._safeCall('onText', this.onText, result.text);
|
|
169
208
|
this._safeEmit({ type: 'loop:done', data: { text: result.text, usage: lastUsage, cost: totalCost } });
|
|
170
|
-
|
|
209
|
+
msgs.push({ role: 'assistant', content: result.text });
|
|
210
|
+
return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, msgs };
|
|
171
211
|
}
|
|
172
212
|
|
|
173
213
|
// Execute tool calls
|
|
@@ -229,6 +269,9 @@ class Loop {
|
|
|
229
269
|
try {
|
|
230
270
|
verdict = await this.policy(tc.name, tc.arguments, ctx);
|
|
231
271
|
} catch (err) {
|
|
272
|
+
// BA2: HaltError bubbles past the per-tool try/catch to the outer
|
|
273
|
+
// handler so halt exits cleanly without ever reaching the LLM.
|
|
274
|
+
if (err instanceof HaltError) throw err;
|
|
232
275
|
verdict = `[Loop] policy error: ${err.message}`;
|
|
233
276
|
}
|
|
234
277
|
if (verdict !== true) {
|
|
@@ -241,26 +284,57 @@ class Loop {
|
|
|
241
284
|
}
|
|
242
285
|
}
|
|
243
286
|
|
|
287
|
+
const toolStartedAt = Date.now();
|
|
288
|
+
let toolResult;
|
|
289
|
+
let toolError;
|
|
244
290
|
try {
|
|
245
291
|
const execute = () => tool.execute(tc.arguments);
|
|
246
|
-
|
|
292
|
+
toolResult = this.retry ? await this.retry.call(execute) : await execute();
|
|
247
293
|
const content = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
|
|
248
294
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content });
|
|
249
295
|
this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, result: content } });
|
|
250
296
|
} catch (err) {
|
|
251
|
-
|
|
252
|
-
const errMsg = `[Loop] Tool error: ${
|
|
297
|
+
toolError = err instanceof ToolError ? err : new ToolError(err.message, { context: { tool: tc.name } });
|
|
298
|
+
const errMsg = `[Loop] Tool error: ${toolError.message}`;
|
|
253
299
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content: errMsg });
|
|
254
300
|
this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, error: errMsg } });
|
|
255
301
|
}
|
|
302
|
+
|
|
303
|
+
// BA1: forward tool result/error to gate.record (via wireGate) with ctx in
|
|
304
|
+
// scope — fixes the lost-_ctx issue that wrapTool can't solve.
|
|
305
|
+
if (this.onToolResult) {
|
|
306
|
+
try {
|
|
307
|
+
await this.onToolResult({
|
|
308
|
+
name: tc.name,
|
|
309
|
+
args: tc.arguments,
|
|
310
|
+
result: toolResult,
|
|
311
|
+
error: toolError || null,
|
|
312
|
+
durationMs: Date.now() - toolStartedAt,
|
|
313
|
+
ctx,
|
|
314
|
+
});
|
|
315
|
+
} catch (err) {
|
|
316
|
+
if (err instanceof HaltError) throw err;
|
|
317
|
+
this._reportError('onToolResult', err, { tool: tc.name });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
256
320
|
}
|
|
257
321
|
}
|
|
322
|
+
} catch (err) {
|
|
323
|
+
// BA2: HaltError is a clean governance exit, not a runtime failure.
|
|
324
|
+
// No throw even when throwOnError:true — the gate halted us deliberately.
|
|
325
|
+
if (err instanceof HaltError) {
|
|
326
|
+
this._reportError('halt', err, { rule: err.rule, reason: err.decision?.reason ?? null });
|
|
327
|
+
this._safeEmit({ type: 'loop:done', data: { text: '', halted: true, rule: err.rule, cost: totalCost } });
|
|
328
|
+
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${err.rule}`, msgs };
|
|
329
|
+
}
|
|
330
|
+
throw err;
|
|
331
|
+
}
|
|
258
332
|
|
|
259
333
|
// Hard safety limit — should never fire under normal usage; bareguard's
|
|
260
334
|
// limits.maxTurns (or the LLM's natural completion) ends the loop first.
|
|
261
335
|
const warning = `[Loop] hit internal safety limit of ${HARD_ROUND_LIMIT} rounds. Wire bareguard for proper governance — see bare-agent/bareguard.`;
|
|
262
336
|
this._safeEmit({ type: 'loop:done', data: { text: '', warning, cost: totalCost } });
|
|
263
|
-
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: warning };
|
|
337
|
+
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, msgs };
|
|
264
338
|
}
|
|
265
339
|
|
|
266
340
|
/**
|
|
@@ -329,9 +403,10 @@ class Loop {
|
|
|
329
403
|
async chat(text, tools = [], options = {}) {
|
|
330
404
|
this._history.push({ role: 'user', content: text });
|
|
331
405
|
const result = await this.run(this._history, tools, options);
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
406
|
+
// Sync _history from the full msgs run() built (tool-call messages, tool results,
|
|
407
|
+
// and final assistant text). Strip the leading system message if one was prepended.
|
|
408
|
+
const effectiveSystem = options.system || this.system;
|
|
409
|
+
this._history = effectiveSystem ? result.msgs.slice(1) : result.msgs.slice();
|
|
335
410
|
return result;
|
|
336
411
|
}
|
|
337
412
|
|
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