bare-agent 0.41.0 → 0.42.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.41.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.14.0` optional peer for governance) | Apache 2.0
4
+ > v0.42.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.16.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/archive/usage-guide.md)
7
7
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
@@ -80,7 +80,7 @@
80
80
  "cron-parser": "^4.9.0"
81
81
  },
82
82
  "peerDependencies": {
83
- "bareguard": ">=0.9.0 <0.14.0",
83
+ "bareguard": ">=0.9.0 <0.16.0",
84
84
  "better-sqlite3": ">=9.0.0"
85
85
  },
86
86
  "peerDependenciesMeta": {
@@ -101,7 +101,7 @@
101
101
  },
102
102
  "devDependencies": {
103
103
  "@types/node": "^22.19.19",
104
- "bareguard": ">=0.9.0 <0.14.0",
104
+ "bareguard": ">=0.9.0 <0.16.0",
105
105
  "litectx": "^0.26.0",
106
106
  "typescript": "^5.7.0"
107
107
  }
package/src/errors.d.ts CHANGED
@@ -21,6 +21,10 @@ export type ProviderErrorOptions = {
21
21
  * - Raw response body.
22
22
  */
23
23
  body?: any;
24
+ /**
25
+ * - Override the status-derived retryability (for transport-class failures with no status).
26
+ */
27
+ retryable?: boolean | undefined;
24
28
  /**
25
29
  * - Arbitrary structured context.
26
30
  */
@@ -50,6 +54,7 @@ export type HaltErrorOptions = {
50
54
  * @typedef {object} ProviderErrorOptions
51
55
  * @property {number} [status] - HTTP status from the provider.
52
56
  * @property {any} [body] - Raw response body.
57
+ * @property {boolean} [retryable] - Override the status-derived retryability (for transport-class failures with no status).
53
58
  * @property {Record<string, any>} [context] - Arbitrary structured context.
54
59
  */
55
60
  /**
@@ -73,7 +78,7 @@ export class ProviderError extends BareAgentError {
73
78
  * @param {string} message
74
79
  * @param {ProviderErrorOptions} [options]
75
80
  */
76
- constructor(message: string, { status, body, context }?: ProviderErrorOptions);
81
+ constructor(message: string, { status, body, retryable, context }?: ProviderErrorOptions);
77
82
  status: number | undefined;
78
83
  body: any;
79
84
  }
package/src/errors.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * @typedef {object} ProviderErrorOptions
12
12
  * @property {number} [status] - HTTP status from the provider.
13
13
  * @property {any} [body] - Raw response body.
14
+ * @property {boolean} [retryable] - Override the status-derived retryability (for transport-class failures with no status).
14
15
  * @property {Record<string, any>} [context] - Arbitrary structured context.
15
16
  */
16
17
 
@@ -40,9 +41,11 @@ class ProviderError extends BareAgentError {
40
41
  * @param {string} message
41
42
  * @param {ProviderErrorOptions} [options]
42
43
  */
43
- constructor(message, { status, body, context = {} } = {}) {
44
- const retryable = status === 429 || (status != null && status >= 500 && status <= 504);
45
- super(message, { code: 'PROVIDER_ERROR', retryable, context });
44
+ constructor(message, { status, body, retryable, context = {} } = {}) {
45
+ // A transport-class failure (BA-25: socket aborted / closed before the body completed) carries no
46
+ // HTTP status, so the status-derived default can't classify it — an explicit `retryable` overrides.
47
+ const derived = status === 429 || (status != null && status >= 500 && status <= 504);
48
+ super(message, { code: 'PROVIDER_ERROR', retryable: retryable != null ? retryable : derived, context });
46
49
  this.status = status;
47
50
  this.body = body;
48
51
  }
package/src/loop.d.ts CHANGED
@@ -161,7 +161,7 @@ export class Loop {
161
161
  * thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
162
162
  * unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
163
163
  * @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
164
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
164
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
165
165
  * On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
166
166
  * thrown HaltError carried no `rule`), and `msgs` is sanitized so any
167
167
  * dangling assistant `tool_calls` from the halted round are paired with
@@ -196,10 +196,12 @@ export class Loop {
196
196
  cost: number;
197
197
  error: string | null;
198
198
  stopReason: string | null;
199
+ model: string | null;
199
200
  msgs: Message[];
200
201
  metrics: RunMetrics;
201
202
  temperatureDropped?: boolean;
202
203
  }>;
204
+ _warnedTruncated: boolean | undefined;
203
205
  /**
204
206
  * Health check — validates provider, store, and tools without throwing.
205
207
  * @param {ToolDef[]} [tools=[]] - Tool definitions to validate.
@@ -226,7 +228,7 @@ export class Loop {
226
228
  * @param {string} text - User message.
227
229
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
228
230
  * @param {Record<string, any>} [options={}] - Per-run overrides.
229
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
231
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
230
232
  */
231
233
  chat(text: string, tools?: ToolDef[], options?: Record<string, any>): Promise<{
232
234
  text: string;
@@ -235,6 +237,7 @@ export class Loop {
235
237
  cost: number;
236
238
  error: string | null;
237
239
  stopReason: string | null;
240
+ model: string | null;
238
241
  msgs: Message[];
239
242
  metrics: RunMetrics;
240
243
  temperatureDropped?: boolean;
package/src/loop.js CHANGED
@@ -449,7 +449,7 @@ class Loop {
449
449
  * thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
450
450
  * unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
451
451
  * @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
452
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
452
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
453
453
  * On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
454
454
  * thrown HaltError carried no `rule`), and `msgs` is sanitized so any
455
455
  * dangling assistant `tool_calls` from the halted round are paired with
@@ -543,6 +543,10 @@ class Loop {
543
543
  // says which kind). Stays null until the first round completes, and across a provider error / a
544
544
  // pre-round stop() it holds the last round's value (or null if none ran).
545
545
  let lastStopReason = null;
546
+ // ASK 3 / fwdloop F3: the resolved model id of the most recent completed round (from the provider
547
+ // RESPONSE, not the provider object — a wrapped/fallback provider can lose `.model`). Surfaced on
548
+ // every return so a caller reads which model produced the result without the onLlmResult side channel.
549
+ let lastModel = null;
546
550
  // BA-10: sticky across rounds — true if ANY round's `temperature` was dropped by the model (400,
547
551
  // unsupported/deprecated) and retried without it. Surfaced on the result so an upstream receipt
548
552
  // (recurse's refineLeaf) can report the EFFECTIVE temperature rather than the ignored request.
@@ -579,7 +583,7 @@ class Loop {
579
583
  sealDanglingToolCalls(msgs, `[halted:${stuckTag}]`);
580
584
  this._reportError('stuck', new Error(`tool "${tc.name}" failed ${identicalErrors} times with identical arguments`), { rule: stuckTag, attempts: identicalErrors });
581
585
  this._safeEmit({ type: 'loop:done', data: { text: lastText, stuck: true, rule: stuckTag, cost: totalCost } });
582
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: stuckTag, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
586
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: stuckTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
583
587
  }
584
588
  return null;
585
589
  };
@@ -826,7 +830,7 @@ class Loop {
826
830
  this._reportError('provider', err, { round });
827
831
  if (this.throwOnError) throw err;
828
832
  // BA-5: a mid-run provider failure must not erase the work of the rounds that succeeded.
829
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
833
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
830
834
  }
831
835
 
832
836
  lastUsage = result.usage || lastUsage;
@@ -838,6 +842,7 @@ class Loop {
838
842
  // BA-13: capture this round's neutral stop reason for surfacing on the run's return (every exit
839
843
  // path reads lastStopReason). Non-string / absent ⇒ null (the provider's pre-BA-6 degrade).
840
844
  lastStopReason = typeof result.stopReason === 'string' ? result.stopReason : null;
845
+ if (typeof result.model === 'string' && result.model) lastModel = result.model;
841
846
  if (result.temperatureDropped) temperatureDropped = true;
842
847
  // Publish the latest measured usage to ctx (non-enumerable, fail-open) so a transcript-bound seam —
843
848
  // e.g. F2 stash auto-compaction — can read EXACT provider-counted `inputTokens` to gauge context
@@ -897,6 +902,10 @@ class Loop {
897
902
  // a silent guess stamped as if it were a real rate (BA-21). `pricing` keeps its two values.
898
903
  pricing: roundCost === null ? 'unpriced' : 'priced',
899
904
  rateSource, // 'provider'|'caller'|'tier'|'default'|null
905
+ // ASK 3 (fwdloop F4): carry the round's neutral stop reason on the metering payload so an
906
+ // audit row records a `max_tokens`/`refusal`/etc. terminal WITHOUT awaiting the run result —
907
+ // a cut reasoning round (empty text, no tool call) is otherwise indistinguishable from a refusal.
908
+ stopReason: typeof result.stopReason === 'string' ? result.stopReason : null,
900
909
  durationMs: Date.now() - llmStartedAt,
901
910
  ctx,
902
911
  kind: 'turn',
@@ -918,7 +927,7 @@ class Loop {
918
927
  sealDanglingToolCalls(msgs, `[halted:${session.error}]`);
919
928
  this._reportError('session', new Error(`provider session terminated: ${session.error}`), { rule: session.error, sessionTurns: session.turns ?? null });
920
929
  this._safeEmit({ type: 'loop:done', data: { text: lastText, rule: session.error, sessionTurns: session.turns ?? null, cost: totalCost } });
921
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: session.error, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
930
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: session.error, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
922
931
  }
923
932
 
924
933
  // BA-13: classify this round's terminal signal against the neutral stop-reason vocabulary. BA-6
@@ -964,6 +973,18 @@ class Loop {
964
973
  // `error:null` + `stopReason:'refusal'` would re-breed BA-6 for these legs.
965
974
  const errorTag = terminal === 'truncated' ? 'truncated:max_tokens' : terminal;
966
975
  const dropped = (result.toolCalls || []).length;
976
+ // ASK 3 (fwdloop F4): a `max_tokens`/`length` round has empty text and no tool call, so it reads
977
+ // exactly like a refusal — fwdloop misdiagnosed a cut-mid-think drafter for an hour. Make it LOUD:
978
+ // a dedicated `loop:truncated` event AND one console.warn per Loop (mirrors the temperature-drop /
979
+ // unpriced-round precedent). The awaited result already error-tags it (BA-13); this surfaces it
980
+ // to a stream consumer and to the console without reading the return value.
981
+ if (terminal === 'truncated') {
982
+ this._safeEmit({ type: 'loop:truncated', data: { round, stopReason: lastStopReason, droppedToolCalls: dropped, outputTokens: lastUsage && lastUsage.outputTokens } });
983
+ if (!this._warnedTruncated) {
984
+ this._warnedTruncated = true;
985
+ console.warn(`[Loop] a round stopped at the output cap (stopReason='${lastStopReason}') with no completed tool call — the response was cut off (often reasoning billed as output). Raise maxTokens or lower reasoning effort. Further truncations from this Loop are silent.`);
986
+ }
987
+ }
967
988
  // Seal the transcript with the partial text only. Deliberately NOT the tool_calls: pushing a call
968
989
  // we refuse to execute would orphan it (a tool_call with no tool_result is a wire-invalid
969
990
  // transcript on Anthropic). Empty text pushes nothing — a bare empty assistant turn is also invalid.
@@ -971,7 +992,7 @@ class Loop {
971
992
  msgs.push({ role: 'assistant', content: result.text });
972
993
  }
973
994
  this._safeEmit({ type: 'loop:done', data: { text: lastText, ...(terminal === 'truncated' && { truncated: true }), terminal, stopReason: lastStopReason, droppedToolCalls: dropped, cost: totalCost } });
974
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: errorTag, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
995
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: errorTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
975
996
  }
976
997
 
977
998
  // No tool calls — LLM gave a final text response
@@ -991,7 +1012,7 @@ class Loop {
991
1012
  try { await flush(msgs, ctx); }
992
1013
  catch (err) { if (err instanceof HaltError) throw err; this._reportError('trim-flush', err, { round }); }
993
1014
  }
994
- return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1015
+ return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
995
1016
  }
996
1017
 
997
1018
  // Execute tool calls
@@ -1100,7 +1121,7 @@ class Loop {
1100
1121
  sealDanglingToolCalls(msgs, `[halted:${denyTag}]`);
1101
1122
  this._reportError('denied', new Error(`policy denied ${consecutiveDenials} consecutive tool calls (${tc.name})`), { rule: denyTag, denials: consecutiveDenials });
1102
1123
  this._safeEmit({ type: 'loop:done', data: { text: lastText, denied: true, rule: denyTag, cost: totalCost } });
1103
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1124
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1104
1125
  }
1105
1126
  continue;
1106
1127
  }
@@ -1174,7 +1195,7 @@ class Loop {
1174
1195
  // BA-5: the rule tag survives on `error`; so does the work. A halt is how a bounded attempt is
1175
1196
  // SUPPOSED to end — the caller reads `error` to know it was bounded and `text` to learn from it.
1176
1197
  this._safeEmit({ type: 'loop:done', data: { text: lastText, halted: true, rule, cost: totalCost } });
1177
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1198
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1178
1199
  }
1179
1200
  throw err;
1180
1201
  }
@@ -1204,20 +1225,20 @@ class Loop {
1204
1225
  const rule = err.rule || 'unknown';
1205
1226
  this._reportError('halt', err, { rule, reason: err.decision?.reason ?? null });
1206
1227
  this._safeEmit({ type: 'loop:done', data: { text: lastText, halted: true, rule, cost: totalCost } });
1207
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1228
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1208
1229
  }
1209
1230
  this._reportError('trim-flush', err, { phase: 'stop' });
1210
1231
  }
1211
1232
  }
1212
1233
  this._safeEmit({ type: 'loop:done', data: { text: lastText, stopped: true, cost: totalCost } });
1213
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1234
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1214
1235
  }
1215
1236
 
1216
1237
  // Hard safety limit — should never fire under normal usage; bareguard's
1217
1238
  // limits.maxTurns (or the LLM's natural completion) ends the loop first.
1218
1239
  const warning = `[Loop] hit internal safety limit of ${HARD_ROUND_LIMIT} rounds. Wire bareguard for proper governance — see bare-agent/bareguard.`;
1219
1240
  this._safeEmit({ type: 'loop:done', data: { text: lastText, warning, cost: totalCost } });
1220
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1241
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1221
1242
  }
1222
1243
 
1223
1244
  /**
@@ -1290,7 +1311,7 @@ class Loop {
1290
1311
  * @param {string} text - User message.
1291
1312
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
1292
1313
  * @param {Record<string, any>} [options={}] - Per-run overrides.
1293
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
1314
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
1294
1315
  */
1295
1316
  async chat(text, tools = [], options = {}) {
1296
1317
  this._history.push({ role: 'user', content: text });
@@ -5,7 +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, applyRequestBounds } = require('./provider-http');
8
+ const { resolveTimeoutMs, applyRequestBounds, guardResponseSettles } = require('./provider-http');
9
9
  const { hasUsageSignal } = require('./provider-usage');
10
10
 
11
11
  // BA-24: the raw Anthropic usage field names. Presence of any (even value 0) means the API reported a
@@ -318,8 +318,11 @@ class AnthropicProvider {
318
318
  },
319
319
  }, (res) => {
320
320
  let chunks = '';
321
+ // BA-25: reject (retryable) if the body is cut after headers, so generate() always settles.
322
+ const { markEnded } = guardResponseSettles(res, reject, 'AnthropicProvider');
321
323
  res.on('data', d => chunks += d);
322
324
  res.on('end', () => {
325
+ markEnded();
323
326
  try {
324
327
  const parsed = JSON.parse(chunks);
325
328
  if ((res.statusCode ?? 0) >= 400) {
@@ -5,7 +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, applyRequestBounds } = require('./provider-http');
8
+ const { resolveTimeoutMs, applyRequestBounds, guardResponseSettles } = require('./provider-http');
9
9
  const { hasUsageSignal } = require('./provider-usage');
10
10
 
11
11
  // BA-24: raw Gemini usageMetadata fields. Any present (even 0) ⇒ a usage signal; none ⇒ null.
@@ -215,8 +215,11 @@ class GeminiProvider {
215
215
  },
216
216
  }, (res) => {
217
217
  let chunks = '';
218
+ // BA-25: reject (retryable) if the body is cut after headers, so generate() always settles.
219
+ const { markEnded } = guardResponseSettles(res, reject, 'GeminiProvider');
218
220
  res.on('data', d => (chunks += d));
219
221
  res.on('end', () => {
222
+ markEnded();
220
223
  try {
221
224
  const parsed = JSON.parse(chunks);
222
225
  if ((res.statusCode ?? 0) >= 400) {
@@ -78,3 +78,28 @@ export function applyRequestBounds(req: import("http").ClientRequest, bounds: {
78
78
  timeoutMs?: number;
79
79
  deadlineMs?: number;
80
80
  }, providerName: string): void;
81
+ /**
82
+ * BA-25 — guarantee the response promise SETTLES when the body is cut after headers. The provider
83
+ * `_request` handlers wired only `res 'data'` + `res 'end'` (and `req 'error'`), so a socket the
84
+ * server aborts or closes AFTER sending headers but BEFORE 'end' fired neither resolved nor rejected:
85
+ * 'end' never came, and the BA-18 idle timer can't rescue it because the socket is already dead (no
86
+ * further activity to time out against). The process then drains with an unsettled top-level await.
87
+ *
88
+ * Three terminal response events are the miss: `res 'aborted'` (peer reset mid-body), `res 'error'`
89
+ * (stream error), and `res 'close'` WITHOUT a prior 'end' (clean-looking FIN before the body
90
+ * completed). Each rejects with a RETRYABLE transport-class `ProviderError` (no HTTP status → the
91
+ * status-derived retryability can't classify it, so `retryable:true` is explicit) so a wired
92
+ * `Retry`/one-retry ladder (which keys on `err.retryable === true`) sees it instead of hanging.
93
+ *
94
+ * Returns `markEnded` — the 'end' handler MUST call it (before its own resolve/reject) so a normal
95
+ * 'close' firing after 'end' does not spuriously reject an already-settled promise. Extra rejects
96
+ * after settle are no-ops (Promise semantics), so ordering is safe either way; `markEnded` only
97
+ * suppresses the benign post-'end' 'close'.
98
+ * @param {import('http').IncomingMessage} res
99
+ * @param {(err: Error) => void} reject - the _request Promise's reject
100
+ * @param {string} providerName - for the error message (e.g. 'OpenAIProvider')
101
+ * @returns {{ markEnded: () => void }}
102
+ */
103
+ export function guardResponseSettles(res: import("http").IncomingMessage, reject: (err: Error) => void, providerName: string): {
104
+ markEnded: () => void;
105
+ };
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { TimeoutError, ValidationError } = require('./errors');
3
+ const { TimeoutError, ValidationError, ProviderError } = require('./errors');
4
4
 
5
5
  /**
6
6
  * BA-18 — shared request-timeout helper for the http(s)-based providers (Anthropic, OpenAI,
@@ -123,4 +123,38 @@ function applyRequestBounds(req, bounds, providerName) {
123
123
  applyRequestDeadline(req, (bounds && bounds.deadlineMs) || 0, providerName);
124
124
  }
125
125
 
126
- module.exports = { DEFAULT_TIMEOUT_MS, resolveTimeoutMs, applyRequestTimeout, applyRequestDeadline, applyRequestBounds };
126
+ /**
127
+ * BA-25 — guarantee the response promise SETTLES when the body is cut after headers. The provider
128
+ * `_request` handlers wired only `res 'data'` + `res 'end'` (and `req 'error'`), so a socket the
129
+ * server aborts or closes AFTER sending headers but BEFORE 'end' fired neither resolved nor rejected:
130
+ * 'end' never came, and the BA-18 idle timer can't rescue it because the socket is already dead (no
131
+ * further activity to time out against). The process then drains with an unsettled top-level await.
132
+ *
133
+ * Three terminal response events are the miss: `res 'aborted'` (peer reset mid-body), `res 'error'`
134
+ * (stream error), and `res 'close'` WITHOUT a prior 'end' (clean-looking FIN before the body
135
+ * completed). Each rejects with a RETRYABLE transport-class `ProviderError` (no HTTP status → the
136
+ * status-derived retryability can't classify it, so `retryable:true` is explicit) so a wired
137
+ * `Retry`/one-retry ladder (which keys on `err.retryable === true`) sees it instead of hanging.
138
+ *
139
+ * Returns `markEnded` — the 'end' handler MUST call it (before its own resolve/reject) so a normal
140
+ * 'close' firing after 'end' does not spuriously reject an already-settled promise. Extra rejects
141
+ * after settle are no-ops (Promise semantics), so ordering is safe either way; `markEnded` only
142
+ * suppresses the benign post-'end' 'close'.
143
+ * @param {import('http').IncomingMessage} res
144
+ * @param {(err: Error) => void} reject - the _request Promise's reject
145
+ * @param {string} providerName - for the error message (e.g. 'OpenAIProvider')
146
+ * @returns {{ markEnded: () => void }}
147
+ */
148
+ function guardResponseSettles(res, reject, providerName) {
149
+ let ended = false;
150
+ const fail = (/** @type {string} */ event, /** @type {any} */ cause = null) => reject(new ProviderError(
151
+ `[${providerName}] response stream ${event} before the body completed`,
152
+ { retryable: true, context: { bound: 'transport', event, ...(cause && cause.code && { causeCode: cause.code }) } },
153
+ ));
154
+ res.on('aborted', () => fail('aborted'));
155
+ res.on('error', (e) => fail('error', e));
156
+ res.on('close', () => { if (!ended) fail('close'); });
157
+ return { markEnded: () => { ended = true; } };
158
+ }
159
+
160
+ module.exports = { DEFAULT_TIMEOUT_MS, resolveTimeoutMs, applyRequestTimeout, applyRequestDeadline, applyRequestBounds, guardResponseSettles };
@@ -4,7 +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, applyRequestBounds } = require('./provider-http');
7
+ const { resolveTimeoutMs, applyRequestBounds, guardResponseSettles } = require('./provider-http');
8
8
  const { hasUsageSignal } = require('./provider-usage');
9
9
 
10
10
  // BA-24: raw Ollama usage fields. Any present (even 0) ⇒ a usage signal; none ⇒ null (unpriceable).
@@ -141,8 +141,11 @@ class OllamaProvider {
141
141
  },
142
142
  }, (res) => {
143
143
  let chunks = '';
144
+ // BA-25: reject (retryable) if the body is cut after headers, so generate() always settles.
145
+ const { markEnded } = guardResponseSettles(res, reject, 'OllamaProvider');
144
146
  res.on('data', d => chunks += d);
145
147
  res.on('end', () => {
148
+ markEnded();
146
149
  try {
147
150
  const parsed = JSON.parse(chunks);
148
151
  if ((res.statusCode ?? 0) >= 400) {
@@ -30,6 +30,14 @@ export type OpenAIOptions = {
30
30
  * `generate(..., { deadlineMs })`.
31
31
  */
32
32
  deadlineMs?: number | undefined;
33
+ /**
34
+ * - BA-24 (fwdloop): send the legacy `max_tokens` request
35
+ * key instead of `max_completion_tokens`. The default is `max_completion_tokens` because current
36
+ * OpenAI GPT-5 models 400 on `max_tokens` ("Unsupported parameter … Use 'max_completion_tokens'").
37
+ * Set `true` for an OpenAI-compatible server that only understands the legacy key (e.g. some
38
+ * self-hosted / proxy endpoints). No model-name sniffing — the caller declares the dialect.
39
+ */
40
+ legacyMaxTokens?: boolean | undefined;
33
41
  };
34
42
  /**
35
43
  * @typedef {object} OpenAIOptions
@@ -51,6 +59,11 @@ export type OpenAIOptions = {
51
59
  * rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`,
