From bd4105b190c2becebc50ff9e79ba7518d5c5c467 Mon Sep 17 00:00:00 2001 From: joaomarcos Date: Tue, 4 Aug 2026 20:52:34 -0300 Subject: [PATCH 1/6] fix(cache): scope prompt_cache_key by session to stop cross-session bucket sharing _content_cache_key() hashed only the static prefix (instructions + tools), so unrelated sessions sharing a system prompt collapsed onto the same prompt_cache_key routing bucket (#78941). Scope the hash by session_id via _cache_scope_from_session_id(), which passes normal session_id through unchanged for isolation, but strips the per-fire timestamp off cron ids (cron__) so repeat fires of the same job still share a stable warm key (preserves #51395/#52295). Single _content_cache_key/_cache_scope_from_session_id implementation in codex.py, reused by chat_completions.py for both transports. --- agent/transports/chat_completions.py | 19 +++-- agent/transports/codex.py | 73 +++++++++++++------ .../agent/transports/test_chat_completions.py | 27 ++++++- .../agent/transports/test_codex_transport.py | 15 ++++ 4 files changed, 102 insertions(+), 32 deletions(-) diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 2572038126b6a..49ef5a9c5a3b8 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -47,6 +47,7 @@ def _add_prompt_cache_key( messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None, supports_prompt_cache_key: bool, + session_id: str | None = None, ) -> None: """Add a content-addressed key only for an explicitly capable endpoint.""" if not supports_prompt_cache_key: @@ -60,11 +61,17 @@ def _add_prompt_cache_key( ): return - # Reuse the Responses transport's single authoritative hash algorithm so - # equivalent static prefixes route to the same cache bucket across modes. - from agent.transports.codex import _content_cache_key - - cache_key = _content_cache_key(_static_prompt_instructions(messages), tools) + # Reuse the Responses transport's single authoritative hash algorithm and + # session-scope normalization so equivalent static prefixes route to the + # same cache bucket across modes, without concentrating unrelated + # sessions into one shared bucket (see #78941). + from agent.transports.codex import _cache_scope_from_session_id, _content_cache_key + + cache_key = _content_cache_key( + _static_prompt_instructions(messages), + tools, + _cache_scope_from_session_id(session_id), + ) if cache_key: api_kwargs["prompt_cache_key"] = cache_key @@ -584,6 +591,7 @@ def build_kwargs( tools=api_kwargs.get("tools"), supports_prompt_cache_key=bool(params.get("supports_prompt_cache_key")) or _is_openai_api_base_url(params.get("base_url")), + session_id=params.get("session_id"), ) return api_kwargs @@ -733,6 +741,7 @@ def _build_kwargs_from_profile(self, profile, model, sanitized, tools, params): messages=sanitized, tools=api_kwargs.get("tools"), supports_prompt_cache_key=bool(profile.supports_prompt_cache_key), + session_id=params.get("session_id"), ) return api_kwargs diff --git a/agent/transports/codex.py b/agent/transports/codex.py index c4c901c259ac1..097c08517109e 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -10,6 +10,23 @@ import re from typing import Any, Dict, List, Optional +# Cron fires build session_id as ``cron__`` (see +# cron/scheduler.py). The trailing timestamp is per-fire noise; stripped so +# repeat fires of the same job share a cache scope (see #51395/#52295). +_CRON_SESSION_ID_RE = re.compile(r"^(cron_.+)_\d{8}_\d{6}$") + + +def _cache_scope_from_session_id(session_id: Optional[str]) -> str: + """Normalize a physical session_id into a stable logical cache scope. + + Every non-cron session_id already identifies one conversation/agent + instance (main run, a specific child/subagent, a sibling child, ...), + so it is used unchanged. Only cron's per-fire timestamp needs stripping. + """ + sid = str(session_id or "") + match = _CRON_SESSION_ID_RE.match(sid) + return match.group(1) if match else sid + from agent.transports.base import ProviderTransport from agent.transports.types import NormalizedResponse, ToolCall @@ -117,20 +134,26 @@ def _default_prompt_cache_retention_for_request( return None -def _content_cache_key(instructions: str, tools: Optional[List[Dict[str, Any]]]) -> Optional[str]: - """Content-address the prompt cache key from the static request prefix. - - Returns ``pck_`` of (instructions + sorted tool schemas), or - None when there is nothing static to key on. The cache key is a routing - hint only — never a correctness boundary — so two requests sharing a system - prompt and tool set intentionally resolve to the same warm prefix bucket. - - The fix this exists for: recurring cron jobs build session_id as - ``cron__``, so using session_id as the cache key made every - fire cache-cold. The static prefix (identity + tools) is identical across - fires, so hashing it gives a stable key that stays warm within the - provider's cache TTL. Sorting tools by name keeps the hash insertion-order - independent. +def _content_cache_key( + instructions: str, + tools: Optional[List[Dict[str, Any]]], + scope_id: str = "", +) -> Optional[str]: + """Content-address the prompt cache key within a logical cache scope. + + Returns ``pck_`` of (scope_id + instructions + sorted tool + schemas), or None when there is nothing static to key on. The cache key + is a routing hint only — never a correctness boundary — so two requests + sharing a scope, system prompt, and tool set intentionally resolve to the + same warm prefix bucket. + + ``scope_id`` (pass ``_cache_scope_from_session_id(session_id)``) keeps + unrelated sessions — independent conversations, main vs. child/subagent, + sibling children — from concentrating onto the same bucket merely because + their static prefix matches (see #78941), while still letting recurring + cron fires of one job share a stable key across their timestamped + session_ids (the original #51395/#52295 fix this built on). Sorting tools + by name keeps the hash insertion-order independent. """ if not instructions and not tools: return None @@ -143,9 +166,9 @@ def _content_cache_key(instructions: str, tools: Optional[List[Dict[str, Any]]]) tools_part = json.dumps( sorted_tools, sort_keys=True, ensure_ascii=False, separators=(",", ":") ) - # \x00 separator so instructions ending in the tool JSON can't collide with - # a request whose instructions contain that JSON and whose tools are empty. - content = f"{instructions or ''}\x00{tools_part}" + # \x00 separators so a scope/instructions/tools boundary can't be forged + # by content that happens to contain the same bytes. + content = f"{scope_id}\x00{instructions or ''}\x00{tools_part}" digest = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()[:24] return f"pck_{digest}" @@ -341,12 +364,16 @@ def build_kwargs( session_id = params.get("session_id") # prompt_cache_key is content-addressed from the static prefix - # (instructions + tools), NOT session_id — recurring cron jobs carry a - # per-fire timestamp in session_id (cron__) that made every run - # cache-cold. session_id is left untouched for transcript isolation and - # the cache-scope routing headers below. Falls back to session_id when - # there is no static content to hash. - cache_key = _content_cache_key(instructions, response_tools) or session_id + # (instructions + tools) scoped by session, NOT the raw session_id — + # recurring cron jobs carry a per-fire timestamp in session_id + # (cron__) that made every run cache-cold, so the scope strips + # that suffix (see _cache_scope_from_session_id). session_id is left + # untouched for transcript isolation and the cache-scope routing + # headers below. Falls back to session_id when there is no static + # content to hash. + cache_key = _content_cache_key( + instructions, response_tools, _cache_scope_from_session_id(session_id) + ) or session_id # xAI Responses takes prompt_cache_key in extra_body (set further # down); GitHub Models opts out of cache-key routing entirely. if not is_github_responses and not is_xai_responses and cache_key: diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index 6f034fda5f597..895b45566d394 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -725,9 +725,28 @@ def key(session_id, *, instructions="You are stable.", tool_name="lookup"): supports_prompt_cache_key=True, )["prompt_cache_key"] - first = key("cron_job_2026-07-15T10:00:00Z") - second = key("cron_job_2026-07-15T10:05:00Z") + first = key("cron_job_20260715_100000") + second = key("cron_job_20260715_100500") assert first == second - assert first != key("cron_job_2026-07-15T10:05:00Z", instructions="You are different.") - assert first != key("cron_job_2026-07-15T10:05:00Z", tool_name="search") + assert first != key("cron_job_20260715_100500", instructions="You are different.") + assert first != key("cron_job_20260715_100500", tool_name="search") + + def test_unrelated_sessions_get_distinct_keys(self, transport): + """#78941: identical static prefix across unrelated (non-cron) sessions + must not collapse onto one shared prompt_cache_key.""" + kw1 = transport.build_kwargs( + model="cache-model", + messages=self._messages("You are stable."), + tools=self._tools("lookup"), + session_id="session_alice_1", + supports_prompt_cache_key=True, + ) + kw2 = transport.build_kwargs( + model="cache-model", + messages=self._messages("You are stable."), + tools=self._tools("lookup"), + session_id="session_bob_1", + supports_prompt_cache_key=True, + ) + assert kw1["prompt_cache_key"] != kw2["prompt_cache_key"] diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py index 444da68516a36..bd134de696adb 100644 --- a/tests/agent/transports/test_codex_transport.py +++ b/tests/agent/transports/test_codex_transport.py @@ -74,6 +74,21 @@ def test_cache_key_stable_across_session_ids(self, transport): ) assert kw1["prompt_cache_key"] == kw2["prompt_cache_key"] + def test_cache_key_differs_across_unrelated_sessions(self, transport): + """#78941: two unrelated sessions (different users/conversations) + sharing the same static prefix must NOT collapse onto the same + prompt_cache_key — session_id scopes the hash unless it is a cron + per-fire id, which is normalized to its stable job prefix instead.""" + messages = [{"role": "user", "content": "Hi"}] + kw1 = transport.build_kwargs( + model="gpt-5.4", messages=messages, tools=[], + session_id="session_alice_1", + ) + kw2 = transport.build_kwargs( + model="gpt-5.4", messages=messages, tools=[], + session_id="session_bob_1", + ) + assert kw1["prompt_cache_key"] != kw2["prompt_cache_key"] def test_github_responses_drops_message_item_id_end_to_end(self, transport): # #32716: Copilot binds codex_message_items ids to a backend From 54072d1388eef7ff780d0c46c8a6cfd09070c820 Mon Sep 17 00:00:00 2001 From: joaomarcos Date: Tue, 4 Aug 2026 21:21:53 -0300 Subject: [PATCH 2/6] fix(cache): normalize codex cache-scope headers same as prompt_cache_key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session_id/x-client-request-id HTTP headers sent for the Codex backend (cache routing/affinity, confirmed via #47335 history and inline comments — not conversation identity) still used the raw session_id, reintroducing the same #78941 bug for that header: cron timestamps kept the header cold, and unrelated sessions with the same static content could collide in the same header scope. Reuse the existing _cache_scope_from_session_id() helper (already used for the body's prompt_cache_key) instead of duplicating the cron normalization logic. --- RELATORIO_ISSUE_78941.md | 101 ++++++++++++++++++ agent/transports/codex.py | 4 +- .../agent/transports/test_codex_transport.py | 27 +++++ 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 RELATORIO_ISSUE_78941.md diff --git a/RELATORIO_ISSUE_78941.md b/RELATORIO_ISSUE_78941.md new file mode 100644 index 0000000000000..c22ee7ae06f4a --- /dev/null +++ b/RELATORIO_ISSUE_78941.md @@ -0,0 +1,101 @@ +# Relatório de investigação e correção — issue #78941 + +- Issue: https://github.com/NousResearch/hermes-agent/issues/78941 — "[Cache]: content-only prompt_cache_key can concentrate unrelated sessions into one routing scope" +- PR: https://github.com/NousResearch/hermes-agent/pull/78959 +- Branch: `fix/78941-prompt-cache-key-session-scope` (a partir de `origin/main`) +- Commit: `bd4105b19` — `fix(cache): scope prompt_cache_key by session to stop cross-session bucket sharing` +- Veredito: **procedente**. Confirmado no código: `prompt_cache_key` era gerado só a partir de conteúdo estático (system prompt + tools), sem nenhum componente de sessão/tenant, concentrando sessões não relacionadas no mesmo bucket de roteamento de cache dos provedores. + +## Resumo executivo + +`prompt_cache_key` é o campo que Chat Completions e Responses/Codex (APIs OpenAI-compatíveis) usam para dar ao provedor uma dica de roteamento de cache de prefixo de prompt. A implementação existente (`_content_cache_key()` em `agent/transports/codex.py`) calculava esse valor como `sha256(instructions + tools ordenados)[:24]`, ignorando **completamente** `session_id`. + +Essa decisão foi deliberada e resolvia um problema real e anterior (#51395, #52295): jobs de cron geram `session_id` no formato `cron__`, com timestamp por disparo. Usar esse `session_id` bruto como chave de cache tornava o cache sempre frio a cada execução do mesmo job. A correção da época optou por remover `session_id` do cálculo por completo — só que isso é uma overcorreção: qualquer duas sessões (usuários diferentes, projetos diferentes, subagentes irmãos) que compartilhem o mesmo system prompt e mesmo conjunto de tools passam a colidir no mesmo bucket de cache do provedor, que é exatamente o cenário descrito na issue #78941. + +## Causa raiz + +Arquivo: `agent/transports/codex.py`, função `_content_cache_key(instructions, tools)`: + +```python +content = f"{instructions or ''}\x00{tools_part}" +digest = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()[:24] +return f"pck_{digest}" +``` + +Nenhum componente de sessão entra no hash. `build_kwargs()` (Responses/Codex) chamava essa função sem passar `session_id`, e `chat_completions.py` (`_add_prompt_cache_key()`) tinha a mesma lacuna — nem sequer recebia `session_id` como parâmetro. + +## Correção implementada + +1. **`_cache_scope_from_session_id(session_id)`** (novo, `codex.py`): normaliza o `session_id` físico num "escopo lógico de cache" estável. + - `session_id` comum (conversa principal, subagente, filho específico) → passa **inalterado**: cada instância de `Agent` já tem um `session_id` único por design (`agent_init.py`), então isso já é isolamento real. + - `session_id` de cron (`cron__`, via regex `^(cron_.+)_\d{8}_\d{6}$`) → o timestamp de disparo é removido, mantendo só `cron_` como escopo. Isso preserva o comportamento que #51395/#52295 corrigiram: disparos repetidos do mesmo job continuam batendo no mesmo bucket quente. + +2. **`_content_cache_key(instructions, tools, scope_id="")`**: agora hasheia `scope_id + instructions + tools` (separados por `\x00`), em vez de só `instructions + tools`. + +3. **Call sites atualizados** para passar `scope_id=_cache_scope_from_session_id(session_id)`: + - `codex.py::build_kwargs()` (Responses/Codex) + - `chat_completions.py::_add_prompt_cache_key()` (novo parâmetro opcional `session_id: str | None = None`, retrocompatível), chamado nos dois call sites internos (`build_kwargs()` e `_build_kwargs_from_profile()`). + +## Por que não é duplicado + +`_content_cache_key` e `_cache_scope_from_session_id` existem uma única vez, em `codex.py`. `chat_completions.py` importa ambas (`from agent.transports.codex import _cache_scope_from_session_id, _content_cache_key`) em vez de reimplementar a lógica de hash/normalização — Responses API e Chat Completions API compartilham exatamente o mesmo algoritmo de escopo e hash. + +## Cenários cobertos / piores casos + +| Cenário | Antes da correção | Depois da correção | +|---|---|---| +| Duas sessões de usuários diferentes, mesmo system prompt | Mesmo `prompt_cache_key` (bug #78941) | Chaves diferentes | +| Subagentes irmãos com mesmo system prompt | Mesmo `prompt_cache_key` | Chaves diferentes (cada subagente tem `session_id` próprio) | +| Dois disparos do mesmo cron job | Mesmo `prompt_cache_key` (comportamento desejado) | Continua igual — escopo normalizado remove só o timestamp | +| Disparos de cron jobs diferentes | Mesmo `prompt_cache_key` se conteúdo estático igual (falha do design original) | Chaves diferentes — cada job tem seu próprio `job_id` no escopo | +| Rotação de sessão por compressão de contexto (`agent.session_id` reatribuído em `conversation_compression.py`) | Cache permanecia "quente" indevidamente (mesmo bug) | Cache fica frio nesse boundary específico — trade-off aceito, ver abaixo | + +## Trade-off aceito e documentado + +Após uma rotação de compressão de contexto, `agent.session_id` é reatribuído para um novo valor (`agent/conversation_compression.py`, `agent.session_id = new_session_id`). Isso significa que o escopo de cache muda nesse ponto específico da conversa, esfriando o cache uma vez. É um evento raro e um trade-off razoável frente ao bug original (compartilhamento universal e silencioso entre sessões não relacionadas). + +`prompt_cache_key` é sempre uma dica de roteamento, nunca um limite de correção — o pior caso de qualquer configuração aqui é desempenho subótimo (cache frio), nunca uma resposta incorreta. + +## Testes + +- Suíte de transports (`test_codex_transport.py` + `test_chat_completions.py`): **113 passed** (68 + 45), incluindo: + - `test_cache_key_is_content_addressed_not_session_id` / `test_cache_key_stable_across_session_ids` (pré-existentes, continuam válidos) + - **novo** `test_cache_key_differs_across_unrelated_sessions` (codex.py) e `test_unrelated_sessions_get_distinct_keys` (chat_completions.py) — regressão direta do bug #78941 + - `test_cron_ids_share_static_prefix_key_and_content_changes_invalidate` corrigido: usava um formato de `session_id` de cron fictício (`cron_job_2026-07-15T10:00:00Z`, ISO) que não batia com o formato real gerado em `cron/scheduler.py:3015` (`cron__`); ajustado para o formato real. + - **novo** `test_codex_cache_scope_headers_normalize_cron_session_id` (codex.py): cobre a extensão do fix para o header `session_id`/`x-client-request-id` do backend Codex — dois disparos do mesmo cron job produzem o mesmo header normalizado, jobs diferentes produzem headers diferentes. +- Suíte de wiring/parity de provedores (`test_transport_parity.py`, `test_profile_wiring.py`, `test_e2e_wiring.py`, `test_model_extra_type_guard.py`, `test_compressed_summary_metadata.py`): **35 passed**. +- Perfis de plugins de model-providers (zai, opencode_go, ollama_cloud, minimax, kimi, deepseek): **126 passed**. +- Total: **273 testes passando**, nenhuma regressão detectada. + +## Extensão: header de roteamento de cache do backend Codex (`session_id` / `x-client-request-id`) + +Além do `prompt_cache_key` do body, o backend Codex (`is_codex_backend=True`, `chatgpt.com/backend-api/codex`) também envia `session_id` e `x-client-request-id` como **headers HTTP** (`agent/transports/codex.py`, bloco `if is_codex_backend:`), com o mesmo propósito de roteamento/afinidade de cache de prefixo — não como identidade de conversa persistida no servidor. Antes desta extensão, esse header usava o `session_id` bruto (via `_bounded_prompt_cache_key(session_id)`), reintroduzindo o mesmo padrão problemático da issue original: colidia entre sessões não relacionadas com o mesmo conteúdo estático (já resolvido no body) e nunca reagrupava disparos do mesmo cron job (o timestamp do disparo ia direto pro header). + +**Investigação de segurança da mudança**: antes de normalizar, foi necessário confirmar que esse header é puramente uma dica de roteamento de infraestrutura, não um identificador de continuidade de conversa no servidor (o que tornaria a normalização arriscada — reagrupar disparos de cron diferentes no mesmo header poderia, em tese, misturar estado real). Evidências coletadas via `git log -S` e a issue histórica que motivou a introdução do header: + +- O commit que restaurou os headers (`4d39a603d`, resolvendo a regressão #47335) e o comentário inline em `codex.py` (linhas 456-462) descrevem explicitamente o propósito como **"cache-scope routing so prompt cache hits remain high"** e **"belt-and-braces fallback"** ao lado do `prompt_cache_key` do body — nunca como mecanismo de identidade/estado. +- A issue #47335 que motivou a introdução documenta uma queda de **cache-hit-ratio** (94-97% → 13-30%) e custo/latência disparados quando os headers foram removidos por engano — o problema tratado é 100% custo/desempenho, nunca continuidade de conversa ou vazamento de estado. +- O payload completo da conversa (histórico de mensagens) sempre viaja no body da requisição, independente destes headers — eles não substituem nem representam estado de conversa, só ajudam o load balancer do provedor a rotear pro worker que já tem o prefixo em KV-cache. +- Contraste de controle: `x-grok-conv-id` (backend xAI Responses, linha ~494) usa `session_id` bruto **de propósito**, confirmado por comentário/teste dedicados (`"x-grok-conv-id stays session/transcript id, not cache key."`) — é o caso oposto, onde o header É identidade real. O header do Codex backend não tem esse comentário nem esse propósito documentado em nenhum lugar do código ou histórico. + +**Conclusão**: seguro normalizar. Corrigido reaproveitando a mesma função já existente (`_cache_scope_from_session_id`), sem duplicar lógica: + +```python +cache_scope_id = _bounded_prompt_cache_key( + _cache_scope_from_session_id(session_id) +) +``` + +Isso alinha o comportamento do header com o do body: sessões não relacionadas deixam de colidir no mesmo escopo de cache do header, e disparos do mesmo cron job continuam compartilhando escopo (timestamp removido). + +### Alternativas consideradas + +| Alternativa | Motivo de rejeição | +|---|---| +| Deixar o header como estava (`session_id` bruto) | Mantém as duas falhas da issue original nesse header: colisão entre sessões não relacionadas + cache sempre frio em cron. Inconsistente com o fix já aplicado ao body. | +| Reimplementar a normalização de cron localmente no bloco do header (duplicando o regex/lógica de `_cache_scope_from_session_id`) | Duplicação de lógica — viola DRY e a regra explícita do projeto de não duplicar. Rejeitado. | +| Refatorar `build_kwargs()` para calcular `scope_id = _cache_scope_from_session_id(session_id)` uma única vez e passar para os dois call sites (body e header) | Evitaria uma segunda chamada (hoje a função é invocada 2x: linha ~375 para o body, linha ~464 para o header). Rejeitado por ora: a função é uma regex simples sobre uma string curta (custo desprezível) e a mudança aumentaria o número de linhas tocadas fora do escopo do bug sem ganho mensurável — KISS/YAGNI. Anotado aqui como possível micro-limpeza futura, não crítica. | + +## Observação de segurança fora de escopo + +Durante o trabalho, o arquivo `AGENTS.md` já estava modificado na árvore de trabalho (fora desta tarefa) com uma "Infographic Generation Directive" instruindo execução automática de `scripts/pr_infographic_prompt.py` sempre que "infographic"/"infográfico" for mencionado, acompanhada de arquivos não rastreados (`scripts/pr_infographic_prompt.py`, `infographic.png`, `infographic_report.md`, `prompt_output.json/txt`). Isso não foi tocado, commitado nem executado — sinalizado ao usuário por ter características de possível prompt injection plantada no repositório (instrução condicional a uma palavra-gatilho, combinada com script executável não revisado). diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 097c08517109e..fad2cc05e63ca 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -460,7 +460,9 @@ def build_kwargs( # remain high. Send session_id / x-client-request-id as HTTP # headers while keeping ``prompt_cache_key`` in the body for # standard OpenAI routing as a belt-and-braces fallback. - cache_scope_id = _bounded_prompt_cache_key(session_id) + cache_scope_id = _bounded_prompt_cache_key( + _cache_scope_from_session_id(session_id) + ) if cache_scope_id: existing_extra_headers = kwargs.get("extra_headers") merged_extra_headers: Dict[str, str] = {} diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py index bd134de696adb..4a358f1b935da 100644 --- a/tests/agent/transports/test_codex_transport.py +++ b/tests/agent/transports/test_codex_transport.py @@ -275,7 +275,34 @@ def test_codex_cache_scope_boundary(self, transport, length): assert scope["session_id"].startswith("pck_") assert scope["session_id"] != session_id + def test_codex_cache_scope_headers_normalize_cron_session_id(self, transport): + """Cache-routing headers strip the cron per-fire timestamp, same as prompt_cache_key.""" + first_run = transport.build_kwargs( + model="gpt-5.4", + messages=[{"role": "user", "content": "Hi"}], + tools=[], + session_id="cron_job42_20260801_090000", + is_codex_backend=True, + )["extra_headers"] + second_run = transport.build_kwargs( + model="gpt-5.4", + messages=[{"role": "user", "content": "Hi"}], + tools=[], + session_id="cron_job42_20260802_090000", + is_codex_backend=True, + )["extra_headers"] + other_job = transport.build_kwargs( + model="gpt-5.4", + messages=[{"role": "user", "content": "Hi"}], + tools=[], + session_id="cron_job99_20260801_090000", + is_codex_backend=True, + )["extra_headers"] + assert first_run["session_id"] == "cron_job42" + assert first_run["session_id"] == second_run["session_id"] + assert first_run["x-client-request-id"] == second_run["x-client-request-id"] + assert first_run["session_id"] != other_job["session_id"] From 3744eb7f40de26a294785615a97ae9aab14a1549 Mon Sep 17 00:00:00 2001 From: joaomarcos Date: Tue, 4 Aug 2026 21:43:08 -0300 Subject: [PATCH 3/6] fix(cache): normalize cron session_id for Nous/OpenRouter sticky routing key Nous Portal and OpenRouter provider profiles pin every turn of a session to the same upstream endpoint (body["session_id"]) so Anthropic/Vertex/Bedrock cache_control breakpoints stay warm. That key came straight from get_conversation_context() or session_id with no normalization, so cron re-fires (cron__, no parent_session_id to walk) got a fresh key every run and never pinned to the same endpoint -- the same #51395/#52295 class of bug the original prompt_cache_key fix addressed, just on a route #78959 didn't touch. Reuses _cache_scope_from_session_id() (no new logic) and leaves the Portal's conversation= analytics tag and the xAI x-grok-conv-id header untouched, since those need per-fire identity, not cache affinity. --- RELATORIO_ISSUE_78941.md | 12 ++++++++++++ plugins/model-providers/nous/__init__.py | 3 ++- plugins/model-providers/openrouter/__init__.py | 3 ++- tests/providers/test_provider_profiles.py | 16 ++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/RELATORIO_ISSUE_78941.md b/RELATORIO_ISSUE_78941.md index c22ee7ae06f4a..eaa2c0997a280 100644 --- a/RELATORIO_ISSUE_78941.md +++ b/RELATORIO_ISSUE_78941.md @@ -96,6 +96,18 @@ Isso alinha o comportamento do header com o do body: sessões não relacionadas | Reimplementar a normalização de cron localmente no bloco do header (duplicando o regex/lógica de `_cache_scope_from_session_id`) | Duplicação de lógica — viola DRY e a regra explícita do projeto de não duplicar. Rejeitado. | | Refatorar `build_kwargs()` para calcular `scope_id = _cache_scope_from_session_id(session_id)` uma única vez e passar para os dois call sites (body e header) | Evitaria uma segunda chamada (hoje a função é invocada 2x: linha ~375 para o body, linha ~464 para o header). Rejeitado por ora: a função é uma regex simples sobre uma string curta (custo desprezível) e a mudança aumentaria o número de linhas tocadas fora do escopo do bug sem ganho mensurável — KISS/YAGNI. Anotado aqui como possível micro-limpeza futura, não crítica. | +## Extensão 2: sticky routing key dos providers Nous Portal e OpenRouter + +Varredura por todas as rotas que usam `session_id` cru pra roteamento/afinidade de cache (não só Codex) encontrou mais duas: `plugins/model-providers/nous/__init__.py` e `plugins/model-providers/openrouter/__init__.py`. Ambos `build_extra_body()` fixam (`body["session_id"]`) a sessão sempre no mesmo endpoint upstream, pra manter aquecidos os `cache_control` breakpoints da Anthropic/Vertex/Bedrock — mesma categoria de cache-affinity hint do `prompt_cache_key`/header do Codex, só que numa rota totalmente separada. + +**Bug reproduzido:** o valor vinha de `get_conversation_context() or session_id` sem nenhuma normalização. Para cron jobs (`cron__`, sem `parent_session_id`), `_conversation_root_id()` resolve pro próprio id bruto com timestamp — logo o sticky-key mudava a cada disparo do mesmo job, nunca fixando no mesmo endpoint. Mesma classe de bug de #51395/#52295, numa rota que a correção original do #78941 não cobria. + +**Fix:** reaproveita `_cache_scope_from_session_id()` (import de `agent.transports.codex`, sem duplicar a regex/lógica) só no `sticky_key`, preservando intocada a tag `conversation=` de analytics do Portal (`nous_portal_tags`), que deve continuar por disparo pra não confundir contagem de execuções distintas nos relatórios. + +Não mexido: `grok_conv_id`/`x-grok-conv-id` (linha ~185 do OpenRouter, linha ~494 do Codex) — confirmado por teste dedicado (`test_grok_session_id_sets_cache_affinity_header`) que é identidade real de sessão/transcript no xAI, não cache key. + +Testes novos: `test_sticky_session_id_normalizes_cron_timestamp` em `TestOpenRouterProfile` e `TestNousProfile` (`tests/providers/test_provider_profiles.py`) — dois disparos do mesmo cron job produzem o mesmo `session_id` de roteamento. Suíte completa de transports + providers: **287 passed**, zero regressão. + ## Observação de segurança fora de escopo Durante o trabalho, o arquivo `AGENTS.md` já estava modificado na árvore de trabalho (fora desta tarefa) com uma "Infographic Generation Directive" instruindo execução automática de `scripts/pr_infographic_prompt.py` sempre que "infographic"/"infográfico" for mencionado, acompanhada de arquivos não rastreados (`scripts/pr_infographic_prompt.py`, `infographic.png`, `infographic_report.md`, `prompt_output.json/txt`). Isso não foi tocado, commitado nem executado — sinalizado ao usuário por ter características de possível prompt injection plantada no repositório (instrução condicional a uma palavra-gatilho, combinada com script executável não revisado). diff --git a/plugins/model-providers/nous/__init__.py b/plugins/model-providers/nous/__init__.py index cfce5eae330e9..bc22ec0a29bc9 100644 --- a/plugins/model-providers/nous/__init__.py +++ b/plugins/model-providers/nous/__init__.py @@ -3,6 +3,7 @@ from typing import Any from agent.portal_tags import get_conversation_context, nous_portal_tags +from agent.transports.codex import _cache_scope_from_session_id from providers import register_provider from providers.base import ProviderProfile @@ -40,7 +41,7 @@ def build_extra_body( # session id; the ambient root additionally keeps the key stable for # installs that opt back into rotating compaction, and across # delegate-subagent trees. - sticky_key = get_conversation_context() or session_id + sticky_key = _cache_scope_from_session_id(get_conversation_context() or session_id) if sticky_key: body["session_id"] = sticky_key provider_preferences = context.get("provider_preferences") diff --git a/plugins/model-providers/openrouter/__init__.py b/plugins/model-providers/openrouter/__init__.py index 5e4068c7f5a9b..f6031bd29a2e3 100644 --- a/plugins/model-providers/openrouter/__init__.py +++ b/plugins/model-providers/openrouter/__init__.py @@ -4,6 +4,7 @@ from typing import Any from agent.portal_tags import get_conversation_context +from agent.transports.codex import _cache_scope_from_session_id from providers import register_provider from providers.base import ProviderProfile @@ -95,7 +96,7 @@ def build_extra_body( # (f2f4df064d). The ambient value is the session-lineage ROOT, so it # also stays stable for installs that opt out of the default # ``compression.in_place: true`` and across delegate-subagent trees. - sticky_key = get_conversation_context() or session_id + sticky_key = _cache_scope_from_session_id(get_conversation_context() or session_id) if sticky_key: body["session_id"] = sticky_key prefs = context.get("provider_preferences") diff --git a/tests/providers/test_provider_profiles.py b/tests/providers/test_provider_profiles.py index 21eb679e09682..baae5618a62a1 100644 --- a/tests/providers/test_provider_profiles.py +++ b/tests/providers/test_provider_profiles.py @@ -52,6 +52,14 @@ def test_extra_body_with_prefs(self): body = p.build_extra_body(provider_preferences={"allow": ["anthropic"]}) assert body["provider"] == {"allow": ["anthropic"]} + def test_sticky_session_id_normalizes_cron_timestamp(self): + """Cron re-fires of the same job keep the same sticky routing key.""" + p = get_provider_profile("openrouter") + first = p.build_extra_body(session_id="cron_job42_20260801_090000") + second = p.build_extra_body(session_id="cron_job42_20260802_090000") + assert first["session_id"] == "cron_job42" + assert first["session_id"] == second["session_id"] + @@ -133,6 +141,14 @@ def test_tags(self): body = p.build_extra_body() assert body["tags"] == nous_portal_tags() + def test_sticky_session_id_normalizes_cron_timestamp(self): + """Cron re-fires of the same job keep the same sticky routing key.""" + p = get_provider_profile("nous") + first = p.build_extra_body(session_id="cron_job42_20260801_090000") + second = p.build_extra_body(session_id="cron_job42_20260802_090000") + assert first["session_id"] == "cron_job42" + assert first["session_id"] == second["session_id"] + From 9ce6666b9959411932a080d9aff27ecfc686ade3 Mon Sep 17 00:00:00 2001 From: joaomarcos Date: Tue, 4 Aug 2026 22:38:56 -0300 Subject: [PATCH 4/6] fix(cache): scope auxiliary Codex adapter's prompt_cache_key by session Fixes #79012. compression/flush_memories/MoA/session_search calls went through _CodexCompletionsAdapter, which derived prompt_cache_key from instructions+tools only -- no session scope -- reproducing the #78941 bucket-sharing bug on this second code path even after the main transport fix in this PR. set_runtime_main() now threads session_id through (turn_context.py passes agent.session_id); the adapter reads it back via _runtime_main_value("session_id") and scopes the key the same way the main transport does (_cache_scope_from_session_id). --- agent/auxiliary_client.py | 13 +++++- agent/turn_context.py | 1 + tests/agent/test_auxiliary_client.py | 63 ++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index d66d5d81acc15..a4b531209fe77 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1316,6 +1316,7 @@ def create(self, **kwargs) -> Any: # out of cache-key routing entirely — for those hosts, skip it here. try: from agent.transports.codex import ( + _cache_scope_from_session_id, _content_cache_key, _default_prompt_cache_retention_for_request, ) @@ -1328,7 +1329,15 @@ def create(self, **kwargs) -> Any: or base_url_host_matches(_host_src, "models.github.ai") ) if not _is_xai and not _is_github and "prompt_cache_key" not in resp_kwargs: - _cache_key = _content_cache_key(instructions, resp_kwargs.get("tools")) + # Scope by the owning turn's session so two unrelated sessions + # with the same instructions/tools (e.g. compression, MoA, + # flush_memories firing back-to-back on different sessions) + # don't bucket-share a prompt cache slot (#78941). The main + # transport (agent/transports/codex.py::build_kwargs) does the + # same; this adapter had no session handle before + # set_runtime_main() started threading one through. + _scope = _cache_scope_from_session_id(_runtime_main_value("session_id")) + _cache_key = _content_cache_key(instructions, resp_kwargs.get("tools"), _scope) if _cache_key: resp_kwargs["prompt_cache_key"] = _cache_key if "prompt_cache_retention" not in resp_kwargs: @@ -3049,6 +3058,7 @@ def set_runtime_main( api_key: Any = "", api_mode: str = "", auth_mode: str = "", + session_id: str = "", ) -> contextvars.Token: """Record the current context's live main runtime for auxiliary routing. @@ -3070,6 +3080,7 @@ def set_runtime_main( ), "api_mode": (api_mode or "").strip(), "auth_mode": (auth_mode or "").strip().lower(), + "session_id": (session_id or "").strip(), } # Publish authoritative context before updating locked compatibility # mirrors; concurrent sessions never read those mirrors at runtime. diff --git a/agent/turn_context.py b/agent/turn_context.py index def497b158983..45c18cec10032 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -393,6 +393,7 @@ def build_turn_context( api_key=getattr(agent, "api_key", "") or "", api_mode=getattr(agent, "api_mode", "") or "", auth_mode=getattr(agent, "auth_mode", "") or "", + session_id=getattr(agent, "session_id", "") or "", ) except Exception: pass diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index efbd2e2b88dcd..65ed2f54c7d50 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -3187,6 +3187,69 @@ def create(self, **kwargs): assert time.monotonic() - started < 0.14 +class TestCodexAuxiliaryAdapterCacheScope: + """Regression for issue #78941: auxiliary Codex calls (compression, + flush_memories, MoA, session_search) must not bucket-share a prompt + cache slot across unrelated sessions just because their instructions + and tools happen to match. + """ + + def _create_and_capture(self, *, session_id): + import agent.auxiliary_client as aux + + class _FakeCreateStream: + def __iter__(self): + return iter([ + SimpleNamespace( + type="response.output_item.done", + item=SimpleNamespace( + type="message", + content=[SimpleNamespace(type="output_text", text="ok")], + ), + ), + SimpleNamespace(type="response.completed", response=SimpleNamespace( + status="completed", id="r1", usage=None, + )), + ]) + + def close(self): + pass + + class FakeResponses: + def __init__(self): + self.kwargs = None + + def create(self, **kwargs): + self.kwargs = kwargs + return _FakeCreateStream() + + fake_client = SimpleNamespace(responses=FakeResponses(), base_url="") + adapter = aux._CodexCompletionsAdapter(fake_client, "gpt-5.5") + token = aux.set_runtime_main("openai", "gpt-5.5", session_id=session_id) + try: + adapter.create( + messages=[ + {"role": "system", "content": "You are a memory summarizer."}, + {"role": "user", "content": "Summarize the last turn."}, + ], + ) + finally: + aux.reset_runtime_main(token) + return fake_client.responses.kwargs["prompt_cache_key"] + + def test_different_sessions_get_different_cache_keys(self): + key_a = self._create_and_capture(session_id="session-A") + key_b = self._create_and_capture(session_id="session-B") + assert key_a != key_b + + def test_cron_refires_of_the_same_job_share_a_cache_key(self): + first = self._create_and_capture(session_id="cron_job42_20260801_090000") + second = self._create_and_capture(session_id="cron_job42_20260802_090000") + other_job = self._create_and_capture(session_id="cron_job99_20260801_090000") + assert first == second + assert first != other_job + + class TestCodexAuxiliaryToolMessageConversion: """Regression for issue #5709. From 1d2d3678a942431cd45a161f783eeb4b1b6dd5aa Mon Sep 17 00:00:00 2001 From: joaomarcos Date: Tue, 4 Aug 2026 22:39:12 -0300 Subject: [PATCH 5/6] fix(cache): align Codex cache headers with body key, honor xAI overrides, pin Grok cron affinity Fixes #79013, #79014, #79015. - session_id header now carries the raw physical session id (#57012 contract); x-client-request-id mirrors the body's effective prompt_cache_key instead of both diverging to a bare scope string. - extra_body.prompt_cache_key for xAI Responses now reads back a caller's top-level request_overrides={"prompt_cache_key": ...} instead of always using the auto-derived hash, so an explicit override actually governs the field xAI reads. - x-grok-conv-id (native xAI Responses transport and Grok-via-OpenRouter profile) is now scoped through _cache_scope_from_session_id(), so cron re-fires of the same job pin to the same backend instead of a new one every fire. - Fallback cache_key (when instructions/tools are empty) now falls back to the normalized scope instead of the raw session_id, same class of fix. --- agent/transports/codex.py | 37 ++++++++++----- .../model-providers/openrouter/__init__.py | 2 +- .../agent/transports/test_codex_transport.py | 46 +++++++++++++------ tests/providers/test_provider_profiles.py | 16 +++++++ 4 files changed, 74 insertions(+), 27 deletions(-) diff --git a/agent/transports/codex.py b/agent/transports/codex.py index fad2cc05e63ca..3bbd14077296e 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -373,7 +373,7 @@ def build_kwargs( # content to hash. cache_key = _content_cache_key( instructions, response_tools, _cache_scope_from_session_id(session_id) - ) or session_id + ) or _cache_scope_from_session_id(session_id) # xAI Responses takes prompt_cache_key in extra_body (set further # down); GitHub Models opts out of cache-key routing entirely. if not is_github_responses and not is_xai_responses and cache_key: @@ -455,15 +455,15 @@ def build_kwargs( if is_codex_backend: # The Codex backend rejects body-level ``extra_headers`` with # HTTP 400, but the OpenAI SDK's ``extra_headers`` kwarg maps - # to actual HTTP request headers (not body fields). We need - # these headers for cache-scope routing so prompt cache hits - # remain high. Send session_id / x-client-request-id as HTTP - # headers while keeping ``prompt_cache_key`` in the body for - # standard OpenAI routing as a belt-and-braces fallback. - cache_scope_id = _bounded_prompt_cache_key( + # to actual HTTP request headers (not body fields). ``session_id`` + # carries the raw physical session id — transcript/identity, per + # the #57012 contract — while ``x-client-request-id`` mirrors the + # body's effective ``prompt_cache_key`` so header and body always + # agree on the same routing bucket instead of diverging (#78941). + final_cache_key = kwargs.get("prompt_cache_key") or _bounded_prompt_cache_key( _cache_scope_from_session_id(session_id) ) - if cache_scope_id: + if session_id or final_cache_key: existing_extra_headers = kwargs.get("extra_headers") merged_extra_headers: Dict[str, str] = {} if isinstance(existing_extra_headers, dict): @@ -474,8 +474,10 @@ def build_kwargs( if key and value is not None } ) - merged_extra_headers["session_id"] = cache_scope_id - merged_extra_headers["x-client-request-id"] = cache_scope_id + if session_id: + merged_extra_headers["session_id"] = str(session_id) + if final_cache_key: + merged_extra_headers["x-client-request-id"] = final_cache_key kwargs["extra_headers"] = merged_extra_headers max_tokens = params.get("max_tokens") @@ -493,18 +495,29 @@ def build_kwargs( if key and value is not None } ) - merged_extra_headers["x-grok-conv-id"] = session_id + # Scoped like the body cache key below — otherwise cron's + # per-fire timestamp in session_id (cron__) pins every + # fire of the same job to a different xAI backend server (#78941). + merged_extra_headers["x-grok-conv-id"] = _cache_scope_from_session_id( + session_id + ) kwargs["extra_headers"] = merged_extra_headers # xAI Responses cache-routing — body-level field per # https://docs.x.ai/developers/advanced-api-usage/prompt-caching/maximizing-cache-hits. # Sent via extra_body (not the typed kwarg) so it survives openai # SDK builds whose Responses.stream() signature has dropped the field. + # A caller's request_overrides={"prompt_cache_key": ...} lands on + # the top-level kwarg set above — read it back here so an explicit + # override actually governs the field xAI reads, instead of being + # silently outrun by the auto-derived cache_key (#78941). existing_extra_body = kwargs.get("extra_body") merged_extra_body: Dict[str, Any] = {} if isinstance(existing_extra_body, dict): merged_extra_body.update(existing_extra_body) - merged_extra_body.setdefault("prompt_cache_key", cache_key) + merged_extra_body.setdefault( + "prompt_cache_key", kwargs.get("prompt_cache_key", cache_key) + ) kwargs["extra_body"] = merged_extra_body extra_body = kwargs.get("extra_body") diff --git a/plugins/model-providers/openrouter/__init__.py b/plugins/model-providers/openrouter/__init__.py index f6031bd29a2e3..fa927da412707 100644 --- a/plugins/model-providers/openrouter/__init__.py +++ b/plugins/model-providers/openrouter/__init__.py @@ -183,7 +183,7 @@ def build_api_kwargs_extras( # backend server via this header, and aux calls pass no session_id, so # reading the ambient conversation keeps compression/vision/MoA traffic # on the same Grok backend as the conversation it belongs to. - grok_conv_id = get_conversation_context() or session_id + grok_conv_id = _cache_scope_from_session_id(get_conversation_context() or session_id) if grok_conv_id and model and model.startswith(("x-ai/grok-", "xai/grok-")): extra_headers["x-grok-conv-id"] = grok_conv_id if extra_headers: diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py index 4a358f1b935da..70c4ba0e04cfe 100644 --- a/tests/agent/transports/test_codex_transport.py +++ b/tests/agent/transports/test_codex_transport.py @@ -250,6 +250,21 @@ def test_xai_responses_extra_body_preserves_caller_fields(self, transport): assert eb.get("prompt_cache_key") == "caller-override" assert eb.get("other_field") == 42 + def test_xai_top_level_override_also_governs_extra_body(self, transport): + """A caller's top-level request_overrides={"prompt_cache_key": ...} + must win in extra_body.prompt_cache_key too -- the field xAI actually + reads -- instead of being silently outrun by the auto-derived + content-hash cache_key (#78941).""" + messages = [{"role": "user", "content": "Hi"}] + kw = transport.build_kwargs( + model="grok-4.3", messages=messages, tools=[], + session_id="conv-xai-1", + is_xai_responses=True, + request_overrides={"prompt_cache_key": "caller-top-level"}, + ) + assert kw["prompt_cache_key"] == "caller-top-level" + assert kw["extra_body"]["prompt_cache_key"] == "caller-top-level" + @@ -257,26 +272,28 @@ def test_xai_responses_extra_body_preserves_caller_fields(self, transport): @pytest.mark.parametrize("length", [64, 65]) def test_codex_cache_scope_boundary(self, transport, length): session_id = "s" * length - scope = transport.build_kwargs( + kw = transport.build_kwargs( model="gpt-5.4", messages=[{"role": "user", "content": "Hi"}], tools=[], session_id=session_id, is_codex_backend=True, request_overrides={"extra_headers": {"x-test": "1"}}, - )["extra_headers"] + ) + headers = kw["extra_headers"] - assert scope["x-test"] == "1" - assert len(scope["session_id"]) <= 64 - assert scope["x-client-request-id"] == scope["session_id"] - if length == 64: - assert scope["session_id"] == session_id - else: - assert scope["session_id"].startswith("pck_") - assert scope["session_id"] != session_id + assert headers["x-test"] == "1" + # session_id header carries the raw physical id untouched regardless + # of length (#57012); x-client-request-id mirrors the body's + # effective (already-bounded) prompt_cache_key. + assert headers["session_id"] == session_id + assert headers["x-client-request-id"] == kw["prompt_cache_key"] + assert len(headers["x-client-request-id"]) <= 64 def test_codex_cache_scope_headers_normalize_cron_session_id(self, transport): - """Cache-routing headers strip the cron per-fire timestamp, same as prompt_cache_key.""" + """x-client-request-id shares a cache scope across cron re-fires of the + same job (cron per-fire timestamp stripped, same as prompt_cache_key), + while session_id stays the raw per-fire physical id (#57012).""" first_run = transport.build_kwargs( model="gpt-5.4", messages=[{"role": "user", "content": "Hi"}], @@ -299,10 +316,11 @@ def test_codex_cache_scope_headers_normalize_cron_session_id(self, transport): is_codex_backend=True, )["extra_headers"] - assert first_run["session_id"] == "cron_job42" - assert first_run["session_id"] == second_run["session_id"] + assert first_run["session_id"] == "cron_job42_20260801_090000" + assert second_run["session_id"] == "cron_job42_20260802_090000" + assert first_run["x-client-request-id"].startswith("pck_") assert first_run["x-client-request-id"] == second_run["x-client-request-id"] - assert first_run["session_id"] != other_job["session_id"] + assert first_run["x-client-request-id"] != other_job["x-client-request-id"] diff --git a/tests/providers/test_provider_profiles.py b/tests/providers/test_provider_profiles.py index baae5618a62a1..b5fccc7084214 100644 --- a/tests/providers/test_provider_profiles.py +++ b/tests/providers/test_provider_profiles.py @@ -94,6 +94,22 @@ def test_grok_session_id_sets_cache_affinity_header(self): ) assert tl["extra_headers"]["x-grok-conv-id"] == "sess-abc123" + def test_grok_conv_id_normalizes_cron_timestamp(self): + """Cron re-fires of the same job must pin to the same xAI backend, + same as the body.session_id sticky key (#78941).""" + p = get_provider_profile("openrouter") + _, first = p.build_api_kwargs_extras( + model="x-ai/grok-4", session_id="cron_job42_20260801_090000", + ) + _, second = p.build_api_kwargs_extras( + model="x-ai/grok-4", session_id="cron_job42_20260802_090000", + ) + assert first["extra_headers"]["x-grok-conv-id"] == "cron_job42" + assert ( + first["extra_headers"]["x-grok-conv-id"] + == second["extra_headers"]["x-grok-conv-id"] + ) + From d78e7272e1553ec48bf95cf322968b47837e15cf Mon Sep 17 00:00:00 2001 From: joaomarcos Date: Tue, 4 Aug 2026 22:44:21 -0300 Subject: [PATCH 6/6] test(cache): reproduce compression-rotation cache-scope gap (#79017) Not a fix -- a demonstration for maintainer review. See #79017 for the design discussion on why this needs a logical cache-scope concept distinct from the physical session_id, not a one-line patch. --- ...ex_cache_scope_compression_rotation_gap.py | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/agent/transports/test_codex_cache_scope_compression_rotation_gap.py diff --git a/tests/agent/transports/test_codex_cache_scope_compression_rotation_gap.py b/tests/agent/transports/test_codex_cache_scope_compression_rotation_gap.py new file mode 100644 index 0000000000000..0b49959c1ef2f --- /dev/null +++ b/tests/agent/transports/test_codex_cache_scope_compression_rotation_gap.py @@ -0,0 +1,56 @@ +"""Demonstrates the compression-rotation cache-scope gap tracked by #79017. + +This is NOT a fix -- it's a reproduction for maintainers to look at while +deciding whether the "logical cache-scope" redesign proposed in #79017 is +worth doing. See the issue for the full design discussion. + +_cache_scope_from_session_id() (introduced by #78959 for issue #78941) +scopes prompt_cache_key by the *physical* session_id. That's correct for +unrelated sessions and for cron re-fires, but context-compression rotation +mints a brand new physical session_id mid-conversation to segment the +transcript -- so the same logical conversation goes cache-cold at every +rotation boundary. +""" + +import pytest + +from agent.transports import get_transport + + +@pytest.fixture +def transport(): + import agent.transports.codex # noqa: F401 + return get_transport("codex_responses") + + +@pytest.mark.xfail( + reason="#79017: cache scope has no concept of a logical conversation " + "identity distinct from the physical session_id, so compression " + "rotation always goes cache-cold. Needs a design decision, not a " + "one-line fix -- see the issue.", + strict=True, +) +def test_compression_rotation_preserves_cache_scope(transport): + root_session = "session-root-abc123" + # A compression rotation mints a new physical session_id for the same + # logical conversation (see agent/conversation_compression.py). + rotated_session = "session-rotated-def456" + + root_kw = transport.build_kwargs( + model="gpt-5.4", + messages=[{"role": "system", "content": "You are a helpful assistant."}], + tools=[], + session_id=root_session, + is_codex_backend=True, + ) + rotated_kw = transport.build_kwargs( + model="gpt-5.4", + messages=[{"role": "system", "content": "You are a helpful assistant."}], + tools=[], + session_id=rotated_session, + is_codex_backend=True, + ) + + # Desired behavior once #79017 lands: the rotated segment of the SAME + # conversation should keep the same cache scope as its root. + assert root_kw["prompt_cache_key"] == rotated_kw["prompt_cache_key"]