跳到主要内容

数据截至 (上游 commit 8d6cbee1b527)

入站:通道归一化、安全闸与会话路由

30 秒导读: 一条 WhatsApp/Telegram/Slack 消息不会直接变成 prompt。OpenClaw 让它走一条很长的漏斗——先去重防抖、再过安全闸(陌生人要配对码)、再把上百个平台字段压成一个统一的 MsgContext、再算出「这条消息属于哪个 agent 的哪个会话」、最后落盘。本章只讲这条漏斗;回复怎么发出去见 04


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

一句话定义: 入站层是 OpenClaw 的「收发室」——把十几个聊天平台各不相同的事件,翻译成同一种「一次 agent 回合」的输入。

它要解决的问题,用大白话讲是四件互相独立的事:

子问题白话不做会怎样
同一条消息可能被送两次平台重推、进程重启、多 worker 抢活agent 回两遍,甚至无限自答
用户会连发三条短消息"在吗" / "帮我看下" / "这个报错"agent 被触发三次,前两次答非所问
陌生人也能给 bot 发私信私信框对全世界开放任何人都能白嫖你的模型、读你的工具
平台字段长得都不一样Telegram 叫 message_id,Slack 叫 ts,Matrix 叫 event_id每加一个通道都要重写一遍上层逻辑

给谁用: 通道插件作者(写一个新平台接入)和运维者(配 dmPolicy、群策略、多 agent 绑定)。

一句话直觉: 把它想成海关。飞机(平台事件)落地,先查是不是重复入境记录,再查证件(allowlist/配对码),再把所有语言的表格换成统一表格(MsgContext),最后按护照分配到哪个柜台(agent + 会话键)。任何一关都可以把你原路遣返。


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

漏斗从左到右,任一段都可能提前结束。虚线是「丢弃」出口。

平台事件 入站漏斗 产出
┌──────────┐
│ Telegram │┐
│ WhatsApp │├─▶ ① 通道插件 ② 缓冲层 ③ 安全闸
│ Slack │┘ 把原始事件 去重 / 防抖 / DM 配对码 /
│ Matrix │ 收进来 回环抑制 allowlist /
└──────────┘ │ │ 群 @ 激活
▼ ▼ │
ingest/classify 丢重复 ┄┄┄┄┄▶ 丢/发配对码 ┄┄┄┄▶
│ │
▼ ▼
④ 归一化 ──────────────────────────▶ ⑤ 会话路由
MsgContext + envelope agent:<id>:<scope>
│ │
└───────────────┬─────────────────────┘

⑥ 落盘 + 派发
sessions.json / 转录(SQLite 护栏)


一次 agent 回合(见 05)

怎么读这张图: 从左到右是时间顺序,命中任何一个「丢」就没有下文了。安全闸的「发配对码」是一个特例——它回了一条消息但不跑 agent

各部件一句话职责:

部件干什么主要文件
通道插件起长连接/webhook,产出原始事件src/channels/plugins/types.adapters.ts
turn kernel统一的 ingest→classify→preflight→dispatch 骨架src/channels/turn/run-channel-turn.ts
ingress queueSQLite 持久化入站队列 + 墓碑去重src/channels/message/ingress-queue.ts
去重 / 防抖进程内消息 id 去重、同会话短消息合并src/auto-reply/reply/inbound-dedupe.tssrc/auto-reply/inbound-debounce.ts
安全闸dmPolicy / groupPolicy / 命令授权 / @ 激活src/channels/message-access/runtime.ts
配对8 位配对码 + allowFrom 存储(SQLite)src/pairing/pairing-store.ts
归一化上百字段 → MsgContextsrc/channels/inbound-event/context.ts
路由绑定匹配 → agent:<id>:<scope>src/routing/resolve-route.ts
会话存储sessions.json + 转录写入src/config/sessions/session-accessor.ts(装配)、session-accessor.sqlite-*.ts(实现)

turn kernel 的阶段名是这条漏斗的官方骨架,日志和诊断都按它打点(ChannelTurnStage,src/channels/turn/types.ts:434):

ingest → classify → preflight → resolve → authorize → assemble → record → dispatch → finalize

3. 通道插件的运行时契约与入站事件形状

这一节讲:写一个新通道,你到底要实现哪几个函数。

3.1 进程侧:插件负责「把事件收进来」

网关启动一个通道账号时,调用 ChannelGatewayAdapter.startAccount,交给插件一个上下文(src/channels/plugins/types.adapters.ts:311 ChannelGatewayAdapter(startAccount:312)、:211 ChannelGatewayContext)。

上下文里有四样关键东西:

字段用途
cfg / accountId / account已解析好的账号配置(多账号场景下 accountId 是隔离维度)
abortSignal网关要停这个账号时的取消信号
getStatus / setStatus往状态面板写「已连接/需登录」
channelRuntime核心能力面(reply / routing / session / pairing …),让插件不必 import 核心内部

插件在这里自己起 WebSocket、长轮询或 HTTP webhook。核心不规定你怎么收,只规定收到之后怎么交。

3.2 交给核心:ChannelTurnAdapter 四段式

真正的契约是这个适配器(src/channels/turn/types.ts:478 ChannelTurnAdapter),由 runChannelTurn 驱动(src/channels/turn/run-channel-turn.ts:143):

