bareloop 0.22.0 → 0.23.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 +125 -0
- package/README.md +1 -1
- package/bareloop.context.md +60 -13
- package/package.json +2 -2
- package/src/authorflow.js +7 -2
- package/src/authorscout.js +83 -9
- package/src/calibrate.js +10 -0
- package/src/cli.js +40 -19
- package/src/declaredclose.js +9 -2
- package/src/job.js +37 -2
- package/src/judged.js +108 -5
- package/src/kinds.js +12 -0
- package/src/planrun.js +180 -39
- package/src/providers.js +176 -0
- package/src/ratelimit.js +116 -0
- package/src/reuse.js +9 -1
- package/src/run.js +59 -5
- package/src/transport.js +29 -12
- package/types/authorscout.d.ts +27 -2
- package/types/declaredclose.d.ts +6 -1
- package/types/job.d.ts +4 -0
- package/types/judged.d.ts +7 -2
- package/types/kinds.d.ts +9 -0
- package/types/providers.d.ts +104 -0
- package/types/ratelimit.d.ts +59 -0
- package/types/run.d.ts +8 -0
- package/types/transport.d.ts +10 -4
package/src/job.js
CHANGED
|
@@ -133,7 +133,11 @@ export const CADENCE_UNITS = Object.freeze(['hour', 'day', 'week']);
|
|
|
133
133
|
* subscription — its costUsd axis is NOTIONAL (API-equivalent value, not
|
|
134
134
|
* billed), so it is a DISTINCT condition: rows never pool with anthropic-api
|
|
135
135
|
* rows on the cost axis, and budgets sized for one do not transfer. */
|
|
136
|
-
|
|
136
|
+
/** PRD item 28 (2026-09-06/09-09): `openai-api` — an OpenAI-shaped provider,
|
|
137
|
+
* constructed via `src/providers.js`'s factory. hamr's ruling (PRD 30.7):
|
|
138
|
+
* `deepseek-chat` is THE secondary provider (one, not a menu of half-tested
|
|
139
|
+
* models); the factory's tier table is where that lives, not this menu. */
|
|
140
|
+
export const PROVIDERS = Object.freeze(['anthropic-api', 'openai-api', 'clipipe-subscription']);
|
|
137
141
|
/** V3 environment label: declared keys only — every field is a lineage-key
|
|
138
142
|
* candidate at N3. `provider` is part of the key by definition (top-level,
|
|
139
143
|
* not duplicated here). */
|
|
@@ -141,7 +145,7 @@ export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', '
|
|
|
141
145
|
// `steps` stays in the field list ONLY so a retired spec reds by name
|
|
142
146
|
// (`shape-retired`) instead of falling through to a generic unknown-field —
|
|
143
147
|
// the operator gets told what happened, not just that something is wrong.
|
|
144
|
-
const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools'];
|
|
148
|
+
const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'baseUrl', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools'];
|
|
145
149
|
/** the four-field plan shape's core (decision 5) — presence of any of these
|
|
146
150
|
* declares the shape; `tools` (the ceiling) rides the shape but alone does not
|
|
147
151
|
* declare it, so a legacy spec carrying it gets a pointed red, not a conflict */
|
|
@@ -267,6 +271,37 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
|
|
|
267
271
|
red('invalid-value', 'model', 'non-empty string — exact provider model id (e.g. "claude-sonnet-5")');
|
|
268
272
|
}
|
|
269
273
|
|
|
274
|
+
// BASE URL — PRD item 28, ruling (d), 2026-09-09: admitted in v1 of
|
|
275
|
+
// openai-api (an OpenAI-compatible gateway/endpoint), OPTIONAL and part of
|
|
276
|
+
// the signed hash like `model` above. This validator has no network
|
|
277
|
+
// access, so it checks SHAPE only: `https://` for a real endpoint, or
|
|
278
|
+
// `http://` restricted to a loopback host (a local OpenAI-compatible
|
|
279
|
+
// server, the only legitimate reason to skip TLS) — and never a
|
|
280
|
+
// credential embedded IN the URL itself. A URL travels into logs and
|
|
281
|
+
// spine records far more casually than a header ever would, and secrets
|
|
282
|
+
// never enter the tree/spine (hard line #3) — a bareUrl carrying
|
|
283
|
+
// `user:pass@host` is exactly that leak, just spelled differently.
|
|
284
|
+
if (spec.baseUrl !== undefined) {
|
|
285
|
+
if (!isNonEmptyString(spec.baseUrl)) {
|
|
286
|
+
red('invalid-value', 'baseUrl', 'non-empty string — an https:// URL (http:// admitted only to 127.0.0.1/localhost/::1)');
|
|
287
|
+
} else {
|
|
288
|
+
/** @type {URL|null} */
|
|
289
|
+
let u = null;
|
|
290
|
+
try { u = new URL(spec.baseUrl); } catch { /* u stays null — caught below */ }
|
|
291
|
+
if (!u) {
|
|
292
|
+
red('invalid-value', 'baseUrl', 'must be a parseable URL');
|
|
293
|
+
} else if (u.username !== '' || u.password !== '') {
|
|
294
|
+
red('invalid-value', 'baseUrl', 'no embedded credentials (user:pass@host) — secrets never enter a signed spec');
|
|
295
|
+
} else if (u.protocol === 'https:') {
|
|
296
|
+
// admitted
|
|
297
|
+
} else if (u.protocol === 'http:' && (u.hostname === '127.0.0.1' || u.hostname === 'localhost' || u.hostname === '::1')) {
|
|
298
|
+
// admitted — a local OpenAI-compatible endpoint, the one case that legitimately skips TLS
|
|
299
|
+
} else {
|
|
300
|
+
red('invalid-value', 'baseUrl', 'https:// required (http:// admitted only to 127.0.0.1/localhost/::1)');
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
270
305
|
// 3. the outer write fence — operator law (interview decision #4), same
|
|
271
306
|
// containment rules as the workflow layer THROUGH THE SAME CODE (F9: two
|
|
272
307
|
// transforms would let a fence validate green and mean something else).
|
package/src/judged.js
CHANGED
|
@@ -565,6 +565,43 @@ export function validateFacts(facts) {
|
|
|
565
565
|
|
|
566
566
|
// ── THE LOCATE PROMPT ───────────────────────────────────────────────────────
|
|
567
567
|
|
|
568
|
+
/**
|
|
569
|
+
* THE ONE SHAPE INVENTORY (PRD 30.8), stated ONCE and never twice. It drives
|
|
570
|
+
* BOTH the LOCATE prompt's prose ("declared with ...") AND the completeness
|
|
571
|
+
* detector's regex — the same standing rule the secret-shape scrubber
|
|
572
|
+
* follows (one inventory drives detection, never two spellings that can
|
|
573
|
+
* drift). A shape added here rides both sides in the same commit; a shape
|
|
574
|
+
* added to only a hand-typed prompt string or only a hand-typed regex is
|
|
575
|
+
* exactly the blind-instrument class this repo keeps paying for.
|
|
576
|
+
*
|
|
577
|
+
* Two BASE shapes, each carrying its own independent `export`/`async`
|
|
578
|
+
* modifiers (the modifiers sit in different POSITIONS in the two shapes —
|
|
579
|
+
* `async function foo(` vs `const foo = async () =>` — so they cannot be
|
|
580
|
+
* factored into one shared prefix):
|
|
581
|
+
* - `function name(...)` — optionally `export`, optionally `async`,
|
|
582
|
+
* in either order before `function`.
|
|
583
|
+
* - `const name = (...) =>` — optionally `export` before `const`,
|
|
584
|
+
* optionally `async` after `=`.
|
|
585
|
+
*
|
|
586
|
+
* Restricted to column-0 lines only, in both the prompt's prose and the
|
|
587
|
+
* detector: a nested function or a function expression returned from one is
|
|
588
|
+
* indented, and excluding it is the SAME cure PROMPT_HEAD already states
|
|
589
|
+
* (the POC's nested-function cure).
|
|
590
|
+
* @type {ReadonlyArray<{prose: string, match: (line: string) => string|null}>}
|
|
591
|
+
*/
|
|
592
|
+
const FN_SHAPES = Object.freeze([
|
|
593
|
+
{
|
|
594
|
+
prose: '`function name(...)` (optionally `async`, optionally `export`, in either order)',
|
|
595
|
+
match: (/** @type {string} */ line) =>
|
|
596
|
+
line.match(/^(?:(?:export|async)\s+){0,2}function\s+([A-Za-z_$][\w$]*)\s*\(/)?.[1] ?? null,
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
prose: '`const name = (...) =>` (optionally `export` before `const`, optionally `async` after `=`)',
|
|
600
|
+
match: (/** @type {string} */ line) =>
|
|
601
|
+
line.match(/^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/)?.[1] ?? null,
|
|
602
|
+
},
|
|
603
|
+
]);
|
|
604
|
+
|
|
568
605
|
/** The spine every card shares. Both POC cures live here rather than on a rule,
|
|
569
606
|
* because both are properties of ENUMERATING functions and not of any one
|
|
570
607
|
* question about them: the nested-function clause (the POC counted a returned
|
|
@@ -573,8 +610,7 @@ export function validateFacts(facts) {
|
|
|
573
610
|
const PROMPT_HEAD =
|
|
574
611
|
'You LOCATE FACTS in a source file. You never decide whether anything passes, and you never judge — '
|
|
575
612
|
+ 'another system does that.\n'
|
|
576
|
-
+
|
|
577
|
-
+ 'or `export function name(...)`), report:\n'
|
|
613
|
+
+ `For EVERY top-level function in the FILE (declared with ${FN_SHAPES.map((s) => s.prose).join(', or ')}), report:\n`
|
|
578
614
|
+ 'Count ONLY functions declared at the top level of the module — never a function nested inside '
|
|
579
615
|
+ 'another function, and never a function expression returned from one.\n'
|
|
580
616
|
+ ' "name": the function name\n'
|
|
@@ -596,6 +632,43 @@ export function locatePrompt(card) {
|
|
|
596
632
|
return `${PROMPT_HEAD}${asks}\n${PROMPT_TAIL}`;
|
|
597
633
|
}
|
|
598
634
|
|
|
635
|
+
/**
|
|
636
|
+
* THE COMPLETENESS MIRROR of `PROMPT_HEAD`'s own contract (PRD 30.8): which
|
|
637
|
+
* top-level function declarations the artifact ACTUALLY holds, read off the
|
|
638
|
+
* SAME `FN_SHAPES` inventory the prompt's prose is built from, restricted to
|
|
639
|
+
* column-0 lines.
|
|
640
|
+
*
|
|
641
|
+
* This is a HEURISTIC, never a parser, and it is used for exactly one thing:
|
|
642
|
+
* telling `decide()` when LOCATE's `functions` list is short of what the file
|
|
643
|
+
* holds. It never grades a rule and it never adds a fact `decide()` acts on
|
|
644
|
+
* beyond "this name exists at the top level" — the missing function's rule
|
|
645
|
+
* violations, if any, stay unknown, which is exactly why the miss is UNSURE
|
|
646
|
+
* and not a manufactured red on a specific rule.
|
|
647
|
+
*
|
|
648
|
+
* TWO DIRECTIONS, both handled but not symmetrically by design:
|
|
649
|
+
* - a shape THIS inventory does not enumerate is a FALSE NEGATIVE — the
|
|
650
|
+
* check stays silent about that function, same as it stays silent with
|
|
651
|
+
* no `artifactText` at all. Widening `FN_SHAPES` closes the hole; nothing
|
|
652
|
+
* here manufactures a claim about a shape it was never told to look for.
|
|
653
|
+
* - a column-0 line that only LOOKS like a declaration (e.g. inside a
|
|
654
|
+
* template literal) is a FALSE POSITIVE and reds a real judged close —
|
|
655
|
+
* that is the SAFE side of the fail-safe tiebreak (unsure is red), and it
|
|
656
|
+
* is never silently swallowed the way a false negative would be.
|
|
657
|
+
* @param {string} artifactText @returns {string[]}
|
|
658
|
+
*/
|
|
659
|
+
function topLevelFunctionNames(artifactText) {
|
|
660
|
+
/** @type {string[]} */
|
|
661
|
+
const names = [];
|
|
662
|
+
for (const line of artifactText.split('\n')) {
|
|
663
|
+
if (/^\s/.test(line)) continue; // indented: nested, or a returned function expression — never top level
|
|
664
|
+
for (const shape of FN_SHAPES) {
|
|
665
|
+
const name = shape.match(line);
|
|
666
|
+
if (name) { names.push(name); break; }
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return names;
|
|
670
|
+
}
|
|
671
|
+
|
|
599
672
|
/** the default toolless seam — one spelling of how this repo drives a judge.
|
|
600
673
|
* A `Loop`, NOT `provider.generate()`: the POC measured that a bare provider
|
|
601
674
|
* result carries no `costUsd` at all, and under F6 an unpriced call is a red,
|
|
@@ -628,12 +701,23 @@ export const defaultJudgeLoop = ({ provider, system }) => {
|
|
|
628
701
|
* On every route out, red or clean, `onCost` fires exactly once with the honest
|
|
629
702
|
* read: a paid call that leaves no meter record is F12 wearing a judge's coat.
|
|
630
703
|
* @param {{artifactText: string, card: any, loopFactory: (o: {provider?: any, system: string}) => any,
|
|
631
|
-
* maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number}) => void
|
|
704
|
+
* maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number}) => void,
|
|
705
|
+
* callBounds?: {timeoutMs?: number, deadlineMs?: number}}} o
|
|
632
706
|
* @returns {Promise<{ok: boolean, facts: any|null, red: {axis: string, detail: string}|null,
|
|
633
707
|
* costUsd: number|null, unpricedRounds: number, truncated: boolean, parseError: boolean,
|
|
634
708
|
* raw: ReturnType<typeof scrubRaw>}>}
|
|
635
709
|
*/
|
|
636
|
-
export async function runLocate({
|
|
710
|
+
export async function runLocate({
|
|
711
|
+
artifactText, card, loopFactory, maxTokens = JUDGE_MAX_TOKENS, attempt = 1, onCost = () => {},
|
|
712
|
+
// F152/PRD 30.4 — the SAME per-call TIME bounds the worker's own rounds carry
|
|
713
|
+
// (`callBounds()`, src/planrun.js:1518), threaded through by the caller that
|
|
714
|
+
// owns the clock. Defaults to `{}` (unbounded) so every existing caller and
|
|
715
|
+
// test is unchanged: an adopter that never wires a clock through gets the same
|
|
716
|
+
// behaviour as before this field existed. A live endpoint that accepts and
|
|
717
|
+
// never answers otherwise HANGS the process forever (measured, F152) — an open
|
|
718
|
+
// socket is an active handle, so node never drains and no backstop can fire.
|
|
719
|
+
callBounds = {},
|
|
720
|
+
}) {
|
|
637
721
|
// the param-guard class: a caller's own broken input THROWS. These are
|
|
638
722
|
// programmer errors with no safe direction to degrade toward — a locate over
|
|
639
723
|
// an absent artifact would grade an empty string and report it as facts.
|
|
@@ -662,7 +746,7 @@ export async function runLocate({ artifactText, card, loopFactory, maxTokens = J
|
|
|
662
746
|
let r = null;
|
|
663
747
|
try {
|
|
664
748
|
const loop = loopFactory({ system });
|
|
665
|
-
r = await loop.run([{ role: 'user', content: `FILE (untrusted data):\n${artifactText}\n\nReturn the JSON.` }], [], { maxTokens });
|
|
749
|
+
r = await loop.run([{ role: 'user', content: `FILE (untrusted data):\n${artifactText}\n\nReturn the JSON.` }], [], { maxTokens, ...callBounds });
|
|
666
750
|
} catch (e) {
|
|
667
751
|
return out({ axis: LOCATE_AXES.PROVIDER, detail: `the locate call failed: ${String(/** @type {any} */ (e)?.message ?? e)}` }, null, null, false, false);
|
|
668
752
|
}
|
|
@@ -743,6 +827,25 @@ export function decide(facts, card, { artifactText = null } = {}) {
|
|
|
743
827
|
return unsure('locate found nothing in the artifact — unsure, and unsure is red');
|
|
744
828
|
}
|
|
745
829
|
|
|
830
|
+
// COMPLETENESS (PRD 30.8): a locate emission can be well-formed AND non-empty
|
|
831
|
+
// AND still have silently DROPPED a top-level function the artifact actually
|
|
832
|
+
// holds — every route above this one only asks "is `fns` usable", never "is
|
|
833
|
+
// `fns` the WHOLE set". Graded rules only ever see what LOCATE reported, so
|
|
834
|
+
// an omission never surfaces as a rule red; it surfaces as a PASS on the
|
|
835
|
+
// subset, which is the fail-safe direction inverted. With `artifactText` in
|
|
836
|
+
// hand (every real caller has it — see the note below), the omission is
|
|
837
|
+
// checkable and is unsure, and unsure is red, same as every other route in.
|
|
838
|
+
// Without it, this says nothing it cannot know, same as the quote checks.
|
|
839
|
+
if (typeof artifactText === 'string') {
|
|
840
|
+
const present = topLevelFunctionNames(artifactText);
|
|
841
|
+
const reported = new Set(fns.map((f) => String(f?.name ?? '')));
|
|
842
|
+
const missing = present.filter((n) => !reported.has(n));
|
|
843
|
+
if (missing.length > 0) {
|
|
844
|
+
return unsure(`locate omitted top-level function(s) the artifact contains: ${missing.join(', ')} — a pass on `
|
|
845
|
+
+ 'the reported subset is not a pass on the file, and unsure is red');
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
746
849
|
const lines = typeof artifactText === 'string'
|
|
747
850
|
? new Set(artifactText.split('\n').map((l) => l.trim()).filter(Boolean))
|
|
748
851
|
: null;
|
package/src/kinds.js
CHANGED
|
@@ -260,6 +260,7 @@ export function mechanicalStages(stages) {
|
|
|
260
260
|
* judgeLoop?: (o: {system: string}) => any,
|
|
261
261
|
* onJudgeCost?: (c: {stage: string|null, kind: string|null, path: string, attempt: number,
|
|
262
262
|
* label: string, model: string, costUsd: number|null, unpricedRounds: number}) => void,
|
|
263
|
+
* callBounds?: () => {timeoutMs?: number, deadlineMs?: number},
|
|
263
264
|
* onStage?: (row: {stage: string|null, kind: string|null, verdict: string, durationMs: number}) => void}} Ctx
|
|
264
265
|
* The arbiter's half of a stage run. Every field here is operator/runner
|
|
265
266
|
* territory: a declaration parameterises kinds, never the contracts.
|
|
@@ -275,6 +276,11 @@ export function mechanicalStages(stages) {
|
|
|
275
276
|
* funded honestly: this executor cannot reach the run's ledger, so every locate
|
|
276
277
|
* call's cost is reported OUT through it, once per call, on every route including
|
|
277
278
|
* the red ones — a paid call that leaves no meter record is F12 in a judge's coat.
|
|
279
|
+
* `callBounds` is F152/PRD 30.4's per-call TIME bound, read fresh before every
|
|
280
|
+
* locate attempt — the same derivation the run's own worker rounds carry
|
|
281
|
+
* (`callBounds()`, src/planrun.js). Absent is the honest unbounded default:
|
|
282
|
+
* an adopter or test that wires no clock through behaves exactly as before
|
|
283
|
+
* this field existed.
|
|
278
284
|
* `onStage` is a REPORTING seam and nothing else: the seed read spawns a real
|
|
279
285
|
* toolchain per stage (a suite is the slow one) and a whole read is one opaque
|
|
280
286
|
* await, so the shell gets told as each stage lands. It decides nothing, it is
|
|
@@ -1781,6 +1787,12 @@ async function runJudgedFloor(stage, ctx) {
|
|
|
1781
1787
|
card: p.card,
|
|
1782
1788
|
loopFactory,
|
|
1783
1789
|
attempt,
|
|
1790
|
+
// F152/PRD 30.4 — read fresh per attempt, the same way `planrun.js`'s own
|
|
1791
|
+
// worker rounds re-read `callBounds()` at every call site rather than
|
|
1792
|
+
// once: the remaining time shrinks between attempts. Absent (no clock
|
|
1793
|
+
// wired through) is `{}`, the honest unbounded default `runLocate` itself
|
|
1794
|
+
// already carries.
|
|
1795
|
+
callBounds: typeof ctx.callBounds === 'function' ? ctx.callBounds() : {},
|
|
1784
1796
|
onCost: (c) => {
|
|
1785
1797
|
if (typeof ctx.onJudgeCost !== 'function') return;
|
|
1786
1798
|
ctx.onJudgeCost({
|
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
|
});
|
|
@@ -1432,6 +1529,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1432
1529
|
* transport-debugging option list for a run that simply ran out of time. A
|
|
1433
1530
|
* thrower that named its own category keeps it (the standing typed-attribution
|
|
1434
1531
|
* rule); only an UNNAMED throw is classified here.
|
|
1532
|
+
*
|
|
1533
|
+
* F146/F149 — this is also the ONE place a categorized error's `.message` gets
|
|
1534
|
+
* the redacted upstream body appended (`bodySuffix`, above). Every downstream
|
|
1535
|
+
* reader of a categorized error's message — `relay()`'s `detail` here, AND
|
|
1536
|
+
* ralph.js's own mid-step dumb-passthrough catch (`detail: String(e.message ||
|
|
1537
|
+
* e)`) reading the SAME error `mkWorker`'s `ask`/`askFrom` threw after routing
|
|
1538
|
+
* it through this function — gets the sentence for free, without ralph.js
|
|
1539
|
+
* importing anything (it stays stdlib-only, PRD §4: fixed, deliberately dumb).
|
|
1540
|
+
* `relay()` re-categorizes an error its own `ask`/`askFrom` already ran through
|
|
1541
|
+
* here, so this must be IDEMPOTENT: `BODY_MARKER` guards a second pass over the
|
|
1542
|
+
* same error from doubling the suffix.
|
|
1435
1543
|
* @param {any} e
|
|
1436
1544
|
* @returns {{ err: CategorizedError, category: string }} the same throw, stamped,
|
|
1437
1545
|
* plus the category as a plain string (so a caller reading it never has to
|
|
@@ -1442,6 +1550,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1442
1550
|
const category = e instanceof HaltError ? 'cap-halt'
|
|
1443
1551
|
: (err.category ?? (isWallTimeout(err, clock) ? 'wall-halt' : 'provider-red'));
|
|
1444
1552
|
err.category = category;
|
|
1553
|
+
if (typeof err.message === 'string' && !err.message.includes(BODY_MARKER)) {
|
|
1554
|
+
const suffix = bodySuffix(err);
|
|
1555
|
+
if (suffix) err.message += suffix;
|
|
1556
|
+
}
|
|
1445
1557
|
return { err, category };
|
|
1446
1558
|
};
|
|
1447
1559
|
|
|
@@ -1521,6 +1633,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1521
1633
|
// an attempt's (a budget funds the attempt PLUS its close), and a null cost trips
|
|
1522
1634
|
// the same F6 pricing halt rather than being laundered into $0.
|
|
1523
1635
|
judgeLoop: judgeProvider ? (/** @type {{system: string}} */ o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system }) : null,
|
|
1636
|
+
// F152/PRD 30.4 — the judge's locate call carried NO time bound at all: a
|
|
1637
|
+
// live endpoint that accepts and never answers hung the process forever
|
|
1638
|
+
// (measured), because an open socket is an active handle and no backstop
|
|
1639
|
+
// can fire on it. The SAME derivation every worker round already uses,
|
|
1640
|
+
// handed through as the FUNCTION (not a snapshot) so a retried locate call
|
|
1641
|
+
// re-reads the remaining time rather than reusing the first attempt's.
|
|
1642
|
+
callBounds,
|
|
1524
1643
|
onJudgeCost: (/** @type {any} */ c) => emit('judge-round', {
|
|
1525
1644
|
stage: c.stage, path: c.path, attempt: c.attempt, label: c.label, model: c.model,
|
|
1526
1645
|
costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
|
|
@@ -2456,7 +2575,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2456
2575
|
// re-spends a subscription turn, not a metered fetch) and is out of scope.
|
|
2457
2576
|
const loopProvider = native
|
|
2458
2577
|
? /** @type {any} */ (nativeProvider)({ policy, maxTurns, hasTools: false })
|
|
2459
|
-
:
|
|
2578
|
+
: withProviderRetries(workerProvider ?? provider, phase, emit, scrub, clock);
|
|
2460
2579
|
// F66 — the stall watchdog. bare-agent's `timeoutMs` bounds socket INACTIVITY,
|
|
2461
2580
|
// not call duration (provider-http.js `req.setTimeout` resets on every byte),
|
|
2462
2581
|
// so a connection that stays alive while producing nothing is invisible to it:
|
|
@@ -2498,7 +2617,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2498
2617
|
const newLoop = (gen) => {
|
|
2499
2618
|
/** @type {any} */
|
|
2500
2619
|
let self = null;
|
|
2501
|
-
/** @param {{costUsd?: number|null, pricing?: string|null, rateSource?: string|null, usage?: any, kind?: string}} arg */
|
|
2620
|
+
/** @param {{costUsd?: number|null, pricing?: string|null, rateSource?: string|null, usage?: any, kind?: string, stopReason?: string|null}} arg */
|
|
2502
2621
|
const metered = async (arg) => {
|
|
2503
2622
|
// The meter fires for EVERY generation, live or abandoned. An orphaned round
|
|
2504
2623
|
// was really billed, and spend that no instrument sees is the one thing this
|
|
@@ -2519,6 +2638,14 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2519
2638
|
...rateSourceFields(arg),
|
|
2520
2639
|
tokens: (arg?.usage?.inputTokens ?? 0) + (arg?.usage?.outputTokens ?? 0),
|
|
2521
2640
|
usage: usageOf(arg?.usage),
|
|
2641
|
+
// PRD item 28 — BA-13: bare-agent's Loop surfaces the round's own neutral
|
|
2642
|
+
// stop reason ('end_turn'|'max_tokens'|'tool_use'|'refusal'|…) on EVERY
|
|
2643
|
+
// onLlmResult call, forwarded verbatim. REPORT-ONLY (nothing here acts on
|
|
2644
|
+
// it — the Loop itself already refuses to execute a 'max_tokens' round's
|
|
2645
|
+
// tool calls, BA-6); null when bare-agent didn't say (pre-BA-13, or a
|
|
2646
|
+
// non-string value), never invented — the same honesty rule every other
|
|
2647
|
+
// provenance field on this record already follows.
|
|
2648
|
+
stopReason: typeof arg?.stopReason === 'string' ? arg.stopReason : null,
|
|
2522
2649
|
});
|
|
2523
2650
|
// Everything BELOW here belongs to the live call only. A superseded call must
|
|
2524
2651
|
// not beat the watch (that is the whole watchdog), must not spend the step's
|
|
@@ -2571,7 +2698,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2571
2698
|
*/
|
|
2572
2699
|
const askFrom = async (msgs, prompt) => {
|
|
2573
2700
|
try {
|
|
2574
|
-
|
|
2701
|
+
// `msgs` is a prior `loop.run()`'s returned transcript, which already has
|
|
2702
|
+
// the Loop's `system` PREPENDED (bare-agent Loop.run, loop.js ~482-486).
|
|
2703
|
+
// The new Loop built below is constructed with the same `system` and would
|
|
2704
|
+
// prepend it again, leaving two identical system messages at index 0/1.
|
|
2705
|
+
// Real OpenAI tolerates that; vLLM-class OpenAI-compatible backends 400 on
|
|
2706
|
+
// it ("System message must be at the beginning") — captured evidence:
|
|
2707
|
+
// bareloop-patients/spines-poc-openai/qwen-failing-request-x760ei99.json.
|
|
2708
|
+
// bare-agent's own Loop.chat() (loop.js ~1321) strips a leading system
|
|
2709
|
+
// message before re-running for exactly this reason; do the same here.
|
|
2710
|
+
const rest = msgs[0]?.role === 'system' ? msgs.slice(1) : msgs;
|
|
2711
|
+
return await stallWatch.watch((gen) => newLoop(gen).run([...rest, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
|
|
2575
2712
|
} catch (e) {
|
|
2576
2713
|
throw categorize(e).err;
|
|
2577
2714
|
}
|
|
@@ -2618,6 +2755,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2618
2755
|
* worker does, so this path needs the same split or it is the blinder route),
|
|
2619
2756
|
* and transport. */
|
|
2620
2757
|
const relay = (/** @type {any} */ e, /** @type {string} */ phase) => {
|
|
2758
|
+
// categorize(e) mutates e.message in place (guarded, idempotent) with the
|
|
2759
|
+
// redacted body suffix — never re-append it here, or a caller whose thrown
|
|
2760
|
+
// error already went through categorize() once (mkWorker's ask/askFrom)
|
|
2761
|
+
// would double it.
|
|
2621
2762
|
const { category } = categorize(e);
|
|
2622
2763
|
const detail = String(e?.message ?? e);
|
|
2623
2764
|
if (category === 'cap-halt') {
|