Hermes 架构全解Hermes Architecture, in Full第 1 章 · 14 章Chapter 1 of 14
全文目录Contents
  1. 0 · 项目全景与代码地图
    1. 0.1 这个软件是什么
    2. 0.2 目录地图
    3. 0.3 从这张地图能读出的三件事
    4. 0.4 一条消息的完整旅程
    5. 0.5 全文章节索引
  2. 1 · 网关层
    1. 1.1 它解决什么问题
    2. 1.2 平台适配器抽象基类
    3. 1.3 消息事件的归一化
    4. 1.4 网关主循环里的那些防御机制
    5. 1.5 状态消息与进度反馈
    6. 1.6 中断与恢复
    7. 1.7 网关内置钩子
    8. 1.8 这一层的代价
  3. 2 · 身份与会话路由
    1. 2.1 Profile:一个进程承载多个身份
    2. 2.2 四级路由与「具体度」打分
    3. 2.3 显式路由被拒绝的情况
    4. 2.4 SOUL.md:人格文件
    5. 2.5 会话路由:跨平台的连续性
    6. 2.6 智能体实例的缓存
  4. 3 · 主循环 ★
    1. 3.1 循环入口:三重预算闸门
    2. 3.2 每轮的准备工作:build_turn_context
    3. 3.3 中途插话:/steer
    4. 3.4 墙上时钟预算的收尾提醒
    5. 3.5 中断检查
    6. 3.6 步骤回调:给网关的观测点
    7. 3.7 循环里的其他状态
    8. 3.8 循环退出原因
    9. 3.9 一个可选的旁路:Codex 应用服务运行时
  5. 4 · 工具系统
    1. 4.1 最重要的设计:实现与投放分离
    2. 4.2 工具集的组合与解析
    3. 4.3 工具的中心分发
    4. 4.4 参数强制矫正层
    5. 4.5 工具错误消息的净化
    6. 4.6 工具结果的观测与钩子
    7. 4.7 委派上下文识别
    8. 4.8 工具定义的缓存
  6. 5 · 审批与安全红线 ★
    1. 5.1 整体结构
    2. 5.2 12 条硬红线
    3. 5.3 真正的难点:区分「命令」和「数据」
    4. 5.4 引号遮蔽:但要给「真会执行的部分」留后门
    5. 5.5 引号不能成为绕过手段
    6. 5.6 敏感路径与写入目标
    7. 5.7 sudo 标准输入守卫
    8. 5.8 性能:预编译的理由
    9. 5.9 被拦截命令的留存
    10. 5.10 智能审批:可选的模型判断
    11. 5.11 上下文感知:不同场景不同策略
    12. 5.12 这一层的定位:最外面也最薄
  7. 6 · 执行环境
    1. 6.1 七种可选环境
    2. 6.2 必须诚实说明的一件事
    3. 6.3 抽象基类里的共用逻辑
    4. 6.4 文件同步
    5. 6.5 环境的选择时机
  8. 7 · 上下文引擎 ★
    1. 7.1 它定义的是什么
    2. 7.2 生命周期
    3. 7.3 三个必须实现的方法
    4. 7.4 最精辟的设计:select 和 compress 是两个正交动词
    5. 7.5 后置观察钩子
    6. 7.6 其他可选钩子
    7. 7.7 默认参数值
    8. 7.8 用户可见状态的可控性
    9. 7.9 内置实现的体量对比
  9. 8 · 记忆系统 ★
    1. 8.1 记忆提供者接口
    2. 8.2 生命周期与钩子
    3. 8.3 接口的版本化契约
    4. 8.4 琐碎提问过滤器
    5. 8.5 记忆使用指示器
    6. 8.6 内置的全息记忆
    7. 8.7 存储层与信任分
    8. 8.8 SQLite 状态层
    9. 8.9 三种记忆的分工总结
  10. 9 · 插件系统
    1. 9.1 三个发现来源
    2. 9.2 插件能提供什么
    3. 9.3 最重要的设计:区分「可叠加能力」与「互斥策略」
    4. 9.4 插件的存储
    5. 9.5 插件与工具集的联动
    6. 9.6 插件钩子
    7. 9.7 MCP:另一条扩展路径
    8. 9.8 这套扩展体系的整体形状
  11. 10 · 委派与多智能体
    1. 10.1 为什么需要委派
    2. 10.2 深度限制:只允许一层
    3. 10.3 并发限制
    4. 10.4 子智能体的工具限制
    5. 10.5 子智能体的审批策略
    6. 10.6 运行中的控制
    7. 10.7 亲缘关系检查
    8. 10.8 看板:智能体之间的协作
    9. 10.9 委派系统为什么有 5,071 行
  12. 11 · 模型供应商与凭据池
    1. 11.1 为什么不能只支持一家
    2. 11.2 供应商适配器清单
    3. 11.3 凭据池:核心机制
    4. 11.4 PooledCredential:单个凭据的状态
    5. 11.5 「持久化」为什么必要
    6. 11.6 与其他机制的配合
    7. 11.7 供应商抽象要抽象到哪一层
  13. 12 · 定时任务
    1. 12.1 「cron」是什么
    2. 12.2 定时智能体的场景
    3. 12.3 最重要的一个类:CronPromptInjectionBlocked
    4. 12.4 定时任务的工具集收窄
    5. 12.5 失败处理
    6. 12.6 防重复执行
    7. 12.7 为什么 scheduler.py 有 367 KB
    8. 12.8 定时任务在整个架构里的位置
  14. 13 · 技能系统
    1. 13.1 技能是什么
    2. 13.2 前置元数据逐字段解释
    3. 13.3 渐进式披露:技能系统的核心机制
    4. 13.4 技能的支撑设施
    5. 13.5 技能 vs 工具 vs 插件
    6. 13.6 技能系统的一个隐含设计:可组合
    7. 13.7 15 个技能类别
    8. 13.8 全文回顾:Hermes 的整体形状
  1. 0 · The Project at a Glance, and a Map of the Code
    1. 0.1 What This Software Is
    2. 0.2 Directory Map
    3. 0.3 Three Things You Can Read Off This Map
    4. 0.4 The Full Journey of One Message
    5. 0.5 Chapter Index
  2. 1 · The Gateway Layer
    1. 1.1 The Problem It Solves
    2. 1.2 The Platform Adapter Abstract Base Class
    3. 1.3 Normalizing Message Events
    4. 1.4 The Defensive Mechanisms in the Gateway Main Loop
    5. 1.5 Status Messages and Progress Feedback
    6. 1.6 Interruption and Recovery
    7. 1.7 Built-in Gateway Hooks
    8. 1.8 What This Layer Costs
  3. 2 · Identity and Session Routing
    1. 2.1 Profiles: One Process, Many Identities
    2. 2.2 Four-Level Routing and the “Specificity” Score
    3. 2.3 When an Explicit Route Is Rejected
    4. 2.4 SOUL.md: The Persona File
    5. 2.5 Session Routing: Continuity Across Platforms
    6. 2.6 Caching Agent Instances
  4. 3 · The Main Loop ★
    1. 3.1 The Loop Entry: A Triple Budget Gate
    2. 3.2 Per-Turn Preparation: build_turn_context
    3. 3.3 Mid-Turn Interjection: /steer
    4. 3.4 The Wall-Clock Budget Wrap-Up Reminder
    5. 3.5 The Interrupt Check
    6. 3.6 The Step Callback: An Observation Point for the Gateway
    7. 3.7 Other State Inside the Loop
    8. 3.8 Why the Loop Exited
    9. 3.9 An Optional Bypass: The Codex App Server Runtime
  5. 4 · The Tool System
    1. 4.1 The Most Important Design Decision: Implementation Separated from Exposure
    2. 4.2 Composing and Resolving Toolsets
    3. 4.3 Central Tool Dispatch
    4. 4.4 The Argument Coercion Layer
    5. 4.5 Sanitizing Tool Error Messages
    6. 4.6 Observing Tool Results, and Hooks
    7. 4.7 Recognizing a Delegation Context
    8. 4.8 Caching Tool Definitions
  6. 5 · Approval and Safety Red Lines ★
    1. 5.1 The Overall Structure
    2. 5.2 The 12 Hard Red Lines
    3. 5.3 The Real Difficulty: Telling “Command” from “Data”
    4. 5.4 Quote Masking: But Leave a Way In for “the Part That Really Executes”
    5. 5.5 Quoting Is Not a Bypass
    6. 5.6 Sensitive Paths and Write Targets
    7. 5.7 The sudo Stdin Guard
    8. 5.8 Performance: Why Precompile
    9. 5.9 Retaining Blocked Commands
    10. 5.10 Smart Approval: An Optional Model Judgment
    11. 5.11 Context Awareness: Different Situations, Different Policies
    12. 5.12 Where This Layer Sits: Outermost, and Thinnest
  7. 6 · Execution Environments
    1. 6.1 Seven Environments to Choose From
    2. 6.2 One Thing That Has to Be Said Plainly
    3. 6.3 Shared Logic in the Abstract Base Class
    4. 6.4 File Synchronization
    5. 6.5 When the Environment Is Chosen
  8. 7 · The Context Engine ★
    1. 7.1 What It Defines
    2. 7.2 Lifecycle
    3. 7.3 The Three Methods You Must Implement
    4. 7.4 The Sharpest Design Decision: select and compress Are Two Orthogonal Verbs
    5. 7.5 The Post-Turn Observation Hook
    6. 7.6 Other Optional Hooks
    7. 7.7 Default Parameter Values
    8. 7.8 Control Over User-Visible Status
    9. 7.9 Size Comparison of the Built-in Implementation
  9. 8 · The Memory System ★
    1. 8.1 The Memory Provider Interface
    2. 8.2 Lifecycle and Hooks
    3. 8.3 The Interface's Versioned Contract
    4. 8.4 The Trivial-Prompt Filter
    5. 8.5 The Memory-Usage Indicator
    6. 8.6 The Built-in Holographic Memory
    7. 8.7 The Storage Layer and Trust Scores
    8. 8.8 The SQLite State Layer
    9. 8.9 Summing Up the Division of Labor Among Three Kinds of Memory
  10. 9 · The Plugin System
    1. 9.1 Three Discovery Sources
    2. 9.2 What a Plugin Can Provide
    3. 9.3 The Most Important Design Decision: “Stackable Capabilities” vs. “Mutually Exclusive Strategies”
    4. 9.4 Plugin Storage
    5. 9.5 How Plugins Tie into Toolsets
    6. 9.6 Plugin Hooks
    7. 9.7 MCP: The Other Extension Path
    8. 9.8 The Overall Shape of This Extension System
  11. 10 · Delegation and Multi-Agent
    1. 10.1 Why Delegation Is Needed
    2. 10.2 The Depth Limit: One Level Only
    3. 10.3 Concurrency Limits
    4. 10.4 Tool Restrictions on Subagents
    5. 10.5 Approval Policy for Subagents
    6. 10.6 In-Flight Control
    7. 10.7 The Lineage Check
    8. 10.8 Kanban: Collaboration Between Agents
    9. 10.9 Why the Delegation System Is 5,071 Lines
  12. 11 · Model Providers and the Credential Pool
    1. 11.1 Why You Can't Support Just One
    2. 11.2 The Provider Adapter Roster
    3. 11.3 The Credential Pool: The Core Mechanism
    4. 11.4 PooledCredential: The State of a Single Credential
    5. 11.5 Why “Persistent” Is Necessary
    6. 11.6 How It Works with Other Mechanisms
    7. 11.7 How Deep the Provider Abstraction Should Go
  13. 12 · Scheduled Tasks (Cron)
    1. 12.1 What “cron” Is
    2. 12.2 Scenarios for Scheduled Agents
    3. 12.3 The Single Most Important Class: CronPromptInjectionBlocked
    4. 12.4 Narrowing the Toolset for Scheduled Tasks
    5. 12.5 Failure Handling
    6. 12.6 Preventing Duplicate Runs
    7. 12.7 Why scheduler.py Is 367 KB
    8. 12.8 Where Scheduled Tasks Sit in the Overall Architecture
  14. 13 · The Skill System
    1. 13.1 What a Skill Is
    2. 13.2 The Front Matter, Field by Field
    3. 13.3 Progressive Disclosure: The Core Mechanism of the Skill System
    4. 13.4 The Infrastructure Around Skills
    5. 13.5 Skills vs. Tools vs. Plugins
    6. 13.6 An Implicit Design in the Skill System: Composability
    7. 13.7 The 15 Skill Categories
    8. 13.8 Looking Back Across the Book: The Overall Shape of Hermes

1 · 网关层

gateway/,99 个文件,其中 run.py 单文件 1.55 MB —— 是整个项目最大的模块。这一章讲一个常驻进程如何让 22 个聊天软件都能触达同一个智能体

1.1 它解决什么问题

先说清楚没有网关会怎样。假设你想让智能体支持 Slack 和 Telegram 两个平台:

朴素做法:写两个脚本 slack_bot.py ← 连 Slack,收到消息就调智能体 telegram_bot.py ← 连 Telegram,收到消息就调智能体 问题立刻出现: · 你在 Slack 聊到一半换 Telegram,上下文没了(两个进程,各存各的) · 定时任务该由谁触发?两个都触发就重复了 · 权限配置要写两份 · Slack 支持编辑消息(可以流式更新),Telegram 也支持但 API 完全不同 · 短信不支持编辑,流式输出根本没法做 · Discord 单条消息上限 2000 字,Telegram 是 4096 · 加第三个平台时,你发现前两个脚本里 70% 的代码是重复的

网关就是把这些共性抽出来的那一层。它是一个长期不关闭的后台进程,同时连着所有平台,负责:

  • 把各平台千奇百怪的消息格式归一化成统一的事件对象
  • 会话路由 —— 判断这条消息属于哪一场对话
  • 用户授权
  • 分发斜杠命令
  • 驱动定时任务的时钟
  • 管理智能体实例的缓存(同一场会话复用同一个实例)
  • 把回复按各平台的规则发出去

1.2 平台适配器抽象基类

gateway/platforms/base.py,333 KB。这是那份「插座标准」—— 规定任何一个平台想接进来必须提供什么。

核心思路:把差异抽象成「能力查询方法」

class BasePlatformAdapter(ABC):

    # —— 消息长度限制 ——
    def max_message_length_for_chat(self, chat_id: str) -> int
    def message_len_fn(self) -> Callable[[str], int]
    def message_len_fn_for_chat(self, chat_id: str) -> Callable[[str], int]

    # —— 流式输出能力 ——
    def supports_draft_streaming(self, ...) -> bool
    def prefers_fresh_final_streaming(self, ...) -> bool
    def streaming_overflow_limit(self) -> Optional[int]
    async def send_draft(self, ...)

    # —— 权限模型 ——
    def enforces_own_access_policy(self) -> bool
    def authorization_is_upstream(self) -> bool

    # —— 渲染 ——
    def render_message_event(self, event, sink) -> None
    def format_tool_event(self, event, *, mode: str = "all", ...) -> str
    def format_tool_preview(self, preview: "ToolPreview") -> str
    def set_status_text(self, chat_id: str, text: Optional[str]) -> None

hermes-agent/gateway/platforms/base.py

为什么是「能力查询」而不是「if 平台名」

对比两种写法:

# 写法 A:在主流程里判断平台
if platform == 'slack':
    max_len = 40000
elif platform == 'discord':
    max_len = 2000
elif platform == 'telegram':
    max_len = 4096
elif platform == 'sms':
    max_len = 160
# ... 22 个分支,而且这样的判断散落在几十处

# 写法 B:问适配器
max_len = adapter.max_message_length_for_chat(chat_id)

写法 A 的问题不是难看,是「新增平台要改几十个地方」。而且你不知道要改哪几个 —— 只能靠搜索和运气。

写法 B 下,新增一个平台 = 实现一组能力声明。主流程一行都不用动。而且抽象基类会强制你实现所有必需的方法,漏一个直接报错。

这就是这一层能撑住 22 个平台的根本原因。

被抽象出来的差异有多大

能力各平台的实际差异
能不能编辑已发消息
supports_draft_streaming
Slack / Telegram / Discord:能。所以可以流式更新同一条消息,用户看到文字逐渐生长。
短信:不能。只能发新消息 —— 流式输出根本没法做,只能等全部生成完再发一条。
偏好重发最终版吗
prefers_fresh_final_streaming
有些平台编辑消息会触发通知或者把消息顶到最新,体验很差。这类平台宁可「流式过程用草稿,最终结果发一条新的」。
单条消息长度上限
max_message_length_for_chat
Discord 2,000 / Telegram 4,096 / 短信 160 / Slack 约 40,000。
注意这个方法接收 chat_id 参数 —— 因为同一平台的不同频道可能有不同限制(比如企业版和免费版)。
长度怎么算
message_len_fn
更微妙:「长度」的定义各平台不同。有的按字符数,有的按 UTF-16 码元,有的把 emoji 算多个。所以返回的是一个计算函数而不是一个数字。
平台自己管权限吗
enforces_own_access_policy
企业微信 / 飞书:有完整的企业权限体系,能进到这个群的人就是被授权的。
IRC:完全没有权限概念。任何人都能发消息,必须由智能体自己做授权。
授权在上游吗
authorization_is_upstream
有些接入方式(比如通过企业网关代理)已经在上游做过身份认证,智能体这里不该再问一遍。

1.3 消息事件的归一化

各平台的消息格式完全不同。网关把它们统一成一个事件对象:

class MessageType(Enum): ...
class ProcessingOutcome(Enum): ...

class MessageEvent:
    ...
    def is_command(self) -> bool                 # 是不是斜杠命令
    def get_command(self) -> Optional[str]       # 命令名
    def get_command_args(self) -> str            # 命令参数

class CachedMedia:
    def context_note(self) -> str                # 附件的上下文说明

class TextDebounceState: ...                     # ★ 文本去抖动
class SendResult: ...

class EphemeralReply(str):                       # ★ 带过期时间的临时回复
    def __new__(cls, text: str, ttl_seconds: Optional[int] = None): ...
    def text(self) -> str: ...

两个值得单独说的类

TextDebounceState(文本去抖动状态)解决的是这个场景:

用户在 Telegram 上连发三条: 09:15:01 "帮我看看" 09:15:03 "那个报错" 09:15:05 "在 server.py 里" 如果每条都立刻触发一次智能体: → 触发三次,前两次都是无效的(信息不完整) → 浪费三次模型调用,而且模型会被前两次的半截问题带偏 去抖动:收到消息后先等一小会儿,如果这期间又来了消息, 就合并起来一起处理。用户"打字打了一半发出去"的 习惯被正确处理了。

EphemeralReply(临时回复)是一个继承自 str 的类,带一个过期时间。用于那些「说完就该消失」的消息 —— 比如「正在思考…」这类状态提示。在支持的平台上,这类消息会在一段时间后自动删除,不污染聊天记录。

让它继承 str 是一个很实用的设计:所有原本处理字符串的代码不用改,照常工作;只有关心过期时间的代码才去检查它是不是 EphemeralReply

1.4 网关主循环里的那些防御机制

run.py 里的函数名本身就是一份「长驻进程会遇到什么问题」的清单:

① 卫生冷却:防止压缩失败反复重试

def _hygiene_cooldown_for_failure(...)
def _reset_hygiene_failure_streak(gateway, session_key: str) -> None
def hygiene_compaction_recovered(...)
def _record_hygiene_cooldown(...)

「卫生」(hygiene)在这里指的是后台自动做的上下文整理。如果某个会话的压缩连续失败,就给它一个冷却期,别再反复尝试 —— 否则会持续消耗资源而且持续失败。

