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.
- package/bareagent.context.md +1 -1
- package/package.json +1 -1
- package/src/loop.d.ts +10 -2
- package/src/loop.js +23 -12
- package/src/provider-ollama.js +7 -2
- package/src/provider-openai.js +27 -3
- package/src/provider-toolcalls.d.ts +29 -0
- package/src/provider-toolcalls.js +45 -0
- package/types/index.d.ts +10 -0
package/bareagent.context.md
CHANGED
|
@@ -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.
|
|
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
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 });
|
package/src/provider-ollama.js
CHANGED
|
@@ -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
|
-
|
|
89
|
-
|
|
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
|
package/src/provider-openai.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|