bare-agent 0.22.0 → 0.24.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). 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
 
@@ -121,7 +121,7 @@ console.log(result.count, result.matchedIds); // a code-derived count + the id
121
121
 
122
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')`
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.22.0 | Node.js >= 18 | zero required deps (`bareguard ^0.9.0` optional peer for governance) | Apache 2.0
4
+ > v0.24.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
 
@@ -37,6 +37,8 @@ Eight entry points:
37
37
  | Decompose a hard task into a verified tree (RLM) | recurse — decompose → fan-out → verify → synthesize in one call (**wire a gate**, cost is open by design) |
38
38
  | Count / answer "how many / all" over a corpus, honestly | recurse(task, ctx, `{ corpus, retrieval: 'scan' }`) — scans every slice, CODE-counts |
39
39
  | Give recurse workers a persona/role (senior-dev stance) | recurse(task, ctx, `{ persona }`) — prepended to every worker, carries down the tree; not applied to the verifier |
40
+ | Tell recurse workers WHERE they are (paths/cwd) so a slice can find its file | recurse(task, ctx, `{ context }`) — read-only blob on every worker's task message + the Planner + verifier; carries down (facts, not a stance) |
41
+ | Let a recurse LEAF retry its own failure with a deterministic check | recurse(task, ctx, `{ refineLeaf: { sensor } }`) — leaf becomes a bounded generate→sense→regenerate loop; your sensor (test/compile/lint), gap fed back, escalating temperature |
40
42
  | Track task state (pending/running/done/failed) | StateMachine |
41
43
  | Run agent turns on a schedule (cron, timers) | Scheduler |
42
44
  | Require human approval before dangerous actions | Checkpoint |
@@ -669,7 +671,18 @@ const out = await recurse('Audit auth.js, billing.js, gateway.js for authz bugs'
669
671
  });
670
672
  ```
671
673
 
672
- **What a delegated child inherits (important the setpoint is the TOP node's job):** when a worker delegates with `spawn_child`, the child runs a **fresh `recurse`** that inherits `tools`, `synthesize`, `maxDepth`, and `persona` but the parent's **`contract`/`evaluate` are stripped** (and the forced `count`/`mode` + the corpus `retrieval` knobs). A slice is not graded against the *whole*-task definition-of-done (that verdict is the top node's, and a slice satisfying the whole DoD is the wrong question); only the top `recurse` verifies the synthesized result. The non-overridable `critical → force-verify` safety floor still fires per node (it keys on the task text, not the contract). So: set `contract`/`evaluate` once at the top; they do not and should not — re-run per intermediate node.
674
+ **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
+
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 — **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
+
678
+ ```javascript
679
+ const out = await recurse('Fix the failing function in calc.js', ctx, {
680
+ context: `project root: ${process.cwd()}\nresolve relative paths against it`,
681
+ refineLeaf: { sensor: (code) => runTestsAndGrade(code) }, // your deterministic test/compile close
682
+ });
683
+ ```
684
+
685
+ **What a delegated child inherits (important — the setpoint is the TOP node's job):** when a worker delegates with `spawn_child`, the child runs a **fresh `recurse`** that inherits `tools`, `synthesize`, `maxDepth`, `persona`, `context`, and `refineLeaf` — but the parent's **`contract`/`evaluate` are stripped** (and the forced `count`/`mode` + the corpus `retrieval` knobs). A slice is not graded against the *whole*-task definition-of-done (that verdict is the top node's, and a slice satisfying the whole DoD is the wrong question); only the top `recurse` verifies the synthesized result. The non-overridable `critical → force-verify` safety floor still fires per node (it keys on the task text, not the contract). So: set `contract`/`evaluate` once at the top; they do not — and should not — re-run per intermediate node.
673
686
 
674
687
  ## Wiring with Evaluator + refine (output-side verification)
675
688
 
@@ -728,6 +741,8 @@ new CLIPipe({ command: 'ollama', args: ['run', 'llama3.2'] })
728
741
 
729
742
  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`.
730
743
 
744
+ **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).
745
+
731
746
  **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.
732
747
 
733
748
  **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.22.0",
3
+ "version": "0.24.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
package/src/loop.d.ts CHANGED
@@ -111,7 +111,7 @@ export class Loop {
111
111
  * thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
112
112
  * unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
113
113
  * @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}>}
114
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
115
115
  * On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
116
116
  * thrown HaltError carried no `rule`), and `msgs` is sanitized so any
117
117
  * dangling assistant `tool_calls` from the halted round are paired with
@@ -129,6 +129,7 @@ export class Loop {
129
129
  error: string | null;
130
130
  msgs: Message[];
131
131
  metrics: RunMetrics;
132
+ temperatureDropped?: boolean;
132
133
  }>;
133
134
  /**
134
135
  * Health check — validates provider, store, and tools without throwing.
@@ -156,7 +157,7 @@ export class Loop {
156
157
  * @param {string} text - User message.
157
158
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
158
159
  * @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}>}
160
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
160
161
  */
