bare-agent 0.33.0 → 0.34.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.
@@ -1,7 +1,7 @@
1
1
  # bareagent — Integration Guide
2
2
 
3
3
  > For AI assistants and developers wiring bareagent into a project.
4
- > v0.33.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
4
+ > v0.34.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
5
5
  >
6
6
  > Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)
7
7
 
@@ -817,11 +817,11 @@ new CLIPipe({ command: 'claude', args: ['-p', '--model', 'sonnet'], toolProtocol
817
817
  **CLIPipe NATIVE tool mode (BA-16, `toolProtocol:'claude-mcp'`).** The claude CLI has a real tool channel; native mode uses it instead of emulating one. The CLI runs its OWN multi-turn session per `generate()` call and executes your `tools` natively over an MCP bridge that calls back into your in-process `execute` closures. Because the CLI owns the inner cycle, the Loop's per-round machinery cannot run — so the governance you'd wire on `Loop` moves to the **provider**, at the one seam every tool call crosses:
818
818
  - `policy` — the SAME `(tool, args, ctx) => true|string` chokepoint as `Loop({policy})`, so a wired `wireGate(gate).policy` writes **audit rows of identical shape, zero gate changes**. A deny is a tool result (advisory); the handler never runs. **Required here** — a `Loop({policy})` in native mode would be a fence that is silently not there, so the Loop throws.
819
819
  - `maxConsecutiveDenials` (3) / `maxIdenticalToolErrors` (3) — BA-11/BA-12 guards at the bridge, same narrowest triggers; end the session `denied:<tool>` / `stuck:<tool>`.
820
- - `maxTurns` — maps to the CLI's `--max-turns`; the bound stop is `error:'max_turns'`, never a silent success.
821
- - `onTurn` — streams each completed turn's usage (four cache tiers, `costUsd:null` the CLI prices the session) as it arrives, then one closing `kind:'session'` event with the authoritative total. Shape mirrors `onLlmResult`, so `wireGate(gate).onLlmResult` drops in; when wired the Loop skips its own forward (billed once, never starved).
820
+ - `maxTurns` — a bound on **assistant/LLM turns**, the SAME unit as the Loop path, so one number means one thing on both surfaces (BA-17). NOT a tool-call count: one turn can fire a dozen parallel tool calls and still be one turn (measured: 12 calls across 2 turns, inside `--max-turns 3`). Enforced twice — the CLI's own `--max-turns` stops cleanly at N (and emits the result event that carries the session's real cost), plus a parent-side counter that kills the session if a turn beyond N is ever seen, since that flag is undocumented in `claude --help`. The stop is `error:'max_turns'` + `stopReason:'max_turns'` and **carries the last turn's text forward** — the CLI reports `result:null` on a bounded session, so an unfixed build returned `text:''`.
821
+ - `onTurn` — fires once per **assistant turn** (BA-17: the CLI emits a separate stream event per content *block*, all repeating that message's usage — firing per event inflated a caller's turn axis ~5–7× and its token axis 5.04×), carrying four cache tiers and `costUsd:null` since the CLI prices the session, not the turn. Then one closing `kind:'session'` event carrying the authoritative cost **and the token residual** — a turn's `message.usage` is a snapshot taken at its first block and never revised (a turn that emitted ~816 output tokens reported 2), so the closing event makes the streamed tiers add up to exactly the CLI's own session total. Shape mirrors `onLlmResult`, so `wireGate(gate).onLlmResult` drops in; when wired the Loop skips its own forward (billed once, never starved).
822
822
  - `sessionTimeout` (600s) / `bridgeTimeoutMs` (120s) — whole-session and per-handler ceilings.
823
823
 
824
- `GenerateResult.session` (`{turns, toolCalls, error, usageReported}`) carries what really happened; `metrics.sessionTurns` reports the real turn count so a 14-turn session never reads as one round. A terminal the CLI detects inside the session (bound, guard, or a **broken tool bridge** — a dead bridge still ends `subtype:'success'`, so it is caught parent-side by attempted-vs-served tool calls) surfaces as the run's `error`, never a laundered clean finish. `assemble`/`trim`/`cacheMessages` and a Loop-level `policy` all THROW at construction in native mode (no silently-dead knobs — the CLI owns the transcript). The bridge is a unix socket (0600 in a 0700 dir), never a listening port. Claude-only for now; the CLI-specific parts live in `src/provider-clipipe-mcp.js` + `src/mcp-bridge-stub.js` behind the same seam as emulation.
824
+ `GenerateResult.session` (`{turns, toolCalls, error, usageReported}`) carries what really happened; `metrics.sessionTurns` reports the real turn count — assistant *messages*, not stream events — so a 14-turn session never reads as one round, and a 2-turn session never reads as 14. A terminal the CLI detects inside the session (bound, guard, or a **broken tool bridge** — a dead bridge still ends `subtype:'success'`, so it is caught parent-side by attempted-vs-served tool calls) surfaces as the run's `error`, never a laundered clean finish. `assemble`/`trim`/`cacheMessages` and a Loop-level `policy` all THROW at construction in native mode (no silently-dead knobs — the CLI owns the transcript). The bridge is a unix socket (0600 in a 0700 dir), never a listening port. Claude-only for now; the CLI-specific parts live in `src/provider-clipipe-mcp.js` + `src/mcp-bridge-stub.js` behind the same seam as emulation.
825
825
 
826
826
  All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model?, costUsd? }`. The optional `model` (v0.16.1+) is the id the response was produced by — Loop prefers it over `provider.model` for cost accounting. By default CLIPipe returns `toolCalls: []` and zero usage (CLI tools don't report tokens) and omits `model`. **Structured output (v0.26.0+):** set `parse: 'claude-json'` (a preset for `claude -p --output-format json`) — or a `(stdout) => Partial<GenerateResult>` function for any other CLI — and CLIPipe maps the CLI's JSON envelope onto real `usage`, `model`, and `costUsd`, throwing `ProviderError` on a malformed/error envelope (never a silent raw-text fall-back). `costUsd` (optional `GenerateResult` field) is an **authoritative** per-call price the provider reports itself; when finite the Loop prefers it over the internal rate-table `estimateCost`, so a CLI-piped run enforces a bareguard USD cap with no local pricing table (a `0` counts as priced, distinct from null/unpriced). `toolCalls` stays `[]` regardless (CLIPipe is tool-free).
827
827
 
@@ -829,6 +829,8 @@ All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model?, cos
829
829
 
830
830
  **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.
831
831
 
832
+ **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'`, `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.
833
+
832
834
  **Plaintext-key warning (Unreleased):** the OpenAI provider's `baseUrl` accepts `http://` (for local/OpenAI-compatible endpoints), but a `Bearer` key sent over plaintext http to a **non-loopback** host is exposed on the wire. The provider now warns once when that happens. Loopback hosts (`localhost`/`127.0.0.0/8`/`::1` — local proxies, Ollama-style endpoints) stay silent, since that's the legitimate keyless-local case. The header is **not** stripped (some local proxies want a key), so use `https` for any remote endpoint, or drop `apiKey` when the local endpoint needs none.
833
835
 
834
836
  **Cost estimation:** Loop automatically estimates USD cost per run based on model and token usage. The `cost` field appears in every `loop.run()` result and in `loop:done` stream events. Pricing covers OpenAI and Anthropic models; unknown models use a default average. To adjust rates, edit `COST_PER_1K` at the top of `src/loop.js`. The model is resolved as `result.model || provider.model` (v0.16.1+) — providers now echo the model in their `generate()` result, so cost accounting holds even when `provider.model` is absent or varies per response, e.g. behind `FallbackProvider` or `CircuitBreaker.wrapProvider` (the wrapper also preserves `model`/`name` passthrough props). Wire `onLlmResult` (via `wireGate`) and a `budget.maxCostUsd` cap then halts on token-heavy workloads too.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
@@ -32,6 +32,10 @@ export type AnthropicOptions = {
32
32
  * - 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).
33
33
  */
34
34
  exposeErrorBody?: boolean | undefined;
35
+ /**
36
+ * - 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'`) 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 })`.
37
+ */
38
+ timeoutMs?: number | undefined;
35
39
  };
36
40
  /** @typedef {import('../types').Message} Message */
37
41
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -47,6 +51,7 @@ export type AnthropicOptions = {
47
51
  *
48
52
  * **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.
49
53
  * @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).
54
+ * @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'`) 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 })`.
50
55
  */
