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.
@@ -0,0 +1,384 @@
1
+ 'use strict';
2
+
3
+ // Normalized `thinkingMode: 'on' | 'off'` (hamr-approved design: try the known shapes, fail LOUDLY,
4
+ // check every reply). Callers say "on"/"off"; this module owns the per-provider/per-model request
5
+ // field, the retry ladder, the measured per-model facts, and the post-reply signal readers. The
6
+ // provider files stay thin: they ask here for the patch and the reader.
7
+ //
8
+ // Every fact in MODELS below was MEASURED live (poc/thinking-mode-verify.mjs →
9
+ // poc/results/thinking-mode-verify.json, 2026-10-11) EXCEPT Ollama, which is shape-known but not
10
+ // live-verified. A model that is not in the table is 'unverified': the ladder tries the shapes at
11
+ // runtime and fails loudly if every one is rejected.
12
+ //
13
+ // The raw `thinking` passthrough options (BA-7) are a DIFFERENT, untouched field: opaque and verbatim.
14
+ // `thinkingMode` is the normalized switch; setting both at once is a ValidationError (no silent precedence).
15
+
16
+ const { ValidationError } = require('./errors');
17
+
18
+ /** @typedef {'anthropic'|'openai'|'deepseek'|'gemini'|'ollama'} LadderKind */
19
+
20
+ /**
21
+ * @typedef {object} Shape
22
+ * @property {string} key - Short stable key (the MODELS table refers to shapes by this).
23
+ * @property {string} id - Human-readable request-field description (error messages, thinkingSupport).
24
+ * @property {(body: Record<string, any>) => (Record<string, any>|null)} apply - Returns a NEW body with
25
+ * the field set (copy-on-write; never mutates `body`), or `null` when the shape cannot apply to this body.
26
+ */
27
+
28
+ /** @type {(key: string, id: string, apply: Shape['apply']) => Shape} */
29
+ const shape = (key, id, apply) => ({ key, id, apply });
30
+
31
+ // Anthropic `enabled` needs budget_tokens >= 1024 and max_tokens > budget_tokens (measured: haiku-4-5).
32
+ const ANTHROPIC_BUDGET = 2048;
33
+
34
+ /** @type {Record<LadderKind, { off: Shape[], on: Shape[] }>} */
35
+ const LADDERS = {
36
+ anthropic: {
37
+ // sonnet-5 STILL thinks when the field is omitted, so off must be sent explicitly.
38
+ off: [shape('disabled', "thinking:{type:'disabled'}", (b) => ({ ...b, thinking: { type: 'disabled' } }))],
39
+ on: [
40
+ shape('adaptive', "thinking:{type:'adaptive'}", (b) => ({ ...b, thinking: { type: 'adaptive' } })),
41
+ shape('enabled', "thinking:{type:'enabled',budget_tokens:N}", (b) => {
42
+ const budget = Math.min(ANTHROPIC_BUDGET, (b.max_tokens || 0) - 1);
43
+ if (!(budget >= 1024)) return null; // max_tokens too small to leave room for a legal budget
44
+ return { ...b, thinking: { type: 'enabled', budget_tokens: budget } };
45
+ }),
46
+ ],
47
+ },
48
+ openai: {
49
+ // gpt-5.1+ accept 'none' and 400 on 'minimal'; gpt-5 / gpt-5-mini are the reverse.
50
+ off: [
51
+ shape('none', "reasoning_effort:'none'", (b) => ({ ...b, reasoning_effort: 'none' })),
52
+ shape('minimal', "reasoning_effort:'minimal'", (b) => ({ ...b, reasoning_effort: 'minimal' })),
53
+ ],
54
+ on: [shape('medium', "reasoning_effort:'medium'", (b) => ({ ...b, reasoning_effort: 'medium' }))],
55
+ },
56
+ deepseek: {
57
+ off: [shape('disabled', "thinking:{type:'disabled'}", (b) => ({ ...b, thinking: { type: 'disabled' } }))],
58
+ // DeepSeek thinks by default: "on" is the ABSENCE of a field (the body goes out unchanged).
59
+ on: [shape('omit', '(field omitted — thinks by default)', (b) => ({ ...b }))],
60
+ },
61
+ gemini: {
62
+ off: [shape('budget0', 'generationConfig.thinkingConfig.thinkingBudget:0',
63
+ (b) => ({ ...b, generationConfig: { ...b.generationConfig, thinkingConfig: { thinkingBudget: 0 } } }))],
64
+ on: [shape('budget-1', 'generationConfig.thinkingConfig.thinkingBudget:-1',
65
+ (b) => ({ ...b, generationConfig: { ...b.generationConfig, thinkingConfig: { thinkingBudget: -1 } } }))],
66
+ },
67
+ ollama: {
68
+ // NOT live-verified (no Ollama thinking model was available to measure).
69
+ off: [shape('think-false', 'think:false', (b) => ({ ...b, think: false }))],
70
+ on: [shape('think-true', 'think:true', (b) => ({ ...b, think: true }))],
71
+ },
72
+ };
73
+
74
+ /**
75
+ * MEASURED per-model facts. Value `null` ⇒ the mode is known NOT switchable on that model (throws
76
+ * before any request is sent). A string ⇒ the first shape that worked (tried first).
77
+ * @type {Record<LadderKind, Record<string, { off: string|null, on: string|null }>>}
78
+ */
79
+ const MODELS = {
80
+ anthropic: {
81
+ 'claude-sonnet-5': { off: 'disabled', on: 'adaptive' },
82
+ 'claude-haiku-4-5': { off: 'disabled', on: 'enabled' }, // 400s on adaptive
83
+ 'claude-opus-5-5': { off: null, on: 'adaptive' }, // 400s on disabled → cannot be turned off
84
+ },
85
+ openai: {
86
+ 'gpt-5': { off: 'minimal', on: 'medium' },
87
+ 'gpt-5-mini': { off: 'minimal', on: 'medium' },
88
+ 'gpt-5.1': { off: 'none', on: 'medium' },
89
+ 'gpt-5.2': { off: 'none', on: 'medium' },
90
+ 'gpt-5.4': { off: 'none', on: 'medium' },
91
+ 'gpt-5.4-mini': { off: 'none', on: 'medium' },
92
+ 'gpt-5.4-nano': { off: 'none', on: 'medium' },
93
+ 'gpt-5.5': { off: 'none', on: 'medium' },
94
+ },
95
+ deepseek: {
96
+ 'deepseek-flash': { off: 'disabled', on: 'omit' },
97
+ 'deepseek-v4-pro': { off: 'disabled', on: 'omit' },
98
+ },
99
+ gemini: {
100
+ 'gemini-2.5-flash': { off: 'budget0', on: 'budget-1' },
101
+ 'gemini-3-flash-preview': { off: 'budget0', on: 'budget-1' },
102
+ 'gemini-3.1-flash-lite': { off: 'budget0', on: 'budget-1' },
103
+ 'gemini-3.1-flash-lite-preview': { off: 'budget0', on: 'budget-1' },
104
+ 'gemini-3.5-flash': { off: 'budget0', on: 'budget-1' },
105
+ 'gemini-3.1-pro-preview': { off: null, on: 'budget-1' }, // 400 "Budget 0 is invalid"
106
+ 'gemini-3.1-pro-preview-customtools': { off: null, on: 'budget-1' }, // same
107
+ },
108
+ ollama: {},
109
+ };
110
+
111
+ // Which 400s are "this shape was rejected" (try the next one) vs an ordinary error (surface it). The
112
+ // error text must NAME the field being set; per-provider so an unrelated 400 that merely happens to
113
+ // say "thinking" elsewhere in a different vendor's vocabulary can't trigger a retry. No `.*`, no
114
+ // unbounded quantifiers (ReDoS-safe on provider-supplied text).
115
+ /** @type {Record<LadderKind, RegExp>} */
116
+ const FIELD_NAMED = {
117
+ anthropic: /\bthinking\b/i,
118
+ openai: /\b(reasoning_effort|reasoning)\b/i,
119
+ deepseek: /\bthinking\b/i,
120
+ gemini: /\b(thinkingBudget|thinkingConfig|thinking_budget|thinking_config|thinking|budget)\b/i,
121
+ ollama: /\b(thinking|think)\b/i,
122
+ };
123
+ // A 400 that names `temperature` is the temperature fallback's business (e.g. Anthropic's "temperature
124
+ // may only be set to 1 when thinking is enabled" names BOTH) — never a shape rejection.
125
+ const TEMPERATURE_NAMED = /temperature/i;
126
+
127
+ /**
128
+ * Longest-prefix lookup of a model's measured facts. A dated/`-latest` suffix of a listed id counts
129
+ * (`claude-haiku-4-5-20251001`); any other suffix does NOT (so `gpt-5-chat` never inherits `gpt-5`).
130
+ * @param {LadderKind} kind
131
+ * @param {string} model
132
+ * @returns {{ off: string|null, on: string|null }|null}
133
+ */
134
+ function lookupModel(kind, model) {
135
+ const table = MODELS[kind];
136
+ if (typeof model !== 'string') return null;
137
+ let best = null;
138
+ for (const id of Object.keys(table)) {
139
+ if (model === id || (model.startsWith(id) && /^-(\d{8}|\d{4}-\d{2}-\d{2}|latest)$/.test(model.slice(id.length)))) {
140
+ if (best === null || id.length > best.length) best = id;
141
+ }
142
+ }
143
+ return best === null ? null : table[best];
144
+ }
145
+
146
+ /**
147
+ * @param {any} model
148
+ * @param {any} baseUrl
149
+ * @returns {boolean}
150
+ */
151
+ function isDeepSeek(model, baseUrl) {
152
+ return (typeof model === 'string' && /^deepseek-/i.test(model)) ||
153
+ (typeof baseUrl === 'string' && /deepseek\.com/i.test(baseUrl));
154
+ }
155
+
156
+ /**
157
+ * @param {string} message
158
+ * @param {Record<string, any>} [ctx]
159
+ */
160
+ const invalid = (message, ctx = {}) => new ValidationError(message, { context: { lib: 'bare-agent', ...ctx } });
161
+
162
+ /**
163
+ * Validate a `thinkingMode` value. `undefined`/`null` ⇒ `null` (unset); anything but 'on'/'off' throws.
164
+ * @param {any} value
165
+ * @param {string} label - provider name for the message
166
+ * @returns {'on'|'off'|null}
167
+ */
168
+ function normalizeThinkingMode(value, label) {
169
+ if (value === undefined || value === null) return null;
170
+ if (value === 'on' || value === 'off') return value;
171
+ throw invalid(`[${label}] thinkingMode must be 'on' or 'off' (got ${typeof value === 'string' ? JSON.stringify(value) : `<${typeof value}>`})`, { provider: label });
172
+ }
173
+
174
+ /**
175
+ * Resolve the EFFECTIVE mode for one call and reject the raw-`thinking` conflict (no silent precedence).
176
+ * Per-call overrides the instance default; `null` suppresses it.
177
+ * @param {object} p
178
+ * @param {string} p.label
179
+ * @param {any} p.instanceMode
180
+ * @param {Record<string, any>} p.options - per-call options
181
+ * @param {any} [p.instanceThinking] - the instance's raw `thinking` (providers that have one)
182
+ * @param {boolean} [p.hasRawThinking] - whether this provider has a raw `thinking` option at all
183
+ * @returns {'on'|'off'|null}
184
+ */
185
+ function resolveThinkingMode({ label, instanceMode, options, instanceThinking = null, hasRawThinking = false }) {
186
+ const mode = normalizeThinkingMode(options.thinkingMode !== undefined ? options.thinkingMode : instanceMode, label);
187
+ if (mode && hasRawThinking) {
188
+ const raw = options.thinking !== undefined ? options.thinking : instanceThinking;
189
+ if (raw) {
190
+ throw invalid(`[${label}] both the raw 'thinking' option and thinkingMode '${mode}' are set — they target the same request field. Use one (thinkingMode is the normalized switch; raw 'thinking' is the verbatim passthrough).`, { provider: label });
191
+ }
192
+ }
193
+ return mode;
194
+ }
195
+
196
+ /**
197
+ * @param {LadderKind} kind
198
+ * @param {any} err
199
+ * @returns {boolean}
200
+ */
201
+ function isShapeRejection(kind, err) {
202
+ const msg = err && typeof err.message === 'string' ? err.message : '';
203
+ return !!err && err.status === 400 && FIELD_NAMED[kind].test(msg) && !TEMPERATURE_NAMED.test(msg);
204
+ }
205
+
206
+ /**
207
+ * Send a request with the `thinkingMode` field applied, walking the shape ladder on a 400 that names
208
+ * the field. Order: the instance cache (a shape that already worked for this mode+model), else the
209
+ * table's known-good shape, else the ladder's first; then the remaining shapes in ladder order. A
210
+ * known-not-switchable model throws BEFORE any request. Every shape rejected ⇒ a loud ValidationError.
211
+ * A 400 that does not name the field (or any non-400) is an ordinary error and propagates untouched.
212
+ *
213
+ * @param {object} p
214
+ * @param {LadderKind} p.kind
215
+ * @param {'on'|'off'} p.mode
216
+ * @param {string} p.model
217
+ * @param {string} p.label - provider class name
218
+ * @param {Record<string, any>} p.body - the base body (NOT mutated; each attempt gets a patched copy)
219
+ * @param {(body: Record<string, any>) => Promise<any>} p.request
220
+ * @param {Map<string, string>} p.cache - per provider instance
221
+ * @returns {Promise<any>} the reply data of the accepted shape
222
+ */
223
+ async function requestWithThinkingMode({ kind, mode, model, label, body, request, cache }) {
224
+ const ladder = LADDERS[kind][mode];
225
+ const facts = lookupModel(kind, model);
226
+ if (facts && facts[mode] === null) {
227
+ throw invalid(`[${label}] thinkingMode '${mode}' is not supported by ${kind} model ${model} (measured: the API rejects every ${mode} shape for it) — no request was sent.`,
228
+ { provider: kind, model, mode, tried: [], sent: false });
229
+ }
230
+ const cacheKey = `${mode}|${model}`;
231
+ const preferredKey = cache.get(cacheKey) || (facts && facts[mode]) || null;
232
+ const ordered = [...ladder].sort((a, b) => (a.key === preferredKey ? -1 : b.key === preferredKey ? 1 : 0));
233
+
234
+ /** @type {string[]} */
235
+ const tried = [];
236
+ let lastMessage = '';
237
+ for (const s of ordered) {
238
+ const patched = s.apply(body);
239
+ if (patched === null) { tried.push(`${s.id} (not applicable to this request)`); continue; }
240
+ try {
241
+ const data = await request(patched);
242
+ cache.set(cacheKey, s.key);
243
+ return data;
244
+ } catch (err) {
245
+ if (!isShapeRejection(kind, err)) throw err;
246
+ tried.push(s.id);
247
+ lastMessage = err.message;
248
+ }
249
+ }
250
+ throw invalid(`[${label}] thinkingMode '${mode}' is not supported by ${kind} model ${model} (tried: ${tried.join('; ')})` +
251
+ (lastMessage ? ` — last API error: ${lastMessage}` : ''),
252
+ { provider: kind, model, mode, tried, sent: tried.length > 0 });
253
+ }
254
+
255
+ /** @param {any} v @returns {boolean} */
256
+ const positive = (v) => typeof v === 'number' && v > 0;
257
+
258
+ /**
259
+ * Read the thinking signal off a raw reply: 'thinking' | 'none' | 'unknown'. "Absent field ⇒ none" is
260
+ * used ONLY where measured: Gemini thoughtsTokenCount (given usageMetadata is present), DeepSeek
261
+ * reasoning_content, Ollama message.thinking. Anthropic counts thinking/redacted_thinking blocks.
262
+ * OpenAI reasoning_tokens absent ⇒ 'unknown'.
263
+ * @param {LadderKind} kind
264
+ * @param {any} data - raw provider reply
265
+ * @returns {'thinking'|'none'|'unknown'}
266
+ */
267
+ function readThinkingSignal(kind, data) {
268
+ if (!data || typeof data !== 'object') return 'unknown';
269
+ switch (kind) {
270
+ case 'anthropic': {
271
+ if (!Array.isArray(data.content)) return 'unknown';
272
+ return data.content.some((/** @type {any} */ b) => b && (b.type === 'thinking' || b.type === 'redacted_thinking')) ? 'thinking' : 'none';
273
+ }
274
+ case 'openai': {
275
+ const t = data.usage && data.usage.completion_tokens_details && data.usage.completion_tokens_details.reasoning_tokens;
276
+ if (typeof t !== 'number') return 'unknown';
277
+ return t > 0 ? 'thinking' : 'none';
278
+ }
279
+ case 'deepseek': {
280
+ const m = data.choices && data.choices[0] && data.choices[0].message;
281
+ if (!m || typeof m !== 'object') return 'unknown';
282
+ return typeof m.reasoning_content === 'string' && m.reasoning_content.length > 0 ? 'thinking' : 'none';
283
+ }
284
+ case 'gemini': {
285
+ const u = data.usageMetadata;
286
+ if (!u || typeof u !== 'object') return 'unknown';
287
+ const parts = (data.candidates && data.candidates[0] && data.candidates[0].content && data.candidates[0].content.parts) || [];
288
+ if (positive(u.thoughtsTokenCount) || (Array.isArray(parts) && parts.some((/** @type {any} */ p) => p && p.thought === true))) return 'thinking';
289
+ return 'none';
290
+ }
291
+ case 'ollama': {
292
+ const m = data.message;
293
+ if (!m || typeof m !== 'object') return 'unknown';
294
+ return typeof m.thinking === 'string' && m.thinking.length > 0 ? 'thinking' : 'none';
295
+ }
296
+ default:
297
+ return 'unknown';
298
+ }
299
+ }
300
+
301
+ /**
302
+ * The `thinkingApplied` verdict for a reply whose request was accepted. 'on' ⇒ `true` (an adaptive
303
+ * model may legitimately choose not to think, so 'on' is never flagged). 'off' ⇒ `true` when the reply
304
+ * shows zero thinking, `false` when thinking happened anyway, `null` when the signal is unreadable.
305
+ * On `false` it warns ONCE per `owner` (the provider instance) and never throws — the round is paid.
306
+ * @param {object} p
307
+ * @param {LadderKind} p.kind
308
+ * @param {'on'|'off'} p.mode
309
+ * @param {any} p.data
310
+ * @param {any} p.owner - provider instance (holds the warn-once flag)
311
+ * @param {string} p.label
312
+ * @param {string} p.model
313
+ * @returns {boolean|null}
314
+ */
315
+ function assessThinkingApplied({ kind, mode, data, owner, label, model }) {
316
+ if (mode === 'on') return true;
317
+ const signal = readThinkingSignal(kind, data);
318
+ if (signal === 'none') return true;
319
+ if (signal === 'unknown') return null;
320
+ if (!owner._warnedThinkingNotApplied) {
321
+ owner._warnedThinkingNotApplied = true;
322
+ console.warn(`[${label}] thinkingMode 'off' was sent for ${model} but the reply shows thinking happened — the switch did not take effect (the result is kept; thinkingApplied:false). Further occurrences from this provider instance are silent.`);
323
+ }
324
+ return false;
325
+ }
326
+
327
+ /**
328
+ * What bare-agent knows about switching a model's thinking on/off, with no network. Measured models
329
+ * answer true/false; a model not in the measured table on a provider with a shape ladder answers
330
+ * 'unverified' (the ladder will try the shapes at runtime and fail loudly if all are rejected);
331
+ * clipipe and unknown providers answer false.
332
+ * @param {'anthropic'|'openai'|'deepseek'|'gemini'|'ollama'|'clipipe'|string} provider
333
+ * @param {string} [model]
334
+ * @returns {{ switchable: boolean|'unverified', off: string|null, on: string|null, note: string }}
335
+ * @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
336
+ * @fails returns switchable:false for clipipe and unknown providers (never throws); 'unverified' means not measured, not unsupported; Ollama's shapes are not live-verified.
337
+ * @category providers
338
+ * @example
339
+ * thinkingSupport('anthropic', 'claude-opus-5-5'); // { switchable: false, off: null, on: "thinking:{type:'adaptive'}", note: '...' }
340
+ */
341
+ function thinkingSupport(provider, model) {
342
+ if (provider === 'clipipe') {
343
+ return { switchable: false, off: null, on: null, note: 'CLIPipe has no thinking-mode mechanism; thinkingMode throws a ValidationError.' };
344
+ }
345
+ const kind = /** @type {LadderKind} */ (provider);
346
+ if (typeof provider !== 'string' || !Object.prototype.hasOwnProperty.call(LADDERS, provider)) {
347
+ return { switchable: false, off: null, on: null, note: `Unknown provider '${String(provider)}'.` };
348
+ }
349
+ const idOf = (/** @type {'on'|'off'} */ mode, /** @type {string|null} */ key) =>
350
+ key === null ? null : (LADDERS[kind][mode].find((s) => s.key === key) || LADDERS[kind][mode][0]).id;
351
+ const facts = lookupModel(kind, /** @type {string} */ (model));
352
+ if (!facts) {
353
+ return {
354
+ switchable: 'unverified',
355
+ off: LADDERS[kind].off[0].id,
356
+ on: LADDERS[kind].on[0].id,
357
+ note: kind === 'ollama'
358
+ ? 'Shapes known (think:true/false) but NOT live-verified; the reply signal is message.thinking.'
359
+ : `Model not in the measured table; the shape ladder is tried at runtime (off: ${LADDERS[kind].off.map((s) => s.id).join(' → ')}; on: ${LADDERS[kind].on.map((s) => s.id).join(' → ')}) and fails loudly if all are rejected.`,
360
+ };
361
+ }
362
+ const switchable = facts.off !== null && facts.on !== null;
363
+ return {
364
+ switchable,
365
+ off: idOf('off', facts.off),
366
+ on: idOf('on', facts.on),
367
+ note: switchable
368
+ ? 'Measured: both modes work.'
369
+ : `Measured: ${facts.off === null ? "'off'" : "'on'"} is rejected by the API for this model — requesting it throws before any request is sent.`,
370
+ };
371
+ }
372
+
373
+ module.exports = {
374
+ thinkingSupport,
375
+ // internal (provider files + tests)
376
+ normalizeThinkingMode,
377
+ resolveThinkingMode,
378
+ requestWithThinkingMode,
379
+ assessThinkingApplied,
380
+ readThinkingSignal,
381
+ isShapeRejection,
382
+ isDeepSeek,
383
+ lookupModel,
384
+ };
package/types/index.d.ts CHANGED
@@ -134,6 +134,15 @@ export interface GenerateResult {
134
134
  * temperature, not the one requested — callers reporting an effective temperature must honor this.
135
135
  */
136
136
  temperatureDropped?: boolean;
137
+ /**
138
+ * Present ONLY when `thinkingMode` ('on'|'off') was set on the provider/call. The post-reply check:
139
+ * `true` — a shape was accepted and, for 'off', the reply shows zero thinking signal (for 'on' always
140
+ * `true` once accepted: an adaptive model may legitimately choose not to think); `false` — 'off' was
141
+ * requested but the reply shows thinking happened (the provider warns once; the round is paid and kept,
142
+ * never thrown); `null` — cannot tell (the reply's thinking signal is unreadable, e.g. OpenAI without
143
+ * `reasoning_tokens`). Surfaced on `Loop.run()`'s return, sticky-false: any round reporting `false` makes the run `false`, else the latest round's value.
144
+ */
145
+ thinkingApplied?: boolean | null;
137
146
  /**
138
147
  * BA-27 — present ONLY when the model emitted a tool call whose `function.arguments` was
139
148
  * syntactically-broken JSON (an extra brace, a truncated object). The billed round already