diff --git a/run_agent.py b/run_agent.py index 984c8e71d..1372def27 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3304,10 +3304,15 @@ class AIAgent: logger.warning("Background memory/skill review failed: %s", e) self._emit_auxiliary_failure("background review", e) finally: - # Close all resources (httpx client, subprocesses, etc.) so - # GC doesn't try to clean them up on a dead asyncio event - # loop (which produces "Event loop is closed" errors). + # Background review agents can initialize memory providers + # (for example Hindsight) that own their own network clients. + # Explicitly stop those providers before closing the agent so + # their aiohttp sessions do not leak until GC/process exit. if review_agent is not None: + try: + review_agent.shutdown_memory_provider() + except Exception: + pass try: review_agent.close() except Exception: diff --git a/tests/run_agent/test_background_review.py b/tests/run_agent/test_background_review.py new file mode 100644 index 000000000..79ececb48 --- /dev/null +++ b/tests/run_agent/test_background_review.py @@ -0,0 +1,66 @@ +"""Regression tests for background review agent cleanup.""" + +from __future__ import annotations + +import run_agent as run_agent_module +from run_agent import AIAgent + + +def _bare_agent() -> AIAgent: + agent = object.__new__(AIAgent) + agent.model = "fake-model" + agent.platform = "telegram" + agent.provider = "openai" + agent._memory_store = object() + agent._memory_enabled = True + agent._user_profile_enabled = False + agent._MEMORY_REVIEW_PROMPT = "review memory" + agent._SKILL_REVIEW_PROMPT = "review skills" + agent._COMBINED_REVIEW_PROMPT = "review both" + agent.background_review_callback = None + agent._safe_print = lambda *_args, **_kwargs: None + return agent + + +class ImmediateThread: + def __init__(self, *, target, daemon=None, name=None): + self._target = target + + def start(self): + self._target() + + +def test_background_review_shuts_down_memory_provider_before_close(monkeypatch): + events = [] + + class FakeReviewAgent: + def __init__(self, **kwargs): + events.append(("init", kwargs)) + self._session_messages = [] + + def run_conversation(self, **kwargs): + events.append(("run_conversation", kwargs)) + + def shutdown_memory_provider(self): + events.append(("shutdown_memory_provider", None)) + + def close(self): + events.append(("close", None)) + + monkeypatch.setattr(run_agent_module, "AIAgent", FakeReviewAgent) + monkeypatch.setattr(run_agent_module.threading, "Thread", ImmediateThread) + + agent = _bare_agent() + + AIAgent._spawn_background_review( + agent, + messages_snapshot=[{"role": "user", "content": "hello"}], + review_memory=True, + ) + + assert [name for name, _payload in events] == [ + "init", + "run_conversation", + "shutdown_memory_provider", + "close", + ]