_reset_hygiene_failure_streak(重置失败连击)这个命名说明它跟踪的是连续失败次数,成功一次就清零。这和第 3 章会讲的幂等锁是同一个思路。

② 瞬时网络错误识别

def _is_transient_network_error(exc: BaseException) -> bool

长驻进程必须区分「网络抖了一下」和「真的出问题了」。前者应该静默重试,后者应该告警。如果不区分,用户会被无意义的网络波动告警淹没,最终屏蔽所有告警。

③ 用户可见文本的密钥脱敏

def _redact_gateway_user_facing_secrets(text: str) -> str
def _redact_approval_command(cmd: "str | None") -> str

网关会把一些内部信息发到聊天窗口(错误提示、审批请求)。这些文本里可能夹带 API 密钥、令牌、密码。而聊天窗口是多人可见的、会被搜索的、会被归档的。所以发出去之前必须脱敏。

注意有两个脱敏函数:一个给通用文本,一个专门给「待审批的命令」。因为命令里的密钥形态不一样(可能在环境变量赋值里、在参数里、在管道里)。

④ 供应商错误的用户友好化

def _gateway_provider_error_reply(text: str) -> str
def _looks_like_gateway_provider_error(text: str) -> bool
def _sanitize_gateway_final_response(platform: Any, text: str) -> str

模型服务返回的错误信息通常是给开发者看的(含堆栈、内部错误码、请求 ID)。直接发到聊天窗口对用户毫无意义。所以要识别出来并翻译成人话。

⑤ 重启守卫

gateway/restart.py
gateway/restart_loop_guard.py     ← ★ 重启风暴防护

重启风暴是长驻进程的经典故障:进程崩溃 → 自动重启 → 启动时又崩 → 又重启 → 无限循环。每秒重启几十次,日志被刷爆,CPU 打满。

守卫的做法通常是:记录最近的重启次数和间隔,如果在短时间内重启太多次,就停止自动重启并保持崩溃状态 —— 让人来看一眼。

⑥ 内存监控与优雅排空

gateway/memory_monitor.py           内存占用监控
gateway/agent_cache_pressure.py     智能体缓存压力
gateway/drain_control.py            ★ 优雅排空
gateway/disk_status.py              磁盘状态

智能体缓存压力:网关会缓存智能体实例(同一场会话复用同一个)。但每个实例都持有完整的消息历史 —— 几十场活跃会话就能吃掉几 GB 内存。所以需要监控压力并在必要时淘汰不活跃的实例。

优雅排空:要关闭网关时,不能直接杀掉 —— 手头正在处理的消息会丢。正确做法是「停止接受新消息,把手头的处理完,然后退出」。

⑦ 投递账本

gateway/delivery.py
gateway/delivery_ledger.py     ← ★ 投递账本
gateway/rich_sent_store.py
gateway/message_timestamps.py
gateway/dead_targets.py        ← 死目标(比如被删掉的频道)

投递账本记录「哪条回复已经发到哪里了」。它防的是重复投递:网络超时时你不知道消息发出去没有,重试可能导致用户收到两遍。有账本就能判断。

死目标处理的是:智能体要回复的那个频道被删了、机器人被踢出群了、用户拉黑了。这些投递会永久失败,必须识别出来并停止重试,否则重试队列会无限增长。

1.5 状态消息与进度反馈

def _status_template_to_regex(template: str) -> str
def _gateway_compression_progress_notices_enabled() -> bool
def _prepare_gateway_status_message(platform, event_type: str, message: str) -> Optional[str]
async def _send_or_update_status_coro(adapter, chat_id, status_key, content, metadata)
def render_notice_line(notice) -> str

智能体在长任务中需要给用户反馈「我还在干活」。但在聊天软件里做这件事很微妙:

  • 发太多状态消息 → 刷屏
  • 不发 → 用户以为死了
  • 用编辑同一条消息的方式更新 → 只在支持编辑的平台可行

_status_template_to_regex 这个函数值得注意:它把状态消息的模板转成正则表达式。用途应该是「识别聊天记录里哪些消息是自己之前发的状态消息」,从而可以更新或删除它们 —— 因为平台 API 返回的消息 ID 可能已经丢失,只能靠内容匹配来找。

1.6 中断与恢复

def _is_fresh_gateway_interruption(...)
def build_resume_recovery_note(...)
def _prepare_resume_pending_message(...)
def _build_replay_entry(...)
def _startup_restore_drain_timeout_secs() -> float
def _auto_continue_freshness_window() -> float

这一组函数处理的是:网关重启后,那些「进行到一半」的会话怎么办。

