# NousResearch/hermes-agent · Cron jobs prompt cache and session reuse

## Open now

- 3 issues, 1 PR open on GitHub.
- #14821: \[Feature\]: Named/resumable sessions for cron jobs
- #33167: \[Feature\]: Cron jobs should support resuming existing sessions
- #39701: Cron jobs create a new session on every run - add session_reuse option
- PR #47094: feat(cron): support reusable sessions

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.

## TL;DR

- 10 member(s): 3 open issue(s), 1 closed issue(s), 1 open PR(s), 1 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #52295. Verify it addresses your case.
- **Recommended:** read PR #52295 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-52295`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.19`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #14821: \[Feature\]: Named/resumable sessions for cron jobs
- #33167: \[Feature\]: Cron jobs should support resuming existing sessions
- #39701: Cron jobs create a new session on every run - add session_reuse option

## Closed / duplicate issues

- #51395: Recurring cron jobs are prompt-cache-cold on every fire (session_id is the cache key)

## Prior & competing PRs

- **#52295: fix(cache): content-address prompt_cache_key so recurring cron jobs reuse the warm prefix**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/transports/codex.py`, `tests/agent/transports/test_codex_transport.py`
  - fix hunks:
  - `agent/transports/codex.py`

    ```diff
    @@ -5,12 +5,47 @@
     streaming, or the _run_codex_stream() call path.
     """
     
    +import hashlib
    +import json
     from typing import Any, Dict, List, Optional
     
     from agent.transports.base import ProviderTransport
     from agent.transports.types import NormalizedResponse, ToolCall
     
     
    +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_<sha256[:24]>`` 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_<id>_<timestamp>``, 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.
    +    """
    +    if not instructions and not tools:
    +        return None
    +    tools_part = ""
    +    if tools:
    +        sorted_tools = sorted(
    +            (t for t in tools if isinstance(t, dict)),
    +            key=lambda t: str(t.get("name") or t.get("type") or ""),
    +        )
    +        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}"
    +    digest = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()[:24]
    +    return f"pck_{digest}"
    +
    +
     class ResponsesApiTransport(ProviderTransport):
         """Transport for api_mode='codex_responses'.
     
    ```

    ```diff
    @@ -71,7 +106,10 @@ def build_kwargs(
             params:
                 instructions: str — system prompt (extracted from messages[0] if not given)
                 reasoning_config: dict | None — {effort, enabled}
    -            session_id: str | None — used for prompt_cache_key + xAI conv header
    +            session_id: str | None — transcript/session id; drives the xAI
    +                x-grok-conv-id header and the Codex cache-scope headers, and is
    +                the fallback prompt_cache_key when there is no static prefix to
    +                content-address
                 max_tokens: int | None — max_output_tokens
                 timeout: float | None — per-request timeout forwarded to the SDK
                 request_overrides: dict | None — extra kwargs merged in
    ```

    ```diff
    @@ -212,10 +250,17 @@ def build_kwargs(
                 kwargs["parallel_tool_calls"] = True
     
             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_<id>_<ts>) 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
             # 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 session_id:
    -            kwargs["prompt_cache_key"] = session_id
    +        if not is_github_responses and not is_xai_responses and cache_key:
    +            kwargs["prompt_cache_key"] = cache_key
     
             if reasoning_enabled and is_xai_responses:
                 from agent.model_metadata import grok_supports_reasoning_effort
    ```

    ```diff
    @@ -326,7 +371,7 @@ def build_kwargs(
                 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", session_id)
    +            merged_extra_body.setdefault("prompt_cache_key", cache_key)
                 kwargs["extra_body"] = merged_extra_body
     
             return kwargs
    ```
  - `tests/agent/transports/test_codex_transport.py`

    ```diff
    @@ -83,13 +83,34 @@ def test_reasoning_disabled(self, transport):
             )
             assert "reasoning" not in kw or kw.get("include") == []
     
    -    def test_session_id_sets_cache_key(self, transport):
    +    def test_cache_key_is_content_addressed_not_session_id(self, transport):
    +        """prompt_cache_key is content-addressed from the static prefix
    +        (instructions + tools), not the session_id. This keeps recurring cron
    +        jobs — whose session_id carries a per-fire timestamp — on a stable warm
    +        cache key. The key is a 'pck_' hash and must NOT equal session_id."""
             messages = [{"role": "user", "content": "Hi"}]
             kw = transport.build_kwargs(
                 model="gpt-5.4", messages=messages, tools=[],
    -            session_id="test-session-123",
    +            session_id="cron_job42_20260624_143000",
             )
    -        assert kw.get("prompt_cache_key") == "test-session-123"
    +        pck = kw.get("prompt_cache_key", "")
    +        assert pck.startswith("pck_")
    +        assert pck != "cron_job42_20260624_143000"
    +
    +    def test_cache_key_stable_across_session_ids(self, transport):
    +        """Same static prefix + different session_id (e.g. two cron fires of the
    +        same job) must yield the same prompt_cache_key — the whole point of the
    +        fix: repeated fires reuse the warm prefix instead of going cold."""
    +        messages = [{"role": "user", "content": "Hi"}]
    +        kw1 = transport.build_kwargs(
    +            model="gpt-5.4", messages=messages, tools=[],
    +            session_id="cron_job42_20260624_143000",
    +        )
    +        kw2 = transport.build_kwargs(
    +            model="gpt-5.4", messages=messages, tools=[],
    +            session_id="cron_job42_20260624_143500",
    +        )
    +        assert kw1["prompt_cache_key"] == kw2["prompt_cache_key"]
     
         def test_github_responses_no_cache_key(self, transport):
             messages = [{"role": "user", "content": "Hi"}]
    ```

    ```diff
    @@ -118,7 +139,12 @@ def test_xai_responses_sends_cache_key_via_extra_body(self, transport):
                 is_xai_responses=True,
             )
             assert "prompt_cache_key" not in kw
    -        assert kw.get("extra_body", {}).get("prompt_cache_key") == "conv-xai-1"
    +        # Body-level prompt_cache_key is content-addressed (pck_ hash), not the
    +        # raw session_id, so recurring cron fires stay on a stable warm key.
    +        eb_pck = kw.get("extra_body", {}).get("prompt_cache_key", "")
    +        assert eb_pck.startswith("pck_")
    +        assert eb_pck != "conv-xai-1"
    +        # x-grok-conv-id stays the session/transcript id, not the cache key.
             assert kw.get("extra_headers", {}).get("x-grok-conv-id") == "conv-xai-1"
     
         def test_xai_responses_extra_body_preserves_caller_fields(self, transport):
    ```
- **#47094: feat(cron): support reusable sessions**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `cron/jobs.py`, `cron/scheduler.py`, `hermes_cli/cron.py`, `hermes_cli/subcommands/cron.py`, `tests/cron/test_cronjob_schema.py`, `tests/cron/test_jobs.py`, `tests/cron/test_scheduler.py`, `tests/hermes_cli/test_cron_parser_builder.py`, `tests/tools/test_cronjob_tools.py`, `tools/cronjob_tools.py`, `website/docs/reference/cli-commands.md`, `website/docs/reference/tools-reference.md`, `website/docs/user-guide/features/cron.md`
- **#51396: fix(cron): stable prompt_cache_key so recurring jobs reuse the warm cache**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#51395`, `#52295`
- **#51580: fix(cache): content-address prompt_cache_key so cron jobs hit provider prefix cache**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#51395`, `#51396`, `#51396`, `#51057`
- **#51585: fix(cache): content-address prompt_cache_key so cron jobs hit provider prefix cache**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#52295`
- **#51942: fix(cron): use stable prompt cache key for recurring jobs, closes #51395**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#51395`, `#52295`

## Fix status

- Merged to main in PR #52295 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-25`.
- Subsystem: `cron`
