Skip to content

test(cache): pin the prompt-cache scope isolation invariant for per-response session ids - #96768

Closed
JoaoMarcos44 wants to merge 3 commits into
NousResearch:mainfrom
JoaoMarcos44:fix/96570-run-nonce-cache-scope
Closed

JoaoMarcos44 wants to merge 3 commits into
NousResearch:mainfrom
JoaoMarcos44:fix/96570-run-nonce-cache-scope

Conversation

@JoaoMarcos44

@JoaoMarcos44 JoaoMarcos44 commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Pins the cache-scope isolation invariant for hosts that mint one physical session per response, and records the per-response affinity-key re-keying those hosts produce.

This PR owns two independent test contracts, deliberately:

| Contract | File | What it pins |

| --- | --- | --- |

| Cache-scope isolation (the title) | tests/agent/test_prompt_cache_scope.py | distinct physical ids never merge onto one affinity key; cron's per-fire timestamp stays the only carve-out |

| System-prompt persistence under a per-response session | tests/agent/test_system_prompt_restore.py | the #96570 lifecycle clarification — the null-prompt warning is a first-turn artifact, not a lost write |

They share issue #96570 as their origin, not a mechanism. The evidence is the routing/affinity key changing per response; no provider cache telemetry or billing outcome is measured or claimed anywhere in this PR.

This PR started as a production change to agent/transports/codex.py (strip a trailing UUID4 hex from the logical cache scope). Review showed that rule is not identity-safe, so that hunk is reverted and only tests remain. The demonstrated key-churn contract now has its own issue: #96811.

Why the id-normalization approach was dropped

Two negative controls, both from @cervantesh's review, are now executable tests:

  1. External client-supplied identities. POST /v1/sessions preserves body["id"] / body["session_id"] verbatim, so two independent conversations may legitimately share a prefix and differ only in a trailing 32-hex identity. Stripping it merges them and makes their prompt_cache_key identical.
  2. Studio's own truncation boundary. groupRuntimeSessionId() slices the semantic prefix gc_run_<room>_<profile>_<name> to 96 characters before appending the per-response token, so two members of one room whose names diverge past that boundary are distinguished only by that token. There is nothing left to key on once it is removed — which also shows the churn cannot be repaired from the id alone.

This is the same failure class recorded during the logical-scope design in #79017: speculative id-regex normalization truncates legitimate identities and collides distinct owners. The accepted design resolves scope from semantic lineage, and the host that knows the logical conversation has to declare it.

What the churn issue still is

Every conversation-affinity hint Hermes sends is derived from the physical session id through one normalizer, so a per-response id re-keys all four on every reply:

Consumer Wire field Purpose
plugins/model-providers/openrouter body.session_id OpenRouter sticky routing key
plugins/model-providers/nous body.session_id Nous Portal sticky key
plugins/model-providers/openrouter x-grok-conv-id header pins xAI's prompt cache to one backend
agent/transports/codex.py, agent/transports/chat_completions.py prompt_cache_key OpenAI / Codex cache routing

Tracked in #96811 with both candidate contracts (host reuses a stable per-member session id; or Hermes honors an explicit logical key such as the existing gateway_session_key, with the #79161 fork isolation designed in).

On the reported write path

The two tests in tests/agent/test_system_prompt_restore.py reproduce the Studio bridge lifecycle (row pre-created and user message pre-persisted before run_conversation()) and record that update_system_prompt() already persists there, and that a second turn restores without a warning. They pass before and after this branch — documentation, not a guard. That is why the closure keyword is Refs, not Fixes.

