merge: resolve conflicts with origin/main (SSH preflight check)

This commit is contained in:
teknium1 2026-03-15 21:13:40 -07:00
commit 01e62c067b
33 changed files with 374 additions and 85 deletions

View file

@ -235,6 +235,7 @@ hermes_cli/skin_engine.py # SkinConfig dataclass, built-in skins, YAML loader
| Spinner verbs | `spinner.thinking_verbs` | `display.py` | | Spinner verbs | `spinner.thinking_verbs` | `display.py` |
| Spinner wings (optional) | `spinner.wings` | `display.py` | | Spinner wings (optional) | `spinner.wings` | `display.py` |
| Tool output prefix | `tool_prefix` | `display.py` | | Tool output prefix | `tool_prefix` | `display.py` |
| Per-tool emojis | `tool_emojis` | `display.py``get_tool_emoji()` |
| Agent name | `branding.agent_name` | `banner.py`, `cli.py` | | Agent name | `branding.agent_name` | `banner.py`, `cli.py` |
| Welcome message | `branding.welcome` | `cli.py` | | Welcome message | `branding.welcome` | `cli.py` |
| Response box label | `branding.response_label` | `cli.py` | | Response box label | `branding.response_label` | `cli.py` |

View file

@ -59,6 +59,32 @@ def get_skin_tool_prefix() -> str:
return "" return ""
def get_tool_emoji(tool_name: str, default: str = "") -> str:
"""Get the display emoji for a tool.
Resolution order:
1. Active skin's ``tool_emojis`` overrides (if a skin is loaded)
2. Tool registry's per-tool ``emoji`` field
3. *default* fallback
"""
# 1. Skin override
skin = _get_skin()
if skin and skin.tool_emojis:
override = skin.tool_emojis.get(tool_name)
if override:
return override
# 2. Registry default
try:
from tools.registry import registry
emoji = registry.get_emoji(tool_name, default="")
if emoji:
return emoji
except Exception:
pass
# 3. Hardcoded fallback
return default
# ========================================================================= # =========================================================================
# Tool preview (one-line summary of a tool call's primary argument) # Tool preview (one-line summary of a tool call's primary argument)
# ========================================================================= # =========================================================================

View file

@ -3857,45 +3857,8 @@ class GatewayRunner:
last_tool[0] = tool_name last_tool[0] = tool_name
# Build progress message with primary argument preview # Build progress message with primary argument preview
tool_emojis = { from agent.display import get_tool_emoji
"terminal": "💻", emoji = get_tool_emoji(tool_name, default="⚙️")
"process": "⚙️",
"web_search": "🔍",
"web_extract": "📄",
"read_file": "📖",
"write_file": "✍️",
"patch": "🔧",
"search": "🔎",
"search_files": "🔎",
"list_directory": "📂",
"image_generate": "🎨",
"text_to_speech": "🔊",
"browser_navigate": "🌐",
"browser_click": "👆",
"browser_type": "⌨️",
"browser_snapshot": "📸",
"browser_scroll": "📜",
"browser_back": "◀️",
"browser_press": "⌨️",
"browser_close": "🚪",
"browser_get_images": "🖼️",
"browser_vision": "👁️",
"moa_query": "🧠",
"mixture_of_agents": "🧠",
"vision_analyze": "👁️",
"skill_view": "📚",
"skills_list": "📋",
"todo": "📋",
"memory": "🧠",
"session_search": "🔍",
"send_message": "📨",
"cronjob": "",
"execute_code": "🐍",
"delegate_task": "🔀",
"clarify": "",
"skill_manage": "📝",
}
emoji = tool_emojis.get(tool_name, "⚙️")
# Verbose mode: show detailed arguments # Verbose mode: show detailed arguments
if progress_mode == "verbose" and args: if progress_mode == "verbose" and args:

View file

@ -60,6 +60,12 @@ All fields are optional. Missing values inherit from the ``default`` skin.
# Tool prefix: character for tool output lines (default: ┊) # Tool prefix: character for tool output lines (default: ┊)
tool_prefix: "" tool_prefix: ""
# Tool emojis: override the default emoji for any tool (used in spinners & progress)
tool_emojis:
terminal: "" # Override terminal tool emoji
web_search: "🔮" # Override web_search tool emoji
# Any tool not listed here uses its registry default
USAGE USAGE
===== =====
@ -111,6 +117,7 @@ class SkinConfig:
spinner: Dict[str, Any] = field(default_factory=dict) spinner: Dict[str, Any] = field(default_factory=dict)
branding: Dict[str, str] = field(default_factory=dict) branding: Dict[str, str] = field(default_factory=dict)
tool_prefix: str = "" tool_prefix: str = ""
tool_emojis: Dict[str, str] = field(default_factory=dict) # per-tool emoji overrides
banner_logo: str = "" # Rich-markup ASCII art logo (replaces HERMES_AGENT_LOGO) banner_logo: str = "" # Rich-markup ASCII art logo (replaces HERMES_AGENT_LOGO)
banner_hero: str = "" # Rich-markup hero art (replaces HERMES_CADUCEUS) banner_hero: str = "" # Rich-markup hero art (replaces HERMES_CADUCEUS)
@ -541,6 +548,7 @@ def _build_skin_config(data: Dict[str, Any]) -> SkinConfig:
spinner=spinner, spinner=spinner,
branding=branding, branding=branding,
tool_prefix=data.get("tool_prefix", default.get("tool_prefix", "")), tool_prefix=data.get("tool_prefix", default.get("tool_prefix", "")),
tool_emojis=data.get("tool_emojis", {}),
banner_logo=data.get("banner_logo", ""), banner_logo=data.get("banner_logo", ""),
banner_hero=data.get("banner_hero", ""), banner_hero=data.get("banner_hero", ""),
) )

View file

