bare-agent 0.49.0 → 0.51.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.
@@ -513,6 +513,14 @@ new OpenAIProvider({ baseUrl: 'https://api.deepseek.com', model: 'deepseek-flash
513
513
 
514
514
  > **Fallback caveat:** per-call options (incl. `thinking`) are forwarded to EVERY provider in a `FallbackProvider` chain. For a mixed-vendor chain set `thinking` on each provider's constructor, not per-call.
515
515
 
516
+ **Normalized `thinkingMode: 'on' | 'off'`.** Say "on"/"off"; bare-agent sends the right field for the provider/model (`AnthropicProvider`, `OpenAIProvider` incl. DeepSeek, `GeminiProvider`, `OllamaProvider`): constructor option, overridable per call (`generate(..., { thinkingMode })` / `loop.run(msgs, tools, { thinkingMode })`), `null` suppresses. It is a NEW field — the raw `thinking` option above is untouched, and setting both is a `ValidationError`. It tries the known request shapes (a 400 naming the field walks the ladder; the winner is cached), throws a `ValidationError` naming what it tried if every shape is rejected, and throws before sending when the model is measured as not switchable (`claude-opus-5-5` off, `gemini-3.1-pro-preview` off). Every reply is checked: `result.thinkingApplied` is `true` (accepted; for `'off'`, zero thinking seen), `false` (`'off'` sent but the model thought anyway — warns once, the result is kept), or `null` (cannot tell; e.g. OpenAI without `reasoning_tokens`). On `loop.run()` it is sticky-false: any round `false` ⇒ the run is `false`. Anthropic `'on'` + a non-default `temperature` (the API rejects it with thinking enabled): the temperature is dropped and retried once, `temperatureDropped: true`. `'on'` is never flagged (adaptive models may skip thinking). `thinkingSupport(provider, model)` from `bare-agent/providers` tells you, offline, what is known. DeepSeek `'on'` + a forced `toolChoice` is a `ValidationError`; CLIPipe has no mechanism and throws. Ollama's `think` is not live-verified.
517
+
518
+ ```js
519
+ const p = new AnthropicProvider({ apiKey, model: 'claude-sonnet-5', thinkingMode: 'off' });
520
+ const r = await p.generate(msgs, [], {}); // r.thinkingApplied === true
521
+ await p.generate(msgs, [], { thinkingMode: 'on' }); // per-call override
522
+ ```
523
+
516
524
  > **Honesty note, and we mean it.** This is a **protocol/data-loss fix, not a capability fix.** A head-to-head with thinking fully preserved vs. stock bare-agent produced **indistinguishable** outcomes. Do not adopt 0.27 expecting better reasoning — you will not get it, and we have the measurement.
517
525
 
518
526
  Legacy `wrapTool` / `wrapTools` are retained as deprecation shims (one-shot console warning, removal in 1.0). Migration: replace `wrapTools(tools)` at `loop.run()` with `filterTools(tools)` once upfront + `onLlmResult` / `onToolResult` on `new Loop({...})` to pick up LLM-cost recording and `_ctx` threading.
@@ -976,6 +984,8 @@ All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model?, cos
976
984
 
977
985
  **Error body (v0.11.0):** on an HTTP error the OpenAI/Anthropic/Ollama providers throw a `ProviderError` whose `message` carries the upstream error string. The full parsed response is **not** attached to `err.body` by default (so an unexpected field can't leak through logs that dump the error object). Pass `{ exposeErrorBody: true }` to attach it for debugging.
978
986
 
987
+ **Malformed tool-call arguments (BA-27):** when an OpenAI-compatible or Ollama model emits `function.arguments` that is not valid JSON, the provider returns `toolCalls: []` plus a `malformedToolCall: { name, error }` marker (round still metered; JSON never repaired); `Loop.run()` surfaces the same marker on its result. Pass `{ exposeMalformedArgs: true }` (OpenAI/Ollama; default off, independent of `exposeErrorBody`) to also get `malformedToolCall.rawArguments` — the FULL raw arguments string, verbatim and uncapped (since 0.50.0; earlier it was the first 500 chars plus `rawTruncated`, which hid the broken tail). Model-generated and possibly large or sensitive: bare-agent only returns it to you and never logs it, but anything you forward it to (logs, audit rows) is your call. `rawTruncated` is deprecated and never set.
988
+
979
989
  **Request/idle timeout (BA-18, v0.34.0):** the four http(s) providers (Anthropic, OpenAI, Gemini, Ollama) accept a `timeoutMs` option — constructor default **600000 (10 min)**, overridable per call via `generate(..., { timeoutMs })`, and `0`/`Infinity` disables it. Before this they wired only `req.on('error')`, so a socket the server silently dropped — or a response that never starts — hung `generate()` until the OS TCP timeout (~2h): a hang, not an error, so retry/casualty policy above it never fired. `timeoutMs` bounds on socket **inactivity** (`req.setTimeout`), so a slow-but-streaming response is not killed — only a silent/never-answering socket trips it; the 10-min default clears any single non-streaming completion (TTFB ≈ generation time). On trip, `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`, `retryable: true`). **Retry is caller-side and already wired:** `new Loop({ provider, retry: new Retry() })` wraps `provider.generate`, and `DEFAULT_RETRY_ON` classifies `ETIMEDOUT` (and `ECONNRESET`/`ENOTFOUND`/429/5xx) as transient — so a wired `Retry` retries a timed-out request and rethrows under `retryOn: () => false`, with no extra wiring (`run-plan`'s `stepRetry` is a second consumer of the same seam). CLIPipe already bounded its child process (`timeout`, default 30000 for one-shot) and is unchanged.
980
990
 
981
991
  **Total call-duration deadline (BA-19, v0.35.0):** `timeoutMs` bounds socket *inactivity*, and `req.setTimeout` resets on any activity by design — so a "zombie stream" that trickles a byte forever (bytes arriving, the response never completing) never trips it and hangs the caller for hours (an adopter saw one `generate()` run **274 min** and end in `ECONNRESET`, not a `TimeoutError`: the reset proves bytes *were* flowing, so the idle timer never fired). The four http(s) providers now also accept a `deadlineMs` option — an absolute, **non-resetting** wall-clock ceiling on the whole request. **Disabled by default** (a deliberately long single call — large `maxTokens`, slow model — is legitimate; a default here would kill it), overridable per call via `generate(..., { deadlineMs })`, `0`/`Infinity` disable. An *unset* deadline resolves to disabled, but an *explicitly-set* garbage value (`NaN`, a non-numeric string) throws a `ValidationError` at resolve time rather than silently disabling the bound and running unbounded — unlike `timeoutMs`, the deadline has no safe default to fall back to, so a config mistake must surface loudly. On trip, `generate()` rejects with a **terminal** `TimeoutError` distinguishable from the idle trip: `code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false` — a deadline is a hard ceiling meant to STOP, so it is *not* auto-retried (retrying would re-spend up to another full `deadlineMs`); a consumer that wants retry opts in via `retryOn`. When both are armed and `timeoutMs < deadlineMs`, a silent socket trips the idle bound first; only a still-active-but-never-completing stream reaches the deadline. The idle bound (BA-18) and the deadline (BA-19) are two independent failure modes — a silent socket vs a zombie stream.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.49.0",
3
+ "version": "0.51.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
package/primitives.json CHANGED
@@ -442,6 +442,15 @@
442
442
  "fails": "never throws on emit; a transport write error is isolated and does not interrupt the run.",
443
443
  "example": "const stream = new Stream({ transport });\nstream.emit('loop:round', { n: 1 });"
444
444
  },
445
+ {
446
+ "name": "thinkingSupport",
447
+ "category": "providers",
448
+ "when": "you need to know, before building a request, whether a provider/model can have thinking turned on or off and which request field does it — pure lookup, no network",
449
+ "import": "import { thinkingSupport } from 'bare-agent/providers'",
450
+ "signature": "thinkingSupport(provider: 'anthropic'|'openai'|'deepseek'|'gemini'|'ollama'|'clipipe'|string, model?: string) => { switchable: boolean|'unverified', off: string|null, on: string|null, note: string }",
451
+ "fails": "returns switchable:false for clipipe and unknown providers (never throws); 'unverified' means not measured, not unsupported; Ollama's shapes are not live-verified.",
452
+ "example": "thinkingSupport('anthropic', 'claude-opus-5-5'); // { switchable: false, off: null, on: \"thinking:{type:'adaptive'}\", note: '...' }"
453
+ },
445
454
  {
446
455
  "name": "unitAssembler",
447
456
  "category": "context",
package/src/loop.d.ts CHANGED
@@ -167,7 +167,7 @@ export class Loop {
167
167
  * thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
168
168
  * unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
169
169
  * @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
170
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true}, temperatureDropped?: boolean}>}
170
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: GenerateResult['malformedToolCall'], temperatureDropped?: boolean, thinkingApplied?: boolean|null}>}
171
171
  * On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
172
172
  * thrown HaltError carried no `rule`), and `msgs` is sanitized so any
173
173
  * dangling assistant `tool_calls` from the halted round are paired with
@@ -205,13 +205,9 @@ export class Loop {
205
205
  model: string | null;
206
206
  msgs: Message[];
207
207
  metrics: RunMetrics;
208
- malformedToolCall?: {
209
- name: string | undefined;
210
- error: string;
211
- rawArguments?: string;
212
- rawTruncated?: true;
213
- };
208
+ malformedToolCall?: GenerateResult["malformedToolCall"];
214
209
  temperatureDropped?: boolean;
210
+ thinkingApplied?: boolean | null;
215
211
  }>;
216
212
  _warnedTruncated: boolean | undefined;
217
213
  /**
@@ -240,7 +236,7 @@ export class Loop {
240
236
  * @param {string} text - User message.
241
237
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
242
238
  * @param {Record<string, any>} [options={}] - Per-run overrides.
243
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true}, temperatureDropped?: boolean}>}
239
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: GenerateResult['malformedToolCall'], temperatureDropped?: boolean, thinkingApplied?: boolean|null}>}
244
240
  */
245
241
  chat(text: string, tools?: ToolDef[], options?: Record<string, any>): Promise<{
246
242
  text: string;
@@ -252,13 +248,9 @@ export class Loop {
252
248
  model: string | null;
253
249
  msgs: Message[];
254
250
  metrics: RunMetrics;
255
- malformedToolCall?: {
256
- name: string | undefined;
257
- error: string;
258
- rawArguments?: string;
259
- rawTruncated?: true;
260
- };
251
+ malformedToolCall?: GenerateResult["malformedToolCall"];
261
252
  temperatureDropped?: boolean;
253
+ thinkingApplied?: boolean | null;
262
254
  }>;
263
255
  /**
264
256
  * Request a clean stop. The current tool call finishes; the loop then exits at the next boundary and
package/src/loop.js CHANGED
@@ -455,7 +455,7 @@ class Loop {
455
455
  * thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
456
456
  * unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
457
457
  * @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
458
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true}, temperatureDropped?: boolean}>}
458
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: GenerateResult['malformedToolCall'], temperatureDropped?: boolean, thinkingApplied?: boolean|null}>}
459
459
  * On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
460
460
  * thrown HaltError carried no `rule`), and `msgs` is sanitized so any
461
461
  * dangling assistant `tool_calls` from the halted round are paired with
@@ -565,6 +565,10 @@ class Loop {
565
565
  // unsupported/deprecated) and retried without it. Surfaced on the result so an upstream receipt
566
566
  // (recurse's refineLeaf) can report the EFFECTIVE temperature rather than the ignored request.
567
567
  let temperatureDropped = false;
568
+ // Normalized thinkingMode (src/thinking-mode.js): the run's `thinkingApplied` verdict — sticky-false
569
+ // (any round false ⇒ false), else the latest round's true/null; undefined ⇒ the provider reported none (thinkingMode unset) ⇒ key absent.
570
+ /** @type {boolean|null|undefined} */
571
+ let lastThinkingApplied;
568
572
  // BA-11: consecutive policy-deny counter (reset by any tool call that PASSES policy). When it reaches
569
573
  // this.maxConsecutiveDenials the run short-circuits cleanly — see the deny block below.
570
574
  let consecutiveDenials = 0;
@@ -597,7 +601,7 @@ class Loop {
597
601
  sealDanglingToolCalls(msgs, `[halted:${stuckTag}]`);
598
602
  this._reportError('stuck', new Error(`tool "${tc.name}" failed ${identicalErrors} times with identical arguments`), { rule: stuckTag, attempts: identicalErrors });
599
603
  this._safeEmit({ type: 'loop:done', data: { text: lastText, stuck: true, rule: stuckTag, cost: totalCost } });
600
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: stuckTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }) };
604
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: stuckTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...(lastThinkingApplied !== undefined && { thinkingApplied: lastThinkingApplied }) };
601
605
  }