%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#00f0ff', 'mainBkg': '#0a0a16', 'primaryTextColor': '#ffffff', 'primaryBorderColor': '#ff007f', 'lineColor': '#00f0ff'}}}%%
graph TD
    A[Physical Session Id] --> N{Cache Scope Normalizer}

    N -->|Cron per-fire timestamp<br/>the one carve-out| C[Stable Job Scope]
    N -->|Everything else<br/>kept verbatim| D[Identity Preserved]

    E[Rejected Rule<br/>Strip Trailing UUID Hex] -.->|Control 1<br/>API-supplied ids| F[Independent Conversations Merged]
    E -.->|Control 2<br/>Studio 96-char truncation| G[Distinct Members Merged]
    F --> H[Isolation Invariant Broken]
    G --> H

    D --> I[Per-Response Ids Still Churn<br/>OpenRouter · Nous · xAI · OpenAI]
    I --> J[Tracked In Follow-Up Issue<br/>Host Declares Logical Identity]
Loading

Related Issue

Fixes #96570
Fixes #96811

Type of Change

  • Test coverage / regression controls (no production behavior change)

Changes Made

  • tests/agent/test_prompt_cache_scope.pyTestPerResponseRunNonceIsolation: both review negative controls (test_external_uuid_identity_remains_isolated, test_studio_truncation_does_not_merge_members), cron as the only carve-out, the missing lineage on these rows, and the per-response churn recorded on all four affinity surfaces.
  • tests/agent/test_system_prompt_restore.pyTestPerResponseSessionWritePath: the Studio bridge lifecycle, showing the system prompt lands in the pre-created row and restores on the next turn.
  • agent/transports/codex.pyunchanged (the earlier hunk is reverted in cce4ffe0bf).

How to Test

scripts/run_tests.sh tests/agent/test_prompt_cache_scope.py tests/agent/test_system_prompt_restore.py tests/agent/transports tests/agent/test_codex_responses_adapter.py -q

Local results: 49 passed (test_prompt_cache_scope.py + test_system_prompt_restore.py), 330 passed (tests/agent/transports + test_codex_responses_adapter.py). ruff check clean on both changed files. Every test in this PR passes on unmodified main — they are controls and documentation, which is the point after the revert.

tests/agent/test_portal_tags.py::test_compress_context_preserves_ambient_context fails identically on unmodified main in my local environment (AttributeError: 'DaemonThreadPoolExecutor' object has no attribute '_initializer' under Python 3.14) — pre-existing and unrelated.

Checklist

Code

Documentation & Housekeeping

  • Relevant documentation updates are N/A — the invariant is documented in the test docstrings, at the code it guards
  • cli-config.yaml.example updates are N/A because no config keys changed
  • CONTRIBUTING.md and AGENTS.md updates are N/A because no architecture or workflow changed
  • I've considered cross-platform impact; tests only, no platform-specific code
  • Tool description/schema updates are N/A because no tool behavior changed

Embedding hosts that mint one physical session per RESPONSE — Hermes Studio
group chat builds gc_run_<room>_<profile>_<name>_<uuid4hex> for every reply
and destroys it when the reply completes — churn every conversation-affinity
hint Hermes sends, because all of them are derived from the physical session
id via _cache_scope_from_session_id():

  * OpenRouter / Nous sticky routing key (body.session_id)
  * xAI x-grok-conv-id (pins the prompt cache to one backend)
  * OpenAI / Codex prompt_cache_key

A fresh key per reply means the conversation never lands back on a warm
prefix, so every group-chat turn is billed as a full cache miss.

The normalizer already carried the same carve-out for cron's per-fire
timestamp, with a docstring asserting that every non-cron session id
identifies one conversation. A whole trailing UUID4 hex is the same class of
per-run token, so strip it too. Deliberately narrow: no Hermes-native id
shape ends in a bare 32-char hex (its own ids carry 6-, 8-, 10- or 16-char
slices, or a dashed str(uuid4())), so this can only reach ids a host stamped
with a whole UUID, and collapsing those runs is safe by construction — the
scope is a routing hint, never a correctness boundary.

The issue also reported that the gc run's update_system_prompt write never
persists. It does: the bridge pre-creates the row before run_conversation(),
so the UPDATE matches and the prompt lands. The "stored system prompt is
null" warning is a first-turn artifact of a pre-populated row (the NousResearch#45499
class), not a lost write — pinned by the new write-path tests, which pass
against unmodified production code.

