bare-agent 0.26.0 → 0.27.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.
@@ -15,9 +15,19 @@ export type AnthropicOptions = {
15
15
  */
16
16
  baseUrl?: string | undefined;
17
17
  /**
18
- * - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`.
18
+ * - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`. NOTE: on its own this rarely helps a tool loop — Anthropic's minimum cacheable prefix is 1024–4096 tokens (model-dependent) and a typical system persona is a few hundred, so it silently never caches. The transcript is where a tool loop's tokens actually live — see `cacheMessages`.
19
19
  */
20
20
  cacheSystem?: boolean | undefined;
21
+ /**
22
+ * - Opt-in TRANSCRIPT caching (BA-1): roll a `cache_control` breakpoint onto the last content block of the last message, so Anthropic caches the whole conversation prefix and the loop stops re-buying it at full price every round. In a tool loop the transcript IS the tool results (file bodies from `shell_read`) and it always ENDS on one, which `_toAnthropicMessage` rebuilds from scratch — so no caller-side seam (`assemble` included) can reach it, and this has to live in the provider. Measured on `claude-sonnet-5` with a ~15k-token tool-result transcript (`poc/ba1-message-caching.mjs`): steady state **$0.0753 → $0.0110 per round, 6.8x cheaper**; round 1 pays a 1.25x cache WRITE once. Off by default — it changes the wire format, so adopters opt in. Overridable per call via `generate(..., { cacheMessages })`. **Interaction:** a destructive `trim`/stash fold that rewrites the transcript PREFIX invalidates the cache (the prefix is the cache key), so a fold must keep the head stable or you re-pay the write premium every round for nothing.
23
+ */
24
+ cacheMessages?: boolean | undefined;
25
+ /**
26
+ * - Opt-in extended thinking (BA-7), forwarded to `body.thinking` VERBATIM and unvalidated — e.g. `{ type: 'adaptive' }`, or `{ type: 'adaptive', display: 'summarized' }` to surface the reasoning (the default `display` is `'omitted'`). Deliberately opaque: this parameter has already broken once (`budget_tokens` was removed and now 400s on `claude-sonnet-5` / Opus 4.7+), and a library that reshapes it would need a release every time the API moves. Overridable per call via `generate(..., { thinking })`; pass `null` there to suppress an instance default.
27
+ *
28
+ * **MEASURED CAVEAT — this option does not "turn thinking on".** On `claude-sonnet-5` adaptive thinking is ALREADY the default: sending this changed the observed thinking rate not at all (2/10 rounds with it vs 3/10 without — `poc/ba7-adaptive-default.mjs`). Its real use is pinning the mode and reaching `display`/`effort`. The change that mattered is that thinking blocks are now PRESERVED and replayed (see `Message.providerBlocks`), which happens whether or not you ever set this.
29
+ */
30
+ thinking?: any;
21
31
  /**
22
32
  * - Attach the full upstream response to `err.body` on HTTP errors (off by default to avoid leaking unexpected fields through error logs; `err.message` still carries the API error).
23
33
  */
@@ -31,7 +41,11 @@ export type AnthropicOptions = {
31
41
  * @property {string} [apiKey] - Anthropic API key (required).
32
42
  * @property {string} [model='claude-haiku-4-5-20251001'] - Model ID.
33
43
  * @property {string} [baseUrl='https://api.anthropic.com/v1'] - API base (override for proxies/gateways; the request posts to `${baseUrl}/messages`).
34
- * @property {boolean} [cacheSystem=false] - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`.
44
+ * @property {boolean} [cacheSystem=false] - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`. NOTE: on its own this rarely helps a tool loop — Anthropic's minimum cacheable prefix is 1024–4096 tokens (model-dependent) and a typical system persona is a few hundred, so it silently never caches. The transcript is where a tool loop's tokens actually live — see `cacheMessages`.
45
+ * @property {boolean} [cacheMessages=false] - Opt-in TRANSCRIPT caching (BA-1): roll a `cache_control` breakpoint onto the last content block of the last message, so Anthropic caches the whole conversation prefix and the loop stops re-buying it at full price every round. In a tool loop the transcript IS the tool results (file bodies from `shell_read`) and it always ENDS on one, which `_toAnthropicMessage` rebuilds from scratch — so no caller-side seam (`assemble` included) can reach it, and this has to live in the provider. Measured on `claude-sonnet-5` with a ~15k-token tool-result transcript (`poc/ba1-message-caching.mjs`): steady state **$0.0753 → $0.0110 per round, 6.8x cheaper**; round 1 pays a 1.25x cache WRITE once. Off by default — it changes the wire format, so adopters opt in. Overridable per call via `generate(..., { cacheMessages })`. **Interaction:** a destructive `trim`/stash fold that rewrites the transcript PREFIX invalidates the cache (the prefix is the cache key), so a fold must keep the head stable or you re-pay the write premium every round for nothing.
46
+ * @property {any} [thinking] - Opt-in extended thinking (BA-7), forwarded to `body.thinking` VERBATIM and unvalidated — e.g. `{ type: 'adaptive' }`, or `{ type: 'adaptive', display: 'summarized' }` to surface the reasoning (the default `display` is `'omitted'`). Deliberately opaque: this parameter has already broken once (`budget_tokens` was removed and now 400s on `claude-sonnet-5` / Opus 4.7+), and a library that reshapes it would need a release every time the API moves. Overridable per call via `generate(..., { thinking })`; pass `null` there to suppress an instance default.
47
+ *
48
+ * **MEASURED CAVEAT — this option does not "turn thinking on".** On `claude-sonnet-5` adaptive thinking is ALREADY the default: sending this changed the observed thinking rate not at all (2/10 rounds with it vs 3/10 without — `poc/ba7-adaptive-default.mjs`). Its real use is pinning the mode and reaching `display`/`effort`. The change that mattered is that thinking blocks are now PRESERVED and replayed (see `Message.providerBlocks`), which happens whether or not you ever set this.
35
49
  * @property {boolean} [exposeErrorBody=false] - Attach the full upstream response to `err.body` on HTTP errors (off by default to avoid leaking unexpected fields through error logs; `err.message` still carries the API error).
36
50
  */
37
51
  export class AnthropicProvider {
@@ -44,6 +58,8 @@ export class AnthropicProvider {
44
58
  model: string;
45
59
  baseUrl: string;
46
60
  cacheSystem: boolean;
61
+ cacheMessages: boolean;
62
+ thinking: any;
47
63
  exposeErrorBody: boolean;
48
64
  /**
49
65
  * Generate a response from the Anthropic API.
@@ -57,6 +73,20 @@ export class AnthropicProvider {
57
73
  /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
58
74
  _warnTemperatureDropped(): void;
59
75
  _warnedTempDropped: boolean | undefined;
76
+ /**
77
+ * BA-7: the provider-native blocks to replay at the FRONT of an assistant turn's content, or `[]`.
78
+ *
79
+ * Only ever returns blocks this model itself produced. The tag carries `this.model` (the CONFIGURED
80
+ * id, not the response's resolved one — those can differ by date suffix, and a mismatch there would
81
+ * silently disable preservation, which is the very bug class BA-7 exists to close).
82
+ *
83
+ * Front, because Anthropic requires `thinking` to lead the content array — the verbatim order we
84
+ * measured a successful round-trip on (`poc/ba7-thinking-contract.mjs`, R3).
85
+ *
86
+ * @param {Message} msg
87
+ * @returns {any[]}
88
+ */
89
+ _nativeBlocks(msg: Message): any[];
60
90
  /**
61
91
  * @param {Message} msg
62
92
  * @returns {any}
@@ -4,6 +4,7 @@ const https = require('https');
4
4
  const http = require('http');
5
5
  const { ProviderError } = require('./errors');
6
6
  const { requestWithTemperatureFallback } = require('./provider-temperature');
7
+ const { normalizeStopReason } = require('./provider-stop-reason');
7
8
 
8
9
  /** @param {string} hostname @returns {boolean} */
9
10
  function isLoopbackHost(hostname) {
@@ -20,7 +21,11 @@ function isLoopbackHost(hostname) {
20
21
  * @property {string} [apiKey] - Anthropic API key (required).
21
22
  * @property {string} [model='claude-haiku-4-5-20251001'] - Model ID.
22
23
  * @property {string} [baseUrl='https://api.anthropic.com/v1'] - API base (override for proxies/gateways; the request posts to `${baseUrl}/messages`).
23
- * @property {boolean} [cacheSystem=false] - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`.
24
+ * @property {boolean} [cacheSystem=false] - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`. NOTE: on its own this rarely helps a tool loop — Anthropic's minimum cacheable prefix is 1024–4096 tokens (model-dependent) and a typical system persona is a few hundred, so it silently never caches. The transcript is where a tool loop's tokens actually live — see `cacheMessages`.
25
+ * @property {boolean} [cacheMessages=false] - Opt-in TRANSCRIPT caching (BA-1): roll a `cache_control` breakpoint onto the last content block of the last message, so Anthropic caches the whole conversation prefix and the loop stops re-buying it at full price every round. In a tool loop the transcript IS the tool results (file bodies from `shell_read`) and it always ENDS on one, which `_toAnthropicMessage` rebuilds from scratch — so no caller-side seam (`assemble` included) can reach it, and this has to live in the provider. Measured on `claude-sonnet-5` with a ~15k-token tool-result transcript (`poc/ba1-message-caching.mjs`): steady state **$0.0753 → $0.0110 per round, 6.8x cheaper**; round 1 pays a 1.25x cache WRITE once. Off by default — it changes the wire format, so adopters opt in. Overridable per call via `generate(..., { cacheMessages })`. **Interaction:** a destructive `trim`/stash fold that rewrites the transcript PREFIX invalidates the cache (the prefix is the cache key), so a fold must keep the head stable or you re-pay the write premium every round for nothing.
26
+ * @property {any} [thinking] - Opt-in extended thinking (BA-7), forwarded to `body.thinking` VERBATIM and unvalidated — e.g. `{ type: 'adaptive' }`, or `{ type: 'adaptive', display: 'summarized' }` to surface the reasoning (the default `display` is `'omitted'`). Deliberately opaque: this parameter has already broken once (`budget_tokens` was removed and now 400s on `claude-sonnet-5` / Opus 4.7+), and a library that reshapes it would need a release every time the API moves. Overridable per call via `generate(..., { thinking })`; pass `null` there to suppress an instance default.
27
+ *
28
+ * **MEASURED CAVEAT — this option does not "turn thinking on".** On `claude-sonnet-5` adaptive thinking is ALREADY the default: sending this changed the observed thinking rate not at all (2/10 rounds with it vs 3/10 without — `poc/ba7-adaptive-default.mjs`). Its real use is pinning the mode and reaching `display`/`effort`. The change that mattered is that thinking blocks are now PRESERVED and replayed (see `Message.providerBlocks`), which happens whether or not you ever set this.
24
29
  * @property {boolean} [exposeErrorBody=false] - Attach the full upstream response to `err.body` on HTTP errors (off by default to avoid leaking unexpected fields through error logs; `err.message` still carries the API error).
25
30
  */
26
31
 
@@ -38,6 +43,13 @@ class AnthropicProvider {
38
43
  // Anthropic caches it (unlike OpenAI/Gemini, Anthropic does NOT auto-cache — without this its
39
44
  // cache_read/cache_creation tiers are always 0). Default off keeps requests byte-identical to before.
40
45
  this.cacheSystem = options.cacheSystem === true;
46
+ this.cacheMessages = options.cacheMessages === true;
47
+ // BA-7: forwarded to `body.thinking` VERBATIM — deliberately unvalidated and un-reshaped. This
48
+ // parameter has already broken once (`budget_tokens` was removed and now 400s on sonnet-5 /
49
+ // Opus 4.7+; `{type:'adaptive'}` replaced it). A library that parses it would have to ship a
50
+ // release every time Anthropic moves; passing it through means the caller can always express
51
+ // the current API. Omit it to keep today's body byte-identical.
52
+ this.thinking = options.thinking != null ? options.thinking : null;
41
53
  // See OpenAIProvider: attach full upstream body to err.body only on opt-in.
42
54
  this.exposeErrorBody = options.exposeErrorBody === true;
43
55
  }
@@ -76,6 +88,33 @@ class AnthropicProvider {
76
88
  system = [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }];
77
89
  }
78
90
 
91
+ // BA-1: roll a cache breakpoint onto the LAST content block of the LAST message. Anthropic caches the
92
+ // whole prefix up to the mark, and rolling it forward each round keeps the GROWING transcript cached
93
+ // — otherwise a tool loop re-buys its entire history at full input price, every single round.
94
+ //
95
+ // This MUST live here, not in a caller-side seam: in a tool loop the transcript IS the tool results,
96
+ // it always ENDS on one, and `_toAnthropicMessage` rebuilds `role:'tool'` messages into fresh
97
+ // `tool_result` blocks — discarding anything a caller attached. There is no other reachable seam.
98
+ //
99
+ // Copy-on-write, deliberately: a caller's `content` array is passed through by reference, so marking
100
+ // it in place would mutate the caller's own message objects and leave a stale breakpoint behind on
101
+ // every later round (Anthropic allows at most 4, and a stray one silently shifts the cache key).
102
+ const cacheMessages = options.cacheMessages != null ? options.cacheMessages === true : this.cacheMessages;
103
+ if (cacheMessages && msgs.length > 0) {
104
+ const i = msgs.length - 1;
105
+ const last = msgs[i];
106
+ if (Array.isArray(last.content) && last.content.length > 0) {
107
+ const blocks = last.content.slice();
108
+ blocks[blocks.length - 1] = { ...blocks[blocks.length - 1], cache_control: { type: 'ephemeral' } };
109
+ msgs[i] = { ...last, content: blocks };
110
+ } else if (typeof last.content === 'string' && last.content.length > 0) {
111
+ msgs[i] = { ...last, content: [{ type: 'text', text: last.content, cache_control: { type: 'ephemeral' } }] };
112
+ }
113
+ // An empty-content message gets no mark — there is no block to hang it on, and a synthesized empty
114
+ // one would be a wire error. Under the cache minimum (1024–4096 tok, model-dependent) Anthropic
115
+ // silently doesn't cache: harmless, just no saving. Never an error.
116
+ }
117
+
79
118
  /** @type {Record<string, any>} */
80
119
  const body = {
81
120
  model: this.model,
@@ -84,6 +123,14 @@ class AnthropicProvider {
84
123
  ...(system && { system }),
85
124
  ...(options.temperature != null && { temperature: options.temperature }),
86
125
  };
126
+
127
+ // BA-7 (b). Measured caveat, and it matters: on `claude-sonnet-5` adaptive thinking is ALREADY
128
+ // the default — sending this changed the thinking rate not at all (2/10 vs 3/10 rounds,
129
+ // `poc/ba7-adaptive-default.mjs`). So this option does NOT "turn thinking on"; it lets you pin
130
+ // the mode and reach `display`/`effort`. The fix that actually mattered is the preservation
131
+ // above, which applies whether or not you ever set this.
132
+ const thinking = options.thinking !== undefined ? options.thinking : this.thinking;
133
+ if (thinking) body.thinking = thinking;
87
134
  if (tools.length > 0) {
88
135
  body.tools = tools.map(t => ({
89
136
  name: t.name,
@@ -105,17 +152,34 @@ class AnthropicProvider {
105
152
  let text = '';
106
153
  /** @type {import('../types').ToolCall[]} */
107
154
  const toolCalls = [];
155
+ // BA-7: everything that is NOT text/tool_use is a block our normalized {text, toolCalls} shape
156
+ // cannot express — today that means `thinking` and `redacted_thinking`. We used to drop these on
157
+ // the floor. Anthropic's contract is that they are echoed back UNCHANGED (signature included) when
158
+ // continuing a tool-use conversation, so they are collected OPAQUELY here: whatever the block is,
159
+ // we keep its bytes. Deliberately not a `block.type === 'thinking'` check — a future block type
160
+ // would be silently dropped again, which is the exact bug being fixed.
161
+ /** @type {any[]} */
162
+ const nativeBlocks = [];
108
163
  for (const block of data.content) {
109
164
  if (block.type === 'text') text += block.text;
110
- if (block.type === 'tool_use') {
111
- toolCalls.push({ id: block.id, name: block.name, arguments: block.input });
112
- }
165
+ else if (block.type === 'tool_use') toolCalls.push({ id: block.id, name: block.name, arguments: block.input });
166
+ else nativeBlocks.push(block);
113
167
  }
114
168
 
115
169
  return {
116
170
  text,
117
171
  toolCalls,
118
172
  model: data.model || this.model,
173
+ // BA-6: why generation ended. `max_tokens` here means the API CUT THIS ROUND OFF — the Loop must
174
+ // not read it as a finished turn, and must not execute any tool call it carries (a complete call
175
+ // arrives as `tool_use`; one riding a `max_tokens` round was cut off mid-generation).
176
+ stopReason: normalizeStopReason(data.stop_reason, 'anthropic', { hasToolCalls: toolCalls.length > 0 }),
177
+ // BA-7: the opaque blocks, tagged so they can only ever be replayed to the model that signed
178
+ // them. A thinking `signature` is model-bound; on a mismatch we drop them and degrade to the
179
+ // pre-BA-7 behavior (a lossy request that still succeeds) rather than risk a 400.
180
+ ...(nativeBlocks.length > 0 && {
181
+ providerBlocks: { provider: 'anthropic', model: this.model, blocks: nativeBlocks },
182
+ }),
119
183
  // Anthropic's `input_tokens` is ALREADY the uncached remainder (cached tokens are reported
120
184
  // separately, not folded in — verified live), so no subtraction here, unlike OpenAI/Gemini.
121
185
  usage: {
@@ -135,6 +199,29 @@ class AnthropicProvider {
135
199
  console.warn(`[AnthropicProvider] '${this.model}' rejected a non-default 'temperature' (unsupported/deprecated) — retrying without it. Further drops from this provider instance are silent.`);
136
200
  }
137
201
 
202
+ /**
203
+ * BA-7: the provider-native blocks to replay at the FRONT of an assistant turn's content, or `[]`.
204
+ *
205
+ * Only ever returns blocks this model itself produced. The tag carries `this.model` (the CONFIGURED
206
+ * id, not the response's resolved one — those can differ by date suffix, and a mismatch there would
207
+ * silently disable preservation, which is the very bug class BA-7 exists to close).
208
+ *
209
+ * Front, because Anthropic requires `thinking` to lead the content array — the verbatim order we
210
+ * measured a successful round-trip on (`poc/ba7-thinking-contract.mjs`, R3).
211
+ *
212
+ * @param {Message} msg
213
+ * @returns {any[]}
214
+ */
215
+ _nativeBlocks(msg) {
216
+ const pb = /** @type {any} */ (msg).providerBlocks;
217
+ if (!pb || pb.provider !== 'anthropic' || !Array.isArray(pb.blocks) || pb.blocks.length === 0) return [];
218
+ // A thinking signature is bound to the model that issued it. Swap models mid-transcript and these
219
+ // blocks are not ours to replay: drop them (lossy but valid) rather than send a signature this
220
+ // model will reject.
221
+ if (pb.model !== this.model) return [];
222
+ return pb.blocks;
223
+ }
224
+
138
225
  /**
139
226
  * @param {Message} msg
140
227
  * @returns {any}
@@ -155,6 +242,11 @@ class AnthropicProvider {
155
242
  if (msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0) {
156
243
  /** @type {any[]} */
157
244
  const content = [];
245
+ // BA-7: thinking blocks lead, then text, then tool_use — this is THE turn the contract is about
246
+ // (continuing a tool-use conversation). Note the normalized `content`/`tool_calls` stay the
247
+ // source of truth: we replay only the opaque blocks, never a cached copy of the text, so a
248
+ // `trim`/`assemble` seam that rewrites this message is not silently undone.
249
+ content.push(...this._nativeBlocks(msg));
158
250
  if (msg.content) content.push({ type: 'text', text: msg.content });
159
251
  for (const tc of msg.tool_calls) {
160
252
  content.push({
@@ -168,6 +260,16 @@ class AnthropicProvider {
168
260
  }
169
261
  return { role: 'assistant', content };
170
262
  }
263
+ // A tool-call-free assistant turn can still carry thinking (a final answer, or an earlier turn a
264
+ // caller replays into a fresh run). Same rule: native blocks lead.
265
+ if (msg.role === 'assistant') {
266
+ const native = this._nativeBlocks(msg);
267
+ if (native.length > 0) {
268
+ const content = [...native];
269
+ if (msg.content) content.push({ type: 'text', text: msg.content });
270
+ return { role: 'assistant', content };
271
+ }
272
+ }
171
273
  return { role: msg.role, content: msg.content };
172
274
  }
173
275
 
@@ -165,39 +165,65 @@ class CLIPipeProvider {
165
165
 
166
166
  let stdout = '';
167
167
  let stderr = '';
168
- let killed = false;
169
168
 
170
- child.stdout.on('data', d => { stdout += d; this.onChunk?.(d.toString()); });
171
- child.stderr.on('data', d => { stderr += d; });
172
-
173
- child.on('error', err => {
174
- reject(new ProviderError(`[CLIPipeProvider] failed to spawn "${this.command}": ${err.message}`, /** @type {any} */ ({ status: 0 })));
175
- });
169
+ // Settle exactly once, no matter which combination of events fires. 'close' can be
170
+ // withheld indefinitely when the CLI spawns a grandchild that inherits its stdio pipes
171
+ // (the child exits, but the pipes stay open) — observed live as a generate() promise
172
+ // that never settled. Every path below funnels through settle().
173
+ let settled = false;
174
+ /** @type {NodeJS.Timeout[]} */
175
+ const timers = [];
176
+ const later = (fn, ms) => { timers.push(setTimeout(fn, ms)); };
177
+ const settle = (/** @type {Error|null} */ err, text = '') => {
178
+ if (settled) return;
179
+ settled = true;
180
+ for (const t of timers) clearTimeout(t);
181
+ if (err) reject(err); else resolve(text);
182
+ };
176
183
 
177
- child.on('close', code => {
178
- if (killed) return; // timeout already rejected
184
+ const finish = (/** @type {number|null} */ code) => {
179
185
  if (code !== 0) {
180
- return reject(new ProviderError(`[CLIPipeProvider] process exited with code ${code}: ${stderr.trim()}`, /** @type {any} */ ({ status: code })));
186
+ // The claude CLI reports errors on STDOUT (a JSON envelope) with stderr often
187
+ // empty — fall back to a stdout tail so the operator never sees a blank reason.
188
+ const detail = stderr.trim() || (stdout.trim() ? `(stderr empty) stdout: ${stdout.trim().slice(-400)}` : '');
189
+ return settle(new ProviderError(`[CLIPipeProvider] process exited with code ${code}: ${detail}`, /** @type {any} */ ({ status: code })));
181
190
  }
182
191
  const text = stdout.trim();
183
192
  if (!text) {
184
- return reject(new ProviderError('[CLIPipeProvider] process produced no output', /** @type {any} */ ({ status: 0 })));
193
+ return settle(new ProviderError('[CLIPipeProvider] process produced no output', /** @type {any} */ ({ status: 0 })));
194
+ }
195
+ settle(null, text);
196
+ };
197
+
198
+ child.stdout.on('data', d => {
199
+ stdout += d;
200
+ try {
201
+ this.onChunk?.(d.toString());
202
+ } catch (err) {
203
+ // an observer callback must fail the call loudly, never crash the host process
204
+ settle(new ProviderError(`[CLIPipeProvider] onChunk callback threw: ${/** @type {Error} */ (err).message}`, /** @type {any} */ ({ status: 0 })));
185
205
  }
186
- resolve(text);
206
+ });
207
+ child.stderr.on('data', d => { stderr += d; });
208
+
209
+ child.on('error', err => {
210
+ settle(new ProviderError(`[CLIPipeProvider] failed to spawn "${this.command}": ${err.message}`, /** @type {any} */ ({ status: 0 })));
187
211
  });
188
212
 
189
- // Timeout handling
190
- const timer = setTimeout(() => {
191
- killed = true;
213
+ // Primary completion path: all stdio drained.
214
+ child.on('close', code => finish(code));
215
+
216
+ // Fallback: the process exited but 'close' is being held open by inherited pipes.
217
+ // Give real drainage a short grace, then finish with what has arrived — a bounded
218
+ // wait, never a hang.
219
+ child.on('exit', code => later(() => finish(code), 2000));
220
+
221
+ later(() => {
192
222
  child.kill('SIGTERM');
193
- setTimeout(() => {
194
- try { child.kill('SIGKILL'); } catch (_) {}
195
- }, 1000);
196
- reject(new ProviderError(`[CLIPipeProvider] timed out after ${this.timeout}ms`, /** @type {any} */ ({ status: 0 })));
223
+ setTimeout(() => { try { child.kill('SIGKILL'); } catch (_) {} }, 1000).unref?.();
224
+ settle(new ProviderError(`[CLIPipeProvider] timed out after ${this.timeout}ms`, /** @type {any} */ ({ status: 0 })));
197
225
  }, this.timeout);
198
226
 
199
- child.on('close', () => clearTimeout(timer));
200
-
201
227
  // Write prompt to stdin — catch errors silently (process may exit early)
202
228
  child.stdin.on('error', () => {});
203
229
  child.stdin.end(prompt);
@@ -4,6 +4,7 @@ const https = require('https');
4
4
  const http = require('http');
5
5
  const { ProviderError } = require('./errors');
6
6
  const { requestWithTemperatureFallback } = require('./provider-temperature');
7
+ const { normalizeStopReason } = require('./provider-stop-reason');
7
8
 
8
9
  /** @typedef {import('../types').Message} Message */
9
10
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -130,6 +131,15 @@ class GeminiProvider {
130
131
  text,
131
132
  toolCalls,
132
133
  model: data.modelVersion || this.model,
134
+ // BA-6: `MAX_TOKENS` ⇒ cut off at the output cap. VERIFIED LIVE on `gemini-2.5-flash`
135
+ // (`poc/ba6-stop-reason-gemini-ollama.mjs`): STOP→end_turn, MAX_TOKENS→max_tokens. An unrecognized
136
+ // value still falls through to `null` (today's behavior), never to a false truncation.
137
+ //
138
+ // Gemini has NO tool_use finish reason — a complete function call comes back as `STOP` (measured).
139
+ // `hasToolCalls` lets the normalizer report the round for what it was: a round that stopped to CALL
140
+ // A TOOL, not one the model chose to end. Without it, `stopReason` would say `end_turn` here and
141
+ // `tool_use` on Anthropic/OpenAI for the identical event.
142
+ stopReason: normalizeStopReason(data.candidates?.[0]?.finishReason, 'gemini', { hasToolCalls: toolCalls.length > 0 }),
133
143
  usage: this._normalizeUsage(data.usageMetadata),
134
144
  ...(temperatureDropped && { temperatureDropped: true }),
135
145
  };
@@ -27,7 +27,7 @@ export class OllamaProvider {
27
27
  * Generate a response from a local Ollama instance.
28
28
  * @param {Message[]} messages - Conversation messages.
29
29
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
30
- * @param {Record<string, any>} [options={}] - Options (temperature).
30
+ * @param {Record<string, any>} [options={}] - Options (`temperature`, `maxTokens`).
31
31
  * @returns {Promise<GenerateResult>}
32
32
  * @throws {Error} `[OllamaProvider] ...` — on HTTP errors or invalid JSON response.
33
33
  */
@@ -3,6 +3,7 @@
3
3
  const http = require('http');
4
4
  const { ProviderError } = require('./errors');
5
5
  const { requestWithTemperatureFallback } = require('./provider-temperature');
6
+ const { normalizeStopReason } = require('./provider-stop-reason');
6
7
 
7
8
  /** @typedef {import('../types').Message} Message */
8
9
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -30,17 +31,29 @@ class OllamaProvider {
30
31
  * Generate a response from a local Ollama instance.
31
32
  * @param {Message[]} messages - Conversation messages.
32
33
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
33
- * @param {Record<string, any>} [options={}] - Options (temperature).
34
+ * @param {Record<string, any>} [options={}] - Options (`temperature`, `maxTokens`).
34
35
  * @returns {Promise<GenerateResult>}
35
36
  * @throws {Error} `[OllamaProvider] ...` — on HTTP errors or invalid JSON response.
36
37
  */
37
38
  async generate(messages, tools = [], options = {}) {
39
+ // Ollama nests generation params under `options` (its `num_predict` is the output cap — the
40
+ // equivalent of `max_tokens` everywhere else).
41
+ //
42
+ // `maxTokens` was NOT forwarded here before, while every other provider honoured it — so a caller
43
+ // capping output on Ollama was silently ignored and generated unbounded. Found by the BA-6 map
44
+ // probe: a 16-token cap produced `done_reason: 'stop'` because nothing ever truncated. The map was
45
+ // right; the cap never reached the wire.
46
+ /** @type {Record<string, any>} */
47
+ const genOptions = {
48
+ ...(options.temperature != null && { temperature: options.temperature }),
49
+ ...(options.maxTokens != null && { num_predict: options.maxTokens }),
50
+ };
38
51
  /** @type {Record<string, any>} */
39
52
  const body = {
40
53
  model: this.model,
41
54
  messages,
42
55
  stream: false,
43
- ...(options.temperature != null && { options: { temperature: options.temperature } }),
56
+ ...(Object.keys(genOptions).length > 0 && { options: genOptions }),
44
57
  };
45
58
  if (tools.length > 0) {
46
59
  body.tools = tools.map(t => ({
@@ -59,16 +72,26 @@ class OllamaProvider {
59
72
  });
60
73
  const msg = data.message || {};
61
74
 
75
+ /** @type {import('../types').ToolCall[]} */
76
+ const toolCalls = (msg.tool_calls || []).map((/** @type {any} */ tc) => ({
77
+ id: tc.id || `call_${Date.now()}`,
78
+ name: tc.function.name,
79
+ arguments: typeof tc.function.arguments === 'string'
80
+ ? JSON.parse(tc.function.arguments)
81
+ : tc.function.arguments,
82
+ }));
83
+
62
84
  return {
63
85
  text: msg.content || '',
64
- toolCalls: (msg.tool_calls || []).map((/** @type {any} */ tc) => ({
65
- id: tc.id || `call_${Date.now()}`,
66
- name: tc.function.name,
67
- arguments: typeof tc.function.arguments === 'string'
68
- ? JSON.parse(tc.function.arguments)
69
- : tc.function.arguments,
70
- })),
86
+ toolCalls,
71
87
  model: data.model || this.model,
88
+ // BA-6: `length` ⇒ cut off at num_predict. VERIFIED LIVE on qwen2.5:0.5b
89
+ // (`poc/ba6-stop-reason-gemini-ollama.mjs`): stop→end_turn, length→max_tokens. Lifecycle values
90
+ // (`load`/`unload`) stay deliberately unmapped rather than forced into the vocabulary.
91
+ //
92
+ // Like Gemini, Ollama has NO tool_use done_reason — a complete tool call returns `stop` (measured).
93
+ // `hasToolCalls` lets the normalizer say so, instead of reporting a tool round as a clean finish.
94
+ stopReason: normalizeStopReason(data.done_reason, 'ollama', { hasToolCalls: toolCalls.length > 0 }),
72
95
  usage: {
73
96
  inputTokens: data.prompt_eval_count || 0,
74
97
  outputTokens: data.eval_count || 0,
@@ -4,6 +4,7 @@ const https = require('https');
4
4
  const http = require('http');
5
5
  const { ProviderError } = require('./errors');
6
6
  const { requestWithTemperatureFallback } = require('./provider-temperature');
7
+ const { normalizeStopReason } = require('./provider-stop-reason');
7
8
 
8
9
  /** @typedef {import('../types').Message} Message */
9
10
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -73,14 +74,22 @@ class OpenAIProvider {
73
74
  const choice = data.choices[0];
74
75
  const msg = choice.message;
75
76
 
77
+ /** @type {import('../types').ToolCall[]} */
78
+ const toolCalls = (msg.tool_calls || []).map((/** @type {any} */ tc) => ({
79
+ id: tc.id,
80
+ name: tc.function.name,
81
+ arguments: JSON.parse(tc.function.arguments),
82
+ }));
83
+
76
84
  return {
77
85
  text: msg.content || '',
78
- toolCalls: (msg.tool_calls || []).map((/** @type {any} */ tc) => ({
79
- id: tc.id,
80
- name: tc.function.name,
81
- arguments: JSON.parse(tc.function.arguments),
82
- })),
86
+ toolCalls,
83
87
  model: data.model || this.model,
88
+ // BA-6: `length` ⇒ cut off at the output cap (normalized to 'max_tokens'). Note OpenAI refuses to
89
+ // emit a tool call it cannot finish — it 400s instead — so a truncated round here carries no
90
+ // tool calls at all; the Loop's refusal to execute them is a no-op on this provider, and a
91
+ // load-bearing guard on Anthropic, which DOES emit the cut-off call.
92
+ stopReason: normalizeStopReason(choice?.finish_reason, 'openai', { hasToolCalls: toolCalls.length > 0 }),
84
93
  usage: this._normalizeUsage(data.usage),
85
94
  ...(temperatureDropped && { temperatureDropped: true }),
86
95
  };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Map a provider's native finish-reason value onto the neutral vocabulary.
3
+ *
4
+ * @param {string|null|undefined} raw - the provider's native value (`stop_reason` / `finish_reason` /
5
+ * `finishReason` / `done_reason`). Absent or non-string ⇒ `null` (pre-BA-6 behavior).
6
+ * @param {'anthropic'|'openai'|'gemini'|'ollama'} provider - which table to read.
7
+ * @param {{hasToolCalls?: boolean}} [ctx] - what the round actually CARRIED. See below: two providers
8
+ * cannot express "stopped to call a tool" in their finish-reason field at all, so the round's own
9
+ * content is the only place that fact exists.
10
+ * @returns {string|null} a neutral value, an unrecognized value passed through verbatim, or `null`.
11
+ */
12
+ export function normalizeStopReason(raw: string | null | undefined, provider: "anthropic" | "openai" | "gemini" | "ollama", ctx?: {
13
+ hasToolCalls?: boolean;
14
+ }): string | null;
15
+ /**
16
+ * Did this round get CUT OFF at the output-token cap?
17
+ *
18
+ * The one predicate the Loop acts on. Deliberately narrow: `context_exceeded`, `refusal` and
19
+ * `pause_turn` are all "not a normal finish" but they are NOT output-cap truncations and must not be
20
+ * folded in here — `pause_turn` in particular is a RESUMABLE state, and erroring on it would break
21
+ * server-side tool flows that are working exactly as designed.
22
+ *
23
+ * @param {string|null|undefined} stopReason - a NEUTRAL value (post-{@link normalizeStopReason}).
24
+ * @returns {boolean}
25
+ */
26
+ export function isTruncated(stopReason: string | null | undefined): boolean;