bare-agent 0.23.0 → 0.25.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 CHANGED
@@ -94,7 +94,7 @@ Every piece works alone — take what you need, ignore the rest. Two axes: **Act
94
94
 
95
95
  ### Recurse — break a hard task into a tree *(the RLM primitive)*
96
96
 
97
- `recurse(task, ctx, opts)` does **decompose → fan-out → verify → synthesize** in one call — Recursive Language Models as a single import, composed *around* the Loop (never a new engine). The default is **model-driven**: the worker is handed a `spawn_child` tool and decides whether to split, bounded by depth + bareguard (no second guard layer). Forced fan-out (`count` / `mode:'fanout'`) and data-driven width (`mode:'partition'`, measured from a corpus) are opt-in. Give workers a stance with `opts.persona` (prepended to every worker, carries down the tree, deliberately kept out of the isolated verifier), and tell them *where they are* with `opts.context` (a read-only paths/cwd blob threaded to every worker so a sliced child can locate its artifact — facts, not a stance). For a leaf that should self-correct, pass `opts.refineLeaf` (opt-in): a definite leaf becomes a bounded generate→sense→regenerate loop driven by *your* deterministic sensor (test/compile/lint), feeding the gap back with escalating temperature. The headline guarantee: **aggregation is code, never a model-stated number**, and a dead worker or exhausted guard returns an honest `{ incomplete, missingSlices }` — never a faked pass.
97
+ `recurse(task, ctx, opts)` does **decompose → fan-out → verify → synthesize** in one call — Recursive Language Models as a single import, composed *around* the Loop (never a new engine). The default is **model-driven**: the worker is handed a `spawn_child` tool and decides whether to split, bounded by depth + bareguard (no second guard layer). Forced fan-out (`count` / `mode:'fanout'`) and data-driven width (`mode:'partition'`, measured from a corpus) are opt-in. Give workers a stance with `opts.persona` (prepended to every worker, carries down the tree, deliberately kept out of the isolated verifier), and tell them *where they are* with `opts.context` (a read-only paths/cwd blob threaded to every worker so a sliced child can locate its artifact — facts, not a stance). For a leaf that should self-correct, pass `opts.refineLeaf` (opt-in): a definite leaf becomes a bounded generate→sense→regenerate loop driven by *your* deterministic sensor (test/compile/lint), feeding the gap back (with escalating temperature on models that accept it; on a temperature-fixed model like `claude-sonnet-5` the gap critique carries recovery, and the receipt records the effective temps). The headline guarantee: **aggregation is code, never a model-stated number**, and a dead worker or exhausted guard returns an honest `{ incomplete, missingSlices }` — never a faked pass.
98
98
 
99
99
  Over a corpus, context reaches a worker as a **handle routed by question shape** (`opts.retrieval`):
100
100
 
@@ -119,9 +119,9 @@ console.log(result.count, result.matchedIds); // a code-derived count + the id
119
119
 
120
120
  > **⚠️ Cost is open by design — wire a cap.** `recurse()` adds no intrinsic total-work limit. On the model-driven default a node can spawn up to ~100 children per level, each recursing to `maxDepth` (default 3), so **token / $ spend compounds and is bounded only by your gate** — not by recurse. Run it **with bareguard** (`ctx.policy`, which enforces depth/budget/call caps) **or with some token/USD cap** for any non-trivial or untrusted task; ungoverned, a weak model that over-decomposes *will* burn tokens. For a hard local brake without a gate, set `maxDepth: 1` (flat, no nesting). The forced modes (`mode:'fanout'` / `'partition'`) are bounded by a deterministic count + concurrency cap; the open path is the model-driven default.
121
121
 
122
- **Govern — one gate over both axes.** `wireGate(gate)` routes every LLM + tool call through one bareguard policy + audit + budget. Denied tools never reach the model; halts (turn / budget / content caps) exit cleanly. `require('bare-agent/bareguard')`
122
+ **Govern — one gate over both axes.** `wireGate(gate)` routes every LLM + tool call through one bareguard policy + audit + budget. Denied tools never reach the model; halts (turn / budget / content caps) exit cleanly. A plain deny stays advisory (the model can pivot to an allowed tool), but the Loop short-circuits a *spin* — `maxConsecutiveDenials` consecutive denials of the same action (default 3) stop the run with `error:'denied:<tool>'` instead of burning the budget to the cap; under `recurse` that surfaces as `{ incomplete, blocker:'governance-deny' }`. `require('bare-agent/bareguard')`
123
123
 
124
- **Providers:** OpenAI-compatible (OpenAI, OpenRouter, Groq, vLLM, LM Studio), Anthropic, Gemini (native), Ollama, CLIPipe, Fallback — or bring your own (one `generate` method). All return the same shape; swap freely. Usage including prompt-cache tiers is normalized, so `result.metrics` reports honest cumulative tokens + cost — and `null`, never a silent `0`, for a model it couldn't price.
124
+ **Providers:** OpenAI-compatible (OpenAI, OpenRouter, Groq, vLLM, LM Studio), Anthropic, Gemini (native), Ollama, CLIPipe, Fallback — or bring your own (one `generate` method). All return the same shape; swap freely. Usage including prompt-cache tiers is normalized, so `result.metrics` reports honest cumulative tokens + cost — and `null`, never a silent `0`, for a model it couldn't price. A model that rejects a non-default `temperature` (e.g. `claude-sonnet-5`, OpenAI o1/gpt-5-class return a `400`) is handled gracefully — the provider drops the param and retries once rather than failing the call, surfacing `temperatureDropped` so a caller can report the effective value.
125
125
 
126
126
  **Tools:** Any function is a tool — REST, MCP, CLI, shell. Built-in web + mobile (optional).
127
127
 
@@ -1,7 +1,7 @@
1
1
  # bareagent — Integration Guide
2
2
 
3
3
  > For AI assistants and developers wiring bareagent into a project.
4
- > v0.23.0 | Node.js >= 18 | zero required deps (`bareguard ^0.9.0` optional peer for governance) | Apache 2.0
4
+ > v0.25.0 | Node.js >= 18 | zero required deps (`bareguard ^0.9.0` optional peer for governance) | Apache 2.0
5
5
  >
6
6
  > Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)
7
7
 