@ -90,6 +90,7 @@ from agent.display import (
KawaiiSpinner, build_tool_preview as _build_tool_preview, KawaiiSpinner, build_tool_preview as _build_tool_preview,
get_cute_tool_message as _get_cute_tool_message_impl, get_cute_tool_message as _get_cute_tool_message_impl,
_detect_tool_failure, _detect_tool_failure,
get_tool_emoji as _get_tool_emoji,
) )
from agent.trajectory import ( from agent.trajectory import (
convert_scratchpad_to_think, has_incomplete_scratchpad, convert_scratchpad_to_think, has_incomplete_scratchpad,
@ -3301,8 +3302,7 @@ class AIAgent:
extra_body["provider"] = provider_preferences extra_body["provider"] = provider_preferences
_is_nous = "nousresearch" in self.base_url.lower() _is_nous = "nousresearch" in self.base_url.lower()
_is_mistral = "api.mistral.ai" in self.base_url.lower() if self._supports_reasoning_extra_body():
if (_is_openrouter or _is_nous) and not _is_mistral:
if self.reasoning_config is not None: if self.reasoning_config is not None:
rc = dict(self.reasoning_config) rc = dict(self.reasoning_config)
# Nous Portal requires reasoning enabled — don't send # Nous Portal requires reasoning enabled — don't send
@ -3326,6 +3326,32 @@ class AIAgent:
return api_kwargs return api_kwargs
def _supports_reasoning_extra_body(self) -> bool:
"""Return True when reasoning extra_body is safe to send for this route/model.
OpenRouter forwards unknown extra_body fields to upstream providers.
Some providers/routes reject `reasoning` with 400s, so gate it to
known reasoning-capable model families and direct Nous Portal.
"""
base_url = (self.base_url or "").lower()
if "nousresearch" in base_url:
return True
if "openrouter" not in base_url:
return False
if "api.mistral.ai" in base_url:
return False
model = (self.model or "").lower()
reasoning_model_prefixes = (
"deepseek/",
"anthropic/",
"openai/",
"x-ai/",
"google/gemini-2",
"qwen/qwen3",
)
return any(model.startswith(prefix) for prefix in reasoning_model_prefixes)
def _build_assistant_message(self, assistant_message, finish_reason: str) -> dict: def _build_assistant_message(self, assistant_message, finish_reason: str) -> dict:
"""Build a normalized assistant message dict from an API response message. """Build a normalized assistant message dict from an API response message.
@ -4095,23 +4121,7 @@ class AIAgent:
self._vprint(f" {cute_msg}") self._vprint(f" {cute_msg}")
elif self.quiet_mode and self._stream_callback is None: elif self.quiet_mode and self._stream_callback is None:
face = random.choice(KawaiiSpinner.KAWAII_WAITING) face = random.choice(KawaiiSpinner.KAWAII_WAITING)
tool_emoji_map = { emoji = _get_tool_emoji(function_name)
'web_search': '🔍', 'web_extract': '📄', 'web_crawl': '🕸️',
'terminal': '💻', 'process': '⚙️',
'read_file': '📖', 'write_file': '✍️', 'patch': '🔧', 'search_files': '🔎',
'browser_navigate': '🌐', 'browser_snapshot': '📸',
'browser_click': '👆', 'browser_type': '⌨️',
'browser_scroll': '📜', 'browser_back': '◀️',
'browser_press': '⌨️', 'browser_close': '🚪',
'browser_get_images': '🖼️', 'browser_vision': '👁️',
'image_generate': '🎨', 'text_to_speech': '🔊',
'vision_analyze': '👁️', 'mixture_of_agents': '🧠',
'skills_list': '📚', 'skill_view': '📚',
'cronjob': '',
'send_message': '📨', 'todo': '📋', 'memory': '🧠', 'session_search': '🔍',
'clarify': '', 'execute_code': '🐍', 'delegate_task': '🔀',
}
emoji = tool_emoji_map.get(function_name, '')
preview = _build_tool_preview(function_name, function_args) or function_name preview = _build_tool_preview(function_name, function_args) or function_name
if len(preview) > 30: if len(preview) > 30:
preview = preview[:27] + "..." preview = preview[:27] + "..."
@ -4280,9 +4290,8 @@ class AIAgent:
api_messages.insert(sys_offset + idx, pfm.copy()) api_messages.insert(sys_offset + idx, pfm.copy())
summary_extra_body = {} summary_extra_body = {}
_is_openrouter = "openrouter" in self.base_url.lower()
_is_nous = "nousresearch" in self.base_url.lower() _is_nous = "nousresearch" in self.base_url.lower()
if _is_openrouter or _is_nous: if self._supports_reasoning_extra_body():
if self.reasoning_config is not None: if self.reasoning_config is not None:
summary_extra_body["reasoning"] = self.reasoning_config summary_extra_body["reasoning"] = self.reasoning_config
else: else:

View file

@ -0,0 +1,123 @@
"""Tests for get_tool_emoji in agent/display.py — skin + registry integration."""
from unittest.mock import patch as mock_patch, MagicMock
from agent.display import get_tool_emoji
class TestGetToolEmoji:
"""Verify the skin → registry → fallback resolution chain."""
def test_returns_registry_emoji_when_no_skin(self):
"""Registry-registered emoji is used when no skin is active."""
mock_registry = MagicMock()
mock_registry.get_emoji.return_value = "🎨"
with mock_patch("agent.display._get_skin", return_value=None), \
mock_patch("agent.display.registry", mock_registry, create=True):
# Need to patch the import inside get_tool_emoji
pass
# Direct test: patch the lazy import path
with mock_patch("agent.display._get_skin", return_value=None):
# get_tool_emoji will try to import registry — mock that
mock_reg = MagicMock()
mock_reg.get_emoji.return_value = "📖"
with mock_patch.dict("sys.modules", {}):
import sys
# Patch tools.registry module
mock_module = MagicMock()
mock_module.registry = mock_reg
with mock_patch.dict(sys.modules, {"tools.registry": mock_module}):
result = get_tool_emoji("read_file")
assert result == "📖"
def test_skin_override_takes_precedence(self):
"""Skin tool_emojis override registry defaults."""
skin = MagicMock()
skin.tool_emojis = {"terminal": ""}
with mock_patch("agent.display._get_skin", return_value=skin):
result = get_tool_emoji("terminal")
assert result == ""
def test_skin_empty_dict_falls_through(self):
"""Empty skin tool_emojis falls through to registry."""
skin = MagicMock()
skin.tool_emojis = {}
mock_reg = MagicMock()
mock_reg.get_emoji.return_value = "💻"
import sys
mock_module = MagicMock()
mock_module.registry = mock_reg
with mock_patch("agent.display._get_skin", return_value=skin), \
mock_patch.dict(sys.modules, {"tools.registry": mock_module}):
result = get_tool_emoji("terminal")
assert result == "💻"
def test_fallback_default(self):
"""When neither skin nor registry has an emoji, use the default."""
skin = MagicMock()
skin.tool_emojis = {}
mock_reg = MagicMock()
mock_reg.get_emoji.return_value = ""
import sys
mock_module = MagicMock()
mock_module.registry = mock_reg
with mock_patch("agent.display._get_skin", return_value=skin), \
mock_patch.dict(sys.modules, {"tools.registry": mock_module}):
result = get_tool_emoji("unknown_tool")
assert result == ""
def test_custom_default(self):
"""Custom default is returned when nothing matches."""
with mock_patch("agent.display._get_skin", return_value=None):
mock_reg = MagicMock()
mock_reg.get_emoji.return_value = ""
import sys
mock_module = MagicMock()
mock_module.registry = mock_reg
with mock_patch.dict(sys.modules, {"tools.registry": mock_module}):
result = get_tool_emoji("x", default="⚙️")
assert result == "⚙️"
def test_skin_override_only_for_matching_tool(self):
"""Skin override for one tool doesn't affect others."""
skin = MagicMock()
skin.tool_emojis = {"terminal": ""}
mock_reg = MagicMock()
mock_reg.get_emoji.return_value = "🔍"
import sys
mock_module = MagicMock()
mock_module.registry = mock_reg
with mock_patch("agent.display._get_skin", return_value=skin), \
mock_patch.dict(sys.modules, {"tools.registry": mock_module}):
assert get_tool_emoji("terminal") == "" # skin override
assert get_tool_emoji("web_search") == "🔍" # registry fallback
class TestSkinConfigToolEmojis:
"""Verify SkinConfig handles tool_emojis field correctly."""
def test_skin_config_has_tool_emojis_field(self):
from hermes_cli.skin_engine import SkinConfig
skin = SkinConfig(name="test")
assert skin.tool_emojis == {}
def test_skin_config_accepts_tool_emojis(self):
from hermes_cli.skin_engine import SkinConfig
emojis = {"terminal": "", "web_search": "🔮"}
skin = SkinConfig(name="test", tool_emojis=emojis)
assert skin.tool_emojis == emojis
def test_build_skin_config_includes_tool_emojis(self):
from hermes_cli.skin_engine import _build_skin_config
data = {
"name": "custom",
"tool_emojis": {"terminal": "🗡️", "patch": "⚒️"},
}
skin = _build_skin_config(data)
assert skin.tool_emojis == {"terminal": "🗡️", "patch": "⚒️"}
def test_build_skin_config_empty_tool_emojis_default(self):
from hermes_cli.skin_engine import _build_skin_config
data = {"name": "minimal"}
skin = _build_skin_config(data)
assert skin.tool_emojis == {}

View file

@ -612,6 +612,25 @@ class TestBuildApiKwargs:
kwargs = agent._build_api_kwargs(messages) kwargs = agent._build_api_kwargs(messages)
assert kwargs["extra_body"]["reasoning"] == {"enabled": False} assert kwargs["extra_body"]["reasoning"] == {"enabled": False}
def test_reasoning_not_sent_for_unsupported_openrouter_model(self, agent):
agent.model = "minimax/minimax-m2.5"
messages = [{"role": "user", "content": "hi"}]
kwargs = agent._build_api_kwargs(messages)
assert "reasoning" not in kwargs.get("extra_body", {})
def test_reasoning_sent_for_supported_openrouter_model(self, agent):
agent.model = "qwen/qwen3.5-plus-02-15"
messages = [{"role": "user", "content": "hi"}]
kwargs = agent._build_api_kwargs(messages)
assert kwargs["extra_body"]["reasoning"]["effort"] == "medium"
def test_reasoning_sent_for_nous_route(self, agent):
agent.base_url = "https://inference-api.nousresearch.com/v1"
agent.model = "minimax/minimax-m2.5"
messages = [{"role": "user", "content": "hi"}]
kwargs = agent._build_api_kwargs(messages)
assert kwargs["extra_body"]["reasoning"]["effort"] == "medium"
def test_max_tokens_injected(self, agent): def test_max_tokens_injected(self, agent):
agent.max_tokens = 4096 agent.max_tokens = 4096
messages = [{"role": "user", "content": "hi"}] messages = [{"role": "user", "content": "hi"}]
@ -942,6 +961,19 @@ class TestHandleMaxIterations:
assert "error" in result.lower() assert "error" in result.lower()
assert "API down" in result assert "API down" in result
def test_summary_skips_reasoning_for_unsupported_openrouter_model(self, agent):
agent.model = "minimax/minimax-m2.5"
resp = _mock_response(content="Summary")
agent.client.chat.completions.create.return_value = resp
agent._cached_system_prompt = "You are helpful."
messages = [{"role": "user", "content": "do stuff"}]
result = agent._handle_max_iterations(messages, 60)
assert result == "Summary"
kwargs = agent.client.chat.completions.create.call_args.kwargs
assert "reasoning" not in kwargs.get("extra_body", {})
class TestRunConversation: class TestRunConversation:
"""Tests for the main run_conversation method. """Tests for the main run_conversation method.

View file

@ -232,6 +232,48 @@ class TestCheckFnExceptionHandling:
assert any(u["name"] == "crashes" for u in unavailable) assert any(u["name"] == "crashes" for u in unavailable)
class TestEmojiMetadata:
"""Verify per-tool emoji registration and lookup."""
def test_emoji_stored_on_entry(self):
reg = ToolRegistry()
reg.register(
name="t", toolset="s", schema=_make_schema(),
handler=_dummy_handler, emoji="🔥",
)
assert reg._tools["t"].emoji == "🔥"
def test_get_emoji_returns_registered(self):
reg = ToolRegistry()
reg.register(
name="t", toolset="s", schema=_make_schema(),
handler=_dummy_handler, emoji="🎯",
)
assert reg.get_emoji("t") == "🎯"
def test_get_emoji_returns_default_when_unset(self):
reg = ToolRegistry()
reg.register(
name="t", toolset="s", schema=_make_schema(),
handler=_dummy_handler,
)
assert reg.get_emoji("t") == ""
assert reg.get_emoji("t", default="🔧") == "🔧"
def test_get_emoji_returns_default_for_unknown_tool(self):
reg = ToolRegistry()
assert reg.get_emoji("nonexistent") == ""
assert reg.get_emoji("nonexistent", default="") == ""
def test_emoji_empty_string_treated_as_unset(self):
reg = ToolRegistry()
reg.register(
name="t", toolset="s", schema=_make_schema(),
handler=_dummy_handler, emoji="",
)
assert reg.get_emoji("t") == ""
class TestSecretCaptureResultContract: class TestSecretCaptureResultContract:
def test_secret_request_result_does_not_include_secret_value(self): def test_secret_request_result_does_not_include_secret_value(self):
result = { result = {

View file

@ -8,6 +8,7 @@ from unittest.mock import MagicMock
import pytest import pytest
from tools.environments.ssh import SSHEnvironment from tools.environments.ssh import SSHEnvironment
from tools.environments import ssh as ssh_env
_SSH_HOST = os.getenv("TERMINAL_SSH_HOST", "") _SSH_HOST = os.getenv("TERMINAL_SSH_HOST", "")
_SSH_USER = os.getenv("TERMINAL_SSH_USER", "") _SSH_USER = os.getenv("TERMINAL_SSH_USER", "")
@ -93,6 +94,41 @@ class TestTerminalToolConfig:
assert _get_env_config()["ssh_persistent"] is False assert _get_env_config()["ssh_persistent"] is False
class TestSSHPreflight:
def test_ensure_ssh_available_raises_clear_error_when_missing(self, monkeypatch):
monkeypatch.setattr(ssh_env.shutil, "which", lambda _name: None)
with pytest.raises(RuntimeError, match="SSH is not installed or not in PATH"):
ssh_env._ensure_ssh_available()
def test_ssh_environment_checks_availability_before_connect(self, monkeypatch):
monkeypatch.setattr(ssh_env.shutil, "which", lambda _name: None)
monkeypatch.setattr(
ssh_env.SSHEnvironment,
"_establish_connection",
lambda self: pytest.fail("_establish_connection should not run when ssh is missing"),
)
with pytest.raises(RuntimeError, match="openssh-client"):
ssh_env.SSHEnvironment(host="example.com", user="alice")
def test_ssh_environment_connects_when_ssh_exists(self, monkeypatch):
called = {"count": 0}
monkeypatch.setattr(ssh_env.shutil, "which", lambda _name: "/usr/bin/ssh")
def _fake_establish(self):
called["count"] += 1
monkeypatch.setattr(ssh_env.SSHEnvironment, "_establish_connection", _fake_establish)
env = ssh_env.SSHEnvironment(host="example.com", user="alice")
assert called["count"] == 1
assert env.host == "example.com"
assert env.user == "alice"
def _setup_ssh_env(monkeypatch, persistent: bool): def _setup_ssh_env(monkeypatch, persistent: bool):
monkeypatch.setenv("TERMINAL_ENV", "ssh") monkeypatch.setenv("TERMINAL_ENV", "ssh")
monkeypatch.setenv("TERMINAL_SSH_HOST", _SSH_HOST) monkeypatch.setenv("TERMINAL_SSH_HOST", _SSH_HOST)

View file

@ -1833,6 +1833,7 @@ registry.register(
schema=_BROWSER_SCHEMA_MAP["browser_navigate"], schema=_BROWSER_SCHEMA_MAP["browser_navigate"],
handler=lambda args, **kw: browser_navigate(url=args.get("url", ""), task_id=kw.get("task_id")), handler=lambda args, **kw: browser_navigate(url=args.get("url", ""), task_id=kw.get("task_id")),
check_fn=check_browser_requirements, check_fn=check_browser_requirements,
emoji="🌐",
) )
registry.register( registry.register(
name="browser_snapshot", name="browser_snapshot",
@ -1841,6 +1842,7 @@ registry.register(
handler=lambda args, **kw: browser_snapshot( handler=lambda args, **kw: browser_snapshot(
full=args.get("full", False), task_id=kw.get("task_id"), user_task=kw.get("user_task")), full=args.get("full", False), task_id=kw.get("task_id"), user_task=kw.get("user_task")),
check_fn=check_browser_requirements, check_fn=check_browser_requirements,
emoji="📸",
) )
registry.register( registry.register(
name="browser_click", name="browser_click",
@ -1848,6 +1850,7 @@ registry.register(
schema=_BROWSER_SCHEMA_MAP["browser_click"], schema=_BROWSER_SCHEMA_MAP["browser_click"],
handler=lambda args, **kw: browser_click(**args, task_id=kw.get("task_id")), handler=lambda args, **kw: browser_click(**args, task_id=kw.get("task_id")),
check_fn=check_browser_requirements, check_fn=check_browser_requirements,
emoji="👆",
) )
registry.register( registry.register(
name="browser_type", name="browser_type",
@ -1855,6 +1858,7 @@ registry.register(
schema=_BROWSER_SCHEMA_MAP["browser_type"], schema=_BROWSER_SCHEMA_MAP["browser_type"],
handler=lambda args, **kw: browser_type(**args, task_id=kw.get("task_id")), handler=lambda args, **kw: browser_type(**args, task_id=kw.get("task_id")),
check_fn=check_browser_requirements, check_fn=check_browser_requirements,
emoji="⌨️",
) )
registry.register( registry.register(
name="browser_scroll", name="browser_scroll",
@ -1862,6 +1866,7 @@ registry.register(
schema=_BROWSER_SCHEMA_MAP["browser_scroll"], schema=_BROWSER_SCHEMA_MAP["browser_scroll"],
handler=lambda args, **kw: browser_scroll(**args, task_id=kw.get("task_id")), handler=lambda args, **kw: browser_scroll(**args, task_id=kw.get("task_id")),
check_fn=check_browser_requirements, check_fn=check_browser_requirements,
emoji="📜",
) )
registry.register( registry.register(
name="browser_back", name="browser_back",
@ -1869,6 +1874,7 @@ registry.register(
schema=_BROWSER_SCHEMA_MAP["browser_back"], schema=_BROWSER_SCHEMA_MAP["browser_back"],
handler=lambda args, **kw: browser_back(task_id=kw.get("task_id")), handler=lambda args, **kw: browser_back(task_id=kw.get("task_id")),
check_fn=check_browser_requirements, check_fn=check_browser_requirements,
emoji="◀️",
) )
registry.register( registry.register(
name="browser_press", name="browser_press",
@ -1876,6 +1882,7 @@ registry.register(
schema=_BROWSER_SCHEMA_MAP["browser_press"], schema=_BROWSER_SCHEMA_MAP["browser_press"],
handler=lambda args, **kw: browser_press(key=args.get("key", ""), task_id=kw.get("task_id")), handler=lambda args, **kw: browser_press(key=args.get("key", ""), task_id=kw.get("task_id")),
check_fn=check_browser_requirements, check_fn=check_browser_requirements,
emoji="⌨️",
) )
registry.register( registry.register(
name="browser_close", name="browser_close",
@ -1883,6 +1890,7 @@ registry.register(
schema=_BROWSER_SCHEMA_MAP["browser_close"], schema=_BROWSER_SCHEMA_MAP["browser_close"],
handler=lambda args, **kw: browser_close(task_id=kw.get("task_id")), handler=lambda args, **kw: browser_close(task_id=kw.get("task_id")),
check_fn=check_browser_requirements, check_fn=check_browser_requirements,
emoji="🚪",
) )
registry.register( registry.register(
name="browser_get_images", name="browser_get_images",
@ -1890,6 +1898,7 @@ registry.register(
schema=_BROWSER_SCHEMA_MAP["browser_get_images"], schema=_BROWSER_SCHEMA_MAP["browser_get_images"],
handler=lambda args, **kw: browser_get_images(task_id=kw.get("task_id")), handler=lambda args, **kw: browser_get_images(task_id=kw.get("task_id")),
check_fn=check_browser_requirements, check_fn=check_browser_requirements,
emoji="🖼️",
) )
registry.register( registry.register(
name="browser_vision", name="browser_vision",
@ -1897,6 +1906,7 @@ registry.register(
schema=_BROWSER_SCHEMA_MAP["browser_vision"], schema=_BROWSER_SCHEMA_MAP["browser_vision"],
handler=lambda args, **kw: browser_vision(question=args.get("question", ""), annotate=args.get("annotate", False), task_id=kw.get("task_id")), handler=lambda args, **kw: browser_vision(question=args.get("question", ""), annotate=args.get("annotate", False), task_id=kw.get("task_id")),
check_fn=check_browser_requirements, check_fn=check_browser_requirements,
emoji="👁️",
) )
registry.register( registry.register(
name="browser_console", name="browser_console",
@ -1904,4 +1914,5 @@ registry.register(
schema=_BROWSER_SCHEMA_MAP["browser_console"], schema=_BROWSER_SCHEMA_MAP["browser_console"],
handler=lambda args, **kw: browser_console(clear=args.get("clear", False), task_id=kw.get("task_id")), handler=lambda args, **kw: browser_console(clear=args.get("clear", False), task_id=kw.get("task_id")),
check_fn=check_browser_requirements, check_fn=check_browser_requirements,
emoji="🖥️",
) )

View file

@ -137,4 +137,5 @@ registry.register(
choices=args.get("choices"), choices=args.get("choices"),
callback=kw.get("callback")), callback=kw.get("callback")),
check_fn=check_clarify_requirements, check_fn=check_clarify_requirements,
emoji="",
) )

View file

@ -776,4 +776,5 @@ registry.register(
task_id=kw.get("task_id"), task_id=kw.get("task_id"),
enabled_tools=kw.get("enabled_tools")), enabled_tools=kw.get("enabled_tools")),
check_fn=check_sandbox_requirements, check_fn=check_sandbox_requirements,
emoji="🐍",
) )

View file

@ -458,4 +458,5 @@ registry.register(
task_id=kw.get("task_id"), task_id=kw.get("task_id"),
), ),
check_fn=check_cronjob_requirements, check_fn=check_cronjob_requirements,
emoji="",
) )

View file

@ -116,15 +116,8 @@ def _build_child_progress_callback(task_index: int, parent_agent, task_count: in
# Regular tool call event # Regular tool call event
if spinner: if spinner:
short = (preview[:35] + "...") if preview and len(preview) > 35 else (preview or "") short = (preview[:35] + "...") if preview and len(preview) > 35 else (preview or "")
tool_emojis = { from agent.display import get_tool_emoji
"terminal": "💻", "web_search": "🔍", "web_extract": "📄", emoji = get_tool_emoji(tool_name)
"read_file": "📖", "write_file": "✍️", "patch": "🔧",
"search_files": "🔎", "list_directory": "📂",
"browser_navigate": "🌐", "browser_click": "👆",
"text_to_speech": "🔊", "image_generate": "🎨",
"vision_analyze": "👁️", "process": "⚙️",
}
emoji = tool_emojis.get(tool_name, "")
line = f" {prefix}├─ {emoji} {tool_name}" line = f" {prefix}├─ {emoji} {tool_name}"
if short: if short:
line += f" \"{short}\"" line += f" \"{short}\""
@ -758,4 +751,5 @@ registry.register(
max_iterations=args.get("max_iterations"), max_iterations=args.get("max_iterations"),
parent_agent=kw.get("parent_agent")), parent_agent=kw.get("parent_agent")),
check_fn=check_delegate_requirements, check_fn=check_delegate_requirements,
emoji="🔀",
) )

View file

@ -1,6 +1,7 @@
"""SSH remote execution environment with ControlMaster connection persistence.""" """SSH remote execution environment with ControlMaster connection persistence."""
import logging import logging
import shutil
import subprocess import subprocess
import tempfile import tempfile
import threading import threading
@ -14,6 +15,14 @@ from tools.interrupt import is_interrupted
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _ensure_ssh_available() -> None:
"""Fail fast with a clear error when the SSH client is unavailable."""
if not shutil.which("ssh"):
raise RuntimeError(
"SSH is not installed or not in PATH. Install OpenSSH client: apt install openssh-client"
)
class SSHEnvironment(PersistentShellMixin, BaseEnvironment): class SSHEnvironment(PersistentShellMixin, BaseEnvironment):
"""Run commands on a remote machine over SSH. """Run commands on a remote machine over SSH.
@ -44,6 +53,7 @@ class SSHEnvironment(PersistentShellMixin, BaseEnvironment):
self.control_dir = Path(tempfile.gettempdir()) / "hermes-ssh" self.control_dir = Path(tempfile.gettempdir()) / "hermes-ssh"
self.control_dir.mkdir(parents=True, exist_ok=True) self.control_dir.mkdir(parents=True, exist_ok=True)
self.control_socket = self.control_dir / f"{user}@{host}:{port}.sock" self.control_socket = self.control_dir / f"{user}@{host}:{port}.sock"
_ensure_ssh_available()
self._establish_connection() self._establish_connection()
if self.persistent: if self.persistent:

View file

@ -483,7 +483,7 @@ def _handle_search_files(args, **kw):
output_mode=args.get("output_mode", "content"), context=args.get("context", 0), task_id=tid) output_mode=args.get("output_mode", "content"), context=args.get("context", 0), task_id=tid)
registry.register(name="read_file", toolset="file", schema=READ_FILE_SCHEMA, handler=_handle_read_file, check_fn=_check_file_reqs) registry.register(name="read_file", toolset="file", schema=READ_FILE_SCHEMA, handler=_handle_read_file, check_fn=_check_file_reqs, emoji="📖")
registry.register(name="write_file", toolset="file", schema=WRITE_FILE_SCHEMA, handler=_handle_write_file, check_fn=_check_file_reqs) registry.register(name="write_file", toolset="file", schema=WRITE_FILE_SCHEMA, handler=_handle_write_file, check_fn=_check_file_reqs, emoji="✍️")
registry.register(name="patch", toolset="file", schema=PATCH_SCHEMA, handler=_handle_patch, check_fn=_check_file_reqs) registry.register(name="patch", toolset="file", schema=PATCH_SCHEMA, handler=_handle_patch, check_fn=_check_file_reqs, emoji="🔧")
registry.register(name="search_files", toolset="file", schema=SEARCH_FILES_SCHEMA, handler=_handle_search_files, check_fn=_check_file_reqs) registry.register(name="search_files", toolset="file", schema=SEARCH_FILES_SCHEMA, handler=_handle_search_files, check_fn=_check_file_reqs, emoji="🔎")

View file

@ -459,6 +459,7 @@ registry.register(
schema=HA_LIST_ENTITIES_SCHEMA, schema=HA_LIST_ENTITIES_SCHEMA,
handler=_handle_list_entities, handler=_handle_list_entities,
check_fn=_check_ha_available, check_fn=_check_ha_available,
emoji="🏠",
) )
registry.register( registry.register(
@ -467,6 +468,7 @@ registry.register(
schema=HA_GET_STATE_SCHEMA, schema=HA_GET_STATE_SCHEMA,
handler=_handle_get_state, handler=_handle_get_state,
check_fn=_check_ha_available, check_fn=_check_ha_available,
emoji="🏠",
) )
registry.register( registry.register(
@ -475,6 +477,7 @@ registry.register(
schema=HA_LIST_SERVICES_SCHEMA, schema=HA_LIST_SERVICES_SCHEMA,
handler=_handle_list_services, handler=_handle_list_services,
check_fn=_check_ha_available, check_fn=_check_ha_available,
emoji="🏠",
) )
registry.register( registry.register(
@ -483,4 +486,5 @@ registry.register(
schema=HA_CALL_SERVICE_SCHEMA, schema=HA_CALL_SERVICE_SCHEMA,
handler=_handle_call_service, handler=_handle_call_service,
check_fn=_check_ha_available, check_fn=_check_ha_available,
emoji="🏠",
) )

View file

@ -222,6 +222,7 @@ registry.register(
schema=_PROFILE_SCHEMA, schema=_PROFILE_SCHEMA,
handler=_handle_honcho_profile, handler=_handle_honcho_profile,
check_fn=_check_honcho_available, check_fn=_check_honcho_available,
emoji="🔮",
) )
registry.register( registry.register(
@ -230,6 +231,7 @@ registry.register(
schema=_SEARCH_SCHEMA, schema=_SEARCH_SCHEMA,
handler=_handle_honcho_search, handler=_handle_honcho_search,
check_fn=_check_honcho_available, check_fn=_check_honcho_available,
emoji="🔮",
) )
registry.register( registry.register(
@ -238,6 +240,7 @@ registry.register(
schema=_QUERY_SCHEMA, schema=_QUERY_SCHEMA,
handler=_handle_honcho_context, handler=_handle_honcho_context,
check_fn=_check_honcho_available, check_fn=_check_honcho_available,
emoji="🔮",
) )
registry.register( registry.register(
@ -246,4 +249,5 @@ registry.register(
schema=_CONCLUDE_SCHEMA, schema=_CONCLUDE_SCHEMA,
handler=_handle_honcho_conclude, handler=_handle_honcho_conclude,
check_fn=_check_honcho_available, check_fn=_check_honcho_available,
emoji="🔮",
) )

View file

@ -558,4 +558,5 @@ registry.register(
check_fn=check_image_generation_requirements, check_fn=check_image_generation_requirements,
requires_env=["FAL_KEY"], requires_env=["FAL_KEY"],
is_async=False, # Switched to sync fal_client API to fix "Event loop is closed" in gateway is_async=False, # Switched to sync fal_client API to fix "Event loop is closed" in gateway
emoji="🎨",
) )

View file

@ -496,6 +496,7 @@ registry.register(
old_text=args.get("old_text"), old_text=args.get("old_text"),
store=kw.get("store")), store=kw.get("store")),
check_fn=check_memory_requirements, check_fn=check_memory_requirements,
emoji="🧠",
) )

View file

@ -544,4 +544,5 @@ registry.register(
check_fn=check_moa_requirements, check_fn=check_moa_requirements,
requires_env=["OPENROUTER_API_KEY"], requires_env=["OPENROUTER_API_KEY"],
is_async=True, is_async=True,
emoji="🧠",
) )