51
56
  export class AnthropicProvider {
52
57
  /**
@@ -61,11 +66,12 @@ export class AnthropicProvider {
61
66
  cacheMessages: boolean;
62
67
  thinking: any;
63
68
  exposeErrorBody: boolean;
69
+ timeoutMs: number | undefined;
64
70
  /**
65
71
  * Generate a response from the Anthropic API.
66
72
  * @param {Message[]} messages - Conversation messages (OpenAI format, auto-converted).
67
73
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
68
- * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, system).
74
+ * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, system, timeoutMs — a per-call override of the constructor's `timeoutMs`; see BA-18).
69
75
  * @returns {Promise<GenerateResult>}
70
76
  * @throws {Error} `[AnthropicProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
71
77
  */
@@ -94,8 +100,9 @@ export class AnthropicProvider {
94
100
  _toAnthropicMessage(msg: Message): any;
95
101
  /**
96
102
  * @param {Record<string, any>} body
103
+ * @param {number} [timeoutMs=0] - Idle-socket timeout (ms); 0 disables. See BA-18 / provider-http.
97
104
  * @returns {Promise<any>}
98
105
  */
99
- _request(body: Record<string, any>): Promise<any>;
106
+ _request(body: Record<string, any>, timeoutMs?: number): Promise<any>;
100
107
  _warnedInsecure: boolean | undefined;
101
108
  }
@@ -5,6 +5,7 @@ const http = require('http');
5
5
  const { ProviderError } = require('./errors');
6
6
  const { requestWithTemperatureFallback } = require('./provider-temperature');
7
7
  const { normalizeStopReason } = require('./provider-stop-reason');
8
+ const { resolveTimeoutMs, applyRequestTimeout } = require('./provider-http');
8
9
 
9
10
  /** @param {string} hostname @returns {boolean} */
10
11
  function isLoopbackHost(hostname) {
@@ -27,6 +28,7 @@ function isLoopbackHost(hostname) {
27
28
  *
28
29
  * **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.
29
30
  * @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).
31
+ * @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'`) 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 })`.
30
32
  */
31
33
 
32
34
  class AnthropicProvider {
@@ -52,13 +54,15 @@ class AnthropicProvider {
52
54
  this.thinking = options.thinking != null ? options.thinking : null;
53
55
  // See OpenAIProvider: attach full upstream body to err.body only on opt-in.
54
56
  this.exposeErrorBody = options.exposeErrorBody === true;
57
+ // BA-18: request/idle timeout (ms). Resolved at call time (default 600000; 0/Infinity disable).
58
+ this.timeoutMs = options.timeoutMs;
55
59
  }
56
60
 
57
61
  /**
58
62
  * Generate a response from the Anthropic API.
59
63
  * @param {Message[]} messages - Conversation messages (OpenAI format, auto-converted).
60
64
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
61
- * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, system).
65
+ * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, system, timeoutMs — a per-call override of the constructor's `timeoutMs`; see BA-18).
62
66
  * @returns {Promise<GenerateResult>}
63
67
  * @throws {Error} `[AnthropicProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
64
68
  */
@@ -142,8 +146,9 @@ class AnthropicProvider {
142
146
  // BA-10: some models (e.g. claude-sonnet-5) reject a non-default `temperature` with a 400 — drop it
143
147
  // and retry once rather than let the whole call fail. `temperatureDropped` flows back so an upstream
144
148
  // receipt (recurse's refineLeaf) can report the effective temperature, not the one the model ignored.
149
+ const timeoutMs = resolveTimeoutMs(this.timeoutMs, options.timeoutMs);
145
150
  const { data, temperatureDropped } = await requestWithTemperatureFallback({
146
- request: () => this._request(body),
151
+ request: () => this._request(body, timeoutMs),
147
152
  hadTemperature: () => body.temperature != null,
148
153
  stripTemperature: () => { delete body.temperature; },
149
154
  warnOnce: () => this._warnTemperatureDropped(),
@@ -275,9 +280,10 @@ class AnthropicProvider {
275
280
 
276
281
  /**
277
282
  * @param {Record<string, any>} body
283
+ * @param {number} [timeoutMs=0] - Idle-socket timeout (ms); 0 disables. See BA-18 / provider-http.
278
284
  * @returns {Promise<any>}
279
285
  */
280
- _request(body) {
286
+ _request(body, timeoutMs = 0) {
281
287
  return new Promise((resolve, reject) => {
282
288
  const payload = JSON.stringify(body);
283
289
  const url = new URL(this.baseUrl + '/messages');
@@ -314,6 +320,7 @@ class AnthropicProvider {
314
320
  }
315
321
  });
316
322
  });
323
+ applyRequestTimeout(req, timeoutMs, 'AnthropicProvider');
317
324
  req.on('error', reject);
318
325
  req.write(payload);
319
326
  req.end();
@@ -89,6 +89,21 @@ export function resolveSessionError(facts: {
89
89
  /**
90
90
  * Line-oriented processor for the CLI's `stream-json` stdout.
91
91
  *
92
+ * ONE TURN IS ONE ASSISTANT MESSAGE, NOT ONE EVENT (BA-17). Measured on the real wire: the CLI
93
+ * emits a SEPARATE `assistant` event per content BLOCK of the same message, and every one of them
94
+ * repeats that message's `usage` verbatim — one 13-block message arrived as 13 events carrying the
95
+ * same numbers. Treating each event as a turn is wrong on both axes a caller meters:
96
+ * - the TURN axis — a caller whose attempt bound is an LLM-turn count sees 14 "turns" for 2 real
97
+ * ones (measured 7×; 4.4× on the adopter's failing run), so its net guillotines
98
+ * the session at a fraction of the allowance it advertised;
99
+ * - the TOKEN axis — the same message's usage is added once per block (measured 5.04× inflated
100
+ * against the CLI's own session total), so a budget cap fires early on tokens
101
+ * that were never spent.
102
+ * So a RUN of consecutive events sharing `message.id` is ONE turn: usage is recorded once and one
103
+ * `onTurn` fires. Adjacent-run dedup, not a Set — an id that somehow recurred later must still count
104
+ * as a new turn (dropping a real turn is the failure that matters). An event with NO id degrades to
105
+ * one-turn-per-event: the pre-BA-17 behaviour, never a collapse of the whole session into one turn.
106
+ *
92
107
  * FRAMING IS SYNCHRONOUS. The obvious shape — an `async` stdout handler that `await`s `onTurn`
93
108
  * inside its parse loop — has a data-corruption bug: while the `await` is suspended, the next
94
109
  * `'data'` event re-enters the handler and mutates the SHARED line buffer, so the suspended parse
@@ -104,18 +119,32 @@ export function resolveSessionError(facts: {
104
119
  * @param {any} o.ctx
105
120
  * @param {number} o.startedAt
106
121
  * @param {(err: Error) => void} o.onHalt - called if a forwarded `onTurn` throws a HaltError.
122
+ * @param {number|null} [o.turnCap] - BA-17 backstop: fires `onLimit` the moment a turn BEYOND this
123
+ * many is observed. Deliberately not `>=`: `--max-turns` is passed to the CLI too, and when it
124
+ * works (measured: it does, and it counts assistant turns) the CLI ends the session ITSELF at the
125
+ * cap and emits its `result` event — which is the only place the authoritative session cost
126
+ * arrives. Killing the session at exactly N would throw that figure away on every bounded run. So
127
+ * this fires ONLY on an overrun, i.e. only if the flag ever stops working — it is undocumented in
128
+ * `claude --help`, so the guarantee cannot rest on it alone.
129
+ * @param {(() => void)} [o.onLimit] - called once when `turnCap` is exceeded.
107
130
  */
108
- export function createSessionStream({ onTurn, ctx, startedAt, onHalt }: {
131
+ export function createSessionStream({ onTurn, ctx, startedAt, onHalt, turnCap, onLimit }: {
109
132
  onTurn: Function | null;
110
133
  ctx: any;
111
134
  startedAt: number;
112
135
  onHalt: (err: Error) => void;
136
+ turnCap?: number | null | undefined;
137
+ onLimit?: (() => void) | undefined;
113
138
  }): {
114
139
  /** Feed a stdout chunk. Pure synchronous framing — never awaits. */
115
140
  feed(chunk: Buffer | string): void;
116
141
  /** Await every queued per-turn forward — call before resolving the session. */
117
142
  flush(): Promise<void>;
118
143
  readonly turns: import("../types").Usage[];
144
+ /** Assistant TURNS observed — including any that carried no usage. */
145
+ readonly turnCount: number;
146
+ /** The last turn's text. The work a bounded/guard-stopped session still did (BA-5). */
147
+ readonly lastText: string;
119
148
  readonly attempted: number;
120
149
  readonly final: any;
121
150
  };
@@ -257,6 +257,13 @@ const SUBTYPE_MAP = {
257
257
  error_during_execution: { stopReason: null, error: 'session_error' },
258
258
  };
259
259
 
260
+ /**
261
+ * Terminals WE impose that ARE stop reasons in the neutral vocabulary. A guard terminal
262
+ * (`denied:`/`stuck:`) is deliberately absent — those are faults, not stop reasons.
263
+ * @type {Record<string, string>}
264
+ */
265
+ const TERMINAL_STOP = { max_turns: 'max_turns' };
266
+
260
267
  /**
261
268
  * @param {string|null|undefined} subtype
262
269
  * @returns {{stopReason: string|null, error: string|null}}
@@ -291,7 +298,15 @@ function classifySubtype(subtype) {
291
298
  */
292
299
  function resolveSessionError(facts) {
293
300
  const fromSubtype = classifySubtype(facts.subtype);
294
- if (facts.terminal) return { stopReason: fromSubtype.stopReason, error: facts.terminal };
301
+ if (facts.terminal) {
302
+ // A terminal WE imposed kills the session before its `result` event, so there is no subtype to
303
+ // read a stop reason from. Where the terminal IS a stop reason in the neutral vocabulary, say so
304
+ // — a consumer branching on `stopReason` must not see `null` for a bound it asked for.
305
+ // Own-property only: same proto-key footgun as SUBTYPE_MAP.
306
+ const named = Object.prototype.hasOwnProperty.call(TERMINAL_STOP, facts.terminal)
307
+ ? TERMINAL_STOP[facts.terminal] : null;
308
+ return { stopReason: named || fromSubtype.stopReason, error: facts.terminal };
309
+ }
295
310
  const bridgeBroken = facts.bridgeDown
296
311
  || (Number.isFinite(facts.attempted) && Number.isFinite(facts.served) && facts.attempted > facts.served);
297
312
  if (bridgeBroken) return { stopReason: fromSubtype.stopReason, error: 'bridge-failed' };
@@ -302,6 +317,21 @@ function resolveSessionError(facts) {
302
317
  /**
303
318
  * Line-oriented processor for the CLI's `stream-json` stdout.
304
319
  *
320
+ * ONE TURN IS ONE ASSISTANT MESSAGE, NOT ONE EVENT (BA-17). Measured on the real wire: the CLI
321
+ * emits a SEPARATE `assistant` event per content BLOCK of the same message, and every one of them
322
+ * repeats that message's `usage` verbatim — one 13-block message arrived as 13 events carrying the
323
+ * same numbers. Treating each event as a turn is wrong on both axes a caller meters:
324
+ * - the TURN axis — a caller whose attempt bound is an LLM-turn count sees 14 "turns" for 2 real
325
+ * ones (measured 7×; 4.4× on the adopter's failing run), so its net guillotines
326
+ * the session at a fraction of the allowance it advertised;
327
+ * - the TOKEN axis — the same message's usage is added once per block (measured 5.04× inflated
328
+ * against the CLI's own session total), so a budget cap fires early on tokens
329
+ * that were never spent.
330
+ * So a RUN of consecutive events sharing `message.id` is ONE turn: usage is recorded once and one
331
+ * `onTurn` fires. Adjacent-run dedup, not a Set — an id that somehow recurred later must still count
332
+ * as a new turn (dropping a real turn is the failure that matters). An event with NO id degrades to
333
+ * one-turn-per-event: the pre-BA-17 behaviour, never a collapse of the whole session into one turn.
334
+ *
305
335
  * FRAMING IS SYNCHRONOUS. The obvious shape — an `async` stdout handler that `await`s `onTurn`
306
336
  * inside its parse loop — has a data-corruption bug: while the `await` is suspended, the next
307
337
  * `'data'` event re-enters the handler and mutates the SHARED line buffer, so the suspended parse
@@ -317,11 +347,28 @@ function resolveSessionError(facts) {
317
347
  * @param {any} o.ctx
318
348
  * @param {number} o.startedAt
319
349
  * @param {(err: Error) => void} o.onHalt - called if a forwarded `onTurn` throws a HaltError.
350
+ * @param {number|null} [o.turnCap] - BA-17 backstop: fires `onLimit` the moment a turn BEYOND this
351
+ * many is observed. Deliberately not `>=`: `--max-turns` is passed to the CLI too, and when it
352
+ * works (measured: it does, and it counts assistant turns) the CLI ends the session ITSELF at the
353
+ * cap and emits its `result` event — which is the only place the authoritative session cost
354
+ * arrives. Killing the session at exactly N would throw that figure away on every bounded run. So
355
+ * this fires ONLY on an overrun, i.e. only if the flag ever stops working — it is undocumented in
356
+ * `claude --help`, so the guarantee cannot rest on it alone.
357
+ * @param {(() => void)} [o.onLimit] - called once when `turnCap` is exceeded.
320
358
  */
321
- function createSessionStream({ onTurn, ctx, startedAt, onHalt }) {
359
+ function createSessionStream({ onTurn, ctx, startedAt, onHalt, turnCap = null, onLimit = () => {} }) {
322
360
  let buf = '';
323
361
  let attempted = 0;
324
362
  let final = null;
363
+ /** Adjacent-run dedup key: the `message.id` of the turn currently being emitted. */
364
+ let turnId = /** @type {string|null} */ (null);
365
+ /** Has the CURRENT turn already contributed its usage? Blocks of one message repeat it. */
366
+ let turnMetered = false;
367
+ let turnCount = 0;
368
+ let limitFired = false;
369
+ /** Text of the current turn, and the last turn that produced any — BA-5 work preservation. */
370
+ let curText = '';
371
+ let lastText = '';
325
372
  /** @type {import('../types').Usage[]} */ const turns = [];
326
373
  /** @type {any[]} */ const queue = [];
327
374
  /** @type {Promise<void>|null} */ let draining = null;
@@ -367,29 +414,59 @@ function createSessionStream({ onTurn, ctx, startedAt, onHalt }) {
367
414
  }
368
415
  }
369
416
 
370
- if (ev.type === 'assistant' && ev.message && ev.message.usage) {
371
- const u = ev.message.usage;
372
- /** @type {import('../types').Usage} */
373
- const usage = {
374
- inputTokens: Number(u.input_tokens) || 0,
375
- outputTokens: Number(u.output_tokens) || 0,
376
- };
377
- // Omit an absent tier rather than emit a synthetic 0 (per the Usage contract).
378
- if (Number.isFinite(u.cache_read_input_tokens)) usage.cacheReadTokens = u.cache_read_input_tokens;
379
- if (Number.isFinite(u.cache_creation_input_tokens)) usage.cacheCreationTokens = u.cache_creation_input_tokens;
380
- turns.push(usage);
381
- // Stream it: a session that dies mid-run must already have surfaced every completed turn's
382
- // spend, or the gate loses it. Queued (not awaited here) so framing cannot race the buffer.
383
- if (onTurn) queue.push({
384
- model: (ev.message && ev.message.model) || null,
385
- provider: 'clipipe',
386
- usage,
387
- costUsd: null, // the CLI prices the SESSION, not the turn explicitly unpriced, never a synthetic 0.
388
- pricing: 'unpriced',
389
- durationMs: Date.now() - startedAt,
390
- ctx, // what a wired gate records spend against same as the Loop's onLlmResult.
391
- kind: 'turn',
392
- });
417
+ if (ev.type === 'assistant' && ev.message) {
418
+ // Is this event the start of a NEW assistant turn, or another block of the current one?
419
+ // No id at all ⇒ its own turn (degrade to the pre-BA-17 shape, never collapse into one).
420
+ const id = (typeof ev.message.id === 'string' && ev.message.id) ? ev.message.id : null;
421
+ if (id === null || id !== turnId) {
422
+ turnId = id;
423
+ turnMetered = false;
424
+ curText = '';
425
+ turnCount++;
426
+ // The overrun backstop. `>` not `>=`, so a CLI that honours --max-turns keeps its clean
427
+ // exit (and with it the only report of the session's real cost).
428
+ if (!limitFired && Number.isFinite(turnCap) && Number(turnCap) > 0 && turnCount > Number(turnCap)) {
429
+ limitFired = true;
430
+ onLimit();
431
+ }
432
+ }
433
+
434
+ // BA-5: keep the turn's own words, so a bound/guard stop still returns the work done. The
435
+ // CLI reports `result: null` on a bounded session — measured — so this is the ONLY source.
436
+ for (const block of (ev.message.content || [])) {
437
+ if (block && block.type === 'text' && typeof block.text === 'string' && block.text) {
438
+ curText += block.text;
439
+ lastText = curText;
440
+ }
441
+ }
442
+
443
+ // Usage rides on EVERY block-event of the message; count it once per turn. Read on any
444
+ // event of the turn (not just the first) — the first block need not be the one carrying it.
445
+ if (ev.message.usage && !turnMetered) {
446
+ turnMetered = true;
447
+ const u = ev.message.usage;
448
+ /** @type {import('../types').Usage} */
449
+ const usage = {
450
+ inputTokens: Number(u.input_tokens) || 0,
451
+ outputTokens: Number(u.output_tokens) || 0,
452
+ };
453
+ // Omit an absent tier rather than emit a synthetic 0 (per the Usage contract).
454
+ if (Number.isFinite(u.cache_read_input_tokens)) usage.cacheReadTokens = u.cache_read_input_tokens;
455
+ if (Number.isFinite(u.cache_creation_input_tokens)) usage.cacheCreationTokens = u.cache_creation_input_tokens;
456
+ turns.push(usage);
457
+ // Stream it: a session that dies mid-run must already have surfaced every completed turn's
458
+ // spend, or the gate loses it. Queued (not awaited here) so framing cannot race the buffer.
459
+ if (onTurn) queue.push({
460
+ model: ev.message.model || null,
461
+ provider: 'clipipe',
462
+ usage,
463
+ costUsd: null, // the CLI prices the SESSION, not the turn — explicitly unpriced, never a synthetic 0.
464
+ pricing: 'unpriced',
465
+ durationMs: Date.now() - startedAt,
466
+ ctx, // what a wired gate records spend against — same as the Loop's onLlmResult.
467
+ kind: 'turn',
468
+ });
469
+ }
393
470
  }
394
471
 
395
472
  if (ev.type === 'result') final = ev;
@@ -399,6 +476,10 @@ function createSessionStream({ onTurn, ctx, startedAt, onHalt }) {
399
476
  /** Await every queued per-turn forward — call before resolving the session. */
400
477
  async flush() { if (draining) await draining; await drain(); },
401
478
  get turns() { return turns; },
479
+ /** Assistant TURNS observed — including any that carried no usage. */
480
+ get turnCount() { return turnCount; },
481
+ /** The last turn's text. The work a bounded/guard-stopped session still did (BA-5). */
482
+ get lastText() { return lastText; },
402
483
  get attempted() { return attempted; },
403
484
  get final() { return final; },
404
485
  };
@@ -452,11 +533,18 @@ function runSession(opts) {
452
533
  const child = spawn(command, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] });
453
534
  let stderr = '', settled = false;
454
535
  /** @type {Error|null} */ let turnHalt = null;
536
+ /** @type {string|null} A terminal WE imposed from the stream (today: the BA-17 turn backstop). */
537
+ let terminal = null;
455
538
  const started = Date.now();
456
539
 
457
540
  /** Kill the session now — a guard tripped or governance halted mid-flight. */
458
541
  const abort = () => { try { child.kill('SIGTERM'); } catch (_) { /* already gone */ } };
459
- const stream = createSessionStream({ onTurn, ctx, startedAt: started, onHalt: (err) => { turnHalt = err; abort(); } });
542
+ const stream = createSessionStream({
543
+ onTurn, ctx, startedAt: started,
544
+ onHalt: (err) => { turnHalt = err; abort(); },
545
+ turnCap: Number.isFinite(maxTurns) ? maxTurns : null,
546
+ onLimit: () => { terminal = 'max_turns'; abort(); },
547
+ });
460
548
 
461
549
  const done = async (extra = {}) => {
462
550
  if (settled) return;
@@ -465,7 +553,11 @@ function runSession(opts) {
465
553
  // Flush queued per-turn forwards before resolving — a session that ends while a forward is
466
554
  // pending must still surface that turn's spend to the gate (F12/F18).
467
555
  try { await stream.flush(); } catch (_) { /* onTurn failures are surfaced in-drain, never fatal */ }
468
- resolve({ turns: stream.turns, final: stream.final, stderr, ms: Date.now() - started, turnHalt, attempted: stream.attempted, ...extra });
556
+ resolve({
557
+ turns: stream.turns, turnCount: stream.turnCount, lastText: stream.lastText,
558
+ final: stream.final, stderr, ms: Date.now() - started, turnHalt, terminal,
559
+ attempted: stream.attempted, ...extra,
560
+ });
469
561
  };
470
562
 
471
563
  const timer = setTimeout(() => {
@@ -68,8 +68,16 @@ export type CLIPipeOptions = {
68
68
  */
69
69
  onTurn?: Function | undefined;
70
70
  /**
71
- * - (native mode) Maps to the CLI's `--max-turns`. The bound stop is NAMED
72
- * (`error_max_turns` `session.error:'max_turns'`), never a silent clean success.
71
+ * - (native mode) Bound on ASSISTANT/LLM TURNS the same unit as the
72
+ * Loop path's turn bound, so a caller's `maxTurns` means one thing on both surfaces (BA-17). NOT a
73
+ * tool-call count: a single turn may issue a dozen parallel tool calls and still be one turn
74
+ * (measured: 12 calls across 2 turns, well inside `--max-turns 3`). Enforced twice on purpose —
75
+ * the CLI's own `--max-turns` stops the session cleanly at N and emits its result event (the only
76
+ * report of the session's real cost), and a parent-side counter kills it if a turn beyond N is
77
+ * ever observed, since that flag is undocumented in `claude --help` and a rename would otherwise
78
+ * silently unbound the session. Either way the stop is NAMED (`session.error:'max_turns'`,
79
+ * `stopReason:'max_turns'`) and carries the last turn's text forward, never a silent clean success
80
+ * and never an empty result.
73
81
  */
74
82
  maxTurns?: number | undefined;
75
83
  /**
@@ -107,65 +115,6 @@ export type CLIPipeOptions = {
107
115
  */
108
116
  probeCapability?: boolean | undefined;
109
117
  };
110
- /** @typedef {import('../types').Message} Message */
111
- /** @typedef {import('../types').ToolDef} ToolDef */
112
- /** @typedef {import('../types').GenerateResult} GenerateResult */
113
- /**
114
- * @typedef {object} CLIPipeOptions
115
- * @property {string} [command] - CLI command to spawn (required).
116
- * @property {string[]} [args=[]] - Arguments to pass to the command.
117
- * @property {string} [cwd] - Working directory for the child process.
118
- * @property {Record<string, string>} [env] - Environment variables for the child process.
119
- * @property {number} [timeout=30000] - Timeout in milliseconds.
120
- * @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.
121
- * @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
122
- * @property {'claude-json'|((stdout: string) => Partial<GenerateResult>)} [parse] - Opt-in structured-output parser for stdout. Default (unset) returns stdout verbatim as `text` with zero usage (no behavior change). `'claude-json'` is a shipped preset for `claude -p --output-format json`: it maps the CLI's result envelope onto `GenerateResult` (text←`result`, usage←`usage.*`, model←first `modelUsage` key, costUsd←`total_cost_usd`) and throws `ProviderError` on malformed JSON or an error envelope (`is_error`/non-success subtype). A function is the CLI-agnostic escape hatch: it receives trimmed stdout and returns a partial `GenerateResult` (merged over defaults); throw to signal a parse failure.
123
- * @property {'claude'|'claude-mcp'} [toolProtocol] - Opt into TOOL MODE. Two modes, and the choice is
124
- * about COST, not capability. `'claude-mcp'` (BA-16, NATIVE — prefer this on the claude CLI): one CLI
125
- * session per call, the caller's `tools` exposed to it as a real MCP server whose handlers call back
126
- * into your own in-process closures. The CLI owns the inner cycle and caches its transcript
127
- * session-side. `'claude'` (v0.32.0, EMULATION): one CLI spawn per round with the whole transcript
128
- * re-rendered and re-sent, parsed back through a JSON envelope. Emulation re-buys the full prefix
129
- * every turn, which the adopter measured at **$0.25–0.55/round** against **~$0.006/turn** native — so
130
- * it is the right instrument only for a CLI with NO MCP support, not a default. NOT claimed for
131
- * native: better output quality (n=2 suggestive evidence exists and is deliberately unminted).
132
- * Native mode sets {@link CLIPipeProvider#ownsCycle}, which makes the Loop REFUSE options it could
133
- * never honor (`assemble`/`trim`/`cacheMessages`, and a Loop-level `policy`) instead of leaving them
134
- * silently dead. See the native-only properties below.
135
- * @property {(tool: string, args: any, ctx?: any) => any} [policy] - (native mode) The gate, same contract as `Loop({policy})`: only `true`
136
- * allows, a string is the deny reason fed back verbatim, a thrown `HaltError` is a clean governance
137
- * exit. REQUIRED here rather than on the Loop, because in native mode no tool call ever reaches the
138
- * Loop — a `Loop({policy})` would be a fence that is silently not there (the Loop throws instead).
139
- * Wiring the same `wireGate(gate).policy` keeps audit rows byte-shape-identical, with zero gate changes.
140
- * @property {Function} [onTurn] - (native mode) Called with `{model, provider, usage, costUsd, pricing,
141
- * durationMs, ctx, kind}` for EACH completed CLI turn as it arrives (`kind:'turn'`, four cache tiers,
142
- * `costUsd:null` — the CLI prices the session, not the turn), then once at session end
143
- * (`kind:'session'`) carrying the authoritative total cost with zero usage. Streaming, never
144
- * sum-at-end: a session that dies mid-run must already have surfaced every completed turn's spend or
145
- * the gate loses all of it. The event shape mirrors `Loop({onLlmResult})`, so `wireGate(gate).onLlmResult`
146
- * drops straight in — and when it is wired the Loop skips its own forward, so nothing is billed twice.
147
- * @property {number} [maxTurns] - (native mode) Maps to the CLI's `--max-turns`. The bound stop is NAMED
148
- * (`error_max_turns` → `session.error:'max_turns'`), never a silent clean success.
149
- * @property {number} [maxConsecutiveDenials=3] - (native mode) BA-11 at the bridge: a single deny stays
150
- * advisory so the model can pivot to an allowed tool; N in a row with no allowed call between ends the
151
- * session with `denied:<tool>`. `0`/`Infinity` disables.
152
- * @property {number} [maxIdenticalToolErrors=3] - (native mode) BA-12 at the bridge: only a BYTE-IDENTICAL
153
- * repeat (name + JSON args) counts, so a model varying its args while recovering is never punished. N in
154
- * a row ends the session with `stuck:<tool>`. `0`/`Infinity` disables.
155
- * @property {number} [sessionTimeout=600000] - (native mode) Wall-clock ceiling for one whole session. The
156
- * 30s `timeout` default is for one-shot text and would kill an agentic session mid-run.
157
- * @property {number} [bridgeTimeoutMs] - (native mode) Ceiling for ONE tool-handler round-trip across the
158
- * bridge. A hung handler becomes an error tool result rather than a hung session (default 120s).
159
- *
160
- * Either mode: a non-empty `tools` array on `generate()` routes to tool mode; an empty one stays
161
- * plain text. With NO `toolProtocol`, `tools` are IGNORED (plain-text, the long-standing behavior —
162
- * a non-tool-calling CLI legitimately sits in a Loop with tools mounted) plus a one-time `console.warn`.
163
- * Emulation additionally requires a capable model (weak ones answer in prose; see `probeCapability`);
164
- * native mode needs no such probe, because the CLI's own tool channel does not depend on the model
165
- * agreeing to fill in a JSON questionnaire. The claude-specific parts of each live in
166
- * `provider-clipipe-tools.js` / `provider-clipipe-mcp.js`, so a second CLI slots in behind the same seams.
167
- * @property {boolean} [probeCapability=true] - (EMULATION tool mode only) On the first tool-mode `generate`, run ONE cheap upfront probe that asks the model to obtain unknowable info via a tool. If it answers in prose instead of emitting a tool_call, throw a loud `ProviderError` naming the model — FAIL FAST rather than silently degrade mid-run (the weak-model failure mode). Behaviour-based, never a model name-list (a roster goes stale, BA-10). The verdict is cached per instance (one probe per provider, not per turn). Set `false` to skip when the caller already knows the model is capable.
168
- */
169
118
  export class CLIPipeProvider {
170
119
  /**
171
120
  * Provider that pipes prompts to a CLI command via stdin and reads stdout.
@@ -43,8 +43,16 @@ const { createBridge, resolveSessionError, runSession } = require('./provider-cl
43
43
  * sum-at-end: a session that dies mid-run must already have surfaced every completed turn's spend or
44
44
  * the gate loses all of it. The event shape mirrors `Loop({onLlmResult})`, so `wireGate(gate).onLlmResult`
45
45
  * drops straight in — and when it is wired the Loop skips its own forward, so nothing is billed twice.
46
- * @property {number} [maxTurns] - (native mode) Maps to the CLI's `--max-turns`. The bound stop is NAMED
47
- * (`error_max_turns` `session.error:'max_turns'`), never a silent clean success.
46
+ * @property {number} [maxTurns] - (native mode) Bound on ASSISTANT/LLM TURNS the same unit as the
47
+ * Loop path's turn bound, so a caller's `maxTurns` means one thing on both surfaces (BA-17). NOT a
48
+ * tool-call count: a single turn may issue a dozen parallel tool calls and still be one turn
49
+ * (measured: 12 calls across 2 turns, well inside `--max-turns 3`). Enforced twice on purpose —
50
+ * the CLI's own `--max-turns` stops the session cleanly at N and emits its result event (the only
51
+ * report of the session's real cost), and a parent-side counter kills it if a turn beyond N is
52
+ * ever observed, since that flag is undocumented in `claude --help` and a rename would otherwise
53
+ * silently unbound the session. Either way the stop is NAMED (`session.error:'max_turns'`,
54
+ * `stopReason:'max_turns'`) and carries the last turn's text forward, never a silent clean success
55
+ * and never an empty result.
48
56
  * @property {number} [maxConsecutiveDenials=3] - (native mode) BA-11 at the bridge: a single deny stays
49
57
  * advisory so the model can pivot to an allowed tool; N in a row with no allowed call between ends the
50
58
  * session with `denied:<tool>`. `0`/`Infinity` disables.
@@ -66,6 +74,31 @@ const { createBridge, resolveSessionError, runSession } = require('./provider-cl
66
74
  * @property {boolean} [probeCapability=true] - (EMULATION tool mode only) On the first tool-mode `generate`, run ONE cheap upfront probe that asks the model to obtain unknowable info via a tool. If it answers in prose instead of emitting a tool_call, throw a loud `ProviderError` naming the model — FAIL FAST rather than silently degrade mid-run (the weak-model failure mode). Behaviour-based, never a model name-list (a roster goes stale, BA-10). The verdict is cached per instance (one probe per provider, not per turn). Set `false` to skip when the caller already knows the model is capable.
67
75
  */
68
76
 
77
+ /**
78
+ * Session total minus what the per-turn events already reported, per tier, floored at 0.
79
+ *
80
+ * Floored because a negative would be a CREDIT to a gate's running total — an under-count that
81
+ * silently widens a budget cap. If the streamed turns ever overshoot the session total, the honest
82
+ * report is "nothing further", never "give some back".
83
+ *
84
+ * @param {import('../types').Usage} total
85
+ * @param {import('../types').Usage[]} streamed
86
+ * @returns {import('../types').Usage}
87
+ */
88
+ function subtractUsage(total, streamed) {
89
+ const sum = (/** @type {keyof import('../types').Usage} */ k) =>
90
+ streamed.reduce((a, t) => a + (Number(t[k]) || 0), 0);
91
+ const at = (/** @type {keyof import('../types').Usage} */ k) =>
92
+ Math.max(0, (Number(total[k]) || 0) - sum(k));
93
+ /** @type {import('../types').Usage} */
94
+ const out = { inputTokens: at('inputTokens'), outputTokens: at('outputTokens') };
95
+ // Only report a cache tier the session actually had — an absent tier stays absent, never a
96
+ // synthetic 0 (the Usage contract).
97
+ if (total.cacheReadTokens !== undefined) out.cacheReadTokens = at('cacheReadTokens');
98
+ if (total.cacheCreationTokens !== undefined) out.cacheCreationTokens = at('cacheCreationTokens');
99
+ return out;
100
+ }
101
+
69
102
  class CLIPipeProvider {
70
103
  /**
71
104
  * Provider that pipes prompts to a CLI command via stdin and reads stdout.
@@ -289,17 +322,25 @@ class CLIPipeProvider {
289
322
  throw new ProviderError(`[CLIPipeProvider] failed to spawn "${this.command}": ${r.spawnError.message}`, /** @type {any} */ ({ status: 0 }));
290
323
  }
291
324
 
292
- /** @type {import('../types').Usage} */
293
- const usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
294
- for (const t of r.turns) {
295
- usage.inputTokens += t.inputTokens || 0;
296
- usage.outputTokens += t.outputTokens || 0;
297
- usage.cacheReadTokens += t.cacheReadTokens || 0;
298
- usage.cacheCreationTokens += t.cacheCreationTokens || 0;
299
- }
325
+ // The result event carries the session's authoritative totals. It has no `model` key — the model
326
+ // id lives under `modelUsage` which is exactly what `mapClaudeMeta` already unpacks for the
327
+ // emulation path, so the native path reuses it rather than re-deriving three fields by hand.
328
+ const meta = r.final ? mapClaudeMeta(r.final) : null;
329
+
330
+ // `result.usage` is the authoritative session total and is preferred when present: it also
331
+ // captures a turn the CLI billed but never emitted as an event (measured — a bounded session's
332
+ // cut-off turn). Summing the per-turn records is the fallback for a session we killed before its
333
+ // result event. Either way the arithmetic is per-TURN, never per block-event (BA-17).
334
+ const usage = (meta && r.final.usage) ? meta.usage : r.turns.reduce((/** @type {any} */ a, t) => ({
335
+ inputTokens: a.inputTokens + (t.inputTokens || 0),
336
+ outputTokens: a.outputTokens + (t.outputTokens || 0),
337
+ cacheReadTokens: a.cacheReadTokens + (t.cacheReadTokens || 0),
338
+ cacheCreationTokens: a.cacheCreationTokens + (t.cacheCreationTokens || 0),
339
+ }), { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 });
300
340
 
301
341
  const { stopReason, error } = resolveSessionError({
302
- terminal: st.terminal,
342
+ // A bridge/guard terminal is more specific than the turn backstop, so it wins the tag.
343
+ terminal: st.terminal || r.terminal,
303
344
  bridgeDown: st.bridgeDown,
304
345
  attempted: r.attempted,
305
346
  served: st.toolCalls,
@@ -307,17 +348,25 @@ class CLIPipeProvider {
307
348
  subtype: r.final && r.final.subtype,
308
349
  });
309
350
 
310
- const costUsd = (r.final && Number.isFinite(r.final.total_cost_usd)) ? r.final.total_cost_usd : null;
311
-
312
- // The authoritative price arrives only at session end (the CLI prices the SESSION, not the turn),
313
- // so when per-turn streaming is wired it gets one closing event carrying the cost with zero
314
- // usage — the tokens were already streamed, and double-counting either axis would be a lie.
351
+ const costUsd = (meta && Number.isFinite(meta.costUsd)) ? /** @type {number} */ (meta.costUsd) : null;
352
+
353
+ // The authoritative figures arrive only at session end the CLI prices the SESSION, not the
354
+ // turn — so when per-turn streaming is wired, one closing event RECONCILES both axes.
355
+ //
356
+ // Money: the whole cost, which no turn reported.
357
+ // Tokens: the RESIDUAL, not zero and not the total. A turn's `message.usage` is a snapshot taken
358
+ // when its first block was emitted and never revised (measured: a turn that emitted ~816 output
359
+ // tokens reported 2, identically on all 13 of its block-events), so the streamed per-turn sum is
360
+ // real but SHORT of the session total. Sending the difference makes a gate's token axis add up
361
+ // to exactly what the CLI itself reports — where sending the total would double-count everything
362
+ // already streamed, and sending zero would leave the axis quietly under-fed.
363
+ const residual = subtractUsage(usage, r.turns);
315
364
  if (this.onTurn) {
316
365
  try {
317
366
  await this.onTurn({
318
- model: (r.final && r.final.model) || null,
367
+ model: (meta && meta.model) || null,
319
368
  provider: 'clipipe',
320
- usage: { inputTokens: 0, outputTokens: 0 },
369
+ usage: residual,
321
370
  costUsd,
322
371
  pricing: costUsd === null ? 'unpriced' : 'priced',
323
372
  durationMs: r.ms,
@@ -329,15 +378,23 @@ class CLIPipeProvider {
329
378
  }
330
379
  }
331
380
 
381
+ // BA-5 on the native path: a bound or a tripped guard is normal termination for a bounded
382
+ // attempt, and the text is the ONLY channel from this attempt to the next. The CLI reports
383
+ // `result: null` when it stops on its own bound (measured), and a session we killed never emits
384
+ // a result at all — so fall back to the last assistant turn's own words rather than ''.
385
+ const finalText = (r.final && typeof r.final.result === 'string' && r.final.result)
386
+ ? r.final.result
387
+ : (r.lastText || '');
388
+
332
389
  /** @type {GenerateResult} */
333
390
  const result = {
334
- text: (r.final && typeof r.final.result === 'string') ? r.final.result : '',
391
+ text: finalText,
335
392
  toolCalls: [],
336
393
  usage,
337
- model: (r.final && r.final.model) || null,
394
+ model: (meta && meta.model) || null,
338
395
  stopReason,
339
396
  session: {
340
- turns: r.turns.length,
397
+ turns: r.turnCount,
341
398
  toolCalls: st.toolCalls,
342
399
  error,
343
400
  // Only true when we ACTUALLY streamed — unwired, the Loop must still forward the total or
@@ -19,6 +19,10 @@ export type GeminiOptions = {
19
19
  * - Attach the full upstream response to `err.body` on HTTP errors (off by default; `err.message` still carries the API error).
20
20
  */
21
21
  exposeErrorBody?: boolean | undefined;
22
+ /**
23
+ * - 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'`) instead of hanging until the OS TCP timeout (~2h). `0`/`Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
24
+ */
25
+ timeoutMs?: number | undefined;
22
26
  };
23
27
  /**
24
28
  * @typedef {object} GeminiOptions
@@ -26,6 +30,7 @@ export type GeminiOptions = {
26
30
  * @property {string} [model='gemini-2.5-flash'] - Model ID.
27
31
  * @property {string} [baseUrl='https://generativelanguage.googleapis.com/v1beta'] - API base (override for proxies; posts to `${baseUrl}/models/${model}:generateContent`).
28
32
  * @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).
33
+ * @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'`) instead of hanging until the OS TCP timeout (~2h). `0`/`Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
29
34
  */
30
35
  /**
31
36
  * Google Gemini provider (native `generateContent` API, NOT the OpenAI-compat endpoint — that endpoint
@@ -41,11 +46,12 @@ export class GeminiProvider {
41
46
  model: string;
42
47
  baseUrl: string;
43
48
  exposeErrorBody: boolean;
49
+ timeoutMs: number | undefined;
44
50
  /**
45
51
  * Generate a response from the Gemini API.
46
52
  * @param {Message[]} messages - Conversation messages (OpenAI format, auto-converted).
47
53
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
48
- * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens).
54
+ * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`; see BA-18).
49
55
  * @returns {Promise<GenerateResult>}
50
56
  * @throws {Error} `[GeminiProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
51
57
  */
@@ -66,8 +72,9 @@ export class GeminiProvider {
66
72
  /**
67
73
  * @param {string} path
68
74
  * @param {Record<string, any>} body
75
+ * @param {number} [timeoutMs=0] - Idle-socket timeout (ms); 0 disables. See BA-18 / provider-http.
69
76
  * @returns {Promise<any>}
70
77
  */
71
- _request(path: string, body: Record<string, any>): Promise<any>;
78
+ _request(path: string, body: Record<string, any>, timeoutMs?: number): Promise<any>;
72
79
  _warnedInsecure: boolean | undefined;
73
80
  }
@@ -5,6 +5,7 @@ const http = require('http');
5
5
  const { ProviderError } = require('./errors');
6
6
  const { requestWithTemperatureFallback } = require('./provider-temperature');
7
7
  const { normalizeStopReason } = require('./provider-stop-reason');
8
+ const { resolveTimeoutMs, applyRequestTimeout } = require('./provider-http');
8
9
 
9
10
  /** @typedef {import('../types').Message} Message */
10
11
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -23,6 +24,7 @@ function isLoopbackHost(hostname) {
23
24
  * @property {string} [model='gemini-2.5-flash'] - Model ID.
24
25
  * @property {string} [baseUrl='https://generativelanguage.googleapis.com/v1beta'] - API base (override for proxies; posts to `${baseUrl}/models/${model}:generateContent`).
25
26
  * @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).
27
+ * @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'`) instead of hanging until the OS TCP timeout (~2h). `0`/`Infinity` disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
26
28
  */
27
29
 
28
30
  /**
@@ -39,13 +41,15 @@ class GeminiProvider {
39
41
  this.model = options.model || 'gemini-2.5-flash';
40
42
  this.baseUrl = options.baseUrl || 'https://generativelanguage.googleapis.com/v1beta';
41
43
  this.exposeErrorBody = options.exposeErrorBody === true;
44
+ // BA-18: request/idle timeout (ms). Resolved at call time (default 600000; 0/Infinity disable).
45
+ this.timeoutMs = options.timeoutMs;
42
46
  }
43
47
 
44
48
  /**
45
49
  * Generate a response from the Gemini API.
46
50
  * @param {Message[]} messages - Conversation messages (OpenAI format, auto-converted).
47
51
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
48
- * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens).
52
+ * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`; see BA-18).
49
53
  * @returns {Promise<GenerateResult>}
50
54
  * @throws {Error} `[GeminiProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
51
55
  */
@@ -107,8 +111,9 @@ class GeminiProvider {
107
111
 
108
112
  // BA-10: graceful degrade if a model rejects a non-default `temperature` (Gemini nests it under
109
113
  // generationConfig). Keyed off the API error text, so dormant on models that accept temperature.
114
+ const timeoutMs = resolveTimeoutMs(this.timeoutMs, options.timeoutMs);
110
115
  const { data, temperatureDropped } = await requestWithTemperatureFallback({
111
- request: () => this._request(`/models/${this.model}:generateContent`, body),
116
+ request: () => this._request(`/models/${this.model}:generateContent`, body, timeoutMs),
112
117
  hadTemperature: () => body.generationConfig?.temperature != null,
113
118
  stripTemperature: () => { if (body.generationConfig) delete body.generationConfig.temperature; },
114
119
  warnOnce: () => this._warnTemperatureDropped(),
@@ -174,9 +179,10 @@ class GeminiProvider {
174
179
  /**
175
180
  * @param {string} path
176
181
  * @param {Record<string, any>} body
182
+ * @param {number} [timeoutMs=0] - Idle-socket timeout (ms); 0 disables. See BA-18 / provider-http.
177
183
  * @returns {Promise<any>}
178
184
  */
179
- _request(path, body) {
185
+ _request(path, body, timeoutMs = 0) {
180
186
  return new Promise((resolve, reject) => {
181
187
  const url = new URL(this.baseUrl + path);
182
188
  const transport = url.protocol === 'https:' ? https : http;
@@ -213,6 +219,7 @@ class GeminiProvider {
213
219
  }
214
220
  });
215
221
  });
222
+ applyRequestTimeout(req, timeoutMs, 'GeminiProvider');
216
223
  req.on('error', reject);
217
224
  req.write(payload);
218
225
  req.end();
@@ -0,0 +1,34 @@
1
+ /**
2
+ * BA-18 — shared request-timeout helper for the http(s)-based providers (Anthropic, OpenAI,
3
+ * Gemini, Ollama). They all build a `http.ClientRequest` with only `req.on('error')` wired, so a
4
+ * socket the server silently dropped — or a response that never starts — was bounded only by the
5
+ * OS TCP timeout (~2h on Linux). That presents to the caller as a hang, not a failure, so every
6
+ * retry/casualty policy above it is inert. This adds a finite, configurable idle bound in one
7
+ * place so the four providers cannot drift.
8
+ */
9
+ export const DEFAULT_TIMEOUT_MS: 600000;
10
+ /**
11
+ * Resolve the effective timeout in ms. A per-call value overrides the instance default, but `null`
12
+ * and `undefined` BOTH mean "inherit" — so a per-call `null` never shadows an instance-level
13
+ * disable (finding-2). The explicit opt-out idiom is `0` or `Infinity` → returns 0 (no bound, the
14
+ * pre-BA-18 behaviour). A NaN / negative / otherwise non-finite value is treated as a caller
15
+ * MISTAKE and falls back to {@link DEFAULT_TIMEOUT_MS}: it must never silently disable the safety
16
+ * bound, which would round optimistically back toward the ~2h hang BA-18 exists to prevent
17
+ * (finding-3; the disable-edge bug class).
18
+ * @param {number|undefined|null} instanceTimeout
19
+ * @param {number|undefined|null} [callTimeout]
20
+ * @returns {number} a finite positive ms bound, or 0 to disable
21
+ */
22
+ export function resolveTimeoutMs(instanceTimeout: number | undefined | null, callTimeout?: number | undefined | null): number;
23
+ /**
24
+ * Bound an in-flight ClientRequest on socket INACTIVITY. On timeout, destroy the request with a
25
+ * retryable {@link TimeoutError} (`code: 'ETIMEDOUT'`) — `DEFAULT_RETRY_ON` classifies that as
26
+ * transient, and the provider's own `req.on('error', reject)` turns it into a rejected promise, so
27
+ * the caller regains control instead of hanging. Idle semantics (via `req.setTimeout`): the timer
28
+ * resets on any socket activity, so a slow-but-streaming response is NOT killed — only a silent or
29
+ * never-answering socket trips it. A `timeoutMs` of 0 is a no-op (bound disabled).
30
+ * @param {import('http').ClientRequest} req
31
+ * @param {number} timeoutMs - resolved bound; 0 disables
32
+ * @param {string} providerName - for the error message (e.g. 'AnthropicProvider')
33
+ */
34
+ export function applyRequestTimeout(req: import("http").ClientRequest, timeoutMs: number, providerName: string): void;
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ const { TimeoutError } = require('./errors');
4
+
5
+ /**
6
+ * BA-18 — shared request-timeout helper for the http(s)-based providers (Anthropic, OpenAI,
7
+ * Gemini, Ollama). They all build a `http.ClientRequest` with only `req.on('error')` wired, so a
8
+ * socket the server silently dropped — or a response that never starts — was bounded only by the
9
+ * OS TCP timeout (~2h on Linux). That presents to the caller as a hang, not a failure, so every
10
+ * retry/casualty policy above it is inert. This adds a finite, configurable idle bound in one
11
+ * place so the four providers cannot drift.
12
+ */
13
+
14
+ // 10 minutes: safely above any single non-streaming completion (a big reasoning response is a few
15
+ // minutes at most), well below the ~2h OS TCP default. These requests are non-streaming, so a legit
16
+ // slow completion can have no socket activity until the whole body arrives (TTFB ≈ generation time)
17
+ // — the default must clear that, not a typical round-trip.
18
+ const DEFAULT_TIMEOUT_MS = 600000;
19
+
20
+ /**
21
+ * Resolve the effective timeout in ms. A per-call value overrides the instance default, but `null`
22
+ * and `undefined` BOTH mean "inherit" — so a per-call `null` never shadows an instance-level
23
+ * disable (finding-2). The explicit opt-out idiom is `0` or `Infinity` → returns 0 (no bound, the
24
+ * pre-BA-18 behaviour). A NaN / negative / otherwise non-finite value is treated as a caller
25
+ * MISTAKE and falls back to {@link DEFAULT_TIMEOUT_MS}: it must never silently disable the safety
26
+ * bound, which would round optimistically back toward the ~2h hang BA-18 exists to prevent
27
+ * (finding-3; the disable-edge bug class).
28
+ * @param {number|undefined|null} instanceTimeout
29
+ * @param {number|undefined|null} [callTimeout]
30
+ * @returns {number} a finite positive ms bound, or 0 to disable
31
+ */
32
+ function resolveTimeoutMs(instanceTimeout, callTimeout) {
33
+ const raw = callTimeout != null ? callTimeout : instanceTimeout; // null/undefined per-call → inherit
34
+ if (raw == null) return DEFAULT_TIMEOUT_MS; // absent on both → finite default
35
+ const n = Number(raw);
36
+ if (n === 0 || n === Infinity) return 0; // the explicit opt-out idiom → no bound
37
+ if (!Number.isFinite(n) || n < 0) return DEFAULT_TIMEOUT_MS; // NaN / negative / garbage → SAFE default, never a silent disable
38
+ return n; // finite positive bound
39
+ }
40
+
41
+ /**
42
+ * Bound an in-flight ClientRequest on socket INACTIVITY. On timeout, destroy the request with a
43
+ * retryable {@link TimeoutError} (`code: 'ETIMEDOUT'`) — `DEFAULT_RETRY_ON` classifies that as
44
+ * transient, and the provider's own `req.on('error', reject)` turns it into a rejected promise, so
45
+ * the caller regains control instead of hanging. Idle semantics (via `req.setTimeout`): the timer
46
+ * resets on any socket activity, so a slow-but-streaming response is NOT killed — only a silent or
47
+ * never-answering socket trips it. A `timeoutMs` of 0 is a no-op (bound disabled).
48
+ * @param {import('http').ClientRequest} req
49
+ * @param {number} timeoutMs - resolved bound; 0 disables
50
+ * @param {string} providerName - for the error message (e.g. 'AnthropicProvider')
51
+ */
52
+ function applyRequestTimeout(req, timeoutMs, providerName) {
53
+ if (!(timeoutMs > 0)) return;
54
+ req.setTimeout(timeoutMs, () => {
55
+ req.destroy(new TimeoutError(`[${providerName}] request timed out after ${timeoutMs}ms of socket inactivity`));
56
+ });
57
+ }
58
+
59
+ module.exports = { DEFAULT_TIMEOUT_MS, resolveTimeoutMs, applyRequestTimeout };
@@ -5,6 +5,10 @@ export type OllamaOptions = {
5
5
  model?: string | undefined;
6
6
  url?: string | undefined;
7
7
  exposeErrorBody?: boolean | undefined;
8
+ /**
9
+ * - 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'`) instead of hanging until the OS TCP timeout. `0`/`Infinity` disables it. Overridable per call via `generate(..., { timeoutMs })`. (A local Ollama that is loading a large model cold can be slow to first byte — raise this or disable it for very large local models.)
10
+ */
11
+ timeoutMs?: number | undefined;
8
12
  };
9
13
  /** @typedef {import('../types').Message} Message */
10
14
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -14,6 +18,7 @@ export type OllamaOptions = {
14
18
  * @property {string} [model='llama3.2']
15
19
  * @property {string} [url='http://localhost:11434']
16
20
  * @property {boolean} [exposeErrorBody=false]
21
+ * @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'`) instead of hanging until the OS TCP timeout. `0`/`Infinity` disables it. Overridable per call via `generate(..., { timeoutMs })`. (A local Ollama that is loading a large model cold can be slow to first byte — raise this or disable it for very large local models.)
17
22
  */
18
23
  export class OllamaProvider {
19
24
  /**
@@ -23,11 +28,12 @@ export class OllamaProvider {
23
28
  model: string;
24
29
  url: string;
25
30
  exposeErrorBody: boolean;
31
+ timeoutMs: number | undefined;
26
32
  /**
27
33
  * Generate a response from a local Ollama instance.
28
34
  * @param {Message[]} messages - Conversation messages.
29
35
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
30
- * @param {Record<string, any>} [options={}] - Options (`temperature`, `maxTokens`).
36
+ * @param {Record<string, any>} [options={}] - Options (`temperature`, `maxTokens`, `timeoutMs` — a per-call override of the constructor's `timeoutMs`; see BA-18).
31
37
  * @returns {Promise<GenerateResult>}
32
38
  * @throws {Error} `[OllamaProvider] ...` — on HTTP errors or invalid JSON response.
33
39
  */
@@ -38,7 +44,8 @@ export class OllamaProvider {
38
44
  /**
39
45
  * @param {string} path
40
46
  * @param {Record<string, any>} body
47
+ * @param {number} [timeoutMs=0] - Idle-socket timeout (ms); 0 disables. See BA-18 / provider-http.
41
48
  * @returns {Promise<any>}
42
49
  */
43
- _request(path: string, body: Record<string, any>): Promise<any>;
50
+ _request(path: string, body: Record<string, any>, timeoutMs?: number): Promise<any>;
44
51
  }
@@ -4,6 +4,7 @@ const http = require('http');
4
4
  const { ProviderError } = require('./errors');
5
5
  const { requestWithTemperatureFallback } = require('./provider-temperature');
6
6
  const { normalizeStopReason } = require('./provider-stop-reason');
7
+ const { resolveTimeoutMs, applyRequestTimeout } = require('./provider-http');
7
8
 
8
9
  /** @typedef {import('../types').Message} Message */
9
10
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -14,6 +15,7 @@ const { normalizeStopReason } = require('./provider-stop-reason');
14
15
  * @property {string} [model='llama3.2']
15
16
  * @property {string} [url='http://localhost:11434']
16
17
  * @property {boolean} [exposeErrorBody=false]
18
+ * @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'`) instead of hanging until the OS TCP timeout. `0`/`Infinity` disables it. Overridable per call via `generate(..., { timeoutMs })`. (A local Ollama that is loading a large model cold can be slow to first byte — raise this or disable it for very large local models.)
17
19
  */
18
20
 
19
21
  class OllamaProvider {
@@ -25,13 +27,15 @@ class OllamaProvider {
25
27
  this.url = options.url || 'http://localhost:11434';
26
28
  // See OpenAIProvider: attach full upstream body to err.body only on opt-in.
27
29
  this.exposeErrorBody = options.exposeErrorBody === true;
30
+ // BA-18: request/idle timeout (ms). Resolved at call time (default 600000; 0/Infinity disable).
31
+ this.timeoutMs = options.timeoutMs;
28
32
  }
29
33
 
30
34
  /**
31
35
  * Generate a response from a local Ollama instance.
32
36
  * @param {Message[]} messages - Conversation messages.
33
37
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
34
- * @param {Record<string, any>} [options={}] - Options (`temperature`, `maxTokens`).
38
+ * @param {Record<string, any>} [options={}] - Options (`temperature`, `maxTokens`, `timeoutMs` — a per-call override of the constructor's `timeoutMs`; see BA-18).
35
39
  * @returns {Promise<GenerateResult>}
36
40
  * @throws {Error} `[OllamaProvider] ...` — on HTTP errors or invalid JSON response.
37
41
  */
@@ -64,8 +68,9 @@ class OllamaProvider {
64
68
 
65
69
  // BA-10: graceful degrade if a model rejects a non-default `temperature` (Ollama nests it under
66
70
  // `options`). Keyed off the API error text, so dormant on models that accept temperature.
71
+ const timeoutMs = resolveTimeoutMs(this.timeoutMs, options.timeoutMs);
67
72
  const { data, temperatureDropped } = await requestWithTemperatureFallback({
68
- request: () => this._request('/api/chat', body),
73
+ request: () => this._request('/api/chat', body, timeoutMs),
69
74
  hadTemperature: () => body.options?.temperature != null,
70
75
  stripTemperature: () => { if (body.options) delete body.options.temperature; },
71
76
  warnOnce: () => this._warnTemperatureDropped(),
@@ -110,9 +115,10 @@ class OllamaProvider {
110
115
  /**
111
116
  * @param {string} path
112
117
  * @param {Record<string, any>} body
118
+ * @param {number} [timeoutMs=0] - Idle-socket timeout (ms); 0 disables. See BA-18 / provider-http.
113
119
  * @returns {Promise<any>}
114
120
  */
115
- _request(path, body) {
121
+ _request(path, body, timeoutMs = 0) {
116
122
  return new Promise((resolve, reject) => {
117
123
  const url = new URL(this.url + path);
118
124
  const payload = JSON.stringify(body);
@@ -141,6 +147,7 @@ class OllamaProvider {
141
147
  }
142
148
  });
143
149
  });
150
+ applyRequestTimeout(req, timeoutMs, 'OllamaProvider');
144
151
  req.on('error', reject);
145
152
  req.write(payload);
146
153
  req.end();
@@ -14,6 +14,13 @@ export type OpenAIOptions = {
14
14
  * debugging only.
15
15
  */
16
16
  exposeErrorBody?: boolean | undefined;
17
+ /**
18
+ * - BA-18: request/idle timeout in ms. Bounds a silent or
19
+ * never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError`
20
+ * (`code: 'ETIMEDOUT'`) instead of hanging until the OS TCP timeout (~2h). `0`/`Infinity`
21
+ * disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
22
+ */
23
+ timeoutMs?: number | undefined;
17
24
  };
18
25
  /**
19
26
  * @typedef {object} OpenAIOptions
@@ -25,6 +32,10 @@ export type OpenAIOptions = {
25
32
  * field in an error payload can't leak through logs that dump the error
26
33
  * object; `err.message` still carries the API's error message. Turn on for
27
34
  * debugging only.
35
+ * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. Bounds a silent or
36
+ * never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError`
37
+ * (`code: 'ETIMEDOUT'`) instead of hanging until the OS TCP timeout (~2h). `0`/`Infinity`
38
+ * disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
28
39
  */
29
40
  export class OpenAIProvider {
30
41
  /**
@@ -35,11 +46,12 @@ export class OpenAIProvider {
35
46
  model: string;
36
47
  baseUrl: string;
37
48
  exposeErrorBody: boolean;
49
+ timeoutMs: number | undefined;
38
50
  /**
39
51
  * Generate a response from the OpenAI API.
40
52
  * @param {Message[]} messages - Conversation messages.
41
53
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
42
- * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens).
54
+ * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`; see BA-18).
43
55
  * @returns {Promise<GenerateResult>}
44
56
  * @throws {Error} `[OpenAIProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
45
57
  */
@@ -60,8 +72,9 @@ export class OpenAIProvider {
60
72
  /**
61
73
  * @param {string} path
62
74
  * @param {Record<string, any>} body
75
+ * @param {number} [timeoutMs=0] - Idle-socket timeout (ms); 0 disables. See BA-18 / provider-http.
63
76
  * @returns {Promise<any>}
64
77
  */
65
- _request(path: string, body: Record<string, any>): Promise<any>;
78
+ _request(path: string, body: Record<string, any>, timeoutMs?: number): Promise<any>;
66
79
  _warnedInsecure: boolean | undefined;
67
80
  }
@@ -5,6 +5,7 @@ const http = require('http');
5
5
  const { ProviderError } = require('./errors');
6
6
  const { requestWithTemperatureFallback } = require('./provider-temperature');
7
7
  const { normalizeStopReason } = require('./provider-stop-reason');
8
+ const { resolveTimeoutMs, applyRequestTimeout } = require('./provider-http');
8
9
 
9
10
  /** @typedef {import('../types').Message} Message */
10
11
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -27,6 +28,10 @@ function isLoopbackHost(hostname) {
27
28
  * field in an error payload can't leak through logs that dump the error
28
29
  * object; `err.message` still carries the API's error message. Turn on for
29
30
  * debugging only.
31
+ * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. Bounds a silent or
32
+ * never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError`
33
+ * (`code: 'ETIMEDOUT'`) instead of hanging until the OS TCP timeout (~2h). `0`/`Infinity`
34
+ * disables it (pre-BA-18 behaviour). Overridable per call via `generate(..., { timeoutMs })`.
30
35
  */
31
36
 
32
37
  class OpenAIProvider {
@@ -38,13 +43,15 @@ class OpenAIProvider {
38
43
  this.model = options.model || 'gpt-4o-mini';
39
44
  this.baseUrl = options.baseUrl || 'https://api.openai.com/v1';
40
45
  this.exposeErrorBody = options.exposeErrorBody === true;
46
+ // BA-18: request/idle timeout (ms). Resolved at call time (default 600000; 0/Infinity disable).
47
+ this.timeoutMs = options.timeoutMs;
41
48
  }
42
49
 
43
50
  /**
44
51
  * Generate a response from the OpenAI API.
45
52
  * @param {Message[]} messages - Conversation messages.
46
53
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
47
- * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens).
54
+ * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`; see BA-18).
48
55
  * @returns {Promise<GenerateResult>}
49
56
  * @throws {Error} `[OpenAIProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
50
57
  */
@@ -65,8 +72,9 @@ class OpenAIProvider {
65
72
 
66
73
  // BA-10: newer models (o1/gpt-5-class) reject a non-default `temperature` with a 400 — drop it and
67
74
  // retry once. `temperatureDropped` flows back so an upstream receipt can report the effective value.
75
+ const timeoutMs = resolveTimeoutMs(this.timeoutMs, options.timeoutMs);
68
76
  const { data, temperatureDropped } = await requestWithTemperatureFallback({
69
- request: () => this._request('/chat/completions', body),
77
+ request: () => this._request('/chat/completions', body, timeoutMs),
70
78
  hadTemperature: () => body.temperature != null,
71
79
  stripTemperature: () => { delete body.temperature; },
72
80
  warnOnce: () => this._warnTemperatureDropped(),
@@ -124,9 +132,10 @@ class OpenAIProvider {
124
132
  /**
125
133
  * @param {string} path
126
134
  * @param {Record<string, any>} body
135
+ * @param {number} [timeoutMs=0] - Idle-socket timeout (ms); 0 disables. See BA-18 / provider-http.
127
136
  * @returns {Promise<any>}
128
137
  */
129
- _request(path, body) {
138
+ _request(path, body, timeoutMs = 0) {
130
139
  return new Promise((resolve, reject) => {
131
140
  const url = new URL(this.baseUrl + path);
132
141
  const transport = url.protocol === 'https:' ? https : http;
@@ -168,6 +177,7 @@ class OpenAIProvider {
168
177
  }
169
178
  });
170
179
  });
180
+ applyRequestTimeout(req, timeoutMs, 'OpenAIProvider');
171
181
  req.on('error', reject);
172
182
  req.write(payload);
173
183
  req.end();