Skip to content

feat: Lark bot 不支持自然语言问答(无通用对话 turn 路由层) #489

@YueZh127

Description

@YueZh127
id: cluster-triage-lark-channel-turn-routing
severity: medium
requires_design: true

来源

本 issue 由 maintainer 手动开,triage codex 深度调研补 evidence。

核心问题

当前代码已经不是“Lark 自然语言完全不走 LLM”:普通文本会进入 LlmReplyRequested,unknown slash 也有 Ornn skill discovery fallback。真正需要进入 refactor loop 的问题是:Lark/Nyx relay 的 turn routing 仍由 ChannelConversationTurnRunner 的 ordered branches、NyxRelayAgentBuilderFlow 白名单 fall-through、ConversationReplyGenerator 工具注入共同隐式表达,缺少一个 typed route decision / policy contract 来统一表达“本地 slash、agent-builder、workflow resume、Ornn skill shortcut、普通 LLM fallback”的边界。

这会让后续新增自然语言、tool、approval、skill discovery 策略继续堆进 runner 中间层,容易破坏分层、命令骨架内聚和 command/query 边界。Fix boundary 必须限制在 aevatar 本仓库内,不能要求 NyxID/chrono-* 新增能力或改 scope。

Evidence

1. Turn runner 同时承担多种业务路由与最终 LLM fallback

agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs:111

public async Task<ConversationTurnResult> RunInboundAsync(
    ChatActivity activity,
    ConversationTurnRuntimeContext runtimeContext,
    CancellationToken ct)
{
    var registration = await ResolveRegistrationAsync(activity, ct);
    ...
    if (await TryHandleWorkflowResumeAsync(inbound, ct) is { } workflowResumeResult)
        return workflowResumeResult;

    if (await TryHandleSlashCommandAsync(activity, inbound, registration, runtimeContext, ct) is { } slashResult)
        return slashResult;
    ...
    if (await TryHandleAgentBuilderAsync(activity, inboundEvent, registration, runtimeContext, typingReactionTask, ct) is { } agentBuilderResult)
        return agentBuilderResult;
    ...
    return ConversationTurnResult.LlmReplyRequested(
        await BuildLlmReplyRequestAsync(...));
}

违反点:routing policy 由方法内顺序和早返回表达;新增策略时只能继续扩张 runner,而不是组合 typed route decision。

2. Agent-builder route 是独立白名单,靠 false fall-through 表达“交给后续 LLM/skill”

agents/Aevatar.GAgents.Authoring.Lark/NyxRelayAgentBuilderFlow.cs:19

public static bool TryResolve(ChannelInboundEvent evt, out AgentBuilderFlowDecision? decision)
{
    ...
    var command = tokens[0];
    if (!IsKnownCommand(command))
        return false;

    if (!IsPrivateChat(evt.ChatType))
    {
        decision = AgentBuilderFlowDecision.DirectReply(BuildPrivateChatRestrictionReply(command));
        return true;
    }

    return TryResolveKnownCommand(command, tokens, out decision);
}

违反点:false 同时可能表示“不是 slash”“unknown slash”“应交给 Ornn skill/LLM”,没有强类型决策结果区分,调用方只能通过外部顺序隐式解释。

3. Runner 内部直接执行 AgentBuilderTool 并选择 renderer,route decision 与执行耦合

agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs:970

var relayDecisionMatched = NyxRelayAgentBuilderFlow.TryResolve(inboundEvent, out decision);
...
if (decision.RequiresToolExecution)
{
    using (AgentToolContextScope.Push(...))
    {
        var tool = ActivatorUtilities.CreateInstance<AgentBuilderTool>(_toolServiceProvider);
        var toolResult = await tool.ExecuteAsync(decision.ToolArgumentsJson!, ct);
        replyContent = relayDecisionMatched
            ? NyxRelayAgentBuilderFlow.FormatToolResult(decision, toolResult)
            : AgentBuilderCardFlow.FormatToolResult(decision, toolResult);
    }
}

违反点:中间层 runner 同时解析 route、构造 tool context、执行 tool、选择渲染器;它不再只是把 typed command/event 投入 actor 主链或把 query 交给 readmodel。

4. Canon 文档确认当前事实是多分支 ordered flow,而不是“没有 LLM 路径”

docs/canon/daily-command-pipeline.md:31

`/daily` slash shortcut 不走本地 agent-builder 创建逻辑;`ChannelConversationTurnRunner` 把它改写为一次 LLM turn,要求模型先调用 `use_skill(skill="chrono-ai-daily", args="<slash args>")`

docs/canon/daily-command-pipeline.md:165

`/daily` 与其他未知 slash(如 `/goal`)是 Ornn skill shortcut:本路由放行给 LLM reply path,不走 `agent_builder`

违反点:文档事实说明普通/unknown 路径已进入 LLM/skill fallback;所以本 issue 的 actionable 方向应是收敛 route/policy 设计,而不是重复添加一条已存在的 LLM 路径。

违反条款

  • 严格分层:Domain / Application / Infrastructure / HostAPI 仅做宿主与组合,不承载业务编排。
  • 读写分离:Command -> EventQuery -> ReadModel;异步完成通过事件通知,不在会话内拼装流程。
  • 依赖反转:上层依赖抽象,禁止跨层反向依赖和对具体实现的直接耦合。
  • 命令骨架内聚:标准生命周期 Normalize -> Resolve Target -> Build Context -> Build Envelope -> Dispatch -> Receipt -> Observe;业务模块只负责目标解析与载荷/结果映射。
  • AI 对话主链必须流式化:实时会话入口必须使用 ChatStreamAsyncChatAsync 仅可用于明确的非交互式离线场景。
  • 本仓库的功能实现禁止依赖外部仓库的新增 / 修改。NyxID、chrono-storage、chrono-ornn 等都是独立产品,aevatar 的需求不是它们的功能路线图。

新原则

Channel inbound 应先归一化为 typed turn route decision,再进入对应 command/event/LLM execution path;false fall-through 不应承载业务语义。

Agent-builder、workflow resume、slash command、Ornn skill shortcut、普通 LLM reply 应共享同一 decision contract,decision 只描述目标语义,不直接执行 tool 或拼装最终流程。

自然语言问答接入必须继续走 ChatStreamAsync 主链;不得新增 ChatAsync 或 webhook 同步 RPC 旁路。

Fix boundary

scope_paths:

  • agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs
  • agents/Aevatar.GAgents.NyxidChat/ConversationReplyGenerator.cs
  • agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.Relay.cs
  • agents/Aevatar.GAgents.NyxidChat/ServiceCollectionExtensions.cs
  • agents/Aevatar.GAgents.Authoring.Lark/NyxRelayAgentBuilderFlow.cs
  • agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardFlow.cs
  • agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderTool.cs
  • agents/Aevatar.GAgents.Channel.Runtime/Conversation/IConversationTurnRunner.cs
  • agents/Aevatar.GAgents.Channel.Runtime/Conversation/ConversationGAgent.cs
  • src/Aevatar.ChatRouting.Core/ChatRouteResolver.cs
  • src/Aevatar.ChatRouting.Core/*
  • src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdProxyTool.cs
  • src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdCodeExecuteTool.cs
  • src/Aevatar.AI.ToolProviders.Skills/UseSkillTool.cs
  • src/Aevatar.AI.ToolProviders.Ornn/*
  • test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelConversationTurnRunnerTests.cs
  • test/Aevatar.GAgents.ChannelRuntime.Tests/NyxRelayAgentBuilderFlowTests.cs
  • test/Aevatar.ChatRouting.Core.Tests/*
  • docs/canon/daily-command-pipeline.md

Out of scope:

  • 不要求 NyxID 增加 im:message:readonly 或任何新 endpoint/scope。
  • 不要求 chrono-ornn/chrono-storage 修改 schema 或协议。
  • 不把 Lark service 权限配置问题作为本 cluster 的完成条件;若现有 surface 不足,solver 应在 aevatar 内降级或不做相关能力。

Decision questions

  1. typed turn route decision 应放在 Aevatar.GAgents.Channel.Runtime 还是 Aevatar.GAgents.NyxidChat?如果放 Runtime,必须避免引入 AI/NyxID/Studio 反向依赖。
  2. NyxRelayAgentBuilderFlow.TryResolve 是否应返回 Matched/NotMatched/PassToLlm/Reject/DirectReply/DispatchCommand 这样的强类型结果,替代 bool fall-through?
  3. unknown slash 的 Ornn skill discovery 是 route policy 的一种 action,还是 LLM prompt builder 的内部策略?
  4. AgentBuilderTool 执行应继续由 turn runner 调用,还是拆成 command handler / port,使 runner 只发 typed command 并返回 accepted/direct reply?
  5. 是否复用 src/Aevatar.ChatRouting.Core/ChatRouteResolver.cs 的 policy/readmodel 语义,还是只抽取 channel-turn-local decision contract,避免扩大 scope?

original_authors

  • agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs: eanzhao, maintainer-redacted
  • agents/Aevatar.GAgents.Authoring.Lark/NyxRelayAgentBuilderFlow.cs: eanzhao, maintainer-redacted
  • agents/Aevatar.GAgents.NyxidChat/ConversationReplyGenerator.cs: eanzhao, maintainer-redacted
  • agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.Relay.cs: eanzhao, maintainer-redacted, louis.li

cc

原始作者见上;本自动 triage 遵守 shared hard rules,不写 private maintainer name 或 @-mention。

⟦AI:AUTO-LOOP⟧

Metadata

Metadata

Assignees

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions