Skip to content

Commit f9be94c

Browse files
1wosGWeale
authored andcommitted
fix(tools): convert image/svg+xml to text in LoadArtifactsTool
Merge #5694 **Please ensure you have read the [contribution guide](https://github.com/google/adk-python/blob/main/CONTRIBUTING.md) before creating a pull request.** ### Link to Issue or Description of Change **1. Link to an existing issue (if applicable):** - Closes: #5693 **2. Or, if no issue exists, describe the change:** **Problem:** `_is_inline_mime_type_supported` in `LoadArtifactsTool` returns `True` for `image/svg+xml` via the `image/` prefix match at `src/google/adk/tools/load_artifacts_tool.py:32-36`, so SVG artifacts get forwarded to Gemini as inline image data. Gemini rejects every SVG MIME variant with `400 INVALID_ARGUMENT - Unsupported MIME type: image/svg+xml`, instead of being text-converted like CSV / JSON / XML are since #4028. Empirically verified against `gemini-2.5-flash` via `google-genai==1.75.0` that `image/svg+xml`, `image/svg`, `application/svg+xml` and `image/xml` are all rejected by Gemini, while `image/png/jpeg/webp/avif/gif`, `audio/*`, `video/*` and `application/pdf` are accepted. So SVG is the only sub-case under a "supported prefix" that needs special handling today. **Solution:** Add an explicit denylist for subtypes that match a supported prefix but that Gemini rejects, and route SVG through the existing text-fallback path. Same shape as the #4028 fix. `src/google/adk/tools/load_artifacts_tool.py`: - Add `_GEMINI_UNSUPPORTED_INLINE_SUBTYPES = frozenset({'image/svg+xml'})` and short-circuit `_is_inline_mime_type_supported` on a hit, so SVG falls through to `_as_safe_part_for_llm`'s text-decoding branch instead of being forwarded inline. - Add `'image/svg+xml'` to `_TEXT_LIKE_MIME_TYPES` so the fallback path utf-8 decodes the SVG markup into a text `Part`, instead of returning the binary placeholder (`[Binary artifact: ...]`). SVG is XML so this is a reasonable representation for the model. ```diff +_GEMINI_UNSUPPORTED_INLINE_SUBTYPES = frozenset({ + 'image/svg+xml', +}) _TEXT_LIKE_MIME_TYPES = frozenset({ 'application/csv', 'application/json', 'application/xml', + 'image/svg+xml', }) def _is_inline_mime_type_supported(mime_type): normalized = _normalize_mime_type(mime_type) if not normalized: return False + if normalized in _GEMINI_UNSUPPORTED_INLINE_SUBTYPES: + return False return normalized.startswith(_GEMINI_SUPPORTED_INLINE_MIME_PREFIXES) or ( normalized in _GEMINI_SUPPORTED_INLINE_MIME_TYPES ) ``` ### Testing Plan **Unit Tests:** - [x] I have added or updated unit tests for my change. - [x] All unit tests pass locally. Added `test_load_artifacts_converts_svg_to_text` in `tests/unittests/tools/test_load_artifacts_tool.py`, mirroring the existing `test_load_artifacts_converts_unsupported_mime_to_text` (CSV) shape. It asserts that after `load_artifacts_tool.process_llm_request` runs: - `artifact_part.inline_data is None` (SVG is no longer forwarded as inline image data) - `artifact_part.text == svg_bytes.decode('utf-8')` (the SVG markup is delivered as a text Part) ``` $ pytest tests/unittests/tools/test_load_artifacts_tool.py -v ============================= test session starts ============================== platform darwin -- Python 3.13.5, pytest-8.4.2 collected 8 items tests/unittests/tools/test_load_artifacts_tool.py::test_load_artifacts_converts_unsupported_mime_to_text PASSED tests/unittests/tools/test_load_artifacts_tool.py::test_load_artifacts_converts_base64_unsupported_mime_to_text PASSED tests/unittests/tools/test_load_artifacts_tool.py::test_load_artifacts_keeps_supported_mime_types PASSED tests/unittests/tools/test_load_artifacts_tool.py::test_load_artifacts_converts_svg_to_text PASSED tests/unittests/tools/test_load_artifacts_tool.py::test_maybe_base64_to_bytes_decodes_standard_base64 PASSED tests/unittests/tools/test_load_artifacts_tool.py::test_maybe_base64_to_bytes_decodes_urlsafe_base64 PASSED tests/unittests/tools/test_load_artifacts_tool.py::test_maybe_base64_to_bytes_returns_none_for_invalid PASSED tests/unittests/tools/test_load_artifacts_tool.py::test_get_declaration_with_json_schema_feature_enabled PASSED ========================= 8 passed, 1 warning in 3.61s ========================= ``` The 1 warning is from a pre-existing test that exercises a `[WIP]` feature flag (`JSON_SCHEMA_FOR_FUNC_DECL`) and is unrelated to this change. **Manual End-to-End (E2E) Tests:** Reproduced the underlying Gemini API behaviour outside ADK with the `google-genai` SDK that ADK depends on: ```python # google-genai==1.75.0, gemini-2.5-flash from google import genai from google.genai import types client = genai.Client(api_key="...") with open("logo.svg", "rb") as f: part = types.Part.from_bytes(data=f.read(), mime_type="image/svg+xml") client.models.generate_content( model="gemini-2.5-flash", contents=["Describe this.", part], ) ``` Before this PR, the same code path inside ADK (`LoadArtifactsTool` → `_as_safe_part_for_llm` → inline forward) ends in: ``` google.genai.errors.ClientError: 400 INVALID_ARGUMENT. {'error': {'code': 400, 'message': 'Unsupported MIME type: image/svg+xml', 'status': 'INVALID_ARGUMENT'}} ``` After this PR, the SVG artifact is delivered to the model as a text `Part` containing the SVG markup, and the agent run completes successfully. Control formats (`image/png/jpeg/webp/avif/gif`, `audio/mpeg/mp3`, `video/mp4/webm`, `application/pdf`) are unchanged: still forwarded as inline data and accepted by Gemini. ### Checklist - [x] I have read the [CONTRIBUTING.md](https://github.com/google/adk-python/blob/main/CONTRIBUTING.md) document. - [x] I have performed a self-review of my own code. - [x] I have commented my code, particularly in hard-to-understand areas. - [x] I have added tests that prove my fix is effective or that my feature works. - [x] New and existing unit tests pass locally with my changes. - [x] I have manually tested my changes end-to-end. - [x] Any dependent changes have been merged and published in downstream modules. ### Additional context Local pre-commit (`isort`, `pyink`, `addlicense`, `end-of-file-fixer`, `trailing-whitespace`) all pass on the two edited files. Related issue history: - #4028 closed by `fdc98d5c`. Same shape of bug for `application/csv`. This PR is the missed sub-case under the `image/` prefix. Co-authored-by: George Weale <[email protected]> COPYBARA_INTEGRATE_REVIEW=#5694 from 1wos:fix/image-svg-xml-unsupported 7b0be51 PiperOrigin-RevId: 933282374
1 parent ea65345 commit f9be94c

