bare-agent 0.30.0 → 0.32.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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { spawn } = require('child_process');
4
4
  const { ProviderError } = require('./errors');
5
+ const { buildToolSystemPrompt, renderTranscript, resolveToolProtocol, mapClaudeMeta } = require('./provider-clipipe-tools');
5
6
 
6
7
  /** @typedef {import('../types').Message} Message */
7
8
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -17,6 +18,8 @@ const { ProviderError } = require('./errors');
17
18
  * @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
19
  * @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
19
20
  * @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.
21
+ * @property {'claude'} [toolProtocol] - Opt into TOOL MODE (v0.32.0). A subscription CLI is a plain turn-provider with no native tool channel; this enables schema-validated tool EMULATION so a caller's `tools` work over the CLI (letting a Claude/etc. subscription drive an agentic Loop without metered-API spend). When set, ANY `generate(msgs, tools)` call with a non-empty `tools` array auto-uses emulation (the caller's own system stance + a tool manifest + a JSON envelope, parsed back into normalized `toolCalls`); an empty `tools` array is unchanged plain-text. When NOT set, `tools` are IGNORED (plain-text mode, the long-standing behavior — a non-tool-calling CLI can legitimately sit in a Loop that has tools mounted) with a one-time `console.warn` for visibility. Claude-only for now (`'claude'`); the claude-specific flags/schema/parse live in `provider-clipipe-tools.js` so a second CLI slots in behind the same seam. NOTE: tool mode requires a capable model — weak models (e.g. haiku) answer in prose instead of calling tools; see `probeCapability`.
22
+ * @property {boolean} [probeCapability=true] - (tool mode only) On the first tool-mode `generate`, run ONE cheap upfront probe that asks the model to obtain unknowable info via a tool. If it answers in prose instead of emitting a tool_call, throw a loud `ProviderError` naming the model — FAIL FAST rather than silently degrade mid-run (the weak-model failure mode). Behaviour-based, never a model name-list (a roster goes stale, BA-10). The verdict is cached per instance (one probe per provider, not per turn). Set `false` to skip when the caller already knows the model is capable.
20
23
  */
21
24
 
22
25
  class CLIPipeProvider {
@@ -38,12 +41,23 @@ class CLIPipeProvider {
38
41
  throw new Error("[CLIPipeProvider] options.parse must be 'claude-json' or a function");
39
42
  }
40
43
  this.parse = options.parse || null;
44
+ // Tool mode (v0.32.0). Resolve the protocol adapter eagerly so an unknown name fails at
45
+ // construction, not mid-run. `_toolCapability` caches the upfront probe verdict per instance
46
+ // (null = not yet probed; a Promise while in flight; true once confirmed capable).
47
+ this.toolProtocol = options.toolProtocol ? resolveToolProtocol(options.toolProtocol) : null;
48
+ this.probeCapability = options.probeCapability !== false;
49
+ /** @type {Promise<void>|null} */
50
+ this._toolCapability = null;
41
51
  }
42
52
 
43
53
  /**
44
- * Generate a response by piping messages to the CLI command.
54
+ * Generate a response by piping messages to the CLI command. With a `toolProtocol` configured and
55
+ * a non-empty `tools` array, routes to schema-validated tool EMULATION (v0.32.0); otherwise the
56
+ * plain-text path below (unchanged). Passing `tools` with no `toolProtocol` warns ONCE and ignores
57
+ * them (a non-tool-calling CLI legitimately coexists in a Loop with tools mounted); the loud
58
+ * failure for a genuinely tool-incapable model lives in the tool-mode capability probe.
45
59
  * @param {Message[]} messages - Conversation messages in OpenAI format.
46
- * @param {ToolDef[]} [tools=[]] - Unused (CLI commands don't support tools).
60
+ * @param {ToolDef[]} [tools=[]] - Caller tools. Honored only in tool mode (`toolProtocol` set).
47
61
  * @param {Record<string, any>} [options={}] - Unused.
48
62
  * @returns {Promise<GenerateResult>}
49
63
  * @throws {Error} `[CLIPipeProvider] failed to spawn "cmd": ...` — when the command cannot be found or executed.
@@ -52,6 +66,24 @@ class CLIPipeProvider {
52
66
  * @throws {Error} `[CLIPipeProvider] process produced no output` — when stdout is empty.
53
67
  */
