Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions acp_adapter/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"delegate_task": "execute",
"vision_analyze": "read",
"image_generate": "execute",
"image_edit": "execute",
"text_to_speech": "execute",
# Thinking / meta
"_thinking": "think",
Expand All @@ -65,7 +66,7 @@
"skill_view", "skills_list", "skill_manage", "web_search", "web_extract",
"browser_navigate", "browser_click", "browser_type", "browser_press", "browser_scroll",
"browser_back", "browser_snapshot", "browser_console", "browser_get_images", "browser_vision",
"vision_analyze", "image_generate", "text_to_speech",
"vision_analyze", "image_generate", "image_edit", "text_to_speech",
# Schedulers / platform integrations
"cronjob", "send_message", "clarify", "discord", "discord_admin",
"ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service",
Expand Down Expand Up @@ -173,6 +174,11 @@ def build_tool_title(tool_name: str, args: Dict[str, Any]) -> str:
if tool_name == "image_generate":
prompt = str(args.get("prompt") or args.get("description") or "").strip()
return f"generate image: {prompt[:50]}" if prompt else "generate image"
if tool_name == "image_edit":
prompt = str(args.get("prompt") or "").strip()
image = str(args.get("image") or args.get("image_path") or args.get("image_url") or "").strip()
target = prompt or image
return f"edit image: {target[:50]}" if target else "edit image"
if tool_name == "cronjob":
action = str(args.get("action") or "manage").strip() or "manage"
job_id = str(args.get("job_id") or args.get("id") or "").strip()
Expand Down Expand Up @@ -720,7 +726,7 @@ def _format_media_or_cron_result(tool_name: str, result: Optional[str]) -> Optio
if data.get("success") is False or data.get("error"):
return f"{tool_name} failed: {data.get('error', 'unknown error')}"
lines = [f"✅ {tool_name} completed"]
for key in ("file_path", "path", "url", "image_url", "job_id", "id", "status", "message", "next_run"):
for key in ("file_path", "path", "url", "image", "image_url", "job_id", "id", "status", "message", "next_run"):
if data.get(key):
lines.append(f"- **{key}:** {data.get(key)}")
return "\n".join(lines)
Expand Down Expand Up @@ -894,6 +900,7 @@ def _build_polished_completion_content(
"browser_get_images": lambda: _format_browser_result(tool_name, result, function_args),
"vision_analyze": lambda: _format_media_or_cron_result(tool_name, result),
"image_generate": lambda: _format_media_or_cron_result(tool_name, result),
"image_edit": lambda: _format_media_or_cron_result(tool_name, result),
"cronjob": lambda: _format_media_or_cron_result(tool_name, result),
}.get(tool_name)
if formatter is None and tool_name in _POLISHED_TOOLS:
Expand Down
2 changes: 1 addition & 1 deletion model_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ def _run_in_worker():
"terminal_tools": ["terminal"],
"vision_tools": ["vision_analyze"],
"moa_tools": ["mixture_of_agents"],
"image_tools": ["image_generate"],
"image_tools": ["image_generate", "image_edit"],
"skills_tools": ["skills_list", "skill_view", "skill_manage"],
"browser_tools": [
"browser_navigate", "browser_snapshot", "browser_click",
Expand Down
7 changes: 7 additions & 0 deletions tests/acp/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ def test_tool_kind_web_search(self):
def test_tool_kind_execute_code(self):
assert get_tool_kind("execute_code") == "execute"

def test_tool_kind_image_edit(self):
assert get_tool_kind("image_edit") == "execute"

def test_tool_kind_todo(self):
assert get_tool_kind("todo") == "other"

Expand Down Expand Up @@ -135,6 +138,10 @@ def test_skill_manage_title_includes_action_and_target(self):
)
assert title == "skill patch: hermes-agent-operations/references/acp.md"

def test_image_edit_title_includes_prompt(self):
title = build_tool_title("image_edit", {"prompt": "make the product blue", "image": "/tmp/source.png"})
assert title == "edit image: make the product blue"

def test_unknown_tool_uses_name(self):
title = build_tool_title("some_new_tool", {"foo": "bar"})
assert title == "some_new_tool"
Expand Down
209 changes: 209 additions & 0 deletions tests/tools/test_image_edit_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import json

from tools import image_edit_tool


class _Provider:
name = "fake-edit"

def __init__(self):
self.calls = []

def supports_edit(self):
return True

def edit(self, **kwargs):
self.calls.append(kwargs)
return {
"success": True,
"image": "/tmp/edited.png",
"model": kwargs.get("model", "fake-model"),
"prompt": kwargs["prompt"],
"aspect_ratio": kwargs["aspect_ratio"],
"provider": self.name,
}


class _UnsupportedProvider:
name = "fake-no-edit"

def supports_edit(self):
return False

def generate(self, *args, **kwargs):
raise AssertionError("generate should not be called for image_edit")


def test_schema_accepts_prompt_and_reference_images():
schema = image_edit_tool.IMAGE_EDIT_SCHEMA
props = schema["parameters"]["properties"]
assert schema["name"] == "image_edit"
assert "prompt" in schema["parameters"]["required"]
assert "image" in props
assert "images" in props
assert "reference_images" in props
assert "references" in props
assert "aspect_ratio" in props
assert "9:16" in props["aspect_ratio"]["enum"]


def test_handler_requires_prompt_and_image():
missing_prompt = image_edit_tool._handle_image_edit({"image": "https://example.test/a.png"})
assert "prompt is required" in missing_prompt

missing_image = image_edit_tool._handle_image_edit({"prompt": "make it blue"})
assert "image is required" in missing_image


def test_handler_routes_to_configured_provider(monkeypatch):
provider = _Provider()
monkeypatch.setattr(image_edit_tool, "_read_configured_image_provider", lambda: "fake-edit")
monkeypatch.setattr(image_edit_tool, "_read_configured_image_model", lambda: "configured-model")
monkeypatch.setattr("hermes_cli.plugins._ensure_plugins_discovered", lambda *args, **kwargs: None)
monkeypatch.setattr("agent.image_gen_registry.get_provider", lambda name: provider if name == "fake-edit" else None)

result = json.loads(image_edit_tool._handle_image_edit({
"prompt": "make it blue",
"images": ["https://example.test/source.png"],
"aspect_ratio": "1:1",
"size": "1024x1024",
"quality_tier": "high",
}))

assert result["success"] is True
assert result["provider"] == "fake-edit"
assert provider.calls == [{
"prompt": "make it blue",
"image": "https://example.test/source.png",
"aspect_ratio": "1:1",
"model": "configured-model",
"size": "1024x1024",
"quality_tier": "high",
"images": ["https://example.test/source.png"],
}]


def test_handler_preserves_images_order_to_provider(monkeypatch):
provider = _Provider()
monkeypatch.setattr(image_edit_tool, "_read_configured_image_provider", lambda: "fake-edit")
monkeypatch.setattr(image_edit_tool, "_read_configured_image_model", lambda: None)
monkeypatch.setattr("hermes_cli.plugins._ensure_plugins_discovered", lambda *args, **kwargs: None)
monkeypatch.setattr("agent.image_gen_registry.get_provider", lambda name: provider if name == "fake-edit" else None)

images = [
"https://example.test/01.png",
"https://example.test/02.png",
"https://example.test/03.png",
]
result = json.loads(image_edit_tool._handle_image_edit({
"prompt": "compose in this order",
"images": images,
}))

assert result["success"] is True
assert provider.calls[0]["image"] == images[0]
assert provider.calls[0]["images"] == images


def test_handler_preserves_reference_images_order_to_provider(monkeypatch):
provider = _Provider()
monkeypatch.setattr(image_edit_tool, "_read_configured_image_provider", lambda: "fake-edit")
monkeypatch.setattr(image_edit_tool, "_read_configured_image_model", lambda: None)
monkeypatch.setattr("hermes_cli.plugins._ensure_plugins_discovered", lambda *args, **kwargs: None)
monkeypatch.setattr("agent.image_gen_registry.get_provider", lambda name: provider if name == "fake-edit" else None)

reference_images = [
"https://example.test/ref-01.png",
"https://example.test/ref-02.png",
"https://example.test/ref-01.png",
]
result = json.loads(image_edit_tool._handle_image_edit({
"prompt": "compose in this reference order",
"reference_images": reference_images,
}))

assert result["success"] is True
assert provider.calls[0]["image"] == reference_images[0]
assert provider.calls[0]["images"] == reference_images


def test_handler_preserves_references_alias_order_to_provider(monkeypatch):
provider = _Provider()
monkeypatch.setattr(image_edit_tool, "_read_configured_image_provider", lambda: "fake-edit")
monkeypatch.setattr(image_edit_tool, "_read_configured_image_model", lambda: None)
monkeypatch.setattr("hermes_cli.plugins._ensure_plugins_discovered", lambda *args, **kwargs: None)
monkeypatch.setattr("agent.image_gen_registry.get_provider", lambda name: provider if name == "fake-edit" else None)

references = [
"https://example.test/url1.png",
"https://example.test/url2.png",
"https://example.test/url1.png",
]
result = json.loads(image_edit_tool._handle_image_edit({
"prompt": "compose in references order",
"references": references,
}))

assert result["success"] is True
assert provider.calls[0]["image"] == references[0]
assert provider.calls[0]["images"] == references


def test_handler_reports_provider_without_edit(monkeypatch):
provider = _UnsupportedProvider()
monkeypatch.setattr(image_edit_tool, "_read_configured_image_provider", lambda: "fake-no-edit")
monkeypatch.setattr(image_edit_tool, "_read_configured_image_model", lambda: None)
monkeypatch.setattr("hermes_cli.plugins._ensure_plugins_discovered", lambda *args, **kwargs: None)
monkeypatch.setattr("agent.image_gen_registry.get_provider", lambda name: provider if name == "fake-no-edit" else None)

result = json.loads(image_edit_tool._handle_image_edit({
"prompt": "make it blue",
"image": "https://example.test/source.png",
}))

assert result["success"] is False
assert result["error_type"] == "unsupported"
assert "does not support image editing" in result["error"]


def test_handler_reports_unconfigured_provider(monkeypatch):
monkeypatch.setattr(image_edit_tool, "_read_configured_image_provider", lambda: None)

result = json.loads(image_edit_tool._handle_image_edit({
"prompt": "make it blue",
"image": "https://example.test/source.png",
}))

assert result["success"] is False
assert result["error_type"] == "provider_not_configured"


def test_check_requirements_only_when_provider_supports_edit(monkeypatch):
provider = _Provider()
monkeypatch.setattr(image_edit_tool, "_read_configured_image_provider", lambda: "fake-edit")
monkeypatch.setattr("hermes_cli.plugins._ensure_plugins_discovered", lambda *args, **kwargs: None)
monkeypatch.setattr("agent.image_gen_registry.get_provider", lambda name: provider if name == "fake-edit" else None)

assert image_edit_tool.check_image_edit_requirements() is True


def test_check_requirements_rejects_non_edit_provider(monkeypatch):
provider = _UnsupportedProvider()
monkeypatch.setattr(image_edit_tool, "_read_configured_image_provider", lambda: "fake-no-edit")
monkeypatch.setattr("hermes_cli.plugins._ensure_plugins_discovered", lambda *args, **kwargs: None)
monkeypatch.setattr("agent.image_gen_registry.get_provider", lambda name: provider if name == "fake-no-edit" else None)

assert image_edit_tool.check_image_edit_requirements() is False


def test_image_edit_is_opt_in_image_gen_tool_not_core():
import toolsets

assert "image_edit" in toolsets.TOOLSETS["image_gen"]["tools"]
assert "image_edit" not in toolsets._HERMES_CORE_TOOLS


def test_legacy_image_tools_include_image_edit():
import model_tools

assert "image_edit" in model_tools._LEGACY_TOOLSET_MAP["image_tools"]
Loading
Loading