Skip to content
Merged
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
2 changes: 1 addition & 1 deletion acp_adapter/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
"kanban_block", "kanban_request_review", "kanban_request_changes",
"kanban_link", "kanban_heartbeat",
"yb_query_group_info", "yb_query_group_members", "yb_search_sticker",
"yb_send_dm", "yb_send_sticker",
"yb_send_dm", "yb_send_sticker", "mixture_of_agents",
}


Expand Down
4 changes: 3 additions & 1 deletion agent/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
"search_files": "pattern", "browser_navigate": "url",
"browser_click": "ref", "browser_type": "text",
"image_generate": "prompt", "text_to_speech": "text",
"vision_analyze": "question",
"vision_analyze": "question", "mixture_of_agents": "user_prompt",
"skill_view": "name", "skills_list": "category",
"cronjob": "action",
"execute_code": "code", "browser_exec": "code", "delegate_task": "goal",
Expand Down Expand Up @@ -1530,6 +1530,8 @@ def _wrap(line: str) -> str:
return _wrap(f"┊ 🔊 speak {_trunc(args.get('text', ''), 30)} {dur}")
if tool_name == "vision_analyze":
return _wrap(f"┊ 👁️ vision {_trunc(args.get('question', ''), 30)} {dur}")
if tool_name == "mixture_of_agents":
return _wrap(f"┊ 🧠 reason {_trunc(args.get('user_prompt', ''), 30)} {dur}")
if tool_name == "send_message":
return _wrap(f"┊ 📨 send {args.get('target', '?')}: \"{_trunc(args.get('message', ''), 25)}\" {dur}")
if tool_name == "cronjob":
Expand Down
1 change: 1 addition & 0 deletions hermes_cli/tips.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@
"The todo tool helps the agent track complex multi-step tasks during a session.",
"session_search performs full-text search across ALL past conversations.",
"The agent automatically saves preferences, corrections, and environment facts to memory.",
"mixture_of_agents routes hard problems through 4 frontier LLMs collaboratively.",
"/moa routes one hard prompt through your configured Mixture of Agents model set.",
"Terminal commands support background mode with notify_on_complete for long-running tasks.",
"Terminal background processes support watch_patterns to alert on specific output lines.",
Expand Down
6 changes: 5 additions & 1 deletion hermes_cli/tools_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ def _post_setup_no_window_flags(*, streams_to_console: bool = False) -> int:
("discord_admin", "🛡️ Discord Server Admin", "list channels/roles, pin, assign roles"),
("yuanbao", "🤖 Yuanbao", "group info, member queries, DM"),
("computer_use", "🖱️ Computer Use (macOS/Windows/Linux)", "background desktop control via cua-driver"),
# Fork-custom (Kyzcreig) — preserve across upstream parity merges.
("messaging", "📨 Cross-Platform Messaging", "send_message"),
("moa", "🧠 Mixture of Agents", "mixture_of_agents"),
]


Expand Down Expand Up @@ -152,7 +155,7 @@ def gui_toolset_label(label: str) -> str:
# `hermes tools` → X (Twitter) Search setup walks users through credential
# setup. The tool's check_fn means the schema still won't appear to the
# model if the credential later goes missing or expires.
_DEFAULT_OFF_TOOLSETS = {"homeassistant", "spotify", "discord", "discord_admin", "video", "video_gen", "x_search", "a2a"}
_DEFAULT_OFF_TOOLSETS = {"homeassistant", "spotify", "discord", "discord_admin", "video", "video_gen", "x_search", "a2a", "moa"}


# Config-only capabilities: they appear in `hermes tools` for provider/API-key
Expand Down Expand Up @@ -758,6 +761,7 @@ def _checklist_toolset_keys(platform: str) -> Set[str]:
# prompted or read for vision; it's purely a presence marker.
TOOLSET_ENV_REQUIREMENTS = {
"vision": [("OPENROUTER_API_KEY", "https://openrouter.ai/keys")],
"moa": [("OPENROUTER_API_KEY", "https://openrouter.ai/keys")],
}