Refs NousResearch#96570
@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 provider/openai OpenAI / Codex Responses API 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) labels Aug 28, 2026
@cervantesh

Copy link
Copy Markdown
Contributor

Thanks for separating the write-path finding from the cache-affinity finding. The new tests are useful, and they confirm that update_system_prompt() already persists under the Studio lifecycle. I did find a blocking scope-collision in the production change at exact main@6dcebea7fc vs head@385de99739.

The trailing-hex rule is not identity-safe

Hermes' public session API accepts a client-provided id / session_id and preserves it as the physical conversation identity:

if not self._browser_control_enabled():
return web.json_response(
_openai_error(
"Browser control is not enabled on this server.",

Using two valid independent IDs:

customer_chat_11111111111141118111111111111111
customer_chat_22222222222242228222222222222222

I get:

main@6dcebea7fc head@385de99739
normalized scopes distinct both customer_chat
prompt_cache_key with the same static prefix distinct identical

So a whole trailing 32-hex value is not "per-run noise by construction" for the API that consumes it. The regex also accepts any lowercase 32-hex token, not specifically a UUID4.

There is a second negative control in Studio's own producer shape. groupRuntimeSessionId() truncates the semantic prefix to 96 characters before appending the nonce:

https://github.com/EKKOLearnAI/hermes-studio/blob/4ae23c69bbf29a287206b9b2cc9def4b5e14cdf4/packages/server/src/modules/studio/services/group-chat/agent-clients.ts#L1269-L1273

For one ordinary-length room id and profile default, two member names sharing the first 66 characters but differing afterwards produce distinct physical ids because of their UUIDs, but this PR strips the only remaining distinguishing part and resolves both to the same scope. That directly contradicts the current test_distinct_rooms_and_members_stay_isolated; the test only covers short components. OpenRouter/Nous sticky session_id and x-grok-conv-id consume that normalized scope directly, so this control does not depend on identical system-prompt bytes.

This is also the same failure class already recorded during the logical-scope design in #79017: speculative id regex normalization truncated legitimate identities and collided distinct jobs, so the accepted design moved scope resolution to semantic lineage while preserving cross-scope isolation.

I think the cache-key churn finding is still worth addressing, but the host that knows the logical conversation needs to supply that identity explicitly (or another semantic owner needs to resolve it); globally inferring it from arbitrary session-id syntax is not safe. At minimum, the regression suite needs controls for:

  1. two independent external sessions whose ids share a prefix and end in different 32-hex identities; and
  2. two Studio logical identities that differ only beyond the producer's 96-character truncation boundary.

Separately, Fixes #96570 does not match what this PR proves: #96570 reports a lost update_system_prompt() write, while these tests show that write already succeeds and the production hunk addresses a different affinity-key issue. Refs #96570 plus a focused issue for the demonstrated key-churn contract would keep the closure semantics accurate.

@cervantesh

Copy link
Copy Markdown
Contributor

For completeness, here is the sanitized, copy-pasteable witness used for the comparison above. These assertions express the isolation invariant, so both pass on main@6dcebea7fc and both fail on head@385de99739:

from agent.transports.codex import (
    ResponsesApiTransport,
    _cache_scope_from_session_id,
)


def prompt_cache_key(session_id: str) -> str:
    return ResponsesApiTransport().build_kwargs(
        model="gpt-5.5",
        messages=[{"role": "system", "content": "same"}],
        tools=[],
        session_id=session_id,
    )["prompt_cache_key"]


def test_external_uuid_identity_remains_isolated():
    first = "customer_chat_11111111111141118111111111111111"
    second = "customer_chat_22222222222242228222222222222222"

    assert _cache_scope_from_session_id(first) != _cache_scope_from_session_id(second)
    assert prompt_cache_key(first) != prompt_cache_key(second)


def test_studio_truncation_does_not_merge_members():
    room = "mabc1234qwerty"
    profile = "default"
    common_name = "A" * 70

    first = (
        f"gc_run_{room}_{profile}_{common_name}Worker"[:96]
        + "_11111111111141118111111111111111"
    )
    second = (
        f"gc_run_{room}_{profile}_{common_name}Reviewer"[:96]
        + "_22222222222242228222222222222222"
    )

    assert first != second
    assert _cache_scope_from_session_id(first) != _cache_scope_from_session_id(second)

The first test covers the public external-session contract. The second uses Studio's producer ordering exactly: truncate the semantic prefix first, then append the per-response token. No provider credentials or live cache telemetry are required to reproduce the scope regression.

Review found the trailing-32-hex rule is not identity-safe. Two independent
conversations can legitimately end in different 32-hex tokens — the public
session API preserves a client-provided ``id``/``session_id`` verbatim — and
Studio's own producer truncates its semantic prefix to 96 characters BEFORE
appending the per-response token, so two members of one room can differ only
in that token. Collapsing it merges distinct conversations onto one affinity
key, the same failure class recorded on NousResearch#79017.

Revert the production hunk in agent/transports/codex.py and keep the tests as
regression controls: both negative controls from the review (external
client-supplied identities, Studio's truncation boundary), cron as the only
carve-out, the missing lineage on these rows, and the per-response key churn
on all four affinity surfaces recorded as the open contract gap.

Refs NousResearch#96570

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y73hL3D8R1RCFE6kuvj8Vg
@JoaoMarcos44 JoaoMarcos44 changed the title fix(cache): strip the per-response run nonce from the prompt-cache scope test(cache): pin the prompt-cache scope isolation invariant for per-response session ids Aug 28, 2026
@JoaoMarcos44

Copy link
Copy Markdown
Contributor Author

You're right on both controls, and I reverted the production hunk in cce4ffe0bf. Confirming the reasoning rather than just the verdict:

Control 1 — external identities. POST /v1/sessions takes raw_id = body.get("id") or body.get("session_id") and preserves it verbatim (only path-safety, control chars and length are checked), so a whole trailing 32-hex value is a legitimate identity for that API, not per-run noise. Your customer_chat_* pair merges under the rule.

Control 2 — Studio's truncation, and why it settles the whole approach. groupRuntimeSessionId() slices gc_run_<room>_<profile>_<name> to 96 characters before appending the token. With room="mabc1234qwerty", profile="default" and 70+ character names, the semantic prefix is already 100 characters, so both members truncate to the same 96 and the physical ids differ only in their UUIDs. That is stronger than a counterexample to my regex: it means the nonce is the sole carrier of identity for that shape, so no rule that drops it can be safe — narrowing to a gc_run_ namespace would not have rescued it either. It also points at a real producer-side bug: appending the token before truncating (or truncating the components) would keep the prefix distinguishing.

The #79017 parallel is the right one; I should have applied it to my own change.

What the branch now contains (tests only, agent/transports/codex.py back to main):

  • test_external_uuid_identity_remains_isolated and test_studio_truncation_does_not_merge_members — your two controls, verbatim in intent, as standing regression controls for the isolation invariant.
  • test_cron_normalization_stays_the_only_carve_out — cron's per-fire timestamp remains the single accepted exception.
  • test_lineage_walk_cannot_resolve_these_rows — the Studio rows carry no parent_session_id, so no semantic owner resolves them today.
  • test_affinity_keys_churn_per_response_today / test_provider_sticky_key_churns_per_response_today — the reported symptom recorded on all four affinity surfaces, as the contract a fix would have to satisfy. All of these pass on unmodified main.

Closure semantics fixed: Fixes #96570Refs #96570, and the demonstrated churn now has its own issue, #96811, with the two candidate contracts: the host reuses one stable session id per room member, or Hermes honors an explicit logical key — gateway_session_key ("stable per-chat key", agent/agent_init.py:686, fed by X-Hermes-Session-Key) already carries that meaning for gateway platforms and agent/prompt_cache_scope.py simply does not consult it. The part that needs design there is keeping the #79161 isolation for /branch children, delegate subagents, tool children and background-review forks, which inherit no such key today — not the plumbing.

Thanks for the sanitized witness; it reproduced without any provider credentials, which is what made the second control easy to confirm.

@cervantesh

Copy link
Copy Markdown
Contributor

Thanks for the thorough update. Reverting the production hunk fully addresses the identity-collision concern from my earlier review. The two new negative controls for external identities and Studio''s truncation boundary now preserve the durable isolation invariant we established.

I have one narrower scope question about the remaining test-only PR, rather than a new production-code objection.

As currently titled, the strict contract appears to be: preserve cache-scope isolation against unsafe inference from per-response session-id syntax. The following tests directly and durably support that contract:

  • test_external_uuid_identity_remains_isolated
  • test_studio_truncation_does_not_merge_members
  • the control preserving cron''s existing carve-out

Some of the other tests appear to serve a different purpose:

  • test_lineage_walk_cannot_resolve_these_rows
  • test_affinity_keys_churn_per_response_today
  • test_provider_sticky_key_churns_per_response_today

Those are useful evidence for #96811, but they assert the current missing-lineage/key-churn behavior. A correct implementation of #96811 would intentionally make those assertions fail, so they seem better suited as reproduction witnesses for that issue—or as RED tests inverted around its eventual stability contract—than as durable regression controls merged beforehand.

The TestPerResponseSessionWritePath tests are also useful and accurately document the #96570 lifecycle clarification, but they protect a separate prompt-persistence contract rather than the cache-scope isolation contract named by this PR. I do not see them as incorrect; I only think the PR should either state clearly that it owns both independent test contracts or keep the scopes separate.

One wording detail follows from the same evidence boundary: the tests demonstrate affinity-key churn, but not that the provider necessarily misses every warm prefix or bills every request uncached. Phrasing this as the routing/affinity mechanism changing each response would preserve the demonstrated finding without claiming provider telemetry that has not been measured.

Would it make sense to keep this PR focused on the durable isolation controls and leave the current-churn witnesses with #96811? That would preserve the value of the review findings without turning the defect that #96811 intends to fix into expected behavior.

Review follow-up on NousResearch#96768. Three changes, no assertion changed:

- The class docstring claimed the room "never lands back on a warm
  prefix". The tests demonstrate the routing/affinity key moving per
  response; they measure no provider cache telemetry and no billing
  outcome. Reworded to the demonstrated finding.

- test_lineage_walk_cannot_resolve_these_rows ->
  test_parentless_rows_resolve_to_their_own_scope. A row that declares
  no owner resolving to itself is the permanent invariant, not a
  snapshot of current behavior.

- test_affinity_keys_churn_per_response_today and
  test_provider_sticky_key_churns_per_response_today lose the "today"
  framing. They pin distinct-id -> distinct-key at the wire layer, which
  is the isolation invariant, and they survive NousResearch#96811: the logical
  identity is supplied one layer up (cache_scope_id, or the ambient
  conversation contextvar for provider profiles) and these call sites
  pass neither.
@JoaoMarcos44

Copy link
Copy Markdown
Contributor Author

Two of your three points are right and are now applied in 72cdd1c611. The third — that a #96811 fix would invert the churn assertions — I checked against the code and it does not hold, so I kept those tests and fixed what was actually wrong with them: their framing.

1. Evidence wording — agreed, fixed. The class docstring claimed the room "never lands back on a warm prefix". Nothing here measures provider cache telemetry or billing; the demonstrated finding is the routing/affinity key moving per response, and that is what it now says. Same bound added to the PR body.

2. Two independent contracts — agreed, now stated. The PR body carries an explicit table: test_prompt_cache_scope.py owns cache-scope isolation, test_system_prompt_restore.py owns system-prompt persistence under a per-response session. They share #96570 as an origin, not a mechanism.

While rewriting that section I found a real defect you did not flag: the body still read Fixes #96570 / Fixes #96811 despite my previous comment claiming I had switched to Refs. Merging would have auto-closed the follow-up issue this PR exists to open. Both are Refs now.

3. The churn witnesses do not invert. Concretely, build_kwargs derives the key from params.get("cache_scope_id") or session_id (agent/transports/codex.py:618), and the provider profiles from get_conversation_context() or session_id (plugins/model-providers/openrouter/__init__.py:137, Nous mirrors it). A #96811 fix supplies the logical identity through the first operand. These tests pass neither — they hand the transport two raw physical ids, exactly as an undeclared conversation does — so they keep asserting distinct-in → distinct-out:

no-logical-key       churn(a != b): True
with cache_scope_id  stable(c == d): True   # the fix, one layer up

test_parentless_rows_resolve_to_their_own_scope (renamed from test_lineage_walk_cannot_resolve_these_rows) is the same story on the resolver: the fixture agent is a SimpleNamespace(session_id, _session_db) with no declared key, so a resolver taught to honor gateway_session_key still walks it to the physical id.

So what those three tests actually pin is the isolation invariant restated at the wire layer — the same assertion shape as test_external_uuid_identity_remains_isolated, which uses the identical _prompt_cache_key helper. If some future change did invert them, it would have to be a syntax rule applied inside the transport, and it would take the two controls you asked to keep down with it. They fail together because they are one invariant.

What was genuinely wrong was that the names and docstrings said _today and "records the reported cost symptom", which reads as a defect snapshot and would rot into "expected behavior" precisely as you describe. Renamed and reframed:

Was Now
test_lineage_walk_cannot_resolve_these_rows test_parentless_rows_resolve_to_their_own_scope
test_affinity_keys_churn_per_response_today test_distinct_ids_keep_distinct_affinity_keys
test_provider_sticky_key_churns_per_response_today test_distinct_ids_keep_distinct_provider_sticky_keys

Each docstring now names the invariant first and the #96570 symptom second, and states where the logical identity has to arrive from instead.

The reproduction narrative belongs to #96811 either way, and I will carry it there in the issue body rather than leaving it to be inferred from test names here.

tests/agent/test_prompt_cache_scope.py + tests/agent/test_system_prompt_restore.py: 49 passed. No assertion changed in this commit — names, docstrings and the PR body only.

If you would still rather these three move out entirely, say so and I will cut them to #96811; I would just be trading a control that holds for one comment less.

@cervantesh

Copy link
Copy Markdown
Contributor

Thanks for checking this against the actual precedence paths. I agree with your adjudication of the third point: without a declared logical key, distinct physical identities must remain distinct; a #96811 solution supplies the semantic identity through the higher-precedence cache_scope_id / conversation context, so these fallback isolation assertions do not invert.

The renamed tests and revised docstrings now express that durable invariant rather than freezing "today's churn" as expected behavior. The explicit two-contract table, bounded telemetry wording, and Refs semantics also resolve my remaining scope concerns. No need to move those tests out from my side.

kshitijk4poor pushed a commit that referenced this pull request Aug 29, 2026
…esponse session ids

Squash of the three commits on PR #96768 (net diff is tests-only: the
mid-series production hunk in agent/transports/codex.py was reverted
within the PR after review). Pins the cache-scope isolation invariant
for hosts that mint one physical session per response, plus the
system-prompt write-path lifecycle under a per-response session (#96570).
@kshitijk4poor

Copy link
Copy Markdown
Contributor

Merged via the P0 batch salvage PR #97704 (rebase merge — your commit landed on main with your authorship preserved).

#97704

Thanks for the fix!

melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
…esponse session ids

Squash of the three commits on PR NousResearch#96768 (net diff is tests-only: the
mid-series production hunk in agent/transports/codex.py was reverted
within the PR after review). Pins the cache-scope isolation invariant
for hosts that mint one physical session per response, plus the
system-prompt write-path lifecycle under a per-response session (NousResearch#96570).
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 P0 Critical — data loss, security, crash loop provider/nous Nous Research API (OAuth) provider/openai OpenAI / Codex Responses API 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

4 participants