Skip to content

Commit da1c176

Browse files
committed
feat: add ReadOnlySessionManager wrapper for read-only sessions (Approach B)
1 parent 7b4df8a commit da1c176

3 files changed

Lines changed: 228 additions & 0 deletions

File tree

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: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
Usage::
25+
26+
from strands import Agent
27+
from strands.session import ReadOnlySessionManager, S3SessionManager
28+
29+
inner = S3SessionManager(session_id="tenant-123", bucket="my-bucket")
30+
agent = Agent(session_manager=ReadOnlySessionManager(inner))
31+
"""
32+
33+
def __init__(self, inner: SessionManager) -> None:
34+
"""Initialize the ReadOnlySessionManager.
35+
36+
Args:
37+
inner: The session manager to delegate read operations to.
38+
"""
39+
self._inner = inner
40+
41+
def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None:
42+
"""Register hooks with write methods pointing to this wrapper's no-ops."""
43+
super().register_hooks(registry, **kwargs)
44+
45+
def initialize(self, agent: "Agent", **kwargs: Any) -> None:
46+
"""Delegate to inner session manager to restore agent state."""
47+
self._inner.initialize(agent, **kwargs)
48+
49+
def initialize_multi_agent(self, source: "MultiAgentBase", **kwargs: Any) -> None:
50+
"""Delegate to inner session manager to restore multi-agent state."""
51+
self._inner.initialize_multi_agent(source, **kwargs)
52+
53+
def initialize_bidi_agent(self, agent: "BidiAgent", **kwargs: Any) -> None:
54+
"""Delegate to inner session manager to restore bidi agent state."""
55+
self._inner.initialize_bidi_agent(agent, **kwargs)
56+
57+
def append_message(self, message: Message, agent: "Agent", **kwargs: Any) -> None:
58+
"""No-op: read-only mode skips message persistence."""
59+
60+
def redact_latest_message(self, redact_message: Message, agent: "Agent", **kwargs: Any) -> None:
61+
"""No-op: read-only mode skips message redaction persistence."""
62+
63+
def sync_agent(self, agent: "Agent", **kwargs: Any) -> None:
64+
"""No-op: read-only mode skips agent sync."""
65+
66+
def sync_multi_agent(self, source: "MultiAgentBase", **kwargs: Any) -> None:
67+
"""No-op: read-only mode skips multi-agent sync."""
68+
69+
def append_bidi_message(self, message: Message, agent: "BidiAgent", **kwargs: Any) -> None:
70+
"""No-op: read-only mode skips bidi message persistence."""
71+
72+
def sync_bidi_agent(self, agent: "BidiAgent", **kwargs: Any) -> None:
73+
"""No-op: read-only mode skips bidi agent sync."""
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""Tests for ReadOnlySessionManager wrapper (Approach B)."""
2+
3+
from unittest.mock import Mock
4+
5+
import pytest
6+
7+
from strands.agent.agent import Agent
8+
from strands.hooks.events import (
9+
AfterInvocationEvent,
10+
AfterMultiAgentInvocationEvent,
11+
AfterNodeCallEvent,
12+
AgentInitializedEvent,
13+
MessageAddedEvent,
14+
MultiAgentInitializedEvent,
15+
)
16+
from strands.hooks.registry import HookRegistry
17+
from strands.session.read_only_session_manager import ReadOnlySessionManager
18+
from strands.session.repository_session_manager import RepositorySessionManager
19+
from strands.types.content import ContentBlock
20+
from strands.types.session import Session, SessionAgent, SessionMessage, SessionType
21+
from tests.fixtures.mock_session_repository import MockedSessionRepository
22+
23+
24+
@pytest.fixture
25+
def mock_repository():
26+
"""Create a mock repository."""
27+
return MockedSessionRepository()
28+
29+
30+
@pytest.fixture
31+
def inner_session_manager(mock_repository):
32+
"""Create an inner read-write session manager."""
33+
return RepositorySessionManager(
34+
session_id="test-session",
35+
session_repository=mock_repository,
36+
)
37+
38+
39+
@pytest.fixture
40+
def read_only_session_manager(inner_session_manager):
41+
"""Create a read-only wrapper around the inner session manager."""
42+
return ReadOnlySessionManager(inner_session_manager)
43+
44+
45+
@pytest.fixture
46+
def existing_read_only_session_manager(mock_repository):
47+
"""Create a read-only wrapper with a pre-existing session."""
48+
session = Session(session_id="test-session", session_type=SessionType.AGENT)
49+
mock_repository.create_session(session)
50+
inner = RepositorySessionManager(
51+
session_id="test-session",
52+
session_repository=mock_repository,
53+
)
54+
return ReadOnlySessionManager(inner)
55+
56+
57+
def test_initialize_delegates_to_inner(existing_read_only_session_manager):
58+
"""Test that initialize restores agent state from the inner session manager."""
59+
inner = existing_read_only_session_manager._inner
60+
session_agent = SessionAgent(
61+
agent_id="test-agent",
62+
state={"key": "value"},
63+
conversation_manager_state={
64+
"__name__": "SlidingWindowConversationManager",
65+
"removed_message_count": 0,
66+
},
67+
)
68+
inner.session_repository.create_agent("test-session", session_agent)
69+
70+
message = SessionMessage(
71+
message={"role": "user", "content": [ContentBlock(text="Hello")]},
72+
message_id=0,
73+
)
74+
inner.session_repository.create_message("test-session", "test-agent", message)
75+
76+
agent = Agent(agent_id="test-agent")
77+
existing_read_only_session_manager.initialize(agent)
78+
79+
assert agent.state.get("key") == "value"
80+
assert len(agent.messages) == 1
81+
assert agent.messages[0]["content"][0]["text"] == "Hello"
82+
83+
84+
def test_write_methods_are_noop(read_only_session_manager):
85+
"""Test that all write methods are no-ops and don't raise."""
86+
agent = Mock()
87+
agent.agent_id = "test-agent"
88+
source = Mock()
89+
90+
# None of these should raise or have side effects
91+
read_only_session_manager.append_message({"role": "user", "content": []}, agent)
92+
read_only_session_manager.redact_latest_message({"role": "user", "content": []}, agent)
93+
read_only_session_manager.sync_agent(agent)
94+
read_only_session_manager.sync_multi_agent(source)
95+
read_only_session_manager.append_bidi_message({"role": "user", "content": []}, agent)
96+
read_only_session_manager.sync_bidi_agent(agent)
97+
98+
99+
def test_hooks_register_with_wrapper_methods(read_only_session_manager):
100+
"""Test that hooks call the wrapper's no-op methods, not the inner's write methods."""
101+
registry = HookRegistry()
102+
read_only_session_manager.register_hooks(registry)
103+
104+
# All hooks should be registered (reads + writes), but writes point to wrapper no-ops
105+
assert len(registry._registered_callbacks.get(AgentInitializedEvent, [])) == 1
106+
assert len(registry._registered_callbacks.get(MessageAddedEvent, [])) == 2
107+
assert len(registry._registered_callbacks.get(AfterInvocationEvent, [])) == 1
108+
assert len(registry._registered_callbacks.get(MultiAgentInitializedEvent, [])) == 1
109+
assert len(registry._registered_callbacks.get(AfterNodeCallEvent, [])) == 1
110+
assert len(registry._registered_callbacks.get(AfterMultiAgentInvocationEvent, [])) == 1
111+
112+
113+
def test_messages_not_persisted_via_hooks(read_only_session_manager):
114+
"""Test that messages are not persisted when hooks fire through the wrapper."""
115+
inner = read_only_session_manager._inner
116+
117+
Agent(agent_id="test-agent", session_manager=read_only_session_manager)
118+
119+
messages = inner.session_repository.list_messages("test-session", "test-agent")
120+
assert len(messages) == 0
121+
122+
123+
def test_direct_write_calls_are_noop(read_only_session_manager):
124+
"""Test that direct calls to write methods don't persist — the key advantage over Approach A."""
125+
inner = read_only_session_manager._inner
126+
127+
agent = Agent(agent_id="test-agent", session_manager=read_only_session_manager)
128+
129+
# Directly calling sync_agent on the wrapper should be a no-op
130+
agent.messages.append({"role": "user", "content": [{"text": "test"}]})
131+
read_only_session_manager.sync_agent(agent)
132+
133+
# Inner repository should not have been updated
134+
session_agent = inner.session_repository.read_agent("test-session", "test-agent")
135+
assert session_agent.state == {}
136+
137+
138+
def test_multi_agent_initialize_delegates(read_only_session_manager):
139+
"""Test that multi-agent initialize delegates to inner."""
140+
mock_multi_agent = Mock()
141+
mock_multi_agent.id = "test-multi-agent"
142+
mock_multi_agent.serialize_state.return_value = {"id": "test-multi-agent", "state": {}}
143+
144+
read_only_session_manager.initialize_multi_agent(mock_multi_agent)
145+
146+
inner = read_only_session_manager._inner
147+
state = inner.session_repository.read_multi_agent("test-session", "test-multi-agent")
148+
assert state is not None
149+
150+
151+
def test_inner_session_manager_accessible(read_only_session_manager, inner_session_manager):
152+
"""Test that the inner session manager is accessible for inspection."""
153+
assert read_only_session_manager._inner is inner_session_manager

0 commit comments

Comments
 (0)