Skip to content

Commit 4bbc1bb

Browse files
committed
fix: copy and clear messages within same lock acquisition
1 parent 26b4dfc commit 4bbc1bb

2 files changed

Lines changed: 117 additions & 28 deletions

File tree

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

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111

1212
import boto3
1313
from botocore.config import Config as BotocoreConfig
14+
from strands.experimental.hooks.events import (
15+
BidiAfterInvocationEvent,
16+
BidiAgentInitializedEvent,
17+
BidiMessageAddedEvent,
18+
)
1419
from strands.experimental.hooks.multiagent.events import (
1520
AfterMultiAgentInvocationEvent,
1621
AfterNodeCallEvent,
@@ -942,50 +947,56 @@ def register_hooks(self, registry: HookRegistry, **kwargs) -> None:
942947

943948
# Async mode: register async callbacks that offload the existing sync
944949
# 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).
950+
# and BidiAgentInitializedEvent must stay sync (Strands disallows async
951+
# callbacks for AgentInitializedEvent — see strands/hooks/registry.py:227).
947952
logger.warning(
948953
"AgentCoreMemorySessionManager async_mode=True: the agent must be invoked "
949954
"via the async path (e.g. agent.stream_async(...) or agent.invoke_async(...)). "
950955
"Sync invocation will raise RuntimeError from Strands' hook registry."
951956
)
952957

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+
953970
registry.add_callback(AgentInitializedEvent, lambda event: self.initialize(event.agent))
954971

955972
async def _on_message_added_persist(event: MessageAddedEvent) -> None:
956973
await asyncio.to_thread(self.append_message, event.message, event.agent)
957974
await asyncio.to_thread(self.sync_agent, event.agent)
958975

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-
965976
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)
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))
968979

969980
if self.config.batch_size > 1:
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)
981+
registry.add_callback(AfterInvocationEvent, _offload(self._flush_messages))
975982

976983
# 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)
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))
979987

980-
async def _on_after_node_call(event: AfterNodeCallEvent) -> None:
981-
await asyncio.to_thread(self.sync_multi_agent, event.source)
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))
982993

983-
async def _on_after_multi_agent_invocation(event: AfterMultiAgentInvocationEvent) -> None:
984-
await asyncio.to_thread(self.sync_multi_agent, event.source)
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)
985997

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)
998+
registry.add_callback(BidiMessageAddedEvent, _on_bidi_message_added)
999+
registry.add_callback(BidiAfterInvocationEvent, _offload(self.sync_bidi_agent, lambda e: e.agent))
9891000

9901001
@override
9911002
def initialize(self, agent: "Agent", **kwargs: Any) -> None:
@@ -1141,6 +1152,7 @@ def _flush_agent_states_only(self) -> list[dict[str, Any]]:
11411152

11421153
with self._agent_state_lock:
11431154
agent_states_to_send = list(self._agent_state_buffer)
1155+
self._agent_state_buffer.clear()
11441156

11451157
if not agent_states_to_send:
11461158
return []
@@ -1171,11 +1183,10 @@ def _flush_agent_states_only(self) -> list[dict[str, Any]]:
11711183
results.append(event)
11721184
logger.debug("Flushed %d agent states for agent %s: %s", len(payloads), agent_id, event.get("eventId"))
11731185

1174-
# Clear agent state buffer only after ALL events succeed
1175-
with self._agent_state_lock:
1176-
self._agent_state_buffer.clear()
1177-
11781186
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)
11791190
logger.error("Failed to flush agent states to AgentCore Memory: %s", e)
11801191
raise SessionException(f"Failed to flush agent states: {e}") from e
11811192

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

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
from botocore.config import Config as BotocoreConfig
1212
from botocore.exceptions import ClientError
1313
from strands.agent.agent import Agent
14+
from strands.experimental.hooks.events import (
15+
BidiAfterInvocationEvent,
16+
BidiAgentInitializedEvent,
17+
BidiMessageAddedEvent,
18+
)
1419
from strands.experimental.hooks.multiagent.events import (
1520
AfterMultiAgentInvocationEvent,
1621
AfterNodeCallEvent,
@@ -3707,3 +3712,76 @@ def test_async_mode_logs_sync_invocation_warning(self, mock_memory_client, caplo
37073712
manager.register_hooks(registry)
37083713

37093714
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)