bareloop 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/src/planrun.js CHANGED
@@ -43,6 +43,8 @@ import {
43
43
  } from './kinds.js';
44
44
  import { workBranchName, prepareWorkBranch } from './workbranch.js';
45
45
  import { TRANSPORT_MAX_ATTEMPTS, isTransportFailure } from './transport.js';
46
+ import { isRateLimited, rateLimitWaitMs, statedWaitMs } from './ratelimit.js';
47
+ import { setTimeout as sleepMs } from 'node:timers/promises';
46
48
  import { checkCloseAbsolutePaths, checkCloseByteSignature, checkStageByteSignature, checkCloseDirRequired } from './close-integrity.js';
47
49
  import { resolveCloseTimeoutMs, closeTimeoutBanner, TIMING_PREFLIGHT_CEILING_MS } from './closetimeout.js';
48
50
 
@@ -72,21 +74,44 @@ const { Loop, Retry, wireGate, HaltError } = require('bare-agent');
72
74
  const transportRetry = new Retry({ maxAttempts: TRANSPORT_MAX_ATTEMPTS, retryOn: isTransportFailure, timeout: 0 });
73
75
 
74
76
  /**
75
- * Wrap a Loop provider's `generate` so a single transport-class throw (fetch
76
- * itself throwing — no HTTP response was ever produced) gets ONE retry via
77
- * `transportRetry`, and the outcome is reported once the retry is settled.
78
- * Every other property of `baseProvider` (`.name`, `.model`, `.ownsCycle`,
79
- * `.policy`) passes through unchanged — bare-agent's Loop reads those
80
- * directly (src/loop.js).
77
+ * Wrap a Loop provider's `generate` with TWO independent, one-shot retry
78
+ * ladders: F115's transport-class retry (unchanged), and PRD item 28's
79
+ * parked (a/b) ruling — hamr's verbatim call: "do a/b as a fallback, and
80
+ * verify/validate + no regression" — a bounded retry on an HTTP 429 that
81
+ * honours the vendor's OWN stated wait (src/ratelimit.js). Every other
82
+ * property of `baseProvider` (`.name`, `.model`, `.ownsCycle`, `.policy`)
83
+ * passes through unchanged — bare-agent's Loop reads those directly
84
+ * (src/loop.js).
81
85
  *
82
- * F64 carve-out: a wall-DERIVED timeout (bare-agent's own `ETIMEDOUT`/
83
- * `EDEADLINE`, thrown because `clock.callTimeoutMs()` told the provider to
84
- * cut the call — `isWallTimeout`, src/clock.js) is doctrine's governance
85
- * stop, never a transport casualty, and retrying it would both mis-spend a
86
- * round the operator's clock already ended and corrupt `categorize`'s own
87
- * classification (which reads the SAME error a moment later). So the
88
- * per-call `retryOn` here is `isTransportFailure(err) && !isWallTimeout(err,
89
- * clock)` — the clock's verdict always wins over the transport shape.
86
+ * The two budgets are independent — one transport retry AND one rate-limit
87
+ * retry may both occur across a single `generate()` call's lifetime, but
88
+ * each at most once. This is a FALLBACK, not the primary answer: the
89
+ * primary answer (F139) is picking a worker model with adequate rate
90
+ * headroom; this softens the failure mode when that was not enough, it does
91
+ * not replace it.
92
+ *
93
+ * F64 carve-out (both ladders): a wall-DERIVED timeout (bare-agent's own
94
+ * `ETIMEDOUT`/`EDEADLINE`, thrown because `clock.callTimeoutMs()` told the
95
+ * provider to cut the call — `isWallTimeout`, src/clock.js) is doctrine's
96
+ * governance stop, never a casualty to retry, and retrying it would both
97
+ * mis-spend a round the operator's clock already ended and corrupt
98
+ * `categorize`'s own classification (which reads the SAME error a moment
99
+ * later). The wall always wins over either retry shape: before EITHER retry,
100
+ * `isWallTimeout` must be false, and before sleeping for a rate-limit retry,
101
+ * the run must have MORE time left than the wait would cost
102
+ * (`clock.remainingMs() > waitMs`) — a wait that would run past the
103
+ * deadline is not honoured; the run ends on the operator's clock instead of
104
+ * sleeping past it.
105
+ *
106
+ * `spendComplete` note (the one place the two ladders differ): a transport
107
+ * throw's first attempt may already have been billed (fetch failing after
108
+ * the request left the socket), so F115 floors `spendComplete` for the rest
109
+ * of the run regardless of `recovered` (src/run.js reads the `transport-retry`
110
+ * record's presence for this). A 429, in contrast, is a REFUSAL — the vendor
111
+ * rejected the request before processing it — so nothing can have been
112
+ * billed, and a `rate-limit-retry` record does NOT floor `spendComplete`
113
+ * (src/run.js only watches for `transport-retry`; this is a stated
114
+ * assumption, not independently measured against a real provider).
90
115
  *
91
116
  * Scope note: this wraps the ONE seam that builds every worker Loop
92
117
  * (scout/drafter/step-worker/fix-worker, planrun.js `newLoop` ~line 2240) —
@@ -103,38 +128,110 @@ const transportRetry = new Retry({ maxAttempts: TRANSPORT_MAX_ATTEMPTS, retryOn:
103
128
  * @param {(s: string) => string} scrub
104
129
  * @param {import('./clock.js').Clock} clock
105
130
  */
