{"repo":"NousResearch/hermes-agent","public_id":"18871","display_label":"Background review agents missing reasoning_config inheritance","summary":"Multiple reports and fix attempts address background review agent forks not inheriting the parent's reasoning_config, which affects thinking/output configuration and Anthropic prefix cache behavior. Several related PRs propose forwarding reasoning_config to the background review fork.","subsystem":"agent","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":64379},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-14T11:49:47+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3028571428571428},"fix_edges":[{"issue_number":18871,"pr_number":64379,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":25322,"pr_number":64379,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":30531,"pr_number":64379,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":18871,"title":"Background review agents ignore reasoning_config and fall back to medium","url":null,"state":"closed"},{"kind":"issue","number":30531,"title":"background_review fork omits `reasoning_config`, sends different `thinking` field than parent — fragments Anthropic prefix cache (~38% of total cache-write tokens)","url":null,"state":"closed"},{"kind":"pr","number":18973,"title":"fix(agent): forward reasoning_config to background review AIAgent fork","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64379","number":64379},{"kind":"pr","value":"#30532","number":30532},{"kind":"pr","value":"#18871","number":18871},{"kind":"pr","value":"#30532","number":30532}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":20674,"title":"fix(run_agent): pass reasoning_config to background review fork (#18871)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64379","number":64379},{"kind":"pr","value":"#30532","number":30532},{"kind":"pr","value":"#27248","number":27248}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":27510,"title":"fix(agent): forward reasoning_config to background review AIAgent fork","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#27248","number":27248},{"kind":"pr","value":"#64379","number":64379},{"kind":"pr","value":"#30532","number":30532},{"kind":"pr","value":"#18871","number":18871}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":30532,"title":"fix(background_review): inherit parent's reasoning_config to keep `thinking`/`output_config` cache-stable (~38% fewer cache-write tokens on long sessions)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"17cfa0f0a","number":0},{"kind":"commit","value":"8ef006933","number":0},{"kind":"pr","value":"#64379","number":64379}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":64379,"title":"fix(background_review): inherit parent's reasoning_config for Anthropic cache parity (salvage #30532)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/background_review.py","tests/run_agent/test_background_review_cache_parity.py"],"fix_hunks":[{"file":"agent/background_review.py","hunks":["@@ -696,6 +696,19 @@ def _bg_review_auto_deny(command, description, **kwargs):\n             if isinstance(_rt.get(\"command\"), str) and _rt[\"command\"]:\n                 _fork_kwargs[\"acp_command\"] = _rt[\"command\"]\n                 _fork_kwargs[\"acp_args\"] = _rt.get(\"args\") or []\n+            # Match parent's reasoning config so the fork's ``thinking`` /\n+            # ``output_config`` are byte-identical in the request body —\n+            # Anthropic's cache key is namespaced by ``thinking`` presence.\n+            # Same-model path only: when routed to a different aux model the\n+            # cache is cold regardless (parity buys nothing) and the parent's\n+            # effort vocabulary may not be valid for the routed model/provider\n+            # (e.g. OpenRouter ``extra_body.reasoning.effort`` is forwarded\n+            # unclamped; codex_responses passes ``max``/``ultra`` through\n+            # unmapped except on gpt-5.6/xAI). Let the routed fork use\n+            # provider defaults — matching the ``not _routed`` gate on\n+            # _cached_system_prompt below.\n+            if not _routed:\n+                _fork_kwargs[\"reasoning_config\"] = getattr(agent, \"reasoning_config\", None)\n             review_agent = AIAgent(\n                 model=_rt.get(\"model\") or agent.model,\n                 max_iterations=16,"]},{"file":"tests/run_agent/test_background_review_cache_parity.py","hunks":["@@ -41,6 +41,9 @@ def _make_agent_stub(agent_cls):\n     # Non-None so the test catches a missing-kwarg regression.\n     agent.enabled_toolsets = [\"memory\", \"skills\", \"terminal\"]\n     agent.disabled_toolsets = [\"spotify\", \"feishu_doc\"]\n+    # Non-None so the test catches reasoning_config NOT being inherited —\n+    # which would put the fork into a different Anthropic cache namespace.\n+    agent.reasoning_config = {\"enabled\": True, \"effort\": \"medium\"}\n     return agent\n \n ","@@ -55,28 +58,52 @@ def start(self):\n             self._target()\n \n \n-class _ReviewAgentRecorder:\n-    \"\"\"Stand-in for the review-fork AIAgent that records the prompt assignment.\"\"\"\n+def _make_recorder_class(captured=None, record_on_run=()):\n+    \"\"\"Build a Recorder class standing in for the review-fork AIAgent.\n \n-    def __init__(self, *args, **kwargs):\n-        self._cached_system_prompt = None\n-        self._memory_write_origin = None\n-        self._memory_write_context = None\n-        self._memory_store = None\n-        self._memory_enabled = None\n-        self._user_profile_enabled = None\n-        self._memory_nudge_interval = None\n-        self._skill_nudge_interval = None\n-        self.suppress_status_output = None\n+    Keeps the stub attribute list in ONE place: when\n+    ``_spawn_background_review`` starts touching a new fork attribute, only\n+    this factory needs the extra stub — not one copy per test.\n \n-    def run_conversation(self, *args, **kwargs):\n-        raise RuntimeError(\"stop after recording state — don't actually call the API\")\n+    ``captured`` (dict): if given, ``__init__`` stores the full constructor\n+    kwargs under ``captured[\"init_kwargs\"]`` so tests can assert on both\n+    kwarg values and kwarg *presence*.\n+    ``record_on_run``: instance attribute names copied into ``captured`` when\n+    ``run_conversation`` fires — for values the production code assigns\n+    after construction.\n+    \"\"\"\n+\n+    class _Recorder:\n+        def __init__(self, *args, **kwargs):\n+            if captured is not None:\n+                captured[\"init_kwargs\"] = dict(kwargs)\n+            self._cached_system_prompt = None\n+            self._memory_write_origin = None\n+            self._memory_write_context = None\n+            self._memory_store = None\n+            self._memory_enabled = None\n+            self._user_profile_enabled = None\n+            self._memory_nudge_interval = None\n+            self._skill_nudge_interval = None\n+            self.suppress_status_output = None\n+            self.session_start = None\n+            self.session_id = None\n \n-    def shutdown_memory_provider(self):\n-        pass\n+        def run_conversation(self, *args, **kwargs):\n+            if captured is not None:\n+                for _name in record_on_run:\n+                    captured[_name] = getattr(self, _name)\n+            raise RuntimeError(\n+                \"stop after recording — don't actually call the API\"\n+            )\n \n-    def close(self):\n-        pass\n+        def shutdown_memory_provider(self):\n+            pass\n+\n+        def close(self):\n+            pass\n+\n+    return _Recorder\n \n \n def test_review_fork_inherits_parent_cached_system_prompt():","@@ -94,27 +121,21 @@ def test_review_fork_inherits_parent_cached_system_prompt():\n     captured = {}\n     parent_prompt = agent._cached_system_prompt\n \n-    # Hook the assignment site: record what gets put on the review agent.\n-    real_recorder_init = _ReviewAgentRecorder.__init__\n+    _Recorder = _make_recorder_class()\n \n-    def _recorder_init(self, *args, **kwargs):\n-        real_recorder_init(self, *args, **kwargs)\n-        # The actual production code assigns _cached_system_prompt AFTER __init__,\n-        # so we need to capture it on attribute set. Use a property-style sentinel\n-        # via __setattr__ on this instance.\n-\n-    with patch.object(run_agent, \"AIAgent\", _ReviewAgentRecorder), \\\n+    with patch.object(run_agent, \"AIAgent\", _Recorder), \\\n          patch(\"threading.Thread\", _SyncThread):\n-        # Wrap the recorder's __setattr__ so we can see the _cached_system_prompt\n-        # write that _spawn_background_review performs after construction.\n-        orig_setattr = _ReviewAgentRecorder.__setattr__\n+        # The production code assigns _cached_system_prompt AFTER __init__,\n+        # so wrap the recorder's __setattr__ to see that post-construction\n+        # write from _spawn_background_review.\n+        orig_setattr = _Recorder.__setattr__\n \n         def _spy_setattr(self, name, value):\n             if name == \"_cached_system_prompt\":\n                 captured[\"written_prompt\"] = value\n             orig_setattr(self, name, value)\n \n-        with patch.object(_ReviewAgentRecorder, \"__setattr__\", _spy_setattr):\n+        with patch.object(_Recorder, \"__setattr__\", _spy_setattr):\n             agent._spawn_background_review(\n                 messages_snapshot=[],\n                 review_memory=True,","@@ -144,31 +165,9 @@ def test_review_fork_pins_session_start_and_session_id():\n     agent = _make_agent_stub(run_agent.AIAgent)\n \n     captured = {}\n-\n-    class _Recorder:\n-        def __init__(self, *args, **kwargs):\n-            self._cached_system_prompt = None\n-            self._memory_write_origin = None\n-            self._memory_write_context = None\n-            self._memory_store = None\n-            self._memory_enabled = None\n-            self._user_profile_enabled = None\n-            self._memory_nudge_interval = None\n-            self._skill_nudge_interval = None\n-            self.suppress_status_output = None\n-            self.session_start = None\n-            self.session_id = None\n-\n-        def run_conversation(self, *args, **kwargs):\n-            captured[\"session_start\"] = self.session_start\n-            captured[\"session_id\"] = self.session_id\n-            raise RuntimeError(\"stop after recording\")\n-\n-        def shutdown_memory_provider(self):\n-            pass\n-\n-        def close(self):\n-            pass\n+    _Recorder = _make_recorder_class(\n+        captured, record_on_run=(\"session_start\", \"session_id\")\n+    )\n \n     with patch.object(run_agent, \"AIAgent\", _Recorder), \\\n          patch(\"threading.Thread\", _SyncThread):","@@ -195,31 +194,40 @@ def test_review_fork_inherits_parent_toolset_config():\n     agent = _make_agent_stub(run_agent.AIAgent)\n \n     captured = {}\n+    _Recorder = _make_recorder_class(captured)\n \n-    class _Recorder:\n-        def __init__(self, *args, **kwargs):\n-            captured[\"enabled_toolsets\"] = kwargs.get(\"enabled_toolsets\")\n-            captured[\"disabled_toolsets\"] = kwargs.get(\"disabled_toolsets\")\n-            self._cached_system_prompt = None\n-            self._memory_write_origin = None\n-            self._memory_write_context = None\n-            self._memory_store = None\n-            self._memory_enabled = None\n-            self._user_profile_enabled = None\n-            self._memory_nudge_interval = None\n-            self._skill_nudge_interval = None\n-            self.suppress_status_output = None\n-            self.session_start = None\n-            self.session_id = None\n+    with patch.object(run_agent, \"AIAgent\", _Recorder), \\\n+         patch(\"threading.Thread\", _SyncThread):\n+        agent._spawn_background_review(\n+            messages_snapshot=[],\n+            review_memory=True,\n+            review_skills=False,\n+        )\n \n-        def run_conversation(self, *args, **kwargs):\n-            raise RuntimeError(\"stop after recording — don't actually call the API\")\n+    init_kwargs = captured.get(\"init_kwargs\", {})\n+    assert init_kwargs.get(\"enabled_toolsets\") == agent.enabled_toolsets, (\n+        f\"enabled_toolsets mismatch: {init_kwargs.get('enabled_toolsets')!r} \"\n+        f\"vs expected {agent.enabled_toolsets!r}\"\n+    )\n+    assert init_kwargs.get(\"disabled_toolsets\") == agent.disabled_toolsets, (\n+        f\"disabled_toolsets mismatch: {init_kwargs.get('disabled_toolsets')!r} \"\n+        f\"vs expected {agent.disabled_toolsets!r}\"\n+    )\n \n-        def shutdown_memory_provider(self):\n-            pass\n \n-        def close(self):\n-            pass\n+def test_review_fork_inherits_parent_reasoning_config():\n+    \"\"\"``reasoning_config`` parity on the default (non-routed) path.\n+\n+    The fork must inherit the parent's value so the request body's\n+    ``thinking`` / ``output_config`` match — Anthropic's cache is\n+    namespaced by ``thinking`` presence.\n+    \"\"\"\n+    import run_agent\n+\n+    agent = _make_agent_stub(run_agent.AIAgent)\n+\n+    captured = {}\n+    _Recorder = _make_recorder_class(captured)\n \n     with patch.object(run_agent, \"AIAgent\", _Recorder), \\\n          patch(\"threading.Thread\", _SyncThread):","@@ -229,11 +237,62 @@ def close(self):\n             review_skills=False,\n         )\n \n-    assert captured.get(\"enabled_toolsets\") == agent.enabled_toolsets, (\n-        f\"enabled_toolsets mismatch: {captured.get('enabled_toolsets')!r} \"\n-        f\"vs expected {agent.enabled_toolsets!r}\"\n+    init_kwargs = captured.get(\"init_kwargs\", {})\n+    assert init_kwargs.get(\"reasoning_config\") == agent.reasoning_config, (\n+        f\"reasoning_config mismatch: {init_kwargs.get('reasoning_config')!r} \"\n+        f\"vs expected {agent.reasoning_config!r}\"\n     )\n-    assert captured.get(\"disabled_toolsets\") == agent.disabled_toolsets, (\n-        f\"disabled_toolsets mismatch: {captured.get('disabled_toolsets')!r} \"\n-        f\"vs expected {agent.disabled_toolsets!r}\"\n+\n+\n+def test_routed_review_fork_does_not_inherit_reasoning_config():\n+    \"\"\"Routed aux path: the fork must NOT inherit the parent's reasoning_config.\n+\n+    When ``auxiliary.background_review.{provider,model}`` routes the review\n+    to a different model, cache parity is moot (the cache is cold on that\n+    model regardless) and the parent's effort vocabulary may be invalid for\n+    the routed model/provider (OpenRouter ``extra_body.reasoning.effort`` is\n+    forwarded unclamped; codex_responses passes ``max``/``ultra`` through\n+    unmapped except on gpt-5.6/xAI). The routed fork must fall back to\n+    provider defaults, mirroring the ``not _routed`` gate on\n+    ``_cached_system_prompt`` inheritance.\n+    \"\"\"\n+    import run_agent\n+    import agent.background_review as bg_review\n+\n+    agent_stub = _make_agent_stub(run_agent.AIAgent)\n+\n+    captured = {}\n+    _Recorder = _make_recorder_class(captured)\n+\n+    routed_runtime = {\n+        \"provider\": \"openrouter\",\n+        \"model\": \"aux-cheap-model\",\n+        \"api_key\": \"test-key\",\n+        \"base_url\": None,\n+        \"api_mode\": None,\n+        \"credential_pool\": None,\n+        \"request_overrides\": {},\n+        \"max_tokens\": None,\n+        \"command\": None,\n+        \"args\": [],\n+        \"routed\": True,\n+    }\n+\n+    with patch.object(run_agent, \"AIAgent\", _Recorder), \\\n+         patch.object(bg_review, \"_resolve_review_runtime\",\n+                      return_value=routed_runtime), \\\n+         patch(\"threading.Thread\", _SyncThread):\n+        agent_stub._spawn_background_review(\n+            messages_snapshot=[],\n+            review_memory=True,\n+            review_skills=False,\n+        )\n+\n+    init_kwargs = captured.get(\"init_kwargs\", {})\n+    assert \"reasoning_config\" not in init_kwargs, (\n+        f\"Routed review fork was passed the parent's reasoning_config \"\n+        f\"({init_kwargs.get('reasoning_config')!r}). On the routed path the \"\n+        \"cache is cold (no parity benefit) and the parent's effort value may \"\n+        \"be invalid for the routed model/provider — it must be omitted so \"\n+        \"the fork uses provider defaults.\"\n     )\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}