diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index da8d7e2c68b23..3045eab18da19 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -1,17 +1,43 @@ -"""Hermes Plugin System — discovers, loads, and manages plugins. - -Sources, later overriding earlier on key collision: bundled ``/plugins//`` (``memory/`` -and ``context_engine/`` have their own discovery), user ``~/.hermes/plugins//``, project -``./.hermes/plugins//`` (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()``. +""" +Hermes Plugin System +==================== + +Discovers, loads, and manages plugins from four sources: + +1. **Bundled plugins** – ``/plugins//`` (shipped with hermes-agent; + ``memory/`` and ``context_engine/`` subdirs are excluded — they have their + own discovery paths) +2. **User plugins** – ``~/.hermes/plugins//`` +3. **Project plugins** – ``./.hermes/plugins//`` (opt-in via + ``HERMES_ENABLE_PROJECT_PLUGINS``) +4. **Pip plugins** – packages that expose the ``hermes_agent.plugins`` + entry-point group. + +Later sources override earlier ones on name collision, so a user or project +plugin with the same name as a bundled plugin replaces it. + +Each directory plugin must contain a ``plugin.yaml`` manifest **and** an +``__init__.py`` with a ``register(ctx)`` function. + +Lifecycle hooks +--------------- +Plugins may register callbacks for any of the hooks in ``VALID_HOOKS``. +The agent core calls ``invoke_hook(name, **kwargs)`` at the appropriate +points. + +Tool registration +----------------- +``PluginContext.register_tool()`` delegates to ``tools.registry.register()`` +so plugin-defined tools appear alongside the built-in tools. """ from __future__ import annotations import asyncio +import copy +import hashlib import importlib.metadata +import importlib.util import inspect import json import logging @@ -21,75 +47,95 @@ import sys import threading import types -from contextlib import suppress +from contextlib import contextmanager from dataclasses import dataclass, field -from functools import cached_property +from functools import wraps from pathlib import Path -from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple, Union +from typing import (Any, Callable, Dict, Iterable, List, Mapping, Optional, Set, Tuple, Type, Union) -from hermes_constants import get_hermes_home, hermes_home_key -from registration_lifecycle import replacement_coordinator -from utils import env_var_enabled -from hermes_cli.config import load_config_readonly -from hermes_cli.middleware import VALID_MIDDLEWARE -from hermes_cli.plugin_capabilities import plugin_capability_granted -from hermes_cli.relay_plugin_cutover import RELAY_PLUGINS_CONFIG_ENV, legacy_relay_plugin_keys -# Sibling modules' names are re-exported here (origin) so plugins and tests keep one import path. -from hermes_cli.plugins_manifest import ( # noqa: F401 — re-exported - _CONFIG_SCHEMA_TYPES, SUPPORTED_MANIFEST_VERSION, PluginManifest, _portable_skill_namespace, - manifest_key, parse_manifest_file, resolve_module_origin, resolve_plugin_load_order, - validate_config_schema, +from hermes_constants import ( + get_hermes_home, + hermes_home_key, + reset_hermes_home_override, + set_hermes_home_override, ) -from hermes_cli.plugins_discovery import ( # noqa: F401 — re-exported - ENTRY_POINTS_GROUP, _get_disabled_plugins, _get_enabled_plugins, collect_directory_manifests, - discover_entrypoint_manifests, gate_manifest, scan_directory, -) -from hermes_cli.plugins_loader import ( - PluginLoaderMixin, _BARE_MODULE_SCOPE, _MODULE_NAMESPACE_LOCK, _NS_PARENT, _evict_modules, - _plugin_home_scope, _serialized_replacement, +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.plugins_dispatch import ( # noqa: F401 — re-exported - DEFAULT_SYSTEM_PROMPT_SECTION_MAX_CHARS, HERMES_EVENT_NAMESPACE, MAX_SYSTEM_PROMPT_SECTION_CHARS, - MAX_SYSTEM_PROMPT_SECTIONS_TOTAL_CHARS, PLUGIN_SECTIONS_END, PLUGIN_SECTIONS_START, - SYSTEM_PROMPT_SECTION_POSITIONS, _EVENT_EMIT_DEPTH_CAP, _EVENT_PENDING_CAP, - _HOOK_CALLBACK_TIMEOUT_SECS, _HOOK_TIMEOUT_SUPPRESSION_SECONDS, _MAX_HOOK_CALLBACK_TIMEOUT_SECS, - _PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE, PluginDispatchMixin, PluginSystemPromptSection, - RenderedPluginSystemPromptSection, _EventSubscription, format_system_prompt_sections, - is_valid_system_prompt_section_id, +from hermes_cli.plugin_capabilities import ( + parse_declared_capabilities as _parse_declared_capabilities, ) -from hermes_cli.plugins_ledger import PluginLedgerMixin, PluginRegistration -from hermes_cli.plugins_state import ( - PluginState, _locked_plugin_state, _nested_plugin_mapping, _nested_plugin_value, - _plugin_relative_segments, _plugin_settings_entry, +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: - """Bundled ``plugins/`` dir: ``HERMES_BUNDLED_PLUGINS`` (Nix wrapper / packaged installs, read-only - store paths) first, else the in-repo 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..allow_tool_override``.""" + """Raised when a plugin attempts to override a built-in tool without + operator opt-in via ``plugins.entries..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 = env_var_enabled("HERMES_PLUGINS_DEBUG") + +# --------------------------------------------------------------------------- +# Plugin developer debug logging +# --------------------------------------------------------------------------- +# +# Set ``HERMES_PLUGINS_DEBUG=1`` to surface verbose plugin-discovery logs to +# stderr in addition to ~/.hermes/logs/agent.log. Aimed at plugin authors +# trying to figure out why their plugin isn't showing up: which directories +# were scanned, which manifests parsed, which plugins were skipped (and why), +# what each ``register(ctx)`` call registered, and full tracebacks on load +# failure. +# +# The env var is read once at import time; tests that need to flip it +# mid-process can call ``_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).""" + """When HERMES_PLUGINS_DEBUG is on, tee plugin logs to stderr at DEBUG. + + Idempotent: only attaches the handler once per process unless ``force`` + is passed. Does not touch the root logger or other Hermes loggers. + """ global _DEBUG_HANDLER_INSTALLED, _PLUGINS_DEBUG if force: - _PLUGINS_DEBUG = env_var_enabled("HERMES_PLUGINS_DEBUG") + _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) @@ -97,102 +143,1032 @@ def _install_plugin_debug_handler(force: bool = False) -> None: handler.setFormatter(logging.Formatter("[plugins] %(levelname)s %(message)s")) logger.addHandler(handler) logger.setLevel(logging.DEBUG) + # Don't double-emit through the root logger when the central logging + # config also writes to stderr. agent.log still captures everything. logger.propagate = True _DEBUG_HANDLER_INSTALLED = True - logger.debug("HERMES_PLUGINS_DEBUG=1 — verbose plugin discovery logging enabled") + 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", - # transform_llm_output: return a replacement string (first non-None wins) or None. - "transform_llm_output", "pre_llm_call", "post_llm_call", - # Streaming observers (agent.plugin_stream_hooks), off the token path; payloads are immutable - # normalized text/lifecycle and cannot transform the stream. - "on_stream_start", "on_stream_delta", "on_stream_end", "on_interim_message", - # pre_verify: once per turn when the agent edited code and is about to verify/finish. Return - # {"action": "continue", "message"} (or Claude-Code Stop {"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", - # transform_api_error_classification: 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": (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", - # on_skill_lifecycle: successful skill lifecycle facts (local skill name visible to plugins). - "on_skill_lifecycle", "subagent_start", "subagent_stop", - # pre_gateway_dispatch: 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; "allow"/None -> normal. + "pre_tool_call", + "post_tool_call", + "transform_terminal_output", + "transform_tool_result", + # Transform LLM output before it's returned to the user. + # Plugins return a string to replace the response text, or None/empty to leave unchanged. + # First non-None string wins. Useful for vocabulary/personality transformation. + "transform_llm_output", + "pre_llm_call", + "post_llm_call", + # Streaming LLM output observer hooks. Fired asynchronously off the token + # path by agent.plugin_stream_hooks; callbacks observe immutable normalized + # text/lifecycle payloads and cannot transform the stream. + "on_stream_start", + "on_stream_delta", + "on_stream_end", + "on_interim_message", + # Verification-loop gate. Fired once per turn when the agent has edited code + # and is about to verify/finish (after the verify-on-stop guard). A callback + # may keep the agent going — run a check, defer it, tidy the diff — instead + # of stopping by returning: + # {"action": "continue", "message": ""} + # The Claude-Code Stop shape {"decision": "block", "reason": "..."} (block + # the stop == keep going) is accepted too. Anything else lets the turn + # finish. Hermes' shipped guidance lives in the evidence-based + # verification-stop nudge; this hook is for user/plugin policy and is + # bounded by agent.max_verify_nudges. + "pre_verify", + "pre_api_request", + "post_api_request", + "api_request_error", + # API-error classification override. Fired once per failed API call at + # the top of ``agent/error_classifier.classify_api_error()``, BEFORE the + # built-in pipeline, so provider plugins can own their provider's error + # quirks without core patches. Callbacks receive the parsed error context + # (provider, model, status_code, error_type, error_code, error_message, + # error_body, error, approx_tokens, context_length, num_messages) and + # should self-scope on ``provider``. Return None to pass, or a dict:: + # {"reason": "", # required + # "retryable": bool, "should_compress": bool, + # "should_rotate_credential": bool, "should_fallback": bool, + # "message": str, "error_context": dict} # all optional + # Dispatch is run-all-then-pick-first: every registered callback runs + # with its failures isolated (an early answer never stops later + # callbacks), then the first valid result in registration order wins — + # on conflict the first-registered plugin is the tie-break, and every + # additional valid-but-losing result is reported with a runtime warning + # (the #64714 skipped-transform rule). Invalid dicts and unknown + # reasons are skipped; a broken plugin can never break error + # classification. Cold path: fires only on API failure. + # Privacy: error_message/error_body may carry an unredacted provider + # error dump. + # Contract: the transform-family first-valid-wins shape in + # docs/plugins/hook-taxonomy.md. + "transform_api_error_classification", + "on_session_start", + "on_session_end", + "on_session_finalize", + "on_session_reset", + # Successful skill lifecycle facts. The local skill name is available to + # plugins, while built-in shared metrics emit only bounded classifications. + "on_skill_lifecycle", + "subagent_start", + "subagent_stop", + # Gateway pre-dispatch hook. Fired once per incoming MessageEvent + # after the internal-event guard but BEFORE auth/pairing and agent + # dispatch. Plugins may return a dict to influence flow: + # {"action": "skip", "reason": "..."} -> drop message (no reply) + # {"action": "rewrite", "text": "..."} -> replace event.text, continue + # {"action": "allow"} / None -> normal dispatch + # Kwargs: event: MessageEvent, gateway: GatewayRunner, session_store. "pre_gateway_dispatch", - # Approval observers (tools/approval.py); returns 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", - # pre_transcription: 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). + # Approval lifecycle hooks. Fired by tools/approval.py when a dangerous + # command needs an approval decision -- fires for CLI-interactive prompts, + # gateway/ACP approvals, and smart-mode auxiliary-LLM decisions. + # Observers only: return values are ignored. Plugins cannot veto or + # pre-answer an approval from these hooks (use pre_tool_call to block + # a tool before it reaches approval). + # + # Kwargs for pre_approval_request: + # command: str, description: str, pattern_key: str, pattern_keys: list[str], + # session_key: str, surface: "cli" | "gateway" | "smart" + # Kwargs for post_approval_response: same as above plus + # choice: "once" | "session" | "always" | "deny" | "timeout" + # | "smart_approve" | "smart_deny" + # decided_by: "aux_llm" -- only on surface="smart" + "pre_approval_request", + "post_approval_response", + # Pre-transcription transform hook. Fired by the STT dispatcher + # (tools.transcription_tools.transcribe_audio) after provider resolution + # and BEFORE any backend — built-in, command-type, or plugin-registered — + # is invoked. Callbacks receive keyword args: + # file_path, provider, model, language, prompt, source + # and may return None (unchanged) or a dict mutating any of + # ``prompt`` / ``language`` / ``model``. Results are applied in + # registration order, last-writer-wins per field. ``file_path`` is + # read-only — attempts to change it are logged and dropped. The static + # ``stt.prompt`` config value is the base; hook results mutate on top. "pre_transcription", - # Kanban task observers (hermes_cli.kanban_db), fired AFTER the DB commit so a slow plugin never - # holds the SQLite write lock; returns ignored. 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; returns ignored; fire sites short-circuit on - # has_hook(). Kwargs: task_id, profile_name, board, assignee, run_id plus, per hook: - # worker_spawned (DISPATCHER, after PID persisted, inside the dispatch lock — stay fast): - # worker_pid, workspace_path (privacy: project layout/usernames). - # 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. - # worker_stale_claim (TTL-expired claim reclaimed; live-PID extensions do NOT fire): - # worker_pid, heartbeat_stale, retry_status. - # task_updated (committed task-row write outside claim/complete/block, in whichever process - # committed it): changed_fields — field NAMES only, never values. - # dispatch_tick (once per dispatch_once, strictly AFTER the dispatch lock is released): board, - # profile_name, dry_run, outcome ("ok"|"skipped_locked"|"idle"), result: DispatchResult - # (privacy: task ids, assignees, workspace paths). - "on_kanban_worker_spawned", "on_kanban_worker_exited", "on_kanban_worker_stale_claim", - "on_kanban_task_updated", "on_kanban_dispatch_tick", - # gateway_platform_event: 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. - # on_kanban_dispatch_tick fires once per dispatcher tick in dispatch_once, strictly AFTER the board's - # single-writer dispatch lock has been released (the #56066 original fired inside the lock — the #64231 - # disposition mandates the post-lock re-port), so a slow subscriber can never extend the writer critical - # section. Kwargs: board: str | None, profile_name: str, dry_run: bool, outcome: "ok" | "skipped_locked" - # | "idle", result: hermes_cli.kanban_db.DispatchResult (spawned, reclaimed, promoted, - # reconciled_orphans, crashed, stale, timed_out, auto_blocked, rate_limited, auto_assigned_default, - # respawn_guarded, skipped_per_profile_capped, skipped_unassigned, skipped_nonspawnable, - # skipped_locked). Privacy: result carries task ids, assignees, and workspace paths. - # Gateway platform-boundary observer hooks (#64176). Observer-only; each callback isolated by - # invoke_hook. This surface grants no adapter handles or platform actions. Fired today: Telegram - # "reaction" + "message_edited"; Discord "message_edited", "message_deleted", "thread_created", - # "thread_renamed". Each event type carries its own event-local additive payload contract (see - # hooks.md). Other event types and hook names land here only together with real fire-sites and payload - # contracts; no inert VALID_HOOKS surface is registered ahead of implementation. + # Kanban task lifecycle hooks. Fired by hermes_cli.kanban_db when a task + # transitions state, AFTER the change is committed to the board DB (so the + # hook always sees durable state and a slow plugin can never hold the + # SQLite write lock). Observers only: return values are ignored. + # + # WHICH PROCESS each fires in matters, because kanban workers run as + # separate `hermes -p chat -q` subprocesses: + # - kanban_task_claimed -> the DISPATCHER process (gateway-embedded + # dispatcher or `hermes kanban dispatch`), + # right before the worker subprocess spawns. + # - kanban_task_completed -> the WORKER process, when it calls + # kanban_complete (or a CLI/manual complete). + # - kanban_task_blocked -> the WORKER process (worker-initiated block) + # or whichever process drove the block. + # A plugin that needs to observe every transition centrally should hook in + # the dispatcher; one that needs per-task in-session context should hook in + # the worker. + # + # Common kwargs: task_id: str, board: str | None, assignee: str | None, + # run_id: int | None, profile_name: str. + # kanban_task_completed adds: summary: str | None. + # kanban_task_blocked adds: reason: str | None. + "kanban_task_claimed", + "kanban_task_completed", + "kanban_task_blocked", + # Kanban worker-lifecycle, task-mutation, and dispatcher-tick observers + # (RFC #58548, accepted as the design basis in the #64231 batch + # disposition; on_kanban_dispatch_tick is the re-port of PR #56066). + # All five are observers only: return values are ignored, and every fire + # site is fully best-effort, so a broken callback can never break + # dispatch or a task mutation. Cost rule: every call site short-circuits + # on has_hook(), so when nothing subscribes no payload is built and the + # hot paths (each dispatcher tick, each task write) pay one dict probe. + # + # WHICH PROCESS: worker spawn/exit/stale-claim and the dispatch tick + # fire in the DISPATCHER process (gateway-embedded dispatcher or + # ``hermes kanban dispatch``); on_kanban_task_updated fires in whichever + # process committed the mutation (CLI, worker, or the gateway-embedded + # dashboard API). + # + # Common kwargs (task-scoped hooks): task_id: str, profile_name: str, + # board: str | None, assignee: str | None, run_id: int | None. + # + # on_kanban_worker_spawned fires after ``spawn_fn`` returns AND the + # worker PID (when one was reported) is durably persisted, per the RFC + # timing contract; like kanban_task_claimed it runs inside the board's + # dispatch lock, so callbacks must stay fast. Adds: + # worker_pid: int | None, workspace_path: str. + # Privacy: workspace_path is a filesystem path and may reveal project + # layout or usernames. + "on_kanban_worker_spawned", + # on_kanban_worker_exited is tick-derived from detect_crashed_workers — + # it fires when a dead-PID running task is reclaimed, AFTER every + # reclaim/accounting txn has committed. Exit visibility latency is + # bounded by the dispatcher tick interval. Adds: + # worker_pid: int, + # exit_kind: "clean_exit" | "rate_limited" | "nonzero_exit" + # | "signaled" | "unknown", + # exit_code: int | None, + # outcome: "crashed" | "rate_limited", + # retry_status: str (the phase the task was released back to). + "on_kanban_worker_exited", + # on_kanban_worker_stale_claim fires when release_stale_claims reclaims + # a TTL-expired claim, after the reclaim txn commits. Live-PID claim + # extensions and deferred reclaims do NOT fire. Adds: + # worker_pid: int | None, heartbeat_stale: bool, retry_status: str. + "on_kanban_worker_stale_claim", + # on_kanban_task_updated is the task-mutation boundary observer: it + # fires after a committed task-row field write outside the + # claim/complete/block lifecycle — kanban_db.assign_task, + # set_model_override, and set_reasoning_effort, plus the dashboard + # plugin API's direct-SQL priority/title/body editors (single and + # bulk) via kanban_db.notify_task_updated. Adds: + # changed_fields: list[str] — field NAMES only; new values are never + # carried (fetch the task if you need them). + # Privacy: names only here, but title/body values in the board DB may + # contain user/project content. + "on_kanban_task_updated", + # on_kanban_dispatch_tick fires once per dispatcher tick in + # dispatch_once, strictly AFTER the board's single-writer dispatch lock + # has been released (the #56066 original fired inside the lock — the + # #64231 disposition mandates the post-lock re-port), so a slow + # subscriber can never extend the writer critical section. + # Kwargs: board: str | None, profile_name: str, dry_run: bool, + # outcome: "ok" | "skipped_locked" | "idle", + # result: hermes_cli.kanban_db.DispatchResult (spawned, reclaimed, + # promoted, reconciled_orphans, crashed, stale, timed_out, + # auto_blocked, rate_limited, auto_assigned_default, + # respawn_guarded, skipped_per_profile_capped, skipped_unassigned, + # skipped_nonspawnable, skipped_locked). + # Privacy: result carries task ids, assignees, and workspace paths. + "on_kanban_dispatch_tick", + # Gateway platform-boundary observer hooks (#64176). Observer-only; each + # callback isolated by invoke_hook. Payloads are normalized envelopes only, + # never raw platform SDK objects (per #64176 / #64182 ground rule). This + # surface grants no adapter handles or platform actions. + # + # gateway_platform_event: inbound platform event as a normalized envelope. + # Kwargs: platform, event_type, payload (event_type-specific dict). + # Fired today: Telegram "reaction" + "message_edited"; Discord + # "message_edited", "message_deleted", "thread_created", + # "thread_renamed". Each event type carries its own event-local + # additive payload contract (see hooks.md). Other event types and + # hook names land here only together with real fire-sites and + # payload contracts; no inert VALID_HOOKS surface is registered + # ahead of implementation. "gateway_platform_event", - # pre_command: BEFORE a recognized slash command's handler on CLI and gateway canonical dispatch; - # returns 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. - # Slash-command dispatch observer (#64204, observer-first per #64182 ground rule 3). Return values are - # IGNORED in v1 — a plugin returning a directive-shaped dict gets a debug log so future block/rewrite - # adopters are discoverable once the middleware variant ships against the #64231 taxonomy. + # Slash-command dispatch observer (#64204, observer-first per #64182 + # ground rule 3). Fired when a recognized slash command is about to be + # dispatched, BEFORE the handler runs, on both the interactive CLI + # (cli.py process_command) and the gateway canonical-command dispatch + # (gateway/run.py _handle_message). Return values are IGNORED in v1 — + # a plugin returning a directive-shaped dict gets a debug log so future + # block/rewrite adopters are discoverable once the middleware variant + # ships against the #64231 taxonomy. + # + # Deliberately NOT fired for the gateway's running-agent intercept path + # (/stop, /approve, busy_policy dispatch while a turn is live): those are + # control-plane operations on an in-flight run — letting plugins observe + # (and one day veto) the operator's escape hatches would turn a slow or + # hostile plugin into a way to lose control of a running agent. + # + # Kwargs: surface: "cli" | "gateway", command: canonical name (str), + # alias_used: the exact token the user typed (str), args_raw: str, + # session_key: str | None (gateway), platform: str | None (gateway). "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"} +# Hooks whose return value carries a directive that the shell-hook response +# parser (``agent/shell_hooks._parse_response``) has no channel for. +# ``VALID_HOOKS`` doubles as the shell-hook config allow-list, so without +# this exclusion a shell hook could register for one of these events and +# have its output silently ignored — registration is refused loudly instead. +# Support for a shell response shape can lift an event out of this set. +SHELL_UNSUPPORTED_HOOKS: Set[str] = { + "transform_api_error_classification", +} + +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. + + Composes the full entry-point manifest contract in one place: + + * **Kind classification** — the module source is resolved import-free + (``_resolve_module_source``) and scanned for provider markers + (``_detect_kind_from_source``), so memory providers (``exclusive``) + and model providers (``model-provider``) are routed to their own + discovery systems instead of being eagerly imported here. + * **Capability declarations** — read from the companion + ``hermes_agent.plugin_capabilities`` entry-point group (declarations + named ``.`` pointing at the same object), + so consent/introspection is accurate without importing plugin code. + + Failures are isolated per entry point: one malformed distribution must + not blank the manifests of every other installed plugin. + """ + 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. + + Module-level twin of ``PluginManager._classify_entrypoint_kind`` so + ``discover_entrypoint_manifests()`` callers outside the manager (the + CLI capabilities path) get identical routing. Unresolvable or + non-Python modules stay ``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 deliberately more constrained than lifecycle +# hooks. They become high-trust prompt bytes and are charged on 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 = "" +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"\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:``. +HERMES_EVENT_NAMESPACE = "hermes" + +# Max inter-plugin event dispatch recursion depth. A subscriber may itself +# call ``ctx.emit``; this bound stops mutually-emitting plugins from looping +# forever. When exceeded the over-deep emit is dropped (with a warning), not +# raised, so delivery always terminates cleanly. +_EVENT_EMIT_DEPTH_CAP = 8 +# Maximum number of queued + currently-running events per manager generation. +# ``emit`` never waits for capacity: a full budget drops the new event with a +# warning so a blocked subscriber cannot back-pressure the emitter forever. +_EVENT_PENDING_CAP = 64 +_EVENT_WORKER_STOP = object() + +_NS_PARENT = "hermes_plugins" +_MODULE_NAMESPACE_LOCK = threading.RLock() +_BARE_MODULE_SCOPE: Dict[str, str] = {} + + +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) + + +def _env_enabled(name: str) -> bool: + """Return True when an env var is set to a truthy opt-in value.""" + return env_var_enabled(name) + + +def _get_disabled_plugins() -> set: + """Read the disabled plugins list from config.yaml. + + Kept for backward compat and explicit deny-list semantics. A plugin + name in this set will never load, even if it appears in + ``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 enabled-plugins allow-list from config.yaml. + + Plugins are opt-in by default — only plugins whose name appears in + this set are loaded. Returns: + + * ``None`` — the key is missing or malformed. Callers should treat + this as "nothing enabled yet" (the opt-in default); the first + ``migrate_config`` run populates the key with a grandfathered set + of currently-installed user plugins so existing setups don't + break on upgrade. + * ``set()`` — an empty list was explicitly set; nothing loads. + * ``set(...)`` — the concrete allow-list. + """ + try: + from hermes_cli.config import load_config + config = load_config() + plugins_cfg = config.get("plugins") + if not isinstance(plugins_cfg, dict): + return None + if "enabled" not in plugins_cfg: + return None + enabled = plugins_cfg.get("enabled") + if not isinstance(enabled, list): + return None + return set(enabled) + 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 (#64165) parsing helpers ────────────────────────────────── + +# Fields the current parser understands. Anything else in plugin.yaml is +# forward-compat surface: warn (once per manifest, at debug for v1 files to +# avoid churning existing plugins, at 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 (#64165) + "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 _parse_manifest_v2_fields(data: Mapping, key: str) -> Dict[str, Any]: + """Validate and normalize the manifest v2 fields (#64165). + + Returns kwargs for :class:`PluginManifest`. Every problem is a warning, + never a load failure — v2 metadata is advisory and additive. + """ + 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") + if raw_api is None: + out["api_version"] = None + else: + 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, + ) + out["api_version"] = None + + # requires_plugins — list of {id, version_range?} (str shorthand ok). + deps: List[Dict[str, Any]] = [] + raw_deps = data.get("requires_plugins") + if raw_deps is not None and not isinstance(raw_deps, list): + logger.warning( + "Plugin %s: requires_plugins must be a list; ignoring", key, + ) + raw_deps = None + for item in raw_deps 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 — declared pip requirement strings. Validated and + # surfaced ONLY; never auto-installed (isolation design deferred). + pydeps: List[str] = [] + raw_pydeps = data.get("python_dependencies") + if raw_pydeps is not None and not isinstance(raw_pydeps, list): + logger.warning( + "Plugin %s: python_dependencies must be a list of requirement " + "strings; ignoring", key, + ) + raw_pydeps = None + 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?}. + raw_schema = data.get("config_schema") + schema: Dict[str, Any] = {} + if raw_schema is not None and not isinstance(raw_schema, Mapping): + logger.warning( + "Plugin %s: config_schema must be a mapping; ignoring", key, + ) + raw_schema = None + 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 = data.get("tags") + if raw_tags is not None and not isinstance(raw_tags, list): + logger.warning("Plugin %s: tags must be a list; ignoring", key) + raw_tags = None + out["tags"] = [str(t) for t in (raw_tags or [])] + + # Forward compat: unknown fields warn (never fail). Keep v1 manifests + # quiet at warning level — they predate the known-field census. + 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]: + """Validate a plugin's config entry against its declared config_schema. + + Returns a list of human-actionable warning strings. Never raises; + schema mismatches must not block plugin load (#64165). + """ + 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-respecting load order (#64165). + + When A requires B, B sorts before A (so B's ``register()`` runs first). + Ties break alphabetically for determinism. Dependency cycles are + detected, warned about, and the members of the cycle fall back to + alphabetical order after every non-cycle plugin they depend on. + Missing dependencies are warned about here (once, at discovery) but do + not remove the dependent plugin from the order — loads never hard-fail + on a missing advisory dependency. + """ + 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 plugin kind implied by source markers, or ``None``. + + Mirrors ``plugins/memory/__init__.py:_is_memory_provider_dir``: a + module that registers a memory provider (``register_memory_provider`` + or ``MemoryProvider``) belongs to the memory-provider discovery + system (``exclusive``); a module that registers a model provider + (``register_provider`` + ``ProviderProfile``) belongs to the + providers discovery (``model-provider``). Applied to both directory + plugins and pip entry-point plugins so neither is eagerly imported + by the general PluginManager. + """ + 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: + """Read the first ``limit`` chars of a module's source file. + + Returns ``""`` on any failure (callers fall back to ``standalone``). + ``.pyc``/``.pyo`` origins are mapped back to their source path so + source is still scanned when only the bytecode cache is present. + """ + 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``. + + ``importlib.util.find_spec`` on a dotted name imports the parent + package first (executing its ``__init__.py``), which would run + arbitrary package initialization during discovery and pay the very + import cost this exists to avoid — a provider whose heavy imports + live in ``package/__init__.py`` would still pay them. + + Only the top-level name is resolved with ``find_spec`` (import-free + for top-level names); the remaining dotted segments are walked + through ``submodule_search_locations`` by hand, mirroring the file + layout conventions of the default PathFinder (``part.py`` module or + ``part/__init__.py`` package). Namespace packages, zipped modules, + extension modules, and anything else unexpected return ``None``. + + Shared with ``plugins/memory/__init__.py``, which needs the directory + of a pip-installed provider to find its ``config_schema.py`` and + ``cli.py`` — both of which are loaded by path precisely so the + provider module never has to be imported. + """ + 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. + + Empty string when the module cannot be resolved or read, which + callers treat as ``standalone`` — the safe default. + """ + return _read_source_from_origin(resolve_module_origin(module_name), limit) + + +def _as_plugin_path(path: Any, *, what: str = "plugin path") -> Path: + """Coerce a plugin filesystem location to ``Path``. + + Manifests store directory locations as ``str`` (entrypoint ``path`` + values are module specs, not files). Call sites that need ``.exists()`` + or ``.is_dir()`` must go through this helper so a string never raises + ``AttributeError: 'str' object has no attribute 'exists'``. + """ + if isinstance(path, Path): + return path + if isinstance(path, str): + if not path.strip(): + raise ValueError(f"{what} is empty") + return Path(path) + raise TypeError( + f"{what} must be a filesystem path (str or Path), " + f"got {type(path).__name__}" + ) + + +@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 = "" # "user", "project", or "entrypoint" + path: Optional[str] = None + # Plugin kind — see plugins.py module docstring for semantics. + # ``standalone`` (default): hooks/tools of its own; opt-in via + # ``plugins.enabled``. + # ``backend``: pluggable backend for an existing core tool (e.g. + # image_gen). Built-in (bundled) backends auto-load; + # user-installed still gated by ``plugins.enabled``. + # ``exclusive``: category with exactly one active provider (memory). + # Selection via ``.provider`` config key; the + # category's own discovery system handles loading and the + # general scanner skips these. + # ``platform``: gateway messaging platform adapter (e.g. IRC). Bundled + # platform plugins auto-load so every shipped platform is + # available out of the box; user-installed platform plugins + # in ~/.hermes/plugins/ still gated by ``plugins.enabled`` + # (untrusted code). + kind: str = "standalone" + # Registry key — path-derived, used by ``plugins.enabled``/``disabled`` + # lookups and by ``hermes plugins list``. For a flat plugin at + # ``plugins/disk-cleanup/`` the key is ``disk-cleanup``; for a nested + # category plugin at ``plugins/image_gen/openai/`` the key is + # ``image_gen/openai``. When empty, falls back to ``name``. + key: str = "" + portable: bool = False + skill_namespace: str = "" + # Declared capability ids from the manifest ``capabilities:`` list + # (#64228). Normalized to KNOWN ids only — see + # ``hermes_cli.plugin_capabilities.CAPABILITY_REGISTRY``. Declaration is + # consent metadata, not a grant: a capability is live only when the user + # granted it (``plugins.entries..granted_capabilities``) or the + # deprecated legacy ``allow_*`` key is set. + capabilities: List[str] = field(default_factory=list) + # ── Manifest v2 fields (#64165) — all optional and additive ────────── + # Manifest SCHEMA version. Absent (v1) manifests are fully supported + # forever. This versions the *file format* only; it is deliberately + # independent from ``api_version`` (the runtime plugin API generation). + manifest_version: int = 1 + # Runtime plugin API generation the plugin targets (ctx surface / + # hook signatures). ``None`` = unspecified (treated as current-compatible). + api_version: Optional[int] = None + # Inter-plugin dependencies: list of {"id": str, "version_range": str|None}. + # Advisory: a missing dependency logs a warning but the plugin still + # loads (plugins can probe availability via ``ctx.has_plugin``). Load + # ORDER honors these edges: if A requires B, B registers first. + requires_plugins: List[Dict[str, Any]] = field(default_factory=list) + # Declared pip dependencies. VALIDATED AND SURFACED ONLY — Hermes never + # auto-installs these (isolation design for the install seam is a + # deferred follow-up; see #64165 round-2 review and #15220). + python_dependencies: List[str] = field(default_factory=list) + # JSON-schema-ish mapping describing keys under + # ``plugins.entries..settings``. Validated at load; mismatches are + # warnings, never load failures. + config_schema: Dict[str, Any] = field(default_factory=dict) + # Formalized standard metadata. + license: str = "" + homepage: str = "" + tags: List[str] = field(default_factory=list) + # Inter-plugin event bus declarations (advisory in v1 — NOT enforced). + # ``emits`` lists the bare event names this plugin publishes under its own + # ``:`` namespace (e.g. ``["ping"]`` → publishes ``:ping``). + # ``listens`` lists the fully-qualified ``:`` names this + # plugin subscribes to. Both are purely for discoverability + # (``hermes plugins show``); a plugin may emit/subscribe without declaring. + 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 + -_env_enabled = env_var_enabled # imported by plugins/memory -_UNSET = object() +@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 @@ -207,201 +1183,476 @@ class LoadedPlugin: 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). + # True for a bundled platform plugin recorded as a deferred (not-yet- + # imported) loader. The module loads on first real use via the + # platform_registry; see PluginManager._register_deferred_platform. deferred: bool = False +@dataclass +class PluginRegistration: + """One host-owned registration made while loading a plugin. + + Plugins only receive the context registration APIs; the manager owns the + matching cleanup operation. Keeping that inverse operation beside the + registration lets a force reload unwind global registries in reverse + order, including an override that needs to restore the entry it replaced. + """ + + kind: str + key: str + release: Callable[[], None] + plugin_key: str = "" + _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 and split a plugin-relative settings key. + + The public API accepts only relative keys (``endpoint`` or + ``retry.policy``). Full Hermes paths, traversal syntax, and the security- + sensitive core roots called out in #64227 are rejected before any config + read occurs. + """ + 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 + # Reuse the portable namespace algorithm for native/nested ids too. Its + # fixed prefix avoids Windows reserved device names (CON, NUL, COM1...), + # while the digest prevents collisions after unsafe characters are folded. + 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 and processes. + + ``fcntl`` is used on POSIX and ``msvcrt`` on native Windows. The lock is + kept in a sibling file because atomic replacement changes the inode/file + handle of the target itself. + """ + 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 - self._llm: Any = None # lazy; tests preseed it (see ``llm``) + # Lazy-built host-owned LLM facade — see ctx.llm property below. + self._llm: Any = None + self._subagent_lifecycle: Any = None + self._state: PluginState | None = None + # Lazy-built capability-gated platform action facade (#64176). + self._platform_actions: Any = None @property def plugin_id(self) -> str: """Return the effective registry id used for this plugin's namespaces.""" - return manifest_key(self.manifest) + 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 True when another plugin is loaded and enabled (#64165). - See #64165. + Companion to the advisory ``requires_plugins`` manifest field: a + missing dependency never blocks load, so plugins probe availability + at runtime with this. 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() - ) + for key, loaded in self._manager._plugins.items(): + if not loaded.enabled: + continue + if key == plugin_id or loaded.manifest.name == plugin_id: + return True + return False + + # -- namespaced config and durable state -------------------------------- + + def get_config(self, key: str, default: Any = None) -> Any: + """Read ``plugins.entries..settings.``. - def _segments(self, key: str) -> tuple[str, ...]: - """Validated plugin-relative settings path (warn + re-raise on rejection).""" + ``key`` is always plugin-relative. For migration compatibility, a + missing canonical value falls back to the former ``config`` subtree; + no global config paths are exposed. + """ try: - return _plugin_relative_segments(key) + segments = _plugin_relative_segments(key) except ValueError: - logger.warning("Rejected config path %r from plugin %s", key, self.plugin_id) + logger.warning( + "Rejected config path %r from plugin %s", key, self.plugin_id + ) raise + from hermes_cli.config import load_config_readonly - def get_config(self, key: str, default: Any = None) -> Any: - """Read plugin-relative ``plugins.entries..settings.`` (falls back to the - legacy ``config`` subtree for migration compatibility).""" - segments = self._segments(key) - entry = _plugin_settings_entry(load_config_readonly() or {}, self.plugin_id) - if entry is None: + 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 - value = _nested_plugin_value(entry.get("settings"), segments, _UNSET) - if value is not _UNSET: + 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.""" - segments = self._segments(key) + 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") + raise PermissionError( + "Plugin settings cannot be changed in a managed install" + ) from hermes_cli import managed_scope - full_path = ("plugins", "entries", self.plugin_id, "settings", *segments) - dotted_path = ".".join(full_path) + + 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 = _nested_plugin_mapping(full_path[:4], _nested_plugin_mapping(segments, value)) - # 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()), 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) - - @cached_property + 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 the merge read plus atomic save, preventing sibling + # plugin writes from racing between those two steps. + # Serialize bridge-to-bridge writes across processes as well as + # threads. Other Hermes config writers still retain their existing + # atomic-replace semantics; this lock specifically prevents two + # plugin read/merge/write transactions from dropping siblings. + with _locked_plugin_state(config_mod.get_config_path()): + with config_mod._CONFIG_LOCK: + # Fail closed on malformed YAML. save_config's raw-cache reader + # intentionally degrades parse failures to {}, which is safe for + # reads but 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: - """This plugin's profile-scoped durable JSON state facade.""" - return PluginState(self.plugin_id, self.manifest.skill_namespace) + """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 - @cached_property + @property def platform_actions(self): - """Capability-gated platform action facade (``add_reaction``, ``set_thread_title``). Every call - re-checks ``gateway.platform_actions`` (legacy ``plugins.entries..allow_platform_actions``, - default OFF) and returns ``{"ok": bool, ...}`` — verbs never raise into hook dispatch; no adapter - handles or raw SDK objects.""" - from hermes_cli.platform_actions import PlatformActions - return PlatformActions(self.plugin_id) - - def _wrong_type(self, obj: Any, base_class: type, label: str, article: str = "a") -> bool: - """Warn-and-ignore gate shared by every registrar that requires a base class.""" - if isinstance(obj, base_class): - return False - 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 True + """Capability-gated platform action facade (#64176, v1). + + Minimal verb set (``add_reaction``, ``set_thread_title``) routed + through the live gateway adapter registry. Every call re-checks the + ``gateway.platform_actions`` capability (legacy gate: + ``plugins.entries..allow_platform_actions``, default OFF) and + returns a structured ``{"ok": bool, ...}`` dict — verbs never raise + into hook dispatch. No adapter handles or raw SDK objects are exposed. + """ + if self._platform_actions is None: + from hermes_cli.platform_actions import PlatformActions - def _refuse(self, what: str) -> ValueError: - """``ValueError`` for a malformed registration (``what`` completes "tried to register ...").""" - return ValueError(f"Plugin '{self.manifest.name}' tried to register {what}.") + self._platform_actions = PlatformActions(self.plugin_id) + return self._platform_actions def _track( - self, kind: str, key: str, release: Callable[[], None], *, persistent: bool = False, + self, + kind: str, + key: str, + release: Callable[[], None], ) -> 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) + """Record host-owned cleanup for a successful registration.""" + return self._manager._track_registration( + self.manifest, kind, key, release + ) def _track_replacement( - self, kind: str, key: str, *, slot: tuple, current: Any, previous: Any, + self, + kind: str, + key: str, + *, + slot: tuple, + current: Any, + previous: Any, restore: Callable[[Any], bool], + finalize: Optional[Callable[[], None]] = None, ) -> PluginRegistration: - """Track one generation in a replaceable manager-local registration slot.""" - lease = replacement_coordinator.acquire(slot, current=current, previous=previous, restore=restore) + """Track one generation in a replaceable registration slot.""" + lease = replacement_coordinator.acquire( + slot, + current=current, + previous=previous, + restore=restore, + finalize=finalize, + ) return self._track(kind, key, lease.dispose) - def _track_mapping_entry( - self, kind: str, key: str, mapping: Dict[str, Any], entry: Any, previous: Any = _UNSET, - ) -> PluginRegistration: - """Store ``entry`` under ``key`` in a manager-local mapping and lease the slot; unload restores - ``previous`` (default: the displaced entry, or removes the key) only while ``entry`` is still - current.""" - if previous is _UNSET: - previous = mapping.get(key) - mapping[key] = entry - return self._track_replacement( - kind, key, slot=("manager_mapping", id(mapping), key), current=entry, previous=previous, - restore=lambda replacement: self._manager._restore_mapping(mapping, key, entry, replacement), - ) - - def _register_entry( - self, kind: str, key: str, mapping: Dict[str, Any], entry: Any, log_fmt: str, *log_args: Any, - previous: Any = _UNSET, - ) -> PluginRegistration: - """Shared tail of the manager-mapping registrars: store + lease the entry, then log - ``log_fmt % (plugin name, *log_args)`` at debug.""" - handle = self._track_mapping_entry(kind, key, mapping, entry, previous) - logger.debug(log_fmt, self.manifest.name, *log_args) - return handle - - 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__provider`` methods: type-check (warn + ignore), - register in the scope-keyed ``registry``, lease the slot so unload restores the displaced entry. - ``None`` when the registry refused/replaced the provider (``ValueError`` with ``reject_message`` - set, or a falsy ``register``).""" - if self._wrong_type(provider, base_class, label, article): - 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) - try: - accepted = (register or registry.register_provider)(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) or 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..llm.*``.""" + """Return the plugin's :class:`agent.plugin_llm.PluginLlm` facade. + + Lets trusted plugins run host-owned chat or structured completions + against the user's active model and auth without bringing their + own provider keys. Override capability (model, agent id, auth + profile) is fail-closed by default and gated through + ``plugins.entries..llm.*`` config keys. + + See :mod:`agent.plugin_llm` for the full surface.""" if self._llm is None: from agent.plugin_llm import PluginLlm - self._llm = PluginLlm(plugin_id=self.plugin_id) + plugin_id = self.manifest.key or self.manifest.name + self._llm = PluginLlm(plugin_id=plugin_id) return self._llm - @cached_property + @property def subagent_lifecycle(self) -> Any: - """Plugin-safe subagent lifecycle service: serializable handles and immutable snapshots, - never a live agent or private registry.""" - from agent.subagent_lifecycle import SubagentLifecycleService, get_active_subagent_parent - return SubagentLifecycleService(get_active_subagent_parent) + """Return the public, plugin-safe subagent lifecycle service. - @property - def profile_name(self) -> str: - """Active profile name (``"default"``, the ``~/.hermes/profiles/`` id, or ``"custom"``), - derived from ``HERMES_HOME`` — not ``_cli_ref``, which is None outside the interactive CLI — - so gateway and kanban workers get it too.""" + The service only resolves the active host-owned parent agent when a + child is launched. Plugins receive serializable handles and immutable + snapshots; they never receive a live agent or a 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: + """Return the active Hermes profile name (e.g. ``"default"``). + + Derived from ``HERMES_HOME`` via + :func:`hermes_cli.profiles.get_active_profile_name`, so it works in + every execution context — interactive CLI, gateway, and + kanban-spawned worker sessions alike — without depending on + ``_cli_ref`` (which is ``None`` outside an interactive CLI run). + + Returns ``"default"`` for the default profile, the profile id when + running under ``~/.hermes/profiles/``, or ``"custom"`` when + ``HERMES_HOME`` points somewhere unrecognized. + """ 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.""" + """Register a cleanup callback that runs when this plugin unloads. + + Callbacks are recorded in the ownership ledger, so they run in + reverse acquisition order interleaved with registration teardown, + and each is isolated — an exception is logged, never propagated + (see :meth:`PluginManager._dispose_registrations`). + """ if not callable(callback): raise TypeError("on_unload callback must be callable") handle = self._track("on_unload", getattr(callback, "__name__", "callback"), callback) @@ -409,125 +1660,251 @@ def on_unload(self, callback: Callable[[], None]) -> PluginRegistration: 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.""" + """Spawn a supervised background asyncio task owned by this plugin. + + The task is recorded in the ownership ledger; unloading the plugin + (or a force reload) cancels it. Requires a running event 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) - handle = self._track("background_task", task_name, lambda: task.done() or task.cancel()) + + 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) + 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: - `` selects it. It receives a redacted ``ApprovalRequest`` and returns only a - correlated decision; policy and persistence stay host-owned. ``present_fn`` may be async.""" - from hermes_cli.approval_transport import RegisteredApprovalTransport - transports = self._manager._approval_transports - 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 transports: - owner = transports[clean].plugin_id - raise ValueError(f"approval transport {clean!r} is already registered by {owner!r}") - entry = RegisteredApprovalTransport( - name=clean, present=present_fn, plugin_id=self.plugin_id, - profile_home=str(get_hermes_home().resolve()), + """Register a human approval presentation transport. + + The transport is inactive until the operator explicitly selects + ``security.approval.transport: ``. It receives a host-created, + redacted ``ApprovalRequest`` and may only return a correlated human + decision; command policy and approval persistence remain host-owned. + ``present_fn`` may be synchronous or async. + """ + self._manager.register_approval_transport( + name, + present_fn, + plugin_id=self.manifest.key or self.manifest.name, ) - logger.info("Plugin %s registered approval transport: %s", self.plugin_id, clean) - # Duplicate names are rejected above, so there is never a displaced previous entry to restore; - # tracking makes unload/force-reload remove this transport. - self._track_mapping_entry("approval_transport", clean, transports, entry, None) + # 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_replacement( + "approval_transport", + clean, + slot=( + "manager_mapping", + id(self._manager._approval_transports), + clean, + ), + current=entry, + previous=None, + restore=lambda replacement: self._manager._restore_mapping( + self._manager._approval_transports, clean, entry, replacement + ), + ) + + # -- 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, + 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 claimed by another toolset is rejected) and - needs operator opt-in via ``plugins.entries..allow_tool_override: true`` — otherwise - any enabled plugin could silently replace a privileged built-in like ``write_file``. - - ``override=True`` against a built-in tool requires the operator to opt in via - ``plugins.entries..allow_tool_override: true`` in config.yaml — mirrors the trust gate - pattern used for ``ctx.llm`` provider/model overrides (#23194). + """Register a tool in the global registry **and** track it as plugin-provided. + + Pass ``override=True`` to replace an existing built-in tool with the + same name (e.g. swap the default ``browser_navigate`` for a custom + CDP-backed implementation). Without it, attempting to register a name + already claimed by a different toolset is rejected. + + ``override=True`` against a built-in tool requires the operator to + opt in via ``plugins.entries..allow_tool_override: true`` + in config.yaml — mirrors the trust gate pattern used for + ``ctx.llm`` provider/model overrides (#23194). Without that gate, + any enabled plugin could silently replace a privileged built-in + like ``shell_exec`` or ``write_file`` and exfiltrate everything + the model invokes through it. """ 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 {name!r}. Set " - f"plugins.entries.{self.plugin_id}.allow_tool_override: true " + 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) - if previous is None and not override and registry.get_entry(name, scope=scope) is not None: - logger.warning("Plugin %s tried to shadow global tool %s without override=True", - self.manifest.name, name) + 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, + 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) - handle = None - if registered is not None and registered is not previous and registered.handler is handler: + 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, + def _restore_tool(replacement: Any) -> bool: + return registry.restore_registration( + name, registered, replacement, scope=scope + ) + + handle = self._track_replacement( + "tool", + name, + slot=("tool", scope, name), + current=registered, + previous=previous, + restore=_restore_tool, finalize=lambda: self._manager._remove_tool_name_if_unowned(name), ) - logger.debug("Plugin %s registered tool: %s%s", self.manifest.name, name, - " (override)" if override else "") + 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: - """True when *capability* is live for this plugin (probe, then degrade gracefully). Bundled - plugins are trusted for ``tools.override``; otherwise granted_capabilities or the legacy - ``allow_*`` key decides. Unknown ids / unreadable consent -> False (fail closed).""" - if self.manifest.source == "bundled" and capability == "tools.override": + """Return True when *capability* is live for this plugin. + + Plugins should probe with this and degrade gracefully instead of + crashing when a gated host surface refuses them. Bundled plugins are + trusted for ``tools.override`` (mirrors the registration gate); for + everything else the answer comes from the granted-capability set or + the deprecated legacy ``allow_*`` config key. Unknown capability ids + and unreadable consent state return False (fail closed). + """ + source = getattr(self.manifest, "source", "") or "" + if source == "bundled" and capability == "tools.override": return True - return plugin_capability_granted(self.plugin_id, capability) + 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, + self, + server: str, + tool: str, + arguments: Optional[Dict[str, Any]] = None, timeout: float = 30, ) -> Dict[str, Any]: - """Call ``tool`` on MCP ``server`` synchronously through :mod:`tools.mcp_tool`'s native client - (same trust gates, breaker, reconnect — never a parallel connection). Servers not in - ``plugins.entries..mcp_allowlist`` raise ``PermissionError`` (default-deny). ``timeout`` - clamps to 1–600s; results over ~64KB are truncated with a marker. - - This is a per-server grant, deliberately not ambient authority over every configured server. - TODO(#64228): swap the per-server allowlist for the declared capability model once it lands - (per-tool grants, expiry, ro/rw). + """Call a tool on a configured MCP server (#64204, capability-gated). + + Synchronous; safe to call from plugin hooks and tools. Routes through + the EXISTING native MCP client machinery in :mod:`tools.mcp_tool` + (background loop, trust-tier gates, circuit breaker, reconnect and + result rendering) — never a parallel client or connection. + + Default-off: a plugin has NO MCP access until the operator lists the + servers it may reach under ``plugins.entries..mcp_allowlist`` + in config.yaml:: + + plugins: + entries: + my-plugin: + mcp_allowlist: ["knowledge_rag", "github"] + + Calls to unlisted servers raise :class:`PermissionError`. This is a + per-server grant, deliberately not ambient authority over every + configured server. + # TODO(#64228): swap the per-server allowlist for the declared + # capability model once it lands (per-tool grants, expiry, ro/rw). + + Args: + server: MCP server name as configured in ``mcp.servers``. + tool: Tool name on that server (unprefixed). + arguments: JSON-serializable arguments dict for the tool. + timeout: Seconds to wait for the call (default 30) so a hung + MCP server can never stall the hook/tool pipeline. + + Returns: + Envelope dict: ``{"ok": True, "result": }`` on + success or ``{"ok": False, "error": }`` when the MCP + call itself failed. Results larger than ~64KB are truncated + with a marker. + + Raises: + PermissionError: server not in this plugin's ``mcp_allowlist``. """ - if server not in self._mcp_allowlist(self.plugin_id): + 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.{self.plugin_id}.mcp_allowlist in config.yaml " + 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_handlers import _make_tool_handler - raw = _make_tool_handler(server, tool, timeout)(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 "")) + + # Reuse the exact handler the tool registry uses for MCP tools — + # same trust gate, circuit breaker, reconnect and rendering paths. + 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 @@ -537,11 +1914,14 @@ 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) - truncated = len(raw) > cls._MCP_RESULT_CHAR_CAP - if truncated: + 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: Any = json.loads(raw) + parsed = json.loads(raw) except (ValueError, TypeError): parsed = None if isinstance(parsed, dict) and "error" in parsed: @@ -552,78 +1932,132 @@ def _mcp_envelope(cls, raw: Any) -> Dict[str, Any]: envelope["structuredContent"] = parsed["structuredContent"] else: envelope = {"ok": True, "result": parsed if parsed is not None else raw} - return {**envelope, "truncated": True} if truncated else envelope + 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).""" + """Return the operator-granted MCP server allowlist for a plugin. + + Missing key or unreadable config → empty list (fail closed, + default-deny). + """ try: from hermes_cli.config import load_config cfg = load_config() or {} except Exception: return [] - allowlist = (_plugin_settings_entry(cfg, plugin_id) or {}).get("mcp_allowlist") - return [str(item) for item in allowlist] if isinstance(allowlist, list) else [] + 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: - """Whether this plugin may override built-in tools: bundled plugins are trusted (a maintainer - choice, not privilege escalation); others need ``tools.override`` via - :func:`plugin_capability_granted` (granted_capabilities OR legacy ``allow_tool_override: true``). - - Bundled plugins (shipped with Hermes core) are trusted by default — an override there is a - deliberate maintainer choice, not a third-party plugin trying to elevate privilege. For every other - source, the canonical check is :func:`plugin_capability_granted` with the ``tools.override`` - capability — satisfied by EITHER the consent-flow grant - (``plugins.entries..granted_capabilities``) OR the deprecated legacy key - ``allow_tool_override: true`` (still honored for backward compatibility; #64228 reference - migration). + """Return True if this plugin is configured to override built-in tools. + + Bundled plugins (shipped with Hermes core) are trusted by default — + an override there is a deliberate maintainer choice, not a third-party + plugin trying to elevate privilege. For every other source, the + canonical check is :func:`plugin_capability_granted` with the + ``tools.override`` capability — satisfied by EITHER the consent-flow + grant (``plugins.entries..granted_capabilities``) OR the + deprecated legacy key ``allow_tool_override: true`` (still honored + for backward compatibility; #64228 reference migration). """ - if self.manifest.source == "bundled": + 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 - # 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(self.plugin_id, "tools.override", config=cfg) - - # Fail-closed by construction: any failure to read consent state inside plugin_capability_granted - # returns False. The profile-scoped config is passed through so a multi-profile process consults THIS - # manager's home, never the active profile's (#65593 constraint). + # If we can't load config, fail closed — better to break the + # override than silently grant it. + return False + plugin_id = self.manifest.key or self.manifest.name + # Fail-closed by construction: any failure to read consent state + # inside plugin_capability_granted returns False. The profile-scoped + # config is passed through so a multi-profile process consults THIS + # manager's home, never the active profile's (#65593 constraint). + 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, + 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 needs an existing ``session_key`` plus - ``plugins.entries..allow_gateway_injection``; ``True`` means the gateway accepted the - request for async dispatch, not that delivery completed.""" + """Inject a message into a CLI or gateway conversation. + + If the agent is idle (waiting for user input), this starts a new turn. + If the agent is running, this interrupts and injects the message. + + This enables plugins (e.g. remote control viewers, messaging bridges) + to send messages into the conversation from external sources. + + Gateway injection requires an existing ``session_key`` and an explicit + ``plugins.entries..allow_gateway_injection`` config grant. + A ``True`` return means the live gateway accepted the request for + asynchronous dispatch, not that platform delivery has completed. + + Returns True if the message was queued successfully. + """ cli = self._manager._cli_ref msg = content if role == "user" else f"[{role}] {content}" + if cli is not None: - queue_ = cli._interrupt_queue if getattr(cli, "_agent_running", False) else cli._pending_input - queue_.put(msg) + if getattr(cli, "_agent_running", False): + # Agent is mid-turn - interrupt with the message + cli._interrupt_queue.put(msg) + else: + # Agent is idle - queue as next input + cli._pending_input.put(msg) return True + if not session_key: - logger.warning("inject_message: gateway mode requires an existing session_key") + logger.warning( + "inject_message: gateway mode requires an existing session_key" + ) return False if not self._gateway_injection_allowed(): - logger.warning("inject_message: gateway injection denied for plugin %s; set " - "plugins.entries.%s.allow_gateway_injection: true to allow it", - self.plugin_id, self.plugin_id) + 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=self.plugin_id, - )) + 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", self.plugin_id, - exc_info=True) + logger.warning( + "inject_message: gateway scheduling failed for plugin %s", + plugin_id, + exc_info=True, + ) return False def _gateway_injection_allowed(self) -> bool: @@ -632,157 +2066,789 @@ def _gateway_injection_allowed(self) -> bool: cfg = load_config_readonly() or {} except Exception: return False - return (_plugin_settings_entry(cfg, self.plugin_id) or {}).get("allow_gateway_injection") is True + + 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, + self, + name: str, + help: str, + setup_fn: Callable, + handler_fn: Callable | None = None, description: str = "", ) -> PluginRegistration: - """Register a CLI subcommand (``hermes ...``). *setup_fn* receives the argparse - subparser; *handler_fn* becomes ``set_defaults(func=...)``.""" + """Register a CLI subcommand (e.g. ``hermes honcho ...``). + + The *setup_fn* receives an argparse subparser and should add any + arguments/sub-subparsers. If *handler_fn* is provided it is set + as the default dispatch function via ``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.plugin_id, + "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, } - return self._register_entry("cli_command", name, self._manager._cli_commands, entry, - "Plugin %s registered CLI command: %s", name) + self._manager._cli_commands[name] = entry + handle = self._track_replacement( + "cli_command", + name, + slot=("manager_mapping", id(self._manager._cli_commands), name), + current=entry, + previous=previous, + restore=lambda replacement: self._manager._restore_mapping( + self._manager._cli_commands, name, entry, replacement + ), + ) + 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, + self, + name: str, + handler: Callable, + description: str = "", + args_hint: str = "", ) -> Optional[PluginRegistration]: - """Register an in-session slash command (``/name``); handler ``fn(raw_args: str) -> str | None`` - (sync or async). ``args_hint`` (e.g. ``""``) lets adapters like Discord surface an argument - field; without it the command registers parameterless there but still accepts trailing text.""" + """Register a slash command (e.g. ``/lcm``) available in CLI and gateway sessions. + + The handler signature is ``fn(raw_args: str) -> str | None``. + It may also be an async callable — the gateway dispatch handles both. + + Unlike ``register_cli_command()`` (which creates ``hermes `` + terminal commands), this registers in-session slash commands that users + invoke during a conversation. + + ``args_hint`` is an optional short string (e.g. ``""`` or + ``"dias:7 formato:json"``) used by gateway adapters to surface the + command with an argument field — for example Discord's native slash + command picker. Plugin commands without ``args_hint`` register as + parameterless in Discord and still accept trailing text when invoked + as free-form chat. + + Names conflicting with built-in commands are rejected with a warning. + """ 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) + logger.warning( + "Plugin '%s' tried to register a command with an empty name.", + self.manifest.name, + ) return - with suppress(Exception): # reject if it conflicts with a built-in command + + # 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) + logger.warning( + "Plugin '%s' tried to register command '/%s' which conflicts " + "with a built-in command. Skipping.", + self.manifest.name, clean, + ) return - hint = (args_hint or "").strip() + except Exception: + pass # If commands module isn't available, skip the check + + previous = self._manager._plugin_commands.get(clean) entry = { - "handler": handler, "description": description or "Plugin command", - "plugin": self.manifest.name, "plugin_key": self.plugin_id, "args_hint": hint, - "argument_mode": argument_mode if argument_mode in {"options", "text", "mixed"} - else ("text" if hint else None), + "handler": handler, + "description": description or "Plugin command", + "plugin": self.manifest.name, + "plugin_key": self.manifest.key or self.manifest.name, + "args_hint": (args_hint or "").strip(), } - return self._register_entry("command", clean, self._manager._plugin_commands, entry, - "Plugin %s registered command: /%s", clean) + self._manager._plugin_commands[clean] = entry + handle = self._track_replacement( + "command", + clean, + slot=("manager_mapping", id(self._manager._plugin_commands), clean), + current=entry, + previous=previous, + restore=lambda replacement: self._manager._restore_mapping( + self._manager._plugin_commands, clean, entry, replacement + ), + ) + 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.""" + """Dispatch a tool call through the registry, with parent agent context. + + This is the public interface for plugin slash commands that need to call + tools like ``delegate_task`` without reaching into framework internals. + The parent agent (if available) is resolved automatically — plugins never + need to access the agent directly. + + Args: + tool_name: Registry name of the tool (e.g. ``"delegate_task"``). + args: Tool arguments dict (same as what the model would pass). + **kwargs: Extra keyword args forwarded to the registry dispatch. + + Returns: + JSON string from the tool handler (same format as model tool calls). + """ from tools.registry import registry - # In gateway mode _cli_ref is None — tools degrade gracefully (no spinner, TERMINAL_CWD). + + # Wire up parent agent context when available (CLI mode). + # In gateway mode _cli_ref is None — tools degrade gracefully + # (workspace hints fall back to TERMINAL_CWD, no spinner). if "parent_agent" not in kwargs: - agent = getattr(self._manager._cli_ref, "agent", None) + 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) + + 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.""" + """Register a context engine to replace the built-in ContextCompressor. + + Only one context engine plugin is allowed. If a second plugin tries + to register one, it is rejected with a warning. + + The engine must be an instance of ``agent.context_engine.ContextEngine``. + """ 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) + 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 + # Defer the import to avoid circular deps at module level from agent.context_engine import ContextEngine - if self._wrong_type(engine, ContextEngine, "context engine"): + 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 # always None here; kept for the restore contract + 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), + "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, ) - 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 ``@:``. Built-in prefixes (diff, staged, file, folder, git, url) are - rejected.""" + """Register a custom @-prefix context reference provider. + + ``provider`` must be an instance of + :class:`agent.context_references.ContextReferenceProvider`. The + ``provider.prefix`` attribute defines the @-prefix (e.g. ``"issue"`` + creates ``@issue:...``). Built-in prefixes (diff, staged, file, + folder, git, url) are reserved and will be rejected. + """ from agent.context_references import ( - ContextReferenceProvider as _CRP, register_context_reference_provider as _register, + ContextReferenceProvider as _CRP, + register_context_reference_provider as _register, ) - if self._wrong_type(provider, _CRP, "context reference provider"): + + 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) + 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) + 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.""" + """Register a memory provider. + + Memory providers are activated exclusively, by name, through + ``memory.provider`` in config.yaml, and ``plugins/memory/__init__.py`` + owns that path with its own collector. A provider reaching *this* + implementation is therefore one the general PluginManager loaded — it + was not classified ``exclusive`` — so the call is recorded and + otherwise inert. Without it, such a plugin's ``register()`` dies on a + missing attribute and the plugin fails to load at all. + + Memory was the only provider category with no ``register_*`` here, + which is what made that failure mode possible. The provider must be an + instance of ``agent.memory_provider.MemoryProvider``. + """ from agent.memory_provider import MemoryProvider - if self._wrong_type(provider, MemoryProvider, "memory provider"): + + 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", "?")) + 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 image generation backend. + + ``provider`` must be an instance of + :class:`agent.image_gen_provider.ImageGenProvider`. The + ``provider.name`` attribute is what ``image_gen.provider`` in + ``config.yaml`` matches against when routing ``image_generate`` + tool calls. + """ + from agent.image_gen_provider import ImageGenProvider + from agent.image_gen_registry import ( + register_provider, + restore_registration, + snapshot_registration, + ) + + if not isinstance(provider, ImageGenProvider): + logger.warning( + "Plugin '%s' tried to register an image_gen provider that does " + "not inherit from ImageGenProvider. Ignoring.", + self.manifest.name, + ) + return + registry_name = provider.name.strip() + scope = self._manager.scope_key + previous = snapshot_registration(registry_name, scope=scope) + register_provider(provider, scope=scope) + registered = snapshot_registration(registry_name, scope=scope) + if registered is not provider: + return None + handle = self._track_replacement( + "image_gen_provider", + registry_name, + slot=("image_gen_provider", scope, registry_name), + current=provider, + previous=previous, + restore=lambda replacement: restore_registration( + registry_name, provider, replacement, scope=scope + ), + ) + logger.info( + "Plugin '%s' registered image_gen provider: %s", + self.manifest.name, registry_name, + ) + return handle + + # -- 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 self._wrong_type(provider, DashboardAuthProvider, "dashboard-auth provider"): + """Register a dashboard authentication provider. + + ``provider`` must be an instance of + :class:`hermes_cli.dashboard_auth.DashboardAuthProvider`. Used by + the dashboard OAuth auth gate, which engages when the dashboard + binds to a non-loopback host without ``--insecure``. + + Misbehaving providers (wrong type, duplicate name) are logged at + WARNING and silently ignored — never raised — so a broken plugin + cannot crash the host. Same convention as + ``register_image_gen_provider``. + """ + from hermes_cli.dashboard_auth import ( + DashboardAuthProvider, + register_provider, + ) + from hermes_cli.dashboard_auth.registry import restore_registration + from hermes_cli.dashboard_auth.registry import snapshot_registration + + 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``). + scope = self._manager.scope_key + previous = snapshot_registration(registry_name, scope=scope) try: - # A per-home manager is torn down routinely (profile-scoped dashboard activity, force - # re-discovery), and disposing this registration on that teardown emptied the auth registry for - # the WHOLE process, permanently disabling sign-in until restart (#91701). The handle still - # disposes explicitly (identity- conditional), and a forced re-discovery rotates the provider in - # place via the upsert. - register_global_provider(provider) + register_provider(provider, scope=scope) except (TypeError, ValueError) as e: - logger.warning("Plugin '%s' failed to register dashboard-auth provider %r: %s", - self.manifest.name, getattr(provider, "name", "?"), e) + logger.warning( + "Plugin '%s' failed to register dashboard-auth provider " + "%r: %s", + self.manifest.name, getattr(provider, "name", "?"), e, + ) + return + registered = snapshot_registration(registry_name, scope=scope) + if registered is not provider: + return None + handle = self._track_replacement( + "dashboard_auth_provider", + registry_name, + slot=("dashboard_auth_provider", scope, registry_name), + current=provider, + previous=previous, + restore=lambda replacement: restore_registration( + registry_name, provider, replacement, scope=scope + ), + ) + 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 a video generation backend. + + ``provider`` must be an instance of + :class:`agent.video_gen_provider.VideoGenProvider`. The + ``provider.name`` attribute is what ``video_gen.provider`` in + ``config.yaml`` matches against when routing ``video_generate`` + tool calls. + """ + from agent.video_gen_provider import VideoGenProvider + from agent.video_gen_registry import ( + register_provider as _register_video_provider, + restore_registration, + snapshot_registration, + ) + + if not isinstance(provider, VideoGenProvider): + logger.warning( + "Plugin '%s' tried to register a video_gen provider that does " + "not inherit from VideoGenProvider. Ignoring.", + self.manifest.name, + ) + return + registry_name = provider.name.strip() + scope = self._manager.scope_key + previous = snapshot_registration(registry_name, scope=scope) + _register_video_provider(provider, scope=scope) + registered = snapshot_registration(registry_name, scope=scope) + if registered is not provider: + return None + handle = self._track_replacement( + "video_gen_provider", + registry_name, + slot=("video_gen_provider", scope, registry_name), + current=provider, + previous=previous, + restore=lambda replacement: restore_registration( + registry_name, provider, replacement, scope=scope + ), + ) + logger.info( + "Plugin '%s' registered video_gen provider: %s", + self.manifest.name, registry_name, + ) + return handle + + # -- web search/extract provider registration ---------------------------- + + @_serialized_replacement + def register_web_search_provider(self, provider) -> Optional[PluginRegistration]: + """Register a web search/extract backend. + + ``provider`` must be an instance of + :class:`agent.web_search_provider.WebSearchProvider`. The + ``provider.name`` attribute is what ``web.search_backend`` / + ``web.extract_backend`` / ``web.backend`` in ``config.yaml`` + matches against when routing ``web_search`` / ``web_extract`` + tool calls. + """ + from agent.web_search_provider import WebSearchProvider + from agent.web_search_registry import ( + register_provider as _register_web_provider, + restore_registration, + snapshot_registration, + ) + + if not isinstance(provider, WebSearchProvider): + logger.warning( + "Plugin '%s' tried to register a web provider that does " + "not inherit from WebSearchProvider. Ignoring.", + self.manifest.name, + ) + return + registry_name = provider.name.strip() + scope = self._manager.scope_key + previous = snapshot_registration(registry_name, scope=scope) + _register_web_provider(provider, scope=scope) + registered = snapshot_registration(registry_name, scope=scope) + if registered is not provider: + return None + handle = self._track_replacement( + "web_search_provider", + registry_name, + slot=("web_search_provider", scope, registry_name), + current=provider, + previous=previous, + restore=lambda replacement: restore_registration( + registry_name, provider, replacement, scope=scope + ), + ) + logger.info( + "Plugin '%s' registered web provider: %s", + self.manifest.name, registry_name, + ) + return handle + + # -- browser provider registration --------------------------------------- + + @_serialized_replacement + def register_browser_provider(self, provider) -> Optional[PluginRegistration]: + """Register a cloud browser backend. + + ``provider`` must be an instance of + :class:`agent.browser_provider.BrowserProvider`. The + ``provider.name`` attribute is what ``browser.cloud_provider`` in + ``config.yaml`` matches against when routing cloud-mode + ``browser_*`` tool calls. + + Mirrors :meth:`register_web_search_provider` exactly — same + registration shape, same gating, same logging. The browser + subsystem's dispatcher (:func:`tools.browser_tool._get_cloud_provider`) + consults the registry built up by these calls. + """ + from agent.browser_provider import BrowserProvider + from agent.browser_registry import ( + register_provider as _register_browser_provider, + restore_registration, + snapshot_registration, + ) + + if not isinstance(provider, BrowserProvider): + logger.warning( + "Plugin '%s' tried to register a browser provider that does " + "not inherit from BrowserProvider. Ignoring.", + self.manifest.name, + ) + return + registry_name = provider.name.strip() + scope = self._manager.scope_key + previous = snapshot_registration(registry_name, scope=scope) + _register_browser_provider(provider, scope=scope) + registered = snapshot_registration(registry_name, scope=scope) + if registered is not provider: + return None + handle = self._track_replacement( + "browser_provider", + registry_name, + slot=("browser_provider", scope, registry_name), + current=provider, + previous=previous, + restore=lambda replacement: restore_registration( + registry_name, provider, replacement, scope=scope + ), + ) + logger.info( + "Plugin '%s' registered browser provider: %s", + self.manifest.name, registry_name, + ) + return handle + + # -- secret source registration ------------------------------------------- + + @_serialized_replacement + def register_secret_source(self, source) -> Optional[PluginRegistration]: + """Register an external secret-manager backend. + + ``source`` must be an instance of + :class:`agent.secret_sources.base.SecretSource`. Registered + sources run during ``load_hermes_dotenv()`` startup — after + ``~/.hermes/.env`` loads, before Hermes reads credentials — when + their ``secrets.`` config section is enabled. The + orchestrator (``agent.secret_sources.registry.apply_all``) owns + ordering, mapped-vs-bulk precedence, conflict warnings, and + provenance; the source only fetches. + + NOTE ON TIMING: ``load_hermes_dotenv()`` usually runs at import + *before* plugin discovery. After discovery completes, the plugin + manager re-pulls enabled plugin secret sources (``reset_secret_source_cache`` + + ``load_hermes_dotenv``) so the first process sees them (#64177). + Child processes that load env after plugins still work without that + re-pull. Failed re-pulls never block startup. + + Contract requirements (rejected with a warning otherwise): + inherit from ``SecretSource``, ``api_version`` matching + ``SECRET_SOURCE_API_VERSION``, lowercase unique ``name``, + ``shape`` of ``"mapped"`` or ``"bulk"``, unique ``scheme`` (when + set), and a ``fetch()`` that never raises and never prompts. + See the base-module docstring for the full contract. + """ + from agent.secret_sources.base import SecretSource + from agent.secret_sources.registry import ( + register_source, + restore_registration, + snapshot_registration, + ) + + if not isinstance(source, SecretSource): + logger.warning( + "Plugin '%s' tried to register a secret source that does " + "not inherit from SecretSource. Ignoring.", + self.manifest.name, + ) + return + registry_name = source.name + scope = self._manager.scope_key + previous = snapshot_registration(registry_name, scope=scope) + if register_source(source, scope=scope): + registered = snapshot_registration(registry_name, scope=scope) + if registered is not source: + return None + handle = self._track_replacement( + "secret_source", + registry_name, + slot=("secret_source", scope, registry_name), + current=source, + previous=previous, + restore=lambda replacement: restore_registration( + registry_name, source, replacement, scope=scope + ), + ) + logger.info( + "Plugin '%s' registered secret source: %s", + self.manifest.name, registry_name, + ) + return handle + return None + + # -- TTS provider registration ------------------------------------------- + + @_serialized_replacement + def register_tts_provider(self, provider) -> Optional[PluginRegistration]: + """Register a text-to-speech backend. + + ``provider`` must be an instance of + :class:`agent.tts_provider.TTSProvider`. The ``provider.name`` + attribute is what ``tts.provider`` in ``config.yaml`` matches + against when routing ``text_to_speech`` tool calls — **but + only when**: + + 1. ``provider.name`` is NOT a built-in TTS provider name + (``edge``, ``openai``, ``elevenlabs``, …). Built-ins always + win — the registry rejects shadowing names with a warning. + 2. There is NO ``tts.providers.: type: command`` entry + with the same name. Command-providers (PR #17843) win on + name collision because config is more local than plugin + install. + + Coexists with the command-provider registry rather than + replacing it — see issue #30398 for the full design rationale. + """ + from agent.tts_provider import TTSProvider + from agent.tts_registry import ( + register_provider as _register_tts_provider, + restore_registration, + snapshot_registration, + ) + + if not isinstance(provider, TTSProvider): + logger.warning( + "Plugin '%s' tried to register a TTS provider that does " + "not inherit from TTSProvider. Ignoring.", + self.manifest.name, + ) + return + registry_name = provider.name.strip().lower() + scope = self._manager.scope_key + previous = snapshot_registration(registry_name, scope=scope) + _register_tts_provider(provider, scope=scope) + registered = snapshot_registration(registry_name, scope=scope) + if registered is not provider: + return None + handle = self._track_replacement( + "tts_provider", + registry_name, + slot=("tts_provider", scope, registry_name), + current=provider, + previous=previous, + restore=lambda replacement: restore_registration( + registry_name, provider, replacement, scope=scope + ), + ) + logger.info( + "Plugin '%s' registered TTS provider: %s", + self.manifest.name, registry_name, + ) + return handle + + # -- transcription (STT) provider registration --------------------------- + + @_serialized_replacement + def register_transcription_provider(self, provider) -> Optional[PluginRegistration]: + """Register a speech-to-text backend. + + ``provider`` must be an instance of + :class:`agent.transcription_provider.TranscriptionProvider`. + The ``provider.name`` attribute is what ``stt.provider`` in + ``config.yaml`` matches against when routing + :func:`tools.transcription_tools.transcribe_audio` calls — + **but only when**: + + 1. ``provider.name`` is NOT a built-in STT provider name + (``local``, ``local_command``, ``groq``, ``openai``, + ``mistral``, ``xai``). Built-ins always win — the registry + rejects shadowing names with a warning. + 2. There is NO ``stt.providers.: type: command`` entry + with the same name. Command-providers win on name + collision because config is more local than plugin install + — same precedence rule as TTS. + + Coexists with the in-tree dispatcher and the STT + command-provider registry rather than replacing them. The 6 + built-in STT backends keep their native implementations in + ``tools/transcription_tools.py``; this hook is for *new* Python + engines (OpenRouter, SenseAudio, Gemini-STT, custom proprietary + backends). + """ + from agent.transcription_provider import TranscriptionProvider + from agent.transcription_registry import ( + register_provider as _register_stt_provider, + restore_registration, + snapshot_registration, + ) + + if not isinstance(provider, TranscriptionProvider): + logger.warning( + "Plugin '%s' tried to register a transcription provider that " + "does not inherit from TranscriptionProvider. Ignoring.", + self.manifest.name, + ) 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) + registry_name = provider.name.strip().lower() + scope = self._manager.scope_key + previous = snapshot_registration(registry_name, scope=scope) + _register_stt_provider(provider, scope=scope) + registered = snapshot_registration(registry_name, scope=scope) + if registered is not provider: + return None + handle = self._track_replacement( + "transcription_provider", + registry_name, + slot=("transcription_provider", scope, registry_name), + current=provider, + previous=previous, + restore=lambda replacement: restore_registration( + registry_name, provider, replacement, scope=scope + ), + ) + logger.info( + "Plugin '%s' registered transcription provider: %s", + self.manifest.name, registry_name, + ) return handle + # -- 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, + 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); an ACTIVE installer goes in ``ensure_deps_fn`` (called 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.""" + """Register a gateway platform adapter. + + The adapter_factory receives a ``PlatformConfig`` and returns a + ``BasePlatformAdapter`` subclass instance. + + ``check_fn`` is a PASSIVE dependency probe — "are deps importable + right now?". It must never install anything: status displays and + config loading call it freely. If your platform's SDK is + lazy-installable, pass the ACTIVE installer separately as + ``ensure_deps_fn`` (forwarded via ``entry_kwargs``); the gateway + calls it from ``create_adapter()`` when ``check_fn`` is False, + right before connecting the platform. + + Extra keyword arguments are forwarded to ``PlatformEntry`` (e.g. + ``setup_fn``, ``emoji``, ``allowed_users_env``, ``platform_hint``, + ``ensure_deps_fn``). Unknown keys raise TypeError from the + dataclass constructor. + + Example:: + + ctx.register_platform( + name="irc", + label="IRC", + adapter_factory=lambda cfg: IRCAdapter(cfg), + check_fn=lambda: True, + emoji="💬", + setup_fn=irc_interactive_setup, + ) + """ 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, + 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) @@ -791,652 +2857,2695 @@ def register_platform( 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, + handle = self._track_replacement( + "platform", + name, + slot=("platform", scope, name), + current=current, + previous=previous, + restore=lambda replacement: self._restore_platform_registration( + platform_registry, name, current, replacement, scope + ), finalize=lambda: self._manager._remove_platform_name_if_unowned(name), ) - logger.debug("Plugin %s registered platform: %s", self.manifest.name, name) + logger.debug( + "Plugin %s registered platform: %s", + self.manifest.name, + name, + ) return handle + def _restore_platform_registration( + self, + platform_registry, + name: str, + current, + replacement, + scope: str, + ) -> bool: + return platform_registry.restore_registration( + name, current, replacement, scope=scope + ) + + # -- slack action handler registration ---------------------------------- + def register_slack_action_handler( - self, action_id: Any, callback: Callable, + 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``.""" + """Register a Slack Block Kit action handler from a plugin. + + Hermes' Slack adapter wires registered handlers into its + ``slack_bolt.AsyncApp`` at connect time. The callback is invoked + when a user clicks a button (or interacts with another Block Kit + action element) whose ``action_id`` matches. + + Callback signature follows the slack_bolt convention:: + + async def handler(ack, body, action) -> None: + await ack() # required, within 3 seconds + ... + + Args: + action_id: Whatever ``slack_bolt.App.action()`` accepts — + a literal ``action_id`` string, a compiled ``re.Pattern`` + for matching multiple ids, or a constraint dict + (e.g. ``{"action_id": "...", "block_id": "..."}``). + callback: Async callable receiving ``(ack, body, action)``. + + Raises: + ValueError: if ``callback`` is not callable, or ``action_id`` + is empty/None. + + Example:: + + async def _on_approve(ack, body, action): + await ack() + # apply some workflow keyed on action["value"] + + ctx.register_slack_action_handler("inbox_sweep_approve", _on_approve) + """ if not callable(callback): - raise self._refuse("a Slack action handler with a non-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 self._refuse("a Slack action handler with an empty action_id") + 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) - handlers = self._manager._slack_action_handlers - handlers.append(entry) - handle = self._track("slack_action_handler", repr(action_id), - lambda: self._manager._remove_identity(handlers, entry)) - logger.debug("Plugin %s registered Slack action handler: %s", self.manifest.name, action_id) + 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 - def register_platform_handler(self, platform: str, factory: Callable) -> None: - """Register ``factory(native, adapter)``, invoked at ``connect()`` 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. Scope handlers in - first-match dispatch tables so core flows keep working. Raises ``ValueError`` when not callable - or platform is empty.""" - if not callable(factory): - raise self._refuse("a platform handler factory with a non-callable factory") - key = (platform or "").strip().lower() - if not key: - raise self._refuse("a platform 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))) - - def register_telegram_handler(self, factory: Callable) -> None: - """``register_platform_handler("telegram", factory)``. 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.""" - self.register_platform_handler("telegram", factory) + # -- hook registration -------------------------------------------------- + + # -- auxiliary task registration --------------------------------------- @_serialized_replacement def register_auxiliary_task( - self, key: str, *, display_name: str, description: str, + self, + key: str, + *, + display_name: str, + description: str, defaults: Optional[Dict[str, Any]] = None, ) -> PluginRegistration: - """Register an auxiliary LLM task with its own ``auxiliary.`` config block (picker entry, - ``AUXILIARY__*`` env bridge, defaults merged into loaded configs). ``defaults`` may - override provider/model/base_url/api_key/timeout/extra_body (unknown keys kept verbatim). - Raises ``ValueError`` for an empty/invalid key, a built-in key, or another plugin's key.""" - me = self.manifest.name + """Register a plugin-defined auxiliary LLM task. + + Auxiliary tasks are LLM-backed side jobs (vision analysis, web extraction, + compression, smart-approval, etc.) that route through ``auxiliary_client.py``. + Each task has its own ``auxiliary.`` config block where users can + pin a provider/model independent of the main chat model. + + Plugins use this to declare their own auxiliary tasks without touching + core files. After registration, the task: + + - Appears in the ``hermes model → Configure auxiliary models`` picker + - Has its provider/model/base_url/api_key bridged from config.yaml to + ``AUXILIARY__*`` env vars at gateway startup + - Gets default routing fields (provider="auto", model="", etc.) merged + into loaded configs so ``cfg.get("auxiliary", {}).get(key)`` works + + Args: + key: stable task key (snake_case). Used in config ``auxiliary.`` + and env vars ``AUXILIARY__*``. Must not shadow a + built-in task key (vision, compression, web_extract, approval, + mcp, title_generation, skills_hub, curator). + display_name: human-readable name shown in the picker. + description: short one-line description shown next to the name. + defaults: optional dict of default routing fields. Recognized keys: + ``provider`` (default "auto"), ``model`` (default ""), + ``base_url`` (default ""), ``api_key`` (default ""), + ``timeout`` (default 60), ``extra_body`` (default {}), + plus any task-specific extras (e.g. ``download_timeout``). + Unknown keys are preserved verbatim — the plugin owns the + schema for its own task. + + Raises: + ValueError: if *key* is empty, contains invalid characters, or + shadows a built-in auxiliary task key. + + Example: + ctx.register_auxiliary_task( + key="memory_retain_filter", + display_name="Memory retain filter", + description="hindsight pre-retain dedup/extract", + defaults={"provider": "auto", "timeout": 30}, + ) + """ + # Validate key shape if not key or not isinstance(key, str): - raise ValueError(f"Plugin '{me}' tried to register auxiliary task with invalid key {key!r}") + 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 '{me}' auxiliary task key {key!r} " - f"must contain only alphanumeric characters and underscores") - from hermes_cli.main_provider_setup import _AUX_TASKS as _BUILTIN_AUX_TASKS - if key in {k for k, _name, _desc in _BUILTIN_AUX_TASKS}: - raise ValueError(f"Plugin '{me}' cannot register auxiliary task {key!r} — that key is reserved " - f"for a built-in task. Pick a plugin-namespaced key (e.g. '{me}_{key}').") - # Owner is the canonical id ``ctx.llm`` is bound to, so agent/plugin_llm.py can match it. - owner_id = self.plugin_id - existing = self._manager._aux_tasks.get(key) + raise ValueError( + f"Plugin '{self.manifest.name}' auxiliary task key {key!r} " + f"must contain only alphanumeric characters and underscores" + ) + + # Lazy import to avoid circular: hermes_cli.main imports plugins indirectly + 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 plugin's canonical id (``key or name``) — the same id + # ``ctx.llm`` is bound to (PluginLlm._plugin_id) — so the task trust + # gate in agent/plugin_llm.py can match ownership. For the common + # case where a manifest sets no explicit ``key`` this equals the name. + owner_id = self.manifest.key or self.manifest.name + + # Reject duplicate registrations across plugins + existing = self._manager._aux_tasks.get(key) if existing is not None and existing.get("plugin") != owner_id: - raise ValueError(f"Plugin '{me}' cannot register auxiliary task {key!r} — already registered " - f"by plugin '{existing.get('plugin')}'") - # Plugin owns the schema; routing fields are guaranteed present so consumers don't crash. - entry = { - "key": key, "display_name": display_name, "description": description, - "defaults": {"provider": "auto", "model": "", "base_url": "", "api_key": "", "timeout": 60, - "extra_body": {}, **(defaults or {})}, - "plugin": owner_id, "plugin_key": owner_id, + raise ValueError( + f"Plugin '{self.manifest.name}' cannot register auxiliary task " + f"{key!r} — already registered by plugin " + f"'{existing.get('plugin')}'" + ) + + # Normalize defaults — plugin owns the schema, but we ensure routing + # fields exist with sensible types so consumers don't crash. + merged_defaults: Dict[str, Any] = { + "provider": "auto", + "model": "", + "base_url": "", + "api_key": "", + "timeout": 60, + "extra_body": {}, + } + if defaults: + for k, v in defaults.items(): + merged_defaults[k] = v + + self._manager._aux_tasks[key] = { + "key": key, + "display_name": display_name, + "description": description, + "defaults": merged_defaults, + "plugin": owner_id, + "plugin_key": owner_id, } - return self._register_entry("auxiliary_task", key, self._manager._aux_tasks, entry, - "Plugin %s registered auxiliary task: %s (%s)", key, display_name, - previous=existing) + entry = self._manager._aux_tasks[key] + handle = self._track_replacement( + "auxiliary_task", + key, + slot=("manager_mapping", id(self._manager._aux_tasks), key), + current=entry, + previous=existing, + restore=lambda replacement: self._manager._restore_mapping( + self._manager._aux_tasks, key, entry, replacement + ), + ) + 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. - 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.""" + """Additively register secret-token regexes with the redaction engine. + + Accepted patterns join the vendor-prefix alternation in + :mod:`agent.redact` and are masked everywhere built-in patterns + apply — logs, terminal output, transport errors, transcripts — + with the same head/tail masking and the same non-reusable + sentinel on ``file_read`` content. Historically every new vendor + token format required a core PR appending to + ``_PREFIX_PATTERNS``; provider plugins should own their own + format instead. + + The registry is **additive-only**: plugins can extend what gets + masked but cannot remove or weaken built-in patterns, so a + plugin can only ever over-redact, never expose. The operator's + global opt-out (``security.redact_secrets: false``) applies to + plugin patterns exactly as it does to built-ins. + + Each pattern must compile as a regex and start with at least 2 + literal characters (e.g. ``r"nvapi-[A-Za-z0-9_-]{20,}"``). + Invalid entries are warned and skipped — never raised. + + Returns the number of patterns accepted. + """ from agent.redact import register_redaction_patterns as _register + try: - count = _register(patterns, source=f"plugin:{self.manifest.name}") + 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) + 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) + 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) + """Register a lifecycle hook callback. - 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 + Unknown hook names produce a warning but are still stored so + forward-compatible plugins don't break. + """ + if hook_name not in VALID_HOOKS: + logger.warning( + "Plugin '%s' registered unknown hook '%s' " + "(valid: %s)", + self.manifest.name, + hook_name, + ", ".join(sorted(VALID_HOOKS)), + ) + callbacks = self._manager._hooks.setdefault(hook_name, []) + callbacks.append(callback) + handle = self._track( + "hook", hook_name, + lambda: self._manager._remove_callback( + self._manager._hooks, hook_name, callback + ), ) - - 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) + logger.debug("Plugin %s registered hook: %s", self.manifest.name, hook_name) 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, + 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.""" + """Register bounded context that is frozen into each new session prompt. + + Callables receive a read-only session-info mapping. The rendered full + system prompt is already persisted by core and restored verbatim, so no + parallel plugin-section state is needed for process restarts. + """ 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 '-'") + 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(f"system prompt section max_chars must be between 1 and {MAX_SYSTEM_PROMPT_SECTION_CHARS}") + 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 plugin {existing.plugin!r}") + 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=self.plugin_id, + id=id, + content=content, + position=position, + max_chars=max_chars, + plugin=plugin_id, ) - return self._register_entry("system_prompt_section", id, self._manager._system_prompt_sections, - section, "Plugin %s registered system prompt section: %s", id, - previous=existing) + self._manager._system_prompt_sections[id] = section + # Record ownership so unload/force-reload removes this section. + # Duplicate ids are rejected above (raise), so there is never a + # displaced previous entry to restore. The parameter ``id`` shadows + # the builtin, so capture the mapping identity via ``builtins.id``. + import builtins + + handle = self._track_replacement( + "system_prompt_section", + id, + slot=( + "manager_mapping", + builtins.id(self._manager._system_prompt_sections), + id, + ), + current=section, + previous=existing, + restore=lambda replacement: self._manager._restore_mapping( + self._manager._system_prompt_sections, id, section, replacement + ), + ) + 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 ``:`` (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.plugin_id + """Publish *event* to all subscribers; return the number invoked. + + The event is delivered as ``:`` where + ``plugin_key`` is FORCED to this plugin's own registry key + (``manifest.key or manifest.name``). Pass only the bare event name — + a plugin may only publish under its own namespace. + + Passing an already-namespaced name (anything containing ``':'``, + including ``hermes:x`` or a foreign ``other:x``) is rejected with a + ``ValueError`` and a logged warning — fail-closed. The ``hermes:`` + prefix is reserved for core. + + Delivery is fire-and-forget through a host-owned, single-worker queue: + registration order is preserved, while a blocking subscriber cannot + stall the emitter. The queue has a bounded pending budget; a full + budget drops the new event with a warning. Each subscriber receives a + deep-copied payload and is isolated in its own ``try/except``. Awaitable + results are resolved through the existing loop-safe plugin path. + + Returns the count of subscriber callbacks scheduled (0 when there are + no subscribers, or when the pending/recursion budget drops the emit). + """ + 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") + 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 bare event name; " - f"the namespace is forced to '{plugin_key}:' and the '{HERMES_EVENT_NAMESPACE}:' " - f"prefix is reserved for core") + 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") - return self._manager._dispatch_event(f"{plugin_key}:{event}", payload or {}) + 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 ``:`` name (unrestricted — only - emitting is namespace-gated). Owner-tagged so unload removes zombie callbacks.""" + """Subscribe *callback* to a fully-qualified event name. + + *event* is the full ``:`` name (or ``hermes:`` + if core ever emits). Subscribing is unrestricted — any plugin may + listen to any published event; only *emitting* is namespace-gated. + + Callbacks are stored in registration order as host-owned ledger + entries. The owner key lets plugin unload/reload remove subscriptions + before any later event can invoke a zombie callback. + """ if not event or not isinstance(event, str): - raise ValueError(f"Plugin '{self.manifest.name}' subscribe() requires a non-empty event name") - self._manager._subscribe_event(self.plugin_id, event, callback) - logger.debug("Plugin %s subscribed to event: %s", self.manifest.name, event) + 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 a behavior-changing middleware callback. + + Middleware is separate from observer hooks: request middleware may + rewrite the effective payload, and execution middleware may wrap the + real callback. Unknown kinds are stored for forward compatibility but + warned so plugin authors can catch typos. + """ + if kind not in VALID_MIDDLEWARE: + logger.warning( + "Plugin '%s' registered unknown middleware '%s' " + "(valid: %s)", + self.manifest.name, + kind, + ", ".join(sorted(VALID_MIDDLEWARE)), + ) + callbacks = self._manager._middleware.setdefault(kind, []) + callbacks.append(callback) + handle = self._track( + "middleware", kind, + lambda: self._manager._remove_callback( + self._manager._middleware, kind, callback + ), + ) + logger.debug("Plugin %s registered middleware: %s", self.manifest.name, kind) + return handle + + # -- skill registration ------------------------------------------------- @_serialized_replacement def register_skill( - self, name: str, path: Path, description: str = "", + self, + name: str, + path: Union[str, Path], + description: str = "", frontmatter: Optional[Mapping[str, Any]] = None, ) -> PluginRegistration: - """Register a read-only skill resolvable as ``':'`` via ``skill_view()``. - Not in ``~/.hermes/skills/`` nor ```` — explicit loads only. Raises - ``ValueError`` (``':'``/invalid chars) or ``FileNotFoundError``.""" + """Register a read-only skill provided by this plugin. + + The skill becomes resolvable as ``':'`` via + ``skill_view()``. It does **not** enter the flat + ``~/.hermes/skills/`` tree and is **not** listed in the system + prompt's ```` index — plugin skills are + opt-in explicit loads only. + + *path* may be a ``Path`` or a filesystem string. String paths are + coerced so plugin ``register()`` helpers that pass ``str`` do not + crash load with ``'str' object has no attribute 'exists'``. + + Raises: + ValueError: if *name* contains ``':'`` or invalid characters, + or *path* is not a usable filesystem location. + FileNotFoundError: if *path* does not exist. + """ from agent.skill_utils import _NAMESPACE_RE + if ":" in name: - raise ValueError(f"Skill name '{name}' must not contain ':' (the namespace is derived from the " - f"plugin name '{self.manifest.name}' automatically).") + 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_-]+.") + raise ValueError( + f"Invalid skill name '{name}'. Must match [a-zA-Z0-9_-]+." + ) + try: + path = _as_plugin_path(path, what="skill path") + except (TypeError, ValueError) as exc: + raise ValueError(f"Invalid skill path: {exc}") from exc 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.plugin_id, "bare_name": name, - "description": description, "frontmatter": dict(frontmatter or {}), + "path": path, + "plugin": namespace, + "plugin_key": self.manifest.key or self.manifest.name, + "bare_name": name, + "description": description, + "frontmatter": dict(frontmatter or {}), } - return self._register_entry("skill", qualified, self._manager._plugin_skills, entry, - "Plugin %s registered skill: %s", qualified) - - -# -- scoped provider registrars ------------------------------------------------------------------ -# Every ``register__provider`` shares one body (:meth:`PluginContext._register_scoped_provider`): -# type-check, register in the scope-keyed process-global registry, lease the slot so unload restores -# the displaced entry. Rows: (method, kind, registry module, base-class module:attr, label, docstring, -# options). ``normalize``: ``strip`` (default), ``lower`` (strip+lowercase) or ``None`` (raw name). -_SCOPED_PROVIDER_REGISTRARS: Tuple[Tuple[str, str, str, str, str, str, Dict[str, Any]], ...] = ( - ("register_image_gen_provider", "image_gen_provider", "agent.image_gen_registry", - "agent.image_gen_provider:ImageGenProvider", "image_gen provider", - "Register an :class:`agent.image_gen_provider.ImageGenProvider`; " - "``provider.name`` is matched by ``image_gen.provider``.", {"article": "an"}), - ("register_video_gen_provider", "video_gen_provider", "agent.video_gen_registry", - "agent.video_gen_provider:VideoGenProvider", "video_gen provider", - "Register an :class:`agent.video_gen_provider.VideoGenProvider`; " - "``provider.name`` is matched by ``video_gen.provider``.", {}), - ("register_web_search_provider", "web_search_provider", "agent.web_search_registry", - "agent.web_search_provider:WebSearchProvider", "web provider", - "Register an :class:`agent.web_search_provider.WebSearchProvider`; " - "``provider.name`` is matched by ``web.search_backend`` / ``web.extract_backend`` / ``web.backend``.", - {}), - ("register_browser_provider", "browser_provider", "agent.browser_registry", - "agent.browser_provider:BrowserProvider", "browser provider", - "Register an :class:`agent.browser_provider.BrowserProvider`; " - "``provider.name`` is matched by ``browser.cloud_provider`` (consulted by " - "``tools.browser_tool_cloud._get_cloud_provider``).", {}), - ("register_terminal_environment_provider", "terminal_environment_provider", - "agent.terminal_env_registry", "agent.terminal_env_provider:TerminalEnvironmentProvider", - "terminal environment provider", - "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.", - {"normalize": "lower", "reject_message": "Plugin '%s' terminal environment provider rejected: %s"}), - ("register_secret_source", "secret_source", "agent.secret_sources.registry", - "agent.secret_sources.base:SecretSource", "secret source", - "Register a :class:`agent.secret_sources.base.SecretSource`, run by ``load_hermes_dotenv()`` " - "(after ``~/.hermes/.env``, before credentials are read) when ``secrets.`` 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.", - {"normalize": None, "register": "register_source", "param": "source"}), - ("register_tts_provider", "tts_provider", "agent.tts_registry", - "agent.tts_provider:TTSProvider", "TTS provider", - "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.: type: command`` entry shares it (command-providers win).", - {"normalize": "lower"}), - ("register_transcription_provider", "transcription_provider", "agent.transcription_registry", - "agent.transcription_provider:TranscriptionProvider", "transcription provider", - "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.: " - "type: command`` entry shares it (command-providers win).", {"normalize": "lower"}), -) - -_NAME_NORMALIZERS: Dict[Optional[str], Optional[Callable[[str], str]]] = { - "strip": lambda n: n.strip(), "lower": lambda n: n.strip().lower(), None: None, -} - - -def _make_scoped_provider_registrar(method_name, kind, registry_mod, base_ref, label, doc, options): - """Build one ``register__provider`` method from a ``_SCOPED_PROVIDER_REGISTRARS`` row.""" - base_mod, base_attr = base_ref.split(":") - normalize_fn = _NAME_NORMALIZERS[options.get("normalize", "strip")] - register_name = options.get("register") - - def register(self, provider) -> Optional[PluginRegistration]: - registry = importlib.import_module(registry_mod) - return self._register_scoped_provider( - provider, kind=kind, base_class=getattr(importlib.import_module(base_mod), base_attr), - registry=registry, label=label, article=options.get("article", "a"), normalize=normalize_fn, - register=getattr(registry, register_name) if register_name else None, - reject_message=options.get("reject_message"), + self._manager._plugin_skills[qualified] = entry + handle = self._track_replacement( + "skill", + qualified, + slot=("manager_mapping", id(self._manager._plugin_skills), qualified), + current=entry, + previous=previous, + restore=lambda replacement: self._manager._restore_mapping( + self._manager._plugin_skills, qualified, entry, replacement + ), ) + logger.debug( + "Plugin %s registered skill: %s", + self.manifest.name, qualified, + ) + return handle - def register_source(self, source) -> Optional[PluginRegistration]: # secret sources: ``source`` - return register(self, source) - - method = register_source if options.get("param") == "source" else register - method.__name__, method.__qualname__, method.__doc__ = method_name, f"PluginContext.{method_name}", doc - return _serialized_replacement(method) - - -for _row in _SCOPED_PROVIDER_REGISTRARS: - setattr(PluginContext, _row[0], _make_scoped_provider_registrar(*_row)) -del _row +# --------------------------------------------------------------------------- +# PluginManager +# --------------------------------------------------------------------------- -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``).""" - default = _HOOK_CALLBACK_TIMEOUT_SECS - try: - from hermes_cli.config import load_config_readonly - plugins_cfg = (load_config_readonly() or {}).get("plugins") - if not isinstance(plugins_cfg, dict) or plugins_cfg.get("hook_callback_timeout") is None: - return default - timeout = float(plugins_cfg["hook_callback_timeout"]) - except (TypeError, ValueError): - logger.warning("plugins.hook_callback_timeout is not a number; using default %gs", default) - return default - except Exception: - return default - if timeout < 0: - logger.warning("plugins.hook_callback_timeout=%g is negative; using default %gs", timeout, default) - return default - 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 - - -class PluginManager(PluginLoaderMixin, PluginDispatchMixin, PluginLedgerMixin): +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. + # Capture the home immutably. Unload can run from a different ambient + # 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._discovered: bool = False - self._cli_ref = None # Set by CLI after plugin discovery - self._gateway_message_injector: tuple[object, Callable] | None = None - self._context_engine = None # Set by a plugin via register_context_engine() - # Manager-local registries keyed by name (see the matching ``PluginContext.register_*``): - # plugins, hooks, middleware, CLI + slash commands, prompt sections, skills (qualified name -> - # metadata), portable MCP servers, auxiliary tasks, approval transports, Slack action handlers - # (matcher, callback, plugin_name), platform handler factories (lowercase platform -> list). self._plugins: Dict[str, LoadedPlugin] = {} self._hooks: Dict[str, List[Callable]] = {} - # Fallback hooks registered by a memory provider before general discovery. - self._memory_hook_registrations: Dict[Tuple[str, str], List[PluginRegistration]] = {} 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._plugin_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 + # Plugin skill registry: qualified name → metadata dict. self._plugin_skills: Dict[str, Dict[str, Any]] = {} self._portable_mcp_servers: Dict[str, Dict[str, Any]] = {} + # Plugin-registered auxiliary tasks: key → {key, display_name, + # description, defaults, plugin}. See PluginContext.register_auxiliary_task. self._aux_tasks: Dict[str, Dict[str, Any]] = {} + # Explicitly-selected, profile-scoped human approval transports. self._approval_transports: Dict[str, Any] = {} - self._slack_action_handlers: List[tuple] = [] - self._platform_handler_factories: Dict[str, List[tuple]] = {} - # Event bus: owner-tagged subscriptions (unload removes zombies); one daemon worker keeps - # registration order while emitters never block; per-worker chain depth caps mutual emitters. + # Inter-plugin event bus. Subscriptions are owner-tagged ledger entries + # so unload/reload can remove zombie callbacks. A single daemon worker + # preserves registration order while keeping emitters non-blocking. 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_queue: queue.Queue[Any] = queue.Queue( + maxsize=_EVENT_PENDING_CAP + ) self._event_worker: Optional[threading.Thread] = None + # Per-worker chain depth caps mutually-emitting plugins even though each + # re-entrant emit is queued rather than invoked recursively. self._emit_depth = threading.local() - # 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. Persistent registrations that survived an - # unload-all park in ``_persistent_carryover`` until force re-discovery evicts the stale ones. - # Registration handles are kept both per plugin (ownership lookup) and globally (reverse-order - # teardown for overrides spanning plugins). Registry overlays keyed by scope_key (see - # tools/registry.py and gateway/platform_registry.py) carry the profile dimension; anything still - # process-global is guarded by the identity checks. TODO(#64178): extend explicit profile keying to - # any remaining process-global slots when the symmetric force-reload lands. + # Slack Block Kit action handlers registered by plugins. Each entry + # is (matcher, callback, plugin_name); the Slack adapter wires them + # into its slack_bolt App at connect() time. ``matcher`` is whatever + # ``app.action()`` accepts (a literal action_id string, a compiled + # ``re.Pattern``, or a constraint dict); ``callback`` is an async + # function with the slack_bolt signature ``(ack, body, action)``. + self._slack_action_handlers: List[tuple] = [] + # Registration handles are kept both per plugin (ownership lookup) and + # globally (reverse-order teardown for overrides spanning plugins). + # + # Multi-profile constraint (#65593): several process-global registries + # (tools, platforms, providers) are shared across profiles while + # multiple PluginManager instances may coexist in one process (keyed + # by resolved hermes home). The ledger is therefore keyed per manager + # — i.e. per (hermes_home, plugin_id) — and every release/restore + # closure is identity-conditional, so one profile's unload can never + # clear another profile's registrations. Registry overlays keyed by + # scope_key (see tools/registry.py and gateway/platform_registry.py) + # carry the profile dimension; anything still process-global is + # guarded by the identity checks. TODO(#64178): extend explicit + # profile keying to any remaining process-global slots when the + # symmetric force-reload lands. self._ownership_ledger: Dict[str, List[PluginRegistration]] = {} self._registration_order: List[PluginRegistration] = [] - # Force re-discovery drains this via _evict_stale_persistent_registrations(): entries whose plugin - # re-registered the same (kind, key) are kept (the upsert rotated them in place), the rest are - # disposed so a disabled/removed auth plugin's provider does not outlive its plugin (#91701 - # follow-up). - 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). + # Deferred platform plugins whose client tools were registered at + # discovery time (see _register_deferred_platform_tools). Keyed by + # plugin id: the already-imported package module, so materializing the + # adapter later doesn't re-execute it, and the tool names it + # contributed, so `hermes plugins list` still attributes them once the + # full plugin loads. self._predeclared_modules: Dict[str, types.ModuleType] = {} self._predeclared_tools: Dict[str, List[str]] = {} + # ----------------------------------------------------------------------- + # Registration ledger internals + # ----------------------------------------------------------------------- + + def _track_registration( + self, + manifest: PluginManifest, + kind: str, + key: str, + release: Callable[[], None], + ) -> PluginRegistration: + """Record one successful registration under its canonical plugin key.""" + plugin_key = manifest.key or manifest.name + registration = PluginRegistration( + kind=kind, + key=key, + release=release, + plugin_key=plugin_key, + ) + registration._on_dispose = lambda disposed: self._forget_registrations( + [disposed] + ) + self._ownership_ledger.setdefault(plugin_key, []).append(registration) + self._registration_order.append(registration) + return registration + + @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_tool_name_if_unowned(self, name: str) -> None: + if not any( + registration.active + and registration.kind == "tool" + and registration.key == name + for registration in self._registration_order + ): + self._plugin_tool_names.discard(name) + + def _remove_platform_name_if_unowned(self, name: str) -> None: + if not any( + registration.active + and registration.kind == "platform" + and registration.key == name + for registration in self._registration_order + ): + self._plugin_platform_names.discard(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 plugins owned by this manager. + + Every registration made through :class:`PluginContext` is disposed in + reverse acquisition order. Registry inverses are conditional on the + exact object still being current, so a later registration is never + removed accidentally. ``plugin=None`` is the lifecycle operation + used by force rediscovery. ``on_unload`` callbacks and supervised + background tasks registered through :class:`PluginContext` are + disposed through the same reverse-order ledger walk. + + Returns ``True`` when at least one plugin or registration 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: + requested = self._resolve_plugin_key(plugin) + exact = { + requested, + } if requested in self._ownership_ledger or requested in self._plugins else set() + if exact: + target_keys = exact + else: + target_keys = { + key + for key, loaded in self._plugins.items() + if loaded.manifest.name == requested + } + target_keys.update( + key + for key in self._ownership_ledger + if key == requested + ) + registrations = [ + registration + for registration in self._registration_order + if registration.plugin_key in target_keys + ] + + found = bool(target_keys or registrations) + self._dispose_registrations(registrations) + self._forget_registrations(registrations) + + if unload_all: + # The handles are authoritative for global registries, while the + # manager-local containers are also reset to clear legacy/manual + # state that predates the ledger. + # + # Platform names may exist in _plugin_platform_names without a + # ledger entry (state predating the ledger, or set manually in + # long-lived processes). Main's force path always unregistered + # them from the global registry — keep that sweep so disabled + # plugins can't leak parsers/send handlers into the next + # discovery pass. + from gateway.platform_registry import platform_registry + + for platform_name in tuple(self._plugin_platform_names): + platform_registry.unregister(platform_name) + # Symmetric sweep for tools: names in _plugin_tool_names that no + # ledger registration covers (pre-ledger state, or set manually) + # would survive in the process-global tools.registry as zombie + # entries after a force reload (#60050; tracking #64178 — + # extracted from PR #64188). Ledger-owned names are excluded: + # their handles were already disposed above with precise + # previous-entry restoration, and blanket deregistration here + # would remove entries 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, + ) + self._ownership_ledger.clear() + self._plugins.clear() + self._hooks.clear() + self._middleware.clear() + self._plugin_tool_names.clear() + self._plugin_platform_names.clear() + self._cli_commands.clear() + self._plugin_commands.clear() + self._plugin_skills.clear() + self._portable_mcp_servers.clear() + self._aux_tasks.clear() + self._system_prompt_sections.clear() + self._approval_transports.clear() + self._slack_action_handlers.clear() + self._predeclared_modules.clear() + self._predeclared_tools.clear() + self._context_engine = None + self._discovered = False + else: + for key in target_keys: + self._plugins.pop(key, None) + + return found + + # ----------------------------------------------------------------------- + # 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: + 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``.""" - if self._gateway_message_injector is not None and self._gateway_message_injector[0] is 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 - return registered is not None and bool(registered[1](**kwargs)) + 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.""" + """Scan all plugin sources and load each plugin found. + + When ``force`` is true, clear cached discovery state first so config + changes or newly-added bundled backends become visible in long-lived + sessions without requiring a full agent restart. + """ 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 + # The ledger owns teardown. Clearing manager-local containers by + # itself leaves process-global tools/platforms/providers installed. + self.unload() 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. + # Set the flag up front as a re-entrancy guard (a plugin's register() + # can transitively trigger discovery again), but reset it if the sweep + # raises so a failed scan is NOT cached as "discovered with an empty + # registry" — callers swallow the exception and would otherwise be + # permanently stranded on the early-return above (the "No web provider + # configured" class of failures). 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. - # Now that plugins have had their chance to re-register, dispose the ones whose plugin did - # not come back (disabled, removed, or omitted from this discovery pass) so e.g. a disabled - # auth plugin's provider does not stay live process-wide until restart. See #91701. - self._evict_stale_persistent_registrations() - # load_hermes_dotenv() ran at import, before plugin secret sources existed: re-pull. - # Plugin secret sources register during discover; the initial load_hermes_dotenv() already - # ran at import time. Re-pull so the first process sees plugin backends (tracking #64177). + # Plugin secret sources register during discover; the initial + # load_hermes_dotenv() already ran at import time. Re-pull so the + # first process sees plugin backends (tracking #64177). 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. - # Re-register so force-reload is symmetric (#60036; tracking #64178 — salvaged from PR - # #64188; outbound webhooks added per #92682 review). - self._re_register_config_hooks_after_force() + # config.yaml shell hooks live in ``_hooks`` but are + # config-owned, not plugin-owned — the ledger-driven + # unload() above wiped them and cannot restore them. + # Re-register so force-reload is symmetric (#60036; + # tracking #64178 — salvaged from PR #64188). + self._re_register_shell_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.""" - for label, module_name in (("shell-hook", "agent.shell_hooks"), - ("outbound-webhook", "agent.outbound_webhooks")): - try: - importlib.import_module(module_name).re_register_config_hooks() - except Exception as exc: - logger.debug("force-reload %s re-register skipped: %s", label, exc) + def _re_register_shell_hooks_after_force(self) -> None: + """Restore config.yaml shell hooks wiped by force-clear of ``_hooks``.""" + try: + from agent.shell_hooks import re_register_config_hooks + + re_register_config_hooks() + except Exception as exc: + # Import cycle / missing module must not abort force reload. + logger.debug("force-reload shell-hook 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.""" + """If any plugin secret source is enabled, reset cache and re-apply. + + Enablement is delegated to each source's ``is_enabled(cfg)`` — the + same contract the orchestrator uses (``registry._ordered_enabled_sources``) + — so a source with custom activation logic is honored, not just + ``secrets..enabled``. + + No-op when only bundled sources exist or none are enabled. + Fail-open: never raise 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 + # Load the secrets config once; hand each source its own section and + # let its is_enabled() decide (honours custom activation extensions). try: from hermes_cli.config import load_config - secrets = (load_config() or {}).get("secrets") or {} + + cfg = load_config() or {} + secrets = cfg.get("secrets") or {} except Exception: secrets = {} - - def _enabled(source) -> bool: - section = secrets.get(getattr(source, "name", "")) + enabled_names = [] + for source in plugin_sources: + name = getattr(source, "name", "") + section = secrets.get(name) + section = section if isinstance(section, dict) else {} try: - return bool(source.is_enabled(section if isinstance(section, dict) else {})) + if source.is_enabled(section): + enabled_names.append(name) except Exception: - return False # mirrors the orchestrator: a raising is_enabled() is skipped - - enabled_names = [getattr(s, "name", "") for s in plugin_sources if _enabled(s)] + # A source whose is_enabled() raises is skipped, mirroring + # the orchestrator's defensive posture. + continue 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))) + 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. + + # Directory plugins are collected above. Pip / entry-point plugins + # are intentionally separate: portable packages are directory-only + # and the startup MCP probe must not import or register entry points. ep_manifests = self._scan_entry_points() logger.debug(" entrypoints: %d manifest(s)", len(ep_manifests)) manifests.extend(ep_manifests) + + # Load each manifest (skip user-disabled plugins). + # Later sources override earlier ones on key collision — user + # plugins take precedence over bundled, project plugins take + # precedence over user. Dedup here so we only load the final + # winner. Keys are path-derived (``image_gen/openai``, + # ``disk-cleanup``) so ``tts/openai`` and ``image_gen/openai`` + # don't collide even when both manifests say ``name: openai``. 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 = {manifest_key(m): m for m in manifests} - to_load = {k: m for k, m in winners.items() if self._gate_manifest(m, disabled, enabled)} + 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, + ) + winners: Dict[str, PluginManifest] = {} + for manifest in manifests: + winners[manifest.key or manifest.name] = manifest + # Standalone/user plugins that pass the gates below are collected + # here and loaded AFTER the sweep in dependency-respecting order + # (requires_plugins topological sort, #64165). + to_load: Dict[str, PluginManifest] = {} + for manifest in winners.values(): + lookup_key = manifest.key or manifest.name + + # Relay lifecycle ownership now lives in the Hermes core. Loading + # an old user or entry-point copy would let plugin.initialize() + # compete for the same process-global Relay registries. + if ( + lookup_key in LEGACY_RELAY_PLUGIN_KEYS + or manifest.name in LEGACY_RELAY_PLUGIN_KEYS + ): + loaded = LoadedPlugin(manifest=manifest, enabled=False) + loaded.error = ( + "removed — Relay lifecycle is owned by Hermes core; configure " + f"{RELAY_PLUGINS_CONFIG_ENV} instead" + ) + self._plugins[lookup_key] = loaded + logger.warning( + "Refusing to load removed Hermes Relay plugin '%s'; %s", + lookup_key, + loaded.error, + ) + continue + + # Explicit disable always wins (matches on key or on legacy + # bare name for back-compat with existing user configs). + if lookup_key in disabled or manifest.name in disabled: + loaded = LoadedPlugin(manifest=manifest, enabled=False) + loaded.error = "disabled via config" + self._plugins[lookup_key] = loaded + logger.debug("Skipping disabled plugin '%s'", lookup_key) + continue + + # Exclusive plugins (memory providers) have their own + # discovery/activation path. The general loader records the + # manifest for introspection but does not load the module. + if manifest.kind == "exclusive": + loaded = LoadedPlugin(manifest=manifest, enabled=False) + loaded.error = ( + "exclusive plugin — activate via .provider config" + ) + self._plugins[lookup_key] = loaded + logger.debug( + "Skipping '%s' (exclusive, handled by category discovery)", + lookup_key, + ) + continue + + # Model provider plugins are loaded by providers/__init__.py + # (its own lazy discovery keyed off first get_provider_profile() + # call). We record the manifest here for introspection but do + # not import the module — a second import would create two + # ProviderProfile instances and break the "last writer wins" + # override semantics between bundled and user plugins. + if manifest.kind == "model-provider": + loaded = LoadedPlugin(manifest=manifest, enabled=True) + self._plugins[lookup_key] = loaded + logger.debug( + "Skipping '%s' (model-provider, handled by providers/ discovery)", + lookup_key, + ) + continue + + # Built-in backends auto-load — they ship with hermes and must + # just work. Selection among them (e.g. which image_gen backend + # services calls) is driven by ``.provider`` config, + # enforced by the tool wrapper. + if manifest.source == "bundled" and manifest.kind == "backend": + self._load_plugin(manifest) + continue + + # Bundled platform plugins (gateway adapters: telegram, discord, + # feishu, teams, ...) are registered LAZILY. Their modules import + # heavy, platform-specific SDKs at module level (lark_oapi, + # microsoft_teams, discord.py, slack_bolt, ...), so eagerly loading + # all ~20 of them added several seconds to every `hermes` + # invocation — including plain `hermes chat`, which never touches a + # gateway platform. Instead we register a cheap deferred loader in + # the platform_registry keyed on the platform name; the real module + # is imported only when the gateway / cron / setup / send_message + # path actually asks for that platform. Every platform Hermes ships + # remains available out of the box — it just loads on first use. + if manifest.source == "bundled" and manifest.kind == "platform": + self._register_deferred_platform(manifest) + continue + + # Everything else (standalone, user-installed backends, + # entry-point plugins) is opt-in via plugins.enabled. + # Accept both the path-derived key and the legacy bare name + # so existing configs keep working. + is_enabled = ( + enabled is not None + and (lookup_key in enabled or manifest.name in enabled) + ) + if not is_enabled: + loaded = LoadedPlugin(manifest=manifest, enabled=False) + loaded.error = ( + "not enabled in config (run `hermes plugins enable {}` to activate)" + .format(lookup_key) + ) + self._plugins[lookup_key] = loaded + logger.debug( + "Skipping '%s' (not in plugins.enabled)", lookup_key + ) + continue + to_load[lookup_key] = manifest + + # Load the surviving standalone plugins in dependency order: + # when A requires B, B's register() runs before A's (topological + # sort, stable alphabetical tiebreak; cycles warn and fall back to + # alphabetical order). Missing deps warn but never block the load. 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)) - self._refresh_plugin_compat_report(list(to_load.values())) - def _refresh_plugin_compat_report(self, manifests: List[PluginManifest]) -> None: - """Refresh HERMES_HOME/.plugin-compat-report.json from this discovery pass (hermes_cli.plugin_compat). - - The Desktop boot modal has no Python runtime of its own and reads that file after the ``serve`` - backend is up, so the scan must run wherever plugins are discovered — not only under the CLI - banner / doctor / update, which never run inside the Desktop's backend. Fail-open: never raises. - """ - try: - from hermes_cli.plugin_compat import compat_report - compat_report(manifests, force=True) - except Exception as exc: - logger.debug("plugin compat report refresh skipped: %s", exc) + 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 _gate_manifest( - self, manifest: PluginManifest, disabled: Set[str], enabled: Optional[Set[str]], - ) -> bool: - """Route one winning manifest per :func:`gate_manifest`: load now, defer, or record as - skipped (introspection-only placeholder). Returns True only for plugins that go through the - dependency-ordered load pass.""" - verdict = gate_manifest(manifest, disabled, enabled) - if verdict.action == "load": - return True - if verdict.action == "load_now": - self._load_plugin(manifest) - elif verdict.action == "defer": - self._register_deferred_platform(manifest) - else: - self._plugins[manifest_key(manifest)] = LoadedPlugin( - manifest=manifest, enabled=verdict.enabled, error=verdict.error) - if verdict.log: - logger.log(*verdict.log) - return False + 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 - def register_approval_transport(self, name: str, present_fn: Callable, *, plugin_id: str) -> None: - """Manager-level registration (public API kept for out-of-tree plugins); the PluginContext - method is the tracked path plugins normally use. Same validation, no unload tracking.""" 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}") + 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}") + 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()), + 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 or registered.profile_home != str(get_hermes_home().resolve()): - return None - return registered + 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]: + """Collect directory manifests in the same order as full discovery. + + This method only reads manifests. It does not load native plugin + modules, register deferred platforms, or otherwise mutate manager + registries. Keeping the source ordering and scanner calls here lets + startup probes share the exact precedence and containment rules used + by :meth:`_discover_and_load_inner`. + """ + manifests: List[PluginManifest] = [] + + # 1. Bundled plugins (/plugins//). The excluded top-level + # categories have their own discovery systems; bundled platforms are + # scanned explicitly one level 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 (./.hermes/plugins/), only when explicitly opted + # in. This must match the full discovery gate exactly. + 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. + + The directory manifest collection is shared with full discovery, so + native ``plugin.yaml`` precedence, source ordering, depth limits, and + project-plugin gating cannot diverge between startup and runtime. + """ + if _env_enabled("HERMES_SAFE_MODE"): + return False + + plugins_config = raw_config.get("plugins") + if not isinstance(plugins_config, dict): + return False + enabled_value = plugins_config.get("enabled") + if not isinstance(enabled_value, list): + return False + enabled = {value for value in enabled_value if isinstance(value, str)} + disabled_value = plugins_config.get("disabled", []) + disabled = ( + {value for value in disabled_value if isinstance(value, str)} + if isinstance(disabled_value, list) + else set() + ) + if not enabled: + return False + + winners: Dict[str, PluginManifest] = {} + for manifest in self._collect_directory_manifests(): + winners[manifest.key or manifest.name] = manifest + + 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): + # Full discovery will report component diagnostics. Startup + # probing should fail closed for an unreadable package. + continue + return False + + # ----------------------------------------------------------------------- + # Directory scanning + # ----------------------------------------------------------------------- + + def _scan_directory( + self, + path: Path, + source: str, + skip_names: Optional[Set[str]] = None, + ) -> List[PluginManifest]: + """Read ``plugin.yaml`` manifests from subdirectories of *path*. + + Supports two layouts, mixed freely: + + * **Flat** — ``//plugin.yaml``. Key is + ```` (e.g. ``disk-cleanup``). + * **Category** — ``///plugin.yaml``, + where the ```` directory itself has no ``plugin.yaml``. + Key is ``/`` (e.g. ``image_gen/openai``). + Depth is capped at two segments. + + *skip_names* is an optional allow-list of names to ignore at the + top level (kept for back-compat; the current call sites no longer + pass it now that categories are first-class). + """ + 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 implementation of :meth:`_scan_directory`. + + ``prefix`` is the category path already accumulated ("" at root, + "image_gen" one level in). ``depth`` is the recursion depth; we + cap at 2 so ``/a/b/c/`` is ignored. + """ + manifests: List[PluginManifest] = [] + try: + path = _as_plugin_path(path, what="plugin directory") + except (TypeError, ValueError): + return manifests + 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 at this level. If we're still within the depth + # cap, treat this directory as a category namespace and recurse + # one level in looking for children with manifests. + 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 a single ``plugin.yaml`` into a :class:`PluginManifest`. + + Returns ``None`` on parse failure (logs a warning). + """ + 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 user-installed memory providers to kind="exclusive" + # so they're routed to plugins/memory discovery instead of being + # loaded by the general PluginManager (whose PluginContext + # register_memory_provider is a recorded no-op, not an + # activation path). Mirrors the heuristic in + # plugins/memory/__init__.py:_is_memory_provider_dir. + # Bundled memory providers are already skipped via skip_names. + if kind == "standalone" and "kind" not in data: + init_file = plugin_dir / "__init__.py" + if init_file.exists(): + try: + 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, + ) + except Exception: + pass + + 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 + + # ----------------------------------------------------------------------- + # Entry-point scanning + # ----------------------------------------------------------------------- + + def _classify_entrypoint_kind(self, ep) -> str: + """Classify a pip entry-point plugin by scanning its module source. + + The ``kind`` semantics are the same for pip entry points as for + directory plugins: memory providers (``exclusive``) and model + providers (``model-provider``) have their own discovery systems, + so importing them here registers nothing and only pays the + module's import cost in every Hermes process (e.g. a pip + memory-provider plugin pulling in onnxruntime via fastembed — + ~60 MB RSS on startup). + + The module source is read without importing the module or any + of its parent packages (see ``_resolve_module_source``); only + the first 8192 chars are scanned, mirroring the directory-plugin + heuristic. Unresolvable or non-Python modules stay ``standalone``. + + Activation contract: this method only decides whether the general + manager imports the module — it does not activate anything. + Memory and model providers activate through their own systems + (``memory.provider`` config via ``plugins/memory`` directory + discovery; ``providers/`` lazy directory discovery). Both are + directory-based today, so a pip-only provider is recorded for + introspection but not activatable until those systems gain + entry-point discovery (tracked for memory: #40644). That is not + a regression: pre-change such a provider was equally + unactivatable — it was merely imported first, at full cost + (e.g. fastembed -> onnxruntime), and logged + ``no register() function``. Classification removes the cost + without changing the activation surface, and is the prerequisite + that prevents double-import once entry-point activation lands. + """ + try: + module_name = ep.value.split(":", 1)[0].strip() + if not module_name: + return "standalone" + source_text = _resolve_module_source(module_name) + return _detect_kind_from_source(source_text) or "standalone" + except Exception: + return "standalone" + + def _scan_entry_points(self) -> List[PluginManifest]: + """Read installed plugin and companion capability entry points. + + Delegates to ``discover_entrypoint_manifests()``, which composes + kind classification (import-free source scan routing memory/model + providers away from the general manager) with capability + declarations from the ``hermes_agent.plugin_capabilities`` group. + Capability declarations live in distribution metadata so discovery + is available before importing untrusted plugin code and does not + depend on a package-data ``plugin.yaml`` being present. + """ + return discover_entrypoint_manifests() + + # ----------------------------------------------------------------------- + # Loading + # ----------------------------------------------------------------------- + + def _platform_name_from_manifest(self, manifest: PluginManifest) -> str: + """Derive the gateway platform name (e.g. ``feishu``) for a platform plugin. + + The platform name registered via ``register_platform(name=...)`` lives + inside the adapter module (which we are explicitly trying NOT to import + early). It is not carried in ``plugin.yaml``. Across every bundled + platform plugin the manifest name is ``-platform`` and the + plugin directory basename is ````, so we derive the name + without importing: strip a trailing ``-platform`` from the manifest + name, falling back to the directory basename. This is also a sensible + convention for third-party platform plugins. + """ + 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 plugin. + + The platform adapter module is imported only when the gateway / cron / + setup / send_message path first asks the ``platform_registry`` for this + platform. Until then we record a lightweight ``LoadedPlugin`` so + ``hermes plugins list`` still shows the platform as available, and we + hand the registry a loader that runs the normal eager-load path. + """ + lookup_key = manifest.key or manifest.name + platform_name = self._platform_name_from_manifest(manifest) + + # Record an enabled placeholder for introspection (`hermes plugins + # list`). The real module load swaps in a fully-populated LoadedPlugin + # (tools/hooks/commands attribution) when the loader fires. + 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: + # Acquire the manager lock before checking cancellation. If an + # unload won the race after the registry marked this loader + # in-flight, it restores the predecessor and this loader exits + # without publishing any plugin registrations. If loading won, + # unload waits and then disposes the completed registration 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) + lease = replacement_coordinator.acquire( + ("platform", scope, platform_name), + current=current, + previous=previous, + restore=lambda replacement: self._restore_deferred_platform( + platform_registry, + platform_name, + current, + replacement, + scope, + ), + finalize=lambda: self._remove_platform_name_if_unowned( + platform_name + ), + ) + self._track_registration( + manifest, + "platform", + platform_name, + lease.dispose, + ) + logger.debug( + "Registered deferred platform loader: %s (plugin=%s)", + platform_name, + lookup_key, + ) + except Exception: + # If the registry import fails for any reason, 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. + + A platform plugin can ship two independent things: an inbound adapter + (heavy — it imports the platform SDK) and outbound client tools the + agent calls like any other tool. Deferring the plugin defers both, so + in a CLI/TUI process the client tools never register at all: + ``resolve_toolset()`` returns ``[]``, the toolset is missing from the + ``hermes tools`` checklist, and even an explicit ``platform_toolsets`` + entry is dropped because the key is unknown. The same tools work in + gateway/web processes only because those materialize every platform at + startup (issue #78050). + + Client tools that live in a dedicated ``tools`` submodule can be + registered at discovery time instead: importing ``/tools.py`` + does not import the adapter, so the SDK stays unloaded and startup + stays cheap. A plugin taking this path must therefore keep its package + ``__init__`` import-light and pull the adapter in from inside + ``register()`` (as ``plugins/platforms/a2a`` does). + + Opting in is explicit: the manifest must declare ``provides_tools`` + (the field the plugin list and web server already read to name a + plugin's tools, per #78538). Keying off the mere presence of a + ``tools.py`` would opt a plugin in by accident — a platform is free to + put internal helpers there — and would leave the contract invisible to + anyone reading the manifest. ``tools.py`` remains where the code is + imported from; ``provides_tools`` is what asks for it. A platform that + does not declare the field is untouched and stays fully deferred. + """ + 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 here reproduces the + # exact symptom this path exists to fix — tools the manifest + # promises, silently absent from the session (#78050) — so say so. + 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 + + # Snapshotted outside the try so the failure path can tell which tools + # a partially-successful register_tools() left behind. + before = set(self._plugin_tool_names) + try: + module = self._load_directory_module(manifest) + # Record the module even if nothing below registers: the package + # body has already run, so materializing the adapter later must + # reuse it rather than execute it a second time. + 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: + # A register_tools() that registered some tools and THEN raised + # leaves those tools live in the registry. Credit them, or + # `hermes plugins list` under-reports what the process is actually + # carrying — and _load_plugin's own diff would miss them later + # too, since they are already in its "before" snapshot. + 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 let a client-tool import break discovery — the platform + # stays deferred and behaves exactly as it did before. But a + # broken tools.py produces the #78050 symptom itself (declared + # tools missing from the session), so this has to be visible + # without turning on debug logging to find it. + # + # Where it failed is the first thing an operator needs: nothing + # registered points at the import or the module body, a partial + # run points at one tool's definition, and a full run that still + # raised points past the registrations entirely. + 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: + """Surface declared pip dependencies (#64165). + + python_dependencies is a declaration seam ONLY: Hermes validates and + prints the requirements with an install hint but NEVER auto-installs + them. The isolation design (constraints installs vs. vendored dirs + vs. conflict-detection-and-refusal) is an explicitly deferred + follow-up — see the round-2 review on #64165 and #15220. + """ + 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: + """Check plugins.entries. settings against config_schema (#64165). + + Mismatches log actionable warnings naming the key and expected type; + they never block the plugin from loading. + """ + 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 _restore_deferred_platform( + self, + platform_registry, + name: str, + current, + replacement, + scope: str, + ) -> bool: + return platform_registry.restore_registration( + name, current, replacement, scope=scope + ) + + 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 + + from tools.registry import registry as _registry + registration_start = len(self._registration_order) + plugin_key = manifest.key or manifest.name + _module_name = self._policy_module_name(manifest) + 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, + ) + try: + # A deferred platform whose client tools were already registered at + # discovery time has its package imported too — reuse it so the + # module body doesn't execute twice (#78050). + 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 + + # Call register() + 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: + ctx = PluginContext(manifest, self) + register_fn(ctx) + registrations = [ + registration + for registration in self._registration_order[registration_start:] + if registration.plugin_key == plugin_key and registration.active + ] + # Tools this plugin already contributed at discovery time were + # registered before ``registration_start``, so the ledger slice + # above cannot see them and `hermes plugins list` would + # under-report once the deferred adapter materializes (#78050). + # Credit them back to the plugin that actually registered them. + _predeclared = [ + t for t in self._predeclared_tools.pop(plugin_key, []) + if t in self._plugin_tool_names + ] + loaded.tools_registered = _predeclared + [ + registration.key + for registration in registrations + if registration.kind == "tool" + and registration.key not in _predeclared + ] + loaded.hooks_registered = [ + registration.key + for registration in registrations + if registration.kind == "hook" + ] + loaded.middleware_registered = [ + registration.key + for registration in registrations + if registration.kind == "middleware" + ] + loaded.commands_registered = [ + registration.key + for registration in registrations + if registration.kind == "command" + ] + loaded.enabled = True + 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 any( + registration.active + and registration.plugin_key == plugin_key + and registration.kind == "cli_command" + and registration.key == c + for registration in registrations + ) + ), + ) + + 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. Remove those + # owner-tagged entries so a failed/unloaded plugin cannot leave a + # 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, + ) + # A materialization that did NOT succeed has already had its + # discovery-time pre-registrations disposed: the failure path above + # sweeps the whole ownership ledger for this plugin key, not just the + # ``registration_start:`` slice, so nothing this plugin registered + # survives it. There is no live tool left to credit — attribution and + # the registry agree at zero. Only the success path pops + # _predeclared_tools, so drop the entry here rather than let the + # bookkeeping outlive the load attempt (#78050). + if not loaded.enabled: + self._predeclared_tools.pop(plugin_key, None) + self._plugins[manifest.key or manifest.name] = loaded + + 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-based plugin as ``hermes_plugins.``. + + The module slug is derived from ``manifest.key`` so category-namespaced + plugins (``image_gen/openai``) import as + ``hermes_plugins.image_gen__openai`` without colliding with any + future ``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}") + + # Ensure the namespace parent package exists + 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 any stale sys.modules entries for this slug before + # (re-)importing. A same-slug module may already be cached here + # from a different Hermes home (profile switch reusing a slug + # like "hermes-lcm") or from an earlier force=True reload in the + # same home. Replacing only sys.modules[module_name] below is not + # enough: the plugin's own relative imports (`from . import foo`) + # are cached separately under "module_name + '.' + submodule", + # and Python's import system resolves those from sys.modules + # first — so a stale submodule would silently keep serving the + # previous load's code/state instead of the fresh one we're + # about to exec. Evict the package and everything nested under + # it so this import starts clean. + stale_prefix = f"{module_name}." + for name in [n for n in sys.modules if n == module_name or n.startswith(stale_prefix)]: + del sys.modules[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 the partially + # imported relative submodules it pulled in before failing) + # cached in sys.modules — a retry or a same-slug plugin in a + # different profile would otherwise inherit broken state. + for name in [n for n in sys.modules if n == module_name or n.startswith(stale_prefix)]: + del sys.modules[name] + raise + return module + + def _load_entrypoint_module(self, manifest: PluginManifest) -> types.ModuleType: + """Load a pip-installed plugin via its entry-point reference.""" + eps = importlib.metadata.entry_points() + if hasattr(eps, "select"): + group_eps = eps.select(group=ENTRY_POINTS_GROUP) + elif isinstance(eps, dict): + group_eps = eps.get(ENTRY_POINTS_GROUP, []) + else: + group_eps = [ep for ep in eps if ep.group == ENTRY_POINTS_GROUP] + + for ep in group_eps: + 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 old callbacks.""" + try: + parameters = inspect.signature(callback).parameters + except (TypeError, ValueError): + # Some extension/builtin callables do not expose a signature. Keep + # the historical behavior for those callables rather than guessing. + return callback(**payload) + + 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 registered callbacks for *hook_name*. + + Hook payloads evolve additively. Callbacks that accept ``**kwargs`` + receive the complete payload; older callbacks with a narrow signature + receive only the keyword arguments they declare. Each callback is + wrapped in its own try/except so a misbehaving plugin cannot break the + core agent loop. + + Returns a list of non-``None`` return values from callbacks. + + For ``pre_llm_call``, callbacks may return a dict describing + context to inject into the current turn's user message:: + + {"context": "recalled text..."} + "recalled text..." # plain string, equivalent + + Context is ALWAYS injected into the user message, never the + system prompt. This preserves the prompt cache prefix — the + system prompt stays identical across turns so cached tokens + are reused. All injected context is ephemeral — never + persisted to session DB. + """ + # Most legacy observer hooks carry the shared telemetry marker. Gateway + # platform events define event-local additive envelopes instead: injecting + # a bus-wide version here would turn unrelated adapter payloads into one + # monolithic compatibility contract (#64176). + if hook_name != "gateway_platform_event": + kwargs.setdefault("telemetry_schema_version", OBSERVER_SCHEMA_VERSION) + callbacks = self._hooks.get(hook_name, []) + results: List[Any] = [] + for cb in callbacks: + try: + 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 _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* and return the count. + + Queued dispatch envelopes re-check ledger membership before each + callback, so removing an owner also cancels callbacks already snapshotted + by an event that has not reached that subscriber yet. + + TODO(#64229): when the central plugin ownership ledger / registration + handles land, route this owner-tagged bookkeeping through that ledger + so per-plugin unload cancels event subscriptions alongside every other + registration surface. This method is the integration seam. + """ + 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 _reset_event_bus(self) -> None: + """Cancel the current event generation and clear its subscriptions.""" + with self._event_lock: + old_queue = self._event_queue + had_worker = self._event_worker is not None + self._event_generation += 1 + self._subscriptions.clear() + self._event_queue = queue.Queue(maxsize=_EVENT_PENDING_CAP) + self._event_worker = None + self._event_pending_by_generation.setdefault( + self._event_generation, 0 + ) + + # Drop work that has not started. A currently-running callback + # cannot be force-killed safely, but generation + ledger checks stop + # it before the next subscriber and prevent all queued callbacks. + while True: + try: + item = old_queue.get_nowait() + except queue.Empty: + break + try: + if item is not _EVENT_WORKER_STOP: + self._mark_event_done(item.generation) + finally: + old_queue.task_done() + if had_worker: + old_queue.put_nowait(_EVENT_WORKER_STOP) + self._event_idle.notify_all() + + 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 _collect_directory_manifests(self) -> List[PluginManifest]: - """Directory manifests in full-discovery order (see :func:`collect_directory_manifests`).""" - return collect_directory_manifests() + 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 remove this exact ledger entry after the + # event was queued but before its callback starts. + if not any( + current is subscription + for current in self._subscriptions.get(item.event, []) + ): + continue + callback = subscription.callback + try: + # A fresh deep copy per subscriber prevents one callback + # from mutating the emitter's nested values or the payload + # observed by the next subscriber. + 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 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 _dispatch_event(self, event: str, payload: Dict[str, Any]) -> int: + """Queue *event* without blocking; return subscriber count scheduled. - def _names(value: Any) -> Set[str]: - return {v for v in value if isinstance(v, str)} if isinstance(value, list) else set() + A single daemon worker preserves registration order. Pending work is + bounded per manager generation so a blocking subscriber can consume at + most one worker while later emits are dropped once the budget is full. + """ + 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 - enabled = _names(plugins_config.get("enabled")) - disabled = _names(plugins_config.get("disabled", [])) - if not enabled: - return False - for lookup_key, manifest in {manifest_key(m): m for m in self._collect_directory_manifests()}.items(): - names = {lookup_key, manifest.name} - if not manifest.portable or names & disabled or not names & enabled: + 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 - try: # lazy: this is a startup probe, keep agent_plugins unimported unless needed - 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 + try: + value = ( + section.content(frozen_info) + if callable(section.content) + else section.content + ) + except Exception as exc: + logger.warning( + "Plugin system prompt section %s (%s) raised and was skipped: %s", + section.id, + section.plugin, + exc, + ) + continue + if not isinstance(value, str): + logger.warning( + "Plugin system prompt section %s (%s) returned %s, not str; skipped", + section.id, + section.plugin, + type(value).__name__, + ) + continue + text = value.strip() + if not text: + continue + if PLUGIN_SECTIONS_START in text or PLUGIN_SECTIONS_END in text: + logger.warning( + "Plugin system prompt section %s (%s) contained a reserved " + "persistence marker and was skipped", + section.id, + section.plugin, + ) + continue + if len(text) > section.max_chars: + logger.warning( + "Plugin system prompt section %s (%s) exceeded max_chars " + "(%d > %d) and was skipped", + section.id, + section.plugin, + len(text), + section.max_chars, + ) + 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 - def _scan_directory( - self, path: Path, source: str, skip_names: Optional[Set[str]] = None, - ) -> List[PluginManifest]: - """Read manifests under *path* (see :func:`scan_directory`).""" - return scan_directory(path, source, skip_names=skip_names) + 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 _scan_entry_points(self) -> List[PluginManifest]: - """Read installed plugin entry points (see :func:`discover_entrypoint_manifests`).""" - return discover_entrypoint_manifests() + def invoke_middleware(self, kind: str, **kwargs: Any) -> List[Any]: + """Call registered middleware callbacks for *kind*. + + Each callback is isolated so one plugin cannot break the base runtime + path. Middleware that wants to change behavior must return the shape + documented by the caller-specific contract. + """ + 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 + + # ----------------------------------------------------------------------- + # Slack action handler accessor + # ----------------------------------------------------------------------- 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) + """Return the list of plugin-registered Slack action handlers. + + Each entry is a ``(action_id, callback, plugin_name)`` tuple. + Consumed by the Slack adapter at connect time to wire callbacks + into its ``slack_bolt.AsyncApp``. - 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`).""" - return list(self._platform_handler_factories.get((platform or "").strip().lower(), [])) + Plugins register handlers via + :meth:`PluginContext.register_slack_action_handler`. + """ + return list(self._slack_action_handlers) - def get_telegram_handler_factories(self) -> List[tuple]: - """Back-compat alias for ``get_platform_handler_factories("telegram")``.""" - return self.get_platform_handler_factories("telegram") + # ----------------------------------------------------------------------- + # Introspection + # ----------------------------------------------------------------------- def list_plugins(self) -> List[Dict[str, Any]]: """Return a list of info dicts for all discovered plugins.""" - return [ - { - "name": p.manifest.name, "key": manifest_key(p.manifest), "kind": p.manifest.kind, - "version": p.manifest.version, "description": p.manifest.description, - "source": p.manifest.source, "enabled": p.enabled, "tools": len(p.tools_registered), - "hooks": len(p.hooks_registered), "middleware": len(p.middleware_registered), - "commands": len(p.commands_registered), "error": p.error, - } for _key, p in sorted(self._plugins.items()) - ] + 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 + + # ----------------------------------------------------------------------- + # Plugin skill lookups + # ----------------------------------------------------------------------- def find_plugin_skill(self, qualified_name: str) -> Optional[Path]: """Return the ``Path`` to a plugin skill's SKILL.md, or ``None``.""" @@ -1446,45 +5555,76 @@ def find_plugin_skill(self, qualified_name: str) -> Optional[Path]: 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)) + 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()) + "name": qualified, + "description": str(entry.get("description", "")), + "category": "plugin", + "frontmatter": dict(entry.get("frontmatter", {})), + } + for qualified, entry in sorted(self._plugin_skills.items()) ] - def has_portable_mcp_servers(self) -> bool: - return bool(self._portable_mcp_servers) - 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()} + + return { + name: dict(config) + for name, config in self._portable_mcp_servers.items() + } + + def has_portable_mcp_servers(self) -> bool: + return bool(self._portable_mcp_servers) 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. +# --------------------------------------------------------------------------- +# 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. +# Legacy single-slot singleton. Kept as the storage for the "current" +# manager so existing test code that does +# ``monkeypatch.setattr(plugins_mod, "_plugin_manager", some_manager)`` +# keeps 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. +# Keyed cache: resolved Hermes home -> PluginManager. Hermes supports +# multiple profiles via different HERMES_HOME directories, and a single +# long-lived process (gateway multiplexer, test session, embedder) can +# switch between them via ``set_hermes_home_override()`` — which is a +# ContextVar and deliberately does NOT touch os.environ (see +# hermes_constants.set_hermes_home_override). A process-wide single-slot +# cache leaks one profile's plugin/context-engine state into another. We +# key the cache by the *resolved* home path so re-entering a previously +# seen profile reuses its manager (and picks up any modules it already +# imported) instead of rebuilding from scratch every switch. _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).""" + """Return the profile/home key for process-global plugin state. + + Plugins are discovered from ``get_hermes_home() / "plugins"`` and some + plugins (notably context engines such as hermes-lcm) capture that home + at registration time for profile-scoped storage. A long-lived process + can temporarily switch Hermes home (env var *or* the context-local + ``set_hermes_home_override()``) while serving another profile, so the + plugin manager must be scoped to the active Hermes home instead of + being one process-wide singleton. + """ try: return get_hermes_home().expanduser().resolve() except Exception: @@ -1492,42 +5632,79 @@ def _plugin_home_key() -> Path: 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). + """Purge ``sys.modules`` entries for directory-loaded plugins. + + ``PluginManager._load_directory_module`` imports each plugin as + ``hermes_plugins.`` and registers that top-level module in + ``sys.modules``. Anything the plugin's ``__init__.py`` imports with a + *relative* import (``from . import foo``, ``from .sub import bar``) + ends up cached in ``sys.modules`` too, under + ``hermes_plugins..``. When we swap in a fresh manager + for a new home, replacing only the parent module leaves those + submodules behind: if a same-named plugin in the new profile does a + relative import, Python resolves it from ``sys.modules`` first and + silently reuses the *previous* profile's already-imported submodule + (and any module-level state it captured), instead of re-executing the + new profile's code. We must evict the package itself and every module + whose name is prefixed with ``"."`` before (or when) + discarding a manager, not just drop our reference to it. """ if manager is None: return - for loaded in getattr(manager, "_plugins", {}).values(): # tolerates test-double managers - module_name = getattr(getattr(loaded, "module", None), "__name__", None) + 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) + 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] 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).""" + """Return the plugin manager for the active Hermes profile/home. + + Managers are cached per resolved home so repeated calls within the + same profile reuse discovery state (normal performance), while a + profile switch — via ``HERMES_HOME`` or the context-local + ``set_hermes_home_override()`` — gets its own manager with its own + plugin submodules, instead of silently inheriting another profile's + context engine or stale relative-import state. + """ 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(): + # Tests and embedders historically monkeypatch ``_plugin_manager`` + # directly (``monkeypatch.setattr(plugins_mod, "_plugin_manager", mgr)``). + # Detect that specifically by checking whether the single-slot pointer + # references a manager our 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.""" + """Test-only helper: drop every cached manager and its submodules. + + Not used by production code paths — tests that want a fully clean + slate (rather than adopting/injecting a specific manager) can call + this instead of reaching into the module's private dict directly. + """ global _plugin_manager with _plugin_managers_lock: managers = list(dict.fromkeys(_plugin_managers_by_home.values())) @@ -1541,24 +5718,27 @@ def _reset_plugin_managers_for_tests() -> None: 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 - clear_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 whether config enables a portable package with MCP servers. + + A fresh manager performs manifest-only scanning, so this startup gate does + not mutate the process-wide plugin registry or import native plugin code. + """ 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.""" + """Discover and load all plugins. + + Default behavior is idempotent. Pass ``force=True`` to rescan plugin + manifests and reload state in the current process. + + If a background discovery started via + :func:`start_background_plugin_discovery` is still running, this waits + for it instead of racing a second scan. + """ _join_background_discovery() get_plugin_manager().discover_and_load(force=force) @@ -1568,9 +5748,16 @@ def discover_plugins(force: bool = False) -> None: 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.""" + """Run plugin discovery in a daemon thread (startup-latency overlap). + + Discovery costs ~150ms of manifest scanning + module imports on the CLI + startup path. Interactive chat doesn't need plugins until the first + agent turn, so callers on that path can start discovery here and let it + overlap the CPU/subprocess-heavy rest of startup. Every synchronous + consumer goes through :func:`discover_plugins`, which joins this thread + first — so no caller can observe a half-loaded registry. Idempotent; + no-op when discovery already ran or is already in flight. + """ global _background_discovery_thread manager = get_plugin_manager() if manager._discovered: @@ -1586,7 +5773,9 @@ def _run() -> None: 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 = threading.Thread( + target=_run, name="plugin-discovery", daemon=True + ) _background_discovery_thread.start() @@ -1598,14 +5787,17 @@ def _join_background_discovery(timeout: float = 30.0) -> None: t.join(timeout=timeout) -def _plugin_toolset_keys_cache_path() -> Path: +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 + import json as _json + import os as _os + import tempfile as _tempfile keys = sorted({ts_key for ts_key, _, _ in get_plugin_toolsets()}) try: portable = sorted(get_plugin_manager().get_portable_mcp_servers()) @@ -1613,52 +5805,97 @@ def _persist_plugin_toolset_keys() -> None: 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) + 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 _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.""" +def _read_plugin_keys_cache() -> Optional[dict]: + try: + import json as _json + 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 get_plugin_toolset_keys_nowait() -> "set[str]": + """Plugin toolset keys without blocking on in-flight discovery. + + When discovery already completed in this process, reads the live + registry. While a background discovery is still running, falls back to + the key set persisted by the previous run — callers on the startup path + (platform toolset resolution) only use these keys to EXCLUDE plugin + toolsets from composite expansion, so a stale set from the last launch + is harmless and self-heals as soon as discovery lands. When neither is + available, blocks via discover_plugins() (correctness first). + """ manager = get_plugin_manager() t = _background_discovery_thread - in_flight = t is not None and t.is_alive() - if manager._discovered and not in_flight: - return live(manager) - if in_flight: - with suppress(Exception): - blob = json.loads(_plugin_toolset_keys_cache_path().read_text(encoding="utf-8")) - values = blob.get(cache_field) if isinstance(blob, dict) else None - if isinstance(values, list) and all(isinstance(v, str) for v in values): - return set(values) + if manager._discovered and (t is None or not t.is_alive()): + return {ts_key for ts_key, _, _ in get_plugin_toolsets()} + if t is not None and t.is_alive(): + blob = _read_plugin_keys_cache() + if blob is not None: + keys = blob.get("toolset_keys") + if isinstance(keys, list) and all(isinstance(k, str) for k in keys): + return set(keys) discover_plugins() - return live(manager) + return {ts_key for ts_key, _, _ in get_plugin_toolsets()} -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 without blocking on in-flight discovery. + + Same contract as :func:`get_plugin_toolset_keys_nowait`: live registry + when discovery finished, last launch's persisted set while a background + discovery is running, blocking discovery otherwise. + """ + manager = get_plugin_manager() + t = _background_discovery_thread + if manager._discovered and (t is None or not t.is_alive()): + return set(manager.get_portable_mcp_servers()) + if t is not None and t.is_alive(): + blob = _read_plugin_keys_cache() + if blob is not None: + names = blob.get("portable_mcp") + if isinstance(names, list) and all(isinstance(n, str) for n in names): + return set(names) + discover_plugins() + return set(manager.get_portable_mcp_servers()) -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 unload_plugins( + plugin: Union[str, PluginManifest, LoadedPlugin, None] = None, +) -> bool: + """Unload one plugin or all plugins from the process-global manager. + + Wait for background discovery first so teardown cannot race an in-flight + registration sweep introduced by the warm-start discovery path. + """ + _join_background_discovery() + return get_plugin_manager().unload(plugin) 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. - - Hook/middleware delivery must not depend on WHICH surface imported us: dashboards, TUI slash workers, - query mode, and cron delivery paths never import ``model_tools`` (whose import side-effect is the - discovery trigger on the interactive CLI path), so hooks registered by user plugins were silently dead - on those surfaces (#50776, #67597, #67890, #50937; tracking #64178 — salvaged from PR #64188). + """Return the active manager, lazily running discovery if it never ran. + + Hook/middleware delivery must not depend on WHICH surface imported us: + dashboards, TUI slash workers, query mode, and cron delivery paths never + import ``model_tools`` (whose import side-effect is the discovery trigger + on the interactive CLI path), so hooks registered by user plugins were + silently dead on those surfaces (#50776, #67597, #67890, #50937; + tracking #64178 — salvaged from PR #64188). + + ``getattr`` with a ``True`` default so test doubles that monkeypatch + ``get_plugin_manager()`` with a bare namespace are invoked untouched. """ manager = get_plugin_manager() if not getattr(manager, "_discovered", True): @@ -1668,41 +5905,46 @@ def _delivery_manager() -> PluginManager: def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]: - """Invoke a lifecycle hook (lazy-discovers first); return non-``None`` callback results. - - Hot-path / observer hooks in ``_HOOK_TIMEOUT_BOUNDED_HOOKS`` and the policy hook ``pre_tool_call`` are - bounded by ``plugins.hook_callback_timeout`` (default 30s). On timeout the worker is abandoned (not - joined) so we do not reintroduce the #6622 hang. Timed-out or still-running ``pre_tool_call`` callbacks - fail closed with a block directive; other bounded hooks fail open (skip). - Ensures plugins are discovered on first invocation so callers in processes that never explicitly call - ``discover_plugins()`` (gateway platform events, TUI slash workers, query mode, cron) still fire + """Invoke a lifecycle hook on loaded plugins. + + Ensures plugins are discovered on first invocation so callers in + processes that never explicitly call ``discover_plugins()`` (gateway + platform events, TUI slash workers, query mode, cron) still fire callbacks registered by user plugins (tracking #64178). + + Returns a list of non-``None`` return values from plugin callbacks. """ return _delivery_manager().invoke_hook(hook_name, **kwargs) -def render_system_prompt_sections(session_info: Mapping[str, Any]) -> List[RenderedPluginSystemPromptSection]: +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`). + """Invoke registered middleware callbacks. + + Lazy-discovers plugins on first use — same delivery-parity guarantee as + :func:`invoke_hook` (tracking #64178). - Lazy-discovers plugins on first use — same delivery-parity guarantee as :func:`invoke_hook` (tracking - #64178). + Returns a list of non-``None`` return values from middleware callbacks. """ 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. + """Return True when middleware callbacks are registered for ``kind``. - Lazy-discovers first: callers use this as a gate before :func:`invoke_middleware`, so a pre-discovery - ``False`` here would silently skip delivery on surfaces that never ran discovery (#64178). + Lazy-discovers first: callers use this as a gate before + :func:`invoke_middleware`, so a pre-discovery ``False`` here would + silently skip delivery on surfaces that never ran discovery (#64178). """ - manager = _delivery_manager() + 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)) @@ -1710,9 +5952,10 @@ def has_middleware(kind: str) -> bool: def has_hook(hook_name: str) -> bool: - """True when a loaded plugin handles a hook (lazy-discovers first, like :func:`has_middleware`). + """Return True when a loaded plugin handles a hook. - Lazy-discovers first — same gate-before-invoke rationale as :func:`has_middleware` (tracking #64178). + Lazy-discovers first — same gate-before-invoke rationale as + :func:`has_middleware` (tracking #64178). """ return _delivery_manager().has_hook(hook_name) @@ -1723,24 +5966,44 @@ def iter_hook_callbacks(hook_name: str) -> tuple[Callable, ...]: def fire_pre_command_hook( - *, surface: str, command: str, alias_used: str, args_raw: str, - session_key: Optional[str] = None, platform: Optional[str] = None, + *, + 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.""" + """Fire the ``pre_command`` observer hook (#64204). Never raises. + + Observer-only in v1: return values are ignored. If a plugin returns a + directive-shaped dict (``action``/``decision`` keys), a debug line is + logged so future block/rewrite adopters are discoverable when the + middleware variant ships against the #64231 command-event taxonomy. + """ 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, + "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) + 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) @@ -1769,198 +6032,451 @@ def clear_thread_tool_whitelist() -> 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 = "", + 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.""" + """Check ``pre_tool_call`` hooks for a blocking or approval directive. + + Plugins that need to enforce policy (rate limiting, security + restrictions, approval workflows) can return one of:: + + {"action": "block", "message": "Reason the tool was blocked"} + {"action": "approve", "message": "Why this needs human confirmation"} + {"action": "approve", "message": "...", "rule_key": "write_file:ssh"} + + from their ``pre_tool_call`` callback. + + - ``block`` vetoes the tool call outright (the message becomes the tool + result the model sees). + - ``approve`` ESCALATES to the existing human-approval gate + (``prompt_dangerous_approval`` on CLI, the approval callback on the + gateway) — the same mechanism Tier-2 dangerous shell patterns use. + This lets a plugin require a human ``[o]nce/[s]ession/[a]lways/[d]eny`` + decision on ANY tool, not just terminal command strings. The caller is + responsible for invoking the gate (see + :func:`tools.approval.request_tool_approval`). + - ``rule_key`` is optional and only honored for ``approve`` directives. It + lets plugins choose the allowlist grain for `[a]lways` approvals. + + The first valid directive wins. Invalid or irrelevant hook return values + are silently ignored so existing observer-only hooks are unaffected. + """ 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)) + 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 []), + "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 []), ) + + block_msg: Optional[str] = None 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": + # "modify" action — transform tool_input before dispatch. + # Processed before the block/approve gate so modify directives + # are visible even when a later hook blocks. Hooks accumulate: + # each modify directive shallow-merges its keys into one + # accumulated dict built from the original args on first hit. + if result.get("action") == "modify": partial = result.get("args") if isinstance(partial, dict) and partial: - modified_args = {**(modified_args if modified_args is not None else - (args if isinstance(args, dict) else {})), **partial} + if modified_args is None: + modified_args = dict(args) if isinstance(args, dict) else {} + modified_args.update(partial) continue + action = result.get("action") 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. + # A block directive requires a message (it becomes the tool result); + # an approve directive can carry an optional reason. 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) + rule_key = rule_key.strip() if isinstance(rule_key, str) else None + if not rule_key: + rule_key = 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]], **hook_kwargs: Any + 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``. - ``hook_kwargs`` are the observability ids of :func:`_get_pre_tool_call_directive_details`.""" - details = _get_pre_tool_call_directive_details(tool_name, args, **hook_kwargs) + """Check ``pre_tool_call`` hooks for a blocking or approval directive. + + Backward-compatible public helper: returns ``(directive, message)`` where + ``directive`` is ``"block"``, ``"approve"``, or ``None``. Internal callers + that need approve-specific metadata use + :func:`_get_pre_tool_call_directive_details`. + """ + 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]], **hook_kwargs: Any + 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, **hook_kwargs) + """Back-compat shim: return only a ``block`` message (or ``None``). + + Deprecated in favor of :func:`get_pre_tool_call_directive`, which also + surfaces the ``approve`` escalation directive. Kept so any external caller + importing the old name keeps working; ``approve`` directives are invisible + to this shim (it only reports blocks). + """ + 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]], **hook_kwargs: Any + 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, **hook_kwargs)[0] + """Resolve the pre_tool_call directive to a final block message (or None). + + Single entry point for every tool-dispatch site: fetches the plugin + directive and, for an ``approve`` escalation, invokes the human-approval + gate (:func:`tools.approval.request_tool_approval`). Returns the message + the tool result should carry when the call is blocked, or ``None`` when + the call may proceed. + + Centralizing this keeps the security-critical fail-closed logic in ONE + place instead of copy-pasted across the concurrent/sequential/helper + dispatch paths: an ``approve`` directive whose gate errors, denies, or + times out is fail-closed to a block; ``block`` blocks with its message; + anything else proceeds. + """ + 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 _resolve_block_from_details( + details, tool_name, + turn_id=turn_id, tool_call_id=tool_call_id, session_id=session_id, + ) def _resolve_block_from_details( - details: "_PreToolCallDirective", tool_name: str, *, turn_id: str = "", tool_call_id: str = "", + 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.""" + """Resolve a fetched directive to a final block message (or ``None``). + + Shared by :func:`resolve_pre_tool_block` and + :func:`_dispatch_pre_tool_call_hooks` so the security-critical + fail-closed approval logic lives in exactly ONE place: ``block`` + blocks with its message; an ``approve`` directive whose gate errors, + denies, or times out is fail-closed to a block; anything else + proceeds. + """ if details.action == "block": return details.message - if details.action != "approve": - return None - try: - from tools.approval import request_tool_approval - from tools.approval_context import 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) + if details.action == "approve": 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}") + from tools.approval import ( + request_tool_approval, + reset_current_observability_context, + set_current_observability_context, + ) + + approval_tokens = None + try: + approval_tokens = set_current_observability_context( + turn_id=turn_id, + tool_call_id=tool_call_id, + session_id=session_id, + ) + except Exception: + pass + 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: + try: + reset_current_observability_context(approval_tokens) + except Exception: + pass + 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]], **hook_kwargs: Any + 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, **hook_kwargs) + """Invoke ``pre_tool_call`` hooks once and process all response types. + + Returns a ``(block_message, modified_args)`` tuple: + - ``block_message`` — the first block/approve directive's resolved message + (or ``None`` when the call may proceed). Shares the exact fail-closed + approval-gate logic of :func:`resolve_pre_tool_block` via + :func:`_resolve_block_from_details`, including the observability + context set around the human-approval gate. + - ``modified_args`` — merged args from ``modify`` directives + (or ``None`` when no hook requested modification). + + This is the single invocation point for ``pre_tool_call`` hooks. + Callers that only need block detection should keep using + :func:`get_pre_tool_call_block_message` or + :func:`resolve_pre_tool_block` for backward compat. + Callers that also need input transformation should call this + function and apply ``modified_args`` if not ``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, **{k: hook_kwargs.get(k, "") for k in ("turn_id", "tool_call_id", "session_id")}) + 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, + *, + 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.""" + """Check user ``pre_verify`` hooks for a directive to keep the agent going. + + Fired once per turn when the agent edited code and is about to verify/finish. + A hook keeps the turn going (run a check, defer it, tidy the diff) by + returning:: + + {"action": "continue", "message": ""} + + The Claude-Code Stop shape ``{"decision": "block", "reason": "..."}`` (block + the stop == keep going) is accepted too. The first directive carrying a + non-empty message wins; any other return lets the turn finish. Mirrors + :func:`get_pre_tool_call_block_message` — the call site stays a one-liner. + + ``coding`` / ``attempt`` let a hook scope itself (``if not coding`` …) and + self-throttle (``if attempt`` …), the same way a ``pre_tool_call`` hook + scopes on ``tool_name``. + """ 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 []), + "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 action in ("continue", "block") and isinstance(message, str) and message.strip(): + 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, + *, + 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: 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: inputs may be unredacted. - - A callback returns ``None`` to decline, or a dict with a required ``"reason"`` (a - :class:`agent.error_classifier.FailoverReason` member or its string name) plus optional recovery-hint - overrides. Dispatch is run-all-then-pick-first: ``invoke_hook`` runs every registered callback with - failures isolated, then the first result carrying a valid reason wins in registration order — mirroring - :func:`get_pre_tool_call_block_message`, invalid or irrelevant returns are silently ignored so a - misbehaving plugin degrades to a no-op. When more than one callback returns a valid classification, the - losing results are skipped with a runtime warning (the #64714 skipped-transform rule) so conflicting - provider plugins are visible in logs instead of silently shadowed. + """Check ``transform_api_error_classification`` hooks for a directive. + + Consulted by :func:`agent.error_classifier.classify_api_error` BEFORE + its built-in pipeline, so a provider plugin can both add classifications + the core patterns miss and correct ones they get wrong for its provider. + + A callback returns ``None`` to decline, or a dict with a required + ``"reason"`` (a :class:`agent.error_classifier.FailoverReason` member or + its string name) plus optional recovery-hint overrides. Dispatch is + run-all-then-pick-first: ``invoke_hook`` runs every registered callback + with failures isolated, then the first result carrying a valid reason + wins in registration order — mirroring + :func:`get_pre_tool_call_block_message`, invalid or irrelevant returns + are silently ignored so a misbehaving plugin degrades to a no-op. + When more than one callback returns a valid classification, the losing + results are skipped with a runtime warning (the #64714 + skipped-transform rule) so conflicting provider plugins are visible in + logs instead of silently shadowed. + + Privacy: ``error_message`` and ``error_body`` may carry an unredacted + provider error dump; callbacks must not log or forward them without + redaction. + + Cold path: fires only on API failure, never on the request hot path. + Contract: the transform-family first-valid-wins shape in + ``docs/plugins/hook-taxonomy.md``. + + Returns a sanitized dict (``reason`` coerced to ``FailoverReason``, hint + fields coerced to ``bool``) or ``None`` when no plugin claimed the error. """ 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, + "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, ) - def _reason(result: Any) -> Any: - reason = result.get("reason") if isinstance(result, dict) else None - if isinstance(reason, str): - with suppress(ValueError): - return FailoverReason(reason.strip().lower()) - return None - return reason if isinstance(reason, FailoverReason) else None + winner: Optional[Dict[str, Any]] = None + skipped_valid = 0 + for result in hook_results: + if not isinstance(result, dict): + continue + reason_raw = result.get("reason") + if isinstance(reason_raw, FailoverReason): + reason = reason_raw + elif isinstance(reason_raw, str): + try: + reason = FailoverReason(reason_raw.strip().lower()) + except ValueError: + continue + else: + continue - valid = [(result, reason) for result in hook_results if (reason := _reason(result)) is not None] - if not valid: - return None - result, reason = valid[0] - winner: Dict[str, Any] = {"reason": reason} - for key in ("retryable", "should_compress", "should_rotate_credential", "should_fallback"): - if key in result: - winner[key] = bool(result[key]) - message = result.get("message") - if isinstance(message, str) and message.strip(): - winner["message"] = message.strip()[:500] - if isinstance(result.get("error_context"), dict): - winner["error_context"] = result["error_context"] - if len(valid) > 1: - logger.warning("transform_api_error_classification: skipped %d valid classification(s) after the " - "first result in registration order won (run-all-then-pick-first)", len(valid) - 1) + 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.""" + """Return the global manager after ensuring plugin discovery has run. + + Pass ``force=True`` to rescan in the current process. + """ manager = get_plugin_manager() manager.discover_and_load(force=force) return manager @@ -1981,15 +6497,23 @@ def get_plugin_command_handler(name: str) -> Optional[Callable]: 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).""" + """Resolve a plugin command return value, awaiting async handlers when needed. + + Sync CLI/TUI dispatch sites call plugin handlers from plain functions. + If a handler is async, await it directly when no loop is running; if + we're already inside an active loop, run it in a helper thread with its + own loop so the caller still gets a concrete result synchronously. The + threaded path is bounded by a 30s timeout so a hung async handler cannot + wedge the terminal indefinitely. + """ 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() @@ -2002,68 +6526,45 @@ def _runner() -> None: finally: done.set() - threading.Thread(target=_runner, name="hermes-plugin-command-await", daemon=True).start() + 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") + 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 the full plugin commands dict (name → {handler, description, plugin}). + + Triggers idempotent plugin discovery so callers can use plugin commands + before any explicit discover_plugins() call. + """ 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)] - + """Return all plugin-registered auxiliary tasks as a stable-ordered list. -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, then map each toolset back to the plugin that - # registered it (first owner wins) for the description. - 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) - 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 + Each entry is the registration dict from + :meth:`PluginContext.register_auxiliary_task`: + ``{key, display_name, description, defaults, plugin}``. + Triggers idempotent plugin discovery so callers can read the registry + before any explicit ``discover_plugins()`` call. Sorted by ``key`` for + deterministic ordering in pickers and tests. + """ + manager = _ensure_plugins_discovered() + return [manager._aux_tasks[k] for k in sorted(manager._aux_tasks)] -# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ---- -# Names external plugins imported from this module before the Sep 2026 decomposition. -# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does). -# The whole block is removed by reverting the commit that added it. -from typing import Iterable # noqa: F401,E402 -from typing import Type # noqa: F401,E402 -from contextlib import contextmanager # noqa: F401,E402 -import contextvars # noqa: F401,E402 -import copy # noqa: F401,E402 -import hashlib # noqa: F401,E402 -import time # noqa: F401,E402 -from functools import wraps # noqa: F401,E402 def get_plugin_subscriptions() -> Dict[str, List[Callable]]: """Return the inter-plugin event bus subscription registry. @@ -2080,39 +6581,47 @@ def get_plugin_subscriptions() -> Dict[str, List[Callable]]: for event, entries in manager._subscriptions.items() } -def unload_plugins( - plugin: Union[str, PluginManifest, LoadedPlugin, None] = None, -) -> bool: - """Unload one plugin or all plugins from the process-global manager. - Wait for background discovery first so teardown cannot race an in-flight - registration sweep introduced by the warm-start discovery path. +def get_plugin_toolsets() -> List[tuple]: + """Return plugin toolsets as ``(key, label, description)`` tuples. + + Used by the ``hermes tools`` TUI so plugin-provided toolsets appear + alongside the built-in ones and can be toggled on/off per platform. """ - _join_background_discovery() - return get_plugin_manager().unload(plugin) + manager = get_plugin_manager() + if not manager._plugin_tool_names: + return [] + + try: + from tools.registry import registry + except Exception: + return [] + # Group plugin tool names by their toolset + toolset_tools: Dict[str, List[str]] = {} + toolset_plugin: Dict[str, LoadedPlugin] = {} + for tool_name in manager._plugin_tool_names: + entry = registry.get_entry(tool_name) + if not entry: + continue + ts = entry.toolset + toolset_tools.setdefault(ts, []).append(entry.name) -_PLUGIN_COMPAT_LAZY = { - 'CAPABILITY_REGISTRY': ('hermes_cli.plugin_capabilities', 'CAPABILITY_REGISTRY'), - 'ENTRY_POINT_CAPABILITIES_GROUP': ('hermes_cli.plugins_discovery', 'ENTRY_POINT_CAPABILITIES_GROUP'), - 'LEGACY_RELAY_PLUGIN_KEYS': ('hermes_cli.relay_plugin_cutover', 'LEGACY_RELAY_PLUGIN_KEYS'), - 'MAX_SYSTEM_PROMPT_SECTIONS': ('hermes_cli.plugins_dispatch', 'MAX_SYSTEM_PROMPT_SECTIONS'), - 'OBSERVER_SCHEMA_VERSION': ('hermes_cli.middleware', 'OBSERVER_SCHEMA_VERSION'), - 'VALID_CAPABILITY_IDS': ('hermes_cli.plugin_capabilities', 'VALID_CAPABILITY_IDS'), - 'cfg_get': ('hermes_cli.config', 'cfg_get'), - 'fast_safe_load': ('utils', 'fast_safe_load'), - 'format_system_prompt_section': ('hermes_cli.plugins_dispatch', 'format_system_prompt_section'), - 'reset_hermes_home_override': ('hermes_constants', 'reset_hermes_home_override'), - 'set_hermes_home_override': ('hermes_constants', 'set_hermes_home_override'), -} + # Map toolsets back to the plugin that registered them + for _name, loaded in manager._plugins.items(): + for tool_name in loaded.tools_registered: + entry = registry.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) + label = f"🔌 {ts_key.replace('_', ' ').title()}" + if plugin and plugin.manifest.description: + desc = plugin.manifest.description + else: + desc = ", ".join(sorted(toolset_tools[ts_key])) + result.append((ts_key, label, desc)) -def __getattr__(name): # PEP 562 — lazy so no import cycles - target = _PLUGIN_COMPAT_LAZY.get(name) - if target is None: - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - import importlib - from hermes_cli.plugin_compat import warn_once - warn_once(__name__, name, *target) - return getattr(importlib.import_module(target[0]), target[1]) -# ---- END PLUGIN-COMPAT ---- + return result diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 85415617ae5b7..5f940fa7ba85c 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -462,6 +462,34 @@ def test_load_registers_namespace_module(self, tmp_path, monkeypatch): assert "hermes_plugins.ns_plugin" in sys.modules + def test_load_survives_register_skill_string_path(self, tmp_path, monkeypatch): + """A plugin that passes a str skill path must load, not AttributeError.""" + hermes_home = tmp_path / "hermes_test" + plugins_dir = hermes_home / "plugins" + plugin_dir = plugins_dir / "string-skill" + plugin_dir.mkdir(parents=True) + (plugin_dir / "SKILL.md").write_text("---\nname: demo\n---\nDemo.\n") + (plugin_dir / "plugin.yaml").write_text( + yaml.dump({"name": "string-skill", "version": "0.1.0", "description": "str path"}) + ) + (plugin_dir / "__init__.py").write_text( + "from pathlib import Path\n" + "def register(ctx):\n" + " ctx.register_skill('demo', str(Path(__file__).parent / 'SKILL.md'), 'demo')\n" + ) + (hermes_home / "config.yaml").write_text( + yaml.safe_dump({"plugins": {"enabled": ["string-skill"]}}) + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + mgr = PluginManager() + mgr.discover_and_load() + + loaded = mgr._plugins["string-skill"] + assert loaded.enabled, loaded.error + assert loaded.error is None + assert mgr.find_plugin_skill("string-skill:demo") is not None + def test_user_memory_plugin_auto_coerced_to_exclusive(self, tmp_path, monkeypatch): """User-installed memory plugins must NOT be loaded by the general PluginManager — they belong to plugins/memory discovery. diff --git a/tests/test_plugin_skills.py b/tests/test_plugin_skills.py index b2f49061fa3d7..b54dd5f874e1c 100644 --- a/tests/test_plugin_skills.py +++ b/tests/test_plugin_skills.py @@ -139,6 +139,22 @@ def test_rejects_missing_file(self, ctx, tmp_path): with pytest.raises(FileNotFoundError): ctx.register_skill("foo", tmp_path / "nonexistent.md") + def test_accepts_string_path(self, ctx, tmp_path): + skill_md = tmp_path / "skills" / "demo" / "SKILL.md" + skill_md.parent.mkdir(parents=True) + skill_md.write_text("---\nname: demo\n---\nContent.\n") + + ctx.register_skill("demo", str(skill_md), "A demo skill") + assert ctx._manager.find_plugin_skill("testplugin:demo") == skill_md + + def test_string_missing_path_is_filenotfound(self, ctx, tmp_path): + with pytest.raises(FileNotFoundError, match="SKILL.md not found"): + ctx.register_skill("demo", str(tmp_path / "missing.md")) + + def test_invalid_path_type_is_clear_error(self, ctx): + with pytest.raises(ValueError, match="Invalid skill path"): + ctx.register_skill("demo", 123) # type: ignore[arg-type] + def test_duplicate_qualified_name_is_rejected(self, ctx, tmp_path): ctx.manifest.portable = True first = tmp_path / "first" / "SKILL.md"