View file

@ -858,4 +858,5 @@ registry.register(
toolset="terminal", toolset="terminal",
schema=PROCESS_SCHEMA, schema=PROCESS_SCHEMA,
handler=_handle_process, handler=_handle_process,
emoji="⚙️",
) )

View file

@ -26,11 +26,11 @@ class ToolEntry:
__slots__ = ( __slots__ = (
"name", "toolset", "schema", "handler", "check_fn", "name", "toolset", "schema", "handler", "check_fn",
"requires_env", "is_async", "description", "requires_env", "is_async", "description", "emoji",
) )
def __init__(self, name, toolset, schema, handler, check_fn, def __init__(self, name, toolset, schema, handler, check_fn,
requires_env, is_async, description): requires_env, is_async, description, emoji):
self.name = name self.name = name
self.toolset = toolset self.toolset = toolset
self.schema = schema self.schema = schema
@ -39,6 +39,7 @@ class ToolEntry:
self.requires_env = requires_env self.requires_env = requires_env
self.is_async = is_async self.is_async = is_async
self.description = description self.description = description
self.emoji = emoji
class ToolRegistry: class ToolRegistry:
@ -62,6 +63,7 @@ class ToolRegistry:
requires_env: list = None, requires_env: list = None,
is_async: bool = False, is_async: bool = False,
description: str = "", description: str = "",
emoji: str = "",
): ):
"""Register a tool. Called at module-import time by each tool file.""" """Register a tool. Called at module-import time by each tool file."""
self._tools[name] = ToolEntry( self._tools[name] = ToolEntry(
@ -73,6 +75,7 @@ class ToolRegistry:
requires_env=requires_env or [], requires_env=requires_env or [],
is_async=is_async, is_async=is_async,
description=description or schema.get("description", ""), description=description or schema.get("description", ""),
emoji=emoji,
) )
if check_fn and toolset not in self._toolset_checks: if check_fn and toolset not in self._toolset_checks:
self._toolset_checks[toolset] = check_fn self._toolset_checks[toolset] = check_fn
@ -141,6 +144,11 @@ class ToolRegistry:
entry = self._tools.get(name) entry = self._tools.get(name)
return entry.toolset if entry else None return entry.toolset if entry else None
def get_emoji(self, name: str, default: str = "") -> str:
"""Return the emoji for a tool, or *default* if unset."""
entry = self._tools.get(name)
return (entry.emoji if entry and entry.emoji else default)
def get_tool_to_toolset_map(self) -> Dict[str, str]: def get_tool_to_toolset_map(self) -> Dict[str, str]:
"""Return ``{tool_name: toolset_name}`` for every registered tool.""" """Return ``{tool_name: toolset_name}`` for every registered tool."""
return {name: e.toolset for name, e in self._tools.items()} return {name: e.toolset for name, e in self._tools.items()}

