#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.
129 lines
6.0 KiB
Python
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))
|