9 · 插件系统

plugins/,351 个文件。这一章讲第三方怎么在不改核心代码的前提下扩展系统

9.1 三个发现来源

~/.hermes/plugins/     用户级 —— 对这台机器上的所有项目生效
./.hermes/plugins/     项目级 —— 跟着代码仓库走,团队共享
pip entry points       包级   —— 用 pip install 安装某个包就自动生效
来源适用场景特点
用户级「我个人习惯用的工具」不进版本控制,不影响别人
项目级「这个项目需要的能力」提交进仓库,团队共享。新同事拉下代码就有
包级「发布给社区用的插件」走标准的 Python 包分发渠道,可以有版本、依赖、更新

「pip entry points」(入口点)是 Python 的标准机制:一个包可以在自己的元数据里声明「我提供了某类插件」,安装后框架自动发现,不需要用户手动注册。

9.2 插件能提供什么

plugins/
├── platforms/          22 个聊天平台适配器          → 第 1 章
├── memory/             8 种记忆后端                 → 第 8 章
├── context_engine/     上下文引擎                   → 第 7 章
├── model-providers/    模型供应商                   → 第 11 章
├── cron_providers/     定时任务提供者               → 第 12 章
├── kanban/             看板协作                     → 第 10 章
├── browser/            浏览器自动化
├── image_gen/          图像生成
├── video_gen/          视频生成
├── observability/      可观测性
├── dashboard_auth/     仪表盘认证
├── security-guidance/  安全指引
├── google_meet/        会议集成
├── spotify/            音乐
├── teams_pipeline/     Teams 流水线
├── disk-cleanup/       磁盘清理
├── hermes-achievements/ 成就系统
├── web/                网页相关
├── plugin_storage.py   ★ 插件的持久化存储
└── plugin_utils.py     ★ 插件工具函数

插件通过一套「上下文 API」向系统注册三类东西:工具、钩子、命令行子命令

9.3 最重要的设计:区分「可叠加能力」与「互斥策略」

这是插件系统设计里最容易漏掉的一个区分
可叠加能力互斥策略
例子 工具插件、平台适配器、图像生成后端 记忆提供者、上下文引擎
装 3 个会怎样 有 3 份能力,互不冲突。装得越多能力越强 系统不知道该听谁的
系统的处理 全部加载 「单选」—— 只允许激活一个

两处源码明确了这个约束:

// 上下文引擎(第 7 章)
「Selection is config-driven: `context.engine` in config.yaml.
  Default is "compressor" (the built-in). Only one engine is active.」

// 记忆提供者(第 8 章)
「The MemoryManager enforces a one-external-provider limit to prevent
  tool schema bloat and conflicting memory backends.
  Only one external provider runs at a time.」

如果不做这个区分会怎样

用户装了两个上下文引擎,都实现了 should_compress() 引擎 A:「该压缩了」 引擎 B:「不用压缩」 系统怎么办? · 听 A 的? → B 的作者会说「我的引擎被无视了」 · 都跑一遍?→ 压缩两次,第二次拿到的是第一次的结果,行为完全不可预测 · 随机选? → 每次行为不一样,无法排查 ★ 没有正确答案。所以必须在"装第二个"的那一刻就报错, 而不是留到运行时产生诡异行为。

在你自己的插件系统里,这个区分要在设计阶段就做出来。

判据很简单:「装两个的语义是『两份能力』还是『两个互相矛盾的答案』?」

如果是后者,就必须标记为单选,并且在加载第二个时明确报错。留到运行时会产生极难排查的问题 —— 因为症状是「行为和预期不一样」,而不是「报错了」。

9.4 插件的存储

plugins/plugin_storage.py

插件需要持久化自己的数据(配置、缓存、状态)。系统提供统一的存储抽象,而不是让每个插件自己决定往哪写。

