Skip to content

Commit 0be29f0

Browse files
committed
feat: add persistence_mode to AgentCoreMemorySessionManager to control ACM persistence
Add PersistenceMode enum (FULL | NONE) and persistence_mode config option to AgentCoreMemoryConfig. When set to NONE, disables persistence to AgentCore Memory (create_event calls) while keeping local session/agent state management and memory injection (LTM retrieval) working. This allows customers who only need memory injection into agent context without persisting agent state to set persistence_mode=PersistenceMode.NONE on their existing configuration.
1 parent 4ebfdcb commit 0be29f0

3 files changed

Lines changed: 263 additions & 29 deletions

File tree

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Configuration for AgentCore Memory Session Manager."""
22

3+
from enum import Enum
34
from typing import Any, Callable, Dict, Optional
45

56
from pydantic import BaseModel, Field, field_validator
@@ -10,6 +11,19 @@ def normalize_metadata(raw: Dict[str, Any]) -> Dict[str, Any]:
1011
return {k: {"stringValue": v} if isinstance(v, str) else v for k, v in raw.items()}
1112

1213

14+
class PersistenceMode(str, Enum):
15+
"""Controls what gets persisted to AgentCore Memory.
16+
17+
Attributes:
18+
FULL: Persist everything (session, agent state, messages) to AgentCore Memory. Default behavior.
19+
NONE: Disable all persistence. Local session/agent state management and memory injection
20+
(LTM retrieval) still work, but no create_event calls are made to AgentCore Memory.
21+
"""
22+
23+
FULL = "FULL"
24+
NONE = "NONE"
25+
26+
1327
class RetrievalConfig(BaseModel):
1428
"""Configuration for memory retrieval operations.
1529
@@ -51,6 +65,9 @@ class AgentCoreMemoryConfig(BaseModel):
5165
event creation, so it can return dynamic values (e.g. current traceId). The returned
5266
dict is merged after default_metadata but before per-call metadata.
5367
Accepts plain strings (auto-wrapped) or explicit MetadataValue dicts.
68+
persistence_mode: Controls what gets persisted to AgentCore Memory.
69+
FULL (default): persist everything. NONE: disable all persistence while keeping
70+
local state management and memory injection working.
5471
"""
5572

5673
memory_id: str = Field(min_length=1)
@@ -63,6 +80,7 @@ class AgentCoreMemoryConfig(BaseModel):
6380
filter_restored_tool_context: bool = Field(default=False)
6481
default_metadata: Optional[Dict[str, Any]] = None
6582
metadata_provider: Optional[Callable[[], Dict[str, Any]]] = None
83+
persistence_mode: PersistenceMode = Field(default=PersistenceMode.FULL)
6684

6785
@field_validator("default_metadata", mode="before")
6886
@classmethod

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

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
)
3030

3131
from .bedrock_converter import AgentCoreMemoryConverter
32-
from .config import AgentCoreMemoryConfig, RetrievalConfig, normalize_metadata
32+
from .config import AgentCoreMemoryConfig, PersistenceMode, RetrievalConfig, normalize_metadata
3333
from .converters import MemoryConverter
3434

3535
if TYPE_CHECKING:
@@ -142,6 +142,7 @@ def __init__(
142142
"""
143143
self.converter = converter or AgentCoreMemoryConverter
144144
self.config = agentcore_memory_config
145+
self.read_only = agentcore_memory_config.persistence_mode is PersistenceMode.NONE
145146
self.memory_client = MemoryClient(region_name=region_name)
146147
session = boto_session or boto3.Session(region_name=region_name)
147148
self.has_existing_agent = False
@@ -255,17 +256,21 @@ def create_session(self, session: Session, **kwargs: Any) -> Session:
255256
if session.session_id != self.config.session_id:
256257
raise SessionException(f"Session ID mismatch: expected {self.config.session_id}, got {session.session_id}")
257258

