docs: finalize streaming turn architecture

This commit is contained in:
xiaoxixi 2026-07-17 17:45:22 +08:00
parent c6e022f6cb
commit 7a3058d8b5
13 changed files with 175 additions and 83 deletions

View File

@ -46,6 +46,8 @@ Channel → MessageBus.inbound → Gateway processor → SessionManager → per-
↑ │
└── Channel ← OutboundDispatcher ← per-conversation lane ← MessageBus.outbound
AgentLoop → TurnEvent → Session TurnController → latest TurnSnapshot → DeliveryCoordinator → per-turn TurnSink → Channel
WebSocket/Channel → MessageBus.control → Gateway processor → SessionManager (dialog operations)
Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler delivery policy → SessionManager/MessageBus
```
@ -58,9 +60,10 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
| `client` | TUI rendering, WebSocket client for CLI chat | `App`, `run()` |
| `channels` | External integrations (Feishu, CLI chat) | `ChannelManager`, `Channel` trait |
| `bus` | Bounded async queues and ordered outbound delivery lanes | `MessageBus`, `OutboundDispatcher`, `InboundMessage`, `OutboundMessage`, `ControlMessage` |
| `session` | Conversation lifecycle, dialog operations, per-session serialization, persistence coordination | `SessionManager`, `Session` |
| `agent` | LLM call loop, tool execution, context compression | `AgentLoop` |
| `providers` | LLM API clients (OpenAI-compatible, Anthropic) | `LLMProvider` trait, factory `create_provider()` |
| `session` | Conversation lifecycle, dialog operations, per-session serialization, Turn state, persistence coordination | `SessionManager`, `Session`, `TurnController` |
| `agent` | LLM call loop, tool execution, context compression, semantic Turn events | `AgentLoop`, `TurnEvent` |
| `providers` | Native LLM streams normalized into text/reasoning/tool/usage chunks | `LLMProvider`, `ProviderChunk`, `create_provider()` |
| `delivery` | Snapshot projection, latest-wins throttling, terminal retry, per-turn sink lifecycle | `DeliveryCoordinator`, `TurnDeliveryService`, `PresentationPolicy` |
| `tools` | Agent tools (bash, file ops, http, web, get_skill) | `ToolRegistry`, `Tool` trait |
| `skills` | Skills loading, management, and prompt building | `SkillsLoader`, `Skill` |
| `storage` | SQLite persistence for sessions and messages | `Storage`, `SessionMeta`, `MessageMeta` |
@ -74,9 +77,11 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
### Functional Boundaries
- **Channels** only send/receive messages via `MessageBus`; they know nothing about sessions or LLM
- **Channels** publish inbound messages through `MessageBus`; outbound writes arrive through `OutboundDispatcher` or a per-turn `TurnSink`. They know nothing about sessions or LLM
- **MessageBus** owns bounded inbound/outbound/control queues; outbound routing and retries belong to `OutboundDispatcher`, not the queue
- **SessionManager** owns session state, dialog operations, context construction, per-session work queues, and persistence coordination
- **TurnController** is the only owner of active Turn state; snapshots are complete latest-wins values, not token queues, and `Completed` is published only after atomic persistence succeeds
- **DeliveryCoordinator** projects active Turn snapshots without mutating history; it owns `TurnSink` lifecycle but no platform message IDs, which remain private to each sink
- **WorkManager** owns the single active plan per session, item state transitions, plan versions, and plan-change events; plans are optional and absent from ordinary chat context
- **Scheduler** supports legacy direct-delivery jobs and managed `task`/`monitor` jobs; managed agents cannot call `send_message`, and `on_alert` suppresses only healthy informational results
- **AgentLoop** is stateless across turns; it receives prepared history, calls LLM providers, executes tools, and returns one result
@ -86,12 +91,15 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
- **WebUI/TUI file transfer** streams bytes over authenticated HTTP and sends only short-lived upload IDs/attachment metadata over WebSocket; messages persist local media paths without guaranteeing later availability, and client responses must never expose those paths
- **WebUI authentication** protects every management API and `/ws`; only static pairing assets, public health/status, and pairing submission may bypass device auth. Pair-code issuance requires both a real loopback peer and the filesystem-held admin token; never put bearer tokens in URLs or logs
- **Providers** are pure HTTP clients; no bus/session/channel awareness
- **Provider reasoning state** is private replay data: persist it, replay it only to the matching provider, and never expose it to clients, channels, or logs
- **Tools** are executed by `AgentLoop`; they receive raw arguments and normally return text. Tools that produce model-consumable media use the structured `execute_with_media` side channel; model capability checks and provider content-block serialization stay outside tools
### Concurrency and Lifecycle Invariants
- Messages in one session are processed serially through a bounded queue; different sessions may run concurrently
- Outbound messages are ordered per `(channel, chat_id)`; a slow destination must not block unrelated destinations
- Active Turn delivery and ordinary outbound delivery share the same per-`(channel, chat_id)` write lock; never enqueue token deltas into MessageBus
- Slow Turn consumers may skip intermediate snapshots but must receive an explicit bounded terminal delivery; shutdown must call sink abort so platform cleanup remains possible
- Never hold a Session mutex across model, network, or database I/O unless a documented invariant requires it
- Slow work derived from session state must validate `worker_generation`/`state_version` before committing results
- Related durable mutations use Storage transaction APIs; persistence failure must not leave silent memory/database divergence

View File

@ -9,9 +9,10 @@ PicoBot 是一个用 Rust 编写的个人 AI 助手运行时。它在本地启
## 适合做什么
- 在终端里和本地 AI 助手持续对话。
- 在浏览器中聊天,并查看日志、任务和记忆,修改运行配置与助手档案。
- 在 TUI 或浏览器中实时查看正文、思考过程和工具执行状态,并在完成后收敛到持久化历史。
- 在浏览器中查看日志、任务和记忆,修改运行配置与助手档案。
- 复杂任务可创建 session 级 Todo 计划,把不同子项并行委托给多个子 Agent聊天页侧栏实时显示进度。
- 将同一套 Agent 能力接入飞书/Lark。
- 将同一套 Agent 能力接入飞书/Lark,并可选用单张卡片实时更新回复
- 让 Agent 使用本地文件、Shell、搜索、HTTP、浏览器、MCP 工具完成任务。
- 把长期偏好、事实和历史摘要存成可检索记忆。
- 用 Cron 定时执行任务,并把结果发回目标渠道。
@ -111,7 +112,7 @@ picobot pair
WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件,提供:
- 在线聊天、会话创建/切换、历史回放、Markdown 消息、可折叠工具调用卡片,以及基于 Gateway 实时命令清单的 `/` 斜杠命令补全。
- 在线聊天、会话创建/切换、历史回放、流式 Markdown、独立思考区、实时工具状态、可折叠历史工具调用卡片,以及基于 Gateway 实时命令清单的 `/` 斜杠命令补全。
- 文件选择、拖放和剪贴板图片上传;消息中的附件可预览或下载。附件按服务端路径引用,原文件移动或删除后历史附件可能不可用。
- 可持久化的浅色/深色主题,首次访问时跟随系统偏好。
- Cron 定时任务、最近运行记录和后台子任务状态。
@ -157,7 +158,7 @@ picobot service uninstall
unit 位于 `~/.config/systemd/user/picobot.service`,以执行 `service install` 时的当前目录作为初始工作目录。服务异常退出时由 systemd 自动重启;`stop``restart` 会通过 SIGTERM 触发 Gateway 的有界优雅关停。
TUI 会把一个随机客户端标识保存到 `~/.picobot/tui_client_id`,因此关闭并重新打开客户端后会恢复同一组 dialog 和最近使用的会话。界面支持历史回放、会话列表与归档筛选、命令补全、Unicode/中文编辑、括号粘贴、多行输入和文件传输。
TUI 会把一个随机客户端标识保存到 `~/.picobot/tui_client_id`,因此关闭并重新打开客户端后会恢复同一组 dialog 和最近使用的会话。界面支持流式正文、独立思考与工具状态、历史回放、会话列表与归档筛选、命令补全、Unicode/中文编辑、括号粘贴、多行输入和文件传输。
常用快捷键:
@ -177,11 +178,11 @@ WebUI/TUI 上传文件默认保存到 `~/.picobot/media/cli_chat`,单文件上
## 运行时数据流
用户消息进入 PicoBot 后,会被转换为统一的 inbound message经由 MessageBus 交给 SessionManager。SessionManager 选择当前 dialog、组装上下文、调用 AgentLoopAgentLoop 调用模型和工具,最终响应通过 outbound bus 回到原渠道
用户消息进入 PicoBot 后,会被转换为统一的 inbound message经由 MessageBus 交给 SessionManager。SessionManager 选择当前 dialog、组装上下文并创建活动 TurnAgentLoop 消费 Provider 原生流、执行工具并发出结构化事件TurnController 将它们归约成可丢中间帧的完整快照。DeliveryCoordinator 把快照投影给 TUI、WebUI 或 Channel最终消息在 SQLite 原子提交成功后才进入 `Completed`
详细时序和失败语义见 [架构文档:消息与控制数据流](docs/ARCHITECTURE.md#4-消息与控制数据流)。
同一 session 的普通消息由专属有界队列串行处理,不同 session 可以并发;出站消息按 `(channel, chat_id)` 分 lane 保序,慢渠道不会阻塞其他目标。Gateway 的长生命周期任务统一由 `TaskSupervisor` 取消和限时回收。
同一 session 的普通消息由专属有界队列串行处理,不同 session 可以并发;活动 Turn 使用 latest-wins 快照,慢展示端只跳过中间状态,不反压模型。普通出站消息按 `(channel, chat_id)` 分 lane 保序,两条投递路径共享目标写锁。Gateway 的长生命周期任务统一由 `TaskSupervisor` 取消和限时回收。
核心边界:
@ -191,7 +192,8 @@ WebUI/TUI 上传文件默认保存到 `~/.picobot/media/cli_chat`,单文件上
| `bus` | 异步消息队列,承载 inbound、outbound、control 三类消息 |
| `session` | 管理会话生命周期、dialog 操作、上下文、记忆召回、压缩和持久化 |
| `agent` | 执行无状态 LLM/tool 循环,处理模型响应和工具调用 |
| `providers` | OpenAI 兼容接口和 Anthropic Messages API 客户端 |
| `providers` | OpenAI 兼容接口和 Anthropic Messages API 的原生流解析与回放 |
| `delivery` | 活动 Turn 的展示过滤、latest-wins 节流、终态投递与 TurnSink 生命周期 |
| `tools` | Agent 可调用工具集合 |
| `storage` | SQLite schema、CRUD、消息和任务持久化 |
| `scheduler` | 领取定时任务,执行普通/巡检 Agent并按投递策略记录或发送结果 |
@ -209,6 +211,8 @@ WebUI/TUI 上传文件默认保存到 `~/.picobot/media/cli_chat`,单文件上
| `cli_chat` | Ratatui 终端客户端,通过 WebSocket 连接 Gateway |
| `feishu` | 飞书/Lark 消息、反应、文件上传下载和媒体引用 |
飞书默认只发送终态结果。设置 `channels.feishu.live_updates=true` 后会创建一张卡片并持续编辑;`live_update_interval_ms` 默认 500ms运行时限制在 2505000ms。外部渠道始终不会收到模型 reasoning。
### 会话
Session ID 使用三段式:
@ -310,6 +314,8 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
| `memory.timeline_retention_days` | `90` |
| `mcp.tool_timeout_secs` | `180` |
| `browser.enabled` | `false` |
| `channels.feishu.live_updates` | `false` |
| `channels.feishu.live_update_interval_ms` | `500` |
更完整的配置字段说明见 [resources/skills/about-picobot/references/config.md](resources/skills/about-picobot/references/config.md)。
@ -341,7 +347,7 @@ Inbound 消息类型:
| `get_slash_commands` | 无 |
| `ping` | 无 |
Outbound 消息类型包括 `assistant_response``error``session_established``session_created``session_list``session_loaded``session_history``session_renamed``session_archived``session_deleted``history_cleared``slash_commands_list``pong``command_executed``system_notification`其中异步 `assistant_response` / `system_notification` 可携带 `session_id`,客户端应避免把迟到结果显示到其他 dialog
Outbound 消息类型包括活动 Turn 使用的 `turn_updated`,以及 `assistant_response``error``session_established``session_created``session_list``session_loaded``session_history``session_renamed``session_archived``session_deleted``history_cleared``slash_commands_list``pong``command_executed``system_notification``turn_updated` 每次携带完整快照和单调 revision客户端只替换当前 session 的活动 Turn`assistant_response` 保留给独立完整消息。历史消息可包含 reasoning、turn/iteration、completion status 和结构化工具元数据,但不会暴露 Provider 私有回放状态
## 测试
@ -370,6 +376,7 @@ src/
channels/ CLI chat 和飞书/Lark 集成
client/ Ratatui 终端 UI
config/ 配置加载、环境变量替换、路径展开
delivery/ 活动 Turn 快照投影、节流与 TurnSink 生命周期
gateway/ Axum HTTP/WebSocket server 和 GatewayState 装配
mcp/ MCP 客户端连接和工具包装
memory/ 记忆管理和记忆类型

View File

@ -2,7 +2,7 @@
本文档描述 PicoBot 当前实现的运行时边界、数据流、并发模型和演进约束。它面向维护者和后续参与改进的 Agent是代码架构的主入口行为细节仍以代码和测试为最终依据。
拟议中的流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递架构见 [STREAMING_TURN_DESIGN.md](STREAMING_TURN_DESIGN.md)。该文档是尚未实现的设计,不代表当前运行时行为。
流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递的详细设计与取舍见 [STREAMING_TURN_DESIGN.md](STREAMING_TURN_DESIGN.md)。
## 1. 设计目标
@ -43,6 +43,8 @@ flowchart LR
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]
@ -61,8 +63,9 @@ flowchart LR
| `channels` | 外部协议适配、权限检查、媒体收发 | 会话选择、LLM 调用 |
| `bus` | 三条有界异步队列与出站投递协调 | 会话路由、业务状态 |
| `session` | dialog 路由、会话状态、串行工作队列、上下文和持久化协调 | 外部渠道协议 |
| `agent` | 单次无状态模型/工具循环、上下文压缩、子 Agent | 持有 dialog 生命周期 |
| `providers` | 把统一请求映射到模型 API | Session、Bus 或 Channel 感知 |
| `agent` | 单次无状态模型/工具循环、上下文压缩、子 Agent、Turn 语义事件 | 持有 dialog 生命周期 |
| `providers` | 把统一请求映射为原生模型流并归一化正文、reasoning、工具和 usage | Session、Bus 或 Channel 感知 |
| `delivery` | 活动 Turn 快照投影、latest-wins 节流、终态重试和 TurnSink 生命周期 | Provider 协议、会话历史、平台 API 细节 |
| `tools` / `mcp` | 工具定义、注册和执行适配 | 隐式修改会话路由 |
| `storage` | SQLite schema、迁移、原子 CRUD | 运行时调度策略 |
| `memory` | Knowledge/Timeline 的存取与召回 | 直接驱动消息发送 |
@ -87,7 +90,9 @@ sequenceDiagram
participant G as Message processor
participant S as SessionManager
participant W as Per-session worker
participant A as AgentLoop
participant A as AgentLoop / Provider
participant T as TurnController
participant L as DeliveryCoordinator
participant D as OutboundDispatcher
C->>B: publish InboundMessage
@ -95,9 +100,18 @@ sequenceDiagram
G->>S: handle_message
S->>W: try_send AgentTask
S-->>G: AgentProcessing
W->>A: process(history)
A-->>W: final response
W->>B: publish OutboundMessage
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
```
@ -108,6 +122,20 @@ sequenceDiagram
- 每个 session 有一条容量为 32 的队列,同一 session 串行处理,不同 session 的 worker 可并发执行。
- 队列满时明确拒绝新消息,不允许无界积压。
- Slash command 不进入 Agent 队列,由 `SessionManager` 直接执行,因此 `/stop` 等控制操作不会排在长模型调用之后。
- 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` 忽略运行态,只处理终态。终态绕过节流并只对明确的瞬态错误重试。
- `cli_chat` 将同一 `turn_updated` 快照发给 TUI 和 WebUI。客户端只保留当前 session 中 revision 更新的 `active_turn`,终态随后由持久化历史校准。
- 飞书默认 `FinalOnly`;开启 `live_updates`第一个可见快照创建卡片后续编辑同一卡片终态编辑失败则发送完整结果兜底。reaction 清理在 finish、abort 和 Gateway shutdown 中幂等执行。
- DeliveryCoordinator 与 OutboundDispatcher 共享 `(channel, chat_id)` 写锁,避免活动 Turn 终态与独立消息并发写入同一目标。
### 出站投递
@ -164,7 +192,7 @@ SessionManager 负责组装会话上下文系统提示、Skills、召回的 K
- 5 秒 busy timeout。
- schema version 迁移。
持久化范围包括 sessions、messages、memories、task plans/items、scheduled jobs、job runs 和 background tasks。修改 schema 时应:
持久化范围包括 sessions、messages、memories、task plans/items、scheduled jobs、job runs 和 background tasks。消息保存可展示 `reasoning_content`、Provider 私有回放状态、`turn_id`、iteration 和 completion status私有 Provider 状态不进入 WebSocket/Channel且只允许回放给同一 Provider。Provider 诊断和失败审计只记录模型、消息数、工具数等请求摘要不保存正文、reasoning 或签名 payload。修改 schema 时应:
1. 更新集中式 schema/迁移逻辑。
2. 保留已有数据库的升级路径。
@ -181,7 +209,7 @@ SessionManager 负责组装会话上下文系统提示、Skills、召回的 K
## 7. 后台任务与生命周期
`TaskSupervisor` 是 Gateway 内部后台任务的统一所有者。message processor、outbound dispatcher、scheduler、session workers、outbound lanes、通知消费者和子 Agent 后台任务都应通过它注册。
`TaskSupervisor` 是 Gateway 内部后台任务的统一所有者。message processor、outbound dispatcher、scheduler、session workers、Turn delivery、outbound lanes、通知消费者和子 Agent 后台任务都应通过它注册。
两种注册方式:
@ -197,6 +225,8 @@ SessionManager 负责组装会话上下文系统提示、Skills、召回的 K
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 提供无样式的可访问组件原语。`build.rs` 监听前端源码、锁文件和构建配置,增量地将生产资源生成到 Cargo `OUT_DIR`Rust 再从该目录编译嵌入;前端产物不进入仓库,最终用户使用发布二进制时不需要 Node.js。浏览器聊天继续使用 `/ws``cli_chat` 渠道,因此复用现有 dialog scope、每会话串行 worker、历史持久化和出站 lane会话历史帧保留工具调用 ID、名称、参数和工具结果角色WebUI 在本轮完成后刷新历史并将其渲染为默认折叠的工具卡片;聊天页的 Todo 侧栏默认隐藏,按 session 保存快照和未读状态,并通过结构化 `session_plan`/`plan_updated` 帧刷新,计划变化不会写入聊天历史;斜杠命令补全通过 `get_slash_commands` 获取 Gateway 的实时命令与别名清单不在前端重复定义WebUI 不直接调用 Provider 或 SessionManager。
@ -249,6 +279,7 @@ WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输WebSocket 只
3. 将可重试错误表示为 `ConnectionError`/`SendError`,永久错误使用其他类型。
4. 为 start/stop 幂等性、取消建连、投递失败和媒体边界增加测试。
5. 不要从 Channel 直接调用 SessionManager 或 Provider。
6. 若支持活动 Turn实现 `live_policy``presentation_policy` 和每 Turn 一个实例的 `open_turn`sink 必须消费完整快照而不是拼接 token并使 finish/abort 清理幂等。
### 新增 Tool

View File

@ -1,12 +1,12 @@
# 流式 Turn、Reasoning 展示与 Channel 投递设计
> 状态:拟议设计,尚未实现
> 状态:已实现2026-07
>
> 本文定义 PicoBot 未来的流式模型输出、reasoning 展示、工具过程展示和 Channel 实时投递架构。当前运行时行为仍以 `docs/ARCHITECTURE.md` 和代码为准。实施本设计时允许重做内部、WebSocket、TUI、WebUI 和 Channel 协议;只要求 SQLite 数据库提供可靠迁移路径
> 本文记录 PicoBot 流式模型输出、reasoning 展示、工具过程展示和 Channel 实时投递的设计依据与架构决策。当前运行时总览见 `docs/ARCHITECTURE.md`,具体行为以代码和测试为准
## 1. 背景
前 Provider 只提供一次性 `chat()` 调用。AgentLoop 等待完整响应,将 `content``reasoning_content` 和 tool calls 组装为 `ChatMessage`Session 在 AgentLoop 完成后原子持久化本轮消息,再通过 `OutboundMessage` 发送最终正文。
改造前 Provider 只提供一次性 `chat()` 调用。AgentLoop 等待完整响应,将 `content``reasoning_content` 和 tool calls 组装为 `ChatMessage`Session 在 AgentLoop 完成后原子持久化本轮消息,再通过 `OutboundMessage` 发送最终正文。
这个模型具有清晰的持久化语义,但无法表达:
@ -17,7 +17,7 @@
- 不支持编辑的 Channel 自动降级为只发送最终结果;
- 取消、失败、慢消费者和投递失败时的确定行为。
现有 `Channel::send_delta(chat_id, delta)` 没有 Turn 身份、消息身份、reasoning/text 分类、工具边界、终态和取消语义,也绕过现有出站排序机制,不适合作为后续架构基础
`Channel::send_delta(chat_id, delta)` 没有 Turn 身份、消息身份、reasoning/text 分类、工具边界、终态和取消语义,也绕过出站排序机制,因此已被 `TurnSink` 取代
## 2. 参考实现结论
@ -351,12 +351,7 @@ thinking 文本进入 `reasoning`,签名和原始 block 进入 `provider_state
### 7.4 reasoning-only
Provider 不擅自把 reasoning 提升为正文。AgentLoop 在最终轮发现只有 reasoning、没有正文且没有工具调用时执行统一产品策略
- 默认生成一个明确的空正文终态,并在 UI 保留 reasoning
- 后续配置允许把 reasoning 作为正文 fallback。
首版建议不泄漏 reasoning 为正文,由 UI 显示“模型未返回最终正文”。
Provider 不擅自把 reasoning 提升为正文。最终轮只有 reasoning、没有正文且没有工具调用时AgentLoop 保存空正文 assistant 消息及其 reasoningTurn 正常进入 Completed交互 UI 仍可显示 reasoning但不会把它冒充最终答案。
## 8. AgentLoop 与 TurnController
@ -565,12 +560,12 @@ WebUI 对 Running Markdown 可以按动画帧或快照频率渲染Completed
## 13. WebSocket 协议
不保留旧 `assistant_response` 流程。活动 Turn 使用一个统一 frame
Agent 主 Turn 不再通过 `assistant_response` 发送最终正文,活动与终态都使用统一 frame。`assistant_response` 仅保留给不属于活动 Turn 的完整独立消息
```json
{
"type": "turn_updated",
"turn": {
"snapshot": {
"id": "...",
"session_id": "...",
"message_id": "...",
@ -604,7 +599,7 @@ Provider 完成
### 14.1 取消
建议首版语义:
采用以下语义:
- 没有 Assistant 正文:不持久化 assistant 消息Turn 标记 Cancelled
- 已向用户展示部分正文:持久化部分正文并标记 `completion_status=cancelled`
@ -621,7 +616,7 @@ Provider 完成
## 15. SQLite 迁移
建议`messages` 增加:
schema v4 `messages` 增加:
```text
turn_id TEXT NULL
@ -640,7 +635,7 @@ provider_state TEXT NULL -- JSONProvider 私有回放状态
- Session 加载继续修复 tool-call chains
- 原子提交覆盖完整 Turn 的所有 emitted messages。
是否新增 `turns` 表首版暂不需要。运行中 Turn 只存在内存,历史可通过 messages.turn_id 分组。如果未来要跨 Gateway 重启恢复运行态,再单独设计 durable turn lease/state。
当前不新增 `turns`。运行中 Turn 只存在内存,历史可通过 messages.turn_id 分组。如果未来要跨 Gateway 重启恢复运行态,再单独设计 durable turn lease/state。
## 16. 生命周期与并发不变量
@ -765,32 +760,19 @@ src/protocol.rs
- cancelled/interrupted 消息恢复;
- 完整 Turn 原子提交失败不产生部分历史。
## 20. 实施顺序
## 20. 实施记录
1. 增加数据库迁移和新的消息 reasoning/provider state 语义。
2. 引入 ProviderChunk 和流式优先 Provider trait。
3. 实现 OpenAI-compatible streaming 和 collect helper。
4. 实现 TurnEvent、TurnController、TurnSnapshot 及纯单元测试。
5. AgentLoop 接入 TurnEmitter但暂时只使用最终结果投递。
6. 增加 DeliveryCoordinator、LivePolicy 和 TurnSink。
7. 重做 cli_chat WebSocket 为统一 `turn_updated` 快照。
8. 重做 TUI/WebUI 为 `history + active_turn` 渲染模型。
9. 实现 Anthropic streaming 和完整 provider_state 回放。
10. 实现 FeishuTurnSink 的创建、编辑、终态和 fallback。
11. 删除旧 `send_delta`、旧 reasoning 特例和 Channel 侧 think 清理。
12. 更新 `ARCHITECTURE.md`、README、AGENTS.md 和运行时产品知识。
实现按可独立验证的里程碑完成SQLite 消息语义、Turn 状态机、OpenAI 原生流、Agent/Session 生命周期、DeliveryCoordinator、WebSocket/TUI/WebUI、Anthropic 签名回放、FeishuTurnSink最后删除过渡适配并同步运行时文档。每个里程碑均保持非流式最终回复可用且没有为旧增量协议保留双栈。
每一步都必须保持非流式最终回复可用;但不为旧协议保留双栈兼容代码。
## 21. 已采用的产品策略
## 21. 实施前需确认的产品策略
这些选择不改变架构,但决定默认产品行为:
这些选择不改变架构,但必须在实现前确定默认值:
1. `/stop` 后是否持久化已展示的部分正文。本文建议持久化并标记 cancelled。
2. TUI/WebUI reasoning 默认折叠还是隐藏。本文建议折叠。
3. 外部 Channel reasoning 默认策略。本文建议隐藏。
4. reasoning-only 是否允许提升为正文。本文建议不提升。
5. provider_state 的保留期限和 LLM 原始响应审计策略。
1. `/stop` 后持久化已展示的部分正文并标记 `cancelled`;只有 reasoning 时不创建 assistant 历史。
2. TUI/WebUI 在独立区域展示 reasoningWebUI 默认折叠TUI 直接显示。
3. 外部 Channel 默认隐藏 reasoning工具只显示紧凑状态。
4. reasoning-only 不提升为正文。
5. `provider_state` 随 assistant 消息保留,用于同 Provider 精确回放;不下发客户端或 Channel损坏时安全忽略。
## 22. 架构验收标准

