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.
- package/bareagent.context.md +1 -1
- package/package.json +1 -1
- package/src/errors.d.ts +6 -1
- package/src/errors.js +6 -3
- package/src/loop.d.ts +13 -2
- package/src/loop.js +44 -12
- package/src/provider-anthropic.js +4 -1
- package/src/provider-gemini.js +4 -1
- package/src/provider-http.d.ts +25 -0
- package/src/provider-http.js +36 -2
- package/src/provider-ollama.js +11 -3
- package/src/provider-openai.d.ts +15 -1
- package/src/provider-openai.js +65 -5
- 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/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
|
-
|
|
45
|
-
|
|
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) {
|
package/src/provider-gemini.js
CHANGED
|
@@ -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) {
|
package/src/provider-http.d.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/provider-http.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/src/provider-ollama.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
@@ -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) {
|
package/src/provider-openai.d.ts
CHANGED
|
@@ -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
|
*/
|
package/src/provider-openai.js
CHANGED
|
@@ -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 && {
|
|
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
|
-
|
|
99
|
-
|
|
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
|