Skip to content

feat: per-call usage_listener callback for external cost metering - #11

Merged
GurneeshBudhiraja merged 4 commits into
mainfrom
feat/usage-listener
Jul 25, 2026
Merged

feat: per-call usage_listener callback for external cost metering#11
GurneeshBudhiraja merged 4 commits into
mainfrom
feat/usage-listener

Conversation

@aman-a-shah

Copy link
Copy Markdown

What

Adds an optional usage_listener callback to AIAgent: fired once per usage-bearing LLM response (from inside the single session-counter accumulation block) with a per-call delta dict — input/output/total/reasoning/cache tokens, cost_usd (float|None), cost status/source, model, provider. Exceptions from the listener are swallowed (standard callback pattern); default None means zero behavior change for every existing consumer (CLI, gateway, batch). Subagents built via delegate_tool and the background memory-review agent inherit the parent's listener, so their spend produces deltas too. May be called concurrently from delegate child threads (documented at the param).

Why

Hermes only records LLM spend after a run completes — the two runaway chat executions killed on 2026-07-24 burned ~$350 and recorded $0. This callback is the runtime half of incremental cost metering: hermes (companion PR) subscribes a throttled flusher that upserts the usage row while the run is alive.

Tests

tests/test_usage_listener.py — 5 tests (per-call delta incl. a two-call tool round-trip, swallow, None-cost, delegate inheritance), plus tests/test_run_agent.py 195/195 regression-clean.

⚠️ After merge

hermes must bump its submodule pin to pick this up — the hermes companion PR guards the kwarg with inspect.signature, so deploys work in either order, but per-call metering only activates once the pin includes this commit.

🤖 Generated with Claude Code

aman-a-shah and others added 2 commits July 24, 2026 15:53
Adds an optional usage_listener callback to AIAgent, fired once per LLM
response with a token/cost delta dict. Lets an external subscriber meter
spend while a run is alive, instead of only after a run completes (today
a killed run records $0 because the runner reads final cost totals).
Subagents built by delegate_tool inherit the parent's listener the same
way they inherit cost_budget, so subagent spend produces deltas too.
Listener exceptions are swallowed so metering can never break a run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…test; threading note

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@aman-a-shah

Copy link
Copy Markdown
Author

Code review — approve with one composition fix needed ✅⚠️