@@ -379,6 +379,8 @@ if (result.error?.startsWith('halt:')) {
379
379
 
380
380
  Halt-severity decisions exit the loop cleanly via a typed `HaltError` — full mechanics (sealed `msgs`, `halt:<rule>` error token, `loop:done{halted:true}` event, `throwOnError:true` interaction, `halt:unknown` coalesce) are in the **Halt decisions throw `HaltError`** paragraph below. Short version: check `result.error?.startsWith('halt:')` after the run.
381
381
 
382
+ **Deny-spin short-circuit (`maxConsecutiveDenials`, default 3, v0.25+).** A *non-halt* deny (a `policy` verdict that isn't `true` — e.g. a `humanChannel: deny`, an allowlist miss, a `content`/`fs.writeScope` block) is **advisory**: it's fed back to the model as a tool result so the model can pivot to a different allowed tool. But a model that keeps retrying the *same* denied action would otherwise spin every round until your `budget.maxCostUsd` finally halts it — burning the whole cap with no progress (this bit a coding agent whose write kept tripping `content.askPatterns`). The Loop now counts **consecutive** denials (any allowed call resets the streak, preserving the pivot) and short-circuits at `maxConsecutiveDenials` with `result.error === 'denied:<tool>'` (a clean return, transcript sealed — never a throw). Check `result.error?.startsWith('denied:')` to distinguish a governance block from a completed run; set `maxConsecutiveDenials: 0` (or `Infinity`) on `new Loop({...})` to restore the pure-advisory behavior. Under `recurse`, a short-circuited worker returns a **labeled** `{ incomplete: true, blocker: 'governance-deny' }` (and `receipts.blocker`) so you can widen scope / re-gate / escalate rather than read it as a model failure.
383
+
382
384
  Legacy `wrapTool` / `wrapTools` are retained as deprecation shims (one-shot console warning, removal in 1.0). Migration: replace `wrapTools(tools)` at `loop.run()` with `filterTools(tools)` once upfront + `onLlmResult` / `onToolResult` on `new Loop({...})` to pick up LLM-cost recording and `_ctx` threading.
383
385
 
384
386
  **`actionTranslator` for bash/fs primitive activation (v0.10.1+).** Bareguard's `bashCheck` / `fsCheck` / `netCheck` only fire when `action.type === 'bash'` / `'read'` / `'write'` / `'fetch'`. The default action shape is `{type: toolName, args, _ctx}` which matches `tools.denylist` / `tools.allowlist` but does NOT activate those primitives. Adopters who want both pass `wireGate(gate, { actionTranslator })`. Since bareguard 0.4.1+, the primitives read fields from either flat (`action.cmd`) or nested (`action.args.cmd` / `.command`) shapes, so you can pass args through verbatim:
@@ -673,7 +675,7 @@ const out = await recurse('Audit auth.js, billing.js, gateway.js for authz bugs'
673
675
 
674
676
  **Worker context (`opts.context`, v0.23.0):** a read-only working-context string (paths/cwd) PREPENDED to every worker's TASK message as a `Working context:` block — so a sliced child can **locate its artifact** (the Planner paraphrases the goal into subtasks and drops absolute paths; without this, workers guess `.`/`~`/`/tmp` and get denied). Forwarded to the Planner as `info` (path-aware slices) and shown to the verifier too (neutral FACTS, not a stance — distinct from `persona`, which is a privileged SYSTEM-prompt stance). Carries down the tree. **Security:** it still becomes part of the prompt, so pass caller-trusted run-state only, never untrusted/end-user text (lower-privilege than `persona` — user message, not system — but still an injection surface). Absent ⇒ the task message is unchanged.
675
677
 
676
- **Leaf self-correction (`opts.refineLeaf`, v0.23.0, opt-in):** turn a **definite leaf** (a node offered no `spawn_child` — `simple` tier or at `maxDepth`) into a bounded generate→sense→regenerate loop instead of a single pass: `{ sensor, maxIterations?, temperatures? }`. `sensor(result, { task, context, contract }) → Verdict` is YOUR **deterministic** close (test/compile/lint — not a model judge); on a non-pass its `critique` (the gap, not the transcript) is fed FRESH into the next attempt and the **retry temperature ESCALATES** (default `[0.2, 0.7, 1.0]` — load-bearing: a weak model at a flat temperature regenerates identical wrong code and ignores even crisp feedback). Each attempt is gate-checked + metered; a HaltError mid-loop → clean `{ incomplete }`; honest non-recovery → `receipts.refineLeaf.passed === false` (never a faked pass); `receipts.tokens` sums all attempts. The error-keyed `recall` stays YOUR tool (`opts.tools`), keyed off the fed-back critique — bareagent stays litectx-agnostic. Carries down (engages at the leaves). Absent ⇒ a leaf is a single pass.
678
+ **Leaf self-correction (`opts.refineLeaf`, v0.23.0, opt-in):** turn a **definite leaf** (a node offered no `spawn_child` — `simple` tier or at `maxDepth`) into a bounded generate→sense→regenerate loop instead of a single pass: `{ sensor, maxIterations?, temperatures? }`. `sensor(result, { task, context, contract }) → Verdict` is YOUR **deterministic** close (test/compile/lint — not a model judge); on a non-pass its `critique` (the gap, not the transcript) is fed FRESH into the next attempt and — **on models that accept `temperature`** — the **retry temperature ESCALATES** (default `[0.2, 0.7, 1.0]` — load-bearing there: a weak model at a flat temperature regenerates identical wrong code and ignores even crisp feedback). On a **temperature-fixed model** (e.g. `claude-sonnet-5`, which 400s any non-default temperature) the provider silently drops the param (see below), the escalation lever is inert, and the fed-back gap critique carries recovery alone; `receipts.refineLeaf.temperatures` then records the EFFECTIVE temps — a `null` marks an attempt that ran at the model's default (never the ignored requested value). Each attempt is gate-checked + metered; a HaltError mid-loop → clean `{ incomplete }`; honest non-recovery → `receipts.refineLeaf.passed === false` (never a faked pass); `receipts.tokens` sums all attempts. The error-keyed `recall` stays YOUR tool (`opts.tools`), keyed off the fed-back critique — bareagent stays litectx-agnostic. Carries down (engages at the leaves). Absent ⇒ a leaf is a single pass.
677
679
 
678
680
  ```javascript
679
681
  const out = await recurse('Fix the failing function in calc.js', ctx, {
@@ -741,6 +743,8 @@ new CLIPipe({ command: 'ollama', args: ['run', 'llama3.2'] })
741
743
 
742
744
  All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model? }`. The optional `model` (v0.16.1+) is the id the response was produced by — Loop prefers it over `provider.model` for cost accounting. CLIPipe always returns `toolCalls: []` and zero usage (CLI tools don't report tokens), and omits `model`.
743
745
 
746
+ **Temperature graceful degradation (BA-10).** Newer models reject ANY non-default `temperature` with a `400` (`claude-sonnet-5`: `` `temperature` is deprecated for this model. ``; OpenAI o1/gpt-5-class: `Unsupported value: 'temperature' … Only the default (1) …`). All four providers detect that specific 400 (message names `temperature` as unsupported/deprecated AND a temperature was sent), **drop the param, warn once per instance, and retry once** — so a call that would otherwise throw succeeds at the model's default temperature. Keyed off the API error text, not a model list. A genuine out-of-range 400 is NOT degraded (it re-throws — dropping it would mask a caller bug). When a drop happens the result carries `temperatureDropped: true` (an optional `GenerateResult`/`Loop.run` field) so a caller can report the effective temperature — `recurse`'s `refineLeaf` uses it for an honest receipt. Dormant on models that accept temperature (byte-identical to before).
747
+
744
748
  **Error body (v0.11.0):** on an HTTP error the OpenAI/Anthropic/Ollama providers throw a `ProviderError` whose `message` carries the upstream error string. The full parsed response is **not** attached to `err.body` by default (so an unexpected field can't leak through logs that dump the error object). Pass `{ exposeErrorBody: true }` to attach it for debugging.
745
749
 
746
750
  **Plaintext-key warning (Unreleased):** the OpenAI provider's `baseUrl` accepts `http://` (for local/OpenAI-compatible endpoints), but a `Bearer` key sent over plaintext http to a **non-loopback** host is exposed on the wire. The provider now warns once when that happens. Loopback hosts (`localhost`/`127.0.0.0/8`/`::1` — local proxies, Ollama-style endpoints) stay silent, since that's the legitimate keyless-local case. The header is **not** stripped (some local proxies want a key), so use `https` for any remote endpoint, or drop `apiKey` when the local endpoint needs none.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
package/src/loop.d.ts CHANGED
@@ -52,6 +52,16 @@ export type LoopOptions = {
52
52
  */
53
53
  onLlmResult?: Function | undefined;
54
54
  onToolResult?: Function | undefined;
55
+ /**
56
+ * - BA-11 safety net (default 3). Short-circuit the run when
57
+ * `policy` denies this many tool calls IN A ROW with no allowed call in between — a governance deny is
58
+ * not a recoverable tool error, so a model that keeps retrying variants of a denied action would
59
+ * otherwise burn the budget to the cap without progress (probe-16: 16 calls, sensor never reached). Any
60
+ * tool call that PASSES policy resets the streak, preserving allowlist-safe pivoting (deny X → allow Y).
61
+ * The run returns cleanly with `error: 'denied:<tool>'` (mirrors the halt return; never throws even under
62
+ * throwOnError). Set `0` or `Infinity` to disable (restores pre-BA-11 advisory-deny behavior).
63
+ */
64
+ maxConsecutiveDenials?: number | undefined;
55
65
  /**
56
66
  * - Removed in v0.8; presence throws a migration error.
57
67
  */
@@ -79,6 +89,7 @@ export class Loop {
79
89
  throwOnError: boolean;
80
90
  store: import("../types").Store | null;
81
91
  policy: Function | null;
92
+ maxConsecutiveDenials: number;
82
93
  assemble: Function | null;
83
94
  trim: Function | null;
84
95
  onLlmResult: Function | null;
@@ -111,7 +122,7 @@ export class Loop {
111
122
  * thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
112
123
  * unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
113
124
  * @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
114
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics}>}
125
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
115
126
  * On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
116
127
  * thrown HaltError carried no `rule`), and `msgs` is sanitized so any
117
128
  * dangling assistant `tool_calls` from the halted round are paired with
@@ -129,6 +140,7 @@ export class Loop {
129
140
  error: string | null;
130
141
  msgs: Message[];
131
142
  metrics: RunMetrics;
143
+ temperatureDropped?: boolean;
132
144
  }>;
133
145
  /**
134
146
  * Health check — validates provider, store, and tools without throwing.
@@ -156,7 +168,7 @@ export class Loop {
156
168
  * @param {string} text - User message.
157
169
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
158
170
  * @param {Record<string, any>} [options={}] - Per-run overrides.
159
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics}>}
171
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
160
172
  */
161
173
  chat(text: string, tools?: ToolDef[], options?: Record<string, any>): Promise<{
162
174
  text: string;
@@ -166,6 +178,7 @@ export class Loop {
166
178
  error: string | null;
167
179
  msgs: Message[];
168
180
  metrics: RunMetrics;
181
+ temperatureDropped?: boolean;
169
182
  }>;
170
183
  stop(): void;
171
184
  }
@@ -226,6 +239,13 @@ export function estimateCost(model: string | null, usage: Usage | null): number
226
239
  * gate.record (via wireGate). `event.kind` discriminates the source: `'turn'` for a main-loop round,
227
240
  * `'summarize'` for an out-of-band `ctx.summarize` call (R-C6). Both count against the budget.
228
241
  * @property {Function} [onToolResult]
242
+ * @property {number} [maxConsecutiveDenials] - BA-11 safety net (default 3). Short-circuit the run when
243
+ * `policy` denies this many tool calls IN A ROW with no allowed call in between — a governance deny is
244
+ * not a recoverable tool error, so a model that keeps retrying variants of a denied action would
245
+ * otherwise burn the budget to the cap without progress (probe-16: 16 calls, sensor never reached). Any
246
+ * tool call that PASSES policy resets the streak, preserving allowlist-safe pivoting (deny X → allow Y).
247
+ * The run returns cleanly with `error: 'denied:<tool>'` (mirrors the halt return; never throws even under
248
+ * throwOnError). Set `0` or `Infinity` to disable (restores pre-BA-11 advisory-deny behavior).
229
249
  * @property {number} [maxRounds] - Removed in v0.8; presence throws a migration error.
230
250
  */
231
251
  /** @type {Record<string, {in: number, out: number, cacheReadMult?: number, cacheWriteMult?: number}>} */
package/src/loop.js CHANGED
@@ -49,6 +49,13 @@ const { ToolError, HaltError } = require('./errors');
49
49
  * gate.record (via wireGate). `event.kind` discriminates the source: `'turn'` for a main-loop round,
50
50
  * `'summarize'` for an out-of-band `ctx.summarize` call (R-C6). Both count against the budget.
51
51
  * @property {Function} [onToolResult]
52
+ * @property {number} [maxConsecutiveDenials] - BA-11 safety net (default 3). Short-circuit the run when
53
+ * `policy` denies this many tool calls IN A ROW with no allowed call in between — a governance deny is
54
+ * not a recoverable tool error, so a model that keeps retrying variants of a denied action would
55
+ * otherwise burn the budget to the cap without progress (probe-16: 16 calls, sensor never reached). Any
56
+ * tool call that PASSES policy resets the streak, preserving allowlist-safe pivoting (deny X → allow Y).
57
+ * The run returns cleanly with `error: 'denied:<tool>'` (mirrors the halt return; never throws even under
58
+ * throwOnError). Set `0` or `Infinity` to disable (restores pre-BA-11 advisory-deny behavior).
52
59
  * @property {number} [maxRounds] - Removed in v0.8; presence throws a migration error.
53
60
  */
54
61
 
@@ -212,6 +219,13 @@ class Loop {
212
219
  throw new Error('[Loop] options.policy must be a function (toolName, args, ctx) => true | string');
213
220
  }
214
221
  this.policy = options.policy || null;
222
+ // BA-11 deny-spin guard. Default 3; 0/Infinity/non-finite disables (restores advisory-deny behavior).
223
+ // Validated only if provided so an explicit 0 is honored as "off" (Infinity also disables).
224
+ if (options.maxConsecutiveDenials != null
225
+ && (typeof options.maxConsecutiveDenials !== 'number' || options.maxConsecutiveDenials < 0 || Number.isNaN(options.maxConsecutiveDenials))) {
226
+ throw new Error('[Loop] options.maxConsecutiveDenials must be a non-negative number (0 or Infinity disables)');
227
+ }
228
+ this.maxConsecutiveDenials = options.maxConsecutiveDenials != null ? options.maxConsecutiveDenials : 3;
215
229
  if (options.assemble != null && typeof options.assemble !== 'function') {
216
230
  throw new Error('[Loop] options.assemble must be a function (msgs, info) => msgs');
217
231
  }
@@ -294,7 +308,7 @@ class Loop {
294
308
  * thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
295
309
  * unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
296
310
  * @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
297
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics}>}
311
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
298
312
  * On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
299
313
  * thrown HaltError carried no `rule`), and `msgs` is sanitized so any
300
314
  * dangling assistant `tool_calls` from the halted round are paired with
@@ -356,6 +370,13 @@ class Loop {
356
370
 
357
371
  let lastUsage = { inputTokens: 0, outputTokens: 0 };
358
372
  let totalCost = 0;
373
+ // BA-10: sticky across rounds — true if ANY round's `temperature` was dropped by the model (400,
374
+ // unsupported/deprecated) and retried without it. Surfaced on the result so an upstream receipt
375
+ // (recurse's refineLeaf) can report the EFFECTIVE temperature rather than the ignored request.
376
+ let temperatureDropped = false;
377
+ // BA-11: consecutive policy-deny counter (reset by any tool call that PASSES policy). When it reaches
378
+ // this.maxConsecutiveDenials the run short-circuits cleanly — see the deny block below.
379
+ let consecutiveDenials = 0;
359
380
 
360
381
  // The meter (Feature 3): bareagent is the canonical run counter. Accumulates across rounds and is
361
382
  // returned as `result.metrics`. `tokens` is CUMULATIVE over all four tiers (fixes the last-round-only
@@ -583,6 +604,7 @@ class Loop {
583
604
  }
584
605
 
585
606
  lastUsage = result.usage || lastUsage;
607
+ if (result.temperatureDropped) temperatureDropped = true;
586
608
  // Publish the latest measured usage to ctx (non-enumerable, fail-open) so a transcript-bound seam —
587
609
  // e.g. F2 stash auto-compaction — can read EXACT provider-counted `inputTokens` to gauge context
588
610
  // pressure on the NEXT round's trim. Symmetric with lending ctx.summarize; the Loop stays unaware of
@@ -642,7 +664,7 @@ class Loop {
642
664
  try { await flush(msgs, ctx); }
643
665
  catch (err) { if (err instanceof HaltError) throw err; this._reportError('trim-flush', err, { round }); }
644
666
  }
645
- return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, msgs, metrics: finalizeMetrics() };
667
+ return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
646
668
  }
647
669
 
648
670
  // Execute tool calls
@@ -726,10 +748,30 @@ class Loop {
726
748
  : `[Loop] Tool "${tc.name}" denied by policy`;
727
749
  msgs.push({ role: 'tool', tool_call_id: tc.id, content: reason });
728
750
  this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, denied: true, reason } });
