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.
@@ -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
- toolCalls.push({ id: block.id, name: block.name, arguments: block.input });
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
 
@@ -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
  };
@@ -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
  */
@@ -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
- ...(options.temperature != null && { options: { temperature: options.temperature } }),
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: (msg.tool_calls || []).map((/** @type {any} */ tc) => ({
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,
@@ -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: (msg.tool_calls || []).map((/** @type {any} */ tc) => ({
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/litectx-enumerate-spec.md). Returns the generic async slice-source recurse's scan reads: a
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
@@ -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/litectx-enumerate-spec.md) and drops in behind this
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/litectx-enumerate-spec.md). Returns the generic async slice-source recurse's scan reads: a
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 back to the lossless concat rather than
66
- // losing the partials entirely. recurse still reports honest completeness via its own paths.
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/litectx-enumerate-spec.md) — so we return an
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