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/CHANGELOG.md +247 -0
- package/README.md +1 -1
- package/bareloop.context.md +113 -29
- package/package.json +2 -2
- package/src/authorflow.js +24 -9
- package/src/authoring.js +32 -0
- package/src/authorjob.js +108 -36
- package/src/authorscout.js +83 -9
- package/src/calibrate.js +43 -12
- package/src/cardauthor.js +23 -16
- package/src/cli.js +73 -21
- package/src/declaredclose.js +25 -11
- package/src/index.js +8 -5
- package/src/job.js +92 -2
- package/src/judged.js +240 -19
- package/src/kinds.js +89 -12
- package/src/planrun.js +194 -41
- package/src/providers.js +301 -0
- package/src/ratelimit.js +116 -0
- package/src/reuse.js +9 -1
- package/src/run.js +68 -9
- package/src/transport.js +29 -12
- package/src/validate.js +4 -0
- package/types/authoring.d.ts +30 -0
- package/types/authorjob.d.ts +10 -6
- package/types/authorscout.d.ts +27 -2
- package/types/calibrate.d.ts +9 -2
- package/types/cardauthor.d.ts +13 -11
- package/types/cli.d.ts +2 -1
- package/types/declaredclose.d.ts +8 -1
- package/types/index.d.ts +1 -1
- package/types/job.d.ts +25 -0
- package/types/judged.d.ts +130 -16
- package/types/kinds.d.ts +51 -3
- package/types/planrun.d.ts +10 -2
- package/types/providers.d.ts +166 -0
- package/types/ratelimit.d.ts +59 -0
- package/types/run.d.ts +17 -3
- package/types/transport.d.ts +10 -4
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`
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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,
|
|
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
|
-
:
|
|
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
|
-
|
|
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') {
|
package/src/providers.js
ADDED
|
@@ -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
|
+
}
|