Skip to content

Commit 76edb16

Browse files
authored
feat: add async support to MemorySessionManager (#478)
* feat: add async support to MemorySessionManager * fix: copy and clear messages within same lock acquisition
1 parent 1042d58 commit 76edb16

3 files changed

Lines changed: 311 additions & 8 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: 89 additions & 8 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,18 @@
1011

1112
import boto3
1213
from botocore.config import Config as BotocoreConfig
14+
from strands.experimental.hooks.events import (
15+
BidiAfterInvocationEvent,
16+
BidiAgentInitializedEvent,
17+
BidiMessageAddedEvent,
18+
)
19+
from strands.experimental.hooks.multiagent.events import (
20+
AfterMultiAgentInvocationEvent,
21+
AfterNodeCallEvent,
22+
MultiAgentInitializedEvent,
23+
)
1324
from strands.hooks import AfterInvocationEvent, MessageAddedEvent
25+
from strands.hooks.events import AgentInitializedEvent
1426
from strands.hooks.registry import HookRegistry
1527
from strands.session.repository_session_manager import RepositorySessionManager
1628
from strands.session.session_repository import SessionRepository
@@ -906,16 +918,85 @@ def retrieve_for_namespace(namespace: str, retrieval_config: RetrievalConfig):
906918
def register_hooks(self, registry: HookRegistry, **kwargs) -> None:
907919
"""Register additional hooks.
908920
921+
In sync mode (the default), delegates to the base class and adds the
922+
retrieve_customer_context + batching callbacks synchronously, preserving
923+
existing behavior exactly.
924+
925+
In async mode, registers async callbacks that wrap every per-turn
926+
boto3-backed operation (append_message, sync_agent, buffer flushes,
927+
customer-context retrieval) with asyncio.to_thread, so the asyncio
928+
event loop stays free while boto3 is blocking on the network.
929+
930+
Note: AgentInitializedEvent cannot be async per Strands' HookRegistry,
931+
so agent restoration (read_session / read_agent / list_messages) still
932+
blocks the calling thread in async mode — see AgentCoreMemoryConfig
933+
docstring for mitigations.
934+
909935
Args:
910936
registry (HookRegistry): The hook registry to register callbacks with.
911937
**kwargs: Additional keyword arguments.
912938
"""
913-
RepositorySessionManager.register_hooks(self, registry, **kwargs)
914-
registry.add_callback(MessageAddedEvent, lambda event: self.retrieve_customer_context(event))
939+
if not self.config.async_mode:
940+
RepositorySessionManager.register_hooks(self, registry, **kwargs)
941+
registry.add_callback(MessageAddedEvent, lambda event: self.retrieve_customer_context(event))
942+
943+
# Only register AfterInvocationEvent hook when batching is enabled
944+
if self.config.batch_size > 1:
945+
registry.add_callback(AfterInvocationEvent, lambda event: self._flush_messages())
946+
return
947+
948+
# Async mode: register async callbacks that offload the existing sync
949+
# methods to a worker thread via asyncio.to_thread. AgentInitializedEvent
950+
# and BidiAgentInitializedEvent must stay sync (Strands disallows async
951+
# callbacks for AgentInitializedEvent — see strands/hooks/registry.py:227).
952+
logger.warning(
953+
"AgentCoreMemorySessionManager async_mode=True: the agent must be invoked "
954+
"via the async path (e.g. agent.stream_async(...) or agent.invoke_async(...)). "
955+
"Sync invocation will raise RuntimeError from Strands' hook registry."
956+
)
957+
958+
def _offload(method, *event_args):
959+
"""Build an async callback that offloads `method(*[a(event) for a in event_args])` to a thread.
960+
961+
Each entry in `event_args` is a callable that extracts an argument from the event;
962+
pass none for a zero-arg method.
963+
"""
964+
965+
async def _callback(event):
966+
await asyncio.to_thread(method, *(extract(event) for extract in event_args))
967+
968+
return _callback
969+
970+
registry.add_callback(AgentInitializedEvent, lambda event: self.initialize(event.agent))
971+
972+
async def _on_message_added_persist(event: MessageAddedEvent) -> None:
973+
await asyncio.to_thread(self.append_message, event.message, event.agent)
974+
await asyncio.to_thread(self.sync_agent, event.agent)
975+
976+
registry.add_callback(MessageAddedEvent, _on_message_added_persist)
977+
registry.add_callback(AfterInvocationEvent, _offload(self.sync_agent, lambda e: e.agent))
978+
registry.add_callback(MessageAddedEvent, _offload(self.retrieve_customer_context, lambda e: e))
915979

916-
# Only register AfterInvocationEvent hook when batching is enabled
917980
if self.config.batch_size > 1:
918-
registry.add_callback(AfterInvocationEvent, lambda event: self._flush_messages())
981+
registry.add_callback(AfterInvocationEvent, _offload(self._flush_messages))
982+
983+
# Register multi-agent callbacks so async-mode parity matches sync-mode
984+
registry.add_callback(MultiAgentInitializedEvent, _offload(self.initialize_multi_agent, lambda e: e.source))
985+
registry.add_callback(AfterNodeCallEvent, _offload(self.sync_multi_agent, lambda e: e.source))
986+
registry.add_callback(AfterMultiAgentInvocationEvent, _offload(self.sync_multi_agent, lambda e: e.source))
987+
988+
# Register BidiAgent callbacks so async-mode parity matches sync-mode.
989+
# BidiAgentInitializedEvent dispatches through invoke_callbacks (sync),
990+
# so its callback must stay sync; the other two dispatch through
991+
# invoke_callbacks_async, so async wrappers are safe.
992+
registry.add_callback(BidiAgentInitializedEvent, lambda event: self.initialize_bidi_agent(event.agent))
993+
994+
async def _on_bidi_message_added(event: BidiMessageAddedEvent) -> None:
995+
await asyncio.to_thread(self.append_bidi_message, event.message, event.agent)
996+
await asyncio.to_thread(self.sync_bidi_agent, event.agent)
997+
998+
registry.add_callback(BidiMessageAddedEvent, _on_bidi_message_added)
999+
registry.add_callback(BidiAfterInvocationEvent, _offload(self.sync_bidi_agent, lambda e: e.agent))
9191000

9201001
@override
9211002
def initialize(self, agent: "Agent", **kwargs: Any) -> None:
@@ -1071,6 +1152,7 @@ def _flush_agent_states_only(self) -> list[dict[str, Any]]:
10711152

10721153
with self._agent_state_lock:
10731154
agent_states_to_send = list(self._agent_state_buffer)
1155+
self._agent_state_buffer.clear()
10741156

10751157
if not agent_states_to_send:
10761158
return []
@@ -1101,11 +1183,10 @@ def _flush_agent_states_only(self) -> list[dict[str, Any]]:
11011183
results.append(event)
11021184
logger.debug("Flushed %d agent states for agent %s: %s", len(payloads), agent_id, event.get("eventId"))
11031185

1104-
# Clear agent state buffer only after ALL events succeed
1105-
with self._agent_state_lock:
1106-
self._agent_state_buffer.clear()
1107-
11081186
except Exception as e:
1187+
# Restore agent states to buffer so they aren't lost
1188+
with self._agent_state_lock:
1189+
self._agent_state_buffer.extend(agent_states_to_send)
11091190
logger.error("Failed to flush agent states to AgentCore Memory: %s", e)
11101191
raise SessionException(f"Failed to flush agent states: {e}") from e
11111192

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

Lines changed: 205 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,16 @@
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.events import (
15+
BidiAfterInvocationEvent,
16+
BidiAgentInitializedEvent,
17+
BidiMessageAddedEvent,
18+
)
19+
from strands.experimental.hooks.multiagent.events import (
20+
AfterMultiAgentInvocationEvent,
21+
AfterNodeCallEvent,
22+
MultiAgentInitializedEvent,
23+
)
1224
from strands.hooks import AfterInvocationEvent, MessageAddedEvent
1325
from strands.hooks.registry import HookRegistry
1426
from strands.types.exceptions import SessionException
@@ -3580,3 +3592,196 @@ def test_retrieve_customer_context_works(self, mock_memory_client):
35803592

35813593
mock_memory_client.retrieve_memories.assert_called_once()
35823594
assert "<user_context>" in mock_agent.messages[0]["content"][0]["text"]
3595+
3596+
3597+
class TestAsyncMode:
3598+
"""Tests for async_mode: callbacks must not block the event loop."""
3599+
3600+
def test_async_mode_defaults_to_false(self, agentcore_config):
3601+
assert agentcore_config.async_mode is False
3602+
3603+
def test_sync_mode_registers_sync_callbacks(self, mock_memory_client):
3604+
"""async_mode=False: all MessageAddedEvent/AfterInvocationEvent callbacks are sync."""
3605+
config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", batch_size=5, async_mode=False)
3606+
manager = _create_session_manager(config, mock_memory_client)
3607+
registry = HookRegistry()
3608+
manager.register_hooks(registry)
3609+
3610+
for event_type in (MessageAddedEvent, AfterInvocationEvent):
3611+
for cb in registry.get_callbacks_for(
3612+
event_type(agent=Mock(), message={"role": "user", "content": [{"text": "x"}]})
3613+
if event_type is MessageAddedEvent
3614+
else event_type(agent=Mock())
3615+
):
3616+
assert not inspect.iscoroutinefunction(cb), f"Sync mode leaked an async callback for {event_type}"
3617+
3618+
def test_async_mode_registers_async_callbacks(self, mock_memory_client):
3619+
"""async_mode=True: MessageAddedEvent and AfterInvocationEvent callbacks are coroutine functions."""
3620+
config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", batch_size=5, async_mode=True)
3621+
manager = _create_session_manager(config, mock_memory_client)
3622+
registry = HookRegistry()
3623+
manager.register_hooks(registry)
3624+
3625+
msg_callbacks = registry.get_callbacks_for(
3626+
MessageAddedEvent(agent=Mock(), message={"role": "user", "content": [{"text": "x"}]})
3627+
)
3628+
assert msg_callbacks, "No MessageAddedEvent callbacks registered in async mode"
3629+
assert all(inspect.iscoroutinefunction(cb) for cb in msg_callbacks)
3630+
3631+
after_callbacks = registry.get_callbacks_for(AfterInvocationEvent(agent=Mock()))
3632+
assert after_callbacks, "No AfterInvocationEvent callbacks registered in async mode"
3633+
assert all(inspect.iscoroutinefunction(cb) for cb in after_callbacks)
3634+
3635+
async def test_async_mode_does_not_block_event_loop(self, mock_memory_client):
3636+
"""The async hooks run boto3 on a worker thread, so the event loop can make progress concurrently."""
3637+
config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", async_mode=True)
3638+
manager = _create_session_manager(config, mock_memory_client)
3639+
3640+
# Simulate each sync session-manager method blocking on boto3.
3641+
def slow_append_message(message, agent, **kwargs):
3642+
time.sleep(0.2)
3643+
3644+
def slow_sync_agent(agent, **kwargs):
3645+
time.sleep(0.2)
3646+
3647+
manager.append_message = slow_append_message
3648+
manager.sync_agent = slow_sync_agent
3649+
3650+
registry = HookRegistry()
3651+
manager.register_hooks(registry)
3652+
3653+
persist_callbacks = [
3654+
cb
3655+
for cb in registry.get_callbacks_for(
3656+
MessageAddedEvent(agent=Mock(), message={"role": "user", "content": [{"text": "x"}]})
3657+
)
3658+
if asyncio.iscoroutinefunction(cb)
3659+
]
3660+
assert persist_callbacks
3661+
3662+
event = MessageAddedEvent(agent=Mock(), message={"role": "user", "content": [{"text": "hello"}]})
3663+
3664+
# Ticker proves the event loop made progress while the hook awaited to_thread.
3665+
ticks = 0
3666+
3667+
async def ticker():
3668+
nonlocal ticks
3669+
while True:
3670+
await asyncio.sleep(0.01)
3671+
ticks += 1
3672+
3673+
ticker_task = asyncio.create_task(ticker())
3674+
try:
3675+
# Run the persist callback (append_message + sync_agent); both sleep 0.2s on a worker thread.
3676+
await persist_callbacks[0](event)
3677+
finally:
3678+
ticker_task.cancel()
3679+
3680+
assert ticks > 5, f"Event loop was blocked; only {ticks} ticks recorded"
3681+
3682+
async def test_async_mode_batching_registers_flush_callback(self, mock_memory_client):
3683+
"""async_mode=True with batch_size>1: AfterInvocationEvent gets both sync_agent and flush callbacks."""
3684+
config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", batch_size=5, async_mode=True)
3685+
manager = _create_session_manager(config, mock_memory_client)
3686+
registry = HookRegistry()
3687+
manager.register_hooks(registry)
3688+
3689+
after_callbacks = list(registry.get_callbacks_for(AfterInvocationEvent(agent=Mock())))
3690+
assert len(after_callbacks) == 2
3691+
assert all(asyncio.iscoroutinefunction(cb) for cb in after_callbacks)
3692+
3693+
def test_async_mode_registers_multi_agent_callbacks(self, mock_memory_client):
3694+
"""async_mode=True: multi-agent events get async callbacks (parity with sync mode)."""
3695+
config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", async_mode=True)
3696+
manager = _create_session_manager(config, mock_memory_client)
3697+
registry = HookRegistry()
3698+
manager.register_hooks(registry)
3699+
3700+
for event_type in (MultiAgentInitializedEvent, AfterNodeCallEvent, AfterMultiAgentInvocationEvent):
3701+
callbacks = registry._registered_callbacks.get(event_type, [])
3702+
assert callbacks, f"No callbacks registered for {event_type.__name__}"
3703+
assert all(asyncio.iscoroutinefunction(cb) for cb in callbacks)
3704+
3705+
def test_async_mode_logs_sync_invocation_warning(self, mock_memory_client, caplog):
3706+
"""async_mode=True emits a WARNING at register_hooks time pointing users to stream_async/invoke_async."""
3707+
config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", async_mode=True)
3708+
manager = _create_session_manager(config, mock_memory_client)
3709+
registry = HookRegistry()
3710+
3711+
with caplog.at_level(logging.WARNING, logger="bedrock_agentcore.memory.integrations.strands.session_manager"):
3712+
manager.register_hooks(registry)
3713+
3714+
assert any("async_mode=True" in rec.message and "stream_async" in rec.message for rec in caplog.records)
3715+
3716+
def test_async_mode_registers_bidi_agent_callbacks(self, mock_memory_client):
3717+
"""async_mode=True: BidiAgent events get callbacks; init stays sync, others are async."""
3718+
config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", async_mode=True)
3719+
manager = _create_session_manager(config, mock_memory_client)
3720+
registry = HookRegistry()
3721+
manager.register_hooks(registry)
3722+
3723+
# BidiAgentInitializedEvent dispatches via the sync hook path, so its callback must NOT be a coroutine.
3724+
init_callbacks = registry._registered_callbacks.get(BidiAgentInitializedEvent, [])
3725+
assert init_callbacks, "No callbacks registered for BidiAgentInitializedEvent"
3726+
assert not any(asyncio.iscoroutinefunction(cb) for cb in init_callbacks)
3727+
3728+
# BidiMessageAddedEvent and BidiAfterInvocationEvent dispatch via invoke_callbacks_async,
3729+
# so their callbacks should be async to keep the event loop unblocked.
3730+
for event_type in (BidiMessageAddedEvent, BidiAfterInvocationEvent):
3731+
callbacks = registry._registered_callbacks.get(event_type, [])
3732+
assert callbacks, f"No callbacks registered for {event_type.__name__}"
3733+
assert all(asyncio.iscoroutinefunction(cb) for cb in callbacks)
3734+
3735+
3736+
class TestFlushAgentStatesRaceCondition:
3737+
"""Tests for the copy-and-clear-under-one-lock fix in _flush_agent_states_only."""
3738+
3739+
def test_flush_agent_states_does_not_drop_concurrent_appends(self, batching_session_manager, mock_memory_client):
3740+
"""States appended during the network I/O window must survive the flush."""
3741+
states_appended_during_flush = []
3742+
3743+
# Pre-populate the buffer with one state to force a flush.
3744+
initial_agent = SessionAgent(
3745+
agent_id="agent-1",
3746+
state={"description": "initial"},
3747+
conversation_manager_state={},
3748+
)
3749+
batching_session_manager.create_agent("test-session-456", initial_agent)
3750+
assert batching_session_manager.pending_agent_state_count() == 1
3751+
3752+
# Simulate a concurrent create_agent during the boto3 call. The mock fires
3753+
# while the buffer is being flushed — i.e. between the copy and any clear —
3754+
# so a second append must NOT be lost.
3755+
def create_event_and_append_concurrently(**kwargs):
3756+
new_agent = SessionAgent(
3757+
agent_id="agent-2",
3758+
state={"description": "appended-mid-flush"},
3759+
conversation_manager_state={},
3760+
)
3761+
batching_session_manager.create_agent("test-session-456", new_agent)
3762+
states_appended_during_flush.append(new_agent)
3763+
return {"eventId": "event_during_flush"}
3764+
3765+
mock_memory_client.gmdp_client.create_event.side_effect = create_event_and_append_concurrently
3766+
3767+
batching_session_manager._flush_agent_states_only()
3768+
3769+
# The state appended during the flush must still be in the buffer afterwards.
3770+
assert states_appended_during_flush, "Test setup error: concurrent append did not run"
3771+
assert batching_session_manager.pending_agent_state_count() == 1, (
3772+
"State appended during flush was dropped — copy/clear is not atomic"
3773+
)
3774+
3775+
def test_flush_agent_states_failure_restores_buffer(self, batching_session_manager, mock_memory_client):
3776+
"""A failed flush must restore the originally-buffered states (no data loss)."""
3777+
mock_memory_client.gmdp_client.create_event.side_effect = Exception("API Error")
3778+
3779+
agent = SessionAgent(agent_id="agent-1", state={"description": "v1"}, conversation_manager_state={})
3780+
batching_session_manager.create_agent("test-session-456", agent)
3781+
assert batching_session_manager.pending_agent_state_count() == 1
3782+
3783+
with pytest.raises(SessionException):
3784+
batching_session_manager._flush_agent_states_only()
3785+
3786+
# State must be back in the buffer for retry.
3787+
assert batching_session_manager.pending_agent_state_count() == 1

0 commit comments

Comments
 (0)