Skip to content

feat: AgentBuilder — 飞书管家 + SkillRunnerGAgent + IHumanInteractionPort (Day One 执行方案) #185

@eanzhao

Description

@eanzhao

背景

Day One (04-25) 目标:外部用户在飞书私聊 bot 创建持久运行的自动化 agent。成功标准:≥5 agent 运行 48h+,≥3 用户无人工指导完成创建。

关联:#182 (Day One spec)、#183 (Owner 自主目标)、NyxID#306 (飞书卡片 outbound 发送 — ✅ 已部署 PR #314)

核心约束

  • 用户不学习任何新工具,唯一界面是飞书私聊 + 必要的浏览器跳转(OAuth)
  • K8s 部署,无本地磁盘
  • 非技术用户,UX 是生死线("上次强推技术工具,同事集体离职")
  • Day One 只支持私聊创建 agent(群聊需要 org credential,安全隐患,post-Day One 做)

架构方案(收敛版,含全部 review 修正 + 第三轮收口)

架构图

飞书 webhook
  │
  ▼
Aevatar ChannelCallbackEndpoints (已有, /{platform}/callback/{registrationId})
  │  处理: im.message.receive_v1 (已有) + card.action.trigger (需扩展)
  │
  ├─ card_action 且有 run_id+step_id?
  │   → 确定性路由: DispatchWorkflowResumeAsync() → /api/workflows/resume
  │   → 不过 LLM,3 秒内返回
  │
  └─ 普通消息 或 card_action 无 run_id
      → DispatchToUserActorAsync() (已有)
      → ChannelUserGAgent (已有, per-sender actor, 持有 State.NyxidAccessToken)
      → NyxIdChatGAgent (管家, 已有)

管家 = NyxID-chat GAgent + AgentBuilderTool
  │
  ├─ Tools:
  │   ├─ AgentBuilderTool (create/list/status/delete) [新建]
  │   ├─ nyxid_providers (OAuth 连接, action=connect_oauth) [已有]
  │   ├─ nyxid_api_keys (创建 non-expiring API Key) [已有]
  │   ├─ nyxid_proxy (API 代理) [已有]
  │   └─ use_skill (skill 加载) [已有]
  │
  ├─ create_agent(template=daily_report)
  │   → nyxid_api_keys 创建 non-expiring API Key (scope=proxy)
  │   → 创建 SkillRunnerGAgent (state 包含 OutboundConfig)
  │   → 注册 Orleans Reminder (定时触发)
  │   → 不创建新 channel registration
  │
  └─ create_agent(template=social_media)
      → nyxid_api_keys 创建 non-expiring API Key
      → IScopeWorkflowCommandPort 创建 WorkflowGAgent (definition only)
      → SchedulerGAgent 持有 timer → fire → 创建 WorkflowRunGAgent

Agent Outbound (发飞书消息/卡片):
  agent 自持 OutboundConfig { ConversationId, NyxProviderSlug, NyxApiKey }
  → NyxIdApiClient.ProxyRequestAsync(apiKey, slug, path, ...)
  → NyxID api-lark-bot (outbound proxy, token_exchange)
  → 飞书 open-apis/im/v1/messages
  不走 channel registration,不走 ChannelUserGAgent

关键架构决策

