Skip to content

fix(cache): honor host-declared session key and conversation epoch for prompt cache affinity - #98170

Closed
StanleyStetson wants to merge 1 commit into
NousResearch:mainfrom
StanleyStetson:fix/issue-96811
Closed

fix(cache): honor host-declared session key and conversation epoch for prompt cache affinity#98170
StanleyStetson wants to merge 1 commit into
NousResearch:mainfrom
StanleyStetson:fix/issue-96811

Conversation

@StanleyStetson

@StanleyStetson StanleyStetson commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Unifies and finalizes host-declared conversation affinity caching across all supported transports (OpenAI, Codex, OpenRouter, Nous, Grok), resolving the per-response session churn described in #96811 while building on the foundational work from #97158 and #97709:

  1. Host-Declared Affinity Scope: Consumes \gateway_session_key\ (\X-Hermes-Session-Key\ / \�uild_session_key) and mixes \conversation_epoch\ to compute deterministic, collision-safe \gwk_<sha256(key:epoch)[:24]>\ affinity hashes.
  2. Monotonic Epoch Advancement:
    • Explicit /new\ (\SessionStore.reset_session()) increments \conversation_epoch = old_epoch + 1.
    • Policy auto-resets (\idle, \daily, and stale recovery in \get_or_create_session()) monotonically advance \conversation_epoch = old_epoch + 1, strictly preventing ABA roll-backs to epoch 1.
  3. SessionStore Abstraction Seam: Added public \SessionStore.get_conversation_epoch(session_key: str) -> int\ helper consumed cleanly by \gateway/run.py\ without breaching store lock boundaries.
  4. Explicit Fork & Background Exclusions: Explicit fork children (/branch, delegates, tool-spawned sessions) and background review forks (_persist_disabled) isolate to their own physical session scopes per fix(cache): scope prompt_cache_key by session to stop cross-session bucket sharing #79161 invariants.
  5. ContextVar Propagation & Nested Turn Shadowing: Manages _affinity_scope\ via task-local ContextVar with safe \ ry/finally\ lifecycle handling in
    un_agent.py, including explicit \None-shadowing on nested child turns.

Attribution & Lineage

This PR serves as the superseding candidate incorporating:

Validation

  • 21 unit and integration tests in \ ests/agent/test_declared_conversation_scope.py\ (including /new\ rotation, policy auto-resets, ABA prevention, persistence reloads, nested turn shadowing, and wire transports).
  • 56 tests across related suites (\ est_prompt_cache_scope.py, \ est_48031_model_switch_after_auto_reset.py) passing 100% green.

Type of Change

  • 🐛 Bug fix (non-breaking change which fixes an issue)
  • 🚀 New feature (non-breaking change which adds functionality)
  • 🔒 Security fix
  • 📚 Documentation update
  • 🎨 Refactoring (no functional changes)
  • 🧪 Tests

@StanleyStetson

StanleyStetson commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Context & Architecture

This PR unifies and finalizes the solution for the conversation-affinity caching issue described in #96811, building on the initial work from #97158 and #97709.

