Compare commits
No commits in common. "f05f09b63648a4d729f0ca7313485cb59d789482" and "7de9a8a054f0d557b79bf196d65c21bb86d0c17f" have entirely different histories.
f05f09b636
...
7de9a8a054
2
.gitignore
vendored
2
.gitignore
vendored
@ -40,5 +40,3 @@ node_modules
|
||||
logs
|
||||
dist
|
||||
.trae
|
||||
.opencode/
|
||||
artifacts/
|
||||
|
||||
133
Cargo.lock
generated
133
Cargo.lock
generated
@ -466,15 +466,6 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
@ -888,25 +879,6 @@ dependencies = [
|
||||
"yaml-rust2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
@ -965,12 +937,6 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
@ -1038,7 +1004,6 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@ -1059,9 +1024,7 @@ dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
@ -1478,52 +1441,6 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "metrics"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3045b4193fbdc5b5681f32f11070da9be3609f189a79f3390706d42587f46bb5"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "metrics-exporter-prometheus"
|
||||
version = "0.15.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4f0c8427b39666bf970460908b213ec09b3b350f20c0c2eabcbba51704a08e6"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"indexmap",
|
||||
"ipnet",
|
||||
"metrics",
|
||||
"metrics-util",
|
||||
"quanta",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "metrics-util"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4259040465c955f9f2f1a4a8a16dc46726169bca0f88e8fb2dbeced487c3e828"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.14.5",
|
||||
"metrics",
|
||||
"num_cpus",
|
||||
"quanta",
|
||||
"sketches-ddsketch",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "meval"
|
||||
version = "0.2.0"
|
||||
@ -1639,16 +1556,6 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_cpus"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@ -1728,7 +1635,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picobot"
|
||||
version = "0.3.5"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@ -1747,8 +1654,6 @@ dependencies = [
|
||||
"iana-time-zone",
|
||||
"image",
|
||||
"libc",
|
||||
"metrics",
|
||||
"metrics-exporter-prometheus",
|
||||
"meval",
|
||||
"mime_guess",
|
||||
"parking_lot",
|
||||
@ -1805,12 +1710,6 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@ -1897,21 +1796,6 @@ version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||
|
||||
[[package]]
|
||||
name = "quanta"
|
||||
version = "0.12.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"raw-cpuid",
|
||||
"wasi",
|
||||
"web-sys",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
@ -2063,15 +1947,6 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@ -2687,12 +2562,6 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "sketches-ddsketch"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "picobot"
|
||||
version = "0.3.5"
|
||||
version = "0.3.3"
|
||||
edition = "2024"
|
||||
|
||||
[lints.rust]
|
||||
@ -41,8 +41,6 @@ prost = "0.14"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
tracing-appender = "0.2"
|
||||
metrics = "0.23"
|
||||
metrics-exporter-prometheus = "0.15"
|
||||
anyhow = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
|
||||
@ -1,588 +0,0 @@
|
||||
# 异步子 Agent 方案设计
|
||||
|
||||
> 版本: v8 (最终版)
|
||||
> 日期: 2026-08-12
|
||||
> 状态: 设计定稿
|
||||
|
||||
## 一、核心思路
|
||||
|
||||
子 agent 执行与父 agent 流程解耦,通过**双通道**实现异步通信:
|
||||
|
||||
- **子代理结果**走独立队列 `sub_done_q`(直达 wait,不绕 SQLite → bus → reload)
|
||||
- **用户消息**走现有 `bus`(保持现有机制,零 channel 适配器改动)
|
||||
|
||||
wait 工具通过 **释放锁 + select! 等待 + 重新获取锁** 实现真等待,bus 消息能在 wait 期间通过 `is_waiting` 分流注入 history 并唤醒 wait。
|
||||
|
||||
### 核心机制
|
||||
|
||||
```
|
||||
sub_done_q(独立队列) ← 后台子代理完成时注入 → wait select! 直达消费
|
||||
bus(现有机制) ← ws.rs 用户消息注入 → process_one 分流注入 + wakeup 唤醒 wait
|
||||
```
|
||||
|
||||
## 二、关键设计决策
|
||||
|
||||
| 决策点 | 选择 | 理由 |
|
||||
|---|---|---|
|
||||
| 子 agent 执行方式 | 异步(tokio::spawn) | 消除最大阻塞源 |
|
||||
| 子代理结果通道 | sub_done_q 独立队列 | 直达 wait,不绕 SQLite → bus → reload |
|
||||
| 用户消息通道 | 复用 bus | 现有机制,channel 零改动 |
|
||||
| wait 语义 | 释放锁 + select! + 重获取锁 | 真等待,无死锁,无 TOCTOU |
|
||||
| is_waiting 判断 | 持锁后判断 | 原子操作,无 TOCTOU |
|
||||
| 完成回调内容 | 当前结果 + 未完成子代理 id 列表 | LLM 据此决策下一步 |
|
||||
| task tool_result | running + 引导提示(只提 wait) | LLM 知道应调 wait |
|
||||
| 嵌套层 | 仅顶层异步 | 避免复杂度爆炸 |
|
||||
| 模式1(中断) | 不做 | 复杂度高,收益低 |
|
||||
|
||||
## 三、数据结构
|
||||
|
||||
### 1. SessionHistory 扩展(核心)
|
||||
|
||||
```rust
|
||||
// src/gateway/session_history.rs
|
||||
pub(crate) struct SessionHistory {
|
||||
// 现有字段
|
||||
topic_histories: HashMap<String, Vec<ChatMessage>>,
|
||||
chat_topic_ids: HashMap<String, String>,
|
||||
compression_in_flight: HashSet<String>,
|
||||
topic_serial_locks: HashMap<String, Arc<tokio::sync::Mutex<()>>>,
|
||||
conversations: Arc<dyn ConversationRepository>,
|
||||
skill_events: Arc<dyn SkillEventRepository>,
|
||||
|
||||
// 新增:per-topic 子代理完成队列
|
||||
sub_done_queues: HashMap<String, mpsc::Sender<SubagentResult>>,
|
||||
// 新增:per-topic wait 唤醒信号
|
||||
wait_wakeups: HashMap<String, Arc<tokio::sync::Notify>>,
|
||||
// 新增:per-topic 等待状态
|
||||
waiting_flags: HashMap<String, bool>,
|
||||
}
|
||||
```
|
||||
|
||||
**生命周期与 topic_serial_locks 完全一致**:
|
||||
- 创建:`topic_serial_lock(topic_id)` 时同步创建
|
||||
- 驱逐:`evict_inactive_if_needed` 时同步移除
|
||||
- 清理:topic 删除时同步清理
|
||||
|
||||
### 2. SubagentResult 结构
|
||||
|
||||
```rust
|
||||
struct SubagentResult {
|
||||
task_id: String,
|
||||
status: SubagentStatus, // completed/failed/timeout/cancelled
|
||||
output: String, // 当前子代理的输出(与 task 工具返回格式一致)
|
||||
pending_task_ids: Vec<String>, // 未完成的子代理 id 列表
|
||||
}
|
||||
```
|
||||
|
||||
**关键设计**:每个完成回调都带上 `pending_task_ids`,LLM 据此判断全局进度。
|
||||
|
||||
### 3. pending_subagents 表(新增)
|
||||
|
||||
```sql
|
||||
CREATE TABLE pending_subagents (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
parent_session_id TEXT NOT NULL,
|
||||
parent_topic_id TEXT NOT NULL,
|
||||
parent_chat_id TEXT NOT NULL,
|
||||
parent_channel TEXT NOT NULL,
|
||||
def_name TEXT,
|
||||
spawned_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'running' -- running/completed/failed/interrupted/cancelled
|
||||
);
|
||||
```
|
||||
|
||||
## 四、执行流程
|
||||
|
||||
### 阶段 ① 发起 task
|
||||
|
||||
```
|
||||
用户消息 → bus → process_one → lock(topic) → agent.process()
|
||||
→ LLM 调用 task 工具
|
||||
→ TaskTool::execute():
|
||||
① INSERT pending_subagents (status='running')
|
||||
② tokio::spawn 子 agent(后台执行)
|
||||
③ 立即返回 tool_result:
|
||||
"running, task_id=t1。
|
||||
调用 wait_for_subagents(timeout_secs) 等待子代理完成并获取结果。"
|
||||
→ LLM 看到 running + 引导,自主决策下一步
|
||||
```
|
||||
|
||||
**LLM 的 3 种可能行为**:
|
||||
- 调用 wait_for_subagents(主动等待)— 主路径
|
||||
- 调用其他工具(继续工作)— 罕见
|
||||
- 生成最终回答(不调 wait)— 后端兜底
|
||||
|
||||
### 阶段 ② 后台子 agent 执行
|
||||
|
||||
```
|
||||
tokio::spawn 独立运行:
|
||||
→ SubAgentEmitter 实时推消息流给前端
|
||||
→ Semaphore 限流(默认 8 并发)
|
||||
→ 执行完成或失败或超时
|
||||
```
|
||||
|
||||
### 阶段 ③ 子 agent 完成回调(直达 sub_done_q)
|
||||
|
||||
```
|
||||
后台 task 结束:
|
||||
① 查询 pending_subagents WHERE parent_topic_id=? AND status='running'
|
||||
→ 得到未完成列表 ["t2", "t3"]
|
||||
② sub_done_q.send(SubagentResult{
|
||||
task_id: "t1",
|
||||
status: "completed",
|
||||
output: "{与 task 工具一致的输出格式}",
|
||||
pending_task_ids: ["t2", "t3"]
|
||||
})
|
||||
③ UPDATE pending_subagents SET status='completed' WHERE task_id='t1'
|
||||
```
|
||||
|
||||
**关键**:子代理结果**不写 SQLite、不入 bus**,直接通过 sub_done_q 传递给 wait。
|
||||
|
||||
**顺序保证**:查询未完成 → send → UPDATE
|
||||
|
||||
### 阶段 ④ wait 工具(释放锁 + select! + 重获取锁)
|
||||
|
||||
```
|
||||
LLM 调用 wait_for_subagents(timeout_secs):
|
||||
① 查询 pending_subagents
|
||||
→ 无 running → 返回 "无需等待",继续循环
|
||||
→ 有 running → 进入等待
|
||||
|
||||
② 释放锁,标记等待
|
||||
drop(lock_guard)
|
||||
session.set_waiting(topic_id, true)
|
||||
|
||||
③ select! 等待(不持锁)
|
||||
select! {
|
||||
result = sub_done_q.recv() => Event::Subagent(result),
|
||||
_ = wakeup.notified() => Event::UserMessage,
|
||||
_ = timeout(60s) => Event::Timeout,
|
||||
}
|
||||
|
||||
④ 重新获取锁,清除标记(顺序重要:先获取锁,再清除)
|
||||
let lock_guard = lock(topic_id).await
|
||||
session.set_waiting(topic_id, false)
|
||||
|
||||
⑤ 返回 tool_result
|
||||
Subagent(r) => "子代理 {task_id} 完成: {output}。未完成: {pending}"
|
||||
UserMessage => "有新用户消息到达"
|
||||
Timeout => "等待超时。未完成子代理: {pending}"
|
||||
```
|
||||
|
||||
### 阶段 ⑤ process_one 分流(wait 期间注入用户消息)
|
||||
|
||||
```rust
|
||||
async fn process_one(&self, inbound: InboundMessage) {
|
||||
let guard = lock(topic_id).await;
|
||||
|
||||
if session.is_waiting(topic_id) {
|
||||
// wait 期间:只注入消息到 history,不启动 process
|
||||
session.inject_to_history(inbound).await; // INSERT + 更新内存 history
|
||||
drop(guard);
|
||||
session.wakeup(topic_id); // 唤醒 wait
|
||||
return;
|
||||
}
|
||||
|
||||
// 正常处理(topic 空闲或 agent 已退出)
|
||||
agent.process().await;
|
||||
}
|
||||
```
|
||||
|
||||
### 阶段 ⑥ process 退出兜底
|
||||
|
||||
```
|
||||
process 退出前:
|
||||
if waited=true:
|
||||
→ 不发 ExecutionCompleted(等 bus 消息触发新轮)
|
||||
else if pending_subagents 有 running:
|
||||
→ 不发 ExecutionCompleted(LLM 没调 wait 但有 pending)
|
||||
else:
|
||||
→ 发 ExecutionCompleted
|
||||
```
|
||||
|
||||
## 五、wait 工具的完整设计
|
||||
|
||||
### 工具定义
|
||||
|
||||
```rust
|
||||
// 工具名:wait_for_subagents
|
||||
// 参数:timeout_secs(可选,默认 60s)
|
||||
// 描述:等待子代理完成或用户消息。用于有 pending 子代理时进入等待状态。
|
||||
|
||||
wait_for_subagents(timeout_secs: Option<u64>) -> ToolResult {
|
||||
let timeout = Duration::from_secs(timeout_secs.unwrap_or(60));
|
||||
let pending = query_pending_task_ids(topic_id);
|
||||
|
||||
if pending.is_empty() {
|
||||
return ToolResult {
|
||||
output: "无 pending 子代理,无需等待".to_string(),
|
||||
metadata: { wait_marker: false }
|
||||
};
|
||||
}
|
||||
|
||||
// 释放锁,标记等待
|
||||
drop(lock_guard);
|
||||
session.set_waiting(topic_id, true);
|
||||
|
||||
// 等待(不持锁)
|
||||
let event = select! {
|
||||
result = sub_done_q.recv() => Event::Subagent(result),
|
||||
_ = wakeup.notified() => Event::UserMessage,
|
||||
_ = sleep(timeout) => Event::Timeout,
|
||||
};
|
||||
|
||||
// 重新获取锁,清除标记(顺序重要)
|
||||
let lock_guard = lock(topic_id).await;
|
||||
session.set_waiting(topic_id, false);
|
||||
|
||||
// 先 drain sub_done_q 积压(处理多子代理同时完成)
|
||||
match event {
|
||||
Event::Subagent(result) => {
|
||||
format_subagent_result(result)
|
||||
}
|
||||
Event::UserMessage => {
|
||||
"有新用户消息到达".to_string()
|
||||
}
|
||||
Event::Timeout => {
|
||||
let pending = query_pending_task_ids(topic_id);
|
||||
format!("等待超时。未完成子代理: {:?}", pending)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### wait 的三种返回场景
|
||||
|
||||
| 事件 | 返回内容 | LLM 行为 |
|
||||
|---|---|---|
|
||||
| 子代理完成 | "子代理 t1 完成: {output}。未完成: [t2, t3]" | 处理结果,看 pending 决定继续 wait 或综合 |
|
||||
| 用户消息到达 | "有新用户消息到达" | LLM 下一轮看到注入的 user 消息 |
|
||||
| 超时 | "等待超时。未完成子代理: [t1, t2]" | 决定继续等或放弃 |
|
||||
|
||||
## 六、两个通道的职责(核心设计)
|
||||
|
||||
### sub_done_q:wait 专属消费
|
||||
|
||||
```
|
||||
后台子代理完成 → sub_done_q.send(result) ← 不获取锁,直达 wait
|
||||
↓
|
||||
wait select! → 立即收到 → 返回给 LLM
|
||||
```
|
||||
|
||||
**不经过 SQLite、不经过 bus**,直接队列传递。
|
||||
|
||||
### bus:用户消息,wait 期间分流注入
|
||||
|
||||
```
|
||||
用户消息 → bus.publish_inbound(UserMessage)
|
||||
→ process_one → lock(topic)
|
||||
├─ is_waiting=true → 注入 history + wakeup 唤醒 wait
|
||||
└─ is_waiting=false → 正常启动 process
|
||||
```
|
||||
|
||||
**wait 期间**:用户消息持锁注入 history,wakeup 唤醒 wait。
|
||||
**非 wait 期间**:正常走 process_one。
|
||||
|
||||
## 七、LLM 看到的信息流
|
||||
|
||||
| 时机 | LLM 看到的内容 |
|
||||
|---|---|
|
||||
| task 调用后 | `tool_result("running, task_id=t1。调用 wait_for_subagents 等待...")` |
|
||||
| wait 调用后 | `tool_result("已进入等待,2 个子代理运行中: [t1,t2]")` |
|
||||
| t1 完成(wait 返回) | `tool_result("子代理 t1 完成: {output}。未完成: [t2]")` |
|
||||
| 用户消息(wait 返回) | `tool_result("有新用户消息到达")` + 下轮 `user("用户消息")` |
|
||||
| t2 完成(wait 返回) | `tool_result("子代理 t2 完成: {output}。未完成: []")` |
|
||||
|
||||
LLM 通过 `pending_task_ids` 能判断:
|
||||
- `[]` 空列表 → 全部完成,综合回答
|
||||
- `["t3"]` 非空 → 还有未完成的,继续调 wait
|
||||
|
||||
## 八、消息的完整路径
|
||||
|
||||
### 子代理结果
|
||||
|
||||
```
|
||||
后台 task 完成 → sub_done_q.send(SubagentResult)
|
||||
→ wait select! 立即收到 → 返回 tool_result 给 LLM
|
||||
```
|
||||
**直达,不绕路**。
|
||||
|
||||
### 用户消息
|
||||
|
||||
```
|
||||
所有 channel 消息 → bus.publish_inbound(UserMessage)
|
||||
→ process_one → lock(topic)
|
||||
├─ is_waiting=true → 注入 history + wakeup 唤醒 wait
|
||||
│ → wait 返回 "有新用户消息到达"
|
||||
│ → LLM 下一轮看到注入的 user 消息
|
||||
└─ is_waiting=false → 正常启动 process
|
||||
```
|
||||
|
||||
**两条路径职责清晰,互不干扰**。
|
||||
|
||||
## 九、三个边界问题的解法
|
||||
|
||||
### 边界 1:wakeup vs timeout 竞态
|
||||
|
||||
```
|
||||
timeout 和 wakeup 同时触发 → select! 随机选一个
|
||||
如果 timeout 赢:
|
||||
- 用户消息已注入 history(process_one 在锁内完成注入)
|
||||
- wait 返回 "超时"
|
||||
- LLM 下一轮 reload history 会看到用户消息
|
||||
- 不会丢失
|
||||
```
|
||||
**解法**:无需特殊处理,消息已持久化,reload 能读到。
|
||||
|
||||
### 边界 2:is_waiting 清除时机
|
||||
|
||||
```
|
||||
错误顺序:清除 is_waiting → 获取锁
|
||||
→ 中间窗口 process_one 看到 is_waiting=false → 启动 process → 死锁
|
||||
|
||||
正确顺序:获取锁 → 清除 is_waiting
|
||||
→ 持锁后才清除,process_one 在锁外看到 is_waiting=true → 走注入路径
|
||||
```
|
||||
**解法**:`let guard = lock().await; set_waiting(false);` 顺序保证。
|
||||
|
||||
### 边界 3:多条用户消息
|
||||
|
||||
```
|
||||
wait 释放锁后,3 条用户消息依次到达:
|
||||
msg1 → lock → 注入 → drop → wakeup
|
||||
msg2 → lock → 注入 → drop → wakeup
|
||||
msg3 → lock → 注入 → drop → wakeup
|
||||
|
||||
wakeup 是 Notify,3 次 notify 只存储 1 个
|
||||
wait 被唤醒 1 次,但 history 已有 3 条消息
|
||||
wait 返回 "有新用户消息到达"
|
||||
LLM 下一轮看到 3 条 user 消息
|
||||
```
|
||||
**解法**:Notify 合并是正确行为,history 完整。
|
||||
|
||||
## 十、时序场景表现
|
||||
|
||||
### 场景 1:多子代理并发完成
|
||||
|
||||
```
|
||||
① task(t1, t2, t3) → running
|
||||
② wait → 释放锁 → select!
|
||||
③ t1, t2, t3 几乎同时完成 → sub_done_q: [t1, t2, t3]
|
||||
④ wait select 收到 t1 → 返回 "t1 完成。未完成: [t2, t3]"
|
||||
⑤ LLM 处理 t1 → 再调 wait
|
||||
wait 先 try_recv → 立即拿到 t2 → 返回 "t2 完成。未完成: [t3]"
|
||||
⑥ LLM 处理 t2 → 再调 wait
|
||||
wait 先 try_recv → 立即拿到 t3 → 返回 "t3 完成。未完成: []"
|
||||
⑦ LLM 综合 t1+t2+t3 回答
|
||||
```
|
||||
**3 轮 wait,但无延迟(try_recv 立即返回积压)**。
|
||||
|
||||
### 场景 2:wait 期间用户发消息
|
||||
|
||||
```
|
||||
① task(t1) → running → wait → 释放锁 → select!
|
||||
② 用户发消息 → bus → process_one → lock(wait 已释放,获取成功)
|
||||
→ is_waiting=true → 注入 history + wakeup
|
||||
③ wait 被 wakeup 唤醒 → 重新获取锁 → 返回 "有新用户消息到达"
|
||||
④ LLM 看到 [wait_result, user("用户消息")] → 处理用户消息
|
||||
```
|
||||
**用户消息即时响应,wait 被唤醒**。
|
||||
|
||||
### 场景 3:子代理完成 + 用户消息同时到达
|
||||
|
||||
```
|
||||
① task(t1) → running → wait → 释放锁 → select!
|
||||
② t1 完成 → sub_done_q
|
||||
用户发消息 → bus → process_one → 注入 + wakeup
|
||||
③ select! 随机选一个:
|
||||
- 选 sub_done_q → 返回 "t1 完成" → LLM 下一轮看到 user 消息
|
||||
- 选 wakeup → 返回 "有新用户消息" → LLM 下一轮看到 t1 结果(try_recv)
|
||||
```
|
||||
**两种情况都不丢失消息**。
|
||||
|
||||
### 场景 4:父 agent 生成最终回答(不调 wait)
|
||||
|
||||
```
|
||||
① task(t1) → running
|
||||
② LLM 生成最终回答(无 tool_calls)→ process 退出
|
||||
③ 退出前检查: pending 有 running → 不发 ExecutionCompleted
|
||||
④ t1 完成 → sub_done_q.send(无人消费,留在队列)
|
||||
→ 触发新轮 process_one(通过 bus 或定期检查)
|
||||
⑤ 新轮 LLM 看到 [自己之前的回答] → try_recv sub_done_q → 处理 t1 结果
|
||||
```
|
||||
|
||||
**注意**:此场景需要额外机制触发新轮 process(sub_done_q 有残留时)。可通过:
|
||||
- 后台 task 完成后同时 publish_inbound(bus) 作为触发信号
|
||||
- 或 wait 退出兜底检查 sub_done_q
|
||||
|
||||
### 场景 5:wait 超时
|
||||
|
||||
```
|
||||
① task(t1) → running → wait(60s) → 释放锁 → select!
|
||||
② 60s 内无事件 → timeout 触发
|
||||
③ wait 重新获取锁 → 返回 "等待超时。未完成: [t1]"
|
||||
④ LLM 决定继续等(再调 wait)或放弃
|
||||
```
|
||||
|
||||
## 十一、关键机制
|
||||
|
||||
### 1. 队列与状态生命周期管理
|
||||
|
||||
```rust
|
||||
// session_history.rs
|
||||
|
||||
/// 获取或创建该 topic 的 sub_done_q sender
|
||||
pub(crate) fn sub_done_queue(&mut self, topic_id: &str) -> mpsc::Sender<SubagentResult> {
|
||||
self.sub_done_queues
|
||||
.entry(topic_id.to_string())
|
||||
.or_insert_with(|| {
|
||||
let (tx, _rx) = mpsc::channel(32);
|
||||
tx
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 获取或创建该 topic 的 wakeup
|
||||
pub(crate) fn wait_wakeup(&mut self, topic_id: &str) -> Arc<Notify> {
|
||||
self.wait_wakeups
|
||||
.entry(topic_id.to_string())
|
||||
.or_insert_with(|| Arc::new(Notify::new()))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 设置等待状态
|
||||
pub(crate) fn set_waiting(&mut self, topic_id: &str, waiting: bool) {
|
||||
self.waiting_flags.insert(topic_id.to_string(), waiting);
|
||||
}
|
||||
|
||||
/// 检查等待状态
|
||||
pub(crate) fn is_waiting(&self, topic_id: &str) -> bool {
|
||||
self.waiting_flags.get(topic_id).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
// evict_inactive_if_needed 中同步清理
|
||||
fn evict_inactive_if_needed(&mut self) {
|
||||
// ... 现有逻辑
|
||||
if let Some(tid) = to_evict.cloned() {
|
||||
self.topic_histories.remove(&tid);
|
||||
self.topic_serial_locks.remove(&tid);
|
||||
self.sub_done_queues.remove(&tid); // 新增
|
||||
self.wait_wakeups.remove(&tid); // 新增
|
||||
self.waiting_flags.remove(&tid); // 新增
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. pending_subagents 状态管理
|
||||
|
||||
| 状态 | 含义 |
|
||||
|---|---|
|
||||
| running | 后台执行中 |
|
||||
| completed | 正常完成 |
|
||||
| failed | 执行失败 |
|
||||
| interrupted | 进程崩溃,启动扫描标记 |
|
||||
| cancelled | 用户取消 |
|
||||
|
||||
### 3. 崩溃恢复
|
||||
|
||||
**启动扫描**:
|
||||
```sql
|
||||
UPDATE pending_subagents SET status='interrupted' WHERE status='running';
|
||||
```
|
||||
|
||||
**history 加载时**:
|
||||
- 遇到 `tool_result("running, task_id=...")` → 查 pending_subagents
|
||||
- status=interrupted → 替换为"子代理因重启中断,请决定是否重新发起"
|
||||
|
||||
**两步操作之间崩溃**(查询未完成 → send → UPDATE):
|
||||
- 崩溃在任何点:启动扫描标记 interrupted,history 加载时替换占位
|
||||
- 最坏情况:sub_done_q 消息丢失(wait 未消费),但 pending 表状态可恢复
|
||||
|
||||
### 4. 取消传播
|
||||
|
||||
- `task_id → CancellationToken` 映射
|
||||
- 用户取消 → 遍历 pending → 触发 cancel_token
|
||||
- 子代理退出 → sub_done_q.send(SubagentResult{status:"cancelled"})
|
||||
|
||||
### 5. 去重
|
||||
|
||||
- DefaultSubAgentRuntime 维护 `completed_tasks: HashSet<task_id>`
|
||||
- 入队前检查,已入队的不再入
|
||||
|
||||
### 6. topic_serial_lock 一致性
|
||||
|
||||
- 用户消息走 bus,用 topic_id 作 lock_key
|
||||
- 子代理结果走 sub_done_q,不需要 lock(直达 wait)
|
||||
- wait 释放锁后,bus 的 process_one 能获取锁注入消息
|
||||
|
||||
## 十二、改动范围
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `session_history.rs` | 新增 sub_done_q + wait_wakeup + waiting_flags(per-topic)+ 生命周期 |
|
||||
| `runtime.rs` | spawn 异步化 + 完成回调(含 pending_task_ids)注入 sub_done_q |
|
||||
| `task_tool.rs`(新) | TaskTool 返回 running + 引导提示 + spawn |
|
||||
| `wait_tool.rs`(新) | wait 释放锁 + select! + 重获取锁 + 返回 pending 信息 |
|
||||
| `agent_loop.rs` | 传递 lock_guard 给 wait 工具(支持释放/重获取) |
|
||||
| `processor.rs` | process_one 分流:is_waiting 检查 + 注入 + wakeup |
|
||||
| `db.rs` | pending_subagents 表 + 启动扫描 |
|
||||
| `history.rs` | running 占位替换 |
|
||||
| `cancel.rs` | 取消传播 |
|
||||
|
||||
**预估总改动:~800 行**
|
||||
|
||||
## 十三、与之前方案的对比
|
||||
|
||||
| 维度 | v6(bus + break) | v8(双通道 + wait 释放锁) |
|
||||
|---|---|---|
|
||||
| 子代理结果通道 | bus(绕 SQLite → reload) | **sub_done_q 直达** ✅ |
|
||||
| 用户消息通道 | bus | bus |
|
||||
| wait 语义 | break 退出 | **select! 真等待** ✅ |
|
||||
| wait 期间用户消息 | 等锁(agent 退出后) | **即时注入 + wakeup** ✅ |
|
||||
| is_waiting 判断 | 不需要 | 需要(持锁后判断,无 TOCTOU) |
|
||||
| TOCTOU 风险 | 无 | **无**(持锁后判断) |
|
||||
| 死锁风险 | 无 | **无**(wait 释放锁) |
|
||||
| LLM 调用次数 | 2(batch)/ N+2 | **2-N** |
|
||||
| process 退出 | 每次都退出 reload | wait 不退出,连续推理 |
|
||||
| 上下文连贯 | 每轮 reload | **同 process 内连续** ✅ |
|
||||
| 实现复杂度 | 低 | 中(~20 行新增 + 边界解法) |
|
||||
| channel 适配器改动 | 零 | **零** ✅ |
|
||||
|
||||
## 十四、不做的事
|
||||
|
||||
- ❌ 模式1(中断插入)
|
||||
- ❌ user_msg_q(用户消息走 bus)
|
||||
- ❌ 嵌套层异步
|
||||
- ❌ 子代理结果走 bus(改回 sub_done_q)
|
||||
- ❌ wait 不持锁(改为释放锁 + select + 重获取)
|
||||
|
||||
## 十五、方案定位总结
|
||||
|
||||
| 机制 | 定位 | 通道 |
|
||||
|---|---|---|
|
||||
| wait 释放锁 + select! | **主路径**(真等待,子代理直达) | sub_done_q |
|
||||
| process_one 分流 + wakeup | **用户消息路径**(注入 + 唤醒) | bus |
|
||||
| process 退出兜底(pending 检查) | **安全网**(LLM 不调 wait 时) | - |
|
||||
|
||||
三机制分工清晰:
|
||||
- wait select! 是核心(子代理结果直达 + 用户消息 wakeup)
|
||||
- process_one 分流是用户消息路径(wait 期间注入 + 唤醒)
|
||||
- 退出兜底是安全网(防止 LLM 不调 wait 时丢消息)
|
||||
|
||||
## 十六、配置说明
|
||||
|
||||
```json
|
||||
{
|
||||
"subagent": {
|
||||
"max_concurrent": 8,
|
||||
"spawn_timeout_secs": 300,
|
||||
"wait_default_timeout_secs": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| max_concurrent | 8 | 并发子 agent 数量(Semaphore) |
|
||||
| spawn_timeout_secs | 300 | 子 agent 执行超时 |
|
||||
| wait_default_timeout_secs | 60 | wait 工具默认超时 |
|
||||
@ -2,142 +2,6 @@
|
||||
|
||||
本文件记录 Picobot 各版本的显著变更,遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 风格。
|
||||
|
||||
## [0.3.5] - 2026-08-12
|
||||
|
||||
较 [0.3.4] 的 7 个 commit 迭代,聚焦 **可观测性**、**并发性能** 与 **外部集成健壮性** 三大方向。
|
||||
|
||||
### 新增功能
|
||||
|
||||
#### MCP 工具调用超时保护
|
||||
- 在 `McpToolWrapper` 适配层用 `tokio::time::timeout` 包裹 `call_tool`,防止外部 MCP server 挂起导致 agent loop 无限阻塞。
|
||||
- 超时时间通过 `config.mcp_tool_timeout_secs` 配置(默认 300 秒,0=不超时)。
|
||||
- 前端 McpTab 设置页提供输入框。
|
||||
|
||||
#### 端到端可观测性整改
|
||||
- **trace_id 全链路传播**:`BusToolCallEmitter` / `SubAgentEmitter` / `processor` 全链路设置 trace_id。
|
||||
- **AgentEnd 指标配对**:补发 5 个 cancel/defensive 路径的 AgentEnd 事件,闭合 AgentStart 指标。
|
||||
- **LLM 计时修正**:`attempt_start` 移入 retry 循环,排除退避等待时间。
|
||||
- **`/metrics` auth**:非 loopback 部署时纳入 Bearer token 校验。
|
||||
- **recorder 复用**:`OnceLock` 缓存 `PrometheusHandle`,热重启后不再返回 503。
|
||||
- **结构化日志**:新增 `tracing_ctx` + JSON 日志格式支持。
|
||||
|
||||
### 修复
|
||||
|
||||
#### 压缩期间 session 锁阻塞新建话题
|
||||
- 压缩任务在 LLM 调用(2-5s)期间持有 session 锁,阻塞 `create_session` 命令获取锁执行 `switch_topic`,导致新建话题后列表刷新延迟。
|
||||
- 将压缩重构为 4 阶段:
|
||||
1. 短暂持锁:读取 history/compressor/store/session_id/provider_config
|
||||
2. 释放锁:LLM 压缩调用(2-5s)
|
||||
3. 释放锁:DB 写入(store 为 Arc,DB 层自带事务保护)
|
||||
4. 短暂持锁:reload 内存历史
|
||||
- 并发安全:同 topic 由调用方的 per-topic serial lock 保证串行;不同 topic 完全并行不受影响。
|
||||
|
||||
#### MCP 前端空输入重置为不超时
|
||||
- 清空输入框时回退到默认值 300 而非 0(0 表示不超时,与用户预期不符)。
|
||||
- 新增两个 tokio 测试覆盖有超时和无超时(error pass-through)路径。
|
||||
|
||||
#### 移除 ChatContainer key remount 修复新建话题列表刷新延迟
|
||||
- 话题切换时 `key={selectedTopic}` 导致整个 `ChatContainer` 子树卸载重建,触发 `ExpertSelector` / `ModelSelector` 重新挂载并发起 3 次冗余 HTTP 请求(`getSelectedExpert` / `listModelOptions` / 模型选择刷新),引入 200-500ms 延迟。
|
||||
- 改用 `topicId` prop 透传话题 ID:
|
||||
- `App.tsx`:移除 key,`viewKey` 纳入 `selectedTopic` 保持各话题独立滚动位置。
|
||||
- `ChatContainer`:新增 `topicId` prop 透传给 `MessageInput`。
|
||||
- `MessageInput`:监听 `topicId` 变化清空草稿,替代原 key remount 重置机制。
|
||||
|
||||
#### Panic 安全增强
|
||||
- `agent_loop.rs` / `processor.rs`:用 `catch_unwind` 隔离工具执行和消息处理的 panic,归一化为错误返回,LLM 可见错误并自我纠正,防止用户消息被静默丢弃。
|
||||
- `utils.rs`:新增 `panic_payload_message()` 从 panic payload 中提取可读消息,支持 `&str` / `String` / 其他类型降级。
|
||||
- `bus/message.rs`:修复 `sanitize_incomplete_tool_call_sequences` 两阶段删除索引未全局排序导致的越界 panic(Phase 1 降序 + Phase 1.5 升序 → 合并后必须重新排序)。
|
||||
|
||||
#### 计算器健壮性
|
||||
- 拒绝 `"NaN"` / `"inf"` 等非有限输入,防止 `sort_by` 中 `partial_cmp().unwrap()` panic。
|
||||
- 修复阶乘溢出:上限从 170 降至 34(35! 超出 `u128::MAX`),改用 `checked_mul` 替代 unchecked 乘法。
|
||||
- `evaluate` 表达式拒绝非有限结果(如 `1/0` → inf、`0/0` → NaN)。
|
||||
- 新增 7 个 tokio 测试覆盖所有边界场景。
|
||||
|
||||
### 测试
|
||||
- MCP 超时路径新增 2 个 tokio 单元测试。
|
||||
|
||||
---
|
||||
|
||||
## [0.3.4] - 2026-08-10
|
||||
|
||||
较 [0.3.3] 的 9 个 commit 迭代,聚焦 **安全加固**、**健壮性防御** 与 **代码质量优化** 三大方向。
|
||||
|
||||
### 新增功能
|
||||
|
||||
#### 顶栏显示后端版本号
|
||||
- 复用现有 `/health` 端点返回的 `CARGO_PKG_VERSION`,避免在 `package.json` 重复维护版本号。
|
||||
- 前端挂载时拉取一次,失败则不显示徽标。
|
||||
|
||||
#### 文件工具路径限制(最小权限原则)
|
||||
- 默认行为:文件读写编辑限制在当前工作目录内。
|
||||
- 新增配置项:`tools.allowed_dirs`(白名单目录列表)+ `tools.file_access_unrestricted`(opt-in 全局放开)。
|
||||
- 共享 `resolve_file_path()` 函数,使用 `canonicalize()` 防御符号链接与 `..` 路径遍历。
|
||||
- 前端:设置页 > 工具标签新增"文件访问"卡片,含开关与目录标签编辑器。
|
||||
- 向后兼容:旧配置默认 cwd 限制(`serde(default)`)。
|
||||
|
||||
### 安全修复
|
||||
|
||||
#### Shell 会话生命周期管理
|
||||
- `GatewayState` 持有 `Arc<ShellSessionManager>` 引用,网关关闭时(`ctrl_c` 和重启两条路径)调用 `shutdown()`。
|
||||
- 新增防御性 `Drop` impl,泄露会话时打 warn 日志。
|
||||
- 子进程 kill 添加 5s 超时,防止无限阻塞。
|
||||
|
||||
#### HTTP 工具 OOM 防护
|
||||
- `WebFetchTool` / `HttpRequestTool` 实现流式读取响应体(`futures_util::StreamExt`),防止大响应撑爆内存。
|
||||
- 检查 `Content-Length` 头,分块读取并累计校验大小。
|
||||
- 默认限制:`WebFetch` 200KB,`HttpRequest` 4MB。
|
||||
|
||||
#### TimeTool 重复类型转换修复
|
||||
- 移除 `time.rs` 中重复的 `u32::try_from` 调用(复制粘贴错误)。
|
||||
|
||||
#### 消除 16 处 unreachable! panic 风险
|
||||
- 15 个 command handler 文件中 `_ => unreachable!()` 替换为 `_ => Err(CommandError::new(...))`,防止 `Command` 枚举新增变体时运行时 panic。
|
||||
|
||||
#### 配置占位符校验
|
||||
- 新增 `validate_no_unresolved_placeholders()`,拒绝包含未解析 `<ENV_VAR>` 或 `\` 占位符的配置。
|
||||
- 防止字面占位符字符串被当作真实 API key 发送到 LLM provider(难以诊断的 401 根因)。
|
||||
- 首次运行跳过校验(`create_default_config` 模板场景)。
|
||||
|
||||
### 修复
|
||||
|
||||
#### 切换话题后滚动到最新消息
|
||||
- 修复切换话题后不滚动到最新消息的问题。
|
||||
- 跟随意图仅由用户输入(wheel/touch/key)推翻,程序触发的滚动不会打断用户浏览。
|
||||
- 将虚拟化 `totalSize` 纳入滚动依赖,使测量收敛到真实底部。
|
||||
|
||||
#### WebSocket 重连竞态修复
|
||||
- 修复 URL 切换时的 WebSocket 重连竞态问题。
|
||||
- 事件处理引入代际同一性守卫,孤儿 socket 事件整体忽略。
|
||||
- disconnect 恰好触发一次 `onDisconnect`,避免重复清理。
|
||||
|
||||
#### 删除话题竞态修复
|
||||
- 修复删除选中话题时的竞态问题。
|
||||
- 乐观移除本地话题并以 `null` 作为唯一无选择哨兵。
|
||||
- 杜绝基于过期列表重选已删除话题。
|
||||
|
||||
### 重构
|
||||
|
||||
#### 8 处 quick wins
|
||||
- `shared.rs` 提取 channel/cli 适配器公共逻辑,消除 124 行重复代码。
|
||||
- `http_utils.rs` 提取 HTTP 响应读取公共逻辑,`http_request.rs` 与 `web_fetch.rs` 共享实现。
|
||||
- `bash.rs` / `shell_session.rs` 简化冗余逻辑。
|
||||
- `gateway/mod.rs` 优化路由分支。
|
||||
- `storage/mod.rs` 精简查询逻辑。
|
||||
- `mcp/client.rs` 增强错误处理。
|
||||
|
||||
### 内部改进
|
||||
- 修复 release 构建编译器警告:补全 `regex::Captures` 显式生命周期标注,`cfg(debug_assertions)` 条件化仅 debug 使用的变量,`AnthropicProvider::llm_timeout_secs` 添加 `cfg_attr allow(dead_code)`。
|
||||
- 飞书 channel 时间戳去重改用 `crate::utils::current_timestamp()`,消除 `unwrap()` panic 点并统一日志格式。
|
||||
- 批量更新依赖 patch 版本(chrono, clap, anyhow, bytes 等),无 breaking change。
|
||||
|
||||
### 测试
|
||||
- `cancel_manager.rs` 新增 8 个单元测试,覆盖 register/cancel/remove/overwrite/list/cancel_all/drop-safety 核心路径。
|
||||
- `session_pool.rs` 新增 3 个单元测试,覆盖 `is_scheduler_chat_id` 路由逻辑。
|
||||
- 配置占位符校验新增 11 个单元测试,覆盖空值、小写、数字、去重、多占位符、首次运行、加载集成等边缘场景。
|
||||
|
||||
---
|
||||
|
||||
## [0.3.3] - 2026-08-07
|
||||
|
||||
较 [0.3.2] 的 12 个 commit 迭代,聚焦 **前端渲染性能飞跃**、**流式通信健壮性** 与 **代码质量治理** 三大方向。
|
||||
@ -516,8 +380,6 @@
|
||||
- 前端静态文件嵌入二进制。
|
||||
- React Web UI 前端界面。
|
||||
|
||||
[0.3.5]: https://github.com/picobot/picobot/compare/v0.3.4...v0.3.5
|
||||
[0.3.4]: https://github.com/picobot/picobot/compare/v0.3.3...v0.3.4
|
||||
[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
|
||||
|
||||
@ -15,7 +15,6 @@ use crate::text::{char_count, take_prefix_chars, take_suffix_chars};
|
||||
use crate::tools::{ToolContext, ToolRegistry};
|
||||
use crate::utils::format_error_chain;
|
||||
use async_trait::async_trait;
|
||||
use futures_util::FutureExt;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::hash::{Hash, Hasher};
|
||||
@ -1069,13 +1068,13 @@ impl AgentLoop {
|
||||
/// - `compaction_sink`: 压缩结果回写端(可选)。配置 `compressor` 后,
|
||||
/// 当 LLM 压缩被触发时通过此 sink 把压缩结果持久化到 DB。
|
||||
/// 传 None 则即使配置了 compressor 也只改内存不回写。
|
||||
#[tracing::instrument(skip(self, messages, system_prompt_context, compaction_sink), fields(history_len = messages.len(), max_iterations = self.max_iterations))]
|
||||
pub async fn process(
|
||||
&self,
|
||||
mut messages: Vec<ChatMessage>,
|
||||
system_prompt_context: Option<&SystemPromptContext>,
|
||||
compaction_sink: Option<&dyn CompactionSink>,
|
||||
) -> Result<AgentProcessResult, AgentError> {
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::debug!(
|
||||
history_len = messages.len(),
|
||||
max_iterations = self.max_iterations,
|
||||
@ -1180,14 +1179,6 @@ impl AgentLoop {
|
||||
)
|
||||
.await;
|
||||
|
||||
// Emit AgentStart event for metrics (LLM 请求耗时/token 指标)
|
||||
if let Some(ref observer) = self.observer {
|
||||
observer.record_event(&ObserverEvent::AgentStart {
|
||||
provider: self.provider.name().to_string(),
|
||||
model: self.provider.model_id().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Set up streaming delta consumer
|
||||
// Pre-generate the message ID so stream deltas and the final assistant
|
||||
// message share the same ID — this lets the front-end replace the
|
||||
@ -1199,12 +1190,9 @@ impl AgentLoop {
|
||||
|
||||
let max_retries = self.runtime_config.max_retries as usize;
|
||||
let mut response: Option<crate::providers::ChatCompletionResponse> = None;
|
||||
// 记录最后一次尝试的耗时(不含重试退避),用于 AgentEnd 指标
|
||||
let mut last_attempt_duration = std::time::Duration::ZERO;
|
||||
|
||||
'retry: for attempt in 0..=max_retries {
|
||||
// 每次重试重建 channel + consumer:上次失败的 channel 可能已关闭。
|
||||
let attempt_start = std::time::Instant::now();
|
||||
let (delta_tx, mut delta_rx) = tokio::sync::mpsc::channel::<StreamDelta>(256);
|
||||
let consumer_handler = self.emitted_message_handler.clone();
|
||||
let consumer_task = tokio::spawn(async move {
|
||||
@ -1234,16 +1222,6 @@ impl AgentLoop {
|
||||
_ = self.cancel_signal() => {
|
||||
drop(stream_callback);
|
||||
let _ = consumer_task.await;
|
||||
// cancel 路径补发 AgentEnd,保证指标配对闭合
|
||||
last_attempt_duration = attempt_start.elapsed();
|
||||
if let Some(ref observer) = self.observer {
|
||||
observer.record_event(&ObserverEvent::AgentEnd {
|
||||
provider: self.provider.name().to_string(),
|
||||
model: self.provider.model_id().to_string(),
|
||||
duration: last_attempt_duration,
|
||||
tokens_used: None,
|
||||
});
|
||||
}
|
||||
let cancel = Self::build_cancel_result(iteration, emitted_messages);
|
||||
self.emit_live_tool_call_message(cancel.final_response.clone()).await;
|
||||
return Ok(cancel);
|
||||
@ -1264,12 +1242,10 @@ impl AgentLoop {
|
||||
|
||||
match llm_result {
|
||||
Ok(resp) => {
|
||||
last_attempt_duration = attempt_start.elapsed();
|
||||
response = Some(resp);
|
||||
break 'retry;
|
||||
}
|
||||
Err(e) => {
|
||||
last_attempt_duration = attempt_start.elapsed();
|
||||
let error_text = e.to_string();
|
||||
let can_retry = attempt < max_retries
|
||||
&& !emitted.load(Ordering::SeqCst)
|
||||
@ -1289,16 +1265,6 @@ impl AgentLoop {
|
||||
if self.cancel_token.is_some() {
|
||||
tokio::select! {
|
||||
_ = self.cancel_signal() => {
|
||||
// cancel 路径补发 AgentEnd,保证指标配对闭合
|
||||
last_attempt_duration = attempt_start.elapsed();
|
||||
if let Some(ref observer) = self.observer {
|
||||
observer.record_event(&ObserverEvent::AgentEnd {
|
||||
provider: self.provider.name().to_string(),
|
||||
model: self.provider.model_id().to_string(),
|
||||
duration: last_attempt_duration,
|
||||
tokens_used: None,
|
||||
});
|
||||
}
|
||||
let cancel = Self::build_cancel_result(iteration, emitted_messages);
|
||||
self.emit_live_tool_call_message(cancel.final_response.clone()).await;
|
||||
return Ok(cancel);
|
||||
@ -1317,15 +1283,6 @@ impl AgentLoop {
|
||||
error_details = %format_error_chain(e.as_ref()),
|
||||
"LLM request failed"
|
||||
);
|
||||
// 错误分支补发 AgentEnd,保证指标配对闭合
|
||||
if let Some(ref observer) = self.observer {
|
||||
observer.record_event(&ObserverEvent::AgentEnd {
|
||||
provider: self.provider.name().to_string(),
|
||||
model: self.provider.model_id().to_string(),
|
||||
duration: last_attempt_duration,
|
||||
tokens_used: None,
|
||||
});
|
||||
}
|
||||
let assistant_message =
|
||||
ChatMessage::assistant(recoverable_llm_message(&error_text));
|
||||
emitted_messages.push(assistant_message.clone());
|
||||
@ -1341,34 +1298,11 @@ impl AgentLoop {
|
||||
}
|
||||
}
|
||||
|
||||
// 防御性兜底:retry 循环异常退出(正常不应发生)。
|
||||
// 补发 AgentEnd 保证指标配对闭合,再返回错误。
|
||||
let response = match response {
|
||||
Some(resp) => resp,
|
||||
None => {
|
||||
if let Some(ref observer) = self.observer {
|
||||
observer.record_event(&ObserverEvent::AgentEnd {
|
||||
provider: self.provider.name().to_string(),
|
||||
model: self.provider.model_id().to_string(),
|
||||
duration: last_attempt_duration,
|
||||
tokens_used: None,
|
||||
});
|
||||
}
|
||||
return Err(AgentError::Other(
|
||||
"retry loop exited without setting response or returning".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Emit AgentEnd event for metrics (LLM 请求耗时/token 指标)
|
||||
if let Some(ref observer) = self.observer {
|
||||
observer.record_event(&ObserverEvent::AgentEnd {
|
||||
provider: self.provider.name().to_string(),
|
||||
model: self.provider.model_id().to_string(),
|
||||
duration: last_attempt_duration,
|
||||
tokens_used: Some(response.usage.total_tokens as u64),
|
||||
});
|
||||
}
|
||||
let response = response.ok_or_else(|| {
|
||||
AgentError::Other(
|
||||
"retry loop exited without setting response or returning".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Signal stream end if handler exists
|
||||
let had_streaming = self.emitted_message_handler.is_some();
|
||||
@ -1447,29 +1381,10 @@ impl AgentLoop {
|
||||
.await;
|
||||
|
||||
// Execute tools and add results to messages
|
||||
//
|
||||
// 取消竞速策略:
|
||||
// - 包含 wait_for_subagents 时:不使用 select!,直接 await execute_tools。
|
||||
// 原因:coordinator.wait() 在 select! 返回后有不可中断的清理逻辑
|
||||
// (步骤 6-8:重获取 serial_lock、回填 guard_slot、清除 is_waiting)。
|
||||
// 若 agent_loop 的 select! 在清理期间 drop execute_tools,
|
||||
// 会导致 is_waiting=true 永久残留、guard_slot 为空、serial_lock 未持有,
|
||||
// 后续所有用户消息走注入路径但 wakeup 无接收者 → 系统永久卡死。
|
||||
// cancel 由 coordinator 内部 select! 的 cancel 分支处理,清理不会被打断。
|
||||
//
|
||||
// - 不含 wait_for_subagents 时:保留 select! 竞速,允许 /stop 中断
|
||||
// 长时间运行的工具(如 MCP HTTP 请求)。
|
||||
let has_wait_tool = response
|
||||
.tool_calls
|
||||
.iter()
|
||||
.any(|tc| tc.name == "wait_for_subagents");
|
||||
|
||||
let tool_results = if self.cancel_token.is_some() && !has_wait_tool {
|
||||
// 工具执行与取消信号竞速:取消时 drop join_all 或 sequential future,
|
||||
// 未完成的工具调用被丢弃。
|
||||
let tool_results = if self.cancel_token.is_some() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
results = self.execute_tools(&response.tool_calls) => {
|
||||
results
|
||||
}
|
||||
_ = self.cancel_signal() => {
|
||||
// 为所有 tool_calls 补充取消结果,避免孤立 assistant(tool_calls)
|
||||
for tool_call in &response.tool_calls {
|
||||
@ -1487,6 +1402,9 @@ impl AgentLoop {
|
||||
self.emit_live_tool_call_message(cancel.final_response.clone()).await;
|
||||
return Ok(cancel);
|
||||
}
|
||||
results = self.execute_tools(&response.tool_calls) => {
|
||||
results
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.execute_tools(&response.tool_calls).await
|
||||
@ -1797,20 +1715,8 @@ impl AgentLoop {
|
||||
.await;
|
||||
let max_retries = self.runtime_config.max_retries as usize;
|
||||
|
||||
// Emit AgentStart for the summary LLM call
|
||||
if let Some(ref observer) = self.observer {
|
||||
observer.record_event(&ObserverEvent::AgentStart {
|
||||
provider: self.provider.name().to_string(),
|
||||
model: self.provider.model_id().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// 记录最后一次尝试的耗时(不含重试退避)
|
||||
let mut summary_last_attempt_duration = std::time::Duration::ZERO;
|
||||
|
||||
for attempt in 0..=max_retries {
|
||||
// 最终 summary 调用也与取消信号竞速
|
||||
let attempt_start = std::time::Instant::now();
|
||||
let final_result: Result<
|
||||
crate::providers::ChatCompletionResponse,
|
||||
Box<dyn std::error::Error + Send + Sync>,
|
||||
@ -1818,16 +1724,6 @@ impl AgentLoop {
|
||||
if self.cancel_token.is_some() {
|
||||
tokio::select! {
|
||||
_ = self.cancel_signal() => {
|
||||
// cancel 路径补发 AgentEnd,保证指标配对闭合
|
||||
summary_last_attempt_duration = attempt_start.elapsed();
|
||||
if let Some(ref observer) = self.observer {
|
||||
observer.record_event(&ObserverEvent::AgentEnd {
|
||||
provider: self.provider.name().to_string(),
|
||||
model: self.provider.model_id().to_string(),
|
||||
duration: summary_last_attempt_duration,
|
||||
tokens_used: None,
|
||||
});
|
||||
}
|
||||
let cancel = Self::build_cancel_result(self.max_iterations, std::mem::take(emitted_messages));
|
||||
self.emit_live_tool_call_message(cancel.final_response.clone()).await;
|
||||
return cancel;
|
||||
@ -1842,16 +1738,6 @@ impl AgentLoop {
|
||||
|
||||
match final_result {
|
||||
Ok(response) => {
|
||||
summary_last_attempt_duration = attempt_start.elapsed();
|
||||
// Emit AgentEnd for the summary LLM call
|
||||
if let Some(ref observer) = self.observer {
|
||||
observer.record_event(&ObserverEvent::AgentEnd {
|
||||
provider: self.provider.name().to_string(),
|
||||
model: self.provider.model_id().to_string(),
|
||||
duration: summary_last_attempt_duration,
|
||||
tokens_used: Some(response.usage.total_tokens as u64),
|
||||
});
|
||||
}
|
||||
let mut assistant_message = if let Some(reasoning_content) =
|
||||
response.reasoning_content
|
||||
{
|
||||
@ -1875,7 +1761,6 @@ impl AgentLoop {
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
summary_last_attempt_duration = attempt_start.elapsed();
|
||||
let error_text = e.to_string();
|
||||
let can_retry = attempt < max_retries && is_recoverable_llm_error(&error_text);
|
||||
if can_retry {
|
||||
@ -1891,16 +1776,6 @@ impl AgentLoop {
|
||||
if self.cancel_token.is_some() {
|
||||
tokio::select! {
|
||||
_ = self.cancel_signal() => {
|
||||
// cancel 路径补发 AgentEnd,保证指标配对闭合
|
||||
summary_last_attempt_duration = attempt_start.elapsed();
|
||||
if let Some(ref observer) = self.observer {
|
||||
observer.record_event(&ObserverEvent::AgentEnd {
|
||||
provider: self.provider.name().to_string(),
|
||||
model: self.provider.model_id().to_string(),
|
||||
duration: summary_last_attempt_duration,
|
||||
tokens_used: None,
|
||||
});
|
||||
}
|
||||
let cancel = Self::build_cancel_result(self.max_iterations, std::mem::take(emitted_messages));
|
||||
self.emit_live_tool_call_message(cancel.final_response.clone()).await;
|
||||
return cancel;
|
||||
@ -1919,15 +1794,6 @@ impl AgentLoop {
|
||||
error_details = %format_error_chain(e.as_ref()),
|
||||
"Failed to get summary from LLM"
|
||||
);
|
||||
// 错误分支补发 AgentEnd,保证指标配对闭合
|
||||
if let Some(ref observer) = self.observer {
|
||||
observer.record_event(&ObserverEvent::AgentEnd {
|
||||
provider: self.provider.name().to_string(),
|
||||
model: self.provider.model_id().to_string(),
|
||||
duration: summary_last_attempt_duration,
|
||||
tokens_used: None,
|
||||
});
|
||||
}
|
||||
let final_message =
|
||||
ChatMessage::assistant(recoverable_llm_message(&error_text));
|
||||
emitted_messages.push(final_message.clone());
|
||||
@ -1952,15 +1818,6 @@ impl AgentLoop {
|
||||
model = %self.provider.model_id(),
|
||||
"run_final_summary retry loop exited without returning"
|
||||
);
|
||||
// 兜底也补发 AgentEnd
|
||||
if let Some(ref observer) = self.observer {
|
||||
observer.record_event(&ObserverEvent::AgentEnd {
|
||||
provider: self.provider.name().to_string(),
|
||||
model: self.provider.model_id().to_string(),
|
||||
duration: summary_last_attempt_duration,
|
||||
tokens_used: None,
|
||||
});
|
||||
}
|
||||
let final_message = ChatMessage::assistant(
|
||||
"Failed to generate final summary: retry loop exited unexpectedly.",
|
||||
);
|
||||
@ -2078,7 +1935,6 @@ impl AgentLoop {
|
||||
}
|
||||
|
||||
/// Execute a single tool and return the outcome with event tracking.
|
||||
#[tracing::instrument(skip(self, tool_call), fields(tool = %tool_call.name))]
|
||||
async fn execute_one_tool(&self, tool_call: &ToolCall) -> ToolExecutionOutcome {
|
||||
let start = Instant::now();
|
||||
let tool_name = tool_call.name.clone();
|
||||
@ -2137,36 +1993,18 @@ impl AgentLoop {
|
||||
}
|
||||
};
|
||||
|
||||
let tool_context = {
|
||||
let mut ctx = self.tool_context.clone();
|
||||
ctx.tool_call_id = Some(tool_call.id.clone());
|
||||
ctx
|
||||
};
|
||||
// catch_unwind 隔离单个工具的 panic:否则一个工具崩溃会终止整个 turn,
|
||||
// 用户消息被静默丢弃。归一化为工具级失败后 LLM 还能看到错误并自我纠正。
|
||||
let execution = std::panic::AssertUnwindSafe(
|
||||
tool.execute_with_context(&tool_context, normalized_arguments.clone()),
|
||||
)
|
||||
.catch_unwind()
|
||||
.await;
|
||||
|
||||
match execution {
|
||||
Err(payload) => {
|
||||
let error = format!(
|
||||
"Tool '{}' panicked: {}",
|
||||
tool_call.name,
|
||||
crate::utils::panic_payload_message(&payload)
|
||||
);
|
||||
tracing::error!(
|
||||
tool = %tool_call.name,
|
||||
args = %truncate_args(&tool_call.arguments, 4_000),
|
||||
normalized_args = %truncate_args(&normalized_arguments, 4_000),
|
||||
error = %error,
|
||||
"Tool execution panicked"
|
||||
);
|
||||
ToolExecutionOutcome::failure(format!("Error: {}", error), Some(error))
|
||||
}
|
||||
Ok(Ok(result)) => {
|
||||
match tool
|
||||
.execute_with_context(
|
||||
&{
|
||||
let mut ctx = self.tool_context.clone();
|
||||
ctx.tool_call_id = Some(tool_call.id.clone());
|
||||
ctx
|
||||
},
|
||||
normalized_arguments.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
if result.success {
|
||||
if let Some(pending_output) = parse_pending_tool_output(&result.output) {
|
||||
ToolExecutionOutcome::pending(pending_output)
|
||||
@ -2193,7 +2031,7 @@ impl AgentLoop {
|
||||
ToolExecutionOutcome::failure(failure_output, Some(error))
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
tool = %tool_call.name,
|
||||
args = %truncate_args(&tool_call.arguments, 4_000),
|
||||
@ -3146,122 +2984,6 @@ mod tests {
|
||||
assert_eq!(messages.len(), 3);
|
||||
}
|
||||
|
||||
/// 良构不变量校验:sanitize 的输出必须满足
|
||||
/// 1. 每个带 tool_calls 的 assistant 之后紧邻其全部 tool 结果(无其他消息隔断);
|
||||
/// 2. 每个 tool 消息都有存活的父 assistant。
|
||||
fn assert_well_formed(messages: &[ChatMessage]) {
|
||||
let mut pending: Vec<String> = Vec::new();
|
||||
for (i, m) in messages.iter().enumerate() {
|
||||
if m.role == "assistant" {
|
||||
if let Some(calls) = m.tool_calls.as_ref().filter(|c| !c.is_empty()) {
|
||||
assert!(
|
||||
pending.is_empty(),
|
||||
"assistant at {i} starts tool_calls while previous results are pending"
|
||||
);
|
||||
pending = calls.iter().map(|tc| tc.id.clone()).collect();
|
||||
}
|
||||
} else if m.role == "tool" {
|
||||
let tc_id = m.tool_call_id.clone().unwrap_or_default();
|
||||
let pos = pending
|
||||
.iter()
|
||||
.position(|id| *id == tc_id)
|
||||
.unwrap_or_else(|| panic!("tool at {i} has no pending parent (id={tc_id})"));
|
||||
pending.remove(pos);
|
||||
} else if !pending.is_empty() {
|
||||
panic!("non-tool message at {i} interrupts pending tool results");
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
pending.is_empty(),
|
||||
"trailing assistant tool_calls without results"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_mixed_removal_order_does_not_panic_or_corrupt() {
|
||||
// Phase 1(反向扫描)按降序收集孤儿 assistant 索引 [2,1,0],
|
||||
// Phase 1.5(正向扫描)随后按升序追加索引 3 → remove_indices=[2,1,0,3]。
|
||||
// 若不全局排序就逐个 Vec::remove,第 4 次删除时越界 panic。
|
||||
let mut messages = vec![
|
||||
ChatMessage::assistant_with_tool_calls(
|
||||
"orphan 1",
|
||||
vec![ToolCall {
|
||||
id: "call_x".to_string(),
|
||||
name: "bash".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
}],
|
||||
),
|
||||
ChatMessage::assistant_with_tool_calls(
|
||||
"orphan 2",
|
||||
vec![ToolCall {
|
||||
id: "call_y".to_string(),
|
||||
name: "bash".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
}],
|
||||
),
|
||||
ChatMessage::assistant_with_tool_calls(
|
||||
"orphan 3",
|
||||
vec![ToolCall {
|
||||
id: "call_z".to_string(),
|
||||
name: "bash".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
}],
|
||||
),
|
||||
ChatMessage::assistant_with_tool_calls(
|
||||
"resolved but interrupted",
|
||||
vec![ToolCall {
|
||||
id: "call_w".to_string(),
|
||||
name: "bash".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
}],
|
||||
),
|
||||
ChatMessage::user("next question"),
|
||||
ChatMessage::tool("call_w", "bash", "result"),
|
||||
];
|
||||
|
||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
|
||||
assert_eq!(removed, 5, "4 assistants + 1 orphaned tool result");
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].role, "user");
|
||||
assert_eq!(messages[0].content, "next question");
|
||||
assert_well_formed(&messages);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_mixed_removal_order_deletes_correct_messages() {
|
||||
// remove_indices=[0(Phase 1), 1(Phase 1.5)]:先 remove(0) 后索引漂移,
|
||||
// 未排序时 remove(1) 会误删 user 消息而非第二个 assistant。
|
||||
let mut messages = vec![
|
||||
ChatMessage::assistant_with_tool_calls(
|
||||
"orphan",
|
||||
vec![ToolCall {
|
||||
id: "call_x".to_string(),
|
||||
name: "bash".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
}],
|
||||
),
|
||||
ChatMessage::assistant_with_tool_calls(
|
||||
"resolved but interrupted",
|
||||
vec![ToolCall {
|
||||
id: "call_y".to_string(),
|
||||
name: "bash".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
}],
|
||||
),
|
||||
ChatMessage::user("interrupting question"),
|
||||
ChatMessage::tool("call_y", "bash", "result"),
|
||||
];
|
||||
|
||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
|
||||
assert_eq!(removed, 3, "2 assistants + 1 orphaned tool result");
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].role, "user");
|
||||
assert_eq!(messages[0].content, "interrupting question");
|
||||
assert_well_formed(&messages);
|
||||
}
|
||||
|
||||
// ===== LLM 重试机制测试 =====
|
||||
|
||||
#[test]
|
||||
|
||||
@ -443,12 +443,7 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
|
||||
}
|
||||
}
|
||||
|
||||
// Remove in descending index order to avoid shifting.
|
||||
// 两阶段产出的索引并非全局降序:Phase 1(反向扫描)按降序追加,
|
||||
// Phase 1.5(正向扫描)按升序追加。逐个 Vec::remove 前必须全局排序,
|
||||
// 否则已删除元素会使后续索引漂移(删错消息)甚至越界 panic。
|
||||
remove_indices.sort_unstable_by(|a, b| b.cmp(a));
|
||||
remove_indices.dedup();
|
||||
// Remove in descending index order to avoid shifting
|
||||
for &idx in &remove_indices {
|
||||
messages.remove(idx);
|
||||
removed += 1;
|
||||
@ -507,9 +502,6 @@ pub struct InboundMessage {
|
||||
pub metadata: HashMap<String, String>,
|
||||
/// Data forwarded from inbound to outbound (copied to OutboundMessage.metadata by gateway).
|
||||
pub forwarded_metadata: HashMap<String, String>,
|
||||
/// 端到端追踪 ID(由 channel 在构造消息时生成,贯穿 bus→processor→agent_loop→provider→tool 全链路)。
|
||||
/// 基础设施层元数据,不进入 domain 层。
|
||||
pub trace_id: String,
|
||||
}
|
||||
|
||||
impl InboundMessage {
|
||||
@ -545,9 +537,6 @@ pub struct OutboundMessage {
|
||||
/// instead of generating a random UUID. Critical for stream delta → assistant_response
|
||||
/// ID matching on the front-end.
|
||||
pub message_id: Option<String>,
|
||||
/// 端到端追踪 ID(从 InboundMessage 继承,用于 outbound dispatcher 日志关联)。
|
||||
/// 非 agent 执行路径产生的消息(如 scheduler 通知)此字段为空。
|
||||
pub trace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@ -575,12 +564,6 @@ impl OutboundMessage {
|
||||
)
|
||||
}
|
||||
|
||||
/// 设置 trace_id(builder 模式,用于 agent 执行路径中从 InboundMessage 继承)。
|
||||
pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
|
||||
self.trace_id = trace_id.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn assistant(
|
||||
channel: impl Into<String>,
|
||||
chat_id: impl Into<String>,
|
||||
@ -604,7 +587,6 @@ impl OutboundMessage {
|
||||
tool_arguments: None,
|
||||
reasoning_content: None,
|
||||
message_id: None,
|
||||
trace_id: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -663,7 +645,6 @@ impl OutboundMessage {
|
||||
tool_arguments: Some(tool_arguments),
|
||||
reasoning_content: None,
|
||||
message_id: None,
|
||||
trace_id: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -695,7 +676,6 @@ impl OutboundMessage {
|
||||
tool_arguments: None,
|
||||
reasoning_content: None,
|
||||
message_id: None,
|
||||
trace_id: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -727,7 +707,6 @@ impl OutboundMessage {
|
||||
tool_arguments: None,
|
||||
reasoning_content: None,
|
||||
message_id: None,
|
||||
trace_id: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -756,7 +735,6 @@ impl OutboundMessage {
|
||||
tool_arguments: None,
|
||||
reasoning_content: reasoning_delta,
|
||||
message_id: None,
|
||||
trace_id: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -783,7 +761,6 @@ impl OutboundMessage {
|
||||
tool_arguments: None,
|
||||
reasoning_content: None,
|
||||
message_id: None,
|
||||
trace_id: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -809,7 +786,6 @@ impl OutboundMessage {
|
||||
tool_arguments: None,
|
||||
reasoning_content: None,
|
||||
message_id: None,
|
||||
trace_id: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -35,15 +35,8 @@ impl MessageBus {
|
||||
|
||||
/// Publish a message to the inbound queue
|
||||
pub async fn publish_inbound(&self, msg: InboundMessage) -> Result<(), BusError> {
|
||||
tracing::debug!(
|
||||
channel = %msg.channel,
|
||||
sender = %msg.sender_id,
|
||||
chat_id = %msg.chat_id,
|
||||
trace_id = %msg.trace_id,
|
||||
content_len = %msg.content.len(),
|
||||
media_count = %msg.media.len(),
|
||||
"Bus: publishing inbound message"
|
||||
);
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::debug!(channel = %msg.channel, sender = %msg.sender_id, chat = %msg.chat_id, content_len = %msg.content.len(), media_count = %msg.media.len(), "Bus: publishing inbound message");
|
||||
self.inbound_tx
|
||||
.send(msg)
|
||||
.await
|
||||
@ -54,13 +47,8 @@ impl MessageBus {
|
||||
/// Returns `None` when the channel is closed (all senders dropped).
|
||||
pub async fn consume_inbound(&self) -> Option<InboundMessage> {
|
||||
let msg = self.inbound_rx.lock().await.recv().await?;
|
||||
tracing::debug!(
|
||||
channel = %msg.channel,
|
||||
sender = %msg.sender_id,
|
||||
chat_id = %msg.chat_id,
|
||||
trace_id = %msg.trace_id,
|
||||
"Bus: consuming inbound message"
|
||||
);
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::debug!(channel = %msg.channel, sender = %msg.sender_id, chat = %msg.chat_id, "Bus: consuming inbound message");
|
||||
Some(msg)
|
||||
}
|
||||
|
||||
@ -71,20 +59,13 @@ impl MessageBus {
|
||||
/// blocked by slow or disconnected display consumers. Persistent state is
|
||||
/// unaffected — messages are stored in SQLite independently.
|
||||
pub async fn publish_outbound(&self, msg: OutboundMessage) -> Result<(), BusError> {
|
||||
tracing::debug!(
|
||||
channel = %msg.channel,
|
||||
chat_id = %msg.chat_id,
|
||||
trace_id = %msg.trace_id,
|
||||
content_len = %msg.content.len(),
|
||||
"Bus: publishing outbound message"
|
||||
);
|
||||
#[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,
|
||||
chat_id = %msg.chat_id,
|
||||
trace_id = %msg.trace_id,
|
||||
"Outbound bus full, dropping message"
|
||||
);
|
||||
Err(BusError::Dropped)
|
||||
|
||||
@ -1327,7 +1327,6 @@ impl FeishuChannel {
|
||||
media: parsed.media.map(|m| vec![m]).unwrap_or_default(),
|
||||
metadata: std::collections::HashMap::new(),
|
||||
forwarded_metadata,
|
||||
trace_id: crate::observability::tracing_ctx::new_trace_id(),
|
||||
};
|
||||
if let Err(e) = channel.handle_and_publish(&bus, &msg).await {
|
||||
tracing::error!(error = %e, open_id = %parsed.open_id, chat_id = %parsed.chat_id, "Failed to publish Feishu message to bus");
|
||||
|
||||
@ -247,7 +247,6 @@ impl Channel for WechatChannel {
|
||||
media,
|
||||
metadata,
|
||||
forwarded_metadata: HashMap::new(),
|
||||
trace_id: crate::observability::tracing_ctx::new_trace_id(),
|
||||
};
|
||||
|
||||
if let Err(error) = bus.publish_inbound(inbound).await {
|
||||
|
||||
@ -78,8 +78,6 @@ impl InitWizard {
|
||||
tools: crate::config::ToolsConfig::default(),
|
||||
memory_maintenance: crate::config::MemoryMaintenanceConfig::default(),
|
||||
mcp_servers: HashMap::new(),
|
||||
mcp_tool_timeout_secs: 300,
|
||||
observability: crate::config::ObservabilityConfig::default(),
|
||||
image_context: crate::config::ImageContextConfig::default(),
|
||||
subagents: crate::config::SubagentsConfig::default(),
|
||||
experts: crate::config::ExpertsConfig::default(),
|
||||
@ -845,8 +843,6 @@ impl InitWizard {
|
||||
tools: existing.tools.clone(),
|
||||
memory_maintenance: existing.memory_maintenance.clone(),
|
||||
mcp_servers: existing.mcp_servers.clone(),
|
||||
mcp_tool_timeout_secs: existing.mcp_tool_timeout_secs,
|
||||
observability: existing.observability.clone(),
|
||||
image_context: existing.image_context.clone(),
|
||||
subagents: existing.subagents.clone(),
|
||||
experts: existing.experts.clone(),
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
@ -7,27 +6,18 @@ use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||
use crate::gateway::cancel_manager::CancelManager;
|
||||
use crate::gateway::session::SessionManager;
|
||||
use crate::tools::SubAgentRuntime;
|
||||
|
||||
/// 处理 StopExecution 命令:按话题取消当前正在执行的 Agent。
|
||||
///
|
||||
/// 取消传播:同时取消该 topic 下所有正在运行的异步子代理(通过 CancellationToken)。
|
||||
pub struct StopExecutionCommandHandler {
|
||||
cancel_manager: CancelManager,
|
||||
session_manager: SessionManager,
|
||||
subagent_executor: Option<Arc<dyn SubAgentRuntime>>,
|
||||
}
|
||||
|
||||
impl StopExecutionCommandHandler {
|
||||
pub fn new(
|
||||
cancel_manager: CancelManager,
|
||||
session_manager: SessionManager,
|
||||
subagent_executor: Option<Arc<dyn SubAgentRuntime>>,
|
||||
) -> Self {
|
||||
pub fn new(cancel_manager: CancelManager, session_manager: SessionManager) -> Self {
|
||||
Self {
|
||||
cancel_manager,
|
||||
session_manager,
|
||||
subagent_executor,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -99,26 +89,9 @@ impl CommandHandler for StopExecutionCommandHandler {
|
||||
|
||||
let cancelled = self.cancel_manager.cancel_by_topic(&topic_id).await;
|
||||
|
||||
// 取消传播:同时取消该 topic 下所有正在运行的异步子代理
|
||||
let cancelled_subagents = if let Some(ref executor) = self.subagent_executor {
|
||||
executor.cancel_pending_for_topic(&topic_id).await
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
if cancelled || cancelled_subagents > 0 {
|
||||
let msg = if cancelled && cancelled_subagents > 0 {
|
||||
format!(
|
||||
"正在停止当前任务及 {} 个后台子代理...",
|
||||
cancelled_subagents
|
||||
)
|
||||
} else if cancelled {
|
||||
"正在停止当前任务...".to_string()
|
||||
} else {
|
||||
format!("正在停止 {} 个后台子代理...", cancelled_subagents)
|
||||
};
|
||||
if cancelled {
|
||||
Ok(CommandResponse::success(ctx.request_id)
|
||||
.with_message(MessageKind::Notification, msg))
|
||||
.with_message(MessageKind::Notification, "正在停止当前任务..."))
|
||||
} else {
|
||||
Ok(CommandResponse::success(ctx.request_id)
|
||||
.with_message(MessageKind::Notification, "当前没有正在执行的任务"))
|
||||
|
||||
@ -34,9 +34,6 @@ pub struct Config {
|
||||
pub memory_maintenance: MemoryMaintenanceConfig,
|
||||
#[serde(default, rename = "mcpServers")]
|
||||
pub mcp_servers: HashMap<String, crate::mcp::McpServerConfig>,
|
||||
/// MCP 工具调用超时时间(秒)。0 表示不超时。默认 300(5 分钟)。
|
||||
#[serde(default = "default_mcp_tool_timeout_secs")]
|
||||
pub mcp_tool_timeout_secs: u64,
|
||||
#[serde(default)]
|
||||
pub image_context: ImageContextConfig,
|
||||
#[serde(default)]
|
||||
@ -45,8 +42,6 @@ pub struct Config {
|
||||
pub experts: ExpertsConfig,
|
||||
#[serde(default)]
|
||||
pub compaction: CompactionConfig,
|
||||
#[serde(default)]
|
||||
pub observability: ObservabilityConfig,
|
||||
}
|
||||
|
||||
/// 图片上下文限制配置
|
||||
@ -127,34 +122,6 @@ impl Default for CompactionConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// 可观测性配置(日志格式、metrics 开关等)
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ObservabilityConfig {
|
||||
/// 日志输出格式:text(默认)或 json。
|
||||
/// json 格式便于接入 ELK/Loki 等日志聚合系统。
|
||||
#[serde(default)]
|
||||
pub log_format: LogFormat,
|
||||
}
|
||||
|
||||
impl Default for ObservabilityConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
log_format: LogFormat::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志输出格式
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LogFormat {
|
||||
/// 纯文本格式(默认,便于人读)
|
||||
#[default]
|
||||
Text,
|
||||
/// JSON 格式(便于机器解析和日志聚合)
|
||||
Json,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct TimeConfig {
|
||||
#[serde(default = "default_timezone")]
|
||||
@ -337,12 +304,6 @@ pub struct TaskConfig {
|
||||
pub allowed_tools: Vec<String>,
|
||||
#[serde(default = "default_task_max_nesting_depth")]
|
||||
pub max_nesting_depth: u32,
|
||||
/// 异步子代理最大并发数(Semaphore 限流,仅主 agent 顶层 spawn 生效)
|
||||
#[serde(default = "default_task_max_concurrent")]
|
||||
pub max_concurrent: usize,
|
||||
/// wait_for_subagents 工具默认超时(秒),LLM 可通过参数覆盖
|
||||
#[serde(default = "default_task_wait_default_timeout_secs")]
|
||||
pub wait_default_timeout_secs: u64,
|
||||
}
|
||||
|
||||
fn default_task_enabled() -> bool {
|
||||
@ -361,14 +322,6 @@ fn default_task_max_nesting_depth() -> u32 {
|
||||
2
|
||||
}
|
||||
|
||||
fn default_task_max_concurrent() -> usize {
|
||||
8
|
||||
}
|
||||
|
||||
fn default_task_wait_default_timeout_secs() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
fn default_task_allowed_tools() -> Vec<String> {
|
||||
vec![
|
||||
"read".to_string(),
|
||||
@ -394,8 +347,6 @@ impl Default for TaskConfig {
|
||||
ttl_hours: default_task_ttl_hours(),
|
||||
allowed_tools: default_task_allowed_tools(),
|
||||
max_nesting_depth: default_task_max_nesting_depth(),
|
||||
max_concurrent: default_task_max_concurrent(),
|
||||
wait_default_timeout_secs: default_task_wait_default_timeout_secs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -605,10 +556,6 @@ fn default_max_retries() -> u32 {
|
||||
3
|
||||
}
|
||||
|
||||
fn default_mcp_tool_timeout_secs() -> u64 {
|
||||
300
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct GatewayConfig {
|
||||
#[serde(default = "default_gateway_host")]
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::agent::context_compressor::ContextCompressor;
|
||||
use crate::agent::{AgentError, AgentLoop, AgentRuntimeConfig, CompositeSystemPromptProvider, SystemPromptProvider};
|
||||
use crate::config::{CompactionConfig, LLMProviderConfig, ModelResolver};
|
||||
@ -11,13 +9,11 @@ use crate::experts::ExpertRuntime;
|
||||
use crate::gateway::agent_prompt_provider::AgentPromptProvider;
|
||||
use crate::gateway::model_selection::ModelSelectionStore;
|
||||
use crate::gateway::tool_prompt_provider::ToolPromptProvider;
|
||||
use crate::observability::Observer;
|
||||
use crate::skills::{SkillPromptProvider, SkillRuntime};
|
||||
use crate::storage::PromptInjectionRepository;
|
||||
use crate::storage::persistent_session_id;
|
||||
use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime};
|
||||
use crate::tools::task::SubagentResult;
|
||||
use crate::tools::{ToolContext, ToolRegistry, WaitCoordinator};
|
||||
use crate::tools::{ToolContext, ToolRegistry};
|
||||
|
||||
/// 构建与 Agent 实际使用的完全一致的组合系统提示词 Provider。
|
||||
///
|
||||
@ -60,8 +56,6 @@ pub(crate) struct AgentFactory {
|
||||
model_selections: Arc<ModelSelectionStore>,
|
||||
/// 上下文压缩算法配置(所有 agent 共享)
|
||||
compaction_config: CompactionConfig,
|
||||
/// 可观测性 Observer(依赖注入到 AgentLoop,业务层不感知具体实现)
|
||||
observer: Option<Arc<dyn Observer>>,
|
||||
/// 实例创建时间戳(用于区分新旧 AgentFactory 实例)
|
||||
instance_id: u64,
|
||||
}
|
||||
@ -77,13 +71,6 @@ pub(crate) struct AgentBuildRequest<'a> {
|
||||
pub(crate) topic_id: Option<String>,
|
||||
/// 取消信号接收端(可选):Agent 在每次迭代时检查是否被取消
|
||||
pub(crate) cancel_token: Option<tokio::sync::watch::Receiver<()>>,
|
||||
/// 端到端追踪 ID(从 InboundMessage 继承,注入 ToolContext 供 tool 执行路径日志关联)
|
||||
pub(crate) trace_id: Option<String>,
|
||||
/// 异步子代理完成队列的 sender(按 topic 隔离)。
|
||||
/// 仅主 agent 有值:TaskTool 据此在子代理完成时发送 SubagentResult。
|
||||
pub(crate) sub_done_sender: Option<mpsc::Sender<SubagentResult>>,
|
||||
/// wait_for_subagents 工具的协调器(仅主 agent 有值)。
|
||||
pub(crate) wait_coordinator: Option<Arc<dyn WaitCoordinator>>,
|
||||
}
|
||||
|
||||
impl AgentFactory {
|
||||
@ -97,7 +84,6 @@ impl AgentFactory {
|
||||
model_resolver: Arc<ModelResolver>,
|
||||
model_selections: Arc<ModelSelectionStore>,
|
||||
compaction_config: CompactionConfig,
|
||||
observer: Option<Arc<dyn Observer>>,
|
||||
) -> Self {
|
||||
// 使用 Arc 指针地址作为实例标识符,用于区分新旧 AgentFactory 实例
|
||||
let instance_id = Arc::as_ptr(&tools) as u64;
|
||||
@ -116,7 +102,6 @@ impl AgentFactory {
|
||||
model_resolver,
|
||||
model_selections,
|
||||
compaction_config,
|
||||
observer,
|
||||
instance_id,
|
||||
}
|
||||
}
|
||||
@ -242,12 +227,6 @@ impl AgentFactory {
|
||||
.notification_chat_id
|
||||
.unwrap_or(request.session_chat_id);
|
||||
// 构建上下文压缩器(参数内聚到 ContextCompressor,CompactionConfig 注入)
|
||||
// 注入取消信号 receiver 的 clone 到 ToolContext,
|
||||
// 供 wait_for_subagents 工具传递给 coordinator.wait() 的 select!。
|
||||
// watch::Receiver::clone() 创建共享同一 sender 的新 receiver,
|
||||
// 各 receiver 的 has_changed()/changed() 状态独立,互不影响。
|
||||
let cancel_rx_for_context = request.cancel_token.as_ref().map(|rx| rx.clone());
|
||||
|
||||
let runtime_config = AgentRuntimeConfig::from(effective_provider_config.clone());
|
||||
let compressor = Arc::new(self.build_compressor(&runtime_config));
|
||||
let mut agent = agent
|
||||
@ -266,19 +245,8 @@ impl AgentFactory {
|
||||
tool_call_id: None,
|
||||
// 注入专家 capability,TaskTool 据此强制校验子代理白/黑名单
|
||||
parent_capability: expert_capability.clone(),
|
||||
trace_id: request.trace_id.clone(),
|
||||
// 注入异步子代理完成队列 sender(按 topic 隔离)
|
||||
sub_done_sender: request.sub_done_sender.clone(),
|
||||
// 注入 wait 协调器(封装释放/重获取 serial_lock 逻辑)
|
||||
wait_coordinator: request.wait_coordinator.clone(),
|
||||
// 注入取消信号 receiver clone(供 wait 工具的 cancel 检查)
|
||||
cancel_rx: cancel_rx_for_context,
|
||||
})
|
||||
.with_compressor(Some(compressor));
|
||||
// 注入观测器(依赖注入,agent_loop 只认 Observer trait)
|
||||
if let Some(ref observer) = self.observer {
|
||||
agent = agent.with_observer(observer.clone());
|
||||
}
|
||||
// 如果有取消信号接收端,注入 Agent
|
||||
if let Some(token) = request.cancel_token {
|
||||
agent = agent.with_cancel_token(token);
|
||||
|
||||
@ -84,10 +84,9 @@ pub fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// axum 中间件:对 `/api/*` 和 `/metrics` 路由强制 Bearer token 校验。
|
||||
/// axum 中间件:对 `/api/*` 路由强制 Bearer token 校验。
|
||||
/// 仅在 `requires_auth` 为 true 时挂载。
|
||||
/// `/health`、`/ws`、静态资源放行;`/ws` 的 token 校验在 ws_handler 内完成。
|
||||
/// `/metrics` 包含运行时指标(provider/model/耗时/token 用量),远程部署时需保护。
|
||||
pub async fn require_bearer_auth(
|
||||
headers: HeaderMap,
|
||||
request: Request,
|
||||
@ -95,9 +94,8 @@ pub async fn require_bearer_auth(
|
||||
) -> Response {
|
||||
let path = request.uri().path();
|
||||
|
||||
// /api/* 和 /metrics 需要认证;其余放行
|
||||
let needs_auth = path.starts_with("/api/") || path == "/metrics";
|
||||
if !needs_auth {
|
||||
// 仅对 /api/ 前缀的请求强制认证
|
||||
if !path.starts_with("/api/") {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
|
||||
@ -6,20 +6,13 @@ use crate::agent::AgentError;
|
||||
|
||||
use super::session::Session;
|
||||
|
||||
/// Run two-segment history compression.
|
||||
/// Run two-segment history compression synchronously.
|
||||
///
|
||||
/// The session lock is held only for the brief data-gathering and
|
||||
/// history-reload phases. The expensive LLM call (2–5 seconds) and the DB
|
||||
/// write happen **without** holding the session lock, so other commands
|
||||
/// (e.g. `create_session`, `list_topics`) are not blocked during compression.
|
||||
///
|
||||
/// Concurrency safety:
|
||||
/// - **Same topic**: the caller (`prepare_and_execute_message`) holds the
|
||||
/// per-topic serial lock (`_serial_guard`) for the entire duration of
|
||||
/// execution + compaction, so no other message for this topic can modify
|
||||
/// the in-memory or DB history between phases.
|
||||
/// - **Different topic**: fully unblocked — the session lock is free during
|
||||
/// the LLM call.
|
||||
/// Unlike the previous background approach (tokio::spawn), this holds the
|
||||
/// session lock during the LLM calls (2–5 seconds). Since the agent loop
|
||||
/// has already finished by this point there is no response-time impact, and
|
||||
/// the synchronous guarantee means the next execution always starts with
|
||||
/// freshly compacted history.
|
||||
///
|
||||
/// 按 topic_id 隔离:压缩只处理指定 topic 的历史,DB 替换也只影响该 topic。
|
||||
pub(crate) async fn schedule_background_history_compaction(
|
||||
@ -30,41 +23,35 @@ pub(crate) async fn schedule_background_history_compaction(
|
||||
let chat_id = chat_id.into();
|
||||
let topic_id = topic_id.into();
|
||||
|
||||
// Phase 1: brief session lock to gather compaction inputs.
|
||||
let (history, compressor, store, session_id, provider_config) = {
|
||||
let mut session_guard = session.lock().await;
|
||||
session_guard.ensure_persistent_session(&chat_id)?;
|
||||
session_guard.ensure_chat_loaded(&chat_id, Some(&topic_id))?;
|
||||
let mut session_guard = session.lock().await;
|
||||
session_guard.ensure_persistent_session(&chat_id)?;
|
||||
session_guard.ensure_chat_loaded(&chat_id, Some(&topic_id))?;
|
||||
|
||||
let history = session_guard.get_or_create_history(&topic_id).clone();
|
||||
let compressor = session_guard.compressor().clone();
|
||||
let store = session_guard.store();
|
||||
let session_id = session_guard.persistent_session_id(&chat_id);
|
||||
let provider_config = session_guard.provider_config().clone();
|
||||
(history, compressor, store, session_id, provider_config)
|
||||
};
|
||||
// session lock released here
|
||||
let history = session_guard.get_or_create_history(&topic_id).clone();
|
||||
let compressor = session_guard.compressor().clone();
|
||||
|
||||
if !compressor.should_compress(&history) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let store = session_guard.store();
|
||||
let session_id = session_guard.persistent_session_id(&chat_id);
|
||||
let provider_config = session_guard.provider_config().clone();
|
||||
|
||||
tracing::info!(
|
||||
chat_id = %chat_id,
|
||||
topic_id = %topic_id,
|
||||
msg_count = history.len(),
|
||||
"Starting two-segment compression (session lock released during LLM call)"
|
||||
"Starting synchronous two-segment compression"
|
||||
);
|
||||
|
||||
// Phase 2: LLM compression WITHOUT holding the session lock.
|
||||
// Synchronous compression — holds lock during LLM calls.
|
||||
// compress_two_segment guarantees the result contains no tool_calls,
|
||||
// so there is no risk of orphaned tool call sequences.
|
||||
let compressed = compressor
|
||||
.compress_two_segment(&history, &provider_config)
|
||||
.await?;
|
||||
|
||||
// Phase 3: DB write — store is Arc<dyn ConversationRepository>, no
|
||||
// session lock needed.
|
||||
// 保留原始消息(标记 is_compacted=1)+ 插入压缩摘要,不删除原消息,
|
||||
// 从而让前端仍能展示完整原始对话,LLM 只看压缩后的精简历史。
|
||||
store
|
||||
@ -78,11 +65,7 @@ pub(crate) async fn schedule_background_history_compaction(
|
||||
"Two-segment compression committed (original messages retained)"
|
||||
);
|
||||
|
||||
// Phase 4: re-acquire session lock to refresh in-memory history.
|
||||
{
|
||||
let mut session_guard = session.lock().await;
|
||||
session_guard.reload_topic_history(&chat_id, &topic_id)?;
|
||||
}
|
||||
session_guard.reload_topic_history(&chat_id, &topic_id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -15,8 +15,6 @@ use tokio::sync::Mutex;
|
||||
use super::compaction::schedule_background_history_compaction;
|
||||
use super::message_prepare::enrich_user_content_with_media_refs;
|
||||
use super::session::Session;
|
||||
use super::wait_coordinator::SessionWaitCoordinator;
|
||||
use crate::tools::WaitCoordinator;
|
||||
|
||||
/// 空的 EmittedMessageHandler,不转发消息,仅配合 PersistingEmittedMessageHandler 做持久化。
|
||||
struct NoOpEmittedMessageHandler;
|
||||
@ -116,8 +114,6 @@ pub(crate) struct MessageExecutionRequest<'a> {
|
||||
pub(crate) live_emitter: Option<Arc<dyn EmittedMessageHandler>>,
|
||||
/// 消息接收时捕获的 topic_id,全程显式传递避免从共享状态重复读取竞态
|
||||
pub(crate) topic_id: Option<String>,
|
||||
/// 端到端追踪 ID(从 InboundMessage 透传,贯穿 agent → tool → outbound)
|
||||
pub(crate) trace_id: &'a str,
|
||||
}
|
||||
|
||||
pub(crate) struct ScheduledExecutionRequest<'a> {
|
||||
@ -131,8 +127,6 @@ pub(crate) struct ScheduledExecutionRequest<'a> {
|
||||
pub(crate) system_prompt: Option<&'a str>,
|
||||
pub(crate) metadata: &'a HashMap<String, String>,
|
||||
pub(crate) fresh_session: bool,
|
||||
/// 端到端追踪 ID(由 ScheduledAgentTaskService 生成)
|
||||
pub(crate) trace_id: String,
|
||||
}
|
||||
|
||||
impl AgentExecutionService {
|
||||
@ -286,32 +280,15 @@ impl AgentExecutionService {
|
||||
// 获取该 topic 的串行锁(通过短暂获取 session 锁)
|
||||
// 同一 topic 的消息处理必须串行执行,防止并发 loop 操作同一历史的不同快照
|
||||
// 不同 topic 之间互不阻塞,支持多话题并发执行
|
||||
let (serial_lock, store, lock_key) = {
|
||||
let serial_lock = {
|
||||
let mut session_guard = request.session.lock().await;
|
||||
let lock_key = request
|
||||
.topic_id
|
||||
.as_deref()
|
||||
.unwrap_or(request.chat_id)
|
||||
.to_string();
|
||||
session_guard.ensure_sub_done_channel(&lock_key);
|
||||
(
|
||||
session_guard.topic_serial_lock(&lock_key),
|
||||
session_guard.session_store(),
|
||||
lock_key,
|
||||
)
|
||||
let lock_key = request.topic_id.as_deref().unwrap_or(request.chat_id);
|
||||
session_guard.topic_serial_lock(lock_key)
|
||||
};
|
||||
|
||||
// 等待该 topic 的前一条消息处理完成(含压缩)
|
||||
// await 串行锁时不持有 session 锁,其他 topic 的消息可以正常处理
|
||||
// 使用 lock_owned 获取 OwnedMutexGuard,存入 guard_slot 供 wait_coordinator 释放/重获取
|
||||
// 注意:lock_owned 消费 Arc<Self>,需 clone 保留 serial_lock 供 coordinator 使用
|
||||
let serial_guard = serial_lock.clone().lock_owned().await;
|
||||
|
||||
// guard_slot:wait_coordinator 通过此 slot 释放/重获取 serial_lock。
|
||||
// 正常执行时 guard 留在 slot 中(锁持有);wait 工具调用时 take guard 释放锁,
|
||||
// select! 等待结束后重获取锁并回填新 guard。
|
||||
// guard_slot 作为 Arc 共享于执行路径与 coordinator,二者全部 drop 时 guard 才释放锁。
|
||||
let guard_slot = Arc::new(Mutex::new(Some(serial_guard)));
|
||||
let _serial_guard = serial_lock.lock().await;
|
||||
|
||||
let (history, agent, user_message, user_message_count, original_topic_id) = {
|
||||
let mut session_guard = request.session.lock().await;
|
||||
@ -359,27 +336,11 @@ impl AgentExecutionService {
|
||||
let history = session_guard.get_or_create_history(history_key).clone();
|
||||
session_guard.record_skill_offer(request.chat_id)?;
|
||||
|
||||
// 创建 wait 协调器(封装释放/重获取 serial_lock + select! 等待逻辑)。
|
||||
// 仅主 agent 注入;coordinator 通过 guard_slot 释放/重获取 serial_lock,
|
||||
// 使 wait_for_subagents 工具能在等待期间让 process_one 注入用户消息。
|
||||
let wait_coordinator: Option<Arc<dyn WaitCoordinator>> = {
|
||||
let coordinator = SessionWaitCoordinator::new(
|
||||
request.session.clone(),
|
||||
guard_slot.clone(),
|
||||
serial_lock.clone(),
|
||||
store.clone(),
|
||||
lock_key.clone(),
|
||||
);
|
||||
Some(Arc::new(coordinator))
|
||||
};
|
||||
|
||||
let mut agent = session_guard.create_agent(
|
||||
request.chat_id,
|
||||
Some(request.sender_id),
|
||||
Some(&user_message.id),
|
||||
original_topic_id.as_deref(),
|
||||
request.trace_id,
|
||||
wait_coordinator,
|
||||
)?;
|
||||
if let Some(handler) = request.live_emitter.clone() {
|
||||
agent = agent.with_emitted_message_handler(handler);
|
||||
@ -442,26 +403,17 @@ impl AgentExecutionService {
|
||||
// 获取该 topic 的串行锁(与普通消息路径共享,保证串行执行)
|
||||
// 定时任务由调度器触发,无用户消息竞态;在锁前一次性捕获 topic_id,
|
||||
// 锁后复用同一值作为 original_topic_id,保证锁键与写入目标一致。
|
||||
let (serial_lock, session_store, lock_key, lock_time_topic_id) = {
|
||||
let (serial_lock, lock_time_topic_id) = {
|
||||
let mut session_guard = request.session.lock().await;
|
||||
let tid = session_guard
|
||||
.current_topic(request.chat_id)
|
||||
.map(|s| s.to_string());
|
||||
let lock_key = tid.as_deref().unwrap_or(request.chat_id).to_string();
|
||||
session_guard.ensure_sub_done_channel(&lock_key);
|
||||
(
|
||||
session_guard.topic_serial_lock(&lock_key),
|
||||
session_guard.session_store(),
|
||||
lock_key,
|
||||
tid,
|
||||
)
|
||||
let lock_key = tid.as_deref().unwrap_or(request.chat_id);
|
||||
(session_guard.topic_serial_lock(lock_key), tid)
|
||||
};
|
||||
|
||||
// 等待该 topic 的前一条消息处理完成(含压缩)
|
||||
// 使用 lock_owned 获取 OwnedMutexGuard,存入 guard_slot 供 wait_coordinator 释放/重获取
|
||||
// 注意:lock_owned 消费 Arc<Self>,需 clone 保留 serial_lock 供 coordinator 使用
|
||||
let serial_guard = serial_lock.clone().lock_owned().await;
|
||||
let guard_slot = Arc::new(Mutex::new(Some(serial_guard)));
|
||||
let _serial_guard = serial_lock.lock().await;
|
||||
|
||||
let (
|
||||
history,
|
||||
@ -518,18 +470,6 @@ impl AgentExecutionService {
|
||||
let history = session_guard.get_or_create_history(history_key).clone();
|
||||
session_guard.record_skill_offer(request.chat_id)?;
|
||||
|
||||
// 创建 wait 协调器(与普通消息路径一致,支持定时任务中 spawn 异步子代理)
|
||||
let wait_coordinator: Option<Arc<dyn WaitCoordinator>> = {
|
||||
let coordinator = SessionWaitCoordinator::new(
|
||||
request.session.clone(),
|
||||
guard_slot.clone(),
|
||||
serial_lock.clone(),
|
||||
session_store.clone(),
|
||||
lock_key.clone(),
|
||||
);
|
||||
Some(Arc::new(coordinator))
|
||||
};
|
||||
|
||||
let agent = session_guard.create_agent_with_provider_config(
|
||||
request.chat_id,
|
||||
request.notification_chat_id, // 传入真实 chat_id
|
||||
@ -537,8 +477,6 @@ impl AgentExecutionService {
|
||||
Some(&user_message.id),
|
||||
request.provider_config.clone(),
|
||||
original_topic_id.as_deref(),
|
||||
&request.trace_id,
|
||||
wait_coordinator,
|
||||
)?;
|
||||
|
||||
// 获取 store 和 session_id,用于构造消息持久化 handler
|
||||
|
||||
@ -240,21 +240,6 @@ pub async fn list_executions(State(state): State<Arc<GatewayState>>) -> Json<Exe
|
||||
Json(ExecutionsResponse { topic_ids })
|
||||
}
|
||||
|
||||
/// GET /metrics — Prometheus metrics 端点
|
||||
///
|
||||
/// 返回 Prometheus 格式的 metrics 文本。若 recorder 未安装则返回 503。
|
||||
pub async fn metrics_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> (StatusCode, String) {
|
||||
match &state.prometheus_handle {
|
||||
Some(handle) => (StatusCode::OK, handle.render()),
|
||||
None => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Metrics recorder not initialized".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/mcp/status — Return MCP server connection status
|
||||
pub async fn mcp_status(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
|
||||
@ -28,7 +28,6 @@ pub mod session_pool;
|
||||
pub mod static_files;
|
||||
pub mod tool_prompt_provider;
|
||||
pub mod tool_registry_factory;
|
||||
pub mod wait_coordinator;
|
||||
pub mod ws;
|
||||
|
||||
use axum::{Router, middleware, routing};
|
||||
@ -71,13 +70,8 @@ pub struct GatewayState {
|
||||
pub skills: Arc<SkillRuntime>,
|
||||
pub experts: Arc<crate::experts::ExpertRuntime>,
|
||||
pub subagent_runtime: Arc<SubagentRuntime>,
|
||||
/// 异步子代理执行器(DefaultSubAgentRuntime),用于取消传播等操作
|
||||
pub subagent_executor: Option<Arc<dyn crate::tools::SubAgentRuntime>>,
|
||||
/// per-session 的用户模型选择(覆盖专家配置)
|
||||
pub model_selections: Arc<model_selection::ModelSelectionStore>,
|
||||
/// Prometheus metrics handle(/metrics 端点渲染用)。
|
||||
/// None 表示 recorder 安装失败;热重启时从 OnceLock 缓存复用,不会因重复安装而变为 None。
|
||||
pub prometheus_handle: Option<metrics_exporter_prometheus::PrometheusHandle>,
|
||||
}
|
||||
|
||||
impl GatewayState {
|
||||
@ -108,7 +102,7 @@ impl GatewayState {
|
||||
mcp_servers: config.mcp_servers.clone(),
|
||||
};
|
||||
|
||||
let (session_manager, task_repository, mcp_manager, subagent_runtime, model_selections, subagent_executor) =
|
||||
let (session_manager, task_repository, mcp_manager, subagent_runtime, model_selections) =
|
||||
build_session_manager_with_sender(
|
||||
agent_prompt_reinject_every,
|
||||
show_tool_results,
|
||||
@ -124,7 +118,6 @@ impl GatewayState {
|
||||
config.memory_maintenance.clone(),
|
||||
session_ttl_hours,
|
||||
mcp_config,
|
||||
config.mcp_tool_timeout_secs,
|
||||
Some(bus.clone()),
|
||||
Arc::new(crate::config::ModelResolver::from_config(&config)),
|
||||
config.compaction.clone(),
|
||||
@ -138,9 +131,6 @@ impl GatewayState {
|
||||
|
||||
let cancel_manager = CancelManager::new();
|
||||
|
||||
// 安装 Prometheus recorder(幂等:首次安装并缓存 handle,热重启时返回缓存)
|
||||
let prometheus_handle = crate::observability::metrics::init_recorder();
|
||||
|
||||
Ok(Self {
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
session_manager,
|
||||
@ -153,9 +143,7 @@ impl GatewayState {
|
||||
skills,
|
||||
experts,
|
||||
subagent_runtime,
|
||||
subagent_executor,
|
||||
model_selections,
|
||||
prometheus_handle,
|
||||
})
|
||||
}
|
||||
|
||||
@ -182,7 +170,6 @@ impl GatewayState {
|
||||
semaphore,
|
||||
provider_config,
|
||||
self.cancel_manager.clone(),
|
||||
self.subagent_executor.clone(),
|
||||
);
|
||||
tokio::spawn(inbound_processor.run());
|
||||
|
||||
@ -207,10 +194,9 @@ pub async fn run(
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let config = Config::load_default()?;
|
||||
let timezone = config.time.parse_timezone()?;
|
||||
let log_format = config.observability.log_format.clone();
|
||||
|
||||
// Initialize logging
|
||||
logging::init_logging(timezone, log_format);
|
||||
logging::init_logging(timezone);
|
||||
tracing::info!("Starting PicoBot Gateway");
|
||||
|
||||
// Restart signal channel
|
||||
@ -218,30 +204,6 @@ pub async fn run(
|
||||
|
||||
let state = Arc::new(GatewayState::from_config(config, restart_tx)?);
|
||||
|
||||
// ── 崩溃恢复:标记中断的异步子代理 ──
|
||||
// 服务器重启后,之前 spawn 的异步子代理进程已不存在,
|
||||
// 将 pending_subagents 表中所有 status='running' 的记录标记为 'interrupted'。
|
||||
// 下次 wait_for_subagents 调用时,这些 task_id 不会出现在 pending 列表中,
|
||||
// agent 可据此判断子代理未正常完成。
|
||||
match state.session_manager.store().mark_all_running_as_interrupted() {
|
||||
Ok(0) => {
|
||||
tracing::info!("Crash recovery: no interrupted subagents to recover");
|
||||
}
|
||||
Ok(n) => {
|
||||
tracing::info!(
|
||||
recovered_count = n,
|
||||
"Crash recovery: marked {} running subagents as interrupted (server restarted)",
|
||||
n
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
"Crash recovery: failed to mark interrupted subagents"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get provider config for channels
|
||||
let cfg = state.config.read().await;
|
||||
let provider_config = cfg.get_provider_config("default")?;
|
||||
@ -369,8 +331,7 @@ pub async fn run(
|
||||
"/api/session/selected-model",
|
||||
routing::get(http::session_selected_model),
|
||||
)
|
||||
.route("/ws", routing::get(ws::ws_handler))
|
||||
.route("/metrics", routing::get(http::metrics_handler));
|
||||
.route("/ws", routing::get(ws::ws_handler));
|
||||
|
||||
// 仅 fallback 按模式区分:嵌入资源 vs 磁盘目录。
|
||||
// fallback 必须在 with_state 之前调用,否则 handler 的 State 类型无法推断。
|
||||
|
||||
@ -171,8 +171,6 @@ impl OutboundDispatcher {
|
||||
channel_name: &str,
|
||||
msg: OutboundMessage,
|
||||
) {
|
||||
let msg_chat_id = msg.chat_id.clone();
|
||||
let msg_trace_id = msg.trace_id.clone();
|
||||
match Self::send_with_retry(channel, msg).await {
|
||||
Ok(()) => {}
|
||||
Err(ChannelError::ChannelFull) => {
|
||||
@ -180,16 +178,12 @@ impl OutboundDispatcher {
|
||||
// 记 warn 而非 error:这是预期的背压丢弃。
|
||||
tracing::warn!(
|
||||
channel = %channel_name,
|
||||
chat_id = %msg_chat_id,
|
||||
trace_id = %msg_trace_id,
|
||||
"Message dropped: channel queue full"
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
channel = %channel_name,
|
||||
chat_id = %msg_chat_id,
|
||||
trace_id = %msg_trace_id,
|
||||
error = %error,
|
||||
"Failed to send message after retries"
|
||||
);
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use futures_util::FutureExt;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use tokio::sync::Semaphore;
|
||||
@ -29,7 +28,6 @@ use crate::storage::persistent_session_id;
|
||||
use crate::topic_description::generate_topic_description;
|
||||
|
||||
use super::session::{BusToolCallEmitter, SessionManager};
|
||||
use super::message_prepare::enrich_user_content_with_media_refs;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct InboundProcessor {
|
||||
@ -49,7 +47,6 @@ impl InboundProcessor {
|
||||
semaphore: Arc<Semaphore>,
|
||||
provider_config: LLMProviderConfig,
|
||||
cancel_manager: CancelManager,
|
||||
subagent_executor: Option<Arc<dyn crate::tools::SubAgentRuntime>>,
|
||||
) -> Self {
|
||||
// 创建命令路由器并注册处理器
|
||||
let mut command_router = CommandRouter::new();
|
||||
@ -120,7 +117,6 @@ impl InboundProcessor {
|
||||
command_router.register(Box::new(StopExecutionCommandHandler::new(
|
||||
cancel_manager.clone(),
|
||||
session_manager.clone(),
|
||||
subagent_executor,
|
||||
)));
|
||||
|
||||
Self {
|
||||
@ -151,15 +147,17 @@ impl InboundProcessor {
|
||||
}
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
channel = %inbound.channel,
|
||||
chat_id = %inbound.chat_id,
|
||||
trace_id = %inbound.trace_id,
|
||||
sender = %inbound.sender_id,
|
||||
content_len = %inbound.content.len(),
|
||||
media_count = %inbound.media.len(),
|
||||
"Processing inbound message"
|
||||
);
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
tracing::debug!(
|
||||
channel = %inbound.channel,
|
||||
chat_id = %inbound.chat_id,
|
||||
sender = %inbound.sender_id,
|
||||
content_len = %inbound.content.len(),
|
||||
media_count = %inbound.media.len(),
|
||||
"Processing inbound message"
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 获取 semaphore permit(控制并发)
|
||||
let permit = match self.semaphore.clone().acquire_owned().await {
|
||||
@ -174,53 +172,18 @@ impl InboundProcessor {
|
||||
let processor = self.clone();
|
||||
|
||||
// 4. 独立任务处理(包含 permit,任务完成自动释放)
|
||||
// spawn 不自动传播父 span,用 traced() 重建 span 上下文,
|
||||
// 使 process_one 内所有日志携带 trace_id/chat_id/session_id。
|
||||
let trace_id = inbound.trace_id.clone();
|
||||
let chat_id_for_span = inbound.chat_id.clone();
|
||||
let session_id_for_span =
|
||||
crate::storage::persistent_session_id(&inbound.channel, &inbound.chat_id);
|
||||
tokio::spawn(
|
||||
crate::observability::tracing_ctx::traced(
|
||||
&trace_id,
|
||||
&chat_id_for_span,
|
||||
&session_id_for_span,
|
||||
async move {
|
||||
let _permit = permit; // 持有 permit 直到任务完成
|
||||
// catch_unwind 将 panic 归一化为错误:否则工具/历史清理中的
|
||||
// panic 只会终止任务并打 panic hook 日志,跳过错误日志与指标,
|
||||
// 用户消息被静默吞掉。参考 channels/wechat.rs 的同类用法。
|
||||
let result = std::panic::AssertUnwindSafe(processor.process_one(inbound))
|
||||
.catch_unwind()
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(
|
||||
error = %crate::utils::format_error_chain(&e),
|
||||
"Message processing failed"
|
||||
);
|
||||
crate::observability::metrics::record_message_processing_error();
|
||||
}
|
||||
Err(payload) => {
|
||||
tracing::error!(
|
||||
error = %crate::utils::panic_payload_message(&payload),
|
||||
"Message processing panicked"
|
||||
);
|
||||
crate::observability::metrics::record_message_processing_error();
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit; // 持有 permit 直到任务完成
|
||||
if let Err(e) = processor.process_one(inbound).await {
|
||||
tracing::error!(error = %e, "Message processing failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self, inbound), fields(trace_id = %inbound.trace_id, chat_id = %inbound.chat_id, session_id))]
|
||||
async fn process_one(&self, inbound: InboundMessage) -> Result<(), AgentError> {
|
||||
// 计算正确的 session_id(根据 channel_name 和 chat_id)
|
||||
let session_id = persistent_session_id(&inbound.channel, &inbound.chat_id);
|
||||
tracing::Span::current().record("session_id", tracing::field::display(&session_id));
|
||||
|
||||
// 获取当前话题(封装了 session 创建逻辑)
|
||||
let current_topic = self
|
||||
@ -257,17 +220,14 @@ impl InboundProcessor {
|
||||
for msg in &response.messages {
|
||||
if let Err(error) = self
|
||||
.bus
|
||||
.publish_outbound(
|
||||
OutboundMessage::assistant(
|
||||
inbound.channel.clone(),
|
||||
inbound.chat_id.clone(),
|
||||
response.metadata.get("session_id").cloned(),
|
||||
msg.content.clone(),
|
||||
None,
|
||||
inbound.forwarded_metadata.clone(),
|
||||
)
|
||||
.with_trace_id(&inbound.trace_id),
|
||||
)
|
||||
.publish_outbound(OutboundMessage::assistant(
|
||||
inbound.channel.clone(),
|
||||
inbound.chat_id.clone(),
|
||||
response.metadata.get("session_id").cloned(),
|
||||
msg.content.clone(),
|
||||
None,
|
||||
inbound.forwarded_metadata.clone(),
|
||||
))
|
||||
.await
|
||||
{
|
||||
match error {
|
||||
@ -283,17 +243,14 @@ impl InboundProcessor {
|
||||
} else if let Some(error) = response.error {
|
||||
if let Err(e) = self
|
||||
.bus
|
||||
.publish_outbound(
|
||||
OutboundMessage::assistant(
|
||||
inbound.channel.clone(),
|
||||
inbound.chat_id.clone(),
|
||||
response.metadata.get("session_id").cloned(),
|
||||
format!("Error [{}]: {}", error.code, error.message),
|
||||
None,
|
||||
inbound.forwarded_metadata.clone(),
|
||||
)
|
||||
.with_trace_id(&inbound.trace_id),
|
||||
)
|
||||
.publish_outbound(OutboundMessage::assistant(
|
||||
inbound.channel.clone(),
|
||||
inbound.chat_id.clone(),
|
||||
response.metadata.get("session_id").cloned(),
|
||||
format!("Error [{}]: {}", error.code, error.message),
|
||||
None,
|
||||
inbound.forwarded_metadata.clone(),
|
||||
))
|
||||
.await
|
||||
{
|
||||
match e {
|
||||
@ -315,86 +272,6 @@ impl InboundProcessor {
|
||||
if let Some(ref topic_id) = current_topic {
|
||||
emitter_metadata.insert("topic_id".to_string(), topic_id.clone());
|
||||
}
|
||||
|
||||
// ── 异步子代理等待注入路径 ──
|
||||
// 当主 agent 正在 wait_for_subagents 中等待(已释放 serial_lock、is_waiting=true)时,
|
||||
// 新用户消息不应启动新的 agent loop,而应注入 history 并唤醒等待中的 agent。
|
||||
//
|
||||
// 流程:
|
||||
// 1. 获取 serial_lock(若 agent 正常运行则阻塞;若 agent 在 wait 中则立即获取)
|
||||
// 2. 检查 is_waiting:true → 注入 + wakeup + return;false → 释放锁走正常路径
|
||||
//
|
||||
// 安全性:is_waiting 在持锁状态下检查,wait_coordinator 清除 is_waiting 需先重获取锁,
|
||||
// 两者互斥,无 TOCTOU。
|
||||
if let Some(ref topic_id) = current_topic {
|
||||
if let Some(session) = self.session_manager.get(&inbound.channel).await {
|
||||
let lock_key = topic_id.clone();
|
||||
|
||||
// 获取 serial_lock Arc(短暂持有 session 锁)
|
||||
let serial_lock = {
|
||||
let mut g = session.lock().await;
|
||||
g.ensure_sub_done_channel(&lock_key);
|
||||
g.topic_serial_lock(&lock_key)
|
||||
};
|
||||
|
||||
// 阻塞获取 serial_lock
|
||||
// - agent 正常运行:阻塞至其完成(天然串行化)
|
||||
// - agent 在 wait 中:wait 已释放锁,可立即获取
|
||||
let _inject_guard = serial_lock.clone().lock_owned().await;
|
||||
|
||||
// 检查 is_waiting(持锁状态下安全)
|
||||
let is_waiting = {
|
||||
let g = session.lock().await;
|
||||
g.is_waiting(&lock_key)
|
||||
};
|
||||
|
||||
if is_waiting {
|
||||
// Agent 正在 wait_for_subagents 中等待 → 注入用户消息 + 唤醒
|
||||
tracing::info!(
|
||||
topic_id = %lock_key,
|
||||
"Topic is in waiting state, injecting user message and waking up agent"
|
||||
);
|
||||
|
||||
let wakeup = {
|
||||
let mut g = session.lock().await;
|
||||
// 确保 session 和 chat 已加载
|
||||
g.ensure_persistent_session(&inbound.chat_id)?;
|
||||
g.ensure_chat_loaded(&inbound.chat_id, Some(&lock_key))?;
|
||||
|
||||
// 构造用户消息(与 prepare_and_execute_message 一致的处理流程)
|
||||
let media_refs: Vec<String> = inbound
|
||||
.media
|
||||
.iter()
|
||||
.map(|m| m.path.clone())
|
||||
.collect();
|
||||
let enriched_content =
|
||||
enrich_user_content_with_media_refs(&inbound.content, &media_refs)?;
|
||||
let user_message =
|
||||
g.create_user_message(&enriched_content, media_refs);
|
||||
g.append_persisted_message(
|
||||
&inbound.chat_id,
|
||||
Some(&lock_key),
|
||||
user_message,
|
||||
)?;
|
||||
|
||||
// 获取 wakeup 信号
|
||||
g.wait_wakeup(&lock_key)
|
||||
};
|
||||
|
||||
// 唤醒等待中的 agent(wait_coordinator 的 select! 会捕获此通知)
|
||||
wakeup.notify_one();
|
||||
|
||||
// _inject_guard 在此处 drop → 释放 serial_lock
|
||||
// wait_coordinator 重获取锁后继续处理(history 已包含新用户消息)
|
||||
//
|
||||
// 跳过 handle_message / cancel 注册 / execution_completed,
|
||||
// 因为等待中的 agent 会处理这条消息。
|
||||
return Ok(());
|
||||
}
|
||||
// is_waiting=false:_inject_guard drop 释放锁,走正常 handle_message 路径
|
||||
}
|
||||
}
|
||||
|
||||
let live_emitter = Arc::new(PersistingEmittedMessageHandler::new(
|
||||
BusToolCallEmitter::new(
|
||||
self.bus.clone(),
|
||||
@ -402,7 +279,6 @@ impl InboundProcessor {
|
||||
inbound.chat_id.clone(),
|
||||
emitter_metadata,
|
||||
self.session_manager.store(),
|
||||
inbound.trace_id.clone(),
|
||||
),
|
||||
self.session_manager.store(),
|
||||
&session_id,
|
||||
@ -431,7 +307,6 @@ impl InboundProcessor {
|
||||
inbound.media,
|
||||
Some(live_emitter),
|
||||
current_topic.as_deref(),
|
||||
&inbound.trace_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@ -444,8 +319,6 @@ impl InboundProcessor {
|
||||
.metadata
|
||||
.insert("topic_id".to_string(), topic_id.clone());
|
||||
}
|
||||
// 透传 trace_id 到出站消息,保持端到端追踪贯通
|
||||
outbound.trace_id = inbound.trace_id.clone();
|
||||
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
||||
match error {
|
||||
crate::bus::BusError::Dropped => {
|
||||
@ -541,26 +414,19 @@ impl InboundProcessor {
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
error = %crate::utils::format_error_chain(&error),
|
||||
"Failed to handle message"
|
||||
);
|
||||
crate::observability::metrics::record_message_processing_error();
|
||||
tracing::error!(error = %error, "Failed to handle message");
|
||||
let mut metadata = inbound.forwarded_metadata.clone();
|
||||
metadata.insert("error_kind".to_string(), "agent_execution".to_string());
|
||||
if let Err(publish_error) = self
|
||||
.bus
|
||||
.publish_outbound(
|
||||
OutboundMessage::error_notification(
|
||||
inbound.channel,
|
||||
inbound.chat_id,
|
||||
None, // session_id
|
||||
error.to_string(),
|
||||
None,
|
||||
metadata,
|
||||
)
|
||||
.with_trace_id(&inbound.trace_id),
|
||||
)
|
||||
.publish_outbound(OutboundMessage::error_notification(
|
||||
inbound.channel,
|
||||
inbound.chat_id,
|
||||
None, // session_id
|
||||
error.to_string(),
|
||||
None,
|
||||
metadata,
|
||||
))
|
||||
.await
|
||||
{
|
||||
match publish_error {
|
||||
@ -580,58 +446,28 @@ impl InboundProcessor {
|
||||
self.cancel_manager.remove_by_topic(topic_id).await;
|
||||
}
|
||||
|
||||
// 发送执行完成信号,通知前端可以停止 loading 状态。
|
||||
//
|
||||
// 退出兜底(safety net):如果当前 topic 仍有 running 状态的子代理,
|
||||
// 不发送 ExecutionCompleted。这防止 LLM 未调用 wait_for_subagents 就退出时,
|
||||
// 前端过早停止 loading 导致子代理结果"丢失"的观感。
|
||||
// 恢复路径:下一条用户消息触发新的 process_one → 加载 history →
|
||||
// LLM 看到 "running" 占位 → 调用 wait_for_subagents → 消费 sub_done_q 结果。
|
||||
let has_pending_subagents = if let Some(ref topic_id) = current_topic {
|
||||
let pending = self
|
||||
.session_manager
|
||||
.store()
|
||||
.list_pending_subagents(topic_id, Some("running"))
|
||||
.unwrap_or_default();
|
||||
if !pending.is_empty() {
|
||||
tracing::info!(
|
||||
topic_id = %topic_id,
|
||||
pending_count = pending.len(),
|
||||
"Skipping ExecutionCompleted: pending subagents still running"
|
||||
);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !has_pending_subagents {
|
||||
let mut completion_metadata = inbound.forwarded_metadata.clone();
|
||||
if let Some(ref topic_id) = current_topic {
|
||||
completion_metadata.insert("topic_id".to_string(), topic_id.clone());
|
||||
}
|
||||
if let Err(error) = self
|
||||
.bus
|
||||
.publish_outbound(
|
||||
OutboundMessage::execution_completed(
|
||||
channel,
|
||||
chat_id,
|
||||
Some(session_id),
|
||||
completion_metadata,
|
||||
)
|
||||
.with_trace_id(&inbound.trace_id),
|
||||
)
|
||||
.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");
|
||||
}
|
||||
// 发送执行完成信号,通知前端可以停止 loading 状态
|
||||
// 无论成功还是失败都发送,确保前端状态正确
|
||||
let mut completion_metadata = inbound.forwarded_metadata.clone();
|
||||
if let Some(ref topic_id) = current_topic {
|
||||
completion_metadata.insert("topic_id".to_string(), topic_id.clone());
|
||||
}
|
||||
if let Err(error) = self
|
||||
.bus
|
||||
.publish_outbound(OutboundMessage::execution_completed(
|
||||
channel,
|
||||
chat_id,
|
||||
Some(session_id),
|
||||
completion_metadata,
|
||||
))
|
||||
.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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@ use crate::tools::task::runtime::SubagentRuntime;
|
||||
use crate::tools::todo_write::TodoItem;
|
||||
use crate::tools::{
|
||||
DefaultSubAgentRuntime, InMemoryTaskRepository, NoopSessionMessageSender, SessionMessageSender,
|
||||
SubAgentRuntime, SubAgentRuntimeConfig, SubagentCatalog, TaskTool, ToolRegistry,
|
||||
SubAgentRuntimeConfig, SubagentCatalog, TaskTool, ToolRegistry,
|
||||
};
|
||||
|
||||
use super::agent_factory::AgentFactory;
|
||||
@ -56,7 +56,6 @@ pub(crate) fn build_session_manager(
|
||||
maintenance_config: MemoryMaintenanceConfig,
|
||||
session_ttl_hours: Option<u64>,
|
||||
mcp_config: crate::mcp::McpConfig,
|
||||
mcp_tool_timeout_secs: u64,
|
||||
bus: Option<Arc<MessageBus>>,
|
||||
model_resolver: Arc<ModelResolver>,
|
||||
compaction_config: CompactionConfig,
|
||||
@ -67,7 +66,6 @@ pub(crate) fn build_session_manager(
|
||||
Option<Arc<McpClientManager>>,
|
||||
Arc<SubagentRuntime>,
|
||||
Arc<ModelSelectionStore>,
|
||||
Option<Arc<dyn SubAgentRuntime>>,
|
||||
),
|
||||
AgentError,
|
||||
> {
|
||||
@ -86,7 +84,6 @@ pub(crate) fn build_session_manager(
|
||||
maintenance_config,
|
||||
session_ttl_hours,
|
||||
mcp_config,
|
||||
mcp_tool_timeout_secs,
|
||||
bus,
|
||||
model_resolver,
|
||||
compaction_config,
|
||||
@ -109,7 +106,6 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
maintenance_config: MemoryMaintenanceConfig,
|
||||
session_ttl_hours: Option<u64>,
|
||||
mcp_config: crate::mcp::McpConfig,
|
||||
mcp_tool_timeout_secs: u64,
|
||||
bus: Option<Arc<MessageBus>>,
|
||||
model_resolver: Arc<ModelResolver>,
|
||||
compaction_config: CompactionConfig,
|
||||
@ -120,7 +116,6 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
Option<Arc<McpClientManager>>,
|
||||
Arc<SubagentRuntime>,
|
||||
Arc<ModelSelectionStore>,
|
||||
Option<Arc<dyn SubAgentRuntime>>,
|
||||
),
|
||||
AgentError,
|
||||
> {
|
||||
@ -197,7 +192,6 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
manager.clone(),
|
||||
server_key.clone(),
|
||||
tool_info,
|
||||
mcp_tool_timeout_secs,
|
||||
);
|
||||
mcp_tools_for_subagents.push(wrapper);
|
||||
}
|
||||
@ -211,11 +205,10 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
}
|
||||
|
||||
// Create SubAgentRuntime (if task tool is enabled)
|
||||
let (factory, task_repository, subagent_runtime, subagent_executor): (
|
||||
let (factory, task_repository, subagent_runtime): (
|
||||
_,
|
||||
Arc<dyn TaskRepository>,
|
||||
Arc<SubagentRuntime>,
|
||||
Option<Arc<dyn SubAgentRuntime>>,
|
||||
) = if task_config.enabled {
|
||||
let task_repository = Arc::new(InMemoryTaskRepository::new());
|
||||
// Build subagent tools with MCP tools (task tool registered separately below)
|
||||
@ -240,7 +233,6 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
default_max_execution_secs: task_config.max_execution_secs,
|
||||
ttl_hours: task_config.ttl_hours,
|
||||
max_nesting_depth: task_config.max_nesting_depth,
|
||||
max_concurrent: task_config.max_concurrent,
|
||||
};
|
||||
|
||||
let default_subagent_runtime = Arc::new(DefaultSubAgentRuntime::new(
|
||||
@ -264,14 +256,10 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
));
|
||||
}
|
||||
|
||||
let subagent_executor: Option<Arc<dyn SubAgentRuntime>> =
|
||||
Some(default_subagent_runtime.clone());
|
||||
|
||||
(
|
||||
factory.with_subagent_runtime(default_subagent_runtime),
|
||||
task_repository,
|
||||
subagent_runtime,
|
||||
subagent_executor,
|
||||
)
|
||||
} else {
|
||||
// task_config 未启用时仍创建 subagent_runtime(供 API 使用)
|
||||
@ -280,7 +268,6 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
factory,
|
||||
Arc::new(InMemoryTaskRepository::new()),
|
||||
subagent_runtime,
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
@ -321,8 +308,6 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
|
||||
let prompt_repository: Arc<dyn PromptInjectionRepository> = store.clone();
|
||||
let model_selections = Arc::new(ModelSelectionStore::new());
|
||||
let observer: Arc<dyn crate::observability::Observer> =
|
||||
crate::observability::metrics::default_observer();
|
||||
let agent_factory = AgentFactory::new(
|
||||
tools.clone(),
|
||||
skills.clone(),
|
||||
@ -333,7 +318,6 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
model_resolver.clone(),
|
||||
model_selections.clone(),
|
||||
compaction_config,
|
||||
Some(observer),
|
||||
);
|
||||
let session_factory = SessionFactory::new(
|
||||
provider_config.clone(),
|
||||
@ -376,6 +360,5 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
mcp_manager,
|
||||
subagent_runtime,
|
||||
model_selections,
|
||||
subagent_executor,
|
||||
))
|
||||
}
|
||||
|
||||
@ -47,10 +47,7 @@ impl ScheduledAgentTaskService {
|
||||
.unwrap_or_else(|| "scheduler".to_string());
|
||||
let provider_config = self.provider_configs.select(options.agent.as_deref())?;
|
||||
|
||||
// 定时任务没有入站消息,在此生成独立 trace_id 以贯穿 agent → tool → outbound
|
||||
let trace_id = crate::observability::tracing_ctx::new_trace_id();
|
||||
|
||||
let mut outbound_messages = AgentExecutionService::new(self.show_tool_results)
|
||||
AgentExecutionService::new(self.show_tool_results)
|
||||
.prepare_and_execute_scheduled_task(ScheduledExecutionRequest {
|
||||
session,
|
||||
channel_name,
|
||||
@ -62,15 +59,7 @@ impl ScheduledAgentTaskService {
|
||||
system_prompt: options.system_prompt.as_deref(),
|
||||
metadata: &options.metadata,
|
||||
fresh_session: options.fresh_session,
|
||||
trace_id: trace_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// 将 trace_id 透传到出站消息,保持端到端追踪贯通
|
||||
for msg in &mut outbound_messages {
|
||||
msg.trace_id = trace_id.clone();
|
||||
}
|
||||
|
||||
Ok(outbound_messages)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,12 +14,10 @@ use crate::storage::{
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::tools::task::repository::TaskRepository;
|
||||
use crate::tools::task::runtime::SubagentRuntime;
|
||||
use crate::tools::task::SubagentResult;
|
||||
use crate::tools::WaitCoordinator;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, Notify, mpsc};
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::agent_factory::{AgentBuildRequest, AgentFactory};
|
||||
@ -63,7 +61,6 @@ pub struct BusToolCallEmitter {
|
||||
metadata: HashMap<String, String>,
|
||||
store: Arc<SessionStore>,
|
||||
stream_message_id: parking_lot::Mutex<Option<String>>,
|
||||
trace_id: String,
|
||||
}
|
||||
|
||||
impl BusToolCallEmitter {
|
||||
@ -73,7 +70,6 @@ impl BusToolCallEmitter {
|
||||
chat_id: impl Into<String>,
|
||||
metadata: HashMap<String, String>,
|
||||
store: Arc<SessionStore>,
|
||||
trace_id: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
bus,
|
||||
@ -82,7 +78,6 @@ impl BusToolCallEmitter {
|
||||
metadata,
|
||||
store,
|
||||
stream_message_id: parking_lot::Mutex::new(None),
|
||||
trace_id: trace_id.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -90,7 +85,7 @@ impl BusToolCallEmitter {
|
||||
#[async_trait]
|
||||
impl EmittedMessageHandler for BusToolCallEmitter {
|
||||
async fn handle(&self, message: ChatMessage) {
|
||||
for mut outbound in OutboundMessage::from_chat_message(
|
||||
for outbound in OutboundMessage::from_chat_message(
|
||||
&self.channel_name,
|
||||
&self.chat_id,
|
||||
None, // session_id
|
||||
@ -98,7 +93,6 @@ impl EmittedMessageHandler for BusToolCallEmitter {
|
||||
&self.metadata,
|
||||
&message,
|
||||
) {
|
||||
outbound.trace_id = self.trace_id.clone();
|
||||
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
||||
match error {
|
||||
crate::bus::BusError::Dropped => {
|
||||
@ -117,7 +111,7 @@ impl EmittedMessageHandler for BusToolCallEmitter {
|
||||
if let Some(ms) = duration_ms {
|
||||
metadata.insert("tool_duration_ms".to_string(), ms.to_string());
|
||||
}
|
||||
for mut outbound in OutboundMessage::from_chat_message(
|
||||
for outbound in OutboundMessage::from_chat_message(
|
||||
&self.channel_name,
|
||||
&self.chat_id,
|
||||
None, // session_id
|
||||
@ -125,7 +119,6 @@ impl EmittedMessageHandler for BusToolCallEmitter {
|
||||
&metadata,
|
||||
&message,
|
||||
) {
|
||||
outbound.trace_id = self.trace_id.clone();
|
||||
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
||||
match error {
|
||||
crate::bus::BusError::Dropped => {
|
||||
@ -154,7 +147,7 @@ impl EmittedMessageHandler for BusToolCallEmitter {
|
||||
};
|
||||
|
||||
// Empty content + no reasoning = stream end signal
|
||||
let mut outbound = if delta.content.is_empty() && delta.reasoning_content.is_none() {
|
||||
let outbound = if delta.content.is_empty() && delta.reasoning_content.is_none() {
|
||||
OutboundMessage::stream_end(
|
||||
&self.channel_name,
|
||||
&self.chat_id,
|
||||
@ -173,7 +166,6 @@ impl EmittedMessageHandler for BusToolCallEmitter {
|
||||
self.metadata.clone(),
|
||||
)
|
||||
};
|
||||
outbound.trace_id = self.trace_id.clone();
|
||||
|
||||
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
||||
match error {
|
||||
@ -310,7 +302,6 @@ impl Session {
|
||||
model_resolver,
|
||||
Arc::new(super::model_selection::ModelSelectionStore::new()),
|
||||
crate::config::CompactionConfig::default(),
|
||||
None,
|
||||
);
|
||||
Self::with_factories(
|
||||
channel_name,
|
||||
@ -415,17 +406,12 @@ impl Session {
|
||||
|
||||
/// 确保指定 topic 的历史已加载到内存。
|
||||
/// 按 topic_id 键化查找,已存在则直接返回,否则从 DB 加载。
|
||||
/// 加载后对账 pending_subagents DB,替换过时的 "running" 占位。
|
||||
pub fn ensure_chat_loaded(
|
||||
&mut self,
|
||||
chat_id: &str,
|
||||
topic_id: Option<&str>,
|
||||
) -> Result<(), AgentError> {
|
||||
self.history.ensure_chat_loaded(chat_id, topic_id)?;
|
||||
if let Some(tid) = topic_id {
|
||||
self.reconcile_running_placeholders(tid);
|
||||
}
|
||||
Ok(())
|
||||
self.history.ensure_chat_loaded(chat_id, topic_id)
|
||||
}
|
||||
|
||||
pub fn ensure_agent_prompt_before_user_message(
|
||||
@ -594,147 +580,19 @@ impl Session {
|
||||
self.history.topic_serial_lock(topic_id)
|
||||
}
|
||||
|
||||
/// 确保该 topic 的 sub_done 队列已创建(与 topic_serial_lock 同步初始化)。
|
||||
pub(crate) fn ensure_sub_done_channel(&mut self, topic_id: &str) {
|
||||
self.history.ensure_sub_done_channel(topic_id);
|
||||
}
|
||||
|
||||
/// 获取该 topic 的 sub_done 队列 sender(用于后台子代理发送结果)。
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn sub_done_sender(
|
||||
&mut self,
|
||||
topic_id: &str,
|
||||
) -> Option<mpsc::Sender<SubagentResult>> {
|
||||
self.history.sub_done_sender(topic_id)
|
||||
}
|
||||
|
||||
/// 取出该 topic 的 sub_done 队列 receiver(wait 工具进入 select! 前调用)。
|
||||
pub(crate) fn take_sub_done_receiver(
|
||||
&mut self,
|
||||
topic_id: &str,
|
||||
) -> Option<mpsc::Receiver<SubagentResult>> {
|
||||
self.history.take_sub_done_receiver(topic_id)
|
||||
}
|
||||
|
||||
/// 归还该 topic 的 sub_done 队列 receiver(wait 工具 select! 结束后调用)。
|
||||
pub(crate) fn restore_sub_done_receiver(
|
||||
&mut self,
|
||||
topic_id: &str,
|
||||
rx: mpsc::Receiver<SubagentResult>,
|
||||
) {
|
||||
self.history.restore_sub_done_receiver(topic_id, rx);
|
||||
}
|
||||
|
||||
/// 获取或创建该 topic 的 wait 唤醒信号。
|
||||
pub(crate) fn wait_wakeup(&mut self, topic_id: &str) -> Arc<Notify> {
|
||||
self.history.wait_wakeup(topic_id)
|
||||
}
|
||||
|
||||
/// 设置该 topic 的等待状态。
|
||||
pub(crate) fn set_waiting(&mut self, topic_id: &str, waiting: bool) {
|
||||
self.history.set_waiting(topic_id, waiting);
|
||||
}
|
||||
|
||||
/// 检查该 topic 是否处于等待状态。
|
||||
pub(crate) fn is_waiting(&self, topic_id: &str) -> bool {
|
||||
self.history.is_waiting(topic_id)
|
||||
}
|
||||
|
||||
/// 按 topic_id 从 DB 重新加载历史到内存
|
||||
pub(crate) fn reload_topic_history(
|
||||
&mut self,
|
||||
chat_id: &str,
|
||||
topic_id: &str,
|
||||
) -> Result<(), AgentError> {
|
||||
self.history.reload_topic_history(chat_id, topic_id)?;
|
||||
self.reconcile_running_placeholders(topic_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 对账 pending_subagents DB,将内存 history 中过时的 "running" 占位
|
||||
/// 替换为 DB 中的实际状态。
|
||||
///
|
||||
/// 场景:服务器崩溃重启后,history 中仍保留 "running" 占位,
|
||||
/// 但 DB 中该子代理状态可能已被启动扫描标记为 "interrupted"。
|
||||
/// 若不对账,LLM 会看到 "running" → 调用 wait_for_subagents →
|
||||
/// query_pending_task_ids 返回空(DB 已非 running)→ 返回 "no pending" →
|
||||
/// LLM 困惑:history 说 running 但 wait 说无 pending。
|
||||
///
|
||||
/// 支持两种 content 格式:
|
||||
/// - JSON: `{"status":"running","task_id":"xxx",...}`(当前 task 工具返回格式)
|
||||
/// - 纯文本: `running, task_id=xxx. ...`(旧格式,向后兼容)
|
||||
///
|
||||
/// 仅修改内存缓存,不持久化到 DB(每次从 DB 加载时重新对账,幂等)。
|
||||
fn reconcile_running_placeholders(&mut self, topic_id: &str) {
|
||||
let pending = match self.store.list_pending_subagents(topic_id, None) {
|
||||
Ok(records) => records,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
topic_id = %topic_id,
|
||||
"Failed to query pending_subagents for reconciliation"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let status_map: std::collections::HashMap<&str, &str> = pending
|
||||
.iter()
|
||||
.map(|r| (r.task_id.as_str(), r.status.as_str()))
|
||||
.collect();
|
||||
|
||||
let history = self.history.get_or_create_history(topic_id);
|
||||
let mut reconciled = 0;
|
||||
|
||||
for msg in history.iter_mut() {
|
||||
if msg.role != "tool" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 尝试提取 task_id(同时支持 JSON 和纯文本格式)
|
||||
let (task_id, is_json) = match extract_task_id_from_content(&msg.content) {
|
||||
Some(id) => id,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let actual_status = match status_map.get(task_id.as_str()) {
|
||||
Some(s) => *s,
|
||||
None => continue, // 记录不存在(已清理),保留原占位
|
||||
};
|
||||
|
||||
if actual_status == "running" {
|
||||
continue; // 仍在运行,保留占位
|
||||
}
|
||||
|
||||
// 替换为实际状态
|
||||
msg.content = format_reconciled_content(&task_id, actual_status, is_json);
|
||||
reconciled += 1;
|
||||
}
|
||||
|
||||
if reconciled > 0 {
|
||||
tracing::info!(
|
||||
topic_id = %topic_id,
|
||||
reconciled_count = reconciled,
|
||||
"Reconciled stale 'running' placeholders with DB status"
|
||||
);
|
||||
}
|
||||
self.history.reload_topic_history(chat_id, topic_id)
|
||||
}
|
||||
|
||||
pub(crate) fn store(&self) -> Arc<dyn ConversationRepository> {
|
||||
self.history.conversations()
|
||||
}
|
||||
|
||||
/// 获取底层 SessionStore(用于 pending_subagents 查询等)。
|
||||
/// 与 `store()` 不同:后者返回 ConversationRepository trait object,
|
||||
/// 此方法返回具体的 SessionStore 类型,暴露 pending_subagents 等 CRUD。
|
||||
pub(crate) fn session_store(&self) -> Arc<SessionStore> {
|
||||
self.store.clone()
|
||||
}
|
||||
|
||||
pub fn record_skill_offer(&self, chat_id: &str) -> Result<(), AgentError> {
|
||||
if self.skills.is_empty() {
|
||||
return Ok(());
|
||||
@ -755,8 +613,6 @@ impl Session {
|
||||
sender_id: Option<&str>,
|
||||
message_id: Option<&str>,
|
||||
explicit_topic_id: Option<&str>,
|
||||
trace_id: &str,
|
||||
wait_coordinator: Option<Arc<dyn WaitCoordinator>>,
|
||||
) -> Result<AgentLoop, AgentError> {
|
||||
self.create_agent_with_provider_config(
|
||||
chat_id,
|
||||
@ -765,8 +621,6 @@ impl Session {
|
||||
message_id,
|
||||
self.provider_config.clone(),
|
||||
explicit_topic_id,
|
||||
trace_id,
|
||||
wait_coordinator,
|
||||
)
|
||||
}
|
||||
|
||||
@ -778,8 +632,6 @@ impl Session {
|
||||
message_id: Option<&str>,
|
||||
provider_config: LLMProviderConfig,
|
||||
explicit_topic_id: Option<&str>,
|
||||
trace_id: &str,
|
||||
wait_coordinator: Option<Arc<dyn WaitCoordinator>>,
|
||||
) -> Result<AgentLoop, AgentError> {
|
||||
// 优先使用显式传入的 topic_id;回退到当前 chat 的活跃 topic
|
||||
let topic_id = explicit_topic_id
|
||||
@ -796,12 +648,6 @@ impl Session {
|
||||
None => self.pending_cancel_tokens.remove(session_chat_id),
|
||||
};
|
||||
|
||||
// 获取该 topic 的 sub_done 队列 sender(用于异步子代理完成回调)
|
||||
// 仅主 agent 注入;无 topic 时为 None(走同步路径)
|
||||
let sub_done_sender = topic_id
|
||||
.as_deref()
|
||||
.and_then(|tid| self.history.sub_done_sender(tid));
|
||||
|
||||
self.agent_factory.create(AgentBuildRequest {
|
||||
channel_name: &self.channel_name,
|
||||
session_chat_id,
|
||||
@ -811,98 +657,10 @@ impl Session {
|
||||
message_id,
|
||||
provider_config,
|
||||
cancel_token,
|
||||
trace_id: Some(trace_id.to_string()),
|
||||
sub_done_sender,
|
||||
wait_coordinator,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 tool result content 中提取 task_id,同时支持 JSON 和纯文本格式。
|
||||
///
|
||||
/// JSON 格式: `{"status":"running","task_id":"xxx",...}`
|
||||
/// 纯文本格式: `running, task_id=xxx. ...`
|
||||
///
|
||||
/// 返回 (task_id, is_json)。仅当 status=="running" 时才提取(只对账 running 占位)。
|
||||
fn extract_task_id_from_content(content: &str) -> Option<(String, bool)> {
|
||||
// 尝试 JSON 格式
|
||||
let json_start = content.find('{')?;
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&content[json_start..]) {
|
||||
let status = parsed.get("status").and_then(|v| v.as_str())?;
|
||||
if status != "running" {
|
||||
return None;
|
||||
}
|
||||
let task_id = parsed.get("task_id").and_then(|v| v.as_str())?;
|
||||
if task_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some((task_id.to_string(), true));
|
||||
}
|
||||
|
||||
// 回退到纯文本格式(向后兼容)
|
||||
let prefix = "running, task_id=";
|
||||
let rest = content.strip_prefix(prefix)?;
|
||||
let end = rest.find('.').unwrap_or(rest.len());
|
||||
let task_id = &rest[..end];
|
||||
if task_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((task_id.to_string(), false))
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据子代理的实际状态生成替换内容。
|
||||
///
|
||||
/// JSON 格式:更新 JSON 中的 status 字段(保持前端 parseTaskResult 兼容)。
|
||||
/// 纯文本格式:替换为描述性文本(向后兼容)。
|
||||
fn format_reconciled_content(task_id: &str, status: &str, is_json: bool) -> String {
|
||||
if is_json {
|
||||
// 更新 JSON 中的 status 字段,保持前端 parseTaskResult 能正确解析
|
||||
let placeholder = match status {
|
||||
"interrupted" => format!(
|
||||
"Subagent {} was interrupted (server restart). Result is unavailable.",
|
||||
task_id
|
||||
),
|
||||
"completed" => format!(
|
||||
"Subagent {} has completed. Call wait_for_subagents to retrieve the result.",
|
||||
task_id
|
||||
),
|
||||
"failed" => format!(
|
||||
"Subagent {} has failed. Call wait_for_subagents to retrieve error details.",
|
||||
task_id
|
||||
),
|
||||
"timeout" => format!("Subagent {} timed out.", task_id),
|
||||
"cancelled" => format!("Subagent {} was cancelled.", task_id),
|
||||
other => format!("Subagent {} status: {}.", task_id, other),
|
||||
};
|
||||
serde_json::json!({
|
||||
"status": status,
|
||||
"summary": placeholder,
|
||||
"output": placeholder,
|
||||
"task_id": task_id,
|
||||
})
|
||||
.to_string()
|
||||
} else {
|
||||
match status {
|
||||
"interrupted" => format!(
|
||||
"Subagent {} was interrupted (server restart). Result is unavailable.",
|
||||
task_id
|
||||
),
|
||||
"completed" => format!(
|
||||
"Subagent {} has completed. Call wait_for_subagents to retrieve the result.",
|
||||
task_id
|
||||
),
|
||||
"failed" => format!(
|
||||
"Subagent {} has failed. Call wait_for_subagents to retrieve error details.",
|
||||
task_id
|
||||
),
|
||||
"timeout" => format!("Subagent {} timed out.", task_id),
|
||||
"cancelled" => format!("Subagent {} was cancelled.", task_id),
|
||||
other => format!("Subagent {} status: {}.", task_id, other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SessionManager 管理所有 Session,按 channel_name 路由
|
||||
#[derive(Clone)]
|
||||
pub struct SessionManager {
|
||||
@ -989,12 +747,11 @@ impl SessionManager {
|
||||
maintenance_config,
|
||||
session_ttl_hours,
|
||||
mcp_config,
|
||||
300,
|
||||
None,
|
||||
model_resolver,
|
||||
crate::config::CompactionConfig::default(),
|
||||
)
|
||||
.map(|(session_manager, _, _, _, _, _)| session_manager)
|
||||
.map(|(session_manager, _, _, _, _)| session_manager)
|
||||
}
|
||||
|
||||
pub fn tools(&self) -> Arc<ToolRegistry> {
|
||||
@ -1150,7 +907,6 @@ impl SessionManager {
|
||||
media: Vec<crate::bus::MediaItem>,
|
||||
live_emitter: Option<Arc<dyn EmittedMessageHandler>>,
|
||||
topic_id: Option<&str>,
|
||||
trace_id: &str,
|
||||
) -> Result<Vec<OutboundMessage>, AgentError> {
|
||||
self.messages
|
||||
.handle_message(
|
||||
@ -1161,7 +917,6 @@ impl SessionManager {
|
||||
media,
|
||||
live_emitter,
|
||||
topic_id,
|
||||
trace_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@ -1549,7 +1304,6 @@ mod tests {
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
"test-trace",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@ -2340,14 +2094,8 @@ mod tests {
|
||||
async fn test_bus_tool_call_emitter_emits_completed_tool_results() {
|
||||
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,
|
||||
"test-trace-id",
|
||||
);
|
||||
let emitter =
|
||||
BusToolCallEmitter::new(bus.clone(), "test-channel", "chat-1", HashMap::new(), store);
|
||||
|
||||
emitter
|
||||
.handle(ChatMessage::tool("call-1", "calculator", "2"))
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::{Notify, mpsc};
|
||||
|
||||
use crate::agent::AgentError;
|
||||
use crate::bus::ChatMessage;
|
||||
use crate::storage::{
|
||||
ConversationRepository, SessionRecord, SkillEventRepository, persistent_session_id,
|
||||
};
|
||||
use crate::tools::task::SubagentResult;
|
||||
|
||||
/// 内存中缓存的 topic 历史上限。
|
||||
/// 超过此值时,驱逐非活跃 topic(不在 chat_topic_ids 当前引用中的 topic)。
|
||||
@ -38,23 +35,6 @@ pub(crate) struct SessionHistory {
|
||||
/// 防止并发 loop 操作同一历史的不同快照产生交错序列。
|
||||
/// 不同 topic 之间互不阻塞,支持多话题并发执行。
|
||||
topic_serial_locks: HashMap<String, Arc<tokio::sync::Mutex<()>>>,
|
||||
/// per-topic 子代理完成队列的 sender 端。
|
||||
/// 后台子代理完成后 clone 此 sender 发送 SubagentResult,
|
||||
/// 由 wait_for_subagents 工具的 receiver 端消费。
|
||||
/// 生命周期与 topic_serial_locks 完全一致。
|
||||
sub_done_senders: HashMap<String, mpsc::Sender<SubagentResult>>,
|
||||
/// per-topic 子代理完成队列的 receiver 端。
|
||||
/// 使用 Option 包装以便 wait_for_subagents 工具在 select! 期间
|
||||
/// 将其取出(不持有 Session 锁),结束后归还。
|
||||
sub_done_receivers: HashMap<String, Option<mpsc::Receiver<SubagentResult>>>,
|
||||
/// per-topic wait 唤醒信号。
|
||||
/// wait 释放锁进入 select! 后,process_one 在注入用户消息到 history 后
|
||||
/// 调用 notify_one() 唤醒 wait。
|
||||
wait_wakeups: HashMap<String, Arc<Notify>>,
|
||||
/// per-topic 等待状态标志。
|
||||
/// true 表示该 topic 的 agent 正在 wait_for_subagents 中等待(已释放 serial_lock)。
|
||||
/// process_one 持锁后检查此标志:true 则注入用户消息 + wakeup,false 则正常处理。
|
||||
waiting_flags: HashMap<String, bool>,
|
||||
conversations: Arc<dyn ConversationRepository>,
|
||||
skill_events: Arc<dyn SkillEventRepository>,
|
||||
}
|
||||
@ -87,20 +67,6 @@ impl SessionHistory {
|
||||
if active.contains(tid.as_str()) || self.compression_in_flight.contains(*tid) {
|
||||
return false;
|
||||
}
|
||||
// 不变量 2:lock 实例单射性 — 驱逐会移除 topic_serial_locks/wakeup/sender 等 entry,
|
||||
// 下次访问会创建新实例,破坏原 Arc 持有者(wait_coordinator)与新建者(process_one)
|
||||
// 的串行化。因此必须充分覆盖所有"活跃"语义:
|
||||
// a) waiting_flag=true → wait_coordinator 正在 select! 等待
|
||||
// b) sub_done_receivers[tid]=None → receiver 被 take 走(wait_coordinator 持有)
|
||||
// c) topic_serial_lock 被持有 → 有 agent 任务正在处理
|
||||
// 任一为真都不能驱逐。
|
||||
if self.waiting_flags.get(*tid).copied().unwrap_or(false) {
|
||||
return false;
|
||||
}
|
||||
if matches!(self.sub_done_receivers.get(*tid), Some(None)) {
|
||||
// receiver 被 take 走 = wait_coordinator 正在 select! 中
|
||||
return false;
|
||||
}
|
||||
// 检查是否有活跃 agent 任务(serial lock 被持有)
|
||||
// try_lock 成功 = 锁空闲 = 无活跃任务 = 可驱逐
|
||||
// try_lock 失败 = 锁被持有 = 有活跃任务 = 不驱逐
|
||||
@ -115,11 +81,6 @@ impl SessionHistory {
|
||||
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);
|
||||
self.topic_serial_locks.remove(&tid);
|
||||
self.sub_done_senders.remove(&tid);
|
||||
self.sub_done_receivers.remove(&tid);
|
||||
self.wait_wakeups.remove(&tid);
|
||||
self.waiting_flags.remove(&tid);
|
||||
tracing::info!(
|
||||
topic_id = %tid,
|
||||
evicted_messages = msg_count,
|
||||
@ -140,10 +101,6 @@ impl SessionHistory {
|
||||
chat_topic_ids: HashMap::new(),
|
||||
compression_in_flight: HashSet::new(),
|
||||
topic_serial_locks: HashMap::new(),
|
||||
sub_done_senders: HashMap::new(),
|
||||
sub_done_receivers: HashMap::new(),
|
||||
wait_wakeups: HashMap::new(),
|
||||
waiting_flags: HashMap::new(),
|
||||
conversations,
|
||||
skill_events,
|
||||
}
|
||||
@ -152,9 +109,6 @@ impl SessionHistory {
|
||||
/// 获取或创建该 topic 的串行化锁。
|
||||
/// 同一 topic 的所有消息处理共享同一个锁,保证串行执行;
|
||||
/// 不同 topic 之间互不阻塞,支持多话题并发执行。
|
||||
///
|
||||
/// 同时同步创建该 topic 的 sub_done 队列、wait 唤醒信号和等待标志,
|
||||
/// 生命周期与 serial_lock 完全一致。
|
||||
pub(crate) fn topic_serial_lock(&mut self, topic_id: &str) -> Arc<tokio::sync::Mutex<()>> {
|
||||
self.topic_serial_locks
|
||||
.entry(topic_id.to_string())
|
||||
@ -162,68 +116,6 @@ impl SessionHistory {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 确保该 topic 的 sub_done 队列已创建。
|
||||
/// 应在 topic 初始化时(与 topic_serial_lock 同步)调用。
|
||||
/// 队列容量为 32,足够缓存多个子代理同时完成的结果。
|
||||
pub(crate) fn ensure_sub_done_channel(&mut self, topic_id: &str) {
|
||||
if !self.sub_done_senders.contains_key(topic_id) {
|
||||
let (tx, rx) = mpsc::channel::<SubagentResult>(32);
|
||||
self.sub_done_senders.insert(topic_id.to_string(), tx);
|
||||
self.sub_done_receivers
|
||||
.insert(topic_id.to_string(), Some(rx));
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取该 topic 的 sub_done 队列 sender(用于后台子代理发送结果)。
|
||||
/// 调用前应已通过 `ensure_sub_done_channel` 创建队列。
|
||||
pub(crate) fn sub_done_sender(&mut self, topic_id: &str) -> Option<mpsc::Sender<SubagentResult>> {
|
||||
self.ensure_sub_done_channel(topic_id);
|
||||
self.sub_done_senders.get(topic_id).cloned()
|
||||
}
|
||||
|
||||
/// 取出该 topic 的 sub_done 队列 receiver。
|
||||
/// 由 wait_for_subagents 工具在进入 select! 前调用(需释放 Session 锁)。
|
||||
/// 调用后 receiver 不在 map 中,需通过 `restore_sub_done_receiver` 归还。
|
||||
pub(crate) fn take_sub_done_receiver(
|
||||
&mut self,
|
||||
topic_id: &str,
|
||||
) -> Option<mpsc::Receiver<SubagentResult>> {
|
||||
self.sub_done_receivers
|
||||
.get_mut(topic_id)
|
||||
.and_then(|opt| opt.take())
|
||||
}
|
||||
|
||||
/// 归还该 topic 的 sub_done 队列 receiver。
|
||||
/// 由 wait_for_subagents 工具在 select! 结束后调用。
|
||||
pub(crate) fn restore_sub_done_receiver(
|
||||
&mut self,
|
||||
topic_id: &str,
|
||||
rx: mpsc::Receiver<SubagentResult>,
|
||||
) {
|
||||
self.sub_done_receivers
|
||||
.insert(topic_id.to_string(), Some(rx));
|
||||
}
|
||||
|
||||
/// 获取或创建该 topic 的 wait 唤醒信号。
|
||||
pub(crate) fn wait_wakeup(&mut self, topic_id: &str) -> Arc<Notify> {
|
||||
self.wait_wakeups
|
||||
.entry(topic_id.to_string())
|
||||
.or_insert_with(|| Arc::new(Notify::new()))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 设置该 topic 的等待状态。
|
||||
/// true = agent 正在 wait_for_subagents 中等待(已释放 serial_lock)。
|
||||
pub(crate) fn set_waiting(&mut self, topic_id: &str, waiting: bool) {
|
||||
self.waiting_flags.insert(topic_id.to_string(), waiting);
|
||||
}
|
||||
|
||||
/// 检查该 topic 是否处于等待状态。
|
||||
/// process_one 持锁后调用:true 则走注入路径,false 则正常处理。
|
||||
pub(crate) fn is_waiting(&self, topic_id: &str) -> bool {
|
||||
self.waiting_flags.get(topic_id).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn persistent_session_id(&self, chat_id: &str) -> String {
|
||||
persistent_session_id(&self.channel_name, chat_id)
|
||||
}
|
||||
@ -320,11 +212,6 @@ impl SessionHistory {
|
||||
// (仅在无活跃任务时安全移除;有活跃任务时 lock 被 Arc clone 持有,
|
||||
// 移除 HashMap entry 不影响正在使用 lock 的任务)
|
||||
self.topic_serial_locks.remove(topic_id);
|
||||
// 同步清理 sub_done 队列、wait 唤醒信号和等待标志
|
||||
self.sub_done_senders.remove(topic_id);
|
||||
self.sub_done_receivers.remove(topic_id);
|
||||
self.wait_wakeups.remove(topic_id);
|
||||
self.waiting_flags.remove(topic_id);
|
||||
}
|
||||
|
||||
/// 清空指定 chat/topic 的内存历史和 DB 消息。
|
||||
|
||||
@ -51,8 +51,7 @@ impl SessionMessageSender for BusSessionMessageSender {
|
||||
text,
|
||||
None,
|
||||
metadata.clone(),
|
||||
)
|
||||
.with_trace_id(context.trace_id.as_deref().unwrap_or(""));
|
||||
);
|
||||
if attachment_count > 0 {
|
||||
outbound.media = request.attachments.clone();
|
||||
}
|
||||
@ -89,8 +88,7 @@ impl SessionMessageSender for BusSessionMessageSender {
|
||||
String::new(),
|
||||
None,
|
||||
metadata.clone(),
|
||||
)
|
||||
.with_trace_id(context.trace_id.as_deref().unwrap_or(""));
|
||||
);
|
||||
outbound.media = vec![attachment];
|
||||
match self.bus.publish_outbound(outbound).await {
|
||||
Ok(()) => {
|
||||
|
||||
@ -29,7 +29,6 @@ impl SessionMessageService {
|
||||
media: Vec<MediaItem>,
|
||||
live_emitter: Option<Arc<dyn EmittedMessageHandler>>,
|
||||
topic_id: Option<&str>,
|
||||
trace_id: &str,
|
||||
) -> Result<Vec<OutboundMessage>, AgentError> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
@ -57,7 +56,6 @@ impl SessionMessageService {
|
||||
media,
|
||||
live_emitter,
|
||||
topic_id: topic_id.map(|s| s.to_string()),
|
||||
trace_id,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ use crate::tools::{
|
||||
BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool, HttpRequestTool,
|
||||
MemoryManageTool, MemorySearchTool, SchedulerManageTool, SessionMessageSender, SessionSendTool,
|
||||
ShellSessionManager, SkillActivateTool, SkillManageTool, SubAgentRuntime, TaskTool, TimeTool,
|
||||
TodoReadTool, TodoWriteTool, ToolRegistry, WaitForSubagentsTool, WebFetchTool,
|
||||
TodoReadTool, TodoWriteTool, ToolRegistry, WebFetchTool,
|
||||
};
|
||||
|
||||
pub(crate) struct ToolRegistryFactory {
|
||||
@ -160,11 +160,6 @@ impl ToolRegistryFactory {
|
||||
if self.is_enabled("task") && self.task_config.enabled {
|
||||
if let Some(runtime) = &self.subagent_runtime {
|
||||
registry.register(TaskTool::new(runtime.clone(), None));
|
||||
// 注册 wait_for_subagents 工具(仅主 agent,用于等待异步子代理完成)
|
||||
// 默认超时从配置读取,LLM 可通过 timeout_secs 参数覆盖
|
||||
registry.register(WaitForSubagentsTool::new(
|
||||
self.task_config.wait_default_timeout_secs,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,291 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::{Mutex, OwnedMutexGuard, watch};
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::gateway::session::Session;
|
||||
use crate::storage::SessionStore;
|
||||
use crate::tools::task::SubagentResult;
|
||||
use crate::tools::{WaitCoordinator, WaitEvent};
|
||||
|
||||
/// 基于 Session 的 wait 协调器实现。
|
||||
///
|
||||
/// 持有 serial_lock 的 guard slot(可通过 take/drop 释放锁,通过 put 回填新 guard),
|
||||
/// 以及 Session 引用(用于管理 sub_done_receiver、wait_wakeup、waiting_flag)。
|
||||
///
|
||||
/// wait() 流程:
|
||||
/// 1. 设置 waiting=true(让 process_one 走注入路径)
|
||||
/// 2. 取出 sub_done_receiver(从 Session 中 take,select! 期间不持有 Session 锁)
|
||||
/// 3. 获取 wait_wakeup(Arc<Notify>,clone 后不持有 Session 锁)
|
||||
/// 4. 释放 serial_lock(从 guard_slot take 并 drop guard)
|
||||
/// 5. select! { sub_done_q.recv(), wakeup.notified(), timeout }
|
||||
/// 6. 重新获取 serial_lock(serial_lock.lock_owned().await)
|
||||
/// 7. 回填 guard 到 guard_slot
|
||||
/// 8. 设置 waiting=false(先获取锁后清除,避免 TOCTOU)
|
||||
/// 9. 归还 sub_done_receiver
|
||||
pub struct SessionWaitCoordinator {
|
||||
/// Session 引用(Arc<Mutex<Session>>),用于访问 SessionHistory 的队列和状态
|
||||
session: Arc<Mutex<Session>>,
|
||||
/// serial_lock guard 的存储槽。
|
||||
/// 执行路径(execution.rs)获取锁后将 guard 存入此槽;
|
||||
/// wait() 取出并 drop 以释放锁,重获取后回填新 guard。
|
||||
guard_slot: Arc<Mutex<Option<OwnedMutexGuard<()>>>>,
|
||||
/// serial_lock 本体(Arc<tokio::sync::Mutex<()>>),用于重获取锁
|
||||
serial_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
/// SessionStore 引用,用于查询 pending_subagents
|
||||
store: Arc<SessionStore>,
|
||||
/// 当前 topic_id
|
||||
topic_id: String,
|
||||
}
|
||||
|
||||
impl SessionWaitCoordinator {
|
||||
pub fn new(
|
||||
session: Arc<Mutex<Session>>,
|
||||
guard_slot: Arc<Mutex<Option<OwnedMutexGuard<()>>>>,
|
||||
serial_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
store: Arc<SessionStore>,
|
||||
topic_id: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
session,
|
||||
guard_slot,
|
||||
serial_lock,
|
||||
store,
|
||||
topic_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WaitCoordinator for SessionWaitCoordinator {
|
||||
fn query_pending_task_ids(&self) -> Vec<String> {
|
||||
self.store
|
||||
.list_pending_subagents(&self.topic_id, Some("running"))
|
||||
.map(|records| records.into_iter().map(|r| r.task_id).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn try_drain_queued_results(&self) -> Vec<SubagentResult> {
|
||||
// 取出 receiver → try_recv 排空 → 归还 receiver
|
||||
// 安全性:此方法在执行路径中被调用(serial_lock 已持有),
|
||||
// 无其他代码并发访问 receiver。
|
||||
let rx = {
|
||||
let mut session = self.session.lock().await;
|
||||
session.take_sub_done_receiver(&self.topic_id)
|
||||
};
|
||||
|
||||
let mut results = Vec::new();
|
||||
if let Some(mut rx) = rx {
|
||||
while let Ok(result) = rx.try_recv() {
|
||||
results.push(result);
|
||||
}
|
||||
// 归还 receiver(即使已排空,仍需放回供后续 wait() 使用)
|
||||
let mut session = self.session.lock().await;
|
||||
session.restore_sub_done_receiver(&self.topic_id, rx);
|
||||
}
|
||||
|
||||
if !results.is_empty() {
|
||||
tracing::debug!(
|
||||
topic_id = %self.topic_id,
|
||||
drained_count = results.len(),
|
||||
"Drained buffered subagent results from sub_done_q"
|
||||
);
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
async fn wait(
|
||||
&self,
|
||||
timeout: Duration,
|
||||
cancel_rx: Option<watch::Receiver<()>>,
|
||||
) -> WaitEvent {
|
||||
// 1. 设置 waiting=true
|
||||
{
|
||||
let mut session = self.session.lock().await;
|
||||
session.set_waiting(&self.topic_id, true);
|
||||
}
|
||||
|
||||
// 2. 取出 sub_done_receiver(select! 期间不持有 Session 锁)
|
||||
let receiver = {
|
||||
let mut session = self.session.lock().await;
|
||||
session.take_sub_done_receiver(&self.topic_id)
|
||||
};
|
||||
|
||||
// 3. 获取 wait_wakeup(Arc<Notify>,clone 后不持有 Session 锁)
|
||||
let wakeup = {
|
||||
let mut session = self.session.lock().await;
|
||||
session.wait_wakeup(&self.topic_id)
|
||||
};
|
||||
|
||||
// 3.5. 记录等待前的用户消息数量(用于 wakeup 后提取新注入的消息)
|
||||
// 直接从 SQLite 读取,不持有任何锁
|
||||
let user_msg_count_before = self
|
||||
.store
|
||||
.load_messages_for_topic(&self.topic_id, None)
|
||||
.map(|msgs| msgs.iter().filter(|m| m.role == "user").count())
|
||||
.unwrap_or(0);
|
||||
|
||||
// 4. 释放 serial_lock(取出 guard 并 drop)
|
||||
{
|
||||
let mut slot = self.guard_slot.lock().await;
|
||||
let _ = slot.take(); // drop guard → 释放 serial_lock
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
topic_id = %self.topic_id,
|
||||
timeout_secs = timeout.as_secs(),
|
||||
user_msg_count_before,
|
||||
has_cancel_rx = cancel_rx.is_some(),
|
||||
"SessionWaitCoordinator: lock released, entering select!"
|
||||
);
|
||||
|
||||
// 5. select! 等待(不持有任何锁)
|
||||
//
|
||||
// cancel 分支放在最后(biased 排序中最后被 poll),
|
||||
// 确保子代理结果和用户消息优先于取消信号被处理。
|
||||
// 场景:/stop 后用户立即发消息 → process_one 注入消息 + wakeup,
|
||||
// select! 优先消费 wakeup(UserMessage),而非 cancel(Cancelled),
|
||||
// 使已注入的用户消息能被 Agent 处理而非丢失。
|
||||
//
|
||||
// 但 cancel_rx.changed() 不会无限阻塞——若无子代理结果、无用户消息,
|
||||
// cancel 仍是唯一就绪分支,等待被优雅终止。
|
||||
let event = if let Some(mut rx) = receiver {
|
||||
let mut cancel_rx = cancel_rx;
|
||||
tokio::select! {
|
||||
biased;
|
||||
result = rx.recv() => {
|
||||
match result {
|
||||
Some(subagent_result) => {
|
||||
let event = WaitEvent::SubagentResult(subagent_result);
|
||||
let mut session = self.session.lock().await;
|
||||
session.restore_sub_done_receiver(&self.topic_id, rx);
|
||||
event
|
||||
}
|
||||
None => {
|
||||
// sender 全部 drop(所有 sub_done_sender 被释放)
|
||||
WaitEvent::Timeout
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = wakeup.notified() => {
|
||||
// 用户消息到达(process_one 已注入 history 并 wakeup)
|
||||
let mut session = self.session.lock().await;
|
||||
session.restore_sub_done_receiver(&self.topic_id, rx);
|
||||
// 提取等待期间新注入的用户消息内容
|
||||
let new_messages = self.fetch_new_user_messages(user_msg_count_before);
|
||||
tracing::info!(
|
||||
topic_id = %self.topic_id,
|
||||
new_msg_count = new_messages.len(),
|
||||
"SessionWaitCoordinator: woke up by user message"
|
||||
);
|
||||
WaitEvent::UserMessage(new_messages)
|
||||
}
|
||||
_ = async {
|
||||
if let Some(ref mut crx) = cancel_rx {
|
||||
let _ = crx.changed().await;
|
||||
} else {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
} => {
|
||||
// 取消信号到达(/stop)→ 归还 receiver,返回 Cancelled
|
||||
tracing::info!(
|
||||
topic_id = %self.topic_id,
|
||||
"SessionWaitCoordinator: cancelled by /stop during wait"
|
||||
);
|
||||
let mut session = self.session.lock().await;
|
||||
session.restore_sub_done_receiver(&self.topic_id, rx);
|
||||
WaitEvent::Cancelled
|
||||
}
|
||||
_ = sleep(timeout) => {
|
||||
let mut session = self.session.lock().await;
|
||||
session.restore_sub_done_receiver(&self.topic_id, rx);
|
||||
WaitEvent::Timeout
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 无 receiver(topic 无 sub_done 队列),直接等待 timeout 或 wakeup
|
||||
let mut cancel_rx = cancel_rx;
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = wakeup.notified() => {
|
||||
let new_messages = self.fetch_new_user_messages(user_msg_count_before);
|
||||
tracing::info!(
|
||||
topic_id = %self.topic_id,
|
||||
new_msg_count = new_messages.len(),
|
||||
"SessionWaitCoordinator: woke up by user message (no receiver)"
|
||||
);
|
||||
WaitEvent::UserMessage(new_messages)
|
||||
}
|
||||
_ = async {
|
||||
if let Some(ref mut crx) = cancel_rx {
|
||||
let _ = crx.changed().await;
|
||||
} else {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
} => {
|
||||
tracing::info!(
|
||||
topic_id = %self.topic_id,
|
||||
"SessionWaitCoordinator: cancelled by /stop during wait (no receiver)"
|
||||
);
|
||||
WaitEvent::Cancelled
|
||||
}
|
||||
_ = sleep(timeout) => WaitEvent::Timeout,
|
||||
}
|
||||
};
|
||||
|
||||
// 6. 重新获取 serial_lock
|
||||
// lock_owned 消费 Arc<Self>,需 clone 保留 self.serial_lock 供后续可能的重入
|
||||
//
|
||||
// 取消场景下此处可能阻塞——如果 process_one 正持有锁注入用户消息,
|
||||
// 需等其释放后才能重获取。这是正确行为:确保 is_waiting 清除与
|
||||
// process_one 的注入互斥,避免 TOCTOU。
|
||||
let new_guard = self.serial_lock.clone().lock_owned().await;
|
||||
|
||||
// 7. 回填 guard 到 guard_slot
|
||||
{
|
||||
let mut slot = self.guard_slot.lock().await;
|
||||
*slot = Some(new_guard);
|
||||
}
|
||||
|
||||
// 8. 设置 waiting=false(先获取锁后清除,避免 TOCTOU)
|
||||
// 注意:serial_lock 已在步骤 6 获取,此时 process_one 无法获取锁,
|
||||
// 所以清除 waiting 是安全的。
|
||||
{
|
||||
let mut session = self.session.lock().await;
|
||||
session.set_waiting(&self.topic_id, false);
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
topic_id = %self.topic_id,
|
||||
"SessionWaitCoordinator: lock reacquired, waiting cleared"
|
||||
);
|
||||
|
||||
event
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionWaitCoordinator {
|
||||
/// 提取等待期间新注入的用户消息内容。
|
||||
/// 通过对比等待前的用户消息数量,从 SQLite 中取出新增的用户消息。
|
||||
fn fetch_new_user_messages(&self, count_before: usize) -> Vec<String> {
|
||||
match self.store.load_messages_for_topic(&self.topic_id, None) {
|
||||
Ok(msgs) => msgs
|
||||
.iter()
|
||||
.filter(|m| m.role == "user")
|
||||
.skip(count_before)
|
||||
.map(|m| m.content.clone())
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
topic_id = %self.topic_id,
|
||||
"Failed to load messages for fetching new user messages"
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -397,7 +397,6 @@ async fn handle_inbound(
|
||||
media,
|
||||
metadata: HashMap::new(),
|
||||
forwarded_metadata: HashMap::new(),
|
||||
trace_id: crate::observability::tracing_ctx::new_trace_id(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| AgentError::Other(error.to_string()))?;
|
||||
@ -535,7 +534,6 @@ async fn handle_inbound(
|
||||
router.register(Box::new(StopExecutionCommandHandler::new(
|
||||
state.cancel_manager.clone(),
|
||||
state.session_manager.clone(),
|
||||
state.subagent_executor.clone(),
|
||||
)));
|
||||
|
||||
// 构建命令上下文
|
||||
|
||||
@ -3,11 +3,9 @@ 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, Layer,
|
||||
fmt, fmt::time::FormatTime, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter,
|
||||
};
|
||||
|
||||
use crate::config::LogFormat;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ConfiguredTimestamp {
|
||||
timezone: Tz,
|
||||
@ -42,10 +40,7 @@ pub fn get_default_config_path() -> PathBuf {
|
||||
|
||||
/// Initialize logging with file appender
|
||||
/// Logs are written to ~/.picobot/logs/ with daily rotation
|
||||
///
|
||||
/// `log_format` 控制文件日志格式:Text(默认)或 Json(便于日志聚合)。
|
||||
/// 控制台始终使用文本格式(便于人读)。
|
||||
pub fn init_logging(timezone: Tz, log_format: LogFormat) {
|
||||
pub fn init_logging(timezone: Tz) {
|
||||
use std::sync::Once;
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
@ -77,28 +72,14 @@ pub fn init_logging(timezone: Tz, log_format: LogFormat) {
|
||||
// Build subscriber with both console and file output
|
||||
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
|
||||
// 文件层:根据 log_format 选择 text 或 json
|
||||
let file_layer = match log_format {
|
||||
LogFormat::Json => fmt::layer()
|
||||
.with_writer(file_appender)
|
||||
.with_timer(ConfiguredTimestamp { timezone })
|
||||
.with_ansi(false)
|
||||
.with_target(true)
|
||||
.with_level(true)
|
||||
.with_thread_ids(true)
|
||||
.json()
|
||||
.boxed(),
|
||||
LogFormat::Text => fmt::layer()
|
||||
.with_writer(file_appender)
|
||||
.with_timer(ConfiguredTimestamp { timezone })
|
||||
.with_ansi(false)
|
||||
.with_target(true)
|
||||
.with_level(true)
|
||||
.with_thread_ids(true)
|
||||
.boxed(),
|
||||
};
|
||||
let file_layer = fmt::layer()
|
||||
.with_writer(file_appender)
|
||||
.with_timer(ConfiguredTimestamp { timezone })
|
||||
.with_ansi(false)
|
||||
.with_target(true)
|
||||
.with_level(true)
|
||||
.with_thread_ids(true);
|
||||
|
||||
// 控制台层:始终文本格式
|
||||
let console_layer = fmt::layer()
|
||||
.with_timer(ConfiguredTimestamp { timezone })
|
||||
.with_target(true)
|
||||
@ -110,11 +91,7 @@ pub fn init_logging(timezone: Tz, log_format: LogFormat) {
|
||||
.with(file_layer)
|
||||
.init();
|
||||
|
||||
tracing::info!(
|
||||
log_format = ?log_format,
|
||||
log_dir = %log_dir.display(),
|
||||
"Logging initialized"
|
||||
);
|
||||
tracing::info!("Logging initialized. Log directory: {}", log_dir.display());
|
||||
}
|
||||
|
||||
/// Initialize logging without file output (console only)
|
||||
|
||||
@ -788,14 +788,13 @@ impl McpInitializer {
|
||||
pub async fn register_tools(
|
||||
&mut self,
|
||||
registry: &mut crate::tools::ToolRegistry,
|
||||
timeout_secs: u64,
|
||||
) -> anyhow::Result<()> {
|
||||
if let Some(manager) = self.manager.clone() {
|
||||
// Wait for connections to complete first
|
||||
self.wait_for_connections().await?;
|
||||
|
||||
tracing::info!("Registering MCP tools after connections completed");
|
||||
crate::mcp::register_mcp_tools(manager, registry, timeout_secs).await?;
|
||||
crate::mcp::register_mcp_tools(manager, registry).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -37,18 +37,11 @@ pub struct McpToolWrapper {
|
||||
full_name: String,
|
||||
/// Tool information from MCP server
|
||||
tool_info: Tool,
|
||||
/// Tool call timeout in seconds (0 = no timeout)
|
||||
timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl McpToolWrapper {
|
||||
/// Create a new tool wrapper
|
||||
pub fn new(
|
||||
manager: Arc<McpClientManager>,
|
||||
server_key: String,
|
||||
tool_info: Tool,
|
||||
timeout_secs: u64,
|
||||
) -> 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);
|
||||
@ -68,7 +61,6 @@ impl McpToolWrapper {
|
||||
tool_name,
|
||||
full_name,
|
||||
tool_info,
|
||||
timeout_secs,
|
||||
}
|
||||
}
|
||||
|
||||
@ -106,33 +98,10 @@ impl PicoBotTool for McpToolWrapper {
|
||||
"Calling MCP tool"
|
||||
);
|
||||
|
||||
let call = self
|
||||
let result = self
|
||||
.manager
|
||||
.call_tool(&self.server_key, &self.tool_name, args);
|
||||
|
||||
let result = if self.timeout_secs > 0 {
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(self.timeout_secs),
|
||||
call,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
tracing::warn!(
|
||||
server_key = %self.server_key,
|
||||
tool = %self.tool_name,
|
||||
timeout_secs = self.timeout_secs,
|
||||
"MCP tool call timed out"
|
||||
);
|
||||
anyhow::anyhow!(
|
||||
"MCP tool '{}' on server '{}' timed out after {}s",
|
||||
self.tool_name,
|
||||
self.server_key,
|
||||
self.timeout_secs
|
||||
)
|
||||
})??
|
||||
} else {
|
||||
call.await?
|
||||
};
|
||||
.call_tool(&self.server_key, &self.tool_name, args)
|
||||
.await?;
|
||||
|
||||
// Convert MCP CallToolResult to PicoBot ToolResult
|
||||
let output = extract_text_content(&result);
|
||||
@ -178,17 +147,11 @@ fn extract_text_content(result: &rmcp::model::CallToolResult) -> String {
|
||||
pub async fn register_mcp_tools(
|
||||
manager: Arc<McpClientManager>,
|
||||
registry: &mut crate::tools::registry::ToolRegistry,
|
||||
timeout_secs: u64,
|
||||
) -> anyhow::Result<()> {
|
||||
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,
|
||||
timeout_secs,
|
||||
);
|
||||
let wrapper = McpToolWrapper::new(manager.clone(), server_key.clone(), tool_info);
|
||||
|
||||
tracing::info!(
|
||||
name = %wrapper.name(),
|
||||
@ -235,7 +198,7 @@ mod tests {
|
||||
.clone();
|
||||
let tool_info = Tool::new("echo", "Echo tool", schema);
|
||||
|
||||
let wrapper = McpToolWrapper::new(manager, "filesystem".to_string(), tool_info, 300);
|
||||
let wrapper = McpToolWrapper::new(manager, "filesystem".to_string(), tool_info);
|
||||
assert_eq!(wrapper.name(), "mcp_filesystem_echo");
|
||||
assert_eq!(wrapper.original_name(), "echo");
|
||||
assert_eq!(wrapper.server_key(), "filesystem");
|
||||
@ -253,7 +216,7 @@ mod tests {
|
||||
.clone();
|
||||
let tool_info = Tool::new("tools.list:read", "Namespaced tool", schema);
|
||||
|
||||
let wrapper = McpToolWrapper::new(manager, "github.api".to_string(), tool_info, 300);
|
||||
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
|
||||
@ -281,49 +244,4 @@ mod tests {
|
||||
// Empty stays empty
|
||||
assert_eq!(sanitize_tool_name(""), "");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_returns_error_when_server_not_connected() {
|
||||
// McpClientManager with no connected servers — call should fail immediately
|
||||
// with "not connected" error, not hang or timeout.
|
||||
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("echo", "Echo tool", schema);
|
||||
let wrapper = McpToolWrapper::new(manager, "ghost".to_string(), tool_info, 300);
|
||||
|
||||
let result = wrapper.execute(serde_json::json!({})).await;
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
msg.contains("not connected"),
|
||||
"expected 'not connected' error, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_timeout_zero_does_not_wrap() {
|
||||
// With timeout_secs = 0, the wrapper should pass through directly
|
||||
// without tokio::time::timeout. The call still fails because no server
|
||||
// is connected, but the error path is the non-timeout branch.
|
||||
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("echo", "Echo tool", schema);
|
||||
let wrapper = McpToolWrapper::new(manager, "ghost".to_string(), tool_info, 0);
|
||||
|
||||
let result = wrapper.execute(serde_json::json!({})).await;
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
msg.contains("not connected"),
|
||||
"expected 'not connected' error, got: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,157 +0,0 @@
|
||||
//! Metrics 基础设施:基于 `metrics` + `metrics-exporter-prometheus`。
|
||||
//!
|
||||
//! 提供 `MetricsObserver`(实现 `Observer` trait,桥接 agent_loop 事件到 metrics),
|
||||
//! 以及 Prometheus recorder 初始化。
|
||||
//!
|
||||
//! 设计原则:
|
||||
//! - 业务层(agent_loop)只认 `dyn Observer` trait,不感知 metrics 实现。
|
||||
//! - 具体指标名和 label 约定集中于此模块。
|
||||
//! - metrics 后端可替换(换掉 recorder + Observer 实现即可)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
|
||||
|
||||
use super::{Observer, ObserverEvent};
|
||||
|
||||
// ============================================================================
|
||||
// 指标名常量
|
||||
// ============================================================================
|
||||
|
||||
/// LLM 请求耗时(直方图,秒)。Labels: provider, model
|
||||
pub const LLM_REQUEST_DURATION: &str = "picobot_llm_request_duration_seconds";
|
||||
/// LLM token 使用量(计数器)。Labels: provider, model, type (prompt/completion/total)
|
||||
pub const LLM_TOKENS_USED: &str = "picobot_llm_tokens_used_total";
|
||||
/// 工具执行耗时(直方图,秒)。Labels: tool
|
||||
pub const TOOL_EXECUTION_DURATION: &str = "picobot_tool_execution_duration_seconds";
|
||||
/// 工具执行总数(计数器)。Labels: tool, success (true/false)
|
||||
pub const TOOL_EXECUTION_TOTAL: &str = "picobot_tool_execution_total";
|
||||
/// Agent 迭代总数(计数器)
|
||||
pub const AGENT_ITERATIONS: &str = "picobot_agent_iterations_total";
|
||||
/// 消息处理错误总数(计数器)
|
||||
pub const MESSAGE_PROCESSING_ERRORS: &str = "picobot_message_processing_errors_total";
|
||||
|
||||
// ============================================================================
|
||||
// Recorder 初始化
|
||||
// ============================================================================
|
||||
|
||||
/// 安装 Prometheus recorder,返回 handle 供 `/metrics` 端点渲染。
|
||||
///
|
||||
/// 幂等:首次调用安装 recorder 并缓存 handle;后续调用(含热重启)返回缓存的 handle。
|
||||
/// 这避免了热重启后 `install_recorder()` 因 recorder 已安装而失败、导致 `/metrics` 返回 503 的问题。
|
||||
/// 返回 None 表示安装失败(非致命,metrics 静默降级)。
|
||||
static PROMETHEUS_HANDLE: std::sync::OnceLock<Option<PrometheusHandle>> = std::sync::OnceLock::new();
|
||||
|
||||
pub fn init_recorder() -> Option<PrometheusHandle> {
|
||||
PROMETHEUS_HANDLE
|
||||
.get_or_init(|| {
|
||||
let builder = PrometheusBuilder::new();
|
||||
match builder.install_recorder() {
|
||||
Ok(handle) => {
|
||||
tracing::info!("Prometheus metrics recorder installed");
|
||||
Some(handle)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Failed to install Prometheus recorder (metrics will be no-op)");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MetricsObserver — 桥接 ObserverEvent 到 metrics 宏
|
||||
// ============================================================================
|
||||
|
||||
/// 将 `ObserverEvent` 转换为 metrics 指标的 Observer 实现。
|
||||
///
|
||||
/// 通过 `AgentFactory` 依赖注入到 `AgentLoop`,agent_loop 不感知 metrics 实现。
|
||||
pub struct MetricsObserver;
|
||||
|
||||
impl MetricsObserver {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MetricsObserver {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Observer for MetricsObserver {
|
||||
fn record_event(&self, event: &ObserverEvent) {
|
||||
match event {
|
||||
ObserverEvent::ToolCallStart { tool, .. } => {
|
||||
// 工具开始:不记录指标,仅 span 日志已覆盖
|
||||
let _ = tool;
|
||||
}
|
||||
ObserverEvent::ToolCall {
|
||||
tool,
|
||||
duration,
|
||||
success,
|
||||
} => {
|
||||
let duration_secs = duration.as_secs_f64();
|
||||
metrics::histogram!(TOOL_EXECUTION_DURATION, "tool" => tool.clone())
|
||||
.record(duration_secs);
|
||||
metrics::counter!(
|
||||
TOOL_EXECUTION_TOTAL,
|
||||
"tool" => tool.clone(),
|
||||
"success" => success.to_string()
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
ObserverEvent::AgentStart { provider, model } => {
|
||||
metrics::counter!(
|
||||
AGENT_ITERATIONS,
|
||||
"provider" => provider.clone(),
|
||||
"model" => model.clone()
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
ObserverEvent::AgentEnd {
|
||||
provider,
|
||||
model,
|
||||
duration,
|
||||
tokens_used,
|
||||
} => {
|
||||
let duration_secs = duration.as_secs_f64();
|
||||
metrics::histogram!(
|
||||
LLM_REQUEST_DURATION,
|
||||
"provider" => provider.clone(),
|
||||
"model" => model.clone()
|
||||
)
|
||||
.record(duration_secs);
|
||||
if let Some(tokens) = tokens_used {
|
||||
metrics::counter!(
|
||||
LLM_TOKENS_USED,
|
||||
"provider" => provider.clone(),
|
||||
"model" => model.clone(),
|
||||
"type" => "total"
|
||||
)
|
||||
.increment(*tokens);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"metrics_observer"
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建默认的 `Arc<dyn Observer>`(供 AgentFactory 注入)。
|
||||
pub fn default_observer() -> Arc<dyn Observer> {
|
||||
Arc::new(MetricsObserver::new())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 辅助函数:供非 agent_loop 路径直接记录指标
|
||||
// ============================================================================
|
||||
|
||||
/// 记录消息处理错误(供 processor 错误路径调用)。
|
||||
pub fn record_message_processing_error() {
|
||||
metrics::counter!(MESSAGE_PROCESSING_ERRORS).increment(1);
|
||||
}
|
||||
@ -3,9 +3,6 @@
|
||||
//! This module provides an Observer pattern for emitting and collecting
|
||||
//! telemetry events during agent execution.
|
||||
|
||||
pub mod metrics;
|
||||
pub mod tracing_ctx;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
//! 观测上下文辅助:trace_id 生成与 span 创建。
|
||||
//!
|
||||
//! 集中管理 trace_id 的生成和 span 字段命名,避免散落在各模块。
|
||||
//! 业务代码调用此模块的辅助函数,不直接拼 span 字段。
|
||||
|
||||
use tracing::Instrument;
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
/// 生成新的 trace_id(UUID v4)。
|
||||
pub fn new_trace_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// 在携带 trace_id/chat_id/session_id 的 span 内执行 future。
|
||||
///
|
||||
/// 用于 `tokio::spawn` 边界:spawn 不自动传播父 span,
|
||||
/// 调用此函数在 spawn 的 async block 内重建 span 上下文。
|
||||
///
|
||||
/// # 示例
|
||||
/// ```ignore
|
||||
/// tokio::spawn(
|
||||
/// traced(&trace_id, &chat_id, &session_id, async move {
|
||||
/// // 此处所有 tracing 日志自动携带 trace_id/chat_id/session_id
|
||||
/// process_one(inbound).await
|
||||
/// })
|
||||
/// );
|
||||
/// ```
|
||||
pub fn traced<F>(trace_id: &str, chat_id: &str, session_id: &str, f: F) -> Instrumented<F>
|
||||
where
|
||||
F: Future,
|
||||
{
|
||||
let span = tracing::info_span!(
|
||||
"request",
|
||||
trace_id = %trace_id,
|
||||
chat_id = %chat_id,
|
||||
session_id = %session_id
|
||||
);
|
||||
f.instrument(span)
|
||||
}
|
||||
|
||||
/// tracing::Instrumented 的重新导出,便于调用方使用。
|
||||
pub type Instrumented<F> = tracing::instrument::Instrumented<F>;
|
||||
@ -330,12 +330,6 @@ pub enum WsOutbound {
|
||||
timestamp: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
subagent_task_id: Option<String>,
|
||||
/// 子代理最终状态(completed/failed/timeout/cancelled/interrupted),
|
||||
/// 供前端更新主视图中 task tool result 占位消息的显示状态。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
subagent_status: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
subagent_summary: Option<String>,
|
||||
},
|
||||
#[serde(rename = "todo_list")]
|
||||
TodoList {
|
||||
|
||||
@ -221,8 +221,6 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
|
||||
topic_id: message.metadata.get("topic_id").cloned(),
|
||||
timestamp: Some(crate::protocol::now_timestamp()),
|
||||
subagent_task_id: message.metadata.get("subagent_task_id").cloned(),
|
||||
subagent_status: message.metadata.get("subagent_status").cloned(),
|
||||
subagent_summary: message.metadata.get("subagent_summary").cloned(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
@ -252,7 +252,6 @@ struct AnthropicUsage {
|
||||
|
||||
#[async_trait]
|
||||
impl LLMProvider for AnthropicProvider {
|
||||
#[tracing::instrument(skip(self, request), fields(provider = %self.name, model = %self.model_id))]
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatCompletionRequest,
|
||||
@ -260,14 +259,6 @@ impl LLMProvider for AnthropicProvider {
|
||||
let url = format!("{}/v1/messages", self.base_url);
|
||||
let max_tokens = request.max_tokens.or(self.max_tokens).unwrap_or(1024);
|
||||
|
||||
tracing::info!(
|
||||
provider = %self.name,
|
||||
model = %self.model_id,
|
||||
message_count = request.messages.len(),
|
||||
has_tools = request.tools.is_some(),
|
||||
"Anthropic: sending chat completion request"
|
||||
);
|
||||
|
||||
let tools = request.tools.map(|tools| {
|
||||
tools
|
||||
.iter()
|
||||
@ -313,16 +304,7 @@ impl LLMProvider for AnthropicProvider {
|
||||
req_builder = req_builder.header(key.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
let resp = req_builder.json(&body).send().await.map_err(|e| {
|
||||
tracing::error!(
|
||||
provider = %self.name,
|
||||
model = %self.model_id,
|
||||
url = %url,
|
||||
error = %format_error_chain(&e),
|
||||
"Anthropic: HTTP request failed"
|
||||
);
|
||||
e
|
||||
})?;
|
||||
let resp = req_builder.json(&body).send().await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await?;
|
||||
|
||||
@ -339,13 +321,11 @@ impl LLMProvider for AnthropicProvider {
|
||||
return Err(format!("API error {}: {}", status, text).into());
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
provider = %self.name,
|
||||
model = %self.model_id,
|
||||
status = %status,
|
||||
response_len = text.len(),
|
||||
"Anthropic response received"
|
||||
);
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let resp_preview: String = text.chars().take(100).collect();
|
||||
tracing::debug!(status = %status, response_preview = %resp_preview, response_len = %text.len(), timeout_secs = self.llm_timeout_secs, "Anthropic response (first 100 chars shown)");
|
||||
}
|
||||
|
||||
let anthropic_resp: AnthropicResponse = serde_json::from_str(&text).map_err(|e| {
|
||||
tracing::error!(
|
||||
@ -384,29 +364,18 @@ impl LLMProvider for AnthropicProvider {
|
||||
}
|
||||
}
|
||||
|
||||
let usage = Usage {
|
||||
prompt_tokens: anthropic_resp.usage.input_tokens,
|
||||
completion_tokens: anthropic_resp.usage.output_tokens,
|
||||
total_tokens: anthropic_resp.usage.input_tokens + anthropic_resp.usage.output_tokens,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
provider = %self.name,
|
||||
model = %self.model_id,
|
||||
prompt_tokens = usage.prompt_tokens,
|
||||
completion_tokens = usage.completion_tokens,
|
||||
total_tokens = usage.total_tokens,
|
||||
has_tool_calls = !tool_calls.is_empty(),
|
||||
"Anthropic: chat completion completed"
|
||||
);
|
||||
|
||||
Ok(ChatCompletionResponse {
|
||||
id: anthropic_resp.id,
|
||||
model: anthropic_resp.model,
|
||||
content,
|
||||
reasoning_content: None,
|
||||
tool_calls,
|
||||
usage,
|
||||
usage: Usage {
|
||||
prompt_tokens: anthropic_resp.usage.input_tokens,
|
||||
completion_tokens: anthropic_resp.usage.output_tokens,
|
||||
total_tokens: anthropic_resp.usage.input_tokens
|
||||
+ anthropic_resp.usage.output_tokens,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -323,28 +323,3 @@ pub(super) fn add_column_if_missing(conn: &Connection, sql: &str) -> Result<(),
|
||||
Err(error) => Err(StorageError::Database(error)),
|
||||
}
|
||||
}
|
||||
|
||||
/// pending_subagents 表:跟踪异步子代理执行状态,用于崩溃恢复。
|
||||
pub(super) fn ensure_pending_subagents_schema(conn: &Connection) -> Result<(), StorageError> {
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS pending_subagents (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
parent_session_id TEXT NOT NULL,
|
||||
parent_topic_id TEXT NOT NULL,
|
||||
parent_chat_id TEXT NOT NULL,
|
||||
parent_channel TEXT NOT NULL,
|
||||
def_name TEXT,
|
||||
spawned_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'running'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pending_subagents_topic
|
||||
ON pending_subagents(parent_topic_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pending_subagents_session
|
||||
ON pending_subagents(parent_session_id);
|
||||
",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -28,10 +28,10 @@ pub use ports::{
|
||||
SkillEventRepository, TodoRepository,
|
||||
};
|
||||
pub use records::{
|
||||
ALLOWED_MEMORY_NAMESPACES, GLOBAL_SCOPE_KEY, MemoryRecord, MemoryUpsert, PendingSubagentRecord,
|
||||
SchedulerJobRecord, SchedulerJobState, SchedulerJobStatus, SchedulerJobUpsert, SessionRecord,
|
||||
SessionTokenStats, SkillEventRecord, TodoRecord, TopicRecord, 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,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@ -234,7 +234,6 @@ impl SessionStore {
|
||||
ensure_scheduler_schema(&conn)?;
|
||||
ensure_memory_scope_key_migration(&conn)?;
|
||||
ensure_todos_schema(&conn)?;
|
||||
ensure_pending_subagents_schema(&conn)?;
|
||||
|
||||
drop(conn);
|
||||
|
||||
@ -2053,140 +2052,6 @@ impl SessionStore {
|
||||
}
|
||||
Ok(todos)
|
||||
}
|
||||
|
||||
// ==================== pending_subagents ====================
|
||||
|
||||
/// 插入一条 pending_subagent 记录(task 工具 spawn 时调用)。
|
||||
pub fn insert_pending_subagent(
|
||||
&self,
|
||||
record: &PendingSubagentRecord,
|
||||
) -> Result<(), StorageError> {
|
||||
let conn = self.pool.get()?;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO pending_subagents
|
||||
(task_id, parent_session_id, parent_topic_id, parent_chat_id, parent_channel, def_name, spawned_at, status)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![
|
||||
record.task_id,
|
||||
record.parent_session_id,
|
||||
record.parent_topic_id,
|
||||
record.parent_chat_id,
|
||||
record.parent_channel,
|
||||
record.def_name,
|
||||
record.spawned_at,
|
||||
record.status,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 查询指定 topic 下匹配状态的 pending_subagent 记录。
|
||||
/// `status` 为 None 时查询所有状态。
|
||||
pub fn list_pending_subagents(
|
||||
&self,
|
||||
topic_id: &str,
|
||||
status: Option<&str>,
|
||||
) -> Result<Vec<PendingSubagentRecord>, StorageError> {
|
||||
let conn = self.pool.get()?;
|
||||
let sql = if status.is_some() {
|
||||
"SELECT task_id, parent_session_id, parent_topic_id, parent_chat_id, parent_channel, def_name, spawned_at, status
|
||||
FROM pending_subagents
|
||||
WHERE parent_topic_id = ?1 AND status = ?2
|
||||
ORDER BY spawned_at ASC"
|
||||
} else {
|
||||
"SELECT task_id, parent_session_id, parent_topic_id, parent_chat_id, parent_channel, def_name, spawned_at, status
|
||||
FROM pending_subagents
|
||||
WHERE parent_topic_id = ?1
|
||||
ORDER BY spawned_at ASC"
|
||||
};
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = if let Some(s) = status {
|
||||
stmt.query_map(params![topic_id, s], map_pending_subagent_record)?
|
||||
} else {
|
||||
stmt.query_map(params![topic_id], map_pending_subagent_record)?
|
||||
};
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
result.push(row?);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 获取指定 task_id 的 pending_subagent 记录。
|
||||
pub fn get_pending_subagent(
|
||||
&self,
|
||||
task_id: &str,
|
||||
) -> Result<Option<PendingSubagentRecord>, StorageError> {
|
||||
let conn = self.pool.get()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT task_id, parent_session_id, parent_topic_id, parent_chat_id, parent_channel, def_name, spawned_at, status
|
||||
FROM pending_subagents
|
||||
WHERE task_id = ?1",
|
||||
)?;
|
||||
let mut rows = stmt.query_map(params![task_id], map_pending_subagent_record)?;
|
||||
match rows.next() {
|
||||
Some(row) => Ok(Some(row?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新指定 task_id 的状态(子代理完成或取消时调用)。
|
||||
pub fn update_pending_subagent_status(
|
||||
&self,
|
||||
task_id: &str,
|
||||
new_status: &str,
|
||||
) -> Result<(), StorageError> {
|
||||
let conn = self.pool.get()?;
|
||||
conn.execute(
|
||||
"UPDATE pending_subagents SET status = ?1 WHERE task_id = ?2",
|
||||
params![new_status, task_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 条件更新状态:仅在当前状态为 `expected_current` 时才更新为 `new_status`。
|
||||
///
|
||||
/// 实现状态机不可逆性不变量:避免 cancel 路径覆盖 spawn 已写入的终态
|
||||
/// (completed → cancelled 是非法转换)。
|
||||
///
|
||||
/// 返回是否实际更新(affected rows > 0)。false 表示状态已被其他路径更新,
|
||||
/// 调用方应跳过后续基于该假设的操作。
|
||||
pub fn try_update_pending_subagent_status(
|
||||
&self,
|
||||
task_id: &str,
|
||||
expected_current: &str,
|
||||
new_status: &str,
|
||||
) -> Result<bool, StorageError> {
|
||||
let conn = self.pool.get()?;
|
||||
let affected = conn.execute(
|
||||
"UPDATE pending_subagents SET status = ?1 WHERE task_id = ?2 AND status = ?3",
|
||||
params![new_status, task_id, expected_current],
|
||||
)?;
|
||||
Ok(affected > 0)
|
||||
}
|
||||
|
||||
/// 将所有 running 状态的 pending_subagent 标记为 interrupted(启动时崩溃恢复调用)。
|
||||
pub fn mark_all_running_as_interrupted(&self) -> Result<usize, StorageError> {
|
||||
let conn = self.pool.get()?;
|
||||
let affected = conn.execute(
|
||||
"UPDATE pending_subagents SET status = 'interrupted' WHERE status = 'running'",
|
||||
[],
|
||||
)?;
|
||||
Ok(affected)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_pending_subagent_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<PendingSubagentRecord> {
|
||||
Ok(PendingSubagentRecord {
|
||||
task_id: row.get(0)?,
|
||||
parent_session_id: row.get(1)?,
|
||||
parent_topic_id: row.get(2)?,
|
||||
parent_chat_id: row.get(3)?,
|
||||
parent_channel: row.get(4)?,
|
||||
def_name: row.get(5)?,
|
||||
spawned_at: row.get(6)?,
|
||||
status: row.get(7)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn persistent_session_id(channel_name: &str, chat_id: &str) -> String {
|
||||
|
||||
@ -111,31 +111,6 @@ pub struct TopicRecord {
|
||||
pub message_count: i64,
|
||||
}
|
||||
|
||||
/// pending_subagents 表的记录,跟踪异步子代理执行状态。
|
||||
///
|
||||
/// 生命周期:task 工具 spawn 时插入(status=running),
|
||||
/// 子代理完成时更新为 completed/failed/timeout,
|
||||
/// 进程重启时 running 状态被标记为 interrupted。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingSubagentRecord {
|
||||
/// 子代理 task_id(主键,与 TaskSession.id 一致)
|
||||
pub task_id: String,
|
||||
/// 父会话 session_id
|
||||
pub parent_session_id: String,
|
||||
/// 父会话 topic_id(用于按 topic 查询未完成子代理)
|
||||
pub parent_topic_id: String,
|
||||
/// 父会话 chat_id
|
||||
pub parent_chat_id: String,
|
||||
/// 父会话 channel_name
|
||||
pub parent_channel: String,
|
||||
/// 子代理定义名称(可选,用于诊断)
|
||||
pub def_name: Option<String>,
|
||||
/// 子代理启动时间戳
|
||||
pub spawned_at: i64,
|
||||
/// 执行状态:running / completed / failed / interrupted / cancelled / timeout
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// 单个 session 的 token 用量统计(聚合结果)。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SessionTokenStats {
|
||||
|
||||
@ -158,20 +158,14 @@ fn extract_f64(args: &serde_json::Value, key: &str, name: &str) -> Result<f64, S
|
||||
match args.get(key) {
|
||||
None => Err(format!("Missing required parameter: {name}")),
|
||||
Some(v) => {
|
||||
let n = if let Some(n) = v.as_f64() {
|
||||
n
|
||||
if let Some(n) = v.as_f64() {
|
||||
Ok(n)
|
||||
} else if let Some(s) = v.as_str() {
|
||||
s.parse::<f64>()
|
||||
.map_err(|_| format!("{name} is not a valid number: {s}"))?
|
||||
.map_err(|_| format!("{name} is not a valid number: {s}"))
|
||||
} else {
|
||||
return Err(format!("{name} must be a number"));
|
||||
};
|
||||
// f64::from_str 接受 "NaN"/"inf";非有限值会使 sort_by 的
|
||||
// partial_cmp().unwrap() panic,且算术结果无意义,统一在边界拒绝。
|
||||
if !n.is_finite() {
|
||||
return Err(format!("{name} must be a finite number"));
|
||||
Err(format!("{name} must be a number"))
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -213,11 +207,6 @@ fn extract_values(args: &serde_json::Value, min_len: usize) -> Result<Vec<f64>,
|
||||
} else {
|
||||
return Err(format!("values[{i}] is not a valid number"));
|
||||
};
|
||||
// f64::from_str 接受 "NaN"/"inf";非有限值会使 sort_by 的
|
||||
// partial_cmp().unwrap() panic,且统计结果无意义,统一在边界拒绝。
|
||||
if !n.is_finite() {
|
||||
return Err(format!("values[{i}] is not a finite number"));
|
||||
}
|
||||
nums.push(n);
|
||||
}
|
||||
Ok(nums)
|
||||
@ -262,16 +251,12 @@ fn calc_factorial(args: &serde_json::Value) -> Result<String, String> {
|
||||
}
|
||||
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
|
||||
let n = x.round() as u128;
|
||||
// u128::MAX ≈ 3.4e38,34! 是最后一个不溢出的阶乘(35! ≈ 1.03e40)。
|
||||
// 修复前按 f64 范围放行到 170,实际在 n≥35 时 debug panic / release 静默回绕。
|
||||
if n > 34 {
|
||||
return Err("Factorial result exceeds supported integer range (max input: 34)".to_string());
|
||||
if n > 170 {
|
||||
return Err("Factorial result exceeds f64 range (max input: 170)".to_string());
|
||||
}
|
||||
let mut result: u128 = 1;
|
||||
for i in 2..=n {
|
||||
result = result
|
||||
.checked_mul(i)
|
||||
.ok_or_else(|| "Factorial result exceeds supported integer range".to_string())?;
|
||||
result *= i;
|
||||
}
|
||||
Ok(result.to_string())
|
||||
}
|
||||
@ -427,15 +412,8 @@ fn calc_evaluate(args: &serde_json::Value) -> Result<String, String> {
|
||||
.ok_or_else(|| "Missing required parameter: expression".to_string())?;
|
||||
|
||||
meval::eval_str(expression)
|
||||
.map(format_num)
|
||||
.map_err(|e| format!("Expression evaluation error: {e}"))
|
||||
.and_then(|n| {
|
||||
// 表达式可产生非有限结果(如 "1/0" → inf、"0/0" → NaN),
|
||||
// 与 extract_values/extract_f64 的边界策略保持一致:拒绝输出。
|
||||
if !n.is_finite() {
|
||||
return Err(format!("Expression result is not a finite number: {expression}"));
|
||||
}
|
||||
Ok(format_num(n))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -801,99 +779,4 @@ mod tests {
|
||||
.contains("Missing required parameters")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_median_rejects_nan_string_value() {
|
||||
// f64::from_str accepts "NaN"; partial_cmp on NaN is None and would
|
||||
// panic inside sort_by — must surface as a tool error instead.
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "median", "values": ["NaN", 1.0, 2.0]}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(
|
||||
result.error.as_ref().unwrap().contains("finite"),
|
||||
"expected finiteness error, got: {:?}",
|
||||
result.error
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_percentile_rejects_infinity_string_value() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "percentile", "values": ["inf", 1.0], "p": 50}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.error.as_ref().unwrap().contains("finite"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sum_rejects_nan_instead_of_returning_nan() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "sum", "values": ["NaN", 1.0]}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.error.as_ref().unwrap().contains("finite"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clamp_rejects_non_finite_scalar() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "clamp", "x": "NaN", "min_val": 0.0, "max_val": 1.0}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.error.as_ref().unwrap().contains("finite"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_factorial_large_input_returns_error_not_overflow() {
|
||||
// 35! ≈ 1.03e40 超出 u128::MAX ≈ 3.4e38:修复前 debug 下乘法溢出 panic、
|
||||
// release 下静默回绕。必须返回工具错误。
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "factorial", "x": 35.0}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(
|
||||
result.error.as_ref().unwrap().contains("range"),
|
||||
"expected range error, got: {:?}",
|
||||
result.error
|
||||
);
|
||||
// 34! 仍可精确计算
|
||||
let ok = tool
|
||||
.execute(json!({"function": "factorial", "x": 34.0}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(ok.success);
|
||||
assert_eq!(
|
||||
ok.output,
|
||||
"295232799039604140847618609643520000000"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_evaluate_rejects_non_finite_result() {
|
||||
let tool = CalculatorTool::new();
|
||||
let division_by_zero = tool
|
||||
.execute(json!({"function": "evaluate", "expression": "1/0"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!division_by_zero.success);
|
||||
assert!(division_by_zero.error.as_ref().unwrap().contains("finite"));
|
||||
|
||||
let nan_result = tool
|
||||
.execute(json!({"function": "evaluate", "expression": "0/0"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!nan_result.success);
|
||||
assert!(nan_result.error.as_ref().unwrap().contains("finite"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,7 +18,6 @@ pub mod time;
|
||||
pub mod todo_read;
|
||||
pub mod todo_write;
|
||||
pub mod traits;
|
||||
pub mod wait_tool;
|
||||
pub mod web_fetch;
|
||||
|
||||
pub use bash::BashTool;
|
||||
@ -46,8 +45,7 @@ pub use task::{
|
||||
pub use time::TimeTool;
|
||||
pub use todo_read::TodoReadTool;
|
||||
pub use todo_write::TodoWriteTool;
|
||||
pub use traits::{Tool, ToolContext, ToolResult, WaitCoordinator, WaitEvent};
|
||||
pub use wait_tool::WaitForSubagentsTool;
|
||||
pub use traits::{Tool, ToolContext, ToolResult};
|
||||
pub use web_fetch::WebFetchTool;
|
||||
|
||||
/// Extract a string parameter from JSON args.
|
||||
|
||||
@ -18,9 +18,6 @@ pub enum TaskError {
|
||||
#[error("Task execution timed out")]
|
||||
Timeout,
|
||||
|
||||
#[error("Task cancelled by user")]
|
||||
Cancelled,
|
||||
|
||||
#[error("Repository error: {0}")]
|
||||
RepositoryError(#[from] StorageError),
|
||||
|
||||
@ -38,7 +35,6 @@ impl TaskError {
|
||||
pub fn as_status(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Timeout => "timeout",
|
||||
Self::Cancelled => "cancelled",
|
||||
Self::SessionNotFound(_) => "failed",
|
||||
Self::InvalidParentSession => "failed",
|
||||
Self::AgentCreationFailed(_) => "failed",
|
||||
|
||||
@ -14,6 +14,6 @@ pub use runtime::{
|
||||
};
|
||||
pub use tool::TaskTool;
|
||||
pub use types::{
|
||||
SubagentDef, SubagentResult, SubagentSource, SubagentStatus, SubagentType, TaskDefinition,
|
||||
TaskHandle, TaskSession, TaskSessionState, TaskToolArgs, TaskToolResult,
|
||||
SubagentDef, SubagentSource, SubagentType, TaskDefinition, TaskHandle, TaskSession,
|
||||
TaskSessionState, TaskToolArgs, TaskToolResult,
|
||||
};
|
||||
|
||||
@ -8,49 +8,6 @@ use std::time::Duration;
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
|
||||
/// RAII guard:spawn 任务退出时(正常/early return/panic)确定性清理 cancel_registry 条目。
|
||||
///
|
||||
/// 实现不变量 3(资源生命周期与作用域严格绑定):
|
||||
/// spawn 块末行的手动清理是脆弱的,panic/early return 会绕过;
|
||||
/// 用 Drop impl 把清理封进作用域语义,由编译器保证执行。
|
||||
struct CancelRegistryGuard {
|
||||
task_id: String,
|
||||
registry: Arc<parking_lot::Mutex<HashMap<String, tokio_util::sync::CancellationToken>>>,
|
||||
/// 标记是否已显式释放(例如 spawn 块成功路径末尾主动 disarm)。
|
||||
/// 默认 false,drop 时执行清理。
|
||||
disarmed: bool,
|
||||
}
|
||||
|
||||
impl CancelRegistryGuard {
|
||||
fn new(
|
||||
task_id: String,
|
||||
registry: Arc<parking_lot::Mutex<HashMap<String, tokio_util::sync::CancellationToken>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
task_id,
|
||||
registry,
|
||||
disarmed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 显式释放:成功路径末尾调用,避免重复清理。
|
||||
/// (实际 drop 也会幂等移除,但 disarm 让语义更清晰。)
|
||||
#[allow(dead_code)]
|
||||
fn disarm(&mut self) {
|
||||
self.disarmed = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CancelRegistryGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.disarmed {
|
||||
return;
|
||||
}
|
||||
// 幂等:条目可能已被 cancel_pending_for_topic 移除或先前已 drop
|
||||
self.registry.lock().remove(&self.task_id);
|
||||
}
|
||||
}
|
||||
|
||||
use crate::agent::{
|
||||
AgentLoop, AgentRuntimeConfig, EmittedMessageHandler, PersistingEmittedMessageHandler,
|
||||
SystemPrompt, SystemPromptContext, SystemPromptProvider,
|
||||
@ -63,18 +20,14 @@ use crate::domain::CapabilityPolicy;
|
||||
use crate::experts::ExpertRuntime;
|
||||
use crate::providers::StreamDelta;
|
||||
use crate::skills::SkillRuntime;
|
||||
use crate::storage::{ConversationRepository, PendingSubagentRecord, SessionStore};
|
||||
use crate::storage::{ConversationRepository, SessionStore};
|
||||
use crate::tools::{ToolContext, ToolRegistry};
|
||||
use crate::utils::current_timestamp;
|
||||
|
||||
use super::error::TaskError;
|
||||
use super::prompt::{SubagentPromptBuilder, extract_summary};
|
||||
use super::repository::TaskRepository;
|
||||
use super::tool::TaskTool;
|
||||
use super::types::{
|
||||
SubagentDef, SubagentResult, SubagentSource, SubagentStatus, TaskDefinition, TaskSession,
|
||||
TaskToolResult,
|
||||
};
|
||||
use super::types::{SubagentDef, SubagentSource, TaskDefinition, TaskSession, TaskToolResult};
|
||||
|
||||
/// 子代理运行时配置
|
||||
#[derive(Debug, Clone)]
|
||||
@ -87,8 +40,6 @@ pub struct SubAgentRuntimeConfig {
|
||||
pub ttl_hours: u64,
|
||||
/// 子代理最大嵌套深度(0 = 禁止嵌套,1 = 允许 1 层孙代理)
|
||||
pub max_nesting_depth: u32,
|
||||
/// 异步子代理最大并发数(Semaphore 限流)
|
||||
pub max_concurrent: usize,
|
||||
}
|
||||
|
||||
impl Default for SubAgentRuntimeConfig {
|
||||
@ -111,7 +62,6 @@ impl Default for SubAgentRuntimeConfig {
|
||||
default_max_execution_secs: 3600, // 60分钟
|
||||
ttl_hours: 24,
|
||||
max_nesting_depth: 1,
|
||||
max_concurrent: 8,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -142,12 +92,6 @@ pub trait SubAgentRuntime: Send + Sync + 'static {
|
||||
|
||||
/// 获取可用的子代理类型列表
|
||||
fn available_subagent_names(&self) -> Vec<String>;
|
||||
|
||||
/// 取消指定 topic 下所有正在运行的异步子代理。
|
||||
///
|
||||
/// 用于 /stop 命令传播:用户取消主 agent 时,同步取消其后台子代理。
|
||||
/// 返回被触发取消的子代理数量。
|
||||
async fn cancel_pending_for_topic(&self, topic_id: &str) -> usize;
|
||||
}
|
||||
|
||||
/// 静态系统提示词提供者(用于子代理)
|
||||
@ -171,13 +115,12 @@ struct SubAgentEmitter {
|
||||
/// 子/孙智能体自身的 task_id,用于持久化时作为 scope_key
|
||||
task_id: String,
|
||||
stream_message_id: parking_lot::Mutex<Option<String>>,
|
||||
trace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EmittedMessageHandler for SubAgentEmitter {
|
||||
async fn handle(&self, message: ChatMessage) {
|
||||
for mut outbound in OutboundMessage::from_chat_message(
|
||||
for outbound in OutboundMessage::from_chat_message(
|
||||
&self.channel_name,
|
||||
&self.chat_id,
|
||||
None,
|
||||
@ -185,9 +128,6 @@ impl EmittedMessageHandler for SubAgentEmitter {
|
||||
&self.metadata,
|
||||
&message,
|
||||
) {
|
||||
if let Some(ref tid) = self.trace_id {
|
||||
outbound.trace_id = tid.clone();
|
||||
}
|
||||
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
||||
match error {
|
||||
crate::bus::BusError::Dropped => {
|
||||
@ -206,7 +146,7 @@ impl EmittedMessageHandler for SubAgentEmitter {
|
||||
if let Some(ms) = duration_ms {
|
||||
metadata.insert("tool_duration_ms".to_string(), ms.to_string());
|
||||
}
|
||||
for mut outbound in OutboundMessage::from_chat_message(
|
||||
for outbound in OutboundMessage::from_chat_message(
|
||||
&self.channel_name,
|
||||
&self.chat_id,
|
||||
None,
|
||||
@ -214,9 +154,6 @@ impl EmittedMessageHandler for SubAgentEmitter {
|
||||
&metadata,
|
||||
&message,
|
||||
) {
|
||||
if let Some(ref tid) = self.trace_id {
|
||||
outbound.trace_id = tid.clone();
|
||||
}
|
||||
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
||||
match error {
|
||||
crate::bus::BusError::Dropped => {
|
||||
@ -243,7 +180,7 @@ impl EmittedMessageHandler for SubAgentEmitter {
|
||||
.clone()
|
||||
};
|
||||
|
||||
let mut outbound = if delta.content.is_empty() && delta.reasoning_content.is_none() {
|
||||
let outbound = if delta.content.is_empty() && delta.reasoning_content.is_none() {
|
||||
OutboundMessage::stream_end(
|
||||
&self.channel_name,
|
||||
&self.chat_id,
|
||||
@ -262,9 +199,6 @@ impl EmittedMessageHandler for SubAgentEmitter {
|
||||
self.metadata.clone(),
|
||||
)
|
||||
};
|
||||
if let Some(ref tid) = self.trace_id {
|
||||
outbound.trace_id = tid.clone();
|
||||
}
|
||||
|
||||
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
||||
match error {
|
||||
@ -368,36 +302,20 @@ fn build_subagent_event_metadata(session: &TaskSession) -> HashMap<String, Strin
|
||||
"topic_id".to_string(),
|
||||
session.parent_topic_id.clone().unwrap_or_default(),
|
||||
);
|
||||
// 子代理最终状态(completed/failed/timeout/cancelled/interrupted),
|
||||
// 供前端更新主视图中 task tool result 占位消息的显示状态。
|
||||
metadata.insert(
|
||||
"subagent_status".to_string(),
|
||||
session.state.as_str().to_string(),
|
||||
);
|
||||
if let Some(ref summary) = session.summary {
|
||||
metadata.insert("subagent_summary".to_string(), summary.clone());
|
||||
}
|
||||
metadata
|
||||
}
|
||||
|
||||
/// 发布子智能体执行完成事件(ExecutionCompleted),metadata 含 subagent_task_id。
|
||||
async fn publish_subagent_completion(
|
||||
bus: &Option<Arc<MessageBus>>,
|
||||
session: &TaskSession,
|
||||
trace_id: &str,
|
||||
) {
|
||||
async fn publish_subagent_completion(bus: &Option<Arc<MessageBus>>, session: &TaskSession) {
|
||||
if let Some(bus) = bus {
|
||||
let metadata = build_subagent_event_metadata(session);
|
||||
if let Err(e) = bus
|
||||
.publish_outbound(
|
||||
OutboundMessage::execution_completed(
|
||||
session.parent_channel_name.clone(),
|
||||
session.parent_chat_id.clone(),
|
||||
Some(session.parent_session_id.clone()),
|
||||
metadata,
|
||||
)
|
||||
.with_trace_id(trace_id),
|
||||
)
|
||||
.publish_outbound(OutboundMessage::execution_completed(
|
||||
session.parent_channel_name.clone(),
|
||||
session.parent_chat_id.clone(),
|
||||
Some(session.parent_session_id.clone()),
|
||||
metadata,
|
||||
))
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, task_id = %session.id, "Failed to publish subagent execution_completed");
|
||||
@ -410,22 +328,18 @@ async fn publish_subagent_error(
|
||||
bus: &Option<Arc<MessageBus>>,
|
||||
session: &TaskSession,
|
||||
error_msg: &str,
|
||||
trace_id: &str,
|
||||
) {
|
||||
if let Some(bus) = bus {
|
||||
let metadata = build_subagent_event_metadata(session);
|
||||
if let Err(e) = bus
|
||||
.publish_outbound(
|
||||
OutboundMessage::error_notification(
|
||||
session.parent_channel_name.clone(),
|
||||
session.parent_chat_id.clone(),
|
||||
Some(session.parent_session_id.clone()),
|
||||
error_msg.to_string(),
|
||||
None,
|
||||
metadata,
|
||||
)
|
||||
.with_trace_id(trace_id),
|
||||
)
|
||||
.publish_outbound(OutboundMessage::error_notification(
|
||||
session.parent_channel_name.clone(),
|
||||
session.parent_chat_id.clone(),
|
||||
Some(session.parent_session_id.clone()),
|
||||
error_msg.to_string(),
|
||||
None,
|
||||
metadata,
|
||||
))
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, task_id = %session.id, "Failed to publish subagent error notification");
|
||||
@ -457,11 +371,6 @@ pub struct DefaultSubAgentRuntime {
|
||||
store: Arc<SessionStore>,
|
||||
/// 技能运行时(实时计算技能索引,替代冻结快照)
|
||||
skills: Arc<SkillRuntime>,
|
||||
/// 异步子代理并发限流(按 config.max_concurrent 初始化)
|
||||
semaphore: Arc<tokio::sync::Semaphore>,
|
||||
/// task_id → CancellationToken 映射,用于取消传播
|
||||
/// Arc 包装以便 spawned task 完成后清理自身条目
|
||||
cancel_registry: Arc<parking_lot::Mutex<HashMap<String, tokio_util::sync::CancellationToken>>>,
|
||||
}
|
||||
|
||||
impl DefaultSubAgentRuntime {
|
||||
@ -477,8 +386,6 @@ impl DefaultSubAgentRuntime {
|
||||
store: Arc<SessionStore>,
|
||||
skills: Arc<SkillRuntime>,
|
||||
) -> Self {
|
||||
let max_concurrent = config.max_concurrent.max(1);
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent));
|
||||
Self {
|
||||
config,
|
||||
task_repository,
|
||||
@ -490,8 +397,6 @@ impl DefaultSubAgentRuntime {
|
||||
bus,
|
||||
store,
|
||||
skills,
|
||||
semaphore,
|
||||
cancel_registry: Arc::new(parking_lot::Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
@ -575,7 +480,6 @@ impl DefaultSubAgentRuntime {
|
||||
def: Option<&SubagentDef>,
|
||||
parent_nesting_depth: u32,
|
||||
parent_task_id: Option<String>,
|
||||
trace_id: Option<String>,
|
||||
) -> Result<AgentLoop, TaskError> {
|
||||
let prompt_provider = Arc::new(StaticSystemPromptProvider::new(system_prompt));
|
||||
|
||||
@ -625,14 +529,6 @@ impl DefaultSubAgentRuntime {
|
||||
// 子代理自身的 capability 作为孙代理的 parent_capability,
|
||||
// 使孙代理的 TaskTool 能按此策略校验(与主 agent 注入专家 capability 同构)
|
||||
parent_capability: def.map(|d| d.capability.clone()),
|
||||
// 从父 ToolContext 继承 trace_id,保持端到端追踪贯通子代理
|
||||
trace_id: trace_id.clone(),
|
||||
// 子代理不注入 sub_done_sender:嵌套层不支持异步,走同步路径
|
||||
sub_done_sender: None,
|
||||
// 子代理不注入 wait_coordinator:嵌套层不支持异步 wait
|
||||
wait_coordinator: None,
|
||||
// 子代理不注入 cancel_rx:嵌套层不支持异步 wait,无需 cancel 检查
|
||||
cancel_rx: None,
|
||||
});
|
||||
|
||||
// 如果有 MessageBus,附加实时广播 emitter
|
||||
@ -654,7 +550,6 @@ impl DefaultSubAgentRuntime {
|
||||
store: self.store.clone(),
|
||||
task_id: session.id.clone(),
|
||||
stream_message_id: parking_lot::Mutex::new(None),
|
||||
trace_id: trace_id.clone(),
|
||||
},
|
||||
self.conversation_repository.clone(),
|
||||
session.session_id.clone(),
|
||||
@ -676,18 +571,6 @@ impl DefaultSubAgentRuntime {
|
||||
session: &TaskSession,
|
||||
def: &SubagentDef,
|
||||
prompt: String,
|
||||
) -> Result<TaskToolResult, TaskError> {
|
||||
let max_secs = self.effective_max_execution_secs(def);
|
||||
Self::execute_task_static(agent, session, def, prompt, max_secs).await
|
||||
}
|
||||
|
||||
/// 静态执行任务(供 tokio::spawn 调用,不依赖 &self)
|
||||
async fn execute_task_static(
|
||||
agent: AgentLoop,
|
||||
session: &TaskSession,
|
||||
_def: &SubagentDef,
|
||||
prompt: String,
|
||||
max_secs: u64,
|
||||
) -> Result<TaskToolResult, TaskError> {
|
||||
// 构建初始消息
|
||||
let history = vec![ChatMessage::user(prompt)];
|
||||
@ -698,6 +581,7 @@ impl DefaultSubAgentRuntime {
|
||||
};
|
||||
|
||||
// 设置超时
|
||||
let max_secs = self.effective_max_execution_secs(def);
|
||||
let timeout_duration = Duration::from_secs(max_secs);
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
@ -774,7 +658,6 @@ impl DefaultSubAgentRuntime {
|
||||
&self,
|
||||
session: TaskSession,
|
||||
error: TaskError,
|
||||
trace_id: &str,
|
||||
) -> Result<TaskToolResult, TaskError> {
|
||||
let status = error.as_status();
|
||||
tracing::warn!(
|
||||
@ -791,7 +674,7 @@ impl DefaultSubAgentRuntime {
|
||||
session.mark_failed(error.to_string());
|
||||
}
|
||||
self.task_repository.save_task_session(&session).await?;
|
||||
publish_subagent_error(&self.bus, &session, &error.to_string(), trace_id).await;
|
||||
publish_subagent_error(&self.bus, &session, &error.to_string()).await;
|
||||
Ok(TaskToolResult {
|
||||
status: status.to_string(),
|
||||
summary: error.to_string(),
|
||||
@ -908,7 +791,6 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
||||
tool_arguments: None,
|
||||
reasoning_content: None,
|
||||
message_id: None,
|
||||
trace_id: parent_context.trace_id.clone().unwrap_or_default(),
|
||||
};
|
||||
|
||||
if let Err(e) = bus.publish_outbound(event).await {
|
||||
@ -916,282 +798,60 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 构建子代理系统提示词
|
||||
// 实时按 def.capability 过滤技能索引(替代冻结快照,反映运行时技能增删)
|
||||
let skills_index = if def.capability.has_skill_policy() {
|
||||
self.skills.system_index_prompt_filtered(
|
||||
def.capability.allowed_skills.as_deref(),
|
||||
&def.capability.denied_skills,
|
||||
)
|
||||
} else {
|
||||
self.skills.system_index_prompt()
|
||||
};
|
||||
// 同步解析 def 中的 provider/model 覆盖,保证环境提示中的模型名与实际使用的模型一致
|
||||
let effective_provider_config = match (def.provider.is_some(), def.model.is_some()) {
|
||||
(true, _) | (_, true) => self
|
||||
.model_resolver
|
||||
.resolve(
|
||||
def.provider.as_deref(),
|
||||
def.model.as_deref(),
|
||||
&self.provider_config,
|
||||
// 6-8. 构建提示词、创建子代理、执行任务
|
||||
// 统一为单个 Result 表达式:model_resolver / create_subagent / execute_task
|
||||
// 的任何失败都流入下方 match 的 Err 分支,经 handle_task_failure 返回结构化结果。
|
||||
let result: Result<TaskToolResult, TaskError> = {
|
||||
// 6. 构建子代理系统提示词
|
||||
// 实时按 def.capability 过滤技能索引(替代冻结快照,反映运行时技能增删)
|
||||
let skills_index = if def.capability.has_skill_policy() {
|
||||
self.skills.system_index_prompt_filtered(
|
||||
def.capability.allowed_skills.as_deref(),
|
||||
&def.capability.denied_skills,
|
||||
)
|
||||
.map_err(|e| {
|
||||
TaskError::AgentCreationFailed(format!(
|
||||
"subagent '{}' model resolution failed: {}",
|
||||
def.name, e
|
||||
))
|
||||
})?,
|
||||
_ => self.provider_config.clone(),
|
||||
};
|
||||
let system_prompt = SubagentPromptBuilder::build(
|
||||
&def,
|
||||
&task.description,
|
||||
&task.prompt,
|
||||
&effective_provider_config,
|
||||
skills_index.as_deref(),
|
||||
);
|
||||
|
||||
// 7. 创建子代理
|
||||
let agent = match self.create_subagent(
|
||||
&session,
|
||||
system_prompt,
|
||||
Some(&def),
|
||||
parent_context.nesting_depth,
|
||||
parent_context.task_id.clone(),
|
||||
parent_context.trace_id.clone(),
|
||||
) {
|
||||
Ok(agent) => agent,
|
||||
Err(e) => {
|
||||
let trace_id = parent_context.trace_id.as_deref().unwrap_or("");
|
||||
return self.handle_task_failure(session, e, trace_id).await;
|
||||
}
|
||||
};
|
||||
|
||||
let trace_id = parent_context.trace_id.as_deref().unwrap_or("");
|
||||
|
||||
// 8. 判断执行模式:异步(主 agent + 有 sub_done_sender)或同步(子代理/无 sender)
|
||||
let is_async_mode = parent_context.nesting_depth == 0
|
||||
&& parent_context.sub_done_sender.is_some()
|
||||
&& session.parent_topic_id.is_some();
|
||||
|
||||
if is_async_mode {
|
||||
// ===== 异步路径 =====
|
||||
let topic_id = session.parent_topic_id.clone().unwrap_or_default();
|
||||
let task_id = session.id.clone();
|
||||
|
||||
// 8a. INSERT pending_subagents 记录
|
||||
let pending_record = PendingSubagentRecord {
|
||||
task_id: task_id.clone(),
|
||||
parent_session_id: session.parent_session_id.clone(),
|
||||
parent_topic_id: topic_id.clone(),
|
||||
parent_chat_id: session.parent_chat_id.clone(),
|
||||
parent_channel: session.parent_channel_name.clone(),
|
||||
def_name: Some(def.name.clone()),
|
||||
spawned_at: current_timestamp(),
|
||||
status: "running".to_string(),
|
||||
} else {
|
||||
self.skills.system_index_prompt()
|
||||
};
|
||||
if let Err(e) = self.store.insert_pending_subagent(&pending_record) {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
task_id = %task_id,
|
||||
"Failed to insert pending_subagent record"
|
||||
);
|
||||
}
|
||||
// 同步解析 def 中的 provider/model 覆盖,保证环境提示中的模型名与实际使用的模型一致
|
||||
let effective_provider_config = match (def.provider.is_some(), def.model.is_some()) {
|
||||
(true, _) | (_, true) => self
|
||||
.model_resolver
|
||||
.resolve(
|
||||
def.provider.as_deref(),
|
||||
def.model.as_deref(),
|
||||
&self.provider_config,
|
||||
)
|
||||
.map_err(|e| {
|
||||
TaskError::AgentCreationFailed(format!(
|
||||
"subagent '{}' model resolution failed: {}",
|
||||
def.name, e
|
||||
))
|
||||
})?,
|
||||
_ => self.provider_config.clone(),
|
||||
};
|
||||
let system_prompt = SubagentPromptBuilder::build(
|
||||
&def,
|
||||
&task.description,
|
||||
&task.prompt,
|
||||
&effective_provider_config,
|
||||
skills_index.as_deref(),
|
||||
);
|
||||
|
||||
// 8b. tokio::spawn 后台执行子代理
|
||||
let store = self.store.clone();
|
||||
let task_repository = self.task_repository.clone();
|
||||
let bus = self.bus.clone();
|
||||
let sub_done_sender = parent_context.sub_done_sender.clone().unwrap();
|
||||
let session_clone = session.clone();
|
||||
let def_clone = def.clone();
|
||||
let prompt = task.prompt.clone();
|
||||
let trace_id_owned = trace_id.to_string();
|
||||
let max_secs = self.effective_max_execution_secs(&def);
|
||||
let task_id_for_spawn = task_id.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
let cancel_registry = self.cancel_registry.clone();
|
||||
// 7. 创建子代理
|
||||
let agent = self.create_subagent(
|
||||
&session,
|
||||
system_prompt,
|
||||
Some(&def),
|
||||
parent_context.nesting_depth,
|
||||
parent_context.task_id.clone(),
|
||||
)?;
|
||||
|
||||
// 创建 CancellationToken 并注册到 registry(供 /stop 取消传播)
|
||||
let cancel_token = tokio_util::sync::CancellationToken::new();
|
||||
cancel_registry
|
||||
.lock()
|
||||
.insert(task_id_for_spawn.clone(), cancel_token.clone());
|
||||
|
||||
// RAII guard:spawn 任务退出时(正常/early return/panic)确定性清理 registry
|
||||
// 不变量 3:清理与作用域绑定,避免末行清理被 panic 绕过
|
||||
let registry_guard =
|
||||
CancelRegistryGuard::new(task_id_for_spawn.clone(), cancel_registry.clone());
|
||||
|
||||
tokio::spawn(async move {
|
||||
// guard 在闭包退出时 drop,确定性清理 cancel_registry 条目
|
||||
let _registry_guard = registry_guard;
|
||||
|
||||
// 获取并发许可(Semaphore 限流)
|
||||
let _permit = match semaphore.acquire_owned().await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
// 不变量 1:状态机收敛性 — early return 也必须收敛终态
|
||||
// 发送 Failed 结果让 wait 收到,更新 DB 状态,否则系统出现悬空记录
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
task_id = %task_id_for_spawn,
|
||||
"Semaphore closed, subagent cannot start; converging state machine to Failed"
|
||||
);
|
||||
let result = SubagentResult {
|
||||
task_id: task_id_for_spawn.clone(),
|
||||
status: SubagentStatus::Failed,
|
||||
output: String::new(),
|
||||
pending_task_ids: store
|
||||
.list_pending_subagents(&topic_id, Some("running"))
|
||||
.map(|records| {
|
||||
records
|
||||
.into_iter()
|
||||
.map(|r| r.task_id)
|
||||
.filter(|id| id != &task_id_for_spawn)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
let _ = sub_done_sender.send(result).await;
|
||||
let _ =
|
||||
store.update_pending_subagent_status(&task_id_for_spawn, "failed");
|
||||
// _registry_guard drop 时清理 registry 条目
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// select! 等待执行完成或取消信号
|
||||
let exec_result = tokio::select! {
|
||||
biased;
|
||||
_ = cancel_token.cancelled() => {
|
||||
tracing::info!(
|
||||
task_id = %task_id_for_spawn,
|
||||
"Subagent cancelled by user"
|
||||
);
|
||||
Err(TaskError::Cancelled)
|
||||
}
|
||||
r = Self::execute_task_static(
|
||||
agent,
|
||||
&session_clone,
|
||||
&def_clone,
|
||||
prompt,
|
||||
max_secs,
|
||||
) => r,
|
||||
};
|
||||
|
||||
// 完成回调:查询未完成 → send SubagentResult → UPDATE status
|
||||
let (status, output, _summary) = match &exec_result {
|
||||
Ok(tool_result) => (
|
||||
SubagentStatus::Completed,
|
||||
serde_json::to_string(&tool_result).unwrap_or_default(),
|
||||
tool_result.summary.clone(),
|
||||
),
|
||||
Err(TaskError::Timeout) => (
|
||||
SubagentStatus::Timeout,
|
||||
String::new(),
|
||||
"timeout".to_string(),
|
||||
),
|
||||
Err(TaskError::Cancelled) => (
|
||||
SubagentStatus::Cancelled,
|
||||
String::new(),
|
||||
"cancelled".to_string(),
|
||||
),
|
||||
Err(e) => (
|
||||
SubagentStatus::Failed,
|
||||
String::new(),
|
||||
e.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
// 查询同 topic 下仍未完成的子代理列表
|
||||
let pending_task_ids = store
|
||||
.list_pending_subagents(&topic_id, Some("running"))
|
||||
.map(|records| {
|
||||
records
|
||||
.into_iter()
|
||||
.map(|r| r.task_id)
|
||||
.filter(|id| id != &task_id_for_spawn)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// 发送 SubagentResult 到 sub_done_q
|
||||
let result = SubagentResult {
|
||||
task_id: task_id_for_spawn.clone(),
|
||||
status,
|
||||
output,
|
||||
pending_task_ids,
|
||||
};
|
||||
if let Err(e) = sub_done_sender.send(result).await {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
task_id = %task_id_for_spawn,
|
||||
"Failed to send SubagentResult to sub_done_q (receiver dropped?)"
|
||||
);
|
||||
}
|
||||
|
||||
// UPDATE pending_subagents 状态
|
||||
let status_str = match status {
|
||||
SubagentStatus::Completed => "completed",
|
||||
SubagentStatus::Failed => "failed",
|
||||
SubagentStatus::Timeout => "timeout",
|
||||
SubagentStatus::Cancelled => "cancelled",
|
||||
};
|
||||
if let Err(e) = store.update_pending_subagent_status(&task_id_for_spawn, status_str) {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
task_id = %task_id_for_spawn,
|
||||
"Failed to update pending_subagent status"
|
||||
);
|
||||
}
|
||||
|
||||
// 更新 TaskSession 状态并发布完成事件
|
||||
let mut session_done = session_clone;
|
||||
match exec_result {
|
||||
Ok(tool_result) => {
|
||||
session_done.mark_completed(tool_result.summary);
|
||||
if let Err(e) = task_repository.save_task_session(&session_done).await {
|
||||
tracing::warn!(error = %e, task_id = %task_id_for_spawn, "Failed to save completed session");
|
||||
}
|
||||
publish_subagent_completion(&bus, &session_done, &trace_id_owned).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
if matches!(e, TaskError::Timeout) {
|
||||
session_done.mark_timeout();
|
||||
} else if matches!(e, TaskError::Cancelled) {
|
||||
session_done.mark_cancelled();
|
||||
} else {
|
||||
session_done.mark_failed(err_str);
|
||||
}
|
||||
if let Err(e) = task_repository.save_task_session(&session_done).await {
|
||||
tracing::warn!(error = %e, task_id = %task_id_for_spawn, "Failed to save failed session");
|
||||
}
|
||||
publish_subagent_error(&bus, &session_done, &e.to_string(), &trace_id_owned).await;
|
||||
}
|
||||
}
|
||||
// _registry_guard 在此 drop,确定性清理 cancel_registry 条目
|
||||
// (替代原末行手动 remove,覆盖 panic/early return 全路径)
|
||||
});
|
||||
|
||||
// 8c. 立即返回 running 占位结果
|
||||
// 注意: summary 留空,output 只含引导信息。LLM 看到 running 后应调 wait_for_subagents。
|
||||
return Ok(TaskToolResult {
|
||||
status: "running".to_string(),
|
||||
summary: format!("Task {} spawned asynchronously", task_id),
|
||||
output: format!(
|
||||
"running, task_id={}. Call wait_for_subagents(timeout_secs) to wait for subagent completion and get results.",
|
||||
task_id
|
||||
),
|
||||
task_id,
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 同步路径(子代理嵌套或无 sub_done_sender) =====
|
||||
// 9. 执行任务并处理结果
|
||||
let result = self.execute_task(agent, &session, &def, task.prompt.clone()).await;
|
||||
// 8. 执行任务
|
||||
self.execute_task(agent, &session, &def, task.prompt.clone())
|
||||
.await
|
||||
};
|
||||
|
||||
// 9. 更新会话状态并保存
|
||||
match result {
|
||||
Ok(tool_result) => {
|
||||
let mut session = session;
|
||||
@ -1203,13 +863,13 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
||||
);
|
||||
self.task_repository.save_task_session(&session).await?;
|
||||
// 发布子智能体 ExecutionCompleted,metadata 注入 subagent_task_id 供前端路由到对应子智能体层
|
||||
publish_subagent_completion(&self.bus, &session, trace_id).await;
|
||||
publish_subagent_completion(&self.bus, &session).await;
|
||||
Ok(tool_result)
|
||||
}
|
||||
Err(e) => {
|
||||
// 会话创建后的任何失败(含 AgentCreationFailed、Timeout、ExecutionFailed)
|
||||
// 统一返回结构化结果,携带 task_id 供前端导航
|
||||
self.handle_task_failure(session, e, trace_id).await
|
||||
self.handle_task_failure(session, e).await
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1279,26 +939,24 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
||||
Some(&def),
|
||||
parent_context.nesting_depth,
|
||||
parent_context.task_id.clone(),
|
||||
parent_context.trace_id.clone(),
|
||||
)?;
|
||||
self.execute_task_with_history(agent, &session, additional_prompt)
|
||||
.await
|
||||
};
|
||||
|
||||
// 7. 更新会话状态
|
||||
let trace_id = parent_context.trace_id.as_deref().unwrap_or("");
|
||||
match result {
|
||||
Ok(tool_result) => {
|
||||
let mut session = session;
|
||||
session.mark_completed(tool_result.summary.clone());
|
||||
self.task_repository.save_task_session(&session).await?;
|
||||
// 发布子智能体 ExecutionCompleted,metadata 注入 subagent_task_id 供前端路由到对应子智能体层
|
||||
publish_subagent_completion(&self.bus, &session, trace_id).await;
|
||||
publish_subagent_completion(&self.bus, &session).await;
|
||||
Ok(tool_result)
|
||||
}
|
||||
Err(e) => {
|
||||
// 修复:原代码一律 mark_failed,未处理 timeout;现统一走 handle_task_failure
|
||||
self.handle_task_failure(session, e, trace_id).await
|
||||
self.handle_task_failure(session, e).await
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1321,75 +979,6 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
||||
fn available_subagent_names(&self) -> Vec<String> {
|
||||
self.subagent_runtime.available_names()
|
||||
}
|
||||
|
||||
async fn cancel_pending_for_topic(&self, topic_id: &str) -> usize {
|
||||
// 查询该 topic 下所有 running 的子代理
|
||||
let running = match self.store.list_pending_subagents(topic_id, Some("running")) {
|
||||
Ok(records) => records,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
topic_id = %topic_id,
|
||||
"Failed to list pending subagents for cancellation"
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
let count = running.len();
|
||||
if count == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
topic_id = %topic_id,
|
||||
count,
|
||||
"Cancelling pending subagents for topic"
|
||||
);
|
||||
|
||||
// 触发每个子代理的 CancellationToken
|
||||
let registry = self.cancel_registry.lock();
|
||||
for record in &running {
|
||||
if let Some(token) = registry.get(&record.task_id) {
|
||||
token.cancel();
|
||||
tracing::info!(
|
||||
task_id = %record.task_id,
|
||||
"Cancelled subagent token"
|
||||
);
|
||||
} else {
|
||||
// token 不在 registry 中(可能已完成但 DB 状态未更新,或进程重启后丢失)
|
||||
// 不变量 1:条件 UPDATE,仅在 status='running' 时转为 cancelled,
|
||||
// 避免 spawn 已完成的终态被覆盖(completed → cancelled 是非法转换)
|
||||
match self
|
||||
.store
|
||||
.try_update_pending_subagent_status(&record.task_id, "running", "cancelled")
|
||||
{
|
||||
Ok(true) => {
|
||||
tracing::info!(
|
||||
task_id = %record.task_id,
|
||||
"Marked subagent as cancelled in DB (token not in registry)"
|
||||
);
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::info!(
|
||||
task_id = %record.task_id,
|
||||
"Subagent status already updated by another path, skip cancel"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
task_id = %record.task_id,
|
||||
"Failed to mark subagent as cancelled in DB"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(registry);
|
||||
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
/// 子代理定义目录
|
||||
|
||||
@ -17,8 +17,6 @@ pub enum TaskSessionState {
|
||||
Failed,
|
||||
/// 已超时
|
||||
Timeout,
|
||||
/// 已取消(用户 /stop 传播)
|
||||
Cancelled,
|
||||
/// 状态未知(如重启后从 DB 重建时无法可靠推断原状态)
|
||||
Unknown,
|
||||
}
|
||||
@ -29,19 +27,6 @@ impl Default for TaskSessionState {
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskSessionState {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Running => "running",
|
||||
Self::Completed => "completed",
|
||||
Self::Failed => "failed",
|
||||
Self::Timeout => "timeout",
|
||||
Self::Cancelled => "cancelled",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 子代理来源
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@ -238,13 +223,6 @@ impl TaskSession {
|
||||
self.error = Some("Task execution timed out".to_string());
|
||||
self.updated_at = current_timestamp();
|
||||
}
|
||||
|
||||
/// 标记取消
|
||||
pub fn mark_cancelled(&mut self) {
|
||||
self.state = TaskSessionState::Cancelled;
|
||||
self.error = Some("Task cancelled by user".to_string());
|
||||
self.updated_at = current_timestamp();
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务工具参数
|
||||
@ -293,7 +271,7 @@ pub struct TaskHandle {
|
||||
/// 任务执行结果
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TaskToolResult {
|
||||
/// 状态: success/failed/timeout/running
|
||||
/// 状态: success/failed/timeout
|
||||
pub status: String,
|
||||
/// 任务完成总结
|
||||
pub summary: String,
|
||||
@ -302,26 +280,3 @@ pub struct TaskToolResult {
|
||||
/// 会话 ID(用于恢复)
|
||||
pub task_id: String,
|
||||
}
|
||||
|
||||
/// 异步子代理完成状态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SubagentStatus {
|
||||
Completed,
|
||||
Failed,
|
||||
Timeout,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// 异步子代理完成回调内容(通过 sub_done_q 传递给 wait 工具)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubagentResult {
|
||||
/// 完成的子代理 task_id
|
||||
pub task_id: String,
|
||||
/// 完成状态
|
||||
pub status: SubagentStatus,
|
||||
/// 子代理输出(与 TaskToolResult.output 格式一致)
|
||||
pub output: String,
|
||||
/// 仍未完成的子代理 task_id 列表(供 LLM 判断全局进度)
|
||||
pub pending_task_ids: Vec<String>,
|
||||
}
|
||||
|
||||
@ -184,8 +184,6 @@ mod tests {
|
||||
parent_task_id: None,
|
||||
tool_call_id: None,
|
||||
parent_capability: None,
|
||||
trace_id: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -486,8 +486,6 @@ mod tests {
|
||||
parent_task_id: None,
|
||||
tool_call_id: None,
|
||||
parent_capability: None,
|
||||
trace_id: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,11 +1,6 @@
|
||||
use std::time::Duration;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
|
||||
use crate::domain::CapabilityPolicy;
|
||||
use crate::tools::task::SubagentResult;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolResult {
|
||||
@ -14,58 +9,7 @@ pub struct ToolResult {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// wait_for_subagents 工具等待期间的事件。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WaitEvent {
|
||||
/// 一个子代理完成,携带其结果
|
||||
SubagentResult(SubagentResult),
|
||||
/// wait 期间有新用户消息到达(已注入 history),携带新用户消息的内容列表
|
||||
UserMessage(Vec<String>),
|
||||
/// 等待超时
|
||||
Timeout,
|
||||
/// 收到取消信号(/stop),等待已优雅终止,状态已清理完毕
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// wait_for_subagents 工具的协调器接口。
|
||||
///
|
||||
/// 封装了释放/重获取 serial_lock + select! 等待 + 管理等待状态的逻辑,
|
||||
/// 使 wait 工具不直接依赖 gateway 模块(避免循环依赖)。
|
||||
///
|
||||
/// 具体实现 `SessionWaitCoordinator` 在 gateway 模块中,持有 Session 引用。
|
||||
#[async_trait]
|
||||
pub trait WaitCoordinator: Send + Sync + 'static {
|
||||
/// 查询当前 topic 下仍处于 running 状态的子代理 task_id 列表。
|
||||
fn query_pending_task_ids(&self) -> Vec<String>;
|
||||
|
||||
/// 尝试排空 sub_done_q 中已缓冲的子代理结果(非阻塞 drain,但方法本身是 async
|
||||
/// 因为需要获取 Session 锁)。
|
||||
///
|
||||
/// 用于两种场景:
|
||||
/// 1. wait_for_subagents 入口处:即使 DB 中无 running 子代理,
|
||||
/// 队列可能仍缓冲了已完成子代理的结果(子代理完成 → send 到队列 →
|
||||
/// DB 更新为 completed,但 LLM 上轮未消费队列)。
|
||||
/// 2. wait() 进入 select! 前:多个子代理同时完成时,批量消费避免
|
||||
/// 每个结果各触发一次 LLM 调用。
|
||||
async fn try_drain_queued_results(&self) -> Vec<SubagentResult>;
|
||||
|
||||
/// 进入等待状态:释放 serial_lock → select! → 重获取 serial_lock。
|
||||
///
|
||||
/// 调用前提:serial_lock 已被执行路径获取,guard 存于 coordinator 内部。
|
||||
/// 返回后:serial_lock 已被重新获取,waiting 标志已清除。
|
||||
///
|
||||
/// `cancel_rx`:可选的取消信号接收端。当收到信号时(/stop 命令),
|
||||
/// select! 立即返回 `WaitEvent::Cancelled`,并完成完整的状态清理
|
||||
///(重获取锁、回填 guard、清除 is_waiting、归还 receiver)。
|
||||
/// 为 None 时退化为不检查取消(向后兼容,子代理场景)。
|
||||
async fn wait(
|
||||
&self,
|
||||
timeout: Duration,
|
||||
cancel_rx: Option<watch::Receiver<()>>,
|
||||
) -> WaitEvent;
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ToolContext {
|
||||
pub channel_name: Option<String>,
|
||||
pub sender_id: Option<String>,
|
||||
@ -88,49 +32,6 @@ pub struct ToolContext {
|
||||
/// TaskTool 据此强制校验子代理加载(白/黑名单),与 spawn/resume 安全范式一致。
|
||||
/// 以数据形式传递,避免 task 模块反向依赖 experts 模块。
|
||||
pub parent_capability: Option<CapabilityPolicy>,
|
||||
/// 端到端追踪 ID(从 InboundMessage 继承,用于 tool 执行路径的日志关联)。
|
||||
/// None 表示无追踪上下文(如子代理独立执行或测试环境)。
|
||||
pub trace_id: Option<String>,
|
||||
/// 异步子代理完成队列的 sender(按 topic 隔离)。
|
||||
/// 仅主 agent(nesting_depth=0)有值:agent_factory 构建时从 SessionHistory 注入。
|
||||
/// TaskTool spawn 异步子代理后,子代理完成时通过此 sender 发送 SubagentResult,
|
||||
/// 由 wait_for_subagents 工具的 receiver 端消费。
|
||||
/// 子代理自身(nesting_depth>0)为 None:嵌套层不支持异步,走同步路径。
|
||||
pub sub_done_sender: Option<mpsc::Sender<SubagentResult>>,
|
||||
/// wait_for_subagents 工具的协调器(仅主 agent 有值)。
|
||||
/// 封装了释放/重获取 serial_lock + select! 等待逻辑。
|
||||
/// wait 工具通过此接口实现真等待(释放锁让 process_one 注入用户消息)。
|
||||
pub wait_coordinator: Option<Arc<dyn WaitCoordinator>>,
|
||||
/// 取消信号接收端(仅主 agent 有值,由 agent_factory 从 cancel_token clone 注入)。
|
||||
/// wait_for_subagents 工具将其传给 coordinator.wait() 的 select!,
|
||||
/// 使 /stop 命令能立即中断等待并完成状态清理。
|
||||
/// watch::Receiver 可安全 clone,多个 receiver 共享同一 sender,
|
||||
/// 互不影响各自的 has_changed() / changed() 状态。
|
||||
pub cancel_rx: Option<watch::Receiver<()>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ToolContext {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ToolContext")
|
||||
.field("channel_name", &self.channel_name)
|
||||
.field("sender_id", &self.sender_id)
|
||||
.field("chat_id", &self.chat_id)
|
||||
.field("session_id", &self.session_id)
|
||||
.field("topic_id", &self.topic_id)
|
||||
.field("message_id", &self.message_id)
|
||||
.field("message_seq", &self.message_seq)
|
||||
.field("subagent_description", &self.subagent_description)
|
||||
.field("nesting_depth", &self.nesting_depth)
|
||||
.field("task_id", &self.task_id)
|
||||
.field("parent_task_id", &self.parent_task_id)
|
||||
.field("tool_call_id", &self.tool_call_id)
|
||||
.field("parent_capability", &self.parent_capability)
|
||||
.field("trace_id", &self.trace_id)
|
||||
.field("sub_done_sender", &self.sub_done_sender)
|
||||
.field("wait_coordinator", &self.wait_coordinator.is_some())
|
||||
.field("cancel_rx", &self.cancel_rx.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@ -1,250 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::tools::{Tool, ToolContext, ToolResult, WaitEvent};
|
||||
|
||||
/// wait_for_subagents 工具 — 等待异步子代理完成或用户消息到达。
|
||||
///
|
||||
/// 调用后释放 serial_lock,进入 select! 等待:
|
||||
/// - 子代理完成 → 返回结果 + 未完成列表
|
||||
/// - 用户消息到达 → 返回 "有新用户消息"(消息已注入 history)
|
||||
/// - 超时 → 返回超时 + 未完成列表
|
||||
///
|
||||
/// 等待结束后重新获取 serial_lock,保证后续工具调用串行。
|
||||
pub struct WaitForSubagentsTool {
|
||||
/// 默认超时(秒),LLM 未指定时使用
|
||||
default_timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl WaitForSubagentsTool {
|
||||
pub const TOOL_NAME: &'static str = "wait_for_subagents";
|
||||
|
||||
pub fn new(default_timeout_secs: u64) -> Self {
|
||||
Self {
|
||||
default_timeout_secs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WaitForSubagentsTool {
|
||||
fn name(&self) -> &str {
|
||||
Self::TOOL_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Wait for asynchronous subagents to complete, or for new user messages to arrive. \
|
||||
Use this after launching subagents via the task tool to receive their results. \
|
||||
Returns the first completed subagent's result and a list of still-pending task IDs. \
|
||||
If pending_task_ids is non-empty, call this tool again to wait for the next one."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timeout_secs": {
|
||||
"type": "integer",
|
||||
"description": "Maximum seconds to wait. Default 60. The tool returns early if a subagent completes or a user message arrives.",
|
||||
"default": 60
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
})
|
||||
}
|
||||
|
||||
fn read_only(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn exclusive(&self) -> bool {
|
||||
// wait 工具释放/重获取 serial_lock,不应与其他工具并发
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(
|
||||
"wait_for_subagents requires tool context with wait_coordinator".to_string(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_with_context(
|
||||
&self,
|
||||
context: &ToolContext,
|
||||
args: serde_json::Value,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
// 1. 获取 wait_coordinator(仅主 agent 有值)
|
||||
let coordinator = match &context.wait_coordinator {
|
||||
Some(c) => c.clone(),
|
||||
None => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(
|
||||
"wait_for_subagents is not available in this context (no wait_coordinator)"
|
||||
.to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 2. 解析超时参数
|
||||
let timeout_secs = args
|
||||
.get("timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(self.default_timeout_secs);
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
|
||||
// 3. 先尝试排空队列中已缓冲的结果
|
||||
// 场景:子代理已完成 → 结果 send 到队列 + DB 更新为 completed,
|
||||
// 但 LLM 上轮未调用 wait(或 wait 超时未消费)→ 结果缓冲在队列中。
|
||||
// 若不排空,query_pending_task_ids 返回空(DB 已非 running)→
|
||||
// 返回 "No pending" → 缓冲结果永远丢失。
|
||||
let drained = coordinator.try_drain_queued_results().await;
|
||||
if !drained.is_empty() {
|
||||
let pending_after = coordinator.query_pending_task_ids();
|
||||
let pending_str = if pending_after.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
pending_after.join(", ")
|
||||
};
|
||||
let formatted: Vec<String> = drained
|
||||
.iter()
|
||||
.map(|r| {
|
||||
format!(
|
||||
"Subagent {} completed (status: {:?}). Output: {}",
|
||||
r.task_id, r.status, r.output
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
return Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!(
|
||||
"Retrieved {} buffered subagent result(s):\n{}\nStill pending: [{}]",
|
||||
drained.len(),
|
||||
formatted.join("\n"),
|
||||
pending_str
|
||||
),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 查询 pending 子代理
|
||||
let pending = coordinator.query_pending_task_ids();
|
||||
if pending.is_empty() {
|
||||
return Ok(ToolResult {
|
||||
success: true,
|
||||
output: "No pending subagents to wait for.".to_string(),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
topic_id = ?context.topic_id,
|
||||
pending_count = pending.len(),
|
||||
timeout_secs,
|
||||
"wait_for_subagents: entering wait"
|
||||
);
|
||||
|
||||
// 4. 进入等待(coordinator 内部:释放锁 → select! → 重获取锁)
|
||||
// 传入 cancel_rx 使 /stop 命令能立即中断等待。
|
||||
// coordinator 在 select! 中以 biased 优先级处理:
|
||||
// 子代理结果 > 用户消息 > 取消信号 > 超时
|
||||
let event = coordinator
|
||||
.wait(timeout, context.cancel_rx.clone())
|
||||
.await;
|
||||
|
||||
// 5. 格式化返回结果
|
||||
let output = match event {
|
||||
WaitEvent::SubagentResult(result) => {
|
||||
let pending_str = if result.pending_task_ids.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
result.pending_task_ids.join(", ")
|
||||
};
|
||||
format!(
|
||||
"Subagent {} completed (status: {:?}). Output: {}\nStill pending: [{}]",
|
||||
result.task_id, result.status, result.output, pending_str
|
||||
)
|
||||
}
|
||||
WaitEvent::UserMessage(messages) => {
|
||||
let pending = coordinator.query_pending_task_ids();
|
||||
let pending_str = if pending.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
pending.join(", ")
|
||||
};
|
||||
if messages.is_empty() {
|
||||
format!(
|
||||
"A new user message arrived while waiting (content could not be retrieved). \
|
||||
Still pending subagents: [{}]",
|
||||
pending_str
|
||||
)
|
||||
} else {
|
||||
let formatted_msgs: Vec<String> = messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, msg)| format!(" [{}] {}", i + 1, msg))
|
||||
.collect();
|
||||
format!(
|
||||
"New user message(s) arrived while waiting:\n{}\n\
|
||||
These messages have been added to the conversation history. \
|
||||
Still pending subagents: [{}]",
|
||||
formatted_msgs.join("\n"),
|
||||
pending_str
|
||||
)
|
||||
}
|
||||
}
|
||||
WaitEvent::Timeout => {
|
||||
let pending = coordinator.query_pending_task_ids();
|
||||
format!(
|
||||
"Wait timed out after {}s. Still pending subagents: [{}]",
|
||||
timeout_secs,
|
||||
if pending.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
pending.join(", ")
|
||||
}
|
||||
)
|
||||
}
|
||||
WaitEvent::Cancelled => {
|
||||
// /stop 命令中断了等待。coordinator 已完成全部状态清理
|
||||
//(重获取 serial_lock、回填 guard、清除 is_waiting、归还 receiver)。
|
||||
// 返回提示性输出,Agent 下一轮迭代会检测到 cancel 并退出。
|
||||
let pending = coordinator.query_pending_task_ids();
|
||||
tracing::info!(
|
||||
topic_id = ?context.topic_id,
|
||||
pending_count = pending.len(),
|
||||
"wait_for_subagents: cancelled by /stop"
|
||||
);
|
||||
format!(
|
||||
"Wait was cancelled by /stop command. \
|
||||
Pending subagents (if any) have been cancelled separately. \
|
||||
Still pending in DB: [{}]",
|
||||
if pending.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
pending.join(", ")
|
||||
}
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
topic_id = ?context.topic_id,
|
||||
"wait_for_subagents: returning result"
|
||||
);
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
38
src/utils.rs
38
src/utils.rs
@ -11,20 +11,6 @@ pub fn current_timestamp() -> i64 {
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
/// 从 `catch_unwind` 的 panic payload 中提取可读消息。
|
||||
///
|
||||
/// `panic!` 的 payload 通常是 `&str` 或 `String`;其他类型(如直接
|
||||
/// `panic!(42)`)无法还原原文,返回占位描述。
|
||||
pub fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
if let Some(s) = payload.downcast_ref::<&str>() {
|
||||
return (*s).to_string();
|
||||
}
|
||||
if let Some(s) = payload.downcast_ref::<String>() {
|
||||
return s.clone();
|
||||
}
|
||||
"<non-string panic payload>".to_string()
|
||||
}
|
||||
|
||||
/// 递归展开 `error.source()` 链,生成 `"顶层错误\ncaused by: 原因\ncaused by: ..."` 格式的字符串。
|
||||
pub fn format_error_chain(error: &(dyn std::error::Error + 'static)) -> String {
|
||||
let mut details = vec![error.to_string()];
|
||||
@ -37,27 +23,3 @@ pub fn format_error_chain(error: &(dyn std::error::Error + 'static)) -> String {
|
||||
|
||||
details.join("\ncaused by: ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::any::Any;
|
||||
|
||||
#[test]
|
||||
fn test_panic_payload_message_str() {
|
||||
let payload: Box<dyn Any + Send> = Box::new("boom");
|
||||
assert_eq!(panic_payload_message(&*payload), "boom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_panic_payload_message_string() {
|
||||
let payload: Box<dyn Any + Send> = Box::new(String::from("boom"));
|
||||
assert_eq!(panic_payload_message(&*payload), "boom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_panic_payload_message_other_type() {
|
||||
let payload: Box<dyn Any + Send> = Box::new(42u32);
|
||||
assert!(panic_payload_message(&*payload).contains("non-string"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -611,8 +611,8 @@ function App() {
|
||||
const viewKey = useMemo(() => {
|
||||
if (schedulerView) return `scheduler:${schedulerView.jobId}`;
|
||||
if (subAgentView) return `subagent:${subAgentView.taskId}`;
|
||||
return `main:${selectedTopic ?? ''}`;
|
||||
}, [schedulerView, subAgentView, selectedTopic]);
|
||||
return 'main';
|
||||
}, [schedulerView, subAgentView]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-[var(--bg-primary)] text-[var(--text-primary)] overflow-hidden">
|
||||
@ -906,9 +906,9 @@ function App() {
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ChatContainer
|
||||
key={selectedTopic ?? 'no-topic'}
|
||||
messages={chatMessages}
|
||||
isLoading={isLoading}
|
||||
topicId={selectedTopic}
|
||||
isReadOnly={subAgentView || schedulerView ? true : isReadOnly}
|
||||
channelName={
|
||||
schedulerView
|
||||
|
||||
@ -24,8 +24,6 @@ interface ChatContainerProps {
|
||||
onOpenSettings?: () => void;
|
||||
/** 设置弹窗关闭信号(每次关闭递增,用于触发 ExpertSelector 刷新) */
|
||||
settingsClosedTick?: number;
|
||||
/** 当前话题 ID,用于切换话题时清空输入框草稿 */
|
||||
topicId?: string | null;
|
||||
}
|
||||
|
||||
export function ChatContainer({
|
||||
@ -42,7 +40,6 @@ export function ChatContainer({
|
||||
sessionId,
|
||||
onOpenSettings,
|
||||
settingsClosedTick,
|
||||
topicId,
|
||||
}: ChatContainerProps) {
|
||||
const [selectedExpert, setSelectedExpert] = useState<{
|
||||
name: string;
|
||||
@ -77,7 +74,6 @@ export function ChatContainer({
|
||||
isReadOnly={isReadOnly}
|
||||
channelName={channelName}
|
||||
selectedExpert={selectedExpert}
|
||||
topicId={topicId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -34,14 +34,13 @@ function StatusIcon({
|
||||
status,
|
||||
size = 14,
|
||||
}: {
|
||||
status: string;
|
||||
status: 'calling' | 'result' | 'pending' | 'success' | 'failed' | 'timeout';
|
||||
size?: number;
|
||||
}) {
|
||||
const iconClass = `transition-all duration-300`;
|
||||
|
||||
switch (status) {
|
||||
case 'calling':
|
||||
case 'running':
|
||||
return (
|
||||
<Loader2
|
||||
className={`${iconClass} animate-spin`}
|
||||
@ -51,7 +50,6 @@ function StatusIcon({
|
||||
);
|
||||
case 'result':
|
||||
case 'success':
|
||||
case 'completed':
|
||||
return (
|
||||
<CheckCircle
|
||||
className={`${iconClass} animate-scale-in`}
|
||||
@ -76,8 +74,6 @@ function StatusIcon({
|
||||
/>
|
||||
);
|
||||
case 'pending':
|
||||
case 'interrupted':
|
||||
case 'cancelled':
|
||||
return (
|
||||
<Loader
|
||||
className={`${iconClass} animate-spin`}
|
||||
@ -441,49 +437,19 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
((message.arguments as Record<string, unknown> | null)?.prompt as string) || '';
|
||||
|
||||
// task tool 专用的状态配色
|
||||
// 支持的状态:
|
||||
// - running: 异步子代理刚 spawn,占位结果
|
||||
// - success/completed: 子代理执行成功
|
||||
// - failed: 子代理执行失败
|
||||
// - timeout: 子代理执行超时
|
||||
// - cancelled: 子代理被用户取消
|
||||
// - interrupted: 子代理因服务器重启被中断
|
||||
const taskStatusConfig: Record<string, { dot: string; borderColor: string; iconColor: string }> = {
|
||||
running: {
|
||||
dot: 'bg-amber-400 animate-pulse',
|
||||
borderColor: 'border-amber-500/40',
|
||||
iconColor: 'text-amber-400',
|
||||
},
|
||||
const taskStatusConfig = {
|
||||
success: {
|
||||
dot: 'bg-emerald-400',
|
||||
borderColor: 'border-emerald-500/40',
|
||||
iconColor: 'text-emerald-400',
|
||||
},
|
||||
completed: {
|
||||
dot: 'bg-emerald-400',
|
||||
borderColor: 'border-emerald-500/40',
|
||||
iconColor: 'text-emerald-400',
|
||||
},
|
||||
failed: { dot: 'bg-red-400', borderColor: 'border-red-500/40', iconColor: 'text-red-400' },
|
||||
timeout: {
|
||||
dot: 'bg-amber-400',
|
||||
borderColor: 'border-amber-500/40',
|
||||
iconColor: 'text-amber-400',
|
||||
},
|
||||
cancelled: {
|
||||
dot: 'bg-zinc-400',
|
||||
borderColor: 'border-zinc-500/40',
|
||||
iconColor: 'text-zinc-400',
|
||||
},
|
||||
interrupted: {
|
||||
dot: 'bg-orange-400',
|
||||
borderColor: 'border-orange-500/40',
|
||||
iconColor: 'text-orange-400',
|
||||
},
|
||||
};
|
||||
|
||||
// 安全获取 task 状态配色,未知状态回退到默认(避免 undefined.borderColor 崩溃)
|
||||
const taskStyle = taskResult ? (taskStatusConfig[taskResult.status] ?? taskStatusConfig.failed) : null;
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div data-message-id={message.id} className="flex gap-3 animate-slide-in">
|
||||
@ -515,14 +481,14 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
<div
|
||||
onClick={() => setToolExpanded(!toolExpanded)}
|
||||
className={`cursor-pointer rounded-xl border bg-[var(--bg-tertiary)]/60 w-full transition-all duration-500 hover:bg-[var(--bg-tertiary)]/80 group ${
|
||||
taskStyle ? taskStyle.borderColor : statusConfig.fullBorder
|
||||
taskResult ? taskStatusConfig[taskResult.status].borderColor : statusConfig.fullBorder
|
||||
}`}
|
||||
>
|
||||
{/* Header row */}
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full flex-shrink-0 transition-colors duration-500 ${
|
||||
taskStyle ? taskStyle.dot : statusConfig.dot
|
||||
taskResult ? taskStatusConfig[taskResult.status].dot : statusConfig.dot
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)] truncate">
|
||||
@ -530,7 +496,9 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
</span>
|
||||
<span
|
||||
className={`flex-shrink-0 transition-all duration-300 ${
|
||||
taskStyle ? taskStyle.iconColor : statusConfig.iconColor
|
||||
taskResult
|
||||
? taskStatusConfig[taskResult.status].iconColor
|
||||
: statusConfig.iconColor
|
||||
}`}
|
||||
>
|
||||
{taskResult ? (
|
||||
|
||||
@ -25,8 +25,6 @@ interface MessageInputProps {
|
||||
isReadOnly?: boolean;
|
||||
channelName?: string;
|
||||
selectedExpert?: { name: string; description: string } | null;
|
||||
/** 当前话题 ID,切换话题时自动清空草稿 */
|
||||
topicId?: string | null;
|
||||
}
|
||||
|
||||
interface FileAttachment {
|
||||
@ -53,7 +51,6 @@ export function MessageInput({
|
||||
isReadOnly = false,
|
||||
channelName,
|
||||
selectedExpert,
|
||||
topicId,
|
||||
}: MessageInputProps) {
|
||||
const effectivePlaceholder =
|
||||
placeholder ??
|
||||
@ -65,16 +62,6 @@ export function MessageInput({
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const wasLoadingRef = useRef(false);
|
||||
const prevTopicIdRef = useRef<string | null | undefined>(topicId);
|
||||
|
||||
// 切换话题时清空草稿(替代原来通过 key remount 的重置机制)
|
||||
useEffect(() => {
|
||||
if (prevTopicIdRef.current !== topicId) {
|
||||
prevTopicIdRef.current = topicId;
|
||||
setContent('');
|
||||
setAttachments([]);
|
||||
}
|
||||
}, [topicId]);
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
|
||||
@ -58,21 +58,6 @@ export function McpTab({ config, update, setToast }: Props) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 p-4">
|
||||
<Field label="工具调用超时(秒)" hint="0 = 不超时,默认 300(5 分钟)">
|
||||
<input
|
||||
type="number"
|
||||
value={config.mcp_tool_timeout_secs ?? 300}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
update('mcp_tool_timeout_secs', v === '' ? 300 : Number(v));
|
||||
}}
|
||||
className={inputCls}
|
||||
min={0}
|
||||
step={30}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{mcpStatus && mcpStatus.enabled && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-[var(--bg-tertiary)] text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
||||
@ -257,7 +257,6 @@ export interface AppConfig {
|
||||
client: ClientConfig;
|
||||
channels: Record<string, ChannelConfig>;
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
mcp_tool_timeout_secs: number;
|
||||
}
|
||||
|
||||
export type TabId =
|
||||
|
||||
@ -314,39 +314,6 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
||||
if (getSubagentTaskId(message)) {
|
||||
// 子代理执行完成:bump 统一 trigger,App.tsx 根据 subAgentView 分派 load_task_messages
|
||||
bumpTopicRefreshTrigger();
|
||||
|
||||
// 更新主视图中 task tool result 占位消息的状态
|
||||
// task 工具返回时 status='running'(黄色转圈),子代理完成后需更新为最终状态
|
||||
if (msg.subagent_task_id && msg.subagent_status) {
|
||||
const taskId = msg.subagent_task_id;
|
||||
const newStatus = msg.subagent_status;
|
||||
const newSummary = msg.subagent_summary;
|
||||
setMessages((prev) => {
|
||||
let changed = false;
|
||||
const updated = prev.map((m) => {
|
||||
if (m.type !== 'tool_result' || m.toolName !== 'task') return m;
|
||||
if (!m.content) return m;
|
||||
// content 是 TaskToolResult JSON(可能带 loop_detector 前缀)
|
||||
const jsonStart = m.content.indexOf('{');
|
||||
if (jsonStart < 0) return m;
|
||||
try {
|
||||
const parsed = JSON.parse(m.content.slice(jsonStart));
|
||||
if (parsed.task_id !== taskId) return m;
|
||||
if (parsed.status === newStatus) return m;
|
||||
parsed.status = newStatus;
|
||||
if (newSummary !== undefined) parsed.summary = newSummary;
|
||||
const newJson = JSON.stringify(parsed);
|
||||
changed = true;
|
||||
const prefix = jsonStart > 0 ? m.content.slice(0, jsonStart) : '';
|
||||
return { ...m, content: prefix + newJson };
|
||||
} catch {
|
||||
return m;
|
||||
}
|
||||
});
|
||||
return changed ? updated : prev;
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
// 按 topic_id 移除处理状态,不论当前选中哪个话题。
|
||||
|
||||
@ -3,49 +3,8 @@ import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean; error: Error | null }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, info.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{ padding: '20px', color: '#ff6b6b', background: '#1a1a1a', minHeight: '100vh', fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>
|
||||
<h2>React Render Error</h2>
|
||||
<p><strong>{this.state.error?.name}:</strong> {this.state.error?.message}</p>
|
||||
<pre>{this.state.error?.stack}</pre>
|
||||
<hr />
|
||||
<p>Try clearing browser cache and localStorage, then refresh.</p>
|
||||
<button
|
||||
onClick={() => { localStorage.clear(); location.reload(); }}
|
||||
style={{ marginTop: '10px', padding: '8px 16px', cursor: 'pointer' }}
|
||||
>
|
||||
Clear localStorage & Refresh
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@ -303,9 +303,6 @@ export interface ExecutionCompleted {
|
||||
topic_id?: string;
|
||||
timestamp?: number;
|
||||
subagent_task_id?: string;
|
||||
/** 子代理最终状态(completed/failed/timeout/cancelled/interrupted) */
|
||||
subagent_status?: string;
|
||||
subagent_summary?: string;
|
||||
}
|
||||
|
||||
export type WsOutbound =
|
||||
@ -507,15 +504,7 @@ export interface ChatMessage {
|
||||
|
||||
/** task 工具返回的 JSON 结构 */
|
||||
export interface TaskToolResult {
|
||||
// status 值由后端 TaskToolResult.status 决定,包括:
|
||||
// - running: 异步子代理刚 spawn 的占位结果
|
||||
// - success: 子代理执行成功(旧)
|
||||
// - completed: 子代理执行成功(新,与 SubagentStatus 对齐)
|
||||
// - failed: 子代理执行失败
|
||||
// - timeout: 子代理执行超时
|
||||
// - cancelled: 子代理被用户取消
|
||||
// - interrupted: 子代理因服务器重启被中断
|
||||
status: string;
|
||||
status: 'success' | 'failed' | 'timeout';
|
||||
summary: string;
|
||||
output: string;
|
||||
task_id: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user