# 决策 选择 依据
1 消息入站路径 飞书直入 Aevatar ChannelCallbackEndpoints system-prompt.md: "Webhooks go directly to Aevatar, NOT through NyxID"
2 NyxID 角色 仅 outbound proxy (存 bot credentials + 代理 API 调用) 同上
3 card_action 路由 ChannelCallbackEndpoints 轻量分流: 有 run_id+step_id → workflow resume (确定性); 无 → 管家 (LLM) 飞书 3 秒超时, resume 是确定性路由不需要 LLM
4 模板 1 执行 SkillRunnerGAgent (新 GAgent 类型, : AIGAgentBase) /daily 是 Claude Code skill, 需 LLM agent loop
5 /daily 适配 改写为 Aevatar-native skill (gh api → NyxIdProxyTool) UseSkillTool 只返回 SKILL.md 给模型, 不是 Claude Code runtime
6 模板 2 定时 SchedulerGAgent 持有 timer, fire → 创建 WorkflowRunGAgent WorkflowGAgent 只承载 definition, WorkflowRunGAgent 承载执行
7 人机交互 IHumanInteractionPort (渲染+发送+关联) 需完成 event observation → 卡片发送 → resume 关联全链路
8 resume 路径 /api/workflows/resume (actorId + runId + stepId) ChatEndpoints.cs 中的实际路径
9 Token 续期 NyxID API Key (永不过期) 存入 agent OutboundConfig 不复用 channel_registrations(它强制取 session token)
10 Agent outbound 模型 agent 自持 OutboundConfig, 直接用 NyxIdApiClient 发消息 一个 bot 一个 registration, 多 agent 共用; 不为每个 agent 创建新 registration
11 LLM Provider Org 预配置 (用户零配置) NyxID org-shared credentials, LLM Gateway 自动路由
12 飞书卡片流程 表单流 (一张卡片收集参数) 减少消息数, 符合零培训原则
13 用户身份 per-user NyxID (私聊), ChannelUserGAgent.State.NyxidAccessToken 已有机制; 群聊 agent 创建 Day One 不开放

新增组件

1. SkillRunnerGAgent

位置: src/Aevatar.AI.Core/SkillRunnerGAgent.cs
继承: AIGAgentBase<SkillRunnerGAgentState>

State:
  skill_content        // Aevatar-native skill 定义
  schedule_cron        // cron 表达式
  outbound_config      // OutboundConfig { ConversationId, NyxProviderSlug, NyxApiKey, OwnerNyxUserId }
  last_run_time, last_output, error_count

Agent Loop:
  Timer fire → self-message: SkillExecutionTriggeredEvent (事件化)
  → 构建 ChatRequest (system=skill content, user="Execute now. Date: {today}")
  → ChatStreamAsync (复用 AIGAgentBase LLM+tool 能力)
  → Tools: NyxIdProxyTool (api-github, api-feishu-bot via NyxID)
  → Max 20 iterations, 5 min timeout
  → 失败: 重试 1 次 (30s backoff), 仍失败 → 通知用户
  → 输出 → NyxIdApiClient.ProxyRequestAsync(outboundConfig.NyxApiKey, ...) 发到飞书

Outbound 发消息:
  直接用 NyxIdApiClient.ProxyRequestAsync()
  不走 channel registration, 不走 ChannelUserGAgent
  API Key 永不过期, 无 token 刷新问题

Cron 触发:
  引入 Cronos NuGet 包 (当前 repo 无此依赖, 需新增)
  CronScheduleCalculator: cron expression → 下一次触发时间 (one-shot)
  GAgentBase.ScheduleSelfDurableTimeoutAsync() one-shot (已有基础设施)
  Orleans Reminders 跨 pod 持久化 (已有)
  每次 fire 后 Cronos 重新算下一次触发时间, 重挂 one-shot timeout
  不用固定 period (无法正确表达时区/DST)

2. OutboundConfig (agent 发消息的凭证)

不是 channel registration。一个飞书 bot 只有一个 registration (管家用的)。
agent 自己持有 outbound 配置:

OutboundConfig {
    ConversationId     // 发到哪个飞书私聊/群 (从创建对话上下文获取)
    NyxProviderSlug    // api-lark-bot (复用 bot 的 NyxID provider)
    NyxApiKey          // nyxid_api_keys 创建的永不过期 key (scope=proxy)
    OwnerNyxUserId     // 谁拥有这个 agent (NyxID user id)
}

