跳到主要内容

数据截至 (上游 commit 4ac938ddecce)

到处都能找到它 —— 网关、会话存储与定时任务

30 秒导读: 前四章讲的是"一轮对话怎么跑完"。这一章讲的是这一轮对话是从哪儿来的—— 可能是你在终端敲的,可能是 Telegram 群里有人 @ 了它,也可能是凌晨三点没有人在, 是一个定时器把它叫醒的。Hermes 的做法是:入口层做得很厚,agent 层一点都不知道。


1. 这是什么(零基础也能懂)

一句话定义: 这是 Hermes 的入口层——把"终端命令行""二十多个聊天平台的机器人""定时任务" 这三种完全不同的触发方式,统一收敛成同一件事:构造一个 AIAgent,喂给它一段消息,把结果送回去。

它要解决的问题。 假设你已经写好了一个能干活的 agent(前四章的内容)。现在你想要:

  • 在自己电脑的终端里用它;
  • 在手机上用 Telegram/Signal/微信 跟它说话;
  • 团队在 Slack 频道里 @ 它;
  • 每天早上八点它自动生成一份简报发到你的 iMessage。

朴素做法是给每个场景写一个程序。Hermes 的做法是只有一份 agent 代码,四类入口各自把 "消息从哪来、回哪去、这段对话算谁的"这些脏活干完,再调同一个类。

入口一览:

入口命令 / 触发方式代码位置
终端 CLIhermes / hermes chathermes_cli/main.pycli.py
消息网关(20+ 平台)hermes gateway startgateway/run.py
定时任务无人触发,60 秒轮询cron/scheduler.py
TUI / 编辑器hermes tuihermes acptui_gateway/acp_adapter/

四条路最后都汇到同一个类 AIAgent(run_agent.py:412)。可以自己 grep 验证: gateway/run.py:22506cron/scheduler.py:6010acp_adapter/session.py:687tui_gateway/server.py:7177hermes_cli/oneshot.py:476 —— 五个调用点,同一个类。

一句话直觉: 把 agent 当成一个只会读写字符串的函数;这一章讲的全部东西, 都是这个函数外面那一圈插座——插座形状各异,函数本身一动不动。


2. 顶层全景(它大概怎么转)

先看这张图。怎么读:从上往下是"消息进来"的方向,从下往上是"回复出去"的方向; 中间那条粗线是所有入口的汇合点。

终端 Telegram/Discord/Slack/… TUI / 编辑器 闹钟
│ │ │ │ │ │
│ ┌──┴──────┴──────┴──┐ │ ┌─────┴─────┐
│ │ 平台适配器 │ │ │ 60s tick │
│ │ BasePlatformAdapter│ │ │ 文件锁去重 │
│ └────────┬──────────┘ │ └─────┬─────┘
│ │ MessageEvent │ │
│ ┌────────┴──────────┐ │ │
│ │ GatewayRunner │ │ │
│ │ 鉴权 / 命令 / 打断 │ │ │
│ └────────┬──────────┘ │ │
│ │ │ │
│ ┌────────┴──────────┐ │ │
│ │ 会话身份:会话键 │ │ │
│ │ 谁的对话?哪一段? │ │ │
│ └────────┬──────────┘ │ │
│ │ │ │
═══╧═════════════════╧═══════════════════════╧══════════════════╧═══
同 一 个 AIAgent 实 例
══════════════════════════════╤════════════════════════════════════

┌──────────────┴──────────────┐
│ state.db + 影子 git 仓库 │
│ 转录/token 账 文件快照 │
└─────────────────────────────┘

部件职责:

部件干什么在哪个文件
GatewayRunner网关主进程:启动适配器、路由消息、优雅退出gateway/run.py:6726
BasePlatformAdapter每个平台的收发抽象(四个抽象方法)gateway/platforms/base.py:2890
PlatformRegistry适配器自注册表,取代 if/elifgateway/platform_registry.py:232
build_session_key把消息来源算成一个会话身份字符串gateway/session.py:1090
SessionStore会话键 → 会话 ID 的路由索引 + 过期策略gateway/session.py:1245
SessionDBSQLite 落地:会话、消息转录、全文检索(检索/建表逻辑拆到 hermes_state_search.pyhermes_state_common.py 等 mixin)hermes_state.py:3258
CheckpointManager改文件前的自动快照(模型看不见)tools/checkpoint_manager.py:755
tick()每分钟检查一次有没有到期的定时任务cron/scheduler.py:7199

主线走一遍(高层): 平台适配器收到一条原始消息 → 包装成 MessageEventGatewayRunner._handle_message(gateway/run.py:16462)按七步走:鉴权 → 斜杠命令 → 打断正在跑的 agent → 取/建会话 → 拼上下文 → 跑 agent → 回消息。 七步的顺序写在那个 docstring 里,读源码时对着看很省事。


3. 消息网关 —— 一个进程,二十几个平台

3.1 它要解决的小问题

每个聊天平台的 SDK 都长得不一样:Telegram 是长轮询、Discord 是 WebSocket、 企业微信是回调 HTTP、Signal 要挂一个本地 HTTP 桥。Hermes 要在一个进程里同时连上这些, 还要在其中一个挂掉时不影响其他的。

3.2 适配器:四个抽象方法,其余全给默认

