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/src/provider-clipipe.js
CHANGED
|
@@ -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=[]] -
|
|
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
|
|
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
|
-
|
|
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[],
|