7194 lines
300 KiB
Python
7194 lines
300 KiB
Python
"""
|
||
Hermes Plugin System
|
||
====================
|
||
|
||
Discovers, loads, and manages plugins from four sources:
|
||
|
||
1. **Bundled plugins** – ``<repo>/plugins/<name>/`` (shipped with hermes-agent;
|
||
``memory/`` and ``context_engine/`` subdirs are excluded — they have their
|
||
own discovery paths)
|
||
2. **User plugins** – ``~/.hermes/plugins/<name>/``
|
||
3. **Project plugins** – ``./.hermes/plugins/<name>/`` (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 contextvars
|
||
import copy
|
||
import hashlib
|
||
import importlib.metadata
|
||
import importlib.util
|
||
import inspect
|
||
import json
|
||
import logging
|
||
import os
|
||
import queue
|
||
import re
|
||
import sys
|
||
import threading
|
||
import time
|
||
import types
|
||
from contextlib import contextmanager
|
||
from dataclasses import dataclass, field
|
||
from functools import wraps
|
||
from pathlib import Path
|
||
from typing import (Any, Callable, Dict, Iterable, List, Mapping, Optional, Set, Tuple, Type, Union)
|
||
|
||
from hermes_constants import (
|
||
get_hermes_home,
|
||
hermes_home_key,
|
||
reset_hermes_home_override,
|
||
set_hermes_home_override,
|
||
)
|
||
from registration_lifecycle import replacement_coordinator
|
||
from utils import env_var_enabled, fast_safe_load
|
||
from hermes_cli.config import cfg_get, load_config_readonly
|
||
from hermes_cli.middleware import OBSERVER_SCHEMA_VERSION, VALID_MIDDLEWARE
|
||
from hermes_cli.plugin_capabilities import ( # noqa: F401 — re-exported
|
||
CAPABILITY_REGISTRY,
|
||
VALID_CAPABILITY_IDS,
|
||
plugin_capability_granted,
|
||
)
|
||
from hermes_cli.plugin_capabilities import (
|
||
parse_declared_capabilities as _parse_declared_capabilities,
|
||
)
|
||
from hermes_cli.relay_plugin_cutover import (
|
||
LEGACY_RELAY_PLUGIN_KEYS,
|
||
RELAY_PLUGINS_CONFIG_ENV,
|
||
legacy_relay_plugin_keys,
|
||
)
|
||
|
||
|
||
def get_bundled_plugins_dir() -> Path:
|
||
"""Locate the bundled ``plugins/`` directory.
|
||
|
||
Honours ``HERMES_BUNDLED_PLUGINS`` (set by the Nix wrapper / packaged
|
||
installs) so read-only store paths are consulted first. Falls back to
|
||
the in-repo path used during development.
|
||
"""
|
||
env_override = os.getenv("HERMES_BUNDLED_PLUGINS")
|
||
if env_override:
|
||
return Path(env_override)
|
||
return Path(__file__).resolve().parent.parent / "plugins"
|
||
|
||
try:
|
||
import yaml
|
||
except ImportError: # pragma: no cover – yaml is optional at import time
|
||
yaml = None # type: ignore[assignment]
|
||
|
||
|
||
class PluginToolOverrideError(PermissionError):
|
||
"""Raised when a plugin attempts to override a built-in tool without
|
||
operator opt-in via ``plugins.entries.<plugin_id>.allow_tool_override``.
|
||
"""
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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.
|
||
|
||
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 = os.getenv("HERMES_PLUGINS_DEBUG", "").strip().lower() in {
|
||
"1", "true", "yes", "on",
|
||
}
|
||
if not _PLUGINS_DEBUG or _DEBUG_HANDLER_INSTALLED:
|
||
return
|
||
handler = logging.StreamHandler(sys.stderr)
|
||
handler.setLevel(logging.DEBUG)
|
||
handler.setFormatter(logging.Formatter("[plugins] %(levelname)s %(message)s"))
|
||
logger.addHandler(handler)
|
||
logger.setLevel(logging.DEBUG)
|
||
# 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"
|
||
)
|
||
|
||
|
||
_install_plugin_debug_handler()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Constants
|
||
# ---------------------------------------------------------------------------
|
||
|
||
VALID_HOOKS: Set[str] = {
|
||
"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": "<follow-up instruction>"}
|
||
# 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": "<FailoverReason name>", # 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 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 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 <profile> 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",
|
||
# 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 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",
|
||
}
|
||
|
||
# Timeout coverage is an allowlist for the agent-turn hot path, not every
|
||
# entry in VALID_HOOKS. The goal is to stop a hung Python plugin callback from
|
||
# wedging the conversation loop (#76821) without joining the worker (avoids
|
||
# the #6622 ThreadPoolExecutor shutdown hang). Hooks not listed below run
|
||
# synchronously to completion.
|
||
#
|
||
# Intentionally unbounded (no hook_callback_timeout wrapper):
|
||
# - on_session_finalize / on_session_reset — infrequent teardown / session
|
||
# swap; finalize is a last-chance flush where fail-open abandon can lose
|
||
# state. (on_session_start/end stay bounded — they sit on the common
|
||
# session-boundary path.)
|
||
# - subagent_start — observer only; blocking delegation belongs in
|
||
# pre_tool_call. Lower frequency than tool/LLM hooks.
|
||
# - pre_gateway_dispatch — policy gate (skip/rewrite/allow). Abandoning is
|
||
# unsafe either way (fail-open skips auth-like checks; fail-closed can
|
||
# drop legitimate messages). Prefer finish-or-exception fallthrough.
|
||
# - pre_approval_request / post_approval_response — observers only (cannot
|
||
# veto); the approval UX already has its own timeout; not on the tool
|
||
# loop hot path.
|
||
# - kanban_task_* — fire after the board DB commit, observers only, in
|
||
# dispatcher/worker processes; kanban has its own heartbeat/stale reclaim.
|
||
# Abandon-without-join also leaves a daemon thread that may still mutate
|
||
# shared state — safer for value-returning observers than for gates/flushes.
|
||
#
|
||
# Bounded hooks: timeout is fail-open (abandon/skip, agent continues).
|
||
_HOOK_TIMEOUT_BOUNDED_HOOKS: Set[str] = {
|
||
"post_tool_call",
|
||
"transform_terminal_output",
|
||
"transform_tool_result",
|
||
"transform_llm_output",
|
||
"pre_llm_call",
|
||
"post_llm_call",
|
||
"pre_api_request",
|
||
"post_api_request",
|
||
"api_request_error",
|
||
"pre_verify",
|
||
"on_session_start",
|
||
"on_session_end",
|
||
}
|
||
|
||
# Policy hooks: timeout / still-running must fail closed (block the tool).
|
||
# Skipping would let the tool run without a completed policy decision.
|
||
_HOOK_TIMEOUT_FAIL_CLOSED_HOOKS: Set[str] = {"pre_tool_call"}
|
||
|
||
# Documented parent-thread serialization contract — never move the callback
|
||
# body onto a timeout worker (see website/docs/user-guide/features/hooks.md).
|
||
_HOOK_CALLER_THREAD_HOOKS: Set[str] = {"subagent_stop"}
|
||
|
||
# After a timeout, suppress re-firing the same callback for this long so a
|
||
# repeatedly invoked hung hook cannot accumulate abandoned daemon threads.
|
||
_HOOK_TIMEOUT_SUPPRESSION_SECONDS = 60.0
|
||
|
||
_PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE = (
|
||
"pre_tool_call plugin callback timed out or is still running"
|
||
)
|
||
|
||
ENTRY_POINTS_GROUP = "hermes_agent.plugins"
|
||
ENTRY_POINT_CAPABILITIES_GROUP = "hermes_agent.plugin_capabilities"
|
||
|
||
|
||
def _select_entry_point_group(entry_points: Any, group: str) -> list:
|
||
"""Return one metadata entry-point group across supported Python APIs."""
|
||
if hasattr(entry_points, "select"):
|
||
return list(entry_points.select(group=group))
|
||
if isinstance(entry_points, dict):
|
||
return list(entry_points.get(group, []))
|
||
return [ep for ep in entry_points if ep.group == group]
|
||
|
||
|
||
def discover_entrypoint_manifests() -> List["PluginManifest"]:
|
||
"""Return metadata-only manifests for installed entry-point plugins.
|
||
|
||
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 ``<plugin-id>.<capability-id>`` 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 = "<!-- hermes-plugin-sections:start -->"
|
||
PLUGIN_SECTIONS_END = "<!-- hermes-plugin-sections:end -->"
|
||
|
||
|
||
def is_valid_system_prompt_section_id(value: Any) -> bool:
|
||
"""Return whether *value* is a stable, heading-safe section identifier."""
|
||
return isinstance(value, str) and bool(_SYSTEM_PROMPT_SECTION_ID_RE.fullmatch(value))
|
||
|
||
|
||
def format_system_prompt_section(section_id: str, content: str) -> str:
|
||
"""Render an auditable, length-framed block recoverable from the full prompt."""
|
||
return (
|
||
f"{_SYSTEM_PROMPT_SECTION_HEADING_PREFIX}{section_id}\n"
|
||
f"<!-- hermes-plugin-section-chars:{len(content)} -->\n\n"
|
||
f"{content}"
|
||
)
|
||
|
||
|
||
def format_system_prompt_sections(sections: list) -> str:
|
||
"""Render the canonical container used for persistence recovery."""
|
||
if not sections:
|
||
return ""
|
||
blocks = [format_system_prompt_section(item.id, item.content) for item in sections]
|
||
return f"{PLUGIN_SECTIONS_START}\n" + "\n\n".join(blocks) + f"\n{PLUGIN_SECTIONS_END}"
|
||
# Reserved event namespace prefix — only core may publish ``hermes:<event>``.
|
||
HERMES_EVENT_NAMESPACE = "hermes"
|
||
|
||
# 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)
|
||
|
||
|
||
@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 ``<category>.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.<id>.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.<id>.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
|
||
# ``<key>:`` namespace (e.g. ``["ping"]`` → publishes ``<key>:ping``).
|
||
# ``listens`` lists the fully-qualified ``<plugin>:<event>`` 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
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _QueuedPluginEvent:
|
||
"""Immutable dispatch envelope consumed by the event worker."""
|
||
|
||
event: str
|
||
payload: Dict[str, Any]
|
||
subscriptions: tuple[_EventSubscription, ...]
|
||
depth: int
|
||
generation: int
|
||
|
||
|
||
@dataclass
|
||
class LoadedPlugin:
|
||
"""Runtime state for a single loaded plugin."""
|
||
|
||
manifest: PluginManifest
|
||
module: Optional[types.ModuleType] = None
|
||
tools_registered: List[str] = field(default_factory=list)
|
||
hooks_registered: List[str] = field(default_factory=list)
|
||
middleware_registered: List[str] = field(default_factory=list)
|
||
commands_registered: List[str] = field(default_factory=list)
|
||
enabled: bool = False
|
||
error: Optional[str] = None
|
||
# 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 = ""
|
||
# Process-global host infrastructure (e.g. dashboard-auth providers) whose
|
||
# lifetime is the server, not this per-home manager: kept out of
|
||
# ``_registration_order`` so a routine unload-all cannot dispose it
|
||
# (#91701), but still disposed by a *targeted* unload (plugin disable/
|
||
# uninstall) and evicted on force re-discovery when the plugin no longer
|
||
# re-registers it.
|
||
persistent: bool = False
|
||
_disposed: bool = field(default=False, init=False, repr=False)
|
||
_on_dispose: Optional[Callable[["PluginRegistration"], None]] = field(
|
||
default=None, init=False, repr=False
|
||
)
|
||
|
||
@property
|
||
def active(self) -> bool:
|
||
"""Whether this handle still owns an active registration."""
|
||
return not self._disposed
|
||
|
||
def dispose(self) -> None:
|
||
"""Release this registration once; repeated disposal is harmless."""
|
||
if self._disposed:
|
||
return
|
||
self._disposed = True
|
||
try:
|
||
self.release()
|
||
finally:
|
||
if self._on_dispose is not None:
|
||
self._on_dispose(self)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# PluginContext – handed to each plugin's ``register()`` function
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_PLUGIN_SETTING_SEGMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
|
||
_PLUGIN_SETTING_RESERVED_ROOTS = frozenset({"model", "plugins", "security", "settings"})
|
||
_PLUGIN_STATE_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
||
_PLUGIN_STATE_QUOTA_BYTES = 10 * 1024 * 1024
|
||
_PLUGIN_STATE_LOCKS: Dict[str, threading.RLock] = {}
|
||
_PLUGIN_STATE_LOCKS_GUARD = threading.Lock()
|
||
|
||
|
||
def _plugin_relative_segments(key: str) -> tuple[str, ...]:
|
||
"""Validate 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
|
||
# 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 self.manifest.key or self.manifest.name
|
||
|
||
def has_plugin(self, plugin_id: str) -> bool:
|
||
"""Return True when another plugin is loaded and enabled (#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.
|
||
"""
|
||
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.<plugin_id>.settings.<key>``.
|
||
|
||
``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:
|
||
segments = _plugin_relative_segments(key)
|
||
except ValueError:
|
||
logger.warning(
|
||
"Rejected config path %r from plugin %s", key, self.plugin_id
|
||
)
|
||
raise
|
||
from hermes_cli.config import load_config_readonly
|
||
|
||
config = load_config_readonly() or {}
|
||
plugins = config.get("plugins") if isinstance(config, Mapping) else None
|
||
entries = plugins.get("entries") if isinstance(plugins, Mapping) else None
|
||
entry = entries.get(self.plugin_id) if isinstance(entries, Mapping) else None
|
||
if not isinstance(entry, Mapping):
|
||
return default
|
||
missing = object()
|
||
value = _nested_plugin_value(entry.get("settings"), segments, missing)
|
||
if value is not missing:
|
||
return value
|
||
return _nested_plugin_value(entry.get("config"), segments, default)
|
||
|
||
def set_config(self, key: str, value: Any) -> None:
|
||
"""Atomically write one value in this plugin's ``settings`` subtree."""
|
||
try:
|
||
segments = _plugin_relative_segments(key)
|
||
except ValueError:
|
||
logger.warning(
|
||
"Rejected config path %r from plugin %s", key, self.plugin_id
|
||
)
|
||
raise
|
||
from hermes_cli import config as config_mod
|
||
|
||
if config_mod.is_managed():
|
||
raise PermissionError(
|
||
"Plugin settings cannot be changed in a managed install"
|
||
)
|
||
from hermes_cli import managed_scope
|
||
|
||
dotted_path = ".".join((
|
||
"plugins",
|
||
"entries",
|
||
self.plugin_id,
|
||
"settings",
|
||
*segments,
|
||
))
|
||
if managed_scope.is_key_managed(dotted_path):
|
||
raise PermissionError(
|
||
f"Plugin setting {dotted_path!r} is administrator-managed"
|
||
)
|
||
partial = {
|
||
"plugins": {
|
||
"entries": {
|
||
self.plugin_id: {
|
||
"settings": _nested_plugin_mapping(segments, value),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
full_path = ("plugins", "entries", self.plugin_id, "settings", *segments)
|
||
# The lock covers 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:
|
||
"""Return this plugin's profile-scoped durable JSON state facade."""
|
||
if self._state is None:
|
||
self._state = PluginState(self.plugin_id, self.manifest.skill_namespace)
|
||
return self._state
|
||
|
||
@property
|
||
def platform_actions(self):
|
||
"""Capability-gated platform action facade (#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.<id>.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
|
||
|
||
self._platform_actions = PlatformActions(self.plugin_id)
|
||
return self._platform_actions
|
||
|
||
def _track(
|
||
self,
|
||
kind: str,
|
||
key: str,
|
||
release: Callable[[], None],
|
||
*,
|
||
persistent: bool = False,
|
||
) -> PluginRegistration:
|
||
"""Record host-owned cleanup for a successful registration.
|
||
|
||
``persistent`` registrations are returned as live handles but kept
|
||
out of the manager's reverse-order teardown, so a routine per-home
|
||
manager unload does not dispose them (see
|
||
:meth:`PluginManager._track_registration`).
|
||
"""
|
||
return self._manager._track_registration(
|
||
self.manifest, kind, key, release, persistent=persistent
|
||
)
|
||
|
||
def _track_replacement(
|
||
self,
|
||
kind: str,
|
||
key: str,
|
||
*,
|
||
slot: tuple,
|
||
current: Any,
|
||
previous: Any,
|
||
restore: Callable[[Any], bool],
|
||
finalize: Optional[Callable[[], None]] = None,
|
||
) -> PluginRegistration:
|
||
"""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)
|
||
|
||
# -- host-owned LLM access ----------------------------------------------
|
||
|
||
@property
|
||
def llm(self) -> Any:
|
||
"""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.<plugin_id>.llm.*`` config keys.
|
||
|
||
See :mod:`agent.plugin_llm` for the full surface."""
|
||
if self._llm is None:
|
||
from agent.plugin_llm import PluginLlm
|
||
plugin_id = self.manifest.key or self.manifest.name
|
||
self._llm = PluginLlm(plugin_id=plugin_id)
|
||
return self._llm
|
||
|
||
@property
|
||
def subagent_lifecycle(self) -> Any:
|
||
"""Return the public, plugin-safe subagent lifecycle service.
|
||
|
||
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/<name>``, 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 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)
|
||
logger.debug("Plugin %s registered on_unload callback", self.manifest.name)
|
||
return handle
|
||
|
||
def spawn_task(self, coro, *, name: Optional[str] = None) -> "asyncio.Task":
|
||
"""Spawn a supervised 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)
|
||
|
||
def _cancel_task() -> None:
|
||
if not task.done():
|
||
task.cancel()
|
||
|
||
handle = self._track("background_task", task_name, _cancel_task)
|
||
task.add_done_callback(lambda _t: handle.dispose())
|
||
logger.debug(
|
||
"Plugin %s spawned supervised task: %s", self.manifest.name, task_name
|
||
)
|
||
return task
|
||
|
||
# -- approval transport registration ------------------------------------
|
||
|
||
def register_approval_transport(self, name: str, present_fn: Callable) -> None:
|
||
"""Register a human approval presentation transport.
|
||
|
||
The transport is inactive until the operator explicitly selects
|
||
``security.approval.transport: <name>``. 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,
|
||
)
|
||
# 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,
|
||
) -> Optional[PluginRegistration]:
|
||
"""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.<plugin_id>.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 "
|
||
f"{name!r}. Set "
|
||
f"plugins.entries.{plugin_id}.allow_tool_override: true "
|
||
f"in config.yaml to allow this plugin to replace built-in tools."
|
||
)
|
||
|
||
from tools.registry import registry
|
||
|
||
scope = self._manager.scope_key
|
||
previous = registry.snapshot_registration(name, scope=scope)
|
||
effective = registry.get_entry(name, scope=scope)
|
||
if previous is None and effective is not None and not override:
|
||
logger.warning(
|
||
"Plugin %s tried to shadow global tool %s without override=True",
|
||
self.manifest.name,
|
||
name,
|
||
)
|
||
return None
|
||
registry.register(
|
||
name=name,
|
||
toolset=toolset,
|
||
schema=schema,
|
||
handler=handler,
|
||
check_fn=check_fn,
|
||
requires_env=requires_env,
|
||
is_async=is_async,
|
||
description=description,
|
||
emoji=emoji,
|
||
override=override,
|
||
scope=scope,
|
||
)
|
||
registered = registry.snapshot_registration(name, scope=scope)
|
||
if (
|
||
registered is not None
|
||
and registered is not previous
|
||
and registered.handler is handler
|
||
):
|
||
self._manager._plugin_tool_names.add(name)
|
||
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),
|
||
)
|
||
else:
|
||
handle = None
|
||
logger.debug(
|
||
"Plugin %s registered tool: %s%s",
|
||
self.manifest.name, name, " (override)" if override else "",
|
||
)
|
||
return handle
|
||
|
||
# -- capability probing (#64228) -----------------------------------------
|
||
|
||
def has_capability(self, capability: str) -> bool:
|
||
"""Return True when *capability* is live for this plugin.
|
||
|
||
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
|
||
plugin_id = self.manifest.key or self.manifest.name
|
||
return plugin_capability_granted(plugin_id, capability)
|
||
|
||
# -- capability-gated MCP access ----------------------------------------
|
||
|
||
def call_mcp(
|
||
self,
|
||
server: str,
|
||
tool: str,
|
||
arguments: Optional[Dict[str, Any]] = None,
|
||
timeout: float = 30,
|
||
) -> Dict[str, Any]:
|
||
"""Call 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.<plugin_id>.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": <parsed result>}`` on
|
||
success or ``{"ok": False, "error": <message>}`` 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``.
|
||
"""
|
||
plugin_id = self.manifest.key or self.manifest.name
|
||
allowlist = self._mcp_allowlist(plugin_id)
|
||
if server not in allowlist:
|
||
raise PermissionError(
|
||
f"Plugin {self.manifest.name!r} is not allowed to call MCP "
|
||
f"server {server!r}. Add it to "
|
||
f"plugins.entries.{plugin_id}.mcp_allowlist in config.yaml "
|
||
f"to grant access (default is no MCP access)."
|
||
)
|
||
|
||
try:
|
||
timeout = float(timeout)
|
||
except (TypeError, ValueError):
|
||
timeout = 30.0
|
||
timeout = max(1.0, min(timeout, 600.0))
|
||
|
||
# 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
|
||
|
||
@classmethod
|
||
def _mcp_envelope(cls, raw: Any) -> Dict[str, Any]:
|
||
"""Normalize an MCP handler result string into a stable envelope."""
|
||
if not isinstance(raw, str):
|
||
raw = "" if raw is None else str(raw)
|
||
if len(raw) > cls._MCP_RESULT_CHAR_CAP:
|
||
raw = raw[: cls._MCP_RESULT_CHAR_CAP] + "… [truncated]"
|
||
truncated = True
|
||
else:
|
||
truncated = False
|
||
parsed: Any = None
|
||
try:
|
||
parsed = json.loads(raw)
|
||
except (ValueError, TypeError):
|
||
parsed = None
|
||
if isinstance(parsed, dict) and "error" in parsed:
|
||
envelope: Dict[str, Any] = {"ok": False, "error": parsed["error"]}
|
||
elif isinstance(parsed, dict) and "result" in parsed:
|
||
envelope = {"ok": True, "result": parsed["result"]}
|
||
if "structuredContent" in parsed:
|
||
envelope["structuredContent"] = parsed["structuredContent"]
|
||
else:
|
||
envelope = {"ok": True, "result": parsed if parsed is not None else raw}
|
||
if truncated:
|
||
envelope["truncated"] = True
|
||
return envelope
|
||
|
||
@staticmethod
|
||
def _mcp_allowlist(plugin_id: str) -> List[str]:
|
||
"""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 []
|
||
entries = (cfg.get("plugins") or {}).get("entries") or {}
|
||
entry = entries.get(plugin_id) or {}
|
||
allowlist = entry.get("mcp_allowlist")
|
||
if not isinstance(allowlist, list):
|
||
return []
|
||
return [str(item) for item in allowlist]
|
||
|
||
# -- override trust gate ------------------------------------------------
|
||
|
||
def _tool_override_allowed(self, tool_name: str) -> bool:
|
||
"""Return True if this plugin 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.<plugin_id>.granted_capabilities``) OR the
|
||
deprecated legacy key ``allow_tool_override: true`` (still honored
|
||
for backward compatibility; #64228 reference migration).
|
||
"""
|
||
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:
|
||
# 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,
|
||
) -> bool:
|
||
"""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.<plugin_id>.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:
|
||
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"
|
||
)
|
||
return False
|
||
if not self._gateway_injection_allowed():
|
||
plugin_id = self.manifest.key or self.manifest.name
|
||
logger.warning(
|
||
"inject_message: gateway injection denied for plugin %s; set "
|
||
"plugins.entries.%s.allow_gateway_injection: true to allow it",
|
||
plugin_id,
|
||
plugin_id,
|
||
)
|
||
return False
|
||
|
||
if not self._manager.has_gateway_message_injector:
|
||
logger.warning("inject_message: no live gateway is available")
|
||
return False
|
||
|
||
plugin_id = self.manifest.key or self.manifest.name
|
||
try:
|
||
return bool(
|
||
self._manager.inject_gateway_message(
|
||
session_key=session_key,
|
||
content=msg,
|
||
plugin_id=plugin_id,
|
||
)
|
||
)
|
||
except Exception:
|
||
logger.warning(
|
||
"inject_message: gateway scheduling failed for plugin %s",
|
||
plugin_id,
|
||
exc_info=True,
|
||
)
|
||
return False
|
||
|
||
def _gateway_injection_allowed(self) -> bool:
|
||
"""Return whether this plugin may trigger gateway session turns."""
|
||
try:
|
||
cfg = load_config_readonly() or {}
|
||
except Exception:
|
||
return False
|
||
|
||
plugin_id = self.manifest.key or self.manifest.name
|
||
return (
|
||
cfg_get(
|
||
cfg,
|
||
"plugins",
|
||
"entries",
|
||
plugin_id,
|
||
"allow_gateway_injection",
|
||
default=False,
|
||
)
|
||
is True
|
||
)
|
||
|
||
# -- CLI command registration --------------------------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_cli_command(
|
||
self,
|
||
name: str,
|
||
help: str,
|
||
setup_fn: Callable,
|
||
handler_fn: Callable | None = None,
|
||
description: str = "",
|
||
) -> PluginRegistration:
|
||
"""Register a CLI subcommand (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.manifest.key or self.manifest.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,
|
||
) -> Optional[PluginRegistration]:
|
||
"""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 <subcommand>``
|
||
terminal commands), this registers in-session slash commands that users
|
||
invoke during a conversation.
|
||
|
||
``args_hint`` is an optional short string (e.g. ``"<file>"`` 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.
|
||
|
||
``argument_mode`` tells the desktop composer how text after the command
|
||
name behaves (``options``, ``text``, or ``mixed``). Omit it to infer
|
||
``text`` whenever ``args_hint`` is set, so ``/myplugin `` stays typeable.
|
||
|
||
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,
|
||
)
|
||
return
|
||
|
||
# Reject if it conflicts with a built-in command
|
||
try:
|
||
from hermes_cli.commands import resolve_command
|
||
if resolve_command(clean) is not None:
|
||
logger.warning(
|
||
"Plugin '%s' tried to register command '/%s' which conflicts "
|
||
"with a built-in command. Skipping.",
|
||
self.manifest.name, clean,
|
||
)
|
||
return
|
||
except Exception:
|
||
pass # If commands module isn't available, skip the check
|
||
|
||
previous = self._manager._plugin_commands.get(clean)
|
||
hint = (args_hint or "").strip()
|
||
mode = argument_mode if argument_mode in {"options", "text", "mixed"} else (
|
||
"text" if hint else None
|
||
)
|
||
entry = {
|
||
"handler": handler,
|
||
"description": description or "Plugin command",
|
||
"plugin": self.manifest.name,
|
||
"plugin_key": self.manifest.key or self.manifest.name,
|
||
"args_hint": hint,
|
||
"argument_mode": mode,
|
||
}
|
||
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 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
|
||
|
||
# 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:
|
||
cli = self._manager._cli_ref
|
||
agent = getattr(cli, "agent", None) if cli else None
|
||
if agent is not None:
|
||
kwargs["parent_agent"] = agent
|
||
|
||
return registry.dispatch(
|
||
tool_name, args, scope=self._manager.scope_key, **kwargs
|
||
)
|
||
|
||
# -- context engine registration -----------------------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_context_engine(self, engine) -> Optional[PluginRegistration]:
|
||
"""Register 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,
|
||
)
|
||
return
|
||
# Defer the import to avoid circular deps at module level
|
||
from agent.context_engine import ContextEngine
|
||
if not isinstance(engine, ContextEngine):
|
||
logger.warning(
|
||
"Plugin '%s' tried to register a context engine that does not "
|
||
"inherit from ContextEngine. Ignoring.",
|
||
self.manifest.name,
|
||
)
|
||
return
|
||
previous = self._manager._context_engine
|
||
self._manager._context_engine = engine
|
||
handle = self._track_replacement(
|
||
"context_engine",
|
||
engine.name,
|
||
slot=("manager_value", id(self._manager), "_context_engine"),
|
||
current=engine,
|
||
previous=previous,
|
||
restore=lambda replacement: self._manager._restore_value(
|
||
"_context_engine", engine, replacement
|
||
),
|
||
)
|
||
logger.info(
|
||
"Plugin '%s' registered context engine: %s",
|
||
self.manifest.name, engine.name,
|
||
)
|
||
return handle
|
||
|
||
# -- context reference registration -------------------------------------
|
||
|
||
def register_context_reference(self, provider) -> None:
|
||
"""Register a 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,
|
||
)
|
||
|
||
if not isinstance(provider, _CRP):
|
||
logger.warning(
|
||
"Plugin '%s' tried to register a context reference provider "
|
||
"that does not inherit from ContextReferenceProvider. Ignoring.",
|
||
self.manifest.name,
|
||
)
|
||
return
|
||
try:
|
||
_register(provider)
|
||
except ValueError as exc:
|
||
logger.warning(
|
||
"Plugin '%s' context reference registration failed: %s",
|
||
self.manifest.name, exc,
|
||
)
|
||
return
|
||
logger.info(
|
||
"Plugin '%s' registered context reference: @%s:",
|
||
self.manifest.name, provider.prefix,
|
||
)
|
||
|
||
# -- memory provider registration ---------------------------------------
|
||
|
||
def register_memory_provider(self, provider) -> None:
|
||
"""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 not isinstance(provider, MemoryProvider):
|
||
logger.warning(
|
||
"Plugin '%s' tried to register a memory provider that does not "
|
||
"inherit from MemoryProvider. Ignoring.",
|
||
self.manifest.name,
|
||
)
|
||
return
|
||
self._memory_provider = provider
|
||
logger.debug(
|
||
"Plugin '%s' registered memory provider: %s",
|
||
self.manifest.name, getattr(provider, "name", "?"),
|
||
)
|
||
|
||
# -- image gen provider registration ------------------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_image_gen_provider(self, provider) -> Optional[PluginRegistration]:
|
||
"""Register an 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 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
|
||
from hermes_cli.dashboard_auth.registry import (
|
||
register_global_provider,
|
||
unregister_global_provider,
|
||
)
|
||
|
||
if not isinstance(provider, DashboardAuthProvider):
|
||
logger.warning(
|
||
"Plugin '%s' tried to register a dashboard-auth provider "
|
||
"that does not inherit from DashboardAuthProvider. Ignoring.",
|
||
self.manifest.name,
|
||
)
|
||
return
|
||
registry_name = provider.name
|
||
# The dashboard auth registry is process-global — its lifetime is the
|
||
# web server, not this per-home plugin manager. 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). So register it in the
|
||
# global slot (upsert) and, crucially, keep it OUT of the manager's
|
||
# reverse-order teardown (``persistent=True``): a per-home unload can
|
||
# no longer clear it. The handle still disposes explicitly (identity-
|
||
# conditional), and a forced re-discovery rotates the provider in
|
||
# place via the upsert.
|
||
try:
|
||
register_global_provider(provider)
|
||
except (TypeError, ValueError) as e:
|
||
logger.warning(
|
||
"Plugin '%s' failed to register dashboard-auth provider "
|
||
"%r: %s",
|
||
self.manifest.name, getattr(provider, "name", "?"), e,
|
||
)
|
||
return
|
||
handle = self._track(
|
||
"dashboard_auth_provider",
|
||
registry_name,
|
||
lambda: unregister_global_provider(registry_name, provider),
|
||
persistent=True,
|
||
)
|
||
logger.info(
|
||
"Plugin '%s' registered dashboard-auth provider: %s (%s)",
|
||
self.manifest.name, registry_name, provider.display_name,
|
||
)
|
||
return handle
|
||
|
||
# -- video gen provider registration -------------------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_video_gen_provider(self, provider) -> Optional[PluginRegistration]:
|
||
"""Register 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
|
||
|
||
# -- terminal environment provider registration ----------------------------
|
||
|
||
@_serialized_replacement
|
||
def register_terminal_environment_provider(self, provider) -> Optional[PluginRegistration]:
|
||
"""Register a pluggable terminal execution backend.
|
||
|
||
``provider`` must be an instance of
|
||
:class:`agent.terminal_env_provider.TerminalEnvironmentProvider`.
|
||
The ``provider.name`` attribute is what ``terminal.backend`` in
|
||
``config.yaml`` (bridged to ``TERMINAL_ENV``) matches against when
|
||
the dispatch ladder in :func:`tools.terminal_tool._create_environment`
|
||
finds no built-in backend of that name.
|
||
|
||
Names colliding with built-in backends (local, docker, singularity,
|
||
modal, daytona, vercel_sandbox, ssh) are rejected by the registry —
|
||
plugins extend the backend set, they never shadow in-tree backends.
|
||
|
||
Mirrors :meth:`register_browser_provider` — same registration shape,
|
||
same gating, same logging.
|
||
"""
|
||
from agent.terminal_env_provider import TerminalEnvironmentProvider
|
||
from agent.terminal_env_registry import (
|
||
register_provider as _register_terminal_env_provider,
|
||
restore_registration,
|
||
snapshot_registration,
|
||
)
|
||
|
||
if not isinstance(provider, TerminalEnvironmentProvider):
|
||
logger.warning(
|
||
"Plugin '%s' tried to register a terminal environment "
|
||
"provider that does not inherit from "
|
||
"TerminalEnvironmentProvider. Ignoring.",
|
||
self.manifest.name,
|
||
)
|
||
return
|
||
registry_name = provider.name.strip().lower()
|
||
scope = self._manager.scope_key
|
||
previous = snapshot_registration(registry_name, scope=scope)
|
||
try:
|
||
_register_terminal_env_provider(provider, scope=scope)
|
||
except ValueError as exc:
|
||
logger.warning(
|
||
"Plugin '%s' terminal environment provider rejected: %s",
|
||
self.manifest.name, exc,
|
||
)
|
||
return
|
||
registered = snapshot_registration(registry_name, scope=scope)
|
||
if registered is not provider:
|
||
return None
|
||
handle = self._track_replacement(
|
||
"terminal_environment_provider",
|
||
registry_name,
|
||
slot=("terminal_environment_provider", scope, registry_name),
|
||
current=provider,
|
||
previous=previous,
|
||
restore=lambda replacement: restore_registration(
|
||
registry_name, provider, replacement, scope=scope
|
||
),
|
||
)
|
||
logger.info(
|
||
"Plugin '%s' registered terminal environment 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.<source.name>`` 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.<name>: 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.<name>: 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
|
||
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,
|
||
) -> Optional[PluginRegistration]:
|
||
"""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,
|
||
)
|
||
scope = self._manager.scope_key
|
||
previous = platform_registry.snapshot_registration(name, scope=scope)
|
||
platform_registry.register(entry, scope=scope)
|
||
current = platform_registry.snapshot_registration(name, scope=scope)
|
||
if current[0] is not entry or current[1] is not None:
|
||
return None
|
||
self._manager._plugin_platform_names.add(name)
|
||
handle = self._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,
|
||
)
|
||
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,
|
||
) -> PluginRegistration:
|
||
"""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 ValueError(
|
||
f"Plugin '{self.manifest.name}' tried to register a Slack "
|
||
f"action handler with a non-callable callback."
|
||
)
|
||
if action_id is None or (isinstance(action_id, str) and not action_id.strip()):
|
||
raise ValueError(
|
||
f"Plugin '{self.manifest.name}' tried to register a Slack "
|
||
f"action handler with an empty action_id."
|
||
)
|
||
entry = (action_id, callback, self.manifest.name)
|
||
self._manager._slack_action_handlers.append(entry)
|
||
handle = self._track(
|
||
"slack_action_handler",
|
||
repr(action_id),
|
||
lambda: self._manager._remove_identity(
|
||
self._manager._slack_action_handlers, entry
|
||
),
|
||
)
|
||
logger.debug(
|
||
"Plugin %s registered Slack action handler: %s",
|
||
self.manifest.name,
|
||
action_id,
|
||
)
|
||
return handle
|
||
|
||
# -- platform handler registration ----------------------------------------
|
||
|
||
def register_platform_handler(self, platform: str, factory: Callable) -> None:
|
||
"""Register a native-client handler factory for a gateway platform.
|
||
|
||
The generic surface for plugins that need to receive platform
|
||
events the core adapter doesn't route (extra update types, native
|
||
button callbacks, reaction/member events, webhook routes, ...).
|
||
|
||
The adapter for ``platform`` invokes registered factories at
|
||
``connect()`` time, after its native client object is built and
|
||
before (or as) its own handlers register. The factory receives
|
||
``(native, adapter)``::
|
||
|
||
def _wire(native, adapter):
|
||
# native: the platform's client/app object (see table)
|
||
# adapter: the platform adapter instance (treat read-only)
|
||
...
|
||
|
||
ctx.register_platform_handler("discord", _wire)
|
||
|
||
What ``native`` is per platform (None when the adapter has no
|
||
separate native client — the adapter itself is then the only
|
||
useful handle):
|
||
|
||
============= ======================================================
|
||
telegram python-telegram-bot ``Application`` (add_handler)
|
||
discord ``discord.ext.commands.Bot`` (add_listener / events)
|
||
slack ``slack_bolt.async_app.AsyncApp`` (event/action)
|
||
matrix the Matrix client (event callbacks)
|
||
teams Microsoft Teams ``App`` (on_message / on_card_action)
|
||
dingtalk ``DingTalkStreamClient`` (register_callback_handler)
|
||
line aiohttp ``web.Application`` (router)
|
||
others ``None`` — connect-time hook with the adapter handle
|
||
============= ======================================================
|
||
|
||
Notes:
|
||
|
||
* Factories are invoked lazily at connect time, so platform SDK
|
||
imports belong inside the factory body — ``register()`` keeps
|
||
working when the SDK isn't installed.
|
||
* Factories are isolated: an exception is logged and the platform
|
||
still connects.
|
||
* When hooking dispatch tables that stop at the first match
|
||
(e.g. PTB callback handlers), always scope your handler
|
||
(pattern prefixes, specific event types) so core flows keep
|
||
working.
|
||
|
||
Args:
|
||
platform: Gateway platform name, lowercase (``"telegram"``,
|
||
``"discord"``, ``"slack"``, ...).
|
||
factory: Callable receiving ``(native, adapter)``.
|
||
|
||
Raises:
|
||
ValueError: if ``factory`` is not callable or ``platform`` is
|
||
empty.
|
||
"""
|
||
if not callable(factory):
|
||
raise ValueError(
|
||
f"Plugin '{self.manifest.name}' tried to register a platform "
|
||
f"handler factory with a non-callable factory."
|
||
)
|
||
key = (platform or "").strip().lower()
|
||
if not key:
|
||
raise ValueError(
|
||
f"Plugin '{self.manifest.name}' tried to register a platform "
|
||
f"handler factory with an empty platform name."
|
||
)
|
||
self._manager._platform_handler_factories.setdefault(key, []).append(
|
||
(factory, self.manifest.name)
|
||
)
|
||
logger.debug(
|
||
"Plugin %s registered %s handler factory: %s",
|
||
self.manifest.name, key,
|
||
getattr(factory, "__name__", repr(factory)),
|
||
)
|
||
|
||
# -- telegram handler registration ---------------------------------------
|
||
|
||
def register_telegram_handler(self, factory: Callable) -> None:
|
||
"""Register a python-telegram-bot handler factory from a plugin.
|
||
|
||
Hermes' Telegram adapter invokes registered factories at ``connect()``
|
||
time, right after the PTB ``Application`` is built and **before** the
|
||
core handlers are added. The factory receives
|
||
``(application, adapter)`` and wires its own handlers::
|
||
|
||
def _wire(application, adapter):
|
||
from telegram.ext import CallbackQueryHandler
|
||
|
||
application.add_handler(
|
||
CallbackQueryHandler(_on_button, pattern=r"^myplugin:")
|
||
)
|
||
|
||
ctx.register_telegram_handler(_wire)
|
||
|
||
Notes:
|
||
|
||
* The factory is called lazily at connect time, so plugins may import
|
||
``telegram`` / ``telegram.ext`` inside the factory body — the
|
||
plugin's ``register()`` still works when PTB is not installed.
|
||
* PTB dispatches only the *first* matching handler within a group,
|
||
and the core adapter registers a catch-all ``CallbackQueryHandler``
|
||
in the default group. Because plugin factories run first,
|
||
a pattern-scoped ``CallbackQueryHandler`` (e.g. ``pattern=r"^bd:"``)
|
||
takes precedence for its own callbacks while every other update
|
||
falls through to the core handlers unchanged. Always scope
|
||
callback handlers with ``pattern=`` — an unscoped handler would
|
||
swallow the core button flows (approvals, model picker, clarify).
|
||
* ``adapter`` is the ``TelegramAdapter`` instance (``adapter.bot``,
|
||
``adapter.config`` etc.); treat it as read-only.
|
||
* Exceptions raised by the factory are caught and logged by the
|
||
adapter — a broken plugin cannot prevent Telegram from connecting.
|
||
|
||
Args:
|
||
factory: Callable receiving ``(application, adapter)``.
|
||
|
||
Raises:
|
||
ValueError: if ``factory`` is not callable.
|
||
"""
|
||
# Thin alias over the generic surface — kept for back-compat and
|
||
# for the Telegram-specific docs above.
|
||
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,
|
||
defaults: Optional[Dict[str, Any]] = None,
|
||
) -> PluginRegistration:
|
||
"""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.<key>`` 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_<KEY_UPPER>_*`` 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.<key>``
|
||
and env vars ``AUXILIARY_<KEY_UPPER>_*``. Must not shadow a
|
||
built-in task key (vision, compression, 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 '{self.manifest.name}' tried to register auxiliary task "
|
||
f"with invalid key {key!r}"
|
||
)
|
||
if not all(c.isalnum() or c == "_" for c in key):
|
||
raise ValueError(
|
||
f"Plugin '{self.manifest.name}' auxiliary task key {key!r} "
|
||
f"must contain only alphanumeric characters and underscores"
|
||
)
|
||
|
||
# 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 '{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,
|
||
}
|
||
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 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}",
|
||
)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"Plugin '%s' redaction pattern registration failed: %s",
|
||
self.manifest.name, exc,
|
||
)
|
||
return 0
|
||
logger.debug(
|
||
"Plugin %s registered %d redaction pattern(s)",
|
||
self.manifest.name, count,
|
||
)
|
||
return count
|
||
|
||
def register_hook(self, hook_name: str, callback: Callable) -> PluginRegistration:
|
||
"""Register a lifecycle hook callback.
|
||
|
||
Unknown 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
|
||
),
|
||
)
|
||
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,
|
||
) -> PluginRegistration:
|
||
"""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 '-'"
|
||
)
|
||
if not isinstance(content, str) and not callable(content):
|
||
raise TypeError("system prompt section content must be a string or callable")
|
||
if position not in SYSTEM_PROMPT_SECTION_POSITIONS:
|
||
raise ValueError(
|
||
"system prompt section position must be one of: "
|
||
+ ", ".join(sorted(SYSTEM_PROMPT_SECTION_POSITIONS))
|
||
)
|
||
if (
|
||
isinstance(max_chars, bool)
|
||
or not isinstance(max_chars, int)
|
||
or not 0 < max_chars <= MAX_SYSTEM_PROMPT_SECTION_CHARS
|
||
):
|
||
raise ValueError(
|
||
"system prompt section max_chars must be between 1 and "
|
||
f"{MAX_SYSTEM_PROMPT_SECTION_CHARS}"
|
||
)
|
||
existing = self._manager._system_prompt_sections.get(id)
|
||
if existing is not None:
|
||
raise ValueError(
|
||
f"system prompt section {id!r} is already registered by "
|
||
f"plugin {existing.plugin!r}"
|
||
)
|
||
plugin_id = self.manifest.key or self.manifest.name
|
||
section = PluginSystemPromptSection(
|
||
id=id,
|
||
content=content,
|
||
position=position,
|
||
max_chars=max_chars,
|
||
plugin=plugin_id,
|
||
)
|
||
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 *event* to all subscribers; return the number invoked.
|
||
|
||
The event is delivered as ``<plugin_key>:<event>`` 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"
|
||
)
|
||
if ":" in event:
|
||
logger.warning(
|
||
"Plugin '%s' tried to emit namespaced/reserved event '%s' — "
|
||
"a plugin may only emit bare event names under its own '%s:' "
|
||
"namespace (the '%s:' prefix is reserved for core, and foreign "
|
||
"namespaces are forbidden)",
|
||
plugin_key, event, plugin_key, HERMES_EVENT_NAMESPACE,
|
||
)
|
||
raise ValueError(
|
||
f"Plugin '{plugin_key}' may not emit '{event}': emit only the "
|
||
f"bare event name; the namespace is forced to '{plugin_key}:' "
|
||
f"and the '{HERMES_EVENT_NAMESPACE}:' prefix is reserved for core"
|
||
)
|
||
if payload is not None and not isinstance(payload, dict):
|
||
raise TypeError(
|
||
f"Plugin '{plugin_key}' emit() payload must be a dict or None"
|
||
)
|
||
full_event = f"{plugin_key}:{event}"
|
||
return self._manager._dispatch_event(full_event, payload or {})
|
||
|
||
def subscribe(self, event: str, callback: Callable) -> None:
|
||
"""Subscribe *callback* to a fully-qualified event name.
|
||
|
||
*event* is the full ``<plugin_key>:<event>`` name (or ``hermes:<event>``
|
||
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 "
|
||
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 = "",
|
||
frontmatter: Optional[Mapping[str, Any]] = None,
|
||
) -> PluginRegistration:
|
||
"""Register a read-only skill provided by this plugin.
|
||
|
||
The skill becomes resolvable as ``'<plugin_name>:<name>'`` via
|
||
``skill_view()``. It does **not** enter the flat
|
||
``~/.hermes/skills/`` tree and is **not** listed in the system
|
||
prompt's ``<available_skills>`` index — plugin skills are
|
||
opt-in explicit loads only.
|
||
|
||
Raises:
|
||
ValueError: if *name* contains ``':'`` or invalid characters.
|
||
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 ':' "
|
||
f"(the namespace is derived from the plugin name "
|
||
f"'{self.manifest.name}' automatically)."
|
||
)
|
||
if not name or not _NAMESPACE_RE.match(name):
|
||
raise ValueError(
|
||
f"Invalid skill name '{name}'. Must match [a-zA-Z0-9_-]+."
|
||
)
|
||
if not path.exists():
|
||
raise FileNotFoundError(f"SKILL.md not found at {path}")
|
||
|
||
namespace = self.manifest.skill_namespace or self.manifest.name
|
||
qualified = f"{namespace}:{name}"
|
||
if self.manifest.portable and qualified in self._manager._plugin_skills:
|
||
raise ValueError(f"Plugin skill '{qualified}' is already registered")
|
||
previous = self._manager._plugin_skills.get(qualified)
|
||
entry = {
|
||
"path": path,
|
||
"plugin": namespace,
|
||
"plugin_key": self.manifest.key or self.manifest.name,
|
||
"bare_name": name,
|
||
"description": description,
|
||
"frontmatter": dict(frontmatter or {}),
|
||
}
|
||
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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Hook callback timeout (non-blocking abandon)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Default wall-clock cap for a single Python plugin hook callback. Overridden
|
||
# by ``plugins.hook_callback_timeout`` in config.yaml (see DEFAULT_CONFIG).
|
||
# Shell hooks already enforce their own subprocess timeout.
|
||
_HOOK_CALLBACK_TIMEOUT_SECS = 30.0
|
||
_MAX_HOOK_CALLBACK_TIMEOUT_SECS = 600.0
|
||
|
||
|
||
def _resolve_hook_callback_timeout() -> float:
|
||
"""Return the effective hook-callback timeout in seconds.
|
||
|
||
Reads ``plugins.hook_callback_timeout`` via the cached readonly config
|
||
loader. Falls back to ``_HOOK_CALLBACK_TIMEOUT_SECS``. Values ``<= 0``
|
||
disable the threaded timeout (sync call). Values above
|
||
``_MAX_HOOK_CALLBACK_TIMEOUT_SECS`` are clamped.
|
||
"""
|
||
timeout = _HOOK_CALLBACK_TIMEOUT_SECS
|
||
try:
|
||
from hermes_cli.config import load_config_readonly
|
||
|
||
plugins_cfg = (load_config_readonly() or {}).get("plugins")
|
||
if isinstance(plugins_cfg, dict) and "hook_callback_timeout" in plugins_cfg:
|
||
raw = plugins_cfg.get("hook_callback_timeout")
|
||
if raw is not None:
|
||
timeout = float(raw)
|
||
except (TypeError, ValueError):
|
||
logger.warning(
|
||
"plugins.hook_callback_timeout is not a number; using default %gs",
|
||
_HOOK_CALLBACK_TIMEOUT_SECS,
|
||
)
|
||
timeout = _HOOK_CALLBACK_TIMEOUT_SECS
|
||
except Exception:
|
||
timeout = _HOOK_CALLBACK_TIMEOUT_SECS
|
||
|
||
if timeout < 0:
|
||
logger.warning(
|
||
"plugins.hook_callback_timeout=%g is negative; using default %gs",
|
||
timeout,
|
||
_HOOK_CALLBACK_TIMEOUT_SECS,
|
||
)
|
||
return _HOOK_CALLBACK_TIMEOUT_SECS
|
||
if timeout > _MAX_HOOK_CALLBACK_TIMEOUT_SECS:
|
||
logger.warning(
|
||
"plugins.hook_callback_timeout=%g exceeds max %gs; clamping",
|
||
timeout,
|
||
_MAX_HOOK_CALLBACK_TIMEOUT_SECS,
|
||
)
|
||
return _MAX_HOOK_CALLBACK_TIMEOUT_SECS
|
||
return timeout
|
||
|
||
|
||
def _hook_uses_callback_timeout(hook_name: str, timeout: float) -> bool:
|
||
"""Whether *hook_name* should run under the non-blocking timeout path."""
|
||
if timeout <= 0 or hook_name in _HOOK_CALLER_THREAD_HOOKS:
|
||
return False
|
||
return (
|
||
hook_name in _HOOK_TIMEOUT_BOUNDED_HOOKS
|
||
or hook_name in _HOOK_TIMEOUT_FAIL_CLOSED_HOOKS
|
||
)
|
||
|
||
|
||
def _pre_tool_call_timeout_block() -> Dict[str, str]:
|
||
"""Fail-closed directive when a policy callback times out or is still running."""
|
||
return {
|
||
"action": "block",
|
||
"message": _PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# PluginManager
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class PluginManager:
|
||
"""Central manager that discovers, loads, and invokes plugins."""
|
||
|
||
def __init__(self, scope_key: Optional[str] = None) -> None:
|
||
# 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._plugins: Dict[str, LoadedPlugin] = {}
|
||
self._hooks: Dict[str, List[Callable]] = {}
|
||
self._middleware: Dict[str, List[Callable]] = {}
|
||
self._plugin_tool_names: Set[str] = set()
|
||
self._plugin_platform_names: Set[str] = set()
|
||
self._cli_commands: Dict[str, dict] = {}
|
||
self._context_engine = None # Set by a plugin via register_context_engine()
|
||
self._plugin_commands: Dict[str, dict] = {} # Slash commands registered by plugins
|
||
self._system_prompt_sections: Dict[str, PluginSystemPromptSection] = {}
|
||
self._discovered: bool = False
|
||
self._cli_ref = None # Set by CLI after plugin discovery
|
||
self._gateway_message_injector: tuple[object, Callable] | None = None
|
||
# 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] = {}
|
||
# 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_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()
|
||
# 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] = []
|
||
# In-flight / recently-timed-out hook callbacks. Keyed by
|
||
# (hook_name, id(cb)) so a stuck policy hook cannot spawn a new
|
||
# abandoned daemon thread on every subsequent 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
|
||
# 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] = []
|
||
# Persistent (process-global) registrations that survived an
|
||
# unload-all. 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 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]] = {}
|
||
# Native platform handler factories registered by plugins, keyed by
|
||
# lowercase platform name. Each entry is (factory, plugin_name);
|
||
# the platform's adapter invokes factories at connect() time with
|
||
# (native_client, adapter) so plugins can wire their own handlers
|
||
# (PTB handlers, discord.py listeners, slack_bolt events, webhook
|
||
# routes, ...) without touching core files.
|
||
# ``register_telegram_handler`` is a thin alias writing into the
|
||
# "telegram" bucket.
|
||
self._platform_handler_factories: Dict[str, List[tuple]] = {}
|
||
|
||
# -----------------------------------------------------------------------
|
||
# Registration ledger internals
|
||
# -----------------------------------------------------------------------
|
||
|
||
def _track_registration(
|
||
self,
|
||
manifest: PluginManifest,
|
||
kind: str,
|
||
key: str,
|
||
release: Callable[[], None],
|
||
*,
|
||
persistent: bool = False,
|
||
) -> PluginRegistration:
|
||
"""Record one successful registration under its canonical plugin key.
|
||
|
||
``persistent`` registrations (process-global host infrastructure such
|
||
as dashboard-auth providers, whose lifetime is the server rather than
|
||
a per-home plugin manager) are still tracked in the ownership ledger
|
||
for attribution, but are NOT enrolled in ``_registration_order`` — so
|
||
a routine per-home manager unload cannot dispose them (#91701). The
|
||
returned handle still releases on explicit ``dispose()``.
|
||
"""
|
||
plugin_key = manifest.key or manifest.name
|
||
registration = PluginRegistration(
|
||
kind=kind,
|
||
key=key,
|
||
release=release,
|
||
plugin_key=plugin_key,
|
||
persistent=persistent,
|
||
)
|
||
registration._on_dispose = lambda disposed: self._forget_registrations(
|
||
[disposed]
|
||
)
|
||
self._ownership_ledger.setdefault(plugin_key, []).append(registration)
|
||
if not persistent:
|
||
self._registration_order.append(registration)
|
||
return registration
|
||
|
||
def _evict_stale_persistent_registrations(self) -> None:
|
||
"""Dispose carried-over persistent registrations not re-registered.
|
||
|
||
Persistent registrations (process-global host infrastructure such as
|
||
dashboard-auth providers) survive an unload-all by design (#91701);
|
||
``_unload_scoped`` parks their handles in ``_persistent_carryover``.
|
||
After a re-discovery pass, three cases exist for each parked handle:
|
||
|
||
- the plugin re-registered the same ``(kind, key)`` → the upsert
|
||
rotated the entry in place. The old handle is superseded; drop it
|
||
WITHOUT disposing (a plugin that re-registered the *same object*
|
||
would otherwise pass the identity check and evict the live entry).
|
||
- the plugin did not come back (disabled, uninstalled, omitted) →
|
||
dispose, releasing the process-global registration.
|
||
- the handle was already disposed elsewhere (targeted unload) → drop.
|
||
"""
|
||
if not self._persistent_carryover:
|
||
return
|
||
parked = self._persistent_carryover
|
||
self._persistent_carryover = []
|
||
current = {
|
||
(registration.kind, registration.key)
|
||
for owned in self._ownership_ledger.values()
|
||
for registration in owned
|
||
if registration.persistent and registration.active
|
||
}
|
||
stale = [
|
||
registration
|
||
for registration in parked
|
||
if registration.active
|
||
and (registration.kind, registration.key) not in current
|
||
]
|
||
for registration in stale:
|
||
logger.info(
|
||
"Evicting persistent registration %s/%s: plugin '%s' no "
|
||
"longer supplies it after re-discovery",
|
||
registration.kind,
|
||
registration.key,
|
||
registration.plugin_key,
|
||
)
|
||
self._dispose_registrations(stale)
|
||
|
||
@staticmethod
|
||
def _remove_identity(values: list, target: Any) -> bool:
|
||
"""Remove the last exact object match from a registration list."""
|
||
for index in range(len(values) - 1, -1, -1):
|
||
if values[index] is target:
|
||
del values[index]
|
||
return True
|
||
return False
|
||
|
||
def _remove_callback(
|
||
self,
|
||
mapping: Dict[str, List[Callable]],
|
||
key: str,
|
||
callback: Callable,
|
||
) -> None:
|
||
callbacks = mapping.get(key)
|
||
if callbacks is None:
|
||
return
|
||
self._remove_identity(callbacks, callback)
|
||
if not callbacks:
|
||
mapping.pop(key, None)
|
||
|
||
def _restore_mapping(
|
||
self,
|
||
mapping: Dict[str, Any],
|
||
key: str,
|
||
current: Any,
|
||
previous: Optional[Any],
|
||
) -> bool:
|
||
"""Restore a manager-local mapping only when *current* is still present."""
|
||
if mapping.get(key) is not current:
|
||
return False
|
||
if previous is None:
|
||
mapping.pop(key, None)
|
||
else:
|
||
mapping[key] = previous
|
||
return True
|
||
|
||
def _restore_value(
|
||
self,
|
||
attribute: str,
|
||
current: Any,
|
||
previous: Any,
|
||
) -> bool:
|
||
"""Restore a manager-local value only when *current* is still active."""
|
||
if getattr(self, attribute) is not current:
|
||
return False
|
||
setattr(self, attribute, previous)
|
||
return True
|
||
|
||
def _remove_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
|
||
]
|
||
# Persistent registrations (process-global host infrastructure,
|
||
# e.g. dashboard-auth providers) are deliberately absent from
|
||
# _registration_order so an unload-all cannot dispose them
|
||
# (#91701). A *targeted* unload is different: it is the plugin
|
||
# disable/uninstall path, and a disabled auth plugin's provider
|
||
# must NOT stay live process-wide. Gather them from the ledger.
|
||
registrations.extend(
|
||
registration
|
||
for key in target_keys
|
||
for registration in self._ownership_ledger.get(key, [])
|
||
if registration.persistent and registration.active
|
||
)
|
||
|
||
found = bool(target_keys or registrations)
|
||
self._dispose_registrations(registrations)
|
||
self._forget_registrations(registrations)
|
||
|
||
if unload_all:
|
||
# 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,
|
||
)
|
||
# Persistent registrations survive this unload-all by design
|
||
# (#91701) but must not be orphaned by the ledger clear below:
|
||
# carry them over so a force re-discovery can evict the ones
|
||
# whose plugin does not come back (disabled/removed/omitted).
|
||
carryover_ids = {
|
||
id(registration) for registration in self._persistent_carryover
|
||
}
|
||
self._persistent_carryover.extend(
|
||
registration
|
||
for owned in self._ownership_ledger.values()
|
||
for registration in owned
|
||
if registration.persistent
|
||
and registration.active
|
||
and id(registration) not in carryover_ids
|
||
)
|
||
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._platform_handler_factories.clear()
|
||
self._context_engine = None
|
||
with self._hook_timeout_lock:
|
||
self._hook_running_callbacks.clear()
|
||
self._hook_timeout_suppressed_until.clear()
|
||
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:
|
||
"""Publish a live gateway injector and its lifecycle owner."""
|
||
self._gateway_message_injector = (owner, injector)
|
||
|
||
def clear_gateway_message_injector(self, owner: object) -> None:
|
||
"""Clear the injector only when it still belongs to ``owner``."""
|
||
registered = self._gateway_message_injector
|
||
if registered is not None and registered[0] is owner:
|
||
self._gateway_message_injector = None
|
||
|
||
def inject_gateway_message(self, **kwargs: Any) -> bool:
|
||
"""Submit a plugin-triggered turn to the live gateway."""
|
||
registered = self._gateway_message_injector
|
||
if registered is None:
|
||
return False
|
||
return bool(registered[1](**kwargs))
|
||
|
||
def discover_and_load(self, force: bool = False) -> None:
|
||
"""Scan all plugin sources and load each plugin found.
|
||
|
||
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:
|
||
# 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
|
||
# 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 deliberately survived the
|
||
# unload-all above (#91701). 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.
|
||
self._evict_stale_persistent_registrations()
|
||
# 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 and outbound webhooks 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; outbound webhooks added per #92682 review).
|
||
self._re_register_config_hooks_after_force()
|
||
except BaseException:
|
||
self._discovered = False
|
||
raise
|
||
|
||
def _re_register_config_hooks_after_force(self) -> None:
|
||
"""Restore config.yaml shell hooks/outbound webhooks wiped by
|
||
force-clear of ``_hooks``. Each re-register call is independently
|
||
guarded so one failing does not skip the other."""
|
||
try:
|
||
from agent.shell_hooks import re_register_config_hooks
|
||
|
||
re_register_config_hooks()
|
||
except Exception as exc:
|
||
# Import cycle / missing module must not abort force reload.
|
||
logger.debug("force-reload shell-hook re-register skipped: %s", exc)
|
||
try:
|
||
from agent.outbound_webhooks import (
|
||
re_register_config_hooks as re_register_outbound_webhooks,
|
||
)
|
||
|
||
re_register_outbound_webhooks()
|
||
except Exception as exc:
|
||
logger.debug("force-reload outbound-webhook re-register skipped: %s", exc)
|
||
|
||
def _refresh_secret_sources_after_discovery(self) -> None:
|
||
"""If any plugin secret source is enabled, 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.<name>.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
|
||
|
||
cfg = load_config() or {}
|
||
secrets = cfg.get("secrets") or {}
|
||
except Exception:
|
||
secrets = {}
|
||
enabled_names = []
|
||
for source in plugin_sources:
|
||
name = getattr(source, "name", "")
|
||
section = secrets.get(name)
|
||
section = section if isinstance(section, dict) else {}
|
||
try:
|
||
if source.is_enabled(section):
|
||
enabled_names.append(name)
|
||
except Exception:
|
||
# 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)),
|
||
)
|
||
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()
|
||
|
||
# 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,
|
||
)
|
||
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 <category>.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 ``<category>.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),
|
||
)
|
||
|
||
def register_approval_transport(
|
||
self,
|
||
name: str,
|
||
present_fn: Callable,
|
||
*,
|
||
plugin_id: str,
|
||
) -> None:
|
||
"""Register one plugin-owned approval transport for this profile."""
|
||
import re
|
||
|
||
from hermes_cli.approval_transport import RegisteredApprovalTransport
|
||
|
||
clean = str(name).strip().lower()
|
||
if clean == "builtin":
|
||
raise ValueError("approval transport name 'builtin' is reserved")
|
||
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", clean):
|
||
raise ValueError(
|
||
"approval transport name must match [a-z0-9][a-z0-9_-]{0,63}"
|
||
)
|
||
if not callable(present_fn):
|
||
raise TypeError("approval transport present_fn must be callable")
|
||
if clean in self._approval_transports:
|
||
owner = self._approval_transports[clean].plugin_id
|
||
raise ValueError(
|
||
f"approval transport {clean!r} is already registered by {owner!r}"
|
||
)
|
||
self._approval_transports[clean] = RegisteredApprovalTransport(
|
||
name=clean,
|
||
present=present_fn,
|
||
plugin_id=plugin_id,
|
||
profile_home=str(get_hermes_home().resolve()),
|
||
)
|
||
logger.info("Plugin %s registered approval transport: %s", plugin_id, clean)
|
||
|
||
def get_approval_transport(self, name: str):
|
||
"""Return a transport only inside the profile that registered it."""
|
||
registered = self._approval_transports.get(str(name).strip().lower())
|
||
if registered is None:
|
||
return None
|
||
if registered.profile_home != str(get_hermes_home().resolve()):
|
||
return None
|
||
return registered
|
||
|
||
def _collect_directory_manifests(self) -> List[PluginManifest]:
|
||
"""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 (<repo>/plugins/<name>/). 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** — ``<root>/<plugin-name>/plugin.yaml``. Key is
|
||
``<plugin-name>`` (e.g. ``disk-cleanup``).
|
||
* **Category** — ``<root>/<category>/<plugin-name>/plugin.yaml``,
|
||
where the ``<category>`` directory itself has no ``plugin.yaml``.
|
||
Key is ``<category>/<plugin-name>`` (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 ``<root>/a/b/c/`` is ignored.
|
||
"""
|
||
manifests: List[PluginManifest] = []
|
||
if not path.is_dir():
|
||
return manifests
|
||
|
||
for child in sorted(path.iterdir()):
|
||
if not child.is_dir():
|
||
continue
|
||
if depth == 0 and skip_names and child.name in skip_names:
|
||
continue
|
||
manifest_file = child / "plugin.yaml"
|
||
if not manifest_file.exists():
|
||
manifest_file = child / "plugin.yml"
|
||
|
||
if manifest_file.exists():
|
||
manifest = self._parse_manifest(
|
||
manifest_file, child, source, prefix
|
||
)
|
||
if manifest is not None:
|
||
manifests.append(manifest)
|
||
continue
|
||
|
||
portable_file = child / "plugin.json"
|
||
if portable_file.exists() or portable_file.is_symlink():
|
||
try:
|
||
from hermes_cli.agent_plugins import read_agent_plugin_manifest
|
||
|
||
data, diagnostics = read_agent_plugin_manifest(child)
|
||
for diagnostic in diagnostics:
|
||
logger.warning(
|
||
"Agent Plugin '%s': %s",
|
||
child,
|
||
diagnostic.message,
|
||
)
|
||
key = f"{prefix}/{child.name}" if prefix else data["name"]
|
||
manifests.append(
|
||
PluginManifest(
|
||
name=data["name"],
|
||
version=data.get("version", ""),
|
||
description=data.get("description", ""),
|
||
author=_display_author(data.get("author", "")),
|
||
source=source,
|
||
path=str(child),
|
||
key=key,
|
||
portable=True,
|
||
skill_namespace=_portable_skill_namespace(key),
|
||
)
|
||
)
|
||
except Exception as exc:
|
||
logger.warning("Failed to parse %s: %s", portable_file, exc)
|
||
continue
|
||
|
||
# No manifest 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>-platform`` and the
|
||
plugin directory basename is ``<platform>``, 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 ``<plugin>/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.<id> 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.<slug>``.
|
||
|
||
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.
|
||
|
||
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).
|
||
|
||
``subagent_stop`` (and any hook in ``_HOOK_CALLER_THREAD_HOOKS``)
|
||
always runs on the caller thread to preserve the documented parent-
|
||
thread serialization contract.
|
||
|
||
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] = []
|
||
timeout = _resolve_hook_callback_timeout()
|
||
use_timeout = _hook_uses_callback_timeout(hook_name, timeout)
|
||
fail_closed = hook_name in _HOOK_TIMEOUT_FAIL_CLOSED_HOOKS
|
||
|
||
for cb in callbacks:
|
||
callback_name = getattr(cb, "__name__", repr(cb))
|
||
callback_key = (hook_name, id(cb))
|
||
try:
|
||
if use_timeout:
|
||
token = object()
|
||
now = time.monotonic()
|
||
with self._hook_timeout_lock:
|
||
suppressed_until = self._hook_timeout_suppressed_until.get(
|
||
callback_key
|
||
)
|
||
running = callback_key in self._hook_running_callbacks
|
||
if (
|
||
suppressed_until is not None and suppressed_until > now
|
||
) or running:
|
||
logger.warning(
|
||
"Hook '%s' callback %s skipped after previous "
|
||
"timeout or while still running",
|
||
hook_name,
|
||
callback_name,
|
||
)
|
||
if fail_closed:
|
||
results.append(_pre_tool_call_timeout_block())
|
||
continue
|
||
if suppressed_until is not None:
|
||
self._hook_timeout_suppressed_until.pop(callback_key, None)
|
||
self._hook_running_callbacks[callback_key] = token
|
||
|
||
context = contextvars.copy_context()
|
||
done = threading.Event()
|
||
outcome: Dict[str, Any] = {}
|
||
failure: Dict[str, Exception] = {}
|
||
|
||
def _runner(
|
||
_cb: Callable[..., Any] = cb,
|
||
_key: tuple = callback_key,
|
||
_token: object = token,
|
||
) -> None:
|
||
try:
|
||
# Route through _invoke_hook_callback so the
|
||
# additive-payload signature filtering (narrow
|
||
# legacy callbacks) applies on the worker too.
|
||
outcome["value"] = context.run(
|
||
self._invoke_hook_callback, _cb, kwargs
|
||
)
|
||
except Exception as exc:
|
||
failure["exc"] = exc
|
||
finally:
|
||
with self._hook_timeout_lock:
|
||
if self._hook_running_callbacks.get(_key) is _token:
|
||
self._hook_running_callbacks.pop(_key, None)
|
||
done.set()
|
||
|
||
thread = threading.Thread(
|
||
target=_runner,
|
||
name=f"hermes-hook-{callback_name}"[:40],
|
||
daemon=True,
|
||
)
|
||
thread.start()
|
||
if not done.wait(timeout=timeout):
|
||
# Do not join — that would reintroduce the #6622 hang.
|
||
with self._hook_timeout_lock:
|
||
self._hook_timeout_suppressed_until[callback_key] = (
|
||
time.monotonic()
|
||
+ self._hook_timeout_suppression_seconds
|
||
)
|
||
logger.warning(
|
||
"Hook '%s' callback %s timed out after %gs — skipping",
|
||
hook_name,
|
||
callback_name,
|
||
timeout,
|
||
)
|
||
if fail_closed:
|
||
results.append(_pre_tool_call_timeout_block())
|
||
continue
|
||
if "exc" in failure:
|
||
raise failure["exc"]
|
||
ret = outcome.get("value")
|
||
else:
|
||
ret = self._invoke_hook_callback(cb, kwargs)
|
||
if ret is not None:
|
||
results.append(ret)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"Hook '%s' callback %s raised: %s",
|
||
hook_name,
|
||
callback_name,
|
||
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 _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 _dispatch_event(self, event: str, payload: Dict[str, Any]) -> int:
|
||
"""Queue *event* without blocking; return subscriber count scheduled.
|
||
|
||
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
|
||
|
||
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:
|
||
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 has_middleware(self, kind: str) -> bool:
|
||
"""Return True when at least one callback is registered for middleware."""
|
||
return bool(self._middleware.get(kind))
|
||
|
||
def invoke_middleware(self, kind: str, **kwargs: Any) -> List[Any]:
|
||
"""Call 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]:
|
||
"""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``.
|
||
|
||
Plugins register handlers via
|
||
:meth:`PluginContext.register_slack_action_handler`.
|
||
"""
|
||
return list(self._slack_action_handlers)
|
||
|
||
# -----------------------------------------------------------------------
|
||
# Platform handler factory accessors
|
||
# -----------------------------------------------------------------------
|
||
|
||
def get_platform_handler_factories(self, platform: str) -> List[tuple]:
|
||
"""Return plugin-registered handler factories for one platform.
|
||
|
||
Each entry is a ``(factory, plugin_name)`` tuple. Consumed by the
|
||
platform's adapter at connect time; each factory is invoked with
|
||
``(native_client, adapter)`` so plugins can wire their own native
|
||
handlers before/alongside the core ones.
|
||
|
||
Plugins register factories via
|
||
:meth:`PluginContext.register_platform_handler` (or the
|
||
Telegram-specific alias
|
||
:meth:`PluginContext.register_telegram_handler`).
|
||
"""
|
||
key = (platform or "").strip().lower()
|
||
return list(self._platform_handler_factories.get(key, []))
|
||
|
||
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."""
|
||
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``."""
|
||
entry = self._plugin_skills.get(qualified_name)
|
||
return entry["path"] if entry else None
|
||
|
||
def list_plugin_skills(self, plugin_name: str) -> List[str]:
|
||
"""Return sorted bare names of all skills registered by *plugin_name*."""
|
||
prefix = f"{plugin_name}:"
|
||
return sorted(
|
||
e["bare_name"]
|
||
for qn, e in self._plugin_skills.items()
|
||
if qn.startswith(prefix)
|
||
)
|
||
|
||
def list_plugin_skill_metadata(self) -> List[Dict[str, Any]]:
|
||
"""Return progressive-disclosure metadata for registered plugin skills."""
|
||
|
||
return [
|
||
{
|
||
"name": qualified,
|
||
"description": str(entry.get("description", "")),
|
||
"category": "plugin",
|
||
"frontmatter": dict(entry.get("frontmatter", {})),
|
||
}
|
||
for qualified, entry in sorted(self._plugin_skills.items())
|
||
]
|
||
|
||
def get_portable_mcp_servers(self) -> Dict[str, Dict[str, Any]]:
|
||
"""Return a defensive copy of enabled portable MCP server configs."""
|
||
|
||
return {
|
||
name: dict(config)
|
||
for name, config in self._portable_mcp_servers.items()
|
||
}
|
||
|
||
def 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
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# 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
|
||
|
||
# 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:
|
||
"""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:
|
||
return get_hermes_home().expanduser()
|
||
|
||
|
||
def _clear_plugin_submodules(manager: Optional[PluginManager]) -> None:
|
||
"""Purge ``sys.modules`` entries for directory-loaded plugins.
|
||
|
||
``PluginManager._load_directory_module`` imports each plugin as
|
||
``hermes_plugins.<slug>`` 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.<slug>.<submodule>``. 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 ``"<module_name>."`` 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():
|
||
module = getattr(loaded, "module", None)
|
||
module_name = getattr(module, "__name__", None)
|
||
if not module_name or not module_name.startswith(f"{_NS_PARENT}."):
|
||
continue
|
||
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.
|
||
|
||
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 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 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()))
|
||
if _plugin_manager is not None and _plugin_manager not in managers:
|
||
managers.append(_plugin_manager)
|
||
for manager in managers:
|
||
_clear_plugin_submodules(manager)
|
||
try:
|
||
manager.unload()
|
||
except Exception:
|
||
logger.debug("test plugin-manager unload failed", exc_info=True)
|
||
_plugin_managers_by_home.clear()
|
||
_plugin_manager = None
|
||
# Dashboard-auth providers are persistent host-owned registrations that
|
||
# deliberately survive a routine manager unload (#91701), so the "clean
|
||
# slate" reset must drop the process-global auth registry explicitly —
|
||
# otherwise a provider auto-registered during one test leaks into the next.
|
||
try:
|
||
from hermes_cli.dashboard_auth.registry import (
|
||
clear_providers as _clear_dashboard_auth_providers,
|
||
)
|
||
|
||
_clear_dashboard_auth_providers()
|
||
except Exception:
|
||
logger.debug("dashboard-auth registry clear failed", exc_info=True)
|
||
|
||
|
||
def has_enabled_agent_plugin_mcp(raw_config: Mapping[str, Any]) -> bool:
|
||
"""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.
|
||
|
||
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)
|
||
|
||
|
||
_background_discovery_thread: Optional[threading.Thread] = None
|
||
_background_discovery_lock = threading.Lock()
|
||
|
||
|
||
def start_background_plugin_discovery() -> None:
|
||
"""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:
|
||
return
|
||
with _background_discovery_lock:
|
||
if _background_discovery_thread is not None and _background_discovery_thread.is_alive():
|
||
return
|
||
|
||
def _run() -> None:
|
||
try:
|
||
manager.discover_and_load()
|
||
_persist_plugin_toolset_keys()
|
||
except Exception:
|
||
logger.warning("background plugin discovery failed", exc_info=True)
|
||
|
||
_background_discovery_thread = threading.Thread(
|
||
target=_run, name="plugin-discovery", daemon=True
|
||
)
|
||
_background_discovery_thread.start()
|
||
|
||
|
||
def _join_background_discovery(timeout: float = 30.0) -> None:
|
||
"""Wait for an in-flight background discovery (no-op from its own thread)."""
|
||
t = _background_discovery_thread
|
||
if t is None or not t.is_alive() or t is threading.current_thread():
|
||
return
|
||
t.join(timeout=timeout)
|
||
|
||
|
||
def _plugin_toolset_keys_cache_path():
|
||
from hermes_constants import get_hermes_home
|
||
return get_hermes_home() / "cache" / "plugin_toolset_keys.json"
|
||
|
||
|
||
def _persist_plugin_toolset_keys() -> None:
|
||
"""Persist discovered plugin toolset keys + portable MCP names (best-effort)."""
|
||
try:
|
||
import 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())
|
||
except Exception:
|
||
portable = []
|
||
path = _plugin_toolset_keys_cache_path()
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
fd, tmp = _tempfile.mkstemp(dir=str(path.parent), prefix=".pt_keys.")
|
||
with _os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||
_json.dump({"toolset_keys": keys, "portable_mcp": portable}, fh)
|
||
_os.replace(tmp, path)
|
||
except Exception:
|
||
logger.debug("plugin toolset key persist failed", exc_info=True)
|
||
|
||
|
||
def _read_plugin_keys_cache() -> Optional[dict]:
|
||
try:
|
||
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
|
||
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 {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 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:
|
||
"""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):
|
||
_join_background_discovery()
|
||
manager.discover_and_load()
|
||
return manager
|
||
|
||
|
||
def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
|
||
"""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]:
|
||
"""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 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:
|
||
"""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).
|
||
"""
|
||
manager = get_plugin_manager()
|
||
if not getattr(manager, "_discovered", True):
|
||
manager = _delivery_manager()
|
||
method = getattr(manager, "has_middleware", None)
|
||
if callable(method):
|
||
return bool(method(kind))
|
||
return bool(getattr(manager, "_middleware", {}).get(kind))
|
||
|
||
|
||
def has_hook(hook_name: str) -> bool:
|
||
"""Return True when a loaded plugin handles a hook.
|
||
|
||
Lazy-discovers first — same gate-before-invoke rationale as
|
||
:func:`has_middleware` (tracking #64178).
|
||
"""
|
||
return _delivery_manager().has_hook(hook_name)
|
||
|
||
|
||
def iter_hook_callbacks(hook_name: str) -> tuple[Callable, ...]:
|
||
"""Return a stable snapshot of callbacks registered for a hook."""
|
||
return get_plugin_manager().iter_hook_callbacks(hook_name)
|
||
|
||
|
||
def fire_pre_command_hook(
|
||
*,
|
||
surface: str,
|
||
command: str,
|
||
alias_used: str,
|
||
args_raw: str,
|
||
session_key: Optional[str] = None,
|
||
platform: Optional[str] = None,
|
||
) -> None:
|
||
"""Fire the ``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,
|
||
)
|
||
for result in results:
|
||
if isinstance(result, dict) and (
|
||
"action" in result or "decision" in result
|
||
):
|
||
logger.debug(
|
||
"pre_command is observer-only in v1: ignoring directive "
|
||
"%r for /%s (surface=%s). Block/rewrite will arrive with "
|
||
"the command middleware variant (#64204/#64231).",
|
||
result, command, surface,
|
||
)
|
||
except Exception as exc: # pragma: no cover - defensive
|
||
logger.debug("pre_command hook dispatch failed (non-fatal): %s", exc)
|
||
|
||
|
||
_thread_tool_whitelist = threading.local()
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _PreToolCallDirective:
|
||
action: Optional[str] = None
|
||
message: Optional[str] = None
|
||
rule_key: Optional[str] = None
|
||
modified_args: Optional[Dict[str, Any]] = None
|
||
|
||
|
||
def set_thread_tool_whitelist(
|
||
allowed: Optional[Set[str]],
|
||
deny_msg_fmt: str = "Tool '{tool_name}' denied: not in this thread's tool whitelist",
|
||
) -> None:
|
||
_thread_tool_whitelist.allowed = allowed
|
||
_thread_tool_whitelist.fmt = deny_msg_fmt
|
||
|
||
|
||
def clear_thread_tool_whitelist() -> None:
|
||
_thread_tool_whitelist.allowed = None
|
||
|
||
|
||
def _get_pre_tool_call_directive_details(
|
||
tool_name: str,
|
||
args: Optional[Dict[str, Any]],
|
||
task_id: str = "",
|
||
session_id: str = "",
|
||
tool_call_id: str = "",
|
||
turn_id: str = "",
|
||
api_request_id: str = "",
|
||
middleware_trace: Optional[List[Dict[str, Any]]] = None,
|
||
) -> _PreToolCallDirective:
|
||
"""Check ``pre_tool_call`` hooks for 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),
|
||
)
|
||
|
||
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 []),
|
||
)
|
||
|
||
block_msg: Optional[str] = None
|
||
modified_args: Optional[Dict[str, Any]] = None
|
||
|
||
for result in hook_results:
|
||
if not isinstance(result, dict):
|
||
continue
|
||
# "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:
|
||
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);
|
||
# 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() 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]],
|
||
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]]:
|
||
"""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]],
|
||
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]:
|
||
"""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]],
|
||
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).
|
||
|
||
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 = "",
|
||
session_id: str = "",
|
||
) -> Optional[str]:
|
||
"""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":
|
||
try:
|
||
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]],
|
||
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 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,
|
||
turn_id=turn_id, tool_call_id=tool_call_id, session_id=session_id,
|
||
)
|
||
return (block_msg, details.modified_args)
|
||
|
||
|
||
def get_pre_verify_continue_message(
|
||
*,
|
||
session_id: str = "",
|
||
platform: str = "",
|
||
model: str = "",
|
||
coding: bool = False,
|
||
attempt: int = 0,
|
||
final_response: str = "",
|
||
changed_paths: Optional[List[str]] = None,
|
||
) -> Optional[str]:
|
||
"""Check 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": "<follow-up for the model>"}
|
||
|
||
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 []),
|
||
)
|
||
|
||
for result in hook_results:
|
||
if not isinstance(result, dict):
|
||
continue
|
||
action = str(result.get("action") or result.get("decision") or "").strip().lower()
|
||
if action not in ("continue", "block"):
|
||
continue
|
||
message = result.get("message") or result.get("reason")
|
||
if isinstance(message, str) and message.strip():
|
||
return message.strip()
|
||
|
||
return None
|
||
|
||
|
||
def get_plugin_error_classification(
|
||
*,
|
||
provider: str = "",
|
||
model: str = "",
|
||
status_code: Optional[int] = None,
|
||
error_type: str = "",
|
||
error_code: str = "",
|
||
error_message: str = "",
|
||
error_body: Optional[Dict[str, Any]] = None,
|
||
error: Optional[BaseException] = None,
|
||
approx_tokens: int = 0,
|
||
context_length: int = 0,
|
||
num_messages: int = 0,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""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,
|
||
num_messages=num_messages,
|
||
)
|
||
|
||
winner: Optional[Dict[str, Any]] = None
|
||
skipped_valid = 0
|
||
for result in hook_results:
|
||
if not isinstance(result, dict):
|
||
continue
|
||
reason_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
|
||
|
||
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 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
|
||
|
||
|
||
def get_plugin_context_engine():
|
||
"""Return the plugin-registered context engine, or None."""
|
||
return _ensure_plugins_discovered()._context_engine
|
||
|
||
|
||
def get_plugin_command_handler(name: str) -> Optional[Callable]:
|
||
"""Return the handler for a plugin-registered slash command, or ``None``."""
|
||
entry = _ensure_plugins_discovered()._plugin_commands.get(name)
|
||
return entry["handler"] if entry else None
|
||
|
||
|
||
_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS = 30.0
|
||
|
||
|
||
def resolve_plugin_command_result(result: Any) -> Any:
|
||
"""Resolve a plugin command 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()
|
||
|
||
def _runner() -> None:
|
||
try:
|
||
outcome["value"] = asyncio.run(result)
|
||
except BaseException as exc: # pragma: no cover - re-raised below
|
||
failure["exc"] = exc
|
||
finally:
|
||
done.set()
|
||
|
||
thread = threading.Thread(
|
||
target=_runner,
|
||
name="hermes-plugin-command-await",
|
||
daemon=True,
|
||
)
|
||
thread.start()
|
||
if not done.wait(timeout=_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS):
|
||
raise TimeoutError(
|
||
"Plugin command async handler did not complete within "
|
||
f"{_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS:.0f}s"
|
||
)
|
||
if "exc" in failure:
|
||
raise failure["exc"]
|
||
return outcome.get("value")
|
||
|
||
|
||
def get_plugin_commands() -> Dict[str, dict]:
|
||
"""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]]:
|
||
"""Return all plugin-registered auxiliary tasks as a stable-ordered list.
|
||
|
||
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)]
|
||
|
||
|
||
def get_plugin_subscriptions() -> Dict[str, List[Callable]]:
|
||
"""Return the inter-plugin event bus subscription registry.
|
||
|
||
Returns a snapshot mapping each fully-qualified event name
|
||
(``<plugin_key>:<event>`` or ``hermes:<event>``) to subscriber callbacks in
|
||
registration order. Owner ledger metadata stays private to the manager.
|
||
Triggers idempotent plugin discovery before reading the snapshot.
|
||
"""
|
||
manager = _ensure_plugins_discovered()
|
||
with manager._event_lock:
|
||
return {
|
||
event: [entry.callback for entry in entries]
|
||
for event, entries in manager._subscriptions.items()
|
||
}
|
||
|
||
|
||
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.
|
||
"""
|
||
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)
|
||
|
||
# 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))
|
||
|
||
return result
|