Skip to content

Plugin System

源码版本v2026.7.20

Responsibility

Plugins are Hermes's extension skeleton: provider profiles, platform adapters, context engine, cron providers, memory backends, browser, image/video generation... all plug in through the plugin mechanism. plugins/ provides PluginContext (the registration entry) + utility functions; each plugin subdirectory is self-contained by domain. It coordinates with ToolRegistry/PlatformRegistry: plugins only "declare and register"; the registries handle "lookup and dispatch".

Design Motivation

Why "directory convention + module-import-time self-registration" instead of an explicit plugins.yaml? The point is to lower the friction of adding capabilities: drop a subdirectory into $HERMES_HOME/plugins/model-providers/<name>/, the __init__.py is auto-imported when discovered, and module-level code calls register_provider() to finish registration — no config file edit needed. The tradeoff is that ordering and overrides have to be handled by registry policy (namespacing, explicit opt-in for override); in return you get hot-pluggability and zero-friction third-party plugins.

Key Files

Data Flow

  1. At startup, init_agent (agent/agent_init.py:276) triggers plugin discovery.
  2. Each plugin subdirectory is imported; module-level code calls PluginContext registration methods:
  3. The registries (singletons) become the lookup truth; the main loop and gateway only read the registries and do not directly depend on specific plugins.
  4. Plugins are hot-pluggable: unloading is just unregister; the registry handles priority and overrides.

Key Code

Directory-scan plugin discovery

_discover_providers is the entry point for model-providers. It scans two directories (the repo-bundled dir + the $HERMES_HOME user dir) and calls _import_plugin_dir on each subdirectory:

python
def _discover_providers() -> None:
    """1. Bundled at <repo>/plugins/model-providers/<name>/
       2. User at $HERMES_HOME/plugins/model-providers/<name>/
       Later steps win on name collision."""
    global _discovered
    if _discovered: return
    _discovered = True
    if _BUNDLED_PLUGINS_DIR.is_dir():
        for child in sorted(_BUNDLED_PLUGINS_DIR.iterdir()):
            if child.is_dir() and not child.name.startswith(("_", ".")):
                _import_plugin_dir(child, "bundled")
    user_dir = _user_plugins_dir()
    if user_dir is not None:
        for child in sorted(user_dir.iterdir()):
            if child.is_dir() and not child.name.startswith(("_", ".")):
                _import_plugin_dir(child, "user")

"user directory overrides bundled" comes from this order: both steps call register_provider, and the latter overwrites the former. Directories prefixed with _ or . are ignored, leaving an escape hatch for things like __pycache__.

Self-registration: importing a module is registering

_import_plugin_dir uses importlib.util to load the directory as a module; module-level code runs and calls register_provider(profile) to push the profile into the global registry:

python
module_name = (f"plugins.model_providers.{safe_name}" if source == "bundled"
               else f"_hermes_user_provider_{safe_name}")
spec = importlib.util.spec_from_file_location(
    module_name, init_file, submodule_search_locations=[str(plugin_dir)])
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)

Bundled and user plugins use different module namespaces, so two profiles with the same name under different HERMES_HOMEs do not alias each other in sys.modules.

Registry: preventing silent override of built-in tools

registry.register refuses same-name overrides across toolsets by default. MCP-over-MCP is allowed (refresh is a legitimate case), but a plugin that wants to replace a built-in tool must pass override=True explicitly, and the operator must set allow_tool_override: true in config first — otherwise it raises a PermissionError instead of silently replacing. The owner is determined by handler.__globals__["__name__"], bound at definition time, so a plugin cannot launder an override through a nested lambda to make it look like "from a built-in module". See registry.register:365-436.

Boundaries and Failure

  • Plugin import failure: _import_plugin_dir swallows the exception, logs a warning, and pops the half-loaded module from sys.modules. One bad plugin does not take down the whole agent, but the corresponding provider/platform is missing — you have to read the logs to notice.
  • Overriding a built-in tool without explicit opt-in: registry.register raises PermissionError rather than silently replacing; the operator must write plugins.entries.<plugin_id>.allow_tool_override: true in config.yaml to allow it.
  • Context engine not deep-copyable: the context engine shared across multiple agents gets copy.deepcopy-ed at child-agent startup to isolate budget state; if a plugin holds non-deep-copyable objects like locks or DB connections, deep-copy failure falls back to the built-in compressor.

Summary

Plugin = "subdirectory + registration". All cross-cutting capabilities (providers, platforms, tools, memory, cron, multimodal) go through the same registration skeleton, so the core loop stays stable. Adding a capability = creating a plugin subdirectory and registering; no need to touch the agent's trunk.

Unofficial community learning site. Content based on the MIT-licensed NousResearch/hermes-agent source.