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