框架的生命力不在于它能做什么,而在于它允许别人用它做什么。
先看全景:三条扩展线各管什么
| 维度 | General Plugin | Memory Provider | Context Engine |
|---|---|---|---|
| 解决什么 | 加工具、加 hook、加斜杠命令、加 CLI 子命令、加技能 | 换记忆后端(Honcho、Mem0、Hindsight...) | 换上下文压缩策略 |
| 可以有几个 | 多个同时加载 | 只能启用 1 个外部 provider(内置始终在) | 只能启用 1 个 |
| 注册位置 | 默认 ~/.hermes/plugins/<name>/ |
plugins/memory/<name>/ (内置)或默认 $HERMES_HOME/plugins/<name>/ |
plugins/context_engine/<name>/ (仓库内)或 General Plugin |
| 基类 | 无(通过 PluginContext API) |
MemoryProvider ABC |
ContextEngine ABC |
| 配置路径 | plugins.disabled 禁用列表 |
memory.provider |
context.engine |
一个常见的误解是"我要扩展 Hermes 就得写插件"。不一定。如果你只是想接一个 MCP 服务器,不需要写插件------配 config.yaml 就行(下一讲专门讲 MCP)。如果你只是想加一段系统提示词,写 .hermes.md 就行。插件是给需要运行代码的扩展准备的。
下文出现的 ~/.hermes/... 都指默认 profile 。如果你启用了 profile,这些路径都会随 HERMES_HOME 切换。
General Plugin:完整的扩展框架
目录结构
一个 General Plugin 的最小结构(默认位于 ~/.hermes/plugins/my-plugin/):
~/.hermes/plugins/my-plugin/
├── plugin.yaml # 清单文件(必需)
└── __init__.py # 入口模块,必须提供 register(ctx) 函数
plugin.yaml 清单
name: my-plugin
version: 1.0.0
description: A brief description of what this plugin does
author: your-name
requires_env:
- MY_API_KEY # 简单环境变量名
- name: MY_SECRET # 或带描述的结构
description: "API secret for My Service"
provides_tools:
- my_tool_name
provides_hooks:
- pre_tool_call
PluginManifest(hermes_cli/plugins.py 第 92 行)解析这些字段。requires_env 声明的环境变量会在 hermes plugins 命令里展示缺失状态,但不会阻止加载 ------插件自己在 check_fn 里决定是否可用。
register(ctx) 入口
插件的唯一入口点是模块级的 register() 函数。启动时 PluginManager._load_plugin() 导入模块后调用它,传入一个 PluginContext 实例:
# __init__.py
def register(ctx):
ctx.register_tool(
name="my_tool",
toolset="my-plugin",
schema={...},
handler=my_handler,
check_fn=lambda: bool(os.getenv("MY_API_KEY")),
)
ctx.register_hook("pre_tool_call", my_pre_tool_hook)
PluginContext(第 124 行)是 General Plugin 能做的所有事情的门面。它提供 8 类核心注册方法------我们逐个看。
PluginContext 的 8 类注册能力
1. register_tool --- 注册工具
ctx.register_tool(
name="my_tool",
toolset="my-plugin",
schema={"name": "my_tool", "description": "...", "parameters": {...}},
handler=my_handler, # def handler(args: dict, **kwargs) -> str
check_fn=lambda: True, # 返回 False 则工具对模型不可见
is_async=False,
description="Human-readable description",
)
内部直接代理到 tools.registry.register()(第 148 行)。注册完的工具和内置工具一模一样------模型不知道它来自插件还是内置。
防遮蔽机制 :如果你的插件工具名和内置工具重名(比如叫 terminal),注册会被拒绝------registry.register() 在第 204 行检查 existing.toolset != toolset,非 MCP 来源的重名一律拒绝。这是故意的:防止插件悄悄劫持核心工具。
2. register_hook --- 注册生命周期钩子
ctx.register_hook("pre_tool_call", my_callback)
10 种合法的 hook name(VALID_HOOKS,第 54 行):
| Hook | 触发时机 | 回调签名 | 特殊能力 |
|---|---|---|---|
pre_tool_call |
工具执行前 | (tool_name, args, task_id, session_id, tool_call_id) |
返回 {"action": "block", "message": "..."} 可阻止执行 |
post_tool_call |
工具执行后 | (tool_name, args, result, task_id) |
观察用 |
pre_llm_call |
LLM 调用前 | (session_id, user_message, conversation_history, ...) |
返回 {"context": "..."} 可注入上下文 |
post_llm_call |
LLM 调用后 | (session_id, user_message, assistant_response, ...) |
观察用 |
pre_api_request |
API 请求前 | 请求元数据 | 观察用 |
post_api_request |
API 响应后 | 响应元数据 | 观察用 |
on_session_start |
新会话开始 | (session_id, model, platform) |
观察用 |
on_session_end |
会话结束 | (session_id, completed, interrupted, ...) |
观察用 |
on_session_finalize |
会话清理 | (session_id, platform) |
观察用 |
on_session_reset |
/new 或 /reset |
(session_id, platform) |
观察用 |
注意 pre_tool_call 和 pre_llm_call------它们不只是观察,而是有主动干预能力 。pre_tool_call 返回 {"action": "block"} 可以阻止工具执行(实现策略控制),pre_llm_call 返回的字符串会被注入到上下文里(实现动态上下文增强)。
hook 调用被 try/except 包裹(invoke_hook 第 632 行)------一个坏掉的插件 hook 不会打断 Agent 主循环。
3. register_command --- 注册会话内斜杠命令
ctx.register_command(
name="status",
handler=lambda args: f"Plugin status: OK",
description="Show plugin status",
)
用户在 CLI 或 Gateway 里输入 /status 就能触发。handler 签名是 fn(raw_args: str) -> str | None,支持同步和异步。
如果名字和内置命令冲突(比如 /help),注册会被拒绝(第 245 行通过 resolve_command() 检查)。
4. register_cli_command --- 注册终端 CLI 子命令
ctx.register_cli_command(
name="incident-admin",
help="Manage the incident plugin from the terminal",
setup_fn=setup_argparser, # 收到 argparse subparser
handler_fn=handle_command,
)
注册后用户可以运行 hermes incident-admin <args>。这和会话内斜杠命令不同------它是一个独立的终端命令,不进入 Agent 对话。
这里要特别区分两条链路:General Plugin 的 CLI 子命令 走 PluginContext.register_cli_command();而 Memory Provider 自己的 CLI 子命令 (比如 hermes honcho ...)走的是 provider 的 cli.py + plugins.memory.discover_plugin_cli_commands() 这条专用发现链,不属于 PluginContext 的注册面。
5. register_context_engine --- 注册上下文引擎
ctx.register_context_engine(my_engine_instance)
只允许注册一个 (第 303 行检查)。第二个插件尝试注册会被拒绝并打 warning。引擎实例必须继承 ContextEngine ABC(第 312 行检查)。
6. register_skill --- 注册插件附带的技能
ctx.register_skill(
name="my-workflow",
path=Path(__file__).parent / "skills" / "my-workflow" / "SKILL.md",
description="A reusable workflow provided by this plugin",
)
注册后技能以 plugin_name:skill_name 的限定名可用(比如 my-plugin:my-workflow),通过 skill_view("my-plugin:my-workflow") 加载。插件技能不出现在系统提示词的技能索引里------它们是显式加载的,不会自动注入。
7. inject_message --- 注入消息到对话
ctx.inject_message("Here's some data from an external system", role="user")
仅在 CLI 模式下可用(Gateway 模式返回 False)。如果 Agent 正在运行,消息进入中断队列;如果 Agent 空闲,消息作为下一轮输入排队。
8. dispatch_tool --- 从插件内调用已注册的工具
result = ctx.dispatch_tool("delegate_task", {"task": "Summarize this file"})
让插件的斜杠命令可以调用 Agent 的工具------比如一个 /research 命令内部调用 delegate_task 来启动子 Agent。
到这里,PluginContext 的核心注册面就讲完了。一个容易搞混的点是:Memory Provider 不通过 PluginContext 注册自己 。它们走的是 plugins/memory/__init__.py 的专用加载器,在那里用一个专门的 collector 捕获 register_memory_provider(...) 调用。
三种插件来源
discover_and_load()(第 415 行)按顺序扫描三个来源:
-
用户插件 :默认
~/.hermes/plugins/<name>/------最常用,直接建目录写代码;启用 profile 时会落到当前HERMES_HOME/plugins/<name>/ -
项目插件 :
./.hermes/plugins/<name>/------需要HERMES_ENABLE_PROJECT_PLUGINS=true才启用。安全考虑默认关闭,因为项目目录可能来自不可信的 git clone -
pip 插件 :通过
hermes_agent.pluginsentry-point group 发现。适合做成可分发的 Python 包
用户可以通过 config.yaml 禁用特定插件:
plugins:
disabled:
- noisy-plugin
- buggy-plugin
Memory Provider:换一个记忆后端
为什么需要可插拔的记忆后端
第 5-6 讲拆过内置记忆------MEMORY.md(2,200 字符)和 USER.md(1,375 字符)。这套系统简单、零依赖、对 prompt cache 友好。但它有明确的上限:空间有限、纯文本、无语义检索。
如果你需要大规模的跨会话记忆、用户画像建模、语义召回------内置记忆不够用。这时候换一个外部 Memory Provider。
8 个内置 Provider
plugins/memory/ 目录下自带 8 个 provider:
| Provider | 特点 |
|---|---|
| honcho | Dialectic reasoning,server-side 推理式记忆,per-peer 多 Agent 画像隔离 |
| hindsight | 本地向量存储 + 语义检索 |
| holographic | 多文件架构(store.py + retrieval.py),全息式记忆编码 |
| mem0 | Mem0 云服务集成 |
| byterover | ByteRover 集成 |
| openviking | OpenViking 集成 |
| retaindb | 本地数据库存储 |
| supermemory | SuperMemory 集成 |
每个 provider 都有自己的 plugin.yaml、__init__.py(实现 MemoryProvider ABC)和 README.md。
MemoryProvider ABC
agent/memory_provider.py(第 42 行)定义了 provider 必须实现的接口:
class MemoryProvider(ABC):
@property
@abstractmethod
def name(self) -> str: ...
@abstractmethod
def is_available(self) -> bool: ...
@abstractmethod
def initialize(self, session_id: str, **kwargs) -> None: ...
@abstractmethod
def get_tool_schemas(self) -> List[Dict[str, Any]]: ...
4 个抽象方法必须实现。另外还有一组可选方法和 hook:
核心可选方法:
| 方法 | 说明 | 默认行为 |
|---|---|---|
system_prompt_block() |
返回注入系统提示词的静态文本 | 空字符串 |
prefetch(query) |
每轮 API 调用前,召回相关上下文 | 空字符串 |
queue_prefetch(query) |
每轮结束后,预队列下一轮的召回 | 无操作 |
sync_turn(user, assistant) |
每轮结束后,持久化对话内容 | 无操作 |
handle_tool_call(name, args) |
处理 provider 暴露的工具调用 | 抛异常 |
shutdown() |
干净关闭 | 无操作 |
可选 hook:
| Hook | 触发时机 | 典型用途 |
|---|---|---|
on_turn_start(turn, message) |
每轮开始 | 轮次计数、定期维护 |
on_session_end(messages) |
会话结束 | 结束时提取知识、总结 |
on_pre_compress(messages) |
上下文压缩前 | 在消息被丢弃前做 flush / curate / 提前提取洞见 |
on_memory_write(action, target, content) |
内置记忆写入时 | 镜像内置记忆的写入到外部后端 |
on_delegation(task, result) |
子 Agent 完成时 | 在父 Agent 侧观察委派结果 |
on_pre_compress 值得单独说------当上下文压缩即将丢弃旧消息时,provider 有机会先从中提取关键信息。当前 run_agent.py 的真实实现重点是在压缩前通知 provider,让它有机会自行 flush / curate / 落盘;不要把它理解成"返回一段文本,框架就一定会自动拼进压缩 prompt"。
initialize() 的 kwargs
initialize() 收到的 kwargs 非常丰富(第 67-81 行注释):
kwargs always include:
- hermes_home (str): 当前 HERMES_HOME 目录
- platform (str): "cli", "telegram", "discord", "cron" 等
kwargs may also include:
- agent_context (str): "primary", "subagent", "cron", "flush"
- agent_identity (str): profile 名称
- agent_workspace (str): 工作区名称
- parent_session_id (str): 子 Agent 场景下的父 session_id
- user_id (str): 平台用户标识
agent_context 特别重要------如果值是 "cron" 或 "flush",provider 应该跳过写入。因为 cron 的系统提示词和正常对话不同,写入会污染用户画像。
运行时边界:当前只挂 1 个外部 Provider
从抽象设计看,MemoryManager(agent/memory_manager.py 第 83 行)支持维护 provider 列表;但当前运行时更保守:内置记忆仍然走独立的 MemoryStore / memory 工具链,MemoryManager 只在配置了 memory.provider 时才初始化,用来托管那个唯一的外部 provider。
源码注释解释了为什么只允许一个外部 provider:
Only one external provider runs at a time to prevent tool schema bloat
and conflicting memory backends.
如果同时跑 Honcho 和 Mem0,两个 provider 各自暴露记忆工具,模型会困惑"该用哪个"。所以更准确的说法是:内置记忆一直在线,但当前 MemoryManager 本身并不是"内置 + 外部"的统一容器,它是"外部 provider 编排层"。
激活方式
在默认 ~/.hermes/config.yaml 里设置:
memory:
provider: honcho
run_agent.py 在初始化阶段(第 1312-1389 行)读取这个配置,调用 load_memory_provider("honcho"),然后 MemoryManager.add_provider() 把它加进去。
如果 provider 的 is_available() 返回 False(比如缺少 API Key),加载会静默跳过,Agent 继续用内置记忆。
Context Engine:换一种上下文管理策略
内置方案:ContextCompressor
默认的上下文管理是"压缩"------当 token 用量超过阈值(默认 75% context length),ContextCompressor 把旧消息总结成一段摘要,腾出空间。第 18 讲会详细拆压缩器。
但压缩不是唯一的策略。比如 LCM(Large Context Management)可能用知识图谱来组织上下文,把消息转化为可检索的节点而不是简单丢弃。Context Engine 就是为这种替代方案准备的扩展点。
ContextEngine ABC
agent/context_engine.py(第 32 行)定义了上下文引擎的接口:
class ContextEngine(ABC):
@property
@abstractmethod
def name(self) -> str: ...
@abstractmethod
def update_from_response(self, usage: Dict[str, Any]) -> None: ...
@abstractmethod
def should_compress(self, prompt_tokens: int = None) -> bool: ...
@abstractmethod
def compress(self, messages: List[Dict[str, Any]],
current_tokens: int = None) -> List[Dict[str, Any]]: ...
3 个抽象方法 + 1 个 name 属性必须实现。
update_from_response(usage):每次 LLM 调用后,引擎从响应的 usage 字典里更新 token 计数。引擎需要维护 6 个类属性:
last_prompt_tokens: int = 0
last_completion_tokens: int = 0
last_total_tokens: int = 0
threshold_tokens: int = 0 # 触发压缩的阈值
context_length: int = 0 # 模型的最大 context length
compression_count: int = 0 # 已压缩次数
run_agent.py 直接读取这些属性来显示状态和做预检。
should_compress(prompt_tokens):返回 True 就触发压缩。内置压缩器的判断逻辑是 prompt_tokens > threshold_tokens,但自定义引擎可以有自己的策略------比如按消息数量、按知识图谱大小、按时间窗口。
compress(messages, current_tokens):核心方法。接收完整消息列表,返回一个更短的列表。只要返回值是合法的 OpenAI 格式消息序列,引擎内部怎么做都行------总结、裁剪、转存到外部、构建 DAG、什么都可以。
可选的工具暴露 :引擎可以通过 get_tool_schemas() 和 handle_tool_call() 给 Agent 提供工具。比如一个 LCM 引擎可能暴露 lcm_grep(在知识图谱里搜索)、lcm_expand(展开某个节点的上下文)。
模型切换支持 :update_model() 方法在用户切换模型时被调用。默认实现重新计算 threshold_tokens:
def update_model(self, model, context_length, **kwargs):
self.context_length = context_length
self.threshold_tokens = int(context_length * self.threshold_percent)
如果你的引擎对模型有依赖(比如用特定模型做摘要),在这里切换。
激活方式
context:
engine: my-engine
引擎可以通过两种方式提供:
-
仓库内目录引擎:
plugins/context_engine/my-engine/ -
General Plugin:在
register(ctx)里调用ctx.register_context_engine(engine)
加载优先级:先查 plugins/context_engine/ 目录,再查 General Plugin 注册的引擎。
这里和 Memory Provider 并不完全对称 。当前源码没有一条独立的 HERMES_HOME/plugins/context_engine/... 用户目录扫描链;用户自定义 Context Engine 更现实的入口是写成 General Plugin,然后在 register(ctx) 里显式注册。
自定义工具注册:registry.register() 全流程
不管是内置工具、插件工具还是 MCP 工具,最终都走同一个注册入口。
ToolEntry 的完整字段
ToolEntry(tools/registry.py 第 76 行)用 __slots__ 定义了 10 个字段:
__slots__ = (
"name", # 工具名(模型调用时用)
"toolset", # 所属工具集(分组用)
"schema", # OpenAI 格式的函数定义
"handler", # 处理函数
"check_fn", # 可用性检查
"requires_env", # 需要的环境变量列表
"is_async", # 是否异步 handler
"description", # 人类可读描述
"emoji", # 展示用 emoji
"max_result_size_chars", # 结果最大字符数
)
register() 的关键行为
ToolRegistry.register()(第 176 行)有几个值得注意的行为:
1. 防遮蔽 :同名工具来自不同 toolset 时,注册被拒绝------除非两者都是 MCP toolset(以 "mcp-" 开头),MCP 之间允许覆盖(应对 server refresh 或 tool name 冲突)。
2. toolset check_fn 共享 :同一个 toolset 的所有工具共享一个 check_fn(第 226 行,first-write-wins)。当某个 toolset 的 check_fn 返回 False,该 toolset 下的所有工具对模型都不可见。
3. 线程安全 :整个注册过程在 self._lock(threading.RLock)保护下执行。MCP 的动态工具发现可能在任何时候触发 nuke-and-repave 刷新,RLock 保证读写不会撕裂。
handler 的约定
工具的 handler 函数需要满足:
def my_handler(args: dict, **kwargs) -> str:
# args: 模型传的参数字典
# **kwargs: 框架传的额外上下文(task_id, user_task 等)
# 返回值: JSON 字符串
return json.dumps({"result": "success"})
返回值必须是字符串 (通常是 JSON)。异步 handler 设 is_async=True,注册表在分发时自动桥接。
内置工具的发现方式
discover_builtin_tools()(第 56 行)不是盲目导入 tools/ 下的所有 .py 文件。它先用 AST 解析 检查每个文件的顶层是否有 registry.register() 调用(第 28-53 行的 _module_registers_tools() / _is_registry_register_call()),只导入确实有注册调用的模块。这避免了"导入一个工具模块就拉起它的全部依赖"的问题。
实战:开发一个连接内部 API 的自定义插件
假设你的团队有一个内部服务 https://internal-api.example.com/incidents\,你希望 Agent 能查询当前的线上事故。
第一步:创建目录和清单
mkdir -p ~/.hermes/plugins/incident-query
这里仍然以默认 profile 为例;如果你在用 profile,这个目录对应当前 HERMES_HOME/plugins/incident-query/。
~/.hermes/plugins/incident-query/plugin.yaml:
name: incident-query
version: 1.0.0
description: Query internal incident management API
author: your-team
requires_env:
- name: INCIDENT_API_TOKEN
description: "API token for the incident service"
provides_tools:
- query_incidents
第二步:实现工具
~/.hermes/plugins/incident-query/__init__.py:
import json
import os
import httpx
SCHEMA = {
"name": "query_incidents",
"description": "Query the internal incident management system. "
"Returns active incidents with severity, status, and owner.",
"parameters": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["active", "resolved", "all"],
"description": "Filter by incident status",
},
"severity": {
"type": "string",
"enum": ["P0", "P1", "P2", "P3", "all"],
"description": "Filter by severity level",
},
},
"required": [ ],
},
}
def _check_available():
return bool(os.getenv("INCIDENT_API_TOKEN"))
def _handler(args: dict, **kwargs) -> str:
token = os.getenv("INCIDENT_API_TOKEN", "")
status = args.get("status", "active")
severity = args.get("severity", "all")
try:
resp = httpx.get(
"https://internal-api.example.com/incidents",
params={"status": status, "severity": severity},
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
resp.raise_for_status()
incidents = resp.json()
return json.dumps(incidents, indent=2)
except Exception as e:
return json.dumps({"error": str(e)})
def register(ctx):
ctx.register_tool(
name="query_incidents",
toolset="incident-query",
schema=SCHEMA,
handler=_handler,
check_fn=_check_available,
description="Query internal incident API",
)
第三步:配置凭证
在默认 ~/.hermes/.env 里加一行:
INCIDENT_API_TOKEN=your-api-token-here
第四步:验证
# 确认插件被发现
hermes plugins
# 在对话中验证
hermes chat -q "现在有哪些 P0 事故?"
Agent 会在工具列表中看到 query_incidents,当用户问到事故相关问题时自动调用。
加一个安全 hook(可选)
如果你想确保 Agent 不会在公开群组里泄露事故详情,可以加一个 pre_tool_call hook:
def _guard_hook(tool_name, args, task_id=None, **kwargs):
if tool_name != "query_incidents":
return None
platform = kwargs.get("platform", "cli")
chat_type = kwargs.get("chat_type", "dm")
if platform != "cli" and chat_type != "dm":
return {
"action": "block",
"message": "Incident queries are only allowed in DMs for security reasons.",
}
return None
def register(ctx):
ctx.register_tool(...) # 同上
ctx.register_hook("pre_tool_call", _guard_hook)
这样在群组里调用 query_incidents 会被拦截,只有私聊和 CLI 模式允许。
小结
这一讲把 Hermes 的三条扩展线拆完了。
General Plugin 是最灵活的扩展方式------PluginContext 提供 8 类核心注册能力,从工具到 hook 到斜杠命令到技能。三种来源(用户目录、项目目录、pip 包)覆盖了开发到分发的全生命周期。10 种 hook 中 pre_tool_call 和 pre_llm_call 有主动干预能力(阻止执行、注入上下文),其余是观察用。要特别记住:Memory Provider 的注册和 CLI 扩展并不走这条 PluginContext 链。
Memory Provider 是单选替换------8 个内置 provider 按 memory.provider 配置激活,和内置记忆并存。MemoryProvider ABC 定义了完整的生命周期(initialize → prefetch → sync_turn → shutdown)和 5 个可选 hook。当前运行时里,内置记忆仍然走独立 MemoryStore,外部 provider 由 MemoryManager 单独托管;on_pre_compress 的现实意义,更接近"压缩前通知 provider 自行抢救信息",而不是"自动把返回文本拼进压缩 prompt"。
Context Engine 是上下文管理的替换点------默认是 ContextCompressor,可以换成基于知识图谱或其他策略的引擎。ContextEngine ABC 要求实现 3 个核心方法(update_from_response / should_compress / compress),可选暴露工具给 Agent 使用。对用户自定义扩展来说,最稳妥的落点通常是 General Plugin,而不是把它想成和 Memory Provider 完全对称的一条用户目录扫描链。
三条线的设计原则一致:单一职责、基类约束、安全隔离。插件不能遮蔽内置工具,外部 provider 不能踢掉内置记忆,hook 的异常不会打断主循环。扩展能力越大,约束也越明确。