-
Notifications
You must be signed in to change notification settings - Fork 1
review: clamp the run budget to the effective credit cap; land before the cap #249
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: jwbron/review-skill-rule-quote
Are you sure you want to change the base?
Changes from all commits
78e501e
c1a0602
f59a7e6
f36d036
eed3d82
d87f728
03960c4
7eb5501
477172c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "review": patch | ||
| --- | ||
|
|
||
| Clamp the router's run budget to the effective per-run AI-credits cap, and make the cap visible to the run. The tier budget table is sized inside the workflow's assumed $10 ceiling, but the cap is enforced runner-side by the firewall api-proxy and was invisible in-container, so a consumer with a tighter `max-ai-credits` got soft targets that promised more work than the cap could pay for; the graceful-degradation path never fired and the run died at the api-proxy with findings in hand (observed on a 400-credit behavior test: three finders completed, ~390 credits spent, killed mid-persist before validation, nothing posted). The frontmatter now sets `max-ai-credits: 1000` explicitly and mirrors it into the agent environment as `REVIEW_MAX_AI_CREDITS` (kept in sync by consumers that override the cap); the router resolves the cap from that mirror (falling back to `GH_AW_MAX_AI_CREDITS`, a visible awf config, then gh-aw's 1000-credit default) and scales the selected tier's soft targets proportionally, floored at the trivial tier's values, marking `runBudget.capClamped` and `runBudget.effectiveCreditCap` in routing.json. The budget prompt gains a matching estimated-credits proxy (sum of in-band `subagent_tokens` over completed sub-agents, divided by 5,000) and two mandatory proxy checkpoints, the critical one immediately after the last finder returns and before claim validation, so a tight run sheds and lands a partial review instead of dying at the cap. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| /** | ||
| * The effective per-run AI-credit cap: discovery from the environment and the | ||
| * clamp that resizes a tier budget to fit inside it. Split from `router.ts` | ||
| * by concern (and its max-lines budget); the router imports both functions | ||
| * and remains the single CLI entry point. | ||
| */ | ||
|
|
||
| import type {RunBudget} from "./router"; | ||
| import {DEFAULT_TIER_BUDGETS} from "./router"; | ||
|
|
||
| /** The read-only slice of the filesystem surface cap discovery needs. */ | ||
| export type CreditCapFs = { | ||
| readFileSync: (p: string, enc: "utf8") => string; | ||
| existsSync: (p: string) => boolean; | ||
| }; | ||
|
|
||
| /** | ||
| * gh-aw's baked-in per-run AI-credits default (credits; 1 credit = $0.01), | ||
| * assumed when no cap is discoverable from the environment. | ||
| */ | ||
| export const DEFAULT_MAX_AI_CREDITS = 1000; | ||
|
|
||
| /** | ||
| * The landing reserve: a clamped budget's soft targets aim at this fraction | ||
| * of the effective cap, never the cap itself. Spend is unobservable mid-run | ||
| * (the orchestrator works from proxies), and requests already in flight when | ||
| * a checkpoint passes still bill after it, so a soft target equal to the hard | ||
| * cap leaves no room to land: the second budget-shed acceptance run engaged | ||
| * the clamp and still died at 416/400 credits mid-skill-audit. A quarter of | ||
| * the cap is sized to absorb both the proxy estimation error and the | ||
| * in-flight overshoot observed there. | ||
| */ | ||
| export const LANDING_RESERVE_RATIO = 0.75; | ||
|
|
||
| /** | ||
| * Clamp a tier budget to the effective per-run credit cap. The tier table is | ||
| * sized inside the workflow's assumed $10 ceiling; when a consumer sets a | ||
| * tighter `max-ai-credits`, the un-clamped soft targets promise more work | ||
| * than the hard cap can pay for, and the run dies at the api-proxy with | ||
| * findings in hand instead of shedding early (observed: a 400-credit run | ||
| * planned against the high tier's $10 targets and was killed mid-validation). | ||
| * The clamped soft dollar target is {@link LANDING_RESERVE_RATIO} of the cap, | ||
| * not the cap itself, so a run that sheds against its targets lands inside | ||
| * the hard ceiling. Scaling is proportional to spend, floored at the trivial | ||
| * tier's values so even a tiny cap yields the smallest designed review rather | ||
| * than zero work. A zero/negative cap means explicitly uncapped; undefined | ||
| * means unknown. | ||
| */ | ||
| export const clampBudgetToCreditCap = ( | ||
| budget: RunBudget, | ||
| capCredits: number | undefined, | ||
| ): RunBudget => { | ||
| if ( | ||
| capCredits === undefined || | ||
| !Number.isFinite(capCredits) || | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick (non-blocking): The |
||
| capCredits <= 0 | ||
| ) { | ||
| return budget; | ||
| } | ||
| const capUsd = capCredits / 100; | ||
| const softCapUsd = capUsd * LANDING_RESERVE_RATIO; | ||
| if (softCapUsd >= budget.maxUsd) { | ||
| // The tier's own soft target already leaves at least the reserve | ||
| // under the hard cap; record the cap and change nothing. | ||
| return {...budget, effectiveCreditCap: capCredits}; | ||
| } | ||
| const ratio = softCapUsd / budget.maxUsd; | ||
| const floor = DEFAULT_TIER_BUDGETS.trivial; | ||
| const scale = (value: number, min: number): number => | ||
| Math.max(min, Math.floor(value * ratio)); | ||
| return { | ||
| ...budget, | ||
| maxReviewerInvocations: scale( | ||
| budget.maxReviewerInvocations, | ||
| floor.maxReviewerInvocations, | ||
| ), | ||
| maxToolCallsPerFinding: scale( | ||
| budget.maxToolCallsPerFinding, | ||
| floor.maxToolCallsPerFinding, | ||
| ), | ||
| maxTotalToolCalls: scale( | ||
| budget.maxTotalToolCalls, | ||
| floor.maxTotalToolCalls, | ||
| ), | ||
| maxWallClockMinutes: scale( | ||
| budget.maxWallClockMinutes, | ||
| floor.maxWallClockMinutes, | ||
| ), | ||
| maxUsd: softCapUsd, | ||
| effectiveCreditCap: capCredits, | ||
| capClamped: true, | ||
| }; | ||
| }; | ||
|
|
||
| /** | ||
| * Resolve the effective per-run AI-credit cap from the environment, most | ||
| * explicit source first: | ||
| * | ||
| * 1. `REVIEW_MAX_AI_CREDITS` — the frontmatter `env:` mirror of | ||
| * `max-ai-credits` (the cap itself is enforced runner-side by the | ||
| * firewall api-proxy and is not otherwise exported to the agent). | ||
| * 2. `GH_AW_MAX_AI_CREDITS` — in case a future gh-aw exports it directly. | ||
| * 3. The awf firewall config (`apiProxy.maxAiCredits`), when its file is | ||
| * visible from the agent container. | ||
| * 4. gh-aw's baked-in default ({@link DEFAULT_MAX_AI_CREDITS}). | ||
| * | ||
| * Returns the cap in credits; may be zero/negative when a source explicitly | ||
| * disables the cap (the clamp treats that as uncapped). | ||
| */ | ||
| export const resolveCreditCap = ( | ||
| env: Record<string, string | undefined>, | ||
| fs: CreditCapFs, | ||
| ): number => { | ||
| for (const name of ["REVIEW_MAX_AI_CREDITS", "GH_AW_MAX_AI_CREDITS"]) { | ||
| const raw = env[name]; | ||
| if (raw !== undefined && raw.trim() !== "") { | ||
| const parsed = Number(raw); | ||
| if (Number.isFinite(parsed)) { | ||
| return parsed; | ||
| } | ||
| } | ||
| } | ||
| const runnerTemp = env["RUNNER_TEMP"]; | ||
| const candidates = [ | ||
| ...(runnerTemp ? [`${runnerTemp}/gh-aw/awf-config.json`] : []), | ||
| "/tmp/gh-aw/awf-config.json", | ||
| ]; | ||
| for (const path of candidates) { | ||
| if (!fs.existsSync(path)) { | ||
| continue; | ||
| } | ||
| try { | ||
| const parsed = JSON.parse(fs.readFileSync(path, "utf8")) as { | ||
| apiProxy?: {maxAiCredits?: unknown}; | ||
| }; | ||
| const cap = parsed.apiProxy?.maxAiCredits; | ||
| if (typeof cap === "number" && Number.isFinite(cap)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (non-blocking): No test covers a valid-JSON awf config whose it("falls through when a valid awf config lacks a numeric maxAiCredits", () => {
const {fs} = fakeFs({"/tmp/gh-aw/awf-config.json": JSON.stringify({apiProxy: {}})});
expect(resolveCreditCap({}, fs)).toBe(DEFAULT_MAX_AI_CREDITS);
}); |
||
| return cap; | ||
| } | ||
| } catch { | ||
| // Unreadable or unparseable candidate: fall through to the next. | ||
| } | ||
| } | ||
| return DEFAULT_MAX_AI_CREDITS; | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
note (non-blocking):
LANDING_RESERVE_RATIO = 0.75reads as the reserve held back, but it is the fraction of the cap the soft target aims at (softCapUsd = capUsd * LANDING_RESERVE_RATIO, line 78); the reserve is the complementary 0.25, as the "A quarter of the cap" docstring says. No behavioral defect — the tests pin 0.75 — but the inverted name invites a future "fix" toward 0.25 that would quietly quarter every clamped budget. A name likeLANDING_TARGET_RATIOwould match the arithmetic.Lower-confidence observations (non-blocking)
credit-cap.ts:114—resolveCreditCapchecks the hand-syncedREVIEW_MAX_AI_CREDITSmirror above the machine-enforced awf-config. No defect today (the in-repo consumer omits the mirror and falls through to the awf-config's true cap), but a consumer that set a stale mirror would clamp to the wrong cap; a min-across-sources or awf-first order would be desync-safe.review.md:244— the in-repo consumer.github/workflows/review.mdoverridesmax-ai-credits: 2500with noREVIEW_MAX_AI_CREDITSmirror, so the KEEP-IN-SYNC contract is already unheld in-repo; it resolves correctly only via the awf-config fall-through.review.md:807— the 0.75 clamp reserve, the ~0.75 shed trigger, and the ÷5,000 token proxy compound to roughly 3× conservatism, so a tight-cap run may start shedding at ~1⁄3 of the cap. Consider carrying the margin in one place.