bare-agent 0.41.1 → 0.43.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.1 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.16.0` optional peer for governance) | Apache 2.0
4
+ > v0.43.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.1",
3
+ "version": "0.43.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
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, malformedToolCall?: {name: string|undefined, error: string}, 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,16 @@ 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;
202
+ malformedToolCall?: {
203
+ name: string | undefined;
204
+ error: string;
205
+ };
201
206
  temperatureDropped?: boolean;
202
207
  }>;
208
+ _warnedTruncated: boolean | undefined;
203
209
  /**
204
210
  * Health check — validates provider, store, and tools without throwing.
205
211
  * @param {ToolDef[]} [tools=[]] - Tool definitions to validate.
@@ -226,7 +232,7 @@ export class Loop {
226
232
  * @param {string} text - User message.
227
233
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
228
234
  * @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}>}
235
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string}, temperatureDropped?: boolean}>}
230
236
  */
231
237
  chat(text: string, tools?: ToolDef[], options?: Record<string, any>): Promise<{
232
238
  text: string;
@@ -235,8 +241,13 @@ export class Loop {
235
241
  cost: number;
236
242
  error: string | null;
237
243
  stopReason: string | null;
244
+ model: string | null;
238
245
  msgs: Message[];
239
246
  metrics: RunMetrics;
247
+ malformedToolCall?: {
248
+ name: string | undefined;
249
+ error: string;
250
+ };
240
251
  temperatureDropped?: boolean;
241
252
  }>;
242
253
  /**
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, malformedToolCall?: {name: string|undefined, error: string}, 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,18 @@ 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;
550
+ // BA-27: the most recent round's malformed-tool-call marker, or null. A model can emit a tool call
551
+ // whose arguments are syntactically-broken JSON; the provider returns NO usable tool calls plus this
552
+ // marker (rather than throwing and losing the billed round). Surfaced on the run's return like
553
+ // lastStopReason, so a Loop caller (e.g. the bareloop adopter — which reads run(), not generate())
554
+ // can tell "the model sent a broken call" apart from "the model sent no call at all" — both present
555
+ // as `toolCalls: []`. Reset each completed round; a malformed round always terminates the run (no
556
+ // usable call to continue with), so it can only be non-null on the round that returns.
557
+ let lastMalformedToolCall = null;
546
558
  // BA-10: sticky across rounds — true if ANY round's `temperature` was dropped by the model (400,
547
559
  // unsupported/deprecated) and retried without it. Surfaced on the result so an upstream receipt
548
560
  // (recurse's refineLeaf) can report the EFFECTIVE temperature rather than the ignored request.
@@ -579,7 +591,7 @@ class Loop {
579
591
  sealDanglingToolCalls(msgs, `[halted:${stuckTag}]`);
580
592
  this._reportError('stuck', new Error(`tool "${tc.name}" failed ${identicalErrors} times with identical arguments`), { rule: stuckTag, attempts: identicalErrors });
581
593
  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 }) };
594
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: stuckTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }) };
583
595
  }
584
596
  return null;
585
597
  };
@@ -826,7 +838,7 @@ class Loop {
826
838
  this._reportError('provider', err, { round });
827
839
  if (this.throwOnError) throw err;
828
840
  // 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 }) };
841
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }) };
830
842
  }
831
843
 
832
844
  lastUsage = result.usage || lastUsage;
@@ -838,6 +850,10 @@ class Loop {
838
850
  // BA-13: capture this round's neutral stop reason for surfacing on the run's return (every exit
839
851
  // path reads lastStopReason). Non-string / absent ⇒ null (the provider's pre-BA-6 degrade).
840
852
  lastStopReason = typeof result.stopReason === 'string' ? result.stopReason : null;
853
+ if (typeof result.model === 'string' && result.model) lastModel = result.model;
854
+ // BA-27: capture (and reset) this round's malformed-tool-call marker for surfacing on the return.
855
+ lastMalformedToolCall = (result.malformedToolCall && typeof result.malformedToolCall === 'object')
856
+ ? result.malformedToolCall : null;
841
857
  if (result.temperatureDropped) temperatureDropped = true;
842
858
  // Publish the latest measured usage to ctx (non-enumerable, fail-open) so a transcript-bound seam —
843
859
  // e.g. F2 stash auto-compaction — can read EXACT provider-counted `inputTokens` to gauge context
@@ -897,6 +913,10 @@ class Loop {
897
913
  // a silent guess stamped as if it were a real rate (BA-21). `pricing` keeps its two values.
898
914
  pricing: roundCost === null ? 'unpriced' : 'priced',
899
915
  rateSource, // 'provider'|'caller'|'tier'|'default'|null
916
+ // ASK 3 (fwdloop F4): carry the round's neutral stop reason on the metering payload so an
917
+ // audit row records a `max_tokens`/`refusal`/etc. terminal WITHOUT awaiting the run result —
918
+ // a cut reasoning round (empty text, no tool call) is otherwise indistinguishable from a refusal.
919
+ stopReason: typeof result.stopReason === 'string' ? result.stopReason : null,
900
920
  durationMs: Date.now() - llmStartedAt,
901
921
  ctx,
902
922
  kind: 'turn',
@@ -918,7 +938,7 @@ class Loop {
918
938
  sealDanglingToolCalls(msgs, `[halted:${session.error}]`);
919
939
  this._reportError('session', new Error(`provider session terminated: ${session.error}`), { rule: session.error, sessionTurns: session.turns ?? null });
920
940
  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 }) };
941
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: session.error, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }) };
922
942
  }
923
943
 
924
944
  // BA-13: classify this round's terminal signal against the neutral stop-reason vocabulary. BA-6
@@ -964,6 +984,18 @@ class Loop {
964
984
  // `error:null` + `stopReason:'refusal'` would re-breed BA-6 for these legs.
965
985
  const errorTag = terminal === 'truncated' ? 'truncated:max_tokens' : terminal;
966
986
  const dropped = (result.toolCalls || []).length;
987
+ // ASK 3 (fwdloop F4): a `max_tokens`/`length` round has empty text and no tool call, so it reads
988
+ // exactly like a refusal — fwdloop misdiagnosed a cut-mid-think drafter for an hour. Make it LOUD:
989
+ // a dedicated `loop:truncated` event AND one console.warn per Loop (mirrors the temperature-drop /
990
+ // unpriced-round precedent). The awaited result already error-tags it (BA-13); this surfaces it
991
+ // to a stream consumer and to the console without reading the return value.
992
+ if (terminal === 'truncated') {
993
+ this._safeEmit({ type: 'loop:truncated', data: { round, stopReason: lastStopReason, droppedToolCalls: dropped, outputTokens: lastUsage && lastUsage.outputTokens } });
994
+ if (!this._warnedTruncated) {
995
+ this._warnedTruncated = true;
996
+ 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.`);
997
+ }
998
+ }
967
999
  // Seal the transcript with the partial text only. Deliberately NOT the tool_calls: pushing a call