52
60
  * `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via
53
61
  * `generate(..., { deadlineMs })`.
62
+ * @property {boolean} [legacyMaxTokens=false] - BA-24 (fwdloop): send the legacy `max_tokens` request
63
+ * key instead of `max_completion_tokens`. The default is `max_completion_tokens` because current
64
+ * OpenAI GPT-5 models 400 on `max_tokens` ("Unsupported parameter … Use 'max_completion_tokens'").
65
+ * Set `true` for an OpenAI-compatible server that only understands the legacy key (e.g. some
66
+ * self-hosted / proxy endpoints). No model-name sniffing — the caller declares the dialect.
54
67
  */
55
68
  export class OpenAIProvider {
56
69
  /**
@@ -63,11 +76,12 @@ export class OpenAIProvider {
63
76
  exposeErrorBody: boolean;
64
77
  timeoutMs: number | undefined;
65
78
  deadlineMs: number | undefined;
79
+ legacyMaxTokens: boolean;
66
80
  /**
67
81
  * Generate a response from the OpenAI API.
68
82
  * @param {Message[]} messages - Conversation messages.
69
83
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
70
- * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19).
84
+ * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19; toolChoice — `'auto'` | `'required'` | `{ name }`, forwarded as OpenAI `tool_choice`, applied only when `tools` are present).
71
85
  * @returns {Promise<GenerateResult>}
72
86
  * @throws {Error} `[OpenAIProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
73
87
  */
@@ -5,7 +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, applyRequestBounds } = require('./provider-http');
8
+ const { resolveTimeoutMs, applyRequestBounds, guardResponseSettles } = require('./provider-http');
9
9
  const { hasUsageSignal } = require('./provider-usage');
