Skip to content

Commit de868c4

Browse files
committed
fix(artifacts): accept dict-shaped artifacts in InMemoryArtifactService (google#3495) google#3622
1 parent 710d9de commit de868c4

9 files changed

Lines changed: 143 additions & 13 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ Thumbs.db
104104
.adk/
105105
.claude/
106106
CLAUDE.md
107+
DICT_ARTIFACTS_FIX.md
107108
.cursor/
108109
.cursorrules
109110
.cursorignore

src/google/adk/agents/context.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,14 +136,15 @@ async def load_artifact(
136136
async def save_artifact(
137137
self,
138138
filename: str,
139-
artifact: types.Part,
139+
artifact: types.Part | dict[str, Any],
140140
custom_metadata: dict[str, Any] | None = None,
141141
) -> int:
142142
"""Saves an artifact and records it as delta for the current session.
143143
144144
Args:
145145
filename: The filename of the artifact.
146-
artifact: The artifact to save.
146+
artifact: The artifact to save. Can be a types.Part object or a
147+
dict-shaped (serialized) artifact.
147148
custom_metadata: Custom metadata to associate with the artifact.
148149
149150
Returns:

src/google/adk/artifacts/base_artifact_service.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,31 @@ class ArtifactVersion(BaseModel):
6363
class BaseArtifactService(ABC):
6464
"""Abstract base class for artifact services."""
6565

66+
@staticmethod
67+
def _convert_artifact_if_dict(
68+
artifact: types.Part | dict[str, Any],
69+
) -> types.Part:
70+
"""Converts a dict-shaped artifact to types.Part if necessary.
71+
72+
Args:
73+
artifact: The artifact to convert. Can be a types.Part or dict.
74+
75+
Returns:
76+
A types.Part object. If input is already a Part, returns as-is.
77+
If input is a dict, converts it to Part via model_validate.
78+
"""
79+
if isinstance(artifact, dict):
80+
return types.Part.model_validate(artifact)
81+
return artifact
82+
6683
@abstractmethod
6784
async def save_artifact(
6885
self,
6986
*,
7087
app_name: str,
7188
user_id: str,
7289
filename: str,
73-
artifact: types.Part,
90+
artifact: types.Part | dict[str, Any],
7491
session_id: Optional[str] = None,
7592
custom_metadata: Optional[dict[str, Any]] = None,
7693
) -> int:
@@ -84,10 +101,11 @@ async def save_artifact(
84101
app_name: The app name.
85102
user_id: The user ID.
86103
filename: The filename of the artifact.
87-
artifact: The artifact to save. If the artifact consists of `file_data`,
88-
the artifact service assumes its content has been uploaded separately,
89-
and this method will associate the `file_data` with the artifact if
90-
necessary.
104+
artifact: The artifact to save. Can be a types.Part object or a
105+
dict-shaped (serialized) artifact that will be converted to types.Part.
106+
If the artifact consists of `file_data`, the artifact service assumes
107+
its content has been uploaded separately, and this method will associate
108+
the `file_data` with the artifact if necessary.
91109
session_id: The session ID. If `None`, the artifact is user-scoped.
92110
custom_metadata: custom metadata to associate with the artifact.
93111

src/google/adk/artifacts/file_artifact_service.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ async def save_artifact(
314314
app_name: str,
315315
user_id: str,
316316
filename: str,
317-
artifact: types.Part,
317+
artifact: types.Part | dict[str, Any],
318318
session_id: Optional[str] = None,
319319
custom_metadata: Optional[dict[str, Any]] = None,
320320
) -> int:
@@ -326,6 +326,9 @@ async def save_artifact(
326326
computed scope root; absolute paths or inputs that traverse outside that
327327
root (for example ``"../../secret.txt"``) raise ``ValueError``.
328328
"""
329+
# Convert dict-shaped artifact to types.Part if necessary
330+
artifact = self._convert_artifact_if_dict(artifact)
331+
329332
return await asyncio.to_thread(
330333
self._save_artifact_sync,
331334
user_id,

src/google/adk/artifacts/gcs_artifact_service.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,13 @@ async def save_artifact(
6161
app_name: str,
6262
user_id: str,
6363
filename: str,
64-
artifact: types.Part,
64+
artifact: types.Part | dict[str, Any],
6565
session_id: Optional[str] = None,
6666
custom_metadata: Optional[dict[str, Any]] = None,
6767
) -> int:
68+
# Convert dict-shaped artifact to types.Part if necessary
69+
artifact = self._convert_artifact_if_dict(artifact)
70+
6871
return await asyncio.to_thread(
6972
self._save_artifact,
7073
app_name,

src/google/adk/artifacts/in_memory_artifact_service.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,13 @@ async def save_artifact(
9999
app_name: str,
100100
user_id: str,
101101
filename: str,
102-
artifact: types.Part,
102+
artifact: types.Part | dict[str, Any],
103103
session_id: Optional[str] = None,
104104
custom_metadata: Optional[dict[str, Any]] = None,
105105
) -> int:
106+
# Convert dict-shaped artifact to types.Part if necessary
107+
artifact = self._convert_artifact_if_dict(artifact)
108+
106109
path = self._artifact_path(app_name, user_id, filename, session_id)
107110
if path not in self.artifacts:
108111
self.artifacts[path] = []

src/google/adk/cli/adk_web_server.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,8 +232,8 @@ class SaveArtifactRequest(common.BaseModel):
232232
"""Request payload for saving a new artifact."""
233233

234234
filename: str = Field(description="Artifact filename.")
235-
artifact: types.Part = Field(
236-
description="Artifact payload encoded as google.genai.types.Part."
235+
artifact: types.Part | dict[str, Any] = Field(
236+
description="Artifact payload encoded as google.genai.types.Part or as a dict-shaped artifact."
237237
)
238238
custom_metadata: Optional[dict[str, Any]] = Field(
239239
default=None,

src/google/adk/tools/_forwarding_artifact_service.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,12 @@ async def save_artifact(
4242
app_name: str,
4343
user_id: str,
4444
filename: str,
45-
artifact: types.Part,
45+
artifact: types.Part | dict[str, Any],
4646
session_id: Optional[str] = None,
4747
custom_metadata: Optional[dict[str, Any]] = None,
4848
) -> int:
49+
# Delegate to parent tool context, which will handle conversion in the
50+
# concrete artifact service implementation.
4951
return await self.tool_context.save_artifact(
5052
filename=filename,
5153
artifact=artifact,

tests/unittests/artifacts/test_artifact_service.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,3 +766,102 @@ async def test_file_save_artifact_rejects_absolute_path_within_scope(tmp_path):
766766
filename=str(absolute_in_scope),
767767
artifact=part,
768768
)
769+
770+
@pytest.mark.asyncio
771+
@pytest.mark.parametrize(
772+
"service_type",
773+
[
774+
ArtifactServiceType.IN_MEMORY,
775+
ArtifactServiceType.GCS,
776+
ArtifactServiceType.FILE,
777+
],
778+
)
779+
async def test_save_load_dict_shaped_artifact(
780+
service_type, artifact_service_factory
781+
):
782+
"""Tests saving and loading dict-shaped artifacts.
783+
784+
This tests the fix for accepting dict-shaped (serialized) artifacts
785+
in the save_artifact method. Dict-shaped artifacts are commonly used
786+
when artifacts are stored/retrieved from JSON or other serialization formats.
787+
"""
788+
artifact_service = artifact_service_factory(service_type)
789+
# Create a dict-shaped artifact by serializing a real Part instance
790+
part = types.Part.from_bytes(data=b"test_data", mime_type="text/plain")
791+
dict_artifact = part.model_dump(exclude_none=True)
792+
793+
app_name = "app0"
794+
user_id = "user0"
795+
session_id = "123"
796+
filename = "dict_file.txt"
797+
798+
# Save the dict-shaped artifact
799+
version = await artifact_service.save_artifact(
800+
app_name=app_name,
801+
user_id=user_id,
802+
session_id=session_id,
803+
filename=filename,
804+
artifact=dict_artifact,
805+
)
806+
assert version == 0
807+
808+
# Load and verify the artifact
809+
loaded = await artifact_service.load_artifact(
810+
app_name=app_name,
811+
user_id=user_id,
812+
session_id=session_id,
813+
filename=filename,
814+
)
815+
assert loaded is not None
816+
assert loaded.inline_data is not None
817+
assert loaded.inline_data.mime_type == "text/plain"
818+
819+
820+
@pytest.mark.asyncio
821+
@pytest.mark.parametrize(
822+
"service_type",
823+
[
824+
ArtifactServiceType.IN_MEMORY,
825+
ArtifactServiceType.GCS,
826+
ArtifactServiceType.FILE,
827+
],
828+
)
829+
async def test_save_text_dict_shaped_artifact(
830+
service_type, artifact_service_factory
831+
):
832+
"""Tests saving and loading dict-shaped artifacts with text content."""
833+
artifact_service = artifact_service_factory(service_type)
834+
# Create a dict-shaped artifact by serializing a real Part instance
835+
part = types.Part(text="Hello, World!")
836+
dict_artifact = part.model_dump(exclude_none=True)
837+
838+
app_name = "app0"
839+
user_id = "user0"
840+
session_id = "123"
841+
filename = "text_file.txt"
842+
843+
# Save the dict-shaped artifact
844+
await artifact_service.save_artifact(
845+
app_name=app_name,
846+
user_id=user_id,
847+
session_id=session_id,
848+
filename=filename,
849+
artifact=dict_artifact,
850+
)
851+
852+
# Load and verify the artifact
853+
loaded = await artifact_service.load_artifact(
854+
app_name=app_name,
855+
user_id=user_id,
856+
session_id=session_id,
857+
filename=filename,
858+
)
859+
assert loaded is not None
860+
# GCS/File services may return text as inline_data bytes; accept either form.
861+
if loaded.text is not None:
862+
assert loaded.text == "Hello, World!"
863+
else:
864+
assert (
865+
loaded.inline_data is not None
866+
and loaded.inline_data.data == b"Hello, World!"
867+
)

0 commit comments

Comments
 (0)