bare-agent 0.31.0 → 0.33.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 +22 -1
- package/package.json +1 -1
- package/src/evaluator.d.ts +3 -0
- package/src/evaluator.js +27 -1
- package/src/loop.js +57 -1
- package/src/mcp-bridge-stub.d.ts +1 -0
- package/src/mcp-bridge-stub.js +107 -0
- package/src/provider-clipipe-mcp.d.ts +128 -0
- package/src/provider-clipipe-mcp.js +496 -0
- package/src/provider-clipipe-tools.d.ts +121 -0
- package/src/provider-clipipe-tools.js +271 -0
- package/src/provider-clipipe.d.ts +194 -2
- package/src/provider-clipipe.js +315 -21
- package/src/recurse.d.ts +2 -2
- package/src/recurse.js +1 -1
- package/types/index.d.ts +40 -0
package/README.md
CHANGED
|
@@ -121,7 +121,7 @@ console.log(result.count, result.matchedIds); // a code-derived count + the id
|
|
|
121
121
|
|
|
122
122
|
**Govern — one gate over both axes.** `wireGate(gate)` routes every LLM + tool call through one bareguard policy + audit + budget. Denied tools never reach the model; halts (turn / budget / content caps) exit cleanly. A plain deny stays advisory (the model can pivot to an allowed tool), but the Loop short-circuits a *spin* — `maxConsecutiveDenials` consecutive denials of the same action (default 3) stop the run with `error:'denied:<tool>'` instead of burning the budget to the cap; under `recurse` that surfaces as `{ incomplete, blocker:'governance-deny' }`. The same bound covers a tool that keeps *failing*: `maxIdenticalToolErrors` (default 3) stops a model re-sending a byte-identical call that cannot succeed, with `error:'stuck:<tool>'`. `require('bare-agent/bareguard')`
|
|
123
123
|
|
|
124
|
-
**Providers:** OpenAI-compatible (OpenAI, OpenRouter, Groq, vLLM, LM Studio), Anthropic, Gemini (native), Ollama, CLIPipe, Fallback — or bring your own (one `generate` method). All return the same shape; swap freely. Usage including prompt-cache tiers is normalized, so `result.metrics` reports honest cumulative tokens + cost — and `null`, never a silent `0`, for a model it couldn't price. A model that rejects a non-default `temperature` (e.g. `claude-sonnet-5`, OpenAI o1/gpt-5-class return a `400`) is handled gracefully — the provider drops the param and retries once rather than failing the call, surfacing `temperatureDropped` so a caller can report the effective value.
|
|
124
|
+
**Providers:** OpenAI-compatible (OpenAI, OpenRouter, Groq, vLLM, LM Studio), Anthropic, Gemini (native), Ollama, CLIPipe, Fallback — or bring your own (one `generate` method). All return the same shape; swap freely. Usage including prompt-cache tiers is normalized, so `result.metrics` reports honest cumulative tokens + cost — and `null`, never a silent `0`, for a model it couldn't price. A model that rejects a non-default `temperature` (e.g. `claude-sonnet-5`, OpenAI o1/gpt-5-class return a `400`) is handled gracefully — the provider drops the param and retries once rather than failing the call, surfacing `temperatureDropped` so a caller can report the effective value. **CLIPipe can drive a full agentic Loop — tools and turns — over a CLI *subscription* instead of the metered API.** Two modes, chosen by cost: `toolProtocol:'claude-mcp'` (v0.33.0, NATIVE — the CLI runs its own session and calls your tools over an MCP bridge; the gate, spin guards, and turn bound ride the provider, and it caches session-side — ~$0.006/turn) is the default for the Claude CLI, while `toolProtocol:'claude'` (v0.32.0, schema-validated emulation with the Loop keeping the cycle) stays right for a CLI with no MCP channel. A capable model is enforced upfront (sonnet-class+ for tools; Claude CLI for now).
|
|
125
125
|
|
|
126
126
|
**The run tells you the truth about what happened on the wire.** Every provider reports **why** generation ended (`stopReason`, normalized across all of them and surfaced on **every** `Loop.run()` return), so a round the API **cut off at the token cap** can no longer masquerade as a finished answer: it returns `error: 'truncated:max_tokens'` with the partial text preserved, instead of a silent `error: null` (BA-6). Its tool calls are **refused, never executed** — a *complete* tool call always arrives tagged `tool_use`, so one riding a truncated round was cut off mid-generation with arguments missing, which is exactly how a truncated `shell_write` can zero a file. The same honesty now covers **every** non-clean terminal signal (BA-13): a safety `refusal` returns `error: 'refusal'` and a blown context window returns `error: 'context_exceeded'` (both were previously laundered into an empty success), while a resumable `pause_turn` makes the loop resume rather than terminate. `error` is the sole success signal; a bound firing preserves the model's work rather than discarding it (BA-5).
|
|
127
127
|
|
package/bareagent.context.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# bareagent — Integration Guide
|
|
2
2
|
|
|
3
3
|
> For AI assistants and developers wiring bareagent into a project.
|
|
4
|
-
> v0.
|
|
4
|
+
> v0.33.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
|
|
5
5
|
>
|
|
6
6
|
> Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)
|
|
7
7
|
|
|
@@ -60,6 +60,8 @@ Eight entry points:
|
|
|
60
60
|
| Cache identical planner calls | Planner({ cacheTTL: 60000 }) |
|
|
61
61
|
| Stream CLIPipe output in real-time | CLIPipeProvider({ onChunk: fn }) |
|
|
62
62
|
| Get real usage + cost from a CLI provider | CLIPipeProvider({ parse: 'claude-json' }) |
|
|
63
|
+
| Drive tools over a CLI subscription — NATIVE, cheapest (no metered API) | CLIPipeProvider({ toolProtocol: 'claude-mcp', policy }) |
|
|
64
|
+
| Drive tools over a CLI with no MCP support (emulation) | CLIPipeProvider({ toolProtocol: 'claude' }) |
|
|
63
65
|
| Browse the web (inline snapshots) | createBrowsingTools + Loop |
|
|
64
66
|
| Browse the web (token-efficient, disk-based) | `barebrowse` CLI session — snapshots to `.barebrowse/*.yml` |
|
|
65
67
|
| Assess website privacy risk | createBrowsingTools + Loop (requires `npm install wearehere`) |
|
|
@@ -800,8 +802,27 @@ new CLIPipe({ command: 'claude', args: ['--print'], systemPromptFlag: '--system-
|
|
|
800
802
|
new CLIPipe({ command: 'ollama', args: ['run', 'llama3.2'] })
|
|
801
803
|
// CLIPipe structured output (v0.26.0+) — map a CLI's JSON envelope to real usage + cost
|
|
802
804
|
new CLIPipe({ command: 'claude', args: ['-p', '--output-format', 'json'], parse: 'claude-json' })
|
|
805
|
+
// CLIPipe TOOL MODE — drive a Loop's tools over a CLI SUBSCRIPTION (no metered API). TWO modes:
|
|
806
|
+
// 'claude-mcp' (BA-16, NATIVE — prefer on the claude CLI) vs 'claude' (v0.32.0, EMULATION). The
|
|
807
|
+
// difference is COST, not capability. Emulation re-sends the whole transcript every round
|
|
808
|
+
// ($0.25-0.55/round measured); native runs one CLI session that caches session-side (~$0.006/turn).
|
|
809
|
+
// Native: caller tools ride an MCP bridge back to your in-process closures; the CLI owns the cycle.
|
|
810
|
+
// Gate + BA-11/BA-12 guards + turn bound live on the PROVIDER (not the Loop) — see below.
|
|
811
|
+
new CLIPipe({ command: 'claude', args: ['-p', '--model', 'sonnet'], toolProtocol: 'claude-mcp', policy, onTurn, maxTurns: 20 })
|
|
812
|
+
// Emulation (v0.32.0) — still right for a CLI with NO MCP support. Weak models rejected upfront by a
|
|
813
|
+
// capability probe (needs sonnet-class+; haiku fine for plain text). setting-sources '' cuts ~18x cost.
|
|
814
|
+
new CLIPipe({ command: 'claude', args: ['-p', '--model', 'sonnet'], toolProtocol: 'claude' })
|
|
803
815
|
```
|
|
804
816
|
|
|
817
|
+
**CLIPipe NATIVE tool mode (BA-16, `toolProtocol:'claude-mcp'`).** The claude CLI has a real tool channel; native mode uses it instead of emulating one. The CLI runs its OWN multi-turn session per `generate()` call and executes your `tools` natively over an MCP bridge that calls back into your in-process `execute` closures. Because the CLI owns the inner cycle, the Loop's per-round machinery cannot run — so the governance you'd wire on `Loop` moves to the **provider**, at the one seam every tool call crosses:
|
|
818
|
+
- `policy` — the SAME `(tool, args, ctx) => true|string` chokepoint as `Loop({policy})`, so a wired `wireGate(gate).policy` writes **audit rows of identical shape, zero gate changes**. A deny is a tool result (advisory); the handler never runs. **Required here** — a `Loop({policy})` in native mode would be a fence that is silently not there, so the Loop throws.
|
|
819
|
+
- `maxConsecutiveDenials` (3) / `maxIdenticalToolErrors` (3) — BA-11/BA-12 guards at the bridge, same narrowest triggers; end the session `denied:<tool>` / `stuck:<tool>`.
|
|
820
|
+
- `maxTurns` — maps to the CLI's `--max-turns`; the bound stop is `error:'max_turns'`, never a silent success.
|
|
821
|
+
- `onTurn` — streams each completed turn's usage (four cache tiers, `costUsd:null` — the CLI prices the session) as it arrives, then one closing `kind:'session'` event with the authoritative total. Shape mirrors `onLlmResult`, so `wireGate(gate).onLlmResult` drops in; when wired the Loop skips its own forward (billed once, never starved).
|
|
822
|
+
- `sessionTimeout` (600s) / `bridgeTimeoutMs` (120s) — whole-session and per-handler ceilings.
|
|
823
|
+
|
|
824
|
+
`GenerateResult.session` (`{turns, toolCalls, error, usageReported}`) carries what really happened; `metrics.sessionTurns` reports the real turn count so a 14-turn session never reads as one round. A terminal the CLI detects inside the session (bound, guard, or a **broken tool bridge** — a dead bridge still ends `subtype:'success'`, so it is caught parent-side by attempted-vs-served tool calls) surfaces as the run's `error`, never a laundered clean finish. `assemble`/`trim`/`cacheMessages` and a Loop-level `policy` all THROW at construction in native mode (no silently-dead knobs — the CLI owns the transcript). The bridge is a unix socket (0600 in a 0700 dir), never a listening port. Claude-only for now; the CLI-specific parts live in `src/provider-clipipe-mcp.js` + `src/mcp-bridge-stub.js` behind the same seam as emulation.
|
|
825
|
+
|
|
805
826
|
All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model?, costUsd? }`. The optional `model` (v0.16.1+) is the id the response was produced by — Loop prefers it over `provider.model` for cost accounting. By default CLIPipe returns `toolCalls: []` and zero usage (CLI tools don't report tokens) and omits `model`. **Structured output (v0.26.0+):** set `parse: 'claude-json'` (a preset for `claude -p --output-format json`) — or a `(stdout) => Partial<GenerateResult>` function for any other CLI — and CLIPipe maps the CLI's JSON envelope onto real `usage`, `model`, and `costUsd`, throwing `ProviderError` on a malformed/error envelope (never a silent raw-text fall-back). `costUsd` (optional `GenerateResult` field) is an **authoritative** per-call price the provider reports itself; when finite the Loop prefers it over the internal rate-table `estimateCost`, so a CLI-piped run enforces a bareguard USD cap with no local pricing table (a `0` counts as priced, distinct from null/unpriced). `toolCalls` stays `[]` regardless (CLIPipe is tool-free).
|
|
806
827
|
|
|
807
828
|
**Temperature graceful degradation (BA-10).** Newer models reject ANY non-default `temperature` with a `400` (`claude-sonnet-5`: `` `temperature` is deprecated for this model. ``; OpenAI o1/gpt-5-class: `Unsupported value: 'temperature' … Only the default (1) …`). All four providers detect that specific 400 (message names `temperature` as unsupported/deprecated AND a temperature was sent), **drop the param, warn once per instance, and retry once** — so a call that would otherwise throw succeeds at the model's default temperature. Keyed off the API error text, not a model list. A genuine out-of-range 400 is NOT degraded (it re-throws — dropping it would mask a caller bug). When a drop happens the result carries `temperatureDropped: true` (an optional `GenerateResult`/`Loop.run` field) so a caller can report the effective temperature — `recurse`'s `refineLeaf` uses it for an honest receipt. Dormant on models that accept temperature (byte-identical to before).
|
package/package.json
CHANGED
package/src/evaluator.d.ts
CHANGED
|
@@ -53,6 +53,9 @@ export type EvaluatorOptions = {
|
|
|
53
53
|
export type Criteria = {
|
|
54
54
|
/**
|
|
55
55
|
* - Deterministic check, no tokens.
|
|
56
|
+
* MUST return a boolean. A non-boolean return THROWS a `ValidationError` (it is not coerced): a truthy
|
|
57
|
+
* object/string/number — e.g. a test-runner result returned by mistake — would otherwise launder a
|
|
58
|
+
* FAILING check into a PASS. Thrown, it routes to `broken-verifier` at recurse's verify slot.
|
|
56
59
|
*/
|
|
57
60
|
predicate?: ((result: any) => boolean | Promise<boolean>) | undefined;
|
|
58
61
|
/**
|
package/src/evaluator.js
CHANGED
|
@@ -35,6 +35,9 @@ const { Loop } = require('./loop');
|
|
|
35
35
|
/**
|
|
36
36
|
* @typedef {object} Criteria
|
|
37
37
|
* @property {(result: any) => boolean | Promise<boolean>} [predicate] - Deterministic check, no tokens.
|
|
38
|
+
* MUST return a boolean. A non-boolean return THROWS a `ValidationError` (it is not coerced): a truthy
|
|
39
|
+
* object/string/number — e.g. a test-runner result returned by mistake — would otherwise launder a
|
|
40
|
+
* FAILING check into a PASS. Thrown, it routes to `broken-verifier` at recurse's verify slot.
|
|
38
41
|
* @property {string} [rubric] - Natural-language grading criteria an LLM scores. Exactly one of predicate|rubric|agentic.
|
|
39
42
|
* @property {string} [agentic] - Instructions for a tool-running critic (D9): how to EXERCISE the live artifact
|
|
40
43
|
* (open it, click, read console/network) and what would make it fail. Runs an ISOLATED Loop with the scoped
|
|
@@ -127,7 +130,30 @@ class Evaluator {
|
|
|
127
130
|
}
|
|
128
131
|
|
|
129
132
|
if (predicate) {
|
|
130
|
-
|
|
133
|
+
// BA-15 family (predicate seam): the contract is `=> boolean`. The OLD `!!(await predicate(...))`
|
|
134
|
+
// coerced ANY truthy return to a PASS — so a predicate that returned a test-runner RESULT instead
|
|
135
|
+
// of a boolean (`{exitCode:1,failures:3}`, `'3 failing'`, a count) laundered a FAILING check into
|
|
136
|
+
// `{status:'satisfied'}` (the optimistic-rounding class of BA-4/5/6/7/13; proven by
|
|
137
|
+
// `poc/rlmplans-predicate-coercion.mjs`). There is no safe non-boolean subset — an object is always
|
|
138
|
+
// truthy, a non-empty string is truthy regardless of meaning, a failure-count is truthy — so the
|
|
139
|
+
// ONLY correct return is a genuine boolean. A non-boolean is a broken arbiter: NAME it loudly rather
|
|
140
|
+
// than coerce it (BA-15's principle). Thrown here, it routes to `broken-verifier` at recurse's verify
|
|
141
|
+
// slot (`runArbiter` catches any non-Halt throw) and surfaces as a clean ValidationError standalone.
|
|
142
|
+
const raw = await predicate(result);
|
|
143
|
+
if (typeof raw !== 'boolean') {
|
|
144
|
+
// Name the TYPE only, never the value — an error string can reach a wired gate's audit log (F16/BA-1).
|
|
145
|
+
const got = raw === null ? 'null'
|
|
146
|
+
: raw === undefined ? 'undefined'
|
|
147
|
+
: Array.isArray(raw) ? 'an array'
|
|
148
|
+
: typeof raw === 'object' ? 'an object'
|
|
149
|
+
: `a ${typeof raw}`;
|
|
150
|
+
throw new ValidationError(
|
|
151
|
+
`[Evaluator] predicate must return a boolean, got ${got}. A truthy non-boolean ` +
|
|
152
|
+
'(a test-runner result object, a summary string, a failure count) would otherwise coerce to a ' +
|
|
153
|
+
'PASS — return true/false explicitly.',
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
const pass = raw;
|
|
131
157
|
return {
|
|
132
158
|
status: pass ? 'satisfied' : 'needs_revision',
|
|
133
159
|
pass,
|
package/src/loop.js
CHANGED
|
@@ -277,6 +277,30 @@ class Loop {
|
|
|
277
277
|
throw new Error('[Loop] options.trim must be a function (msgs, ctx) => msgs (e.g. unitTrimmer({ trim, onHarvest, policy }))');
|
|
278
278
|
}
|
|
279
279
|
this.trim = options.trim || null;
|
|
280
|
+
// BA-16: a CYCLE-OWNING provider (one that runs its own multi-turn session internally, e.g.
|
|
281
|
+
// CLIPipe native tool mode) has no per-round seam for the Loop to hand a transcript to — the
|
|
282
|
+
// transcript lives inside the provider's session. `assemble`/`trim` would therefore be accepted
|
|
283
|
+
// and then silently never called, which is precisely the "silently-dead knob" shape this repo
|
|
284
|
+
// keeps paying for. Fail at CONSTRUCTION instead: an unhonorable option is a configuration
|
|
285
|
+
// error, not a no-op.
|
|
286
|
+
if (this.provider && this.provider.ownsCycle && (this.assemble || this.trim)) {
|
|
287
|
+
const named = [this.assemble && 'assemble', this.trim && 'trim'].filter(Boolean).join(' and ');
|
|
288
|
+
throw new Error(
|
|
289
|
+
`[Loop] provider '${this.provider.name || 'unknown'}' owns its own turn cycle, so ${named} can never run `
|
|
290
|
+
+ '(the provider owns the transcript, not the Loop). Remove the option, or use a provider whose cycle the Loop drives.',
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
// The same rule, applied to the option where being silently dead is WORST. With a cycle-owning
|
|
294
|
+
// provider no tool call ever reaches the Loop, so a `Loop({policy})` is a fence that is simply
|
|
295
|
+
// not there — the run looks governed and is not. Refuse it: the gate must be wired on the
|
|
296
|
+
// provider, where every tool call actually crosses.
|
|
297
|
+
if (this.provider && this.provider.ownsCycle && this.policy && !this.provider.policy) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`[Loop] provider '${this.provider.name || 'unknown'}' owns its own turn cycle, so Loop-level policy would `
|
|
300
|
+
+ 'NEVER run — no tool call reaches the Loop. Wire the gate on the provider instead '
|
|
301
|
+
+ '(e.g. new CLIPipeProvider({ toolProtocol: \'claude-mcp\', policy })).',
|
|
302
|
+
);
|
|
303
|
+
}
|
|
280
304
|
if (options.onLlmResult != null && typeof options.onLlmResult !== 'function') {
|
|
281
305
|
throw new Error('[Loop] options.onLlmResult must be a function');
|
|
282
306
|
}
|
|
@@ -491,6 +515,12 @@ class Loop {
|
|
|
491
515
|
const metrics = {
|
|
492
516
|
turns: 0,
|
|
493
517
|
toolCalls: 0,
|
|
518
|
+
// BA-16 — turns that happened INSIDE a cycle-owning provider's own session (CLIPipe native tool
|
|
519
|
+
// mode). Such a session is ONE Loop round no matter how many turns it really took, so `turns`
|
|
520
|
+
// alone would report a 14-turn session as 1 — a round count that reads far cheaper and far
|
|
521
|
+
// shorter than the run actually was. Reporting the real number keeps the meter honest; 0 for
|
|
522
|
+
// every provider whose cycle the Loop drives.
|
|
523
|
+
sessionTurns: 0,
|
|
494
524
|
/** @type {Record<string, number>} */
|
|
495
525
|
byTool: {},
|
|
496
526
|
tokens: { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 },
|
|
@@ -525,6 +555,7 @@ class Loop {
|
|
|
525
555
|
/** Snapshot the meter for a return — finalizes the run-scoped fields. @returns {RunMetrics} */
|
|
526
556
|
const finalizeMetrics = () => ({
|
|
527
557
|
turns: metrics.turns,
|
|
558
|
+
sessionTurns: metrics.sessionTurns,
|
|
528
559
|
toolCalls: metrics.toolCalls,
|
|
529
560
|
byTool: metrics.byTool,
|
|
530
561
|
tokens: { ...metrics.tokens },
|
|
@@ -742,11 +773,22 @@ class Loop {
|
|
|
742
773
|
metrics.turns++;
|
|
743
774
|
addUsage(result.usage);
|
|
744
775
|
if (roundCost === null) metrics.unpricedRounds++; else pricedAny = true;
|
|
776
|
+
// BA-16: a cycle-owning provider reports what really happened inside its session.
|
|
777
|
+
const session = (result.session && typeof result.session === 'object') ? result.session : null;
|
|
778
|
+
if (session) {
|
|
779
|
+
if (Number.isFinite(session.turns)) metrics.sessionTurns += session.turns;
|
|
780
|
+
if (Number.isFinite(session.toolCalls)) metrics.toolCalls += session.toolCalls;
|
|
781
|
+
}
|
|
745
782
|
|
|
746
783
|
// BA1: forward LLM usage to gate.record (via wireGate) so budget.maxCostUsd
|
|
747
784
|
// covers token-heavy / tool-light workloads. Callback errors route through
|
|
748
785
|
// _reportError but never kill the loop — governance failure ≠ run failure.
|
|
749
|
-
|
|
786
|
+
// BA-16: when a cycle-owning provider has ALREADY streamed this call's usage per internal turn
|
|
787
|
+
// (so a session that dies mid-run has surfaced every completed turn's spend rather than losing
|
|
788
|
+
// all of it), forwarding the summed total here too would bill the gate twice for one session.
|
|
789
|
+
// The provider only sets this when it genuinely reported; unwired, it stays false and the
|
|
790
|
+
// normal forward below runs — so the gate is never silently starved either.
|
|
791
|
+
if (this.onLlmResult && !(session && session.usageReported === true)) {
|
|
750
792
|
try {
|
|
751
793
|
await this.onLlmResult({
|
|
752
794
|
model,
|
|
@@ -766,6 +808,20 @@ class Loop {
|
|
|
766
808
|
}
|
|
767
809
|
}
|
|
768
810
|
|
|
811
|
+
// BA-16: a cycle-owning provider detected a terminal INSIDE its own session — a turn bound, a
|
|
812
|
+
// guard streak, or a broken tool bridge. It is reported as the run's `error` (never merely
|
|
813
|
+
// surfaced) because every downstream consumer — `recurse`, the bareloop adopter — branches on
|
|
814
|
+
// `error` as the SOLE success signal. Surfacing alone would let a session in which no tool call
|
|
815
|
+
// ever succeeded propagate as converged. `lastText` (BA-5) preserves the partial work: a bound
|
|
816
|
+
// firing is normal termination for a bounded attempt, and that text is the only channel from
|
|
817
|
+
// attempt N to N+1.
|
|
818
|
+
if (session && typeof session.error === 'string' && session.error) {
|
|
819
|
+
sealDanglingToolCalls(msgs, `[halted:${session.error}]`);
|
|
820
|
+
this._reportError('session', new Error(`provider session terminated: ${session.error}`), { rule: session.error, sessionTurns: session.turns ?? null });
|
|
821
|
+
this._safeEmit({ type: 'loop:done', data: { text: lastText, rule: session.error, sessionTurns: session.turns ?? null, cost: totalCost } });
|
|
822
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: session.error, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
823
|
+
}
|
|
824
|
+
|
|
769
825
|
// BA-13: classify this round's terminal signal against the neutral stop-reason vocabulary. BA-6
|
|
770
826
|
// short-circuited exactly one non-clean reason (max_tokens); this gate is the general form. It sits
|
|
771
827
|
// AFTER metering (the tokens were really spent — the gate must see them) and BEFORE tool execution,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BA-16 — the MCP stdio server the CLI spawns in native tool mode (`toolProtocol:'claude-mcp'`).
|
|
5
|
+
*
|
|
6
|
+
* This file owns NO tool logic. bare-agent's tools are `execute` CLOSURES living in the caller's
|
|
7
|
+
* process, but `--mcp-config` can only point the CLI at a COMMAND — so the tool surface has to cross
|
|
8
|
+
* a process boundary that the closures cannot. This stub is the bridge for exactly that hop:
|
|
9
|
+
*
|
|
10
|
+
* claude CLI <--stdio JSON-RPC--> THIS FILE <--unix socket--> parent (caller's closures)
|
|
11
|
+
*
|
|
12
|
+
* It is deliberately dumb. Everything that can decide anything — the manifest, the gate, the spin
|
|
13
|
+
* guards, redaction — lives parent-side, so the stub can never become a second place where policy
|
|
14
|
+
* is enforced (and never a second place where a secret can be logged: it writes no files at all).
|
|
15
|
+
*
|
|
16
|
+
* The one rule it does enforce is the BA-15 principle at the process boundary: EVERY failure of the
|
|
17
|
+
* hop is returned as a tool RESULT, never as a crash and never as a hang. A dead parent, a malformed
|
|
18
|
+
* frame and a slow handler all become `isError` results the model can read and react to — validated
|
|
19
|
+
* live before this shipped (probe 6: parent killed mid-call, session ended in 5.9s, no hang).
|
|
20
|
+
*
|
|
21
|
+
* Not a package export and never `require`d by the library — it is spawned as `node <this file>`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const { createInterface } = require('readline');
|
|
25
|
+
const net = require('net');
|
|
26
|
+
|
|
27
|
+
const SOCK = process.env.BAREAGENT_BRIDGE_SOCK;
|
|
28
|
+
const CALL_TIMEOUT_MS = Number(process.env.BAREAGENT_BRIDGE_TIMEOUT_MS) || 120000;
|
|
29
|
+
|
|
30
|
+
const send = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
|
|
31
|
+
const errResult = (text) => ({ content: [{ type: 'text', text }], isError: true });
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One request/response over the unix socket.
|
|
35
|
+
*
|
|
36
|
+
* A FRESH connection per call is deliberate: a pooled socket opened while the parent was alive
|
|
37
|
+
* hides a parent that died later, turning "the bridge is gone" into a silent hang. Connecting per
|
|
38
|
+
* call makes a dead parent surface immediately, as an error result.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} payload
|
|
41
|
+
* @returns {Promise<any>} always resolves — never rejects, so no failure can escape as a crash.
|
|
42
|
+
*/
|
|
43
|
+
function bridge(payload) {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
let settled = false;
|
|
46
|
+
const done = (v) => { if (!settled) { settled = true; resolve(v); } };
|
|
47
|
+
if (!SOCK) return done({ error: 'bridge not configured' });
|
|
48
|
+
|
|
49
|
+
const sock = net.createConnection(SOCK);
|
|
50
|
+
let buf = '';
|
|
51
|
+
|
|
52
|
+
// A hung parent handler must not hang the CLI session. Bounded, always.
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
try { sock.destroy(); } catch (_) { /* already gone */ }
|
|
55
|
+
done({ error: `bridge timeout after ${CALL_TIMEOUT_MS}ms` });
|
|
56
|
+
}, CALL_TIMEOUT_MS);
|
|
57
|
+
|
|
58
|
+
const finish = (v) => {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
try { sock.end(); } catch (_) { /* already gone */ }
|
|
61
|
+
done(v);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
sock.on('connect', () => sock.write(JSON.stringify(payload) + '\n'));
|
|
65
|
+
sock.on('data', (d) => {
|
|
66
|
+
buf += d;
|
|
67
|
+
const nl = buf.indexOf('\n');
|
|
68
|
+
if (nl === -1) return; // frame incomplete — wait for the rest
|
|
69
|
+
try { finish(JSON.parse(buf.slice(0, nl))); }
|
|
70
|
+
catch (err) { finish({ error: `bridge sent malformed JSON: ${/** @type {Error} */ (err).message}` }); }
|
|
71
|
+
});
|
|
72
|
+
sock.on('error', (err) => finish({ error: `bridge unreachable: ${/** @type {any} */ (err).code || /** @type {Error} */ (err).message}` }));
|
|
73
|
+
sock.on('close', () => finish({ error: 'bridge closed before responding' }));
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const rl = createInterface({ input: process.stdin });
|
|
78
|
+
rl.on('line', async (line) => {
|
|
79
|
+
if (!line.trim()) return;
|
|
80
|
+
let msg;
|
|
81
|
+
try { msg = JSON.parse(line); } catch (_) { return; } // not JSON — not ours to answer
|
|
82
|
+
const { id, method, params } = msg;
|
|
83
|
+
|
|
84
|
+
if (method === 'initialize') {
|
|
85
|
+
return send({ jsonrpc: '2.0', id, result: {
|
|
86
|
+
protocolVersion: (params && params.protocolVersion) || '2025-06-18',
|
|
87
|
+
capabilities: { tools: {} },
|
|
88
|
+
serverInfo: { name: 'bareagent', version: '1' },
|
|
89
|
+
} });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (method === 'tools/list') {
|
|
93
|
+
// The PARENT owns the manifest — a tool definition is never duplicated here.
|
|
94
|
+
const res = await bridge({ op: 'list' });
|
|
95
|
+
return send({ jsonrpc: '2.0', id, result: { tools: (res && res.tools) || [] } });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (method === 'tools/call') {
|
|
99
|
+
const res = await bridge({ op: 'call', name: params && params.name, args: (params && params.arguments) || {} });
|
|
100
|
+
if (res && res.error) return send({ jsonrpc: '2.0', id, result: errResult(`TOOL BRIDGE ERROR: ${res.error}`) });
|
|
101
|
+
if (res && res.isError) return send({ jsonrpc: '2.0', id, result: errResult(String(res.text)) });
|
|
102
|
+
return send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: String((res && res.text) ?? '') }] } });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (method === 'ping') return send({ jsonrpc: '2.0', id, result: {} });
|
|
106
|
+
if (id !== undefined) send({ jsonrpc: '2.0', id, error: { code: -32601, message: `method not found: ${method}` } });
|
|
107
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/** Path to the stdio stub the CLI spawns. Resolved once — it ships inside the package. */
|
|
2
|
+
export const STUB_PATH: string;
|
|
3
|
+
/** The MCP server name the CLI sees; tool names reach the model as `mcp__bareagent__<tool>`. */
|
|
4
|
+
export const SERVER_NAME: "bareagent";
|
|
5
|
+
/** Mirrors the Loop's own BA-11 / BA-12 defaults so native mode is not quietly laxer. */
|
|
6
|
+
export const DEFAULT_MAX_CONSECUTIVE_DENIALS: 3;
|
|
7
|
+
export const DEFAULT_MAX_IDENTICAL_TOOL_ERRORS: 3;
|
|
8
|
+
/**
|
|
9
|
+
* Stable key for BA-12's identical-tool-error guard. NARROWEST trigger by design: only a
|
|
10
|
+
* BYTE-IDENTICAL repeat counts. Counting any consecutive failure was measured to punish the
|
|
11
|
+
* recovery it exists to protect (a model varying args while working through an ENOENT).
|
|
12
|
+
* @param {string} name
|
|
13
|
+
* @param {any} args
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function toolErrorKey(name: string, args: any): string;
|
|
17
|
+
/**
|
|
18
|
+
* Clamp a diagnostic built from a thrown value of unknown shape before it can ride into a
|
|
19
|
+
* `receipts`/audit string. A non-Error throw serialized wholesale has previously pushed a secret
|
|
20
|
+
* into a plaintext audit log (the F16/BA-1 capture class), and an append-only log that captures a
|
|
21
|
+
* key captures it forever. Conventional fields only, hard length cap, never the whole object.
|
|
22
|
+
* @param {any} err
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
export function safeErrorText(err: any): string;
|
|
26
|
+
/**
|
|
27
|
+
* The parent-side bridge: a unix-socket server backed by the caller's in-process tool closures,
|
|
28
|
+
* with the gate and both spin guards wired at the one seam that sees every call.
|
|
29
|
+
*
|
|
30
|
+
* Unix socket, not loopback TCP: a library must not open a listening network port as a side effect
|
|
31
|
+
* of running a tool loop. The socket lives in a 0700 directory and is itself chmod 0600.
|
|
32
|
+
*
|
|
33
|
+
* @param {object} opts
|
|
34
|
+
* @param {import('../types').ToolDef[]} opts.tools - the caller's tools (with live `execute` closures).
|
|
35
|
+
* @param {Function|null} [opts.policy] - the SAME contract as `Loop({policy})`: only `true` allows;
|
|
36
|
+
* a string is the deny reason fed back verbatim.
|
|
37
|
+
* @param {any} [opts.ctx] - opaque governance ctx, forwarded to `policy` unchanged.
|
|
38
|
+
* @param {number} [opts.maxConsecutiveDenials]
|
|
39
|
+
* @param {number} [opts.maxIdenticalToolErrors]
|
|
40
|
+
* @returns {Promise<{sockPath: string, state: any, close: () => void}>}
|
|
41
|
+
*/
|
|
42
|
+
export function createBridge({ tools, policy, ctx, maxConsecutiveDenials, maxIdenticalToolErrors }: {
|
|
43
|
+
tools: import("../types").ToolDef[];
|
|
44
|
+
policy?: Function | null | undefined;
|
|
45
|
+
ctx?: any;
|
|
46
|
+
maxConsecutiveDenials?: number | undefined;
|
|
47
|
+
maxIdenticalToolErrors?: number | undefined;
|
|
48
|
+
}): Promise<{
|
|
49
|
+
sockPath: string;
|
|
50
|
+
state: any;
|
|
51
|
+
close: () => void;
|
|
52
|
+
}>;
|
|
53
|
+
/**
|
|
54
|
+
* @param {string|null|undefined} subtype
|
|
55
|
+
* @returns {{stopReason: string|null, error: string|null}}
|
|
56
|
+
*/
|
|
57
|
+
export function classifySubtype(subtype: string | null | undefined): {
|
|
58
|
+
stopReason: string | null;
|
|
59
|
+
error: string | null;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Decide the session's terminal tag. Pure, so the precedence is testable without a live CLI.
|
|
63
|
+
*
|
|
64
|
+
* The ordering is the whole point:
|
|
65
|
+
* 1. `terminal` — a guard tripped and WE killed the session; most specific, and it explains why
|
|
66
|
+
* the CLI's own subtype is missing or odd.
|
|
67
|
+
* 2. bridge broken — a tool call the CLI ATTEMPTED but the bridge never SERVED never reached the
|
|
68
|
+
* caller's closure. This must outrank a reported `success`, because a session
|
|
69
|
+
* whose tools were all broken still ends `subtype:'success'` — the model writes
|
|
70
|
+
* a tidy final answer explaining that its tools failed, and mapping that onto
|
|
71
|
+
* `error:null` reports a run in which nothing worked as converged.
|
|
72
|
+
* 3. `timedOut` — we killed it on the wall clock.
|
|
73
|
+
* 4. the subtype — what the CLI itself said.
|
|
74
|
+
*
|
|
75
|
+
* @param {{terminal: string|null, bridgeDown: boolean, attempted: number, served: number, timedOut: boolean, subtype: string|null|undefined}} facts
|
|
76
|
+
* @returns {{stopReason: string|null, error: string|null}}
|
|
77
|
+
*/
|
|
78
|
+
export function resolveSessionError(facts: {
|
|
79
|
+
terminal: string | null;
|
|
80
|
+
bridgeDown: boolean;
|
|
81
|
+
attempted: number;
|
|
82
|
+
served: number;
|
|
83
|
+
timedOut: boolean;
|
|
84
|
+
subtype: string | null | undefined;
|
|
85
|
+
}): {
|
|
86
|
+
stopReason: string | null;
|
|
87
|
+
error: string | null;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Line-oriented processor for the CLI's `stream-json` stdout.
|
|
91
|
+
*
|
|
92
|
+
* FRAMING IS SYNCHRONOUS. The obvious shape — an `async` stdout handler that `await`s `onTurn`
|
|
93
|
+
* inside its parse loop — has a data-corruption bug: while the `await` is suspended, the next
|
|
94
|
+
* `'data'` event re-enters the handler and mutates the SHARED line buffer, so the suspended parse
|
|
95
|
+
* resumes against a buffer that moved under it (dropped/duplicated/mangled lines). It is invisible
|
|
96
|
+
* with a synchronous `onTurn` (the await resolves on the microtask queue before the next macrotask
|
|
97
|
+
* `'data'` event) and REACHABLE the moment `onTurn` does real async work — e.g. a wired gate's
|
|
98
|
+
* `gate.record`, which is exactly the intended consumer. So framing never awaits: it parses lines
|
|
99
|
+
* synchronously and pushes per-turn forwards onto a queue that a SERIAL async drainer empties in
|
|
100
|
+
* arrival order. Extracted from `runSession` so this can be unit-tested without spawning the CLI.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} o
|
|
103
|
+
* @param {Function|null} o.onTurn
|
|
104
|
+
* @param {any} o.ctx
|
|
105
|
+
* @param {number} o.startedAt
|
|
106
|
+
* @param {(err: Error) => void} o.onHalt - called if a forwarded `onTurn` throws a HaltError.
|
|
107
|
+
*/
|
|
108
|
+
export function createSessionStream({ onTurn, ctx, startedAt, onHalt }: {
|
|
109
|
+
onTurn: Function | null;
|
|
110
|
+
ctx: any;
|
|
111
|
+
startedAt: number;
|
|
112
|
+
onHalt: (err: Error) => void;
|
|
113
|
+
}): {
|
|
114
|
+
/** Feed a stdout chunk. Pure synchronous framing — never awaits. */
|
|
115
|
+
feed(chunk: Buffer | string): void;
|
|
116
|
+
/** Await every queued per-turn forward — call before resolving the session. */
|
|
117
|
+
flush(): Promise<void>;
|
|
118
|
+
readonly turns: import("../types").Usage[];
|
|
119
|
+
readonly attempted: number;
|
|
120
|
+
readonly final: any;
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* Run ONE native CLI session to completion, streaming per-turn usage as it arrives.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} opts
|
|
126
|
+
* @returns {Promise<any>}
|
|
127
|
+
*/
|
|
128
|
+
export function runSession(opts: object): Promise<any>;
|