bare-agent 0.51.0 → 0.53.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/src/mcp-bridge.js CHANGED
@@ -4,7 +4,7 @@ const { spawn } = require('node:child_process');
4
4
  const { readFileSync, writeFileSync, existsSync } = require('node:fs');
5
5
  const { join } = require('node:path');
6
6
  const { homedir } = require('node:os');
7
- const { ToolError } = require('./errors');
7
+ const { ToolError, ValidationError } = require('./errors');
8
8
 
9
9
  /** @typedef {import('../types').ToolDef} ToolDef */
10
10
 
@@ -54,7 +54,7 @@ const { ToolError } = require('./errors');
54
54
  /**
55
55
  * JSON-RPC stdio client over a spawned MCP server.
56
56
  * @typedef {object} RpcClient
57
- * @property {(method: string, params?: object, timeoutMs?: number) => Promise<any>} rpc
57
+ * @property {(method: string, params?: object, timeoutMs?: number, label?: {server: string, tool: string}) => Promise<any>} rpc
58
58
  * @property {(method: string, params?: object) => void} notify
59
59
  * @property {import('node:child_process').ChildProcessWithoutNullStreams} child
60
60
  * @property {string} stderr
@@ -309,9 +309,11 @@ function createRpcClient(name, def) {
309
309
  * @param {string} method
310
310
  * @param {object} [params]
311
311
  * @param {number} [timeoutMs=0] - Reject if no response arrives within this many ms (0 = no limit).
312
+ * @param {{server: string, tool: string}} [label] - tools/call only: when given, a timeout throws a
313
+ * labelled ToolError (`context: {timeout, server, tool, timeoutMs, stderr}`, stderr kept OUT of the message).
312
314
  * @returns {Promise<any>}
313
315
  */
