Hermes 架构全解Hermes Architecture, in Full第 2 章 · 14 章Chapter 2 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

2 · 身份与会话路由

这一章讲两件相关但不同的事:一条消息属于「哪个智能体身份」,以及属于「哪一场对话」

2.1 Profile:一个进程承载多个身份

「Profile」在 Hermes 里指一个完整的智能体身份。文档第一句定义得很清楚:

「Allows a single Hermes instance to route specific Discord guilds/channels/threads to different profiles — each with their own model, tools, memory, and persona.」

译:让单个 Hermes 实例把特定的 Discord 服务器 / 频道 / 会话线程路由到不同的身份 —— 每个身份有自己的模型、工具、记忆和人格。

一个 Profile 拥有独立的:

组成部分说明
config.yaml自己的配置:用哪个模型、开哪些工具集、上下文阈值多少
SOUL.md人格与行为准则文件。这个身份是什么风格、遵循什么原则
MEMORY.md / USER.md这个身份的长期记忆和对用户的认知
memory_store.db独立的 SQLite 记忆数据库
网关进程可以有自己的网关实例
定时任务自己的任务清单
为什么需要多身份

典型场景:

  • 公司的 Discord 服务器:技术频道要一个会看代码、能跑命令的身份;客服频道要一个只读文档、语气礼貌的身份。它们绝不能共享记忆 —— 客服频道的智能体不该知道内部技术讨论。
  • 个人使用:工作用一个身份(严肃、有代码权限),生活用另一个(轻松、只有日程和搜索)。
  • 成本控制:重要频道用贵的强模型,闲聊频道用便宜的小模型。

关键在于「一个进程」。如果每个身份都要跑一个独立进程,那么内存占用、部署复杂度、定时任务的协调都会变成问题。

2.2 四级路由与「具体度」打分

路由规则写在配置文件里:

gateway:
  profile_routes:
    - name: server-default          # 规则名
      platform: discord
      guild_id: "服务器ID"
      profile: server-profile       # 路由到哪个身份

    - name: special-channel
      platform: discord
      guild_id: "服务器ID"
      chat_id: "频道ID"
      profile: channel-profile

    - name: thread-route
      platform: discord
      chat_id: "频道ID"
      thread_id: "线程ID"
      profile: thread-profile

具体度打分

@dataclass(frozen=True)
class ProfileRoute:
    name: str
    platform: str
    profile: str
    guild_id:  Optional[str] = None      # 服务器
    chat_id:   Optional[str] = None      # 频道
    thread_id: Optional[str] = None      # 会话线程
    enabled: bool = True

    @property
    def specificity(self) -> int:
        """Higher value = more specific match."""
        s = 0
        if self.guild_id:  s += 2        # 服务器  权重 2
        if self.chat_id:   s += 4        # 频道    权重 4
        if self.thread_id: s += 8        # 线程    权重 8
        return s

hermes-agent/gateway/profile_routing.py

这个打分用的是二进制位权重(2、4、8),所以四级优先级刚好对应文档里那张表:

优先级匹配条件具体度含义
1平台 + 频道 + 线程14 = 2+4+8精确到某个会话线程
2平台 + 频道6 = 2+4整个频道
3平台 + 服务器2整个服务器
4都不匹配回落到默认身份

(严格说打分只是排序依据,实际优先级 14 > 6 > 2 是位权重的自然结果。用 2/4/8 而不是 1/2/3 的好处是:任意组合的分数都不重复,排序永远确定。)

匹配是「合取」的

def matches(self, platform, guild_id=None, chat_id=None,
            thread_id=None, parent_chat_id=None) -> bool:
    if not self.enabled:                                       return False
    if self.platform != platform:                              return False
    if self.thread_id and self.thread_id != thread_id:         return False
    if self.chat_id and self.chat_id != chat_id \
                    and self.chat_id != parent_chat_id:        return False    # ★
    if self.guild_id and self.guild_id != guild_id:            return False
    return True

文档明确了语义:

「All configured discriminators are matched conjunctively (AND): every discriminator that the route declares must hold. …A route declaring both guild_id and chat_id requires both to match (a chat match alone does not satisfy a guild constraint).」

译:所有配置的判别条件都是「与」关系:路由声明的每一个条件都必须成立。……一条同时声明了服务器和频道的路由,要求两者都匹配(光频道匹配不能满足服务器约束)。

父链匹配:Discord 论坛与线程

那个 parent_chat_id 分支处理的是 Discord 特有的层级结构:

Discord 的结构: 服务器 (guild) └─ 频道 (channel) ├─ 直接发在频道里的消息 └─ 线程 (thread) / 论坛帖 (forum post) └─ 发在线程里的消息 问题:你为"某个频道"配了一条路由规则, 那么发在这个频道的"线程"里的消息,该不该匹配? 答案:应该。所以匹配时同时检查: chat_id == route.chat_id (直接发在频道里) 或 parent_chat_id == route.chat_id (发在这个频道的线程里) 文档原话: 「Routes keyed on a channel match both direct messages and messages in any thread/post whose parent is that channel.」

这个细节体现的是「抽象要贴合真实世界的结构」。

如果只做扁平的「频道 ID 精确匹配」,用户会遇到一个非常困惑的行为:在频道里说话是一个身份,在这个频道开个线程说话就换成默认身份了。而用户的心智模型里,线程明显属于那个频道。

抽象和用户心智模型不一致时,用户会认为是 bug —— 即使代码完全按设计工作。

2.3 显式路由被拒绝的情况

class ProfileRouteRejected(RuntimeError):
    """An explicit route matched a profile this gateway does not serve."""

译:一条显式路由匹配到了一个「本网关不服务」的身份。

场景是:你可以跑多个网关进程,每个只服务一部分身份(比如为了资源隔离)。这时一条消息可能匹配到一个「不归我管」的身份 —— 这不是错误配置,只是这条消息该由另一个网关处理。

用一个专门的异常类型而不是「静默回落到默认身份」,是正确的选择 —— 因为静默回落会导致消息被错误的身份处理,而用户很难发现。

2.4 SOUL.md:人格文件

每个身份有一个 SOUL.md。它和系统提示词的关系是:

系统提示词的组装(agent/prompt_builder.py): ① SOUL.md ← 这个身份的人格与行为准则 ② MEMORY.md ← 长期记忆索引 ③ USER.md ← 对用户的认知 ④ 已加载的技能 ← 技能的头部元数据(渐进式披露,见第 13 章) ⑤ 上下文文件 ← AGENTS.md / .hermes.md(项目级规范) ⑥ 工具说明 ← 当前工具集里每个工具的描述

这个分层和 Claude Code 的 CLAUDE.md 是同类东西,但有一个重要区别:Hermes 把「人格」和「记忆」「用户认知」拆成了三个独立文件。

文件谁写的内容性质
SOUL.md人写的身份设定。基本不变,改动是刻意的
MEMORY.md智能体自己写的它学到的事实。持续增长
USER.md智能体自己写的对用户的建模。持续修正

这个拆分对「自我改进」是必要的。

如果人格和记忆混在一个文件里,那么智能体在写记忆时就有可能改到人格设定 —— 而人格是不该由智能体自己修改的

拆开之后:智能体只往 MEMORY.mdUSER.md 里写,SOUL.md 是只读的。「可自我修改的部分」和「不可自我修改的部分」有了物理边界。

2.5 会话路由:跨平台的连续性

身份路由决定「用哪个人格」,会话路由决定「接哪场对话」。

这是网关最有价值的能力之一:你在电脑上用 Slack 聊到一半,出门换成手机上的 Telegram 继续聊,对话上下文完全连续。

它是怎么做到的

关键在于"会话"的定义不绑定平台: 会话键 (session key) 由这些因素决定: · 是哪个用户(跨平台的用户身份映射) · 是哪个身份 (profile) · 在什么范围(私聊 / 某个频道 / 某个线程) 而不是: · 平台是什么(× 这个不该影响会话归属) · 用哪个客户端(× 同上) ★ 所以从 Slack 私聊和从 Telegram 私聊,只要 "同一个用户 + 同一个身份 + 都是私聊", 就命中同一个会话键 → 同一个 AIAgent 实例 → 同一段历史

