bare-agent 0.51.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,11 +513,11 @@ 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.
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
517
 
518
518
  ```js
519
519
  const p = new AnthropicProvider({ apiKey, model: 'claude-sonnet-5', thinkingMode: 'off' });
520
- const r = await p.generate(msgs, [], {}); // r.thinkingApplied === true
520
+ const r = await p.generate(msgs, [], {}); // r.thinkingStatus === 'applied' (r.thinkingApplied === true)
521
521
  await p.generate(msgs, [], { thinkingMode: 'on' }); // per-call override
522
522
  ```
523
523
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
package/primitives.json CHANGED
@@ -447,7 +447,7 @@
447
447
  "category": "providers",
448
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
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 }",
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
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
452
  "example": "thinkingSupport('anthropic', 'claude-opus-5-5'); // { switchable: false, off: null, on: \"thinking:{type:'adaptive'}\", note: '...' }"
453
453
  },
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, thinkingApplied?: boolean|null}>}
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
@@ -208,6 +208,7 @@ export class Loop {
208
208
  malformedToolCall?: GenerateResult["malformedToolCall"];
209
209
  temperatureDropped?: boolean;
210
210
  thinkingApplied?: boolean | null;
211
+ thinkingStatus?: "applied" | "unsupported" | "ignored" | "unknown";
211
212
  }>;
212
213
  _warnedTruncated: boolean | undefined;
213
214
  /**
@@ -236,7 +237,7 @@ export class Loop {
236
237
  * @param {string} text - User message.
237
238
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
238
239
  * @param {Record<string, any>} [options={}] - Per-run overrides.
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}>}
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'}>}
240
241
  */