钩子必填干什么返回什么会导致丢弃
ingest(raw)把平台原始对象压成 NormalizedTurnInput返回 nulldrop: ingest-null
classify(input)这是消息?反应?生命周期事件?canStartAgentTurn: falsehandled
preflight(input, cls)早期权限/命令/媒体判定admission.kinddrop/handled
resolveTurn(...)产出完整的 turn(会话键、上下文、投递适配器)
onFinalize(result)收尾(ack、清理)

NormalizedTurnInput 是整条漏斗的最小公共形状(src/channels/turn/types.ts:56):

// 示意,非源码:字段名与源码一致
type NormalizedTurnInput = {
id: string; // 平台消息 id,后面去重就靠它
timestamp?: number;
rawText: string; // 原文
textForAgent?: string; // 给模型看的正文(可能已拼了 envelope/历史)
textForCommands?: string; // 给命令解析器看的"干净"正文
raw?: unknown; // 平台原始对象,兜底用
};

重点看 rawText / textForAgent / textForCommands 这三分法——这是后面 MsgContext 三个 Body 字段族的源头(见 §6.1)。

3.3 准入结果只有四种

kernel 用一个封闭联合表达「这条事件的命运」(src/channels/turn/types.ts:42-47 ChannelTurnAdmission):

kind含义会不会跑 agent会不会写会话
dispatch正常处理
observeOnly只观察(记历史、不回话)不会(直接换成预先备好的空结果)
handled通道已自行处理完(如按钮回调)不会不会
drop丢弃,可选择性记一条历史不会不会

observeOnly 的实现很干净:不在下游到处写 if (observeOnly),而是直接把派发结果替换成一份预先备好的空结果(resolveObserveOnlyDispatchResult,src/channels/turn/execution.ts:52-59;准入分支见 :94)。

被丢掉的消息未必完全消失——群聊里常常要把它记进「待用历史」,好让下次被 @ 时有上下文(recordDroppedChannelTurnHistory,src/channels/turn/run-channel-turn.ts:104)。它只在 admission.kind === "drop" 且历史开关打开时才记,并且正文为空就不记。

3.4 什么时候给平台回 ACK

有些平台(webhook 类)要求你尽快 ack,否则会重推。核心把「ack 时机」做成了通道声明的策略(src/channels/message/types.ts:436 ChannelMessageReceiveAckPolicy):

策略何时 ack适合谁
after_receive_record落进入站记录就 ack平台超时紧、重推激进
after_agent_dispatchagent 派发完 ack想让平台在崩溃时重推
after_durable_send回复真的发出去才 ack最强投递保证
manual插件自己控制特殊协议

4. 入站队列、去重、防抖与机器人回环抑制

这一节讲:为什么同一条消息不会被回两遍,而三条连发会被并成一次。

OpenClaw 在这里叠了四层,各管一件事,不要混:

解决什么作用域存哪
ingress queue崩溃后不丢事件 + 跨进程重复通道 + 账号SQLite
inbound dedupe同一条平台消息被处理两次进程内(全局单例)内存 TTL 缓存
inbound debounce用户连发被拆成多回合按会话键内存 buffer
bot loop guard两个 bot 互相刷屏会话内的「一对参与者」内存滑窗

4.1 ingress queue:用主键冲突做去重

队列表按「通道 + 账号」分区,队列名是一个 JSON 元组,避免 id 里含冒号时切错(queueNameForParts,src/channels/message/ingress-queue.ts:539-541):

return JSON.stringify([channelId, accountId]);

去重不靠额外的「见过没」表,而是直接让唯一键冲突说话(:572):

.onConflict((conflict) => conflict.columns(["queue_name", "event_id"]).doNothing())

插入影响行数 > 0 就是新事件(accepted),否则读回那一行、按它当前状态告诉调用方是 pending / claimed / completed / failed——结果类型见 :144 ChannelIngressQueueEnqueueResult

处理完之后行不会删,而是把 payload 抹成 "null" 只留墓碑,让后来的重复 id 仍能被认出来(「Completed/Failed ingress event tombstone retained for duplicate detection」,:74:84);清理交给 prune 按 TTL 和条数上限做(接口在 :245)。

并发方面有三个细节值得抄:

  • claim token:claim 时生成一个 randomUUID 写进行里(:849:903),后续 complete/release/fail 必须带同一个 token 才生效(接口 :214 起)。防的是「慢 worker 复活后误提交别人的活」。
  • lane key:同一条 lane(通常是同一个会话)在被占用时不再发第二条(:832),保证同会话串行。
  • 候选快照竞态:调用方传进来的候选 id 列表可能已经过期,所以先把已被 claim 的行的 lane 也加入阻塞集(:797)。

4.2 inbound dedupe:key 里为什么带 agent

进程内去重的 key 由三段拼成(src/auto-reply/reply/inbound-dedupe.ts:56 buildInboundDedupeKey):

[ 会话作用域 , 路由键(channel/to/account/thread) , messageId ]

会话作用域这一段有讲究:它不是完整会话键,而是被砍到只剩 agent:<id>(:41-54):

// src/auto-reply/reply/inbound-dedupe.ts:52-53
// 同一条物理消息,对同一个 agent 绝不能跑两次,
// 即使路由 bug 让它同时以 main 键和 direct 键出现。
return `agent:${parsed.agentId}`;

这一句就是这套设计的精华:去重的粒度定在「agent」而不是「会话」,于是会话键计算出错也兜得住,同时多 agent 各自回一次仍然合法。

