From dc0b9eae5637072a8cc402f3fc02a2e3e589e001 Mon Sep 17 00:00:00 2001 From: strands-agent <217235299+strands-agent@users.noreply.github.com> Date: Thu, 15 Jan 2026 06:26:16 +0000 Subject: [PATCH 1/4] feat(bedrock): add s3Location support for document, image, and video sources Adds support for using S3 URIs as sources for documents, images, and videos in Bedrock model requests. This enables users to reference large files stored in S3 instead of embedding them as bytes. When both bytes and s3Location are provided, bytes takes priority to maintain backward compatibility. Closes #1482 --- src/strands/models/bedrock.py | 16 +++++-- tests/strands/models/test_bedrock.py | 70 ++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/strands/models/bedrock.py b/src/strands/models/bedrock.py index 8e1558ca7..f8e3eff38 100644 --- a/src/strands/models/bedrock.py +++ b/src/strands/models/bedrock.py @@ -414,9 +414,13 @@ def _format_request_message_content(self, content: ContentBlock) -> dict[str, An if "format" in document: result["format"] = document["format"] - # Handle source + # Handle source (supports both bytes and s3Location) if "source" in document: - result["source"] = {"bytes": document["source"]["bytes"]} + source = document["source"] + if "bytes" in source: + result["source"] = {"bytes": source["bytes"]} + elif "s3Location" in source: + result["source"] = {"s3Location": source["s3Location"]} # Handle optional fields if "citations" in document and document["citations"] is not None: @@ -437,9 +441,11 @@ def _format_request_message_content(self, content: ContentBlock) -> dict[str, An if "image" in content: image = content["image"] source = image["source"] - formatted_source = {} + formatted_source: dict[str, Any] = {} if "bytes" in source: formatted_source = {"bytes": source["bytes"]} + elif "s3Location" in source: + formatted_source = {"s3Location": source["s3Location"]} result = {"format": image["format"], "source": formatted_source} return {"image": result} @@ -502,9 +508,11 @@ def _format_request_message_content(self, content: ContentBlock) -> dict[str, An if "video" in content: video = content["video"] source = video["source"] - formatted_source = {} + formatted_source: dict[str, Any] = {} if "bytes" in source: formatted_source = {"bytes": source["bytes"]} + elif "s3Location" in source: + formatted_source = {"s3Location": source["s3Location"]} result = {"format": video["format"], "source": formatted_source} return {"video": result} diff --git a/tests/strands/models/test_bedrock.py b/tests/strands/models/test_bedrock.py index 7697c5e03..c105a5e40 100644 --- a/tests/strands/models/test_bedrock.py +++ b/tests/strands/models/test_bedrock.py @@ -2240,3 +2240,73 @@ async def test_format_request_with_guardrail_latest_message(model): # Latest user message image should also be wrapped assert "guardContent" in formatted_messages[2]["content"][1] assert formatted_messages[2]["content"][1]["guardContent"]["image"]["format"] == "png" + + +def test_format_request_s3_location_document_source(model, model_id): + """Test that s3Location source is supported for documents when bytes is not present.""" + messages = [ + { + "role": "user", + "content": [ + { + "document": { + "name": "test.pdf", + "format": "pdf", + "source": {"s3Location": {"uri": "s3://bucket/key.pdf"}}, + } + }, + ], + } + ] + + formatted_request = model._format_request(messages) + + document_block = formatted_request["messages"][0]["content"][0]["document"] + expected = {"name": "test.pdf", "format": "pdf", "source": {"s3Location": {"uri": "s3://bucket/key.pdf"}}} + assert document_block == expected + + +def test_format_request_s3_location_image_source(model, model_id): + """Test that s3Location source is supported for images when bytes is not present.""" + messages = [ + { + "role": "user", + "content": [ + { + "image": { + "format": "png", + "source": {"s3Location": {"uri": "s3://bucket/image.png"}}, + } + }, + ], + } + ] + + formatted_request = model._format_request(messages) + + image_block = formatted_request["messages"][0]["content"][0]["image"] + expected = {"format": "png", "source": {"s3Location": {"uri": "s3://bucket/image.png"}}} + assert image_block == expected + + +def test_format_request_s3_location_video_source(model, model_id): + """Test that s3Location source is supported for videos when bytes is not present.""" + messages = [ + { + "role": "user", + "content": [ + { + "video": { + "format": "mp4", + "source": {"s3Location": {"uri": "s3://bucket/video.mp4", "bucketOwner": "123456789012"}}, + } + }, + ], + } + ] + + formatted_request = model._format_request(messages) + + video_block = formatted_request["messages"][0]["content"][0]["video"] + expected = {"format": "mp4", "source": {"s3Location": {"uri": "s3://bucket/video.mp4", "bucketOwner": "123456789012"}}} + assert video_block == expected From bb6fe7cbcec8c575e45ab9bf1cab1e9251e606bf Mon Sep 17 00:00:00 2001 From: strands-agent Date: Thu, 15 Jan 2026 09:13:47 +0000 Subject: [PATCH 2/4] fix: resolve lint error - line too long in test --- tests/strands/models/test_bedrock.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/strands/models/test_bedrock.py b/tests/strands/models/test_bedrock.py index c105a5e40..9de08c4ea 100644 --- a/tests/strands/models/test_bedrock.py +++ b/tests/strands/models/test_bedrock.py @@ -2308,5 +2308,8 @@ def test_format_request_s3_location_video_source(model, model_id): formatted_request = model._format_request(messages) video_block = formatted_request["messages"][0]["content"][0]["video"] - expected = {"format": "mp4", "source": {"s3Location": {"uri": "s3://bucket/video.mp4", "bucketOwner": "123456789012"}}} + expected = { + "format": "mp4", + "source": {"s3Location": {"uri": "s3://bucket/video.mp4", "bucketOwner": "123456789012"}}, + } assert video_block == expected From 1d171966bce7d3714259054f25525c20414a6768 Mon Sep 17 00:00:00 2001 From: strands-agent <217235299+strands-agent@users.noreply.github.com> Date: Thu, 15 Jan 2026 09:31:33 +0000 Subject: [PATCH 3/4] fix: resolve mypy errors in s3Location support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add S3Location TypedDict to media.py for proper type definitions - Update DocumentSource, ImageSource, VideoSource to include s3Location option - Change source TypedDicts to total=False to allow either bytes or s3Location - Rename variables to avoid mypy 'Name already defined' errors - 'formatted_source' → 'image_source' in image block - 'formatted_source' → 'video_source' in video block - All lint/mypy checks now pass --- src/strands/models/bedrock.py | 18 ++++++++---------- src/strands/types/media.py | 25 ++++++++++++++++++++++--- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/strands/models/bedrock.py b/src/strands/models/bedrock.py index f8e3eff38..871b0db47 100644 --- a/src/strands/models/bedrock.py +++ b/src/strands/models/bedrock.py @@ -441,13 +441,12 @@ def _format_request_message_content(self, content: ContentBlock) -> dict[str, An if "image" in content: image = content["image"] source = image["source"] - formatted_source: dict[str, Any] = {} + image_source: dict[str, Any] = {} if "bytes" in source: - formatted_source = {"bytes": source["bytes"]} + image_source = {"bytes": source["bytes"]} elif "s3Location" in source: - formatted_source = {"s3Location": source["s3Location"]} - result = {"format": image["format"], "source": formatted_source} - return {"image": result} + image_source = {"s3Location": source["s3Location"]} + return {"image": {"format": image["format"], "source": image_source}} # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ReasoningContentBlock.html if "reasoningContent" in content: @@ -508,13 +507,12 @@ def _format_request_message_content(self, content: ContentBlock) -> dict[str, An if "video" in content: video = content["video"] source = video["source"] - formatted_source: dict[str, Any] = {} + video_source: dict[str, Any] = {} if "bytes" in source: - formatted_source = {"bytes": source["bytes"]} + video_source = {"bytes": source["bytes"]} elif "s3Location" in source: - formatted_source = {"s3Location": source["s3Location"]} - result = {"format": video["format"], "source": formatted_source} - return {"video": result} + video_source = {"s3Location": source["s3Location"]} + return {"video": {"format": video["format"], "source": video_source}} # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_CitationsContentBlock.html if "citationsContent" in content: diff --git a/src/strands/types/media.py b/src/strands/types/media.py index 69cd60cf3..d2ccb0bcb 100644 --- a/src/strands/types/media.py +++ b/src/strands/types/media.py @@ -11,18 +11,33 @@ from .citations import CitationsConfig + +class S3Location(TypedDict, total=False): + """S3 location for media content. + + Attributes: + uri: The S3 URI of the content. + bucketOwner: The account ID of the bucket owner. + """ + + uri: str + bucketOwner: str + + DocumentFormat = Literal["pdf", "csv", "doc", "docx", "xls", "xlsx", "html", "txt", "md"] """Supported document formats.""" -class DocumentSource(TypedDict): +class DocumentSource(TypedDict, total=False): """Contains the content of a document. Attributes: bytes: The binary content of the document. + s3Location: The S3 location of the document. """ bytes: bytes + s3Location: S3Location class DocumentContent(TypedDict, total=False): @@ -45,14 +60,16 @@ class DocumentContent(TypedDict, total=False): """Supported image formats.""" -class ImageSource(TypedDict): +class ImageSource(TypedDict, total=False): """Contains the content of an image. Attributes: bytes: The binary content of the image. + s3Location: The S3 location of the image. """ bytes: bytes + s3Location: S3Location class ImageContent(TypedDict): @@ -71,14 +88,16 @@ class ImageContent(TypedDict): """Supported video formats.""" -class VideoSource(TypedDict): +class VideoSource(TypedDict, total=False): """Contains the content of a video. Attributes: bytes: The binary content of the video. + s3Location: The S3 location of the video. """ bytes: bytes + s3Location: S3Location class VideoContent(TypedDict): From a869d8bc16e03ab62e4d7cf26637ed89708d1e7a Mon Sep 17 00:00:00 2001 From: strands-agent Date: Fri, 16 Jan 2026 08:12:21 +0000 Subject: [PATCH 4/4] test: add coverage for _is_session_active close_future check --- .../mcp/test_mcp_client_tool_provider.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/strands/tools/mcp/test_mcp_client_tool_provider.py b/tests/strands/tools/mcp/test_mcp_client_tool_provider.py index 9cb90167d..af56cf367 100644 --- a/tests/strands/tools/mcp/test_mcp_client_tool_provider.py +++ b/tests/strands/tools/mcp/test_mcp_client_tool_provider.py @@ -824,3 +824,56 @@ def short_names_only(tool) -> bool: # Should only include short tool (name length <= 10) assert len(result) == 1 assert result[0] is mock_agent_tool1 + + +def test_is_session_active_with_close_future_done(): + """Test that _is_session_active returns False when close_future is done.""" + from unittest.mock import Mock + + client = MCPClient(transport_callable=lambda: Mock()) + + # Mock background thread as alive + client._background_thread = Mock() + client._background_thread.is_alive.return_value = True + + # Mock close_future as done + client._close_future = Mock() + client._close_future.done.return_value = True + + # Should return False because close_future is done + assert client._is_session_active() is False + + +def test_is_session_active_with_close_future_not_done(): + """Test that _is_session_active returns True when close_future is not done.""" + from unittest.mock import Mock + + client = MCPClient(transport_callable=lambda: Mock()) + + # Mock background thread as alive + client._background_thread = Mock() + client._background_thread.is_alive.return_value = True + + # Mock close_future as not done + client._close_future = Mock() + client._close_future.done.return_value = False + + # Should return True + assert client._is_session_active() is True + + +def test_is_session_active_with_none_close_future(): + """Test that _is_session_active returns True when close_future is None.""" + from unittest.mock import Mock + + client = MCPClient(transport_callable=lambda: Mock()) + + # Mock background thread as alive + client._background_thread = Mock() + client._background_thread.is_alive.return_value = True + + # close_future is None + client._close_future = None + + # Should return True + assert client._is_session_active() is True