Compare commits

..

No commits in common. "762fd16e3f2b374e2b0867ce9046672c49208d22" and "7a5d95e78662851f179ffe9fc2d3b85f9ccf8802" have entirely different histories.

39 changed files with 869 additions and 3683 deletions

View File

@ -7,7 +7,6 @@ This file is the operational contract for coding agents working in this reposito
- `cargo build` — build the binary
- `cargo run -- gateway` — start gateway server (binds `127.0.0.1:19876` by default)
- `cargo run -- chat` — connect to gateway as CLI client (default `ws://127.0.0.1:19876/ws`)
- `cargo run -- run "prompt"` — send one prompt through Gateway, print the terminal Turn, and exit; stdin, JSON, verbose progress, and timeout modes are available
- `docker compose up -d` — start the container with Gateway bound/published on `0.0.0.0:19876`; override `PICOBOT_GATEWAY_HOST`, `PICOBOT_PUBLISH_HOST`, or `PICOBOT_GATEWAY_PORT` as needed
- WebUI — start Gateway, then open `http://127.0.0.1:19876/`; no separate frontend build is required
- `cd webui && npm ci && npm run check && npm run build` — validate the Svelte WebUI independently (Node.js 20+); its local `dist/` is ignored
@ -20,7 +19,6 @@ This file is the operational contract for coding agents working in this reposito
- `.env` files use a custom parser, not dotenv: load `<config-dir>/.env`, then `<workspace_dir>/.env`, while pre-existing process variables remain highest priority; config placeholders `<VAR_NAME>` use the merged values
- Config example: `resources/templates/config.example.json` (released to `~/.picobot/` on first run)
- CLI TUI identity is stored in `~/.picobot/tui_client_id`; it is a non-secret stable chat scope used to restore dialogs across reconnects
- One-shot `run` uses a unique chat scope per invocation; for loopback Gateway URLs it authenticates `/ws` with `~/.picobot/web_admin_token`, while remote URLs use the existing paired CLI token
## Tests
@ -41,18 +39,17 @@ This file is the operational contract for coding agents working in this reposito
- **Gateway mode** (`cargo run -- gateway`): HTTP/WebSocket server; owns `GatewayState` which holds all services
- **Client mode** (`cargo run -- chat`): TUI chat client; connects to gateway via WebSocket, purely for user interaction
- **One-shot client mode** (`cargo run -- run "prompt"`): isolated CLI chat scope; connects to Gateway, waits for a terminal Turn, prints it, and exits
### Core Data Flow
```
Channel → MessageBus.inbound → Gateway inbound router/lane → SessionManager → per-session worker → AgentLoop
Channel → MessageBus.inbound → Gateway processor → SessionManager → per-session worker → AgentLoop
↑ │
└── Channel ← OutboundDispatcher ← per-conversation lane ← MessageBus.outbound
AgentLoop → TurnEvent → Session TurnController → latest TurnSnapshot → DeliveryCoordinator → per-turn TurnSink → Channel
WebSocket/Channel → MessageBus.control → Gateway control router → SessionManager (dialog operations)
WebSocket/Channel → MessageBus.control → Gateway processor → SessionManager (dialog operations)
Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler delivery policy → SessionManager/MessageBus
```
@ -82,7 +79,6 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
### Functional Boundaries
- **Channels** publish inbound messages through `MessageBus`; outbound writes arrive through `OutboundDispatcher` or a per-turn `TurnSink`. They know nothing about sessions or LLM
- **Inbound contract** carries normalized sender/time/media plus `ChannelContext`; core routing may interpret `reply_to` but must treat platform-private context as opaque reply data
- **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
@ -92,9 +88,9 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
- **AgentLoop** is stateless across turns; it receives prepared history, calls LLM providers, executes tools, and returns one result
- **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact
- **WebUI slash completion** must consume the existing `get_slash_commands` WebSocket response; do not duplicate the backend command list in frontend source
- **WebUI chat rendering** sanitizes Markdown before inserting HTML; durable `turn_committed` deltas calibrate normal terminal Turns without a full history reload, and history must preserve structured tool-call metadata so calls and results remain independently collapsible
- **WebUI chat rendering** sanitizes Markdown before inserting HTML; session history must preserve structured tool-call metadata so calls and results remain independently collapsible
- **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. The same conjunction may authenticate only `/ws` for local one-shot `run`; it must never authorize management APIs. Never put bearer or admin tokens in URLs or logs
- **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
@ -139,6 +135,3 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
- `docs/ARCHITECTURE.md` — maintainer-facing runtime design, invariants, lifecycle, and extension guidance
- `AGENTS.md` — concise operational rules for repository agents
- `resources/skills/about-picobot/references/` — runtime knowledge shipped to PicoBot; update it only when the assistant's built-in product knowledge must change
## Version Management
- 在每次功能变化、架构变化后,适当地更新整个产品的版本号

View File

@ -1,6 +1,6 @@
[package]
name = "picobot"
version = "1.2.0"
version = "1.1.2"
edition = "2024"
[dependencies]

View File

@ -83,15 +83,14 @@ RUN useradd -m -s /bin/bash app
WORKDIR /app
# Install the pre-built binary in the standard executable path.
COPY target/release/picobot /usr/local/bin/picobot
# Copy pre-built binary from host
COPY target/release/picobot /app/picobot
# Copy config template
COPY resources/templates/config.example.json /app/config.json.example
# Create persistent application directories. Browser temporary data stays in
# /tmp so bind-mounting /app/.picobot cannot hide its temporary directory.
RUN mkdir -p /app/.picobot/workspace /app/.picobot/media && \
# Create required directories
RUN mkdir -p /app/.picobot/workspace /app/.picobot/media /app/.picobot/tmp && \
chown -R app:app /app
USER app
@ -99,9 +98,9 @@ ENV HOME=/app
# Environment variables for Chromium in containers
ENV CHROME_BIN=/usr/bin/chromium
ENV TMPDIR=/tmp
ENV TMPDIR=/app/.picobot/tmp
ENTRYPOINT ["/usr/local/bin/picobot"]
ENTRYPOINT ["/app/picobot"]
CMD ["gateway"]
EXPOSE 19876

View File

@ -9,7 +9,6 @@ PicoBot 是一个用 Rust 编写的个人 AI 助手运行时。它在本地启
## 适合做什么
- 在终端里和本地 AI 助手持续对话。
- 从脚本或命令行发送一条任务,等待完整的模型/工具循环后只输出最终结果。
- 在 TUI 或浏览器中实时查看正文、思考过程和工具执行状态,并在完成后收敛到持久化历史。
- 在浏览器中查看日志、任务和记忆,修改运行配置与助手档案。
- 复杂任务可创建 session 级 Todo 计划,把不同子项并行委托给多个子 Agent聊天页侧栏实时显示进度。
@ -125,22 +124,9 @@ docker compose up -d
cargo run -- chat
```
CLI 默认连接 `ws://127.0.0.1:19876/ws`TUI 首次使用先运行 `picobot pair`,再执行 `picobot chat --pair-code <CODE>`;客户端令牌会以 `0600` 权限保存到 `~/.picobot/tui_auth_token`。如需指定地址,可使用 `--gateway-url`
CLI 默认连接 `ws://127.0.0.1:19876/ws`。首次使用先运行 `picobot pair`,再执行 `picobot chat --pair-code <CODE>`;客户端令牌会以 `0600` 权限保存到 `~/.picobot/tui_auth_token`。如需指定地址,可使用 `--gateway-url`
### 5.1 一次性执行
`run` 通过 Gateway 发送一条消息,复用正常的 SessionManager、AgentLoop 和工具调用流程,收到 Turn 终态后打印最终回复并退出:
```bash
picobot run "检查这个项目并总结测试结果"
printf '使用浏览器打开 example.com 并返回页面标题\n' | picobot run
```
默认情况下 stdout 只包含最终回复,便于管道和脚本消费。`--verbose` 把阶段和工具状态写到 stderr`--json` 输出包含 session、turn、状态、正文、usage 和错误的一行 JSON`--timeout` 设置最大等待秒数。超时或按下 Ctrl-C 时,客户端会先向当前会话发送 `/stop`
连接本机回环地址时不需要人工配对:`run` 自动读取 `~/.picobot/web_admin_token`Gateway 只有在真实 TCP 对端也是回环地址时才允许该凭据访问 `/ws`。每次调用使用独立的临时 chat scope不会替换正在运行的 TUI 连接。连接远程 Gateway 时仍使用 `~/.picobot/tui_auth_token` 中已有的配对令牌。
### 5.2 使用 WebUI
### 5.1 使用 WebUI
Gateway 启动后直接打开:
@ -156,14 +142,6 @@ picobot pair
在浏览器配对页输入输出的 8 位代码即可。配对码 5 分钟内有效且只能使用一次;浏览器凭据由 HttpOnly Cookie 保存。需要撤销全部浏览器和 CLI 客户端时运行 `picobot pair --revoke-all`,再用新代码重新配对。
Docker 部署必须在 Gateway 容器内签发配对码,使请求来自容器自身回环地址并能读取映射目录中的管理密钥:
```bash
docker compose exec picobot picobot pair --gateway-url http://127.0.0.1:19876
```
不要从宿主机经发布端口直接调用签发接口;容器会把该连接识别为非回环来源并拒绝。`picobot` 已加入正式镜像的 `PATH`,可在容器 shell 中直接调用。
WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件,提供:
- 在线聊天、会话创建/切换、历史回放、流式 Markdown、独立思考区、实时工具状态、可折叠历史工具调用卡片以及基于 Gateway 实时命令清单的 `/` 斜杠命令补全。
@ -177,7 +155,7 @@ WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件
配置接口会掩码 API Key、secret、password 和 token保留 `********` 再保存不会覆盖原密钥。运行配置采用原子写入并在 Gateway 重启后生效,`USER.md``AGENTS.md` 则会用于后续构建的 Agent 上下文。
WebUI 默认启用设备配对鉴权,管理 API 与 `/ws` 都拒绝未配对客户端;静态配对页、公开健康检查和配对提交接口除外。唯一的 WebSocket 例外是本机 `picobot run`:请求必须同时来自真实回环对端并持有权限为 `0600``~/.picobot/web_admin_token`,该管理令牌不能绕过任何管理 API 的设备鉴权。配对令牌只以 SHA-256 哈希写入 `~/.picobot/web_auth.json`。鉴权不提供传输加密;如果通过 `--host 0.0.0.0`、反向代理或端口转发暴露 Gateway仍必须使用 TLS。可通过 `gateway.require_pairing=false` 显式关闭配对,但不建议在非隔离环境使用。
WebUI 默认启用设备配对鉴权,管理 API 与 `/ws` 都拒绝未配对客户端;静态配对页、公开健康检查和配对提交接口除外。令牌只以 SHA-256 哈希写入 `~/.picobot/web_auth.json`,本地配对码管理密钥位于权限为 `0600``~/.picobot/web_admin_token`。鉴权不提供传输加密;如果通过 `--host 0.0.0.0`、反向代理或端口转发暴露 Gateway仍必须使用 TLS。可通过 `gateway.require_pairing=false` 显式关闭配对,但不建议在非隔离环境使用。
#### WebUI 开发
@ -265,7 +243,7 @@ WebUI/TUI 上传文件默认保存到 `~/.picobot/media/cli_chat`,单文件上
| `cli_chat` | Ratatui 终端客户端,通过 WebSocket 连接 Gateway |
| `feishu` | 飞书/Lark 消息、反应、文件上传下载和媒体引用 |
飞书默认只接受 `allow_from` 中的用户,且群聊消息必须明确 @ 机器人(可通过 `channels.feishu.require_mention=false` 关闭)。回复会使用飞书原生引用/话题语义保持在原消息位置。默认只发送终态结果;设置 `channels.feishu.live_updates=true` 后会创建一张卡片并持续编辑,`live_update_interval_ms` 默认 500ms运行时限制在 2505000ms。外部渠道始终不会收到模型 reasoning。
飞书默认只发送终态结果。设置 `channels.feishu.live_updates=true` 后会创建一张卡片并持续编辑;`live_update_interval_ms` 默认 500ms运行时限制在 2505000ms。外部渠道始终不会收到模型 reasoning。
### 会话
@ -370,11 +348,6 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
| `browser.enabled` | `false` |
| `channels.feishu.live_updates` | `false` |
| `channels.feishu.live_update_interval_ms` | `500` |
| `channels.feishu.require_mention` | `true` |
| `channels.feishu.max_image_bytes` | `10485760` |
| `channels.feishu.max_file_bytes` | `26214400` |
| `channels.feishu.media_dir_max_bytes` | `536870912` |
| `channels.feishu.request_timeout_secs` | `30` |
更完整的配置字段说明见 [resources/skills/about-picobot/references/config.md](resources/skills/about-picobot/references/config.md)。

View File

@ -1,33 +0,0 @@
{
"providers": {
"aliyun": {
"type": "openai",
"base_url": "https://example.invalid/v1",
"api_key": "test-only-not-a-real-key",
"extra_headers": {}
}
},
"models": {
"qwen-plus": {
"model_id": "qwen-plus",
"temperature": 0.0,
"max_tokens": 100,
"input_type": ["text"]
}
},
"agents": {
"default": {
"provider": "aliyun",
"model": "qwen-plus",
"max_tool_iterations": 20,
"token_limit": 128000
}
},
"gateway": {
"host": "127.0.0.1",
"port": 19876,
"require_pairing": true
},
"channels": {},
"workspace_dir": "/tmp/picobot-test-workspace"
}

View File

@ -1,21 +0,0 @@
services:
picobot:
build:
context: .
dockerfile: Dockerfile
image: picobot:1.2.0
container_name: picobot-test
restart: unless-stopped
ports:
- "${PICOBOT_PUBLISH_HOST:-127.0.0.1}:${PICOBOT_GATEWAY_PORT:-19876}:${PICOBOT_GATEWAY_PORT:-19876}"
volumes:
- "${HOME}/.picobot:/app/.picobot"
environment:
RUST_LOG: "${RUST_LOG:-info}"
TZ: "${TZ:-Asia/Shanghai}"
command:
- gateway
- --host
- "${PICOBOT_GATEWAY_HOST:-0.0.0.0}"
- --port
- "${PICOBOT_GATEWAY_PORT:-19876}"

View File

@ -2,7 +2,7 @@
本文档描述 PicoBot 当前实现的运行时边界、数据流、并发模型和演进约束。它面向维护者和后续参与改进的 Agent是代码架构的主入口行为细节仍以代码和测试为最终依据。
流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递的详细设计与取舍见 [STREAMING_TURN_DESIGN.md](STREAMING_TURN_DESIGN.md)。用户输入路由、Session 执行拆分、终态投递确认和历史增量校准的重构方案见 [MESSAGE_FLOW_REFACTOR_DESIGN.md](MESSAGE_FLOW_REFACTOR_DESIGN.md)。
流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递的详细设计与取舍见 [STREAMING_TURN_DESIGN.md](STREAMING_TURN_DESIGN.md)。
## 1. 设计目标
@ -18,13 +18,12 @@ PicoBot 是一个单进程、异步、可扩展的个人 AI 助手运行时。
## 2. 运行模式与进程边界
PicoBot 只有一个二进制,提供三种运行模式:
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 终态,输出结果后退出 |
Linux 上可通过 `picobot service install/start/stop/status/restart/uninstall` 管理 systemd 用户服务。unit 固定为 `picobot.service`,其主进程仍是普通 Gateway 模式,不引入额外 daemon/fork 层;异常退出由 systemd 按 `Restart=on-failure` 拉起。
@ -32,8 +31,6 @@ Linux 上可通过 `picobot service install/start/stop/status/restart/uninstall`
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 层不得重定向自身位置。环境文件只在单线程启动阶段写入进程环境,不能移到后台任务启动之后。切换完成后所有相对路径都应按 workspace 解释,不能假设仍位于源码仓库。
## 3. 组件关系
@ -42,7 +39,7 @@ Gateway 启动时先从配置目录 `.env`、workspace `.env` 和既有进程环
flowchart LR
External[CLI / Feishu] --> Channels[channels]
Channels -->|InboundMessage| Bus[MessageBus]
Bus --> Processor[Gateway inbound/control routers]
Bus --> Processor[Gateway message processor]
Processor --> Sessions[SessionManager]
Sessions --> Agent[AgentLoop]
Agent --> Providers[LLM providers]
@ -82,9 +79,9 @@ flowchart LR
`MessageBus` 包含三条容量相同的 Tokio MPSC 队列:
- `inbound`Channel → Gateway inbound router。
- `inbound`Channel → Gateway message processor。
- `outbound`Session/Tool → `OutboundDispatcher`
- `control`WebSocket/Channel → Gateway control router用于 dialog 操作。
- `control`WebSocket/Channel → Gateway message processor用于 dialog 操作。
### 普通消息
@ -92,7 +89,7 @@ flowchart LR
sequenceDiagram
participant C as Channel
participant B as MessageBus
participant G as Inbound router
participant G as Message processor
participant S as SessionManager
participant W as Per-session worker
participant A as AgentLoop / Provider
@ -123,12 +120,10 @@ sequenceDiagram
关键语义:
- Gateway inbound router 按 `(channel, chat_id)` 使用容量 32 的短生命周期 lane 保持入口顺序,不同聊天可并发路由;普通消息进入对应 session worker 后立即返回 `AgentProcessing`
- `/stop` 绕过同聊天的 inbound lane直接使正在运行的 worker/Turn 失效;其他 slash command 仍在聊天 lane 内有序执行。
- Gateway 的主消息处理循环不等待模型完成;普通消息进入对应 session worker 后立即返回 `AgentProcessing`
- 每个 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 发 deltaAgentLoop 发结构化 TurnEvent只有 TurnController 能把事件归约为有序 block 和单调 revision 的完整快照。
- Turn 快照经 Tokio `watch` 发布,语义为 latest-wins慢客户端或慢渠道跳过中间状态不反压 Provider。终态明确编码在快照中不依赖 sender 关闭。
- Agent 本轮消息原子持久化成功后才发布 `Completed`。取消或失败若已有可见正文,则保存为 `cancelled`/`interrupted` partial只有 reasoning 时不创建 assistant 历史。
@ -140,11 +135,8 @@ sequenceDiagram
- `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 缓存失效后重新获取。
- 飞书默认 `FinalOnly`;开启 `live_updates`第一个可见快照创建卡片后续编辑同一卡片终态编辑失败则发送完整结果兜底。reaction 清理在 finish、abort 和 Gateway shutdown 中幂等执行。
- DeliveryCoordinator 与 OutboundDispatcher 共享 `(channel, chat_id)` 写锁,避免活动 Turn 终态与独立消息并发写入同一目标。
### 出站投递
@ -161,7 +153,7 @@ sequenceDiagram
### Control 消息
WebSocket dialog 操作通过 `ControlMessage` 携带一次性回复通道。Gateway control router 以最多 64 个并发受监督任务调用 `SessionManager`,再将 `SessionEvent` 回传给发起者;慢 control 不阻塞其他聊天的 inbound 路由。Bus 只承载消息,不解释操作。
WebSocket dialog 操作通过 `ControlMessage` 携带一次性回复通道。Gateway 在统一 message processor 中调用 `SessionManager`,再将 `SessionEvent` 回传给发起者。Bus 只承载消息,不解释操作。
TUI 的历史回放同样走 control 队列:`get_session_history` 先校验 session 属于当前客户端 scope再由 SessionManager 从 Storage 读取最近消息。单次查询限制为 12000 条TUI 默认请求最近 1000 条;迟到的历史响应只有在目标仍是当前 dialog 时才允许更新界面。
@ -189,9 +181,7 @@ Session ID 格式为:
5. 持久化写入由 `persistence_lock` 串行化;多条相关记录应使用 Storage 的原子接口。
6. 内存先变更但持久化失败时,必须回滚精确匹配的消息后缀,不能删除无关的新状态。
SessionManager 负责组装会话上下文系统提示、Skills、召回的 Knowledge、压缩后的 Timeline、可选的 active plan 摘要和当前消息历史。`session::turn_input` 在 Session 锁外并行读取 Knowledge、active plan 并压缩历史,然后通过同一个 assembly 路径生成首次请求和 context-overflow 重试输入;重试不得复制系统提示或 runtime context 拼接逻辑。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态。
当前 Turn 的工具进度只从 `AgentLoop` 的结构化 `TurnEvent` 进入 `TurnController`,不能另建字符串 notification 通道重复投递。后台子 Agent 的 `TaskNotification` 表达跨 Turn 的任务完成仍由独立的受监督消费者投递。自动标题属于非关键派生工作Turn 持久化完成后由 `TaskSupervisor` 调度Session worker 不等待模型生成;同一 Session 同时最多有一个标题任务,提交时仍校验标题保持默认值,避免覆盖用户改名。
SessionManager 负责组装会话上下文系统提示、Skills、召回的 Knowledge、压缩后的 Timeline、可选的 active plan 摘要和当前消息历史。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,在历史压缩之后追加,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态。
每个 session 最多有一个 active plan但 plan 内多个 item 可以分别绑定不同子 Agent 并行执行。主 Agent 通过 `todo` 创建和维护计划,通过 `delegate.plan_item_id` 委托;子 Agent 不获得 `todo``delegate`。领取 item 使用条件更新防止重复执行,迟到结果只有在 plan 仍 active 且 execution ID 匹配时才允许提交。
@ -204,7 +194,7 @@ SessionManager 负责组装会话上下文系统提示、Skills、召回的 K
- 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 时应:
持久化范围包括 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. 保留已有数据库的升级路径。
@ -221,7 +211,7 @@ SessionManager 负责组装会话上下文系统提示、Skills、召回的 K
## 7. 后台任务与生命周期
`TaskSupervisor` 是 Gateway 内部后台任务的统一所有者。inbound/control routers、inbound lanes、outbound dispatcher、scheduler、session workers、Turn delivery、outbound lanes、后台任务通知消费者、自动标题和子 Agent 后台任务都应通过它注册。
`TaskSupervisor` 是 Gateway 内部后台任务的统一所有者。message processor、outbound dispatcher、scheduler、session workers、Turn delivery、outbound lanes、通知消费者和子 Agent 后台任务都应通过它注册。
两种注册方式:
@ -241,13 +231,13 @@ Turn delivery 使用 `spawn_graceful`。全局取消发生时先停止读取快
### 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 在正常完成时合并 `turn_committed` 增量并将工具信息渲染为默认折叠的工具卡片;聊天页的 Todo 侧栏默认隐藏,按 session 保存快照和未读状态,并通过结构化 `session_plan`/`plan_updated` 帧刷新,计划变化不会写入聊天历史;斜杠命令补全通过 `get_slash_commands` 获取 Gateway 的实时命令与别名清单不在前端重复定义WebUI 不直接调用 Provider 或 SessionManager。
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。
WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输WebSocket 只携带短期 `upload_id` 和结构化附件描述。`UploadRegistry` 在内存中按 `cli_chat` chat scope 校验并消费待发送上传;消息继续以 `media_refs` 保存 Gateway 本地路径,不建立永久附件资产。下载接口必须通过 client、session、message 和附件序号反查路径,不能接受客户端路径。历史附件路径失效属于正常状态,不得影响历史文本读取。待发送但未进入消息的上传由 `TaskSupervisor` 所有的限时清理任务回收。Agent 上下文会为所有媒体注入内部路径清单,客户端响应不得暴露该路径。
工具默认通过 `ToolResult` 返回文本;需要把图片等产物交给模型时,通过 `Tool::execute_with_media` 返回文本和结构化 `MediaRef`。工具只负责经过自身路径策略校验后声明媒体,不感知当前模型或 Provider。`AgentLoop` 仅将最新连续工具结果批次的媒体交给 `MediaHandlerRegistry`,旧工具媒体只回放文本和路径,避免历史 Base64 膨胀。OpenAI-compatible Provider 保持 `tool` 结果为文本,并在完整工具批次后构造仅存在于请求内的临时多模态 `user` 消息Anthropic Provider 将媒体放入对应 `tool_result.content`。媒体加载、格式或能力检查失败必须降级成文本,不得使历史记录不可读取。
`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 秒复核身份并回收已撤销连接。
`AuthManager` 默认保护所有管理 API 和 `/ws`。静态资源、`/health``/api/auth/status``/api/auth/pair` 保持公开,使未配对浏览器只能加载配对界面。`picobot pair` 使用权限为 `0600` 的本机管理密钥调用仅接受真实回环连接的 `/api/auth/code`;反向代理即使从回环连接也无法在没有该密钥时签发代码。配对码为 8 位、5 分钟有效、单次消费,并按来源实施失败锁定。浏览器收到 HttpOnly、SameSite=Strict CookieCLI 使用 Bearer token服务端仅持久化 SHA-256 哈希。`--revoke-all` 的持久化成功后才清空内存令牌,活动 WebSocket 每 5 秒复核身份并回收已撤销连接。
同源 `/api/*` 管理接口只提供显式白名单能力:
@ -269,7 +259,7 @@ WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输WebSocket 只
3. 初始化 SQLite、MemoryManager、MessageBus 和 SessionManagerScheduler 启用时幂等创建默认日常维护巡检。
4. 注册内置工具、渠道、MCP 工具和 Cron 工具。
5. 启动所有 Channel。
6. 通过 TaskSupervisor 启动 inbound/control routers、dispatcher 和 scheduler。
6. 通过 TaskSupervisor 启动 message processor、dispatcher 和 scheduler。
7. 注册 WebUI 静态资源、管理 API 与聊天 WebSocket 路由。
8. 绑定 Axum listener开始接收请求。

View File

@ -1,363 +0,0 @@
# 用户消息到 LLM 回复链路重构设计
> 状态实施中2026-07
>
> 本文定义用户消息入口、Session 执行、Turn 提交和客户端校准链路的重构方案。运行时总览见 `docs/ARCHITECTURE.md`,流式状态模型见 `docs/STREAMING_TURN_DESIGN.md`;代码和测试始终是最终事实来源。
## 1. 背景
现有链路已经具备 Channel/Provider 隔离、每 Session 串行、Turn latest-wins 快照和持久化后才发布 `Completed` 等正确基础,但演进过程中留下了以下问题:
1. `TurnDeliveryService::start` 只报告 sink task 已启动Session 丢弃 task 的最终结果sink 终态失败后可能没有普通消息兜底。
2. Gateway 用一个循环同步等待所有 inbound 和 control 操作,慢命令或数据库查询会阻塞无关会话。
3. Session worker 同时负责上下文准备、Agent 执行、overflow 恢复、提交、投递降级、标题生成和清理。
4. 普通工具通知与结构化 `TurnEvent::ToolStarted/ToolFinished` 重复。
5. `InboundMessage` 声明了 sender、接收时间和 metadata但进入 Session 后部分字段被丢弃;平台字段依赖字符串约定透传。
6. TUI/WebUI 每次收到终态都重新请求最多 1000 条历史,重复传输刚刚已经通过 TurnSnapshot 下发的结果。
7. 自动标题生成在 Turn 完成后仍占用 Session worker阻塞下一条排队消息。
## 2. 设计目标
1. sink 终态失败必须可观测,并至多触发一次普通消息兜底。
2. 一个会话的慢 control/command 不得阻塞其他会话的输入和 `/stop`
3. Session worker 只负责队列和生命周期编排,慢步骤由职责明确的 helper/service 承担。
4. 上下文首次准备与 overflow 恢复复用同一构建路径。
5. 工具进度只有一个权威来源TurnEvent。
6. 用户消息的发送者和接收时间要么被持久化,要么从公共数据契约中删除,不能静默丢失。
7. 平台私有上下文以不透明值传递,核心层不解释平台 key。
8. 终态提交向客户端提供历史增量;全量历史只用于初次加载、重连和 revision 缺口恢复。
9. 标题生成不属于 Turn 完成关键路径,并且迟到结果必须条件提交。
10. 所有新增等待、队列、重试和后台任务都必须受 `TaskSupervisor` 管理并有硬边界。
## 3. 非目标
- 不合并 `TurnSnapshot` 与持久化 `ChatMessage`;两者分别是暂态展示和耐久事实。
- 不把 token delta 放入 MessageBus。
- 不取消每 Session 串行语义。
- 不让 Channel、Provider 或客户端直接访问 Session 内部状态。
- 不在本次重构中改变 SQLite schema 版本;新增消息来源信息复用现有 `source` JSON。
- 不保证运行中 Turn 在 Gateway 重启后恢复逐帧状态。
## 4. 目标数据流
```text
Channel
│ normalize + authorize
InboundEnvelope
IngressRouter ───────────────► scoped command task
│ │
▼ ▼
per-session AgentTask queue Session command API
ConversationExecutor
├── persist user message
├── TurnInputBuilder.prepare/recover
├── TurnRunner (AgentLoop + cancel)
├── TurnCommitter (generation check + atomic persistence)
├── DeliveryHandle.await_terminal/fallback
└── schedule TitleService
├── TurnSnapshot ─► TurnSink
└── TurnCommitted ─► client history delta
```
## 5. 可观测的 Turn 投递
### 5.1 接口
`TurnDeliveryService::start` 返回一个必须消费的 handle
```rust
pub struct TurnDeliveryHandle {
completion: oneshot::Receiver<Result<(), DeliveryError>>,
}
impl TurnDeliveryHandle {
pub async fn wait(self) -> Result<(), DeliveryError>;
}
```
启动失败与异步失败语义分开:
- `start(...) -> Err`Channel 不存在、`open_turn` 失败或 supervisor 已停止Session 从一开始使用普通终态投递。
- `start(...) -> Ok(handle)`sink 生命周期已启动,但不代表终态已经到达外部平台。
- `handle.wait() -> Err`:终态重试耗尽或 shutdown abort 失败Session 执行普通消息兜底。
### 5.2 兜底规则
1. Agent 结果必须先持久化,之后 Turn 才能 `Completed`
2. Session 发布终态后等待 delivery handle等待本身由 coordinator 的 sink timeout/retry 限制。
3. delivery 成功:不发送普通消息。
4. delivery 失败:通过 `MessageBus::deliver_outbound` 发送最终正文,并记录明确错误。
5. `Cancelled`/`Failed` 终态不重复发送正文;只有存在可展示 partial 且 sink 失败时才发送 partial/failure 摘要。
6. Channel sink 内部可以做平台特定编辑降级,但不得把“未找到目标/未发送”报告为成功。
### 5.3 测试
- open 失败时发送一次普通终态。
- open 成功、finish 永久失败时发送一次普通终态。
- finish 瞬态失败后成功时不发送普通终态。
- 持久化失败时不得把失败前正文作为 completed fallback 发送。
- shutdown/cancel 不造成双重终态。
## 6. Gateway 入口并发
### 6.1 现状问题
单个 `message-processor``tokio::select!` 分支内等待 `handle_message` 和 control I/O。`/compact` 的 LLM 调用、大历史查询或慢 SQLite 操作会造成跨 Session 队头阻塞。
### 6.2 目标
拆成两个只负责消费和派发的 supervisor task
- `inbound-router`:消费 `InboundMessage`,为每条输入启动受监督的短派发任务;普通消息最终进入 Session 队列。
- `control-router`:消费 `ControlMessage`,为每个请求启动受监督任务并通过一次性回复通道返回。
Session 内部继续用 mutex、`persistence_lock``worker_generation``state_version` 保证同一对话的一致性。Gateway 不再通过全局串行获得隐式正确性。
### 6.3 有界性
- MessageBus 仍是全局 admission queue。
- 普通 AgentTask 仍受每 Session 容量 32 限制。
- router 通过 `TaskSupervisor::spawn` 管理请求任务spawn 失败必须向调用者/Channel 返回错误。
- control reply 使用 `oneshot`,每个请求只有一个结果。
- `/stop` 直接修改目标 Session cancellation/generation不进入 AgentTask 队列。
### 6.4 测试
- Session A 的慢 command 不阻塞 Session B 的普通输入。
- Session A 的慢 history query 不阻塞 Session B `/stop`
- router shutdown 后新请求收到明确失败。
- 同一 Session 的 AgentTask 顺序保持不变。
## 7. Session 执行拆分
### 7.1 `TurnInputBuilder`
输入:稳定的 `SessionTurnSnapshot`、用户输入、skills、MemoryManager、WorkManager。
输出:
```rust
struct PreparedTurnInput {
messages: Vec<ChatMessage>,
base_state_version: u64,
compression_update: Option<CompressionUpdate>,
}
```
职责:
- 并发读取 Knowledge memory 和 active plan
- 运行 ContextCompressor
- 统一插入 system prompt
- 统一向最后一条用户消息追加 runtime context
- 返回需要条件提交的 compression metadata不直接持有 Session 锁做慢 I/O。
`recover_after_overflow` 复用同一 assembly 函数,只替换 context window 和压缩结果,不能复制 prompt/runtime context 拼装逻辑。
### 7.2 `TurnRunner`
职责:
- 创建 `TurnController``AgentTurnContext` 和 delivery handle
- 在 `AgentLoop` 与 cancel receiver 之间 select
- 最多执行一次 context-overflow recovery
- 返回类型化 `TurnRunOutcome`,不直接构造 OutboundMessage。
```rust
enum TurnRunOutcome {
Completed(AgentProcessResult),
Cancelled(Option<ChatMessage>),
Failed { error: AgentError, partial: Option<ChatMessage> },
Stale,
}
```
### 7.3 `TurnCommitter`
职责:
- 提交前验证 generation/state version
- 原子持久化 `emitted_messages`
- 成功后发布 `Completed`
- 失败/取消时按 partial 规则持久化并发布对应终态;
- 产生 `CommittedTurnDelta`
### 7.4 Session worker 保留职责
- 从队列接收 AgentTask
- 持久化原始用户消息;
- 捕获稳定快照;
- 顺序调用 builder/runner/committer
- 清理 active turn 和 cancel handle
- 调度非关键后台工作。
## 8. 上下文策略收口
上下文策略分两级,但 owner 明确:
- `TurnInputBuilder`跨轮历史压缩、Timeline/Memory/Plan、overflow recovery。
- `AgentLoop`:单次工具循环中临时裁剪过大的旧 tool result不修改 Session 历史。
二者不能重复构建 system/runtime prompt。`AgentLoop` 的“缺 system 时自动注入”仅保留给明确的 stateless API交互 Session 调用使用要求首条必须为 system 的入口或 debug assertion。
Memory recall 和 active plan 查询互不依赖,应使用 `tokio::join!` 并发执行。任何结果提交前都验证 `base_state_version`
## 9. 工具进度唯一来源
交互 Turn 删除 `AgentLoop.notify_tx: UnboundedSender<String>` 和每消息 notification publisher。工具进度仅由
```text
ToolStarted → TurnController → TurnSnapshot
ToolFinished → TurnController → TurnSnapshot
```
后台子 Agent 的 `TaskNotification` 是另一种领域事件,继续保留,因为它表达跨 Turn 的任务完成,而不是当前 Turn 的工具进度。
## 10. Inbound 与 ChannelContext
### 10.1 规范化输入
```rust
struct InboundMessage {
channel: String,
chat_id: String,
sender_id: String,
content: String,
received_at: i64,
media: Vec<MediaItem>,
channel_context: ChannelContext,
}
```
`metadata``forwarded_metadata` 合并为语义明确的不透明 `ChannelContext`。核心只允许:
- 原样传给本轮 `TurnTarget` 或普通错误回复;
- 从通用 typed 字段读取 `reply_to`
- 不解析 `feishu.*` 等平台 key。
平台 message/reaction ID 最终由具体 sink 持有。`feishu.parent_id` 要么映射为 typed `reply_to`,要么删除,不能继续作为无消费者字段。
### 10.2 持久化用户来源
用户 `ChatMessage` 使用原始 `received_at`,并设置:
```rust
MessageSource {
kind: UserInput,
from_channel: Some(channel),
from_user_id: Some(sender_id),
...
}
```
客户端历史投影可以隐藏内部 sender IDLLM 上下文是否展示发言者由独立策略决定,不能直接泄露平台标识。
## 11. 终态历史增量
### 11.1 协议
持久化成功后发布:
```rust
WsOutbound::TurnCommitted {
session_id: String,
history_revision: u64,
messages: Vec<HistoryMessage>,
}
```
规则:
- `TurnUpdated(Completed)` 仍负责结束 active turn 展示。
- `TurnCommitted` 只包含本轮新持久化消息,负责把 transcript 校准到数据库事实。
- Session 维护单调 `history_revision`;客户端只接受连续 revision。
- revision 缺口、重连或显式切换 Session 时才请求全量历史。
- 全量 `SessionHistory` 返回当前 revision。
第一阶段可使用最终 assistant/tool message IDs 去重;如果不修改数据库 schemarevision 使用 Session 内 `state_version`/最新 message sequence 投影,重启后从 Storage 最大 sequence 恢复。
### 11.2 测试
- 正常 Turn 完成不触发全量 history 请求。
- 增量包含 assistant tool call、tool result 和最终 assistant。
- 重复增量按 message ID 幂等。
- revision 缺口触发一次全量校准。
- 其他 Session 的增量只更新对应缓存/未读状态。
## 12. 标题后台化
Turn 提交后Session worker 调用 `TitleService::schedule` 并立即处理下一条任务。
后台任务:
1. 在锁内捕获 title prompt、session ID 和 `state_version`
2. 在锁外调用 Provider。
3. 获取 `persistence_lock`
4. 只有标题仍为默认值且 generation/state 条件允许时提交。
5. 任务由 `TaskSupervisor` 管理shutdown 时取消并限时回收。
标题失败只记录 warning不改变 Turn 状态,不向用户发送错误消息。
## 13. 错误模型
Session worker 不再散落构造英文字符串 OutboundMessage而是返回类型化错误
```rust
enum TurnFailureKind {
InputPersistence,
AgentCreation,
ContextPreparation,
Provider,
TurnPersistence,
Delivery,
}
```
统一 `TurnFailurePresenter` 根据 Channel/PresentationPolicy 生成用户可见内容。原始 provider/storage 错误只进入安全日志和 Turn internal error不直接暴露 secrets。
Gateway 的 `handle_message` 错误不能只写日志;必须通过输入携带的 ChannelContext 返回一个关联到原消息的错误结果。
## 14. 迁移与提交顺序
1. 文档:落地本设计和架构链接。
2. Delivery返回 completion handleSession 消费结果并做一次兜底。
3. Gateway拆分 inbound/control router消除全局慢操作串行。
4. Session提取输入构建和 overflow recovery再提取 run/commit helper。
5. Progress/title删除旧工具通知标题移入 supervisor。
6. Contract规范 InboundMessage、用户来源和 ChannelContext。
7. Protocol增加 TurnCommitted/history revision客户端改为增量校准。
8. 最终清理:删除不可达 `HandleResult::AgentResponse` 交互分支和重复 helper。
每一步都保持可编译、可测试、可单独回滚,不允许一个提交同时更改全部并发和协议语义。
## 15. 回归测试矩阵
| 风险 | 必需测试 |
|---|---|
| sink 异步终态失败 | fallback 恰好一次,成功时零次 |
| Gateway 队头阻塞 | 慢 A 不阻塞 B 输入/control |
| stale worker | generation/state 改变后不得提交 |
| overflow recovery | prompt/runtime context 只附加一次 |
| duplicate tool progress | 交互 Turn 不产生普通工具通知 |
| inbound fidelity | sender、received_at、reply_to 正确保留 |
| title race | 用户重命名后迟到标题不得覆盖 |
| history delta | 连续、重复、缺口、跨 Session |
| shutdown | router、delivery、title task 均被监督和有界回收 |
## 16. 完成条件
- `cargo test --lib`
- `cargo test --test test_scheduler --test test_request_format`
- `cargo clippy --all-targets --all-features -- -D warnings`
- `cargo build`
- `webui/npm run check`
- `webui/npm run build`
- 新增的失败、取消、并发、revision 和 fallback 测试全部通过。
- `docs/ARCHITECTURE.md`、AGENTS.md 中的运行时不变量与实现一致。

View File

@ -17,7 +17,7 @@ PicoBot 是一个基于 Rust 的个人 AI 助手运行时,包含本地 Gateway
| `references/db-schema.md` | 数据库表结构与运行约束sessions、messages、memories、scheduled_jobs、job_runs、llm_calls、background_tasks |
| `references/architecture.md` | 核心架构消息并发、会话系统、持久化、生命周期、上下文压缩、记忆、MCP、子 Agent |
| `references/faq.md` | 常见问题模型切换、渠道添加、Skill 安装、历史查询、定时任务、MCP 等 |
| `references/commands.md` | 常用命令:编译、启动网关、Docker/WebUI 设备配对、启动客户端、运行测试 |
| `references/commands.md` | 常用命令:编译、启动网关、启动客户端、运行测试 |
| `references/tools.md` | 内置工具名称、参数和重要使用约束 |
| `assets/config.example.json` | config.json 完整示例 |

View File

@ -60,16 +60,9 @@
"app_id": "<FEISHU_APP_ID>",
"app_secret": "<FEISHU_APP_SECRET>",
"allow_from": ["*"],
"require_mention": true,
"agent": "default",
"media_dir": "~/.picobot/media/feishu",
"reaction_emoji": "Typing",
"live_updates": false,
"live_update_interval_ms": 500,
"max_image_bytes": 10485760,
"max_file_bytes": 26214400,
"media_dir_max_bytes": 536870912,
"request_timeout_secs": 30
"reaction_emoji": "Typing"
}
},
"memory": {

View File

@ -22,12 +22,6 @@ picobot pair
# 撤销全部设备并生成新配对码
picobot pair --revoke-all
# Docker 部署:必须在 Gateway 容器内执行,保证请求来自容器回环地址
docker compose exec picobot picobot pair --gateway-url http://127.0.0.1:19876
# 使用仓库测试 Compose 文件时
docker compose -f docker-compose.test.yml exec picobot picobot pair --gateway-url http://127.0.0.1:19876
# 修改 WebUI 后独立检查Node.js 20+
cd webui
npm ci
@ -72,5 +66,3 @@ cargo test --test test_tool_calling -- --ignored
`test_scheduler``test_request_format` 不需要 API Key也没有标记 `#[ignore]`。只有会真实调用 Provider 的测试需要从 `tests/test.env.example` 创建 `tests/test.env` 后使用 `-- --ignored`
最终用户使用 WebUI 不需要单独构建;开发源码采用 Svelte 5、Vite 和 Bits UI`cargo build` 会增量生成前端到 Cargo `OUT_DIR` 并嵌入二进制生成产物不提交。WebUI 支持在线聊天、动态斜杠命令补全、日志、任务、记忆以及 `config.json``USER.md``AGENTS.md` 编辑。新设备默认必须配对,管理 API 与 WebSocket 共用设备鉴权;非回环部署仍需要 TLS。
配对码签发接口同时校验真实回环来源和 `~/.picobot/web_admin_token`。Docker 发布端口上的宿主机请求在容器内不是回环连接,因此应使用 `docker compose exec` 在 Gateway 容器中运行 `picobot pair`;不要手工读取或传递管理密钥。配对码为 8 位、5 分钟有效且只能消费一次。

View File

@ -94,16 +94,11 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
| `app_id` | string | - | 飞书应用 ID |
| `app_secret` | string | - | 飞书应用密钥 |
| `allow_from` | []string | ["*"] | 允许交互的用户列表 |
| `require_mention` | bool | true | 群聊中是否必须明确 @ 机器人;无法解析机器人身份时安全地忽略群消息 |
| `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 |
| `max_image_bytes` | int | 10485760 | 单个入站/出站图片的最大字节数 |
| `max_file_bytes` | int | 26214400 | 单个入站/出站文件、音频或视频的最大字节数 |
| `media_dir_max_bytes` | int | 536870912 | 飞书媒体目录容量上限;达到上限后拒绝新下载,不自动删除旧文件 |
| `request_timeout_secs` | int | 30 | 单次飞书 HTTP 请求及响应体读取的硬超时,运行时限制在 5120 秒 |
飞书属于外部渠道:无论是否开启实时卡片,都不会接收模型 reasoning工具只显示紧凑状态。配置修改需重启 Gateway 生效。

View File

@ -22,16 +22,6 @@
内置 Skill 只在目标目录不存在时释放,不会覆盖已安装目录。升级 PicoBot 后如需获取新版内置文档,应先备份自己的修改,再删除旧的 `~/.picobot/skills/about-picobot/` 并重启。也可把定制版放在 `{workspace}/skills/about-picobot/`,它的优先级更高。
## Q: Docker 部署如何获取 WebUI 设备配对码?
在 Gateway 容器内执行:
```bash
docker compose exec picobot picobot pair --gateway-url http://127.0.0.1:19876
```
使用 `docker-compose.test.yml` 时增加 `-f docker-compose.test.yml`。签发接口要求请求来自 Gateway 的真实回环地址,并校验 `/app/.picobot/web_admin_token`;因此不要从宿主机经发布端口直接请求,也不要复制或输出管理密钥。代码为 8 位、5 分钟有效且只能使用一次。
## Q: 数据库文件在哪里?
默认 `{workspace}/picobot.db`workspace 默认 `~/.picobot/workspace/`

View File

@ -68,16 +68,11 @@
"app_id": "<FEISHU_APP_ID>",
"app_secret": "<FEISHU_APP_SECRET>",
"allow_from": ["*"],
"require_mention": true,
"agent": "default",
"media_dir": "~/.picobot/media/feishu",
"reaction_emoji": "Typing",
"live_updates": false,
"live_update_interval_ms": 500,
"max_image_bytes": 10485760,
"max_file_bytes": 26214400,
"media_dir_max_bytes": 536870912,
"request_timeout_secs": 30
"live_update_interval_ms": 500
}
},
"memory": {

View File

@ -305,6 +305,7 @@ pub struct AgentLoop {
workspace_dir: PathBuf,
model_name: String,
context_window: usize,
notify_tx: Option<tokio::sync::mpsc::UnboundedSender<String>>,
input_types: Vec<String>,
media_registry: MediaHandlerRegistry,
}
@ -355,6 +356,7 @@ impl AgentLoop {
provider: Arc::from(provider),
tools: Arc::new(ToolRegistry::new()),
observer: None,
notify_tx: None,
context_window: 0,
max_iterations,
workspace_dir,
@ -380,6 +382,7 @@ impl AgentLoop {
provider: Arc::from(provider),
tools,
observer: None,
notify_tx: None,
context_window: 0,
max_iterations,
workspace_dir,
@ -401,6 +404,7 @@ impl AgentLoop {
provider,
tools: Arc::new(ToolRegistry::new()),
observer: None,
notify_tx: None,
context_window: 0,
max_iterations,
workspace_dir,
@ -423,6 +427,7 @@ impl AgentLoop {
provider,
tools,
observer: None,
notify_tx: None,
context_window: 0,
max_iterations,
workspace_dir,
@ -450,6 +455,11 @@ impl AgentLoop {
self
}
pub fn with_notify(mut self, tx: tokio::sync::mpsc::UnboundedSender<String>) -> Self {
self.notify_tx = Some(tx);
self
}
/// Preemptive trim: truncate old tool results in-place when history is
/// approaching the context window limit. Old results (outside of `keep_recent`
/// zone) are replaced with a short placeholder; recent results are truncated
@ -708,8 +718,7 @@ impl AgentLoop {
.map_err(|error| AgentError::Other(format!("turn event rejected: {error}")))?;
}
// Execute tool calls. User-visible progress is emitted through the
// structured TurnEvent stream, not a second notification channel.
// Execute tool calls — log and notify immediately
{
let tools_info: Vec<String> = response
.tool_calls
@ -717,6 +726,9 @@ impl AgentLoop {
.map(|tc| {
let args = serde_json::to_string(&tc.arguments).unwrap_or_default();
let s = format!("{}:{}", tc.name, args);
if let Some(ref tx) = self.notify_tx {
let _ = tx.send(format!("调用工具 {}", s));
}
s
})
.collect();

View File

@ -154,8 +154,6 @@ pub struct ChatMessage {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SourceKind {
#[serde(rename = "user_input")]
UserInput,
#[serde(rename = "system_notification")]
SystemNotification,
#[serde(rename = "cross_channel")]
@ -366,48 +364,18 @@ mod conversation_message_tests {
// InboundMessage - Message from Channel to Bus (user input)
// ============================================================================
/// Opaque channel-owned context that may be carried to the corresponding reply.
/// Core routing understands `reply_to`; all other platform data remains private.
#[derive(Debug, Clone, Default)]
pub struct ChannelContext {
pub reply_to: Option<String>,
pub private: HashMap<String, String>,
}
/// Public, durable projection of a newly committed conversation message.
/// Provider replay state and source identities are deliberately excluded.
#[derive(Debug, Clone)]
pub struct CommittedMessage {
pub id: String,
pub seq: i64,
pub role: String,
pub content: String,
pub reasoning_content: Option<String>,
pub completion_status: CompletionStatus,
pub media_refs: Vec<MediaRef>,
pub created_at: i64,
pub tool_call_id: Option<String>,
pub tool_name: Option<String>,
pub tool_calls: Option<Vec<ToolCall>>,
}
#[derive(Debug, Clone)]
pub struct CommittedTurnDelta {
pub session_id: String,
/// Highest durable message sequence included in this commit.
pub history_revision: i64,
pub messages: Vec<CommittedMessage>,
}
#[derive(Debug, Clone)]
pub struct InboundMessage {
pub channel: String,
pub sender_id: String,
pub chat_id: String,
pub content: String,
pub received_at: i64,
pub timestamp: i64,
pub media: Vec<MediaItem>,
pub channel_context: ChannelContext,
/// Channel-specific data used internally by the channel (not forwarded).
pub metadata: HashMap<String, String>,
/// Data forwarded from inbound to outbound (copied to OutboundMessage.metadata by gateway).
pub forwarded_metadata: HashMap<String, String>,
}
// ============================================================================

View File

@ -3,9 +3,8 @@ pub mod message;
pub use dispatcher::OutboundDispatcher;
pub use message::{
ChannelContext, ChatMessage, CommittedMessage, CommittedTurnDelta, CompletionStatus,
ContentBlock, ControlMessage, InboundMessage, MediaItem, MediaRef, MessageSource,
OutboundMessage, ProviderReasoningState, SourceKind,
ChatMessage, CompletionStatus, ContentBlock, ControlMessage, InboundMessage, MediaItem,
MediaRef, MessageSource, OutboundMessage, ProviderReasoningState, SourceKind,
};
use std::sync::Arc;

View File

@ -3,7 +3,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use crate::bus::{BusError, CommittedTurnDelta, InboundMessage, MessageBus, OutboundMessage};
use crate::bus::{BusError, InboundMessage, MessageBus, OutboundMessage};
use crate::delivery::PresentationPolicy;
use crate::session::TurnSnapshot;
@ -90,16 +90,6 @@ pub trait Channel: Send + Sync + 'static {
)))
}
/// Deliver a durable history delta after a Turn commit. Channels without
/// local history views intentionally ignore this event.
async fn commit_turn(
&self,
_target: &TurnTarget,
_delta: CommittedTurnDelta,
) -> Result<(), ChannelError> {
Ok(())
}
/// Send a message to the channel (called by OutboundDispatcher)
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError>;

View File

@ -4,7 +4,7 @@ use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Mutex, mpsc};
use crate::bus::{CommittedTurnDelta, ControlMessage, InboundMessage, MessageBus, OutboundMessage};
use crate::bus::{ControlMessage, InboundMessage, MessageBus, OutboundMessage};
use crate::gateway::uploads::UploadRegistry;
use crate::protocol::{
HistoryMessage, MessageAttachment, SlashCommandInfo, WsInbound, WsOutbound, parse_inbound,
@ -211,9 +211,10 @@ impl CliChatChannel {
sender_id: "cli".to_string(),
chat_id: target_chat_id,
content,
received_at: crate::bus::message::current_timestamp(),
timestamp: crate::bus::message::current_timestamp(),
media,
channel_context: Default::default(),
metadata: Default::default(),
forwarded_metadata: Default::default(),
};
if let Err(error) = bus.publish_inbound(msg).await {
self.uploads.restore(uploads).await;
@ -433,7 +434,36 @@ impl CliChatChannel {
|| message.tool_calls.is_some()
|| message.role == "tool"
})
.map(HistoryMessage::from_message_meta)
.map(|message| {
let attachments = message
.media_refs
.as_deref()
.and_then(|refs| {
serde_json::from_str::<Vec<crate::bus::MediaRef>>(refs).ok()
})
.unwrap_or_default()
.iter()
.enumerate()
.map(|(index, media_ref)| {
MessageAttachment::from_media_ref(index, media_ref)
})
.collect();
HistoryMessage {
id: message.id,
seq: message.seq,
role: message.role,
content: message.content,
reasoning_content: message.reasoning_content,
completion_status: message.completion_status,
created_at: message.created_at,
tool_call_id: message.tool_call_id,
tool_name: message.tool_name,
tool_calls: message
.tool_calls
.and_then(|calls| serde_json::from_str(&calls).ok()),
attachments,
}
})
.collect();
let _ = client
.sender
@ -863,29 +893,6 @@ impl Channel for CliChatChannel {
}))
}
async fn commit_turn(
&self,
target: &TurnTarget,
delta: CommittedTurnDelta,
) -> Result<(), ChannelError> {
let client = self.clients.lock().await.get(&target.chat_id).cloned();
let Some(client) = client else {
return Ok(());
};
let frame = WsOutbound::TurnCommitted {
session_id: delta.session_id,
history_revision: delta.history_revision,
messages: delta
.messages
.into_iter()
.map(HistoryMessage::from)
.collect(),
};
client.sender.send(frame).await.map_err(|_| {
ChannelError::ConnectionError("CLI client disconnected during turn commit".to_string())
})
}
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
let client = self.clients.lock().await.get(&msg.chat_id).cloned();
let Some(client) = client else {
@ -1137,59 +1144,4 @@ mod tests {
other => panic!("unexpected outbound: {other:?}"),
}
}
#[tokio::test]
async fn committed_turn_is_projected_as_incremental_history_frame() {
let channel = CliChatChannel::new();
let (sender, mut receiver) = mpsc::channel(1);
let client = Arc::new(Client {
sender,
chat_id: "client".into(),
current_session_id: Mutex::new(None),
});
channel.clients.lock().await.insert("client".into(), client);
let target = TurnTarget {
channel: "cli_chat".into(),
chat_id: "client".into(),
session_id: "cli_chat:client:dialog".into(),
reply_to: None,
metadata: HashMap::new(),
};
channel
.commit_turn(
&target,
CommittedTurnDelta {
session_id: target.session_id.clone(),
history_revision: 4,
messages: vec![crate::bus::CommittedMessage {
id: "message".into(),
seq: 4,
role: "assistant".into(),
content: "done".into(),
reasoning_content: None,
completion_status: crate::bus::CompletionStatus::Completed,
media_refs: Vec::new(),
created_at: 1,
tool_call_id: None,
tool_name: None,
tool_calls: None,
}],
},
)
.await
.unwrap();
match receiver.recv().await.unwrap() {
WsOutbound::TurnCommitted {
history_revision,
messages,
..
} => {
assert_eq!(history_revision, 4);
assert_eq!(messages[0].id, "message");
}
other => panic!("unexpected outbound: {other:?}"),
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,7 @@
pub use crate::protocol::{WsInbound, WsOutbound, serialize_inbound, serialize_outbound};
mod oneshot;
mod tui;
pub use oneshot::{RunOptions, read_run_prompt, run_once};
use crate::client::tui::app::{App, MessageRole};
use crate::client::tui::event::{
handle_key_event, handle_paste, request_history, request_session_list, send,
@ -258,7 +255,6 @@ async fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
match outbound {
WsOutbound::TurnUpdated { snapshot } => {
let terminal = snapshot.status != crate::session::TurnStatus::Running;
let completed = snapshot.status == crate::session::TurnStatus::Completed;
let session_id = snapshot.session_id.clone();
if terminal {
app.pending_responses = app.pending_responses.saturating_sub(1);
@ -267,9 +263,7 @@ async fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
if terminal {
app.status_message = None;
if app.current_session_id.as_deref() == Some(&session_id) {
if !completed {
request_history(app, session_id).await;
}
request_history(app, session_id).await;
} else {
app.status_message = Some("另一个会话已完成响应".to_string());
}
@ -281,13 +275,6 @@ async fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
app.status_message = Some("另一个会话已完成响应".to_string());
}
}
WsOutbound::TurnCommitted {
session_id,
history_revision,
messages,
} => {
app.apply_turn_commit(&session_id, history_revision, messages);
}
WsOutbound::AssistantResponse {
id,
content,

View File

@ -1,428 +0,0 @@
use super::{WsInbound, WsOutbound, load_auth_token};
use crate::config::get_user_config_dir;
use crate::gateway::auth::ADMIN_TOKEN_HEADER;
use crate::session::{ToolStatus, TurnBlock, TurnPhase, TurnSnapshot, TurnStatus};
use futures_util::{SinkExt, StreamExt};
use serde::Serialize;
use std::collections::HashMap;
use std::io::{self, IsTerminal, Read, Write};
use std::net::IpAddr;
use std::time::Duration;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::{
Message,
client::IntoClientRequest,
http::{HeaderValue, header},
};
const MAX_RUN_PROMPT_BYTES: usize = 1024 * 1024;
type DynError = Box<dyn std::error::Error>;
#[derive(Debug, Clone, Copy)]
pub struct RunOptions {
pub timeout: Duration,
pub json: bool,
pub verbose: bool,
}
#[derive(Debug, Serialize)]
struct RunOutput {
session_id: String,
turn_id: String,
status: TurnStatus,
content: String,
usage: Option<crate::providers::Usage>,
error: Option<String>,
}
pub fn read_run_prompt(parts: Vec<String>) -> Result<String, DynError> {
if !parts.is_empty() {
return validate_prompt(parts.join(" "));
}
if io::stdin().is_terminal() {
return Err("provide a prompt as arguments or pipe it on stdin".into());
}
let stdin = io::stdin();
let mut locked = stdin.lock();
read_prompt_from(&mut locked)
}
pub async fn run_once(
gateway_url: &str,
prompt: String,
options: RunOptions,
) -> Result<(), Box<dyn std::error::Error>> {
if options.timeout.is_zero() {
return Err("run timeout must be greater than zero".into());
}
let prompt = validate_prompt(prompt)?;
let client_id = format!("run-{}", uuid::Uuid::new_v4().simple());
let (connect_url, local_gateway) = websocket_url(gateway_url, &client_id)?;
let admin_token = local_gateway
.then(|| std::fs::read_to_string(get_user_config_dir().join("web_admin_token")).ok())
.flatten()
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty());
let bearer_token = admin_token.is_none().then(load_auth_token).flatten();
let mut request = connect_url.into_client_request()?;
if let Some(token) = &admin_token {
let mut value = HeaderValue::from_str(token)?;
value.set_sensitive(true);
request.headers_mut().insert(ADMIN_TOKEN_HEADER, value);
} else if let Some(token) = &bearer_token {
let mut value = HeaderValue::from_str(&format!("Bearer {token}"))?;
value.set_sensitive(true);
request.headers_mut().insert(header::AUTHORIZATION, value);
}
let (stream, _) = connect_async(request).await.map_err(|error| {
if local_gateway && admin_token.is_none() {
format!(
"gateway connection failed: {error}; local admin token is unavailable at {}",
get_user_config_dir().join("web_admin_token").display()
)
} else {
format!(
"gateway connection failed: {error}. Remote gateways require an existing paired CLI token"
)
}
})?;
let (mut sender, mut receiver) = stream.split();
let operation = async {
let session_id = loop {
match receiver.next().await {
Some(Ok(Message::Text(text))) => match serde_json::from_str::<WsOutbound>(&text)? {
WsOutbound::SessionEstablished { session_id, .. } => break session_id,
WsOutbound::Error { code, message } => {
return Err::<RunOutput, DynError>(
format!("gateway error {code}: {message}").into(),
);
}
_ => {}
},
Some(Ok(Message::Close(_))) | None => {
return Err("gateway closed before establishing a session".into());
}
Some(Err(error)) => return Err(error.into()),
_ => {}
}
};
let input = WsInbound::UserInput {
content: prompt,
upload_ids: Vec::new(),
channel: None,
chat_id: None,
sender_id: None,
};
sender
.send(Message::Text(serde_json::to_string(&input)?.into()))
.await?;
let mut turn_id = None;
let mut last_phase = None;
let mut tool_states: HashMap<String, (String, ToolStatus)> = HashMap::new();
loop {
match receiver.next().await {
Some(Ok(Message::Text(text))) => match serde_json::from_str::<WsOutbound>(&text)? {
WsOutbound::TurnUpdated { snapshot }
if snapshot.session_id == session_id
&& turn_id.as_ref().is_none_or(|id| id == &snapshot.id.0) =>
{
turn_id.get_or_insert_with(|| snapshot.id.0.clone());
if options.verbose {
report_progress(&snapshot, &mut last_phase, &mut tool_states);
}
if snapshot.status != TurnStatus::Running {
break Ok(output_from_snapshot(snapshot));
}
}
WsOutbound::Error { code, message } => {
break Err(format!("gateway error {code}: {message}").into());
}
_ => {}
},
Some(Ok(Message::Close(_))) | None => {
break Err("gateway closed before the run completed".into());
}
Some(Err(error)) => break Err(error.into()),
_ => {}
}
}
};
let output = tokio::select! {
result = tokio::time::timeout(options.timeout, operation) => {
match result {
Ok(result) => result?,
Err(_) => {
send_stop(&mut sender).await;
return Err(format!("run timed out after {} seconds", options.timeout.as_secs()).into());
}
}
}
signal = tokio::signal::ctrl_c() => {
send_stop(&mut sender).await;
signal?;
return Err("run cancelled".into());
}
};
render_output(&output, options.json)?;
match output.status {
TurnStatus::Completed => Ok(()),
TurnStatus::Cancelled => Err(output
.error
.unwrap_or_else(|| "run cancelled".to_string())
.into()),
TurnStatus::Failed => Err(output
.error
.unwrap_or_else(|| "run failed".to_string())
.into()),
TurnStatus::Running => Err("gateway returned a non-terminal run result".into()),
}
}
async fn send_stop<S>(sender: &mut S)
where
S: futures_util::Sink<Message> + Unpin,
{
let stop = WsInbound::UserInput {
content: "/stop".to_string(),
upload_ids: Vec::new(),
channel: None,
chat_id: None,
sender_id: None,
};
if let Ok(text) = serde_json::to_string(&stop) {
let _ = sender.send(Message::Text(text.into())).await;
let _ = sender.flush().await;
}
}
fn websocket_url(
gateway_url: &str,
client_id: &str,
) -> Result<(String, bool), Box<dyn std::error::Error>> {
let mut url = reqwest::Url::parse(gateway_url)?;
let scheme = match url.scheme() {
"ws" => "ws",
"wss" => "wss",
"http" => "ws",
"https" => "wss",
other => return Err(format!("unsupported gateway URL scheme: {other}").into()),
};
url.set_scheme(scheme)
.map_err(|_| "failed to set gateway URL scheme")?;
if url.path().is_empty() || url.path() == "/" {
url.set_path("/ws");
}
url.query_pairs_mut().append_pair("client_id", client_id);
let local_gateway = url.host_str().is_some_and(|host| {
let host = host
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
.unwrap_or(host);
host.eq_ignore_ascii_case("localhost")
|| host
.parse::<IpAddr>()
.is_ok_and(|address| address.is_loopback())
});
Ok((url.to_string(), local_gateway))
}
fn read_prompt_from(reader: &mut impl Read) -> Result<String, Box<dyn std::error::Error>> {
let mut bytes = Vec::new();
reader
.take((MAX_RUN_PROMPT_BYTES + 1) as u64)
.read_to_end(&mut bytes)?;
if bytes.len() > MAX_RUN_PROMPT_BYTES {
return Err(format!("prompt exceeds {MAX_RUN_PROMPT_BYTES} bytes").into());
}
let prompt = String::from_utf8(bytes)?;
validate_prompt(prompt.trim_end_matches(['\r', '\n']).to_string())
}
fn validate_prompt(prompt: String) -> Result<String, Box<dyn std::error::Error>> {
if prompt.len() > MAX_RUN_PROMPT_BYTES {
return Err(format!("prompt exceeds {MAX_RUN_PROMPT_BYTES} bytes").into());
}
if prompt.trim().is_empty() {
return Err("prompt is empty".into());
}
Ok(prompt)
}
fn output_from_snapshot(snapshot: TurnSnapshot) -> RunOutput {
RunOutput {
session_id: snapshot.session_id,
turn_id: snapshot.id.0,
status: snapshot.status,
content: assistant_text(&snapshot.blocks),
usage: snapshot.usage,
error: snapshot.error,
}
}
fn assistant_text(blocks: &[TurnBlock]) -> String {
blocks
.iter()
.filter_map(|block| match block {
TurnBlock::Assistant { text, .. } if !text.is_empty() => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n\n")
}
fn report_progress(
snapshot: &TurnSnapshot,
last_phase: &mut Option<TurnPhase>,
tool_states: &mut HashMap<String, (String, ToolStatus)>,
) {
if last_phase.as_ref() != Some(&snapshot.phase) {
eprintln!("[phase: {}]", phase_name(snapshot.phase));
*last_phase = Some(snapshot.phase);
}
for block in &snapshot.blocks {
let TurnBlock::Tool {
id, name, status, ..
} = block
else {
continue;
};
let current = (name.clone(), *status);
if tool_states.get(id) != Some(&current) {
eprintln!("[tool: {name}: {}]", tool_status_name(*status));
tool_states.insert(id.clone(), current);
}
}
}
fn phase_name(phase: TurnPhase) -> &'static str {
match phase {
TurnPhase::Queued => "queued",
TurnPhase::Reasoning => "reasoning",
TurnPhase::Responding => "responding",
TurnPhase::Acting => "acting",
TurnPhase::Finalizing => "finalizing",
}
}
fn tool_status_name(status: ToolStatus) -> &'static str {
match status {
ToolStatus::Running => "running",
ToolStatus::Completed => "completed",
ToolStatus::Failed => "failed",
}
}
fn render_output(output: &RunOutput, json: bool) -> Result<(), Box<dyn std::error::Error>> {
if json {
println!("{}", serde_json::to_string(output)?);
} else if output.status == TurnStatus::Completed {
print!("{}", output.content);
if !output.content.ends_with('\n') {
println!();
}
io::stdout().flush()?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{BlockId, TurnId};
#[test]
fn positional_prompt_parts_are_joined() {
assert_eq!(
validate_prompt(["hello", "world"].join(" ")).unwrap(),
"hello world"
);
}
#[test]
fn stdin_prompt_preserves_lines_and_trims_terminal_newline() {
let mut input = "first\nsecond\n".as_bytes();
assert_eq!(read_prompt_from(&mut input).unwrap(), "first\nsecond");
}
#[test]
fn empty_and_oversized_prompts_are_rejected() {
assert!(validate_prompt(" \n".to_string()).is_err());
assert!(validate_prompt("x".repeat(MAX_RUN_PROMPT_BYTES + 1)).is_err());
}
#[test]
fn assistant_blocks_are_joined_without_reasoning_or_tools() {
let blocks = vec![
TurnBlock::Reasoning {
id: BlockId("reasoning".to_string()),
iteration: 0,
text: "hidden".to_string(),
},
TurnBlock::Assistant {
id: BlockId("answer-1".to_string()),
iteration: 0,
text: "hello".to_string(),
},
TurnBlock::Tool {
id: "tool".to_string(),
iteration: 0,
name: "bash".to_string(),
arguments: serde_json::json!({}),
status: ToolStatus::Completed,
preview: None,
},
TurnBlock::Assistant {
id: BlockId("answer-2".to_string()),
iteration: 1,
text: "world".to_string(),
},
];
assert_eq!(assistant_text(&blocks), "hello\n\nworld");
}
#[test]
fn loopback_gate_uses_the_url_host() {
assert!(
websocket_url("ws://127.0.0.1:19876/ws", "run-id")
.unwrap()
.1
);
assert!(websocket_url("ws://[::1]:19876/ws", "run-id").unwrap().1);
assert!(websocket_url("http://localhost:19876", "run-id").unwrap().1);
assert!(
!websocket_url("wss://gateway.example/ws", "run-id")
.unwrap()
.1
);
}
#[test]
fn terminal_snapshot_becomes_script_output() {
let output = output_from_snapshot(TurnSnapshot {
id: TurnId("turn".to_string()),
session_id: "session".to_string(),
message_id: "message".to_string(),
revision: 1,
status: TurnStatus::Completed,
phase: TurnPhase::Finalizing,
blocks: vec![TurnBlock::Assistant {
id: BlockId("answer".to_string()),
iteration: 0,
text: "done".to_string(),
}],
usage: None,
error: None,
});
assert_eq!(output.turn_id, "turn");
assert_eq!(output.content, "done");
assert_eq!(output.status, TurnStatus::Completed);
}
}

View File

@ -66,7 +66,6 @@ pub struct App {
pub selected_session: usize,
pub show_archived: bool,
pub messages: VecDeque<ChatMessage>,
pub history_revision: i64,
pub active_turn: Option<TurnSnapshot>,
pub input: String,
/// UTF-8 byte offset. It is always maintained at a character boundary.
@ -100,7 +99,6 @@ impl App {
selected_session: 0,
show_archived: false,
messages: VecDeque::new(),
history_revision: 0,
active_turn: None,
input: String::new(),
input_cursor_pos: 0,
@ -153,11 +151,6 @@ impl App {
if self.current_session_id.as_deref() != Some(session_id) {
return;
}
let history_revision = messages
.iter()
.map(|message| message.seq)
.max()
.unwrap_or(0);
let calibrates_terminal = self.active_turn.as_ref().is_some_and(|turn| {
turn.status != TurnStatus::Running
&& messages.iter().any(|message| message.id == turn.message_id)
@ -185,65 +178,12 @@ impl App {
self.messages.pop_front();
}
self.chat_scroll_from_bottom = 0;
self.history_revision = history_revision;
self.status_message = None;
if calibrates_terminal {
self.active_turn = None;
}
}
pub fn apply_turn_commit(
&mut self,
session_id: &str,
history_revision: i64,
messages: Vec<HistoryMessage>,
) -> bool {
if self.current_session_id.as_deref() != Some(session_id)
|| history_revision <= self.history_revision
{
return false;
}
let calibrates_terminal = self.active_turn.as_ref().is_some_and(|turn| {
turn.status != TurnStatus::Running
&& messages.iter().any(|message| message.id == turn.message_id)
});
for message in messages {
let role = match message.role.as_str() {
"user" => MessageRole::User,
"assistant" => MessageRole::Assistant,
"system" | "tool" => MessageRole::System,
_ => continue,
};
let projected = ChatMessage {
id: message.id.clone(),
role,
content: message.content,
reasoning_content: message.reasoning_content,
completion_status: message.completion_status,
attachments: message.attachments,
};
if let Some(existing) = self
.messages
.iter_mut()
.find(|existing| existing.id == message.id)
{
*existing = projected;
} else {
self.messages.push_back(projected);
}
}
while self.messages.len() > MAX_MESSAGES {
self.messages.pop_front();
}
self.history_revision = history_revision;
self.chat_scroll_from_bottom = 0;
self.status_message = None;
if calibrates_terminal {
self.active_turn = None;
}
true
}
pub fn set_sessions(&mut self, sessions: Vec<SessionSummary>) {
self.sessions = sessions;
if let Some(current) = &self.current_session_id
@ -262,7 +202,6 @@ impl App {
self.current_session_id = session_id;
self.messages.clear();
self.active_turn = None;
self.history_revision = 0;
self.pending_uploads.clear();
self.chat_scroll_from_bottom = 0;
}
@ -286,12 +225,7 @@ impl App {
{
return false;
}
let already_committed = snapshot.status != TurnStatus::Running
&& self
.messages
.iter()
.any(|message| message.id == snapshot.message_id);
self.active_turn = (!already_committed).then_some(snapshot);
self.active_turn = Some(snapshot);
self.chat_scroll_from_bottom = 0;
true
}
@ -557,60 +491,6 @@ mod tests {
assert!(app.active_turn.is_none());
}
#[test]
fn committed_delta_calibrates_terminal_without_reloading_history() {
let mut app = App::new();
app.set_current_session(Some("current".into()));
app.apply_turn_snapshot(turn(3, TurnStatus::Completed));
assert!(app.apply_turn_commit(
"current",
2,
vec![HistoryMessage {
id: "message".into(),
seq: 2,
role: "assistant".into(),
content: "done".into(),
reasoning_content: None,
completion_status: crate::bus::CompletionStatus::Completed,
created_at: 1,
tool_call_id: None,
tool_name: None,
tool_calls: None,
attachments: Vec::new(),
}],
));
assert!(app.active_turn.is_none());
assert_eq!(app.messages.back().unwrap().content, "done");
assert!(!app.apply_turn_commit("current", 2, Vec::new()));
}
#[test]
fn terminal_snapshot_calibrates_when_commit_arrived_first() {
let mut app = App::new();
app.set_current_session(Some("current".into()));
assert!(app.apply_turn_commit(
"current",
2,
vec![HistoryMessage {
id: "message".into(),
seq: 2,
role: "assistant".into(),
content: "done".into(),
reasoning_content: None,
completion_status: crate::bus::CompletionStatus::Completed,
created_at: 1,
tool_call_id: None,
tool_name: None,
tool_calls: None,
attachments: Vec::new(),
}],
));
assert!(app.apply_turn_snapshot(turn(3, TurnStatus::Completed)));
assert!(app.active_turn.is_none());
}
#[test]
fn failed_turn_without_a_durable_message_remains_visible_after_history_refresh() {
let mut app = App::new();

View File

@ -71,9 +71,6 @@ pub struct FeishuChannelConfig {
pub app_secret: String,
#[serde(default = "default_allow_from")]
pub allow_from: Vec<String>,
/// Require an explicit bot @mention before accepting group-chat messages.
#[serde(default = "default_true")]
pub require_mention: bool,
#[serde(default)]
pub agent: String,
#[serde(default = "default_media_dir")]
@ -86,14 +83,6 @@ pub struct FeishuChannelConfig {
pub live_updates: bool,
#[serde(default = "default_feishu_live_update_interval_ms")]
pub live_update_interval_ms: u64,
#[serde(default = "default_feishu_max_image_bytes")]
pub max_image_bytes: u64,
#[serde(default = "default_feishu_max_file_bytes")]
pub max_file_bytes: u64,
#[serde(default = "default_feishu_media_dir_max_bytes")]
pub media_dir_max_bytes: u64,
#[serde(default = "default_feishu_request_timeout_secs")]
pub request_timeout_secs: u64,
}
fn default_allow_from() -> Vec<String> {
@ -115,22 +104,6 @@ fn default_feishu_live_update_interval_ms() -> u64 {
500
}
fn default_feishu_max_image_bytes() -> u64 {
10 * 1024 * 1024
}
fn default_feishu_max_file_bytes() -> u64 {
25 * 1024 * 1024
}
fn default_feishu_media_dir_max_bytes() -> u64 {
512 * 1024 * 1024
}
fn default_feishu_request_timeout_secs() -> u64 {
30
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProviderConfig {
#[serde(rename = "type")]

View File

@ -23,7 +23,6 @@ pub enum DeliveryError {
OpenFailed(ChannelError),
SnapshotStreamClosed,
SupervisorStopping,
CompletionLost,
FinalTimedOut,
FinalFailed(ChannelError),
}
@ -39,9 +38,6 @@ impl std::fmt::Display for DeliveryError {
Self::SupervisorStopping => {
formatter.write_str("cannot start turn delivery while Gateway is stopping")
}
Self::CompletionLost => {
formatter.write_str("turn delivery task stopped without reporting completion")
}
Self::FinalTimedOut => formatter.write_str("final turn delivery timed out"),
Self::FinalFailed(error) => write!(formatter, "final turn delivery failed: {error}"),
}

View File

@ -4,4 +4,4 @@ mod service;
pub use coordinator::{ConversationWriteLocks, DeliveryCoordinator, DeliveryError};
pub use policy::{PresentationPolicy, ReasoningVisibility, ToolVisibility, project_snapshot};
pub use service::{TurnDeliveryHandle, TurnDeliveryService};
pub use service::TurnDeliveryService;

View File

@ -1,8 +1,7 @@
use std::sync::Arc;
use tokio::sync::{oneshot, watch};
use tokio::sync::watch;
use crate::bus::CommittedTurnDelta;
use crate::channels::{ChannelManager, TurnTarget};
use crate::delivery::{DeliveryCoordinator, DeliveryError};
use crate::session::TurnSnapshot;
@ -20,23 +19,6 @@ pub struct TurnDeliveryService {
supervisor: TaskSupervisor,
}
/// Completion handle for one TurnSink lifecycle.
///
/// Creating a sink only proves that delivery started. Callers consume this
/// handle after publishing a terminal snapshot to learn whether the terminal
/// write actually reached the Channel.
pub struct TurnDeliveryHandle {
pub(crate) completion: oneshot::Receiver<Result<(), DeliveryError>>,
}
impl TurnDeliveryHandle {
pub async fn wait(self) -> Result<(), DeliveryError> {
self.completion
.await
.unwrap_or(Err(DeliveryError::CompletionLost))
}
}
impl TurnDeliveryService {
pub fn new(
coordinator: DeliveryCoordinator,
@ -54,7 +36,7 @@ impl TurnDeliveryService {
&self,
target: TurnTarget,
snapshots: watch::Receiver<Arc<TurnSnapshot>>,
) -> Result<TurnDeliveryHandle, DeliveryError> {
) -> Result<(), DeliveryError> {
let channel = self
.channels
.get_channel(&target.channel)
@ -66,7 +48,7 @@ impl TurnDeliveryService {
.open_turn(target.clone())
.await
.map_err(DeliveryError::OpenFailed)?;
let completion = self.coordinator.spawn_sink(
let _result = self.coordinator.spawn_sink(
&self.supervisor,
SinkRoute {
channel: target.channel,
@ -77,45 +59,6 @@ impl TurnDeliveryService {
snapshots,
sink,
)?;
Ok(TurnDeliveryHandle { completion })
}
pub async fn commit(
&self,
target: &TurnTarget,
delta: CommittedTurnDelta,
) -> Result<(), DeliveryError> {
let channel = self
.channels
.get_channel(&target.channel)
.await
.ok_or_else(|| DeliveryError::ChannelNotFound(target.channel.clone()))?;
channel
.commit_turn(target, delta)
.await
.map_err(DeliveryError::FinalFailed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn handle_reports_terminal_delivery_failure() {
let (sender, completion) = oneshot::channel();
sender.send(Err(DeliveryError::FinalTimedOut)).unwrap();
let result = TurnDeliveryHandle { completion }.wait().await;
assert!(matches!(result, Err(DeliveryError::FinalTimedOut)));
}
#[tokio::test]
async fn handle_reports_lost_delivery_task() {
let (sender, completion) = oneshot::channel();
drop(sender);
let result = TurnDeliveryHandle { completion }.wait().await;
assert!(matches!(result, Err(DeliveryError::CompletionLost)));
Ok(())
}
}

View File

@ -21,7 +21,6 @@ const MAX_FAILED_ATTEMPTS: u32 = 5;
const MAX_TRACKED_CLIENTS: usize = 4096;
const MAX_PAIRED_TOKENS: usize = 128;
const AUTH_COOKIE: &str = "picobot_auth";
pub const ADMIN_TOKEN_HEADER: &str = "X-Picobot-Admin-Token";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct AuthStore {
@ -66,12 +65,8 @@ pub struct AuthManager {
state: Arc<Mutex<AuthState>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthIdentity {
PairingDisabled,
Paired { token_hash: String },
LocalAdmin,
}
#[derive(Debug, Clone)]
pub struct AuthIdentity(pub Option<String>);
#[derive(Debug)]
pub enum PairError {
@ -115,7 +110,7 @@ impl AuthManager {
pub async fn authenticate(&self, token: Option<&str>) -> Option<AuthIdentity> {
if !self.required {
return Some(AuthIdentity::PairingDisabled);
return Some(AuthIdentity(None));
}
let hash = hash_token(token?);
self.state
@ -123,17 +118,17 @@ impl AuthManager {
.await
.token_hashes
.contains(&hash)
.then_some(AuthIdentity::Paired { token_hash: hash })
.then_some(AuthIdentity(Some(hash)))
}
pub async fn identity_is_active(&self, identity: &AuthIdentity) -> bool {
match identity {
AuthIdentity::PairingDisabled => !self.required,
AuthIdentity::LocalAdmin => true,
AuthIdentity::Paired { token_hash } => {
self.required && self.state.lock().await.token_hashes.contains(token_hash)
}
if !self.required {
return true;
}
let Some(hash) = identity.0.as_ref() else {
return false;
};
self.state.lock().await.token_hashes.contains(hash)
}
pub fn authenticate_admin(&self, token: Option<&str>) -> bool {
@ -215,24 +210,9 @@ pub async fn require_auth(
mut request: Request<Body>,
next: Next,
) -> Response {
let mut identity = auth
let identity = auth
.authenticate(token_from_headers(request.headers()))
.await;
if identity.is_none()
&& request.uri().path() == "/ws"
&& request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.is_some_and(|ConnectInfo(peer)| peer.ip().is_loopback())
&& auth.authenticate_admin(
request
.headers()
.get(ADMIN_TOKEN_HEADER)
.and_then(|value| value.to_str().ok()),
)
{
identity = Some(AuthIdentity::LocalAdmin);
}
let Some(identity) = identity else {
return (
StatusCode::UNAUTHORIZED,
@ -334,7 +314,7 @@ pub async fn issue_code(
headers: HeaderMap,
) -> Response {
let admin_token = headers
.get(ADMIN_TOKEN_HEADER)
.get("X-Picobot-Admin-Token")
.and_then(|value| value.to_str().ok());
if !peer.ip().is_loopback() || !state.auth.authenticate_admin(admin_token) {
return (
@ -508,7 +488,7 @@ async fn load_or_create_admin_token(path: &Path) -> Result<String, std::io::Erro
#[cfg(test)]
mod tests {
use super::*;
use axum::{Extension, Router, middleware, routing};
use axum::{Router, middleware, routing};
use tower::ServiceExt;
#[tokio::test]
@ -613,61 +593,4 @@ mod tests {
.unwrap();
assert_eq!(authorized.status(), StatusCode::OK);
}
#[tokio::test]
async fn local_admin_auth_is_limited_to_loopback_websockets() {
let dir = tempfile::tempdir().unwrap();
let manager = AuthManager::load(true, dir.path().join("auth.json"))
.await
.unwrap();
let admin_token = tokio::fs::read_to_string(dir.path().join("web_admin_token"))
.await
.unwrap();
let app = Router::new()
.route(
"/ws",
routing::get(|Extension(identity): Extension<AuthIdentity>| async move {
assert_eq!(identity, AuthIdentity::LocalAdmin);
StatusCode::OK
}),
)
.route("/protected", routing::get(|| async { StatusCode::OK }))
.route_layer(middleware::from_fn_with_state(manager, require_auth));
let mut local_ws = Request::get("/ws")
.header(ADMIN_TOKEN_HEADER, admin_token.trim())
.body(Body::empty())
.unwrap();
local_ws
.extensions_mut()
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 42000))));
assert_eq!(
app.clone().oneshot(local_ws).await.unwrap().status(),
StatusCode::OK
);
let mut remote_ws = Request::get("/ws")
.header(ADMIN_TOKEN_HEADER, admin_token.trim())
.body(Body::empty())
.unwrap();
remote_ws
.extensions_mut()
.insert(ConnectInfo(SocketAddr::from(([192, 0, 2, 10], 42000))));
assert_eq!(
app.clone().oneshot(remote_ws).await.unwrap().status(),
StatusCode::UNAUTHORIZED
);
let mut local_api = Request::get("/protected")
.header(ADMIN_TOKEN_HEADER, admin_token.trim())
.body(Body::empty())
.unwrap();
local_api
.extensions_mut()
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 42000))));
assert_eq!(
app.oneshot(local_api).await.unwrap().status(),
StatusCode::UNAUTHORIZED
);
}
}

View File

@ -1,6 +1,5 @@
pub mod auth;
pub mod http;
mod router;
pub mod uploads;
pub mod ws;
@ -9,7 +8,8 @@ use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener;
use crate::bus::{MessageBus, OutboundDispatcher};
use crate::bus::{ControlMessage, MessageBus, OutboundDispatcher};
use crate::channels::base::ChannelError;
use crate::channels::{ChannelManager, CliChatChannel};
use crate::config::{Config, ensure_workspace_dir, expand_path};
use crate::delivery::{ConversationWriteLocks, DeliveryCoordinator, TurnDeliveryService};
@ -276,7 +276,80 @@ impl GatewayState {
}
});
router::spawn_message_routers(bus.clone(), session_manager, self.task_supervisor.clone());
// Spawn unified message processor
// This handles both inbound AI messages and control messages in one loop
self.task_supervisor.spawn("message-processor", async move {
tracing::info!("Message processor started");
loop {
tokio::select! {
// Inbound: AI message flow
inbound = bus.consume_inbound() => {
let Some(inbound) = inbound else {
tracing::warn!("Message processor stopping because inbound bus closed");
break;
};
match session_manager.handle_message(
&inbound.channel,
&inbound.sender_id,
&inbound.chat_id,
&inbound.content,
inbound.media,
inbound.forwarded_metadata.clone(),
).await {
Ok(crate::session::session::HandleResult::AgentResponse(content)) => {
let outbound = crate::bus::OutboundMessage {
channel: inbound.channel.clone(),
chat_id: inbound.chat_id.clone(),
content,
reply_to: None,
media: vec![],
metadata: inbound.forwarded_metadata,
delivery: None,
};
if let Err(e) = bus.publish_outbound(outbound).await {
tracing::error!(error = %e, "Failed to publish outbound");
}
}
Ok(crate::session::session::HandleResult::CommandOutput(content)) => {
let mut metadata = inbound.forwarded_metadata;
metadata.insert("_type".to_string(), "command".to_string());
let outbound = crate::bus::OutboundMessage {
channel: inbound.channel.clone(),
chat_id: inbound.chat_id.clone(),
content,
reply_to: None,
media: vec![],
metadata,
delivery: None,
};
if let Err(e) = bus.publish_outbound(outbound).await {
tracing::error!(error = %e, "Failed to publish outbound");
}
}
Ok(crate::session::session::HandleResult::AgentProcessing) => {
// Agent is processing in background; response will be
// sent via bus directly from the spawned task.
// The select loop remains free to handle subsequent
// messages (including slash commands).
}
Err(e) => {
tracing::error!(error = %e, "Failed to handle message");
}
}
}
// Control: session management operations
msg = bus.consume_control() => {
let Some(msg) = msg else {
tracing::warn!("Message processor stopping because control bus closed");
break;
};
Self::handle_control_message(&session_manager, msg).await;
}
}
}
});
// Spawn outbound dispatcher
let dispatcher = OutboundDispatcher::new(
@ -306,6 +379,112 @@ impl GatewayState {
tracing::info!("Scheduler background task spawned");
}
}
/// Handle control messages (session management operations)
async fn handle_control_message(session_manager: &SessionManager, msg: ControlMessage) {
use crate::session::{SessionCommand::*, SessionEvent};
let reply_tx = msg.reply_tx;
let result: Result<SessionEvent, ChannelError> = match msg.op {
CreateDialog {
channel,
chat_id,
title,
} => session_manager
.create_dialog(&channel, &chat_id, title.as_deref())
.await
.map(|(session_id, title)| SessionEvent::DialogCreated { session_id, title })
.map_err(|e| ChannelError::Other(e.to_string())),
ListDialogs {
channel,
chat_id,
include_archived,
} => session_manager
.list_dialogs(&channel, &chat_id, include_archived)
.await
.map(|(dialogs, current_dialog_id)| SessionEvent::DialogList {
dialogs,
current_dialog_id,
})
.map_err(|e| ChannelError::Other(e.to_string())),
GetCurrentDialog { channel, chat_id } => session_manager
.get_current_dialog(&channel, &chat_id)
.await
.map(|session_id| SessionEvent::CurrentDialog { session_id })
.map_err(|e| ChannelError::Other(e.to_string())),
SwitchDialog {
channel,
chat_id,
dialog_id,
} => session_manager
.switch_dialog(&channel, &chat_id, &dialog_id)
.await
.map(|session_id| SessionEvent::DialogSwitched { session_id })
.map_err(|e| ChannelError::Other(e.to_string())),
GetDialogHistory { session_id, limit } => session_manager
.get_dialog_history(&session_id, limit)
.await
.map(|messages| SessionEvent::DialogHistory {
session_id,
messages,
})
.map_err(|e| ChannelError::Other(e.to_string())),
GetTaskPlan { session_id } => session_manager
.get_task_plan(&session_id)
.await
.map(|plan| SessionEvent::TaskPlan { session_id, plan })
.map_err(|e| ChannelError::Other(e.to_string())),
RenameDialog { session_id, title } => session_manager
.rename_dialog(&session_id, &title)
.await
.map(|()| SessionEvent::DialogRenamed { session_id, title })
.map_err(|e| ChannelError::Other(e.to_string())),
ArchiveDialog { session_id } => session_manager
.archive_dialog(&session_id)
.await
.map(|()| SessionEvent::DialogArchived { session_id })
.map_err(|e| ChannelError::Other(e.to_string())),
DeleteDialog { session_id } => session_manager
.delete_dialog(&session_id)
.await
.map(|()| SessionEvent::DialogDeleted { session_id })
.map_err(|e| ChannelError::Other(e.to_string())),
ClearHistory { session_id } => session_manager
.clear_dialog_history(&session_id)
.await
.map(|()| SessionEvent::HistoryCleared { session_id })
.map_err(|e| ChannelError::Other(e.to_string())),
GetSlashCommands {
channel: _,
chat_id: _,
} => {
let commands = session_manager.get_slash_commands().to_vec();
Ok(SessionEvent::SlashCommandsList { commands })
}
ExecuteSlashCommand {
command,
args,
channel,
chat_id,
current_session_id,
} => session_manager
.execute_slash_command(
&command,
args.as_deref(),
&channel,
&chat_id,
current_session_id.as_ref(),
)
.await
.map(|(new_id, msg)| SessionEvent::SlashCommandExecuted {
new_session_id: new_id,
message: msg,
})
.map_err(|e| ChannelError::Other(e.to_string())),
};
let _ = reply_tx.send(result).await;
}
}
pub async fn run(

View File

@ -1,446 +0,0 @@
use std::collections::HashMap;
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Semaphore, mpsc};
use crate::bus::{ControlMessage, InboundMessage, MessageBus, OutboundMessage};
use crate::channels::ChannelError;
use crate::channels::parse_slash_command;
use crate::session::{SessionCommand, SessionEvent, SessionManager};
use crate::task_supervisor::TaskSupervisor;
const INBOUND_LANE_CAPACITY: usize = 32;
const INBOUND_LANE_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
const CONTROL_MAX_IN_FLIGHT: usize = 64;
pub(super) fn spawn_message_routers(
bus: Arc<MessageBus>,
session_manager: Arc<SessionManager>,
supervisor: TaskSupervisor,
) {
spawn_inbound_router(bus.clone(), session_manager.clone(), supervisor.clone());
spawn_control_router(bus, session_manager, supervisor);
}
fn spawn_inbound_router(
bus: Arc<MessageBus>,
session_manager: Arc<SessionManager>,
supervisor: TaskSupervisor,
) {
let lane_supervisor = supervisor.clone();
supervisor.spawn("inbound-router", async move {
tracing::info!(lane_capacity = INBOUND_LANE_CAPACITY, "Inbound router started");
let mut lanes: HashMap<String, mpsc::Sender<InboundMessage>> = HashMap::new();
let mut messages_seen = 0_u64;
while let Some(inbound) = bus.consume_inbound().await {
messages_seen = messages_seen.wrapping_add(1);
if messages_seen.is_multiple_of(128) {
lanes.retain(|_, sender| !sender.is_closed());
}
// Stop must be able to invalidate a running worker even when an
// earlier slow slash command occupies this conversation's lane.
if is_priority_stop(&inbound.content) {
let request_bus = bus.clone();
let request_manager = session_manager.clone();
let task_name = format!("inbound-stop:{}:{}", inbound.channel, inbound.chat_id);
if !lane_supervisor.spawn(task_name, async move {
process_inbound(request_bus, request_manager, inbound).await;
}) {
break;
}
continue;
}
let key = conversation_key(&inbound.channel, &inbound.chat_id);
let mut sender = lanes.get(&key).cloned();
if sender.as_ref().is_none_or(mpsc::Sender::is_closed) {
let (new_sender, receiver) = mpsc::channel(INBOUND_LANE_CAPACITY);
if !spawn_inbound_lane(
&lane_supervisor,
bus.clone(),
session_manager.clone(),
inbound.channel.clone(),
inbound.chat_id.clone(),
receiver,
) {
tracing::warn!("Inbound router is stopping");
break;
}
lanes.insert(key.clone(), new_sender.clone());
sender = Some(new_sender);
}
let Some(sender) = sender else {
tracing::error!("Inbound lane creation did not produce a sender");
continue;
};
match sender.try_send(inbound) {
Ok(()) => {}
Err(mpsc::error::TrySendError::Full(inbound)) => {
tracing::warn!(channel = %inbound.channel, chat_id = %inbound.chat_id, "Inbound conversation lane is full");
publish_command_output(
&bus,
inbound,
"当前对话入口队列已满,请稍后重试。".to_string(),
)
.await;
}
Err(mpsc::error::TrySendError::Closed(inbound)) => {
// The lane may have exited on its idle boundary between the
// closed check and try_send. Recreate it once without
// dropping this input.
let (new_sender, receiver) = mpsc::channel(INBOUND_LANE_CAPACITY);
if !spawn_inbound_lane(
&lane_supervisor,
bus.clone(),
session_manager.clone(),
inbound.channel.clone(),
inbound.chat_id.clone(),
receiver,
) {
break;
}
lanes.insert(key, new_sender.clone());
if let Err(error) = new_sender.try_send(inbound) {
tracing::error!(error = %error, "Failed to enqueue input into replacement lane");
}
}
}
}
tracing::warn!("Inbound router stopped because inbound bus closed");
});
}
fn spawn_inbound_lane(
supervisor: &TaskSupervisor,
bus: Arc<MessageBus>,
session_manager: Arc<SessionManager>,
channel: String,
chat_id: String,
receiver: mpsc::Receiver<InboundMessage>,
) -> bool {
supervisor.spawn(format!("inbound-lane:{channel}:{chat_id}"), async move {
run_ordered_lane(receiver, INBOUND_LANE_IDLE_TIMEOUT, move |inbound| {
process_inbound(bus.clone(), session_manager.clone(), inbound)
})
.await;
})
}
async fn run_ordered_lane<T, F, Fut>(
mut receiver: mpsc::Receiver<T>,
idle_timeout: Duration,
mut handler: F,
) where
T: Send + 'static,
F: FnMut(T) -> Fut,
Fut: Future<Output = ()>,
{
loop {
let item = match tokio::time::timeout(idle_timeout, receiver.recv()).await {
Ok(Some(item)) => item,
Ok(None) | Err(_) => break,
};
handler(item).await;
}
}
async fn process_inbound(
bus: Arc<MessageBus>,
session_manager: Arc<SessionManager>,
inbound: InboundMessage,
) {
let result = session_manager.handle_message(&inbound).await;
match result {
Ok(crate::session::session::HandleResult::AgentResponse(content)) => {
publish_assistant_output(&bus, inbound, content).await;
}
Ok(crate::session::session::HandleResult::CommandOutput(content)) => {
publish_command_output(&bus, inbound, content).await;
}
Ok(crate::session::session::HandleResult::AgentProcessing) => {}
Err(error) => {
tracing::error!(channel = %inbound.channel, chat_id = %inbound.chat_id, error = %error, "Failed to handle inbound message");
publish_command_output(&bus, inbound, "消息处理失败,请稍后重试。".to_string()).await;
}
}
}
async fn publish_assistant_output(bus: &MessageBus, inbound: InboundMessage, content: String) {
publish_output(bus, inbound, content, false).await;
}
async fn publish_command_output(bus: &MessageBus, inbound: InboundMessage, content: String) {
publish_output(bus, inbound, content, true).await;
}
async fn publish_output(bus: &MessageBus, inbound: InboundMessage, content: String, command: bool) {
let mut metadata = inbound.channel_context.private;
if command {
metadata.insert("_type".to_string(), "command".to_string());
}
let outbound = OutboundMessage {
channel: inbound.channel,
chat_id: inbound.chat_id,
content,
reply_to: inbound.channel_context.reply_to,
media: vec![],
metadata,
delivery: None,
};
if let Err(error) = bus.publish_outbound(outbound).await {
tracing::error!(error = %error, "Failed to publish routed outbound message");
}
}
fn spawn_control_router(
bus: Arc<MessageBus>,
session_manager: Arc<SessionManager>,
supervisor: TaskSupervisor,
) {
let request_supervisor = supervisor.clone();
supervisor.spawn("control-router", async move {
tracing::info!(
max_in_flight = CONTROL_MAX_IN_FLIGHT,
"Control router started"
);
let permits = Arc::new(Semaphore::new(CONTROL_MAX_IN_FLIGHT));
loop {
let permit = match permits.clone().acquire_owned().await {
Ok(permit) => permit,
Err(_) => break,
};
let Some(message) = bus.consume_control().await else {
break;
};
let manager = session_manager.clone();
if !request_supervisor.spawn("control-request", async move {
let _permit = permit;
handle_control_message(&manager, message).await;
}) {
break;
}
}
tracing::warn!("Control router stopped because control bus closed");
});
}
async fn handle_control_message(session_manager: &SessionManager, message: ControlMessage) {
use SessionCommand::*;
let reply_tx = message.reply_tx;
let result: Result<SessionEvent, ChannelError> = match message.op {
CreateDialog {
channel,
chat_id,
title,
} => session_manager
.create_dialog(&channel, &chat_id, title.as_deref())
.await
.map(|(session_id, title)| SessionEvent::DialogCreated { session_id, title })
.map_err(|error| ChannelError::Other(error.to_string())),
ListDialogs {
channel,
chat_id,
include_archived,
} => session_manager
.list_dialogs(&channel, &chat_id, include_archived)
.await
.map(|(dialogs, current_dialog_id)| SessionEvent::DialogList {
dialogs,
current_dialog_id,
})
.map_err(|error| ChannelError::Other(error.to_string())),
GetCurrentDialog { channel, chat_id } => session_manager
.get_current_dialog(&channel, &chat_id)
.await
.map(|session_id| SessionEvent::CurrentDialog { session_id })
.map_err(|error| ChannelError::Other(error.to_string())),
SwitchDialog {
channel,
chat_id,
dialog_id,
} => session_manager
.switch_dialog(&channel, &chat_id, &dialog_id)
.await
.map(|session_id| SessionEvent::DialogSwitched { session_id })
.map_err(|error| ChannelError::Other(error.to_string())),
GetDialogHistory { session_id, limit } => session_manager
.get_dialog_history(&session_id, limit)
.await
.map(|messages| SessionEvent::DialogHistory {
session_id,
messages,
})
.map_err(|error| ChannelError::Other(error.to_string())),
GetTaskPlan { session_id } => session_manager
.get_task_plan(&session_id)
.await
.map(|plan| SessionEvent::TaskPlan { session_id, plan })
.map_err(|error| ChannelError::Other(error.to_string())),
RenameDialog { session_id, title } => session_manager
.rename_dialog(&session_id, &title)
.await
.map(|()| SessionEvent::DialogRenamed { session_id, title })
.map_err(|error| ChannelError::Other(error.to_string())),
ArchiveDialog { session_id } => session_manager
.archive_dialog(&session_id)
.await
.map(|()| SessionEvent::DialogArchived { session_id })
.map_err(|error| ChannelError::Other(error.to_string())),
DeleteDialog { session_id } => session_manager
.delete_dialog(&session_id)
.await
.map(|()| SessionEvent::DialogDeleted { session_id })
.map_err(|error| ChannelError::Other(error.to_string())),
ClearHistory { session_id } => session_manager
.clear_dialog_history(&session_id)
.await
.map(|()| SessionEvent::HistoryCleared { session_id })
.map_err(|error| ChannelError::Other(error.to_string())),
GetSlashCommands { .. } => Ok(SessionEvent::SlashCommandsList {
commands: session_manager.get_slash_commands().to_vec(),
}),
ExecuteSlashCommand {
command,
args,
channel,
chat_id,
current_session_id,
} => session_manager
.execute_slash_command(
&command,
args.as_deref(),
&channel,
&chat_id,
current_session_id.as_ref(),
)
.await
.map(
|(new_session_id, message)| SessionEvent::SlashCommandExecuted {
new_session_id,
message,
},
)
.map_err(|error| ChannelError::Other(error.to_string())),
};
let _ = reply_tx.send(result).await;
}
fn conversation_key(channel: &str, chat_id: &str) -> String {
format!("{channel}\0{chat_id}")
}
fn is_priority_stop(content: &str) -> bool {
parse_slash_command(content).is_some_and(|(command, _)| command == "stop")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bus::ChannelContext;
use std::collections::HashSet;
use tokio::sync::Notify;
#[test]
fn only_stop_bypasses_a_conversation_lane() {
assert!(is_priority_stop("/stop"));
assert!(is_priority_stop(" /stop "));
assert!(!is_priority_stop("/compact"));
assert!(!is_priority_stop("normal message"));
}
#[test]
fn conversation_keys_do_not_alias() {
let keys = HashSet::from([
conversation_key("a", "bc"),
conversation_key("ab", "c"),
conversation_key("a", "bd"),
]);
assert_eq!(keys.len(), 3);
}
#[tokio::test]
async fn routed_output_preserves_reply_target_and_private_context() {
let bus = MessageBus::new(2);
let inbound = InboundMessage {
channel: "test".to_string(),
sender_id: "user".to_string(),
chat_id: "chat".to_string(),
content: "hello".to_string(),
received_at: 123,
media: vec![],
channel_context: ChannelContext {
reply_to: Some("parent".to_string()),
private: HashMap::from([("opaque".to_string(), "value".to_string())]),
},
};
publish_command_output(&bus, inbound, "done".to_string()).await;
let output = bus.consume_outbound().await.unwrap();
assert_eq!(output.reply_to.as_deref(), Some("parent"));
assert_eq!(
output.metadata.get("opaque").map(String::as_str),
Some("value")
);
assert_eq!(
output.metadata.get("_type").map(String::as_str),
Some("command")
);
}
#[tokio::test]
async fn slow_conversation_lane_does_not_block_another_lane() {
let (slow_tx, slow_rx) = mpsc::channel(2);
let (fast_tx, fast_rx) = mpsc::channel(2);
let slow_started = Arc::new(Notify::new());
let release_slow = Arc::new(Notify::new());
let fast_finished = Arc::new(Notify::new());
let slow_task = tokio::spawn({
let slow_started = slow_started.clone();
let release_slow = release_slow.clone();
async move {
run_ordered_lane(slow_rx, Duration::from_secs(1), move |_| {
let slow_started = slow_started.clone();
let release_slow = release_slow.clone();
async move {
slow_started.notify_one();
release_slow.notified().await;
}
})
.await;
}
});
let fast_task = tokio::spawn({
let fast_finished = fast_finished.clone();
async move {
run_ordered_lane(fast_rx, Duration::from_secs(1), move |_| {
let fast_finished = fast_finished.clone();
async move { fast_finished.notify_one() }
})
.await;
}
});
slow_tx.send("slow").await.unwrap();
slow_started.notified().await;
fast_tx.send("fast").await.unwrap();
tokio::time::timeout(Duration::from_millis(100), fast_finished.notified())
.await
.expect("fast lane was blocked by unrelated slow lane");
release_slow.notify_one();
drop(slow_tx);
drop(fast_tx);
slow_task.await.unwrap();
fast_task.await.unwrap();
}
}

View File

@ -19,7 +19,7 @@ enum ServiceCommand {
#[derive(Parser)]
#[command(name = "picobot")]
#[command(about = "A CLI chatbot", long_about = None)]
#[command(version)]
#[command(version = "1.1.1")]
enum Command {
/// Connect to gateway
Chat {
@ -30,23 +30,6 @@ enum Command {
#[arg(long)]
pair_code: Option<String>,
},
/// Send one prompt through the gateway, print the final response, and exit
Run {
/// Prompt text; when omitted, read it from stdin
prompt: Vec<String>,
/// Gateway WebSocket or HTTP URL
#[arg(long)]
gateway_url: Option<String>,
/// Maximum time to wait for the turn, in seconds
#[arg(long, default_value_t = 300)]
timeout: u64,
/// Print the terminal turn as one JSON object
#[arg(long)]
json: bool,
/// Print phase and tool progress to stderr
#[arg(long)]
verbose: bool,
},
/// Start gateway server
Gateway {
/// Host to bind to
@ -94,32 +77,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.unwrap_or_else(|| "ws://127.0.0.1:19876/ws".to_string());
picobot::client::run(&url, pair_code.as_deref()).await?;
}
Command::Run {
prompt,
gateway_url,
timeout,
json,
verbose,
} => {
if timeout == 0 {
return Err("--timeout must be greater than zero".into());
}
let config = picobot::config::Config::load_default().ok();
let url = gateway_url
.or_else(|| config.as_ref().map(|c| c.client.gateway_url.clone()))
.unwrap_or_else(|| "ws://127.0.0.1:19876/ws".to_string());
let prompt = picobot::client::read_run_prompt(prompt)?;
picobot::client::run_once(
&url,
prompt,
picobot::client::RunOptions {
timeout: std::time::Duration::from_secs(timeout),
json,
verbose,
},
)
.await?;
}
Command::Gateway { host, port } => {
picobot::gateway::run(host, port).await?;
}
@ -144,10 +101,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
})?;
let response = reqwest::Client::new()
.post(endpoint)
.header(
picobot::gateway::auth::ADMIN_TOKEN_HEADER,
admin_token.trim(),
)
.header("X-Picobot-Admin-Token", admin_token.trim())
.send()
.await?;
let status = response.status();

View File

@ -77,59 +77,6 @@ pub struct HistoryMessage {
pub attachments: Vec<MessageAttachment>,
}
impl From<crate::bus::CommittedMessage> for HistoryMessage {
fn from(message: crate::bus::CommittedMessage) -> Self {
let attachments = message
.media_refs
.iter()
.enumerate()
.map(|(index, media_ref)| MessageAttachment::from_media_ref(index, media_ref))
.collect();
Self {
id: message.id,
seq: message.seq,
role: message.role,
content: message.content,
reasoning_content: message.reasoning_content,
completion_status: message.completion_status,
created_at: message.created_at,
tool_call_id: message.tool_call_id,
tool_name: message.tool_name,
tool_calls: message.tool_calls,
attachments,
}
}
}
impl HistoryMessage {
pub fn from_message_meta(message: crate::storage::message::MessageMeta) -> Self {
let attachments = message
.media_refs
.as_deref()
.and_then(|refs| serde_json::from_str::<Vec<crate::bus::MediaRef>>(refs).ok())
.unwrap_or_default()
.iter()
.enumerate()
.map(|(index, media_ref)| MessageAttachment::from_media_ref(index, media_ref))
.collect();
Self {
id: message.id,
seq: message.seq,
role: message.role,
content: message.content,
reasoning_content: message.reasoning_content,
completion_status: message.completion_status,
created_at: message.created_at,
tool_call_id: message.tool_call_id,
tool_name: message.tool_name,
tool_calls: message
.tool_calls
.and_then(|calls| serde_json::from_str(&calls).ok()),
attachments,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum WsInbound {
@ -201,12 +148,6 @@ pub enum WsOutbound {
TurnUpdated {
snapshot: crate::session::TurnSnapshot,
},
#[serde(rename = "turn_committed")]
TurnCommitted {
session_id: String,
history_revision: i64,
messages: Vec<HistoryMessage>,
},
#[serde(rename = "assistant_response")]
AssistantResponse {
id: String,
@ -318,32 +259,6 @@ mod tests {
assert_eq!(value["snapshot"]["status"], "running");
}
#[test]
fn turn_committed_serializes_revision_and_durable_delta() {
let frame = WsOutbound::TurnCommitted {
session_id: "session".to_string(),
history_revision: 7,
messages: vec![HistoryMessage {
id: "message".to_string(),
seq: 7,
role: "assistant".to_string(),
content: "done".to_string(),
reasoning_content: None,
completion_status: crate::bus::CompletionStatus::Completed,
created_at: 1,
tool_call_id: None,
tool_name: None,
tool_calls: None,
attachments: Vec::new(),
}],
};
let value = serde_json::to_value(frame).unwrap();
assert_eq!(value["type"], "turn_committed");
assert_eq!(value["history_revision"], 7);
assert_eq!(value["messages"][0]["id"], "message");
}
#[test]
fn history_defaults_new_reasoning_fields_for_old_frames() {
let message: HistoryMessage = serde_json::from_value(serde_json::json!({

View File

@ -3,7 +3,6 @@ pub mod error;
pub mod events;
mod messenger;
mod persistence;
mod turn_input;
// The public `session::session` path is retained for API compatibility.
#[allow(clippy::module_inception)]
pub mod session;

View File

@ -44,40 +44,26 @@ pub(super) async fn append_persisted_messages(
session: &Arc<Mutex<Session>>,
messages: Vec<ChatMessage>,
) -> Result<(), StorageError> {
append_persisted_messages_with_meta(session, messages)
.await
.map(|_| ())
}
pub(super) async fn append_persisted_messages_with_meta(
session: &Arc<Mutex<Session>>,
messages: Vec<ChatMessage>,
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
if messages.is_empty() {
return Ok(Vec::new());
return Ok(());
}
let persistence_lock = { session.lock().await.persistence_lock.clone() };
let _persistence_guard = persistence_lock.lock().await;
let message_ids: Vec<_> = messages.iter().map(|message| message.id.clone()).collect();
let snapshots: Vec<Option<MessagePersistSnapshot>> = {
let snapshots = {
let mut guard = session.lock().await;
messages
.into_iter()
.map(|message| guard.add_message_in_memory(message, true))
.collect()
};
let committed = snapshots
.iter()
.flatten()
.map(|(_, _, message, _)| message.clone())
.collect();
if let Err(error) = persist_added_messages(snapshots).await {
session.lock().await.rollback_message_suffix(&message_ids);
return Err(error);
}
Ok(committed)
Ok(())
}
/// Publish `Completed` only after the supplied durable write succeeds.

View File

@ -3,14 +3,10 @@ use std::sync::Arc;
use tokio::sync::{Mutex, mpsc, oneshot};
use super::persistence::{
append_persisted_messages, append_persisted_messages_with_meta, finalize_turn_after_persistence,
};
use super::persistence::{append_persisted_messages, finalize_turn_after_persistence};
use super::turn::{TurnBlock, TurnController, TurnSnapshot};
use super::turn_input::prepare_turn_input;
use crate::bus::{
ChannelContext, ChatMessage, CompletionStatus, InboundMessage, MediaItem, MediaRef,
MessageSource, OutboundMessage, SourceKind,
ChatMessage, CompletionStatus, MediaItem, MediaRef, MessageSource, OutboundMessage, SourceKind,
};
use crate::mcp::get_mcp_status;
use crate::storage::{Storage, StorageError};
@ -31,46 +27,13 @@ fn outbound_session_metadata(session_id: &str) -> HashMap<String, String> {
fn outbound_turn_metadata(
session_id: &str,
private_context: &HashMap<String, String>,
forwarded: &HashMap<String, String>,
) -> HashMap<String, String> {
let mut metadata = private_context.clone();
let mut metadata = forwarded.clone();
metadata.insert("_session_id".to_string(), session_id.to_string());
metadata
}
fn committed_turn_delta(
session_id: &str,
messages: Vec<crate::storage::message::MessageMeta>,
) -> crate::bus::CommittedTurnDelta {
let history_revision = messages.last().map_or(0, |message| message.seq);
let messages = messages
.into_iter()
.map(|message| crate::bus::CommittedMessage {
id: message.id,
seq: message.seq,
role: message.role,
content: message.content,
reasoning_content: message.reasoning_content,
completion_status: message.completion_status,
media_refs: message
.media_refs
.and_then(|refs| serde_json::from_str(&refs).ok())
.unwrap_or_default(),
created_at: message.created_at,
tool_call_id: message.tool_call_id,
tool_name: message.tool_name,
tool_calls: message
.tool_calls
.and_then(|calls| serde_json::from_str(&calls).ok()),
})
.collect();
crate::bus::CommittedTurnDelta {
session_id: session_id.to_string(),
history_revision,
messages,
}
}
tokio::task_local! {
pub(super) static CURRENT_SOURCE_SESSION: Option<String>;
}
@ -85,12 +48,12 @@ pub enum HandleResult {
AgentProcessing,
}
use crate::agent::context_compressor::ContextCompressionConfig;
use crate::agent::system_prompt::build_system_prompt;
use crate::agent::system_prompt::{build_runtime_context, build_system_prompt};
use crate::agent::{AgentError, AgentLoop, AgentTurnContext, ContextCompressor, TurnEmitter};
use crate::channels::slash_command::parse_slash_command;
use crate::config::BrowserConfig;
use crate::config::LLMProviderConfig;
use crate::delivery::{TurnDeliveryHandle, TurnDeliveryService};
use crate::delivery::TurnDeliveryService;
/// Check if an LLM error message indicates a context window overflow.
fn is_context_overflow_error(msg: &str) -> bool {
@ -139,43 +102,6 @@ fn partial_assistant_message(
Some(message)
}
fn terminal_fallback_content(snapshot: &TurnSnapshot) -> Option<String> {
partial_assistant_message(snapshot, CompletionStatus::Interrupted)
.map(|message| message.content)
.or_else(|| {
(snapshot.status == super::turn::TurnStatus::Failed)
.then(|| "The response could not be delivered. Please try again.".to_string())
})
}
async fn deliver_terminal_fallback(
handle: TurnDeliveryHandle,
bus: &MessageBus,
target: &crate::channels::TurnTarget,
controller: &TurnController,
) {
let Err(error) = handle.wait().await else {
return;
};
tracing::error!(channel = %target.channel, chat_id = %target.chat_id, error = %error, "Turn sink terminal delivery failed; using ordinary outbound fallback");
let snapshot = controller.snapshot();
let Some(content) = terminal_fallback_content(&snapshot) else {
return;
};
let outbound = OutboundMessage {
channel: target.channel.clone(),
chat_id: target.chat_id.clone(),
content,
reply_to: target.reply_to.clone(),
media: vec![],
metadata: target.metadata.clone(),
delivery: None,
};
if let Err(fallback_error) = bus.deliver_outbound(outbound).await {
tracing::error!(channel = %target.channel, chat_id = %target.chat_id, error = %fallback_error, "Ordinary terminal fallback delivery failed");
}
}
async fn fail_turn_with_partial(
controller: &TurnController,
session: &Arc<Mutex<Session>>,
@ -199,39 +125,6 @@ async fn fail_turn_with_partial(
mod cancelled_partial_tests {
use super::*;
use crate::agent::TurnEvent;
use crate::bus::{MessageBus, OutboundDispatcher};
use crate::channels::{Channel, ChannelError, ChannelManager, CliChatChannel};
use crate::delivery::{ConversationWriteLocks, DeliveryError};
use crate::task_supervisor::TaskSupervisor;
#[derive(Default)]
struct RecordingChannel {
messages: Mutex<Vec<String>>,
}
#[async_trait::async_trait]
impl Channel for RecordingChannel {
fn name(&self) -> &str {
"recording"
}
fn is_running(&self) -> bool {
true
}
async fn start(&self, _bus: Arc<MessageBus>) -> Result<(), ChannelError> {
Ok(())
}
async fn stop(&self) -> Result<(), ChannelError> {
Ok(())
}
async fn send(&self, message: OutboundMessage) -> Result<(), ChannelError> {
self.messages.lock().await.push(message.content);
Ok(())
}
}
#[test]
fn turn_metadata_preserves_channel_cleanup_fields() {
@ -291,70 +184,6 @@ mod cancelled_partial_tests {
assert_eq!(message.completion_status, CompletionStatus::Cancelled);
}
#[test]
fn failed_terminal_without_visible_text_has_safe_fallback() {
let (controller, _emitter, _) = TurnController::start("session", "message-id");
controller.fail("provider response contained a secret");
assert_eq!(
terminal_fallback_content(&controller.snapshot()).as_deref(),
Some("The response could not be delivered. Please try again.")
);
}
#[tokio::test]
async fn asynchronous_terminal_failure_uses_one_ordinary_fallback() {
let bus = MessageBus::new(8);
let cli = Arc::new(CliChatChannel::new());
let channels = ChannelManager::with_bus(cli, bus.clone());
let channel = Arc::new(RecordingChannel::default());
channels
.register_channel("recording", channel.clone())
.await;
let supervisor = TaskSupervisor::new();
let dispatcher = OutboundDispatcher::new(
bus.clone(),
channels,
supervisor.clone(),
ConversationWriteLocks::default(),
);
let dispatcher_task = tokio::spawn(async move { dispatcher.run().await });
let (controller, emitter, _) = TurnController::start("session", "message-id");
emitter
.emit(TurnEvent::TextDelta {
iteration: 0,
delta: "completed response".into(),
})
.unwrap();
controller.complete(None);
let (sender, completion) = oneshot::channel();
sender.send(Err(DeliveryError::FinalTimedOut)).unwrap();
let target = crate::channels::TurnTarget {
channel: "recording".to_string(),
chat_id: "chat".to_string(),
session_id: "session".to_string(),
reply_to: None,
metadata: HashMap::new(),
};
deliver_terminal_fallback(
TurnDeliveryHandle { completion },
&bus,
&target,
&controller,
)
.await;
assert_eq!(
channel.messages.lock().await.as_slice(),
&["completed response"]
);
dispatcher_task.abort();
let _ = dispatcher_task.await;
supervisor.shutdown(std::time::Duration::from_secs(1)).await;
}
#[test]
fn reasoning_only_cancel_does_not_create_assistant_history() {
let (controller, emitter, _) = TurnController::start("session", "message-id");
@ -413,8 +242,6 @@ pub struct Session {
active_turn_emitter: Option<ActiveTurnEmitter>,
/// Monotonic counter to detect stale workers
worker_generation: u64,
/// Prevents duplicate background title requests while the title is still default.
title_generation_in_flight: bool,
/// Monotonic counter for in-memory session mutations.
///
/// Slow work such as memory recall, compression, and title generation runs
@ -436,12 +263,10 @@ struct ActiveTurnEmitter {
/// A task to be processed by the per-session agent worker
struct AgentTask {
channel: String,
sender_id: String,
chat_id: String,
content: String,
received_at: i64,
media: Vec<MediaItem>,
channel_context: ChannelContext,
forwarded_metadata: HashMap<String, String>,
}
#[derive(Clone)]
@ -509,7 +334,6 @@ impl Session {
current_cancel: None,
active_turn_emitter: None,
worker_generation: 0,
title_generation_in_flight: false,
state_version: 0,
persistence_lock: Arc::new(Mutex::new(())),
})
@ -699,7 +523,6 @@ impl Session {
current_cancel: None,
active_turn_emitter: None,
worker_generation: 0,
title_generation_in_flight: false,
state_version: 0,
persistence_lock: Arc::new(Mutex::new(())),
})
@ -835,6 +658,18 @@ impl Session {
}
}
fn append_runtime_context_to_user_message(message: &mut ChatMessage, runtime_context: &str) {
if runtime_context.trim().is_empty() {
return;
}
if message.content.trim().is_empty() {
message.content = runtime_context.to_string();
} else {
message.content = format!("{}\n\n{}", message.content, runtime_context);
}
}
pub fn create_user_message_with_source(
&self,
content: &str,
@ -959,6 +794,14 @@ impl Session {
.with_context_window(self.provider_config.token_limit))
}
/// 创建一个附通知通道的 AgentLoop 实例
pub fn create_agent_with_notify(
&self,
notify_tx: tokio::sync::mpsc::UnboundedSender<String>,
) -> Result<AgentLoop, AgentError> {
Ok(self.create_agent()?.with_notify(notify_tx))
}
/// 构建系统提示词(包含 AgentLoop 的基础提示词 + skills + memory
pub fn build_system_prompt(&self, skills_prompt: &str) -> String {
let base_prompt = build_system_prompt(
@ -2362,12 +2205,13 @@ impl SessionManager {
pub async fn handle_message(
&self,
inbound: &InboundMessage,
channel: &str,
_sender_id: &str,
chat_id: &str,
content: &str,
media: Vec<MediaItem>,
forwarded_metadata: HashMap<String, String>,
) -> Result<HandleResult, AgentError> {
let channel = inbound.channel.as_str();
let sender_id = inbound.sender_id.as_str();
let chat_id = inbound.chat_id.as_str();
let content = inbound.content.as_str();
let unified_id = self.resolve_dialog_id(channel, chat_id).await?;
tracing::debug!(unified_id = %unified_id, "handle_message resolved unified_id");
let session = self.get_or_create_session(&unified_id).await?;
@ -2401,12 +2245,10 @@ impl SessionManager {
// Normal message: enqueue to per-session worker for serial processing.
let task = AgentTask {
channel: channel.to_string(),
sender_id: sender_id.to_string(),
chat_id: chat_id.to_string(),
content: content.to_string(),
received_at: inbound.received_at,
media: inbound.media.clone(),
channel_context: inbound.channel_context.clone(),
media,
forwarded_metadata,
};
let session_clone = session.clone();
let unified_str = unified_id.to_string();
@ -2478,13 +2320,17 @@ impl SessionManager {
}
}
async fn generate_title(
session: Arc<Mutex<Session>>,
provider: Arc<dyn LLMProvider>,
prompt: String,
) -> Result<(), AgentError> {
async fn maybe_generate_title_outside_lock(session: Arc<Mutex<Session>>) -> Result<(), AgentError> {
use crate::providers::{ChatCompletionRequest, ChatCompletionResponse, Message};
let (provider, prompt) = {
let guard = session.lock().await;
let Some(prompt) = guard.title_prompt_snapshot() else {
return Ok(());
};
(guard.provider.clone(), prompt)
};
let request = ChatCompletionRequest {
messages: vec![Message::user(prompt)],
temperature: Some(0.3),
@ -2519,39 +2365,6 @@ async fn generate_title(
Ok(())
}
async fn schedule_title_generation(
session: Arc<Mutex<Session>>,
supervisor: crate::task_supervisor::TaskSupervisor,
session_id: &str,
) {
let title_job = {
let mut guard = session.lock().await;
if guard.title_generation_in_flight {
None
} else {
guard.title_prompt_snapshot().map(|prompt| {
guard.title_generation_in_flight = true;
(guard.provider.clone(), prompt)
})
}
};
let Some((provider, prompt)) = title_job else {
return;
};
let title_session = session.clone();
let task_session = session.clone();
let spawned = supervisor.spawn(format!("session-title:{session_id}"), async move {
if let Err(error) = generate_title(title_session, provider, prompt).await {
tracing::warn!(error = %error, "Failed to generate session title");
}
task_session.lock().await.title_generation_in_flight = false;
});
if !spawned {
session.lock().await.title_generation_in_flight = false;
}
}
fn spawn_agent_worker(
mut task_rx: mpsc::Receiver<AgentTask>,
session: Arc<Mutex<Session>>,
@ -2574,8 +2387,41 @@ fn spawn_agent_worker(
'tasks: while let Some(task) = task_rx.recv().await {
let task_chan = task.channel.clone();
let task_cid = task.chat_id.clone();
let task_metadata = task.channel_context.private.clone();
let task_reply_to = task.channel_context.reply_to.clone();
let task_metadata = task.forwarded_metadata.clone();
let notification_session_id = unified_str.clone();
let (notify_tx, mut notify_rx) = mpsc::unbounded_channel();
// Spawn notification publisher
{
let bus = bus.clone();
let ch = task_chan.clone();
let cid = task_cid.clone();
worker_supervisor.spawn(
format!("session-notifications:{ch}:{cid}"),
async move {
while let Some(notif) = notify_rx.recv().await {
let mut metadata = HashMap::new();
metadata.insert("_type".to_string(), "notification".to_string());
metadata.insert(
"_session_id".to_string(),
notification_session_id.clone(),
);
let outbound = OutboundMessage {
channel: ch.clone(),
chat_id: cid.clone(),
content: notif,
reply_to: None,
media: vec![],
metadata,
delivery: None,
};
let _ = bus.publish_outbound(outbound).await;
}
},
);
}
// Phase 1: capture a stable session snapshot under lock.
// Memory recall and compression happen outside this block so
// /stop and other commands are not blocked behind slow I/O or
@ -2588,18 +2434,7 @@ fn spawn_agent_worker(
}
let media_refs: Vec<MediaRef> =
task.media.iter().map(MediaItem::to_media_ref).collect();
let source = MessageSource {
kind: SourceKind::UserInput,
from_channel: Some(task.channel.clone()),
from_session: None,
from_user_id: Some(task.sender_id.clone()),
system_name: None,
task_id: None,
};
let mut message =
guard.create_user_message_with_source(&task.content, media_refs, source);
message.timestamp = task.received_at;
message
guard.create_user_message(&task.content, media_refs)
};
if let Err(e) = append_persisted_messages(&session, vec![user_message]).await {
tracing::error!(error = %e, "Failed to persist user message");
@ -2607,7 +2442,7 @@ fn spawn_agent_worker(
channel: task_chan.clone(),
chat_id: task_cid.clone(),
content: "Failed to save your message, please try again.".to_string(),
reply_to: task_reply_to.clone(),
reply_to: None,
media: vec![],
metadata: outbound_turn_metadata(&unified_str, &task_metadata),
delivery: None,
@ -2616,14 +2451,7 @@ fn spawn_agent_worker(
continue 'tasks;
}
let (
agent,
history_raw,
mut compressor,
system_prompt_out,
base_version,
cancel_rx,
) = {
let (agent, history_raw, mut compressor, base_version, cancel_rx) = {
let mut guard = session.lock().await;
if guard.worker_generation != worker_gen {
@ -2632,7 +2460,7 @@ fn spawn_agent_worker(
let history_raw = guard.get_history().to_vec();
let agent = match guard.create_agent() {
let agent = match guard.create_agent_with_notify(notify_tx) {
Ok(a) => a,
Err(e) => {
tracing::error!(error = %e, "Failed to create agent");
@ -2641,7 +2469,7 @@ fn spawn_agent_worker(
chat_id: task_cid.clone(),
content: "Agent creation failed, please try again."
.to_string(),
reply_to: task_reply_to.clone(),
reply_to: None,
media: vec![],
metadata: outbound_turn_metadata(&unified_str, &task_metadata),
delivery: None,
@ -2662,49 +2490,100 @@ fn spawn_agent_worker(
agent,
history_raw,
guard.fresh_context_compressor(),
guard.build_system_prompt(&skills_prompt),
guard.state_version,
cancel_rx,
)
}; // lock released
let prepared_input = prepare_turn_input(
memory_manager.clone(),
work_manager.clone(),
&unified_str,
&task.content,
system_prompt_out,
&mut compressor,
history_raw,
)
.await;
let meta_snapshot = {
let mut guard = session.lock().await;
let memory_context = match memory_manager
.recall(
&task.content,
5,
Some(crate::memory::MemoryCategory::Knowledge),
None,
)
.await
{
Ok(entries) if !entries.is_empty() => Some(
entries
.iter()
.map(|e| format!("- {}: {}", e.key, e.content))
.collect::<Vec<_>>()
.join("\n"),
),
Err(e) => {
tracing::warn!(error = %e, "Failed to fetch memory context");
None
}
_ => None,
};
let work_context = match work_manager.active_plan(&unified_str).await {
Ok(Some(plan)) => Some(plan.compact_context()),
Ok(None) => None,
Err(error) => {
tracing::warn!(error = %error, "Failed to load active task plan");
None
}
};
let runtime_context = build_runtime_context(
Some(unified_str.as_str()),
memory_context.as_deref(),
work_context.as_deref(),
);
let system_prompt_out = {
let guard = session.lock().await;
if guard.worker_generation != worker_gen {
return;
}
if guard.state_version != base_version {
tracing::warn!(
session_id = %guard.id,
"Session changed while preparing agent history; dropping stale task"
);
guard.current_cancel = None;
continue 'tasks;
}
if prepared_input.created_timelines {
guard.last_compressed_message_at =
Some(chrono::Utc::now().timestamp_millis());
}
guard.last_consolidated_at = Some(chrono::Utc::now().timestamp_millis());
guard.session_meta_snapshot()
guard.build_system_prompt(&skills_prompt)
};
if let Some((storage, meta)) = meta_snapshot
&& let Err(e) = storage.upsert_session(&meta).await
{
tracing::warn!(error = %e, "Failed to persist session meta after compression");
let compression_result = compressor.compress_if_needed(history_raw).await;
let mut history_out = match compression_result {
Ok(result) => {
let meta_snapshot = {
let mut guard = session.lock().await;
if guard.worker_generation != worker_gen {
return;
}
if guard.state_version != base_version {
tracing::warn!(
session_id = %guard.id,
"Session changed while preparing agent history; dropping stale task"
);
guard.current_cancel = None;
continue 'tasks;
}
if result.created_timelines {
guard.last_compressed_message_at =
Some(chrono::Utc::now().timestamp_millis());
}
guard.last_consolidated_at =
Some(chrono::Utc::now().timestamp_millis());
guard.session_meta_snapshot()
};
if let Some((storage, meta)) = meta_snapshot
&& let Err(e) = storage.upsert_session(&meta).await
{
tracing::warn!(error = %e, "Failed to persist session meta after compression");
}
result.history
}
Err(e) => {
tracing::warn!(error = %e, "Context compression failed in worker");
let guard = session.lock().await;
if guard.worker_generation != worker_gen {
return;
}
guard.get_history().to_vec()
}
};
history_out.insert(0, ChatMessage::system(system_prompt_out.clone()));
if let Some(last_msg) = history_out.iter_mut().rev().find(|m| m.role == "user") {
Session::append_runtime_context_to_user_message(last_msg, &runtime_context);
}
let history_out = prepared_input.messages;
let runtime_context = prepared_input.runtime;
let (turn_controller, turn_emitter, turn_receiver) = TurnController::start(
unified_str.clone(),
@ -2712,28 +2591,29 @@ fn spawn_agent_worker(
);
let initial_turn = turn_controller.snapshot();
let active_turn_id = initial_turn.id.0.clone();
let turn_target = crate::channels::TurnTarget {
channel: task_chan.clone(),
chat_id: task_cid.clone(),
session_id: unified_str.clone(),
reply_to: task_reply_to.clone(),
metadata: outbound_turn_metadata(&unified_str, &task_metadata),
};
let delivery_handle = match turn_delivery
.start(turn_target.clone(), turn_receiver)
let live_delivery_started = match turn_delivery
.start(
crate::channels::TurnTarget {
channel: task_chan.clone(),
chat_id: task_cid.clone(),
session_id: unified_str.clone(),
reply_to: None,
metadata: outbound_turn_metadata(&unified_str, &task_metadata),
},
turn_receiver,
)
.await
{
Ok(handle) => Some(handle),
Ok(()) => true,
Err(error) => {
tracing::debug!(
channel = %task_chan,
error = %error,
"Live turn delivery unavailable; using ordinary final delivery"
);
None
false
}
};
let live_delivery_started = delivery_handle.is_some();
{
let mut guard = session.lock().await;
if guard.worker_generation != worker_gen || guard.state_version != base_version {
@ -2762,10 +2642,6 @@ fn spawn_agent_worker(
let cid2 = task_cid.clone();
let unified_str2 = unified_str.clone();
let task_metadata2 = task_metadata.clone();
let task_reply_to2 = task_reply_to.clone();
let title_supervisor = worker_supervisor.clone();
let commit_delivery = turn_delivery.clone();
let commit_target = turn_target.clone();
let turn_lifecycle = &turn_controller;
let process_future = async move {
let response_session_id = unified_str2.clone();
@ -2816,7 +2692,7 @@ fn spawn_agent_worker(
chat_id: cid2,
content: "Context overflow handling failed."
.to_string(),
reply_to: task_reply_to2.clone(),
reply_to: None,
media: vec![],
metadata: outbound_turn_metadata(
&response_session_id,
@ -2857,7 +2733,21 @@ fn spawn_agent_worker(
tracing::warn!(error = %e, "Failed to persist session meta after retry compression");
}
let retry_history = runtime_context.assemble(retry_result.history);
let retry_history = {
let mut retry = retry_result.history;
retry.insert(
0,
ChatMessage::system(system_prompt_out.clone()),
);
if let Some(last_msg) = retry.iter_mut().rev().find(|m| m.role == "user")
{
Session::append_runtime_context_to_user_message(
last_msg,
&runtime_context,
);
}
retry
};
match agent
.process_streaming(retry_history, agent_turn.clone())
@ -2879,7 +2769,7 @@ fn spawn_agent_worker(
channel: chan2,
chat_id: cid2,
content: format!("Processing error: {}", e),
reply_to: task_reply_to2.clone(),
reply_to: None,
media: vec![],
metadata: outbound_turn_metadata(
&response_session_id,
@ -2906,7 +2796,7 @@ fn spawn_agent_worker(
channel: chan2,
chat_id: cid2,
content: format!("Processing error: {}", e),
reply_to: task_reply_to2.clone(),
reply_to: None,
media: vec![],
metadata: outbound_turn_metadata(
&response_session_id,
@ -2938,18 +2828,15 @@ fn spawn_agent_worker(
let response = match finalize_turn_after_persistence(
turn_lifecycle,
usage,
append_persisted_messages_with_meta(
&session2,
result.emitted_messages,
),
append_persisted_messages(&session2, result.emitted_messages),
)
.await
{
Ok(committed_messages) => {
Ok(()) => {
let mut guard = session2.lock().await;
let sent_count = guard.messages.len();
guard.compressor.set_last_api_info(sent_count, total_tokens);
Some((response_content, committed_messages))
Some(response_content)
}
Err(e) => {
tracing::error!(error = %e, "Failed to atomically persist agent turn");
@ -2957,13 +2844,13 @@ fn spawn_agent_worker(
}
};
let Some((response, committed_messages)) = response else {
let Some(response) = response else {
let err_outbound = OutboundMessage {
channel: chan2,
chat_id: cid2,
content: "Failed to save the agent response, please try again."
.to_string(),
reply_to: task_reply_to2.clone(),
reply_to: None,
media: vec![],
metadata: outbound_turn_metadata(
&response_session_id,
@ -2977,24 +2864,16 @@ fn spawn_agent_worker(
return;
};
let delta = committed_turn_delta(&response_session_id, committed_messages);
if let Err(error) = commit_delivery.commit(&commit_target, delta).await {
tracing::warn!(error = %error, "Failed to publish committed turn delta");
if let Err(e) = maybe_generate_title_outside_lock(session2.clone()).await {
tracing::warn!("failed to generate title: {}", e);
}
schedule_title_generation(
session2.clone(),
title_supervisor,
&response_session_id,
)
.await;
if !live_delivery_started {
let outbound = OutboundMessage {
channel: chan2,
chat_id: cid2,
content: response,
reply_to: task_reply_to2.clone(),
reply_to: None,
media: vec![],
metadata: outbound_turn_metadata(
&response_session_id,
@ -3033,16 +2912,6 @@ fn spawn_agent_worker(
}
}
if let Some(handle) = delivery_handle {
deliver_terminal_fallback(
handle,
&bus,
&turn_target,
&turn_controller,
)
.await;
}
// Clean up
let mut guard = session.lock().await;
if guard

View File

@ -1,148 +0,0 @@
use std::sync::Arc;
use crate::agent::ContextCompressor;
use crate::agent::system_prompt::build_runtime_context;
use crate::bus::ChatMessage;
use crate::memory::{MemoryCategory, MemoryManager};
use crate::work::WorkManager;
/// Immutable context used to assemble provider input for both the initial call
/// and context-overflow recovery.
pub(super) struct TurnRuntimeContext {
system_prompt: String,
runtime_context: String,
}
impl TurnRuntimeContext {
pub(super) fn assemble(&self, mut history: Vec<ChatMessage>) -> Vec<ChatMessage> {
history.insert(0, ChatMessage::system(self.system_prompt.clone()));
if let Some(last_user) = history
.iter_mut()
.rev()
.find(|message| message.role == "user")
{
append_runtime_context(last_user, &self.runtime_context);
}
history
}
}
pub(super) struct PreparedTurnInput {
pub(super) messages: Vec<ChatMessage>,
pub(super) runtime: TurnRuntimeContext,
pub(super) created_timelines: bool,
}
/// Builds the complete cross-turn provider input outside the Session lock.
/// Independent context sources and compression are fetched concurrently.
pub(super) async fn prepare_turn_input(
memory_manager: Arc<MemoryManager>,
work_manager: Arc<WorkManager>,
session_id: &str,
query: &str,
system_prompt: String,
compressor: &mut ContextCompressor,
history: Vec<ChatMessage>,
) -> PreparedTurnInput {
let memory_future = memory_manager.recall(query, 5, Some(MemoryCategory::Knowledge), None);
let work_future = work_manager.active_plan(session_id);
let compression_future = compressor.compress_if_needed(history.clone());
let (memory_result, work_result, compression_result) =
tokio::join!(memory_future, work_future, compression_future);
let memory_context = match memory_result {
Ok(entries) if !entries.is_empty() => Some(
entries
.iter()
.map(|entry| format!("- {}: {}", entry.key, entry.content))
.collect::<Vec<_>>()
.join("\n"),
),
Err(error) => {
tracing::warn!(error = %error, "Failed to fetch memory context");
None
}
_ => None,
};
let work_context = match work_result {
Ok(Some(plan)) => Some(plan.compact_context()),
Ok(None) => None,
Err(error) => {
tracing::warn!(error = %error, "Failed to load active task plan");
None
}
};
let compression = match compression_result {
Ok(result) => result,
Err(error) => {
tracing::warn!(error = %error, "Context compression failed while preparing turn input");
crate::agent::context_compressor::CompressionResult {
history,
created_timelines: false,
}
}
};
let runtime = TurnRuntimeContext {
system_prompt,
runtime_context: build_runtime_context(
Some(session_id),
memory_context.as_deref(),
work_context.as_deref(),
),
};
PreparedTurnInput {
messages: runtime.assemble(compression.history),
runtime,
created_timelines: compression.created_timelines,
}
}
fn append_runtime_context(message: &mut ChatMessage, runtime_context: &str) {
if runtime_context.trim().is_empty() {
return;
}
if message.content.trim().is_empty() {
message.content = runtime_context.to_string();
} else {
message.content = format!("{}\n\n{}", message.content, runtime_context);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn runtime_context_is_added_only_to_latest_user_message() {
let runtime = TurnRuntimeContext {
system_prompt: "system".to_string(),
runtime_context: "runtime".to_string(),
};
let messages = runtime.assemble(vec![
ChatMessage::user("old"),
ChatMessage::assistant("answer"),
ChatMessage::user("new"),
]);
assert_eq!(messages[0].role, "system");
assert_eq!(messages[1].content, "old");
assert_eq!(messages[3].content, "new\n\nruntime");
}
#[test]
fn overflow_reassembly_does_not_duplicate_runtime_context() {
let runtime = TurnRuntimeContext {
system_prompt: "system".to_string(),
runtime_context: "runtime".to_string(),
};
let first = runtime.assemble(vec![ChatMessage::user("question")]);
let recovered = runtime.assemble(vec![ChatMessage::user("question")]);
assert_eq!(first.len(), recovered.len());
assert_eq!(first[0].content, recovered[0].content);
assert_eq!(first[1].content, recovered[1].content);
assert_eq!(recovered.len(), 2);
}
}

View File

@ -1,12 +1,12 @@
{
"name": "picobot-webui",
"version": "1.2.0",
"version": "1.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picobot-webui",
"version": "1.2.0",
"version": "1.1.2",
"dependencies": {
"bits-ui": "^2.0.0",
"dompurify": "^3.4.12",

View File

@ -1,7 +1,7 @@
{
"name": "picobot-webui",
"private": true,
"version": "1.2.0",
"version": "1.1.2",
"type": "module",
"engines": {
"node": ">=20"

View File

@ -19,7 +19,6 @@
let commandMenuDismissed = $state(false);
let thinking = $state(false);
let activeTurn = $state(null);
let historyRevision = $state(0);
let pendingUploads = $state([]);
let fileInput;
let plansBySession = $state({});
@ -78,7 +77,7 @@
function handleFrame(frame) {
switch (frame.type) {
case "session_established": currentId = frame.session_id; activeTurn = null; historyRevision = 0; break;
case "session_established": currentId = frame.session_id; activeTurn = null; break;
case "session_list":
sessions = frame.sessions || [];
if (frame.current_session_id) currentId = frame.current_session_id;
@ -88,20 +87,17 @@
currentId = frame.session_id;
messages = [];
activeTurn = null;
historyRevision = 0;
send({ type: "list_sessions", include_archived: false });
break;
case "session_loaded":
currentId = frame.session_id;
activeTurn = null;
historyRevision = 0;
send({ type: "get_session_history", session_id: currentId, limit: 1000 });
send({ type: "get_session_plan", session_id: currentId });
break;
case "session_history":
if (frame.session_id === currentId) {
messages = frame.messages || [];
historyRevision = Math.max(0, ...messages.map((message) => message.seq || 0));
if (activeTurn?.status !== "running" && messages.some((message) => message.id === activeTurn?.message_id)) activeTurn = null;
scrollToBottom();
}
@ -148,31 +144,13 @@
if (activeTurn?.id === next.id && activeTurn.revision >= next.revision) break;
activeTurn = next;
thinking = next.status === "running";
if (next.status !== "running" && messages.some((message) => message.id === next.message_id)) {
activeTurn = null;
}
scrollToBottom();
if (next.status !== "running") {
if (next.status !== "completed") {
send({ type: "get_session_history", session_id: currentId, limit: 1000 });
}
send({ type: "get_session_history", session_id: currentId, limit: 1000 });
send({ type: "list_sessions", include_archived: false });
}
break;
}
case "turn_committed": {
if (frame.session_id !== currentId || frame.history_revision <= historyRevision) break;
const byId = new Map(messages.map((message) => [message.id, message]));
for (const message of frame.messages || []) byId.set(message.id, message);
messages = [...byId.values()];
historyRevision = frame.history_revision;
if (activeTurn?.status !== "running"
&& (frame.messages || []).some((message) => message.id === activeTurn?.message_id)) {
activeTurn = null;
}
scrollToBottom();
break;
}
case "system_notification":
if (!frame.session_id || frame.session_id === currentId) appendMessage("assistant", frame.content);
break;
@ -194,7 +172,6 @@
function loadSession(id) {
if (!id) return;
currentId = id;
historyRevision = 0;
clearPendingUploads();
messages = [];
activeTurn = null;