751
+ // BA-11: a governance deny is not a recoverable tool error. Count consecutive denials; when the
752
+ // model keeps retrying denied actions (proven live: 8 in a row before giving up) short-circuit the
753
+ // run rather than let it burn the budget to the cap. The streak resets on any ALLOWED tool call
754
+ // (below), so a legit deny-then-pivot (deny X → allow Y) never trips this. 0/Infinity disables.
755
+ consecutiveDenials += 1;
756
+ if (this.maxConsecutiveDenials > 0 && Number.isFinite(this.maxConsecutiveDenials)
757
+ && consecutiveDenials >= this.maxConsecutiveDenials) {
758
+ const denyTag = `denied:${tc.name}`;
759
+ // Pair any still-dangling tool_calls from this round so the returned transcript stays
760
+ // provider-valid (same seal the halt path uses), then exit cleanly — no throw even under
761
+ // throwOnError, mirroring the governance-halt contract.
762
+ sealDanglingToolCalls(msgs, denyTag);
763
+ this._reportError('denied', new Error(`policy denied ${consecutiveDenials} consecutive tool calls (${tc.name})`), { rule: denyTag, denials: consecutiveDenials });
764
+ this._safeEmit({ type: 'loop:done', data: { text: '', denied: true, rule: denyTag, cost: totalCost } });
765
+ return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, msgs, metrics: finalizeMetrics() };
766
+ }
729
767
  continue;