相关的模块:

gateway/channel_directory.py    频道目录
gateway/pairing.py              ★ 配对(把不同平台的账号关联到同一个用户)
gateway/mirror.py               镜像
gateway/profile_routing.py      身份路由

pairing.py(配对)是这套机制的基础:系统需要知道「Slack 上的 @alice」和「Telegram 上的 alice_w」是同一个人。这通常通过一次性验证码之类的方式建立关联。

2.6 智能体实例的缓存

网关会缓存 AIAgent 实例,同一场会话复用同一个。这带来两个后果:

好处:状态自然连续

消息历史、上下文引擎的记账、压缩计数器都在实例里,不需要每次从磁盘重建。

代价:跨轮次的状态污染风险

主循环开头有一段专门处理这个问题的代码,注释说得很清楚:

「The gateway caches agents across user turns. Compression state is per-turn: carrying a prior in-place boundary forward would make a later uncompressed result look like a compacted transcript to gateway writers.」

译:网关跨用户轮次缓存智能体。而压缩状态是每轮独立的:把上一轮的就地分界点带到下一轮,会让后面一个未压缩的结果在网关写入方看来像是一份已压缩的记录。

还有一段处理配置热更新:

「Adopt any ~/.hermes/.env credential/base-url edits made since the last turn — a Settings save updates .env but not this worker's client, which was built at agent init. No-op when .env is unchanged.」

译:采纳自上一轮以来对凭据 / 服务地址所做的任何修改 —— 用户在设置界面保存时会更新 .env 文件,但不会更新这个工作进程的客户端对象(那是在智能体初始化时创建的)。如果 .env 没变,这一步是空操作。

这是长驻进程特有的问题:用户在设置界面改了 API 密钥,期待立刻生效。但智能体实例是几小时前创建的,它手里的客户端对象还用着旧密钥。所以每轮开头要检查一次配置文件有没有变。

这一章的核心结论

「长驻 + 多入口 + 多身份」这三个特性一旦叠加,就会产生一整类命令行工具不会遇到的问题:

· 状态在轮次之间要不要清?哪些清哪些留?
· 配置改了怎么热更新?
· 同一个用户从不同入口进来,算不算同一场对话?
· 一条消息该由哪个身份处理?规则冲突怎么办?
· 进程重启后,进行到一半的会话怎么恢复?

这些问题的答案构成了 gateway/ 那 99 个文件的绝大部分。

2 · Identity and Session Routing

This chapter covers two related but distinct things: which “agent identity” a message belongs to, and which “conversation” it belongs to.

2.1 Profiles: One Process, Many Identities

In Hermes, a “Profile” is a complete agent identity. The first sentence of the docs defines it clearly:

“Allows a single Hermes instance to route specific Discord guilds/channels/threads to different profiles — each with their own model, tools, memory, and persona.”

In plain terms: one Hermes instance can send particular Discord servers / channels / threads to different identities — and each identity has its own model, tools, memory, and persona.

A Profile owns its own:

ComponentDescription
config.yamlIts own configuration: which model, which toolsets are enabled, what the context thresholds are
SOUL.mdThe persona and code-of-conduct file. What style this identity has, what principles it follows
MEMORY.md / USER.mdThis identity's long-term memory and its understanding of the user
memory_store.dbA separate SQLite memory database
Gateway processCan have its own gateway instance
Scheduled tasksIts own task list
Why multiple identities

Typical scenarios:

  • A company Discord server: the engineering channel wants an identity that reads code and can run commands; the support channel wants one that only reads docs and speaks politely. They must never share memory — the support-channel agent should know nothing about internal engineering discussions.
  • Personal use: one identity for work (serious, with code permissions), another for life (relaxed, with only calendar and search).
  • Cost control: important channels get the expensive, strong model; chit-chat channels get the cheap, small one.

The key phrase is “one process.” If every identity had to run as its own process, memory usage, deployment complexity, and coordinating scheduled tasks would all become problems.

2.2 Four-Level Routing and the “Specificity” Score

Routing rules live in the config file:

