From cd9432bcc15e865a0088e1e49b95a56a1331aa15 Mon Sep 17 00:00:00 2001 From: Ryker_Feng <90562015+18062706139fcz@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:12:43 +0800 Subject: [PATCH] feat(tools): support GIF images in view_image (#4438) Add GIF to the view_image allowlist: map the .gif extension to image/gif and detect the GIF87a/GIF89a magic bytes so the existing extension/content cross-check accepts GIFs instead of rejecting them as an unsupported format. Covered by a new success test. --- .../tools/builtins/view_image_tool.py | 5 ++++- backend/tests/test_view_image_tool.py | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/backend/packages/harness/deerflow/tools/builtins/view_image_tool.py b/backend/packages/harness/deerflow/tools/builtins/view_image_tool.py index 10e22f4f7..33d208fb6 100644 --- a/backend/packages/harness/deerflow/tools/builtins/view_image_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/view_image_tool.py @@ -22,6 +22,7 @@ _EXTENSION_TO_MIME = { ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", + ".gif": "image/gif", } @@ -36,6 +37,8 @@ def _detect_image_mime(image_data: bytes) -> str | None: return "image/png" if len(image_data) >= 12 and image_data.startswith(b"RIFF") and image_data[8:12] == b"WEBP": return "image/webp" + if image_data.startswith((b"GIF87a", b"GIF89a")): + return "image/gif" return None @@ -63,7 +66,7 @@ def view_image_tool( - For multiple files at once (use present_files instead) Args: - image_path: Absolute /mnt/user-data virtual path to the image file. Common formats supported: jpg, jpeg, png, webp. + image_path: Absolute /mnt/user-data virtual path to the image file. Common formats supported: jpg, jpeg, png, webp, gif. """ from deerflow.sandbox.exceptions import SandboxRuntimeError from deerflow.sandbox.tools import ( diff --git a/backend/tests/test_view_image_tool.py b/backend/tests/test_view_image_tool.py index a136b58d8..5cccafbea 100644 --- a/backend/tests/test_view_image_tool.py +++ b/backend/tests/test_view_image_tool.py @@ -12,6 +12,9 @@ view_image_module = importlib.import_module("deerflow.tools.builtins.view_image_ PNG_BYTES = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==") +# Minimal 1x1 transparent GIF (starts with the "GIF89a" magic bytes). +GIF_BYTES = base64.b64decode("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7") + def _make_thread_data(tmp_path: Path) -> dict[str, str]: user_data = tmp_path / "threads" / "thread-1" / "user-data" @@ -73,6 +76,23 @@ def test_view_image_reads_virtual_uploads_path(tmp_path: Path) -> None: assert viewed_image["actual_path"] == str(image_path) +def test_view_image_reads_gif(tmp_path: Path) -> None: + thread_data = _make_thread_data(tmp_path) + image_path = Path(thread_data["uploads_path"]) / "animation.gif" + image_path.write_bytes(GIF_BYTES) + + result = view_image_tool.func( + runtime=_make_runtime(thread_data), + image_path="/mnt/user-data/uploads/animation.gif", + tool_call_id="tc-gif", + ) + + assert _message_content(result) == "Successfully read image" + viewed_image = result.update["viewed_images"]["/mnt/user-data/uploads/animation.gif"] + assert viewed_image["mime_type"] == "image/gif" + assert viewed_image["size"] == len(GIF_BYTES) + + def test_view_image_rejects_spoofed_extension(tmp_path: Path) -> None: thread_data = _make_thread_data(tmp_path) image_path = Path(thread_data["uploads_path"]) / "not-really.png"