730
768
  }
731
769
  }
732
770
 
771
+ // BA-11: reaching here means this tool call PASSED policy (or there is no policy) — progress, so the
772
+ // consecutive-deny streak resets. A single deny followed by an allowed call never trips the guard.
773
+ consecutiveDenials = 0;
774
+
733
775
  const toolStartedAt = Date.now();
734
776
  let toolResult;
735
777
  let toolError;
@@ -863,7 +905,7 @@ class Loop {
863
905
  * @param {string} text - User message.
864
906
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
865
907
  * @param {Record<string, any>} [options={}] - Per-run overrides.
866
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics}>}
908
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
867
909
  */
868
910
  async chat(text, tools = [], options = {}) {
869
911
  this._history.push({ role: 'user', content: text });
@@ -54,6 +54,9 @@ export class AnthropicProvider {
54
54
  * @throws {Error} `[AnthropicProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
55
55
  */
56
56
  generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
57
+ /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
58
+ _warnTemperatureDropped(): void;
59
+ _warnedTempDropped: boolean | undefined;
57
60
  /**
58
61
  * @param {Message} msg
59
62
  * @returns {any}
@@ -3,6 +3,7 @@
3
3
  const https = require('https');
4
4
  const http = require('http');
5
5
  const { ProviderError } = require('./errors');
6
+ const { requestWithTemperatureFallback } = require('./provider-temperature');
6
7
 
7
8
  /** @param {string} hostname @returns {boolean} */
8
9
  function isLoopbackHost(hostname) {
@@ -91,7 +92,15 @@ class AnthropicProvider {
91
92
  }));
92
93
  }
93
94
 
94
- const data = await this._request(body);
95
+ // BA-10: some models (e.g. claude-sonnet-5) reject a non-default `temperature` with a 400 — drop it
96
+ // and retry once rather than let the whole call fail. `temperatureDropped` flows back so an upstream
97
+ // receipt (recurse's refineLeaf) can report the effective temperature, not the one the model ignored.
98
+ const { data, temperatureDropped } = await requestWithTemperatureFallback({
99
+ request: () => this._request(body),
100
+ hadTemperature: () => body.temperature != null,
101
+ stripTemperature: () => { delete body.temperature; },
102
+ warnOnce: () => this._warnTemperatureDropped(),
103
+ });
95
104
 
96
105
  let text = '';
97
106
  /** @type {import('../types').ToolCall[]} */
@@ -115,9 +124,17 @@ class AnthropicProvider {
115
124
  cacheReadTokens: data.usage?.cache_read_input_tokens || 0,
116
125
  cacheCreationTokens: data.usage?.cache_creation_input_tokens || 0,
117
126
  },
127
+ ...(temperatureDropped && { temperatureDropped: true }),
118
128
  };
119
129
  }
120
130
 
131
+ /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
132
+ _warnTemperatureDropped() {
133
+ if (this._warnedTempDropped) return;
134
+ this._warnedTempDropped = true;
135
+ console.warn(`[AnthropicProvider] '${this.model}' rejected a non-default 'temperature' (unsupported/deprecated) — retrying without it. Further drops from this provider instance are silent.`);
136
+ }
137
+
121
138
  /**
122
139
  * @param {Message} msg
123
140
  * @returns {any}
@@ -50,6 +50,9 @@ export class GeminiProvider {
50
50
  * @throws {Error} `[GeminiProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
51
51
  */
52
52
  generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
53
+ /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
54
+ _warnTemperatureDropped(): void;
55
+ _warnedTempDropped: boolean | undefined;
53
56
  /**
54
57
  * Normalize Gemini `usageMetadata` to the neutral {@link Usage} shape. Like OpenAI, `promptTokenCount`
55
58
  * INCLUDES the cached tokens (`cachedContentTokenCount`), so subtract for the uncached remainder
@@ -3,6 +3,7 @@
3
3
  const https = require('https');
4
4
  const http = require('http');
5
5
  const { ProviderError } = require('./errors');
6
+ const { requestWithTemperatureFallback } = require('./provider-temperature');
6
7
 
7
8
  /** @typedef {import('../types').Message} Message */
8
9
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -103,7 +104,14 @@ class GeminiProvider {
103
104
  if (options.temperature != null) genConfig.temperature = options.temperature;
104
105
  if (Object.keys(genConfig).length) body.generationConfig = genConfig;
105
106
 
106
- const data = await this._request(`/models/${this.model}:generateContent`, body);
107
+ // BA-10: graceful degrade if a model rejects a non-default `temperature` (Gemini nests it under
108
+ // generationConfig). Keyed off the API error text, so dormant on models that accept temperature.
109
+ const { data, temperatureDropped } = await requestWithTemperatureFallback({
110
+ request: () => this._request(`/models/${this.model}:generateContent`, body),
111
+ hadTemperature: () => body.generationConfig?.temperature != null,
112
+ stripTemperature: () => { if (body.generationConfig) delete body.generationConfig.temperature; },
113
+ warnOnce: () => this._warnTemperatureDropped(),
114
+ });
107
115
 
108
116
  let text = '';
109
117
  /** @type {ToolCall[]} */
@@ -123,9 +131,17 @@ class GeminiProvider {
123
131
  toolCalls,
124
132
  model: data.modelVersion || this.model,
125
133
  usage: this._normalizeUsage(data.usageMetadata),
134
+ ...(temperatureDropped && { temperatureDropped: true }),
126
135
  };
127
136
  }
128
137
 
138
+ /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
139
+ _warnTemperatureDropped() {
140
+ if (this._warnedTempDropped) return;
141
+ this._warnedTempDropped = true;
142
+ console.warn(`[GeminiProvider] '${this.model}' rejected a non-default 'temperature' (unsupported/deprecated) — retrying without it. Further drops from this provider instance are silent.`);
143
+ }
144
+
129
145
  /**
130
146
  * Normalize Gemini `usageMetadata` to the neutral {@link Usage} shape. Like OpenAI, `promptTokenCount`
131
147
  * INCLUDES the cached tokens (`cachedContentTokenCount`), so subtract for the uncached remainder
@@ -32,6 +32,9 @@ export class OllamaProvider {
32
32
  * @throws {Error} `[OllamaProvider] ...` — on HTTP errors or invalid JSON response.
33
33
  */
34
34
  generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
35
+ /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
36
+ _warnTemperatureDropped(): void;
37
+ _warnedTempDropped: boolean | undefined;
35
38
  /**
36
39
  * @param {string} path
37
40
  * @param {Record<string, any>} body
@@ -2,6 +2,7 @@
2
2
 
3
3
  const http = require('http');
4
4
  const { ProviderError } = require('./errors');
5
+ const { requestWithTemperatureFallback } = require('./provider-temperature');
5
6
 
6
7
  /** @typedef {import('../types').Message} Message */
7
8
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -48,7 +49,14 @@ class OllamaProvider {
48
49
  }));
49
50
  }
