diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ada14897..b337ae16 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1094,11 +1094,36 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: return [node, str(root / "dist" / "entry.js")], root +def _normalize_tui_toolsets(toolsets: object) -> list[str]: + """Normalize argparse/Fire-style toolset input for the TUI subprocess.""" + try: + from hermes_cli.oneshot import _normalize_toolsets + + return _normalize_toolsets(toolsets) or [] + except (AttributeError, ImportError): + if not toolsets: + return [] + + raw_items = [toolsets] if isinstance(toolsets, str) else toolsets + if not isinstance(raw_items, (list, tuple)): + raw_items = [raw_items] + + normalized: list[str] = [] + for item in raw_items: + if isinstance(item, str): + normalized.extend(part.strip() for part in item.split(",")) + else: + normalized.append(str(item).strip()) + + return [item for item in normalized if item] + + def _launch_tui( resume_session_id: Optional[str] = None, tui_dev: bool = False, model: Optional[str] = None, provider: Optional[str] = None, + toolsets: object = None, ): """Replace current process with the TUI.""" tui_dir = PROJECT_ROOT / "ui-tui" @@ -1123,6 +1148,9 @@ def _launch_tui( if provider: env["HERMES_TUI_PROVIDER"] = provider env["HERMES_INFERENCE_PROVIDER"] = provider + tui_toolsets = _normalize_tui_toolsets(toolsets) + if tui_toolsets: + env["HERMES_TUI_TOOLSETS"] = ",".join(tui_toolsets) # Guarantee an 8GB V8 heap + exposed GC for the TUI. Default node cap is # ~1.5–4GB depending on version and can fatal-OOM on long sessions with # large transcripts / reasoning blobs. Token-level merge: respect any @@ -1270,6 +1298,7 @@ def cmd_chat(args): tui_dev=getattr(args, "tui_dev", False), model=getattr(args, "model", None), provider=getattr(args, "provider", None), + toolsets=getattr(args, "toolsets", None), ) # Import and run the CLI @@ -7887,6 +7916,12 @@ For more help on a command: "Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_PROVIDER env var." ), ) + parser.add_argument( + "-t", + "--toolsets", + default=None, + help="Comma-separated toolsets to enable for this invocation. Applies to -z/--oneshot and --tui.", + ) parser.add_argument( "--resume", "-r", @@ -10327,6 +10362,7 @@ Examples: args.oneshot, model=getattr(args, "model", None), provider=getattr(args, "provider", None), + toolsets=getattr(args, "toolsets", None), )) # Handle top-level --resume / --continue as shortcut to chat diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index e1065b66..ca30f079 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -3,7 +3,8 @@ Bypasses cli.py entirely. No banner, no spinner, no session_id line, no stderr chatter. Just the agent's final text to stdout. -Toolsets = whatever the user has configured for "cli" in `hermes tools`. +Toolsets = explicit --toolsets when provided, otherwise whatever the user has +configured for "cli" in `hermes tools`. Rules / memory / AGENTS.md / preloaded skills = same as a normal chat turn. Approvals = auto-bypassed (HERMES_YOLO_MODE=1 is set for the call). Working directory = the user's CWD (AGENTS.md etc. resolve from there as usual). @@ -28,10 +29,103 @@ from contextlib import redirect_stderr, redirect_stdout from typing import Optional +def _normalize_toolsets(toolsets: object = None) -> list[str] | None: + if not toolsets: + return None + + raw_items = [toolsets] if isinstance(toolsets, str) else toolsets + if not isinstance(raw_items, (list, tuple)): + raw_items = [raw_items] + + normalized: list[str] = [] + for item in raw_items: + if isinstance(item, str): + normalized.extend(part.strip() for part in item.split(",")) + else: + normalized.append(str(item).strip()) + + return [item for item in normalized if item] or None + + +def _validate_explicit_toolsets(toolsets: object = None) -> tuple[list[str] | None, str | None]: + normalized = _normalize_toolsets(toolsets) + if normalized is None: + return None, None + + try: + from toolsets import validate_toolset + except Exception as exc: + return None, f"hermes -z: failed to validate --toolsets: {exc}\n" + + built_in = [name for name in normalized if validate_toolset(name)] + unresolved = [name for name in normalized if name not in built_in] + + if unresolved: + try: + from hermes_cli.plugins import discover_plugins + + discover_plugins() + plugin_valid = [name for name in unresolved if validate_toolset(name)] + except Exception: + plugin_valid = [] + + if plugin_valid: + built_in.extend(plugin_valid) + unresolved = [name for name in unresolved if name not in plugin_valid] + + if any(name in {"all", "*"} for name in built_in): + ignored = [name for name in normalized if name not in {"all", "*"}] + if ignored: + sys.stderr.write( + "hermes -z: --toolsets all enables every toolset; " + f"ignoring additional entries: {', '.join(ignored)}\n" + ) + return None, None + + mcp_names: set[str] = set() + mcp_disabled: set[str] = set() + if unresolved: + try: + from hermes_cli.config import read_raw_config + from hermes_cli.tools_config import _parse_enabled_flag + + cfg = read_raw_config() + mcp_servers = cfg.get("mcp_servers") if isinstance(cfg.get("mcp_servers"), dict) else {} + for name, server_cfg in mcp_servers.items(): + if not isinstance(server_cfg, dict): + continue + if _parse_enabled_flag(server_cfg.get("enabled", True), default=True): + mcp_names.add(str(name)) + else: + mcp_disabled.add(str(name)) + except Exception: + mcp_names = set() + mcp_disabled = set() + + mcp_valid = [name for name in unresolved if name in mcp_names] + disabled = [name for name in unresolved if name in mcp_disabled] + unknown = [name for name in unresolved if name not in mcp_names and name not in mcp_disabled] + valid = built_in + mcp_valid + + if unknown: + sys.stderr.write(f"hermes -z: ignoring unknown --toolsets entries: {', '.join(unknown)}\n") + if disabled: + sys.stderr.write( + "hermes -z: ignoring disabled MCP servers (set enabled: true in config.yaml to use): " + f"{', '.join(disabled)}\n" + ) + + if not valid: + return None, "hermes -z: --toolsets did not contain any valid toolsets.\n" + + return valid, None + + def run_oneshot( prompt: str, model: Optional[str] = None, provider: Optional[str] = None, + toolsets: object = None, ) -> int: """Execute a single prompt and print only the final content block. @@ -42,6 +136,7 @@ def run_oneshot( provider: Optional provider override. Falls back to HERMES_INFERENCE_PROVIDER env var, then config.yaml's model.provider, then "auto". + toolsets: Optional comma-separated string or iterable of toolsets. Returns the exit code. Caller should sys.exit() with the return. """ @@ -65,6 +160,12 @@ def run_oneshot( ) return 2 + explicit_toolsets, toolsets_error = _validate_explicit_toolsets(toolsets) + if toolsets_error: + sys.stderr.write(toolsets_error) + return 2 + use_config_toolsets = _normalize_toolsets(toolsets) is None + # Auto-approve any shell / tool approvals. Non-interactive by # definition — a prompt would hang forever. os.environ["HERMES_YOLO_MODE"] = "1" @@ -77,7 +178,13 @@ def run_oneshot( try: with redirect_stdout(devnull), redirect_stderr(devnull): - response = _run_agent(prompt, model=model, provider=provider) + response = _run_agent( + prompt, + model=model, + provider=provider, + toolsets=explicit_toolsets, + use_config_toolsets=use_config_toolsets, + ) finally: try: devnull.close() @@ -96,6 +203,8 @@ def _run_agent( prompt: str, model: Optional[str] = None, provider: Optional[str] = None, + toolsets: object = None, + use_config_toolsets: bool = True, ) -> str: """Build an AIAgent exactly like a normal CLI chat turn would, then run a single conversation. Returns the final response string.""" @@ -168,9 +277,12 @@ def _run_agent( explicit_base_url=explicit_base_url_from_alias, ) - # Pull in whatever toolsets the user has enabled for "cli". - # sorted() gives stable ordering; set→list for AIAgent's signature. - toolsets_list = sorted(_get_platform_tools(cfg, "cli")) + # Pull in explicit toolsets when provided; otherwise use whatever the user + # has enabled for "cli". sorted() gives stable ordering for config-derived + # sets; explicit values preserve user order. + toolsets_list = _normalize_toolsets(toolsets) + if toolsets_list is None and use_config_toolsets: + toolsets_list = sorted(_get_platform_tools(cfg, "cli")) agent = AIAgent( api_key=runtime.get("api_key"), diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index eb927498..8086ee87 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -12,6 +12,7 @@ def _args(**overrides): "model": None, "provider": None, "resume": None, + "toolsets": None, "tui": True, "tui_dev": False, } @@ -35,7 +36,7 @@ def test_cmd_chat_tui_continue_uses_latest_tui_session(monkeypatch, main_mod): calls.append(source) return "20260408_235959_a1b2c3" if source == "tui" else None - def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None): + def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None): captured["resume"] = resume_session_id raise SystemExit(0) @@ -62,7 +63,7 @@ def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, mai return "20260408_235959_d4e5f6" return None - def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None): + def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None): captured["resume"] = resume_session_id raise SystemExit(0) @@ -80,7 +81,7 @@ def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, mai def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod): captured = {} - def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None): + def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None): captured["resume"] = resume_session_id raise SystemExit(0) @@ -98,12 +99,13 @@ def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod) def test_cmd_chat_tui_passes_model_and_provider(monkeypatch, main_mod): captured = {} - def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None): + def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None): captured.update( { "model": model, "provider": provider, "resume": resume_session_id, + "toolsets": toolsets, "tui_dev": tui_dev, } ) @@ -120,11 +122,193 @@ def test_cmd_chat_tui_passes_model_and_provider(monkeypatch, main_mod): "model": "anthropic/claude-sonnet-4.6", "provider": "anthropic", "resume": None, + "toolsets": None, "tui_dev": False, } -def test_launch_tui_exports_model_and_provider(monkeypatch, main_mod): +def test_cmd_chat_tui_passes_toolsets(monkeypatch, main_mod): + captured = {} + + def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None): + captured["toolsets"] = toolsets + raise SystemExit(0) + + monkeypatch.setattr(main_mod, "_launch_tui", fake_launch) + + with pytest.raises(SystemExit): + main_mod.cmd_chat(_args(toolsets="web,terminal")) + + assert captured["toolsets"] == "web,terminal" + + +def test_main_top_level_tui_accepts_toolsets(monkeypatch, main_mod): + captured = {} + + import hermes_cli.config as config_mod + + monkeypatch.setattr(sys, "argv", ["hermes", "--tui", "--toolsets", "web,terminal"]) + monkeypatch.setitem(sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None)) + monkeypatch.setitem(sys.modules, "tools.mcp_tool", types.SimpleNamespace(discover_mcp_tools=lambda: None)) + monkeypatch.setattr(config_mod, "load_config", lambda: {}) + monkeypatch.setattr(config_mod, "get_container_exec_info", lambda: None) + monkeypatch.setitem( + sys.modules, + "agent.shell_hooks", + types.SimpleNamespace(register_from_config=lambda _cfg, accept_hooks=False: None), + ) + monkeypatch.setattr(main_mod, "cmd_chat", lambda args: captured.update({"toolsets": args.toolsets, "tui": args.tui})) + + main_mod.main() + + assert captured == {"toolsets": "web,terminal", "tui": True} + + +def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod): + captured = {} + + import hermes_cli.config as config_mod + + monkeypatch.setattr(sys, "argv", ["hermes", "-z", "hello", "--toolsets", "web,terminal"]) + monkeypatch.setitem(sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None)) + monkeypatch.setitem(sys.modules, "tools.mcp_tool", types.SimpleNamespace(discover_mcp_tools=lambda: None)) + monkeypatch.setattr(config_mod, "load_config", lambda: {}) + monkeypatch.setattr(config_mod, "get_container_exec_info", lambda: None) + monkeypatch.setitem( + sys.modules, + "agent.shell_hooks", + types.SimpleNamespace(register_from_config=lambda _cfg, accept_hooks=False: None), + ) + monkeypatch.setitem( + sys.modules, + "hermes_cli.oneshot", + types.SimpleNamespace(run_oneshot=lambda prompt, **kwargs: captured.update({"prompt": prompt, **kwargs}) or 0), + ) + + with pytest.raises(SystemExit) as exc: + main_mod.main() + + assert exc.value.code == 0 + assert captured == {"prompt": "hello", "model": None, "provider": None, "toolsets": "web,terminal"} + + +def _stub_plugin_discovery(monkeypatch): + monkeypatch.setitem( + sys.modules, + "hermes_cli.plugins", + types.SimpleNamespace(discover_plugins=lambda: None), + ) + + +def test_oneshot_rejects_invalid_only_toolsets(monkeypatch, capsys): + _stub_plugin_discovery(monkeypatch) + from hermes_cli.oneshot import run_oneshot + + assert run_oneshot("hello", toolsets="nope") == 2 + err = capsys.readouterr().err + assert "nope" in err + assert "did not contain any valid toolsets" in err + + +def test_oneshot_filters_invalid_toolsets_before_redirect(monkeypatch, capsys): + _stub_plugin_discovery(monkeypatch) + from hermes_cli.oneshot import _validate_explicit_toolsets + + valid, error = _validate_explicit_toolsets("web,nope") + + assert valid == ["web"] + assert error is None + assert "nope" in capsys.readouterr().err + + +def test_oneshot_all_toolsets_means_all_not_configured_cli(): + from hermes_cli.oneshot import _validate_explicit_toolsets + + valid, error = _validate_explicit_toolsets("all") + + assert valid is None + assert error is None + + +def test_oneshot_all_toolsets_warns_about_ignored_extra_entries(monkeypatch, capsys): + _stub_plugin_discovery(monkeypatch) + from hermes_cli.oneshot import _validate_explicit_toolsets + + valid, error = _validate_explicit_toolsets("all,nope") + + assert valid is None + assert error is None + assert "ignoring additional entries: nope" in capsys.readouterr().err + + +def test_oneshot_accepts_plugin_toolset_after_discovery(monkeypatch): + import toolsets + + from hermes_cli.oneshot import _validate_explicit_toolsets + + discovered = {"ready": False} + original_validate = toolsets.validate_toolset + + def fake_validate(name): + return name == "plugin_demo" and discovered["ready"] or original_validate(name) + + monkeypatch.setattr(toolsets, "validate_toolset", fake_validate) + monkeypatch.setitem( + sys.modules, + "hermes_cli.plugins", + types.SimpleNamespace(discover_plugins=lambda: discovered.update({"ready": True})), + ) + + valid, error = _validate_explicit_toolsets("plugin_demo") + + assert valid == ["plugin_demo"] + assert error is None + + +def test_oneshot_rejects_disabled_mcp_toolset(monkeypatch, capsys): + _stub_plugin_discovery(monkeypatch) + import hermes_cli.config as config_mod + + from hermes_cli.oneshot import _validate_explicit_toolsets + + monkeypatch.setattr( + config_mod, + "read_raw_config", + lambda: {"mcp_servers": {"mcp-off": {"enabled": False}}}, + ) + + valid, error = _validate_explicit_toolsets("mcp-off") + + assert valid is None + assert error == "hermes -z: --toolsets did not contain any valid toolsets.\n" + err = capsys.readouterr().err + assert "ignoring disabled MCP servers" in err + assert "mcp-off" in err + + +def test_oneshot_distinguishes_disabled_mcp_from_unknown(monkeypatch, capsys): + _stub_plugin_discovery(monkeypatch) + import hermes_cli.config as config_mod + + from hermes_cli.oneshot import _validate_explicit_toolsets + + monkeypatch.setattr( + config_mod, + "read_raw_config", + lambda: {"mcp_servers": {"mcp-off": {"enabled": False}}}, + ) + + valid, error = _validate_explicit_toolsets("web,mcp-off,nope") + + assert valid == ["web"] + assert error is None + err = capsys.readouterr().err + assert "ignoring unknown --toolsets entries: nope" in err + assert "ignoring disabled MCP servers" in err + assert "mcp-off" in err + + +def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod): captured = {} active_path_during_call = None @@ -144,13 +328,14 @@ def test_launch_tui_exports_model_and_provider(monkeypatch, main_mod): monkeypatch.setattr(main_mod.subprocess, "call", fake_call) with pytest.raises(SystemExit): - main_mod._launch_tui(model="nous/hermes-test", provider="nous") + main_mod._launch_tui(model="nous/hermes-test", provider="nous", toolsets="web, terminal") env = captured["env"] assert env["HERMES_MODEL"] == "nous/hermes-test" assert env["HERMES_INFERENCE_MODEL"] == "nous/hermes-test" assert env["HERMES_TUI_PROVIDER"] == "nous" assert env["HERMES_INFERENCE_PROVIDER"] == "nous" + assert env["HERMES_TUI_TOOLSETS"] == "web,terminal" active_path = Path(env["HERMES_TUI_ACTIVE_SESSION_FILE"]) assert active_path.name.startswith("hermes-tui-active-session-") assert active_path.suffix == ".json" diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index a4ee993d..24c37c14 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -59,6 +59,147 @@ def test_write_json_returns_false_on_broken_pipe(monkeypatch): assert server.write_json({"ok": True}) is False +def test_load_enabled_toolsets_prefers_tui_env(monkeypatch): + monkeypatch.setenv("HERMES_TUI_TOOLSETS", "web, terminal, ,memory") + + assert server._load_enabled_toolsets() == ["web", "terminal", "memory"] + + +def test_load_enabled_toolsets_filters_invalid_tui_env(monkeypatch, capsys): + monkeypatch.setenv("HERMES_TUI_TOOLSETS", "web, nope") + monkeypatch.setitem( + sys.modules, + "hermes_cli.plugins", + types.SimpleNamespace(discover_plugins=lambda: None), + ) + + assert server._load_enabled_toolsets() == ["web"] + assert "nope" in capsys.readouterr().err + + +def test_load_enabled_toolsets_accepts_plugin_env_after_discovery(monkeypatch): + monkeypatch.setenv("HERMES_TUI_TOOLSETS", "plugin_demo") + + import toolsets + + discovered = {"ready": False} + original_validate = toolsets.validate_toolset + + def fake_validate(name): + return name == "plugin_demo" and discovered["ready"] or original_validate(name) + + monkeypatch.setattr(toolsets, "validate_toolset", fake_validate) + monkeypatch.setitem( + sys.modules, + "hermes_cli.plugins", + types.SimpleNamespace(discover_plugins=lambda: discovered.update({"ready": True})), + ) + + assert server._load_enabled_toolsets() == ["plugin_demo"] + + +def test_load_enabled_toolsets_rejects_disabled_mcp_env(monkeypatch, capsys): + monkeypatch.setenv("HERMES_TUI_TOOLSETS", "mcp-off") + monkeypatch.setitem( + sys.modules, + "hermes_cli.plugins", + types.SimpleNamespace(discover_plugins=lambda: None), + ) + + import hermes_cli.config as config_mod + + monkeypatch.setattr( + config_mod, + "read_raw_config", + lambda: {"mcp_servers": {"mcp-off": {"enabled": False}}}, + ) + monkeypatch.setattr(config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}}) + + assert server._load_enabled_toolsets() == ["memory"] + err = capsys.readouterr().err + assert "ignoring disabled MCP servers" in err + assert "mcp-off" in err + assert "using configured CLI toolsets" in err + + +def test_load_enabled_toolsets_falls_back_when_tui_env_invalid(monkeypatch, capsys): + monkeypatch.setenv("HERMES_TUI_TOOLSETS", "nope") + monkeypatch.setitem( + sys.modules, + "hermes_cli.plugins", + types.SimpleNamespace(discover_plugins=lambda: None), + ) + + import hermes_cli.config as config_mod + + monkeypatch.setattr(config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}}) + + assert server._load_enabled_toolsets() == ["memory"] + assert "using configured CLI toolsets" in capsys.readouterr().err + + +def test_load_enabled_toolsets_warns_when_config_fallback_fails(monkeypatch, capsys): + monkeypatch.setenv("HERMES_TUI_TOOLSETS", "nope") + monkeypatch.setitem( + sys.modules, + "hermes_cli.plugins", + types.SimpleNamespace(discover_plugins=lambda: None), + ) + + import hermes_cli.config as config_mod + + monkeypatch.setattr(config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom"))) + + assert server._load_enabled_toolsets() is None + assert "could not be loaded" in capsys.readouterr().err + + +def test_load_enabled_toolsets_honors_builtin_env_if_config_fails(monkeypatch): + monkeypatch.setenv("HERMES_TUI_TOOLSETS", "web") + + import hermes_cli.config as config_mod + + monkeypatch.setattr(config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom"))) + + assert server._load_enabled_toolsets() == ["web"] + + +def test_load_enabled_toolsets_all_env_means_all(monkeypatch): + monkeypatch.setenv("HERMES_TUI_TOOLSETS", "all") + + assert server._load_enabled_toolsets() is None + + +def test_load_enabled_toolsets_all_env_warns_about_ignored_extra_entries(monkeypatch, capsys): + monkeypatch.setenv("HERMES_TUI_TOOLSETS", "all,nope") + + assert server._load_enabled_toolsets() is None + assert "ignoring additional entries: nope" in capsys.readouterr().err + + +def test_load_enabled_toolsets_reports_disabled_mcp_separately(monkeypatch, capsys): + monkeypatch.setenv("HERMES_TUI_TOOLSETS", "web,mcp-off,nope") + monkeypatch.setitem( + sys.modules, + "hermes_cli.plugins", + types.SimpleNamespace(discover_plugins=lambda: None), + ) + + import hermes_cli.config as config_mod + + monkeypatch.setattr( + config_mod, + "read_raw_config", + lambda: {"mcp_servers": {"mcp-off": {"enabled": False}}}, + ) + + assert server._load_enabled_toolsets() == ["web"] + err = capsys.readouterr().err + assert "ignoring unknown HERMES_TUI_TOOLSETS entries: nope" in err + assert "ignoring disabled MCP servers" in err + assert "mcp-off" in err + + def test_history_to_messages_preserves_tool_calls_for_resume_display(): history = [ {"role": "user", "content": "first prompt"}, diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7eae9e7f..c3ef5bb5 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -861,10 +861,100 @@ def _load_tool_progress_mode() -> str: def _load_enabled_toolsets() -> list[str] | None: + explicit = [ + item.strip() + for item in os.environ.get("HERMES_TUI_TOOLSETS", "").split(",") + if item.strip() + ] + cfg = None + fallback_notice = None + + try: + from toolsets import validate_toolset + except Exception: + validate_toolset = None + + if explicit and validate_toolset is not None: + built_in = [name for name in explicit if validate_toolset(name)] + unresolved = [name for name in explicit if name not in built_in] + + if unresolved: + try: + from hermes_cli.plugins import discover_plugins + + discover_plugins() + plugin_valid = [name for name in unresolved if validate_toolset(name)] + except Exception: + plugin_valid = [] + + if plugin_valid: + built_in.extend(plugin_valid) + unresolved = [name for name in unresolved if name not in plugin_valid] + + if any(name in {"all", "*"} for name in built_in): + ignored = [name for name in explicit if name not in {"all", "*"}] + if ignored: + print( + "[tui] HERMES_TUI_TOOLSETS=all enables every toolset; " + f"ignoring additional entries: {', '.join(ignored)}", + file=sys.stderr, + flush=True, + ) + return None + + if not unresolved: + return built_in + + mcp_names: set[str] = set() + mcp_disabled: set[str] = set() + try: + from hermes_cli.config import read_raw_config + from hermes_cli.tools_config import _parse_enabled_flag + + raw_cfg = read_raw_config() + mcp_servers = raw_cfg.get("mcp_servers") if isinstance(raw_cfg.get("mcp_servers"), dict) else {} + for name, server_cfg in mcp_servers.items(): + if not isinstance(server_cfg, dict): + continue + if _parse_enabled_flag(server_cfg.get("enabled", True), default=True): + mcp_names.add(str(name)) + else: + mcp_disabled.add(str(name)) + except Exception: + mcp_names = set() + mcp_disabled = set() + + mcp_valid = [name for name in unresolved if name in mcp_names] + disabled = [name for name in unresolved if name in mcp_disabled] + unknown = [name for name in unresolved if name not in mcp_names and name not in mcp_disabled] + valid = built_in + mcp_valid + + if unknown: + print( + f"[tui] ignoring unknown HERMES_TUI_TOOLSETS entries: {', '.join(unknown)}", + file=sys.stderr, + flush=True, + ) + if disabled: + print( + "[tui] ignoring disabled MCP servers in HERMES_TUI_TOOLSETS " + "(set enabled: true in config.yaml to use): " + f"{', '.join(disabled)}", + file=sys.stderr, + flush=True, + ) + + if valid: + return valid + + fallback_notice = "[tui] no valid HERMES_TUI_TOOLSETS entries; using configured CLI toolsets" + try: from hermes_cli.config import load_config from hermes_cli.tools_config import _get_platform_tools + cfg = cfg if cfg is not None else load_config() + # Runtime toolset resolution must include default MCP servers so the # agent can actually call them. Passing ``False`` here is the # config-editing variant — used when we need to persist a toolset @@ -872,10 +962,18 @@ def _load_enabled_toolsets() -> list[str] | None: # variant at agent creation time makes MCP tools silently missing # from the TUI. See PR #3252 for the original design split. enabled = sorted( - _get_platform_tools(load_config(), "cli", include_default_mcp_servers=True) + _get_platform_tools(cfg, "cli", include_default_mcp_servers=True) ) + if fallback_notice is not None: + print(fallback_notice, file=sys.stderr, flush=True) return enabled or None except Exception: + if fallback_notice is not None: + print( + "[tui] no valid HERMES_TUI_TOOLSETS entries and configured CLI toolsets could not be loaded; enabling all toolsets", + file=sys.stderr, + flush=True, + ) return None