Skip to content

Budget Engine

The enforcement reality (A4 audit finding)

Section titled “The enforcement reality (A4 audit finding)”

The budget engine was designed AFTER auditing what Hermes hooks can actually do. The audit classified every enforcement primitive:

Primitive Blocks? Mechanism
pre_llm_call return ❌ No Context injection only
pre_api_request return ❌ No Return discarded
pre_approval_request / post_approval_response ❌ No “observers only”
pre_tool_call return {"action":"block",…} ✅ Yes Aborts tool, sends error to model
cron.jobs.pause_job(job_id, reason) ✅ Yes (future runs) Pauses cron job

Consequence: there is NO true mid-call abort. When a hard breach is detected:

  1. The current in-flight model response completes (and is billed).
  2. The next tool call is blocked by pre_tool_call.
  3. The agent loop eventually terminates because it can’t execute tools.
  4. For cron: the job is paused for future runs.

Soft alert (once per window): context injected via pre_llm_call.
Hard gate (every tool call): pre_tool_call returns {"action":"block",…}.

pre_tool_call fires on every tool call — potentially dozens per turn. Re-querying SQLite on each call would be expensive. A 5-second TTL cache is safe because spend only changes when a new post_api_request is recorded (which can’t happen while a tool call is running synchronously).

The cache is keyed by (scope, scope_id). A reload_config() call clears it (e.g., after /budget set).

_set_budget(scope, window, usd, scope_id="") persists a limit to budget.yaml and hot-reloads. With a scope_id it writes per_<scope>.overrides.<id>; without one it writes the scope’s shared default bucket. global takes no id. The id-override form is generic across cron_job / sender / profile — the read side (_resolve_limits) already resolved overrides.<id> or default for all of them, so set just fills the same shape. Surface asymmetry (intentional): the chat form takes the id as a positional after the scope (/budget set <scope> [<id>] <window> <usd>), while the standalone CLI uses a --id flag — argparse can’t express an optional middle positional, and the CLI already validates window/usd as typed positionals. Writes are atomic (temp file + os.replace), then reload_config() clears the verdict cache.

_status_block() does not enumerate scope ids from budget.yaml — it asks the DB which ids actually had activity, via db.list_cron_job_ids(since30) / list_sender_ids(since30) / list_profile_ids(since30), where since30 = _days_ago_utc(30). Each section (Cron jobs: / Senders: / Profiles:) is rendered only when its list is non-empty, then each id is resolved through the same check(scope, scope_id) verdict path /budget set and /budget forecast use, so a scope’s default/overrides.<id> limit renders identically across all three commands.

Consequence: a scope with a configured limit but no runs in the last 30 days renders no section at all — budget.yaml having an entry is not enough. This is why the “No budgets configured” fallback guard checks g is None and not cron_ids and not senders and not profiles — every enumerated list has to be empty, not just the absence of a budget.yaml file, or the fallback would wrongly claim nothing is configured while a stale/inactive scope still has a limit set.

Failed reads are never memoized — but a missing file still is

Section titled “Failed reads are never memoized — but a missing file still is”

load_config() is on the pre_tool_call path, so its contract is it never raises. A read or parse failure therefore cannot be allowed to disable budget enforcement for the rest of the process’s life: instead the failure is recorded as the file’s stat signature (st_ino, st_mtime_ns, st_size), captured before the read. While the file is unchanged, the next call returns the default (budgets-disabled) config from one stat without re-opening the file; the first call after the file changes retries the parse for real — no reload_config() and no restart. One WARNING per failure streak, DEBUG after.

Two behaviours that are easy to conflate, and differ deliberately:

state of budget.yaml result retried?
missing budgets disabled no — cached as before (documented behavior, unchanged)
present but unreadable/malformed budgets disabled for that call yes — the next call re-parses once the signature changes

The signature is captured outside the try. Inside it, a failure in the very first statement (the import yaml) would reach the except branch with sig still unbound and raise UnboundLocalError out of load_config() — the one thing this function must not do.

pricing.yaml reads were given the same treatment, capturing the signature as pre_read_sig before the read. The two now behave identically on failure and differ on the missing-file case (a missing pricing.yaml still falls through to the bundled defaults). See ### Atomic write (issue #105/#106) below.

Soft alerts fire once per window per scope — not on every pre_llm_call. try_budget_alert() does INSERT OR IGNORE into budget_alerts and returns True only on first insert (checked via rowcount > 0). The UNIQUE constraint on (scope, scope_id, window, period_key, level) is the database-level guarantee.

When spend rests on estimated rows (estimated=1 or _estimated_price: true models), a hard verdict degrades to soft under on_estimated.mode: warn_only (the default). Rationale: you shouldn’t block work based on numbers that might be significantly wrong.

on_estimated.mode: enforce opts into strict enforcement regardless.

Daily/monthly windows are computed in the user’s local timezone. A cron job at 11:59 PM and another at 12:01 AM count against different daily windows. The start timestamp is converted back to UTC ISO before querying the DB (the DB stores UTC).

Scope How spend is aggregated
global All runs rows in the window
per_cron_job runs WHERE cron_job_id = ?, plus every descendant reachable through subagent_edges (recursive CTE) — includes delegated subagent spend
per_sender runs WHERE sender_id = ?
per_profile runs WHERE profile = ? — NULL-profile runs excluded

per_cron_job seeds from the cron job’s own root session(s) and walks subagent_edges down to every descendant, so delegated (async/nested) subagent spend is now attributed back to the cron job that started the chain (schema v11, issue #49). global still sums every runs row unconditionally, so per_cron_job only regroups the same cost rows — there is no double counting. See § Subagent Architecture.

Profile attribution — known limitations

  • Multiplexed-gateway accuracy: profile is captured from ctx.profile_name, which the core derives from the HERMES_HOME ContextVar. In a multiplexed gateway (one process serving many profiles), the value is correct only if the core propagated that ContextVar into the thread that fires the hook. If it did not, a run may be tagged with the wrong profile (typically default). Dedicated per-profile processes (per-profile gateway/cron) are not affected — they resolve the profile from the process’s own HERMES_HOME.
  • NULL profile: pre-v12 rows and any session whose profile could not be resolved keep profile = NULL; these are excluded from per_profile spend and budgets.

hermes-telemetry is an independent community plugin for Hermes Agent. It is not affiliated with or endorsed by Nous Research.