diff --git a/src/strands/types/session.py b/src/strands/types/session.py index 29453f4b7..5e1290442 100644 --- a/src/strands/types/session.py +++ b/src/strands/types/session.py @@ -55,6 +55,22 @@ def decode_bytes_values(obj: Any) -> Any: return obj +def _encode_dict_values(obj: dict[str, Any]) -> dict[str, Any]: + """Encode bytes in a dictionary while preserving the expected payload shape.""" + encoded = encode_bytes_values(obj) + if not isinstance(encoded, dict): + raise TypeError("encoded session payload must be a dictionary") + return encoded + + +def _decode_dict_values(obj: dict[str, Any]) -> dict[str, Any]: + """Decode bytes markers in a dictionary while preserving the expected payload shape.""" + decoded = decode_bytes_values(obj) + if not isinstance(decoded, dict): + raise TypeError("decoded session payload must be a dictionary") + return decoded + + @dataclass class SessionMessage: """Message within a SessionAgent. @@ -97,11 +113,11 @@ def to_message(self) -> Message: def from_dict(cls, env: dict[str, Any]) -> "SessionMessage": """Initialize a SessionMessage from a dictionary, ignoring keys that are not class parameters.""" extracted_relevant_parameters = {k: v for k, v in env.items() if k in inspect.signature(cls).parameters} - return cls(**decode_bytes_values(extracted_relevant_parameters)) + return cls(**_decode_dict_values(extracted_relevant_parameters)) def to_dict(self) -> dict[str, Any]: """Convert the SessionMessage to a dictionary representation.""" - return encode_bytes_values(asdict(self)) # type: ignore + return _encode_dict_values(asdict(self)) @dataclass @@ -165,11 +181,11 @@ def from_bidi_agent(cls, agent: "BidiAgent") -> "SessionAgent": @classmethod def from_dict(cls, env: dict[str, Any]) -> "SessionAgent": """Initialize a SessionAgent from a dictionary, ignoring keys that are not class parameters.""" - return cls(**{k: v for k, v in env.items() if k in inspect.signature(cls).parameters}) + return cls(**_decode_dict_values({k: v for k, v in env.items() if k in inspect.signature(cls).parameters})) def to_dict(self) -> dict[str, Any]: """Convert the SessionAgent to a dictionary representation.""" - return asdict(self) + return _encode_dict_values(asdict(self)) def initialize_internal_state(self, agent: "Agent") -> None: """Initialize internal state of agent.""" @@ -204,4 +220,4 @@ def from_dict(cls, env: dict[str, Any]) -> "Session": def to_dict(self) -> dict[str, Any]: """Convert the Session to a dictionary representation.""" - return asdict(self) + return _encode_dict_values(asdict(self)) diff --git a/tests/strands/types/test_session.py b/tests/strands/types/test_session.py index 3e5360742..566ff9843 100644 --- a/tests/strands/types/test_session.py +++ b/tests/strands/types/test_session.py @@ -129,3 +129,43 @@ def test_session_agent_initialize_internal_state(): tru_interrupt_state = agent._interrupt_state exp_interrupt_state = _InterruptState(interrupts={}, context={"test": "init"}, activated=False) assert tru_interrupt_state == exp_interrupt_state + + +def test_session_agent_with_bytes(): + """SessionAgent.to_dict() must encode bytes so json.dumps() doesn't crash. + + This is the root cause of issue #1864: S3SessionManager fails when agent state + contains binary document content (e.g., inline PDF bytes from multimodal prompts). + """ + state_with_bytes = { + "documents": [ + { + "format": "pdf", + "name": "document.pdf", + "source": {"bytes": b"fake-pdf-binary-content"}, + } + ] + } + + session_agent = SessionAgent( + agent_id="a1", + conversation_manager_state={}, + state=state_with_bytes, + ) + + # Must be JSON-serializable (this is where #1864 crashes without the fix) + agent_dict = session_agent.to_dict() + json_str = json.dumps(agent_dict) + + # Round-trip: from_dict must decode bytes back + loaded_agent = SessionAgent.from_dict(json.loads(json_str)) + assert loaded_agent.state["documents"][0]["source"]["bytes"] == b"fake-pdf-binary-content" + assert loaded_agent.agent_id == "a1" + + +def test_session_with_bytes_in_session_type(): + """Session.to_dict() must encode any bytes values to remain JSON-safe.""" + session = Session(session_id="test-id", session_type=SessionType.AGENT) + session_dict = session.to_dict() + # Should not raise + json.dumps(session_dict)