diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 952a09ef99f..62f9f40e7a6 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -575,6 +575,18 @@ def build_welcome_banner(console: "Console", model: str, cwd: str, enabled_toolsets = enabled_toolsets or [] _, unavailable_toolsets = check_tool_availability(quiet=True) + # The availability check walks the GLOBAL toolset registry, so it includes + # toolsets that aren't part of this agent's platform set at all (e.g. + # `discord`, `feishu_doc` on a CLI session). Those must never surface in the + # banner's "Available Tools" — they aren't exposed to the agent. Restrict to + # toolsets actually enabled for this agent; a toolset that's enabled but + # currently has unmet deps legitimately shows as disabled/lazy below. + _enabled_ts = {str(t) for t in enabled_toolsets} + if _enabled_ts: + unavailable_toolsets = [ + item for item in unavailable_toolsets + if str(item.get("id", item.get("name", ""))) in _enabled_ts + ] disabled_tools = set() # Tools whose toolset has a check_fn are lazy-initialized (e.g. honcho, # homeassistant) — they show as unavailable at banner time because the @@ -722,10 +734,21 @@ def build_welcome_banner(console: "Console", model: str, cwd: str, right_lines.append("") right_lines.append(f"[bold {accent}]Available Skills[/]") - skills_by_category = get_available_skills() - total_skills = sum(len(s) for s in skills_by_category.values()) + # The skills catalog is only reachable when the `skills` toolset is enabled + # (it exposes skill_view / skill_manage). When it's disabled — e.g. a Blank + # Slate install — the agent literally cannot load any skill, so advertising + # the on-disk catalog here is misleading. Reflect the real state instead. + _skills_enabled = (not _enabled_ts) or ("skills" in _enabled_ts) + if _skills_enabled: + skills_by_category = get_available_skills() + total_skills = sum(len(s) for s in skills_by_category.values()) + else: + skills_by_category = {} + total_skills = 0 - if skills_by_category: + if not _skills_enabled: + right_lines.append(f"[dim {dim}]Skills toolset disabled[/]") + elif skills_by_category: for category in sorted(skills_by_category.keys()): skill_names = sorted(skills_by_category[category]) if len(skill_names) > 8: diff --git a/tests/hermes_cli/test_banner.py b/tests/hermes_cli/test_banner.py index 9afff8f5883..ec179cdb7e4 100644 --- a/tests/hermes_cli/test_banner.py +++ b/tests/hermes_cli/test_banner.py @@ -200,3 +200,81 @@ def test_build_welcome_banner_configured_mcp_is_not_failed(): assert "docker-profile" in output assert "configured" in output assert "failed" not in output + + +def test_banner_hides_toolsets_not_enabled_for_platform(): + """A globally-registered toolset that isn't enabled for this agent (e.g. + discord / feishu on a CLI session) must NOT appear in 'Available Tools'. + + Regression: check_tool_availability() walks the global registry, so the + banner used to merge in every unavailable toolset regardless of whether it + was part of this platform's set. On a Blank Slate CLI (file + terminal only) + that surfaced discord/feishu tools the agent was never given. + """ + with ( + patch.object( + model_tools, + "check_tool_availability", + return_value=( + ["file", "terminal"], + [ + {"name": "discord", "tools": ["discord_fetch_messages"]}, + {"name": "feishu_doc", "tools": ["feishu_doc_read"]}, + ], + ), + ), + patch.object(banner, "get_available_skills", return_value={}), + patch.object(banner, "get_update_result", return_value=None), + patch.object(tools.mcp_tool, "get_mcp_status", return_value=[]), + ): + console = Console(record=True, force_terminal=False, color_system=None, width=160) + banner.build_welcome_banner( + console=console, + model="anthropic/test-model", + cwd="/tmp/project", + tools=[{"function": {"name": "read_file"}}], + enabled_toolsets=["file", "terminal"], + get_toolset_for_tool=lambda n: "file", + ) + + output = console.export_text() + assert "discord" not in output + assert "feishu" not in output + + +def test_banner_skills_section_reflects_disabled_skills_toolset(): + """When the `skills` toolset is disabled (Blank Slate), the banner must not + advertise the on-disk skill catalog — the agent can't load any of them.""" + fake_skills = {"creative": ["ascii-art", "p5js"], "devops": ["bug-triage-work"]} + + # skills toolset DISABLED -> catalog hidden, "disabled" message shown + with ( + patch.object(model_tools, "check_tool_availability", return_value=(["file", "terminal"], [])), + patch.object(banner, "get_available_skills", return_value=fake_skills), + patch.object(banner, "get_update_result", return_value=None), + patch.object(tools.mcp_tool, "get_mcp_status", return_value=[]), + ): + console = Console(record=True, force_terminal=False, color_system=None, width=160) + banner.build_welcome_banner( + console=console, model="m", cwd="/tmp", tools=[{"function": {"name": "read_file"}}], + enabled_toolsets=["file", "terminal"], get_toolset_for_tool=lambda n: "file", + ) + out_disabled = console.export_text() + assert "Skills toolset disabled" in out_disabled + assert "ascii-art" not in out_disabled + + # skills toolset ENABLED -> catalog listed as before + with ( + patch.object(model_tools, "check_tool_availability", return_value=(["file", "terminal", "skills"], [])), + patch.object(banner, "get_available_skills", return_value=fake_skills), + patch.object(banner, "get_update_result", return_value=None), + patch.object(tools.mcp_tool, "get_mcp_status", return_value=[]), + ): + console = Console(record=True, force_terminal=False, color_system=None, width=160) + banner.build_welcome_banner( + console=console, model="m", cwd="/tmp", tools=[{"function": {"name": "read_file"}}], + enabled_toolsets=["file", "terminal", "skills"], get_toolset_for_tool=lambda n: "file", + ) + out_enabled = console.export_text() + assert "Skills toolset disabled" not in out_enabled + assert "ascii-art" in out_enabled