968
1000
  // we refuse to execute would orphan it (a tool_call with no tool_result is a wire-invalid
969
1001
  // transcript on Anthropic). Empty text pushes nothing — a bare empty assistant turn is also invalid.
@@ -971,7 +1003,7 @@ class Loop {
971
1003
  msgs.push({ role: 'assistant', content: result.text });
972
1004
  }
973
1005
  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 }) };
1006
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: errorTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }) };
975
1007
  }
976
1008
 
977
1009
  // No tool calls — LLM gave a final text response
@@ -991,7 +1023,7 @@ class Loop {
991
1023
  try { await flush(msgs, ctx); }
992
1024
  catch (err) { if (err instanceof HaltError) throw err; this._reportError('trim-flush', err, { round }); }
993
1025
  }
994
- return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1026
+ return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }) };
995
1027
  }
996
1028
 
997
1029
  // Execute tool calls
@@ -1100,7 +1132,7 @@ class Loop {
1100
1132
  sealDanglingToolCalls(msgs, `[halted:${denyTag}]`);
1101
1133
  this._reportError('denied', new Error(`policy denied ${consecutiveDenials} consecutive tool calls (${tc.name})`), { rule: denyTag, denials: consecutiveDenials });
1102
1134
  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 }) };
1135
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }) };
1104
1136
  }
