# PicoBot 架构

本文档描述 PicoBot 当前实现的运行时边界、数据流、并发模型和演进约束。它面向维护者和后续参与改进的 Agent，是代码架构的主入口；行为细节仍以代码和测试为最终依据。

流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递的详细设计与取舍见 [STREAMING_TURN_DESIGN.md](STREAMING_TURN_DESIGN.md)。用户输入路由、Session 执行拆分、终态投递确认和历史增量校准的重构方案见 [MESSAGE_FLOW_REFACTOR_DESIGN.md](MESSAGE_FLOW_REFACTOR_DESIGN.md)。已实施的 checkpoint 上下文压缩、pi 风格 reserve 阈值、统一编排和 overflow 失败语义见 [CONTEXT_COMPACTION_DESIGN.md](CONTEXT_COMPACTION_DESIGN.md)。配置运行代、重载边界和失败语义见 [CONFIG_HOT_RELOAD_DESIGN.md](CONFIG_HOT_RELOAD_DESIGN.md)。具名子 Agent、委托图、后台收件箱、结果传递机制与 `queue`/`steer` 信号的设计见 [SUB_AGENT_DESIGN.md](SUB_AGENT_DESIGN.md)。已实施的统一 Scheduled Run、结构化终结协议、中央投递和 v11 数据库迁移见 [SCHEDULED_RUN_DESIGN.md](SCHEDULED_RUN_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. 组件关系

```mermaid
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` | 工具定义、注册和执行适配；MCP 工具的本地执行属性声明 | 隐式修改会话路由 |
| `health` | 聚合只读依赖检查，供 CLI、Tool 与 slash command 复用 | 安装、修复或连接 Provider |
| `storage` | SQLite schema、迁移、原子 CRUD | 运行时调度策略 |
| `memory` | Knowledge/Timeline 的存取与召回 | 直接驱动消息发送 |
| `scheduler` | 原子领取 occurrence、运行隔离的 Root/命名 Agent、提交结构化结果并 drain 持久化投递 outbox | 解析模型自然语言、绕过 Bus 直接调用 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 操作。

### 普通消息

```mermaid
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 (idle or /queue)
    S-->>G: AgentProcessing
    W->>T: start Turn
    W->>L: subscribe latest snapshots
    W->>A: process_streaming(history)
    C->>B: ordinary input during active Turn
    B->>G: consume inbound
    G->>S: handle_message
    S->>A: bounded steering mailbox
    A->>A: drain after tool batch / before final
    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 保持入口顺序，不同聊天可并发路由；没有活动 Turn 时，普通消息进入对应 session worker 后立即返回 `AgentProcessing`。
- `/stop` 绕过同聊天的 inbound lane，直接使正在运行的 worker/Turn 失效；其他 slash command 仍在聊天 lane 内有序执行。
- 活动 Turn 存在时，普通消息默认作为 steering 进入本 Turn 的有界 mailbox；`/queue <message>` 明确进入下一 Turn。AgentLoop 只在完整工具批次结束后、或准备接受无工具最终回复时排空 mailbox，并把输入作为真实、可持久化的 `role=user` 消息加入下一次模型请求。
- Steering mailbox 最多容纳 32 条、合计 64 KiB 文本与元数据。mailbox 已关闭或满时，输入可靠回退到 session 队列；若 session 队列也满则明确拒绝。Session 在入站时分配单调序号，Turn 结束时未消费的 steering 由 worker 本地恢复队列接管，并与 `/queue` 输入按该序号合并选择，不能丢失或互相超越。
- 每个 session 有一条容量为 32 的普通队列，同一 session 仍只运行一个 Turn，不同 session 的 worker 可并发执行。
- 队列满时明确拒绝新消息，不允许无界积压。
- Slash command 通常不进入 Agent 队列，由 `SessionManager` 直接执行；`/queue` 是显式排队输入，`/stop` 是显式中断并清空当前 mailbox 与队列。
- WebSocket `user_input.client_message_id` 只用于让 `turn_committed` 以同一消息 ID 替换 WebUI 的乐观用户气泡；它不改变入站顺序或 steering/queue 决策。
- `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`/`interrupted` partial；只有 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`。

### Scheduled Run

Scheduler 不复用聊天历史，也不根据模型正文猜测是否通知。一次到期状态在同一 SQLite 事务中取得 Job 租约、插入 `job_runs(status=claimed)`、快照 Agent/投递目标/策略，并提前推进 recurring `next_run_at`；`At` 在 claim 时立即禁用。事件循环以独立有界 JoinSet 执行 Run 和 drain delivery，长任务不阻塞其他领取或通知。

每次 Run 通过 `AgentCoordinator` 建立顶层 `agent_runs` 审计记录并执行隔离的 Root 或命名 Agent。Scheduled origin 贯穿所有后代，但只有顶层获得 exactly-once `complete_scheduled_run` sink；后代不继承 sink。Scheduled Agent 不获得 `send_message`、cron/config 管理或 `emit_signal`，不创建 Inbox completion slot，任何 background 委托都收敛为 foreground。普通最终文本不代表成功；没有提交 `ok/alert/failed/refused` 之一即 fail-closed。

顶层 AgentRun 终态、JobRun 的 lifecycle/outcome/message/diagnostic、Job 最近摘要、初始 delivery status 和租约释放在一个事务中提交。`always` 投递所有 outcome，`on_alert` 只抑制结构化 `ok`，`never` 始终只记录。`job_runs` 同时作为轻量 outbox：`pending → delivering → delivered/failed`，瞬态错误最多进行三次持久化尝试；OutboundDispatcher 返回清洗后的类型化回执，Scheduler 不解析错误字符串。

首次投递把目标 dialog 固定到 `target_session_id`，并先用稳定消息 ID `scheduled:<job_run_id>` 幂等写入本地历史，再调用 `MessageBus::deliver_outbound`。发送成功但 ack 提交前崩溃允许带稳定 metadata 的重复通知，不能为避免重复而丢失告警。启动恢复把遗留 claimed/running JobRun 原子收敛为 `unknown+unknown`、关联非终态 AgentRun 收敛为 interrupted，并按 claim-time 策略决定是否进入 outbox；已推进的 occurrence 不自动重跑。完整状态矩阵、v11 schema 和迁移规则见 [SCHEDULED_RUN_DESIGN.md](SCHEDULED_RUN_DESIGN.md)。

按需 Health 检查只读查询任务引用、投递积压、最近失败/超时/unknown、`never+unknown`、Every 周期被执行时长覆盖以及不可计算的 next run；它不执行任务、不连接 Provider，也不修改数据。

### 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 格式为：

```text
<channel>:<chat_id>:<dialog_id>
```

`SessionManagerInner` 保存：

- `sessions`：完整 Session ID 到内存 Session 的映射。
- `current_sessions`：`channel:chat_id` 到当前 dialog 的映射。

必须维护以下不变量：

1. 同一 Session 的 Agent 工作由一个 generation 对应的 worker 串行执行。
2. `/stop` 或 worker 替换会递增 `worker_generation`；旧 worker 不得再提交结果。
3. 慢操作（模型、记忆召回、压缩、SQLite I/O）不能长期持有 Session mutex。
4. 慢操作开始前记录 `state_version`，提交前重新验证，防止旧快照覆盖 `/clear`、`/delete` 等并发修改。
5. 持久化写入由 `persistence_lock` 串行化；多条相关记录应使用 Storage 的原子接口。
6. 内存先变更但持久化失败时，必须回滚精确匹配的消息后缀，不能删除无关的新状态。
7. Steering 的接收、最终边界关闭和 `/stop` 必须通过同一个 mailbox 状态串行化；每条输入只能落入当前 Turn 或下一 Turn 之一。

当前 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、可选的 active plan 摘要，以及由活动 `ContextCheckpoint` 投影出的会话历史。`messages` 始终是 append-only 原始日志；每个 Session 最多有一个活动 checkpoint，模型历史确定为“一条累计摘要 + `seq >= first_retained_seq` 的原始尾部”，Timeline 只是 checkpoint 提交后的 best-effort 检索副本，恢复流程不读取 Timeline、也不调用 Provider。所选 Model 的可选 `token_limit` 定义上下文硬上限，缺失时固定回退 128000；Agent 的可选 `token_limit` 只允许收紧该上限，有效窗口为 `min(agent_token_limit, model_token_limit_or_128K)`，Agent 不能扩大模型窗口。主 Agent Profile、具名 Agent 的内联 Provider/Model 和 `llm_profile` 引用必须使用同一规则，definition 上的显式值也只能收紧已解析 Profile。`session::turn_input` 只在 Session 锁外并行读取 Knowledge 和 active plan；完整请求草稿随后使用同一个 reserve 预算评估，自动触发公式唯一为 `context_tokens > context_window - effective_reserve`。手动、自动和首次 context-overflow 共用唯一的 `compact_session_context` 编排与 checkpoint CAS 提交路径；candidate 自带快照 generation，提交成功后必须从当前 raw log 重新投影，不能返回摘要前的旧尾部向量。摘要请求输入预算由有效 `token_limit` 扣除动态摘要输出、固定提示词和安全余量得到；超大压缩源只在 request-local 副本中保留已有 checkpoint 和最新材料、对单条内容做确定性 head/tail 截取，不能用固定 32K 上限拒绝压缩，也不能改写 durable raw log。每个 Turn 最多进行一次语义摘要；换模后的发送前预检若已硬超限，可在摘要失败时直接生成明确标记的确定性降级 checkpoint，避免先发送必然失败的普通请求；首次 Provider 请求 overflow 最多正式重试一次。AgentLoop 把 Provider overflow 转成类型化错误：工具尚未执行时交回 Session；工具已执行后只在同一个 AgentLoop 中删除旧完整 Turn 的请求副本并重试当前 Provider step 一次，保留本 Turn tool call/result，绝不从 durable history 重启并重复副作用工具。首次请求与重试必须复用同一个 runtime assembly，不能复制系统提示、丢失 mailbox steering 或把压缩投影写回原始历史。Provider prompt usage 仅在 provider/model/checkpoint generation/raw seq 和完整请求摘要都匹配时复用，否则完整保守估算；不再按消息数外推。普通闲聊 session 没有 plan 摘要；计划状态由 `WorkManager` 从 SQLite 读取，因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环，本身不拥有会话状态，但通过本 Turn 的 mailbox 在安全边界接收追加用户输入。执行工具时额外传递只包含 session/turn 身份的 `ToolExecutionContext`；无状态工具使用默认实现忽略它，有状态外部适配器用它路由资源，但可按明确的单用户配置跨 dialog 共享，且不能自行反向查询 SessionManager。完整压缩设计见 [CONTEXT_COMPACTION_DESIGN.md](CONTEXT_COMPACTION_DESIGN.md)。

当前 Turn 的工具进度只从 `AgentLoop` 的结构化 `TurnEvent` 进入 `TurnController`，不能另建字符串 notification 通道重复投递。具名子 Agent 基础已接入：候选运行代从受信任配置目录严格加载不可变 `AgentCatalog`，Definition 固定 Provider/Model（内联或 `llm_profile`）、工具/Skill allowlist、委托边和执行限制，工具集完全由定义文件决定；单个定义校验失败（坏 YAML、未知 provider/profile/model/tool/skill、或显式委托到缺失目标）仅停用该定义并记入 `load_errors`（`GET /api/agents` 返回），不会阻塞启动或热重载，配置与目录信任级错误仍然致命；`delegate` 使用 `foreground/background` 两个 canonical 生命周期词，批量并发与生命周期正交。具名 foreground 支持批量并发、显式 `AgentExecutionContext`、祖先环路和委托边校验；Root 对具名 Agent 的 background（单任务或批量，批量并发、每个 run 独立 completion 事件）走 durable run/inbox + continuation 投递，空闲时完成即返回。内置 general-purpose 定义随二进制释放，WebUI「子 Agent」页可增删改与启停定义。旧匿名 general 兼容路径已移除。自动标题属于非关键派生工作：Turn 持久化完成后由 `TaskSupervisor` 调度，Session worker 不等待模型生成；同一 Session 同时最多有一个标题任务，提交时仍校验标题保持默认值，避免覆盖用户改名。

每个 session 最多有一个 active plan，但 plan 内多个 item 可以分别绑定不同子 Agent 并行执行。主 Agent 通过 `todo` 创建和维护计划，通过 `delegate.plan_item_id` 委托；子 Agent 的工具集由其定义文件决定，能否继续委托由其 `delegates` 白名单决定。领取 item 使用条件更新防止重复执行，迟到结果只有在 plan 仍 active 且 execution ID 匹配时才允许提交。

## 6. 持久化

`Storage` 使用 SQLx + SQLite，默认数据库为 `{config_dir}/data/picobot.db`（`config_dir` 默认 `~/.picobot`），与 workspace 相互独立。连接启用：

- WAL journal mode。
- foreign keys。
- 5 秒 busy timeout。
- schema version 迁移。

持久化范围包括 sessions、messages、context checkpoints、session turn usage、memories、task plans/items、scheduled jobs、job runs、agent run/inbox/session state。checkpoint 插入、活动指针切换和 `context_generation` 递增在同一事务中完成；`/clear` 在删除消息的事务内使活动 checkpoint 失效，旧 checkpoint 行仅作为审计记录保留。消息保存可展示 `reasoning_content`、Provider 私有回放状态、`turn_id`、iteration 和 completion status；私有 Provider 状态不进入 WebSocket/Channel，且只允许回放给同一 Provider。成功 Turn 的 Provider usage 与消息批次在同一事务中写入 `session_turn_usage`，以 `turn_id` 幂等累计会话输入、输出、缓存输入和请求数；升级前的历史没有可归属 usage，统计起点必须显式呈现。成功持久化一个 Turn 后，交互 Channel 收到只包含公开字段的 `CommittedTurnDelta`，其中 `history_revision` 是本批次最高 durable sequence；客户端按 revision 幂等合并，正常完成不重新加载整段历史，断线重连和失败/取消仍使用 `SessionHistory` 校准。Provider 诊断和失败审计只记录模型、消息数、工具数等请求摘要，不保存正文、reasoning 或签名 payload。修改 schema 时应：

1. 更新集中式 schema/迁移逻辑。
2. 保留已有数据库的升级路径。
3. 为新库初始化和旧库迁移分别增加测试。
4. 对“状态更新 + 执行记录”等复合写入使用事务。

### 安全边界

- 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` 帧刷新，计划变化不会写入聊天历史；活动状态栏通过结构化 `session_stats` 展示当前 session 的已提交 Turn 用量与上下文占用，累计量来自 Provider usage，窗口占用明确区分精确匹配的 Provider 实测与完整字符估算；斜杠命令补全通过 `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`，紧随工具结果的 steering 不会使该批次媒体失去可见性，而旧工具媒体只回放文本和路径，避免历史 Base64 膨胀；用户媒体累积到本 Turn 最终 assistant 消息，随工具链原子持久化，并由 committed-history 或普通出站路径呈现。OpenAI-compatible Provider 保持 `tool` 结果为文本，并在完整工具批次后构造仅存在于请求内的临时多模态 `user` 消息，同时保持后续 user steering 的顺序；Anthropic Provider 将同批媒体放入对应 `tool_result.content`，并将紧随的 user steering 合并进 API 所需的同一 `role=user` 内容数组，持久化消息仍彼此独立。媒体加载、格式或能力检查失败必须降级成文本，不得使历史记录不可读取。

MCP 发现的工具在 `ToolRegistry` 中使用 `mcp_<server-name>_<tool-name>` 命名空间，避免与内置工具混淆；`tool_settings` 仍按 MCP 原始 `<tool-name>` 键入。MCP 协议不提供 PicoBot 可依赖的副作用或并发契约，因此每个 `mcp.servers[].tool_settings.<tool-name>` 可在受信任本地配置中声明 `read_only` 与 `exclusive`。未声明的 MCP 工具保守地按“可能有副作用、顺序执行”处理。`concurrency_safe` 不保存为独立状态，而是严格由 `read_only && !exclusive` 推导；工具批次只有全部工具满足该条件才允许并发执行。WebUI 的 MCP 工具展开项提供这两个声明的复选框，并将推导结果显示为“可并发”；属性编辑先保留在页面草稿中，只有选择“保存并应用”才原子写入配置并触发一次热重载，离开 MCP 标签或刷新页面会丢弃草稿。

`browser` 是有状态工具适配器：`BrowserTool` 保持模型侧 action schema，`BrowserManager` 按每次调用是否带 `persistent_id` 分流。省略 ID 时把 PicoBot dialog 映射到随机临时 agent-browser session，并用每 session mutex 保证同一页面串行、不同 dialog 并发；临时 daemon 按 `browser.idle_timeout_secs`（默认一小时）自动退出，Manager 在容量检查时惰性回收对应空闲条目。长期工作需要保持浏览器进程或保留登录和站点状态时，Agent 可自主创建持久身份并在后续相关 action 中持续传入同一个 ID。Manager 按持久 ID 保存 agent-browser session 和 mutex，持久 daemon 的空闲超时固定为禁用；同一 ID 跨 dialog 共享且串行，不同 ID 相互独立并可并发，Gateway 重启或显式关闭浏览器后仍可继续使用原 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](AGENT_BROWSER_INTEGRATION.md)。

`HealthService` 是依赖检查的唯一实现。CLI `picobot health`、只读 `health` 工具、`/health` 斜杠命令和 WebUI“配置 → 健康检查”必须复用它；受鉴权的 `GET /api/health` 按需返回 `HealthReport`，公开 `GET /health` 仍只承担轻量在线与版本探测，避免页面常驻轮询反复执行外部诊断命令。检查可探测命令、版本和配置路径；`fd` 与 Debian/Ubuntu 的同程序命令名 `fdfind` 都是首选后端，传统 `find` 才是降级回退。浏览器检查使用临时 socket 目录和专用 namespace 运行完整的 agent-browser offline doctor，将浏览器安装、真实 headless 启动和其余环境问题拆分报告，既不接触活动 daemon socket，也不跳过 launch probe。Health 不能安装/修复软件、连接模型 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/*` 管理接口只提供显式白名单能力：

- `GET /api/health` 返回当前运行代 `HealthService` 的完整只读报告；它只在用户进入健康检查页或手动刷新时运行，不属于 Gateway 在线探测轮询。
- 配置读取/原子写入；响应中的密钥字段统一掩码，原样提交掩码会恢复现有值。配置加载器在不修改原始文件的前提下，从请求内副本移除可恢复的未知字段、类型/枚举错误和失效的非核心命名条目，生成有效 `Config`、RFC 6901 诊断路径和原始文件 SHA-256 revision；数组元素另外保留原始索引映射，避免恢复过程中索引移动导致清理错位。`models.*` 的 `flatten` Provider 扩展参数以及 MCP 的 `env`/`headers`/`tool_settings` 动态键属于显式扩展面，不作为未知项。JSON 损坏、不可用的 `default` Agent Provider/Model 链路和候选运行代无法安全构造仍是致命错误。普通 `PUT /api/config` 保持严格，不允许写入会被忽略的新配置；`POST /api/config/cleanup-invalid` 只按同一加载器报告的路径清理，并在 revision 不匹配时返回冲突。`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`，并依靠 Cargo `rerun-if-changed` 避免后端代码变化触发前端重建。前端开发仍须运行 `npm run check`，并以 `cargo build` 验证最终嵌入路径。

配对鉴权只证明设备持有凭据，不提供机密性。非回环部署仍必须由反向代理或其他外层提供 TLS；显式设置 `gateway.require_pairing=false` 会恢复无鉴权模式，仅适合隔离环境。

## 8. 启动与关停顺序

### 启动

1. 解析配置路径，加载配置目录 `.env`，据此定位 workspace，再加载 workspace `.env`；既有进程环境保持最高优先级，合并后通过统一容错加载器重新解析配置。可恢复项只影响有效运行时投影并产生诊断，原始 `config.json` 保持不变。
2. 初始化日志，创建并切换到 workspace，初始化 WebUI 配对存储与本机管理密钥。
3. 初始化 SQLite、MemoryManager、MessageBus 和 SessionManager；Scheduler 启用时幂等创建默认日常维护巡检。
4. 注册内置工具、渠道、MCP 工具和 Cron 工具。
5. 启动所有 Channel。
6. 通过 TaskSupervisor 启动 inbound/control routers、dispatcher 和 scheduler。
7. 注册 WebUI 静态资源、管理 API 与聊天 WebSocket 路由。
8. 绑定 Axum listener，开始接收请求。

### 关停

1. `Ctrl-C`/SIGINT 或 SIGTERM 触发 Axum graceful shutdown，并取消所有 WebSocket 连接。systemd 的 `stop`/`restart` 使用 SIGTERM，因此与前台退出共享同一清理链路。
2. `ChannelManager::stop_all` 先停止外部消息入口并注销渠道。
3. 取消 TaskSupervisor，停止接受新后台任务。
4. 在共享的 10 秒总宽限期内等待任务退出，之后 abort 剩余任务。

渠道自己的 `stop()` 也必须有界。以飞书为例，端点请求、WebSocket 建连、重试等待和已连接循环共享 CancellationToken，另有 5 秒强制回收兜底。

## 9. 扩展指南

### 新增 Channel

1. 实现 `Channel` trait，仅处理外部协议和统一消息转换。
2. 在 `ChannelManager::init` 注册，并通过同一个 MessageBus 收发。
3. 将可重试错误表示为 `ConnectionError`/`SendError`，永久错误使用其他类型。
4. 为 start/stop 幂等性、取消建连、投递失败和媒体边界增加测试。
5. 不要从 Channel 直接调用 SessionManager 或 Provider。
6. 若支持活动 Turn，实现 `live_policy`、`presentation_policy` 和每 Turn 一个实例的 `open_turn`；sink 必须消费完整快照而不是拼接 token，并使 finish/abort 清理幂等。

### 新增 Tool

1. 实现 `Tool`，在集中注册点加入 `ToolRegistry`。
2. 参数 schema 必须明确；返回值保持可供模型消费的字符串协议。
3. 明确工具需要“workspace 默认目录”还是“不可逃逸的硬边界”；后者必须显式校验 canonical path，不能只依赖 cwd。
4. 网络工具必须保留 SSRF/私网地址校验。
5. 长操作应有超时；后台执行应交给 SubAgentManager/TaskSupervisor。
6. 需要跨调用保存外部状态时，实现 `execute_with_context` 并按 session 隔离；不得让模型控制底层全局 session ID。

没有模型可调用的前台 `sleep`/等待工具：Agent 等待异步工作时，应结束当前 Turn 让排队完成/信号开启续接 Turn，或轮询状态工具。Turn 进入 `Cancelled` 时仍必须把 `Running` 的工具块同步归约为 `Cancelled`。需要跨重启的可靠延迟必须使用 Scheduler/后台任务。

### 新增 Provider

1. 实现 `LLMProvider`，保持其为纯 HTTP/API 适配器。
2. 统一映射文本、媒体、tool calls、usage 和错误。
3. 不在 Provider 中访问 Session、Bus 或 Channel。
4. 为请求序列化和响应兼容性编写离线单元测试。

### 修改 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` |
