Plugin System
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
plugins/__init__.py—PluginContext, registration entries (register_platform,register_provider, etc.)plugin_utils.py— shared plugin utility functionsmodel-providers/— LLM provider profiles (see Providers)platforms/— Telegram/Discord/Slack platform adapters (see Platform Adapters)context_engine/— context engine extensionscron_providers/— cron scheduling backendsmemory/— memory backendsbrowser//web/— browser and web capabilitiesimage_gen//video_gen/— multimodal generationobservability//security-guidance/— observability and security
Data Flow
- At startup,
init_agent(agent/agent_init.py:276) triggers plugin discovery. - Each plugin subdirectory is imported; module-level code calls
PluginContextregistration methods:- model-provider →
register_provider:53 - platform →
platform_registry.register:231 - tool →
registry.register:365
- model-provider →
- The registries (singletons) become the lookup truth; the main loop and gateway only read the registries and do not directly depend on specific plugins.
- 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:
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:
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_dirswallows the exception, logs a warning, and pops the half-loaded module fromsys.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.registerraisesPermissionErrorrather than silently replacing; the operator must writeplugins.entries.<plugin_id>.allow_tool_override: truein 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.