这解决三个问题:

  • 路径统一 —— 备份、清理、迁移都有单一入口(呼应第 8 章的 backup_paths()
  • 隔离 —— 插件之间互相看不到对方的数据
  • 清理 —— 卸载插件时能完整清理它的数据

9.5 插件与工具集的联动

回顾第 4 章的工具集解析函数:

def _get_plugin_toolset_names() -> Set[str]        # 插件提供的工具集
def _get_registry_toolset_aliases() -> Dict[str, str]
def resolve_toolset(name, visited=None, *, include_registry: bool = True)

插件不只是「注册几个工具」,它可以注册一整个工具集。这样用户在配置里写 toolsets: [my_plugin_set] 就能启用插件的全部能力,而不用逐个列工具名。

那个 include_registry 参数说明:系统区分「内置工具集」和「注册表里的工具集(含插件的)」,某些场景下可以只解析内置的 —— 大概是为了在插件还没加载完时也能工作,或者为了安全场景下排除第三方工具。

9.6 插件钩子

插件可以挂钩到系统的几个关键点:

钩子时机与用途
pre_llm_call调模型前。注意它的约束:只能「追加到用户消息」,从不重写消息列表 —— 这是为了保护提示词缓存的前缀(第 7 章的 select_context() 才可以替换列表)
post_tool_call工具执行后。可以观察、记录、告警
agent:step智能体每走一步(第 3.6 节的步骤回调)
审批钩子第 5 章的 _fire_approval_hook,让插件参与安全决策
网关钩子gateway/hooks.py + gateway/builtin_hooks/,消息进出的节点

pre_llm_callselect_context 的权限差别

第 7 章的原文:「Unlike the pre_llm_call plugin hook (which appends to the user message and intentionally never rewrites the list, to preserve the cache prefix), select_context() may replace the message list.」

译:不同于 pre_llm_call 插件钩子(它只追加到用户消息,并且刻意从不重写列表,以保护缓存前缀),select_context() 可以替换整个消息列表。

这是一个分级授权的设计:

· 普通插件pre_llm_call)→ 只能追加,权限小,不会破坏缓存
· 上下文引擎select_context)→ 可以整个替换,权限大 —— 但它是「单选」的,用户明确选择了它,而且它的输出仍要过所有校验器

权限的大小和「用户是否明确授权」成正比。一个可以随便装十个的普通插件,不该有替换整个上下文的权力。

9.7 MCP:另一条扩展路径

除了插件,Hermes 还支持 MCP(Model Context Protocol,模型上下文协议)—— 一个让智能体接入外部工具服务的开放标准。

tools/mcp_tool.py       378 KB    MCP 客户端
mcp_serve.py            38 KB     ★ 把 Hermes 自己作为 MCP 服务暴露
optional-mcps/          65 个文件  内置的可选 MCP 服务
插件MCP
语言必须是 Python任何语言(跨进程通信)
进程同进程独立进程或远程服务
能力深 —— 可以挂钩子、注册工具集、替换核心策略浅 —— 主要是提供工具和资源
崩溃影响可能影响主进程隔离,不影响
生态Hermes 专属跨智能体产品通用

mcp_serve.py 那一项值得注意:Hermes 可以把自己作为 MCP 服务暴露出去。也就是说另一个智能体可以把 Hermes 当成一个工具来调用 —— 这让「智能体调用智能体」成为可能。

9.8 这套扩展体系的整体形状

按「权限大小」和「侵入深度」排列: ┌─ 最深、权限最大 ──────────────────────────────┐ │ 上下文引擎 / 记忆提供者 │ │ → 可替换核心策略,但【单选】,配置驱动 │ ├───────────────────────────────────────────────┤ │ 平台适配器 / 模型供应商 / 定时任务提供者 │ │ → 实现一个明确的抽象基类,可叠加 │ ├───────────────────────────────────────────────┤ │ 普通插件 │ │ → 注册工具、钩子、命令;钩子只能追加不能替换 │ ├───────────────────────────────────────────────┤ │ MCP 外部服务 │ │ → 跨进程、跨语言,只能提供工具和资源 │ ├───────────────────────────────────────────────┤ │ 技能(第 13 章) │ │ → 纯 Markdown 文本,零代码 │ └─ 最浅、权限最小 ──────────────────────────────┘

这个梯度是有意义的:扩展的门槛和它能造成的破坏成正比。

写一个技能只需要写 Markdown,任何人都能做,最多让智能体多知道一些操作步骤。
写一个上下文引擎需要理解整套契约(缓存不变式、生命周期、版本兼容),而它一旦出错会让整个系统的上下文管理失效。

