Repository navigation
AgentCoreMemorySessionManager: BidiAgent transcripts are never stored (MessageUpdatedEvent not handled) #716
Description
Describe the bug
With a Strands BidiAgent (bidirectional streaming, e.g. Amazon Nova 2.5 Sonic), AgentCoreMemorySessionManager never stores what the user or the assistant said. Long-term memory strategies (e.g. the built-in summary strategy) therefore have nothing to extract, and a voice agent can't remember a caller across sessions.
Cause: since strands-agents/harness-sdk#4560, BidiAgent appends each transcript to agent.messages as an empty placeholder when the transcript starts (MessageAddedEvent, metadata.custom.bidi.status == "pending") and replaces it with the finished text when the transcript stops (MessageUpdatedEvent, status "complete"). AgentCoreMemorySessionManager persists on MessageAddedEvent only: the converter drops the empty placeholder, and MessageUpdatedEvent isn't registered, so the finished text is never written. strands-agents/harness-sdk#4698 describes the same problem for Strands' own repository-based managers (FileSessionManager, S3SessionManager); Strands handles it in SnapshotSessionManager (strands-agents/harness-sdk#4603).
What does get stored for a BidiAgent: the messages= passed to the constructor (e.g. a greeting), the SESSION/AGENT state blobs, and (from reading the code, not tested) tool use/result messages.
A related effect with async_mode=True: BidiAgent awaits hook callbacks inside its model receive loop. The MessageAddedEvent callback awaits append_message and then sync_agent (one CreateEvent for the agent state), so the first audio of every assistant response waits for one memory write (about 100 ms with the fake 100 ms latency below). From reading the code, an exception in these callbacks propagates into the receive loop and ends the session (not tested).
Scope: only BidiAgent. A regular Agent with AgentCoreMemorySessionManager is not affected.
To Reproduce
No AWS calls: a fake bidi model plays one user turn and one assistant turn in Nova Sonic's event order; the AgentCore Memory data plane is a fake client with 100 ms latency per call.
"""BidiAgent + AgentCoreMemorySessionManager: what gets stored, and does it hold up the audio? No AWS calls."""
import asyncio, base64, time
from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig
from bedrock_agentcore.memory.integrations.strands.session_manager import AgentCoreMemorySessionManager
from strands.bidi.agent import BidiAgent
from strands.bidi.models.model import BidiModel
from strands.bidi.types import events as ev
LATENCY = 0.1 # fake memory data plane latency per call
calls = []
class FakeDataPlane:
class meta:
region_name = "us-east-1"
def list_events(self, **kw):
time.sleep(LATENCY)
return {"events": []}
def create_event(self, **kw):
time.sleep(LATENCY)
for p in kw["payload"]:
calls.append(("conversational", p["conversational"]["role"], p["conversational"]["content"]["text"][:60])
if "conversational" in p else ("blob", kw.get("metadata", {}).get("stateType", {}).get("stringValue")))
return {"event": {"eventId": str(len(calls))}}
class FakeSession:
region_name = "us-east-1"
def client(self, *args, **kwargs):
return FakeDataPlane()
class FakeNovaLikeModel(BidiModel):
"""One user turn, then one assistant turn with audio, in the event order Nova Sonic produces."""
def __init__(self):
self.config, self.marks, self.stopped = {"model_id": "fake"}, {}, asyncio.Event()
def get_config(self):
return self.config
def update_config(self, **kw):
self.config.update(kw)
async def start(self, system_prompt=None, tools=None, messages=None, **kw):
pass
async def stop(self):
self.stopped.set()
async def send(self, content):
pass
async def receive(self):
yield ev.BidiResponseStartEvent("r1")
yield ev.BidiTranscriptStartEvent("user", "u1")
yield ev.BidiTranscriptDeltaEvent("I need a hotel in London.", "user", "u1")
yield ev.BidiTranscriptStopEvent("user", "u1")
self.marks["assistant_start"] = time.monotonic()
yield ev.BidiTranscriptStartEvent("assistant", "a1")
yield ev.BidiTranscriptDeltaEvent("Lovely, when are you travelling?", "assistant", "a1")
yield ev.BidiAudioStartEvent("au1")
yield ev.BidiAudioDeltaEvent(base64.b64encode(b"\0\0" * 480).decode(), "pcm", 24000, 1, "au1")
yield ev.BidiAudioStopEvent("au1")
yield ev.BidiTranscriptStopEvent("assistant", "a1")
yield ev.BidiResponseStopEvent("r1")
await self.stopped.wait()
async def main():
config = AgentCoreMemoryConfig(memory_id="mem-1", session_id="session-1", actor_id="user-1", async_mode=True)
manager = AgentCoreMemorySessionManager(config, region_name="us-east-1", boto_session=FakeSession())
model = FakeNovaLikeModel()
agent = BidiAgent(model=model, system_prompt="x", session_manager=manager,
messages=[{"role": "assistant", "content": [{"text": "Hi, how can I help?"}]}])
await agent.start()
first_audio = None
async for event in agent.receive():
if event.get("type") == "bidi_audio_delta" and first_audio is None:
first_audio = time.monotonic()
if event.get("type") == "bidi_response_stop":
break
await agent.stop()
print("history:", [(m["role"], m["content"][0]["text"]) for m in agent.messages])
print("stored in AgentCore Memory:")
for c in calls:
print(" ", c)
print(f"assistant transcript start -> first audio event: {(first_audio - model.marks['assistant_start']) * 1000:.0f} ms "
f"(fake write latency {LATENCY * 1000:.0f} ms)")
asyncio.run(main())Output (strands-agents 1.62.0, bedrock-agentcore 1.25.0; the same with 1.61.0 / 1.24.1):
history: [('assistant', 'Hi, how can I help?'), ('user', 'I need a hotel in London.'), ('assistant', 'Lovely, when are you travelling?')]
stored in AgentCore Memory:
('blob', 'SESSION')
('blob', 'AGENT')
('conversational', 'ASSISTANT', '{"message": {"role": "assistant", "content": [{"text": "Hi, ')
('blob', 'AGENT')
('blob', 'AGENT')
('blob', 'AGENT')
assistant transcript start -> first audio event: 102 ms (fake write latency 100 ms)
The agent's history holds both spoken turns; the memory holds neither.
Expected behavior
- Finished transcripts (
MessageUpdatedEventwithstatus == "complete") are stored as conversational events (USER/ASSISTANT), so memory strategies can extract them. Pending placeholders and"incomplete"updates are skipped. - For a
BidiAgent, memory writes don't hold up the model receive loop (for example, queued to a background writer), and a failed write doesn't end the session.
Environment
- Python 3.13 (macOS; same behavior on AgentCore Runtime, Linux arm64)
- strands-agents[bidi] 1.61.0 and 1.62.0, bedrock-agentcore 1.24.1 and 1.25.0
Additional context
- fix(memory): use current Strands bidi session hooks #664 (bidi hooks after the Strands 1.56.0 change; skips long-term memory retrieval for
BidiAgent) and the open fix(strands): ban experimental imports #686 (removes the remaining Bidi-specific hooks) touch the same code. - [FEATURE] Deprecate the message-log session managers once snapshot parity and a migration path land strands-agents/harness-sdk#3664 and feat(session): deprecate repository-based managers and add migration guide strands-agents/harness-sdk#4797 plan to deprecate the repository-based managers that
AgentCoreMemorySessionManagerextends. - Workaround we use: a small
HookProvideronMessageUpdatedEventthat queues finished transcripts and writes them withCreateEvent(conversational payload) from a background thread; earlier summaries are loaded withListMemoryRecordsbeforeagent.start()and added to the system prompt.
Activity