161
162
  chat(text: string, tools?: ToolDef[], options?: Record<string, any>): Promise<{
162
163
  text: string;
@@ -166,6 +167,7 @@ export class Loop {
166
167
  error: string | null;
167
168
  msgs: Message[];
168
169
  metrics: RunMetrics;
170
+ temperatureDropped?: boolean;
169
171
  }>;
170
172
  stop(): void;
171
173
  }
package/src/loop.js CHANGED
@@ -294,7 +294,7 @@ class Loop {
294
294
  * thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
295
295
  * unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
296
296
  * @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}>}
297
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
298
298
  * On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
299
299
  * thrown HaltError carried no `rule`), and `msgs` is sanitized so any
300
300
  * dangling assistant `tool_calls` from the halted round are paired with
@@ -356,6 +356,10 @@ class Loop {
356
356
 
357
357
  let lastUsage = { inputTokens: 0, outputTokens: 0 };
358
358
  let totalCost = 0;
359
+ // BA-10: sticky across rounds — true if ANY round's `temperature` was dropped by the model (400,
360
+ // unsupported/deprecated) and retried without it. Surfaced on the result so an upstream receipt
361
+ // (recurse's refineLeaf) can report the EFFECTIVE temperature rather than the ignored request.
362
+ let temperatureDropped = false;
359
363
 
360
364
  // The meter (Feature 3): bareagent is the canonical run counter. Accumulates across rounds and is
361
365
  // returned as `result.metrics`. `tokens` is CUMULATIVE over all four tiers (fixes the last-round-only
@@ -583,6 +587,7 @@ class Loop {
583
587
  }
584
588
 
585
589
  lastUsage = result.usage || lastUsage;
590
+ if (result.temperatureDropped) temperatureDropped = true;
586
591
  // Publish the latest measured usage to ctx (non-enumerable, fail-open) so a transcript-bound seam —
587
592
  // e.g. F2 stash auto-compaction — can read EXACT provider-counted `inputTokens` to gauge context
588
593
  // pressure on the NEXT round's trim. Symmetric with lending ctx.summarize; the Loop stays unaware of
@@ -642,7 +647,7 @@ class Loop {
642
647
  try { await flush(msgs, ctx); }
643
648
  catch (err) { if (err instanceof HaltError) throw err; this._reportError('trim-flush', err, { round }); }
644
649
  }
645
- return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, msgs, metrics: finalizeMetrics() };
650
+ return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
646
651
  }
647
652
 
648
653
  // Execute tool calls
@@ -863,7 +868,7 @@ class Loop {
863
868
  * @param {string} text - User message.
864
869
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
865
870
  * @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}>}
871
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
867
872
  */
