revert: remove trailing empty assistant message stripping

Reverts the sanitizer addition from PR #2466 (originally #2129).
We already have _empty_content_retries handling for reasoning-only
responses. The trailing strip risks silently eating valid messages
and is redundant with existing empty-content handling.
This commit is contained in:
Teknium
2026-03-22 04:55:34 -07:00
parent 5407d12bc6
commit 34be3f8be6
8 changed files with 220 additions and 152 deletions

View File

@@ -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':

View File

@@ -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

View File

@@ -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:

View File

@@ -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,8 +1358,23 @@ 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)}")
@@ -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:

View File

@@ -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)

View File

@@ -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

View File

@@ -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 ──────────────────────────────────────────────────

View File

@@ -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(