test(cache): pin the prompt-cache scope isolation invariant for per-response session ids - #96768
JoaoMarcos44 wants to merge 3 commits into
Conversation
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
|
Thanks for separating the write-path finding from the cache-affinity finding. The new tests are useful, and they confirm that The trailing-hex rule is not identity-safeHermes' public session API accepts a client-provided hermes-agent/gateway/platforms/api_server.py Lines 3454 to 3457 in 6dcebea Using two valid independent IDs: I get:
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. For one ordinary-length room id and profile 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:
Separately, |
|
For completeness, here is the sanitized, copy-pasteable witness used for the comparison above. These assertions express the isolation invariant, so both pass on 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
|
You're right on both controls, and I reverted the production hunk in Control 1 — external identities. Control 2 — Studio's truncation, and why it settles the whole approach. The #79017 parallel is the right one; I should have applied it to my own change. What the branch now contains (tests only,
Closure semantics fixed: Thanks for the sanitized witness; it reproduced without any provider credentials, which is what made the second control easy to confirm. |
|
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:
Some of the other tests appear to serve a different purpose:
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 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.
|
Two of your three points are right and are now applied in 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: While rewriting that section I found a real defect you did not flag: the body still read 3. The churn witnesses do not invert. Concretely,
So what those three tests actually pin is the isolation invariant restated at the wire layer — the same assertion shape as What was genuinely wrong was that the names and docstrings said
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.
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. |
|
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 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 |
…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).
…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).
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:
POST /v1/sessionspreservesbody["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 theirprompt_cache_keyidentical.groupRuntimeSessionId()slices the semantic prefixgc_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:
plugins/model-providers/openrouterbody.session_idplugins/model-providers/nousbody.session_idplugins/model-providers/openrouterx-grok-conv-idheaderagent/transports/codex.py,agent/transports/chat_completions.pyprompt_cache_keyTracked 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.pyreproduce the Studio bridge lifecycle (row pre-created and user message pre-persisted beforerun_conversation()) and record thatupdate_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 isRefs, notFixes.%%{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]Related Issue
Fixes #96570
Fixes #96811
Type of Change
Changes Made
tests/agent/test_prompt_cache_scope.py—TestPerResponseRunNonceIsolation: 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.py—TestPerResponseSessionWritePath: the Studio bridge lifecycle, showing the system prompt lands in the pre-created row and restores on the next turn.agent/transports/codex.py— unchanged (the earlier hunk is reverted incce4ffe0bf).How to Test
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 checkclean on both changed files. Every test in this PR passes on unmodifiedmain— they are controls and documentation, which is the point after the revert.tests/agent/test_portal_tags.py::test_compress_context_preserves_ambient_contextfails identically on unmodifiedmainin my local environment (AttributeError: 'DaemonThreadPoolExecutor' object has no attribute '_initializer'under Python 3.14) — pre-existing and unrelated.Checklist
Code
agent/conversation_loop.py; this PR touches no production file at all, so there is no overlap.Documentation & Housekeeping
cli-config.yaml.exampleupdates are N/A because no config keys changedCONTRIBUTING.mdandAGENTS.mdupdates are N/A because no architecture or workflow changed