场景:智能体正在处理一个长任务,网关进程被重启了(部署 / 崩溃 / 内存不足) 重启后需要判断: ① 这次中断是"新鲜"的吗?(_is_fresh_gateway_interruption) 刚断的可以直接续上;断了两小时的,用户可能已经不关心了 ② 要不要自动继续?(_auto_continue_freshness_window) 有一个"新鲜度窗口" —— 超过这个时间就不自动继续,等用户说话 ③ 给用户什么解释?(build_resume_recovery_note) 不能默默地接着干,用户会困惑。要说明"我刚才被中断了,现在继续" ④ 启动时排空积压消息要等多久?(_startup_restore_drain_timeout_secs) 重启期间用户可能发了几条消息,要先处理完再进入正常状态

这一组函数是「长驻」和「命令行工具」的本质差别所在。

命令行工具崩了就崩了,用户重新跑一次。长驻进程崩了之后必须自己判断「刚才干到哪了、该不该继续、要不要告诉用户」 —— 而且判断依据只有磁盘上的状态。

这部分逻辑在架构图上完全看不见,但它占了网关模块相当大的比重。

1.7 网关内置钩子

gateway/hooks.py
gateway/builtin_hooks/

网关层有自己的钩子系统,让插件可以在消息进出的关键节点插入逻辑。和第 9 章讲的插件系统配合,构成了「不改核心代码就能扩展网关行为」的能力。

1.8 这一层的代价

1.55 MB 的单文件是这一层最直观的代价。它不是设计缺陷,是「22 个平台 × 每个平台的边角情况」组合爆炸的必然结果。

从函数名可以看出,这个文件里塞了:冷却策略、错误分类、脱敏、状态渲染、时间戳处理、审批转发、进度线程解析、平台显示配置、Telegram 特有的提及格式转换(_telegramize_command_mentions)……

每一个都很小,但加起来就是 1.55 MB。而且它们大多无法被抽象掉 —— 因为它们本质上就是在处理外部世界的不规则性。

1 · The Gateway Layer

gateway/: 99 files, including run.py, a single 1.55 MB file — the largest module in the whole project. This chapter is about how one always-on process lets 22 chat apps reach the same agent.

1.1 The Problem It Solves

Start with what happens without a gateway. Say you want the agent to support two platforms, Slack and Telegram:

The naive approach: write two scripts slack_bot.py ← connects to Slack, calls the agent on each message telegram_bot.py ← connects to Telegram, calls the agent on each message The problems show up immediately: · You switch from Slack to Telegram mid-conversation and the context is gone (two processes, each with its own state) · Who fires the scheduled tasks? If both do, they run twice · Permission config has to be written twice · Slack supports editing messages (so you can stream updates); Telegram does too, but with a completely different API · SMS can't edit at all, so streaming output is simply impossible · Discord caps a single message at 2000 characters, Telegram at 4096 · When you add a third platform, you find that 70% of the code in the first two scripts is duplicated

The gateway is the layer that pulls all of that shared logic out. It is a background process that never shuts down, connected to every platform at once, and it is responsible for:

  • Normalizing the wildly different message formats of each platform into a uniform event object
  • Session routing — deciding which conversation a message belongs to
  • User authorization
  • Dispatching slash commands
  • Driving the clock for scheduled tasks
  • Managing the cache of agent instances (the same session reuses the same instance)
  • Sending replies out according to each platform's rules

1.2 The Platform Adapter Abstract Base Class

gateway/platforms/base.py, 333 KB. This is the “wall-socket standard” — it specifies what any platform must provide in order to plug in.

The core idea: turn the differences into “capability query methods”

class BasePlatformAdapter(ABC):

    # —— message length limits ——
    def max_message_length_for_chat(self, chat_id: str) -> int
    def message_len_fn(self) -> Callable[[str], int]
    def message_len_fn_for_chat(self, chat_id: str) -> Callable[[str], int]

    # —— streaming capabilities ——
    def supports_draft_streaming(self, ...) -> bool
    def prefers_fresh_final_streaming(self, ...) -> bool
    def streaming_overflow_limit(self) -> Optional[int]
    async def send_draft(self, ...)

    # —— permission model ——
    def enforces_own_access_policy(self) -> bool
    def authorization_is_upstream(self) -> bool

    # —— rendering ——
    def render_message_event(self, event, sink) -> None
    def format_tool_event(self, event, *, mode: str = "all", ...) -> str
    def format_tool_preview(self, preview: "ToolPreview") -> str
    def set_status_text(self, chat_id: str, text: Optional[str]) -> None

hermes-agent/gateway/platforms/base.py

Why “capability queries” instead of “if platform name”

Compare the two styles:

# Style A: branch on the platform in the main flow
if platform == 'slack':
    max_len = 40000
elif platform == 'discord':
    max_len = 2000
elif platform == 'telegram':
    max_len = 4096
elif platform == 'sms':
    max_len = 160
# ... 22 branches, and checks like this scattered across dozens of places

# Style B: ask the adapter
max_len = adapter.max_message_length_for_chat(chat_id)

The problem with Style A isn't that it's ugly. It's that “adding a platform means editing dozens of places.” And you don't know which places — you find them by grep and by luck.

Under Style B, adding a platform = implementing one set of capability declarations. Not a single line of the main flow changes. And the abstract base class forces you to implement every required method; miss one and it errors out immediately.

