Skip to content
Closed
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
104 changes: 104 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9007,6 +9007,8 @@ def process_command(self, command: str) -> bool:
# The auto-reload path (file watcher) calls _reload_mcp directly
# without this confirmation.
self._confirm_and_reload_mcp(cmd_original)
elif canonical == "mcp":
self._handle_mcp_command(cmd_original)
elif canonical == "reload-skills":
with self._busy_command(self._slow_command_status(cmd_original)):
self._reload_skills()
Expand Down Expand Up @@ -10427,6 +10429,108 @@ def _confirm_and_reload_mcp(self, cmd_original: str = "") -> None:
with self._busy_command(self._slow_command_status(cmd_original)):
self._reload_mcp()

def _handle_mcp_command(self, cmd_original: str) -> None:
"""Handle /mcp list|enable|disable|load <name>."""
parts = cmd_original.strip().split(None, 2)
subcmd = parts[1].lower() if len(parts) > 1 else "list"
arg = parts[2] if len(parts) > 2 else ""

from tools.mcp_tool import (
_load_mcp_config, _servers, _lock,
_is_lazy_server, _is_on_demand_server,
_is_server_loaded_in_session, _mark_server_loaded_in_session,
_activate_lazy_server, _activate_on_demand_server,
_get_connect_strategy, _parse_boolish,
_remove_session_loaded_server,
)

if subcmd == "list":
servers = _load_mcp_config()
if not servers:
print(" No MCP servers configured.")
return

print(f" MCP servers ({len(servers)} configured):")
for name, cfg in sorted(servers.items()):
enabled = _parse_boolish(cfg.get("enabled", True), default=True)
if not enabled:
print(f" {name}: disabled")
continue

strategy = _get_connect_strategy(cfg)
transport = "http" if "url" in cfg else "stdio"

with _lock:
server = _servers.get(name)
connected = server is not None and server.session is not None
tool_count = len(getattr(server, "_registered_tool_names", [])) if server else 0

is_lazy = _is_lazy_server(name)
is_ondemand = _is_on_demand_server(name)
loaded = _is_server_loaded_in_session(name) if is_ondemand else True

status = "connected" if connected else "lazy" if is_lazy else "on_demand" if is_ondemand else "disconnected"
active = "active" if (connected or (is_lazy and not is_ondemand) or loaded) else "inactive"
print(f" {name}: {status} ({transport}, {tool_count} tools, {active})")

elif subcmd == "enable":
if not arg:
print(" Usage: /mcp enable <server-name>")
return
servers = _load_mcp_config()
if arg not in servers:
print(f" Unknown MCP server '{arg}'. Use /mcp list to see available servers.")
return
strategy = _get_connect_strategy(servers[arg])
if strategy == "on_demand":
if _activate_on_demand_server(arg):
print(f" ✅ MCP server '{arg}' loaded and activated.")
else:
print(f" ❌ Failed to load MCP server '{arg}'.")
elif strategy == "lazy":
if _activate_lazy_server(arg):
print(f" ✅ MCP server '{arg}' activated.")
else:
print(f" ❌ Failed to activate MCP server '{arg}'.")
else:
print(f" MCP server '{arg}' is already configured as 'startup' (always connected).")

elif subcmd == "disable":
if not arg:
print(" Usage: /mcp disable <server-name>")
return
# For on_demand servers, mark as not loaded in this session
if _is_on_demand_server(arg):
_remove_session_loaded_server(arg)
print(f" MCP server '{arg}' disabled for this session.")
else:
print(f" MCP server '{arg}' cannot be disabled mid-session. Use /reload-mcp to restart all servers.")

elif subcmd == "load":
if not arg:
print(" Usage: /mcp load <server-name>")
return
servers = _load_mcp_config()
if arg not in servers:
print(f" Unknown MCP server '{arg}'. Use /mcp list to see available servers.")
return
strategy = _get_connect_strategy(servers[arg])
if strategy == "on_demand":
if _activate_on_demand_server(arg):
print(f" ✅ MCP server '{arg}' loaded and activated.")
else:
print(f" ❌ Failed to load MCP server '{arg}'.")
elif strategy == "lazy":
if _activate_lazy_server(arg):
print(f" ✅ MCP server '{arg}' activated.")
else:
print(f" ❌ Failed to activate MCP server '{arg}'.")
else:
print(f" MCP server '{arg}' is already connected (startup strategy).")