缓存本身用 Symbol.for 挂在全局(:20-21),避免打包分片后出现两份缓存、让同一条消息从另一个副本溜进来。

三态领取协议也很朴素(:78 claimInboundDedupe):duplicate(TTL 内见过)/ inflight(正在处理)/ claimed(拿到了)。调用方在派发前领取,成功路径 commit,异常路径 release(调用点 src/auto-reply/reply/dispatch-from-config.prepare-context.ts:417)。

4.3 inbound debounce:合并连发,还要保序

防抖器按 key 缓冲,到点一次性 flush(src/auto-reply/inbound-debounce.ts:157 createInboundDebouncer)。难点不在「等一会」,而在同一个 key 的顺序不能乱

它的做法是给每个 key 维护一条 promise 链 keyChains(:159),工作一到就接在链尾(:223-226),于是定时器触发的 flush 和后到的立即消息都不会插队。

降级路径也保序:跟踪的 key 数超过 maxTrackedKeys(默认 2048,DEFAULT_MAX_TRACKED_KEYS :140)时,放弃缓冲,退化成「立即但仍串行」(:361)。

生效毫秒数按「显式参数 → 按通道 → 全局」逐级回退(:24 resolveInboundDebounceMs)。Telegram 是典型消费者,它按「会话 + 发件人 + lane」算防抖键,把媒体组、文本连发都合成一条再派发(extensions/telegram/src/bot-handlers.inbound-processing.ts:331-337,键构造函数在 bot-handlers.debounce-key.ts:10)。

4.4 bot 回环抑制:无向配对 + 冷却

两个 bot 互相回复会指数爆炸。守卫按「作用域 + 会话 + 无向的一对参与者」计数(recordChannelBotPairLoopAndCheckSuppression,src/channels/turn/bot-loop-protection.ts:25),默认阈值是 60 秒窗口内 20 次,超了冷却 60 秒(DEFAULT_PAIR_LOOP_GUARD_CONFIG,src/plugin-sdk/pair-loop-guard-runtime.ts:83-88)。

关键是触发时机:turn 执行层在真正派发前检查,一旦命中就直接返回 drop: bot-loop-protection(resolveBotLoopProtectionDrop,src/channels/turn/execution.ts:124-135,调用点 :130),连 agent 都不会启动。


5. 安全闸:把陌生 DM 当不可信输入

这一节讲:为什么陌生人给 bot 发私信,收到的是一串配对码而不是回答。

5.1 决策入口

所有闸门收敛在一个解析器 resolveChannelMessageIngress(src/channels/message-access/runtime.ts:553)。它把路由、发信人、命令、事件、@ 激活五类门合成一张「门图」,再投影成结论。

发信人这一门的判定(resolveDmGroupAccessWithLists,src/security/dm-policy-shared.ts:209;reason 码表 :55-65,群优先于 DM):

条件结果reason code
群聊 且 groupPolicy 允许allowgroup_policy_allowed
群聊 且 groupPolicy=disabledblockgroup_policy_disabled
群聊 且 allowlist 为空blockgroup_policy_empty_allowlist
DM 且 dmPolicy=disabledblockdm_policy_disabled
DM 且 dmPolicy=open 且列表含 *allowdm_policy_open
DM 且命中 allowlistallowdm_policy_allowlisted
DM 且 dmPolicy=pairing 且未命中pairingdm_policy_pairing_required
其他未命中blockdm_policy_not_allowlisted

注意 dmPolicy 的默认值就是 pairing(src/channels/message-access/runtime.ts:192src/security/dm-policy-shared.ts:137)——默认安全,不是默认开放。

配对批准存下来的那份 allowFrom 只在需要时才读:必须是直聊,且 dmPolicy 既不是 allowlist 也不是 open(shouldReadStore,src/channels/message-access/runtime.ts:64)。也就是说,你显式配了 allowlist,配对存储就完全不参与判定。配对存储的命中也只在 pairing 策略下有效(sender-gates.ts:96-97 的注释:"Pairing-store matches are only valid for pairing policy, never for open/allowlist modes")。

5.2 配对码流程

陌生人 DM ──▶ 安全闸判定 pairing


upsertChannelPairingRequest 写配对状态(SQLite)
生成 8 位码(去掉 I/L/O/0/1) │
│ │ TTL 1 小时
▼ │ 每账号最多 3 条待批
回一条"你的配对码是 XXXX" ◀──────────┘


机主执行 approve <code>


approveChannelPairingCode
删除请求 + 写入 allowFrom


该发信人以后走 dm_policy_allowlisted

对应源码锚点:

步骤符号位置
统一的「建请求 + 回消息」issuePairingChallengesrc/pairing/pairing-challenge.ts:55
生成/复用配对码upsertChannelPairingRequestsrc/pairing/pairing-store.ts:280
批准并落 allowlistapproveChannelPairingCodesrc/pairing/pairing-store.ts:410

几个不显然但重要的细节:

  • 码表刻意剔除易混字符:ABCDEFGHJKLMNPQRSTUVWXYZ23456789,没有 I/L/O/0/1,因为人要手抄(src/pairing/pairing-store.ts:24-25)。
  • 重复请求不换码:同一个 id + 同一个账号再来,返回原码且 created: false(:312 起),于是不会每条消息都回一次配对提示。
  • 配额是硬的:1 小时 TTL、每账号最多 3 条待批(CHANNEL_PAIRING_PENDING_TTL_MS / CHANNEL_PAIRING_PENDING_MAX,:27-28);超了直接返回空码且 created: false,静默丢弃(:332)。
  • 写是事务的:整个 upsert 包在 runOpenClawStateWriteTransaction 里(:290),批准走同一个事务路径(resolveChannelPairingRequest),先删请求再写 allowFrom,不会留下半截状态。

