bareloop 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/providers.js CHANGED
@@ -12,16 +12,18 @@
12
12
  // models; it is exactly the two provider identities the PROVIDERS menu
13
13
  // (`src/job.js`) admits.
14
14
  //
15
- // What this module is NOT: it never picks a judge (the judge stays pinned
16
- // to `anthropic-api`/`JUDGE_MODEL` regardless of the job's worker provider —
17
- // arbiter territory, PRD item 28), never prices a round (F113: rates
18
- // passthrough is dead), and never decides a budget. It only constructs the
19
- // provider objects `bare-agent`'s `Loop` calls.
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.
20
22
 
21
23
  import { createRequire } from 'node:module';
22
24
 
23
25
  const require = createRequire(import.meta.url);
24
- const { AnthropicProvider, OpenAIProvider } = require('bare-agent/providers');
26
+ const { AnthropicProvider, OpenAIProvider, GeminiProvider } = require('bare-agent/providers');
25
27
 
26
28
  /**
27
29
  * Today's anthropic-api tier table — EXACTLY the `DEFAULT_TIER_MODELS` both
@@ -49,6 +51,38 @@ export const OPENAI_TIER_MODELS = Object.freeze({
49
51
  haiku: 'deepseek-chat',
50
52
  });
51
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
+
52
86
  /**
53
87
  * Per-MODEL request-key gating (F149, live-measured 2026-09-09): DeepSeek
54
88
  * silently IGNORES the modern `max_completion_tokens` key — asked for 64
@@ -73,15 +107,44 @@ const OPENAI_MODEL_OPTIONS = Object.freeze({
73
107
  * @property {new (options: any) => any} ctor the bare-agent provider class
74
108
  * @property {string} envKey the environment variable this provider reads its key from
75
109
  * @property {Readonly<Record<string, string>>} tiers tier name -> model id
76
- * @property {(model: string|undefined) => Record<string, any>} paramsFor per-model request-key/param gating, applied on TOP of {exposeErrorBody:true, apiKey, model, baseUrl?}
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>?}
77
112
  */
78
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
+
79
141
  /** @type {Readonly<Record<string, ProviderTableEntry>>} */
80
142
  const PROVIDER_TABLE = Object.freeze({
81
143
  'anthropic-api': Object.freeze({
82
144
  ctor: AnthropicProvider,
83
145
  envKey: 'ANTHROPIC_API_KEY',
84
146
  tiers: ANTHROPIC_TIER_MODELS,
147
+ endpointKey: 'baseUrl',
85
148
  // Anthropic has only ever had one request key for the output cap
86
149
  // (`max_tokens`) — nothing to gate here, ever (unlike OpenAI-shaped
87
150
  // backends, which fork on `max_tokens` vs `max_completion_tokens`).
@@ -91,8 +154,19 @@ const PROVIDER_TABLE = Object.freeze({
91
154
  ctor: OpenAIProvider,
92
155
  envKey: 'OPENAI_API_KEY',
93
156
  tiers: OPENAI_TIER_MODELS,
157
+ endpointKey: 'baseUrl',
94
158
  paramsFor: (model) => OPENAI_MODEL_OPTIONS[/** @type {string} */ (model)] ?? {},
95
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
+ }),
96
170
  });
97
171
 
98
172
  /**
@@ -130,7 +204,13 @@ export function makeProvider(providerName, { apiKey, model, baseUrl } = {}) {
130
204
  exposeErrorBody: true,
131
205
  apiKey,
132
206
  model,
133
- ...(baseUrl !== undefined ? { baseUrl } : {}),
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 } : {}),
134
214
  ...entry.paramsFor(model),
135
215
  });
136
216
  }
@@ -144,24 +224,33 @@ export function makeProvider(providerName, { apiKey, model, baseUrl } = {}) {
144
224
  * before this factory existed — that decision stays where each runner's own
145
225
  * `--model` flag/spec precedence lives, not duplicated in here).
146
226
  *
147
- * The JUDGE stays pinned to `anthropic-api`/`judgeModel` regardless of
148
- * `providerName` — arbiter territory (PRD item 28: "the judge stays PINNED
149
- * … regardless of the job's worker provider"). A signed `judge:{provider,
150
- * model}` field with its own calibration record is PRD item 28 part (2),
151
- * NOT built here; until it lands, the judge always needs its own
152
- * `judgeApiKey` (today, always `ANTHROPIC_API_KEY`), independent of which
153
- * key the worker used.
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.
154
240
  * @param {object} args
155
241
  * @param {string} args.providerName spec.provider (or the anthropic-api default)
156
242
  * @param {string|undefined} args.apiKey the WORKER's key, read from the provider's own `envKey`
157
243
  * @param {string} args.model the already-resolved worker model id
158
244
  * @param {Readonly<Record<string, string>>} args.tierModels the already-resolved tier -> model map (spec override folded in by the caller)
159
245
  * @param {string|undefined} [args.baseUrl] spec.baseUrl, if any
160
- * @param {string|undefined} args.judgeApiKey the judge's own key (today: `ANTHROPIC_API_KEY`)
161
- * @param {string} args.judgeModel `JUDGE_MODEL`
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
162
250
  */
163
251
  export function buildRunnerProviders({
164
252
  providerName, apiKey, model, tierModels, baseUrl, judgeApiKey, judgeModel,
253
+ judgeProviderName = providerName, judgeBaseUrl = undefined,
165
254
  }) {
166
255
  const provider = makeProvider(providerName, { apiKey, model, baseUrl });
167
256
  /** @type {Record<string, any>} */
@@ -171,6 +260,42 @@ export function buildRunnerProviders({
171
260
  ? provider
172
261
  : makeProvider(providerName, { apiKey, model: tierModels[tier], baseUrl })
173
262
  ));
174
- const judgeProvider = makeProvider('anthropic-api', { apiKey: judgeApiKey, model: judgeModel });
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 });
175
272
  return { provider, providerFor, judgeProvider };
176
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
+ }
package/src/run.js CHANGED
@@ -95,9 +95,14 @@ async function primitiveSmoke(workdir) {
95
95
  * metered claude-json text provider for the toolless drafter); ignored on every Loop-driven provider
96
96
  * @param {(type: string, data?: object) => object} opts.emit spine emitter
97
97
  * @param {(tier: string) => any} [opts.providerFor] P: per-step model-tier provider factory (forwarded to the plan flow)
98
+ * @param {string|null} [opts.judgeModel] SOFTGREEN — WHICH model the judge provider drives
99
+ * (PRD item 32.1), resolved by the CALLER (`resolveJudge`, src/judged.js) and passed
100
+ * through untouched. Travels with `judgeProvider`; absent, a judged stage stops as a
101
+ * wiring gap rather than grading under an identity nobody named.
98
102
  * @param {any} [opts.judgeProvider] SOFTGREEN — the provider a JUDGED close stage runs its
99
- * locate call through, wired by the operator and PINNED to `JUDGE_MODEL` (src/judged.js:
100
- * the only tier with established injection resistance upstream). Forwarded to the plan
103
+ * locate call through, built by the CALLER from the RESOLVED judge identity (PRD item
104
+ * 32.1: `resolveJudge`/`resolveJobJudge`, src/judged.js — the spec's signed `judge`
105
+ * override, else the job's own worker provider and model), never pinned. Forwarded to the plan
101
106
  * flow. It is its own seam and not `provider`/`providerFor`, because the judge tier is
102
107
  * never a step knob and never agent-selectable; absent, a judged stage instrument-stops as
103
108
  * a wiring gap rather than grading on whatever binding happened to be at hand. Its spend
@@ -237,7 +242,7 @@ async function primitiveSmoke(workdir) {
237
242
  * deliberately NOT in the resumable/checkpoint set (`src/reuse.js`) — an
238
243
  * unknown in-flight state is not a known-safe resume point.
239
244
  */
240
- export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, closeDir = null, layerRoot = false, readShim = false, scout = true, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null, resumable = true }) {
245
+ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, closeDir = null, layerRoot = false, readShim = false, scout = true, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null, resumable = true }) {
241
246
  // THE READ SHIM's ARM, resolved at the door — the FIRST thing this entry does,
242
247
  // before the ledger, before the approval gate, before a byte of the spec is read.
243
248
  // An unrecognised spelling throws here at zero cost instead of being coerced by
@@ -487,7 +492,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
487
492
  // accounts it natively (F12) and the job-end money contract is unchanged.
488
493
  {
489
494
  const outcome = await runPlan(job, {
490
- workdir, provider, nativeProvider, providerFor, judgeProvider, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, closeDir, layerRoot, readShim, scout, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd, resumable,
495
+ workdir, provider, nativeProvider, providerFor, judgeProvider, judgeModel, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, closeDir, layerRoot, readShim, scout, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd, resumable,
491
496
  remainingUsd: () => Math.min(shellCapUsd, job.budgetUsd - spentUsd),
492
497
  isUnpriced: () => unpriced, // F6: let the plan flow bail in-flight, not just after it returns
493
498
  spendComplete, // …and let its money-halt readout say whether the remaining it quotes is exact