258-
event = self.memory_client.gmdp_client.create_event(
259-
memoryId=self.config.memory_id,
260-
actorId=self.config.actor_id,
261-
sessionId=self.session_id,
262-
payload=[
263-
{"blob": json.dumps(session.to_dict())},
264-
],
265-
eventTimestamp=self._get_monotonic_timestamp(),
266-
metadata={STATE_TYPE_KEY: {"stringValue": StateType.SESSION.value}},
267-
)
268-
logger.info("Created session: %s with event: %s", session.session_id, event.get("event", {}).get("eventId"))
259+
if not self.read_only:
260+
event = self.memory_client.gmdp_client.create_event(
261+
memoryId=self.config.memory_id,
262+
actorId=self.config.actor_id,
263+
sessionId=self.session_id,
264+
payload=[
265+
{"blob": json.dumps(session.to_dict())},
266+
],
267+
eventTimestamp=self._get_monotonic_timestamp(),
268+
metadata={STATE_TYPE_KEY: {"stringValue": StateType.SESSION.value}},
269+
)
270+
logger.info(
271+
"Created session: %s with event: %s", session.session_id, event.get("event", {}).get("eventId")
272+
)
273+
269274
return session
270275

271276
def read_session(self, session_id: str, **kwargs: Any) -> Optional[Session]:
@@ -318,14 +323,15 @@ def read_session(self, session_id: str, **kwargs: Any) -> Optional[Session]:
318323
session_data = json.loads(old_event.get("payload", {})[0].get("blob"))
319324
session = Session.from_dict(session_data)
320325
# Migrate: create new event with metadata, delete old
321-
self.create_session(session)
322-
self.memory_client.gmdp_client.delete_event(
323-
memoryId=self.config.memory_id,
324-
actorId=legacy_actor_id,
325-
sessionId=session_id,
326-
eventId=old_event.get("eventId"),
327-
)
328-
logger.info("Migrated legacy session event for session: %s", session_id)
326+
if not self.read_only:
327+
self.create_session(session)
328+
self.memory_client.gmdp_client.delete_event(
329+
memoryId=self.config.memory_id,
330+
actorId=legacy_actor_id,
331+
sessionId=session_id,
332+
eventId=old_event.get("eventId"),
333+
)
334+
logger.info("Migrated legacy session event for session: %s", session_id)
329335
return session
330336

331337
return None
@@ -364,6 +370,9 @@ def create_agent(self, session_id: str, session_agent: SessionAgent, **kwargs: A
364370
if session_agent.created_at:
365371
self._agent_created_at_cache[session_agent.agent_id] = session_agent.created_at
366372

373+
if self.read_only:
374+
return
375+
367376
if self.config.batch_size > 1:
368377
# Buffer the agent state events
369378
should_flush = False
@@ -462,14 +471,15 @@ def read_agent(self, session_id: str, agent_id: str, **kwargs: Any) -> Optional[
462471
agent_data = json.loads(old_event.get("payload", {})[0].get("blob"))
463472
agent = SessionAgent.from_dict(agent_data)
464473
# Migrate: create new event with metadata, delete old
465-
self.create_agent(session_id, agent)
466-
self.memory_client.gmdp_client.delete_event(
467-
memoryId=self.config.memory_id,
468-
actorId=legacy_actor_id,
469-
sessionId=session_id,
470-
eventId=old_event.get("eventId"),
471-
)
472-
logger.info("Migrated legacy agent event for agent: %s", agent_id)
474+
if not self.read_only:
475+
self.create_agent(session_id, agent)
476+
self.memory_client.gmdp_client.delete_event(
477+
memoryId=self.config.memory_id,
478+
actorId=legacy_actor_id,
479+
sessionId=session_id,
480+
eventId=old_event.get("eventId"),
481+
)
482+
logger.info("Migrated legacy agent event for agent: %s", agent_id)
473483
return agent
474484

475485
return None
@@ -546,6 +556,9 @@ def create_message(
546556
if not messages:
547557
return None
548558

559+
if self.read_only:
560+
return {}
561+
549562
is_blob = self.converter.exceeds_conversational_limit(messages[0])
550563

551564
# Build merged metadata from config defaults + per-call overrides
@@ -862,6 +875,9 @@ def _flush_messages_only(self) -> list[dict[str, Any]]:
862875
Raises:
863876
SessionException: If message creation fails. On failure, messages remain in the buffer.
864877
"""
878+
if self.read_only:
879+
return []
880+
865881
with self._message_lock:
866882
messages_to_send = list(self._message_buffer)
867883

@@ -940,6 +956,9 @@ def _flush_agent_states_only(self) -> list[dict[str, Any]]:
940956
Raises:
941957
SessionException: If agent state creation fails. On failure, agent states remain in the buffer.
942958
"""
959+
if self.read_only:
960+
return []
961+
943962
with self._agent_state_lock:
944963
agent_states_to_send = list(self._agent_state_buffer)
945964

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

Lines changed: 198 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
CONVERSATIONAL_MAX_SIZE,
1919
AgentCoreMemoryConverter,
2020
)
21-
from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig, RetrievalConfig
21+
from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig, PersistenceMode, RetrievalConfig
2222
from bedrock_agentcore.memory.integrations.strands.session_manager import (
2323
AgentCoreMemorySessionManager,
2424
BufferedMessage,
@@ -3234,3 +3234,200 @@ def test_default_metadata_plain_strings_normalized(self, mock_memory_client):
32343234

32353235
kwargs = mock_memory_client.create_event.call_args[1]
32363236
assert kwargs["metadata"]["project"] == {"stringValue": "atlas"}
3237+
3238+
3239+
class TestPersistenceMode:
3240+
"""Test persistence_mode=NONE disables ACM persistence but keeps local state management and LTM retrieval."""
3241+
3242+
@pytest.fixture
3243+
def no_persist_config(self):
3244+
return AgentCoreMemoryConfig(
3245+
memory_id="test-memory-123",
3246+
session_id="test-session-456",
3247+
actor_id="test-actor-789",
3248+
persistence_mode=PersistenceMode.NONE,
3249+
)
3250+
3251+
@pytest.fixture
3252+
def no_persist_manager(self, no_persist_config, mock_memory_client):
3253+
return _create_session_manager(no_persist_config, mock_memory_client)
3254+
3255+
# --- Config ---
3256+
3257+
def test_config_defaults_to_full(self):
3258+
config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a")
3259+
assert config.persistence_mode is PersistenceMode.FULL
3260+
3261+
def test_config_accepts_none_mode(self):
3262+
config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", persistence_mode=PersistenceMode.NONE)
3263+
assert config.persistence_mode is PersistenceMode.NONE
3264+
3265+
def test_config_accepts_string_value(self):
3266+
config = AgentCoreMemoryConfig(memory_id="m", session_id="s", actor_id="a", persistence_mode="NONE")
3267+
assert config.persistence_mode is PersistenceMode.NONE
3268+
3269+
# --- create_session: local state works, no ACM write ---
3270+
3271+
def test_create_session_returns_session(self, no_persist_manager, mock_memory_client):
3272+
session = Session(session_id="test-session-456", session_type=SessionType.AGENT)
3273+
result = no_persist_manager.create_session(session)
3274+
assert result.session_id == "test-session-456"
3275+
mock_memory_client.gmdp_client.create_event.assert_not_called()
3276+
3277+
def test_create_session_still_validates(self, no_persist_manager):
3278+
session = Session(session_id="wrong-id", session_type=SessionType.AGENT)
3279+
with pytest.raises(SessionException, match="Session ID mismatch"):
3280+
no_persist_manager.create_session(session)
3281+
3282+
# --- create_agent: local cache works, no ACM write ---
3283+
3284+
def test_create_agent_caches_timestamp(self, no_persist_manager, mock_memory_client):
3285+
agent = SessionAgent(agent_id="a1", state={}, conversation_manager_state={})
3286+
no_persist_manager.create_agent("test-session-456", agent)
3287+
assert "a1" in no_persist_manager._agent_created_at_cache
3288+
mock_memory_client.gmdp_client.create_event.assert_not_called()
3289+
3290+
def test_create_agent_still_validates(self, no_persist_manager):
3291+
agent = SessionAgent(agent_id="a1", state={}, conversation_manager_state={})
3292+
with pytest.raises(SessionException, match="Session ID mismatch"):
3293+
no_persist_manager.create_agent("wrong-id", agent)
3294+
3295+
# --- update_agent: local cache works, no ACM write ---
3296+
3297+
def test_update_agent_no_acm_write(self, no_persist_manager, mock_memory_client):
3298+
no_persist_manager._agent_created_at_cache["a1"] = "2024-01-01T00:00:00+00:00"
3299+
agent = SessionAgent(agent_id="a1", state={"k": "v"}, conversation_manager_state={})
3300+
no_persist_manager.update_agent("test-session-456", agent)
3301+
assert agent.created_at == "2024-01-01T00:00:00+00:00"
3302+
mock_memory_client.gmdp_client.create_event.assert_not_called()
3303+
3304+
# --- create_message: validation works, returns non-None for append_message, no ACM write ---
3305+
3306+
def test_create_message_returns_empty_dict(self, no_persist_manager, mock_memory_client):
3307+
msg = SessionMessage(
3308+
message={"role": "user", "content": [{"text": "hi"}]},
3309+
message_id=1,
3310+
created_at="2024-01-01T12:00:00Z",
3311+
)
3312+
result = no_persist_manager.create_message("test-session-456", "a1", msg)
3313+
assert result == {}
3314+
mock_memory_client.create_event.assert_not_called()
3315+
mock_memory_client.gmdp_client.create_event.assert_not_called()
3316+
3317+
def test_create_message_still_validates(self, no_persist_manager):
3318+
msg = SessionMessage(
3319+
message={"role": "user", "content": [{"text": "hi"}]},
3320+
message_id=1,
3321+
created_at="2024-01-01T12:00:00Z",
3322+
)
3323+
with pytest.raises(SessionException, match="Session ID mismatch"):
3324+
no_persist_manager.create_message("wrong-id", "a1", msg)
3325+
3326+
def test_create_message_returns_none_for_empty_payload(self, no_persist_manager):
3327+
msg = SessionMessage(
3328+
message={"role": "user", "content": []},
3329+
message_id=1,
3330+
created_at="2024-01-01T12:00:00Z",
3331+
)
3332+
result = no_persist_manager.create_message("test-session-456", "a1", msg)
3333+
assert result is None
3334+
3335+
# --- append_message: local state tracking works ---
3336+
3337+
def test_append_message_tracks_local_state(self, no_persist_manager, mock_memory_client):
3338+
no_persist_manager._latest_agent_message = {}
3339+
mock_agent = Mock()
3340+
mock_agent.agent_id = "a1"
3341+
message = {"role": "user", "content": [{"text": "hello"}]}
3342+
no_persist_manager.append_message(message, mock_agent)
3343+
assert "a1" in no_persist_manager._latest_agent_message
3344+
mock_memory_client.create_event.assert_not_called()
3345+
mock_memory_client.gmdp_client.create_event.assert_not_called()
3346+
3347+
# --- flush: no-ops ---
3348+
3349+
def test_flush_messages_only_noop(self, no_persist_manager, mock_memory_client):
3350+
assert no_persist_manager._flush_messages_only() == []
3351+
mock_memory_client.gmdp_client.create_event.assert_not_called()
3352+
3353+
def test_flush_agent_states_only_noop(self, no_persist_manager, mock_memory_client):
3354+
assert no_persist_manager._flush_agent_states_only() == []
3355+
mock_memory_client.gmdp_client.create_event.assert_not_called()
3356+
3357+
def test_close_no_acm_writes(self, no_persist_manager, mock_memory_client):
3358+
no_persist_manager.close()
3359+
mock_memory_client.gmdp_client.create_event.assert_not_called()
3360+
3361+
# --- reads still work ---
3362+
3363+
def test_read_session_works(self, no_persist_manager, mock_memory_client):
3364+
mock_memory_client.list_events.return_value = [
3365+
{"eventId": "e1", "payload": [{"blob": '{"session_id": "test-session-456", "session_type": "AGENT"}'}]},
3366+
]
3367+
result = no_persist_manager.read_session("test-session-456")
3368+
assert result is not None
3369+
assert result.session_id == "test-session-456"
3370+
3371+
def test_read_agent_works(self, no_persist_manager, mock_memory_client):
3372+
mock_memory_client.list_events.return_value = [
3373+
{"eventId": "e1", "payload": [{"blob": '{"agent_id": "a1", "state": {}, "conversation_manager_state": {}}'}]},
3374+
]
3375+
result = no_persist_manager.read_agent("test-session-456", "a1")
3376+
assert result is not None
3377+
assert result.agent_id == "a1"
3378+
3379+
def test_list_messages_works(self, no_persist_manager, mock_memory_client):
3380+
mock_memory_client.list_events.return_value = [
3381+
{
3382+
"eventId": "e1",
3383+
"eventTimestamp": "2024-01-01T12:00:00Z",
3384+
"payload": [{"conversational": {"content": {"text": '{"message": {"role": "user", "content": [{"text": "Hello"}]}, "message_id": 1}'}, "role": "USER"}}],
3385+
},
3386+
]
3387+
messages = no_persist_manager.list_messages("test-session-456", "a1")
3388+
assert len(messages) == 1
3389+
3390+
# --- legacy migration skipped ---
3391+
3392+
def test_legacy_session_migration_skipped(self, no_persist_manager, mock_memory_client):
3393+
mock_memory_client.list_events.side_effect = [
3394+
[],
3395+
[{"eventId": "legacy-1", "payload": [{"blob": '{"session_id": "test-session-456", "session_type": "AGENT"}'}]}],
3396+
]
3397+
result = no_persist_manager.read_session("test-session-456")
3398+
assert result is not None
3399+
mock_memory_client.gmdp_client.create_event.assert_not_called()
3400+
mock_memory_client.gmdp_client.delete_event.assert_not_called()
3401+
3402+
def test_legacy_agent_migration_skipped(self, no_persist_manager, mock_memory_client):
3403+
mock_memory_client.list_events.side_effect = [
3404+
[],
3405+
[{"eventId": "legacy-1", "payload": [{"blob": '{"agent_id": "a1", "state": {}, "conversation_manager_state": {}}'}]}],
3406+
]
3407+
result = no_persist_manager.read_agent("test-session-456", "a1")
3408+
assert result is not None
3409+
mock_memory_client.gmdp_client.create_event.assert_not_called()
3410+
mock_memory_client.gmdp_client.delete_event.assert_not_called()
3411+
3412+
# --- LTM retrieval still works ---
3413+
3414+
def test_retrieve_customer_context_works(self, mock_memory_client):
3415+
config = AgentCoreMemoryConfig(
3416+
memory_id="test-memory-123",
3417+
session_id="test-session-456",
3418+
actor_id="test-actor-789",
3419+
persistence_mode=PersistenceMode.NONE,
3420+
retrieval_config={"ns/": RetrievalConfig(top_k=5, relevance_score=0.3)},
3421+
)
3422+
manager = _create_session_manager(config, mock_memory_client)
3423+
mock_memory_client.retrieve_memories.return_value = [
3424+
{"content": {"text": "remembered fact"}},
3425+
]
3426+
3427+
mock_agent = Mock()
3428+
mock_agent.messages = [{"role": "user", "content": [{"text": "query"}]}]
3429+
event = MessageAddedEvent(agent=mock_agent, message={"role": "user", "content": [{"text": "query"}]})
3430+
manager.retrieve_customer_context(event)
3431+
3432+
mock_memory_client.retrieve_memories.assert_called_once()
3433+
assert "<user_context>" in mock_agent.messages[0]["content"][0]["text"]

0 commit comments

Comments
 (0)