diff --git a/docs/CODE_QUALITY_ANALYSIS.md b/docs/CODE_QUALITY_ANALYSIS.md deleted file mode 100644 index 19cd791..0000000 --- a/docs/CODE_QUALITY_ANALYSIS.md +++ /dev/null @@ -1,369 +0,0 @@ -# PicoBot 代码质量分析报告 - -审查日期:2026-06-15 - -## 结论摘要 - -PicoBot 的总体架构方向是清晰的:Gateway 负责装配,Channel 只做收发,MessageBus 解耦输入输出,SessionManager 管理会话,AgentLoop 保持无状态并执行工具,Storage 统一持久化。这条主线是成立的,也已经具备较完整的 AI 助手运行时能力。 - -当前主要质量风险集中在三类: - -1. 会话/CLI 路由语义不一致,导致多客户端隔离、加载会话、当前会话追踪不可靠。 -2. 若干公开控制接口是空实现或弱实现,协议层暴露的能力和后端实际行为不匹配。 -3. 工具和后台任务的资源边界偏弱,文件、shell、HTTP、长期任务在异常情况下容易突破预期的安全或稳定性边界。 - -如果只安排一轮修复,优先处理会话路由和控制接口。这些问题会直接影响用户看到的行为;工具安全和大模块拆分可以作为第二阶段。 - -## 修复状态 - -- 已修复:CLI 会话路由现在按每个 WebSocket client 的稳定 `chat_id` 隔离,普通输入、创建、列表、加载和 outbound 投递不再混用完整 `session_id` 与 `chat_id`。 -- 已修复:Dialog 控制接口已补齐当前会话查询、列表 current 标记、归档、清空历史和 `/delete` 删除当前会话后新建的行为;`include_archived` 现在由 Storage 查询生效。 -- 已修复:Session 主处理路径不再在持有 session mutex 时执行 memory recall、上下文压缩、标题 LLM 生成、消息持久化、`/stop` sub-agent 取消或清历史存储操作;慢操作改为锁外执行并用 `state_version`/`worker_generation` 防止陈旧结果覆盖当前会话。 -- 已修复:Bash 超时清理、文件读取大文件限制、HTTP DNS 私网校验、Bus 关闭退出、Cron `from` 语义和 PTY 工具接入等中等级问题已完成清扫。 -- 待处理:工具文件边界仍是后续质量风险。 - -## 主要发现 - -### 已修复:CLI 会话路由会破坏会话连续性和多客户端隔离 - -位置: - -- `src/channels/cli_chat.rs:113-126` -- `src/channels/cli_chat.rs:160-164` -- `src/channels/cli_chat.rs:225-249` -- `src/channels/cli_chat.rs:479-494` -- `src/session/session.rs:1305-1310` - -问题: - -`Client.current_session_id` 存的是完整 session id,但 CLI channel 在多个地方把它当作 `chat_id` 使用。普通用户输入如果没有显式传 `chat_id`,会在 `src/channels/cli_chat.rs:119` 生成新的短 ID,而不是复用当前 client 的 chat scope。`CreateSession` 又把当前完整 session id 当成新会话的 chat_id。`LoadSession` 解析了传入 session id,但随后调用 `GetCurrentDialog`,而后端 `get_current_dialog()` 固定返回 `None`。 - -同时,`send()` 会把所有 `OutboundMessage` 广播给所有 CLI WebSocket client,没有按 `msg.chat_id` 或 client 当前会话过滤。这意味着一个客户端的回复可能出现在另一个客户端里。 - -影响: - -- CLI 多轮对话可能落入不同 chat scope。 -- 创建/列出/加载会话得到的结果可能不符合 UI 预期。 -- 多个 CLI 客户端同时连接时存在串话。 - -建议: - -- 将 client 状态拆成 `chat_id` 和 `current_session_id`,不要混用。 -- 注册 client 时生成稳定 `chat_id`,后续 `UserInput` 默认复用它。 -- `send()` 按 `OutboundMessage.chat_id` 精确投递;必要时维护 `chat_id -> clients` 映射。 -- `LoadSession` 应直接切换到指定 session,或通过 `SwitchDialog` 使用其中的 `dialog_id`。 -- 为 CLI WebSocket 增加多客户端路由测试。 - -### 已修复:Dialog 控制接口与协议承诺不一致 - -位置: - -- `src/session/session.rs:996-997` -- `src/session/session.rs:1305-1310` -- `src/session/session.rs:1329-1349` -- `src/session/session.rs:1378-1384` -- `src/channels/cli_chat.rs:128-158` - -问题: - -后端暴露了 create/list/load/rename/archive/delete/clear 等 dialog 操作,但部分行为是空实现或语义错位: - -- `/delete` 只创建新 session,并没有删除当前 session。 -- `get_current_dialog()` 固定返回 `Ok(None)`。 -- `list_dialogs()` 忽略 `include_archived`,且总是返回 `current_dialog_id = None`。 -- `archive_dialog()` 是空操作。 -- `clear_dialog_history()` 直接返回不可用,但 WebSocket 协议仍暴露 `clear_history`。 - -影响: - -用户通过 slash command 和 WebSocket 调用同一类能力时,会得到不一致结果。前端难以基于协议实现可靠状态同步。 - -建议: - -- 明确“archive/clear 是否支持”。不支持就从协议和命令列表移除;支持就实现到底。 -- `/delete` 应调用 `delete_dialog(current_session_id)`,再创建一个新的 current session。 -- `get_current_dialog()` 应读取 `current_sessions[channel:chat_id]` 并解析为 `UnifiedSessionId`。 -- `list_dialogs()` 返回真实 current dialog,并补上 archived 模型或移除 archived 参数。 - -### 高优先级:工具文件边界不符合“工作目录内工具”的架构约束 - -位置: - -- `src/tools/mod.rs:56-62` -- `src/tools/path_utils.rs:3-23` -- `src/tools/bash.rs:146-185` - -问题: - -文件工具默认通过 `FileReadTool::new()`、`FileWriteTool::new()` 等注册,没有传入 workspace allowlist。`resolve_path()` 对绝对路径直接放行;即使传入 allowlist,也只是做 `Path::starts_with()` 的词法判断,没有 canonicalize,不能防御 `..`、符号链接等路径逃逸。 - -`bash` 默认工作目录是 `"."`,Gateway 启动时切到 workspace,这对相对路径有效,但 shell 命令仍然可以访问绝对路径。当前 denylist 只挡少数危险模式,不构成权限边界。 - -影响: - -Agent 工具实际可以读写 workspace 外文件,和文档/架构里的“工作目录内操作”不一致。对于个人助手这可能是有意设计,但如果未来接入外部渠道、多用户或 MCP,风险会放大。 - -建议: - -- 工具注册时传入 `workspace_dir`,默认所有文件工具限制在 workspace。 -- `resolve_path()` 使用 `std::fs::canonicalize` 或 `path_absolutize` 风格逻辑,并处理目标文件不存在时的父目录 canonicalize。 -- 写工具禁止跟随危险符号链接,或至少在文档中明确该能力是全文件系统权限。 -- shell 工具如果保留,应在配置中显式开关,并区分本地可信模式和渠道暴露模式。 - -### 已修复:Session 锁内执行过多异步操作 - -位置: - -- `src/session/session.rs:1001-1018` -- `src/session/session.rs:1604-1711` - -问题: - -`/compact` 在持有 session mutex 时执行压缩和持久化。agent worker 的 Phase 1 也在持有 session mutex 时执行用户消息落库、memory recall、上下文压缩、session meta 持久化和 agent 创建。其中 `compress_if_needed()` 可能触发 LLM 摘要,属于慢操作。 - -影响: - -- 同一 session 的 slash command、stop、消息排队、状态查询会被慢操作阻塞。 -- 当压缩或存储出现抖动时,用户感觉像“卡死”。 -- 后续如果在这些慢操作里间接需要 session 状态,容易形成锁顺序问题。 - -已采取修复: - -- 为 `Session` 增加 `state_version`,慢操作提交前检查会话是否已被 `/stop`、清历史或其它内存变更替换。 -- `/compact` 改为锁内取 history 快照,锁外压缩,锁内提交压缩结果,锁外持久化 meta。 -- agent worker Phase 1 改为锁内只创建用户消息、agent、cancel handle 和 history 快照;memory recall 与 context compression 都在锁外执行。 -- context overflow retry 的二次压缩移到锁外。 -- 标题生成改为锁内取 prompt/provider 快照,锁外调用 LLM,锁内应用标题,锁外持久化。 -- `add_message` 拆出内存更新和持久化快照,主消息路径在释放 session 锁后写入 SQLite。 -- `/stop` 和清历史不再持有 session 锁等待 sub-agent 取消或 Storage 操作。 - -### 已修复:Bash 超时不会显式终止子进程 - -位置: - -- `src/tools/bash.rs:150-174` -- `src/tools/bash.rs:180-207` - -问题: - -`timeout()` 包裹的是 `run_command()` future。超时后 future 被取消,但代码没有持有 child 句柄并显式 `kill()` / `wait()`。对于已经启动的长运行命令或子进程树,可能留下后台进程。 - -影响: - -长任务、服务进程或卡住的 shell 命令会泄漏进程和资源,后续工具调用的行为也会变得不可预测。 - -已采取修复: - -- Bash 一次性命令改用 `wait_with_output()`,避免 stdout/stderr 顺序读取造成 pipe 阻塞。 -- 子进程启用 `kill_on_drop(true)`,超时后丢弃等待 future 时会清理 child。 -- 新增大 stderr 输出测试,覆盖不会因为 stderr pipe 填满而卡住。 -- 持久/交互式进程通过已接入的 PTY 工具承载。 - -### 已修复:文件读取对大二进制文件没有输出上限 - -位置: - -- `src/tools/file_read.rs:121-131` -- `src/tools/file_read.rs:214-229` - -问题: - -`file_read` 先 `std::fs::read()` 读取整个文件。文本路径有 `MAX_CHARS` 截断,但二进制路径会完整 base64 编码后返回,没有大小限制。 - -影响: - -读取大文件会造成内存膨胀、响应膨胀、上下文污染,甚至拖垮进程。 - -已采取修复: - -- `file_read` 在读取前检查 metadata size,超过安全阈值直接拒绝。 -- 二进制 inline base64 增加单独大小上限,超限只返回错误和文件信息。 -- 含 NUL 字节内容按二进制处理,避免全 0 文件被 UTF-8 路径误判为文本。 -- 增加大文件和大二进制文件测试。 - -### 已修复:HTTP 私网防护只检查字面 host,未做 DNS 解析校验 - -位置: - -- `src/tools/http_request.rs:31-59` - -问题: - -`http_request` 阻止 localhost、私网 IP 字面量和 `.local`,但普通域名不会解析后检查最终 IP。DNS rebinding 或内网域名解析到私网地址时,当前校验拦不住。 - -影响: - -如果该工具暴露给非完全可信输入,存在 SSRF 风险。 - -已采取修复: - -- `http_request` 和 `web_fetch` 在发送请求前通过 DNS 解析 host,并拒绝解析到 loopback、private、link-local、multicast、unspecified 的地址。 -- IPv6 unique-local 和 link-local 地址也纳入私网判定。 -- 禁用 reqwest 自动重定向,避免跳转到未校验的内网地址。 -- 增加端口解析和 IPv6 私网判断测试。 - -### 已修复:后台任务和主循环缺少监督与优雅关闭 - -位置: - -- `src/bus/mod.rs:51-99` -- `src/gateway/mod.rs:187-244` -- `src/gateway/mod.rs:247-266` - -问题: - -Gateway 中多个长期任务通过 `tokio::spawn` 启动后没有保存 JoinHandle,也没有统一 cancellation token。MessageBus 的 `consume_*()` 在 channel 关闭时使用 `expect()` panic。 - -影响: - -- 某个后台 loop 异常退出后,Gateway 不一定能发现。 -- 关闭流程只能 stop channel,无法系统性停止 scheduler、dispatcher、agent workers、notification publishers。 -- bus channel 关闭时更像崩溃,而不是可恢复状态。 - -已采取修复: - -- `MessageBus::consume_inbound/consume_outbound/consume_control` 不再在 channel 关闭时 `expect()` panic,改为返回 `Option`。 -- Gateway message processor 在 inbound/control bus 关闭时记录 warning 并退出 loop。 -- OutboundDispatcher 在 outbound bus 关闭时记录 warning 并退出 loop。 -- 这不是完整 runtime supervisor,但已消除 bus 关闭导致的 panic 崩溃路径,为后续集中 JoinHandle 管理留出接口。 - -### 已修复:Cron 计算函数没有按入参 `from` 计算 cron 下一次时间 - -位置: - -- `src/scheduler/mod.rs:18-40` - -问题: - -`next_run_for_schedule(schedule, from)` 的注释说基于 `from` 计算,但 cron 分支创建了 `from_dt` 后没有传给 `cron_schedule`,实际使用的是 `upcoming(Utc)` 或 `upcoming(tz)` 的当前时间。 - -影响: - -单元测试或补偿调度传入历史/未来时间时,结果不符合函数契约。线上 reschedule 当前使用 now,影响较小,但函数语义是错的。 - -已采取修复: - -- cron 分支改用 `cron_schedule.after(&from_dt).next()`。 -- timezone 分支用 `from_dt.with_timezone(&tz)` 作为计算起点。 -- 增加 UTC 和 Asia/Shanghai 固定时间输入测试。 - -### 已修复:存在未接入或半接入代码,增加维护噪音 - -位置: - -- `src/tools/pty.rs` -- `src/tools/mod.rs:1-20` -- `src/tools/mod.rs:49-88` - -问题: - -仓库里有完整 `pty.rs`,但 `tools/mod.rs` 没有声明 `pub mod pty`,`create_default_tools()` 也没有注册 PTY 工具。类似情况会让文档、计划和实现状态难以判断。 - -影响: - -维护者会误以为功能已上线。未来改动容易遗漏测试和注册路径。 - -已采取修复: - -- `src/tools/pty.rs` 已接入 `tools/mod.rs`,导出 `PtyManager`/`PtyTool`。 -- `create_default_tools()` 默认注册共享 `PtyManager` 的 `PtyTool`。 -- 修复 PTY 原本因未编译暴露不出的借用问题。 - -## 架构评价 - -### 做得好的地方 - -- 模块分层方向清楚:Channel、Bus、Session、Agent、Provider、Tool、Storage 边界基本可理解。 -- AgentLoop 设计为无状态,历史由 SessionManager 管理,这一点利于恢复、压缩和测试。 -- Provider 抽象简单直接,OpenAI-compatible 与 Anthropic 的差异被限制在 provider 层。 -- Storage 集中初始化 schema,便于部署单二进制应用。 -- Skill、memory、MCP、delegate 这几条扩展线已经形成统一的 ToolRegistry 接入点。 - -### 主要架构债务 - -- SessionManager 承担过多职责:会话生命周期、命令解析、memory recall、压缩、agent worker、任务取消、send_message 目标解析都在一个 2000 行文件内。 -- Channel 和 Session 对 chat_id/session_id/dialog_id 的边界没有类型保护,导致 CLI 层混用字符串。 -- Tool 权限模型不够显式:工具是否能访问全文件系统、是否能联网、是否能修改状态主要靠工具自身约定。 -- 后台任务生命周期分散:gateway loop、agent worker、notification publisher、scheduler、sub-agent task 各自 spawn,缺少统一管理。 - -## 模块级分析 - -### gateway - -`GatewayState::new()` 是清晰的装配中心:配置、workspace、storage、memory、bus、session manager、channels、MCP、scheduler 都在这里接线。问题是启动后任务监督不足,且 scheduler 默认 `unwrap_or_default()` 会在省略 `gateway.scheduler` 时启用调度器,这和“省略配置是否代表开启”需要产品层确认。 - -### channels - -Feishu channel 功能较厚,单文件接近 2000 行,建议后续按 API client、message parsing、media handling、outbound rendering 拆分。CLI channel 目前是质量风险最高的 channel,核心问题是会话身份混用和广播投递。 - -### bus - -MessageBus 简洁,但当前消费者 API 通过 mutex 包住 receiver 并 `expect()`,更像“单消费者内部队列”。这没问题,但应该把“只能有一个 consumer”写进类型/文档,并把关闭作为正常状态处理。 - -### session - -这是系统核心,也是债务最集中的模块。建议把 `session.rs` 拆成: - -- `manager.rs`:SessionManager 状态和 dialog 生命周期 -- `worker.rs`:per-session agent worker 和 cancellation -- `commands.rs`:slash command 执行 -- `outbound.rs`:OutboundMessenger 实现 -- `restore.rs`:storage 恢复与 tool call chain repair - -拆分之前,先补行为测试,尤其是 CLI/WS session lifecycle。 - -### agent - -AgentLoop 的职责相对聚焦:请求模型、执行工具、回填 tool result、循环直到 final response。需要关注的是工具并发的语义:`read_only()` 目前是工具自己声明,副作用工具不能错标。LoopDetector 有帮助,但属于 runtime guard,不应替代工具层的资源限制。 - -### providers - -Provider 层整体可维护。OpenAI/Anthropic 的请求构造逻辑可以继续保留在 provider 内。建议补充请求脱敏策略:当前 debug log 和 `llm_calls` 会持久化完整 request/response,可能包含用户隐私、API 返回内容和文件内容。 - -### tools - -工具体系覆盖面很强,但需要明确权限模型。建议新增统一的 `ToolExecutionContext`,包含 workspace、channel、session_id、权限策略、网络策略、输出预算。现在很多策略散落在各工具构造函数里,默认值容易失控。 - -### storage - -Storage schema 初始化实用,但迁移方式是“CREATE IF NOT EXISTS + ALTER IGNORE”,适合早期迭代,不适合长期演进。建议引入 schema version 表或 sqlx migrations,至少把每次迁移记录下来。 - -### skills - -Skill 加载优先级清晰,内置 skill 打包也实用。需要注意 `SkillsLoader` 使用同步文件系统扫描和 `std::sync::Mutex`,在请求路径频繁 `reload_if_changed()` 时可能造成阻塞。短期可以接受,长期建议缓存刷新放到后台 watcher。 - -## 建议修复路线 - -### P0:先修会话正确性 - -1. 修正 CLI `chat_id/current_session_id` 数据模型。 -2. 修正 CLI 出站按 client/chat_id 投递。 -3. 实现 `get_current_dialog()`、`list_dialogs()` current 返回。 -4. 修正 `/delete`、`clear_history`、`archive` 的真实行为或从协议移除。 -5. 增加 WebSocket session lifecycle 测试。 - -### P1:收紧工具和资源边界 - -1. 文件工具默认限制 workspace,路径 canonicalize。 -2. bash 超时杀进程,必要时引入进程组。 -3. file_read 增加文件大小上限和二进制输出上限。 -4. HTTP/web 工具增加 DNS 解析后的私网校验和重定向校验。 -5. 明确高危工具的配置开关。 - -### P2:降低架构复杂度 - -1. 拆分 `session.rs`、`feishu.rs`、`storage/mod.rs`、`browser.rs`。 -2. 引入任务 supervisor 和统一 shutdown token。 -3. 引入正式数据库迁移。 -4. 增加工具注册快照测试,避免死代码和文档漂移。 - -## 建议测试补充 - -- CLI 多客户端并发:两个 WebSocket client 同时发消息,互不串话。 -- CLI 不传 chat_id 的连续对话:所有消息应进入同一 session。 -- Load/switch/list/delete/clear 的完整 WebSocket 流程。 -- `/delete` 后旧 session 软删除、新 session 成为 current。 -- 文件路径逃逸:`../`、绝对路径、符号链接、workspace 前缀欺骗。 -- bash timeout 后检查子进程不存在。 -- cron `next_run_for_schedule()` 使用固定 `from` 的 deterministic 测试。 -- HTTP 工具对 DNS 解析到 `127.0.0.1` / `10.0.0.0/8` 的域名拒绝测试。 diff --git a/docs/INTERACTIVE_CHANNEL_DESIGN.md b/docs/INTERACTIVE_CHANNEL_DESIGN.md deleted file mode 100644 index 7f21db3..0000000 --- a/docs/INTERACTIVE_CHANNEL_DESIGN.md +++ /dev/null @@ -1,496 +0,0 @@ -# PicoBot 跨渠道交互式消息规划 - -规划日期:2026-06-16 - -## 背景 - -飞书交互式卡片可以让用户直接在消息卡片上点击按钮、提交选择或触发回调。这个能力很适合用于工具调用审批、快捷回复、任务确认、表单收集等 agent 交互。 - -参考项目调研结果: - -- `reference/zeroclaw` 已实现飞书/Lark 工具审批卡片:发送 Card JSON 2.0 按钮卡片,收到 `card.action.trigger` 后解析 `approval_id` 和 `decision`,唤醒等待中的 approval future,并 PATCH 原卡片为已处理状态。 -- `reference/nanobot` 主要使用飞书 CardKit 做 agent 输出展示和流式更新,适合参考消息渲染体验,但没有完整的按钮回调驱动 agent 流程。 -- `reference/openlark` 是 SDK/API 封装,支持发送 interactive card 和 CardKit API,不包含完整 agent channel 编排。 - -PicoBot 当前飞书渠道已经会把普通 markdown 回复发送成 interactive card,但还缺少“用户在卡片上操作 -> 统一交互事件 -> Session/Agent/Tool 流程继续”的抽象。 - -## 目标 - -1. 支持飞书交互式卡片按钮回调。 -2. 设计成跨渠道能力,后续 Slack、Telegram、Discord、CLI chat 等渠道可以复用同一套交互语义。 -3. 支持渠道降级:不支持按钮的渠道也能用纯文本命令完成同样操作。 -4. 保持 PicoBot 现有边界:Channel 只做收发和渠道适配,SessionManager 管会话,AgentLoop 执行 LLM 和工具。 -5. 为工具调用审批、快捷回复和未来表单交互预留扩展点。 - -非目标: - -- 本阶段不立即实现完整功能。 -- 不把飞书卡片细节泄漏到 AgentLoop 或工具层。 -- 不要求所有渠道同时支持原生交互组件。 - -## 核心原则 - -交互语义和渠道渲染分离。 - -Agent、工具或 Session 层只表达“我要一个 approval/quick reply/form interaction”。具体是飞书卡片按钮、Slack Block Kit、Telegram inline keyboard,还是 CLI 里显示编号选项,由 Channel 根据能力渲染。 - -回调也要统一。 - -飞书的 `card.action.trigger`、Telegram 的 `callback_query`、Slack 的 interaction payload 都应归一化成 PicoBot 内部的 `InteractionEvent`,再交给统一的处理器。 - -## 数据模型 - -建议新增一个 `interaction` 模块,定义渠道无关的数据结构。 - -```rust -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub enum InteractionKind { - QuickReply, - Approval, - FormSubmit, - Command, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub enum InteractionStyle { - Default, - Primary, - Danger, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct InteractionAction { - pub id: String, - pub label: String, - pub value: String, - pub style: InteractionStyle, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct InteractionPayload { - pub interaction_id: String, - pub kind: InteractionKind, - pub title: Option, - pub body: String, - pub actions: Vec, - pub expires_at: Option, -} - -#[derive(Debug, Clone)] -pub struct InteractionEvent { - pub channel: String, - pub chat_id: String, - pub sender_id: String, - pub interaction_id: String, - pub action_id: String, - pub action_value: String, - pub timestamp: i64, - pub metadata: std::collections::HashMap, -} -``` - -`InteractionPayload` 用于 outbound 渲染,`InteractionEvent` 用于 inbound 回调。 - -## OutboundMessage 扩展 - -短期兼容方案: - -- 继续使用 `OutboundMessage.metadata` 携带交互描述。 -- 例如: - - `interaction.kind = "approval"` - - `interaction.id = ""` - - `interaction.actions = ""` - -长期推荐方案: - -```rust -pub struct OutboundMessage { - pub channel: String, - pub chat_id: String, - pub content: String, - pub reply_to: Option, - pub media: Vec, - pub metadata: HashMap, - pub interaction: Option, -} -``` - -推荐长期方案。它能避免把结构化交互塞进字符串 metadata,也让每个 Channel 的 `send()` 更清晰。 - -## Channel 能力声明 - -给 `Channel` 增加可选能力声明: - -```rust -#[derive(Debug, Clone, Default)] -pub struct ChannelCapabilities { - pub interactive_buttons: bool, - pub forms: bool, - pub message_update: bool, - pub markdown_cards: bool, -} - -pub trait Channel { - fn capabilities(&self) -> ChannelCapabilities { - ChannelCapabilities::default() - } -} -``` - -渠道能力示例: - -| 渠道 | 原生按钮 | 表单 | 更新原消息 | 降级策略 | -|------|----------|------|------------|----------| -| Feishu | 是,interactive card | 可后续支持 | 是,PATCH message/card | 文本命令 | -| Slack | 是,Block Kit | 是 | 是 | 文本命令 | -| Telegram | 是,inline keyboard | 有限 | 可编辑消息 | 文本命令 | -| Discord | 是,components | 有限 | 可编辑消息 | 文本命令 | -| CLI chat | 否 | 否 | 局部可模拟 | 编号/命令输入 | -| Webhook/Email/SMS | 否 | 否 | 通常否 | 纯文本命令或链接 | - -## 渲染策略 - -每个 channel 实现一个渠道内的渲染函数: - -```rust -async fn send_interaction( - &self, - chat_id: &str, - payload: &InteractionPayload, -) -> Result<(), ChannelError>; -``` - -也可以先不改 trait,在 `send()` 内部判断 `msg.interaction`。 - -飞书渲染: - -- 使用 Card JSON 2.0。 -- `schema = "2.0"`。 -- body 用 markdown 展示 `payload.body`。 -- actions 渲染为 button。 -- 每个按钮的 callback value 写入: - -```json -{ - "interaction_id": "...", - "action_id": "...", - "action_value": "approve" -} -``` - -需要兼容飞书 Card 2.0 回调路径: - -- `/action/value` -- `/action/behaviors/0/value` - -CLI 降级渲染: - -```text -需要确认: - -Tool: bash -Args: cargo test --lib - -可选操作: -1. Approve -2. Deny -3. Always approve - -回复: -/_interaction approve -/_interaction deny -/_interaction always -``` - -纯文本渠道都可以复用这个 fallback renderer。 - -## Inbound 回调归一化 - -飞书 WebSocket 当前在 `src/channels/feishu.rs` 里处理 `im.message.receive_v1`。需要新增对 `card.action.trigger` 的识别: - -1. ACK 仍要尽快发送,飞书要求 3 秒内响应。 -2. 如果 event type 是 `card.action.trigger`,不要走普通消息解析。 -3. 从 event payload 中解析 `interaction_id`、`action_id`、`action_value`。 -4. 构造 `InteractionEvent` 发布给统一处理器。 -5. 对未知、过期或重复 interaction 返回成功但记录日志,不应导致渠道重连或报错。 - -如果短期不新增 interaction bus,可以把回调转成特殊 `InboundMessage`: - -```text -content = "/_interaction " -metadata["event.kind"] = "interaction" -metadata["interaction.id"] = "" -metadata["interaction.action_id"] = "" -metadata["interaction.action_value"] = "" -``` - -但必须由 SessionManager 或 InteractionManager 先拦截,不能把 `/_interaction` 当普通用户文本直接送进 LLM。 - -长期推荐新增 bus 通道: - -```rust -pub enum InboundEvent { - Message(InboundMessage), - Interaction(InteractionEvent), -} -``` - -或者在 `MessageBus` 上增加 `interaction_tx`。 - -## InteractionManager - -建议新增 `InteractionManager`,集中管理 pending 交互状态,而不是让每个 Channel 各自维护。 - -职责: - -- 生成 `interaction_id`。 -- 保存 pending interaction。 -- 处理超时和过期。 -- 接收 `InteractionEvent` 并解析成业务结果。 -- 对重复点击、未知 interaction、过期 interaction 做幂等处理。 -- 必要时通知 channel 更新原消息。 - -内部状态示例: - -```rust -pub struct PendingInteraction { - pub id: String, - pub kind: InteractionKind, - pub channel: String, - pub chat_id: String, - pub sender_id: Option, - pub session_id: Option, - pub created_at: i64, - pub expires_at: Option, - pub status: InteractionStatus, - pub responder: InteractionResponder, - pub message_ref: Option, -} - -pub struct InteractionMessageRef { - pub channel: String, - pub chat_id: String, - pub message_id: String, - pub metadata: HashMap, -} -``` - -`InteractionResponder` 可以先支持 oneshot: - -```rust -pub enum InteractionResponder { - Approval(tokio::sync::oneshot::Sender), - InboundMessage, -} -``` - -后续如果需要持久化长期交互,oneshot 不够,需要落库。 - -## 工具审批流程 - -工具审批是第一批最适合落地的交互类型。 - -推荐流程: - -1. AgentLoop 准备执行需要审批的工具。 -2. 调用 `InteractionManager::request_approval(...)`。 -3. InteractionManager 创建 `InteractionPayload`,通过 outbound 发送到原 channel/chat。 -4. AgentLoop 等待 oneshot,带 timeout。 -5. 用户在飞书卡片上点击 Approve/Deny/Always。 -6. FeishuChannel 收到 `card.action.trigger`,发布 `InteractionEvent`。 -7. InteractionManager resolve pending approval。 -8. AgentLoop 收到结果,继续执行或拒绝工具。 -9. 如果 channel 支持更新消息,InteractionManager 或 Channel 把原卡片更新成 resolved 状态。 - -审批 action 建议: - -```rust -approve -> ApprovalDecision::Approve -deny -> ApprovalDecision::Deny -always -> ApprovalDecision::AlwaysApprove -``` - -`DenyWithEdit` 可后续支持,适合 ACP/Web/CLI 这类能输入文本的渠道。 - -## 快捷回复流程 - -快捷回复不是阻塞工具执行,而是把用户点击转成新的用户输入。 - -示例: - -```json -{ - "kind": "QuickReply", - "body": "你想继续哪个操作?", - "actions": [ - { "label": "继续分析", "value": "继续分析" }, - { "label": "生成报告", "value": "生成报告" } - ] -} -``` - -用户点击后: - -- `InteractionEvent.action_value` 转成一条普通 `InboundMessage.content`。 -- `sender_id` 和 `chat_id` 保留原用户和会话。 -- metadata 标记来源为 interaction,供审计或 UI 使用。 - -## 消息更新 - -支持原消息更新的渠道应在交互完成后更新 UI,避免重复点击。 - -飞书: - -- 发送卡片后保存 `data.message_id`。 -- resolve 后 PATCH `/im/v1/messages/{message_id}`。 -- 卡片 schema 发送和更新都使用 Card JSON 2.0,参考项目指出跨版本 PATCH 可能返回成功但客户端不重渲染。 - -不支持更新的渠道: - -- 发送一条新消息提示“已批准/已拒绝”。 -- 或仅在后台幂等拒绝重复点击。 - -## 安全和权限 - -交互回调必须校验: - -- `interaction_id` 是否存在。 -- 是否已过期。 -- 是否已处理。 -- 点击用户是否允许处理该 interaction。 -- 当前 channel/chat 是否匹配。 - -对于工具审批,默认建议只有触发该 agent turn 的用户或允许列表用户可以审批。群聊里要特别注意 `sender_id`,不能只看 `chat_id`。 - -日志中避免记录原始飞书回调敏感字段: - -- callback token -- operator open_id/union_id/user_id/tenant_key -- open_chat_id/open_message_id - -可以记录脱敏后的 payload shape,用于排查飞书回调字段变化。 - -## 持久化策略 - -第一阶段可以只做内存 pending map: - -- 适合短时工具审批。 -- 进程重启后旧按钮点击会变成 unknown/expired。 -- 实现简单。 - -后续如果要支持长期任务或跨重启交互,需要持久化: - -- `interactions` 表保存 id、kind、channel、chat_id、sender_id、status、payload、created_at、expires_at。 -- `interaction_actions` 可选,或直接 JSON 存在 payload 中。 -- resolve 时事务更新 status,防止重复点击竞态。 - -## 与现有架构的关系 - -现有数据流: - -```text -Channel -> MessageBus -> SessionManager -> AgentLoop -> tools -> SessionManager -> MessageBus -> OutboundDispatcher -> Channel -``` - -加入 interaction 后建议: - -```text -Outbound: -AgentLoop/Tool approval -> InteractionManager -> MessageBus outbound -> OutboundDispatcher -> Channel renderer - -Inbound: -Channel callback -> InteractionEvent -> InteractionManager -> pending waiter / synthetic InboundMessage -``` - -Channel 仍然只做渠道协议适配: - -- 飞书负责 Card JSON 和 `card.action.trigger`。 -- Slack 负责 Block Kit 和 signing secret。 -- Telegram 负责 callback query。 -- CLI 负责文本命令 fallback。 - -InteractionManager 负责语义: - -- 这是 approval 还是 quick reply。 -- 是否过期。 -- 是否有权限。 -- 应该唤醒哪个等待者。 - -SessionManager/AgentLoop 不需要知道飞书卡片格式。 - -## 分阶段实施计划 - -### 阶段 1:模型和 fallback - -- 新增 `src/interaction/` 模块。 -- 定义 `InteractionPayload`、`InteractionAction`、`InteractionEvent`。 -- 增加 fallback text renderer。 -- 为 `OutboundMessage` 增加 `interaction: Option`,或短期使用 metadata。 -- 增加单元测试覆盖序列化和 fallback 文本。 - -### 阶段 2:Feishu card action - -- Feishu outbound 支持把 `InteractionPayload` 渲染为 Card JSON 2.0。 -- Feishu inbound 在 WebSocket frame 中识别 `card.action.trigger`。 -- 解析 `/action/value` 和 `/action/behaviors/0/value`。 -- 发布统一 `InteractionEvent`。 -- 保存 `message_id`,支持完成后 PATCH resolved card。 -- 增加 fixtures 测试真实/模拟的 `card.action.trigger` payload。 - -### 阶段 3:InteractionManager 和审批 - -- 新增内存版 `InteractionManager`。 -- 支持 request/resolve/timeout。 -- 接入工具执行前审批点。 -- 支持 Approve/Deny/AlwaysApprove。 -- 未知、过期、重复点击保持幂等。 -- 增加 agent/tool 审批单元测试。 - -### 阶段 4:其他渠道兼容 - -- CLI chat 支持 `/_interaction ` fallback。 -- 其他不支持原生按钮的渠道使用纯文本 fallback。 -- 后续按需实现 Slack/Telegram/Discord 原生按钮。 - -### 阶段 5:持久化和高级交互 - -- 需要时落库 pending interaction。 -- 支持 quick reply 生成 synthetic inbound message。 -- 支持表单提交。 -- 支持 `DenyWithEdit`。 -- 支持长期任务交互和重启恢复。 - -## 测试计划 - -单元测试: - -- `InteractionPayload` 序列化。 -- fallback text renderer 输出。 -- Feishu Card JSON 包含正确 callback value。 -- Feishu 回调同时支持 `/action/value` 和 `/action/behaviors/0/value`。 -- unknown/expired interaction 不报错。 -- 重复点击只 resolve 一次。 - -集成测试: - -- 模拟 Feishu `card.action.trigger`,验证 pending approval 被唤醒。 -- 模拟超时,验证默认 deny。 -- CLI fallback 输入 `/_interaction`,验证能 resolve。 -- 不支持按钮的 channel 能收到可读 fallback 文本。 - -手工验证: - -- 飞书群聊点击 Approve/Deny/Always。 -- 私聊点击。 -- 点击后原卡片更新为 resolved。 -- 重复点击不会重复执行工具。 -- 非触发用户点击时按权限策略处理。 - -## 开放问题 - -1. 工具审批应该由 AgentLoop 直接调用 InteractionManager,还是通过 SessionManager 代理? -2. 群聊中是否只允许原始触发者审批,还是允许配置中的所有 allowed user 审批? -3. `AlwaysApprove` 的作用域是本次会话、本 dialog、本 chat,还是全局工具策略? -4. 是否需要第一阶段就修改 `OutboundMessage` 结构,还是先用 metadata 降低改动面? -5. 飞书 CardKit 流式输出是否要与交互卡片统一,还是继续保持普通回复卡片和交互卡片两套路径? - diff --git a/docs/assets/message-flow.svg b/docs/assets/message-flow.svg deleted file mode 100644 index 5400292..0000000 --- a/docs/assets/message-flow.svg +++ /dev/null @@ -1,101 +0,0 @@ - - PicoBot message flow - Message flow from channel input through bus, session manager, agent loop, tools, provider, storage, outbound dispatcher, and back to the channel. - - - - - - - - - - - - Message Flow - A user message becomes a session-scoped agent run, then returns to the original channel. - - - - - - - 1 - Channel - CLI / Feishu - - - - - - 2 - MessageBus - inbound queue - - - - - - 3 - SessionManager - dialog + context - - - - - - 4 - AgentLoop - LLM/tool loop - - - - - - 5 - Tools - side effects - - - - - - 6 - Response - outbound bus - - - - - - - - - - recall memory + build prompt - - - persist messages and metadata - - - provider calls - - - OutboundDispatcher routes the reply back to the same channel/chat scope. - diff --git a/docs/assets/runtime-architecture.svg b/docs/assets/runtime-architecture.svg deleted file mode 100644 index 8efb5dc..0000000 --- a/docs/assets/runtime-architecture.svg +++ /dev/null @@ -1,88 +0,0 @@ - - PicoBot runtime architecture - High level architecture diagram showing channels, gateway, message bus, session manager, agent loop, tools, providers, storage, scheduler, skills, and MCP. - - - - - - - - - - - - PicoBot Runtime Architecture - Channels stay thin, SessionManager owns conversation state, AgentLoop remains stateless. - - - Channels - - CLI TUI - - Feishu/Lark - - WebSocket - Only receive and send - - - Gateway Core - - MessageBus - - SessionManager - - AgentLoop - - Outbound - Dispatcher - - Control Channel - - - Capabilities - - Tools - - Providers - - SQLite - - Skills - MCP tools join ToolRegistry - - - Scheduler - - Memory - - MCP - - - - - - - - - - - - - -