This is the fundamental reason this layer can carry 22 platforms.

How big are the differences being abstracted away?

CapabilityHow the platforms actually differ
Can it edit a sent message?
supports_draft_streaming
Slack / Telegram / Discord: yes. So the same message can be updated in a stream, and the user watches the text grow.
SMS: no. It can only send new messages — streaming is simply not possible; you wait for the full response and send it in one go.
Does it prefer a fresh final message?
prefers_fresh_final_streaming
On some platforms, editing a message fires a notification or bumps the message to the top, which is a bad experience. Those platforms would rather “use a draft while streaming, then send the final result as a new message.”
Per-message length cap
max_message_length_for_chat
Discord 2,000 / Telegram 4,096 / SMS 160 / Slack roughly 40,000.
Note that this method takes a chat_id argument — because different channels on the same platform can have different limits (enterprise vs. free tier, for instance).
How length is counted
message_len_fn
Subtler: the very definition of “length” differs by platform. Some count characters, some count UTF-16 code units, some count an emoji as several. So it returns a counting function, not a number.
Does the platform manage permissions itself?
enforces_own_access_policy
WeCom / Feishu: they have a full enterprise permission system; anyone who can get into the group is, by definition, authorized.
IRC: no concept of permissions at all. Anyone can send a message, so the agent must do authorization itself.
Is authorization upstream?
authorization_is_upstream
Some integration paths (through an enterprise gateway proxy, say) have already authenticated the identity upstream, and the agent shouldn't ask again.

1.3 Normalizing Message Events

Every platform has a completely different message format. The gateway unifies them into one event object:

class MessageType(Enum): ...
class ProcessingOutcome(Enum): ...

class MessageEvent:
    ...
    def is_command(self) -> bool                 # is this a slash command?
    def get_command(self) -> Optional[str]       # command name
    def get_command_args(self) -> str            # command arguments

class CachedMedia:
    def context_note(self) -> str                # context note for an attachment

class TextDebounceState: ...                     # ★ text debouncing
class SendResult: ...

class EphemeralReply(str):                       # ★ a temporary reply with an expiry
    def __new__(cls, text: str, ttl_seconds: Optional[int] = None): ...
    def text(self) -> str: ...

Two classes worth calling out

TextDebounceState handles this scenario:

A user fires off three messages in a row on Telegram: 09:15:01 "take a look at" 09:15:03 "that error" 09:15:05 "in server.py" If each one triggers the agent immediately: → three runs, the first two useless (incomplete information) → three wasted model calls, and the model gets led astray by the two half-questions Debouncing: after a message arrives, wait a moment; if more messages come in during that window, merge them and handle them together. The habit of "hitting send halfway through typing" is handled correctly.

EphemeralReply is a class that inherits from str and carries an expiry time. It is for messages that “should disappear once said” — status hints like “Thinking…”. On platforms that support it, these messages delete themselves after a while and don't clutter the chat history.

Inheriting from str is a very practical design: every piece of code that already handles strings keeps working unchanged; only the code that cares about expiry checks whether it is an EphemeralReply.

1.4 The Defensive Mechanisms in the Gateway Main Loop

The function names in run.py are, on their own, a checklist of “what goes wrong for a long-running process”:

① Hygiene cooldown: stop retrying failed compaction

def _hygiene_cooldown_for_failure(...)
def _reset_hygiene_failure_streak(gateway, session_key: str) -> None
def hygiene_compaction_recovered(...)
def _record_hygiene_cooldown(...)

“Hygiene” here means the automatic context tidying done in the background. If compaction keeps failing for a session, give it a cooldown period rather than trying again and again — otherwise it burns resources continuously and fails continuously.

The name _reset_hygiene_failure_streak tells you it tracks a count of consecutive failures, reset to zero on the first success. This is the same idea as the idempotency lock in Chapter 3.

② Recognizing transient network errors

def _is_transient_network_error(exc: BaseException) -> bool

A long-running process has to tell “the network hiccuped” apart from “something is actually broken.” The former should retry silently; the latter should alert. If you don't distinguish them, users drown in meaningless alerts about network blips and eventually mute all alerts.

③ Redacting secrets from user-visible text

def _redact_gateway_user_facing_secrets(text: str) -> str
def _redact_approval_command(cmd: "str | None") -> str

The gateway sends some internal information into the chat window (error messages, approval requests). That text may carry API keys, tokens, and passwords. And a chat window is visible to many people, searchable, and archived. So it must be redacted before it goes out.

Note that there are two redaction functions: one for general text, one specifically for “the command awaiting approval.” Secrets in a command take different shapes (in an environment variable assignment, in an argument, in a pipeline).

④ Making provider errors user-friendly

def _gateway_provider_error_reply(text: str) -> str
def _looks_like_gateway_provider_error(text: str) -> bool
def _sanitize_gateway_final_response(platform: Any, text: str) -> str

