Files
hermes-agent/hermes_cli/plugins_activation_live.py
Teknium 097fcaa1de fix(skills): every turn announces skill changes once, on every surface
#118661's note only fired on the stored-prompt restore path, which a long-lived agent (classic
CLI, TUI, Desktop) runs once per conversation: those surfaces never saw a mid-conversation skill
change. The check now runs from build_turn_context every turn against the prompt the turn
actually sends.

- What was told is kept on the agent for its prompt; a fresh agent (gateway, -q --resume) reads
  the newest note back from the WHOLE transcript instead of the last 200 rows, so a long session
  no longer re-announces an unchanged delta (review by @kvnloo).
- A compaction rebuild that now lists the announced skills stays quiet.
- Index rows split on ": ", so qualified plugin skills (plugin:skill) parse.
- The plugin go-live note drops its own skills list: the per-turn note covers plugin skills like
  any other, so a plugin install no longer announces its skills twice.
2026-10-09 19:31:10 -07:00

129 lines
6.0 KiB
Python

"""Make an installed plugin's MCP servers and skills usable in THIS process now (#87770 follow-up).
:func:`hermes_cli.plugins_activation.activate_plugin_now` loads the plugin; skills are live from that
point (``skill_view`` resolves ``<plugin>:<skill>``). MCP servers are not: nothing connects them until
``reload.mcp``. :func:`connect_plugin_mcp` connects the plugin's servers one by one (the connector
flow's ``register_mcp_servers({name: config})``), and :func:`live_notice` renders what became usable
as the note an open chat receives on its next turn.
MCP tools are deferred behind ``tool_search`` / ``tool_describe`` / ``tool_call``, so adding them to a
live session leaves the model-facing tool array (and the cached prompt prefix) byte-identical.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
def connect_plugin_mcp(activation: dict[str, Any], portable: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
"""Connect every portable MCP server ``activation`` defers, under the caller's profile scope.
``portable`` is the manager's portable server configs, read together with ``activation``.
Returns ``[{name, connected, tools, error?}]``, one row per server; a server that fails to
connect is reported, never retried. Never raises."""
names = list((activation.get("deferred") or {}).get("mcp_servers") or ())
if not names:
return []
try:
from tools.mcp_tool_config import _filter_suspicious_mcp_servers, _load_mcp_config
from tools.mcp_tool_discovery import register_mcp_servers
configured = _load_mcp_config()
except Exception as exc:
logger.warning("plugin MCP activation could not read server config: %s", exc)
return [{"name": n, "connected": False, "tools": [], "error": str(exc)} for n in names]
rows = []
for name in names:
config = configured.get(name) or _filter_suspicious_mcp_servers(
{name: portable[name]} if name in portable else {}).get(name)
if not isinstance(config, dict):
rows.append({"name": name, "connected": False, "tools": [], "error": "no MCP configuration"})
continue
try:
register_mcp_servers({name: config})
from tools.connectors.mcp import _registered_tool_names
tools = [t for t in _registered_tool_names(name) if not t.endswith(_utility_suffixes())]
except Exception as exc:
rows.append({"name": name, "connected": False, "tools": [], "error": str(exc)})
continue
row: dict[str, Any] = {"name": name, "connected": bool(tools), "tools": tools}
if not tools:
row["error"] = _server_error(name) or "the server did not connect"
rows.append(row)
return rows
def _utility_suffixes() -> tuple:
"""The resource/prompt wrappers every MCP server gets (``mcp__x__list_resources``, ...): callable,
but they say nothing about what the plugin does, so they are left out of counts and listings."""
from tools.mcp_tool_registration import _UTILITY_HANDLER_FACTORIES
return tuple(f"_{kind}" for kind in _UTILITY_HANDLER_FACTORIES)
def _server_error(name: str) -> Optional[str]:
try:
from tools.mcp_tool_discovery import get_mcp_status
for entry in get_mcp_status():
if entry.get("name") == name:
return entry.get("error")
except Exception:
return None
return None
def plugin_skills(plugin_key: str) -> list[dict[str, str]]:
"""``[{name, description}]`` for the skills ``plugin_key`` registered (qualified ``<ns>:<skill>``)."""
try:
from hermes_cli.plugins import get_plugin_manager
skills = get_plugin_manager()._plugin_skills
except Exception:
return []
return [{"name": qualified, "description": str(entry.get("description") or "")}
for qualified, entry in sorted(skills.items()) if entry.get("plugin_key") == plugin_key]
def live_notice(activation: dict[str, Any]) -> str:
"""The note an open chat gets on its next turn: connected MCP servers with their tools and what
waits for the next session. Empty without servers. The plugin's skills are not listed here: every
turn announces any skill the chat's system prompt does not list (``agent/skills_index_delta.py``),
whichever way it arrived."""
servers = (activation.get("live_now") or {}).get("mcp_servers") or []
deferred = activation.get("deferred") or {}
if not servers:
return ""
lines = [f"[plugin installed: {activation.get('name') or activation.get('key')}]"]
connected = [s for s in servers if s.get("connected")]
if connected:
lines.append("MCP servers now connected; call their tools with tool_describe / tool_call:")
for server in connected:
lines.append(f"- {server['name']}")
listing = _tool_listing(server["tools"])
if listing:
lines.extend(f" {line}" for line in listing.splitlines())
for server in (s for s in servers if not s.get("connected")):
lines.append(f"- {server['name']}: not connected ({server.get('error') or 'unknown error'})")
later = [f"{len(deferred['tools'])} Python tools" if deferred.get("tools") else "",
f"{len(deferred['prompt'])} prompt sections" if deferred.get("prompt") else ""]
if any(later):
lines.append("Next session: " + ", ".join(p for p in later if p) + ".")
return "\n".join(lines)
def _tool_listing(names: list[str]) -> str:
"""``name: first sentence`` per tool."""
try:
from tools.registry import registry
from tools.tool_search_catalog import _short_desc
definitions = registry.get_definitions(set(names), quiet=True)
except Exception:
return ""
rows = []
for definition in definitions:
fn = definition.get("function") or definition
name = str(fn.get("name") or "")
if name:
desc = _short_desc(fn.get("description") or "")
rows.append(f"{name}: {desc}" if desc else name)
return "\n".join(sorted(rows))