系统通过「不同层级用不同机制」把这个梯度显式化了 —— 而不是提供一个万能的插件接口让所有人都能做所有事。

9 · The Plugin System

plugins/, 351 files. This chapter covers how third parties extend the system without touching core code.

9.1 Three Discovery Sources

~/.hermes/plugins/     user-level    — applies to every project on this machine
./.hermes/plugins/     project-level — travels with the repo, shared by the team
pip entry points       package-level — pip install a package and it takes effect automatically
SourceWhere it fitsCharacteristics
User-level“Tools I personally like to use”Not under version control; affects nobody else
Project-level“Capabilities this project needs”Committed to the repo, shared by the team. A new teammate pulls the code and has them
Package-level“Plugins published for the community”Goes through the standard Python packaging channel, with versions, dependencies, and updates

“pip entry points” are a standard Python mechanism: a package declares in its own metadata “I provide this kind of plugin,” and after installation the framework discovers it automatically, with no manual registration by the user.

9.2 What a Plugin Can Provide

plugins/
├── platforms/          22 chat platform adapters        → chapter 1
├── memory/             8 memory backends                → chapter 8
├── context_engine/     context engines                  → chapter 7
├── model-providers/    model providers                  → chapter 11
├── cron_providers/     scheduled-task providers         → chapter 12
├── kanban/             kanban collaboration             → chapter 10
├── browser/            browser automation
├── image_gen/          image generation
├── video_gen/          video generation
├── observability/      observability
├── dashboard_auth/     dashboard authentication
├── security-guidance/  security guidance
├── google_meet/        meeting integration
├── spotify/            music
├── teams_pipeline/     Teams pipeline
├── disk-cleanup/       disk cleanup
├── hermes-achievements/ achievement system
├── web/                web-related
├── plugin_storage.py   ★ persistent storage for plugins
└── plugin_utils.py     ★ plugin utility functions

Through a “context API,” a plugin registers three kinds of things with the system: tools, hooks, and CLI subcommands.

9.3 The Most Important Design Decision: “Stackable Capabilities” vs. “Mutually Exclusive Strategies”

This is the distinction most easily missed when designing a plugin system
Stackable capabilityMutually exclusive strategy
Examples Tool plugins, platform adapters, image-generation backends Memory providers, context engines
What happens if you install 3 You have 3 capabilities that don't conflict. The more you install, the more you can do The system doesn't know which one to listen to
How the system handles it Loads them all “Single-select” — only one may be active

Two places in the source make this constraint explicit:

// Context engine (chapter 7)
“Selection is config-driven: `context.engine` in config.yaml.
  Default is "compressor" (the built-in). Only one engine is active.”

// Memory provider (chapter 8)
“The MemoryManager enforces a one-external-provider limit to prevent
  tool schema bloat and conflicting memory backends.
  Only one external provider runs at a time.”

What happens if you don't make this distinction

The user installs two context engines, both implementing should_compress() Engine A: "Time to compact" Engine B: "No need to compact" What does the system do? · Listen to A? → B's author says "my engine is being ignored" · Run both? → compacts twice; the second run gets the first run's output; behavior is totally unpredictable · Pick at random? → different behavior every time; impossible to debug ★ There is no right answer. So it has to fail the moment the "second one is installed," rather than wait until runtime and produce bizarre behavior.

In your own plugin system, make this distinction at the design stage.

The test is simple: “If two are installed, does that mean ‘two capabilities’ or ‘two contradictory answers’?”

If it's the latter, you must mark it single-select and fail explicitly when a second one is loaded. Leaving it to runtime produces problems that are extremely hard to track down — because the symptom is “behavior differs from expectations,” not “an error was thrown.”

9.4 Plugin Storage

plugins/plugin_storage.py

Plugins need to persist their own data (configuration, caches, state). The system provides a unified storage abstraction rather than letting each plugin decide where to write.

This solves three problems:

  • Unified paths — backup, cleanup, and migration all have a single entry point (echoing backup_paths() from chapter 8)
  • Isolation — plugins can't see each other's data
  • Cleanup — uninstalling a plugin can remove all of its data cleanly

9.5 How Plugins Tie into Toolsets

Recall the toolset resolution functions from chapter 4:

