bare-agent 0.42.0 → 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.42.0 | 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.42.0",
3
+ "version": "0.43.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
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, model: 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
@@ -199,6 +199,10 @@ export class Loop {
199
199
  model: string | null;
200
200
  msgs: Message[];
201
201
  metrics: RunMetrics;
202
+ malformedToolCall?: {
203
+ name: string | undefined;
204
+ error: string;
205
+ };
202
206
  temperatureDropped?: boolean;
203
207
  }>;
204
208
  _warnedTruncated: boolean | undefined;
@@ -228,7 +232,7 @@ export class Loop {
228
232
  * @param {string} text - User message.
229
233
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
230
234
  * @param {Record<string, any>} [options={}] - Per-run overrides.
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}>}
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}>}
232
236
  */
233
237
  chat(text: string, tools?: ToolDef[], options?: Record<string, any>): Promise<{
234
238
  text: string;
@@ -240,6 +244,10 @@ export class Loop {
240
244
  model: string | null;
241
245
  msgs: Message[];
242
246
  metrics: RunMetrics;
247
+ malformedToolCall?: {
248
+ name: string | undefined;
249
+ error: string;
250
+ };
243
251
  temperatureDropped?: boolean;
244
252
  }>;
245
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, model: 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
@@ -547,6 +547,14 @@ class Loop {
547
547
  // RESPONSE, not the provider object — a wrapped/fallback provider can lose `.model`). Surfaced on
548
548
  // every return so a caller reads which model produced the result without the onLlmResult side channel.
549
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;
550
558
  // BA-10: sticky across rounds — true if ANY round's `temperature` was dropped by the model (400,
551
559
  // unsupported/deprecated) and retried without it. Surfaced on the result so an upstream receipt
552
560
  // (recurse's refineLeaf) can report the EFFECTIVE temperature rather than the ignored request.
@@ -583,7 +591,7 @@ class Loop {
583
591
  sealDanglingToolCalls(msgs, `[halted:${stuckTag}]`);
584
592
  this._reportError('stuck', new Error(`tool "${tc.name}" failed ${identicalErrors} times with identical arguments`), { rule: stuckTag, attempts: identicalErrors });
585
593
  this._safeEmit({ type: 'loop:done', data: { text: lastText, stuck: true, rule: stuckTag, cost: totalCost } });
586
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: stuckTag, stopReason: lastStopReason, model: lastModel, 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 }) };
587
595
  }
588
596
  return null;
589
597
  };
@@ -830,7 +838,7 @@ class Loop {
830
838
  this._reportError('provider', err, { round });
831
839
  if (this.throwOnError) throw err;
832
840
  // BA-5: a mid-run provider failure must not erase the work of the rounds that succeeded.
833
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, stopReason: lastStopReason, model: lastModel, 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 }) };
834
842
  }
835
843
 
836
844
  lastUsage = result.usage || lastUsage;
@@ -843,6 +851,9 @@ class Loop {
843
851
  // path reads lastStopReason). Non-string / absent ⇒ null (the provider's pre-BA-6 degrade).
844
852
  lastStopReason = typeof result.stopReason === 'string' ? result.stopReason : null;
845
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;
846
857
  if (result.temperatureDropped) temperatureDropped = true;
847
858
  // Publish the latest measured usage to ctx (non-enumerable, fail-open) so a transcript-bound seam —
848
859
  // e.g. F2 stash auto-compaction — can read EXACT provider-counted `inputTokens` to gauge context
@@ -927,7 +938,7 @@ class Loop {
927
938
  sealDanglingToolCalls(msgs, `[halted:${session.error}]`);
928
939
  this._reportError('session', new Error(`provider session terminated: ${session.error}`), { rule: session.error, sessionTurns: session.turns ?? null });
929
940
  this._safeEmit({ type: 'loop:done', data: { text: lastText, rule: session.error, sessionTurns: session.turns ?? null, cost: totalCost } });
930
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: session.error, stopReason: lastStopReason, model: lastModel, 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 }) };
931
942
  }
932
943
 
933
944
  // BA-13: classify this round's terminal signal against the neutral stop-reason vocabulary. BA-6
@@ -992,7 +1003,7 @@ class Loop {
992
1003
  msgs.push({ role: 'assistant', content: result.text });
993
1004
  }
994
1005
  this._safeEmit({ type: 'loop:done', data: { text: lastText, ...(terminal === 'truncated' && { truncated: true }), terminal, stopReason: lastStopReason, droppedToolCalls: dropped, cost: totalCost } });
995
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: errorTag, stopReason: lastStopReason, model: lastModel, 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 }) };
996
1007
  }
997
1008
 
998
1009
  // No tool calls — LLM gave a final text response
@@ -1012,7 +1023,7 @@ class Loop {
1012
1023
  try { await flush(msgs, ctx); }
1013
1024
  catch (err) { if (err instanceof HaltError) throw err; this._reportError('trim-flush', err, { round }); }
1014
1025
  }
1015
- return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, model: lastModel, 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 }) };
1016
1027
  }
1017
1028
 
1018
1029
  // Execute tool calls
