|
1 | 1 | """Tests for AgentCoreMemorySessionManager.""" |
2 | 2 |
|
| 3 | +import asyncio |
| 4 | +import inspect |
3 | 5 | import logging |
4 | 6 | import time |
5 | 7 | from datetime import datetime, timezone |
|
9 | 11 | from botocore.config import Config as BotocoreConfig |
10 | 12 | from botocore.exceptions import ClientError |
11 | 13 | from strands.agent.agent import Agent |
| 14 | +from strands.experimental.hooks.multiagent.events import ( |
| 15 | + AfterMultiAgentInvocationEvent, |
| 16 | + AfterNodeCallEvent, |
| 17 | + MultiAgentInitializedEvent, |
| 18 | +) |
12 | 19 | from strands.hooks import AfterInvocationEvent, MessageAddedEvent |
13 | 20 | from strands.hooks.registry import HookRegistry |
14 | 21 | from strands.types.exceptions import SessionException |
@@ -3580,3 +3587,123 @@ def test_retrieve_customer_context_works(self, mock_memory_client): |
3580 | 3587 |
|
3581 | 3588 | mock_memory_client.retrieve_memories.assert_called_once() |
3582 | 3589 | assert "<user_context>" in mock_agent.messages[0]["content"][0]["text"] |
| 3590 | + |
| 3591 | + |
| 3592 | +class TestAsyncMode: |
| 3593 | + """Tests for async_mode: callbacks must not block the event loop.""" |
| 3594 | + |
| 3595 | + def test_async_mode_defaults_to_false(self, agentcore_config): |
| 3596 | + assert agentcore_config.async_mode is False |
| 3597 | + |
| 3598 | + def test_sync_mode_registers_sync_callbacks(self, mock_memory_client): |
| 3599 | + """async_mode=False: all MessageAddedEvent/AfterInvocationEvent callbacks are sync.""" |
| 3600 | + config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", batch_size=5, async_mode=False) |
| 3601 | + manager = _create_session_manager(config, mock_memory_client) |
| 3602 | + registry = HookRegistry() |
| 3603 | + manager.register_hooks(registry) |
| 3604 | + |
| 3605 | + for event_type in (MessageAddedEvent, AfterInvocationEvent): |
| 3606 | + for cb in registry.get_callbacks_for( |
| 3607 | + event_type(agent=Mock(), message={"role": "user", "content": [{"text": "x"}]}) |
| 3608 | + if event_type is MessageAddedEvent |
| 3609 | + else event_type(agent=Mock()) |
| 3610 | + ): |
| 3611 | + assert not inspect.iscoroutinefunction(cb), f"Sync mode leaked an async callback for {event_type}" |
| 3612 | + |
| 3613 | + def test_async_mode_registers_async_callbacks(self, mock_memory_client): |
| 3614 | + """async_mode=True: MessageAddedEvent and AfterInvocationEvent callbacks are coroutine functions.""" |
| 3615 | + config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", batch_size=5, async_mode=True) |
| 3616 | + manager = _create_session_manager(config, mock_memory_client) |
| 3617 | + registry = HookRegistry() |
| 3618 | + manager.register_hooks(registry) |
| 3619 | + |
| 3620 | + msg_callbacks = registry.get_callbacks_for( |
| 3621 | + MessageAddedEvent(agent=Mock(), message={"role": "user", "content": [{"text": "x"}]}) |
| 3622 | + ) |
| 3623 | + assert msg_callbacks, "No MessageAddedEvent callbacks registered in async mode" |
| 3624 | + assert all(inspect.iscoroutinefunction(cb) for cb in msg_callbacks) |
| 3625 | + |
| 3626 | + after_callbacks = registry.get_callbacks_for(AfterInvocationEvent(agent=Mock())) |
| 3627 | + assert after_callbacks, "No AfterInvocationEvent callbacks registered in async mode" |
| 3628 | + assert all(inspect.iscoroutinefunction(cb) for cb in after_callbacks) |
| 3629 | + |
| 3630 | + async def test_async_mode_does_not_block_event_loop(self, mock_memory_client): |
| 3631 | + """The async hooks run boto3 on a worker thread, so the event loop can make progress concurrently.""" |
| 3632 | + config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", async_mode=True) |
| 3633 | + manager = _create_session_manager(config, mock_memory_client) |
| 3634 | + |
| 3635 | + # Simulate each sync session-manager method blocking on boto3. |
| 3636 | + def slow_append_message(message, agent, **kwargs): |
| 3637 | + time.sleep(0.2) |
| 3638 | + |
| 3639 | + def slow_sync_agent(agent, **kwargs): |
| 3640 | + time.sleep(0.2) |
| 3641 | + |
| 3642 | + manager.append_message = slow_append_message |
| 3643 | + manager.sync_agent = slow_sync_agent |
| 3644 | + |
| 3645 | + registry = HookRegistry() |
| 3646 | + manager.register_hooks(registry) |
| 3647 | + |
| 3648 | + persist_callbacks = [ |
| 3649 | + cb |
| 3650 | + for cb in registry.get_callbacks_for( |
| 3651 | + MessageAddedEvent(agent=Mock(), message={"role": "user", "content": [{"text": "x"}]}) |
| 3652 | + ) |
| 3653 | + if asyncio.iscoroutinefunction(cb) |
| 3654 | + ] |
| 3655 | + assert persist_callbacks |
| 3656 | + |
| 3657 | + event = MessageAddedEvent(agent=Mock(), message={"role": "user", "content": [{"text": "hello"}]}) |
| 3658 | + |
| 3659 | + # Ticker proves the event loop made progress while the hook awaited to_thread. |
| 3660 | + ticks = 0 |
| 3661 | + |
| 3662 | + async def ticker(): |
| 3663 | + nonlocal ticks |
| 3664 | + while True: |
| 3665 | + await asyncio.sleep(0.01) |
| 3666 | + ticks += 1 |
| 3667 | + |
| 3668 | + ticker_task = asyncio.create_task(ticker()) |
| 3669 | + try: |
| 3670 | + # Run the persist callback (append_message + sync_agent); both sleep 0.2s on a worker thread. |
| 3671 | + await persist_callbacks[0](event) |
| 3672 | + finally: |
| 3673 | + ticker_task.cancel() |
| 3674 | + |
| 3675 | + assert ticks > 5, f"Event loop was blocked; only {ticks} ticks recorded" |
| 3676 | + |
| 3677 | + async def test_async_mode_batching_registers_flush_callback(self, mock_memory_client): |
| 3678 | + """async_mode=True with batch_size>1: AfterInvocationEvent gets both sync_agent and flush callbacks.""" |
| 3679 | + config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", batch_size=5, async_mode=True) |
| 3680 | + manager = _create_session_manager(config, mock_memory_client) |
| 3681 | + registry = HookRegistry() |
| 3682 | + manager.register_hooks(registry) |
| 3683 | + |
| 3684 | + after_callbacks = list(registry.get_callbacks_for(AfterInvocationEvent(agent=Mock()))) |
| 3685 | + assert len(after_callbacks) == 2 |
| 3686 | + assert all(asyncio.iscoroutinefunction(cb) for cb in after_callbacks) |
| 3687 | + |
| 3688 | + def test_async_mode_registers_multi_agent_callbacks(self, mock_memory_client): |
| 3689 | + """async_mode=True: multi-agent events get async callbacks (parity with sync mode).""" |
| 3690 | + config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", async_mode=True) |
| 3691 | + manager = _create_session_manager(config, mock_memory_client) |
| 3692 | + registry = HookRegistry() |
| 3693 | + manager.register_hooks(registry) |
| 3694 | + |
| 3695 | + for event_type in (MultiAgentInitializedEvent, AfterNodeCallEvent, AfterMultiAgentInvocationEvent): |
| 3696 | + callbacks = registry._registered_callbacks.get(event_type, []) |
| 3697 | + assert callbacks, f"No callbacks registered for {event_type.__name__}" |
| 3698 | + assert all(asyncio.iscoroutinefunction(cb) for cb in callbacks) |
| 3699 | + |
| 3700 | + def test_async_mode_logs_sync_invocation_warning(self, mock_memory_client, caplog): |
| 3701 | + """async_mode=True emits a WARNING at register_hooks time pointing users to stream_async/invoke_async.""" |
| 3702 | + config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", async_mode=True) |
| 3703 | + manager = _create_session_manager(config, mock_memory_client) |
| 3704 | + registry = HookRegistry() |
| 3705 | + |
| 3706 | + with caplog.at_level(logging.WARNING, logger="bedrock_agentcore.memory.integrations.strands.session_manager"): |
| 3707 | + manager.register_hooks(registry) |
| 3708 | + |
| 3709 | + assert any("async_mode=True" in rec.message and "stream_async" in rec.message for rec in caplog.records) |
0 commit comments