bare-agent 0.26.0 → 0.27.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/README.md +7 -1
- package/bareagent.context.md +47 -2
- package/package.json +3 -3
- package/src/bareguard-adapter.d.ts +2 -2
- package/src/bareguard-adapter.js +1 -1
- package/src/loop.d.ts +40 -0
- package/src/loop.js +185 -15
- package/src/provider-anthropic.d.ts +32 -2
- package/src/provider-anthropic.js +106 -4
- package/src/provider-clipipe.js +47 -21
- package/src/provider-gemini.js +10 -0
- package/src/provider-ollama.d.ts +1 -1
- package/src/provider-ollama.js +32 -9
- package/src/provider-openai.js +14 -5
- package/src/provider-stop-reason.d.ts +26 -0
- package/src/provider-stop-reason.js +148 -0
- package/src/recurse-synthesize.js +11 -2
- package/tools/shell.d.ts +13 -2
- package/tools/shell.js +28 -8
- package/types/index.d.ts +46 -0
|
@@ -15,9 +15,19 @@ export type AnthropicOptions = {
|
|
|
15
15
|
*/
|
|
16
16
|
baseUrl?: string | undefined;
|
|
17
17
|
/**
|
|
18
|
-
* - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`.
|
|
18
|
+
* - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`. NOTE: on its own this rarely helps a tool loop — Anthropic's minimum cacheable prefix is 1024–4096 tokens (model-dependent) and a typical system persona is a few hundred, so it silently never caches. The transcript is where a tool loop's tokens actually live — see `cacheMessages`.
|
|
19
19
|
*/
|
|
20
20
|
cacheSystem?: boolean | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* - Opt-in TRANSCRIPT caching (BA-1): roll a `cache_control` breakpoint onto the last content block of the last message, so Anthropic caches the whole conversation prefix and the loop stops re-buying it at full price every round. In a tool loop the transcript IS the tool results (file bodies from `shell_read`) and it always ENDS on one, which `_toAnthropicMessage` rebuilds from scratch — so no caller-side seam (`assemble` included) can reach it, and this has to live in the provider. Measured on `claude-sonnet-5` with a ~15k-token tool-result transcript (`poc/ba1-message-caching.mjs`): steady state **$0.0753 → $0.0110 per round, 6.8x cheaper**; round 1 pays a 1.25x cache WRITE once. Off by default — it changes the wire format, so adopters opt in. Overridable per call via `generate(..., { cacheMessages })`. **Interaction:** a destructive `trim`/stash fold that rewrites the transcript PREFIX invalidates the cache (the prefix is the cache key), so a fold must keep the head stable or you re-pay the write premium every round for nothing.
|
|
23
|
+
*/
|
|
24
|
+
cacheMessages?: boolean | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* - Opt-in extended thinking (BA-7), forwarded to `body.thinking` VERBATIM and unvalidated — e.g. `{ type: 'adaptive' }`, or `{ type: 'adaptive', display: 'summarized' }` to surface the reasoning (the default `display` is `'omitted'`). Deliberately opaque: this parameter has already broken once (`budget_tokens` was removed and now 400s on `claude-sonnet-5` / Opus 4.7+), and a library that reshapes it would need a release every time the API moves. Overridable per call via `generate(..., { thinking })`; pass `null` there to suppress an instance default.
|
|
27
|
+
*
|
|
28
|
+
* **MEASURED CAVEAT — this option does not "turn thinking on".** On `claude-sonnet-5` adaptive thinking is ALREADY the default: sending this changed the observed thinking rate not at all (2/10 rounds with it vs 3/10 without — `poc/ba7-adaptive-default.mjs`). Its real use is pinning the mode and reaching `display`/`effort`. The change that mattered is that thinking blocks are now PRESERVED and replayed (see `Message.providerBlocks`), which happens whether or not you ever set this.
|
|
29
|
+
*/
|
|
30
|
+
thinking?: any;
|
|
21
31
|
/**
|
|
22
32
|
* - Attach the full upstream response to `err.body` on HTTP errors (off by default to avoid leaking unexpected fields through error logs; `err.message` still carries the API error).
|
|
23
33
|
*/
|
|
@@ -31,7 +41,11 @@ export type AnthropicOptions = {
|
|
|
31
41
|
* @property {string} [apiKey] - Anthropic API key (required).
|
|
32
42
|
* @property {string} [model='claude-haiku-4-5-20251001'] - Model ID.
|
|
33
43
|
* @property {string} [baseUrl='https://api.anthropic.com/v1'] - API base (override for proxies/gateways; the request posts to `${baseUrl}/messages`).
|
|
34
|
-
* @property {boolean} [cacheSystem=false] - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`.
|
|
44
|
+
* @property {boolean} [cacheSystem=false] - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`. NOTE: on its own this rarely helps a tool loop — Anthropic's minimum cacheable prefix is 1024–4096 tokens (model-dependent) and a typical system persona is a few hundred, so it silently never caches. The transcript is where a tool loop's tokens actually live — see `cacheMessages`.
|
|
45
|
+
* @property {boolean} [cacheMessages=false] - Opt-in TRANSCRIPT caching (BA-1): roll a `cache_control` breakpoint onto the last content block of the last message, so Anthropic caches the whole conversation prefix and the loop stops re-buying it at full price every round. In a tool loop the transcript IS the tool results (file bodies from `shell_read`) and it always ENDS on one, which `_toAnthropicMessage` rebuilds from scratch — so no caller-side seam (`assemble` included) can reach it, and this has to live in the provider. Measured on `claude-sonnet-5` with a ~15k-token tool-result transcript (`poc/ba1-message-caching.mjs`): steady state **$0.0753 → $0.0110 per round, 6.8x cheaper**; round 1 pays a 1.25x cache WRITE once. Off by default — it changes the wire format, so adopters opt in. Overridable per call via `generate(..., { cacheMessages })`. **Interaction:** a destructive `trim`/stash fold that rewrites the transcript PREFIX invalidates the cache (the prefix is the cache key), so a fold must keep the head stable or you re-pay the write premium every round for nothing.
|
|
46
|
+
* @property {any} [thinking] - Opt-in extended thinking (BA-7), forwarded to `body.thinking` VERBATIM and unvalidated — e.g. `{ type: 'adaptive' }`, or `{ type: 'adaptive', display: 'summarized' }` to surface the reasoning (the default `display` is `'omitted'`). Deliberately opaque: this parameter has already broken once (`budget_tokens` was removed and now 400s on `claude-sonnet-5` / Opus 4.7+), and a library that reshapes it would need a release every time the API moves. Overridable per call via `generate(..., { thinking })`; pass `null` there to suppress an instance default.
|
|
47
|
+
*
|
|
48
|
+
* **MEASURED CAVEAT — this option does not "turn thinking on".** On `claude-sonnet-5` adaptive thinking is ALREADY the default: sending this changed the observed thinking rate not at all (2/10 rounds with it vs 3/10 without — `poc/ba7-adaptive-default.mjs`). Its real use is pinning the mode and reaching `display`/`effort`. The change that mattered is that thinking blocks are now PRESERVED and replayed (see `Message.providerBlocks`), which happens whether or not you ever set this.
|
|
35
49
|
* @property {boolean} [exposeErrorBody=false] - Attach the full upstream response to `err.body` on HTTP errors (off by default to avoid leaking unexpected fields through error logs; `err.message` still carries the API error).
|
|
36
50
|
*/
|
|
37
51
|
export class AnthropicProvider {
|
|
@@ -44,6 +58,8 @@ export class AnthropicProvider {
|
|
|
44
58
|
model: string;
|
|
45
59
|
baseUrl: string;
|
|
46
60
|
cacheSystem: boolean;
|
|
61
|
+
cacheMessages: boolean;
|
|
62
|
+
thinking: any;
|
|
47
63
|
exposeErrorBody: boolean;
|
|
48
64
|
/**
|
|
49
65
|
* Generate a response from the Anthropic API.
|
|
@@ -57,6 +73,20 @@ export class AnthropicProvider {
|
|
|
57
73
|
/** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
|
|
58
74
|
_warnTemperatureDropped(): void;
|
|
59
75
|
_warnedTempDropped: boolean | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* BA-7: the provider-native blocks to replay at the FRONT of an assistant turn's content, or `[]`.
|
|
78
|
+
*
|
|
79
|
+
* Only ever returns blocks this model itself produced. The tag carries `this.model` (the CONFIGURED
|
|
80
|
+
* id, not the response's resolved one — those can differ by date suffix, and a mismatch there would
|
|
81
|
+
* silently disable preservation, which is the very bug class BA-7 exists to close).
|
|
82
|
+
*
|
|
83
|
+
* Front, because Anthropic requires `thinking` to lead the content array — the verbatim order we
|
|
84
|
+
* measured a successful round-trip on (`poc/ba7-thinking-contract.mjs`, R3).
|
|
85
|
+
*
|
|
86
|
+
* @param {Message} msg
|
|
87
|
+
* @returns {any[]}
|
|
88
|
+
*/
|
|
89
|
+
_nativeBlocks(msg: Message): any[];
|
|
60
90
|
/**
|
|
61
91
|
* @param {Message} msg
|
|
62
92
|
* @returns {any}
|
|
@@ -4,6 +4,7 @@ const https = require('https');
|
|
|
4
4
|
const http = require('http');
|
|
5
5
|
const { ProviderError } = require('./errors');
|
|
6
6
|
const { requestWithTemperatureFallback } = require('./provider-temperature');
|
|
7
|
+
const { normalizeStopReason } = require('./provider-stop-reason');
|
|
7
8
|
|
|
8
9
|
/** @param {string} hostname @returns {boolean} */
|
|
9
10
|
function isLoopbackHost(hostname) {
|
|
@@ -20,7 +21,11 @@ function isLoopbackHost(hostname) {
|
|
|
20
21
|
* @property {string} [apiKey] - Anthropic API key (required).
|
|
21
22
|
* @property {string} [model='claude-haiku-4-5-20251001'] - Model ID.
|
|
22
23
|
* @property {string} [baseUrl='https://api.anthropic.com/v1'] - API base (override for proxies/gateways; the request posts to `${baseUrl}/messages`).
|
|
23
|
-
* @property {boolean} [cacheSystem=false] - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`.
|
|
24
|
+
* @property {boolean} [cacheSystem=false] - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`. NOTE: on its own this rarely helps a tool loop — Anthropic's minimum cacheable prefix is 1024–4096 tokens (model-dependent) and a typical system persona is a few hundred, so it silently never caches. The transcript is where a tool loop's tokens actually live — see `cacheMessages`.
|
|
25
|
+
* @property {boolean} [cacheMessages=false] - Opt-in TRANSCRIPT caching (BA-1): roll a `cache_control` breakpoint onto the last content block of the last message, so Anthropic caches the whole conversation prefix and the loop stops re-buying it at full price every round. In a tool loop the transcript IS the tool results (file bodies from `shell_read`) and it always ENDS on one, which `_toAnthropicMessage` rebuilds from scratch — so no caller-side seam (`assemble` included) can reach it, and this has to live in the provider. Measured on `claude-sonnet-5` with a ~15k-token tool-result transcript (`poc/ba1-message-caching.mjs`): steady state **$0.0753 → $0.0110 per round, 6.8x cheaper**; round 1 pays a 1.25x cache WRITE once. Off by default — it changes the wire format, so adopters opt in. Overridable per call via `generate(..., { cacheMessages })`. **Interaction:** a destructive `trim`/stash fold that rewrites the transcript PREFIX invalidates the cache (the prefix is the cache key), so a fold must keep the head stable or you re-pay the write premium every round for nothing.
|
|
26
|
+
* @property {any} [thinking] - Opt-in extended thinking (BA-7), forwarded to `body.thinking` VERBATIM and unvalidated — e.g. `{ type: 'adaptive' }`, or `{ type: 'adaptive', display: 'summarized' }` to surface the reasoning (the default `display` is `'omitted'`). Deliberately opaque: this parameter has already broken once (`budget_tokens` was removed and now 400s on `claude-sonnet-5` / Opus 4.7+), and a library that reshapes it would need a release every time the API moves. Overridable per call via `generate(..., { thinking })`; pass `null` there to suppress an instance default.
|
|
27
|
+
*
|
|
28
|
+
* **MEASURED CAVEAT — this option does not "turn thinking on".** On `claude-sonnet-5` adaptive thinking is ALREADY the default: sending this changed the observed thinking rate not at all (2/10 rounds with it vs 3/10 without — `poc/ba7-adaptive-default.mjs`). Its real use is pinning the mode and reaching `display`/`effort`. The change that mattered is that thinking blocks are now PRESERVED and replayed (see `Message.providerBlocks`), which happens whether or not you ever set this.
|
|
24
29
|
* @property {boolean} [exposeErrorBody=false] - Attach the full upstream response to `err.body` on HTTP errors (off by default to avoid leaking unexpected fields through error logs; `err.message` still carries the API error).
|
|
25
30
|
*/
|
|
26
31
|
|
|
@@ -38,6 +43,13 @@ class AnthropicProvider {
|
|
|
38
43
|
// Anthropic caches it (unlike OpenAI/Gemini, Anthropic does NOT auto-cache — without this its
|
|
39
44
|
// cache_read/cache_creation tiers are always 0). Default off keeps requests byte-identical to before.
|
|
40
45
|
this.cacheSystem = options.cacheSystem === true;
|
|
46
|
+
this.cacheMessages = options.cacheMessages === true;
|
|
47
|
+
// BA-7: forwarded to `body.thinking` VERBATIM — deliberately unvalidated and un-reshaped. This
|
|
48
|
+
// parameter has already broken once (`budget_tokens` was removed and now 400s on sonnet-5 /
|
|
49
|
+
// Opus 4.7+; `{type:'adaptive'}` replaced it). A library that parses it would have to ship a
|
|
50
|
+
// release every time Anthropic moves; passing it through means the caller can always express
|
|
51
|
+
// the current API. Omit it to keep today's body byte-identical.
|
|
52
|
+
this.thinking = options.thinking != null ? options.thinking : null;
|
|
41
53
|
// See OpenAIProvider: attach full upstream body to err.body only on opt-in.
|
|
42
54
|
this.exposeErrorBody = options.exposeErrorBody === true;
|
|
43
55
|
}
|
|
@@ -76,6 +88,33 @@ class AnthropicProvider {
|
|
|
76
88
|
system = [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }];
|
|
77
89
|
}
|
|
78
90
|
|
|
91
|
+
// BA-1: roll a cache breakpoint onto the LAST content block of the LAST message. Anthropic caches the
|
|
92
|
+
// whole prefix up to the mark, and rolling it forward each round keeps the GROWING transcript cached
|
|
93
|
+
// — otherwise a tool loop re-buys its entire history at full input price, every single round.
|
|
94
|
+
//
|
|
95
|
+
// This MUST live here, not in a caller-side seam: in a tool loop the transcript IS the tool results,
|
|
96
|
+
// it always ENDS on one, and `_toAnthropicMessage` rebuilds `role:'tool'` messages into fresh
|
|
97
|
+
// `tool_result` blocks — discarding anything a caller attached. There is no other reachable seam.
|
|
98
|
+
//
|
|
99
|
+
// Copy-on-write, deliberately: a caller's `content` array is passed through by reference, so marking
|
|
100
|
+
// it in place would mutate the caller's own message objects and leave a stale breakpoint behind on
|
|
101
|
+
// every later round (Anthropic allows at most 4, and a stray one silently shifts the cache key).
|
|
102
|
+
const cacheMessages = options.cacheMessages != null ? options.cacheMessages === true : this.cacheMessages;
|
|
103
|
+
if (cacheMessages && msgs.length > 0) {
|
|
104
|
+
const i = msgs.length - 1;
|
|
105
|
+
const last = msgs[i];
|
|
106
|
+
if (Array.isArray(last.content) && last.content.length > 0) {
|
|
107
|
+
const blocks = last.content.slice();
|
|
108
|
+
blocks[blocks.length - 1] = { ...blocks[blocks.length - 1], cache_control: { type: 'ephemeral' } };
|
|
109
|
+
msgs[i] = { ...last, content: blocks };
|
|
110
|
+
} else if (typeof last.content === 'string' && last.content.length > 0) {
|
|
111
|
+
msgs[i] = { ...last, content: [{ type: 'text', text: last.content, cache_control: { type: 'ephemeral' } }] };
|
|
112
|
+
}
|
|
113
|
+
// An empty-content message gets no mark — there is no block to hang it on, and a synthesized empty
|
|
114
|
+
// one would be a wire error. Under the cache minimum (1024–4096 tok, model-dependent) Anthropic
|
|
115
|
+
// silently doesn't cache: harmless, just no saving. Never an error.
|
|
116
|
+
}
|
|
117
|
+
|
|
79
118
|
/** @type {Record<string, any>} */
|
|
80
119
|
const body = {
|
|
81
120
|
model: this.model,
|
|
@@ -84,6 +123,14 @@ class AnthropicProvider {
|
|
|
84
123
|
...(system && { system }),
|
|
85
124
|
...(options.temperature != null && { temperature: options.temperature }),
|
|
86
125
|
};
|
|
126
|
+
|
|
127
|
+
// BA-7 (b). Measured caveat, and it matters: on `claude-sonnet-5` adaptive thinking is ALREADY
|
|
128
|
+
// the default — sending this changed the thinking rate not at all (2/10 vs 3/10 rounds,
|
|
129
|
+
// `poc/ba7-adaptive-default.mjs`). So this option does NOT "turn thinking on"; it lets you pin
|
|
130
|
+
// the mode and reach `display`/`effort`. The fix that actually mattered is the preservation
|
|
131
|
+
// above, which applies whether or not you ever set this.
|
|
132
|
+
const thinking = options.thinking !== undefined ? options.thinking : this.thinking;
|
|
133
|
+
if (thinking) body.thinking = thinking;
|
|
87
134
|
if (tools.length > 0) {
|
|
88
135
|
body.tools = tools.map(t => ({
|
|
89
136
|
name: t.name,
|
|
@@ -105,17 +152,34 @@ class AnthropicProvider {
|
|
|
105
152
|
let text = '';
|
|
106
153
|
/** @type {import('../types').ToolCall[]} */
|
|
107
154
|
const toolCalls = [];
|
|
155
|
+
// BA-7: everything that is NOT text/tool_use is a block our normalized {text, toolCalls} shape
|
|
156
|
+
// cannot express — today that means `thinking` and `redacted_thinking`. We used to drop these on
|
|
157
|
+
// the floor. Anthropic's contract is that they are echoed back UNCHANGED (signature included) when
|
|
158
|
+
// continuing a tool-use conversation, so they are collected OPAQUELY here: whatever the block is,
|
|
159
|
+
// we keep its bytes. Deliberately not a `block.type === 'thinking'` check — a future block type
|
|
160
|
+
// would be silently dropped again, which is the exact bug being fixed.
|
|
161
|
+
/** @type {any[]} */
|
|
162
|
+
const nativeBlocks = [];
|
|
108
163
|
for (const block of data.content) {
|
|
109
164
|
if (block.type === 'text') text += block.text;
|
|
110
|
-
if (block.type === 'tool_use') {
|
|
111
|
-
|
|
112
|
-
}
|
|
165
|
+
else if (block.type === 'tool_use') toolCalls.push({ id: block.id, name: block.name, arguments: block.input });
|
|
166
|
+
else nativeBlocks.push(block);
|
|
113
167
|
}
|
|
114
168
|
|
|
115
169
|
return {
|
|
116
170
|
text,
|
|
117
171
|
toolCalls,
|
|
118
172
|
model: data.model || this.model,
|
|
173
|
+
// BA-6: why generation ended. `max_tokens` here means the API CUT THIS ROUND OFF — the Loop must
|
|
174
|
+
// not read it as a finished turn, and must not execute any tool call it carries (a complete call
|
|
175
|
+
// arrives as `tool_use`; one riding a `max_tokens` round was cut off mid-generation).
|
|
176
|
+
stopReason: normalizeStopReason(data.stop_reason, 'anthropic', { hasToolCalls: toolCalls.length > 0 }),
|
|
177
|
+
// BA-7: the opaque blocks, tagged so they can only ever be replayed to the model that signed
|
|
178
|
+
// them. A thinking `signature` is model-bound; on a mismatch we drop them and degrade to the
|
|
179
|
+
// pre-BA-7 behavior (a lossy request that still succeeds) rather than risk a 400.
|
|
180
|
+
...(nativeBlocks.length > 0 && {
|
|
181
|
+
providerBlocks: { provider: 'anthropic', model: this.model, blocks: nativeBlocks },
|
|
182
|
+
}),
|
|
119
183
|
// Anthropic's `input_tokens` is ALREADY the uncached remainder (cached tokens are reported
|
|
120
184
|
// separately, not folded in — verified live), so no subtraction here, unlike OpenAI/Gemini.
|
|
121
185
|
usage: {
|
|
@@ -135,6 +199,29 @@ class AnthropicProvider {
|
|
|
135
199
|
console.warn(`[AnthropicProvider] '${this.model}' rejected a non-default 'temperature' (unsupported/deprecated) — retrying without it. Further drops from this provider instance are silent.`);
|
|
136
200
|
}
|
|
137
201
|
|
|
202
|
+
/**
|
|
203
|
+
* BA-7: the provider-native blocks to replay at the FRONT of an assistant turn's content, or `[]`.
|
|
204
|
+
*
|
|
205
|
+
* Only ever returns blocks this model itself produced. The tag carries `this.model` (the CONFIGURED
|
|
206
|
+
* id, not the response's resolved one — those can differ by date suffix, and a mismatch there would
|
|
207
|
+
* silently disable preservation, which is the very bug class BA-7 exists to close).
|
|
208
|
+
*
|
|
209
|
+
* Front, because Anthropic requires `thinking` to lead the content array — the verbatim order we
|
|
210
|
+
* measured a successful round-trip on (`poc/ba7-thinking-contract.mjs`, R3).
|
|
211
|
+
*
|
|
212
|
+
* @param {Message} msg
|
|
213
|
+
* @returns {any[]}
|
|
214
|
+
*/
|
|
215
|
+
_nativeBlocks(msg) {
|
|
216
|
+
const pb = /** @type {any} */ (msg).providerBlocks;
|
|
217
|
+
if (!pb || pb.provider !== 'anthropic' || !Array.isArray(pb.blocks) || pb.blocks.length === 0) return [];
|
|
218
|
+
// A thinking signature is bound to the model that issued it. Swap models mid-transcript and these
|
|
219
|
+
// blocks are not ours to replay: drop them (lossy but valid) rather than send a signature this
|
|
220
|
+
// model will reject.
|
|
221
|
+
if (pb.model !== this.model) return [];
|
|
222
|
+
return pb.blocks;
|
|
223
|
+
}
|
|
224
|
+
|
|
138
225
|
/**
|
|
139
226
|
* @param {Message} msg
|
|
140
227
|
* @returns {any}
|
|
@@ -155,6 +242,11 @@ class AnthropicProvider {
|
|
|
155
242
|
if (msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0) {
|
|
156
243
|
/** @type {any[]} */
|
|
157
244
|
const content = [];
|
|
245
|
+
// BA-7: thinking blocks lead, then text, then tool_use — this is THE turn the contract is about
|
|
246
|
+
// (continuing a tool-use conversation). Note the normalized `content`/`tool_calls` stay the
|
|
247
|
+
// source of truth: we replay only the opaque blocks, never a cached copy of the text, so a
|
|
248
|
+
// `trim`/`assemble` seam that rewrites this message is not silently undone.
|
|
249
|
+
content.push(...this._nativeBlocks(msg));
|
|
158
250
|
if (msg.content) content.push({ type: 'text', text: msg.content });
|
|
159
251
|
for (const tc of msg.tool_calls) {
|
|
160
252
|
content.push({
|
|
@@ -168,6 +260,16 @@ class AnthropicProvider {
|
|
|
168
260
|
}
|
|
169
261
|
return { role: 'assistant', content };
|
|
170
262
|
}
|
|
263
|
+
// A tool-call-free assistant turn can still carry thinking (a final answer, or an earlier turn a
|
|
264
|
+
// caller replays into a fresh run). Same rule: native blocks lead.
|
|
265
|
+
if (msg.role === 'assistant') {
|
|
266
|
+
const native = this._nativeBlocks(msg);
|
|
267
|
+
if (native.length > 0) {
|
|
268
|
+
const content = [...native];
|
|
269
|
+
if (msg.content) content.push({ type: 'text', text: msg.content });
|
|
270
|
+
return { role: 'assistant', content };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
171
273
|
return { role: msg.role, content: msg.content };
|
|
172
274
|
}
|
|
173
275
|
|
package/src/provider-clipipe.js
CHANGED
|
@@ -165,39 +165,65 @@ class CLIPipeProvider {
|
|
|
165
165
|
|
|
166
166
|
let stdout = '';
|
|
167
167
|
let stderr = '';
|
|
168
|
-
let killed = false;
|
|
169
168
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
169
|
+
// Settle exactly once, no matter which combination of events fires. 'close' can be
|
|
170
|
+
// withheld indefinitely when the CLI spawns a grandchild that inherits its stdio pipes
|
|
171
|
+
// (the child exits, but the pipes stay open) — observed live as a generate() promise
|
|
172
|
+
// that never settled. Every path below funnels through settle().
|
|
173
|
+
let settled = false;
|
|
174
|
+
/** @type {NodeJS.Timeout[]} */
|
|
175
|
+
const timers = [];
|
|
176
|
+
const later = (fn, ms) => { timers.push(setTimeout(fn, ms)); };
|
|
177
|
+
const settle = (/** @type {Error|null} */ err, text = '') => {
|
|
178
|
+
if (settled) return;
|
|
179
|
+
settled = true;
|
|
180
|
+
for (const t of timers) clearTimeout(t);
|
|
181
|
+
if (err) reject(err); else resolve(text);
|
|
182
|
+
};
|
|
176
183
|
|
|
177
|
-
|
|
178
|
-
if (killed) return; // timeout already rejected
|
|
184
|
+
const finish = (/** @type {number|null} */ code) => {
|
|
179
185
|
if (code !== 0) {
|
|
180
|
-
|
|
186
|
+
// The claude CLI reports errors on STDOUT (a JSON envelope) with stderr often
|
|
187
|
+
// empty — fall back to a stdout tail so the operator never sees a blank reason.
|
|
188
|
+
const detail = stderr.trim() || (stdout.trim() ? `(stderr empty) stdout: ${stdout.trim().slice(-400)}` : '');
|
|
189
|
+
return settle(new ProviderError(`[CLIPipeProvider] process exited with code ${code}: ${detail}`, /** @type {any} */ ({ status: code })));
|
|
181
190
|
}
|
|
182
191
|
const text = stdout.trim();
|
|
183
192
|
if (!text) {
|
|
184
|
-
return
|
|
193
|
+
return settle(new ProviderError('[CLIPipeProvider] process produced no output', /** @type {any} */ ({ status: 0 })));
|
|
194
|
+
}
|
|
195
|
+
settle(null, text);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
child.stdout.on('data', d => {
|
|
199
|
+
stdout += d;
|
|
200
|
+
try {
|
|
201
|
+
this.onChunk?.(d.toString());
|
|
202
|
+
} catch (err) {
|
|
203
|
+
// an observer callback must fail the call loudly, never crash the host process
|
|
204
|
+
settle(new ProviderError(`[CLIPipeProvider] onChunk callback threw: ${/** @type {Error} */ (err).message}`, /** @type {any} */ ({ status: 0 })));
|
|
185
205
|
}
|
|
186
|
-
|
|
206
|
+
});
|
|
207
|
+
child.stderr.on('data', d => { stderr += d; });
|
|
208
|
+
|
|
209
|
+
child.on('error', err => {
|
|
210
|
+
settle(new ProviderError(`[CLIPipeProvider] failed to spawn "${this.command}": ${err.message}`, /** @type {any} */ ({ status: 0 })));
|
|
187
211
|
});
|
|
188
212
|
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
213
|
+
// Primary completion path: all stdio drained.
|
|
214
|
+
child.on('close', code => finish(code));
|
|
215
|
+
|
|
216
|
+
// Fallback: the process exited but 'close' is being held open by inherited pipes.
|
|
217
|
+
// Give real drainage a short grace, then finish with what has arrived — a bounded
|
|
218
|
+
// wait, never a hang.
|
|
219
|
+
child.on('exit', code => later(() => finish(code), 2000));
|
|
220
|
+
|
|
221
|
+
later(() => {
|
|
192
222
|
child.kill('SIGTERM');
|
|
193
|
-
setTimeout(() => {
|
|
194
|
-
|
|
195
|
-
}, 1000);
|
|
196
|
-
reject(new ProviderError(`[CLIPipeProvider] timed out after ${this.timeout}ms`, /** @type {any} */ ({ status: 0 })));
|
|
223
|
+
setTimeout(() => { try { child.kill('SIGKILL'); } catch (_) {} }, 1000).unref?.();
|
|
224
|
+
settle(new ProviderError(`[CLIPipeProvider] timed out after ${this.timeout}ms`, /** @type {any} */ ({ status: 0 })));
|
|
197
225
|
}, this.timeout);
|
|
198
226
|
|
|
199
|
-
child.on('close', () => clearTimeout(timer));
|
|
200
|
-
|
|
201
227
|
// Write prompt to stdin — catch errors silently (process may exit early)
|
|
202
228
|
child.stdin.on('error', () => {});
|
|
203
229
|
child.stdin.end(prompt);
|
package/src/provider-gemini.js
CHANGED
|
@@ -4,6 +4,7 @@ const https = require('https');
|
|
|
4
4
|
const http = require('http');
|
|
5
5
|
const { ProviderError } = require('./errors');
|
|
6
6
|
const { requestWithTemperatureFallback } = require('./provider-temperature');
|
|
7
|
+
const { normalizeStopReason } = require('./provider-stop-reason');
|
|
7
8
|
|
|
8
9
|
/** @typedef {import('../types').Message} Message */
|
|
9
10
|
/** @typedef {import('../types').ToolDef} ToolDef */
|
|
@@ -130,6 +131,15 @@ class GeminiProvider {
|
|
|
130
131
|
text,
|
|
131
132
|
toolCalls,
|
|
132
133
|
model: data.modelVersion || this.model,
|
|
134
|
+
// BA-6: `MAX_TOKENS` ⇒ cut off at the output cap. VERIFIED LIVE on `gemini-2.5-flash`
|
|
135
|
+
// (`poc/ba6-stop-reason-gemini-ollama.mjs`): STOP→end_turn, MAX_TOKENS→max_tokens. An unrecognized
|
|
136
|
+
// value still falls through to `null` (today's behavior), never to a false truncation.
|
|
137
|
+
//
|
|
138
|
+
// Gemini has NO tool_use finish reason — a complete function call comes back as `STOP` (measured).
|
|
139
|
+
// `hasToolCalls` lets the normalizer report the round for what it was: a round that stopped to CALL
|
|
140
|
+
// A TOOL, not one the model chose to end. Without it, `stopReason` would say `end_turn` here and
|
|
141
|
+
// `tool_use` on Anthropic/OpenAI for the identical event.
|
|
142
|
+
stopReason: normalizeStopReason(data.candidates?.[0]?.finishReason, 'gemini', { hasToolCalls: toolCalls.length > 0 }),
|
|
133
143
|
usage: this._normalizeUsage(data.usageMetadata),
|
|
134
144
|
...(temperatureDropped && { temperatureDropped: true }),
|
|
135
145
|
};
|
package/src/provider-ollama.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ export class OllamaProvider {
|
|
|
27
27
|
* Generate a response from a local Ollama instance.
|
|
28
28
|
* @param {Message[]} messages - Conversation messages.
|
|
29
29
|
* @param {ToolDef[]} [tools=[]] - Tool definitions.
|
|
30
|
-
* @param {Record<string, any>} [options={}] - Options (temperature).
|
|
30
|
+
* @param {Record<string, any>} [options={}] - Options (`temperature`, `maxTokens`).
|
|
31
31
|
* @returns {Promise<GenerateResult>}
|
|
32
32
|
* @throws {Error} `[OllamaProvider] ...` — on HTTP errors or invalid JSON response.
|
|
33
33
|
*/
|
package/src/provider-ollama.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const http = require('http');
|
|
4
4
|
const { ProviderError } = require('./errors');
|
|
5
5
|
const { requestWithTemperatureFallback } = require('./provider-temperature');
|
|
6
|
+
const { normalizeStopReason } = require('./provider-stop-reason');
|
|
6
7
|
|
|
7
8
|
/** @typedef {import('../types').Message} Message */
|
|
8
9
|
/** @typedef {import('../types').ToolDef} ToolDef */
|
|
@@ -30,17 +31,29 @@ class OllamaProvider {
|
|
|
30
31
|
* Generate a response from a local Ollama instance.
|
|
31
32
|
* @param {Message[]} messages - Conversation messages.
|
|
32
33
|
* @param {ToolDef[]} [tools=[]] - Tool definitions.
|
|
33
|
-
* @param {Record<string, any>} [options={}] - Options (temperature).
|
|
34
|
+
* @param {Record<string, any>} [options={}] - Options (`temperature`, `maxTokens`).
|
|
34
35
|
* @returns {Promise<GenerateResult>}
|
|
35
36
|
* @throws {Error} `[OllamaProvider] ...` — on HTTP errors or invalid JSON response.
|
|
36
37
|
*/
|
|
37
38
|
async generate(messages, tools = [], options = {}) {
|
|
39
|
+
// Ollama nests generation params under `options` (its `num_predict` is the output cap — the
|
|
40
|
+
// equivalent of `max_tokens` everywhere else).
|
|
41
|
+
//
|
|
42
|
+
// `maxTokens` was NOT forwarded here before, while every other provider honoured it — so a caller
|
|
43
|
+
// capping output on Ollama was silently ignored and generated unbounded. Found by the BA-6 map
|
|
44
|
+
// probe: a 16-token cap produced `done_reason: 'stop'` because nothing ever truncated. The map was
|
|
45
|
+
// right; the cap never reached the wire.
|
|
46
|
+
/** @type {Record<string, any>} */
|
|
47
|
+
const genOptions = {
|
|
48
|
+
...(options.temperature != null && { temperature: options.temperature }),
|
|
49
|
+
...(options.maxTokens != null && { num_predict: options.maxTokens }),
|
|
50
|
+
};
|
|
38
51
|
/** @type {Record<string, any>} */
|
|
39
52
|
const body = {
|
|
40
53
|
model: this.model,
|
|
41
54
|
messages,
|
|
42
55
|
stream: false,
|
|
43
|
-
...(
|
|
56
|
+
...(Object.keys(genOptions).length > 0 && { options: genOptions }),
|
|
44
57
|
};
|
|
45
58
|
if (tools.length > 0) {
|
|
46
59
|
body.tools = tools.map(t => ({
|
|
@@ -59,16 +72,26 @@ class OllamaProvider {
|
|
|
59
72
|
});
|
|
60
73
|
const msg = data.message || {};
|
|
61
74
|
|
|
75
|
+
/** @type {import('../types').ToolCall[]} */
|
|
76
|
+
const toolCalls = (msg.tool_calls || []).map((/** @type {any} */ tc) => ({
|
|
77
|
+
id: tc.id || `call_${Date.now()}`,
|
|
78
|
+
name: tc.function.name,
|
|
79
|
+
arguments: typeof tc.function.arguments === 'string'
|
|
80
|
+
? JSON.parse(tc.function.arguments)
|
|
81
|
+
: tc.function.arguments,
|
|
82
|
+
}));
|
|
83
|
+
|
|
62
84
|
return {
|
|
63
85
|
text: msg.content || '',
|
|
64
|
-
toolCalls
|
|
65
|
-
id: tc.id || `call_${Date.now()}`,
|
|
66
|
-
name: tc.function.name,
|
|
67
|
-
arguments: typeof tc.function.arguments === 'string'
|
|
68
|
-
? JSON.parse(tc.function.arguments)
|
|
69
|
-
: tc.function.arguments,
|
|
70
|
-
})),
|
|
86
|
+
toolCalls,
|
|
71
87
|
model: data.model || this.model,
|
|
88
|
+
// BA-6: `length` ⇒ cut off at num_predict. VERIFIED LIVE on qwen2.5:0.5b
|
|
89
|
+
// (`poc/ba6-stop-reason-gemini-ollama.mjs`): stop→end_turn, length→max_tokens. Lifecycle values
|
|
90
|
+
// (`load`/`unload`) stay deliberately unmapped rather than forced into the vocabulary.
|
|
91
|
+
//
|
|
92
|
+
// Like Gemini, Ollama has NO tool_use done_reason — a complete tool call returns `stop` (measured).
|
|
93
|
+
// `hasToolCalls` lets the normalizer say so, instead of reporting a tool round as a clean finish.
|
|
94
|
+
stopReason: normalizeStopReason(data.done_reason, 'ollama', { hasToolCalls: toolCalls.length > 0 }),
|
|
72
95
|
usage: {
|
|
73
96
|
inputTokens: data.prompt_eval_count || 0,
|
|
74
97
|
outputTokens: data.eval_count || 0,
|
package/src/provider-openai.js
CHANGED
|
@@ -4,6 +4,7 @@ const https = require('https');
|
|
|
4
4
|
const http = require('http');
|
|
5
5
|
const { ProviderError } = require('./errors');
|
|
6
6
|
const { requestWithTemperatureFallback } = require('./provider-temperature');
|
|
7
|
+
const { normalizeStopReason } = require('./provider-stop-reason');
|
|
7
8
|
|
|
8
9
|
/** @typedef {import('../types').Message} Message */
|
|
9
10
|
/** @typedef {import('../types').ToolDef} ToolDef */
|
|
@@ -73,14 +74,22 @@ class OpenAIProvider {
|
|
|
73
74
|
const choice = data.choices[0];
|
|
74
75
|
const msg = choice.message;
|
|
75
76
|
|
|
77
|
+
/** @type {import('../types').ToolCall[]} */
|
|
78
|
+
const toolCalls = (msg.tool_calls || []).map((/** @type {any} */ tc) => ({
|
|
79
|
+
id: tc.id,
|
|
80
|
+
name: tc.function.name,
|
|
81
|
+
arguments: JSON.parse(tc.function.arguments),
|
|
82
|
+
}));
|
|
83
|
+
|
|
76
84
|
return {
|
|
77
85
|
text: msg.content || '',
|
|
78
|
-
toolCalls
|
|
79
|
-
id: tc.id,
|
|
80
|
-
name: tc.function.name,
|
|
81
|
-
arguments: JSON.parse(tc.function.arguments),
|
|
82
|
-
})),
|
|
86
|
+
toolCalls,
|
|
83
87
|
model: data.model || this.model,
|
|
88
|
+
// BA-6: `length` ⇒ cut off at the output cap (normalized to 'max_tokens'). Note OpenAI refuses to
|
|
89
|
+
// emit a tool call it cannot finish — it 400s instead — so a truncated round here carries no
|
|
90
|
+
// tool calls at all; the Loop's refusal to execute them is a no-op on this provider, and a
|
|
91
|
+
// load-bearing guard on Anthropic, which DOES emit the cut-off call.
|
|
92
|
+
stopReason: normalizeStopReason(choice?.finish_reason, 'openai', { hasToolCalls: toolCalls.length > 0 }),
|
|
84
93
|
usage: this._normalizeUsage(data.usage),
|
|
85
94
|
...(temperatureDropped && { temperatureDropped: true }),
|
|
86
95
|
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map a provider's native finish-reason value onto the neutral vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* @param {string|null|undefined} raw - the provider's native value (`stop_reason` / `finish_reason` /
|
|
5
|
+
* `finishReason` / `done_reason`). Absent or non-string ⇒ `null` (pre-BA-6 behavior).
|
|
6
|
+
* @param {'anthropic'|'openai'|'gemini'|'ollama'} provider - which table to read.
|
|
7
|
+
* @param {{hasToolCalls?: boolean}} [ctx] - what the round actually CARRIED. See below: two providers
|
|
8
|
+
* cannot express "stopped to call a tool" in their finish-reason field at all, so the round's own
|
|
9
|
+
* content is the only place that fact exists.
|
|
10
|
+
* @returns {string|null} a neutral value, an unrecognized value passed through verbatim, or `null`.
|
|
11
|
+
*/
|
|
12
|
+
export function normalizeStopReason(raw: string | null | undefined, provider: "anthropic" | "openai" | "gemini" | "ollama", ctx?: {
|
|
13
|
+
hasToolCalls?: boolean;
|
|
14
|
+
}): string | null;
|
|
15
|
+
/**
|
|
16
|
+
* Did this round get CUT OFF at the output-token cap?
|
|
17
|
+
*
|
|
18
|
+
* The one predicate the Loop acts on. Deliberately narrow: `context_exceeded`, `refusal` and
|
|
19
|
+
* `pause_turn` are all "not a normal finish" but they are NOT output-cap truncations and must not be
|
|
20
|
+
* folded in here — `pause_turn` in particular is a RESUMABLE state, and erroring on it would break
|
|
21
|
+
* server-side tool flows that are working exactly as designed.
|
|
22
|
+
*
|
|
23
|
+
* @param {string|null|undefined} stopReason - a NEUTRAL value (post-{@link normalizeStopReason}).
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
export function isTruncated(stopReason: string | null | undefined): boolean;
|