def _get_plugin_toolset_names() -> Set[str]        # toolsets provided by plugins
def _get_registry_toolset_aliases() -> Dict[str, str]
def resolve_toolset(name, visited=None, *, include_registry: bool = True)

A plugin doesn't just “register a few tools”; it can register an entire toolset. That way a user can write toolsets: [my_plugin_set] in their config to enable everything the plugin offers, without listing tool names one by one.

That include_registry parameter tells you something: the system distinguishes “built-in toolsets” from “toolsets in the registry (including plugins')”, and in some scenarios it can resolve only the built-in ones — presumably so things still work before plugins have finished loading, or to exclude third-party tools in security-sensitive contexts.

9.6 Plugin Hooks

Plugins can hook into several key points in the system:

HookWhen and what for
pre_llm_callBefore calling the model. Note its constraint: it may only “append to the user message,” never rewrite the message list — this protects the prompt-cache prefix (only select_context() from chapter 7 may replace the list)
post_tool_callAfter a tool runs. Can observe, log, or alert
agent:stepEvery step the agent takes (the step callback from section 3.6)
Approval hooks_fire_approval_hook from chapter 5, letting plugins take part in safety decisions
Gateway hooksgateway/hooks.py + gateway/builtin_hooks/, the points where messages enter and leave

The permission gap between pre_llm_call and select_context

From chapter 7's source: “Unlike the pre_llm_call plugin hook (which appends to the user message and intentionally never rewrites the list, to preserve the cache prefix), select_context() may replace the message list.”

In plain terms: unlike the pre_llm_call plugin hook (which only appends to the user message and deliberately never rewrites the list, to preserve the cache prefix), select_context() may replace the entire message list.

This is tiered authorization by design:

· Ordinary plugins (pre_llm_call) → append only; small privilege; can't break the cache
· Context engines (select_context) → may replace the whole thing; large privilege — but they are “single-select,” the user explicitly chose them, and their output still passes through every validator

The size of the privilege scales with “did the user explicitly authorize it.” An ordinary plugin that you can casually install ten of should never have the power to replace the entire context.

9.7 MCP: The Other Extension Path

Besides plugins, Hermes also supports MCP (Model Context Protocol) — an open standard for connecting agents to external tool services.

tools/mcp_tool.py       378 KB    MCP client
mcp_serve.py            38 KB     ★ exposes Hermes itself as an MCP server
optional-mcps/          65 files  built-in optional MCP servers
PluginMCP
LanguageMust be PythonAny language (cross-process communication)
ProcessSame processSeparate process or remote service
ReachDeep — can attach hooks, register toolsets, replace core strategiesShallow — mainly provides tools and resources
Blast radius of a crashCan take down the main processIsolated; no effect
EcosystemHermes-specificShared across agent products

The mcp_serve.py entry deserves attention: Hermes can expose itself as an MCP server. In other words, another agent can call Hermes as a tool — which makes “agents calling agents” possible.

9.8 The Overall Shape of This Extension System

Ordered by "size of privilege" and "depth of intrusion": ┌─ Deepest, most privileged ───────────────────────────────────┐ │ Context engine / memory provider │ │ → replaces a core strategy; SINGLE-SELECT, config-driven │ ├──────────────────────────────────────────────────────────────┤ │ Platform adapters / model providers / cron providers │ │ → implements a clear abstract base class; stackable │ ├──────────────────────────────────────────────────────────────┤ │ Ordinary plugins │ │ → tools, hooks, CLI commands; hooks append, never replace │ ├──────────────────────────────────────────────────────────────┤ │ MCP external services │ │ → cross-process, cross-language; tools and resources only │ ├──────────────────────────────────────────────────────────────┤ │ Skills (chapter 13) │ │ → pure Markdown text, zero code │ └─ Shallowest, least privileged ───────────────────────────────┘

This gradient means something: the barrier to entry for an extension scales with the damage it can do.

Writing a skill just means writing Markdown; anyone can do it, and at most it teaches the agent a few more procedures.
Writing a context engine requires understanding the whole contract (the cache invariant, the lifecycle, version compatibility), and once it goes wrong, context management for the entire system breaks down.

The system makes this gradient explicit by “using different mechanisms at different tiers” — rather than offering one all-purpose plugin interface that lets everyone do everything.