bare-agent 0.32.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 +21 -7
- package/package.json +1 -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.d.ts +146 -5
- package/src/provider-clipipe.js +206 -4
- package/types/index.d.ts +40 -0
|
@@ -35,11 +35,75 @@ export type CLIPipeOptions = {
|
|
|
35
35
|
*/
|
|
36
36
|
parse?: "claude-json" | ((stdout: string) => Partial<GenerateResult>) | undefined;
|
|
37
37
|
/**
|
|
38
|
-
* - Opt into TOOL MODE
|
|
38
|
+
* - Opt into TOOL MODE. Two modes, and the choice is
|
|
39
|
+
* about COST, not capability. `'claude-mcp'` (BA-16, NATIVE — prefer this on the claude CLI): one CLI
|
|
40
|
+
* session per call, the caller's `tools` exposed to it as a real MCP server whose handlers call back
|
|
41
|
+
* into your own in-process closures. The CLI owns the inner cycle and caches its transcript
|
|
42
|
+
* session-side. `'claude'` (v0.32.0, EMULATION): one CLI spawn per round with the whole transcript
|
|
43
|
+
* re-rendered and re-sent, parsed back through a JSON envelope. Emulation re-buys the full prefix
|
|
44
|
+
* every turn, which the adopter measured at **$0.25–0.55/round** against **~$0.006/turn** native — so
|
|
45
|
+
* it is the right instrument only for a CLI with NO MCP support, not a default. NOT claimed for
|
|
46
|
+
* native: better output quality (n=2 suggestive evidence exists and is deliberately unminted).
|
|
47
|
+
* Native mode sets {@link CLIPipeProvider#ownsCycle}, which makes the Loop REFUSE options it could
|
|
48
|
+
* never honor (`assemble`/`trim`/`cacheMessages`, and a Loop-level `policy`) instead of leaving them
|
|
49
|
+
* silently dead. See the native-only properties below.
|
|
39
50
|
*/
|
|
40
|
-
toolProtocol?: "claude" | undefined;
|
|
51
|
+
toolProtocol?: "claude" | "claude-mcp" | undefined;
|
|
41
52
|
/**
|
|
42
|
-
* - (
|
|
53
|
+
* - (native mode) The gate, same contract as `Loop({policy})`: only `true`
|
|
54
|
+
* allows, a string is the deny reason fed back verbatim, a thrown `HaltError` is a clean governance
|
|
55
|
+
* exit. REQUIRED here rather than on the Loop, because in native mode no tool call ever reaches the
|
|
56
|
+
* Loop — a `Loop({policy})` would be a fence that is silently not there (the Loop throws instead).
|
|
57
|
+
* Wiring the same `wireGate(gate).policy` keeps audit rows byte-shape-identical, with zero gate changes.
|
|
58
|
+
*/
|
|
59
|
+
policy?: ((tool: string, args: any, ctx?: any) => any) | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* - (native mode) Called with `{model, provider, usage, costUsd, pricing,
|
|
62
|
+
* durationMs, ctx, kind}` for EACH completed CLI turn as it arrives (`kind:'turn'`, four cache tiers,
|
|
63
|
+
* `costUsd:null` — the CLI prices the session, not the turn), then once at session end
|
|
64
|
+
* (`kind:'session'`) carrying the authoritative total cost with zero usage. Streaming, never
|
|
65
|
+
* sum-at-end: a session that dies mid-run must already have surfaced every completed turn's spend or
|
|
66
|
+
* the gate loses all of it. The event shape mirrors `Loop({onLlmResult})`, so `wireGate(gate).onLlmResult`
|
|
67
|
+
* drops straight in — and when it is wired the Loop skips its own forward, so nothing is billed twice.
|
|
68
|
+
*/
|
|
69
|
+
onTurn?: Function | undefined;
|
|
70
|
+
/**
|
|
71
|
+
* - (native mode) Maps to the CLI's `--max-turns`. The bound stop is NAMED
|
|
72
|
+
* (`error_max_turns` → `session.error:'max_turns'`), never a silent clean success.
|
|
73
|
+
*/
|
|
74
|
+
maxTurns?: number | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* - (native mode) BA-11 at the bridge: a single deny stays
|
|
77
|
+
* advisory so the model can pivot to an allowed tool; N in a row with no allowed call between ends the
|
|
78
|
+
* session with `denied:<tool>`. `0`/`Infinity` disables.
|
|
79
|
+
*/
|
|
80
|
+
maxConsecutiveDenials?: number | undefined;
|
|
81
|
+
/**
|
|
82
|
+
* - (native mode) BA-12 at the bridge: only a BYTE-IDENTICAL
|
|
83
|
+
* repeat (name + JSON args) counts, so a model varying its args while recovering is never punished. N in
|
|
84
|
+
* a row ends the session with `stuck:<tool>`. `0`/`Infinity` disables.
|
|
85
|
+
*/
|
|
86
|
+
maxIdenticalToolErrors?: number | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* - (native mode) Wall-clock ceiling for one whole session. The
|
|
89
|
+
* 30s `timeout` default is for one-shot text and would kill an agentic session mid-run.
|
|
90
|
+
*/
|
|
91
|
+
sessionTimeout?: number | undefined;
|
|
92
|
+
/**
|
|
93
|
+
* - (native mode) Ceiling for ONE tool-handler round-trip across the
|
|
94
|
+
* bridge. A hung handler becomes an error tool result rather than a hung session (default 120s).
|
|
95
|
+
*
|
|
96
|
+
* Either mode: a non-empty `tools` array on `generate()` routes to tool mode; an empty one stays
|
|
97
|
+
* plain text. With NO `toolProtocol`, `tools` are IGNORED (plain-text, the long-standing behavior —
|
|
98
|
+
* a non-tool-calling CLI legitimately sits in a Loop with tools mounted) plus a one-time `console.warn`.
|
|
99
|
+
* Emulation additionally requires a capable model (weak ones answer in prose; see `probeCapability`);
|
|
100
|
+
* native mode needs no such probe, because the CLI's own tool channel does not depend on the model
|
|
101
|
+
* agreeing to fill in a JSON questionnaire. The claude-specific parts of each live in
|
|
102
|
+
* `provider-clipipe-tools.js` / `provider-clipipe-mcp.js`, so a second CLI slots in behind the same seams.
|
|
103
|
+
*/
|
|
104
|
+
bridgeTimeoutMs?: number | undefined;
|
|
105
|
+
/**
|
|
106
|
+
* - (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.
|
|
43
107
|
*/
|
|
44
108
|
probeCapability?: boolean | undefined;
|
|
45
109
|
};
|
|
@@ -56,8 +120,51 @@ export type CLIPipeOptions = {
|
|
|
56
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.
|
|
57
121
|
* @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
|
|
58
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.
|
|
59
|
-
* @property {'claude'} [toolProtocol] - Opt into TOOL MODE
|
|
60
|
-
*
|
|
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.
|
|
61
168
|
*/
|
|
62
169
|
export class CLIPipeProvider {
|
|
63
170
|
/**
|
|
@@ -74,6 +181,13 @@ export class CLIPipeProvider {
|
|
|
74
181
|
systemPromptFlag: string | null;
|
|
75
182
|
onChunk: ((chunk: string) => void) | null;
|
|
76
183
|
parse: "claude-json" | ((stdout: string) => Partial<GenerateResult>) | null;
|
|
184
|
+
nativeTools: boolean;
|
|
185
|
+
/**
|
|
186
|
+
* Declares to the Loop that this provider runs its OWN turn cycle. The Loop reads it to refuse
|
|
187
|
+
* options it could never honor (assemble/trim/cacheMessages) and to require the fence be wired
|
|
188
|
+
* where it can actually run. Generic provider-contract flag; nothing here is claude-specific.
|
|
189
|
+
*/
|
|
190
|
+
ownsCycle: boolean;
|
|
77
191
|
toolProtocol: {
|
|
78
192
|
name: string;
|
|
79
193
|
turnArgs(systemPrompt: string): string[];
|
|
@@ -87,6 +201,13 @@ export class CLIPipeProvider {
|
|
|
87
201
|
probeCapability: boolean;
|
|
88
202
|
/** @type {Promise<void>|null} */
|
|
89
203
|
_toolCapability: Promise<void> | null;
|
|
204
|
+
policy: ((tool: string, args: any, ctx?: any) => any) | null | undefined;
|
|
205
|
+
onTurn: Function | null | undefined;
|
|
206
|
+
maxTurns: number | null | undefined;
|
|
207
|
+
maxConsecutiveDenials: number | undefined;
|
|
208
|
+
maxIdenticalToolErrors: number | undefined;
|
|
209
|
+
sessionTimeout: number | undefined;
|
|
210
|
+
bridgeTimeoutMs: number | null | undefined;
|
|
90
211
|
/**
|
|
91
212
|
* Generate a response by piping messages to the CLI command. With a `toolProtocol` configured and
|
|
92
213
|
* a non-empty `tools` array, routes to schema-validated tool EMULATION (v0.32.0); otherwise the
|
|
@@ -115,6 +236,26 @@ export class CLIPipeProvider {
|
|
|
115
236
|
*/
|
|
116
237
|
_generateWithTools(messages: Message[], tools: ToolDef[]): Promise<GenerateResult>;
|
|
117
238
|
_toolCallSeq: any;
|
|
239
|
+
/**
|
|
240
|
+
* BA-16 native tool mode — run ONE whole CLI session and report it honestly as one call.
|
|
241
|
+
*
|
|
242
|
+
* The CLI owns the inner cycle here: it calls the caller's tools natively over an MCP bridge and
|
|
243
|
+
* keeps going until it answers or hits a bound. So this returns `toolCalls: []` always (there is
|
|
244
|
+
* nothing left for the Loop to execute) plus a `session` block describing what really happened —
|
|
245
|
+
* the real turn count, the real tool-call count, and any terminal the Loop must surface as the
|
|
246
|
+
* run's `error`.
|
|
247
|
+
*
|
|
248
|
+
* Ordering of terminals is deliberate. A governance halt outranks everything (it is a clean exit,
|
|
249
|
+
* not a fault). A tripped guard outranks the CLI's own subtype, because the guard is why we killed
|
|
250
|
+
* the session. And `bridgeDown` outranks a reported `success`, because a session whose tools were
|
|
251
|
+
* all broken still ends `subtype:'success'` — measured, and the reason this block exists.
|
|
252
|
+
*
|
|
253
|
+
* @param {Message[]} messages
|
|
254
|
+
* @param {ToolDef[]} tools
|
|
255
|
+
* @param {Record<string, any>} options - the Loop's run options (`ctx` is read from here).
|
|
256
|
+
* @returns {Promise<GenerateResult>}
|
|
257
|
+
*/
|
|
258
|
+
_generateWithMcp(messages: Message[], tools: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
|
|
118
259
|
/**
|
|
119
260
|
* Run the upfront capability probe ONCE per instance (cached), unless `probeCapability` is off.
|
|
120
261
|
* A model that answers the probe in prose instead of emitting a tool_call throws a loud
|
package/src/provider-clipipe.js
CHANGED
|
@@ -1,8 +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
5
|
const { buildToolSystemPrompt, renderTranscript, resolveToolProtocol, mapClaudeMeta } = require('./provider-clipipe-tools');
|
|
6
|
+
const { createBridge, resolveSessionError, runSession } = require('./provider-clipipe-mcp');
|
|
6
7
|
|
|
7
8
|
/** @typedef {import('../types').Message} Message */
|
|
8
9
|
/** @typedef {import('../types').ToolDef} ToolDef */
|
|
@@ -18,8 +19,51 @@ const { buildToolSystemPrompt, renderTranscript, resolveToolProtocol, mapClaudeM
|
|
|
18
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.
|
|
19
20
|
* @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
|
|
20
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.
|
|
21
|
-
* @property {'claude'} [toolProtocol] - Opt into TOOL MODE
|
|
22
|
-
*
|
|
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.
|
|
23
67
|
*/
|
|
24
68
|
|
|
25
69
|
class CLIPipeProvider {
|
|
@@ -44,10 +88,40 @@ class CLIPipeProvider {
|
|
|
44
88
|
// Tool mode (v0.32.0). Resolve the protocol adapter eagerly so an unknown name fails at
|
|
45
89
|
// construction, not mid-run. `_toolCapability` caches the upfront probe verdict per instance
|
|
46
90
|
// (null = not yet probed; a Promise while in flight; true once confirmed capable).
|
|
47
|
-
|
|
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;
|
|
48
102
|
this.probeCapability = options.probeCapability !== false;
|
|
49
103
|
/** @type {Promise<void>|null} */
|
|
50
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
|
+
}
|
|
51
125
|
}
|
|
52
126
|
|
|
53
127
|
/**
|
|
@@ -67,6 +141,7 @@ class CLIPipeProvider {
|
|
|
67
141
|
*/
|
|
68
142
|
async generate(messages, tools = [], options = {}) {
|
|
69
143
|
if (Array.isArray(tools) && tools.length > 0) {
|
|
144
|
+
if (this.nativeTools) return this._generateWithMcp(messages, tools, options);
|
|
70
145
|
if (this.toolProtocol) return this._generateWithTools(messages, tools);
|
|
71
146
|
// No protocol configured → plain-text mode, tools IGNORED — the long-standing behavior, kept
|
|
72
147
|
// for backward compatibility (a non-tool-calling CLI legitimately coexists in a Loop that has
|
|
@@ -147,6 +222,133 @@ class CLIPipeProvider {
|
|
|
147
222
|
return result;
|
|
148
223
|
}
|
|
149
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
|
+
|
|
150
352
|
/**
|
|
151
353
|
* Run the upfront capability probe ONCE per instance (cached), unless `probeCapability` is off.
|
|
152
354
|
* A model that answers the probe in prose instead of emitting a tool_call throws a loud
|
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[],
|