mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
fix(vision): convert SVG/unsupported image formats to PNG before embedding
vision_analyze embedded SVG (and BMP/TIFF) tool-results into conversation history with media_type image/svg+xml. Anthropic only accepts jpeg/png/ gif/webp, so the request fails with a non-retryable 400. Because the image is baked into immutable history and re-sent every turn, the session is permanently wedged on resume — retries re-send the same bad bytes. Add _normalize_to_supported_image(): SVG is rasterized to PNG (best-effort via cairosvg/svglib/rsvg-convert/inkscape), other non-supported raster formats are re-encoded to PNG via Pillow, and if conversion is impossible the tool returns an actionable error instead of a session-wedging payload. Wired into both the native-vision fast path and the auxiliary-API path so the whole bug class is covered, not just the one call site. All 99 existing vision tests pass.
This commit is contained in:
parent
ab2f5a077e
commit
ac94f2c8a1
3 changed files with 168 additions and 14 deletions
|
|
@ -91,15 +91,17 @@ class TestLocalBackend:
|
|||
"ftp://example.com/pic.png", isrc.ResolveContext())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_svg_rejected_with_conversion_hint(self, tmp_path, monkeypatch):
|
||||
"""SVG has no raster magic bytes; the resolver rejects it with a
|
||||
dedicated, actionable message (review note W3)."""
|
||||
async def test_svg_passes_through_for_rasterization(self, tmp_path, monkeypatch):
|
||||
"""SVG has no raster magic bytes but is passed through with mime
|
||||
image/svg+xml so the vision call sites can rasterize it to PNG."""
|
||||
isrc = _reload(monkeypatch, tmp_path / "hermes")
|
||||
monkeypatch.setenv("TERMINAL_ENV", "local")
|
||||
svg = tmp_path / "art.svg"
|
||||
svg.write_bytes(b'<svg xmlns="http://www.w3.org/2000/svg"></svg>')
|
||||
with pytest.raises(isrc.NotAnImage, match="SVG is not supported"):
|
||||
await isrc.resolve_image_source(str(svg), isrc.ResolveContext())
|
||||
svg_bytes = b'<svg xmlns="http://www.w3.org/2000/svg"></svg>'
|
||||
svg.write_bytes(svg_bytes)
|
||||
res = await isrc.resolve_image_source(str(svg), isrc.ResolveContext())
|
||||
assert res.mime == "image/svg+xml"
|
||||
assert res.data == svg_bytes
|
||||
|
||||
|
||||
class TestNonLocalBackendConfinement:
|
||||
|
|
|
|||
|
|
@ -304,11 +304,9 @@ def _finalize(data: bytes, declared_mime: str, origin: str, src: str) -> Resolve
|
|||
sniffed = _detect_image_mime_type_from_bytes(data)
|
||||
if sniffed is None:
|
||||
if b"<svg" in data[:4096].lower():
|
||||
raise NotAnImage(
|
||||
"SVG is not supported for vision analysis (providers only "
|
||||
"ingest raster images). Convert it to PNG first, e.g. "
|
||||
"`rsvg-convert` or ImageMagick's `convert`.",
|
||||
src=src, origin=origin,
|
||||
)
|
||||
# Pass SVG through — the vision call sites rasterize it to PNG
|
||||
# via _normalize_to_supported_image before embedding (providers
|
||||
# only ingest raster images).
|
||||
return ResolvedImage(data=data, mime="image/svg+xml", origin=origin)
|
||||
raise NotAnImage("source is not a recognized image", src=src, origin=origin)
|
||||
return ResolvedImage(data=data, mime=sniffed, origin=origin)
|
||||
|
|
|
|||
|
|
@ -225,8 +225,8 @@ def _detect_image_mime_type_from_bytes(data: bytes) -> Optional[str]:
|
|||
"""Magic-byte MIME sniff on raw bytes (authoritative; no extension trust).
|
||||
|
||||
Returns ``None`` for anything without a recognized image header — including
|
||||
SVG, which has no magic bytes and cannot be ingested as raster vision input
|
||||
by any provider. The resolver surfaces a dedicated error for SVG sources.
|
||||
SVG, which has no magic bytes. The resolver special-cases SVG (sniffs
|
||||
``<svg``) and passes it through for rasterization at the call sites.
|
||||
"""
|
||||
header = data[:64]
|
||||
if header.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
|
|
@ -242,6 +242,120 @@ def _detect_image_mime_type_from_bytes(data: bytes) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
# Media types the major vision providers (Anthropic in particular) accept for
|
||||
# inline base64 images. Anything outside this set — SVG, BMP, TIFF, etc. — is
|
||||
# rejected with a non-retryable 400. Because a vision tool-result is baked into
|
||||
# immutable conversation history and re-sent every turn, embedding an
|
||||
# unsupported media_type permanently wedges the session (retries re-send the
|
||||
# same bad bytes). We MUST normalize to one of these before embedding.
|
||||
_ANTHROPIC_SUPPORTED_MEDIA_TYPES = frozenset(
|
||||
{"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||
)
|
||||
|
||||
|
||||
def _rasterize_svg_to_png(svg_path: Path, out_path: Path) -> bool:
|
||||
"""Best-effort SVG → PNG rasterization. Returns True on success.
|
||||
|
||||
Tries, in order: cairosvg, svglib+reportlab, then system rasterizers
|
||||
(rsvg-convert, inkscape). All are soft dependencies; if none is available
|
||||
we return False and the caller rejects the image with an actionable error
|
||||
rather than embedding an unsupported media_type that would wedge the
|
||||
session.
|
||||
"""
|
||||
# 1) cairosvg (pure-python-ish, most common)
|
||||
try:
|
||||
import cairosvg # type: ignore
|
||||
cairosvg.svg2png(url=str(svg_path), write_to=str(out_path))
|
||||
return out_path.exists() and out_path.stat().st_size > 0
|
||||
except Exception:
|
||||
pass
|
||||
# 2) svglib + reportlab
|
||||
try:
|
||||
from svglib.svglib import svg2rlg # type: ignore
|
||||
from reportlab.graphics import renderPM # type: ignore
|
||||
drawing = svg2rlg(str(svg_path))
|
||||
if drawing is not None:
|
||||
renderPM.drawToFile(drawing, str(out_path), fmt="PNG")
|
||||
return out_path.exists() and out_path.stat().st_size > 0
|
||||
except Exception:
|
||||
pass
|
||||
# 3) system rasterizers
|
||||
import shutil as _shutil
|
||||
import subprocess as _subprocess
|
||||
for cmd in (
|
||||
["rsvg-convert", "-o", str(out_path), str(svg_path)],
|
||||
["inkscape", str(svg_path), "--export-type=png",
|
||||
f"--export-filename={out_path}"],
|
||||
):
|
||||
if _shutil.which(cmd[0]):
|
||||
try:
|
||||
_subprocess.run(cmd, check=True, capture_output=True, timeout=30)
|
||||
if out_path.exists() and out_path.stat().st_size > 0:
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_to_supported_image(
|
||||
image_path: Path, detected_mime: str
|
||||
) -> tuple[Optional[Path], Optional[str], Optional[str]]:
|
||||
"""Ensure an image is in a vision-provider-supported format.
|
||||
|
||||
Returns a 3-tuple ``(path, mime, error)``:
|
||||
- If ``detected_mime`` is already supported: ``(image_path, detected_mime, None)``.
|
||||
- If conversion succeeds: ``(new_png_path, "image/png", None)`` — the new
|
||||
path is a temp file the CALLER must clean up.
|
||||
- If conversion is impossible: ``(None, None, <error message>)``.
|
||||
|
||||
SVG is rasterized to PNG (best-effort, soft deps). Other raster formats
|
||||
Pillow can read (BMP, TIFF, etc.) are re-encoded to PNG. This runs BEFORE
|
||||
the image is base64-embedded into conversation history, so an unsupported
|
||||
media_type can never reach the provider and wedge the session.
|
||||
"""
|
||||
if detected_mime in _ANTHROPIC_SUPPORTED_MEDIA_TYPES:
|
||||
return image_path, detected_mime, None
|
||||
|
||||
out_path = (
|
||||
get_hermes_dir("cache/vision", "temp_vision_images")
|
||||
/ f"converted_{uuid.uuid4()}.png"
|
||||
)
|
||||
|
||||
# SVG: needs a rasterizer (Pillow cannot render SVG).
|
||||
if detected_mime == "image/svg+xml":
|
||||
if _rasterize_svg_to_png(image_path, out_path):
|
||||
return out_path, "image/png", None
|
||||
return (
|
||||
None,
|
||||
None,
|
||||
"This is an SVG, which vision models cannot read directly, and no "
|
||||
"SVG rasterizer is installed (tried cairosvg, svglib, rsvg-convert, "
|
||||
"inkscape). Convert the SVG to PNG first — e.g. open it in a browser "
|
||||
"and screenshot it, or install a rasterizer "
|
||||
"(`pip install cairosvg`) — then re-run vision_analyze on the PNG.",
|
||||
)
|
||||
|
||||
# Other non-supported raster formats (BMP, TIFF, ...): re-encode via Pillow.
|
||||
try:
|
||||
from PIL import Image as _PILImage
|
||||
with _PILImage.open(image_path) as _img:
|
||||
if _img.mode not in ("RGB", "RGBA", "L"):
|
||||
_img = _img.convert("RGBA")
|
||||
_img.save(out_path, format="PNG")
|
||||
if out_path.exists() and out_path.stat().st_size > 0:
|
||||
return out_path, "image/png", None
|
||||
except Exception as _exc:
|
||||
logger.warning("Failed to normalize %s image to PNG: %s",
|
||||
detected_mime, _exc)
|
||||
return (
|
||||
None,
|
||||
None,
|
||||
f"Image format {detected_mime!r} is not supported by the vision API "
|
||||
f"and could not be converted to PNG (install Pillow for raster "
|
||||
f"conversion). Convert it to PNG or JPEG and try again.",
|
||||
)
|
||||
|
||||
|
||||
def _is_retryable_download_error(error: Exception) -> bool:
|
||||
"""Return True only for transient image-download failures worth retrying.
|
||||
|
||||
|
|
@ -863,6 +977,29 @@ async def _vision_analyze_native(
|
|||
await asyncio.to_thread(temp_image_path.write_bytes, resolved.data)
|
||||
should_cleanup = True
|
||||
|
||||
# Normalize unsupported formats (SVG, BMP, ...) to PNG BEFORE embedding.
|
||||
# Anthropic only accepts jpeg/png/gif/webp; an unsupported media_type
|
||||
# baked into immutable history wedges the session with a 400 on every
|
||||
# resume. Convert here so it can never enter history. Offloaded — the
|
||||
# rasterizers/Pillow are blocking.
|
||||
normalized_path, detected_mime_type, _norm_err = await asyncio.to_thread(
|
||||
_normalize_to_supported_image, temp_image_path, detected_mime_type,
|
||||
)
|
||||
if _norm_err or normalized_path is None:
|
||||
return tool_error(
|
||||
_norm_err or "Image normalization failed.", success=False,
|
||||
)
|
||||
if normalized_path != temp_image_path:
|
||||
# We created a temp PNG — swap to it and ensure it's cleaned up.
|
||||
if should_cleanup and temp_image_path.exists():
|
||||
try:
|
||||
temp_image_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
temp_image_path = normalized_path
|
||||
should_cleanup = True
|
||||
image_size_bytes = temp_image_path.stat().st_size
|
||||
|
||||
image_data_url = await _run_encode_on_cpu_executor(
|
||||
_image_to_base64_data_url,
|
||||
temp_image_path, mime_type=detected_mime_type,
|
||||
|
|
@ -1014,6 +1151,23 @@ async def vision_analyze_tool(
|
|||
image_size_bytes = len(resolved.data)
|
||||
image_size_kb = image_size_bytes / 1024
|
||||
logger.info("Image ready (%.1f KB)", image_size_kb)
|
||||
# Normalize unsupported formats (SVG, BMP, ...) to PNG. Vision providers
|
||||
# reject these media types; convert before encoding. Offloaded — the
|
||||
# rasterizers/Pillow are blocking.
|
||||
normalized_path, detected_mime_type, _norm_err = await asyncio.to_thread(
|
||||
_normalize_to_supported_image, temp_image_path, detected_mime_type,
|
||||
)
|
||||
if _norm_err or normalized_path is None:
|
||||
raise ValueError(_norm_err or "Image normalization failed.")
|
||||
if normalized_path != temp_image_path:
|
||||
if should_cleanup and temp_image_path.exists():
|
||||
try:
|
||||
temp_image_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
temp_image_path = normalized_path
|
||||
should_cleanup = True
|
||||
|
||||
# Convert image to base64 — send at full resolution first.
|
||||
# If the provider rejects it as too large, we auto-resize and retry.
|
||||
# Offloaded to the bounded vision CPU executor so a fan-out of encodes
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue