fix(agent): rehydrate session cost counters on agent construction (#67762) - #67770
fix(agent): rehydrate session cost counters on agent construction (#67762)#67770DavidMetcalfe wants to merge 3 commits into
Conversation
Before this fix, agent.session_estimated_cost_usd and
agent.session_cost_status were reset to 0.0 / 'unknown' inside
init_agent (hermes_state.py:2049-2061) with no read from any
persisted source. After a gateway restart mid-session, the live
counter would silently drop to $0.00 even though session_model_usage
had the real accumulated cost; the persisted data stayed correct,
so /insights would show the truth and the live counter would lie.
The fix:
- Adds SessionDB.get_session_cost_summary(session_id) at
hermes_state.py which aggregates per-model rows into a single
{estimated_cost_usd, cost_status} pair. The status uses a sticky
priority ladder (actual > included > unknown > latest call's
status), implemented with a single SUM(CASE WHEN ...) query that
matches the codebase's existing conditional-aggregation pattern
(see agent/insights.py:373-376).
- Adds agent_init.py::_rehydrate_session_cost(agent) which the
init_agent body now calls immediately after the existing reset
block. The helper reads from agent._session_db if available,
scopes exceptions to (sqlite3.Error, AttributeError, TypeError,
ValueError), logs via _ra().logger.debug() on the scoped path,
and lets other exceptions surface.
- Adds tests/hermes_state/test_session_cost_rehydration.py with 14
regression tests covering the reader, the helper, and the fail-open
behavior under transient DB errors vs. bug-class errors.
Tests: 14/14 pass in the new file; 99/99 pass across tests/hermes_state/
and tests/run_agent/test_notice_spine.py with no regressions.
Known caveat: agent.session_cost_status will be overwritten by the
unconditional = assignment at agent/conversation_loop.py:2321
(equivalent at codex_runtime.py:150) on the very next API call,
so the rehydrated status reverts to 'latest call wins' until NousResearch#67764
(priority ladder) also lands. The cost value persists correctly
across a gateway restart.
Closes NousResearch#67762
eb40e2e to
7a3be6d
Compare
|
Thanks for isolating the construction-time reset: current Problems
Suggested changes
Automated hermes-sweeper review. |
|
Both points are correct — verified against the source. On auxiliary-task rows: On delegated-child cost: Minimal fix for the domain mismatch: Add For child cost persistence: This is a follow-up. The current PR fixes the restart bug for main-loop cost. Child cost persistence requires a new mechanism (either a special For test coverage: Agreed — the current tests call Suggested path forward:
|
get_session_cost_summary now filters session_model_usage by task='' so the rehydrated live counter matches the pre-restart cost domain (main-loop only). Auxiliary-task rows (title generation, vision, delegate summaries) are excluded from the summary query and its inner fallback subquery. Reviewer feedback from teknium1 on NousResearch#67770: rehydrating from ALL session_model_usage rows silently changes the cost domain after a gateway restart, because aux accounting (record_auxiliary_usage) deliberately writes per-model rows without touching the sessions summary. Added three tests: - aux_rows_excluded_from_summary (main-loop only, aux excluded) - summary_returns_none_when_only_aux_rows_exist (contract preserved) - aux_rows_do_not_affect_rehydration (end-to-end through the helper) Child-cost persistence (delegate_tool.py in-memory only) remains a separate follow-up concern.
SummarySeven PRs address or reference three linked issues: #67770, #67778, and #67796 rehydrate restart-time accounting; #67774, #67790, and #67804 implement sticky cost-status semantics; and #67834 only documents a proposed desktop cost display. The diffs make #67770 the recorded best fix for #67762 and #67790 the recorded best fix for #67764, while #67765 still has no gateway-to-desktop implementation in this set. Related pull requests
Duplicates#67774 and #67804 duplicate the sticky-status work consolidated more completely in #67790; #67774 is already closed. #67778 and #67796 overlap the restart-rehydration work in #67770; #67778 is already closed, while #67796's broader counters require semantic correction before any part is salvaged. Suggested consolidationKeep #67770 open with a salvage path: preserve its tested main-loop cost rehydration, add an actual Complex graphflowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
I67762(["issue #67762 (open)"])
I67764(["issue #67764 (open)"])
subgraph Dup67770 ["PRs duplicating each other"]
P67770["PR #67770 (open)"]
P67778["PR #67778 (closed)"]
P67796["PR #67796 (open)"]
end
P67770 -->|best fix| I67762
P67770 -.->|partial| I67764
class I67762 open
class I67764 open
class P67770 open
class P67778 closed
class P67796 open
class P67770 best
class P67770 target
click I67762 "https://github.com/NousResearch/hermes-agent/issues/67762"
click I67764 "https://github.com/NousResearch/hermes-agent/issues/67764"
click P67770 "https://github.com/NousResearch/hermes-agent/pull/67770"
click P67778 "https://github.com/NousResearch/hermes-agent/pull/67778"
click P67796 "https://github.com/NousResearch/hermes-agent/pull/67796"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label). Cross-PR triage: Reviewed 7 pull requests and 3 issues in this complex. Each diff was read against this issue; Assessment working set: 93 kB of PR diffs, 64 kB of issue/PR text, 16 kB of discussion (21 comments), 11 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch. |
Fixes #67762.
Problem
agent.session_estimated_cost_usdwas reset to0.0insideinit_agent(agent/agent_init.py:2049-2061) with no read from any persisted source. After a gateway restart mid-session, the live counter would silently drop to$0.00even thoughsession_model_usagehad the real accumulated cost.The persisted data stayed correct, so
/insightswould show the truth and the live counter would lie. The bug affected every gateway-mediated surface; the only surface that masked it was the agents panel, which folds children-cost viatools/delegate_tool.py:2828and adds the parent's never-zeroed prior total.Fix
SessionDB.get_session_cost_summary(session_id)athermes_state.py(~line 3266). Aggregates per-model rows into a single{estimated_cost_usd, cost_status}pair. The status uses a sticky priority ladder (actual>included>unknown> latest call's status), implemented with oneSUM(CASE WHEN …)query that matches the codebase's existing conditional-aggregation pattern (agent/insights.py:373-376).agent_init.py::_rehydrate_session_cost(agent)whichinit_agentnow calls immediately after the existing reset block. Reads fromagent._session_dbif available, scopes exceptions to(sqlite3.Error, AttributeError, TypeError, ValueError), logs via_ra().logger.debug()on the scoped path. Other exceptions surface.tests/hermes_state/test_session_cost_rehydration.pywith 14 regression tests covering the reader, the helper, and fail-open behavior under transient DB errors vs. bug-class errors.Tests
tests/hermes_state/andtests/run_agent/test_notice_spine.pywith no regressionsnpx tsc -b .inapps/desktop/is not affected (no renderer changes in this PR)Reproduction
hermes chator via the desktop app).agent.session_estimated_cost_usdprogrammatically.agent.session_estimated_cost_usdshows0.0until the next API call adds to it.Known caveat
agent.session_cost_statuswill be overwritten by the unconditional=assignment atagent/conversation_loop.py:2321(and the equivalent atagent/codex_runtime.py:150) on the very next API call, so the rehydrated status reverts to "latest call wins" until issue #67764 (priority ladder) also lands. The cost value persists correctly across a gateway restart.This is documented in the helper's docstring, the call site comment, the test
test_init_agent_rehydration_incremental_calls, and the issue body.Out of scope
actual_cost_usd): not addressed here. Theactual_cost_usdcolumn exists in the schema but is never written by core code today. Once Phase 2 lands, the priority ladder here becomes the real "actual" promotion path.Review
Cross-vendor review pass via
agy(Gemini 3.1 Pro High + Gemini 3.5 Flash Medium) ran in parallel on the patch. Findings applied:_rehydration_entryJSON fallback (no callers)except Exception:to(sqlite3.Error, AttributeError, TypeError, ValueError)with_ra().logger.debug()logEXISTSsubqueries + a separateSELECT 1fast-path round-trip with a singleSUM(CASE WHEN …)+COUNT(*)aggregationif self._conn is None: return Noneguard at the top ofget_session_cost_summaryfor type safety