背景
当前 Lark bot 的生产路径(Lark → NyxID → Aevatar /api/webhooks/nyxid-relay)存在两条 inbound 主干做同一件事 的架构问题。chat_type 丢失导致 /daily-report 在 1:1 私聊里被错误拒绝(用户报的 bug),只是这个架构问题的一个症状。
具体症状:1:1 私聊里发 /daily-report,bot 回复"请在和机器人 1:1 私聊里发送这个命令,仅支持私聊创建/运行 Day One agent,群聊里不可用。" —— 是 LLM 幻觉,不是 hardcoded 文案。
相关 PR:#323 (在 legacy 路径上加确定性 slash flow,但 relay 路径根本不走这条路径,修复在生产上没生效)。
根因(架构层面)
路径 A(legacy,直接 Lark webhook,已 410 Gone):
Lark webhook → LarkChannelAdapter.ParseMessage → ChatActivity
→ ConversationGAgent.HandleInboundActivityAsync(actor,事实源)
→ LarkConversationTurnRunner.RunInboundAsync
→ [workflow resume | slash flow | agent builder | LLM fallback]
→ IChannelOutboundPort.SendAsync
路径 B(生产,Nyx relay):
Lark → Nyx → POST /api/webhooks/nyxid-relay
→ HandleRelayWebhookAsync(HTTP endpoint 直接承载业务编排)
→ 手搓 NyxIdChatGAgent + 手搓 subscribe + 手搓 reply 累积 + 手搓 classify
→ NyxIdApiClient.SendChannelRelayTextReplyAsync
路径 B 的违规点:
HTTP endpoint 承载业务编排 → 违反 CLAUDE.md "API 仅做宿主与组合,不承载业务编排"
绕过 ConversationGAgent → 失去 dedup、conversation actor 边界、ConversationTurnCompletedEvent 投影链路、重试策略,违反 "Actor 即业务实体"、"事实源唯一"
直接选 NyxIdChatGAgent 作处理器 → 跳过 slash/workflow-resume/agent-builder 所有确定性短路
重新实现 reply 累积 + 超时 + 错误分类 → 和 ConversationPlatformReplyService / IChannelOutboundPort 两套,违反 "单一主干,插件扩展"、"删除优先"
payload 不落到 ChatActivity → chat_type / mentions / scope 全丢;这就是这次的症状(LLM 不知道 chat_type,system prompt 写了"不是 p2p 就让用户去 DM",LLM 保守地假设不是 p2p 就拒绝)
NyxID relay 事实调研(~/Code/NyxID)
当前支持 5 个平台 :Telegram / Discord / Lark / Feishu / Slack(Slack 是 2026-03 新加)
relay 内核是 platform-neutral 的 :单一 CallbackPayload struct(所有平台共享),单一 forward_to_agent() 转发;channel_relay_service.rs:28-49, 274-362
conversation.type 已在 NyxID 侧标准化为 4 个枚举值:"private" / "group" / "channel" / "device",各平台 adapter 内部做映射
设计文档明确支持扩展新平台(WhatsApp / LINE 等):docs/CHANNEL_BOT_RELAY.md:402-423
Aevatar 侧入口只有一个 endpoint,POST /api/webhooks/nyxid-relay,platform 字段在 payload 里指明来源
结论 :Aevatar 侧的 relay transport 必须是 channel-neutral 的,不能和 Lark 绑死。
目标架构
Nyx relay 降级为一个 channel-neutral transport adapter ,和 direct webhook 并列,汇入同一条主干:
[Lark direct webhook | Telegram direct webhook | ... | Nyx relay webhook(channel-neutral)]
↓ (boundary:解析 + 认证)
IChannelTransport adapter → ChatActivity(带完整 ConversationReference.Scope)
↓
ConversationGAgent(单一主干,按 CanonicalKey 定位,事实源)
↓
IConversationTurnRunner.RunInboundAsync
├─ workflow resume(/approve /reject /submit)
├─ slash command flow(/daily-report 等,确定性短路)
├─ agent builder card flow
└─ IConversationReplyGenerator(LLM fallback,通过 NyxIdChatGAgent 作为实现)
↓
ConversationTurnCompletedEvent → Projection
↓
IChannelOutboundPort.SendAsync
└─ 具体平台实现内部按 transport kind 挑:direct API / Nyx channel-relay/reply
核心不变量 :
ChatActivity 是 inbound 唯一契约 ,transport 怎么进来是 adapter 私事
ConversationGAgent 是 inbound 事实源 ,没有第二条绕过它的路径
ConversationReference.Scope 是 chat_type 的权威表达 ,字符串 "p2p"/"group" 只在 runner → tool metadata 翻译时出现
Endpoint 只做 shim :auth → adapter.parse → dispatch ChatActivity → 202
命名空间对齐(Phase 0 前置)
先说结论 :现有 `Aevatar.GAgents.Channel.Lark` 命名混淆了两个正交 concern——post-refactor 后 Lark 不是 channel,是 platform 。要让代码模块语义 self-evident(不靠 ADR 解释),先把命名轴理清楚。
两个正交 concern
轴
含义
数量
Channel(传输)
消息怎么进出 Aevatar
目前 1 个:NyxID relay
Platform(平台)
具体平台的渲染 / PII / streaming 规则
5 个:Lark / Telegram / Slack / Discord / WeChat
目录 + 命名空间对齐
```
agents/
├── channels/ ← 传输层(ingress + egress)
│ └── Aevatar.GAgents.Channel.NyxIdRelay/ ← 唯一的 Channel,5 个平台共享
│ ├── NyxIdRelayTransport.cs
│ ├── NyxIdRelayOutboundPort.cs
│ ├── NyxIdRelayCallbackPayload.cs
│ ├── NyxIdRelayJwtValidator.cs
│ └── ...
│
├── platforms/ ← 平台渲染 / PII / streaming
│ ├── Aevatar.GAgents.Platform.Lark/ ← rename 自 Channel.Lark
│ │ ├── LarkMessageComposer.cs
│ │ ├── LarkStreamingHandle.cs
│ │ ├── LarkPayloadRedactor.cs
│ │ └── LarkOutboundMessage.cs
│ ├── Aevatar.GAgents.Platform.Telegram/ ← (future, #262 应对齐)
│ ├── Aevatar.GAgents.Platform.Slack/ ← (future)
│ └── ...
└── ...
```
具体 rename 动作
`Aevatar.GAgents.Channel.Lark` → `Aevatar.GAgents.Platform.Lark` (原 PR [Channel RFC] Implement Lark channel adapter #288 刚合的 project,csproj + namespace + 所有 reference 一起改)
目录 `agents/channels/Aevatar.GAgents.Channel.Lark/` → `agents/platforms/Aevatar.GAgents.Platform.Lark/`
proposed `Channel.NyxRelay` 命名改为 `Channel.NyxIdRelay` ,和 `NyxIdLLMProvider` / `NyxIdApiClient` / `NyxIdChatGAgent` 一系列 `NyxId` 前缀风格一致
Telegram draft PR [Channel RFC] Add Telegram channel adapter #289 ([Channel RFC] Telegram adapter migration (shim → full TelegramChannelAdapter) #262 ) 同步对齐 :如果已经用 `Channel.Telegram` 命名,同步改成 `Platform.Telegram`——越早定越少返工
solution 文件分流 :`aevatar.channel.slnf` 分成 `aevatar.channels.slnf`(transport)+ `aevatar.platforms.slnf`(渲染),不再混合
类名保留 platform 前缀 (`LarkMessageComposer` / `TelegramMessageComposer`),不精简——`IMessageComposer` factory 场景下 consumer 需要 disambiguate
为什么不靠 ADR 解释
如果新 contributor 需要读 ADR-0013 才理解 "Channel.Lark 其实是 Lark 渲染层",说明命名失败。正确目标:
看到 `channels/Channel.NyxIdRelay/` → 立即知道这是传输通道
看到 `platforms/Platform.Lark/` → 立即知道这是 Lark 平台的渲染
"加 Telegram 支持从哪下手?" 的答案从目录结构直接看出:复制 `platforms/Platform.Lark/` 改成 Telegram 就对了
这是 CLAUDE.md "项目名=命名空间=目录语义" 原则的直接体现。ADR-0013 只需要解释 architecture decision,不需要用文字补回命名失败的语义。
具体改动清单
A. 新增 `agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/`(channel-neutral,唯一 transport)
`NyxIdRelayTransport.cs` — `IChannelTransport`,解析 NyxID `CallbackPayload` → `ChatActivity`
`ChannelId` = `payload.platform`("lark" / "telegram" / "slack" / ...)
`ConversationScope` 从 `conversation.type` exhaustive map:"private" → `DirectMessage`,"group" → `Group`,"channel" → `Channel`(proto 已有此 enum 值,见 `chat_activity.proto`),"device" → 单独处理或走 `DeviceEventEndpoints` 那条线
`RawPayloadBlobRef` 保留 hash-only 语义(`-raw:`),不承载 reply 凭证或结构化 metadata ——这类需求走下方 `TransportExtras`
`NyxIdRelayOutboundPort.cs` — `IChannelOutboundPort`,`SendAsync` 调 `POST /api/v1/channel-relay/reply { message_id, content }`,内部 dispatch 到 per-platform `IMessageComposer`(按 `ChatActivity.Platform`)
`NyxIdRelayCallbackPayload.cs` — NyxID relay 契约 DTO(和 NyxID `CallbackPayload` struct 一一对应)
`NyxIdRelayConversationTypeMap.cs` — 4 个枚举值 → `ConversationScope` 的 exhaustive 映射
`NyxIdRelayAuthValidator.cs` — 多层认证校验 ,从 `Aevatar.GAgents.NyxidChat` 的 `NyxRelayJwtValidator` 演化而来:
JWT 校验 (`X-NyxID-User-Token`,现有)
HMAC signature 校验 (`X-NyxID-Signature`)—— Nyx callback 侧已在带(`backend/src/services/channel_relay_service.rs`),Aevatar 自己的 device 通道也把 HMAC 当硬边界(`DeviceEventEndpoints.cs`)。只留 JWT 校验 = 信任边界退化
`X-NyxID-Message-Id` replay 检查 (和 RFC §5.7 dedup pipeline 打通)
共享 secret 从 Aevatar secret store 读(ADR-0012 credential boundary 内);需先跑 `channel_relay_service.rs` 确认 HMAC input 格式 + 密钥分发路径
`ServiceCollectionExtensions.cs`
A.1 数据层扩展(Finding 2 + 5 引入的必要支撑)
`ChannelBotRegistration` state 扩展 :当前 runner 按 `activity.Bot.Value` 查 registration(`LarkConversationTurnRunner.cs` + `ChannelBotRegistrationQueryPort.cs`),但 Nyx callback 给的是 `agent.api_key_id` / `conversation.id`,不是 Aevatar registration id,没映射表统一主干一进 runner 就失联。加:
proto 加字段:`string nyx_channel_bot_id = N;` `string nyx_agent_api_key_id = M;`(字段号取下一个可用)
`ChannelBotRegistrationProjector` 加基于 nyx identifiers 的二级索引
新 query port `IChannelBotRegistrationQueryByNyxIdentityPort`:`GetByNyxAgentApiKeyIdAsync(...)` / `GetByNyxChannelBotIdAsync(...)`
runner 解析 registration 时优先走 nyx identifiers(从 `TransportExtras` 取,见下),fallback 到 `Bot.Value`
`ChatActivity` proto 加 `TransportExtras` 字段 :`RawPayloadBlobRef` 只是 string hash,proto 里没有 blob store / read path 能 dereference。reply 凭证 / 平台 identifier 这类运行时数据流要独立字段:
```protobuf
message ChatActivity {
// ... existing fields ...
string raw_payload_blob_ref = N; // 保留,仅哈希引用(forensic)
TransportExtras transport_extras = M; // 新字段
}
message TransportExtras {
string nyx_message_id = 1; // reply 关联
string nyx_agent_api_key_id = 2; // registration lookup(见上)
string nyx_platform = 3; // composer dispatch
string nyx_conversation_id = 4; // Nyx 侧会话 id(原始)
// 未来其他 transport-specific 扩展
}
```
`RawPayloadBlobRef` 职责不变(forensic hash)。未来真要做 blob store 回查,另做 `IRawPayloadBlobStore` port + backing store。
决策点 1 (Finding 4 修正):`conversation.type = "channel"`(Telegram 公告频道 / Slack public channel)业务语义
选 A:视为 `Group` ← 拒绝 。proto 已经有 `ConversationScope.Channel`(`chat_activity.proto`);把 "channel" 压成 `Group` 是主动丢语义
选 B:使用 `ConversationScope.Channel` + 修翻译链路 ✅
`NyxIdRelayConversationTypeMap` 的 "channel" → `ConversationScope.Channel`
runner / `BuildReplyMetadata` 的 `chat_type` 派生改为 exhaustive 覆盖全部 `ConversationScope` 值(当前 `LarkConversationTurnRunner.cs` 把除 `Group` 外一切都当 `p2p`,需修)
AgentBuilder slash 的 "`chat_type != p2p`" 硬检查改为 `Scope in {DirectMessage}` 才允许
B. Endpoint 瘦身
`agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs` :
`HandleRelayWebhookAsync` 改成只做 auth + adapter dispatch + dispatch ChatActivity + 202 ,所有业务剥离
删除:`RelayReplyAccumulator`、`FinalizeRelayReplyAsync`、`TryExtractLlmError`、`BuildRelayDiagnostic`、`ClassifyError`(迁到 runner/reply generator 层,或保留在 Studio stream endpoint 那条线但不再复用给 channel relay)
删除:`RelayMessage` 及子类(迁到 `NyxRelayCallbackPayload`)
最终 relay 相关代码完全搬离 NyxidChat project
`agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs` :
`HandleCallbackAsync` 已经是 410 Gone 空壳,直接删
C. 主干编排统一
`agents/Aevatar.GAgents.ChannelRuntime/LarkConversationTurnRunner.cs` :
改名 `ChannelConversationTurnRunner`(对多平台通用,不带 "Lark" 前缀)
`IConversationReplyGenerator` 的 LLM 实现指到 `NyxIdChatGAgent`(走 IActorRuntime + subscribe),以后 relay 路径上的 LLM 调用也从这里走
`BuildReplyMetadata` 把 `ConversationReference.Scope` 翻译成 `ChannelMetadataKeys.ChatType` 字符串,下游 tool 正确拿到 `p2p` / `group`
注入 channel context system message(platform / chat_type / sender / conversation),借用 `ConnectedServicesContextMiddleware` 机制做一个 `ChannelContextMiddleware`
`agents/Aevatar.GAgents.Channel.Runtime/Conversation/ConversationGAgent.cs` :
不改。它已经是正确的单一主干,只是目前生产上没人调。让 relay adapter 的 inbound 也落到它这里
D. LLM reply path(Finding 1 修正后的事件驱动形态)
原方案把"`IConversationReplyGenerator` 里 subscribe `TextMessageContentEvent` / `TextMessageEndEvent` 累积 reply"的 ambient-wait 反模式从 HTTP endpoint 搬进 actor turn——仍是反模式 :actor turn 不允许 block 在 cross-actor event 上。正确形状是事件驱动三段式 :
```
① ConversationGAgent turn(瞬时完成):
runner 路由 inbound → 判定需要 LLM fallback →
emit NeedsLlmReplyEvent { correlation_id, conversation_ref, prompt, scope,
transport_extras }
→ commit state → turn 结束返回(不等 reply)
② LlmReplyGeneratorActor(subscribe 层):
订阅 NeedsLlmReplyEvent →
内部调 NyxIdChatGAgent 生成 reply →
emit LlmReplyReadyEvent { correlation_id, content, terminal_state }
③ OutboundDispatcher(可以是 ConversationGAgent 的第二个 handler):
订阅 LlmReplyReadyEvent →
调 IChannelOutboundPort.SendAsync(凭 transport_extras 里的 reply 凭证回写)
```
Phase 1 契约加 :
`NeedsLlmReplyEvent` proto
`LlmReplyReadyEvent` proto
`IConversationReplyGenerator` 接口语义从 "sync 生成 reply 返回字符串" 改为 "发布 `NeedsLlmReplyEvent` + correlation_id"
outbound dispatcher 接口 / 注册机制
UX 影响 :从 "turn 同步等 LLM" 变为 "turn 结束后异步 reply 到达"。但当前 HTTP endpoint 已经是异步 ——`HandleRelayWebhookAsync` 返 202 然后靠 ambient TCS 等 reply。新模式只是把异步边界从 HTTP layer 搬到 actor event layer,本来就该这样,且符合 RFC §5.8 durable inbox 语义。
删除的旧逻辑 :endpoint 里的 `RelayReplyAccumulator` / `FinalizeRelayReplyAsync` / `TryExtractLlmError` / `ClassifyError` 逻辑全部消掉,不在 runner 层复刻。
E. 文档 + ADR + 门禁
新 ADR:`docs/decisions/0013-unified-channel-inbound-backbone.md`
Nyx relay 降级为 channel-neutral transport adapter
`ConversationGAgent` 是唯一 inbound 主干
supersede ADR-0011 里 "relay 直接到 NyxIdChat" 的隐含设计
更新 `docs/canon/aevatar-channel-architecture.md`:inbound 拓扑图换成上述
新增 CI 门禁:禁止 `IActorRuntime.CreateAsync` 出现在 channel relay/webhook 代码里(只允许 runner / reply generator / Studio 对话 endpoint 用)—— 防止 "HTTP endpoint 直连业务 actor" 反模式再长
F. 测试
`NyxRelayTransportTests`:payload → ChatActivity,覆盖 `conversation.type` 全部 4 个取值(private / group / channel / device)
`NyxRelayConversationTypeMapTests`:各 type → ConversationScope 映射
`NyxRelayOutboundPortTests`:SendAsync → 正确的 `POST /api/v1/channel-relay/reply` 请求
`ChannelConversationTurnRunnerTests`:relay-originated ChatActivity 走 slash flow 短路(`/daily-report` 在 p2p 下不落 LLM)
端到端:relay webhook → 202 + 异步 outbound reply 经 Nyx channel-relay/reply endpoint,覆盖 slash / agent-builder / LLM fallback
删除 `NyxIdChatEndpointsCoverageTests` 中 relay 业务逻辑部分,保留 Studio stream 相关
`ConversationGAgent` 现有测试覆盖补齐:relay-originated inbound dedup、ConversationTurnCompletedEvent 投影
分阶段落地
每步都能独立 `build/test` 过 + 可回滚:
Phase 0(命名空间对齐) :执行上节"命名空间对齐"里的 rename 动作——`Channel.Lark` → `Platform.Lark`,新建 `agents/platforms/` 目录,solution 文件分流。独立 PR,纯 rename 零业务变更,为后续 phases 让出干净命名空间(`Channel.` = 传输 / `Platform. ` = 渲染)。也对齐 Telegram draft PR [Channel RFC] Add Telegram channel adapter #289 避免返工。
Phase 1(契约) :
`ChatActivity` proto 加 `TransportExtras` message(nyx_message_id / nyx_agent_api_key_id / nyx_platform / nyx_conversation_id)——Finding 5
`ChannelBotRegistration` proto 加 `nyx_channel_bot_id` / `nyx_agent_api_key_id` 字段 + projection 二级索引 + `IChannelBotRegistrationQueryByNyxIdentityPort`——Finding 2
`NeedsLlmReplyEvent` / `LlmReplyReadyEvent` proto 定义——Finding 1
transport-neutral `OutboundDeliveryContext` 抽象
outbound credential 方案待定 :依赖 NyxID #469 (short-lived per-callback reply-token)确认可行性——Finding 3,暂不在本 issue 做决定
Phase 2(adapter + outbound) :
实现 `Aevatar.GAgents.Channel.NyxIdRelay` project(`NyxIdRelayTransport` / `NyxIdRelayOutboundPort` / `NyxIdRelayCallbackPayload`)
`NyxIdRelayAuthValidator`:JWT + HMAC + message-id replay 三重校验——Finding 6
endpoint 暂不切,只有测试覆盖
Phase 3(runner 通用化 + 事件驱动 LLM reply) :
runner 改名 `ChannelConversationTurnRunner`;`BuildReplyMetadata` 按 exhaustive `ConversationScope` 映射 `ChatType`——Finding 4
移除原 `IConversationReplyGenerator` 同步语义,改为发布 `NeedsLlmReplyEvent` + correlation——Finding 1
新 `LlmReplyGeneratorActor` 订阅 `NeedsLlmReplyEvent`,内部调 `NyxIdChatGAgent`,产出 `LlmReplyReadyEvent`
outbound dispatcher 订阅 `LlmReplyReadyEvent` → `IChannelOutboundPort.SendAsync`
runner 注入 channel context system message
Phase 4(切流量) :`HandleRelayWebhookAsync` 改为 shim(auth → transport.parse → dispatch → 202);删除 endpoint 里累积 / classify / 直发 reply 老逻辑;生产 on
Phase 5(清理) :删 `ChannelCallbackEndpoints.HandleCallbackAsync`(410 壳)、relay endpoint 里的业务残留、废弃测试;删 `Platform.Lark` 内已无用的 `LarkChannelAdapter.cs` / `LarkWebhookRequest.cs` / `LarkWebhookResponse.cs` / `LarkCredentialSnapshot.cs`(post-Phase 4 已无 ingress/egress 代码);更新 ADR / canon / CI 门禁
验证
CLAUDE.md 强制 :
```bash
dotnet build aevatar.slnx --nologo
dotnet test aevatar.slnx --nologo
bash tools/ci/architecture_guards.sh
bash tools/ci/workflow_binding_boundary_guard.sh
bash tools/ci/query_projection_priming_guard.sh
```
手工 E2E :
Lark p2p 发 `/daily-report alice` → 确定性 slash flow 命中 → Nyx relay reply(不落 LLM)
Lark 群发 `/daily-report` → `AgentBuilderTool` 拿到 `chat_type=group` → 按契约拒绝并附建议 DM
Telegram p2p(未来)→ 同一条主干,不需要另写 runner/endpoint
工作量估计
4–6 个 PR 分阶段落地,每个 PR 不超过一个 Phase。
Codex adversarial review 响应(2026-04-23)
经独立 Codex adversarial review 挖出 6 条 findings,本 issue 已按其修正:
#
Finding
严重度
本 issue 内的修正位置
状态
1
Phase D 把 LLM fallback 改成 actor turn 内 subscribe-wait = 反模式搬家
HIGH
§D 整段重写为事件驱动三段式(`NeedsLlmReplyEvent` → `LlmReplyReadyEvent` → outbound);Phase 1 加相应 proto 定义;Phase 3 加 `LlmReplyGeneratorActor`
✅ 已修正
2
Nyx callback identity(`agent.api_key_id` / `conversation.id`)→ Aevatar `ChannelBotRegistration` 无映射,runner 失联
HIGH
§A.1 加 `ChannelBotRegistration` proto 扩展 + projection 二级索引 + `IChannelBotRegistrationQueryByNyxIdentityPort`;Phase 1 承接
✅ 已修正
3
Outbound credential 设计不成立(`NyxLarkProvisioningService` 只存 api_key id 不存 full_key,Nyx reply 按 agent identity 校验)
HIGH
NyxID #469 提议 per-callback short-lived reply-token(Aevatar 零 secret 持久化);Phase 1 留为待定,等 NyxID 侧确认可行性
🟡 pending
4
`conversation.type = "channel"` 选 A(flatten to Group)基于过时模型,proto 已有 `ConversationScope.Channel`
MED
决策点 1 改为选 B + runner / `BuildReplyMetadata` exhaustive 映射 `ConversationScope`
✅ 已修正
5
`RawPayloadBlobRef` 只是 hash 字符串,扛不起 "reply metadata 回查" 职责
MED
§A.1 加独立 `TransportExtras` 字段;`RawPayloadBlobRef` 保持 hash-only
✅ 已修正
6
入口 auth 漏了 Nyx HMAC signature / `X-NyxID-Message-Id` replay,信任边界退化
MED
§A `NyxIdRelayJwtValidator` 演化为 `NyxIdRelayAuthValidator`:JWT + HMAC + replay 三重校验;Phase 2 承接
✅ 已修正
Phase 1 不开工直到 Finding 3 解决 ——无正确 outbound credential 整个 backbone 是 "inbound 能收,outbound 发不出"。
相关 issue / PR
背景
当前 Lark bot 的生产路径(
Lark → NyxID → Aevatar /api/webhooks/nyxid-relay)存在两条 inbound 主干做同一件事的架构问题。chat_type 丢失导致/daily-report在 1:1 私聊里被错误拒绝(用户报的 bug),只是这个架构问题的一个症状。相关 PR:#323(在 legacy 路径上加确定性 slash flow,但 relay 路径根本不走这条路径,修复在生产上没生效)。
根因(架构层面)
路径 B 的违规点:
NyxID relay 事实调研(
~/Code/NyxID)CallbackPayloadstruct(所有平台共享),单一forward_to_agent()转发;channel_relay_service.rs:28-49, 274-362conversation.type已在 NyxID 侧标准化为 4 个枚举值:"private"/"group"/"channel"/"device",各平台 adapter 内部做映射docs/CHANNEL_BOT_RELAY.md:402-423POST /api/webhooks/nyxid-relay,platform字段在 payload 里指明来源结论:Aevatar 侧的 relay transport 必须是 channel-neutral 的,不能和 Lark 绑死。
目标架构
Nyx relay 降级为一个 channel-neutral transport adapter,和 direct webhook 并列,汇入同一条主干:
核心不变量:
ChatActivity是 inbound 唯一契约,transport 怎么进来是 adapter 私事ConversationGAgent是 inbound 事实源,没有第二条绕过它的路径ConversationReference.Scope是 chat_type 的权威表达,字符串 "p2p"/"group" 只在 runner → tool metadata 翻译时出现命名空间对齐(Phase 0 前置)
先说结论:现有 `Aevatar.GAgents.Channel.Lark` 命名混淆了两个正交 concern——post-refactor 后 Lark 不是 channel,是 platform。要让代码模块语义 self-evident(不靠 ADR 解释),先把命名轴理清楚。
两个正交 concern
目录 + 命名空间对齐
```
agents/
├── channels/ ← 传输层(ingress + egress)
│ └── Aevatar.GAgents.Channel.NyxIdRelay/ ← 唯一的 Channel,5 个平台共享
│ ├── NyxIdRelayTransport.cs
│ ├── NyxIdRelayOutboundPort.cs
│ ├── NyxIdRelayCallbackPayload.cs
│ ├── NyxIdRelayJwtValidator.cs
│ └── ...
│
├── platforms/ ← 平台渲染 / PII / streaming
│ ├── Aevatar.GAgents.Platform.Lark/ ← rename 自 Channel.Lark
│ │ ├── LarkMessageComposer.cs
│ │ ├── LarkStreamingHandle.cs
│ │ ├── LarkPayloadRedactor.cs
│ │ └── LarkOutboundMessage.cs
│ ├── Aevatar.GAgents.Platform.Telegram/ ← (future, #262 应对齐)
│ ├── Aevatar.GAgents.Platform.Slack/ ← (future)
│ └── ...
└── ...
```
具体 rename 动作
为什么不靠 ADR 解释
如果新 contributor 需要读 ADR-0013 才理解 "Channel.Lark 其实是 Lark 渲染层",说明命名失败。正确目标:
这是 CLAUDE.md "项目名=命名空间=目录语义" 原则的直接体现。ADR-0013 只需要解释 architecture decision,不需要用文字补回命名失败的语义。
具体改动清单
A. 新增 `agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/`(channel-neutral,唯一 transport)
A.1 数据层扩展(Finding 2 + 5 引入的必要支撑)
`ChannelBotRegistration` state 扩展:当前 runner 按 `activity.Bot.Value` 查 registration(`LarkConversationTurnRunner.cs` + `ChannelBotRegistrationQueryPort.cs`),但 Nyx callback 给的是 `agent.api_key_id` / `conversation.id`,不是 Aevatar registration id,没映射表统一主干一进 runner 就失联。加:
`ChatActivity` proto 加 `TransportExtras` 字段:`RawPayloadBlobRef` 只是 string hash,proto 里没有 blob store / read path 能 dereference。reply 凭证 / 平台 identifier 这类运行时数据流要独立字段:
```protobuf
message ChatActivity {
// ... existing fields ...
string raw_payload_blob_ref = N; // 保留,仅哈希引用(forensic)
TransportExtras transport_extras = M; // 新字段
}
message TransportExtras {
string nyx_message_id = 1; // reply 关联
string nyx_agent_api_key_id = 2; // registration lookup(见上)
string nyx_platform = 3; // composer dispatch
string nyx_conversation_id = 4; // Nyx 侧会话 id(原始)
// 未来其他 transport-specific 扩展
}
```
`RawPayloadBlobRef` 职责不变(forensic hash)。未来真要做 blob store 回查,另做 `IRawPayloadBlobStore` port + backing store。
决策点 1(Finding 4 修正):`conversation.type = "channel"`(Telegram 公告频道 / Slack public channel)业务语义
选 A:视为 `Group`← 拒绝。proto 已经有 `ConversationScope.Channel`(`chat_activity.proto`);把 "channel" 压成 `Group` 是主动丢语义B. Endpoint 瘦身
`agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs`:
`agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs`:
C. 主干编排统一
`agents/Aevatar.GAgents.ChannelRuntime/LarkConversationTurnRunner.cs`:
`agents/Aevatar.GAgents.Channel.Runtime/Conversation/ConversationGAgent.cs`:
D. LLM reply path(Finding 1 修正后的事件驱动形态)
原方案把"`IConversationReplyGenerator` 里 subscribe `TextMessageContentEvent` / `TextMessageEndEvent` 累积 reply"的 ambient-wait 反模式从 HTTP endpoint 搬进 actor turn——仍是反模式:actor turn 不允许 block 在 cross-actor event 上。正确形状是事件驱动三段式:
```
① ConversationGAgent turn(瞬时完成):
runner 路由 inbound → 判定需要 LLM fallback →
emit NeedsLlmReplyEvent { correlation_id, conversation_ref, prompt, scope,
transport_extras }
→ commit state → turn 结束返回(不等 reply)
② LlmReplyGeneratorActor(subscribe 层):
订阅 NeedsLlmReplyEvent →
内部调 NyxIdChatGAgent 生成 reply →
emit LlmReplyReadyEvent { correlation_id, content, terminal_state }
③ OutboundDispatcher(可以是 ConversationGAgent 的第二个 handler):
订阅 LlmReplyReadyEvent →
调 IChannelOutboundPort.SendAsync(凭 transport_extras 里的 reply 凭证回写)
```
Phase 1 契约加:
UX 影响:从 "turn 同步等 LLM" 变为 "turn 结束后异步 reply 到达"。但当前 HTTP endpoint 已经是异步——`HandleRelayWebhookAsync` 返 202 然后靠 ambient TCS 等 reply。新模式只是把异步边界从 HTTP layer 搬到 actor event layer,本来就该这样,且符合 RFC §5.8 durable inbox 语义。
删除的旧逻辑:endpoint 里的 `RelayReplyAccumulator` / `FinalizeRelayReplyAsync` / `TryExtractLlmError` / `ClassifyError` 逻辑全部消掉,不在 runner 层复刻。
E. 文档 + ADR + 门禁
F. 测试
分阶段落地
每步都能独立 `build/test` 过 + 可回滚:
验证
CLAUDE.md 强制:
```bash
dotnet build aevatar.slnx --nologo
dotnet test aevatar.slnx --nologo
bash tools/ci/architecture_guards.sh
bash tools/ci/workflow_binding_boundary_guard.sh
bash tools/ci/query_projection_priming_guard.sh
```
手工 E2E:
工作量估计
4–6 个 PR 分阶段落地,每个 PR 不超过一个 Phase。
Codex adversarial review 响应(2026-04-23)
经独立 Codex adversarial review 挖出 6 条 findings,本 issue 已按其修正:
Phase 1 不开工直到 Finding 3 解决——无正确 outbound credential 整个 backbone 是 "inbound 能收,outbound 发不出"。
相关 issue / PR