-
Notifications
You must be signed in to change notification settings - Fork 1
review: enforce the run's spend ceiling in code, in real dollars #323
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: jwies/review-pi-harness-seam
Are you sure you want to change the base?
Changes from all commits
664d01c
3f5a5a4
2fb2666
70fcbb1
1b2ead2
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,61 @@ | ||
| --- | ||
| "review": minor | ||
| --- | ||
|
|
||
| review: enforce the run's spend ceiling in code, in real dollars | ||
|
|
||
| Dispatch now carries a budget ledger. Work may start while the run's spend is | ||
| under a dispatch budget of `CEILING_USD - LANDING_RESERVE_USD`; crossing it | ||
| refuses further dispatches, aborts the agents already in flight, and discloses | ||
| every shed as a note line rather than as a silently missing dimension. | ||
|
|
||
| Why in code rather than at the proxy. The only ceiling today is gh-aw's | ||
| api-proxy credit cap (`maxAiCredits: 2500` in the compiled lock), which is | ||
| denominated in list-price credits rather than the dollars Khan pays, cannot shed | ||
| gracefully (it fails the run), and disappears with the proxy when the migration | ||
| completes. This supersedes the intent of #314, which tried to fix the | ||
| denomination at the proxy and was blocked on a gh-aw version; the proxy cap | ||
| stays as a coarse list-price backstop while it exists. | ||
|
|
||
| The numbers are derived, not chosen, and a test pins them so a change to live | ||
| spend behaviour is a review conversation: **$12.50** ceiling, from the lock's | ||
| 2500-credit allowance minus the detection pass's measured ~$0.27 which this | ||
| ledger does not govern, against a measured ~$0.50 for a full case's sub-agents; | ||
| **$1.50** landing reserve, from the validator dispatch's measured ~$0.30 plus | ||
| the reconciler. The reserve is the part that is easy to omit and expensive to | ||
| miss: a ceiling with nothing held back turns an over-budget run into a wasted | ||
| one, because the run can no longer validate or post what it already paid to | ||
| find. Each consumer compiles its own lock, so a consumer with a different | ||
| allowance wants a different ceiling. | ||
|
|
||
| Enforcement is per turn where it can be. The runner asks the ledger at each turn | ||
| boundary whether the agent may continue, so a single heavy reviewer cannot | ||
| outspend a whole wave before anyone notices, and stopping happens at a turn | ||
| boundary rather than by throwing: an agent stopped for budget has usually | ||
| already found something worth keeping. The ledger has exactly one writer (the | ||
| dispatcher, once per completed attempt, so retries and refusal fallbacks each | ||
| pay), and the per-turn check is a read-only probe against it, which is what | ||
| keeps the accounting from double counting. Concurrency is approximated the way | ||
| the investigation cap approximates it: an in-flight agent cannot see its | ||
| siblings' unsettled spend, so a wave can overshoot by at most the number of | ||
| agents running at once, and `overshootUsd` measures that rather than assuming it | ||
| away. | ||
|
|
||
| `REVIEW_SPEND_ENFORCEMENT=proxy-only` is the rollback, and it measures without | ||
| enforcing: the record still reports `crossed`, still lists the sheds, and the | ||
| notice is loud, so a rolled-back run is not a blind run. The record names which | ||
| enforcement was in force, per the standing rule that where enforcement changes | ||
| over time the artifact says which one produced it. | ||
|
|
||
| The record itself (`DispatchResult.spend`: ceiling, reserve, spend, overshoot, | ||
| sheds, enforcement) is the run/cost/outcome telemetry shape, staged in both | ||
| `dispatch-result.json` and the `out/` copy the artifact upload carries. It is | ||
| deliberately a schema rather than an ad-hoc log line, because the same shape is | ||
| what a second consumer would need. | ||
|
|
||
| One property this does NOT restore, recorded in the module rather than implied: | ||
| awf's cap is un-weakenable because it lives in a container image no Khan repo | ||
| can edit, and this ceiling lives in a repo whose PRs this reviewer reviews. The | ||
| provider-side workspace limit that would have replaced that property is not | ||
| available, so CODEOWNERS on the ledger (naming two owners, since one stalls when | ||
| away and none enforces nothing) plus required review is the whole mitigation. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| # Ownership for the files where a quiet edit changes what this repo SPENDS or | ||
| # what it is allowed to do. Everything else in this repo is reviewed by whoever | ||
| # is around; these are the paths where "whoever is around" is not enough. | ||
| # | ||
| # The spend ceiling especially. It is the only limit on a review run's cost once | ||
| # the migration removes gh-aw's api-proxy credit cap, and unlike that cap it | ||
| # lives in a repo whose pull requests this reviewer reviews. A provider-side | ||
| # workspace limit would have restored the property of living somewhere no PR can | ||
| # reach; it is not available, so this file is the mitigation. Two owners rather | ||
| # than one, because a single-owner entry stalls the moment that owner is away | ||
| # and an ownerless entry enforces nothing at all. | ||
| # | ||
| # Pin bumps and policy edits under these paths are behaviour changes: reviewed | ||
| # as such, never batch-merged. | ||
|
|
||
| /workflows/review/lib/spend-ledger.ts @jwbron @jeresig | ||
| /CODEOWNERS @jwbron @jeresig | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -670,6 +670,30 @@ export type PiRunnerOptions = { | |
| * time to tell which one sheds findings. | ||
| */ | ||
| systemPrompt?: string; | ||
| /** | ||
| * Asked at each turn boundary whether to keep going, given what THIS agent | ||
| * has spent so far. The spend ledger answers (lib/spend-ledger.ts), as a | ||
| * read-only probe: the ledger's accounting is written once per completed | ||
| * dispatch, and this only decides whether to stop early. | ||
| * | ||
| * Per turn rather than per dispatch because that is the only granularity | ||
| * where the answer is still useful: cost is known when a turn ends, and one | ||
| * heavy reviewer can outspend a whole wave of light ones, so a check that | ||
| * runs between dispatches learns about the overshoot after paying it. | ||
| */ | ||
| onTurnCost?: (agentSpentUsd: number) => "continue" | "abort"; | ||
| /** | ||
| * Run-wide abort. The spend ledger fires this when a COMPLETED dispatch | ||
| * pushes the run past its budget, which is the case `onTurnCost` cannot | ||
| * cover: that probe only stops the agent doing the spending, while this | ||
| * stops the siblings already in flight beside it. Without it, "crossing | ||
| * aborts in-flight agents" would be true only of the agent that crossed. | ||
| * | ||
| * A thunk is accepted because the ledger's signal is phase-dependent (the | ||
| * landing phase swaps in a fresh signal so reserve-funded dispatches are | ||
| * not born aborted); it is resolved once per request. | ||
| */ | ||
| abortSignal?: AbortSignal | (() => AbortSignal | undefined); | ||
| }; | ||
|
|
||
| export const createPiRunner = async ( | ||
|
|
@@ -719,6 +743,21 @@ export const createPiRunner = async ( | |
| return async (request: AgentRequest): Promise<AgentResult> => { | ||
| const started = Date.now(); | ||
| const abort = new AbortController(); | ||
| // Link the run-wide abort in both directions in time: already-aborted | ||
| // means never start, and aborting later means stop now. | ||
| const runAbort = | ||
| typeof options.abortSignal === "function" | ||
| ? options.abortSignal() | ||
| : options.abortSignal; | ||
| if (runAbort?.aborted === true) { | ||
| abort.abort(runAbort.reason); | ||
| } else { | ||
| runAbort?.addEventListener( | ||
|
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. todo (blocking): Run-wide abortSignal linking into the runner's internal AbortController is untested. The ledger unit test asserts signal.aborted flips, but nothing verifies the runner consumes that signal to stop an in-flight request. The fake chargingRunner ignores the signal entirely, so both the pre-aborted and later-abort branches are dead to the suite. A sketch, not a committable replacement: |
||
| "abort", | ||
| () => abort.abort(runAbort.reason), | ||
| {once: true}, | ||
| ); | ||
| } | ||
| let timedOut = false; | ||
| const timer = setTimeout(() => { | ||
| timedOut = true; | ||
|
|
@@ -742,6 +781,13 @@ export const createPiRunner = async ( | |
| let usd = 0; | ||
| let turns = 0; | ||
| let toolCalls = 0; | ||
| /** | ||
| * Set when the spend ledger says stop. Read by shouldStopAfterTurn, so | ||
| * the loop ends at a TURN boundary rather than by throwing: an agent | ||
| * stopped for budget has usually already found something, and killing | ||
| * it mid-turn would throw that away along with the turn's cost. | ||
| */ | ||
| let budgetExhausted = false; | ||
| let stopReason: string | undefined; | ||
| let errorMessage: string | undefined; | ||
| let rawStopReason: string | undefined; | ||
|
|
@@ -780,7 +826,9 @@ export const createPiRunner = async ( | |
| * "end the turn now". | ||
| */ | ||
| shouldStopAfterTurn: () => | ||
| captured !== undefined || turns >= request.maxTurns, | ||
| captured !== undefined || | ||
| turns >= request.maxTurns || | ||
| budgetExhausted, | ||
| }, | ||
| (event: Record<string, unknown>) => { | ||
| if (event["type"] === "tool_execution_end") { | ||
|
|
@@ -835,6 +883,12 @@ export const createPiRunner = async ( | |
| | {cost?: {total?: number}} | ||
| | undefined; | ||
| usd += Number(usage?.cost?.total ?? 0); | ||
| // Cumulative for this agent, because the probe is a | ||
| // question about the run's total and the caller adds this | ||
| // agent's in-flight spend to what it has already settled. | ||
| if (options.onTurnCost?.(usd) === "abort") { | ||
|
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. todo (blocking): Per-turn budget probe (onTurnCost → budgetExhausted → stoppedForBudget) is untested. grep confirms onTurnCost/budgetExhausted/stoppedForBudget appear only in source, never in any test; dispatch-runner-pi.test.ts never passes onTurnCost, and dispatch-spend.test.ts's fake chargingRunner never invokes it. The runner test harness already emits turn_end events with costs, so this is cheap to pin. A sketch, not a committable replacement: |
||
| budgetExhausted = true; | ||
| } | ||
| const content = (message?.["content"] ?? []) as TextBlock[]; | ||
| const text = content | ||
| .filter((block) => block.type === "text") | ||
|
|
@@ -874,6 +928,7 @@ export const createPiRunner = async ( | |
| stopReason === "refusal" || rawStopReason === "refusal", | ||
| wallMs: Date.now() - started, | ||
| structured: true, | ||
| ...(budgetExhausted ? {stoppedForBudget: true} : {}), | ||
| }; | ||
| } | ||
| // Out of turns and finished-with-prose return the same shape: a | ||
|
|
@@ -903,6 +958,7 @@ export const createPiRunner = async ( | |
| refused: | ||
| stopReason === "refusal" || rawStopReason === "refusal", | ||
| wallMs: Date.now() - started, | ||
| ...(budgetExhausted ? {stoppedForBudget: true} : {}), | ||
| }; | ||
| } catch (error) { | ||
| // A payload the tool already accepted is complete and validated: | ||
|
|
@@ -926,6 +982,7 @@ export const createPiRunner = async ( | |
| stopReason === "refusal" || rawStopReason === "refusal", | ||
| wallMs: Date.now() - started, | ||
| structured: true, | ||
| ...(budgetExhausted ? {stoppedForBudget: true} : {}), | ||
| }; | ||
| } | ||
| if (timedOut) { | ||
|
|
||
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.
question (non-blocking): Does CODEOWNERS on spend-ledger.ts alone cover the bypass surfaces the mitigation claims to close? The owned file holds the constants, but the enforcement is wired elsewhere: dispatch.ts (unowned) constructs the ledger and accepts an arbitrary
ledgeroverride via DispatchOptions, and the rollback flag is read from process.env, settable from the workflow lock/frontmatter — none of which are under the new entries. Grep confirms REVIEW_SPEND_ENFORCEMENT flows through dispatch.ts and the runner, not just the owned module; the mitigation as written protects the number, not the decision to use it. (Also worth confirming branch protection actually requires code-owner review in this repo — a CODEOWNERS file with no such rule enforces nothing.)A sketch, not a committable replacement: