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 build` — build the binary
|
||||||
- `cargo run -- gateway` — start gateway server (binds `127.0.0.1:19876` by default)
|
- `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 -- 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
|
- `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
|
- 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
|
- `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
|
- `.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)
|
- 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
|
- 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
|
## 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
|
- **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
|
- **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
|
### 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
|
└── Channel ← OutboundDispatcher ← per-conversation lane ← MessageBus.outbound
|
||||||
|
|
||||||
AgentLoop → TurnEvent → Session TurnController → latest TurnSnapshot → DeliveryCoordinator → per-turn TurnSink → Channel
|
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
|
Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler delivery policy → SessionManager/MessageBus
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -79,6 +82,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
|
|||||||
### Functional Boundaries
|
### 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
|
- **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
|
- **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
|
- **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
|
- **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
|
- **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 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 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/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
|
- **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
|
- **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
|
- **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
|
- `docs/ARCHITECTURE.md` — maintainer-facing runtime design, invariants, lifecycle, and extension guidance
|
||||||
- `AGENTS.md` — concise operational rules for repository agents
|
- `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
|
- `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]
|
[package]
|
||||||
name = "picobot"
|
name = "picobot"
|
||||||
version = "1.1.2"
|
version = "1.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
13
Dockerfile
13
Dockerfile
@ -83,14 +83,15 @@ RUN useradd -m -s /bin/bash app
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy pre-built binary from host
|
# Install the pre-built binary in the standard executable path.
|
||||||
COPY target/release/picobot /app/picobot
|
COPY target/release/picobot /usr/local/bin/picobot
|
||||||
|
|
||||||
# Copy config template
|
# Copy config template
|
||||||
COPY resources/templates/config.example.json /app/config.json.example
|
COPY resources/templates/config.example.json /app/config.json.example
|
||||||
|
|
||||||
# Create required directories
|
# Create persistent application directories. Browser temporary data stays in
|
||||||
RUN mkdir -p /app/.picobot/workspace /app/.picobot/media /app/.picobot/tmp && \
|
# /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
|
chown -R app:app /app
|
||||||
|
|
||||||
USER app
|
USER app
|
||||||
@ -98,9 +99,9 @@ ENV HOME=/app
|
|||||||
|
|
||||||
# Environment variables for Chromium in containers
|
# Environment variables for Chromium in containers
|
||||||
ENV CHROME_BIN=/usr/bin/chromium
|
ENV CHROME_BIN=/usr/bin/chromium
|
||||||
ENV TMPDIR=/app/.picobot/tmp
|
ENV TMPDIR=/tmp
|
||||||
|
|
||||||
ENTRYPOINT ["/app/picobot"]
|
ENTRYPOINT ["/usr/local/bin/picobot"]
|
||||||
CMD ["gateway"]
|
CMD ["gateway"]
|
||||||
|
|
||||||
EXPOSE 19876
|
EXPOSE 19876
|
||||||
|
|||||||
35
README.md
35
README.md
@ -9,6 +9,7 @@ PicoBot 是一个用 Rust 编写的个人 AI 助手运行时。它在本地启
|
|||||||
## 适合做什么
|
## 适合做什么
|
||||||
|
|
||||||
- 在终端里和本地 AI 助手持续对话。
|
- 在终端里和本地 AI 助手持续对话。
|
||||||
|
- 从脚本或命令行发送一条任务,等待完整的模型/工具循环后只输出最终结果。
|
||||||
- 在 TUI 或浏览器中实时查看正文、思考过程和工具执行状态,并在完成后收敛到持久化历史。
|
- 在 TUI 或浏览器中实时查看正文、思考过程和工具执行状态,并在完成后收敛到持久化历史。
|
||||||
- 在浏览器中查看日志、任务和记忆,修改运行配置与助手档案。
|
- 在浏览器中查看日志、任务和记忆,修改运行配置与助手档案。
|
||||||
- 复杂任务可创建 session 级 Todo 计划,把不同子项并行委托给多个子 Agent;聊天页侧栏实时显示进度。
|
- 复杂任务可创建 session 级 Todo 计划,把不同子项并行委托给多个子 Agent;聊天页侧栏实时显示进度。
|
||||||
@ -124,9 +125,22 @@ docker compose up -d
|
|||||||
cargo run -- chat
|
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 启动后直接打开:
|
Gateway 启动后直接打开:
|
||||||
|
|
||||||
@ -142,6 +156,14 @@ picobot pair
|
|||||||
|
|
||||||
在浏览器配对页输入输出的 8 位代码即可。配对码 5 分钟内有效且只能使用一次;浏览器凭据由 HttpOnly Cookie 保存。需要撤销全部浏览器和 CLI 客户端时运行 `picobot pair --revoke-all`,再用新代码重新配对。
|
在浏览器配对页输入输出的 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 或单独部署静态文件,提供:
|
WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件,提供:
|
||||||
|
|
||||||
- 在线聊天、会话创建/切换、历史回放、流式 Markdown、独立思考区、实时工具状态、可折叠历史工具调用卡片,以及基于 Gateway 实时命令清单的 `/` 斜杠命令补全。
|
- 在线聊天、会话创建/切换、历史回放、流式 Markdown、独立思考区、实时工具状态、可折叠历史工具调用卡片,以及基于 Gateway 实时命令清单的 `/` 斜杠命令补全。
|
||||||
@ -155,7 +177,7 @@ WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件
|
|||||||
|
|
||||||
配置接口会掩码 API Key、secret、password 和 token;保留 `********` 再保存不会覆盖原密钥。运行配置采用原子写入并在 Gateway 重启后生效,`USER.md` 和 `AGENTS.md` 则会用于后续构建的 Agent 上下文。
|
配置接口会掩码 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 开发
|
#### WebUI 开发
|
||||||
|
|
||||||
@ -243,7 +265,7 @@ WebUI/TUI 上传文件默认保存到 `~/.picobot/media/cli_chat`,单文件上
|
|||||||
| `cli_chat` | Ratatui 终端客户端,通过 WebSocket 连接 Gateway |
|
| `cli_chat` | Ratatui 终端客户端,通过 WebSocket 连接 Gateway |
|
||||||
| `feishu` | 飞书/Lark 消息、反应、文件上传下载和媒体引用 |
|
| `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` |
|
| `browser.enabled` | `false` |
|
||||||
| `channels.feishu.live_updates` | `false` |
|
| `channels.feishu.live_updates` | `false` |
|
||||||
| `channels.feishu.live_update_interval_ms` | `500` |
|
| `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)。
|
更完整的配置字段说明见 [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,是代码架构的主入口;行为细节仍以代码和测试为最终依据。
|
本文档描述 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. 设计目标
|
## 1. 设计目标
|
||||||
|
|
||||||
@ -18,12 +18,13 @@ PicoBot 是一个单进程、异步、可扩展的个人 AI 助手运行时。
|
|||||||
|
|
||||||
## 2. 运行模式与进程边界
|
## 2. 运行模式与进程边界
|
||||||
|
|
||||||
PicoBot 只有一个二进制,提供两种模式:
|
PicoBot 只有一个二进制,提供三种运行模式:
|
||||||
|
|
||||||
| 模式 | 入口 | 职责 |
|
| 模式 | 入口 | 职责 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| Gateway | `cargo run -- gateway` | 组装服务、监听 HTTP/WebSocket、提供嵌入式 WebUI,运行渠道、会话、调度器和后台任务 |
|
| Gateway | `cargo run -- gateway` | 组装服务、监听 HTTP/WebSocket、提供嵌入式 WebUI,运行渠道、会话、调度器和后台任务 |
|
||||||
| CLI client | `cargo run -- chat` | 运行 Ratatui UI,通过 WebSocket 使用 Gateway,不持有业务状态 |
|
| 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` 拉起。
|
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。
|
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 解释,不能假设仍位于源码仓库。
|
Gateway 启动时先从配置目录 `.env`、workspace `.env` 和既有进程环境合并启动变量,再初始化日志并切换进程工作目录到 `workspace_dir`。优先级为进程环境 > workspace `.env` > 配置目录 `.env`;配置目录层先用于定位 workspace,workspace 层不得重定向自身位置。环境文件只在单线程启动阶段写入进程环境,不能移到后台任务启动之后。切换完成后所有相对路径都应按 workspace 解释,不能假设仍位于源码仓库。
|
||||||
|
|
||||||
## 3. 组件关系
|
## 3. 组件关系
|
||||||
@ -39,7 +42,7 @@ Gateway 启动时先从配置目录 `.env`、workspace `.env` 和既有进程环
|
|||||||
flowchart LR
|
flowchart LR
|
||||||
External[CLI / Feishu] --> Channels[channels]
|
External[CLI / Feishu] --> Channels[channels]
|
||||||
Channels -->|InboundMessage| Bus[MessageBus]
|
Channels -->|InboundMessage| Bus[MessageBus]
|
||||||
Bus --> Processor[Gateway message processor]
|
Bus --> Processor[Gateway inbound/control routers]
|
||||||
Processor --> Sessions[SessionManager]
|
Processor --> Sessions[SessionManager]
|
||||||
Sessions --> Agent[AgentLoop]
|
Sessions --> Agent[AgentLoop]
|
||||||
Agent --> Providers[LLM providers]
|
Agent --> Providers[LLM providers]
|
||||||
@ -79,9 +82,9 @@ flowchart LR
|
|||||||
|
|
||||||
`MessageBus` 包含三条容量相同的 Tokio MPSC 队列:
|
`MessageBus` 包含三条容量相同的 Tokio MPSC 队列:
|
||||||
|
|
||||||
- `inbound`:Channel → Gateway message processor。
|
- `inbound`:Channel → Gateway inbound router。
|
||||||
- `outbound`:Session/Tool → `OutboundDispatcher`。
|
- `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
|
sequenceDiagram
|
||||||
participant C as Channel
|
participant C as Channel
|
||||||
participant B as MessageBus
|
participant B as MessageBus
|
||||||
participant G as Message processor
|
participant G as Inbound router
|
||||||
participant S as SessionManager
|
participant S as SessionManager
|
||||||
participant W as Per-session worker
|
participant W as Per-session worker
|
||||||
participant A as AgentLoop / Provider
|
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 可并发执行。
|
- 每个 session 有一条容量为 32 的队列,同一 session 串行处理,不同 session 的 worker 可并发执行。
|
||||||
- 队列满时明确拒绝新消息,不允许无界积压。
|
- 队列满时明确拒绝新消息,不允许无界积压。
|
||||||
- Slash command 不进入 Agent 队列,由 `SessionManager` 直接执行,因此 `/stop` 等控制操作不会排在长模型调用之后。
|
- 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 的完整快照。
|
- Session 为每个 Agent 请求创建一个 `TurnController`。Provider 向 AgentLoop 发 delta,AgentLoop 发结构化 TurnEvent,只有 TurnController 能把事件归约为有序 block 和单调 revision 的完整快照。
|
||||||
- Turn 快照经 Tokio `watch` 发布,语义为 latest-wins;慢客户端或慢渠道跳过中间状态,不反压 Provider。终态明确编码在快照中,不依赖 sender 关闭。
|
- Turn 快照经 Tokio `watch` 发布,语义为 latest-wins;慢客户端或慢渠道跳过中间状态,不反压 Provider。终态明确编码在快照中,不依赖 sender 关闭。
|
||||||
- Agent 本轮消息原子持久化成功后才发布 `Completed`。取消或失败若已有可见正文,则保存为 `cancelled`/`interrupted` partial;只有 reasoning 时不创建 assistant 历史。
|
- Agent 本轮消息原子持久化成功后才发布 `Completed`。取消或失败若已有可见正文,则保存为 `cancelled`/`interrupted` partial;只有 reasoning 时不创建 assistant 历史。
|
||||||
@ -135,8 +140,11 @@ sequenceDiagram
|
|||||||
- `TurnDeliveryService` 根据 Channel 创建本轮独占的 `TurnSink`;sink 私有保存远端消息 ID 和清理资源。
|
- `TurnDeliveryService` 根据 Channel 创建本轮独占的 `TurnSink`;sink 私有保存远端消息 ID 和清理资源。
|
||||||
- `PresentationPolicy` 在快照离开 Gateway 核心前过滤内容。TUI/WebUI 展示独立 reasoning 和详细工具状态;外部 Channel 不接收 reasoning,只接收紧凑工具状态;无人值守投递只保留正文。
|
- `PresentationPolicy` 在快照离开 Gateway 核心前过滤内容。TUI/WebUI 展示独立 reasoning 和详细工具状态;外部 Channel 不接收 reasoning,只接收紧凑工具状态;无人值守投递只保留正文。
|
||||||
- `LivePolicy::Snapshot` 按渠道间隔发送最新运行态;`FinalOnly` 忽略运行态,只处理终态。终态绕过节流并只对明确的瞬态错误重试。
|
- `LivePolicy::Snapshot` 按渠道间隔发送最新运行态;`FinalOnly` 忽略运行态,只处理终态。终态绕过节流并只对明确的瞬态错误重试。
|
||||||
|
- `TurnDeliveryService` 返回可等待的终态句柄;sink 生命周期启动不等于终态已送达。Session 在终态重试最终失败时通过普通出站路径兜底一次。
|
||||||
- `cli_chat` 将同一 `turn_updated` 快照发给 TUI 和 WebUI。客户端只保留当前 session 中 revision 更新的 `active_turn`,终态随后由持久化历史校准。
|
- `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 终态与独立消息并发写入同一目标。
|
- DeliveryCoordinator 与 OutboundDispatcher 共享 `(channel, chat_id)` 写锁,避免活动 Turn 终态与独立消息并发写入同一目标。
|
||||||
|
|
||||||
### 出站投递
|
### 出站投递
|
||||||
@ -153,7 +161,7 @@ sequenceDiagram
|
|||||||
|
|
||||||
### Control 消息
|
### 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 时才允许更新界面。
|
TUI 的历史回放同样走 control 队列:`get_session_history` 先校验 session 属于当前客户端 scope,再由 SessionManager 从 Storage 读取最近消息。单次查询限制为 1–2000 条,TUI 默认请求最近 1000 条;迟到的历史响应只有在目标仍是当前 dialog 时才允许更新界面。
|
||||||
|
|
||||||
@ -181,7 +189,9 @@ Session ID 格式为:
|
|||||||
5. 持久化写入由 `persistence_lock` 串行化;多条相关记录应使用 Storage 的原子接口。
|
5. 持久化写入由 `persistence_lock` 串行化;多条相关记录应使用 Storage 的原子接口。
|
||||||
6. 内存先变更但持久化失败时,必须回滚精确匹配的消息后缀,不能删除无关的新状态。
|
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 匹配时才允许提交。
|
每个 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。
|
- 5 秒 busy timeout。
|
||||||
- schema version 迁移。
|
- 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/迁移逻辑。
|
1. 更新集中式 schema/迁移逻辑。
|
||||||
2. 保留已有数据库的升级路径。
|
2. 保留已有数据库的升级路径。
|
||||||
@ -211,7 +221,7 @@ SessionManager 负责组装会话上下文:系统提示、Skills、召回的 K
|
|||||||
|
|
||||||
## 7. 后台任务与生命周期
|
## 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
|
### 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 上下文会为所有媒体注入内部路径清单,客户端响应不得暴露该路径。
|
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`。媒体加载、格式或能力检查失败必须降级成文本,不得使历史记录不可读取。
|
工具默认通过 `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/*` 管理接口只提供显式白名单能力:
|
同源 `/api/*` 管理接口只提供显式白名单能力:
|
||||||
|
|
||||||
@ -259,7 +269,7 @@ WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输,WebSocket 只
|
|||||||
3. 初始化 SQLite、MemoryManager、MessageBus 和 SessionManager;Scheduler 启用时幂等创建默认日常维护巡检。
|
3. 初始化 SQLite、MemoryManager、MessageBus 和 SessionManager;Scheduler 启用时幂等创建默认日常维护巡检。
|
||||||
4. 注册内置工具、渠道、MCP 工具和 Cron 工具。
|
4. 注册内置工具、渠道、MCP 工具和 Cron 工具。
|
||||||
5. 启动所有 Channel。
|
5. 启动所有 Channel。
|
||||||
6. 通过 TaskSupervisor 启动 message processor、dispatcher 和 scheduler。
|
6. 通过 TaskSupervisor 启动 inbound/control routers、dispatcher 和 scheduler。
|
||||||
7. 注册 WebUI 静态资源、管理 API 与聊天 WebSocket 路由。
|
7. 注册 WebUI 静态资源、管理 API 与聊天 WebSocket 路由。
|
||||||
8. 绑定 Axum listener,开始接收请求。
|
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/db-schema.md` | 数据库表结构与运行约束:sessions、messages、memories、scheduled_jobs、job_runs、llm_calls、background_tasks |
|
||||||
| `references/architecture.md` | 核心架构:消息并发、会话系统、持久化、生命周期、上下文压缩、记忆、MCP、子 Agent |
|
| `references/architecture.md` | 核心架构:消息并发、会话系统、持久化、生命周期、上下文压缩、记忆、MCP、子 Agent |
|
||||||
| `references/faq.md` | 常见问题:模型切换、渠道添加、Skill 安装、历史查询、定时任务、MCP 等 |
|
| `references/faq.md` | 常见问题:模型切换、渠道添加、Skill 安装、历史查询、定时任务、MCP 等 |
|
||||||
| `references/commands.md` | 常用命令:编译、启动网关、启动客户端、运行测试 |
|
| `references/commands.md` | 常用命令:编译、启动网关、Docker/WebUI 设备配对、启动客户端、运行测试 |
|
||||||
| `references/tools.md` | 内置工具名称、参数和重要使用约束 |
|
| `references/tools.md` | 内置工具名称、参数和重要使用约束 |
|
||||||
| `assets/config.example.json` | config.json 完整示例 |
|
| `assets/config.example.json` | config.json 完整示例 |
|
||||||
|
|
||||||
|
|||||||
@ -60,9 +60,16 @@
|
|||||||
"app_id": "<FEISHU_APP_ID>",
|
"app_id": "<FEISHU_APP_ID>",
|
||||||
"app_secret": "<FEISHU_APP_SECRET>",
|
"app_secret": "<FEISHU_APP_SECRET>",
|
||||||
"allow_from": ["*"],
|
"allow_from": ["*"],
|
||||||
|
"require_mention": true,
|
||||||
"agent": "default",
|
"agent": "default",
|
||||||
"media_dir": "~/.picobot/media/feishu",
|
"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": {
|
"memory": {
|
||||||
|
|||||||
@ -22,6 +22,12 @@ picobot pair
|
|||||||
# 撤销全部设备并生成新配对码
|
# 撤销全部设备并生成新配对码
|
||||||
picobot pair --revoke-all
|
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+)
|
# 修改 WebUI 后独立检查(Node.js 20+)
|
||||||
cd webui
|
cd webui
|
||||||
npm ci
|
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`。
|
`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。
|
最终用户使用 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_id` | string | - | 飞书应用 ID |
|
||||||
| `app_secret` | string | - | 飞书应用密钥 |
|
| `app_secret` | string | - | 飞书应用密钥 |
|
||||||
| `allow_from` | []string | ["*"] | 允许交互的用户列表 |
|
| `allow_from` | []string | ["*"] | 允许交互的用户列表 |
|
||||||
|
| `require_mention` | bool | true | 群聊中是否必须明确 @ 机器人;无法解析机器人身份时安全地忽略群消息 |
|
||||||
| `agent` | string | - | 使用的 agent 名称 |
|
| `agent` | string | - | 使用的 agent 名称 |
|
||||||
| `media_dir` | string | ~/.picobot/media/feishu | 配置默认值;Gateway 注册渠道时会覆盖为 `{workspace}/media/feishu` |
|
| `media_dir` | string | ~/.picobot/media/feishu | 配置默认值;Gateway 注册渠道时会覆盖为 `{workspace}/media/feishu` |
|
||||||
| `reaction_emoji` | string | "Typing" | 回复意向表达的表情 |
|
| `reaction_emoji` | string | "Typing" | 回复意向表达的表情 |
|
||||||
| `live_updates` | bool | false | 是否用单张卡片实时编辑活动 Turn;关闭时只发送终态 |
|
| `live_updates` | bool | false | 是否用单张卡片实时编辑活动 Turn;关闭时只发送终态 |
|
||||||
| `live_update_interval_ms` | int | 500 | 卡片更新最小间隔,运行时限制在 250–5000ms |
|
| `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 生效。
|
飞书属于外部渠道:无论是否开启实时卡片,都不会接收模型 reasoning;工具只显示紧凑状态。配置修改需重启 Gateway 生效。
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,16 @@
|
|||||||
|
|
||||||
内置 Skill 只在目标目录不存在时释放,不会覆盖已安装目录。升级 PicoBot 后如需获取新版内置文档,应先备份自己的修改,再删除旧的 `~/.picobot/skills/about-picobot/` 并重启。也可把定制版放在 `{workspace}/skills/about-picobot/`,它的优先级更高。
|
内置 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: 数据库文件在哪里?
|
## Q: 数据库文件在哪里?
|
||||||
|
|
||||||
默认 `{workspace}/picobot.db`,workspace 默认 `~/.picobot/workspace/`。
|
默认 `{workspace}/picobot.db`,workspace 默认 `~/.picobot/workspace/`。
|
||||||
|
|||||||
@ -68,11 +68,16 @@
|
|||||||
"app_id": "<FEISHU_APP_ID>",
|
"app_id": "<FEISHU_APP_ID>",
|
||||||
"app_secret": "<FEISHU_APP_SECRET>",
|
"app_secret": "<FEISHU_APP_SECRET>",
|
||||||
"allow_from": ["*"],
|
"allow_from": ["*"],
|
||||||
|
"require_mention": true,
|
||||||
"agent": "default",
|
"agent": "default",
|
||||||
"media_dir": "~/.picobot/media/feishu",
|
"media_dir": "~/.picobot/media/feishu",
|
||||||
"reaction_emoji": "Typing",
|
"reaction_emoji": "Typing",
|
||||||
"live_updates": false,
|
"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": {
|
"memory": {
|
||||||
|
|||||||
@ -305,7 +305,6 @@ pub struct AgentLoop {
|
|||||||
workspace_dir: PathBuf,
|
workspace_dir: PathBuf,
|
||||||
model_name: String,
|
model_name: String,
|
||||||
context_window: usize,
|
context_window: usize,
|
||||||
notify_tx: Option<tokio::sync::mpsc::UnboundedSender<String>>,
|
|
||||||
input_types: Vec<String>,
|
input_types: Vec<String>,
|
||||||
media_registry: MediaHandlerRegistry,
|
media_registry: MediaHandlerRegistry,
|
||||||
}
|
}
|
||||||
@ -356,7 +355,6 @@ impl AgentLoop {
|
|||||||
provider: Arc::from(provider),
|
provider: Arc::from(provider),
|
||||||
tools: Arc::new(ToolRegistry::new()),
|
tools: Arc::new(ToolRegistry::new()),
|
||||||
observer: None,
|
observer: None,
|
||||||
notify_tx: None,
|
|
||||||
context_window: 0,
|
context_window: 0,
|
||||||
max_iterations,
|
max_iterations,
|
||||||
workspace_dir,
|
workspace_dir,
|
||||||
@ -382,7 +380,6 @@ impl AgentLoop {
|
|||||||
provider: Arc::from(provider),
|
provider: Arc::from(provider),
|
||||||
tools,
|
tools,
|
||||||
observer: None,
|
observer: None,
|
||||||
notify_tx: None,
|
|
||||||
context_window: 0,
|
context_window: 0,
|
||||||
max_iterations,
|
max_iterations,
|
||||||
workspace_dir,
|
workspace_dir,
|
||||||
@ -404,7 +401,6 @@ impl AgentLoop {
|
|||||||
provider,
|
provider,
|
||||||
tools: Arc::new(ToolRegistry::new()),
|
tools: Arc::new(ToolRegistry::new()),
|
||||||
observer: None,
|
observer: None,
|
||||||
notify_tx: None,
|
|
||||||
context_window: 0,
|
context_window: 0,
|
||||||
max_iterations,
|
max_iterations,
|
||||||
workspace_dir,
|
workspace_dir,
|
||||||
@ -427,7 +423,6 @@ impl AgentLoop {
|
|||||||
provider,
|
provider,
|
||||||
tools,
|
tools,
|
||||||
observer: None,
|
observer: None,
|
||||||
notify_tx: None,
|
|
||||||
context_window: 0,
|
context_window: 0,
|
||||||
max_iterations,
|
max_iterations,
|
||||||
workspace_dir,
|
workspace_dir,
|
||||||
@ -455,11 +450,6 @@ impl AgentLoop {
|
|||||||
self
|
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
|
/// Preemptive trim: truncate old tool results in-place when history is
|
||||||
/// approaching the context window limit. Old results (outside of `keep_recent`
|
/// approaching the context window limit. Old results (outside of `keep_recent`
|
||||||
/// zone) are replaced with a short placeholder; recent results are truncated
|
/// 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}")))?;
|
.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
|
let tools_info: Vec<String> = response
|
||||||
.tool_calls
|
.tool_calls
|
||||||
@ -726,9 +717,6 @@ impl AgentLoop {
|
|||||||
.map(|tc| {
|
.map(|tc| {
|
||||||
let args = serde_json::to_string(&tc.arguments).unwrap_or_default();
|
let args = serde_json::to_string(&tc.arguments).unwrap_or_default();
|
||||||
let s = format!("{}:{}", tc.name, args);
|
let s = format!("{}:{}", tc.name, args);
|
||||||
if let Some(ref tx) = self.notify_tx {
|
|
||||||
let _ = tx.send(format!("调用工具 {}", s));
|
|
||||||
}
|
|
||||||
s
|
s
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@ -154,6 +154,8 @@ pub struct ChatMessage {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum SourceKind {
|
pub enum SourceKind {
|
||||||
|
#[serde(rename = "user_input")]
|
||||||
|
UserInput,
|
||||||
#[serde(rename = "system_notification")]
|
#[serde(rename = "system_notification")]
|
||||||
SystemNotification,
|
SystemNotification,
|
||||||
#[serde(rename = "cross_channel")]
|
#[serde(rename = "cross_channel")]
|
||||||
@ -364,18 +366,48 @@ mod conversation_message_tests {
|
|||||||
// InboundMessage - Message from Channel to Bus (user input)
|
// 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct InboundMessage {
|
pub struct InboundMessage {
|
||||||
pub channel: String,
|
pub channel: String,
|
||||||
pub sender_id: String,
|
pub sender_id: String,
|
||||||
pub chat_id: String,
|
pub chat_id: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub timestamp: i64,
|
pub received_at: i64,
|
||||||
pub media: Vec<MediaItem>,
|
pub media: Vec<MediaItem>,
|
||||||
/// Channel-specific data used internally by the channel (not forwarded).
|
pub channel_context: ChannelContext,
|
||||||
pub metadata: HashMap<String, String>,
|
|
||||||
/// Data forwarded from inbound to outbound (copied to OutboundMessage.metadata by gateway).
|
|
||||||
pub forwarded_metadata: HashMap<String, String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -3,8 +3,9 @@ pub mod message;
|
|||||||
|
|
||||||
pub use dispatcher::OutboundDispatcher;
|
pub use dispatcher::OutboundDispatcher;
|
||||||
pub use message::{
|
pub use message::{
|
||||||
ChatMessage, CompletionStatus, ContentBlock, ControlMessage, InboundMessage, MediaItem,
|
ChannelContext, ChatMessage, CommittedMessage, CommittedTurnDelta, CompletionStatus,
|
||||||
MediaRef, MessageSource, OutboundMessage, ProviderReasoningState, SourceKind,
|
ContentBlock, ControlMessage, InboundMessage, MediaItem, MediaRef, MessageSource,
|
||||||
|
OutboundMessage, ProviderReasoningState, SourceKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|||||||
@ -3,7 +3,7 @@ use std::collections::HashMap;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
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::delivery::PresentationPolicy;
|
||||||
use crate::session::TurnSnapshot;
|
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)
|
/// Send a message to the channel (called by OutboundDispatcher)
|
||||||
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError>;
|
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError>;
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::{Mutex, mpsc};
|
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::gateway::uploads::UploadRegistry;
|
||||||
use crate::protocol::{
|
use crate::protocol::{
|
||||||
HistoryMessage, MessageAttachment, SlashCommandInfo, WsInbound, WsOutbound, parse_inbound,
|
HistoryMessage, MessageAttachment, SlashCommandInfo, WsInbound, WsOutbound, parse_inbound,
|
||||||
@ -211,10 +211,9 @@ impl CliChatChannel {
|
|||||||
sender_id: "cli".to_string(),
|
sender_id: "cli".to_string(),
|
||||||
chat_id: target_chat_id,
|
chat_id: target_chat_id,
|
||||||
content,
|
content,
|
||||||
timestamp: crate::bus::message::current_timestamp(),
|
received_at: crate::bus::message::current_timestamp(),
|
||||||
media,
|
media,
|
||||||
metadata: Default::default(),
|
channel_context: Default::default(),
|
||||||
forwarded_metadata: Default::default(),
|
|
||||||
};
|
};
|
||||||
if let Err(error) = bus.publish_inbound(msg).await {
|
if let Err(error) = bus.publish_inbound(msg).await {
|
||||||
self.uploads.restore(uploads).await;
|
self.uploads.restore(uploads).await;
|
||||||
@ -434,36 +433,7 @@ impl CliChatChannel {
|
|||||||
|| message.tool_calls.is_some()
|
|| message.tool_calls.is_some()
|
||||||
|| message.role == "tool"
|
|| message.role == "tool"
|
||||||
})
|
})
|
||||||
.map(|message| {
|
.map(HistoryMessage::from_message_meta)
|
||||||
let attachments = message
|
|
||||||
.media_refs
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|refs| {
|
|
||||||
serde_json::from_str::<Vec<crate::bus::MediaRef>>(refs).ok()
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(index, media_ref)| {
|
|
||||||
MessageAttachment::from_media_ref(index, media_ref)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
HistoryMessage {
|
|
||||||
id: message.id,
|
|
||||||
seq: message.seq,
|
|
||||||
role: message.role,
|
|
||||||
content: message.content,
|
|
||||||
reasoning_content: message.reasoning_content,
|
|
||||||
completion_status: message.completion_status,
|
|
||||||
created_at: message.created_at,
|
|
||||||
tool_call_id: message.tool_call_id,
|
|
||||||
tool_name: message.tool_name,
|
|
||||||
tool_calls: message
|
|
||||||
.tool_calls
|
|
||||||
.and_then(|calls| serde_json::from_str(&calls).ok()),
|
|
||||||
attachments,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
let _ = client
|
let _ = client
|
||||||
.sender
|
.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> {
|
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
|
||||||
let client = self.clients.lock().await.get(&msg.chat_id).cloned();
|
let client = self.clients.lock().await.get(&msg.chat_id).cloned();
|
||||||
let Some(client) = client else {
|
let Some(client) = client else {
|
||||||
@ -1144,4 +1137,59 @@ mod tests {
|
|||||||
other => panic!("unexpected outbound: {other:?}"),
|
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};
|
pub use crate::protocol::{WsInbound, WsOutbound, serialize_inbound, serialize_outbound};
|
||||||
|
|
||||||
|
mod oneshot;
|
||||||
mod tui;
|
mod tui;
|
||||||
|
|
||||||
|
pub use oneshot::{RunOptions, read_run_prompt, run_once};
|
||||||
|
|
||||||
use crate::client::tui::app::{App, MessageRole};
|
use crate::client::tui::app::{App, MessageRole};
|
||||||
use crate::client::tui::event::{
|
use crate::client::tui::event::{
|
||||||
handle_key_event, handle_paste, request_history, request_session_list, send,
|
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 {
|
match outbound {
|
||||||
WsOutbound::TurnUpdated { snapshot } => {
|
WsOutbound::TurnUpdated { snapshot } => {
|
||||||
let terminal = snapshot.status != crate::session::TurnStatus::Running;
|
let terminal = snapshot.status != crate::session::TurnStatus::Running;
|
||||||
|
let completed = snapshot.status == crate::session::TurnStatus::Completed;
|
||||||
let session_id = snapshot.session_id.clone();
|
let session_id = snapshot.session_id.clone();
|
||||||
if terminal {
|
if terminal {
|
||||||
app.pending_responses = app.pending_responses.saturating_sub(1);
|
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 {
|
if terminal {
|
||||||
app.status_message = None;
|
app.status_message = None;
|
||||||
if app.current_session_id.as_deref() == Some(&session_id) {
|
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 {
|
} else {
|
||||||
app.status_message = Some("另一个会话已完成响应".to_string());
|
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());
|
app.status_message = Some("另一个会话已完成响应".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
WsOutbound::TurnCommitted {
|
||||||
|
session_id,
|
||||||
|
history_revision,
|
||||||
|
messages,
|
||||||
|
} => {
|
||||||
|
app.apply_turn_commit(&session_id, history_revision, messages);
|
||||||
|
}
|
||||||
WsOutbound::AssistantResponse {
|
WsOutbound::AssistantResponse {
|
||||||
id,
|
id,
|
||||||
content,
|
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 selected_session: usize,
|
||||||
pub show_archived: bool,
|
pub show_archived: bool,
|
||||||
pub messages: VecDeque<ChatMessage>,
|
pub messages: VecDeque<ChatMessage>,
|
||||||
|
pub history_revision: i64,
|
||||||
pub active_turn: Option<TurnSnapshot>,
|
pub active_turn: Option<TurnSnapshot>,
|
||||||
pub input: String,
|
pub input: String,
|
||||||
/// UTF-8 byte offset. It is always maintained at a character boundary.
|
/// UTF-8 byte offset. It is always maintained at a character boundary.
|
||||||
@ -99,6 +100,7 @@ impl App {
|
|||||||
selected_session: 0,
|
selected_session: 0,
|
||||||
show_archived: false,
|
show_archived: false,
|
||||||
messages: VecDeque::new(),
|
messages: VecDeque::new(),
|
||||||
|
history_revision: 0,
|
||||||
active_turn: None,
|
active_turn: None,
|
||||||
input: String::new(),
|
input: String::new(),
|
||||||
input_cursor_pos: 0,
|
input_cursor_pos: 0,
|
||||||
@ -151,6 +153,11 @@ impl App {
|
|||||||
if self.current_session_id.as_deref() != Some(session_id) {
|
if self.current_session_id.as_deref() != Some(session_id) {
|
||||||
return;
|
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| {
|
let calibrates_terminal = self.active_turn.as_ref().is_some_and(|turn| {
|
||||||
turn.status != TurnStatus::Running
|
turn.status != TurnStatus::Running
|
||||||
&& messages.iter().any(|message| message.id == turn.message_id)
|
&& messages.iter().any(|message| message.id == turn.message_id)
|
||||||
@ -178,12 +185,65 @@ impl App {
|
|||||||
self.messages.pop_front();
|
self.messages.pop_front();
|
||||||
}
|
}
|
||||||
self.chat_scroll_from_bottom = 0;
|
self.chat_scroll_from_bottom = 0;
|
||||||
|
self.history_revision = history_revision;
|
||||||
self.status_message = None;
|
self.status_message = None;
|
||||||
if calibrates_terminal {
|
if calibrates_terminal {
|
||||||
self.active_turn = None;
|
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>) {
|
pub fn set_sessions(&mut self, sessions: Vec<SessionSummary>) {
|
||||||
self.sessions = sessions;
|
self.sessions = sessions;
|
||||||
if let Some(current) = &self.current_session_id
|
if let Some(current) = &self.current_session_id
|
||||||
@ -202,6 +262,7 @@ impl App {
|
|||||||
self.current_session_id = session_id;
|
self.current_session_id = session_id;
|
||||||
self.messages.clear();
|
self.messages.clear();
|
||||||
self.active_turn = None;
|
self.active_turn = None;
|
||||||
|
self.history_revision = 0;
|
||||||
self.pending_uploads.clear();
|
self.pending_uploads.clear();
|
||||||
self.chat_scroll_from_bottom = 0;
|
self.chat_scroll_from_bottom = 0;
|
||||||
}
|
}
|
||||||
@ -225,7 +286,12 @@ impl App {
|
|||||||
{
|
{
|
||||||
return false;
|
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;
|
self.chat_scroll_from_bottom = 0;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@ -491,6 +557,60 @@ mod tests {
|
|||||||
assert!(app.active_turn.is_none());
|
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]
|
#[test]
|
||||||
fn failed_turn_without_a_durable_message_remains_visible_after_history_refresh() {
|
fn failed_turn_without_a_durable_message_remains_visible_after_history_refresh() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
|||||||
@ -71,6 +71,9 @@ pub struct FeishuChannelConfig {
|
|||||||
pub app_secret: String,
|
pub app_secret: String,
|
||||||
#[serde(default = "default_allow_from")]
|
#[serde(default = "default_allow_from")]
|
||||||
pub allow_from: Vec<String>,
|
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)]
|
#[serde(default)]
|
||||||
pub agent: String,
|
pub agent: String,
|
||||||
#[serde(default = "default_media_dir")]
|
#[serde(default = "default_media_dir")]
|
||||||
@ -83,6 +86,14 @@ pub struct FeishuChannelConfig {
|
|||||||
pub live_updates: bool,
|
pub live_updates: bool,
|
||||||
#[serde(default = "default_feishu_live_update_interval_ms")]
|
#[serde(default = "default_feishu_live_update_interval_ms")]
|
||||||
pub live_update_interval_ms: u64,
|
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> {
|
fn default_allow_from() -> Vec<String> {
|
||||||
@ -104,6 +115,22 @@ fn default_feishu_live_update_interval_ms() -> u64 {
|
|||||||
500
|
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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct ProviderConfig {
|
pub struct ProviderConfig {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
|
|||||||
@ -23,6 +23,7 @@ pub enum DeliveryError {
|
|||||||
OpenFailed(ChannelError),
|
OpenFailed(ChannelError),
|
||||||
SnapshotStreamClosed,
|
SnapshotStreamClosed,
|
||||||
SupervisorStopping,
|
SupervisorStopping,
|
||||||
|
CompletionLost,
|
||||||
FinalTimedOut,
|
FinalTimedOut,
|
||||||
FinalFailed(ChannelError),
|
FinalFailed(ChannelError),
|
||||||
}
|
}
|
||||||
@ -38,6 +39,9 @@ impl std::fmt::Display for DeliveryError {
|
|||||||
Self::SupervisorStopping => {
|
Self::SupervisorStopping => {
|
||||||
formatter.write_str("cannot start turn delivery while Gateway is stopping")
|
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::FinalTimedOut => formatter.write_str("final turn delivery timed out"),
|
||||||
Self::FinalFailed(error) => write!(formatter, "final turn delivery failed: {error}"),
|
Self::FinalFailed(error) => write!(formatter, "final turn delivery failed: {error}"),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,4 +4,4 @@ mod service;
|
|||||||
|
|
||||||
pub use coordinator::{ConversationWriteLocks, DeliveryCoordinator, DeliveryError};
|
pub use coordinator::{ConversationWriteLocks, DeliveryCoordinator, DeliveryError};
|
||||||
pub use policy::{PresentationPolicy, ReasoningVisibility, ToolVisibility, project_snapshot};
|
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 std::sync::Arc;
|
||||||
|
|
||||||
use tokio::sync::watch;
|
use tokio::sync::{oneshot, watch};
|
||||||
|
|
||||||
|
use crate::bus::CommittedTurnDelta;
|
||||||
use crate::channels::{ChannelManager, TurnTarget};
|
use crate::channels::{ChannelManager, TurnTarget};
|
||||||
use crate::delivery::{DeliveryCoordinator, DeliveryError};
|
use crate::delivery::{DeliveryCoordinator, DeliveryError};
|
||||||
use crate::session::TurnSnapshot;
|
use crate::session::TurnSnapshot;
|
||||||
@ -19,6 +20,23 @@ pub struct TurnDeliveryService {
|
|||||||
supervisor: TaskSupervisor,
|
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 {
|
impl TurnDeliveryService {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
coordinator: DeliveryCoordinator,
|
coordinator: DeliveryCoordinator,
|
||||||
@ -36,7 +54,7 @@ impl TurnDeliveryService {
|
|||||||
&self,
|
&self,
|
||||||
target: TurnTarget,
|
target: TurnTarget,
|
||||||
snapshots: watch::Receiver<Arc<TurnSnapshot>>,
|
snapshots: watch::Receiver<Arc<TurnSnapshot>>,
|
||||||
) -> Result<(), DeliveryError> {
|
) -> Result<TurnDeliveryHandle, DeliveryError> {
|
||||||
let channel = self
|
let channel = self
|
||||||
.channels
|
.channels
|
||||||
.get_channel(&target.channel)
|
.get_channel(&target.channel)
|
||||||
@ -48,7 +66,7 @@ impl TurnDeliveryService {
|
|||||||
.open_turn(target.clone())
|
.open_turn(target.clone())
|
||||||
.await
|
.await
|
||||||
.map_err(DeliveryError::OpenFailed)?;
|
.map_err(DeliveryError::OpenFailed)?;
|
||||||
let _result = self.coordinator.spawn_sink(
|
let completion = self.coordinator.spawn_sink(
|
||||||
&self.supervisor,
|
&self.supervisor,
|
||||||
SinkRoute {
|
SinkRoute {
|
||||||
channel: target.channel,
|
channel: target.channel,
|
||||||
@ -59,6 +77,45 @@ impl TurnDeliveryService {
|
|||||||
snapshots,
|
snapshots,
|
||||||
sink,
|
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_TRACKED_CLIENTS: usize = 4096;
|
||||||
const MAX_PAIRED_TOKENS: usize = 128;
|
const MAX_PAIRED_TOKENS: usize = 128;
|
||||||
const AUTH_COOKIE: &str = "picobot_auth";
|
const AUTH_COOKIE: &str = "picobot_auth";
|
||||||
|
pub const ADMIN_TOKEN_HEADER: &str = "X-Picobot-Admin-Token";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
struct AuthStore {
|
struct AuthStore {
|
||||||
@ -65,8 +66,12 @@ pub struct AuthManager {
|
|||||||
state: Arc<Mutex<AuthState>>,
|
state: Arc<Mutex<AuthState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct AuthIdentity(pub Option<String>);
|
pub enum AuthIdentity {
|
||||||
|
PairingDisabled,
|
||||||
|
Paired { token_hash: String },
|
||||||
|
LocalAdmin,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum PairError {
|
pub enum PairError {
|
||||||
@ -110,7 +115,7 @@ impl AuthManager {
|
|||||||
|
|
||||||
pub async fn authenticate(&self, token: Option<&str>) -> Option<AuthIdentity> {
|
pub async fn authenticate(&self, token: Option<&str>) -> Option<AuthIdentity> {
|
||||||
if !self.required {
|
if !self.required {
|
||||||
return Some(AuthIdentity(None));
|
return Some(AuthIdentity::PairingDisabled);
|
||||||
}
|
}
|
||||||
let hash = hash_token(token?);
|
let hash = hash_token(token?);
|
||||||
self.state
|
self.state
|
||||||
@ -118,17 +123,17 @@ impl AuthManager {
|
|||||||
.await
|
.await
|
||||||
.token_hashes
|
.token_hashes
|
||||||
.contains(&hash)
|
.contains(&hash)
|
||||||
.then_some(AuthIdentity(Some(hash)))
|
.then_some(AuthIdentity::Paired { token_hash: hash })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn identity_is_active(&self, identity: &AuthIdentity) -> bool {
|
pub async fn identity_is_active(&self, identity: &AuthIdentity) -> bool {
|
||||||
if !self.required {
|
match identity {
|
||||||
return true;
|
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 {
|
pub fn authenticate_admin(&self, token: Option<&str>) -> bool {
|
||||||
@ -210,9 +215,24 @@ pub async fn require_auth(
|
|||||||
mut request: Request<Body>,
|
mut request: Request<Body>,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let identity = auth
|
let mut identity = auth
|
||||||
.authenticate(token_from_headers(request.headers()))
|
.authenticate(token_from_headers(request.headers()))
|
||||||
.await;
|
.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 {
|
let Some(identity) = identity else {
|
||||||
return (
|
return (
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
@ -314,7 +334,7 @@ pub async fn issue_code(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let admin_token = headers
|
let admin_token = headers
|
||||||
.get("X-Picobot-Admin-Token")
|
.get(ADMIN_TOKEN_HEADER)
|
||||||
.and_then(|value| value.to_str().ok());
|
.and_then(|value| value.to_str().ok());
|
||||||
if !peer.ip().is_loopback() || !state.auth.authenticate_admin(admin_token) {
|
if !peer.ip().is_loopback() || !state.auth.authenticate_admin(admin_token) {
|
||||||
return (
|
return (
|
||||||
@ -488,7 +508,7 @@ async fn load_or_create_admin_token(path: &Path) -> Result<String, std::io::Erro
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use axum::{Router, middleware, routing};
|
use axum::{Extension, Router, middleware, routing};
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@ -593,4 +613,61 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(authorized.status(), StatusCode::OK);
|
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 auth;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
|
mod router;
|
||||||
pub mod uploads;
|
pub mod uploads;
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
|
|
||||||
@ -8,8 +9,7 @@ use std::net::SocketAddr;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
use crate::bus::{ControlMessage, MessageBus, OutboundDispatcher};
|
use crate::bus::{MessageBus, OutboundDispatcher};
|
||||||
use crate::channels::base::ChannelError;
|
|
||||||
use crate::channels::{ChannelManager, CliChatChannel};
|
use crate::channels::{ChannelManager, CliChatChannel};
|
||||||
use crate::config::{Config, ensure_workspace_dir, expand_path};
|
use crate::config::{Config, ensure_workspace_dir, expand_path};
|
||||||
use crate::delivery::{ConversationWriteLocks, DeliveryCoordinator, TurnDeliveryService};
|
use crate::delivery::{ConversationWriteLocks, DeliveryCoordinator, TurnDeliveryService};
|
||||||
@ -276,80 +276,7 @@ impl GatewayState {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Spawn unified message processor
|
router::spawn_message_routers(bus.clone(), session_manager, self.task_supervisor.clone());
|
||||||
// This handles both inbound AI messages and control messages in one loop
|
|
||||||
self.task_supervisor.spawn("message-processor", async move {
|
|
||||||
tracing::info!("Message processor started");
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
// Inbound: AI message flow
|
|
||||||
inbound = bus.consume_inbound() => {
|
|
||||||
let Some(inbound) = inbound else {
|
|
||||||
tracing::warn!("Message processor stopping because inbound bus closed");
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
match session_manager.handle_message(
|
|
||||||
&inbound.channel,
|
|
||||||
&inbound.sender_id,
|
|
||||||
&inbound.chat_id,
|
|
||||||
&inbound.content,
|
|
||||||
inbound.media,
|
|
||||||
inbound.forwarded_metadata.clone(),
|
|
||||||
).await {
|
|
||||||
Ok(crate::session::session::HandleResult::AgentResponse(content)) => {
|
|
||||||
let outbound = crate::bus::OutboundMessage {
|
|
||||||
channel: inbound.channel.clone(),
|
|
||||||
chat_id: inbound.chat_id.clone(),
|
|
||||||
content,
|
|
||||||
reply_to: None,
|
|
||||||
media: vec![],
|
|
||||||
metadata: inbound.forwarded_metadata,
|
|
||||||
delivery: None,
|
|
||||||
};
|
|
||||||
if let Err(e) = bus.publish_outbound(outbound).await {
|
|
||||||
tracing::error!(error = %e, "Failed to publish outbound");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(crate::session::session::HandleResult::CommandOutput(content)) => {
|
|
||||||
let mut metadata = inbound.forwarded_metadata;
|
|
||||||
metadata.insert("_type".to_string(), "command".to_string());
|
|
||||||
let outbound = crate::bus::OutboundMessage {
|
|
||||||
channel: inbound.channel.clone(),
|
|
||||||
chat_id: inbound.chat_id.clone(),
|
|
||||||
content,
|
|
||||||
reply_to: None,
|
|
||||||
media: vec![],
|
|
||||||
metadata,
|
|
||||||
delivery: None,
|
|
||||||
};
|
|
||||||
if let Err(e) = bus.publish_outbound(outbound).await {
|
|
||||||
tracing::error!(error = %e, "Failed to publish outbound");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(crate::session::session::HandleResult::AgentProcessing) => {
|
|
||||||
// Agent is processing in background; response will be
|
|
||||||
// sent via bus directly from the spawned task.
|
|
||||||
// The select loop remains free to handle subsequent
|
|
||||||
// messages (including slash commands).
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(error = %e, "Failed to handle message");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Control: session management operations
|
|
||||||
msg = bus.consume_control() => {
|
|
||||||
let Some(msg) = msg else {
|
|
||||||
tracing::warn!("Message processor stopping because control bus closed");
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
Self::handle_control_message(&session_manager, msg).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Spawn outbound dispatcher
|
// Spawn outbound dispatcher
|
||||||
let dispatcher = OutboundDispatcher::new(
|
let dispatcher = OutboundDispatcher::new(
|
||||||
@ -379,112 +306,6 @@ impl GatewayState {
|
|||||||
tracing::info!("Scheduler background task spawned");
|
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(
|
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)]
|
#[derive(Parser)]
|
||||||
#[command(name = "picobot")]
|
#[command(name = "picobot")]
|
||||||
#[command(about = "A CLI chatbot", long_about = None)]
|
#[command(about = "A CLI chatbot", long_about = None)]
|
||||||
#[command(version = "1.1.1")]
|
#[command(version)]
|
||||||
enum Command {
|
enum Command {
|
||||||
/// Connect to gateway
|
/// Connect to gateway
|
||||||
Chat {
|
Chat {
|
||||||
@ -30,6 +30,23 @@ enum Command {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pair_code: Option<String>,
|
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
|
/// Start gateway server
|
||||||
Gateway {
|
Gateway {
|
||||||
/// Host to bind to
|
/// 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());
|
.unwrap_or_else(|| "ws://127.0.0.1:19876/ws".to_string());
|
||||||
picobot::client::run(&url, pair_code.as_deref()).await?;
|
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 } => {
|
Command::Gateway { host, port } => {
|
||||||
picobot::gateway::run(host, port).await?;
|
picobot::gateway::run(host, port).await?;
|
||||||
}
|
}
|
||||||
@ -101,7 +144,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
})?;
|
})?;
|
||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.post(endpoint)
|
.post(endpoint)
|
||||||
.header("X-Picobot-Admin-Token", admin_token.trim())
|
.header(
|
||||||
|
picobot::gateway::auth::ADMIN_TOKEN_HEADER,
|
||||||
|
admin_token.trim(),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
|
|||||||
@ -77,6 +77,59 @@ pub struct HistoryMessage {
|
|||||||
pub attachments: Vec<MessageAttachment>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum WsInbound {
|
pub enum WsInbound {
|
||||||
@ -148,6 +201,12 @@ pub enum WsOutbound {
|
|||||||
TurnUpdated {
|
TurnUpdated {
|
||||||
snapshot: crate::session::TurnSnapshot,
|
snapshot: crate::session::TurnSnapshot,
|
||||||
},
|
},
|
||||||
|
#[serde(rename = "turn_committed")]
|
||||||
|
TurnCommitted {
|
||||||
|
session_id: String,
|
||||||
|
history_revision: i64,
|
||||||
|
messages: Vec<HistoryMessage>,
|
||||||
|
},
|
||||||
#[serde(rename = "assistant_response")]
|
#[serde(rename = "assistant_response")]
|
||||||
AssistantResponse {
|
AssistantResponse {
|
||||||
id: String,
|
id: String,
|
||||||
@ -259,6 +318,32 @@ mod tests {
|
|||||||
assert_eq!(value["snapshot"]["status"], "running");
|
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]
|
#[test]
|
||||||
fn history_defaults_new_reasoning_fields_for_old_frames() {
|
fn history_defaults_new_reasoning_fields_for_old_frames() {
|
||||||
let message: HistoryMessage = serde_json::from_value(serde_json::json!({
|
let message: HistoryMessage = serde_json::from_value(serde_json::json!({
|
||||||
|
|||||||
@ -3,6 +3,7 @@ pub mod error;
|
|||||||
pub mod events;
|
pub mod events;
|
||||||
mod messenger;
|
mod messenger;
|
||||||
mod persistence;
|
mod persistence;
|
||||||
|
mod turn_input;
|
||||||
// The public `session::session` path is retained for API compatibility.
|
// The public `session::session` path is retained for API compatibility.
|
||||||
#[allow(clippy::module_inception)]
|
#[allow(clippy::module_inception)]
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|||||||
@ -44,26 +44,40 @@ pub(super) async fn append_persisted_messages(
|
|||||||
session: &Arc<Mutex<Session>>,
|
session: &Arc<Mutex<Session>>,
|
||||||
messages: Vec<ChatMessage>,
|
messages: Vec<ChatMessage>,
|
||||||
) -> Result<(), StorageError> {
|
) -> 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() {
|
if messages.is_empty() {
|
||||||
return Ok(());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let persistence_lock = { session.lock().await.persistence_lock.clone() };
|
let persistence_lock = { session.lock().await.persistence_lock.clone() };
|
||||||
let _persistence_guard = persistence_lock.lock().await;
|
let _persistence_guard = persistence_lock.lock().await;
|
||||||
let message_ids: Vec<_> = messages.iter().map(|message| message.id.clone()).collect();
|
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;
|
let mut guard = session.lock().await;
|
||||||
messages
|
messages
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|message| guard.add_message_in_memory(message, true))
|
.map(|message| guard.add_message_in_memory(message, true))
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
let committed = snapshots
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.map(|(_, _, message, _)| message.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
if let Err(error) = persist_added_messages(snapshots).await {
|
if let Err(error) = persist_added_messages(snapshots).await {
|
||||||
session.lock().await.rollback_message_suffix(&message_ids);
|
session.lock().await.rollback_message_suffix(&message_ids);
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(committed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish `Completed` only after the supplied durable write succeeds.
|
/// Publish `Completed` only after the supplied durable write succeeds.
|
||||||
|
|||||||
@ -3,10 +3,14 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use tokio::sync::{Mutex, mpsc, oneshot};
|
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::{TurnBlock, TurnController, TurnSnapshot};
|
||||||
|
use super::turn_input::prepare_turn_input;
|
||||||
use crate::bus::{
|
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::mcp::get_mcp_status;
|
||||||
use crate::storage::{Storage, StorageError};
|
use crate::storage::{Storage, StorageError};
|
||||||
@ -27,13 +31,46 @@ fn outbound_session_metadata(session_id: &str) -> HashMap<String, String> {
|
|||||||
|
|
||||||
fn outbound_turn_metadata(
|
fn outbound_turn_metadata(
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
forwarded: &HashMap<String, String>,
|
private_context: &HashMap<String, String>,
|
||||||
) -> 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.insert("_session_id".to_string(), session_id.to_string());
|
||||||
metadata
|
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! {
|
tokio::task_local! {
|
||||||
pub(super) static CURRENT_SOURCE_SESSION: Option<String>;
|
pub(super) static CURRENT_SOURCE_SESSION: Option<String>;
|
||||||
}
|
}
|
||||||
@ -48,12 +85,12 @@ pub enum HandleResult {
|
|||||||
AgentProcessing,
|
AgentProcessing,
|
||||||
}
|
}
|
||||||
use crate::agent::context_compressor::ContextCompressionConfig;
|
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::agent::{AgentError, AgentLoop, AgentTurnContext, ContextCompressor, TurnEmitter};
|
||||||
use crate::channels::slash_command::parse_slash_command;
|
use crate::channels::slash_command::parse_slash_command;
|
||||||
use crate::config::BrowserConfig;
|
use crate::config::BrowserConfig;
|
||||||
use crate::config::LLMProviderConfig;
|
use crate::config::LLMProviderConfig;
|
||||||
use crate::delivery::TurnDeliveryService;
|
use crate::delivery::{TurnDeliveryHandle, TurnDeliveryService};
|
||||||
|
|
||||||
/// Check if an LLM error message indicates a context window overflow.
|
/// Check if an LLM error message indicates a context window overflow.
|
||||||
fn is_context_overflow_error(msg: &str) -> bool {
|
fn is_context_overflow_error(msg: &str) -> bool {
|
||||||
@ -102,6 +139,43 @@ fn partial_assistant_message(
|
|||||||
Some(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(
|
async fn fail_turn_with_partial(
|
||||||
controller: &TurnController,
|
controller: &TurnController,
|
||||||
session: &Arc<Mutex<Session>>,
|
session: &Arc<Mutex<Session>>,
|
||||||
@ -125,6 +199,39 @@ async fn fail_turn_with_partial(
|
|||||||
mod cancelled_partial_tests {
|
mod cancelled_partial_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::agent::TurnEvent;
|
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]
|
#[test]
|
||||||
fn turn_metadata_preserves_channel_cleanup_fields() {
|
fn turn_metadata_preserves_channel_cleanup_fields() {
|
||||||
@ -184,6 +291,70 @@ mod cancelled_partial_tests {
|
|||||||
assert_eq!(message.completion_status, CompletionStatus::Cancelled);
|
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]
|
#[test]
|
||||||
fn reasoning_only_cancel_does_not_create_assistant_history() {
|
fn reasoning_only_cancel_does_not_create_assistant_history() {
|
||||||
let (controller, emitter, _) = TurnController::start("session", "message-id");
|
let (controller, emitter, _) = TurnController::start("session", "message-id");
|
||||||
@ -242,6 +413,8 @@ pub struct Session {
|
|||||||
active_turn_emitter: Option<ActiveTurnEmitter>,
|
active_turn_emitter: Option<ActiveTurnEmitter>,
|
||||||
/// Monotonic counter to detect stale workers
|
/// Monotonic counter to detect stale workers
|
||||||
worker_generation: u64,
|
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.
|
/// Monotonic counter for in-memory session mutations.
|
||||||
///
|
///
|
||||||
/// Slow work such as memory recall, compression, and title generation runs
|
/// 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
|
/// A task to be processed by the per-session agent worker
|
||||||
struct AgentTask {
|
struct AgentTask {
|
||||||
channel: String,
|
channel: String,
|
||||||
|
sender_id: String,
|
||||||
chat_id: String,
|
chat_id: String,
|
||||||
content: String,
|
content: String,
|
||||||
|
received_at: i64,
|
||||||
media: Vec<MediaItem>,
|
media: Vec<MediaItem>,
|
||||||
forwarded_metadata: HashMap<String, String>,
|
channel_context: ChannelContext,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@ -334,6 +509,7 @@ impl Session {
|
|||||||
current_cancel: None,
|
current_cancel: None,
|
||||||
active_turn_emitter: None,
|
active_turn_emitter: None,
|
||||||
worker_generation: 0,
|
worker_generation: 0,
|
||||||
|
title_generation_in_flight: false,
|
||||||
state_version: 0,
|
state_version: 0,
|
||||||
persistence_lock: Arc::new(Mutex::new(())),
|
persistence_lock: Arc::new(Mutex::new(())),
|
||||||
})
|
})
|
||||||
@ -523,6 +699,7 @@ impl Session {
|
|||||||
current_cancel: None,
|
current_cancel: None,
|
||||||
active_turn_emitter: None,
|
active_turn_emitter: None,
|
||||||
worker_generation: 0,
|
worker_generation: 0,
|
||||||
|
title_generation_in_flight: false,
|
||||||
state_version: 0,
|
state_version: 0,
|
||||||
persistence_lock: Arc::new(Mutex::new(())),
|
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(
|
pub fn create_user_message_with_source(
|
||||||
&self,
|
&self,
|
||||||
content: &str,
|
content: &str,
|
||||||
@ -794,14 +959,6 @@ impl Session {
|
|||||||
.with_context_window(self.provider_config.token_limit))
|
.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)
|
/// 构建系统提示词(包含 AgentLoop 的基础提示词 + skills + memory)
|
||||||
pub fn build_system_prompt(&self, skills_prompt: &str) -> String {
|
pub fn build_system_prompt(&self, skills_prompt: &str) -> String {
|
||||||
let base_prompt = build_system_prompt(
|
let base_prompt = build_system_prompt(
|
||||||
@ -2205,13 +2362,12 @@ impl SessionManager {
|
|||||||
|
|
||||||
pub async fn handle_message(
|
pub async fn handle_message(
|
||||||
&self,
|
&self,
|
||||||
channel: &str,
|
inbound: &InboundMessage,
|
||||||
_sender_id: &str,
|
|
||||||
chat_id: &str,
|
|
||||||
content: &str,
|
|
||||||
media: Vec<MediaItem>,
|
|
||||||
forwarded_metadata: HashMap<String, String>,
|
|
||||||
) -> Result<HandleResult, AgentError> {
|
) -> 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?;
|
let unified_id = self.resolve_dialog_id(channel, chat_id).await?;
|
||||||
tracing::debug!(unified_id = %unified_id, "handle_message resolved unified_id");
|
tracing::debug!(unified_id = %unified_id, "handle_message resolved unified_id");
|
||||||
let session = self.get_or_create_session(&unified_id).await?;
|
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.
|
// Normal message: enqueue to per-session worker for serial processing.
|
||||||
let task = AgentTask {
|
let task = AgentTask {
|
||||||
channel: channel.to_string(),
|
channel: channel.to_string(),
|
||||||
|
sender_id: sender_id.to_string(),
|
||||||
chat_id: chat_id.to_string(),
|
chat_id: chat_id.to_string(),
|
||||||
content: content.to_string(),
|
content: content.to_string(),
|
||||||
media,
|
received_at: inbound.received_at,
|
||||||
forwarded_metadata,
|
media: inbound.media.clone(),
|
||||||
|
channel_context: inbound.channel_context.clone(),
|
||||||
};
|
};
|
||||||
let session_clone = session.clone();
|
let session_clone = session.clone();
|
||||||
let unified_str = unified_id.to_string();
|
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};
|
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 {
|
let request = ChatCompletionRequest {
|
||||||
messages: vec![Message::user(prompt)],
|
messages: vec![Message::user(prompt)],
|
||||||
temperature: Some(0.3),
|
temperature: Some(0.3),
|
||||||
@ -2365,6 +2519,39 @@ async fn maybe_generate_title_outside_lock(session: Arc<Mutex<Session>>) -> Resu
|
|||||||
Ok(())
|
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(
|
fn spawn_agent_worker(
|
||||||
mut task_rx: mpsc::Receiver<AgentTask>,
|
mut task_rx: mpsc::Receiver<AgentTask>,
|
||||||
session: Arc<Mutex<Session>>,
|
session: Arc<Mutex<Session>>,
|
||||||
@ -2387,41 +2574,8 @@ fn spawn_agent_worker(
|
|||||||
'tasks: while let Some(task) = task_rx.recv().await {
|
'tasks: while let Some(task) = task_rx.recv().await {
|
||||||
let task_chan = task.channel.clone();
|
let task_chan = task.channel.clone();
|
||||||
let task_cid = task.chat_id.clone();
|
let task_cid = task.chat_id.clone();
|
||||||
let task_metadata = task.forwarded_metadata.clone();
|
let task_metadata = task.channel_context.private.clone();
|
||||||
let notification_session_id = unified_str.clone();
|
let task_reply_to = task.channel_context.reply_to.clone();
|
||||||
|
|
||||||
let (notify_tx, mut notify_rx) = mpsc::unbounded_channel();
|
|
||||||
|
|
||||||
// Spawn notification publisher
|
|
||||||
{
|
|
||||||
let bus = bus.clone();
|
|
||||||
let ch = task_chan.clone();
|
|
||||||
let cid = task_cid.clone();
|
|
||||||
worker_supervisor.spawn(
|
|
||||||
format!("session-notifications:{ch}:{cid}"),
|
|
||||||
async move {
|
|
||||||
while let Some(notif) = notify_rx.recv().await {
|
|
||||||
let mut metadata = HashMap::new();
|
|
||||||
metadata.insert("_type".to_string(), "notification".to_string());
|
|
||||||
metadata.insert(
|
|
||||||
"_session_id".to_string(),
|
|
||||||
notification_session_id.clone(),
|
|
||||||
);
|
|
||||||
let outbound = OutboundMessage {
|
|
||||||
channel: ch.clone(),
|
|
||||||
chat_id: cid.clone(),
|
|
||||||
content: notif,
|
|
||||||
reply_to: None,
|
|
||||||
media: vec![],
|
|
||||||
metadata,
|
|
||||||
delivery: None,
|
|
||||||
};
|
|
||||||
let _ = bus.publish_outbound(outbound).await;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 1: capture a stable session snapshot under lock.
|
// Phase 1: capture a stable session snapshot under lock.
|
||||||
// Memory recall and compression happen outside this block so
|
// Memory recall and compression happen outside this block so
|
||||||
// /stop and other commands are not blocked behind slow I/O or
|
// /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> =
|
let media_refs: Vec<MediaRef> =
|
||||||
task.media.iter().map(MediaItem::to_media_ref).collect();
|
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 {
|
if let Err(e) = append_persisted_messages(&session, vec![user_message]).await {
|
||||||
tracing::error!(error = %e, "Failed to persist user message");
|
tracing::error!(error = %e, "Failed to persist user message");
|
||||||
@ -2442,7 +2607,7 @@ fn spawn_agent_worker(
|
|||||||
channel: task_chan.clone(),
|
channel: task_chan.clone(),
|
||||||
chat_id: task_cid.clone(),
|
chat_id: task_cid.clone(),
|
||||||
content: "Failed to save your message, please try again.".to_string(),
|
content: "Failed to save your message, please try again.".to_string(),
|
||||||
reply_to: None,
|
reply_to: task_reply_to.clone(),
|
||||||
media: vec![],
|
media: vec![],
|
||||||
metadata: outbound_turn_metadata(&unified_str, &task_metadata),
|
metadata: outbound_turn_metadata(&unified_str, &task_metadata),
|
||||||
delivery: None,
|
delivery: None,
|
||||||
@ -2451,7 +2616,14 @@ fn spawn_agent_worker(
|
|||||||
continue 'tasks;
|
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;
|
let mut guard = session.lock().await;
|
||||||
|
|
||||||
if guard.worker_generation != worker_gen {
|
if guard.worker_generation != worker_gen {
|
||||||
@ -2460,7 +2632,7 @@ fn spawn_agent_worker(
|
|||||||
|
|
||||||
let history_raw = guard.get_history().to_vec();
|
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,
|
Ok(a) => a,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(error = %e, "Failed to create agent");
|
tracing::error!(error = %e, "Failed to create agent");
|
||||||
@ -2469,7 +2641,7 @@ fn spawn_agent_worker(
|
|||||||
chat_id: task_cid.clone(),
|
chat_id: task_cid.clone(),
|
||||||
content: "Agent creation failed, please try again."
|
content: "Agent creation failed, please try again."
|
||||||
.to_string(),
|
.to_string(),
|
||||||
reply_to: None,
|
reply_to: task_reply_to.clone(),
|
||||||
media: vec![],
|
media: vec![],
|
||||||
metadata: outbound_turn_metadata(&unified_str, &task_metadata),
|
metadata: outbound_turn_metadata(&unified_str, &task_metadata),
|
||||||
delivery: None,
|
delivery: None,
|
||||||
@ -2490,100 +2662,49 @@ fn spawn_agent_worker(
|
|||||||
agent,
|
agent,
|
||||||
history_raw,
|
history_raw,
|
||||||
guard.fresh_context_compressor(),
|
guard.fresh_context_compressor(),
|
||||||
|
guard.build_system_prompt(&skills_prompt),
|
||||||
guard.state_version,
|
guard.state_version,
|
||||||
cancel_rx,
|
cancel_rx,
|
||||||
)
|
)
|
||||||
}; // lock released
|
}; // lock released
|
||||||
|
|
||||||
let memory_context = match memory_manager
|
let prepared_input = prepare_turn_input(
|
||||||
.recall(
|
memory_manager.clone(),
|
||||||
&task.content,
|
work_manager.clone(),
|
||||||
5,
|
&unified_str,
|
||||||
Some(crate::memory::MemoryCategory::Knowledge),
|
&task.content,
|
||||||
None,
|
system_prompt_out,
|
||||||
)
|
&mut compressor,
|
||||||
.await
|
history_raw,
|
||||||
{
|
)
|
||||||
Ok(entries) if !entries.is_empty() => Some(
|
.await;
|
||||||
entries
|
let meta_snapshot = {
|
||||||
.iter()
|
let mut guard = session.lock().await;
|
||||||
.map(|e| format!("- {}: {}", e.key, e.content))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n"),
|
|
||||||
),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = %e, "Failed to fetch memory context");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let work_context = match work_manager.active_plan(&unified_str).await {
|
|
||||||
Ok(Some(plan)) => Some(plan.compact_context()),
|
|
||||||
Ok(None) => None,
|
|
||||||
Err(error) => {
|
|
||||||
tracing::warn!(error = %error, "Failed to load active task plan");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let runtime_context = build_runtime_context(
|
|
||||||
Some(unified_str.as_str()),
|
|
||||||
memory_context.as_deref(),
|
|
||||||
work_context.as_deref(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let system_prompt_out = {
|
|
||||||
let guard = session.lock().await;
|
|
||||||
if guard.worker_generation != worker_gen {
|
if guard.worker_generation != worker_gen {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
guard.build_system_prompt(&skills_prompt)
|
if guard.state_version != base_version {
|
||||||
};
|
tracing::warn!(
|
||||||
|
session_id = %guard.id,
|
||||||
let compression_result = compressor.compress_if_needed(history_raw).await;
|
"Session changed while preparing agent history; dropping stale task"
|
||||||
let mut history_out = match compression_result {
|
);
|
||||||
Ok(result) => {
|
guard.current_cancel = None;
|
||||||
let meta_snapshot = {
|
continue 'tasks;
|
||||||
let mut guard = session.lock().await;
|
|
||||||
if guard.worker_generation != worker_gen {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if guard.state_version != base_version {
|
|
||||||
tracing::warn!(
|
|
||||||
session_id = %guard.id,
|
|
||||||
"Session changed while preparing agent history; dropping stale task"
|
|
||||||
);
|
|
||||||
guard.current_cancel = None;
|
|
||||||
continue 'tasks;
|
|
||||||
}
|
|
||||||
if result.created_timelines {
|
|
||||||
guard.last_compressed_message_at =
|
|
||||||
Some(chrono::Utc::now().timestamp_millis());
|
|
||||||
}
|
|
||||||
guard.last_consolidated_at =
|
|
||||||
Some(chrono::Utc::now().timestamp_millis());
|
|
||||||
guard.session_meta_snapshot()
|
|
||||||
};
|
|
||||||
if let Some((storage, meta)) = meta_snapshot
|
|
||||||
&& let Err(e) = storage.upsert_session(&meta).await
|
|
||||||
{
|
|
||||||
tracing::warn!(error = %e, "Failed to persist session meta after compression");
|
|
||||||
}
|
|
||||||
result.history
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
if prepared_input.created_timelines {
|
||||||
tracing::warn!(error = %e, "Context compression failed in worker");
|
guard.last_compressed_message_at =
|
||||||
let guard = session.lock().await;
|
Some(chrono::Utc::now().timestamp_millis());
|
||||||
if guard.worker_generation != worker_gen {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
guard.get_history().to_vec()
|
|
||||||
}
|
}
|
||||||
|
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((storage, meta)) = meta_snapshot
|
||||||
if let Some(last_msg) = history_out.iter_mut().rev().find(|m| m.role == "user") {
|
&& let Err(e) = storage.upsert_session(&meta).await
|
||||||
Session::append_runtime_context_to_user_message(last_msg, &runtime_context);
|
{
|
||||||
|
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(
|
let (turn_controller, turn_emitter, turn_receiver) = TurnController::start(
|
||||||
unified_str.clone(),
|
unified_str.clone(),
|
||||||
@ -2591,29 +2712,28 @@ fn spawn_agent_worker(
|
|||||||
);
|
);
|
||||||
let initial_turn = turn_controller.snapshot();
|
let initial_turn = turn_controller.snapshot();
|
||||||
let active_turn_id = initial_turn.id.0.clone();
|
let active_turn_id = initial_turn.id.0.clone();
|
||||||
let live_delivery_started = match turn_delivery
|
let turn_target = crate::channels::TurnTarget {
|
||||||
.start(
|
channel: task_chan.clone(),
|
||||||
crate::channels::TurnTarget {
|
chat_id: task_cid.clone(),
|
||||||
channel: task_chan.clone(),
|
session_id: unified_str.clone(),
|
||||||
chat_id: task_cid.clone(),
|
reply_to: task_reply_to.clone(),
|
||||||
session_id: unified_str.clone(),
|
metadata: outbound_turn_metadata(&unified_str, &task_metadata),
|
||||||
reply_to: None,
|
};
|
||||||
metadata: outbound_turn_metadata(&unified_str, &task_metadata),
|
let delivery_handle = match turn_delivery
|
||||||
},
|
.start(turn_target.clone(), turn_receiver)
|
||||||
turn_receiver,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(()) => true,
|
Ok(handle) => Some(handle),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
channel = %task_chan,
|
channel = %task_chan,
|
||||||
error = %error,
|
error = %error,
|
||||||
"Live turn delivery unavailable; using ordinary final delivery"
|
"Live turn delivery unavailable; using ordinary final delivery"
|
||||||
);
|
);
|
||||||
false
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let live_delivery_started = delivery_handle.is_some();
|
||||||
{
|
{
|
||||||
let mut guard = session.lock().await;
|
let mut guard = session.lock().await;
|
||||||
if guard.worker_generation != worker_gen || guard.state_version != base_version {
|
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 cid2 = task_cid.clone();
|
||||||
let unified_str2 = unified_str.clone();
|
let unified_str2 = unified_str.clone();
|
||||||
let task_metadata2 = task_metadata.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 turn_lifecycle = &turn_controller;
|
||||||
let process_future = async move {
|
let process_future = async move {
|
||||||
let response_session_id = unified_str2.clone();
|
let response_session_id = unified_str2.clone();
|
||||||
@ -2692,7 +2816,7 @@ fn spawn_agent_worker(
|
|||||||
chat_id: cid2,
|
chat_id: cid2,
|
||||||
content: "Context overflow handling failed."
|
content: "Context overflow handling failed."
|
||||||
.to_string(),
|
.to_string(),
|
||||||
reply_to: None,
|
reply_to: task_reply_to2.clone(),
|
||||||
media: vec![],
|
media: vec![],
|
||||||
metadata: outbound_turn_metadata(
|
metadata: outbound_turn_metadata(
|
||||||
&response_session_id,
|
&response_session_id,
|
||||||
@ -2733,21 +2857,7 @@ fn spawn_agent_worker(
|
|||||||
tracing::warn!(error = %e, "Failed to persist session meta after retry compression");
|
tracing::warn!(error = %e, "Failed to persist session meta after retry compression");
|
||||||
}
|
}
|
||||||
|
|
||||||
let retry_history = {
|
let retry_history = runtime_context.assemble(retry_result.history);
|
||||||
let mut retry = retry_result.history;
|
|
||||||
retry.insert(
|
|
||||||
0,
|
|
||||||
ChatMessage::system(system_prompt_out.clone()),
|
|
||||||
);
|
|
||||||
if let Some(last_msg) = retry.iter_mut().rev().find(|m| m.role == "user")
|
|
||||||
{
|
|
||||||
Session::append_runtime_context_to_user_message(
|
|
||||||
last_msg,
|
|
||||||
&runtime_context,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
retry
|
|
||||||
};
|
|
||||||
|
|
||||||
match agent
|
match agent
|
||||||
.process_streaming(retry_history, agent_turn.clone())
|
.process_streaming(retry_history, agent_turn.clone())
|
||||||
@ -2769,7 +2879,7 @@ fn spawn_agent_worker(
|
|||||||
channel: chan2,
|
channel: chan2,
|
||||||
chat_id: cid2,
|
chat_id: cid2,
|
||||||
content: format!("Processing error: {}", e),
|
content: format!("Processing error: {}", e),
|
||||||
reply_to: None,
|
reply_to: task_reply_to2.clone(),
|
||||||
media: vec![],
|
media: vec![],
|
||||||
metadata: outbound_turn_metadata(
|
metadata: outbound_turn_metadata(
|
||||||
&response_session_id,
|
&response_session_id,
|
||||||
@ -2796,7 +2906,7 @@ fn spawn_agent_worker(
|
|||||||
channel: chan2,
|
channel: chan2,
|
||||||
chat_id: cid2,
|
chat_id: cid2,
|
||||||
content: format!("Processing error: {}", e),
|
content: format!("Processing error: {}", e),
|
||||||
reply_to: None,
|
reply_to: task_reply_to2.clone(),
|
||||||
media: vec![],
|
media: vec![],
|
||||||
metadata: outbound_turn_metadata(
|
metadata: outbound_turn_metadata(
|
||||||
&response_session_id,
|
&response_session_id,
|
||||||
@ -2828,15 +2938,18 @@ fn spawn_agent_worker(
|
|||||||
let response = match finalize_turn_after_persistence(
|
let response = match finalize_turn_after_persistence(
|
||||||
turn_lifecycle,
|
turn_lifecycle,
|
||||||
usage,
|
usage,
|
||||||
append_persisted_messages(&session2, result.emitted_messages),
|
append_persisted_messages_with_meta(
|
||||||
|
&session2,
|
||||||
|
result.emitted_messages,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(()) => {
|
Ok(committed_messages) => {
|
||||||
let mut guard = session2.lock().await;
|
let mut guard = session2.lock().await;
|
||||||
let sent_count = guard.messages.len();
|
let sent_count = guard.messages.len();
|
||||||
guard.compressor.set_last_api_info(sent_count, total_tokens);
|
guard.compressor.set_last_api_info(sent_count, total_tokens);
|
||||||
Some(response_content)
|
Some((response_content, committed_messages))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(error = %e, "Failed to atomically persist agent turn");
|
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 {
|
let err_outbound = OutboundMessage {
|
||||||
channel: chan2,
|
channel: chan2,
|
||||||
chat_id: cid2,
|
chat_id: cid2,
|
||||||
content: "Failed to save the agent response, please try again."
|
content: "Failed to save the agent response, please try again."
|
||||||
.to_string(),
|
.to_string(),
|
||||||
reply_to: None,
|
reply_to: task_reply_to2.clone(),
|
||||||
media: vec![],
|
media: vec![],
|
||||||
metadata: outbound_turn_metadata(
|
metadata: outbound_turn_metadata(
|
||||||
&response_session_id,
|
&response_session_id,
|
||||||
@ -2864,16 +2977,24 @@ fn spawn_agent_worker(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = maybe_generate_title_outside_lock(session2.clone()).await {
|
let delta = committed_turn_delta(&response_session_id, committed_messages);
|
||||||
tracing::warn!("failed to generate title: {}", e);
|
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 {
|
if !live_delivery_started {
|
||||||
let outbound = OutboundMessage {
|
let outbound = OutboundMessage {
|
||||||
channel: chan2,
|
channel: chan2,
|
||||||
chat_id: cid2,
|
chat_id: cid2,
|
||||||
content: response,
|
content: response,
|
||||||
reply_to: None,
|
reply_to: task_reply_to2.clone(),
|
||||||
media: vec![],
|
media: vec![],
|
||||||
metadata: outbound_turn_metadata(
|
metadata: outbound_turn_metadata(
|
||||||
&response_session_id,
|
&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
|
// Clean up
|
||||||
let mut guard = session.lock().await;
|
let mut guard = session.lock().await;
|
||||||
if guard
|
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",
|
"name": "picobot-webui",
|
||||||
"version": "1.1.2",
|
"version": "1.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picobot-webui",
|
"name": "picobot-webui",
|
||||||
"version": "1.1.2",
|
"version": "1.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bits-ui": "^2.0.0",
|
"bits-ui": "^2.0.0",
|
||||||
"dompurify": "^3.4.12",
|
"dompurify": "^3.4.12",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picobot-webui",
|
"name": "picobot-webui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.2",
|
"version": "1.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
let commandMenuDismissed = $state(false);
|
let commandMenuDismissed = $state(false);
|
||||||
let thinking = $state(false);
|
let thinking = $state(false);
|
||||||
let activeTurn = $state(null);
|
let activeTurn = $state(null);
|
||||||
|
let historyRevision = $state(0);
|
||||||
let pendingUploads = $state([]);
|
let pendingUploads = $state([]);
|
||||||
let fileInput;
|
let fileInput;
|
||||||
let plansBySession = $state({});
|
let plansBySession = $state({});
|
||||||
@ -77,7 +78,7 @@
|
|||||||
|
|
||||||
function handleFrame(frame) {
|
function handleFrame(frame) {
|
||||||
switch (frame.type) {
|
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":
|
case "session_list":
|
||||||
sessions = frame.sessions || [];
|
sessions = frame.sessions || [];
|
||||||
if (frame.current_session_id) currentId = frame.current_session_id;
|
if (frame.current_session_id) currentId = frame.current_session_id;
|
||||||
@ -87,17 +88,20 @@
|
|||||||
currentId = frame.session_id;
|
currentId = frame.session_id;
|
||||||
messages = [];
|
messages = [];
|
||||||
activeTurn = null;
|
activeTurn = null;
|
||||||
|
historyRevision = 0;
|
||||||
send({ type: "list_sessions", include_archived: false });
|
send({ type: "list_sessions", include_archived: false });
|
||||||
break;
|
break;
|
||||||
case "session_loaded":
|
case "session_loaded":
|
||||||
currentId = frame.session_id;
|
currentId = frame.session_id;
|
||||||
activeTurn = null;
|
activeTurn = null;
|
||||||
|
historyRevision = 0;
|
||||||
send({ type: "get_session_history", session_id: currentId, limit: 1000 });
|
send({ type: "get_session_history", session_id: currentId, limit: 1000 });
|
||||||
send({ type: "get_session_plan", session_id: currentId });
|
send({ type: "get_session_plan", session_id: currentId });
|
||||||
break;
|
break;
|
||||||
case "session_history":
|
case "session_history":
|
||||||
if (frame.session_id === currentId) {
|
if (frame.session_id === currentId) {
|
||||||
messages = frame.messages || [];
|
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;
|
if (activeTurn?.status !== "running" && messages.some((message) => message.id === activeTurn?.message_id)) activeTurn = null;
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
@ -144,13 +148,31 @@
|
|||||||
if (activeTurn?.id === next.id && activeTurn.revision >= next.revision) break;
|
if (activeTurn?.id === next.id && activeTurn.revision >= next.revision) break;
|
||||||
activeTurn = next;
|
activeTurn = next;
|
||||||
thinking = next.status === "running";
|
thinking = next.status === "running";
|
||||||
|
if (next.status !== "running" && messages.some((message) => message.id === next.message_id)) {
|
||||||
|
activeTurn = null;
|
||||||
|
}
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
if (next.status !== "running") {
|
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 });
|
send({ type: "list_sessions", include_archived: false });
|
||||||
}
|
}
|
||||||
break;
|
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":
|
case "system_notification":
|
||||||
if (!frame.session_id || frame.session_id === currentId) appendMessage("assistant", frame.content);
|
if (!frame.session_id || frame.session_id === currentId) appendMessage("assistant", frame.content);
|
||||||
break;
|
break;
|
||||||
@ -172,6 +194,7 @@
|
|||||||
function loadSession(id) {
|
function loadSession(id) {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
currentId = id;
|
currentId = id;
|
||||||
|
historyRevision = 0;
|
||||||
clearPendingUploads();
|
clearPendingUploads();
|
||||||
messages = [];
|
messages = [];
|
||||||
activeTurn = null;
|
activeTurn = null;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user