diff --git a/agent/background_review.py b/agent/background_review.py index 5488da08de39..ba65b2b1bc8e 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -390,6 +390,9 @@ def _bg_review_auto_deny(command, description, **kwargs): # parent below so memory(action="add") writes from # the review still land on disk; the review just # has zero side effects on external providers. + # Match parent's toolset config so ``tools[]`` is byte-identical + # in the request body — Anthropic's cache key includes it. + # (The runtime whitelist below still restricts dispatch.) review_agent = AIAgent( model=agent.model, max_iterations=16, @@ -401,6 +404,8 @@ def _bg_review_auto_deny(command, description, **kwargs): api_key=_parent_runtime.get("api_key") or None, credential_pool=getattr(agent, "_credential_pool", None), parent_session_id=agent.session_id, + enabled_toolsets=getattr(agent, "enabled_toolsets", None), + disabled_toolsets=getattr(agent, "disabled_toolsets", None), skip_memory=True, ) review_agent._memory_write_origin = "background_review" diff --git a/scripts/release.py b/scripts/release.py index 718b1079a0c7..a7c9436b2c5f 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -713,6 +713,7 @@ "9219265+cresslank@users.noreply.github.com": "cresslank", "trevmanthony@gmail.com": "trevthefoolish", "ziliangpeng@users.noreply.github.com": "ziliangpeng", + "ziliangdotme@gmail.com": "ziliangpeng", "centripetal-star@users.noreply.github.com": "centripetal-star", "LeonSGP43@users.noreply.github.com": "LeonSGP43", "154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43", diff --git a/tests/run_agent/test_background_review_cache_parity.py b/tests/run_agent/test_background_review_cache_parity.py index ac91cf75f7a6..58a2dfa4812f 100644 --- a/tests/run_agent/test_background_review_cache_parity.py +++ b/tests/run_agent/test_background_review_cache_parity.py @@ -38,6 +38,9 @@ def _make_agent_stub(agent_cls): agent._MEMORY_REVIEW_PROMPT = "review memory" agent._SKILL_REVIEW_PROMPT = "review skills" agent._COMBINED_REVIEW_PROMPT = "review both" + # Non-None so the test catches a missing-kwarg regression. + agent.enabled_toolsets = ["memory", "skills", "terminal"] + agent.disabled_toolsets = ["spotify", "feishu_doc"] return agent @@ -183,3 +186,54 @@ def close(self): "Review fork did not inherit parent's session_id — " "system-prompt rebuild paths would diverge." ) + + +def test_review_fork_inherits_parent_toolset_config(): + """``tools[]`` byte-stability: fork must inherit parent's toolset config.""" + import run_agent + + agent = _make_agent_stub(run_agent.AIAgent) + + captured = {} + + class _Recorder: + def __init__(self, *args, **kwargs): + captured["enabled_toolsets"] = kwargs.get("enabled_toolsets") + captured["disabled_toolsets"] = kwargs.get("disabled_toolsets") + self._cached_system_prompt = None + self._memory_write_origin = None + self._memory_write_context = None + self._memory_store = None + self._memory_enabled = None + self._user_profile_enabled = None + self._memory_nudge_interval = None + self._skill_nudge_interval = None + self.suppress_status_output = None + self.session_start = None + self.session_id = None + + def run_conversation(self, *args, **kwargs): + raise RuntimeError("stop after recording — don't actually call the API") + + def shutdown_memory_provider(self): + pass + + def close(self): + pass + + with patch.object(run_agent, "AIAgent", _Recorder), \ + patch("threading.Thread", _SyncThread): + agent._spawn_background_review( + messages_snapshot=[], + review_memory=True, + review_skills=False, + ) + + assert captured.get("enabled_toolsets") == agent.enabled_toolsets, ( + f"enabled_toolsets mismatch: {captured.get('enabled_toolsets')!r} " + f"vs expected {agent.enabled_toolsets!r}" + ) + assert captured.get("disabled_toolsets") == agent.disabled_toolsets, ( + f"disabled_toolsets mismatch: {captured.get('disabled_toolsets')!r} " + f"vs expected {agent.disabled_toolsets!r}" + ) diff --git a/tests/run_agent/test_background_review_toolset_restriction.py b/tests/run_agent/test_background_review_toolset_restriction.py index 7eea665b86f1..9682014ee44e 100644 --- a/tests/run_agent/test_background_review_toolset_restriction.py +++ b/tests/run_agent/test_background_review_toolset_restriction.py @@ -38,6 +38,9 @@ def _make_agent_stub(agent_cls): agent._MEMORY_REVIEW_PROMPT = "review memory" agent._SKILL_REVIEW_PROMPT = "review skills" agent._COMBINED_REVIEW_PROMPT = "review both" + # Non-None so the test catches a missing-kwarg regression. + agent.enabled_toolsets = ["memory", "skills", "terminal"] + agent.disabled_toolsets = ["spotify", "feishu_doc"] return agent @@ -52,13 +55,8 @@ def start(self): self._target() -def test_background_review_does_not_narrow_toolset_schema(): - """The review fork must NOT pass enabled_toolsets to AIAgent. - - Narrowing the schema diverges the ``tools`` cache key from the parent's, - which sits above ``system`` in Anthropic's cache hierarchy and forces a - full prefix-cache miss on every review (see #25322, PR #17276). - """ +def test_background_review_matches_parent_toolset_config(): + """Fork must receive parent's toolset config so ``tools[]`` cache key matches.""" import run_agent agent = _make_agent_stub(run_agent.AIAgent) @@ -66,6 +64,7 @@ def test_background_review_does_not_narrow_toolset_schema(): def _capture_init(self, *args, **kwargs): captured["enabled_toolsets"] = kwargs.get("enabled_toolsets", "UNSET") + captured["disabled_toolsets"] = kwargs.get("disabled_toolsets", "UNSET") raise RuntimeError("stop after capturing init args") with patch.object(run_agent.AIAgent, "__init__", _capture_init), \ @@ -77,11 +76,13 @@ def _capture_init(self, *args, **kwargs): ) assert "enabled_toolsets" in captured, "AIAgent.__init__ was not called" - # The kwarg must be absent — letting AIAgent inherit the default full - # toolset so the schema bytes match the parent's. - assert captured["enabled_toolsets"] == "UNSET", ( - f"Review fork narrowed the toolset schema (got {captured['enabled_toolsets']!r}), " - "which breaks prefix-cache parity with the parent." + assert captured["enabled_toolsets"] == agent.enabled_toolsets, ( + f"enabled_toolsets mismatch: {captured['enabled_toolsets']!r} " + f"vs expected {agent.enabled_toolsets!r}" + ) + assert captured["disabled_toolsets"] == agent.disabled_toolsets, ( + f"disabled_toolsets mismatch: {captured['disabled_toolsets']!r} " + f"vs expected {agent.disabled_toolsets!r}" )