{"repo":"NousResearch/hermes-agent","public_id":"14821","display_label":"Cron jobs prompt cache and session reuse","summary":"These issues and pull requests address cron jobs creating a new session or cache key on every run, preventing reuse of warm prompt caches. Proposed solutions include stable or content-addressed prompt cache keys and optional resumable/named sessions for recurring jobs.","subsystem":"cron","counts":{"members":10,"open_issues":3,"open_issues_distinct":3,"open_prs":1,"closed_issues":1,"merged_prs":1,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":52295},"resolution":{"state":"partially_resolved","by":"pr-52295"},"freshness":{"state":"verified","last_verified_at":"2026-06-25T04:46:31+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1875555555555556},"fix_edges":[{"issue_number":51395,"pr_number":52295,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":14821,"title":"[Feature]: Named/resumable sessions for cron jobs","url":null,"state":"open"},{"kind":"issue","number":33167,"title":"[Feature]: Cron jobs should support resuming existing sessions","url":null,"state":"open"},{"kind":"issue","number":39701,"title":"Cron jobs create a new session on every run - add session_reuse option","url":null,"state":"open"},{"kind":"issue","number":51395,"title":"Recurring cron jobs are prompt-cache-cold on every fire (session_id is the cache key)","url":null,"state":"closed"},{"kind":"pr","number":47094,"title":"feat(cron): support reusable sessions","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":51396,"title":"fix(cron): stable prompt_cache_key so recurring jobs reuse the warm cache","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#51395","number":51395},{"kind":"pr","value":"#52295","number":52295}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":51580,"title":"fix(cache): content-address prompt_cache_key so cron jobs hit provider prefix cache","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#51395","number":51395},{"kind":"pr","value":"#51396","number":51396},{"kind":"pr","value":"#51396","number":51396},{"kind":"pr","value":"#51057","number":51057}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":51585,"title":"fix(cache): content-address prompt_cache_key so cron jobs hit provider prefix cache","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52295","number":52295}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":51942,"title":"fix(cron): use stable prompt cache key for recurring jobs, closes #51395","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#51395","number":51395},{"kind":"pr","value":"#52295","number":52295}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":52295,"title":"fix(cache): content-address prompt_cache_key so recurring cron jobs reuse the warm prefix","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/transports/codex.py","tests/agent/transports/test_codex_transport.py"],"fix_hunks":[{"file":"agent/transports/codex.py","hunks":["@@ -5,12 +5,47 @@\n streaming, or the _run_codex_stream() call path.\n \"\"\"\n \n+import hashlib\n+import json\n from typing import Any, Dict, List, Optional\n \n from agent.transports.base import ProviderTransport\n from agent.transports.types import NormalizedResponse, ToolCall\n \n \n+def _content_cache_key(instructions: str, tools: Optional[List[Dict[str, Any]]]) -> Optional[str]:\n+    \"\"\"Content-address the prompt cache key from the static request prefix.\n+\n+    Returns ``pck_<sha256[:24]>`` of (instructions + sorted tool schemas), or\n+    None when there is nothing static to key on. The cache key is a routing\n+    hint only — never a correctness boundary — so two requests sharing a system\n+    prompt and tool set intentionally resolve to the same warm prefix bucket.\n+\n+    The fix this exists for: recurring cron jobs build session_id as\n+    ``cron_<id>_<timestamp>``, so using session_id as the cache key made every\n+    fire cache-cold. The static prefix (identity + tools) is identical across\n+    fires, so hashing it gives a stable key that stays warm within the\n+    provider's cache TTL. Sorting tools by name keeps the hash insertion-order\n+    independent.\n+    \"\"\"\n+    if not instructions and not tools:\n+        return None\n+    tools_part = \"\"\n+    if tools:\n+        sorted_tools = sorted(\n+            (t for t in tools if isinstance(t, dict)),\n+            key=lambda t: str(t.get(\"name\") or t.get(\"type\") or \"\"),\n+        )\n+        tools_part = json.dumps(\n+            sorted_tools, sort_keys=True, ensure_ascii=False, separators=(\",\", \":\")\n+        )\n+    # \\x00 separator so instructions ending in the tool JSON can't collide with\n+    # a request whose instructions contain that JSON and whose tools are empty.\n+    content = f\"{instructions or ''}\\x00{tools_part}\"\n+    digest = hashlib.sha256(content.encode(\"utf-8\", errors=\"replace\")).hexdigest()[:24]\n+    return f\"pck_{digest}\"\n+\n+\n class ResponsesApiTransport(ProviderTransport):\n     \"\"\"Transport for api_mode='codex_responses'.\n ","@@ -71,7 +106,10 @@ def build_kwargs(\n         params:\n             instructions: str — system prompt (extracted from messages[0] if not given)\n             reasoning_config: dict | None — {effort, enabled}\n-            session_id: str | None — used for prompt_cache_key + xAI conv header\n+            session_id: str | None — transcript/session id; drives the xAI\n+                x-grok-conv-id header and the Codex cache-scope headers, and is\n+                the fallback prompt_cache_key when there is no static prefix to\n+                content-address\n             max_tokens: int | None — max_output_tokens\n             timeout: float | None — per-request timeout forwarded to the SDK\n             request_overrides: dict | None — extra kwargs merged in","@@ -212,10 +250,17 @@ def build_kwargs(\n             kwargs[\"parallel_tool_calls\"] = True\n \n         session_id = params.get(\"session_id\")\n+        # prompt_cache_key is content-addressed from the static prefix\n+        # (instructions + tools), NOT session_id — recurring cron jobs carry a\n+        # per-fire timestamp in session_id (cron_<id>_<ts>) that made every run\n+        # cache-cold. session_id is left untouched for transcript isolation and\n+        # the cache-scope routing headers below. Falls back to session_id when\n+        # there is no static content to hash.\n+        cache_key = _content_cache_key(instructions, response_tools) or session_id\n         # xAI Responses takes prompt_cache_key in extra_body (set further\n         # down); GitHub Models opts out of cache-key routing entirely.\n-        if not is_github_responses and not is_xai_responses and session_id:\n-            kwargs[\"prompt_cache_key\"] = session_id\n+        if not is_github_responses and not is_xai_responses and cache_key:\n+            kwargs[\"prompt_cache_key\"] = cache_key\n \n         if reasoning_enabled and is_xai_responses:\n             from agent.model_metadata import grok_supports_reasoning_effort","@@ -326,7 +371,7 @@ def build_kwargs(\n             merged_extra_body: Dict[str, Any] = {}\n             if isinstance(existing_extra_body, dict):\n                 merged_extra_body.update(existing_extra_body)\n-            merged_extra_body.setdefault(\"prompt_cache_key\", session_id)\n+            merged_extra_body.setdefault(\"prompt_cache_key\", cache_key)\n             kwargs[\"extra_body\"] = merged_extra_body\n \n         return kwargs"]},{"file":"tests/agent/transports/test_codex_transport.py","hunks":["@@ -83,13 +83,34 @@ def test_reasoning_disabled(self, transport):\n         )\n         assert \"reasoning\" not in kw or kw.get(\"include\") == []\n \n-    def test_session_id_sets_cache_key(self, transport):\n+    def test_cache_key_is_content_addressed_not_session_id(self, transport):\n+        \"\"\"prompt_cache_key is content-addressed from the static prefix\n+        (instructions + tools), not the session_id. This keeps recurring cron\n+        jobs — whose session_id carries a per-fire timestamp — on a stable warm\n+        cache key. The key is a 'pck_' hash and must NOT equal session_id.\"\"\"\n         messages = [{\"role\": \"user\", \"content\": \"Hi\"}]\n         kw = transport.build_kwargs(\n             model=\"gpt-5.4\", messages=messages, tools=[],\n-            session_id=\"test-session-123\",\n+            session_id=\"cron_job42_20260624_143000\",\n         )\n-        assert kw.get(\"prompt_cache_key\") == \"test-session-123\"\n+        pck = kw.get(\"prompt_cache_key\", \"\")\n+        assert pck.startswith(\"pck_\")\n+        assert pck != \"cron_job42_20260624_143000\"\n+\n+    def test_cache_key_stable_across_session_ids(self, transport):\n+        \"\"\"Same static prefix + different session_id (e.g. two cron fires of the\n+        same job) must yield the same prompt_cache_key — the whole point of the\n+        fix: repeated fires reuse the warm prefix instead of going cold.\"\"\"\n+        messages = [{\"role\": \"user\", \"content\": \"Hi\"}]\n+        kw1 = transport.build_kwargs(\n+            model=\"gpt-5.4\", messages=messages, tools=[],\n+            session_id=\"cron_job42_20260624_143000\",\n+        )\n+        kw2 = transport.build_kwargs(\n+            model=\"gpt-5.4\", messages=messages, tools=[],\n+            session_id=\"cron_job42_20260624_143500\",\n+        )\n+        assert kw1[\"prompt_cache_key\"] == kw2[\"prompt_cache_key\"]\n \n     def test_github_responses_no_cache_key(self, transport):\n         messages = [{\"role\": \"user\", \"content\": \"Hi\"}]","@@ -118,7 +139,12 @@ def test_xai_responses_sends_cache_key_via_extra_body(self, transport):\n             is_xai_responses=True,\n         )\n         assert \"prompt_cache_key\" not in kw\n-        assert kw.get(\"extra_body\", {}).get(\"prompt_cache_key\") == \"conv-xai-1\"\n+        # Body-level prompt_cache_key is content-addressed (pck_ hash), not the\n+        # raw session_id, so recurring cron fires stay on a stable warm key.\n+        eb_pck = kw.get(\"extra_body\", {}).get(\"prompt_cache_key\", \"\")\n+        assert eb_pck.startswith(\"pck_\")\n+        assert eb_pck != \"conv-xai-1\"\n+        # x-grok-conv-id stays the session/transcript id, not the cache key.\n         assert kw.get(\"extra_headers\", {}).get(\"x-grok-conv-id\") == \"conv-xai-1\"\n \n     def test_xai_responses_extra_body_preserves_caller_fields(self, transport):\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}