平台差异被压到一个抽象基类里。打了 @abstractmethod 的只有四个:

抽象方法干什么行号
connect(is_reconnect=False)连上平台、起监听,返回是否成功gateway/platforms/base.py:3959
disconnect()停监听、关连接、取消任务gateway/platforms/base.py:3979
send(chat_id, text, …)发一条文本,返回 SendResultgateway/platforms/base.py:3984
get_chat_info(chat_id)返回至少含 name / type 的聊天信息gateway/platforms/base.py:7210

剩下的方法(send_image / send_voice / send_clarify / send_exec_approval / 打字气泡 / 草稿流式)在基类里都有默认实现或降级路径,平台能力强就重写,不能就自动退回纯文本。 这就是为什么一个 IRC 适配器只用标准库、971 行就能跑完整功能 (plugins/platforms/irc/adapter.py)。

一个体现"降级优先"的细节:send_clarify 在支持按钮的平台渲染成可点选项, 不支持的平台就退化成一段普通文字问句——ADDING_A_PLATFORM.md 里写明"They all degrade gracefully to plain text when not overridden"。

3.3 注册表:用自注册干掉 if/elif

早期版本用一条 if/elif 链把平台名映射到适配器类。现在改成注册表:

# 示意,非源码 —— 插件侧只做这一件事
ctx.register_platform(
name="irc",
label="IRC",
adapter_factory=lambda cfg: IRCAdapter(cfg), # 怎么造
check_fn=check_requirements, # 依赖齐了吗
cron_deliver_env_var="IRC_HOME_CHANNEL", # 定时任务能投这儿
standalone_sender_fn=_standalone_send, # 网关不在时也能发
max_message_length=450, # 分片长度
platform_hint="You are chatting via IRC. …", # 塞进系统提示的一句话
)

真实的是 PlatformEntry(gateway/platform_registry.py:63)——一个字段很宽的 dataclass (数 class PlatformEntry 体里带类型注解的字段,是 22 个)。 关键在于它不只是"怎么造适配器",而是把一个平台在整个系统里的所有接入点都收进一条记录: 鉴权环境变量名(allowed_users_env)、是否可以脱敏(pii_safe)、YAML 配置翻译钩子 (apply_yaml_config_fn)、定时投递目标(cron_deliver_env_var)、脱离网关进程的独立发送 (standalone_sender_fn)。插件作者填表,核心代码不动一行。

PlatformRegistry.create_adapter(gateway/platform_registry.py:618)里的顺序是: check_fn() 不过 → 返回 None 并打提示;validate_config() 不过 → 返回 None;工厂抛异常 → 记 error 返回 None。任何一步失败都只是这个平台不上线,不影响别的平台。

注册表优先于内置。 GatewayRunner._create_adapter(gateway/run.py:15785)先查注册表, 查到就用;查不到才落到内置的 if/elif(gateway/run.py:15832 起)。而且注册表里若"登记了但造不出来", 它会明确 return None 而不是往下穿——因为插件平台在内置链里本来就没有对应分支。