else:
print(f" Unknown subcommand '{subcmd}'. Use: list, enable <name>, disable <name>, load <name>")

def _reload_mcp(self):
"""Reload MCP servers: disconnect all, re-read config.yaml, reconnect.

Expand Down
1 change: 1 addition & 0 deletions contributors/emails/christopher.wenn@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
prismatic7
3 changes: 3 additions & 0 deletions hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,9 @@ class CommandDef:
cli_only=True),
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
aliases=("reload_mcp",)),
CommandDef("mcp", "Manage MCP servers: list, enable, disable, load", "Tools & Skills",
Comment thread
prismatic7 marked this conversation as resolved.
args_hint="[list|enable <name>|disable <name>|load <name>]",
subcommands=("list", "enable", "disable", "load")),
CommandDef("reload-skills", "Re-scan ~/.hermes/skills/ for newly installed or removed skills",
"Tools & Skills", aliases=("reload_skills",)),
CommandDef("browser", "Connect browser tools to your live Chromium-family browser via CDP", "Tools & Skills",
Expand Down
6 changes: 6 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1423,6 +1423,12 @@ def _ensure_hermes_home_managed(home: Path):
"auto_reload_on_config_change": True,
},

# MCP tool schema budget: max MCP tool schemas to inject into the
# LLM system prompt. When the total MCP tools exceed this budget,
# least-recently-used servers' tools are dropped. 0 = unlimited
# (current default, backward compatible).
"mcp_tool_budget": 0,

# Tool-output truncation thresholds. When terminal output or a
# single read_file page exceeds these limits, Hermes truncates the
# payload sent to the model (keeping head + tail for terminal,
Expand Down
64 changes: 64 additions & 0 deletions model_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,35 @@
# advisory (#33924) is logged once per name, not on every tool recompute.
_WARNED_DISABLED_BUNDLES: set = set()

# MCP tool budget tracking: LRU timestamps for MCP tool calls.
# Keyed by tool name, value is monotonic time of last use.
_mcp_tool_last_used: Dict[str, float] = {}
_mcp_tool_last_used_lock = threading.Lock()


def _resolve_mcp_tool_budget() -> int:
"""Read the MCP tool budget from config. 0 = unlimited."""
try:
from hermes_cli.config import load_config
cfg = load_config() or {}
return int(cfg.get("mcp_tool_budget", 0))
except Exception:
return 0


def _get_mcp_tool_last_used(tool_name: str) -> float:
"""Return the last-used timestamp for an MCP tool (0 if never used)."""
with _mcp_tool_last_used_lock:
return _mcp_tool_last_used.get(tool_name, 0.0)


def _mark_mcp_tool_used(tool_name: str) -> None:
"""Record that an MCP tool was just used (for LRU eviction)."""
if not tool_name.startswith("mcp__"):
return
with _mcp_tool_last_used_lock:
_mcp_tool_last_used[tool_name] = time.monotonic()


# =============================================================================
# Async Bridging (single source of truth -- used by registry.dispatch too)
Expand Down Expand Up @@ -450,6 +479,38 @@ def _compute_tool_definitions(
# descriptions that don't actually exist, and hallucinates calls to them.
available_tool_names = {t["function"]["name"] for t in filtered_tools}

# Apply MCP tool budget: if configured, cap the number of MCP tool
# schemas in the prompt. Meta-tools (mcp_list_servers, mcp_load_server)
# are always included. When the budget is exceeded, tools from
# least-recently-used servers are dropped first.
_mcp_tool_budget = _resolve_mcp_tool_budget()
if _mcp_tool_budget > 0:
mcp_tools = [
t for t in filtered_tools
if t.get("function", {}).get("name", "").startswith("mcp__")
and t.get("function", {}).get("name", "") not in ("mcp_list_servers", "mcp_load_server")
]
if len(mcp_tools) > _mcp_tool_budget:
# Keep meta-tools, drop excess MCP tools
non_mcp_tools = [
t for t in filtered_tools
if not t.get("function", {}).get("name", "").startswith("mcp__")
or t.get("function", {}).get("name", "") in ("mcp_list_servers", "mcp_load_server")
]
# Sort MCP tools by LRU: keep the most recently used ones
mcp_tools.sort(
key=lambda t: _get_mcp_tool_last_used(t.get("function", {}).get("name", "")),
reverse=True,
)
filtered_tools = non_mcp_tools + mcp_tools[:_mcp_tool_budget]
# Recompute available_tool_names
available_tool_names = {t["function"]["name"] for t in filtered_tools}
logger.debug(
"MCP tool budget: capped %d MCP tools to %d (dropped %d)",
len(mcp_tools), _mcp_tool_budget,
len(mcp_tools) - _mcp_tool_budget,
)

# Rebuild execute_code schema to only list sandbox tools that are actually
# available. Without this, the model sees "web_search is available in
# execute_code" even when the API key isn't configured or the toolset is
Expand Down Expand Up @@ -1289,6 +1350,9 @@ def _dispatch(next_args: Dict[str, Any]) -> Any:
turn_id=turn_id or "",
api_request_id=api_request_id or "",
)

# Track MCP tool usage for LRU eviction
_mark_mcp_tool_used(function_name)
finally:
if _approval_tokens is not None and reset_current_observability_context is not None:
try:
Expand Down
46 changes: 42 additions & 4 deletions tools/delegate_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,7 @@ def _build_child_system_prompt(
role: str = "leaf",
max_spawn_depth: int = 2,
child_depth: int = 1,
persona: Optional[str] = None,
) -> str:
"""Build a focused system prompt for a child agent.

