|
| 1 | +import gc |
1 | 2 | import unittest.mock |
| 3 | +import weakref |
2 | 4 |
|
3 | 5 | import pytest |
4 | 6 |
|
5 | 7 | from strands import Agent, tool |
| 8 | +from strands.tools.tool_provider import ToolProvider |
6 | 9 |
|
7 | 10 |
|
8 | 11 | @pytest.fixture |
@@ -312,3 +315,122 @@ def test_agent_tool_caller_interrupt_activated(): |
312 | 315 | exp_message = r"cannot directly call tool during interrupt" |
313 | 316 | with pytest.raises(RuntimeError, match=exp_message): |
314 | 317 | agent.tool.test_tool() |
| 318 | + |
| 319 | + |
| 320 | +def test_agent_collected_without_cyclic_gc(): |
| 321 | + """Verify that Agent is promptly collectable (no persistent reference cycle). |
| 322 | +
|
| 323 | + This ensures that the weakref-based back-references in _ToolCaller and _PluginRegistry |
| 324 | + do not create reference cycles that would delay cleanup until interpreter shutdown. |
| 325 | + When cleanup is deferred to interpreter shutdown, MCPClient.stop() hangs because its |
| 326 | + background thread cannot complete async cleanup at that point. |
| 327 | +
|
| 328 | + Note: On some platforms/versions (e.g. Python 3.14 with deferred refcounting), del may |
| 329 | + not immediately trigger collection. A single gc.collect() is allowed as a fallback since |
| 330 | + it still proves no persistent cycle exists — the agent is collected promptly, not deferred |
| 331 | + to interpreter shutdown. |
| 332 | + """ |
| 333 | + gc.disable() |
| 334 | + try: |
| 335 | + agent = Agent() |
| 336 | + ref = weakref.ref(agent) |
| 337 | + del agent |
| 338 | + |
| 339 | + if ref() is not None: |
| 340 | + # Deferred refcounting (Python 3.14+) may not collect immediately on del; |
| 341 | + # a single gc.collect() should still reclaim it since there are no cycles. |
| 342 | + gc.collect() |
| 343 | + |
| 344 | + assert ref() is None, "Agent was not collected; a reference cycle likely exists" |
| 345 | + finally: |
| 346 | + gc.enable() |
| 347 | + |
| 348 | + |
| 349 | +class _MockToolProvider(ToolProvider): |
| 350 | + """Minimal ToolProvider that tracks cleanup calls, mimicking MCPClient lifecycle.""" |
| 351 | + |
| 352 | + def __init__(self): |
| 353 | + self.consumers: set = set() |
| 354 | + self.cleanup_called = False |
| 355 | + |
| 356 | + async def load_tools(self, **kwargs): |
| 357 | + return [] |
| 358 | + |
| 359 | + def add_consumer(self, consumer_id, **kwargs): |
| 360 | + self.consumers.add(consumer_id) |
| 361 | + |
| 362 | + def remove_consumer(self, consumer_id, **kwargs): |
| 363 | + self.consumers.discard(consumer_id) |
| 364 | + if not self.consumers: |
| 365 | + self.cleanup_called = True |
| 366 | + |
| 367 | + |
| 368 | +def test_agent_with_tool_provider_cleaned_up_when_function_returns(): |
| 369 | + """Replicate the hang from issue #1732: Agent with MCPClient created inside a function. |
| 370 | +
|
| 371 | + When an Agent using a managed MCPClient (as ToolProvider) is created inside a function, |
| 372 | + the script used to hang on exit. The Agent went out of scope when the function returned, |
| 373 | + but circular references (Agent → _ToolCaller._agent → Agent) prevented refcount-based |
| 374 | + destruction. Cleanup was deferred to the cyclic GC during interpreter shutdown, where |
| 375 | + MCPClient.stop() → thread.join() would hang. |
| 376 | +
|
| 377 | + This test verifies that with the weakref fix, the Agent is destroyed immediately when |
| 378 | + the function returns, and the tool provider's cleanup runs promptly. |
| 379 | + """ |
| 380 | + provider = _MockToolProvider() |
| 381 | + |
| 382 | + def get_agent(): |
| 383 | + return Agent(tools=[provider]) |
| 384 | + |
| 385 | + def main(): |
| 386 | + agent = get_agent() # noqa: F841 |
| 387 | + |
| 388 | + gc.disable() |
| 389 | + try: |
| 390 | + main() |
| 391 | + |
| 392 | + if not provider.cleanup_called: |
| 393 | + # Deferred refcounting (Python 3.14+) may not collect immediately on scope exit; |
| 394 | + # a single gc.collect() should still reclaim it since there are no cycles. |
| 395 | + gc.collect() |
| 396 | + |
| 397 | + assert provider.cleanup_called, ( |
| 398 | + "Tool provider was not cleaned up when the function returned; Agent likely leaked due to a reference cycle" |
| 399 | + ) |
| 400 | + finally: |
| 401 | + gc.enable() |
| 402 | + |
| 403 | + |
| 404 | +def test_agent_with_tool_provider_cleaned_up_on_del(): |
| 405 | + """Replicate the working case from issue #1732: Agent at module scope, explicitly deleted. |
| 406 | +
|
| 407 | + In the issue, an Agent created at module level did not hang because module-level variables |
| 408 | + are cleared early during interpreter shutdown (while the runtime is still functional). |
| 409 | + This test verifies the equivalent: explicitly deleting the agent triggers immediate cleanup. |
| 410 | + """ |
| 411 | + provider = _MockToolProvider() |
| 412 | + |
| 413 | + agent = Agent(tools=[provider]) |
| 414 | + assert not provider.cleanup_called |
| 415 | + |
| 416 | + del agent |
| 417 | + |
| 418 | + if not provider.cleanup_called: |
| 419 | + # Deferred refcounting (Python 3.14+) may not collect immediately on del; |
| 420 | + # a single gc.collect() should still reclaim it since there are no cycles. |
| 421 | + gc.collect() |
| 422 | + |
| 423 | + assert provider.cleanup_called, "Tool provider was not cleaned up after del agent" |
| 424 | + |
| 425 | + |
| 426 | +def test_tool_caller_raises_reference_error_after_agent_collected(): |
| 427 | + """Verify _ToolCaller raises ReferenceError when the Agent has been garbage collected.""" |
| 428 | + agent = Agent() |
| 429 | + caller = agent.tool_caller |
| 430 | + # Clear the weak reference by replacing it directly |
| 431 | + caller._agent_ref = weakref.ref(agent) |
| 432 | + del agent |
| 433 | + gc.collect() |
| 434 | + |
| 435 | + with pytest.raises(ReferenceError, match="Agent has been garbage collected"): |
| 436 | + _ = caller._agent |
0 commit comments