本章目录In this chapter
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 特有的层级结构:
这个细节体现的是「抽象要贴合真实世界的结构」。
如果只做扁平的「频道 ID 精确匹配」,用户会遇到一个非常困惑的行为:在频道里说话是一个身份,在这个频道开个线程说话就换成默认身份了。而用户的心智模型里,线程明显属于那个频道。
抽象和用户心智模型不一致时,用户会认为是 bug —— 即使代码完全按设计工作。
2.3 显式路由被拒绝的情况
class ProfileRouteRejected(RuntimeError):
"""An explicit route matched a profile this gateway does not serve."""
译:一条显式路由匹配到了一个「本网关不服务」的身份。
场景是:你可以跑多个网关进程,每个只服务一部分身份(比如为了资源隔离)。这时一条消息可能匹配到一个「不归我管」的身份 —— 这不是错误配置,只是这条消息该由另一个网关处理。
用一个专门的异常类型而不是「静默回落到默认身份」,是正确的选择 —— 因为静默回落会导致消息被错误的身份处理,而用户很难发现。
2.4 SOUL.md:人格文件
每个身份有一个 SOUL.md。它和系统提示词的关系是:
这个分层和 Claude Code 的 CLAUDE.md 是同类东西,但有一个重要区别:Hermes 把「人格」和「记忆」「用户认知」拆成了三个独立文件。
| 文件 | 谁写的 | 内容性质 |
|---|---|---|
SOUL.md | 人写的 | 身份设定。基本不变,改动是刻意的 |
MEMORY.md | 智能体自己写的 | 它学到的事实。持续增长 |
USER.md | 智能体自己写的 | 对用户的建模。持续修正 |
这个拆分对「自我改进」是必要的。
如果人格和记忆混在一个文件里,那么智能体在写记忆时就有可能改到人格设定 —— 而人格是不该由智能体自己修改的。
拆开之后:智能体只往 MEMORY.md 和 USER.md 里写,SOUL.md 是只读的。「可自我修改的部分」和「不可自我修改的部分」有了物理边界。
2.5 会话路由:跨平台的连续性
身份路由决定「用哪个人格」,会话路由决定「接哪场对话」。
这是网关最有价值的能力之一:你在电脑上用 Slack 聊到一半,出门换成手机上的 Telegram 继续聊,对话上下文完全连续。
它是怎么做到的
相关的模块:
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:
| Component | Description |
|---|---|
config.yaml | Its own configuration: which model, which toolsets are enabled, what the context thresholds are |
SOUL.md | The persona and code-of-conduct file. What style this identity has, what principles it follows |
MEMORY.md / USER.md | This identity's long-term memory and its understanding of the user |
memory_store.db | A separate SQLite memory database |
| Gateway process | Can have its own gateway instance |
| Scheduled tasks | Its own task list |
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:
| Priority | Match condition | Specificity | Meaning |
|---|---|---|---|
| 1 | platform + channel + thread | 14 = 2+4+8 | Pinned to one specific thread |
| 2 | platform + channel | 6 = 2+4 | The whole channel |
| 3 | platform + server | 2 | The whole server |
| 4 | nothing matches | — | Fall 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:
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:
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.
| File | Who writes it | Nature of the content |
|---|---|---|
SOUL.md | A human | Identity definition. Rarely changes; changes are deliberate |
MEMORY.md | The agent itself | Facts it has learned. Grows continuously |
USER.md | The agent itself | Its 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 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.
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/.