@@ -1121,7 +1132,7 @@ class Loop {
1121
1132
  sealDanglingToolCalls(msgs, `[halted:${denyTag}]`);
1122
1133
  this._reportError('denied', new Error(`policy denied ${consecutiveDenials} consecutive tool calls (${tc.name})`), { rule: denyTag, denials: consecutiveDenials });
1123
1134
  this._safeEmit({ type: 'loop:done', data: { text: lastText, denied: true, rule: denyTag, cost: totalCost } });
1124
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, stopReason: lastStopReason, model: lastModel, 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 }) };
1125
1136
  }
1126
1137
  continue;
1127
1138
  }
@@ -1195,7 +1206,7 @@ class Loop {
1195
1206
  // BA-5: the rule tag survives on `error`; so does the work. A halt is how a bounded attempt is
1196
1207
  // SUPPOSED to end — the caller reads `error` to know it was bounded and `text` to learn from it.
1197
1208
  this._safeEmit({ type: 'loop:done', data: { text: lastText, halted: true, rule, cost: totalCost } });
1198
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, model: lastModel, 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 }) };
1199
1210
  }
1200
1211
  throw err;
1201
1212
  }
@@ -1225,20 +1236,20 @@ class Loop {
1225
1236
  const rule = err.rule || 'unknown';
1226
1237
  this._reportError('halt', err, { rule, reason: err.decision?.reason ?? null });
1227
1238
  this._safeEmit({ type: 'loop:done', data: { text: lastText, halted: true, rule, cost: totalCost } });
1228
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, model: lastModel, 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 }) };
1229
1240
  }
1230
1241
  this._reportError('trim-flush', err, { phase: 'stop' });
1231
1242
  }
1232
1243
  }
1233
1244
  this._safeEmit({ type: 'loop:done', data: { text: lastText, stopped: true, cost: totalCost } });
1234
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, model: lastModel, 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 }) };
1235
1246
  }
1236
1247
 
1237
1248
  // Hard safety limit — should never fire under normal usage; bareguard's
1238
1249
  // limits.maxTurns (or the LLM's natural completion) ends the loop first.
1239
1250
  const warning = `[Loop] hit internal safety limit of ${HARD_ROUND_LIMIT} rounds. Wire bareguard for proper governance — see bare-agent/bareguard.`;
1240
1251
  this._safeEmit({ type: 'loop:done', data: { text: lastText, warning, cost: totalCost } });
1241
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, stopReason: lastStopReason, model: lastModel, 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 }) };
1242
1253
  }
1243
1254
 
1244
1255
  /**
@@ -1311,7 +1322,7 @@ class Loop {
1311
1322
  * @param {string} text - User message.
1312
1323
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
1313
1324
  * @param {Record<string, any>} [options={}] - Per-run overrides.
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}>}
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}>}
1315
1326
  */
1316
1327
  async chat(text, tools = [], options = {}) {
1317
1328
  this._history.push({ role: 'user', content: text });
@@ -6,6 +6,7 @@ const { requestWithTemperatureFallback } = require('./provider-temperature');
6
6
  const { normalizeStopReason } = require('./provider-stop-reason');
7
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
@@ -7,6 +7,7 @@ const { requestWithTemperatureFallback } = require('./provider-temperature');
7
7
  const { normalizeStopReason } = require('./provider-stop-reason');
8
8
  const { resolveTimeoutMs, applyRequestBounds, guardResponseSettles } = require('./provider-http');
9
9
  const { hasUsageSignal } = require('./provider-usage');
10
+ const { 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'];
@@ -30,7 +31,13 @@ function toOpenAIToolChoice(choice) {
30
31
  if (typeof choice === 'object' && typeof choice.name === 'string' && choice.name) {
31
32
  return { type: 'function', function: { name: choice.name } };
32
33
  }
33
- throw new ProviderError(`[OpenAIProvider] invalid toolChoice: expected 'auto', 'required', or { name }, got ${JSON.stringify(choice)}`);
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}`);
34
41
  }
35
42
 
36
43
  /** @param {string} hostname @returns {boolean} */
@@ -125,11 +132,27 @@ class OpenAIProvider {
125
132
  stripTemperature: () => { delete body.temperature; },
126
133
  warnOnce: () => this._warnTemperatureDropped(),
127
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
+ }
128
149
  const choice = data.choices[0];
129
150
  const msg = choice.message;
130
151
 
131
- /** @type {import('../types').ToolCall[]} */
132
- 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) => ({
133
156
  id: tc.id,
134
157
  name: tc.function.name,
135
158
  arguments: JSON.parse(tc.function.arguments),
@@ -138,6 +161,7 @@ class OpenAIProvider {
138
161
  return {
139
162
  text: msg.content || '',
140
163
  toolCalls,
164
+ ...(malformedToolCall && { malformedToolCall }),
141
165
  model: data.model || this.model,
142
166
  // BA-6: `length` ⇒ cut off at the output cap (normalized to 'max_tokens'). Note OpenAI refuses to
143
167
  // emit a tool call it cannot finish — it 400s instead — so a truncated round here carries no
@@ -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