34 KiB
PicoBot 架构
本文档描述 PicoBot 当前实现的运行时边界、数据流、并发模型和演进约束。它面向维护者和后续参与改进的 Agent,是代码架构的主入口;行为细节仍以代码和测试为最终依据。
流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递的详细设计与取舍见 STREAMING_TURN_DESIGN.md。用户输入路由、Session 执行拆分、终态投递确认和历史增量校准的重构方案见 MESSAGE_FLOW_REFACTOR_DESIGN.md。配置运行代、重载边界和失败语义见 CONFIG_HOT_RELOAD_DESIGN.md。
1. 设计目标
PicoBot 是一个单进程、异步、可扩展的个人 AI 助手运行时。核心目标是:
- 用统一消息模型接入不同聊天渠道。
- 隔离渠道、会话、模型、工具和持久化职责。
- 同一会话内保持消息顺序,不同会话之间允许并发。
- 让外部 I/O、后台任务和程序关停都有明确边界。
- 通过 SQLite 保存会话、消息、记忆、定时任务及后台任务状态。
当前不是分布式系统。除调度任务使用数据库租约防止重复领取外,运行时会话状态由单个 Gateway 进程持有。
2. 运行模式与进程边界
PicoBot 只有一个二进制,提供四种运行模式:
| 模式 | 入口 | 职责 |
|---|---|---|
| Gateway | cargo run -- gateway |
组装服务、监听 HTTP/WebSocket、提供嵌入式 WebUI,运行渠道、会话、调度器和后台任务 |
| CLI client | cargo run -- chat |
运行 Ratatui UI,通过 WebSocket 使用 Gateway,不持有业务状态 |
| One-shot client | cargo run -- run "prompt" |
使用独立临时 chat scope 通过 WebSocket 提交一条消息,等待 Turn 终态,输出结果后退出 |
| Health diagnostic | cargo run -- health [--json] |
只读检查核心、配置相关和可选外部依赖,不启动 Gateway 或连接 Provider |
Linux 上可通过 picobot service install/start/stop/status/restart/uninstall 管理 systemd 用户服务。unit 固定为 picobot.service,其主进程仍是普通 Gateway 模式,不引入额外 daemon/fork 层;异常退出由 systemd 按 Restart=on-failure 拉起。
picobot reload、/reload 和根交互 Agent 的 reload_config 工具共享 Gateway 内部的有界重载控制通道。重载先用启动前捕获的进程环境重新解析配置和 .env,构造完整的下一运行代;候选构造失败时旧运行代不变。成功后关闭旧代 admission,等待已进入的消息、交互 Turn、Scheduler job 和后台子 Agent 到达持久化/投递边界,再停止旧渠道和受监督任务、主动关闭旧 WebSocket,并在保留的监听 socket 上启动新运行代。MCP 只在新代激活时连接。每次重载有 generation ID,可通过 GET /api/config/reload/status 查询相位。监听 host/port、workspace 和 SQLite 有效路径属于进程级不变量,变更时拒绝热重载并要求完整重启。
原生 Gateway 默认绑定 127.0.0.1:19876,gateway.host/gateway.port 可由命令行 --host/--port 覆盖。Docker Compose 为保证端口映射可达,默认向容器传入 0.0.0.0:19876;PICOBOT_GATEWAY_HOST 控制容器内监听地址,PICOBOT_PUBLISH_HOST 控制宿主机发布地址,PICOBOT_GATEWAY_PORT 同时控制监听与映射端口。
CLI TUI 在 ~/.picobot/tui_client_id 保存非敏感客户端标识,并通过 WebSocket 查询参数 client_id 传给 Gateway。cli_chat 以该标识作为稳定 chat scope;重连时恢复内存中的当前 dialog,Gateway 重启后则恢复该 scope 最近活跃的未归档 dialog。无效或缺失的标识会退化为连接级随机 scope。
One-shot client 不绕过 Gateway 直接调用 Provider。每次 run 生成独立的 run-<uuid> scope,通过相同的 cli_chat、MessageBus、SessionManager、AgentLoop 和 Turn delivery 路径执行;它不复用 TUI scope,因而不会替换同一 scope 的活动 WebSocket。默认 stdout 只投影终态 Assistant blocks,进度写到 stderr;超时或 Ctrl-C 会先在当前 scope 发送 /stop。
Gateway 启动时先从配置目录 .env、workspace .env 和既有进程环境合并启动变量,再初始化日志并切换进程工作目录到 workspace_dir。优先级为进程环境 > workspace .env > 配置目录 .env;配置目录层先用于定位 workspace,workspace 层不得重定向自身位置。环境文件只在单线程启动阶段写入进程环境,不能移到后台任务启动之后。重载使用启动前保存的进程环境快照解析各层,但不再修改进程环境,避免多线程运行期调用 set_var;新 Provider、MCP 和渠道使用解析后配置中的值。切换完成后所有相对路径都应按 workspace 解释,不能假设仍位于源码仓库。
3. 组件关系
flowchart LR
External[CLI / Feishu] --> Channels[channels]
Channels -->|InboundMessage| Bus[MessageBus]
Bus --> Processor[Gateway inbound/control routers]
Processor --> Sessions[SessionManager]
Sessions --> Agent[AgentLoop]
Agent --> Providers[LLM providers]
Agent --> Tools[ToolRegistry / MCP]
Sessions <--> Storage[(SQLite)]
Sessions -->|TurnSnapshot| Delivery[DeliveryCoordinator]
Delivery -->|TurnSink| Channels
Sessions -->|OutboundMessage| Bus
Scheduler[Scheduler] --> Sessions
Bus --> Dispatcher[OutboundDispatcher]
Dispatcher --> Channels
Supervisor[TaskSupervisor] -. lifecycle .-> Processor
Supervisor -. lifecycle .-> Dispatcher
Supervisor -. lifecycle .-> Scheduler
Supervisor -. lifecycle .-> Sessions
模块职责
| 模块 | 拥有的职责 | 不应承担的职责 |
|---|---|---|
gateway |
依赖装配、HTTP/WS/WebUI 入口、启动和关停顺序 | 业务规则、渠道协议细节 |
channels |
外部协议适配、权限检查、媒体收发 | 会话选择、LLM 调用 |
bus |
三条有界异步队列与出站投递协调 | 会话路由、业务状态 |
session |
dialog 路由、会话状态、串行工作队列、上下文和持久化协调 | 外部渠道协议 |
agent |
单次无状态模型/工具循环、上下文压缩、子 Agent、Turn 语义事件 | 持有 dialog 生命周期 |
providers |
把统一请求映射为原生模型流,并归一化正文、reasoning、工具和 usage | Session、Bus 或 Channel 感知 |
delivery |
活动 Turn 快照投影、latest-wins 节流、终态重试和 TurnSink 生命周期 | Provider 协议、会话历史、平台 API 细节 |
tools / mcp |
工具定义、注册和执行适配 | 隐式修改会话路由 |
health |
聚合只读依赖检查,供 CLI、Tool 与 slash command 复用 | 安装、修复或连接 Provider |
storage |
SQLite schema、迁移、原子 CRUD | 运行时调度策略 |
memory |
Knowledge/Timeline 的存取与召回 | 直接驱动消息发送 |
scheduler |
领取到期任务、执行普通/巡检 Agent、应用投递策略、原子记录结果 | 复用聊天会话历史、直接感知 Channel |
work |
session 级单 active plan、并行子项状态机、版本和变更事件 | 执行模型调用、持有 Channel/WebSocket |
task_supervisor |
后台任务注册、取消、限时回收 | 业务级重试和结果语义 |
4. 消息与控制数据流
MessageBus 包含三条容量相同的 Tokio MPSC 队列:
inbound:Channel → Gateway inbound router。outbound:Session/Tool →OutboundDispatcher。control:WebSocket/Channel → Gateway control router,用于 dialog 操作。
普通消息
sequenceDiagram
participant C as Channel
participant B as MessageBus
participant G as Inbound router
participant S as SessionManager
participant W as Per-session worker
participant A as AgentLoop / Provider
participant T as TurnController
participant L as DeliveryCoordinator
participant D as OutboundDispatcher
C->>B: publish InboundMessage
B->>G: consume inbound
G->>S: handle_message
S->>W: try_send AgentTask
S-->>G: AgentProcessing
W->>T: start Turn
W->>L: subscribe latest snapshots
W->>A: process_streaming(history)
A-->>T: reasoning/text/tool events
T-->>L: complete TurnSnapshot
L->>C: TurnSink update (best effort)
A-->>W: emitted messages
W->>W: atomic persistence
W->>T: Completed
T-->>L: terminal snapshot
L->>C: TurnSink finish (bounded retry)
W->>B: independent messages/fallback only
B->>D: consume outbound
D->>C: Channel::send
关键语义:
- Gateway inbound router 按
(channel, chat_id)使用容量 32 的短生命周期 lane 保持入口顺序,不同聊天可并发路由;普通消息进入对应 session worker 后立即返回AgentProcessing。 /stop绕过同聊天的 inbound lane,直接使正在运行的 worker/Turn 失效;其他 slash command 仍在聊天 lane 内有序执行。- 每个 session 有一条容量为 32 的队列,同一 session 串行处理,不同 session 的 worker 可并发执行。
- 队列满时明确拒绝新消息,不允许无界积压。
- Slash command 不进入 Agent 队列,由
SessionManager直接执行,因此/stop等控制操作不会排在长模型调用之后。 InboundMessage只保存规范化输入:sender_id、received_at、媒体和一个ChannelContext。核心只解释其中的reply_to,其语义是本轮出站应回复的当前入站消息;被用户引用的父消息只用于补充模型上下文。reaction/message ID、话题 root/thread 等平台字段作为private不透明传到对应 Turn/普通回复,不能散落为核心层 magic key。持久化的用户消息保留真实接收时间和UserInput来源,客户端历史投影不暴露来源中的平台用户 ID。- Session 为每个 Agent 请求创建一个
TurnController。Provider 向 AgentLoop 发 delta,AgentLoop 发结构化 TurnEvent,只有 TurnController 能把事件归约为有序 block 和单调 revision 的完整快照。 - Turn 快照经 Tokio
watch发布,语义为 latest-wins;慢客户端或慢渠道跳过中间状态,不反压 Provider。终态明确编码在快照中,不依赖 sender 关闭。 - Agent 本轮消息原子持久化成功后才发布
Completed。取消或失败若已有可见正文,则保存为cancelled/interruptedpartial;只有 reasoning 时不创建 assistant 历史。
活动 Turn 投递
DeliveryCoordinator 与普通出站投递并列:
TurnDeliveryService根据 Channel 创建本轮独占的TurnSink;sink 私有保存远端消息 ID 和清理资源。PresentationPolicy在快照离开 Gateway 核心前过滤内容。TUI/WebUI 展示独立 reasoning 和详细工具状态;外部 Channel 不接收 reasoning,只接收紧凑工具状态;无人值守投递只保留正文。LivePolicy::Snapshot按渠道间隔发送最新运行态;FinalOnly忽略运行态,只处理终态。终态绕过节流并只对明确的瞬态错误重试。TurnDeliveryService返回可等待的终态句柄;sink 生命周期启动不等于终态已送达。Session 在终态重试最终失败时通过普通出站路径兜底一次。cli_chat将同一turn_updated快照发给 TUI 和 WebUI。客户端只保留当前 session 中 revision 更新的active_turn,终态随后由持久化历史校准。- 飞书对每个 DATA 帧先在 2 秒硬期限内 ACK,再进行有界分片重组,并把完整事件交给容量 32 的连接内处理队列;媒体下载和引用查询不占用正常的 WebSocket 读循环。队列饱和时当前事件在连接任务中同步处理而不丢弃。连接异常采用有上限的指数退避持续重连,不因累计故障永久停止。
- 飞书在协议解析阶段按
allow_from拒绝未授权用户;群聊默认必须明确 @ 运行时解析出的机器人身份,身份解析失败时安全地忽略群消息。飞书把当前消息 ID 作为reply_to,并在私有 metadata 中携带 root/thread 信息;Sink 使用原生 reply API 及reply_in_thread保持客户端引用和话题位置。飞书默认FinalOnly;开启live_updates后,第一个可见快照创建卡片,后续编辑同一卡片,终态编辑失败则发送完整结果兜底。reaction 清理在 finish、abort 和 Gateway shutdown 中幂等执行。 - 飞书入站响应体按类型流式执行字节上限和超时检查,写盘前校验媒体目录总容量,客户端文件名先收敛为安全 basename;出站上传也先检查本地文件大小。消息发送和资源下载对网络错误、429、5xx 和 401 进行有界重试,401 或飞书失效 token 业务码会使租户 token 缓存失效后重新获取。
- DeliveryCoordinator 与 OutboundDispatcher 共享
(channel, chat_id)写锁,避免活动 Turn 终态与独立消息并发写入同一目标。
出站投递
OutboundDispatcher 按 (channel, chat_id) 建立独立 lane:
- 同一目标的消息保持顺序。
- 慢目标不会阻塞其他目标。
- 每条 lane 容量为 64,空闲 300 秒后退出。
- 单次发送超时 30 秒;最多尝试 3 次,前两次失败后分别等待 1/2 秒。只有
ConnectionError和SendError会重试。 deliver_outbound可等待渠道真实投递结果,等待上限 120 秒;普通publish_outbound只保证成功入队。
不要把“已进入 Bus”误认为“外部渠道已收到”。需要确认语义时必须使用 deliver_outbound。
Control 消息
WebSocket dialog 操作通过 ControlMessage 携带一次性回复通道。Gateway control router 以最多 64 个并发受监督任务调用 SessionManager,再将 SessionEvent 回传给发起者;慢 control 不阻塞其他聊天的 inbound 路由。Bus 只承载消息,不解释操作。
TUI 的历史回放同样走 control 队列:get_session_history 先校验 session 属于当前客户端 scope,再由 SessionManager 从 Storage 读取最近消息。单次查询限制为 1–2000 条,TUI 默认请求最近 1000 条;迟到的历史响应只有在目标仍是当前 dialog 时才允许更新界面。
Agent worker 发出的异步回复和通知在 OutboundMessage metadata 中标记来源 session,cli_chat 将其映射为 WebSocket session_id。TUI 切换 dialog 后不渲染其他 session 的迟到结果;结果仍按原 session 持久化,切回时通过历史回放显示。
5. 会话模型与并发不变量
Session ID 格式为:
<channel>:<chat_id>:<dialog_id>
SessionManagerInner 保存:
sessions:完整 Session ID 到内存 Session 的映射。current_sessions:channel:chat_id到当前 dialog 的映射。
必须维护以下不变量:
- 同一 Session 的 Agent 工作由一个 generation 对应的 worker 串行执行。
/stop或 worker 替换会递增worker_generation;旧 worker 不得再提交结果。- 慢操作(模型、记忆召回、压缩、SQLite I/O)不能长期持有 Session mutex。
- 慢操作开始前记录
state_version,提交前重新验证,防止旧快照覆盖/clear、/delete等并发修改。 - 持久化写入由
persistence_lock串行化;多条相关记录应使用 Storage 的原子接口。 - 内存先变更但持久化失败时,必须回滚精确匹配的消息后缀,不能删除无关的新状态。
当前 WebUI/TUI Turn 通过 send_message(files=...) 向自身 session 投递文件时,文件先进入 task-local Turn delivery 暂存区,成功结束后附加到最终 assistant 消息,与工具链一起原子提交;因此持久化和刷新后的顺序都是工具调用/结果在前、携带附件的最终回复在后,也不会生成带 [message from ...] 的自投递气泡。其他同 Turn 自投递仍是受控例外:只有 task-local Turn ID 仍匹配该 session 的 active Turn,写入才允许不递增 state_version。跨 Turn、跨 session 以及无法证明所有权的写入仍必须递增版本。Provider 回放历史附件时,只有 user 输入和当前工具结果可生成模型原生媒体块;assistant/system 附件只回放文本清单,避免把图片放到供应商不接受的角色。
SessionManager 负责组装会话上下文:系统提示、Skills、召回的 Knowledge、压缩后的 Timeline、可选的 active plan 摘要和当前消息历史。session::turn_input 在 Session 锁外并行读取 Knowledge、active plan 并压缩历史,然后通过同一个 assembly 路径生成首次请求和 context-overflow 重试输入;重试不得复制系统提示或 runtime context 拼接逻辑。普通闲聊 session 没有 plan 摘要;计划状态由 WorkManager 从 SQLite 读取,因此不以自然语言摘要作为权威来源。AgentLoop 接收完整输入执行一次模型/工具循环,本身不拥有会话状态。执行工具时额外传递只包含 session/turn 身份的 ToolExecutionContext;无状态工具使用默认实现忽略它,有状态外部适配器用它路由资源,但可按明确的单用户配置跨 dialog 共享,且不能自行反向查询 SessionManager。
当前 Turn 的工具进度只从 AgentLoop 的结构化 TurnEvent 进入 TurnController,不能另建字符串 notification 通道重复投递。后台子 Agent 的 TaskNotification 表达跨 Turn 的任务完成,仍由独立的受监督消费者投递。自动标题属于非关键派生工作:Turn 持久化完成后由 TaskSupervisor 调度,Session worker 不等待模型生成;同一 Session 同时最多有一个标题任务,提交时仍校验标题保持默认值,避免覆盖用户改名。
每个 session 最多有一个 active plan,但 plan 内多个 item 可以分别绑定不同子 Agent 并行执行。主 Agent 通过 todo 创建和维护计划,通过 delegate.plan_item_id 委托;子 Agent 不获得 todo 或 delegate。领取 item 使用条件更新防止重复执行,迟到结果只有在 plan 仍 active 且 execution ID 匹配时才允许提交。
6. 持久化
Storage 使用 SQLx + SQLite,默认数据库为 {workspace_dir}/picobot.db。连接启用:
- WAL journal mode。
- foreign keys。
- 5 秒 busy timeout。
- schema version 迁移。
持久化范围包括 sessions、messages、memories、task plans/items、scheduled jobs、job runs 和 background tasks。消息保存可展示 reasoning_content、Provider 私有回放状态、turn_id、iteration 和 completion status;私有 Provider 状态不进入 WebSocket/Channel,且只允许回放给同一 Provider。成功持久化一个 Turn 后,交互 Channel 收到只包含公开字段的 CommittedTurnDelta,其中 history_revision 是本批次最高 durable sequence;客户端按 revision 幂等合并,正常完成不重新加载整段历史,断线重连和失败/取消仍使用 SessionHistory 校准。Provider 诊断和失败审计只记录模型、消息数、工具数等请求摘要,不保存正文、reasoning 或签名 payload。修改 schema 时应:
- 更新集中式 schema/迁移逻辑。
- 保留已有数据库的升级路径。
- 为新库初始化和旧库迁移分别增加测试。
- 对“状态更新 + 执行记录”等复合写入使用事务。
安全边界
- API Key 和渠道凭据只来自配置占位符、配置目录/workspace 的
.env或进程环境,不得写入仓库。既有进程环境优先级最高,workspace.env可覆盖配置目录.env;日志只能记录所加载的文件路径,不能记录变量值。 - 日志不得输出 token、secret、Authorization header,或包含临时凭据的完整 URL;应记录脱敏后的 host/path 和必要诊断字段。
- Gateway 把 cwd 切到 workspace,因此相对文件路径和 Shell 默认从 workspace 开始;这不是硬沙箱。当前内置文件工具接受绝对路径,Bash 也可访问进程权限允许的位置。若某场景需要硬边界,必须显式配置/实现 allowed directory 和进程隔离。
http_request和web_fetch的私网/回环地址校验属于 SSRF 防线,重构网络层时不能绕过。- 外部内容、Tool 输出和 MCP 响应均是不可信输入;解析错误应返回结构化失败,不能 panic。
7. 后台任务与生命周期
TaskSupervisor 是 Gateway 内部后台任务的统一所有者。inbound/control routers、inbound lanes、outbound dispatcher、scheduler、session workers、Turn delivery、outbound lanes、后台任务通知消费者、自动标题和子 Agent 后台任务都应通过它注册。
两种注册方式:
spawn:收到全局取消后直接丢弃任务 future,适合无需异步清理的任务。spawn_graceful:任务自己观察 cancellation token 并清理,Supervisor 在总宽限期结束后再强制 abort。
新增长生命周期任务时必须满足:
- 有明确 owner,禁止无法回收的裸
tokio::spawn。 - 能响应取消;外部连接、重试 sleep 和阻塞式等待也要纳入取消分支。
- 等待任务退出必须有硬超时,超时后 abort 并回收 JoinHandle。
- 任务 panic、超时和永久错误要可观测,且不能阻止其他组件清理。
WebSocket 每个客户端的 writer task 是连接局部任务,由连接 handler 自己限时回收;它不跨越连接生命周期。
Turn delivery 使用 spawn_graceful。全局取消发生时先停止读取快照,再在共享目标写锁下有界调用 TurnSink::abort,使平台 reaction 等私有资源能在 Supervisor 宽限期内清理。
WebUI 与管理 API
Gateway 在 / 提供随二进制编译的 HTML/CSS/JavaScript,不依赖外部 CDN 或前端运行服务。前端源码位于 webui/,由 Svelte 5 + Vite 构建,Bits UI 提供无样式的可访问组件原语。视觉层通过 webui/src/styles.css 中的本地 Fluent 2 语义令牌实现浅色/深色表面、六套品牌色、状态色、层级和控件状态;页面组件必须复用语义别名,不能把独立硬编码调色板或外部 Fluent 运行库引入发布产物。明暗模式和品牌色只保存在浏览器 localStorage,theme-init.js 必须在 Svelte 挂载前恢复 data-theme 与 data-accent,防止首屏颜色闪烁;这些外观选项不属于 Gateway 配置,也不跨设备同步。build.rs 监听前端源码、锁文件和构建配置,增量地将生产资源生成到 Cargo OUT_DIR,Rust 再从该目录编译嵌入;前端产物不进入仓库,最终用户使用发布二进制时不需要 Node.js。浏览器聊天继续使用 /ws 和 cli_chat 渠道,因此复用现有 dialog scope、每会话串行 worker、历史持久化和出站 lane;会话历史帧保留工具调用 ID、名称、参数和工具结果角色,WebUI 在正常完成时合并 turn_committed 增量并将工具信息渲染为默认折叠的工具卡片;聊天页的 Todo 侧栏默认隐藏,按 session 保存快照和未读状态,并通过结构化 session_plan/plan_updated 帧刷新,计划变化不会写入聊天历史;斜杠命令补全通过 get_slash_commands 获取 Gateway 的实时命令与别名清单,不在前端重复定义;WebUI 不直接调用 Provider 或 SessionManager。
WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输,WebSocket 只携带短期 upload_id 和结构化附件描述。UploadRegistry 在内存中按 cli_chat chat scope 校验并消费待发送上传;消息继续以 media_refs 保存 Gateway 本地路径,不建立永久附件资产。下载接口必须通过 client、session、message 和附件序号反查路径,不能接受客户端路径。历史附件路径失效属于正常状态,不得影响历史文本读取。待发送但未进入消息的上传由 TaskSupervisor 所有的限时清理任务回收。Agent 上下文会为所有媒体注入内部路径清单,客户端响应不得暴露该路径。
所有工具调用统一归一化为 ToolOutput,并由 AgentLoop 中唯一的 ToolOutputProcessor 后处理。普通文本工具仍实现 ToolResult,默认转换会将其包装为无产物的 ToolOutput;产物工具返回带 ToolArtifact 的输出,并用 Model、User 或 ModelAndUser 声明受众。处理器只发布成功工具的产物,去重后分别形成下一轮模型媒体和最终用户回复附件。工具只负责经过自身路径策略校验后声明产物与意图,不感知当前模型、Provider、Session 或 Channel。AgentLoop 仅将最新连续工具结果批次的模型媒体交给 MediaHandlerRegistry,旧工具媒体只回放文本和路径,避免历史 Base64 膨胀;用户媒体累积到本 Turn 最终 assistant 消息,随工具链原子持久化,并由 committed-history 或普通出站路径呈现。OpenAI-compatible Provider 保持 tool 结果为文本,并在完整工具批次后构造仅存在于请求内的临时多模态 user 消息;Anthropic Provider 将媒体放入对应 tool_result.content。媒体加载、格式或能力检查失败必须降级成文本,不得使历史记录不可读取。
browser 是有状态工具适配器:BrowserTool 保持模型侧 action schema,BrowserManager 按每次调用是否带 persistent_id 分流。省略 ID 时把 PicoBot dialog 映射到随机临时 agent-browser session,并用每 session mutex 保证同一页面串行、不同 dialog 并发;长期工作需要保留登录或站点状态时,Agent 可自主创建持久身份并在后续相关 action 中持续传入同一个 ID。Manager 按持久 ID 保存 agent-browser session 和 mutex,同一 ID 跨 dialog 共享且串行,不同 ID 相互独立并可并发,Gateway 重启或 daemon 退出后继续使用原 Profile;没有全局持久化开关、默认 ID 或按 dialog 隐式选择。browser_profiles 在受控根目录下创建、设置语义化标签、列出或删除格式合法的 ID;标签只负责识别,选择仍使用不可变 ID,删除活动 ID 时先等待其 action 并关闭浏览器。AgentBrowserRunner 以 argv 和 --json 调用外部原生 CLI,设置硬超时、输出/content boundaries/domain allowlist,并在持久调用中传入受控 --profile 路径,底层 daemon 通过 Chrome CDP 工作。PicoBot 不链接 agent-browser 内部 crate、不直接暴露其 MCP、不使用 Fantoccini/ChromeDriver/WebDriver。持久 Profile 与 allowed_domains 因上游安全边界互斥;设置域名限制时临时浏览器仍可用,持久调用会被拒绝。截图只能写入配置的 artifact directory,并作为 ModelAndUser 产物返回,默认附到最终用户回复;仅当调用显式设置 present_to_user=false 时才作为模型内部观察。完整边界见 AGENT_BROWSER_INTEGRATION.md。
HealthService 是依赖检查的唯一实现。CLI picobot health、只读 health 工具和 /health 斜杠命令必须复用它;检查可探测命令、版本、配置路径和 agent-browser offline quick doctor,但不能安装/修复软件、连接模型 API 或泄漏配置秘密。
AuthManager 默认保护所有管理 API 和 /ws。静态资源、/health、/api/auth/status 与 /api/auth/pair 保持公开,使未配对浏览器只能加载配对界面。picobot pair 使用权限为 0600 的本机管理密钥调用仅接受真实回环连接的 /api/auth/code;反向代理即使从回环连接也无法在没有该密钥时签发代码。本机 picobot run 可用同一个管理密钥直接认证 /ws,但中间件必须同时验证请求路径严格等于 /ws 且 ConnectInfo 中的真实 TCP 对端为回环地址;这一身份不能访问管理 API。远程 run 与 TUI 一样使用已配对的 Bearer token。配对码为 8 位、5 分钟有效、单次消费,并按来源实施失败锁定。浏览器收到 HttpOnly、SameSite=Strict Cookie,CLI 使用 Bearer token;服务端仅持久化 SHA-256 哈希。--revoke-all 的持久化成功后才清空内存令牌,活动 WebSocket 每 5 秒复核身份并回收已撤销连接。
同源 /api/* 管理接口只提供显式白名单能力:
- 配置读取/原子写入;响应中的密钥字段统一掩码,原样提交掩码会恢复现有值。
POST /api/config/reload通过同一重载控制器校验并切换 Gateway 运行代,GET /api/config/reload/status查询 generation、相位与最近错误。 USER.md、AGENTS.md只允许固定文件名,不接受任意路径。- 日志、记忆、任务和运行记录均限制单次返回数量;日志目录固定为
~/.picobot/logs。 - 任务与记忆读取复用 Storage API,不允许 WebUI 直接持有 SQLite 连接或拼接任意查询。
- 文件上传/下载复用设备鉴权并校验 chat/session/message scope;客户端不能提交或获得服务端路径,inline 预览仅允许安全 MIME 白名单。
- 前端依赖只存在于源码构建阶段;生产页面不加载 CDN。
build.rs以package-lock.json的依赖 stamp 判断是否需要npm ci,并依靠 Cargorerun-if-changed避免后端代码变化触发前端重建。前端开发仍须运行npm run check,并以cargo build验证最终嵌入路径。
配对鉴权只证明设备持有凭据,不提供机密性。非回环部署仍必须由反向代理或其他外层提供 TLS;显式设置 gateway.require_pairing=false 会恢复无鉴权模式,仅适合隔离环境。
8. 启动与关停顺序
启动
- 解析配置路径,加载配置目录
.env,据此定位 workspace,再加载 workspace.env;既有进程环境保持最高优先级,合并后重新解析配置。 - 初始化日志,创建并切换到 workspace,初始化 WebUI 配对存储与本机管理密钥。
- 初始化 SQLite、MemoryManager、MessageBus 和 SessionManager;Scheduler 启用时幂等创建默认日常维护巡检。
- 注册内置工具、渠道、MCP 工具和 Cron 工具。
- 启动所有 Channel。
- 通过 TaskSupervisor 启动 inbound/control routers、dispatcher 和 scheduler。
- 注册 WebUI 静态资源、管理 API 与聊天 WebSocket 路由。
- 绑定 Axum listener,开始接收请求。
关停
Ctrl-C/SIGINT 或 SIGTERM 触发 Axum graceful shutdown,并取消所有 WebSocket 连接。systemd 的stop/restart使用 SIGTERM,因此与前台退出共享同一清理链路。ChannelManager::stop_all先停止外部消息入口并注销渠道。- 取消 TaskSupervisor,停止接受新后台任务。
- 在共享的 10 秒总宽限期内等待任务退出,之后 abort 剩余任务。
渠道自己的 stop() 也必须有界。以飞书为例,端点请求、WebSocket 建连、重试等待和已连接循环共享 CancellationToken,另有 5 秒强制回收兜底。
9. 扩展指南
新增 Channel
- 实现
Channeltrait,仅处理外部协议和统一消息转换。 - 在
ChannelManager::init注册,并通过同一个 MessageBus 收发。 - 将可重试错误表示为
ConnectionError/SendError,永久错误使用其他类型。 - 为 start/stop 幂等性、取消建连、投递失败和媒体边界增加测试。
- 不要从 Channel 直接调用 SessionManager 或 Provider。
- 若支持活动 Turn,实现
live_policy、presentation_policy和每 Turn 一个实例的open_turn;sink 必须消费完整快照而不是拼接 token,并使 finish/abort 清理幂等。
新增 Tool
- 实现
Tool,在集中注册点加入ToolRegistry。 - 参数 schema 必须明确;返回值保持可供模型消费的字符串协议。
- 明确工具需要“workspace 默认目录”还是“不可逃逸的硬边界”;后者必须显式校验 canonical path,不能只依赖 cwd。
- 网络工具必须保留 SSRF/私网地址校验。
- 长操作应有超时;后台执行应交给 SubAgentManager/TaskSupervisor。
- 需要跨调用保存外部状态时,实现
execute_with_context并按 session 隔离;不得让模型控制底层全局 session ID。
新增 Provider
- 实现
LLMProvider,保持其为纯 HTTP/API 适配器。 - 统一映射文本、媒体、tool calls、usage 和错误。
- 不在 Provider 中访问 Session、Bus 或 Channel。
- 为请求序列化和响应兼容性编写离线单元测试。
修改 Session
先画出锁、慢 I/O、版本检查和持久化顺序。任何跨 await 的 Session 锁都需要特别审查;任何由旧快照产生的结果都必须在提交前验证 generation/state version。
10. 验证策略
按改动范围选择最小但充分的验证:
| 改动 | 至少执行 |
|---|---|
| 文档 | 检查链接、命令和源码路径;git diff --check |
| Rust 实现 | 相关定向测试、cargo test --lib、cargo clippy --all-targets --all-features -- -D warnings |
| 构建/依赖 | 上述检查加 cargo build |
| Provider/真实渠道 | 离线测试;有凭据时再运行 ignored integration tests |
| SQLite schema | 新库测试、迁移测试、原子性测试 |
| 生命周期/并发 | 成功、失败、超时、取消、队列满和重复 start/stop 测试 |
模型 API 集成测试需要 tests/test.env 中的真实 API Key,并默认 ignored;test_scheduler 和 test_request_format 是可直接运行的离线测试。不应在无凭据时假装已经验证真实 Provider。
11. 演进决策清单
提交架构性修改前,逐项确认:
- 是否仍遵守 Channel → Bus → Session → Agent 的依赖方向?
- 是否引入第二个 owner、重复状态或绕过统一注册点?
- 队列、并发、重试和等待是否全部有界?
- 取消信号能否覆盖建连、sleep、I/O 和清理阶段?
- 是否在持锁时执行了网络、模型或数据库慢操作?
- 内存与 SQLite 失败时能否保持一致?
- 错误是否区分瞬态和永久语义?
- 是否有测试覆盖正常路径与最危险的失败路径?
- README、AGENTS.md 和本文档是否需要同步更新?
12. 代码导航
| 主题 | 入口 |
|---|---|
| Gateway 装配和关停 | src/gateway/mod.rs |
| WebSocket 生命周期 | src/gateway/ws.rs |
| Bus 和消息类型 | src/bus/mod.rs, src/bus/message.rs |
| 出站并发与重试 | src/bus/dispatcher.rs |
| Channel 接口与注册 | src/channels/base.rs, src/channels/manager.rs |
| Session 核心 | src/session/session.rs |
| Session 命令/事件 | src/session/commands.rs, src/session/events.rs |
| Agent loop | src/agent/agent_loop.rs |
| 后台任务监督 | src/task_supervisor.rs |
| SQLite 初始化和迁移 | src/storage/mod.rs |
| Scheduler | src/scheduler/mod.rs |
| 配置加载 | src/config/mod.rs |