5.3 allowlist 是怎么存的

配对与 allowFrom 状态现在落在 SQLite 状态库里,按通道 × 账号成行(readChannelPairingStateFromDatabase / writeChannelPairingStateToDatabase,src/pairing/pairing-store-sqlite.ts:82:147;读 allowFrom 的便捷入口 readChannelAllowFrom,同文件 :174 附近)。

因为键要进数据库和文件名,通道 id 和账号 id 都过同一道清洗 normalizePairingKey(src/pairing/pairing-store-keys.ts:29-42):先小写化,再把 \ / : * ? " < > | 换成 _.. 也换成 _,空结果直接抛错而不是静默降级。这是防路径/键注入的地方——通道 id 和账号 id 都可能来自配置。对外是 safeChannelKey(:44)、safeAccountKey(:48)、resolveAllowFromAccountId(:56)。

5.4 群聊激活:mention 还是 always

群里如果每条消息都触发 agent,既吵又贵。所以群有一道额外的激活闸

requireMention 的解析顺序是三级回退(resolveGroupRequireMention,src/auto-reply/reply/groups.ts:46):

通道插件 groups.resolveRequireMention() ← 平台自己的规则(如 Slack 频道)
│ 未给出

Discord 专用回退(guild → channel 逐层找)
│ 未给出

通用配置:groups[<id>].requireMention → groups["*"].requireMention → 默认 true

拿到 requireMention 之后,判定本身只有两行(resolveMentionDecisionCore,src/channels/mention-gating.ts:101-103):

const effectiveWasMentioned =
params.wasMentioned || implicitMention || params.shouldBypassMention;
const shouldSkip = params.requireMention && params.canDetectMention && !effectiveWasMentioned;

这两行里藏了三个设计:

概念含义为什么需要
隐式 @回复了 bot、引用了 bot、bot 在这个 thread 里、平台原生标记用户在 thread 里追问不该再打一次 @
canDetectMention这个通道能不能可靠识别 @识别不了就不要因为「没检测到 @」而静默
命令旁路群里发控制命令且已授权,可跳过 @ 要求机主发 /status 不该被 @ 挡住

旁路条件写得很紧——必须同时满足「是群 + 要求 @ + 确实没被 @ + 这条消息里没有任何 @ + 允许文本命令 + 命令已授权 + 确实有控制命令」(src/channels/mention-gating.ts:164-171,在 resolveInboundMentionDecision :157 里)。隐式 @ 种类还可以按通道白名单收窄(allowedImplicitMentionKindsFromConfig,:160-163)。

运行时可以用 /activation mention|always 改这个会话的激活模式(parseActivationCommand,src/auto-reply/group-activation.ts:20),结果存在会话条目的 groupActivation 上(src/config/sessions/types.ts:539),并会改写注入给模型的那句说明(buildGroupIntro,src/auto-reply/reply/groups.ts:210)。

5.5 一条容易被忽略的安全线

群里的命令授权不继承 DM 的配对批准。 判定命令权限时,群场景用的是配置里的 allowFrom,而不是含配对存储的「有效」列表(commandOwnerAllowFrom,src/channels/message-access/runtime.ts:506-519):

if (!params.isGroup) {
return params.effectiveAllowFrom;
}
return params.command?.groupOwnerAllowFrom === "none" ? [] : params.configuredAllowFrom;

否则「某人私聊配对成功」就等于「他在任何群里都能发控制命令」。


6. 归一化:MsgContext 与 envelope

这一节讲:平台字段是怎么被压成一份统一上下文,以及模型最终看到的正文长什么样。

MsgContext 是入站的核心数据结构,字段很多(src/auto-reply/templating.ts:110),但可以按用途分成五族来记。

6.1 三个 Body:同一段话的三个版本

字段给谁看内容
Body兼容/展示原始正文(历史上可能已带 envelope)
BodyForAgent模型可能拼了 envelope、历史、上下文块(templating.ts:117)
BodyForCommands命令解析器干净文本,不含历史与发件人标签(templating.ts:140)

为什么必须分开:命令解析要是看到拼了历史的正文,历史里别人说过的 /reset 就会被当成本轮命令执行——这是一个典型的提示注入面。RawBodyCommandBody 的历史别名,源码里已标 @deprecated(templating.ts:126-131;CommandBody:134)。

兜底逻辑在 resolveCanonicalInboundText(src/auto-reply/reply/inbound-context.ts:42):「给模型的正文」按 agentText → BodyForAgent → CommandBody → rawText 取(:56-61),「给命令的正文」按 commandText → BodyForCommands → CommandBody → rawText 取(:63-68)——宁可少给上下文也不误喂 envelope。装配在 finalizeInboundContext(:200)里完成,两个旧名作为投影保留(:149-151)。

6.2 会话与身份族

字段含义
SessionKey本轮实际使用的会话键
AgentId当会话键不编码 agent(如全局会话)时的显式归属
RuntimePolicySessionKey沙箱/工具策略专用键(templating.ts:156)——会话键故意保持粗(如 DM 主会话)时,策略仍要细
ParentSessionKey父会话(会触发转录 fork,:159)
ModelParentSessionKey只继承模型/供应商覆盖,触发 fork(:162-165 的注释)

最后两个的区别是踩过坑的产物:想复用父会话的模型选择,不等于想复制父会话的转录。

6.3 消息 id 与引用族

字段用途
MessageSidMessageSidMessageSidFullMessageSidsMessageSidFirstMessageSidLast短别名 vs 平台完整 id;合并多条消息时保留首尾
ReplyToReplyToIdReplyToIdFullReplyToBodyReplyToQuoteTextReplyToSenderReplyChainReplyToIsQuote被引用消息的内容与作者,ReplyChain 是完整引用链
转发ForwardedFrom*ReplyToForwardedFrom*转发来源,以及「被引用的那条本身是转发」

MessageSid 之所以要配一个 Full,是因为防抖合并和短 id 场景下会话键里放的是别名,而回复投递需要平台完整 id。

6.4 可信 vs 不可信:一条硬边界

SupplementalContextFacts 是引用/转发/thread/群提示这类补充事实(src/auto-reply/templating.ts:65)。它们默认不可信,处理方式有三层:

  1. 可见性过滤:按可见性规则决定引用/转发/thread 是否进上下文(filterChannelInboundSupplementalContext,src/channels/inbound-event/context.ts:195)。
  2. 不可信通道隔离:用户可控的群提示词进 ChannelStructuredContext(templating.ts:302;旧名 UntrustedStructuredContext 已标 deprecated,:303-304),绝不GroupSystemPrompt(src/channels/inbound-event/context.ts:445-448 的注释:"User-controlled group prompt metadata must stay out of GroupSystemPrompt")。
  3. 特殊标记净化:模型特殊 token 有专门的清洗器(sanitizeModelSpecialTokens,src/security/external-content.ts:296),envelope 头也逐段净化(见 §6.5)。

另外 CommandAuthorized默认拒绝的:finalizeInboundContext 强制 === true 才算授权,上游忘填就是没授权(src/auto-reply/reply/inbound-context.ts:164-165;命令回合的授权则以 CommandTurn.authorized 覆盖,:166-172)。

6.5 envelope:发件人身份怎么进正文

模型看到的不是裸文本,而是带方括号头的一行(formatInboundEnvelope,src/auto-reply/envelope.ts:214formatAgentEnvelope :172),形如:

