diff --git a/run_agent.py b/run_agent.py index 4d414587e..359a92185 100644 --- a/run_agent.py +++ b/run_agent.py @@ -7466,6 +7466,16 @@ class AIAgent: old_text=function_args.get("old_text"), store=self._memory_store, ) + # Bridge: notify external memory provider of built-in memory writes + if self._memory_manager and function_args.get("action") in ("add", "replace"): + try: + self._memory_manager.on_memory_write( + function_args.get("action", ""), + target, + function_args.get("content", ""), + ) + except Exception: + pass tool_duration = time.time() - tool_start_time if self._should_emit_quiet_tool_messages(): self._vprint(f" {_get_cute_tool_message_impl('memory', function_args, tool_duration, result=function_result)}") diff --git a/tests/agent/test_memory_provider.py b/tests/agent/test_memory_provider.py index 505f40bd5..eba772a04 100644 --- a/tests/agent/test_memory_provider.py +++ b/tests/agent/test_memory_provider.py @@ -736,3 +736,61 @@ class TestCommitMemorySessionRouting: mgr.add_provider(bad) mgr.on_session_end([]) # must not raise + + +# --------------------------------------------------------------------------- +# on_memory_write bridge — must fire from both concurrent AND sequential paths +# --------------------------------------------------------------------------- + + +class TestOnMemoryWriteBridge: + """Verify that MemoryManager.on_memory_write is called when built-in + memory writes happen. This is a regression test for #10174 where the + sequential tool execution path (_execute_tool_calls_sequential) was + missing the bridge call, so single memory tool calls never notified + external memory providers. + """ + + def test_on_memory_write_add(self): + """on_memory_write fires for 'add' actions.""" + mgr = MemoryManager() + p = FakeMemoryProvider("ext") + mgr.add_provider(p) + + mgr.on_memory_write("add", "memory", "new fact") + assert p.memory_writes == [("add", "memory", "new fact")] + + def test_on_memory_write_replace(self): + """on_memory_write fires for 'replace' actions.""" + mgr = MemoryManager() + p = FakeMemoryProvider("ext") + mgr.add_provider(p) + + mgr.on_memory_write("replace", "user", "updated pref") + assert p.memory_writes == [("replace", "user", "updated pref")] + + def test_on_memory_write_remove_not_bridged(self): + """The bridge intentionally skips 'remove' — only add/replace notify.""" + # This tests the contract that run_agent.py checks: + # function_args.get("action") in ("add", "replace") + mgr = MemoryManager() + p = FakeMemoryProvider("ext") + mgr.add_provider(p) + + # Manager itself doesn't filter — run_agent.py does. + # But providers should handle remove gracefully. + mgr.on_memory_write("remove", "memory", "old fact") + assert p.memory_writes == [("remove", "memory", "old fact")] + + def test_on_memory_write_tolerates_provider_failure(self): + """If a provider's on_memory_write raises, others still get notified.""" + mgr = MemoryManager() + bad = FakeMemoryProvider("builtin") + bad.on_memory_write = MagicMock(side_effect=RuntimeError("boom")) + good = FakeMemoryProvider("good") + mgr.add_provider(bad) + mgr.add_provider(good) + + mgr.on_memory_write("add", "user", "test") + # Good provider still received the call despite bad provider crashing + assert good.memory_writes == [("add", "user", "test")]