bare-agent 0.26.2 → 0.28.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 +55 -2
- package/package.json +1 -1
- package/src/loop.d.ts +51 -2
- package/src/loop.js +231 -18
- package/src/provider-anthropic.d.ts +32 -2
- package/src/provider-anthropic.js +106 -4
- 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 +34 -0
- package/src/provider-stop-reason.js +200 -0
- package/src/recurse-retrieval.d.ts +1 -1
- package/src/recurse-retrieval.js +2 -2
- package/src/recurse-synthesize.js +11 -2
- package/src/recurse.js +1 -1
- package/tools/shell.d.ts +13 -2
- package/tools/shell.js +28 -8
- package/types/index.d.ts +46 -0
|
@@ -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-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,34 @@
|
|
|
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
|
+
* Retained for back-compat (it was the Loop's original BA-6 gate). The Loop now routes through
|
|
24
|
+
* {@link classifyStopReason} instead — `isTruncated(x)` is exactly `classifyStopReason(x) === 'truncated'`.
|
|
25
|
+
*
|
|
26
|
+
* @param {string|null|undefined} stopReason - a NEUTRAL value (post-{@link normalizeStopReason}).
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
export function isTruncated(stopReason: string | null | undefined): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* @param {string|null|undefined} stopReason - a NEUTRAL value (post-{@link normalizeStopReason}).
|
|
32
|
+
* @returns {'truncated'|'refusal'|'context_exceeded'|'resume'|null}
|
|
33
|
+
*/
|
|
34
|
+
export function classifyStopReason(stopReason: string | null | undefined): "truncated" | "refusal" | "context_exceeded" | "resume" | null;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BA-6 — normalize each provider's native finish-reason field to one neutral vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* Every provider tells you WHY generation ended. Before this, bare-agent read the field on none of
|
|
5
|
+
* them (`grep -rn 'stop_reason\|finish_reason\|done_reason' src/` → zero hits), so a round the API
|
|
6
|
+
* CUT OFF at the token cap was indistinguishable from one the model chose to end — the Loop's rule is
|
|
7
|
+
* "no tool calls ⇒ final answer", and a truncation has no tool calls, so it returned as a clean finish
|
|
8
|
+
* with `error: null`. A truncation was laundered into a completion.
|
|
9
|
+
*
|
|
10
|
+
* The neutral vocabulary (what the Loop is allowed to reason about):
|
|
11
|
+
*
|
|
12
|
+
* 'end_turn' the model finished of its own accord — the ONLY clean finish
|
|
13
|
+
* 'max_tokens' CUT OFF at the output cap. NOT a finish. Load-bearing (see below).
|
|
14
|
+
* 'tool_use' stopped to call a tool, and the call is COMPLETE
|
|
15
|
+
* 'stop_sequence' hit a caller-supplied stop string — a legitimate finish
|
|
16
|
+
* 'refusal' declined on safety grounds (Anthropic `refusal`, OpenAI `content_filter`, …)
|
|
17
|
+
* 'pause_turn' server-side tool loop paused; the caller is expected to RESUME, not to error
|
|
18
|
+
* 'context_exceeded' ran out of CONTEXT WINDOW (distinct from running out of output budget)
|
|
19
|
+
* null provider didn't say / we don't recognize it
|
|
20
|
+
*
|
|
21
|
+
* `null` is the safe default and it is deliberate: an unmapped or absent value reproduces the
|
|
22
|
+
* pre-BA-6 behavior exactly. A wrong guess therefore degrades to the status quo rather than inventing
|
|
23
|
+
* a false truncation error on a healthy run. Unknown-but-present values pass through verbatim (a
|
|
24
|
+
* caller can still see them) but the Loop only ever ACTS on the values above.
|
|
25
|
+
*
|
|
26
|
+
* ── Why 'max_tokens' vs 'tool_use' is the load-bearing distinction (measured, not assumed) ──
|
|
27
|
+
*
|
|
28
|
+
* `poc/ba6-stop-reason-mapping.mjs`, real API, claude-sonnet-5 + gpt-4o-mini:
|
|
29
|
+
*
|
|
30
|
+
* a COMPLETE tool call ALWAYS arrives tagged 'tool_use' — never 'max_tokens'.
|
|
31
|
+
*
|
|
32
|
+
* Anthropic returned `stop_reason: "tool_use"` with intact arguments even at a tight 1024-token cap;
|
|
33
|
+
* OpenAI refuses outright (HTTP 400) rather than emit a tool call it could not finish. Neither ever
|
|
34
|
+
* handed back a COMPLETE tool call tagged as truncated.
|
|
35
|
+
*
|
|
36
|
+
* The converse is the dangerous case, and it is not hypothetical — it is the BA-4 file-zeroing bug one
|
|
37
|
+
* layer up: a round tagged 'max_tokens' that CARRIES a tool call carries a tool call that was cut off
|
|
38
|
+
* mid-generation, whose arguments are missing keys. That is precisely how a `claude-haiku-4-5` worker
|
|
39
|
+
* emptied a 1789-line file — it hit the output cap mid-`shell_write`, the `content` argument never
|
|
40
|
+
* arrived, and the truncated call was executed as if whole. So the Loop must NEVER execute the tool
|
|
41
|
+
* calls of a 'max_tokens' round. Refusing costs nothing legitimate (complete calls come back
|
|
42
|
+
* 'tool_use') and closes the data-loss path at the protocol layer, for every tool, not just
|
|
43
|
+
* `shell_write`.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** Anthropic `stop_reason` → neutral. */
|
|
47
|
+
const ANTHROPIC = {
|
|
48
|
+
end_turn: 'end_turn',
|
|
49
|
+
max_tokens: 'max_tokens',
|
|
50
|
+
tool_use: 'tool_use',
|
|
51
|
+
stop_sequence: 'stop_sequence',
|
|
52
|
+
refusal: 'refusal',
|
|
53
|
+
pause_turn: 'pause_turn',
|
|
54
|
+
model_context_window_exceeded: 'context_exceeded',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** OpenAI (and OpenAI-compatible) `finish_reason` → neutral. */
|
|
58
|
+
const OPENAI = {
|
|
59
|
+
stop: 'end_turn',
|
|
60
|
+
length: 'max_tokens',
|
|
61
|
+
tool_calls: 'tool_use',
|
|
62
|
+
function_call: 'tool_use',
|
|
63
|
+
content_filter: 'refusal',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Gemini `finishReason` → neutral. Gemini does NOT tag a function call specially — a complete tool call
|
|
68
|
+
* comes back as `STOP` with a `functionCall` part (measured live) — so there is no `tool_use` row here
|
|
69
|
+
* by design. `normalizeStopReason` derives it from `hasToolCalls` instead; see the note there.
|
|
70
|
+
*/
|
|
71
|
+
const GEMINI = {
|
|
72
|
+
STOP: 'end_turn',
|
|
73
|
+
MAX_TOKENS: 'max_tokens',
|
|
74
|
+
SAFETY: 'refusal',
|
|
75
|
+
RECITATION: 'refusal',
|
|
76
|
+
BLOCKLIST: 'refusal',
|
|
77
|
+
PROHIBITED_CONTENT: 'refusal',
|
|
78
|
+
SPII: 'refusal',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Ollama `done_reason` → neutral. `load`/`unload` are lifecycle values, not completions — they map to
|
|
83
|
+
* null (unknown) rather than being forced into the vocabulary.
|
|
84
|
+
*/
|
|
85
|
+
const OLLAMA = {
|
|
86
|
+
stop: 'end_turn',
|
|
87
|
+
length: 'max_tokens',
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const TABLES = {
|
|
91
|
+
anthropic: ANTHROPIC,
|
|
92
|
+
openai: OPENAI,
|
|
93
|
+
gemini: GEMINI,
|
|
94
|
+
ollama: OLLAMA,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Map a provider's native finish-reason value onto the neutral vocabulary.
|
|
99
|
+
*
|
|
100
|
+
* @param {string|null|undefined} raw - the provider's native value (`stop_reason` / `finish_reason` /
|
|
101
|
+
* `finishReason` / `done_reason`). Absent or non-string ⇒ `null` (pre-BA-6 behavior).
|
|
102
|
+
* @param {'anthropic'|'openai'|'gemini'|'ollama'} provider - which table to read.
|
|
103
|
+
* @param {{hasToolCalls?: boolean}} [ctx] - what the round actually CARRIED. See below: two providers
|
|
104
|
+
* cannot express "stopped to call a tool" in their finish-reason field at all, so the round's own
|
|
105
|
+
* content is the only place that fact exists.
|
|
106
|
+
* @returns {string|null} a neutral value, an unrecognized value passed through verbatim, or `null`.
|
|
107
|
+
*/
|
|
108
|
+
function normalizeStopReason(raw, provider, ctx = {}) {
|
|
109
|
+
if (typeof raw !== 'string' || raw === '') return null;
|
|
110
|
+
const table = TABLES[provider];
|
|
111
|
+
if (!table) return raw;
|
|
112
|
+
// An unrecognized-but-present value passes through: the caller can still SEE it, and the Loop only
|
|
113
|
+
// acts on the known vocabulary — so a new upstream value can never be mistaken for a truncation.
|
|
114
|
+
// Own-property only (mirror of classifyStopReason): `raw` is a provider/proxy-supplied field, so a
|
|
115
|
+
// value like 'toString'/'constructor' would otherwise resolve `table[raw]` to an inherited
|
|
116
|
+
// Object.prototype function (truthy) and be returned in place of the verbatim string.
|
|
117
|
+
const mapped = Object.prototype.hasOwnProperty.call(table, raw) ? table[raw] : raw;
|
|
118
|
+
|
|
119
|
+
// GEMINI AND OLLAMA HAVE NO `tool_use` FINISH REASON (both measured live: Gemini returns
|
|
120
|
+
// `finishReason: STOP` and Ollama `done_reason: 'stop'` on a round that emitted a complete function
|
|
121
|
+
// call). Reported verbatim, a round that stopped TO CALL A TOOL would come back as `end_turn` — "the
|
|
122
|
+
// model finished of its own accord" — on 2 of 5 providers and `tool_use` on the other 3.
|
|
123
|
+
//
|
|
124
|
+
// That is the BA-6 defect class in miniature: a round that is NOT a finish, reporting as a finish.
|
|
125
|
+
// An adopter branching on `stopReason === 'end_turn'` would be right on Anthropic/OpenAI and wrong
|
|
126
|
+
// on Gemini/Ollama. So derive it from what the round CARRIED — the model did stop to call a tool,
|
|
127
|
+
// and that is a report, not an invention.
|
|
128
|
+
//
|
|
129
|
+
// Narrow on purpose: only ever promotes `end_turn` → `tool_use`. It cannot touch `max_tokens` (a
|
|
130
|
+
// truncated round carrying a half-generated call must stay TRUNCATED — that is BA-4's mechanism),
|
|
131
|
+
// nor `refusal`/`pause_turn`/`context_exceeded`, nor an unrecognized passthrough value.
|
|
132
|
+
if (mapped === 'end_turn' && ctx.hasToolCalls === true) return 'tool_use';
|
|
133
|
+
return mapped;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Did this round get CUT OFF at the output-token cap?
|
|
138
|
+
*
|
|
139
|
+
* The one predicate the Loop acts on. Deliberately narrow: `context_exceeded`, `refusal` and
|
|
140
|
+
* `pause_turn` are all "not a normal finish" but they are NOT output-cap truncations and must not be
|
|
141
|
+
* folded in here — `pause_turn` in particular is a RESUMABLE state, and erroring on it would break
|
|
142
|
+
* server-side tool flows that are working exactly as designed.
|
|
143
|
+
*
|
|
144
|
+
* Retained for back-compat (it was the Loop's original BA-6 gate). The Loop now routes through
|
|
145
|
+
* {@link classifyStopReason} instead — `isTruncated(x)` is exactly `classifyStopReason(x) === 'truncated'`.
|
|
146
|
+
*
|
|
147
|
+
* @param {string|null|undefined} stopReason - a NEUTRAL value (post-{@link normalizeStopReason}).
|
|
148
|
+
* @returns {boolean}
|
|
149
|
+
*/
|
|
150
|
+
function isTruncated(stopReason) {
|
|
151
|
+
return stopReason === 'max_tokens';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* BA-13 — classify a round's NEUTRAL stop reason into the terminal ACTION the Loop must take.
|
|
156
|
+
*
|
|
157
|
+
* BA-6 short-circuited exactly one non-clean stop reason (`max_tokens`). Every OTHER non-clean reason
|
|
158
|
+
* — `refusal`, `context_exceeded`, `pause_turn` — fell through the Loop's "no tool calls ⇒ final
|
|
159
|
+
* answer" rule and was laundered into a clean `error: null` empty success (the BA-4/5/6/7 bug class:
|
|
160
|
+
* an under-modeled boundary round rounding optimistically toward "done"). A `RECITATION` refusal fires
|
|
161
|
+
* on entirely BENIGN prompts, so this was reachable on ordinary runs, and it propagated up `recurse`'s
|
|
162
|
+
* agent tree as a converged sub-task.
|
|
163
|
+
*
|
|
164
|
+
* One table with an EXPLICIT pass-through default replaces the single `if (isTruncated)` — the BA-7
|
|
165
|
+
* lesson ("don't parse-key on a closed set") applied to termination: BA-6 added one leg, BA-13 adds
|
|
166
|
+
* two terminals plus one resume, and the NEXT new stop reason degrades to pass-through (status quo)
|
|
167
|
+
* rather than re-breeding the bug.
|
|
168
|
+
*
|
|
169
|
+
* 'truncated' `max_tokens` — cut off at the output cap (BA-6). Loop returns `error:'truncated:max_tokens'`.
|
|
170
|
+
* 'refusal' declined on safety grounds. Loop returns `error:'refusal'` + partial text.
|
|
171
|
+
* 'context_exceeded' ran out of context window. Loop returns `error:'context_exceeded'` + partial text.
|
|
172
|
+
* 'resume' `pause_turn` — a RESUMABLE server-tool pause. NOT terminal, NOT an error: the
|
|
173
|
+
* Loop CONTINUES the round loop (bounded by HARD_ROUND_LIMIT / the gate's maxTurns).
|
|
174
|
+
* null pass-through: `end_turn` / `stop_sequence` / `tool_use` / an unrecognized value /
|
|
175
|
+
* absent. The Loop's existing tool-exec / final-answer logic runs unchanged.
|
|
176
|
+
*
|
|
177
|
+
* NB: `tool_use` is already derived by {@link normalizeStopReason} from what the round carried, so it
|
|
178
|
+
* needs no row here — a round that stopped to call a complete tool passes through to tool execution.
|
|
179
|
+
*/
|
|
180
|
+
const TERMINAL_ACTIONS = /** @type {Record<string, 'truncated'|'refusal'|'context_exceeded'|'resume'>} */ ({
|
|
181
|
+
max_tokens: 'truncated',
|
|
182
|
+
refusal: 'refusal',
|
|
183
|
+
context_exceeded: 'context_exceeded',
|
|
184
|
+
pause_turn: 'resume',
|
|
185
|
+
});
|
|
186
|
+
/**
|
|
187
|
+
* @param {string|null|undefined} stopReason - a NEUTRAL value (post-{@link normalizeStopReason}).
|
|
188
|
+
* @returns {'truncated'|'refusal'|'context_exceeded'|'resume'|null}
|
|
189
|
+
*/
|
|
190
|
+
function classifyStopReason(stopReason) {
|
|
191
|
+
if (typeof stopReason !== 'string') return null;
|
|
192
|
+
// Own-property only: `normalizeStopReason` passes an unrecognized value through verbatim, so a
|
|
193
|
+
// provider/proxy emitting stop_reason:'toString'/'constructor'/etc. would otherwise resolve to an
|
|
194
|
+
// inherited Object.prototype function (truthy) and be mistaken for a terminal action.
|
|
195
|
+
return Object.prototype.hasOwnProperty.call(TERMINAL_ACTIONS, stopReason)
|
|
196
|
+
? TERMINAL_ACTIONS[stopReason]
|
|
197
|
+
: null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = { normalizeStopReason, isTruncated, classifyStopReason };
|
|
@@ -143,7 +143,7 @@ export function buildScanTool(corpus: Slice[] | (() => Promise<Slice[]>), opts:
|
|
|
143
143
|
/**
|
|
144
144
|
* Build a SCAN slice-source backed by a litectx-RESIDENT corpus (facts/episodes the agent already accrued) —
|
|
145
145
|
* the §10-step-7 deferral un-blocked by litectx 0.26's `enumerate` verb (spec:
|
|
146
|
-
* docs/01-product/
|
|
146
|
+
* docs/01-product/prd.md). Returns the generic async slice-source recurse's scan reads: a
|
|
147
147
|
* `() => Promise<Slice[]>` that pages through EVERY row of `kind` via `enumerate` (exhaustive — the rank-free
|
|
148
148
|
* read `recall` structurally cannot do) and maps each to `{id: item.path, text: item.body}`. recurse stays
|
|
149
149
|
* litectx-agnostic: it depends on this source SHAPE, never on litectx (same stance as `remember`'s Store
|
package/src/recurse-retrieval.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
//
|
|
17
17
|
// THE CORPUS FOR SCAN IS A GENERIC ARRAY SLICE-SOURCE (`opts.corpus`), NOT litectx: litectx has no exhaustive,
|
|
18
18
|
// rank-free enumerate verb today (every read is FTS-gated). The "corpus that already LIVES in litectx" case
|
|
19
|
-
// waits on the litectx `enumerate` verb (docs/01-product/
|
|
19
|
+
// waits on the litectx `enumerate` verb (docs/01-product/prd.md) and drops in behind this
|
|
20
20
|
// same slice-source socket with ZERO recurse changes — the same backend-agnostic stance as `remember`'s Store
|
|
21
21
|
// socket. Composes AROUND a Loop; NEVER imported by loop.js.
|
|
22
22
|
|
|
@@ -326,7 +326,7 @@ const ENUM_PAGE = 200;
|
|
|
326
326
|
/**
|
|
327
327
|
* Build a SCAN slice-source backed by a litectx-RESIDENT corpus (facts/episodes the agent already accrued) —
|
|
328
328
|
* the §10-step-7 deferral un-blocked by litectx 0.26's `enumerate` verb (spec:
|
|
329
|
-
* docs/01-product/
|
|
329
|
+
* docs/01-product/prd.md). Returns the generic async slice-source recurse's scan reads: a
|
|
330
330
|
* `() => Promise<Slice[]>` that pages through EVERY row of `kind` via `enumerate` (exhaustive — the rank-free
|
|
331
331
|
* read `recall` structurally cannot do) and maps each to `{id: item.path, text: item.body}`. recurse stays
|
|
332
332
|
* litectx-agnostic: it depends on this source SHAPE, never on litectx (same stance as `remember`'s Store
|
|
@@ -62,8 +62,17 @@ async function mergeReduce(task, results, opts) {
|
|
|
62
62
|
if (typeof out.error === 'string' && out.error.startsWith('halt:')) {
|
|
63
63
|
throw new HaltError('[synthesize] merge halted by governance', { rule: out.error.slice('halt:'.length) });
|
|
64
64
|
}
|
|
65
|
-
// A non-halt fault (e.g. provider error) is non-fatal here — fall
|
|
66
|
-
// losing the partials entirely. recurse still reports honest
|
|
65
|
+
// A non-halt fault (e.g. provider error, deny short-circuit, the hard round limit) is non-fatal here — fall
|
|
66
|
+
// back to the LOSSLESS concat rather than losing the partials entirely. recurse still reports honest
|
|
67
|
+
// completeness via its own paths.
|
|
68
|
+
//
|
|
69
|
+
// Branch on `out.error`, NOT on the falsiness of `out.text` (BA-5). Since the Loop now preserves the text a
|
|
70
|
+
// bounded run produced, a faulted merge returns its PARTIAL, aborted prose — so `out.text || concat` would
|
|
71
|
+
// silently ship that fragment as the synthesized answer and every child result would be lost. Proven
|
|
72
|
+
// reachable: the merge Loop registers no tools, so a hallucinated tool call is fed back as
|
|
73
|
+
// `[Loop] Unknown tool` and the round loop CONTINUES — round 1 emits prose, round 2 dies, and the fallback
|
|
74
|
+
// never fires. `error` is the sole success signal; text-falsiness never was one.
|
|
75
|
+
if (out.error) return concatReduce(results);
|
|
67
76
|
return out.text || concatReduce(results);
|
|
68
77
|
}
|
|
69
78
|
|
package/src/recurse.js
CHANGED
|
@@ -708,7 +708,7 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
|
|
|
708
708
|
* never folded into the count as a zero; a governance HaltError mid-scan → clean incomplete.
|
|
709
709
|
*
|
|
710
710
|
* The corpus is the generic array slice-source `opts.corpus`. Absent it, scan has nothing to read — litectx's
|
|
711
|
-
* resident-corpus enumerate path is deferred (docs/01-product/
|
|
711
|
+
* resident-corpus enumerate path is deferred (docs/01-product/prd.md) — so we return an
|
|
712
712
|
* honest incomplete, never a fabricated zero.
|
|
713
713
|
* @param {string} task
|
|
714
714
|
* @param {RecurseCtx} ctx
|