会话被压缩、被删除时,插件终于能收到通知了


你写了一个记忆插件,专门把项目的编码约定、几个关键 API 地址塞进每一轮对话的上下文。前二十分钟它工作得很好。然后 Hermes 发现对话太长了,自动做了一次压缩,把所有早期消息揉成一段摘要——你塞进去的约定恰好在那里面。从那一刻起,模型开始反复问你同样的问题,而你的插件完全不知道刚刚发生了什么。

另一个更没人在意的角落:你给每个会话在磁盘上存了一份索引文件。某天同事在桌面端一口气删掉了四十个会话,索引文件却一个都没少,只是再也没人认领它们。

这两件事的根源相同:插件能看见“工具被调用”“一轮对话开始”,却看不见“会话内容被改写了”“会话不存在了”。Hermes 在 10 月 18 日合并的两个补丁补上了这两个缺口——一个全新的 post_compaction 钩子,一个覆盖所有删除入口的 on_session_delete 钩子。

以前的插件为什么看不见

插件系统一直有一批生命周期钩子:on_session_start、on_session_end、on_session_finalize、on_session_reset。听起来很全,但它们是“会话开始/结束”的时间点,不是“会话内容被改写”的事件。

压缩是个内部动作,可能在一轮对话中途发生(上下文快满了、刚跑完一个大工具、系统主动清理过期的工具结果),也可能由用户手动敲 /compress 触发。对插件来说,这些都是隐形的。同理,会话删除发生在很多个入口:CLI 的 hermes sessions delete、桌面端的单条/批量删除、/exit --delete、空会话清扫、保留期清理、幽灵行清理……每个入口最后都落到同一个数据库方法上,但插件拿不到任何回调。

所以插件作者只能用笨办法补:定时轮询会话库、比较 token 数量猜有没有压缩、扫描目录找孤儿文件。这些办法要么慢,要么错。

post_compaction:压缩之后,把被摘要掉的东西补回来

先看新钩子长什么样。注册方式和其它钩子完全一样:

def register(ctx):
    ctx.register_hook("post_compaction", on_compaction)


def on_compaction(session_id, kind, reason, **kwargs):
    # 这里记一笔,下一轮 pre_llm_call 时把关键上下文重新注入
    mark_session_for_reinjection(session_id, kwargs.get("messages_removed"))

回调能拿到的字段如下:

字段 含义
session_id 压缩之后持有这份对话的会话 id
old_session_id 如果压缩轮转到了新会话,这里是原来的 id;原地压缩时为空字符串
kind summarize(完整压缩)、micro_summarize、prune_tool_results、provider_native(Codex 侧压缩)
reason 触发原因:manual(/compress)、pre_api、post_tool、overflow、gateway_hygiene、between_turns、proactive_prune、provider、unknown
before_tokens / after_tokens 压缩前后的大致 token 估算,未知时为 None
messages_removed 少了多少条消息,未知时为 None

关键点是“每一条真正落地的压缩路径都会触发一次”:CLI、TUI、桌面端 agent、网关 agent、cron 里跑的会话,以及网关的 /compress、Codex 自己做的压缩、微压缩、主动裁剪工具结果,全都算。而没有真正改变内容的尝试(摘要被中止、被冷却时间挡住、因为会变长而被拒绝、空的 /compress)不会触发——所以你不必自己判断“这次压缩是否是空转”。

一个真实可用的模式是这样:插件在自己的状态里记下“这个会话需要重新注入”,然后在下一轮 pre_llm_call 里把记忆重新塞回上下文。因为钩子本身是观察者,返回值会被忽略,你没法在钩子里直接改对话——这是有意的设计,压缩已经提交了,中途改会破坏一致性。

_pending = {}

def on_compaction(session_id, kind, reason, messages_removed=None, **kw):
    if kind in ("summarize", "micro_summarize"):
        _pending[session_id] = {"kind": kind, "removed": messages_removed}

def inject(session_id, conversation_history, **kw):
    if session_id in _pending:
        _pending.pop(session_id)
        return "项目约定:所有新接口必须带 OpenAPI 注释;数据库迁移只走 alembic。"
    return None

def register(ctx):
    ctx.register_hook("post_compaction", on_compaction)
    ctx.register_hook("pre_llm_call", inject)

on_session_delete:会话没了,插件自己的账也要平

第二个钩子解决清理问题。它在会话真正被删除或用户主动归档之后触发一次,参数是:

