Skip to content

AgentCoreMemorySessionManager: BidiAgent transcripts are never stored (MessageUpdatedEvent not handled) #716

Open

Description

@rotmi

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 (MessageUpdatedEvent with status == "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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions