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:
- The current in-flight model response completes (and is billed).
- The next tool call is blocked by
pre_tool_call. - The agent loop eventually terminates because it can’t execute tools.
- 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",…}.
Verdict cache (TTL = 5s)
Section titled “Verdict cache (TTL = 5s)”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).
Setting budgets (/budget set)
Section titled “Setting budgets (/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.
Reading budgets (/budget status block)
Section titled “Reading budgets (/budget status block)”_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.
Anti-spam ledger (budget_alerts table)
Section titled “Anti-spam ledger (budget_alerts table)”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.
Verdict degradation
Section titled “Verdict degradation”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.
Window math: local timezone
Section titled “Window math: local timezone”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 resolution
Section titled “Scope resolution”| 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:
profileis captured fromctx.profile_name, which the core derives from theHERMES_HOMEContextVar. 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 (typicallydefault). Dedicated per-profile processes (per-profile gateway/cron) are not affected — they resolve the profile from the process’s ownHERMES_HOME. - NULL profile: pre-v12 rows and any session whose profile could not be resolved
keep
profile = NULL; these are excluded fromper_profilespend and budgets.
hermes-telemetry is an independent community plugin for Hermes Agent. It is not affiliated with or endorsed by Nous Research.