Expand Down
1 change: 1 addition & 0 deletions model_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ def _run_in_worker():
"web_tools": ["web_search", "web_extract"],
"terminal_tools": ["terminal"],
"vision_tools": ["vision_analyze"],
"moa_tools": ["mixture_of_agents"],
"image_tools": ["image_generate"],
"skills_tools": ["skills_list", "skill_view", "skill_manage"],
"browser_tools": [
Expand Down
133 changes: 133 additions & 0 deletions tests/agent/test_fork_custom_toolsets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Fork-custom toolset regression guard (Kyzcreig fork).

`messaging` (agent-callable ``send_message``) and `moa` (``mixture_of_agents``)
are DELIBERATE fork divergences from upstream — upstream has neither (its stance
is that agents do not get an agent-callable send_message, and it has no MoA tool).

An upstream parity merge that blindly takes upstream's ``toolsets.py`` /
``tools/`` silently DROPS them: ``resolve_toolset()`` then returns ``[]`` for the
name with no error, and every platform that lists the toolset quietly loses the
tool. That exact regression happened in the 2026-06-29 parity sync (PR #119) and
was only caught by the config-migration dry-run's post-migration toolset
validation. This test makes the invariant a HARD CI gate so the next sync can't
reintroduce it.

If this test fails after an upstream merge, you dropped a fork feature — restore
the toolset entry in ``toolsets.py`` (and, for ``moa``, the
``tools/mixture_of_agents_tool.py`` module that self-registers it), do not delete
the test.
"""

import importlib

import pytest

from toolsets import TOOLSETS, resolve_toolset, validate_toolset


class TestForkCustomToolsetsPresent:
"""The two fork-custom toolsets must exist and resolve to their tool(s)."""

def test_messaging_toolset_resolves_send_message(self):
# Upstream deliberately omits an agent-callable send_message; the fork
# re-enables it (origin-routing work #71/#83 depends on it).
assert "messaging" in TOOLSETS, "fork-custom 'messaging' toolset was dropped (likely an upstream merge)"
assert validate_toolset("messaging") is True
tools = resolve_toolset("messaging")
assert tools, "resolve_toolset('messaging') is empty — the toolset lost its tools"
assert "send_message" in tools

def test_moa_toolset_resolves_mixture_of_agents(self):
assert "moa" in TOOLSETS, "fork-custom 'moa' toolset was dropped (likely an upstream merge)"
assert validate_toolset("moa") is True
tools = resolve_toolset("moa")
assert tools, "resolve_toolset('moa') is empty — the toolset lost its tools"
assert "mixture_of_agents" in tools


class TestMixtureOfAgentsToolRegistered:
"""The moa toolset is only useful if the tool module self-registers it."""

def test_module_imports_and_self_registers(self):
# The module is auto-discovered by tools.registry.discover_builtin_tools()
# (it contains a registry.register() call). Import it directly here so the
# test is order-independent, then assert the registration landed.
#
# Guard against the editable-install live-tree leak (the very TRAP this
# whole PR is about): a deleted module can still import from a sibling
# install. Pin the module's resolved file to THIS repo's tools/ dir, so a
# phantom import from elsewhere is treated as "missing", not a pass.
import os
import toolsets as _toolsets_mod

repo_root = os.path.dirname(os.path.abspath(_toolsets_mod.__file__))
expected = os.path.join(repo_root, "tools", "mixture_of_agents_tool.py")
assert os.path.isfile(expected), (
"tools/mixture_of_agents_tool.py is missing from THIS repo — the moa "
"tool module was dropped (likely an upstream parity merge)"
)

mod = importlib.import_module("tools.mixture_of_agents_tool")
mod_file = getattr(mod, "__file__", None)
assert mod_file is not None and os.path.abspath(mod_file) == expected, (
f"tools.mixture_of_agents_tool imported from {mod_file}, not this "
f"repo ({expected}) — editable-install leak masking a dropped module"
)

from tools.registry import registry

entry = registry._tools.get("mixture_of_agents")
assert entry is not None, "tools/mixture_of_agents_tool.py no longer registers 'mixture_of_agents'"
assert entry.toolset == "moa"

def test_tool_schema_shape(self):
import os
import toolsets as _toolsets_mod

repo_root = os.path.dirname(os.path.abspath(_toolsets_mod.__file__))
expected = os.path.join(repo_root, "tools", "mixture_of_agents_tool.py")
assert os.path.isfile(expected), "tools/mixture_of_agents_tool.py missing from this repo"

importlib.import_module("tools.mixture_of_agents_tool")
from tools.registry import registry

entry = registry._tools["mixture_of_agents"]
schema = entry.schema
assert schema.get("name") == "mixture_of_agents"
# contract: takes a single required free-text prompt
params = schema.get("parameters", {})
assert "user_prompt" in params.get("properties", {})
assert "user_prompt" in params.get("required", [])


class TestForkToolsetsWiredInToolsUI:
"""`hermes tools` must surface both toolsets so a user can toggle them, and
moa must declare its OpenRouter credential requirement."""

def test_tools_config_rows_and_env(self):
from hermes_cli import tools_config

# CONFIGURABLE_TOOLSETS rows is a list of (key, label, desc) tuples.
keys = {row[0] for row in tools_config.CONFIGURABLE_TOOLSETS}
assert "messaging" in keys, "'messaging' missing from the `hermes tools` UI list"
assert "moa" in keys, "'moa' missing from the `hermes tools` UI list"

# moa is an opt-in (default-off) toolset that needs OPENROUTER_API_KEY.
assert "moa" in tools_config._DEFAULT_OFF_TOOLSETS
env_req = tools_config.TOOLSET_ENV_REQUIREMENTS.get("moa", [])
assert any(name == "OPENROUTER_API_KEY" for name, _url in env_req), \
"moa must declare its OPENROUTER_API_KEY requirement"


def test_send_message_and_moa_not_silently_zero():
"""The headline invariant: neither fork toolset may resolve to zero tools.

A zero-tool resolve is exactly the silent-drop failure mode (resolve_toolset
returns [] for an unknown/empty toolset with no error). Assert it can't happen
for either fork-custom toolset.
"""
for name in ("messaging", "moa"):
assert len(resolve_toolset(name)) >= 1, (
f"fork-custom toolset {name!r} resolves to 0 tools — it was dropped, "
"probably by an upstream parity merge taking upstream's toolsets.py"
)
Loading
Loading