50
51
 
51
- const data = await this._request('/api/chat', body);
52
+ // BA-10: graceful degrade if a model rejects a non-default `temperature` (Ollama nests it under
53
+ // `options`). Keyed off the API error text, so dormant on models that accept temperature.
54
+ const { data, temperatureDropped } = await requestWithTemperatureFallback({
55
+ request: () => this._request('/api/chat', body),
56
+ hadTemperature: () => body.options?.temperature != null,
57
+ stripTemperature: () => { if (body.options) delete body.options.temperature; },
58
+ warnOnce: () => this._warnTemperatureDropped(),
59
+ });
52
60
  const msg = data.message || {};
53
61
 
54
62
  return {
@@ -65,9 +73,17 @@ class OllamaProvider {
65
73
  inputTokens: data.prompt_eval_count || 0,
66
74
  outputTokens: data.eval_count || 0,
67
75
  },
76
+ ...(temperatureDropped && { temperatureDropped: true }),
68
77
  };
69
78
  }
70
79
 
80
+ /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
81
+ _warnTemperatureDropped() {
82
+ if (this._warnedTempDropped) return;
83
+ this._warnedTempDropped = true;
84
+ console.warn(`[OllamaProvider] '${this.model}' rejected a non-default 'temperature' (unsupported/deprecated) — retrying without it. Further drops from this provider instance are silent.`);
85
+ }
86
+
71
87
  /**
72
88
  * @param {string} path
73
89
  * @param {Record<string, any>} body
@@ -44,6 +44,9 @@ export class OpenAIProvider {
44
44
  * @throws {Error} `[OpenAIProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
45
45
  */
46
46
  generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
47
+ /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
48
+ _warnTemperatureDropped(): void;
49
+ _warnedTempDropped: boolean | undefined;
47
50
  /**
48
51
  * Normalize OpenAI usage to the neutral {@link Usage} shape. OpenAI auto-caches prompt prefixes
49
52
  * (>=1024 tokens) and reports the cached portion in `prompt_tokens_details.cached_tokens` —
@@ -3,6 +3,7 @@
3
3
  const https = require('https');
4
4
  const http = require('http');
5
5
  const { ProviderError } = require('./errors');
6
+ const { requestWithTemperatureFallback } = require('./provider-temperature');
6
7
 
7
8
  /** @typedef {import('../types').Message} Message */
8
9
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -61,7 +62,14 @@ class OpenAIProvider {
61
62
  }));
62
63
  }
63
64
 
64
- const data = await this._request('/chat/completions', body);
65
+ // BA-10: newer models (o1/gpt-5-class) reject a non-default `temperature` with a 400 — drop it and
66
+ // retry once. `temperatureDropped` flows back so an upstream receipt can report the effective value.
67
+ const { data, temperatureDropped } = await requestWithTemperatureFallback({
68
+ request: () => this._request('/chat/completions', body),
69
+ hadTemperature: () => body.temperature != null,
70
+ stripTemperature: () => { delete body.temperature; },
71
+ warnOnce: () => this._warnTemperatureDropped(),
72
+ });
65
73
  const choice = data.choices[0];
66
74
  const msg = choice.message;
67
75
 
@@ -74,9 +82,17 @@ class OpenAIProvider {
74
82
  })),
75
83
  model: data.model || this.model,
76
84
  usage: this._normalizeUsage(data.usage),
85
+ ...(temperatureDropped && { temperatureDropped: true }),
77
86
  };
78
87
  }
79
88
 