1105
1137
  continue;
1106
1138
  }
@@ -1174,7 +1206,7 @@ class Loop {
1174
1206
  // BA-5: the rule tag survives on `error`; so does the work. A halt is how a bounded attempt is
1175
1207
  // SUPPOSED to end — the caller reads `error` to know it was bounded and `text` to learn from it.
1176
1208
  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 }) };
1209
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }) };
1178
1210
  }
1179
1211
  throw err;
1180
1212
  }
@@ -1204,20 +1236,20 @@ class Loop {
1204
1236
  const rule = err.rule || 'unknown';
1205
1237
  this._reportError('halt', err, { rule, reason: err.decision?.reason ?? null });
1206
1238
  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 }) };
1239
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }) };
1208
1240
  }
1209
1241
  this._reportError('trim-flush', err, { phase: 'stop' });
1210
1242
  }
1211
1243
  }
1212
1244
  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 }) };
1245
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }) };
1214
1246
  }
1215
1247
 
1216
1248
  // Hard safety limit — should never fire under normal usage; bareguard's
1217
1249
  // limits.maxTurns (or the LLM's natural completion) ends the loop first.
1218
1250
  const warning = `[Loop] hit internal safety limit of ${HARD_ROUND_LIMIT} rounds. Wire bareguard for proper governance — see bare-agent/bareguard.`;
1219
1251
  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 }) };
1252
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, stopReason: lastStopReason, model: lastModel, msgs, metrics: finalizeMetrics(), ...(lastMalformedToolCall && { malformedToolCall: lastMalformedToolCall }), ...(temperatureDropped && { temperatureDropped: true }) };
1221
1253
  }
1222
1254
 
1223
1255
  /**
@@ -1290,7 +1322,7 @@ class Loop {
1290
1322
  * @param {string} text - User message.
1291
1323
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
1292
1324
  * @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}>}
1325
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string}, temperatureDropped?: boolean}>}
1294
1326
  */
1295
1327
  async chat(text, tools = [], options = {}) {
1296
1328
  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,8 +4,9 @@ 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
+ const { parseToolCalls } = require('./provider-toolcalls');
9
10
 
10
11
  // BA-24: raw Ollama usage fields. Any present (even 0) ⇒ a usage signal; none ⇒ null (unpriceable).
11
12
  const OLLAMA_USAGE_KEYS = ['prompt_eval_count', 'eval_count'];
@@ -85,8 +86,11 @@ class OllamaProvider {
85
86
  });
86
87
  const msg = data.message || {};
87
88
 
88
- /** @type {import('../types').ToolCall[]} */
89
- const toolCalls = (msg.tool_calls || []).map((/** @type {any} */ tc) => ({
89
+ // BA-27: Ollama returns `function.arguments` as an OBJECT for well-formed calls, but some builds
90
+ // pass it through as a model-generated STRING a malformed one must not throw here (the round
91
+ // already billed; a throw loses usage + hangs metering). Mirror the OpenAI path: no usable calls
92
+ // + a marker, never repair. The object case is untouched (JSON.parse only runs on a string).
93
+ const { toolCalls, malformedToolCall } = parseToolCalls(msg.tool_calls, (/** @type {any} */ tc) => ({
90
94
  id: tc.id || `call_${Date.now()}`,
91
95
  name: tc.function.name,
92
96
  arguments: typeof tc.function.arguments === 'string'
@@ -97,6 +101,7 @@ class OllamaProvider {
97
101
  return {
98
102
  text: msg.content || '',
99
103
  toolCalls,
104
+ ...(malformedToolCall && { malformedToolCall }),
100
105
  model: data.model || this.model,
101
106
  // BA-6: `length` ⇒ cut off at num_predict. VERIFIED LIVE on qwen2.5:0.5b
102
107
  // (`poc/ba6-stop-reason-gemini-ollama.mjs`): stop→end_turn, length→max_tokens. Lifecycle values
@@ -141,8 +146,11 @@ class OllamaProvider {
141
146
  },
142
147
  }, (res) => {
143
148
  let chunks = '';
149
+ // BA-25: reject (retryable) if the body is cut after headers, so generate() always settles.
150
+ const { markEnded } = guardResponseSettles(res, reject, 'OllamaProvider');
144
151
  res.on('data', d => chunks += d);
145
152
  res.on('end', () => {
153
+ markEnded();
146
154
  try {
147
155
  const parsed = JSON.parse(chunks);
148
156
  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,8 +5,9 @@ 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
+ const { parseToolCalls } = require('./provider-toolcalls');
10
11
 
11
12
  // BA-24: raw OpenAI usage fields. Any present (even 0) ⇒ a usage signal; none ⇒ null (unpriceable).
12
13
  const OPENAI_USAGE_KEYS = ['prompt_tokens', 'completion_tokens', 'prompt_tokens_details'];
@@ -16,6 +17,29 @@ const OPENAI_USAGE_KEYS = ['prompt_tokens', 'completion_tokens', 'prompt_tokens_
16
17
  /** @typedef {import('../types').ToolCall} ToolCall */
17
18
  /** @typedef {import('../types').GenerateResult} GenerateResult */
18
19
 
20
+ /**
21
+ * Map a neutral `toolChoice` option to OpenAI's `tool_choice` wire shape (Ask 4, fwdloop).
22
+ * `'auto'`/`'required'` pass through; `{ name }` becomes `{ type:'function', function:{ name } }`.
23
+ * `null`/`undefined` ⇒ omit the field (the API default `auto`). An unrecognized shape throws — a
24
+ * silently-dropped force would read as "the model chose not to call", the exact confusion Ask 3 fixes.
25
+ * @param {undefined|null|'auto'|'required'|{name: string}} choice
26
+ * @returns {undefined|'auto'|'required'|{type:'function', function:{name:string}}}
27
+ */
28
+ function toOpenAIToolChoice(choice) {
29
+ if (choice == null) return undefined;
30
+ if (choice === 'auto' || choice === 'required') return choice;
31
+ if (typeof choice === 'object' && typeof choice.name === 'string' && choice.name) {
32
+ return { type: 'function', function: { name: choice.name } };
33
+ }
34
+ let describedChoice;
35
+ try {
36
+ describedChoice = JSON.stringify(choice);
37
+ } catch {
38
+ describedChoice = '<unserializable>';
39
+ }
40
+ throw new ProviderError(`[OpenAIProvider] invalid toolChoice: expected 'auto', 'required', or { name }, got ${describedChoice}`);
41
+ }
42
+
19
43
  /** @param {string} hostname @returns {boolean} */
20
44
  function isLoopbackHost(hostname) {
21
45
  const h = hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets
@@ -42,6 +66,11 @@ function isLoopbackHost(hostname) {
42
66
  * rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`,
43
67
  * `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via
44
68
  * `generate(..., { deadlineMs })`.
69
+ * @property {boolean} [legacyMaxTokens=false] - BA-24 (fwdloop): send the legacy `max_tokens` request
70
+ * key instead of `max_completion_tokens`. The default is `max_completion_tokens` because current
71
+ * OpenAI GPT-5 models 400 on `max_tokens` ("Unsupported parameter … Use 'max_completion_tokens'").
72
+ * Set `true` for an OpenAI-compatible server that only understands the legacy key (e.g. some
73
+ * self-hosted / proxy endpoints). No model-name sniffing — the caller declares the dialect.
45
74
  */
46
75
 
47
76
  class OpenAIProvider {
@@ -57,29 +86,40 @@ class OpenAIProvider {
57
86
  this.timeoutMs = options.timeoutMs;
58
87
  // BA-19: total call-duration deadline (ms). Resolved at call time (default 0 = disabled).
59
88
  this.deadlineMs = options.deadlineMs;
89
+ // BA-24 (fwdloop): use the legacy `max_tokens` key. Default false ⇒ `max_completion_tokens` (GPT-5-safe).
90
+ this.legacyMaxTokens = options.legacyMaxTokens === true;
60
91
  }
61
92
 
62
93
  /**
63
94
  * Generate a response from the OpenAI API.
64
95
  * @param {Message[]} messages - Conversation messages.
65
96
  * @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).
97
+ * @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
98
  * @returns {Promise<GenerateResult>}
68
99
  * @throws {Error} `[OpenAIProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
69
100
  */
70
101
  async generate(messages, tools = [], options = {}) {
102
+ // BA-24 (fwdloop): GPT-5 models 400 on `max_tokens` and want `max_completion_tokens`; the legacy
103
+ // key stays reachable via the constructor's `legacyMaxTokens` for compat servers. No model sniffing.
104
+ const maxTokensKey = this.legacyMaxTokens ? 'max_tokens' : 'max_completion_tokens';
71
105
  /** @type {Record<string, any>} */
72
106
  const body = {
73
107
  model: this.model,
74
108
  messages,
75
109
  ...(options.temperature != null && { temperature: options.temperature }),
76
- ...(options.maxTokens && { max_tokens: options.maxTokens }),
110
+ ...(options.maxTokens && { [maxTokensKey]: options.maxTokens }),
77
111
  };
112
+ // Ask 4 (fwdloop): validate the toolChoice SHAPE unconditionally so an invalid value ALWAYS throws
113
+ // (a silently-dropped force is the exact confusion this surfaces) — even when tools happen to be
114
+ // empty. Attach it only when tools are present: OpenAI 400s on a tool_choice with no tools, so a
115
+ // valid choice with nothing to force is dropped (documented), while absent ⇒ the API default 'auto'.
116
+ const toolChoice = toOpenAIToolChoice(options.toolChoice);
78
117
  if (tools.length > 0) {
79
118
  body.tools = tools.map(t => ({
80
119
  type: 'function',
81
120
  function: { name: t.name, description: t.description, parameters: t.parameters },
82
121
  }));
122
+ if (toolChoice != null) body.tool_choice = toolChoice;
83
123
  }
84
124
 
85
125
  // BA-10: newer models (o1/gpt-5-class) reject a non-default `temperature` with a 400 — drop it and
@@ -92,11 +132,27 @@ class OpenAIProvider {
92
132
  stripTemperature: () => { delete body.temperature; },
93
133
  warnOnce: () => this._warnTemperatureDropped(),
94
134
  });
135
+ // BA-27: a successful 200 whose body carries no `choices` (some OpenAI-compat servers return a
136
+ // 4xx-shaped error object with HTTP 200) reached `data.choices[0]` as a bare TypeError with no
137
+ // context. Throw a ProviderError carrying the first ~300 bytes of the body so it can be told apart.
138
+ if (!Array.isArray(data.choices) || data.choices.length === 0) {
139
+ // The `context.bound:'no-choices'` marker ALWAYS distinguishes a 4xx-in-200 from other failures.
140
+ // The raw body snippet is gated behind `exposeErrorBody` (default off) like every other error path
141
+ // here — an unexpected field in a compat server's error body must not leak into logs/audit rows
142
+ // (err.message flows into Loop.run().error) unless the caller opts in.
143
+ throw new ProviderError(
144
+ `[OpenAIProvider] response has no choices` +
145
+ (this.exposeErrorBody ? `: ${JSON.stringify(data).slice(0, 300)}` : ''),
146
+ /** @type {any} */ ({ context: { bound: 'no-choices' }, body: this.exposeErrorBody ? data : undefined })
147
+ );
148
+ }
95
149
  const choice = data.choices[0];
96
150
  const msg = choice.message;
97
151
 
98
- /** @type {import('../types').ToolCall[]} */
99
- const toolCalls = (msg.tool_calls || []).map((/** @type {any} */ tc) => ({
152
+ // BA-27: `function.arguments` is a model-generated JSON STRING — a malformed one (extra brace,
153
+ // truncated object) must NOT throw here (the round already billed; a throw loses usage + hangs
154
+ // metering). parseToolCalls returns no usable calls + a marker; usage/model still flow below.
155
+ const { toolCalls, malformedToolCall } = parseToolCalls(msg.tool_calls, (/** @type {any} */ tc) => ({
100
156
  id: tc.id,
101
157
  name: tc.function.name,
102
158
  arguments: JSON.parse(tc.function.arguments),
@@ -105,6 +161,7 @@ class OpenAIProvider {
105
161
  return {
106
162
  text: msg.content || '',
107
163
  toolCalls,
164
+ ...(malformedToolCall && { malformedToolCall }),
108
165
  model: data.model || this.model,
109
166
  // BA-6: `length` ⇒ cut off at the output cap (normalized to 'max_tokens'). Note OpenAI refuses to
110
167
  // emit a tool call it cannot finish — it 400s instead — so a truncated round here carries no
@@ -178,8 +235,11 @@ class OpenAIProvider {
178
235
  },
179
236
  }, (res) => {
180
237
  let chunks = '';
238
+ // BA-25: reject (retryable) if the body is cut after headers, so generate() always settles.
239
+ const { markEnded } = guardResponseSettles(res, reject, 'OpenAIProvider');
181
240
  res.on('data', d => chunks += d);
182
241
  res.on('end', () => {
242
+ markEnded();
183
243
  try {
184
244
  const parsed = JSON.parse(chunks);
185
245
  if ((res.statusCode ?? 0) >= 400) {
@@ -0,0 +1,29 @@
1
+ export type ToolCall = import("../types").ToolCall;
2
+ /** @typedef {import('../types').ToolCall} ToolCall */
3
+ /**
4
+ * BA-27 — parse a round's raw tool calls into the neutral {@link ToolCall} shape WITHOUT throwing on
5
+ * malformed arguments.
6
+ *
7
+ * OpenAI-compatible providers return `function.arguments` as a JSON STRING the model generated, so a
8
+ * model that emits syntactically-broken JSON (an extra brace, a truncated object — seen live on
9
+ * deepseek-flash and other compat servers) makes a bare `JSON.parse` throw a `SyntaxError`. That throw
10
+ * lands AFTER the HTTP round already succeeded and `usage` came back, so it loses the billed round and
11
+ * hangs any metering, and no caller can tell "the model emitted bad arguments" from a transport fault.
12
+ *
13
+ * Instead: on the FIRST unparseable call, return NO usable tool calls (`toolCalls: []`) plus a marker
14
+ * `{ name, error }`. The caller treats it as "no usable tool call" and retries; usage/model still flow
15
+ * so the round is metered. We NEVER repair the JSON — a guessed brace could execute the wrong action.
16
+ * All-or-nothing (mirrors BA-4's refusal to execute a truncated round's calls): a partial set risks
17
+ * running half a decomposed intent, so one bad call voids the whole round's calls.
18
+ *
19
+ * @param {any[]} rawToolCalls - provider-native tool-call entries (may be undefined/empty)
20
+ * @param {(tc: any) => ToolCall} mapOne - maps one raw entry to a ToolCall; MAY throw on bad arguments
21
+ * @returns {{ toolCalls: ToolCall[], malformedToolCall?: { name: string|undefined, error: string } }}
22
+ */
23
+ export function parseToolCalls(rawToolCalls: any[], mapOne: (tc: any) => ToolCall): {
24
+ toolCalls: ToolCall[];
25
+ malformedToolCall?: {
26
+ name: string | undefined;
27
+ error: string;
28
+ };
29
+ };
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ /** @typedef {import('../types').ToolCall} ToolCall */
4
+
5
+ /**
6
+ * BA-27 — parse a round's raw tool calls into the neutral {@link ToolCall} shape WITHOUT throwing on
7
+ * malformed arguments.
8
+ *
9
+ * OpenAI-compatible providers return `function.arguments` as a JSON STRING the model generated, so a
10
+ * model that emits syntactically-broken JSON (an extra brace, a truncated object — seen live on
11
+ * deepseek-flash and other compat servers) makes a bare `JSON.parse` throw a `SyntaxError`. That throw
12
+ * lands AFTER the HTTP round already succeeded and `usage` came back, so it loses the billed round and
13
+ * hangs any metering, and no caller can tell "the model emitted bad arguments" from a transport fault.
14
+ *
15
+ * Instead: on the FIRST unparseable call, return NO usable tool calls (`toolCalls: []`) plus a marker
16
+ * `{ name, error }`. The caller treats it as "no usable tool call" and retries; usage/model still flow
17
+ * so the round is metered. We NEVER repair the JSON — a guessed brace could execute the wrong action.
18
+ * All-or-nothing (mirrors BA-4's refusal to execute a truncated round's calls): a partial set risks
19
+ * running half a decomposed intent, so one bad call voids the whole round's calls.
20
+ *
21
+ * @param {any[]} rawToolCalls - provider-native tool-call entries (may be undefined/empty)
22
+ * @param {(tc: any) => ToolCall} mapOne - maps one raw entry to a ToolCall; MAY throw on bad arguments
23
+ * @returns {{ toolCalls: ToolCall[], malformedToolCall?: { name: string|undefined, error: string } }}
24
+ */
25
+ function parseToolCalls(rawToolCalls, mapOne) {
26
+ const raw = rawToolCalls || [];
27
+ /** @type {ToolCall[]} */
28
+ const toolCalls = [];
29
+ for (const tc of raw) {
30
+ try {
31
+ toolCalls.push(mapOne(tc));
32
+ } catch (e) {
33
+ return {
34
+ toolCalls: [],
35
+ malformedToolCall: {
36
+ name: tc && tc.function ? tc.function.name : undefined,
37
+ error: e instanceof Error ? e.message : String(e),
38
+ },
39
+ };
40
+ }
41
+ }
42
+ return { toolCalls };
43
+ }
44
+
45
+ module.exports = { parseToolCalls };
package/types/index.d.ts CHANGED
@@ -134,6 +134,16 @@ export interface GenerateResult {
134
134
  * temperature, not the one requested — callers reporting an effective temperature must honor this.
135
135
  */
136
136
  temperatureDropped?: boolean;
137
+ /**
138
+ * BA-27 — present ONLY when the model emitted a tool call whose `function.arguments` was
139
+ * syntactically-broken JSON (an extra brace, a truncated object). The billed round already
140
+ * succeeded, so rather than throw (which loses the round's usage and hangs metering), the provider
141
+ * returns NO usable tool calls (`toolCalls: []`) plus this marker. A caller treats it as "no usable
142
+ * tool call" and retries; `usage`/`model` still flow so the round is metered. The JSON is NEVER
143
+ * repaired. Absent on a clean round. OpenAI-compatible + Ollama string-arguments only; Anthropic
144
+ * arrives pre-parsed and cannot hit this.
145
+ */
146
+ malformedToolCall?: { name: string | undefined; error: string };
137
147
  /**
138
148
  * BA-7 — provider-native content blocks the normalized `{text, toolCalls}` shape cannot express
139
149
  * (Anthropic `thinking` / `redacted_thinking`), captured opaquely so the Loop can put them on the