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
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { ProviderError } = require('./errors');
|
|
4
|
+
|
|
5
|
+
/** @typedef {import('../types').Message} Message */
|
|
6
|
+
/** @typedef {import('../types').ToolDef} ToolDef */
|
|
7
|
+
|
|
8
|
+
// CLIPipe tool-mode support (v0.32.0). A subscription CLI (`claude -p`, …) is a plain
|
|
9
|
+
// TURN-provider: it takes text and returns text, with no native channel for a caller's tools. This
|
|
10
|
+
// module adds Option C — SCHEMA-VALIDATED TOOL EMULATION: the caller's tools are described in the
|
|
11
|
+
// system prompt, the CLI is constrained to a JSON envelope, and the envelope is parsed back into
|
|
12
|
+
// normalized `toolCalls` so bareagent's own `Loop` keeps ownership of the agentic cycle (round
|
|
13
|
+
// accounting, spin guards, stop-reason classification all still apply — unlike an MCP-callback
|
|
14
|
+
// design where the CLI would own the turns). Proven end-to-end through a real Loop in
|
|
15
|
+
// `poc/clipipe-tools-03-through-loop.mjs` (sonnet, multi-round).
|
|
16
|
+
//
|
|
17
|
+
// Two layers, deliberately split so a SECOND CLI (codex/gemini) slots in behind the same seam
|
|
18
|
+
// without touching the generic provider (the "keep the claude-specific parts isolated" constraint):
|
|
19
|
+
// - PROTOCOL-AGNOSTIC (renderTranscript / buildToolSystemPrompt): produce the text a CLI is fed.
|
|
20
|
+
// - PROTOCOL-SPECIFIC (CLAUDE_TOOL_PROTOCOL): the claude-CLI flags, the envelope schema, the
|
|
21
|
+
// result parse, and the capability-probe assets. Claude-only for now, by design.
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The JSON envelope the CLI is constrained to (claude `--json-schema`). `tool_call` carries the
|
|
25
|
+
* name + args; `final_answer` carries prose. A closed `action` enum is the discriminator.
|
|
26
|
+
* @type {string}
|
|
27
|
+
*/
|
|
28
|
+
const ENVELOPE_SCHEMA = JSON.stringify({
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
action: { type: 'string', enum: ['tool_call', 'final_answer'] },
|
|
32
|
+
tool_name: { type: 'string' },
|
|
33
|
+
tool_arguments: { type: 'object' },
|
|
34
|
+
answer: { type: 'string' },
|
|
35
|
+
},
|
|
36
|
+
required: ['action'],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One tool's signature line for the manifest. Protocol-agnostic.
|
|
41
|
+
* @param {ToolDef} t
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
function toolLine(t) {
|
|
45
|
+
const props = (t.parameters && t.parameters.properties) || {};
|
|
46
|
+
const args = Object.keys(props).map((k) => `${k}: ${props[k].type || 'any'}`).join(', ');
|
|
47
|
+
return `- ${t.name}(${args})${t.description ? ` — ${t.description}` : ''}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the tool-mode system prompt: the caller's own system stance (if any) + the tool manifest +
|
|
52
|
+
* the envelope contract. The contract text is load-bearing — the step-2 probe showed a weak model
|
|
53
|
+
* that BELIEVES it must execute the tool itself answers in prose ("I attempted… I failed"), so the
|
|
54
|
+
* prompt states plainly that EMITTING the envelope IS the call and "attempting/failing" is not a
|
|
55
|
+
* thing it can do. Protocol-agnostic (the envelope vocabulary is shared across CLIs).
|
|
56
|
+
* @param {string|null} baseSystem - the caller's system message content, or null.
|
|
57
|
+
* @param {ToolDef[]} tools
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
function buildToolSystemPrompt(baseSystem, tools) {
|
|
61
|
+
const manifest = tools.length
|
|
62
|
+
? ['You can use these tools:', ...tools.map(toolLine)].join('\n')
|
|
63
|
+
: 'You have no tools.';
|
|
64
|
+
return [
|
|
65
|
+
baseSystem ? String(baseSystem) : 'You are the reasoning half of a tool-using system.',
|
|
66
|
+
'',
|
|
67
|
+
manifest,
|
|
68
|
+
'',
|
|
69
|
+
'An external runtime executes tools for you. Emitting a tool_call envelope IS how a tool runs —',
|
|
70
|
+
'you never execute one yourself, and "attempting" or "failing" to call a tool is not something',
|
|
71
|
+
'you can do. To use a tool: action="tool_call" with tool_name and tool_arguments. When the tool',
|
|
72
|
+
'results you need are already present in the conversation, reply action="final_answer" with the',
|
|
73
|
+
'answer. Reply ONLY with the JSON envelope.',
|
|
74
|
+
].join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Render the Loop's OpenAI-shaped transcript into a plain-text conversation a CLI can read. The
|
|
79
|
+
* load-bearing part: assistant `tool_calls` and `role:'tool'` results must SURVIVE (the default
|
|
80
|
+
* `_formatPrompt` drops them), and each result must trace to the call that produced it (id → name).
|
|
81
|
+
* Protocol-agnostic. The system message is excluded here — it rides the CLI's system-prompt flag.
|
|
82
|
+
* @param {Message[]} messages
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
function renderTranscript(messages) {
|
|
86
|
+
/** @type {Map<string,string>} */
|
|
87
|
+
const idToName = new Map();
|
|
88
|
+
for (const m of messages) {
|
|
89
|
+
if (m.role === 'assistant' && Array.isArray(m.tool_calls)) {
|
|
90
|
+
for (const tc of m.tool_calls) {
|
|
91
|
+
if (!tc.id) continue;
|
|
92
|
+
const nm = (tc.function && tc.function.name) || tc.name || '?';
|
|
93
|
+
idToName.set(String(tc.id), String(nm));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const lines = [];
|
|
98
|
+
for (const m of messages) {
|
|
99
|
+
if (m.role === 'system') continue;
|
|
100
|
+
if (m.role === 'user') { lines.push(`User: ${m.content}`); continue; }
|
|
101
|
+
if (m.role === 'assistant') {
|
|
102
|
+
let s = m.content ? `Assistant: ${m.content}` : 'Assistant:';
|
|
103
|
+
if (Array.isArray(m.tool_calls)) {
|
|
104
|
+
for (const tc of m.tool_calls) {
|
|
105
|
+
const fn = tc.function || {};
|
|
106
|
+
const args = typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments || {});
|
|
107
|
+
s += `\n (you called ${fn.name || tc.name}(${args}))`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
lines.push(s);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (m.role === 'tool') {
|
|
114
|
+
const name = idToName.get(m.tool_call_id || '') || '?';
|
|
115
|
+
lines.push(`Tool result from ${name}: ${m.content}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return lines.join('\n');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Map the claude `--output-format json` OUTER envelope's usage / model / cost onto neutral shapes.
|
|
123
|
+
* Shared by tool-mode {@link CLAUDE_TOOL_PROTOCOL.parseResult} and the plain-text `_parseClaudeJson`
|
|
124
|
+
* preset so the claude usage contract (token tiers, `modelUsage` first-key, `total_cost_usd`) lives
|
|
125
|
+
* in exactly ONE place — a future CLI format change touches this function, not two copies.
|
|
126
|
+
* @param {any} outer - the parsed outer CLI envelope (already validated non-null by the caller).
|
|
127
|
+
* @returns {{usage: import('../types').Usage, model: string|null, costUsd?: number}}
|
|
128
|
+
*/
|
|
129
|
+
function mapClaudeMeta(outer) {
|
|
130
|
+
const u = (outer.usage && typeof outer.usage === 'object') ? outer.usage : {};
|
|
131
|
+
/** @type {import('../types').Usage} */
|
|
132
|
+
const usage = {
|
|
133
|
+
inputTokens: Number(u.input_tokens) || 0,
|
|
134
|
+
outputTokens: Number(u.output_tokens) || 0,
|
|
135
|
+
};
|
|
136
|
+
// Absent cache tiers mean the model didn't cache — omit rather than emit a synthetic 0 (per Usage docs).
|
|
137
|
+
if (Number.isFinite(u.cache_read_input_tokens)) usage.cacheReadTokens = u.cache_read_input_tokens;
|
|
138
|
+
if (Number.isFinite(u.cache_creation_input_tokens)) usage.cacheCreationTokens = u.cache_creation_input_tokens;
|
|
139
|
+
// `modelUsage` is an object keyed by model id (e.g. {"claude-opus-4-8[1m]": {...}}) — take the first key.
|
|
140
|
+
const model = (outer.modelUsage && typeof outer.modelUsage === 'object')
|
|
141
|
+
? (Object.keys(outer.modelUsage)[0] ?? null)
|
|
142
|
+
: null;
|
|
143
|
+
/** @type {{usage: import('../types').Usage, model: string|null, costUsd?: number}} */
|
|
144
|
+
const meta = { usage, model };
|
|
145
|
+
// The CLI's own price is authoritative (a subscription run reports an equivalent cost even at $0
|
|
146
|
+
// marginal) — feeds bareguard's USD axis with no local rate table. Only a finite number counts.
|
|
147
|
+
if (Number.isFinite(outer.total_cost_usd)) meta.costUsd = outer.total_cost_usd;
|
|
148
|
+
return meta;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @typedef {object} ParsedEnvelope
|
|
153
|
+
* @property {'tool_call'|'final_answer'} action
|
|
154
|
+
* @property {string} [toolName]
|
|
155
|
+
* @property {Record<string, any>} [toolArguments]
|
|
156
|
+
* @property {string} [answer]
|
|
157
|
+
* @property {import('../types').Usage} usage
|
|
158
|
+
* @property {string|null} [model]
|
|
159
|
+
* @property {number} [costUsd]
|
|
160
|
+
*/
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The claude-CLI tool protocol. Everything CLI-shaped lives here; a second CLI is a sibling object
|
|
164
|
+
* implementing the same three members. Isolated so the generic provider never grows a claude branch.
|
|
165
|
+
*/
|
|
166
|
+
const CLAUDE_TOOL_PROTOCOL = {
|
|
167
|
+
name: 'claude',
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The extra CLI args for one tool-mode turn, appended after the caller's base args (`-p --model X`).
|
|
171
|
+
* `--tools ''` + `--strict-mcp-config` strip the CLI's own tools/MCP to a bare brain (step 1);
|
|
172
|
+
* `--setting-sources ''` suppresses cwd CLAUDE.md/memory/settings auto-discovery — a MEASURED
|
|
173
|
+
* ~18× cost drop (37,423 → 2,026 tokens/turn, step 2), load-bearing for a subscription strategy;
|
|
174
|
+
* `--system-prompt` REPLACES the CLI's prompt (vs --append); `--json-schema` + `--output-format
|
|
175
|
+
* json` force the parseable envelope.
|
|
176
|
+
* @param {string} systemPrompt
|
|
177
|
+
* @returns {string[]}
|
|
178
|
+
*/
|
|
179
|
+
turnArgs(systemPrompt) {
|
|
180
|
+
return [
|
|
181
|
+
'--tools', '', '--strict-mcp-config', '--setting-sources', '',
|
|
182
|
+
'--system-prompt', systemPrompt,
|
|
183
|
+
'--json-schema', ENVELOPE_SCHEMA,
|
|
184
|
+
'--output-format', 'json',
|
|
185
|
+
];
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Parse claude's `--output-format json` stdout into a neutral {@link ParsedEnvelope}. A malformed
|
|
190
|
+
* or error envelope is a LOUD `ProviderError` — NEVER a silent fall-through to prose (the whole
|
|
191
|
+
* point is that a broken tool turn cannot masquerade as a final answer, the BA-6 failure shape).
|
|
192
|
+
* @param {string} stdout
|
|
193
|
+
* @returns {ParsedEnvelope}
|
|
194
|
+
*/
|
|
195
|
+
parseResult(stdout) {
|
|
196
|
+
let outer;
|
|
197
|
+
try {
|
|
198
|
+
outer = JSON.parse(stdout);
|
|
199
|
+
} catch (_) {
|
|
200
|
+
const preview = stdout.length > 200 ? `${stdout.slice(0, 200)}…` : stdout;
|
|
201
|
+
throw new ProviderError(`[CLIPipeProvider] tool-mode expected JSON on stdout, got: ${preview}`, /** @type {any} */ ({ status: 0 }));
|
|
202
|
+
}
|
|
203
|
+
if (!outer || typeof outer !== 'object' || outer.is_error === true || outer.subtype !== 'success') {
|
|
204
|
+
const detail = outer && typeof outer.result === 'string' ? outer.result : JSON.stringify(outer && outer.subtype);
|
|
205
|
+
throw new ProviderError(`[CLIPipeProvider] claude CLI reported failure (subtype='${outer && outer.subtype}'): ${detail}`, /** @type {any} */ ({ status: 0 }));
|
|
206
|
+
}
|
|
207
|
+
let env;
|
|
208
|
+
try {
|
|
209
|
+
env = JSON.parse(outer.result);
|
|
210
|
+
} catch (_) {
|
|
211
|
+
throw new ProviderError(`[CLIPipeProvider] tool-mode envelope was not valid JSON: ${String(outer.result).slice(0, 200)}`, /** @type {any} */ ({ status: 0 }));
|
|
212
|
+
}
|
|
213
|
+
if (!env || (env.action !== 'tool_call' && env.action !== 'final_answer')) {
|
|
214
|
+
throw new ProviderError(`[CLIPipeProvider] tool-mode envelope missing a valid action (got ${env && JSON.stringify(env.action)})`, /** @type {any} */ ({ status: 0 }));
|
|
215
|
+
}
|
|
216
|
+
if (env.action === 'tool_call' && (typeof env.tool_name !== 'string' || !env.tool_name)) {
|
|
217
|
+
throw new ProviderError('[CLIPipeProvider] tool-mode tool_call envelope has no tool_name', /** @type {any} */ ({ status: 0 }));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const { usage, model, costUsd } = mapClaudeMeta(outer);
|
|
221
|
+
/** @type {ParsedEnvelope} */
|
|
222
|
+
const parsed = { action: env.action, usage, model };
|
|
223
|
+
if (costUsd !== undefined) parsed.costUsd = costUsd;
|
|
224
|
+
if (env.action === 'tool_call') {
|
|
225
|
+
parsed.toolName = env.tool_name;
|
|
226
|
+
parsed.toolArguments = (env.tool_arguments && typeof env.tool_arguments === 'object') ? env.tool_arguments : {};
|
|
227
|
+
} else {
|
|
228
|
+
parsed.answer = typeof env.answer === 'string' ? env.answer : '';
|
|
229
|
+
}
|
|
230
|
+
return parsed;
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Capability-probe assets. The probe mirrors REAL-TASK shape — a question whose answer the model
|
|
235
|
+
* cannot know, with a tool that yields it, and NO "call the tool" instruction — because a trivial
|
|
236
|
+
* "call capability_ping" instruction is a FALSE POSITIVE: haiku emits it 4/4 yet fails real tasks
|
|
237
|
+
* 0/5 (proven in `poc/clipipe-tools-04-capability-probe.mjs`). The question shape sorts cleanly:
|
|
238
|
+
* sonnet CAPABLE 4/4, haiku INCAPABLE 0/4, stable. Capable iff it emits a `tool_call` for the tool.
|
|
239
|
+
*/
|
|
240
|
+
probe: {
|
|
241
|
+
system: [
|
|
242
|
+
'You are the reasoning half of a tool-using system. An external runtime executes tools for you.',
|
|
243
|
+
'',
|
|
244
|
+
'Tool available:',
|
|
245
|
+
'- lookup_code(name: string) — returns the secret verification code for a given name.',
|
|
246
|
+
'',
|
|
247
|
+
'You have NO knowledge of verification codes; the ONLY way to obtain one is to emit a tool_call.',
|
|
248
|
+
'Emitting a tool_call envelope IS how a tool runs; you never execute one yourself, and you cannot',
|
|
249
|
+
'"attempt" or "fail" to call one. Reply ONLY with the JSON envelope.',
|
|
250
|
+
].join('\n'),
|
|
251
|
+
user: 'What is the verification code for "orchard-42"?',
|
|
252
|
+
/** @param {ParsedEnvelope} parsed @returns {boolean} */
|
|
253
|
+
isCapable: (parsed) => parsed.action === 'tool_call' && parsed.toolName === 'lookup_code',
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/** Resolve a `toolProtocol` option to an adapter. Claude-only for now; unknown names throw. */
|
|
258
|
+
function resolveToolProtocol(name) {
|
|
259
|
+
if (name === 'claude') return CLAUDE_TOOL_PROTOCOL;
|
|
260
|
+
throw new Error(`[CLIPipeProvider] unknown toolProtocol '${name}' — only 'claude' is supported`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
module.exports = {
|
|
264
|
+
ENVELOPE_SCHEMA,
|
|
265
|
+
toolLine,
|
|
266
|
+
buildToolSystemPrompt,
|
|
267
|
+
renderTranscript,
|
|
268
|
+
mapClaudeMeta,
|
|
269
|
+
CLAUDE_TOOL_PROTOCOL,
|
|
270
|
+
resolveToolProtocol,
|
|
271
|
+
};
|
|
@@ -34,6 +34,78 @@ export type CLIPipeOptions = {
|
|
|
34
34
|
* - 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.
|
|
35
35
|
*/
|
|
36
36
|
parse?: "claude-json" | ((stdout: string) => Partial<GenerateResult>) | undefined;
|
|
37
|
+
/**
|
|
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.
|
|
50
|
+
*/
|
|
51
|
+
toolProtocol?: "claude" | "claude-mcp" | undefined;
|
|
52
|
+
/**
|
|
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.
|
|
107
|
+
*/
|
|
108
|
+
probeCapability?: boolean | undefined;
|
|
37
109
|
};
|
|
38
110
|
/** @typedef {import('../types').Message} Message */
|
|
39
111
|
/** @typedef {import('../types').ToolDef} ToolDef */
|
|
@@ -48,6 +120,51 @@ export type CLIPipeOptions = {
|
|
|
48
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.
|
|
49
121
|
* @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
|
|
50
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.
|
|
51
168
|
*/
|
|
52
169
|
export class CLIPipeProvider {
|
|
53
170
|
/**
|
|
@@ -64,10 +181,41 @@ export class CLIPipeProvider {
|
|
|
64
181
|
systemPromptFlag: string | null;
|
|
65
182
|
onChunk: ((chunk: string) => void) | null;
|
|
66
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;
|
|
191
|
+
toolProtocol: {
|
|
192
|
+
name: string;
|
|
193
|
+
turnArgs(systemPrompt: string): string[];
|
|
194
|
+
parseResult(stdout: string): ParsedEnvelope;
|
|
195
|
+
probe: {
|
|
196
|
+
system: string;
|
|
197
|
+
user: string;
|
|
198
|
+
isCapable: (parsed: ParsedEnvelope) => boolean;
|
|
199
|
+
};
|
|
200
|
+
} | null;
|
|
201
|
+
probeCapability: boolean;
|
|
202
|
+
/** @type {Promise<void>|null} */
|
|
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;
|
|
67
211
|
/**
|
|
68
|
-
* Generate a response by piping messages to the CLI command.
|
|
212
|
+
* Generate a response by piping messages to the CLI command. With a `toolProtocol` configured and
|
|
213
|
+
* a non-empty `tools` array, routes to schema-validated tool EMULATION (v0.32.0); otherwise the
|
|
214
|
+
* plain-text path below (unchanged). Passing `tools` with no `toolProtocol` warns ONCE and ignores
|
|
215
|
+
* them (a non-tool-calling CLI legitimately coexists in a Loop with tools mounted); the loud
|
|
216
|
+
* failure for a genuinely tool-incapable model lives in the tool-mode capability probe.
|
|
69
217
|
* @param {Message[]} messages - Conversation messages in OpenAI format.
|
|
70
|
-
* @param {ToolDef[]} [tools=[]] -
|
|
218
|
+
* @param {ToolDef[]} [tools=[]] - Caller tools. Honored only in tool mode (`toolProtocol` set).
|
|
71
219
|
* @param {Record<string, any>} [options={}] - Unused.
|
|
72
220
|
* @returns {Promise<GenerateResult>}
|
|
73
221
|
* @throws {Error} `[CLIPipeProvider] failed to spawn "cmd": ...` — when the command cannot be found or executed.
|
|
@@ -76,6 +224,50 @@ export class CLIPipeProvider {
|
|
|
76
224
|
* @throws {Error} `[CLIPipeProvider] process produced no output` — when stdout is empty.
|
|
77
225
|
*/
|
|
78
226
|
generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
|
|
227
|
+
_warnedNoProtocol: boolean | undefined;
|
|
228
|
+
/**
|
|
229
|
+
* Tool mode (v0.32.0) — one turn of schema-validated tool emulation. Renders the Loop's
|
|
230
|
+
* OpenAI-shaped transcript to text, injects the caller's system stance + a tool manifest + the
|
|
231
|
+
* envelope contract, spawns the CLI under the protocol's flags, and parses the envelope back into
|
|
232
|
+
* normalized `toolCalls` (a `tool_call`) or `text` (a `final_answer`). The Loop drives the cycle.
|
|
233
|
+
* @param {Message[]} messages
|
|
234
|
+
* @param {ToolDef[]} tools
|
|
235
|
+
* @returns {Promise<GenerateResult>}
|
|
236
|
+
*/
|
|
237
|
+
_generateWithTools(messages: Message[], tools: ToolDef[]): Promise<GenerateResult>;
|
|
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>;
|
|
259
|
+
/**
|
|
260
|
+
* Run the upfront capability probe ONCE per instance (cached), unless `probeCapability` is off.
|
|
261
|
+
* A model that answers the probe in prose instead of emitting a tool_call throws a loud
|
|
262
|
+
* `ProviderError` — fail fast, never silently degrade to a no-tools run mid-conversation.
|
|
263
|
+
* NOTE: the probe is a single internal CLI turn whose token usage/cost is NOT surfaced to the Loop
|
|
264
|
+
* (it never flows to `onLlmResult`), so a wired budget gate does not see it — negligible for the
|
|
265
|
+
* subscription use case this exists for (flat cost, one probe per instance), by design.
|
|
266
|
+
* @returns {Promise<void>}
|
|
267
|
+
*/
|
|
268
|
+
_ensureToolCapability(): Promise<void>;
|
|
269
|
+
/** Best-effort model id from `--model X` in the base args, for a clearer probe-failure message. */
|
|
270
|
+
_modelFromArgs(): string | null;
|
|
79
271
|
/**
|
|
80
272
|
* Map the `claude -p --output-format json` result envelope onto a normalized GenerateResult.
|
|
81
273
|
* The caller explicitly opted into structured output, so a malformed or error envelope is a LOUD
|