Expand All @@ -675,12 +676,18 @@ def _build_child_system_prompt(
inspiration/openclaw/src/agents/subagent-system-prompt.ts:63-95).
The depth note is literal truth (grounded in the passed config) so
the LLM doesn't confabulate nesting capabilities that don't exist.

When ``persona`` is provided, it is injected as a "YOUR ROLE" block
that specializes the subagent's behavior (e.g. "web researcher",
"senior engineer", "code reviewer").
"""
parts = [
"You are a focused subagent working on a specific delegated task.",
"",
f"YOUR TASK:\n{goal}",
]
if persona and persona.strip():
parts.append(f"\nYOUR ROLE:\n{persona.strip()}")
if context and context.strip():
parts.append(f"\nCONTEXT:\n{context}")
if workspace_path and str(workspace_path).strip():
Expand Down Expand Up @@ -1086,6 +1093,13 @@ def _build_child_agent(
# 'leaf' (default) cannot; 'orchestrator' retains the delegation
# toolset subject to depth/kill-switch bounds applied below.
role: str = "leaf",
# Per-task persona — injected into the child's system prompt as
# a "YOUR ROLE" block to specialize behavior.
persona: Optional[str] = None,
# Per-task wall-clock timeout in seconds. Overrides the global
# delegation.child_timeout_seconds for this child. None = inherit
# the global config default (which itself defaults to no timeout).
timeout: Optional[float] = None,
):
"""
Build a child AIAgent on the main thread (thread-safe construction).
Expand Down Expand Up @@ -1195,6 +1209,7 @@ def _build_child_agent(
role=effective_role,
max_spawn_depth=max_spawn,
child_depth=child_depth,
persona=persona,
)
# Extract parent's API key so subagents inherit auth (e.g. Nous Portal).
parent_api_key = getattr(parent_agent, "api_key", None)
Expand Down Expand Up @@ -1417,6 +1432,8 @@ def _child_thinking(text: str) -> None:
child._parent_subagent_id = parent_subagent_id
child._subagent_goal = goal
child._parent_turn_id = getattr(parent_agent, "_current_turn_id", "") or ""
# Per-task timeout override (None = inherit global config default)
child._delegate_timeout = timeout
# Stable sidebar marker: delegate subagent sessions must stay out of
# session pickers even when a parent delete orphans them (parent_session_id
# → NULL). Mirrors /branch's ``_branched_from`` pattern — see
Expand Down Expand Up @@ -1971,7 +1988,9 @@ def _heartbeat_loop():
# Run child with an optional hard timeout (off by default —
# result(timeout=None) blocks until the child finishes). Stuck-child
# protection comes from the heartbeat staleness monitor instead.
child_timeout = _get_child_timeout()
# Per-task timeout (child._delegate_timeout) beats the global config.
_per_task_timeout = getattr(child, "_delegate_timeout", None)
child_timeout = _per_task_timeout if _per_task_timeout is not None else _get_child_timeout()
# Daemon worker (tools.daemon_pool): a timed-out child is abandoned
# below; a stdlib non-daemon worker would then block interpreter
# exit at atexit-join time if the child never unwinds.
Expand Down Expand Up @@ -2586,13 +2605,16 @@ def delegate_task(
# Per-task role beats top-level; normalise again so unknown
# per-task values warn and degrade to leaf uniformly.
effective_role = _normalize_role(t.get("role") or top_role)
# Per-task toolsets: when provided, the model can narrow which
# tools a subagent gets. Intersection with parent + blocked-tool
# stripping is handled inside _build_child_agent. None = inherit
# the parent's full toolset (existing behaviour).
per_task_toolsets = t.get("toolsets")
child = _build_child_agent(
task_index=i,
goal=t["goal"],
context=t.get("context"),
# Subagents always inherit the parent's toolsets; the model
# cannot choose or narrow them (no model-facing toolsets arg).
toolsets=None,
toolsets=per_task_toolsets,
model=creds["model"],
max_iterations=effective_max_iter,
task_count=n_tasks,
Expand All @@ -2606,6 +2628,8 @@ def delegate_task(
override_acp_command=creds.get("command"),
override_acp_args=creds.get("args"),
role=effective_role,
persona=t.get("persona"),
timeout=t.get("timeout"),
)
# Override with correct parent tool names (before child construction mutated global)
child._delegate_saved_tool_names = _parent_tool_names
Expand Down Expand Up @@ -3436,6 +3460,7 @@ def _build_top_level_description() -> str:
"delegation.orchestrator_enabled=false.\n"
"- Subagent model is NOT selectable per call: children inherit the parent model (plus its fallback chain) unless you pin all subagents to a model via delegation.provider / delegation.model in config.yaml.\n"
"- Each subagent gets its own terminal session (separate working directory and state).\n"
"- Per-task fields on batch items: 'toolsets' (restrict which tools the subagent loads), 'persona' (specialize its behavior via a role description), 'timeout' (per-task wall-clock cap in seconds).\n"
"- Results are always returned as an array, one entry per task."
)

Expand Down Expand Up @@ -3563,6 +3588,19 @@ def _build_dynamic_schema_overrides() -> dict:
"enum": ["leaf", "orchestrator"],
"description": "Per-task role override. See top-level 'role' for semantics.",
},
"toolsets": {
"type": "array",
"items": {"type": "string"},
"description": "Optional toolset names to restrict this subagent to (e.g. [\"web\", \"terminal\", \"file\"]). When set, only tools from these toolsets are loaded, significantly reducing input token overhead. When omitted, the subagent inherits the parent's full toolset. Infer from the task — e.g. use [\"web\"] for research, [\"terminal\", \"file\"] for coding, [\"web\", \"terminal\", \"file\", \"delegation\"] for orchestrator tasks.",
},
"persona": {
"type": "string",
"description": "Optional role/persona description injected into the subagent's system prompt (e.g. 'web researcher — focus on finding authoritative sources, cite URLs', 'senior engineer — prioritize correctness, add error handling'). Helps specialize subagent behavior without cramming instructions into context.",
},
"timeout": {
"type": "number",
"description": "Optional per-task wall-clock timeout in seconds. Overrides the global delegation.child_timeout_seconds for this subagent. Use a short timeout for quick lookups (e.g. 30) and a longer one for deep analysis (e.g. 300). Omit to inherit the global default (no timeout by default).",
},
},
"required": ["goal"],
},
Expand Down
Loading