Skip to content

fix(cache): honor the host-declared conversation key on the affinity-key path - #97158

Closed
JoaoMarcos44 wants to merge 1 commit into
NousResearch:mainfrom
JoaoMarcos44:fix/declared-conversation-affinity-scope
Closed

fix(cache): honor the host-declared conversation key on the affinity-key path#97158
JoaoMarcos44 wants to merge 1 commit into
NousResearch:mainfrom
JoaoMarcos44:fix/declared-conversation-affinity-scope

Conversation

@JoaoMarcos44

@JoaoMarcos44 JoaoMarcos44 commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Lets the host declare the logical conversation behind its physical session ids, and makes the four conversation-affinity surfaces consult that declaration. This is the fix for the key churn demonstrated in #96811 — the mechanism behind the cost half of #96570.

Root cause

Every affinity hint Hermes sends is derived from the physical session id:

Consumer Wire field Derived from
agent/transports/codex.py, agent/transports/chat_completions.py prompt_cache_key resolve_prompt_cache_scope() → compression-lineage root → physical id
plugins/model-providers/openrouter body.session_id (sticky routing) ambient conversation id → get_conversation_root() → physical id
plugins/model-providers/nous body.session_id (sticky routing) same
plugins/model-providers/openrouter x-grok-conv-id header same

A host that mints one physical session per response therefore re-keys all four on every reply, and the conversation never lands back on the routing bucket it just warmed. Two hosts do exactly that:

  1. Hermes Studio group chatgroupRuntimeSessionId() mints gc_run_<room>_<profile>_<name>_<uuid4hex> per reply and destroys the session afterwards.
  2. POST /v1/responses with client-managed historysession_id = stored_session_id or str(uuid.uuid4()) in gateway/platforms/api_server.py. Without previous_response_id that is a fresh id per request, even though the same handler parsed X-Hermes-Session-Key a few lines earlier and passes it straight to _create_agent(gateway_session_key=…).

Case 2 closes the whole loop inside this repo: the stable identity is already in hand, already on the agent — the affinity path just never consulted it.

Why not normalize the id

Stripping a per-run-looking suffix is not identity-safe, and #96768 already carries both negative controls as tests: POST /v1/sessions preserves a client-supplied id verbatim (two independent conversations may differ only in a trailing 32-hex identity), and Studio truncates its semantic prefix to 96 characters before appending the token (two members of one room can end up distinguished by that token alone). Same failure class as #79017: syntactic inference truncates legitimate identities and collides distinct owners.

The declaration is semantic instead, and the carrier already exists: gateway_session_key, the "stable per-chat key" (agent:main:telegram:dm:123) built by gateway.session.build_session_key() from X-Hermes-Session-Key. Its own documentation notes that branching keys off session_id, not this slot — exactly the property a conversation scope needs. agent/prompt_cache_scope.py simply did not read it. (AIAgent.__init__ already accepts it, and run_agent.py:741 already treats it as the context engine's conversation_id.)

Changes

  • agent/prompt_cache_scope.pydeclared_conversation_scope() resolves the key to gwk_<sha256[:24]> and outranks the lineage walk: it is stable across compression rotation and across per-response ids, which the walk cannot see. Hashed because — unlike a session id — the key embeds platform/chat/user identifiers and leaves the process verbatim as OpenRouter's sticky session_id and as x-grok-conv-id; hashing also keeps it inside every consumer's length budget (28 chars).
  • agent/portal_tags.py — a second ambient scope for routing, kept separate from the conversation id, which is an attribution value (conversation=<id> on Portal requests). The two agree for any host that keeps one id per conversation; they diverge only for a per-response host. It is published only when a host declared something, so unset ⇒ providers fall back to the attribution id exactly as before: delegate trees keep sharing their parent's sticky key and Portal attribution is untouched.
  • hermes_state.pyis_explicit_fork_child(), the public view of _is_explicit_fork_child_row()'s marker rules, so the fork boundary is enforced without re-implementing it.
  • run_agent.py — publishes the declared routing scope beside the existing conversation context (turn entry, and the out-of-turn compaction forwarder), with symmetric resets.
  • openrouter / nous pluginsget_affinity_scope() or get_conversation_context() or session_id.

Isolation the declaration must not cross (#79161)

/branch children, delegate subagents and tool children share their parent's chat key but are separate conversations — the session row's fork markers keep them on their own scope. Background-review forks clone the live runtime (key included), so _persist_disabled excludes them. A DB error during that check degrades to the physical-id scope rather than risking a merge.

Blast radius

Zero wire change for any agent without a declared key: no key ⇒ declared_conversation_scope() returns None and every path resolves exactly as on main. POST /v1/responses + X-Hermes-Session-Key is fixed with no host change. Studio group chat needs one kwarg on its side (AIAgent(..., gateway_session_key=<stable per-room-member key>)), since its bridge constructs the agent directly; its truncate-before-nonce ordering is a separate producer-side bug worth fixing there too.

%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#8b0000', 'mainBkg': '#0a0204', 'primaryTextColor': '#ffccd5', 'primaryBorderColor': '#ff0038', 'lineColor': '#ff0038'}}}%%
graph TD
    A[Reply N - gc_run_room_member_uuidA] --> R{Scope Resolver}
    B[Reply N+1 - gc_run_room_member_uuidB] --> R

    R -->|Declared chat key present| K[Hashed Logical Scope - gwk_sha256]
    R -->|No declaration| P[Physical Id / Lineage Root - unchanged]
    R -->|Branch, Delegate, Tool child| F[Own Scope - fork isolation kept]
    R -->|Background review fork| F

    K --> S1[prompt_cache_key]
    K --> S2[OpenRouter sticky session_id]
    K --> S3[Nous Portal sticky key]
    K --> S4[x-grok-conv-id]

    S1 --> W[One Warm Routing Bucket across every reply]
    S2 --> W
    S3 --> W
    S4 --> W
Loading

Related Issue

Fixes #96811
fixes #96570

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

How to Test

scripts/run_tests.sh tests/agent/test_declared_conversation_scope.py tests/agent/test_prompt_cache_scope.py tests/agent/test_portal_tags.py tests/providers tests/agent/transports tests/hermes_state -q

Local results: 22 passed (new file), 433 passed / 1 failed across the adjacent suites, 200 passed (tests/hermes_state). The single failure — tests/agent/test_portal_tags.py::test_compress_context_preserves_ambient_context — reproduces identically on unmodified main in this environment (AttributeError: 'DaemonThreadPoolExecutor' object has no attribute '_initializer', a Python 3.14 concurrent.futures change surfacing in tools/daemon_pool.py), so it is not caused by this branch. ruff check clean on all changed files.

New coverage in tests/agent/test_declared_conversation_scope.py: two per-response ids resolving to one scope; distinct declarations staying isolated; the raw key never appearing in the scope; no-declaration behavior identical to the lineage path; /branch, delegate, tool and background-review forks ignoring the declaration; the fork-check DB failure degrading safely; prompt_cache_key stability on both transports; sticky session_id and x-grok-conv-id following the declared scope while an undeclared turn still resolves through the conversation id.

…key path

Every conversation-affinity hint Hermes sends is derived from the PHYSICAL
session id: prompt_cache_key on both OpenAI-wire transports, OpenRouter's and
Nous Portal's sticky session_id, and xAI's x-grok-conv-id. A host that mints
one physical session per RESPONSE re-keys all four on every reply, so the
conversation never lands back on the routing bucket it just warmed (NousResearch#96811).

Two hosts do exactly that. Hermes Studio's group chat mints
gc_run_<room>_<profile>_<name>_<uuid4hex> per reply and destroys it after,
and POST /v1/responses with client-managed history mints str(uuid4()) per
request — while parsing X-Hermes-Session-Key one screen earlier and handing
it to the agent.

Hermes must not infer the logical conversation from the id's syntax: that
rule merges independent client-supplied ids and Studio members truncated past
its 96-character boundary (the NousResearch#79017 failure class). It does not have to.
gateway_session_key is already the "stable per-chat key" built by
gateway.session.build_session_key from that header, and branching
deliberately does not key off it. The affinity path simply never consulted it.

- agent/prompt_cache_scope.py: declared_conversation_scope() resolves the key
  into gwk_<sha256[:24]> and outranks the lineage walk (it is stable across
  rotation AND across per-response ids). Hashed because, unlike a session id,
  the key embeds platform/chat/user identifiers and leaves the process
  verbatim as a sticky id and as x-grok-conv-id.
- agent/portal_tags.py: a separate ambient scope for ROUTING, published only
  when a host declared one. The providers read the attribution id when it is
  unset, so delegate trees keep sharing their parent's sticky key and every
  host that keeps one id per conversation is byte-identical to before.
- hermes_state.py: is_explicit_fork_child() — the public view of the marker
  rules that keep /branch children, delegate subagents and tool children off
  their parent's chat key. Background-review forks clone the live runtime, so
  _persist_disabled excludes them for the same reason (NousResearch#79161).

Refs NousResearch#96570
Fixes NousResearch#96811
@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/portal Nous portal / Hermes Pro / hosted-Hermes path comp/plugins Plugin system and bundled plugins 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) labels Aug 28, 2026
@cervantesh

cervantesh commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Thanks for isolating a real Hermes-native reproduction for the affinity-key churn. The /v1/responses path can mint a fresh physical UUID for each client-managed-history request while X-Hermes-Session-Key is already available to the agent. The write-loss premise from #96570 remains disproved, but this separate mechanism is valid and belongs to #96811.

I reviewed exact PR head 0c5929f68a against current main@0abecf7a93 and also applied the PR cleanly onto that main revision. The candidate demonstrates the intended improvement, but I found two correctness blockers. The earlier real-entry-point evidence gap is now closed by the reproduction package linked below.

1. gateway_session_key has a longer lifetime than a conversation

The proposed precedence treats gateway_session_key as a host-declared logical-conversation identity. That conflicts with its existing documented contract.

PR #20199 introduced X-Hermes-Session-Key specifically as a stable per-channel, long-term-memory scope independent of the transcript ID: one stable key per assistant channel across transcripts that rotate on /new. The current API-server documentation says the same thing: gateway_session_key persists across transcripts while session_id rotates on /new. Native messaging gateways also pass ctx.session_key into every newly created agent, so this is not limited to unusual /v1/responses clients.

That creates the following reachable lifecycle:

  1. conversation A runs under physical session ID A and gateway key K;
  2. /new calls reset_session(K) and creates physical session ID B;
  3. the next agent is created with the same gateway key K; and
  4. declared-key precedence maps both A and B to the same gwk_<sha256(K)>.

I first confirmed that behavior at the resolver level. I then drove both production boundaries through a real-path differential:

  • two actual POST /v1/responses requests carrying one X-Hermes-Session-Key; and
  • the native gateway's actual /new handler backed by a temporary real SessionStore and SessionDB.

The effective result was:

Boundary current main PR head current main + PR
Consecutive Responses requests keep all affinity values no yes yes
/new rotates all affinity values yes no no

For consecutive Responses requests, the PR correctly kept all six values stable:

  • Chat Completions prompt_cache_key;
  • Responses API prompt_cache_key;
  • OpenRouter sticky session_id;
  • Nous sticky session_id;
  • x-grok-conv-id; and
  • the internal resolved scope.

That is a direct positive witness for the #96811 fix. However, the same six values also remained identical across /new, even though the physical session ID rotated. This conflicts with the cache-scope contract established in #79017 and implemented by #86733: continuity survives compression rotation, but the scope resets on /new, /branch, and other independent-conversation boundaries.

This also makes the blast radius broader than the PR description suggests. Every ordinary native gateway supplies this key, so normal messaging conversations would move from physical/lineage affinity to per-chat affinity—not only hosts that mint one ID per response.

There is related work around the /v1/responses conversation identity in #47294, #41209, and #16517. Those are not duplicates of #96811: they cover namespacing, ownership, and durable continuity rather than provider affinity, but they may contain a semantic owner closer to a logical conversation than the deliberately cross-transcript gateway key. Conversely, #71556/#71608 use gateway_session_key for Langfuse grouping, where per-channel observability is a different and intentionally broader contract.

This is not future hardening beyond #96811. It is a negative control on the lifetime of the identity carrier introduced by this PR. A safe contract would need to remain stable across per-response physical IDs while rotating at /new and preserving the existing branch/delegate isolation.

2. The current head has a reproducible cleanup regression

The hosted Python test job is red, and I reproduced the affected selection locally:

4 failed, 9 passed

All four failures reach the outer cleanup before affinity_token has been assigned. The variable is created only after durable-lease, Relay, and task-start operations that may return or raise, but the outer finally reads it unconditionally at run_agent.py:9092, producing:

UnboundLocalError: cannot access local variable 'affinity_token'
where it is not associated with a value

This is attributable to the candidate change rather than CI infrastructure.

For balance, the new focused suite passes locally:

tests/agent/test_declared_conversation_scope.py: 22 passed

The adjacent prompt-cache, Portal-tag, and API-server selections also pass:

141 passed

Those green results validate the added helper and provider wiring; they do not negate the early-exit regression.

Real-path reproduction package

The complete witness, exact revisions, summarized outputs, instructions, and integrity hash are here:

https://gist.github.com/cervantesh/1fa5099000d0163478d5fb6a6a325b47

It makes zero model-provider network calls. It uses the real Responses handler and real /new handler, replacing only model execution with a deterministic capture. The resulting identities are then passed through the repository's actual resolver, OpenAI-wire transports, and OpenRouter/Nous provider profiles. The result was repeated and remained the same after integrating the PR onto current main.

Assessment

My current assessment is REQUEST CHANGES, for two strict reasons:

  • the selected identity demonstrably crosses /new, violating an existing isolation invariant; and
  • the exact PR head has a reproducible cleanup regression.

The general direction—letting a host declare a semantic affinity owner instead of inferring identity from UUID syntax—still looks sound. Evidence that would change this assessment would be:

  • a declaration or derivation whose lifecycle is explicitly per logical conversation rather than per channel;
  • a negative control proving /new receives a distinct scope;
  • the real-path witness remaining GREEN for consecutive per-response requests; and
  • green required checks on the resulting exact head.

Studio can remain a separately acknowledged integration step because it still needs to supply whichever per-conversation declaration is adopted.

StanleyStetson added a commit to StanleyStetson/hermes-agent that referenced this pull request Aug 30, 2026
…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
@StanleyStetson

Copy link
Copy Markdown
Contributor

Following the review feedback on #98170 and the discussion on #97158 / #97709, I put together an exploratory revision branch built on top of this salvage (preserving @JoaoMarcos44's and @kshitijk4poor's commits via cherry-pick).

Sharing here in case any of these pieces are helpful for the salvage effort:

  1. Memoization & Hot Path: Kept declared_conversation_scope() strictly inside the memoization miss path in resolve_prompt_cache_scope(), avoiding SQLite queries on cached calls. The memo key is (sid, db_present, epoch).
  2. Epoch Layer: Mixed conversation_epoch into the declared scope hash (key:epoch for epoch > 1) and wired it through SessionStore.reset_session() and TurnContext to handle /new rotation while keeping affinity stable across per-response requests.
  3. Tests: Added tests for epoch rotation, memo invalidation on epoch advance, and ABA monotonicity under mixed /new + auto-resets.

The branch and commit are available here if you'd like to inspect, cherry-pick, or adapt anything from it:

@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Closing this revision while keeping the bug (#96811) open — the mechanism is real and your reproduction package proved it, but the carrier as implemented has a verified lifecycle violation that can't be fixed by a follow-up commit on this branch.

State of the review, consolidated:

Your diagnosis, the four-surface consumer map, the gwk_ hashing scheme, and the fork/background-review exclusions all carry into the revision, and the salvage vehicle (#97709) preserves your commit and authorship — the revised contract will land with your credit. Thanks for a genuinely rigorous PR; this one moved the design forward even though this revision can't merge.

JoaoMarcos44 pushed a commit to JoaoMarcos44/hermes-agent that referenced this pull request Aug 30, 2026
The turn-lease timeout/interrupt paths return from inside the try block
before set_affinity_scope() runs; the finally then read an unassigned
local -> UnboundLocalError. This was the cause of the 4 red
cross-process lease tests on PR NousResearch#97158's CI.

(cherry picked from commit 05a3c8a)
JoaoMarcos44 added a commit to JoaoMarcos44/hermes-agent that referenced this pull request Aug 30, 2026
The declared key is a per-CHAT identifier and outlives the conversation it
names: reset_session() mints a fresh physical id on /new but keeps the key, and
the idle/daily/suspended policy resets do the same. Hashing the key alone
therefore mapped the conversation before a reset and the one after it onto one
gwk_ scope -- the lifecycle violation @cervantesh raised on NousResearch#97158 and
@kshitijk4poor reproduced on NousResearch#97709.

No counter is introduced. The generation that must rotate is already durable:
every one of those boundaries closes the outgoing row with an
_RESET_END_REASONS end_reason, so SessionDB.latest_conversation_boundary reads
the most recent one and declared_conversation_scope hashes 'key|generation'.

That makes the carrier stable across a host's per-response physical ids -- a
host that never resets writes no boundary, so every reply hashes the same value
-- while rotating on every conversation replacement, /new and the policy
auto-resets alike. ended_at only moves forward, so a retired generation can
never be reused: no ABA.

It also cannot drift from the rest of the codebase's notion of a conversation
boundary, because find_latest_gateway_session_for_peer fences on the same set.

The read is on the memoized resolution path, not per API call, and both lookups
fail closed: an unqualified key would span a /new, so a DB error degrades to the
physical-id scope. A SessionDB without the lookup keeps the previous behaviour.

Refs NousResearch#96811
teknium1 pushed a commit that referenced this pull request Sep 1, 2026
The turn-lease timeout/interrupt paths return from inside the try block
before set_affinity_scope() runs; the finally then read an unassigned
local -> UnboundLocalError. This was the cause of the 4 red
cross-process lease tests on PR #97158's CI.

(cherry picked from commit 05a3c8a)
teknium1 pushed a commit that referenced this pull request Sep 1, 2026
The declared key is a per-CHAT identifier and outlives the conversation it
names: reset_session() mints a fresh physical id on /new but keeps the key, and
the idle/daily/suspended policy resets do the same. Hashing the key alone
therefore mapped the conversation before a reset and the one after it onto one
gwk_ scope -- the lifecycle violation @cervantesh raised on #97158 and
@kshitijk4poor reproduced on #97709.

No counter is introduced. The generation that must rotate is already durable:
every one of those boundaries closes the outgoing row with an
_RESET_END_REASONS end_reason, so SessionDB.latest_conversation_boundary reads
the most recent one and declared_conversation_scope hashes 'key|generation'.

That makes the carrier stable across a host's per-response physical ids -- a
host that never resets writes no boundary, so every reply hashes the same value
-- while rotating on every conversation replacement, /new and the policy
auto-resets alike. ended_at only moves forward, so a retired generation can
never be reused: no ABA.

It also cannot drift from the rest of the codebase's notion of a conversation
boundary, because find_latest_gateway_session_for_peer fences on the same set.

The read is on the memoized resolution path, not per API call, and both lookups
fail closed: an unqualified key would span a /new, so a DB error degrades to the
physical-id scope. A SessionDB without the lookup keeps the previous behaviour.

Refs #96811
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
The turn-lease timeout/interrupt paths return from inside the try block
before set_affinity_scope() runs; the finally then read an unassigned
local -> UnboundLocalError. This was the cause of the 4 red
cross-process lease tests on PR NousResearch#97158's CI.

(cherry picked from commit 05a3c8a)
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
The declared key is a per-CHAT identifier and outlives the conversation it
names: reset_session() mints a fresh physical id on /new but keeps the key, and
the idle/daily/suspended policy resets do the same. Hashing the key alone
therefore mapped the conversation before a reset and the one after it onto one
gwk_ scope -- the lifecycle violation @cervantesh raised on NousResearch#97158 and
@kshitijk4poor reproduced on NousResearch#97709.

No counter is introduced. The generation that must rotate is already durable:
every one of those boundaries closes the outgoing row with an
_RESET_END_REASONS end_reason, so SessionDB.latest_conversation_boundary reads
the most recent one and declared_conversation_scope hashes 'key|generation'.

That makes the carrier stable across a host's per-response physical ids -- a
host that never resets writes no boundary, so every reply hashes the same value
-- while rotating on every conversation replacement, /new and the policy
auto-resets alike. ended_at only moves forward, so a retired generation can
never be reused: no ABA.

It also cannot drift from the rest of the codebase's notion of a conversation
boundary, because find_latest_gateway_session_for_peer fences on the same set.

The read is on the memoized resolution path, not per API call, and both lookups
fail closed: an unqualified key would span a /new, so a DB error degrades to the
physical-id scope. A SessionDB without the lookup keeps the previous behaviour.

Refs NousResearch#96811
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/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) type/bug Something isn't working

Projects

None yet

5 participants