Files
hermes-agent/tests/tools/test_delegate_images.py
teknium1 230ca004a7 fix(delegate): forward inline data-URL images to vision children; trim tests to invariants
data:image/... entries were treated as local paths and silently skipped as
"unreadable". They now ride as image_url parts only (never pasted into the
text hint, never appended to a text-mode goal). Skips and forwarding
failures log at warning since the caller explicitly asked for the images;
decide_image_input_mode gets the child's requested_provider like the CLI
and gateway callers.

Tests collapse to five invariants, including one that drives _ChildRun
and asserts the multimodal content list reaches run_conversation as the
first user turn. Docs mention data: URLs and the read guard.
2026-09-13 21:05:42 -07:00

99 lines
4.7 KiB
Python

"""Per-task image forwarding on delegate_task: a task's ``images`` (local paths, http(s) or data: URLs) reach a
vision-capable child as native ``image_url`` content parts on its goal turn; non-vision children get
``[Image attached …]`` hints for ``vision_analyze``; any failure degrades to the text-only goal."""
import base64
from unittest.mock import patch
from tools.delegate_tool import DELEGATE_TASK_SCHEMA, _MAX_TASK_IMAGES, _build_child_goal_message, _normalize_task_images
from tools.delegate_tool_child_run import _ChildRun
_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg=="
)
class _FakeChild:
provider = "openrouter"
model = "some/vision-model"
def _png(tmp_path, name="shot.png"):
p = tmp_path / name
p.write_bytes(_PNG)
return str(p)
def test_normalize_task_images_shapes_and_limit():
assert _normalize_task_images({"goal": "g"}, 0) == (None, None)
assert _normalize_task_images({"images": []}, 0) == (None, None)
assert _normalize_task_images({"images": "/tmp/a.png"}, 0) == (["/tmp/a.png"], None)
assert _normalize_task_images({"images": [" /tmp/a.png ", "https://x.test/b.jpg"]}, 0) == (
["/tmp/a.png", "https://x.test/b.jpg"], None,
)
for bad in ({"path": "x"}, ["/tmp/a.png", 42], [" "]):
cleaned, err = _normalize_task_images({"images": bad}, 2)
assert cleaned is None and "Task 2" in err
cleaned, err = _normalize_task_images({"images": [f"/tmp/{i}.png" for i in range(_MAX_TASK_IMAGES + 1)]}, 1)
assert cleaned is None and str(_MAX_TASK_IMAGES) in err
def test_native_mode_builds_image_parts_for_every_source_kind(tmp_path):
path = _png(tmp_path)
url = "https://example.test/mock.png"
data_url = "data:image/png;base64," + base64.b64encode(_PNG).decode()
with patch("agent.image_routing.decide_image_input_mode", return_value="native"):
msg = _build_child_goal_message("Inspect the mock", [path, url, data_url], _FakeChild())
assert msg[0]["type"] == "text" and "Inspect the mock" in msg[0]["text"]
images = [p["image_url"]["url"] for p in msg if p.get("type") == "image_url"]
assert len(images) == 3 and url in images
assert images.count(data_url) == 2 # local file embedded as a data URL + the inline data URL passed verbatim
assert data_url not in msg[0]["text"] # inline base64 never leaks into the text part
# every source unreadable → plain goal, no empty multimodal envelope
with patch("agent.image_routing.decide_image_input_mode", return_value="native"):
assert _build_child_goal_message("Goal text", [str(tmp_path / "nope.png")], _FakeChild()) == "Goal text"
def test_text_mode_hints_and_failure_degrade(tmp_path):
path = _png(tmp_path)
url = "https://example.test/a.png"
with patch("agent.image_routing.decide_image_input_mode", return_value="text"):
msg = _build_child_goal_message("Goal", [path, url, str(tmp_path / "gone.png")], _FakeChild())
assert isinstance(msg, str)
assert f"[Image attached at: {path}]" in msg and f"[Image attached: {url}]" in msg and "vision_analyze" in msg
assert "gone.png" not in msg
with patch("agent.image_routing.decide_image_input_mode", side_effect=RuntimeError("boom")):
assert _build_child_goal_message("Plain goal", ["/tmp/x.png"], _FakeChild()) == "Plain goal"
def test_child_run_sends_multimodal_goal_turn(tmp_path):
"""The attached images reach ``run_conversation`` as the FIRST user turn's content list."""
seen = {}
class _Child(_FakeChild):
_delegate_images = [_png(tmp_path)]
def run_conversation(self, user_message, **kw):
seen["user_message"] = user_message
return {"final_response": "ok", "completed": True}
class _Parent:
_current_task_id = None
run = _ChildRun(_Child(), _Parent(), 0, "Describe it", None, None)
with patch("tools.delegate_tool_child_run._create_isolated_worktree", return_value=None), \
patch("agent.image_routing.decide_image_input_mode", return_value="native"):
run.seed_workspace()
result, failure, _ = run.await_child()
assert failure is None and result["final_response"] == "ok"
content = seen["user_message"]
assert isinstance(content, list) and content[0]["type"] == "text"
assert [p["type"] for p in content].count("image_url") == 1
assert content[1]["image_url"]["url"].startswith("data:image/png;base64,")
def test_images_advertised_per_task_only():
item = DELEGATE_TASK_SCHEMA["parameters"]["properties"]["tasks"]["items"]
assert item["properties"]["images"]["type"] == "array" and "images" not in item["required"]
assert "images" not in DELEGATE_TASK_SCHEMA["parameters"]["properties"]