10
10
 
11
11
  // BA-24: raw OpenAI usage fields. Any present (even 0) ⇒ a usage signal; none ⇒ null (unpriceable).
@@ -16,6 +16,23 @@ const OPENAI_USAGE_KEYS = ['prompt_tokens', 'completion_tokens', 'prompt_tokens_
16
16
  /** @typedef {import('../types').ToolCall} ToolCall */
17
17
  /** @typedef {import('../types').GenerateResult} GenerateResult */
18
18
 
19
+ /**
20
+ * Map a neutral `toolChoice` option to OpenAI's `tool_choice` wire shape (Ask 4, fwdloop).
21
+ * `'auto'`/`'required'` pass through; `{ name }` becomes `{ type:'function', function:{ name } }`.
22
+ * `null`/`undefined` ⇒ omit the field (the API default `auto`). An unrecognized shape throws — a
23
+ * silently-dropped force would read as "the model chose not to call", the exact confusion Ask 3 fixes.
24
+ * @param {undefined|null|'auto'|'required'|{name: string}} choice
25
+ * @returns {undefined|'auto'|'required'|{type:'function', function:{name:string}}}
26
+ */
27
+ function toOpenAIToolChoice(choice) {
28
+ if (choice == null) return undefined;
29
+ if (choice === 'auto' || choice === 'required') return choice;
30
+ if (typeof choice === 'object' && typeof choice.name === 'string' && choice.name) {
31
+ return { type: 'function', function: { name: choice.name } };
32
+ }
33
+ throw new ProviderError(`[OpenAIProvider] invalid toolChoice: expected 'auto', 'required', or { name }, got ${JSON.stringify(choice)}`);
34
+ }
35
+
19
36
  /** @param {string} hostname @returns {boolean} */
20
37
  function isLoopbackHost(hostname) {
21
38
  const h = hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets
@@ -42,6 +59,11 @@ function isLoopbackHost(hostname) {
42
59
  * rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`,
43
60
  * `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via
44
61
  * `generate(..., { deadlineMs })`.
62
+ * @property {boolean} [legacyMaxTokens=false] - BA-24 (fwdloop): send the legacy `max_tokens` request
63
+ * key instead of `max_completion_tokens`. The default is `max_completion_tokens` because current
64
+ * OpenAI GPT-5 models 400 on `max_tokens` ("Unsupported parameter … Use 'max_completion_tokens'").
65
+ * Set `true` for an OpenAI-compatible server that only understands the legacy key (e.g. some
66
+ * self-hosted / proxy endpoints). No model-name sniffing — the caller declares the dialect.
45
67
  */
46
68
 
47
69
  class OpenAIProvider {
@@ -57,29 +79,40 @@ class OpenAIProvider {
57
79
  this.timeoutMs = options.timeoutMs;
58
80
  // BA-19: total call-duration deadline (ms). Resolved at call time (default 0 = disabled).
59
81
  this.deadlineMs = options.deadlineMs;
82
+ // BA-24 (fwdloop): use the legacy `max_tokens` key. Default false ⇒ `max_completion_tokens` (GPT-5-safe).
83
+ this.legacyMaxTokens = options.legacyMaxTokens === true;
60
84
  }
61
85
 
62
86
  /**
63
87
  * Generate a response from the OpenAI API.
64
88
  * @param {Message[]} messages - Conversation messages.
65
89
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
66
- * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19).
90
+ * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens, timeoutMs — a per-call override of the constructor's `timeoutMs`, see BA-18; deadlineMs — a per-call override of the constructor's `deadlineMs`, see BA-19; toolChoice — `'auto'` | `'required'` | `{ name }`, forwarded as OpenAI `tool_choice`, applied only when `tools` are present).
67
91
  * @returns {Promise<GenerateResult>}
68
92
  * @throws {Error} `[OpenAIProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
69
93
  */
70
94
  async generate(messages, tools = [], options = {}) {
95
+ // BA-24 (fwdloop): GPT-5 models 400 on `max_tokens` and want `max_completion_tokens`; the legacy
96
+ // key stays reachable via the constructor's `legacyMaxTokens` for compat servers. No model sniffing.
97
+ const maxTokensKey = this.legacyMaxTokens ? 'max_tokens' : 'max_completion_tokens';
71
98
  /** @type {Record<string, any>} */
72
99
  const body = {
73
100
  model: this.model,
74
101
  messages,
75
102
  ...(options.temperature != null && { temperature: options.temperature }),
76
- ...(options.maxTokens && { max_tokens: options.maxTokens }),
103
+ ...(options.maxTokens && { [maxTokensKey]: options.maxTokens }),
77
104
  };
105
+ // Ask 4 (fwdloop): validate the toolChoice SHAPE unconditionally so an invalid value ALWAYS throws
106
+ // (a silently-dropped force is the exact confusion this surfaces) — even when tools happen to be
107
+ // empty. Attach it only when tools are present: OpenAI 400s on a tool_choice with no tools, so a
108
+ // valid choice with nothing to force is dropped (documented), while absent ⇒ the API default 'auto'.
109
+ const toolChoice = toOpenAIToolChoice(options.toolChoice);
78
110
  if (tools.length > 0) {
79
111
  body.tools = tools.map(t => ({
80
112
  type: 'function',
81
113
  function: { name: t.name, description: t.description, parameters: t.parameters },
82
114
  }));
115
+ if (toolChoice != null) body.tool_choice = toolChoice;
83
116
  }
84
117
 
85
118
  // BA-10: newer models (o1/gpt-5-class) reject a non-default `temperature` with a 400 — drop it and
@@ -178,8 +211,11 @@ class OpenAIProvider {
178
211
  },
179
212
  }, (res) => {
180
213
  let chunks = '';
214
+ // BA-25: reject (retryable) if the body is cut after headers, so generate() always settles.
215
+ const { markEnded } = guardResponseSettles(res, reject, 'OpenAIProvider');
181
216
  res.on('data', d => chunks += d);
182
217
  res.on('end', () => {
218
+ markEnded();
183
219
  try {
184
220
  const parsed = JSON.parse(chunks);
185
221
  if ((res.statusCode ?? 0) >= 400) {