868
873
  async chat(text, tools = [], options = {}) {
869
874
  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
@@ -64,11 +64,53 @@ export type RecurseOptions = {
64
64
  * and can override it (and any safety framing) for every worker in the tree. Caller-trusted input only.
65
65
  */
66
66
  persona?: string | undefined;
67
+ /**
68
+ * - (BA-9 / relayfact F19) An optional caller-supplied READ-ONLY working-context
69
+ * blob (e.g. "project root: /abs/path\nfiles are relative to it") prepended to EVERY worker's task message as
70
+ * a `Working context:` block, so a sliced child can LOCATE its artifact — the concrete context (absolute
71
+ * paths / cwd) the Planner strips when it paraphrases the parent goal into child subtasks. CARRIES DOWN the
72
+ * tree (preserved by `forChild`, like `persona`) and, when forced fan-out plans, is forwarded as the Planner's
73
+ * `info` so the slices themselves are path-aware. Also shown to the verifier (neutral FACTS, not a stance, so
74
+ * no anti-sycophancy concern — and an agentic critic needs the path to exercise the artifact). Distinct from
75
+ * `persona`: persona is a privileged SYSTEM-prompt stance; context is run-state facts on the USER message.
76
+ * Absent/blank ⇒ byte-identical to pre-BA-9 (backward-compatible). Validated live (`poc/ba9-context-thread.mjs`:
77
+ * a weak model went 0/3 → 3/3 at locating an unguessable file once the root was threaded).
78
+ * **SECURITY:** this becomes part of every worker's prompt (and the verifier's) — lower-privilege than
79
+ * `persona` (the USER message, not the SYSTEM prompt) but still a prompt-injection surface. Intended for
80
+ * TRUSTED run-state (paths/cwd); do NOT pass untrusted / end-user-controlled text here.
81
+ */
82
+ context?: string | undefined;
67
83
  /**
68
84
  * - Handle tools offered to EVERY worker (RC-5 pull-default: litectx
69
85
  * `recall`/`get`, wired at build step 7). Workers query on demand; never the whole corpus.
70
86
  */
71
87
  tools?: import("../types").ToolDef[] | undefined;
88
+ /**
89
+ * (Opt-in, BA-8 / relayfact F17) Turn a DEFINITE LEAF (a node that is offered no `spawn_child` — `simple`
90
+ * tier or at `maxDepth`) into a bounded generate→sense→regenerate loop instead of a single pass, so a failed
91
+ * slice can self-correct. `sensor` is a DETERMINISTIC close (test/compile/lint — NOT a model judge, R-S8) that
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, 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
98
+ * `temperatures.length`; the REAL bound is bareguard (each attempt is gate-checked + metered). CARRIES DOWN the
99
+ * tree (preserved by `forChild`), so it engages at the leaves of a Family-A decomposition. Recovery is PARTIAL
100
+ * (a stubborn blind spot may persist) — `receipts.refineLeaf.passed` reports honestly. Does NOT apply to a node
101
+ * that delegates (its children + the tree verify own quality), nor to the scan/fanout/partition dispatch paths.
102
+ * Absent ⇒ a leaf is a single pass (byte-identical to pre-BA-8). An error-keyed `recall` is the CALLER's tool
103
+ * (`opts.tools`) keyed off the fed-back critique — bareagent stays litectx-agnostic.
104
+ */
105
+ refineLeaf?: {
106
+ sensor: (result: any, ctx: {
107
+ task: string;
108
+ context: string | undefined;
109
+ contract: string | null;
110
+ }) => (Verdict | Promise<Verdict>);
111
+ maxIterations?: number;
112
+ temperatures?: number[];
113
+ } | undefined;
72
114
  /**
73
115
  * - Definition of done (A3). When present, the verifier grades against THIS,
74
116
  * not the loose task, and verification always runs.
@@ -177,6 +219,17 @@ export type RecurseNode = {
177
219
  * - The worker Loop's `metrics.tokens`.
178
220
  */
179
221
  tokens: object | null;
222
+ /**
223
+ * - (BA-8) when this
224
+ * leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
225
+ * passed (false = honest non-recovery, not a faked success). `temperatures` are the EFFECTIVE per-attempt temps
226
+ * (BA-10): a `null` marks an attempt the model ran at its DEFAULT because it rejected the requested temperature.
227
+ */
228
+ refineLeaf?: {
229
+ iterations: number;
230
+ passed: boolean;
231
+ temperatures: (number | null)[];
232
+ } | undefined;
180
233
  model: string | null;
181
234
  /**
182
235
  * - (§10 step 7) the retrieval mode this node ran (`scan`/`search`/`exact`),
@@ -283,8 +336,37 @@ export type Slice = {
283
336
  * **SECURITY:** this is a PRIVILEGED system-prompt seam — treat `persona` like a system prompt. Do NOT pass
284
337
  * untrusted / end-user-controlled text here; a hostile persona is prepended ahead of the decomposition policy
285
338
  * and can override it (and any safety framing) for every worker in the tree. Caller-trusted input only.
339
+ * @property {string} [context] - (BA-9 / relayfact F19) An optional caller-supplied READ-ONLY working-context
340
+ * blob (e.g. "project root: /abs/path\nfiles are relative to it") prepended to EVERY worker's task message as
341
+ * a `Working context:` block, so a sliced child can LOCATE its artifact — the concrete context (absolute
342
+ * paths / cwd) the Planner strips when it paraphrases the parent goal into child subtasks. CARRIES DOWN the
343
+ * tree (preserved by `forChild`, like `persona`) and, when forced fan-out plans, is forwarded as the Planner's
344
+ * `info` so the slices themselves are path-aware. Also shown to the verifier (neutral FACTS, not a stance, so
345
+ * no anti-sycophancy concern — and an agentic critic needs the path to exercise the artifact). Distinct from
346
+ * `persona`: persona is a privileged SYSTEM-prompt stance; context is run-state facts on the USER message.
347
+ * Absent/blank ⇒ byte-identical to pre-BA-9 (backward-compatible). Validated live (`poc/ba9-context-thread.mjs`:
348
+ * a weak model went 0/3 → 3/3 at locating an unguessable file once the root was threaded).
349
+ * **SECURITY:** this becomes part of every worker's prompt (and the verifier's) — lower-privilege than
350
+ * `persona` (the USER message, not the SYSTEM prompt) but still a prompt-injection surface. Intended for
351
+ * TRUSTED run-state (paths/cwd); do NOT pass untrusted / end-user-controlled text here.
286
352
  * @property {ToolDef[]} [tools] - Handle tools offered to EVERY worker (RC-5 pull-default: litectx
287
353
  * `recall`/`get`, wired at build step 7). Workers query on demand; never the whole corpus.
354
+ * @property {{sensor: (result: any, ctx: {task: string, context: string|undefined, contract: string|null}) => (Verdict|Promise<Verdict>), maxIterations?: number, temperatures?: number[]}} [refineLeaf]
355
+ * (Opt-in, BA-8 / relayfact F17) Turn a DEFINITE LEAF (a node that is offered no `spawn_child` — `simple`
356
+ * tier or at `maxDepth`) into a bounded generate→sense→regenerate loop instead of a single pass, so a failed
357
+ * slice can self-correct. `sensor` is a DETERMINISTIC close (test/compile/lint — NOT a model judge, R-S8) that
358
+ * returns a `Verdict`; on a non-pass its `critique` (the GAP, not the transcript) is fed FRESH into the next
359
+ * attempt (D6/A1 anti-anchoring) and, on models that ACCEPT `temperature`, the **retry temperature ESCALATES**
360
+ * (`temperatures`, default `[0.2,0.7,1.0]`) — the live-validated lever that lets a weak model escape a
361
+ * repeat-the-same-mistake rut (`poc/ba8-leaf-refine.mjs`: 0/5 → 2-3/5; flat temp recovers 0/5). On a
362
+ * temperature-fixed model (BA-10) the provider drops the param, `receipts.refineLeaf.temperatures` records
363
+ * `null`, and the fed-back gap critique carries recovery alone. `maxIterations` defaults to
364
+ * `temperatures.length`; the REAL bound is bareguard (each attempt is gate-checked + metered). CARRIES DOWN the
365
+ * tree (preserved by `forChild`), so it engages at the leaves of a Family-A decomposition. Recovery is PARTIAL
366
+ * (a stubborn blind spot may persist) — `receipts.refineLeaf.passed` reports honestly. Does NOT apply to a node
367
+ * that delegates (its children + the tree verify own quality), nor to the scan/fanout/partition dispatch paths.
368
+ * Absent ⇒ a leaf is a single pass (byte-identical to pre-BA-8). An error-keyed `recall` is the CALLER's tool
369
+ * (`opts.tools`) keyed off the fed-back critique — bareagent stays litectx-agnostic.
288
370
  * @property {string} [contract] - Definition of done (A3). When present, the verifier grades against THIS,
289
371
  * not the loose task, and verification always runs.
290
372
  * @property {(result: any, ctx: {contract: string|null, task: string}) => (Verdict|Promise<Verdict>)} [evaluate]
@@ -343,6 +425,10 @@ export type Slice = {
343
425
  * @property {boolean} incomplete
344
426
  * @property {boolean} halted
345
427
  * @property {object|null} tokens - The worker Loop's `metrics.tokens`.
428
+ * @property {{iterations: number, passed: boolean, temperatures: (number|null)[]}} [refineLeaf] - (BA-8) when this
429
+ * leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
430
+ * passed (false = honest non-recovery, not a faked success). `temperatures` are the EFFECTIVE per-attempt temps
431
+ * (BA-10): a `null` marks an attempt the model ran at its DEFAULT because it rejected the requested temperature.
346
432
  * @property {string|null} model
347
433
  * @property {string|null} [retrieval] - (§10 step 7) the retrieval mode this node ran (`scan`/`search`/`exact`),
348
434
  * or null/absent for a plain reasoning node.
package/src/recurse.js CHANGED
@@ -28,6 +28,7 @@ const { Evaluator } = require('./evaluator');
28
28
  const { Planner } = require('./planner');
29
29
  const { runPlan } = require('./run-plan');
30
30
  const { assessComplexity, isCritical } = require('./complexity');
31
+ const { refine } = require('./refine');
31
32
  const { HaltError } = require('./errors');
32
33
  const { DECOMPOSITION_POLICY, capabilityScrub } = require('./recurse-prompts');
33
34
  const { synthesize } = require('./recurse-synthesize');
@@ -51,6 +52,15 @@ const DEFAULT_FANOUT_CONCURRENCY = 4;
51
52
  // many parallel scan-workers a measured corpus needs. A calibratable knob (the §9.1 algorithm; corpus-specific),
52
53
  // not a discovered constant — overridable via `opts.workerBudget`. 100 items ≈ a worker doing ~25 scan windows.
53
54
  const DEFAULT_WORKER_BUDGET = 100;
55
+ // BA-8 leaf-refine: temperature ESCALATES per retry. The live POC (poc/ba8-leaf-refine.mjs) found that at a flat
56
+ // low temperature a weak model regenerates byte-identical wrong code and IGNORES even crisp deterministic
57
+ // feedback (0/5 recovery); recovery only appears once retries are given room to vary (0/5 → 2-3/5). So escalation
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.
63
+ const DEFAULT_REFINE_TEMPS = [0.2, 0.7, 1.0];
54
64
 
55
65
  /**
56
66
  * Split an array into EXACTLY `n` contiguous, near-equal chunks (the data-partition for the §11 width path).
@@ -82,6 +92,23 @@ function workerPersonaPrefix(persona) {
82
92
  return p ? p + '\n\n' : '';
83
93
  }
84
94
 
95
+ /**
96
+ * BA-9 (relayfact F19): prepend the caller's read-only working-context blob to a worker's task message, so a
97
+ * sliced child can LOCATE its artifact (absolute paths / cwd) — the concrete context the Planner otherwise
98
+ * strips when it paraphrases the parent goal into child subtasks. Distinct from `persona`: persona is a STANCE
99
+ * on the SYSTEM prompt (a privileged seam); context is neutral run-state FACTS on the USER message (the form
100
+ * the live POC `poc/ba9-context-thread.mjs` validated: no-context 0/3 → context 3/3 on a weak model). Absent/
101
+ * blank ⇒ the task message is byte-identical to pre-BA-9 (backward-compatible). Carries down the tree via
102
+ * `forChild` (a child of a worker rooted at `/proj` is still rooted at `/proj`), like `persona`.
103
+ * @param {string} task
104
+ * @param {unknown} context
105
+ * @returns {string}
106
+ */
107
+ function withContext(task, context) {
108
+ const c = typeof context === 'string' ? context.trim() : '';
109
+ return c ? `Working context (read-only):\n${c}\n\n${task}` : task;
110
+ }
111
+
85
112
  /**
86
113
  * The opts a delegated child inherits. Strips the parent's TOP-LEVEL SETPOINT — `contract`/`evaluate` grade
87
114
  * the WHOLE task's final answer; a child grading its own slice against the whole definition-of-done is wasted
@@ -91,9 +118,10 @@ function workerPersonaPrefix(persona) {
91
118
  * answered over the parent's corpus; a child has its own subtask and must not re-scan the parent's full corpus
92
119
  * (that would fan a whole-corpus count out under every child). The `critical → force-verify` SAFETY FLOOR is
93
120
  * unaffected — it keys on the task text via `isCritical`, not the contract, so a critical child still
94
- * self-verifies. Handle tools (`opts.tools`), `synthesize`, `maxDepth`, and **`persona`** carry down — the
95
- * persona is a DURABLE worker stance (a child of a "senior security engineer" is still one), unlike the
96
- * top-only `contract`/`evaluate` setpoint. It rides through the `...opts` spread (not in the strip list).
121
+ * self-verifies. Handle tools (`opts.tools`), `synthesize`, `maxDepth`, **`persona`**, and **`context`** (BA-9)
122
+ * carry down — the persona is a DURABLE worker stance (a child of a "senior security engineer" is still one)
123
+ * and the context is durable run-state (a child rooted at `/proj` is still rooted at `/proj`), unlike the
124
+ * top-only `contract`/`evaluate` setpoint. They ride through the `...opts` spread (not in the strip list).
97
125
  * @param {RecurseOptions} opts
98
126
  * @returns {RecurseOptions}
99
127
  */
@@ -173,8 +201,37 @@ function auditSafeCtx(ctx, overrides = {}) {
173
201
  * **SECURITY:** this is a PRIVILEGED system-prompt seam — treat `persona` like a system prompt. Do NOT pass
174
202
  * untrusted / end-user-controlled text here; a hostile persona is prepended ahead of the decomposition policy
175
203
  * and can override it (and any safety framing) for every worker in the tree. Caller-trusted input only.
204
+ * @property {string} [context] - (BA-9 / relayfact F19) An optional caller-supplied READ-ONLY working-context
205
+ * blob (e.g. "project root: /abs/path\nfiles are relative to it") prepended to EVERY worker's task message as
206
+ * a `Working context:` block, so a sliced child can LOCATE its artifact — the concrete context (absolute
207
+ * paths / cwd) the Planner strips when it paraphrases the parent goal into child subtasks. CARRIES DOWN the
208
+ * tree (preserved by `forChild`, like `persona`) and, when forced fan-out plans, is forwarded as the Planner's
209
+ * `info` so the slices themselves are path-aware. Also shown to the verifier (neutral FACTS, not a stance, so
210
+ * no anti-sycophancy concern — and an agentic critic needs the path to exercise the artifact). Distinct from
211
+ * `persona`: persona is a privileged SYSTEM-prompt stance; context is run-state facts on the USER message.
212
+ * Absent/blank ⇒ byte-identical to pre-BA-9 (backward-compatible). Validated live (`poc/ba9-context-thread.mjs`:
213
+ * a weak model went 0/3 → 3/3 at locating an unguessable file once the root was threaded).
214
+ * **SECURITY:** this becomes part of every worker's prompt (and the verifier's) — lower-privilege than
215
+ * `persona` (the USER message, not the SYSTEM prompt) but still a prompt-injection surface. Intended for
216
+ * TRUSTED run-state (paths/cwd); do NOT pass untrusted / end-user-controlled text here.
176
217
  * @property {ToolDef[]} [tools] - Handle tools offered to EVERY worker (RC-5 pull-default: litectx
177
218
  * `recall`/`get`, wired at build step 7). Workers query on demand; never the whole corpus.
219
+ * @property {{sensor: (result: any, ctx: {task: string, context: string|undefined, contract: string|null}) => (Verdict|Promise<Verdict>), maxIterations?: number, temperatures?: number[]}} [refineLeaf]
220
+ * (Opt-in, BA-8 / relayfact F17) Turn a DEFINITE LEAF (a node that is offered no `spawn_child` — `simple`
221
+ * tier or at `maxDepth`) into a bounded generate→sense→regenerate loop instead of a single pass, so a failed
222
+ * slice can self-correct. `sensor` is a DETERMINISTIC close (test/compile/lint — NOT a model judge, R-S8) that
223
+ * returns a `Verdict`; on a non-pass its `critique` (the GAP, not the transcript) is fed FRESH into the next
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
229
+ * `temperatures.length`; the REAL bound is bareguard (each attempt is gate-checked + metered). CARRIES DOWN the
230
+ * tree (preserved by `forChild`), so it engages at the leaves of a Family-A decomposition. Recovery is PARTIAL
231
+ * (a stubborn blind spot may persist) — `receipts.refineLeaf.passed` reports honestly. Does NOT apply to a node
232
+ * that delegates (its children + the tree verify own quality), nor to the scan/fanout/partition dispatch paths.
233
+ * Absent ⇒ a leaf is a single pass (byte-identical to pre-BA-8). An error-keyed `recall` is the CALLER's tool
234
+ * (`opts.tools`) keyed off the fed-back critique — bareagent stays litectx-agnostic.
178
235
  * @property {string} [contract] - Definition of done (A3). When present, the verifier grades against THIS,
179
236
  * not the loose task, and verification always runs.
180
237
  * @property {(result: any, ctx: {contract: string|null, task: string}) => (Verdict|Promise<Verdict>)} [evaluate]
@@ -234,6 +291,10 @@ function auditSafeCtx(ctx, overrides = {}) {
234
291
  * @property {boolean} incomplete
235
292
  * @property {boolean} halted
236
293
  * @property {object|null} tokens - The worker Loop's `metrics.tokens`.
294
+ * @property {{iterations: number, passed: boolean, temperatures: (number|null)[]}} [refineLeaf] - (BA-8) when this
295
+ * leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
296
+ * passed (false = honest non-recovery, not a faked success). `temperatures` are the EFFECTIVE per-attempt temps
297
+ * (BA-10): a `null` marks an attempt the model ran at its DEFAULT because it rejected the requested temperature.
237
298
  * @property {string|null} model
238
299
  * @property {string|null} [retrieval] - (§10 step 7) the retrieval mode this node ran (`scan`/`search`/`exact`),
239
300
  * or null/absent for a plain reasoning node.
@@ -401,6 +462,15 @@ async function recurse(task, ctx = {}, opts = {}) {
401
462
  if (Array.isArray(opts.corpus)) retrievalTools.push(buildExactTool(normalizeCorpus(opts.corpus)));
402
463
  }
403
464
  const handleTools = [...(Array.isArray(opts.tools) ? opts.tools : []), ...retrievalTools];
465
+
466
+ // BA-8 (opt-in): a DEFINITE leaf (no spawn offered — `simple` tier or at `maxDepth`) with a caller sensor runs
467
+ // as a bounded refine-with-escalation loop instead of a single pass, so a failed slice self-corrects. Gating on
468
+ // `!canSpawn` keeps it predictable (a node that may delegate is an orchestrator, not a leaf) and means the seam
469
+ // engages exactly at the leaves of a Family-A tree (it carries down via forChild). A no-op when unset.
470
+ if (!canSpawn && opts.refineLeaf && typeof opts.refineLeaf.sensor === 'function') {
471
+ return recurseRefineLeaf(task, ctx, opts, { provider, system, handleTools, depth, critical, node, sensor: opts.refineLeaf.sensor });
472
+ }
473
+
404
474
  // NB-3: collect each child's declared RESULT value (copy-on-return: the value, never its transcript) so the
405
475
  // reducer can aggregate them. Step-3's seam handed the receipts only, so a code-reduce could not see what to
406
476
  // combine — this closes that gap and is what Family B (step 5) will reduce over `runPlan` results[].
@@ -421,7 +491,8 @@ async function recurse(task, ctx = {}, opts = {}) {
421
491
  // Fresh message array = a true fresh window (RC-2 copy-on-return, IN side): the worker sees ONLY its task,
422
492
  // never a parent transcript. `ctx.depth` is threaded so bareguard's policy can enforce the depth cap (§6).
423
493
  const out = await loop.run(
424
- [{ role: 'user', content: task }],
494
+ // BA-9: prepend the caller's read-only working-context (paths/cwd) so this worker can locate its artifact.
495
+ [{ role: 'user', content: withContext(task, opts.context) }],
425
496
  tools,
426
497
  // auditSafeCtx: the run ctx reaches the gate as `_ctx`; strip the key-bearing provider (F16/BA-1). The
427
498
  // worker Loop already has `provider` as a constructor option, so stripping it from the run ctx is invisible
@@ -504,6 +575,109 @@ async function recurse(task, ctx = {}, opts = {}) {
504
575
  }
505
576
  }
506
577
 
578
+ /**
579
+ * BA-8 leaf-refine — run a DEFINITE leaf as a bounded generate→sense→regenerate loop (relayfact F17). Reuses the
580
+ * existing `refine.js` primitive (the Outcomes iterate→grade→revise port): each attempt is a FRESH leaf Loop
581
+ * (fresh window = fresh-feedback, D6/A1) seeded with the working-context'd task + (on a retry) the prior GAP, run
582
+ * at an ESCALATING temperature — the live-validated requirement that lets a weak model escape a repeat-the-same-
583
+ * mistake rut (a flat temperature recovered 0/5 in `poc/ba8-leaf-refine.mjs`). The `sensor` is the caller's
584
+ * DETERMINISTIC close (test/compile/lint, not a model judge). Governance is bareguard's: every attempt is gate-
585
+ * checked (`ctx.policy`) and metered (`onLlmResult`); a HaltError mid-loop is a clean `{incomplete}`. Honest
586
+ * non-recovery is reported (`receipts.refineLeaf.passed=false`), never a faked pass. An optional rubric `verify`
587
+ * still runs on top when a `contract`/`evaluate`/critical applies (the sensor gates retries; the rubric grades).
588
+ * @param {string} task
589
+ * @param {RecurseCtx} ctx
590
+ * @param {RecurseOptions} opts
591
+ * @param {{provider: Provider, system: string, handleTools: ToolDef[], depth: number, critical: boolean, node: RecurseNode, sensor: Function}} state
592
+ * @returns {Promise<RecurseResult>}
593
+ */
594
+ async function recurseRefineLeaf(task, ctx, opts, state) {
595
+ const { provider, system, handleTools, depth, critical, node, sensor } = state;
596
+ node.model = provider.model || null;
597
+ const cfg = /** @type {{maxIterations?: number, temperatures?: number[]}} */ (opts.refineLeaf || {});
598
+ const temps = Array.isArray(cfg.temperatures) && cfg.temperatures.length ? cfg.temperatures : DEFAULT_REFINE_TEMPS;
599
+ const maxIterations = Number.isInteger(cfg.maxIterations) && /** @type {number} */ (cfg.maxIterations) > 0
600
+ ? /** @type {number} */ (cfg.maxIterations) : temps.length;
601
+
602
+ // A refine leaf runs N Loops, so its receipts.tokens SUMS every attempt's spend (not just the last) — the
603
+ // honest cost of the node. The 4-tier tokens object (`{input,output,cacheCreation,cacheRead}`, loop.js) is flat
604
+ // numeric, so we accrue field-wise (robust to extra/renamed numeric fields). The gate already sees each attempt
605
+ // via onLlmResult independently; this is the receipts mirror. Stays null until an attempt produces metrics.
606
+ /** @type {Record<string, number>|null} */
607
+ let tokensSum = null;
608
+ const accrueTokens = (/** @type {any} */ t) => {
609
+ if (!t || typeof t !== 'object') return;
610
+ tokensSum = tokensSum || {};
611
+ for (const [k, v] of Object.entries(t)) if (typeof v === 'number') tokensSum[k] = (tokensSum[k] || 0) + v;
612
+ };
613
+ // BA-10 honest receipt: the EFFECTIVE temperature per attempt. A model that rejects a non-default
614
+ // `temperature` (400, unsupported/deprecated) runs at its DEFAULT — the provider drops it and the Loop
615
+ // surfaces `temperatureDropped`. Recording the requested temp would claim a value the model ignored, so
616
+ // a dropped attempt is stored as `null` ("provider default"). Indexed by iteration (refine calls once each).
617
+ /** @type {(number|null)[]} */
618
+ const effectiveTemps = [];
619
+ // One attempt = a fresh leaf Loop (no spawn tool: a retry is a direct correction, not a re-decomposition) at the
620
+ // iteration's temperature, with the GAP fed forward as fresh feedback. A governance halt → throw so refine stops.
621
+ const attempt = async ({ iteration, critique }) => {
622
+ const temperature = temps[Math.min(iteration, temps.length - 1)];
623
+ const loop = new Loop({
624
+ provider, system,
625
+ policy: ctx.policy || undefined,
626
+ onLlmResult: ctx.onLlmResult || undefined,
627
+ stream: ctx.stream || undefined,
628
+ throwOnError: false,
629
+ });
630
+ const base = withContext(task, opts.context);
631
+ const userText = critique
632
+ ? `${base}\n\nYour previous attempt FAILED these checks:\n${critique}\n\nReturn a corrected result that passes ALL of them.`
633
+ : base;
634
+ const out = await loop.run([{ role: 'user', content: userText }], handleTools, { ctx: auditSafeCtx(ctx, { depth }), temperature });
635
+ // `temperatureDropped` is set on the Loop result only when the model rejected the requested temperature
636
+ // (BA-10); it's absent on the error/halt return shapes, so read it through a narrow cast.
637
+ const dropped = /** @type {{temperatureDropped?: boolean}} */ (out).temperatureDropped;
638
+ effectiveTemps[iteration] = dropped ? null : temperature;
639
+ accrueTokens(out.metrics ? out.metrics.tokens : null);
640
+ if (typeof out.error === 'string' && out.error.startsWith('halt:')) throw new HaltError('refine-leaf attempt halted', { rule: out.error.slice('halt:'.length) });
641
+ if (out.error) throw new Error(out.error); // a non-halt worker fault → honest incomplete
642
+ return out.text;
643
+ };
644
+
645
+ try {
646
+ const outcome = await refine({
647
+ attempt,
648
+ evaluate: (result, c) => sensor(result, { task, context: opts.context, contract: c.contract }),
649
+ contract: typeof opts.contract === 'string' ? opts.contract : undefined,
650
+ maxIterations,
651
+ });
652
+ node.tokens = tokensSum;
653
+ // `temperatures` = the EFFECTIVE temps (BA-10): a `null` marks an attempt whose requested temperature the
654
+ // model rejected and ran at its default — so the receipt never claims a value the model ignored. On a
655
+ // temperature-accepting model this equals the requested `temps.slice(0, iterations)` (byte-identical receipt).
656
+ node.refineLeaf = { iterations: outcome.iterations, passed: !!(outcome.verdict && outcome.verdict.pass), temperatures: effectiveTemps.slice(0, outcome.iterations) };
657
+ const result = outcome.result;
658
+
659
+ // Optional rubric layer on top of the deterministic sensor (RC-7): forced for critical, or a contract/override.
660
+ const wantVerify = critical || typeof opts.contract === 'string' || typeof opts.evaluate === 'function';
661
+ if (wantVerify) {
662
+ const verdict = await verify(task, result, ctx, opts);
663
+ node.verdict = verdict;
664
+ return { result, verdict, receipts: node };
665
+ }
666
+ // No rubric layer ⇒ the sensor's final verdict IS the node verdict (a non-pass is surfaced, not hidden).
667
+ node.verdict = outcome.verdict || null;
668
+ return { result, verdict: outcome.verdict || null, receipts: node };
669
+ } catch (err) {
670
+ node.tokens = tokensSum; // record whatever attempts DID spend, on both the halt and fault paths
671
+ if (err instanceof HaltError) {
672
+ node.halted = true;
673
+ node.incomplete = true;
674
+ return { incomplete: true, best: null, receipts: node };
675
+ }
676
+ node.incomplete = true;
677
+ return { incomplete: true, best: null, receipts: node };
678
+ }
679
+ }
680
+
507
681
  /**
508
682
  * SCAN (§10 step 7 / §9.2.1) — the default retrieval mode for a "how many / all" task over a corpus. A
509
683
  * deterministic ORCHESTRATION, not a worker model call: every slice is processed, an isolated Loop LLM-judges
@@ -747,7 +921,10 @@ async function recurseFanout(task, ctx, opts, state) {
747
921
  const planner = new Planner({ provider, onLlmResult: /** @type {any} */ (ctx.onLlmResult) || undefined });
748
922
  let steps;
749
923
  try {
750
- steps = await planner.plan(task, { count });
924
+ // BA-9: forward the working-context as the Planner's `info` so the slices it writes are path-aware (a
925
+ // child still also receives `opts.context` directly via `forChild` — this just improves the split).
926
+ const planContext = typeof opts.context === 'string' && opts.context.trim() ? { count, info: opts.context } : { count };
927
+ steps = await planner.plan(task, planContext);
751
928
  } catch (err) {
752
929
  if (err instanceof HaltError) throw err;
753
930
  node.incomplete = true;
@@ -928,6 +1105,9 @@ function buildSpawnTool(ctx, opts, depth, maxDepth, node, childResults) {
928
1105
  */
929
1106
  function verify(task, result, ctx, opts) {
930
1107
  const contract = typeof opts.contract === 'string' ? opts.contract : null;
1108
+ // BA-9: the verifier sees the working-context too — neutral facts (not a stance, so no anti-sycophancy risk),
1109
+ // and an agentic critic needs the path to exercise the artifact. A caller `evaluate` gets the RAW task (it owns
1110
+ // its own context); only the default isolated grader is contextualized.
931
1111
  if (typeof opts.evaluate === 'function') {
932
1112
  return Promise.resolve(opts.evaluate(result, { contract, task }));
933
1113
  }
@@ -937,7 +1117,7 @@ function verify(task, result, ctx, opts) {
937
1117
  ? 'Judge whether the result satisfies the definition of done. Be strict and adversarial; cite the specific gap on any shortfall.'
938
1118
  : 'Judge whether the result fully and correctly answers the goal. Be strict and adversarial; cite the specific gap on any shortfall.';
939
1119
  return evaluator.evaluate(
940
- task,
1120
+ withContext(task, opts.context),
941
1121
  result,
942
1122
  { rubric, contract: contract || undefined },
943
1123
  { onLlmResult: /** @type {any} */ (ctx.onLlmResult), policy: ctx.policy },
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. */