Repository navigation
Cap the agent's spend per session by tokens or priced cost, record its model usage, and make a cancel final - #3177
Draft
DavidCockerill wants to merge 18 commits into
Draft
DavidCockerill wants to merge 18 commits into
DavidCockerill wants to merge 18 commits into
Conversation
… maxCostUsd from operator-declared model pricing
Each assistant message now carries the usage its model call reported, and get_agent_session /
list_agent_sessions return the session's totals folded from the transcript. agent.maxTokens and
agent.maxCostUsd cap a whole session: checked when a run starts and after every call, they end the
session `error`, answer pending tool calls without running them, and stop rather than run uncapped
when a cap cannot be measured.
A models.generative entry may declare `pricing: { inputPerMtok, outputPerMtok }`. Models.generate
prices each call from the entry that served it (so a fallback is priced at its own rate) and
returns `costUsd`, which also makes `maxCostUsd` work on `toolMode: 'auto'`. agent.maxCostUsd no
longer defaults to 5.
Closes #3040
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd name the cap on a completed run A budget stop now answers and closes every unconsumed approval, so no tool call is left pending once the session leaves awaiting_approval, and endRun keeps an aborted status a cancel wrote first. A final answer that crosses a cap records the cap in lastError. The auto loop treats a half token report as unmeasured, matching the facade's pricing rule. Refs #3040 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ackend result falling through The budget read before consuming approvals widened the window in which a cancelled run could still execute an approved action; the run now rechecks the signal first. A backend that reports completed with no output falls through to the next candidate again, and the auto loop's measured check rejects negative counts like the facade does. Refs #3040 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An unmeasured call stays in the session's totals, so adding pricing or raising the cap never clears it; the lastError now says to start a new session or clear the cap. Refs #3040 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ith no output as a failure A run cancelled before it consumes approvals now answers and closes them in one write, so an approved action cannot run on the session's next prompt instead. A backend that completes without output is a failed attempt: the next candidate serves, and no success row is recorded for it. The facade, the auto loop and backend parsing share one token-count check. Refs #3040 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ancelled run returns cancel_agent_run now marks the session aborted and answers every open approval with cancelled_by_operator in one locked write (cancelSession), so the cancel is acknowledged only once no approved action is left to run and no tool call lacks a response. The run itself only returns when it sees the cancel. Token counts must be integers, as the shared check and the design note now both say. Refs #3040 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nly under the session lock A cancel landing while an approved handler ran answered that call as cancelled, then the handler's real result was appended as a second response. Approvals are now claimed under the lock before their action runs, endRun and cancelSession decide which approvals are open on the row they read under the lock, and the approvals loop checks the cancel before each action. Execution is at-most-once. Refs #3040 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he claim Refs #3040 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-budgets # Conflicts: # DESIGN.md # agent/DESIGN.md # unitTests/agent/agent.test.js
…ke the caps Every status the loop writes after `running` now goes through endRun, which keeps an `aborted` status a cancel wrote under the session lock; a run cancelled before it starts is not marked running; and a gated call on a cancelled session is answered cancelled_by_operator instead of reopening it for approval. maxTurns is validated at startup and by set_agent_config as a positive integer: null, 0 or a string no longer makes every later run end immediately. A bare maxTurns key in config keeps the default. Refs #3040 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #3040 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…run, a cancel over a finished run, and unanswered calls agent_prompt answers 409 while a cancelled run is still finishing a model call or handler, rather than appending a prompt that would land between that call's tool calls and their results, or be folded into the cancelled run and lost. cancelSession decides under the session lock and leaves a session a run already finished as it is, so a cancel no longer writes aborted over completed. A cancel between a turn's tool calls answers the rest of the turn cancelled_by_operator in one write. Refs #3040 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a prompt during a finishing run Refs #3040 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fired followup is not undone A cancel that arrives while agent_prompt or approve_agent_action is still writing finds no live run to abort, and the run started afterwards wrote running over aborted. Every cancel now bumps a per-session count (fenceRuns); both operations and a fired followup take an admitRun token before their first await, and runAgent starts nothing for a token a later cancel has fenced out. isRunInFlight and admitRun are required operation deps, so a missing wire is a type error. Refs #3040 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #3040 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…start startRun checks the admission token before creating a controller and reports whether a run started; agent_prompt answers 409 when a cancel arrived while it was accepting the prompt, instead of reporting running for a run that never starts. Refs #3040 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Code Review
This pull request implements per-session spend caps (maxSessionTokens and maxCostUsd) for the built-in agent, ensuring that usage is dynamically calculated from the session transcript to prevent drift. It introduces a pricing model for generative model configurations, enabling the framework to compute the cost of each call based on the serving backend's rates. Additionally, it enhances the agent loop and session management with robust cancellation fencing to prevent race conditions between cancelled runs and incoming prompts or approvals. Since no review comments were provided, I have no feedback to provide on the changes.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
⊙ Problem
The built-in agent's only spend control,
agent.maxCostUsd, was stored and never read, and a session recorded none of the token usage its model calls already report. One measured investigation used about 800K tokens, visible only by queryinghdb_analytics;maxTurnswas the only brake, and it resets on every approval resume.Closes #3040 (agent: carry per-call token usage onto the session and enforce a budget).
💡 Solution
usageandcostUsdits model call reported, written with the message.get_agent_sessionandlist_agent_sessionsadd the session's totals asusage, folded from the transcript by a field-by-name copy of the row, so a total cannot drift from the calls. A total is omitted once any call did not report it.models.generativeentry may carrypricing: { inputPerMtok, outputPerMtok }.Models.generateprices each call from the entry that served it (a fallback at its own rate) and returnscostUsd. Harper ships no prices. ThetoolMode: 'auto'loop now reads that cost instead of a stub that returned 0.agent.maxSessionTokens(new) andagent.maxCostUsd(now enforced, default5removed) are checked when a run starts and after every model call. A session at a cap makes no further call and endserrorwith the cap inlastError; its tool calls are answeredbudget_exceeded, not run. A cap that cannot be measured stops the session rather than running uncapped.cancel_agent_runanswers every pending approval in the write that marks the sessionaborted. Nothing the loop writes afterwards replacesaborted, a cancel between a turn's tool calls answers the rest of the turn, and a cancel that arrives whileagent_promptorapprove_agent_actionis still writing fences out the run it would start.agent_promptanswers 409 while a cancelled run is still finishing or when a cancel fenced out its run.maxSessionTokens,maxCostUsdandmaxTurnsare checked at startup (a malformed value stops the agent from starting) and byset_agent_config(400).maxTurns: nullused to make every later run end without a model call.⚖️ Alternatives
toolMode: 'auto'. The auto loop cannot pause for approval, and its budget is per invocation, so it would reset on every approval resume.🔧 Changes
Product and architecture tour
What a session reports it used
Can an operator see what a run cost, and trust the number?
get_agent_sessionreturns the transcript and status; usage exists only as per-minute rows inhdb_analytics.usageandcostUsd, and the response addsusage: { calls, promptTokens, completionTokens, costUsd }folded from those messages.Example: an unpriced call in a priced session
A session makes two calls on a priced entry, then one on a fallback entry with no
pricing.Outcome:
usagekeepscalls: 3and both token totals, but dropscostUsd; amaxCostUsdcap then stops the session instead of treating the third call as free.How a call is priced
Which rate does a call pay when routing falls back to another model?
Pricing follows the backend that answered
Bootstrap attaches each generative entry's
pricingto the backend instance it builds, andModels.generateprices the call from the instance that actually served it, after the fallback loop has finished.flowchart LR Config[models.generative entry] --> Boot[bootstrap publishEntry] Boot --> Instance[backend instance plus pricing] Call[generate call] --> Route[primary then fallbacks] Route --> Served[backend that answered] Served --> Price[costUsd from its pricing] Instance -.-> Servedpricingis accepted only on generative entries, built-in or module-backed; an embedding or decision entry with it fails validation. It is left out of the calibration source fingerprint, because a price does not change what a model returns. A price edit applies on reload for built-in entries and on restart for module-backed ones, like any other change to them.When a cap stops a session
What happens to a run, and to its transcript, when it reaches a cap?
One run under a cap
budget_exceeded, ordenied_by_operatorfor a denial) and endserrorbefore any call.budget_exceededanswer per tool call are written in one write; the session endserror, with the cap inlastError.completed, still naming the cap inlastError.Making a cancel final
Can anything that races a cancel undo it, run an approved action, or leave a tool call unanswered?
A cancel against a run that is still working
The cancel aborts the live run and, in one locked write, marks the session aborted and answers every open approval; every later write from the run keeps that status.
sequenceDiagram participant Op as cancel_agent_run participant Run as running loop participant Row as session row Op->>Op: fenceRuns and abort controller Op->>Row: cancelSession aborted plus answers Run->>Row: claimApproval refused if closed Run->>Row: endRun keeps aborted Run-->>Run: no further model callagent_promptanswers 409 while a cancelled run is still finishing a model call or a handler, and when a cancel fenced out the run it would start; in the second case the prompt is recorded but not run.cancelSessiondecides under the lock and leaves a session that a run already finished as it is. The admission count lives in process memory beside the in-flight runs, so it is never pruned: deleting a session's entry would re-admit a request that took its token before the cancel.Files
agent/budget.ts(new): the session fold, cap checks, and validation of the caps andmaxTurns.agent/loop.ts: run admission and fencing; norunningwrite for a cancelled run; per-call usage and the post-call check; terminal writes throughendRun; the run-start stop; a mid-turn cancel; claim before running; andisRunInFlight.agent/session.ts:endRun,cancelSession,closeOpenApprovals,addPendingApprovalon an aborted session andclaimApproval, which replacesmarkApprovalConsumed.agent/operations.ts: session reads withusage;agent_prompt's 409s;cancel_agent_run;approve_agent_action's admission token; andset_agent_configvalidation.agent/agent.ts: startup validation, the admission check instartRun, caps passed to each run, the cancel fence, and the removed$5default and unenforced warning.agent/types.ts: per-callusage,AgentSessionUsageand the optional caps.config-root.schema.json,utility/hdbTerms.ts:maxSessionTokensandmaxCostUsdin place of the "NOT YET ENFORCED" text, andAGENT_MAXSESSIONTOKENS.resources/models/Models.ts: a completed result with no output as a failed attempt, pricing the served call,finalizeGenerateResultandcallCostUsd.resources/models/backendRegistry.ts,bootstrap.ts,types.ts: the per-instance pricing map, published at bootstrap from the entry'spricing,ModelPricingandGenerateResult.costUsd.resources/models/agentLoop.ts: the unmeasurable warn-once, the cost tally, both token counts required, and the deleted stub.resources/models/backendHelpers.ts: oneisTokenCountfor backend parsing, the facade and the auto loop.resources/models/decisionStore.ts:pricingleft out of the calibration fingerprint.validation/configValidator.ts: the pricing schema, also on module-backed generative entries.agent/DESIGN.md,resources/models/DESIGN.md,DESIGN.md: the spend-cap and cancel invariants, the pricing invariant and their index entries.✅ Verification
End-to-end route: a new integration test drives the built server and the operations API against a fake OpenAI-compatible model with priced and unpriced entries: usage matches what the model reported, both caps stop the session with every tool call answered, an unpriced model is named, and a malformed cap is a 400. It passes on RocksDB and on LMDB (
HARPER_STORAGE_ENGINE=lmdb).Unit coverage, each block mutation-checked (reverting the guard it covers makes it fail):
budget.test.js(new): the fold, cap checks, and cap andmaxTurnsvalidation.loop.test.jsbudgets and cancels: stops on both turn kinds, unmeasured caps, approvals closed on a stop, every loop write keepingaborted, mid-turn cancel, claim races, and the admission fence.operations.test.js:set_agent_configvalidation, session reads through accessor-only rows,cancel_agent_runclosing approvals, andagent_prompt's 409s and tokens.agent.test.js: a malformed limit stops an enabled agent before it registers anything.pricing.test.js(new): priced, unpriced, partial and smuggled usage; fallback attribution; hollow results; bootstrap and reload.agentLoop.test.js: cost caps from real pricing, and the warn-once for unpriced, empty, half and negative reports.sourceIdentity.test.jsandconfigValidator.test.js: pricing outside the fingerprint, and wherepricingis accepted.Full gates on the final head (
68f8df165), run on macOS. Every failure was rerun on a clean build of the merge base (c2f94e561) on the same machine and fails there identically:test:unit:maingit tagsigning, native addons, the dependency-bundle test), plus 4 deploy-activation tests that pass on a quiet reruntest:unit:resourcestest:integration:alldeploy by reference, same on basetest:unit:mainskippedapplicationSpawn.test.js, which hangs on this machine (test:unit:main hangs/flakes on macOS (#2538)). The LMDB apiTests ran without the script'sharper stop, to leave other local instances running.Companion docs: Document model pricing, the agent's per-session spend caps, and final cancels (HarperFast/documentation#727).
🤖 Generated by Claude Code (Anthropic Claude Opus 5.5); posted via @DavidCockerill.
Related PRs: #562 independent, #1770 independent, #1835 independent, #2155 independent, #2170 overlaps (
agent/session.tswrite path;endRun,cancelSessionandclaimApprovaluse the same Resource-levelput), #2436 independent, #2446 independent, #2532 overlaps (set_agent_configinput schema should includemaxSessionTokensand allownullcaps), #2752 independent, #2763 independent, #2775 independent, #2901 independent, #2906 independent, #2939 independent, #2986 independent, #2990 independent, #2994 independent, #3064 overlaps (agent loop, config andset_agent_config; it takesagent.maxTokensfor the per-request output limit), #3070 independent, #3078 independent, #3082 overlaps (pricing is keyed by backend instance inbackendRegistry.ts; a backend served across worker threads needs its pricing attached to the instance each thread calls), #3084 independent, #3086 independent, #3093 independent, #3110 independent, #3117 overlaps (agent loop, session writes and the auto loop; whichever lands second keeps usage on every assistant turn), #3126 independent, #3137 independent, #3138 independent, #3140 independent, #3174 independent, #3139 independent, #3072 independent, #2930 independent, #3148 independent, #3141 independent, #3128 independent, #3119 independent, #3079 independent, #3124 independent, #3123 independent, #3116 independent, #3113 independent, #3118 independent, #3083 independent, #3107 independent, #3098 independent, #2649 independent, #3100 independent, #3103 independent, #3069 independent, #3085 independent, #3057 independent, #3030 independent, #3035 independent, #3065 independent, #3036 independent, #2825 independent, #3021 independent, #2981 independentComplexity: complicated
Review-Coverage: authored=claude; ran=codex,gemini; adjudicated=domain; blocked=cursor-grok(not-installed); declined=cursor-composer,cursor-kimi,cursor-muse; rounds=9; full=1 @ 68f8df1
Review-Attention: read ~7m @ 68f8df1