插件系统
职责
插件是 Hermes 的扩展骨架:provider profile、平台适配器、context engine、cron provider、记忆后端、浏览器、图像/视频生成……都通过插件机制接入。plugins/ 提供 PluginContext(注册入口)+ 工具函数,各插件子目录按领域自包含。它和 ToolRegistry/PlatformRegistry 协同:插件只负责「声明并注册」,注册表负责「查询与调度」。
设计动机
为什么是「目录约定 + 模块导入时自注册」,而不是显式 plugins.yaml?核心是降低新增能力的摩擦:把子目录扔进 $HERMES_HOME/plugins/model-providers/<name>/,__init__.py 被发现时自动 import,模块级代码调用 register_provider() 完成注册——不用改配置文件。代价是排序和覆盖要靠注册表策略(命名空间、override 显式 opt-in)兜底,换来的是热插拔和第三方插件零摩擦接入。
关键文件
plugins/__init__.py—PluginContext、注册入口(register_platform、register_provider等)plugin_utils.py— 插件共用工具函数model-providers/— LLM provider profile(见 Providers)platforms/— Telegram/Discord/Slack 等平台适配器(见平台适配器)context_engine/— 上下文引擎扩展cron_providers/— cron 调度后端memory/— 记忆后端browser//web/— 浏览器与 Web 能力image_gen//video_gen/— 多模态生成observability//security-guidance/— 可观测与安全
数据流
- 启动期
init_agent(agent/agent_init.py:276)触发插件发现。 - 每个插件子目录被导入,模块级代码调用
PluginContext的注册方法:- model-provider →
register_provider:53 - platform →
platform_registry.register:231 - 工具 →
registry.register:365
- model-provider →
- 注册表(单例)成为查询真相,主循环与网关只读注册表,不直接依赖具体插件。
- 插件可热插拔:卸载只需
unregister,注册表处理优先级与覆盖。
关键代码
目录扫描发现插件
_discover_providers 是 model-providers 的发现入口。它扫描两个目录(仓库内置 + $HERMES_HOME 用户目录),对每个子目录调用 _import_plugin_dir:
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")「用户目录覆盖内置」就来自这个顺序:两步都调用 register_provider,后者覆盖前者。_/. 前缀的目录被忽略,留出 __pycache__ 之类的逃生口。
自注册:模块导入即注册
_import_plugin_dir 用 importlib.util 把目录当模块加载,模块级代码运行时调用 register_provider(profile) 把 profile 推进全局注册表:
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 和 user 走不同模块命名空间,避免两个 HERMES_HOME 下的同名 profile 在 sys.modules 里互相 alias。
注册表:防止默默覆盖内置工具
registry.register 默认拒绝跨 toolset 的同名覆盖。MCP 之间允许覆盖(刷新场景合法),但插件想替内置工具必须显式 override=True,且要先有 operator 在 config 里 allow_tool_override: true 才放行——否则直接抛 PermissionError,而不是默默替换。owner 判定基于 handler.__globals__["__name__"],定义时绑定,插件没法用嵌套 lambda 把覆盖洗白成「来自内置模块」。详见 registry.register:365-436。
边界与失败
- 插件 import 失败:
_import_plugin_dir把异常吞掉只打 warning,并从sys.modules弹出避免半加载状态。一个坏插件不会拖死整个 agent,但对应的 provider/platform 就拿不到,需要看日志才能发现。 - 覆盖内置工具没显式 opt-in:
registry.register抛PermissionError而不是默默替换;operator 必须在 config.yaml 里写plugins.entries.<plugin_id>.allow_tool_override: true才放行。 - context engine 不可深拷贝:多 agent 共享的 context engine 在 child agent 启动时
copy.deepcopy隔离 budget 状态;插件若持有锁、DB 连接等不可深拷贝对象,深拷贝失败会 fallback 到内置 compressor。
小结
插件 = 「子目录 + 注册」。所有跨切面能力(provider、平台、工具、记忆、cron、多模态)都走同一套注册骨架,核心循环因此保持稳定。新增能力 = 新建一个插件子目录并注册,无需动 agent 主干。