hermes_cli/plugins.py (7193 -> 4961): - _register_scoped_provider: one body for the 8 scope-keyed register_*_provider methods; _track_callback/_track_mapping_entry unify manager-mapping lease tracking (4 sites); _track_scoped_registration for the ownership ledger. Public method names, signatures, return values and warning strings unchanged. - Manifest v2 type checks table-driven (_manifest_field_of_type); _unload_scoped split into _unload_target_keys + _reset_after_unload_all; _gate_manifest/_record_placeholder out of _discover_and_load_inner; _track_tool_override_policy/_attribute_registrations out of _load_plugin_scoped; bounded hook worker lifted into _run_hook_callback_bounded; prompt-section rendering extracted; resolve_pre_tool_block delegates to _dispatch_pre_tool_call_hooks; _evict_modules (3 sites); _remove_name_if_unowned and _nowait_plugin_set unify unowned-name cleanup and *_nowait probes. - Dead (zero references outside their own test): unload_plugins, has_portable_mcp_servers, _classify_entrypoint_kind, _reset_event_bus, get_plugin_subscriptions, get_telegram_handler_factories, pass-through _restore_* helpers; _env_enabled is now an alias of utils.env_var_enabled (plugins/memory still imports it). - Docstrings/comments compacted; contract sentences and rationale kept (multi-profile ledger keying, persistent auth-provider registration, capability declaration is not a grant).
4962 lines
209 KiB
Python
4962 lines
209 KiB
Python
"""Hermes Plugin System — discovers, loads, and manages plugins.
|
||
|
||
Sources, later overriding earlier on key collision: bundled ``<repo>/plugins/<name>/`` (``memory/``
|
||
and ``context_engine/`` have their own discovery), user ``~/.hermes/plugins/<name>/``, project
|
||
``./.hermes/plugins/<name>/`` (opt-in via ``HERMES_ENABLE_PROJECT_PLUGINS``), and pip packages in
|
||
the ``hermes_agent.plugins`` entry-point group. A directory plugin needs a ``plugin.yaml`` manifest
|
||
and an ``__init__.py`` exposing ``register(ctx)``. Plugins register callbacks for ``VALID_HOOKS``
|
||
(core fires ``invoke_hook(name, **kwargs)``) and tools via ``PluginContext.register_tool()``.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import builtins
|
||
import contextvars
|
||
import copy
|
||
import hashlib
|
||
import importlib.metadata
|
||
import importlib.util
|
||
import inspect
|
||
import json
|
||
import logging
|
||
import os
|
||
import queue
|
||
import re
|
||
import sys
|
||
import threading
|
||
import time
|
||
import types
|
||
from contextlib import contextmanager, suppress
|
||
from dataclasses import dataclass, field
|
||
from functools import wraps
|
||
from pathlib import Path
|
||
from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple, Union
|
||
|
||
from hermes_constants import (
|
||
get_hermes_home,
|
||
hermes_home_key,
|
||
reset_hermes_home_override,
|
||
set_hermes_home_override,
|
||
)
|
||
from registration_lifecycle import replacement_coordinator
|
||
from utils import env_var_enabled, fast_safe_load
|
||
from hermes_cli.config import cfg_get, load_config_readonly
|
||
from hermes_cli.middleware import OBSERVER_SCHEMA_VERSION, VALID_MIDDLEWARE
|
||
from hermes_cli.plugin_capabilities import ( # noqa: F401 — re-exported
|
||
CAPABILITY_REGISTRY,
|
||
VALID_CAPABILITY_IDS,
|
||
plugin_capability_granted,
|
||
)
|
||
from hermes_cli.plugin_capabilities import (
|
||
parse_declared_capabilities as _parse_declared_capabilities,
|
||
)
|
||
from hermes_cli.relay_plugin_cutover import (
|
||
LEGACY_RELAY_PLUGIN_KEYS,
|
||
RELAY_PLUGINS_CONFIG_ENV,
|
||
legacy_relay_plugin_keys,
|
||
)
|
||
|
||
|
||
def get_bundled_plugins_dir() -> Path:
|
||
"""Locate the bundled ``plugins/`` directory.
|
||
|
||
Honours ``HERMES_BUNDLED_PLUGINS`` (set by the Nix wrapper / packaged installs) so read-only
|
||
store paths are consulted first. Falls back to the in-repo path used during development.
|
||
"""
|
||
env_override = os.getenv("HERMES_BUNDLED_PLUGINS")
|
||
if env_override:
|
||
return Path(env_override)
|
||
return Path(__file__).resolve().parent.parent / "plugins"
|
||
|
||
try:
|
||
import yaml
|
||
except ImportError: # pragma: no cover – yaml is optional at import time
|
||
yaml = None # type: ignore[assignment]
|
||
|
||
|
||
class PluginToolOverrideError(PermissionError):
|
||
"""Plugin tried to override a built-in tool without ``plugins.entries.<id>.allow_tool_override``."""
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ``HERMES_PLUGINS_DEBUG=1`` tees verbose discovery logs to stderr in addition to agent.log. Read
|
||
# once at import; tests flip it mid-process via ``_install_plugin_debug_handler(force=True)``.
|
||
_PLUGINS_DEBUG = os.getenv("HERMES_PLUGINS_DEBUG", "").strip().lower() in {"1", "true", "yes", "on"}
|
||
_DEBUG_HANDLER_INSTALLED = False
|
||
|
||
|
||
def _install_plugin_debug_handler(force: bool = False) -> None:
|
||
"""When HERMES_PLUGINS_DEBUG is on, tee plugin logs to stderr at DEBUG (once per process)."""
|
||
global _DEBUG_HANDLER_INSTALLED, _PLUGINS_DEBUG
|
||
if force:
|
||
_PLUGINS_DEBUG = os.getenv("HERMES_PLUGINS_DEBUG", "").strip().lower() in {
|
||
"1", "true", "yes", "on",
|
||
}
|
||
if not _PLUGINS_DEBUG or _DEBUG_HANDLER_INSTALLED:
|
||
return
|
||
handler = logging.StreamHandler(sys.stderr)
|
||
handler.setLevel(logging.DEBUG)
|
||
handler.setFormatter(logging.Formatter("[plugins] %(levelname)s %(message)s"))
|
||
logger.addHandler(handler)
|
||
logger.setLevel(logging.DEBUG)
|
||
logger.propagate = True
|
||
_DEBUG_HANDLER_INSTALLED = True
|
||
logger.debug("HERMES_PLUGINS_DEBUG=1 — verbose plugin discovery logging enabled")
|
||
|
||
|
||
_install_plugin_debug_handler()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Constants
|
||
# ---------------------------------------------------------------------------
|
||
|
||
VALID_HOOKS: Set[str] = {
|
||
"pre_tool_call",
|
||
"post_tool_call",
|
||
"transform_terminal_output",
|
||
"transform_tool_result",
|
||
# Return a string to replace the response text (first non-None wins) or None to leave it.
|
||
"transform_llm_output",
|
||
"pre_llm_call",
|
||
"post_llm_call",
|
||
# Streaming observers fired off the token path by agent.plugin_stream_hooks; payloads are
|
||
# immutable normalized text/lifecycle and cannot transform the stream.
|
||
"on_stream_start",
|
||
"on_stream_delta",
|
||
"on_stream_end",
|
||
"on_interim_message",
|
||
# Fired once per turn when the agent edited code and is about to verify/finish. Return
|
||
# {"action": "continue", "message"} (or Claude-Code Stop shape {"decision": "block", "reason"})
|
||
# to keep going; anything else finishes. Bounded by agent.max_verify_nudges.
|
||
"pre_verify",
|
||
"pre_api_request",
|
||
"post_api_request",
|
||
"api_request_error",
|
||
# Fired once per failed API call BEFORE agent/error_classifier.classify_api_error(). Kwargs:
|
||
# provider, model, status_code, error_type, error_code, error_message, error_body, error,
|
||
# approx_tokens, context_length, num_messages. Return None, or {"reason": <FailoverReason name>
|
||
# (required), "retryable"/"should_compress"/"should_rotate_credential"/"should_fallback": bool,
|
||
# "message": str, "error_context": dict}. Run-all-then-pick-first (see
|
||
# get_plugin_error_classification). Privacy: error_message/error_body may be unredacted.
|
||
"transform_api_error_classification",
|
||
"on_session_start",
|
||
"on_session_end",
|
||
"on_session_finalize",
|
||
"on_session_reset",
|
||
# Successful skill lifecycle facts (local skill name visible to plugins).
|
||
"on_skill_lifecycle",
|
||
"subagent_start",
|
||
"subagent_stop",
|
||
# Once per incoming MessageEvent, after the internal-event guard, BEFORE auth/pairing and
|
||
# dispatch. Kwargs: event, gateway, session_store. Return {"action": "skip", "reason"} -> drop;
|
||
# {"action": "rewrite", "text"} -> replace event.text; {"action": "allow"} / None -> normal.
|
||
"pre_gateway_dispatch",
|
||
# Approval observers (tools/approval.py). Return values ignored — plugins cannot veto or
|
||
# pre-answer (use pre_tool_call). Kwargs: command, description, pattern_key, pattern_keys,
|
||
# session_key, surface: "cli" | "gateway" | "smart"; post_approval_response adds choice
|
||
# ("once"|"session"|"always"|"deny"|"timeout"|"smart_approve"|"smart_deny") and decided_by.
|
||
"pre_approval_request",
|
||
"post_approval_response",
|
||
# Fired by transcribe_audio after provider resolution, BEFORE any backend runs. Kwargs:
|
||
# file_path, provider, model, language, prompt, source. Return None or a dict mutating
|
||
# prompt/language/model (registration order, last-writer-wins; file_path is read-only).
|
||
"pre_transcription",
|
||
# Kanban task observers (hermes_cli.kanban_db), fired AFTER the DB commit so a slow plugin never
|
||
# holds the SQLite write lock. Return values ignored. Process matters: claimed fires in the
|
||
# DISPATCHER right before spawn; completed/blocked fire in the WORKER (or whichever process
|
||
# drove it). Kwargs: task_id, board, assignee, run_id, profile_name; completed adds summary,
|
||
# blocked adds reason.
|
||
"kanban_task_claimed",
|
||
"kanban_task_completed",
|
||
"kanban_task_blocked",
|
||
# Kanban worker/mutation/tick observers; return values ignored; fire sites short-circuit on
|
||
# has_hook(). Kwargs: task_id, profile_name, board, assignee, run_id plus:
|
||
# worker_spawned (DISPATCHER, after PID persisted, inside the dispatch lock — stay fast):
|
||
# worker_pid, workspace_path (privacy: project layout/usernames).
|
||
"on_kanban_worker_spawned",
|
||
# worker_exited (tick-derived on dead-PID reclaim): worker_pid, exit_kind ("clean_exit" |
|
||
# "rate_limited" | "nonzero_exit" | "signaled" | "unknown"), exit_code, outcome, retry_status.
|
||
"on_kanban_worker_exited",
|
||
# worker_stale_claim (TTL-expired claim reclaimed; live-PID extensions do NOT fire):
|
||
# worker_pid, heartbeat_stale, retry_status.
|
||
"on_kanban_worker_stale_claim",
|
||
# task_updated (committed task-row write outside claim/complete/block, in whichever process
|
||
# committed it): changed_fields — field NAMES only, never values.
|
||
"on_kanban_task_updated",
|
||
# dispatch_tick: once per dispatch_once, strictly AFTER the dispatch lock is released. Kwargs:
|
||
# board, profile_name, dry_run, outcome ("ok"|"skipped_locked"|"idle"), result: DispatchResult
|
||
# (privacy: task ids, assignees, workspace paths).
|
||
"on_kanban_dispatch_tick",
|
||
# Gateway platform-boundary observer: normalized envelopes only, never raw SDK objects or
|
||
# adapter handles. Kwargs: platform, event_type, payload (event_type-local; see hooks.md).
|
||
# New event types land only together with real fire-sites.
|
||
"gateway_platform_event",
|
||
# Fired BEFORE a recognized slash command's handler on CLI and gateway canonical dispatch.
|
||
# Return values IGNORED in v1. Deliberately NOT fired for the gateway's running-agent intercept
|
||
# path (/stop, /approve, busy_policy) — a slow/hostile plugin must not touch the operator's
|
||
# escape hatches. Kwargs: surface, command (canonical), alias_used, args_raw, session_key,
|
||
# platform.
|
||
"pre_command",
|
||
}
|
||
|
||
# Hooks whose directive the shell-hook response parser has no channel for. VALID_HOOKS doubles as
|
||
# the shell-hook allow-list, so these are refused loudly instead of having output silently ignored.
|
||
SHELL_UNSUPPORTED_HOOKS: Set[str] = {"transform_api_error_classification"}
|
||
|
||
# Allowlist of agent-turn hot-path hooks bounded by plugins.hook_callback_timeout (fail-open:
|
||
# abandon without join — joining reintroduced a shutdown hang). Unlisted hooks run synchronously.
|
||
# Intentionally unbounded: on_session_finalize/reset (last-chance flush — abandon can lose state);
|
||
# subagent_start (observer); pre_gateway_dispatch (policy gate — neither fail mode is acceptable);
|
||
# pre/post_approval_* (approval UX has its own timeout); kanban_* (own heartbeat/stale reclaim).
|
||
_HOOK_TIMEOUT_BOUNDED_HOOKS: Set[str] = {
|
||
"post_tool_call",
|
||
"transform_terminal_output",
|
||
"transform_tool_result",
|
||
"transform_llm_output",
|
||
"pre_llm_call",
|
||
"post_llm_call",
|
||
"pre_api_request",
|
||
"post_api_request",
|
||
"api_request_error",
|
||
"pre_verify",
|
||
"on_session_start",
|
||
"on_session_end",
|
||
}
|
||
|
||
# Policy hooks: timeout / still-running must fail closed (block the tool).
|
||
_HOOK_TIMEOUT_FAIL_CLOSED_HOOKS: Set[str] = {"pre_tool_call"}
|
||
# Documented parent-thread serialization contract — never run on a timeout worker (hooks.md).
|
||
_HOOK_CALLER_THREAD_HOOKS: Set[str] = {"subagent_stop"}
|
||
# After a timeout, suppress the same callback this long so a hung hook cannot pile up threads.
|
||
_HOOK_TIMEOUT_SUPPRESSION_SECONDS = 60.0
|
||
|
||
_PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE = (
|
||
"pre_tool_call plugin callback timed out or is still running"
|
||
)
|
||
|
||
ENTRY_POINTS_GROUP = "hermes_agent.plugins"
|
||
ENTRY_POINT_CAPABILITIES_GROUP = "hermes_agent.plugin_capabilities"
|
||
|
||
|
||
def _select_entry_point_group(entry_points: Any, group: str) -> list:
|
||
"""Return one metadata entry-point group across supported Python APIs."""
|
||
if hasattr(entry_points, "select"):
|
||
return list(entry_points.select(group=group))
|
||
if isinstance(entry_points, dict):
|
||
return list(entry_points.get(group, []))
|
||
return [ep for ep in entry_points if ep.group == group]
|
||
|
||
|
||
def discover_entrypoint_manifests() -> List["PluginManifest"]:
|
||
"""Return metadata-only manifests for installed entry-point plugins.
|
||
|
||
Kind comes from an import-free source scan (memory/model providers route to their own
|
||
discovery). Capabilities come from the companion ``hermes_agent.plugin_capabilities`` group
|
||
(``<plugin-id>.<capability-id>`` entries pointing at the same object), so consent works without
|
||
importing plugin code. Failures are isolated per entry point.
|
||
"""
|
||
manifests: List[PluginManifest] = []
|
||
try:
|
||
eps = importlib.metadata.entry_points()
|
||
group_eps = _select_entry_point_group(eps, ENTRY_POINTS_GROUP)
|
||
capability_eps = _select_entry_point_group(eps, ENTRY_POINT_CAPABILITIES_GROUP)
|
||
except Exception as exc:
|
||
logger.debug("Entry-point scan failed: %s", exc)
|
||
return manifests
|
||
|
||
for ep in group_eps:
|
||
try:
|
||
capabilities = []
|
||
for capability in VALID_CAPABILITY_IDS:
|
||
declaration_name = f"{ep.name}.{capability}"
|
||
if any(
|
||
declaration.name == declaration_name
|
||
and declaration.value == ep.value
|
||
for declaration in capability_eps
|
||
):
|
||
capabilities.append(capability)
|
||
dist = getattr(ep, "dist", None)
|
||
metadata = getattr(dist, "metadata", None)
|
||
manifest = PluginManifest(
|
||
name=ep.name,
|
||
version=str(getattr(dist, "version", "") or ""),
|
||
description=(
|
||
str(metadata.get("Summary", "") or "")
|
||
if metadata is not None
|
||
else ""
|
||
),
|
||
source="entrypoint",
|
||
path=ep.value,
|
||
key=ep.name,
|
||
capabilities=_parse_declared_capabilities(
|
||
capabilities, ep.name
|
||
),
|
||
)
|
||
manifest.kind = _classify_entrypoint_value_kind(ep.value)
|
||
manifests.append(manifest)
|
||
except Exception as exc:
|
||
logger.debug("Entry-point manifest for %r skipped: %s", getattr(ep, "name", "?"), exc)
|
||
return manifests
|
||
|
||
|
||
def _classify_entrypoint_value_kind(value: str) -> str:
|
||
"""Classify an entry-point target by import-free source scan (unresolvable -> standalone)."""
|
||
try:
|
||
module_name = str(value).split(":", 1)[0].strip()
|
||
if not module_name:
|
||
return "standalone"
|
||
return _detect_kind_from_source(_resolve_module_source(module_name)) or "standalone"
|
||
except Exception:
|
||
return "standalone"
|
||
|
||
# System-prompt sections are tightly bounded: they become high-trust prompt bytes charged every turn.
|
||
SYSTEM_PROMPT_SECTION_POSITIONS = frozenset({"after_memory"})
|
||
DEFAULT_SYSTEM_PROMPT_SECTION_MAX_CHARS = 4_000
|
||
MAX_SYSTEM_PROMPT_SECTION_CHARS = 4_000
|
||
MAX_SYSTEM_PROMPT_SECTIONS = 32
|
||
MAX_SYSTEM_PROMPT_SECTIONS_TOTAL_CHARS = 8_000
|
||
_SYSTEM_PROMPT_SECTION_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,127}$")
|
||
_SYSTEM_PROMPT_SECTION_HEADING_PREFIX = "## Plugin Context: "
|
||
PLUGIN_SECTIONS_START = "<!-- hermes-plugin-sections:start -->"
|
||
PLUGIN_SECTIONS_END = "<!-- hermes-plugin-sections:end -->"
|
||
|
||
|
||
def is_valid_system_prompt_section_id(value: Any) -> bool:
|
||
"""Return whether *value* is a stable, heading-safe section identifier."""
|
||
return isinstance(value, str) and bool(_SYSTEM_PROMPT_SECTION_ID_RE.fullmatch(value))
|
||
|
||
|
||
def format_system_prompt_section(section_id: str, content: str) -> str:
|
||
"""Render an auditable, length-framed block recoverable from the full prompt."""
|
||
return (
|
||
f"{_SYSTEM_PROMPT_SECTION_HEADING_PREFIX}{section_id}\n"
|
||
f"<!-- hermes-plugin-section-chars:{len(content)} -->\n\n"
|
||
f"{content}"
|
||
)
|
||
|
||
|
||
def format_system_prompt_sections(sections: list) -> str:
|
||
"""Render the canonical container used for persistence recovery."""
|
||
if not sections:
|
||
return ""
|
||
blocks = [format_system_prompt_section(item.id, item.content) for item in sections]
|
||
return f"{PLUGIN_SECTIONS_START}\n" + "\n\n".join(blocks) + f"\n{PLUGIN_SECTIONS_END}"
|
||
# Reserved event namespace prefix — only core may publish ``hermes:<event>``.
|
||
HERMES_EVENT_NAMESPACE = "hermes"
|
||
|
||
# Event recursion depth cap (subscribers may emit); over-deep emits are dropped with a warning.
|
||
_EVENT_EMIT_DEPTH_CAP = 8
|
||
# Max queued + running events per manager generation; emit never waits — a full budget drops.
|
||
_EVENT_PENDING_CAP = 64
|
||
_EVENT_WORKER_STOP = object()
|
||
|
||
_NS_PARENT = "hermes_plugins"
|
||
_MODULE_NAMESPACE_LOCK = threading.RLock()
|
||
_BARE_MODULE_SCOPE: Dict[str, str] = {}
|
||
|
||
|
||
def _evict_modules(module_name: str) -> None:
|
||
"""Drop ``module_name`` and every ``module_name.*`` submodule from ``sys.modules``."""
|
||
prefix = f"{module_name}."
|
||
for name in [n for n in sys.modules if n == module_name or n.startswith(prefix)]:
|
||
del sys.modules[name]
|
||
|
||
|
||
def _serialized_replacement(method):
|
||
"""Make snapshot → write → lease attachment one atomic transaction."""
|
||
@wraps(method)
|
||
def wrapped(*args, **kwargs):
|
||
with replacement_coordinator.transaction():
|
||
return method(*args, **kwargs)
|
||
|
||
return wrapped
|
||
|
||
|
||
@contextmanager
|
||
def _plugin_home_scope(home: Path):
|
||
"""Bind discovery and loading to the manager's immutable Hermes home."""
|
||
token = set_hermes_home_override(home)
|
||
try:
|
||
yield
|
||
finally:
|
||
reset_hermes_home_override(token)
|
||
|
||
|
||
_env_enabled = env_var_enabled # imported by plugins/memory
|
||
|
||
|
||
def _get_disabled_plugins() -> set:
|
||
"""Read ``plugins.disabled`` — a deny-list that wins over ``plugins.enabled``."""
|
||
try:
|
||
from hermes_cli.config import load_config
|
||
config = load_config()
|
||
disabled = cfg_get(config, "plugins", "disabled", default=[])
|
||
return set(disabled) if isinstance(disabled, list) else set()
|
||
except Exception:
|
||
return set()
|
||
|
||
|
||
def _get_enabled_plugins() -> Optional[set]:
|
||
"""Read the ``plugins.enabled`` allow-list (plugins are opt-in).
|
||
|
||
``None`` = key missing/malformed ("nothing enabled yet"; the first ``migrate_config`` run
|
||
grandfathers installed user plugins); ``set()`` = explicitly empty; else the allow-list.
|
||
"""
|
||
try:
|
||
from hermes_cli.config import load_config
|
||
plugins_cfg = load_config().get("plugins")
|
||
enabled = plugins_cfg.get("enabled") if isinstance(plugins_cfg, dict) else None
|
||
return set(enabled) if isinstance(enabled, list) else None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Data classes
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform", "model-provider"}
|
||
|
||
|
||
def _portable_skill_namespace(key: str) -> str:
|
||
"""Return a readable, collision-resistant namespace for a portable plugin."""
|
||
|
||
slug = "".join(
|
||
ch if ch.isascii() and (ch.isalnum() or ch in "_-") else "-"
|
||
for ch in key.lower()
|
||
)
|
||
slug = slug.strip("-_") or "plugin"
|
||
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:8]
|
||
return f"agent-plugin-{slug}-{digest}"
|
||
|
||
|
||
def _display_author(value: object) -> str:
|
||
"""Normalize a manifest author value for the string PluginManifest field."""
|
||
if isinstance(value, Mapping):
|
||
return ", ".join(
|
||
str(value[field])
|
||
for field in ("name", "email", "url")
|
||
if value.get(field)
|
||
)
|
||
return "" if value is None else str(value)
|
||
|
||
|
||
# Manifest v2 parsing. Unknown plugin.yaml fields are forward-compat surface: warn (debug for v1
|
||
# files, warning for v2+) and continue loading.
|
||
_KNOWN_MANIFEST_FIELDS: Set[str] = {
|
||
# v1
|
||
"name", "version", "description", "author", "requires_env",
|
||
"provides_tools", "provides_hooks", "kind", "hooks", "label",
|
||
"optional_env", "platforms", "external_dependencies", "pip_dependencies",
|
||
"provides_browser_providers", "provides_web_providers",
|
||
# v2
|
||
"manifest_version", "api_version", "requires_plugins",
|
||
"python_dependencies", "config_schema", "license", "homepage", "tags",
|
||
# owned by sibling sub-issues but reserved so their manifests don't warn
|
||
"capabilities", "emits", "listens", "hermes", "depends",
|
||
}
|
||
|
||
# Highest manifest schema version this Hermes understands.
|
||
SUPPORTED_MANIFEST_VERSION = 2
|
||
|
||
_CONFIG_SCHEMA_TYPES: Dict[str, tuple] = {
|
||
"str": (str,),
|
||
"string": (str,),
|
||
"int": (int,),
|
||
"integer": (int,),
|
||
"float": (int, float),
|
||
"number": (int, float),
|
||
"bool": (bool,),
|
||
"boolean": (bool,),
|
||
"list": (list,),
|
||
"array": (list,),
|
||
"dict": (dict,),
|
||
"object": (dict,),
|
||
}
|
||
|
||
|
||
def _manifest_field_of_type(data: Mapping, key: str, field_name: str, typ, what: str):
|
||
"""Return ``data[field_name]`` when absent or of ``typ``; warn and return None otherwise."""
|
||
raw = data.get(field_name)
|
||
if raw is not None and not isinstance(raw, typ):
|
||
logger.warning("Plugin %s: %s must be %s; ignoring", key, field_name, what)
|
||
return None
|
||
return raw
|
||
|
||
|
||
def _parse_manifest_v2_fields(data: Mapping, key: str) -> Dict[str, Any]:
|
||
"""Validate/normalize manifest v2 fields into PluginManifest kwargs (warnings, never failures)."""
|
||
out: Dict[str, Any] = {}
|
||
|
||
# manifest_version — absent means v1 (supported forever).
|
||
raw_mv = data.get("manifest_version", 1)
|
||
try:
|
||
mv = int(raw_mv)
|
||
except (TypeError, ValueError):
|
||
logger.warning(
|
||
"Plugin %s: manifest_version %r is not an integer; treating as 1",
|
||
key, raw_mv,
|
||
)
|
||
mv = 1
|
||
if mv > SUPPORTED_MANIFEST_VERSION:
|
||
logger.warning(
|
||
"Plugin %s: manifest_version %d is newer than this Hermes "
|
||
"supports (%d); loading anyway and ignoring unknown fields",
|
||
key, mv, SUPPORTED_MANIFEST_VERSION,
|
||
)
|
||
out["manifest_version"] = mv
|
||
|
||
# api_version — plugin API generation (independent of manifest_version).
|
||
raw_api = data.get("api_version")
|
||
out["api_version"] = None
|
||
if raw_api is not None:
|
||
try:
|
||
out["api_version"] = int(raw_api)
|
||
except (TypeError, ValueError):
|
||
logger.warning("Plugin %s: api_version %r is not an integer; ignoring", key, raw_api)
|
||
|
||
# requires_plugins — list of {id, version_range?} (str shorthand ok).
|
||
deps: List[Dict[str, Any]] = []
|
||
for item in _manifest_field_of_type(data, key, "requires_plugins", list, "a list") or []:
|
||
if isinstance(item, str):
|
||
deps.append({"id": item, "version_range": None})
|
||
elif isinstance(item, Mapping) and isinstance(item.get("id"), str) and item["id"]:
|
||
vr = item.get("version_range")
|
||
deps.append({"id": item["id"], "version_range": str(vr) if vr is not None else None})
|
||
else:
|
||
logger.warning(
|
||
"Plugin %s: requires_plugins entry %r must be a plugin id "
|
||
"string or a {id, version_range} mapping; skipping", key, item,
|
||
)
|
||
out["requires_plugins"] = deps
|
||
|
||
# python_dependencies — validated and surfaced ONLY; never auto-installed.
|
||
pydeps: List[str] = []
|
||
raw_pydeps = _manifest_field_of_type(
|
||
data, key, "python_dependencies", list, "a list of requirement strings"
|
||
)
|
||
for item in raw_pydeps or []:
|
||
if isinstance(item, str) and item.strip():
|
||
pydeps.append(item.strip())
|
||
else:
|
||
logger.warning(
|
||
"Plugin %s: python_dependencies entry %r must be a non-empty "
|
||
"requirement string; skipping", key, item,
|
||
)
|
||
out["python_dependencies"] = pydeps
|
||
|
||
# config_schema — mapping of key -> {type?, default?, description?, required?}.
|
||
schema: Dict[str, Any] = {}
|
||
raw_schema = _manifest_field_of_type(data, key, "config_schema", Mapping, "a mapping")
|
||
for skey, spec in (raw_schema or {}).items():
|
||
if not isinstance(spec, Mapping):
|
||
logger.warning(
|
||
"Plugin %s: config_schema entry %r must be a mapping "
|
||
"(e.g. {type: str}); skipping", key, skey,
|
||
)
|
||
continue
|
||
stype = spec.get("type")
|
||
if stype is not None and str(stype).lower() not in _CONFIG_SCHEMA_TYPES:
|
||
logger.warning(
|
||
"Plugin %s: config_schema key %r declares unknown type %r "
|
||
"(known: %s); type check will be skipped for it",
|
||
key, skey, stype, ", ".join(sorted(_CONFIG_SCHEMA_TYPES)),
|
||
)
|
||
schema[str(skey)] = dict(spec)
|
||
out["config_schema"] = schema
|
||
|
||
# Standard metadata.
|
||
out["license"] = str(data.get("license") or "")
|
||
out["homepage"] = str(data.get("homepage") or "")
|
||
raw_tags = _manifest_field_of_type(data, key, "tags", list, "a list")
|
||
out["tags"] = [str(t) for t in (raw_tags or [])]
|
||
|
||
# Forward compat: unknown fields warn (never fail); v1 manifests only at debug.
|
||
unknown = sorted(set(data.keys()) - _KNOWN_MANIFEST_FIELDS)
|
||
if unknown:
|
||
log = logger.warning if mv >= 2 else logger.debug
|
||
log(
|
||
"Plugin %s: unknown manifest field(s) ignored: %s "
|
||
"(newer manifest schema or typo; plugin still loads)",
|
||
key, ", ".join(unknown),
|
||
)
|
||
|
||
return out
|
||
|
||
|
||
def validate_config_schema(plugin_id: str, schema: Mapping, settings: Mapping) -> List[str]:
|
||
"""Return actionable warning strings for settings vs config_schema mismatches (never raises)."""
|
||
warnings: List[str] = []
|
||
if not isinstance(schema, Mapping) or not isinstance(settings, Mapping):
|
||
return warnings
|
||
for skey, spec in schema.items():
|
||
if not isinstance(spec, Mapping):
|
||
continue
|
||
present = skey in settings
|
||
if not present:
|
||
if spec.get("required") and "default" not in spec:
|
||
warnings.append(
|
||
f"plugins.entries.{plugin_id}.settings.{skey} is required "
|
||
"by the plugin's config_schema but is not set"
|
||
)
|
||
continue
|
||
stype = spec.get("type")
|
||
expected = _CONFIG_SCHEMA_TYPES.get(str(stype).lower()) if stype else None
|
||
if expected is not None:
|
||
value = settings[skey]
|
||
# bool is an int subclass — don't let True satisfy int/float.
|
||
ok = isinstance(value, expected) and not (
|
||
isinstance(value, bool) and bool not in expected
|
||
)
|
||
if not ok:
|
||
warnings.append(
|
||
f"plugins.entries.{plugin_id}.settings.{skey} should be "
|
||
f"{stype} (got {type(value).__name__})"
|
||
)
|
||
return warnings
|
||
|
||
|
||
def resolve_plugin_load_order(manifests: Mapping[str, "PluginManifest"]) -> List[str]:
|
||
"""Return plugin keys in dependency order: B before A when A requires B; alphabetical ties.
|
||
|
||
A cycle warns and falls back to alphabetical order for all; a missing dependency warns once
|
||
but never removes the dependent plugin (loads never hard-fail on advisory deps).
|
||
"""
|
||
import graphlib
|
||
|
||
keys = sorted(manifests.keys())
|
||
by_name: Dict[str, str] = {}
|
||
for k in keys:
|
||
name = manifests[k].name
|
||
if name and name not in by_name:
|
||
by_name[name] = k
|
||
|
||
def _resolve_dep(dep_id: str) -> Optional[str]:
|
||
if dep_id in manifests:
|
||
return dep_id
|
||
return by_name.get(dep_id)
|
||
|
||
edges: Dict[str, Set[str]] = {k: set() for k in keys}
|
||
for k in keys:
|
||
for dep in manifests[k].requires_plugins:
|
||
dep_id = dep.get("id") if isinstance(dep, Mapping) else None
|
||
if not dep_id:
|
||
continue
|
||
resolved = _resolve_dep(dep_id)
|
||
if resolved is None:
|
||
logger.warning(
|
||
"Plugin %s requires plugin '%s' which is not enabled/"
|
||
"installed; loading anyway (probe availability at runtime "
|
||
"via ctx.has_plugin). Run `hermes plugins enable %s` if "
|
||
"it is installed.",
|
||
k, dep_id, dep_id,
|
||
)
|
||
continue
|
||
if resolved == k:
|
||
logger.warning("Plugin %s declares a dependency on itself; ignoring", k)
|
||
continue
|
||
edges[k].add(resolved)
|
||
|
||
sorter = graphlib.TopologicalSorter(edges)
|
||
try:
|
||
sorter.prepare()
|
||
except graphlib.CycleError as exc:
|
||
cycle = exc.args[1] if len(exc.args) > 1 else []
|
||
logger.warning(
|
||
"Plugin dependency cycle detected (%s); falling back to "
|
||
"alphabetical load order for all plugins",
|
||
" -> ".join(str(c) for c in cycle),
|
||
)
|
||
return keys
|
||
|
||
ordered: List[str] = []
|
||
while sorter.is_active():
|
||
ready = sorted(sorter.get_ready())
|
||
ordered.extend(ready)
|
||
sorter.done(*ready)
|
||
return ordered
|
||
|
||
|
||
def _detect_kind_from_source(source_text: str) -> Optional[str]:
|
||
"""Return the kind implied by source markers (mirrors plugins/memory ``_is_memory_provider_dir``).
|
||
|
||
Memory-provider markers -> ``exclusive``; ``register_provider`` + ``ProviderProfile`` ->
|
||
``model-provider``; else ``None``. Keeps both kinds out of the general manager's eager import.
|
||
"""
|
||
if "register_memory_provider" in source_text or "MemoryProvider" in source_text:
|
||
return "exclusive"
|
||
if "register_provider" in source_text and "ProviderProfile" in source_text:
|
||
return "model-provider"
|
||
return None
|
||
|
||
|
||
def _read_source_from_origin(origin: Optional[str], limit: int = 8192) -> str:
|
||
"""First ``limit`` chars of a module's source (``.pyc`` mapped back to ``.py``); "" on failure."""
|
||
if not origin:
|
||
return ""
|
||
if origin.endswith((".pyc", ".pyo")):
|
||
try:
|
||
origin = importlib.util.source_from_cache(origin)
|
||
except Exception:
|
||
return ""
|
||
if not origin.endswith(".py"):
|
||
return ""
|
||
try:
|
||
return Path(origin).read_text(encoding="utf-8", errors="replace")[:limit]
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def resolve_module_origin(module_name: str) -> Optional[str]:
|
||
"""Return a module's source path WITHOUT importing it, or ``None``.
|
||
|
||
``find_spec`` on a dotted name imports the parent package, so only the top-level name uses it;
|
||
remaining segments are walked through ``submodule_search_locations`` by hand. Namespace/zipped/
|
||
extension modules return ``None``. Shared with ``plugins/memory/__init__.py``.
|
||
"""
|
||
parts = [p for p in module_name.split(".") if p]
|
||
if not parts:
|
||
return None
|
||
try:
|
||
spec = importlib.util.find_spec(parts[0])
|
||
if spec is None or not spec.origin:
|
||
return None
|
||
if len(parts) == 1:
|
||
return spec.origin
|
||
|
||
search_paths = spec.submodule_search_locations
|
||
if not search_paths:
|
||
return None
|
||
for i, part in enumerate(parts[1:], start=2):
|
||
found_origin = None
|
||
next_paths = None
|
||
for base in search_paths:
|
||
base = Path(base)
|
||
pkg_init = base / part / "__init__.py"
|
||
if pkg_init.is_file():
|
||
found_origin = str(pkg_init)
|
||
next_paths = [base / part]
|
||
break
|
||
mod_file = base / (part + ".py")
|
||
if mod_file.is_file():
|
||
found_origin = str(mod_file)
|
||
break
|
||
if found_origin is None:
|
||
return None
|
||
if i == len(parts) or next_paths is None:
|
||
return found_origin
|
||
search_paths = next_paths
|
||
return None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _resolve_module_source(module_name: str, limit: int = 8192) -> str:
|
||
"""First ``limit`` chars of a module's source without importing it ("" when unresolvable)."""
|
||
return _read_source_from_origin(resolve_module_origin(module_name), limit)
|
||
|
||
|
||
@dataclass
|
||
class PluginManifest:
|
||
"""Parsed representation of a plugin.yaml manifest."""
|
||
|
||
name: str
|
||
version: str = ""
|
||
description: str = ""
|
||
author: str = ""
|
||
requires_env: List[Union[str, Dict[str, Any]]] = field(default_factory=list)
|
||
provides_tools: List[str] = field(default_factory=list)
|
||
provides_hooks: List[str] = field(default_factory=list)
|
||
source: str = "" # "bundled", "user", "project", or "entrypoint"
|
||
path: Optional[str] = None
|
||
# ``standalone`` (default; opt-in via plugins.enabled) | ``backend`` (pluggable backend for a
|
||
# core tool; bundled auto-load, user-installed gated) | ``exclusive`` (one active provider,
|
||
# selected via <category>.provider; own discovery, general scanner skips) | ``platform``
|
||
# (gateway adapter; bundled auto-load, user-installed gated as untrusted code).
|
||
kind: str = "standalone"
|
||
# Path-derived registry key used by plugins.enabled/disabled and `hermes plugins list`:
|
||
# ``disk-cleanup`` for a flat plugin, ``image_gen/openai`` for a category plugin. Empty -> name.
|
||
key: str = ""
|
||
portable: bool = False
|
||
skill_namespace: str = ""
|
||
# Declared capability ids, normalized to KNOWN ids. Declaration is consent metadata, NOT a
|
||
# grant: live only via plugins.entries.<id>.granted_capabilities or the legacy allow_* key.
|
||
capabilities: List[str] = field(default_factory=list)
|
||
# Manifest v2 fields — all optional and additive. manifest_version versions the FILE FORMAT
|
||
# (v1 supported forever); api_version is the runtime plugin API generation (None = current).
|
||
manifest_version: int = 1
|
||
api_version: Optional[int] = None
|
||
# Advisory deps [{"id", "version_range"}]: missing ones warn but load; they order the load.
|
||
requires_plugins: List[Dict[str, Any]] = field(default_factory=list)
|
||
# Declared pip deps — VALIDATED AND SURFACED ONLY, never auto-installed.
|
||
python_dependencies: List[str] = field(default_factory=list)
|
||
# Schema for plugins.entries.<id>.settings; mismatches warn, never fail.
|
||
config_schema: Dict[str, Any] = field(default_factory=dict)
|
||
license: str = ""
|
||
homepage: str = ""
|
||
tags: List[str] = field(default_factory=list)
|
||
# Event-bus declarations, advisory (discoverability only): ``emits`` bare names published under
|
||
# ``<key>:``; ``listens`` fully-qualified ``<plugin>:<event>`` names.
|
||
emits: List[str] = field(default_factory=list)
|
||
listens: List[str] = field(default_factory=list)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PluginSystemPromptSection:
|
||
"""A plugin-owned section rendered once for each new session."""
|
||
|
||
id: str
|
||
content: Union[str, Callable[[Mapping[str, Any]], str]]
|
||
position: str
|
||
max_chars: int
|
||
plugin: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RenderedPluginSystemPromptSection:
|
||
"""Validated prompt bytes frozen on the owning AIAgent."""
|
||
|
||
id: str
|
||
content: str
|
||
position: str
|
||
plugin: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _EventSubscription:
|
||
"""Host-owned subscription ledger entry."""
|
||
|
||
owner: str
|
||
callback: Callable
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _QueuedPluginEvent:
|
||
"""Immutable dispatch envelope consumed by the event worker."""
|
||
|
||
event: str
|
||
payload: Dict[str, Any]
|
||
subscriptions: tuple[_EventSubscription, ...]
|
||
depth: int
|
||
generation: int
|
||
|
||
|
||
@dataclass
|
||
class LoadedPlugin:
|
||
"""Runtime state for a single loaded plugin."""
|
||
|
||
manifest: PluginManifest
|
||
module: Optional[types.ModuleType] = None
|
||
tools_registered: List[str] = field(default_factory=list)
|
||
hooks_registered: List[str] = field(default_factory=list)
|
||
middleware_registered: List[str] = field(default_factory=list)
|
||
commands_registered: List[str] = field(default_factory=list)
|
||
enabled: bool = False
|
||
error: Optional[str] = None
|
||
# Bundled platform recorded as a not-yet-imported loader (see _register_deferred_platform).
|
||
deferred: bool = False
|
||
|
||
|
||
@dataclass
|
||
class PluginRegistration:
|
||
"""One host-owned registration plus its inverse, so force reload unwinds registries in reverse
|
||
order (including restoring the entry an override replaced)."""
|
||
|
||
kind: str
|
||
key: str
|
||
release: Callable[[], None]
|
||
plugin_key: str = ""
|
||
# Process-global host infrastructure (e.g. dashboard-auth providers): kept out of
|
||
# ``_registration_order`` so unload-all cannot dispose it, but still disposed by a *targeted*
|
||
# unload and evicted on force re-discovery when the plugin no longer re-registers it.
|
||
persistent: bool = False
|
||
_disposed: bool = field(default=False, init=False, repr=False)
|
||
_on_dispose: Optional[Callable[["PluginRegistration"], None]] = field(
|
||
default=None, init=False, repr=False
|
||
)
|
||
|
||
@property
|
||
def active(self) -> bool:
|
||
"""Whether this handle still owns an active registration."""
|
||
return not self._disposed
|
||
|
||
def dispose(self) -> None:
|
||
"""Release this registration once; repeated disposal is harmless."""
|
||
if self._disposed:
|
||
return
|
||
self._disposed = True
|
||
try:
|
||
self.release()
|
||
finally:
|
||
if self._on_dispose is not None:
|
||
self._on_dispose(self)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# PluginContext – handed to each plugin's ``register()`` function
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_PLUGIN_SETTING_SEGMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
|
||
_PLUGIN_SETTING_RESERVED_ROOTS = frozenset({"model", "plugins", "security", "settings"})
|
||
_PLUGIN_STATE_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
||
_PLUGIN_STATE_QUOTA_BYTES = 10 * 1024 * 1024
|
||
_PLUGIN_STATE_LOCKS: Dict[str, threading.RLock] = {}
|
||
_PLUGIN_STATE_LOCKS_GUARD = threading.Lock()
|
||
|
||
|
||
def _plugin_relative_segments(key: str) -> tuple[str, ...]:
|
||
"""Validate/split a plugin-relative settings key; global paths, traversal, and core roots are
|
||
rejected before any config read."""
|
||
if not isinstance(key, str):
|
||
raise ValueError("Expected a plugin-relative config key string")
|
||
segments = tuple(key.split("."))
|
||
if (
|
||
not key
|
||
or "/" in key
|
||
or "\\" in key
|
||
or any(
|
||
not _PLUGIN_SETTING_SEGMENT_RE.fullmatch(segment) for segment in segments
|
||
)
|
||
or segments[0].lower() in _PLUGIN_SETTING_RESERVED_ROOTS
|
||
):
|
||
raise ValueError(
|
||
"Expected a plugin-relative config key such as 'endpoint' or "
|
||
"'retry.policy'; global, cross-plugin, and traversal paths are forbidden"
|
||
)
|
||
return segments
|
||
|
||
|
||
def _nested_plugin_value(root: object, segments: tuple[str, ...], default: Any) -> Any:
|
||
current = root
|
||
for segment in segments:
|
||
if not isinstance(current, Mapping) or segment not in current:
|
||
return default
|
||
current = current[segment]
|
||
return current
|
||
|
||
|
||
def _nested_plugin_mapping(segments: tuple[str, ...], value: Any) -> dict[str, Any]:
|
||
nested: Any = value
|
||
for segment in reversed(segments):
|
||
nested = {segment: nested}
|
||
return nested
|
||
|
||
|
||
def _plugin_data_namespace(plugin_id: str, skill_namespace: str) -> str:
|
||
"""Return one Windows-safe directory component for plugin-owned data."""
|
||
candidate = skill_namespace or plugin_id
|
||
if (
|
||
skill_namespace
|
||
and candidate.startswith("agent-plugin-")
|
||
and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,191}", candidate)
|
||
):
|
||
# Portable Agent Plugins already receive this exact PLUGIN_DATA path.
|
||
return candidate
|
||
# Fixed prefix avoids Windows reserved device names; digest prevents fold collisions.
|
||
return _portable_skill_namespace(candidate)
|
||
|
||
|
||
def _state_thread_lock(path: Path) -> threading.RLock:
|
||
key = str(path.resolve(strict=False))
|
||
with _PLUGIN_STATE_LOCKS_GUARD:
|
||
return _PLUGIN_STATE_LOCKS.setdefault(key, threading.RLock())
|
||
|
||
|
||
@contextmanager
|
||
def _locked_plugin_state(path: Path):
|
||
"""Serialize state read-modify-write across threads/processes (fcntl / msvcrt). The lock lives
|
||
in a sibling file because atomic replacement changes the target's inode."""
|
||
lock_path = path.with_name(f".{path.name}.lock")
|
||
thread_lock = _state_thread_lock(lock_path)
|
||
with thread_lock:
|
||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||
with open(lock_path, "a+b") as handle:
|
||
if os.name == "nt": # pragma: no cover - exercised on Windows CI
|
||
import msvcrt
|
||
|
||
if handle.seek(0, os.SEEK_END) == 0:
|
||
handle.write(b"\0")
|
||
handle.flush()
|
||
handle.seek(0)
|
||
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
||
else:
|
||
import fcntl
|
||
|
||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||
try:
|
||
yield
|
||
finally:
|
||
if os.name == "nt": # pragma: no cover - exercised on Windows CI
|
||
handle.seek(0)
|
||
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||
else:
|
||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||
|
||
|
||
class PluginState:
|
||
"""Atomic, quota-bounded JSON key/value state owned by one plugin."""
|
||
|
||
def __init__(self, plugin_id: str, skill_namespace: str = "") -> None:
|
||
self._data_namespace = _plugin_data_namespace(plugin_id, skill_namespace)
|
||
|
||
@property
|
||
def data_dir(self) -> Path:
|
||
"""Profile-scoped directory matching portable plugins' PLUGIN_DATA."""
|
||
return get_hermes_home() / "plugin-data" / self._data_namespace
|
||
|
||
@property
|
||
def path(self) -> Path:
|
||
return self.data_dir / "state.json"
|
||
|
||
@property
|
||
def quota_bytes(self) -> int:
|
||
return _PLUGIN_STATE_QUOTA_BYTES
|
||
|
||
@staticmethod
|
||
def _validate_key(key: str) -> None:
|
||
if (
|
||
not isinstance(key, str)
|
||
or not _PLUGIN_STATE_KEY_RE.fullmatch(key)
|
||
or ".." in key
|
||
):
|
||
raise ValueError(
|
||
"Plugin state keys must be 1-128 characters using letters, "
|
||
"numbers, '_', '-', '.', or ':' (without '..')"
|
||
)
|
||
|
||
def _read_unlocked(self) -> dict[str, Any]:
|
||
try:
|
||
with open(self.path, encoding="utf-8") as handle:
|
||
data = json.load(handle)
|
||
except FileNotFoundError:
|
||
return {}
|
||
except (OSError, ValueError) as exc:
|
||
raise RuntimeError(f"Cannot parse plugin state {self.path}: {exc}") from exc
|
||
if not isinstance(data, dict):
|
||
raise RuntimeError(f"Cannot parse plugin state {self.path}: root must be an object")
|
||
return data
|
||
|
||
def get(self, key: str, default: Any = None) -> Any:
|
||
"""Read a JSON value, returning *default* when the key is absent."""
|
||
self._validate_key(key)
|
||
with _locked_plugin_state(self.path):
|
||
return self._read_unlocked().get(key, default)
|
||
|
||
def set(self, key: str, value: Any) -> None:
|
||
"""Atomically set one JSON value without dropping concurrent updates."""
|
||
self._validate_key(key)
|
||
with _locked_plugin_state(self.path):
|
||
data = self._read_unlocked()
|
||
data[key] = value
|
||
try:
|
||
encoded = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
|
||
except (TypeError, ValueError) as exc:
|
||
raise ValueError(
|
||
f"Plugin state value for {key!r} is not JSON-serializable"
|
||
) from exc
|
||
if len(encoded) > self.quota_bytes:
|
||
raise ValueError(
|
||
f"Plugin state quota exceeded: {len(encoded)} bytes is greater "
|
||
f"than the {self.quota_bytes}-byte per-plugin quota"
|
||
)
|
||
from utils import atomic_json_write
|
||
|
||
atomic_json_write(self.path, data, mode=0o600)
|
||
|
||
|
||
class PluginContext:
|
||
"""Facade given to plugins so they can register tools and hooks."""
|
||
|
||
def __init__(self, manifest: PluginManifest, manager: "PluginManager"):
|
||
self.manifest = manifest
|
||
self._manager = manager
|
||
# Lazy-built facades (see the matching properties).
|
||
self._llm: Any = None
|
||
self._subagent_lifecycle: Any = None
|
||
self._state: PluginState | None = None
|
||
self._platform_actions: Any = None
|
||
|
||
@property
|
||
def plugin_id(self) -> str:
|
||
"""Return the effective registry id used for this plugin's namespaces."""
|
||
return self.manifest.key or self.manifest.name
|
||
|
||
def has_plugin(self, plugin_id: str) -> bool:
|
||
"""Return True when another plugin is loaded and enabled (runtime probe for advisory
|
||
``requires_plugins``). Matches on registry key or manifest name."""
|
||
return any(
|
||
loaded.enabled and (key == plugin_id or loaded.manifest.name == plugin_id)
|
||
for key, loaded in self._manager._plugins.items()
|
||
)
|
||
|
||
# -- namespaced config and durable state --------------------------------
|
||
|
||
def get_config(self, key: str, default: Any = None) -> Any:
|
||
"""Read plugin-relative ``plugins.entries.<plugin_id>.settings.<key>`` (falls back to the
|
||
legacy ``config`` subtree for migration compatibility)."""
|
||
try:
|
||
segments = _plugin_relative_segments(key)
|
||
except ValueError:
|
||
logger.warning("Rejected config path %r from plugin %s", key, self.plugin_id)
|
||
raise
|
||
from hermes_cli.config import load_config_readonly
|
||
|
||
config = load_config_readonly() or {}
|
||
plugins = config.get("plugins") if isinstance(config, Mapping) else None
|
||
entries = plugins.get("entries") if isinstance(plugins, Mapping) else None
|
||
entry = entries.get(self.plugin_id) if isinstance(entries, Mapping) else None
|
||
if not isinstance(entry, Mapping):
|
||
return default
|
||
missing = object()
|
||
value = _nested_plugin_value(entry.get("settings"), segments, missing)
|
||
if value is not missing:
|
||
return value
|
||
return _nested_plugin_value(entry.get("config"), segments, default)
|
||
|
||
def set_config(self, key: str, value: Any) -> None:
|
||
"""Atomically write one value in this plugin's ``settings`` subtree."""
|
||
try:
|
||
segments = _plugin_relative_segments(key)
|
||
except ValueError:
|
||
logger.warning("Rejected config path %r from plugin %s", key, self.plugin_id)
|
||
raise
|
||
from hermes_cli import config as config_mod
|
||
|
||
if config_mod.is_managed():
|
||
raise PermissionError("Plugin settings cannot be changed in a managed install")
|
||
from hermes_cli import managed_scope
|
||
|
||
dotted_path = ".".join(("plugins", "entries", self.plugin_id, "settings", *segments))
|
||
if managed_scope.is_key_managed(dotted_path):
|
||
raise PermissionError(f"Plugin setting {dotted_path!r} is administrator-managed")
|
||
partial = {
|
||
"plugins": {
|
||
"entries": {
|
||
self.plugin_id: {
|
||
"settings": _nested_plugin_mapping(segments, value),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
full_path = ("plugins", "entries", self.plugin_id, "settings", *segments)
|
||
# The lock covers merge-read plus atomic save so sibling plugin writes (threads or
|
||
# processes) cannot race between the two steps.
|
||
with _locked_plugin_state(config_mod.get_config_path()):
|
||
with config_mod._CONFIG_LOCK:
|
||
# Fail closed on malformed YAML: save_config degrades parse failures to {} — safe
|
||
# for reads, destructive for read-modify-write.
|
||
config_mod.read_user_config_raw()
|
||
config_mod.save_config(partial, preserve_keys={full_path}, merge_existing=True)
|
||
|
||
@property
|
||
def state(self) -> PluginState:
|
||
"""Return this plugin's profile-scoped durable JSON state facade."""
|
||
if self._state is None:
|
||
self._state = PluginState(self.plugin_id, self.manifest.skill_namespace)
|
||
return self._state
|
||
|
||
@property
|
||
def platform_actions(self):
|
||
"""Capability-gated platform action facade (``add_reaction``, ``set_thread_title``).
|
||
|
||
Every call re-checks ``gateway.platform_actions`` (legacy gate
|
||
``plugins.entries.<id>.allow_platform_actions``, default OFF) and returns ``{"ok": bool,
|
||
...}`` — verbs never raise into hook dispatch; no adapter handles or raw SDK objects.
|
||
"""
|
||
if self._platform_actions is None:
|
||
from hermes_cli.platform_actions import PlatformActions
|
||
|
||
self._platform_actions = PlatformActions(self.plugin_id)
|
||
return self._platform_actions
|
||
|
||
def _track(
|
||
self,
|
||
kind: str,
|
||
key: str,
|
||
release: Callable[[], None],
|
||
*,
|
||
persistent: bool = False,
|
||
) -> PluginRegistration:
|
||
"""Record host-owned cleanup for a successful registration (see
|
||
:meth:`PluginManager._track_registration` for ``persistent``)."""
|
||
return self._manager._track_registration(
|
||
self.manifest, kind, key, release, persistent=persistent
|
||
)
|
||
|
||
def _track_replacement(
|
||
self, kind: str, key: str, *, slot: tuple, current: Any, previous: Any,
|
||
restore: Callable[[Any], bool],
|
||
) -> PluginRegistration:
|
||
"""Track one generation in a replaceable manager-local registration slot."""
|
||
lease = replacement_coordinator.acquire(
|
||
slot, current=current, previous=previous, restore=restore
|
||
)
|
||
return self._track(kind, key, lease.dispose)
|
||
|
||
def _track_mapping_entry(
|
||
self, kind: str, key: str, mapping: Dict[str, Any], entry: Any, previous: Any
|
||
) -> PluginRegistration:
|
||
"""Store ``entry`` under ``key`` in a manager-local mapping and lease the slot.
|
||
|
||
Unload restores ``previous`` (or removes the key) only while ``entry`` is still current.
|
||
"""
|
||
mapping[key] = entry
|
||
return self._track_replacement(
|
||
kind, key,
|
||
slot=("manager_mapping", builtins.id(mapping), key),
|
||
current=entry, previous=previous,
|
||
restore=lambda replacement: self._manager._restore_mapping(
|
||
mapping, key, entry, replacement
|
||
),
|
||
)
|
||
|
||
def _register_scoped_provider(
|
||
self,
|
||
provider: Any,
|
||
*,
|
||
kind: str,
|
||
base_class: type,
|
||
registry: Any,
|
||
label: str,
|
||
article: str = "a",
|
||
normalize: Optional[Callable[[str], str]] = lambda n: n.strip(),
|
||
register: Optional[Callable[..., Any]] = None,
|
||
reject_message: Optional[str] = None,
|
||
) -> Optional[PluginRegistration]:
|
||
"""Shared body of the ``register_<category>_provider`` methods.
|
||
|
||
Type-check (warn + ignore on mismatch), register in the scope-keyed ``registry``, lease the
|
||
slot so unload restores the displaced entry. Returns ``None`` when the registry refused or
|
||
replaced the provider (``ValueError`` with ``reject_message`` set, or a falsy ``register``).
|
||
"""
|
||
if not isinstance(provider, base_class):
|
||
logger.warning(
|
||
"Plugin '%s' tried to register %s %s that does not inherit from %s. Ignoring.",
|
||
self.manifest.name, article, label, base_class.__name__,
|
||
)
|
||
return None
|
||
registry_name = provider.name if normalize is None else normalize(provider.name)
|
||
scope = self._manager.scope_key
|
||
previous = registry.snapshot_registration(registry_name, scope=scope)
|
||
register_fn = register or registry.register_provider
|
||
try:
|
||
accepted = register_fn(provider, scope=scope)
|
||
except ValueError as exc:
|
||
if reject_message is None:
|
||
raise
|
||
logger.warning(reject_message, self.manifest.name, exc)
|
||
return None
|
||
if register is not None and not accepted:
|
||
return None
|
||
if registry.snapshot_registration(registry_name, scope=scope) is not provider:
|
||
return None
|
||
handle = self._manager._track_scoped_registration(
|
||
self.manifest, kind, registry_name, registry, provider, previous
|
||
)
|
||
logger.info("Plugin '%s' registered %s: %s", self.manifest.name, label, registry_name)
|
||
return handle
|
||
|
||
# -- host-owned LLM access ----------------------------------------------
|
||
|
||
@property
|
||
def llm(self) -> Any:
|
||
"""Host-owned :class:`agent.plugin_llm.PluginLlm` facade: completions on the user's active
|
||
model/auth. Overrides (model, agent id, auth profile) are fail-closed, gated by
|
||
``plugins.entries.<plugin_id>.llm.*``."""
|
||
if self._llm is None:
|
||
from agent.plugin_llm import PluginLlm
|
||
plugin_id = self.manifest.key or self.manifest.name
|
||
self._llm = PluginLlm(plugin_id=plugin_id)
|
||
return self._llm
|
||
|
||
@property
|
||
def subagent_lifecycle(self) -> Any:
|
||
"""Plugin-safe subagent lifecycle service: serializable handles and immutable snapshots,
|
||
never a live agent or private registry."""
|
||
if self._subagent_lifecycle is None:
|
||
from agent.subagent_lifecycle import (
|
||
SubagentLifecycleService,
|
||
get_active_subagent_parent,
|
||
)
|
||
self._subagent_lifecycle = SubagentLifecycleService(get_active_subagent_parent)
|
||
return self._subagent_lifecycle
|
||
|
||
# -- profile awareness --------------------------------------------------
|
||
|
||
@property
|
||
def profile_name(self) -> str:
|
||
"""Active profile name: ``"default"``, the ``~/.hermes/profiles/<name>`` id, or ``"custom"``.
|
||
|
||
Derived from ``HERMES_HOME`` (not ``_cli_ref``, which is None outside interactive CLI) so it
|
||
works in the gateway and kanban workers too.
|
||
"""
|
||
try:
|
||
from hermes_cli.profiles import get_active_profile_name
|
||
return get_active_profile_name()
|
||
except Exception:
|
||
return "default"
|
||
|
||
# -- lifecycle: unload callbacks and supervised tasks --------------------
|
||
|
||
def on_unload(self, callback: Callable[[], None]) -> PluginRegistration:
|
||
"""Register a cleanup callback for unload: runs in reverse acquisition order interleaved
|
||
with registration teardown; exceptions are logged, never propagated."""
|
||
if not callable(callback):
|
||
raise TypeError("on_unload callback must be callable")
|
||
handle = self._track("on_unload", getattr(callback, "__name__", "callback"), callback)
|
||
logger.debug("Plugin %s registered on_unload callback", self.manifest.name)
|
||
return handle
|
||
|
||
def spawn_task(self, coro, *, name: Optional[str] = None) -> "asyncio.Task":
|
||
"""Spawn a supervised asyncio task; unload/force reload cancels it. Needs a running loop."""
|
||
if not asyncio.iscoroutine(coro):
|
||
raise TypeError("spawn_task expects a coroutine")
|
||
loop = asyncio.get_running_loop()
|
||
task_name = name or f"plugin:{self.plugin_id}:task"
|
||
task = loop.create_task(coro, name=task_name)
|
||
|
||
def _cancel_task() -> None:
|
||
if not task.done():
|
||
task.cancel()
|
||
|
||
handle = self._track("background_task", task_name, _cancel_task)
|
||
task.add_done_callback(lambda _t: handle.dispose())
|
||
logger.debug("Plugin %s spawned supervised task: %s", self.manifest.name, task_name)
|
||
return task
|
||
|
||
# -- approval transport registration ------------------------------------
|
||
|
||
def register_approval_transport(self, name: str, present_fn: Callable) -> None:
|
||
"""Register a human approval transport, inactive until ``security.approval.transport:
|
||
<name>`` selects it. It receives a redacted ``ApprovalRequest`` and returns only a
|
||
correlated decision; policy and persistence stay host-owned. ``present_fn`` may be async."""
|
||
self._manager.register_approval_transport(
|
||
name,
|
||
present_fn,
|
||
plugin_id=self.manifest.key or self.manifest.name,
|
||
)
|
||
# Record ownership so unload/force-reload removes this transport. Duplicate names are
|
||
# rejected above (raise), so there is never a displaced previous entry to restore.
|
||
clean = str(name).strip().lower()
|
||
entry = self._manager._approval_transports.get(clean)
|
||
if entry is not None:
|
||
self._track_mapping_entry(
|
||
"approval_transport", clean, self._manager._approval_transports, entry, None
|
||
)
|
||
|
||
# -- tool registration --------------------------------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_tool(
|
||
self,
|
||
name: str,
|
||
toolset: str,
|
||
schema: dict,
|
||
handler: Callable,
|
||
check_fn: Callable | None = None,
|
||
requires_env: list | None = None,
|
||
is_async: bool = False,
|
||
description: str = "",
|
||
emoji: str = "",
|
||
override: bool = False,
|
||
) -> Optional[PluginRegistration]:
|
||
"""Register a tool in the global registry and track it as plugin-provided.
|
||
|
||
``override=True`` replaces a same-named built-in; without it a name already claimed by
|
||
another toolset is rejected. Overriding requires operator opt-in via
|
||
``plugins.entries.<plugin_id>.allow_tool_override: true`` — otherwise any enabled plugin
|
||
could silently replace a privileged built-in like ``write_file``.
|
||
"""
|
||
if override and not self._tool_override_allowed(name):
|
||
plugin_id = self.manifest.key or self.manifest.name
|
||
raise PluginToolOverrideError(
|
||
f"Plugin {self.manifest.name!r} cannot override built-in tool "
|
||
f"{name!r}. Set "
|
||
f"plugins.entries.{plugin_id}.allow_tool_override: true "
|
||
f"in config.yaml to allow this plugin to replace built-in tools."
|
||
)
|
||
|
||
from tools.registry import registry
|
||
|
||
scope = self._manager.scope_key
|
||
previous = registry.snapshot_registration(name, scope=scope)
|
||
effective = registry.get_entry(name, scope=scope)
|
||
if previous is None and effective is not None and not override:
|
||
logger.warning(
|
||
"Plugin %s tried to shadow global tool %s without override=True",
|
||
self.manifest.name,
|
||
name,
|
||
)
|
||
return None
|
||
registry.register(
|
||
name=name,
|
||
toolset=toolset,
|
||
schema=schema,
|
||
handler=handler,
|
||
check_fn=check_fn,
|
||
requires_env=requires_env,
|
||
is_async=is_async,
|
||
description=description,
|
||
emoji=emoji,
|
||
override=override,
|
||
scope=scope,
|
||
)
|
||
registered = registry.snapshot_registration(name, scope=scope)
|
||
if (
|
||
registered is not None
|
||
and registered is not previous
|
||
and registered.handler is handler
|
||
):
|
||
self._manager._plugin_tool_names.add(name)
|
||
handle = self._manager._track_scoped_registration(
|
||
self.manifest, "tool", name, registry, registered, previous,
|
||
finalize=lambda: self._manager._remove_tool_name_if_unowned(name),
|
||
)
|
||
else:
|
||
handle = None
|
||
logger.debug(
|
||
"Plugin %s registered tool: %s%s",
|
||
self.manifest.name, name, " (override)" if override else "",
|
||
)
|
||
return handle
|
||
|
||
# -- capability probing (#64228) -----------------------------------------
|
||
|
||
def has_capability(self, capability: str) -> bool:
|
||
"""Return True when *capability* is live for this plugin (probe, then degrade gracefully).
|
||
|
||
Bundled plugins are trusted for ``tools.override``; otherwise the granted-capability set or
|
||
the legacy ``allow_*`` key decides. Unknown ids / unreadable consent -> False (fail closed).
|
||
"""
|
||
source = getattr(self.manifest, "source", "") or ""
|
||
if source == "bundled" and capability == "tools.override":
|
||
return True
|
||
plugin_id = self.manifest.key or self.manifest.name
|
||
return plugin_capability_granted(plugin_id, capability)
|
||
|
||
# -- capability-gated MCP access ----------------------------------------
|
||
|
||
def call_mcp(
|
||
self,
|
||
server: str,
|
||
tool: str,
|
||
arguments: Optional[Dict[str, Any]] = None,
|
||
timeout: float = 30,
|
||
) -> Dict[str, Any]:
|
||
"""Call ``tool`` on MCP ``server`` synchronously through the native client in
|
||
:mod:`tools.mcp_tool` (same trust gates, breaker, reconnect) — never a parallel connection.
|
||
|
||
Default-off, per-server grant: unlisted servers (``plugins.entries.<plugin_id>.mcp_allowlist``)
|
||
raise ``PermissionError``. ``timeout`` clamps to 1–600s. Returns ``{"ok": True, "result"}`` or
|
||
``{"ok": False, "error"}``; results over ~64KB are truncated with a marker.
|
||
"""
|
||
plugin_id = self.manifest.key or self.manifest.name
|
||
allowlist = self._mcp_allowlist(plugin_id)
|
||
if server not in allowlist:
|
||
raise PermissionError(
|
||
f"Plugin {self.manifest.name!r} is not allowed to call MCP "
|
||
f"server {server!r}. Add it to "
|
||
f"plugins.entries.{plugin_id}.mcp_allowlist in config.yaml "
|
||
f"to grant access (default is no MCP access)."
|
||
)
|
||
|
||
try:
|
||
timeout = float(timeout)
|
||
except (TypeError, ValueError):
|
||
timeout = 30.0
|
||
timeout = max(1.0, min(timeout, 600.0))
|
||
|
||
from tools.mcp_tool import _make_tool_handler
|
||
|
||
handler = _make_tool_handler(server, tool, timeout)
|
||
raw = handler(dict(arguments or {}))
|
||
|
||
logger.debug(
|
||
"Plugin %s called MCP %s/%s (timeout=%ss, %d chars returned)",
|
||
self.manifest.name, server, tool, timeout, len(raw or ""),
|
||
)
|
||
return self._mcp_envelope(raw)
|
||
|
||
_MCP_RESULT_CHAR_CAP = 65536
|
||
|
||
@classmethod
|
||
def _mcp_envelope(cls, raw: Any) -> Dict[str, Any]:
|
||
"""Normalize an MCP handler result string into a stable envelope."""
|
||
if not isinstance(raw, str):
|
||
raw = "" if raw is None else str(raw)
|
||
if len(raw) > cls._MCP_RESULT_CHAR_CAP:
|
||
raw = raw[: cls._MCP_RESULT_CHAR_CAP] + "… [truncated]"
|
||
truncated = True
|
||
else:
|
||
truncated = False
|
||
parsed: Any = None
|
||
try:
|
||
parsed = json.loads(raw)
|
||
except (ValueError, TypeError):
|
||
parsed = None
|
||
if isinstance(parsed, dict) and "error" in parsed:
|
||
envelope: Dict[str, Any] = {"ok": False, "error": parsed["error"]}
|
||
elif isinstance(parsed, dict) and "result" in parsed:
|
||
envelope = {"ok": True, "result": parsed["result"]}
|
||
if "structuredContent" in parsed:
|
||
envelope["structuredContent"] = parsed["structuredContent"]
|
||
else:
|
||
envelope = {"ok": True, "result": parsed if parsed is not None else raw}
|
||
if truncated:
|
||
envelope["truncated"] = True
|
||
return envelope
|
||
|
||
@staticmethod
|
||
def _mcp_allowlist(plugin_id: str) -> List[str]:
|
||
"""Operator-granted MCP server allowlist; missing/unreadable -> [] (default-deny)."""
|
||
try:
|
||
from hermes_cli.config import load_config
|
||
cfg = load_config() or {}
|
||
except Exception:
|
||
return []
|
||
entries = (cfg.get("plugins") or {}).get("entries") or {}
|
||
entry = entries.get(plugin_id) or {}
|
||
allowlist = entry.get("mcp_allowlist")
|
||
if not isinstance(allowlist, list):
|
||
return []
|
||
return [str(item) for item in allowlist]
|
||
|
||
# -- override trust gate ------------------------------------------------
|
||
|
||
def _tool_override_allowed(self, tool_name: str) -> bool:
|
||
"""Return True if this plugin may override built-in tools.
|
||
|
||
Bundled plugins are trusted (a maintainer choice, not privilege escalation). Others need the
|
||
``tools.override`` capability via :func:`plugin_capability_granted` — granted_capabilities OR
|
||
the legacy ``allow_tool_override: true`` key.
|
||
"""
|
||
source = getattr(self.manifest, "source", "") or ""
|
||
if source == "bundled":
|
||
return True
|
||
try:
|
||
from hermes_cli.config import load_config
|
||
|
||
with _plugin_home_scope(self._manager.home_path):
|
||
cfg = load_config() or {}
|
||
except Exception:
|
||
return False # fail closed: better to break the override than silently grant it
|
||
plugin_id = self.manifest.key or self.manifest.name
|
||
# Pass THIS manager's profile-scoped config so a multi-profile process never consults the
|
||
# active profile's consent state instead.
|
||
return plugin_capability_granted(plugin_id, "tools.override", config=cfg)
|
||
|
||
# -- message injection --------------------------------------------------
|
||
|
||
def inject_message(
|
||
self,
|
||
content: str,
|
||
role: str = "user",
|
||
*,
|
||
session_key: str | None = None,
|
||
) -> bool:
|
||
"""Inject a message into a CLI or gateway conversation (new turn if idle, interrupt if
|
||
running).
|
||
|
||
Gateway injection requires an existing ``session_key`` and
|
||
``plugins.entries.<plugin_id>.allow_gateway_injection``. ``True`` means the gateway accepted
|
||
the request for async dispatch, not that delivery completed.
|
||
"""
|
||
cli = self._manager._cli_ref
|
||
msg = content if role == "user" else f"[{role}] {content}"
|
||
|
||
if cli is not None:
|
||
if getattr(cli, "_agent_running", False):
|
||
cli._interrupt_queue.put(msg)
|
||
else:
|
||
cli._pending_input.put(msg)
|
||
return True
|
||
|
||
if not session_key:
|
||
logger.warning("inject_message: gateway mode requires an existing session_key")
|
||
return False
|
||
if not self._gateway_injection_allowed():
|
||
plugin_id = self.manifest.key or self.manifest.name
|
||
logger.warning(
|
||
"inject_message: gateway injection denied for plugin %s; set "
|
||
"plugins.entries.%s.allow_gateway_injection: true to allow it",
|
||
plugin_id,
|
||
plugin_id,
|
||
)
|
||
return False
|
||
|
||
if not self._manager.has_gateway_message_injector:
|
||
logger.warning("inject_message: no live gateway is available")
|
||
return False
|
||
|
||
plugin_id = self.manifest.key or self.manifest.name
|
||
try:
|
||
return bool(
|
||
self._manager.inject_gateway_message(
|
||
session_key=session_key,
|
||
content=msg,
|
||
plugin_id=plugin_id,
|
||
)
|
||
)
|
||
except Exception:
|
||
logger.warning(
|
||
"inject_message: gateway scheduling failed for plugin %s",
|
||
plugin_id,
|
||
exc_info=True,
|
||
)
|
||
return False
|
||
|
||
def _gateway_injection_allowed(self) -> bool:
|
||
"""Return whether this plugin may trigger gateway session turns."""
|
||
try:
|
||
cfg = load_config_readonly() or {}
|
||
except Exception:
|
||
return False
|
||
|
||
plugin_id = self.manifest.key or self.manifest.name
|
||
return (
|
||
cfg_get(
|
||
cfg,
|
||
"plugins",
|
||
"entries",
|
||
plugin_id,
|
||
"allow_gateway_injection",
|
||
default=False,
|
||
)
|
||
is True
|
||
)
|
||
|
||
# -- CLI command registration --------------------------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_cli_command(
|
||
self,
|
||
name: str,
|
||
help: str,
|
||
setup_fn: Callable,
|
||
handler_fn: Callable | None = None,
|
||
description: str = "",
|
||
) -> PluginRegistration:
|
||
"""Register a CLI subcommand (``hermes <name> ...``). *setup_fn* receives the argparse
|
||
subparser; *handler_fn* becomes ``set_defaults(func=...)``."""
|
||
previous = self._manager._cli_commands.get(name)
|
||
entry = {
|
||
"name": name,
|
||
"help": help,
|
||
"description": description,
|
||
"setup_fn": setup_fn,
|
||
"handler_fn": handler_fn,
|
||
"plugin": self.manifest.name,
|
||
"plugin_key": self.manifest.key or self.manifest.name,
|
||
}
|
||
handle = self._track_mapping_entry(
|
||
"cli_command", name, self._manager._cli_commands, entry, previous
|
||
)
|
||
logger.debug("Plugin %s registered CLI command: %s", self.manifest.name, name)
|
||
return handle
|
||
|
||
# -- slash command registration -------------------------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_command(
|
||
self,
|
||
name: str,
|
||
handler: Callable,
|
||
description: str = "",
|
||
args_hint: str = "",
|
||
argument_mode: str | None = None,
|
||
) -> Optional[PluginRegistration]:
|
||
"""Register an in-session slash command (``/name``) for CLI and gateway sessions.
|
||
|
||
Handler: ``fn(raw_args: str) -> str | None`` (sync or async). ``args_hint`` (e.g.
|
||
``"<file>"``) lets adapters like Discord surface an argument field; without it the command
|
||
registers parameterless there but still accepts trailing text as free-form chat.
|
||
"""
|
||
clean = name.lower().strip().lstrip("/").replace(" ", "-")
|
||
if not clean:
|
||
logger.warning(
|
||
"Plugin '%s' tried to register a command with an empty name.",
|
||
self.manifest.name,
|
||
)
|
||
return
|
||
|
||
# Reject if it conflicts with a built-in command
|
||
try:
|
||
from hermes_cli.commands import resolve_command
|
||
if resolve_command(clean) is not None:
|
||
logger.warning(
|
||
"Plugin '%s' tried to register command '/%s' which conflicts "
|
||
"with a built-in command. Skipping.",
|
||
self.manifest.name, clean,
|
||
)
|
||
return
|
||
except Exception:
|
||
pass
|
||
|
||
previous = self._manager._plugin_commands.get(clean)
|
||
hint = (args_hint or "").strip()
|
||
mode = argument_mode if argument_mode in {"options", "text", "mixed"} else (
|
||
"text" if hint else None
|
||
)
|
||
entry = {
|
||
"handler": handler,
|
||
"description": description or "Plugin command",
|
||
"plugin": self.manifest.name,
|
||
"plugin_key": self.manifest.key or self.manifest.name,
|
||
"args_hint": hint,
|
||
"argument_mode": mode,
|
||
}
|
||
handle = self._track_mapping_entry(
|
||
"command", clean, self._manager._plugin_commands, entry, previous
|
||
)
|
||
logger.debug("Plugin %s registered command: /%s", self.manifest.name, clean)
|
||
return handle
|
||
|
||
# -- tool dispatch -------------------------------------------------------
|
||
|
||
def dispatch_tool(self, tool_name: str, args: dict, **kwargs) -> str:
|
||
"""Dispatch a tool call through the registry with the parent agent (when available)
|
||
resolved automatically; returns the handler's JSON string. ``kwargs`` forward to dispatch."""
|
||
from tools.registry import registry
|
||
|
||
# In gateway mode _cli_ref is None — tools degrade gracefully (no spinner, TERMINAL_CWD).
|
||
if "parent_agent" not in kwargs:
|
||
cli = self._manager._cli_ref
|
||
agent = getattr(cli, "agent", None) if cli else None
|
||
if agent is not None:
|
||
kwargs["parent_agent"] = agent
|
||
|
||
return registry.dispatch(tool_name, args, scope=self._manager.scope_key, **kwargs)
|
||
|
||
# -- context engine registration -----------------------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_context_engine(self, engine) -> Optional[PluginRegistration]:
|
||
"""Register the (single) ``agent.context_engine.ContextEngine`` replacing the built-in
|
||
ContextCompressor; a second registration is rejected with a warning."""
|
||
if self._manager._context_engine is not None:
|
||
logger.warning(
|
||
"Plugin '%s' tried to register a context engine, but one is "
|
||
"already registered. Only one context engine plugin is allowed.",
|
||
self.manifest.name,
|
||
)
|
||
return
|
||
from agent.context_engine import ContextEngine
|
||
if not isinstance(engine, ContextEngine):
|
||
logger.warning(
|
||
"Plugin '%s' tried to register a context engine that does not "
|
||
"inherit from ContextEngine. Ignoring.",
|
||
self.manifest.name,
|
||
)
|
||
return
|
||
previous = self._manager._context_engine
|
||
self._manager._context_engine = engine
|
||
handle = self._track_replacement(
|
||
"context_engine", engine.name,
|
||
slot=("manager_value", id(self._manager), "_context_engine"),
|
||
current=engine, previous=previous,
|
||
restore=lambda replacement: self._manager._restore_value(
|
||
"_context_engine", engine, replacement
|
||
),
|
||
)
|
||
logger.info("Plugin '%s' registered context engine: %s", self.manifest.name, engine.name)
|
||
return handle
|
||
|
||
# -- context reference registration -------------------------------------
|
||
|
||
def register_context_reference(self, provider) -> None:
|
||
"""Register a :class:`agent.context_references.ContextReferenceProvider`; ``provider.prefix``
|
||
defines ``@<prefix>:``. Built-in prefixes (diff, staged, file, folder, git, url) are
|
||
rejected."""
|
||
from agent.context_references import (
|
||
ContextReferenceProvider as _CRP,
|
||
register_context_reference_provider as _register,
|
||
)
|
||
|
||
if not isinstance(provider, _CRP):
|
||
logger.warning(
|
||
"Plugin '%s' tried to register a context reference provider "
|
||
"that does not inherit from ContextReferenceProvider. Ignoring.",
|
||
self.manifest.name,
|
||
)
|
||
return
|
||
try:
|
||
_register(provider)
|
||
except ValueError as exc:
|
||
logger.warning(
|
||
"Plugin '%s' context reference registration failed: %s",
|
||
self.manifest.name, exc,
|
||
)
|
||
return
|
||
logger.info(
|
||
"Plugin '%s' registered context reference: @%s:",
|
||
self.manifest.name, provider.prefix,
|
||
)
|
||
|
||
# -- memory provider registration ---------------------------------------
|
||
|
||
def register_memory_provider(self, provider) -> None:
|
||
"""Record a memory provider (inert). Activation is owned by ``plugins/memory`` via
|
||
``memory.provider``; a provider reaching here was loaded by the general manager, and
|
||
without this method its ``register()`` would fail on a missing attribute."""
|
||
from agent.memory_provider import MemoryProvider
|
||
|
||
if not isinstance(provider, MemoryProvider):
|
||
logger.warning(
|
||
"Plugin '%s' tried to register a memory provider that does not "
|
||
"inherit from MemoryProvider. Ignoring.",
|
||
self.manifest.name,
|
||
)
|
||
return
|
||
self._memory_provider = provider
|
||
logger.debug(
|
||
"Plugin '%s' registered memory provider: %s",
|
||
self.manifest.name, getattr(provider, "name", "?"),
|
||
)
|
||
|
||
# -- image gen provider registration ------------------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_image_gen_provider(self, provider) -> Optional[PluginRegistration]:
|
||
"""Register an :class:`agent.image_gen_provider.ImageGenProvider`; ``provider.name`` is
|
||
matched by ``image_gen.provider``."""
|
||
from agent import image_gen_registry
|
||
from agent.image_gen_provider import ImageGenProvider
|
||
|
||
return self._register_scoped_provider(
|
||
provider,
|
||
kind="image_gen_provider",
|
||
base_class=ImageGenProvider,
|
||
registry=image_gen_registry,
|
||
label="image_gen provider",
|
||
article="an",
|
||
)
|
||
|
||
# -- dashboard auth provider registration --------------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_dashboard_auth_provider(self, provider) -> Optional[PluginRegistration]:
|
||
"""Register a :class:`hermes_cli.dashboard_auth.DashboardAuthProvider` for the dashboard
|
||
auth gate (non-loopback bind without ``--insecure``). Wrong type / duplicate name warn and
|
||
are ignored, never raised."""
|
||
from hermes_cli.dashboard_auth import DashboardAuthProvider
|
||
from hermes_cli.dashboard_auth.registry import (
|
||
register_global_provider,
|
||
unregister_global_provider,
|
||
)
|
||
|
||
if not isinstance(provider, DashboardAuthProvider):
|
||
logger.warning(
|
||
"Plugin '%s' tried to register a dashboard-auth provider "
|
||
"that does not inherit from DashboardAuthProvider. Ignoring.",
|
||
self.manifest.name,
|
||
)
|
||
return
|
||
registry_name = provider.name
|
||
# The auth registry is process-global (lifetime = web server). Disposing it on a routine
|
||
# per-home manager teardown emptied it for the WHOLE process and disabled sign-in until
|
||
# restart — so upsert and keep it out of reverse-order teardown (``persistent=True``).
|
||
try:
|
||
register_global_provider(provider)
|
||
except (TypeError, ValueError) as e:
|
||
logger.warning(
|
||
"Plugin '%s' failed to register dashboard-auth provider "
|
||
"%r: %s",
|
||
self.manifest.name, getattr(provider, "name", "?"), e,
|
||
)
|
||
return
|
||
handle = self._track(
|
||
"dashboard_auth_provider",
|
||
registry_name,
|
||
lambda: unregister_global_provider(registry_name, provider),
|
||
persistent=True,
|
||
)
|
||
logger.info(
|
||
"Plugin '%s' registered dashboard-auth provider: %s (%s)",
|
||
self.manifest.name, registry_name, provider.display_name,
|
||
)
|
||
return handle
|
||
|
||
# -- video gen provider registration -------------------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_video_gen_provider(self, provider) -> Optional[PluginRegistration]:
|
||
"""Register an :class:`agent.video_gen_provider.VideoGenProvider`; ``provider.name`` is
|
||
matched by ``video_gen.provider``."""
|
||
from agent import video_gen_registry
|
||
from agent.video_gen_provider import VideoGenProvider
|
||
|
||
return self._register_scoped_provider(
|
||
provider,
|
||
kind="video_gen_provider",
|
||
base_class=VideoGenProvider,
|
||
registry=video_gen_registry,
|
||
label="video_gen provider",
|
||
)
|
||
|
||
# -- web search/extract provider registration ----------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_web_search_provider(self, provider) -> Optional[PluginRegistration]:
|
||
"""Register an :class:`agent.web_search_provider.WebSearchProvider`; ``provider.name`` is
|
||
matched by ``web.search_backend`` / ``web.extract_backend`` / ``web.backend``."""
|
||
from agent import web_search_registry
|
||
from agent.web_search_provider import WebSearchProvider
|
||
|
||
return self._register_scoped_provider(
|
||
provider,
|
||
kind="web_search_provider",
|
||
base_class=WebSearchProvider,
|
||
registry=web_search_registry,
|
||
label="web provider",
|
||
)
|
||
|
||
# -- browser provider registration ---------------------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_browser_provider(self, provider) -> Optional[PluginRegistration]:
|
||
"""Register an :class:`agent.browser_provider.BrowserProvider`; ``provider.name`` is matched
|
||
by ``browser.cloud_provider`` (consulted by ``tools.browser_tool._get_cloud_provider``)."""
|
||
from agent import browser_registry
|
||
from agent.browser_provider import BrowserProvider
|
||
|
||
return self._register_scoped_provider(
|
||
provider,
|
||
kind="browser_provider",
|
||
base_class=BrowserProvider,
|
||
registry=browser_registry,
|
||
label="browser provider",
|
||
)
|
||
|
||
# -- terminal environment provider registration ----------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_terminal_environment_provider(self, provider) -> Optional[PluginRegistration]:
|
||
"""Register a :class:`agent.terminal_env_provider.TerminalEnvironmentProvider`;
|
||
``provider.name`` is matched by ``terminal.backend`` when no built-in backend has that name.
|
||
Built-in names (local, docker, singularity, modal, daytona, vercel_sandbox, ssh) are
|
||
rejected — plugins never shadow in-tree backends."""
|
||
from agent import terminal_env_registry
|
||
from agent.terminal_env_provider import TerminalEnvironmentProvider
|
||
|
||
return self._register_scoped_provider(
|
||
provider,
|
||
kind="terminal_environment_provider",
|
||
base_class=TerminalEnvironmentProvider,
|
||
registry=terminal_env_registry,
|
||
label="terminal environment provider",
|
||
normalize=lambda n: n.strip().lower(),
|
||
reject_message="Plugin '%s' terminal environment provider rejected: %s",
|
||
)
|
||
|
||
# -- secret source registration -------------------------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_secret_source(self, source) -> Optional[PluginRegistration]:
|
||
"""Register a :class:`agent.secret_sources.base.SecretSource`, run by ``load_hermes_dotenv()``
|
||
(after ``~/.hermes/.env``, before credentials are read) when ``secrets.<name>`` is enabled.
|
||
The orchestrator owns ordering/precedence/provenance; the source only fetches. Since dotenv
|
||
usually loads before discovery, the manager re-pulls enabled plugin sources afterwards."""
|
||
import agent.secret_sources.registry as secret_registry
|
||
from agent.secret_sources.base import SecretSource
|
||
|
||
return self._register_scoped_provider(
|
||
source,
|
||
kind="secret_source",
|
||
base_class=SecretSource,
|
||
registry=secret_registry,
|
||
label="secret source",
|
||
normalize=None,
|
||
register=secret_registry.register_source,
|
||
)
|
||
|
||
# -- TTS provider registration -------------------------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_tts_provider(self, provider) -> Optional[PluginRegistration]:
|
||
"""Register an :class:`agent.tts_provider.TTSProvider`; ``provider.name`` is matched by
|
||
``tts.provider`` unless it is a built-in name (rejected with a warning) or a
|
||
``tts.providers.<name>: type: command`` entry shares it (command-providers win)."""
|
||
from agent import tts_registry
|
||
from agent.tts_provider import TTSProvider
|
||
|
||
return self._register_scoped_provider(
|
||
provider,
|
||
kind="tts_provider",
|
||
base_class=TTSProvider,
|
||
registry=tts_registry,
|
||
label="TTS provider",
|
||
normalize=lambda n: n.strip().lower(),
|
||
)
|
||
|
||
# -- transcription (STT) provider registration ---------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_transcription_provider(self, provider) -> Optional[PluginRegistration]:
|
||
"""Register an :class:`agent.transcription_provider.TranscriptionProvider`; ``provider.name``
|
||
is matched by ``stt.provider`` unless it is a built-in name (rejected) or a
|
||
``stt.providers.<name>: type: command`` entry shares it (command-providers win)."""
|
||
from agent import transcription_registry
|
||
from agent.transcription_provider import TranscriptionProvider
|
||
|
||
return self._register_scoped_provider(
|
||
provider,
|
||
kind="transcription_provider",
|
||
base_class=TranscriptionProvider,
|
||
registry=transcription_registry,
|
||
label="transcription provider",
|
||
normalize=lambda n: n.strip().lower(),
|
||
)
|
||
|
||
# -- platform adapter registration ---------------------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_platform(
|
||
self,
|
||
name: str,
|
||
label: str,
|
||
adapter_factory: Callable,
|
||
check_fn: Callable,
|
||
validate_config: Callable | None = None,
|
||
required_env: list | None = None,
|
||
install_hint: str = "",
|
||
**entry_kwargs: Any,
|
||
) -> Optional[PluginRegistration]:
|
||
"""Register a gateway platform adapter (``adapter_factory(PlatformConfig) ->
|
||
BasePlatformAdapter``).
|
||
|
||
``check_fn`` is a PASSIVE "deps importable?" probe that must never install — status displays
|
||
call it freely; pass an ACTIVE installer as ``ensure_deps_fn`` (the gateway calls it from
|
||
``create_adapter()`` when ``check_fn`` is False). Extra kwargs (``setup_fn``, ``emoji``,
|
||
``allowed_users_env``, ``platform_hint``, ``ensure_deps_fn``) forward to ``PlatformEntry``;
|
||
unknown keys raise TypeError.
|
||
"""
|
||
from gateway.platform_registry import platform_registry, PlatformEntry
|
||
|
||
entry_kwargs.setdefault("plugin_name", self.manifest.name)
|
||
entry = PlatformEntry(
|
||
name=name,
|
||
label=label,
|
||
adapter_factory=adapter_factory,
|
||
check_fn=check_fn,
|
||
validate_config=validate_config,
|
||
required_env=required_env or [],
|
||
install_hint=install_hint,
|
||
source="plugin",
|
||
**entry_kwargs,
|
||
)
|
||
scope = self._manager.scope_key
|
||
previous = platform_registry.snapshot_registration(name, scope=scope)
|
||
platform_registry.register(entry, scope=scope)
|
||
current = platform_registry.snapshot_registration(name, scope=scope)
|
||
if current[0] is not entry or current[1] is not None:
|
||
return None
|
||
self._manager._plugin_platform_names.add(name)
|
||
handle = self._manager._track_scoped_registration(
|
||
self.manifest, "platform", name, platform_registry, current, previous,
|
||
finalize=lambda: self._manager._remove_platform_name_if_unowned(name),
|
||
)
|
||
logger.debug("Plugin %s registered platform: %s", self.manifest.name, name)
|
||
return handle
|
||
|
||
# -- slack action handler registration ----------------------------------
|
||
|
||
def register_slack_action_handler(
|
||
self,
|
||
action_id: Any,
|
||
callback: Callable,
|
||
) -> PluginRegistration:
|
||
"""Register a Slack Block Kit action handler, wired into ``slack_bolt.AsyncApp`` at connect.
|
||
|
||
``action_id`` is anything ``slack_bolt.App.action()`` accepts; ``callback`` is
|
||
``async def handler(ack, body, action)`` (``await ack()`` within 3s). Raises ``ValueError``
|
||
for a non-callable callback or empty ``action_id``.
|
||
"""
|
||
if not callable(callback):
|
||
raise ValueError(
|
||
f"Plugin '{self.manifest.name}' tried to register a Slack "
|
||
f"action handler with a non-callable callback."
|
||
)
|
||
if action_id is None or (isinstance(action_id, str) and not action_id.strip()):
|
||
raise ValueError(
|
||
f"Plugin '{self.manifest.name}' tried to register a Slack "
|
||
f"action handler with an empty action_id."
|
||
)
|
||
entry = (action_id, callback, self.manifest.name)
|
||
self._manager._slack_action_handlers.append(entry)
|
||
handle = self._track(
|
||
"slack_action_handler",
|
||
repr(action_id),
|
||
lambda: self._manager._remove_identity(
|
||
self._manager._slack_action_handlers, entry
|
||
),
|
||
)
|
||
logger.debug("Plugin %s registered Slack action handler: %s", self.manifest.name, action_id)
|
||
return handle
|
||
|
||
# -- platform handler registration ----------------------------------------
|
||
|
||
def register_platform_handler(self, platform: str, factory: Callable) -> None:
|
||
"""Register a native-client handler factory for a gateway platform, invoked at ``connect()``
|
||
as ``factory(native, adapter)`` before/as the core handlers register (``adapter`` read-only).
|
||
|
||
``native``: telegram PTB ``Application``, discord ``commands.Bot``, slack ``AsyncApp``,
|
||
matrix client, teams ``App``, dingtalk ``DingTalkStreamClient``, line aiohttp
|
||
``web.Application``, others ``None``. Keep SDK imports inside the factory; exceptions are
|
||
logged and the platform still connects. Always scope handlers hooked into first-match
|
||
dispatch tables so core flows keep working. Raises ``ValueError`` for a non-callable factory
|
||
or empty platform.
|
||
"""
|
||
if not callable(factory):
|
||
raise ValueError(
|
||
f"Plugin '{self.manifest.name}' tried to register a platform "
|
||
f"handler factory with a non-callable factory."
|
||
)
|
||
key = (platform or "").strip().lower()
|
||
if not key:
|
||
raise ValueError(
|
||
f"Plugin '{self.manifest.name}' tried to register a platform "
|
||
f"handler factory with an empty platform name."
|
||
)
|
||
self._manager._platform_handler_factories.setdefault(key, []).append(
|
||
(factory, self.manifest.name)
|
||
)
|
||
logger.debug(
|
||
"Plugin %s registered %s handler factory: %s",
|
||
self.manifest.name, key,
|
||
getattr(factory, "__name__", repr(factory)),
|
||
)
|
||
|
||
# -- telegram handler registration ---------------------------------------
|
||
|
||
def register_telegram_handler(self, factory: Callable) -> None:
|
||
"""Alias of ``register_platform_handler("telegram", factory)``: ``factory(application,
|
||
adapter)`` runs before the core handlers. PTB dispatches only the FIRST matching handler per
|
||
group and core registers a catch-all ``CallbackQueryHandler`` — always scope with
|
||
``pattern=`` or you swallow the core button flows. Raises ``ValueError`` if not callable."""
|
||
self.register_platform_handler("telegram", factory)
|
||
|
||
# -- auxiliary task registration ---------------------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_auxiliary_task(
|
||
self,
|
||
key: str,
|
||
*,
|
||
display_name: str,
|
||
description: str,
|
||
defaults: Optional[Dict[str, Any]] = None,
|
||
) -> PluginRegistration:
|
||
"""Register an auxiliary LLM task with its own ``auxiliary.<key>`` config block (picker
|
||
entry, ``AUXILIARY_<KEY>_*`` env bridge, defaults merged into loaded configs).
|
||
|
||
``key`` is snake_case and must not shadow a built-in task. ``defaults`` may override
|
||
provider/model/base_url/api_key/timeout/extra_body; unknown keys are preserved verbatim.
|
||
Raises ``ValueError`` for an empty/invalid key, a built-in key, or another plugin's key.
|
||
"""
|
||
if not key or not isinstance(key, str):
|
||
raise ValueError(
|
||
f"Plugin '{self.manifest.name}' tried to register auxiliary task "
|
||
f"with invalid key {key!r}"
|
||
)
|
||
if not all(c.isalnum() or c == "_" for c in key):
|
||
raise ValueError(
|
||
f"Plugin '{self.manifest.name}' auxiliary task key {key!r} "
|
||
f"must contain only alphanumeric characters and underscores"
|
||
)
|
||
|
||
from hermes_cli.main import _AUX_TASKS as _BUILTIN_AUX_TASKS
|
||
|
||
builtin_keys = {k for k, _name, _desc in _BUILTIN_AUX_TASKS}
|
||
if key in builtin_keys:
|
||
raise ValueError(
|
||
f"Plugin '{self.manifest.name}' cannot register auxiliary task "
|
||
f"{key!r} — that key is reserved for a built-in task. "
|
||
f"Pick a plugin-namespaced key (e.g. '{self.manifest.name}_{key}')."
|
||
)
|
||
|
||
# Owner is the canonical id ``ctx.llm`` is bound to, so agent/plugin_llm.py can match it.
|
||
owner_id = self.manifest.key or self.manifest.name
|
||
|
||
existing = self._manager._aux_tasks.get(key)
|
||
if existing is not None and existing.get("plugin") != owner_id:
|
||
raise ValueError(
|
||
f"Plugin '{self.manifest.name}' cannot register auxiliary task "
|
||
f"{key!r} — already registered by plugin "
|
||
f"'{existing.get('plugin')}'"
|
||
)
|
||
|
||
# Plugin owns the schema; routing fields are guaranteed present so consumers don't crash.
|
||
merged_defaults: Dict[str, Any] = {
|
||
"provider": "auto", "model": "", "base_url": "", "api_key": "",
|
||
"timeout": 60, "extra_body": {},
|
||
}
|
||
merged_defaults.update(defaults or {})
|
||
|
||
entry = {
|
||
"key": key,
|
||
"display_name": display_name,
|
||
"description": description,
|
||
"defaults": merged_defaults,
|
||
"plugin": owner_id,
|
||
"plugin_key": owner_id,
|
||
}
|
||
handle = self._track_mapping_entry(
|
||
"auxiliary_task", key, self._manager._aux_tasks, entry, existing
|
||
)
|
||
logger.debug(
|
||
"Plugin %s registered auxiliary task: %s (%s)",
|
||
self.manifest.name,
|
||
key,
|
||
display_name,
|
||
)
|
||
return handle
|
||
|
||
# -- redaction pattern registration --------------------------------------
|
||
|
||
def register_redaction_patterns(self, patterns) -> int:
|
||
"""Additively register secret-token regexes with :mod:`agent.redact`; returns the count
|
||
accepted.
|
||
|
||
Additive-only: plugins can over-redact, never weaken built-ins; ``security.redact_secrets:
|
||
false`` applies equally. Each pattern must compile and start with >= 2 literal characters;
|
||
invalid entries warn and are skipped.
|
||
"""
|
||
from agent.redact import register_redaction_patterns as _register
|
||
|
||
try:
|
||
count = _register(patterns, source=f"plugin:{self.manifest.name}")
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"Plugin '%s' redaction pattern registration failed: %s",
|
||
self.manifest.name, exc,
|
||
)
|
||
return 0
|
||
logger.debug("Plugin %s registered %d redaction pattern(s)", self.manifest.name, count)
|
||
return count
|
||
|
||
def register_hook(self, hook_name: str, callback: Callable) -> PluginRegistration:
|
||
"""Register a lifecycle hook callback (unknown names warn but are still stored)."""
|
||
return self._track_callback(
|
||
"hook", hook_name, callback, self._manager._hooks, VALID_HOOKS
|
||
)
|
||
|
||
def _track_callback(
|
||
self, kind: str, key: str, callback: Callable, mapping: Dict[str, List[Callable]],
|
||
valid: Set[str],
|
||
) -> PluginRegistration:
|
||
"""Append ``callback`` under ``key`` (warning on unknown ``key``) and lease its removal."""
|
||
if key not in valid:
|
||
logger.warning(
|
||
"Plugin '%s' registered unknown %s '%s' (valid: %s)",
|
||
self.manifest.name, kind, key, ", ".join(sorted(valid)),
|
||
)
|
||
mapping.setdefault(key, []).append(callback)
|
||
handle = self._track(
|
||
kind, key, lambda: self._manager._remove_callback(mapping, key, callback),
|
||
)
|
||
logger.debug("Plugin %s registered %s: %s", self.manifest.name, kind, key)
|
||
return handle
|
||
|
||
def register_system_prompt_section(
|
||
self,
|
||
id: str,
|
||
content: Union[str, Callable[[Mapping[str, Any]], str]],
|
||
*,
|
||
position: str = "after_memory",
|
||
max_chars: int = DEFAULT_SYSTEM_PROMPT_SECTION_MAX_CHARS,
|
||
) -> PluginRegistration:
|
||
"""Register bounded context frozen into each new session prompt. Callables receive a
|
||
read-only session-info mapping; the rendered prompt is persisted by core verbatim."""
|
||
if not is_valid_system_prompt_section_id(id):
|
||
raise ValueError(
|
||
"system prompt section id must be 1-128 lowercase characters "
|
||
"using letters, numbers, '.', '_', or '-'"
|
||
)
|
||
if not isinstance(content, str) and not callable(content):
|
||
raise TypeError("system prompt section content must be a string or callable")
|
||
if position not in SYSTEM_PROMPT_SECTION_POSITIONS:
|
||
raise ValueError(
|
||
"system prompt section position must be one of: "
|
||
+ ", ".join(sorted(SYSTEM_PROMPT_SECTION_POSITIONS))
|
||
)
|
||
if (
|
||
isinstance(max_chars, bool)
|
||
or not isinstance(max_chars, int)
|
||
or not 0 < max_chars <= MAX_SYSTEM_PROMPT_SECTION_CHARS
|
||
):
|
||
raise ValueError(
|
||
"system prompt section max_chars must be between 1 and "
|
||
f"{MAX_SYSTEM_PROMPT_SECTION_CHARS}"
|
||
)
|
||
existing = self._manager._system_prompt_sections.get(id)
|
||
if existing is not None:
|
||
raise ValueError(
|
||
f"system prompt section {id!r} is already registered by "
|
||
f"plugin {existing.plugin!r}"
|
||
)
|
||
plugin_id = self.manifest.key or self.manifest.name
|
||
section = PluginSystemPromptSection(
|
||
id=id,
|
||
content=content,
|
||
position=position,
|
||
max_chars=max_chars,
|
||
plugin=plugin_id,
|
||
)
|
||
handle = self._track_mapping_entry(
|
||
"system_prompt_section", id, self._manager._system_prompt_sections, section, existing
|
||
)
|
||
logger.debug("Plugin %s registered system prompt section: %s", self.manifest.name, id)
|
||
return handle
|
||
|
||
# -- inter-plugin event bus --------------------------------------------
|
||
|
||
def emit(self, event: str, payload: Optional[dict] = None) -> int:
|
||
"""Publish bare *event* as ``<plugin_key>:<event>`` (namespace FORCED to this plugin);
|
||
return the subscriber count scheduled.
|
||
|
||
Any ``':'`` in the name (``hermes:x`` is reserved for core, foreign namespaces forbidden)
|
||
raises ``ValueError``. Delivery is fire-and-forget via a single-worker queue: order
|
||
preserved, a blocking subscriber cannot stall the emitter.
|
||
"""
|
||
plugin_key = self.manifest.key or self.manifest.name
|
||
if not event or not isinstance(event, str):
|
||
logger.warning("Plugin '%s' tried to emit an invalid event name %r", plugin_key, event)
|
||
raise ValueError(f"Plugin '{plugin_key}' emit() requires a non-empty event name")
|
||
if ":" in event:
|
||
logger.warning(
|
||
"Plugin '%s' tried to emit namespaced/reserved event '%s' — "
|
||
"a plugin may only emit bare event names under its own '%s:' "
|
||
"namespace (the '%s:' prefix is reserved for core, and foreign "
|
||
"namespaces are forbidden)",
|
||
plugin_key, event, plugin_key, HERMES_EVENT_NAMESPACE,
|
||
)
|
||
raise ValueError(
|
||
f"Plugin '{plugin_key}' may not emit '{event}': emit only the "
|
||
f"bare event name; the namespace is forced to '{plugin_key}:' "
|
||
f"and the '{HERMES_EVENT_NAMESPACE}:' prefix is reserved for core"
|
||
)
|
||
if payload is not None and not isinstance(payload, dict):
|
||
raise TypeError(f"Plugin '{plugin_key}' emit() payload must be a dict or None")
|
||
full_event = f"{plugin_key}:{event}"
|
||
return self._manager._dispatch_event(full_event, payload or {})
|
||
|
||
def subscribe(self, event: str, callback: Callable) -> None:
|
||
"""Subscribe to a fully-qualified ``<plugin_key>:<event>`` name (unrestricted — only
|
||
emitting is namespace-gated). Owner-tagged so unload removes zombie callbacks."""
|
||
if not event or not isinstance(event, str):
|
||
raise ValueError(
|
||
f"Plugin '{self.manifest.name}' subscribe() requires a "
|
||
f"non-empty event name"
|
||
)
|
||
plugin_key = self.manifest.key or self.manifest.name
|
||
self._manager._subscribe_event(plugin_key, event, callback)
|
||
logger.debug("Plugin %s subscribed to event: %s", self.manifest.name, event)
|
||
|
||
# -- middleware registration -------------------------------------------
|
||
|
||
def register_middleware(self, kind: str, callback: Callable) -> PluginRegistration:
|
||
"""Register behavior-changing middleware (request kinds rewrite the payload, execution kinds
|
||
wrap the callback). Unknown kinds warn but are stored."""
|
||
return self._track_callback(
|
||
"middleware", kind, callback, self._manager._middleware, VALID_MIDDLEWARE
|
||
)
|
||
|
||
# -- skill registration -------------------------------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_skill(
|
||
self,
|
||
name: str,
|
||
path: Path,
|
||
description: str = "",
|
||
frontmatter: Optional[Mapping[str, Any]] = None,
|
||
) -> PluginRegistration:
|
||
"""Register a read-only skill resolvable as ``'<plugin_name>:<name>'`` via ``skill_view()``.
|
||
Not in ``~/.hermes/skills/`` nor ``<available_skills>`` — explicit loads only. Raises
|
||
``ValueError`` (``':'``/invalid chars) or ``FileNotFoundError``."""
|
||
from agent.skill_utils import _NAMESPACE_RE
|
||
|
||
if ":" in name:
|
||
raise ValueError(
|
||
f"Skill name '{name}' must not contain ':' "
|
||
f"(the namespace is derived from the plugin name "
|
||
f"'{self.manifest.name}' automatically)."
|
||
)
|
||
if not name or not _NAMESPACE_RE.match(name):
|
||
raise ValueError(f"Invalid skill name '{name}'. Must match [a-zA-Z0-9_-]+.")
|
||
if not path.exists():
|
||
raise FileNotFoundError(f"SKILL.md not found at {path}")
|
||
|
||
namespace = self.manifest.skill_namespace or self.manifest.name
|
||
qualified = f"{namespace}:{name}"
|
||
if self.manifest.portable and qualified in self._manager._plugin_skills:
|
||
raise ValueError(f"Plugin skill '{qualified}' is already registered")
|
||
previous = self._manager._plugin_skills.get(qualified)
|
||
entry = {
|
||
"path": path,
|
||
"plugin": namespace,
|
||
"plugin_key": self.manifest.key or self.manifest.name,
|
||
"bare_name": name,
|
||
"description": description,
|
||
"frontmatter": dict(frontmatter or {}),
|
||
}
|
||
handle = self._track_mapping_entry(
|
||
"skill", qualified, self._manager._plugin_skills, entry, previous
|
||
)
|
||
logger.debug("Plugin %s registered skill: %s", self.manifest.name, qualified)
|
||
return handle
|
||
|
||
|
||
# Hook callback timeout (non-blocking abandon). Default cap per Python hook callback; overridden by
|
||
# ``plugins.hook_callback_timeout``. Shell hooks enforce their own subprocess timeout.
|
||
_HOOK_CALLBACK_TIMEOUT_SECS = 30.0
|
||
_HOOK_SKIPPED = object() # returned by _run_hook_callback_bounded on skip/timeout
|
||
_MAX_HOOK_CALLBACK_TIMEOUT_SECS = 600.0
|
||
|
||
|
||
def _resolve_hook_callback_timeout() -> float:
|
||
"""Effective hook-callback timeout from ``plugins.hook_callback_timeout`` (default 30s; ``<= 0``
|
||
disables the threaded path; clamped to ``_MAX_HOOK_CALLBACK_TIMEOUT_SECS``)."""
|
||
timeout = _HOOK_CALLBACK_TIMEOUT_SECS
|
||
try:
|
||
from hermes_cli.config import load_config_readonly
|
||
|
||
plugins_cfg = (load_config_readonly() or {}).get("plugins")
|
||
if isinstance(plugins_cfg, dict) and "hook_callback_timeout" in plugins_cfg:
|
||
raw = plugins_cfg.get("hook_callback_timeout")
|
||
if raw is not None:
|
||
timeout = float(raw)
|
||
except (TypeError, ValueError):
|
||
logger.warning(
|
||
"plugins.hook_callback_timeout is not a number; using default %gs",
|
||
_HOOK_CALLBACK_TIMEOUT_SECS,
|
||
)
|
||
timeout = _HOOK_CALLBACK_TIMEOUT_SECS
|
||
except Exception:
|
||
timeout = _HOOK_CALLBACK_TIMEOUT_SECS
|
||
|
||
if timeout < 0:
|
||
logger.warning(
|
||
"plugins.hook_callback_timeout=%g is negative; using default %gs",
|
||
timeout,
|
||
_HOOK_CALLBACK_TIMEOUT_SECS,
|
||
)
|
||
return _HOOK_CALLBACK_TIMEOUT_SECS
|
||
if timeout > _MAX_HOOK_CALLBACK_TIMEOUT_SECS:
|
||
logger.warning(
|
||
"plugins.hook_callback_timeout=%g exceeds max %gs; clamping",
|
||
timeout,
|
||
_MAX_HOOK_CALLBACK_TIMEOUT_SECS,
|
||
)
|
||
return _MAX_HOOK_CALLBACK_TIMEOUT_SECS
|
||
return timeout
|
||
|
||
|
||
def _hook_uses_callback_timeout(hook_name: str, timeout: float) -> bool:
|
||
"""Whether *hook_name* should run under the non-blocking timeout path."""
|
||
if timeout <= 0 or hook_name in _HOOK_CALLER_THREAD_HOOKS:
|
||
return False
|
||
return (
|
||
hook_name in _HOOK_TIMEOUT_BOUNDED_HOOKS
|
||
or hook_name in _HOOK_TIMEOUT_FAIL_CLOSED_HOOKS
|
||
)
|
||
|
||
|
||
def _pre_tool_call_timeout_block() -> Dict[str, str]:
|
||
"""Fail-closed directive when a policy callback times out or is still running."""
|
||
return {"action": "block", "message": _PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE}
|
||
|
||
|
||
class PluginManager:
|
||
"""Central manager that discovers, loads, and invokes plugins."""
|
||
|
||
def __init__(self, scope_key: Optional[str] = None) -> None:
|
||
# Home is captured immutably: unload may run from another profile context, but every
|
||
# inverse must target the registration's original scope.
|
||
self.scope_key = scope_key or hermes_home_key()
|
||
self.home_path = Path(self.scope_key)
|
||
self._discovery_lock = threading.RLock()
|
||
self._plugins: Dict[str, LoadedPlugin] = {}
|
||
self._hooks: Dict[str, List[Callable]] = {}
|
||
self._middleware: Dict[str, List[Callable]] = {}
|
||
self._plugin_tool_names: Set[str] = set()
|
||
self._plugin_platform_names: Set[str] = set()
|
||
self._cli_commands: Dict[str, dict] = {}
|
||
self._context_engine = None # Set by a plugin via register_context_engine()
|
||
self._plugin_commands: Dict[str, dict] = {} # Slash commands registered by plugins
|
||
self._system_prompt_sections: Dict[str, PluginSystemPromptSection] = {}
|
||
self._discovered: bool = False
|
||
self._cli_ref = None # Set by CLI after plugin discovery
|
||
self._gateway_message_injector: tuple[object, Callable] | None = None
|
||
self._plugin_skills: Dict[str, Dict[str, Any]] = {} # qualified name -> metadata
|
||
self._portable_mcp_servers: Dict[str, Dict[str, Any]] = {}
|
||
self._aux_tasks: Dict[str, Dict[str, Any]] = {} # see register_auxiliary_task
|
||
self._approval_transports: Dict[str, Any] = {}
|
||
# Event bus: owner-tagged subscriptions (unload removes zombies); one daemon worker keeps
|
||
# registration order while emitters never block.
|
||
self._subscriptions: Dict[str, List[_EventSubscription]] = {}
|
||
self._event_lock = threading.RLock()
|
||
self._event_idle = threading.Condition(self._event_lock)
|
||
self._event_generation = 0
|
||
self._event_pending_by_generation: Dict[int, int] = {0: 0}
|
||
self._event_queue: queue.Queue[Any] = queue.Queue(maxsize=_EVENT_PENDING_CAP)
|
||
self._event_worker: Optional[threading.Thread] = None
|
||
self._emit_depth = threading.local() # per-worker chain depth caps mutual emitters
|
||
self._slack_action_handlers: List[tuple] = [] # (matcher, callback, plugin_name)
|
||
# In-flight / recently-timed-out hook callbacks keyed by (hook_name, id(cb)) so a stuck
|
||
# policy hook cannot spawn a new abandoned thread on every fire.
|
||
self._hook_running_callbacks: Dict[tuple, object] = {}
|
||
self._hook_timeout_suppressed_until: Dict[tuple, float] = {}
|
||
self._hook_timeout_lock = threading.Lock()
|
||
self._hook_timeout_suppression_seconds = _HOOK_TIMEOUT_SUPPRESSION_SECONDS
|
||
# Ledger per plugin (ownership) plus global order (reverse teardown across plugins).
|
||
# Process-global registries are shared across profiles while several managers coexist, so
|
||
# the ledger is keyed per (hermes_home, plugin_id) and every inverse is identity-conditional
|
||
# — one profile's unload can never clear another's registrations.
|
||
self._ownership_ledger: Dict[str, List[PluginRegistration]] = {}
|
||
self._registration_order: List[PluginRegistration] = []
|
||
# Persistent registrations that survived an unload-all; force re-discovery drains this via
|
||
# _evict_stale_persistent_registrations().
|
||
self._persistent_carryover: List[PluginRegistration] = []
|
||
# Deferred platforms whose client tools registered at discovery (see
|
||
# _register_deferred_platform_tools): imported package (don't re-execute on materialize)
|
||
# and contributed tool names (so `hermes plugins list` still attributes them).
|
||
self._predeclared_modules: Dict[str, types.ModuleType] = {}
|
||
self._predeclared_tools: Dict[str, List[str]] = {}
|
||
# Native platform handler factories keyed by lowercase platform name.
|
||
self._platform_handler_factories: Dict[str, List[tuple]] = {}
|
||
|
||
# -- registration ledger internals ----------------------------------------
|
||
|
||
def _track_registration(
|
||
self,
|
||
manifest: PluginManifest,
|
||
kind: str,
|
||
key: str,
|
||
release: Callable[[], None],
|
||
*,
|
||
persistent: bool = False,
|
||
) -> PluginRegistration:
|
||
"""Record one registration under its canonical plugin key.
|
||
|
||
``persistent`` ones (process-global host infrastructure) stay in the ownership ledger for
|
||
attribution but NOT in ``_registration_order``, so a routine unload cannot dispose them;
|
||
the handle still releases on explicit ``dispose()``.
|
||
"""
|
||
plugin_key = manifest.key or manifest.name
|
||
registration = PluginRegistration(
|
||
kind=kind,
|
||
key=key,
|
||
release=release,
|
||
plugin_key=plugin_key,
|
||
persistent=persistent,
|
||
)
|
||
registration._on_dispose = lambda disposed: self._forget_registrations([disposed])
|
||
self._ownership_ledger.setdefault(plugin_key, []).append(registration)
|
||
if not persistent:
|
||
self._registration_order.append(registration)
|
||
return registration
|
||
|
||
def _track_scoped_registration(
|
||
self,
|
||
manifest: PluginManifest,
|
||
kind: str,
|
||
name: str,
|
||
registry: Any,
|
||
current: Any,
|
||
previous: Any,
|
||
*,
|
||
finalize: Optional[Callable[[], None]] = None,
|
||
) -> PluginRegistration:
|
||
"""Lease one ``(kind, scope, name)`` slot of a scope-keyed process-global registry.
|
||
|
||
Unload calls ``registry.restore_registration(name, current, replacement, scope=...)`` —
|
||
identity-conditional, so a later generation is never removed by an earlier owner.
|
||
"""
|
||
scope = self.scope_key
|
||
lease = replacement_coordinator.acquire(
|
||
(kind, scope, name),
|
||
current=current,
|
||
previous=previous,
|
||
restore=lambda replacement: registry.restore_registration(
|
||
name, current, replacement, scope=scope
|
||
),
|
||
finalize=finalize,
|
||
)
|
||
return self._track_registration(manifest, kind, name, lease.dispose)
|
||
|
||
def _evict_stale_persistent_registrations(self) -> None:
|
||
"""After re-discovery, dispose parked persistent handles whose plugin did not re-register
|
||
the same ``(kind, key)``. Re-registered ones are dropped WITHOUT disposing — the same object
|
||
re-registered would pass the identity check and evict the live entry."""
|
||
if not self._persistent_carryover:
|
||
return
|
||
parked = self._persistent_carryover
|
||
self._persistent_carryover = []
|
||
current = {
|
||
(registration.kind, registration.key)
|
||
for owned in self._ownership_ledger.values()
|
||
for registration in owned
|
||
if registration.persistent and registration.active
|
||
}
|
||
stale = [
|
||
registration
|
||
for registration in parked
|
||
if registration.active
|
||
and (registration.kind, registration.key) not in current
|
||
]
|
||
for registration in stale:
|
||
logger.info(
|
||
"Evicting persistent registration %s/%s: plugin '%s' no "
|
||
"longer supplies it after re-discovery",
|
||
registration.kind,
|
||
registration.key,
|
||
registration.plugin_key,
|
||
)
|
||
self._dispose_registrations(stale)
|
||
|
||
@staticmethod
|
||
def _remove_identity(values: list, target: Any) -> bool:
|
||
"""Remove the last exact object match from a registration list."""
|
||
for index in range(len(values) - 1, -1, -1):
|
||
if values[index] is target:
|
||
del values[index]
|
||
return True
|
||
return False
|
||
|
||
def _remove_callback(
|
||
self,
|
||
mapping: Dict[str, List[Callable]],
|
||
key: str,
|
||
callback: Callable,
|
||
) -> None:
|
||
callbacks = mapping.get(key)
|
||
if callbacks is None:
|
||
return
|
||
self._remove_identity(callbacks, callback)
|
||
if not callbacks:
|
||
mapping.pop(key, None)
|
||
|
||
def _restore_mapping(
|
||
self,
|
||
mapping: Dict[str, Any],
|
||
key: str,
|
||
current: Any,
|
||
previous: Optional[Any],
|
||
) -> bool:
|
||
"""Restore a manager-local mapping only when *current* is still present."""
|
||
if mapping.get(key) is not current:
|
||
return False
|
||
if previous is None:
|
||
mapping.pop(key, None)
|
||
else:
|
||
mapping[key] = previous
|
||
return True
|
||
|
||
def _restore_value(self, attribute: str, current: Any, previous: Any) -> bool:
|
||
"""Restore a manager-local value only when *current* is still active."""
|
||
if getattr(self, attribute) is not current:
|
||
return False
|
||
setattr(self, attribute, previous)
|
||
return True
|
||
|
||
def _remove_name_if_unowned(self, kind: str, names: Set[str], name: str) -> None:
|
||
"""Drop *name* from the manager-local name set once no active ledger entry owns it."""
|
||
if not any(
|
||
registration.active
|
||
and registration.kind == kind
|
||
and registration.key == name
|
||
for registration in self._registration_order
|
||
):
|
||
names.discard(name)
|
||
|
||
def _remove_tool_name_if_unowned(self, name: str) -> None:
|
||
self._remove_name_if_unowned("tool", self._plugin_tool_names, name)
|
||
|
||
def _remove_platform_name_if_unowned(self, name: str) -> None:
|
||
self._remove_name_if_unowned("platform", self._plugin_platform_names, name)
|
||
|
||
def _forget_registrations(self, registrations: List[PluginRegistration]) -> None:
|
||
if not registrations:
|
||
return
|
||
registration_ids = {id(registration) for registration in registrations}
|
||
self._registration_order = [
|
||
registration
|
||
for registration in self._registration_order
|
||
if id(registration) not in registration_ids
|
||
]
|
||
for plugin_key, owned in list(self._ownership_ledger.items()):
|
||
remaining = [
|
||
registration
|
||
for registration in owned
|
||
if id(registration) not in registration_ids
|
||
]
|
||
if remaining:
|
||
self._ownership_ledger[plugin_key] = remaining
|
||
else:
|
||
self._ownership_ledger.pop(plugin_key, None)
|
||
|
||
def _dispose_registrations(self, registrations: List[PluginRegistration]) -> None:
|
||
"""Dispose registrations in reverse acquisition order, best effort."""
|
||
for registration in reversed(registrations):
|
||
try:
|
||
registration.dispose()
|
||
except Exception as exc: # pragma: no cover - defensive cleanup
|
||
logger.warning(
|
||
"Failed to unload plugin registration %s/%s: %s",
|
||
registration.plugin_key,
|
||
registration.key,
|
||
exc,
|
||
exc_info=_PLUGINS_DEBUG,
|
||
)
|
||
|
||
@staticmethod
|
||
def _resolve_plugin_key(plugin: Union[str, PluginManifest, LoadedPlugin]) -> str:
|
||
if isinstance(plugin, LoadedPlugin):
|
||
return plugin.manifest.key or plugin.manifest.name
|
||
if isinstance(plugin, PluginManifest):
|
||
return plugin.key or plugin.name
|
||
return str(plugin)
|
||
|
||
def unload(self, plugin: Union[str, PluginManifest, LoadedPlugin, None] = None) -> bool:
|
||
"""Unload registrations while excluding discovery/deferred loading."""
|
||
with self._discovery_lock, _plugin_home_scope(self.home_path):
|
||
return self._unload_scoped(plugin)
|
||
|
||
def _unload_scoped(self, plugin: Union[str, PluginManifest, LoadedPlugin, None] = None) -> bool:
|
||
"""Unload one plugin (or all when ``plugin=None``, as force rediscovery does).
|
||
|
||
Every ledger registration — including on_unload callbacks and supervised tasks — is disposed
|
||
in reverse acquisition order with identity-conditional inverses. Returns ``True`` when
|
||
anything was found.
|
||
"""
|
||
unload_all = plugin is None
|
||
if unload_all:
|
||
target_keys = set(self._ownership_ledger) | set(self._plugins)
|
||
registrations = list(self._registration_order)
|
||
else:
|
||
target_keys = self._unload_target_keys(self._resolve_plugin_key(plugin))
|
||
registrations = [
|
||
registration
|
||
for registration in self._registration_order
|
||
if registration.plugin_key in target_keys
|
||
]
|
||
# Persistent registrations are absent from _registration_order (unload-all keeps them),
|
||
# but a *targeted* unload is the disable/uninstall path: a disabled auth plugin's
|
||
# provider must NOT stay live process-wide.
|
||
registrations.extend(
|
||
registration
|
||
for key in target_keys
|
||
for registration in self._ownership_ledger.get(key, [])
|
||
if registration.persistent and registration.active
|
||
)
|
||
|
||
found = bool(target_keys or registrations)
|
||
self._dispose_registrations(registrations)
|
||
self._forget_registrations(registrations)
|
||
|
||
if unload_all:
|
||
self._reset_after_unload_all(registrations)
|
||
else:
|
||
for key in target_keys:
|
||
self._plugins.pop(key, None)
|
||
|
||
return found
|
||
|
||
def _unload_target_keys(self, requested: str) -> Set[str]:
|
||
"""Resolve a targeted-unload request to canonical plugin keys (exact key, else by name)."""
|
||
if requested in self._ownership_ledger or requested in self._plugins:
|
||
return {requested}
|
||
return {key for key, loaded in self._plugins.items() if loaded.manifest.name == requested}
|
||
|
||
def _reset_after_unload_all(self, registrations: List[PluginRegistration]) -> None:
|
||
"""Sweep pre-ledger global state and clear every manager-local container."""
|
||
# Handles are authoritative for global registries; names present in the manager-local sets
|
||
# without a ledger entry (pre-ledger or manually set state) are swept here so they do not
|
||
# survive a force reload as zombies.
|
||
from gateway.platform_registry import platform_registry
|
||
|
||
for platform_name in tuple(self._plugin_platform_names):
|
||
platform_registry.unregister(platform_name)
|
||
# Ledger-owned tool names are excluded: their handles already restored the previous entry,
|
||
# and blanket deregistration would remove what the ledger just restored.
|
||
ledger_tool_names = {
|
||
registration.key
|
||
for registration in registrations
|
||
if registration.kind == "tool"
|
||
}
|
||
preledger_tools = tuple(
|
||
name
|
||
for name in self._plugin_tool_names
|
||
if name not in ledger_tool_names
|
||
)
|
||
if preledger_tools:
|
||
try:
|
||
from tools.registry import registry as tool_registry
|
||
except Exception as exc: # pragma: no cover - defensive
|
||
logger.debug("unload: tools.registry unavailable: %s", exc)
|
||
else:
|
||
for tool_name in preledger_tools:
|
||
try:
|
||
tool_registry.deregister(tool_name)
|
||
except Exception as exc:
|
||
logger.debug("unload: tool deregister %s failed: %s", tool_name, exc)
|
||
# Persistent registrations survive unload-all but must not be orphaned by the ledger clear:
|
||
# carry them over so force re-discovery can evict the ones whose plugin does not come back.
|
||
carryover_ids = {id(registration) for registration in self._persistent_carryover}
|
||
self._persistent_carryover.extend(
|
||
registration
|
||
for owned in self._ownership_ledger.values()
|
||
for registration in owned
|
||
if registration.persistent
|
||
and registration.active
|
||
and id(registration) not in carryover_ids
|
||
)
|
||
for container in (
|
||
self._ownership_ledger, self._plugins, self._hooks, self._middleware,
|
||
self._plugin_tool_names, self._plugin_platform_names, self._cli_commands,
|
||
self._plugin_commands, self._plugin_skills, self._portable_mcp_servers,
|
||
self._aux_tasks, self._system_prompt_sections, self._approval_transports,
|
||
self._slack_action_handlers, self._predeclared_modules, self._predeclared_tools,
|
||
self._platform_handler_factories,
|
||
):
|
||
container.clear()
|
||
self._context_engine = None
|
||
with self._hook_timeout_lock:
|
||
self._hook_running_callbacks.clear()
|
||
self._hook_timeout_suppressed_until.clear()
|
||
self._discovered = False
|
||
|
||
# -- public ----------------------------------------------------------------
|
||
|
||
@property
|
||
def has_gateway_message_injector(self) -> bool:
|
||
"""Return whether a live gateway can accept plugin-triggered turns."""
|
||
return self._gateway_message_injector is not None
|
||
|
||
def set_gateway_message_injector(self, owner: object, injector: Callable[..., bool]) -> None:
|
||
"""Publish a live gateway injector and its lifecycle owner."""
|
||
self._gateway_message_injector = (owner, injector)
|
||
|
||
def clear_gateway_message_injector(self, owner: object) -> None:
|
||
"""Clear the injector only when it still belongs to ``owner``."""
|
||
registered = self._gateway_message_injector
|
||
if registered is not None and registered[0] is owner:
|
||
self._gateway_message_injector = None
|
||
|
||
def inject_gateway_message(self, **kwargs: Any) -> bool:
|
||
"""Submit a plugin-triggered turn to the live gateway."""
|
||
registered = self._gateway_message_injector
|
||
if registered is None:
|
||
return False
|
||
return bool(registered[1](**kwargs))
|
||
|
||
def discover_and_load(self, force: bool = False) -> None:
|
||
"""Scan all plugin sources and load each plugin found; ``force`` unloads first so config
|
||
changes / new bundled backends become visible in long-lived sessions."""
|
||
with self._discovery_lock, _plugin_home_scope(self.home_path):
|
||
if self._discovered and not force:
|
||
return
|
||
if force:
|
||
self.unload() # the ledger owns teardown of process-global registries
|
||
if env_var_enabled("HERMES_SAFE_MODE"):
|
||
logger.info("HERMES_SAFE_MODE=1 — plugin discovery skipped")
|
||
self._discovered = True
|
||
return
|
||
# Flag set up front as a re-entrancy guard (register() can trigger discovery again) but
|
||
# reset on failure so a failed scan is NOT cached as "discovered with an empty registry"
|
||
# — callers swallow the exception and would be stranded on the early return above.
|
||
self._discovered = True
|
||
try:
|
||
self._discover_and_load_inner()
|
||
# Persistent registrations survived the unload-all; now that plugins re-registered,
|
||
# dispose the ones whose plugin did not come back.
|
||
self._evict_stale_persistent_registrations()
|
||
# load_hermes_dotenv() ran at import, before plugin secret sources existed: re-pull.
|
||
self._refresh_secret_sources_after_discovery()
|
||
if force:
|
||
# config.yaml shell hooks / outbound webhooks live in ``_hooks`` but are
|
||
# config-owned; unload() wiped them and cannot restore them.
|
||
self._re_register_config_hooks_after_force()
|
||
except BaseException:
|
||
self._discovered = False
|
||
raise
|
||
|
||
def _re_register_config_hooks_after_force(self) -> None:
|
||
"""Restore config-owned shell hooks/outbound webhooks after a force clear; each guarded
|
||
independently so one failing does not skip the other."""
|
||
try:
|
||
from agent.shell_hooks import re_register_config_hooks
|
||
|
||
re_register_config_hooks()
|
||
except Exception as exc:
|
||
logger.debug("force-reload shell-hook re-register skipped: %s", exc)
|
||
try:
|
||
from agent.outbound_webhooks import (
|
||
re_register_config_hooks as re_register_outbound_webhooks,
|
||
)
|
||
|
||
re_register_outbound_webhooks()
|
||
except Exception as exc:
|
||
logger.debug("force-reload outbound-webhook re-register skipped: %s", exc)
|
||
|
||
def _refresh_secret_sources_after_discovery(self) -> None:
|
||
"""If any plugin secret source is enabled (per its own ``is_enabled(cfg)``, honoring custom
|
||
activation), reset the cache and re-apply. Fail-open: never raises into discover_and_load."""
|
||
try:
|
||
from agent.secret_sources.registry import list_plugin_sources
|
||
from hermes_cli.env_loader import load_hermes_dotenv, reset_secret_source_cache
|
||
except Exception:
|
||
return
|
||
try:
|
||
plugin_sources = list_plugin_sources()
|
||
except Exception:
|
||
return
|
||
if not plugin_sources:
|
||
return
|
||
try:
|
||
from hermes_cli.config import load_config
|
||
|
||
cfg = load_config() or {}
|
||
secrets = cfg.get("secrets") or {}
|
||
except Exception:
|
||
secrets = {}
|
||
enabled_names = []
|
||
for source in plugin_sources:
|
||
name = getattr(source, "name", "")
|
||
section = secrets.get(name)
|
||
section = section if isinstance(section, dict) else {}
|
||
try:
|
||
if source.is_enabled(section):
|
||
enabled_names.append(name)
|
||
except Exception:
|
||
continue # mirrors the orchestrator: a raising is_enabled() is skipped
|
||
if not enabled_names:
|
||
return
|
||
try:
|
||
reset_secret_source_cache()
|
||
load_hermes_dotenv()
|
||
logger.debug(
|
||
"Re-applied secret sources after plugin discovery for: %s",
|
||
", ".join(sorted(enabled_names)),
|
||
)
|
||
except Exception as exc:
|
||
logger.debug("secret source re-apply after discovery failed: %s", exc)
|
||
|
||
def _discover_and_load_inner(self) -> None:
|
||
"""The actual discovery sweep — see :meth:`discover_and_load`."""
|
||
manifests: List[PluginManifest] = self._collect_directory_manifests()
|
||
# Entry points are separate from the directory scan: the startup MCP probe must not import
|
||
# or register them.
|
||
ep_manifests = self._scan_entry_points()
|
||
logger.debug(" entrypoints: %d manifest(s)", len(ep_manifests))
|
||
manifests.extend(ep_manifests)
|
||
|
||
disabled = _get_disabled_plugins()
|
||
enabled = _get_enabled_plugins() # None = opt-in default (nothing enabled)
|
||
stale_relay_keys = legacy_relay_plugin_keys(enabled)
|
||
if stale_relay_keys:
|
||
logger.warning(
|
||
"Removed Hermes plugin %s is still listed in plugins.enabled; "
|
||
"remove it and configure native Relay plugins with %s",
|
||
", ".join(stale_relay_keys),
|
||
RELAY_PLUGINS_CONFIG_ENV,
|
||
)
|
||
# Later sources win on key collision (project > user > bundled); gate the winners, then
|
||
# load survivors in requires_plugins order (see resolve_plugin_load_order).
|
||
winners = {m.key or m.name: m for m in manifests}
|
||
to_load = {
|
||
key: manifest for key, manifest in winners.items()
|
||
if self._gate_manifest(manifest, disabled, enabled)
|
||
}
|
||
for lookup_key in resolve_plugin_load_order(to_load):
|
||
manifest = to_load[lookup_key]
|
||
self._warn_python_dependencies(manifest)
|
||
self._validate_plugin_config_schema(manifest)
|
||
self._load_plugin(manifest)
|
||
|
||
if manifests:
|
||
logger.info(
|
||
"Plugin discovery complete: %d found, %d enabled",
|
||
len(self._plugins),
|
||
sum(1 for p in self._plugins.values() if p.enabled),
|
||
)
|
||
|
||
def _record_placeholder(
|
||
self, manifest: PluginManifest, *, enabled: bool, error: Optional[str] = None
|
||
) -> None:
|
||
"""Record a manifest that discovery will not import (introspection-only entry)."""
|
||
loaded = LoadedPlugin(manifest=manifest, enabled=enabled)
|
||
if error is not None:
|
||
loaded.error = error
|
||
self._plugins[manifest.key or manifest.name] = loaded
|
||
|
||
def _gate_manifest(
|
||
self,
|
||
manifest: PluginManifest,
|
||
disabled: Set[str],
|
||
enabled: Optional[Set[str]],
|
||
) -> bool:
|
||
"""Route one winning manifest: load now, defer, or record as skipped. Returns True only for
|
||
plugins that go through the dependency-ordered load pass. Gate order matters: legacy relay
|
||
refusal, explicit disable, category-owned kinds, bundled auto-loads, then opt-in."""
|
||
lookup_key = manifest.key or manifest.name
|
||
|
||
# Relay lifecycle is core-owned; an old plugin copy would compete for its registries.
|
||
if lookup_key in LEGACY_RELAY_PLUGIN_KEYS or manifest.name in LEGACY_RELAY_PLUGIN_KEYS:
|
||
error = (
|
||
"removed — Relay lifecycle is owned by Hermes core; configure "
|
||
f"{RELAY_PLUGINS_CONFIG_ENV} instead"
|
||
)
|
||
self._record_placeholder(manifest, enabled=False, error=error)
|
||
logger.warning(
|
||
"Refusing to load removed Hermes Relay plugin '%s'; %s", lookup_key, error,
|
||
)
|
||
return False
|
||
|
||
# Explicit disable always wins (key or legacy bare name).
|
||
if lookup_key in disabled or manifest.name in disabled:
|
||
self._record_placeholder(manifest, enabled=False, error="disabled via config")
|
||
logger.debug("Skipping disabled plugin '%s'", lookup_key)
|
||
return False
|
||
|
||
# Exclusive plugins (memory providers) have their own activation path; record only.
|
||
if manifest.kind == "exclusive":
|
||
self._record_placeholder(
|
||
manifest, enabled=False,
|
||
error="exclusive plugin — activate via <category>.provider config",
|
||
)
|
||
logger.debug("Skipping '%s' (exclusive, handled by category discovery)", lookup_key)
|
||
return False
|
||
|
||
# Model providers load via providers/__init__.py; a second import here would create two
|
||
# ProviderProfile instances and break the bundled-vs-user "last writer wins" override.
|
||
if manifest.kind == "model-provider":
|
||
self._record_placeholder(manifest, enabled=True)
|
||
logger.debug(
|
||
"Skipping '%s' (model-provider, handled by providers/ discovery)", lookup_key,
|
||
)
|
||
return False
|
||
|
||
# Bundled backends auto-load; selection among them is ``<category>.provider`` config.
|
||
if manifest.source == "bundled" and manifest.kind == "backend":
|
||
self._load_plugin(manifest)
|
||
return False
|
||
|
||
# Bundled platforms register LAZILY: eagerly importing ~20 heavy SDKs added seconds to
|
||
# every `hermes` invocation. A deferred loader keeps every platform available on first use.
|
||
if manifest.source == "bundled" and manifest.kind == "platform":
|
||
self._register_deferred_platform(manifest)
|
||
return False
|
||
|
||
# Everything else is opt-in via plugins.enabled (path-derived key or legacy bare name).
|
||
if enabled is None or not (lookup_key in enabled or manifest.name in enabled):
|
||
self._record_placeholder(
|
||
manifest, enabled=False,
|
||
error=f"not enabled in config (run `hermes plugins enable {lookup_key}` to activate)",
|
||
)
|
||
logger.debug("Skipping '%s' (not in plugins.enabled)", lookup_key)
|
||
return False
|
||
return True
|
||
|
||
def register_approval_transport(
|
||
self,
|
||
name: str,
|
||
present_fn: Callable,
|
||
*,
|
||
plugin_id: str,
|
||
) -> None:
|
||
"""Register one plugin-owned approval transport for this profile."""
|
||
import re
|
||
|
||
from hermes_cli.approval_transport import RegisteredApprovalTransport
|
||
|
||
clean = str(name).strip().lower()
|
||
if clean == "builtin":
|
||
raise ValueError("approval transport name 'builtin' is reserved")
|
||
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", clean):
|
||
raise ValueError("approval transport name must match [a-z0-9][a-z0-9_-]{0,63}")
|
||
if not callable(present_fn):
|
||
raise TypeError("approval transport present_fn must be callable")
|
||
if clean in self._approval_transports:
|
||
owner = self._approval_transports[clean].plugin_id
|
||
raise ValueError(f"approval transport {clean!r} is already registered by {owner!r}")
|
||
self._approval_transports[clean] = RegisteredApprovalTransport(
|
||
name=clean,
|
||
present=present_fn,
|
||
plugin_id=plugin_id,
|
||
profile_home=str(get_hermes_home().resolve()),
|
||
)
|
||
logger.info("Plugin %s registered approval transport: %s", plugin_id, clean)
|
||
|
||
def get_approval_transport(self, name: str):
|
||
"""Return a transport only inside the profile that registered it."""
|
||
registered = self._approval_transports.get(str(name).strip().lower())
|
||
if registered is None:
|
||
return None
|
||
if registered.profile_home != str(get_hermes_home().resolve()):
|
||
return None
|
||
return registered
|
||
|
||
def _collect_directory_manifests(self) -> List[PluginManifest]:
|
||
"""Read directory manifests in full-discovery order without loading or mutating anything, so
|
||
startup probes share the exact precedence/containment rules of ``_discover_and_load_inner``.
|
||
"""
|
||
manifests: List[PluginManifest] = []
|
||
|
||
# 1. Bundled; excluded top-level categories have their own discovery, platforms scan below.
|
||
repo_plugins = get_bundled_plugins_dir()
|
||
logger.debug("Scanning bundled plugins: %s", repo_plugins)
|
||
bundled = self._scan_directory(
|
||
repo_plugins,
|
||
source="bundled",
|
||
skip_names={"memory", "context_engine", "platforms", "model-providers"},
|
||
)
|
||
logger.debug(" bundled (top-level): %d manifest(s)", len(bundled))
|
||
manifests.extend(bundled)
|
||
bundled_platforms = self._scan_directory(repo_plugins / "platforms", source="bundled")
|
||
logger.debug(" bundled/platforms: %d manifest(s)", len(bundled_platforms))
|
||
manifests.extend(bundled_platforms)
|
||
|
||
# 2. User plugins (~/.hermes/plugins/)
|
||
user_dir = get_hermes_home() / "plugins"
|
||
logger.debug("Scanning user plugins: %s", user_dir)
|
||
user_manifests = self._scan_directory(user_dir, source="user")
|
||
logger.debug(" user: %d manifest(s)", len(user_manifests))
|
||
manifests.extend(user_manifests)
|
||
|
||
# 3. Project plugins, only when explicitly opted in (must match the full-discovery gate).
|
||
if _env_enabled("HERMES_ENABLE_PROJECT_PLUGINS"):
|
||
project_dir = Path.cwd() / ".hermes" / "plugins"
|
||
logger.debug("Scanning project plugins: %s", project_dir)
|
||
project_manifests = self._scan_directory(project_dir, source="project")
|
||
logger.debug(" project: %d manifest(s)", len(project_manifests))
|
||
manifests.extend(project_manifests)
|
||
else:
|
||
logger.debug("Project plugins disabled (set HERMES_ENABLE_PROJECT_PLUGINS=1 to enable)")
|
||
|
||
return manifests
|
||
|
||
def has_enabled_portable_mcp(self, raw_config: Mapping[str, Any]) -> bool:
|
||
"""Probe enabled portable MCP packages without loading plugins (shares the full-discovery
|
||
manifest collection so precedence/gating cannot diverge)."""
|
||
if _env_enabled("HERMES_SAFE_MODE"):
|
||
return False
|
||
|
||
plugins_config = raw_config.get("plugins")
|
||
if not isinstance(plugins_config, dict):
|
||
return False
|
||
|
||
def _names(value: Any) -> Set[str]:
|
||
return {v for v in value if isinstance(v, str)} if isinstance(value, list) else set()
|
||
|
||
enabled = _names(plugins_config.get("enabled"))
|
||
disabled = _names(plugins_config.get("disabled", []))
|
||
if not enabled:
|
||
return False
|
||
|
||
winners = {m.key or m.name: m for m in self._collect_directory_manifests()}
|
||
for manifest in winners.values():
|
||
if not manifest.portable:
|
||
continue
|
||
lookup_key = manifest.key or manifest.name
|
||
if lookup_key in disabled or manifest.name in disabled:
|
||
continue
|
||
if lookup_key not in enabled and manifest.name not in enabled:
|
||
continue
|
||
try:
|
||
from hermes_cli.agent_plugins import _discover_mcp
|
||
|
||
if _discover_mcp(
|
||
Path(manifest.path),
|
||
get_hermes_home()
|
||
/ "plugin-data"
|
||
/ (manifest.skill_namespace or lookup_key),
|
||
[],
|
||
create_data=False,
|
||
):
|
||
return True
|
||
except (OSError, RuntimeError, ValueError):
|
||
continue # fail closed on an unreadable package; full discovery reports it
|
||
return False
|
||
|
||
# -- directory scanning -----------------------------------------------------
|
||
|
||
def _scan_directory(
|
||
self,
|
||
path: Path,
|
||
source: str,
|
||
skip_names: Optional[Set[str]] = None,
|
||
) -> List[PluginManifest]:
|
||
"""Read manifests under *path*: flat ``<root>/<name>/plugin.yaml`` (key ``name``) or
|
||
category ``<root>/<cat>/<name>/plugin.yaml`` (key ``cat/name``, depth capped at two).
|
||
*skip_names* ignores top-level names."""
|
||
return self._scan_directory_level(path, source, skip_names=skip_names, prefix="", depth=0)
|
||
|
||
def _scan_directory_level(
|
||
self,
|
||
path: Path,
|
||
source: str,
|
||
*,
|
||
skip_names: Optional[Set[str]],
|
||
prefix: str,
|
||
depth: int,
|
||
) -> List[PluginManifest]:
|
||
"""Recursive body of :meth:`_scan_directory` (``prefix`` = accumulated category path)."""
|
||
manifests: List[PluginManifest] = []
|
||
if not path.is_dir():
|
||
return manifests
|
||
|
||
for child in sorted(path.iterdir()):
|
||
if not child.is_dir():
|
||
continue
|
||
if depth == 0 and skip_names and child.name in skip_names:
|
||
continue
|
||
manifest_file = child / "plugin.yaml"
|
||
if not manifest_file.exists():
|
||
manifest_file = child / "plugin.yml"
|
||
|
||
if manifest_file.exists():
|
||
manifest = self._parse_manifest(manifest_file, child, source, prefix)
|
||
if manifest is not None:
|
||
manifests.append(manifest)
|
||
continue
|
||
|
||
portable_file = child / "plugin.json"
|
||
if portable_file.exists() or portable_file.is_symlink():
|
||
try:
|
||
from hermes_cli.agent_plugins import read_agent_plugin_manifest
|
||
|
||
data, diagnostics = read_agent_plugin_manifest(child)
|
||
for diagnostic in diagnostics:
|
||
logger.warning("Agent Plugin '%s': %s", child, diagnostic.message)
|
||
key = f"{prefix}/{child.name}" if prefix else data["name"]
|
||
manifests.append(
|
||
PluginManifest(
|
||
name=data["name"],
|
||
version=data.get("version", ""),
|
||
description=data.get("description", ""),
|
||
author=_display_author(data.get("author", "")),
|
||
source=source,
|
||
path=str(child),
|
||
key=key,
|
||
portable=True,
|
||
skill_namespace=_portable_skill_namespace(key),
|
||
)
|
||
)
|
||
except Exception as exc:
|
||
logger.warning("Failed to parse %s: %s", portable_file, exc)
|
||
continue
|
||
|
||
# No manifest: treat as a category namespace and recurse one level (depth cap 2).
|
||
if depth >= 1:
|
||
logger.debug("Skipping %s (no plugin.yaml, depth cap reached)", child)
|
||
continue
|
||
|
||
sub_prefix = f"{prefix}/{child.name}" if prefix else child.name
|
||
manifests.extend(
|
||
self._scan_directory_level(
|
||
child,
|
||
source,
|
||
skip_names=None,
|
||
prefix=sub_prefix,
|
||
depth=depth + 1,
|
||
)
|
||
)
|
||
|
||
return manifests
|
||
|
||
def _parse_manifest(
|
||
self,
|
||
manifest_file: Path,
|
||
plugin_dir: Path,
|
||
source: str,
|
||
prefix: str,
|
||
) -> Optional[PluginManifest]:
|
||
"""Parse one ``plugin.yaml`` into a :class:`PluginManifest`; ``None`` (warned) on failure."""
|
||
try:
|
||
if yaml is None:
|
||
logger.warning("PyYAML not installed – cannot load %s", manifest_file)
|
||
return None
|
||
data = fast_safe_load(manifest_file.read_text(encoding="utf-8")) or {}
|
||
|
||
name = data.get("name", plugin_dir.name)
|
||
key = f"{prefix}/{plugin_dir.name}" if prefix else name
|
||
|
||
raw_kind = data.get("kind", "standalone")
|
||
if not isinstance(raw_kind, str):
|
||
raw_kind = "standalone"
|
||
kind = raw_kind.strip().lower()
|
||
if kind not in _VALID_PLUGIN_KINDS:
|
||
logger.warning(
|
||
"Plugin %s: unknown kind '%s' (valid: %s); treating as 'standalone'",
|
||
key, raw_kind, ", ".join(sorted(_VALID_PLUGIN_KINDS)),
|
||
)
|
||
kind = "standalone"
|
||
|
||
# Auto-coerce undeclared memory/model providers so they route to their own discovery
|
||
# instead of the general manager (register_memory_provider here is a no-op).
|
||
if kind == "standalone" and "kind" not in data:
|
||
init_file = plugin_dir / "__init__.py"
|
||
if init_file.exists():
|
||
with suppress(Exception):
|
||
detected = _detect_kind_from_source(
|
||
init_file.read_text(errors="replace", encoding="utf-8")[:8192]
|
||
)
|
||
if detected:
|
||
kind = detected
|
||
logger.debug(
|
||
"Plugin %s: detected %s, treating as kind='%s'",
|
||
key, detected, detected,
|
||
)
|
||
|
||
logger.debug(
|
||
"Parsed manifest: key=%s name=%s kind=%s source=%s path=%s",
|
||
key, name, kind, source, plugin_dir,
|
||
)
|
||
v2_fields = _parse_manifest_v2_fields(data, key)
|
||
return PluginManifest(
|
||
name=name,
|
||
version=str(data.get("version", "")),
|
||
description=data.get("description", ""),
|
||
author=_display_author(data.get("author", "")),
|
||
requires_env=data.get("requires_env", []),
|
||
provides_tools=data.get("provides_tools", []),
|
||
provides_hooks=data.get("provides_hooks", []),
|
||
source=source,
|
||
path=str(plugin_dir),
|
||
kind=kind,
|
||
key=key,
|
||
capabilities=_parse_declared_capabilities(
|
||
data.get("capabilities"), name
|
||
),
|
||
**v2_fields,
|
||
emits=data.get("emits") or [],
|
||
listens=data.get("listens") or [],
|
||
)
|
||
except Exception as exc:
|
||
logger.warning("Failed to parse %s: %s", manifest_file, exc, exc_info=_PLUGINS_DEBUG)
|
||
return None
|
||
|
||
def _scan_entry_points(self) -> List[PluginManifest]:
|
||
"""Read installed plugin entry points (see :func:`discover_entrypoint_manifests`)."""
|
||
return discover_entrypoint_manifests()
|
||
|
||
# -- loading ---------------------------------------------------------------
|
||
|
||
def _platform_name_from_manifest(self, manifest: PluginManifest) -> str:
|
||
"""Derive the platform name without importing the adapter: strip a trailing ``-platform``
|
||
from the manifest name, else the directory basename (the bundled convention)."""
|
||
name = manifest.name or ""
|
||
if name.endswith("-platform"):
|
||
return name[: -len("-platform")]
|
||
if manifest.path:
|
||
return Path(manifest.path).name
|
||
return name
|
||
|
||
@_serialized_replacement
|
||
def _register_deferred_platform(self, manifest: PluginManifest) -> None:
|
||
"""Register a lazy loader for a bundled platform: the adapter imports only when the
|
||
``platform_registry`` is first asked for it; a placeholder ``LoadedPlugin`` keeps it visible
|
||
in ``hermes plugins list`` until then."""
|
||
lookup_key = manifest.key or manifest.name
|
||
platform_name = self._platform_name_from_manifest(manifest)
|
||
|
||
loaded = LoadedPlugin(manifest=manifest, enabled=True)
|
||
loaded.deferred = True
|
||
self._plugins[lookup_key] = loaded
|
||
|
||
try:
|
||
from gateway.platform_registry import platform_registry
|
||
|
||
scope = self.scope_key
|
||
|
||
def _loader(_manifest: PluginManifest = manifest) -> None:
|
||
# Lock before checking cancellation: if an unload won the race it restored the
|
||
# predecessor and this loader must publish nothing; if loading won, unload waits
|
||
# and disposes the completed set.
|
||
with self._discovery_lock, _plugin_home_scope(self.home_path):
|
||
if platform_registry.is_deferred_load_cancelled(
|
||
platform_name, scope=scope
|
||
):
|
||
return
|
||
self._load_plugin_scoped(_manifest)
|
||
|
||
previous = platform_registry.snapshot_registration(platform_name, scope=scope)
|
||
platform_registry.register_deferred(platform_name, _loader, scope=scope)
|
||
current = platform_registry.snapshot_registration(platform_name, scope=scope)
|
||
if current[0] is None and current[1] is _loader:
|
||
self._plugin_platform_names.add(platform_name)
|
||
self._track_scoped_registration(
|
||
manifest, "platform", platform_name, platform_registry, current, previous,
|
||
finalize=lambda: self._remove_platform_name_if_unowned(platform_name),
|
||
)
|
||
logger.debug(
|
||
"Registered deferred platform loader: %s (plugin=%s)",
|
||
platform_name,
|
||
lookup_key,
|
||
)
|
||
except Exception:
|
||
# Fall back to eager loading so the platform is never silently lost.
|
||
logger.debug(
|
||
"Deferred platform registration failed for '%s'; eager-loading",
|
||
lookup_key,
|
||
exc_info=True,
|
||
)
|
||
self._load_plugin(manifest)
|
||
return
|
||
|
||
self._register_deferred_platform_tools(manifest, loaded)
|
||
|
||
def _register_deferred_platform_tools(
|
||
self, manifest: PluginManifest, loaded: LoadedPlugin
|
||
) -> None:
|
||
"""Register a deferred platform's *client* tools without its adapter.
|
||
|
||
Deferring the plugin would otherwise defer its outbound tools too, so in CLI/TUI processes
|
||
(which never materialize platforms) they would be missing from ``hermes tools`` and dropped
|
||
from ``platform_toolsets``. Opt-in is explicit via ``provides_tools``; tools live in a
|
||
``tools`` submodule so ``__init__`` stays import-light.
|
||
"""
|
||
if not manifest.provides_tools:
|
||
return
|
||
|
||
lookup_key = manifest.key or manifest.name
|
||
plugin_dir = Path(manifest.path) if manifest.path else None
|
||
if plugin_dir is None or not (plugin_dir / "tools.py").is_file():
|
||
# Declared but undeliverable — staying quiet reproduces the very symptom this fixes.
|
||
logger.warning(
|
||
"Plugin '%s' declares provides_tools %s but has no tools.py; "
|
||
"those tools will not be available in CLI/TUI sessions.",
|
||
lookup_key,
|
||
list(manifest.provides_tools),
|
||
)
|
||
return
|
||
|
||
before = set(self._plugin_tool_names) # lets the failure path credit partial registrations
|
||
try:
|
||
module = self._load_directory_module(manifest)
|
||
# Record the module even if nothing registers: the package body has run, so
|
||
# materializing the adapter later must reuse it rather than execute it twice.
|
||
loaded.module = module
|
||
self._predeclared_modules[lookup_key] = module
|
||
|
||
tools_module = importlib.import_module(f"{module.__name__}.tools")
|
||
register_tools = getattr(tools_module, "register_tools", None)
|
||
if register_tools is None:
|
||
logger.warning(
|
||
"Plugin '%s' declares provides_tools %s but its tools.py "
|
||
"has no register_tools(ctx); those tools will not be "
|
||
"available in CLI/TUI sessions.",
|
||
lookup_key,
|
||
list(manifest.provides_tools),
|
||
)
|
||
return
|
||
|
||
register_tools(PluginContext(manifest, self))
|
||
registered = [t for t in self._plugin_tool_names if t not in before]
|
||
|
||
loaded.tools_registered = registered
|
||
self._predeclared_tools[lookup_key] = registered
|
||
logger.debug(
|
||
"Deferred platform '%s': pre-registered %d client tool(s) %s",
|
||
lookup_key,
|
||
len(registered),
|
||
registered,
|
||
)
|
||
except Exception as exc:
|
||
# Tools registered before the raise are live: credit them or `hermes plugins list`
|
||
# under-reports (and _load_plugin's later diff would miss them too).
|
||
partial = [t for t in self._plugin_tool_names if t not in before]
|
||
if partial:
|
||
loaded.tools_registered = partial
|
||
self._predeclared_tools[lookup_key] = partial
|
||
|
||
# Never break discovery (the platform stays deferred), but a broken tools.py IS the
|
||
# symptom, so warn — and say where it failed, which is what the operator needs first.
|
||
declared = len(manifest.provides_tools)
|
||
if not partial:
|
||
scope = f"before registering any of its {declared} declared tool(s)"
|
||
elif len(partial) >= declared:
|
||
scope = f"after registering all {declared} declared tool(s)"
|
||
else:
|
||
scope = f"after registering {len(partial)} of {declared} declared tool(s)"
|
||
logger.warning(
|
||
"Plugin '%s': client-tool pre-registration failed %s (%s).%s",
|
||
lookup_key,
|
||
scope,
|
||
exc,
|
||
"" if len(partial) >= declared else
|
||
" The remainder will be missing from CLI/TUI sessions.",
|
||
exc_info=_PLUGINS_DEBUG,
|
||
)
|
||
|
||
def _warn_python_dependencies(self, manifest: PluginManifest) -> None:
|
||
"""Warn about missing declared pip dependencies with an install hint — NEVER auto-install."""
|
||
deps = manifest.python_dependencies
|
||
if not deps:
|
||
return
|
||
key = manifest.key or manifest.name
|
||
missing: List[str] = []
|
||
for req in deps:
|
||
# Best-effort presence probe on the distribution name.
|
||
dist = re.split(r"[<>=!~\[;\s]", req, maxsplit=1)[0].strip()
|
||
if not dist:
|
||
continue
|
||
try:
|
||
importlib.metadata.version(dist)
|
||
except importlib.metadata.PackageNotFoundError:
|
||
missing.append(req)
|
||
except Exception:
|
||
continue
|
||
if missing:
|
||
logger.warning(
|
||
"Plugin %s declares Python dependencies that are not "
|
||
"installed: %s. Hermes does not install plugin dependencies "
|
||
"automatically; install them yourself, e.g.: pip install %s",
|
||
key, ", ".join(missing),
|
||
" ".join(f"'{m}'" for m in missing),
|
||
)
|
||
else:
|
||
logger.debug("Plugin %s python_dependencies satisfied: %s", key, ", ".join(deps))
|
||
|
||
def _validate_plugin_config_schema(self, manifest: PluginManifest) -> None:
|
||
"""Warn (never block) on plugins.entries.<id> settings that violate config_schema."""
|
||
if not manifest.config_schema:
|
||
return
|
||
plugin_id = manifest.key or manifest.name
|
||
settings: Mapping[str, Any] = {}
|
||
try:
|
||
from hermes_cli.config import load_config
|
||
|
||
cfg = load_config() or {}
|
||
entries = (cfg.get("plugins") or {}).get("entries") or {}
|
||
entry = entries.get(plugin_id) if isinstance(entries, Mapping) else None
|
||
raw = entry.get("settings") if isinstance(entry, Mapping) else None
|
||
if not isinstance(raw, Mapping):
|
||
# Migration fallback mirroring ctx.get_config.
|
||
raw = entry.get("config") if isinstance(entry, Mapping) else None
|
||
settings = raw if isinstance(raw, Mapping) else {}
|
||
except Exception:
|
||
settings = {}
|
||
for warning in validate_config_schema(
|
||
plugin_id, manifest.config_schema, settings
|
||
):
|
||
logger.warning("Plugin %s config: %s", plugin_id, warning)
|
||
|
||
def _load_plugin(self, manifest: PluginManifest) -> None:
|
||
"""Import a plugin module and call its ``register(ctx)`` function."""
|
||
with self._discovery_lock, _plugin_home_scope(self.home_path):
|
||
self._load_plugin_scoped(manifest)
|
||
|
||
def _load_plugin_scoped(self, manifest: PluginManifest) -> None:
|
||
"""Load one plugin with the manager's home bound as current."""
|
||
loaded = LoadedPlugin(manifest=manifest)
|
||
logger.debug(
|
||
"Loading plugin '%s' (source=%s, kind=%s, path=%s)",
|
||
manifest.key or manifest.name, manifest.source, manifest.kind, manifest.path,
|
||
)
|
||
|
||
if manifest.portable:
|
||
self._load_portable_plugin(manifest, loaded)
|
||
return
|
||
|
||
registration_start = len(self._registration_order)
|
||
plugin_key = manifest.key or manifest.name
|
||
_module_name = self._policy_module_name(manifest)
|
||
self._track_tool_override_policy(manifest, _module_name)
|
||
try:
|
||
# Reuse a deferred platform's already-imported package so its body doesn't run twice.
|
||
preloaded = self._predeclared_modules.pop(plugin_key, None)
|
||
if preloaded is not None:
|
||
module = preloaded
|
||
elif manifest.source in {"user", "project", "bundled"}:
|
||
module = self._load_directory_module(manifest, module_name=_module_name)
|
||
else:
|
||
module = self._load_entrypoint_module(manifest)
|
||
|
||
loaded.module = module
|
||
register_fn = getattr(module, "register", None)
|
||
if register_fn is None:
|
||
loaded.error = "no register() function"
|
||
logger.warning("Plugin '%s' has no register() function", manifest.name)
|
||
else:
|
||
register_fn(PluginContext(manifest, self))
|
||
self._attribute_registrations(loaded, plugin_key, registration_start)
|
||
loaded.enabled = True
|
||
|
||
except Exception as exc:
|
||
owned = [
|
||
registration
|
||
for registration in self._registration_order
|
||
if registration.plugin_key == plugin_key
|
||
]
|
||
self._dispose_registrations(owned)
|
||
self._forget_registrations(owned)
|
||
loaded.error = str(exc)
|
||
# register() may have subscribed before raising; a failed plugin must leave no callable
|
||
# reachable from later event dispatch.
|
||
self._remove_plugin_subscriptions(plugin_key)
|
||
logger.warning(
|
||
"Failed to load plugin '%s': %s",
|
||
manifest.name, exc, exc_info=_PLUGINS_DEBUG,
|
||
)
|
||
# The failure path swept this plugin's whole ledger (not just the registration_start slice),
|
||
# so discovery-time pre-registrations are gone too.
|
||
if not loaded.enabled:
|
||
self._predeclared_tools.pop(plugin_key, None)
|
||
self._plugins[manifest.key or manifest.name] = loaded
|
||
|
||
def _track_tool_override_policy(self, manifest: PluginManifest, module_name: str) -> None:
|
||
"""Install the plugin's tool-override policy in tools.registry as a ledger-owned lease."""
|
||
from tools.registry import registry as _registry
|
||
|
||
with replacement_coordinator.transaction():
|
||
previous_policy = _registry.snapshot_plugin_override_policy(
|
||
module_name, scope=self.scope_key
|
||
)
|
||
current_policy = _registry.register_plugin_override_policy(
|
||
module_name,
|
||
PluginContext(manifest, self)._tool_override_allowed(""),
|
||
scope=self.scope_key,
|
||
)
|
||
policy_lease = replacement_coordinator.acquire(
|
||
("tool_override_policy", self.scope_key, module_name),
|
||
current=current_policy,
|
||
previous=previous_policy,
|
||
restore=lambda replacement: _registry.restore_plugin_override_policy(
|
||
module_name,
|
||
current_policy,
|
||
replacement,
|
||
scope=self.scope_key,
|
||
),
|
||
)
|
||
self._track_registration(
|
||
manifest, "tool_override_policy", module_name, policy_lease.dispose,
|
||
)
|
||
|
||
def _attribute_registrations(
|
||
self, loaded: LoadedPlugin, plugin_key: str, registration_start: int
|
||
) -> None:
|
||
"""Fill ``loaded.*_registered`` from the ledger slice this plugin's register() produced."""
|
||
registrations = [
|
||
registration
|
||
for registration in self._registration_order[registration_start:]
|
||
if registration.plugin_key == plugin_key and registration.active
|
||
]
|
||
|
||
def _keys(kind: str) -> List[str]:
|
||
return [r.key for r in registrations if r.kind == kind]
|
||
|
||
# Discovery-time tools predate registration_start; credit them back or `hermes plugins
|
||
# list` under-reports once the deferred adapter materializes.
|
||
_predeclared = [
|
||
t for t in self._predeclared_tools.pop(plugin_key, [])
|
||
if t in self._plugin_tool_names
|
||
]
|
||
loaded.tools_registered = _predeclared + [
|
||
key for key in _keys("tool") if key not in _predeclared
|
||
]
|
||
loaded.hooks_registered = _keys("hook")
|
||
loaded.middleware_registered = _keys("middleware")
|
||
loaded.commands_registered = _keys("command")
|
||
logger.debug(
|
||
" registered: %d tool(s), %d hook(s), %d middleware, %d slash command(s), %d CLI command(s)",
|
||
len(loaded.tools_registered),
|
||
len(loaded.hooks_registered),
|
||
len(loaded.middleware_registered),
|
||
len(loaded.commands_registered),
|
||
sum(1 for c in self._cli_commands if c in _keys("cli_command")),
|
||
)
|
||
|
||
def _load_portable_plugin(self, manifest: PluginManifest, loaded: LoadedPlugin) -> None:
|
||
"""Load validated portable components without importing Python code."""
|
||
|
||
lookup_key = manifest.key or manifest.name
|
||
try:
|
||
from hermes_cli.agent_plugins import load_agent_plugin
|
||
|
||
package = load_agent_plugin(
|
||
Path(manifest.path),
|
||
get_hermes_home() / "plugin-data" / manifest.skill_namespace,
|
||
)
|
||
ctx = PluginContext(manifest, self)
|
||
for diagnostic in package.diagnostics:
|
||
logger.warning(
|
||
"Agent Plugin '%s' [%s]: %s",
|
||
lookup_key,
|
||
diagnostic.scope,
|
||
diagnostic.message,
|
||
)
|
||
for skill in package.skills:
|
||
try:
|
||
ctx.register_skill(
|
||
skill.name,
|
||
skill.skill_md,
|
||
skill.description,
|
||
skill.frontmatter,
|
||
)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"Agent Plugin '%s' skill '%s' skipped: %s",
|
||
lookup_key,
|
||
skill.name,
|
||
exc,
|
||
)
|
||
for server_name, config in package.mcp_servers.items():
|
||
internal_name = f"{manifest.skill_namespace}__{server_name}"
|
||
if internal_name in self._portable_mcp_servers:
|
||
logger.warning(
|
||
"Agent Plugin '%s' MCP server collision: %s",
|
||
lookup_key,
|
||
internal_name,
|
||
)
|
||
continue
|
||
self._portable_mcp_servers[internal_name] = dict(config)
|
||
loaded.enabled = True
|
||
except Exception as exc:
|
||
loaded.error = str(exc)
|
||
logger.warning("Failed to load Agent Plugin '%s': %s", lookup_key, exc)
|
||
self._plugins[lookup_key] = loaded
|
||
|
||
def _directory_module_name(self, manifest: PluginManifest) -> str:
|
||
"""Return a profile-safe import namespace for a directory plugin."""
|
||
key = manifest.key or manifest.name
|
||
slug = key.replace("/", "__").replace("-", "_")
|
||
bare_name = f"{_NS_PARENT}.{slug}"
|
||
with _MODULE_NAMESPACE_LOCK:
|
||
owner = _BARE_MODULE_SCOPE.get(bare_name)
|
||
if owner is None:
|
||
_BARE_MODULE_SCOPE[bare_name] = self.scope_key
|
||
return bare_name
|
||
if owner == self.scope_key:
|
||
return bare_name
|
||
digest = hashlib.sha256(self.scope_key.encode("utf-8")).hexdigest()[:12]
|
||
return f"{bare_name}__home_{digest}"
|
||
|
||
def _policy_module_name(self, manifest: PluginManifest) -> str:
|
||
"""Return the module prefix whose callbacks inherit plugin policy."""
|
||
if manifest.source == "entrypoint" and manifest.path:
|
||
module_name = str(manifest.path).partition(":")[0].strip()
|
||
if module_name:
|
||
return module_name
|
||
return self._directory_module_name(manifest)
|
||
|
||
def _load_directory_module(
|
||
self,
|
||
manifest: PluginManifest,
|
||
*,
|
||
module_name: Optional[str] = None,
|
||
) -> types.ModuleType:
|
||
"""Import a directory plugin as ``hermes_plugins.<slug>`` (slug from ``manifest.key`` so
|
||
``image_gen/openai`` cannot collide with ``tts/openai``)."""
|
||
plugin_dir = Path(manifest.path) # type: ignore[arg-type]
|
||
init_file = plugin_dir / "__init__.py"
|
||
if not init_file.exists():
|
||
raise FileNotFoundError(f"No __init__.py in {plugin_dir}")
|
||
|
||
if _NS_PARENT not in sys.modules:
|
||
ns_pkg = types.ModuleType(_NS_PARENT)
|
||
ns_pkg.__path__ = [] # type: ignore[attr-defined]
|
||
ns_pkg.__package__ = _NS_PARENT
|
||
sys.modules[_NS_PARENT] = ns_pkg
|
||
|
||
module_name = module_name or self._directory_module_name(manifest)
|
||
|
||
# Evict stale entries for this slug (same slug cached from another Hermes home, or an
|
||
# earlier force reload). Replacing only sys.modules[module_name] is not enough: the plugin's
|
||
# relative imports are cached as "module_name.sub" and resolve from sys.modules first, so a
|
||
# stale submodule would keep serving the previous load's code/state.
|
||
_evict_modules(module_name)
|
||
|
||
spec = importlib.util.spec_from_file_location(
|
||
module_name,
|
||
init_file,
|
||
submodule_search_locations=[str(plugin_dir)],
|
||
)
|
||
if spec is None or spec.loader is None:
|
||
raise ImportError(f"Cannot create module spec for {init_file}")
|
||
|
||
module = importlib.util.module_from_spec(spec)
|
||
module.__package__ = module_name
|
||
module.__path__ = [str(plugin_dir)] # type: ignore[attr-defined]
|
||
sys.modules[module_name] = module
|
||
try:
|
||
spec.loader.exec_module(module)
|
||
except BaseException:
|
||
# Don't leave a half-initialized module (or its partially imported relative submodules)
|
||
# cached — a retry or a same-slug plugin in another profile would inherit broken state.
|
||
_evict_modules(module_name)
|
||
raise
|
||
return module
|
||
|
||
def _load_entrypoint_module(self, manifest: PluginManifest) -> types.ModuleType:
|
||
"""Load a pip-installed plugin via its entry-point reference."""
|
||
for ep in _select_entry_point_group(importlib.metadata.entry_points(), ENTRY_POINTS_GROUP):
|
||
if ep.name == manifest.name:
|
||
return ep.load()
|
||
|
||
raise ImportError(
|
||
f"Entry point '{manifest.name}' not found in group '{ENTRY_POINTS_GROUP}'"
|
||
)
|
||
|
||
# -- hook invocation ----------------------------------------------------------
|
||
|
||
@staticmethod
|
||
def _invoke_hook_callback(callback: Callable, payload: Dict[str, Any]) -> Any:
|
||
"""Invoke a hook while withholding additive fields from narrow legacy callbacks."""
|
||
try:
|
||
parameters = inspect.signature(callback).parameters
|
||
except (TypeError, ValueError):
|
||
return callback(**payload) # no introspectable signature: historical behavior
|
||
|
||
if any(
|
||
parameter.kind == inspect.Parameter.VAR_KEYWORD
|
||
for parameter in parameters.values()
|
||
):
|
||
return callback(**payload)
|
||
|
||
accepted_payload = {
|
||
name: value
|
||
for name, value in payload.items()
|
||
if name in parameters
|
||
and parameters[name].kind
|
||
in {
|
||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||
inspect.Parameter.KEYWORD_ONLY,
|
||
}
|
||
}
|
||
return callback(**accepted_payload)
|
||
|
||
def invoke_hook(self, hook_name: str, **kwargs: Any) -> List[Any]:
|
||
"""Call all callbacks for *hook_name*; return their non-``None`` results.
|
||
|
||
Payloads evolve additively: ``**kwargs`` callbacks get everything, narrow signatures only
|
||
what they declare. Each callback is isolated. Hooks in ``_HOOK_TIMEOUT_BOUNDED_HOOKS`` and
|
||
``pre_tool_call`` are bounded by ``plugins.hook_callback_timeout`` (worker abandoned, never
|
||
joined); ``pre_tool_call`` fails closed with a block directive, others skip.
|
||
``_HOOK_CALLER_THREAD_HOOKS`` always run on the caller thread. ``pre_llm_call`` callbacks may
|
||
return ``{"context": "..."}`` (or a plain string) to inject into the turn.
|
||
"""
|
||
# Gateway platform events define event-local envelopes; a bus-wide version here would turn
|
||
# unrelated adapter payloads into one monolithic compatibility contract.
|
||
if hook_name != "gateway_platform_event":
|
||
kwargs.setdefault("telemetry_schema_version", OBSERVER_SCHEMA_VERSION)
|
||
callbacks = self._hooks.get(hook_name, [])
|
||
results: List[Any] = []
|
||
timeout = _resolve_hook_callback_timeout()
|
||
use_timeout = _hook_uses_callback_timeout(hook_name, timeout)
|
||
fail_closed = hook_name in _HOOK_TIMEOUT_FAIL_CLOSED_HOOKS
|
||
|
||
for cb in callbacks:
|
||
try:
|
||
if use_timeout:
|
||
ret = self._run_hook_callback_bounded(hook_name, cb, kwargs, timeout)
|
||
if ret is _HOOK_SKIPPED:
|
||
if fail_closed:
|
||
results.append(_pre_tool_call_timeout_block())
|
||
continue
|
||
else:
|
||
ret = self._invoke_hook_callback(cb, kwargs)
|
||
if ret is not None:
|
||
results.append(ret)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"Hook '%s' callback %s raised: %s",
|
||
hook_name,
|
||
getattr(cb, "__name__", repr(cb)),
|
||
exc,
|
||
)
|
||
return results
|
||
|
||
def _run_hook_callback_bounded(
|
||
self, hook_name: str, cb: Callable, kwargs: Dict[str, Any], timeout: float
|
||
) -> Any:
|
||
"""Run one callback on a daemon worker with a wall-clock cap; ``_HOOK_SKIPPED`` when
|
||
suppressed, still running, or timed out (worker abandoned, never joined). Exceptions
|
||
propagate."""
|
||
callback_name = getattr(cb, "__name__", repr(cb))
|
||
callback_key = (hook_name, id(cb))
|
||
token = object()
|
||
now = time.monotonic()
|
||
with self._hook_timeout_lock:
|
||
suppressed_until = self._hook_timeout_suppressed_until.get(callback_key)
|
||
running = callback_key in self._hook_running_callbacks
|
||
if (suppressed_until is not None and suppressed_until > now) or running:
|
||
logger.warning(
|
||
"Hook '%s' callback %s skipped after previous "
|
||
"timeout or while still running",
|
||
hook_name,
|
||
callback_name,
|
||
)
|
||
return _HOOK_SKIPPED
|
||
if suppressed_until is not None:
|
||
self._hook_timeout_suppressed_until.pop(callback_key, None)
|
||
self._hook_running_callbacks[callback_key] = token
|
||
|
||
context = contextvars.copy_context()
|
||
done = threading.Event()
|
||
outcome: Dict[str, Any] = {}
|
||
failure: Dict[str, Exception] = {}
|
||
|
||
def _runner() -> None:
|
||
try:
|
||
outcome["value"] = context.run(self._invoke_hook_callback, cb, kwargs)
|
||
except Exception as exc:
|
||
failure["exc"] = exc
|
||
finally:
|
||
with self._hook_timeout_lock:
|
||
if self._hook_running_callbacks.get(callback_key) is token:
|
||
self._hook_running_callbacks.pop(callback_key, None)
|
||
done.set()
|
||
|
||
thread = threading.Thread(
|
||
target=_runner,
|
||
name=f"hermes-hook-{callback_name}"[:40],
|
||
daemon=True,
|
||
)
|
||
thread.start()
|
||
if not done.wait(timeout=timeout): # do not join — that would reintroduce the hang
|
||
with self._hook_timeout_lock:
|
||
self._hook_timeout_suppressed_until[callback_key] = (
|
||
time.monotonic() + self._hook_timeout_suppression_seconds
|
||
)
|
||
logger.warning(
|
||
"Hook '%s' callback %s timed out after %gs — skipping",
|
||
hook_name,
|
||
callback_name,
|
||
timeout,
|
||
)
|
||
return _HOOK_SKIPPED
|
||
if "exc" in failure:
|
||
raise failure["exc"]
|
||
return outcome.get("value")
|
||
|
||
def _subscribe_event(self, owner: str, event: str, callback: Callable) -> None:
|
||
"""Add an owner-tagged event subscription in registration order."""
|
||
if not callable(callback):
|
||
raise TypeError("Event subscriber callback must be callable")
|
||
entry = _EventSubscription(owner=owner, callback=callback)
|
||
with self._event_lock:
|
||
self._subscriptions.setdefault(event, []).append(entry)
|
||
|
||
def _remove_plugin_subscriptions(self, owner: str) -> int:
|
||
"""Remove every subscription owned by *owner*; return the count. Queued envelopes re-check
|
||
membership per callback, so this also cancels already-snapshotted deliveries."""
|
||
removed = 0
|
||
with self._event_lock:
|
||
for event in list(self._subscriptions):
|
||
entries = self._subscriptions[event]
|
||
retained = [entry for entry in entries if entry.owner != owner]
|
||
removed += len(entries) - len(retained)
|
||
if retained:
|
||
self._subscriptions[event] = retained
|
||
else:
|
||
del self._subscriptions[event]
|
||
return removed
|
||
|
||
def _ensure_event_worker_locked(self) -> None:
|
||
worker = self._event_worker
|
||
if worker is not None and worker.is_alive():
|
||
return
|
||
dispatch_queue = self._event_queue
|
||
worker = threading.Thread(
|
||
target=self._event_worker_loop,
|
||
args=(dispatch_queue,),
|
||
name="hermes-plugin-events",
|
||
daemon=True,
|
||
)
|
||
self._event_worker = worker
|
||
worker.start()
|
||
|
||
def _event_worker_loop(self, dispatch_queue: queue.Queue[Any]) -> None:
|
||
while True:
|
||
item = dispatch_queue.get()
|
||
try:
|
||
if item is _EVENT_WORKER_STOP:
|
||
return
|
||
self._deliver_event(item)
|
||
finally:
|
||
if item is not _EVENT_WORKER_STOP:
|
||
self._mark_event_done(item.generation)
|
||
dispatch_queue.task_done()
|
||
|
||
def _mark_event_done(self, generation: int) -> None:
|
||
with self._event_idle:
|
||
pending = self._event_pending_by_generation.get(generation, 0)
|
||
if pending > 0:
|
||
self._event_pending_by_generation[generation] = pending - 1
|
||
self._event_idle.notify_all()
|
||
|
||
def _deliver_event(self, item: _QueuedPluginEvent) -> None:
|
||
"""Deliver one queued event on the host-owned worker thread."""
|
||
with self._event_lock:
|
||
if item.generation != self._event_generation:
|
||
return
|
||
previous_depth = getattr(self._emit_depth, "value", 0)
|
||
self._emit_depth.value = item.depth
|
||
try:
|
||
for subscription in item.subscriptions:
|
||
with self._event_lock:
|
||
if item.generation != self._event_generation:
|
||
break
|
||
# Owner unload may have removed this entry after the event was queued.
|
||
if not any(
|
||
current is subscription
|
||
for current in self._subscriptions.get(item.event, [])
|
||
):
|
||
continue
|
||
callback = subscription.callback
|
||
try:
|
||
# Fresh deep copy per subscriber: no callback can mutate what the next sees.
|
||
owned_payload = copy.deepcopy(item.payload)
|
||
result = callback(**owned_payload)
|
||
resolve_plugin_command_result(result)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"Event '%s' subscriber %s raised: %s",
|
||
item.event,
|
||
getattr(callback, "__name__", repr(callback)),
|
||
exc,
|
||
)
|
||
finally:
|
||
self._emit_depth.value = previous_depth
|
||
|
||
def _wait_for_event_dispatch(self, timeout: float = 2.0) -> bool:
|
||
"""Wait for the current event generation to become idle (test helper)."""
|
||
with self._event_idle:
|
||
generation = self._event_generation
|
||
return self._event_idle.wait_for(
|
||
lambda: self._event_pending_by_generation.get(generation, 0) == 0,
|
||
timeout=timeout,
|
||
)
|
||
|
||
def _dispatch_event(self, event: str, payload: Dict[str, Any]) -> int:
|
||
"""Queue *event* without blocking; return the subscriber count scheduled. Pending work is
|
||
bounded per generation so a blocking subscriber costs one worker and later emits drop."""
|
||
depth = getattr(self._emit_depth, "value", 0)
|
||
if depth >= _EVENT_EMIT_DEPTH_CAP:
|
||
logger.warning(
|
||
"Event bus recursion cap (%d) exceeded while dispatching '%s' "
|
||
"— dropping this emit to prevent an infinite loop",
|
||
_EVENT_EMIT_DEPTH_CAP, event,
|
||
)
|
||
return 0
|
||
|
||
with self._event_lock:
|
||
subscriptions = tuple(self._subscriptions.get(event, []))
|
||
if not subscriptions:
|
||
return 0
|
||
generation = self._event_generation
|
||
pending = self._event_pending_by_generation.get(generation, 0)
|
||
if pending >= _EVENT_PENDING_CAP:
|
||
logger.warning(
|
||
"Event bus pending budget (%d) exhausted while dispatching "
|
||
"'%s' — dropping this emit",
|
||
_EVENT_PENDING_CAP,
|
||
event,
|
||
)
|
||
return 0
|
||
item = _QueuedPluginEvent(
|
||
event=event,
|
||
payload=dict(payload),
|
||
subscriptions=subscriptions,
|
||
depth=depth + 1,
|
||
generation=generation,
|
||
)
|
||
try:
|
||
self._event_queue.put_nowait(item)
|
||
except queue.Full:
|
||
logger.warning(
|
||
"Event bus pending budget (%d) exhausted while dispatching "
|
||
"'%s' — dropping this emit",
|
||
_EVENT_PENDING_CAP,
|
||
event,
|
||
)
|
||
return 0
|
||
self._event_pending_by_generation[generation] = pending + 1
|
||
self._ensure_event_worker_locked()
|
||
return len(subscriptions)
|
||
|
||
def has_hook(self, hook_name: str) -> bool:
|
||
"""Return True when at least one callback is registered for a hook."""
|
||
return bool(self._hooks.get(hook_name))
|
||
|
||
def iter_hook_callbacks(self, hook_name: str) -> tuple[Callable, ...]:
|
||
"""Return a stable snapshot of callbacks registered for a hook."""
|
||
return tuple(self._hooks.get(hook_name, ()))
|
||
|
||
def render_system_prompt_sections(
|
||
self, session_info: Mapping[str, Any]
|
||
) -> List[RenderedPluginSystemPromptSection]:
|
||
"""Render all registered sections deterministically and fail open."""
|
||
frozen_info = types.MappingProxyType(dict(session_info))
|
||
rendered: List[RenderedPluginSystemPromptSection] = []
|
||
total_chars = len(PLUGIN_SECTIONS_START) + len(PLUGIN_SECTIONS_END) + 2
|
||
for section_id in sorted(self._system_prompt_sections):
|
||
section = self._system_prompt_sections[section_id]
|
||
if len(rendered) >= MAX_SYSTEM_PROMPT_SECTIONS:
|
||
logger.warning(
|
||
"Plugin system prompt section %s exceeded the section-count "
|
||
"budget (%d) and was skipped",
|
||
section.id,
|
||
MAX_SYSTEM_PROMPT_SECTIONS,
|
||
)
|
||
continue
|
||
text = self._render_prompt_section_text(section, frozen_info)
|
||
if text is None:
|
||
continue
|
||
rendered_chars = len(format_system_prompt_section(section.id, text))
|
||
if rendered:
|
||
rendered_chars += 2 # canonical ``\n\n`` separator
|
||
if total_chars + rendered_chars > MAX_SYSTEM_PROMPT_SECTIONS_TOTAL_CHARS:
|
||
logger.warning(
|
||
"Plugin system prompt section %s (%s) exceeded the aggregate "
|
||
"session budget (%d chars) and was skipped",
|
||
section.id,
|
||
section.plugin,
|
||
MAX_SYSTEM_PROMPT_SECTIONS_TOTAL_CHARS,
|
||
)
|
||
continue
|
||
rendered.append(
|
||
RenderedPluginSystemPromptSection(
|
||
id=section.id,
|
||
content=text,
|
||
position=section.position,
|
||
plugin=section.plugin,
|
||
)
|
||
)
|
||
total_chars += rendered_chars
|
||
logger.info(
|
||
"Session plugin prompt section: id=%s plugin=%s position=%s chars=%d",
|
||
section.id,
|
||
section.plugin,
|
||
section.position,
|
||
len(text),
|
||
)
|
||
return rendered
|
||
|
||
@staticmethod
|
||
def _render_prompt_section_text(
|
||
section: PluginSystemPromptSection, frozen_info: Mapping[str, Any]
|
||
) -> Optional[str]:
|
||
"""Evaluate one section; return its stripped text or None (with a warning) when skipped."""
|
||
def _skip(detail: str, *args: Any) -> None:
|
||
logger.warning(
|
||
"Plugin system prompt section %s (%s) " + detail,
|
||
section.id, section.plugin, *args,
|
||
)
|
||
|
||
try:
|
||
value = section.content(frozen_info) if callable(section.content) else section.content
|
||
except Exception as exc:
|
||
_skip("raised and was skipped: %s", exc)
|
||
return None
|
||
if not isinstance(value, str):
|
||
_skip("returned %s, not str; skipped", type(value).__name__)
|
||
return None
|
||
text = value.strip()
|
||
if not text:
|
||
return None
|
||
if PLUGIN_SECTIONS_START in text or PLUGIN_SECTIONS_END in text:
|
||
_skip("contained a reserved persistence marker and was skipped")
|
||
return None
|
||
if len(text) > section.max_chars:
|
||
_skip("exceeded max_chars (%d > %d) and was skipped", len(text), section.max_chars)
|
||
return None
|
||
return text
|
||
|
||
def has_middleware(self, kind: str) -> bool:
|
||
"""Return True when at least one callback is registered for middleware."""
|
||
return bool(self._middleware.get(kind))
|
||
|
||
def invoke_middleware(self, kind: str, **kwargs: Any) -> List[Any]:
|
||
"""Call middleware callbacks for *kind* (each isolated); return non-``None`` results."""
|
||
callbacks = self._middleware.get(kind, [])
|
||
results: List[Any] = []
|
||
for cb in callbacks:
|
||
try:
|
||
ret = cb(**kwargs)
|
||
if ret is not None:
|
||
results.append(ret)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"Middleware '%s' callback %s raised: %s",
|
||
kind,
|
||
getattr(cb, "__name__", repr(cb)),
|
||
exc,
|
||
)
|
||
return results
|
||
|
||
# -- adapter accessors / introspection -----------------------------------------
|
||
|
||
def get_slack_action_handlers(self) -> List[tuple]:
|
||
"""``(action_id, callback, plugin_name)`` tuples for the Slack adapter to wire at connect."""
|
||
return list(self._slack_action_handlers)
|
||
|
||
def get_platform_handler_factories(self, platform: str) -> List[tuple]:
|
||
"""``(factory, plugin_name)`` tuples for one platform; adapters call ``factory(native,
|
||
adapter)`` at connect (see :meth:`PluginContext.register_platform_handler`)."""
|
||
key = (platform or "").strip().lower()
|
||
return list(self._platform_handler_factories.get(key, []))
|
||
|
||
def list_plugins(self) -> List[Dict[str, Any]]:
|
||
"""Return a list of info dicts for all discovered plugins."""
|
||
result: List[Dict[str, Any]] = []
|
||
for key, loaded in sorted(self._plugins.items()):
|
||
result.append(
|
||
{
|
||
"name": loaded.manifest.name,
|
||
"key": loaded.manifest.key or loaded.manifest.name,
|
||
"kind": loaded.manifest.kind,
|
||
"version": loaded.manifest.version,
|
||
"description": loaded.manifest.description,
|
||
"source": loaded.manifest.source,
|
||
"enabled": loaded.enabled,
|
||
"tools": len(loaded.tools_registered),
|
||
"hooks": len(loaded.hooks_registered),
|
||
"middleware": len(loaded.middleware_registered),
|
||
"commands": len(loaded.commands_registered),
|
||
"error": loaded.error,
|
||
}
|
||
)
|
||
return result
|
||
|
||
def find_plugin_skill(self, qualified_name: str) -> Optional[Path]:
|
||
"""Return the ``Path`` to a plugin skill's SKILL.md, or ``None``."""
|
||
entry = self._plugin_skills.get(qualified_name)
|
||
return entry["path"] if entry else None
|
||
|
||
def list_plugin_skills(self, plugin_name: str) -> List[str]:
|
||
"""Return sorted bare names of all skills registered by *plugin_name*."""
|
||
prefix = f"{plugin_name}:"
|
||
return sorted(
|
||
e["bare_name"]
|
||
for qn, e in self._plugin_skills.items()
|
||
if qn.startswith(prefix)
|
||
)
|
||
|
||
def list_plugin_skill_metadata(self) -> List[Dict[str, Any]]:
|
||
"""Return progressive-disclosure metadata for registered plugin skills."""
|
||
return [
|
||
{
|
||
"name": qualified,
|
||
"description": str(entry.get("description", "")),
|
||
"category": "plugin",
|
||
"frontmatter": dict(entry.get("frontmatter", {})),
|
||
}
|
||
for qualified, entry in sorted(self._plugin_skills.items())
|
||
]
|
||
|
||
def get_portable_mcp_servers(self) -> Dict[str, Dict[str, Any]]:
|
||
"""Return a defensive copy of enabled portable MCP server configs."""
|
||
return {name: dict(config) for name, config in self._portable_mcp_servers.items()}
|
||
|
||
def remove_plugin_skill(self, qualified_name: str) -> None:
|
||
"""Remove a stale registry entry (silently ignores missing keys)."""
|
||
self._plugin_skills.pop(qualified_name, None)
|
||
|
||
|
||
# Module-level singleton & convenience functions.
|
||
|
||
# Legacy single-slot "current" manager, kept so tests that monkeypatch ``_plugin_manager`` keep
|
||
# working — ``get_plugin_manager()`` still reads/writes this name.
|
||
_plugin_manager: Optional[PluginManager] = None
|
||
|
||
# Resolved Hermes home -> PluginManager. A process can switch profiles via
|
||
# ``set_hermes_home_override()``; a single slot would leak one profile's plugin/context-engine state
|
||
# into another, and keying by resolved home lets a re-entered profile reuse its imported modules.
|
||
_plugin_managers_by_home: Dict[Path, PluginManager] = {}
|
||
_plugin_managers_lock = threading.RLock()
|
||
|
||
|
||
def _plugin_home_key() -> Path:
|
||
"""Resolved active Hermes home — the key for per-profile plugin managers (plugins capture the
|
||
home at registration, so a process serving several profiles cannot share one manager)."""
|
||
try:
|
||
return get_hermes_home().expanduser().resolve()
|
||
except Exception:
|
||
return get_hermes_home().expanduser()
|
||
|
||
|
||
def _clear_plugin_submodules(manager: Optional[PluginManager]) -> None:
|
||
"""Purge ``sys.modules`` entries for this manager's directory plugins (package AND submodules —
|
||
otherwise a same-slug plugin in another profile reuses the previous profile's submodule state).
|
||
"""
|
||
if manager is None:
|
||
return
|
||
for loaded in getattr(manager, "_plugins", {}).values():
|
||
module = getattr(loaded, "module", None)
|
||
module_name = getattr(module, "__name__", None)
|
||
if not module_name or not module_name.startswith(f"{_NS_PARENT}."):
|
||
continue
|
||
_evict_modules(module_name)
|
||
with _MODULE_NAMESPACE_LOCK:
|
||
if _BARE_MODULE_SCOPE.get(module_name) == manager.scope_key:
|
||
_BARE_MODULE_SCOPE.pop(module_name, None)
|
||
|
||
|
||
def get_plugin_manager() -> PluginManager:
|
||
"""Return the plugin manager for the active Hermes profile/home (cached per resolved home; a
|
||
profile switch gets its own manager and plugin submodules)."""
|
||
global _plugin_manager
|
||
current_home = _plugin_home_key()
|
||
|
||
with _plugin_managers_lock:
|
||
# Tests/embedders monkeypatch ``_plugin_manager`` directly: adopt a single-slot manager the
|
||
# keyed cache doesn't know about at all.
|
||
if (
|
||
_plugin_manager is not None
|
||
and _plugin_manager not in _plugin_managers_by_home.values()
|
||
):
|
||
_plugin_managers_by_home[current_home] = _plugin_manager
|
||
return _plugin_manager
|
||
|
||
manager = _plugin_managers_by_home.get(current_home)
|
||
if manager is None:
|
||
manager = PluginManager(scope_key=hermes_home_key(current_home))
|
||
_plugin_managers_by_home[current_home] = manager
|
||
|
||
_plugin_manager = manager
|
||
return manager
|
||
|
||
|
||
def _reset_plugin_managers_for_tests() -> None:
|
||
"""Test-only: drop every cached manager and its submodules for a fully clean slate."""
|
||
global _plugin_manager
|
||
with _plugin_managers_lock:
|
||
managers = list(dict.fromkeys(_plugin_managers_by_home.values()))
|
||
if _plugin_manager is not None and _plugin_manager not in managers:
|
||
managers.append(_plugin_manager)
|
||
for manager in managers:
|
||
_clear_plugin_submodules(manager)
|
||
try:
|
||
manager.unload()
|
||
except Exception:
|
||
logger.debug("test plugin-manager unload failed", exc_info=True)
|
||
_plugin_managers_by_home.clear()
|
||
_plugin_manager = None
|
||
# Dashboard-auth providers are persistent and survive a routine unload, so the clean-slate
|
||
# reset must clear that process-global registry explicitly or a test's provider leaks.
|
||
try:
|
||
from hermes_cli.dashboard_auth.registry import (
|
||
clear_providers as _clear_dashboard_auth_providers,
|
||
)
|
||
|
||
_clear_dashboard_auth_providers()
|
||
except Exception:
|
||
logger.debug("dashboard-auth registry clear failed", exc_info=True)
|
||
|
||
|
||
def has_enabled_agent_plugin_mcp(raw_config: Mapping[str, Any]) -> bool:
|
||
"""Whether config enables a portable package with MCP servers (manifest-only scan on a fresh
|
||
manager; imports nothing, mutates no registry)."""
|
||
return PluginManager().has_enabled_portable_mcp(raw_config)
|
||
|
||
|
||
def discover_plugins(force: bool = False) -> None:
|
||
"""Discover and load all plugins (idempotent; ``force=True`` rescans). Joins an in-flight
|
||
background discovery instead of racing a second scan."""
|
||
_join_background_discovery()
|
||
get_plugin_manager().discover_and_load(force=force)
|
||
|
||
|
||
_background_discovery_thread: Optional[threading.Thread] = None
|
||
_background_discovery_lock = threading.Lock()
|
||
|
||
|
||
def start_background_plugin_discovery() -> None:
|
||
"""Run discovery in a daemon thread to overlap the rest of CLI startup (~150ms). Every
|
||
synchronous consumer joins it via :func:`discover_plugins`, so no one sees a half-loaded
|
||
registry. No-op when already done or in flight."""
|
||
global _background_discovery_thread
|
||
manager = get_plugin_manager()
|
||
if manager._discovered:
|
||
return
|
||
with _background_discovery_lock:
|
||
if _background_discovery_thread is not None and _background_discovery_thread.is_alive():
|
||
return
|
||
|
||
def _run() -> None:
|
||
try:
|
||
manager.discover_and_load()
|
||
_persist_plugin_toolset_keys()
|
||
except Exception:
|
||
logger.warning("background plugin discovery failed", exc_info=True)
|
||
|
||
_background_discovery_thread = threading.Thread(
|
||
target=_run, name="plugin-discovery", daemon=True
|
||
)
|
||
_background_discovery_thread.start()
|
||
|
||
|
||
def _join_background_discovery(timeout: float = 30.0) -> None:
|
||
"""Wait for an in-flight background discovery (no-op from its own thread)."""
|
||
t = _background_discovery_thread
|
||
if t is None or not t.is_alive() or t is threading.current_thread():
|
||
return
|
||
t.join(timeout=timeout)
|
||
|
||
|
||
def _plugin_toolset_keys_cache_path():
|
||
from hermes_constants import get_hermes_home
|
||
return get_hermes_home() / "cache" / "plugin_toolset_keys.json"
|
||
|
||
|
||
def _persist_plugin_toolset_keys() -> None:
|
||
"""Persist discovered plugin toolset keys + portable MCP names (best-effort)."""
|
||
try:
|
||
import tempfile
|
||
keys = sorted({ts_key for ts_key, _, _ in get_plugin_toolsets()})
|
||
try:
|
||
portable = sorted(get_plugin_manager().get_portable_mcp_servers())
|
||
except Exception:
|
||
portable = []
|
||
path = _plugin_toolset_keys_cache_path()
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".pt_keys.")
|
||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||
json.dump({"toolset_keys": keys, "portable_mcp": portable}, fh)
|
||
os.replace(tmp, path)
|
||
except Exception:
|
||
logger.debug("plugin toolset key persist failed", exc_info=True)
|
||
|
||
|
||
def _read_plugin_keys_cache() -> Optional[dict]:
|
||
try:
|
||
blob = json.loads(_plugin_toolset_keys_cache_path().read_text(encoding="utf-8"))
|
||
if isinstance(blob, dict):
|
||
return blob
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def _nowait_plugin_set(cache_field: str, live: Callable[[PluginManager], "set[str]"]) -> "set[str]":
|
||
"""Shared body of the ``*_nowait`` probes: live registry, else last launch's cache, else block."""
|
||
manager = get_plugin_manager()
|
||
t = _background_discovery_thread
|
||
if manager._discovered and (t is None or not t.is_alive()):
|
||
return live(manager)
|
||
if t is not None and t.is_alive():
|
||
blob = _read_plugin_keys_cache()
|
||
if blob is not None:
|
||
values = blob.get(cache_field)
|
||
if isinstance(values, list) and all(isinstance(v, str) for v in values):
|
||
return set(values)
|
||
discover_plugins()
|
||
return live(manager)
|
||
|
||
|
||
def get_plugin_toolset_keys_nowait() -> "set[str]":
|
||
"""Plugin toolset keys without blocking on in-flight discovery: live registry when done, last
|
||
launch's persisted set while a background scan runs (callers only EXCLUDE these keys, so a stale
|
||
set is harmless and self-heals), else block via discover_plugins()."""
|
||
return _nowait_plugin_set(
|
||
"toolset_keys", lambda _m: {ts_key for ts_key, _, _ in get_plugin_toolsets()}
|
||
)
|
||
|
||
|
||
def get_portable_mcp_server_names_nowait() -> "set[str]":
|
||
"""Portable MCP server names; same contract as :func:`get_plugin_toolset_keys_nowait`."""
|
||
return _nowait_plugin_set("portable_mcp", lambda m: set(m.get_portable_mcp_servers()))
|
||
|
||
|
||
def _delivery_manager() -> PluginManager:
|
||
"""Active manager, lazily discovering if it never ran — delivery must not depend on WHICH
|
||
surface imported us (dashboards/TUI/cron never import model_tools). ``getattr`` default
|
||
``True`` leaves test doubles untouched."""
|
||
manager = get_plugin_manager()
|
||
if not getattr(manager, "_discovered", True):
|
||
_join_background_discovery()
|
||
manager.discover_and_load()
|
||
return manager
|
||
|
||
|
||
def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
|
||
"""Invoke a lifecycle hook (lazy-discovers first); return non-``None`` callback results."""
|
||
return _delivery_manager().invoke_hook(hook_name, **kwargs)
|
||
|
||
|
||
def render_system_prompt_sections(
|
||
session_info: Mapping[str, Any],
|
||
) -> List[RenderedPluginSystemPromptSection]:
|
||
"""Render plugin prompt sections after idempotent plugin discovery."""
|
||
return _ensure_plugins_discovered().render_system_prompt_sections(session_info)
|
||
|
||
|
||
def invoke_middleware(kind: str, **kwargs: Any) -> List[Any]:
|
||
"""Invoke registered middleware callbacks (lazy-discovers like :func:`invoke_hook`)."""
|
||
return _delivery_manager().invoke_middleware(kind, **kwargs)
|
||
|
||
|
||
def has_middleware(kind: str) -> bool:
|
||
"""True when middleware is registered for ``kind``; lazy-discovers first since callers gate
|
||
:func:`invoke_middleware` on it."""
|
||
manager = get_plugin_manager()
|
||
if not getattr(manager, "_discovered", True):
|
||
manager = _delivery_manager()
|
||
method = getattr(manager, "has_middleware", None)
|
||
if callable(method):
|
||
return bool(method(kind))
|
||
return bool(getattr(manager, "_middleware", {}).get(kind))
|
||
|
||
|
||
def has_hook(hook_name: str) -> bool:
|
||
"""True when a loaded plugin handles a hook (lazy-discovers first, like :func:`has_middleware`)."""
|
||
return _delivery_manager().has_hook(hook_name)
|
||
|
||
|
||
def iter_hook_callbacks(hook_name: str) -> tuple[Callable, ...]:
|
||
"""Return a stable snapshot of callbacks registered for a hook."""
|
||
return get_plugin_manager().iter_hook_callbacks(hook_name)
|
||
|
||
|
||
def fire_pre_command_hook(
|
||
*,
|
||
surface: str,
|
||
command: str,
|
||
alias_used: str,
|
||
args_raw: str,
|
||
session_key: Optional[str] = None,
|
||
platform: Optional[str] = None,
|
||
) -> None:
|
||
"""Fire the observer-only ``pre_command`` hook; never raises. Directive-shaped returns are
|
||
logged at debug so future block/rewrite adopters are discoverable."""
|
||
try:
|
||
manager = get_plugin_manager()
|
||
if not manager.has_hook("pre_command"):
|
||
return
|
||
results = manager.invoke_hook(
|
||
"pre_command",
|
||
surface=surface,
|
||
command=command,
|
||
alias_used=alias_used,
|
||
args_raw=args_raw,
|
||
session_key=session_key,
|
||
platform=platform,
|
||
)
|
||
for result in results:
|
||
if isinstance(result, dict) and (
|
||
"action" in result or "decision" in result
|
||
):
|
||
logger.debug(
|
||
"pre_command is observer-only in v1: ignoring directive "
|
||
"%r for /%s (surface=%s). Block/rewrite will arrive with "
|
||
"the command middleware variant (#64204/#64231).",
|
||
result, command, surface,
|
||
)
|
||
except Exception as exc: # pragma: no cover - defensive
|
||
logger.debug("pre_command hook dispatch failed (non-fatal): %s", exc)
|
||
|
||
|
||
_thread_tool_whitelist = threading.local()
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _PreToolCallDirective:
|
||
action: Optional[str] = None
|
||
message: Optional[str] = None
|
||
rule_key: Optional[str] = None
|
||
modified_args: Optional[Dict[str, Any]] = None
|
||
|
||
|
||
def set_thread_tool_whitelist(
|
||
allowed: Optional[Set[str]],
|
||
deny_msg_fmt: str = "Tool '{tool_name}' denied: not in this thread's tool whitelist",
|
||
) -> None:
|
||
_thread_tool_whitelist.allowed = allowed
|
||
_thread_tool_whitelist.fmt = deny_msg_fmt
|
||
|
||
|
||
def clear_thread_tool_whitelist() -> None:
|
||
_thread_tool_whitelist.allowed = None
|
||
|
||
|
||
def _get_pre_tool_call_directive_details(
|
||
tool_name: str, args: Optional[Dict[str, Any]], task_id: str = "", session_id: str = "",
|
||
tool_call_id: str = "", turn_id: str = "", api_request_id: str = "",
|
||
middleware_trace: Optional[List[Dict[str, Any]]] = None,
|
||
) -> _PreToolCallDirective:
|
||
"""Check ``pre_tool_call`` hooks for ``{"action": "block", "message"}`` (veto; message becomes
|
||
the tool result) or ``{"action": "approve", "message", "rule_key"?}`` (escalate ANY tool to the
|
||
human-approval gate; ``rule_key`` picks the ``[a]lways`` allowlist grain). First valid directive
|
||
wins; irrelevant returns are ignored."""
|
||
allowed = getattr(_thread_tool_whitelist, "allowed", None)
|
||
if allowed is not None and tool_name not in allowed:
|
||
fmt = getattr(_thread_tool_whitelist, "fmt", "Tool '{tool_name}' denied")
|
||
return _PreToolCallDirective(action="block", message=fmt.format(tool_name=tool_name))
|
||
|
||
from hermes_cli.lifecycle import invoke_hook as invoke_lifecycle_hook
|
||
|
||
hook_results = invoke_lifecycle_hook(
|
||
"pre_tool_call",
|
||
tool_name=tool_name,
|
||
args=args if isinstance(args, dict) else {},
|
||
task_id=task_id,
|
||
session_id=session_id,
|
||
tool_call_id=tool_call_id,
|
||
turn_id=turn_id,
|
||
api_request_id=api_request_id,
|
||
middleware_trace=list(middleware_trace or []),
|
||
)
|
||
|
||
modified_args: Optional[Dict[str, Any]] = None
|
||
|
||
for result in hook_results:
|
||
if not isinstance(result, dict):
|
||
continue
|
||
action = result.get("action")
|
||
# "modify" — transform tool_input before dispatch. Processed before the block/approve gate
|
||
# so modify directives are visible even when a later hook blocks. Each modify directive
|
||
# shallow-merges its keys into one accumulated dict built from the original args.
|
||
if action == "modify":
|
||
partial = result.get("args")
|
||
if isinstance(partial, dict) and partial:
|
||
if modified_args is None:
|
||
modified_args = dict(args) if isinstance(args, dict) else {}
|
||
modified_args.update(partial)
|
||
continue
|
||
if action not in ("block", "approve"):
|
||
continue
|
||
message = result.get("message")
|
||
message = message if isinstance(message, str) and message else None
|
||
# A block directive requires a message (it becomes the tool result); approve's is optional.
|
||
if action == "block" and not message:
|
||
continue
|
||
rule_key = result.get("rule_key") if action == "approve" else None
|
||
rule_key = (rule_key.strip() or None) if isinstance(rule_key, str) else None
|
||
return _PreToolCallDirective(
|
||
action=action, message=message, rule_key=rule_key,
|
||
modified_args=modified_args,
|
||
)
|
||
|
||
return _PreToolCallDirective(modified_args=modified_args)
|
||
|
||
|
||
def get_pre_tool_call_directive(
|
||
tool_name: str, args: Optional[Dict[str, Any]], task_id: str = "", session_id: str = "",
|
||
tool_call_id: str = "", turn_id: str = "", api_request_id: str = "",
|
||
middleware_trace: Optional[List[Dict[str, Any]]] = None,
|
||
) -> tuple[Optional[str], Optional[str]]:
|
||
"""Back-compat: ``(directive, message)`` with directive ``"block"`` / ``"approve"`` / ``None``."""
|
||
details = _get_pre_tool_call_directive_details(
|
||
tool_name, args, task_id=task_id, session_id=session_id, tool_call_id=tool_call_id,
|
||
turn_id=turn_id, api_request_id=api_request_id, middleware_trace=middleware_trace,
|
||
)
|
||
return (details.action, details.message)
|
||
|
||
|
||
def get_pre_tool_call_block_message(
|
||
tool_name: str, args: Optional[Dict[str, Any]], task_id: str = "", session_id: str = "",
|
||
tool_call_id: str = "", turn_id: str = "", api_request_id: str = "",
|
||
middleware_trace: Optional[List[Dict[str, Any]]] = None,
|
||
) -> Optional[str]:
|
||
"""Deprecated shim: only the ``block`` message (or ``None``); ``approve`` is invisible here."""
|
||
directive, message = get_pre_tool_call_directive(
|
||
tool_name, args, task_id=task_id, session_id=session_id, tool_call_id=tool_call_id,
|
||
turn_id=turn_id, api_request_id=api_request_id, middleware_trace=middleware_trace,
|
||
)
|
||
return message if directive == "block" else None
|
||
|
||
|
||
def resolve_pre_tool_block(
|
||
tool_name: str, args: Optional[Dict[str, Any]], task_id: str = "", session_id: str = "",
|
||
tool_call_id: str = "", turn_id: str = "", api_request_id: str = "",
|
||
middleware_trace: Optional[List[Dict[str, Any]]] = None,
|
||
) -> Optional[str]:
|
||
"""Resolve the pre_tool_call directive to a final block message (or ``None`` to proceed),
|
||
running the human-approval gate for ``approve``. See :func:`_resolve_block_from_details`."""
|
||
return _dispatch_pre_tool_call_hooks(
|
||
tool_name, args, task_id=task_id, session_id=session_id, tool_call_id=tool_call_id,
|
||
turn_id=turn_id, api_request_id=api_request_id, middleware_trace=middleware_trace,
|
||
)[0]
|
||
|
||
|
||
def _resolve_block_from_details(
|
||
details: "_PreToolCallDirective",
|
||
tool_name: str,
|
||
*,
|
||
turn_id: str = "",
|
||
tool_call_id: str = "",
|
||
session_id: str = "",
|
||
) -> Optional[str]:
|
||
"""The ONE place for the fail-closed approval logic: ``block`` blocks with its message; an
|
||
``approve`` whose gate errors, denies, or times out is blocked; anything else proceeds."""
|
||
if details.action == "block":
|
||
return details.message
|
||
if details.action != "approve":
|
||
return None
|
||
try:
|
||
from tools.approval import (
|
||
request_tool_approval,
|
||
reset_current_observability_context,
|
||
set_current_observability_context,
|
||
)
|
||
|
||
approval_tokens = None
|
||
with suppress(Exception):
|
||
approval_tokens = set_current_observability_context(
|
||
turn_id=turn_id, tool_call_id=tool_call_id, session_id=session_id,
|
||
)
|
||
try:
|
||
result = request_tool_approval(
|
||
tool_name, details.message or "", rule_key=details.rule_key or tool_name,
|
||
)
|
||
finally:
|
||
if approval_tokens is not None:
|
||
with suppress(Exception):
|
||
reset_current_observability_context(approval_tokens)
|
||
except Exception:
|
||
# Fail-closed: if the gate itself errors, block rather than silently execute an action a
|
||
# plugin flagged for approval.
|
||
return f"BLOCKED: plugin approval gate failed for {tool_name}"
|
||
if not result.get("approved"):
|
||
return str(result.get("message") or f"BLOCKED: plugin approval required for {tool_name}")
|
||
return None
|
||
|
||
|
||
def _dispatch_pre_tool_call_hooks(
|
||
tool_name: str, args: Optional[Dict[str, Any]], task_id: str = "", session_id: str = "",
|
||
tool_call_id: str = "", turn_id: str = "", api_request_id: str = "",
|
||
middleware_trace: Optional[List[Dict[str, Any]]] = None,
|
||
) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
|
||
"""Invoke ``pre_tool_call`` hooks once; return ``(block_message, modified_args)`` — the resolved
|
||
block/approve message (``None`` to proceed) and merged ``modify`` args (``None`` if none)."""
|
||
details = _get_pre_tool_call_directive_details(
|
||
tool_name, args, task_id=task_id, session_id=session_id, tool_call_id=tool_call_id,
|
||
turn_id=turn_id, api_request_id=api_request_id, middleware_trace=middleware_trace,
|
||
)
|
||
block_msg = _resolve_block_from_details(
|
||
details, tool_name,
|
||
turn_id=turn_id, tool_call_id=tool_call_id, session_id=session_id,
|
||
)
|
||
return (block_msg, details.modified_args)
|
||
|
||
|
||
def get_pre_verify_continue_message(
|
||
*,
|
||
session_id: str = "",
|
||
platform: str = "",
|
||
model: str = "",
|
||
coding: bool = False,
|
||
attempt: int = 0,
|
||
final_response: str = "",
|
||
changed_paths: Optional[List[str]] = None,
|
||
) -> Optional[str]:
|
||
"""Check ``pre_verify`` hooks for ``{"action": "continue", "message"}`` (or Claude-Code Stop
|
||
``{"decision": "block", "reason"}``) to keep the turn going; first non-empty message wins, any
|
||
other return lets the turn finish. ``coding``/``attempt`` let hooks scope and self-throttle."""
|
||
hook_results = invoke_hook(
|
||
"pre_verify",
|
||
session_id=session_id,
|
||
platform=platform,
|
||
model=model,
|
||
coding=coding,
|
||
attempt=attempt,
|
||
final_response=final_response,
|
||
changed_paths=list(changed_paths or []),
|
||
)
|
||
|
||
for result in hook_results:
|
||
if not isinstance(result, dict):
|
||
continue
|
||
action = str(result.get("action") or result.get("decision") or "").strip().lower()
|
||
if action not in ("continue", "block"):
|
||
continue
|
||
message = result.get("message") or result.get("reason")
|
||
if isinstance(message, str) and message.strip():
|
||
return message.strip()
|
||
|
||
return None
|
||
|
||
|
||
def get_plugin_error_classification(
|
||
*,
|
||
provider: str = "",
|
||
model: str = "",
|
||
status_code: Optional[int] = None,
|
||
error_type: str = "",
|
||
error_code: str = "",
|
||
error_message: str = "",
|
||
error_body: Optional[Dict[str, Any]] = None,
|
||
error: Optional[BaseException] = None,
|
||
approx_tokens: int = 0,
|
||
context_length: int = 0,
|
||
num_messages: int = 0,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""Consult ``transform_api_error_classification`` hooks BEFORE the built-in classifier.
|
||
|
||
Run-all-then-pick-first: every callback runs isolated, the first valid result in registration
|
||
order wins, losing valid results warn (conflicts visible, not shadowed). Returns a sanitized
|
||
dict (``reason`` -> ``FailoverReason``, hint flags -> bool, ``message`` capped at 500) or
|
||
``None``. Privacy: ``error_message``/``error_body`` may be unredacted.
|
||
"""
|
||
from agent.error_classifier import FailoverReason
|
||
|
||
hook_results = invoke_hook(
|
||
"transform_api_error_classification",
|
||
provider=provider,
|
||
model=model,
|
||
status_code=status_code,
|
||
error_type=error_type,
|
||
error_code=error_code,
|
||
error_message=error_message,
|
||
error_body=error_body if isinstance(error_body, dict) else {},
|
||
error=error,
|
||
approx_tokens=approx_tokens,
|
||
context_length=context_length,
|
||
num_messages=num_messages,
|
||
)
|
||
|
||
winner: Optional[Dict[str, Any]] = None
|
||
skipped_valid = 0
|
||
for result in hook_results:
|
||
if not isinstance(result, dict):
|
||
continue
|
||
reason = result.get("reason")
|
||
if isinstance(reason, str):
|
||
try:
|
||
reason = FailoverReason(reason.strip().lower())
|
||
except ValueError:
|
||
continue
|
||
if not isinstance(reason, FailoverReason):
|
||
continue
|
||
|
||
if winner is not None:
|
||
skipped_valid += 1
|
||
continue
|
||
|
||
out: Dict[str, Any] = {"reason": reason}
|
||
for key in (
|
||
"retryable",
|
||
"should_compress",
|
||
"should_rotate_credential",
|
||
"should_fallback",
|
||
):
|
||
if key in result:
|
||
out[key] = bool(result[key])
|
||
message = result.get("message")
|
||
if isinstance(message, str) and message.strip():
|
||
out["message"] = message.strip()[:500]
|
||
error_context = result.get("error_context")
|
||
if isinstance(error_context, dict):
|
||
out["error_context"] = error_context
|
||
winner = out
|
||
|
||
if winner is not None and skipped_valid:
|
||
logger.warning(
|
||
"transform_api_error_classification: skipped %d valid "
|
||
"classification(s) after the first result in registration order "
|
||
"won (run-all-then-pick-first)",
|
||
skipped_valid,
|
||
)
|
||
return winner
|
||
|
||
|
||
def _ensure_plugins_discovered(force: bool = False) -> PluginManager:
|
||
"""Return the global manager after idempotent (or ``force``d) discovery."""
|
||
manager = get_plugin_manager()
|
||
manager.discover_and_load(force=force)
|
||
return manager
|
||
|
||
|
||
def get_plugin_context_engine():
|
||
"""Return the plugin-registered context engine, or None."""
|
||
return _ensure_plugins_discovered()._context_engine
|
||
|
||
|
||
def get_plugin_command_handler(name: str) -> Optional[Callable]:
|
||
"""Return the handler for a plugin-registered slash command, or ``None``."""
|
||
entry = _ensure_plugins_discovered()._plugin_commands.get(name)
|
||
return entry["handler"] if entry else None
|
||
|
||
|
||
_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS = 30.0
|
||
|
||
|
||
def resolve_plugin_command_result(result: Any) -> Any:
|
||
"""Resolve a plugin command result, awaiting async handlers: ``asyncio.run`` when no loop is
|
||
running, else a helper thread with its own loop (30s bound so a hung handler cannot wedge the
|
||
terminal)."""
|
||
if not inspect.isawaitable(result):
|
||
return result
|
||
|
||
try:
|
||
asyncio.get_running_loop()
|
||
except RuntimeError:
|
||
return asyncio.run(result)
|
||
|
||
outcome: Dict[str, Any] = {}
|
||
failure: Dict[str, BaseException] = {}
|
||
done = threading.Event()
|
||
|
||
def _runner() -> None:
|
||
try:
|
||
outcome["value"] = asyncio.run(result)
|
||
except BaseException as exc: # pragma: no cover - re-raised below
|
||
failure["exc"] = exc
|
||
finally:
|
||
done.set()
|
||
|
||
thread = threading.Thread(target=_runner, name="hermes-plugin-command-await", daemon=True)
|
||
thread.start()
|
||
if not done.wait(timeout=_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS):
|
||
raise TimeoutError(
|
||
"Plugin command async handler did not complete within "
|
||
f"{_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS:.0f}s"
|
||
)
|
||
if "exc" in failure:
|
||
raise failure["exc"]
|
||
return outcome.get("value")
|
||
|
||
|
||
def get_plugin_commands() -> Dict[str, dict]:
|
||
"""Plugin commands dict (name -> {handler, description, plugin}) after idempotent discovery."""
|
||
return _ensure_plugins_discovered()._plugin_commands
|
||
|
||
|
||
def get_plugin_auxiliary_tasks() -> List[Dict[str, Any]]:
|
||
"""Plugin auxiliary-task registration dicts sorted by ``key`` (after idempotent discovery)."""
|
||
manager = _ensure_plugins_discovered()
|
||
return [manager._aux_tasks[k] for k in sorted(manager._aux_tasks)]
|
||
|
||
|
||
def get_plugin_toolsets() -> List[tuple]:
|
||
"""Plugin toolsets as ``(key, label, description)`` tuples for the ``hermes tools`` TUI."""
|
||
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]] = {}
|
||
for tool_name in manager._plugin_tool_names:
|
||
entry = registry.get_entry(tool_name)
|
||
if entry:
|
||
toolset_tools.setdefault(entry.toolset, []).append(entry.name)
|
||
|
||
# Map toolsets back to the plugin that registered them
|
||
toolset_plugin: Dict[str, LoadedPlugin] = {}
|
||
for loaded in manager._plugins.values():
|
||
for tool_name in loaded.tools_registered:
|
||
entry = registry.get_entry(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)
|
||
desc = (plugin.manifest.description if plugin else "") or ", ".join(
|
||
sorted(toolset_tools[ts_key])
|
||
)
|
||
result.append((ts_key, f"🔌 {ts_key.replace('_', ' ').title()}", desc))
|
||
return result
|