PicoBot/docs/ARCHITECTURE.md
xiaoxixi d9ad58b84b feat(scheduler): unify scheduled task execution and delivery
Replace the dual task/monitor model, NO_REPLY string protocol, and Agent
self-delivery with a single Scheduled Run path: claim-time JobRun snapshots,
isolated Root/named Agent execution, exactly-once complete_scheduled_run
termination, and Scheduler-owned policy delivery through a persistent outbox.

- SQLite v11: drop job_kind/model/delete_after_run, add job_runs with
  status/outcome joint constraints and delivery lease columns; one-shot
  BEGIN IMMEDIATE migration with atomic rollback.
- Non-blocking JoinSet event loop with bounded run/delivery concurrency;
  terminal commit before any channel I/O; recover unfinished runs as unknown.
- ExecutionOrigin::Scheduled propagates to descendants, completion sink is
  top-level only, background delegation downgrades to foreground.
- Typed delivery receipts, fixed target_session_id, idempotent
  scheduled:<job_run_id> history insert.
- New cron_runs read-only tool; cron_add/update drop kind/model; WebUI and
  Health consume the same JobRun projection.
- Bump version to 1.22.0.
2026-08-21 14:59:02 +08:00

397 lines
46 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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重连时恢复内存中的当前 dialogGateway 重启后则恢复该 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`;配置目录层先用于定位 workspaceworkspace 层不得重定向自身位置。环境文件只在单线程启动阶段写入进程环境,不能移到后台任务启动之后。重载使用启动前保存的进程环境快照解析各层,但不再修改进程环境,避免多线程运行期调用 `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 发 deltaAgentLoop 发结构化 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 读取最近消息。单次查询限制为 12000 条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` 定义上下文硬上限,缺失时固定回退 128000Agent 的可选 `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 CookieCLI 使用 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 和 SessionManagerScheduler 启用时幂等创建默认日常维护巡检。
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` |