View File

@ -7,6 +7,8 @@ Channel → MessageBus.inbound → Gateway processor → SessionManager → per-
↑ │
└── Channel ← OutboundDispatcher ← per-conversation lane ← MessageBus.outbound
AgentLoop → TurnEvent → TurnController → latest TurnSnapshot → DeliveryCoordinator → TurnSink → Channel
WebSocket/Channel → MessageBus.control → Gateway processor → SessionManager (dialog 操作)
Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
```
@ -19,9 +21,10 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
| `client` | TUI 聊天客户端 |
| `channels` | 外部集成飞书、CLI仅收发消息 |
| `bus` | 有界 inbound/outbound/control 队列;出站 dispatcher 与分目标 lane |
| `session` | 会话生命周期、dialog 操作、每 session 串行队列、上下文与持久化协调 |
| `agent` | LLM 调用循环、工具执行、上下文压缩、媒体处理、子 Agent |
| `providers` | LLM API 客户端OpenAI 兼容、Anthropic |
| `session` | 会话生命周期、dialog 操作、每 session 串行队列、Turn 状态、上下文与持久化协调 |
| `agent` | LLM 调用循环、工具执行、上下文压缩、媒体处理、子 Agent、Turn 语义事件 |
| `providers` | OpenAI/Anthropic 原生流解析统一正文、reasoning、工具、usage 与私有回放状态 |
| `delivery` | 完整 Turn 快照的展示过滤、latest-wins 节流、终态投递和 TurnSink 生命周期 |
| `tools` | Agent 工具bash、文件操作、搜索、HTTP、web、browser、memory、delegate 等) |
| `skills` | Skill 加载、管理和 prompt 构建 |
| `storage` | SQLite 持久化 |
@ -36,11 +39,14 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
## 功能边界
- Channels 仅收发消息,不感知 session 或 LLM
- Channels 通过 MessageBus 发布入站消息,通过 OutboundDispatcher 或每 Turn 一个的 TurnSink 接收出站写入,不感知 session 或 LLM
- MessageBus 本体持有三条有界队列;出站路由、顺序和重试由 `OutboundDispatcher` 负责
- SessionManager 拥有 session 状态、dialog 路由、上下文构建和每 session worker并通过 worker 创建 AgentLoop
- TurnController 是活动 Turn 状态的唯一 ownerSession 在消息原子提交成功后才发布 Completed
- AgentLoop 跨轮无状态,接收已准备的 history 调用 LLM、执行工具并返回一次结果
- Providers 是纯 HTTP 客户端,无 bus/session/channel 感知
- Providers 是纯 HTTP 流客户端,无 bus/session/channel 感知;签名 reasoning 状态只回放给匹配 Provider不下发客户端或 Channel
- DeliveryCoordinator 只投影完整快照,不修改会话历史;慢消费者跳过中间 revision终态显式、有界投递
- 每个活动 Turn 独占一个 TurnSink平台 message ID 和 reaction 清理状态只存在于 sink 内
- Tools 接收原始参数,返回字符串结果
- MCP 工具在 Gateway 初始化时连接服务器、发现工具,并包装成普通 Tool 注册到 ToolRegistry
- 子 Agent 由 `delegate` 工具创建,复用 provider 配置和按需过滤后的工具集;后台任务结果通过 MessageBus 发回原会话
@ -58,6 +64,8 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
- `browser` 工具只有在 `browser.enabled=true` 时注册,依赖 Chrome/Chromium 与 WebDriver
- 同一 session 的普通消息串行处理,不同 session 可并发session 队列容量为 32满时明确拒绝
- 出站消息按 `(channel, chat_id)` 分 lane 保序lane 容量为 64慢目标不阻塞其他目标
- 活动 Turn 与普通出站消息共享 `(channel, chat_id)` 写锁;禁止把 token delta 放入 MessageBus
- `cli_chat` 向 TUI/WebUI 发送统一 `turn_updated` 完整快照;飞书默认 FinalOnly开启 `live_updates` 后编辑同一卡片
- 长生命周期后台任务由 TaskSupervisor 管理;连接局部任务由其 owner 限时 join 或 abort
- 外部建连、重试等待和关停 join 必须可取消且有硬超时
- 不得记录 API Key、Authorization header 或包含临时凭据的完整连接 URL
@ -138,6 +146,12 @@ Worker 的处理原则:
4. Session 持久化由独立 `persistence_lock` 串行化;批量消息使用原子写入,失败时精确回滚内存后缀。
5. 上下文溢出时按 Provider 返回的真实限制重新压缩并重试。
### 活动 Turn
每个主 Agent 请求会创建一个内存 Turn。Provider delta 经 AgentLoop 转换为 reasoning、正文、工具开始/完成等语义事件TurnController 归约为有序 block 和单调 revision 的完整快照。TUI/WebUI 使用 `history + active_turn` 渲染,不自行拼接 token中间帧可丢下一快照会自动收敛。
展示策略在 Gateway 核心出口应用:交互客户端可显示 reasoning 和详细工具状态;外部渠道隐藏 reasoning、工具仅显示紧凑状态无人值守投递只保留正文。运行态不逐 token 入库,完成、取消或中断时才原子保存消息及 completion status。
### 会话恢复
从 Storage 恢复 session 时:

View File

@ -97,6 +97,10 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
| `agent` | string | - | 使用的 agent 名称 |
| `media_dir` | string | ~/.picobot/media/feishu | 配置默认值Gateway 注册渠道时会覆盖为 `{workspace}/media/feishu` |
| `reaction_emoji` | string | "Typing" | 回复意向表达的表情 |
| `live_updates` | bool | false | 是否用单张卡片实时编辑活动 Turn关闭时只发送终态 |
| `live_update_interval_ms` | int | 500 | 卡片更新最小间隔,运行时限制在 2505000ms |
飞书属于外部渠道:无论是否开启实时卡片,都不会接收模型 reasoning工具只显示紧凑状态。配置修改需重启 Gateway 生效。
## mcp 字段

View File

@ -2,7 +2,7 @@
数据库为 SQLite默认位于 workspace 下的 `picobot.db`
连接启用 WAL、`synchronous=NORMAL`、foreign keys、5 秒 busy timeout连接池最多 8 个连接。当前 `PRAGMA user_version=1`;启动时会在事务内补齐旧库字段和索引,遇到比程序更新的 schema version 会拒绝启动。
连接启用 WAL、`synchronous=NORMAL`、foreign keys、5 秒 busy timeout连接池最多 8 个连接。当前 `PRAGMA user_version=4`;启动时会在事务内补齐旧库字段和索引,遇到比程序更新的 schema version 会拒绝启动。
## sessions 表
@ -41,7 +41,11 @@
| `tool_calls` | TEXT | 工具调用参数 JSON |
| `source` | TEXT | 消息来源(跨会话消息时标记来源 session_id |
| `created_at` | INTEGER | 创建时间Unix 毫秒) |
| `reasoning_content` | TEXT | provider 返回的推理内容(如有) |
| `reasoning_content` | TEXT | 可展示的模型 reasoning如有 |
| `provider_state` | TEXT | Provider 私有回放状态 JSON只回放给匹配 Provider不下发客户端或 Channel |
| `turn_id` | TEXT | 产生该消息的活动 Turn ID |
| `iteration` | INTEGER | Agent 工具循环中的迭代序号 |
| `completion_status` | TEXT | `completed` / `cancelled` / `interrupted`,旧数据默认 completed |
`(session_id, seq)` 有唯一索引,防止并发写入重复序号。删除 session 会通过外键级联删除 messages。
@ -137,9 +141,9 @@ Scheduler 使用原子 `UPDATE ... RETURNING` 领取到期任务。任务结果
| `created_at` | INTEGER | 调用时间 |
| `provider` | TEXT | 提供商类型 |
| `model` | TEXT | 模型名称 |
| `request_body` | TEXT | 请求体 JSON |
| `request_body` | TEXT | 请求摘要 JSON旧记录可能是完整请求体 |
| `response_body` | TEXT | 响应体 JSON |
| `error` | TEXT | 错误信息 |
| `duration_ms` | INTEGER | 耗时(毫秒) |
`request_body`/`response_body` 可能包含用户内容,排障和导出数据库时应按敏感数据处理。
旧数据中的 `request_body`/`response_body` 可能包含用户内容,排障和导出数据库时应按敏感数据处理。新 Provider 请求的 `request_body` 只保存模型、消息数、工具数和 stream 标志等摘要;错误响应仍可能包含服务端回显内容。

View File

@ -1248,7 +1248,7 @@ mod tests {
cache_creation_input_tokens: None,
},
};
Ok(crate::providers::provider_stream_from_response(response))
Ok(crate::providers::provider_stream_for_test(response))
}
fn ptype(&self) -> &str {

View File

@ -703,7 +703,7 @@ mod tests {
&self,
_request: ChatCompletionRequest,
) -> Result<crate::providers::ProviderStream, crate::providers::DynProviderError> {
Ok(crate::providers::provider_stream_from_response(
Ok(crate::providers::provider_stream_for_test(
ChatCompletionResponse {
id: "mock".into(),
model: "mock".into(),

View File

@ -545,8 +545,16 @@ impl LLMProvider for AnthropicProvider {
req_builder = req_builder.header(key.as_str(), value.as_str());
}
let req_body_str = serde_json::to_string_pretty(&body).unwrap_or_default();
tracing::debug!(req_body = %req_body_str, "LLM request");
let request_summary = super::stream::diagnostic_request_summary(
&self.model_id,
body.messages.len(),
body.tools.as_ref().map_or(0, Vec::len),
);
tracing::debug!(
message_count = body.messages.len(),
tool_count = body.tools.as_ref().map_or(0, Vec::len),
"Anthropic streaming request"
);
let resp = req_builder.json(&body).send().await.inspect_err(|e| {
let is_timeout = e.is_timeout();
@ -586,7 +594,7 @@ impl LLMProvider for AnthropicProvider {
.append_llm_call(
&self.name,
&self.model_id,
&req_body_str,
&request_summary,
Some(&body_text),
Some(&error_msg),
start.elapsed().as_millis() as u64,

View File

@ -7,9 +7,11 @@ pub use self::anthropic::AnthropicProvider;
pub use self::openai::OpenAIProvider;
use crate::config::LLMProviderConfig;
#[cfg(test)]
pub use stream::provider_stream_for_test;
pub use stream::{
DynProviderError, FinishReason, ProviderChunk, ProviderResponseAccumulator, ProviderStream,
ProviderStreamItem, collect_provider_stream, provider_stream_from_response,
ProviderStreamItem, collect_provider_stream,
};
pub use traits::{
ChatCompletionRequest, ChatCompletionResponse, LLMProvider, Message, Tool, ToolCall,

View File

@ -592,8 +592,11 @@ impl LLMProvider for OpenAIProvider {
req_builder = req_builder.header(key.as_str(), value.as_str());
}
let req_body_str = serde_json::to_string_pretty(&body).unwrap_or_default();
tracing::debug!(req_body = %req_body_str, "LLM request");
let request_summary = super::stream::diagnostic_request_summary(
&self.model_id,
body["messages"].as_array().map_or(0, Vec::len),
body["tools"].as_array().map_or(0, Vec::len),
);
let resp = req_builder.json(&body).send().await.inspect_err(|e| {
let is_timeout = e.is_timeout();
@ -625,7 +628,7 @@ impl LLMProvider for OpenAIProvider {
.append_llm_call(
&self.name,
&self.model_id,
&req_body_str,
&request_summary,
Some(&text),
Some(&error),
start.elapsed().as_millis() as u64,

View File

@ -2,7 +2,7 @@ use std::collections::BTreeMap;
use std::error::Error;
use std::pin::Pin;
use futures_util::{Stream, StreamExt, stream};
use futures_util::{Stream, StreamExt};
use serde::{Deserialize, Serialize};
use crate::bus::ProviderReasoningState;
@ -13,6 +13,20 @@ pub type DynProviderError = Box<dyn Error + Send + Sync>;
pub type ProviderStreamItem = Result<ProviderChunk, DynProviderError>;
pub type ProviderStream = Pin<Box<dyn Stream<Item = ProviderStreamItem> + Send>>;
pub(crate) fn diagnostic_request_summary(
model: &str,
message_count: usize,
tool_count: usize,
) -> String {
serde_json::json!({
"model": model,
"message_count": message_count,
"tool_count": tool_count,
"stream": true,
})
.to_string()
}
/// Incremental framing shared by SSE-based providers. It accepts arbitrary
/// byte/UTF-8 boundaries and returns only joined `data:` payloads.
#[derive(Default)]
@ -212,11 +226,8 @@ pub async fn collect_provider_stream(
Ok(accumulator.finish())
}
/// Adapt a complete response to the stream-first provider contract.
///
/// This is intentionally a compatibility bridge for providers while their
/// native streaming parser is implemented; consumers still use one interface.
pub fn provider_stream_from_response(response: ChatCompletionResponse) -> ProviderStream {
#[cfg(test)]
pub fn provider_stream_for_test(response: ChatCompletionResponse) -> ProviderStream {
let finish_reason = if response.tool_calls.is_empty() {
FinishReason::Stop
} else {
@ -248,12 +259,30 @@ pub fn provider_stream_from_response(response: ChatCompletionResponse) -> Provid
}
chunks.push(ProviderChunk::Usage(response.usage));
chunks.push(ProviderChunk::Done(finish_reason));
Box::pin(stream::iter(chunks.into_iter().map(Ok)))
Box::pin(futures_util::stream::iter(chunks.into_iter().map(Ok)))
}
#[cfg(test)]
mod tests {
use super::*;
use futures_util::stream;
#[test]
fn diagnostic_summary_contains_counts_without_message_content() {
let summary = diagnostic_request_summary("model", 3, 2);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&summary).unwrap(),
serde_json::json!({
"model": "model",
"message_count": 3,
"tool_count": 2,
"stream": true
})
);
assert!(!summary.contains("reasoning"));
assert!(!summary.contains("signature"));
}
#[tokio::test]
async fn collector_assembles_interleaved_tool_argument_fragments() {