54
68
  async generate(messages, tools = [], options = {}) {
69
+ if (Array.isArray(tools) && tools.length > 0) {
70
+ if (this.toolProtocol) return this._generateWithTools(messages, tools);
71
+ // No protocol configured → plain-text mode, tools IGNORED — the long-standing behavior, kept
72
+ // for backward compatibility (a non-tool-calling CLI legitimately coexists in a Loop that has
73
+ // tools mounted, e.g. via MCP; the Loop's contract lets a provider simply not call them).
74
+ // A silent ignore is the trap the caller might not notice, so warn ONCE per instance (the
75
+ // provider-temperature BA-10 pattern) — visible, not fatal. Genuine loud-failure for tool
76
+ // mode lives in the capability probe, where a weak model that SHOULD call tools cannot.
77
+ if (!this._warnedNoProtocol) {
78
+ this._warnedNoProtocol = true;
79
+ // eslint-disable-next-line no-console
80
+ console.warn(
81
+ '[CLIPipeProvider] received tools but no toolProtocol is configured — tools are IGNORED ' +
82
+ "(plain-text mode). Construct with { toolProtocol: 'claude' } to enable tool emulation.",
83
+ );
84
+ }
85
+ }
86
+
55
87
  /** @type {string[]} */
56
88
  let extraArgs = [];
57
89
  let promptMessages = messages;
@@ -85,6 +117,82 @@ class CLIPipeProvider {
85
117
  };
86
118
  }
87
119
 
120
+ /**
121
+ * Tool mode (v0.32.0) — one turn of schema-validated tool emulation. Renders the Loop's
122
+ * OpenAI-shaped transcript to text, injects the caller's system stance + a tool manifest + the
123
+ * envelope contract, spawns the CLI under the protocol's flags, and parses the envelope back into
124
+ * normalized `toolCalls` (a `tool_call`) or `text` (a `final_answer`). The Loop drives the cycle.
125
+ * @param {Message[]} messages
126
+ * @param {ToolDef[]} tools
127
+ * @returns {Promise<GenerateResult>}
128
+ */
129
+ async _generateWithTools(messages, tools) {
130
+ await this._ensureToolCapability();
131
+ const proto = this.toolProtocol;
132
+ if (!proto) throw new ProviderError('[CLIPipeProvider] tool mode not configured', /** @type {any} */ ({ status: 0 })); // unreachable: only called from the tools branch
133
+ const sysMsg = messages.find((m) => m.role === 'system');
134
+ const systemPrompt = buildToolSystemPrompt(sysMsg && typeof sysMsg.content === 'string' ? sysMsg.content : null, tools);
135
+ const stdout = await this._spawn(renderTranscript(messages), proto.turnArgs(systemPrompt));
136
+ const parsed = proto.parseResult(stdout);
137
+
138
+ /** @type {GenerateResult} */
139
+ const result = { text: '', toolCalls: [], usage: parsed.usage, model: parsed.model ?? null };
140
+ if (Number.isFinite(parsed.costUsd)) result.costUsd = parsed.costUsd;
141
+ if (parsed.action === 'tool_call') {
142
+ this._toolCallSeq = (this._toolCallSeq || 0) + 1;
143
+ result.toolCalls = [{ id: `cli_${this._toolCallSeq}`, name: parsed.toolName || '', arguments: parsed.toolArguments || {} }];
144
+ } else {
145
+ result.text = parsed.answer || '';
146
+ }
147
+ return result;
148
+ }
149
+
150
+ /**
151
+ * Run the upfront capability probe ONCE per instance (cached), unless `probeCapability` is off.
152
+ * A model that answers the probe in prose instead of emitting a tool_call throws a loud
153
+ * `ProviderError` — fail fast, never silently degrade to a no-tools run mid-conversation.
154
+ * NOTE: the probe is a single internal CLI turn whose token usage/cost is NOT surfaced to the Loop
155
+ * (it never flows to `onLlmResult`), so a wired budget gate does not see it — negligible for the
156
+ * subscription use case this exists for (flat cost, one probe per instance), by design.
157
+ * @returns {Promise<void>}
158
+ */
159
+ _ensureToolCapability() {
160
+ if (!this.probeCapability) return Promise.resolve();
161
+ // Cache the in-flight promise so concurrent first-calls share one probe, and a resolved
162
+ // capable verdict is never re-probed.
163
+ if (this._toolCapability) return this._toolCapability;
164
+ const proto = this.toolProtocol;
165
+ if (!proto) return Promise.resolve(); // unreachable: only called from the tools branch
166
+ this._toolCapability = (async () => {
167
+ const stdout = await this._spawn(proto.probe.user, proto.turnArgs(proto.probe.system));
168
+ let parsed;
169
+ try {
170
+ parsed = proto.parseResult(stdout);
171
+ } catch (err) {
172
+ // A malformed probe response is itself an incapability signal — name it as such.
173
+ this._toolCapability = null; // allow a retry on a transient parse failure
174
+ throw new ProviderError(`[CLIPipeProvider] tool-mode capability probe failed to parse: ${/** @type {Error} */ (err).message}`, /** @type {any} */ ({ status: 0 }));
175
+ }
176
+ if (!proto.probe.isCapable(parsed)) {
177
+ const model = this._modelFromArgs();
178
+ throw new ProviderError(
179
+ `[CLIPipeProvider] the CLI model${model ? ` '${model}'` : ''} is not capable of tool use: the ` +
180
+ 'capability probe answered in prose instead of emitting a tool_call. Weak models (e.g. haiku) ' +
181
+ 'cannot drive tool emulation reliably — use a stronger model for tool mode, or run without ' +
182
+ 'tools for plain-text. (Set { probeCapability: false } to skip this check.)',
183
+ /** @type {any} */ ({ status: 0 }),
184
+ );
185
+ }
186
+ })();
187
+ return this._toolCapability;
188
+ }
189
+
190
+ /** Best-effort model id from `--model X` in the base args, for a clearer probe-failure message. */
191
+ _modelFromArgs() {
192
+ const i = this.args.indexOf('--model');
193
+ return i >= 0 && i + 1 < this.args.length ? this.args[i + 1] : null;
194
+ }
195
+
88
196
  /**
89
197
  * Map the `claude -p --output-format json` result envelope onto a normalized GenerateResult.
90
198
  * The caller explicitly opted into structured output, so a malformed or error envelope is a LOUD
@@ -109,21 +217,7 @@ class CLIPipeProvider {
109
217
  throw new ProviderError(`[CLIPipeProvider] claude CLI reported failure (subtype='${obj.subtype}'): ${detail}`, /** @type {any} */ ({ status: 0 }));