106
- function withTransportRetry(baseProvider, phase, emit, scrub, clock) {
131
+ // F146/F149 (PRD item 30.5) — every provider construction site in this repo now
132
+ // passes `exposeErrorBody: true` (bare-agent's providers), which attaches the
133
+ // parsed upstream error body as `err.body` on an HTTP throw. The body often
134
+ // carries the ONE sentence that names what actually broke — the category and
135
+ // HTTP status alone never do (F146: Qwen's `HTTP 400` said nothing on its own;
136
+ // its body said "System message must be at the beginning"). This was PARKED
137
+ // (2026-09-05 design note) because a raw body can echo the API key straight
138
+ // back — so it goes through `redactSecrets`, the ONE secret shape inventory
139
+ // (`SECRET_PATTERNS`/`scanSecrets`/`redactSecrets`, `src/validate.js`),
140
+ // REGARDLESS of whatever `scrub` a caller has in scope, and is bounded at
141
+ // `BODY_MAX` chars (600 — bounded like every other detail this file emits:
142
+ // `CHECK_GAP_MAX`, `SCOUT_BLOB_MAX`) so one oversized body can never blow a
143
+ // record open. Only this redacted, capped STRING ever rides an emit — the raw
144
+ // `e.body` object itself must never reach a spine record.
145
+ const BODY_MAX = 600;
146
+ const BODY_MARKER = ' — body: ';
147
+ /** @param {any} e @returns {string} '' when there is no body, else ' — body: <redacted, ≤BODY_MAX chars>' */
148
+ const bodySuffix = (e) => {
149
+ if (e?.body === undefined) return '';
150
+ let raw;
151
+ try { raw = JSON.stringify(e.body); } catch { raw = String(e.body); }
152
+ return `${BODY_MARKER}${redactSecrets(raw).slice(0, BODY_MAX)}`;
153
+ };
154
+
155
+ function withProviderRetries(baseProvider, phase, emit, scrub, clock) {
107
156
  return new Proxy(baseProvider, {
108
157
  get(target, prop, receiver) {
109
158
  if (prop !== 'generate') return Reflect.get(target, prop, receiver);
110
159
  return async (...args) => {
111
- let attempts = 0;
112
- let firstErr = null;
113
- const fn = async () => {
114
- attempts += 1;
160
+ // Each budget is consumed at most once across the WHOLE call,
161
+ // however many times the two ladders alternate.
162
+ let transportBudget = 1;
163
+ let rateLimitBudget = 1;
164
+
165
+ /**
166
+ * One "transport-protected" attempt: calls `target.generate` through
167
+ * the shared `transportRetry` instance, which itself may retry once
168
+ * for a transport-class throw — but only while `transportBudget`
169
+ * is still available, so a SECOND pass through this function (after
170
+ * a rate-limit sleep) cannot spend a transport retry twice.
171
+ */
172
+ const attemptWithTransport = async () => {
173
+ let calls = 0;
174
+ /** @type {any} */
175
+ let firstErr = null;
176
+ const fn = async () => {
177
+ calls += 1;
178
+ try {
179
+ return await target.generate(...args);
180
+ } catch (err) {
181
+ if (calls === 1) firstErr = err;
182
+ throw err;
183
+ }
184
+ };
115
185
  try {
116
- return await target.generate(...args);
186
+ const result = await transportRetry.call(fn, {
187
+ retryOn: (err) => transportBudget > 0 && isTransportFailure(err) && !isWallTimeout(err, clock),
188
+ });
189
+ if (calls > 1) {
190
+ transportBudget -= 1;
191
+ const message = String(firstErr?.message ?? firstErr ?? '').slice(0, 200) + bodySuffix(firstErr);
192
+ emit('transport-retry', { phase, attempt: 1, error: scrub(message), recovered: true });
193
+ }
194
+ return result;
117
195
  } catch (err) {
118
- if (attempts === 1) firstErr = err;
196
+ if (calls > 1) {
197
+ transportBudget -= 1;
198
+ const message = String(firstErr?.message ?? firstErr ?? '').slice(0, 200) + bodySuffix(firstErr);
199
+ emit('transport-retry', { phase, attempt: 1, error: scrub(message), recovered: false });
200
+ }
119
201
  throw err;
120
202
  }
121
203
  };
122
- // `recovered` is knowable only once the retried attempt has settled
123
- // (succeeded or exhausted the budget) — emit exactly once, here,
124
- // after the outcome is known, never speculatively before it.
125
- const report = (recovered) => {
126
- if (attempts <= 1) return; // no retry happened — nothing to report
127
- const message = String(firstErr?.message ?? firstErr ?? '').slice(0, 200);
128
- emit('transport-retry', { phase, attempt: 1, error: scrub(message), recovered });
204
+
205
+ /** The whole call, recursing (at most once) through a rate-limit sleep. */
206
+ const attempt = async () => {
207
+ try {
208
+ return await attemptWithTransport();
209
+ } catch (err) {
210
+ if (rateLimitBudget <= 0 || !isRateLimited(err) || isWallTimeout(err, clock)) throw err;
211
+
212
+ const waitMs = rateLimitWaitMs(err);
213
+ const remainingMs = clock.remainingMs();
214
+ // The wall always wins: no retry when nothing parses to an
215
+ // honourable wait, and none when the run has no more time left
216
+ // than the wait itself would cost.
217
+ if (waitMs == null || (remainingMs !== Infinity && remainingMs <= waitMs)) throw err;
218
+
219
+ rateLimitBudget -= 1;
220
+ const stated = statedWaitMs(err);
221
+ const message = String(err?.message ?? err ?? '').slice(0, 200) + bodySuffix(err);
222
+ await sleepMs(waitMs, undefined, { ref: false });
223
+ try {
224
+ const result = await attempt();
225
+ emit('rate-limit-retry', { phase, attempt: 1, statedMs: stated, waitedMs: waitMs, error: scrub(message), recovered: true });
226
+ return result;
227
+ } catch (err2) {
228
+ emit('rate-limit-retry', { phase, attempt: 1, statedMs: stated, waitedMs: waitMs, error: scrub(message), recovered: false });
229
+ throw err2;
230
+ }
231
+ }
129
232
  };
130
- try {
131
- const result = await transportRetry.call(fn, { retryOn: (err) => isTransportFailure(err) && !isWallTimeout(err, clock) });
132
- report(true);
133
- return result;
134
- } catch (err) {
135
- report(false);
136
- throw err;
137
- }
233
+
234
+ return attempt();
138
235
  };
139
236
  },
140
237
  });
@@ -617,8 +714,15 @@ ${scoutBlob || '(no scout notes)'}`;
617
714
  * `false` → the drafter has no tools, so a native session would report NO cost — return a
618
715
  * metered claude-json TEXT provider (`--output-format json`, `parse:'claude-json'`) instead,
619
716
  * so its spend is never invisible. The Loop path (`anthropic-api`) never touches this.
717
+ * @param {string|null} [opts.judgeModel] softgreen — WHICH model that judge provider drives
718
+ * (PRD item 32.1). Resolved by the CALLER (`resolveJudge`, src/judged.js: the spec's signed
719
+ * `judge` override, else the job's own worker model), never pinned in this library. It
720
+ * travels with `judgeProvider` or the judged stage stops as a wiring gap: a verdict whose
721
+ * grader cannot be named is a floor nobody can attribute.
620
722
  * @param {any} [opts.judgeProvider] softgreen — the provider a JUDGED close stage runs its
621
- * locate call through, pinned by the operator to `JUDGE_MODEL` (src/judged.js). Separate
723
+ * locate call through, built by the CALLER from the RESOLVED judge identity (PRD item
724
+ * 32.1: `resolveJudge`, src/judged.js — the spec's signed `judge` override, else the job's
725
+ * own worker provider and model), never pinned in this library. Separate
622
726
  * from `provider`/`providerFor` on purpose: the judge tier is not a step knob and never
623
727
  * agent-selectable, and the worker's binding is not a legal stand-in for it. Absent is not
624
728
  * a fall-back — a judged stage with no seam instrument-STOPS as a wiring gap, the same
@@ -831,7 +935,7 @@ ${scoutBlob || '(no scout notes)'}`;
831
935
  * 'branch-red' | 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' |
832
936
  * 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
833
937
  */
834
- export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, closeDir = null, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null, resumable = true }) {
938
+ export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, closeDir = null, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null, resumable = true }) {
835
939
  // MEMORY-CACHE: what the read shim (src/readshim.js) saved THIS run, summed across
836
940
  // every mkWorker's own shim instance (scout, drafter, each step's worker, the fix
837
941
  // worker) — one accumulator closed over by all of them, because the shim's ledger
@@ -1432,6 +1536,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1432
1536
  * transport-debugging option list for a run that simply ran out of time. A
1433
1537
  * thrower that named its own category keeps it (the standing typed-attribution
1434
1538
  * rule); only an UNNAMED throw is classified here.
1539
+ *
1540
+ * F146/F149 — this is also the ONE place a categorized error's `.message` gets
1541
+ * the redacted upstream body appended (`bodySuffix`, above). Every downstream
1542
+ * reader of a categorized error's message — `relay()`'s `detail` here, AND
1543
+ * ralph.js's own mid-step dumb-passthrough catch (`detail: String(e.message ||
1544
+ * e)`) reading the SAME error `mkWorker`'s `ask`/`askFrom` threw after routing
1545
+ * it through this function — gets the sentence for free, without ralph.js
1546
+ * importing anything (it stays stdlib-only, PRD §4: fixed, deliberately dumb).
1547
+ * `relay()` re-categorizes an error its own `ask`/`askFrom` already ran through
1548
+ * here, so this must be IDEMPOTENT: `BODY_MARKER` guards a second pass over the
1549
+ * same error from doubling the suffix.
1435
1550
  * @param {any} e
1436
1551
  * @returns {{ err: CategorizedError, category: string }} the same throw, stamped,
1437
1552
  * plus the category as a plain string (so a caller reading it never has to
@@ -1442,6 +1557,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1442
1557
  const category = e instanceof HaltError ? 'cap-halt'
1443
1558
  : (err.category ?? (isWallTimeout(err, clock) ? 'wall-halt' : 'provider-red'));
1444
1559
  err.category = category;
1560
+ if (typeof err.message === 'string' && !err.message.includes(BODY_MARKER)) {
1561
+ const suffix = bodySuffix(err);
1562
+ if (suffix) err.message += suffix;
1563
+ }
1445
1564
  return { err, category };
1446
1565
  };
1447
1566
 
@@ -1521,6 +1640,18 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1521
1640
  // an attempt's (a budget funds the attempt PLUS its close), and a null cost trips
1522
1641
  // the same F6 pricing halt rather than being laundered into $0.
1523
1642
  judgeLoop: judgeProvider ? (/** @type {{system: string}} */ o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system }) : null,
1643
+ // the seam's other half (PRD item 32.1) — WHICH model that loop drives. Passed
1644
+ // through untouched: this module resolves no judge, exactly as it constructs no
1645
+ // provider. `runJudgedFloor` stops as a wiring gap when it is absent rather than
1646
+ // falling back to a library pin, so a null here is a stop and never a default.
1647
+ ...(judgeModel === null ? {} : { judgeModel }),
1648
+ // F152/PRD 30.4 — the judge's locate call carried NO time bound at all: a
1649
+ // live endpoint that accepts and never answers hung the process forever
1650
+ // (measured), because an open socket is an active handle and no backstop
1651
+ // can fire on it. The SAME derivation every worker round already uses,
1652
+ // handed through as the FUNCTION (not a snapshot) so a retried locate call
1653
+ // re-reads the remaining time rather than reusing the first attempt's.
1654
+ callBounds,
1524
1655
  onJudgeCost: (/** @type {any} */ c) => emit('judge-round', {
1525
1656
  stage: c.stage, path: c.path, attempt: c.attempt, label: c.label, model: c.model,
1526
1657
  costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
@@ -2456,7 +2587,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2456
2587
  // re-spends a subscription turn, not a metered fetch) and is out of scope.
2457
2588
  const loopProvider = native
2458
2589
  ? /** @type {any} */ (nativeProvider)({ policy, maxTurns, hasTools: false })
2459
- : withTransportRetry(workerProvider ?? provider, phase, emit, scrub, clock);
2590
+ : withProviderRetries(workerProvider ?? provider, phase, emit, scrub, clock);
2460
2591
  // F66 — the stall watchdog. bare-agent's `timeoutMs` bounds socket INACTIVITY,
2461
2592
  // not call duration (provider-http.js `req.setTimeout` resets on every byte),
2462
2593
  // so a connection that stays alive while producing nothing is invisible to it:
@@ -2498,7 +2629,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2498
2629
  const newLoop = (gen) => {
2499
2630
  /** @type {any} */
2500
2631
  let self = null;
2501
- /** @param {{costUsd?: number|null, pricing?: string|null, rateSource?: string|null, usage?: any, kind?: string}} arg */
2632
+ /** @param {{costUsd?: number|null, pricing?: string|null, rateSource?: string|null, usage?: any, kind?: string, stopReason?: string|null}} arg */
2502
2633
  const metered = async (arg) => {
2503
2634
  // The meter fires for EVERY generation, live or abandoned. An orphaned round
2504
2635
  // was really billed, and spend that no instrument sees is the one thing this
@@ -2519,6 +2650,14 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2519
2650
  ...rateSourceFields(arg),
2520
2651
  tokens: (arg?.usage?.inputTokens ?? 0) + (arg?.usage?.outputTokens ?? 0),
2521
2652
  usage: usageOf(arg?.usage),
2653
+ // PRD item 28 — BA-13: bare-agent's Loop surfaces the round's own neutral
2654
+ // stop reason ('end_turn'|'max_tokens'|'tool_use'|'refusal'|…) on EVERY
2655
+ // onLlmResult call, forwarded verbatim. REPORT-ONLY (nothing here acts on
2656
+ // it — the Loop itself already refuses to execute a 'max_tokens' round's
2657
+ // tool calls, BA-6); null when bare-agent didn't say (pre-BA-13, or a
2658
+ // non-string value), never invented — the same honesty rule every other
2659
+ // provenance field on this record already follows.
2660
+ stopReason: typeof arg?.stopReason === 'string' ? arg.stopReason : null,
2522
2661
  });
2523
2662
  // Everything BELOW here belongs to the live call only. A superseded call must
2524
2663
  // not beat the watch (that is the whole watchdog), must not spend the step's
@@ -2571,7 +2710,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2571
2710
  */
2572
2711
  const askFrom = async (msgs, prompt) => {
2573
2712
  try {
2574
- return await stallWatch.watch((gen) => newLoop(gen).run([...msgs, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
2713
+ // `msgs` is a prior `loop.run()`'s returned transcript, which already has
2714
+ // the Loop's `system` PREPENDED (bare-agent Loop.run, loop.js ~482-486).
2715
+ // The new Loop built below is constructed with the same `system` and would
2716
+ // prepend it again, leaving two identical system messages at index 0/1.
2717
+ // Real OpenAI tolerates that; vLLM-class OpenAI-compatible backends 400 on
2718
+ // it ("System message must be at the beginning") — captured evidence:
2719
+ // bareloop-patients/spines-poc-openai/qwen-failing-request-x760ei99.json.
2720
+ // bare-agent's own Loop.chat() (loop.js ~1321) strips a leading system
2721
+ // message before re-running for exactly this reason; do the same here.
2722
+ const rest = msgs[0]?.role === 'system' ? msgs.slice(1) : msgs;
2723
+ return await stallWatch.watch((gen) => newLoop(gen).run([...rest, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
2575
2724
  } catch (e) {
2576
2725
  throw categorize(e).err;
2577
2726
  }
@@ -2618,6 +2767,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2618
2767
  * worker does, so this path needs the same split or it is the blinder route),
2619
2768
  * and transport. */
2620
2769
  const relay = (/** @type {any} */ e, /** @type {string} */ phase) => {
2770
+ // categorize(e) mutates e.message in place (guarded, idempotent) with the
2771
+ // redacted body suffix — never re-append it here, or a caller whose thrown
2772
+ // error already went through categorize() once (mkWorker's ask/askFrom)
2773
+ // would double it.
2621
2774
  const { category } = categorize(e);
2622
2775
  const detail = String(e?.message ?? e);
2623
2776
  if (category === 'cap-halt') {
@@ -0,0 +1,301 @@
1
+ // Provider factory (PRD item 28, shape (1)): ONE seam mapping a job spec's
2
+ // `provider` name to the bare-agent constructor it drives, the env var
3
+ // carrying its key, and the per-tier model table — including any
4
+ // provider-specific request-key gating. Before this module existed, both
5
+ // runners constructed `AnthropicProvider` BY NAME (`src/cli.js`'s
6
+ // `buildProviders`, `scripts/run-u.mjs`'s top-level wiring) and any second
7
+ // provider would have meant scattering `if (provider === …)` across both.
8
+ //
9
+ // hamr's ruling (PRD 30.7, 2026-09-09): `deepseek-chat` is THE secondary
10
+ // provider — "settle on one secondary/replacement for claude … don't
11
+ // pickpocket random models". This table is not a menu of half-tested
12
+ // models; it is exactly the two provider identities the PROVIDERS menu
13
+ // (`src/job.js`) admits.
14
+ //
15
+ // What this module is NOT: it never RESOLVES a judge identity itself (that
16
+ // stays arbiter territory, `resolveJudge`/`resolveJobJudge` in
17
+ // src/judged.js — the spec's signed `judge` override, else the job's own
18
+ // worker provider and model, PRD item 32) — it only CONSTRUCTS the judge
19
+ // provider object from an identity the caller already resolved
20
+ // (`buildRunnerProviders`, below); it never prices a round (F113: rates
21
+ // passthrough is dead), and never decides a budget.
22
+
23
+ import { createRequire } from 'node:module';
24
+
25
+ const require = createRequire(import.meta.url);
26
+ const { AnthropicProvider, OpenAIProvider, GeminiProvider } = require('bare-agent/providers');
27
+
28
+ /**
29
+ * Today's anthropic-api tier table — EXACTLY the `DEFAULT_TIER_MODELS` both
30
+ * runners hardcoded before this factory existed. Any change to these two
31
+ * values is a change to shipped anthropic-api behaviour, not a refactor.
32
+ */
33
+ export const ANTHROPIC_TIER_MODELS = Object.freeze({
34
+ sonnet: 'claude-sonnet-5',
35
+ haiku: 'claude-haiku-4-5-20251001',
36
+ });
37
+
38
+ /**
39
+ * DeepSeek (F149/F150) is a SINGLE model, not a tiered family — hamr's
40
+ * ruling above: one secondary provider, not a menu. Both tiers map to the
41
+ * same model id ON PURPOSE: `resolveWorkerModel`/a runner's `providerFor
42
+ * (tier)` only ever LOOK UP a tier's model id (src/job.js:818-827,
43
+ * scripts/run-u.mjs's `providerFor`) — neither assumes the tiers name
44
+ * different models — so repeating the one id is the honest representation
45
+ * of a single-model provider, not a second model masquerading as `haiku`.
46
+ * If DeepSeek (or a future openai-api entrant) ever ships a genuinely
47
+ * cheaper tier model, this is where it would earn its own `haiku` id.
48
+ */
49
+ export const OPENAI_TIER_MODELS = Object.freeze({
50
+ sonnet: 'deepseek-chat',
51
+ haiku: 'deepseek-chat',
52
+ });
53
+
54
+ /**
55
+ * Gemini (PRD item 31.3, hamr 2026-09-09: "anthropic, openai, gemini drop
56
+ * ollama for now"). Two real tiers, unlike DeepSeek's single model: `flash` is
57
+ * the cheap tier by design, so `haiku` maps to it honestly rather than
58
+ * repeating one id.
59
+ *
60
+ * ADMITTED-PENDING-PROBE. The probe rule stands (PRD item 28 ruling (d),
61
+ * 2026-09-09): no endpoint is in the menu without its own clean paid probe,
62
+ * and `gemini-api` has ZERO runs. `anthropic-api` is the original (~167
63
+ * archived runs) and `openai-api` was probed green through the shipped runner
64
+ * (run mtu12vks, $1.64/$4, F157); gemini owes that fire and has not paid it.
65
+ * `PROBE_STATUS` below is the machine-readable form of that debt — nothing in
66
+ * here silently pretends the provider is proven.
67
+ */
68
+ export const GEMINI_TIER_MODELS = Object.freeze({
69
+ sonnet: 'gemini-2.5-pro',
70
+ haiku: 'gemini-2.5-flash',
71
+ });
72
+
73
+ /**
74
+ * Which admitted providers have paid for their own end-to-end probe, and which
75
+ * have not. Read by the runner to print a loud marker; never read to REFUSE —
76
+ * hamr admitted gemini to the menu, and a table that quietly withheld it would
77
+ * be a second, invisible ruling. An unprobed provider runs, and says so.
78
+ * @type {Readonly<Record<string, {probed: boolean, evidence: string}>>}
79
+ */
80
+ export const PROBE_STATUS = Object.freeze({
81
+ 'anthropic-api': Object.freeze({ probed: true, evidence: 'the original surface — ~167 archived runs' }),
82
+ 'openai-api': Object.freeze({ probed: true, evidence: 'run mtu12vks, green through scripts/run-u.mjs, $1.64/$4 (F157)' }),
83
+ 'gemini-api': Object.freeze({ probed: false, evidence: 'ZERO runs — admitted to the menu, owes its own paid probe (PRD item 31.3)' }),
84
+ });
85
+
86
+ /**
87
+ * Per-MODEL request-key gating (F149, live-measured 2026-09-09): DeepSeek
88
+ * silently IGNORES the modern `max_completion_tokens` key — asked for 64
89
+ * output tokens, it returned 665/608 — and only honours the legacy
90
+ * `max_tokens` key. `bare-agent`'s `OpenAIProvider` already exposes the
91
+ * switch (`legacyMaxTokens`, BA-24; no model-name sniffing inside
92
+ * bare-agent itself — "the caller declares the dialect"), so THIS table is
93
+ * bareloop's routing of it, keyed per model, never a global default. An
94
+ * output cap that does not bind is a money hazard: a reasoning model can
95
+ * run to its own ceiling on every round, silently, so getting this wrong
96
+ * for even one model defeats every budget that model's tier sets.
97
+ *
98
+ * A model with no entry here gets `{}` — bare-agent's own default
99
+ * (`max_completion_tokens`, GPT-5-safe) applies, unmodified.
100
+ */
101
+ const OPENAI_MODEL_OPTIONS = Object.freeze({
102
+ 'deepseek-chat': Object.freeze({ legacyMaxTokens: true }),
103
+ });
104
+
105
+ /**
106
+ * @typedef {object} ProviderTableEntry
107
+ * @property {new (options: any) => any} ctor the bare-agent provider class
108
+ * @property {string} envKey the environment variable this provider reads its key from
109
+ * @property {Readonly<Record<string, string>>} tiers tier name -> model id
110
+ * @property {string} endpointKey the CONSTRUCTOR OPTION NAME this provider reads its endpoint from — NOT assumed to be `baseUrl` (see below)
111
+ * @property {(model: string|undefined) => Record<string, any>} paramsFor per-model request-key/param gating, applied on TOP of {exposeErrorBody:true, apiKey, model, <endpointKey>?}
112
+ */
113
+
114
+ // ── WHY `endpointKey` IS A FIELD AND NOT THE LITERAL `baseUrl` ──────────────
115
+ //
116
+ // hamr's framing for this item was "api + api shape + endpoint" (PRD 31.3), and
117
+ // the endpoint half is the one that does NOT generalize. Read from bare-agent's
118
+ // own constructors, not assumed:
119
+ //
120
+ // AnthropicProvider `baseUrl` (provider-anthropic.js:49)
121
+ // OpenAIProvider `baseUrl` (provider-openai.js:76)
122
+ // GeminiProvider `baseUrl` (provider-gemini.js:47)
123
+ // OllamaProvider `url` (provider-ollama.js:32) <- and NO apiKey at all
124
+ //
125
+ // Every one of these constructors defaults its endpoint when the option is
126
+ // absent and none of them validate unknown option names. So passing `baseUrl`
127
+ // to Ollama would not throw: it would be IGNORED, the provider would default to
128
+ // `http://localhost:11434`, and the run would quietly talk to the wrong machine.
129
+ // That is the F149 class exactly — DeepSeek silently ignoring
130
+ // `max_completion_tokens` and returning 665 tokens for a 64-token cap — where a
131
+ // silently-dropped option produces a plausible-looking run against a
132
+ // configuration nobody chose.
133
+ //
134
+ // Ollama is NOT admitted today (hamr, 2026-09-09: "drop ollama for now"), so
135
+ // this field currently reads `baseUrl` for all three entries and its value is
136
+ // never exercised against a second spelling. It is here anyway, because the
137
+ // alternative is a hardcoded `baseUrl` that is correct for exactly as long as
138
+ // the menu holds only http-shaped API providers, and the FIRST thing that
139
+ // changes when Ollama is admitted is the one line nobody would think to look at.
140
+
141
+ /** @type {Readonly<Record<string, ProviderTableEntry>>} */
142
+ const PROVIDER_TABLE = Object.freeze({
143
+ 'anthropic-api': Object.freeze({
144
+ ctor: AnthropicProvider,
145
+ envKey: 'ANTHROPIC_API_KEY',
146
+ tiers: ANTHROPIC_TIER_MODELS,
147
+ endpointKey: 'baseUrl',
148
+ // Anthropic has only ever had one request key for the output cap
149
+ // (`max_tokens`) — nothing to gate here, ever (unlike OpenAI-shaped
150
+ // backends, which fork on `max_tokens` vs `max_completion_tokens`).
151
+ paramsFor: () => ({}),
152
+ }),
153
+ 'openai-api': Object.freeze({
154
+ ctor: OpenAIProvider,
155
+ envKey: 'OPENAI_API_KEY',
156
+ tiers: OPENAI_TIER_MODELS,
157
+ endpointKey: 'baseUrl',
158
+ paramsFor: (model) => OPENAI_MODEL_OPTIONS[/** @type {string} */ (model)] ?? {},
159
+ }),
160
+ 'gemini-api': Object.freeze({
161
+ ctor: GeminiProvider,
162
+ envKey: 'GEMINI_API_KEY',
163
+ tiers: GEMINI_TIER_MODELS,
164
+ endpointKey: 'baseUrl',
165
+ // Gemini carries no `max_tokens`/`max_completion_tokens` fork of its own —
166
+ // bare-agent's GeminiProvider maps the cap internally. Nothing to gate, and
167
+ // an empty object is the honest statement of that, not an omission.
168
+ paramsFor: () => ({}),
169
+ }),
170
+ });
171
+
172
+ /**
173
+ * Look up a provider's table entry. Throws a NAMED error on an unrecognized
174
+ * provider — never a silent default (the `resolveRates`-style-substring-
175
+ * match lesson: a forward hazard even at zero observed rounds, MEMORY.md).
176
+ * `src/job.js`'s `PROVIDERS` menu is the one place a spec's `provider`
177
+ * field is validated; this is the runtime construction seam it feeds.
178
+ * @param {string} providerName
179
+ * @returns {ProviderTableEntry}
180
+ */
181
+ export function resolveProvider(providerName) {
182
+ const entry = PROVIDER_TABLE[/** @type {keyof typeof PROVIDER_TABLE} */ (providerName)];
183
+ if (!entry) {
184
+ throw new Error(
185
+ `src/providers.js: unknown provider "${providerName}" — no silent default. `
186
+ + `Known providers: ${Object.keys(PROVIDER_TABLE).join(', ')}`,
187
+ );
188
+ }
189
+ return entry;
190
+ }
191
+
192
+ /**
193
+ * Construct ONE provider instance for `providerName`/`model`, applying
194
+ * `exposeErrorBody: true` (F153 — every constructed provider, unconditionally)
195
+ * and the model's own request-key gating (`paramsFor`). `baseUrl` (PRD item
196
+ * 28, ruling (d), 2026-09-09: admitted in v1) is forwarded only when given —
197
+ * every provider constructor here defaults it on its own when absent.
198
+ * @param {string} providerName
199
+ * @param {{apiKey?: string, model?: string, baseUrl?: string}} [options]
200
+ */
201
+ export function makeProvider(providerName, { apiKey, model, baseUrl } = {}) {
202
+ const entry = resolveProvider(providerName);
203
+ return new entry.ctor({
204
+ exposeErrorBody: true,
205
+ apiKey,
206
+ model,
207
+ // the endpoint goes in under THIS PROVIDER'S OWN OPTION NAME, never a
208
+ // hardcoded `baseUrl` (PRD item 31.3). The spec field stays `baseUrl` for
209
+ // everyone — one name for the person writing the job — and the translation
210
+ // to the constructor's spelling happens here, once. A provider whose option
211
+ // is spelled differently would otherwise IGNORE the endpoint silently and
212
+ // run against its own default; see the note above the table.
213
+ ...(baseUrl !== undefined ? { [entry.endpointKey]: baseUrl } : {}),
214
+ ...entry.paramsFor(model),
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Build the `{provider, providerFor, judgeProvider}` triple both runners
220
+ * need (`src/cli.js`'s `buildProviders`, `scripts/run-u.mjs`'s top-level
221
+ * wiring) — ONE seam instead of two copies of the same construction and
222
+ * tier-memoization logic. `model`/`tierModels` are already-RESOLVED inputs
223
+ * (the caller runs `resolveWorkerModel` itself, exactly as both runners did
224
+ * before this factory existed — that decision stays where each runner's own
225
+ * `--model` flag/spec precedence lives, not duplicated in here).
226
+ *
227
+ * THE JUDGE IS NO LONGER PINNED TO ANTHROPIC (PRD item 32.2, hamr 2026-09-10:
228
+ * *"what i care about is that judge becomes llm agnostic and not set to one
229
+ * model or provider"*). It is constructed through this same factory, from a
230
+ * `judgeProviderName`/`judgeModel` pair the CALLER resolved (`resolveJudge`,
231
+ * src/judged.js — the spec's signed `judge` override, else the job's own worker
232
+ * provider and model). Item 28's pin, and item 28 part (2)'s deferral of a
233
+ * signed `judge:{provider,model}` field, both close here.
234
+ *
235
+ * `judgeApiKey` stays a SEPARATE argument rather than being derived: the judge
236
+ * may legitimately be a different provider from the worker, and reading its key
237
+ * for it would put a second env-var reader in a module whose whole job is to
238
+ * have exactly one. The caller reads the resolved judge provider's own `envKey`
239
+ * and hands the key in — the same contract the worker key already has here.
240
+ * @param {object} args
241
+ * @param {string} args.providerName spec.provider (or the anthropic-api default)
242
+ * @param {string|undefined} args.apiKey the WORKER's key, read from the provider's own `envKey`
243
+ * @param {string} args.model the already-resolved worker model id
244
+ * @param {Readonly<Record<string, string>>} args.tierModels the already-resolved tier -> model map (spec override folded in by the caller)
245
+ * @param {string|undefined} [args.baseUrl] spec.baseUrl, if any
246
+ * @param {string|undefined} args.judgeApiKey the judge's own key, read by the caller from the RESOLVED judge provider's `envKey`
247
+ * @param {string} args.judgeModel the resolved judge model id
248
+ * @param {string} [args.judgeProviderName] the resolved judge provider name; defaults to `providerName` (the job's own worker provider), which is `resolveJudge`'s own default
249
+ * @param {string|undefined} [args.judgeBaseUrl] the judge provider's endpoint, when it takes one
250
+ */
251
+ export function buildRunnerProviders({
252
+ providerName, apiKey, model, tierModels, baseUrl, judgeApiKey, judgeModel,
253
+ judgeProviderName = providerName, judgeBaseUrl = undefined,
254
+ }) {
255
+ const provider = makeProvider(providerName, { apiKey, model, baseUrl });
256
+ /** @type {Record<string, any>} */
257
+ const tierCache = {};
258
+ const providerFor = (/** @type {string} */ tier) => (tierCache[tier] ??= (
259
+ tierModels[tier] === model
260
+ ? provider
261
+ : makeProvider(providerName, { apiKey, model: tierModels[tier], baseUrl })
262
+ ));
263
+ // the judge goes through the SAME table as the worker — one place names a
264
+ // provider identity, which is the entire reason this factory exists. Reusing
265
+ // the worker instance when the identity matches exactly is not an optimisation
266
+ // for its own sake: a second instance against the same endpoint/model is a
267
+ // second prompt-cache prefix, and cache writes are ~41% of the bill.
268
+ const judgeProvider = (judgeProviderName === providerName && judgeModel === model && judgeApiKey === apiKey
269
+ && judgeBaseUrl === baseUrl)
270
+ ? provider
271
+ : makeProvider(judgeProviderName, { apiKey: judgeApiKey, model: judgeModel, baseUrl: judgeBaseUrl });
272
+ return { provider, providerFor, judgeProvider };
273
+ }
274
+
275
+ /**
276
+ * The launch-time warning lines for an ADMITTED-PENDING-PROBE provider, or
277
+ * `null` when the provider has paid for its own end-to-end probe (and for an
278
+ * off-table name — an unknown provider is `resolveProvider`'s named throw to
279
+ * report, never this function's warning to soften).
280
+ *
281
+ * This lives here, next to `PROBE_STATUS`, rather than inline in the runner,
282
+ * for one reason: inline it was UNTESTABLE. `scripts/run-u.mjs` reaches this
283
+ * point only with a real job row (a machine-local patient path) and a real
284
+ * signed spec, so nothing in the suite could reach the branch, and a warning
285
+ * nothing executes drifts exactly the way the green-only prose did (item 31.2).
286
+ *
287
+ * It never REFUSES, and it never returns a shape a caller can mistake for a
288
+ * refusal: hamr admitted `gemini-api` to the menu, and a probe table that
289
+ * quietly withheld it would be a second ruling nobody made.
290
+ * @param {string} providerName
291
+ * @returns {string[]|null} lines to print, in order, or null when there is nothing to say
292
+ */
293
+ export function probeWarningLines(providerName) {
294
+ const probe = PROBE_STATUS[/** @type {keyof typeof PROBE_STATUS} */ (providerName)];
295
+ if (!probe || probe.probed !== false) return null;
296
+ return [
297
+ `⚠ UNPROVEN PROVIDER — "${providerName}" is on the menu but has never been probed end to end.`,
298
+ ` ${probe.evidence}`,
299
+ ' It will run. Nothing has shown that it CAN run this job to a close-rendered verdict.',
300
+ ];
301
+ }