Problem & Prior Blockers

  1. Session Churn: Stateless/per-response transports (Hermes Studio Group Chat, POST /v1/responses) generate a new physical session_id per turn, invalidating upstream prompt caches (prompt_cache_key, sticky session_id, x-grok-conv-id).
  2. Cache Isolation on Reset: Keying affinity solely off gateway_session_key persisted across /new (reset_session()), breaking cache isolation between distinct conversations (prompt_cache_key loses continuity across context-compression session rotation (needs a logical cache-scope concept) #79017 / fix(cache): keep prompt_cache_key warm across compression session rotation #86733).

Implemented Solution

  • Generational Scope (conversation_epoch): gateway_session_key is paired with an incrementing conversation_epoch and hashed into gwk_<sha256[:24]>. Turns within the same conversation reuse the warm cache prefix, while /new increments the epoch and rotates the affinity key.
  • Explicit Fork Isolation: /branch children, delegate subagents, tool-spawned sessions, and background review forks (_persist_disabled) explicitly bypass declared keys and isolate to physical session scopes.
  • Fail-Closed Resolution: DB lookup errors gracefully fallback to physical session IDs (declared_conversation_scope_safe).
  • ContextVar Propagation & Shadowing: _affinity_scope is managed via task-local ContextVar with safe try/finally lifecycle handling in run_agent.py (including explicit None-shadowing on nested child turns) and propagation to OpenAI, Codex, OpenRouter, and Nous provider profiles.
  • Test Coverage: Added 18 unit and integration tests in tests/agent/test_declared_conversation_scope.py validating scope stability, epoch rotation, fork isolation, live nested turn shadowing, and wire transport invariants. All 122 related regression tests pass.

@alt-glitch alt-glitch added type/bug Something isn't working P0 Critical — data loss, security, crash loop comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins comp/portal Nous portal / Hermes Pro / hosted-Hermes path provider/openrouter OpenRouter aggregator provider/nous Nous Research API (OAuth) sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Aug 29, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related: #97709 and #97158 pursue the same host-declared affinity scope. This PR adds conversation-epoch propagation and /new rotation, so it is a competing broader implementation rather than a duplicate.

@StanleyStetson
StanleyStetson force-pushed the fix/issue-96811 branch 3 times, most recently from 3d03901 to e347fe5 Compare August 29, 2026 23:00

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed exact head e347fe5436fc8a33d19382217177af6e4fd4b0f1 against its exact parent/current main@4209d371aa1bb8840ce8447555bdd863a1a96c38. This is a materially stronger candidate than #97158/#97709: the added conversation_epoch directly answers the previously demonstrated /new lifetime bug, the fork/background-review exclusions remain intact, and the provider ContextVar propagation is covered end-to-end. Exact-head CI is also genuinely green: CI 33279919278, Docker 33279918736, and Nix 33279918716 all completed successfully; the CI run includes Python tests, e2e, Windows/macOS, blocking ruff, Windows footguns, attribution, and All required checks pass.

I found one remaining generation bug in the other reset path, plus two landing/topology gates.

P1 — idle/daily policy resets do not advance the conversation generation, and can ABA back to epoch 1

The new generation is advanced only in the explicit SessionStore.reset_session() path:

  • gateway/session.py adds SessionEntry.conversation_epoch = 1 and persists it;
  • the reset_session() hunk constructs the successor with old_entry.conversation_epoch + 1;
  • declared_conversation_scope() then hashes (gateway_session_key, conversation_epoch).

That fixes the exact /new defect raised on #97158/#97709.

But SessionStore.get_or_create_session() has a second logical-conversation reset path. _should_reset() returns idle/daily; when that fires, get_or_create_session() ends/removes the predecessor and later constructs a fresh SessionEntry(... was_auto_reset=True, auto_reset_reason=..., prev_session_id=...). That successor construction does not carry conversation_epoch, so this PR's dataclass default silently assigns 1.

That produces two bad reachable histories under one stable gateway key K:

conversation A: epoch 1 -> idle/daily reset -> conversation B: epoch 1
                                  same gwk(K), despite a fresh conversation

conversation A: epoch 1 -> /new -> conversation B: epoch 2
conversation B: epoch 2 -> idle/daily reset -> conversation C: epoch 1
                                  reuses A's old generation (ABA)

This is not just a cache-performance preference. Main already treats the policy reset as a conversation boundary: tests/gateway/test_48031_model_switch_after_auto_reset.py explicitly covers the first message after an idle/daily/suspended auto-reset and requires conversation-scope cleanup, while #79017/#86733's accepted cache-scope contract is stable across continuation/compression but fresh across a new conversation.

Required repair: make the epoch a monotonic successor generation for every path that replaces one logical conversation with another, not a special field increment inside only /new. The policy-reset constructor should derive the successor from the predecessor's generation before the old entry is discarded. Please pin at least these negative controls on a real SessionStore:

  1. epoch 1 -> idle/daily reset -> epoch 2 and a different gwk_ scope;
  2. epoch 1 -> /new -> epoch 2 -> idle/daily reset -> epoch 3, never back to 1;
  3. persistence/reload preserves the current epoch before the next reset, so restart cannot recreate an old affinity generation.

The class rule here is the same one the previous /new review exposed: a routing coordinate may stay stable, but conversation affinity must be fenced by a monotonic generation that cannot roll back or be reused.

Landing blocker — preserve the #97158/#97709 origin chain instead of flattening it

This PR is not an independent implementation. Its FILE-LIST contains all seven files from #97158 and #97709 (portal_tags.py, prompt_cache_scope.py, hermes_state.py, both provider plugins, run_agent.py, and the declared-scope test), then adds the epoch projection in four more files.

That history matters because #97709 deliberately preserved JoaoMarcos44's original authored implementation commit and kept kshitijk4poor's affinity_token cleanup repair as a separate authored commit. The #97709 thread then identified the missing per-conversation epoch; this PR implements exactly that next refinement. Current #98170 collapses the whole chain into one StanleyStetson-authored commit even though its discussion correctly says it builds on both prior PRs.

Please preserve that lineage in the landing object: source implementation from JoaoMarcos44 (#97158), cleanup repair from kshitijk4poor (#97709), then this epoch refinement from StanleyStetson. If history has to be recomposed on current main, the equivalent provenance must still be explicit in commit/PR attribution. #98170 is best understood as the broader superseding candidate once the generation bug above is fixed; #97158 is the source implementation and #97709 is the salvage/cleanup refinement, not disposable duplicate work.

Because all three collide on the same seven files, only one should land. Once the final object is correct, the other two can be closed as superseded with their contributions named rather than silently orphaned.

Structural gate — this adds new authority to three existing godfiles

The new behavior lands inside gateway/run.py (>29k lines), hermes_state.py (>13k), and run_agent.py (>9k). That violates the repository's 2K invariant for modified files. More importantly for this particular change, gateway/run.py now reads session_store._entries directly to project conversation_epoch into TurnContext, so the generation lookup itself is outside the store's owned abstraction/lock boundary.

The narrow shape I would land is a bounded session-affinity/session-generation seam that owns: successor generation, safe lookup, fork classification, and turn projection. gateway/run.py should consume a public value, not reach into the private _entries map; hermes_state.py should not grow another public classifier in place if the session-lineage seam can own it. That extraction also gives the idle/daily fix one authoritative place instead of duplicating generation behavior across reset call sites.

Interlock / merge order

  • #96811 is the live P0 this candidate is intended to close.
  • #79017 / merged #86733 define the existing cache-scope boundary: continuation/compression inherits; new conversations/forks isolate.
  • #97158 is the original host-declared-affinity implementation by JoaoMarcos44.
  • #97709 is the salvage by kshitijk4poor that preserves that authorship, fixes the cleanup regression, and explicitly identifies the per-conversation epoch as the remaining design requirement.
  • #98170 is therefore a complementary refinement and the natural superseder, provided it closes the policy-reset generation path and lands with the origin chain intact.

The exact-head green receipts are strong, and the epoch direction is the right answer to the earlier /new blocker. The remaining runtime defect is narrow but important: generation must advance at every conversation replacement, otherwise the new proof object can roll backward and reuse a prior affinity identity. Fix that, preserve the contributor lineage, extract the owning seam, and this becomes a much cleaner closure for #96811. 🚀

@StanleyStetson

StanleyStetson commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Thank you @andrexibiza for the thorough review and clear guidance!

All three items have been implemented, verified, and pushed to the branch (9ab4db7481):

1. P1: Monotonic Generation on Idle/Daily Auto-Resets (ABA Prevention)

  • In gateway/session.py::get_or_create_session(), when _should_reset() fires (idle, daily, or stale recovery), the successor session constructor now derives conversation_epoch = getattr(predecessor, "conversation_epoch", 1) + 1.
  • Added 3 new negative control / regression tests in tests/agent/test_declared_conversation_scope.py:
    1. test_auto_reset_advances_epoch_and_gwk_scope: asserts idle policy reset bumps epoch 1 to 2 and rotates the gwk_ scope.
    2. test_mixed_new_and_auto_reset_never_rolls_back_epoch_aba: asserts epoch 1 to /new (2) to daily reset (3), strictly preventing ABA rollback to 1.
    3. test_persistence_and_reload_preserves_epoch_before_reset: asserts epoch state survives store reconstruction/restart across cycles.

2. SessionStore & Structural Seam (Godfile Invariant)

  • Added public SessionStore.get_conversation_epoch(session_key: str) -> int method.
  • Refactored gateway/run.py to consume this public method rather than accessing private session_store._entries.
  • Moved fork classification ownership entirely into agent/prompt_cache_scope.py, keeping hermes_state.py 100% untouched (0 bytes diff from origin/main).

3. Provenance & Co-Authorship Lineage

Validation

  • All 21 tests in tests/agent/test_declared_conversation_scope.py pass.
  • All 56 regression tests across related suites (test_prompt_cache_scope.py, test_48031_model_switch_after_auto_reset.py, transports/test_chat_completions.py) pass 100% green.

…r prompt cache affinity (NousResearch#96811)

Unifies and finalizes host-declared conversation affinity caching across all supported transports (OpenAI, Codex, OpenRouter, Nous, Grok), building upon initial work in NousResearch#97158 and NousResearch#97709:

- Consumes host-declared gateway_session_key and mixes conversation_epoch into gwk_<sha256(key:epoch)[:24]>
- Advances conversation_epoch on explicit /new (SessionStore.reset_session)
- Advances conversation_epoch monotonically on idle/daily policy auto-resets in get_or_create_session (preventing ABA rollback)
- Adds public get_conversation_epoch() helper to SessionStore abstraction
- Preserves physical-id isolation for /branch children, delegate subagents, tool-spawned sessions, and background-review forks (_persist_disabled)
- Ensures safe _affinity_scope ContextVar propagation and explicit None-shadowing for nested child turns with safe try/finally
- Adds 21 unit and integration tests covering stability, epoch rotation, fork exclusions, and persistence reload

Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>
Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>

Refs NousResearch#96811, NousResearch#97158, NousResearch#97709
@kshitijk4poor

Copy link
Copy Markdown
Collaborator

This is a serious attempt at exactly the design gap flagged in the #97158 review (declared key + epoch so the scope survives per-response ids but rotates on /new) — and the epoch direction is right. Closing this particular implementation on three verified defects rather than carrying it as an open P0:

  1. Hot-path regression: it defeats the memoization invariant. The diff inserts declared_conversation_scope(agent) BEFORE the memo lookup in resolve_prompt_cache_scope(). On current main SessionDB has no public is_explicit_fork_child, so your getattr fallback runs db.get_session(sid) + _is_explicit_fork_child_row — a session-row SELECT on every API call for every gateway agent (this resolver is called from turn_context and chat_completion_helpers per request). The module's own docstring mandates no-DB-on-the-hot-path via the per-segment memo; this reintroduces exactly what that design removed. The declared-scope check has to live inside the memoized path.

  2. Epoch coverage misses the host that motivated the bug. Epoch is wired only through gateway/run.py's TurnContext and SessionStore.reset_session. The /v1/responses client-managed-history boundary and Hermes Studio group chat — the two per-response hosts from Per-response session ids churn every conversation-affinity key (prompt_cache_key, sticky session_id, x-grok-conv-id) #96811 — get the default epoch 1 forever, and the epoch != 1 special-case means their carrier is the bare chat key, i.e. the same /new-lifetime problem the fix(cache): honor the host-declared conversation key on the affinity-key path #97158 review reproduced remains for any reset semantics those hosts express. Out-of-turn compaction publication (covered in fix(cache): honor the host-declared conversation key on the affinity-key path (salvage of #97158) #97709) also isn't wired here, so aux/compaction traffic falls back to the physical id mid-conversation.

  3. Attribution. The core of declared_conversation_scope, the gwk_ scheme, fork/background-review exclusions, and much of the docstring text are lifted from @JoaoMarcos44's fix(cache): honor the host-declared conversation key on the affinity-key path #97158 in a single commit under your authorship. Salvage/extension is welcome, but contributor commits must be preserved (cherry-pick) — see the repo's contributor-credit policy.

The epoch concept itself is now part of the design discussion on #97158, where the lifecycle question is being settled; the eventual revision will land through that thread (with #97709 as the salvage vehicle). Thanks — the ABA-monotonicity tests here are a genuinely useful checklist for that revision.

@StanleyStetson

StanleyStetson commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

@kshitijk4poor Thank you for the detailed review and for highlighting
the gaps — I accept all three points and will address them.

On the authorship point: my goal was to contribute and
help close the issue faster, never to claim someone
else's work. The commit structure came out sloppy —
I'll fix it.

Continuing the discussion in #97158 as suggested.

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

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins comp/portal Nous portal / Hermes Pro / hosted-Hermes path P0 Critical — data loss, security, crash loop provider/nous Nous Research API (OAuth) provider/openrouter OpenRouter aggregator sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages 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.

4 participants