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.
@@ -1,7 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  const { spawn } = require('child_process');
4
- const { ProviderError } = require('./errors');
4
+ const { ProviderError, HaltError } = require('./errors');
5
+ const { buildToolSystemPrompt, renderTranscript, resolveToolProtocol, mapClaudeMeta } = require('./provider-clipipe-tools');
6
+ const { createBridge, resolveSessionError, runSession } = require('./provider-clipipe-mcp');
5
7
 
6
8
  /** @typedef {import('../types').Message} Message */
7
9
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -17,6 +19,51 @@ const { ProviderError } = require('./errors');
17
19
  * @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
20
  * @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
19
21
  * @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.
22
+ * @property {'claude'|'claude-mcp'} [toolProtocol] - Opt into TOOL MODE. Two modes, and the choice is
23
+ * about COST, not capability. `'claude-mcp'` (BA-16, NATIVE — prefer this on the claude CLI): one CLI
24
+ * session per call, the caller's `tools` exposed to it as a real MCP server whose handlers call back
25
+ * into your own in-process closures. The CLI owns the inner cycle and caches its transcript
26
+ * session-side. `'claude'` (v0.32.0, EMULATION): one CLI spawn per round with the whole transcript
27
+ * re-rendered and re-sent, parsed back through a JSON envelope. Emulation re-buys the full prefix
28
+ * every turn, which the adopter measured at **$0.25–0.55/round** against **~$0.006/turn** native — so
29
+ * it is the right instrument only for a CLI with NO MCP support, not a default. NOT claimed for
30
+ * native: better output quality (n=2 suggestive evidence exists and is deliberately unminted).
31
+ * Native mode sets {@link CLIPipeProvider#ownsCycle}, which makes the Loop REFUSE options it could
32
+ * never honor (`assemble`/`trim`/`cacheMessages`, and a Loop-level `policy`) instead of leaving them
33
+ * silently dead. See the native-only properties below.
34
+ * @property {(tool: string, args: any, ctx?: any) => any} [policy] - (native mode) The gate, same contract as `Loop({policy})`: only `true`
35
+ * allows, a string is the deny reason fed back verbatim, a thrown `HaltError` is a clean governance
36
+ * exit. REQUIRED here rather than on the Loop, because in native mode no tool call ever reaches the
37
+ * Loop — a `Loop({policy})` would be a fence that is silently not there (the Loop throws instead).
38
+ * Wiring the same `wireGate(gate).policy` keeps audit rows byte-shape-identical, with zero gate changes.
39
+ * @property {Function} [onTurn] - (native mode) Called with `{model, provider, usage, costUsd, pricing,
40
+ * durationMs, ctx, kind}` for EACH completed CLI turn as it arrives (`kind:'turn'`, four cache tiers,
41
+ * `costUsd:null` — the CLI prices the session, not the turn), then once at session end
42
+ * (`kind:'session'`) carrying the authoritative total cost with zero usage. Streaming, never
43
+ * sum-at-end: a session that dies mid-run must already have surfaced every completed turn's spend or
44
+ * the gate loses all of it. The event shape mirrors `Loop({onLlmResult})`, so `wireGate(gate).onLlmResult`
45
+ * drops straight in — and when it is wired the Loop skips its own forward, so nothing is billed twice.
46
+ * @property {number} [maxTurns] - (native mode) Maps to the CLI's `--max-turns`. The bound stop is NAMED
47
+ * (`error_max_turns` → `session.error:'max_turns'`), never a silent clean success.
48
+ * @property {number} [maxConsecutiveDenials=3] - (native mode) BA-11 at the bridge: a single deny stays
49
+ * advisory so the model can pivot to an allowed tool; N in a row with no allowed call between ends the
50
+ * session with `denied:<tool>`. `0`/`Infinity` disables.
51
+ * @property {number} [maxIdenticalToolErrors=3] - (native mode) BA-12 at the bridge: only a BYTE-IDENTICAL
52
+ * repeat (name + JSON args) counts, so a model varying its args while recovering is never punished. N in
53
+ * a row ends the session with `stuck:<tool>`. `0`/`Infinity` disables.
54
+ * @property {number} [sessionTimeout=600000] - (native mode) Wall-clock ceiling for one whole session. The
55
+ * 30s `timeout` default is for one-shot text and would kill an agentic session mid-run.
56
+ * @property {number} [bridgeTimeoutMs] - (native mode) Ceiling for ONE tool-handler round-trip across the
57
+ * bridge. A hung handler becomes an error tool result rather than a hung session (default 120s).
58
+ *
59
+ * Either mode: a non-empty `tools` array on `generate()` routes to tool mode; an empty one stays
60
+ * plain text. With NO `toolProtocol`, `tools` are IGNORED (plain-text, the long-standing behavior —
61
+ * a non-tool-calling CLI legitimately sits in a Loop with tools mounted) plus a one-time `console.warn`.
62
+ * Emulation additionally requires a capable model (weak ones answer in prose; see `probeCapability`);
63
+ * native mode needs no such probe, because the CLI's own tool channel does not depend on the model
64
+ * agreeing to fill in a JSON questionnaire. The claude-specific parts of each live in
65
+ * `provider-clipipe-tools.js` / `provider-clipipe-mcp.js`, so a second CLI slots in behind the same seams.
66
+ * @property {boolean} [probeCapability=true] - (EMULATION 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
67
  */
21
68
 
22
69
  class CLIPipeProvider {
@@ -38,12 +85,53 @@ class CLIPipeProvider {
38
85
  throw new Error("[CLIPipeProvider] options.parse must be 'claude-json' or a function");
39
86
  }
40
87
  this.parse = options.parse || null;
88
+ // Tool mode (v0.32.0). Resolve the protocol adapter eagerly so an unknown name fails at
89
+ // construction, not mid-run. `_toolCapability` caches the upfront probe verdict per instance
90
+ // (null = not yet probed; a Promise while in flight; true once confirmed capable).
91
+ // BA-16 native tool mode. `claude-mcp` is NOT an envelope protocol — the CLI runs its own
92
+ // multi-turn session and executes the caller's tools natively over MCP — so it is resolved on a
93
+ // separate axis rather than being forced through `resolveToolProtocol`'s emulation shape.
94
+ this.nativeTools = options.toolProtocol === 'claude-mcp';
95
+ /**
96
+ * Declares to the Loop that this provider runs its OWN turn cycle. The Loop reads it to refuse
97
+ * options it could never honor (assemble/trim/cacheMessages) and to require the fence be wired
98
+ * where it can actually run. Generic provider-contract flag; nothing here is claude-specific.
99
+ */
100
+ this.ownsCycle = this.nativeTools;
101
+ this.toolProtocol = (options.toolProtocol && !this.nativeTools) ? resolveToolProtocol(options.toolProtocol) : null;
102
+ this.probeCapability = options.probeCapability !== false;
103
+ /** @type {Promise<void>|null} */
104
+ this._toolCapability = null;
105
+
106
+ if (this.nativeTools) {
107
+ // The gate CANNOT ride on the Loop in native mode: no tool call ever reaches the Loop, so a
108
+ // `Loop({policy})` would be a fence that silently is not there. It must be wired HERE, at the
109
+ // bridge, which is the one seam every tool call crosses.
110
+ if (options.policy != null && typeof options.policy !== 'function') {
111
+ throw new Error('[CLIPipeProvider] options.policy must be a function (tool, args, ctx) => true|string');
112
+ }
113
+ this.policy = options.policy || null;
114
+ this.onTurn = options.onTurn || null;
115
+ if (this.onTurn != null && typeof this.onTurn !== 'function') {
116
+ throw new Error('[CLIPipeProvider] options.onTurn must be a function');
117
+ }
118
+ this.maxTurns = options.maxTurns ?? null;
119
+ this.maxConsecutiveDenials = options.maxConsecutiveDenials;
120
+ this.maxIdenticalToolErrors = options.maxIdenticalToolErrors;
121
+ // A session is a whole agentic run, not one prompt — the 30s one-shot default would kill it.
122
+ this.sessionTimeout = options.sessionTimeout ?? 600000;
123
+ this.bridgeTimeoutMs = options.bridgeTimeoutMs ?? null;
124
+ }
41
125
  }
42
126
 
43
127
  /**
44
- * Generate a response by piping messages to the CLI command.
128
+ * Generate a response by piping messages to the CLI command. With a `toolProtocol` configured and
129
+ * a non-empty `tools` array, routes to schema-validated tool EMULATION (v0.32.0); otherwise the
130
+ * plain-text path below (unchanged). Passing `tools` with no `toolProtocol` warns ONCE and ignores
131
+ * them (a non-tool-calling CLI legitimately coexists in a Loop with tools mounted); the loud
132
+ * failure for a genuinely tool-incapable model lives in the tool-mode capability probe.
45
133
  * @param {Message[]} messages - Conversation messages in OpenAI format.
46
- * @param {ToolDef[]} [tools=[]] - Unused (CLI commands don't support tools).
134
+ * @param {ToolDef[]} [tools=[]] - Caller tools. Honored only in tool mode (`toolProtocol` set).
47
135
  * @param {Record<string, any>} [options={}] - Unused.
48
136
  * @returns {Promise<GenerateResult>}
49
137
  * @throws {Error} `[CLIPipeProvider] failed to spawn "cmd": ...` — when the command cannot be found or executed.
@@ -52,6 +140,25 @@ class CLIPipeProvider {
52
140
  * @throws {Error} `[CLIPipeProvider] process produced no output` — when stdout is empty.
53
141
  */
54
142
  async generate(messages, tools = [], options = {}) {
143
+ if (Array.isArray(tools) && tools.length > 0) {
144
+ if (this.nativeTools) return this._generateWithMcp(messages, tools, options);
145
+ if (this.toolProtocol) return this._generateWithTools(messages, tools);
146
+ // No protocol configured → plain-text mode, tools IGNORED — the long-standing behavior, kept
147
+ // for backward compatibility (a non-tool-calling CLI legitimately coexists in a Loop that has
148
+ // tools mounted, e.g. via MCP; the Loop's contract lets a provider simply not call them).
149
+ // A silent ignore is the trap the caller might not notice, so warn ONCE per instance (the
150
+ // provider-temperature BA-10 pattern) — visible, not fatal. Genuine loud-failure for tool
151
+ // mode lives in the capability probe, where a weak model that SHOULD call tools cannot.
152
+ if (!this._warnedNoProtocol) {
153
+ this._warnedNoProtocol = true;
154
+ // eslint-disable-next-line no-console
155
+ console.warn(
156
+ '[CLIPipeProvider] received tools but no toolProtocol is configured — tools are IGNORED ' +
157
+ "(plain-text mode). Construct with { toolProtocol: 'claude' } to enable tool emulation.",
158
+ );
159
+ }
160
+ }
161
+
55
162
  /** @type {string[]} */
56
163
  let extraArgs = [];
57
164
  let promptMessages = messages;
@@ -85,6 +192,209 @@ class CLIPipeProvider {
85
192
  };
86
193
  }
87
194
 
195
+ /**
196
+ * Tool mode (v0.32.0) — one turn of schema-validated tool emulation. Renders the Loop's
197
+ * OpenAI-shaped transcript to text, injects the caller's system stance + a tool manifest + the
198
+ * envelope contract, spawns the CLI under the protocol's flags, and parses the envelope back into
199
+ * normalized `toolCalls` (a `tool_call`) or `text` (a `final_answer`). The Loop drives the cycle.
200
+ * @param {Message[]} messages
201
+ * @param {ToolDef[]} tools
202
+ * @returns {Promise<GenerateResult>}
203
+ */
204
+ async _generateWithTools(messages, tools) {
205
+ await this._ensureToolCapability();
206
+ const proto = this.toolProtocol;
207
+ if (!proto) throw new ProviderError('[CLIPipeProvider] tool mode not configured', /** @type {any} */ ({ status: 0 })); // unreachable: only called from the tools branch
208
+ const sysMsg = messages.find((m) => m.role === 'system');
209
+ const systemPrompt = buildToolSystemPrompt(sysMsg && typeof sysMsg.content === 'string' ? sysMsg.content : null, tools);
210
+ const stdout = await this._spawn(renderTranscript(messages), proto.turnArgs(systemPrompt));
211
+ const parsed = proto.parseResult(stdout);
212
+
213
+ /** @type {GenerateResult} */
214
+ const result = { text: '', toolCalls: [], usage: parsed.usage, model: parsed.model ?? null };
215
+ if (Number.isFinite(parsed.costUsd)) result.costUsd = parsed.costUsd;
216
+ if (parsed.action === 'tool_call') {
217
+ this._toolCallSeq = (this._toolCallSeq || 0) + 1;
218
+ result.toolCalls = [{ id: `cli_${this._toolCallSeq}`, name: parsed.toolName || '', arguments: parsed.toolArguments || {} }];
219
+ } else {
220
+ result.text = parsed.answer || '';
221
+ }
222
+ return result;
223
+ }
224
+
225
+ /**
226
+ * BA-16 native tool mode — run ONE whole CLI session and report it honestly as one call.
227
+ *
228
+ * The CLI owns the inner cycle here: it calls the caller's tools natively over an MCP bridge and
229
+ * keeps going until it answers or hits a bound. So this returns `toolCalls: []` always (there is
230
+ * nothing left for the Loop to execute) plus a `session` block describing what really happened —
231
+ * the real turn count, the real tool-call count, and any terminal the Loop must surface as the
232
+ * run's `error`.
233
+ *
234
+ * Ordering of terminals is deliberate. A governance halt outranks everything (it is a clean exit,
235
+ * not a fault). A tripped guard outranks the CLI's own subtype, because the guard is why we killed
236
+ * the session. And `bridgeDown` outranks a reported `success`, because a session whose tools were
237
+ * all broken still ends `subtype:'success'` — measured, and the reason this block exists.
238
+ *
239
+ * @param {Message[]} messages
240
+ * @param {ToolDef[]} tools
241
+ * @param {Record<string, any>} options - the Loop's run options (`ctx` is read from here).
242
+ * @returns {Promise<GenerateResult>}
243
+ */
244
+ async _generateWithMcp(messages, tools, options = {}) {
245
+ if (options.cacheMessages) {
246
+ throw new Error(
247
+ '[CLIPipeProvider] cacheMessages cannot apply in native tool mode — the CLI owns the transcript '
248
+ + 'and caches it session-side, so there is no request body for a breakpoint to ride on. Remove the option.',
249
+ );
250
+ }
251
+ const sysMsg = messages.find((m) => m.role === 'system');
252
+ const systemPrompt = (sysMsg && typeof sysMsg.content === 'string' && sysMsg.content)
253
+ || 'You are an agent. Use the tools provided over MCP when they are needed.';
254
+
255
+ const bridge = await createBridge({
256
+ tools,
257
+ policy: this.policy,
258
+ ctx: options.ctx,
259
+ maxConsecutiveDenials: this.maxConsecutiveDenials,
260
+ maxIdenticalToolErrors: this.maxIdenticalToolErrors,
261
+ });
262
+
263
+ let r;
264
+ try {
265
+ r = await runSession({
266
+ command: this.command,
267
+ baseArgs: this.args,
268
+ systemPrompt,
269
+ task: renderTranscript(messages),
270
+ sockPath: bridge.sockPath,
271
+ maxTurns: this.maxTurns,
272
+ timeoutMs: this.sessionTimeout,
273
+ bridgeTimeoutMs: this.bridgeTimeoutMs,
274
+ onTurn: this.onTurn,
275
+ ctx: options.ctx,
276
+ cwd: this.cwd,
277
+ env: this.env,
278
+ });
279
+ } finally {
280
+ bridge.close();
281
+ }
282
+
283
+ const st = bridge.state;
284
+ // A governance halt is a CLEAN exit and must reach the Loop as a HaltError, not as a session
285
+ // error tag — the Loop is the thing that knows a halt seals the transcript rather than faulting.
286
+ if (st.halt) throw st.halt;
287
+ if (r.turnHalt) throw r.turnHalt;
288
+ if (r.spawnError) {
289
+ throw new ProviderError(`[CLIPipeProvider] failed to spawn "${this.command}": ${r.spawnError.message}`, /** @type {any} */ ({ status: 0 }));
290
+ }
291
+
292
+ /** @type {import('../types').Usage} */
293
+ const usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
294
+ for (const t of r.turns) {
295
+ usage.inputTokens += t.inputTokens || 0;
296
+ usage.outputTokens += t.outputTokens || 0;
297
+ usage.cacheReadTokens += t.cacheReadTokens || 0;
298
+ usage.cacheCreationTokens += t.cacheCreationTokens || 0;
299
+ }
300
+
301
+ const { stopReason, error } = resolveSessionError({
302
+ terminal: st.terminal,
303
+ bridgeDown: st.bridgeDown,
304
+ attempted: r.attempted,
305
+ served: st.toolCalls,
306
+ timedOut: Boolean(r.timedOut),
307
+ subtype: r.final && r.final.subtype,
308
+ });
309
+
310
+ const costUsd = (r.final && Number.isFinite(r.final.total_cost_usd)) ? r.final.total_cost_usd : null;
311
+
312
+ // The authoritative price arrives only at session end (the CLI prices the SESSION, not the turn),
313
+ // so when per-turn streaming is wired it gets one closing event carrying the cost with zero
314
+ // usage — the tokens were already streamed, and double-counting either axis would be a lie.
315
+ if (this.onTurn) {
316
+ try {
317
+ await this.onTurn({
318
+ model: (r.final && r.final.model) || null,
319
+ provider: 'clipipe',
320
+ usage: { inputTokens: 0, outputTokens: 0 },
321
+ costUsd,
322
+ pricing: costUsd === null ? 'unpriced' : 'priced',
323
+ durationMs: r.ms,
324
+ ctx: options.ctx,
325
+ kind: 'session',
326
+ });
327
+ } catch (err) {
328
+ if (err instanceof HaltError) throw err;
329
+ }
330
+ }
331
+
332
+ /** @type {GenerateResult} */
333
+ const result = {
334
+ text: (r.final && typeof r.final.result === 'string') ? r.final.result : '',
335
+ toolCalls: [],
336
+ usage,
337
+ model: (r.final && r.final.model) || null,
338
+ stopReason,
339
+ session: {
340
+ turns: r.turns.length,
341
+ toolCalls: st.toolCalls,
342
+ error,
343
+ // Only true when we ACTUALLY streamed — unwired, the Loop must still forward the total or
344
+ // the gate would see this session as free.
345
+ usageReported: Boolean(this.onTurn),
346
+ },
347
+ };
348
+ if (costUsd !== null) result.costUsd = costUsd;
349
+ return result;
350
+ }
351
+
352
+ /**
353
+ * Run the upfront capability probe ONCE per instance (cached), unless `probeCapability` is off.
354
+ * A model that answers the probe in prose instead of emitting a tool_call throws a loud
355
+ * `ProviderError` — fail fast, never silently degrade to a no-tools run mid-conversation.
356
+ * NOTE: the probe is a single internal CLI turn whose token usage/cost is NOT surfaced to the Loop
357
+ * (it never flows to `onLlmResult`), so a wired budget gate does not see it — negligible for the
358
+ * subscription use case this exists for (flat cost, one probe per instance), by design.
359
+ * @returns {Promise<void>}
360
+ */
361
+ _ensureToolCapability() {
362
+ if (!this.probeCapability) return Promise.resolve();
363
+ // Cache the in-flight promise so concurrent first-calls share one probe, and a resolved
364
+ // capable verdict is never re-probed.
365
+ if (this._toolCapability) return this._toolCapability;
366
+ const proto = this.toolProtocol;
367
+ if (!proto) return Promise.resolve(); // unreachable: only called from the tools branch
368
+ this._toolCapability = (async () => {
369
+ const stdout = await this._spawn(proto.probe.user, proto.turnArgs(proto.probe.system));
370
+ let parsed;
371
+ try {
372
+ parsed = proto.parseResult(stdout);
373
+ } catch (err) {
374
+ // A malformed probe response is itself an incapability signal — name it as such.
375
+ this._toolCapability = null; // allow a retry on a transient parse failure
376
+ throw new ProviderError(`[CLIPipeProvider] tool-mode capability probe failed to parse: ${/** @type {Error} */ (err).message}`, /** @type {any} */ ({ status: 0 }));
377
+ }
378
+ if (!proto.probe.isCapable(parsed)) {
379
+ const model = this._modelFromArgs();
380
+ throw new ProviderError(
381
+ `[CLIPipeProvider] the CLI model${model ? ` '${model}'` : ''} is not capable of tool use: the ` +
382
+ 'capability probe answered in prose instead of emitting a tool_call. Weak models (e.g. haiku) ' +
383
+ 'cannot drive tool emulation reliably — use a stronger model for tool mode, or run without ' +
384
+ 'tools for plain-text. (Set { probeCapability: false } to skip this check.)',
385
+ /** @type {any} */ ({ status: 0 }),
386
+ );
387
+ }
388
+ })();
389
+ return this._toolCapability;
390
+ }
391
+
392
+ /** Best-effort model id from `--model X` in the base args, for a clearer probe-failure message. */
393
+ _modelFromArgs() {
394
+ const i = this.args.indexOf('--model');
395
+ return i >= 0 && i + 1 < this.args.length ? this.args[i + 1] : null;
396
+ }
397
+
88
398
  /**
89
399
  * Map the `claude -p --output-format json` result envelope onto a normalized GenerateResult.
90
400
  * The caller explicitly opted into structured output, so a malformed or error envelope is a LOUD
@@ -109,21 +419,7 @@ class CLIPipeProvider {
109
419
  throw new ProviderError(`[CLIPipeProvider] claude CLI reported failure (subtype='${obj.subtype}'): ${detail}`, /** @type {any} */ ({ status: 0 }));
110
420
  }
111
421
 
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
-
422
+ const { usage, model, costUsd } = mapClaudeMeta(obj);
127
423
  /** @type {GenerateResult} */
128
424
  const result = {
129
425
  text: typeof obj.result === 'string' ? obj.result : '',
@@ -131,9 +427,7 @@ class CLIPipeProvider {
131
427
  usage,
132
428
  model,
133
429
  };
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;
430
+ if (costUsd !== undefined) result.costUsd = costUsd;
137
431
  return result;
138
432
  }
139
433
 
package/src/recurse.d.ts CHANGED
@@ -113,7 +113,7 @@ export type RecurseOptions = {
113
113
  * (+ `receipts.blockerDetail`), with `best` preserving the model's last attempt. A `HaltError` thrown by the
114
114
  * sensor stays a clean governance halt. The sensor's EXECUTION environment is the caller's: run untrusted /
115
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).
116
+ * leaf (no bareguard checkpoint fires between sensor start and return; confirmed by `poc/rlmplans-hung-sensor.mjs`).
117
117
  * **`rejectedBuffer` (BA-14):** a SkillOpt-shaped rejected-attempt buffer — instead of only the LATEST critique,
118
118
  * surface the model's OWN prior failed attempts verbatim ("you wrote these, they failed X — write something
119
119
  * STRUCTURALLY DIFFERENT"). This is DIRECTED diversity (attack the specific repeated mistake), where escalation
@@ -456,7 +456,7 @@ export type Slice = {
456
456
  * (+ `receipts.blockerDetail`), with `best` preserving the model's last attempt. A `HaltError` thrown by the
457
457
  * sensor stays a clean governance halt. The sensor's EXECUTION environment is the caller's: run untrusted /
458
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).
459
+ * leaf (no bareguard checkpoint fires between sensor start and return; confirmed by `poc/rlmplans-hung-sensor.mjs`).
460
460
  * **`rejectedBuffer` (BA-14):** a SkillOpt-shaped rejected-attempt buffer — instead of only the LATEST critique,
461
461
  * surface the model's OWN prior failed attempts verbatim ("you wrote these, they failed X — write something
462
462
  * STRUCTURALLY DIFFERENT"). This is DIRECTED diversity (attack the specific repeated mistake), where escalation
package/src/recurse.js CHANGED
@@ -244,7 +244,7 @@ function auditSafeCtx(ctx, overrides = {}) {
244
244
  * (+ `receipts.blockerDetail`), with `best` preserving the model's last attempt. A `HaltError` thrown by the
245
245
  * sensor stays a clean governance halt. The sensor's EXECUTION environment is the caller's: run untrusted /
246
246
  * model-generated checks in an isolated child process WITH A TIMEOUT — a sensor that hangs forever hangs the
247
- * leaf (no bareguard checkpoint fires between sensor start and return).
247
+ * leaf (no bareguard checkpoint fires between sensor start and return; confirmed by `poc/rlmplans-hung-sensor.mjs`).
248
248
  * **`rejectedBuffer` (BA-14):** a SkillOpt-shaped rejected-attempt buffer — instead of only the LATEST critique,
249
249
  * surface the model's OWN prior failed attempts verbatim ("you wrote these, they failed X — write something
250
250
  * STRUCTURALLY DIFFERENT"). This is DIRECTED diversity (attack the specific repeated mistake), where escalation
package/types/index.d.ts CHANGED
@@ -34,6 +34,13 @@ export interface RunMetrics {
34
34
  turns: number;
35
35
  /** Total tool calls the model made (every invocation, including denied/unknown). */
36
36
  toolCalls: number;
37
+ /**
38
+ * BA-16 — turns that happened INSIDE a cycle-owning provider's own session (CLIPipe native tool
39
+ * mode). Such a session is ONE Loop round however many turns it really took, so `turns` alone
40
+ * would report a 14-turn session as 1 — a round count that reads far cheaper and shorter than the
41
+ * run actually was. 0 for every provider whose cycle the Loop drives.
42
+ */
43
+ sessionTurns: number;
37
44
  /** Per-tool invocation counts, keyed by tool name. */
38
45
  byTool: Record<string, number>;
39
46
  /** Cumulative token spend across all rounds (incl. summarize calls), by tier. */
@@ -97,6 +104,26 @@ export interface GenerateResult {
97
104
  * pre-BA-6 behavior exactly, so an unmapped provider degrades to the status quo.
98
105
  */
99
106
  stopReason?: string | null;
107
+ /**
108
+ * BA-16 — present ONLY when the provider ran its own multi-turn session for this single call
109
+ * (`Provider.ownsCycle`), e.g. CLIPipe native tool mode, where the CLI executes the caller's tools
110
+ * natively over MCP and keeps going until it answers or hits a bound.
111
+ *
112
+ * It exists so the Loop can stay honest about a call that was not one turn:
113
+ * - `turns` / `toolCalls` — what really happened, so `metrics` cannot report a 14-turn session as 1.
114
+ * - `error` — a terminal the provider detected INSIDE the session (a turn bound, a deny/stuck
115
+ * streak, a broken tool bridge). The Loop surfaces it as the run's `error`, never merely as a
116
+ * field: every downstream consumer branches on `error` as the sole success signal, so surfacing
117
+ * alone would let a session in which no tool call ever succeeded propagate as converged.
118
+ * - `usageReported` — the provider ALREADY forwarded this call's usage per internal turn, so the
119
+ * Loop must not forward the summed total again and bill the gate twice.
120
+ */
121
+ session?: {
122
+ turns: number;
123
+ toolCalls: number;
124
+ error: string | null;
125
+ usageReported: boolean;
126
+ };
100
127
  /**
101
128
  * True when the requested `temperature` was rejected by the model (400, unsupported/deprecated) and
102
129
  * the request was retried without it (BA-10). The response was produced at the model's DEFAULT
@@ -165,6 +192,19 @@ export interface Provider {
165
192
  model?: string | null;
166
193
  /** Provider name, surfaced in onLlmResult. */
167
194
  name?: string | null;
195
+ /**
196
+ * BA-16 — true when the provider runs its OWN multi-turn cycle inside a single `generate()` call
197
+ * (CLIPipe native tool mode), executing the caller's tools itself rather than returning
198
+ * `toolCalls` for the Loop to run.
199
+ *
200
+ * The Loop reads this to REFUSE options it could never honor rather than accept them and leave
201
+ * them silently dead: `assemble`/`trim` (the provider owns the transcript) and, most importantly,
202
+ * a Loop-level `policy` — no tool call reaches the Loop, so that fence would simply not be there
203
+ * while the run still looked governed. Such a provider must carry its own `policy`.
204
+ */
205
+ ownsCycle?: boolean;
206
+ /** Gate chokepoint for a cycle-owning provider — same contract as `Loop({policy})`. */
207
+ policy?: ((tool: string, args: any, ctx?: any) => any) | null;
168
208
  generate(
169
209
  messages: Message[],
170
210
  tools?: ToolDef[],