gateway:
  profile_routes:
    - name: server-default          # rule name
      platform: discord
      guild_id: "SERVER_ID"
      profile: server-profile       # which identity to route to

    - name: special-channel
      platform: discord
      guild_id: "SERVER_ID"
      chat_id: "CHANNEL_ID"
      profile: channel-profile

    - name: thread-route
      platform: discord
      chat_id: "CHANNEL_ID"
      thread_id: "THREAD_ID"
      profile: thread-profile

The specificity score

@dataclass(frozen=True)
class ProfileRoute:
    name: str
    platform: str
    profile: str
    guild_id:  Optional[str] = None      # server
    chat_id:   Optional[str] = None      # channel
    thread_id: Optional[str] = None      # thread
    enabled: bool = True

    @property
    def specificity(self) -> int:
        """Higher value = more specific match."""
        s = 0
        if self.guild_id:  s += 2        # server   weight 2
        if self.chat_id:   s += 4        # channel  weight 4
        if self.thread_id: s += 8        # thread   weight 8
        return s

hermes-agent/gateway/profile_routing.py

The score uses binary bit weights (2, 4, 8), so the four priority levels line up exactly with the table in the docs:

PriorityMatch conditionSpecificityMeaning
1platform + channel + thread14 = 2+4+8Pinned to one specific thread
2platform + channel6 = 2+4The whole channel
3platform + server2The whole server
4nothing matchesFall back to the default identity

(Strictly speaking the score is only a sort key; the actual priority 14 > 6 > 2 falls out naturally from the bit weights. The advantage of 2/4/8 over 1/2/3 is that no combination produces a duplicate score, so the ordering is always deterministic.)

Matching is conjunctive

def matches(self, platform, guild_id=None, chat_id=None,
            thread_id=None, parent_chat_id=None) -> bool:
    if not self.enabled:                                       return False
    if self.platform != platform:                              return False
    if self.thread_id and self.thread_id != thread_id:         return False
    if self.chat_id and self.chat_id != chat_id \
                    and self.chat_id != parent_chat_id:        return False    # ★
    if self.guild_id and self.guild_id != guild_id:            return False
    return True

The docs spell out the semantics:

“All configured discriminators are matched conjunctively (AND): every discriminator that the route declares must hold. …A route declaring both guild_id and chat_id requires both to match (a chat match alone does not satisfy a guild constraint).”

In plain terms: every configured condition is ANDed together: each condition a route declares must hold. …A route that declares both a server and a channel requires both to match (matching the channel alone does not satisfy the server constraint).

Parent-chain matching: Discord forums and threads

That parent_chat_id branch handles a hierarchy specific to Discord:

Discord's structure: server (guild) └─ channel ├─ messages posted directly in the channel └─ thread / forum post └─ messages posted inside the thread Question: you configured a routing rule for "a certain channel." Should a message posted in a "thread" under that channel match? Answer: yes. So the match checks both: chat_id == route.chat_id (posted directly in the channel) or parent_chat_id == route.chat_id (posted in a thread under that channel) In the docs' own words: “Routes keyed on a channel match both direct messages and messages in any thread/post whose parent is that channel.”

This detail is an example of “the abstraction has to follow the shape of the real world.”

With a flat “exact channel ID match” only, users would run into a deeply confusing behavior: talking in the channel gets one identity; open a thread in that same channel and you're suddenly talking to the default identity. In the user's mental model, the thread obviously belongs to the channel.

When the abstraction and the user's mental model disagree, the user calls it a bug — even if the code works exactly as designed.

2.3 When an Explicit Route Is Rejected

class ProfileRouteRejected(RuntimeError):
    """An explicit route matched a profile this gateway does not serve."""

In other words: an explicit route matched an identity that “this gateway doesn't serve.”

The scenario: you can run several gateway processes, each serving only a subset of identities (for resource isolation, say). A message may then match an identity that “isn't mine” — that's not a misconfiguration; the message just belongs to a different gateway.

Using a dedicated exception type rather than “silently fall back to the default identity” is the right call — a silent fallback would let the wrong identity handle the message, and the user would have a hard time noticing.

2.4 SOUL.md: The Persona File

Each identity has a SOUL.md. Its relationship to the system prompt looks like this:

System prompt assembly (agent/prompt_builder.py): ① SOUL.md ← this identity's persona and code of conduct ② MEMORY.md ← long-term memory index ③ USER.md ← understanding of the user ④ loaded skills ← skill header metadata (progressive disclosure, see Chapter 13) ⑤ context files ← AGENTS.md / .hermes.md (project-level conventions) ⑥ tool descriptions ← a description of every tool in the current toolset

This layering is the same kind of thing as CLAUDE.md in Claude Code, with one important difference: Hermes splits “persona,” “memory,” and “understanding of the user” into three separate files.

FileWho writes itNature of the content
SOUL.mdA humanIdentity definition. Rarely changes; changes are deliberate
MEMORY.mdThe agent itselfFacts it has learned. Grows continuously
USER.mdThe agent itselfIts model of the user. Continuously revised

This split is necessary for “self-improvement.”

If persona and memory were mixed in one file, the agent could alter the persona definition while writing memory — and the persona is something the agent should not modify on its own.

Split apart: the agent only writes to MEMORY.md and USER.md; SOUL.md is read-only. “The part that may modify itself” and “the part that may not” now have a physical boundary between them.

2.5 Session Routing: Continuity Across Platforms

Identity routing decides “which persona to use”; session routing decides “which conversation to continue.”

This is one of the gateway's most valuable capabilities: you can be halfway through a conversation on Slack at your desk, walk out the door, switch to Telegram on your phone, and pick up right where you left off, with the conversation context fully intact.

How it does that

The key is that the definition of "session" is not tied to a platform: The session key is determined by: · which user (a cross-platform user identity mapping) · which identity (profile) · which scope (DM / a certain channel / a certain thread) And not by: · which platform (× this should not affect which session it belongs to) · which client (× same) ★ So a DM from Slack and a DM from Telegram, as long as they are "the same user + the same identity + both DMs", hit the same session key → the same AIAgent instance → the same history

The related modules:

gateway/channel_directory.py    channel directory
gateway/pairing.py              ★ pairing (links accounts on different platforms to the same user)
gateway/mirror.py               mirroring
gateway/profile_routing.py      identity routing

pairing.py is the foundation of this mechanism: the system needs to know that “@alice on Slack” and “alice_w on Telegram” are the same person. The link is usually established with something like a one-time verification code.

2.6 Caching Agent Instances

The gateway caches AIAgent instances, reusing the same one for the same session. That has two consequences:

The upside: state carries over naturally

Message history, the context engine's bookkeeping, and compaction counters all live in the instance; nothing has to be rebuilt from disk every time.

The cost: the risk of state leaking across turns

The top of the main loop has a block of code dedicated to this, and the comment says it plainly:

“The gateway caches agents across user turns. Compression state is per-turn: carrying a prior in-place boundary forward would make a later uncompressed result look like a compacted transcript to gateway writers.”

In plain terms: the gateway keeps agents cached across user turns, but compaction state belongs to a single turn. If the in-place boundary from the previous turn were carried forward, a later uncompressed result would look, to the gateway's writers, like a transcript that had already been compacted.

There is another block that handles hot config reloads:

“Adopt any ~/.hermes/.env credential/base-url edits made since the last turn — a Settings save updates .env but not this worker's client, which was built at agent init. No-op when .env is unchanged.”

In plain terms: pick up any credential or base-URL edits made since the last turn — saving in the Settings UI updates the .env file, but not this worker process's client object, which was built when the agent was initialized. If .env hasn't changed, this step does nothing.

This is a problem unique to long-running processes: the user changes an API key in the Settings UI and expects it to take effect immediately. But the agent instance was created hours ago, and the client object it holds is still using the old key. So the start of every turn checks whether the config file has changed.

The core takeaway of this chapter

Once you stack “always-on + multiple entry points + multiple identities,” you get an entire class of problems that a command-line tool never faces:

· Should state be cleared between turns? Which parts cleared, which kept?
· How do config changes get hot-reloaded?
· If the same user comes in through different entry points, is it the same conversation?
· Which identity should handle a given message? What if the rules conflict?
· After a process restart, how do half-finished sessions recover?

The answers to these questions make up the vast majority of the 99 files in gateway/.