Skip to content

Commit 843385a

Browse files
committed
feat: add ReadOnlySessionManager wrapper for read-only sessions
1 parent 94fc8dd commit 843385a

File tree

3 files changed

+222
-0
lines changed

3 files changed

+222
-0
lines changed

src/strands/session/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
"""
55

66
from .file_session_manager import FileSessionManager
7+
from .read_only_session_manager import ReadOnlySessionManager
78
from .repository_session_manager import RepositorySessionManager
89
from .s3_session_manager import S3SessionManager
910
from .session_manager import SessionManager
1011
from .session_repository import SessionRepository
1112

1213
__all__ = [
1314
"FileSessionManager",
15+
"ReadOnlySessionManager",
1416
"RepositorySessionManager",
1517
"S3SessionManager",
1618
"SessionManager",
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Read-only session manager wrapper."""
2+
3+
import logging
4+
from typing import TYPE_CHECKING, Any
5+
6+
from ..hooks.registry import HookRegistry
7+
from ..types.content import Message
8+
from .session_manager import SessionManager
9+
10+
if TYPE_CHECKING:
11+
from ..agent.agent import Agent
12+
from ..experimental.bidi.agent.agent import BidiAgent
13+
from ..multiagent.base import MultiAgentBase
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
class ReadOnlySessionManager(SessionManager):
19+
"""A wrapper that delegates read operations to an inner session manager and no-ops all writes.
20+
21+
Read-only enforcement happens at the SessionManager level — all write methods are no-ops regardless
22+
of whether they are called by the Agent, custom hooks, or user code.
23+
24+
Attribute access is forwarded to the inner session manager, so properties like ``session_id``
25+
and ``bucket`` are available directly on the wrapper.
26+
27+
Note:
28+
The wrapper protects writes because the Agent holds a reference to this wrapper instance.
29+
Bypassing the wrapper by obtaining the inner session manager and passing it directly to an
30+
Agent will lose read-only protection.
31+
32+
Usage::
33+
34+
from strands import Agent
35+
from strands.session import ReadOnlySessionManager, S3SessionManager
36+
37+
inner = S3SessionManager(session_id="tenant-123", bucket="my-bucket")
38+
agent = Agent(session_manager=ReadOnlySessionManager(inner))
39+
"""
40+
41+
def __init__(self, inner: SessionManager) -> None:
42+
"""Initialize the ReadOnlySessionManager.
43+
44+
Args:
45+
inner: The session manager to delegate read operations to.
46+
"""
47+
self._inner = inner
48+
49+
def __getattr__(self, name: str) -> Any:
50+
"""Forward attribute access to the inner session manager."""
51+
return getattr(self._inner, name)
52+
53+
def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None:
54+
"""Register hooks with write methods pointing to this wrapper's no-ops."""
55+
super().register_hooks(registry, **kwargs)
56+
57+
def initialize(self, agent: "Agent", **kwargs: Any) -> None:
58+
"""Delegate to inner session manager to restore agent state."""
59+
self._inner.initialize(agent, **kwargs)
60+
61+
def initialize_multi_agent(self, source: "MultiAgentBase", **kwargs: Any) -> None:
62+
"""Delegate to inner session manager to restore multi-agent state."""
63+
self._inner.initialize_multi_agent(source, **kwargs)
64+
65+
def initialize_bidi_agent(self, agent: "BidiAgent", **kwargs: Any) -> None:
66+
"""Delegate to inner session manager to restore bidi agent state."""
67+
self._inner.initialize_bidi_agent(agent, **kwargs)
68+
69+
def append_message(self, message: Message, agent: "Agent", **kwargs: Any) -> None:
70+
"""No-op: read-only mode skips message persistence."""
71+
logger.debug("read_only=<True> | skipping append_message")
72+
73+
def redact_latest_message(self, redact_message: Message, agent: "Agent", **kwargs: Any) -> None:
74+
"""No-op: read-only mode skips message redaction persistence."""
75+
logger.debug("read_only=<True> | skipping redact_latest_message")
76+
77+
def sync_agent(self, agent: "Agent", **kwargs: Any) -> None:
78+
"""No-op: read-only mode skips agent sync."""
79+
logger.debug("read_only=<True> | skipping sync_agent")
80+
81+
def sync_multi_agent(self, source: "MultiAgentBase", **kwargs: Any) -> None:
82+
"""No-op: read-only mode skips multi-agent sync."""
83+
logger.debug("read_only=<True> | skipping sync_multi_agent")
84+
85+
def append_bidi_message(self, message: Message, agent: "BidiAgent", **kwargs: Any) -> None:
86+
"""No-op: read-only mode skips bidi message persistence."""
87+
logger.debug("read_only=<True> | skipping append_bidi_message")
88+
89+
def sync_bidi_agent(self, agent: "BidiAgent", **kwargs: Any) -> None:
90+
"""No-op: read-only mode skips bidi agent sync."""
91+
logger.debug("read_only=<True> | skipping sync_bidi_agent")
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Tests for ReadOnlySessionManager wrapper."""
2+
3+
from unittest.mock import Mock, patch
4+
5+
import pytest
6+
7+
from strands.agent.agent import Agent
8+
from strands.session.read_only_session_manager import ReadOnlySessionManager
9+
from strands.session.repository_session_manager import RepositorySessionManager
10+
from strands.types.content import ContentBlock
11+
from strands.types.session import Session, SessionAgent, SessionMessage, SessionType
12+
from tests.fixtures.mock_session_repository import MockedSessionRepository
13+
14+
15+
@pytest.fixture
16+
def mock_repository():
17+
"""Create a mock repository."""
18+
return MockedSessionRepository()
19+
20+
21+
@pytest.fixture
22+
def inner_session_manager(mock_repository):
23+
"""Create an inner read-write session manager."""
24+
return RepositorySessionManager(
25+
session_id="test-session",
26+
session_repository=mock_repository,
27+
)
28+
29+
30+
@pytest.fixture
31+
def read_only_session_manager(inner_session_manager):
32+
"""Create a read-only wrapper around the inner session manager."""
33+
return ReadOnlySessionManager(inner_session_manager)
34+
35+
36+
@pytest.fixture
37+
def existing_read_only_session_manager(mock_repository):
38+
"""Create a read-only wrapper with a pre-existing session."""
39+
session = Session(session_id="test-session", session_type=SessionType.AGENT)
40+
mock_repository.create_session(session)
41+
inner = RepositorySessionManager(
42+
session_id="test-session",
43+
session_repository=mock_repository,
44+
)
45+
return ReadOnlySessionManager(inner)
46+
47+
48+
def test_initialize_delegates_to_inner(existing_read_only_session_manager, mock_repository):
49+
"""Test that initialize restores agent state from the inner session manager."""
50+
session_agent = SessionAgent(
51+
agent_id="test-agent",
52+
state={"key": "value"},
53+
conversation_manager_state={
54+
"__name__": "SlidingWindowConversationManager",
55+
"removed_message_count": 0,
56+
},
57+
)
58+
mock_repository.create_agent("test-session", session_agent)
59+
mock_repository.create_message(
60+
"test-session",
61+
"test-agent",
62+
SessionMessage(message={"role": "user", "content": [ContentBlock(text="Hello")]}, message_id=0),
63+
)
64+
65+
agent = Agent(agent_id="test-agent")
66+
existing_read_only_session_manager.initialize(agent)
67+
68+
assert agent.state.get("key") == "value"
69+
assert len(agent.messages) == 1
70+
assert agent.messages[0]["content"][0]["text"] == "Hello"
71+
72+
73+
def test_write_methods_are_noop(read_only_session_manager):
74+
"""Test that all write methods are no-ops and don't raise."""
75+
agent = Mock(agent_id="test-agent")
76+
source = Mock()
77+
78+
read_only_session_manager.append_message({"role": "user", "content": []}, agent)
79+
read_only_session_manager.redact_latest_message({"role": "user", "content": []}, agent)
80+
read_only_session_manager.sync_agent(agent)
81+
read_only_session_manager.sync_multi_agent(source)
82+
read_only_session_manager.append_bidi_message({"role": "user", "content": []}, agent)
83+
read_only_session_manager.sync_bidi_agent(agent)
84+
85+
86+
def test_hooks_do_not_call_inner_write_methods(inner_session_manager):
87+
"""Test that hooks fire the wrapper's no-ops, not the inner's write methods."""
88+
with (
89+
patch.object(inner_session_manager, "append_message") as mock_append,
90+
patch.object(inner_session_manager, "sync_agent") as mock_sync,
91+
):
92+
ro = ReadOnlySessionManager(inner_session_manager)
93+
Agent(agent_id="test-agent", session_manager=ro)
94+
95+
mock_append.assert_not_called()
96+
mock_sync.assert_not_called()
97+
98+
99+
def test_messages_not_persisted_via_hooks(read_only_session_manager, mock_repository):
100+
"""Test that messages are not persisted when hooks fire through the wrapper."""
101+
Agent(agent_id="test-agent", session_manager=read_only_session_manager)
102+
103+
messages = mock_repository.list_messages("test-session", "test-agent")
104+
assert len(messages) == 0
105+
106+
107+
def test_direct_write_calls_are_noop(read_only_session_manager, mock_repository):
108+
"""Test that direct calls to write methods don't persist."""
109+
agent = Agent(agent_id="test-agent", session_manager=read_only_session_manager)
110+
111+
agent.messages.append({"role": "user", "content": [{"text": "test"}]})
112+
read_only_session_manager.sync_agent(agent)
113+
114+
session_agent = mock_repository.read_agent("test-session", "test-agent")
115+
assert session_agent.state == {}
116+
117+
118+
def test_multi_agent_initialize_delegates(read_only_session_manager):
119+
"""Test that multi-agent initialize delegates to inner."""
120+
mock_multi_agent = Mock()
121+
mock_multi_agent.id = "test-multi-agent"
122+
mock_multi_agent.serialize_state.return_value = {"id": "test-multi-agent", "state": {}}
123+
124+
read_only_session_manager.initialize_multi_agent(mock_multi_agent)
125+
126+
127+
def test_getattr_forwards_to_inner(read_only_session_manager, inner_session_manager):
128+
"""Test that attribute access is forwarded to the inner session manager."""
129+
assert read_only_session_manager.session_id == inner_session_manager.session_id

0 commit comments

Comments
 (0)