[Discord #general id:99 +3m Mon 2026-08-12 10:03:11] Alice: 帮我看下这个报错
└──头:通道 / 会话或发件人 / 距上条时长 / 时间戳──┘ └身份┘ └──正文──┘

组装规则:

位置来源备注
头·通道params.channel缺省 "Channel"(:173)
头·fromformatInboundFromLabel(:251)群带 id,DM 只在 id 与显示名不同才带
头·+Xm当前时间戳 − 上一条时间戳只有两者都在且非负才算
头·时间戳支持 local/utc/user/显式 IANA前缀加英文星期几,因为小模型算星期不可靠(:130 的注释)
正文前缀群用发信人标签,DM 用对端标签,自己发的标 (self)(:234)

安全细节: 头里每一段都过 sanitizeEnvelopeHeaderPart——换行压成空格、[ ] 换成 ( )(src/auto-reply/envelope.ts:60,使用点 :162:173:192)。不这么做,一个昵称叫 x] [System 的人就能伪造 envelope 头。同理,DM 的正文标签如果自身含冒号,会被替换成 (sender),避免伪造 别人: 的发言归属(:166-168)。

群聊历史是把每条旧消息各自套一遍 envelope 再拼进当前消息前面,典型用法见 extensions/discord/src/monitor/message-handler.context.ts

6.6 组装口:buildChannelInboundEventContext

通道插件不直接拼 MsgContext,而是交「事实包」——sender / conversation / route / reply / message / access / media / supplemental——由核心映射成字段(src/channels/inbound-event/context.ts:487-493)。映射规则集中在一处,几个值得记的:

// src/channels/inbound-event/context.ts:545、:550
SessionKey: params.route.dispatchSessionKey ?? params.route.routeSessionKey,
ModelParentSessionKey: params.route.modelParentSessionKey,

以及群主题只在非直聊时才设 GroupSubject(:558),Surface 优先于 Provider(:568)。最后统一交给 finalizeInboundContext 做净化和兜底。


7. 会话键与多智能体路由

这一节讲:「这条消息属于谁的哪个会话」是怎么算出来的。

7.1 键的形状

规范形状是三段起步:

agent:<agentId>:<scope>
└ 归属 ┘ └ 作用域(可以再带冒号)┘

具体形状由 dmScope 和会话类型决定(buildAgentPeerSessionKey,src/routing/session-key.ts:215):

场景dmScope
直聊,默认mainagent:<id>:main
直聊,按对端隔离per-peeragent:<id>:direct:<peerId>
直聊,按通道+对端per-channel-peeragent:<id>:<channel>:direct:<peerId>
直聊,按账号+通道+对端per-account-channel-peeragent:<id>:<channel>:<accountId>:direct:<peerId>
群 / 频道(不适用)agent:<id>:<channel>:<group|channel>:<peerId>
thread(追加后缀)<基础键>:thread:<threadId>(resolveThreadSessionKeys,:337)

默认 main 是有意的:同一个人从 WhatsApp 和 Telegram 找你,默认落在同一个会话里,记忆是连续的。想要隔离才调 dmScope

identityLinks 更进一步:配置里可以声明「这几个平台 id 是同一个人」,peer 键构造时会把 peerId 折叠成规范名(session-key.ts:222:275-280)。

7.2 绑定路由:八级 tier,先到先得

resolveAgentRoute 决定哪个 agent 接管(src/routing/resolve-route.ts:616)。它按固定优先级依次尝试,命中即停(tier 定义在 :745-763 起,matchedBy 的封闭集合在 :71-80):

顺序matchedBy匹配依据
1binding.peer精确对端(群 id / 用户 id)
2binding.peer.parent父对端 —— thread 继承所在频道的绑定
3binding.peer.wildcard某类对端的通配(如"所有群")
4binding.guild+rolesDiscord 服务器 + 成员角色
5binding.guildDiscord 服务器
6binding.teamSlack team
7binding.account具体账号
8binding.channel整个通道(accountId: "*")
default配置的默认 agent

从最具体到最宽泛,这是这类路由表唯一正确的排法。第 2 级尤其实用:Discord/Slack 的 thread 是独立 peer,没有它就得给每个 thread 单独配绑定。

性能上做了三层缓存,都以配置对象引用作为失效判据(WeakMap + agentsRef/bindingsRef 比对):agent 名字查找表(:129)、按通道账号预筛的绑定索引(:218)、完整路由结果(:220)。有 identityLinks 或开了 debug 日志时跳过结果缓存(:642)。

路由结果里还有一个字段 lastRoutePolicy:会话键等于主会话键时是 main,否则是 session——它决定「最后一次路由」信息写到哪个会话条目上(deriveLastRoutePolicy,:83;写入点 :699)。

7.3 legacy 键的迁移与分类

老版本写下的会话键没有 agent: 前缀。核心提供了三个小函数来分诊:

函数位置回答什么
parseAgentSessionKeysrc/sessions/session-key-utils.ts:259能拆成 agent:<id>:<rest>
classifySessionKeyShapesrc/routing/session-key.ts:159missing / agent / legacy_or_alias / malformed_agent
scopeLegacySessionKeyToAgentsrc/routing/session-key.ts:177只把 legacy_or_alias 补上 agent 前缀,其余原样返回

malformed_agent 这一态很关键——以 agent: 开头但拆不出三段的键,不会被当成 legacy 再加一层前缀,避免把坏键越修越坏。

另一条历史债在 main 会话上:早期所有写路径都硬编码 DEFAULT_AGENT_ID = "main",于是配了 ops agent 的用户磁盘上仍然躺着 agent:main:<mainKey>canonicalizeMainSessionAlias 把这些别名统一收敛到当前 agent 的规范键(src/config/sessions/main-session.ts:100,注释里挂了 issue #29683,:121)。

7.4 另一条键推导路径

src/config/sessions/session-key.ts 里还有一对函数,给的是「从 MsgContext 反推存储键」的路径,用在没有预先解析路由的地方:

  • deriveSessionKey(scope, ctx)(:19):全局 scope 直接返回 "global";有群解析结果就用群键;否则用规范化后的 From
  • resolveSessionKey(...)(:37):显式 SessionKey 走兼容规范化;直聊塌缩到 agent 主会话;群/频道保持隔离但加 agent 前缀。

判断是不是群用的是朴素的子串检查 raw.includes(":group:") || raw.includes(":channel:")(:60)——因为群键的形状是核心自己造的,这里可以这么判。


8. 会话落盘:store、转录与工作区

这一节讲:一次回合结束后,东西写到磁盘的哪里。

8.1 目录布局:一切按 agent 切开

<stateDir>/
agents/
<agentId>/
agent/ ← agent 私有目录(凭据、SQLite)
sessions/
sessions.json ← 会话条目存储
<sessionId>.jsonl ← 转录
workspace-<agentId>/ ← 非默认 agent 的默认工作区

对应函数:resolveDefaultSessionStorePath(src/config/sessions/paths.ts:33,内部用私有的 resolveAgentSessionsDir :12)、转录目录 resolveSessionTranscriptsDirForAgent(:25)、resolveAgentDir(src/agents/agent-scope-config.ts:440)、resolveAgentWorkspaceDir(:402)。

工作区的回退顺序值得记(resolveAgentWorkspaceDir,src/agents/agent-scope-config.ts:402):

情况工作区
agent 显式配了 workspace用它
是默认 agent 且有 agents.defaults.workspace用它
是默认 agent 且没配默认工作区目录
非默认 agent 且有 defaults<defaults>/<agentId> —— 自动加子目录
非默认 agent 且没配<stateDir>/workspace-<agentId>

非默认 agent 永远拿到自己的子目录,这就是「按 agent 隔离工作区」的落点。

会话文件路径还有一道容器校验:相对路径必须落在 sessions 目录内,绝对路径先 realpath 再算相对——两侧都 realpath 是为了让符号链接的会话目录仍然被围栏住(src/config/sessions/targets-path-validation.ts:52-53)。

8.2 store 条目怎么写

kernel 在派发前后调 recordInboundSession(src/channels/session.ts:29),核心实现是这两个(装配在 src/config/sessions/session-accessor.ts:168-172,实现在 session-accessor.sqlite-entry.ts):

函数位置写什么
recordInboundSessionMetasrc/config/sessions/session-accessor.sqlite-entry.ts:654MsgContext 派生的会话元信息
updateSessionLastRoutesrc/config/sessions/session-accessor.sqlite-entry.ts:695最近一次投递路由(channel/to/account/thread)

两者都故意不刷新 updatedAt,并各自留了注释说明原因(session-accessor.sqlite-entry.ts:686-687:698):

// Inbound metadata must not refresh activity timestamps; idle reset
// evaluation relies on updatedAt from actual session turns.

不这么做,一条被安全闸丢掉的消息也会让会话看起来「刚活跃过」,空闲重置就永远不触发。合并条目用 mergeSessionEntryPreserveActivity(src/config/sessions/types.ts:829)。

读写都先过 resolveSessionStoreEntryCore(src/config/sessions/store-entry.ts:280),它同时返回规范键、现有条目和一批 legacy 别名键,让后续写入顺手把旧键合并掉。别名认定很谨慎:大小写折叠的候选必须拿出仍然投递到同一个房间的证据才算别名(store-entry.ts:77-79 的注释 + isConfirmedLowercasedLegacyAlias :79),否则一个真正大小写不同的兄弟会话会被误删。

SessionEntry 字段极多(组合自 SessionEntryCore,src/config/sessions/types.ts:309 起),与本章相关的几类:

类别字段
身份sessionIdsessionFileupdatedAtlastInteractionAt
路由routedeliveryContextlastChannellastTolastAccountIdlastThreadId
群激活groupActivationgroupActivationNeedsSystemIntro(types.ts:539-540)
血缘parentSessionKeyspawnedByspawnedWorkspaceDirforkedFromParentspawnDepth

8.3 转录:JSONL 外壳,SQLite 护栏

转录文件在磁盘上仍是每会话一个 .jsonl、一行一条(文件名规则 paths.ts:279-280),但写入路径已经不是"直接往文件末尾 append"了:

机制干什么位置
appendTranscriptMessageSync每次追加都包在 SQLite 写事务里,先核对会话条目没被换代(sessionId / lifecycleRevision / writerRunId 不匹配就拒写)src/config/sessions/session-accessor.sqlite-transcript-write.ts:514-536
writer fence每个同步追加都继承并校验「被承认的写者」声明,写权被抢走时抛 SessionTranscriptWriterClaimReboundError同文件 :519:535
withTranscriptWriteLock读/追加共用一个 SQLite 写队列临界区同文件 :540
serializeJsonlLines批量序列化时保证每批以换行结尾——读方不会把两条记录拼成一条坏 JSONsrc/config/sessions/transcript-jsonl.ts:3-6

轮转发生在 /new/reset 这类生命周期操作上(resetSessionEntryLifecycle,src/config/sessions/session-accessor.sqlite-lifecycle.ts:176):先写新条目,再按 resetBoundaryReason 归档旧转录,给新转录写头。归档而不是删除,所以旧对话仍可回溯。


9. 巧妙之处(可以直接抄走的)

  1. 去重粒度定在 agent,不定在会话。 会话键算错是常见 bug,但「同一条消息对同一个 agent 只跑一次」是更硬的不变量,拿它当去重键就把上游 bug 兜住了(src/auto-reply/reply/inbound-dedupe.ts:52-53)。
  2. 用唯一键冲突当去重,用墓碑当记忆。 队列不额外维护「见过没」的表,插入冲突即重复;完成后保留行、抹掉 payload,既省空间又保留识别能力(src/channels/message/ingress-queue.ts:592:74)。
  3. 防抖器用 promise 链保序。 每个 key 的工作都接在该 key 的链尾,定时 flush 与立即消息都无法插队(src/auto-reply/inbound-debounce.ts:159:223-226)。
  4. envelope 头做字符净化。 方括号换圆括号、换行压空格——一行代码挡住「靠改昵称伪造系统头」这类注入(src/auto-reply/envelope.ts:60)。
  5. 不可信群提示词单独开一条通道。 用户可控的群描述永远进 ChannelStructuredContext,绝不流进 GroupSystemPrompt(src/channels/inbound-event/context.ts:445-448)。
  6. 群命令权限不继承 DM 配对结果。 一个三元表达式挡住了一整类越权(src/channels/message-access/runtime.ts:515-518)。
  7. 元信息写入不刷新活动时间。 让「空闲重置」这类基于时间的策略保持正确(src/config/sessions/session-accessor.sqlite-entry.ts:686-687)。
  8. 配对码剔除易混字符。 人要手抄,I/L/O/0/1 就是错误率(src/pairing/pairing-store.ts:24-25)。
  9. observeOnly 用预置空结果实现。 不在下游到处加分支,而是把派发结果换掉(src/channels/turn/execution.ts:52-59)。
  10. 零回复的可见派发会告警。 一次本应可见的回合最后什么也没发,会打 zero-count-visible-dispatch 警告(src/channels/turn/execution.ts:104-121)。

10. 边界与局限

  • 进程内去重和防抖不跨进程。 只有 ingress queue 是持久化的;inbound-dedupeSymbol.for 全局单例(inbound-dedupe.ts:20-21)、inbound-debounce 是纯内存 Map。多网关进程同时消费同一个账号时,跨进程互斥要靠队列的 claim token 和 lane key。
  • 防抖上限是 2048 个 key。 超过就退化为不缓冲(仍保序),没有 LRU 淘汰(src/auto-reply/inbound-debounce.ts:140:361)。
  • 配对配额可能静默丢弃。 每账号 3 条待批已满时,upsertChannelPairingRequest 返回空码且 created: false,发信人拿不到任何提示(src/pairing/pairing-store.ts:332)。
  • @ 识别能力由通道决定。 canDetectMention 为 false 时,requireMention 直接失效——宁可多回也不静默(src/channels/mention-gating.ts:103)。
  • resolveSessionKey 用子串判群。 raw.includes(":group:") 依赖群键形状由核心自造这一前提(src/config/sessions/session-key.ts:60)。
  • 会话存储正在向 SQLite 迁移。 读写都要过 session-accessor.sqlite-* 一族的护栏;sessions.json.jsonl 仍是磁盘上的外壳,但直接编辑它们不再安全——写权有 fence,外行追加会被拒。
  • 本章不覆盖出站。 分块、排队、投递与重试见 04;agent 循环本身见 05;工具与沙箱边界见 06

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

主题文件路径符号名
turn 骨架驱动src/channels/turn/run-channel-turn.tsrunChannelTurnrecordDroppedChannelTurnHistory
turn 执行(准入/空结果/告警)src/channels/turn/execution.tsresolveObserveOnlyDispatchResultresolveBotLoopProtectionDrop
准入/事实/阶段类型src/channels/turn/types.tsChannelTurnAdmissionNormalizedTurnInputChannelTurnAdapterChannelTurnStage
bot 回环守卫src/channels/turn/bot-loop-protection.tsrecordChannelBotPairLoopAndCheckSuppression
回环阈值默认值src/plugin-sdk/pair-loop-guard-runtime.tsDEFAULT_PAIR_LOOP_GUARD_CONFIG
持久入站队列src/channels/message/ingress-queue.tsqueueNameForPartsclaimNextcompleteprune
ack 时机策略src/channels/message/types.tsChannelMessageReceiveAckPolicy
进程内去重src/auto-reply/reply/inbound-dedupe.tsbuildInboundDedupeKeyclaimInboundDedupe
去重接入点src/auto-reply/reply/dispatch-from-config.prepare-context.tsclaimInboundDedupe 调用处(:427)
入站防抖src/auto-reply/inbound-debounce.tscreateInboundDebouncerresolveInboundDebounceMs
安全闸总入口src/channels/message-access/runtime.tsresolveChannelMessageIngressshouldReadStorecommandOwnerAllowFrom
DM/群准入判定src/security/dm-policy-shared.tsresolveDmGroupAccessWithListsDM_GROUP_ACCESS_REASON
配对挑战src/pairing/pairing-challenge.tsissuePairingChallenge
配对码存储src/pairing/pairing-store.tspairing-store-sqlite.tspairing-store-keys.tsupsertChannelPairingRequestapproveChannelPairingCodereadChannelPairingStateFromDatabasenormalizePairingKey
@ 激活判定src/channels/mention-gating.tsresolveInboundMentionDecisionresolveMentionDecisionCore
requireMention 解析src/auto-reply/reply/groups.tsresolveGroupRequireMentionbuildGroupIntro
/activation 命令src/auto-reply/group-activation.tsparseActivationCommandnormalizeGroupActivation
入站上下文类型src/auto-reply/templating.tsMsgContextFinalizedMsgContextSupplementalContextFacts
上下文组装src/channels/inbound-event/context.tsbuildChannelInboundEventContextfilterChannelInboundSupplementalContext
上下文兜底与净化src/auto-reply/reply/inbound-context.tsfinalizeInboundContextresolveCanonicalInboundText
prompt envelopesrc/auto-reply/envelope.tsformatInboundEnvelopeformatAgentEnvelopeformatInboundFromLabelsanitizeEnvelopeHeaderPart
绑定路由src/routing/resolve-route.tsresolveAgentRoutederiveLastRoutePolicy
会话键构造src/routing/session-key.tsbuildAgentPeerSessionKeybuildAgentMainSessionKeyresolveThreadSessionKeys
键分类与迁移src/routing/session-key.tsclassifySessionKeyShapescopeLegacySessionKeyToAgent
键解析原语src/sessions/session-key-utils.tsparseAgentSessionKey
上下文→存储键src/config/sessions/session-key.tsderiveSessionKeyresolveSessionKey
main 别名收敛src/config/sessions/main-session.tscanonicalizeMainSessionAlias
会话写入src/config/sessions/session-accessor.tssession-accessor.sqlite-entry.tsrecordInboundSessionMetaupdateSessionLastRoute
键/别名解析src/config/sessions/store-entry.tsresolveSessionStoreEntryCoreisConfirmedLowercasedLegacyAlias
转录写入src/config/sessions/session-accessor.sqlite-transcript-write.tstranscript-jsonl.tsappendTranscriptMessageSyncwithTranscriptWriteLockserializeJsonlLines
会话路径围栏src/config/sessions/paths.tstargets-path-validation.tsresolveDefaultSessionStorePathresolveSessionTranscriptsDirForAgent
agent 目录与工作区src/agents/agent-scope-config.tsresolveAgentDirresolveAgentWorkspaceDir
通道插件契约src/channels/plugins/types.adapters.tsChannelGatewayAdapterChannelGatewayContext
通道能力声明src/channels/plugins/types.core.tsChannelCapabilities
会话类型src/config/sessions/types.tsSessionEntryCoremergeSessionEntryPreserveActivity