bare-agent 0.24.0 → 0.26.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 +7 -2
- package/package.json +1 -1
- package/src/loop.d.ts +18 -0
- package/src/loop.js +56 -2
- package/src/provider-clipipe.d.ts +15 -0
- package/src/provider-clipipe.js +70 -2
- package/src/recurse.d.ts +16 -0
- package/src/recurse.js +21 -0
- package/types/index.d.ts +8 -0
package/README.md
CHANGED
|
@@ -119,7 +119,7 @@ console.log(result.count, result.matchedIds); // a code-derived count + the id
|
|
|
119
119
|
|
|
120
120
|
> **⚠️ Cost is open by design — wire a cap.** `recurse()` adds no intrinsic total-work limit. On the model-driven default a node can spawn up to ~100 children per level, each recursing to `maxDepth` (default 3), so **token / $ spend compounds and is bounded only by your gate** — not by recurse. Run it **with bareguard** (`ctx.policy`, which enforces depth/budget/call caps) **or with some token/USD cap** for any non-trivial or untrusted task; ungoverned, a weak model that over-decomposes *will* burn tokens. For a hard local brake without a gate, set `maxDepth: 1` (flat, no nesting). The forced modes (`mode:'fanout'` / `'partition'`) are bounded by a deterministic count + concurrency cap; the open path is the model-driven default.
|
|
121
121
|
|
|
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. `require('bare-agent/bareguard')`
|
|
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' }`. `require('bare-agent/bareguard')`
|
|
123
123
|
|
|
124
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.
|
|
125
125
|
|
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.26.0 | Node.js >= 18 | zero required deps (`bareguard ^0.9.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
|
|
|
@@ -59,6 +59,7 @@ Eight entry points:
|
|
|
59
59
|
| Catch typed errors programmatically | ProviderError, ToolError, TimeoutError, CircuitOpenError |
|
|
60
60
|
| Cache identical planner calls | Planner({ cacheTTL: 60000 }) |
|
|
61
61
|
| Stream CLIPipe output in real-time | CLIPipeProvider({ onChunk: fn }) |
|
|
62
|
+
| Get real usage + cost from a CLI provider | CLIPipeProvider({ parse: 'claude-json' }) |
|
|
62
63
|
| Browse the web (inline snapshots) | createBrowsingTools + Loop |
|
|
63
64
|
| Browse the web (token-efficient, disk-based) | `barebrowse` CLI session — snapshots to `.barebrowse/*.yml` |
|
|
64
65
|
| Assess website privacy risk | createBrowsingTools + Loop (requires `npm install wearehere`) |
|
|
@@ -379,6 +380,8 @@ if (result.error?.startsWith('halt:')) {
|
|
|
379
380
|
|
|
380
381
|
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.
|
|
381
382
|
|
|
383
|
+
**Deny-spin short-circuit (`maxConsecutiveDenials`, default 3, v0.25+).** A *non-halt* deny (a `policy` verdict that isn't `true` — e.g. a `humanChannel: deny`, an allowlist miss, a `content`/`fs.writeScope` block) is **advisory**: it's fed back to the model as a tool result so the model can pivot to a different allowed tool. But a model that keeps retrying the *same* denied action would otherwise spin every round until your `budget.maxCostUsd` finally halts it — burning the whole cap with no progress (this bit a coding agent whose write kept tripping `content.askPatterns`). The Loop now counts **consecutive** denials (any allowed call resets the streak, preserving the pivot) and short-circuits at `maxConsecutiveDenials` with `result.error === 'denied:<tool>'` (a clean return, transcript sealed — never a throw). Check `result.error?.startsWith('denied:')` to distinguish a governance block from a completed run; set `maxConsecutiveDenials: 0` (or `Infinity`) on `new Loop({...})` to restore the pure-advisory behavior. Under `recurse`, a short-circuited worker returns a **labeled** `{ incomplete: true, blocker: 'governance-deny' }` (and `receipts.blocker`) so you can widen scope / re-gate / escalate rather than read it as a model failure.
|
|
384
|
+
|
|
382
385
|
Legacy `wrapTool` / `wrapTools` are retained as deprecation shims (one-shot console warning, removal in 1.0). Migration: replace `wrapTools(tools)` at `loop.run()` with `filterTools(tools)` once upfront + `onLlmResult` / `onToolResult` on `new Loop({...})` to pick up LLM-cost recording and `_ctx` threading.
|
|
383
386
|
|
|
384
387
|
**`actionTranslator` for bash/fs primitive activation (v0.10.1+).** Bareguard's `bashCheck` / `fsCheck` / `netCheck` only fire when `action.type === 'bash'` / `'read'` / `'write'` / `'fetch'`. The default action shape is `{type: toolName, args, _ctx}` which matches `tools.denylist` / `tools.allowlist` but does NOT activate those primitives. Adopters who want both pass `wireGate(gate, { actionTranslator })`. Since bareguard 0.4.1+, the primitives read fields from either flat (`action.cmd`) or nested (`action.args.cmd` / `.command`) shapes, so you can pass args through verbatim:
|
|
@@ -737,9 +740,11 @@ new Ollama({ model: 'llama3.2', url: 'http://localhost:11434' })
|
|
|
737
740
|
// CLIPipe — pipe prompts to any CLI tool via stdin/stdout
|
|
738
741
|
new CLIPipe({ command: 'claude', args: ['--print'], systemPromptFlag: '--system-prompt', timeout: 30000 })
|
|
739
742
|
new CLIPipe({ command: 'ollama', args: ['run', 'llama3.2'] })
|
|
743
|
+
// CLIPipe structured output (v0.26.0+) — map a CLI's JSON envelope to real usage + cost
|
|
744
|
+
new CLIPipe({ command: 'claude', args: ['-p', '--output-format', 'json'], parse: 'claude-json' })
|
|
740
745
|
```
|
|
741
746
|
|
|
742
|
-
All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model? }`. The optional `model` (v0.16.1+) is the id the response was produced by — Loop prefers it over `provider.model` for cost accounting. CLIPipe
|
|
747
|
+
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).
|
|
743
748
|
|
|
744
749
|
**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).
|
|
745
750
|
|
package/package.json
CHANGED
package/src/loop.d.ts
CHANGED
|
@@ -52,6 +52,16 @@ export type LoopOptions = {
|
|
|
52
52
|
*/
|
|
53
53
|
onLlmResult?: Function | undefined;
|
|
54
54
|
onToolResult?: Function | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* - BA-11 safety net (default 3). Short-circuit the run when
|
|
57
|
+
* `policy` denies this many tool calls IN A ROW with no allowed call in between — a governance deny is
|
|
58
|
+
* not a recoverable tool error, so a model that keeps retrying variants of a denied action would
|
|
59
|
+
* otherwise burn the budget to the cap without progress (probe-16: 16 calls, sensor never reached). Any
|
|
60
|
+
* tool call that PASSES policy resets the streak, preserving allowlist-safe pivoting (deny X → allow Y).
|
|
61
|
+
* The run returns cleanly with `error: 'denied:<tool>'` (mirrors the halt return; never throws even under
|
|
62
|
+
* throwOnError). Set `0` or `Infinity` to disable (restores pre-BA-11 advisory-deny behavior).
|
|
63
|
+
*/
|
|
64
|
+
maxConsecutiveDenials?: number | undefined;
|
|
55
65
|
/**
|
|
56
66
|
* - Removed in v0.8; presence throws a migration error.
|
|
57
67
|
*/
|
|
@@ -79,6 +89,7 @@ export class Loop {
|
|
|
79
89
|
throwOnError: boolean;
|
|
80
90
|
store: import("../types").Store | null;
|
|
81
91
|
policy: Function | null;
|
|
92
|
+
maxConsecutiveDenials: number;
|
|
82
93
|
assemble: Function | null;
|
|
83
94
|
trim: Function | null;
|
|
84
95
|
onLlmResult: Function | null;
|
|
@@ -228,6 +239,13 @@ export function estimateCost(model: string | null, usage: Usage | null): number
|
|
|
228
239
|
* gate.record (via wireGate). `event.kind` discriminates the source: `'turn'` for a main-loop round,
|
|
229
240
|
* `'summarize'` for an out-of-band `ctx.summarize` call (R-C6). Both count against the budget.
|
|
230
241
|
* @property {Function} [onToolResult]
|
|
242
|
+
* @property {number} [maxConsecutiveDenials] - BA-11 safety net (default 3). Short-circuit the run when
|
|
243
|
+
* `policy` denies this many tool calls IN A ROW with no allowed call in between — a governance deny is
|
|
244
|
+
* not a recoverable tool error, so a model that keeps retrying variants of a denied action would
|
|
245
|
+
* otherwise burn the budget to the cap without progress (probe-16: 16 calls, sensor never reached). Any
|
|
246
|
+
* tool call that PASSES policy resets the streak, preserving allowlist-safe pivoting (deny X → allow Y).
|
|
247
|
+
* The run returns cleanly with `error: 'denied:<tool>'` (mirrors the halt return; never throws even under
|
|
248
|
+
* throwOnError). Set `0` or `Infinity` to disable (restores pre-BA-11 advisory-deny behavior).
|
|
231
249
|
* @property {number} [maxRounds] - Removed in v0.8; presence throws a migration error.
|
|
232
250
|
*/
|
|
233
251
|
/** @type {Record<string, {in: number, out: number, cacheReadMult?: number, cacheWriteMult?: number}>} */
|
package/src/loop.js
CHANGED
|
@@ -49,6 +49,13 @@ const { ToolError, HaltError } = require('./errors');
|
|
|
49
49
|
* gate.record (via wireGate). `event.kind` discriminates the source: `'turn'` for a main-loop round,
|
|
50
50
|
* `'summarize'` for an out-of-band `ctx.summarize` call (R-C6). Both count against the budget.
|
|
51
51
|
* @property {Function} [onToolResult]
|
|
52
|
+
* @property {number} [maxConsecutiveDenials] - BA-11 safety net (default 3). Short-circuit the run when
|
|
53
|
+
* `policy` denies this many tool calls IN A ROW with no allowed call in between — a governance deny is
|
|
54
|
+
* not a recoverable tool error, so a model that keeps retrying variants of a denied action would
|
|
55
|
+
* otherwise burn the budget to the cap without progress (probe-16: 16 calls, sensor never reached). Any
|
|
56
|
+
* tool call that PASSES policy resets the streak, preserving allowlist-safe pivoting (deny X → allow Y).
|
|
57
|
+
* The run returns cleanly with `error: 'denied:<tool>'` (mirrors the halt return; never throws even under
|
|
58
|
+
* throwOnError). Set `0` or `Infinity` to disable (restores pre-BA-11 advisory-deny behavior).
|
|
52
59
|
* @property {number} [maxRounds] - Removed in v0.8; presence throws a migration error.
|
|
53
60
|
*/
|
|
54
61
|
|
|
@@ -146,6 +153,23 @@ function estimateCost(model, usage) {
|
|
|
146
153
|
return Number.isFinite(cost) ? cost : null;
|
|
147
154
|
}
|
|
148
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Resolve the priced USD for a round. A provider MAY report its own authoritative `costUsd` on the
|
|
158
|
+
* GenerateResult (e.g. CLIPipeProvider `parse:'claude-json'` surfacing the claude CLI's own
|
|
159
|
+
* `total_cost_usd` — a real price with NO local rate table). When present as a FINITE number it wins
|
|
160
|
+
* over the rate-table estimate — including `0`, a valid priced value (a subscription/marginal-$0 run),
|
|
161
|
+
* which stays 'priced', never demoted to the null/unpriced sentinel. A non-finite provider cost
|
|
162
|
+
* (±Inf/NaN) is NOT a price → fall through to estimateCost (same couldn't-price guard as above).
|
|
163
|
+
* @param {any} result - the GenerateResult from provider.generate()
|
|
164
|
+
* @param {string|null} model
|
|
165
|
+
* @param {Usage|null} usage
|
|
166
|
+
* @returns {number|null}
|
|
167
|
+
*/
|
|
168
|
+
function resolveRoundCost(result, model, usage) {
|
|
169
|
+
if (result && Number.isFinite(result.costUsd)) return result.costUsd;
|
|
170
|
+
return estimateCost(model, usage);
|
|
171
|
+
}
|
|
172
|
+
|
|
149
173
|
// R-C6: default instruction for the provider-bound `ctx.summarize` lent to the assemble seam.
|
|
150
174
|
const DEFAULT_SUMMARY_INSTRUCTION =
|
|
151
175
|
'You are a precise conversation summarizer. Produce a concise, factual summary of the following ' +
|
|
@@ -212,6 +236,13 @@ class Loop {
|
|
|
212
236
|
throw new Error('[Loop] options.policy must be a function (toolName, args, ctx) => true | string');
|
|
213
237
|
}
|
|
214
238
|
this.policy = options.policy || null;
|
|
239
|
+
// BA-11 deny-spin guard. Default 3; 0/Infinity/non-finite disables (restores advisory-deny behavior).
|
|
240
|
+
// Validated only if provided so an explicit 0 is honored as "off" (Infinity also disables).
|
|
241
|
+
if (options.maxConsecutiveDenials != null
|
|
242
|
+
&& (typeof options.maxConsecutiveDenials !== 'number' || options.maxConsecutiveDenials < 0 || Number.isNaN(options.maxConsecutiveDenials))) {
|
|
243
|
+
throw new Error('[Loop] options.maxConsecutiveDenials must be a non-negative number (0 or Infinity disables)');
|
|
244
|
+
}
|
|
245
|
+
this.maxConsecutiveDenials = options.maxConsecutiveDenials != null ? options.maxConsecutiveDenials : 3;
|
|
215
246
|
if (options.assemble != null && typeof options.assemble !== 'function') {
|
|
216
247
|
throw new Error('[Loop] options.assemble must be a function (msgs, info) => msgs');
|
|
217
248
|
}
|
|
@@ -360,6 +391,9 @@ class Loop {
|
|
|
360
391
|
// unsupported/deprecated) and retried without it. Surfaced on the result so an upstream receipt
|
|
361
392
|
// (recurse's refineLeaf) can report the EFFECTIVE temperature rather than the ignored request.
|
|
362
393
|
let temperatureDropped = false;
|
|
394
|
+
// BA-11: consecutive policy-deny counter (reset by any tool call that PASSES policy). When it reaches
|
|
395
|
+
// this.maxConsecutiveDenials the run short-circuits cleanly — see the deny block below.
|
|
396
|
+
let consecutiveDenials = 0;
|
|
363
397
|
|
|
364
398
|
// The meter (Feature 3): bareagent is the canonical run counter. Accumulates across rounds and is
|
|
365
399
|
// returned as `result.metrics`. `tokens` is CUMULATIVE over all four tiers (fixes the last-round-only
|
|
@@ -455,7 +489,7 @@ class Loop {
|
|
|
455
489
|
const result = await loop.provider.generate(prompt, [], { temperature: 0, ...genOpts });
|
|
456
490
|
const usage = (result && result.usage) || null;
|
|
457
491
|
const model = (result && result.model) || loop.provider.model || null;
|
|
458
|
-
const cost =
|
|
492
|
+
const cost = resolveRoundCost(result, model, usage);
|
|
459
493
|
if (cost !== null) { totalCost += cost; pricedAny = true; }
|
|
460
494
|
addUsage(usage); // summarize tokens are real spend → count them in the cumulative meter
|
|
461
495
|
metrics.context.summaries++; // §3.6 CE-activity rollup
|
|
@@ -599,7 +633,7 @@ class Loop {
|
|
|
599
633
|
// Prefer the model the response reports (robust when provider.model is absent or varies per
|
|
600
634
|
// response — e.g. FallbackProvider, or a CircuitBreaker-wrapped provider that drops .model).
|
|
601
635
|
const model = result.model || this.provider.model || null;
|
|
602
|
-
const roundCost =
|
|
636
|
+
const roundCost = resolveRoundCost(result, model, lastUsage);
|
|
603
637
|
if (roundCost !== null) totalCost += roundCost;
|
|
604
638
|
|
|
605
639
|
// Meter this round: count the turn, accumulate the four token tiers, and classify pricing —
|
|
@@ -731,10 +765,30 @@ class Loop {
|
|
|
731
765
|
: `[Loop] Tool "${tc.name}" denied by policy`;
|
|
732
766
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content: reason });
|
|
733
767
|
this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, denied: true, reason } });
|
|
768
|
+
// BA-11: a governance deny is not a recoverable tool error. Count consecutive denials; when the
|
|
769
|
+
// model keeps retrying denied actions (proven live: 8 in a row before giving up) short-circuit the
|
|
770
|
+
// run rather than let it burn the budget to the cap. The streak resets on any ALLOWED tool call
|
|
771
|
+
// (below), so a legit deny-then-pivot (deny X → allow Y) never trips this. 0/Infinity disables.
|
|
772
|
+
consecutiveDenials += 1;
|
|
773
|
+
if (this.maxConsecutiveDenials > 0 && Number.isFinite(this.maxConsecutiveDenials)
|
|
774
|
+
&& consecutiveDenials >= this.maxConsecutiveDenials) {
|
|
775
|
+
const denyTag = `denied:${tc.name}`;
|
|
776
|
+
// Pair any still-dangling tool_calls from this round so the returned transcript stays
|
|
777
|
+
// provider-valid (same seal the halt path uses), then exit cleanly — no throw even under
|
|
778
|
+
// throwOnError, mirroring the governance-halt contract.
|
|
779
|
+
sealDanglingToolCalls(msgs, denyTag);
|
|
780
|
+
this._reportError('denied', new Error(`policy denied ${consecutiveDenials} consecutive tool calls (${tc.name})`), { rule: denyTag, denials: consecutiveDenials });
|
|
781
|
+
this._safeEmit({ type: 'loop:done', data: { text: '', denied: true, rule: denyTag, cost: totalCost } });
|
|
782
|
+
return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, msgs, metrics: finalizeMetrics() };
|
|
783
|
+
}
|
|
734
784
|
continue;
|
|
735
785
|
}
|
|
736
786
|
}
|
|
737
787
|
|
|
788
|
+
// BA-11: reaching here means this tool call PASSED policy (or there is no policy) — progress, so the
|
|
789
|
+
// consecutive-deny streak resets. A single deny followed by an allowed call never trips the guard.
|
|
790
|
+
consecutiveDenials = 0;
|
|
791
|
+
|
|
738
792
|
const toolStartedAt = Date.now();
|
|
739
793
|
let toolResult;
|
|
740
794
|
let toolError;
|
|
@@ -30,6 +30,10 @@ export type CLIPipeOptions = {
|
|
|
30
30
|
* - Called with each stdout chunk as it streams.
|
|
31
31
|
*/
|
|
32
32
|
onChunk?: ((chunk: string) => void) | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* - Opt-in structured-output parser for stdout. Default (unset) returns stdout verbatim as `text` with zero usage (no behavior change). `'claude-json'` is a shipped preset for `claude -p --output-format json`: it maps the CLI's result envelope onto `GenerateResult` (text←`result`, usage←`usage.*`, model←first `modelUsage` key, costUsd←`total_cost_usd`) and throws `ProviderError` on malformed JSON or an error envelope (`is_error`/non-success subtype). A function is the CLI-agnostic escape hatch: it receives trimmed stdout and returns a partial `GenerateResult` (merged over defaults); throw to signal a parse failure.
|
|
35
|
+
*/
|
|
36
|
+
parse?: "claude-json" | ((stdout: string) => Partial<GenerateResult>) | undefined;
|
|
33
37
|
};
|
|
34
38
|
/** @typedef {import('../types').Message} Message */
|
|
35
39
|
/** @typedef {import('../types').ToolDef} ToolDef */
|
|
@@ -43,6 +47,7 @@ export type CLIPipeOptions = {
|
|
|
43
47
|
* @property {number} [timeout=30000] - Timeout in milliseconds.
|
|
44
48
|
* @property {string} [systemPromptFlag] - CLI flag for system prompt (e.g. '--system'). When set, system messages are extracted and passed via this flag instead of stdin.
|
|
45
49
|
* @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
|
|
50
|
+
* @property {'claude-json'|((stdout: string) => Partial<GenerateResult>)} [parse] - Opt-in structured-output parser for stdout. Default (unset) returns stdout verbatim as `text` with zero usage (no behavior change). `'claude-json'` is a shipped preset for `claude -p --output-format json`: it maps the CLI's result envelope onto `GenerateResult` (text←`result`, usage←`usage.*`, model←first `modelUsage` key, costUsd←`total_cost_usd`) and throws `ProviderError` on malformed JSON or an error envelope (`is_error`/non-success subtype). A function is the CLI-agnostic escape hatch: it receives trimmed stdout and returns a partial `GenerateResult` (merged over defaults); throw to signal a parse failure.
|
|
46
51
|
*/
|
|
47
52
|
export class CLIPipeProvider {
|
|
48
53
|
/**
|
|
@@ -58,6 +63,7 @@ export class CLIPipeProvider {
|
|
|
58
63
|
timeout: number;
|
|
59
64
|
systemPromptFlag: string | null;
|
|
60
65
|
onChunk: ((chunk: string) => void) | null;
|
|
66
|
+
parse: "claude-json" | ((stdout: string) => Partial<GenerateResult>) | null;
|
|
61
67
|
/**
|
|
62
68
|
* Generate a response by piping messages to the CLI command.
|
|
63
69
|
* @param {Message[]} messages - Conversation messages in OpenAI format.
|
|
@@ -70,6 +76,15 @@ export class CLIPipeProvider {
|
|
|
70
76
|
* @throws {Error} `[CLIPipeProvider] process produced no output` — when stdout is empty.
|
|
71
77
|
*/
|
|
72
78
|
generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
|
|
79
|
+
/**
|
|
80
|
+
* Map the `claude -p --output-format json` result envelope onto a normalized GenerateResult.
|
|
81
|
+
* The caller explicitly opted into structured output, so a malformed or error envelope is a LOUD
|
|
82
|
+
* ProviderError — never a silent fall-back to raw text.
|
|
83
|
+
* @param {string} stdout - Trimmed stdout from the CLI.
|
|
84
|
+
* @returns {GenerateResult}
|
|
85
|
+
* @throws {ProviderError} On non-JSON stdout, or an error envelope (`is_error` / non-success subtype).
|
|
86
|
+
*/
|
|
87
|
+
_parseClaudeJson(stdout: string): GenerateResult;
|
|
73
88
|
/**
|
|
74
89
|
* Convert OpenAI-format messages to a plain text prompt.
|
|
75
90
|
* @param {Message[]} messages
|
package/src/provider-clipipe.js
CHANGED
|
@@ -16,6 +16,7 @@ const { ProviderError } = require('./errors');
|
|
|
16
16
|
* @property {number} [timeout=30000] - Timeout in milliseconds.
|
|
17
17
|
* @property {string} [systemPromptFlag] - CLI flag for system prompt (e.g. '--system'). When set, system messages are extracted and passed via this flag instead of stdin.
|
|
18
18
|
* @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
|
|
19
|
+
* @property {'claude-json'|((stdout: string) => Partial<GenerateResult>)} [parse] - Opt-in structured-output parser for stdout. Default (unset) returns stdout verbatim as `text` with zero usage (no behavior change). `'claude-json'` is a shipped preset for `claude -p --output-format json`: it maps the CLI's result envelope onto `GenerateResult` (text←`result`, usage←`usage.*`, model←first `modelUsage` key, costUsd←`total_cost_usd`) and throws `ProviderError` on malformed JSON or an error envelope (`is_error`/non-success subtype). A function is the CLI-agnostic escape hatch: it receives trimmed stdout and returns a partial `GenerateResult` (merged over defaults); throw to signal a parse failure.
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
class CLIPipeProvider {
|
|
@@ -33,6 +34,10 @@ class CLIPipeProvider {
|
|
|
33
34
|
this.timeout = options.timeout ?? 30000;
|
|
34
35
|
this.systemPromptFlag = options.systemPromptFlag || null;
|
|
35
36
|
this.onChunk = options.onChunk || null;
|
|
37
|
+
if (options.parse != null && options.parse !== 'claude-json' && typeof options.parse !== 'function') {
|
|
38
|
+
throw new Error("[CLIPipeProvider] options.parse must be 'claude-json' or a function");
|
|
39
|
+
}
|
|
40
|
+
this.parse = options.parse || null;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
/**
|
|
@@ -61,14 +66,77 @@ class CLIPipeProvider {
|
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
const prompt = this._formatPrompt(promptMessages);
|
|
64
|
-
const
|
|
69
|
+
const stdout = await this._spawn(prompt, extraArgs);
|
|
70
|
+
|
|
71
|
+
if (this.parse === 'claude-json') return this._parseClaudeJson(stdout);
|
|
72
|
+
if (typeof this.parse === 'function') {
|
|
73
|
+
const partial = this.parse(stdout) || {};
|
|
74
|
+
return {
|
|
75
|
+
text: '',
|
|
76
|
+
toolCalls: [],
|
|
77
|
+
...partial,
|
|
78
|
+
usage: { inputTokens: 0, outputTokens: 0, ...(partial.usage || {}) },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
65
81
|
return {
|
|
66
|
-
text,
|
|
82
|
+
text: stdout,
|
|
67
83
|
toolCalls: [],
|
|
68
84
|
usage: { inputTokens: 0, outputTokens: 0 },
|
|
69
85
|
};
|
|
70
86
|
}
|
|
71
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Map the `claude -p --output-format json` result envelope onto a normalized GenerateResult.
|
|
90
|
+
* The caller explicitly opted into structured output, so a malformed or error envelope is a LOUD
|
|
91
|
+
* ProviderError — never a silent fall-back to raw text.
|
|
92
|
+
* @param {string} stdout - Trimmed stdout from the CLI.
|
|
93
|
+
* @returns {GenerateResult}
|
|
94
|
+
* @throws {ProviderError} On non-JSON stdout, or an error envelope (`is_error` / non-success subtype).
|
|
95
|
+
*/
|
|
96
|
+
_parseClaudeJson(stdout) {
|
|
97
|
+
let obj;
|
|
98
|
+
try {
|
|
99
|
+
obj = JSON.parse(stdout);
|
|
100
|
+
} catch (_) {
|
|
101
|
+
const preview = stdout.length > 200 ? `${stdout.slice(0, 200)}…` : stdout;
|
|
102
|
+
throw new ProviderError(`[CLIPipeProvider] parse:'claude-json' expected JSON on stdout, got: ${preview}`, /** @type {any} */ ({ status: 0 }));
|
|
103
|
+
}
|
|
104
|
+
if (!obj || typeof obj !== 'object') {
|
|
105
|
+
throw new ProviderError(`[CLIPipeProvider] parse:'claude-json' expected a JSON object, got ${obj === null ? 'null' : typeof obj}`, /** @type {any} */ ({ status: 0 }));
|
|
106
|
+
}
|
|
107
|
+
if (obj.is_error === true || obj.subtype !== 'success') {
|
|
108
|
+
const detail = typeof obj.result === 'string' ? obj.result : JSON.stringify(obj.result ?? null);
|
|
109
|
+
throw new ProviderError(`[CLIPipeProvider] claude CLI reported failure (subtype='${obj.subtype}'): ${detail}`, /** @type {any} */ ({ status: 0 }));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const u = (obj.usage && typeof obj.usage === 'object') ? obj.usage : {};
|
|
113
|
+
/** @type {import('../types').Usage} */
|
|
114
|
+
const usage = {
|
|
115
|
+
inputTokens: Number(u.input_tokens) || 0,
|
|
116
|
+
outputTokens: Number(u.output_tokens) || 0,
|
|
117
|
+
};
|
|
118
|
+
// Absent cache tiers mean the model didn't cache — omit rather than emit a synthetic 0 (per Usage docs).
|
|
119
|
+
if (Number.isFinite(u.cache_read_input_tokens)) usage.cacheReadTokens = u.cache_read_input_tokens;
|
|
120
|
+
if (Number.isFinite(u.cache_creation_input_tokens)) usage.cacheCreationTokens = u.cache_creation_input_tokens;
|
|
121
|
+
|
|
122
|
+
// `modelUsage` is an object keyed by model id (e.g. {"claude-opus-4-8[1m]": {...}}) — take the first key.
|
|
123
|
+
const model = (obj.modelUsage && typeof obj.modelUsage === 'object')
|
|
124
|
+
? (Object.keys(obj.modelUsage)[0] ?? null)
|
|
125
|
+
: null;
|
|
126
|
+
|
|
127
|
+
/** @type {GenerateResult} */
|
|
128
|
+
const result = {
|
|
129
|
+
text: typeof obj.result === 'string' ? obj.result : '',
|
|
130
|
+
toolCalls: [],
|
|
131
|
+
usage,
|
|
132
|
+
model,
|
|
133
|
+
};
|
|
134
|
+
// The CLI's own price is authoritative (subscription runs report an equivalent cost even at $0
|
|
135
|
+
// marginal) — feeds bareguard's USD axis with no local rate table. Only a finite number counts.
|
|
136
|
+
if (Number.isFinite(obj.total_cost_usd)) result.costUsd = obj.total_cost_usd;
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
72
140
|
/**
|
|
73
141
|
* Convert OpenAI-format messages to a plain text prompt.
|
|
74
142
|
* @param {Message[]} messages
|
package/src/recurse.d.ts
CHANGED
|
@@ -215,6 +215,11 @@ export type RecurseNode = {
|
|
|
215
215
|
verdict: Verdict | null;
|
|
216
216
|
incomplete: boolean;
|
|
217
217
|
halted: boolean;
|
|
218
|
+
/**
|
|
219
|
+
* - (BA-11) set to `'governance-deny'` when this node stopped because its Loop
|
|
220
|
+
* short-circuited a consecutive-policy-deny spin (not a model failure). Mirrors `RecurseResult.blocker`.
|
|
221
|
+
*/
|
|
222
|
+
blocker?: string | undefined;
|
|
218
223
|
/**
|
|
219
224
|
* - The worker Loop's `metrics.tokens`.
|
|
220
225
|
*/
|
|
@@ -287,6 +292,12 @@ export type RecurseResult = {
|
|
|
287
292
|
* back incomplete (§9 scenario 1) — the anti-survivor-sum signal, not a quiet undercount.
|
|
288
293
|
*/
|
|
289
294
|
missingSlices?: string[] | undefined;
|
|
295
|
+
/**
|
|
296
|
+
* - Present when `incomplete` for a specific, actionable reason. `'governance-deny'`
|
|
297
|
+
* (BA-11): the worker's Loop short-circuited after N consecutive policy denials rather than burn to the
|
|
298
|
+
* budget cap — the caller can widen scope / re-gate / escalate instead of reading it as a model failure.
|
|
299
|
+
*/
|
|
300
|
+
blocker?: string | undefined;
|
|
290
301
|
/**
|
|
291
302
|
* - The audit node for this call (RC-10).
|
|
292
303
|
*/
|
|
@@ -424,6 +435,8 @@ export type Slice = {
|
|
|
424
435
|
* @property {Verdict|null} verdict
|
|
425
436
|
* @property {boolean} incomplete
|
|
426
437
|
* @property {boolean} halted
|
|
438
|
+
* @property {string} [blocker] - (BA-11) set to `'governance-deny'` when this node stopped because its Loop
|
|
439
|
+
* short-circuited a consecutive-policy-deny spin (not a model failure). Mirrors `RecurseResult.blocker`.
|
|
427
440
|
* @property {object|null} tokens - The worker Loop's `metrics.tokens`.
|
|
428
441
|
* @property {{iterations: number, passed: boolean, temperatures: (number|null)[]}} [refineLeaf] - (BA-8) when this
|
|
429
442
|
* leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
|
|
@@ -449,6 +462,9 @@ export type Slice = {
|
|
|
449
462
|
* @property {any} [best] - The best partial answer when `incomplete` (RC-9).
|
|
450
463
|
* @property {string[]} [missingSlices] - When `incomplete` because a child failed: the sub-task(s) that came
|
|
451
464
|
* back incomplete (§9 scenario 1) — the anti-survivor-sum signal, not a quiet undercount.
|
|
465
|
+
* @property {string} [blocker] - Present when `incomplete` for a specific, actionable reason. `'governance-deny'`
|
|
466
|
+
* (BA-11): the worker's Loop short-circuited after N consecutive policy denials rather than burn to the
|
|
467
|
+
* budget cap — the caller can widen scope / re-gate / escalate instead of reading it as a model failure.
|
|
452
468
|
* @property {RecurseNode} receipts - The audit node for this call (RC-10).
|
|
453
469
|
*/
|
|
454
470
|
/**
|
package/src/recurse.js
CHANGED
|
@@ -290,6 +290,8 @@ function auditSafeCtx(ctx, overrides = {}) {
|
|
|
290
290
|
* @property {Verdict|null} verdict
|
|
291
291
|
* @property {boolean} incomplete
|
|
292
292
|
* @property {boolean} halted
|
|
293
|
+
* @property {string} [blocker] - (BA-11) set to `'governance-deny'` when this node stopped because its Loop
|
|
294
|
+
* short-circuited a consecutive-policy-deny spin (not a model failure). Mirrors `RecurseResult.blocker`.
|
|
293
295
|
* @property {object|null} tokens - The worker Loop's `metrics.tokens`.
|
|
294
296
|
* @property {{iterations: number, passed: boolean, temperatures: (number|null)[]}} [refineLeaf] - (BA-8) when this
|
|
295
297
|
* leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
|
|
@@ -316,6 +318,9 @@ function auditSafeCtx(ctx, overrides = {}) {
|
|
|
316
318
|
* @property {any} [best] - The best partial answer when `incomplete` (RC-9).
|
|
317
319
|
* @property {string[]} [missingSlices] - When `incomplete` because a child failed: the sub-task(s) that came
|
|
318
320
|
* back incomplete (§9 scenario 1) — the anti-survivor-sum signal, not a quiet undercount.
|
|
321
|
+
* @property {string} [blocker] - Present when `incomplete` for a specific, actionable reason. `'governance-deny'`
|
|
322
|
+
* (BA-11): the worker's Loop short-circuited after N consecutive policy denials rather than burn to the
|
|
323
|
+
* budget cap — the caller can widen scope / re-gate / escalate instead of reading it as a model failure.
|
|
319
324
|
* @property {RecurseNode} receipts - The audit node for this call (RC-10).
|
|
320
325
|
*/
|
|
321
326
|
|
|
@@ -510,6 +515,15 @@ async function recurse(task, ctx = {}, opts = {}) {
|
|
|
510
515
|
node.incomplete = true;
|
|
511
516
|
return { incomplete: true, best: out.text || null, receipts: node };
|
|
512
517
|
}
|
|
518
|
+
// BA-11: a deny-spin short-circuit. The Loop stopped the worker after N consecutive governance denials
|
|
519
|
+
// (a governance deny is not a recoverable tool error — retrying variants would burn to the budget cap;
|
|
520
|
+
// probe-16: 16 calls, sensor never reached → incomplete). Surface it as a clean, LABELED incomplete so a
|
|
521
|
+
// caller can tell a governance block apart from a model failure and act (widen scope, re-gate, escalate).
|
|
522
|
+
if (typeof out.error === 'string' && out.error.startsWith('denied:')) {
|
|
523
|
+
node.incomplete = true;
|
|
524
|
+
node.blocker = 'governance-deny';
|
|
525
|
+
return { incomplete: true, best: out.text || null, blocker: 'governance-deny', receipts: node };
|
|
526
|
+
}
|
|
513
527
|
if (out.error) {
|
|
514
528
|
node.incomplete = true;
|
|
515
529
|
return { incomplete: true, best: out.text || null, receipts: node };
|
|
@@ -673,6 +687,13 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
|
|
|
673
687
|
node.incomplete = true;
|
|
674
688
|
return { incomplete: true, best: null, receipts: node };
|
|
675
689
|
}
|
|
690
|
+
// BA-11: a deny-spin inside a refine attempt (the Loop short-circuited after N consecutive governance
|
|
691
|
+
// denials, rethrown at recurse.js as `denied:<tool>`) is a LABELED governance block, not a model fault.
|
|
692
|
+
if (typeof err?.message === 'string' && err.message.startsWith('denied:')) {
|
|
693
|
+
node.incomplete = true;
|
|
694
|
+
node.blocker = 'governance-deny';
|
|
695
|
+
return { incomplete: true, best: null, blocker: 'governance-deny', receipts: node };
|
|
696
|
+
}
|
|
676
697
|
node.incomplete = true;
|
|
677
698
|
return { incomplete: true, best: null, receipts: node };
|
|
678
699
|
}
|
package/types/index.d.ts
CHANGED
|
@@ -86,6 +86,14 @@ export interface GenerateResult {
|
|
|
86
86
|
* temperature, not the one requested — callers reporting an effective temperature must honor this.
|
|
87
87
|
*/
|
|
88
88
|
temperatureDropped?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Authoritative per-call cost in USD, reported by the provider itself — e.g. CLIPipeProvider
|
|
91
|
+
* `parse:'claude-json'` surfacing the claude CLI's own `total_cost_usd`, a real price with no local
|
|
92
|
+
* rate table. When a FINITE number the Loop prefers it over `estimateCost` and treats the round as
|
|
93
|
+
* priced (feeding bareguard's USD budget axis). `0` is a valid priced value (a subscription/marginal-$0
|
|
94
|
+
* run) — distinct from omitted/null, which means "couldn't price" and falls back to the rate table.
|
|
95
|
+
*/
|
|
96
|
+
costUsd?: number;
|
|
89
97
|
}
|
|
90
98
|
|
|
91
99
|
/** A conversation message in OpenAI chat format. */
|