bare-agent 0.49.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bareagent.context.md +10 -0
- package/package.json +1 -1
- package/primitives.json +9 -0
- package/src/loop.d.ts +6 -14
- 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 +15 -5
- package/src/provider-ollama.js +15 -4
- package/src/provider-openai.d.ts +19 -9
- package/src/provider-openai.js +23 -7
- package/src/provider-temperature.d.ts +9 -1
- package/src/provider-temperature.js +20 -3
- package/src/provider-toolcalls.d.ts +7 -6
- package/src/provider-toolcalls.js +8 -13
- 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 +16 -4
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.
|
|
@@ -976,6 +984,8 @@ All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model?, cos
|
|
|
976
984
|
|
|
977
985
|
**Error body (v0.11.0):** on an HTTP error the OpenAI/Anthropic/Ollama providers throw a `ProviderError` whose `message` carries the upstream error string. The full parsed response is **not** attached to `err.body` by default (so an unexpected field can't leak through logs that dump the error object). Pass `{ exposeErrorBody: true }` to attach it for debugging.
|
|
978
986
|
|
|
987
|
+
**Malformed tool-call arguments (BA-27):** when an OpenAI-compatible or Ollama model emits `function.arguments` that is not valid JSON, the provider returns `toolCalls: []` plus a `malformedToolCall: { name, error }` marker (round still metered; JSON never repaired); `Loop.run()` surfaces the same marker on its result. Pass `{ exposeMalformedArgs: true }` (OpenAI/Ollama; default off, independent of `exposeErrorBody`) to also get `malformedToolCall.rawArguments` — the FULL raw arguments string, verbatim and uncapped (since 0.50.0; earlier it was the first 500 chars plus `rawTruncated`, which hid the broken tail). Model-generated and possibly large or sensitive: bare-agent only returns it to you and never logs it, but anything you forward it to (logs, audit rows) is your call. `rawTruncated` is deprecated and never set.
|
|
988
|
+
|
|
979
989
|
**Request/idle timeout (BA-18, v0.34.0):** the four http(s) providers (Anthropic, OpenAI, Gemini, Ollama) accept a `timeoutMs` option — constructor default **600000 (10 min)**, overridable per call via `generate(..., { timeoutMs })`, and `0`/`Infinity` disables it. Before this they wired only `req.on('error')`, so a socket the server silently dropped — or a response that never starts — hung `generate()` until the OS TCP timeout (~2h): a hang, not an error, so retry/casualty policy above it never fired. `timeoutMs` bounds on socket **inactivity** (`req.setTimeout`), so a slow-but-streaming response is not killed — only a silent/never-answering socket trips it; the 10-min default clears any single non-streaming completion (TTFB ≈ generation time). On trip, `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`, `retryable: true`). **Retry is caller-side and already wired:** `new Loop({ provider, retry: new Retry() })` wraps `provider.generate`, and `DEFAULT_RETRY_ON` classifies `ETIMEDOUT` (and `ECONNRESET`/`ENOTFOUND`/429/5xx) as transient — so a wired `Retry` retries a timed-out request and rethrows under `retryOn: () => false`, with no extra wiring (`run-plan`'s `stepRetry` is a second consumer of the same seam). CLIPipe already bounded its child process (`timeout`, default 30000 for one-shot) and is unchanged.
|
|
980
990
|
|
|
981
991
|
**Total call-duration deadline (BA-19, v0.35.0):** `timeoutMs` bounds socket *inactivity*, and `req.setTimeout` resets on any activity by design — so a "zombie stream" that trickles a byte forever (bytes arriving, the response never completing) never trips it and hangs the caller for hours (an adopter saw one `generate()` run **274 min** and end in `ECONNRESET`, not a `TimeoutError`: the reset proves bytes *were* flowing, so the idle timer never fired). The four http(s) providers now also accept a `deadlineMs` option — an absolute, **non-resetting** wall-clock ceiling on the whole request. **Disabled by default** (a deliberately long single call — large `maxTokens`, slow model — is legitimate; a default here would kill it), overridable per call via `generate(..., { deadlineMs })`, `0`/`Infinity` disable. An *unset* deadline resolves to disabled, but an *explicitly-set* garbage value (`NaN`, a non-numeric string) throws a `ValidationError` at resolve time rather than silently disabling the bound and running unbounded — unlike `timeoutMs`, the deadline has no safe default to fall back to, so a config mistake must surface loudly. On trip, `generate()` rejects with a **terminal** `TimeoutError` distinguishable from the idle trip: `code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false` — a deadline is a hard ceiling meant to STOP, so it is *not* auto-retried (retrying would re-spend up to another full `deadlineMs`); a consumer that wants retry opts in via `retryOn`. When both are armed and `timeoutMs < deadlineMs`, a silent socket trips the idle bound first; only a still-active-but-never-completing stream reaches the deadline. The idle bound (BA-18) and the deadline (BA-19) are two independent failure modes — a silent socket vs a zombie stream.
|
package/package.json
CHANGED
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?:
|
|
170
|
+
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: GenerateResult['malformedToolCall'], temperatureDropped?: boolean, thinkingApplied?: boolean|null}>}
|
|
171
171
|
* On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
|
|
172
172
|
* thrown HaltError carried no `rule`), and `msgs` is sanitized so any
|
|
173
173
|
* dangling assistant `tool_calls` from the halted round are paired with
|
|
@@ -205,13 +205,9 @@ export class Loop {
|
|
|
205
205
|
model: string | null;
|
|
206
206
|
msgs: Message[];
|
|
207
207
|
metrics: RunMetrics;
|
|
208
|
-
malformedToolCall?:
|
|
209
|
-
name: string | undefined;
|
|
210
|
-
error: string;
|
|
211
|
-
rawArguments?: string;
|
|
212
|
-
rawTruncated?: true;
|
|
213
|
-
};
|
|
208
|
+
malformedToolCall?: GenerateResult["malformedToolCall"];
|
|
214
209
|
temperatureDropped?: boolean;
|
|
210
|
+
thinkingApplied?: boolean | null;
|
|
215
211
|
}>;
|
|
216
212
|
_warnedTruncated: boolean | undefined;
|
|
217
213
|
/**
|
|
@@ -240,7 +236,7 @@ export class Loop {
|
|
|
240
236
|
* @param {string} text - User message.
|
|
241
237
|
* @param {ToolDef[]} [tools=[]] - Tool definitions.
|
|
242
238
|
* @param {Record<string, any>} [options={}] - Per-run overrides.
|
|
243
|
-
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?:
|
|
239
|
+
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: GenerateResult['malformedToolCall'], temperatureDropped?: boolean, thinkingApplied?: boolean|null}>}
|
|
244
240
|
*/
|
|
245
241
|
chat(text: string, tools?: ToolDef[], options?: Record<string, any>): Promise<{
|
|
246
242
|
text: string;
|
|
@@ -252,13 +248,9 @@ export class Loop {
|
|
|
252
248
|
model: string | null;
|
|
253
249
|
msgs: Message[];
|
|
254
250
|
metrics: RunMetrics;
|
|
255
|
-
malformedToolCall?:
|
|
256
|
-
name: string | undefined;
|
|
257
|
-
error: string;
|
|
258
|
-
rawArguments?: string;
|
|
259
|
-
rawTruncated?: true;
|
|
260
|
-
};
|
|
251
|
+
malformedToolCall?: GenerateResult["malformedToolCall"];
|
|
261
252
|
temperatureDropped?: boolean;
|
|
253
|
+
thinkingApplied?: boolean | null;
|
|
262
254
|
}>;
|
|
263
255
|
/**
|
|
264
256
|
* Request a clean stop. The current tool call finishes; the loop then exits at the next boundary and
|
package/src/loop.js
CHANGED
|
@@ -455,7 +455,7 @@ class Loop {
|
|
|
455
455
|
* thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
|
|
456
456
|
* unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
|
|
457
457
|
* @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
|
|
458
|
-
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?:
|
|
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?:
|
|
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
|
|