110
218
  }
111
219
 
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
-
220
+ const { usage, model, costUsd } = mapClaudeMeta(obj);
127
221
  /** @type {GenerateResult} */
128
222
  const result = {
129
223
  text: typeof obj.result === 'string' ? obj.result : '',
@@ -131,9 +225,7 @@ class CLIPipeProvider {
131
225
  usage,
132
226
  model,
133
227
  };
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;
228
+ if (costUsd !== undefined) result.costUsd = costUsd;
137
229
  return result;
138
230
  }
139
231
 
package/src/recurse.d.ts CHANGED
@@ -107,6 +107,13 @@ export type RecurseOptions = {
107
107
  * NEVER a worker side-effect a worker with edit tools could GAME (writing a passing file then returning junk, or
108
108
  * editing the failing test itself). A gameable close is the reward-hacking surface every RSI system in the field
109
109
  * got bitten by; the loop optimizes against WHATEVER the sensor reads, so keep it outside what the worker can write.
110
+ * **Broken sensor ≠ failing model (BA-15):** a sensor that THROWS (non-Halt) or returns a MALFORMED verdict
111
+ * (anything but `{pass: boolean}` or a valid tri-state `status`) is a faulty ARBITER — the loop stops at the
112
+ * FIRST broken close (never retries against it) and returns a labeled `{incomplete, blocker:'broken-sensor'}`
113
+ * (+ `receipts.blockerDetail`), with `best` preserving the model's last attempt. A `HaltError` thrown by the
114
+ * sensor stays a clean governance halt. The sensor's EXECUTION environment is the caller's: run untrusted /
115
+ * model-generated checks in an isolated child process WITH A TIMEOUT — a sensor that hangs forever hangs the
116
+ * leaf (no bareguard checkpoint fires between sensor start and return; confirmed by `poc/rlmplans-hung-sensor.mjs`).
110
117
  * **`rejectedBuffer` (BA-14):** a SkillOpt-shaped rejected-attempt buffer — instead of only the LATEST critique,
111
118
  * surface the model's OWN prior failed attempts verbatim ("you wrote these, they failed X — write something
112
119
  * STRUCTURALLY DIFFERENT"). This is DIRECTED diversity (attack the specific repeated mistake), where escalation
@@ -233,10 +240,29 @@ export type RecurseNode = {
233
240
  incomplete: boolean;
234
241
  halted: boolean;
235
242
  /**
236
- * - (BA-11) set to `'governance-deny'` when this node stopped because its Loop
237
- * short-circuited a consecutive-policy-deny spin (not a model failure). Mirrors `RecurseResult.blocker`.
243
+ * - Set when this node stopped for a specific non-model reason (mirrors
244
+ * `RecurseResult.blocker`): `'governance-deny'` (BA-11) — its Loop short-circuited a consecutive-policy-deny
245
+ * spin; `'broken-sensor'` (BA-15) — the caller's `refineLeaf.sensor` threw or returned a malformed verdict;
246
+ * `'broken-verifier'` (BA-15) — the caller's `opts.evaluate` did (the default Evaluator path is never labeled).
238
247
  */
239
248
  blocker?: string | undefined;
249
+ /**
250
+ * - (BA-15) with a `broken-*` blocker: what the arbiter did (threw with
251
+ * which message, or which malformed shape it returned) — the actionable half of the label.
252
+ */
253
+ blockerDetail?: string | undefined;
254
+ /**
255
+ * - (BA-15) a
256
+ * DESCENDANT's blocker, surfaced here so an aggregating node still reports the fault upward. Deliberately
257
+ * SEPARATE from this node's own `blocker` (which means "THIS node's arbiter/Loop broke"): stamping a
258
+ * descendant's label onto every ancestor made the receipts tree accuse nodes whose sensor never ran, and
259
+ * re-labelled a parent `governance-deny` when only one child was denied. `blockerTask` names the culprit.
260
+ */
261
+ blockerFrom?: {
262
+ blocker: string;
263
+ blockerDetail?: string;
264
+ blockerTask?: string;
265
+ } | undefined;
240
266
  /**
241
267
  * - The worker Loop's `metrics.tokens`.
242
268
  */
@@ -318,8 +344,26 @@ export type RecurseResult = {
318
344
  * - Present when `incomplete` for a specific, actionable reason. `'governance-deny'`
319
345
  * (BA-11): the worker's Loop short-circuited after N consecutive policy denials rather than burn to the
320
346
  * budget cap — the caller can widen scope / re-gate / escalate instead of reading it as a model failure.
347
+ * `'broken-sensor'` (BA-15): the caller's `refineLeaf.sensor` threw or returned a malformed verdict — the
348
+ * ARBITER is faulty, not the model; fix the sensor and re-run (`receipts.blockerDetail` says what it did).
349
+ * `'broken-verifier'` (BA-15): same fault class at the verify slot — the caller's `opts.evaluate` threw
350
+ * (non-Halt) or returned a malformed verdict; the default Evaluator path is never labeled (its failures are
351
+ * provider-class faults). For both `broken-*` blockers `best` preserves the model's last non-empty output
352
+ * (BA-5) — the arbiter broke, so treat it as best-effort work rather than a graded pass.
321
353
  */
322
354
  blocker?: string | undefined;
355
+ /**
356
+ * - (BA-15) with a `broken-*` blocker: what the arbiter did — the
357
+ * ACTIONABLE half of the label, surfaced on the result (not only in `receipts`) so a caller branching on
358
+ * `blocker` can report the cause without walking the receipts tree.
359
+ */
360
+ blockerDetail?: string | undefined;
361
+ /**
362
+ * - (BA-15) when the blocker was INHERITED from a descendant in a nested run:
363
+ * which sub-task actually broke. Without it a nested failure reports "a sensor broke" with no way to find
364
+ * which one.
365
+ */
366
+ blockerTask?: string | undefined;
323
367
  /**
324
368
  * - The audit node for this call (RC-10).
325
369
  */
@@ -406,6 +450,13 @@ export type Slice = {
406
450
  * NEVER a worker side-effect a worker with edit tools could GAME (writing a passing file then returning junk, or
407
451
  * editing the failing test itself). A gameable close is the reward-hacking surface every RSI system in the field
408
452
  * got bitten by; the loop optimizes against WHATEVER the sensor reads, so keep it outside what the worker can write.
453
+ * **Broken sensor ≠ failing model (BA-15):** a sensor that THROWS (non-Halt) or returns a MALFORMED verdict
454
+ * (anything but `{pass: boolean}` or a valid tri-state `status`) is a faulty ARBITER — the loop stops at the
455
+ * FIRST broken close (never retries against it) and returns a labeled `{incomplete, blocker:'broken-sensor'}`
456
+ * (+ `receipts.blockerDetail`), with `best` preserving the model's last attempt. A `HaltError` thrown by the
457
+ * sensor stays a clean governance halt. The sensor's EXECUTION environment is the caller's: run untrusted /
458
+ * model-generated checks in an isolated child process WITH A TIMEOUT — a sensor that hangs forever hangs the
459
+ * leaf (no bareguard checkpoint fires between sensor start and return; confirmed by `poc/rlmplans-hung-sensor.mjs`).
409
460
  * **`rejectedBuffer` (BA-14):** a SkillOpt-shaped rejected-attempt buffer — instead of only the LATEST critique,
410
461
  * surface the model's OWN prior failed attempts verbatim ("you wrote these, they failed X — write something
411
462
  * STRUCTURALLY DIFFERENT"). This is DIRECTED diversity (attack the specific repeated mistake), where escalation
@@ -473,8 +524,17 @@ export type Slice = {
473
524
  * @property {Verdict|null} verdict
474
525
  * @property {boolean} incomplete
475
526
  * @property {boolean} halted
476
- * @property {string} [blocker] - (BA-11) set to `'governance-deny'` when this node stopped because its Loop
477
- * short-circuited a consecutive-policy-deny spin (not a model failure). Mirrors `RecurseResult.blocker`.
527
+ * @property {string} [blocker] - Set when this node stopped for a specific non-model reason (mirrors
528
+ * `RecurseResult.blocker`): `'governance-deny'` (BA-11) — its Loop short-circuited a consecutive-policy-deny
529
+ * spin; `'broken-sensor'` (BA-15) — the caller's `refineLeaf.sensor` threw or returned a malformed verdict;
530
+ * `'broken-verifier'` (BA-15) — the caller's `opts.evaluate` did (the default Evaluator path is never labeled).
531
+ * @property {string} [blockerDetail] - (BA-15) with a `broken-*` blocker: what the arbiter did (threw with
532
+ * which message, or which malformed shape it returned) — the actionable half of the label.
533
+ * @property {{blocker: string, blockerDetail?: string, blockerTask?: string}} [blockerFrom] - (BA-15) a
534
+ * DESCENDANT's blocker, surfaced here so an aggregating node still reports the fault upward. Deliberately
535
+ * SEPARATE from this node's own `blocker` (which means "THIS node's arbiter/Loop broke"): stamping a
536
+ * descendant's label onto every ancestor made the receipts tree accuse nodes whose sensor never ran, and
537
+ * re-labelled a parent `governance-deny` when only one child was denied. `blockerTask` names the culprit.
478
538
  * @property {object|null} tokens - The worker Loop's `metrics.tokens`.
479
539
  * @property {{iterations: number, passed: boolean, temperatures: (number|null)[], rejectedBuffer: boolean}} [refineLeaf] - (BA-8) when
480
540
  * this leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
@@ -507,6 +567,18 @@ export type Slice = {
507
567
  * @property {string} [blocker] - Present when `incomplete` for a specific, actionable reason. `'governance-deny'`
508
568
  * (BA-11): the worker's Loop short-circuited after N consecutive policy denials rather than burn to the
509
569
  * budget cap — the caller can widen scope / re-gate / escalate instead of reading it as a model failure.
570
+ * `'broken-sensor'` (BA-15): the caller's `refineLeaf.sensor` threw or returned a malformed verdict — the
571
+ * ARBITER is faulty, not the model; fix the sensor and re-run (`receipts.blockerDetail` says what it did).
572
+ * `'broken-verifier'` (BA-15): same fault class at the verify slot — the caller's `opts.evaluate` threw
573
+ * (non-Halt) or returned a malformed verdict; the default Evaluator path is never labeled (its failures are
574
+ * provider-class faults). For both `broken-*` blockers `best` preserves the model's last non-empty output
575
+ * (BA-5) — the arbiter broke, so treat it as best-effort work rather than a graded pass.
576
+ * @property {string} [blockerDetail] - (BA-15) with a `broken-*` blocker: what the arbiter did — the
577
+ * ACTIONABLE half of the label, surfaced on the result (not only in `receipts`) so a caller branching on
578
+ * `blocker` can report the cause without walking the receipts tree.
579
+ * @property {string} [blockerTask] - (BA-15) when the blocker was INHERITED from a descendant in a nested run:
580
+ * which sub-task actually broke. Without it a nested failure reports "a sensor broke" with no way to find
581
+ * which one.
510
582
  * @property {RecurseNode} receipts - The audit node for this call (RC-10).
511
583
  */
512
584
  /**