Error messages from model services are usually written for developers (stack traces, internal error codes, request IDs). Sent straight into a chat window, they mean nothing to the user. So they have to be recognized and translated into plain language.

⑤ Restart guard

gateway/restart.py
gateway/restart_loop_guard.py     ← ★ restart storm protection

A restart storm is the classic failure of a long-running process: the process crashes → auto-restarts → crashes again on startup → restarts again → forever. Dozens of restarts per second, logs flooded, CPU pinned.

The usual guard: record recent restart counts and intervals, and if there are too many restarts in a short window, stop auto-restarting and stay down — so a human takes a look.

⑥ Memory monitoring and graceful drain

gateway/memory_monitor.py           memory usage monitoring
gateway/agent_cache_pressure.py     agent cache pressure
gateway/drain_control.py            ★ graceful drain
gateway/disk_status.py              disk status

Agent cache pressure: the gateway caches agent instances (the same session reuses the same one). But every instance holds the full message history — a few dozen active sessions can eat several GB of memory. So the pressure has to be monitored, and inactive instances evicted when necessary.

Graceful drain: when the gateway needs to shut down, you can't just kill it — messages currently being processed would be lost. The right way is “stop accepting new messages, finish what's in hand, then exit.”

⑦ Delivery ledger

gateway/delivery.py
gateway/delivery_ledger.py     ← ★ delivery ledger
gateway/rich_sent_store.py
gateway/message_timestamps.py
gateway/dead_targets.py        ← dead targets (a deleted channel, say)

The delivery ledger records “which reply has already been sent where.” It guards against duplicate delivery: on a network timeout you don't know whether the message went out, and a retry could make the user see it twice. With a ledger, you can tell.

Dead targets covers the cases where the channel the agent wants to reply to has been deleted, the bot has been kicked from the group, or the user has blocked it. These deliveries will fail forever, and they must be recognized and retries stopped, or the retry queue grows without bound.

1.5 Status Messages and Progress Feedback

def _status_template_to_regex(template: str) -> str
def _gateway_compression_progress_notices_enabled() -> bool
def _prepare_gateway_status_message(platform, event_type: str, message: str) -> Optional[str]
async def _send_or_update_status_coro(adapter, chat_id, status_key, content, metadata)
def render_notice_line(notice) -> str

During a long task the agent needs to tell the user “I'm still working.” But doing that inside a chat app is delicate:

  • Too many status messages → spam
  • None → the user thinks it died
  • Updating by editing the same message → only works on platforms that support editing

The function _status_template_to_regex deserves a note: it converts a status-message template into a regular expression. The likely purpose is “recognize which messages in the chat history are status messages I sent earlier,” so they can be updated or deleted — because the message IDs returned by the platform API may already be lost, and matching on content is the only way to find them.

1.6 Interruption and Recovery

def _is_fresh_gateway_interruption(...)
def build_resume_recovery_note(...)
def _prepare_resume_pending_message(...)
def _build_replay_entry(...)
def _startup_restore_drain_timeout_secs() -> float
def _auto_continue_freshness_window() -> float

This group of functions handles: after the gateway restarts, what happens to the sessions that were “halfway through”?

Scenario: the agent is in the middle of a long task and the gateway process gets restarted (deploy / crash / out of memory) After the restart it has to decide: ① Is this interruption "fresh"? (_is_fresh_gateway_interruption) One that just happened can be resumed directly; one from two hours ago, the user may no longer care about ② Should it continue automatically? (_auto_continue_freshness_window) There is a "freshness window" — past it, don't auto-continue; wait for the user to speak ③ What does it tell the user? (build_resume_recovery_note) It can't just silently carry on; the user would be confused. It should say "I was interrupted; continuing now" ④ How long to wait while draining the backlog at startup? (_startup_restore_drain_timeout_secs) The user may have sent several messages during the restart; process them before returning to normal

This group of functions is where the essential difference between “always-on” and “command-line tool” lives.

When a command-line tool crashes, it crashes; the user runs it again. When a long-running process crashes, it has to work out on its own “where was I, should I continue, should I tell the user” — and the only evidence it has is the state on disk.

None of this logic shows up on an architecture diagram, but it accounts for a substantial share of the gateway module.

1.7 Built-in Gateway Hooks

gateway/hooks.py
gateway/builtin_hooks/

The gateway layer has its own hook system, letting plugins insert logic at the key points where messages enter and leave. Together with the plugin system in Chapter 9, this gives you the ability to “extend gateway behavior without changing core code.”

1.8 What This Layer Costs

A single 1.55 MB file is the most visible cost of this layer. It isn't a design flaw; it is the inevitable result of the combinatorial explosion of “22 platforms × every platform's edge cases.”

You can tell from the function names what's packed into this file: cooldown policy, error classification, redaction, status rendering, timestamp handling, approval forwarding, progress-thread parsing, per-platform display config, Telegram-specific mention format conversion (_telegramize_command_mentions)…

Each one is small, but together they add up to 1.55 MB. And most of them can't be abstracted away — because what they are doing, fundamentally, is handling the irregularity of the outside world.