参数 含义
session_id 本次操作针对的会话
reason delete、archive、empty_sweep、prune、ghost_prune、profile_repair
profile 会话库所属的 profile,根目录为 default
deleted_ids 一次删掉多行时给出完整列表(含级联删除的子会话、压缩链上的行)
sessions_dir 调用方传了的话,给出该 profile 的 sessions/ 目录
def on_delete(session_id, reason, profile=None, deleted_ids=None, sessions_dir=None, **kw):
    ids = deleted_ids or [session_id]
    for sid in ids:
        drop_sidecar(sid)          # 删掉自己维护的 per-session 缓存/索引
    log.info("cleaned %d session rows (%s)", len(ids), reason)

def register(ctx):
    ctx.register_hook("on_session_delete", on_delete)

这次改动有两个容易忽略但又很重要的细节。

第一,桌面端以前根本不触发。桌面端和 dashboard 的单条删除与归档不走 SessionDB.delete_session,而是走一条“运行时会话变更”的收据路径,所以早先的钩子在这些入口上是哑的。现在这条路径也会触发,并且重复的幂等重试不会重复发事件。

第二,归档也算。“归档”在 Hermes 里是用户把会话藏起来,transcript 还留着,以后可以取消归档。对插件来说这通常意味着“这条线结束了”——比如你在跟踪某个任务,用户归档了对应聊天,你该把任务标记为关闭。但只有真正的“未归档 → 归档”翻转才触发,空闲自动归档扫描和 profile 收养不会。

顺手修好的第三件事:插件斜杠命令知道自己在哪个会话里

同一个窗口里还有一个相关改动值得提一句。插件注册的斜杠命令(ctx.register_command)以前只能拿到一串原始参数字符串,不知道是谁、在哪个平台、哪个会话里敲的。现在只要在处理函数的签名里声明这些关键字参数,Hermes 就会把它们传进来:

def _handle_whoami(raw_args, *, session_id="", platform="", source=None):
    user = source.user_id if source is not None else "local user"
    return f"{user} on {platform}, session {session_id or '(none yet)'}"

def register(ctx):
    ctx.register_command("whoami", handler=_handle_whoami, description="Show the calling session")

可用的关键字是 session_id、session_key、platform、source(传输层的身份对象,含 user_id、chat_id、thread_id 等),或者干脆声明 **kwargs 一次全要。老插件一行都不用改:只声明一个参数的函数仍然按 handler(raw_args) 调用,事件循环里的处理函数则照旧被 await。

这里有个安全提醒:source 是“身份描述”,不是“授权凭证”。真正敏感的插件命令要基于稳定的 user_id / chat_id 判断,遇到 source 为空时要默认拒绝,而不是放行。

三个边界条件,写的时候别踩

  • 都是观察者。返回值会被忽略,抛异常只记日志、不影响主流程。删除和压缩已经提交,插件不能否决它们,也不该指望自己能让操作“回滚”。
  • 回调有时间上限,由 plugins.hook_callback_timeout 控制(默认 30 秒)。在钩子里做网络请求要自己加超时,卡住的是你的回调,不是 Hermes 的对话。
  • 投递是进程内的,没有 exactly-once 保证。删除事件只在执行删除的那个进程里发出;如果你在回调里做了外部副作用(比如调第三方 API 记账),要能容忍偶尔漏一次或重复一次。

什么时候能用上

这两个钩子都在 10 月 18 日合并进主分支,晚于最新的 v0.22.2 发布(10 月 15 日),所以现在还在 main 上,没有进入任何正式版本。如果你是从 PyPI 或更新渠道装的 Hermes,暂时还用不到;想提前验证可以从源码跑主分支。

顺带一提,如果你只是想看完整的钩子清单和每个钩子的触发时机,官方文档的 Event Hooks 页面是权威来源;本站的 命令参考里的 plugins 页 可以对照看插件 CLI 的用法。想把插件能力再往上叠一层,之前写过的 插件 token 代理 和 压缩提示词重建 两篇可以接着读——前者讲插件如何在不持有密钥的情况下替用户登录第三方 API,后者讲压缩时提示词是怎么重新拼的。如果你在写记忆型插件,做梦式记忆整理那篇 讲的是另一条路径:不靠压缩事件,而是在空闲时主动整理。

小结

post_compaction 回答的是“我注入的上下文被摘要掉了怎么办”,on_session_delete 回答的是“会话消失后我的垃圾谁清”。两个钩子都不改变 Hermes 的行为,只是把原本藏在内部的两个时刻暴露给插件。对插件作者来说,这意味着一整类轮询、猜测和兜底逻辑可以删掉了。