602
606
  return null;
603
607
  };
@@ -844,7 +848,7 @@ class Loop {
844
848
  this._reportError('provider', err, { round });
845
849
  if (this.throwOnError) throw err;
846
850
  // BA-5: a mid-run provider failure must not erase the work of the rounds that succeeded.
847
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }) };
851
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...(lastThinkingApplied !== undefined && { thinkingApplied: lastThinkingApplied }) };
848
852
  }
849
853
 
850
854
  lastUsage = result.usage || lastUsage;
@@ -861,6 +865,11 @@ class Loop {
861
865
  lastMalformedToolCall = (result.malformedToolCall && typeof result.malformedToolCall === 'object')
862
866
  ? result.malformedToolCall : null;
863
867
  if (result.temperatureDropped) temperatureDropped = true;
868
+ // Sticky-false: once ANY round reports false (thinking happened despite 'off'), the run stays false;
869
+ // otherwise the latest round's true/null wins; a round without the field leaves it untouched.
870
+ if (result.thinkingApplied === true || result.thinkingApplied === false || result.thinkingApplied === null) {
871
+ if (lastThinkingApplied !== false) lastThinkingApplied = result.thinkingApplied;
872
+ }
864
873
  // Publish the latest measured usage to ctx (non-enumerable, fail-open) so a transcript-bound seam —
865
874
  // e.g. F2 stash auto-compaction — can read EXACT provider-counted `inputTokens` to gauge context
866
875
  // pressure on the NEXT round's trim. Symmetric with lending ctx.summarize; the Loop stays unaware of
@@ -944,7 +953,7 @@ class Loop {
944
953
  sealDanglingToolCalls(msgs, `[halted:${session.error}]`);
945
954
  this._reportError('session', new Error(`provider session terminated: ${session.error}`), { rule: session.error, sessionTurns: session.turns ?? null });
946
955
  this._safeEmit({ type: 'loop:done', data: { text: lastText, rule: session.error, sessionTurns: session.turns ?? null, cost: totalCost } });
947
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: session.error, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }) };
956
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: session.error, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...(lastThinkingApplied !== undefined && { thinkingApplied: lastThinkingApplied }) };
948
957
  }
949
958
 
950
959
  // BA-13: classify this round's terminal signal against the neutral stop-reason vocabulary. BA-6
@@ -1009,7 +1018,7 @@ class Loop {
1009
1018
  msgs.push({ role: 'assistant', content: result.text });
1010
1019
  }
1011
1020
  this._safeEmit({ type: 'loop:done', data: { text: lastText, ...(terminal === 'truncated' && { truncated: true }), terminal, stopReason: lastStopReason, droppedToolCalls: dropped, cost: totalCost } });
1012
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: errorTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }) };
1021
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: errorTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...(lastThinkingApplied !== undefined && { thinkingApplied: lastThinkingApplied }) };
1013
1022
  }
1014
1023
 
1015
1024
  // No tool calls — LLM gave a final text response
@@ -1029,7 +1038,7 @@ class Loop {
1029
1038
  try { await flush(msgs, ctx); }
1030
1039
  catch (err) { if (err instanceof HaltError) throw err; this._reportError('trim-flush', err, { round }); }
1031
1040
  }
1032
- return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }) };
1041
+ return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...(lastThinkingApplied !== undefined && { thinkingApplied: lastThinkingApplied }) };
1033
1042
  }
1034
1043
 
1035
1044
  // Execute tool calls
@@ -1138,7 +1147,7 @@ class Loop {
1138
1147
  sealDanglingToolCalls(msgs, `[halted:${denyTag}]`);
1139
1148
  this._reportError('denied', new Error(`policy denied ${consecutiveDenials} consecutive tool calls (${tc.name})`), { rule: denyTag, denials: consecutiveDenials });
1140
1149
  this._safeEmit({ type: 'loop:done', data: { text: lastText, denied: true, rule: denyTag, cost: totalCost } });
1141
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }) };
1150
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...(lastThinkingApplied !== undefined && { thinkingApplied: lastThinkingApplied }) };
1142
1151
  }
1143
1152
  continue;
1144
1153
  }