内置 9 个,插件 20 个。 内置的是 whatsapp_cloud / signal / weixin / api_server / webhook / msgraph_webhook / bluebubbles / qqbot / yuanbao(gateway/run.py:15832-15914 的分支链)。 Telegram、Discord、Slack 这些"旗舰"平台反而住在 plugins/platforms/ 里, 和社区插件同一套机制——grep -rl "register_platform" plugins/platforms/*/*.py 数出来正好 20 个目录。

3.4 延迟加载:为什么 hermes chat 不会变慢

二十个平台插件在模块顶层各自 import 自己的重型 SDK(lark_oapidiscord.pyslack_bolt…)。 如果启动时全都加载,连一句 hermes chat 都要多等几秒——而 chat 根本不碰网关。

解法是两级注册:发现阶段只登记一个零参数的加载器 (PlatformRegistry.register_deferred,gateway/platform_registry.py:296), 真正 import 推迟到有人第一次查这个平台名时(_resolve,:393)。 is_registered() 特意把"尚未 import 的延迟项"也算作已注册(:601), 这样"这个平台存不存在"这种廉价判断不会触发重型 import。

3.5 生命周期:启动与优雅退出

启动侧(GatewayRunner.start,gateway/run.py:12390)是一个平台一个平台串起来的循环 (:6175):造适配器 → 挂五个回调(消息处理器、致命错误处理器、会话存储、忙时处理器、 话题恢复函数,:6196-6200)→ 带超时地连接(_connect_adapter_with_timeout,:3181)。 连接失败时有个容易被忽略的细节:它会主动调一次 disconnect()(:6234), 因为失败的 connect() 可能已经开了 aiohttp session 或起了轮询任务,不收就会泄漏。

退出侧(stop,gateway/run.py:14511)比启动复杂得多,顺序是刻意排的:

收到停止信号


① 先给还在跑 agent 的聊天发一句"我要重启了" ← 适配器此时还连着


② 给每个在跑的会话打上 resume_pending durable 标记 ← 在等待之前打


③ 等待 drain_timeout,让在跑的 turn 自己跑完

┌────┴────┐
│ │
跑完了 超时了
│ │
│ ▼ 强行打断 + 杀工具子进程 + 拆终端环境 + 关浏览器
│ │
▼ ▼
清掉标记 保留标记(下次启动据此恢复)


④ 断开适配器 → 落 gateway_state → 视情况写 .clean_shutdown 标记

第 ② 步的注释把理由说得很清楚(gateway/run.py:14679-14682):标记必须写在等待之前, 因为如果 systemd 在 drain 期间直接把进程杀了,写在后面就永远写不上了。 只有 drain 干净地跑完,才会写 .clean_shutdown 标记(:7448);超时的路径明确跳过(:7453)。

还有一个运维向的检查很有意思:启动时会读 systemd unit 的 TimeoutStopSec, 如果它比配置的 drain 超时还短,就打一条 WARNING——因为这种配置下 systemd 会在排空中途 发 SIGKILL,在日志里看起来像"幽灵杀进程"(gateway/run.py:12453-12466, check_systemd_timing_alignment)。

3.6 出站:发出去之前还要过几道

回复不是算完就直接发。出站侧有几个独立的小模块:

  • 静默过滤 —— 模型可以整条回复只输出 NO_REPLY / [SILENT] 表示"这轮我不说话"。 is_intentional_silence_response(gateway/response_filters.py:56)只在整条回复恰好是 标记词时才算静默:超过 64 字符不算、正文里顺口提到 NO_REPLY 不算、空回复也不算 (空回复走失败路径,不是静默)。
  • 运行时页脚 —— build_footer_line(gateway/runtime_footer.py:151)在回复末尾附一行 模型 · 上下文占比 · 工作目录。任何一项数据缺失就静默跳过那一项, 宁可少一格也不显示 ?%
  • 投递路由 —— DeliveryTarget.parse(gateway/delivery.py:231)把 "origin" / "local" / "telegram" / "telegram:123456:789" 这几种写法解析成结构化目标。 注意它对平台名 .lower()、对 chat_id 保留原始大小写(:149),因为很多平台的 ID 大小写敏感。
  • 流式回传 —— GatewayStreamConsumer(gateway/stream_consumer.py:164)把 agent 在工作线程里 同步吐出的 delta,经 queue.Queue 转到异步任务,再按节流间隔反复编辑同一条消息。 选"编辑"而非"追加"是因为 Telegram / Discord / Slack 都支持编辑,是最大公约数。
  • 事件分发 —— GatewayEventDispatcher(gateway/stream_dispatch.py:40)是更新的一层: agent 发结构化事件,由适配器决定怎么渲染;适配器的 format_tool_event 返回 None 就等于"这个平台吃掉这个事件"。它的 dispatch()(:88)整个包在 try 里—— "presentation must never break the agent loop"。

3.7 中继:让别人替你连平台

gateway/relay/ 是一个实验性方向:适配器本身不知道自己在服务哪个平台。 RelayAdapter(gateway/relay/adapter.py:65)在握手时从连接器收到一份 CapabilityDescriptor (gateway/relay/descriptor.py:42), 从里面读出"我该报多长的消息长度上限""长度按字符数还是 UTF-16 码元算" (_LEN_FNS,gateway/relay/adapter.py:59),然后把收发全部委托给注入的 transport。 按模块头的说法,网关侧没有任何按平台分支的代码——"这个 chat_id 是个 Discord 频道" 只有连接器一侧知道。

这带来一个信任问题,下一节接着说。

3.8 钩子

HookRegistry(gateway/hooks.py:54)扫 ~/.hermes/hooks/ 下的目录, 每个目录要有 HOOK.yaml + handler.py,在 gateway:startupsession:startagent:start / agent:step / agent:endcommand:* 这些点上触发。 gateway/builtin_hooks/ 目前是空的——_register_builtin_hooks(:72)明说保留为扩展点。 钩子里的异常一律吞掉,永不阻断主流程。


4. 会话身份 —— 同一个 bot,凭什么记得住谁是谁

4.1 它要解决的小问题

一个 bot 同时在:你的私聊、一个 50 人的群、群里的三个话题串、另一个平台的另一个群。 哪些消息该共享一段记忆,哪些必须彻底隔开? 答错的后果不是体验差,是串味—— A 的私聊内容出现在 B 的回复里。

4.2 会话键:一个字符串定生死

build_session_key(gateway/session.py:1090)是"唯一真源"(源码原话:single source of truth)。 它把来源拼成一个冒号分段的字符串:

agent:main:telegram:dm:12345
└──┬──┘ └───┬───┘ └┬┘ └──┬──┘
命名空间 平台 类型 聊天/用户 ID

规则分两支,记住这四行就够了:

场景是否按用户隔离理由
私聊(dm)天然隔离,键里带 chat_id一个私聊就是一段对话
群 / 频道(非话题)默认隔离(group_sessions_per_user=True)群里各说各的,互不打扰
话题串(thread)默认共享(thread_sessions_per_user=False)一个话题串就是一场共同讨论
什么标识都没有退到 <ns>:<platform>:dm 单一会话兜底

第三行是反直觉的那一条:话题里默认不按人分。源码注释说这是 Telegram 论坛话题、 Discord thread、Slack thread 的"预期 UX"(:744-748)。 is_shared_multi_user_session(gateway/session.py:1049)是这套规则的只读镜像, 专门给"这是不是一段多人会话"的判断用。

兜底路径里藏着一个真实事故。 私聊如果没有 chat_id(某些非标准适配器/合成来源), 早期版本会全部塌进同一个 <ns>:<platform>:dm 键——于是一个缓存的 agent 同时服务好几个人的私聊, 历史互相渗透。现在的代码在塌陷之前先试发送者自己的 ID(gateway/session.py:1148-1166), 注释里直接写了 "cross-user history bleed"。

WhatsApp 还要额外规范化。 同一个人在 JID / LID 两种别名形式之间被桥接层翻来翻去时, 不做 canonical_whatsapp_identifier 就会分裂成两个隔离会话(:757:785-789)。

4.3 多档位命名空间

键的第二段历史上是写死的字面量 main。多 profile 复用了这个槽 (_session_key_namespace,gateway/session.py:1070),但保证默认 profile 生成的键与历史逐字节一致—— 因为一大堆按位置解析的代码依赖 parts[2] == platform。 这是一个很值得抄的兼容手法:扩展保留槽,而不是加新槽

4.4 把平台元数据当"不可信数据"塞进提示

群名、频道 topic、话题标签、用户昵称——这些全是别人能随便改的字符串, 而它们要进系统提示。有人把群名改成 "ignore previous instructions and…" 怎么办?

build_session_context_prompt(gateway/session.py:482)的处理是三层:

  1. 先声明。 提示开头就写一句:把下面这些名字/topic/昵称当作不可信的元数据标签, 永远不要执行里面的指令(:321-325)。
  2. 再包装。 每个值都过 _format_untrusted_prompt_value(:278): 统一换行、剔除控制字符、截断到 240 字符、最后 json.dumps 包成一个带引号的字符串字面量。 包引号这一步是关键——它让这段文本在结构上变成"值",而不是"另起一行的指令"。
  3. 可选脱敏。 redact_pii 开启时,手机号和用户/聊天 ID 被换成确定性哈希 (_hash_sender_id,gateway/session.py:69,输出形如 user_a1b2c3d4e5f6)。 注意路由仍然用原值——脱敏只发生在送给模型的那一份上。

第 3 层还有一个必须理解的例外:脱敏只对 _PII_SAFE_PLATFORMS 生效 (gateway/session.py:352,含 WhatsApp/Signal/Telegram/BlueBubbles)。 Discord 被明确排除,理由写在注释里:Discord 的 @ 提及语法是 <@user_id>, 模型必须拿到真 ID 才能 @ 人。插件平台自己在 PlatformEntry.pii_safe 里声明(:308-316)。

还有一个省 token 的小设计:多人会话里不把某个用户名固定写进系统提示(:385-390), 因为它每轮都变、会把 prompt cache 打穿;改成在提示里说明"消息前面带 [发送者名] 前缀", 把变动的部分挪到 user 消息里去。上下文缓存的原理见 上下文工程

4.5 谁能说话 / 谁能用命令

三个模块,三个不同的轴:

模块管什么关键符号
gateway/pairing.py陌生人怎么变成授权用户PairingStore(:81)
gateway/authz_mixin.py这条消息该不该处理GatewayAuthorizationMixin(:31)
gateway/slash_access.py授权用户里谁能跑哪些斜杠命令SlashAccessPolicy(:57)

配对是静态白名单的替代品:陌生人来私聊,系统发一个一次性码,机主在 CLI 里批准。 安全参数按 OWASP / NIST SP 800-63-4 定:8 位码取自去掉易混字符(0/O/1/I)的 32 字母表、 secrets.choice() 取随机、1 小时过期、每平台最多 3 个待批、每人 10 分钟一次、 失败 5 次锁 1 小时、文件 0600、码永不打进日志(gateway/pairing.py:8-19)。

鉴权里最微妙的是两个看起来很像的布尔量,而它们的信任级别完全不同:

  • _adapter_enforces_own_access_policy(gateway/authz_mixin.py:218)——适配器有本地策略。 但注释警告:这个标志本身不等于"已授权",因为这些适配器默认是 open(转发所有人); 只有当它对该 chat 类型的有效策略确实是 allowlist 时,网关才信它。
  • _adapter_authorization_is_upstream(gateway/authz_mixin.py:194)——上游已决策,直接采信。 只有中继适配器置为 True。

对应到数据结构上,SessionSource.delivered_via_upstream_relay (gateway/session.py:220)是一个线上不可见的信号:它被刻意排除在 to_dict/from_dict 之外, 所以对端伪造不了,持久化也恢复不出来。而且注释特别强调: platform 字段带的是底层平台(如 discord)而不是 relay, 所以鉴权必须认这个标志、不能认 platform。这是一条读起来很不起眼、写错就会开后门的规则。

斜杠命令的默认是"完全兼容旧行为":没配 allow_admin_from 就等于不开门禁。 开了之后,非管理员也永远保留 help / whoami(_ALWAYS_ALLOWED_FOR_USERS, gateway/slash_access.py:50)——否则用户连"我能干什么"都问不出来。

信任边界的完整讨论见 信任边界

4.6 会话的生老病死

SessionStore(gateway/session.py:1245)维护"会话键 → 会话 ID"的路由索引。它有几件事值得看:

  • 过期即重置。 _should_reset(:1126)按平台/会话类型取策略,支持 idle(闲置 N 分钟)、 daily(每天某点)、bothnone。但有后台进程在跑的会话永不过期(:1135-1142)—— 用户挂了个长任务,不能因为他两小时没说话就把上下文丢了。
  • 陈旧条目自愈。 网关硬崩(exit 1)时不走优雅退出,sessions.json 里会留下指向"已结束会话"的键。 这些键在下次启动时是活的路由键,而 get_or_create_session 只看时间策略、 从不查 end_reason——结果是每条新消息都被静静路由进一个已关闭的会话。 _prune_stale_sessions_locked(:890)在启动时做一次对账把它们删掉。
  • 索引丢了还能从 DB 捞回来。 _recover_session_from_db(:1021)按 平台/用户/chat/thread 反查 state.db 里最近的网关会话行,再 reopen_session 复活它。
  • sessions.json 会自我解释。 _save(:930)在文件顶部写一个 _README 哨兵键, 内容是"这只是网关路由索引,不是会话列表;所有会话在 ~/.hermes/state.db"。 加载时以 _ 开头的键被跳过(:856)。这是一个很便宜的反用户误解设计。

5. 持久化底座 —— 崩了之后还在

5.1 SessionDB:一个被很多进程同时写的 SQLite

SessionDB(hermes_state.py:3258)是会话、消息转录、token 账、FTS5 全文检索的落地层。 它的设计前提是多进程并发:网关 + 若干 CLI 会话 + worktree agent 共用一个 state.db

写竞争不用 SQLite 的重试,用自己的。 SQLite 内建的 busy handler 用的是确定性睡眠表, 高并发下会形成"车队效应"(convoy)——大家同时醒、同时抢、同时失败。 Hermes 把 SQLite 超时压到 1 秒,在应用层用带随机抖动的重试让竞争者自然错开。 预算是按时间而不是按次数算的(#74478):例行写等 _WRITE_PATIENCE_S = 20s, 转录写(append_message / 会话行,失败会毁掉用户一轮)给到 _TRANSCRIPT_WRITE_PATIENCE_S = 60s,活动心跳只给 0.5s; 抖动前 2 秒保持 20–150ms 快速重抢,之后退避到 250ms–1s (hermes_state.py:3295-3313)。每 50 次成功写做一次 PASSIVE checkpoint(:3152)。

跨 profile 读用只读连接。 read_only=True 时走 file:…?mode=ro URI 并完全跳过 schema 初始化 (hermes_state.py:3487-3537):不加写锁,所以侧边栏每次刷新去 poll 另一个 profile 的活 DB, 不会和那个 profile 的后端抢锁。

5.2 WAL,和 macOS 上那个必须打的补丁

apply_wal_with_fallback(hermes_state.py:1065)做两件事。

第一件是降级。 NFS / SMB / 某些 FUSE 上设 WAL 会抛 locking protocol, 这时退回 pre-WAL 的 DELETE 模式。三个细节:只对已知的不兼容错误降级,别的 OperationalError 照抛(:321-323); 如果磁盘上的 DB 头已经是 WAL(说明别的进程设过了),宁可抛错也不降级(:324-327); 警告按 db_label 每进程只打一次(_log_wal_fallback_once,:333)—— 否则 kanban 那种每次操作开一条新连接的模块会把日志刷爆。

第二件是 macOS 的写屏障。 这段注释是整个文件里最值得读的一处 (_apply_macos_checkpoint_barrier,hermes_state.py:930)。链条是这样的:

SQLite 的 WAL 崩溃安全性
↓ 依赖
操作系统的 fsync() 真的是写屏障
↓ 但是
macOS 的 fsync(2) man page 明说:不保证落盘、不保证不重排
↓ 于是
launchd 系统关机时页缓存被丢弃(等效掉电)
↓ 结果
一次"报告成功"的 checkpoint 可能根本没写到盘上 → state.db 损坏

修法是只在 Darwin 上开 PRAGMA checkpoint_fullfsync=1: 只在 checkpoint 边界(WAL 帧落进主库的那一刻)强制 F_FULLFSYNC。 注释给了成本对比——摊到每次提交约 +0.1 ms,而更宽的 fullfsync=1 是 +4 ms。 只在真正危险的那一个点上付代价,这是很典型的工程取舍。

5.3 三级自愈

数据库还是坏了怎么办。repair_state_db_schema(hermes_state.py:2375)按破坏性从小到大试三招:

① FTS 原地 rebuild INSERT INTO messages_fts(messages_fts) VALUES('rebuild')
│ 不行 → 从 messages 正表重建倒排索引,schema 完全不动

② sqlite_master 去重 writable_schema=ON,同 (type,name) 只留最小 rowid
│ 不行 → 治"table X already exists",FTS 索引保住

③ 删掉整个 FTS schema + VACUUM 下次开库时从 messages 重建
→ 最后手段,索引没了但转录一条不丢

三招之前先备份;sessions / messages 正表全程不动(:516-517)。

这里有个只有踩过才知道的坑,注释里写得很细(hermes_state.py:1513-1522): 这类损坏(比如 sqlite_master 里有两条 CREATE VIRTUAL TABLE messages_fts)会让 连接上的第一条语句就炸——因为 SQLite 在准备第一条语句时会解析整个 schema。 所以它连 PRAGMA journal_mode 都过不去,自然也永远走不到 _init_schema 那层的 FTS 修复。 这就是为什么修复要挂在 SessionDB.__init__apply_wal_with_fallback 抛出的那个位置 (hermes_state.py:3635-3657),而且用进程级一次性锁 _claim_repair_attempt(:1701)防止修复循环。

用户看到的症状是:桌面端显示"没有会话",而磁盘上躺着 200 多个 JSON 文件。

5.4 影子 git:模型完全看不见的文件快照

CheckpointManager(tools/checkpoint_manager.py:755)在 agent 改文件之前自动打快照(shadow git), 用户可以回滚。它不是一个工具——模块头第一句就是 "This is NOT a tool — the LLM never sees it" (tools/checkpoint_manager.py:10)。模型无法调用它、无法看见它、也就无法绕过它。

用法只有两个调用点:每轮开始 new_turn()(:648)清空去重集, 每次改文件前 ensure_checkpoint(dir)(:656)。同一目录同一轮只快照一次。 ensure_checkpoint 永不抛异常(:686-688),失败只记 debug——快照挂了不能拖垮 agent。

存储上有一次值得学的重构。v1 是每个工作目录一个完整影子仓库, 同一个项目的十几个 worktree 各存一份几乎相同的 blob,每个约 40 MB、一个用户合计烧掉约 500 MB (tools/checkpoint_manager.py:30-36)。v2 改成单一共享 store:

v2 布局是什么
store/objects/共享的 git 对象库,跨项目跨轮次天然去重
store/refs/hermes/<hash16>每个项目一个分支尖
store/indexes/<hash16>每个项目一个 git index
store/projects/<hash16>.json{workdir, created_at, last_touch}

GIT_DIR + GIT_WORK_TREE + GIT_INDEX_FILE 三个环境变量操作 (_git_env,:239),所以没有任何 git 状态泄漏进用户的项目目录—— 用户的 git status 是干净的。加一个新 worktree 的成本接近零。

安全上有个小护栏:/ 和用户家目录被直接拒绝快照(:675-677),太宽的目录不给做。


6. 定时与自动化 —— 没人说话的时候

6.1 触发:60 秒一跳,文件锁防重入

tick()(cron/scheduler.py:7199)是心跳。它做的事按顺序是:

  1. 抢文件锁(Unix 用 fcntl.flock,Windows 用 msvcrt.locking,都是非阻塞)。 抢不到直接 return 0——网关的内置 ticker、独立守护进程、手工 hermes cron tick 可能同时来,这把锁保证同一时刻只有一个 tick(:6754-6783)。
  2. 先推进,再执行。 拿到到期任务后在锁内先把所有 next_run_at 推到下一次(:6891-6901), 然后才开始跑。这是 at-most-once 语义的关键:先推进意味着即使执行崩了也不会重复触发。
  3. 分区并行。workdir 的任务会改 os.environ["TERMINAL_CWD"]——那是进程全局的, 所以它们必须串行;不带 workdir 的任务并行跑(:6958-6965)。 这个分区是个很实在的踩坑产物。
  4. 在飞去重。 _submit_with_guard(:6970)拿 _running_job_ids 挡住"上一跳还没跑完的同一个任务"。

一个部署上的推论:那个"内置 ticker"是网关进程起的线程(_start_cron_ticker, gateway/run.py:30153,默认 interval=60),所以要让定时任务无人值守地跑, 要么让网关活着,要么自己另起一个 tick 来源。

InProcessCronScheduler.start(cron/scheduler_provider.py:505)是外面那圈循环。 它 catch BaseException 而不是 Exception(:574-583),注释解释得很清楚: 某个 provider SDK 抛 SystemExit 不该悄悄杀死 ticker 线程; KeyboardInterrupt 也故意接住——关停由主线程置 stop_event 驱动,不该由这个守护线程里的异常决定。 每跳都记心跳,只有干净跑完才记成功标记(:553:597), 这样 hermes cron status 能分辨"活着但每跳都失败"和"真的在干活"。

6.2 触发器可换:Axis B

CronScheduler(cron/scheduler_provider.py:67)把"什么时候触发"抽成 provider, 而"触发意味着什么"(执行 + 投递)留在 run_job / _deliver_result 里所有 provider 共享。 模块头写了硬约束:provider 绝不允许重新实现 agent 构造或投递逻辑(:10-13)。

这个 ABC 有一条罕见但很聪明的自我约束:后加的三个钩子 (on_jobs_changed / fire_due / reconcile,:78:85:107) 故意做成非抽象、带默认实现,并且有一个测试 test_abc_growth_stays_additive 盯着, 保证接口只能加可选方法、不能改 start() 签名。 resolve_cron_scheduler(:114)在 provider 缺失/加载失败/is_available() 为假时 一律回落到内置——"cron must never be left without a trigger"。

fire_due 的默认实现(:85)是给外部调度器用的:先 claim_job_for_fire 做一次 store 级的 compare-and-set(跨机器 at-most-once),抢到才跑。

6.3 任务本身

概念说明位置
调度语法30m(30 分钟后一次)、every 2h(每 2 小时)、0 9 * * *(cron 表达式)、ISO 时间戳cron/jobs.py:694 parse_schedule
存储~/.hermes/cron/jobs.json,文件锁 + 原子写 + 0600cron/jobs.py:273 _jobs_lock
到期判定含时区偏移变化与"挂起的一次性任务"的恢复逻辑cron/jobs.py:3079 get_due_jobs
投递目标origin / local / <platform> / <platform>:<chat>cron/scheduler.py:2645 _resolve_delivery_targets
模型侧接口一个 cronjob 工具,action 分派 create/list/update/remove/runtools/cronjob_tools.py:1226
人侧接口hermes cron … 子命令hermes_cli/cron.py

定时任务跑起来时构造的是同一个 AIAgent(cron/scheduler.py:6010), 只是 quiet_mode=True、工具集按 _resolve_cron_enabled_toolsets 收窄。 它同样能"沉默"——_is_cron_silence_response(cron/scheduler.py:568)让一个监控型任务 在"没什么可报的"时候什么都不发。

定时任务是提示注入的高危面:任务提示里可能拼进邮件正文、网页内容。 所以有一整套扫描:_scan_assembled_cron_prompt(cron/scheduler.py:4476)、 _check_invisible_unicode / _strip_invisible_unicode(tools/cronjob_tools.py:220:200), 以及一个专门的异常类 CronPromptInjectionBlocked(cron/scheduler.py:345)。 cron/lifecycle_guard.py:56_GATEWAY_LIFECYCLE_PATTERN 还专挡"让定时任务去重启网关"这类自指命令 (hermes_cli/cron.py:24-26 为 terminal_tool 转引同一检查)。

6.4 建议:自动化必须由人点头

cron/suggestions.py 是"提议"这一层。要点只有一句:建议永不自动建任务,接受永远是显式的 (模块头:"Suggestions never auto-create jobs; acceptance is always explicit (consent-first)")。

四个来源合并成一个界面(/suggestions):

来源从哪来
catalog内置的启动套餐(每日简报、重要邮件监控…),见 cron/suggestion_catalog.py:33
blueprint装了带 blueprint: 块的技能,装的时候登记一条建议而不是直接排期
usage后台复盘发现某个请求反复出现
integration用户刚连上 Gmail / GitHub,推荐该场景的显然自动化

接受时直接调 cron.jobs.create_job——没有第二套任务引擎。 拒绝按稳定的 dedup_key 落闩,同一条建议不会再被推第二次。 后台复盘与技能策展的其余部分见 自我进化闭环


7. 其他入口

入口说明位置
hermes 主命令交互 chat、gatewaycronsessionstoolssetup 等子命令pyproject.toml:373hermes_cli/main.py
交互 REPLRich 渲染的终端会话cli.py
hermes-agent直接跑 agent 的薄入口pyproject.toml:374run_agent.py
TUITypeScript 前端(ui-tui/)+ Python 后端(tui_gateway/server.py),WS 通信tui_gateway/entry.py
ACP把 Hermes 挂进支持 ACP 的编辑器,JSON-RPC over stdiopyproject.toml:375acp_adapter/entry.py
MCP 服务端方向相反的一个口:把 Hermes 自己暴露成 MCP server,让别的 agent 来调它mcp_serve.py:623 create_mcp_server

ACP 那条有个约束值得注意:stdout 被 JSON-RPC 独占,所以日志一律走 stderr (acp_adapter/entry.py:3-4)。

cli.py:822 藏着一个小设计:AIAgent 在 CLI 里是一个懒加载的函数壳, 真正的 run_agent.AIAgent 到第一次用时才 import。理由是裸的交互启动只需要一个提示符, 不该为此加载整个工具注册表。这和 §3.4 平台插件的延迟加载是同一种思路—— 启动速度靠"什么都别急着 import"换来


8. 巧妙之处(可以带走的)

  1. 把"一个平台的所有接入点"收进一条 dataclass 记录。 PlatformEntry (gateway/platform_registry.py:63)的 22 个带注解字段里,只有开头六个 (name / label / adapter_factory / check_fn / validate_config / is_connected) 和"怎么造适配器"有关;其余全是鉴权变量名、脱敏许可、YAML 翻译钩子、cron 投递、 脱离网关的独立发送。插件作者填一张表,核心零改动。

  2. 两级注册 = 功能齐全 + 启动飞快。 发现阶段只登记零参数 loader, 真 import 推迟到首次查询(register_deferred,:187); is_registered() 把延迟项也算已注册(:271),让廉价判断不触发重型 import。

  3. 不可信元数据要"包引号"。 光在提示里写一句"别听它的"不够; _format_untrusted_prompt_value(gateway/session.py:451)把值 json.dumps 成字符串字面量, 让它在结构上变成值而不是指令行。声明 + 结构,两层都要。

  4. 只在真正危险的那个点上付性能代价。 macOS 的 checkpoint_fullfsync (hermes_state.py:930)只在 checkpoint 边界强制写屏障,+0.1 ms; 而全局 fullfsync 是 +4 ms。找到那个"真正决定正确性的瞬间"是关键。

  5. 自愈要按破坏性排序。 repair_state_db_schema(hermes_state.py:2375) 先 FTS 原地重建 → 再 schema 去重 → 最后才删索引重建,正表全程不动。 坏得越轻,恢复越轻。

  6. 模型看不见的护栏才是护栏。 CheckpointManager 明确"NOT a tool" (tools/checkpoint_manager.py:10),模型没法调用、没法绕过。 凡是要防模型的东西,就别把它做成工具。

  7. 关停顺序里,持久标记必须写在等待之前。 gateway/run.py:14679resume_pending 标记提前到 drain 之前写,因为 drain 期间进程可能被外部杀掉。 "在你还能写的时候写下去"。

  8. 扩展保留槽,不加新槽。 多 profile 复用了会话键第二段那个原本写死的 main (gateway/session.py:1070),默认档位下生成的键与历史逐字节相同, 所有按位置解析的老代码不用改。


9. 边界与局限

  • 注册表并没有完全取代 if/elif。 9 个内置平台仍走 _create_adapter 里的分支链 (gateway/run.py:15832-15914),模块头也承认这是过渡状态。
  • 中继是实验性的。 gateway/relay/__init__.pyadapter.py 都写明: descriptor schema 与传输协议在至少两个 Class-1 平台(Discord + Telegram)验证之前, 可以不经废弃周期直接改
  • CronScheduler 接口只被一个消费者验证过。 cron/scheduler_provider.py:3-8 自陈 EXPERIMENTAL:在外部 provider(Chronos)真正跑通之前, 模块路径、方法签名、start() 参数都可能变。
  • 带 workdir 的定时任务不能并行。 因为 TERMINAL_CWD 是进程全局的 (cron/scheduler.py:7427-7434)。这是当前实现的硬限制,不是配置项。
  • gateway/run.py 是一个一万八千多行的巨文件(实测 18844 行)。已经在往外拆 (authz_mixin.pykanban_watchers.pyslash_commands.py 都标注为 "god-file decomposition campaign" 的产物),但主体仍未拆完。
  • 群会话默认按人隔离,话题默认共享。 这两条默认值对不同团队的直觉差异很大, 配错了体验会很怪(group_sessions_per_user / thread_sessions_per_user)。

10. 代码地图(导航索引)

主题文件路径符号名
网关主进程gateway/run.pyGatewayRunnerstartstop_handle_message
适配器创建(注册表优先)gateway/run.py_create_adapter_connect_adapter_with_timeout
网关内的 cron 心跳gateway/run.py_start_cron_ticker
适配器抽象基类gateway/platforms/base.pyBasePlatformAdapterMessageEventSendResult
平台注册表gateway/platform_registry.pyPlatformEntryPlatformRegistryregister_deferredcreate_adapter
插件侧注册入口hermes_cli/plugins.pyregister_platform_register_deferred_platform
插件适配器范例plugins/platforms/irc/adapter.pyregisterIRCAdapter
扩展点文档gateway/platforms/ADDING_A_PLATFORM.md(必需/可选方法表、插件钩子清单)
出站投递路由gateway/delivery.pyDeliveryTarget.parseDeliveryRouter.deliver
静默过滤gateway/response_filters.pyis_intentional_silence_response
运行时页脚gateway/runtime_footer.pybuild_footer_lineformat_runtime_footer
流式回传gateway/stream_consumer.pyGatewayStreamConsumerrun
事件分发gateway/stream_dispatch.pyGatewayEventDispatcher.dispatch
中继连接器gateway/relay/adapter.pygateway/relay/descriptor.pyRelayAdapterCapabilityDescriptor
钩子gateway/hooks.pyHookRegistry.discover_and_loademit
会话来源与上下文gateway/session.pySessionSourceSessionContextbuild_session_context
不可信元数据入提示gateway/session.pybuild_session_context_prompt_format_untrusted_prompt_value
会话键构造gateway/session.pybuild_session_key_session_key_namespaceis_shared_multi_user_session
会话存储与过期gateway/session.pySessionStore_should_reset_prune_stale_sessions_lockedreset_session
配对授权gateway/pairing.pyPairingStore
消息鉴权gateway/authz_mixin.pyGatewayAuthorizationMixin_adapter_authorization_is_upstream
斜杠命令门禁gateway/slash_access.pySlashAccessPolicypolicy_from_extra
SQLite 落地hermes_state.pySessionDB_execute_writecreate_sessionappend_message
WAL 与写屏障hermes_state.pyapply_wal_with_fallback_apply_macos_checkpoint_barrier
损坏自愈hermes_state.pyrepair_state_db_schemais_malformed_db_error_claim_repair_attempt
文件快照tools/checkpoint_manager.pyCheckpointManagerensure_checkpointrestore_git_env
定时心跳cron/scheduler.pytickrun_one_jobrun_job_deliver_result
触发器 providercron/scheduler_provider.pyCronSchedulerInProcessCronSchedulerresolve_cron_scheduler
任务存储与调度语法cron/jobs.pyparse_schedulecreate_jobget_due_jobsclaim_job_for_fire
自动化建议cron/suggestions.pycron/suggestion_catalog.pyCatalogEntryseed_catalog_suggestions
模型侧定时工具tools/cronjob_tools.pycronjob_scan_cron_prompt
CLI 入口hermes_cli/main.pycli.pymainAIAgent(懒加载壳)
TUI 后端tui_gateway/entry.pytui_gateway/server.pydispatch
ACP 适配acp_adapter/entry.pyacp_adapter/session.pymain
MCP 服务端mcp_serve.pycreate_mcp_server

继续读: 这一章讲的是消息怎么进来、怎么落地;进来之后那一轮怎么跑完见 一轮对话的生命周期,系统提示怎么拼见 上下文工程,工具和执行环境见 工具层与执行环境, 本章反复提到的"不可信输入"的完整防线见 信任边界