Skip to content

Commit c11771c

Browse files
committed
feat: add async support to MemorySessionManager
1 parent 82eddf3 commit c11771c

3 files changed

Lines changed: 218 additions & 4 deletions

File tree

src/bedrock_agentcore/memory/integrations/strands/config.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,22 @@ class AgentCoreMemoryConfig(BaseModel):
6868
persistence_mode: Controls what gets persisted to AgentCore Memory.
6969
FULL (default): persist everything. NONE: disable all persistence while keeping
7070
local state management and memory injection working.
71+
async_mode: When True, the session manager registers async hook callbacks that
72+
offload the per-turn boto3 calls (append_message, sync_agent,
73+
retrieve_customer_context, and buffer flushes) to a thread via
74+
asyncio.to_thread, keeping the asyncio event loop unblocked. Intended for
75+
async agent runtimes (e.g. Agent.stream_async() in a WebSocket server).
76+
Default is False (existing synchronous behavior, unchanged).
77+
78+
Requires async invocation (stream_async / invoke_async). Sync agent() calls
79+
will raise RuntimeError from Strands' hook registry because it refuses to
80+
dispatch coroutine callbacks through the sync path.
81+
82+
Note: this does NOT cover agent initialization. Strands disallows async
83+
callbacks for AgentInitializedEvent, so the read_session / read_agent /
84+
list_messages calls that run during Agent(...) construction still block
85+
the calling thread. If that matters, construct the Agent off-loop
86+
(e.g. `await asyncio.to_thread(Agent, ...)`).
7187
"""
7288

7389
memory_id: str = Field(min_length=1)
@@ -81,6 +97,7 @@ class AgentCoreMemoryConfig(BaseModel):
8197
default_metadata: Optional[Dict[str, Any]] = None
8298
metadata_provider: Optional[Callable[[], Dict[str, Any]]] = None
8399
persistence_mode: PersistenceMode = Field(default=PersistenceMode.FULL)
100+
async_mode: bool = Field(default=False)
84101

85102
@field_validator("default_metadata", mode="before")
86103
@classmethod

src/bedrock_agentcore/memory/integrations/strands/session_manager.py

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""AgentCore Memory-based session manager for Bedrock AgentCore Memory integration."""
22

3+
import asyncio
34
import json
45
import logging
56
import threading
@@ -10,7 +11,13 @@
1011

1112
import boto3
1213
from botocore.config import Config as BotocoreConfig
14+
from strands.experimental.hooks.multiagent.events import (
15+
AfterMultiAgentInvocationEvent,
16+
AfterNodeCallEvent,
17+
MultiAgentInitializedEvent,
18+
)
1319
from strands.hooks import AfterInvocationEvent, MessageAddedEvent
20+
from strands.hooks.events import AgentInitializedEvent
1421
from strands.hooks.registry import HookRegistry
1522
from strands.session.repository_session_manager import RepositorySessionManager
1623
from strands.session.session_repository import SessionRepository
@@ -906,16 +913,79 @@ def retrieve_for_namespace(namespace: str, retrieval_config: RetrievalConfig):
906913
def register_hooks(self, registry: HookRegistry, **kwargs) -> None:
907914
"""Register additional hooks.
908915
916+
In sync mode (the default), delegates to the base class and adds the
917+
retrieve_customer_context + batching callbacks synchronously, preserving
918+
existing behavior exactly.
919+
920+
In async mode, registers async callbacks that wrap every per-turn
921+
boto3-backed operation (append_message, sync_agent, buffer flushes,
922+
customer-context retrieval) with asyncio.to_thread, so the asyncio
923+
event loop stays free while boto3 is blocking on the network.
924+
925+
Note: AgentInitializedEvent cannot be async per Strands' HookRegistry,
926+
so agent restoration (read_session / read_agent / list_messages) still
927+
blocks the calling thread in async mode — see AgentCoreMemoryConfig
928+
docstring for mitigations.
929+
909930
Args:
910931
registry (HookRegistry): The hook registry to register callbacks with.
911932
**kwargs: Additional keyword arguments.
912933
"""
913-
RepositorySessionManager.register_hooks(self, registry, **kwargs)
914-
registry.add_callback(MessageAddedEvent, lambda event: self.retrieve_customer_context(event))
934+
if not self.config.async_mode:
935+
RepositorySessionManager.register_hooks(self, registry, **kwargs)
936+
registry.add_callback(MessageAddedEvent, lambda event: self.retrieve_customer_context(event))
937+
938+
# Only register AfterInvocationEvent hook when batching is enabled
939+
if self.config.batch_size > 1:
940+
registry.add_callback(AfterInvocationEvent, lambda event: self._flush_messages())
941+
return
942+
943+
# Async mode: register async callbacks that offload the existing sync
944+
# methods to a worker thread via asyncio.to_thread. AgentInitializedEvent
945+
# must stay sync (Strands disallows async callbacks on this event; see
946+
# strands/hooks/registry.py:174).
947+
logger.warning(
948+
"AgentCoreMemorySessionManager async_mode=True: the agent must be invoked "
949+
"via the async path (e.g. agent.stream_async(...) or agent.invoke_async(...)). "
950+
"Sync invocation will raise RuntimeError from Strands' hook registry."
951+
)
952+
953+
registry.add_callback(AgentInitializedEvent, lambda event: self.initialize(event.agent))
954+
955+
async def _on_message_added_persist(event: MessageAddedEvent) -> None:
956+
await asyncio.to_thread(self.append_message, event.message, event.agent)
957+
await asyncio.to_thread(self.sync_agent, event.agent)
958+
959+
async def _on_message_added_retrieve(event: MessageAddedEvent) -> None:
960+
await asyncio.to_thread(self.retrieve_customer_context, event)
961+
962+
async def _on_after_invocation_sync(event: AfterInvocationEvent) -> None:
963+
await asyncio.to_thread(self.sync_agent, event.agent)
964+
965+
registry.add_callback(MessageAddedEvent, _on_message_added_persist)
966+
registry.add_callback(AfterInvocationEvent, _on_after_invocation_sync)
967+
registry.add_callback(MessageAddedEvent, _on_message_added_retrieve)
915968