Solid, minimal, well-placed change, verified against head 02104d1c and cross-checked against the fixed hermes NousResearch#101 flusher:

  • Delta payload contract matches hermes Move glob import to top level in terminal_tool.py NousResearch/hermes-agent#101 field-for-field: input/output/total/reasoning/cache_read/cache_write tokens, cost_usd, cost_status, cost_source, model, provider — all present with the names on_delta reads; api_calls is correctly derived flusher-side. The raw cost_source taxonomy emitted matches what the fixed flusher now maps (including the "none" fall-through). Result-dict fallback keys all exist in run_conversation's return.
  • No double-reporting: the listener fires from the single main-loop accumulation block; delegate children fire deltas on their own instances and the parent never re-adds child usage.
  • Exception-safe (a raising listener can't break the loop — tested), honest about threading (flusher-side locking), and inheritance covers both sub-agent spawn paths (_build_child_agent, _run_review).
  • Composes with feat(agent): CostBudget - hard per-run USD spend ceiling #10 in the main loop: both edits are driven by the same cost_result in the same block, and the budget stops runs before an API call, so there's no metered-but-unreported call there.

Findings

  1. Medium — the iteration-limit salvage/summary call never fires the listener (run_agent.py _handle_max_iterations, call sites ~5301-5379), contradicting "fired once per usage-bearing LLM response". Harmless on this branch alone (session counters also skip it here), but the moment companion feat(agent): CostBudget - hard per-run USD spend ceiling #10 merges, _meter_summary_usage adds that call's spend to session totals and the CostBudget while the delta stream — the flusher's primary source — never hears about it. A runaway run hitting the cap is exactly the scenario this metering exists for, and the summary call carries the run's largest context; its spend would silently never land in usage_events (finalize's result-dict fallback only applies when api_calls == 0), leaving delta totals permanently disagreeing with result-dict totals. Fix being coordinated on feat(agent): CostBudget - hard per-run USD spend ceiling #10's branch (guarded getattr listener invocation inside _meter_summary_usage) so it's a no-op until both are pinned together.
  2. Low — memory-flush LLM calls unmetered (run_agent.py:4488-4526) — pre-existing on main, bounded, and consistent (deltas and result dict agree in omitting it); noted only because the PR body slightly overstates coverage.
  3. Low — test gaps: nothing covers the background review agent inheriting the listener (that propagation at run_agent.py:1420 was itself the fix in 02104d1c — a refactor dropping the kwarg would go unnoticed); no concurrent-invocation test; and test_delegate_child_inherits_listener's broad try/except: pass makes a constructor failure die as an opaque AttributeError on call_args=None. A review-agent inheritance test is being added on this branch.

Tests: ran the suite from a scratch extract of the branch — tests/test_usage_listener.py 5/5, tests/test_run_agent.py 195/195, matching the PR's claim.

🤖 Generated with Claude Code

aman-a-shah added a commit that referenced this pull request Jul 25, 2026
…ost-limited completed semantics

Cross-PR composition fix with #11 (feat/usage-listener): the salvage
summary call's spend was metered into session counters and the
CostBudget but never reached the per-call delta stream that hermes's
UsageFlusher consumes. _meter_summary_usage now fires the listener
with a payload matching #11's main-loop delta field-for-field, behind
a getattr guard so it stays a no-op until both PRs land together, and
with the same swallow-and-debug-log protection (a raising listener
can never break the salvage flow).

Also documents why cost-limited runs deliberately report
completed=True (suppresses client-side whole-turn retries; consumers
must check cost_limited), unlike iteration-capped runs.

Tests: TestSummaryUsageListener (2) — exact-payload contract via an
attribute-injected mock listener, and raising-listener resilience.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…failure mode

- New test pins the usage_listener kwarg on the background review AIAgent
  (the 02104d1 fix itself had zero coverage; a refactor dropping the
  kwarg would previously go unnoticed). Review thread runs inline via a
  fake Thread for determinism.
- test_delegate_child_inherits_listener: precondition assert with a clear
  message so a pre-ctor construction failure no longer dies as an opaque
  AttributeError on call_args=None.
- Concurrency smoke test: two agents sharing one listener run in parallel
  threads; both deltas must arrive unmangled (documented multi-thread
  listener contract).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@aman-a-shah

Copy link
Copy Markdown
Author

Follow-up on review finding 3 (test gaps) — pushed acc39103:

  • test_background_review_agent_inherits_listener: pins the usage_listener kwarg on the background review AIAgent (run_agent.py ~1420) — the propagation that was itself the fix in 02104d1c and previously had zero coverage. The review thread runs inline via a fake Thread so the test is deterministic.
  • test_delegate_child_inherits_listener: added a precondition assert (call_args is not None with an explicit message), so a construction failure before the ctor no longer surfaces as an opaque AttributeError on call_args=None.
  • test_listener_shared_across_concurrent_agents_receives_all_deltas: concurrency smoke test — two agents sharing one listener run in parallel threads; both deltas must arrive unmangled.

tests/test_usage_listener.py: 7/7 passing (new tests also re-run 5x for flake-check). No production-code changes.

Finding 1 (iteration-limit salvage/summary call not firing the listener) is being carried on PR #10's branch as a guarded getattr invocation inside _meter_summary_usage, so it lands with the budget work it composes with.

🤖 Generated with Claude Code

@GurneeshBudhiraja GurneeshBudhiraja left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review: feat: per-call usage_listener callback for external cost metering

Overview

Adds an optional usage_listener callback to AIAgent, fired once per usage-bearing LLM response with a per-call token/cost delta dict. Default None → zero behavior change for existing consumers. Subagents (via delegate_tool._build_child_agent) and the background memory/skill review agent inherit the parent's listener. Motivation is sound and well-scoped: today a killed run records $0 because spend is only read from final totals; this is the runtime half of incremental metering.

I read the changed hunks against the surrounding code at head (acc39103). This is a clean, low-risk, additive change with genuinely good test coverage.

Findings

🟢 Correct placement (run_agent.py:6082-6099). The callback lives inside the single if hasattr(response, 'usage') and response.usage: accumulation block, after estimate_usage_cost. It fires exactly once per usage-bearing response, on the same path that updates session_* counters and the per-call session-DB persistence. Metering therefore stays consistent with recorded session totals — no double-counting, no missed paths.

🟢 Exception isolation is correct. Listener errors are caught and logged, mirroring the adjacent except Exception: pass on the session-DB write; a broken meter can never break a run. Verified by test_listener_exception_is_swallowed.

🟢 No secret leakage. The delta payload carries only tokens/cost/model/provider — no api_key. The debug log emits only the exception, not the payload.

🟢 Inheritance wired correctly and tested. Subagent path (delegate_tool.py:203, 241) and the review agent (run_agent.py:1420) both pass the listener through. test_delegate_child_inherits_listener, test_background_review_agent_inherits_listener, and the two-call tool-round-trip test pin the behavior — including the 02104d1 review-agent fix that previously had no coverage.

🟡 Thread-safety contract documented only at the call site (run_agent.py:6076-6081), not at the param. The PR body says it's "documented at the param," but the __init__ signature (line 408) has no note; the concurrency warning lives in the call-site comment. Minor doc discrepancy. More importantly: the whole burden of thread-safety falls on the consumer's listener, since delegate children / the review agent run as separate AIAgent instances on their own threads sharing one callable. That's the right design (the payload dict is freshly built per call, no shared mutable state), but please confirm the hermes companion flusher's upsert is actually thread-safe under concurrent delegate spend. test_listener_shared_across_concurrent_agents_receives_all_deltas exercises this contract.

🟡 Listener failures are logged at DEBUG only (run_agent.py:6099). A persistently broken meter is invisible at default log level — precisely the "records $0" failure mode this PR exists to prevent, just relocated to the metering layer. "Never break the run" is right, but a rate-limited logger.warning would give the metering path some observability.

🟢 Nit — getattr(self, "provider", None) (line 6096) is inconsistent with the direct self.provider used two lines up at line 6067 (and in the sibling DB call). Harmless defensiveness; provider is always set. Not blocking.

ℹ️ Informational: the background review agent runs in a daemon thread that can outlive run_conversation's return, so some deltas may arrive after the consumer performs its "final" flush. Expected for live metering — just make sure the upsert tolerates late/post-completion deltas.

Verdict

LGTM. Additive, correctly placed, no security or correctness concerns found. The two 🟡 items (observability of swallowed listener errors; confirming the consumer flusher is thread-safe) are worth a look but are non-blocking. Nice test suite — concurrency, inheritance, swallow, None-cost, and multi-call all covered.

Automated review; verify anything load-bearing before merge.

Conflict: delegate_tool.py child-agent constructor — kept both kwargs
(usage_listener + cost_budget). Updated the #10-era composition test:
with both PRs together a cost-limited run now emits two deltas (main-loop
call + salvage summary), which is the intended composed behavior the
getattr guard in _meter_summary_usage was built for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@aman-a-shah

Copy link
Copy Markdown
Author

Merge conflict with main (from #10 landing) resolved in cac17f1: kept both constructor kwargs in delegate_tool.py (usage_listener + cost_budget), and updated the #10-era composition test — with both PRs together a cost-limited run correctly emits two deltas (the breaching main-loop call and the salvage summary), which is exactly the composed behavior the getattr guard in _meter_summary_usage was built for. Full suites green: 220 passed (test_usage_listener 7, test_cost_budget 18, test_run_agent 195).

🤖 Generated with Claude Code

@GurneeshBudhiraja
GurneeshBudhiraja merged commit 414da8a into main Jul 25, 2026
2 checks passed
aman-a-shah added a commit that referenced this pull request Jul 25, 2026
Conflict: run_agent.py usage-accumulation block — kept both consumers
of canonical_usage/cost_result: the llm_usage trace event (this PR) and
the per-call usage_listener delta for external metering (main, PR #11).
They are independent read-only consumers; order is trace then delta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants