diff --git a/hermes_cli/config.py b/hermes_cli/config.py index b0e14f2a0d..4c874f7012 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1625,11 +1625,11 @@ def show_config(): print(f" Timeout: {terminal.get('timeout', 60)}s") if terminal.get('backend') == 'docker': - print(f" Docker image: {terminal.get('docker_image', 'python:3.11-slim')}") + print(f" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}") elif terminal.get('backend') == 'singularity': - print(f" Image: {terminal.get('singularity_image', 'docker://python:3.11')}") + print(f" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}") elif terminal.get('backend') == 'modal': - print(f" Modal image: {terminal.get('modal_image', 'python:3.11')}") + print(f" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}") modal_token = get_env_value('MODAL_TOKEN_ID') print(f" Modal token: {'configured' if modal_token else '(not set)'}") elif terminal.get('backend') == 'daytona': diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 2c14f0ed7e..5e27535a0b 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -23,12 +23,6 @@ Tool registration ----------------- ``PluginContext.register_tool()`` delegates to ``tools.registry.register()`` so plugin-defined tools appear alongside the built-in tools. - -Slash command registration --------------------------- -``PluginContext.register_command()`` adds a slash command to the central -``COMMAND_REGISTRY`` so it appears in /help, autocomplete, and gateway -dispatch. Handlers receive the argument string and return a response. """ from __future__ import annotations @@ -101,7 +95,6 @@ class LoadedPlugin: module: Optional[types.ModuleType] = None tools_registered: List[str] = field(default_factory=list) hooks_registered: List[str] = field(default_factory=list) - commands_registered: List[str] = field(default_factory=list) enabled: bool = False error: Optional[str] = None @@ -148,45 +141,6 @@ class PluginContext: self._manager._plugin_tool_names.add(name) logger.debug("Plugin %s registered tool: %s", self.manifest.name, name) - # -- command registration ------------------------------------------------ - - def register_command( - self, - name: str, - handler: Callable, - description: str = "", - aliases: tuple[str, ...] = (), - args_hint: str = "", - cli_only: bool = False, - gateway_only: bool = False, - ) -> None: - """Register a slash command in the central command registry. - - The *handler* is called with a single ``args`` string (everything - after the command name) and should return a string to display to the - user, or ``None`` for no output. Async handlers are also supported - (they will be awaited in the gateway). - - The command automatically appears in ``/help``, tab-autocomplete, - Telegram bot menu, Slack subcommand mapping, and gateway dispatch. - """ - from hermes_cli.commands import CommandDef, register_plugin_command - - cmd_def = CommandDef( - name=name, - description=description or f"Plugin command: {name}", - category="Plugins", - aliases=aliases, - args_hint=args_hint, - cli_only=cli_only, - gateway_only=gateway_only, - ) - register_plugin_command(cmd_def) - self._manager._plugin_commands[name] = handler - for alias in aliases: - self._manager._plugin_commands[alias] = handler - logger.debug("Plugin %s registered command: /%s", self.manifest.name, name) - # -- hook registration -------------------------------------------------- def register_hook(self, hook_name: str, callback: Callable) -> None: @@ -218,7 +172,6 @@ class PluginManager: self._plugins: Dict[str, LoadedPlugin] = {} self._hooks: Dict[str, List[Callable]] = {} self._plugin_tool_names: Set[str] = set() - self._plugin_commands: Dict[str, Callable] = {} self._discovered: bool = False # ----------------------------------------------------------------------- @@ -372,14 +325,6 @@ class PluginManager: for h in p.hooks_registered } ) - loaded.commands_registered = [ - c for c in self._plugin_commands - if c not in { - n - for name, p in self._plugins.items() - for n in p.commands_registered - } - ] loaded.enabled = True except Exception as exc: @@ -475,7 +420,6 @@ class PluginManager: "enabled": loaded.enabled, "tools": len(loaded.tools_registered), "hooks": len(loaded.hooks_registered), - "commands": len(loaded.commands_registered), "error": loaded.error, } ) @@ -512,6 +456,46 @@ def get_plugin_tool_names() -> Set[str]: return get_plugin_manager()._plugin_tool_names -def get_plugin_command_handler(name: str) -> Optional[Callable]: - """Return the handler for a plugin-registered slash command, or None.""" - return get_plugin_manager()._plugin_commands.get(name) +def get_plugin_toolsets() -> List[tuple]: + """Return plugin toolsets as ``(key, label, description)`` tuples. + + Used by the ``hermes tools`` TUI so plugin-provided toolsets appear + alongside the built-in ones and can be toggled on/off per platform. + """ + manager = get_plugin_manager() + if not manager._plugin_tool_names: + return [] + + try: + from tools.registry import registry + except Exception: + return [] + + # Group plugin tool names by their toolset + toolset_tools: Dict[str, List[str]] = {} + toolset_plugin: Dict[str, LoadedPlugin] = {} + for tool_name in manager._plugin_tool_names: + entry = registry._tools.get(tool_name) + if not entry: + continue + ts = entry.toolset + toolset_tools.setdefault(ts, []).append(entry.name) + + # Map toolsets back to the plugin that registered them + for _name, loaded in manager._plugins.items(): + for tool_name in loaded.tools_registered: + entry = registry._tools.get(tool_name) + if entry and entry.toolset in toolset_tools: + toolset_plugin.setdefault(entry.toolset, loaded) + + result = [] + for ts_key in sorted(toolset_tools): + plugin = toolset_plugin.get(ts_key) + label = f"🔌 {ts_key.replace('_', ' ').title()}" + if plugin and plugin.manifest.description: + desc = plugin.manifest.description + else: + desc = ", ".join(sorted(toolset_tools[ts_key])) + result.append((ts_key, label, desc)) + + return result diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 09aefa436b..478a6acd5f 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -4,9 +4,9 @@ Interactive setup wizard for Hermes Agent. Modular wizard with independently-runnable sections: 1. Model & Provider — choose your AI provider and model 2. Terminal Backend — where your agent runs commands - 3. Messaging Platforms — connect Telegram, Discord, etc. - 4. Tools — configure TTS, web search, image generation, etc. - 5. Agent Settings — iterations, compression, session reset + 3. Agent Settings — iterations, compression, session reset + 4. Messaging Platforms — connect Telegram, Discord, etc. + 5. Tools — configure TTS, web search, image generation, etc. Config files are stored in ~/.hermes/ for easy access. """ @@ -2037,7 +2037,7 @@ def setup_terminal_backend(config: dict): # Docker image current_image = config.get("terminal", {}).get( - "docker_image", "python:3.11-slim" + "docker_image", "nikolaik/python-nodejs:python3.11-nodejs20" ) image = prompt(" Docker image", current_image) config["terminal"]["docker_image"] = image @@ -2059,7 +2059,7 @@ def setup_terminal_backend(config: dict): print_info(f"Found: {sing_bin}") current_image = config.get("terminal", {}).get( - "singularity_image", "docker://python:3.11-slim" + "singularity_image", "docker://nikolaik/python-nodejs:python3.11-nodejs20" ) image = prompt(" Container image", current_image) config["terminal"]["singularity_image"] = image @@ -2261,7 +2261,7 @@ def setup_agent_settings(config: dict): ) print_info("Maximum tool-calling iterations per conversation.") print_info("Higher = more complex tasks, but costs more tokens.") - print_info("Recommended: 30-60 for most tasks, 100+ for open exploration.") + print_info("Default is 90, which works for most tasks. Use 150+ for open exploration.") max_iter_str = prompt("Max iterations", current_max) try: @@ -2303,7 +2303,7 @@ def setup_agent_settings(config: dict): config.setdefault("compression", {})["enabled"] = True - current_threshold = config.get("compression", {}).get("threshold", 0.85) + current_threshold = config.get("compression", {}).get("threshold", 0.50) threshold_str = prompt("Compression threshold (0.5-0.95)", str(current_threshold)) try: threshold = float(threshold_str) @@ -2313,7 +2313,7 @@ def setup_agent_settings(config: dict): pass print_success( - f"Context compression threshold set to {config['compression'].get('threshold', 0.85)}" + f"Context compression threshold set to {config['compression'].get('threshold', 0.50)}" ) # ── Session Reset Policy ── @@ -3248,9 +3248,9 @@ def run_setup_wizard(args): print_info("We'll walk you through:") print_info(" 1. Model & Provider — choose your AI provider and model") print_info(" 2. Terminal Backend — where your agent runs commands") - print_info(" 3. Messaging Platforms — connect Telegram, Discord, etc.") - print_info(" 4. Tools — configure TTS, web search, image generation, etc.") - print_info(" 5. Agent Settings — iterations, compression, session reset") + print_info(" 3. Agent Settings — iterations, compression, session reset") + print_info(" 4. Messaging Platforms — connect Telegram, Discord, etc.") + print_info(" 5. Tools — configure TTS, web search, image generation, etc.") print() print_info("Press Enter to begin, or Ctrl+C to exit.") try: diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 2d623fbd7d..1bc1c64355 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -101,6 +101,30 @@ CONFIGURABLE_TOOLSETS = [ # but the setup checklist won't pre-select them for first-time users. _DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "rl"} + +def _get_effective_configurable_toolsets(): + """Return CONFIGURABLE_TOOLSETS + any plugin-provided toolsets. + + Plugin toolsets are appended at the end so they appear after the + built-in toolsets in the TUI checklist. + """ + result = list(CONFIGURABLE_TOOLSETS) + try: + from hermes_cli.plugins import get_plugin_toolsets + result.extend(get_plugin_toolsets()) + except Exception: + pass + return result + + +def _get_plugin_toolset_keys() -> set: + """Return the set of toolset keys provided by plugins.""" + try: + from hermes_cli.plugins import get_plugin_toolsets + return {ts_key for ts_key, _, _ in get_plugin_toolsets()} + except Exception: + return set() + # Platform display config PLATFORMS = { "cli": {"label": "🖥️ CLI", "default_toolset": "hermes-cli"}, @@ -367,71 +391,72 @@ def _get_platform_tools(config: dict, platform: str) -> Set[str]: default_ts = PLATFORMS[platform]["default_toolset"] toolset_names = [default_ts] - configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} - - # If the saved list contains any configurable keys directly, the user - # has explicitly configured this platform — use direct membership. - # This avoids the subset-inference bug where composite toolsets like - # "hermes-cli" (which include all _HERMES_CORE_TOOLS) cause disabled - # toolsets to re-appear as enabled. - has_explicit_config = any(ts in configurable_keys for ts in toolset_names) - - if has_explicit_config: - return {ts for ts in toolset_names if ts in configurable_keys} - - # No explicit config — fall back to resolving composite toolset names - # (e.g. "hermes-cli") to individual tool names and reverse-mapping. + # Resolve to individual tool names, then map back to which + # configurable toolsets are covered all_tool_names = set() for ts_name in toolset_names: all_tool_names.update(resolve_toolset(ts_name)) + # Map individual tool names back to configurable toolset keys enabled_toolsets = set() for ts_key, _, _ in CONFIGURABLE_TOOLSETS: ts_tools = set(resolve_toolset(ts_key)) if ts_tools and ts_tools.issubset(all_tool_names): enabled_toolsets.add(ts_key) + # Plugin toolsets: enabled by default unless explicitly disabled. + # A plugin toolset is "known" for a platform once `hermes tools` + # has been saved for that platform (tracked via known_plugin_toolsets). + # Unknown plugins default to enabled; known-but-absent = disabled. + plugin_ts_keys = _get_plugin_toolset_keys() + if plugin_ts_keys: + known_map = config.get("known_plugin_toolsets", {}) + known_for_platform = set(known_map.get(platform, [])) + for pts in plugin_ts_keys: + if pts in toolset_names: + # Explicitly listed in config — enabled + enabled_toolsets.add(pts) + elif pts not in known_for_platform: + # New plugin not yet seen by hermes tools — default enabled + enabled_toolsets.add(pts) + # else: known but not in config = user disabled it + return enabled_toolsets def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[str]): """Save the selected toolset keys for a platform to config. - Preserves any non-configurable, non-composite entries (like MCP server - names) that were already in the config for this platform. - - Composite platform toolsets (hermes-cli, hermes-telegram, etc.) are - dropped once the user has explicitly configured individual toolsets — - keeping them would override the user's selections because they include - all tools via _HERMES_CORE_TOOLS. + Preserves any non-configurable toolset entries (like MCP server names) + that were already in the config for this platform. """ - from toolsets import TOOLSETS - config.setdefault("platform_toolsets", {}) - # Keys the user can toggle in the checklist UI + # Get the set of all configurable toolset keys (built-in + plugin) configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} - - # Keys that are known composite/individual toolsets in toolsets.py - # (hermes-cli, hermes-telegram, homeassistant, web, terminal, etc.) - known_toolset_keys = set(TOOLSETS.keys()) + plugin_keys = _get_plugin_toolset_keys() + configurable_keys |= plugin_keys # Get existing toolsets for this platform existing_toolsets = config.get("platform_toolsets", {}).get(platform, []) if not isinstance(existing_toolsets, list): existing_toolsets = [] - # Preserve entries that are neither configurable toolsets nor known - # composite toolsets — this keeps MCP server names and other custom - # entries while dropping composites like "hermes-cli" that would - # silently re-enable everything the user just disabled. + # Preserve any entries that are NOT configurable toolsets (i.e. MCP server names) preserved_entries = { entry for entry in existing_toolsets - if entry not in configurable_keys and entry not in known_toolset_keys + if entry not in configurable_keys } # Merge preserved entries with new enabled toolsets config["platform_toolsets"][platform] = sorted(enabled_toolset_keys | preserved_entries) + + # Track which plugin toolsets are "known" for this platform so we can + # distinguish "new plugin, default enabled" from "user disabled it". + if plugin_keys: + config.setdefault("known_plugin_toolsets", {}) + config["known_plugin_toolsets"][platform] = sorted(plugin_keys) + save_config(config) @@ -549,15 +574,17 @@ def _prompt_toolset_checklist(platform_label: str, enabled: Set[str]) -> Set[str """Multi-select checklist of toolsets. Returns set of selected toolset keys.""" from hermes_cli.curses_ui import curses_checklist + effective = _get_effective_configurable_toolsets() + labels = [] - for ts_key, ts_label, ts_desc in CONFIGURABLE_TOOLSETS: + for ts_key, ts_label, ts_desc in effective: suffix = "" if not _toolset_has_keys(ts_key) and (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)): suffix = " [no API key]" labels.append(f"{ts_label} ({ts_desc}){suffix}") pre_selected = { - i for i, (ts_key, _, _) in enumerate(CONFIGURABLE_TOOLSETS) + i for i, (ts_key, _, _) in enumerate(effective) if ts_key in enabled } @@ -567,7 +594,7 @@ def _prompt_toolset_checklist(platform_label: str, enabled: Set[str]) -> Set[str pre_selected, cancel_returns=pre_selected, ) - return {CONFIGURABLE_TOOLSETS[i][0] for i in chosen} + return {effective[i][0] for i in chosen} # ─── Provider-Aware Configuration ──────────────────────────────────────────── @@ -782,7 +809,7 @@ def _configure_simple_requirements(ts_key: str): if not missing: return - ts_label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key) + ts_label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts_key), ts_key) print() print(color(f" {ts_label} requires configuration:", Colors.YELLOW)) @@ -801,7 +828,7 @@ def _reconfigure_tool(config: dict): """Let user reconfigure an existing tool's provider or API key.""" # Build list of configurable tools that are currently set up configurable = [] - for ts_key, ts_label, _ in CONFIGURABLE_TOOLSETS: + for ts_key, ts_label, _ in _get_effective_configurable_toolsets(): cat = TOOL_CATEGORIES.get(ts_key) reqs = TOOLSET_ENV_REQUIREMENTS.get(ts_key) if cat or reqs: @@ -915,7 +942,7 @@ def _reconfigure_simple_requirements(ts_key: str): if not requirements: return - ts_label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key) + ts_label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts_key), ts_key) print() print(color(f" {ts_label}:", Colors.CYAN)) @@ -954,7 +981,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None): # Non-interactive summary mode for CLI usage if getattr(args, "summary", False): - total = len(CONFIGURABLE_TOOLSETS) + total = len(_get_effective_configurable_toolsets()) print(color("⚕ Tool Summary", Colors.CYAN, Colors.BOLD)) print() summary = _platform_toolset_summary(config, enabled_platforms) @@ -965,7 +992,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None): print(color(f" {pinfo['label']}", Colors.BOLD) + color(f" ({count}/{total})", Colors.DIM)) if enabled: for ts_key in sorted(enabled): - label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key) + label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts_key), ts_key) print(color(f" ✓ {label}", Colors.GREEN)) else: print(color(" (none enabled)", Colors.DIM)) @@ -992,11 +1019,11 @@ def tools_command(args=None, first_install: bool = False, config: dict = None): removed = current_enabled - new_enabled if added: for ts in sorted(added): - label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts), ts) + label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts) print(color(f" + {label}", Colors.GREEN)) if removed: for ts in sorted(removed): - label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts), ts) + label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts) print(color(f" - {label}", Colors.RED)) # Walk through ALL selected tools that have provider options or @@ -1012,7 +1039,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None): print() print(color(f" Configuring {len(to_configure)} tool(s):", Colors.YELLOW)) for ts_key in to_configure: - label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key) + label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts_key), ts_key) print(color(f" • {label}", Colors.DIM)) print(color(" You can skip any tool you don't need right now.", Colors.DIM)) print() @@ -1034,7 +1061,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None): pinfo = PLATFORMS[pkey] current = _get_platform_tools(config, pkey) count = len(current) - total = len(CONFIGURABLE_TOOLSETS) + total = len(_get_effective_configurable_toolsets()) platform_choices.append(f"Configure {pinfo['label']} ({count}/{total} enabled)") platform_keys.append(pkey) @@ -1090,10 +1117,10 @@ def tools_command(args=None, first_install: bool = False, config: dict = None): if added or removed: print(color(f" {pinfo_inner['label']}:", Colors.DIM)) for ts in sorted(added): - label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts), ts) + label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts) print(color(f" + {label}", Colors.GREEN)) for ts in sorted(removed): - label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts), ts) + label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts) print(color(f" - {label}", Colors.RED)) # Configure API keys for newly enabled tools for ts_key in sorted(added): @@ -1106,7 +1133,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None): # Update choice labels for ci, pk in enumerate(platform_keys): new_count = len(_get_platform_tools(config, pk)) - total = len(CONFIGURABLE_TOOLSETS) + total = len(_get_effective_configurable_toolsets()) platform_choices[ci] = f"Configure {PLATFORMS[pk]['label']} ({new_count}/{total} enabled)" else: print(color(" No changes", Colors.DIM)) @@ -1128,11 +1155,11 @@ def tools_command(args=None, first_install: bool = False, config: dict = None): if added: for ts in sorted(added): - label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts), ts) + label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts) print(color(f" + {label}", Colors.GREEN)) if removed: for ts in sorted(removed): - label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts), ts) + label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts) print(color(f" - {label}", Colors.RED)) # Configure newly enabled toolsets that need API keys @@ -1151,7 +1178,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None): # Update the choice label with new count new_count = len(_get_platform_tools(config, pkey)) - total = len(CONFIGURABLE_TOOLSETS) + total = len(_get_effective_configurable_toolsets()) platform_choices[idx] = f"Configure {pinfo['label']} ({new_count}/{total} enabled)" print() @@ -1331,12 +1358,27 @@ def _apply_mcp_change(config: dict, targets: List[str], action: str) -> Set[str] def _print_tools_list(enabled_toolsets: set, mcp_servers: dict, platform: str = "cli"): """Print a summary of enabled/disabled toolsets and MCP tool filters.""" + effective = _get_effective_configurable_toolsets() + builtin_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} + print(f"Built-in toolsets ({platform}):") - for ts_key, label, _ in CONFIGURABLE_TOOLSETS: + for ts_key, label, _ in effective: + if ts_key not in builtin_keys: + continue status = (color("✓ enabled", Colors.GREEN) if ts_key in enabled_toolsets else color("✗ disabled", Colors.RED)) print(f" {status} {ts_key} {color(label, Colors.DIM)}") + # Plugin toolsets + plugin_entries = [(k, l) for k, l, _ in effective if k not in builtin_keys] + if plugin_entries: + print() + print(f"Plugin toolsets ({platform}):") + for ts_key, label in plugin_entries: + status = (color("✓ enabled", Colors.GREEN) if ts_key in enabled_toolsets + else color("✗ disabled", Colors.RED)) + print(f" {status} {ts_key} {color(label, Colors.DIM)}") + if mcp_servers: print() print("MCP servers:") @@ -1375,7 +1417,7 @@ def tools_disable_enable_command(args): toolset_targets = [t for t in targets if ":" not in t] mcp_targets = [t for t in targets if ":" in t] - valid_toolsets = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} + valid_toolsets = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} | _get_plugin_toolset_keys() unknown_toolsets = [t for t in toolset_targets if t not in valid_toolsets] if unknown_toolsets: for name in unknown_toolsets: diff --git a/model_tools.py b/model_tools.py index a380d0e9ce..5fcf4213d2 100644 --- a/model_tools.py +++ b/model_tools.py @@ -293,15 +293,11 @@ def get_tool_definitions( for ts_name in get_all_toolsets(): tools_to_include.update(resolve_toolset(ts_name)) - # Always include plugin-registered tools — they bypass the toolset filter - # because their toolsets are dynamic (created at plugin load time). - try: - from hermes_cli.plugins import get_plugin_tool_names - plugin_tools = get_plugin_tool_names() - if plugin_tools: - tools_to_include.update(plugin_tools) - except Exception: - pass + # Plugin-registered tools are now resolved through the normal toolset + # path — validate_toolset() / resolve_toolset() / get_all_toolsets() + # all check the tool registry for plugin-provided toolsets. No bypass + # needed; plugins respect enabled_toolsets / disabled_toolsets like any + # other toolset. # Ask the registry for schemas (only returns tools whose check_fn passes) filtered_tools = registry.get_definitions(tools_to_include, quiet=quiet_mode) diff --git a/run_agent.py b/run_agent.py index 67a187583e..a2353c0538 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2443,18 +2443,6 @@ class AIAgent: "Pre-call sanitizer: added %d stub tool result(s)", len(missing_results), ) - # 3. Strip trailing empty assistant messages to prevent prefill rejection. - # These can leak from Responses API reasoning-only turns (Codex/MiniMax) - # where an empty assistant message is required by the Responses API but - # must NOT be sent to Chat Completions or Anthropic Messages API providers. - while ( - messages - and messages[-1].get("role") == "assistant" - and not (messages[-1].get("content") or "").strip() - and not messages[-1].get("tool_calls") - ): - logger.debug("Pre-call sanitizer: removed trailing empty assistant message") - messages = messages[:-1] return messages @staticmethod diff --git a/tests/test_plugins.py b/tests/test_plugins.py index d2b2594549..9a0257f371 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -282,7 +282,7 @@ class TestPluginToolVisibility: """Plugin-registered tools appear in get_tool_definitions().""" def test_plugin_tools_in_definitions(self, tmp_path, monkeypatch): - """Tools from plugins bypass the toolset filter.""" + """Plugin tools are included when their toolset is in enabled_toolsets.""" import hermes_cli.plugins as plugins_mod plugins_dir = tmp_path / "hermes_test" / "plugins" @@ -305,10 +305,22 @@ class TestPluginToolVisibility: monkeypatch.setattr(plugins_mod, "_plugin_manager", mgr) from model_tools import get_tool_definitions - tools = get_tool_definitions(enabled_toolsets=["terminal"], quiet_mode=True) + + # Plugin tools are included when their toolset is explicitly enabled + tools = get_tool_definitions(enabled_toolsets=["terminal", "plugin_vis_plugin"], quiet_mode=True) tool_names = [t["function"]["name"] for t in tools] assert "vis_tool" in tool_names + # Plugin tools are excluded when only other toolsets are enabled + tools2 = get_tool_definitions(enabled_toolsets=["terminal"], quiet_mode=True) + tool_names2 = [t["function"]["name"] for t in tools2] + assert "vis_tool" not in tool_names2 + + # Plugin tools are included when no toolset filter is active (all enabled) + tools3 = get_tool_definitions(quiet_mode=True) + tool_names3 = [t["function"]["name"] for t in tools3] + assert "vis_tool" in tool_names3 + # ── TestPluginManagerList ────────────────────────────────────────────────── diff --git a/toolsets.py b/toolsets.py index 23c8ba66a5..84f2d5c4cb 100644 --- a/toolsets.py +++ b/toolsets.py @@ -366,6 +366,13 @@ def resolve_toolset(name: str, visited: Set[str] = None) -> List[str]: # Get toolset definition toolset = TOOLSETS.get(name) if not toolset: + # Fall back to tool registry for plugin-provided toolsets + if name in _get_plugin_toolset_names(): + try: + from tools.registry import registry + return [e.name for e in registry._tools.values() if e.toolset == name] + except Exception: + pass return [] # Collect direct tools @@ -400,24 +407,60 @@ def resolve_multiple_toolsets(toolset_names: List[str]) -> List[str]: return list(all_tools) +def _get_plugin_toolset_names() -> Set[str]: + """Return toolset names registered by plugins (from the tool registry). + + These are toolsets that exist in the registry but not in the static + ``TOOLSETS`` dict — i.e. they were added by plugins at load time. + """ + try: + from tools.registry import registry + return { + entry.toolset + for entry in registry._tools.values() + if entry.toolset not in TOOLSETS + } + except Exception: + return set() + + def get_all_toolsets() -> Dict[str, Dict[str, Any]]: """ Get all available toolsets with their definitions. + + Includes both statically-defined toolsets and plugin-registered ones. Returns: Dict: All toolset definitions """ - return TOOLSETS.copy() + result = TOOLSETS.copy() + # Add plugin-provided toolsets (synthetic entries) + for ts_name in _get_plugin_toolset_names(): + if ts_name not in result: + try: + from tools.registry import registry + tools = [e.name for e in registry._tools.values() if e.toolset == ts_name] + result[ts_name] = { + "description": f"Plugin toolset: {ts_name}", + "tools": tools, + } + except Exception: + pass + return result def get_toolset_names() -> List[str]: """ Get names of all available toolsets (excluding aliases). + + Includes plugin-registered toolset names. Returns: List[str]: List of toolset names """ - return list(TOOLSETS.keys()) + names = set(TOOLSETS.keys()) + names |= _get_plugin_toolset_names() + return sorted(names) @@ -435,7 +478,10 @@ def validate_toolset(name: str) -> bool: # Accept special alias names for convenience if name in {"all", "*"}: return True - return name in TOOLSETS + if name in TOOLSETS: + return True + # Check tool registry for plugin-provided toolsets + return name in _get_plugin_toolset_names() def create_custom_toolset(