314
- function rpc(method, params = {}, timeoutMs = 0) {
316
+ function rpc(method, params = {}, timeoutMs = 0, label) {
315
317
  const id = nextId++;
316
318
  return new Promise((resolve, reject) => {
317
319
  if (!child.stdin.writable) {
@@ -321,7 +323,14 @@ function createRpcClient(name, def) {
321
323
  let timer = null;
322
324
  if (timeoutMs > 0) {
323
325
  timer = setTimeout(() => {
324
- settle(id, false, new ToolError(`MCP server "${name}" "${method}" timed out after ${timeoutMs}ms. stderr: ${stderrBuf.slice(-500)}`));
326
+ if (label) {
327
+ settle(id, false, new ToolError(
328
+ `MCP server "${label.server}" tool "${label.tool}" timed out after ${timeoutMs}ms`,
329
+ { context: { timeout: true, server: label.server, tool: label.tool, timeoutMs, stderr: stderrBuf.slice(-500) } },
330
+ ));
331
+ } else {
332
+ settle(id, false, new ToolError(`MCP server "${name}" "${method}" timed out after ${timeoutMs}ms. stderr: ${stderrBuf.slice(-500)}`));
333
+ }
325
334
  }, timeoutMs);
326
335
  timer.unref?.();
327
336
  }
@@ -367,7 +376,7 @@ function unwrapContent(content) {
367
376
  /**
368
377
  * @param {string} serverName
369
378
  * @param {McpTool[]} mcpTools
370
- * @param {(method: string, params?: object, timeoutMs?: number) => Promise<any>} rpc
379
+ * @param {(method: string, params?: object, timeoutMs?: number, label?: {server: string, tool: string}) => Promise<any>} rpc
371
380
  * @param {number} [callTimeout=0] - Per-invocation timeout (ms) for tools/call; 0 = no limit.
372
381
  * @returns {ToolDef[]}
373
382
  */
@@ -377,7 +386,7 @@ function wrapTools(serverName, mcpTools, rpc, callTimeout = 0) {
377
386
  description: t.description || '',
378
387
  parameters: t.inputSchema || { type: 'object', properties: {} },
379
388
  execute: async (args) => {
380
- const result = await rpc('tools/call', { name: t.name, arguments: args }, callTimeout);
389
+ const result = await rpc('tools/call', { name: t.name, arguments: args }, callTimeout, { server: serverName, tool: t.name });
381
390
  if (result.isError) {
382
391
  throw new ToolError(unwrapContent(result.content) || 'MCP tool error', {
383
392
  context: { server: serverName, tool: t.name },
@@ -451,7 +460,14 @@ async function connectAndListTools(name, def, timeout = 15000) {
451
460
  }, timeout);
452
461
  client.notify('notifications/initialized');
453
462
 
454
- const { tools: mcpTools } = await client.rpc('tools/list', {}, timeout);
463
+ const listed = await client.rpc('tools/list', {}, timeout);
464
+ // A reply without a `tools` array is a protocol-violating server, not an empty one: fail the
465
+ // connect HERE (inside the try, so the child is reaped) rather than let every caller's
466
+ // `.map`/`.filter` throw after the child is already running but before it is tracked.
467
+ const mcpTools = listed && listed.tools;
468
+ if (!Array.isArray(mcpTools)) {
469
+ throw new Error(`MCP server "${name}" returned a malformed tools/list reply (no tools array)`);
470
+ }
455
471
 
456
472
  return { mcpTools, client };
457
473
  } catch (err) {
@@ -470,9 +486,10 @@ async function connectAndListTools(name, def, timeout = 15000) {
470
486
  * @param {string[]} servers
471
487
  * @param {ToolDef[]} tools
472
488
  * @param {DeniedTool[]} denied
489
+ * @param {boolean} [allowMode] - Signed allow-list mode: say nothing about restricted/other tools or the cache file.
473
490
  * @returns {string}
474
491
  */
475
- function buildSystemContext(servers, tools, denied) {
492
+ function buildSystemContext(servers, tools, denied, allowMode = false) {
476
493
  /** @type {string[]} */
477
494
  const lines = [];
478
495
  lines.push(`MCP Bridge: ${tools.length} tools available from ${servers.length} server(s): ${servers.join(', ')}.`);
@@ -489,6 +506,8 @@ function buildSystemContext(servers, tools, denied) {
489
506
  lines.push(` ${server}: ${toolNames.join(', ')}`);
490
507
  }
491
508
 
509
+ if (allowMode) return lines.join('\n');
510
+
492
511
  if (denied.length > 0) {
493
512
  lines.push(`Restricted tools (${denied.length}, not available to you):`);
494
513
  for (const d of denied) {
@@ -526,13 +545,16 @@ function buildSystemContext(servers, tools, denied) {
526
545
  *
527
546
  * @param {ToolDef[]} tools - The bulk-loaded, name-prefixed tools array.
528
547
  * @param {string} [discoveredAt] - ISO timestamp from .mcp-bridge.json.
548
+ * @param {Map<string, {server: string, tool: string}>} [excluded] - Allow mode only: names the server
549
+ * LISTS but the signed allow-list excludes. mcp_invoke refuses them with `context.denied === true`;
550
+ * they never appear in the catalog. Omitted ⇒ unchanged behaviour.
529
551
  * @returns {ToolDef[]} [mcp_discover, mcp_invoke]
530
552
  * @when you want the two bulk MCP meta-tools (mcp_discover, mcp_invoke) instead of exposing every discovered tool individually — one gate-check per invocation
531
553
  * @fails returns [mcp_discover, mcp_invoke]; a tool name does not travel as action.type (a deliberate v0.9 trade for one gate-check per call).
532
554
  * @example
533
555
  * const metaTools = buildMetaTools(tools);
534
556
  */
535
- function buildMetaTools(tools, discoveredAt) {
557
+ function buildMetaTools(tools, discoveredAt, excluded) {
536
558
  // Catalog descriptors: same info the LLM would see for bulk-loaded tools,
537
559
  // but exposed via mcp_discover instead of taking up tool-array slots upfront.
538
560
  const catalog = tools.map(t => {
@@ -596,6 +618,12 @@ function buildMetaTools(tools, discoveredAt) {
596
618
  },
597
619
  execute: async (/** @type {{ name: string, args?: object }} */ { name, args }) => {
598
620
  const tool = byName.get(name);
621
+ const ex = !tool && excluded ? excluded.get(name) : undefined;
622
+ if (ex) {
623
+ throw new ToolError(`mcp_invoke: "${name}" is not on this run's allowed list`, {
624
+ context: { denied: true, server: ex.server, tool: ex.tool },
625
+ });
626
+ }
599
627
  if (!tool) {
600
628
  throw new ToolError(`mcp_invoke: unknown tool "${name}". Call mcp_discover for the current catalog.`, {
601
629
  context: { name, knownNames: [...byName.keys()] },
@@ -608,6 +636,118 @@ function buildMetaTools(tools, discoveredAt) {
608
636
  return [mcpDiscover, mcpInvoke];
609
637
  }
610
638
 
639
+ // --- Signed allow-list mode (opts.allow) ---
640
+
641
+ /** @param {any} v @returns {string} */
642
+ const typeName = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v);
643
+
644
+ /**
645
+ * Validate `opts.allow`. Messages name the TYPE only — never echo a value.
646
+ * @param {any} allow
647
+ * @returns {Map<string, string[]>} server name -> de-duplicated signed raw tool names
648
+ */
649
+ function validateAllow(allow) {
650
+ const bad = (/** @type {string} */ msg) =>
651
+ new ValidationError(`[MCP Bridge] ${msg}`, { context: { lib: 'bare-agent' } });
652
+ const proto = allow !== null && typeof allow === 'object' ? Object.getPrototypeOf(allow) : undefined;
653
+ if (typeof allow !== 'object' || allow === null || Array.isArray(allow) || (proto !== Object.prototype && proto !== null)) {
654
+ throw bad(`allow must be a plain object of { serverName: string[] } (got ${typeName(allow)})`);
655
+ }
656
+ const entries = Object.entries(allow);
657
+ if (entries.length === 0) throw bad('allow must name at least one server (got an empty object)');
658
+ /** @type {Map<string, string[]>} */
659
+ const out = new Map();
660
+ for (const [server, list] of entries) {
661
+ if (!Array.isArray(list)) throw bad(`allow values must be arrays of tool names (got ${typeName(list)})`);
662
+ if (list.length === 0) throw bad('allow values must be non-empty arrays of tool names (got an empty array)');
663
+ for (const t of list) {
664
+ if (typeof t !== 'string' || t === '') {
665
+ throw bad(`allow tool names must be non-empty strings (got ${t === '' ? 'empty string' : typeName(t)})`);
666
+ }
667
+ }
668
+ out.set(server, [...new Set(list)]);
669
+ }
670
+ return out;
671
+ }
672
+
673
+ /**
674
+ * allow-mode body of createMCPBridge: in memory only (no .mcp-bridge.json read or write, no TTL),
675
+ * server definitions come ONLY from discoverServers(), one connect per signed server, expose
676
+ * signed ∩ listed, silent on stdout, and the model is never told other tools exist.
677
+ * @param {Map<string, string[]>} allow
678
+ * @param {object} o
679
+ * @param {string[]} [o.configPaths]
680
+ * @param {boolean} o.includeProjectConfig
681
+ * @param {number} o.timeout
682
+ * @param {number} o.callTimeout
683
+ * @param {(name: string, def: ServerDef) => Promise<boolean>} o.vetServer
684
+ * @param {(specs: Array<{name: string, command: string, args?: string[]}>) => void} o.warnUnvettedSpawn
685
+ */
686
+ async function createAllowBridge(allow, o) {
687
+ const discovered = discoverServers(o.configPaths, { includeProjectConfig: o.includeProjectConfig });
688
+ /** @type {Array<[string, ServerDef]>} */
689
+ const toConnect = [];
690
+ /** @type {Array<{server: string, error: string}>} */
691
+ const errors = [];
692
+ for (const name of allow.keys()) {
693
+ const def = discovered.get(name);
694
+ if (def) toConnect.push([name, def]);
695
+ else errors.push({ server: name, error: 'server not found in discovered MCP configs' });
696
+ }
697
+ o.warnUnvettedSpawn(toConnect.map(([name, def]) => ({ name, command: def.command, args: def.args })));
698
+
699
+ /** @type {ToolDef[]} */
700
+ const tools = [];
701
+ /** @type {import('node:child_process').ChildProcess[]} */
702
+ const children = [];
703
+ /** @type {string[]} */
704
+ const connected = [];
705
+ /** @type {Array<{server: string, tool: string}>} */
706
+ const missing = [];
707
+ /** @type {Map<string, {server: string, tool: string}>} */
708
+ const excluded = new Map();
709
+ /** @type {Set<string>} */
710
+ const listedOk = new Set();
711
+
712
+ await Promise.all(toConnect.map(async ([name, def]) => {
713
+ try {
714
+ if (!(await o.vetServer(name, def))) return;
715
+ const { mcpTools, client } = await connectAndListTools(name, def, o.timeout);
716
+ // Track the child BEFORE any processing so close() reaps it even if what follows throws.
717
+ children.push(client.child);
718
+ const signed = new Set(allow.get(name));
719
+ const listed = new Set(mcpTools.map(t => t.name));
720
+ tools.push(...wrapTools(name, mcpTools.filter(t => signed.has(t.name)), client.rpc, o.callTimeout));
721
+ for (const t of mcpTools) {
722
+ if (!signed.has(t.name)) excluded.set(`${name}_${t.name}`, { server: name, tool: t.name });
723
+ }
724
+ for (const t of signed) if (!listed.has(t)) missing.push({ server: name, tool: t });
725
+ connected.push(name);
726
+ listedOk.add(name);
727
+ } catch (err) {
728
+ errors.push({ server: name, error: err.message });
729
+ }
730
+ }));
731
+
732
+ // Every signed tool of a server that was not found / failed / was rejected by confirmServer is missing.
733
+ for (const [server, list] of allow) {
734
+ if (!listedOk.has(server)) for (const tool of list) missing.push({ server, tool });
735
+ }
736
+
737
+ if (errors.length > 0) console.warn('[MCP Bridge] Some servers failed to connect:', errors);
738
+
739
+ return {
740
+ tools,
741
+ metaTools: buildMetaTools(tools, undefined, excluded),
742
+ servers: connected,
743
+ denied: [],
744
+ systemContext: buildSystemContext(connected, tools, [], true),
745
+ errors,
746
+ missing,
747
+ close: async () => { await Promise.all(children.map(killServer)); },
748
+ };
749
+ }
750
+
611
751
  // --- Main entry point ---
612
752
 
613
753
  /**
@@ -632,7 +772,16 @@ function buildMetaTools(tools, discoveredAt) {
632
772
  * @param {boolean} [opts.includeProjectConfig=false] - Also scan the project-cwd `./.mcp.json`
633
773
  * during default discovery. Off by default: a project config in an untrusted repo can auto-spawn
634
774
  * arbitrary commands. Implied true when a `confirmServer` hook is present (it vets each command).
635
- * @param {string[]} [opts.servers] - Limit to these server names.
775
+ * @param {Record<string, string[]>} [opts.allow] - Signed allow-list `{ [serverName]: rawMcpToolNames[] }`
776
+ * (raw MCP names, NOT the `server_tool` prefixed form; each list must be non-empty). Switches to an IN-MEMORY mode: never reads or
777
+ * writes `.mcp-bridge.json` (no cache, no TTL; `bridgePath`/`refresh`/`servers` are ignored); server
778
+ * definitions come only from `discoverServers(configPaths)`; only servers named here are spawned (still
779
+ * vetted by `confirmServer`); each connects ONCE and exposes signed ∩ listed — every other tool is ABSENT
780
+ * (not in `tools`, `metaTools` catalog or `systemContext`). Signed tools the server does not list (or whose
781
+ * server was not found / failed / was rejected) come back in `missing`. `denied` is always `[]`, nothing is
782
+ * written to stdout, and `mcp_invoke` on a listed-but-unsigned name throws `context.denied === true`.
783
+ * Invalid shape throws `ValidationError` (type only, never values).
784
+ * @param {string[]} [opts.servers] - Limit to these server names (ignored when `allow` is given).
636
785
  * @param {number} [opts.timeout=15000] - Per-server handshake timeout in ms (initialize + tools/list).
637
786
  * @param {number} [opts.callTimeout=120000] - Per-invocation timeout in ms for tools/call. Bounds a
638
787
  * server that accepts a tool call but never responds. Set 0 to disable (unbounded).
@@ -644,9 +793,9 @@ function buildMetaTools(tools, discoveredAt) {
644
793
  * discovered server is trusted — pass this to gate command execution. Presence
645
794
  * of this hook also opts default discovery into the project-cwd `./.mcp.json`,
646
795
  * since each command is then vetted regardless of source.
647
- * @returns {Promise<{tools: ToolDef[], metaTools?: ToolDef[], servers: string[], systemContext: string, denied: DeniedTool[], errors?: Array<{server: string, error: string}>, close: Function}>}
648
- * @when you want to auto-discover MCP servers and expose them as bareagent tools in one call — with a trust hook gating command execution
649
- * @fails a trust hook returning false skips a server (its command never runs) and a throw is fail-closed (deny); returns tools plus a close() to shut servers down.
796
+ * @returns {Promise<{tools: ToolDef[], metaTools?: ToolDef[], servers: string[], systemContext: string, denied: DeniedTool[], errors?: Array<{server: string, error: string}>, missing?: Array<{server: string, tool: string}>, close: Function}>}
797
+ * @when you want to auto-discover MCP servers and expose them as bareagent tools in one call — with a trust hook gating command execution, or a per-run signed `allow` list that exposes only the named tools
798
+ * @fails a trust hook returning false skips a server (its command never runs) and a throw is fail-closed (deny); with `allow` it is in-memory only (no cache file read or written) and signed tools the server does not list land in `missing`, never a throw; returns tools plus a close() to shut servers down.
650
799
  * @example
651
800
  * const { tools, close } = await createMCPBridge();
652
801
  * const loop = new Loop({ provider, tools });
@@ -693,6 +842,15 @@ async function createMCPBridge(opts = {}) {
693
842
  );
694
843
  };
695
844
 
845
+ if (opts.allow !== undefined) {
846
+ const allow = validateAllow(opts.allow);
847
+ return createAllowBridge(allow, {
848
+ configPaths: opts.configPaths,
849
+ includeProjectConfig: opts.includeProjectConfig === true || !!confirmServer,
850
+ timeout, callTimeout, vetServer, warnUnvettedSpawn,
851
+ });
852
+ }
853
+
696
854
  let config = readBridgeConfig(bridgePath);
697
855
  const needsRefresh = opts.refresh || !config || isExpired(config);
698
856
 
@@ -731,8 +889,8 @@ async function createMCPBridge(opts = {}) {
731
889
  // decision, not a connection failure, so it must not land in `errors`.
732
890
  if (!(await vetServer(name, def))) return;
733
891
  const result = await connectAndListTools(name, def, timeout);
734
- freshTools.set(name, result.mcpTools);
735
892
  connectResults.set(name, result.client);
893
+ freshTools.set(name, result.mcpTools);
736
894
  } catch (err) {
737
895
  errors.push({ server: name, error: err.message });
738
896
  }
@@ -742,20 +900,25 @@ async function createMCPBridge(opts = {}) {
742
900
  console.warn('[MCP Bridge] Some servers failed to connect:', errors);
743
901
  }
744
902
 
745
- // Only write config when at least one server connected successfully.
746
- // If all servers failed, retain the existing config unchanged so
747
- // user-curated allow/deny settings are not destroyed on transient failures.
748
- if (freshTools.size > 0) {
749
- config = mergeBridgeConfig(config, new Map(toDiscover), freshTools);
750
- writeBridgeConfig(bridgePath, config);
751
- console.log(`[MCP Bridge] Wrote ${bridgePath}`);
752
- } else if (!config) {
753
- return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
754
- }
903
+ // Discovery connections are killed in `finally`: a throw from the config merge/write (e.g. an
904
+ // unwritable bridgePath) must not strand the live children held only in connectResults.
905
+ try {
906
+ // Only write config when at least one server connected successfully.
907
+ // If all servers failed, retain the existing config unchanged so
908
+ // user-curated allow/deny settings are not destroyed on transient failures.
909
+ if (freshTools.size > 0) {
910
+ config = mergeBridgeConfig(config, new Map(toDiscover), freshTools);
911
+ writeBridgeConfig(bridgePath, config);
912
+ console.log(`[MCP Bridge] Wrote ${bridgePath}`);
913
+ } else if (!config) {
914
+ return { tools: [], servers: [], systemContext: '', denied: [], close: async () => {} };
915
+ }
755
916
 
756
- // Close the discovery connections — we'll reconnect below using the config
757
- for (const client of connectResults.values()) {
758
- await killServer(client.child);
917
+ } finally {
918
+ // Close the discovery connections — we'll reconnect below using the config
919
+ for (const client of connectResults.values()) {
920
+ await killServer(client.child);
921
+ }
759
922
  }
760
923
  }
761
924
  }
@@ -808,13 +971,14 @@ async function createMCPBridge(opts = {}) {
808
971
  // Denied by confirmServer: skip silently (the caller's decision, not a failure).
809
972
  if (!(await vetServer(name, serverConf))) return;
810
973
  const { mcpTools, client } = await connectAndListTools(name, serverConf, timeout);
974
+ // Track the child BEFORE any processing so close() reaps it even if what follows throws.
975
+ children.push(client.child);
811
976
 
812
977
  // Only wrap tools that are allowed in config
813
978
  const allowed = mcpTools.filter(t => allowedToolNames.includes(t.name));
814
979
  const wrapped = wrapTools(name, allowed, client.rpc, callTimeout);
815
980
 
816
981
  tools.push(...wrapped);
817
- children.push(client.child);
818
982
  connected.push(name);
819
983
 
820
984
  // Track denied tools with descriptions from the server
@@ -31,8 +31,8 @@ export type AnthropicOptions = {
31
31
  */
32
32
  thinking?: any;
33
33
  /**
34
- * - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right Anthropic `thinking` request field for the provider/model, retries the known alternative shapes if the API rejects the field by name, and THROWS a `ValidationError` (naming what it tried) if every shape is rejected — or before any request when the model is measured as not switchable. Every reply is checked: `GenerateResult.thinkingApplied` is `true`/`false`/`null` (`false` = 'off' was sent but the reply shows thinking happened — warns once per instance, result kept). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`.
35
- * Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — throws `ValidationError`, which the chain treats like any failure and falls through).
34
+ * - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right Anthropic `thinking` request field for the provider/model, tries the next known shape when a shape request fails with a 400/422 (not the temperature path), and the call ALWAYS RUNS: if every shape fails that way, ONE plain request without a thinking field decides — plain OK ⇒ `thinkingStatus:'unsupported'`, plain fails ⇒ that plain request's error is thrown as-is; other failures propagate after one request; a model measured as not switchable is sent plain directly. Only the winning shape is cached per instance+model ('unsupported' is never cached); warns once per status. Every reply is checked: `GenerateResult.thinkingStatus` is `'applied'`/`'unsupported'`/`'ignored'`/`'unknown'` (`'ignored'` = 'off' was accepted but the reply shows thinking anyway — warns once, result kept) and the legacy `thinkingApplied` is derived (`true`/`false`/`null`). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`.
35
+ * Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — just runs without it and reports `thinkingStatus:'unsupported'`, so the first member answers and the chain does not fall through).
36
36
  */
37
37
  thinkingMode?: "on" | "off" | null | undefined;
38
38
  /**
@@ -63,8 +63,8 @@ export type AnthropicOptions = {
63
63
  * chain set `thinking` on each provider's CONSTRUCTOR instead of per-call.
64
64
  *
65
65
  * **MEASURED CAVEAT — this option does not "turn thinking on".** On `claude-sonnet-5` adaptive thinking is ALREADY the default: sending this changed the observed thinking rate not at all (2/10 rounds with it vs 3/10 without — `poc/ba7-adaptive-default.mjs`). Its real use is pinning the mode and reaching `display`/`effort`. The change that mattered is that thinking blocks are now PRESERVED and replayed (see `Message.providerBlocks`), which happens whether or not you ever set this.
66
- * @property {'on'|'off'|null} [thinkingMode] - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right Anthropic `thinking` request field for the provider/model, retries the known alternative shapes if the API rejects the field by name, and THROWS a `ValidationError` (naming what it tried) if every shape is rejected — or before any request when the model is measured as not switchable. Every reply is checked: `GenerateResult.thinkingApplied` is `true`/`false`/`null` (`false` = 'off' was sent but the reply shows thinking happened — warns once per instance, result kept). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`.
67
- * Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — throws `ValidationError`, which the chain treats like any failure and falls through).
66
+ * @property {'on'|'off'|null} [thinkingMode] - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right Anthropic `thinking` request field for the provider/model, tries the next known shape when a shape request fails with a 400/422 (not the temperature path), and the call ALWAYS RUNS: if every shape fails that way, ONE plain request without a thinking field decides — plain OK ⇒ `thinkingStatus:'unsupported'`, plain fails ⇒ that plain request's error is thrown as-is; other failures propagate after one request; a model measured as not switchable is sent plain directly. Only the winning shape is cached per instance+model ('unsupported' is never cached); warns once per status. Every reply is checked: `GenerateResult.thinkingStatus` is `'applied'`/`'unsupported'`/`'ignored'`/`'unknown'` (`'ignored'` = 'off' was accepted but the reply shows thinking anyway — warns once, result kept) and the legacy `thinkingApplied` is derived (`true`/`false`/`null`). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`.
67
+ * Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — just runs without it and reports `thinkingStatus:'unsupported'`, so the first member answers and the chain does not fall through).
68
68
  * @property {boolean} [exposeErrorBody=false] - Attach the full upstream response to `err.body` on HTTP errors (off by default to avoid leaking unexpected fields through error logs; `err.message` still carries the API error).
69
69
  * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. A silent or never-answering socket (dropped by the server, or a response that never starts) was otherwise bounded only by the OS TCP timeout (~2h) — a hang, not an error, so every retry policy above it was inert. Bounds on socket INACTIVITY (the timer resets on activity, so a slow-but-streaming response is not killed); on trip, `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) that a wired `Retry` (`Loop({ retry })`) retries. Default 10 min sits above any single non-streaming completion; `0` or `Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
70
70
  * @property {number} [deadlineMs=0] - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever (a "zombie stream") never trips it and hangs the caller for hours. This is an absolute, non-resetting wall-clock ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false` — a hard ceiling meant to STOP, not re-spend). DISABLED by default (a deliberately long single call is legitimate); `0`/`Infinity` disable. When both are set with `timeoutMs < deadlineMs`, a silent socket trips the idle bound first. Overridable per call via `generate(..., { deadlineMs })`.
@@ -100,9 +100,8 @@ export class AnthropicProvider {
100
100
  * @throws {Error} `[AnthropicProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
101
101
  */
102
102
  generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
103
- /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
104
- _warnTemperatureDropped(): void;
105
- _warnedTempDropped: boolean | undefined;
103
+ /** One-time warning (per reason) that this model rejected `temperature` and the request was retried without it (BA-10). */
104
+ _warnTemperatureDropped(reason?: string): void;
106
105
  /**
107
106
  * BA-7: the provider-native blocks to replay at the FRONT of an assistant turn's content, or `[]`.
108
107
  *
@@ -7,7 +7,7 @@ const { requestWithTemperatureFallback } = require('./provider-temperature');
7
7
  const { normalizeStopReason } = require('./provider-stop-reason');
8
8
  const { resolveTimeoutMs, applyRequestBounds, guardResponseSettles } = require('./provider-http');
9
9
  const { hasUsageSignal } = require('./provider-usage');
10
- const { normalizeThinkingMode, resolveThinkingMode, requestWithThinkingMode, assessThinkingApplied } = require('./thinking-mode');
10
+ const { normalizeThinkingMode, resolveThinkingMode, requestWithThinkingMode, assessThinking } = require('./thinking-mode');
11
11
 
12
12
  // BA-24: the raw Anthropic usage field names. Presence of any (even value 0) means the API reported a
13
13
  // usage signal → build the object; absence of all means no signal → surface null (unpriceable).
@@ -35,8 +35,8 @@ function isLoopbackHost(hostname) {
35
35
  * chain set `thinking` on each provider's CONSTRUCTOR instead of per-call.
36
36
  *
37
37
  * **MEASURED CAVEAT — this option does not "turn thinking on".** On `claude-sonnet-5` adaptive thinking is ALREADY the default: sending this changed the observed thinking rate not at all (2/10 rounds with it vs 3/10 without — `poc/ba7-adaptive-default.mjs`). Its real use is pinning the mode and reaching `display`/`effort`. The change that mattered is that thinking blocks are now PRESERVED and replayed (see `Message.providerBlocks`), which happens whether or not you ever set this.
38
- * @property {'on'|'off'|null} [thinkingMode] - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right Anthropic `thinking` request field for the provider/model, retries the known alternative shapes if the API rejects the field by name, and THROWS a `ValidationError` (naming what it tried) if every shape is rejected — or before any request when the model is measured as not switchable. Every reply is checked: `GenerateResult.thinkingApplied` is `true`/`false`/`null` (`false` = 'off' was sent but the reply shows thinking happened — warns once per instance, result kept). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`.
39
- * Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — throws `ValidationError`, which the chain treats like any failure and falls through).
38
+ * @property {'on'|'off'|null} [thinkingMode] - Normalized thinking switch: `'on'` or `'off'`, and bare-agent sends the right Anthropic `thinking` request field for the provider/model, tries the next known shape when a shape request fails with a 400/422 (not the temperature path), and the call ALWAYS RUNS: if every shape fails that way, ONE plain request without a thinking field decides — plain OK ⇒ `thinkingStatus:'unsupported'`, plain fails ⇒ that plain request's error is thrown as-is; other failures propagate after one request; a model measured as not switchable is sent plain directly. Only the winning shape is cached per instance+model ('unsupported' is never cached); warns once per status. Every reply is checked: `GenerateResult.thinkingStatus` is `'applied'`/`'unsupported'`/`'ignored'`/`'unknown'` (`'ignored'` = 'off' was accepted but the reply shows thinking anyway — warns once, result kept) and the legacy `thinkingApplied` is derived (`true`/`false`/`null`). Unset ⇒ request body byte-identical to before. Overridable per call via `generate(..., { thinkingMode })`; `null` suppresses an instance default. Any value but 'on'/'off' ⇒ `ValidationError`. A NEW field: it does not change the raw `thinking` option; setting both (after per-call override) ⇒ `ValidationError`. See `src/thinking-mode.js` / `thinkingSupport()`.
39
+ * Per-call `thinkingMode` reaches EVERY provider in a `FallbackProvider` chain — correct for a normalized field (each provider maps it to its own shape; one that cannot — CLIPipe, a non-switchable model — just runs without it and reports `thinkingStatus:'unsupported'`, so the first member answers and the chain does not fall through).
40
40
  * @property {boolean} [exposeErrorBody=false] - Attach the full upstream response to `err.body` on HTTP errors (off by default to avoid leaking unexpected fields through error logs; `err.message` still carries the API error).
41
41
  * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. A silent or never-answering socket (dropped by the server, or a response that never starts) was otherwise bounded only by the OS TCP timeout (~2h) — a hang, not an error, so every retry policy above it was inert. Bounds on socket INACTIVITY (the timer resets on activity, so a slow-but-streaming response is not killed); on trip, `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) that a wired `Retry` (`Loop({ retry })`) retries. Default 10 min sits above any single non-streaming completion; `0` or `Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
42
42
  * @property {number} [deadlineMs=0] - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever (a "zombie stream") never trips it and hangs the caller for hours. This is an absolute, non-resetting wall-clock ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false` — a hard ceiling meant to STOP, not re-spend). DISABLED by default (a deliberately long single call is legitimate); `0`/`Infinity` disable. When both are set with `timeoutMs < deadlineMs`, a silent socket trips the idle bound first. Overridable per call via `generate(..., { deadlineMs })`.
@@ -159,6 +159,8 @@ class AnthropicProvider {
159
159
  const thinking = options.thinking !== undefined ? options.thinking : this.thinking;
160
160
  if (thinking) body.thinking = thinking;
161
161
  const thinkingMode = resolveThinkingMode({ label: 'AnthropicProvider', instanceMode: this.thinkingMode, options, instanceThinking: this.thinking, hasRawThinking: true });
162
+ /** @type {{ unsupported: boolean }} */
163
+ const thinkingTrack = { unsupported: false };
162
164
  if (tools.length > 0) {
163
165
  body.tools = tools.map(t => ({
164
166
  name: t.name,
@@ -174,12 +176,12 @@ class AnthropicProvider {
174
176
  const deadlineMs = resolveTimeoutMs(this.deadlineMs, options.deadlineMs, 0, 'deadlineMs');
175
177
  const { data, temperatureDropped } = await requestWithTemperatureFallback({
176
178
  request: () => thinkingMode
177
- ? requestWithThinkingMode({ kind: 'anthropic', mode: thinkingMode, model: this.model, label: 'AnthropicProvider', body, cache: this._thinkingShapes, request: (b) => this._request(b, timeoutMs, deadlineMs) })
179
+ ? requestWithThinkingMode({ kind: 'anthropic', mode: thinkingMode, model: this.model, body, cache: this._thinkingShapes, track: thinkingTrack, request: (b) => this._request(b, timeoutMs, deadlineMs) })
178
180
  : this._request(body, timeoutMs, deadlineMs),
179
181
  hadTemperature: () => body.temperature != null,
180
182
  stripTemperature: () => { delete body.temperature; },
181
183
  thinkingOn: thinkingMode === 'on',
182
- warnOnce: () => this._warnTemperatureDropped(),
184
+ warnOnce: (reason) => this._warnTemperatureDropped(reason),
183
185
  });
184
186
 
185
187
  let text = '';
@@ -226,15 +228,18 @@ class AnthropicProvider {
226
228
  }
227
229
  : null,
228
230
  ...(temperatureDropped && { temperatureDropped: true }),
229
- ...(thinkingMode && { thinkingApplied: assessThinkingApplied({ kind: 'anthropic', mode: thinkingMode, data, owner: this, label: 'AnthropicProvider', model: this.model }) }),
231
+ ...(thinkingMode && assessThinking({ kind: 'anthropic', mode: thinkingMode, data, unsupported: thinkingTrack.unsupported, owner: this, label: 'AnthropicProvider', model: this.model })),
230
232
  };
231
233
  }
232
234
 
233
- /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
234
- _warnTemperatureDropped() {
235
- if (this._warnedTempDropped) return;
236
- this._warnedTempDropped = true;
237
- console.warn(`[AnthropicProvider] '${this.model}' rejected a non-default 'temperature' (unsupported/deprecated) — retrying without it. Further drops from this provider instance are silent.`);
235
+ /** One-time warning (per reason) that this model rejected `temperature` and the request was retried without it (BA-10). */
236
+ _warnTemperatureDropped(reason = 'unsupported') {
237
+ const flag = reason === 'thinking' ? '_warnedTempThinking' : '_warnedTempDropped';
238
+ if (this[flag]) return;
239
+ this[flag] = true;
240
+ console.warn(reason === 'thinking'
241
+ ? `[AnthropicProvider] '${this.model}': 'temperature' must be 1 while thinking is on — retrying without it. Further drops from this provider instance are silent.`
242
+ : `[AnthropicProvider] '${this.model}' rejected a non-default 'temperature' (unsupported/deprecated) — retrying without it. Further drops from this provider instance are silent.`);
238
243
  }
239
244
 
240
245
  /**
@@ -20,7 +20,7 @@ export type CLIPipeOptions = {
20
20
  */
21
21
  env?: Record<string, string> | undefined;
22
22
  /**
23
- * - NOT supported: any non-null value throws `ValidationError` (CLIPipe has no thinking switch). Declared so the loud failure is typed, not silent.
23
+ * - Accepted ('on'|'off', else `ValidationError`) but CLIPipe has NO thinking switch: the call runs unchanged and the result carries `thinkingStatus:'unsupported'` (+ `thinkingApplied:false`; warns once per instance). Overridable per call; `null` suppresses an instance default.
24
24
  */
25
25
  thinkingMode?: any;
26
26
  /**
@@ -133,6 +133,7 @@ export class CLIPipeProvider {
133
133
  * const provider = new CLIPipeProvider({ command: 'claude' });
134
134
  */
135
135
  constructor(options?: CLIPipeOptions);
136
+ thinkingMode: "on" | "off" | null;
136
137
  command: string;
137
138
  args: string[];
138
139
  cwd: string | undefined;
@@ -176,7 +177,7 @@ export class CLIPipeProvider {
176
177
  * failure for a genuinely tool-incapable model lives in the tool-mode capability probe.
177
178
  * @param {Message[]} messages - Conversation messages in OpenAI format.
178
179
  * @param {ToolDef[]} [tools=[]] - Caller tools. Honored only in tool mode (`toolProtocol` set).
179
- * @param {Record<string, any>} [options={}] - Unused, except `thinkingMode` (any non-null value throws `ValidationError`: CLIPipe has no thinking switch).
180
+ * @param {Record<string, any>} [options={}] - Unused, except `thinkingMode` (validated; CLIPipe has no thinking switch, so the call runs unchanged with `thinkingStatus:'unsupported'`).
180
181
  * @returns {Promise<GenerateResult>}
181
182
  * @throws {Error} `[CLIPipeProvider] failed to spawn "cmd": ...` — when the command cannot be found or executed.
182
183
  * @throws {Error} `[CLIPipeProvider] process exited with code N: ...` — on non-zero exit.
@@ -184,6 +185,14 @@ export class CLIPipeProvider {
184
185
  * @throws {Error} `[CLIPipeProvider] process produced no output` — when stdout is empty.
185
186
  */
186
187
  generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
188
+ /**
189
+ * The pre-thinkingMode generate body: routes to native / emulation / plain-text.
190
+ * @param {Message[]} messages
191
+ * @param {ToolDef[]} tools
192
+ * @param {Record<string, any>} options
193
+ * @returns {Promise<GenerateResult>}
194
+ */
195
+ _generateInner(messages: Message[], tools: ToolDef[], options: Record<string, any>): Promise<GenerateResult>;
187
196
  _warnedNoProtocol: boolean | undefined;
188
197
  /**
189
198
  * Tool mode (v0.32.0) — one turn of schema-validated tool emulation. Renders the Loop's
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const { spawn } = require('child_process');
4
- const { ProviderError, HaltError, ValidationError } = require('./errors');
4
+ const { ProviderError, HaltError } = require('./errors');
5
+ const { normalizeThinkingMode, resolveThinkingMode, warnThinkingStatus } = require('./thinking-mode');
5
6
  const { buildToolSystemPrompt, renderTranscript, resolveToolProtocol, mapClaudeMeta } = require('./provider-clipipe-tools');
6
7
  const { createBridge, resolveSessionError, runSession } = require('./provider-clipipe-mcp');
7
8
 
@@ -21,7 +22,7 @@ const { createBridge, resolveSessionError, runSession } = require('./provider-cl
21
22
  * @property {string[]} [args=[]] - Arguments to pass to the command.
22
23
  * @property {string} [cwd] - Working directory for the child process.
23
24
  * @property {Record<string, string>} [env] - Environment variables for the child process.
24
- * @property {any} [thinkingMode] - NOT supported: any non-null value throws `ValidationError` (CLIPipe has no thinking switch). Declared so the loud failure is typed, not silent.
25
+ * @property {any} [thinkingMode] - Accepted ('on'|'off', else `ValidationError`) but CLIPipe has NO thinking switch: the call runs unchanged and the result carries `thinkingStatus:'unsupported'` (+ `thinkingApplied:false`; warns once per instance). Overridable per call; `null` suppresses an instance default.
25
26
  * @property {number} [timeout=30000] - Timeout in milliseconds.
26
27
  * @property {string} [systemPromptFlag] - CLI flag for system prompt (e.g. '--system'). When set, system messages are extracted and passed via this flag instead of stdin.
27
28
  * @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
@@ -108,10 +109,6 @@ function subtractUsage(total, streamed) {
108
109
  return out;
109
110
  }
110
111
 
111
- const CLIPIPE_NO_THINKING_MODE = () => new ValidationError(
112
- '[CLIPipeProvider] thinkingMode is not supported — a CLI subprocess has no thinking switch bare-agent can set. Remove thinkingMode (see thinkingSupport(\'clipipe\')).',
113
- { context: { lib: 'bare-agent', provider: 'clipipe' } });
114
-
115
112
  class CLIPipeProvider {
116
113
  /**
117
114
  * Provider that pipes prompts to a CLI command via stdin and reads stdout.
@@ -124,8 +121,9 @@ class CLIPipeProvider {
124
121
  */
125
122
  constructor(options = {}) {
126
123
  if (!options.command) throw new Error('[CLIPipeProvider] requires command');
127
- // thinkingMode has NO mechanism here — loud, never silently ignored.
128
- if (options.thinkingMode != null) throw CLIPIPE_NO_THINKING_MODE();
124
+ // thinkingMode has NO mechanism here: validated (a bad value is a caller mistake), then every call
125
+ // runs unchanged and reports thinkingStatus 'unsupported'.
126
+ this.thinkingMode = normalizeThinkingMode(options.thinkingMode, 'CLIPipeProvider');
129
127
  this.command = options.command;
130
128
  this.args = options.args || [];
131
129
  this.cwd = options.cwd || undefined;
@@ -184,7 +182,7 @@ class CLIPipeProvider {
184
182
  * failure for a genuinely tool-incapable model lives in the tool-mode capability probe.
185
183
  * @param {Message[]} messages - Conversation messages in OpenAI format.
186
184
  * @param {ToolDef[]} [tools=[]] - Caller tools. Honored only in tool mode (`toolProtocol` set).
187
- * @param {Record<string, any>} [options={}] - Unused, except `thinkingMode` (any non-null value throws `ValidationError`: CLIPipe has no thinking switch).
185
+ * @param {Record<string, any>} [options={}] - Unused, except `thinkingMode` (validated; CLIPipe has no thinking switch, so the call runs unchanged with `thinkingStatus:'unsupported'`).
188
186
  * @returns {Promise<GenerateResult>}
189
187
  * @throws {Error} `[CLIPipeProvider] failed to spawn "cmd": ...` — when the command cannot be found or executed.
190
188
  * @throws {Error} `[CLIPipeProvider] process exited with code N: ...` — on non-zero exit.
@@ -192,7 +190,21 @@ class CLIPipeProvider {
192
190
  * @throws {Error} `[CLIPipeProvider] process produced no output` — when stdout is empty.
193
191
  */
194
192
  async generate(messages, tools = [], options = {}) {
195
- if (options && options.thinkingMode != null) throw CLIPIPE_NO_THINKING_MODE();
193
+ const mode = resolveThinkingMode({ label: 'CLIPipeProvider', instanceMode: this.thinkingMode, options: options || {} });
194
+ const result = await this._generateInner(messages, tools, options);
195
+ if (!mode) return result;
196
+ warnThinkingStatus({ owner: this, label: 'CLIPipeProvider', model: result.model || this.command, mode, status: 'unsupported' });
197
+ return { ...result, thinkingStatus: 'unsupported', thinkingApplied: false };
198
+ }
199
+
200
+ /**
201
+ * The pre-thinkingMode generate body: routes to native / emulation / plain-text.
202
+ * @param {Message[]} messages
203
+ * @param {ToolDef[]} tools
204
+ * @param {Record<string, any>} options
205
+ * @returns {Promise<GenerateResult>}
206
+ */
207
+ async _generateInner(messages, tools, options) {
196
208
  if (Array.isArray(tools) && tools.length > 0) {
197
209
  if (this.nativeTools) return this._generateWithMcp(messages, tools, options);
198
210
  if (this.toolProtocol) return this._generateWithTools(messages, tools);