@@ -1212,7 +1221,7 @@ class Loop {
1212
1221
  // BA-5: the rule tag survives on `error`; so does the work. A halt is how a bounded attempt is
1213
1222
  // SUPPOSED to end — the caller reads `error` to know it was bounded and `text` to learn from it.
1214
1223
  this._safeEmit({ type: 'loop:done', data: { text: lastText, halted: true, rule, cost: totalCost } });
1215
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }) };
1224
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...(lastThinkingApplied !== undefined && { thinkingApplied: lastThinkingApplied }) };
1216
1225
  }
1217
1226
  throw err;
1218
1227
  }
@@ -1242,20 +1251,20 @@ class Loop {
1242
1251
  const rule = err.rule || 'unknown';
1243
1252
  this._reportError('halt', err, { rule, reason: err.decision?.reason ?? null });
1244
1253
  this._safeEmit({ type: 'loop:done', data: { text: lastText, halted: true, rule, cost: totalCost } });
1245
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }) };
1254
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...(lastThinkingApplied !== undefined && { thinkingApplied: lastThinkingApplied }) };
1246
1255
  }
1247
1256
  this._reportError('trim-flush', err, { phase: 'stop' });
1248
1257
  }
1249
1258
  }
1250
1259
  this._safeEmit({ type: 'loop:done', data: { text: lastText, stopped: true, cost: totalCost } });
1251
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }) };
1260
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...(lastThinkingApplied !== undefined && { thinkingApplied: lastThinkingApplied }) };
1252
1261
  }
1253
1262
 
1254
1263
  // Hard safety limit — should never fire under normal usage; bareguard's
1255
1264
  // limits.maxTurns (or the LLM's natural completion) ends the loop first.
1256
1265
  const warning = `[Loop] hit internal safety limit of ${HARD_ROUND_LIMIT} rounds. Wire bareguard for proper governance — see bare-agent/bareguard.`;
1257
1266
  this._safeEmit({ type: 'loop:done', data: { text: lastText, warning, cost: totalCost } });
1258
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }) };
1267
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...(lastThinkingApplied !== undefined && { thinkingApplied: lastThinkingApplied }) };
1259
1268
  }
1260
1269
 
1261
1270
  /**
@@ -1328,7 +1337,7 @@ class Loop {
1328
1337
  * @param {string} text - User message.
1329
1338
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
1330
1339
  * @param {Record<string, any>} [options={}] - Per-run overrides.
1331
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true}, temperatureDropped?: boolean}>}
1340
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: GenerateResult['malformedToolCall'], temperatureDropped?: boolean, thinkingApplied?: boolean|null}>}
1332
1341
  */
1333
1342
  async chat(text, tools = [], options = {}) {
1334
1343
  this._history.push({ role: 'user', content: text });
@@ -30,6 +30,11 @@ export type AnthropicOptions = {
30
30
  * **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.
31
31
  */
32
32
  thinking?: any;
33
+ /**
34
+ * - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right Anthropic `thinking` request field for the provider/model, retries the known alternative shapes if the API rejects the field by name, and THROWS a `ValidationError` (naming what it tried) if every shape is rejected — or before any request when the model is measured as not switchable. Every reply is checked: `GenerateResult.thinkingApplied` is `true`/`false`/`null` (`false` = 'off' was sent but the reply shows thinking happened — warns once per instance, result kept). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`.
35
+ * Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — throws `ValidationError`, which the chain treats like any failure and falls through).
36
+ */
37
+ thinkingMode?: "on" | "off" | null | undefined;
33
38
  /**
34
39
  * - 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).
35
40
  */
@@ -58,6 +63,8 @@ export type AnthropicOptions = {
58
63
  * chain set `thinking` on each provider's CONSTRUCTOR instead of per-call.
59
64
  *
60
65
  * **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.
66
+ * @property {'on'|'off'|null} [thinkingMode] - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right Anthropic `thinking` request field for the provider/model, retries the known alternative shapes if the API rejects the field by name, and THROWS a `ValidationError` (naming what it tried) if every shape is rejected — or before any request when the model is measured as not switchable. Every reply is checked: `GenerateResult.thinkingApplied` is `true`/`false`/`null` (`false` = 'off' was sent but the reply shows thinking happened — warns once per instance, result kept). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`.
67
+ * Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — throws `ValidationError`, which the chain treats like any failure and falls through).
61
68
  * @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).
62
69
  * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. A silent or never-answering socket (dropped by the server, or a response that never starts) was otherwise bounded only by the OS TCP timeout (~2h) — a hang, not an error, so every retry policy above it was inert. Bounds on socket INACTIVITY (the timer resets on activity, so a slow-but-streaming response is not killed); on trip, `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) that a wired `Retry` (`Loop({ retry })`) retries. Default 10 min sits above any single non-streaming completion; `0` or `Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
63
70
  * @property {number} [deadlineMs=0] - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever (a "zombie stream") never trips it and hangs the caller for hours. This is an absolute, non-resetting wall-clock ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false` — a hard ceiling meant to STOP, not re-spend). DISABLED by default (a deliberately long single call is legitimate); `0`/`Infinity` disable. When both are set with `timeoutMs < deadlineMs`, a silent socket trips the idle bound first. Overridable per call via `generate(..., { deadlineMs })`.
@@ -78,6 +85,9 @@ export class AnthropicProvider {
78
85
  cacheSystem: boolean;
79
86
  cacheMessages: boolean;
80
87
  thinking: any;
88
+ thinkingMode: "on" | "off" | null;
89
+ /** @type {Map<string, string>} shape that worked per mode+model (no re-probing) */
90
+ _thinkingShapes: Map<string, string>;
81
91
  exposeErrorBody: boolean;
82
92
  timeoutMs: number | undefined;
83
93
  deadlineMs: number | undefined;
@@ -85,7 +95,7 @@ export class AnthropicProvider {
85
95
  * Generate a response from the Anthropic API.
86
96
  * @param {Message[]} messages - Conversation messages (OpenAI format, auto-converted).
87
97
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
88
- * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, system, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19).
98
+ * @param {Record<string, any>} [options={}] - Options (thinkingMode — per-call override of the constructor's, `null` suppresses; temperature, maxTokens, system, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19).
89
99
  * @returns {Promise<GenerateResult>}
90
100
  * @throws {Error} `[AnthropicProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
91
101
  */
@@ -7,6 +7,7 @@ const { requestWithTemperatureFallback } = require('./provider-temperature');
7
7
  const { normalizeStopReason } = require('./provider-stop-reason');
8
8
  const { resolveTimeoutMs, applyRequestBounds, guardResponseSettles } = require('./provider-http');
9
9
  const { hasUsageSignal } = require('./provider-usage');
10
+ const { normalizeThinkingMode, resolveThinkingMode, requestWithThinkingMode, assessThinkingApplied } = require('./thinking-mode');
10
11
 
11
12
  // BA-24: the raw Anthropic usage field names. Presence of any (even value 0) means the API reported a
12
13
  // usage signal → build the object; absence of all means no signal → surface null (unpriceable).
@@ -34,6 +35,8 @@ function isLoopbackHost(hostname) {
34
35
  * chain set `thinking` on each provider's CONSTRUCTOR instead of per-call.
35
36
  *
36
37
  * **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.
38
+ * @property {'on'|'off'|null} [thinkingMode] - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right Anthropic `thinking` request field for the provider/model, retries the known alternative shapes if the API rejects the field by name, and THROWS a `ValidationError` (naming what it tried) if every shape is rejected — or before any request when the model is measured as not switchable. Every reply is checked: `GenerateResult.thinkingApplied` is `true`/`false`/`null` (`false` = 'off' was sent but the reply shows thinking happened — warns once per instance, result kept). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`.
39
+ * Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — throws `ValidationError`, which the chain treats like any failure and falls through).
37
40
  * @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).
38
41
  * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. A silent or never-answering socket (dropped by the server, or a response that never starts) was otherwise bounded only by the OS TCP timeout (~2h) — a hang, not an error, so every retry policy above it was inert. Bounds on socket INACTIVITY (the timer resets on activity, so a slow-but-streaming response is not killed); on trip, `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) that a wired `Retry` (`Loop({ retry })`) retries. Default 10 min sits above any single non-streaming completion; `0` or `Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
39
42
  * @property {number} [deadlineMs=0] - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever (a "zombie stream") never trips it and hangs the caller for hours. This is an absolute, non-resetting wall-clock ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false` — a hard ceiling meant to STOP, not re-spend). DISABLED by default (a deliberately long single call is legitimate); `0`/`Infinity` disable. When both are set with `timeoutMs < deadlineMs`, a silent socket trips the idle bound first. Overridable per call via `generate(..., { deadlineMs })`.
@@ -64,6 +67,12 @@ class AnthropicProvider {
64
67
  // release every time Anthropic moves; passing it through means the caller can always express
65
68
  // the current API. Omit it to keep today's body byte-identical.
66
69
  this.thinking = options.thinking != null ? options.thinking : null;
70
+ // Normalized on/off switch (src/thinking-mode.js). Validated here; conflict with raw `thinking` is a
71
+ // ValidationError (no silent precedence). Unset ⇒ null ⇒ the request path is byte-identical.
72
+ this.thinkingMode = normalizeThinkingMode(options.thinkingMode, 'AnthropicProvider');
73
+ resolveThinkingMode({ label: 'AnthropicProvider', instanceMode: this.thinkingMode, options: {}, instanceThinking: this.thinking, hasRawThinking: true });
74
+ /** @type {Map<string, string>} shape that worked per mode+model (no re-probing) */
75
+ this._thinkingShapes = new Map();
67
76
  // See OpenAIProvider: attach full upstream body to err.body only on opt-in.
68
77
  this.exposeErrorBody = options.exposeErrorBody === true;
69
78
  // BA-18: request/idle timeout (ms). Resolved at call time (default 600000; 0/Infinity disable).
@@ -76,7 +85,7 @@ class AnthropicProvider {
76
85
  * Generate a response from the Anthropic API.
77
86
  * @param {Message[]} messages - Conversation messages (OpenAI format, auto-converted).
78
87
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
79
- * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, system, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19).
88
+ * @param {Record<string, any>} [options={}] - Options (thinkingMode — per-call override of the constructor's, `null` suppresses; temperature, maxTokens, system, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19).
80
89
  * @returns {Promise<GenerateResult>}
81
90
  * @throws {Error} `[AnthropicProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
82
91
  */
@@ -149,6 +158,7 @@ class AnthropicProvider {
149
158
  // above, which applies whether or not you ever set this.
150
159
  const thinking = options.thinking !== undefined ? options.thinking : this.thinking;
151
160
  if (thinking) body.thinking = thinking;
161
+ const thinkingMode = resolveThinkingMode({ label: 'AnthropicProvider', instanceMode: this.thinkingMode, options, instanceThinking: this.thinking, hasRawThinking: true });
152
162
  if (tools.length > 0) {
153
163
  body.tools = tools.map(t => ({
154
164
  name: t.name,
@@ -163,9 +173,12 @@ class AnthropicProvider {
163
173
  const timeoutMs = resolveTimeoutMs(this.timeoutMs, options.timeoutMs);
164
174
  const deadlineMs = resolveTimeoutMs(this.deadlineMs, options.deadlineMs, 0, 'deadlineMs');
165
175
  const { data, temperatureDropped } = await requestWithTemperatureFallback({
166
- request: () => this._request(body, timeoutMs, deadlineMs),
176
+ request: () => thinkingMode
177
+ ? requestWithThinkingMode({ kind: 'anthropic', mode: thinkingMode, model: this.model, label: 'AnthropicProvider', body, cache: this._thinkingShapes, request: (b) => this._request(b, timeoutMs, deadlineMs) })
178
+ : this._request(body, timeoutMs, deadlineMs),
167
179
  hadTemperature: () => body.temperature != null,
168
180
  stripTemperature: () => { delete body.temperature; },
181
+ thinkingOn: thinkingMode === 'on',
169
182
  warnOnce: () => this._warnTemperatureDropped(),
170
183
  });
171
184
 
@@ -213,6 +226,7 @@ class AnthropicProvider {
213
226
  }
214
227
  : null,
215
228
  ...(temperatureDropped && { temperatureDropped: true }),
229
+ ...(thinkingMode && { thinkingApplied: assessThinkingApplied({ kind: 'anthropic', mode: thinkingMode, data, owner: this, label: 'AnthropicProvider', model: this.model }) }),
216
230
  };
217
231
  }
218
232
 
@@ -19,6 +19,10 @@ export type CLIPipeOptions = {
19
19
  * - Environment variables for the child process.
20
20
  */
21
21
  env?: Record<string, string> | undefined;
22
+ /**
23
+ * - NOT supported: any non-null value throws `ValidationError` (CLIPipe has no thinking switch). Declared so the loud failure is typed, not silent.
24
+ */
25
+ thinkingMode?: any;
22
26
  /**
23
27
  * - Timeout in milliseconds.
24
28
  */
@@ -172,7 +176,7 @@ export class CLIPipeProvider {
172
176
  * failure for a genuinely tool-incapable model lives in the tool-mode capability probe.
173
177
  * @param {Message[]} messages - Conversation messages in OpenAI format.
174
178
  * @param {ToolDef[]} [tools=[]] - Caller tools. Honored only in tool mode (`toolProtocol` set).
175
- * @param {Record<string, any>} [options={}] - Unused.
179
+ * @param {Record<string, any>} [options={}] - Unused, except `thinkingMode` (any non-null value throws `ValidationError`: CLIPipe has no thinking switch).
176
180
  * @returns {Promise<GenerateResult>}
177
181
  * @throws {Error} `[CLIPipeProvider] failed to spawn "cmd": ...` — when the command cannot be found or executed.
178
182
  * @throws {Error} `[CLIPipeProvider] process exited with code N: ...` — on non-zero exit.
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { spawn } = require('child_process');
4
- const { ProviderError, HaltError } = require('./errors');
4
+ const { ProviderError, HaltError, ValidationError } = require('./errors');
5
5
  const { buildToolSystemPrompt, renderTranscript, resolveToolProtocol, mapClaudeMeta } = require('./provider-clipipe-tools');
6
6
  const { createBridge, resolveSessionError, runSession } = require('./provider-clipipe-mcp');
7
7
 
@@ -21,6 +21,7 @@ const { createBridge, resolveSessionError, runSession } = require('./provider-cl
21
21
  * @property {string[]} [args=[]] - Arguments to pass to the command.
22
22
  * @property {string} [cwd] - Working directory for the child process.
23
23
  * @property {Record<string, string>} [env] - Environment variables for the child process.
24
+ * @property {any} [thinkingMode] - NOT supported: any non-null value throws `ValidationError` (CLIPipe has no thinking switch). Declared so the loud failure is typed, not silent.
24
25
  * @property {number} [timeout=30000] - Timeout in milliseconds.
25
26
  * @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.
26
27
  * @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
@@ -107,6 +108,10 @@ function subtractUsage(total, streamed) {
107
108
  return out;
108
109
  }
109
110
 
111
+ const CLIPIPE_NO_THINKING_MODE = () => new ValidationError(
112
+ '[CLIPipeProvider] thinkingMode is not supported — a CLI subprocess has no thinking switch bare-agent can set. Remove thinkingMode (see thinkingSupport(\'clipipe\')).',
113
+ { context: { lib: 'bare-agent', provider: 'clipipe' } });
114
+
110
115
  class CLIPipeProvider {
111
116
  /**
112
117
  * Provider that pipes prompts to a CLI command via stdin and reads stdout.
@@ -119,6 +124,8 @@ class CLIPipeProvider {
119
124
  */
120
125
  constructor(options = {}) {
121
126
  if (!options.command) throw new Error('[CLIPipeProvider] requires command');
127
+ // thinkingMode has NO mechanism here — loud, never silently ignored.
128
+ if (options.thinkingMode != null) throw CLIPIPE_NO_THINKING_MODE();
122
129
  this.command = options.command;
123
130
  this.args = options.args || [];
124
131
  this.cwd = options.cwd || undefined;
@@ -177,7 +184,7 @@ class CLIPipeProvider {
177
184
  * failure for a genuinely tool-incapable model lives in the tool-mode capability probe.
178
185
  * @param {Message[]} messages - Conversation messages in OpenAI format.
179
186
  * @param {ToolDef[]} [tools=[]] - Caller tools. Honored only in tool mode (`toolProtocol` set).
180
- * @param {Record<string, any>} [options={}] - Unused.
187
+ * @param {Record<string, any>} [options={}] - Unused, except `thinkingMode` (any non-null value throws `ValidationError`: CLIPipe has no thinking switch).
181
188
  * @returns {Promise<GenerateResult>}
182
189
  * @throws {Error} `[CLIPipeProvider] failed to spawn "cmd": ...` — when the command cannot be found or executed.
183
190
  * @throws {Error} `[CLIPipeProvider] process exited with code N: ...` — on non-zero exit.
@@ -185,6 +192,7 @@ class CLIPipeProvider {
185
192
  * @throws {Error} `[CLIPipeProvider] process produced no output` — when stdout is empty.
186
193
  */
187
194
  async generate(messages, tools = [], options = {}) {
195
+ if (options && options.thinkingMode != null) throw CLIPIPE_NO_THINKING_MODE();
188
196
  if (Array.isArray(tools) && tools.length > 0) {
189
197
  if (this.nativeTools) return this._generateWithMcp(messages, tools, options);
190
198
  if (this.toolProtocol) return this._generateWithTools(messages, tools);
@@ -25,6 +25,9 @@ export type FallbackOptions = {
25
25
  * NOTE: per-call `generate()` options (incl. `thinking`, `toolChoice`) are forwarded UNCHANGED to EVERY provider in the chain. For a
26
26
  * mixed-vendor chain (e.g. OpenAI-compat DeepSeek + Anthropic) set `thinking` on each provider's CONSTRUCTOR instead of per-call,
27
27
  * or a value shaped for one vendor lands in another's `body.thinking` on fallback.
28
+ * The normalized `thinkingMode: 'on'|'off'` is the exception: reaching every provider is CORRECT for it (each maps it to its own
29
+ * request shape); a provider that cannot honor it (CLIPipe, a measured non-switchable model) throws `ValidationError`, which the chain
30
+ * treats like any failure.
28
31
  */
29
32
  export class FallbackProvider {
30
33
  /**
@@ -15,6 +15,9 @@
15
15
  * NOTE: per-call `generate()` options (incl. `thinking`, `toolChoice`) are forwarded UNCHANGED to EVERY provider in the chain. For a
16
16
  * mixed-vendor chain (e.g. OpenAI-compat DeepSeek + Anthropic) set `thinking` on each provider's CONSTRUCTOR instead of per-call,
17
17
  * or a value shaped for one vendor lands in another's `body.thinking` on fallback.
18
+ * The normalized `thinkingMode: 'on'|'off'` is the exception: reaching every provider is CORRECT for it (each maps it to its own
19
+ * request shape); a provider that cannot honor it (CLIPipe, a measured non-switchable model) throws `ValidationError`, which the chain
20
+ * treats like any failure.
18
21
  */
19
22
  class FallbackProvider {
20
23
  /**
@@ -15,6 +15,11 @@ export type GeminiOptions = {
15
15
  * - API base (override for proxies; posts to `${baseUrl}/models/${model}:generateContent`).
16
16
  */
17
17
  baseUrl?: string | undefined;
18
+ /**
19
+ * - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right `generationConfig.thinkingConfig.thinkingBudget` request field for the provider/model, retries the known alternative shapes if the API rejects the field by name, and THROWS a `ValidationError` (naming what it tried) if every shape is rejected — or before any request when the model is measured as not switchable. Every reply is checked: `GenerateResult.thinkingApplied` is `true`/`false`/`null` (`false` = 'off' was sent but the reply shows thinking happened — warns once per instance, result kept). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`.
20
+ * Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — throws `ValidationError`, which the chain treats like any failure and falls through).
21
+ */
22
+ thinkingMode?: "on" | "off" | null | undefined;
18
23
  /**
19
24
  * - Attach the full upstream response to `err.body` on HTTP errors (off by default; `err.message` still carries the API error).
20
25
  */
@@ -33,6 +38,8 @@ export type GeminiOptions = {
33
38
  * @property {string} [apiKey] - Google AI Studio (Gemini) API key.
34
39
  * @property {string} [model='gemini-2.5-flash'] - Model ID.
35
40
  * @property {string} [baseUrl='https://generativelanguage.googleapis.com/v1beta'] - API base (override for proxies; posts to `${baseUrl}/models/${model}:generateContent`).
41
+ * @property {'on'|'off'|null} [thinkingMode] - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right `generationConfig.thinkingConfig.thinkingBudget` request field for the provider/model, retries the known alternative shapes if the API rejects the field by name, and THROWS a `ValidationError` (naming what it tried) if every shape is rejected — or before any request when the model is measured as not switchable. Every reply is checked: `GenerateResult.thinkingApplied` is `true`/`false`/`null` (`false` = 'off' was sent but the reply shows thinking happened — warns once per instance, result kept). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`.
42
+ * Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — throws `ValidationError`, which the chain treats like any failure and falls through).
36
43
  * @property {boolean} [exposeErrorBody=false] - Attach the full upstream response to `err.body` on HTTP errors (off by default; `err.message` still carries the API error).
37
44
  * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. Bounds a silent or never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) instead of hanging until the OS TCP timeout (~2h). `0`/`Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
38
45
  * @property {number} [deadlineMs=0] - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever never trips it and hangs for hours. This is an absolute, non-resetting ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via `generate(..., { deadlineMs })`.
@@ -57,13 +64,16 @@ export class GeminiProvider {
57
64
  model: string;
58
65
  baseUrl: string;
59
66
  exposeErrorBody: boolean;
67
+ thinkingMode: "on" | "off" | null;
68
+ /** @type {Map<string, string>} shape that worked per mode+model (no re-probing) */
69
+ _thinkingShapes: Map<string, string>;
60
70
  timeoutMs: number | undefined;
61
71
  deadlineMs: number | undefined;
62
72
  /**
63
73
  * Generate a response from the Gemini API.
64
74
  * @param {Message[]} messages - Conversation messages (OpenAI format, auto-converted).
65
75
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
66
- * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19).
76
+ * @param {Record<string, any>} [options={}] - Options (thinkingMode — per-call override of the constructor's, `null` suppresses; temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19).
67
77
  * @returns {Promise<GenerateResult>}
68
78
  * @throws {Error} `[GeminiProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
69
79
  */
@@ -7,6 +7,7 @@ const { requestWithTemperatureFallback } = require('./provider-temperature');
7
7
  const { normalizeStopReason } = require('./provider-stop-reason');
8
8
  const { resolveTimeoutMs, applyRequestBounds, guardResponseSettles } = require('./provider-http');
9
9
  const { hasUsageSignal } = require('./provider-usage');
10
+ const { normalizeThinkingMode, resolveThinkingMode, requestWithThinkingMode, assessThinkingApplied } = require('./thinking-mode');
10
11
 
11
12
  // BA-24: raw Gemini usageMetadata fields. Any present (even 0) ⇒ a usage signal; none ⇒ null.
12
13
  const GEMINI_USAGE_KEYS = ['promptTokenCount', 'candidatesTokenCount', 'thoughtsTokenCount', 'cachedContentTokenCount'];
@@ -27,6 +28,8 @@ function isLoopbackHost(hostname) {
27
28
  * @property {string} [apiKey] - Google AI Studio (Gemini) API key.
28
29
  * @property {string} [model='gemini-2.5-flash'] - Model ID.
29
30
  * @property {string} [baseUrl='https://generativelanguage.googleapis.com/v1beta'] - API base (override for proxies; posts to `${baseUrl}/models/${model}:generateContent`).
31
+ * @property {'on'|'off'|null} [thinkingMode] - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right `generationConfig.thinkingConfig.thinkingBudget` request field for the provider/model, retries the known alternative shapes if the API rejects the field by name, and THROWS a `ValidationError` (naming what it tried) if every shape is rejected — or before any request when the model is measured as not switchable. Every reply is checked: `GenerateResult.thinkingApplied` is `true`/`false`/`null` (`false` = 'off' was sent but the reply shows thinking happened — warns once per instance, result kept). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`.
32
+ * Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — throws `ValidationError`, which the chain treats like any failure and falls through).
30
33
  * @property {boolean} [exposeErrorBody=false] - Attach the full upstream response to `err.body` on HTTP errors (off by default; `err.message` still carries the API error).
31
34
  * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. Bounds a silent or never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) instead of hanging until the OS TCP timeout (~2h). `0`/`Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
32
35
  * @property {number} [deadlineMs=0] - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever never trips it and hangs for hours. This is an absolute, non-resetting ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via `generate(..., { deadlineMs })`.
@@ -52,6 +55,10 @@ class GeminiProvider {
52
55
  this.model = options.model || 'gemini-2.5-flash';
53
56
  this.baseUrl = options.baseUrl || 'https://generativelanguage.googleapis.com/v1beta';
54
57
  this.exposeErrorBody = options.exposeErrorBody === true;
58
+ // Normalized on/off switch (src/thinking-mode.js). Gemini has no raw `thinking` option to conflict with.
59
+ this.thinkingMode = normalizeThinkingMode(options.thinkingMode, 'GeminiProvider');
60
+ /** @type {Map<string, string>} shape that worked per mode+model (no re-probing) */
61
+ this._thinkingShapes = new Map();
55
62
  // BA-18: request/idle timeout (ms). Resolved at call time (default 600000; 0/Infinity disable).
56
63
  this.timeoutMs = options.timeoutMs;
57
64
  // BA-19: total call-duration deadline (ms). Resolved at call time (default 0 = disabled).
@@ -62,7 +69,7 @@ class GeminiProvider {
62
69
  * Generate a response from the Gemini API.
63
70
  * @param {Message[]} messages - Conversation messages (OpenAI format, auto-converted).
64
71
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
65
- * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19).
72
+ * @param {Record<string, any>} [options={}] - Options (thinkingMode — per-call override of the constructor's, `null` suppresses; temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19).
66
73
  * @returns {Promise<GenerateResult>}
67
74
  * @throws {Error} `[GeminiProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
68
75
  */
@@ -124,10 +131,13 @@ class GeminiProvider {
124
131
 
125
132
  // BA-10: graceful degrade if a model rejects a non-default `temperature` (Gemini nests it under
126
133
  // generationConfig). Keyed off the API error text, so dormant on models that accept temperature.
134
+ const thinkingMode = resolveThinkingMode({ label: 'GeminiProvider', instanceMode: this.thinkingMode, options });
127
135
  const timeoutMs = resolveTimeoutMs(this.timeoutMs, options.timeoutMs);
128
136
  const deadlineMs = resolveTimeoutMs(this.deadlineMs, options.deadlineMs, 0, 'deadlineMs');
129
137
  const { data, temperatureDropped } = await requestWithTemperatureFallback({
130
- request: () => this._request(`/models/${this.model}:generateContent`, body, timeoutMs, deadlineMs),
138
+ request: () => thinkingMode
139
+ ? requestWithThinkingMode({ kind: 'gemini', mode: thinkingMode, model: this.model, label: 'GeminiProvider', body, cache: this._thinkingShapes, request: (b) => this._request(`/models/${this.model}:generateContent`, b, timeoutMs, deadlineMs) })
140
+ : this._request(`/models/${this.model}:generateContent`, body, timeoutMs, deadlineMs),
131
141
  hadTemperature: () => body.generationConfig?.temperature != null,
132
142
  stripTemperature: () => { if (body.generationConfig) delete body.generationConfig.temperature; },
133
143
  warnOnce: () => this._warnTemperatureDropped(),
@@ -161,6 +171,7 @@ class GeminiProvider {
161
171
  stopReason: normalizeStopReason(data.candidates?.[0]?.finishReason, 'gemini', { hasToolCalls: toolCalls.length > 0 }),
162
172
  usage: this._normalizeUsage(data.usageMetadata),
163
173
  ...(temperatureDropped && { temperatureDropped: true }),
174
+ ...(thinkingMode && { thinkingApplied: assessThinkingApplied({ kind: 'gemini', mode: thinkingMode, data, owner: this, label: 'GeminiProvider', model: this.model }) }),
164
175
  };
165
176
  }
166
177