From 454c2ebe4b13acd66aeda7b64ec0a3f50a8f591f Mon Sep 17 00:00:00 2001 From: Fangmbeng Date: Wed, 18 Feb 2026 01:09:30 -0500 Subject: [PATCH 1/3] Accept dict-shaped artifacts in artifact services; add tests (fixes #3622) --- src/google/adk/agents/context.py | 5 +- .../adk/artifacts/base_artifact_service.py | 11 ++- .../adk/artifacts/file_artifact_service.py | 6 +- .../adk/artifacts/gcs_artifact_service.py | 6 +- .../artifacts/in_memory_artifact_service.py | 6 +- src/google/adk/cli/adk_web_server.py | 4 +- .../adk/tools/_forwarding_artifact_service.py | 6 +- .../artifacts/test_artifact_service.py | 99 +++++++++++++++++++ 8 files changed, 130 insertions(+), 13 deletions(-) diff --git a/src/google/adk/agents/context.py b/src/google/adk/agents/context.py index 70dfa05f59..8e0e23bb6d 100644 --- a/src/google/adk/agents/context.py +++ b/src/google/adk/agents/context.py @@ -136,14 +136,15 @@ async def load_artifact( async def save_artifact( self, filename: str, - artifact: types.Part, + artifact: types.Part | dict[str, Any], custom_metadata: dict[str, Any] | None = None, ) -> int: """Saves an artifact and records it as delta for the current session. Args: filename: The filename of the artifact. - artifact: The artifact to save. + artifact: The artifact to save. Can be a types.Part object or a + dict-shaped (serialized) artifact. custom_metadata: Custom metadata to associate with the artifact. Returns: diff --git a/src/google/adk/artifacts/base_artifact_service.py b/src/google/adk/artifacts/base_artifact_service.py index 1a265f8ad9..df279c7c43 100644 --- a/src/google/adk/artifacts/base_artifact_service.py +++ b/src/google/adk/artifacts/base_artifact_service.py @@ -70,7 +70,7 @@ async def save_artifact( app_name: str, user_id: str, filename: str, - artifact: types.Part, + artifact: types.Part | dict[str, Any], session_id: Optional[str] = None, custom_metadata: Optional[dict[str, Any]] = None, ) -> int: @@ -84,10 +84,11 @@ async def save_artifact( app_name: The app name. user_id: The user ID. filename: The filename of the artifact. - artifact: The artifact to save. If the artifact consists of `file_data`, - the artifact service assumes its content has been uploaded separately, - and this method will associate the `file_data` with the artifact if - necessary. + artifact: The artifact to save. Can be a types.Part object or a + dict-shaped (serialized) artifact that will be converted to types.Part. + If the artifact consists of `file_data`, the artifact service assumes + its content has been uploaded separately, and this method will associate + the `file_data` with the artifact if necessary. session_id: The session ID. If `None`, the artifact is user-scoped. custom_metadata: custom metadata to associate with the artifact. diff --git a/src/google/adk/artifacts/file_artifact_service.py b/src/google/adk/artifacts/file_artifact_service.py index be5adb4818..518ecbbf5a 100644 --- a/src/google/adk/artifacts/file_artifact_service.py +++ b/src/google/adk/artifacts/file_artifact_service.py @@ -314,7 +314,7 @@ async def save_artifact( app_name: str, user_id: str, filename: str, - artifact: types.Part, + artifact: types.Part | dict[str, Any], session_id: Optional[str] = None, custom_metadata: Optional[dict[str, Any]] = None, ) -> int: @@ -326,6 +326,10 @@ async def save_artifact( computed scope root; absolute paths or inputs that traverse outside that root (for example ``"../../secret.txt"``) raise ``ValueError``. """ + # Convert dict-shaped artifact to types.Part if necessary + if isinstance(artifact, dict): + artifact = types.Part.model_validate(artifact) + return await asyncio.to_thread( self._save_artifact_sync, user_id, diff --git a/src/google/adk/artifacts/gcs_artifact_service.py b/src/google/adk/artifacts/gcs_artifact_service.py index 4108cfb06b..8125b9a067 100644 --- a/src/google/adk/artifacts/gcs_artifact_service.py +++ b/src/google/adk/artifacts/gcs_artifact_service.py @@ -61,10 +61,14 @@ async def save_artifact( app_name: str, user_id: str, filename: str, - artifact: types.Part, + artifact: types.Part | dict[str, Any], session_id: Optional[str] = None, custom_metadata: Optional[dict[str, Any]] = None, ) -> int: + # Convert dict-shaped artifact to types.Part if necessary + if isinstance(artifact, dict): + artifact = types.Part.model_validate(artifact) + return await asyncio.to_thread( self._save_artifact, app_name, diff --git a/src/google/adk/artifacts/in_memory_artifact_service.py b/src/google/adk/artifacts/in_memory_artifact_service.py index 45552b1452..7f27f24425 100644 --- a/src/google/adk/artifacts/in_memory_artifact_service.py +++ b/src/google/adk/artifacts/in_memory_artifact_service.py @@ -99,10 +99,14 @@ async def save_artifact( app_name: str, user_id: str, filename: str, - artifact: types.Part, + artifact: types.Part | dict[str, Any], session_id: Optional[str] = None, custom_metadata: Optional[dict[str, Any]] = None, ) -> int: + # Convert dict-shaped artifact to types.Part if necessary + if isinstance(artifact, dict): + artifact = types.Part.model_validate(artifact) + path = self._artifact_path(app_name, user_id, filename, session_id) if path not in self.artifacts: self.artifacts[path] = [] diff --git a/src/google/adk/cli/adk_web_server.py b/src/google/adk/cli/adk_web_server.py index c61f855fc7..758e7640e2 100644 --- a/src/google/adk/cli/adk_web_server.py +++ b/src/google/adk/cli/adk_web_server.py @@ -232,8 +232,8 @@ class SaveArtifactRequest(common.BaseModel): """Request payload for saving a new artifact.""" filename: str = Field(description="Artifact filename.") - artifact: types.Part = Field( - description="Artifact payload encoded as google.genai.types.Part." + artifact: types.Part | dict[str, Any] = Field( + description="Artifact payload encoded as google.genai.types.Part or as a dict-shaped artifact." ) custom_metadata: Optional[dict[str, Any]] = Field( default=None, diff --git a/src/google/adk/tools/_forwarding_artifact_service.py b/src/google/adk/tools/_forwarding_artifact_service.py index 9667e8d4c3..e8d4d3c482 100644 --- a/src/google/adk/tools/_forwarding_artifact_service.py +++ b/src/google/adk/tools/_forwarding_artifact_service.py @@ -42,10 +42,14 @@ async def save_artifact( app_name: str, user_id: str, filename: str, - artifact: types.Part, + artifact: types.Part | dict[str, Any], session_id: Optional[str] = None, custom_metadata: Optional[dict[str, Any]] = None, ) -> int: + # Convert dict-shaped artifact to types.Part if necessary + if isinstance(artifact, dict): + artifact = types.Part.model_validate(artifact) + return await self.tool_context.save_artifact( filename=filename, artifact=artifact, diff --git a/tests/unittests/artifacts/test_artifact_service.py b/tests/unittests/artifacts/test_artifact_service.py index ec74f8abe3..18bf263269 100644 --- a/tests/unittests/artifacts/test_artifact_service.py +++ b/tests/unittests/artifacts/test_artifact_service.py @@ -766,3 +766,102 @@ async def test_file_save_artifact_rejects_absolute_path_within_scope(tmp_path): filename=str(absolute_in_scope), artifact=part, ) + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "service_type", + [ + ArtifactServiceType.IN_MEMORY, + ArtifactServiceType.GCS, + ArtifactServiceType.FILE, + ], +) +async def test_save_load_dict_shaped_artifact( + service_type, artifact_service_factory +): + """Tests saving and loading dict-shaped artifacts. + + This tests the fix for accepting dict-shaped (serialized) artifacts + in the save_artifact method. Dict-shaped artifacts are commonly used + when artifacts are stored/retrieved from JSON or other serialization formats. + """ + artifact_service = artifact_service_factory(service_type) + # Create a dict-shaped artifact by serializing a real Part instance + part = types.Part.from_bytes(data=b"test_data", mime_type="text/plain") + dict_artifact = part.model_dump(exclude_none=True) + + app_name = "app0" + user_id = "user0" + session_id = "123" + filename = "dict_file.txt" + + # Save the dict-shaped artifact + version = await artifact_service.save_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + artifact=dict_artifact, + ) + assert version == 0 + + # Load and verify the artifact + loaded = await artifact_service.load_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + ) + assert loaded is not None + assert loaded.inline_data is not None + assert loaded.inline_data.mime_type == "text/plain" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "service_type", + [ + ArtifactServiceType.IN_MEMORY, + ArtifactServiceType.GCS, + ArtifactServiceType.FILE, + ], +) +async def test_save_text_dict_shaped_artifact( + service_type, artifact_service_factory +): + """Tests saving and loading dict-shaped artifacts with text content.""" + artifact_service = artifact_service_factory(service_type) + # Create a dict-shaped artifact by serializing a real Part instance + part = types.Part(text="Hello, World!") + dict_artifact = part.model_dump(exclude_none=True) + + app_name = "app0" + user_id = "user0" + session_id = "123" + filename = "text_file.txt" + + # Save the dict-shaped artifact + await artifact_service.save_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + artifact=dict_artifact, + ) + + # Load and verify the artifact + loaded = await artifact_service.load_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + ) + assert loaded is not None + # GCS/File services may return text as inline_data bytes; accept either form. + if loaded.text is not None: + assert loaded.text == "Hello, World!" + else: + assert ( + loaded.inline_data is not None + and loaded.inline_data.data == b"Hello, World!" + ) \ No newline at end of file From c3a76feee011dc7e0bd082a8cca99ee8946e2d55 Mon Sep 17 00:00:00 2001 From: Fangmbeng Date: Wed, 18 Feb 2026 02:40:28 -0500 Subject: [PATCH 2/3] refactor: centralize dict-to-Part conversion in BaseArtifactService; remove redundant conversion from ForwardingArtifactService --- DICT_ARTIFACTS_FIX.md | 69 +++++++++++++++++++ .../adk/artifacts/base_artifact_service.py | 17 +++++ .../adk/artifacts/file_artifact_service.py | 3 +- .../adk/artifacts/gcs_artifact_service.py | 3 +- .../artifacts/in_memory_artifact_service.py | 3 +- .../adk/tools/_forwarding_artifact_service.py | 6 +- .../artifacts/test_artifact_service.py | 2 +- 7 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 DICT_ARTIFACTS_FIX.md diff --git a/DICT_ARTIFACTS_FIX.md b/DICT_ARTIFACTS_FIX.md new file mode 100644 index 0000000000..d5468efc48 --- /dev/null +++ b/DICT_ARTIFACTS_FIX.md @@ -0,0 +1,69 @@ +# Fix for Issue #3622: Accept dict-shaped artifacts in InMemoryArtifactService + +## Summary +Fixed the artifact services to accept dict-shaped (serialized) artifacts in addition to `types.Part` objects. This allows users to pass artifacts as dictionaries, which are automatically converted to `types.Part` objects internally. + +## Changes Made + +### 1. Base Artifact Service (`base_artifact_service.py`) +- Updated the `save_artifact()` method signature to accept `types.Part | dict[str, Any]` +- Updated the docstring to clarify that dict-shaped artifacts are now supported + +### 2. InMemoryArtifactService (`in_memory_artifact_service.py`) +- Updated the `save_artifact()` method to: + - Accept `types.Part | dict[str, Any]` parameter type + - Added conversion logic: `if isinstance(artifact, dict): artifact = types.Part.model_validate(artifact)` + - This deserialization happens before any artifact processing + +### 3. GcsArtifactService (`gcs_artifact_service.py`) +- Updated the async `save_artifact()` method to: + - Accept `types.Part | dict[str, Any]` parameter type + - Added conversion logic before threading to sync method +- The internal `_save_artifact()` method processes the already-converted `types.Part` object + +### 4. FileArtifactService (`file_artifact_service.py`) +- Updated the async `save_artifact()` method to: + - Accept `types.Part | dict[str, Any]` parameter type + - Added conversion logic before threading to sync method +- The internal `_save_artifact_sync()` method processes the already-converted `types.Part` object + +### 5. ForwardingArtifactService (`_forwarding_artifact_service.py`) +- Updated the `save_artifact()` method to: + - Accept `types.Part | dict[str, Any]` parameter type + - Added conversion logic before forwarding to the parent tool context + +### 6. Test Suite (`test_artifact_service.py`) +- Added `test_save_load_dict_shaped_artifact()` test to verify dict-shaped artifacts can be saved and loaded across all service types (IN_MEMORY, GCS, FILE) +- Added `test_save_text_dict_shaped_artifact()` test to verify text-based dict-shaped artifacts work correctly in InMemoryArtifactService + +## How It Works + +When a dictionary is passed to `save_artifact()`: +1. The method checks if the artifact is a dictionary using `isinstance(artifact, dict)` +2. If it is, it converts it to a `types.Part` object using `types.Part.model_validate(artifact)` +3. The rest of the method processes the converted `types.Part` object as usual + +## Example Usage + +```python +# Before (still supported) +artifact = types.Part(text="Hello, World!") +await service.save_artifact(..., artifact=artifact) + +# After (now also supported) +artifact_dict = {"text": "Hello, World!"} +await service.save_artifact(..., artifact=artifact_dict) + +# Also works with inline data +artifact_dict = { + "inline_data": { + "data": "dGVzdF9kYXRh", # base64 encoded + "mime_type": "text/plain", + } +} +await service.save_artifact(..., artifact=artifact_dict) +``` + +## Backward Compatibility + +✅ **Fully backward compatible** - All existing code using `types.Part` objects will continue to work exactly as before. diff --git a/src/google/adk/artifacts/base_artifact_service.py b/src/google/adk/artifacts/base_artifact_service.py index df279c7c43..48fc7457fd 100644 --- a/src/google/adk/artifacts/base_artifact_service.py +++ b/src/google/adk/artifacts/base_artifact_service.py @@ -63,6 +63,23 @@ class ArtifactVersion(BaseModel): class BaseArtifactService(ABC): """Abstract base class for artifact services.""" + @staticmethod + def _convert_artifact_if_dict( + artifact: types.Part | dict[str, Any], + ) -> types.Part: + """Converts a dict-shaped artifact to types.Part if necessary. + + Args: + artifact: The artifact to convert. Can be a types.Part or dict. + + Returns: + A types.Part object. If input is already a Part, returns as-is. + If input is a dict, converts it to Part via model_validate. + """ + if isinstance(artifact, dict): + return types.Part.model_validate(artifact) + return artifact + @abstractmethod async def save_artifact( self, diff --git a/src/google/adk/artifacts/file_artifact_service.py b/src/google/adk/artifacts/file_artifact_service.py index 518ecbbf5a..37457b8b29 100644 --- a/src/google/adk/artifacts/file_artifact_service.py +++ b/src/google/adk/artifacts/file_artifact_service.py @@ -327,8 +327,7 @@ async def save_artifact( root (for example ``"../../secret.txt"``) raise ``ValueError``. """ # Convert dict-shaped artifact to types.Part if necessary - if isinstance(artifact, dict): - artifact = types.Part.model_validate(artifact) + artifact = self._convert_artifact_if_dict(artifact) return await asyncio.to_thread( self._save_artifact_sync, diff --git a/src/google/adk/artifacts/gcs_artifact_service.py b/src/google/adk/artifacts/gcs_artifact_service.py index 8125b9a067..03692c3605 100644 --- a/src/google/adk/artifacts/gcs_artifact_service.py +++ b/src/google/adk/artifacts/gcs_artifact_service.py @@ -66,8 +66,7 @@ async def save_artifact( custom_metadata: Optional[dict[str, Any]] = None, ) -> int: # Convert dict-shaped artifact to types.Part if necessary - if isinstance(artifact, dict): - artifact = types.Part.model_validate(artifact) + artifact = self._convert_artifact_if_dict(artifact) return await asyncio.to_thread( self._save_artifact, diff --git a/src/google/adk/artifacts/in_memory_artifact_service.py b/src/google/adk/artifacts/in_memory_artifact_service.py index 7f27f24425..421b66ec42 100644 --- a/src/google/adk/artifacts/in_memory_artifact_service.py +++ b/src/google/adk/artifacts/in_memory_artifact_service.py @@ -104,8 +104,7 @@ async def save_artifact( custom_metadata: Optional[dict[str, Any]] = None, ) -> int: # Convert dict-shaped artifact to types.Part if necessary - if isinstance(artifact, dict): - artifact = types.Part.model_validate(artifact) + artifact = self._convert_artifact_if_dict(artifact) path = self._artifact_path(app_name, user_id, filename, session_id) if path not in self.artifacts: diff --git a/src/google/adk/tools/_forwarding_artifact_service.py b/src/google/adk/tools/_forwarding_artifact_service.py index e8d4d3c482..48fbeb1aa8 100644 --- a/src/google/adk/tools/_forwarding_artifact_service.py +++ b/src/google/adk/tools/_forwarding_artifact_service.py @@ -46,10 +46,8 @@ async def save_artifact( session_id: Optional[str] = None, custom_metadata: Optional[dict[str, Any]] = None, ) -> int: - # Convert dict-shaped artifact to types.Part if necessary - if isinstance(artifact, dict): - artifact = types.Part.model_validate(artifact) - + # Delegate to parent tool context, which will handle conversion in the + # concrete artifact service implementation. return await self.tool_context.save_artifact( filename=filename, artifact=artifact, diff --git a/tests/unittests/artifacts/test_artifact_service.py b/tests/unittests/artifacts/test_artifact_service.py index 18bf263269..9471ec9624 100644 --- a/tests/unittests/artifacts/test_artifact_service.py +++ b/tests/unittests/artifacts/test_artifact_service.py @@ -789,7 +789,7 @@ async def test_save_load_dict_shaped_artifact( # Create a dict-shaped artifact by serializing a real Part instance part = types.Part.from_bytes(data=b"test_data", mime_type="text/plain") dict_artifact = part.model_dump(exclude_none=True) - + app_name = "app0" user_id = "user0" session_id = "123" From fec8e2560a1504260817a1a6198a7d884c10718b Mon Sep 17 00:00:00 2001 From: Fangmbeng Date: Wed, 18 Feb 2026 02:54:09 -0500 Subject: [PATCH 3/3] chore: remove DICT_ARTIFACTS_FIX.md from tracking and add to gitignore --- .gitignore | 1 + DICT_ARTIFACTS_FIX.md | 69 ------------------------------------------- 2 files changed, 1 insertion(+), 69 deletions(-) delete mode 100644 DICT_ARTIFACTS_FIX.md diff --git a/.gitignore b/.gitignore index 47f633c5c5..f842a0a2f8 100644 --- a/.gitignore +++ b/.gitignore @@ -104,6 +104,7 @@ Thumbs.db .adk/ .claude/ CLAUDE.md +DICT_ARTIFACTS_FIX.md .cursor/ .cursorrules .cursorignore diff --git a/DICT_ARTIFACTS_FIX.md b/DICT_ARTIFACTS_FIX.md deleted file mode 100644 index d5468efc48..0000000000 --- a/DICT_ARTIFACTS_FIX.md +++ /dev/null @@ -1,69 +0,0 @@ -# Fix for Issue #3622: Accept dict-shaped artifacts in InMemoryArtifactService - -## Summary -Fixed the artifact services to accept dict-shaped (serialized) artifacts in addition to `types.Part` objects. This allows users to pass artifacts as dictionaries, which are automatically converted to `types.Part` objects internally. - -## Changes Made - -### 1. Base Artifact Service (`base_artifact_service.py`) -- Updated the `save_artifact()` method signature to accept `types.Part | dict[str, Any]` -- Updated the docstring to clarify that dict-shaped artifacts are now supported - -### 2. InMemoryArtifactService (`in_memory_artifact_service.py`) -- Updated the `save_artifact()` method to: - - Accept `types.Part | dict[str, Any]` parameter type - - Added conversion logic: `if isinstance(artifact, dict): artifact = types.Part.model_validate(artifact)` - - This deserialization happens before any artifact processing - -### 3. GcsArtifactService (`gcs_artifact_service.py`) -- Updated the async `save_artifact()` method to: - - Accept `types.Part | dict[str, Any]` parameter type - - Added conversion logic before threading to sync method -- The internal `_save_artifact()` method processes the already-converted `types.Part` object - -### 4. FileArtifactService (`file_artifact_service.py`) -- Updated the async `save_artifact()` method to: - - Accept `types.Part | dict[str, Any]` parameter type - - Added conversion logic before threading to sync method -- The internal `_save_artifact_sync()` method processes the already-converted `types.Part` object - -### 5. ForwardingArtifactService (`_forwarding_artifact_service.py`) -- Updated the `save_artifact()` method to: - - Accept `types.Part | dict[str, Any]` parameter type - - Added conversion logic before forwarding to the parent tool context - -### 6. Test Suite (`test_artifact_service.py`) -- Added `test_save_load_dict_shaped_artifact()` test to verify dict-shaped artifacts can be saved and loaded across all service types (IN_MEMORY, GCS, FILE) -- Added `test_save_text_dict_shaped_artifact()` test to verify text-based dict-shaped artifacts work correctly in InMemoryArtifactService - -## How It Works - -When a dictionary is passed to `save_artifact()`: -1. The method checks if the artifact is a dictionary using `isinstance(artifact, dict)` -2. If it is, it converts it to a `types.Part` object using `types.Part.model_validate(artifact)` -3. The rest of the method processes the converted `types.Part` object as usual - -## Example Usage - -```python -# Before (still supported) -artifact = types.Part(text="Hello, World!") -await service.save_artifact(..., artifact=artifact) - -# After (now also supported) -artifact_dict = {"text": "Hello, World!"} -await service.save_artifact(..., artifact=artifact_dict) - -# Also works with inline data -artifact_dict = { - "inline_data": { - "data": "dGVzdF9kYXRh", # base64 encoded - "mime_type": "text/plain", - } -} -await service.save_artifact(..., artifact=artifact_dict) -``` - -## Backward Compatibility - -✅ **Fully backward compatible** - All existing code using `types.Part` objects will continue to work exactly as before.