2 files changed

Lines changed: 67 additions & 0 deletions

File tree

src/google/adk/tools/load_artifacts_tool.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,28 @@
3535
'video/',
3636
)
3737
_GEMINI_SUPPORTED_INLINE_MIME_TYPES = frozenset({'application/pdf'})
38+
# MIME subtypes that match a supported prefix above but that Gemini
39+
# rejects with 400 INVALID_ARGUMENT when sent as inline data. These
40+
# must fall through to the text-conversion path in
41+
# `_as_safe_part_for_llm` instead of being forwarded as inline image
42+
# data. Verified empirically against gemini-2.5-flash via
43+
# google-genai 1.69.0 on 2026-05-13.
44+
_GEMINI_UNSUPPORTED_INLINE_SUBTYPES = frozenset({
45+
'image/svg',
46+
'image/svg+xml',
47+
'image/xml',
48+
})
3849
_TEXT_LIKE_MIME_TYPES = frozenset({
3950
'application/csv',
4051
'application/json',
52+
'application/svg+xml',
4153
'application/xml',
54+
# SVG/XML image variants are XML-based and Gemini rejects them as
55+
# inline image data (see _GEMINI_UNSUPPORTED_INLINE_SUBTYPES above), so
56+
# they fall through here and are delivered to the model as text.
57+
'image/svg',
58+
'image/svg+xml',
59+
'image/xml',
4260
})
4361

4462
if TYPE_CHECKING:
@@ -60,6 +78,8 @@ def _is_inline_mime_type_supported(mime_type: str | None) -> bool:
6078
normalized = _normalize_mime_type(mime_type)
6179
if not normalized:
6280
return False
81+
if normalized in _GEMINI_UNSUPPORTED_INLINE_SUBTYPES:
82+
return False
6383
return normalized.startswith(_GEMINI_SUPPORTED_INLINE_MIME_PREFIXES) or (
6484
normalized in _GEMINI_SUPPORTED_INLINE_MIME_TYPES
6585
)

tests/unittests/tools/test_load_artifacts_tool.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,53 @@ async def test_load_artifacts_keeps_supported_mime_types():
144144
assert artifact_part.inline_data.mime_type == 'application/pdf'
145145

146146

147+
@mark.asyncio
148+
@mark.parametrize(
149+
'mime_type',
150+
['image/svg+xml', 'image/svg', 'application/svg+xml', 'image/xml'],
151+
)
152+
async def test_load_artifacts_converts_svg_to_text(mime_type):
153+
"""SVG/XML image variants are rejected by Gemini with 400 INVALID_ARGUMENT,
154+
so they must fall through to the text-conversion path instead of being
155+
forwarded as inline image data.
156+
"""
157+
artifact_name = 'logo.svg'
158+
svg_bytes = (
159+
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">'
160+
b'<circle cx="5" cy="5" r="4"/></svg>'
161+
)
162+
artifact = types.Part(
163+
inline_data=types.Blob(data=svg_bytes, mime_type=mime_type)
164+
)
165+
166+
tool_context = _StubToolContext({artifact_name: artifact})
167+
llm_request = LlmRequest(
168+
contents=[
169+
types.Content(
170+
role='user',
171+
parts=[
172+
types.Part(
173+
function_response=types.FunctionResponse(
174+
name='load_artifacts',
175+
response={'artifact_names': [artifact_name]},
176+
)
177+
)
178+
],
179+
)
180+
]
181+
)
182+
183+
await load_artifacts_tool.process_llm_request(
184+
tool_context=tool_context, llm_request=llm_request
185+
)
186+
187+
artifact_part = llm_request.contents[-1].parts[1]
188+
# The SVG must NOT be forwarded as inline image data — Gemini would 400.
189+
assert artifact_part.inline_data is None
190+
# And the original SVG markup is delivered as a text part instead.
191+
assert artifact_part.text == svg_bytes.decode('utf-8')
192+
193+
147194
def test_maybe_base64_to_bytes_decodes_standard_base64():
148195
"""Standard base64 encoded strings are decoded correctly."""
149196
original = b'hello world'

0 commit comments

Comments
 (0)