feat: per-call usage_listener callback for external cost metering - #11
Conversation
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>
Code review — approve with one composition fix needed ✅
|
…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>
|
Follow-up on review finding 3 (test gaps) — pushed
Finding 1 (iteration-limit salvage/summary call not firing the listener) is being carried on PR #10's branch as a guarded 🤖 Generated with Claude Code |
GurneeshBudhiraja
left a comment
There was a problem hiding this comment.
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>
|
Merge conflict with main (from #10 landing) resolved in cac17f1: kept both constructor kwargs in 🤖 Generated with Claude Code |
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>
What
Adds an optional
usage_listenercallback toAIAgent: 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); defaultNonemeans zero behavior change for every existing consumer (CLI, gateway, batch). Subagents built viadelegate_tooland 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), plustests/test_run_agent.py195/195 regression-clean.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