agent 发消息时:
  NyxIdApiClient.ProxyRequestAsync(
      outboundConfig.NyxApiKey,           // API Key 认证
      outboundConfig.NyxProviderSlug,     // "api-lark-bot"
      "open-apis/im/v1/messages?receive_id_type=chat_id",
      "POST",
      body,  // { receive_id, msg_type, content }
      ct)

3. Aevatar-native Daily Report Skill

/daily SKILL.md 的 Aevatar 适配版。不是直接复用 chrono-ai-ceo 的 skill(依赖 bash/gh CLI),改写为 NyxIdProxyTool 调用:

原: gh api "search/commits?q=author:{username}+author-date:>={7d_ago}"
改: NyxIdProxyTool → GET api-github/search/commits?q=author:{username}+author-date:>={7d_ago}

原: npx @larksuite/cli api POST /open-apis/attendance/v1/user_approvals/query
改: NyxIdProxyTool → POST api-feishu-bot/open-apis/attendance/v1/user_approvals/query

原: gh project item-list 3 --owner ChronoAIProject --format json
改: NyxIdProxyTool → GET api-github/graphql (Projects v2 API)

模板 1 hard gate 的前置任务。

4. IHumanInteractionPort

位置: src/Aevatar.Foundation.Abstractions/HumanInteraction/

接口:
  IHumanInteractionPort
    DeliverSuspensionAsync(HumanInteractionRequest, string deliveryTargetId, CancellationToken)

模型:
  HumanInteractionRequest
    ActorId (run actor id, 用于 /api/workflows/resume)
    RunId, StepId
    SuspensionType ("approval" | "input")
    Prompt, Content (待确认内容)
    Options (["approve", "reject", "edit"])
    TimeoutSeconds, Metadata

deliveryTargetId 解析链:
  deliveryTargetId = AgentRegistryEntry.AgentId
  FeishuCardAdapter 内部:
    1. 查 AgentRegistryGAgent readmodel, 拿到 AgentRegistryEntry
    2. 从 entry 取 ConversationId, NyxProviderSlug, ApiKeyId
    3. 从 ApiKeyId 解析实际 key (或 entry 直接存 encrypted key ref)
    4. NyxIdApiClient.ProxyRequestAsync(apiKey, slug, ...) 发卡片
  传输细节全部在 adapter 内部, Foundation 层只看到 deliveryTargetId

完整链路:
  WorkflowRunGAgent
    → HumanApprovalModule 发布 WorkflowSuspendedEvent
    → Projection/Observation 层捕获
    → IHumanInteractionPort.DeliverSuspensionAsync(request, agentId)
    → FeishuCardAdapter 查 registry → 拿到凭证 → 构建卡片 JSON → 发飞书
    → 用户点击按钮
    → 飞书 card.action.trigger → Aevatar ChannelCallbackEndpoints
    → action 有 run_id+step_id → DispatchWorkflowResumeAsync() → /api/workflows/resume

Adapter:
  FeishuCardAdapter (Day One) — 依赖 AgentRegistryGAgent readmodel 解析凭证
  AGUIAdapter (已有, HTTP/SSE)
  未来: OpenUI, Telegram

Timeout: 默认 reject (不执行操作)

5. AgentBuilderTool

位置: src/Aevatar.AI.ToolProviders.NyxId/Tools/AgentBuilderTool.cs (或独立项目)
模式: action-based dispatch

Actions:
  list_templates → 返回可用模板
  create_agent → 见下方流程
  list_agents → 按 scope + owner 查询
  agent_status → last_run_time, next_scheduled, error_count, state
  delete_agent:
    1. CancelDurableCallbackAsync() — 取消 Orleans Reminder
    2. nyxid_api_keys action=delete key_id={apiKeyId} — revoke 永久 API Key
    3. AgentRegistryGAgent 标记 tombstone (软删除)
    4. Day One 不清理 workflow definition (IScopeWorkflowCommandPort 无 delete 语义)
       Post-Day One: 新增 workflow deactivation port
    5. 返回确认卡片

create_agent 内部流程:
  1. 检查 ChatType == "p2p" (群聊拒绝, 提示私聊)
  2. nyxid_api_keys action=create (non-expiring, scope=proxy, allowed_services)
     用当前用户的 session token 创建 (用户在线时完成, 不能推迟)
  3. 构建 OutboundConfig (conversationId 从当前对话上下文获取)
  4. 创建 SkillRunnerGAgent 或 WorkflowGAgent, 把 OutboundConfig 存入 state
  5. 注册 Orleans Reminder (cron → timer)
  6. 返回 agent ID + 状态卡片

不复用 channel_registrations (语义不对, 且强制取 session token)
不创建新的 channel bot registration (一个 bot 一个 registration)

6. AgentRegistryGAgent (agent 元数据 fact owner)

位置: agents/ 或 src/platform/

长期 actor, 持有所有用户创建的 agent 的元数据。
list_agents / agent_status / delete_agent 都查这个 registry。
IScopeWorkflowQueryPort 只覆盖 workflow summary, 不覆盖 SkillRunnerGAgent。
通用 actor store 没有 owner/schedule/error 等业务字段。

AgentRegistryEntry {
    AgentId, AgentType (skill_runner / workflow)
    OwnerId (NyxID user id)
    ScopeId
    TemplateName
    ScheduleCron
    OutboundConversationId
    ApiKeyId (用于 delete 时 revoke)
    CreatedAt
    Status (running / paused / error)
    LastRunTime, NextScheduledRun, ErrorCount
}

更新机制:
  SkillRunnerGAgent 执行完成 → AgentExecutionCompletedEvent
  → registry projection 更新 LastRunTime / ErrorCount
  → agent_status 查 registry readmodel

6. SchedulerGAgent (模板 2 定时触发)

WorkflowGAgent 只承载 definition facts, 不持有 timer。
SchedulerGAgent 持有 Orleans Reminder:
  - 绑定: definition actor id + workflow name + cron
  - timer fire → 创建新 WorkflowRunGAgent (run-scoped) 执行一次
  - WorkflowRunGAgent 执行完成即结束

或者: 管家自身持有 timer (管家是长期 actor), 省去新 GAgent

48h Token 续期方案

问题: timer 触发的 actor 没有请求上下文, 无法获取 NyxID session token。channel_registrations 的 update_token 只是"写入当前调用者的 token",不能凭空生成新 token。

方案: agent 自持 NyxID API Key (永不过期), 存在 OutboundConfig 中。

已验证全链路:

  • NyxID POST /api/v1/api-keys 支持 expires_at 省略 (永不过期)
  • NyxID proxy auth middleware 同时支持 JWT 和 API Key (同一个 Bearer 格式)
  • API Key 关联 user_id 自动传给下游, 用于查找用户的 credentials
  • Aevatar 已有 nyxid_api_keys tool (create/list/rotate/delete)

流程:

管家创建 agent 时 (用户在线, 有 session token):
  1. nyxid_api_keys action=create
     name: "aevatar-agent-{agent_id}"
     scopes: "proxy"
     allowed_service_ids: [api-lark-bot service id, api-github service id]
     expires_at: 省略 (永不过期)
  2. 拿到 full_key (创建时一次性返回)
  3. 存入 SkillRunnerGAgent.State.OutboundConfig.NyxApiKey
  4. agent timer 触发 → 用 API Key 发 outbound → 永不过期

不涉及 channel_registrations,不需要改 ChannelRegistrationTool。


card.action.trigger 实现 (Aevatar 侧)

Inbound: LarkPlatformAdapter 扩展

LarkPlatformAdapter.ParseInboundAsync() 当前只处理 im.message.receive_v1 (第 90 行)。需扩展:

// 新增 card.action.trigger 分支:
if (eventType == "card.action.trigger")
{
    // event.operator.open_id → senderId
    // event.context.open_chat_id → conversationId
    // event.context.open_message_id → messageId
    // event.action.value + event.action.form_value → action 数据

    return new InboundMessage {
        Platform = Platform,
        ConversationId = chatId,
        SenderId = operatorId,
        Text = actionJsonSerialized,
        MessageId = messageId,
        ChatType = "card_action",
    };
}

Routing: ChannelCallbackEndpoints 分流

// ParseInboundAsync 之后, DispatchToUserActorAsync 之前:
if (inbound.ChatType == "card_action")
{
    var action = JsonDocument.Parse(inbound.Text);
    if (action.RootElement.TryGetProperty("run_id", out _) &&
        action.RootElement.TryGetProperty("step_id", out _))
    {
        // 确定性路由: workflow resume, 不过 LLM
        await DispatchWorkflowResumeAsync(action, actorRuntime);
        return Results.Ok(new { status = "resume_dispatched" });
    }
}
// 其他 card_action (select_template 等) + 普通消息 → 正常 chat 路径
await DispatchToUserActorAsync(inbound, registration, actorRuntime, cache);

Outbound: LarkPlatformAdapter 卡片发送

SendReplyAsync() 当前硬编码 msg_type: "text" (第 179 行)。需扩展:

string msgType = IsFeishuCardJson(replyText) ? "interactive" : "text";
string content = msgType == "interactive"
    ? replyText
    : JsonSerializer.Serialize(new { text = replyText });

飞书后台配置 (运维)

在 bot 应用"事件订阅"中添加 card.action.trigger 事件类型 (走同一个 webhook URL)。这不是代码任务,但是发布前置条件。


飞书卡片设计

5 种卡片类型 (Feishu Card JSON 2.0):

卡片 触发时机 关键组件
欢迎+模板选择 用户首次私聊 button (日报/社交)
参数收集表单 选择模板后 form: input(repos) + picker_time + select_static(timezone)
OAuth 授权 GitHub 未授权 button with multi_url (跳转浏览器, 3s 内返回卡片)
创建成功 agent 创建完成 markdown(配置摘要) + button(测试运行/查看状态)
社交内容确认 LLM 生成内容后 (HumanApproval) markdown(预览) + button(确认/拒绝/编辑, value 含 run_id+step_id)

LLM Provider

Org 预配置, 用户零配置。NyxID org-shared credentials + LLM Gateway。

Scope 映射

Day One: 共享预创建 scope, 用 scope + owner NyxID user id 做 namespace。

用户身份

  • 私聊: per-user NyxID OAuth → ChannelUserGAgent.State.NyxidAccessToken (已有)
  • agent 的 API Key 属于该用户 → agent 拉该用户的 GitHub/Lark 数据
  • 群聊: Day One 不开放 agent 创建 (安全风险: 个人 credential 不应群共享)
  • Post-Day One: 群聊用 NyxID org credential

已有基础设施 (无需新建)

组件 位置 用途
ChannelCallbackEndpoints agents/ChannelRuntime/ 飞书 webhook 直入 (需扩展 card_action 分流)
ChannelUserGAgent agents/ChannelRuntime/ per-sender 会话管理 + NyxID 身份绑定
LarkPlatformAdapter agents/ChannelRuntime/Adapters/ Lark 消息解析 (需扩展 card + outbound card)
AIGAgentBase src/Aevatar.AI.Core/ SkillRunnerGAgent 基类
ScheduleSelfDurableTimerAsync src/Foundation.Core/GAgentBase.cs 定时触发 (timer API 已有, cron 解析需新增)
RuntimeCallbackSchedulerGrain src/Foundation.Runtime.Orleans/ Orleans Reminders 实现
HumanApprovalModule src/workflow/Workflow.Core/Modules/ 审批事件流
WorkflowSuspendedEvent/ResumedEvent src/workflow/ 暂停/恢复协议
IScopeWorkflowCommandPort src/platform/ Workflow 创建
NyxIdApiClient.ProxyRequestAsync src/AI.ToolProviders.NyxId/ outbound API 调用
nyxid_providers NyxidChat tools OAuth 连接
nyxid_api_keys NyxidChat tools API Key 创建 (non-expiring)
nyxid_proxy NyxidChat tools API 代理
UseSkillTool src/AI.ToolProviders.Skills/ Skill 加载

NyxID proxy service seeds (已配置, 无需新建):


需要新建/扩展

工作项 类型 估算
SkillRunnerGAgent + OutboundConfig 新 GAgent 类型 2-3 天
Aevatar-native daily skill 改写 /daily 为 NyxIdProxyTool 调用 1-2 天
AgentBuilderTool 新 IAgentTool (不复用 channel_registrations) 1-2 天
AgentRegistryGAgent + readmodel projection 新 actor (agent 元数据 fact owner) 1-2 天
IHumanInteractionPort + FeishuCardAdapter 新抽象 + adapter 2-3 天
ChannelCallbackEndpoints card_action 分流 扩展 endpoint (run_id 检查 → resume) 0.5 天
LarkPlatformAdapter card.action.trigger 解析 扩展 adapter inbound 0.5 天
LarkPlatformAdapter card 发送 扩展 adapter outbound 0.5 天
Cronos NuGet + CronScheduleCalculator 新依赖 + one-shot 循环 (repo 当前无 Cronos) 0.5 天
SchedulerGAgent (模板 2 定时) 新 GAgent 或管家持有 0.5 天
飞书后台 card.action.trigger 事件订阅 运维配置 0.5h
NyxID org + LLM credentials 运维配置 0.5h
NyxID#306 outbound 卡片发送 NyxID lark.rs (Kaiwei) ✅ 已部署 (PR #314)

并行化策略

Lane A: SkillRunnerGAgent + OutboundConfig → daily skill 改写 → AgentBuilderTool
Lane B: IHumanInteractionPort → FeishuCardAdapter
Lane C: ChannelCallbackEndpoints 分流 + LarkPlatformAdapter 扩展 (card inbound + outbound)
Lane D: ~~NyxID#306~~ ✅ 已部署

A + B + C + D 并行开发。合并后集成测试。

Timeline

日期 Gate 验收标准
04-16 执行计划 + 接口定义 IHumanInteractionPort 接口 + SkillRunnerGAgent 骨架 + OutboundConfig proto
04-18 A0 Done 测试网稳定
04-19 卡片决策点 card.action.trigger 分流 + LarkAdapter card 发送就绪, 否则 fallback 纯文本
04-21 模板 1 端到端 SkillRunner 执行 daily skill → 输出在测试飞书私聊 → 存活 pod 重启
04-23 模板 2 端到端 LLM 生成 → 确认卡片 → 确认后发 X
04-25 Day One ≥5 agent 运行 48h+, ≥3 用户自主创建 (私聊)

协作

Review Status

Review Status 关键发现
/office-hours APPROVED 方案 C (卡片 UX) + tool-based concierge
/plan-ceo-review CLEAR 2 模板 + SkillRunnerGAgent + IHumanInteractionPort
/plan-eng-review CLEAR 新 GAgent 类型, port 渲染+发送, Cronos 库
/plan-design-review CLEAR (8/10) 表单流, 5 种卡片, 飞书 JSON 2.0
Review 报告 R1 7/7 CONFIRMED 消息路径, timer 归属, skill 改写, tool 清单, scope, token
Review 报告 R2 3/3 收口 card_action 路由 owner, API Key 不走 registration, outbound 模型
Review 报告 R3 4/4 收口 AgentRegistry readmodel, Port 不泄漏传输细节, cron one-shot 循环, delete 完整清理

Metadata

Metadata

Assignees

Labels

todoIssue ready for Symphony to pick up

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions