Compare commits

..

No commits in common. "7de9a8a054f0d557b79bf196d65c21bb86d0c17f" and "c7ee6bb519071cbc56a72a895fe6e122840d6d00" have entirely different histories.

208 changed files with 8036 additions and 18552 deletions

View File

@ -1,98 +0,0 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
env:
CARGO_TERM_COLOR: always
# 跳过前端构建build.rs 会触发 npm install + npm run build
# CI 中独立处理前端检查,避免 cargo check 时重复构建
SKIP_FRONTEND_BUILD: "1"
jobs:
rust-checks:
name: Rust fmt + clippy + test
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Check formatting
run: cargo fmt --all -- --check
# 不使用 -D warnings现有代码有大量 warn 级别的存量问题
# unwrap/expect/clone 等),一上线会让 CI 一直红。
# 仅 correctness 类别clippy 默认 deny会失败先渐进收集。
# 等存量清理后再考虑加 -- -D warnings
- name: Clippy
run: cargo clippy --all-targets --all-features
- name: Build
# 构建 lib + 二进制入口main.rs确保 gateway 二进制也被编译验证
run: cargo build
- name: Run tests
# 运行 lib 单元测试 + tests/ 集成测试
# test_integration.rs / test_tool_calling.rs 中的 #[ignore] 测试
# 需要真实 API key会被跳过test_request_format.rs 的测试会实际执行
run: cargo test
- name: Security audit
run: cargo install cargo-audit --locked && cargo audit
frontend-checks:
name: Frontend build + test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Install dependencies
working-directory: web
run: npm ci
- name: Lint (eslint)
working-directory: web
run: npm run lint
- name: Format check (prettier)
working-directory: web
run: npm run format:check
- name: Type check
working-directory: web
run: npx tsc --noEmit
- name: Run tests
working-directory: web
run: npm run test
- name: Security audit
working-directory: web
run: npm audit --audit-level=high

5
.gitignore vendored
View File

@ -11,11 +11,6 @@ web/.cache
web/coverage
web/*.local
# Secrets — never commit real credentials
*.env
!*.env.example
tests/test.env
# Build output
static

View File

@ -1,116 +0,0 @@
# PicoBot 架构
> 本文档聚焦"为什么这样设计"和"数据如何流动",不是代码导读。
> 代码是唯一真相源,文档可能滞后;有冲突以代码为准。
## 一句话定位
PicoBot 是一个**多渠道接入的 Agent 网关**:外部消息(微信/飞书/Web/CLI经统一总线进入
由会话管理器路由到对应 Agent 循环Agent 调用 LLM + 工具完成任务,结果原路返回。
## 核心数据流
```
┌─────────┐ ┌──────────┐ ┌──────────────┐ ┌────────────┐ ┌──────────┐
│ Channel │──▶│ MessageBus│──▶│InboundProc. │──▶│ Session │──▶│AgentLoop │
│ (微信/ │ │ (解耦) │ │(并发+限流) │ │ Manager │ │(LLM+工具)│
│ 飞书/Web)│ │ │ │ │ │ │ │ │
└─────────┘ └──────────┘ └──────────────┘ └────────────┘ └────┬─────┘
▲ │
│ ▼
┌────┴────┐ ┌──────────┐ ┌────────────┐ ┌──────────┐
│Outbound │◀──│ MessageBus│◀─── SessionMessageSender ◀─│ storage │◀─│ tools │
│Dispatcher│ │ │ │ (SQLite) │ │(bash/file│
└─────────┘ └──────────┘ └────────────┘ │ /memory) │
└──────────┘
```
**入站**Channel → MessageBus → InboundProcessor并发控制 + 限流)→ SessionManager → AgentLoop
**出站**AgentLoop → SessionMessageSender → MessageBus → OutboundDispatcher → Channel
**持久化**AgentLoop / Tools → SessionStoreSQLite + r2d2 连接池)
## 关键设计决策
### 1. MessageBus 解耦 Channel 与 Session
**问题**:多个 Channel微信/飞书/Web接入每个 Channel 协议不同,但 Session 处理逻辑相同。
**决策**:引入 MessageBus 作为中间件Channel 只负责协议适配和收发Session 不关心消息来自哪个 Channel。
**代价**:多一层间接。换来的是新增 Channel如钉钉只需实现 Channel trait不碰 Session 逻辑。
### 2. SessionPool 会话隔离
**问题**:多用户同时对话,会话状态不能串扰。
**决策**SessionManager 持有 SessionPool按 (channel_name, chat_id) 路由到独立 Session。
每个 Session 有自己的 AgentLoop、消息历史、工具上下文。
**代价**:内存占用随活跃会话数增长。用 session_ttl_hours 过期回收。
### 3. AgentLoop 的工具循环
**问题**LLM 需要多轮工具调用才能完成任务(如"读文件→分析→写文件")。
**决策**AgentLoop 是一个有界循环max_tool_iterations默认 1000每轮
1. 把消息历史 + 工具定义发给 LLM
2. LLM 返回文本或 tool_call
3. 如果是 tool_call执行工具把结果加入历史回到 1
4. 如果是文本,结束循环
**代价**:单次对话可能很长。用 CancelManager 支持中途取消。
### 4. SQLite + r2d2 连接池
**问题**:需要持久化会话历史、话题、记忆、待办,且要支持并发读写的 sub-agent 场景。
**决策**SQLitebundled+ r2d2 连接池max_size=8+ busy_timeout(30s)。
**代价**SQLite 写并发有限。通过 busy_timeout + 事务隔离级别(之前的修复)缓解锁冲突。
**不选 Postgres 的原因**:单机部署、零外部依赖、足够用。
### 5. 重启机制watch channel
**问题**:配置变更后需要重启 gateway但不能要求用户手动杀进程。
**决策**main.rs 用 `while should_restart` 循环gateway 通过 `watch::Sender<bool>` 通知是否需要重启。
配置 API `/api/restart` 触发 graceful shutdownmain 收到 should_restart=true 后重新初始化。
**代价**:重启期间短暂不可用。比热重载简单且可靠。
### 6. 嵌入式静态文件
**问题**Web 前端需要随二进制分发,但不想要求用户额外下载。
**决策**build.rs 在 cargo build 时执行 npm run build产物通过 rust-embed 编译进二进制。
开发时设 `STATIC_DIR` 环境变量走磁盘文件,支持热更新。
**代价**:二进制体积增大。换来的是单文件部署。
### 7. Safety Guard命令安全护栏
**问题**Agent 可以调用 bash 工具执行任意命令,需要防止误操作(如 `format C:``rm -rf`)。
**决策**platform 模块按平台注入危险命令正则,执行前匹配拦截。
**关键教训**:正则要精确(曾因 `\bformat\s+` 误拦 `dart format`),按平台分组避免跨平台误伤。
## 模块职责速查
| 模块 | 职责 | 关键文件 |
|------|------|---------|
| gateway | HTTP/WS 服务、路由、生命周期 | `gateway/mod.rs`, `gateway/runtime.rs` |
| gateway/session | 会话管理、路由、池化 | `gateway/session.rs`, `session_pool.rs` |
| gateway/processor | 入站消息处理、并发控制 | `gateway/processor.rs` |
| agent | Agent 循环、上下文压缩 | `agent/agent_loop.rs` |
| providers | LLM Provider 抽象OpenAI/Anthropic | `providers/openai.rs`, `anthropic.rs` |
| tools | 工具实现与注册 | `tools/` (bash/file/memory/task/...) |
| storage | SQLite 持久化 | `storage/mod.rs`, `migrations.rs` |
| channels | 渠道适配(微信/飞书/CLI | `channels/` |
| bus | 消息总线(解耦 channel 与 session | `bus/message.rs` |
| command | 前端命令处理(话题/会话/记忆 CRUD | `command/handlers/` |
| mcp | Model Context Protocol 客户端 | `mcp/client.rs` |
| scheduler | 定时任务调度 | `scheduler/mod.rs` |
| skills | 技能加载与激活 | `skills/mod.rs` |
| experts | 专家配置运行时 | `experts/mod.rs` |
## 测试策略
- **单元测试**:与代码同文件 `#[cfg(test)] mod tests`,覆盖纯函数和逻辑分支
- **集成测试**`tests/` 目录,覆盖跨模块请求格式
- **测试密度**test 行 / 总行tools 98.8%、gateway 96.4%、storage 91.8% 为高覆盖区;
providers 31%anthropic 曾为 0%已补、bus 18.7%、cli 4.5% 为薄弱区
- **不强制覆盖率工具**:静态审计 + 高风险区定向补测,比全量 tarpaulin 更务实
## 工程化基线
- **格式化**rustfmtRust+ prettier前端CI 强制 `--check`
- **静态检查**clippyRust+ eslint前端CI 强制
- **CI**GitHub Actions双平台ubuntu + windows跑 fmt + clippy + test + eslint + tsc + vitest
- **本地**`make check` 与 CI 完全对齐

4
Cargo.lock generated
View File

@ -1635,7 +1635,7 @@ dependencies = [
[[package]]
name = "picobot"
version = "0.3.3"
version = "0.2.0"
dependencies = [
"anyhow",
"async-trait",
@ -1656,7 +1656,6 @@ dependencies = [
"libc",
"meval",
"mime_guess",
"parking_lot",
"prost",
"r2d2",
"r2d2_sqlite",
@ -1670,7 +1669,6 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
"subtle",
"tempfile",
"thiserror 2.0.18",
"tokio",

View File

@ -1,25 +1,8 @@
[package]
name = "picobot"
version = "0.3.3"
version = "0.2.0"
edition = "2024"
[lints.rust]
# 编译期硬错误:避免明显的内存安全/正确性隐患
unsafe_op_in_unsafe_fn = "warn"
rust_2018_idioms = "warn"
[lints.clippy]
# 渐进式策略:
# - 不直接声明 lint groupcorrectness/suspicious/complexity/perf
# 因为 lint group 在 [lints] 中需用 priority 语法,简单 = "warn" 会报错;
# 且 clippy 默认已把 correctness 设为 deny无需重复声明。
# - 仅显式 warn 少量高价值且存量不大的具体规则,避免一上线淹没在噪音中。
# 后续随着存量问题清理,可逐步把 unwrap_used/expect_used 升级为 warn。
redundant_clone = "warn"
dbg_macro = "warn"
print_stderr = "warn"
print_stdout = "warn"
[dependencies]
reqwest = { version = "0.13.2", default-features = false, features = ["json", "rustls", "multipart", "stream"] }
dotenv = "0.15"
@ -55,12 +38,18 @@ rusqlite = { version = "0.39", features = ["bundled"] }
r2d2 = "0.8"
r2d2_sqlite = "0.34"
rustls = { version = "0.23", features = ["ring"] }
subtle = "2.6"
parking_lot = "0.12"
wechatbot = { path = "vendor/wechatbot" }
encoding_rs = "0.8"
libc = "0.2"
gray_matter = { version = "0.2", default-features = false, features = ["yaml"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [
"Win32_System_Threading",
"Win32_System_Diagnostics_Debug",
"Win32_Foundation",
"Win32_System_Kernel",
] }
# MCP (Model Context Protocol) support
rmcp = { version = "1.7", features = [
"client",
@ -70,13 +59,5 @@ rmcp = { version = "1.7", features = [
] }
schemars = "1.0"
http = "1"
tower-http = { version = "0.6", features = ["fs", "cors"] }
tower-http = { version = "0.6", features = ["fs"] }
rust-embed = "8"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [
"Win32_System_Threading",
"Win32_System_Diagnostics_Debug",
"Win32_Foundation",
"Win32_System_Kernel",
] }

View File

@ -1,6 +1,6 @@
# PicoBot Web UI Makefile
.PHONY: dev dev-backend dev-frontend build clean install check fmt fix help
.PHONY: dev dev-backend dev-frontend build clean install
# Default target
all: build
@ -47,23 +47,11 @@ clean:
# Check code formatting and linting
check:
@echo "Checking formatting..."
cargo fmt --all -- --check
@echo "Checking frontend (lint + format + build)..."
cd web && npm run lint
cd web && npm run format:check
@echo "Checking frontend..."
cd web && npm run build
@echo "Checking Rust code..."
cargo check
cargo clippy --all-targets --all-features
# Format all Rust code in place
fmt:
cargo fmt --all
# Auto-fix clippy lints where possible
fix:
cargo clippy --fix --all-targets --allow-dirty --allow-no-vcs
cargo clippy
# Help
help:
@ -78,6 +66,4 @@ help:
@echo " make run - Run production build"
@echo " make clean - Clean build artifacts"
@echo " make check - Check code formatting and linting"
@echo " make fmt - Format all Rust code in place"
@echo " make fix - Auto-fix clippy lints where possible"
@echo " make help - Show this help message"

View File

@ -24,7 +24,7 @@
}
},
"gateway": {
"host": "127.0.0.1",
"host": "0.0.0.0",
"port": 19876,
"agent_prompt_reinject_every": 100
},

View File

@ -2,273 +2,6 @@
本文件记录 Picobot 各版本的显著变更,遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 风格。
## [0.3.3] - 2026-08-07
较 [0.3.2] 的 12 个 commit 迭代,聚焦 **前端渲染性能飞跃**、**流式通信健壮性** 与 **代码质量治理** 三大方向。
### 新增功能
#### 执行状态对账端点
- 新增 `GET /api/executions` HTTP 端点,返回后端权威的执行中话题集合。前端 WebSocket 重连时调用此端点对账,修正断连期间丢失的 `execution_completed` 信号导致的状态漂移。
### 性能优化
#### 消息列表虚拟化(长对话不再卡顿)
- 引入 `@tanstack/react-virtual` 替换原 `messages.map` 全量渲染,只渲染视口内 + overscan=6 条消息DOM 节点数恒定。
- `measureElement` 动态测量变高度消息(一行文本 vs 50 行代码块),`estimateSize=120` 提供初始估计,测量后自动校正。
- Firefox 特殊处理 `measureElement``getBoundingClientRect`)。
- 适配现有功能:自动滚到底部改用 `virtualizer.scrollToIndex``viewKey` 滚动位置记忆保留;`highlightedMessageId` 先渲染目标项再 rAF 加 class流式 delta 不计数(消息条数不变)。
- 性能对比500 条消息DOM 节点 10000+ → ~200首次渲染数百 ms → <16ms流式 diff 从全量列表降至仅可见项
#### 流式 delta 批处理(避免每 token 全数组拷贝)
- 用 `streamingRef` 累加 delta chunks + `requestAnimationFrame` 批量 flush将每秒数十次 O(n) 数组拷贝合并为一帧一次状态更新。
- `useMessages.ts` 新增 `streamingRef` 累加器 + rAF 调度,所有 delta 只 push 到 ref`flushStreaming` 批量落盘。
- `useChat.ts` 5 处 `setMessages([])` 改为 `clearMessages()`,切话题/会话/通道时重置流式 ref避免脏状态残留。
- 复杂度O(n²·L) → O(n) 每帧。
### 改进
#### 压缩阈值默认值下调
- 工程化压缩阈值 `threshold_ratio` 从 0.7 下调至 0.550%LLM 压缩阈值 `llm_compaction_threshold_ratio` 从 0.5 下调至 0.330%)。
- 降低触发门槛使压缩更积极介入,减少上下文溢出风险。同步更新相关注释、日志信息和前端 hint 文案。
### 修复
#### 输入框/发送按钮状态按话题隔离
- 将单一全局 `isLoading` 布尔值重构为按 `topic_id` 跟踪的 `processingTopicIds` 集合,`isLoading` 派生自当前选中话题是否在集合中。
- 导航响应不再清空处理状态,切换话题后切回原话题仍能正确禁用输入。
- 重连时通过 `/api/executions` 端点对账后端权威执行状态,修正断连期间丢失的 `execution_completed` 信号。
#### WebSocket 断连时清理流式状态
- 修复 WebSocket 在流式输出中途断连时 `streamingRef`contentChunks/index不重置导致重连后新消息 delta 追加到旧消息错误位置。
- 修复链路:`useWebSocket``onDisconnect` 回调 → `useChat``finishStreaming``useMessages``finishStreaming`(重置 ref + flush pending chunks
#### 会话消息泄露到定时任务视图
- `handleSchedulerMessage`Tier 1 路由)原先无条件吞掉所有可转换的 chat 消息,导致正在执行的实时消息被错误追加到调度器视图。
- 修复:调度器视图只接收不带 `topic_id` 的历史加载消息;实时流式消息和带 `topic_id` 的实时消息 fall through 到主视图。
### 重构
#### 公共工具函数提取与 DRY
- 提取 `src/utils.rs` 公共工具函数模块,消除跨模块重复逻辑。
- 修复飞书 channel 正则表达式重复编译问题,改为 `Lazy<Regex>` 一次编译。
- 网关路由逻辑 DRY 化,减少 `gateway/mod.rs` 重复代码。
- `agent_loop` / `providers` / `config` / `storage` 等多模块统一引用公共函数20 个文件受影响。
- CI 新增安全审计步骤(`cargo audit`)。
### 内部改进
- 存储模块应用 `rustfmt` 格式化(`migrations.rs` / `row_mapping.rs` / `tests.rs`)。
- 降低 `list_todos` handler 与 gateway 转发日志级别为 debug减少子代理多次更新待办时的 INFO 日志噪音。
- 删除临时优化计划文件 `OPTIMIZATION_PLAN.md`
### 测试
- 新增 MessageList 虚拟化对抗性 smoke test5 个用例空消息渲染、100 条消息不崩溃、highlight 视口外不崩溃、viewKey 切换不崩溃、流式 rerender 不崩溃。
- 新增流式断连对抗性测试2 个用例):`stream_end` 幂等性验证、多周期断连-重连循环下 `finishStreaming` 幂等验证。
- 新增断连后新 delta 不污染旧消息测试(用例 17
- 新增定时任务视图消息隔离测试(用例 16
- 新增批量 delta 累加与 `stream_end``assistant_response` 替换对抗性测试。
---
## [0.3.2] - 2026-08-06
较 [0.3.1] 的 7 个 commit 迭代,聚焦 **上下文压缩可配置化**、**网关安全加固**、**并发模型优化** 与 **错误防御性提升** 四大方向。
### 新增功能
#### 压缩算法关键参数暴露到设置页面
- 将 4 个上下文压缩参数从硬编码提取到 `config.json` 顶层 `compaction` 节:
- `threshold_ratio`0.7):工程化压缩触发阈值,占 `context_window` 的比例
- `llm_compaction_threshold_ratio`0.5LLM 压缩触发阈值
- `truncate_max_tokens`100工程化压缩时 tool 结果截断到的 token 数
- `preserve_count`5LLM 三段压缩保留最旧/最新的 unit 数
- 前端新增"上下文压缩"设置标签页Archive 图标4 个 number input 分两组展示。
- `ContextCompressor` 内聚所有压缩参数,新增 `with_compaction_config()` 构造函数和 `truncate_tool_results()` 方法。
- `AgentLoop` 通过调用 `compressor.truncate_tool_results()` 实现零参数耦合,压缩参数变更仅影响 `ContextCompressor`
#### HTTP API 认证与 CORS 防护
- 新增 HTTP API 认证机制,未授权请求直接返回 401。
- CORS 防护:明确指定允许的 origin阻止跨域未授权访问。
- 修复密钥泄露风险:敏感信息不再出现在错误响应中。
### 改进
#### 上下文压缩重构为两阶段+双阈值+三段保留
- **两阶段压缩**:工具调用完成后触发压缩流程,先用 `prompt_tokens` 判断 70% 阈值。
- **阶段 1工程化压缩**:截断非子代理 tool 结果到约 100 token仅改内存数据。
- **阶段 2LLM 压缩)**:工程化压缩后若估算 token 仍超 50% 阈值,才调用 LLM 压缩。
- **三段保留**:保留最旧 5 个 unit + 中间段 LLM 摘要 + 最新 5 个 unit通过 `HistoryUnit` 原子单元保证 `ToolRound` 不被拆分。
- LLM 压缩失败时降级为仅工程化压缩,不中断 agent loop。
#### 迁移 parking_lot 锁并优化阻塞 IO 与内存管理
- 将 `std::sync` 锁替换为 `parking_lot` 实现,减少锁竞争开销。
- 优化阻塞 IO 路径,避免热路径无谓阻塞。
- 内存管理改进:减少不必要的克隆与分配。
### 修复
#### 子智能体任务状态卡死
- 修复已完成子智能体任务卡在"子智能体正在执行..."状态,前端无法正确展示结果。
#### 飞书 channel 消息丢失
- 修正飞书 reaction 语义,与飞书 API 规范对齐。
- 增容 message bus 队列,避免高频消息场景下队列满导致最后一条消息丢失。
#### 防御性错误处理
- 替换 `unreachable!()` 宏调用为防御性错误返回,避免运行时 panic。
- agent/gateway 关键路径改用 `Result` 传播错误,提升系统鲁棒性。
---
## [0.3.1] - 2026-08-05
较 [0.3.0] 的 19 个 commit 迭代,聚焦 **历史压缩可追溯性**、**token 统计准确性**、**前端渲染稳定性**、**工具执行可靠性** 与 **网关吞吐优化** 五大方向。
### 新增功能
#### 历史压缩保留原消息(双视图分离)
- 压缩流程从物理删除原消息改为标记位 `is_compacted` 分离两个视图:
- **LLM 视角**`load_messages_for_topic`):只看 `is_compacted=0` 的压缩摘要 + 新消息context 仍精简。
- **UI 视角**`load_messages_for_topic_full`):看原消息(含 `is_compacted=1`+ 新消息,排除压缩摘要,前端切回老话题仍能展示完整原始对话。
- 新增 `compact_topic_history`:不删除原消息,仅打标记 + 插入摘要,同事务内删除旧摘要避免累积。修复 token 统计因压缩丢失 `usage` 的问题。
- `messages` 表新增 `is_compacted INTEGER NOT NULL DEFAULT 0`migration 幂等。
#### Topic 维度 token 统计
- 新增 `batch_topic_token_stats`,按 `topic_id` 聚合 token 消耗,替代原按 `session_id` 聚合导致的同 session 多 topic 共享总和问题。
- 前端侧边栏每个 topic 显示独立 token 消耗与 context 占用百分比Coins 图标 + 数值,绿/黄/红三色指示)。
- 主代理执行结束时触发 `list_topics` 刷新500ms 防抖),实时更新侧边栏 token 统计;子代理每轮执行也支持刷新。
#### 设置页重启按钮
- 设置页 Footer 区新增重启按钮,样式为 `bg-tertiary` + 边框,与主保存按钮区分。
- 确认对话框区分触发来源:保存后触发显示"配置已保存 / 稍后",手动触发显示"重启服务 / 取消"并警告未保存改动。
- 状态管理改用 `restartDialogMode: 'saved' | 'manual' | null`,按钮在重启期间显示 loading 并禁用。
#### 设置页懒加载标签页与子代理管理
- 拆分设置页为懒加载标签页,减少首屏渲染负担。
- 前端支持子代理创建与删除,配合 `PUT /api/subagents/update` 端点。
#### LLM 主调用可配置重试机制0.3.0 收尾补充)
- `ProviderConfig` 增加 `max_retries` 字段serde default=3向后兼容
- `agent_loop` 流式 + summary 两个调用点实现重试循环:指数退避 1s/2s/4s仅对 429/502/503/504/timeout/connection reset 重试;流式仅在未 emit delta 时重试,避免重复输出。
### 修复
#### 前端渲染稳定性
- `MessageBubble` 使用 `React.memo` 避免流式期间不必要重渲染。
- `useWebSocket` 回调 ref-化,避免父组件重渲染触发 WebSocket 无谓重连。
- 修复触发滚动到底部按钮时新消息计数错误。
- 修复话题重命名预填字段与写入字段不一致。
- 修复流式期间消息重渲染、WebSocket 无谓重连与新消息计数错误。
#### 子智能体结果展示
- 失败/超时子智能体返回结构化结果,前端可点击查看详情,而非静默丢失。
#### Token 统计准确性
- 修复 token 统计按 `session_id` 聚合导致同 session 多 topic 共享总和:改为按 `topic_id` 聚合,子代理消息通过 `session_id NOT LIKE 'sub:%'` 排除。
- 修复主代理执行结束后 token 统计不刷新:`useMessages``execution_completed` 事件时触发 `bumpTopicRefreshTrigger()`
#### 工具执行可靠性
- 修复 shell 工具超时未生效:`adb start-server` 守护进程继承 stdout 管道导致 EOF 阻塞,`adb devices` 误判 socket 等待为 stdin 等待绕过 deadline。新增 `STREAM_DRAIN_MS` 常量、改进 `kill_and_reap`、移除 `should_return_pending` 中的错误检查,确保 timeout 参数完全覆盖默认值。
#### 安全加固
- `web_fetch` 加固 SSRF 防护,禁用 HTTP 重定向,与 `http_request` 实现对齐。
#### MCP 兼容性
- 限制 MCP server 名称字符并兜底 `tool_name` 清洗,避免非法字符导致 OpenAI API 400。
### 性能优化
#### 网关吞吐
- `send_with_retry``ChannelFull` 错误短路(不重试、不退避),加速队列消费,避免 dispatcher 在满队列时 7s 退避阻塞。
- 新增 `ChannelError::ChannelFull` 变体,`CliChannel``try_send` 返回 `Full` 时映射为 `ChannelFull`
#### 存储查询
- `get_topic_message_count` 改用 `SELECT COUNT(*)` 在数据库侧计数,避免将所有消息(含 `content``tool_calls_json` 等大字段)加载到内存。
#### 热路径优化
- 减少热路径无谓拷贝与重复正则编译,跳过已完成 migration。
### 内部改进
- 删除临时计划文件 `PLAN.md`
- CHANGELOG 补充 LLM 重试机制条目并修正 commit 计数。
---
## [0.3.0] - 2026-08-04
较 [0.2.0] 的 18 个 commit 迭代,聚焦 **Agent 执行与显示层解耦**、**LLM 调用稳定性**、**工程化基线**、**并发持久化稳定性**、**安全加固** 与 **MCP 兼容性** 六大方向。锁屏冻结架构修复经五轮对抗性审查验证。
### 新增功能
#### 话题重命名
- 新增 `RenameTopic` 命令与 `TopicRenamed` 协议消息,复用存储层已有的 `update_topic_title` 方法。后端响应携带刷新后的完整 topic 列表,前端零额外往返同步侧边栏。
- 前端 `TopicList` 侧边栏增加内联编辑入口悬停显示铅笔图标Enter 提交 / Esc 取消 / blur 取消,通过 `onMouseDown preventDefault` 防止按钮点击时 input 提前失焦。
#### 前端自动构建集成
- 新增 `build.rs``cargo build` 时自动执行 `npm install + npm run build`,消除前后端构建割裂。支持 `SKIP_FRONTEND_BUILD` 环境变量跳过。
#### 工程化基线
- `rustfmt.toml` 固化 `max_width=100` / 4 空格缩进;`Cargo.toml` 配置 `[lints.rust]``[lints.clippy]` 渐进式规则。
- `.github/workflows/ci.yml`Rustfmt + clippy + test+ 前端eslint + tsc + test + prettier format:check双平台 CI。
- `Makefile` 新增 `check` / `fmt` / `fix` 目标clippy 对齐 `--all-targets --all-features`
- 前端 eslint flat config + prettier 配置,对 47 个前端文件统一格式化并在 CI 强制 `format:check`
#### 默认迭代上限调整
- `max_tool_iterations` 默认值调整为 1000匹配长任务子代理的实际需求。
#### LLM 主调用可配置重试机制
- `ProviderConfig` 增加 `max_retries` 字段serde default=3向后兼容透传至 `AgentRuntimeConfig` 归属 agent 行为层,不进 `ProviderRuntimeConfig` 以保持 provider 构造包纯净。
- `agent_loop` 流式 + summary 两个调用点实现重试循环:指数退避 1s/2s/4s仅对 429/502/503/504/timeout/connection reset 重试;流式仅在未 emit delta 时重试(`AtomicBool` 跟踪),避免重复输出;退避 sleep 期间响应 `cancel_signal`,取消优先。
- 前端 `ProviderConfig` 类型和表单增加 `max_retries` 字段。
- 7 个单元测试3 判定 + 4 行为540 个 lib 测试全绿。
### 架构修复
#### Agent 执行与显示层解耦(锁屏冻结根因修复)
根因:浏览器锁屏导致 WebSocket 半死,`ws_sender.send().await` 永久阻塞,级联阻塞 dispatcher → MessageBus → Agent Loop后端停止执行直到解锁。
基于第一性原理建立"执行-显示解耦"原则agent 执行只依赖 SQLite 持久化,实时广播是可丢弃的最佳努力通道。
- `MessageBus::publish_outbound``send().await` 改为 `try_send()`bus 满时丢弃消息并告警agent 不再被显示层阻塞。新增 `BusError::Dropped` 变体。
- WebSocket writer task 包裹 `tokio::time::timeout(30s)`:半死连接超时即关闭。使用每连接独立的 `CancellationToken`(非共享的 CliChannel 级 token避免一个连接超时关闭所有连接。writer 退出时 cancel 通知主 loop 退出,确保 `unregister_connection` 执行。
- `CliChannel::send``send().await` 改为 `try_send()`dispatcher 单线程顺序处理,原阻塞式发送在 writer 卡住时会阻塞所有连接 37s改后立即返回。
- 全仓 13 处 `publish_outbound` 调用统一区分 `Dropped`warn预期背压/ `Closed`error异常日志级别消除监控噪音。scheduler 的 3 处 `?` 改为 warn避免 `Dropped` 触发 misfire 重试风暴。
- 前端 WebSocket 添加 25s 客户端 ping + 指数退避重连3s→6s→12s→24s→60s 封顶,上限 999 次)。重连后区分"重连恢复"与"首次/切换通道":前者保留断连前 messages 并刷新 topic 列表,后者清空数据避免污染。
### 安全加固
#### SSRF 重定向绕过修复
- `web_fetch` 工具禁用 HTTP 重定向(`redirect::Policy::none()`):原 `validate_url` 只校验初始 URL 的 host跟随 302 跳转可重定向到 `169.254.169.254`(云元数据)或 `127.0.0.1` 等内网地址,绕过 `is_private_host` 的 SSRF 防护。与 `http_request` 工具保持一致。
#### Safety Guard 正则收紧
- `format` 正则收紧为 `\bformat\s+.*[a-z]:`,要求出现盘符才拦截,避免误伤 `dart format``buf format``pytest --format` 等合法命令。
- 按平台分组注入规则Unix 不再注入 Windows 专用规则;`Remove-Item` 正则改为小写,与 `guard_command` 的大小写处理一致。
### 稳定性修复
#### SQLite 并发写入死锁
- 7 个写事务从 `BEGIN DEFERRED` 改为 `BEGIN IMMEDIATE`:在事务开始即获取写锁,消除多 sub-agent 并发写入时的死锁路径。`busy_timeout` 从 5s 提升至 30s。
- 根因:`BEGIN DEFERRED` 下多个事务可同时读 `MAX(seq)` 不持写锁提交时互相阻塞5s timeout 耗尽后返回 `SQLITE_BUSY``BEGIN IMMEDIATE` 强制写者串行排队,顺带消除 `MAX(seq)+1` 竞态导致的 UNIQUE 约束冲突。
#### 测试与代码质量
- 修复 `anthropic` 错误链嵌套测试为真正的 `#[source]`原测试是无效的inner 变量被 `let _` 抑制)。
- 补充 `anthropic` provider 15 个纯函数单测(原 396 行零测试),覆盖 data URL 解析、图片过滤、字段过滤、响应反序列化、错误链格式化。
- 修复 3 个失败测试:`agent_md_template` 写入模板内容 / `StreamingAccumulator` BTreeMap 保序 / `source_order` 断言补全。
- 清理未使用的 dead code 函数与方法。
- 新增 `ARCHITECTURE.md` 架构文档,聚焦数据流与 7 个关键设计决策。
#### CI 覆盖范围扩大
- `cargo build --lib` 改为 `cargo build`,确保 `main.rs` 二进制入口被编译验证。
- `cargo test --lib` 改为 `cargo test`,纳入 `tests/` 目录集成测试。`test_request_format.rs` 的 8 个序列化测试此前从未在 CI 中运行。
#### 跨平台构建修复
- 修正依赖分类错误:`rmcp` / `schemars` / `http` / `tower-http` / `rust-embed` 均为跨平台 crate错放在 `[target.'cfg(windows)'.dependencies]` 下导致 Linux 构建失败,移出 target 段。
#### MCP 工具名清洗
- OpenAI 要求 function name 匹配 `^[a-zA-Z0-9_-]+$`,否则整个请求 400。MCP 工具名由 `mcp_{server_key}_{tool_name}` 拼成,两个输入源分别处理:
- 后端:`sanitize_tool_name` 替换 `tool_name` 中的非法字符(`.`, `:`, `/` 等)为 `_`,保留 `server_key``tool_name` 原值用于路由,仅清洗 LLM 可见的 `full_name`,发生清洗时打 warn 日志便于定位。
- 前端MCP 卡片头部改用 `MapEntryHeader` 支持点击重命名,`addMcp` / `renameMcp` 正则校验 server 名称 + toast 提示。顺手修了 `MapEntryHeader` 进入编辑时 `val` 未同步当前 `name` 的 bug。
## [0.2.0] - 2026-07-31
较 [0.1.2] 的 47 个 commit 迭代,聚焦 **能力策略**、**模型独立配置**、**话题级并发隔离** 与 **Agent Loop 性能优化** 四大方向。
@ -380,9 +113,5 @@
- 前端静态文件嵌入二进制。
- React Web UI 前端界面。
[0.3.3]: https://github.com/picobot/picobot/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/picobot/picobot/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/picobot/picobot/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/picobot/picobot/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/picobot/picobot/compare/v0.1.2...v0.2.0
[0.1.2]: https://github.com/picobot/picobot/releases/tag/v0.1.2

View File

@ -1,12 +0,0 @@
# PicoBot Rust 代码格式化规则
#
# 设计原则:尽量贴近 rustfmt 默认风格,仅固化少数项目级偏好。
# 不追求激进重排,避免一次性产生大量 diff。
# 仅使用 stable rustfmt 支持的选项,不依赖 nightly 特性。
# 行宽100现代显示器友好
max_width = 100
# 缩进用 4 空格Rust 社区主流,与现有代码一致)
hard_tabs = false
tab_spaces = 4

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,11 @@
use crate::agent::{AgentError, AgentRuntimeConfig};
use crate::bus::{
ChatMessage, SYSTEM_CONTEXT_AGENT_PROMPT, SYSTEM_CONTEXT_HISTORY_COMPACTION,
SYSTEM_CONTEXT_SCHEDULED_PROMPT,
};
use crate::config::{CompactionConfig, LLMProviderConfig};
use crate::config::LLMProviderConfig;
use crate::providers::{ChatCompletionRequest, LLMProvider, Message, create_provider};
use crate::text::{char_count, take_prefix_chars};
use crate::agent::{AgentError, AgentRuntimeConfig};
const TOKEN_ESTIMATE_SAFETY_MULTIPLIER: f64 = 1.2;
const CJK_CHARS_PER_TOKEN: f64 = 2.0;
@ -17,6 +17,13 @@ pub const SYSTEM_CONTEXT_HISTORY_COMPACTION_OLDER: &str = "history_compaction_ol
/// System context marker for the light compression (newer segment) summary.
pub const SYSTEM_CONTEXT_HISTORY_COMPACTION_NEWER: &str = "history_compaction_newer";
/// Default threshold ratio: compress when estimated tokens exceed 70% of context window.
const DEFAULT_THRESHOLD_RATIO: f64 = 0.7;
/// Default budget split: older segment gets 30% of summary budget, newer gets 70%.
const OLDER_BUDGET_RATIO: f64 = 0.3;
const NEWER_BUDGET_RATIO: f64 = 0.7;
// ============================================================================
// HistoryUnit — atomic message units for compression
// ============================================================================
@ -39,6 +46,22 @@ enum HistoryUnit {
AssistantText(ChatMessage),
}
impl HistoryUnit {
/// Estimate tokens for this unit alone.
fn estimate_tokens(&self) -> usize {
match self {
HistoryUnit::SystemGuard(msg) | HistoryUnit::UserMessage(msg) | HistoryUnit::AssistantText(msg) => {
estimate_tokens(std::slice::from_ref(msg))
}
HistoryUnit::ToolRound { assistant, results } => {
let mut all = vec![assistant.clone()];
all.extend(results.clone());
estimate_tokens(&all)
}
}
}
}
// ============================================================================
// Unit parser — one forward pass, O(n)
// ============================================================================
@ -102,19 +125,9 @@ fn parse_to_units(messages: &[ChatMessage]) -> Vec<HistoryUnit> {
units
}
/// Flatten a HistoryUnit back into its constituent ChatMessage(s).
/// ToolRound yields [assistant, results...]; others yield a single message.
fn unit_to_messages(unit: &HistoryUnit) -> Vec<ChatMessage> {
match unit {
HistoryUnit::ToolRound { assistant, results } => {
let mut msgs = vec![assistant.clone()];
msgs.extend(results.clone());
msgs
}
HistoryUnit::AssistantText(msg)
| HistoryUnit::UserMessage(msg)
| HistoryUnit::SystemGuard(msg) => vec![msg.clone()],
}
/// Estimate total tokens from a slice of units.
fn estimate_tokens_from_units(units: &[HistoryUnit]) -> usize {
units.iter().map(|u| u.estimate_tokens()).sum()
}
/// Check if a character is CJK (Chinese, Japanese, Korean)
@ -149,8 +162,8 @@ pub fn estimate_tokens(messages: &[ChatMessage]) -> usize {
}
// Weighted token calculation: CJK chars need more tokens per character
let content_tokens =
(cjk_count as f64 / CJK_CHARS_PER_TOKEN) + (other_count as f64 / OTHER_CHARS_PER_TOKEN);
let content_tokens = (cjk_count as f64 / CJK_CHARS_PER_TOKEN)
+ (other_count as f64 / OTHER_CHARS_PER_TOKEN);
// JSON serialization overhead for message structure (fields, brackets, etc.)
let json_overhead = messages.len() * JSON_OVERHEAD_PER_MESSAGE;
@ -163,72 +176,6 @@ pub fn estimate_tokens(messages: &[ChatMessage]) -> usize {
* TOKEN_ESTIMATE_SAFETY_MULTIPLIER) as usize
}
/// 估算纯文本的 token 数(不含消息级 JSON 开销,仅内容)。
fn estimate_text_tokens(text: &str) -> usize {
let mut cjk = 0usize;
let mut other = 0usize;
for ch in text.chars() {
if is_cjk_char(ch) {
cjk += 1;
} else {
other += 1;
}
}
let content_tokens =
(cjk as f64 / CJK_CHARS_PER_TOKEN) + (other as f64 / OTHER_CHARS_PER_TOKEN);
(content_tokens * TOKEN_ESTIMATE_SAFETY_MULTIPLIER) as usize
}
/// 将文本截断到约 max_tokens确保 UTF-8 字符边界安全。
/// 截断后追加 "\n...[已截断]" 标记。
fn truncate_to_token_limit(text: &str, max_tokens: usize) -> String {
let mut token_count = 0.0f64;
let mut cutoff_byte = text.len();
for (byte_idx, ch) in text.char_indices() {
let char_tokens = if is_cjk_char(ch) { 0.5 } else { 0.25 };
token_count += char_tokens;
if token_count >= max_tokens as f64 {
cutoff_byte = byte_idx;
break;
}
}
if cutoff_byte >= text.len() {
return text.to_string();
}
let mut result = text[..cutoff_byte].to_string();
result.push_str("\n...[已截断]");
result
}
/// 工程化压缩:将非子代理的 tool 结果截断到约 max_tokens。
///
/// 规则:
/// - role="tool" 且 tool_name != "task" 的消息content 截断到约 max_tokens
/// - role="tool" 且 tool_name == "task"(子代理返回)的消息,保持原样
/// - 其他 role 的消息不受影响
/// - 截断时用 char_indices 确保 UTF-8 字符边界安全
/// - 截断后追加 "\n...[已截断]" 标记
///
/// **只修改传入的 messages不涉及 DB 操作。**
pub fn truncate_tool_results_in_place(messages: &mut [ChatMessage], max_tokens: usize) {
for msg in messages.iter_mut() {
if msg.role != "tool" {
continue;
}
if msg.tool_name.as_deref() == Some("task") {
continue;
}
let estimated = estimate_text_tokens(&msg.content);
if estimated <= max_tokens {
continue;
}
msg.content = truncate_to_token_limit(&msg.content, max_tokens);
}
}
/// Configuration for context compression.
#[derive(Debug, Clone)]
pub struct ContextCompressionConfig {
@ -267,14 +214,8 @@ impl Default for ContextCompressionConfig {
pub struct ContextCompressor {
config: ContextCompressionConfig,
context_window: usize,
/// Threshold ratio to trigger compression (50% of context window).
/// Threshold ratio to trigger compression (70% of context window).
threshold_ratio: f64,
/// LLM 压缩阈值比例(工程化压缩后仍超此比例才调 LLM
llm_compaction_threshold_ratio: f64,
/// 三段压缩保留 unit 数(最旧 N + 最新 N
preserve_count: usize,
/// 工程化压缩时 tool 结果截断 token 数
truncate_max_tokens: usize,
}
impl ContextCompressor {
@ -436,6 +377,44 @@ OLDER SEGMENT (events from earlier in the session):
{}
"#,
target_chars, transcript
)
}
/// Prompt for the newer segment — lighter compression, keep more detail.
fn build_light_summary_prompt(transcript: &str, target_chars: usize) -> String {
format!(
r#"You are a conversation compaction engine. Lightly summarize the following RECENT conversation segment. These events happened just before the current moment and will be marked as "".
An older segment (already summarized separately) precedes this. Keep enough detail so the model can continue the task seamlessly without re-reading files for data that appears in these recent results.
=== MUST PRESERVE (keep with more detail than a normal summary) ===
- All file paths, URLs, and identifiers
- The exact sequence of recent operations (step by step)
- Tool parameters (especially file paths, search queries, command strings)
- Key outputs and results (shortened but keep the substance)
- Error messages (complete, not truncated)
- Current task status and what should happen next
=== SHOULD CONDENSE ===
- Tool outputs truncate to the most meaningful parts (key data, conclusions, not raw output)
- Long text keep the essence but not every word
- Repeated similar outputs note the pattern but keep a representative example
=== SHOULD DROP ===
- Completely irrelevant debug output
- Boilerplate text with no informational value
- Trivial operations that have no bearing on the task
Be concise, aim for {} characters or less. Output the summary in Chinese if the original conversation was in Chinese.
---
RECENT SEGMENT (events from just before the current moment):
{}
"#,
target_chars, transcript
)
@ -518,9 +497,33 @@ OLDER SEGMENT (events from earlier in the session):
}
// =========================================================================
// Three-segment compression
// Two-segment compression
// =========================================================================
/// Find a safe split point in the unit list. The split ensures:
/// - Accumulated tokens up to `ratio` of total are placed in the older segment.
/// - The split never lands on a SystemGuard (they're always preserved).
/// - The split lands on a unit boundary (ToolRound, AssistantText, or UserMessage).
fn find_safe_split_point(&self, units: &[HistoryUnit], ratio: f64) -> usize {
let total_tokens = estimate_tokens_from_units(units);
let target = (total_tokens as f64 * ratio) as usize;
let mut accumulated = 0;
for (i, unit) in units.iter().enumerate() {
// Never split on a SystemGuard — they must be preserved
if matches!(unit, HistoryUnit::SystemGuard(_)) {
continue;
}
accumulated += unit.estimate_tokens();
if accumulated >= target {
return i + 1;
}
}
// Fallback: put everything in the older segment
units.len()
}
/// Summarize a transcript with a custom prompt builder function.
async fn summarize_with_prompt(
&self,
@ -611,165 +614,206 @@ OLDER SEGMENT (events from earlier in the session):
Ok(take_prefix_chars(transcript, target))
}
/// Main entry point for three-segment compression.
/// Main entry point for two-segment compression.
///
/// Preserves the oldest 5 units and newest 5 units in full, then
/// summarizes the middle segment with LLM. The result maintains
/// ToolRound atomicity and the middle summary is a pure system message
/// with no tool_calls — eliminating any risk of API 400 errors
/// Splits history into older and newer segments, then summarizes each
/// with LLM using different prompts and budget allocations. The result
/// contains no tool_calls or tool-result messages — only system summaries
/// and the original user message — eliminating any risk of API 400 errors
/// from orphaned tool call sequences.
pub async fn compress_two_segment(
&self,
history: &[ChatMessage],
provider_config: &LLMProviderConfig,
) -> Result<Vec<ChatMessage>, AgentError> {
let mut truncated: Vec<ChatMessage> = history.to_vec();
truncate_tool_results_in_place(&mut truncated, 100);
let tokens = estimate_tokens(&truncated);
if tokens <= self.threshold() {
return Ok(truncated);
}
let provider = create_provider(AgentRuntimeConfig::from(provider_config.clone()).provider)
.map_err(|e| AgentError::ProviderCreation(e.to_string()))?;
self.compress_two_segment_inner(&truncated, provider.as_ref())
.await
}
/// 复用调用方已有的 provider 实例(用于 AgentLoop 内,避免重复创建 provider
///
/// **注意**:此方法假设调用方已对 `history` 做过工程化压缩
/// `truncate_tool_results_in_place`),因此内部不再重复截断。
/// 这样可避免二次截断导致 `\n...[已截断]` 标记累积和内容损失。
pub async fn compress_two_segment_with_provider(
&self,
history: &[ChatMessage],
provider: &dyn LLMProvider,
) -> Result<Vec<ChatMessage>, AgentError> {
let tokens = estimate_tokens(history);
if tokens <= self.threshold() {
tracing::info!(
tokens = tokens,
threshold = self.threshold(),
msg_count = history.len(),
"Two-segment compression not needed (under threshold)"
);
return Ok(history.to_vec());
}
self.compress_two_segment_inner(history, provider).await
}
/// 三段压缩核心逻辑:保留最旧 N + 最新 N unit中间段用 LLM 压缩。
///
/// 策略:
/// - SystemGuard 永远保留在头部(不计入 N 条配额)
/// - 可压缩单元UserMessage / AssistantText / ToolRound按时间顺序
/// - 最旧 preserve_count 个 unit 原样保留
/// - 最新 preserve_count 个 unit 原样保留
/// - 中间段用 LLM 生成摘要system 消息,无 tool_calls
/// - ToolRound 原子性由 parse_to_units 保证,切分在 unit 边界
/// - 中间段摘要为纯文本 system 消息,符合 API 提交要求
async fn compress_two_segment_inner(
&self,
history: &[ChatMessage],
provider: &dyn LLMProvider,
) -> Result<Vec<ChatMessage>, AgentError> {
let preserve_count = self.preserve_count;
let tokens = estimate_tokens(history);
tracing::info!(
tokens = tokens,
threshold = self.threshold(),
msg_count = history.len(),
preserve_count = preserve_count,
"Starting three-segment compression"
"Starting two-segment compression"
);
// Step 1: Parse into atomic units
let units = parse_to_units(history);
// Step 1: Separate SystemGuard (always preserved, not counted in 5)
// Step 2: Separate system guards + user messages from compressible units
let mut system_guards: Vec<ChatMessage> = Vec::new();
let mut user_messages: Vec<ChatMessage> = Vec::new();
let mut compressible: Vec<HistoryUnit> = Vec::new();
for unit in units {
match unit {
HistoryUnit::SystemGuard(msg) => system_guards.push(msg),
HistoryUnit::UserMessage(msg) => user_messages.push(msg),
other => compressible.push(other),
}
}
// Step 2: If compressible units are too few, skip LLM compression
if compressible.len() <= preserve_count * 2 {
tracing::info!(
compressible_count = compressible.len(),
preserve_threshold = preserve_count * 2,
"Too few compressible units, skipping LLM compaction"
);
let mut result = system_guards;
for unit in &compressible {
result.extend(unit_to_messages(unit));
}
return Ok(result);
}
// Keep only the latest user message in full; older ones go into compression
let latest_user_msg = user_messages.pop();
// Step 3: Three-segment split
let split = compressible.len() - preserve_count;
let oldest_units = &compressible[..preserve_count];
let newest_units = &compressible[split..];
let middle_units = &compressible[preserve_count..split];
// Step 4: Build middle segment messages and transcript
let middle_messages: Vec<ChatMessage> = middle_units
.iter()
.flat_map(unit_to_messages)
.collect();
let middle_transcript = Self::build_transcript(&middle_messages);
// Step 5: Summarize middle segment with LLM (heavy prompt)
let budget = self.config.summary_max_chars;
let middle_summary = if middle_messages.is_empty() {
String::new()
// Step 3: Find split point in compressible units
let total_compressible = estimate_tokens_from_units(&compressible);
let split_point = if total_compressible > 0 {
self.find_safe_split_point(&compressible, 0.5)
} else {
self.summarize_units_segment(
provider,
&middle_messages,
&middle_transcript,
budget,
0
};
let older_units: Vec<&HistoryUnit> = compressible[..split_point].iter().collect();
let newer_units: Vec<&HistoryUnit> = compressible[split_point..].iter().collect();
// Step 4: Build transcripts
let older_messages: Vec<ChatMessage> = older_units
.iter()
.flat_map(|u| match u {
HistoryUnit::ToolRound { assistant, results } => {
let mut msgs = vec![assistant.clone()];
msgs.extend(results.clone());
msgs
}
HistoryUnit::AssistantText(msg) => vec![msg.clone()],
HistoryUnit::UserMessage(msg) => {
// Older user messages go into the compressible transcript
vec![msg.clone()]
}
_ => vec![],
})
.collect();
let newer_messages: Vec<ChatMessage> = newer_units
.iter()
.flat_map(|u| match u {
HistoryUnit::ToolRound { assistant, results } => {
let mut msgs = vec![assistant.clone()];
msgs.extend(results.clone());
msgs
}
HistoryUnit::AssistantText(msg) => vec![msg.clone()],
_ => vec![],
})
.collect();
// Include older user messages in the older transcript
let older_user_msgs: Vec<ChatMessage> = user_messages
.iter()
.map(|m| {
let mut msg = m.clone();
msg.role = "user".to_string();
msg
})
.collect();
let all_older_messages: Vec<ChatMessage> = older_user_msgs
.iter()
.chain(older_messages.iter())
.cloned()
.collect();
let older_transcript = Self::build_transcript(&all_older_messages);
let newer_transcript = Self::build_transcript(&newer_messages);
// Step 5: Budget allocation
let total_budget = self.config.summary_max_chars;
let older_budget = (total_budget as f64 * OLDER_BUDGET_RATIO) as usize;
let newer_budget = (total_budget as f64 * NEWER_BUDGET_RATIO) as usize;
// Step 6: Create provider and run both summaries (can be parallel)
let runtime_config = AgentRuntimeConfig::from(provider_config.clone());
let provider = create_provider(runtime_config.provider)
.map_err(|e| AgentError::ProviderCreation(e.to_string()))?;
let (older_result, newer_result) = if older_units.is_empty() && newer_units.is_empty() {
(Ok(String::new()), Ok(String::new()))
} else if older_units.is_empty() {
let result = self
.summarize_units_segment(
provider.as_ref(),
&newer_messages,
&newer_transcript,
newer_budget,
Self::build_light_summary_prompt,
)
.await;
(Ok(String::new()), result)
} else if newer_units.is_empty() {
let result = self
.summarize_units_segment(
provider.as_ref(),
&all_older_messages,
&older_transcript,
older_budget,
Self::build_heavy_summary_prompt,
)
.await;
(result, Ok(String::new()))
} else {
let older_fut = self.summarize_units_segment(
provider.as_ref(),
&all_older_messages,
&older_transcript,
older_budget,
Self::build_heavy_summary_prompt,
)
.await?
);
let newer_fut = self.summarize_units_segment(
provider.as_ref(),
&newer_messages,
&newer_transcript,
newer_budget,
Self::build_light_summary_prompt,
);
tokio::join!(older_fut, newer_fut)
};
// Step 6: Assemble compressed history
// [SystemGuards] + [oldest 5 units raw] + [middle summary] + [newest 5 units raw]
let mut compressed: Vec<ChatMessage> = Vec::with_capacity(
system_guards.len() + middle_messages.len() + 1 + middle_messages.len(),
);
let older_summary = older_result?;
let newer_summary = newer_result?;
// Step 7: Assemble compressed history
let mut compressed: Vec<ChatMessage> = Vec::with_capacity(4);
// System guards first
compressed.extend(system_guards);
// Oldest preserve_count units (raw)
for unit in oldest_units {
compressed.extend(unit_to_messages(unit));
// Latest user message
if let Some(user_msg) = latest_user_msg {
compressed.push(user_msg);
}
// Middle segment summary (system message, no tool_calls)
if !middle_summary.is_empty() {
// Heavy compression summary (older)
if !older_summary.is_empty() {
compressed.push(ChatMessage::system_with_context(
format!("## 较早的操作记录(已压缩)\n\n{}", middle_summary),
format!("## 较早的操作记录(已压缩)\n\n{}", older_summary),
Some(SYSTEM_CONTEXT_HISTORY_COMPACTION_OLDER.to_string()),
));
}
// Newest preserve_count units (raw)
for unit in newest_units {
compressed.extend(unit_to_messages(unit));
// Light compression summary (newer)
if !newer_summary.is_empty() {
compressed.push(ChatMessage::system_with_context(
format!(
"## 近期操作记录(轻度压缩,保留了更多细节)\n\n{}\n\n---\n以上为近期操作记录。如需精确数据,可使用工具重新读取相关文件。",
newer_summary
),
Some(SYSTEM_CONTEXT_HISTORY_COMPACTION_NEWER.to_string()),
));
}
tracing::info!(
original_tokens = tokens,
original_msg_count = history.len(),
final_tokens = estimate_tokens(&compressed),
final_msg_count = compressed.len(),
oldest_units = preserve_count,
newest_units = preserve_count,
middle_units = middle_units.len(),
"Three-segment compression completed"
older_units = older_units.len(),
newer_units = newer_units.len(),
"Two-segment compression completed"
);
Ok(compressed)
@ -780,50 +824,39 @@ OLDER SEGMENT (events from earlier in the session):
// =========================================================================
/// Create a new compressor with the given context window size.
/// 测试 fallback 路径:所有压缩参数用 CompactionConfig::default()。
pub fn new(context_window: usize) -> Self {
let default = CompactionConfig::default();
Self {
config: ContextCompressionConfig::default(),
context_window,
threshold_ratio: default.threshold_ratio,
llm_compaction_threshold_ratio: default.llm_compaction_threshold_ratio,
preserve_count: default.preserve_count,
truncate_max_tokens: default.truncate_max_tokens,
threshold_ratio: DEFAULT_THRESHOLD_RATIO,
}
}
/// 从 runtime config + compaction config 构造AgentFactory 生产路径调用)。
/// 所有压缩参数内聚到 ContextCompressorAgentLoop 零参数耦合。
/// 对用户配置做防御性 clamp避免极端值破坏压缩逻辑
/// - ratio 限制在 [0.1, 1.0]:过低导致每轮压缩,过高导致永不压缩
/// - truncate_max_tokens 限制 >= 10 会把所有 tool 结果截断成空串
/// - preserve_count 限制 >= 10 会丢失全部原始上下文
pub fn with_compaction_config(
context_window: usize,
summary_max_chars: usize,
compaction: &CompactionConfig,
) -> Self {
let clamp_ratio = |r: f64| r.clamp(0.1, 1.0);
let threshold_ratio = clamp_ratio(compaction.threshold_ratio);
let llm_compaction_threshold_ratio = clamp_ratio(compaction.llm_compaction_threshold_ratio);
let preserve_count = compaction.preserve_count.max(1);
let truncate_max_tokens = compaction.truncate_max_tokens.max(1);
Self {
config: ContextCompressionConfig {
summary_max_chars,
pub fn from_provider_config(provider_config: &LLMProviderConfig) -> Self {
Self::from_runtime_config(&AgentRuntimeConfig::from(provider_config.clone()))
}
pub fn from_runtime_config(config: &AgentRuntimeConfig) -> Self {
Self::with_config(
config.context_window_tokens,
ContextCompressionConfig {
summary_max_chars: config.context_summary_char_budget,
..ContextCompressionConfig::default()
},
)
}
/// Create with custom configuration.
pub fn with_config(context_window: usize, config: ContextCompressionConfig) -> Self {
Self {
config,
context_window,
threshold_ratio,
llm_compaction_threshold_ratio,
preserve_count,
truncate_max_tokens,
threshold_ratio: DEFAULT_THRESHOLD_RATIO,
}
}
/// Get the compression threshold in tokens (50% of context window).
pub fn threshold(&self) -> usize {
/// Get the compression threshold in tokens (70% of context window).
fn threshold(&self) -> usize {
(self.context_window as f64 * self.threshold_ratio) as usize
}
@ -831,22 +864,6 @@ OLDER SEGMENT (events from earlier in the session):
estimate_tokens(history) > self.threshold()
}
/// 触发阈值50%):用真实 prompt_tokens 判断是否进入压缩流程。
pub fn should_compress_by_usage(&self, prompt_tokens: u32) -> bool {
(prompt_tokens as usize) > self.threshold()
}
/// LLM 压缩阈值30%):工程化压缩后用 estimate_tokens 判断是否需要 LLM 压缩。
pub fn llm_compaction_threshold(&self) -> usize {
(self.context_window as f64 * self.llm_compaction_threshold_ratio) as usize
}
/// 工程化压缩:截断非子代理 tool 结果到 self.truncate_max_tokens。
/// AgentLoop 调用此方法,不需要知道截断参数细节(参数内聚)。
pub fn truncate_tool_results(&self, messages: &mut [ChatMessage]) {
truncate_tool_results_in_place(messages, self.truncate_max_tokens);
}
fn user_turn_ranges(&self, history: &[ChatMessage]) -> Vec<UserTurnRange> {
let user_indices: Vec<usize> = history
.iter()
@ -1097,7 +1114,9 @@ mod tests {
#[test]
fn test_estimate_tokens_mixed_content() {
let messages = vec![ChatMessage::user("Hello 世界 this is 测试")];
let messages = vec![
ChatMessage::user("Hello 世界 this is 测试"),
];
let tokens = estimate_tokens(&messages);
// Content: 18 English chars + 4 CJK chars
@ -1216,7 +1235,7 @@ mod tests {
#[test]
fn test_threshold() {
let compressor = ContextCompressor::new(128_000);
assert_eq!(compressor.threshold(), 64_000); // 50% of 128_000
assert_eq!(compressor.threshold(), 89_600); // 70% of 128_000
}
#[test]
@ -1377,6 +1396,22 @@ mod tests {
assert!(units.is_empty());
}
#[test]
fn test_find_safe_split_point() {
let compressor = ContextCompressor::new(100_000);
// Build a list of units with known token sizes
let messages: Vec<ChatMessage> = (0..10)
.map(|i| ChatMessage::assistant(&format!("message content number {}", i)))
.collect();
let units = parse_to_units(&messages);
// All units are AssistantText, split at 50% token ratio
let split = compressor.find_safe_split_point(&units, 0.5);
// Should split somewhere in the middle (not 0, not len())
assert!(split > 0 && split < units.len(),
"split {} should be between 0 and {}", split, units.len());
}
#[test]
fn test_compress_two_segment_no_tool_calls_in_output() {
// This test verifies the critical invariant:
@ -1403,7 +1438,6 @@ mod tests {
extra_headers: std::collections::HashMap::new(),
llm_timeout_secs: 120,
memory_maintenance_timeout_secs: 300,
max_retries: 3,
model_id: "test-model".to_string(),
temperature: None,
max_tokens: None,
@ -1424,14 +1458,10 @@ mod tests {
assert_eq!(compressed.len(), 3);
// Critical invariant: NO tool_calls or tool_call_id anywhere
for msg in &compressed {
assert!(
msg.tool_calls.is_none(),
"compress_two_segment output should never contain tool_calls"
);
assert!(
msg.tool_call_id.is_none(),
"compress_two_segment output should never contain tool_call_id"
);
assert!(msg.tool_calls.is_none(),
"compress_two_segment output should never contain tool_calls");
assert!(msg.tool_call_id.is_none(),
"compress_two_segment output should never contain tool_call_id");
}
}
}

View File

@ -4,12 +4,12 @@ pub mod runtime_config;
pub mod system_prompt;
pub use agent_loop::{
AgentError, AgentLoop, AgentProcessResult, CompactionSink, EmittedMessageHandler,
AgentError, AgentLoop, AgentProcessResult, EmittedMessageHandler,
PersistingEmittedMessageHandler, SkillProvider,
};
pub use context_compressor::ContextCompressor;
pub use runtime_config::AgentRuntimeConfig;
pub use system_prompt::{
CompositeSystemPromptProvider, SystemPrompt, SystemPromptContext, SystemPromptProvider,
generate_system_env_prompt,
CompositeSystemPromptProvider, generate_system_env_prompt, SystemPrompt, SystemPromptContext,
SystemPromptProvider,
};

View File

@ -12,9 +12,6 @@ pub struct AgentRuntimeConfig {
/// 图片上下文限制配置
pub max_images_in_context: usize,
pub max_image_age_rounds: usize,
/// LLM 请求瞬态失败的最大重试次数(仅对 timeout/502/503/504/429 等可恢复错误重试)。
/// 0 表示不重试。归属 agent 行为层,不进 ProviderRuntimeConfig保持 provider 构造包纯净)。
pub max_retries: u32,
}
impl From<LLMProviderConfig> for AgentRuntimeConfig {
@ -42,7 +39,6 @@ impl From<LLMProviderConfig> for AgentRuntimeConfig {
context_tool_result_trim_chars: config.context_tool_result_trim_chars,
max_images_in_context: config.max_images_in_context,
max_image_age_rounds: config.max_image_age_rounds,
max_retries: config.max_retries,
}
}
}

View File

@ -2,7 +2,6 @@ use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use crate::domain::messages::ToolCall;
use crate::utils::current_timestamp;
pub const SYSTEM_CONTEXT_AGENT_PROMPT: &str = "agent_prompt";
pub const SYSTEM_CONTEXT_SCHEDULED_PROMPT: &str = "scheduled_system_prompt";
@ -26,7 +25,7 @@ pub struct MediaItem {
pub mime_type: Option<String>,
pub original_key: Option<String>, // Feishu file_key for download
pub content_base64: Option<String>, // Base64-encoded file content for web download
pub file_name: Option<String>, // Display file name
pub file_name: Option<String>, // Display file name
}
impl MediaItem {
@ -67,38 +66,6 @@ pub struct ChatMessage {
pub tool_duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
/// LLM 调用 usage仅 assistant 消息有值,来自 provider 响应)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<MessageUsage>,
}
/// 单次 LLM 调用的 token 用量
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MessageUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
/// 本次调用所用模型的上下文窗口大小(来自 AgentRuntimeConfig
/// 与 prompt_tokens 一起持久化,用于计算上下文占用率。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window_tokens: Option<u32>,
}
impl MessageUsage {
pub fn from_provider_usage(u: crate::providers::Usage) -> Self {
Self {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
context_window_tokens: None,
}
}
/// 链式设置 context_window_tokens来自 AgentRuntimeConfig
pub fn with_context_window(mut self, ctx: usize) -> Self {
self.context_window_tokens = Some(ctx as u32);
self
}
}
impl ChatMessage {
@ -116,7 +83,6 @@ impl ChatMessage {
tool_duration_ms: None,
tool_state: None,
tool_calls: None,
usage: None,
}
}
@ -134,7 +100,6 @@ impl ChatMessage {
tool_duration_ms: None,
tool_state: None,
tool_calls: None,
usage: None,
}
}
@ -152,7 +117,6 @@ impl ChatMessage {
tool_duration_ms: None,
tool_state: None,
tool_calls: None,
usage: None,
}
}
@ -182,7 +146,6 @@ impl ChatMessage {
tool_duration_ms: None,
tool_state: None,
tool_calls: Some(tool_calls),
usage: None,
}
}
@ -217,7 +180,6 @@ impl ChatMessage {
tool_duration_ms: None,
tool_state: None,
tool_calls: None,
usage: None,
}
}
@ -253,7 +215,6 @@ impl ChatMessage {
tool_duration_ms: None,
tool_state: Some(tool_state),
tool_calls: None,
usage: None,
}
}
@ -323,13 +284,12 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
}
if msg.role == "assistant"
&& msg
.tool_calls
.as_ref()
.map_or(false, |calls| !calls.is_empty())
&& msg.tool_calls.as_ref().map_or(false, |calls| !calls.is_empty())
{
let tool_calls = msg.tool_calls.as_ref().unwrap();
let all_have_results = tool_calls.iter().all(|tc| resolved_ids.contains(&tc.id));
let all_have_results = tool_calls
.iter()
.all(|tc| resolved_ids.contains(&tc.id));
if all_have_results {
for tc in tool_calls.iter() {
@ -399,19 +359,12 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
}
if m.role == "assistant"
&& m.tool_calls
.as_ref()
.map_or(false, |calls| !calls.is_empty())
&& m.tool_calls.as_ref().map_or(false, |calls| !calls.is_empty())
{
let already_marked = remove_indices.contains(&i);
if !already_marked {
pending_tool_ids = m
.tool_calls
.as_ref()
.unwrap()
.iter()
.map(|tc| tc.id.clone())
.collect();
pending_tool_ids = m.tool_calls.as_ref().unwrap()
.iter().map(|tc| tc.id.clone()).collect();
pending_assistant_idx = Some(i);
}
} else if m.role == "tool" {
@ -558,10 +511,7 @@ pub enum OutboundEventKind {
impl OutboundMessage {
pub fn is_stream_delta(&self) -> bool {
matches!(
self.event_kind,
OutboundEventKind::StreamDelta | OutboundEventKind::StreamEnd
)
matches!(self.event_kind, OutboundEventKind::StreamDelta | OutboundEventKind::StreamEnd)
}
pub fn assistant(
@ -598,8 +548,7 @@ impl OutboundMessage {
reply_to: Option<String>,
metadata: HashMap<String, String>,
) -> Self {
let mut message =
Self::assistant(channel, chat_id, session_id, content, reply_to, metadata);
let mut message = Self::assistant(channel, chat_id, session_id, content, reply_to, metadata);
message.event_kind = OutboundEventKind::SchedulerNotification;
message
}
@ -612,8 +561,7 @@ impl OutboundMessage {
reply_to: Option<String>,
metadata: HashMap<String, String>,
) -> Self {
let mut message =
Self::assistant(channel, chat_id, session_id, content, reply_to, metadata);
let mut message = Self::assistant(channel, chat_id, session_id, content, reply_to, metadata);
message.event_kind = OutboundEventKind::ErrorNotification;
message
}
@ -801,8 +749,7 @@ impl OutboundMessage {
"assistant" => {
if let Some(tool_calls) = &message.tool_calls {
let mut outbound = Vec::new();
let has_content_or_reasoning =
!message.content.trim().is_empty() || message.reasoning_content.is_some();
let has_content_or_reasoning = !message.content.trim().is_empty() || message.reasoning_content.is_some();
if has_content_or_reasoning {
let mut resp = Self::assistant(
channel.to_string(),
@ -819,11 +766,7 @@ impl OutboundMessage {
// AssistantResponse 已携带 reasoning 时ToolCall 不再重复;
// 只有 AssistantResponse 没发时ToolCall 才带 reasoning
let tc_reasoning = if has_content_or_reasoning {
None
} else {
message.reasoning_content.clone()
};
let tc_reasoning = if has_content_or_reasoning { None } else { message.reasoning_content.clone() };
outbound.extend(tool_calls.iter().map(|tool_call| {
let mut tc = Self::tool_call(
channel.to_string(),
@ -932,6 +875,13 @@ fn format_tool_arguments_json(value: &serde_json::Value) -> String {
// Helpers
// ============================================================================
fn current_timestamp() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
#[cfg(test)]
mod tests {
use super::{ChatMessage, OutboundEventKind, OutboundMessage, ToolMessageState};
@ -980,7 +930,10 @@ mod tests {
"calculator\nargs: {\"expression\":\"1 + 1\"}"
);
assert_eq!(outbound[1].tool_name.as_deref(), Some("read"));
assert_eq!(outbound[1].content, "read\nargs: {\"path\":\"README.md\"}");
assert_eq!(
outbound[1].content,
"read\nargs: {\"path\":\"README.md\"}"
);
}
#[test]

View File

@ -52,26 +52,14 @@ impl MessageBus {
Some(msg)
}
/// Publish a message to the outbound queue.
///
/// Uses `try_send` (non-blocking): if the queue is full, the message is
/// dropped immediately with a warning. This ensures the agent loop is never
/// blocked by slow or disconnected display consumers. Persistent state is
/// unaffected — messages are stored in SQLite independently.
/// Publish a message to the outbound queue
pub async fn publish_outbound(&self, msg: OutboundMessage) -> Result<(), BusError> {
#[cfg(debug_assertions)]
tracing::debug!(channel = %msg.channel, chat_id = %msg.chat_id, content_len = %msg.content.len(), "Bus: publishing outbound message");
match self.outbound_tx.try_send(msg) {
Ok(()) => Ok(()),
Err(tokio::sync::mpsc::error::TrySendError::Full(msg)) => {
tracing::warn!(
channel = %msg.channel,
"Outbound bus full, dropping message"
);
Err(BusError::Dropped)
}
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => Err(BusError::Closed),
}
self.outbound_tx
.send(msg)
.await
.map_err(|_| BusError::Closed)
}
/// Consume an outbound message from the outbound queue.
@ -88,14 +76,12 @@ impl MessageBus {
#[derive(Debug)]
pub enum BusError {
Closed,
Dropped,
}
impl std::fmt::Display for BusError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BusError::Closed => write!(f, "Bus channel closed"),
BusError::Dropped => write!(f, "Bus full, message dropped"),
}
}
}

View File

@ -8,9 +8,6 @@ pub enum ChannelError {
ConfigError(String),
ConnectionError(String),
SendError(String),
/// Channel 内部队列已满——不可重试,立即丢弃。
/// 重试只会浪费退避时间并阻塞 channel 队列消费。
ChannelFull,
BusError(String),
Other(String),
}
@ -21,7 +18,6 @@ impl std::fmt::Display for ChannelError {
ChannelError::ConfigError(s) => write!(f, "Config error: {}", s),
ChannelError::ConnectionError(s) => write!(f, "Connection error: {}", s),
ChannelError::SendError(s) => write!(f, "Send error: {}", s),
ChannelError::ChannelFull => write!(f, "Channel queue full"),
ChannelError::BusError(s) => write!(f, "Bus error: {}", s),
ChannelError::Other(s) => write!(f, "Error: {}", s),
}

View File

@ -84,10 +84,7 @@ impl Channel for CliChannel {
self.shutdown_token.cancel();
let count = self.connections.read().await.len();
self.connections.write().await.clear();
tracing::info!(
connection_count = count,
"CliChannel stopped, all connections signaled to close"
);
tracing::info!(connection_count = count, "CliChannel stopped, all connections signaled to close");
Ok(())
}
@ -100,22 +97,12 @@ impl Channel for CliChannel {
)));
};
// 使用 try_send 避免阻塞 dispatcher——dispatcher 是单线程顺序处理,
// 若 writer task 卡在 ws_sender.send() 上send().await 会阻塞,
// 导致所有连接的实时消息被阻塞。try_send 满时返回 ChannelFull
// (不可重试,立即丢弃);关闭时返回 SendError可重试
for outbound in ws_outbound_from_outbound_message(&msg) {
match connection.sender.try_send(outbound) {
Ok(()) => {}
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
return Err(ChannelError::ChannelFull);
}
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
return Err(ChannelError::SendError(
"CLI websocket sender closed".to_string(),
));
}
}
connection
.sender
.send(outbound)
.await
.map_err(|_| ChannelError::SendError("CLI websocket sender closed".to_string()))?;
}
Ok(())

View File

@ -1,7 +1,6 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::sync::LazyLock;
use std::time::{Duration, Instant};
use async_trait::async_trait;
@ -11,8 +10,8 @@ use regex::Regex;
use serde::Deserialize;
use tokio::sync::{RwLock, broadcast};
use crate::bus::message::OutboundEventKind;
use crate::bus::{MediaItem, MessageBus, OutboundMessage};
use crate::bus::message::OutboundEventKind;
use crate::channels::base::{Channel, ChannelError};
use crate::config::{FeishuChannelConfig, LLMProviderConfig};
use crate::text::{char_count, truncate_with_ellipsis};
@ -549,10 +548,9 @@ impl FeishuChannel {
}
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| ChannelError::Other(format!("Read upload image response error: {}", e)))?;
let body = resp.text().await.map_err(|e| {
ChannelError::Other(format!("Read upload image response error: {}", e))
})?;
let result: UploadResp = serde_json::from_str(&body).map_err(|e| {
ChannelError::Other(format!(
"Parse upload image response error: {} (status={}, body={})",
@ -633,10 +631,9 @@ impl FeishuChannel {
}
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| ChannelError::Other(format!("Read upload file response error: {}", e)))?;
let body = resp.text().await.map_err(|e| {
ChannelError::Other(format!("Read upload file response error: {}", e))
})?;
let result: UploadResp = serde_json::from_str(&body).map_err(|e| {
ChannelError::Other(format!(
"Parse upload file response error: {} (status={}, body={})",
@ -985,11 +982,9 @@ impl FeishuChannel {
reply_to: Option<&str>,
) -> Result<(), ChannelError> {
if let Some(parent_id) = reply_to {
self.reply_to_feishu_message(parent_id, msg_type, content)
.await
self.reply_to_feishu_message(parent_id, msg_type, content).await
} else {
self.send_message_to_feishu(receive_id, receive_id_type, msg_type, content)
.await
self.send_message_to_feishu(receive_id, receive_id_type, msg_type, content).await
}
}
@ -1311,7 +1306,6 @@ impl FeishuChannel {
let channel = self.clone();
let bus = bus.clone();
tokio::spawn(async move {
#[cfg(debug_assertions)]
let media_count = if parsed.media.is_some() { 1 } else { 0 };
#[cfg(debug_assertions)]
tracing::debug!(open_id = %parsed.open_id, chat_id = %parsed.chat_id, content_len = %parsed.content.len(), media_count = %media_count, "Publishing message to bus");
@ -1446,20 +1440,16 @@ fn parse_post_content(content: &str) -> String {
}
"code_block" => {
let lang = el.get("language").and_then(|l| l.as_str()).unwrap_or("");
let code_text =
if let Some(content_arr) = el.get("content").and_then(|c| c.as_array()) {
content_arr
.iter()
.filter_map(|item| item.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("")
} else {
// Fallback to text field for backwards compatibility
el.get("text")
.and_then(|t| t.as_str())
.unwrap_or("")
.to_string()
};
let code_text = if let Some(content_arr) = el.get("content").and_then(|c| c.as_array()) {
content_arr
.iter()
.filter_map(|item| item.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("")
} else {
// Fallback to text field for backwards compatibility
el.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string()
};
out.push(format!("\n```{}\n{}\n```\n", lang, code_text));
}
_ => {
@ -1865,13 +1855,10 @@ impl Default for MdPatterns {
}
}
/// 全局唯一的正则模式实例,仅编译一次。
static MD_PATTERNS: LazyLock<MdPatterns> = LazyLock::new(MdPatterns::new);
impl FeishuChannel {
/// Determine the optimal Feishu message format for content.
fn detect_msg_format(content: &str) -> MsgFormat {
let patterns = &MD_PATTERNS;
let patterns = MdPatterns::new();
let stripped = content.trim();
// Tables and headings are not supported by post `md` nodes, so use cards.
@ -1907,7 +1894,7 @@ impl FeishuChannel {
/// Strip markdown formatting markers from text for plain display.
fn strip_md_formatting(text: &str) -> String {
let patterns = &MD_PATTERNS;
let patterns = MdPatterns::new();
let mut result = text.to_string();
// Remove bold markers
@ -1986,7 +1973,7 @@ impl FeishuChannel {
/// Split content by headings, converting headings to div elements.
fn split_headings(content: &str) -> Vec<serde_json::Value> {
let patterns = &MD_PATTERNS;
let patterns = MdPatterns::new();
let mut protected = content.to_string();
// Protect code blocks by replacing them with placeholders
@ -2092,7 +2079,7 @@ impl FeishuChannel {
/// Build content into card elements (div/markdown + table).
fn build_card_elements(content: &str) -> Vec<serde_json::Value> {
let patterns = &MD_PATTERNS;
let patterns = MdPatterns::new();
let mut elements: Vec<serde_json::Value> = Vec::new();
let mut last_end = 0;
@ -2393,8 +2380,7 @@ mod tests {
#[test]
fn parse_post_content_handles_empty_code_block() {
// Test code_block with empty content
let post_json =
r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"go"}]]}}}"#;
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"go"}]]}}}"#;
let result = parse_post_content(post_json);
assert!(result.contains("```go"));
}
@ -2475,18 +2461,8 @@ impl Channel for FeishuChannel {
}
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
if matches!(
msg.event_kind,
OutboundEventKind::ToolResult
| OutboundEventKind::ToolPending
| OutboundEventKind::StreamDelta
| OutboundEventKind::StreamEnd
| OutboundEventKind::ExecutionCompleted
) || msg
.metadata
.get("is_subagent_event")
.map(|v| v == "true")
.unwrap_or(false)
if matches!(msg.event_kind, OutboundEventKind::ToolResult | OutboundEventKind::ToolPending | OutboundEventKind::StreamDelta | OutboundEventKind::StreamEnd | OutboundEventKind::ExecutionCompleted)
|| msg.metadata.get("is_subagent_event").map(|v| v == "true").unwrap_or(false)
{
return Ok(());
}
@ -2509,15 +2485,6 @@ impl Channel for FeishuChannel {
"open_id"
};
// reaction 语义:仅"终态消息"成功发送后才移除 reaction。
// 终态 = AssistantResponse(最终响应)或 ErrorNotification(agent 异常终止的错误通知)。
// ToolCall 等中间过程事件不触碰 reaction——让 reaction 真实反映用户是否已收到
// agent 的最终产出(reaction 持续 = 尚未收到响应或错误;reaction 消失 = 已收到)。
// 发送失败时保留 reaction 作为异常信号;空内容不视为送达,保留 reaction。
let is_terminal = matches!(
msg.event_kind,
OutboundEventKind::AssistantResponse | OutboundEventKind::ErrorNotification
);
let remove_reaction = async {
self.remove_reaction_from_metadata(&msg.metadata).await;
};
@ -2528,14 +2495,7 @@ impl Channel for FeishuChannel {
// Empty content
if content.is_empty() {
// 空最终响应是异常:保留 reaction 让用户察觉,仅记录 warn。
// 非最终响应(如 ToolCall 空内容)本来就不触碰 reaction。
if is_terminal {
tracing::warn!(
chat_id = %msg.chat_id,
"Final response has empty content, keeping reaction as anomaly signal"
);
}
remove_reaction.await;
return Ok(());
}
@ -2547,10 +2507,7 @@ impl Channel for FeishuChannel {
let result = self
.dispatch_send(receive_id, receive_id_type, "text", content, reply_to)
.await;
// 仅最终响应且发送成功才移除 reaction
if is_terminal && result.is_ok() {
remove_reaction.await;
}
remove_reaction.await;
return result;
}
MsgFormat::Post => {
@ -2559,9 +2516,7 @@ impl Channel for FeishuChannel {
let result = self
.dispatch_send(receive_id, receive_id_type, "post", &post_body, reply_to)
.await;
if is_terminal && result.is_ok() {
remove_reaction.await;
}
remove_reaction.await;
return result;
}
MsgFormat::Interactive => {
@ -2587,31 +2542,19 @@ impl Channel for FeishuChannel {
reply_to,
)
.await;
// 回退成功才移除 reaction;回退失败则保留作为异常信号
if is_terminal && result.is_ok() {
remove_reaction.await;
}
remove_reaction.await;
return result;
}
}
// 所有 chunk 成功:仅最终响应移除 reaction
if is_terminal {
remove_reaction.await;
}
remove_reaction.await;
return Ok(());
}
}
}
if !msg.content.trim().is_empty() {
self.dispatch_send(
receive_id,
receive_id_type,
"text",
msg.content.trim(),
reply_to,
)
.await?;
self.dispatch_send(receive_id, receive_id_type, "text", msg.content.trim(), reply_to)
.await?;
}
let mut sent_media = 0usize;
@ -2650,23 +2593,19 @@ impl Channel for FeishuChannel {
Ok(()) => sent_media += 1,
Err(error) => {
tracing::warn!(error = %error, path = %path, media_type = %media_item.media_type, "Failed to send media message to Feishu");
// 媒体失败:保留 reaction 作为异常信号(不调用 remove_reaction)
return Err(error);
}
}
}
if msg.content.trim().is_empty() && sent_media == 0 {
// 无内容无媒体:保留 reaction 作为异常信号
remove_reaction.await;
return Err(ChannelError::Other(
"No supported media items were sent to Feishu".to_string(),
));
}
// 全部成功:仅最终响应移除 reaction
if is_terminal {
remove_reaction.await;
}
remove_reaction.await;
Ok(())
}
}

View File

@ -26,7 +26,7 @@ impl ChannelManager {
Self {
channels: Arc::new(RwLock::new(channels)),
bus: MessageBus::new(256),
bus: MessageBus::new(100),
websocket_channel,
}
}
@ -48,9 +48,7 @@ impl ChannelManager {
) -> Result<(), ChannelError> {
for (name, channel_config) in &config.channels {
match channel_config {
crate::config::ChannelConfig::Tagged(TaggedChannelConfig::Feishu(
feishu_config,
))
crate::config::ChannelConfig::Tagged(TaggedChannelConfig::Feishu(feishu_config))
| crate::config::ChannelConfig::LegacyFeishu(feishu_config) => {
if feishu_config.enabled {
let channel = FeishuChannel::new(
@ -74,9 +72,7 @@ impl ChannelManager {
tracing::info!(channel = %name, kind = channel_config.kind(), "Channel disabled in config");
}
}
crate::config::ChannelConfig::Tagged(TaggedChannelConfig::Wechat(
wechat_config,
)) => {
crate::config::ChannelConfig::Tagged(TaggedChannelConfig::Wechat(wechat_config)) => {
if wechat_config.enabled {
let channel = WechatChannel::new(
name.clone(),
@ -257,14 +253,8 @@ mod tests {
names.sort();
assert_eq!(names, vec!["backup", "primary", "websocket"]);
assert_eq!(
manager.get_channel("primary").await.unwrap().name(),
"primary"
);
assert_eq!(
manager.get_channel("backup").await.unwrap().name(),
"backup"
);
assert_eq!(manager.get_channel("primary").await.unwrap().name(), "primary");
assert_eq!(manager.get_channel("backup").await.unwrap().name(), "backup");
}
#[tokio::test]
@ -305,8 +295,7 @@ mod tests {
"cred_path": "<CRED_PATH>"
}
}
}"#
.replace("<CRED_PATH>", &cred_path_json),
}"#.replace("<CRED_PATH>", &cred_path_json),
)
.unwrap();
@ -325,9 +314,6 @@ mod tests {
names.sort();
assert_eq!(names, vec!["websocket", "wechat_main"]);
assert_eq!(
manager.get_channel("wechat_main").await.unwrap().name(),
"wechat_main"
);
assert_eq!(manager.get_channel("wechat_main").await.unwrap().name(), "wechat_main");
}
}

View File

@ -13,8 +13,8 @@ use tokio::sync::RwLock;
use tokio::task::JoinHandle;
use wechatbot::{BotOptions, SendContent, WeChatBot};
use crate::bus::message::OutboundEventKind;
use crate::bus::{InboundMessage, MediaItem, MessageBus, OutboundMessage};
use crate::bus::message::OutboundEventKind;
use crate::channels::base::{Channel, ChannelError};
use crate::config::{LLMProviderConfig, WechatChannelConfig};
@ -55,29 +55,19 @@ impl WechatChannel {
}
fn sender_allowed(&self, sender_id: &str) -> bool {
self.config
.allow_from
.iter()
.any(|pattern| pattern == "*" || pattern == sender_id)
self.config.allow_from.iter().any(|pattern| pattern == "*" || pattern == sender_id)
}
async fn media_to_send_content(
fn media_to_send_content(
media: &MediaItem,
caption: Option<String>,
) -> Result<SendContent, ChannelError> {
// 媒体文件读取是阻塞 IO放到 blocking 线程池避免阻塞 async worker。
let path = media.path.clone();
let data = tokio::task::spawn_blocking(move || std::fs::read(&path))
.await
.map_err(|e| {
ChannelError::SendError(format!("WeChat media read task failed: {}", e))
})?
.map_err(|error| {
ChannelError::SendError(format!(
"WeChat media read failed for '{}': {}",
media.path, error
))
})?;
let data = std::fs::read(&media.path).map_err(|error| {
ChannelError::SendError(format!(
"WeChat media read failed for '{}': {}",
media.path, error
))
})?;
if data.is_empty() {
return Err(ChannelError::SendError(format!(
@ -104,7 +94,7 @@ impl WechatChannel {
}
fn default_media_dir() -> PathBuf {
let home = crate::platform::picobot_home_dir();
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".picobot").join("media").join("wechat")
}
@ -142,17 +132,14 @@ impl WechatChannel {
) -> Result<Vec<MediaItem>, ChannelError> {
let Some(downloaded) = bot.download(&msg).await.map_err(|error| {
ChannelError::Other(format!("WeChat media download failed: {}", error))
})?
else {
})? else {
return Ok(Vec::new());
};
let media_dir = Self::default_media_dir();
tokio::fs::create_dir_all(&media_dir)
.await
.map_err(|error| {
ChannelError::Other(format!("Failed to create WeChat media dir: {}", error))
})?;
.map_err(|error| ChannelError::Other(format!("Failed to create WeChat media dir: {}", error)))?;
let filename = Self::build_download_filename(
&downloaded.media_type,
@ -162,9 +149,7 @@ impl WechatChannel {
let file_path = media_dir.join(&filename);
tokio::fs::write(&file_path, downloaded.data)
.await
.map_err(|error| {
ChannelError::Other(format!("Failed to write WeChat media file: {}", error))
})?;
.map_err(|error| ChannelError::Other(format!("Failed to write WeChat media file: {}", error)))?;
tracing::info!(filename = %filename, media_type = %downloaded.media_type, "Downloaded WeChat media");
@ -331,11 +316,7 @@ impl Channel for WechatChannel {
| OutboundEventKind::StreamDelta
| OutboundEventKind::StreamEnd
| OutboundEventKind::ExecutionCompleted
) || msg
.metadata
.get("is_subagent_event")
.map(|v| v == "true")
.unwrap_or(false)
) || msg.metadata.get("is_subagent_event").map(|v| v == "true").unwrap_or(false)
{
return Ok(());
}
@ -362,13 +343,10 @@ impl Channel for WechatChannel {
} else {
None
};
let content = Self::media_to_send_content(media, caption).await?;
self.bot
.send_media(&msg.chat_id, content)
.await
.map_err(|error| {
ChannelError::SendError(format!("WeChat media send failed: {}", error))
})?;
let content = Self::media_to_send_content(media, caption)?;
self.bot.send_media(&msg.chat_id, content).await.map_err(|error| {
ChannelError::SendError(format!("WeChat media send failed: {}", error))
})?;
tracing::info!(
channel = %self.name,
chat_id = %msg.chat_id,
@ -410,33 +388,34 @@ mod tests {
assert!(filename.ends_with(".silk"));
}
#[tokio::test]
async fn media_to_send_content_maps_image() {
#[test]
fn media_to_send_content_maps_image() {
let file = NamedTempFile::new().unwrap();
std::fs::write(file.path(), b"demo-image").unwrap();
let image_path = file.path().with_extension("png");
std::fs::rename(file.path(), &image_path).unwrap();
let media = MediaItem::new(image_path.to_string_lossy().to_string(), "image");
let content = WechatChannel::media_to_send_content(&media, None).await.unwrap();
let content = WechatChannel::media_to_send_content(&media, None).unwrap();
assert!(matches!(content, SendContent::Image { .. }));
}
#[tokio::test]
async fn media_to_send_content_maps_generic_file() {
#[test]
fn media_to_send_content_maps_generic_file() {
let file = NamedTempFile::new().unwrap();
std::fs::write(file.path(), b"hello").unwrap();
let doc_path = file.path().with_extension("md");
std::fs::rename(file.path(), &doc_path).unwrap();
let media = MediaItem::new(doc_path.to_string_lossy().to_string(), "file");
let content =
WechatChannel::media_to_send_content(&media, Some("note".to_string())).await.unwrap();
let content = WechatChannel::media_to_send_content(&media, Some("note".to_string())).unwrap();
match content {
SendContent::File {
file_name, caption, ..
file_name,
caption,
..
} => {
assert_eq!(file_name, doc_path.file_name().unwrap().to_string_lossy());
assert_eq!(caption.as_deref(), Some("note"));

View File

@ -18,7 +18,7 @@ pub struct InitWizard {
impl InitWizard {
pub fn new() -> Self {
let home = crate::platform::picobot_home_dir();
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
Self {
read: BufReader::new(tokio::io::stdin()),
write: tokio::io::stdout(),
@ -81,7 +81,6 @@ impl InitWizard {
image_context: crate::config::ImageContextConfig::default(),
subagents: crate::config::SubagentsConfig::default(),
experts: crate::config::ExpertsConfig::default(),
compaction: crate::config::CompactionConfig::default(),
}
}
@ -118,11 +117,7 @@ impl InitWizard {
}
let input = line.trim().to_string();
Ok(if input.is_empty() {
default.to_string()
} else {
input
})
Ok(if input.is_empty() { default.to_string() } else { input })
}
async fn prompt_required(&mut self, label: &str) -> Result<String, InitError> {
@ -159,9 +154,9 @@ impl InitWizard {
let default_str = (default + 1).to_string();
let input = self.prompt_with_default(label, &default_str).await?;
let selected: usize = input
.parse()
.map_err(|_| InitError::InputError(format!("Invalid selection: {}", input)))?;
let selected: usize = input.parse().map_err(|_| {
InitError::InputError(format!("Invalid selection: {}", input))
})?;
if selected == 0 || selected > options.len() {
return Err(InitError::InputError(format!(
@ -200,7 +195,9 @@ impl InitWizard {
println!(" 4. Skip");
println!();
let choice = self.prompt_with_default("Select option", "1").await?;
let choice = self
.prompt_with_default("Select option", "1")
.await?;
match choice.as_str() {
"1" => return self.add_provider(existing).await,
@ -246,7 +243,9 @@ impl InitWizard {
&mut self,
existing: &Config,
) -> Result<HashMap<String, ProviderConfig>, InitError> {
let provider_name = self.prompt_with_default("Provider name", "default").await?;
let provider_name = self
.prompt_with_default("Provider name", "default")
.await?;
println!("Provider type:");
println!(" 1. openai");
@ -272,7 +271,6 @@ impl InitWizard {
extra_headers: HashMap::new(),
llm_timeout_secs: 120,
memory_maintenance_timeout_secs: 600,
max_retries: 3,
};
let mut providers = existing.providers.clone();
@ -309,9 +307,7 @@ impl InitWizard {
};
let type_options = vec!["openai".to_string(), "anthropic".to_string()];
println!("Provider type:");
let type_idx = self
.prompt_select("", &type_options, current_type_idx)
.await?;
let type_idx = self.prompt_select("", &type_options, current_type_idx).await?;
let provider_type = &type_options[type_idx];
let base_url = self
@ -331,7 +327,6 @@ impl InitWizard {
extra_headers: current_provider.extra_headers.clone(),
llm_timeout_secs: current_provider.llm_timeout_secs,
memory_maintenance_timeout_secs: current_provider.memory_maintenance_timeout_secs,
max_retries: current_provider.max_retries,
};
let mut providers = existing.providers.clone();
@ -541,7 +536,9 @@ impl InitWizard {
providers: &HashMap<String, ProviderConfig>,
models: &HashMap<String, ModelConfig>,
) -> Result<HashMap<String, AgentConfig>, InitError> {
let agent_name = self.prompt_with_default("Agent name", "default").await?;
let agent_name = self
.prompt_with_default("Agent name", "default")
.await?;
// Select provider
let provider_names: Vec<String> = providers.keys().cloned().collect();
@ -603,9 +600,7 @@ impl InitWizard {
.position(|p| p == &current_agent.provider)
.unwrap_or(0);
println!("Select provider:");
let provider_idx = self
.prompt_select("", &provider_names, current_provider_idx)
.await?;
let provider_idx = self.prompt_select("", &provider_names, current_provider_idx).await?;
let selected_provider = &provider_names[provider_idx];
// Select new model
@ -616,9 +611,7 @@ impl InitWizard {
.unwrap_or(0);
println!();
println!("Select model:");
let model_idx = self
.prompt_select("", &model_names, current_model_idx)
.await?;
let model_idx = self.prompt_select("", &model_names, current_model_idx).await?;
let selected_model = &model_names[model_idx];
let agent = AgentConfig {
@ -657,11 +650,7 @@ impl InitWizard {
if !existing.channels.is_empty() {
println!("Existing channels:");
for (name, config) in &existing.channels {
let status = if config.enabled() {
"enabled"
} else {
"disabled"
};
let status = if config.enabled() { "enabled" } else { "disabled" };
println!(" - {} ({})", name, status);
}
println!();
@ -711,7 +700,9 @@ impl InitWizard {
println!("Configuring Feishu channel...");
println!();
let channel_name = self.prompt_with_default("Channel name", "feishu").await?;
let channel_name = self
.prompt_with_default("Channel name", "feishu")
.await?;
let _existing_config = existing.get(&channel_name).and_then(|c| c.as_feishu());
@ -775,8 +766,7 @@ impl InitWizard {
println!();
println!("Starting WeChat login...");
self.do_wechat_login(base_url, &Self::default_wechat_cred_path())
.await?;
self.do_wechat_login(base_url, &Self::default_wechat_cred_path()).await?;
println!();
println!("WeChat login successful! Credentials saved.");
@ -806,10 +796,9 @@ impl InitWizard {
})),
});
let creds = bot
.login(true)
.await
.map_err(|e| InitError::WeChatError(format!("WeChat login failed: {}", e)))?;
let creds = bot.login(true).await.map_err(|e| {
InitError::WeChatError(format!("WeChat login failed: {}", e))
})?;
println!();
println!(
@ -846,7 +835,6 @@ impl InitWizard {
image_context: existing.image_context.clone(),
subagents: existing.subagents.clone(),
experts: existing.experts.clone(),
compaction: existing.compaction.clone(),
}
}
@ -892,14 +880,14 @@ impl InitWizard {
}
fn default_feishu_media_dir() -> String {
let home = crate::platform::picobot_home_dir();
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".picobot/media/feishu")
.to_string_lossy()
.to_string()
}
fn default_wechat_cred_path() -> String {
let home = crate::platform::picobot_home_dir();
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".picobot/wechat/credentials.json")
.to_string_lossy()
.to_string()

View File

@ -1,7 +1,7 @@
pub mod channel;
pub mod init;
pub mod input;
pub mod init;
pub use channel::CliChannel;
pub use init::InitWizard;
pub use input::{InputCommand, InputEvent, InputHandler};
pub use init::InitWizard;

View File

@ -26,11 +26,8 @@ pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
let mut current_session_id: Option<String> = None;
// Track message IDs that were already streamed so we can skip
// the duplicate AssistantResponse that arrives afterwards.
let mut streamed_message_ids: std::collections::HashSet<String> =
std::collections::HashSet::new();
input
.write_output("picobot CLI - Commands: /new [title], /save [filepath], /quit\n")
.await?;
let mut streamed_message_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
input.write_output("picobot CLI - Commands: /new [title], /save [filepath], /quit\n").await?;
// Main loop: poll both stdin and WebSocket
loop {

View File

@ -1,6 +1,6 @@
use crate::command::Command;
use crate::command::context::AdapterContext;
use crate::command::response::{CommandError, CommandResponse};
use crate::command::Command;
/// 输入适配器:将渠道特定输入转换为 Command
///
@ -13,7 +13,11 @@ pub trait InputAdapter: Send + Sync {
/// - `Ok(Some(Command))`:成功解析为命令
/// - `Ok(None)`:不是命令(如普通聊天消息)
/// - `Err(CommandError)`:解析错误(如缺少参数)
fn try_parse(&self, input: &str, ctx: AdapterContext) -> Result<Option<Command>, AdapterError>;
fn try_parse(
&self,
input: &str,
ctx: AdapterContext,
) -> Result<Option<Command>, AdapterError>;
}
/// 输出适配器:将 CommandResponse 转换为渠道特定输出

View File

@ -1,6 +1,6 @@
use crate::command::Command;
use crate::command::adapter::{AdapterError, InputAdapter};
use crate::command::context::AdapterContext;
use crate::command::Command;
/// Channel 输入适配器
///

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::adapter::{AdapterError, InputAdapter, OutputAdapter};
use crate::command::context::AdapterContext;
use crate::command::response::{CommandResponse, MessageKind};
use crate::command::Command;
/// CLI 输入适配器
///
@ -313,14 +313,7 @@ mod tests {
assert!(result.is_some());
let cmd = result.unwrap();
assert!(matches!(
cmd,
Command::SaveSession {
filepath: None,
include_all: false,
..
}
));
assert!(matches!(cmd, Command::SaveSession { filepath: None, include_all: false, .. }));
}
#[test]
@ -328,9 +321,7 @@ mod tests {
let adapter = CliInputAdapter::new();
let ctx = AdapterContext::new("test");
let result = adapter
.try_parse("/save-session ./debug/session.md", ctx)
.unwrap();
let result = adapter.try_parse("/save-session ./debug/session.md", ctx).unwrap();
assert!(result.is_some());
let cmd = result.unwrap();
@ -353,14 +344,7 @@ mod tests {
assert!(result.is_some());
let cmd = result.unwrap();
assert!(matches!(
cmd,
Command::SaveSession {
filepath: None,
include_all: true,
..
}
));
assert!(matches!(cmd, Command::SaveSession { filepath: None, include_all: true, .. }));
}
#[test]
@ -368,9 +352,7 @@ mod tests {
let adapter = CliInputAdapter::new();
let ctx = AdapterContext::new("test");
let result = adapter
.try_parse("/save-session all ./debug/session.md", ctx)
.unwrap();
let result = adapter.try_parse("/save-session all ./debug/session.md", ctx).unwrap();
assert!(result.is_some());
let cmd = result.unwrap();

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::adapter::{AdapterError, InputAdapter, OutputAdapter};
use crate::command::context::AdapterContext;
use crate::command::response::{CommandResponse, MessageKind};
use crate::command::Command;
use crate::protocol::WsOutbound;
/// WebSocket 输入适配器
@ -79,12 +79,8 @@ impl OutputAdapter for WebSocketOutputAdapter {
id: response.request_id.to_string(),
content: msg.content.clone(),
role: "assistant".to_string(),
attachments: Vec::new(),
subagent_task_id: None,
topic_id: None,
timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None,
user_message_id: None,
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, user_message_id: None,
},
MessageKind::Notification => {
// 根据元数据判断具体类型
@ -94,13 +90,9 @@ impl OutputAdapter for WebSocketOutputAdapter {
response.metadata.get("topic_id"),
response.metadata.get("title"),
) {
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(
topics_json,
) {
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
Ok(topics) => {
let session_id = response
.metadata
.get("session_id")
let session_id = response.metadata.get("session_id")
.cloned()
.unwrap_or_default();
WsOutbound::TopicRenamed {
@ -114,37 +106,28 @@ impl OutputAdapter for WebSocketOutputAdapter {
id: response.request_id.to_string(),
content: msg.content.clone(),
role: "assistant".to_string(),
attachments: Vec::new(),
subagent_task_id: None,
topic_id: None,
timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None,
user_message_id: None,
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, user_message_id: None,
},
}
} else if let Some(topics_json) = response.metadata.get("topics") {
// Topic 列表响应 - 优先检查 topics
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(
topics_json,
) {
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
Ok(topics) => {
let session_id = response
.metadata
.get("session_id")
let session_id = response.metadata.get("session_id")
.cloned()
.unwrap_or_default();
WsOutbound::TopicList { topics, session_id }
WsOutbound::TopicList {
topics,
session_id,
}
}
Err(_) => WsOutbound::AssistantResponse {
id: response.request_id.to_string(),
content: msg.content.clone(),
role: "assistant".to_string(),
attachments: Vec::new(),
subagent_task_id: None,
topic_id: None,
timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None,
user_message_id: None,
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, user_message_id: None,
},
}
} else if let Some(session_id) = response.metadata.get("session_id") {
@ -156,9 +139,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
}
} else {
// 加载会话
let message_count = response
.metadata
.get("message_count")
let message_count = response.metadata.get("message_count")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
WsOutbound::SessionLoaded {
@ -169,9 +150,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
}
} else if let Some(topic_id) = response.metadata.get("topic_id") {
// 只有 topic_id可能是加载话题
let message_count = response
.metadata
.get("message_count")
let message_count = response.metadata.get("message_count")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
WsOutbound::SessionLoaded {
@ -187,19 +166,13 @@ impl OutputAdapter for WebSocketOutputAdapter {
id: response.request_id.to_string(),
content: msg.content.clone(),
role: "assistant".to_string(),
attachments: Vec::new(),
subagent_task_id: None,
topic_id: None,
timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None,
user_message_id: None,
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, user_message_id: None,
},
}
} else if let Some(sessions_json) = response.metadata.get("sessions") {
// 会话列表响应
match serde_json::from_str::<Vec<crate::protocol::SessionSummary>>(
sessions_json,
) {
match serde_json::from_str::<Vec<crate::protocol::SessionSummary>>(sessions_json) {
Ok(sessions) => {
let channel_name = response.metadata.get("channel_name").cloned();
WsOutbound::SessionList {
@ -212,37 +185,28 @@ impl OutputAdapter for WebSocketOutputAdapter {
id: response.request_id.to_string(),
content: msg.content.clone(),
role: "assistant".to_string(),
attachments: Vec::new(),
subagent_task_id: None,
topic_id: None,
timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None,
user_message_id: None,
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, user_message_id: None,
},
}
} else if let Some(topics_json) = response.metadata.get("topics") {
// Topic 列表响应
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(
topics_json,
) {
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
Ok(topics) => {
let session_id = response
.metadata
.get("session_id")
let session_id = response.metadata.get("session_id")
.cloned()
.unwrap_or_default();
WsOutbound::TopicList { topics, session_id }
WsOutbound::TopicList {
topics,
session_id,
}
}
Err(_) => WsOutbound::AssistantResponse {
id: response.request_id.to_string(),
content: msg.content.clone(),
role: "assistant".to_string(),
attachments: Vec::new(),
subagent_task_id: None,
topic_id: None,
timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None,
user_message_id: None,
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, user_message_id: None,
},
}
} else {
@ -251,12 +215,8 @@ impl OutputAdapter for WebSocketOutputAdapter {
id: response.request_id.to_string(),
content: msg.content.clone(),
role: "assistant".to_string(),
attachments: Vec::new(),
subagent_task_id: None,
topic_id: None,
timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None,
user_message_id: None,
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, user_message_id: None,
}
}
}
@ -270,12 +230,8 @@ impl OutputAdapter for WebSocketOutputAdapter {
id: response.request_id.to_string(),
content: msg.content.clone(),
role: "assistant".to_string(),
attachments: Vec::new(),
subagent_task_id: None,
topic_id: None,
timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None,
user_message_id: None,
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, user_message_id: None,
},
};
outbounds.push(outbound);

View File

@ -1,8 +1,8 @@
use crate::agent::AgentError;
use crate::bus::InboundMessage;
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::response::{CommandError, CommandResponse};
use crate::command::Command;
use crate::agent::AgentError;
use crate::gateway::session::SessionManager;
use async_trait::async_trait;
use std::sync::Arc;
@ -78,7 +78,7 @@ pub trait InChatCommandHandler: Send + Sync {
/// 负责将命令分发到合适的处理器
pub struct CommandRouter {
handlers: Vec<Box<dyn CommandHandler>>,
metadata: Arc<parking_lot::Mutex<Vec<CommandMetadata>>>,
metadata: Arc<std::sync::Mutex<Vec<CommandMetadata>>>,
}
impl CommandRouter {
@ -86,7 +86,7 @@ impl CommandRouter {
pub fn new() -> Self {
Self {
handlers: Vec::new(),
metadata: Arc::new(parking_lot::Mutex::new(Vec::new())),
metadata: Arc::new(std::sync::Mutex::new(Vec::new())),
}
}
@ -96,13 +96,13 @@ impl CommandRouter {
/// * `handler` - 要注册的处理器
pub fn register(&mut self, handler: Box<dyn CommandHandler>) {
if let Some(meta) = handler.metadata() {
self.metadata.lock().push(meta);
self.metadata.lock().unwrap().push(meta);
}
self.handlers.push(handler);
}
/// 获取已注册命令的元数据列表(用于 Help 命令)
pub fn metadata_arc(&self) -> Arc<parking_lot::Mutex<Vec<CommandMetadata>>> {
pub fn metadata_arc(&self) -> Arc<std::sync::Mutex<Vec<CommandMetadata>>> {
self.metadata.clone()
}

View File

@ -1,8 +1,8 @@
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::handlers::list_topics::build_topic_summaries;
use crate::command::handlers::list_topics::TopicSummary;
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::gateway::session::SessionManager;
use crate::storage::SessionStore;
use async_trait::async_trait;
@ -87,10 +87,22 @@ async fn handle_delete_topic(
.list_topics(session_id)
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
let topic_summaries = build_topic_summaries(handler.store.as_ref(), topics)?;
let topic_summaries: Vec<TopicSummary> = topics
.into_iter()
.map(|t| TopicSummary {
topic_id: t.id,
session_id: t.session_id,
title: t.title,
description: t.description.filter(|d| !d.is_empty()),
message_count: t.message_count,
created_at: t.created_at,
last_active_at: t.last_active_at,
})
.collect();
let topics_json = serde_json::to_string(&topic_summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
let topics_json =
serde_json::to_string(&topic_summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
let message = format!("✓ 已删除话题: {}", topic_title);
@ -118,7 +130,9 @@ mod tests {
// 先创建 session 和 topic
let session = store.create_session("test_channel", Some("test")).unwrap();
let topic = store.create_topic(&session.id, "test topic", None).unwrap();
let topic = store
.create_topic(&session.id, "test topic", None)
.unwrap();
let ctx = CommandContext::new("test", "test_channel")
.with_session_id(&session.id)

View File

@ -1,9 +1,9 @@
use crate::agent::context_compressor::estimate_tokens;
use crate::agent::{SystemPromptContext, SystemPromptProvider};
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::storage::SessionStore;
use async_trait::async_trait;
use std::sync::Arc;
@ -58,28 +58,22 @@ async fn handle_get_current_session(
handler: &GetCurrentSessionCommandHandler,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
let topic_id = ctx
.topic_id
.as_deref()
let topic_id = ctx.topic_id.as_deref()
.ok_or_else(|| CommandError::new("NO_CURRENT_TOPIC", "No current topic"))?;
let chat_id = ctx
.chat_id
.as_deref()
let chat_id = ctx.chat_id.as_deref()
.ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat id".to_string()))?;
let topic = handler
.store
.get_topic(topic_id)
.map_err(|e| CommandError::new("GET_TOPIC_ERROR", e.to_string()))?
.ok_or_else(|| {
CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id))
})?;
.ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)))?;
// 直读 DB 按话题加载消息(不依赖 SessionManager 内存,重启后也能正确获取历史)
let messages = handler
.store
.load_messages_for_topic_full(topic_id, Some(&topic.session_id))
.load_messages_for_topic(topic_id, Some(&topic.session_id))
.map_err(|e| CommandError::new("LOAD_MESSAGES_ERROR", e.to_string()))?;
let actual_message_count = messages.len();
@ -94,8 +88,7 @@ async fn handle_get_current_session(
user_message_count,
};
provider
.build(&system_prompt_context)
provider.build(&system_prompt_context)
.map(|sp| {
use crate::bus::ChatMessage;
let system_msg = ChatMessage::system(&sp.content);

View File

@ -1,10 +1,9 @@
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use async_trait::async_trait;
use parking_lot::Mutex;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
/// Help 命令处理器
///
@ -42,10 +41,11 @@ impl CommandHandler for HelpCommandHandler {
_cmd: Command,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
let metadata = self.metadata.lock();
let metadata = self.metadata.lock().unwrap();
let help_text = format_help(&metadata);
Ok(CommandResponse::success(ctx.request_id).with_message(MessageKind::Text, &help_text))
Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Text, &help_text))
}
}

View File

@ -1,8 +1,8 @@
use crate::channels::manager::ChannelManager;
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use async_trait::async_trait;
use std::sync::Arc;

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse};
use crate::command::Command;
use crate::storage::SessionStore;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
@ -68,6 +68,7 @@ impl CommandHandler for ListMemoriesCommandHandler {
let memories_json = serde_json::to_string(&summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
Ok(CommandResponse::success(ctx.request_id).with_metadata("memories", &memories_json))
Ok(CommandResponse::success(ctx.request_id)
.with_metadata("memories", &memories_json))
}
}

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse};
use crate::command::Command;
use crate::protocol::{SchedulerJobSessionLookup, SchedulerJobSummary};
use crate::storage::SessionStore;
use async_trait::async_trait;
@ -67,7 +67,8 @@ impl CommandHandler for ListSchedulerJobsCommandHandler {
let jobs_json = serde_json::to_string(&summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
Ok(CommandResponse::success(ctx.request_id).with_metadata("scheduler_jobs", &jobs_json))
Ok(CommandResponse::success(ctx.request_id)
.with_metadata("scheduler_jobs", &jobs_json))
}
}

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::storage::SessionStore;
use async_trait::async_trait;
use std::sync::Arc;
@ -50,9 +50,7 @@ async fn handle_list_sessions(
_include_archived: bool,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
let session_id = ctx
.session_id
.as_deref()
let session_id = ctx.session_id.as_deref()
.ok_or_else(|| CommandError::new("NO_SESSION", "No active session"))?;
let topics = handler
@ -74,8 +72,7 @@ async fn handle_list_sessions(
let marker = if is_current { " *" } else { "" };
// 使用辅助方法获取消息数量
let msg_count = handler
.store
let msg_count = handler.store
.get_topic_message_count(&topic.id)
.unwrap_or(0);

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::protocol::SessionSummary;
use crate::storage::SessionStore;
use async_trait::async_trait;

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse};
use crate::command::Command;
use crate::skills::SkillRuntime;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
@ -58,6 +58,7 @@ impl CommandHandler for ListSkillsCommandHandler {
let skills_json = serde_json::to_string(&summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
Ok(CommandResponse::success(ctx.request_id).with_metadata("skills", &skills_json))
Ok(CommandResponse::success(ctx.request_id)
.with_metadata("skills", &skills_json))
}
}

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse};
use crate::command::Command;
use crate::protocol::TodoItemSummary;
use crate::storage::SessionStore;
use async_trait::async_trait;
@ -64,7 +64,7 @@ impl CommandHandler for ListTodosCommandHandler {
.list_todos(&scope_key)
.map_err(|e| CommandError::new("LIST_TODOS_ERROR", e.to_string()))?;
tracing::debug!(
tracing::info!(
scope_key = %scope_key,
record_count = records.len(),
"list_todos handler: reading from store"

View File

@ -1,28 +1,12 @@
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::storage::{SessionStore, SessionTokenStats, TopicRecord};
use crate::command::Command;
use crate::storage::SessionStore;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
/// Topic 维度的 token 统计cost 累计 + context 瞬时)。
///
/// - `prompt_tokens` / `completion_tokens` / `total_tokens`累计求和cost 维度)
/// - `last_prompt_tokens`:最后一条 assistant 消息的 prompt_tokenscontext 占用瞬时值)
/// - `context_window_tokens`:当前 session 使用的模型上下文窗口上限(来自配置);
/// 为 0 表示未配置,前端不显示百分比。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TopicTokenStats {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
pub last_prompt_tokens: Option<u32>,
pub context_window_tokens: u32,
}
/// Topic 摘要信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopicSummary {
@ -33,65 +17,6 @@ pub struct TopicSummary {
pub message_count: i64,
pub created_at: i64,
pub last_active_at: i64,
/// Token 用量统计。老 topic 或无 LLM 调用时为 None前端不显示 token 标签。
#[serde(skip_serializing_if = "Option::is_none")]
pub token_stats: Option<TopicTokenStats>,
}
/// 构建 TopicSummary 列表的公共函数。
///
/// 一次批量查询所有 topic 的 token 统计,按 `topic_id` 聚合,
/// 避免按 session_id 聚合时同 session 下多个 topic 共享同一总和。
/// 子代理天然隔离:子代理消息的 topic_id 属于子代理自身的 topic
/// 不在主 topic 列表中。
///
/// `context_window_tokens` 来自最新 assistant 消息记录LLM 调用时持久化),
/// 无需从配置链路注入,保持存储层与配置解耦。
pub fn build_topic_summaries(
store: &SessionStore,
topics: Vec<TopicRecord>,
) -> Result<Vec<TopicSummary>, CommandError> {
if topics.is_empty() {
return Ok(Vec::new());
}
// 收集所有 topic_id去重
let mut topic_ids: Vec<String> = Vec::new();
for t in &topics {
if !topic_ids.contains(&t.id) {
topic_ids.push(t.id.clone());
}
}
let topic_id_refs: Vec<&str> = topic_ids.iter().map(|s| s.as_str()).collect();
// 一次批量查询 token 统计(按 topic_id 聚合)
let stats_map: HashMap<String, SessionTokenStats> = store
.batch_topic_token_stats(&topic_id_refs)
.map_err(|e| CommandError::new("TOKEN_STATS_ERROR", e.to_string()))?;
let summaries = topics
.into_iter()
.map(|t| {
let token_stats = stats_map.get(&t.id).map(|s| TopicTokenStats {
prompt_tokens: s.prompt_tokens,
completion_tokens: s.completion_tokens,
total_tokens: s.total_tokens,
last_prompt_tokens: s.last_prompt_tokens,
context_window_tokens: s.context_window_tokens.unwrap_or(0),
});
TopicSummary {
topic_id: t.id,
session_id: t.session_id,
title: t.title,
description: t.description.filter(|d| !d.is_empty()),
message_count: t.message_count,
created_at: t.created_at,
last_active_at: t.last_active_at,
token_stats,
}
})
.collect();
Ok(summaries)
}
/// 列出 Session 的 Topics 命令处理器
@ -125,7 +50,9 @@ impl CommandHandler for ListTopicsCommandHandler {
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
match cmd {
Command::ListTopics { session_id } => handle_list_topics(self, session_id, ctx).await,
Command::ListTopics { session_id } => {
handle_list_topics(self, session_id, ctx).await
}
_ => unreachable!(),
}
}
@ -141,7 +68,18 @@ async fn handle_list_topics(
.list_topics(&session_id)
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
let summaries = build_topic_summaries(handler.store.as_ref(), topics)?;
let summaries: Vec<TopicSummary> = topics
.into_iter()
.map(|t| TopicSummary {
topic_id: t.id,
session_id: t.session_id,
title: t.title,
description: t.description.filter(|d| !d.is_empty()),
message_count: t.message_count,
created_at: t.created_at,
last_active_at: t.last_active_at,
})
.collect();
let topics_json = serde_json::to_string(&summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse};
use crate::command::Command;
use async_trait::async_trait;
/// 加载指定 channel + chat_id 的对话消息。

View File

@ -1,8 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::handlers::list_topics::TopicTokenStats;
use crate::command::response::{CommandError, CommandResponse};
use crate::command::Command;
use crate::storage::SessionStore;
use crate::tools::task::repository::TaskRepository;
use crate::tools::task::types::{TaskSession, TaskSessionState};
@ -15,11 +14,11 @@ pub struct LoadTaskMessagesCommandHandler {
}
impl LoadTaskMessagesCommandHandler {
pub fn new(task_repository: Arc<dyn TaskRepository>, store: Arc<SessionStore>) -> Self {
Self {
task_repository,
store,
}
pub fn new(
task_repository: Arc<dyn TaskRepository>,
store: Arc<SessionStore>,
) -> Self {
Self { task_repository, store }
}
}
@ -63,7 +62,11 @@ async fn handle_load_task_messages(
);
// 1. Try in-memory repository first
let task = match handler.task_repository.load_task_session(&task_id).await {
let task = match handler
.task_repository
.load_task_session(&task_id)
.await
{
Ok(Some(task)) => {
tracing::info!(
task_id = %task.id,
@ -101,19 +104,6 @@ async fn handle_load_task_messages(
let status = format!("{:?}", task.state).to_lowercase();
// 查询子代理 session 的 token 统计(按 session_id 精确匹配,不过滤 sub:%
let token_stats = handler
.store
.get_session_token_stats(&task.session_id)
.map_err(|e| CommandError::new("TOKEN_STATS_ERROR", e.to_string()))?
.map(|s| TopicTokenStats {
prompt_tokens: s.prompt_tokens,
completion_tokens: s.completion_tokens,
total_tokens: s.total_tokens,
last_prompt_tokens: s.last_prompt_tokens,
context_window_tokens: s.context_window_tokens.unwrap_or(0),
});
let mut response = CommandResponse::success(ctx.request_id)
.with_metadata("task_session_id", &task.session_id)
.with_metadata("task_id", &task.id)
@ -125,12 +115,6 @@ async fn handle_load_task_messages(
response = response.with_metadata("task_summary", summary);
}
if let Some(ref stats) = token_stats {
let stats_json = serde_json::to_string(stats)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
response = response.with_metadata("task_token_stats", &stats_json);
}
Ok(response)
}
@ -188,7 +172,6 @@ fn reconstruct_task_from_db(
updated_at: now,
summary: None,
error: None,
tool_call_id: None,
}))
}
@ -203,9 +186,6 @@ fn parse_subagent_title(title: &str) -> (String, String) {
return (agent_type, desc);
}
}
let desc = title
.strip_prefix("Subagent: ")
.unwrap_or(title)
.to_string();
let desc = title.strip_prefix("Subagent: ").unwrap_or(title).to_string();
("general".to_string(), desc)
}

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::storage::SessionStore;
use async_trait::async_trait;
use std::sync::Arc;
@ -37,7 +37,9 @@ impl CommandHandler for LoadTopicCommandHandler {
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
match cmd {
Command::LoadTopic { topic_id } => handle_load_topic(self, topic_id, ctx).await,
Command::LoadTopic { topic_id } => {
handle_load_topic(self, topic_id, ctx).await
}
_ => unreachable!(),
}
}
@ -52,9 +54,7 @@ async fn handle_load_topic(
.store
.get_topic(&topic_id)
.map_err(|e| CommandError::new("LOAD_TOPIC_ERROR", e.to_string()))?
.ok_or_else(|| {
CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id))
})?;
.ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)))?;
Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Notification, &topic.title)

View File

@ -1,8 +1,8 @@
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse};
use crate::storage::{GLOBAL_SCOPE_KEY, MemoryUpsert, SessionStore};
use crate::command::Command;
use crate::storage::{MemoryUpsert, SessionStore, GLOBAL_SCOPE_KEY};
use async_trait::async_trait;
use std::sync::Arc;
@ -17,7 +17,10 @@ impl MemoryCrudCommandHandler {
}
/// 通过 ID 查找记忆的 namespace 和 memory_key
fn find_by_id(store: &SessionStore, id: &str) -> Result<Option<(String, String)>, CommandError> {
fn find_by_id(
store: &SessionStore,
id: &str,
) -> Result<Option<(String, String)>, CommandError> {
let records = store
.list_memories_for_scope("user", GLOBAL_SCOPE_KEY)
.map_err(|e| CommandError::new("LIST_ERROR", e.to_string()))?;
@ -32,9 +35,7 @@ impl CommandHandler for MemoryCrudCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool {
matches!(
cmd,
Command::CreateMemory { .. }
| Command::UpdateMemory { .. }
| Command::DeleteMemory { .. }
Command::CreateMemory { .. } | Command::UpdateMemory { .. } | Command::DeleteMemory { .. }
)
}
@ -111,6 +112,7 @@ impl CommandHandler for MemoryCrudCommandHandler {
_ => unreachable!(),
}
Ok(CommandResponse::success(ctx.request_id).with_metadata("memory_updated", "true"))
Ok(CommandResponse::success(ctx.request_id)
.with_metadata("memory_updated", "true"))
}
}

View File

@ -4,15 +4,15 @@ pub mod help;
pub mod list_channels;
pub mod list_memories;
pub mod list_scheduler_jobs;
pub mod list_sessions;
pub mod list_sessions_by_channel;
pub mod list_skills;
pub mod list_todos;
pub mod memory_crud;
pub mod list_sessions;
pub mod list_sessions_by_channel;
pub mod list_topics;
pub mod load_chat_messages;
pub mod load_task_messages;
pub mod load_topic;
pub mod memory_crud;
pub mod rename_topic;
pub mod save_session;
pub mod save_topic;
@ -22,7 +22,8 @@ pub mod switch_topic;
// 导出公共函数供其他模块复用
pub use save_session::{
SubagentTaskData, escape_yaml_string, format_message_content, format_timestamp,
generate_messages_markdown, generate_subagent_tasks_markdown, generate_system_prompt_markdown,
load_subagent_data,
escape_yaml_string, format_message_content, format_timestamp,
generate_messages_markdown, generate_system_prompt_markdown,
generate_subagent_tasks_markdown, load_subagent_data, SubagentTaskData,
};

View File

@ -1,8 +1,8 @@
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::handlers::list_topics::build_topic_summaries;
use crate::command::handlers::list_topics::TopicSummary;
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::storage::SessionStore;
use async_trait::async_trait;
use std::sync::Arc;
@ -75,38 +75,28 @@ async fn handle_rename_topic(
CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id))
})?;
let old_display = topic
.description
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(&topic.title)
.to_string();
let old_title = topic.title.clone();
// 标题未变化时直接返回当前列表,避免无意义写入
if old_display == trimmed_title {
if old_title == trimmed_title {
let topics = handler
.store
.list_topics(session_id)
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
let topic_summaries = build_topic_summaries(handler.store.as_ref(), topics)?;
let topic_summaries_json = serde_json::to_string(&topic_summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
let topic_summaries = serialize_summaries(&topics);
return Ok(CommandResponse::success(ctx.request_id)
.with_message(
MessageKind::Notification,
&format!("✓ 话题标题未变化: {}", trimmed_title),
)
.with_metadata("topics", &topic_summaries_json)
.with_message(MessageKind::Notification, &format!("✓ 话题标题未变化: {}", trimmed_title))
.with_metadata("topics", &topic_summaries)
.with_metadata("topic_id", &topic_id)
.with_metadata("title", trimmed_title)
.with_metadata("session_id", session_id));
}
// 执行重命名:更新 description显示字段保留 title 作为内部标识
// 执行重命名(存储层方法已存在)
handler
.store
.update_topic_description(&topic_id, trimmed_title)
.update_topic_title(&topic_id, trimmed_title)
.map_err(|e| CommandError::new("RENAME_TOPIC_ERROR", e.to_string()))?;
// 查询更新后的话题列表,返回给前端刷新侧边栏
@ -115,20 +105,34 @@ async fn handle_rename_topic(
.list_topics(session_id)
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
let topic_summaries = build_topic_summaries(handler.store.as_ref(), topics)?;
let topic_summaries_json = serde_json::to_string(&topic_summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
let topic_summaries = serialize_summaries(&topics);
let message = format!("✓ 已重命名话题: {}{}", old_display, trimmed_title);
let message = format!("✓ 已重命名话题: {}{}", old_title, trimmed_title);
Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Notification, &message)
.with_metadata("topics", &topic_summaries_json)
.with_metadata("topics", &topic_summaries)
.with_metadata("topic_id", &topic_id)
.with_metadata("title", trimmed_title)
.with_metadata("session_id", session_id))
}
fn serialize_summaries(topics: &[crate::storage::TopicRecord]) -> String {
let summaries: Vec<TopicSummary> = topics
.iter()
.map(|t| TopicSummary {
topic_id: t.id.clone(),
session_id: t.session_id.clone(),
title: t.title.clone(),
description: t.description.clone().filter(|d| !d.is_empty()),
message_count: t.message_count,
created_at: t.created_at,
last_active_at: t.last_active_at,
})
.collect();
serde_json::to_string(&summaries).unwrap_or_else(|_| "[]".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
@ -145,7 +149,9 @@ mod tests {
let store = handler.store.clone();
let session = store.create_session("test_channel", Some("test")).unwrap();
let topic = store.create_topic(&session.id, "old title", None).unwrap();
let topic = store
.create_topic(&session.id, "old title", None)
.unwrap();
let ctx = CommandContext::new("test", "test_channel")
.with_session_id(&session.id)
@ -160,20 +166,13 @@ mod tests {
let resp = result.unwrap();
assert!(resp.success);
assert_eq!(
resp.metadata.get("title").map(String::as_str),
Some("new title")
);
assert_eq!(
resp.metadata.get("topic_id").map(String::as_str),
Some(topic.id.as_str())
);
assert_eq!(resp.metadata.get("title").map(String::as_str), Some("new title"));
assert_eq!(resp.metadata.get("topic_id").map(String::as_str), Some(topic.id.as_str()));
assert!(resp.metadata.contains_key("topics"));
// 验证存储层已更新 description显示字段title 保持原内部标识
// 验证存储层已更新
let updated = store.get_topic(&topic.id).unwrap().unwrap();
assert_eq!(updated.title, "old title");
assert_eq!(updated.description.as_deref(), Some("new title"));
assert_eq!(updated.title, "new title");
}
#[tokio::test]
@ -182,7 +181,9 @@ mod tests {
let store = handler.store.clone();
let session = store.create_session("test_channel", Some("test")).unwrap();
let topic = store.create_topic(&session.id, "old title", None).unwrap();
let topic = store
.create_topic(&session.id, "old title", None)
.unwrap();
let ctx = CommandContext::new("test", "test_channel")
.with_session_id(&session.id)
@ -228,8 +229,9 @@ mod tests {
let store = handler.store.clone();
let session = store.create_session("test_channel", Some("test")).unwrap();
// description=None当前显示值 fallback 到 title
let topic = store.create_topic(&session.id, "same title", None).unwrap();
let topic = store
.create_topic(&session.id, "same title", None)
.unwrap();
let original_updated_at = store.get_topic(&topic.id).unwrap().unwrap().updated_at;
// 等待一秒确保 updated_at 会变化(如果真的写入)
@ -251,38 +253,6 @@ mod tests {
assert_eq!(after.updated_at, original_updated_at);
}
#[tokio::test]
async fn test_rename_topic_compares_against_description_when_present() {
// 有 description 时,"未变化"比较应基于 description 而非 title
let handler = create_test_handler();
let store = handler.store.clone();
let session = store.create_session("test_channel", Some("test")).unwrap();
// title 是内部标识description 是显示值
let topic = store
.create_topic(&session.id, "topic_internal_id", Some("AI 生成的摘要"))
.unwrap();
let original_updated_at = store.get_topic(&topic.id).unwrap().unwrap().updated_at;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let ctx = CommandContext::new("test", "test_channel")
.with_session_id(&session.id)
.with_chat_id(&session.id);
// 提交与 description 相同的值(不是 title应跳过写入
let cmd = Command::RenameTopic {
topic_id: topic.id.clone(),
title: "AI 生成的摘要".to_string(),
};
let result = handler.handle(cmd, ctx).await;
assert!(result.is_ok());
let after = store.get_topic(&topic.id).unwrap().unwrap();
assert_eq!(after.updated_at, original_updated_at);
assert_eq!(after.title, "topic_internal_id");
}
#[test]
fn test_can_handle() {
let handler = create_test_handler();

View File

@ -1,12 +1,12 @@
use crate::agent::AgentError;
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
use crate::bus::InboundMessage;
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata, InChatCommandHandler};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::storage::{SessionRecord, SessionStore};
use crate::tools::task::repository::TaskRepository;
use crate::agent::AgentError;
use async_trait::async_trait;
use chrono::{Local, TimeZone};
use std::path::PathBuf;
@ -65,8 +65,7 @@ pub async fn save_session_to_file(
let system_prompt = build_system_prompt(system_prompt_provider, &record, user_message_count);
// 生成 Markdown 内容
let markdown =
generate_markdown_with_subagents(&record, &system_prompt, &messages, &subagent_data);
let markdown = generate_markdown_with_subagents(&record, &system_prompt, &messages, &subagent_data);
// 确定输出路径
let output_path = resolve_filepath(filepath, &record);
@ -80,7 +79,8 @@ pub async fn save_session_to_file(
}
// 写入文件
std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?;
std::fs::write(&output_path, markdown)
.map_err(|e| format!("Failed to write file: {}", e))?;
Ok(output_path)
}
@ -134,11 +134,9 @@ impl CommandHandler for SaveSessionCommandHandler {
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
match cmd {
Command::SaveSession {
filepath,
include_all,
include_subagents,
} => handle_save_session(self, filepath, include_all, include_subagents, ctx).await,
Command::SaveSession { filepath, include_all, include_subagents } => {
handle_save_session(self, filepath, include_all, include_subagents, ctx).await
}
_ => unreachable!(),
}
}
@ -201,9 +199,13 @@ async fn handle_save_session(
// 根据 include_all 获取消息数量
let message_count = if include_all {
handler.store.load_all_messages(session_id)
handler
.store
.load_all_messages(session_id)
} else {
handler.store.load_messages(session_id)
handler
.store
.load_messages(session_id)
}
.map_err(|e| CommandError::new("LOAD_MESSAGES_ERROR", e.to_string()))?
.len();
@ -213,15 +215,9 @@ async fn handle_save_session(
MessageKind::Notification,
// 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉,
// 统一转换为正斜杠以保证显示完整(跨平台兼容)
&format!(
"Session saved to: {}",
output_path.display().to_string().replace('\\', "/")
),
)
.with_metadata(
"filepath",
&output_path.display().to_string().replace('\\', "/"),
&format!("Session saved to: {}", output_path.display().to_string().replace('\\', "/")),
)
.with_metadata("filepath", &output_path.display().to_string().replace('\\', "/"))
.with_metadata("message_count", &message_count.to_string()))
}
@ -351,18 +347,12 @@ pub fn generate_subagent_tasks_markdown(subagent_data: &[SubagentTaskData]) -> S
output.push_str("# Subagent Tasks\n\n");
for task in subagent_data {
output.push_str(&format!(
"## Task: {} ({})",
task.description, task.subagent_type
));
output.push_str(&format!("## Task: {} ({})", task.description, task.subagent_type));
output.push('\n');
output.push_str(&format!("**Task ID:** `{}`\n\n", task.task_id));
output.push_str(&format!("**Session ID:** `{}`\n\n", task.session_id));
output.push_str(&format!("**Status:** {}\n\n", task.state));
output.push_str(&format!(
"**Created:** {}\n\n",
format_timestamp(task.created_at)
));
output.push_str(&format!("**Created:** {}\n\n", format_timestamp(task.created_at)));
output.push_str(&format!("**Message Count:** {}\n\n", task.messages.len()));
// 子智能体消息
@ -371,10 +361,7 @@ pub fn generate_subagent_tasks_markdown(subagent_data: &[SubagentTaskData]) -> S
for (idx, msg) in task.messages.iter().enumerate() {
output.push_str(&format!("#### Message {}\n\n", idx + 1));
output.push_str(&format!("**Role:** {}\n\n", msg.role));
output.push_str(&format!(
"**Time:** {}\n\n",
format_timestamp(msg.timestamp)
));
output.push_str(&format!("**Time:** {}\n\n", format_timestamp(msg.timestamp)));
if let Some(ref reasoning) = msg.reasoning_content {
output.push_str("**Reasoning:**\n");
@ -689,12 +676,7 @@ impl InChatCommandHandler for SaveSessionInChatHandler {
inbound: &InboundMessage,
session_manager: &crate::gateway::session::SessionManager,
) -> Result<Option<String>, AgentError> {
let Command::SaveSession {
filepath,
include_all,
include_subagents,
} = cmd
else {
let Command::SaveSession { filepath, include_all, include_subagents } = cmd else {
return Ok(None);
};
@ -725,10 +707,7 @@ impl InChatCommandHandler for SaveSessionInChatHandler {
// 返回成功或失败消息
match result {
Ok(output_path) => {
let msg = format!(
"Session saved to: {}",
output_path.display().to_string().replace('\\', "/")
);
let msg = format!("Session saved to: {}", output_path.display().to_string().replace('\\', "/"));
tracing::info!("{}", msg);
Ok(Some(msg))
}
@ -795,10 +774,7 @@ mod tests {
fn test_escape_yaml_string() {
assert_eq!(escape_yaml_string("simple"), "simple");
assert_eq!(escape_yaml_string("with: colon"), "\"with: colon\"");
assert_eq!(
escape_yaml_string("with \"quote\""),
"\"with \\\"quote\\\"\""
);
assert_eq!(escape_yaml_string("with \"quote\""), "\"with \\\"quote\\\"\"");
}
#[test]
@ -859,26 +835,14 @@ mod tests {
#[test]
fn test_can_handle() {
let store = Arc::new(SessionStore::in_memory().unwrap());
let task_repository =
Arc::new(crate::tools::task::repository::InMemoryTaskRepository::new());
let task_repository = Arc::new(crate::tools::task::repository::InMemoryTaskRepository::new());
let provider = Arc::new(TestSystemPromptProvider);
let handler = SaveSessionCommandHandler::new(store, task_repository, provider);
assert!(handler.can_handle(&Command::SaveSession {
filepath: None,
include_all: false,
include_subagents: false
}));
assert!(handler.can_handle(&Command::SaveSession {
filepath: None,
include_all: true,
include_subagents: false
}));
assert!(handler.can_handle(&Command::SaveSession { filepath: None, include_all: false, include_subagents: false }));
assert!(handler.can_handle(&Command::SaveSession { filepath: None, include_all: true, include_subagents: false }));
assert!(!handler.can_handle(&Command::CreateSession { title: None }));
assert!(!handler.can_handle(&Command::SaveTopic {
filepath: None,
include_subagents: false
}));
assert!(!handler.can_handle(&Command::SaveTopic { filepath: None, include_subagents: false }));
}
/// 测试用的系统提示词提供者

View File

@ -1,13 +1,14 @@
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
use crate::bus::ChatMessage;
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::handlers::{
SubagentTaskData, escape_yaml_string, format_timestamp, generate_messages_markdown,
generate_subagent_tasks_markdown, generate_system_prompt_markdown, load_subagent_data,
escape_yaml_string, format_timestamp, generate_messages_markdown,
generate_subagent_tasks_markdown, generate_system_prompt_markdown,
load_subagent_data, SubagentTaskData,
};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::storage::{SessionStore, TopicRecord};
use crate::tools::task::repository::TaskRepository;
use async_trait::async_trait;
@ -62,7 +63,8 @@ pub async fn save_topic_to_file(
}
// 写入文件
std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?;
std::fs::write(&output_path, markdown)
.map_err(|e| format!("Failed to write file: {}", e))?;
Ok(output_path)
}
@ -208,10 +210,9 @@ impl CommandHandler for SaveTopicCommandHandler {
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
match cmd {
Command::SaveTopic {
filepath,
include_subagents,
} => handle_save_topic(self, filepath, include_subagents, ctx).await,
Command::SaveTopic { filepath, include_subagents } => {
handle_save_topic(self, filepath, include_subagents, ctx).await
}
_ => unreachable!(),
}
}
@ -248,19 +249,14 @@ async fn handle_save_topic(
.store
.get_topic(topic_id)
.map_err(|e| CommandError::new("GET_TOPIC_ERROR", e.to_string()))?
.ok_or_else(|| {
CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id))
})?;
.ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)))?;
let messages = handler
.store
.load_messages_for_topic_full(topic_id, Some(&topic_record.session_id))
.load_messages_for_topic(topic_id, Some(&topic_record.session_id))
.map_err(|e| CommandError::new("LOAD_MESSAGES_ERROR", e.to_string()))?;
tracing::debug!(
message_count = messages.len(),
"Loaded messages from DB for topic"
);
tracing::debug!(message_count = messages.len(), "Loaded messages from DB for topic");
// 调用保存函数
let output_path = save_topic_to_file(
@ -282,14 +278,8 @@ async fn handle_save_topic(
MessageKind::Notification,
// 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉,
// 统一转换为正斜杠以保证显示完整(跨平台兼容)
&format!(
"Topic saved to: {}",
output_path.display().to_string().replace('\\', "/")
),
)
.with_metadata(
"filepath",
&output_path.display().to_string().replace('\\', "/"),
&format!("Topic saved to: {}", output_path.display().to_string().replace('\\', "/")),
)
.with_metadata("filepath", &output_path.display().to_string().replace('\\', "/"))
.with_metadata("message_count", &message_count.to_string()))
}

View File

@ -1,8 +1,8 @@
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::handlers::list_topics::build_topic_summaries;
use crate::command::handlers::list_topics::TopicSummary;
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::gateway::session::SessionManager;
use crate::storage::SessionStore;
use async_trait::async_trait;
@ -56,9 +56,7 @@ impl CommandHandler for SessionCommandHandler {
) -> Result<CommandResponse, CommandError> {
match cmd {
Command::CreateSession { title } => handle_create_session(self, title, ctx).await,
Command::SaveSession { .. } => {
unreachable!("SaveSession should be handled by SaveSessionCommandHandler")
}
Command::SaveSession { .. } => unreachable!("SaveSession should be handled by SaveSessionCommandHandler"),
_ => unreachable!("Other commands should be handled by other handlers"),
}
}
@ -71,16 +69,13 @@ async fn handle_create_session(
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
// 获取当前 session_id如果没有则报错
let session_id = ctx.session_id.as_deref().ok_or_else(|| {
CommandError::new(
"NO_SESSION",
"No active session. Please ensure a session exists first.",
)
})?;
let session_id = ctx.session_id.as_deref()
.ok_or_else(|| CommandError::new("NO_SESSION", "No active session. Please ensure a session exists first."))?;
// 创建新话题(在同一个 Session 内)
let topic_title =
title.unwrap_or_else(|| format!("Topic {}", &uuid::Uuid::new_v4().to_string()[..8]));
let topic_title = title.unwrap_or_else(|| {
format!("Topic {}", &uuid::Uuid::new_v4().to_string()[..8])
});
let topic = handler
.store
@ -88,17 +83,14 @@ async fn handle_create_session(
.map_err(|e| CommandError::new("CREATE_TOPIC_ERROR", e.to_string()))?;
// 获取 chat_id
let chat_id = ctx
.chat_id
.as_deref()
let chat_id = ctx.chat_id.as_deref()
.ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?;
// 如果有 SessionManager自动切换到新话题
if let Some(ref session_manager) = handler.session_manager {
if let Some(session) = session_manager.get(&ctx.channel_name).await {
let mut session_guard = session.lock().await;
session_guard
.switch_topic(chat_id, &topic.id)
session_guard.switch_topic(chat_id, &topic.id)
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?;
}
}
@ -109,7 +101,18 @@ async fn handle_create_session(
.list_topics(session_id)
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
let topic_summaries = build_topic_summaries(handler.store.as_ref(), topics)?;
let topic_summaries: Vec<TopicSummary> = topics
.into_iter()
.map(|t| TopicSummary {
topic_id: t.id,
session_id: t.session_id,
title: t.title,
description: t.description.filter(|d| !d.is_empty()),
message_count: t.message_count,
created_at: t.created_at,
last_active_at: t.last_active_at,
})
.collect();
let topics_json = serde_json::to_string(&topic_summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;

View File

@ -1,9 +1,9 @@
use async_trait::async_trait;
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::gateway::cancel_manager::CancelManager;
use crate::gateway::session::SessionManager;
@ -15,10 +15,7 @@ pub struct StopExecutionCommandHandler {
impl StopExecutionCommandHandler {
pub fn new(cancel_manager: CancelManager, session_manager: SessionManager) -> Self {
Self {
cancel_manager,
session_manager,
}
Self { cancel_manager, session_manager }
}
}
@ -56,11 +53,7 @@ impl CommandHandler for StopExecutionCommandHandler {
None => {
// 从 SessionManager 获取真实的 current topic
let chat_id = ctx.chat_id.as_deref().unwrap_or("");
match self
.session_manager
.get_current_topic(&ctx.channel_name, chat_id)
.await
{
match self.session_manager.get_current_topic(&ctx.channel_name, chat_id).await {
Ok(Some(id)) => {
tracing::info!(
channel = %ctx.channel_name,
@ -72,16 +65,12 @@ impl CommandHandler for StopExecutionCommandHandler {
id
}
Ok(None) => {
return Ok(CommandResponse::success(ctx.request_id).with_message(
MessageKind::Notification,
"当前没有活跃的话题,无法停止",
));
return Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Notification, "当前没有活跃的话题,无法停止"));
}
Err(e) => {
return Ok(CommandResponse::error(
ctx.request_id,
CommandError::new("QUERY_TOPIC_ERROR", e.to_string()),
));
return Ok(CommandResponse::error(ctx.request_id,
CommandError::new("QUERY_TOPIC_ERROR", e.to_string())));
}
}
}

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::gateway::session::SessionManager;
use crate::storage::SessionStore;
use async_trait::async_trait;
@ -15,10 +15,7 @@ pub struct SwitchTopicCommandHandler {
impl SwitchTopicCommandHandler {
pub fn new(store: Arc<SessionStore>) -> Self {
Self {
store,
session_manager: None,
}
Self { store, session_manager: None }
}
pub fn with_session_manager(mut self, session_manager: SessionManager) -> Self {
@ -47,7 +44,9 @@ impl CommandHandler for SwitchTopicCommandHandler {
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
match cmd {
Command::SwitchTopic { topic_id } => handle_switch_topic(self, topic_id, ctx).await,
Command::SwitchTopic { topic_id } => {
handle_switch_topic(self, topic_id, ctx).await
}
_ => unreachable!(),
}
}
@ -58,13 +57,9 @@ async fn handle_switch_topic(
topic_id: String,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
let session_id = ctx
.session_id
.as_deref()
let session_id = ctx.session_id.as_deref()
.ok_or_else(|| CommandError::new("NO_SESSION", "No active session"))?;
let chat_id = ctx
.chat_id
.as_deref()
let chat_id = ctx.chat_id.as_deref()
.ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?;
// 尝试解析为序号
@ -78,11 +73,7 @@ async fn handle_switch_topic(
if index >= topics.len() {
return Err(CommandError::new(
"INVALID_TOPIC_INDEX",
format!(
"Topic index {} is out of range (1-{})",
index + 1,
topics.len()
),
format!("Topic index {} is out of range (1-{})", index + 1, topics.len())
));
}
topics[index].id.clone()
@ -95,26 +86,19 @@ async fn handle_switch_topic(
.store
.get_topic(&target_topic_id)
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?
.ok_or_else(|| {
CommandError::new(
"TOPIC_NOT_FOUND",
format!("Topic not found: {}", target_topic_id),
)
})?;
.ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", target_topic_id)))?;
// 如果有 SessionManager实际切换话题历史
if let Some(ref session_manager) = handler.session_manager {
if let Some(session) = session_manager.get(&ctx.channel_name).await {
let mut session_guard = session.lock().await;
session_guard
.switch_topic(chat_id, &target_topic_id)
session_guard.switch_topic(chat_id, &target_topic_id)
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?;
}
}
// 使用辅助方法获取消息数量
let msg_count = handler
.store
let msg_count = handler.store
.get_topic_message_count(&target_topic_id)
.unwrap_or(0);

View File

@ -48,7 +48,10 @@ pub enum Command {
/// 列出所有定时任务
ListSchedulerJobs,
/// 加载指定 channel + chat_id 的对话消息
LoadChatMessages { channel: String, chat_id: String },
LoadChatMessages {
channel: String,
chat_id: String,
},
/// 删除指定话题
DeleteTopic { topic_id: String },
/// 重命名指定话题
@ -64,7 +67,10 @@ pub enum Command {
content: String,
},
/// 更新已有记忆
UpdateMemory { id: String, content: String },
UpdateMemory {
id: String,
content: String,
},
/// 删除记忆
DeleteMemory { id: String },
/// 列出所有技能

View File

@ -40,8 +40,6 @@ pub struct Config {
pub subagents: SubagentsConfig,
#[serde(default)]
pub experts: ExpertsConfig,
#[serde(default)]
pub compaction: CompactionConfig,
}
/// 图片上下文限制配置
@ -74,54 +72,6 @@ impl Default for ImageContextConfig {
}
}
/// 上下文压缩算法配置(全局,所有 agent 共享)
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CompactionConfig {
/// 工程化压缩触发阈值(占 context_window 的比例0.0-1.0
/// 当 LLM 返回的 prompt_tokens 超过 context_window × 此比例时,触发压缩流程
#[serde(default = "default_threshold_ratio")]
pub threshold_ratio: f64,
/// LLM 压缩触发阈值(占 context_window 的比例0.0-1.0
/// 工程化压缩(截断 tool 结果)后,若估算 token 仍超过此比例才调 LLM 压缩
#[serde(default = "default_llm_compaction_threshold_ratio")]
pub llm_compaction_threshold_ratio: f64,
/// 工程化压缩时 tool 结果截断到的 token 数
/// 子代理返回tool_name="task")不受此限制
#[serde(default = "default_truncate_max_tokens")]
pub truncate_max_tokens: usize,
/// LLM 三段压缩保留的最旧/最新 unit 数
/// 压缩后保留最旧 N 个 unit + 中间段摘要 + 最新 N 个 unit
#[serde(default = "default_preserve_count")]
pub preserve_count: usize,
}
fn default_threshold_ratio() -> f64 {
0.5
}
fn default_llm_compaction_threshold_ratio() -> f64 {
0.3
}
fn default_truncate_max_tokens() -> usize {
100
}
fn default_preserve_count() -> usize {
5
}
impl Default for CompactionConfig {
fn default() -> Self {
Self {
threshold_ratio: default_threshold_ratio(),
llm_compaction_threshold_ratio: default_llm_compaction_threshold_ratio(),
truncate_max_tokens: default_truncate_max_tokens(),
preserve_count: default_preserve_count(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TimeConfig {
#[serde(default = "default_timezone")]
@ -311,7 +261,7 @@ fn default_task_enabled() -> bool {
}
fn default_task_max_execution_secs() -> u64 {
3600 // 60分钟
3600 // 60分钟
}
fn default_task_ttl_hours() -> u64 {
@ -457,7 +407,7 @@ fn default_allow_from() -> Vec<String> {
}
fn default_media_dir() -> String {
let home = crate::platform::picobot_home_dir();
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
home.join(".picobot/media/feishu")
.to_string_lossy()
.to_string()
@ -471,7 +421,7 @@ fn default_wechat_base_url() -> String {
}
fn default_wechat_cred_path() -> String {
let home = crate::platform::picobot_home_dir();
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
home.join(".picobot/wechat/credentials.json")
.to_string_lossy()
.to_string()
@ -501,10 +451,6 @@ pub struct ProviderConfig {
pub llm_timeout_secs: u64,
#[serde(default = "default_memory_maintenance_timeout_secs")]
pub memory_maintenance_timeout_secs: u64,
/// LLM 请求瞬态失败timeout/502/503/504/429 等)的最大重试次数。
/// 0 表示不重试。默认 3。
#[serde(default = "default_max_retries")]
pub max_retries: u32,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@ -552,10 +498,6 @@ fn default_memory_maintenance_timeout_secs() -> u64 {
600
}
fn default_max_retries() -> u32 {
3
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GatewayConfig {
#[serde(default = "default_gateway_host")]
@ -573,11 +515,6 @@ pub struct GatewayConfig {
pub max_concurrent_requests: usize,
#[serde(default, rename = "session_ttl_hours")]
pub session_ttl_hours: Option<u64>,
/// 网关认证 token。当绑定到非 loopback 地址时必须配置,否则启动会报错。
/// 绑定到 loopback 时可不配置(本地访问免认证)。
/// 前端通过 Authorization: Bearer <token> 头或 WS query param ?token=<token> 携带。
#[serde(default, rename = "auth_token")]
pub auth_token: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@ -887,7 +824,6 @@ impl Default for GatewayConfig {
agent_prompt_reinject_every: default_agent_prompt_reinject_every(),
max_concurrent_requests: default_max_concurrent_requests(),
session_ttl_hours: Some(24),
auth_token: None,
}
}
}
@ -921,8 +857,6 @@ pub struct LLMProviderConfig {
pub extra_headers: HashMap<String, String>,
pub llm_timeout_secs: u64,
pub memory_maintenance_timeout_secs: u64,
/// LLM 请求瞬态失败的最大重试次数(透传自 ProviderConfig
pub max_retries: u32,
pub model_id: String,
pub temperature: Option<f32>,
pub max_tokens: Option<u32>,
@ -955,7 +889,7 @@ impl LLMProviderConfig {
}
pub(crate) fn get_default_config_path() -> PathBuf {
let home = crate::platform::picobot_home_dir();
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".picobot").join("config.json")
}
@ -1070,7 +1004,6 @@ impl Config {
extra_headers: provider.extra_headers.clone(),
llm_timeout_secs: provider.llm_timeout_secs,
memory_maintenance_timeout_secs: provider.memory_maintenance_timeout_secs,
max_retries: provider.max_retries,
model_id: model.model_id.clone(),
temperature: model.temperature,
max_tokens: model.max_tokens,
@ -1128,10 +1061,7 @@ pub struct ModelResolver {
}
impl ModelResolver {
pub fn new(
providers: HashMap<String, ProviderConfig>,
models: HashMap<String, ModelConfig>,
) -> Self {
pub fn new(providers: HashMap<String, ProviderConfig>, models: HashMap<String, ModelConfig>) -> Self {
Self { providers, models }
}
@ -1165,7 +1095,6 @@ impl ModelResolver {
result.extra_headers = provider.extra_headers.clone();
result.llm_timeout_secs = provider.llm_timeout_secs;
result.memory_maintenance_timeout_secs = provider.memory_maintenance_timeout_secs;
result.max_retries = provider.max_retries;
}
if let Some(name) = model_name.map(str::trim).filter(|s| !s.is_empty()) {
@ -1222,17 +1151,16 @@ fn resolve_env_placeholders(content: &str) -> String {
let re_braces = Regex::new(r"\$\{([A-Z_][A-Z0-9_]*)\}").expect("invalid regex");
let re_angle = Regex::new(r"<([A-Z_]+)>").expect("invalid regex");
let content = re_braces.replace_all(content, |caps: &regex::Captures<'_>| {
let content = re_braces.replace_all(content, |caps: &regex::Captures| {
let var_name = &caps[1];
env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
});
re_angle
.replace_all(&content, |caps: &regex::Captures<'_>| {
let var_name = &caps[1];
env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
})
.to_string()
re_angle.replace_all(&content, |caps: &regex::Captures| {
let var_name = &caps[1];
env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
})
.to_string()
}
#[cfg(test)]
@ -1810,8 +1738,7 @@ mod tests {
"allow_from": ["wxid_1"]
}
}
}"#
.replace("<CRED_PATH>", &cred_path_json),
}"#.replace("<CRED_PATH>", &cred_path_json),
)
.unwrap();
@ -1931,7 +1858,7 @@ mod tests {
timezone: "Asia/Shanghai".to_string(),
});
assert_eq!(effective_jobs.len(), 3); // 2个内置 + 1个自定义
// 第一个作业:内存维护(被覆盖为禁用)
// 第一个作业:内存维护(被覆盖为禁用)
assert_eq!(effective_jobs[0].id, BUILTIN_MEMORY_MAINTENANCE_JOB_ID);
assert!(!effective_jobs[0].enabled);
assert_eq!(
@ -2252,25 +2179,33 @@ mod tests {
#[test]
fn test_scheduler_schedule_validation_rejects_invalid_values() {
assert!(SchedulerSchedule::Delay { seconds: 0 }
.validate("delay.job")
.is_err());
assert!(SchedulerSchedule::Interval {
seconds: 0,
startup_delay_secs: 0,
}
.validate("interval.job")
.is_err());
assert!(SchedulerSchedule::At {
timestamp: "bad timestamp".to_string(),
}
.validate("at.job")
.is_err());
assert!(SchedulerSchedule::Cron {
expression: "bad cron".to_string(),
}
.validate("cron.job")
.is_err());
assert!(
SchedulerSchedule::Delay { seconds: 0 }
.validate("delay.job")
.is_err()
);
assert!(
SchedulerSchedule::Interval {
seconds: 0,
startup_delay_secs: 0,
}
.validate("interval.job")
.is_err()
);
assert!(
SchedulerSchedule::At {
timestamp: "bad timestamp".to_string(),
}
.validate("at.job")
.is_err()
);
assert!(
SchedulerSchedule::Cron {
expression: "bad cron".to_string(),
}
.validate("cron.job")
.is_err()
);
}
#[test]

View File

@ -5,17 +5,14 @@ use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use parking_lot::RwLock;
use std::sync::{Arc, RwLock};
#[cfg(test)]
static EXPERT_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
pub(crate) fn acquire_expert_test_env_lock() -> std::sync::MutexGuard<'static, ()> {
EXPERT_TEST_ENV_LOCK
.lock()
.unwrap_or_else(|err| err.into_inner())
EXPERT_TEST_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner())
}
/// A discovered expert definition.
@ -294,18 +291,13 @@ impl ExpertRuntime {
/// Re-discover experts from the filesystem.
pub fn reload(&self) -> Result<ExpertCatalog, String> {
let config = self
.config
.read()
.clone();
let config = self.config.read().expect("experts config rwlock poisoned").clone();
let catalog = ExpertCatalog::discover_with_state(
&config,
&self.cwd,
Some(&load_expert_disable_state(&self.cwd)),
);
let mut guard = self
.catalog
.write();
let mut guard = self.catalog.write().expect("experts catalog rwlock poisoned");
*guard = catalog.clone();
Ok(catalog)
}
@ -314,7 +306,7 @@ impl ExpertRuntime {
/// 用于前端保存配置后即时生效,无需重启网关。
pub fn update_config(&self, new_config: ExpertsConfig) -> Result<(), String> {
{
let mut guard = self.config.write();
let mut guard = self.config.write().expect("experts config rwlock poisoned");
*guard = new_config;
}
self.reload()?;
@ -325,16 +317,14 @@ impl ExpertRuntime {
pub fn list_experts(&self) -> Vec<Expert> {
self.catalog
.read()
.expect("experts catalog rwlock poisoned")
.experts
.clone()
}
/// List all discovered experts including disabled ones, with their disabled scopes.
pub fn list_experts_with_status(&self) -> Vec<ExpertWithStatus> {
let config = self
.config
.read()
.clone();
let config = self.config.read().expect("experts config rwlock poisoned").clone();
let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd);
let disable_state = load_expert_disable_state(&self.cwd);
@ -363,6 +353,7 @@ impl ExpertRuntime {
pub fn get_expert(&self, name: &str) -> Option<Expert> {
self.catalog
.read()
.expect("experts catalog rwlock poisoned")
.find_expert(name)
.cloned()
}
@ -420,15 +411,7 @@ impl ExpertRuntime {
let next_provider = provider.cloned().unwrap_or(existing.provider);
let next_model = model.cloned().unwrap_or(existing.model);
write_expert_file(
&path,
name,
next_description,
next_body,
&next_capability,
&next_provider,
&next_model,
)?;
write_expert_file(&path, name, next_description, next_body, &next_capability, &next_provider, &next_model)?;
let expert = parse_expert_file(&path, scope.into())?;
if reload {
let _ = self.reload()?;
@ -474,10 +457,7 @@ impl ExpertRuntime {
pub fn has_expert_definition(&self, name: &str) -> Result<bool, String> {
validate_expert_name(name)?;
let config = self
.config
.read()
.clone();
let config = self.config.read().expect("experts config rwlock poisoned").clone();
let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd);
Ok(catalog.find_expert(name).is_some())
}
@ -511,7 +491,8 @@ impl ExpertRuntime {
{
let mut state = self
.disable_state
.write();
.write()
.expect("experts disable_state rwlock poisoned");
match scope {
ExpertScope::User => {
if enabled {
@ -535,7 +516,8 @@ impl ExpertRuntime {
let state = self
.disable_state
.read();
.read()
.expect("experts disable_state rwlock poisoned");
let disabled_in_scopes = state.disabled_scopes_for(name);
Ok(ExpertAvailabilityChange {
@ -554,13 +536,17 @@ impl ExpertRuntime {
}
// The expert must exist (and not be disabled) for selection to be meaningful.
if self.get_expert(expert_name).is_none() {
return Err(format!("expert '{}' not found or disabled", expert_name));
return Err(format!(
"expert '{}' not found or disabled",
expert_name
));
}
{
let mut sessions = self
.session_experts
.write();
.write()
.expect("experts session_experts rwlock poisoned");
sessions.insert(session_id.to_string(), expert_name.to_string());
}
persist_session_experts(&self.cwd, |state| {
@ -575,7 +561,8 @@ impl ExpertRuntime {
{
let mut sessions = self
.session_experts
.write();
.write()
.expect("experts session_experts rwlock poisoned");
sessions.remove(session_id);
}
persist_session_experts(&self.cwd, |state| {
@ -588,14 +575,16 @@ impl ExpertRuntime {
let name = {
let sessions = self
.session_experts
.read();
.read()
.expect("experts session_experts rwlock poisoned");
sessions.get(session_id).cloned()
}?;
// Filter out disabled experts.
let state = self
.disable_state
.read();
.read()
.expect("experts disable_state rwlock poisoned");
if state.is_disabled(&name) {
return None;
}
@ -635,7 +624,10 @@ impl SystemPromptProvider for ExpertPromptProvider {
let content = if expert.body.trim().is_empty() {
// Empty body is OK; inject a header so the LLM still knows the role.
format!("# 专家角色: {}\n\n{}", expert.name, expert.description)
format!(
"# 专家角色: {}\n\n{}",
expert.name, expert.description
)
} else {
expert.body.clone()
};
@ -675,9 +667,8 @@ fn expert_state_path(scope: ExpertScope, cwd: &Path) -> PathBuf {
fn root_for_scope(scope: ExpertScope, cwd: &Path) -> Result<PathBuf, String> {
match scope {
ExpertScope::User => {
user_experts_root().ok_or_else(|| "failed to resolve home directory".to_string())
}
ExpertScope::User => user_experts_root()
.ok_or_else(|| "failed to resolve home directory".to_string()),
ExpertScope::Project => Ok(project_experts_root(cwd)),
}
}
@ -1029,10 +1020,7 @@ fn load_project_session_experts(cwd: &Path) -> HashMap<String, String> {
/// Persist a mutation to the project-scope state file's session_experts while
/// preserving the existing disabled_experts field.
fn persist_session_experts<F: FnOnce(&mut ExpertStateFile)>(
cwd: &Path,
mutate: F,
) -> Result<(), String> {
fn persist_session_experts<F: FnOnce(&mut ExpertStateFile)>(cwd: &Path, mutate: F) -> Result<(), String> {
let path = project_expert_state_path(cwd);
let mut state = load_expert_state_file(&path)?;
mutate(&mut state);
@ -1109,11 +1097,7 @@ mod tests {
let expert_dir = dir.path().join("demo");
fs::create_dir_all(&expert_dir).unwrap();
let expert_md = expert_dir.join("EXPERT.md");
fs::write(
&expert_md,
"---\r\ndescription: demo expert\r\n---\r\nStep A\r\nStep B",
)
.unwrap();
fs::write(&expert_md, "---\r\ndescription: demo expert\r\n---\r\nStep A\r\nStep B").unwrap();
let expert = parse_expert_file(&expert_md, ExpertSource::Project).unwrap();
assert_eq!(expert.name, "demo");
@ -1153,49 +1137,33 @@ mod tests {
#[test]
fn test_render_expert_file_requires_description() {
let err = render_expert_file(
"demo",
" ",
"body",
&CapabilityPolicy::default(),
&None,
&None,
)
.unwrap_err();
let err = render_expert_file("demo", " ", "body", &CapabilityPolicy::default(), &None, &None).unwrap_err();
assert!(err.contains("description"));
}
#[test]
fn test_capability_policy_is_empty_helpers() {
assert!(CapabilityPolicy::default().is_empty());
assert!(
!CapabilityPolicy {
allowed_skills: Some(vec!["a".to_string()]),
..Default::default()
}
.is_empty()
);
assert!(
CapabilityPolicy {
denied_tools: vec![],
..Default::default()
}
.is_empty()
);
assert!(
CapabilityPolicy {
allowed_tools: Some(vec![]),
..Default::default()
}
.has_tool_policy()
);
assert!(
CapabilityPolicy {
denied_skills: vec!["x".to_string()],
..Default::default()
}
.has_skill_policy()
);
assert!(!CapabilityPolicy {
allowed_skills: Some(vec!["a".to_string()]),
..Default::default()
}
.is_empty());
assert!(CapabilityPolicy {
denied_tools: vec![],
..Default::default()
}
.is_empty());
assert!(CapabilityPolicy {
allowed_tools: Some(vec![]),
..Default::default()
}
.has_tool_policy());
assert!(CapabilityPolicy {
denied_skills: vec!["x".to_string()],
..Default::default()
}
.has_skill_policy());
}
#[test]
@ -1228,15 +1196,7 @@ mod tests {
#[test]
fn test_empty_capability_omits_keys() {
// 空策略不应输出多余 frontmatter 键,保持旧文件格式兼容
let rendered = render_expert_file(
"plain",
"desc",
"body",
&CapabilityPolicy::default(),
&None,
&None,
)
.unwrap();
let rendered = render_expert_file("plain", "desc", "body", &CapabilityPolicy::default(), &None, &None).unwrap();
assert!(!rendered.contains("allowed_skills"));
assert!(!rendered.contains("denied_skills"));
assert!(!rendered.contains("allowed_tools"));
@ -1264,7 +1224,10 @@ mod tests {
.unwrap();
// project scope (overrides user)
let project_dir_expert = project_dir.join(".picobot").join("experts").join("demo");
let project_dir_expert = project_dir
.join(".picobot")
.join("experts")
.join("demo");
fs::create_dir_all(&project_dir_expert).unwrap();
fs::write(
project_dir_expert.join("EXPERT.md"),
@ -1347,16 +1310,7 @@ mod tests {
// update with None preserves fields
let updated_none = runtime
.update_expert(
ExpertScope::Project,
"translator",
None,
None,
None,
None,
None,
true,
)
.update_expert(ExpertScope::Project, "translator", None, None, None, None, None, true)
.unwrap();
assert_eq!(updated_none.description, "更新翻译专家");
assert_eq!(updated_none.body, "你是一名中文教师。");
@ -1555,21 +1509,13 @@ mod tests {
.unwrap();
let items = runtime.list_experts_with_status();
assert_eq!(
items.len(),
1,
"list_experts_with_status should include disabled experts"
);
assert_eq!(items.len(), 1, "list_experts_with_status should include disabled experts");
assert_eq!(items[0].name, "planner");
assert_eq!(items[0].disabled_in_scopes, vec!["project".to_string()]);
// list_experts (filtered) should be empty
let active = runtime.list_experts();
assert_eq!(
active.len(),
0,
"list_experts should filter out disabled experts"
);
assert_eq!(active.len(), 0, "list_experts should filter out disabled experts");
}
#[test]
@ -1580,9 +1526,7 @@ mod tests {
session_experts: HashMap::new(),
disabled_experts: vec!["demo".to_string()],
};
state
.session_experts
.insert("sess-1".to_string(), "demo".to_string());
state.session_experts.insert("sess-1".to_string(), "demo".to_string());
save_expert_state_file(&path, &state).unwrap();

View File

@ -1,5 +1,5 @@
use gray_matter::Matter;
use gray_matter::engine::YAML;
use gray_matter::Matter;
use serde::de::DeserializeOwned;
/// Parse a markdown document with YAML frontmatter into `(frontmatter, body)`.
@ -45,13 +45,7 @@ mod tests {
fn parses_lf_endings() {
let input = "---\ndescription: demo\n---\nbody text";
let (fm, body) = parse::<FrontMatter>(input).unwrap();
assert_eq!(
fm,
FrontMatter {
description: "demo".to_string(),
name: None
}
);
assert_eq!(fm, FrontMatter { description: "demo".to_string(), name: None });
assert_eq!(body, "body text");
}

View File

@ -1,8 +1,7 @@
use std::sync::Arc;
use crate::agent::context_compressor::ContextCompressor;
use crate::agent::{AgentError, AgentLoop, AgentRuntimeConfig, CompositeSystemPromptProvider, SystemPromptProvider};
use crate::config::{CompactionConfig, LLMProviderConfig, ModelResolver};
use crate::agent::{AgentError, AgentLoop, CompositeSystemPromptProvider, SystemPromptProvider};
use crate::config::{LLMProviderConfig, ModelResolver};
use crate::domain::CapabilityPolicy;
use crate::experts::ExpertPromptProvider;
use crate::experts::ExpertRuntime;
@ -10,8 +9,8 @@ use crate::gateway::agent_prompt_provider::AgentPromptProvider;
use crate::gateway::model_selection::ModelSelectionStore;
use crate::gateway::tool_prompt_provider::ToolPromptProvider;
use crate::skills::{SkillPromptProvider, SkillRuntime};
use crate::storage::PromptInjectionRepository;
use crate::storage::persistent_session_id;
use crate::storage::PromptInjectionRepository;
use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime};
use crate::tools::{ToolContext, ToolRegistry};
@ -54,8 +53,6 @@ pub(crate) struct AgentFactory {
model_resolver: Arc<ModelResolver>,
/// per-session 的用户模型选择(最高优先级,覆盖专家配置)
model_selections: Arc<ModelSelectionStore>,
/// 上下文压缩算法配置(所有 agent 共享)
compaction_config: CompactionConfig,
/// 实例创建时间戳(用于区分新旧 AgentFactory 实例)
instance_id: u64,
}
@ -83,7 +80,6 @@ impl AgentFactory {
prompt_repository: Arc<dyn PromptInjectionRepository>,
model_resolver: Arc<ModelResolver>,
model_selections: Arc<ModelSelectionStore>,
compaction_config: CompactionConfig,
) -> Self {
// 使用 Arc 指针地址作为实例标识符,用于区分新旧 AgentFactory 实例
let instance_id = Arc::as_ptr(&tools) as u64;
@ -101,22 +97,10 @@ impl AgentFactory {
prompt_repository,
model_resolver,
model_selections,
compaction_config,
instance_id,
}
}
/// 构造 ContextCompressor参数内聚到 ContextCompressorCompactionConfig 注入)。
/// AgentLoopin-loop 压缩)和 Sessionsync 兜底压缩)共用此方法,
/// 确保两条压缩路径使用同一套用户配置的压缩参数。
pub(crate) fn build_compressor(&self, runtime_config: &AgentRuntimeConfig) -> ContextCompressor {
ContextCompressor::with_compaction_config(
runtime_config.context_window_tokens,
runtime_config.context_summary_char_budget,
&self.compaction_config,
)
}
pub(crate) fn create(&self, request: AgentBuildRequest<'_>) -> Result<AgentLoop, AgentError> {
let session_id = persistent_session_id(request.channel_name, request.session_chat_id);
@ -128,14 +112,12 @@ impl AgentFactory {
// 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择的角色,配置错误应明确反馈)。
let expert_provider_config = match &expert {
Some(e) if e.provider.is_some() || e.model.is_some() => {
let resolved = self
.model_resolver
.resolve(
e.provider.as_deref(),
e.model.as_deref(),
&request.provider_config,
)
.map_err(|e| AgentError::Other(e.to_string()))?;
let resolved = self.model_resolver.resolve(
e.provider.as_deref(),
e.model.as_deref(),
&request.provider_config,
)
.map_err(|e| AgentError::Other(e.to_string()))?;
tracing::info!(
instance_id = self.instance_id,
session_id = %session_id,
@ -151,29 +133,30 @@ impl AgentFactory {
// 按用户手动选择的 provider/model 覆盖(最高优先级,覆盖专家配置)。
// 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择,配置错误应明确反馈)。
let effective_provider_config = match self.model_selections.get(&session_id) {
Some((user_provider, user_model))
if user_provider.is_some() || user_model.is_some() =>
{
let resolved = self
.model_resolver
.resolve(
user_provider.as_deref(),
user_model.as_deref(),
&expert_provider_config,
)
.map_err(|e| AgentError::Other(e.to_string()))?;
tracing::info!(
instance_id = self.instance_id,
session_id = %session_id,
provider = %resolved.name,
model_id = %resolved.model_id,
"AgentFactory: applied user model override"
);
resolved
}
_ => expert_provider_config,
};
let effective_provider_config =
match self.model_selections.get(&session_id) {
Some((user_provider, user_model))
if user_provider.is_some() || user_model.is_some() =>
{
let resolved = self
.model_resolver
.resolve(
user_provider.as_deref(),
user_model.as_deref(),
&expert_provider_config,
)
.map_err(|e| AgentError::Other(e.to_string()))?;
tracing::info!(
instance_id = self.instance_id,
session_id = %session_id,
provider = %resolved.name,
model_id = %resolved.model_id,
"AgentFactory: applied user model override"
);
resolved
}
_ => expert_provider_config,
};
// 诊断日志:记录 agent 实际使用的配置和实例 ID
tracing::info!(
@ -216,7 +199,7 @@ impl AgentFactory {
};
AgentLoop::with_tools_and_system_prompt_provider(
effective_provider_config.clone(),
effective_provider_config,
tools,
system_prompt_provider,
Some(self.skills.clone()),
@ -226,27 +209,22 @@ impl AgentFactory {
let tool_chat_id = request
.notification_chat_id
.unwrap_or(request.session_chat_id);
// 构建上下文压缩器(参数内聚到 ContextCompressorCompactionConfig 注入)
let runtime_config = AgentRuntimeConfig::from(effective_provider_config.clone());
let compressor = Arc::new(self.build_compressor(&runtime_config));
let mut agent = agent
.with_tool_context(ToolContext {
channel_name: Some(request.channel_name.to_string()),
sender_id: request.sender_id.map(str::to_string),
chat_id: Some(tool_chat_id.to_string()),
session_id: Some(session_id),
topic_id: request.topic_id.clone(),
message_id: request.message_id.map(str::to_string),
message_seq: None,
subagent_description: None,
nesting_depth: 0,
task_id: None,
parent_task_id: None,
tool_call_id: None,
// 注入专家 capabilityTaskTool 据此强制校验子代理白/黑名单
parent_capability: expert_capability.clone(),
})
.with_compressor(Some(compressor));
let mut agent = agent.with_tool_context(ToolContext {
channel_name: Some(request.channel_name.to_string()),
sender_id: request.sender_id.map(str::to_string),
chat_id: Some(tool_chat_id.to_string()),
session_id: Some(session_id),
topic_id: request.topic_id.clone(),
message_id: request.message_id.map(str::to_string),
message_seq: None,
subagent_description: None,
nesting_depth: 0,
task_id: None,
parent_task_id: None,
tool_call_id: None,
// 注入专家 capabilityTaskTool 据此强制校验子代理白/黑名单
parent_capability: expert_capability.clone(),
});
// 如果有取消信号接收端,注入 Agent
if let Some(token) = request.cancel_token {
agent = agent.with_cancel_token(token);

View File

@ -129,7 +129,6 @@ mod tests {
extra_headers: HashMap::new(),
llm_timeout_secs: 120,
memory_maintenance_timeout_secs: 600,
max_retries: 3,
model_id: "test-model".to_string(),
temperature: Some(0.0),
max_tokens: Some(32),

View File

@ -40,13 +40,7 @@ impl AgentTaskExecutor {
options: ScheduledAgentTaskOptions,
) -> Result<Vec<OutboundMessage>, AgentError> {
self.session_manager
.run_silent_agent_task(
channel_name,
session_chat_id,
notification_chat_id,
prompt,
options,
)
.run_silent_agent_task(channel_name, session_chat_id, notification_chat_id, prompt, options)
.await
}
}
@ -99,12 +93,8 @@ impl SchedulerMaintenanceService {
self.session_manager.cleanup_expired_sessions().await
}
async fn run_memory_maintenance(
&self,
) -> Result<Option<MemoryMaintenanceScopeResult>, AgentError> {
self.session_manager
.run_memory_maintenance_for_all_scopes()
.await
async fn run_memory_maintenance(&self) -> Result<Option<MemoryMaintenanceScopeResult>, AgentError> {
self.session_manager.run_memory_maintenance_for_all_scopes().await
}
}
@ -114,9 +104,7 @@ impl MaintenanceExecutor for SchedulerMaintenanceService {
self.cleanup_sessions().await
}
async fn run_memory_maintenance_for_all_scopes(
&self,
) -> anyhow::Result<Vec<MaintenanceRunSummary>> {
async fn run_memory_maintenance_for_all_scopes(&self) -> anyhow::Result<Vec<MaintenanceRunSummary>> {
self.run_memory_maintenance()
.await
.map(|results| {

View File

@ -1,243 +0,0 @@
//! 网关认证与访问控制。
//!
//! 设计目标(第一性原理):
//! - 本地单机部署host 为 loopback免认证仅靠 CORS 防御 DNS rebinding / CSRF。
//! - 远程访问host 非 loopback必须配置 `auth_token`,所有 `/api/*` 与 `/ws` 强制校验。
//! - token 通过 `Authorization: Bearer <token>`HTTP或 `?token=<token>`WS传递。
//! - 校验使用常量时间比较,避免计时侧信道。
use axum::extract::Request;
use axum::http::{HeaderMap, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
use subtle::ConstantTimeEq;
/// 判定给定 host 是否为 loopback 地址。
/// 通过 `std::net::IpAddr` 解析,覆盖 IPv4/IPv6 的所有等价表示。
/// 也接受 `localhost` 主机名。
pub fn is_loopback_host(host: &str) -> bool {
let h = host.trim();
if h.eq_ignore_ascii_case("localhost") {
return true;
}
// 去除 IPv6 方括号(如 `[::1]`
let bare = h
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(h);
// 尝试解析为 IpAddr
match bare.parse::<std::net::IpAddr>() {
Ok(std::net::IpAddr::V4(v4)) => v4.is_loopback(),
Ok(std::net::IpAddr::V6(v6)) => v6.is_loopback(),
Err(_) => {
// 解析失败(如域名),保守判定为非 loopback强制认证
false
}
}
}
/// 判定当前配置是否需要强制认证。
/// - host 非 loopback必须认证且 auth_token 必须存在,否则启动会报错)
/// - host 为 loopback 但显式配置了 auth_token也启用认证用户主动加固
pub fn requires_auth(host: &str, auth_token: &Option<String>) -> bool {
!is_loopback_host(host) || auth_token.is_some()
}
/// 校验请求携带的 token 是否与配置的 auth_token 匹配(常量时间)。
/// 返回 true 表示通过(含「未配置 token 则放行」的兜底,调用方应先用 requires_auth 判定)。
pub fn token_matches(provided: Option<&str>, expected: &Option<String>) -> bool {
match expected {
// 未配置 token放行调用方已通过 requires_auth 保证只在 loopback 下到达此处)
None => true,
Some(expected_str) => match provided {
None => false,
Some(provided_str) => {
// subtle::ConstantTimeEq 要求两端等长;长度不等时 ct_eq 返回 0false
// 但为避免长度差异导致的提前退出计时泄露,我们确保比较路径不因长度而分支提前返回。
let p = provided_str.as_bytes();
let e = expected_str.as_bytes();
// ct_eq 内部在长度不等时仍会遍历较短的一侧,返回 0无提前退出
p.ct_eq(e).unwrap_u8() == 1
}
},
}
}
/// 从 Authorization 头提取 Bearer tokenRFC 6750scheme 不区分大小写)。
pub fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| {
// RFC 6750: scheme 名不区分大小写Bearer / bearer / BEARER 均合法)
// 找到第一个空格分隔 scheme 与 token比较 scheme 部分忽略大小写
let trimmed = s.trim_start();
let split = trimmed.find(char::is_whitespace)?;
let (scheme, rest) = trimmed.split_at(split);
if scheme.eq_ignore_ascii_case("Bearer") {
Some(rest.trim())
} else {
None
}
})
}
/// axum 中间件:对 `/api/*` 路由强制 Bearer token 校验。
/// 仅在 `requires_auth` 为 true 时挂载。
/// `/health`、`/ws`、静态资源放行;`/ws` 的 token 校验在 ws_handler 内完成。
pub async fn require_bearer_auth(
headers: HeaderMap,
request: Request,
next: Next,
) -> Response {
let path = request.uri().path();
// 仅对 /api/ 前缀的请求强制认证
if !path.starts_with("/api/") {
return next.run(request).await;
}
// expected_token 通过 extension 注入(见 mod.rs 装配处)
let expected = request
.extensions()
.get::<AuthConfig>()
.map(|c| c.token.clone())
.unwrap_or(None);
let provided = extract_bearer_token(&headers);
if token_matches(provided, &expected) {
next.run(request).await
} else {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "unauthorized", "message": "missing or invalid token" })),
)
.into_response()
}
}
/// 通过 extension 注入到 Router 的认证配置。
#[derive(Clone, Debug)]
pub struct AuthConfig {
pub token: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderMap;
#[test]
fn loopback_detection() {
// IPv4 loopback 整个 127.0.0.0/8 段
assert!(is_loopback_host("127.0.0.1"));
assert!(is_loopback_host("127.0.0.2"));
assert!(is_loopback_host("127.1.2.3"));
assert!(is_loopback_host("127.255.255.255"));
// IPv6 loopback 的各种等价表示
assert!(is_loopback_host("::1"));
assert!(is_loopback_host("[::1]"));
assert!(is_loopback_host("0:0:0:0:0:0:0:1"));
// localhost 主机名(大小写不敏感)
assert!(is_loopback_host("localhost"));
assert!(is_loopback_host("LOCALHOST"));
assert!(is_loopback_host("Localhost"));
// 空白容错
assert!(is_loopback_host(" 127.0.0.1 "));
assert!(is_loopback_host(" localhost "));
// 非 loopback
assert!(!is_loopback_host("0.0.0.0"));
assert!(!is_loopback_host("192.168.1.1"));
assert!(!is_loopback_host("10.0.0.1"));
assert!(!is_loopback_host("172.16.0.1"));
assert!(!is_loopback_host("example.com"));
assert!(!is_loopback_host("picobot.local"));
assert!(!is_loopback_host("::"));
assert!(!is_loopback_host(""));
}
#[test]
fn requires_auth_logic() {
// 非 loopback 无论 token 是否配置都要认证
assert!(requires_auth("0.0.0.0", &None));
assert!(requires_auth("192.168.1.1", &None));
assert!(requires_auth("0.0.0.0", &Some("t".into())));
// loopback 无 token免认证
assert!(!requires_auth("127.0.0.1", &None));
assert!(!requires_auth("::1", &None));
assert!(!requires_auth("localhost", &None));
// loopback 有 token认证用户主动加固
assert!(requires_auth("127.0.0.1", &Some("t".into())));
// 域名始终需认证
assert!(requires_auth("myserver.com", &None));
}
#[test]
fn token_matching() {
// 未配置 token始终放行
assert!(token_matches(None, &None));
assert!(token_matches(Some("anything"), &None));
// 配置了 token
let expected = Some("s3cr3t".to_string());
assert!(token_matches(Some("s3cr3t"), &expected));
assert!(!token_matches(Some("wrong"), &expected));
assert!(!token_matches(None, &expected));
// 长度不同
assert!(!token_matches(Some("s3cr3t-extra"), &expected));
assert!(!token_matches(Some("s3"), &expected));
assert!(!token_matches(Some(""), &expected));
// 空字符串 token配置了但为空 — 等同于未配置的放行语义由 requires_auth 控制)
let empty_expected = Some(String::new());
assert!(token_matches(Some(""), &empty_expected));
assert!(!token_matches(Some("x"), &empty_expected));
}
#[test]
fn extract_bearer_token_rfc6750() {
fn make_auth_header(value: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(
axum::http::header::AUTHORIZATION,
axum::http::HeaderValue::from_str(value).unwrap(),
);
h
}
// 标准格式
assert_eq!(
extract_bearer_token(&make_auth_header("Bearer abc123")),
Some("abc123")
);
// scheme 大小写不敏感
assert_eq!(
extract_bearer_token(&make_auth_header("bearer abc123")),
Some("abc123")
);
assert_eq!(
extract_bearer_token(&make_auth_header("BEARER abc123")),
Some("abc123")
);
// 多空格容忍
assert_eq!(
extract_bearer_token(&make_auth_header("Bearer abc123")),
Some("abc123")
);
assert_eq!(
extract_bearer_token(&make_auth_header("Bearer\tabc123")),
Some("abc123")
);
// 非 Bearer scheme
assert_eq!(
extract_bearer_token(&make_auth_header("Basic dXNlcjpwYXNz")),
None
);
// 无 Authorization 头
let empty = HeaderMap::new();
assert_eq!(extract_bearer_token(&empty), None);
// 缺少 token 部分
assert_eq!(extract_bearer_token(&make_auth_header("Bearer")), None);
assert_eq!(extract_bearer_token(&make_auth_header("Bearer ")), Some(""));
}
}

View File

@ -58,14 +58,6 @@ impl CancelManager {
self.tokens.lock().await.len()
}
/// 返回当前正在执行的 Agent 的 topic_id 列表。
///
/// 用于前端重连时对账执行状态:前端通过此 API 判断断连期间
/// 哪些话题的智能体仍在运行、哪些已完成。
pub async fn list_active_topic_ids(&self) -> Vec<String> {
self.tokens.lock().await.keys().cloned().collect()
}
/// 取消所有正在运行的 Agent 并清空注册表。
///
/// 用于 graceful shutdown / restart 场景。

View File

@ -52,17 +52,17 @@ pub(crate) async fn schedule_background_history_compaction(
.compress_two_segment(&history, &provider_config)
.await?;
// 保留原始消息(标记 is_compacted=1+ 插入压缩摘要,不删除原消息,
// 从而让前端仍能展示完整原始对话LLM 只看压缩后的精简历史。
// Replace only this topic's history in DB (not the entire session).
// This avoids clobbering other topics' messages during compaction.
store
.compact_topic_history(&session_id, &topic_id, &compressed)
.map_err(|e| AgentError::Other(format!("compact_topic_history error: {}", e)))?;
.replace_topic_history(&session_id, &topic_id, &compressed)
.map_err(|e| AgentError::Other(format!("replace_topic_history error: {}", e)))?;
tracing::info!(
chat_id = %chat_id,
topic_id = %topic_id,
compressed_msg_count = compressed.len(),
"Two-segment compression committed (original messages retained)"
"Two-segment compression committed"
);
session_guard.reload_topic_history(&chat_id, &topic_id)?;

View File

@ -1,15 +1,12 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::agent::{
AgentError, AgentProcessResult, CompactionSink, EmittedMessageHandler,
PersistingEmittedMessageHandler, SystemPromptContext,
};
use async_trait::async_trait;
use crate::agent::{AgentError, AgentProcessResult, EmittedMessageHandler, PersistingEmittedMessageHandler, SystemPromptContext};
use crate::bus::message::ToolMessageState;
use crate::bus::{ChatMessage, MediaItem, OutboundMessage, SYSTEM_CONTEXT_SCHEDULED_PROMPT};
use crate::config::LLMProviderConfig;
use crate::storage::{ConversationRepository, persistent_session_id};
use async_trait::async_trait;
use crate::storage::{persistent_session_id, ConversationRepository};
use tokio::sync::Mutex;
use super::compaction::schedule_background_history_compaction;
@ -24,51 +21,6 @@ impl EmittedMessageHandler for NoOpEmittedMessageHandler {
async fn handle(&self, _message: ChatMessage) {}
}
/// CompactionSink 实现:在 AgentLoop 内部触发 LLM 压缩时,
/// 把压缩后的消息写回 DB标记原消息 is_compacted=1 + 插入摘要)。
///
/// 不在此处 reload 内存历史——process() 仍在使用局部 messages 变量,
/// 内存历史的刷新由 finalize_result 在 process 返回后统一处理。
pub(crate) struct CompactionSinkImpl {
session: Arc<Mutex<Session>>,
chat_id: String,
topic_id: String,
}
impl CompactionSinkImpl {
pub(crate) fn new(session: Arc<Mutex<Session>>, chat_id: String, topic_id: String) -> Self {
Self {
session,
chat_id,
topic_id,
}
}
}
#[async_trait]
impl CompactionSink for CompactionSinkImpl {
async fn compact(&self, compressed: &[ChatMessage]) -> Result<(), AgentError> {
let mut session_guard = self.session.lock().await;
session_guard.ensure_persistent_session(&self.chat_id)?;
session_guard.ensure_chat_loaded(&self.chat_id, Some(&self.topic_id))?;
let store = session_guard.store();
let session_id = session_guard.persistent_session_id(&self.chat_id);
store
.compact_topic_history(&session_id, &self.topic_id, compressed)
.map_err(|e| AgentError::Other(format!("compact_topic_history error: {}", e)))?;
tracing::info!(
chat_id = %self.chat_id,
topic_id = %self.topic_id,
compressed_msg_count = compressed.len(),
"In-loop LLM compaction committed to DB (original messages retained as is_compacted=1)"
);
Ok(())
}
}
const SCHEDULED_TASK_EXECUTION_SYSTEM_PROMPT: &str = "系统说明当前输入来自一次已经触发的定时任务执行。你现在需要执行任务内容本身而不是创建、修改、恢复、暂停或查询新的定时任务。除非当前任务内容明确要求管理调度器否则不要调用任何定时任务管理工具像“每小时”、“每天”、“cron”、“定时”等词只应视为任务背景不应再解释为新的建任务请求。";
pub(crate) fn compose_scheduled_task_system_prompt(system_prompt: Option<&str>) -> String {
@ -148,12 +100,9 @@ impl AgentExecutionService {
};
if !is_current_turn {
let (latest_user_id, latest_user_preview, compression_in_flight, history_len) = session
.stale_result_diagnostics(
request
.original_topic_id
.as_deref()
.unwrap_or(request.chat_id),
let (latest_user_id, latest_user_preview, compression_in_flight, history_len) =
session.stale_result_diagnostics(
request.original_topic_id.as_deref().unwrap_or(request.chat_id),
);
tracing::info!(
channel = %request.channel_name,
@ -173,31 +122,14 @@ impl AgentExecutionService {
// 始终使用执行开始时捕获的 original_topic_id避免从共享状态重复读取竞态
let target_topic_id = request.original_topic_id.as_deref();
// 如果 AgentLoop 内部已触发 LLM 压缩DB 已被 CompactionSink 更新
// (原消息标记 is_compacted=1 + 压缩摘要已插入)。
// 此时 emitted_messages 早已通过 handler 持久化,且作为"最新5个 unit"
// 包含在压缩输出中。直接 append 到内存历史会产生重复,因此从 DB 重新加载。
if request.result.compaction_performed && is_current_turn {
let reload_topic = target_topic_id.unwrap_or(request.chat_id);
if let Err(err) = session.reload_topic_history(request.chat_id, reload_topic) {
tracing::error!(
error = %err,
chat_id = %request.chat_id,
topic_id = %reload_topic,
"Failed to reload topic history after in-loop compaction"
);
}
tracing::info!(
chat_id = %request.chat_id,
topic_id = %reload_topic,
"In-loop compaction was performed; reloaded topic history from DB"
);
} else if let Some(topic_id) = target_topic_id {
// 将结果消息保存到确定的话题
if let Some(topic_id) = target_topic_id {
if is_current_turn {
// 话题未切换current_topic == original_topic_id安全更新内存历史
if let Err(err) = session
.append_persisted_messages(topic_id, request.result.emitted_messages.clone())
{
if let Err(err) = session.append_persisted_messages(
topic_id,
request.result.emitted_messages.clone(),
) {
tracing::error!(
error = %err,
topic_id = %topic_id,
@ -221,9 +153,10 @@ impl AgentExecutionService {
} else if is_current_turn {
// 没有话题直接更新内存历史append_persisted_messages 会处理持久化)
// 无 topic 场景用 chat_id 作为 topic_histories 的回退 key
if let Err(err) = session
.append_persisted_messages(request.chat_id, request.result.emitted_messages.clone())
{
if let Err(err) = session.append_persisted_messages(
request.chat_id,
request.result.emitted_messages.clone(),
) {
tracing::error!(
error = %err,
chat_id = %request.chat_id,
@ -259,13 +192,8 @@ impl AgentExecutionService {
Vec::new()
};
// 只有当是最新回合且未在 loop 内触发过任何压缩(工程化或 LLM
// 才触发兜底历史压缩。in-loop 已做工程化压缩时跳过——因为兜底基于
// 未压缩历史的 estimate_tokens 判断会不准确,可能冗余触发 LLM 压缩,
// 违背"in-loop 已判断工程化压缩足够则不 LLM 压缩"的意图。
let should_schedule_compaction = is_current_turn
&& !request.result.compaction_performed
&& !request.result.engineering_compaction_applied;
// 只有当是最新回合时才触发历史压缩
let should_schedule_compaction = is_current_turn;
Ok(FinalizedAgentResult {
outbound_messages,
@ -346,13 +274,7 @@ impl AgentExecutionService {
agent = agent.with_emitted_message_handler(handler);
}
(
history,
agent,
user_message,
user_message_count,
original_topic_id,
)
(history, agent, user_message, user_message_count, original_topic_id)
};
// 构建系统提示词上下文
@ -362,20 +284,7 @@ impl AgentExecutionService {
user_message_count,
};
// 构建 CompactionSink在 AgentLoop 内部触发 LLM 压缩时把结果写回 DB。
// topic_id 退化为 chat_id与 history_key 一致)。
let compaction_topic_id = original_topic_id
.clone()
.unwrap_or_else(|| request.chat_id.to_string());
let compaction_sink = CompactionSinkImpl::new(
request.session.clone(),
request.chat_id.to_string(),
compaction_topic_id,
);
let result = agent
.process(history, Some(&system_prompt_context), Some(&compaction_sink))
.await?;
let result = agent.process(history, Some(&system_prompt_context)).await?;
let mut metadata = HashMap::new();
// 把用户消息的 UUID 回传给前端,前端用此更新本地消息 ID使 todo 点击跳转能匹配
metadata.insert("user_message_id".to_string(), user_message.id.clone());
@ -415,15 +324,7 @@ impl AgentExecutionService {
// 等待该 topic 的前一条消息处理完成(含压缩)
let _serial_guard = serial_lock.lock().await;
let (
history,
mut agent,
user_message,
user_message_count,
original_topic_id,
store,
session_id,
) = {
let (history, mut agent, user_message, user_message_count, original_topic_id, store, session_id) = {
let mut session_guard = request.session.lock().await;
session_guard.ensure_persistent_session(request.chat_id)?;
@ -481,18 +382,12 @@ impl AgentExecutionService {
// 获取 store 和 session_id用于构造消息持久化 handler
let store = session_guard.store();
let session_id =
crate::storage::persistent_session_id(request.channel_name, request.chat_id);
let session_id = crate::storage::persistent_session_id(
request.channel_name,
request.chat_id,
);
(
history,
agent,
user_message,
user_message_count,
original_topic_id,
store,
session_id,
)
(history, agent, user_message, user_message_count, original_topic_id, store, session_id)
};
// 定时任务没有 live_emitter需要 PersistingEmittedMessageHandler 来持久化消息
@ -513,35 +408,22 @@ impl AgentExecutionService {
user_message_count,
};
// 构建 CompactionSink在 AgentLoop 内部触发 LLM 压缩时把结果写回 DB。
let compaction_topic_id = original_topic_id
.clone()
.unwrap_or_else(|| request.chat_id.to_string());
let compaction_sink = CompactionSinkImpl::new(
let result = agent.process(history, Some(&system_prompt_context)).await?;
let outbound_messages = self.finalize_result_and_schedule_compaction(
request.session.clone(),
request.chat_id.to_string(),
compaction_topic_id,
);
let result = agent
.process(history, Some(&system_prompt_context), Some(&compaction_sink))
.await?;
let outbound_messages = self
.finalize_result_and_schedule_compaction(
request.session.clone(),
FinalizeAgentResultRequest {
channel_name: request.channel_name,
chat_id: request.chat_id,
user_message: &user_message,
result,
metadata: request.metadata,
suppress_live_tool_calls: false,
execution_kind: "scheduled_task",
original_topic_id: original_topic_id.clone(),
},
)
.await?;
FinalizeAgentResultRequest {
channel_name: request.channel_name,
chat_id: request.chat_id,
user_message: &user_message,
result,
metadata: request.metadata,
suppress_live_tool_calls: false,
execution_kind: "scheduled_task",
original_topic_id: original_topic_id.clone(),
},
)
.await?;
// 清理内存历史,释放内存(数据库历史保留)
{
@ -661,7 +543,11 @@ mod tests {
let _guard1 = lock.lock().await;
// 第二次获取应阻塞1ms 超时验证
let result = tokio::time::timeout(std::time::Duration::from_millis(1), lock.lock()).await;
let result = tokio::time::timeout(
std::time::Duration::from_millis(1),
lock.lock(),
)
.await;
assert!(result.is_err(), "第二次获取同一锁应阻塞");
}
@ -675,8 +561,11 @@ mod tests {
let _guard_a = lock_a.lock().await;
// 不同锁应立即可获取
let result =
tokio::time::timeout(std::time::Duration::from_millis(100), lock_b.lock()).await;
let result = tokio::time::timeout(
std::time::Duration::from_millis(100),
lock_b.lock(),
)
.await;
assert!(result.is_ok(), "不同 topic 的锁应互不影响");
}
@ -696,7 +585,11 @@ mod tests {
}
// 锁应已释放,可再次获取
let result = tokio::time::timeout(std::time::Duration::from_millis(100), lock.lock()).await;
let result = tokio::time::timeout(
std::time::Duration::from_millis(100),
lock.lock(),
)
.await;
assert!(result.is_ok(), "错误返回后锁应已释放");
}

View File

@ -1,8 +1,5 @@
use axum::{Json, extract::{Query, State}};
use axum::http::StatusCode;
use axum::{
Json,
extract::{Query, State},
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
@ -72,13 +69,6 @@ fn mask_config(config: &Config) -> Config {
}
}
}
// 掩码网关认证 token避免通过 /api/config 泄露)
if let Some(ref token) = masked.gateway.auth_token {
if !token.is_empty() {
let visible: String = token.chars().take(4).collect();
masked.gateway.auth_token = Some(format!("{}{}", visible, API_KEY_MASK));
}
}
masked
}
@ -100,7 +90,9 @@ pub struct SaveConfigResponse {
}
/// GET /api/config — Return current config with masked sensitive fields
pub async fn get_config(State(state): State<Arc<GatewayState>>) -> Json<Config> {
pub async fn get_config(
State(state): State<Arc<GatewayState>>,
) -> Json<Config> {
Json(mask_config(&*state.config.read().await))
}
@ -133,12 +125,6 @@ pub async fn save_config(
}
}
}
// 保留原始 auth_token若提交的是掩码值
if let Some(ref submitted) = new_config.gateway.auth_token {
if is_masked_key(submitted) {
new_config.gateway.auth_token = cfg.gateway.auth_token.clone();
}
}
} // read lock released here
// Validate timezone
@ -152,19 +138,11 @@ pub async fn save_config(
.unwrap_or_else(|_| get_default_config_path());
// Serialize and write to disk (no lock held)
let json = serde_json::to_string_pretty(&new_config).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Serialize error: {}", e),
)
})?;
let json = serde_json::to_string_pretty(&new_config)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Serialize error: {}", e)))?;
std::fs::write(&config_path, &json).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Write error: {}", e),
)
})?;
std::fs::write(&config_path, &json)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Write error: {}", e)))?;
// Update in-memory config (write lock, held only for assignment)
{
@ -225,21 +203,6 @@ pub async fn restart(
}))
}
#[derive(Serialize)]
pub struct ExecutionsResponse {
/// 当前正在执行的 Agent 的 topic_id 列表
pub topic_ids: Vec<String>,
}
/// GET /api/executions — 返回当前正在执行的 Agent 的 topic_id 列表
///
/// 供前端重连时对账执行状态:前端据此判断断连期间哪些话题的
/// 智能体仍在运行(需保持禁用)、哪些已完成(应解锁)。
pub async fn list_executions(State(state): State<Arc<GatewayState>>) -> Json<ExecutionsResponse> {
let topic_ids = state.cancel_manager.list_active_topic_ids().await;
Json(ExecutionsResponse { topic_ids })
}
/// GET /api/mcp/status — Return MCP server connection status
pub async fn mcp_status(
State(state): State<Arc<GatewayState>>,
@ -263,7 +226,9 @@ pub async fn mcp_status(
}
/// GET /api/skills — Return all discovered skills with their disabled status
pub async fn skills_list(State(state): State<Arc<GatewayState>>) -> Json<SkillListResponse> {
pub async fn skills_list(
State(state): State<Arc<GatewayState>>,
) -> Json<SkillListResponse> {
let skills_enabled = state.config.read().await.skills.enabled;
if !skills_enabled {
@ -316,7 +281,9 @@ pub struct CurrentModel {
/// GET /api/tools — Return all registered tools (builtin + MCP) with name/description/source.
/// 通过 SessionManager::tools() 只读访问 ToolRegistry不修改状态。
pub async fn tools_list(State(state): State<Arc<GatewayState>>) -> Json<ToolsListResponse> {
pub async fn tools_list(
State(state): State<Arc<GatewayState>>,
) -> Json<ToolsListResponse> {
let registry = state.session_manager.tools();
let tools: Vec<ToolInfo> = registry
.get_definitions()
@ -345,7 +312,9 @@ pub async fn tools_list(State(state): State<Arc<GatewayState>>) -> Json<ToolsLis
}
/// GET /api/model-options — 返回 config.json 中配置的 provider/model 名列表。
pub async fn model_options(State(state): State<Arc<GatewayState>>) -> Json<ModelOptionsResponse> {
pub async fn model_options(
State(state): State<Arc<GatewayState>>,
) -> Json<ModelOptionsResponse> {
let config = state.config.read().await;
let resolver = crate::config::ModelResolver::from_config(&config);
// 当前默认 agent 的 provider/model 名(直接引用 providers/models 表的 key
@ -402,11 +371,7 @@ pub async fn skills_toggle(
changed: Some(change.changed),
available: Some(change.available),
disabled_in_scopes: Some(
change
.disabled_in_scopes
.iter()
.map(|s| s.as_str().to_string())
.collect(),
change.disabled_in_scopes.iter().map(|s| s.as_str().to_string()).collect(),
),
error: None,
}),
@ -459,7 +424,9 @@ pub struct SubagentListResponse {
}
/// GET /api/subagents — Return all discovered subagents with their disabled status
pub async fn subagents_list(State(state): State<Arc<GatewayState>>) -> Json<SubagentListResponse> {
pub async fn subagents_list(
State(state): State<Arc<GatewayState>>,
) -> Json<SubagentListResponse> {
let subagents_enabled = state.config.read().await.subagents.enabled;
if !subagents_enabled {
@ -606,7 +573,6 @@ pub async fn subagents_update(
capability: updated.capability.clone(),
provider: updated.provider.clone(),
model: updated.model.clone(),
body: updated.body.clone(),
});
Ok(Json(SubagentUpdateResponse {
@ -616,124 +582,6 @@ pub async fn subagents_update(
}))
}
#[derive(Deserialize)]
pub struct SubagentCreateRequest {
pub name: String,
pub description: String,
#[serde(default)]
pub body: String,
pub scope: String,
#[serde(default)]
pub capability: CapabilityPolicy,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub model: Option<String>,
}
#[derive(Deserialize)]
pub struct SubagentDeleteRequest {
pub name: String,
}
#[derive(Serialize)]
pub struct SubagentDeleteResponse {
pub success: bool,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// POST /api/subagents/create — Create a new subagent (writes SUBAGENT.md)
pub async fn subagents_create(
State(state): State<Arc<GatewayState>>,
Json(req): Json<SubagentCreateRequest>,
) -> Result<Json<SubagentUpdateResponse>, (StatusCode, String)> {
let scope = SubagentScope::parse(&req.scope).ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
format!("invalid scope: {}", req.scope),
)
})?;
let created = state
.subagent_runtime
.create_subagent(
scope,
&req.name,
&req.description,
&req.body,
&req.capability,
&req.provider,
&req.model,
true,
)
.map_err(|err| {
let status = if err.contains("already exists") {
StatusCode::CONFLICT
} else {
StatusCode::BAD_REQUEST
};
(status, err)
})?;
// 返回创建后的状态(含 disabled_in_scopes
let status = state
.subagent_runtime
.list_with_status()
.into_iter()
.find(|s| s.name == created.name)
.unwrap_or_else(|| SubagentWithStatus {
name: created.name.clone(),
description: created.description.clone(),
source: created.source.as_str().to_string(),
disabled_in_scopes: vec![],
capability: created.capability.clone(),
provider: created.provider.clone(),
model: created.model.clone(),
body: created.body.clone(),
});
Ok(Json(SubagentUpdateResponse {
success: true,
subagent: Some(status),
error: None,
}))
}
/// DELETE /api/subagents/delete?name= — Delete a subagent (removes SUBAGENT.md)
pub async fn subagents_delete(
State(state): State<Arc<GatewayState>>,
Query(req): Query<SubagentDeleteRequest>,
) -> Result<Json<SubagentDeleteResponse>, (StatusCode, Json<SubagentDeleteResponse>)> {
let path = state
.subagent_runtime
.delete_subagent(&req.name, true)
.map_err(|err| {
let status = if err.contains("not found") {
StatusCode::NOT_FOUND
} else if err.contains("builtin") {
StatusCode::BAD_REQUEST
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
(
status,
Json(SubagentDeleteResponse {
success: false,
path: String::new(),
error: Some(err),
}),
)
})?;
Ok(Json(SubagentDeleteResponse {
success: true,
path: path.display().to_string(),
error: None,
}))
}
// ===================== Experts =====================
#[derive(Deserialize)]
@ -877,7 +725,9 @@ pub struct ExpertDeleteResponse {
}
/// GET /api/experts — Return all discovered experts with their disabled status
pub async fn experts_list(State(state): State<Arc<GatewayState>>) -> Json<ExpertListResponse> {
pub async fn experts_list(
State(state): State<Arc<GatewayState>>,
) -> Json<ExpertListResponse> {
let experts_enabled = state.config.read().await.experts.enabled;
if !experts_enabled {
@ -967,12 +817,8 @@ pub async fn experts_create(
State(state): State<Arc<GatewayState>>,
Json(req): Json<ExpertCreateRequest>,
) -> Result<Json<ExpertResponse>, (StatusCode, String)> {
let scope = ExpertScope::parse(&req.scope).ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
format!("invalid scope: {}", req.scope),
)
})?;
let scope = ExpertScope::parse(&req.scope)
.ok_or_else(|| (StatusCode::BAD_REQUEST, format!("invalid scope: {}", req.scope)))?;
let expert = state
.experts
@ -1003,12 +849,8 @@ pub async fn experts_update(
State(state): State<Arc<GatewayState>>,
Json(req): Json<ExpertUpdateRequest>,
) -> Result<Json<ExpertResponse>, (StatusCode, String)> {
let scope = ExpertScope::parse(&req.scope).ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
format!("invalid scope: {}", req.scope),
)
})?;
let scope = ExpertScope::parse(&req.scope)
.ok_or_else(|| (StatusCode::BAD_REQUEST, format!("invalid scope: {}", req.scope)))?;
let expert = state
.experts
@ -1169,7 +1011,9 @@ pub async fn session_select_model(
}
drop(config);
state.model_selections.set(&req.session_id, provider, model);
state
.model_selections
.set(&req.session_id, provider, model);
(
StatusCode::OK,
Json(SelectModelResponse {

View File

@ -26,7 +26,7 @@ pub(crate) struct MemoryMaintenanceCandidate {
pub(crate) namespace: String,
pub(crate) key: String,
pub(crate) content: String,
pub(crate) updated_at: i64, // 记忆更新时间Unix timestamp
pub(crate) updated_at: i64, // 记忆更新时间Unix timestamp
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
@ -227,8 +227,8 @@ impl MemoryMaintenanceService {
Ok(parsed) => return Ok(parsed),
Err(err) => {
let error_msg = err.to_string();
let is_truncated =
error_msg.contains("EOF while parsing") || error_msg.contains("expected");
let is_truncated = error_msg.contains("EOF while parsing")
|| error_msg.contains("expected");
let should_retry = delay_ms.is_some() && is_truncated;
last_error = Some(error_msg.clone());
@ -369,10 +369,9 @@ impl MemoryMaintenanceService {
pub(crate) async fn run_for_all_scopes(
&self,
) -> Result<Option<MemoryMaintenanceScopeResult>, AgentError> {
let scope_keys = self
.store
.list_memory_scope_keys("user")
.map_err(|err| AgentError::Other(format!("list memory scope keys error: {}", err)))?;
let scope_keys = self.store.list_memory_scope_keys("user").map_err(|err| {
AgentError::Other(format!("list memory scope keys error: {}", err))
})?;
if scope_keys.is_empty() {
return Ok(None);
@ -419,8 +418,7 @@ impl MemoryMaintenanceService {
let managed_markdown = if all_remaining_memories.is_empty() {
String::new()
} else {
self.generate_summary("all", &all_remaining_memories)
.await?
self.generate_summary("all", &all_remaining_memories).await?
};
if !managed_markdown.is_empty() {
@ -680,29 +678,24 @@ pub(crate) fn validate_memory_maintenance_output(
}
// 验证 2: 跨 namespace 合并检测(完全禁止)
let candidates_by_id: HashMap<&str, &MemoryMaintenanceCandidate> =
plan.candidates.iter().map(|c| (c.id.as_str(), c)).collect();
let candidates_by_id: HashMap<&str, &MemoryMaintenanceCandidate> = plan
.candidates
.iter()
.map(|c| (c.id.as_str(), c))
.collect();
for merge in &output.merges {
let source_namespaces: HashSet<&str> = merge
.source_ids
.iter()
.filter_map(|id| {
candidates_by_id
.get(id.as_str())
.map(|c| c.namespace.as_str())
})
.filter_map(|id| candidates_by_id.get(id.as_str()).map(|c| c.namespace.as_str()))
.collect();
// 检查是否跨越多个 namespace
if source_namespaces.len() > 1 {
return Err(format!(
"跨 namespace 合并被禁止: 源来自 {}",
source_namespaces
.iter()
.cloned()
.collect::<Vec<_>>()
.join(", ")
source_namespaces.iter().cloned().collect::<Vec<_>>().join(", ")
));
}
@ -725,7 +718,11 @@ pub(crate) fn validate_memory_maintenance_output(
.map(|s| s.as_str())
.collect();
let deleted_ids: HashSet<&str> = output.low_value_ids.iter().map(|s| s.as_str()).collect();
let deleted_ids: HashSet<&str> = output
.low_value_ids
.iter()
.map(|s| s.as_str())
.collect();
let affected = merged_ids.len() + deleted_ids.len();
let max_allowed = (total as f32 * max_merge_ratio).ceil() as usize;
@ -761,14 +758,8 @@ pub(crate) fn apply_memory_maintenance_output(
max_merge_per_group: usize,
) -> Result<(), AgentError> {
// 新增: 验证合并输出
validate_memory_maintenance_output(
plan,
output,
max_merge_ratio,
min_memories_to_keep,
max_merge_per_group,
)
.map_err(|e| AgentError::Other(e))?;
validate_memory_maintenance_output(plan, output, max_merge_ratio, min_memories_to_keep, max_merge_per_group)
.map_err(|e| AgentError::Other(e))?;
let all_candidates = plan.candidates.clone();

View File

@ -1,7 +1,6 @@
pub mod agent_factory;
pub mod agent_prompt_provider;
pub mod agent_task_executor;
pub mod auth;
pub mod cancel_manager;
pub mod cli_session;
pub mod command;
@ -26,16 +25,15 @@ pub mod session_message_sender;
pub mod session_message_service;
pub mod session_pool;
pub mod static_files;
pub mod tool_prompt_provider;
pub mod tool_registry_factory;
pub mod tool_prompt_provider;
pub mod ws;
use axum::{Router, middleware, routing};
use axum::{Router, routing};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::net::TcpSocket;
use tokio::sync::Semaphore;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir;
use crate::bus::MessageBus;
@ -52,11 +50,11 @@ use cancel_manager::CancelManager;
use outbound_dispatcher::OutboundDispatcher;
use processor::InboundProcessor;
use runtime::build_session_manager_with_sender;
use session::SessionManager;
use session_message_sender::BusSessionMessageSender;
use session::SessionManager;
use static_files::static_handler;
use tokio::sync::{RwLock, watch};
use tokio::sync::{watch, RwLock};
pub struct GatewayState {
pub config: Arc<RwLock<Config>>,
@ -75,10 +73,7 @@ pub struct GatewayState {
}
impl GatewayState {
pub fn from_config(
config: Config,
restart_tx: watch::Sender<bool>,
) -> Result<Self, Box<dyn std::error::Error>> {
pub fn from_config(config: Config, restart_tx: watch::Sender<bool>) -> Result<Self, Box<dyn std::error::Error>> {
// Get provider config for SessionManager
let provider_config = config.get_provider_config("default")?;
let mut provider_configs = HashMap::<String, LLMProviderConfig>::new();
@ -92,9 +87,7 @@ impl GatewayState {
let session_ttl_hours = config.gateway.session_ttl_hours;
let skills = Arc::new(SkillRuntime::from_config(config.skills.clone()));
let experts = Arc::new(crate::experts::ExpertRuntime::from_config(
config.experts.clone(),
));
let experts = Arc::new(crate::experts::ExpertRuntime::from_config(config.experts.clone()));
let channel_manager = ChannelManager::new();
let bus = channel_manager.bus();
@ -102,26 +95,24 @@ impl GatewayState {
mcp_servers: config.mcp_servers.clone(),
};
let (session_manager, task_repository, mcp_manager, subagent_runtime, model_selections) =
build_session_manager_with_sender(
agent_prompt_reinject_every,
show_tool_results,
config.time.timezone.clone(),
provider_config,
provider_configs,
skills.clone(),
experts.clone(),
Arc::new(BusSessionMessageSender::new(bus.clone())),
std::collections::HashSet::new(),
config.tools.task.clone(),
config.subagents.clone(),
config.memory_maintenance.clone(),
session_ttl_hours,
mcp_config,
Some(bus.clone()),
Arc::new(crate::config::ModelResolver::from_config(&config)),
config.compaction.clone(),
)?;
let (session_manager, task_repository, mcp_manager, subagent_runtime, model_selections) = build_session_manager_with_sender(
agent_prompt_reinject_every,
show_tool_results,
config.time.timezone.clone(),
provider_config,
provider_configs,
skills.clone(),
experts.clone(),
Arc::new(BusSessionMessageSender::new(bus.clone())),
std::collections::HashSet::new(),
config.tools.task.clone(),
config.subagents.clone(),
config.memory_maintenance.clone(),
session_ttl_hours,
mcp_config,
Some(bus.clone()),
Arc::new(crate::config::ModelResolver::from_config(&config)),
)?;
// 诊断日志:记录新 GatewayState 的创建(用于排查重启后是否使用了新状态)
tracing::info!(
@ -164,13 +155,8 @@ impl GatewayState {
drop(cfg); // release read lock before spawning long-running tasks
let semaphore = Arc::new(Semaphore::new(max_concurrent));
let inbound_processor = InboundProcessor::new(
self.bus.clone(),
self.session_manager.clone(),
semaphore,
provider_config,
self.cancel_manager.clone(),
);
let inbound_processor =
InboundProcessor::new(self.bus.clone(), self.session_manager.clone(), semaphore, provider_config, self.cancel_manager.clone());
tokio::spawn(inbound_processor.run());
// Spawn outbound dispatcher
@ -241,132 +227,70 @@ pub async fn run(
}
// CLI args override config file values
let (bind_host, bind_port, auth_token) = {
let (bind_host, bind_port) = {
let cfg = state.config.read().await;
let h = host.unwrap_or_else(|| cfg.gateway.host.clone());
let p = port.unwrap_or(cfg.gateway.port);
(h, p, cfg.gateway.auth_token.clone())
(h, p)
};
// 安全校验:绑定到非 loopback 地址时必须配置 auth_token
if !auth::is_loopback_host(&bind_host) && auth_token.is_none() {
return Err(format!(
"Gateway is bound to non-loopback address '{}' but no `gateway.auth_token` is configured. \
Remote access requires authentication. \
Please set `auth_token` in the `gateway` section of config.json.",
bind_host
)
.into());
}
let auth_required = auth::requires_auth(&bind_host, &auth_token);
let auth_config = auth::AuthConfig {
token: auth_token.clone(),
};
if auth_required {
tracing::info!(
host = %bind_host,
has_token = auth_token.is_some(),
"Authentication enabled for gateway"
);
} else {
tracing::info!(
host = %bind_host,
"Authentication disabled (loopback binding without explicit token)"
);
}
// 使用嵌入的静态文件(编译时打包进二进制)
// 开发模式下可通过 STATIC_DIR 环境变量使用磁盘文件
let use_embedded = std::env::var("STATIC_DIR").is_err();
// 公共路由:生产/开发两种模式共享,避免重复注册导致漏配。
// 仅 fallback嵌入 vs 磁盘)与 state 绑定按模式区分。
let app = Router::new()
.route("/health", routing::get(http::health))
.route(
"/api/config",
routing::get(http::get_config).put(http::save_config),
)
.route("/api/restart", routing::post(http::restart))
.route("/api/executions", routing::get(http::list_executions))
.route("/api/mcp/status", routing::get(http::mcp_status))
.route("/api/skills", routing::get(http::skills_list))
.route("/api/skills/toggle", routing::post(http::skills_toggle))
.route("/api/tools", routing::get(http::tools_list))
.route("/api/model-options", routing::get(http::model_options))
.route("/api/subagents", routing::get(http::subagents_list))
.route(
"/api/subagents/toggle",
routing::post(http::subagents_toggle),
)
.route(
"/api/subagents/update",
routing::put(http::subagents_update),
)
.route(
"/api/subagents/create",
routing::post(http::subagents_create),
)
.route(
"/api/subagents/delete",
routing::delete(http::subagents_delete),
)
.route("/api/experts", routing::get(http::experts_list))
.route("/api/experts/toggle", routing::post(http::experts_toggle))
.route("/api/experts/create", routing::post(http::experts_create))
.route("/api/experts/update", routing::put(http::experts_update))
.route("/api/experts/delete", routing::delete(http::experts_delete))
.route(
"/api/experts/selected",
routing::get(http::experts_selected),
)
.route("/api/experts/select", routing::post(http::experts_select))
.route(
"/api/session/select-model",
routing::post(http::session_select_model),
)
.route(
"/api/session/selected-model",
routing::get(http::session_selected_model),
)
.route("/ws", routing::get(ws::ws_handler));
// 仅 fallback 按模式区分:嵌入资源 vs 磁盘目录。
// fallback 必须在 with_state 之前调用,否则 handler 的 State 类型无法推断。
let app = if use_embedded {
app.fallback(static_handler)
Router::new()
.route("/health", routing::get(http::health))
.route("/api/config", routing::get(http::get_config).put(http::save_config))
.route("/api/restart", routing::post(http::restart))
.route("/api/mcp/status", routing::get(http::mcp_status))
.route("/api/skills", routing::get(http::skills_list))
.route("/api/skills/toggle", routing::post(http::skills_toggle))
.route("/api/tools", routing::get(http::tools_list))
.route("/api/model-options", routing::get(http::model_options))
.route("/api/subagents", routing::get(http::subagents_list))
.route("/api/subagents/toggle", routing::post(http::subagents_toggle))
.route("/api/subagents/update", routing::put(http::subagents_update))
.route("/api/experts", routing::get(http::experts_list))
.route("/api/experts/toggle", routing::post(http::experts_toggle))
.route("/api/experts/create", routing::post(http::experts_create))
.route("/api/experts/update", routing::put(http::experts_update))
.route("/api/experts/delete", routing::delete(http::experts_delete))
.route("/api/experts/selected", routing::get(http::experts_selected))
.route("/api/experts/select", routing::post(http::experts_select))
.route("/api/session/select-model", routing::post(http::session_select_model))
.route("/api/session/selected-model", routing::get(http::session_selected_model))
.route("/ws", routing::get(ws::ws_handler))
.fallback(static_handler)
.with_state(state.clone())
} else {
let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "static".to_string());
app.fallback_service(ServeDir::new(&static_dir))
}
.with_state(state.clone());
// 条件性挂载认证中间件:仅在需要认证时启用。
// 中间件内部按 path 前缀判断,仅 /api/* 需要校验;
// /health、/wsWS 在 handler 内单独校验)和静态资源放行。
let app = if auth_required {
app.layer(axum::Extension(auth_config))
.layer(middleware::from_fn(auth::require_bearer_auth))
} else {
app
Router::new()
.route("/health", routing::get(http::health))
.route("/api/config", routing::get(http::get_config).put(http::save_config))
.route("/api/restart", routing::post(http::restart))
.route("/api/mcp/status", routing::get(http::mcp_status))
.route("/api/skills", routing::get(http::skills_list))
.route("/api/skills/toggle", routing::post(http::skills_toggle))
.route("/api/tools", routing::get(http::tools_list))
.route("/api/model-options", routing::get(http::model_options))
.route("/api/subagents", routing::get(http::subagents_list))
.route("/api/subagents/toggle", routing::post(http::subagents_toggle))
.route("/api/subagents/update", routing::put(http::subagents_update))
.route("/api/experts", routing::get(http::experts_list))
.route("/api/experts/toggle", routing::post(http::experts_toggle))
.route("/api/experts/create", routing::post(http::experts_create))
.route("/api/experts/update", routing::put(http::experts_update))
.route("/api/experts/delete", routing::delete(http::experts_delete))
.route("/api/experts/selected", routing::get(http::experts_selected))
.route("/api/experts/select", routing::post(http::experts_select))
.route("/api/session/select-model", routing::post(http::session_select_model))
.route("/api/session/selected-model", routing::get(http::session_selected_model))
.route("/ws", routing::get(ws::ws_handler))
.fallback_service(ServeDir::new(&static_dir))
.with_state(state.clone())
};
// CORSloopback 下宽松(仅同源);非 loopback 下允许任意来源(由 auth_token 保护)。
// 不论哪种情况都显式设置以避免浏览器默认行为差异。
let cors = if auth::is_loopback_host(&bind_host) {
// 本地开发:同源即可,阻止跨域(防 DNS rebinding
CorsLayer::new()
.allow_origin(tower_http::cors::AllowOrigin::mirror_request())
.allow_methods(Any)
.allow_headers(Any)
} else {
// 远程访问:允许跨域,但由 token 保护
CorsLayer::permissive()
};
let app = app.layer(cors);
let addr: std::net::SocketAddr = format!("{}:{}", bind_host, bind_port).parse()?;
let listener = {
let socket = match addr {

View File

@ -1,5 +1,5 @@
use std::collections::HashMap;
use parking_lot::RwLock;
use std::sync::RwLock;
/// per-session 的用户模型覆盖选择存储。
///
@ -16,10 +16,16 @@ impl ModelSelectionStore {
}
/// 设置 session 的用户模型覆盖。provider 和 model 均为 None 时清除该 session 的选择。
pub fn set(&self, session_id: &str, provider: Option<String>, model: Option<String>) {
pub fn set(
&self,
session_id: &str,
provider: Option<String>,
model: Option<String>,
) {
let mut selections = self
.selections
.write();
.write()
.expect("model selections rwlock poisoned");
if provider.is_none() && model.is_none() {
selections.remove(session_id);
} else {
@ -31,6 +37,7 @@ impl ModelSelectionStore {
pub fn get(&self, session_id: &str) -> Option<(Option<String>, Option<String>)> {
self.selections
.read()
.expect("model selections rwlock poisoned")
.get(session_id)
.cloned()
}
@ -69,6 +76,9 @@ mod tests {
fn set_only_provider_keeps_entry() {
let store = ModelSelectionStore::new();
store.set("s1", Some("p1".to_string()), None);
assert_eq!(store.get("s1"), Some((Some("p1".to_string()), None)));
assert_eq!(
store.get("s1"),
Some((Some("p1".to_string()), None))
);
}
}

View File

@ -1,69 +1,20 @@
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{RwLock, mpsc};
use tokio_util::sync::CancellationToken;
use tokio::sync::RwLock;
use crate::bus::message::OutboundEventKind;
use crate::bus::{MessageBus, OutboundMessage};
use crate::channels::base::{Channel, ChannelError};
/// 低优先级队列容量ToolCall / ToolResult / StreamDelta 等中间过程事件)。
///
/// 略小于 MessageBus 的容量,确保 bus 的 `try_send` 丢消息防线仍有效——
/// channel 队列满时 dispatcher 立即丢弃该消息,不阻塞路由循环影响其他 channel。
const LOW_PRIORITY_QUEUE_CAPACITY: usize = 64;
/// 高优先级队列容量(仅 AssistantResponse 最终响应)。
///
/// 单次 agent 执行仅产生 1 条最终响应32 足够缓冲多 topic 并发的最终响应,
/// 极罕见满。最终响应是 agent 与用户的契约,必达。
const HIGH_PRIORITY_QUEUE_CAPACITY: usize = 32;
/// 低优先级事件的发送重试间隔(秒)。
const RETRY_DELAYS_SECS: [u64; 3] = [1, 2, 4];
/// 高优先级事件(最终响应)的发送重试间隔(秒)——更多次、更长退避,
/// 尽最大努力送达最终响应。
const EXTENDED_RETRY_DELAYS_SECS: [u64; 5] = [1, 2, 4, 8, 16];
/// Consumes outbound messages from MessageBus and dispatches them to channels.
pub struct OutboundDispatcher {
bus: Arc<MessageBus>,
channels: Arc<RwLock<HashMap<String, Arc<dyn Channel + Send + Sync>>>>,
}
/// Prefix for virtual scheduler chat IDs that should not be sent to external channels.
const SCHEDULER_VIRTUAL_CHAT_ID_PREFIX: &str = "scheduler/";
/// 判断消息是否为高优先级(必达)。
///
/// `AssistantResponse`(最终响应)和 `ErrorNotification`agent 异常终止的错误通知)
/// 都是 agent 与用户的终态契约,必须送达;其他事件(工具调用进度、流式增量、
/// 执行完成信号等)是中间过程,可丢。
fn is_high_priority(msg: &OutboundMessage) -> bool {
matches!(
msg.event_kind,
OutboundEventKind::AssistantResponse | OutboundEventKind::ErrorNotification
)
}
/// 单个 channel 的发送上下文:双优先级 mpsc 队列 + sender task。
///
/// dispatcher 按消息优先级 `try_send` 到 `high_tx`(最终响应,必达)或
/// `low_tx`(中间过程,可丢);`sender_task` 优先消费 high 队列,再消费
/// low 队列,串行调用 `Channel::send`含重试。channel 之间完全隔离——
/// 某个 channel 的慢发送或重试 sleep 不会阻塞其他 channel 的消息投递。
#[derive(Clone)]
struct ChannelSink {
high_tx: mpsc::Sender<OutboundMessage>,
low_tx: mpsc::Sender<OutboundMessage>,
cancel: CancellationToken,
}
/// Consumes outbound messages from MessageBus and dispatches them to channels.
///
/// 架构dispatcher 主循环只负责路由O(1) try_send不参与发送。
/// 每个 channel 拥有独立的 sender task 和有界队列,实现 channel 级隔离。
pub struct OutboundDispatcher {
bus: Arc<MessageBus>,
channels: Arc<RwLock<HashMap<String, ChannelSink>>>,
}
impl OutboundDispatcher {
pub fn new(bus: Arc<MessageBus>) -> Self {
Self {
@ -72,145 +23,11 @@ impl OutboundDispatcher {
}
}
/// 注册 channel 并启动其独立 sender task。
///
/// sender task 生命周期与 dispatcher 一致dispatcher `run()` 退出时
/// 通过 cancel token 终止所有 sender task。
pub async fn register_channel(&self, name: &str, channel: Arc<dyn Channel + Send + Sync>) {
let (high_tx, high_rx) =
mpsc::channel::<OutboundMessage>(HIGH_PRIORITY_QUEUE_CAPACITY);
let (low_tx, low_rx) =
mpsc::channel::<OutboundMessage>(LOW_PRIORITY_QUEUE_CAPACITY);
let cancel = CancellationToken::new();
let channel_name = name.to_string();
let cancel_for_task = cancel.clone();
tokio::spawn(async move {
Self::run_sender_task(
&channel_name,
channel,
high_rx,
low_rx,
cancel_for_task,
)
.await;
});
self.channels
.write()
.await
.insert(
name.to_string(),
ChannelSink {
high_tx,
low_tx,
cancel,
},
);
}
/// sender task优先消费 high 队列(最终响应),再消费 low 队列(中间过程),
/// 串行调用 `Channel::send` 并重试。
///
/// 重试 sleep 只阻塞当前 channel 的 task不影响其他 channel。
async fn run_sender_task(
channel_name: &str,
channel: Arc<dyn Channel + Send + Sync>,
mut high_rx: mpsc::Receiver<OutboundMessage>,
mut low_rx: mpsc::Receiver<OutboundMessage>,
cancel: CancellationToken,
) {
tracing::info!(channel = %channel_name, "Channel sender task started");
loop {
// 优先消费 high 队列:保证最终响应优先发送,不被中间过程阻塞。
// try_recv 非阻塞,有则立即处理,无则进入 select! 等待。
if let Ok(msg) = high_rx.try_recv() {
Self::send_one(&*channel, channel_name, msg).await;
continue;
}
tokio::select! {
// high 优先:一旦有最终响应立即处理
msg = high_rx.recv() => {
match msg {
Some(msg) => Self::send_one(&*channel, channel_name, msg).await,
None => {
// high 关闭:仅消费 low 残留后退出
tracing::debug!(channel = %channel_name, "High-priority queue closed, draining low queue");
Self::drain_low(&*channel, channel_name, &mut low_rx).await;
break;
}
}
}
// lowhigh 空时消费中间过程
msg = low_rx.recv() => {
match msg {
Some(msg) => Self::send_one(&*channel, channel_name, msg).await,
None => {
// low 关闭:仅消费 high 残留后退出
tracing::debug!(channel = %channel_name, "Low-priority queue closed, draining high queue");
Self::drain_high(&*channel, channel_name, &mut high_rx).await;
break;
}
}
}
// dispatcher 退出时取消所有 sender task
_ = cancel.cancelled() => {
tracing::info!(channel = %channel_name, "Sender task cancelled, stopping");
break;
}
}
}
}
/// 发送单条消息,处理重试结果日志。
async fn send_one(
channel: &dyn Channel,
channel_name: &str,
msg: OutboundMessage,
) {
match Self::send_with_retry(channel, msg).await {
Ok(()) => {}
Err(ChannelError::ChannelFull) => {
// 队列满是不可重试的——send_with_retry 已跳过重试。
// 记 warn 而非 error这是预期的背压丢弃。
tracing::warn!(
channel = %channel_name,
"Message dropped: channel queue full"
);
}
Err(error) => {
tracing::error!(
channel = %channel_name,
error = %error,
"Failed to send message after retries"
);
}
}
}
/// 排空 high 队列残留消息后返回。
async fn drain_high(
channel: &dyn Channel,
channel_name: &str,
high_rx: &mut mpsc::Receiver<OutboundMessage>,
) {
while let Ok(msg) = high_rx.try_recv() {
Self::send_one(channel, channel_name, msg).await;
}
}
/// 排空 low 队列残留消息后返回。
async fn drain_low(
channel: &dyn Channel,
channel_name: &str,
low_rx: &mut mpsc::Receiver<OutboundMessage>,
) {
while let Ok(msg) = low_rx.try_recv() {
Self::send_one(channel, channel_name, msg).await;
}
.insert(name.to_string(), channel);
}
pub async fn run(&self) {
@ -224,7 +41,6 @@ impl OutboundDispatcher {
break;
}
};
#[cfg(debug_assertions)]
tracing::debug!(
channel = %msg.channel,
@ -236,7 +52,6 @@ impl OutboundDispatcher {
// Skip messages with virtual scheduler chat IDs (e.g., "scheduler/job_id")
// These are internal messages from SilentAgentTask that should not be sent externally
if msg.chat_id.starts_with(SCHEDULER_VIRTUAL_CHAT_ID_PREFIX) {
#[cfg(debug_assertions)]
tracing::debug!(
channel = %msg.channel,
chat_id = %msg.chat_id,
@ -246,35 +61,12 @@ impl OutboundDispatcher {
}
let channel_name = msg.channel.clone();
let sink = self.channels.read().await.get(&channel_name).cloned();
let channel = self.channels.read().await.get(&channel_name).cloned();
match sink {
Some(sink) => {
// try_send 保证 dispatcher 永不阻塞:队列满时立即丢弃该消息,
// 不影响其他 channel 的投递。与 bus.publish_outbound 策略一致。
// 高优先级(最终响应)投递到 high 队列——容量 32 且仅最终响应,
// 极罕见满;低优先级(中间过程)投递到 low 队列,满则丢弃。
let (queue, priority_label) = if is_high_priority(&msg) {
(&sink.high_tx, "high")
} else {
(&sink.low_tx, "low")
};
match queue.try_send(msg) {
Ok(()) => {}
Err(mpsc::error::TrySendError::Full(_)) => {
tracing::warn!(
channel = %channel_name,
priority = priority_label,
"Channel queue full, dropping message"
);
}
Err(mpsc::error::TrySendError::Closed(_)) => {
tracing::warn!(
channel = %channel_name,
priority = priority_label,
"Channel queue closed, dropping message"
);
}
match channel {
Some(ch) => {
if let Err(error) = self.send_with_retry(&*ch, msg).await {
tracing::error!(channel = %channel_name, error = %error, "Failed to send message after retries");
}
}
None => {
@ -282,44 +74,22 @@ impl OutboundDispatcher {
}
}
}
// 通知所有 sender task 退出
let sinks = self.channels.write().await;
for (name, sink) in sinks.iter() {
sink.cancel.cancel();
tracing::debug!(channel = %name, "Cancelled sender task");
}
}
/// 发送消息,失败时按重试间隔重试。
///
/// 高优先级消息(最终响应)使用 `EXTENDED_RETRY_DELAYS_SECS`5 次,最长 16 秒),
/// 尽最大努力送达;低优先级消息(中间过程)使用 `RETRY_DELAYS_SECS`3 次)。
///
/// `ChannelFull` 不可重试——队列满时重试只会浪费退避时间并阻塞
/// channel 队列消费。立即返回让 sender task 尽快处理下一条消息。
///
/// 仅在单个 channel 的 sender task 内执行——重试 sleep 只阻塞
/// 该 channel 的发送,不影响其他 channel。
async fn send_with_retry(
&self,
channel: &dyn Channel,
msg: OutboundMessage,
) -> Result<(), ChannelError> {
let delays: &[u64] = if is_high_priority(&msg) {
&EXTENDED_RETRY_DELAYS_SECS
} else {
&RETRY_DELAYS_SECS
};
for (attempt_index, delay) in delays.iter().enumerate() {
const DELAYS: [u64; 3] = [1, 2, 4];
for (attempt_index, delay) in DELAYS.iter().enumerate() {
match channel.send(msg.clone()).await {
Ok(()) => return Ok(()),
// 队列满:不可重试,立即返回
Err(ChannelError::ChannelFull) => return Err(ChannelError::ChannelFull),
Err(error) if attempt_index < delays.len() - 1 => {
Err(error) if attempt_index < DELAYS.len() - 1 => {
tracing::warn!(
attempt = attempt_index + 1,
delay = delay,
high_priority = is_high_priority(&msg),
error = %error,
"Send failed, retrying"
);
@ -329,573 +99,6 @@ impl OutboundDispatcher {
}
}
// 防御性兜底:正常情况下循环最后一次迭代会 return Err(error)。
// 若 delays 被改为空数组,循环体不执行,返回错误而非 panic。
Err(ChannelError::SendError(
"send_with_retry exhausted with no retry attempts configured".into(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bus::OutboundMessage;
use async_trait::async_trait;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
/// 测试用 channel记录所有收到的消息内容可配置人为延迟和失败。
struct TestChannel {
name: String,
received: Arc<AtomicU32>,
/// 按发送顺序记录每条成功发送消息的 content用于断言优先级顺序。
received_contents: Arc<std::sync::Mutex<Vec<String>>>,
delay_ms: u64,
fail_first_n: u32,
/// 始终返回 `ChannelFull`,用于验证 `send_with_retry` 不重试队列满错误。
always_full: bool,
call_count: Arc<AtomicU32>,
}
impl TestChannel {
fn new(name: &str) -> Self {
Self {
name: name.to_string(),
received: Arc::new(AtomicU32::new(0)),
received_contents: Arc::new(std::sync::Mutex::new(Vec::new())),
delay_ms: 0,
fail_first_n: 0,
always_full: false,
call_count: Arc::new(AtomicU32::new(0)),
}
}
fn with_delay(mut self, ms: u64) -> Self {
self.delay_ms = ms;
self
}
fn with_fail_first_n(mut self, n: u32) -> Self {
self.fail_first_n = n;
self
}
fn with_channel_full(mut self) -> Self {
self.always_full = true;
self
}
}
#[async_trait]
impl Channel for TestChannel {
fn name(&self) -> &str {
&self.name
}
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, msg: OutboundMessage) -> Result<(), ChannelError> {
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
// 队列满:不可重试错误,用于验证 send_with_retry 立即返回
if self.always_full {
return Err(ChannelError::ChannelFull);
}
if (count as u32) < self.fail_first_n {
return Err(ChannelError::SendError("simulated failure".to_string()));
}
if self.delay_ms > 0 {
tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
}
self.received.fetch_add(1, Ordering::SeqCst);
if let Ok(mut guard) = self.received_contents.lock() {
guard.push(msg.content.clone());
}
Ok(())
}
}
fn make_message(channel: &str, chat_id: &str, content: &str) -> OutboundMessage {
OutboundMessage::assistant(
channel,
chat_id,
None,
content,
None,
std::collections::HashMap::new(),
)
}
/// 构造低优先级消息ToolCall用于验证 low 队列的丢弃与优先级行为。
fn make_low_message(channel: &str, chat_id: &str, content: &str) -> OutboundMessage {
OutboundMessage::tool_call(
channel,
chat_id,
None,
"msg-id",
content,
serde_json::json!({}),
None,
std::collections::HashMap::new(),
)
}
/// 构造错误通知agent 异常终止),用于验证 ErrorNotification 走高优队列。
fn make_error_message(channel: &str, chat_id: &str, content: &str) -> OutboundMessage {
OutboundMessage::error_notification(
channel,
chat_id,
None,
content,
None,
std::collections::HashMap::new(),
)
}
#[test]
fn test_is_high_priority_classifies_terminal_events() {
// AssistantResponse 和 ErrorNotification 都是终态,必须走高优队列必达。
let assistant = make_message("c", "chat", "final");
let error = make_error_message("c", "chat", "agent failed");
let tool_call = make_low_message("c", "chat", "calling tool");
let tool_result = OutboundMessage::tool_result(
"c", "chat", None, "id", "tool", "result", None,
std::collections::HashMap::new(),
);
let exec_done = OutboundMessage::execution_completed(
"c", "chat", None,
std::collections::HashMap::new(),
);
assert!(is_high_priority(&assistant), "AssistantResponse should be high priority");
assert!(is_high_priority(&error), "ErrorNotification should be high priority");
assert!(!is_high_priority(&tool_call), "ToolCall should be low priority");
assert!(!is_high_priority(&tool_result), "ToolResult should be low priority");
assert!(!is_high_priority(&exec_done), "ExecutionCompleted should be low priority");
}
#[tokio::test]
async fn test_fast_channel_not_blocked_by_slow_channel() {
// 验证核心目标channel A 慢发送不应阻塞 channel B 的消息投递
let bus = MessageBus::new(16);
let dispatcher = OutboundDispatcher::new(bus.clone());
let slow = Arc::new(TestChannel::new("slow").with_delay(500));
let fast = Arc::new(TestChannel::new("fast"));
let slow_received = slow.received.clone();
let fast_received = fast.received.clone();
dispatcher.register_channel("slow", slow).await;
dispatcher.register_channel("fast", fast).await;
let dispatcher_handle = tokio::spawn(async move {
dispatcher.run().await;
});
// 先发一条 slow500ms 延迟),紧接着发一条 fast
bus.publish_outbound(make_message("slow", "chat-1", "slow-msg")).await.unwrap();
bus.publish_outbound(make_message("fast", "chat-2", "fast-msg")).await.unwrap();
// 等待 fast 消息被投递(远早于 slow 完成)
tokio::time::timeout(Duration::from_millis(200), async {
while fast_received.load(Ordering::SeqCst) == 0 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("fast channel should receive message within 200ms, but was blocked by slow channel");
// 等待 slow 消息完成
tokio::time::timeout(Duration::from_secs(2), async {
while slow_received.load(Ordering::SeqCst) == 0 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("slow channel should eventually receive message");
assert_eq!(fast_received.load(Ordering::SeqCst), 1);
assert_eq!(slow_received.load(Ordering::SeqCst), 1);
// 关闭 bus 让 dispatcher 退出
// dispatcher 持有 Arc<MessageBus> 克隆drop(bus) 不会关闭 bus。
// 直接 abort dispatcher 及其 sender task 即可清理。
dispatcher_handle.abort();
}
#[tokio::test]
async fn test_retry_does_not_block_other_channel() {
// 验证channel A 重试 sleep1+2=3秒期间channel B 正常投递
let bus = MessageBus::new(16);
let dispatcher = OutboundDispatcher::new(bus.clone());
let flaky = Arc::new(
TestChannel::new("flaky")
.with_fail_first_n(2) // 前 2 次失败,触发 1+2 秒重试
.with_delay(0),
);
let stable = Arc::new(TestChannel::new("stable"));
let flaky_received = flaky.received.clone();
let stable_received = stable.received.clone();
dispatcher.register_channel("flaky", flaky).await;
dispatcher.register_channel("stable", stable).await;
let dispatcher_handle = tokio::spawn(async move {
dispatcher.run().await;
});
// 先发 flaky会重试 3 秒),紧接着发 stable
bus.publish_outbound(make_message("flaky", "chat-1", "flaky-msg")).await.unwrap();
bus.publish_outbound(make_message("stable", "chat-2", "stable-msg")).await.unwrap();
// stable 应在 200ms 内收到,远早于 flaky 的 3 秒重试完成
tokio::time::timeout(Duration::from_millis(200), async {
while stable_received.load(Ordering::SeqCst) == 0 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("stable channel should not be blocked by flaky channel's retry sleep");
// 等待 flaky 重试成功(第 3 次尝试)
tokio::time::timeout(Duration::from_secs(5), async {
while flaky_received.load(Ordering::SeqCst) == 0 {
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.expect("flaky channel should eventually succeed after retries");
assert_eq!(stable_received.load(Ordering::SeqCst), 1);
assert_eq!(flaky_received.load(Ordering::SeqCst), 1);
// dispatcher 持有 Arc<MessageBus> 克隆drop(bus) 不会关闭 bus。
// 直接 abort dispatcher 及其 sender task 即可清理。
dispatcher_handle.abort();
}
#[tokio::test]
async fn test_scheduler_virtual_chat_id_skipped() {
// 验证scheduler/ 前缀的 chat_id 不被投递到任何 channel
let bus = MessageBus::new(16);
let dispatcher = OutboundDispatcher::new(bus.clone());
let channel = Arc::new(TestChannel::new("test"));
let received = channel.received.clone();
dispatcher.register_channel("test", channel).await;
let dispatcher_handle = tokio::spawn(async move {
dispatcher.run().await;
});
// scheduler 虚拟消息应被跳过
bus.publish_outbound(make_message("test", "scheduler/job-1", "internal"))
.await
.unwrap();
// 正常消息应被投递
bus.publish_outbound(make_message("test", "chat-1", "normal"))
.await
.unwrap();
tokio::time::timeout(Duration::from_secs(2), async {
while received.load(Ordering::SeqCst) == 0 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("normal message should be delivered");
// 只收到 1 条scheduler 虚拟消息被跳过)
assert_eq!(received.load(Ordering::SeqCst), 1);
// dispatcher 持有 Arc<MessageBus> 克隆drop(bus) 不会关闭 bus。
// 直接 abort dispatcher 及其 sender task 即可清理。
dispatcher_handle.abort();
}
#[tokio::test]
async fn test_unknown_channel_warns_and_continues() {
// 验证:未知 channel 的消息被跳过,不影响后续消息投递
let bus = MessageBus::new(16);
let dispatcher = OutboundDispatcher::new(bus.clone());
let channel = Arc::new(TestChannel::new("known"));
let received = channel.received.clone();
dispatcher.register_channel("known", channel).await;
let dispatcher_handle = tokio::spawn(async move {
dispatcher.run().await;
});
// 发往未知 channel 的消息
bus.publish_outbound(make_message("unknown", "chat-1", "lost"))
.await
.unwrap();
// 发往已知 channel 的消息
bus.publish_outbound(make_message("known", "chat-2", "delivered"))
.await
.unwrap();
tokio::time::timeout(Duration::from_secs(2), async {
while received.load(Ordering::SeqCst) == 0 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("known channel should receive message despite preceding unknown channel message");
assert_eq!(received.load(Ordering::SeqCst), 1);
// dispatcher 持有 Arc<MessageBus> 克隆drop(bus) 不会关闭 bus。
// 直接 abort dispatcher 及其 sender task 即可清理。
dispatcher_handle.abort();
}
#[tokio::test]
async fn test_send_with_retry_no_retry_on_channel_full() {
// 验证send_with_retry 遇到 ChannelFull 应立即返回,不重试。
// 若错误地重试,会 sleep 1+2+4=7 秒,测试将在超时阈值内失败。
let channel = TestChannel::new("full").with_channel_full();
let call_count = channel.call_count.clone();
let msg = make_message("full", "chat-1", "dropped");
// 500ms 阈值:远小于首次重试间隔 1s足以区分"立即返回"与"至少一次重试"
let result = tokio::time::timeout(Duration::from_millis(500), async {
OutboundDispatcher::send_with_retry(&channel, msg).await
})
.await
.expect("send_with_retry should return immediately on ChannelFull, not retry");
// 必须返回 ChannelFull 错误
assert!(
matches!(result, Err(ChannelError::ChannelFull)),
"expected ChannelFull error, got {:?}",
result
);
// send 只被调用一次——证明没有重试
assert_eq!(
call_count.load(Ordering::SeqCst),
1,
"send should be called exactly once when ChannelFull is returned"
);
}
#[tokio::test]
async fn test_high_priority_not_dropped_when_low_full() {
// 验证:low 队列被大量低优消息填满时,高优(AssistantResponse)仍被发送。
// 这是本次修复的核心目标——最终响应必达,不被中间过程挤占丢弃。
let bus = MessageBus::new(256);
let dispatcher = OutboundDispatcher::new(bus.clone());
// 慢 channel:50ms/条,确保 low 队列持续积压
let channel = Arc::new(TestChannel::new("test").with_delay(50));
let contents = channel.received_contents.clone();
dispatcher.register_channel("test", channel).await;
let dispatcher_handle = tokio::spawn(async move {
dispatcher.run().await;
});
// 投递大量低优消息(超过 low 容量 64),填满 low 队列
for i in 0..80 {
bus.publish_outbound(make_low_message("test", "chat-1", &format!("low-{i}")))
.await
.unwrap();
}
// 投递高优消息(最终响应)
bus.publish_outbound(make_message("test", "chat-1", "HIGH-FINAL"))
.await
.unwrap();
// 高优应在 3 秒内被发送(独立 high 队列 + sender 优先消费)
tokio::time::timeout(Duration::from_secs(3), async {
loop {
let sent = contents.lock().unwrap().clone();
if sent.iter().any(|c| c == "HIGH-FINAL") {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.expect("high priority message should be sent even when low queue is full");
dispatcher_handle.abort();
}
#[tokio::test]
async fn test_low_priority_dropped_when_full() {
// 验证:low 队列满时,后续低优消息被丢弃(不进入 high 队列)。
// 低优是中间过程,可丢——这是与高优必达的对比行为。
let bus = MessageBus::new(256);
let dispatcher = OutboundDispatcher::new(bus.clone());
// 慢 channel:50ms/条,确保 low 队列积压触发丢弃
let channel = Arc::new(TestChannel::new("test").with_delay(50));
let received = channel.received.clone();
dispatcher.register_channel("test", channel).await;
let dispatcher_handle = tokio::spawn(async move {
dispatcher.run().await;
});
// 投递远超 low 容量的低优消息(200 条),确保触发丢弃
let total_sent: u32 = 200;
for i in 0..total_sent {
bus.publish_outbound(make_low_message("test", "chat-1", &format!("low-{i}")))
.await
.unwrap();
}
// 等 sender 消费完 low 队列内的消息(至少 LOW_PRIORITY_QUEUE_CAPACITY 条)
tokio::time::timeout(Duration::from_secs(10), async {
while received.load(Ordering::SeqCst) < LOW_PRIORITY_QUEUE_CAPACITY as u32 {
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.expect("should receive at least LOW_PRIORITY_QUEUE_CAPACITY messages");
// 额外等待确认 sender 已消费完 low 队列残留(64 * 50ms ≈ 3.2s,给足 1s 余量)
tokio::time::sleep(Duration::from_secs(1)).await;
let final_received = received.load(Ordering::SeqCst);
// 断言:有丢弃发生(received < 投递数),且 low 队列曾被填满(received >= 容量)。
// 不断言精确数量——sender 与 dispatcher 的并发竞态会使进入 low 的条数略多于容量。
assert!(
final_received < total_sent,
"some low-priority messages should be dropped when queue full, got {}/{}",
final_received,
total_sent
);
assert!(
final_received >= LOW_PRIORITY_QUEUE_CAPACITY as u32,
"should receive at least {} messages (queue was filled), got {}",
LOW_PRIORITY_QUEUE_CAPACITY,
final_received
);
dispatcher_handle.abort();
}
#[tokio::test]
async fn test_high_priority_uses_extended_retry() {
// 验证:高优消息(AssistantResponse)使用 EXTENDED_RETRY_DELAYS(5次)。
// fail_first_n(3):前 3 次失败,第 4 次成功——证明高优在 3 次后仍继续重试。
let channel = TestChannel::new("flaky").with_fail_first_n(3);
let call_count = channel.call_count.clone();
let msg = make_message("flaky", "chat-1", "final"); // 高优
// EXTENDED_RETRY_DELAYS = [1,2,4,8,16];前 3 次失败后第 4 次成功,耗时 1+2+4=7s
let result = tokio::time::timeout(Duration::from_secs(15), async {
OutboundDispatcher::send_with_retry(&channel, msg).await
})
.await
.expect("high priority should succeed within extended retry budget");
assert!(result.is_ok(), "high priority should succeed after 4 attempts");
assert_eq!(
call_count.load(Ordering::SeqCst),
4,
"high priority should attempt 4 times (extended retry)"
);
}
#[tokio::test]
async fn test_low_priority_gives_up_after_standard_retries() {
// 验证:低优消息(ToolCall)使用 RETRY_DELAYS(3次),前 3 次失败后放弃。
// 与高优的 5 次重试形成对比——低优是中间过程,放弃可接受。
let channel = TestChannel::new("flaky").with_fail_first_n(3);
let call_count = channel.call_count.clone();
let msg = make_low_message("flaky", "chat-1", "tool"); // 低优
// RETRY_DELAYS = [1,2,4];3 次都失败,耗时 1+2=3s
let result = tokio::time::timeout(Duration::from_secs(10), async {
OutboundDispatcher::send_with_retry(&channel, msg).await
})
.await
.expect("low priority should give up within standard retry budget");
assert!(result.is_err(), "low priority should fail after 3 attempts");
assert_eq!(
call_count.load(Ordering::SeqCst),
3,
"low priority should attempt 3 times (standard retry)"
);
}
#[tokio::test]
async fn test_sender_consumes_high_first() {
// 验证:high 队列优先于 low 队列被消费。
// 投递多条低优后再投递高优,高优应在大部分低优之前被发送。
let bus = MessageBus::new(64);
let dispatcher = OutboundDispatcher::new(bus.clone());
let channel = Arc::new(TestChannel::new("test").with_delay(30));
let contents = channel.received_contents.clone();
let received = channel.received.clone();
dispatcher.register_channel("test", channel).await;
let dispatcher_handle = tokio::spawn(async move {
dispatcher.run().await;
});
// 投递 5 条低优
for i in 0..5 {
bus.publish_outbound(make_low_message("test", "chat-1", &format!("low-{i}")))
.await
.unwrap();
}
// 等待低优入 low 队列
tokio::time::sleep(Duration::from_millis(10)).await;
// 投递 1 条高优
bus.publish_outbound(make_message("test", "chat-1", "HIGH"))
.await
.unwrap();
// 等待 6 条全部发送完成
tokio::time::timeout(Duration::from_secs(3), async {
while received.load(Ordering::SeqCst) < 6 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("all 6 messages should be sent within 3s");
let sent = contents.lock().unwrap().clone();
let high_index = sent
.iter()
.position(|c| c == "HIGH")
.expect("HIGH should be in sent list");
// HIGH 应在前 3 条内:sender 完成当前低优后立即取 high,优先于剩余低优
assert!(
high_index < 3,
"HIGH should be sent before most low-priority messages, got index {} in {:?}",
high_index,
sent
);
dispatcher_handle.abort();
unreachable!()
}
}

View File

@ -1,6 +1,5 @@
use std::collections::HashSet;
use std::sync::Arc;
use parking_lot::Mutex;
use std::sync::{Arc, Mutex};
use tokio::sync::Semaphore;
@ -23,7 +22,7 @@ use crate::command::handlers::switch_topic::SwitchTopicCommandHandler;
use crate::config::LLMProviderConfig;
use crate::gateway::agent_factory::build_system_prompt_provider;
use crate::gateway::cancel_manager::CancelManager;
use crate::providers::{ProviderRuntimeConfig, create_provider};
use crate::providers::{create_provider, ProviderRuntimeConfig};
use crate::storage::persistent_session_id;
use crate::topic_description::generate_topic_description;
@ -53,8 +52,8 @@ impl InboundProcessor {
let store = session_manager.store();
// 注册 Session 处理器
let session_handler =
SessionCommandHandler::new(store.clone()).with_session_manager(session_manager.clone());
let session_handler = SessionCommandHandler::new(store.clone())
.with_session_manager(session_manager.clone());
command_router.register(Box::new(session_handler));
// 注册 list_sessions 处理器
@ -80,7 +79,7 @@ impl InboundProcessor {
// 注册 get_current 处理器
command_router.register(Box::new(
GetCurrentSessionCommandHandler::new(store.clone())
.with_system_prompt_provider(system_prompt_provider.clone()),
.with_system_prompt_provider(system_prompt_provider.clone())
));
// 注册 load_topic 处理器
@ -186,8 +185,7 @@ impl InboundProcessor {
let session_id = persistent_session_id(&inbound.channel, &inbound.chat_id);
// 获取当前话题(封装了 session 创建逻辑)
let current_topic = self
.session_manager
let current_topic = self.session_manager
.get_current_topic(&inbound.channel, &inbound.chat_id)
.await?;
@ -198,19 +196,15 @@ impl InboundProcessor {
if let Ok(Some(cmd)) = adapter.try_parse(&inbound.content, ctx) {
// 使用命令路由器处理
let mut cmd_ctx =
crate::command::context::CommandContext::new(&inbound.channel, &inbound.channel)
.with_session_id(&session_id)
.with_chat_id(&inbound.chat_id);
let mut cmd_ctx = crate::command::context::CommandContext::new(&inbound.channel, &inbound.channel)
.with_session_id(&session_id)
.with_chat_id(&inbound.chat_id);
// 只在有话题时才设置 topic_id
if let Some(ref topic_id) = current_topic {
cmd_ctx = cmd_ctx.with_topic_id(topic_id.as_str());
}
let response = self
.command_router
.dispatch_with_response(cmd, cmd_ctx)
.await;
let response = self.command_router.dispatch_with_response(cmd, cmd_ctx).await;
// 发送响应给用户
if response.success {
@ -230,14 +224,7 @@ impl InboundProcessor {
))
.await
{
match error {
crate::bus::BusError::Dropped => {
tracing::warn!(error = %error, "Outbound dropped (bus full)");
}
crate::bus::BusError::Closed => {
tracing::error!(error = %error, "Failed to publish command response");
}
}
tracing::error!(error = %error, "Failed to publish command response");
}
}
} else if let Some(error) = response.error {
@ -253,14 +240,7 @@ impl InboundProcessor {
))
.await
{
match e {
crate::bus::BusError::Dropped => {
tracing::warn!(error = %e, "Outbound dropped (bus full)");
}
crate::bus::BusError::Closed => {
tracing::error!(error = %e, "Failed to publish error response");
}
}
tracing::error!(error = %e, "Failed to publish error response");
}
}
return Ok(());
@ -315,19 +295,10 @@ impl InboundProcessor {
outbound.metadata.extend(inbound.forwarded_metadata.clone());
// 注入 topic_id 到 outbound metadata用于前端按话题隔离消息
if let Some(ref topic_id) = current_topic {
outbound
.metadata
.insert("topic_id".to_string(), topic_id.clone());
outbound.metadata.insert("topic_id".to_string(), topic_id.clone());
}
if let Err(error) = self.bus.publish_outbound(outbound).await {
match error {
crate::bus::BusError::Dropped => {
tracing::warn!(error = %error, "Outbound dropped (bus full)");
}
crate::bus::BusError::Closed => {
tracing::error!(error = %error, "Failed to publish outbound");
}
}
tracing::error!(error = %error, "Failed to publish outbound");
}
}
@ -335,17 +306,10 @@ impl InboundProcessor {
if let Some(ref topic_id) = current_topic {
let store = self.session_manager.store();
if let Ok(Some(topic)) = store.get_topic(topic_id) {
if topic.description.is_none()
|| topic
.description
.as_ref()
.map(|d| d.is_empty())
.unwrap_or(true)
{
if topic.description.is_none() || topic.description.as_ref().map(|d| d.is_empty()).unwrap_or(true) {
// 检查并设置"生成中"守卫,防止竞态条件导致重复生成
let should_generate = {
let mut in_flight =
self.description_generation_in_flight.lock();
let mut in_flight = self.description_generation_in_flight.lock().unwrap();
if in_flight.contains(topic_id) {
false
} else {
@ -363,38 +327,25 @@ impl InboundProcessor {
tokio::spawn(async move {
// 从 DB 查询该 topic 的第一条用户消息作为描述生成的依据
let first_user_message = store_clone
.load_messages_for_topic_full(&topic_id_clone, None)
.load_messages_for_topic(&topic_id_clone, None)
.ok()
.and_then(|msgs| {
msgs.into_iter().find(|m| m.role == "user")
})
.and_then(|msgs| msgs.into_iter().find(|m| m.role == "user"))
.map(|m| m.content);
let message_content = match first_user_message {
Some(content) => content,
None => {
tracing::warn!(topic_id = %topic_id_clone, "No user message found for topic, skipping description generation");
in_flight.lock().remove(&topic_id_clone);
in_flight.lock().unwrap().remove(&topic_id_clone);
return;
}
};
let runtime_config: ProviderRuntimeConfig =
provider_config.into();
let runtime_config: ProviderRuntimeConfig = provider_config.into();
if let Ok(provider) = create_provider(runtime_config) {
match generate_topic_description(
provider.as_ref(),
&message_content,
)
.await
{
match generate_topic_description(provider.as_ref(), &message_content).await {
Ok(description) => {
if let Err(e) = store_clone
.update_topic_description(
&topic_id_clone,
&description,
)
{
if let Err(e) = store_clone.update_topic_description(&topic_id_clone, &description) {
tracing::error!(error = %e, topic_id = %topic_id_clone, "Failed to update topic description");
} else {
tracing::info!(topic_id = %topic_id_clone, description = %description, "Topic description generated");
@ -406,7 +357,7 @@ impl InboundProcessor {
}
}
// 无论成功失败,释放生成守卫
in_flight.lock().remove(&topic_id_clone);
in_flight.lock().unwrap().remove(&topic_id_clone);
});
}
}
@ -429,14 +380,7 @@ impl InboundProcessor {
))
.await
{
match publish_error {
crate::bus::BusError::Dropped => {
tracing::warn!(error = %publish_error, "Outbound dropped (bus full)");
}
crate::bus::BusError::Closed => {
tracing::error!(error = %publish_error, "Failed to publish execution error outbound");
}
}
tracing::error!(error = %publish_error, "Failed to publish execution error outbound");
}
}
}
@ -462,14 +406,7 @@ impl InboundProcessor {
))
.await
{
match error {
crate::bus::BusError::Dropped => {
tracing::warn!(error = %error, "Outbound dropped (bus full)");
}
crate::bus::BusError::Closed => {
tracing::error!(error = %error, "Failed to publish execution_completed");
}
}
tracing::error!(error = %error, "Failed to publish execution_completed");
}
Ok(())

View File

@ -57,9 +57,8 @@ fn load_prompt_from_sources(sources: &[PromptSource]) -> Result<Option<String>,
ensure_parent_dir(path)?;
// 文件不存在时创建空白模板
if !path.exists() {
fs::write(path, template).map_err(|err| {
AgentError::Other(format!("create AGENT.md template error: {}", err))
})?;
fs::write(path, template)
.map_err(|err| AgentError::Other(format!("create AGENT.md template error: {}", err)))?;
}
// 读取内容,仅当非空(去除注释后)时注入
let content = fs::read_to_string(path)
@ -71,9 +70,8 @@ fn load_prompt_from_sources(sources: &[PromptSource]) -> Result<Option<String>,
}
PromptSource::AutoGenerated(path) => {
if path.exists() {
let content = fs::read_to_string(path).map_err(|err| {
AgentError::Other(format!("read MEMORY_SUMMARY.md error: {}", err))
})?;
let content = fs::read_to_string(path)
.map_err(|err| AgentError::Other(format!("read MEMORY_SUMMARY.md error: {}", err)))?;
let without_comments = strip_comments_and_whitespace(&content);
if !without_comments.is_empty() {
fragments.push(without_comments);
@ -339,9 +337,6 @@ mod tests {
persist_memory_summary(&memory_path, "\n## 用户记忆摘要\n- 偏好简洁\n\n").unwrap();
assert_eq!(
fs::read_to_string(&memory_path).unwrap(),
"## 用户记忆摘要\n- 偏好简洁\n"
);
assert_eq!(fs::read_to_string(&memory_path).unwrap(), "## 用户记忆摘要\n- 偏好简洁\n");
}
}

View File

@ -59,7 +59,6 @@ mod tests {
extra_headers: HashMap::new(),
llm_timeout_secs: 120,
memory_maintenance_timeout_secs: 600,
max_retries: 3,
model_id: model_id.to_string(),
temperature: Some(0.0),
max_tokens: Some(32),

View File

@ -8,10 +8,7 @@ use tokio::sync::RwLock;
use crate::agent::AgentError;
use crate::bus::MessageBus;
use crate::config::{
CompactionConfig, LLMProviderConfig, MemoryMaintenanceConfig, ModelResolver, SubagentsConfig,
TaskConfig,
};
use crate::config::{LLMProviderConfig, MemoryMaintenanceConfig, ModelResolver, SubagentsConfig, TaskConfig};
use crate::gateway::model_selection::ModelSelectionStore;
use crate::gateway::tool_registry_factory::ToolRegistryFactory;
use crate::mcp::McpInitializer;
@ -21,13 +18,13 @@ use crate::storage::{
ConversationRepository, MemoryRepository, PromptInjectionRepository, SchedulerJobRepository,
SessionStore, SkillEventRepository, TodoRepository,
};
use crate::tools::task::repository::TaskRepository;
use crate::tools::task::runtime::SubagentRuntime;
use crate::tools::todo_write::TodoItem;
use crate::tools::{
DefaultSubAgentRuntime, InMemoryTaskRepository, NoopSessionMessageSender, SessionMessageSender,
SubAgentRuntimeConfig, SubagentCatalog, TaskTool, ToolRegistry,
DefaultSubAgentRuntime, InMemoryTaskRepository, NoopSessionMessageSender,
SessionMessageSender, SubAgentRuntimeConfig, SubagentCatalog, TaskTool, ToolRegistry,
};
use crate::tools::task::repository::TaskRepository;
use crate::tools::todo_write::TodoItem;
use super::agent_factory::AgentFactory;
use super::cli_session::CliSessionService;
@ -58,17 +55,7 @@ pub(crate) fn build_session_manager(
mcp_config: crate::mcp::McpConfig,
bus: Option<Arc<MessageBus>>,
model_resolver: Arc<ModelResolver>,
compaction_config: CompactionConfig,
) -> Result<
(
SessionManager,
Arc<dyn TaskRepository>,
Option<Arc<McpClientManager>>,
Arc<SubagentRuntime>,
Arc<ModelSelectionStore>,
),
AgentError,
> {
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>, Arc<ModelSelectionStore>), AgentError> {
build_session_manager_with_sender(
agent_prompt_reinject_every,
show_tool_results,
@ -86,7 +73,6 @@ pub(crate) fn build_session_manager(
mcp_config,
bus,
model_resolver,
compaction_config,
)
}
@ -108,17 +94,7 @@ pub(crate) fn build_session_manager_with_sender(
mcp_config: crate::mcp::McpConfig,
bus: Option<Arc<MessageBus>>,
model_resolver: Arc<ModelResolver>,
compaction_config: CompactionConfig,
) -> Result<
(
SessionManager,
Arc<dyn TaskRepository>,
Option<Arc<McpClientManager>>,
Arc<SubagentRuntime>,
Arc<ModelSelectionStore>,
),
AgentError,
> {
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>, Arc<ModelSelectionStore>), AgentError> {
let store = Arc::new(
SessionStore::new()
.map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?,
@ -205,20 +181,18 @@ pub(crate) fn build_session_manager_with_sender(
}
// Create SubAgentRuntime (if task tool is enabled)
let (factory, task_repository, subagent_runtime): (
_,
Arc<dyn TaskRepository>,
Arc<SubagentRuntime>,
) = if task_config.enabled {
let (factory, task_repository, subagent_runtime): (_, Arc<dyn TaskRepository>, Arc<SubagentRuntime>) = if task_config.enabled {
let task_repository = Arc::new(InMemoryTaskRepository::new());
// Build subagent tools with MCP tools (task tool registered separately below)
let subagent_tools = Arc::new(factory.build_subagent_tools(
if mcp_tools_for_subagents.is_empty() {
None
} else {
Some(mcp_tools_for_subagents.clone())
},
));
let subagent_tools = Arc::new(
factory.build_subagent_tools(
if mcp_tools_for_subagents.is_empty() {
None
} else {
Some(mcp_tools_for_subagents.clone())
}
)
);
// Create subagent catalog with discovery, wrap in SubagentRuntime
let catalog = SubagentCatalog::discover(&subagents_config);
@ -256,19 +230,11 @@ pub(crate) fn build_session_manager_with_sender(
));
}
(
factory.with_subagent_runtime(default_subagent_runtime),
task_repository,
subagent_runtime,
)
(factory.with_subagent_runtime(default_subagent_runtime), task_repository, subagent_runtime)
} else {
// task_config 未启用时仍创建 subagent_runtime供 API 使用)
let subagent_runtime = Arc::new(SubagentRuntime::from_config(subagents_config.clone()));
(
factory,
Arc::new(InMemoryTaskRepository::new()),
subagent_runtime,
)
(factory, Arc::new(InMemoryTaskRepository::new()), subagent_runtime)
};
// Build base tools
@ -317,7 +283,6 @@ pub(crate) fn build_session_manager_with_sender(
prompt_repository.clone(),
model_resolver.clone(),
model_selections.clone(),
compaction_config,
);
let session_factory = SessionFactory::new(
provider_config.clone(),
@ -341,24 +306,18 @@ pub(crate) fn build_session_manager_with_sender(
// Extract MCP manager for lifecycle management (e.g., disconnect on restart)
let mcp_manager = mcp_initializer.manager();
Ok((
SessionManager::from_services(SessionManagerServices {
tools: tools as Arc<ToolRegistry>,
skills,
experts,
subagent_runtime: subagent_runtime.clone(),
store,
show_tool_results,
lifecycle,
cli_sessions,
messages,
scheduled_tasks,
memory_maintenance,
task_repository: task_repository.clone(),
}),
task_repository,
mcp_manager,
subagent_runtime,
model_selections,
))
Ok((SessionManager::from_services(SessionManagerServices {
tools: tools as Arc<ToolRegistry>,
skills,
experts,
subagent_runtime: subagent_runtime.clone(),
store,
show_tool_results,
lifecycle,
cli_sessions,
messages,
scheduled_tasks,
memory_maintenance,
task_repository: task_repository.clone(),
}), task_repository, mcp_manager, subagent_runtime, model_selections))
}

View File

@ -37,10 +37,7 @@ impl ScheduledAgentTaskService {
// 根据 chat_id 自动选择 Session
// - scheduler/ 开头:使用定时任务专用 Session独立实例不与用户消息竞争锁
// - 其他:使用主 Session
let session = self
.lifecycle
.active_session_for_chat_id(channel_name, chat_id)
.await?;
let session = self.lifecycle.active_session_for_chat_id(channel_name, chat_id).await?;
let sender_id = options
.sender_id
.clone()

View File

@ -1,16 +1,13 @@
use crate::agent::{AgentError, AgentLoop, AgentRuntimeConfig, ContextCompressor, EmittedMessageHandler};
use crate::agent::{AgentError, AgentLoop, ContextCompressor, EmittedMessageHandler};
#[cfg(test)]
use crate::bus::SYSTEM_CONTEXT_SCHEDULED_PROMPT;
use crate::bus::{ChatMessage, MessageBus, OutboundMessage};
use crate::providers::StreamDelta;
use crate::config::LLMProviderConfig;
use crate::protocol::WsOutbound;
use crate::providers::StreamDelta;
use crate::scheduler::ScheduledAgentTaskOptions;
use crate::skills::SkillRuntime;
use crate::storage::{
ConversationRepository, PromptInjectionRepository, SessionRecord, SessionStore,
SkillEventRepository,
};
use crate::storage::{ConversationRepository, PromptInjectionRepository, SessionRecord, SessionStore, SkillEventRepository};
use crate::tools::ToolRegistry;
use crate::tools::task::repository::TaskRepository;
use crate::tools::task::runtime::SubagentRuntime;
@ -27,7 +24,8 @@ use super::execution::should_display_message_to_user;
#[cfg(test)]
use super::memory_maintenance::{
MemoryMaintenanceMerge, apply_memory_maintenance_output, build_memory_maintenance_plan,
extract_json_object, is_recoverable_maintenance_llm_error, strip_json_code_fence,
extract_json_object, is_recoverable_maintenance_llm_error,
strip_json_code_fence,
};
use super::memory_maintenance::{MemoryMaintenanceScopeResult, MemoryOrganizationOutput};
use super::memory_maintenance_coordinator::MemoryMaintenanceCoordinator;
@ -60,7 +58,7 @@ pub struct BusToolCallEmitter {
chat_id: String,
metadata: HashMap<String, String>,
store: Arc<SessionStore>,
stream_message_id: parking_lot::Mutex<Option<String>>,
stream_message_id: std::sync::Mutex<Option<String>>,
}
impl BusToolCallEmitter {
@ -77,7 +75,7 @@ impl BusToolCallEmitter {
chat_id: chat_id.into(),
metadata,
store,
stream_message_id: parking_lot::Mutex::new(None),
stream_message_id: std::sync::Mutex::new(None),
}
}
}
@ -94,14 +92,7 @@ impl EmittedMessageHandler for BusToolCallEmitter {
&message,
) {
if let Err(error) = self.bus.publish_outbound(outbound).await {
match error {
crate::bus::BusError::Dropped => {
tracing::warn!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Outbound dropped (bus full)");
}
crate::bus::BusError::Closed => {
tracing::error!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Failed to publish live outbound tool call");
}
}
tracing::error!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Failed to publish live outbound tool call");
}
}
}
@ -120,14 +111,7 @@ impl EmittedMessageHandler for BusToolCallEmitter {
&message,
) {
if let Err(error) = self.bus.publish_outbound(outbound).await {
match error {
crate::bus::BusError::Dropped => {
tracing::warn!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Outbound dropped (bus full)");
}
crate::bus::BusError::Closed => {
tracing::error!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Failed to publish live outbound tool call");
}
}
tracing::error!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Failed to publish live outbound tool call");
}
}
@ -140,10 +124,8 @@ impl EmittedMessageHandler for BusToolCallEmitter {
async fn handle_stream_delta(&self, delta: &StreamDelta) {
// Get or create the stream message ID
let message_id = {
let mut guard = self.stream_message_id.lock();
guard
.get_or_insert_with(|| Uuid::new_v4().to_string())
.clone()
let mut guard = self.stream_message_id.lock().unwrap();
guard.get_or_insert_with(|| Uuid::new_v4().to_string()).clone()
};
// Empty content + no reasoning = stream end signal
@ -168,19 +150,12 @@ impl EmittedMessageHandler for BusToolCallEmitter {
};
if let Err(error) = self.bus.publish_outbound(outbound).await {
match error {
crate::bus::BusError::Dropped => {
tracing::warn!(error = %error, channel = %self.channel_name, "Outbound dropped (bus full)");
}
crate::bus::BusError::Closed => {
tracing::error!(error = %error, channel = %self.channel_name, "Failed to publish stream delta");
}
}
tracing::error!(error = %error, channel = %self.channel_name, "Failed to publish stream delta");
}
}
async fn set_stream_message_id(&self, id: &str) {
*self.stream_message_id.lock() = Some(id.to_string());
*self.stream_message_id.lock().unwrap() = Some(id.to_string());
}
}
@ -205,11 +180,7 @@ impl BusToolCallEmitter {
.cloned()
.unwrap_or_else(|| session_id.clone());
let topic_id = self
.metadata
.get("topic_id")
.filter(|t| !t.is_empty())
.cloned();
let topic_id = self.metadata.get("topic_id").filter(|t| !t.is_empty()).cloned();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -301,7 +272,6 @@ impl Session {
prompt_repository.clone(),
model_resolver,
Arc::new(super::model_selection::ModelSelectionStore::new()),
crate::config::CompactionConfig::default(),
);
Self::with_factories(
channel_name,
@ -326,10 +296,6 @@ impl Session {
skill_events: Arc<dyn SkillEventRepository>,
store: Arc<SessionStore>,
) -> Result<Self, AgentError> {
// Session 的 compressor 用于 sync 兜底压缩路径compaction.rs
// 必须与 AgentLoop 的 compressor 共用同一套用户配置的压缩参数。
let runtime_config = AgentRuntimeConfig::from(provider_config.clone());
let compressor = agent_factory.build_compressor(&runtime_config);
Ok(Self {
id: Uuid::new_v4(),
channel_name: channel_name.clone(),
@ -337,8 +303,12 @@ impl Session {
provider_config: provider_config.clone(),
skills,
agent_factory,
compressor,
history: SessionHistory::new(channel_name, conversations, skill_events),
compressor: ContextCompressor::from_provider_config(&provider_config),
history: SessionHistory::new(
channel_name,
conversations,
skill_events,
),
store,
pending_cancel_tokens: HashMap::new(),
})
@ -616,7 +586,7 @@ impl Session {
) -> Result<AgentLoop, AgentError> {
self.create_agent_with_provider_config(
chat_id,
None, // notification_chat_id = None使用 session_chat_id
None, // notification_chat_id = None使用 session_chat_id
sender_id,
message_id,
self.provider_config.clone(),
@ -641,9 +611,7 @@ impl Session {
// 消费 pending 的取消信号接收端(如果存在)
// 优先按 topic_id 查找;无 topic 时回退 chat_id
let cancel_token = match &topic_id {
Some(tid) => self
.pending_cancel_tokens
.remove(tid)
Some(tid) => self.pending_cancel_tokens.remove(tid)
.or_else(|| self.pending_cancel_tokens.remove(session_chat_id)),
None => self.pending_cancel_tokens.remove(session_chat_id),
};
@ -749,7 +717,6 @@ impl SessionManager {
mcp_config,
None,
model_resolver,
crate::config::CompactionConfig::default(),
)
.map(|(session_manager, _, _, _, _)| session_manager)
}
@ -813,11 +780,7 @@ impl SessionManager {
}
/// 获取指定 chat 的当前话题(确保 session 存在,自动从数据库恢复)
pub async fn get_current_topic(
&self,
channel_name: &str,
chat_id: &str,
) -> Result<Option<String>, AgentError> {
pub async fn get_current_topic(&self, channel_name: &str, chat_id: &str) -> Result<Option<String>, AgentError> {
self.ensure_session(channel_name).await?;
if let Some(session) = self.get(channel_name).await {
let mut guard = session.lock().await;
@ -825,9 +788,7 @@ impl SessionManager {
// 如果内存中没有当前话题,从数据库恢复最近活跃的话题
if guard.current_topic(chat_id).is_none() {
let session_id = guard.persistent_session_id(chat_id);
let topics = self
.store
.list_topics(&session_id)
let topics = self.store.list_topics(&session_id)
.map_err(|e| AgentError::Other(format!("Failed to list topics: {}", e)))?;
if let Some(latest_topic) = topics.first() {
@ -841,7 +802,10 @@ impl SessionManager {
);
} else {
// 数据库中也没有话题,自动创建默认话题
let title = format!("话题 {}", chrono::Local::now().format("%m/%d %H:%M"));
let title = format!(
"话题 {}",
chrono::Local::now().format("%m/%d %H:%M")
);
match self.store.create_topic(&session_id, &title, None) {
Ok(topic) => {
guard.set_current_topic(chat_id, Some(topic.id.clone()));
@ -881,10 +845,7 @@ impl SessionManager {
token: tokio::sync::watch::Receiver<()>,
) {
if let Some(session) = self.get(channel_name).await {
session
.lock()
.await
.set_cancel_receiver(chat_id, topic_id, token);
session.lock().await.set_cancel_receiver(chat_id, topic_id, token);
}
}
@ -943,13 +904,7 @@ impl SessionManager {
options: ScheduledAgentTaskOptions,
) -> Result<Vec<OutboundMessage>, AgentError> {
self.scheduled_tasks
.run(
channel_name,
session_chat_id,
notification_chat_id,
prompt,
options,
)
.run(channel_name, session_chat_id, notification_chat_id, prompt, options)
.await
}
@ -991,7 +946,6 @@ mod tests {
extra_headers: HashMap::new(),
llm_timeout_secs: 120,
memory_maintenance_timeout_secs: 600,
max_retries: 3,
model_id: "test-model".to_string(),
temperature: Some(0.0),
max_tokens: Some(32),
@ -1018,9 +972,9 @@ mod tests {
store.clone(),
store.clone(),
Arc::new(NoopSessionMessageSender),
HashSet::new(),
HashSet::new(),
"Asia/Shanghai".to_string(),
HashSet::new(),
HashSet::new(),
Default::default(),
)
.build(),
@ -1046,16 +1000,12 @@ mod tests {
let first = session.create_user_message("first", Vec::new());
let first_id = first.id.clone();
session
.append_persisted_message("chat-1", Some(&topic_id), first)
.unwrap();
session.append_persisted_message("chat-1", Some(&topic_id), first).unwrap();
assert!(session.is_latest_user_message(&topic_id, &first_id));
let second = session.create_user_message("second", Vec::new());
let second_id = second.id.clone();
session
.append_persisted_message("chat-1", Some(&topic_id), second)
.unwrap();
session.append_persisted_message("chat-1", Some(&topic_id), second).unwrap();
assert!(!session.is_latest_user_message(&topic_id, &first_id));
assert!(session.is_latest_user_message(&topic_id, &second_id));
@ -1074,9 +1024,9 @@ mod tests {
store.clone(),
store.clone(),
Arc::new(NoopSessionMessageSender),
HashSet::new(),
HashSet::new(),
"Asia/Shanghai".to_string(),
HashSet::new(),
HashSet::new(),
Default::default(),
)
.build(),
@ -1102,15 +1052,9 @@ mod tests {
let first = session.create_user_message("first", Vec::new());
let first_id = first.id.clone();
session.append_persisted_message("chat-1", Some(&topic_id), first).unwrap();
session
.append_persisted_message("chat-1", Some(&topic_id), first)
.unwrap();
session
.append_persisted_message(
"chat-1",
Some(&topic_id),
ChatMessage::assistant("answer-1"),
)
.append_persisted_message("chat-1", Some(&topic_id), ChatMessage::assistant("answer-1"))
.unwrap();
let second = session.create_user_message("second", Vec::new());
@ -1118,11 +1062,7 @@ mod tests {
.append_persisted_message("chat-1", Some(&topic_id), second.clone())
.unwrap();
session
.append_persisted_message(
"chat-1",
Some(&topic_id),
ChatMessage::assistant("answer-2"),
)
.append_persisted_message("chat-1", Some(&topic_id), ChatMessage::assistant("answer-2"))
.unwrap();
let preserved_messages = session.get_history(&topic_id).unwrap().clone();
@ -1272,7 +1212,6 @@ mod tests {
max_tool_iterations: 1,
llm_timeout_secs: 30,
memory_maintenance_timeout_secs: 600,
max_retries: 3,
tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000,
max_images_in_context: 1,
@ -1296,15 +1235,7 @@ mod tests {
.unwrap();
let outbound = session_manager
.handle_message(
"test-channel",
"user-1",
"chat-1",
"hello",
Vec::new(),
None,
None,
)
.handle_message("test-channel", "user-1", "chat-1", "hello", Vec::new(), None, None)
.await
.unwrap();
@ -1329,7 +1260,6 @@ mod tests {
max_tool_iterations: 1,
llm_timeout_secs: 30,
memory_maintenance_timeout_secs: 600,
max_retries: 3,
tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000,
max_images_in_context: 1,
@ -1408,7 +1338,6 @@ mod tests {
max_tool_iterations: 1,
llm_timeout_secs: 30,
memory_maintenance_timeout_secs: 600,
max_retries: 3,
tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000,
max_images_in_context: 1,
@ -1505,7 +1434,6 @@ mod tests {
max_tool_iterations: 1,
llm_timeout_secs: 30,
memory_maintenance_timeout_secs: 600,
max_retries: 3,
tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000,
max_images_in_context: 1,
@ -1596,7 +1524,6 @@ mod tests {
max_tool_iterations: 1,
llm_timeout_secs: 1,
memory_maintenance_timeout_secs: 600,
max_retries: 3,
tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000,
max_images_in_context: 1,
@ -1686,7 +1613,6 @@ mod tests {
max_tool_iterations: 1,
llm_timeout_secs: 30,
memory_maintenance_timeout_secs: 600,
max_retries: 3,
tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000,
max_images_in_context: 1,
@ -1758,7 +1684,6 @@ mod tests {
max_tool_iterations: 1,
llm_timeout_secs: 30,
memory_maintenance_timeout_secs: 600,
max_retries: 3,
tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000,
max_images_in_context: 1,
@ -1808,8 +1733,7 @@ mod tests {
}
#[tokio::test]
async fn test_run_memory_maintenance_for_all_scopes_scans_all_scopes_even_without_recent_updates()
{
async fn test_run_memory_maintenance_for_all_scopes_scans_all_scopes_even_without_recent_updates() {
let mock_response_content = serde_json::to_string(&json!({
"user_facts": ["用户在做AI产品"],
"preferences": [],
@ -1840,7 +1764,6 @@ mod tests {
max_tool_iterations: 1,
llm_timeout_secs: 30,
memory_maintenance_timeout_secs: 600,
max_retries: 3,
tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000,
max_images_in_context: 1,
@ -1908,7 +1831,6 @@ mod tests {
max_tool_iterations: 1,
llm_timeout_secs: 1,
memory_maintenance_timeout_secs: 600,
max_retries: 3,
tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000,
max_images_in_context: 1,
@ -2061,18 +1983,11 @@ mod tests {
let all_memories = store.list_memories_for_scope("user", scope_key).unwrap();
// 过滤掉 _meta 记录
let user_memories: Vec<_> = all_memories
.iter()
.filter(|m| m.namespace != "_meta")
.collect();
let user_memories: Vec<_> = all_memories.iter().filter(|m| m.namespace != "_meta").collect();
// 合并 2 条为 1 条,删除 1 条7 - 2 + 1 = 5 条
assert_eq!(user_memories.len(), 5);
// 验证合并后的记忆存在
assert!(
user_memories
.iter()
.any(|m| m.namespace == "user" && m.memory_key == "work")
);
assert!(user_memories.iter().any(|m| m.namespace == "user" && m.memory_key == "work"));
}
#[test]
@ -2095,19 +2010,22 @@ mod tests {
let store = Arc::new(SessionStore::in_memory().unwrap());
let bus = MessageBus::new(4);
let emitter =
BusToolCallEmitter::new(bus.clone(), "test-channel", "chat-1", HashMap::new(), store);
BusToolCallEmitter::new(
bus.clone(),
"test-channel",
"chat-1",
HashMap::new(),
store,
);
emitter
.handle(ChatMessage::tool("call-1", "calculator", "2"))
.await;
let msg = tokio::time::timeout(
std::time::Duration::from_millis(500),
bus.consume_outbound(),
)
.await
.expect("timeout waiting for outbound message")
.expect("bus outbound closed");
let msg = tokio::time::timeout(std::time::Duration::from_millis(500), bus.consume_outbound())
.await
.expect("timeout waiting for outbound message")
.expect("bus outbound closed");
assert_eq!(msg.event_kind, OutboundEventKind::ToolResult);
}
@ -2124,9 +2042,9 @@ mod tests {
store.clone(),
store.clone(),
Arc::new(NoopSessionMessageSender),
HashSet::new(),
HashSet::new(),
"Asia/Shanghai".to_string(),
HashSet::new(),
HashSet::new(),
Default::default(),
)
.build(),
@ -2165,9 +2083,9 @@ mod tests {
store.clone(),
store.clone(),
Arc::new(NoopSessionMessageSender),
HashSet::new(),
HashSet::new(),
"Asia/Shanghai".to_string(),
HashSet::new(),
HashSet::new(),
Default::default(),
)
.build(),
@ -2193,11 +2111,7 @@ mod tests {
for turn in 0..100 {
session
.append_persisted_message(
"chat-1",
Some(&topic_id),
ChatMessage::user(format!("user-{turn}")),
)
.append_persisted_message("chat-1", Some(&topic_id), ChatMessage::user(format!("user-{turn}")))
.unwrap();
}
@ -2246,9 +2160,9 @@ mod tests {
store.clone(),
store.clone(),
Arc::new(NoopSessionMessageSender),
HashSet::new(),
HashSet::new(),
"Asia/Shanghai".to_string(),
HashSet::new(),
HashSet::new(),
Default::default(),
)
.build(),
@ -2274,11 +2188,7 @@ mod tests {
for turn in 0..100 {
session
.append_persisted_message(
"chat-1",
Some(&topic_id),
ChatMessage::user(format!("user-{turn}")),
)
.append_persisted_message("chat-1", Some(&topic_id), ChatMessage::user(format!("user-{turn}")))
.unwrap();
}

View File

@ -7,11 +7,6 @@ use crate::storage::{
ConversationRepository, SessionRecord, SkillEventRepository, persistent_session_id,
};
/// 内存中缓存的 topic 历史上限。
/// 超过此值时,驱逐非活跃 topic不在 chat_topic_ids 当前引用中的 topic
/// 活跃 topic 永不被驱逐,避免影响正在进行的对话。
const MAX_CACHED_TOPICS: usize = 32;
fn preview_text(content: &str, max_chars: usize) -> String {
let mut preview = content.chars().take(max_chars).collect::<String>();
if content.chars().count() > max_chars {
@ -24,7 +19,6 @@ pub(crate) struct SessionHistory {
channel_name: String,
/// 按 topic_id 键化的内存历史缓存。
/// 不同 topic 的历史独立存储,互不干扰,支持多话题并发执行。
/// 超过 `MAX_CACHED_TOPICS` 时自动驱逐非活跃 topic。
topic_histories: HashMap<String, Vec<ChatMessage>>,
/// UI 状态:每个 chat 当前活跃的 topic按 chat_id 键)。
chat_topic_ids: HashMap<String, String>,
@ -40,56 +34,6 @@ pub(crate) struct SessionHistory {
}
impl SessionHistory {
/// 当缓存 topic 数超过 `MAX_CACHED_TOPICS` 时,驱逐非活跃 topic。
///
/// 活跃判定(任一满足即活跃,不驱逐):
/// 1. 在 `chat_topic_ids` 的 values 中UI 当前引用的 topic
/// 2. 在 `compression_in_flight` 中(正在压缩的 topic
/// 3. `topic_serial_lock` 被持有(有活跃 agent 任务正在处理该 topic
///
/// 第 3 项防止驱逐正在 agent 处理中的 topicagent 处理使用 `original_topic_id`
/// 而非 UI 状态 `chat_topic_ids`,用户切换 topic 后原 topic 不在 UI 集合中,
/// 但 agent 仍在处理(持有 serial lock此时不应驱逐。
fn evict_inactive_if_needed(&mut self) {
if self.topic_histories.len() <= MAX_CACHED_TOPICS {
return;
}
// 收集当前活跃 topic 集合
let active: HashSet<&str> = self
.chat_topic_ids
.values()
.map(|s| s.as_str())
.collect();
// 找一个非活跃 topic 驱逐
let to_evict = self.topic_histories.keys().find(|tid| {
if active.contains(tid.as_str()) || self.compression_in_flight.contains(*tid) {
return false;
}
// 检查是否有活跃 agent 任务serial lock 被持有)
// try_lock 成功 = 锁空闲 = 无活跃任务 = 可驱逐
// try_lock 失败 = 锁被持有 = 有活跃任务 = 不驱逐
if let Some(lock) = self.topic_serial_locks.get(*tid) {
if lock.try_lock().is_err() {
return false;
}
}
true
});
if let Some(tid) = to_evict.cloned() {
let msg_count = self.topic_histories.get(&tid).map(|h| h.len()).unwrap_or(0);
self.topic_histories.remove(&tid);
tracing::info!(
topic_id = %tid,
evicted_messages = msg_count,
remaining_topics = self.topic_histories.len(),
"Evicted inactive topic history to respect MAX_CACHED_TOPICS"
);
}
}
pub(crate) fn new(
channel_name: impl Into<String>,
conversations: Arc<dyn ConversationRepository>,
@ -159,7 +103,6 @@ impl SessionHistory {
}
self.topic_histories.insert(tid.to_string(), history);
self.evict_inactive_if_needed();
Ok(())
}
@ -172,9 +115,7 @@ impl SessionHistory {
}
pub(crate) fn get_or_create_history(&mut self, topic_id: &str) -> &mut Vec<ChatMessage> {
self.topic_histories
.entry(topic_id.to_string())
.or_default()
self.topic_histories.entry(topic_id.to_string()).or_default()
}
pub(crate) fn get_history(&self, topic_id: &str) -> Option<&Vec<ChatMessage>> {
@ -183,7 +124,6 @@ impl SessionHistory {
pub(crate) fn set_history(&mut self, topic_id: &str, history: Vec<ChatMessage>) {
self.topic_histories.insert(topic_id.to_string(), history);
self.evict_inactive_if_needed();
}
/// 设置指定 chat 的当前 topicUI 状态)
@ -208,10 +148,6 @@ impl SessionHistory {
pub(crate) fn remove_history(&mut self, topic_id: &str) {
self.topic_histories.remove(topic_id);
self.compression_in_flight.remove(topic_id);
// 清理 serial lock防止 topic_serial_locks 无限增长
// (仅在无活跃任务时安全移除;有活跃任务时 lock 被 Arc clone 持有,
// 移除 HashMap entry 不影响正在使用 lock 的任务)
self.topic_serial_locks.remove(topic_id);
}
/// 清空指定 chat/topic 的内存历史和 DB 消息。
@ -223,7 +159,6 @@ impl SessionHistory {
) -> Result<(), AgentError> {
if let Some(tid) = topic_id {
if let Some(history) = self.topic_histories.get_mut(tid) {
#[cfg(debug_assertions)]
let len = history.len();
history.clear();
#[cfg(debug_assertions)]
@ -321,7 +256,6 @@ impl SessionHistory {
/// 清空所有内存历史(主要用于测试全局重置)。
/// 不遍历清 DB生产环境如需清 DB 应由调用方显式调用。
pub(crate) fn clear_all_history(&mut self) -> Result<(), AgentError> {
#[cfg(debug_assertions)]
let total: usize = self.topic_histories.values().map(|h| h.len()).sum();
self.topic_histories.clear();
self.compression_in_flight.clear();
@ -342,7 +276,6 @@ impl SessionHistory {
.load_messages_for_topic(topic_id, Some(&sid))
.map_err(|err| AgentError::Other(format!("session history reload error: {}", err)))?;
self.topic_histories.insert(topic_id.to_string(), history);
self.evict_inactive_if_needed();
Ok(())
}

View File

@ -52,12 +52,9 @@ impl SessionLifecycleService {
channel_name: &str,
chat_id: &str,
) -> Result<Arc<Mutex<Session>>, AgentError> {
self.session_pool
.ensure_session_for_chat_id(channel_name, chat_id)
.await?;
self.session_pool.ensure_session_for_chat_id(channel_name, chat_id).await?;
self.touch(channel_name).await;
self.session_pool
.get_for_chat_id(channel_name, chat_id)
self.session_pool.get_for_chat_id(channel_name, chat_id)
.await
.ok_or_else(|| AgentError::Other("Session not found".to_string()))
}

View File

@ -3,7 +3,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use crate::bus::{BusError, MessageBus, OutboundMessage};
use crate::bus::{MessageBus, OutboundMessage};
use crate::tools::{SessionMessageSender, SessionSendOutcome, SessionSendRequest, ToolContext};
pub(crate) struct BusSessionMessageSender {
@ -55,28 +55,15 @@ impl SessionMessageSender for BusSessionMessageSender {
if attachment_count > 0 {
outbound.media = request.attachments.clone();
}
match self.bus.publish_outbound(outbound).await {
Ok(()) => {
published_messages += 1;
tracing::info!(
channel = %channel_name,
chat_id = %chat_id,
content_len = content_len,
attachment_count = attachment_count,
"Published session text message to outbound bus"
);
}
Err(BusError::Dropped) => {
tracing::warn!(
channel = %channel_name,
chat_id = %chat_id,
"Outbound bus full, dropping session text message"
);
}
Err(BusError::Closed) => {
return Err(anyhow::anyhow!("Outbound bus closed"));
}
}
self.bus.publish_outbound(outbound).await?;
published_messages += 1;
tracing::info!(
channel = %channel_name,
chat_id = %chat_id,
content_len = content_len,
attachment_count = attachment_count,
"Published session text message to outbound bus"
);
} else {
for attachment in request.attachments {
let media_path = attachment.path.clone();
@ -90,28 +77,15 @@ impl SessionMessageSender for BusSessionMessageSender {
metadata.clone(),
);
outbound.media = vec![attachment];
match self.bus.publish_outbound(outbound).await {
Ok(()) => {
published_messages += 1;
tracing::info!(
channel = %channel_name,
chat_id = %chat_id,
media_type = %media_type,
media_path = %media_path,
"Published session attachment to outbound bus"
);
}
Err(BusError::Dropped) => {
tracing::warn!(
channel = %channel_name,
chat_id = %chat_id,
"Outbound bus full, dropping session attachment"
);
}
Err(BusError::Closed) => {
return Err(anyhow::anyhow!("Outbound bus closed"));
}
}
self.bus.publish_outbound(outbound).await?;
published_messages += 1;
tracing::info!(
channel = %channel_name,
chat_id = %chat_id,
media_type = %media_type,
media_path = %media_path,
"Published session attachment to outbound bus"
);
}
}
@ -148,7 +122,7 @@ mod tests {
// 使用临时目录确保跨平台兼容
attachments: vec![MediaItem::new(
&std::env::temp_dir().join("demo.png").display().to_string(),
"image",
"image"
)],
},
)

View File

@ -49,10 +49,7 @@ impl SessionPool {
}
/// 确保定时任务专用 Session 存在
pub(crate) async fn ensure_scheduler_session(
&self,
channel_name: &str,
) -> Result<(), AgentError> {
pub(crate) async fn ensure_scheduler_session(&self, channel_name: &str) -> Result<(), AgentError> {
self.ensure_session_internal(channel_name, true).await
}
@ -62,11 +59,7 @@ impl SessionPool {
/// session 创建含配置加载、agent 工厂构造),再次持锁插入并处理竞态。
/// 避免跨 `session_factory.create().await` 持有全局锁导致所有 channel 的
/// session 访问串行化。
async fn ensure_session_internal(
&self,
channel_name: &str,
is_scheduler: bool,
) -> Result<(), AgentError> {
async fn ensure_session_internal(&self, channel_name: &str, is_scheduler: bool) -> Result<(), AgentError> {
// Fast path: 已存在直接返回(短暂持锁)
{
let inner = self.inner.lock().await;
@ -116,26 +109,14 @@ impl SessionPool {
}
/// 获取定时任务专用 Session
pub(crate) async fn get_scheduler_session(
&self,
channel_name: &str,
) -> Option<Arc<Mutex<Session>>> {
self.inner
.lock()
.await
.scheduler_sessions
.get(channel_name)
.cloned()
pub(crate) async fn get_scheduler_session(&self, channel_name: &str) -> Option<Arc<Mutex<Session>>> {
self.inner.lock().await.scheduler_sessions.get(channel_name).cloned()
}
/// 根据 chat_id 自动选择 Session
/// - scheduler/ 开头:返回定时任务专用 Session
/// - 其他:返回主 Session
pub(crate) async fn get_for_chat_id(
&self,
channel_name: &str,
chat_id: &str,
) -> Option<Arc<Mutex<Session>>> {
pub(crate) async fn get_for_chat_id(&self, channel_name: &str, chat_id: &str) -> Option<Arc<Mutex<Session>>> {
if is_scheduler_chat_id(chat_id) {
self.get_scheduler_session(channel_name).await
} else {
@ -144,11 +125,7 @@ impl SessionPool {
}
/// 确保 Session 存在(根据 chat_id 自动选择)
pub(crate) async fn ensure_session_for_chat_id(
&self,
channel_name: &str,
chat_id: &str,
) -> Result<(), AgentError> {
pub(crate) async fn ensure_session_for_chat_id(&self, channel_name: &str, chat_id: &str) -> Result<(), AgentError> {
if is_scheduler_chat_id(chat_id) {
self.ensure_scheduler_session(channel_name).await
} else {

View File

@ -1,6 +1,6 @@
use axum::{
body::Body,
http::{Response, StatusCode, Uri, header},
http::{header, Response, StatusCode, Uri},
};
use rust_embed::RustEmbed;
@ -16,7 +16,11 @@ pub async fn static_handler(uri: Uri) -> Response<Body> {
let path = uri.path().trim_start_matches('/');
// 处理根路径,返回 index.html
let path = if path.is_empty() { "index.html" } else { path };
let path = if path.is_empty() {
"index.html"
} else {
path
};
match StaticAssets::get(path) {
Some(content) => {

View File

@ -6,14 +6,13 @@ use tokio::sync::RwLock;
use crate::config::TaskConfig;
use crate::mcp::McpClientManager;
use crate::skills::SkillRuntime;
use crate::storage::{
MemoryRepository, SchedulerJobRepository, SkillEventRepository, TodoRepository,
};
use crate::storage::{MemoryRepository, SchedulerJobRepository, SkillEventRepository, TodoRepository};
use crate::tools::todo_write::TodoItem;
use crate::tools::{
BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool, HttpRequestTool,
MemoryManageTool, MemorySearchTool, SchedulerManageTool, SessionMessageSender, SessionSendTool,
ShellSessionManager, SkillActivateTool, SkillManageTool, SubAgentRuntime, TaskTool, TimeTool,
BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool,
HttpRequestTool, MemoryManageTool, MemorySearchTool,
SchedulerManageTool, SessionMessageSender, SessionSendTool, ShellSessionManager,
SkillActivateTool, SkillManageTool, SubAgentRuntime, TaskTool, TimeTool,
TodoReadTool, TodoWriteTool, ToolRegistry, WebFetchTool,
};
@ -73,12 +72,18 @@ impl ToolRegistryFactory {
self
}
pub(crate) fn with_subagent_runtime(mut self, runtime: Arc<dyn SubAgentRuntime>) -> Self {
pub(crate) fn with_subagent_runtime(
mut self,
runtime: Arc<dyn SubAgentRuntime>,
) -> Self {
self.subagent_runtime = Some(runtime);
self
}
pub(crate) fn with_mcp_manager(mut self, manager: Arc<McpClientManager>) -> Self {
pub(crate) fn with_mcp_manager(
mut self,
manager: Arc<McpClientManager>,
) -> Self {
self.mcp_manager = Some(manager);
self
}
@ -113,14 +118,8 @@ impl ToolRegistryFactory {
}
if self.is_enabled("todo_write") {
if let Some(ref state) = self.todo_state {
registry.register(TodoWriteTool::new(
state.clone(),
self.todo_repository.clone(),
));
registry.register(TodoReadTool::new(
state.clone(),
self.todo_repository.clone(),
));
registry.register(TodoWriteTool::new(state.clone(), self.todo_repository.clone()));
registry.register(TodoReadTool::new(state.clone(), self.todo_repository.clone()));
}
}
if self.is_enabled("session_send") {
@ -227,14 +226,8 @@ impl ToolRegistryFactory {
// Todo 追踪工具
if self.is_enabled("todo_write") {
if let Some(ref state) = self.todo_state {
registry.register(TodoWriteTool::new(
state.clone(),
self.todo_repository.clone(),
));
registry.register(TodoReadTool::new(
state.clone(),
self.todo_repository.clone(),
));
registry.register(TodoWriteTool::new(state.clone(), self.todo_repository.clone()));
registry.register(TodoReadTool::new(state.clone(), self.todo_repository.clone()));
}
}

View File

@ -10,16 +10,16 @@ use crate::command::handlers::get_current::GetCurrentSessionCommandHandler;
use crate::command::handlers::help::HelpCommandHandler;
use crate::command::handlers::list_channels::ListChannelsCommandHandler;
use crate::command::handlers::list_memories::ListMemoriesCommandHandler;
use crate::command::handlers::list_skills::ListSkillsCommandHandler;
use crate::command::handlers::list_scheduler_jobs::ListSchedulerJobsCommandHandler;
use crate::command::handlers::list_todos::ListTodosCommandHandler;
use crate::command::handlers::memory_crud::MemoryCrudCommandHandler;
use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
use crate::command::handlers::list_sessions_by_channel::ListSessionsByChannelCommandHandler;
use crate::command::handlers::list_skills::ListSkillsCommandHandler;
use crate::command::handlers::list_todos::ListTodosCommandHandler;
use crate::command::handlers::list_topics::ListTopicsCommandHandler;
use crate::command::handlers::load_chat_messages::LoadChatMessagesCommandHandler;
use crate::command::handlers::load_task_messages::LoadTaskMessagesCommandHandler;
use crate::command::handlers::load_topic::LoadTopicCommandHandler;
use crate::command::handlers::memory_crud::MemoryCrudCommandHandler;
use crate::command::handlers::rename_topic::RenameTopicCommandHandler;
use crate::command::handlers::save_session::SaveSessionCommandHandler;
use crate::command::handlers::save_topic::SaveTopicCommandHandler;
@ -27,28 +27,25 @@ use crate::command::handlers::session::SessionCommandHandler;
use crate::command::handlers::stop_execution::StopExecutionCommandHandler;
use crate::command::handlers::switch_topic::SwitchTopicCommandHandler;
use crate::gateway::agent_factory::build_system_prompt_provider;
use crate::protocol::{MediaSummary, WsInbound, WsOutbound, parse_inbound, serialize_outbound};
use crate::protocol::{WsInbound, WsOutbound, MediaSummary, parse_inbound, serialize_outbound};
use crate::storage::persistent_session_id;
use crate::tools::task::repository::TaskRepository;
use crate::utils::current_timestamp;
use axum::extract::Query;
use crate::tools::task::types::TaskSessionState;
use axum::extract::State;
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::response::Response;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use futures_util::{SinkExt, StreamExt};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
const WS_CHANNEL_NAME: &str = "websocket";
/// Default media directory for WebSocket uploads
fn default_ws_media_dir() -> PathBuf {
let home = crate::platform::picobot_home_dir();
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".picobot").join("media").join("ws")
}
@ -71,9 +68,7 @@ fn build_media_filename(media_type: &str, file_name: Option<&str>) -> String {
/// Process attachments with base64 content: save to local file and return MediaItem with correct path
/// Keeps content_base64 for frontend display/download
fn process_attachments_with_base64(
attachments: Vec<MediaSummary>,
) -> Result<Vec<MediaItem>, AgentError> {
fn process_attachments_with_base64(attachments: Vec<MediaSummary>) -> Result<Vec<MediaItem>, AgentError> {
if attachments.is_empty() {
return Ok(Vec::new());
}
@ -87,16 +82,15 @@ fn process_attachments_with_base64(
.map(|att| {
// If content_base64 exists, save to file and update path
if let Some(base64_content) = &att.content_base64 {
let decoded = STANDARD.decode(base64_content).map_err(|error| {
AgentError::Other(format!("Failed to decode base64: {}", error))
})?;
let decoded = STANDARD
.decode(base64_content)
.map_err(|error| AgentError::Other(format!("Failed to decode base64: {}", error)))?;
let filename = build_media_filename(&att.media_type, att.file_name.as_deref());
let file_path = media_dir.join(&filename);
std::fs::write(&file_path, decoded).map_err(|error| {
AgentError::Other(format!("Failed to write media file: {}", error))
})?;
std::fs::write(&file_path, decoded)
.map_err(|error| AgentError::Other(format!("Failed to write media file: {}", error)))?;
tracing::info!(
filename = %filename,
@ -129,29 +123,7 @@ fn process_attachments_with_base64(
.collect()
}
#[derive(serde::Deserialize)]
pub struct WsAuthQuery {
/// 可选的认证 token浏览器原生 WebSocket 不支持自定义 header通过 query param 传递)
pub token: Option<String>,
}
pub async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<GatewayState>>,
Query(query): Query<WsAuthQuery>,
auth_cfg: Option<axum::Extension<crate::gateway::auth::AuthConfig>>,
) -> Response {
// 若启用了认证auth_cfg 存在且 token 已配置),校验 query param 中的 token
if let Some(axum::Extension(cfg)) = auth_cfg {
if let Some(ref expected) = cfg.token {
let provided = query.token.as_deref();
if !crate::gateway::auth::token_matches(provided, &Some(expected.clone())) {
tracing::warn!("WebSocket connection rejected: missing or invalid token");
return (StatusCode::UNAUTHORIZED, "missing or invalid token").into_response();
}
}
}
pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<Arc<GatewayState>>) -> Response {
ws.on_upgrade(|socket| async {
handle_socket(socket, state).await;
})
@ -164,8 +136,10 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
let store = state.session_manager.store();
// 1. 查询 websocket 和 cli 两个通道的 Sessions兼容旧版本 cli 通道创建的会话)
let mut websocket_sessions = store.list_sessions("websocket", false).unwrap_or_default();
let cli_channel_sessions = store.list_sessions("cli", false).unwrap_or_default();
let mut websocket_sessions = store.list_sessions("websocket", false)
.unwrap_or_default();
let cli_channel_sessions = store.list_sessions("cli", false)
.unwrap_or_default();
websocket_sessions.extend(cli_channel_sessions);
websocket_sessions.sort_by_key(|s| -(s.last_active_at));
@ -206,7 +180,9 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
// 连接建立后立即发送通道列表(合并 websocket + ChannelManager 动态通道)
let channels = state.channel_manager.build_channel_list().await;
let _ = sender.send(WsOutbound::ChannelList { channels }).await;
let _ = sender
.send(WsOutbound::ChannelList { channels })
.await;
// 3. 发送合并后的 Session 列表(已在上面合并了 websocket + cli 通道)
// 如果刚创建了新会话,确保它也在列表中
@ -246,34 +222,13 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
let mut receiver = receiver;
let session_id_for_sender = runtime_session_id.clone();
// 每连接独立的关闭信号writer 超时/错误时 cancel通知主 loop 退出
// 不能用共享的 shutdown_token——那是 CliChannel 级别的cancel 会关闭所有连接
let writer_closed = CancellationToken::new();
let writer_closed_clone = writer_closed.clone();
tokio::spawn(async move {
while let Some(msg) = receiver.recv().await {
if let Ok(text) = serialize_outbound(&msg) {
let send_result = tokio::time::timeout(
std::time::Duration::from_secs(30),
ws_sender.send(WsMessage::Text(text.into())),
)
.await;
match send_result {
Ok(Ok(())) => {}
Ok(Err(_)) => {
#[cfg(debug_assertions)]
tracing::debug!(session_id = %session_id_for_sender, "WebSocket send error");
writer_closed_clone.cancel();
break;
}
Err(_) => {
tracing::warn!(
session_id = %session_id_for_sender,
"WebSocket send timed out after 30s, closing connection"
);
writer_closed_clone.cancel();
break;
}
if ws_sender.send(WsMessage::Text(text.into())).await.is_err() {
#[cfg(debug_assertions)]
tracing::debug!(session_id = %session_id_for_sender, "WebSocket send error");
break;
}
}
}
@ -281,16 +236,11 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
loop {
tokio::select! {
// 监听全局 shutdown 信号(来自 CliChannel::stop()
// 监听 shutdown 信号(来自 CliChannel::stop()
_ = shutdown_token.cancelled() => {
tracing::info!(session_id = %current_session_id, "WebSocket shutdown signal received, closing connection");
break;
}
// 监听 writer 退出信号writer 超时或错误,仅关闭当前连接)
_ = writer_closed.cancelled() => {
tracing::info!(session_id = %current_session_id, "WebSocket writer closed, shutting down connection");
break;
}
// 监听 WebSocket 消息
msg = ws_receiver.next() => {
let Some(msg) = msg else {
@ -354,6 +304,7 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
tracing::info!(session_id = %runtime_session_id, current_session_id = %current_session_id, "CLI session ended");
}
async fn handle_inbound(
state: &Arc<GatewayState>,
sender: &mpsc::Sender<WsOutbound>,
@ -443,11 +394,7 @@ async fn handle_inbound(
let store = state.session_manager.store();
let skills = state.session_manager.skills();
let skills_for_handler = skills.clone();
let provider_config = state
.config
.read()
.await
.get_provider_config("default")
let provider_config = state.config.read().await.get_provider_config("default")
.map_err(|e| AgentError::Other(e.to_string()))?;
let prompt_repository = state.session_manager.store().clone();
@ -470,13 +417,9 @@ async fn handle_inbound(
// 注册 list_sessions 处理器
router.register(Box::new(ListSessionsCommandHandler::new(store.clone())));
// 注册 list_sessions_by_channel 处理器
router.register(Box::new(ListSessionsByChannelCommandHandler::new(
store.clone(),
)));
router.register(Box::new(ListSessionsByChannelCommandHandler::new(store.clone())));
// 注册 list_channels 处理器
router.register(Box::new(ListChannelsCommandHandler::new(Arc::new(
state.channel_manager.clone(),
))));
router.register(Box::new(ListChannelsCommandHandler::new(Arc::new(state.channel_manager.clone()))));
// 注册 list_topics 处理器
router.register(Box::new(ListTopicsCommandHandler::new(store.clone())));
// 注册 switch_topic 处理器
@ -517,9 +460,7 @@ async fn handle_inbound(
let metadata = router.metadata_arc();
router.register(Box::new(HelpCommandHandler::new(metadata)));
// 注册 list_scheduler_jobs 处理器
router.register(Box::new(ListSchedulerJobsCommandHandler::new(
store.clone(),
)));
router.register(Box::new(ListSchedulerJobsCommandHandler::new(store.clone())));
// 注册 list_memories 处理器
router.register(Box::new(ListMemoriesCommandHandler::new(store.clone())));
// 注册 list_skills 处理器
@ -583,118 +524,66 @@ async fn handle_inbound(
*current_topic_id = Some(topic_id.clone());
// 加载并发送该话题的历史消息
if let Err(e) = send_topic_history(
&store,
current_session_id,
topic_id,
sender,
&state.task_repository,
)
.await
{
if let Err(e) = send_topic_history(&store, current_session_id, topic_id, sender, &state.task_repository).await {
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send topic history");
}
}
// 加载子智能体任务消息
if let Some(task_session_id) = response.metadata.get("task_session_id") {
// 提前提取 task_id用于给历史消息打标记
let task_id = response
.metadata
.get("task_id")
.cloned()
.unwrap_or_default();
if let Err(e) = send_task_messages(
&store,
task_session_id,
sender,
Some(task_id.clone()),
Some(&state.task_repository),
)
.await
{
let task_id = response.metadata.get("task_id").cloned().unwrap_or_default();
if let Err(e) = send_task_messages(&store, task_session_id, sender, Some(task_id.clone()), Some(&state.task_repository)).await {
tracing::warn!(error = %e, task_session_id = %task_session_id, "Failed to send task messages");
}
// 发送 TaskMessagesLoaded 元数据
let description = response
.metadata
.get("task_description")
.cloned()
.unwrap_or_default();
let subagent_type = response
.metadata
.get("task_subagent_type")
.cloned()
.unwrap_or_default();
let status = response
.metadata
.get("task_status")
.cloned()
.unwrap_or_default();
let description = response.metadata.get("task_description").cloned().unwrap_or_default();
let subagent_type = response.metadata.get("task_subagent_type").cloned().unwrap_or_default();
let status = response.metadata.get("task_status").cloned().unwrap_or_default();
let summary = response.metadata.get("task_summary").cloned();
let token_stats = response.metadata.get("task_token_stats").and_then(|json| {
serde_json::from_str::<crate::protocol::TopicTokenStats>(json).ok()
});
let _ = sender
.send(WsOutbound::TaskMessagesLoaded {
task_id,
description,
subagent_type,
status,
summary,
token_stats,
})
.await;
let _ = sender.send(WsOutbound::TaskMessagesLoaded {
task_id,
description,
subagent_type,
status,
summary,
}).await;
}
// 处理定时任务列表
if let Some(jobs_json) = response.metadata.get("scheduler_jobs") {
if let Ok(jobs) =
serde_json::from_str::<Vec<crate::protocol::SchedulerJobSummary>>(jobs_json)
{
if let Ok(jobs) = serde_json::from_str::<Vec<crate::protocol::SchedulerJobSummary>>(jobs_json) {
let _ = sender.send(WsOutbound::SchedulerJobList { jobs }).await;
}
}
// 处理技能列表
if let Some(skills_json) = response.metadata.get("skills") {
if let Ok(skills) =
serde_json::from_str::<Vec<crate::protocol::SkillSummary>>(skills_json)
{
if let Ok(skills) = serde_json::from_str::<Vec<crate::protocol::SkillSummary>>(skills_json) {
let _ = sender.send(WsOutbound::SkillList { skills }).await;
}
}
// 处理 Todo 列表
if let Some(todos_json) = response.metadata.get("todos") {
if let Ok(todos) =
serde_json::from_str::<Vec<crate::protocol::TodoItemSummary>>(todos_json)
{
let scope_key = response
.metadata
.get("todos_scope_key")
.cloned()
.unwrap_or_default();
tracing::debug!(todo_count = todos.len(), %scope_key, "list_todos command response");
if let Ok(todos) = serde_json::from_str::<Vec<crate::protocol::TodoItemSummary>>(todos_json) {
let scope_key = response.metadata.get("todos_scope_key").cloned().unwrap_or_default();
tracing::info!(todo_count = todos.len(), %scope_key, "list_todos command response");
let _ = sender.send(WsOutbound::TodoList { todos, scope_key }).await;
}
}
// 处理记忆列表
if let Some(memories_json) = response.metadata.get("memories") {
if let Ok(memories) =
serde_json::from_str::<Vec<crate::protocol::MemorySummary>>(memories_json)
{
if let Ok(memories) = serde_json::from_str::<Vec<crate::protocol::MemorySummary>>(memories_json) {
let _ = sender.send(WsOutbound::MemoryList { memories }).await;
}
}
// 记忆 CRUD 后自动刷新列表
if response.metadata.get("memory_updated").map(|v| v.as_str()) == Some("true") {
if let Ok(records) =
store.list_memories_for_scope("user", crate::storage::GLOBAL_SCOPE_KEY)
{
if let Ok(records) = store.list_memories_for_scope("user", crate::storage::GLOBAL_SCOPE_KEY) {
let memories: Vec<crate::protocol::MemorySummary> = records
.into_iter()
.filter(|m| m.namespace != "_meta")
@ -713,17 +602,15 @@ async fn handle_inbound(
// 处理加载聊天消息请求
if let Some(load_chat_id) = response.metadata.get("load_chat_id") {
let load_chat_channel = response
.metadata
.get("load_chat_channel")
let load_chat_channel = response.metadata.get("load_chat_channel")
.cloned()
.unwrap_or_default();
// session_id = "{channel}:{chat_id}" (cli channel 例外)
let session_id =
crate::storage::persistent_session_id(&load_chat_channel, load_chat_id);
if let Err(e) =
send_task_messages(&store, &session_id, sender, None, None).await
{
let session_id = crate::storage::persistent_session_id(
&load_chat_channel,
load_chat_id,
);
if let Err(e) = send_task_messages(&store, &session_id, sender, None, None).await {
tracing::warn!(
error = %e,
channel = %load_chat_channel,
@ -736,22 +623,12 @@ async fn handle_inbound(
if current_topic_id.is_none() {
if let Some(topics_json) = response.metadata.get("topics") {
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(
topics_json,
) {
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
Ok(topics) => {
if let Some(first_topic) = topics.first() {
let topic_id = first_topic.topic_id.clone();
*current_topic_id = Some(topic_id.clone());
if let Err(e) = send_topic_history(
&store,
current_session_id,
&topic_id,
sender,
&state.task_repository,
)
.await
{
if let Err(e) = send_topic_history(&store, current_session_id, &topic_id, sender, &state.task_repository).await {
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send initial topic history");
}
}
@ -785,6 +662,13 @@ async fn handle_inbound(
}
}
fn current_timestamp() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
fn resolve_ws_sender_id(sender_id: Option<&str>, runtime_session_id: &str) -> String {
sender_id
.map(str::trim)
@ -802,21 +686,10 @@ async fn send_topic_history(
task_repository: &Arc<dyn TaskRepository>,
) -> Result<(), Box<dyn std::error::Error>> {
// 加载话题消息,按 session_id 过滤,避免混入子智能体消息
let messages = store.load_messages_for_topic_full(topic_id, Some(session_id))?;
let messages = store.load_messages_for_topic(topic_id, Some(session_id))?;
tracing::info!(topic_id = %topic_id, message_count = messages.len(), "Sending topic history");
// 收集已有 tool_result 的 tool_call_id 集合,用于判断任务是否已有结果
let mut tool_call_ids_with_results: std::collections::HashSet<String> =
std::collections::HashSet::new();
for msg in &messages {
if msg.role == "tool" {
if let Some(ref tcid) = msg.tool_call_id {
tool_call_ids_with_results.insert(tcid.clone());
}
}
}
// 将消息转换为 WsOutbound 并发送
for msg in messages {
for outbound in chat_message_to_ws_outbound(&msg) {
@ -824,9 +697,9 @@ async fn send_topic_history(
}
}
// 查询该话题下所有子智能体任务,补发 TaskStarted 事件
// 查询该话题下所有运行中的子智能体任务,补发 TaskStarted 事件
// 解决页面刷新后 navigateToTaskId 丢失的问题
let tasks = match task_repository.list_tasks_for_topic(topic_id).await {
let running_tasks = match task_repository.list_tasks_for_topic(topic_id).await {
Ok(tasks) => tasks,
Err(e) => {
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to list tasks for topic");
@ -834,40 +707,28 @@ async fn send_topic_history(
}
};
for task in tasks {
// 判断是否需要补发 TaskStarted
// - 如果该任务的 tool_call_id 已有对应的 tool_result前端会显示结果不需要补发
// - 否则Running 状态或已完成但结果未进入历史),补发 TaskStarted 以便前端显示"查看实时进度"
let has_tool_result = task
.tool_call_id
.as_ref()
.map(|tcid| tool_call_ids_with_results.contains(tcid))
.unwrap_or(false);
if has_tool_result {
continue;
for task in running_tasks {
if task.state == TaskSessionState::Running {
// 判断是否为孙智能体parent_session_id 以 "sub:" 开头表示父会话是子智能体
let parent_task_id = extract_parent_task_id(&task);
tracing::info!(
task_id = %task.id,
description = %task.description,
parent_task_id = ?parent_task_id,
"Re-sending TaskStarted for running task after topic history load"
);
let _ = sender
.send(WsOutbound::TaskStarted {
task_id: task.id.clone(),
description: task.description.clone(),
subagent_type: task.subagent_type.clone(),
topic_id: Some(topic_id.to_string()),
parent_task_id,
tool_call_id: None,
})
.await;
}
// 判断是否为孙智能体parent_session_id 以 "sub:" 开头表示父会话是子智能体
let parent_task_id = extract_parent_task_id(&task);
tracing::info!(
task_id = %task.id,
description = %task.description,
parent_task_id = ?parent_task_id,
tool_call_id = ?task.tool_call_id,
state = ?task.state,
"Re-sending TaskStarted for task without tool_result after topic history load"
);
let _ = sender
.send(WsOutbound::TaskStarted {
task_id: task.id.clone(),
description: task.description.clone(),
subagent_type: task.subagent_type.clone(),
topic_id: Some(topic_id.to_string()),
parent_task_id,
tool_call_id: task.tool_call_id.clone(),
})
.await;
}
Ok(())
@ -885,17 +746,6 @@ async fn send_task_messages(
tracing::info!(session_id = %session_id, message_count = messages.len(), "Sending task messages");
// 收集已有 tool_result 的 tool_call_id 集合,用于判断子任务是否已有结果
let mut tool_call_ids_with_results: std::collections::HashSet<String> =
std::collections::HashSet::new();
for msg in &messages {
if msg.role == "tool" {
if let Some(ref tcid) = msg.tool_call_id {
tool_call_ids_with_results.insert(tcid.clone());
}
}
}
for msg in messages {
let mut outbounds = chat_message_to_ws_outbound(&msg);
if let Some(ref task_id) = subagent_task_id {
@ -914,33 +764,23 @@ async fn send_task_messages(
match repo.list_tasks_for_session(session_id).await {
Ok(child_tasks) => {
for child in child_tasks {
// 如果该子任务的 tool_call_id 已有对应的 tool_result前端会显示结果不需要补发
let has_tool_result = child
.tool_call_id
.as_ref()
.map(|tcid| tool_call_ids_with_results.contains(tcid))
.unwrap_or(false);
if has_tool_result {
continue;
if child.state == TaskSessionState::Running {
tracing::info!(
child_task_id = %child.id,
parent_task_id = %parent_task_id,
"Re-sending TaskStarted for child task after sub-agent view re-enter"
);
let _ = sender
.send(WsOutbound::TaskStarted {
task_id: child.id.clone(),
description: child.description.clone(),
subagent_type: child.subagent_type.clone(),
topic_id: child.parent_topic_id.clone(),
parent_task_id: Some(parent_task_id.clone()),
tool_call_id: None,
})
.await;
}
tracing::info!(
child_task_id = %child.id,
parent_task_id = %parent_task_id,
tool_call_id = ?child.tool_call_id,
state = ?child.state,
"Re-sending TaskStarted for child task without tool_result after sub-agent view re-enter"
);
let _ = sender
.send(WsOutbound::TaskStarted {
task_id: child.id.clone(),
description: child.description.clone(),
subagent_type: child.subagent_type.clone(),
topic_id: child.parent_topic_id.clone(),
parent_task_id: Some(parent_task_id.clone()),
tool_call_id: child.tool_call_id.clone(),
})
.await;
}
}
Err(e) => {
@ -1025,15 +865,10 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
let media_type = mime_type
.as_ref()
.map(|m| {
if m.starts_with("image/") {
"image"
} else if m.starts_with("audio/") {
"audio"
} else if m.starts_with("video/") {
"video"
} else {
"file"
}
if m.starts_with("image/") { "image" }
else if m.starts_with("audio/") { "audio" }
else if m.starts_with("video/") { "video" }
else { "file" }
})
.unwrap_or("file");
@ -1057,8 +892,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
"assistant" => {
if let Some(tool_calls) = &msg.tool_calls {
let mut outbound = Vec::new();
let has_content_or_reasoning =
!msg.content.trim().is_empty() || msg.reasoning_content.is_some();
let has_content_or_reasoning = !msg.content.trim().is_empty() || msg.reasoning_content.is_some();
if has_content_or_reasoning {
outbound.push(WsOutbound::AssistantResponse {
id: msg.id.clone(),
@ -1073,11 +907,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
});
}
// AssistantResponse 已携带 reasoning 时ToolCall 不再重复
let tc_reasoning = if has_content_or_reasoning {
None
} else {
msg.reasoning_content.clone()
};
let tc_reasoning = if has_content_or_reasoning { None } else { msg.reasoning_content.clone() };
for tool_call in tool_calls {
outbound.push(WsOutbound::ToolCall {
id: tool_call.id.clone(),
@ -1110,16 +940,10 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
}
}
"tool" => {
let tool_state = msg
.tool_state
.as_ref()
.unwrap_or(&ToolMessageState::Completed);
let tool_state = msg.tool_state.as_ref().unwrap_or(&ToolMessageState::Completed);
match tool_state {
ToolMessageState::Completed => vec![WsOutbound::ToolResult {
id: msg
.tool_call_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
id: msg.tool_call_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
tool_call_id: msg.tool_call_id.clone().unwrap_or_default(),
tool_name: msg.tool_name.clone().unwrap_or_default(),
content: msg.content.clone(),
@ -1130,10 +954,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
timestamp: Some(msg.timestamp / 1000),
}],
ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending {
id: msg
.tool_call_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
id: msg.tool_call_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
tool_call_id: msg.tool_call_id.clone().unwrap_or_default(),
tool_name: msg.tool_name.clone().unwrap_or_default(),
content: msg.content.clone(),
@ -1162,7 +983,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
#[cfg(test)]
mod tests {
use super::{build_media_filename, process_attachments_with_base64, resolve_ws_sender_id};
use super::{resolve_ws_sender_id, build_media_filename, process_attachments_with_base64};
use crate::protocol::MediaSummary;
use base64::{Engine as _, engine::general_purpose::STANDARD};

View File

@ -20,6 +20,5 @@ pub mod scheduler;
pub mod skills;
pub mod storage;
pub mod text;
pub mod tools;
pub mod topic_description;
pub mod utils;
pub mod tools;

View File

@ -3,7 +3,7 @@ use chrono_tz::Tz;
use std::path::PathBuf;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::{
fmt, fmt::time::FormatTime, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter,
EnvFilter, fmt, fmt::time::FormatTime, layer::SubscriberExt, util::SubscriberInitExt,
};
#[derive(Clone, Copy, Debug)]
@ -28,13 +28,13 @@ impl FormatTime for ConfiguredTimestamp {
/// Get the default log directory path: ~/.picobot/logs
pub fn get_default_log_dir() -> PathBuf {
let home = crate::platform::picobot_home_dir();
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".picobot").join("logs")
}
/// Get the default config file path: ~/.picobot/config.json
pub fn get_default_config_path() -> PathBuf {
let home = crate::platform::picobot_home_dir();
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".picobot").join("config.json")
}
@ -45,9 +45,7 @@ pub fn init_logging(timezone: Tz) {
static INIT: Once = Once::new();
let mut initialized = false;
INIT.call_once(|| {
initialized = true;
});
INIT.call_once(|| { initialized = true; });
if !initialized {
// Already initialized (e.g. after gateway restart), skip
return;

View File

@ -40,14 +40,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
if std::env::args().len() <= 1 {
cmd.print_help()?;
println!();
return Ok(());
return Ok(())
}
match Command::parse() {
Command::Init {
force,
skip_channels,
} => {
Command::Init { force, skip_channels } => {
let mut wizard = picobot::cli::InitWizard::new();
wizard.run(force, skip_channels).await?;
}
@ -59,12 +56,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
picobot::client::run(&url).await?;
}
Command::Gateway { host, port } => {
let mut should_restart = true;
while should_restart {
should_restart = picobot::gateway::run(host.clone(), port).await?;
if should_restart {
tracing::info!("Gateway restarting...");
loop {
let should_restart = picobot::gateway::run(host.clone(), port).await?;
if !should_restart {
break;
}
tracing::info!("Gateway restarting...");
}
}
}

View File

@ -7,20 +7,17 @@
//! - Dynamically registers MCP tools via the Tool trait adapter
use std::collections::HashMap;
use std::sync::Arc;
use parking_lot::Mutex;
use std::sync::{Arc, Mutex};
use tokio::sync::RwLock;
use http::{HeaderName, HeaderValue};
use rmcp::{
RoleClient, ServiceExt,
model::{CallToolRequestParams, CallToolResult, ServerInfo, Tool},
RoleClient, ServiceExt,
service::RunningService,
transport::TokioChildProcess,
transport::streamable_http_client::{
StreamableHttpClientTransport, StreamableHttpClientTransportConfig,
},
transport::streamable_http_client::{StreamableHttpClientTransport, StreamableHttpClientTransportConfig},
};
use http::{HeaderName, HeaderValue};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
@ -32,7 +29,7 @@ use std::process::Stdio;
/// Resolve ${ENV_VAR} placeholders in a value string
fn resolve_env_placeholders_in_value(value: &str) -> String {
let re = regex::Regex::new(r"\$\{([A-Z_][A-Z0-9_]*)\}").expect("invalid regex");
re.replace_all(value, |caps: &regex::Captures<'_>| {
re.replace_all(value, |caps: &regex::Captures| {
let var_name = &caps[1];
env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
})
@ -67,11 +64,7 @@ fn resolve_command_path(command: &str) -> Option<PathBuf> {
}
// If it has a path separator (relative path), don't search PATH
if path
.parent()
.map(|p| !p.as_os_str().is_empty())
.unwrap_or(false)
{
if path.parent().map(|p| !p.as_os_str().is_empty()).unwrap_or(false) {
return None;
}
@ -215,10 +208,7 @@ impl McpClientManager {
"Failed to connect to MCP server after all retries"
);
// Record error for status reporting
self.connection_errors
.write()
.await
.insert(key.clone(), e.to_string());
self.connection_errors.write().await.insert(key.clone(), e.to_string());
failed += 1;
} else {
// Clear any previous error on successful connection
@ -248,23 +238,18 @@ impl McpClientManager {
}
/// Connect to a single MCP server
pub async fn connect_server(
&self,
key: &str,
config: &McpServerConfig,
) -> anyhow::Result<McpServerInfo> {
pub async fn connect_server(&self, key: &str, config: &McpServerConfig) -> anyhow::Result<McpServerInfo> {
let effective_name = config.effective_name(key);
tracing::info!(key = %key, name = %effective_name, transport_type = %config.transport_type, "Connecting to MCP server");
let transport = config.transport().map_err(|e| anyhow::anyhow!("{}", e))?;
let client = match transport {
McpTransportConfig::Stdio {
command,
args,
env,
cwd,
} => self.connect_stdio(key, &command, &args, &env, &cwd).await?,
McpTransportConfig::Http { url, headers } => self.connect_http(&url, &headers).await?,
McpTransportConfig::Stdio { command, args, env, cwd } => {
self.connect_stdio(key, &command, &args, &env, &cwd).await?
}
McpTransportConfig::Http { url, headers } => {
self.connect_http(&url, &headers).await?
}
};
// Get server info (returns Option<Arc<ServerInfo>> in rmcp 1.8+)
@ -314,10 +299,7 @@ impl McpClientManager {
if resolved_command.is_none() {
let path = std::path::Path::new(command);
let is_absolute = path.is_absolute();
let has_separator = path
.parent()
.map(|p| !p.as_os_str().is_empty())
.unwrap_or(false);
let has_separator = path.parent().map(|p| !p.as_os_str().is_empty()).unwrap_or(false);
if !is_absolute && !has_separator && path.extension().is_none() {
// Bare name not found on Windows
let path_env = std::env::var("PATH").unwrap_or_default();
@ -326,8 +308,7 @@ impl McpClientManager {
Current PATH: {}. \
Suggestion: use the full absolute path to the executable, \
or ensure the tool is installed and its directory is in PATH.",
command,
path_env
command, path_env
));
}
}
@ -403,8 +384,7 @@ impl McpClientManager {
);
}
// Also collect into the shared buffer (cap at 50 lines)
{
let mut buf = stderr_lines_for_task.lock();
if let Ok(mut buf) = stderr_lines_for_task.lock() {
if buf.len() < 50 {
buf.push(line);
}
@ -416,14 +396,17 @@ impl McpClientManager {
// Use default client handler (empty tuple)
let client = ().serve(transport).await.map_err(|e| {
// Include stderr summary in error if available
let stderr_summary = {
let buf = stderr_lines.lock();
if buf.is_empty() {
String::new()
} else {
format!("\nstderr:\n {}", buf.join("\n "))
}
};
let stderr_summary = stderr_lines
.lock()
.ok()
.map(|buf| {
if buf.is_empty() {
String::new()
} else {
format!("\nstderr:\n {}", buf.join("\n "))
}
})
.unwrap_or_default();
anyhow::anyhow!(
"Failed to establish MCP stdio connection '{}': {}{}",
effective_command.display(),
@ -433,8 +416,7 @@ impl McpClientManager {
})?;
// Track that we have a stdio (child process) connection
self.stdio_client_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.stdio_client_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(client)
}
@ -464,21 +446,25 @@ impl McpClientManager {
.iter()
.filter_map(|(key, value)| {
// Try to parse header name and value
HeaderName::try_from(key.clone()).ok().and_then(|name| {
HeaderValue::try_from(value.clone())
.ok()
.map(|val| (name, val))
})
HeaderName::try_from(key.clone())
.ok()
.and_then(|name| {
HeaderValue::try_from(value.clone())
.ok()
.map(|val| (name, val))
})
})
.collect();
// Create transport config with custom headers
let config =
StreamableHttpClientTransportConfig::with_uri(url).custom_headers(custom_headers);
let config = StreamableHttpClientTransportConfig::with_uri(url)
.custom_headers(custom_headers);
// Create transport using reqwest client (default)
let transport =
StreamableHttpClientTransport::with_client(reqwest::Client::default(), config);
let transport = StreamableHttpClientTransport::with_client(
reqwest::Client::default(),
config,
);
// Connect
let client = ().serve(transport).await?;
@ -511,9 +497,7 @@ impl McpClientManager {
info_map
.values()
.flat_map(|info| {
info.tools
.iter()
.map(|tool| (info.key.clone(), tool.clone()))
info.tools.iter().map(|tool| (info.key.clone(), tool.clone()))
})
.collect()
}
@ -577,9 +561,7 @@ impl McpClientManager {
/// gateway restart where old MCP processes may still be running when
/// new ones start.
pub async fn shutdown_all(&self) -> anyhow::Result<()> {
let stdio_count = self
.stdio_client_count
.load(std::sync::atomic::Ordering::SeqCst);
let stdio_count = self.stdio_client_count.load(std::sync::atomic::Ordering::SeqCst);
// Drop all clients (triggers cancellation + graceful shutdown in rmcp)
self.disconnect_all().await?;
@ -596,8 +578,7 @@ impl McpClientManager {
);
tokio::time::sleep(std::time::Duration::from_secs(6)).await;
tracing::info!("MCP child process cleanup wait complete");
self.stdio_client_count
.store(0, std::sync::atomic::Ordering::SeqCst);
self.stdio_client_count.store(0, std::sync::atomic::Ordering::SeqCst);
}
Ok(())
@ -785,10 +766,7 @@ impl McpInitializer {
///
/// This should be called after the gateway is ready to accept tools.
/// Waits for connections to complete before registering tools.
pub async fn register_tools(
&mut self,
registry: &mut crate::tools::ToolRegistry,
) -> anyhow::Result<()> {
pub async fn register_tools(&mut self, registry: &mut crate::tools::ToolRegistry) -> anyhow::Result<()> {
if let Some(manager) = self.manager.clone() {
// Wait for connections to complete first
self.wait_for_connections().await?;
@ -811,15 +789,9 @@ mod tests {
// On all platforms, this should return None (either because it's not found,
// or because non-Windows always returns None)
#[cfg(windows)]
assert!(
result.is_none(),
"Expected None for nonexistent command on Windows"
);
assert!(result.is_none(), "Expected None for nonexistent command on Windows");
#[cfg(not(windows))]
assert!(
result.is_none(),
"Expected None on non-Windows (always returns None)"
);
assert!(result.is_none(), "Expected None on non-Windows (always returns None)");
}
#[test]
@ -834,10 +806,7 @@ mod tests {
#[cfg(windows)]
assert!(result.is_some(), "Expected to find {} on Windows", cmd);
#[cfg(not(windows))]
assert!(
result.is_none(),
"Expected None on non-Windows for absolute path"
);
assert!(result.is_none(), "Expected None on non-Windows for absolute path");
}
#[test]
@ -851,10 +820,7 @@ mod tests {
fn test_resolve_command_path_finds_known_windows_binary() {
// cmd.exe should always be in C:\Windows\System32 which is in PATH
let result = resolve_command_path("cmd");
assert!(
result.is_some(),
"Expected to find cmd.exe via PATH on Windows"
);
assert!(result.is_some(), "Expected to find cmd.exe via PATH on Windows");
if let Some(p) = result {
assert!(
p.to_string_lossy().to_lowercase().ends_with("cmd.exe"),

View File

@ -203,11 +203,7 @@ mod tests {
let config = McpServerConfig::stdio(
"filesystem",
"npx",
vec![
"-y".to_string(),
"@modelcontextprotocol/server-filesystem".to_string(),
"/tmp".to_string(),
],
vec!["-y".to_string(), "@modelcontextprotocol/server-filesystem".to_string(), "/tmp".to_string()],
);
assert_eq!(config.name, Some("filesystem".to_string()));
@ -271,14 +267,11 @@ mod tests {
match transport {
McpTransportConfig::Stdio { command, args, .. } => {
assert_eq!(command, "npx");
assert_eq!(
args,
vec![
"-y",
"@modelcontextprotocol/server-filesystem",
"/home/user"
]
);
assert_eq!(args, vec![
"-y",
"@modelcontextprotocol/server-filesystem",
"/home/user"
]);
}
_ => panic!("Expected stdio transport"),
}
@ -286,18 +279,12 @@ mod tests {
// Check WebSearch server (streamableHttp)
let websearch = config.mcp_servers.get("WebSearch").unwrap();
assert_eq!(websearch.transport_type, "streamableHttp");
assert_eq!(
websearch.name,
Some("AliyunBailianMCP_WebSearch".to_string())
);
assert_eq!(websearch.name, Some("AliyunBailianMCP_WebSearch".to_string()));
assert!(websearch.is_active);
let transport = websearch.transport().unwrap();
match transport {
McpTransportConfig::Http { url, headers } => {
assert_eq!(
url,
"https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp"
);
assert_eq!(url, "https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp");
assert_eq!(
headers.get("Authorization"),
Some(&"Bearer ${DASHSCOPE_API_KEY}".to_string())
@ -398,31 +385,17 @@ mod tests {
#[test]
fn test_http_type_alias() {
// Both "http" and "streamableHttp" should work
let json_http =
r#"{"mcpServers": {"test": {"type": "http", "baseUrl": "http://localhost"}}}"#;
let json_http = r#"{"mcpServers": {"test": {"type": "http", "baseUrl": "http://localhost"}}}"#;
let json_streamable = r#"{"mcpServers": {"test": {"type": "streamableHttp", "baseUrl": "http://localhost"}}}"#;
let config_http: McpConfig = serde_json::from_str(json_http).unwrap();
let config_streamable: McpConfig = serde_json::from_str(json_streamable).unwrap();
let transport_http = config_http
.mcp_servers
.get("test")
.unwrap()
.transport()
.unwrap();
let transport_streamable = config_streamable
.mcp_servers
.get("test")
.unwrap()
.transport()
.unwrap();
let transport_http = config_http.mcp_servers.get("test").unwrap().transport().unwrap();
let transport_streamable = config_streamable.mcp_servers.get("test").unwrap().transport().unwrap();
assert!(matches!(transport_http, McpTransportConfig::Http { .. }));
assert!(matches!(
transport_streamable,
McpTransportConfig::Http { .. }
));
assert!(matches!(transport_streamable, McpTransportConfig::Http { .. }));
}
#[test]

View File

@ -11,12 +11,10 @@
//!
//! MCP is completely optional and disabled by default.
pub mod client;
pub mod config;
pub mod client;
pub mod tool_adapter;
pub use client::{
McpClient, McpClientManager, McpInitializer, McpServerInfo, McpServerStatus, McpStatusResponse,
};
pub use config::{McpConfig, McpServerConfig, McpTransportConfig};
pub use client::{McpClientManager, McpClient, McpServerInfo, McpInitializer, McpServerStatus, McpStatusResponse};
pub use tool_adapter::{McpToolWrapper, register_mcp_tools};

View File

@ -8,22 +8,6 @@ use rmcp::model::Tool;
use crate::mcp::client::McpClientManager;
use crate::tools::traits::{Tool as PicoBotTool, ToolResult};
/// Sanitize a tool name to comply with OpenAI's function name pattern `^[a-zA-Z0-9_-]+$`.
/// Any character outside [a-zA-Z0-9_-] (e.g. '.', ':', '/') is replaced with '_'.
/// This is applied to the LLM-facing name only; `McpToolWrapper` retains the original
/// `server_key` and `tool_name` for routing tool calls to the correct MCP server.
fn sanitize_tool_name(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect()
}
/// Wrapper that adapts an MCP tool to PicoBot's Tool trait
#[derive(Clone)]
pub struct McpToolWrapper {
@ -41,20 +25,13 @@ pub struct McpToolWrapper {
impl McpToolWrapper {
/// Create a new tool wrapper
pub fn new(manager: Arc<McpClientManager>, server_key: String, tool_info: Tool) -> Self {
pub fn new(
manager: Arc<McpClientManager>,
server_key: String,
tool_info: Tool,
) -> Self {
let tool_name = tool_info.name.clone().into_owned();
let raw_name = format!("mcp_{}_{}", server_key, tool_name);
let full_name = sanitize_tool_name(&raw_name);
if full_name != raw_name {
tracing::warn!(
original = %raw_name,
sanitized = %full_name,
server_key = %server_key,
tool_name = %tool_name,
"MCP tool name contained characters invalid for OpenAI function name pattern \
(^[a-zA-Z0-9_-]+$); sanitized to comply"
);
}
let full_name = format!("mcp_{}_{}", server_key, tool_name);
Self {
manager,
server_key,
@ -151,7 +128,11 @@ pub async fn register_mcp_tools(
let all_tools = manager.all_tools().await;
for (server_key, tool_info) in all_tools {
let wrapper = McpToolWrapper::new(manager.clone(), server_key.clone(), tool_info);
let wrapper = McpToolWrapper::new(
manager.clone(),
server_key.clone(),
tool_info,
);
tracing::info!(
name = %wrapper.name(),
@ -172,7 +153,10 @@ mod tests {
#[test]
fn test_extract_text_content_from_text() {
let result = CallToolResult::success(vec![Content::text("Hello"), Content::text("World")]);
let result = CallToolResult::success(vec![
Content::text("Hello"),
Content::text("World"),
]);
let text = extract_text_content(&result);
assert_eq!(text, "Hello\nWorld");
@ -191,11 +175,10 @@ mod tests {
fn test_mcp_tool_wrapper_name() {
let manager = Arc::new(McpClientManager::new());
// Create a minimal tool info using rmcp's Tool constructor
let schema: serde_json::Map<String, serde_json::Value> =
serde_json::json!({"type": "object"})
.as_object()
.unwrap()
.clone();
let schema: serde_json::Map<String, serde_json::Value> = serde_json::json!({"type": "object"})
.as_object()
.unwrap()
.clone();
let tool_info = Tool::new("echo", "Echo tool", schema);
let wrapper = McpToolWrapper::new(manager, "filesystem".to_string(), tool_info);
@ -203,45 +186,4 @@ mod tests {
assert_eq!(wrapper.original_name(), "echo");
assert_eq!(wrapper.server_key(), "filesystem");
}
#[test]
fn test_mcp_tool_wrapper_name_sanitizes_invalid_chars() {
// OpenAI requires function names to match ^[a-zA-Z0-9_-]+$.
// server_key and tool_name from MCP servers may contain '.', ':', '/', etc.
let manager = Arc::new(McpClientManager::new());
let schema: serde_json::Map<String, serde_json::Value> =
serde_json::json!({"type": "object"})
.as_object()
.unwrap()
.clone();
let tool_info = Tool::new("tools.list:read", "Namespaced tool", schema);
let wrapper = McpToolWrapper::new(manager, "github.api".to_string(), tool_info);
// mcp_github.api_tools.list:read → mcp_github_api_tools_list_read
assert_eq!(wrapper.name(), "mcp_github_api_tools_list_read");
// Original identifiers preserved for routing
assert_eq!(wrapper.original_name(), "tools.list:read");
assert_eq!(wrapper.server_key(), "github.api");
}
#[test]
fn test_sanitize_tool_name_matches_openai_pattern() {
let re = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
for input in [
"mcp_filesystem_echo",
"mcp_github.api_tools.list:read",
"mcp_a/b@c d",
"mcp_中文_tool",
] {
let sanitized = sanitize_tool_name(input);
assert!(
re.is_match(&sanitized),
"sanitized name {:?} (from {:?}) does not match OpenAI pattern",
sanitized,
input
);
}
// Empty stays empty
assert_eq!(sanitize_tool_name(""), "");
}
}

View File

@ -300,8 +300,7 @@ mod tests {
fn test_truncate_args_utf8_boundary() {
// Test that truncation respects UTF-8 character boundaries
// Each Chinese character is 3 bytes in UTF-8
let long_args =
serde_json::json!({"key": "测试测试测试测试测试测试测试测试测试测试测试测试测试测试"});
let long_args = serde_json::json!({"key": "测试测试测试测试测试测试测试测试测试测试测试测试测试测试"});
let truncated = truncate_args(&long_args, 50);
assert!(truncated.ends_with("...truncated"));
// Verify the truncated string is valid UTF-8 (no panic occurred)

View File

@ -93,47 +93,22 @@ impl ShellInfo {
}
/// Dangerous command patterns for safety guards.
///
/// Returns patterns filtered by the current platform. Platform-specific
/// rules (e.g. `format` on Windows, `rm` on Unix) are only injected on
/// their target platform to avoid false positives.
pub fn dangerous_command_patterns() -> Vec<String> {
dangerous_command_patterns_for_platform(Platform::current())
}
/// Platform-specific dangerous command patterns.
///
/// Exposed primarily for testing. Callers should prefer
/// [`dangerous_command_patterns`] which auto-detects the platform.
pub fn dangerous_command_patterns_for_platform(platform: Platform) -> Vec<String> {
let mut patterns: Vec<String> = Vec::new();
// Cross-platform: fork bomb
patterns.push(r":\(\)\s*\{.*\};\s*:".to_string());
match platform {
Platform::Unix => {
// Unix dangerous commands
patterns.push(r"\brm\s+-[rf]{1,2}\b".to_string());
patterns.push(r"\bchmod\s+-[Rr]".to_string());
patterns.push(r"\bchown\s+-[Rr]".to_string());
}
Platform::Windows => {
// Windows cmd dangerous commands.
// `format` requires a drive letter (`[a-z]:`) somewhere after it,
// so legitimate uses like `dart format lib/` or
// `pytest --format json` (no drive letter) are not matched.
patterns.push(r"\bformat\s+.*[a-z]:".to_string());
patterns.push(r"\bdel\s+/[fq]\b".to_string());
patterns.push(r"\brmdir\s+/s\b".to_string());
// PowerShell dangerous commands. Patterns are lowercase because
// `guard_command` lowercases the command string before matching.
patterns.push(r"\bremove-item\s+.*-recurse".to_string());
patterns.push(r"\bremove-item\s+.*-force".to_string());
}
}
patterns
vec![
// Unix dangerous commands
r"\brm\s+-[rf]{1,2}\b".to_string(),
r"\bchmod\s+-[Rr]".to_string(),
r"\bchown\s+-[Rr]".to_string(),
// Windows dangerous commands
r"\bdel\s+/[fq]\b".to_string(),
r"\brmdir\s+/s\b".to_string(),
r"\bformat\s+".to_string(),
// PowerShell dangerous commands
r"\bRemove-Item\s+.*-Recurse".to_string(),
r"\bRemove-Item\s+.*-Force".to_string(),
// Fork bomb (cross-platform)
r":\(\)\s*\{.*\};\s*:".to_string(),
]
}
/// Check whether a child process is blocked waiting for stdin input.
@ -151,7 +126,11 @@ pub fn is_process_waiting_on_stdin(pid: u32) -> Option<bool> {
if wchan.is_empty() {
return None;
}
Some(wchan.contains("tty_read") || wchan.contains("n_tty_read") || wchan == "pipe_wait")
Some(
wchan.contains("tty_read")
|| wchan.contains("n_tty_read")
|| wchan == "pipe_wait",
)
}
#[cfg(target_os = "macos")]
{
@ -345,14 +324,6 @@ pub fn home_dir() -> Option<PathBuf> {
.or_else(|| dirs::home_dir())
}
/// 返回 PicoBot 主目录,若无法确定则回退到当前目录 `"."`。
///
/// 包装 [`home_dir`] 并内置 fallback消除各模块重复的
/// `dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))` 模式。
pub fn picobot_home_dir() -> PathBuf {
home_dir().unwrap_or_else(|| PathBuf::from("."))
}
/// Atomically rename a file, handling platform differences.
///
/// On Windows, `fs::rename` fails if the destination exists, so we need to
@ -464,64 +435,9 @@ mod tests {
fn test_dangerous_patterns() {
let patterns = dangerous_command_patterns();
assert!(!patterns.is_empty());
// Cross-platform fork bomb rule is always present
assert!(patterns.iter().any(|p| p.contains(r":\(\)")));
}
#[test]
fn test_dangerous_patterns_unix() {
let patterns = dangerous_command_patterns_for_platform(Platform::Unix);
// Unix-specific rules
// Should contain patterns for both platforms
assert!(patterns.iter().any(|p| p.contains("rm")));
assert!(patterns.iter().any(|p| p.contains("chmod")));
assert!(patterns.iter().any(|p| p.contains("chown")));
// Windows-specific rules must NOT be present on Unix
assert!(!patterns.iter().any(|p| p.contains("format")));
assert!(!patterns.iter().any(|p| p.contains("del")));
assert!(!patterns.iter().any(|p| p.contains("remove-item")));
}
#[test]
fn test_dangerous_patterns_windows() {
let patterns = dangerous_command_patterns_for_platform(Platform::Windows);
// Windows-specific rules
assert!(patterns.iter().any(|p| p.contains("del")));
assert!(patterns.iter().any(|p| p.contains("format")));
assert!(patterns.iter().any(|p| p.contains("remove-item")));
// Unix-specific rules must NOT be present on Windows.
// Use r"\brm\s" to match rm-as-command without matching rmdir.
assert!(!patterns.iter().any(|p| p.contains(r"\brm\s")));
assert!(!patterns.iter().any(|p| p.contains("chmod")));
assert!(!patterns.iter().any(|p| p.contains("chown")));
}
#[test]
fn test_format_pattern_precision() {
let patterns = dangerous_command_patterns_for_platform(Platform::Windows);
let format_pat = patterns
.iter()
.find(|p| p.contains("format"))
.expect("format pattern should exist on Windows");
let re = regex::Regex::new(format_pat).unwrap();
// Helper: guard_command lowercases before matching, so tests must too.
let m = |cmd: &str| re.is_match(&cmd.to_lowercase());
// Truly dangerous commands — should match
assert!(m("format c:"));
assert!(m("format /q c:"));
assert!(m("format d: /fs:ntfs"));
assert!(m("echo ok | format c:"));
assert!(m("echo ok; format c:"));
// Sub-shell invocation must also be caught
assert!(m(r#"cmd /c "format C:""#));
// Legitimate commands containing literal "format " — should NOT match
assert!(!m("dart format lib/"));
assert!(!m("buf format -w"));
assert!(!m("pytest --format json"));
assert!(!m(r#"echo "please format the disk""#));
assert!(!m(r#"git log --pretty=format:"%h""#));
}
#[test]

View File

@ -42,22 +42,6 @@ pub struct TopicSummary {
pub last_active_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Token 用量统计(与 command::handlers::TopicSummary 对应)。
/// 老消息或未触发 LLM 调用的 topic 为 None。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_stats: Option<TopicTokenStats>,
}
/// Topic 维度的 token 统计(与 command::handlers::TopicTokenStats 对应)。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TopicTokenStats {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_prompt_tokens: Option<u32>,
#[serde(default)]
pub context_window_tokens: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -256,7 +240,9 @@ pub enum WsOutbound {
channel_name: Option<String>,
},
#[serde(rename = "channel_list")]
ChannelList { channels: Vec<Channel> },
ChannelList {
channels: Vec<Channel>,
},
#[serde(rename = "topic_list")]
TopicList {
topics: Vec<TopicSummary>,
@ -276,10 +262,7 @@ pub enum WsOutbound {
message_count: i64,
},
#[serde(rename = "session_saved")]
SessionSaved {
session_id: String,
filepath: String,
},
SessionSaved { session_id: String, filepath: String },
#[serde(rename = "task_messages_loaded")]
TaskMessagesLoaded {
task_id: String,
@ -288,17 +271,19 @@ pub enum WsOutbound {
status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
summary: Option<String>,
/// 子代理 session 的 token 用量统计cost 累计 + context 瞬时)。
/// 无 assistant 消息时为 None。前端用于在子代理视图下显示 token 统计。
#[serde(default, skip_serializing_if = "Option::is_none")]
token_stats: Option<TopicTokenStats>,
},
#[serde(rename = "scheduler_job_list")]
SchedulerJobList { jobs: Vec<SchedulerJobSummary> },
SchedulerJobList {
jobs: Vec<SchedulerJobSummary>,
},
#[serde(rename = "memory_list")]
MemoryList { memories: Vec<MemorySummary> },
MemoryList {
memories: Vec<MemorySummary>,
},
#[serde(rename = "skill_list")]
SkillList { skills: Vec<SkillSummary> },
SkillList {
skills: Vec<SkillSummary>,
},
#[serde(rename = "execution_cancelled")]
ExecutionCancelled { message: String },
#[serde(rename = "stream_delta")]

View File

@ -15,8 +15,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
"assistant" => {
if let Some(tool_calls) = &message.tool_calls {
let mut outbound = Vec::new();
let has_content_or_reasoning =
!message.content.trim().is_empty() || message.reasoning_content.is_some();
let has_content_or_reasoning = !message.content.trim().is_empty() || message.reasoning_content.is_some();
if has_content_or_reasoning {
outbound.push(WsOutbound::AssistantResponse {
id: message.id.clone(),
@ -32,11 +31,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
}
// AssistantResponse 已携带 reasoning 时ToolCall 不再重复
let tc_reasoning = if has_content_or_reasoning {
None
} else {
message.reasoning_content.clone()
};
let tc_reasoning = if has_content_or_reasoning { None } else { message.reasoning_content.clone() };
outbound.extend(tool_calls.iter().map(|tool_call| WsOutbound::ToolCall {
id: tool_call.id.clone(),
tool_call_id: tool_call.id.clone(),
@ -71,10 +66,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
.unwrap_or(&ToolMessageState::Completed)
{
ToolMessageState::Completed => vec![WsOutbound::ToolResult {
id: message
.tool_call_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
id: message.tool_call_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
tool_call_id: message.tool_call_id.clone().unwrap_or_default(),
tool_name: message.tool_name.clone().unwrap_or_default(),
content: message.content.clone(),
@ -85,10 +77,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
timestamp: None,
}],
ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending {
id: message
.tool_call_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
id: message.tool_call_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
tool_call_id: message.tool_call_id.clone().unwrap_or_default(),
tool_name: message.tool_name.clone().unwrap_or_default(),
content: message.content.clone(),
@ -118,10 +107,7 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
})
.collect();
vec![WsOutbound::AssistantResponse {
id: message
.message_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
id: message.message_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
content: message.content.clone(),
role: message.role.clone(),
attachments,
@ -190,16 +176,8 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
}],
OutboundEventKind::TaskStarted => vec![WsOutbound::TaskStarted {
task_id: message.metadata.get("task_id").cloned().unwrap_or_default(),
description: message
.metadata
.get("task_description")
.cloned()
.unwrap_or_default(),
subagent_type: message
.metadata
.get("task_subagent_type")
.cloned()
.unwrap_or_default(),
description: message.metadata.get("task_description").cloned().unwrap_or_default(),
subagent_type: message.metadata.get("task_subagent_type").cloned().unwrap_or_default(),
topic_id: message.metadata.get("topic_id").cloned(),
parent_task_id: message.metadata.get("parent_task_id").cloned(),
tool_call_id: message.metadata.get("tool_call_id").cloned(),

View File

@ -2,16 +2,26 @@ use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::OnceLock;
use std::time::Duration;
use super::traits::Usage;
use super::{ChatCompletionRequest, ChatCompletionResponse, LLMProvider, Tool, ToolCall};
use crate::domain::messages::ContentBlock;
use crate::utils::format_error_chain;
const INTERNAL_MODEL_EXTRA_KEYS: &[&str] = &["supported_content_types"];
fn format_error_chain(error: &(dyn std::error::Error + 'static)) -> String {
let mut details = vec![error.to_string()];
let mut current = error.source();
while let Some(source) = current {
details.push(source.to_string());
current = source.source();
}
details.join("\ncaused by: ")
}
fn serialize_content_blocks<S>(
blocks: &[serde_json::Value],
serializer: S,
@ -31,9 +41,7 @@ fn convert_content_blocks(
) -> Vec<serde_json::Value> {
// 检查是否有图片且模型不支持
if !supports_images {
let has_images = blocks
.iter()
.any(|b| matches!(b, ContentBlock::ImageUrl { .. }));
let has_images = blocks.iter().any(|b| matches!(b, ContentBlock::ImageUrl { .. }));
if has_images {
let image_count = blocks
@ -71,8 +79,10 @@ fn convert_content_blocks(
// 添加通知文本块
if !notices.is_empty() {
let notice_text =
format!("[系统提示] 以下图片未能成功入模:\n{}", notices.join("\n"));
let notice_text = format!(
"[系统提示] 以下图片未能成功入模:\n{}",
notices.join("\n")
);
converted_blocks.push(serde_json::json!({ "type": "text", "text": notice_text }));
}
@ -94,10 +104,10 @@ fn convert_content_blocks(
fn convert_image_url_to_anthropic(url: &str) -> serde_json::Value {
// data:image/png;base64,... -> Anthropic image block
static RE: OnceLock<regex::Regex> = OnceLock::new();
let re =
RE.get_or_init(|| regex::Regex::new(r"data:(image/\w+);base64,(.+)").expect("valid regex"));
if let Some(caps) = re.captures(url) {
if let Some(caps) = regex::Regex::new(r"data:(image/\w+);base64,(.+)")
.ok()
.and_then(|re| re.captures(url))
{
let media_type = caps.get(1).map(|m| m.as_str()).unwrap_or("image/png");
let data = caps.get(2).map(|d| d.as_str()).unwrap_or("");
return serde_json::json!({
@ -125,7 +135,6 @@ pub struct AnthropicProvider {
api_key: String,
base_url: String,
extra_headers: HashMap<String, String>,
#[cfg_attr(not(debug_assertions), allow(dead_code))]
llm_timeout_secs: u64,
model_id: String,
temperature: Option<f32>,
@ -173,7 +182,9 @@ impl AnthropicProvider {
self.model_extra
.get("supported_content_types")
.and_then(|value| value.as_array())
.map(|types| types.iter().any(|t| t.as_str() == Some(content_type)))
.map(|types| {
types.iter().any(|t| t.as_str() == Some(content_type))
})
.unwrap_or(true)
}
@ -391,234 +402,3 @@ impl LLMProvider for AnthropicProvider {
&self.model_id
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::messages::ContentBlock;
use std::collections::HashMap;
/// 构造一个最小 Provider 用于测试配置驱动的方法
fn make_provider(model_extra: HashMap<String, serde_json::Value>) -> AnthropicProvider {
AnthropicProvider::new(
"test".to_string(),
"key".to_string(),
"https://api.test".to_string(),
HashMap::new(),
30,
"claude-test".to_string(),
None,
None,
model_extra,
)
}
// ---- convert_image_url_to_anthropic ----
#[test]
fn test_convert_data_url_extracts_media_type_and_base64() {
let url = "data:image/png;base64,iVBORw0KGgo=";
let v = convert_image_url_to_anthropic(url);
assert_eq!(v["type"], "image");
assert_eq!(v["source"]["type"], "base64");
assert_eq!(v["source"]["media_type"], "image/png");
assert_eq!(v["source"]["data"], "iVBORw0KGgo=");
}
#[test]
fn test_convert_data_url_jpeg() {
let url = "data:image/jpeg;base64,/9j/4AAQ";
let v = convert_image_url_to_anthropic(url);
assert_eq!(v["source"]["media_type"], "image/jpeg");
assert_eq!(v["source"]["data"], "/9j/4AAQ");
}
#[test]
fn test_convert_regular_url_uses_url_source() {
let url = "https://example.com/img.png";
let v = convert_image_url_to_anthropic(url);
assert_eq!(v["type"], "image");
assert_eq!(v["source"]["type"], "url");
assert_eq!(v["source"]["url"], url);
}
// ---- convert_content_blocks: 图片不支持时的过滤 ----
#[test]
fn test_convert_blocks_filters_images_when_unsupported() {
let blocks = vec![
ContentBlock::text("hello"),
ContentBlock::image_url("data:image/png;base64,abc"),
ContentBlock::image_url("data:image/png;base64,def"),
];
let result = convert_content_blocks(false, "test", "claude-test", &blocks, 0);
// 文本块保留,图片块被替换为通知
assert_eq!(result.len(), 2);
assert_eq!(result[0]["type"], "text");
assert_eq!(result[0]["text"], "hello");
// 第二个是合并的图片通知
assert_eq!(result[1]["type"], "text");
let notice = result[1]["text"].as_str().unwrap();
assert!(notice.contains("第 1 张图片"));
assert!(notice.contains("第 2 张图片"));
}
#[test]
fn test_convert_blocks_keeps_images_when_supported() {
let blocks = vec![
ContentBlock::text("hi"),
ContentBlock::image_url("data:image/png;base64,abc"),
];
let result = convert_content_blocks(true, "test", "claude-test", &blocks, 0);
assert_eq!(result.len(), 2);
assert_eq!(result[0]["type"], "text");
assert_eq!(result[1]["type"], "image");
assert_eq!(result[1]["source"]["data"], "abc");
}
#[test]
fn test_convert_blocks_text_only_passthrough() {
let blocks = vec![ContentBlock::text("just text")];
let result = convert_content_blocks(false, "test", "claude-test", &blocks, 0);
assert_eq!(result.len(), 1);
assert_eq!(result[0]["type"], "text");
}
// ---- request_model_extra: 内部字段过滤 ----
#[test]
fn test_request_model_extra_filters_internal_keys() {
let mut extra = HashMap::new();
extra.insert(
"supported_content_types".to_string(),
serde_json::json!(["text"]),
);
extra.insert("top_p".to_string(), serde_json::json!(0.9));
let provider = make_provider(extra);
let filtered = provider.request_model_extra();
// 内部字段被过滤
assert!(!filtered.contains_key("supported_content_types"));
// 业务字段保留
assert_eq!(filtered.get("top_p").and_then(|v| v.as_f64()), Some(0.9));
}
#[test]
fn test_request_model_extra_empty_when_only_internal() {
let mut extra = HashMap::new();
extra.insert(
"supported_content_types".to_string(),
serde_json::json!(["text", "image"]),
);
let provider = make_provider(extra);
assert!(provider.request_model_extra().is_empty());
}
// ---- supports_images: 配置驱动 ----
#[test]
fn test_supports_images_default_true() {
let provider = make_provider(HashMap::new());
assert!(provider.supports_images());
}
#[test]
fn test_supports_images_disabled_via_config() {
let mut extra = HashMap::new();
extra.insert(
"supported_content_types".to_string(),
serde_json::json!(["text"]),
);
let provider = make_provider(extra);
assert!(!provider.supports_images());
}
// ---- AnthropicResponse 反序列化 ----
#[test]
fn test_deserialize_response_with_text_and_tool_use() {
let json = r#"{
"id": "msg_001",
"model": "claude-3-sonnet",
"content": [
{"type": "text", "text": "I'll use a tool"},
{"type": "tool_use", "id": "call_1", "name": "bash", "input": {"cmd": "ls"}}
],
"usage": {"input_tokens": 10, "output_tokens": 20}
}"#;
let resp: AnthropicResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.id, "msg_001");
assert_eq!(resp.content.len(), 2);
match &resp.content[0] {
AnthropicContent::Text { text } => assert_eq!(text, "I'll use a tool"),
_ => panic!("expected Text"),
}
match &resp.content[1] {
AnthropicContent::ToolUse { id, name, input } => {
assert_eq!(id, "call_1");
assert_eq!(name, "bash");
assert_eq!(input["cmd"], "ls");
}
_ => panic!("expected ToolUse"),
}
assert_eq!(resp.usage.input_tokens, 10);
assert_eq!(resp.usage.output_tokens, 20);
}
#[test]
fn test_deserialize_response_thinking_variant() {
let json = r#"{
"id": "msg_002",
"model": "claude-3",
"content": [
{"type": "thinking", "thinking": "internal reasoning"}
],
"usage": {"input_tokens": 5, "output_tokens": 5}
}"#;
let resp: AnthropicResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.content.len(), 1);
match &resp.content[0] {
AnthropicContent::Thinking { thinking } => {
assert_eq!(thinking, "internal reasoning");
}
_ => panic!("expected Thinking"),
}
}
#[test]
fn test_deserialize_response_empty_content() {
let json = r#"{
"id": "msg_003",
"model": "claude-3",
"content": [],
"usage": {"input_tokens": 1, "output_tokens": 1}
}"#;
let resp: AnthropicResponse = serde_json::from_str(json).unwrap();
assert!(resp.content.is_empty());
}
// ---- format_error_chain ----
#[test]
fn test_format_error_chain_single() {
let err = std::io::Error::new(std::io::ErrorKind::Other, "single error");
let chain = format_error_chain(&err);
assert_eq!(chain, "single error");
}
/// 用 thiserror 构造真正的嵌套 source 链,验证 "caused by" 拼接
#[derive(Debug, thiserror::Error)]
enum OuterError {
#[error("outer wrapper")]
Wrapped(#[source] std::io::Error),
}
#[test]
fn test_format_error_chain_nested() {
let inner = std::io::Error::new(std::io::ErrorKind::Other, "root cause");
let outer = OuterError::Wrapped(inner);
let chain = format_error_chain(&outer);
assert!(chain.contains("outer wrapper"));
assert!(chain.contains("caused by"));
assert!(chain.contains("root cause"));
}
}

View File

@ -10,13 +10,8 @@ use std::time::Duration;
use super::traits::{StreamCallback, StreamDelta, Usage};
use super::{ChatCompletionRequest, ChatCompletionResponse, LLMProvider, ToolCall};
use crate::domain::messages::ContentBlock;
use crate::utils::format_error_chain;
const INTERNAL_MODEL_EXTRA_KEYS: &[&str] = &[
"tool_call_arguments_json",
"mock_response_content",
"supported_content_types",
];
const INTERNAL_MODEL_EXTRA_KEYS: &[&str] = &["tool_call_arguments_json", "mock_response_content", "supported_content_types"];
/// 流式响应中的工具调用增量
#[derive(Debug, Default)]
@ -33,8 +28,6 @@ struct StreamingAccumulator {
reasoning_content: Option<String>,
tool_calls: BTreeMap<usize, StreamingToolCall>,
response_id: String,
/// 流式末帧返回的 usage需要 stream_options.include_usage=true
usage: Option<OpenAIUsage>,
}
impl StreamingAccumulator {
@ -56,17 +49,8 @@ impl StreamingAccumulator {
}
/// 添加工具调用增量
fn add_tool_call(
&mut self,
index: usize,
id: Option<&str>,
name: Option<&str>,
arguments: Option<&str>,
) {
let entry = self
.tool_calls
.entry(index)
.or_insert_with(StreamingToolCall::default);
fn add_tool_call(&mut self, index: usize, id: Option<&str>, name: Option<&str>, arguments: Option<&str>) {
let entry = self.tool_calls.entry(index).or_insert_with(StreamingToolCall::default);
// 只在 id 非空时才更新,防止流式响应中后续 chunk 的空 id 覆盖之前的值
if let Some(id) = id {
@ -92,18 +76,9 @@ impl StreamingAccumulator {
}
}
/// 设置 usage来自流式末帧的 usage 字段)
/// 跳过 total_tokens=0 的占位帧,避免覆盖真实值。
fn set_usage(&mut self, usage: OpenAIUsage) {
if usage.total_tokens > 0 {
self.usage = Some(usage);
}
}
/// 构建最终的 ChatCompletionResponse
fn build_response(self, model: String) -> ChatCompletionResponse {
let tool_calls: Vec<ToolCall> = self
.tool_calls
let tool_calls: Vec<ToolCall> = self.tool_calls
.into_iter()
.filter(|(_, call)| !call.id.is_empty() && !call.name.is_empty())
.map(|(_, call)| {
@ -127,23 +102,27 @@ impl StreamingAccumulator {
content: self.content,
reasoning_content: self.reasoning_content,
tool_calls,
usage: self
.usage
.clone()
.map(|u| Usage {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
})
.unwrap_or(Usage {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
}),
usage: Usage {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
},
}
}
}
fn format_error_chain(error: &(dyn std::error::Error + 'static)) -> String {
let mut details = vec![error.to_string()];
let mut current = error.source();
while let Some(source) = current {
details.push(source.to_string());
current = source.source();
}
details.join("\ncaused by: ")
}
fn format_transport_error_context(
provider_name: &str,
model_id: &str,
@ -170,13 +149,10 @@ fn convert_content_blocks(
) -> Value {
// 检查是否有图片且模型不支持
if !supports_images {
let has_images = blocks
.iter()
.any(|b| matches!(b, ContentBlock::ImageUrl { .. }));
let has_images = blocks.iter().any(|b| matches!(b, ContentBlock::ImageUrl { .. }));
if has_images {
let image_count = blocks
.iter()
let image_count = blocks.iter()
.filter(|b| matches!(b, ContentBlock::ImageUrl { .. }))
.count();
@ -210,8 +186,10 @@ fn convert_content_blocks(
// 添加通知文本块
if !notices.is_empty() {
let notice_text =
format!("[系统提示] 以下图片未能成功入模:\n{}", notices.join("\n"));
let notice_text = format!(
"[系统提示] 以下图片未能成功入模:\n{}",
notices.join("\n")
);
converted_blocks.push(json!({ "type": "text", "text": notice_text }));
}
@ -325,7 +303,9 @@ impl OpenAIProvider {
self.model_extra
.get("supported_content_types")
.and_then(|value| value.as_array())
.map(|types| types.iter().any(|t| t.as_str() == Some(content_type)))
.map(|types| {
types.iter().any(|t| t.as_str() == Some(content_type))
})
.unwrap_or(true)
}
@ -359,9 +339,7 @@ impl OpenAIProvider {
Value::String(raw)
} else {
// Invalid JSON string - wrap it as a proper JSON string
Value::String(
serde_json::to_string(&raw).unwrap_or_else(|_| "null".to_string()),
)
Value::String(serde_json::to_string(&raw).unwrap_or_else(|_| "null".to_string()))
}
}
value => Value::String(
@ -392,8 +370,6 @@ impl OpenAIProvider {
let mut body = self.build_request_body(request);
// 启用流式输出
body["stream"] = json!(true);
// 请求在流式末帧返回 usageDeepSeek/OpenAI 兼容协议)
body["stream_options"] = json!({ "include_usage": true });
let mut req_builder = self
.client
@ -438,7 +414,7 @@ impl OpenAIProvider {
// 读取 SSE 流
let mut stream = resp.bytes_stream();
let mut buffer = String::new();
let mut raw_body = String::new(); // 完整原始响应,用于非 SSE JSON 回退
let mut raw_body = String::new(); // 完整原始响应,用于非 SSE JSON 回退
let mut done_received = false;
while let Some(chunk_result) = stream.next().await {
@ -459,8 +435,7 @@ impl OpenAIProvider {
}
// SSE 格式: data: {...} 或 data:{...}(某些 API 如 139 云没有空格)
let data_opt = line_trimmed
.strip_prefix("data: ")
let data_opt = line_trimmed.strip_prefix("data: ")
.or_else(|| line_trimmed.strip_prefix("data:"));
if let Some(data) = data_opt {
@ -478,26 +453,13 @@ impl OpenAIProvider {
accumulator.set_response_id(id.to_string());
}
// 提取流式末帧的 usagestream_options.include_usage=true 时返回)
if let Some(usage_val) = json.get("usage") {
if !usage_val.is_null() {
if let Ok(u) =
serde_json::from_value::<OpenAIUsage>(usage_val.clone())
{
accumulator.set_usage(u);
}
}
}
// 提取 choices
if let Some(choices) = json.get("choices").and_then(|c| c.as_array()) {
for choice in choices {
// 尝试从 delta 提取(标准 OpenAI 流式格式)
if let Some(delta) = choice.get("delta") {
// 提取内容增量
if let Some(content) =
delta.get("content").and_then(|c| c.as_str())
{
if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
accumulator.add_content(content);
if let Some(cb) = &stream_callback {
cb(StreamDelta {
@ -508,9 +470,7 @@ impl OpenAIProvider {
}
// 提取推理内容增量
if let Some(reasoning) =
delta.get("reasoning_content").and_then(|r| r.as_str())
{
if let Some(reasoning) = delta.get("reasoning_content").and_then(|r| r.as_str()) {
accumulator.add_reasoning_content(reasoning);
if let Some(cb) = &stream_callback {
cb(StreamDelta {
@ -521,43 +481,28 @@ impl OpenAIProvider {
}
// 提取工具调用增量
if let Some(tool_calls) =
delta.get("tool_calls").and_then(|t| t.as_array())
{
if let Some(tool_calls) = delta.get("tool_calls").and_then(|t| t.as_array()) {
for tool_call in tool_calls {
let index = tool_call
.get("index")
.and_then(|i| i.as_u64())
.unwrap_or(0)
as usize;
let index = tool_call.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
let id =
tool_call.get("id").and_then(|v| v.as_str());
let name = tool_call
.get("function")
let id = tool_call.get("id").and_then(|v| v.as_str());
let name = tool_call.get("function")
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str());
let arguments = tool_call
.get("function")
let arguments = tool_call.get("function")
.and_then(|f| f.get("arguments"))
.and_then(|a| a.as_str());
accumulator
.add_tool_call(index, id, name, arguments);
accumulator.add_tool_call(index, id, name, arguments);
}
}
}
// 尝试从 message 提取(某些非标准 API 格式)
else if let Some(message) = choice.get("message") {
if let Some(content) =
message.get("content").and_then(|c| c.as_str())
{
if let Some(content) = message.get("content").and_then(|c| c.as_str()) {
accumulator.add_content(content);
}
if let Some(reasoning) = message
.get("reasoning_content")
.and_then(|r| r.as_str())
{
if let Some(reasoning) = message.get("reasoning_content").and_then(|r| r.as_str()) {
accumulator.add_reasoning_content(reasoning);
}
}
@ -588,8 +533,7 @@ impl OpenAIProvider {
}
// 同样支持 data: {...} 和 data:{...} 两种格式
let data_opt = line_trimmed
.strip_prefix("data: ")
let data_opt = line_trimmed.strip_prefix("data: ")
.or_else(|| line_trimmed.strip_prefix("data:"));
if let Some(data) = data_opt {
@ -602,22 +546,11 @@ impl OpenAIProvider {
accumulator.set_response_id(id.to_string());
}
// 提取流式末帧的 usage与主循环一致
if let Some(usage_val) = json.get("usage") {
if !usage_val.is_null() {
if let Ok(u) = serde_json::from_value::<OpenAIUsage>(usage_val.clone())
{
accumulator.set_usage(u);
}
}
}
if let Some(choices) = json.get("choices").and_then(|c| c.as_array()) {
for choice in choices {
// 尝试从 delta 提取
if let Some(delta) = choice.get("delta") {
if let Some(content) = delta.get("content").and_then(|c| c.as_str())
{
if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
accumulator.add_content(content);
if let Some(cb) = &stream_callback {
cb(StreamDelta {
@ -626,9 +559,7 @@ impl OpenAIProvider {
});
}
}
if let Some(reasoning) =
delta.get("reasoning_content").and_then(|r| r.as_str())
{
if let Some(reasoning) = delta.get("reasoning_content").and_then(|r| r.as_str()) {
accumulator.add_reasoning_content(reasoning);
if let Some(cb) = &stream_callback {
cb(StreamDelta {
@ -637,22 +568,14 @@ impl OpenAIProvider {
});
}
}
if let Some(tool_calls) =
delta.get("tool_calls").and_then(|t| t.as_array())
{
if let Some(tool_calls) = delta.get("tool_calls").and_then(|t| t.as_array()) {
for tool_call in tool_calls {
let index = tool_call
.get("index")
.and_then(|i| i.as_u64())
.unwrap_or(0)
as usize;
let index = tool_call.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
let id = tool_call.get("id").and_then(|v| v.as_str());
let name = tool_call
.get("function")
let name = tool_call.get("function")
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str());
let arguments = tool_call
.get("function")
let arguments = tool_call.get("function")
.and_then(|f| f.get("arguments"))
.and_then(|a| a.as_str());
accumulator.add_tool_call(index, id, name, arguments);
@ -661,14 +584,10 @@ impl OpenAIProvider {
}
// 尝试从 message 提取(某些非标准 API 格式)
else if let Some(message) = choice.get("message") {
if let Some(content) =
message.get("content").and_then(|c| c.as_str())
{
if let Some(content) = message.get("content").and_then(|c| c.as_str()) {
accumulator.add_content(content);
}
if let Some(reasoning) =
message.get("reasoning_content").and_then(|r| r.as_str())
{
if let Some(reasoning) = message.get("reasoning_content").and_then(|r| r.as_str()) {
accumulator.add_reasoning_content(reasoning);
}
}
@ -684,8 +603,7 @@ impl OpenAIProvider {
// 服务器可能返回的是非 SSE 格式的纯 JSON尝试直接反序列化整个响应体
if response.content.is_empty() && response.tool_calls.is_empty() {
if let Ok(openai_resp) = serde_json::from_str::<OpenAIResponse>(&raw_body) {
let fallback_content = openai_resp
.choices
let fallback_content = openai_resp.choices
.first()
.and_then(|c| c.message.content.as_deref())
.unwrap_or("")
@ -696,37 +614,24 @@ impl OpenAIProvider {
"Streaming accumulator empty, falling back to non-SSE JSON parsing"
);
response.content = fallback_content;
response.reasoning_content = openai_resp
.choices
response.reasoning_content = openai_resp.choices
.first()
.and_then(|c| c.message.reasoning_content.clone());
response.tool_calls = openai_resp
.choices
response.tool_calls = openai_resp.choices
.first()
.map(|c| {
c.message
.tool_calls
.iter()
.map(|tc| ToolCall {
id: tc.id.clone(),
name: tc.function.name.clone(),
arguments: match &tc.function.arguments {
OAIFunctionArguments::Json(args) => args.clone(),
OAIFunctionArguments::String(args) => {
serde_json::from_str(args)
.unwrap_or(serde_json::Value::Null)
}
},
})
.collect()
c.message.tool_calls.iter().map(|tc| ToolCall {
id: tc.id.clone(),
name: tc.function.name.clone(),
arguments: match &tc.function.arguments {
OAIFunctionArguments::Json(args) => args.clone(),
OAIFunctionArguments::String(args) => {
serde_json::from_str(args).unwrap_or(serde_json::Value::Null)
}
},
}).collect()
})
.unwrap_or_default();
// 回退场景下也从非流式响应提取 usage
response.usage = Usage {
prompt_tokens: openai_resp.usage.prompt_tokens,
completion_tokens: openai_resp.usage.completion_tokens,
total_tokens: openai_resp.usage.total_tokens,
};
}
}
}
@ -751,11 +656,9 @@ impl OpenAIProvider {
// result that precedes its parent assistant (e.g. after compaction
// boundary splits), leading to API 400 errors:
// "insufficient tool messages following tool_calls message".
let mut resolved_tool_ids: std::collections::HashSet<&str> =
std::collections::HashSet::new();
let mut resolved_tool_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut with_parent: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut skip_assistant_indices: std::collections::HashSet<usize> =
std::collections::HashSet::new();
let mut skip_assistant_indices: std::collections::HashSet<usize> = std::collections::HashSet::new();
for (i, m) in request.messages.iter().enumerate().rev() {
if m.role == "tool" {
@ -767,9 +670,8 @@ impl OpenAIProvider {
if m.role == "assistant" {
if let Some(ref calls) = m.tool_calls {
if !calls.is_empty() {
let all_resolved = calls
.iter()
.all(|tc| resolved_tool_ids.contains(tc.id.as_str()));
let all_resolved =
calls.iter().all(|tc| resolved_tool_ids.contains(tc.id.as_str()));
if all_resolved {
for tc in calls {
with_parent.insert(tc.id.as_str());
@ -793,8 +695,7 @@ impl OpenAIProvider {
// ^ reverse scan sees tool(A) after assistant → "resolved"
// but API requires tool(A) to be IMMEDIATELY after assistant
{
let mut pending_tool_ids: std::collections::HashSet<&str> =
std::collections::HashSet::new();
let mut pending_tool_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut pending_assistant_idx: Option<usize> = None;
for (i, m) in request.messages.iter().enumerate() {
@ -991,38 +892,30 @@ impl OpenAIProvider {
/// avoid flooding logs on every request — see callers in `chat` and
/// `chat_streaming_internal`.
fn format_message_sequence(body: &Value) -> Vec<String> {
body["messages"]
.as_array()
.map(|msgs| {
msgs.iter()
.enumerate()
.map(|(i, m)| {
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?");
match role {
"assistant" => {
let tc_count = m
.get("tool_calls")
.and_then(|t| t.as_array())
.map(|a| a.len())
.unwrap_or(0);
if tc_count > 0 {
format!("[{}] assistant(tool_calls={})", i, tc_count)
} else {
format!("[{}] assistant", i)
}
}
"tool" => {
let tcid = m
.get("tool_call_id")
.and_then(|t| t.as_str())
.unwrap_or("??");
format!("[{}] tool(id={})", i, tcid)
}
_ => format!("[{}] {}", i, role),
body["messages"].as_array()
.map(|msgs| msgs.iter().enumerate().map(|(i, m)| {
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?");
match role {
"assistant" => {
let tc_count = m.get("tool_calls")
.and_then(|t| t.as_array())
.map(|a| a.len())
.unwrap_or(0);
if tc_count > 0 {
format!("[{}] assistant(tool_calls={})", i, tc_count)
} else {
format!("[{}] assistant", i)
}
})
.collect()
})
}
"tool" => {
let tcid = m.get("tool_call_id")
.and_then(|t| t.as_str())
.unwrap_or("??");
format!("[{}] tool(id={})", i, tcid)
}
_ => format!("[{}] {}", i, role),
}
}).collect())
.unwrap_or_default()
}
@ -1069,7 +962,7 @@ struct OAIFunction {
arguments: OAIFunctionArguments,
}
#[derive(Deserialize, Default, Clone, Debug)]
#[derive(Deserialize, Default)]
struct OpenAIUsage {
#[serde(default)]
prompt_tokens: u32,
@ -1248,10 +1141,7 @@ impl LLMProvider for OpenAIProvider {
callback: StreamCallback,
) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> {
if self.is_streaming_enabled() {
match self
.chat_streaming_internal(&request, Some(&callback))
.await
{
match self.chat_streaming_internal(&request, Some(&callback)).await {
Ok(response) => return Ok(response),
Err(e) => {
tracing::debug!(
@ -1581,12 +1471,7 @@ mod tests {
let mut accumulator = StreamingAccumulator::new();
// 第一个 chunk包含完整的 id 和 name
accumulator.add_tool_call(
0,
Some("call_abc123"),
Some("memory_search"),
Some("{\"action\":\""),
);
accumulator.add_tool_call(0, Some("call_abc123"), Some("memory_search"), Some("{\"action\":\""));
// 第二个 chunk只有参数增量
accumulator.add_tool_call(0, None, None, Some("list"));
// 第三个 chunk参数继续
@ -1602,10 +1487,7 @@ mod tests {
assert_eq!(response.tool_calls.len(), 1);
assert_eq!(response.tool_calls[0].id, "call_abc123");
assert_eq!(response.tool_calls[0].name, "memory_search");
assert_eq!(
response.tool_calls[0].arguments,
json!({"action":"list", "limit": 20})
);
assert_eq!(response.tool_calls[0].arguments, json!({"action":"list", "limit": 20}));
}
#[test]
@ -1613,12 +1495,7 @@ mod tests {
let mut accumulator = StreamingAccumulator::new();
// 第一个工具调用
accumulator.add_tool_call(
0,
Some("call_1"),
Some("calculator"),
Some("{\"expr\": \"1+1\"}"),
);
accumulator.add_tool_call(0, Some("call_1"), Some("calculator"), Some("{\"expr\": \"1+1\"}"));
// 第二个工具调用id 和 name 只在第一个 chunk 出现)
accumulator.add_tool_call(1, Some("call_2"), Some("get_time"), Some("{}"));
@ -1723,10 +1600,7 @@ mod tests {
"supported_content_types".to_string(),
Value::Array(vec![Value::String("text".to_string())]),
),
(
"custom_param".to_string(),
Value::String("value".to_string()),
),
("custom_param".to_string(), Value::String("value".to_string())),
]),
);
@ -1863,8 +1737,7 @@ mod tests {
let messages = body["messages"].as_array().unwrap();
// Assistant should keep tool_calls (valid immediate sequence)
let tool_calls = messages[0]
.get("tool_calls")
let tool_calls = messages[0].get("tool_calls")
.and_then(|t| t.as_array())
.expect("tool_calls should be preserved when immediately followed");
assert_eq!(tool_calls.len(), 1);

View File

@ -1,6 +1,6 @@
use crate::config::LLMProviderConfig;
use crate::domain::messages::{ContentBlock, ToolCall};
use crate::domain::tools::Tool;
use crate::config::LLMProviderConfig;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

View File

@ -59,9 +59,7 @@ pub trait AgentTaskExecutor: Send + Sync {
pub trait MaintenanceExecutor: Send + Sync {
async fn cleanup_expired_sessions(&self) -> usize;
async fn run_memory_maintenance_for_all_scopes(
&self,
) -> anyhow::Result<Vec<MaintenanceRunSummary>>;
async fn run_memory_maintenance_for_all_scopes(&self) -> anyhow::Result<Vec<MaintenanceRunSummary>>;
}
pub struct Scheduler {
@ -298,11 +296,7 @@ impl Scheduler {
match job.kind {
SchedulerJobKind::OutboundMessage => {
let message = build_outbound_message(job)?;
// publish_outbound 失败bus 满或关闭)不视为 job 失败:
// 通知丢弃是预期的背压行为,标记 job 失败会触发 misfire 重试风暴
if let Err(e) = self.bus.publish_outbound(message).await {
tracing::warn!(error = %e, job_id = %job.id, "Dropping outbound for scheduler job");
}
self.bus.publish_outbound(message).await?;
}
SchedulerJobKind::InternalEvent => {
execute_internal_event(self.maintenance_executor.as_ref(), job).await?;
@ -315,9 +309,7 @@ impl Scheduler {
)
.await?;
for message in outbound_messages {
if let Err(e) = self.bus.publish_outbound(message).await {
tracing::warn!(error = %e, job_id = %job.id, "Dropping outbound for scheduler agent task");
}
self.bus.publish_outbound(message).await?;
}
}
SchedulerJobKind::SilentAgentTask => {
@ -413,8 +405,7 @@ impl Scheduler {
"silent_agent_task".to_string(),
);
if let Err(e) = self
.bus
self.bus
.publish_outbound(OutboundMessage::error_notification(
channel,
chat_id,
@ -428,10 +419,7 @@ impl Scheduler {
metadata,
))
.await
{
tracing::warn!(error = %e, job_id = %job.id, "Dropping silent agent task failure notification");
}
Ok(())
.map_err(|error| anyhow::anyhow!(error.to_string()))
}
}
@ -464,15 +452,11 @@ fn scheduler_job_definition_matches(
existing: &SchedulerJobRecord,
) -> bool {
let input_schedule = serde_json::from_value::<SchedulerSchedule>(input.schedule.clone()).ok();
let existing_schedule = deserialize_schedule(
&existing.schedule,
existing.interval_secs,
existing.startup_delay_secs,
)
.ok();
let existing_schedule =
deserialize_schedule(&existing.schedule, existing.interval_secs, existing.startup_delay_secs)
.ok();
let input_target = serde_json::from_value::<SchedulerJobTarget>(input.target.clone()).ok();
let existing_target =
serde_json::from_value::<SchedulerJobTarget>(existing.target.clone()).ok();
let existing_target = serde_json::from_value::<SchedulerJobTarget>(existing.target.clone()).ok();
let targets_match = match (input_target, existing_target) {
(Some(input_target), Some(existing_target)) => {
input_target.channel == existing_target.channel
@ -829,10 +813,7 @@ fn convert_weekday_field(expression: &str) -> String {
let weekday_field = parts[5];
let converted = convert_cron_weekday(weekday_field);
format!(
"{} {} {} {} {} {}",
parts[0], parts[1], parts[2], parts[3], parts[4], converted
)
format!("{} {} {} {} {} {}", parts[0], parts[1], parts[2], parts[3], parts[4], converted)
}
/// 转换星期表达式中的数字
@ -843,10 +824,9 @@ fn convert_cron_weekday(field: &str) -> String {
// 处理列表(逗号分隔)
let items: Vec<&str> = field.split(',').collect();
let converted_items: Vec<String> = items
.iter()
.map(|item| convert_weekday_item(item.trim()))
.collect();
let converted_items: Vec<String> = items.iter().map(|item| {
convert_weekday_item(item.trim())
}).collect();
converted_items.join(",")
}
@ -885,14 +865,14 @@ fn convert_weekday_range_or_value(item: &str) -> String {
/// 转换单个星期数字
fn convert_single_weekday(day: &str) -> String {
match day {
"0" | "7" => "1".to_string(), // 周日 -> 1
"1" => "2".to_string(), // 周一 -> 2
"2" => "3".to_string(), // 周二 -> 3
"3" => "4".to_string(), // 周三 -> 4
"4" => "5".to_string(), // 周四 -> 5
"5" => "6".to_string(), // 周五 -> 6
"6" => "7".to_string(), // 周六 -> 7
_ => day.to_string(), // 其他(如字母)保持不变
"0" | "7" => "1".to_string(), // 周日 -> 1
"1" => "2".to_string(), // 周一 -> 2
"2" => "3".to_string(), // 周二 -> 3
"3" => "4".to_string(), // 周三 -> 4
"4" => "5".to_string(), // 周四 -> 5
"5" => "6".to_string(), // 周五 -> 6
"6" => "7".to_string(), // 周六 -> 7
_ => day.to_string(), // 其他(如字母)保持不变
}
}
@ -949,9 +929,7 @@ async fn execute_internal_event(
Ok(())
}
"memory_maintenance" => {
let results = maintenance_executor
.run_memory_maintenance_for_all_scopes()
.await?;
let results = maintenance_executor.run_memory_maintenance_for_all_scopes().await?;
for result in &results {
tracing::info!(
job_id = %job.id,
@ -1306,10 +1284,10 @@ impl TryFrom<serde_json::Value> for SchedulerJobTarget {
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Datelike, Timelike};
use crate::bus::MessageBus;
use crate::config::BUILTIN_MEMORY_MAINTENANCE_JOB_ID;
use crate::storage::{SchedulerJobUpsert, SessionStore};
use chrono::{Datelike, Timelike};
#[derive(Clone)]
struct TestAgentTaskExecutor;
@ -1347,9 +1325,7 @@ mod tests {
0
}
async fn run_memory_maintenance_for_all_scopes(
&self,
) -> anyhow::Result<Vec<MaintenanceRunSummary>> {
async fn run_memory_maintenance_for_all_scopes(&self) -> anyhow::Result<Vec<MaintenanceRunSummary>> {
Ok(Vec::new())
}
}
@ -1616,19 +1592,17 @@ mod tests {
let probe_runtime = RuntimeJob::from_config(
&config_job,
Utc.timestamp_millis_opt(1_700_000_000_000)
.single()
.unwrap(),
Utc.timestamp_millis_opt(1_700_000_000_000).single().unwrap(),
SchedulerMisfirePolicy::Skip,
chrono_tz::Asia::Shanghai,
)
.unwrap();
let probe_existing = store.get_scheduler_job("agent.heartbeat").unwrap().unwrap();
let probe_existing = store
.get_scheduler_job("agent.heartbeat")
.unwrap()
.unwrap();
let probe_upsert = probe_runtime.to_upsert();
assert!(scheduler_job_definition_matches(
&probe_upsert,
&probe_existing
));
assert!(scheduler_job_definition_matches(&probe_upsert, &probe_existing));
let (agent_task_executor, maintenance_service) = test_scheduler_services();
let scheduler = Scheduler::new(
@ -1648,7 +1622,10 @@ mod tests {
scheduler.sync_config_jobs().unwrap();
let saved = store.get_scheduler_job("agent.heartbeat").unwrap().unwrap();
let saved = store
.get_scheduler_job("agent.heartbeat")
.unwrap()
.unwrap();
assert_eq!(saved.next_fire_at, Some(persisted_next_fire_at));
assert_eq!(saved.run_count, 3);
@ -1746,6 +1723,7 @@ mod tests {
);
}
#[test]
fn debug_cron_weekday_definitions() {
// 重大发现cron crate 的星期定义是反常规的!
@ -1764,16 +1742,9 @@ mod tests {
];
// 从周六2026-04-25开始测试
let saturday = Utc
.with_ymd_and_hms(2026, 4, 25, 10, 0, 0)
.single()
.unwrap();
let saturday = Utc.with_ymd_and_hms(2026, 4, 25, 10, 0, 0).single().unwrap();
let shanghai_saturday = saturday.with_timezone(&chrono_tz::Asia::Shanghai);
println!(
"\n=== 从周六 {} ({:?}) 开始测试 ===",
shanghai_saturday,
shanghai_saturday.weekday()
);
println!("\n=== 从周六 {} ({:?}) 开始测试 ===", shanghai_saturday, shanghai_saturday.weekday());
for (expr, desc) in &test_cases {
let schedule = parse_scheduler_cron(expr).unwrap();
@ -1786,49 +1757,21 @@ mod tests {
let schedule_workday = parse_scheduler_cron("0 9 * * 1-5").unwrap();
let sat_next = schedule_workday.after(&shanghai_saturday).next().unwrap();
println!(
"周六 -> 1-5 下次执行: {} (星期: {:?})",
sat_next,
sat_next.weekday()
);
assert_eq!(
sat_next.weekday(),
chrono::Weekday::Mon,
"1-5 应该从周六跳到周一"
);
println!("周六 -> 1-5 下次执行: {} (星期: {:?})", sat_next, sat_next.weekday());
assert_eq!(sat_next.weekday(), chrono::Weekday::Mon, "1-5 应该从周六跳到周一");
// 从周日开始
let sunday = Utc
.with_ymd_and_hms(2026, 4, 26, 10, 0, 0)
.single()
.unwrap();
let sunday = Utc.with_ymd_and_hms(2026, 4, 26, 10, 0, 0).single().unwrap();
let shanghai_sunday = sunday.with_timezone(&chrono_tz::Asia::Shanghai);
let sun_next = schedule_workday.after(&shanghai_sunday).next().unwrap();
println!(
"周日 -> 1-5 下次执行: {} (星期: {:?})",
sun_next,
sun_next.weekday()
);
assert_eq!(
sun_next.weekday(),
chrono::Weekday::Mon,
"1-5 应该从周日跳到周一"
);
println!("周日 -> 1-5 下次执行: {} (星期: {:?})", sun_next, sun_next.weekday());
assert_eq!(sun_next.weekday(), chrono::Weekday::Mon, "1-5 应该从周日跳到周一");
// 从周一早上7点开始
let shanghai_monday = chrono_tz::Asia::Shanghai
.with_ymd_and_hms(2026, 4, 27, 7, 0, 0)
.single()
.unwrap();
println!(
"周一早上7点 -> 1-5 下次执行: {} (星期: {:?})",
let shanghai_monday = chrono_tz::Asia::Shanghai.with_ymd_and_hms(2026, 4, 27, 7, 0, 0).single().unwrap();
println!("周一早上7点 -> 1-5 下次执行: {} (星期: {:?})",
schedule_workday.after(&shanghai_monday).next().unwrap(),
schedule_workday
.after(&shanghai_monday)
.next()
.unwrap()
.weekday()
);
schedule_workday.after(&shanghai_monday).next().unwrap().weekday());
}
/// 测试标准 cron 星期转换功能
@ -1841,103 +1784,62 @@ mod tests {
#[test]
fn standard_cron_weekday_conversion() {
// 测试:标准 cron 的 1-5 应该表示周一到周五
let saturday = Utc
.with_ymd_and_hms(2026, 4, 25, 10, 0, 0)
.single()
.unwrap();
let saturday = Utc.with_ymd_and_hms(2026, 4, 25, 10, 0, 0).single().unwrap();
let shanghai_saturday = saturday.with_timezone(&chrono_tz::Asia::Shanghai);
// 现在使用标准 cron1-5 表示周一到周五
let schedule_std = parse_scheduler_cron("0 9 * * 1-5").unwrap();
let sat_next = schedule_std.after(&shanghai_saturday).next().unwrap();
println!(
"周六 -> 标准 cron 1-5 下次执行: {} (星期: {:?})",
sat_next,
sat_next.weekday()
);
assert_eq!(
sat_next.weekday(),
chrono::Weekday::Mon,
"标准 cron 1-5 应该从周六跳到周一"
);
println!("周六 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", sat_next, sat_next.weekday());
assert_eq!(sat_next.weekday(), chrono::Weekday::Mon, "标准 cron 1-5 应该从周六跳到周一");
// 从周日开始
let sunday = Utc
.with_ymd_and_hms(2026, 4, 26, 10, 0, 0)
.single()
.unwrap();
let sunday = Utc.with_ymd_and_hms(2026, 4, 26, 10, 0, 0).single().unwrap();
let shanghai_sunday = sunday.with_timezone(&chrono_tz::Asia::Shanghai);
let sun_next = schedule_std.after(&shanghai_sunday).next().unwrap();
println!(
"周日 -> 标准 cron 1-5 下次执行: {} (星期: {:?})",
sun_next,
sun_next.weekday()
);
assert_eq!(
sun_next.weekday(),
chrono::Weekday::Mon,
"标准 cron 1-5 应该从周日跳到周一"
);
println!("周日 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", sun_next, sun_next.weekday());
assert_eq!(sun_next.weekday(), chrono::Weekday::Mon, "标准 cron 1-5 应该从周日跳到周一");
// 从周一开始上海时间周一早上7点
let shanghai_monday = chrono_tz::Asia::Shanghai
.with_ymd_and_hms(2026, 4, 27, 7, 0, 0)
.single()
.unwrap();
let shanghai_monday = chrono_tz::Asia::Shanghai.with_ymd_and_hms(2026, 4, 27, 7, 0, 0).single().unwrap();
let mon_next = schedule_std.after(&shanghai_monday).next().unwrap();
println!(
"周一早上 -> 标准 cron 1-5 下次执行: {} (星期: {:?})",
mon_next,
mon_next.weekday()
);
assert_eq!(
mon_next.weekday(),
chrono::Weekday::Mon,
"标准 cron 1-5 应该当天执行"
);
println!("周一早上 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", mon_next, mon_next.weekday());
assert_eq!(mon_next.weekday(), chrono::Weekday::Mon, "标准 cron 1-5 应该当天执行");
assert_eq!(mon_next.hour(), 9, "应该是上海时间9点");
// 从周五开始(应该下周周一)
let friday = Utc.with_ymd_and_hms(2026, 5, 1, 10, 0, 0).single().unwrap(); // 周五
let shanghai_friday = friday.with_timezone(&chrono_tz::Asia::Shanghai);
let fri_next = schedule_std.after(&shanghai_friday).next().unwrap();
println!(
"周五 -> 标准 cron 1-5 下次执行: {} (星期: {:?})",
fri_next,
fri_next.weekday()
);
assert_eq!(
fri_next.weekday(),
chrono::Weekday::Mon,
"标准 cron 1-5 应该从周五跳到下周一"
);
println!("周五 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", fri_next, fri_next.weekday());
assert_eq!(fri_next.weekday(), chrono::Weekday::Mon, "标准 cron 1-5 应该从周五跳到下周一");
}
/// 测试转换辅助函数
#[test]
fn test_weekday_conversion_helper() {
// 测试单个值
assert_eq!(convert_single_weekday("0"), "1"); // 周日
assert_eq!(convert_single_weekday("1"), "2"); // 周一
assert_eq!(convert_single_weekday("5"), "6"); // 周五
assert_eq!(convert_single_weekday("6"), "7"); // 周六
assert_eq!(convert_single_weekday("7"), "1"); // 周日(标准 cron 兼容写法)
assert_eq!(convert_single_weekday("0"), "1"); // 周日
assert_eq!(convert_single_weekday("1"), "2"); // 周一
assert_eq!(convert_single_weekday("5"), "6"); // 周五
assert_eq!(convert_single_weekday("6"), "7"); // 周六
assert_eq!(convert_single_weekday("7"), "1"); // 周日(标准 cron 兼容写法)
// 测试范围
assert_eq!(convert_weekday_range_or_value("1-5"), "2-6"); // 周一到周五
assert_eq!(convert_weekday_range_or_value("0-6"), "1-7"); // 周日到周六
assert_eq!(convert_weekday_range_or_value("0-7"), "1-1"); // 周日(循环)
assert_eq!(convert_weekday_range_or_value("1-5"), "2-6"); // 周一到周五
assert_eq!(convert_weekday_range_or_value("0-6"), "1-7"); // 周日到周六
assert_eq!(convert_weekday_range_or_value("0-7"), "1-1"); // 周日(循环)
// 测试列表
assert_eq!(convert_cron_weekday("1,3,5"), "2,4,6"); // 周一、三、五
assert_eq!(convert_cron_weekday("0,6"), "1,7"); // 周日和周六
assert_eq!(convert_cron_weekday("1,3,5"), "2,4,6"); // 周一、三、五
assert_eq!(convert_cron_weekday("0,6"), "1,7"); // 周日和周六
// 测试步长
assert_eq!(convert_weekday_item("*/2"), "*/2"); // 步长保持不变
assert_eq!(convert_weekday_item("*/2"), "*/2"); // 步长保持不变
// 测试混合
assert_eq!(convert_cron_weekday("1-5,7"), "2-6,1"); // 周一到周五 + 周日
assert_eq!(convert_cron_weekday("1-5,7"), "2-6,1"); // 周一到周五 + 周日
// 测试特殊字符
assert_eq!(convert_cron_weekday("*"), "*");

View File

@ -1,22 +1,17 @@
use crate::platform::{
atomic_rename, home_dir as platform_home_dir, path_to_uri, xml_escape as platform_xml_escape,
};
use crate::platform::{atomic_rename, home_dir as platform_home_dir, path_to_uri, xml_escape as platform_xml_escape};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use parking_lot::RwLock;
use std::sync::{Arc, RwLock};
#[cfg(test)]
static SKILL_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
pub(crate) fn acquire_skill_test_env_lock() -> std::sync::MutexGuard<'static, ()> {
SKILL_TEST_ENV_LOCK
.lock()
.unwrap_or_else(|err| err.into_inner())
SKILL_TEST_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner())
}
use crate::config::SkillsConfig;
@ -137,7 +132,7 @@ impl SkillRuntime {
pub fn reload(&self) -> Result<SkillCatalog, String> {
let catalog = SkillCatalog::discover(&self.config);
let mut guard = self.catalog.write();
let mut guard = self.catalog.write().expect("skills rwlock poisoned");
*guard = catalog.clone();
Ok(catalog)
}
@ -145,16 +140,18 @@ impl SkillRuntime {
pub fn is_empty(&self) -> bool {
self.catalog
.read()
.expect("skills rwlock poisoned")
.is_empty()
}
pub fn len(&self) -> usize {
self.catalog.read().len()
self.catalog.read().expect("skills rwlock poisoned").len()
}
pub fn system_index_prompt(&self) -> Option<String> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.system_index_prompt()
}
@ -166,36 +163,42 @@ impl SkillRuntime {
) -> Option<String> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.system_index_prompt_filtered(allowed, denied)
}
pub fn discovery_event_payload(&self) -> serde_json::Value {
self.catalog
.read()
.expect("skills rwlock poisoned")
.discovery_event_payload()
}
pub fn offered_event_payload(&self) -> serde_json::Value {
self.catalog
.read()
.expect("skills rwlock poisoned")
.offered_event_payload()
}
pub fn activation_payload(&self, name: &str) -> Result<String, String> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.activation_payload(name)
}
pub fn activation_event_payload(&self, name: &str) -> Result<serde_json::Value, String> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.activation_event_payload(name)
}
pub fn list_skills(&self) -> Vec<Skill> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.skills
.clone()
}
@ -206,28 +209,22 @@ impl SkillRuntime {
let catalog = SkillCatalog::discover_without_state(&self.config, &cwd);
let disable_state = load_skill_disable_state(&cwd);
catalog
.skills
.iter()
.map(|skill| {
let disabled_scopes = disable_state.disabled_scopes_for(&skill.name);
SkillWithStatus {
name: skill.name.clone(),
description: skill.description.clone(),
source: skill.source.as_str().to_string(),
path: skill.path.display().to_string(),
disabled_in_scopes: disabled_scopes
.iter()
.map(|s| s.as_str().to_string())
.collect(),
}
})
.collect()
catalog.skills.iter().map(|skill| {
let disabled_scopes = disable_state.disabled_scopes_for(&skill.name);
SkillWithStatus {
name: skill.name.clone(),
description: skill.description.clone(),
source: skill.source.as_str().to_string(),
path: skill.path.display().to_string(),
disabled_in_scopes: disabled_scopes.iter().map(|s| s.as_str().to_string()).collect(),
}
}).collect()
}
pub fn get_skill(&self, name: &str) -> Option<Skill> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.find_skill(name)
.cloned()
}
@ -324,8 +321,8 @@ impl SkillRuntime {
pub fn has_skill_definition(&self, name: &str) -> Result<bool, String> {
validate_skill_name(name)?;
let cwd =
std::env::current_dir().map_err(|err| format!("failed to get current dir: {}", err))?;
let cwd = std::env::current_dir()
.map_err(|err| format!("failed to get current dir: {}", err))?;
Ok(SkillCatalog::discover_without_state(&self.config, &cwd)
.find_skill(name)
.is_some())
@ -361,8 +358,8 @@ impl SkillRuntime {
let _ = self.reload()?;
}
let cwd =
std::env::current_dir().map_err(|err| format!("failed to get current dir: {}", err))?;
let cwd = std::env::current_dir()
.map_err(|err| format!("failed to get current dir: {}", err))?;
let effective_state = load_skill_disable_state(&cwd);
let disabled_in_scopes = effective_state.disabled_scopes_for(name);
@ -759,9 +756,8 @@ fn skill_file_path(scope: SkillScope, name: &str) -> Result<PathBuf, String> {
fn skill_state_path(scope: SkillScope) -> Result<PathBuf, String> {
match scope {
SkillScope::User => {
user_skill_state_path().ok_or_else(|| "failed to resolve home directory".to_string())
}
SkillScope::User => user_skill_state_path()
.ok_or_else(|| "failed to resolve home directory".to_string()),
SkillScope::Project => {
let cwd = std::env::current_dir()
.map_err(|err| format!("failed to get current dir: {}", err))?;
@ -970,7 +966,10 @@ impl SystemPromptProvider for SkillPromptProvider {
// 读取所选专家的技能策略;无专家或无策略时走全局索引(主智能体默认)
let content = match context.session_id.as_deref() {
Some(sid) => {
let policy = self.experts.selected_expert_for(sid).map(|e| e.capability);
let policy = self
.experts
.selected_expert_for(sid)
.map(|e| e.capability);
match policy {
Some(p) if p.has_skill_policy() => self.skills.system_index_prompt_filtered(
p.allowed_skills.as_deref(),
@ -1059,11 +1058,7 @@ mod tests {
let skill_dir = dir.path().join("demo");
fs::create_dir_all(&skill_dir).unwrap();
let skill_md = skill_dir.join("SKILL.md");
fs::write(
&skill_md,
"---\r\ndescription: demo skill\r\n---\r\nStep A\r\nStep B",
)
.unwrap();
fs::write(&skill_md, "---\r\ndescription: demo skill\r\n---\r\nStep A\r\nStep B").unwrap();
let skill = parse_skill_file(&skill_md, SkillSource::Project).unwrap();
assert_eq!(skill.name, "demo");
@ -1133,10 +1128,7 @@ mod tests {
// 验证 location 包含正确的 file:// URI 格式
let expected_uri = path_to_uri(&skill_path);
assert!(prompt.contains(&format!(
"<location>{}</location>",
platform_xml_escape(&expected_uri)
)));
assert!(prompt.contains(&format!("<location>{}</location>", platform_xml_escape(&expected_uri))));
assert!(prompt.contains("</available_skills>"));
}
@ -1396,17 +1388,13 @@ mod tests {
max_listed_skills: 32,
});
let disabled = runtime
.disable_skill(SkillScope::Project, "demo", true)
.unwrap();
let disabled = runtime.disable_skill(SkillScope::Project, "demo", true).unwrap();
assert!(disabled.changed);
assert_eq!(disabled.disabled_in_scopes, vec![SkillScope::Project]);
assert!(!disabled.available);
assert!(runtime.get_skill("demo").is_none());
let enabled = runtime
.enable_skill(SkillScope::Project, "demo", true)
.unwrap();
let enabled = runtime.enable_skill(SkillScope::Project, "demo", true).unwrap();
assert!(enabled.changed);
assert!(enabled.disabled_in_scopes.is_empty());
assert!(enabled.available);
@ -1439,22 +1427,16 @@ mod tests {
max_listed_skills: 32,
});
let user_disabled = runtime
.disable_skill(SkillScope::User, "demo", true)
.unwrap();
let user_disabled = runtime.disable_skill(SkillScope::User, "demo", true).unwrap();
assert_eq!(user_disabled.disabled_in_scopes, vec![SkillScope::User]);
assert!(runtime.get_skill("demo").is_none());
let project_enabled = runtime
.enable_skill(SkillScope::Project, "demo", true)
.unwrap();
let project_enabled = runtime.enable_skill(SkillScope::Project, "demo", true).unwrap();
assert!(!project_enabled.available);
assert_eq!(project_enabled.disabled_in_scopes, vec![SkillScope::User]);
assert!(runtime.get_skill("demo").is_none());
let user_enabled = runtime
.enable_skill(SkillScope::User, "demo", true)
.unwrap();
let user_enabled = runtime.enable_skill(SkillScope::User, "demo", true).unwrap();
assert!(user_enabled.available);
assert!(user_enabled.disabled_in_scopes.is_empty());
assert!(runtime.get_skill("demo").is_some());
@ -1524,9 +1506,7 @@ mod tests {
});
assert_eq!(catalog.len(), 1);
let payload = catalog
.activation_event_payload("demo-user-openclaw")
.unwrap();
let payload = catalog.activation_event_payload("demo-user-openclaw").unwrap();
assert_eq!(payload["source"], "user_openclaw");
}
@ -1585,9 +1565,7 @@ mod tests {
);
// After enabling, list_skills_with_status should report no disabled scopes
runtime
.enable_skill(SkillScope::Project, "demo", true)
.unwrap();
runtime.enable_skill(SkillScope::Project, "demo", true).unwrap();
let skills_after = runtime.list_skills_with_status();
assert_eq!(skills_after.len(), 1);
assert!(skills_after[0].disabled_in_scopes.is_empty());

View File

@ -51,39 +51,6 @@ pub(super) fn ensure_messages_schema(conn: &Connection) -> Result<(), StorageErr
)?;
}
// Token usage 字段(仅 assistant 消息有值,来自 LLM 响应)
if !has_column(conn, "messages", "prompt_tokens")? {
add_column_if_missing(
conn,
"ALTER TABLE messages ADD COLUMN prompt_tokens INTEGER",
)?;
}
if !has_column(conn, "messages", "completion_tokens")? {
add_column_if_missing(
conn,
"ALTER TABLE messages ADD COLUMN completion_tokens INTEGER",
)?;
}
if !has_column(conn, "messages", "total_tokens")? {
add_column_if_missing(conn, "ALTER TABLE messages ADD COLUMN total_tokens INTEGER")?;
}
if !has_column(conn, "messages", "context_window_tokens")? {
add_column_if_missing(
conn,
"ALTER TABLE messages ADD COLUMN context_window_tokens INTEGER",
)?;
}
// is_compacted: 1 表示该消息是被压缩消费掉的原始消息前端可见、LLM 不可见)。
// 压缩摘要消息 is_compacted=0LLM 可见),通过 system_context='history_compaction*'
// 在前端查询中被排除。保留的原消息system_guards / 最新 useris_compacted=0不重复。
if !has_column(conn, "messages", "is_compacted")? {
add_column_if_missing(
conn,
"ALTER TABLE messages ADD COLUMN is_compacted INTEGER NOT NULL DEFAULT 0",
)?;
}
// 创建 topic_id 索引(如果不存在)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_messages_topic_seq ON messages(topic_id, seq) WHERE topic_id IS NOT NULL",
@ -145,18 +112,6 @@ pub(super) fn ensure_scheduler_schema(conn: &Connection) -> Result<(), StorageEr
}
pub(super) fn ensure_memory_scope_key_migration(conn: &Connection) -> Result<(), StorageError> {
// 用 PRAGMA user_version 追踪迁移是否已完成,避免每次启动都执行全表 DELETE + UPDATE。
// user_version 是 SQLite 内置的 32 位整数,持久化在数据库文件头中。
// 版本 0未迁移版本 1memory_scope_key 迁移已完成。
const MEMORY_SCOPE_KEY_MIGRATION_VERSION: i64 = 1;
let current_version: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
if current_version >= MEMORY_SCOPE_KEY_MIGRATION_VERSION {
// 已迁移过,跳过
return Ok(());
}
// 步骤1去重。多条记录 scope_key 不同,改为 "default" 后会违反唯一约束。
// 对每个 (scope_kind, namespace, memory_key) 组合保留 updated_at 最新的一条。
conn.execute(
@ -181,16 +136,6 @@ pub(super) fn ensure_memory_scope_key_migration(conn: &Connection) -> Result<(),
"UPDATE memories SET scope_key = 'default' WHERE scope_key != 'default'",
[],
)?;
// 步骤3记录迁移版本后续启动直接跳过
conn.execute(
&format!(
"PRAGMA user_version = {}",
MEMORY_SCOPE_KEY_MIGRATION_VERSION
),
[],
)?;
Ok(())
}

View File

@ -1,10 +1,6 @@
#[cfg(not(test))]
use std::path::{Path, PathBuf};
use std::collections::HashMap;
use crate::utils::current_timestamp;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
@ -28,10 +24,10 @@ pub use ports::{
SkillEventRepository, TodoRepository,
};
pub use records::{
allowed_namespace_names, get_namespace_description, is_valid_namespace,
ALLOWED_MEMORY_NAMESPACES, GLOBAL_SCOPE_KEY, MemoryRecord, MemoryUpsert, SchedulerJobRecord,
SchedulerJobState, SchedulerJobStatus, SchedulerJobUpsert, SessionRecord, SessionTokenStats,
SkillEventRecord, TodoRecord, TopicRecord, allowed_namespace_names, get_namespace_description,
is_valid_namespace,
SchedulerJobState, SchedulerJobStatus, SchedulerJobUpsert, SessionRecord, SkillEventRecord,
TodoRecord, TopicRecord,
};
#[derive(Clone)]
@ -106,11 +102,6 @@ impl SessionStore {
tool_call_id TEXT,
tool_name TEXT,
tool_calls_json TEXT,
tool_duration_ms INTEGER,
prompt_tokens INTEGER,
completion_tokens INTEGER,
total_tokens INTEGER,
context_window_tokens INTEGER,
created_at INTEGER NOT NULL,
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE,
FOREIGN KEY(topic_id) REFERENCES topics(id) ON DELETE SET NULL,
@ -237,11 +228,14 @@ impl SessionStore {
drop(conn);
let manager = SqliteConnectionManager::file(db_uri).with_init(|c| {
c.busy_timeout(std::time::Duration::from_secs(30))?;
Ok(())
});
let pool = Pool::builder().max_size(8).build(manager)?;
let manager = SqliteConnectionManager::file(db_uri)
.with_init(|c| {
c.busy_timeout(std::time::Duration::from_secs(30))?;
Ok(())
});
let pool = Pool::builder()
.max_size(8)
.build(manager)?;
Ok(Self { pool })
}
@ -251,7 +245,8 @@ impl SessionStore {
// Use a temp file so the database survives across pool connections.
// Temp dir is cleaned by the OS eventually; tests that need cleanup
// can call std::fs::remove_file on the path.
let path = std::env::temp_dir().join(format!("picobot_test_{}.db", uuid::Uuid::new_v4()));
let path = std::env::temp_dir()
.join(format!("picobot_test_{}.db", uuid::Uuid::new_v4()));
let conn = Connection::open(&path)?;
let path_str = path.to_string_lossy().to_string();
// ignore unused mut warning for manager in tests
@ -309,12 +304,7 @@ impl SessionStore {
chat_id: &str,
) -> Result<SessionRecord, StorageError> {
let session_id = persistent_session_id(channel_name, chat_id);
self.ensure_session(
&session_id,
channel_name,
chat_id,
&format!("{}:{}", channel_name, chat_id),
)
self.ensure_session(&session_id, channel_name, chat_id, &format!("{}:{}", channel_name, chat_id))
}
/// 确保指定 session_id 的会话存在(如果不存在则创建)
@ -522,11 +512,7 @@ impl SessionStore {
Ok(())
}
pub fn update_topic_description(
&self,
topic_id: &str,
description: &str,
) -> Result<(), StorageError> {
pub fn update_topic_description(&self, topic_id: &str, description: &str) -> Result<(), StorageError> {
let now = current_timestamp();
let conn = self.pool.get()?;
conn.execute(
@ -608,8 +594,8 @@ impl SessionStore {
"
INSERT INTO messages (
id, session_id, topic_id, seq, role, content,
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
",
params![
message.id,
@ -625,10 +611,6 @@ impl SessionStore {
message.tool_name,
tool_calls_json,
message.tool_duration_ms.map(|v| v as i64),
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
message.usage.as_ref().map(|u| u.completion_tokens as i64),
message.usage.as_ref().map(|u| u.total_tokens as i64),
message.usage.as_ref().and_then(|u| u.context_window_tokens.map(|v| v as i64)),
message.timestamp,
],
)?;
@ -690,8 +672,8 @@ impl SessionStore {
INSERT INTO messages (
id, session_id, topic_id, seq, role, content,
system_context, reasoning_content, media_refs_json,
tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
tool_call_id, tool_name, tool_calls_json, tool_duration_ms, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
",
params![
message.id,
@ -707,10 +689,6 @@ impl SessionStore {
message.tool_name,
tool_calls_json,
message.tool_duration_ms.map(|v| v as i64),
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
message.usage.as_ref().map(|u| u.completion_tokens as i64),
message.usage.as_ref().map(|u| u.total_tokens as i64),
message.usage.as_ref().and_then(|u| u.context_window_tokens.map(|v| v as i64)),
message.timestamp,
],
)?;
@ -832,7 +810,12 @@ impl SessionStore {
archived_at = NULL
WHERE id = ?1 AND deleted_at IS NULL
",
params![session_id, inserted_count, active_user_turn_count, now,],
params![
session_id,
inserted_count,
active_user_turn_count,
now,
],
)?;
tx.commit()?;
@ -960,105 +943,6 @@ impl SessionStore {
Ok(())
}
/// 压缩该 topic 的历史:保留原始消息(标记 is_compacted=1前端可见、LLM 不可见),
/// 并插入压缩摘要消息is_compacted=0LLM 可见,前端通过 system_context 过滤排除)。
///
/// 与 `replace_topic_history` 的区别:不删除原消息,仅打标记,从而让前端仍能展示
/// 完整原始对话,同时 LLM 只看到压缩后的精简历史。
///
/// `new_messages` 是 `compress_two_segment` 的输出,包含:
/// - 保留原样的消息system_guards / 最新 user保留原 ID
/// - 压缩摘要消息system_context = history_compaction_*,新 ID
pub fn compact_topic_history(
&self,
session_id: &str,
topic_id: &str,
new_messages: &[ChatMessage],
) -> Result<(), StorageError> {
let mut conn = self.pool.get()?;
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let now = current_timestamp();
// 分离摘要消息与保留消息(保留消息携带原 ID摘要消息是新构造的
let (summaries, preserved): (Vec<&ChatMessage>, Vec<&ChatMessage>) =
new_messages.iter().partition(|m| {
m.system_context
.as_deref()
.map_or(false, |sc| sc.starts_with("history_compaction"))
});
// 先删除该 topic 下已有的旧压缩摘要system_context LIKE 'history_compaction%')。
// 旧摘要已被新摘要替代,保留它们只会累积垃圾行(前端和 LLM 都看不到,但占存储)。
tx.execute(
"DELETE FROM messages \
WHERE topic_id = ?1 AND session_id = ?2 \
AND system_context LIKE 'history_compaction%'",
params![topic_id, session_id],
)?;
// 将该 topic 中未被保留的原消息标记为 is_compacted=1仅更新尚未标记的行避免重复写
// 保留消息system_guards / 最新 user保持 is_compacted=0不重复插入。
let preserved_ids: Vec<String> = preserved.iter().map(|m| m.id.clone()).collect();
if preserved_ids.is_empty() {
tx.execute(
"UPDATE messages SET is_compacted = 1 \
WHERE topic_id = ?1 AND session_id = ?2 AND is_compacted = 0",
params![topic_id, session_id],
)?;
} else {
let placeholders = (0..preserved_ids.len())
.map(|_| "?")
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"UPDATE messages SET is_compacted = 1 \
WHERE topic_id = ? AND session_id = ? AND is_compacted = 0 \
AND id NOT IN ({})",
placeholders
);
let mut params_vec: Vec<String> = vec![topic_id.to_string(), session_id.to_string()];
params_vec.extend(preserved_ids.iter().cloned());
tx.execute(&sql, rusqlite::params_from_iter(params_vec))?;
}
// 插入压缩摘要消息is_compacted=0由列默认值保证
let start_seq: i64 = tx.query_row(
"SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?1",
params![session_id],
|row| row.get(0),
)?;
for (i, message) in summaries.iter().enumerate() {
let seq = start_seq + i as i64;
insert_message_with_topic_seq(&tx, session_id, topic_id, seq, message)?;
}
// 更新 topic / session 计数(基于该 topic 全部消息,含被压缩的原始消息)
let topic_count: i64 = tx.query_row(
"SELECT COUNT(*) FROM messages WHERE session_id = ?1 AND topic_id = ?2",
params![session_id, topic_id],
|row| row.get(0),
)?;
tx.execute(
"UPDATE topics SET message_count = ?2, last_active_at = ?3, updated_at = ?3 WHERE id = ?1",
params![topic_id, topic_count, now],
)?;
let (total_count, user_turn_count): (i64, i64) = tx.query_row(
"SELECT COUNT(*), COALESCE(SUM(CASE WHEN role = 'user' THEN 1 ELSE 0 END), 0) \
FROM messages WHERE session_id = ?1",
params![session_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
tx.execute(
"UPDATE sessions SET message_count = ?2, user_turn_count = ?3, \
updated_at = ?4, last_active_at = ?4, archived_at = NULL \
WHERE id = ?1 AND deleted_at IS NULL",
params![session_id, total_count, user_turn_count, now],
)?;
tx.commit()?;
Ok(())
}
pub fn mark_agent_prompt_reinjected(&self, session_id: &str) -> Result<(), StorageError> {
let now = current_timestamp();
let conn = self.pool.get()?;
@ -1657,8 +1541,6 @@ impl SessionStore {
load_messages_after(&conn, session_id, 0)
}
/// LLM 视角:只返回 is_compacted = 0 的消息(压缩摘要 + 未被压缩的新消息)。
/// 被压缩消费掉的原始消息is_compacted = 1对 LLM 不可见,以节省 context。
pub fn load_messages_for_topic(
&self,
topic_id: &str,
@ -1669,53 +1551,9 @@ impl SessionStore {
if let Some(sid) = session_id {
let mut stmt = conn.prepare(
"
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens
FROM messages
WHERE topic_id = ?1 AND session_id = ?2 AND is_compacted = 0
ORDER BY seq ASC
",
)?;
let rows = stmt.query_map(params![topic_id, sid], map_chat_message_row)?;
let mut messages = Vec::new();
for row in rows {
messages.push(row?);
}
Ok(messages)
} else {
let mut stmt = conn.prepare(
"
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens
FROM messages
WHERE topic_id = ?1 AND is_compacted = 0
ORDER BY seq ASC
",
)?;
let rows = stmt.query_map(params![topic_id], map_chat_message_row)?;
let mut messages = Vec::new();
for row in rows {
messages.push(row?);
}
Ok(messages)
}
}
/// UI 视角:返回原始消息(含被压缩消费的 is_compacted=1 消息)+ 未压缩新消息,
/// 排除压缩摘要消息system_context LIKE 'history_compaction%')。
/// 用于前端历史展示、/current、/save-topic、topic 描述生成等场景。
pub fn load_messages_for_topic_full(
&self,
topic_id: &str,
session_id: Option<&str>,
) -> Result<Vec<ChatMessage>, StorageError> {
let conn = self.pool.get()?;
if let Some(sid) = session_id {
let mut stmt = conn.prepare(
"
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms
FROM messages
WHERE topic_id = ?1 AND session_id = ?2
AND (system_context IS NULL OR system_context NOT LIKE 'history_compaction%')
ORDER BY seq ASC
",
)?;
@ -1728,10 +1566,9 @@ impl SessionStore {
} else {
let mut stmt = conn.prepare(
"
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms
FROM messages
WHERE topic_id = ?1
AND (system_context IS NULL OR system_context NOT LIKE 'history_compaction%')
ORDER BY seq ASC
",
)?;
@ -1744,19 +1581,9 @@ impl SessionStore {
}
}
/// 获取指定话题的消息数量。
///
/// 使用 `SELECT COUNT(*)` 在数据库侧计数,避免将所有消息
/// (含 content、tool_calls_json 等大字段反序列化)加载到内存。
/// 查询命中 `idx_messages_topic_seq(topic_id, seq)` 索引。
/// 获取指定话题的消息数量(动态计算,确保准确)
pub fn get_topic_message_count(&self, topic_id: &str) -> Result<usize, StorageError> {
let conn = self.pool.get()?;
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM messages WHERE topic_id = ?1",
params![topic_id],
|row| row.get(0),
)?;
Ok(count as usize)
self.load_messages_for_topic(topic_id, None).map(|msgs| msgs.len())
}
pub fn load_all_messages(&self, session_id: &str) -> Result<Vec<ChatMessage>, StorageError> {
@ -1778,184 +1605,6 @@ impl SessionStore {
.map_err(StorageError::from)
}
/// 批量查询多个 topic 的 token 消耗统计cost 累计 + context 瞬时)。
///
/// 按 `topic_id` 聚合而非 `session_id`:一个 session 可包含多个 topic
/// 若按 session_id 聚合会导致同 session 下的所有 topic 显示相同的总和。
///
/// 子代理隔离:子代理消息持久化时 session_id='sub:...'topic_id=父 topic_id
/// (见 task::runtime PersistingEmittedMessageHandler 构造),因此不能仅靠
/// topic_id 隔离。此处用 `session_id NOT LIKE 'sub:%'` 显式排除子代理消息,
/// 与项目约定一致session 列表同样过滤 'sub:%')。子代理 token 不计入父 topic
/// 保持"子代理分别计算"语义。
pub fn batch_topic_token_stats(
&self,
topic_ids: &[&str],
) -> Result<HashMap<String, SessionTokenStats>, StorageError> {
if topic_ids.is_empty() {
return Ok(HashMap::new());
}
let conn = self.pool.get()?;
let placeholders = (0..topic_ids.len())
.map(|i| format!("?{}", i + 1))
.collect::<Vec<_>>()
.join(", ");
// topic_id IN (...) 自动排除 NULL topic_id 的旧消息;
// session_id NOT LIKE 'sub:%' 排除子代理消息(其 topic_id=父 topic_id
let sum_sql = format!(
"SELECT topic_id, \
COALESCE(SUM(prompt_tokens), 0) AS sum_prompt, \
COALESCE(SUM(completion_tokens), 0) AS sum_completion, \
COALESCE(SUM(total_tokens), 0) AS sum_total \
FROM messages \
WHERE topic_id IN ({placeholders}) AND role = 'assistant' \
AND session_id NOT LIKE 'sub:%' \
GROUP BY topic_id"
);
let mut stmt = conn.prepare(&sum_sql)?;
let params: Vec<&dyn rusqlite::ToSql> = topic_ids
.iter()
.map(|s| s as &dyn rusqlite::ToSql)
.collect();
let sum_rows = stmt.query_map(params.as_slice(), |row| {
Ok((
row.get::<_, String>(0)?,
SessionTokenStats {
prompt_tokens: row.get::<_, i64>(1)? as u64,
completion_tokens: row.get::<_, i64>(2)? as u64,
total_tokens: row.get::<_, i64>(3)? as u64,
last_prompt_tokens: None,
context_window_tokens: None,
},
))
})?;
let mut stats: HashMap<String, SessionTokenStats> = HashMap::new();
for row in sum_rows {
let (tid, s) = row?;
stats.insert(tid, s);
}
// 查找每个 topic 中最新的**有 usage 数据的** assistant 消息,
// 读取其 prompt_tokens 和 context_window_tokens。
// 过滤 prompt_tokens IS NOT NULL 确保跳过 error/cancel 消息usage 为 NULL
// session_id NOT LIKE 'sub:%' 排除子代理消息,避免取到子代理的 context_window。
//
// 注意seq 是 session 级递增(见 append_message_with_topic主 session 与
// 子代理 session 各自独立计数,可能存在相同 seq。外层 WHERE 必须再次过滤
// session_id NOT LIKE 'sub:%',否则 JOIN 会同时匹配主消息和子代理消息,
// 导致重复行并使 stats.entry(tid) 被覆盖,结果不确定。
let last_sql = format!(
"SELECT m.topic_id, m.prompt_tokens, m.context_window_tokens \
FROM messages m \
INNER JOIN ( \
SELECT topic_id, MAX(seq) AS max_seq \
FROM messages \
WHERE topic_id IN ({placeholders}) AND role = 'assistant' \
AND prompt_tokens IS NOT NULL \
AND session_id NOT LIKE 'sub:%' \
GROUP BY topic_id \
) latest ON m.topic_id = latest.topic_id AND m.seq = latest.max_seq \
WHERE m.session_id NOT LIKE 'sub:%'"
);
let mut stmt2 = conn.prepare(&last_sql)?;
let params2: Vec<&dyn rusqlite::ToSql> = topic_ids
.iter()
.map(|s| s as &dyn rusqlite::ToSql)
.collect();
let last_rows = stmt2.query_map(params2.as_slice(), |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<i64>>(1)?,
row.get::<_, Option<i64>>(2)?,
))
})?;
for row in last_rows {
let (tid, last_prompt, last_ctx_window) = row?;
let entry = stats.entry(tid).or_insert(SessionTokenStats {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
last_prompt_tokens: None,
context_window_tokens: None,
});
entry.last_prompt_tokens = last_prompt.map(|v| v as u32);
entry.context_window_tokens = last_ctx_window.map(|v| v as u32);
}
Ok(stats)
}
/// 查询单个 session 的 token 消耗统计cost 累计 + context 瞬时)。
///
/// 按 `session_id` 精确匹配查询,**不过滤** `sub:%`——专门用于子代理 session
/// session_id = `sub:...`)的 token 统计。子代理没有 topic一个 session 即
/// 一个完整执行单元,因此按 session 维度聚合而非 topic 维度。
///
/// 与 `batch_topic_token_stats` 共享同一套 SQL 模式SUM + last差异仅在
/// WHERE 条件:单 session 精确匹配,无 `NOT LIKE 'sub:%'` 过滤,无 topic 维度。
pub fn get_session_token_stats(
&self,
session_id: &str,
) -> Result<Option<SessionTokenStats>, StorageError> {
let conn = self.pool.get()?;
// 1. SUM 查询:累计 prompt/completion/total
let sum_sql = "SELECT \
COALESCE(SUM(prompt_tokens), 0), \
COALESCE(SUM(completion_tokens), 0), \
COALESCE(SUM(total_tokens), 0) \
FROM messages \
WHERE session_id = ?1 AND role = 'assistant'";
let mut stmt = conn.prepare(sum_sql)?;
let sum_row = stmt.query_row(params![session_id], |row| {
Ok((
row.get::<_, i64>(0)? as u64,
row.get::<_, i64>(1)? as u64,
row.get::<_, i64>(2)? as u64,
))
})?;
let (prompt_tokens, completion_tokens, total_tokens) = sum_row;
// 无 assistant 消息时直接返回 None
if total_tokens == 0 && prompt_tokens == 0 && completion_tokens == 0 {
// 需要二次确认是否真的没有 assistant 消息usage 全 0 也可能是合法的)
let count_sql =
"SELECT COUNT(*) FROM messages WHERE session_id = ?1 AND role = 'assistant'";
let count: i64 = conn.query_row(count_sql, params![session_id], |row| row.get(0))?;
if count == 0 {
return Ok(None);
}
}
// 2. last 查询:最新有 usage 的 assistant 消息的 prompt_tokens + context_window_tokens
let last_sql = "SELECT prompt_tokens, context_window_tokens \
FROM messages \
WHERE session_id = ?1 AND role = 'assistant' AND prompt_tokens IS NOT NULL \
ORDER BY seq DESC LIMIT 1";
let mut stmt2 = conn.prepare(last_sql)?;
let last_row = stmt2
.query_row(params![session_id], |row| {
Ok((row.get::<_, Option<i64>>(0)?, row.get::<_, Option<i64>>(1)?))
})
.optional()?;
let (last_prompt_tokens, context_window_tokens) = match last_row {
Some((lp, lcw)) => (lp.map(|v| v as u32), lcw.map(|v| v as u32)),
None => (None, None),
};
Ok(Some(SessionTokenStats {
prompt_tokens,
completion_tokens,
total_tokens,
last_prompt_tokens,
context_window_tokens,
}))
}
pub fn replace_todos(
&self,
scope_key: &str,
@ -1970,7 +1619,10 @@ impl SessionStore {
let now = current_timestamp();
// Delete existing todos for this scope_key
tx.execute("DELETE FROM todos WHERE scope_key = ?1", params![scope_key])?;
tx.execute(
"DELETE FROM todos WHERE scope_key = ?1",
params![scope_key],
)?;
// Insert new todos
for item in items {
@ -2017,7 +1669,7 @@ impl SessionStore {
for row in rows {
result.push(row?);
}
drop(stmt); // 释放 stmt 借用,才能 commit
drop(stmt); // 释放 stmt 借用,才能 commit
tx.commit()?;
Ok(result)
}
@ -2071,7 +1723,7 @@ pub fn persistent_session_id(channel_name: &str, chat_id: &str) -> String {
#[cfg(not(test))]
fn default_session_db_path() -> Result<PathBuf, std::io::Error> {
let home = crate::platform::picobot_home_dir();
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
Ok(home.join(".picobot").join("storage").join("sessions.db"))
}
@ -2091,8 +1743,8 @@ fn insert_message_with_seq(
"
INSERT INTO messages (
id, session_id, seq, role, content,
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
",
params![
message.id,
@ -2107,10 +1759,6 @@ fn insert_message_with_seq(
message.tool_name,
tool_calls_json,
message.tool_duration_ms.map(|v| v as i64),
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
message.usage.as_ref().map(|u| u.completion_tokens as i64),
message.usage.as_ref().map(|u| u.total_tokens as i64),
message.usage.as_ref().and_then(|u| u.context_window_tokens.map(|v| v as i64)),
message.timestamp,
],
)?;
@ -2139,8 +1787,8 @@ fn insert_message_with_topic_seq(
"
INSERT INTO messages (
id, session_id, topic_id, seq, role, content,
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
",
params![
message.id,
@ -2156,10 +1804,6 @@ fn insert_message_with_topic_seq(
message.tool_name,
tool_calls_json,
message.tool_duration_ms.map(|v| v as i64),
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
message.usage.as_ref().map(|u| u.completion_tokens as i64),
message.usage.as_ref().map(|u| u.total_tokens as i64),
message.usage.as_ref().and_then(|u| u.context_window_tokens.map(|v| v as i64)),
message.timestamp,
],
)?;
@ -2180,8 +1824,6 @@ fn clone_message_for_compaction(message: &ChatMessage, timestamp: i64) -> ChatMe
tool_state: message.tool_state.clone(),
tool_duration_ms: message.tool_duration_ms,
tool_calls: message.tool_calls.clone(),
// 压缩克隆不保留 usage压缩产生的是合成消息不代表真实 LLM 调用
usage: None,
}
}
@ -2193,7 +1835,7 @@ fn load_messages_between(
) -> Result<Vec<ChatMessage>, StorageError> {
let mut stmt = conn.prepare(
"
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms
FROM messages
WHERE session_id = ?1 AND seq > ?2 AND seq <= ?3
ORDER BY seq ASC
@ -2239,7 +1881,6 @@ fn load_messages_between(
tool_state: None,
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
tool_calls,
usage: map_usage_row(row, 11, 12, 13, 14)?,
})
},
)?;
@ -2258,7 +1899,7 @@ fn load_messages_after(
) -> Result<Vec<ChatMessage>, StorageError> {
let mut stmt = conn.prepare(
"
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms
FROM messages
WHERE session_id = ?1 AND seq > ?2
ORDER BY seq ASC
@ -2301,7 +1942,6 @@ fn load_messages_after(
tool_state: None,
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
tool_calls,
usage: map_usage_row(row, 11, 12, 13, 14)?,
})
})?;
@ -2310,6 +1950,13 @@ fn load_messages_after(
messages.push(row?);
}
Ok(messages)
}
fn current_timestamp() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_millis() as i64
}
fn quote_fts_query(query: &str) -> String {

View File

@ -28,14 +28,6 @@ pub trait ConversationRepository: Send + Sync + 'static {
session_id: Option<&str>,
) -> Result<Vec<ChatMessage>, StorageError>;
/// UI 视角:返回原始消息(含被压缩消费的 is_compacted=1 消息)+ 未压缩新消息,
/// 排除压缩摘要消息system_context LIKE 'history_compaction%')。
fn load_messages_for_topic_full(
&self,
topic_id: &str,
session_id: Option<&str>,
) -> Result<Vec<ChatMessage>, StorageError>;
fn append_message(&self, session_id: &str, message: &ChatMessage) -> Result<(), StorageError>;
fn append_message_with_topic(
@ -82,15 +74,6 @@ pub trait ConversationRepository: Send + Sync + 'static {
topic_id: &str,
messages: &[ChatMessage],
) -> Result<(), StorageError>;
/// 压缩该 topic 的历史:保留原始消息(标记 is_compacted=1+ 插入压缩摘要。
/// 不删除原消息让前端仍能展示完整原始对话LLM 只看压缩后的精简历史。
fn compact_topic_history(
&self,
session_id: &str,
topic_id: &str,
new_messages: &[ChatMessage],
) -> Result<(), StorageError>;
}
pub trait PromptInjectionRepository: Send + Sync + 'static {
@ -289,23 +272,6 @@ impl ConversationRepository for super::SessionStore {
) -> Result<(), StorageError> {
super::SessionStore::replace_topic_history(self, session_id, topic_id, messages)
}
fn load_messages_for_topic_full(
&self,
topic_id: &str,
session_id: Option<&str>,
) -> Result<Vec<ChatMessage>, StorageError> {
super::SessionStore::load_messages_for_topic_full(self, topic_id, session_id)
}
fn compact_topic_history(
&self,
session_id: &str,
topic_id: &str,
new_messages: &[ChatMessage],
) -> Result<(), StorageError> {
super::SessionStore::compact_topic_history(self, session_id, topic_id, new_messages)
}
}
impl PromptInjectionRepository for super::SessionStore {

View File

@ -8,38 +8,18 @@ pub const GLOBAL_SCOPE_KEY: &str = "default";
/// 每个命名空间代表一类记忆内容,用于分类管理和检索。
/// 禁止使用未在此列表中的 namespace 创建记忆。
pub const ALLOWED_MEMORY_NAMESPACES: &[(&str, &str)] = &[
(
"user",
"用户记忆:存储用户长期偏好、身份背景和历史协作信息,实现跨会话的个性化服务与持续协作",
),
(
"semantic",
"语义记忆:存储结构化或非结构化知识内容,支持知识检索、问答增强和长期知识积累",
),
(
"episodic",
"情景记忆:记录历史对话、任务执行过程及关键事件,支持经验回溯、案例复用和行为追踪",
),
(
"skill",
"技能记忆:存储技能定义、工作流、工具调用策略及最佳实践,支持能力复用与自动化执行",
),
(
"environment",
"环境记忆:存储外部系统状态、运行环境配置和实时资源信息,为智能决策提供环境感知能力",
),
(
"reflection",
"反思记忆:沉淀任务执行过程中的成功经验、失败原因和优化建议,支持智能体持续学习与自我改进",
),
("user", "用户记忆:存储用户长期偏好、身份背景和历史协作信息,实现跨会话的个性化服务与持续协作"),
("semantic", "语义记忆:存储结构化或非结构化知识内容,支持知识检索、问答增强和长期知识积累"),
("episodic", "情景记忆:记录历史对话、任务执行过程及关键事件,支持经验回溯、案例复用和行为追踪"),
("skill", "技能记忆:存储技能定义、工作流、工具调用策略及最佳实践,支持能力复用与自动化执行"),
("environment", "环境记忆:存储外部系统状态、运行环境配置和实时资源信息,为智能决策提供环境感知能力"),
("reflection", "反思记忆:沉淀任务执行过程中的成功经验、失败原因和优化建议,支持智能体持续学习与自我改进"),
("other", "其他记忆:不属于以上分类的其他记忆内容"),
];
/// 验证 namespace 是否在允许列表中
pub fn is_valid_namespace(namespace: &str) -> bool {
ALLOWED_MEMORY_NAMESPACES
.iter()
.any(|(name, _)| *name == namespace)
ALLOWED_MEMORY_NAMESPACES.iter().any(|(name, _)| *name == namespace)
}
/// 获取 namespace 的中文描述
@ -52,10 +32,7 @@ pub fn get_namespace_description(namespace: &str) -> Option<&'static str> {
/// 获取所有允许的 namespace 名称列表(用于 JSON schema enum
pub fn allowed_namespace_names() -> Vec<&'static str> {
ALLOWED_MEMORY_NAMESPACES
.iter()
.map(|(name, _)| *name)
.collect()
ALLOWED_MEMORY_NAMESPACES.iter().map(|(name, _)| *name).collect()
}
#[derive(Debug, Clone)]
@ -111,16 +88,6 @@ pub struct TopicRecord {
pub message_count: i64,
}
/// 单个 session 的 token 用量统计(聚合结果)。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionTokenStats {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
pub last_prompt_tokens: Option<u32>,
pub context_window_tokens: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryRecord {
pub id: String,

View File

@ -8,37 +8,12 @@
use rusqlite::{Connection, OptionalExtension, params};
use crate::bus::ChatMessage;
use crate::bus::message::MessageUsage;
use super::{
MemoryRecord, SchedulerJobRecord, SchedulerJobState, SchedulerJobStatus, SessionRecord,
SkillEventRecord, StorageError,
};
/// 从指定列索引读取 token usage 四元组(含 context_window_tokens
pub(super) fn map_usage_row(
row: &rusqlite::Row<'_>,
prompt_idx: usize,
completion_idx: usize,
total_idx: usize,
context_window_idx: usize,
) -> rusqlite::Result<Option<MessageUsage>> {
let prompt: Option<i64> = row.get(prompt_idx)?;
let completion: Option<i64> = row.get(completion_idx)?;
let total: Option<i64> = row.get(total_idx)?;
let context_window: Option<i64> = row.get(context_window_idx)?;
if prompt.is_none() && completion.is_none() && total.is_none() && context_window.is_none() {
Ok(None)
} else {
Ok(Some(MessageUsage {
prompt_tokens: prompt.unwrap_or(0) as u32,
completion_tokens: completion.unwrap_or(0) as u32,
total_tokens: total.unwrap_or(0) as u32,
context_window_tokens: context_window.map(|v| v as u32),
}))
}
}
pub(super) fn get_session_with_conn(
conn: &Connection,
session_id: &str,
@ -122,9 +97,7 @@ pub(super) fn map_session_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<Se
})
}
pub(super) fn map_skill_event_record(
row: &rusqlite::Row<'_>,
) -> rusqlite::Result<SkillEventRecord> {
pub(super) fn map_skill_event_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<SkillEventRecord> {
let payload_json: String = row.get(4)?;
let payload = serde_json::from_str(&payload_json).map_err(|err| {
rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(err))
@ -156,7 +129,11 @@ pub(super) fn map_chat_message_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<
.map(serde_json::from_str)
.transpose()
.map_err(|err| {
rusqlite::Error::FromSqlConversionFailure(9, rusqlite::types::Type::Text, Box::new(err))
rusqlite::Error::FromSqlConversionFailure(
9,
rusqlite::types::Type::Text,
Box::new(err),
)
})?;
Ok(ChatMessage {
@ -172,7 +149,6 @@ pub(super) fn map_chat_message_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<
tool_state: None,
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
tool_calls,
usage: map_usage_row(row, 11, 12, 13, 14)?,
})
}

Some files were not shown because too many files have changed in this diff Show More