89
+ /** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
90
+ _warnTemperatureDropped() {
91
+ if (this._warnedTempDropped) return;
92
+ this._warnedTempDropped = true;
93
+ console.warn(`[OpenAIProvider] '${this.model}' rejected a non-default 'temperature' (unsupported/deprecated) — retrying without it. Further drops from this provider instance are silent.`);
94
+ }
95
+
80
96
  /**
81
97
  * Normalize OpenAI usage to the neutral {@link Usage} shape. OpenAI auto-caches prompt prefixes
82
98
  * (>=1024 tokens) and reports the cached portion in `prompt_tokens_details.cached_tokens` —
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Does this error mean the model rejected `temperature` as unsupported/deprecated?
3
+ * @param {any} err - the rejection from a provider `_request` (a {@link ProviderError} carries `.status`).
4
+ * @returns {boolean}
5
+ */
6
+ export function isTemperatureUnsupported(err: any): boolean;
7
+ /**
8
+ * Issue a provider request; if it 400s because `temperature` is unsupported AND a temperature was
9
+ * actually sent, strip it and retry ONCE. Returns whether the temperature was dropped so the caller
10
+ * can report the EFFECTIVE temperature (the receipt must not claim a value the model ignored).
11
+ *
12
+ * @param {object} opts
13
+ * @param {() => Promise<any>} opts.request - issues the API call (rejects `ProviderError` on 4xx).
14
+ * @param {() => boolean} opts.hadTemperature - was a temperature actually in the request body?
15
+ * @param {() => void} opts.stripTemperature - mutate the request body to remove the temperature.
16
+ * @param {() => void} [opts.warnOnce] - emit the one-time degrade warning (caller dedupes per instance).
17
+ * @returns {Promise<{ data: any, temperatureDropped: boolean }>}
18
+ */
19
+ export function requestWithTemperatureFallback({ request, hadTemperature, stripTemperature, warnOnce }: {
20
+ request: () => Promise<any>;
21
+ hadTemperature: () => boolean;
22
+ stripTemperature: () => void;
23
+ warnOnce?: (() => void) | undefined;
24
+ }): Promise<{
25
+ data: any;
26
+ temperatureDropped: boolean;
27
+ }>;
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+
3
+ // Graceful degradation for models that reject a non-default `temperature` (BA-10 / relayfact F34).
4
+ //
5
+ // Newer models — claude-sonnet-5 ("`temperature` is deprecated for this model."), OpenAI o1/gpt-5-class
6
+ // ("Unsupported value: 'temperature' … Only the default (1) …") — return a 400 for ANY non-default
7
+ // temperature. Left unhandled, the whole `generate` throws; upstream (e.g. recurse's `refineLeaf`) then
8
+ // collapses to `incomplete` with the executable close never run — a failure that LOOKS like "the model
9
+ // couldn't do it" when in fact no attempt was ever made.
10
+ //
11
+ // The fix keys off the API's own error TEXT, never a hardcoded model list, so it survives future models
12
+ // that drop the param and stays dormant on every model that accepts it. It retries ONCE without the
13
+ // temperature, and ONLY for the unsupported/deprecated class — a genuine out-of-range 400 re-throws
14
+ // (dropping it would mask a caller bug).
15
+
16
+ /** The error names `temperature` … */
17
+ const TEMP_NAMED = /temperature/i;
18
+ // … AND indicates it's unsupported/deprecated (NOT merely out of range — that stays a hard error).
19
+ // The `only…default` alternative uses a BOUNDED gap (`[^.]{0,40}`, not `.*`): an unbounded `.*` here is a
20
+ // quadratic-blowup footgun on a long provider/proxy-supplied error message that repeats "only" with no
21
+ // "default" (O(n) start positions × O(n) backtrack). The bound keeps it linear and still matches
22
+ // "Only the default (1) value is supported."
23
+ const TEMP_UNSUPPORTED = /(deprecat|unsupported|not support|does not support|no longer support|only\b[^.]{0,40}\bdefault|must be omitted|isn't supported|is not allowed)/i;
24
+
25
+ /**
26
+ * Does this error mean the model rejected `temperature` as unsupported/deprecated?
27
+ * @param {any} err - the rejection from a provider `_request` (a {@link ProviderError} carries `.status`).
28
+ * @returns {boolean}
29
+ */
30
+ function isTemperatureUnsupported(err) {
31
+ const msg = err && typeof err.message === 'string' ? err.message : '';
32
+ return !!err && err.status === 400 && TEMP_NAMED.test(msg) && TEMP_UNSUPPORTED.test(msg);
33
+ }
34
+
35
+ /**
36
+ * Issue a provider request; if it 400s because `temperature` is unsupported AND a temperature was
37
+ * actually sent, strip it and retry ONCE. Returns whether the temperature was dropped so the caller
38
+ * can report the EFFECTIVE temperature (the receipt must not claim a value the model ignored).
39
+ *
40
+ * @param {object} opts
41
+ * @param {() => Promise<any>} opts.request - issues the API call (rejects `ProviderError` on 4xx).
42
+ * @param {() => boolean} opts.hadTemperature - was a temperature actually in the request body?
43
+ * @param {() => void} opts.stripTemperature - mutate the request body to remove the temperature.
44
+ * @param {() => void} [opts.warnOnce] - emit the one-time degrade warning (caller dedupes per instance).
45
+ * @returns {Promise<{ data: any, temperatureDropped: boolean }>}
46
+ */
47
+ async function requestWithTemperatureFallback({ request, hadTemperature, stripTemperature, warnOnce }) {
48
+ try {
49
+ return { data: await request(), temperatureDropped: false };
50
+ } catch (err) {
51
+ if (isTemperatureUnsupported(err) && hadTemperature()) {
52
+ stripTemperature();
53
+ if (warnOnce) warnOnce();
54
+ return { data: await request(), temperatureDropped: true };
55
+ }
56
+ throw err;
57
+ }
58
+ }
59
+
60
+ module.exports = { isTemperatureUnsupported, requestWithTemperatureFallback };
package/src/recurse.d.ts CHANGED
@@ -90,9 +90,11 @@ export type RecurseOptions = {
90
90
  * tier or at `maxDepth`) into a bounded generate→sense→regenerate loop instead of a single pass, so a failed
91
91
  * slice can self-correct. `sensor` is a DETERMINISTIC close (test/compile/lint — NOT a model judge, R-S8) that
92
92
  * returns a `Verdict`; on a non-pass its `critique` (the GAP, not the transcript) is fed FRESH into the next
93
- * attempt (D6/A1 anti-anchoring) and the **retry temperature ESCALATES** (`temperatures`, default
94
- * `[0.2,0.7,1.0]`) — the live-validated requirement that lets a weak model escape a repeat-the-same-mistake rut
95
- * (`poc/ba8-leaf-refine.mjs`: 0/5 → 2-3/5; flat temp recovers 0/5). `maxIterations` defaults to
93
+ * attempt (D6/A1 anti-anchoring) and, on models that ACCEPT `temperature`, the **retry temperature ESCALATES**
94
+ * (`temperatures`, default `[0.2,0.7,1.0]`) — the live-validated lever that lets a weak model escape a
95
+ * repeat-the-same-mistake rut (`poc/ba8-leaf-refine.mjs`: 0/5 → 2-3/5; flat temp recovers 0/5). On a
96
+ * temperature-fixed model (BA-10) the provider drops the param, `receipts.refineLeaf.temperatures` records
97
+ * `null`, and the fed-back gap critique carries recovery alone. `maxIterations` defaults to
96
98
  * `temperatures.length`; the REAL bound is bareguard (each attempt is gate-checked + metered). CARRIES DOWN the
97
99
  * tree (preserved by `forChild`), so it engages at the leaves of a Family-A decomposition. Recovery is PARTIAL
98
100
  * (a stubborn blind spot may persist) — `receipts.refineLeaf.passed` reports honestly. Does NOT apply to a node
@@ -213,19 +215,25 @@ export type RecurseNode = {
213
215
  verdict: Verdict | null;
214
216
  incomplete: boolean;
215
217
  halted: boolean;
218
+ /**
219
+ * - (BA-11) set to `'governance-deny'` when this node stopped because its Loop
220
+ * short-circuited a consecutive-policy-deny spin (not a model failure). Mirrors `RecurseResult.blocker`.
221
+ */
222
+ blocker?: string | undefined;
216
223
  /**
217
224
  * - The worker Loop's `metrics.tokens`.
218
225
  */
219
226
  tokens: object | null;
220
227
  /**
221
- * - (BA-8) when this leaf
222
- * ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally passed
223
- * (false = honest non-recovery, not a faked success).
228
+ * - (BA-8) when this
229
+ * leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
230
+ * passed (false = honest non-recovery, not a faked success). `temperatures` are the EFFECTIVE per-attempt temps
231
+ * (BA-10): a `null` marks an attempt the model ran at its DEFAULT because it rejected the requested temperature.
224
232
  */
225
233
  refineLeaf?: {
226
234
  iterations: number;
227
235
  passed: boolean;
228
- temperatures: number[];
236
+ temperatures: (number | null)[];
229
237
  } | undefined;
230
238
  model: string | null;
231
239
  /**
@@ -284,6 +292,12 @@ export type RecurseResult = {
284
292
  * back incomplete (§9 scenario 1) — the anti-survivor-sum signal, not a quiet undercount.
285
293
  */
286
294
  missingSlices?: string[] | undefined;
295
+ /**
296
+ * - Present when `incomplete` for a specific, actionable reason. `'governance-deny'`
297
+ * (BA-11): the worker's Loop short-circuited after N consecutive policy denials rather than burn to the
298
+ * budget cap — the caller can widen scope / re-gate / escalate instead of reading it as a model failure.
299
+ */
300
+ blocker?: string | undefined;
287
301
  /**
288
302
  * - The audit node for this call (RC-10).
289
303
  */
@@ -353,9 +367,11 @@ export type Slice = {
353
367
  * tier or at `maxDepth`) into a bounded generate→sense→regenerate loop instead of a single pass, so a failed
354
368
  * slice can self-correct. `sensor` is a DETERMINISTIC close (test/compile/lint — NOT a model judge, R-S8) that
355
369
  * returns a `Verdict`; on a non-pass its `critique` (the GAP, not the transcript) is fed FRESH into the next
356
- * attempt (D6/A1 anti-anchoring) and the **retry temperature ESCALATES** (`temperatures`, default
357
- * `[0.2,0.7,1.0]`) — the live-validated requirement that lets a weak model escape a repeat-the-same-mistake rut
358
- * (`poc/ba8-leaf-refine.mjs`: 0/5 → 2-3/5; flat temp recovers 0/5). `maxIterations` defaults to
370
+ * attempt (D6/A1 anti-anchoring) and, on models that ACCEPT `temperature`, the **retry temperature ESCALATES**
371
+ * (`temperatures`, default `[0.2,0.7,1.0]`) — the live-validated lever that lets a weak model escape a
372
+ * repeat-the-same-mistake rut (`poc/ba8-leaf-refine.mjs`: 0/5 → 2-3/5; flat temp recovers 0/5). On a
373
+ * temperature-fixed model (BA-10) the provider drops the param, `receipts.refineLeaf.temperatures` records
374
+ * `null`, and the fed-back gap critique carries recovery alone. `maxIterations` defaults to
359
375
  * `temperatures.length`; the REAL bound is bareguard (each attempt is gate-checked + metered). CARRIES DOWN the
360
376
  * tree (preserved by `forChild`), so it engages at the leaves of a Family-A decomposition. Recovery is PARTIAL
361
377
  * (a stubborn blind spot may persist) — `receipts.refineLeaf.passed` reports honestly. Does NOT apply to a node
@@ -419,10 +435,13 @@ export type Slice = {
419
435
  * @property {Verdict|null} verdict
420
436
  * @property {boolean} incomplete
421
437
  * @property {boolean} halted
438
+ * @property {string} [blocker] - (BA-11) set to `'governance-deny'` when this node stopped because its Loop
439
+ * short-circuited a consecutive-policy-deny spin (not a model failure). Mirrors `RecurseResult.blocker`.
422
440
  * @property {object|null} tokens - The worker Loop's `metrics.tokens`.
423
- * @property {{iterations: number, passed: boolean, temperatures: number[]}} [refineLeaf] - (BA-8) when this leaf
424
- * ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally passed
425
- * (false = honest non-recovery, not a faked success).
441
+ * @property {{iterations: number, passed: boolean, temperatures: (number|null)[]}} [refineLeaf] - (BA-8) when this
442
+ * leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
443
+ * passed (false = honest non-recovery, not a faked success). `temperatures` are the EFFECTIVE per-attempt temps
444
+ * (BA-10): a `null` marks an attempt the model ran at its DEFAULT because it rejected the requested temperature.
426
445
  * @property {string|null} model
427
446
  * @property {string|null} [retrieval] - (§10 step 7) the retrieval mode this node ran (`scan`/`search`/`exact`),
428
447
  * or null/absent for a plain reasoning node.
@@ -443,6 +462,9 @@ export type Slice = {
443
462
  * @property {any} [best] - The best partial answer when `incomplete` (RC-9).
444
463
  * @property {string[]} [missingSlices] - When `incomplete` because a child failed: the sub-task(s) that came
445
464
  * back incomplete (§9 scenario 1) — the anti-survivor-sum signal, not a quiet undercount.
465
+ * @property {string} [blocker] - Present when `incomplete` for a specific, actionable reason. `'governance-deny'`
466
+ * (BA-11): the worker's Loop short-circuited after N consecutive policy denials rather than burn to the
467
+ * budget cap — the caller can widen scope / re-gate / escalate instead of reading it as a model failure.
446
468
  * @property {RecurseNode} receipts - The audit node for this call (RC-10).
447
469
  */
448
470
  /**
package/src/recurse.js CHANGED
@@ -56,6 +56,10 @@ const DEFAULT_WORKER_BUDGET = 100;
56
56
  // low temperature a weak model regenerates byte-identical wrong code and IGNORES even crisp deterministic
57
57
  // feedback (0/5 recovery); recovery only appears once retries are given room to vary (0/5 → 2-3/5). So escalation
58
58
  // is a DESIGN REQUIREMENT of the seam, not a tuning nicety. Overridable via `opts.refineLeaf.temperatures`.
59
+ // SCOPE (BA-10): this holds for models that ACCEPT `temperature`. On a temperature-fixed model (e.g.
60
+ // claude-sonnet-5 — the provider drops the param, `receipts.refineLeaf.temperatures` records `null`), the
61
+ // escalation lever is inert and the fed-back gap `critique` carries recovery alone (an empirical question the
62
+ // live run answers). The critique is the primary correction lever; temperature is a secondary diversity lever.
59
63
  const DEFAULT_REFINE_TEMPS = [0.2, 0.7, 1.0];
60
64
 
61
65
  /**
@@ -217,9 +221,11 @@ function auditSafeCtx(ctx, overrides = {}) {
217
221
  * tier or at `maxDepth`) into a bounded generate→sense→regenerate loop instead of a single pass, so a failed
218
222
  * slice can self-correct. `sensor` is a DETERMINISTIC close (test/compile/lint — NOT a model judge, R-S8) that
219
223
  * returns a `Verdict`; on a non-pass its `critique` (the GAP, not the transcript) is fed FRESH into the next
220
- * attempt (D6/A1 anti-anchoring) and the **retry temperature ESCALATES** (`temperatures`, default
221
- * `[0.2,0.7,1.0]`) — the live-validated requirement that lets a weak model escape a repeat-the-same-mistake rut
222
- * (`poc/ba8-leaf-refine.mjs`: 0/5 → 2-3/5; flat temp recovers 0/5). `maxIterations` defaults to
224
+ * attempt (D6/A1 anti-anchoring) and, on models that ACCEPT `temperature`, the **retry temperature ESCALATES**
225
+ * (`temperatures`, default `[0.2,0.7,1.0]`) — the live-validated lever that lets a weak model escape a
226
+ * repeat-the-same-mistake rut (`poc/ba8-leaf-refine.mjs`: 0/5 → 2-3/5; flat temp recovers 0/5). On a
227
+ * temperature-fixed model (BA-10) the provider drops the param, `receipts.refineLeaf.temperatures` records
228
+ * `null`, and the fed-back gap critique carries recovery alone. `maxIterations` defaults to
223
229
  * `temperatures.length`; the REAL bound is bareguard (each attempt is gate-checked + metered). CARRIES DOWN the
224
230
  * tree (preserved by `forChild`), so it engages at the leaves of a Family-A decomposition. Recovery is PARTIAL
225
231
  * (a stubborn blind spot may persist) — `receipts.refineLeaf.passed` reports honestly. Does NOT apply to a node
@@ -284,10 +290,13 @@ function auditSafeCtx(ctx, overrides = {}) {
284
290
  * @property {Verdict|null} verdict
285
291
  * @property {boolean} incomplete
286
292
  * @property {boolean} halted
293
+ * @property {string} [blocker] - (BA-11) set to `'governance-deny'` when this node stopped because its Loop
294
+ * short-circuited a consecutive-policy-deny spin (not a model failure). Mirrors `RecurseResult.blocker`.
287
295
  * @property {object|null} tokens - The worker Loop's `metrics.tokens`.
288
- * @property {{iterations: number, passed: boolean, temperatures: number[]}} [refineLeaf] - (BA-8) when this leaf
289
- * ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally passed
290
- * (false = honest non-recovery, not a faked success).
296
+ * @property {{iterations: number, passed: boolean, temperatures: (number|null)[]}} [refineLeaf] - (BA-8) when this
297
+ * leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
298
+ * passed (false = honest non-recovery, not a faked success). `temperatures` are the EFFECTIVE per-attempt temps
299
+ * (BA-10): a `null` marks an attempt the model ran at its DEFAULT because it rejected the requested temperature.
291
300
  * @property {string|null} model
292
301
  * @property {string|null} [retrieval] - (§10 step 7) the retrieval mode this node ran (`scan`/`search`/`exact`),
293
302
  * or null/absent for a plain reasoning node.
@@ -309,6 +318,9 @@ function auditSafeCtx(ctx, overrides = {}) {
309
318
  * @property {any} [best] - The best partial answer when `incomplete` (RC-9).
310
319
  * @property {string[]} [missingSlices] - When `incomplete` because a child failed: the sub-task(s) that came
311
320
  * back incomplete (§9 scenario 1) — the anti-survivor-sum signal, not a quiet undercount.
321
+ * @property {string} [blocker] - Present when `incomplete` for a specific, actionable reason. `'governance-deny'`
322
+ * (BA-11): the worker's Loop short-circuited after N consecutive policy denials rather than burn to the
323
+ * budget cap — the caller can widen scope / re-gate / escalate instead of reading it as a model failure.
312
324
  * @property {RecurseNode} receipts - The audit node for this call (RC-10).
313
325
  */
314
326
 
@@ -503,6 +515,15 @@ async function recurse(task, ctx = {}, opts = {}) {
503
515
  node.incomplete = true;
504
516
  return { incomplete: true, best: out.text || null, receipts: node };
505
517
  }
518
+ // BA-11: a deny-spin short-circuit. The Loop stopped the worker after N consecutive governance denials
519
+ // (a governance deny is not a recoverable tool error — retrying variants would burn to the budget cap;
520
+ // probe-16: 16 calls, sensor never reached → incomplete). Surface it as a clean, LABELED incomplete so a
521
+ // caller can tell a governance block apart from a model failure and act (widen scope, re-gate, escalate).
522
+ if (typeof out.error === 'string' && out.error.startsWith('denied:')) {
523
+ node.incomplete = true;
524
+ node.blocker = 'governance-deny';
525
+ return { incomplete: true, best: out.text || null, blocker: 'governance-deny', receipts: node };
526
+ }
506
527
  if (out.error) {
507
528
  node.incomplete = true;
508
529
  return { incomplete: true, best: out.text || null, receipts: node };
@@ -603,6 +624,12 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
603
624
  tokensSum = tokensSum || {};
604
625
  for (const [k, v] of Object.entries(t)) if (typeof v === 'number') tokensSum[k] = (tokensSum[k] || 0) + v;
605
626
  };
627
+ // BA-10 honest receipt: the EFFECTIVE temperature per attempt. A model that rejects a non-default
628
+ // `temperature` (400, unsupported/deprecated) runs at its DEFAULT — the provider drops it and the Loop
629
+ // surfaces `temperatureDropped`. Recording the requested temp would claim a value the model ignored, so
630
+ // a dropped attempt is stored as `null` ("provider default"). Indexed by iteration (refine calls once each).
631
+ /** @type {(number|null)[]} */
632
+ const effectiveTemps = [];
606
633
  // One attempt = a fresh leaf Loop (no spawn tool: a retry is a direct correction, not a re-decomposition) at the
607
634
  // iteration's temperature, with the GAP fed forward as fresh feedback. A governance halt → throw so refine stops.
608
635
  const attempt = async ({ iteration, critique }) => {
@@ -619,6 +646,10 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
619
646
  ? `${base}\n\nYour previous attempt FAILED these checks:\n${critique}\n\nReturn a corrected result that passes ALL of them.`
620
647
  : base;
621
648
  const out = await loop.run([{ role: 'user', content: userText }], handleTools, { ctx: auditSafeCtx(ctx, { depth }), temperature });
649
+ // `temperatureDropped` is set on the Loop result only when the model rejected the requested temperature
650
+ // (BA-10); it's absent on the error/halt return shapes, so read it through a narrow cast.
651
+ const dropped = /** @type {{temperatureDropped?: boolean}} */ (out).temperatureDropped;
652
+ effectiveTemps[iteration] = dropped ? null : temperature;
622
653
  accrueTokens(out.metrics ? out.metrics.tokens : null);
623
654
  if (typeof out.error === 'string' && out.error.startsWith('halt:')) throw new HaltError('refine-leaf attempt halted', { rule: out.error.slice('halt:'.length) });
624
655
  if (out.error) throw new Error(out.error); // a non-halt worker fault → honest incomplete
@@ -633,7 +664,10 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
633
664
  maxIterations,
634
665
  });
635
666
  node.tokens = tokensSum;
636
- node.refineLeaf = { iterations: outcome.iterations, passed: !!(outcome.verdict && outcome.verdict.pass), temperatures: temps.slice(0, outcome.iterations) };
667
+ // `temperatures` = the EFFECTIVE temps (BA-10): a `null` marks an attempt whose requested temperature the
668
+ // model rejected and ran at its default — so the receipt never claims a value the model ignored. On a
669
+ // temperature-accepting model this equals the requested `temps.slice(0, iterations)` (byte-identical receipt).
670
+ node.refineLeaf = { iterations: outcome.iterations, passed: !!(outcome.verdict && outcome.verdict.pass), temperatures: effectiveTemps.slice(0, outcome.iterations) };
637
671
  const result = outcome.result;
638
672
 
639
673
  // Optional rubric layer on top of the deterministic sensor (RC-7): forced for critical, or a contract/override.
@@ -653,6 +687,13 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
653
687
  node.incomplete = true;
654
688
  return { incomplete: true, best: null, receipts: node };
655
689
  }
690
+ // BA-11: a deny-spin inside a refine attempt (the Loop short-circuited after N consecutive governance
691
+ // denials, rethrown at recurse.js as `denied:<tool>`) is a LABELED governance block, not a model fault.
692
+ if (typeof err?.message === 'string' && err.message.startsWith('denied:')) {
693
+ node.incomplete = true;
694
+ node.blocker = 'governance-deny';
695
+ return { incomplete: true, best: null, blocker: 'governance-deny', receipts: node };
696
+ }
656
697
  node.incomplete = true;
657
698
  return { incomplete: true, best: null, receipts: node };
658
699
  }
package/types/index.d.ts CHANGED
@@ -80,6 +80,12 @@ export interface GenerateResult {
80
80
  usage: Usage;
81
81
  /** Model id the response was produced by; preferred over Provider.model for cost accounting. */
82
82
  model?: string | null;
83
+ /**
84
+ * True when the requested `temperature` was rejected by the model (400, unsupported/deprecated) and
85
+ * the request was retried without it (BA-10). The response was produced at the model's DEFAULT
86
+ * temperature, not the one requested — callers reporting an effective temperature must honor this.
87
+ */
88
+ temperatureDropped?: boolean;
83
89
  }
84
90
 
85
91
  /** A conversation message in OpenAI chat format. */