diff --git a/gateway/run.py b/gateway/run.py index 8b40bf62..2e8f393b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -9404,11 +9404,17 @@ class GatewayRunner: @classmethod def _extract_cache_busting_config(cls, user_config: dict | None) -> dict: - """Pull the subset of config values that must bust the agent cache. + """Pull values that must bust the cached agent. - Returns a flat dict keyed by 'section.key'. Missing keys and - non-dict sections yield None values, which still contribute to - the signature (so 'absent' vs 'present-and-null' differ). + Returns a flat dict keyed by 'section.key'. Missing config keys and + non-dict sections yield None values, which still contribute to the + signature (so 'absent' vs 'present-and-null' differ). + + The live tool registry generation is included too. MCP reloads and + dynamic MCP tool-list changes mutate the registry without necessarily + changing config.yaml. Cached AIAgent instances freeze their tool + schemas at construction time, so a registry generation change must + rebuild the agent before the next turn. """ out: Dict[str, Any] = {} cfg = user_config if isinstance(user_config, dict) else {} @@ -9418,6 +9424,12 @@ class GatewayRunner: out[f"{section}.{key}"] = section_val.get(key) else: out[f"{section}.{key}"] = None + try: + from tools.registry import registry + + out["tools.registry_generation"] = getattr(registry, "_generation", None) + except Exception: + out["tools.registry_generation"] = None return out @staticmethod diff --git a/tests/gateway/test_agent_cache.py b/tests/gateway/test_agent_cache.py index f3e63b07..abf0ce34 100644 --- a/tests/gateway/test_agent_cache.py +++ b/tests/gateway/test_agent_cache.py @@ -170,6 +170,22 @@ class TestAgentConfigSignature: ) assert sig_a == sig_b + def test_tool_registry_generation_change_busts_cache(self): + """MCP reloads mutate the tool registry, so cached agents must rebuild.""" + from gateway.run import GatewayRunner + + runtime = {"api_key": "k", "base_url": "u", "provider": "p"} + sig_before = GatewayRunner._agent_config_signature( + "m", runtime, ["telegram"], "", + cache_keys={"tools.registry_generation": 10}, + ) + sig_after = GatewayRunner._agent_config_signature( + "m", runtime, ["telegram"], "", + cache_keys={"tools.registry_generation": 11}, + ) + + assert sig_before != sig_after + class TestExtractCacheBustingConfig: """Verify _extract_cache_busting_config pulls the documented subset of @@ -229,6 +245,17 @@ class TestExtractCacheBustingConfig: out = GatewayRunner._extract_cache_busting_config(None) for section, key in GatewayRunner._CACHE_BUSTING_CONFIG_KEYS: assert out[f"{section}.{key}"] is None + assert "tools.registry_generation" in out + + def test_extract_includes_live_tool_registry_generation(self, monkeypatch): + from gateway.run import GatewayRunner + from tools.registry import registry + + monkeypatch.setattr(registry, "_generation", 12345) + + out = GatewayRunner._extract_cache_busting_config({}) + + assert out["tools.registry_generation"] == 12345 def test_full_round_trip_busts_cache_on_real_edit(self): """End-to-end: simulate a config edit on main and verify the