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