Skip to content

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
mainfrom
david/3040-agent-usage-budgets
Draft

DavidCockerill wants to merge 18 commits into
mainfrom
david/3040-agent-usage-budgets

Conversation

@DavidCockerill

@DavidCockerill DavidCockerill commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

⊙ 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 querying hdb_analytics; maxTurns was 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).

❓ Your call: requirement. As specified in the issue, plus cancellation work it pulled in. Budget stops and cancels both have to leave every tool call answered and never run an approved action twice, and review rounds found the existing cancel path could be overwritten, undone or lose a prompt; those fixes are folded in here rather than split out. Milestoned v5.4 (main), so no backport.

💡 Solution

⚠️ Look hardest: the approval and cancel ordering. An approval is claimed under the session lock before its action runs; every writer that answers approvals decides which are open on the row it read under the lock; and every cancel bumps a per-session count that runs admitted earlier are checked against. That is what keeps one response per tool call and a cancel final when a cancel, a budget stop and a running action race.

⚖️ Alternatives

  • Planning review adopted a better alternative. The plan stored a running usage total on the session row; the planning reviewer (Codex) proposed per-call usage on each assistant message, folded on read. Adopted: it cannot drift, needs no partial-report state, and a session from before this change reads as unmeasured rather than free.
  • Agent caps live in the agent loop, not in toolMode: 'auto'. The auto loop cannot pause for approval, and its budget is per invocation, so it would reset on every approval resume.
  • Pricing is resolved in the facade, keyed by backend instance. Pricing by the requested model name would bill a fallback at the primary's rate; a Harper-maintained rate card would be wrong for negotiated, regional and self-hosted pricing.
  • A prompt racing a cancelled run is refused, not queued. Queuing it behind the cancelled run was tried: the cancelled run's in-flight tool result then lands after the new prompt, which a provider rejects.

❓ Your call: fail-closed vs fail-open. The agent stops a session whose cap cannot be measured; the auto loop keeps its existing contract of warning once and continuing. Making the auto loop fail closed is a behavior change for application callers, so it is left out. Easy to flip either way.

❓ Your call: naming. The cumulative cap is agent.maxSessionTokens because End agent runs cut off by the output-token limit as errors, and add agent.maxTokens (#3064) uses agent.maxTokens for the per-request output limit, as the models API does. Whichever of the two PRs lands second must keep usage on any assistant turn it rejects, or the next capped run reads the session as unmeasured.

❓ Your call: at-most-once approvals. A crash between claiming an approval and writing its result leaves that call unanswered, where main would run the approved action again. Chosen because these are operator-approved, often destructive, actions.

❓ Your call: what a cap does not bound. It is a stop-after-call threshold, so the call that reaches it can overshoot. A failed call is never counted, even one the provider billed (failure records carry no usage). An unpriced model is detected only after its first call. Caps are per session; a new session starts from zero, so total spend across sessions is unbounded.

❓ Your call: a bare maxTurns: in the config file keeps the default, while set_agent_config refuses null. Reviewers flagged the asymmetry; the config-file case is a YAML artifact rather than an instruction.

🔧 Changes

Product and architecture tour

What a session reports it used

Can an operator see what a run cost, and trust the number?

Before After
get_agent_session returns the transcript and status; usage exists only as per-minute rows in hdb_analytics. Each assistant message carries its call's usage and costUsd, and the response adds usage: { calls, promptTokens, completionTokens, costUsd } folded from those messages.

A session total is present only while every call in the session reported it. An absent total with calls > 0 means unmeasured, never zero.

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: usage keeps calls: 3 and both token totals, but drops costUsd; a maxCostUsd cap 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 pricing to the backend instance it builds, and Models.generate prices 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 -.-> Served
Loading

A call is priced only from both token counts as non-negative integers; a missing, negative or fractional count leaves it unpriced rather than under-priced. The arithmetic runs after the candidate try/catch, so an accounting fault cannot trigger a second, billed fallback call.

pricing is 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

  1. Run start — the persisted usage is folded; a session already at a cap, or unmeasured under one, answers every open approval without running it (budget_exceeded, or denied_by_operator for a denial) and ends error before any call.
  2. Model call — the assistant message is built with the call's usage and cost.
  3. After the call — the fold includes the new message; under the cap the run continues as before.
  4. Stop on a tool-call turn — the message and one budget_exceeded answer per tool call are written in one write; the session ends error, with the cap in lastError.
  5. Stop on a final answer — the answer is delivered and the run ends completed, still naming the cap in lastError.

Caps are per session, read from the transcript, so an approval resume or a follow-up prompt cannot reset them. A capped session stays capped until the cap is raised or cleared, or a new session starts.

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 call
Loading

An approval is claimed under the session lock before its action runs, so a cancel or a budget stop answers only approvals whose action has not started, and no tool call gets two responses.
Every status the loop writes after running goes through endRun, which never replaces aborted.
A run admitted before a cancel starts nothing, so a cancel that lands while agent_prompt or approve_agent_action is still writing is not undone.

agent_prompt answers 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. cancelSession decides 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

✅ 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 and maxTurns validation.
  • loop.test.js budgets and cancels: stops on both turn kinds, unmeasured caps, approvals closed on a stop, every loop write keeping aborted, mid-turn cancel, claim races, and the admission fence.
  • operations.test.js: set_agent_config validation, session reads through accessor-only rows, cancel_agent_run closing approvals, and agent_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.js and configValidator.test.js: pricing outside the fingerprint, and where pricing is 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:

gate passing failing
test:unit:main 6887 23: the 19 that fail on base (tmpdir paths, git tag signing, native addons, the dependency-bundle test), plus 4 deploy-activation tests that pass on a quiet rerun
test:unit:resources 4270 0
test:integration:all 2297 25 in 7 files (socket-path length, single HTTP worker), the same 25 on base
LMDB: resources, apiTests, bin 3243, 201, 307 bin: 2 deploy by reference, same on base

test:unit:main skipped applicationSpawn.test.js, which hangs on this machine (test:unit:main hangs/flakes on macOS (#2538)). The LMDB apiTests ran without the script's harper 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.ts write path; endRun, cancelSession and claimApproval use the same Resource-level put), #2436 independent, #2446 independent, #2532 overlaps (set_agent_config input schema should include maxSessionTokens and allow null caps), #2752 independent, #2763 independent, #2775 independent, #2901 independent, #2906 independent, #2939 independent, #2986 independent, #2990 independent, #2994 independent, #3064 overlaps (agent loop, config and set_agent_config; it takes agent.maxTokens for the per-request output limit), #3070 independent, #3078 independent, #3082 overlaps (pricing is keyed by backend instance in backendRegistry.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 independent
Complexity: 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

DavidCockerill and others added 18 commits October 6, 2026 12:28
… 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>
agent.maxTokens is the per-request output-token limit in #3064, matching what maxTokens means across
the models API; the cumulative session cap takes its own name.

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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

agent: carry per-call token usage onto the session and enforce a budget — maxCostUsd is still advertised but unenforced

1 participant