View file

@ -1374,24 +1374,24 @@ RL_TEST_INFERENCE_SCHEMA = {"name": "rl_test_inference", "description": "Quick i
_rl_env = ["TINKER_API_KEY", "WANDB_API_KEY"] _rl_env = ["TINKER_API_KEY", "WANDB_API_KEY"]
registry.register(name="rl_list_environments", toolset="rl", schema=RL_LIST_ENVIRONMENTS_SCHEMA, registry.register(name="rl_list_environments", emoji="🧪", toolset="rl", schema=RL_LIST_ENVIRONMENTS_SCHEMA,
handler=lambda args, **kw: rl_list_environments(), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True) handler=lambda args, **kw: rl_list_environments(), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True)
registry.register(name="rl_select_environment", toolset="rl", schema=RL_SELECT_ENVIRONMENT_SCHEMA, registry.register(name="rl_select_environment", emoji="🧪", toolset="rl", schema=RL_SELECT_ENVIRONMENT_SCHEMA,
handler=lambda args, **kw: rl_select_environment(name=args.get("name", "")), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True) handler=lambda args, **kw: rl_select_environment(name=args.get("name", "")), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True)
registry.register(name="rl_get_current_config", toolset="rl", schema=RL_GET_CURRENT_CONFIG_SCHEMA, registry.register(name="rl_get_current_config", emoji="🧪", toolset="rl", schema=RL_GET_CURRENT_CONFIG_SCHEMA,
handler=lambda args, **kw: rl_get_current_config(), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True) handler=lambda args, **kw: rl_get_current_config(), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True)
registry.register(name="rl_edit_config", toolset="rl", schema=RL_EDIT_CONFIG_SCHEMA, registry.register(name="rl_edit_config", emoji="🧪", toolset="rl", schema=RL_EDIT_CONFIG_SCHEMA,
handler=lambda args, **kw: rl_edit_config(field=args.get("field", ""), value=args.get("value")), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True) handler=lambda args, **kw: rl_edit_config(field=args.get("field", ""), value=args.get("value")), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True)
registry.register(name="rl_start_training", toolset="rl", schema=RL_START_TRAINING_SCHEMA, registry.register(name="rl_start_training", emoji="🧪", toolset="rl", schema=RL_START_TRAINING_SCHEMA,
handler=lambda args, **kw: rl_start_training(), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True) handler=lambda args, **kw: rl_start_training(), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True)
registry.register(name="rl_check_status", toolset="rl", schema=RL_CHECK_STATUS_SCHEMA, registry.register(name="rl_check_status", emoji="🧪", toolset="rl", schema=RL_CHECK_STATUS_SCHEMA,
handler=lambda args, **kw: rl_check_status(run_id=args.get("run_id", "")), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True) handler=lambda args, **kw: rl_check_status(run_id=args.get("run_id", "")), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True)
registry.register(name="rl_stop_training", toolset="rl", schema=RL_STOP_TRAINING_SCHEMA, registry.register(name="rl_stop_training", emoji="🧪", toolset="rl", schema=RL_STOP_TRAINING_SCHEMA,
handler=lambda args, **kw: rl_stop_training(run_id=args.get("run_id", "")), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True) handler=lambda args, **kw: rl_stop_training(run_id=args.get("run_id", "")), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True)
registry.register(name="rl_get_results", toolset="rl", schema=RL_GET_RESULTS_SCHEMA, registry.register(name="rl_get_results", emoji="🧪", toolset="rl", schema=RL_GET_RESULTS_SCHEMA,
handler=lambda args, **kw: rl_get_results(run_id=args.get("run_id", "")), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True) handler=lambda args, **kw: rl_get_results(run_id=args.get("run_id", "")), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True)
registry.register(name="rl_list_runs", toolset="rl", schema=RL_LIST_RUNS_SCHEMA, registry.register(name="rl_list_runs", emoji="🧪", toolset="rl", schema=RL_LIST_RUNS_SCHEMA,
handler=lambda args, **kw: rl_list_runs(), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True) handler=lambda args, **kw: rl_list_runs(), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True)
registry.register(name="rl_test_inference", toolset="rl", schema=RL_TEST_INFERENCE_SCHEMA, registry.register(name="rl_test_inference", emoji="🧪", toolset="rl", schema=RL_TEST_INFERENCE_SCHEMA,
handler=lambda args, **kw: rl_test_inference(num_steps=args.get("num_steps", 3), group_size=args.get("group_size", 16), models=args.get("models")), handler=lambda args, **kw: rl_test_inference(num_steps=args.get("num_steps", 3), group_size=args.get("group_size", 16), models=args.get("models")),
check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True) check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True)

View file

@ -512,4 +512,5 @@ registry.register(
schema=SEND_MESSAGE_SCHEMA, schema=SEND_MESSAGE_SCHEMA,
handler=send_message_tool, handler=send_message_tool,
check_fn=_check_send_message, check_fn=_check_send_message,
emoji="📨",
) )

View file

@ -385,4 +385,5 @@ registry.register(
db=kw.get("db"), db=kw.get("db"),
current_session_id=kw.get("current_session_id")), current_session_id=kw.get("current_session_id")),
check_fn=check_session_search_requirements, check_fn=check_session_search_requirements,
emoji="🔍",
) )

View file

@ -653,4 +653,5 @@ registry.register(
old_string=args.get("old_string"), old_string=args.get("old_string"),
new_string=args.get("new_string"), new_string=args.get("new_string"),
replace_all=args.get("replace_all", False)), replace_all=args.get("replace_all", False)),
emoji="📝",
) )

View file

@ -1261,6 +1261,7 @@ registry.register(
category=args.get("category"), task_id=kw.get("task_id") category=args.get("category"), task_id=kw.get("task_id")
), ),
check_fn=check_skills_requirements, check_fn=check_skills_requirements,
emoji="📚",
) )
registry.register( registry.register(
name="skill_view", name="skill_view",
@ -1270,4 +1271,5 @@ registry.register(
args.get("name", ""), file_path=args.get("file_path"), task_id=kw.get("task_id") args.get("name", ""), file_path=args.get("file_path"), task_id=kw.get("task_id")
), ),
check_fn=check_skills_requirements, check_fn=check_skills_requirements,
emoji="📚",
) )

View file

@ -1339,4 +1339,5 @@ registry.register(
schema=TERMINAL_SCHEMA, schema=TERMINAL_SCHEMA,
handler=_handle_terminal, handler=_handle_terminal,
check_fn=check_terminal_requirements, check_fn=check_terminal_requirements,
emoji="💻",
) )

View file

@ -264,4 +264,5 @@ registry.register(
handler=lambda args, **kw: todo_tool( handler=lambda args, **kw: todo_tool(
todos=args.get("todos"), merge=args.get("merge", False), store=kw.get("store")), todos=args.get("todos"), merge=args.get("merge", False), store=kw.get("store")),
check_fn=check_todo_requirements, check_fn=check_todo_requirements,
emoji="📋",
) )

View file

@ -743,4 +743,5 @@ registry.register(
text=args.get("text", ""), text=args.get("text", ""),
output_path=args.get("output_path")), output_path=args.get("output_path")),
check_fn=check_tts_requirements, check_fn=check_tts_requirements,
emoji="🔊",
) )

View file

@ -493,4 +493,5 @@ registry.register(
handler=_handle_vision_analyze, handler=_handle_vision_analyze,
check_fn=check_vision_requirements, check_fn=check_vision_requirements,
is_async=True, is_async=True,
emoji="👁️",
) )

View file

@ -1258,6 +1258,7 @@ registry.register(
handler=lambda args, **kw: web_search_tool(args.get("query", ""), limit=5), handler=lambda args, **kw: web_search_tool(args.get("query", ""), limit=5),
check_fn=check_firecrawl_api_key, check_fn=check_firecrawl_api_key,
requires_env=["FIRECRAWL_API_KEY"], requires_env=["FIRECRAWL_API_KEY"],
emoji="🔍",
) )
registry.register( registry.register(
name="web_extract", name="web_extract",
@ -1268,4 +1269,5 @@ registry.register(
check_fn=check_firecrawl_api_key, check_fn=check_firecrawl_api_key,
requires_env=["FIRECRAWL_API_KEY"], requires_env=["FIRECRAWL_API_KEY"],
is_async=True, is_async=True,
emoji="📄",
) )