bare-agent 0.51.0 → 0.53.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 +1 -1
- package/bareagent.context.md +14 -5
- package/package.json +1 -1
- package/primitives.json +5 -5
- package/src/bareguard-adapter.d.ts +4 -0
- package/src/bareguard-adapter.js +38 -0
- package/src/loop.d.ts +4 -2
- package/src/loop.js +30 -20
- package/src/mcp-bridge.d.ts +29 -6
- package/src/mcp-bridge.js +192 -28
- package/src/provider-anthropic.d.ts +6 -7
- package/src/provider-anthropic.js +16 -11
- package/src/provider-clipipe.d.ts +11 -2
- package/src/provider-clipipe.js +22 -10
- package/src/provider-gemini.d.ts +6 -7
- package/src/provider-gemini.js +16 -11
- package/src/provider-ollama.d.ts +6 -7
- package/src/provider-ollama.js +16 -11
- package/src/provider-openai.d.ts +8 -9
- package/src/provider-openai.js +24 -17
- package/src/provider-temperature.d.ts +2 -2
- package/src/provider-temperature.js +5 -3
- package/src/thinking-mode.d.ts +75 -26
- package/src/thinking-mode.js +130 -71
- package/types/index.d.ts +16 -6
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ Some things an LLM can't judge about its own work. Building a harness or an exte
|
|
|
33
33
|
|
|
34
34
|
## Fast gut check — Jev
|
|
35
35
|
|
|
36
|
-
An LLM call is slow, deliberate thinking (System 2). Jev, from TypeSafe, is the fast gut check (System 1): a classifier that returns a typed decision with a calibrated probability —
|
|
36
|
+
An LLM call is slow, deliberate thinking (System 2). Jev, from TypeSafe, is the fast gut check (System 1): a classifier that returns a typed decision with a calibrated probability — far faster and cheaper than an LLM call. bare-agent's `JevProvider` covers all three shapes — **yes/no**, **pick-one**, **score** — so your automation calls it like any other step: act when it's confident, escalate when it isn't. Injection-hardened by default; also usable as the `jev` check in the Evaluator.
|
|
37
37
|
|
|
38
38
|
## Break big tasks down — RLM
|
|
39
39
|
|
package/bareagent.context.md
CHANGED
|
@@ -72,7 +72,7 @@ Eight entry points:
|
|
|
72
72
|
| Read/write files, list directories, run shell commands, grep | createShellTools (shell_read/grep/**write**/**edit**/run/exec) + Loop({ policy }) — gate `shell_write`/`shell_edit` via `fs.writeScope` with an actionTranslator |
|
|
73
73
|
| Change one span of a big file without re-emitting the whole thing | `shell_edit({path, oldText, newText})` — anchored exact-once replace; the surgical alternative to whole-file `shell_write` (BA-13). Gate it as `{type:'edit'}` |
|
|
74
74
|
| Auto-discover MCP servers from IDE configs | createMCPBridge |
|
|
75
|
-
| Gate MCP tools with allow/deny lists | createMCPBridge + `.mcp-bridge.json` |
|
|
75
|
+
| Gate MCP tools with allow/deny lists | createMCPBridge + `.mcp-bridge.json` (or per-run `{ allow }`) |
|
|
76
76
|
| Gate every tool call with one policy hook | `wireGate(gate).policy` → `Loop({ policy })` |
|
|
77
77
|
| Route policy decisions per user / tenant / chat | `wireGate(gate).policy` + `loop.run(msgs, tools, { ctx })` (ctx routes to bareguard's check via `_ctx`) |
|
|
78
78
|
| Cap total USD spend per run | `new Gate({ budget: { maxCostUsd: 0.50 } })` |
|
|
@@ -431,7 +431,10 @@ every command it is about to spawn — `confirmServer` is how you actually *gate
|
|
|
431
431
|
server that never answers can't hang the bridge or the loop. `opts.timeout`
|
|
432
432
|
(default 15 s) bounds the handshake (`initialize` + `tools/list`);
|
|
433
433
|
`opts.callTimeout` (default 120 s, `0` disables) bounds each `tools/call`. A
|
|
434
|
-
timed-out tool call rejects with a
|
|
434
|
+
timed-out tool call rejects with a labelled `ToolError`
|
|
435
|
+
(`MCP server "s" tool "t" timed out after Nms`, `context: { timeout: true, server, tool, timeoutMs, stderr }` —
|
|
436
|
+
stderr is in `context`, not the message; `wireGate`'s `onToolResult` forwards only the
|
|
437
|
+
non-stderr label fields to the audit row as an allow-listed `errorContext`) rather
|
|
435
438
|
than blocking forever; a server that breaks its stdin pipe surfaces as a
|
|
436
439
|
failed connection, never an uncaught `EPIPE` crash.
|
|
437
440
|
|
|
@@ -477,6 +480,8 @@ if (result.error?.startsWith('halt:')) {
|
|
|
477
480
|
|
|
478
481
|
**Why four pieces (`policy` + `onLlmResult` + `onToolResult` + `filterTools`).** `policy` runs `gate.check` *before* every tool call. `onLlmResult` fires after every successful `provider.generate` — without it, `budget.maxCostUsd` never sees LLM cost and is silently undercounted for token-heavy / tool-light workloads (every chatbot). It also fires for the out-of-band `ctx.summarize` call (R-C6) tagged `kind:'summarize'`; main-loop rounds carry `kind:'turn'` — so summary-window tokens count against the budget too, and a consumer can tell the two apart. `onToolResult` fires after every `tool.execute` and carries the per-run `ctx` opaque blob into `gate.record` so per-principal accounting works. `filterTools` is a `gate.allows` pre-filter — denied tools are dropped from the catalog the LLM ever sees, no `gate.check` round-trip per call.
|
|
479
482
|
|
|
483
|
+
`onToolResult` on a tool ERROR records `{error, durationMs}` plus an allow-listed `errorContext` copied from `error.context` — `timeout`/`denied` (boolean), `server`/`tool` (string, ≤200 chars), `timeoutMs` (finite number); wrong-typed values are dropped and `stderr`/everything else is never forwarded (the gate writes the record verbatim into the audit row). The key is absent when no allowed field is present.
|
|
484
|
+
|
|
480
485
|
Halt-severity decisions exit the loop cleanly via a typed `HaltError` — full mechanics (sealed `msgs`, `halt:<rule>` error token, `loop:done{halted:true}` event, `throwOnError:true` interaction, `halt:unknown` coalesce) are in the **Halt decisions throw `HaltError`** paragraph below. Short version: check `result.error?.startsWith('halt:')` after the run.
|
|
481
486
|
|
|
482
487
|
**A bound that fires PRESERVES the model's work (BA-5, v0.27+).** Every terminating path — governance halt, the deny-spin short-circuit, a provider error under `throwOnError:false`, `loop.stop()`, and the internal hard round limit — returns the **last non-empty assistant text** in `result.text` instead of `''`. If you drive bare-agent from an outer retry loop (`while not-done and under-cap: run the worker`), a bound firing is **normal termination**, not an exception, and that text is your only channel from attempt N to attempt N+1 — before this, a bounded attempt taught its successor nothing. Two rules: **`error` is the sole success signal** (a non-empty `text` never means the run converged — always branch on `error`), and `text` is `''` when the model genuinely produced none (no placeholder is invented, so you can trust emptiness). Under `recurse` this is what finally populates `best` on an incomplete node. Relatedly, **`loop.stop()` now returns `error: null`** — a deliberate stop is not a fault; it previously fell through to the hard-round-limit return and reported that safety warning as its error, indistinguishable from a runaway.
|
|
@@ -513,11 +518,11 @@ new OpenAIProvider({ baseUrl: 'https://api.deepseek.com', model: 'deepseek-flash
|
|
|
513
518
|
|
|
514
519
|
> **Fallback caveat:** per-call options (incl. `thinking`) are forwarded to EVERY provider in a `FallbackProvider` chain. For a mixed-vendor chain set `thinking` on each provider's constructor, not per-call.
|
|
515
520
|
|
|
516
|
-
**Normalized `thinkingMode: 'on' | 'off'`.** Say "on"/"off"; bare-agent sends the right field for the provider/model (`AnthropicProvider`, `OpenAIProvider` incl. DeepSeek, `GeminiProvider`, `OllamaProvider`): constructor option, overridable per call (`generate(..., { thinkingMode })` / `loop.run(msgs, tools, { thinkingMode })`), `null` suppresses. It is a NEW field — the raw `thinking` option above is untouched, and setting both is a `ValidationError`. It tries the known request shapes (a 400
|
|
521
|
+
**Normalized `thinkingMode: 'on' | 'off'`.** Say "on"/"off"; bare-agent sends the right field for the provider/model (`AnthropicProvider`, `OpenAIProvider` incl. DeepSeek, `GeminiProvider`, `OllamaProvider`): constructor option, overridable per call (`generate(..., { thinkingMode })` / `loop.run(msgs, tools, { thinkingMode })`), `null` suppresses. It is a NEW field — the raw `thinking` option above is untouched, and setting both is a `ValidationError`. It tries the known request shapes (a client validation error — 400, or 422 — walks the ladder; the winning shape is cached) and **always runs the call** — callers need not care about providers. After the shapes, ONE plain request (no thinking field) decides: it succeeds ⇒ the setting was the problem and the result says `unsupported` (warns once); it fails too ⇒ a real error, thrown as the plain request's own (never a `ValidationError`). 5xx/429/timeouts/401/403/404 propagate at once with no ladder. A model measured as not switchable (`claude-opus-5-5` off, `gemini-3.1-pro-preview` off) goes out once WITHOUT a thinking field. "Unsupported" is never cached, so an unknown model that can't switch pays its refused shape requests on each call. Every reply carries `result.thinkingStatus`: `'applied'` (accepted; for `'off'`, zero thinking seen), `'unsupported'` (this provider/model can't do it — the call ran without our field), `'ignored'` (`'off'` accepted but the model thought anyway — warns once, result kept), or `'unknown'` (accepted, signal unreadable; e.g. OpenAI without `reasoning_tokens`). `result.thinkingApplied` is kept and derived (`true`/`false`/`false`/`null`). On `loop.run()` the WORST status across rounds wins (ignored > unsupported > unknown > applied). Still a `ValidationError`: a bad value, or raw `thinking` + `thinkingMode` together. Anthropic `'on'` + a non-default `temperature` (the API rejects it with thinking enabled): the temperature is dropped and retried once, `temperatureDropped: true`. `'on'` is never flagged (adaptive models may skip thinking). `thinkingSupport(provider, model, { baseUrl }?)` from `bare-agent/providers` (`baseUrl` makes an OpenAI-wire DeepSeek host answer with DeepSeek shapes, same detection as the request path) tells you, offline, what is known. DeepSeek `'on'` + a forced `toolChoice` can't think, so it is sent with `thinking:{type:'disabled'}` and reports `'unsupported'`; CLIPipe has no mechanism and runs unchanged, `'unsupported'`. In a `FallbackProvider` a not-switchable member just runs (no fall-through). Ollama's `think` is not live-verified.
|
|
517
522
|
|
|
518
523
|
```js
|
|
519
524
|
const p = new AnthropicProvider({ apiKey, model: 'claude-sonnet-5', thinkingMode: 'off' });
|
|
520
|
-
const r = await p.generate(msgs, [], {}); // r.thinkingApplied === true
|
|
525
|
+
const r = await p.generate(msgs, [], {}); // r.thinkingStatus === 'applied' (r.thinkingApplied === true)
|
|
521
526
|
await p.generate(msgs, [], { thinkingMode: 'on' }); // per-call override
|
|
522
527
|
```
|
|
523
528
|
|
|
@@ -1508,7 +1513,7 @@ const { createMCPBridge } = require('bare-agent/mcp');
|
|
|
1508
1513
|
const provider = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, model: 'gpt-4o-mini' });
|
|
1509
1514
|
|
|
1510
1515
|
const bridge = await createMCPBridge();
|
|
1511
|
-
// bridge = { tools, servers, denied, systemContext, errors, close }
|
|
1516
|
+
// bridge = { tools, servers, denied, systemContext, errors, close } (+ missing when `allow` is given)
|
|
1512
1517
|
|
|
1513
1518
|
const loop = new Loop({
|
|
1514
1519
|
provider,
|
|
@@ -1528,6 +1533,8 @@ try {
|
|
|
1528
1533
|
|
|
1529
1534
|
**Governance via `.mcp-bridge.json`.** On first run, the bridge writes `.mcp-bridge.json` in the cwd listing every discovered server and tool with permission `"allow"`. Edit any entry to `"deny"` and the tool is dropped from the next run's tool array; the LLM sees it listed in `systemContext` as restricted, with instructions not to retry it. Re-discovery happens automatically after TTL expiry (default `24h`, settable via `ttl` field in the file).
|
|
1530
1535
|
|
|
1536
|
+
**Signed per-run allow-list (`allow`).** `createMCPBridge({ allow: { server: ['raw_tool_name', ...] } })` skips `.mcp-bridge.json` entirely (never read or written — no cache, no TTL; `bridgePath`/`refresh`/`servers` ignored), takes server definitions only from `discoverServers(configPaths)`, spawns only the named servers (still `confirmServer`-vetted), connects once and exposes signed ∩ listed. Everything else is absent from `tools`, the `metaTools` catalog and `systemContext` (no "Restricted tools" text, `denied: []`), nothing goes to stdout. The return gains `missing: [{server, tool}]` (signed tools the server did not list, or whose server was not found/failed/rejected) — never a throw. `mcp_invoke` of a listed-but-unsigned name throws `ToolError` with `context.denied === true`. Bad `allow` shape (including an empty tool list) throws `ValidationError`. In every mode (not only `allow`), a server whose `tools/list` reply has no `tools` array (e.g. `{}`) now fails its connect — it lands in `errors`, its child is killed — where it used to read as a working empty server.
|
|
1537
|
+
|
|
1531
1538
|
```json
|
|
1532
1539
|
{
|
|
1533
1540
|
"discovered": "2026-04-13T12:00:00.000Z",
|
|
@@ -1596,6 +1603,8 @@ MCP tools arrive with the server name prepended (`beeperbox_send_message`, not `
|
|
|
1596
1603
|
| `servers` | all discovered | Limit to a subset by name |
|
|
1597
1604
|
| `timeout` | `15000` | Per-server init timeout in ms |
|
|
1598
1605
|
| `refresh` | `false` | Force re-discovery regardless of TTL |
|
|
1606
|
+
| `allow` | none | Signed per-run allow-list `{ server: [rawToolNames] }` — in-memory, no `.mcp-bridge.json`; see "Signed per-run allow-list" above |
|
|
1607
|
+
| `callTimeout` | `120000` | Per-`tools/call` timeout in ms (`0` disables) |
|
|
1599
1608
|
|
|
1600
1609
|
### Recipe 10: beeperbox — multi-messenger reach via MCP bridge
|
|
1601
1610
|
|
package/package.json
CHANGED
package/primitives.json
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"category": "mcp",
|
|
34
34
|
"when": "you want the two bulk MCP meta-tools (mcp_discover, mcp_invoke) instead of exposing every discovered tool individually — one gate-check per invocation",
|
|
35
35
|
"import": "import { buildMetaTools } from 'bare-agent/mcp'",
|
|
36
|
-
"signature": "buildMetaTools(tools: ToolDef[], discoveredAt?: string) => ToolDef[]",
|
|
36
|
+
"signature": "buildMetaTools(tools: ToolDef[], discoveredAt?: string, excluded?: Map<string, {server: string, tool: string}>) => ToolDef[]",
|
|
37
37
|
"fails": "returns [mcp_discover, mcp_invoke]; a tool name does not travel as action.type (a deliberate v0.9 trade for one gate-check per call).",
|
|
38
38
|
"example": "const metaTools = buildMetaTools(tools);"
|
|
39
39
|
},
|
|
@@ -121,10 +121,10 @@
|
|
|
121
121
|
{
|
|
122
122
|
"name": "createMCPBridge",
|
|
123
123
|
"category": "mcp",
|
|
124
|
-
"when": "you want to auto-discover MCP servers and expose them as bareagent tools in one call — with a trust hook gating command execution",
|
|
124
|
+
"when": "you want to auto-discover MCP servers and expose them as bareagent tools in one call — with a trust hook gating command execution, or a per-run signed `allow` list that exposes only the named tools",
|
|
125
125
|
"import": "import { createMCPBridge } from 'bare-agent/mcp'",
|
|
126
|
-
"signature": "createMCPBridge(opts?: object) => Promise<{tools: ToolDef[], metaTools?: ToolDef[], servers: string[], systemContext: string, denied: DeniedTool[], errors?: Array<{server: string, error: string}>, close: Function}>",
|
|
127
|
-
"fails": "a trust hook returning false skips a server (its command never runs) and a throw is fail-closed (deny); returns tools plus a close() to shut servers down.",
|
|
126
|
+
"signature": "createMCPBridge(opts?: object) => Promise<{tools: ToolDef[], metaTools?: ToolDef[], servers: string[], systemContext: string, denied: DeniedTool[], errors?: Array<{server: string, error: string}>, missing?: Array<{server: string, tool: string}>, close: Function}>",
|
|
127
|
+
"fails": "a trust hook returning false skips a server (its command never runs) and a throw is fail-closed (deny); with `allow` it is in-memory only (no cache file read or written) and signed tools the server does not list land in `missing`, never a throw; returns tools plus a close() to shut servers down.",
|
|
128
128
|
"example": "const { tools, close } = await createMCPBridge();\nconst loop = new Loop({ provider, tools });"
|
|
129
129
|
},
|
|
130
130
|
{
|
|
@@ -447,7 +447,7 @@
|
|
|
447
447
|
"category": "providers",
|
|
448
448
|
"when": "you need to know, before building a request, whether a provider/model can have thinking turned on or off and which request field does it — pure lookup, no network",
|
|
449
449
|
"import": "import { thinkingSupport } from 'bare-agent/providers'",
|
|
450
|
-
"signature": "thinkingSupport(provider: 'anthropic'|'openai'|'deepseek'|'gemini'|'ollama'|'clipipe'|string, model?: string) => { switchable: boolean|'unverified', off: string|null, on: string|null, note: string }",
|
|
450
|
+
"signature": "thinkingSupport(provider: 'anthropic'|'openai'|'deepseek'|'gemini'|'ollama'|'clipipe'|string, model?: string, opts?: { baseUrl?: string }) => { switchable: boolean|'unverified', off: string|null, on: string|null, note: string }",
|
|
451
451
|
"fails": "returns switchable:false for clipipe and unknown providers (never throws); 'unverified' means not measured, not unsupported; Ollama's shapes are not live-verified.",
|
|
452
452
|
"example": "thinkingSupport('anthropic', 'claude-opus-5-5'); // { switchable: false, off: null, on: \"thinking:{type:'adaptive'}\", note: '...' }"
|
|
453
453
|
},
|
|
@@ -130,6 +130,10 @@ export type Annotation = {
|
|
|
130
130
|
* (`tools.allowlist:['shell_read']`) keeps matching even though `type` is now `'read'`. This
|
|
131
131
|
* activates `fs`/`bash` scoping automatically (`fs.readScope`/`fs.writeScope` deny by default
|
|
132
132
|
* when unset — see CHANGELOG) — override when a different shape is needed.
|
|
133
|
+
* `onToolResult` on a tool error records `{error: message, durationMs}` plus, only when present, an
|
|
134
|
+
* allow-listed `errorContext` copied from `error.context` (`timeout`/`denied` booleans, `server`/`tool`
|
|
135
|
+
* strings capped at 200 chars, finite `timeoutMs`; wrong-typed values dropped) — so an MCP timeout is
|
|
136
|
+
* distinguishable in the audit row. `stderr` and every other context field are NEVER forwarded.
|
|
133
137
|
* @returns {{policy: Function, onLlmResult: Function, onToolResult: Function, filterTools: Function, wrapTool: Function, wrapTools: Function}}
|
|
134
138
|
* @when you have a bareguard Gate and want to wire it into a Loop as the policy + metering chokepoint in one line
|
|
135
139
|
* @fails never throws; a gate halt surfaces as a HaltError the Loop catches and exits cleanly, and a deny is an advisory string fed back to the model.
|
package/src/bareguard-adapter.js
CHANGED
|
@@ -41,6 +41,38 @@ function safeStringify(value) {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// Keys of a tool error's `context` that onToolResult may forward to gate.record (and so into the
|
|
45
|
+
// audit row), with the exact type each must have. ALLOW-LIST, never the whole object: MCP timeout
|
|
46
|
+
// errors carry `stderr` (server output, possibly secret-bearing) in `context`, and gate.record writes
|
|
47
|
+
// its result verbatim to the audit log. Anything not listed here, or of the wrong type, is dropped.
|
|
48
|
+
const ERROR_CONTEXT_KEYS = /** @type {const} */ ({
|
|
49
|
+
timeout: 'boolean', denied: 'boolean', server: 'string', tool: 'string', timeoutMs: 'number',
|
|
50
|
+
});
|
|
51
|
+
const ERROR_CONTEXT_MAX_STR = 200;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {any} error
|
|
55
|
+
* @returns {Record<string, boolean|string|number>|undefined} undefined when no allowed key is present
|
|
56
|
+
*/
|
|
57
|
+
function pickErrorContext(error) {
|
|
58
|
+
/** @type {Record<string, boolean|string|number>} */
|
|
59
|
+
const out = {};
|
|
60
|
+
try {
|
|
61
|
+
const ctx = error?.context;
|
|
62
|
+
if (ctx === null || typeof ctx !== 'object') return undefined;
|
|
63
|
+
for (const [key, type] of Object.entries(ERROR_CONTEXT_KEYS)) {
|
|
64
|
+
if (!Object.prototype.hasOwnProperty.call(ctx, key)) continue;
|
|
65
|
+
const v = ctx[key];
|
|
66
|
+
if (typeof v !== type) continue;
|
|
67
|
+
if (type === 'number' && !Number.isFinite(v)) continue;
|
|
68
|
+
out[key] = type === 'string' ? v.slice(0, ERROR_CONTEXT_MAX_STR) : v;
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined; // a throwing getter / exotic context must never break the audit write
|
|
72
|
+
}
|
|
73
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
44
76
|
// Module-scope so a process that spawns many child agents (each with its own
|
|
45
77
|
// wireGate call) only prints the wrapTool deprecation warning once.
|
|
46
78
|
let warnedWrap = false;
|
|
@@ -98,6 +130,10 @@ let warnedWrap = false;
|
|
|
98
130
|
* (`tools.allowlist:['shell_read']`) keeps matching even though `type` is now `'read'`. This
|
|
99
131
|
* activates `fs`/`bash` scoping automatically (`fs.readScope`/`fs.writeScope` deny by default
|
|
100
132
|
* when unset — see CHANGELOG) — override when a different shape is needed.
|
|
133
|
+
* `onToolResult` on a tool error records `{error: message, durationMs}` plus, only when present, an
|
|
134
|
+
* allow-listed `errorContext` copied from `error.context` (`timeout`/`denied` booleans, `server`/`tool`
|
|
135
|
+
* strings capped at 200 chars, finite `timeoutMs`; wrong-typed values dropped) — so an MCP timeout is
|
|
136
|
+
* distinguishable in the audit row. `stderr` and every other context field are NEVER forwarded.
|
|
101
137
|
* @returns {{policy: Function, onLlmResult: Function, onToolResult: Function, filterTools: Function, wrapTool: Function, wrapTools: Function}}
|
|
102
138
|
* @when you have a bareguard Gate and want to wire it into a Loop as the policy + metering chokepoint in one line
|
|
103
139
|
* @fails never throws; a gate halt surfaces as a HaltError the Loop catches and exits cleanly, and a deny is an advisory string fed back to the model.
|
|
@@ -201,8 +237,10 @@ function wireGate(gate, options = {}) {
|
|
|
201
237
|
const onToolResult = async ({ name, args, result, error, durationMs, ctx }) => {
|
|
202
238
|
const action = translate(name, args, ctx);
|
|
203
239
|
if (error) {
|
|
240
|
+
const errorContext = pickErrorContext(error);
|
|
204
241
|
await gate.record(action, {
|
|
205
242
|
error: error?.message || String(error),
|
|
243
|
+
...(errorContext && { errorContext }),
|
|
206
244
|
durationMs: durationMs ?? null,
|
|
207
245
|
});
|
|
208
246
|
} else {
|
package/src/loop.d.ts
CHANGED
|
@@ -167,7 +167,7 @@ export class Loop {
|
|
|
167
167
|
* thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
|
|
168
168
|
* unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
|
|
169
169
|
* @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
|
|
170
|
-
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: GenerateResult['malformedToolCall'], temperatureDropped?: boolean, thinkingApplied?: boolean|null}>}
|
|
170
|
+
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: GenerateResult['malformedToolCall'], temperatureDropped?: boolean, thinkingApplied?: boolean|null, thinkingStatus?: 'applied'|'unsupported'|'ignored'|'unknown'}>}
|
|
171
171
|
* On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
|
|
172
172
|
* thrown HaltError carried no `rule`), and `msgs` is sanitized so any
|
|
173
173
|
* dangling assistant `tool_calls` from the halted round are paired with
|
|
@@ -208,6 +208,7 @@ export class Loop {
|
|
|
208
208
|
malformedToolCall?: GenerateResult["malformedToolCall"];
|
|
209
209
|
temperatureDropped?: boolean;
|
|
210
210
|
thinkingApplied?: boolean | null;
|
|
211
|
+
thinkingStatus?: "applied" | "unsupported" | "ignored" | "unknown";
|
|
211
212
|
}>;
|
|
212
213
|
_warnedTruncated: boolean | undefined;
|
|
213
214
|
/**
|
|
@@ -236,7 +237,7 @@ export class Loop {
|
|
|
236
237
|
* @param {string} text - User message.
|
|
237
238
|
* @param {ToolDef[]} [tools=[]] - Tool definitions.
|
|
238
239
|
* @param {Record<string, any>} [options={}] - Per-run overrides.
|
|
239
|
-
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: GenerateResult['malformedToolCall'], temperatureDropped?: boolean, thinkingApplied?: boolean|null}>}
|
|
240
|
+
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: GenerateResult['malformedToolCall'], temperatureDropped?: boolean, thinkingApplied?: boolean|null, thinkingStatus?: 'applied'|'unsupported'|'ignored'|'unknown'}>}
|
|
240
241
|
*/
|
|
241
242
|
chat(text: string, tools?: ToolDef[], options?: Record<string, any>): Promise<{
|
|
242
243
|
text: string;
|
|
@@ -251,6 +252,7 @@ export class Loop {
|
|
|
251
252
|
malformedToolCall?: GenerateResult["malformedToolCall"];
|
|
252
253
|
temperatureDropped?: boolean;
|
|
253
254
|
thinkingApplied?: boolean | null;
|
|
255
|
+
thinkingStatus?: "applied" | "unsupported" | "ignored" | "unknown";
|
|
254
256
|
}>;
|
|
255
257
|
/**
|
|
256
258
|
* Request a clean stop. The current tool call finishes; the loop then exits at the next boundary and
|
package/src/loop.js
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
const { ToolError, HaltError } = require('./errors');
|
|
4
4
|
const { classifyStopReason } = require('./provider-stop-reason');
|
|
5
|
+
const { appliedOf } = require('./thinking-mode');
|
|
6
|
+
|
|
7
|
+
// Worst-wins precedence for the run's `thinkingStatus`. Null-prototype so a provider-supplied
|
|
8
|
+
// status string like 'constructor' cannot match an inherited key.
|
|
9
|
+
/** @type {Record<string, number>} */
|
|
10
|
+
const THINKING_STATUS_RANK = Object.assign(Object.create(null), { applied: 0, unknown: 1, unsupported: 2, ignored: 3 });
|
|
5
11
|
|
|
6
12
|
/** @typedef {import('../types').Provider} Provider */
|
|
7
13
|
/** @typedef {import('../types').Message} Message */
|
|
@@ -455,7 +461,7 @@ class Loop {
|
|
|
455
461
|
* thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
|
|
456
462
|
* unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
|
|
457
463
|
* @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
|
|
458
|
-
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: GenerateResult['malformedToolCall'], temperatureDropped?: boolean, thinkingApplied?: boolean|null}>}
|
|
464
|
+
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: GenerateResult['malformedToolCall'], temperatureDropped?: boolean, thinkingApplied?: boolean|null, thinkingStatus?: 'applied'|'unsupported'|'ignored'|'unknown'}>}
|
|
459
465
|
* On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
|
|
460
466
|
* thrown HaltError carried no `rule`), and `msgs` is sanitized so any
|
|
461
467
|
* dangling assistant `tool_calls` from the halted round are paired with
|
|
@@ -565,10 +571,12 @@ class Loop {
|
|
|
565
571
|
// unsupported/deprecated) and retried without it. Surfaced on the result so an upstream receipt
|
|
566
572
|
// (recurse's refineLeaf) can report the EFFECTIVE temperature rather than the ignored request.
|
|
567
573
|
let temperatureDropped = false;
|
|
568
|
-
// Normalized thinkingMode (src/thinking-mode.js): the run's `
|
|
569
|
-
// (
|
|
570
|
-
|
|
571
|
-
|
|
574
|
+
// Normalized thinkingMode (src/thinking-mode.js): the run's `thinkingStatus` is the WORST status across
|
|
575
|
+
// rounds (ignored > unsupported > unknown > applied); `thinkingApplied` is derived from it (applied⇒true,
|
|
576
|
+
// ignored/unsupported⇒false, unknown⇒null). undefined ⇒ no round carried either ⇒ both keys absent.
|
|
577
|
+
/** @type {'applied'|'unsupported'|'ignored'|'unknown'|undefined} */
|
|
578
|
+
let worstThinkingStatus;
|
|
579
|
+
const thinkingFields = () => worstThinkingStatus === undefined ? undefined : { thinkingStatus: worstThinkingStatus, thinkingApplied: appliedOf(worstThinkingStatus) };
|
|
572
580
|
// BA-11: consecutive policy-deny counter (reset by any tool call that PASSES policy). When it reaches
|
|
573
581
|
// this.maxConsecutiveDenials the run short-circuits cleanly — see the deny block below.
|
|
574
582
|
let consecutiveDenials = 0;
|
|
@@ -601,7 +609,7 @@ class Loop {
|
|
|
601
609
|
sealDanglingToolCalls(msgs, `[halted:${stuckTag}]`);
|
|
602
610
|
this._reportError('stuck', new Error(`tool "${tc.name}" failed ${identicalErrors} times with identical arguments`), { rule: stuckTag, attempts: identicalErrors });
|
|
603
611
|
this._safeEmit({ type: 'loop:done', data: { text: lastText, stuck: true, rule: stuckTag, cost: totalCost } });
|
|
604
|
-
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: stuckTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...(
|
|
612
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: stuckTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...thinkingFields() };
|
|
605
613
|
}
|
|
606
614
|
return null;
|
|
607
615
|
};
|
|
@@ -848,7 +856,7 @@ class Loop {
|
|
|
848
856
|
this._reportError('provider', err, { round });
|
|
849
857
|
if (this.throwOnError) throw err;
|
|
850
858
|
// BA-5: a mid-run provider failure must not erase the work of the rounds that succeeded.
|
|
851
|
-
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...(
|
|
859
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...thinkingFields() };
|
|
852
860
|
}
|
|
853
861
|
|
|
854
862
|
lastUsage = result.usage || lastUsage;
|
|
@@ -865,10 +873,12 @@ class Loop {
|
|
|
865
873
|
lastMalformedToolCall = (result.malformedToolCall && typeof result.malformedToolCall === 'object')
|
|
866
874
|
? result.malformedToolCall : null;
|
|
867
875
|
if (result.temperatureDropped) temperatureDropped = true;
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
|
|
871
|
-
|
|
876
|
+
// Worst-status-wins across rounds; a round carrying only the legacy `thinkingApplied` (a custom provider)
|
|
877
|
+
// is mapped true→applied, false→ignored, null→unknown.
|
|
878
|
+
{
|
|
879
|
+
const st = THINKING_STATUS_RANK[result.thinkingStatus] !== undefined ? result.thinkingStatus
|
|
880
|
+
: result.thinkingApplied === true ? 'applied' : result.thinkingApplied === false ? 'ignored' : result.thinkingApplied === null ? 'unknown' : undefined;
|
|
881
|
+
if (st !== undefined && (worstThinkingStatus === undefined || THINKING_STATUS_RANK[st] > THINKING_STATUS_RANK[worstThinkingStatus])) worstThinkingStatus = st;
|
|
872
882
|
}
|
|
873
883
|
// Publish the latest measured usage to ctx (non-enumerable, fail-open) so a transcript-bound seam —
|
|
874
884
|
// e.g. F2 stash auto-compaction — can read EXACT provider-counted `inputTokens` to gauge context
|
|
@@ -953,7 +963,7 @@ class Loop {
|
|
|
953
963
|
sealDanglingToolCalls(msgs, `[halted:${session.error}]`);
|
|
954
964
|
this._reportError('session', new Error(`provider session terminated: ${session.error}`), { rule: session.error, sessionTurns: session.turns ?? null });
|
|
955
965
|
this._safeEmit({ type: 'loop:done', data: { text: lastText, rule: session.error, sessionTurns: session.turns ?? null, cost: totalCost } });
|
|
956
|
-
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: session.error, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...(
|
|
966
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: session.error, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...thinkingFields() };
|
|
957
967
|
}
|
|
958
968
|
|
|
959
969
|
// BA-13: classify this round's terminal signal against the neutral stop-reason vocabulary. BA-6
|
|
@@ -1018,7 +1028,7 @@ class Loop {
|
|
|
1018
1028
|
msgs.push({ role: 'assistant', content: result.text });
|
|
1019
1029
|
}
|
|
1020
1030
|
this._safeEmit({ type: 'loop:done', data: { text: lastText, ...(terminal === 'truncated' && { truncated: true }), terminal, stopReason: lastStopReason, droppedToolCalls: dropped, cost: totalCost } });
|
|
1021
|
-
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: errorTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...(
|
|
1031
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: errorTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...thinkingFields() };
|
|
1022
1032
|
}
|
|
1023
1033
|
|
|
1024
1034
|
// No tool calls — LLM gave a final text response
|
|
@@ -1038,7 +1048,7 @@ class Loop {
|
|
|
1038
1048
|
try { await flush(msgs, ctx); }
|
|
1039
1049
|
catch (err) { if (err instanceof HaltError) throw err; this._reportError('trim-flush', err, { round }); }
|
|
1040
1050
|
}
|
|
1041
|
-
return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...(
|
|
1051
|
+
return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...thinkingFields() };
|
|
1042
1052
|
}
|
|
1043
1053
|
|
|
1044
1054
|
// Execute tool calls
|
|
@@ -1147,7 +1157,7 @@ class Loop {
|
|
|
1147
1157
|
sealDanglingToolCalls(msgs, `[halted:${denyTag}]`);
|
|
1148
1158
|
this._reportError('denied', new Error(`policy denied ${consecutiveDenials} consecutive tool calls (${tc.name})`), { rule: denyTag, denials: consecutiveDenials });
|
|
1149
1159
|
this._safeEmit({ type: 'loop:done', data: { text: lastText, denied: true, rule: denyTag, cost: totalCost } });
|
|
1150
|
-
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...(
|
|
1160
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...thinkingFields() };
|
|
1151
1161
|
}
|
|
1152
1162
|
continue;
|
|
1153
1163
|
}
|
|
@@ -1221,7 +1231,7 @@ class Loop {
|
|
|
1221
1231
|
// BA-5: the rule tag survives on `error`; so does the work. A halt is how a bounded attempt is
|
|
1222
1232
|
// SUPPOSED to end — the caller reads `error` to know it was bounded and `text` to learn from it.
|
|
1223
1233
|
this._safeEmit({ type: 'loop:done', data: { text: lastText, halted: true, rule, cost: totalCost } });
|
|
1224
|
-
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...(
|
|
1234
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...thinkingFields() };
|
|
1225
1235
|
}
|
|
1226
1236
|
throw err;
|
|
1227
1237
|
}
|
|
@@ -1251,20 +1261,20 @@ class Loop {
|
|
|
1251
1261
|
const rule = err.rule || 'unknown';
|
|
1252
1262
|
this._reportError('halt', err, { rule, reason: err.decision?.reason ?? null });
|
|
1253
1263
|
this._safeEmit({ type: 'loop:done', data: { text: lastText, halted: true, rule, cost: totalCost } });
|
|
1254
|
-
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...(
|
|
1264
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...thinkingFields() };
|
|
1255
1265
|
}
|
|
1256
1266
|
this._reportError('trim-flush', err, { phase: 'stop' });
|
|
1257
1267
|
}
|
|
1258
1268
|
}
|
|
1259
1269
|
this._safeEmit({ type: 'loop:done', data: { text: lastText, stopped: true, cost: totalCost } });
|
|
1260
|
-
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...(
|
|
1270
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...thinkingFields() };
|
|
1261
1271
|
}
|
|
1262
1272
|
|
|
1263
1273
|
// Hard safety limit — should never fire under normal usage; bareguard's
|
|
1264
1274
|
// limits.maxTurns (or the LLM's natural completion) ends the loop first.
|
|
1265
1275
|
const warning = `[Loop] hit internal safety limit of ${HARD_ROUND_LIMIT} rounds. Wire bareguard for proper governance — see bare-agent/bareguard.`;
|
|
1266
1276
|
this._safeEmit({ type: 'loop:done', data: { text: lastText, warning, cost: totalCost } });
|
|
1267
|
-
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...(
|
|
1277
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...thinkingFields() };
|
|
1268
1278
|
}
|
|
1269
1279
|
|
|
1270
1280
|
/**
|
|
@@ -1337,7 +1347,7 @@ class Loop {
|
|
|
1337
1347
|
* @param {string} text - User message.
|
|
1338
1348
|
* @param {ToolDef[]} [tools=[]] - Tool definitions.
|
|
1339
1349
|
* @param {Record<string, any>} [options={}] - Per-run overrides.
|
|
1340
|
-
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: GenerateResult['malformedToolCall'], temperatureDropped?: boolean, thinkingApplied?: boolean|null}>}
|
|
1350
|
+
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: GenerateResult['malformedToolCall'], temperatureDropped?: boolean, thinkingApplied?: boolean|null, thinkingStatus?: 'applied'|'unsupported'|'ignored'|'unknown'}>}
|
|
1341
1351
|
*/
|
|
1342
1352
|
async chat(text, tools = [], options = {}) {
|
|
1343
1353
|
this._history.push({ role: 'user', content: text });
|
package/src/mcp-bridge.d.ts
CHANGED
|
@@ -52,7 +52,10 @@ export type DeniedTool = {
|
|
|
52
52
|
* JSON-RPC stdio client over a spawned MCP server.
|
|
53
53
|
*/
|
|
54
54
|
export type RpcClient = {
|
|
55
|
-
rpc: (method: string, params?: object, timeoutMs?: number
|
|
55
|
+
rpc: (method: string, params?: object, timeoutMs?: number, label?: {
|
|
56
|
+
server: string;
|
|
57
|
+
tool: string;
|
|
58
|
+
}) => Promise<any>;
|
|
56
59
|
notify: (method: string, params?: object) => void;
|
|
57
60
|
child: import("node:child_process").ChildProcessWithoutNullStreams;
|
|
58
61
|
stderr: string;
|
|
@@ -79,7 +82,16 @@ export type RpcClient = {
|
|
|
79
82
|
* @param {boolean} [opts.includeProjectConfig=false] - Also scan the project-cwd `./.mcp.json`
|
|
80
83
|
* during default discovery. Off by default: a project config in an untrusted repo can auto-spawn
|
|
81
84
|
* arbitrary commands. Implied true when a `confirmServer` hook is present (it vets each command).
|
|
82
|
-
* @param {string[]} [opts.
|
|
85
|
+
* @param {Record<string, string[]>} [opts.allow] - Signed allow-list `{ [serverName]: rawMcpToolNames[] }`
|
|
86
|
+
* (raw MCP names, NOT the `server_tool` prefixed form; each list must be non-empty). Switches to an IN-MEMORY mode: never reads or
|
|
87
|
+
* writes `.mcp-bridge.json` (no cache, no TTL; `bridgePath`/`refresh`/`servers` are ignored); server
|
|
88
|
+
* definitions come only from `discoverServers(configPaths)`; only servers named here are spawned (still
|
|
89
|
+
* vetted by `confirmServer`); each connects ONCE and exposes signed ∩ listed — every other tool is ABSENT
|
|
90
|
+
* (not in `tools`, `metaTools` catalog or `systemContext`). Signed tools the server does not list (or whose
|
|
91
|
+
* server was not found / failed / was rejected) come back in `missing`. `denied` is always `[]`, nothing is
|
|
92
|
+
* written to stdout, and `mcp_invoke` on a listed-but-unsigned name throws `context.denied === true`.
|
|
93
|
+
* Invalid shape throws `ValidationError` (type only, never values).
|
|
94
|
+
* @param {string[]} [opts.servers] - Limit to these server names (ignored when `allow` is given).
|
|
83
95
|
* @param {number} [opts.timeout=15000] - Per-server handshake timeout in ms (initialize + tools/list).
|
|
84
96
|
* @param {number} [opts.callTimeout=120000] - Per-invocation timeout in ms for tools/call. Bounds a
|
|
85
97
|
* server that accepts a tool call but never responds. Set 0 to disable (unbounded).
|
|
@@ -91,9 +103,9 @@ export type RpcClient = {
|
|
|
91
103
|
* discovered server is trusted — pass this to gate command execution. Presence
|
|
92
104
|
* of this hook also opts default discovery into the project-cwd `./.mcp.json`,
|
|
93
105
|
* since each command is then vetted regardless of source.
|
|
94
|
-
* @returns {Promise<{tools: ToolDef[], metaTools?: ToolDef[], servers: string[], systemContext: string, denied: DeniedTool[], errors?: Array<{server: string, error: string}>, close: Function}>}
|
|
95
|
-
* @when you want to auto-discover MCP servers and expose them as bareagent tools in one call — with a trust hook gating command execution
|
|
96
|
-
* @fails a trust hook returning false skips a server (its command never runs) and a throw is fail-closed (deny); returns tools plus a close() to shut servers down.
|
|
106
|
+
* @returns {Promise<{tools: ToolDef[], metaTools?: ToolDef[], servers: string[], systemContext: string, denied: DeniedTool[], errors?: Array<{server: string, error: string}>, missing?: Array<{server: string, tool: string}>, close: Function}>}
|
|
107
|
+
* @when you want to auto-discover MCP servers and expose them as bareagent tools in one call — with a trust hook gating command execution, or a per-run signed `allow` list that exposes only the named tools
|
|
108
|
+
* @fails a trust hook returning false skips a server (its command never runs) and a throw is fail-closed (deny); with `allow` it is in-memory only (no cache file read or written) and signed tools the server does not list land in `missing`, never a throw; returns tools plus a close() to shut servers down.
|
|
97
109
|
* @example
|
|
98
110
|
* const { tools, close } = await createMCPBridge();
|
|
99
111
|
* const loop = new Loop({ provider, tools });
|
|
@@ -102,6 +114,7 @@ export function createMCPBridge(opts?: {
|
|
|
102
114
|
bridgePath?: string | undefined;
|
|
103
115
|
configPaths?: string[] | undefined;
|
|
104
116
|
includeProjectConfig?: boolean | undefined;
|
|
117
|
+
allow?: Record<string, string[]> | undefined;
|
|
105
118
|
servers?: string[] | undefined;
|
|
106
119
|
timeout?: number | undefined;
|
|
107
120
|
callTimeout?: number | undefined;
|
|
@@ -117,6 +130,10 @@ export function createMCPBridge(opts?: {
|
|
|
117
130
|
server: string;
|
|
118
131
|
error: string;
|
|
119
132
|
}>;
|
|
133
|
+
missing?: Array<{
|
|
134
|
+
server: string;
|
|
135
|
+
tool: string;
|
|
136
|
+
}>;
|
|
120
137
|
close: Function;
|
|
121
138
|
}>;
|
|
122
139
|
/**
|
|
@@ -150,10 +167,16 @@ export function discoverServers(configPaths?: string[], { includeProjectConfig }
|
|
|
150
167
|
*
|
|
151
168
|
* @param {ToolDef[]} tools - The bulk-loaded, name-prefixed tools array.
|
|
152
169
|
* @param {string} [discoveredAt] - ISO timestamp from .mcp-bridge.json.
|
|
170
|
+
* @param {Map<string, {server: string, tool: string}>} [excluded] - Allow mode only: names the server
|
|
171
|
+
* LISTS but the signed allow-list excludes. mcp_invoke refuses them with `context.denied === true`;
|
|
172
|
+
* they never appear in the catalog. Omitted ⇒ unchanged behaviour.
|
|
153
173
|
* @returns {ToolDef[]} [mcp_discover, mcp_invoke]
|
|
154
174
|
* @when you want the two bulk MCP meta-tools (mcp_discover, mcp_invoke) instead of exposing every discovered tool individually — one gate-check per invocation
|
|
155
175
|
* @fails returns [mcp_discover, mcp_invoke]; a tool name does not travel as action.type (a deliberate v0.9 trade for one gate-check per call).
|
|
156
176
|
* @example
|
|
157
177
|
* const metaTools = buildMetaTools(tools);
|
|
158
178
|
*/
|
|
159
|
-
export function buildMetaTools(tools: ToolDef[], discoveredAt?: string
|
|
179
|
+
export function buildMetaTools(tools: ToolDef[], discoveredAt?: string, excluded?: Map<string, {
|
|
180
|
+
server: string;
|
|
181
|
+
tool: string;
|
|
182
|
+
}>): ToolDef[];
|