241
242
  chat(text: string, tools?: ToolDef[], options?: Record<string, any>): Promise<{
242
243
  text: string;
@@ -251,6 +252,7 @@ export class Loop {
251
252
  malformedToolCall?: GenerateResult["malformedToolCall"];
252
253
  temperatureDropped?: boolean;
253
254
  thinkingApplied?: boolean | null;
255
+ thinkingStatus?: "applied" | "unsupported" | "ignored" | "unknown";
254
256
  }>;
255
257
  /**
256
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, thinkingApplied?: boolean|null}>}
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,10 +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;
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;
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) };
572
580
  // BA-11: consecutive policy-deny counter (reset by any tool call that PASSES policy). When it reaches
573
581
  // this.maxConsecutiveDenials the run short-circuits cleanly — see the deny block below.
574
582
  let consecutiveDenials = 0;
@@ -601,7 +609,7 @@ class Loop {
601
609
  sealDanglingToolCalls(msgs, `[halted:${stuckTag}]`);
602
610
  this._reportError('stuck', new Error(`tool "${tc.name}" failed ${identicalErrors} times with identical arguments`), { rule: stuckTag, attempts: identicalErrors });
603
611
  this._safeEmit({ type: 'loop:done', data: { text: lastText, stuck: true, rule: stuckTag, cost: totalCost } });
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 }) };
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() };
605
613
  }
606
614
  return null;
607
615
  };
@@ -848,7 +856,7 @@ class Loop {
848
856
  this._reportError('provider', err, { round });
849
857
  if (this.throwOnError) throw err;
850
858
  // BA-5: a mid-run provider failure must not erase the work of the rounds that succeeded.
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 }) };
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() };
852
860
  }
853
861
 
854
862
  lastUsage = result.usage || lastUsage;
@@ -865,10 +873,12 @@ class Loop {
865
873
  lastMalformedToolCall = (result.malformedToolCall && typeof result.malformedToolCall === 'object')
866
874
  ? result.malformedToolCall : null;
867
875
  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;
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;
872
882
  }
873
883
  // Publish the latest measured usage to ctx (non-enumerable, fail-open) so a transcript-bound seam —
874
884
  // e.g. F2 stash auto-compaction — can read EXACT provider-counted `inputTokens` to gauge context
@@ -953,7 +963,7 @@ class Loop {
953
963
  sealDanglingToolCalls(msgs, `[halted:${session.error}]`);
954
964
  this._reportError('session', new Error(`provider session terminated: ${session.error}`), { rule: session.error, sessionTurns: session.turns ?? null });
955
965
  this._safeEmit({ type: 'loop:done', data: { text: lastText, rule: session.error, sessionTurns: session.turns ?? null, cost: totalCost } });
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 }) };
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() };
957
967
  }
958
968
 
959
969
  // BA-13: classify this round's terminal signal against the neutral stop-reason vocabulary. BA-6
@@ -1018,7 +1028,7 @@ class Loop {
1018
1028
  msgs.push({ role: 'assistant', content: result.text });
1019
1029
  }
1020
1030
  this._safeEmit({ type: 'loop:done', data: { text: lastText, ...(terminal === 'truncated' && { truncated: true }), terminal, stopReason: lastStopReason, droppedToolCalls: dropped, cost: totalCost } });
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 }) };
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() };
1022
1032
  }
1023
1033
 
1024
1034
  // No tool calls — LLM gave a final text response
@@ -1038,7 +1048,7 @@ class Loop {
1038
1048
  try { await flush(msgs, ctx); }
1039
1049
  catch (err) { if (err instanceof HaltError) throw err; this._reportError('trim-flush', err, { round }); }
1040
1050
  }
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 }) };
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() };
1042
1052
  }
1043
1053
 
1044
1054
  // Execute tool calls
@@ -1147,7 +1157,7 @@ class Loop {
1147
1157
  sealDanglingToolCalls(msgs, `[halted:${denyTag}]`);
1148
1158
  this._reportError('denied', new Error(`policy denied ${consecutiveDenials} consecutive tool calls (${tc.name})`), { rule: denyTag, denials: consecutiveDenials });
1149
1159
  this._safeEmit({ type: 'loop:done', data: { text: lastText, denied: true, rule: denyTag, cost: totalCost } });
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 }) };
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() };
1151
1161
  }
1152
1162
  continue;
1153
1163
  }
@@ -1221,7 +1231,7 @@ class Loop {
1221
1231
  // BA-5: the rule tag survives on `error`; so does the work. A halt is how a bounded attempt is
1222
1232
  // SUPPOSED to end — the caller reads `error` to know it was bounded and `text` to learn from it.
1223
1233
  this._safeEmit({ type: 'loop:done', data: { text: lastText, halted: true, rule, cost: totalCost } });
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 }) };
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() };
1225
1235
  }
1226
1236
  throw err;
1227
1237
  }
@@ -1251,20 +1261,20 @@ class Loop {
1251
1261
  const rule = err.rule || 'unknown';
1252
1262
  this._reportError('halt', err, { rule, reason: err.decision?.reason ?? null });
1253
1263
  this._safeEmit({ type: 'loop:done', data: { text: lastText, halted: true, rule, cost: totalCost } });
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 }) };
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() };
1255
1265
  }
1256
1266
  this._reportError('trim-flush', err, { phase: 'stop' });
1257
1267
  }
1258
1268
  }
1259
1269
  this._safeEmit({ type: 'loop:done', data: { text: lastText, stopped: true, cost: totalCost } });
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 }) };
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() };
1261
1271
  }
1262
1272
 
1263
1273
  // Hard safety limit — should never fire under normal usage; bareguard's
1264
1274
  // limits.maxTurns (or the LLM's natural completion) ends the loop first.
1265
1275
  const warning = `[Loop] hit internal safety limit of ${HARD_ROUND_LIMIT} rounds. Wire bareguard for proper governance — see bare-agent/bareguard.`;
1266
1276
  this._safeEmit({ type: 'loop:done', data: { text: lastText, warning, cost: totalCost } });
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 }) };
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() };
1268
1278
  }
1269
1279
 
1270
1280
  /**
@@ -1337,7 +1347,7 @@ class Loop {
1337
1347
  * @param {string} text - User message.
1338
1348
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
1339
1349
  * @param {Record<string, any>} [options={}] - Per-run overrides.
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}>}
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'}>}
1341
1351
  */
1342
1352
  async chat(text, tools = [], options = {}) {
1343
1353
  this._history.push({ role: 'user', content: text });
@@ -31,8 +31,8 @@ export type AnthropicOptions = {
31
31
  */
32
32
  thinking?: any;
33
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).
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
36
  */
37
37
  thinkingMode?: "on" | "off" | null | undefined;
38
38
  /**
@@ -63,8 +63,8 @@ export type AnthropicOptions = {
63
63
  * chain set `thinking` on each provider's CONSTRUCTOR instead of per-call.
64
64
  *
65
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).
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).
68
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).
69
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 })`.
70
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 })`.
@@ -100,9 +100,8 @@ export class AnthropicProvider {
100
100
  * @throws {Error} `[AnthropicProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
101
101
  */
102
102
  generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
103
- /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
104
- _warnTemperatureDropped(): void;
105
- _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;
106
105
  /**
107
106
  * BA-7: the provider-native blocks to replay at the FRONT of an assistant turn's content, or `[]`.
108
107
  *
@@ -7,7 +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
+ const { normalizeThinkingMode, resolveThinkingMode, requestWithThinkingMode, assessThinking } = require('./thinking-mode');
11
11
 
12
12
  // BA-24: the raw Anthropic usage field names. Presence of any (even value 0) means the API reported a
13
13
  // usage signal → build the object; absence of all means no signal → surface null (unpriceable).
@@ -35,8 +35,8 @@ function isLoopbackHost(hostname) {
35
35
  * chain set `thinking` on each provider's CONSTRUCTOR instead of per-call.
36
36
  *
37
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).
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).
40
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).
41
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 })`.
42
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 })`.
@@ -159,6 +159,8 @@ class AnthropicProvider {
159
159
  const thinking = options.thinking !== undefined ? options.thinking : this.thinking;
160
160
  if (thinking) body.thinking = thinking;
161
161
  const thinkingMode = resolveThinkingMode({ label: 'AnthropicProvider', instanceMode: this.thinkingMode, options, instanceThinking: this.thinking, hasRawThinking: true });
162
+ /** @type {{ unsupported: boolean }} */
163
+ const thinkingTrack = { unsupported: false };
162
164
  if (tools.length > 0) {
163
165
  body.tools = tools.map(t => ({
164
166
  name: t.name,
@@ -174,12 +176,12 @@ class AnthropicProvider {
174
176
  const deadlineMs = resolveTimeoutMs(this.deadlineMs, options.deadlineMs, 0, 'deadlineMs');
175
177
  const { data, temperatureDropped } = await requestWithTemperatureFallback({
176
178
  request: () => thinkingMode
177
- ? requestWithThinkingMode({ kind: 'anthropic', mode: thinkingMode, model: this.model, label: 'AnthropicProvider', body, cache: this._thinkingShapes, request: (b) => this._request(b, timeoutMs, deadlineMs) })
179
+ ? requestWithThinkingMode({ kind: 'anthropic', mode: thinkingMode, model: this.model, body, cache: this._thinkingShapes, track: thinkingTrack, request: (b) => this._request(b, timeoutMs, deadlineMs) })
178
180
  : this._request(body, timeoutMs, deadlineMs),
179
181
  hadTemperature: () => body.temperature != null,
180
182
  stripTemperature: () => { delete body.temperature; },
181
183
  thinkingOn: thinkingMode === 'on',
182
- warnOnce: () => this._warnTemperatureDropped(),
184
+ warnOnce: (reason) => this._warnTemperatureDropped(reason),
183
185
  });
184
186
 
185
187
  let text = '';
@@ -226,15 +228,18 @@ class AnthropicProvider {
226
228
  }
227
229
  : null,
228
230
  ...(temperatureDropped && { temperatureDropped: true }),
229
- ...(thinkingMode && { thinkingApplied: assessThinkingApplied({ kind: 'anthropic', mode: thinkingMode, data, owner: this, label: 'AnthropicProvider', model: this.model }) }),
231
+ ...(thinkingMode && assessThinking({ kind: 'anthropic', mode: thinkingMode, data, unsupported: thinkingTrack.unsupported, owner: this, label: 'AnthropicProvider', model: this.model })),
230
232
  };
231
233
  }
232
234
 
233
- /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
234
- _warnTemperatureDropped() {
235
- if (this._warnedTempDropped) return;
236
- this._warnedTempDropped = true;
237
- 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.`);
238
243
  }
239
244
 
240
245
  /**
@@ -20,7 +20,7 @@ export type CLIPipeOptions = {
20
20
  */
21
21
  env?: Record<string, string> | undefined;
22
22
  /**
23
- * - NOT supported: any non-null value throws `ValidationError` (CLIPipe has no thinking switch). Declared so the loud failure is typed, not silent.
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
24
  */
25
25
  thinkingMode?: any;
26
26
  /**
@@ -133,6 +133,7 @@ export class CLIPipeProvider {
133
133
  * const provider = new CLIPipeProvider({ command: 'claude' });
134
134
  */
135
135
  constructor(options?: CLIPipeOptions);
136
+ thinkingMode: "on" | "off" | null;
136
137
  command: string;
137
138
  args: string[];
138
139
  cwd: string | undefined;
@@ -176,7 +177,7 @@ export class CLIPipeProvider {
176
177
  * failure for a genuinely tool-incapable model lives in the tool-mode capability probe.
177
178
  * @param {Message[]} messages - Conversation messages in OpenAI format.
178
179
  * @param {ToolDef[]} [tools=[]] - Caller tools. Honored only in tool mode (`toolProtocol` set).
179
- * @param {Record<string, any>} [options={}] - Unused, except `thinkingMode` (any non-null value throws `ValidationError`: CLIPipe has no thinking switch).
180
+ * @param {Record<string, any>} [options={}] - Unused, except `thinkingMode` (validated; CLIPipe has no thinking switch, so the call runs unchanged with `thinkingStatus:'unsupported'`).
180
181
  * @returns {Promise<GenerateResult>}
181
182
  * @throws {Error} `[CLIPipeProvider] failed to spawn "cmd": ...` — when the command cannot be found or executed.
182
183
  * @throws {Error} `[CLIPipeProvider] process exited with code N: ...` — on non-zero exit.
@@ -184,6 +185,14 @@ export class CLIPipeProvider {
184
185
  * @throws {Error} `[CLIPipeProvider] process produced no output` — when stdout is empty.
185
186
  */
186
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>;
187
196
  _warnedNoProtocol: boolean | undefined;
188
197
  /**
189
198
  * Tool mode (v0.32.0) — one turn of schema-validated tool emulation. Renders the Loop's
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const { spawn } = require('child_process');
4
- const { ProviderError, HaltError, ValidationError } = require('./errors');
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,7 +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.
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.
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.
25
26
  * @property {number} [timeout=30000] - Timeout in milliseconds.
26
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.
27
28
  * @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
@@ -108,10 +109,6 @@ function subtractUsage(total, streamed) {
108
109
  return out;
109
110
  }
110
111
 
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
-
115
112
  class CLIPipeProvider {
116
113
  /**
117
114
  * Provider that pipes prompts to a CLI command via stdin and reads stdout.
@@ -124,8 +121,9 @@ class CLIPipeProvider {
124
121
  */
125
122
  constructor(options = {}) {
126
123
  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();
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');
129
127
  this.command = options.command;
130
128
  this.args = options.args || [];
131
129
  this.cwd = options.cwd || undefined;
@@ -184,7 +182,7 @@ class CLIPipeProvider {
184
182
  * failure for a genuinely tool-incapable model lives in the tool-mode capability probe.
185
183
  * @param {Message[]} messages - Conversation messages in OpenAI format.
186
184
  * @param {ToolDef[]} [tools=[]] - Caller tools. Honored only in tool mode (`toolProtocol` set).
187
- * @param {Record<string, any>} [options={}] - Unused, except `thinkingMode` (any non-null value throws `ValidationError`: CLIPipe has no thinking switch).
185
+ * @param {Record<string, any>} [options={}] - Unused, except `thinkingMode` (validated; CLIPipe has no thinking switch, so the call runs unchanged with `thinkingStatus:'unsupported'`).
188
186
  * @returns {Promise<GenerateResult>}
189
187
  * @throws {Error} `[CLIPipeProvider] failed to spawn "cmd": ...` — when the command cannot be found or executed.
190
188
  * @throws {Error} `[CLIPipeProvider] process exited with code N: ...` — on non-zero exit.
@@ -192,7 +190,21 @@ class CLIPipeProvider {
192
190
  * @throws {Error} `[CLIPipeProvider] process produced no output` — when stdout is empty.
193
191
  */
194
192
  async generate(messages, tools = [], options = {}) {
195
- if (options && options.thinkingMode != null) throw CLIPIPE_NO_THINKING_MODE();
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) {
196
208
  if (Array.isArray(tools) && tools.length > 0) {
197
209
  if (this.nativeTools) return this._generateWithMcp(messages, tools, options);
198
210
  if (this.toolProtocol) return this._generateWithTools(messages, tools);
@@ -16,8 +16,8 @@ export type GeminiOptions = {
16
16
  */
17
17
  baseUrl?: string | undefined;
18
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).
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
21
  */
22
22
  thinkingMode?: "on" | "off" | null | undefined;
23
23
  /**
@@ -38,8 +38,8 @@ export type GeminiOptions = {
38
38
  * @property {string} [apiKey] - Google AI Studio (Gemini) API key.
39
39
  * @property {string} [model='gemini-2.5-flash'] - Model ID.
40
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).
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).
43
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).
44
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 })`.
45
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 })`.
@@ -78,9 +78,8 @@ export class GeminiProvider {
78
78
  * @throws {Error} `[GeminiProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
79
79
  */
80
80
  generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
81
- /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
82
- _warnTemperatureDropped(): void;
83
- _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;
84
83
  /**
85
84
  * Normalize Gemini `usageMetadata` to the neutral {@link Usage} shape. Like OpenAI, `promptTokenCount`
86
85
  * INCLUDES the cached tokens (`cachedContentTokenCount`), so subtract for the uncached remainder