Skip to content

fix(agent): rehydrate session cost counters on agent construction (#67762) - #67770

Open
DavidMetcalfe wants to merge 3 commits into
NousResearch:mainfrom
DavidMetcalfe:fix/session-cost-rehydration
Open

fix(agent): rehydrate session cost counters on agent construction (#67762)#67770
DavidMetcalfe wants to merge 3 commits into
NousResearch:mainfrom
DavidMetcalfe:fix/session-cost-rehydration

Conversation

@DavidMetcalfe

Copy link
Copy Markdown
Contributor

Fixes #67762.

Problem

agent.session_estimated_cost_usd was reset to 0.0 inside init_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.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 bug affected every gateway-mediated surface; the only surface that masked it was the agents panel, which folds children-cost via tools/delegate_tool.py:2828 and adds the parent's never-zeroed prior total.

Fix

  • Adds SessionDB.get_session_cost_summary(session_id) at hermes_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 one SUM(CASE WHEN …) query that matches the codebase's existing conditional-aggregation pattern (agent/insights.py:373-376).
  • Adds agent_init.py::_rehydrate_session_cost(agent) which init_agent now calls immediately after the existing reset block. Reads from agent._session_db if available, scopes exceptions to (sqlite3.Error, AttributeError, TypeError, ValueError), logs via _ra().logger.debug() on the scoped path. Other exceptions surface.
  • Adds tests/hermes_state/test_session_cost_rehydration.py with 14 regression tests covering the reader, the helper, and fail-open behavior under transient DB errors vs. bug-class errors.

Tests

  • 14/14 new tests pass
  • 99/99 pass across tests/hermes_state/ and tests/run_agent/test_notice_spine.py with no regressions
  • npx tsc -b . in apps/desktop/ is not affected (no renderer changes in this PR)

Reproduction

  1. Start a new session (hermes chat or via the desktop app).
  2. Send several turns until the session has accumulated non-zero cost (e.g., $4.27).
  3. Verify the live cost counter shows $4.27. Inspect agent.session_estimated_cost_usd programmatically.
  4. Restart the gateway process.
  5. Resume the same session.
  6. Before this fix: agent.session_estimated_cost_usd shows 0.0 until the next API call adds to it.
  7. After this fix: the persisted $4.27 is rehydrated into the agent's attribute on construction, so the counter shows $4.27 + post-resume.

Known caveat

agent.session_cost_status will be overwritten by the unconditional = assignment at agent/conversation_loop.py:2321 (and the equivalent at agent/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

  • Phase 2 (provider-reported actual_cost_usd): not addressed here. The actual_cost_usd column 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.
  • JSON SessionEntry fallback: removed during review as speculative infrastructure with no caller. Will be re-added if a real caller emerges.

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:

  • Removed speculative _rehydration_entry JSON fallback (no callers)
  • Scoped except Exception: to (sqlite3.Error, AttributeError, TypeError, ValueError) with _ra().logger.debug() log
  • Replaced 3 EXISTS subqueries + a separate SELECT 1 fast-path round-trip with a single SUM(CASE WHEN …) + COUNT(*) aggregation
  • Added explicit if self._conn is None: return None guard at the top of get_session_cost_summary for type safety
  • Tests added for scoped-vs-bug-class exception handling

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
@DavidMetcalfe
DavidMetcalfe force-pushed the fix/session-cost-rehydration branch from eb40e2e to 7a3be6d Compare July 20, 2026 00:13
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint area/billing Account usage, credit usage, billing (cross-cutting) area/usage-cost Token accounting, usage reporting, billing, cost tracking sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state needs-decision Awaiting maintainer decision before any implementation labels Jul 20, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Fix PR for #67762. Related to #67774: both touch session cost-status summary data, while this patch uniquely wires rehydration into agent construction; they need a maintainer consolidation choice rather than a duplicate closure.

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for isolating the construction-time reset: current main still sets agent.session_estimated_cost_usd = 0.0 in agent/agent_init.py:2531, so the underlying restart bug is real.

Problems

  • The proposed summary sums all session_model_usage rows, but that table includes auxiliary-task rows (hermes_state.py:1134). Auxiliary accounting deliberately writes those rows without touching the session summary (hermes_state.py:4920-4930), while the live accumulator is updated by the main loop (agent/conversation_loop.py:2900-2917). A restart would therefore change the live counter's cost domain.
  • Delegated-child cost is folded only into the parent object (tools/delegate_tool.py:2862-2880), not persisted as a parent usage row. A parent-row summary still loses that portion after restart.

Suggested changes

  • Decide and encode the intended live-cost domain, then test auxiliary-task rows explicitly.
  • Persist or derive the parent-plus-child rollup before rehydrating it.
  • Exercise the actual init_agent() call site with a temporary SessionDB; the current new tests call the extracted helper directly.

Automated hermes-sweeper review.

@DavidMetcalfe

Copy link
Copy Markdown
Contributor Author

Both points are correct — verified against the source.

On auxiliary-task rows: get_session_cost_summary sums ALL session_model_usage rows for the session (WHERE session_id = :sid, no task filter). The main loop writes with task="", while record_auxiliary_usage writes with explicit task names (title generation, delegate summaries, etc.). Before restart, the live counter reflects main-loop only. After restart, it would include aux tasks — the domain changes silently.

On delegated-child cost: delegate_tool.py folds child cost into parent_agent.session_estimated_cost_usd in-memory only. No update_token_counts call, no persisted row. After restart, that portion is lost.

Minimal fix for the domain mismatch: Add AND task = '' to the WHERE clause in get_session_cost_summary. This keeps the rehydrated domain matching the live counter's pre-restart behavior. Aux-task cost is already visible in /insights via the per-model aggregation — it doesn't need to be in the live counter.

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 task='__child__' marker in session_model_usage, or a separate rollup table). That's a separate concern from the restart bug.

For test coverage: Agreed — the current tests call _rehydrate_session_cost directly. An init_agent() integration test with a temporary SessionDB would catch the domain mismatch. I'll add one.

Suggested path forward:

  1. Add AND task = '' filter to get_session_cost_summary (one-line fix)
  2. Add init_agent() integration test with temp SessionDB
  3. Child cost persistence → separate follow-up issue/PR

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.
@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit area/sessions Session lifecycle, resume, persistence, history labels Jul 30, 2026
@GottZ

GottZ commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Summary

Seven 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 consolidation

Keep #67770 open with a salvage path: preserve its tested main-loop cost rehydration, add an actual init_agent() restart test, and track delegated-child persistence explicitly without reopening #67778. Keep #67790 open as the recorded best fix for sticky cost_status; close #67804 as duplicate of #67790 despite its keep-open review because #67790's diff already supplies the requested SessionDB enforcement and tests, and keep #67774 closed as superseded. Close #67796 as duplicate of #67770 despite its keep-open review, or split out only correctly modeled token/API-counter rehydration after preserving the distinct counter semantics. Keep #67834 open with a salvage path only if the product-policy decision approves an accuracy-labeled cost surface; otherwise the placeholder supplies no implementation to retain.

Complex graph

flowchart 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"
Loading

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/billing Account usage, credit usage, billing (cross-cutting) area/sessions Session lifecycle, resume, persistence, history area/usage-cost Token accounting, usage reporting, billing, cost tracking comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint needs-decision Awaiting maintainer decision before any implementation P2 Medium — degraded but workaround exists sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: agent.session_estimated_cost_usd resets to $0 on gateway restart

4 participants