916-
# Only register AfterInvocationEvent hook when batching is enabled
917969
if self.config.batch_size > 1:
918-
registry.add_callback(AfterInvocationEvent, lambda event: self._flush_messages())
970+
971+
async def _on_after_invocation_flush(event: AfterInvocationEvent) -> None:
972+
await asyncio.to_thread(self._flush_messages)
973+
974+
registry.add_callback(AfterInvocationEvent, _on_after_invocation_flush)
975+
976+
# Register multi-agent callbacks so async-mode parity matches sync-mode
977+
async def _on_multi_agent_initialized(event: MultiAgentInitializedEvent) -> None:
978+
await asyncio.to_thread(self.initialize_multi_agent, event.source)
979+
980+
async def _on_after_node_call(event: AfterNodeCallEvent) -> None:
981+
await asyncio.to_thread(self.sync_multi_agent, event.source)
982+
983+
async def _on_after_multi_agent_invocation(event: AfterMultiAgentInvocationEvent) -> None:
984+
await asyncio.to_thread(self.sync_multi_agent, event.source)
985+
986+
registry.add_callback(MultiAgentInitializedEvent, _on_multi_agent_initialized)
987+
registry.add_callback(AfterNodeCallEvent, _on_after_node_call)
988+
registry.add_callback(AfterMultiAgentInvocationEvent, _on_after_multi_agent_invocation)
919989

920990
@override
921991
def initialize(self, agent: "Agent", **kwargs: Any) -> None:

tests/bedrock_agentcore/memory/integrations/strands/test_agentcore_memory_session_manager.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Tests for AgentCoreMemorySessionManager."""
22

3+
import asyncio
4+
import inspect
35
import logging
46
import time
57
from datetime import datetime, timezone
@@ -9,6 +11,11 @@
911
from botocore.config import Config as BotocoreConfig
1012
from botocore.exceptions import ClientError
1113
from strands.agent.agent import Agent
14+
from strands.experimental.hooks.multiagent.events import (
15+
AfterMultiAgentInvocationEvent,
16+
AfterNodeCallEvent,
17+
MultiAgentInitializedEvent,
18+
)
1219
from strands.hooks import AfterInvocationEvent, MessageAddedEvent
1320
from strands.hooks.registry import HookRegistry
1421
from strands.types.exceptions import SessionException
@@ -3580,3 +3587,123 @@ def test_retrieve_customer_context_works(self, mock_memory_client):
35803587

35813588
mock_memory_client.retrieve_memories.assert_called_once()
35823589
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

Comments
 (0)