{"repo":"NousResearch/hermes-agent","public_id":"29567","display_label":"Background review fork tools cache fragmentation fix","summary":"These issues and PRs address a problem where background review forks send a wider tools[] array than the parent, breaking Anthropic prefix cache hits and wasting roughly 50% of cache-write tokens on long sessions. The proposed fix is to inherit the parent's toolset configuration in the background review fork to maintain cache parity.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":29704,"merged_at":"2026-05-21T07:19:22+00:00"},"resolution":{"state":"resolved","by":"pr-29704"},"freshness":{"state":"verified","last_verified_at":"2026-05-21T07:19:22+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":29567,"pr_number":29704,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-21T07:19:22+00:00"}],"members":[{"kind":"issue","number":29567,"title":"background_review fork sends wider tools[] than parent, fragments Anthropic prefix cache (~50% wasted cache-write on long sessions)","url":null,"state":"closed"},{"kind":"pr","number":29568,"title":"fix(background_review): inherit parent's toolset config to keep tools[] cache-stable (~50% fewer cache-write tokens on long sessions)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#29704","number":29704}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":29704,"title":"fix(background_review): inherit parent toolset config for tools[] cache parity (salvage #29568)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/background_review.py","scripts/release.py","tests/run_agent/test_background_review_cache_parity.py","tests/run_agent/test_background_review_toolset_restriction.py"],"fix_hunks":[{"file":"agent/background_review.py","hunks":["@@ -390,6 +390,9 @@ def _bg_review_auto_deny(command, description, **kwargs):\n             # parent below so memory(action=\"add\") writes from\n             # the review still land on disk; the review just\n             # has zero side effects on external providers.\n+            # Match parent's toolset config so ``tools[]`` is byte-identical\n+            # in the request body — Anthropic's cache key includes it.\n+            # (The runtime whitelist below still restricts dispatch.)\n             review_agent = AIAgent(\n                 model=agent.model,\n                 max_iterations=16,","@@ -401,6 +404,8 @@ def _bg_review_auto_deny(command, description, **kwargs):\n                 api_key=_parent_runtime.get(\"api_key\") or None,\n                 credential_pool=[redacted:secret-assignment] \"_credential_pool\", None),\n                 parent_session_id=agent.session_id,\n+                enabled_toolsets=getattr(agent, \"enabled_toolsets\", None),\n+                disabled_toolsets=getattr(agent, \"disabled_toolsets\", None),\n                 skip_memory=True,\n             )\n             review_agent._memory_write_origin = \"background_review\""]},{"file":"scripts/release.py","hunks":["@@ -718,6 +718,7 @@\n     \"9219265+cresslank@users.noreply.github.com\": \"cresslank\",\n     \"[redacted:email]\": \"trevthefoolish\",\n     \"ziliangpeng@users.noreply.github.com\": \"ziliangpeng\",\n+    \"[redacted:email]\": \"ziliangpeng\",\n     \"centripetal-star@users.noreply.github.com\": \"centripetal-star\",\n     \"LeonSGP43@users.noreply.github.com\": \"LeonSGP43\",\n     \"154585401+LeonSGP43@users.noreply.github.com\": \"LeonSGP43\","]},{"file":"tests/run_agent/test_background_review_cache_parity.py","hunks":["@@ -38,6 +38,9 @@ def _make_agent_stub(agent_cls):\n     agent._MEMORY_REVIEW_PROMPT = \"review memory\"\n     agent._SKILL_REVIEW_PROMPT = \"review skills\"\n     agent._COMBINED_REVIEW_PROMPT = \"review both\"\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     return agent\n \n ","@@ -183,3 +186,54 @@ def close(self):\n         \"Review fork did not inherit parent's session_id — \"\n         \"system-prompt rebuild paths would diverge.\"\n     )\n+\n+\n+def test_review_fork_inherits_parent_toolset_config():\n+    \"\"\"``tools[]`` byte-stability: fork must inherit parent's toolset config.\"\"\"\n+    import run_agent\n+\n+    agent = _make_agent_stub(run_agent.AIAgent)\n+\n+    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+\n+        def run_conversation(self, *args, **kwargs):\n+            raise RuntimeError(\"stop after recording — don't actually call the API\")\n+\n+        def shutdown_memory_provider(self):\n+            pass\n+\n+        def close(self):\n+            pass\n+\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+    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+    )\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+    )"]},{"file":"tests/run_agent/test_background_review_toolset_restriction.py","hunks":["@@ -38,6 +38,9 @@ def _make_agent_stub(agent_cls):\n     agent._MEMORY_REVIEW_PROMPT = \"review memory\"\n     agent._SKILL_REVIEW_PROMPT = \"review skills\"\n     agent._COMBINED_REVIEW_PROMPT = \"review both\"\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     return agent\n \n ","@@ -52,13 +55,8 @@ def start(self):\n             self._target()\n \n \n-def test_background_review_does_not_narrow_toolset_schema():\n-    \"\"\"The review fork must NOT pass enabled_toolsets to AIAgent.\n-\n-    Narrowing the schema diverges the ``tools`` cache key from the parent's,\n-    which sits above ``system`` in Anthropic's cache hierarchy and forces a\n-    full prefix-cache miss on every review (see #25322, PR #17276).\n-    \"\"\"\n+def test_background_review_matches_parent_toolset_config():\n+    \"\"\"Fork must receive parent's toolset config so ``tools[]`` cache key matches.\"\"\"\n     import run_agent\n \n     agent = _make_agent_stub(run_agent.AIAgent)","@@ -66,6 +64,7 @@ def test_background_review_does_not_narrow_toolset_schema():\n \n     def _capture_init(self, *args, **kwargs):\n         captured[\"enabled_toolsets\"] = kwargs.get(\"enabled_toolsets\", \"UNSET\")\n+        captured[\"disabled_toolsets\"] = kwargs.get(\"disabled_toolsets\", \"UNSET\")\n         raise RuntimeError(\"stop after capturing init args\")\n \n     with patch.object(run_agent.AIAgent, \"__init__\", _capture_init), \\","@@ -77,11 +76,13 @@ def _capture_init(self, *args, **kwargs):\n         )\n \n     assert \"enabled_toolsets\" in captured, \"AIAgent.__init__ was not called\"\n-    # The kwarg must be absent — letting AIAgent inherit the default full\n-    # toolset so the schema bytes match the parent's.\n-    assert captured[\"enabled_toolsets\"] == \"UNSET\", (\n-        f\"Review fork narrowed the toolset schema (got {captured['enabled_toolsets']!r}), \"\n-        \"which breaks prefix-cache parity with the parent.\"\n+    assert captured[\"enabled_toolsets\"] == agent.enabled_toolsets, (\n+        f\"enabled_toolsets mismatch: {captured['enabled_toolsets']!r} \"\n+        f\"vs expected {agent.enabled_toolsets!r}\"\n+    )\n+    assert captured[\"disabled_toolsets\"] == agent.disabled_toolsets, (\n+        f\"disabled_toolsets mismatch: {captured['disabled_toolsets']!r} \"\n+        f\"vs expected {agent.disabled_toolsets!r}\"\n     )\n \n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}