Compare commits
16 Commits
7a5d95e786
...
762fd16e3f
| Author | SHA1 | Date | |
|---|---|---|---|
| 762fd16e3f | |||
| 711b07af83 | |||
| 2d1eacfab7 | |||
| 55422ed48a | |||
| 23e0b3e7a6 | |||
| 7e53fa658b | |||
| 0512d91729 | |||
| 5d081e2580 | |||
| 76de8139de | |||
| 76685c3983 | |||
| 355244a3d6 | |||
| 42fe650785 | |||
| 515a07ec1f | |||
| 0c835db380 | |||
| 65ef919714 | |||
| 07b09ca486 |
15
AGENTS.md
15
AGENTS.md
@ -7,6 +7,7 @@ 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
|
||||
@ -19,6 +20,7 @@ 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
|
||||
|
||||
@ -39,17 +41,18 @@ 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 processor → SessionManager → per-session worker → AgentLoop
|
||||
Channel → MessageBus.inbound → Gateway inbound router/lane → 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 processor → SessionManager (dialog operations)
|
||||
WebSocket/Channel → MessageBus.control → Gateway control router → SessionManager (dialog operations)
|
||||
Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler delivery policy → SessionManager/MessageBus
|
||||
```
|
||||
|
||||
@ -79,6 +82,7 @@ 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
|
||||
@ -88,9 +92,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; session history must preserve structured tool-call metadata so calls and results remain independently collapsible
|
||||
- **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/TUI file transfer** streams bytes over authenticated HTTP and sends only short-lived upload IDs/attachment metadata over WebSocket; messages persist local media paths without guaranteeing later availability, and client responses must never expose those paths
|
||||
- **WebUI authentication** protects every management API and `/ws`; only static pairing assets, public health/status, and pairing submission may bypass device auth. Pair-code issuance requires both a real loopback peer and the filesystem-held admin token; never put bearer tokens in URLs or logs
|
||||
- **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
|
||||
- **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
|
||||
@ -135,3 +139,6 @@ 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
|
||||
- 在每次功能变化、架构变化后,适当地更新整个产品的版本号
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "picobot"
|
||||
version = "1.1.2"
|
||||
version = "1.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@ -83,14 +83,15 @@ RUN useradd -m -s /bin/bash app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy pre-built binary from host
|
||||
COPY target/release/picobot /app/picobot
|
||||
# Install the pre-built binary in the standard executable path.
|
||||
COPY target/release/picobot /usr/local/bin/picobot
|
||||
|
||||
# Copy config template
|
||||
COPY resources/templates/config.example.json /app/config.json.example
|
||||
|
||||
# Create required directories
|
||||
RUN mkdir -p /app/.picobot/workspace /app/.picobot/media /app/.picobot/tmp && \
|
||||
# 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 && \
|
||||
chown -R app:app /app
|
||||
|
||||
USER app
|
||||
@ -98,9 +99,9 @@ ENV HOME=/app
|
||||
|
||||
# Environment variables for Chromium in containers
|
||||
ENV CHROME_BIN=/usr/bin/chromium
|
||||
ENV TMPDIR=/app/.picobot/tmp
|
||||
ENV TMPDIR=/tmp
|
||||
|
||||
ENTRYPOINT ["/app/picobot"]
|
||||
ENTRYPOINT ["/usr/local/bin/picobot"]
|
||||
CMD ["gateway"]
|
||||
|
||||
EXPOSE 19876
|
||||
|
||||
35
README.md
35
README.md
@ -9,6 +9,7 @@ PicoBot 是一个用 Rust 编写的个人 AI 助手运行时。它在本地启
|
||||
## 适合做什么
|
||||
|
||||
- 在终端里和本地 AI 助手持续对话。
|
||||
- 从脚本或命令行发送一条任务,等待完整的模型/工具循环后只输出最终结果。
|
||||
- 在 TUI 或浏览器中实时查看正文、思考过程和工具执行状态,并在完成后收敛到持久化历史。
|
||||
- 在浏览器中查看日志、任务和记忆,修改运行配置与助手档案。
|
||||
- 复杂任务可创建 session 级 Todo 计划,把不同子项并行委托给多个子 Agent;聊天页侧栏实时显示进度。
|
||||
@ -124,9 +125,22 @@ docker compose up -d
|
||||
cargo run -- chat
|
||||
```
|
||||
|
||||
CLI 默认连接 `ws://127.0.0.1:19876/ws`。首次使用先运行 `picobot pair`,再执行 `picobot chat --pair-code <CODE>`;客户端令牌会以 `0600` 权限保存到 `~/.picobot/tui_auth_token`。如需指定地址,可使用 `--gateway-url`。
|
||||
CLI 默认连接 `ws://127.0.0.1:19876/ws`。TUI 首次使用先运行 `picobot pair`,再执行 `picobot chat --pair-code <CODE>`;客户端令牌会以 `0600` 权限保存到 `~/.picobot/tui_auth_token`。如需指定地址,可使用 `--gateway-url`。
|
||||
|
||||
### 5.1 使用 WebUI
|
||||
### 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
|
||||
|
||||
Gateway 启动后直接打开:
|
||||
|
||||
@ -142,6 +156,14 @@ 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 实时命令清单的 `/` 斜杠命令补全。
|
||||
@ -155,7 +177,7 @@ WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件
|
||||
|
||||
配置接口会掩码 API Key、secret、password 和 token;保留 `********` 再保存不会覆盖原密钥。运行配置采用原子写入并在 Gateway 重启后生效,`USER.md` 和 `AGENTS.md` 则会用于后续构建的 Agent 上下文。
|
||||
|
||||
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 默认启用设备配对鉴权,管理 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 开发
|
||||
|
||||
@ -243,7 +265,7 @@ WebUI/TUI 上传文件默认保存到 `~/.picobot/media/cli_chat`,单文件上
|
||||
| `cli_chat` | Ratatui 终端客户端,通过 WebSocket 连接 Gateway |
|
||||
| `feishu` | 飞书/Lark 消息、反应、文件上传下载和媒体引用 |
|
||||
|
||||
飞书默认只发送终态结果。设置 `channels.feishu.live_updates=true` 后会创建一张卡片并持续编辑;`live_update_interval_ms` 默认 500ms,运行时限制在 250–5000ms。外部渠道始终不会收到模型 reasoning。
|
||||
飞书默认只接受 `allow_from` 中的用户,且群聊消息必须明确 @ 机器人(可通过 `channels.feishu.require_mention=false` 关闭)。回复会使用飞书原生引用/话题语义保持在原消息位置。默认只发送终态结果;设置 `channels.feishu.live_updates=true` 后会创建一张卡片并持续编辑,`live_update_interval_ms` 默认 500ms,运行时限制在 250–5000ms。外部渠道始终不会收到模型 reasoning。
|
||||
|
||||
### 会话
|
||||
|
||||
@ -348,6 +370,11 @@ 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)。
|
||||
|
||||
|
||||
33
config.json
Normal file
33
config.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
21
docker-compose.test.yml
Normal file
21
docker-compose.test.yml
Normal file
@ -0,0 +1,21 @@
|
||||
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}"
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
本文档描述 PicoBot 当前实现的运行时边界、数据流、并发模型和演进约束。它面向维护者和后续参与改进的 Agent,是代码架构的主入口;行为细节仍以代码和测试为最终依据。
|
||||
|
||||
流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递的详细设计与取舍见 [STREAMING_TURN_DESIGN.md](STREAMING_TURN_DESIGN.md)。
|
||||
流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递的详细设计与取舍见 [STREAMING_TURN_DESIGN.md](STREAMING_TURN_DESIGN.md)。用户输入路由、Session 执行拆分、终态投递确认和历史增量校准的重构方案见 [MESSAGE_FLOW_REFACTOR_DESIGN.md](MESSAGE_FLOW_REFACTOR_DESIGN.md)。
|
||||
|
||||
## 1. 设计目标
|
||||
|
||||
@ -18,12 +18,13 @@ 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` 拉起。
|
||||
|
||||
@ -31,6 +32,8 @@ Linux 上可通过 `picobot service install/start/stop/status/restart/uninstall`
|
||||
|
||||
CLI TUI 在 `~/.picobot/tui_client_id` 保存非敏感客户端标识,并通过 WebSocket 查询参数 `client_id` 传给 Gateway。`cli_chat` 以该标识作为稳定 chat scope;重连时恢复内存中的当前 dialog,Gateway 重启后则恢复该 scope 最近活跃的未归档 dialog。无效或缺失的标识会退化为连接级随机 scope。
|
||||
|
||||
One-shot client 不绕过 Gateway 直接调用 Provider。每次 `run` 生成独立的 `run-<uuid>` scope,通过相同的 `cli_chat`、MessageBus、SessionManager、AgentLoop 和 Turn delivery 路径执行;它不复用 TUI scope,因而不会替换同一 scope 的活动 WebSocket。默认 stdout 只投影终态 Assistant blocks,进度写到 stderr;超时或 Ctrl-C 会先在当前 scope 发送 `/stop`。
|
||||
|
||||
Gateway 启动时先从配置目录 `.env`、workspace `.env` 和既有进程环境合并启动变量,再初始化日志并切换进程工作目录到 `workspace_dir`。优先级为进程环境 > workspace `.env` > 配置目录 `.env`;配置目录层先用于定位 workspace,workspace 层不得重定向自身位置。环境文件只在单线程启动阶段写入进程环境,不能移到后台任务启动之后。切换完成后所有相对路径都应按 workspace 解释,不能假设仍位于源码仓库。
|
||||
|
||||
## 3. 组件关系
|
||||
@ -39,7 +42,7 @@ Gateway 启动时先从配置目录 `.env`、workspace `.env` 和既有进程环
|
||||
flowchart LR
|
||||
External[CLI / Feishu] --> Channels[channels]
|
||||
Channels -->|InboundMessage| Bus[MessageBus]
|
||||
Bus --> Processor[Gateway message processor]
|
||||
Bus --> Processor[Gateway inbound/control routers]
|
||||
Processor --> Sessions[SessionManager]
|
||||
Sessions --> Agent[AgentLoop]
|
||||
Agent --> Providers[LLM providers]
|
||||
@ -79,9 +82,9 @@ flowchart LR
|
||||
|
||||
`MessageBus` 包含三条容量相同的 Tokio MPSC 队列:
|
||||
|
||||
- `inbound`:Channel → Gateway message processor。
|
||||
- `inbound`:Channel → Gateway inbound router。
|
||||
- `outbound`:Session/Tool → `OutboundDispatcher`。
|
||||
- `control`:WebSocket/Channel → Gateway message processor,用于 dialog 操作。
|
||||
- `control`:WebSocket/Channel → Gateway control router,用于 dialog 操作。
|
||||
|
||||
### 普通消息
|
||||
|
||||
@ -89,7 +92,7 @@ flowchart LR
|
||||
sequenceDiagram
|
||||
participant C as Channel
|
||||
participant B as MessageBus
|
||||
participant G as Message processor
|
||||
participant G as Inbound router
|
||||
participant S as SessionManager
|
||||
participant W as Per-session worker
|
||||
participant A as AgentLoop / Provider
|
||||
@ -120,10 +123,12 @@ sequenceDiagram
|
||||
|
||||
关键语义:
|
||||
|
||||
- Gateway 的主消息处理循环不等待模型完成;普通消息进入对应 session worker 后立即返回 `AgentProcessing`。
|
||||
- Gateway inbound router 按 `(channel, chat_id)` 使用容量 32 的短生命周期 lane 保持入口顺序,不同聊天可并发路由;普通消息进入对应 session worker 后立即返回 `AgentProcessing`。
|
||||
- `/stop` 绕过同聊天的 inbound lane,直接使正在运行的 worker/Turn 失效;其他 slash command 仍在聊天 lane 内有序执行。
|
||||
- 每个 session 有一条容量为 32 的队列,同一 session 串行处理,不同 session 的 worker 可并发执行。
|
||||
- 队列满时明确拒绝新消息,不允许无界积压。
|
||||
- Slash command 不进入 Agent 队列,由 `SessionManager` 直接执行,因此 `/stop` 等控制操作不会排在长模型调用之后。
|
||||
- `InboundMessage` 只保存规范化输入:`sender_id`、`received_at`、媒体和一个 `ChannelContext`。核心只解释其中的 `reply_to`,其语义是本轮出站应回复的当前入站消息;被用户引用的父消息只用于补充模型上下文。reaction/message ID、话题 root/thread 等平台字段作为 `private` 不透明传到对应 Turn/普通回复,不能散落为核心层 magic key。持久化的用户消息保留真实接收时间和 `UserInput` 来源,客户端历史投影不暴露来源中的平台用户 ID。
|
||||
- Session 为每个 Agent 请求创建一个 `TurnController`。Provider 向 AgentLoop 发 delta,AgentLoop 发结构化 TurnEvent,只有 TurnController 能把事件归约为有序 block 和单调 revision 的完整快照。
|
||||
- Turn 快照经 Tokio `watch` 发布,语义为 latest-wins;慢客户端或慢渠道跳过中间状态,不反压 Provider。终态明确编码在快照中,不依赖 sender 关闭。
|
||||
- Agent 本轮消息原子持久化成功后才发布 `Completed`。取消或失败若已有可见正文,则保存为 `cancelled`/`interrupted` partial;只有 reasoning 时不创建 assistant 历史。
|
||||
@ -135,8 +140,11 @@ 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`,终态随后由持久化历史校准。
|
||||
- 飞书默认 `FinalOnly`;开启 `live_updates` 后,第一个可见快照创建卡片,后续编辑同一卡片,终态编辑失败则发送完整结果兜底。reaction 清理在 finish、abort 和 Gateway shutdown 中幂等执行。
|
||||
- 飞书对每个 DATA 帧先在 2 秒硬期限内 ACK,再进行有界分片重组,并把完整事件交给容量 32 的连接内处理队列;媒体下载和引用查询不占用正常的 WebSocket 读循环。队列饱和时当前事件在连接任务中同步处理而不丢弃。连接异常采用有上限的指数退避持续重连,不因累计故障永久停止。
|
||||
- 飞书在协议解析阶段按 `allow_from` 拒绝未授权用户;群聊默认必须明确 @ 运行时解析出的机器人身份,身份解析失败时安全地忽略群消息。飞书把当前消息 ID 作为 `reply_to`,并在私有 metadata 中携带 root/thread 信息;Sink 使用原生 reply API 及 `reply_in_thread` 保持客户端引用和话题位置。飞书默认 `FinalOnly`;开启 `live_updates` 后,第一个可见快照创建卡片,后续编辑同一卡片,终态编辑失败则发送完整结果兜底。reaction 清理在 finish、abort 和 Gateway shutdown 中幂等执行。
|
||||
- 飞书入站响应体按类型流式执行字节上限和超时检查,写盘前校验媒体目录总容量,客户端文件名先收敛为安全 basename;出站上传也先检查本地文件大小。消息发送和资源下载对网络错误、429、5xx 和 401 进行有界重试,401 或飞书失效 token 业务码会使租户 token 缓存失效后重新获取。
|
||||
- DeliveryCoordinator 与 OutboundDispatcher 共享 `(channel, chat_id)` 写锁,避免活动 Turn 终态与独立消息并发写入同一目标。
|
||||
|
||||
### 出站投递
|
||||
@ -153,7 +161,7 @@ sequenceDiagram
|
||||
|
||||
### Control 消息
|
||||
|
||||
WebSocket dialog 操作通过 `ControlMessage` 携带一次性回复通道。Gateway 在统一 message processor 中调用 `SessionManager`,再将 `SessionEvent` 回传给发起者。Bus 只承载消息,不解释操作。
|
||||
WebSocket dialog 操作通过 `ControlMessage` 携带一次性回复通道。Gateway control router 以最多 64 个并发受监督任务调用 `SessionManager`,再将 `SessionEvent` 回传给发起者;慢 control 不阻塞其他聊天的 inbound 路由。Bus 只承载消息,不解释操作。
|
||||
|
||||
TUI 的历史回放同样走 control 队列:`get_session_history` 先校验 session 属于当前客户端 scope,再由 SessionManager 从 Storage 读取最近消息。单次查询限制为 1–2000 条,TUI 默认请求最近 1000 条;迟到的历史响应只有在目标仍是当前 dialog 时才允许更新界面。
|
||||
|
||||
@ -181,7 +189,9 @@ Session ID 格式为:
|
||||
5. 持久化写入由 `persistence_lock` 串行化;多条相关记录应使用 Storage 的原子接口。
|
||||
6. 内存先变更但持久化失败时,必须回滚精确匹配的消息后缀,不能删除无关的新状态。
|
||||
|
||||
SessionManager 负责组装会话上下文:系统提示、Skills、召回的 Knowledge、压缩后的 Timeline、可选的 active plan 摘要和当前消息历史。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,在历史压缩之后追加,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态。
|
||||
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 同时最多有一个标题任务,提交时仍校验标题保持默认值,避免覆盖用户改名。
|
||||
|
||||
每个 session 最多有一个 active plan,但 plan 内多个 item 可以分别绑定不同子 Agent 并行执行。主 Agent 通过 `todo` 创建和维护计划,通过 `delegate.plan_item_id` 委托;子 Agent 不获得 `todo` 或 `delegate`。领取 item 使用条件更新防止重复执行,迟到结果只有在 plan 仍 active 且 execution ID 匹配时才允许提交。
|
||||
|
||||
@ -194,7 +204,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。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。成功持久化一个 Turn 后,交互 Channel 收到只包含公开字段的 `CommittedTurnDelta`,其中 `history_revision` 是本批次最高 durable sequence;客户端按 revision 幂等合并,正常完成不重新加载整段历史,断线重连和失败/取消仍使用 `SessionHistory` 校准。Provider 诊断和失败审计只记录模型、消息数、工具数等请求摘要,不保存正文、reasoning 或签名 payload。修改 schema 时应:
|
||||
|
||||
1. 更新集中式 schema/迁移逻辑。
|
||||
2. 保留已有数据库的升级路径。
|
||||
@ -211,7 +221,7 @@ SessionManager 负责组装会话上下文:系统提示、Skills、召回的 K
|
||||
|
||||
## 7. 后台任务与生命周期
|
||||
|
||||
`TaskSupervisor` 是 Gateway 内部后台任务的统一所有者。message processor、outbound dispatcher、scheduler、session workers、Turn delivery、outbound lanes、通知消费者和子 Agent 后台任务都应通过它注册。
|
||||
`TaskSupervisor` 是 Gateway 内部后台任务的统一所有者。inbound/control routers、inbound lanes、outbound dispatcher、scheduler、session workers、Turn delivery、outbound lanes、后台任务通知消费者、自动标题和子 Agent 后台任务都应通过它注册。
|
||||
|
||||
两种注册方式:
|
||||
|
||||
@ -231,13 +241,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 在本轮完成后刷新历史并将其渲染为默认折叠的工具卡片;聊天页的 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 在正常完成时合并 `turn_committed` 增量并将工具信息渲染为默认折叠的工具卡片;聊天页的 Todo 侧栏默认隐藏,按 session 保存快照和未读状态,并通过结构化 `session_plan`/`plan_updated` 帧刷新,计划变化不会写入聊天历史;斜杠命令补全通过 `get_slash_commands` 获取 Gateway 的实时命令与别名清单,不在前端重复定义;WebUI 不直接调用 Provider 或 SessionManager。
|
||||
|
||||
WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输,WebSocket 只携带短期 `upload_id` 和结构化附件描述。`UploadRegistry` 在内存中按 `cli_chat` chat scope 校验并消费待发送上传;消息继续以 `media_refs` 保存 Gateway 本地路径,不建立永久附件资产。下载接口必须通过 client、session、message 和附件序号反查路径,不能接受客户端路径。历史附件路径失效属于正常状态,不得影响历史文本读取。待发送但未进入消息的上传由 `TaskSupervisor` 所有的限时清理任务回收。Agent 上下文会为所有媒体注入内部路径清单,客户端响应不得暴露该路径。
|
||||
|
||||
工具默认通过 `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`;反向代理即使从回环连接也无法在没有该密钥时签发代码。配对码为 8 位、5 分钟有效、单次消费,并按来源实施失败锁定。浏览器收到 HttpOnly、SameSite=Strict Cookie,CLI 使用 Bearer token;服务端仅持久化 SHA-256 哈希。`--revoke-all` 的持久化成功后才清空内存令牌,活动 WebSocket 每 5 秒复核身份并回收已撤销连接。
|
||||
`AuthManager` 默认保护所有管理 API 和 `/ws`。静态资源、`/health`、`/api/auth/status` 与 `/api/auth/pair` 保持公开,使未配对浏览器只能加载配对界面。`picobot pair` 使用权限为 `0600` 的本机管理密钥调用仅接受真实回环连接的 `/api/auth/code`;反向代理即使从回环连接也无法在没有该密钥时签发代码。本机 `picobot run` 可用同一个管理密钥直接认证 `/ws`,但中间件必须同时验证请求路径严格等于 `/ws` 且 `ConnectInfo` 中的真实 TCP 对端为回环地址;这一身份不能访问管理 API。远程 `run` 与 TUI 一样使用已配对的 Bearer token。配对码为 8 位、5 分钟有效、单次消费,并按来源实施失败锁定。浏览器收到 HttpOnly、SameSite=Strict Cookie,CLI 使用 Bearer token;服务端仅持久化 SHA-256 哈希。`--revoke-all` 的持久化成功后才清空内存令牌,活动 WebSocket 每 5 秒复核身份并回收已撤销连接。
|
||||
|
||||
同源 `/api/*` 管理接口只提供显式白名单能力:
|
||||
|
||||
@ -259,7 +269,7 @@ WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输,WebSocket 只
|
||||
3. 初始化 SQLite、MemoryManager、MessageBus 和 SessionManager;Scheduler 启用时幂等创建默认日常维护巡检。
|
||||
4. 注册内置工具、渠道、MCP 工具和 Cron 工具。
|
||||
5. 启动所有 Channel。
|
||||
6. 通过 TaskSupervisor 启动 message processor、dispatcher 和 scheduler。
|
||||
6. 通过 TaskSupervisor 启动 inbound/control routers、dispatcher 和 scheduler。
|
||||
7. 注册 WebUI 静态资源、管理 API 与聊天 WebSocket 路由。
|
||||
8. 绑定 Axum listener,开始接收请求。
|
||||
|
||||
|
||||
363
docs/MESSAGE_FLOW_REFACTOR_DESIGN.md
Normal file
363
docs/MESSAGE_FLOW_REFACTOR_DESIGN.md
Normal file
@ -0,0 +1,363 @@
|
||||
# 用户消息到 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 ID;LLM 上下文是否展示发言者由独立策略决定,不能直接泄露平台标识。
|
||||
|
||||
## 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 去重;如果不修改数据库 schema,revision 使用 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 handle,Session 消费结果并做一次兜底。
|
||||
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 中的运行时不变量与实现一致。
|
||||
|
||||
@ -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` | 常用命令:编译、启动网关、启动客户端、运行测试 |
|
||||
| `references/commands.md` | 常用命令:编译、启动网关、Docker/WebUI 设备配对、启动客户端、运行测试 |
|
||||
| `references/tools.md` | 内置工具名称、参数和重要使用约束 |
|
||||
| `assets/config.example.json` | config.json 完整示例 |
|
||||
|
||||
|
||||
@ -60,9 +60,16 @@
|
||||
"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"
|
||||
"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
|
||||
}
|
||||
},
|
||||
"memory": {
|
||||
|
||||
@ -22,6 +22,12 @@ 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
|
||||
@ -66,3 +72,5 @@ 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 分钟有效且只能消费一次。
|
||||
|
||||
@ -94,11 +94,16 @@ 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 | 卡片更新最小间隔,运行时限制在 250–5000ms |
|
||||
| `max_image_bytes` | int | 10485760 | 单个入站/出站图片的最大字节数 |
|
||||
| `max_file_bytes` | int | 26214400 | 单个入站/出站文件、音频或视频的最大字节数 |
|
||||
| `media_dir_max_bytes` | int | 536870912 | 飞书媒体目录容量上限;达到上限后拒绝新下载,不自动删除旧文件 |
|
||||
| `request_timeout_secs` | int | 30 | 单次飞书 HTTP 请求及响应体读取的硬超时,运行时限制在 5–120 秒 |
|
||||
|
||||
飞书属于外部渠道:无论是否开启实时卡片,都不会接收模型 reasoning;工具只显示紧凑状态。配置修改需重启 Gateway 生效。
|
||||
|
||||
|
||||
@ -22,6 +22,16 @@
|
||||
|
||||
内置 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/`。
|
||||
|
||||
@ -68,11 +68,16 @@
|
||||
"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
|
||||
"live_update_interval_ms": 500,
|
||||
"max_image_bytes": 10485760,
|
||||
"max_file_bytes": 26214400,
|
||||
"media_dir_max_bytes": 536870912,
|
||||
"request_timeout_secs": 30
|
||||
}
|
||||
},
|
||||
"memory": {
|
||||
|
||||
@ -305,7 +305,6 @@ 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,
|
||||
}
|
||||
@ -356,7 +355,6 @@ impl AgentLoop {
|
||||
provider: Arc::from(provider),
|
||||
tools: Arc::new(ToolRegistry::new()),
|
||||
observer: None,
|
||||
notify_tx: None,
|
||||
context_window: 0,
|
||||
max_iterations,
|
||||
workspace_dir,
|
||||
@ -382,7 +380,6 @@ impl AgentLoop {
|
||||
provider: Arc::from(provider),
|
||||
tools,
|
||||
observer: None,
|
||||
notify_tx: None,
|
||||
context_window: 0,
|
||||
max_iterations,
|
||||
workspace_dir,
|
||||
@ -404,7 +401,6 @@ impl AgentLoop {
|
||||
provider,
|
||||
tools: Arc::new(ToolRegistry::new()),
|
||||
observer: None,
|
||||
notify_tx: None,
|
||||
context_window: 0,
|
||||
max_iterations,
|
||||
workspace_dir,
|
||||
@ -427,7 +423,6 @@ impl AgentLoop {
|
||||
provider,
|
||||
tools,
|
||||
observer: None,
|
||||
notify_tx: None,
|
||||
context_window: 0,
|
||||
max_iterations,
|
||||
workspace_dir,
|
||||
@ -455,11 +450,6 @@ 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
|
||||
@ -718,7 +708,8 @@ impl AgentLoop {
|
||||
.map_err(|error| AgentError::Other(format!("turn event rejected: {error}")))?;
|
||||
}
|
||||
|
||||
// Execute tool calls — log and notify immediately
|
||||
// Execute tool calls. User-visible progress is emitted through the
|
||||
// structured TurnEvent stream, not a second notification channel.
|
||||
{
|
||||
let tools_info: Vec<String> = response
|
||||
.tool_calls
|
||||
@ -726,9 +717,6 @@ 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();
|
||||
|
||||
@ -154,6 +154,8 @@ 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")]
|
||||
@ -364,18 +366,48 @@ 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 timestamp: i64,
|
||||
pub received_at: i64,
|
||||
pub media: Vec<MediaItem>,
|
||||
/// 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>,
|
||||
pub channel_context: ChannelContext,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@ -3,8 +3,9 @@ pub mod message;
|
||||
|
||||
pub use dispatcher::OutboundDispatcher;
|
||||
pub use message::{
|
||||
ChatMessage, CompletionStatus, ContentBlock, ControlMessage, InboundMessage, MediaItem,
|
||||
MediaRef, MessageSource, OutboundMessage, ProviderReasoningState, SourceKind,
|
||||
ChannelContext, ChatMessage, CommittedMessage, CommittedTurnDelta, CompletionStatus,
|
||||
ContentBlock, ControlMessage, InboundMessage, MediaItem, MediaRef, MessageSource,
|
||||
OutboundMessage, ProviderReasoningState, SourceKind,
|
||||
};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@ -3,7 +3,7 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::bus::{BusError, InboundMessage, MessageBus, OutboundMessage};
|
||||
use crate::bus::{BusError, CommittedTurnDelta, InboundMessage, MessageBus, OutboundMessage};
|
||||
use crate::delivery::PresentationPolicy;
|
||||
use crate::session::TurnSnapshot;
|
||||
|
||||
@ -90,6 +90,16 @@ 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>;
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
|
||||
use crate::bus::{ControlMessage, InboundMessage, MessageBus, OutboundMessage};
|
||||
use crate::bus::{CommittedTurnDelta, ControlMessage, InboundMessage, MessageBus, OutboundMessage};
|
||||
use crate::gateway::uploads::UploadRegistry;
|
||||
use crate::protocol::{
|
||||
HistoryMessage, MessageAttachment, SlashCommandInfo, WsInbound, WsOutbound, parse_inbound,
|
||||
@ -211,10 +211,9 @@ impl CliChatChannel {
|
||||
sender_id: "cli".to_string(),
|
||||
chat_id: target_chat_id,
|
||||
content,
|
||||
timestamp: crate::bus::message::current_timestamp(),
|
||||
received_at: crate::bus::message::current_timestamp(),
|
||||
media,
|
||||
metadata: Default::default(),
|
||||
forwarded_metadata: Default::default(),
|
||||
channel_context: Default::default(),
|
||||
};
|
||||
if let Err(error) = bus.publish_inbound(msg).await {
|
||||
self.uploads.restore(uploads).await;
|
||||
@ -434,36 +433,7 @@ impl CliChatChannel {
|
||||
|| message.tool_calls.is_some()
|
||||
|| message.role == "tool"
|
||||
})
|
||||
.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,
|
||||
}
|
||||
})
|
||||
.map(HistoryMessage::from_message_meta)
|
||||
.collect();
|
||||
let _ = client
|
||||
.sender
|
||||
@ -893,6 +863,29 @@ 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 {
|
||||
@ -1144,4 +1137,59 @@ 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
@ -1,7 +1,10 @@
|
||||
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,
|
||||
@ -255,6 +258,7 @@ 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);
|
||||
@ -263,7 +267,9 @@ 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) {
|
||||
request_history(app, session_id).await;
|
||||
if !completed {
|
||||
request_history(app, session_id).await;
|
||||
}
|
||||
} else {
|
||||
app.status_message = Some("另一个会话已完成响应".to_string());
|
||||
}
|
||||
@ -275,6 +281,13 @@ 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,
|
||||
|
||||
428
src/client/oneshot.rs
Normal file
428
src/client/oneshot.rs
Normal file
@ -0,0 +1,428 @@
|
||||
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(¤t) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -66,6 +66,7 @@ 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.
|
||||
@ -99,6 +100,7 @@ impl App {
|
||||
selected_session: 0,
|
||||
show_archived: false,
|
||||
messages: VecDeque::new(),
|
||||
history_revision: 0,
|
||||
active_turn: None,
|
||||
input: String::new(),
|
||||
input_cursor_pos: 0,
|
||||
@ -151,6 +153,11 @@ 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)
|
||||
@ -178,12 +185,65 @@ 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
|
||||
@ -202,6 +262,7 @@ 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;
|
||||
}
|
||||
@ -225,7 +286,12 @@ impl App {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.active_turn = Some(snapshot);
|
||||
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.chat_scroll_from_bottom = 0;
|
||||
true
|
||||
}
|
||||
@ -491,6 +557,60 @@ 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();
|
||||
|
||||
@ -71,6 +71,9 @@ 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")]
|
||||
@ -83,6 +86,14 @@ 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> {
|
||||
@ -104,6 +115,22 @@ 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")]
|
||||
|
||||
@ -23,6 +23,7 @@ pub enum DeliveryError {
|
||||
OpenFailed(ChannelError),
|
||||
SnapshotStreamClosed,
|
||||
SupervisorStopping,
|
||||
CompletionLost,
|
||||
FinalTimedOut,
|
||||
FinalFailed(ChannelError),
|
||||
}
|
||||
@ -38,6 +39,9 @@ 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}"),
|
||||
}
|
||||
|
||||
@ -4,4 +4,4 @@ mod service;
|
||||
|
||||
pub use coordinator::{ConversationWriteLocks, DeliveryCoordinator, DeliveryError};
|
||||
pub use policy::{PresentationPolicy, ReasoningVisibility, ToolVisibility, project_snapshot};
|
||||
pub use service::TurnDeliveryService;
|
||||
pub use service::{TurnDeliveryHandle, TurnDeliveryService};
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::watch;
|
||||
use tokio::sync::{oneshot, watch};
|
||||
|
||||
use crate::bus::CommittedTurnDelta;
|
||||
use crate::channels::{ChannelManager, TurnTarget};
|
||||
use crate::delivery::{DeliveryCoordinator, DeliveryError};
|
||||
use crate::session::TurnSnapshot;
|
||||
@ -19,6 +20,23 @@ 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,
|
||||
@ -36,7 +54,7 @@ impl TurnDeliveryService {
|
||||
&self,
|
||||
target: TurnTarget,
|
||||
snapshots: watch::Receiver<Arc<TurnSnapshot>>,
|
||||
) -> Result<(), DeliveryError> {
|
||||
) -> Result<TurnDeliveryHandle, DeliveryError> {
|
||||
let channel = self
|
||||
.channels
|
||||
.get_channel(&target.channel)
|
||||
@ -48,7 +66,7 @@ impl TurnDeliveryService {
|
||||
.open_turn(target.clone())
|
||||
.await
|
||||
.map_err(DeliveryError::OpenFailed)?;
|
||||
let _result = self.coordinator.spawn_sink(
|
||||
let completion = self.coordinator.spawn_sink(
|
||||
&self.supervisor,
|
||||
SinkRoute {
|
||||
channel: target.channel,
|
||||
@ -59,6 +77,45 @@ impl TurnDeliveryService {
|
||||
snapshots,
|
||||
sink,
|
||||
)?;
|
||||
Ok(())
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ 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 {
|
||||
@ -65,8 +66,12 @@ pub struct AuthManager {
|
||||
state: Arc<Mutex<AuthState>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthIdentity(pub Option<String>);
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthIdentity {
|
||||
PairingDisabled,
|
||||
Paired { token_hash: String },
|
||||
LocalAdmin,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PairError {
|
||||
@ -110,7 +115,7 @@ impl AuthManager {
|
||||
|
||||
pub async fn authenticate(&self, token: Option<&str>) -> Option<AuthIdentity> {
|
||||
if !self.required {
|
||||
return Some(AuthIdentity(None));
|
||||
return Some(AuthIdentity::PairingDisabled);
|
||||
}
|
||||
let hash = hash_token(token?);
|
||||
self.state
|
||||
@ -118,17 +123,17 @@ impl AuthManager {
|
||||
.await
|
||||
.token_hashes
|
||||
.contains(&hash)
|
||||
.then_some(AuthIdentity(Some(hash)))
|
||||
.then_some(AuthIdentity::Paired { token_hash: hash })
|
||||
}
|
||||
|
||||
pub async fn identity_is_active(&self, identity: &AuthIdentity) -> bool {
|
||||
if !self.required {
|
||||
return true;
|
||||
match identity {
|
||||
AuthIdentity::PairingDisabled => !self.required,
|
||||
AuthIdentity::LocalAdmin => true,
|
||||
AuthIdentity::Paired { token_hash } => {
|
||||
self.required && self.state.lock().await.token_hashes.contains(token_hash)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@ -210,9 +215,24 @@ pub async fn require_auth(
|
||||
mut request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let identity = auth
|
||||
let mut 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,
|
||||
@ -314,7 +334,7 @@ pub async fn issue_code(
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let admin_token = headers
|
||||
.get("X-Picobot-Admin-Token")
|
||||
.get(ADMIN_TOKEN_HEADER)
|
||||
.and_then(|value| value.to_str().ok());
|
||||
if !peer.ip().is_loopback() || !state.auth.authenticate_admin(admin_token) {
|
||||
return (
|
||||
@ -488,7 +508,7 @@ async fn load_or_create_admin_token(path: &Path) -> Result<String, std::io::Erro
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{Router, middleware, routing};
|
||||
use axum::{Extension, Router, middleware, routing};
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
@ -593,4 +613,61 @@ 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
pub mod auth;
|
||||
pub mod http;
|
||||
mod router;
|
||||
pub mod uploads;
|
||||
pub mod ws;
|
||||
|
||||
@ -8,8 +9,7 @@ use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::bus::{ControlMessage, MessageBus, OutboundDispatcher};
|
||||
use crate::channels::base::ChannelError;
|
||||
use crate::bus::{MessageBus, OutboundDispatcher};
|
||||
use crate::channels::{ChannelManager, CliChatChannel};
|
||||
use crate::config::{Config, ensure_workspace_dir, expand_path};
|
||||
use crate::delivery::{ConversationWriteLocks, DeliveryCoordinator, TurnDeliveryService};
|
||||
@ -276,80 +276,7 @@ impl GatewayState {
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
router::spawn_message_routers(bus.clone(), session_manager, self.task_supervisor.clone());
|
||||
|
||||
// Spawn outbound dispatcher
|
||||
let dispatcher = OutboundDispatcher::new(
|
||||
@ -379,112 +306,6 @@ 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(
|
||||
|
||||
446
src/gateway/router.rs
Normal file
446
src/gateway/router.rs
Normal file
@ -0,0 +1,446 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
50
src/main.rs
50
src/main.rs
@ -19,7 +19,7 @@ enum ServiceCommand {
|
||||
#[derive(Parser)]
|
||||
#[command(name = "picobot")]
|
||||
#[command(about = "A CLI chatbot", long_about = None)]
|
||||
#[command(version = "1.1.1")]
|
||||
#[command(version)]
|
||||
enum Command {
|
||||
/// Connect to gateway
|
||||
Chat {
|
||||
@ -30,6 +30,23 @@ 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
|
||||
@ -77,6 +94,32 @@ 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?;
|
||||
}
|
||||
@ -101,7 +144,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
})?;
|
||||
let response = reqwest::Client::new()
|
||||
.post(endpoint)
|
||||
.header("X-Picobot-Admin-Token", admin_token.trim())
|
||||
.header(
|
||||
picobot::gateway::auth::ADMIN_TOKEN_HEADER,
|
||||
admin_token.trim(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
|
||||
@ -77,6 +77,59 @@ 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 {
|
||||
@ -148,6 +201,12 @@ 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,
|
||||
@ -259,6 +318,32 @@ 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!({
|
||||
|
||||
@ -3,6 +3,7 @@ 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;
|
||||
|
||||
@ -44,26 +44,40 @@ 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(());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
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 = {
|
||||
let snapshots: Vec<Option<MessagePersistSnapshot>> = {
|
||||
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(())
|
||||
Ok(committed)
|
||||
}
|
||||
|
||||
/// Publish `Completed` only after the supplied durable write succeeds.
|
||||
|
||||
@ -3,10 +3,14 @@ use std::sync::Arc;
|
||||
|
||||
use tokio::sync::{Mutex, mpsc, oneshot};
|
||||
|
||||
use super::persistence::{append_persisted_messages, finalize_turn_after_persistence};
|
||||
use super::persistence::{
|
||||
append_persisted_messages, append_persisted_messages_with_meta, finalize_turn_after_persistence,
|
||||
};
|
||||
use super::turn::{TurnBlock, TurnController, TurnSnapshot};
|
||||
use super::turn_input::prepare_turn_input;
|
||||
use crate::bus::{
|
||||
ChatMessage, CompletionStatus, MediaItem, MediaRef, MessageSource, OutboundMessage, SourceKind,
|
||||
ChannelContext, ChatMessage, CompletionStatus, InboundMessage, MediaItem, MediaRef,
|
||||
MessageSource, OutboundMessage, SourceKind,
|
||||
};
|
||||
use crate::mcp::get_mcp_status;
|
||||
use crate::storage::{Storage, StorageError};
|
||||
@ -27,13 +31,46 @@ fn outbound_session_metadata(session_id: &str) -> HashMap<String, String> {
|
||||
|
||||
fn outbound_turn_metadata(
|
||||
session_id: &str,
|
||||
forwarded: &HashMap<String, String>,
|
||||
private_context: &HashMap<String, String>,
|
||||
) -> HashMap<String, String> {
|
||||
let mut metadata = forwarded.clone();
|
||||
let mut metadata = private_context.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>;
|
||||
}
|
||||
@ -48,12 +85,12 @@ pub enum HandleResult {
|
||||
AgentProcessing,
|
||||
}
|
||||
use crate::agent::context_compressor::ContextCompressionConfig;
|
||||
use crate::agent::system_prompt::{build_runtime_context, build_system_prompt};
|
||||
use crate::agent::system_prompt::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::TurnDeliveryService;
|
||||
use crate::delivery::{TurnDeliveryHandle, TurnDeliveryService};
|
||||
|
||||
/// Check if an LLM error message indicates a context window overflow.
|
||||
fn is_context_overflow_error(msg: &str) -> bool {
|
||||
@ -102,6 +139,43 @@ 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>>,
|
||||
@ -125,6 +199,39 @@ 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() {
|
||||
@ -184,6 +291,70 @@ 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");
|
||||
@ -242,6 +413,8 @@ 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
|
||||
@ -263,10 +436,12 @@ 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>,
|
||||
forwarded_metadata: HashMap<String, String>,
|
||||
channel_context: ChannelContext,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@ -334,6 +509,7 @@ 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(())),
|
||||
})
|
||||
@ -523,6 +699,7 @@ 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(())),
|
||||
})
|
||||
@ -658,18 +835,6 @@ 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,
|
||||
@ -794,14 +959,6 @@ 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(
|
||||
@ -2205,13 +2362,12 @@ impl SessionManager {
|
||||
|
||||
pub async fn handle_message(
|
||||
&self,
|
||||
channel: &str,
|
||||
_sender_id: &str,
|
||||
chat_id: &str,
|
||||
content: &str,
|
||||
media: Vec<MediaItem>,
|
||||
forwarded_metadata: HashMap<String, String>,
|
||||
inbound: &InboundMessage,
|
||||
) -> 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?;
|
||||
@ -2245,10 +2401,12 @@ 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(),
|
||||
media,
|
||||
forwarded_metadata,
|
||||
received_at: inbound.received_at,
|
||||
media: inbound.media.clone(),
|
||||
channel_context: inbound.channel_context.clone(),
|
||||
};
|
||||
let session_clone = session.clone();
|
||||
let unified_str = unified_id.to_string();
|
||||
@ -2320,17 +2478,13 @@ impl SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_generate_title_outside_lock(session: Arc<Mutex<Session>>) -> Result<(), AgentError> {
|
||||
async fn generate_title(
|
||||
session: Arc<Mutex<Session>>,
|
||||
provider: Arc<dyn LLMProvider>,
|
||||
prompt: String,
|
||||
) -> 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),
|
||||
@ -2365,6 +2519,39 @@ async fn maybe_generate_title_outside_lock(session: Arc<Mutex<Session>>) -> Resu
|
||||
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>>,
|
||||
@ -2387,41 +2574,8 @@ 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.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;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let task_metadata = task.channel_context.private.clone();
|
||||
let task_reply_to = task.channel_context.reply_to.clone();
|
||||
// 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
|
||||
@ -2434,7 +2588,18 @@ fn spawn_agent_worker(
|
||||
}
|
||||
let media_refs: Vec<MediaRef> =
|
||||
task.media.iter().map(MediaItem::to_media_ref).collect();
|
||||
guard.create_user_message(&task.content, media_refs)
|
||||
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
|
||||
};
|
||||
if let Err(e) = append_persisted_messages(&session, vec![user_message]).await {
|
||||
tracing::error!(error = %e, "Failed to persist user message");
|
||||
@ -2442,7 +2607,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: None,
|
||||
reply_to: task_reply_to.clone(),
|
||||
media: vec![],
|
||||
metadata: outbound_turn_metadata(&unified_str, &task_metadata),
|
||||
delivery: None,
|
||||
@ -2451,7 +2616,14 @@ fn spawn_agent_worker(
|
||||
continue 'tasks;
|
||||
}
|
||||
|
||||
let (agent, history_raw, mut compressor, base_version, cancel_rx) = {
|
||||
let (
|
||||
agent,
|
||||
history_raw,
|
||||
mut compressor,
|
||||
system_prompt_out,
|
||||
base_version,
|
||||
cancel_rx,
|
||||
) = {
|
||||
let mut guard = session.lock().await;
|
||||
|
||||
if guard.worker_generation != worker_gen {
|
||||
@ -2460,7 +2632,7 @@ fn spawn_agent_worker(
|
||||
|
||||
let history_raw = guard.get_history().to_vec();
|
||||
|
||||
let agent = match guard.create_agent_with_notify(notify_tx) {
|
||||
let agent = match guard.create_agent() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to create agent");
|
||||
@ -2469,7 +2641,7 @@ fn spawn_agent_worker(
|
||||
chat_id: task_cid.clone(),
|
||||
content: "Agent creation failed, please try again."
|
||||
.to_string(),
|
||||
reply_to: None,
|
||||
reply_to: task_reply_to.clone(),
|
||||
media: vec![],
|
||||
metadata: outbound_turn_metadata(&unified_str, &task_metadata),
|
||||
delivery: None,
|
||||
@ -2490,100 +2662,49 @@ 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 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;
|
||||
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;
|
||||
if guard.worker_generation != worker_gen {
|
||||
return;
|
||||
}
|
||||
guard.build_system_prompt(&skills_prompt)
|
||||
};
|
||||
|
||||
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
|
||||
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;
|
||||
}
|
||||
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()
|
||||
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()
|
||||
};
|
||||
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);
|
||||
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 history_out = prepared_input.messages;
|
||||
let runtime_context = prepared_input.runtime;
|
||||
|
||||
let (turn_controller, turn_emitter, turn_receiver) = TurnController::start(
|
||||
unified_str.clone(),
|
||||
@ -2591,29 +2712,28 @@ fn spawn_agent_worker(
|
||||
);
|
||||
let initial_turn = turn_controller.snapshot();
|
||||
let active_turn_id = initial_turn.id.0.clone();
|
||||
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,
|
||||
)
|
||||
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)
|
||||
.await
|
||||
{
|
||||
Ok(()) => true,
|
||||
Ok(handle) => Some(handle),
|
||||
Err(error) => {
|
||||
tracing::debug!(
|
||||
channel = %task_chan,
|
||||
error = %error,
|
||||
"Live turn delivery unavailable; using ordinary final delivery"
|
||||
);
|
||||
false
|
||||
None
|
||||
}
|
||||
};
|
||||
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 {
|
||||
@ -2642,6 +2762,10 @@ 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();
|
||||
@ -2692,7 +2816,7 @@ fn spawn_agent_worker(
|
||||
chat_id: cid2,
|
||||
content: "Context overflow handling failed."
|
||||
.to_string(),
|
||||
reply_to: None,
|
||||
reply_to: task_reply_to2.clone(),
|
||||
media: vec![],
|
||||
metadata: outbound_turn_metadata(
|
||||
&response_session_id,
|
||||
@ -2733,21 +2857,7 @@ fn spawn_agent_worker(
|
||||
tracing::warn!(error = %e, "Failed to persist session meta after retry compression");
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
let retry_history = runtime_context.assemble(retry_result.history);
|
||||
|
||||
match agent
|
||||
.process_streaming(retry_history, agent_turn.clone())
|
||||
@ -2769,7 +2879,7 @@ fn spawn_agent_worker(
|
||||
channel: chan2,
|
||||
chat_id: cid2,
|
||||
content: format!("Processing error: {}", e),
|
||||
reply_to: None,
|
||||
reply_to: task_reply_to2.clone(),
|
||||
media: vec![],
|
||||
metadata: outbound_turn_metadata(
|
||||
&response_session_id,
|
||||
@ -2796,7 +2906,7 @@ fn spawn_agent_worker(
|
||||
channel: chan2,
|
||||
chat_id: cid2,
|
||||
content: format!("Processing error: {}", e),
|
||||
reply_to: None,
|
||||
reply_to: task_reply_to2.clone(),
|
||||
media: vec![],
|
||||
metadata: outbound_turn_metadata(
|
||||
&response_session_id,
|
||||
@ -2828,15 +2938,18 @@ fn spawn_agent_worker(
|
||||
let response = match finalize_turn_after_persistence(
|
||||
turn_lifecycle,
|
||||
usage,
|
||||
append_persisted_messages(&session2, result.emitted_messages),
|
||||
append_persisted_messages_with_meta(
|
||||
&session2,
|
||||
result.emitted_messages,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
Ok(committed_messages) => {
|
||||
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)
|
||||
Some((response_content, committed_messages))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to atomically persist agent turn");
|
||||
@ -2844,13 +2957,13 @@ fn spawn_agent_worker(
|
||||
}
|
||||
};
|
||||
|
||||
let Some(response) = response else {
|
||||
let Some((response, committed_messages)) = 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: None,
|
||||
reply_to: task_reply_to2.clone(),
|
||||
media: vec![],
|
||||
metadata: outbound_turn_metadata(
|
||||
&response_session_id,
|
||||
@ -2864,16 +2977,24 @@ fn spawn_agent_worker(
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(e) = maybe_generate_title_outside_lock(session2.clone()).await {
|
||||
tracing::warn!("failed to generate title: {}", e);
|
||||
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");
|
||||
}
|
||||
|
||||
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: None,
|
||||
reply_to: task_reply_to2.clone(),
|
||||
media: vec![],
|
||||
metadata: outbound_turn_metadata(
|
||||
&response_session_id,
|
||||
@ -2912,6 +3033,16 @@ 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
|
||||
|
||||
148
src/session/turn_input.rs
Normal file
148
src/session/turn_input.rs
Normal file
@ -0,0 +1,148 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
4
webui/package-lock.json
generated
4
webui/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picobot-webui",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picobot-webui",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"bits-ui": "^2.0.0",
|
||||
"dompurify": "^3.4.12",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picobot-webui",
|
||||
"private": true,
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
let commandMenuDismissed = $state(false);
|
||||
let thinking = $state(false);
|
||||
let activeTurn = $state(null);
|
||||
let historyRevision = $state(0);
|
||||
let pendingUploads = $state([]);
|
||||
let fileInput;
|
||||
let plansBySession = $state({});
|
||||
@ -77,7 +78,7 @@
|
||||
|
||||
function handleFrame(frame) {
|
||||
switch (frame.type) {
|
||||
case "session_established": currentId = frame.session_id; activeTurn = null; break;
|
||||
case "session_established": currentId = frame.session_id; activeTurn = null; historyRevision = 0; break;
|
||||
case "session_list":
|
||||
sessions = frame.sessions || [];
|
||||
if (frame.current_session_id) currentId = frame.current_session_id;
|
||||
@ -87,17 +88,20 @@
|
||||
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();
|
||||
}
|
||||
@ -144,13 +148,31 @@
|
||||
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") {
|
||||
send({ type: "get_session_history", session_id: currentId, limit: 1000 });
|
||||
if (next.status !== "completed") {
|
||||
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;
|
||||
@ -172,6 +194,7 @@
|
||||
function loadSession(id) {
|
||||
if (!id) return;
|
||||
currentId = id;
|
||||
historyRevision = 0;
|
||||
clearPendingUploads();
|
||||
messages = [];
|
||||
activeTurn = null;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user