bare-agent 0.50.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,7 +33,7 @@ Some things an LLM can't judge about its own work. Building a harness or an exte
33
33
 
34
34
  ## Fast gut check — Jev
35
35
 
36
- An LLM call is slow, deliberate thinking (System 2). Jev, from TypeSafe, is the fast gut check (System 1): a classifier that returns a typed decision with a calibrated probability — roughly 200× faster and 400× cheaper than an LLM, per TypeSafe. bare-agent's `JevProvider` covers all three shapes — **yes/no**, **pick-one**, **score** — so your automation calls it like any other step: act when it's confident, escalate when it isn't. Injection-hardened by default; also usable as the `jev` check in the Evaluator.
36
+ An LLM call is slow, deliberate thinking (System 2). Jev, from TypeSafe, is the fast gut check (System 1): a classifier that returns a typed decision with a calibrated probability — far faster and cheaper than an LLM call. bare-agent's `JevProvider` covers all three shapes — **yes/no**, **pick-one**, **score** — so your automation calls it like any other step: act when it's confident, escalate when it isn't. Injection-hardened by default; also usable as the `jev` check in the Evaluator.
37
37
 
38
38
  ## Break big tasks down — RLM
39
39
 
@@ -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 client validation error — 400, or 422 — walks the ladder; the winning shape is cached) and **always runs the call** — callers need not care about providers. After the shapes, ONE plain request (no thinking field) decides: it succeeds ⇒ the setting was the problem and the result says `unsupported` (warns once); it fails too ⇒ a real error, thrown as the plain request's own (never a `ValidationError`). 5xx/429/timeouts/401/403/404 propagate at once with no ladder. A model measured as not switchable (`claude-opus-5-5` off, `gemini-3.1-pro-preview` off) goes out once WITHOUT a thinking field. "Unsupported" is never cached, so an unknown model that can't switch pays its refused shape requests on each call. Every reply carries `result.thinkingStatus`: `'applied'` (accepted; for `'off'`, zero thinking seen), `'unsupported'` (this provider/model can't do it — the call ran without our field), `'ignored'` (`'off'` accepted but the model thought anyway — warns once, result kept), or `'unknown'` (accepted, signal unreadable; e.g. OpenAI without `reasoning_tokens`). `result.thinkingApplied` is kept and derived (`true`/`false`/`false`/`null`). On `loop.run()` the WORST status across rounds wins (ignored > unsupported > unknown > applied). Still a `ValidationError`: a bad value, or raw `thinking` + `thinkingMode` together. 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, { baseUrl }?)` from `bare-agent/providers` (`baseUrl` makes an OpenAI-wire DeepSeek host answer with DeepSeek shapes, same detection as the request path) tells you, offline, what is known. DeepSeek `'on'` + a forced `toolChoice` can't think, so it is sent with `thinking:{type:'disabled'}` and reports `'unsupported'`; CLIPipe has no mechanism and runs unchanged, `'unsupported'`. In a `FallbackProvider` a not-switchable member just runs (no fall-through). 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.thinkingStatus === 'applied' (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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.50.0",
3
+ "version": "0.52.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, opts?: { baseUrl?: 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?: GenerateResult['malformedToolCall'], 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, thinkingStatus?: 'applied'|'unsupported'|'ignored'|'unknown'}>}
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
@@ -207,6 +207,8 @@ export class Loop {
207
207
  metrics: RunMetrics;
208
208
  malformedToolCall?: GenerateResult["malformedToolCall"];
209
209
  temperatureDropped?: boolean;
210
+ thinkingApplied?: boolean | null;
211
+ thinkingStatus?: "applied" | "unsupported" | "ignored" | "unknown";
210
212
  }>;
211
213
  _warnedTruncated: boolean | undefined;
212
214
  /**
@@ -235,7 +237,7 @@ export class Loop {
235
237
  * @param {string} text - User message.
236
238
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
237
239
  * @param {Record<string, any>} [options={}] - Per-run overrides.
238
- * @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}>}
240
+ * @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, thinkingStatus?: 'applied'|'unsupported'|'ignored'|'unknown'}>}
239
241
  */
240
242
  chat(text: string, tools?: ToolDef[], options?: Record<string, any>): Promise<{
241
243
  text: string;
@@ -249,6 +251,8 @@ export class Loop {
249
251
  metrics: RunMetrics;
250
252
  malformedToolCall?: GenerateResult["malformedToolCall"];
251
253
  temperatureDropped?: boolean;
254
+ thinkingApplied?: boolean | null;
255
+ thinkingStatus?: "applied" | "unsupported" | "ignored" | "unknown";
252
256
  }>;
253
257
  /**
254
258
  * Request a clean stop. The current tool call finishes; the loop then exits at the next boundary and
package/src/loop.js CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  const { ToolError, HaltError } = require('./errors');
4
4
  const { classifyStopReason } = require('./provider-stop-reason');
5
+ const { appliedOf } = require('./thinking-mode');
6
+
7
+ // Worst-wins precedence for the run's `thinkingStatus`. Null-prototype so a provider-supplied
8
+ // status string like 'constructor' cannot match an inherited key.
9
+ /** @type {Record<string, number>} */
10
+ const THINKING_STATUS_RANK = Object.assign(Object.create(null), { applied: 0, unknown: 1, unsupported: 2, ignored: 3 });
5
11
 
6
12
  /** @typedef {import('../types').Provider} Provider */
7
13
  /** @typedef {import('../types').Message} Message */
@@ -455,7 +461,7 @@ class Loop {
455
461
  * thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
456
462
  * unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
457
463
  * @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?: GenerateResult['malformedToolCall'], temperatureDropped?: boolean}>}
464
+ * @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, thinkingStatus?: 'applied'|'unsupported'|'ignored'|'unknown'}>}
459
465
  * On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
460
466
  * thrown HaltError carried no `rule`), and `msgs` is sanitized so any
461
467
  * dangling assistant `tool_calls` from the halted round are paired with
@@ -565,6 +571,12 @@ class Loop {
565
571
  // unsupported/deprecated) and retried without it. Surfaced on the result so an upstream receipt
566
572
  // (recurse's refineLeaf) can report the EFFECTIVE temperature rather than the ignored request.
567
573
  let temperatureDropped = false;
574
+ // Normalized thinkingMode (src/thinking-mode.js): the run's `thinkingStatus` is the WORST status across
575
+ // rounds (ignored > unsupported > unknown > applied); `thinkingApplied` is derived from it (applied⇒true,
576
+ // ignored/unsupported⇒false, unknown⇒null). undefined ⇒ no round carried either ⇒ both keys absent.
577
+ /** @type {'applied'|'unsupported'|'ignored'|'unknown'|undefined} */
578
+ let worstThinkingStatus;
579
+ const thinkingFields = () => worstThinkingStatus === undefined ? undefined : { thinkingStatus: worstThinkingStatus, thinkingApplied: appliedOf(worstThinkingStatus) };
568
580
  // BA-11: consecutive policy-deny counter (reset by any tool call that PASSES policy). When it reaches
569
581
  // this.maxConsecutiveDenials the run short-circuits cleanly — see the deny block below.
570
582
  let consecutiveDenials = 0;
@@ -597,7 +609,7 @@ class Loop {
597
609
  sealDanglingToolCalls(msgs, `[halted:${stuckTag}]`);
598
610
  this._reportError('stuck', new Error(`tool "${tc.name}" failed ${identicalErrors} times with identical arguments`), { rule: stuckTag, attempts: identicalErrors });
599
611
  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 }) };
612
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: stuckTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...thinkingFields() };
601
613
  }
602
614
  return null;
603
615
  };
@@ -844,7 +856,7 @@ class Loop {
844
856
  this._reportError('provider', err, { round });
845
857
  if (this.throwOnError) throw err;
846
858
  // 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 }) };
859
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...thinkingFields() };
848
860
  }
849
861
 
850
862
  lastUsage = result.usage || lastUsage;
@@ -861,6 +873,13 @@ class Loop {
861
873
  lastMalformedToolCall = (result.malformedToolCall && typeof result.malformedToolCall === 'object')
862
874
  ? result.malformedToolCall : null;
863
875
  if (result.temperatureDropped) temperatureDropped = true;
876
+ // Worst-status-wins across rounds; a round carrying only the legacy `thinkingApplied` (a custom provider)
877
+ // is mapped true→applied, false→ignored, null→unknown.
878
+ {
879
+ const st = THINKING_STATUS_RANK[result.thinkingStatus] !== undefined ? result.thinkingStatus
880
+ : result.thinkingApplied === true ? 'applied' : result.thinkingApplied === false ? 'ignored' : result.thinkingApplied === null ? 'unknown' : undefined;
881
+ if (st !== undefined && (worstThinkingStatus === undefined || THINKING_STATUS_RANK[st] > THINKING_STATUS_RANK[worstThinkingStatus])) worstThinkingStatus = st;
882
+ }
864
883
  // Publish the latest measured usage to ctx (non-enumerable, fail-open) so a transcript-bound seam —
865
884
  // e.g. F2 stash auto-compaction — can read EXACT provider-counted `inputTokens` to gauge context
866
885
  // pressure on the NEXT round's trim. Symmetric with lending ctx.summarize; the Loop stays unaware of
@@ -944,7 +963,7 @@ class Loop {
944
963
  sealDanglingToolCalls(msgs, `[halted:${session.error}]`);
945
964
  this._reportError('session', new Error(`provider session terminated: ${session.error}`), { rule: session.error, sessionTurns: session.turns ?? null });
946
965
  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 }) };
966
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: session.error, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...thinkingFields() };
948
967
  }
949
968
 
950
969
  // BA-13: classify this round's terminal signal against the neutral stop-reason vocabulary. BA-6
@@ -1009,7 +1028,7 @@ class Loop {
1009
1028
  msgs.push({ role: 'assistant', content: result.text });
1010
1029
  }
1011
1030
  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 }) };
1031
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: errorTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...thinkingFields() };
1013
1032
  }
1014
1033
 
1015
1034
  // No tool calls — LLM gave a final text response
@@ -1029,7 +1048,7 @@ class Loop {
1029
1048
  try { await flush(msgs, ctx); }
1030
1049
  catch (err) { if (err instanceof HaltError) throw err; this._reportError('trim-flush', err, { round }); }
1031
1050
  }
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 }) };
1051
+ return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...thinkingFields() };
1033
1052
  }
1034
1053
 
1035
1054
  // Execute tool calls
@@ -1138,7 +1157,7 @@ class Loop {
1138
1157
  sealDanglingToolCalls(msgs, `[halted:${denyTag}]`);
1139
1158
  this._reportError('denied', new Error(`policy denied ${consecutiveDenials} consecutive tool calls (${tc.name})`), { rule: denyTag, denials: consecutiveDenials });
1140
1159
  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 }) };
1160
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...thinkingFields() };
1142
1161
  }
1143
1162
  continue;
1144
1163
  }
@@ -1212,7 +1231,7 @@ class Loop {
1212
1231
  // BA-5: the rule tag survives on `error`; so does the work. A halt is how a bounded attempt is
1213
1232
  // SUPPOSED to end — the caller reads `error` to know it was bounded and `text` to learn from it.
1214
1233
  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 }) };
1234
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...thinkingFields() };
1216
1235
  }
1217
1236
  throw err;
1218
1237
  }
@@ -1242,20 +1261,20 @@ class Loop {
1242
1261
  const rule = err.rule || 'unknown';
1243
1262
  this._reportError('halt', err, { rule, reason: err.decision?.reason ?? null });
1244
1263
  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 }) };
1264
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...thinkingFields() };
1246
1265
  }
1247
1266
  this._reportError('trim-flush', err, { phase: 'stop' });
1248
1267
  }
1249
1268
  }
1250
1269
  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 }) };
1270
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...thinkingFields() };
1252
1271
  }
1253
1272
 
1254
1273
  // Hard safety limit — should never fire under normal usage; bareguard's
1255
1274
  // limits.maxTurns (or the LLM's natural completion) ends the loop first.
1256
1275
  const warning = `[Loop] hit internal safety limit of ${HARD_ROUND_LIMIT} rounds. Wire bareguard for proper governance — see bare-agent/bareguard.`;
1257
1276
  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 }) };
1277
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }), ...thinkingFields() };
1259
1278
  }
1260
1279
 
1261
1280
  /**
@@ -1328,7 +1347,7 @@ class Loop {
1328
1347
  * @param {string} text - User message.
1329
1348
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
1330
1349
  * @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?: GenerateResult['malformedToolCall'], temperatureDropped?: boolean}>}
1350
+ * @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, thinkingStatus?: 'applied'|'unsupported'|'ignored'|'unknown'}>}
1332
1351
  */
1333
1352
  async chat(text, tools = [], options = {}) {
1334
1353
  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, tries the next known shape when a shape request fails with a 400/422 (not the temperature path), and the call ALWAYS RUNS: if every shape fails that way, ONE plain request without a thinking field decides — plain OK ⇒ `thinkingStatus:'unsupported'`, plain fails ⇒ that plain request's error is thrown as-is; other failures propagate after one request; a model measured as not switchable is sent plain directly. Only the winning shape is cached per instance+model ('unsupported' is never cached); warns once per status. Every reply is checked: `GenerateResult.thinkingStatus` is `'applied'`/`'unsupported'`/`'ignored'`/`'unknown'` (`'ignored'` = 'off' was accepted but the reply shows thinking anyway — warns once, result kept) and the legacy `thinkingApplied` is derived (`true`/`false`/`null`). 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 — just runs without it and reports `thinkingStatus:'unsupported'`, so the first member answers and the chain does not fall 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, tries the next known shape when a shape request fails with a 400/422 (not the temperature path), and the call ALWAYS RUNS: if every shape fails that way, ONE plain request without a thinking field decides — plain OK ⇒ `thinkingStatus:'unsupported'`, plain fails ⇒ that plain request's error is thrown as-is; other failures propagate after one request; a model measured as not switchable is sent plain directly. Only the winning shape is cached per instance+model ('unsupported' is never cached); warns once per status. Every reply is checked: `GenerateResult.thinkingStatus` is `'applied'`/`'unsupported'`/`'ignored'`/`'unknown'` (`'ignored'` = 'off' was accepted but the reply shows thinking anyway — warns once, result kept) and the legacy `thinkingApplied` is derived (`true`/`false`/`null`). 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 — just runs without it and reports `thinkingStatus:'unsupported'`, so the first member answers and the chain does not fall 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,14 +95,13 @@ 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
  */
92
102
  generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
93
- /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
94
- _warnTemperatureDropped(): void;
95
- _warnedTempDropped: boolean | undefined;
103
+ /** One-time warning (per reason) that this model rejected `temperature` and the request was retried without it (BA-10). */
104
+ _warnTemperatureDropped(reason?: string): void;
96
105
  /**
97
106
  * BA-7: the provider-native blocks to replay at the FRONT of an assistant turn's content, or `[]`.
98
107
  *
@@ -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, assessThinking } = 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, tries the next known shape when a shape request fails with a 400/422 (not the temperature path), and the call ALWAYS RUNS: if every shape fails that way, ONE plain request without a thinking field decides — plain OK ⇒ `thinkingStatus:'unsupported'`, plain fails ⇒ that plain request's error is thrown as-is; other failures propagate after one request; a model measured as not switchable is sent plain directly. Only the winning shape is cached per instance+model ('unsupported' is never cached); warns once per status. Every reply is checked: `GenerateResult.thinkingStatus` is `'applied'`/`'unsupported'`/`'ignored'`/`'unknown'` (`'ignored'` = 'off' was accepted but the reply shows thinking anyway — warns once, result kept) and the legacy `thinkingApplied` is derived (`true`/`false`/`null`). 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 — just runs without it and reports `thinkingStatus:'unsupported'`, so the first member answers and the chain does not fall 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,9 @@ 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 });
162
+ /** @type {{ unsupported: boolean }} */
163
+ const thinkingTrack = { unsupported: false };
152
164
  if (tools.length > 0) {
153
165
  body.tools = tools.map(t => ({
154
166
  name: t.name,
@@ -163,10 +175,13 @@ class AnthropicProvider {
163
175
  const timeoutMs = resolveTimeoutMs(this.timeoutMs, options.timeoutMs);
164
176
  const deadlineMs = resolveTimeoutMs(this.deadlineMs, options.deadlineMs, 0, 'deadlineMs');
165
177
  const { data, temperatureDropped } = await requestWithTemperatureFallback({
166
- request: () => this._request(body, timeoutMs, deadlineMs),
178
+ request: () => thinkingMode
179
+ ? requestWithThinkingMode({ kind: 'anthropic', mode: thinkingMode, model: this.model, body, cache: this._thinkingShapes, track: thinkingTrack, request: (b) => this._request(b, timeoutMs, deadlineMs) })
180
+ : this._request(body, timeoutMs, deadlineMs),
167
181
  hadTemperature: () => body.temperature != null,
168
182
  stripTemperature: () => { delete body.temperature; },
169
- warnOnce: () => this._warnTemperatureDropped(),
183
+ thinkingOn: thinkingMode === 'on',
184
+ warnOnce: (reason) => this._warnTemperatureDropped(reason),
170
185
  });
171
186
 
172
187
  let text = '';
@@ -213,14 +228,18 @@ class AnthropicProvider {
213
228
  }
214
229
  : null,
215
230
  ...(temperatureDropped && { temperatureDropped: true }),
231
+ ...(thinkingMode && assessThinking({ kind: 'anthropic', mode: thinkingMode, data, unsupported: thinkingTrack.unsupported, owner: this, label: 'AnthropicProvider', model: this.model })),
216
232
  };
217
233
  }
218
234
 
219
- /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
220
- _warnTemperatureDropped() {
221
- if (this._warnedTempDropped) return;
222
- this._warnedTempDropped = true;
223
- console.warn(`[AnthropicProvider] '${this.model}' rejected a non-default 'temperature' (unsupported/deprecated) — retrying without it. Further drops from this provider instance are silent.`);
235
+ /** One-time warning (per reason) that this model rejected `temperature` and the request was retried without it (BA-10). */
236
+ _warnTemperatureDropped(reason = 'unsupported') {
237
+ const flag = reason === 'thinking' ? '_warnedTempThinking' : '_warnedTempDropped';
238
+ if (this[flag]) return;
239
+ this[flag] = true;
240
+ console.warn(reason === 'thinking'
241
+ ? `[AnthropicProvider] '${this.model}': 'temperature' must be 1 while thinking is on — retrying without it. Further drops from this provider instance are silent.`
242
+ : `[AnthropicProvider] '${this.model}' rejected a non-default 'temperature' (unsupported/deprecated) — retrying without it. Further drops from this provider instance are silent.`);
224
243
  }
225
244
 
226
245
  /**
@@ -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
+ * - Accepted ('on'|'off', else `ValidationError`) but CLIPipe has NO thinking switch: the call runs unchanged and the result carries `thinkingStatus:'unsupported'` (+ `thinkingApplied:false`; warns once per instance). Overridable per call; `null` suppresses an instance default.
24
+ */
25
+ thinkingMode?: any;
22
26
  /**
23
27
  * - Timeout in milliseconds.
24
28
  */
@@ -129,6 +133,7 @@ export class CLIPipeProvider {
129
133
  * const provider = new CLIPipeProvider({ command: 'claude' });
130
134
  */
131
135
  constructor(options?: CLIPipeOptions);
136
+ thinkingMode: "on" | "off" | null;
132
137
  command: string;
133
138
  args: string[];
134
139
  cwd: string | undefined;
@@ -172,7 +177,7 @@ export class CLIPipeProvider {
172
177
  * failure for a genuinely tool-incapable model lives in the tool-mode capability probe.
173
178
  * @param {Message[]} messages - Conversation messages in OpenAI format.
174
179
  * @param {ToolDef[]} [tools=[]] - Caller tools. Honored only in tool mode (`toolProtocol` set).
175
- * @param {Record<string, any>} [options={}] - Unused.
180
+ * @param {Record<string, any>} [options={}] - Unused, except `thinkingMode` (validated; CLIPipe has no thinking switch, so the call runs unchanged with `thinkingStatus:'unsupported'`).
176
181
  * @returns {Promise<GenerateResult>}
177
182
  * @throws {Error} `[CLIPipeProvider] failed to spawn "cmd": ...` — when the command cannot be found or executed.
178
183
  * @throws {Error} `[CLIPipeProvider] process exited with code N: ...` — on non-zero exit.
@@ -180,6 +185,14 @@ export class CLIPipeProvider {
180
185
  * @throws {Error} `[CLIPipeProvider] process produced no output` — when stdout is empty.
181
186
  */
182
187
  generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
188
+ /**
189
+ * The pre-thinkingMode generate body: routes to native / emulation / plain-text.
190
+ * @param {Message[]} messages
191
+ * @param {ToolDef[]} tools
192
+ * @param {Record<string, any>} options
193
+ * @returns {Promise<GenerateResult>}
194
+ */
195
+ _generateInner(messages: Message[], tools: ToolDef[], options: Record<string, any>): Promise<GenerateResult>;
183
196
  _warnedNoProtocol: boolean | undefined;
184
197
  /**
185
198
  * Tool mode (v0.32.0) — one turn of schema-validated tool emulation. Renders the Loop's
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { spawn } = require('child_process');
4
4
  const { ProviderError, HaltError } = require('./errors');
5
+ const { normalizeThinkingMode, resolveThinkingMode, warnThinkingStatus } = require('./thinking-mode');
5
6
  const { buildToolSystemPrompt, renderTranscript, resolveToolProtocol, mapClaudeMeta } = require('./provider-clipipe-tools');
6
7
  const { createBridge, resolveSessionError, runSession } = require('./provider-clipipe-mcp');
7
8
 
@@ -21,6 +22,7 @@ const { createBridge, resolveSessionError, runSession } = require('./provider-cl
21
22
  * @property {string[]} [args=[]] - Arguments to pass to the command.
22
23
  * @property {string} [cwd] - Working directory for the child process.
23
24
  * @property {Record<string, string>} [env] - Environment variables for the child process.
25
+ * @property {any} [thinkingMode] - Accepted ('on'|'off', else `ValidationError`) but CLIPipe has NO thinking switch: the call runs unchanged and the result carries `thinkingStatus:'unsupported'` (+ `thinkingApplied:false`; warns once per instance). Overridable per call; `null` suppresses an instance default.
24
26
  * @property {number} [timeout=30000] - Timeout in milliseconds.
25
27
  * @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
28
  * @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
@@ -119,6 +121,9 @@ class CLIPipeProvider {
119
121
  */
120
122
  constructor(options = {}) {
121
123
  if (!options.command) throw new Error('[CLIPipeProvider] requires command');
124
+ // thinkingMode has NO mechanism here: validated (a bad value is a caller mistake), then every call
125
+ // runs unchanged and reports thinkingStatus 'unsupported'.
126
+ this.thinkingMode = normalizeThinkingMode(options.thinkingMode, 'CLIPipeProvider');
122
127
  this.command = options.command;
123
128
  this.args = options.args || [];
124
129
  this.cwd = options.cwd || undefined;
@@ -177,7 +182,7 @@ class CLIPipeProvider {
177
182
  * failure for a genuinely tool-incapable model lives in the tool-mode capability probe.
178
183
  * @param {Message[]} messages - Conversation messages in OpenAI format.
179
184
  * @param {ToolDef[]} [tools=[]] - Caller tools. Honored only in tool mode (`toolProtocol` set).
180
- * @param {Record<string, any>} [options={}] - Unused.
185
+ * @param {Record<string, any>} [options={}] - Unused, except `thinkingMode` (validated; CLIPipe has no thinking switch, so the call runs unchanged with `thinkingStatus:'unsupported'`).
181
186
  * @returns {Promise<GenerateResult>}
182
187
  * @throws {Error} `[CLIPipeProvider] failed to spawn "cmd": ...` — when the command cannot be found or executed.
183
188
  * @throws {Error} `[CLIPipeProvider] process exited with code N: ...` — on non-zero exit.
@@ -185,6 +190,21 @@ class CLIPipeProvider {
185
190
  * @throws {Error} `[CLIPipeProvider] process produced no output` — when stdout is empty.
186
191
  */
187
192
  async generate(messages, tools = [], options = {}) {
193
+ const mode = resolveThinkingMode({ label: 'CLIPipeProvider', instanceMode: this.thinkingMode, options: options || {} });
194
+ const result = await this._generateInner(messages, tools, options);
195
+ if (!mode) return result;
196
+ warnThinkingStatus({ owner: this, label: 'CLIPipeProvider', model: result.model || this.command, mode, status: 'unsupported' });
197
+ return { ...result, thinkingStatus: 'unsupported', thinkingApplied: false };
198
+ }
199
+
200
+ /**
201
+ * The pre-thinkingMode generate body: routes to native / emulation / plain-text.
202
+ * @param {Message[]} messages
203
+ * @param {ToolDef[]} tools
204
+ * @param {Record<string, any>} options
205
+ * @returns {Promise<GenerateResult>}
206
+ */
207
+ async _generateInner(messages, tools, options) {
188
208
  if (Array.isArray(tools) && tools.length > 0) {
189
209
  if (this.nativeTools) return this._generateWithMcp(messages, tools, options);
190
210
  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, tries the next known shape when a shape request fails with a 400/422 (not the temperature path), and the call ALWAYS RUNS: if every shape fails that way, ONE plain request without a thinking field decides — plain OK ⇒ `thinkingStatus:'unsupported'`, plain fails ⇒ that plain request's error is thrown as-is; other failures propagate after one request; a model measured as not switchable is sent plain directly. Only the winning shape is cached per instance+model ('unsupported' is never cached); warns once per status. Every reply is checked: `GenerateResult.thinkingStatus` is `'applied'`/`'unsupported'`/`'ignored'`/`'unknown'` (`'ignored'` = 'off' was accepted but the reply shows thinking anyway — warns once, result kept) and the legacy `thinkingApplied` is derived (`true`/`false`/`null`). 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 — just runs without it and reports `thinkingStatus:'unsupported'`, so the first member answers and the chain does not fall 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, tries the next known shape when a shape request fails with a 400/422 (not the temperature path), and the call ALWAYS RUNS: if every shape fails that way, ONE plain request without a thinking field decides — plain OK ⇒ `thinkingStatus:'unsupported'`, plain fails ⇒ that plain request's error is thrown as-is; other failures propagate after one request; a model measured as not switchable is sent plain directly. Only the winning shape is cached per instance+model ('unsupported' is never cached); warns once per status. Every reply is checked: `GenerateResult.thinkingStatus` is `'applied'`/`'unsupported'`/`'ignored'`/`'unknown'` (`'ignored'` = 'off' was accepted but the reply shows thinking anyway — warns once, result kept) and the legacy `thinkingApplied` is derived (`true`/`false`/`null`). 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 — just runs without it and reports `thinkingStatus:'unsupported'`, so the first member answers and the chain does not fall 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,20 +64,22 @@ 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
  */
70
80
  generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
71
- /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
72
- _warnTemperatureDropped(): void;
73
- _warnedTempDropped: boolean | undefined;
81
+ /** One-time warning (per reason) that this model rejected `temperature` and the request was retried without it (BA-10). */
82
+ _warnTemperatureDropped(reason?: string): void;
74
83
  /**
75
84
  * Normalize Gemini `usageMetadata` to the neutral {@link Usage} shape. Like OpenAI, `promptTokenCount`
76
85
  * INCLUDES the cached tokens (`cachedContentTokenCount`), so subtract for the uncached remainder