bare-agent 0.33.0 → 0.33.1
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/bareagent.context.md +4 -4
- package/package.json +1 -1
- package/src/provider-clipipe-mcp.d.ts +30 -1
- package/src/provider-clipipe-mcp.js +119 -27
- package/src/provider-clipipe.d.ts +10 -61
- package/src/provider-clipipe.js +78 -21
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.33.
|
|
4
|
+
> v0.33.1 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
|
|
5
5
|
>
|
|
6
6
|
> Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)
|
|
7
7
|
|
|
@@ -817,11 +817,11 @@ new CLIPipe({ command: 'claude', args: ['-p', '--model', 'sonnet'], toolProtocol
|
|
|
817
817
|
**CLIPipe NATIVE tool mode (BA-16, `toolProtocol:'claude-mcp'`).** The claude CLI has a real tool channel; native mode uses it instead of emulating one. The CLI runs its OWN multi-turn session per `generate()` call and executes your `tools` natively over an MCP bridge that calls back into your in-process `execute` closures. Because the CLI owns the inner cycle, the Loop's per-round machinery cannot run — so the governance you'd wire on `Loop` moves to the **provider**, at the one seam every tool call crosses:
|
|
818
818
|
- `policy` — the SAME `(tool, args, ctx) => true|string` chokepoint as `Loop({policy})`, so a wired `wireGate(gate).policy` writes **audit rows of identical shape, zero gate changes**. A deny is a tool result (advisory); the handler never runs. **Required here** — a `Loop({policy})` in native mode would be a fence that is silently not there, so the Loop throws.
|
|
819
819
|
- `maxConsecutiveDenials` (3) / `maxIdenticalToolErrors` (3) — BA-11/BA-12 guards at the bridge, same narrowest triggers; end the session `denied:<tool>` / `stuck:<tool>`.
|
|
820
|
-
- `maxTurns` —
|
|
821
|
-
- `onTurn` —
|
|
820
|
+
- `maxTurns` — a bound on **assistant/LLM turns**, the SAME unit as the Loop path, so one number means one thing on both surfaces (BA-17). NOT a tool-call count: one turn can fire a dozen parallel tool calls and still be one turn (measured: 12 calls across 2 turns, inside `--max-turns 3`). Enforced twice — the CLI's own `--max-turns` stops cleanly at N (and emits the result event that carries the session's real cost), plus a parent-side counter that kills the session if a turn beyond N is ever seen, since that flag is undocumented in `claude --help`. The stop is `error:'max_turns'` + `stopReason:'max_turns'` and **carries the last turn's text forward** — the CLI reports `result:null` on a bounded session, so an unfixed build returned `text:''`.
|
|
821
|
+
- `onTurn` — fires once per **assistant turn** (BA-17: the CLI emits a separate stream event per content *block*, all repeating that message's usage — firing per event inflated a caller's turn axis ~5–7× and its token axis 5.04×), carrying four cache tiers and `costUsd:null` since the CLI prices the session, not the turn. Then one closing `kind:'session'` event carrying the authoritative cost **and the token residual** — a turn's `message.usage` is a snapshot taken at its first block and never revised (a turn that emitted ~816 output tokens reported 2), so the closing event makes the streamed tiers add up to exactly the CLI's own session total. Shape mirrors `onLlmResult`, so `wireGate(gate).onLlmResult` drops in; when wired the Loop skips its own forward (billed once, never starved).
|
|
822
822
|
- `sessionTimeout` (600s) / `bridgeTimeoutMs` (120s) — whole-session and per-handler ceilings.
|
|
823
823
|
|
|
824
|
-
`GenerateResult.session` (`{turns, toolCalls, error, usageReported}`) carries what really happened; `metrics.sessionTurns` reports the real turn count so a 14-turn session never reads as one round. A terminal the CLI detects inside the session (bound, guard, or a **broken tool bridge** — a dead bridge still ends `subtype:'success'`, so it is caught parent-side by attempted-vs-served tool calls) surfaces as the run's `error`, never a laundered clean finish. `assemble`/`trim`/`cacheMessages` and a Loop-level `policy` all THROW at construction in native mode (no silently-dead knobs — the CLI owns the transcript). The bridge is a unix socket (0600 in a 0700 dir), never a listening port. Claude-only for now; the CLI-specific parts live in `src/provider-clipipe-mcp.js` + `src/mcp-bridge-stub.js` behind the same seam as emulation.
|
|
824
|
+
`GenerateResult.session` (`{turns, toolCalls, error, usageReported}`) carries what really happened; `metrics.sessionTurns` reports the real turn count — assistant *messages*, not stream events — so a 14-turn session never reads as one round, and a 2-turn session never reads as 14. A terminal the CLI detects inside the session (bound, guard, or a **broken tool bridge** — a dead bridge still ends `subtype:'success'`, so it is caught parent-side by attempted-vs-served tool calls) surfaces as the run's `error`, never a laundered clean finish. `assemble`/`trim`/`cacheMessages` and a Loop-level `policy` all THROW at construction in native mode (no silently-dead knobs — the CLI owns the transcript). The bridge is a unix socket (0600 in a 0700 dir), never a listening port. Claude-only for now; the CLI-specific parts live in `src/provider-clipipe-mcp.js` + `src/mcp-bridge-stub.js` behind the same seam as emulation.
|
|
825
825
|
|
|
826
826
|
All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model?, costUsd? }`. The optional `model` (v0.16.1+) is the id the response was produced by — Loop prefers it over `provider.model` for cost accounting. By default CLIPipe returns `toolCalls: []` and zero usage (CLI tools don't report tokens) and omits `model`. **Structured output (v0.26.0+):** set `parse: 'claude-json'` (a preset for `claude -p --output-format json`) — or a `(stdout) => Partial<GenerateResult>` function for any other CLI — and CLIPipe maps the CLI's JSON envelope onto real `usage`, `model`, and `costUsd`, throwing `ProviderError` on a malformed/error envelope (never a silent raw-text fall-back). `costUsd` (optional `GenerateResult` field) is an **authoritative** per-call price the provider reports itself; when finite the Loop prefers it over the internal rate-table `estimateCost`, so a CLI-piped run enforces a bareguard USD cap with no local pricing table (a `0` counts as priced, distinct from null/unpriced). `toolCalls` stays `[]` regardless (CLIPipe is tool-free).
|
|
827
827
|
|
package/package.json
CHANGED
|
@@ -89,6 +89,21 @@ export function resolveSessionError(facts: {
|
|
|
89
89
|
/**
|
|
90
90
|
* Line-oriented processor for the CLI's `stream-json` stdout.
|
|
91
91
|
*
|
|
92
|
+
* ONE TURN IS ONE ASSISTANT MESSAGE, NOT ONE EVENT (BA-17). Measured on the real wire: the CLI
|
|
93
|
+
* emits a SEPARATE `assistant` event per content BLOCK of the same message, and every one of them
|
|
94
|
+
* repeats that message's `usage` verbatim — one 13-block message arrived as 13 events carrying the
|
|
95
|
+
* same numbers. Treating each event as a turn is wrong on both axes a caller meters:
|
|
96
|
+
* - the TURN axis — a caller whose attempt bound is an LLM-turn count sees 14 "turns" for 2 real
|
|
97
|
+
* ones (measured 7×; 4.4× on the adopter's failing run), so its net guillotines
|
|
98
|
+
* the session at a fraction of the allowance it advertised;
|
|
99
|
+
* - the TOKEN axis — the same message's usage is added once per block (measured 5.04× inflated
|
|
100
|
+
* against the CLI's own session total), so a budget cap fires early on tokens
|
|
101
|
+
* that were never spent.
|
|
102
|
+
* So a RUN of consecutive events sharing `message.id` is ONE turn: usage is recorded once and one
|
|
103
|
+
* `onTurn` fires. Adjacent-run dedup, not a Set — an id that somehow recurred later must still count
|
|
104
|
+
* as a new turn (dropping a real turn is the failure that matters). An event with NO id degrades to
|
|
105
|
+
* one-turn-per-event: the pre-BA-17 behaviour, never a collapse of the whole session into one turn.
|
|
106
|
+
*
|
|
92
107
|
* FRAMING IS SYNCHRONOUS. The obvious shape — an `async` stdout handler that `await`s `onTurn`
|
|
93
108
|
* inside its parse loop — has a data-corruption bug: while the `await` is suspended, the next
|
|
94
109
|
* `'data'` event re-enters the handler and mutates the SHARED line buffer, so the suspended parse
|
|
@@ -104,18 +119,32 @@ export function resolveSessionError(facts: {
|
|
|
104
119
|
* @param {any} o.ctx
|
|
105
120
|
* @param {number} o.startedAt
|
|
106
121
|
* @param {(err: Error) => void} o.onHalt - called if a forwarded `onTurn` throws a HaltError.
|
|
122
|
+
* @param {number|null} [o.turnCap] - BA-17 backstop: fires `onLimit` the moment a turn BEYOND this
|
|
123
|
+
* many is observed. Deliberately not `>=`: `--max-turns` is passed to the CLI too, and when it
|
|
124
|
+
* works (measured: it does, and it counts assistant turns) the CLI ends the session ITSELF at the
|
|
125
|
+
* cap and emits its `result` event — which is the only place the authoritative session cost
|
|
126
|
+
* arrives. Killing the session at exactly N would throw that figure away on every bounded run. So
|
|
127
|
+
* this fires ONLY on an overrun, i.e. only if the flag ever stops working — it is undocumented in
|
|
128
|
+
* `claude --help`, so the guarantee cannot rest on it alone.
|
|
129
|
+
* @param {(() => void)} [o.onLimit] - called once when `turnCap` is exceeded.
|
|
107
130
|
*/
|
|
108
|
-
export function createSessionStream({ onTurn, ctx, startedAt, onHalt }: {
|
|
131
|
+
export function createSessionStream({ onTurn, ctx, startedAt, onHalt, turnCap, onLimit }: {
|
|
109
132
|
onTurn: Function | null;
|
|
110
133
|
ctx: any;
|
|
111
134
|
startedAt: number;
|
|
112
135
|
onHalt: (err: Error) => void;
|
|
136
|
+
turnCap?: number | null | undefined;
|
|
137
|
+
onLimit?: (() => void) | undefined;
|
|
113
138
|
}): {
|
|
114
139
|
/** Feed a stdout chunk. Pure synchronous framing — never awaits. */
|
|
115
140
|
feed(chunk: Buffer | string): void;
|
|
116
141
|
/** Await every queued per-turn forward — call before resolving the session. */
|
|
117
142
|
flush(): Promise<void>;
|
|
118
143
|
readonly turns: import("../types").Usage[];
|
|
144
|
+
/** Assistant TURNS observed — including any that carried no usage. */
|
|
145
|
+
readonly turnCount: number;
|
|
146
|
+
/** The last turn's text. The work a bounded/guard-stopped session still did (BA-5). */
|
|
147
|
+
readonly lastText: string;
|
|
119
148
|
readonly attempted: number;
|
|
120
149
|
readonly final: any;
|
|
121
150
|
};
|
|
@@ -257,6 +257,13 @@ const SUBTYPE_MAP = {
|
|
|
257
257
|
error_during_execution: { stopReason: null, error: 'session_error' },
|
|
258
258
|
};
|
|
259
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Terminals WE impose that ARE stop reasons in the neutral vocabulary. A guard terminal
|
|
262
|
+
* (`denied:`/`stuck:`) is deliberately absent — those are faults, not stop reasons.
|
|
263
|
+
* @type {Record<string, string>}
|
|
264
|
+
*/
|
|
265
|
+
const TERMINAL_STOP = { max_turns: 'max_turns' };
|
|
266
|
+
|
|
260
267
|
/**
|
|
261
268
|
* @param {string|null|undefined} subtype
|
|
262
269
|
* @returns {{stopReason: string|null, error: string|null}}
|
|
@@ -291,7 +298,15 @@ function classifySubtype(subtype) {
|
|
|
291
298
|
*/
|
|
292
299
|
function resolveSessionError(facts) {
|
|
293
300
|
const fromSubtype = classifySubtype(facts.subtype);
|
|
294
|
-
if (facts.terminal)
|
|
301
|
+
if (facts.terminal) {
|
|
302
|
+
// A terminal WE imposed kills the session before its `result` event, so there is no subtype to
|
|
303
|
+
// read a stop reason from. Where the terminal IS a stop reason in the neutral vocabulary, say so
|
|
304
|
+
// — a consumer branching on `stopReason` must not see `null` for a bound it asked for.
|
|
305
|
+
// Own-property only: same proto-key footgun as SUBTYPE_MAP.
|
|
306
|
+
const named = Object.prototype.hasOwnProperty.call(TERMINAL_STOP, facts.terminal)
|
|
307
|
+
? TERMINAL_STOP[facts.terminal] : null;
|
|
308
|
+
return { stopReason: named || fromSubtype.stopReason, error: facts.terminal };
|
|
309
|
+
}
|
|
295
310
|
const bridgeBroken = facts.bridgeDown
|
|
296
311
|
|| (Number.isFinite(facts.attempted) && Number.isFinite(facts.served) && facts.attempted > facts.served);
|
|
297
312
|
if (bridgeBroken) return { stopReason: fromSubtype.stopReason, error: 'bridge-failed' };
|
|
@@ -302,6 +317,21 @@ function resolveSessionError(facts) {
|
|
|
302
317
|
/**
|
|
303
318
|
* Line-oriented processor for the CLI's `stream-json` stdout.
|
|
304
319
|
*
|
|
320
|
+
* ONE TURN IS ONE ASSISTANT MESSAGE, NOT ONE EVENT (BA-17). Measured on the real wire: the CLI
|
|
321
|
+
* emits a SEPARATE `assistant` event per content BLOCK of the same message, and every one of them
|
|
322
|
+
* repeats that message's `usage` verbatim — one 13-block message arrived as 13 events carrying the
|
|
323
|
+
* same numbers. Treating each event as a turn is wrong on both axes a caller meters:
|
|
324
|
+
* - the TURN axis — a caller whose attempt bound is an LLM-turn count sees 14 "turns" for 2 real
|
|
325
|
+
* ones (measured 7×; 4.4× on the adopter's failing run), so its net guillotines
|
|
326
|
+
* the session at a fraction of the allowance it advertised;
|
|
327
|
+
* - the TOKEN axis — the same message's usage is added once per block (measured 5.04× inflated
|
|
328
|
+
* against the CLI's own session total), so a budget cap fires early on tokens
|
|
329
|
+
* that were never spent.
|
|
330
|
+
* So a RUN of consecutive events sharing `message.id` is ONE turn: usage is recorded once and one
|
|
331
|
+
* `onTurn` fires. Adjacent-run dedup, not a Set — an id that somehow recurred later must still count
|
|
332
|
+
* as a new turn (dropping a real turn is the failure that matters). An event with NO id degrades to
|
|
333
|
+
* one-turn-per-event: the pre-BA-17 behaviour, never a collapse of the whole session into one turn.
|
|
334
|
+
*
|
|
305
335
|
* FRAMING IS SYNCHRONOUS. The obvious shape — an `async` stdout handler that `await`s `onTurn`
|
|
306
336
|
* inside its parse loop — has a data-corruption bug: while the `await` is suspended, the next
|
|
307
337
|
* `'data'` event re-enters the handler and mutates the SHARED line buffer, so the suspended parse
|
|
@@ -317,11 +347,28 @@ function resolveSessionError(facts) {
|
|
|
317
347
|
* @param {any} o.ctx
|
|
318
348
|
* @param {number} o.startedAt
|
|
319
349
|
* @param {(err: Error) => void} o.onHalt - called if a forwarded `onTurn` throws a HaltError.
|
|
350
|
+
* @param {number|null} [o.turnCap] - BA-17 backstop: fires `onLimit` the moment a turn BEYOND this
|
|
351
|
+
* many is observed. Deliberately not `>=`: `--max-turns` is passed to the CLI too, and when it
|
|
352
|
+
* works (measured: it does, and it counts assistant turns) the CLI ends the session ITSELF at the
|
|
353
|
+
* cap and emits its `result` event — which is the only place the authoritative session cost
|
|
354
|
+
* arrives. Killing the session at exactly N would throw that figure away on every bounded run. So
|
|
355
|
+
* this fires ONLY on an overrun, i.e. only if the flag ever stops working — it is undocumented in
|
|
356
|
+
* `claude --help`, so the guarantee cannot rest on it alone.
|
|
357
|
+
* @param {(() => void)} [o.onLimit] - called once when `turnCap` is exceeded.
|
|
320
358
|
*/
|
|
321
|
-
function createSessionStream({ onTurn, ctx, startedAt, onHalt }) {
|
|
359
|
+
function createSessionStream({ onTurn, ctx, startedAt, onHalt, turnCap = null, onLimit = () => {} }) {
|
|
322
360
|
let buf = '';
|
|
323
361
|
let attempted = 0;
|
|
324
362
|
let final = null;
|
|
363
|
+
/** Adjacent-run dedup key: the `message.id` of the turn currently being emitted. */
|
|
364
|
+
let turnId = /** @type {string|null} */ (null);
|
|
365
|
+
/** Has the CURRENT turn already contributed its usage? Blocks of one message repeat it. */
|
|
366
|
+
let turnMetered = false;
|
|
367
|
+
let turnCount = 0;
|
|
368
|
+
let limitFired = false;
|
|
369
|
+
/** Text of the current turn, and the last turn that produced any — BA-5 work preservation. */
|
|
370
|
+
let curText = '';
|
|
371
|
+
let lastText = '';
|
|
325
372
|
/** @type {import('../types').Usage[]} */ const turns = [];
|
|
326
373
|
/** @type {any[]} */ const queue = [];
|
|
327
374
|
/** @type {Promise<void>|null} */ let draining = null;
|
|
@@ -367,29 +414,59 @@ function createSessionStream({ onTurn, ctx, startedAt, onHalt }) {
|
|
|
367
414
|
}
|
|
368
415
|
}
|
|
369
416
|
|
|
370
|
-
if (ev.type === 'assistant' && ev.message
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
417
|
+
if (ev.type === 'assistant' && ev.message) {
|
|
418
|
+
// Is this event the start of a NEW assistant turn, or another block of the current one?
|
|
419
|
+
// No id at all ⇒ its own turn (degrade to the pre-BA-17 shape, never collapse into one).
|
|
420
|
+
const id = (typeof ev.message.id === 'string' && ev.message.id) ? ev.message.id : null;
|
|
421
|
+
if (id === null || id !== turnId) {
|
|
422
|
+
turnId = id;
|
|
423
|
+
turnMetered = false;
|
|
424
|
+
curText = '';
|
|
425
|
+
turnCount++;
|
|
426
|
+
// The overrun backstop. `>` not `>=`, so a CLI that honours --max-turns keeps its clean
|
|
427
|
+
// exit (and with it the only report of the session's real cost).
|
|
428
|
+
if (!limitFired && Number.isFinite(turnCap) && Number(turnCap) > 0 && turnCount > Number(turnCap)) {
|
|
429
|
+
limitFired = true;
|
|
430
|
+
onLimit();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// BA-5: keep the turn's own words, so a bound/guard stop still returns the work done. The
|
|
435
|
+
// CLI reports `result: null` on a bounded session — measured — so this is the ONLY source.
|
|
436
|
+
for (const block of (ev.message.content || [])) {
|
|
437
|
+
if (block && block.type === 'text' && typeof block.text === 'string' && block.text) {
|
|
438
|
+
curText += block.text;
|
|
439
|
+
lastText = curText;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Usage rides on EVERY block-event of the message; count it once per turn. Read on any
|
|
444
|
+
// event of the turn (not just the first) — the first block need not be the one carrying it.
|
|
445
|
+
if (ev.message.usage && !turnMetered) {
|
|
446
|
+
turnMetered = true;
|
|
447
|
+
const u = ev.message.usage;
|
|
448
|
+
/** @type {import('../types').Usage} */
|
|
449
|
+
const usage = {
|
|
450
|
+
inputTokens: Number(u.input_tokens) || 0,
|
|
451
|
+
outputTokens: Number(u.output_tokens) || 0,
|
|
452
|
+
};
|
|
453
|
+
// Omit an absent tier rather than emit a synthetic 0 (per the Usage contract).
|
|
454
|
+
if (Number.isFinite(u.cache_read_input_tokens)) usage.cacheReadTokens = u.cache_read_input_tokens;
|
|
455
|
+
if (Number.isFinite(u.cache_creation_input_tokens)) usage.cacheCreationTokens = u.cache_creation_input_tokens;
|
|
456
|
+
turns.push(usage);
|
|
457
|
+
// Stream it: a session that dies mid-run must already have surfaced every completed turn's
|
|
458
|
+
// spend, or the gate loses it. Queued (not awaited here) so framing cannot race the buffer.
|
|
459
|
+
if (onTurn) queue.push({
|
|
460
|
+
model: ev.message.model || null,
|
|
461
|
+
provider: 'clipipe',
|
|
462
|
+
usage,
|
|
463
|
+
costUsd: null, // the CLI prices the SESSION, not the turn — explicitly unpriced, never a synthetic 0.
|
|
464
|
+
pricing: 'unpriced',
|
|
465
|
+
durationMs: Date.now() - startedAt,
|
|
466
|
+
ctx, // what a wired gate records spend against — same as the Loop's onLlmResult.
|
|
467
|
+
kind: 'turn',
|
|
468
|
+
});
|
|
469
|
+
}
|
|
393
470
|
}
|
|
394
471
|
|
|
395
472
|
if (ev.type === 'result') final = ev;
|
|
@@ -399,6 +476,10 @@ function createSessionStream({ onTurn, ctx, startedAt, onHalt }) {
|
|
|
399
476
|
/** Await every queued per-turn forward — call before resolving the session. */
|
|
400
477
|
async flush() { if (draining) await draining; await drain(); },
|
|
401
478
|
get turns() { return turns; },
|
|
479
|
+
/** Assistant TURNS observed — including any that carried no usage. */
|
|
480
|
+
get turnCount() { return turnCount; },
|
|
481
|
+
/** The last turn's text. The work a bounded/guard-stopped session still did (BA-5). */
|
|
482
|
+
get lastText() { return lastText; },
|
|
402
483
|
get attempted() { return attempted; },
|
|
403
484
|
get final() { return final; },
|
|
404
485
|
};
|
|
@@ -452,11 +533,18 @@ function runSession(opts) {
|
|
|
452
533
|
const child = spawn(command, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
453
534
|
let stderr = '', settled = false;
|
|
454
535
|
/** @type {Error|null} */ let turnHalt = null;
|
|
536
|
+
/** @type {string|null} A terminal WE imposed from the stream (today: the BA-17 turn backstop). */
|
|
537
|
+
let terminal = null;
|
|
455
538
|
const started = Date.now();
|
|
456
539
|
|
|
457
540
|
/** Kill the session now — a guard tripped or governance halted mid-flight. */
|
|
458
541
|
const abort = () => { try { child.kill('SIGTERM'); } catch (_) { /* already gone */ } };
|
|
459
|
-
const stream = createSessionStream({
|
|
542
|
+
const stream = createSessionStream({
|
|
543
|
+
onTurn, ctx, startedAt: started,
|
|
544
|
+
onHalt: (err) => { turnHalt = err; abort(); },
|
|
545
|
+
turnCap: Number.isFinite(maxTurns) ? maxTurns : null,
|
|
546
|
+
onLimit: () => { terminal = 'max_turns'; abort(); },
|
|
547
|
+
});
|
|
460
548
|
|
|
461
549
|
const done = async (extra = {}) => {
|
|
462
550
|
if (settled) return;
|
|
@@ -465,7 +553,11 @@ function runSession(opts) {
|
|
|
465
553
|
// Flush queued per-turn forwards before resolving — a session that ends while a forward is
|
|
466
554
|
// pending must still surface that turn's spend to the gate (F12/F18).
|
|
467
555
|
try { await stream.flush(); } catch (_) { /* onTurn failures are surfaced in-drain, never fatal */ }
|
|
468
|
-
resolve({
|
|
556
|
+
resolve({
|
|
557
|
+
turns: stream.turns, turnCount: stream.turnCount, lastText: stream.lastText,
|
|
558
|
+
final: stream.final, stderr, ms: Date.now() - started, turnHalt, terminal,
|
|
559
|
+
attempted: stream.attempted, ...extra,
|
|
560
|
+
});
|
|
469
561
|
};
|
|
470
562
|
|
|
471
563
|
const timer = setTimeout(() => {
|
|
@@ -68,8 +68,16 @@ export type CLIPipeOptions = {
|
|
|
68
68
|
*/
|
|
69
69
|
onTurn?: Function | undefined;
|
|
70
70
|
/**
|
|
71
|
-
* - (native mode)
|
|
72
|
-
*
|
|
71
|
+
* - (native mode) Bound on ASSISTANT/LLM TURNS — the same unit as the
|
|
72
|
+
* Loop path's turn bound, so a caller's `maxTurns` means one thing on both surfaces (BA-17). NOT a
|
|
73
|
+
* tool-call count: a single turn may issue a dozen parallel tool calls and still be one turn
|
|
74
|
+
* (measured: 12 calls across 2 turns, well inside `--max-turns 3`). Enforced twice on purpose —
|
|
75
|
+
* the CLI's own `--max-turns` stops the session cleanly at N and emits its result event (the only
|
|
76
|
+
* report of the session's real cost), and a parent-side counter kills it if a turn beyond N is
|
|
77
|
+
* ever observed, since that flag is undocumented in `claude --help` and a rename would otherwise
|
|
78
|
+
* silently unbound the session. Either way the stop is NAMED (`session.error:'max_turns'`,
|
|
79
|
+
* `stopReason:'max_turns'`) and carries the last turn's text forward, never a silent clean success
|
|
80
|
+
* and never an empty result.
|
|
73
81
|
*/
|
|
74
82
|
maxTurns?: number | undefined;
|
|
75
83
|
/**
|
|
@@ -107,65 +115,6 @@ export type CLIPipeOptions = {
|
|
|
107
115
|
*/
|
|
108
116
|
probeCapability?: boolean | undefined;
|
|
109
117
|
};
|
|
110
|
-
/** @typedef {import('../types').Message} Message */
|
|
111
|
-
/** @typedef {import('../types').ToolDef} ToolDef */
|
|
112
|
-
/** @typedef {import('../types').GenerateResult} GenerateResult */
|
|
113
|
-
/**
|
|
114
|
-
* @typedef {object} CLIPipeOptions
|
|
115
|
-
* @property {string} [command] - CLI command to spawn (required).
|
|
116
|
-
* @property {string[]} [args=[]] - Arguments to pass to the command.
|
|
117
|
-
* @property {string} [cwd] - Working directory for the child process.
|
|
118
|
-
* @property {Record<string, string>} [env] - Environment variables for the child process.
|
|
119
|
-
* @property {number} [timeout=30000] - Timeout in milliseconds.
|
|
120
|
-
* @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.
|
|
121
|
-
* @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
|
|
122
|
-
* @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.
|
|
123
|
-
* @property {'claude'|'claude-mcp'} [toolProtocol] - Opt into TOOL MODE. Two modes, and the choice is
|
|
124
|
-
* about COST, not capability. `'claude-mcp'` (BA-16, NATIVE — prefer this on the claude CLI): one CLI
|
|
125
|
-
* session per call, the caller's `tools` exposed to it as a real MCP server whose handlers call back
|
|
126
|
-
* into your own in-process closures. The CLI owns the inner cycle and caches its transcript
|
|
127
|
-
* session-side. `'claude'` (v0.32.0, EMULATION): one CLI spawn per round with the whole transcript
|
|
128
|
-
* re-rendered and re-sent, parsed back through a JSON envelope. Emulation re-buys the full prefix
|
|
129
|
-
* every turn, which the adopter measured at **$0.25–0.55/round** against **~$0.006/turn** native — so
|
|
130
|
-
* it is the right instrument only for a CLI with NO MCP support, not a default. NOT claimed for
|
|
131
|
-
* native: better output quality (n=2 suggestive evidence exists and is deliberately unminted).
|
|
132
|
-
* Native mode sets {@link CLIPipeProvider#ownsCycle}, which makes the Loop REFUSE options it could
|
|
133
|
-
* never honor (`assemble`/`trim`/`cacheMessages`, and a Loop-level `policy`) instead of leaving them
|
|
134
|
-
* silently dead. See the native-only properties below.
|
|
135
|
-
* @property {(tool: string, args: any, ctx?: any) => any} [policy] - (native mode) The gate, same contract as `Loop({policy})`: only `true`
|
|
136
|
-
* allows, a string is the deny reason fed back verbatim, a thrown `HaltError` is a clean governance
|
|
137
|
-
* exit. REQUIRED here rather than on the Loop, because in native mode no tool call ever reaches the
|
|
138
|
-
* Loop — a `Loop({policy})` would be a fence that is silently not there (the Loop throws instead).
|
|
139
|
-
* Wiring the same `wireGate(gate).policy` keeps audit rows byte-shape-identical, with zero gate changes.
|
|
140
|
-
* @property {Function} [onTurn] - (native mode) Called with `{model, provider, usage, costUsd, pricing,
|
|
141
|
-
* durationMs, ctx, kind}` for EACH completed CLI turn as it arrives (`kind:'turn'`, four cache tiers,
|
|
142
|
-
* `costUsd:null` — the CLI prices the session, not the turn), then once at session end
|
|
143
|
-
* (`kind:'session'`) carrying the authoritative total cost with zero usage. Streaming, never
|
|
144
|
-
* sum-at-end: a session that dies mid-run must already have surfaced every completed turn's spend or
|
|
145
|
-
* the gate loses all of it. The event shape mirrors `Loop({onLlmResult})`, so `wireGate(gate).onLlmResult`
|
|
146
|
-
* drops straight in — and when it is wired the Loop skips its own forward, so nothing is billed twice.
|
|
147
|
-
* @property {number} [maxTurns] - (native mode) Maps to the CLI's `--max-turns`. The bound stop is NAMED
|
|
148
|
-
* (`error_max_turns` → `session.error:'max_turns'`), never a silent clean success.
|
|
149
|
-
* @property {number} [maxConsecutiveDenials=3] - (native mode) BA-11 at the bridge: a single deny stays
|
|
150
|
-
* advisory so the model can pivot to an allowed tool; N in a row with no allowed call between ends the
|
|
151
|
-
* session with `denied:<tool>`. `0`/`Infinity` disables.
|
|
152
|
-
* @property {number} [maxIdenticalToolErrors=3] - (native mode) BA-12 at the bridge: only a BYTE-IDENTICAL
|
|
153
|
-
* repeat (name + JSON args) counts, so a model varying its args while recovering is never punished. N in
|
|
154
|
-
* a row ends the session with `stuck:<tool>`. `0`/`Infinity` disables.
|
|
155
|
-
* @property {number} [sessionTimeout=600000] - (native mode) Wall-clock ceiling for one whole session. The
|
|
156
|
-
* 30s `timeout` default is for one-shot text and would kill an agentic session mid-run.
|
|
157
|
-
* @property {number} [bridgeTimeoutMs] - (native mode) Ceiling for ONE tool-handler round-trip across the
|
|
158
|
-
* bridge. A hung handler becomes an error tool result rather than a hung session (default 120s).
|
|
159
|
-
*
|
|
160
|
-
* Either mode: a non-empty `tools` array on `generate()` routes to tool mode; an empty one stays
|
|
161
|
-
* plain text. With NO `toolProtocol`, `tools` are IGNORED (plain-text, the long-standing behavior —
|
|
162
|
-
* a non-tool-calling CLI legitimately sits in a Loop with tools mounted) plus a one-time `console.warn`.
|
|
163
|
-
* Emulation additionally requires a capable model (weak ones answer in prose; see `probeCapability`);
|
|
164
|
-
* native mode needs no such probe, because the CLI's own tool channel does not depend on the model
|
|
165
|
-
* agreeing to fill in a JSON questionnaire. The claude-specific parts of each live in
|
|
166
|
-
* `provider-clipipe-tools.js` / `provider-clipipe-mcp.js`, so a second CLI slots in behind the same seams.
|
|
167
|
-
* @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.
|
|
168
|
-
*/
|
|
169
118
|
export class CLIPipeProvider {
|
|
170
119
|
/**
|
|
171
120
|
* Provider that pipes prompts to a CLI command via stdin and reads stdout.
|
package/src/provider-clipipe.js
CHANGED
|
@@ -43,8 +43,16 @@ const { createBridge, resolveSessionError, runSession } = require('./provider-cl
|
|
|
43
43
|
* sum-at-end: a session that dies mid-run must already have surfaced every completed turn's spend or
|
|
44
44
|
* the gate loses all of it. The event shape mirrors `Loop({onLlmResult})`, so `wireGate(gate).onLlmResult`
|
|
45
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)
|
|
47
|
-
*
|
|
46
|
+
* @property {number} [maxTurns] - (native mode) Bound on ASSISTANT/LLM TURNS — the same unit as the
|
|
47
|
+
* Loop path's turn bound, so a caller's `maxTurns` means one thing on both surfaces (BA-17). NOT a
|
|
48
|
+
* tool-call count: a single turn may issue a dozen parallel tool calls and still be one turn
|
|
49
|
+
* (measured: 12 calls across 2 turns, well inside `--max-turns 3`). Enforced twice on purpose —
|
|
50
|
+
* the CLI's own `--max-turns` stops the session cleanly at N and emits its result event (the only
|
|
51
|
+
* report of the session's real cost), and a parent-side counter kills it if a turn beyond N is
|
|
52
|
+
* ever observed, since that flag is undocumented in `claude --help` and a rename would otherwise
|
|
53
|
+
* silently unbound the session. Either way the stop is NAMED (`session.error:'max_turns'`,
|
|
54
|
+
* `stopReason:'max_turns'`) and carries the last turn's text forward, never a silent clean success
|
|
55
|
+
* and never an empty result.
|
|
48
56
|
* @property {number} [maxConsecutiveDenials=3] - (native mode) BA-11 at the bridge: a single deny stays
|
|
49
57
|
* advisory so the model can pivot to an allowed tool; N in a row with no allowed call between ends the
|
|
50
58
|
* session with `denied:<tool>`. `0`/`Infinity` disables.
|
|
@@ -66,6 +74,31 @@ const { createBridge, resolveSessionError, runSession } = require('./provider-cl
|
|
|
66
74
|
* @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.
|
|
67
75
|
*/
|
|
68
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Session total minus what the per-turn events already reported, per tier, floored at 0.
|
|
79
|
+
*
|
|
80
|
+
* Floored because a negative would be a CREDIT to a gate's running total — an under-count that
|
|
81
|
+
* silently widens a budget cap. If the streamed turns ever overshoot the session total, the honest
|
|
82
|
+
* report is "nothing further", never "give some back".
|
|
83
|
+
*
|
|
84
|
+
* @param {import('../types').Usage} total
|
|
85
|
+
* @param {import('../types').Usage[]} streamed
|
|
86
|
+
* @returns {import('../types').Usage}
|
|
87
|
+
*/
|
|
88
|
+
function subtractUsage(total, streamed) {
|
|
89
|
+
const sum = (/** @type {keyof import('../types').Usage} */ k) =>
|
|
90
|
+
streamed.reduce((a, t) => a + (Number(t[k]) || 0), 0);
|
|
91
|
+
const at = (/** @type {keyof import('../types').Usage} */ k) =>
|
|
92
|
+
Math.max(0, (Number(total[k]) || 0) - sum(k));
|
|
93
|
+
/** @type {import('../types').Usage} */
|
|
94
|
+
const out = { inputTokens: at('inputTokens'), outputTokens: at('outputTokens') };
|
|
95
|
+
// Only report a cache tier the session actually had — an absent tier stays absent, never a
|
|
96
|
+
// synthetic 0 (the Usage contract).
|
|
97
|
+
if (total.cacheReadTokens !== undefined) out.cacheReadTokens = at('cacheReadTokens');
|
|
98
|
+
if (total.cacheCreationTokens !== undefined) out.cacheCreationTokens = at('cacheCreationTokens');
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
69
102
|
class CLIPipeProvider {
|
|
70
103
|
/**
|
|
71
104
|
* Provider that pipes prompts to a CLI command via stdin and reads stdout.
|
|
@@ -289,17 +322,25 @@ class CLIPipeProvider {
|
|
|
289
322
|
throw new ProviderError(`[CLIPipeProvider] failed to spawn "${this.command}": ${r.spawnError.message}`, /** @type {any} */ ({ status: 0 }));
|
|
290
323
|
}
|
|
291
324
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
325
|
+
// The result event carries the session's authoritative totals. It has no `model` key — the model
|
|
326
|
+
// id lives under `modelUsage` — which is exactly what `mapClaudeMeta` already unpacks for the
|
|
327
|
+
// emulation path, so the native path reuses it rather than re-deriving three fields by hand.
|
|
328
|
+
const meta = r.final ? mapClaudeMeta(r.final) : null;
|
|
329
|
+
|
|
330
|
+
// `result.usage` is the authoritative session total and is preferred when present: it also
|
|
331
|
+
// captures a turn the CLI billed but never emitted as an event (measured — a bounded session's
|
|
332
|
+
// cut-off turn). Summing the per-turn records is the fallback for a session we killed before its
|
|
333
|
+
// result event. Either way the arithmetic is per-TURN, never per block-event (BA-17).
|
|
334
|
+
const usage = (meta && r.final.usage) ? meta.usage : r.turns.reduce((/** @type {any} */ a, t) => ({
|
|
335
|
+
inputTokens: a.inputTokens + (t.inputTokens || 0),
|
|
336
|
+
outputTokens: a.outputTokens + (t.outputTokens || 0),
|
|
337
|
+
cacheReadTokens: a.cacheReadTokens + (t.cacheReadTokens || 0),
|
|
338
|
+
cacheCreationTokens: a.cacheCreationTokens + (t.cacheCreationTokens || 0),
|
|
339
|
+
}), { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 });
|
|
300
340
|
|
|
301
341
|
const { stopReason, error } = resolveSessionError({
|
|
302
|
-
terminal
|
|
342
|
+
// A bridge/guard terminal is more specific than the turn backstop, so it wins the tag.
|
|
343
|
+
terminal: st.terminal || r.terminal,
|
|
303
344
|
bridgeDown: st.bridgeDown,
|
|
304
345
|
attempted: r.attempted,
|
|
305
346
|
served: st.toolCalls,
|
|
@@ -307,17 +348,25 @@ class CLIPipeProvider {
|
|
|
307
348
|
subtype: r.final && r.final.subtype,
|
|
308
349
|
});
|
|
309
350
|
|
|
310
|
-
const costUsd = (
|
|
311
|
-
|
|
312
|
-
// The authoritative
|
|
313
|
-
// so when per-turn streaming is wired
|
|
314
|
-
//
|
|
351
|
+
const costUsd = (meta && Number.isFinite(meta.costUsd)) ? /** @type {number} */ (meta.costUsd) : null;
|
|
352
|
+
|
|
353
|
+
// The authoritative figures arrive only at session end — the CLI prices the SESSION, not the
|
|
354
|
+
// turn — so when per-turn streaming is wired, one closing event RECONCILES both axes.
|
|
355
|
+
//
|
|
356
|
+
// Money: the whole cost, which no turn reported.
|
|
357
|
+
// Tokens: the RESIDUAL, not zero and not the total. A turn's `message.usage` is a snapshot taken
|
|
358
|
+
// when its first block was emitted and never revised (measured: a turn that emitted ~816 output
|
|
359
|
+
// tokens reported 2, identically on all 13 of its block-events), so the streamed per-turn sum is
|
|
360
|
+
// real but SHORT of the session total. Sending the difference makes a gate's token axis add up
|
|
361
|
+
// to exactly what the CLI itself reports — where sending the total would double-count everything
|
|
362
|
+
// already streamed, and sending zero would leave the axis quietly under-fed.
|
|
363
|
+
const residual = subtractUsage(usage, r.turns);
|
|
315
364
|
if (this.onTurn) {
|
|
316
365
|
try {
|
|
317
366
|
await this.onTurn({
|
|
318
|
-
model: (
|
|
367
|
+
model: (meta && meta.model) || null,
|
|
319
368
|
provider: 'clipipe',
|
|
320
|
-
usage:
|
|
369
|
+
usage: residual,
|
|
321
370
|
costUsd,
|
|
322
371
|
pricing: costUsd === null ? 'unpriced' : 'priced',
|
|
323
372
|
durationMs: r.ms,
|
|
@@ -329,15 +378,23 @@ class CLIPipeProvider {
|
|
|
329
378
|
}
|
|
330
379
|
}
|
|
331
380
|
|
|
381
|
+
// BA-5 on the native path: a bound or a tripped guard is normal termination for a bounded
|
|
382
|
+
// attempt, and the text is the ONLY channel from this attempt to the next. The CLI reports
|
|
383
|
+
// `result: null` when it stops on its own bound (measured), and a session we killed never emits
|
|
384
|
+
// a result at all — so fall back to the last assistant turn's own words rather than ''.
|
|
385
|
+
const finalText = (r.final && typeof r.final.result === 'string' && r.final.result)
|
|
386
|
+
? r.final.result
|
|
387
|
+
: (r.lastText || '');
|
|
388
|
+
|
|
332
389
|
/** @type {GenerateResult} */
|
|
333
390
|
const result = {
|
|
334
|
-
text:
|
|
391
|
+
text: finalText,
|
|
335
392
|
toolCalls: [],
|
|
336
393
|
usage,
|
|
337
|
-
model: (
|
|
394
|
+
model: (meta && meta.model) || null,
|
|
338
395
|
stopReason,
|
|
339
396
|
session: {
|
|
340
|
-
turns: r.
|
|
397
|
+
turns: r.turnCount,
|
|
341
398
|
toolCalls: st.toolCalls,
|
|
342
399
|
error,
|
|
343
400
|
// Only true when we ACTUALLY streamed — unwired, the Loop must still forward the total or
|