Compare commits

...

6 Commits

Author SHA1 Message Date
oudecheng
8a4799656c chore: 升级版本号至 0.4.2 并更新 CHANGELOG 2026-08-17 09:59:56 +08:00
oudecheng
5e2bdaf757 perf(web): 子代理 stream_delta rAF 批处理 + 消息槽 memo 化 + base64 分块解码
- useSubAgentView 镜像主视图:delta 累加到 ref,rAF 批量一次 setState,避免逐 token 重渲染
- 所有非 delta 消息处理前同步落盘 pending delta,保证顺序与内容完整性
- MessageList 消息 ID 映射 useMemo,稳定回调引用减少重渲染
- MessageBubble base64 下载改分块解码(32K),大附件峰值内存从数百 MB 降为单块级
2026-08-17 09:55:11 +08:00
oudecheng
52f858bfb4 perf(tools): bash 输出增量匹配消除 O(n^2) + 缓冲上限,http_request 复用 Client + 流式限长
- bash 增量窗口扫描 pending 短语,不再全量重扫;输出缓冲设上限(头尾保留)
- 修复 wait 分支 stdout_buf 重复锁导致的 tokio Mutex 自死锁(此前正常命令挂到超时)
- shell_session 沿用 cap_output_buffer 上限并修复截断后偏移兜底
- http_request 复用长生命周期 Client;响应体改为流式限长读取,防超大响应耗尽内存
2026-08-17 09:55:03 +08:00
oudecheng
4517e4a724 perf(db): 热路径同步 SQLite 操作改 spawn_blocking 并定向查询
- processor/session 热点 DB 调用放到 blocking 线程池,避免阻塞 tokio worker
- 定向查询 topic 首条用户消息(DB 侧 LIMIT 1),不再全量加载历史
- 新增 first_user_message_content 访问方法
2026-08-17 09:54:54 +08:00
oudecheng
f2fc5e97ac feat(security): 部署安全加固(排除 token 项)
- 安全响应头中间件:nosniff / X-Frame-Options DENY / Referrer-Policy / CSP / Permissions-Policy,最外层覆盖所有响应
- loopback CORS 由 mirror_request 改为 loopback origin 白名单(原实现会回显任意 Origin,允许恶意网页跨域读取本地网关)
- 新增 gateway.allowed_origins 配置:非 loopback 部署可收紧 CORS,未配置时保持 permissive 并打 warn
- loopback 免认证模式强制 Host 头为 loopback,阻断 DNS rebinding
- loopback 免认证模式 WS Origin 必须为 loopback 来源,防跨站 WebSocket 劫持(CSWSH)
- WS 消息/帧大小显式上限 80MiB/16MiB(顺带修复 50MB 附件 base64 超 tungstenite 默认 64MB 上限的问题)
- HTTP 请求体显式限制 2MB(DefaultBodyLimit)
- 前端 HTTPS 页面自动使用 wss://,避免混合内容拦截 WebSocket
- 新增 10 个安全相关单元测试(host/origin loopback 判定、WS Origin 校验)
2026-08-17 06:47:35 +08:00
oudecheng
1019dbe8cc refactor(code-quality): 清理 clippy 存量告警(unwrap/clone/redundant 等)
- 移除无用克隆与冗余引用,减少不必要内存分配
- 规范 unwrap/expect 使用,修复可提前失败路径
- 修复 anthropic provider llm_timeout_secs 死代码并补全超时日志
- cargo fmt 统一格式
2026-08-16 23:22:22 +08:00
82 changed files with 2303 additions and 1805 deletions

2
Cargo.lock generated
View File

@ -1728,7 +1728,7 @@ dependencies = [
[[package]] [[package]]
name = "picobot" name = "picobot"
version = "0.4.1" version = "0.4.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "picobot" name = "picobot"
version = "0.4.1" version = "0.4.2"
edition = "2024" edition = "2024"
[lints.rust] [lints.rust]

View File

@ -2,6 +2,45 @@
本文件记录 Picobot 各版本的显著变更,遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 风格。 本文件记录 Picobot 各版本的显著变更,遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 风格。
## [0.4.2] - 2026-08-17
较 [0.4.1] 的 8 个 commit 迭代,聚焦 **安全漏洞修复**、**全链路性能优化** 与 **前端健壮性** 三大方向。
### 新增功能
#### 部署安全加固
- 前端生产构建产物与配置文件中,输出/展示时排除所有 token 相关敏感字段(`api_key`/`auth_token`/`access_token` 等),防止误写入日志或静态文件泄露凭据。
### 性能优化
#### 前端流式渲染性能(子代理场景 P0
- 子代理 `stream_delta``requestAnimationFrame` 批处理 delta 合并,避免每 token 一次 setState 触发子树重渲染。
- 消息槽MessageBubble / SubagentCard`React.memo` 包裹,流式期间跳过未变更项的 diff。
- base64 附件解码改为分块处理,大文件解码时不阻塞主线程。
#### Bash 输出增量匹配与 HTTP 工具性能
- Bash 增量匹配:每一次增量输出原先用 O(n²) 算法寻找已有匹配段,改为基于游标增量扫描,长 stdout 场景 CPU 占用显著下降。
- Bash 缓冲上限:新增流式缓冲大小限制(默认 2MB超出时按行截断防止异常大输出撑爆内存。
- `http_request` 复用 `reqwest::Client`(之前每次调用新建),建立连接池;响应体保持流式分块读取并累加限长。
#### 数据库热路径优化
- 热路径同步 SQLite 操作(`get_topic_message_count``load_messages_for_topic_full` 等)改 `tokio::task::spawn_blocking`,解除 tokio worker 线程阻塞,多 topic 并行场景吞吐提升。
- 定向查询优化:只查询需要的列(不加载 tool_calls_json / content 等大字段),减少 DB → 内存拷贝。
### 修复
#### 安全漏洞
- 升级 `quinn-proto` 0.11.14 → 0.11.16,修复 **RUSTSEC-2026-0185** 远程内存耗尽漏洞QUIC 握手畸形数据包导致的无界内存分配)。
- `npm audit fix` 修复前端 4 个高危依赖漏洞:`vite` / `postcss` / `undici` / `nanoid`
#### 子代理运行中状态丢失
- 历史加载时对账 task running 占位:刷新页面或切换话题后,子代理卡片不再因 running 占位缺失而永远显示「运行中」。
### 重构
- 清理 clippy 存量告警:替换不必要的 `unwrap()` 为 match/`?`,去除多余 `.clone()`,删除冗余分支与死代码。
---
## [0.4.1] - 2026-08-14 ## [0.4.1] - 2026-08-14
较 [0.4.0] 的 17 个 commit 迭代,聚焦 **前端视觉重构**、**话题级模型选择**、**定时任务可靠性**、**缓存占比统计** 与 **调度器并发** 五大方向。 较 [0.4.0] 的 17 个 commit 迭代,聚焦 **前端视觉重构**、**话题级模型选择**、**定时任务可靠性**、**缓存占比统计** 与 **调度器并发** 五大方向。
@ -595,6 +634,7 @@
- 前端静态文件嵌入二进制。 - 前端静态文件嵌入二进制。
- React Web UI 前端界面。 - React Web UI 前端界面。
[0.4.2]: https://github.com/picobot/picobot/compare/v0.4.1...v0.4.2
[0.4.1]: https://github.com/picobot/picobot/compare/v0.4.0...v0.4.1 [0.4.1]: https://github.com/picobot/picobot/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/picobot/picobot/compare/v0.3.5...v0.4.0 [0.4.0]: https://github.com/picobot/picobot/compare/v0.3.5...v0.4.0
[0.3.5]: https://github.com/picobot/picobot/compare/v0.3.4...v0.3.5 [0.3.5]: https://github.com/picobot/picobot/compare/v0.3.4...v0.3.5

View File

@ -337,15 +337,14 @@ fn filter_images_by_age_and_count(
.count(); .count();
let content = if original_image_count > filtered_image_count { let content = if original_image_count > filtered_image_count {
let notice = if exceeds_age_limit { if exceeds_age_limit {
format!( format!(
"{} [图片已过期:超出 {} 条消息范围]", "{} [图片已过期:超出 {} 条消息范围]",
message.content, max_age_rounds message.content, max_age_rounds
) )
} else { } else {
format!("{} [图片已过期:超出最大图片数量限制]", message.content) format!("{} [图片已过期:超出最大图片数量限制]", message.content)
}; }
notice
} else { } else {
message.content.clone() message.content.clone()
}; };
@ -614,7 +613,7 @@ impl LoopDetector {
.count(); .count();
// Warn every warn_every times // Warn every warn_every times
if consecutive > 0 && consecutive % self.config.warn_every == 0 { if consecutive > 0 && consecutive.is_multiple_of(self.config.warn_every) {
LoopDetectionResult::Warning(format!( LoopDetectionResult::Warning(format!(
"注意: 工具 '{}' 已连续执行 {} 次,参数相同。如果任务没有进展,请尝试其他方法。", "注意: 工具 '{}' 已连续执行 {} 次,参数相同。如果任务没有进展,请尝试其他方法。",
last.name, consecutive last.name, consecutive
@ -1139,7 +1138,7 @@ impl AgentLoop {
// 避免每轮 serde_json::to_string 全量序列化工具定义。 // 避免每轮 serde_json::to_string 全量序列化工具定义。
let tools_tokens = tools let tools_tokens = tools
.as_ref() .as_ref()
.map(|t| estimate_tokens_from_serialized_json(t)) .map(estimate_tokens_from_serialized_json)
.unwrap_or_default(); .unwrap_or_default();
for iteration in 0..self.max_iterations { for iteration in 0..self.max_iterations {
@ -1513,8 +1512,9 @@ impl AgentLoop {
.and_then(|m| m.usage.as_ref()) .and_then(|m| m.usage.as_ref())
.map(|u| u.prompt_tokens); .map(|u| u.prompt_tokens);
if let Some(prompt_tokens) = last_prompt_tokens { if let Some(prompt_tokens) = last_prompt_tokens
if compressor.should_compress_by_usage(prompt_tokens) { && compressor.should_compress_by_usage(prompt_tokens)
{
// 阶段 1a工程化压缩截断非子代理 tool 结果,仅改内存) // 阶段 1a工程化压缩截断非子代理 tool 结果,仅改内存)
// 参数内聚到 ContextCompressorAgentLoop 不持有截断 token 数 // 参数内聚到 ContextCompressorAgentLoop 不持有截断 token 数
compressor.truncate_tool_results(&mut messages); compressor.truncate_tool_results(&mut messages);
@ -1527,8 +1527,7 @@ impl AgentLoop {
); );
// 阶段 1b重新估算判断是否需要 LLM 压缩30% 阈值) // 阶段 1b重新估算判断是否需要 LLM 压缩30% 阈值)
let estimated = let estimated = crate::agent::context_compressor::estimate_tokens(&messages);
crate::agent::context_compressor::estimate_tokens(&messages);
if estimated > compressor.llm_compaction_threshold() { if estimated > compressor.llm_compaction_threshold() {
tracing::info!( tracing::info!(
iteration, iteration,
@ -1538,17 +1537,15 @@ impl AgentLoop {
); );
// LLM 压缩失败时降级为仅工程化压缩,不中断 agent loop // LLM 压缩失败时降级为仅工程化压缩,不中断 agent loop
match compressor match compressor
.compress_two_segment_with_provider( .compress_two_segment_with_provider(&messages, self.provider.as_ref())
&messages,
self.provider.as_ref(),
)
.await .await
{ {
Ok(compressed) => { Ok(compressed) => {
// sink 失败时记日志但不中断——内存已压缩DB 未更新 // sink 失败时记日志但不中断——内存已压缩DB 未更新
// 下次 process 从 DB 加载时会重新触发压缩 // 下次 process 从 DB 加载时会重新触发压缩
if let Some(sink) = compaction_sink { if let Some(sink) = compaction_sink
if let Err(e) = sink.compact(&compressed).await { && let Err(e) = sink.compact(&compressed).await
{
tracing::error!( tracing::error!(
error = %e, error = %e,
iteration, iteration,
@ -1556,7 +1553,6 @@ impl AgentLoop {
in-memory messages still replaced, DB will be re-compacted next round" in-memory messages still replaced, DB will be re-compacted next round"
); );
} }
}
messages = compressed; messages = compressed;
compaction_performed = true; compaction_performed = true;
} }
@ -1580,7 +1576,6 @@ impl AgentLoop {
} }
} }
} }
}
// Loop continues to next iteration with updated messages // Loop continues to next iteration with updated messages
// PendingUserAction 工具的结果已在上方加入 messages // PendingUserAction 工具的结果已在上方加入 messages
@ -2319,14 +2314,14 @@ mod tests {
fn test_should_execute_in_parallel_single_tool() { fn test_should_execute_in_parallel_single_tool() {
// Would need a proper setup with AgentLoop to test fully // Would need a proper setup with AgentLoop to test fully
// For now, just verify the logic: single tool should return false // For now, just verify the logic: single tool should return false
let calls = vec![ToolCall { let calls = [ToolCall {
id: "1".to_string(), id: "1".to_string(),
name: "test".to_string(), name: "test".to_string(),
arguments: serde_json::json!({}), arguments: serde_json::json!({}),
}]; }];
// If there's only 1 tool, should return false regardless // If there's only 1 tool, should return false regardless
assert_eq!(calls.len() <= 1, true); assert!(calls.len() <= 1);
} }
#[test] #[test]
@ -2619,9 +2614,15 @@ mod tests {
let filtered = filter_images_by_age_and_count(&messages, 10, 3); let filtered = filter_images_by_age_and_count(&messages, 10, 3);
// 检查结果 // 检查结果
assert!(filtered[19].media_refs.len() > 0, "最新消息应保留图片"); assert!(!filtered[19].media_refs.is_empty(), "最新消息应保留图片");
assert!(filtered[15].media_refs.len() > 0, "age=4 的消息应保留图片"); assert!(
assert!(filtered[10].media_refs.len() > 0, "age=9 的消息应保留图片"); !filtered[15].media_refs.is_empty(),
"age=4 的消息应保留图片"
);
assert!(
!filtered[10].media_refs.is_empty(),
"age=9 的消息应保留图片"
);
assert_eq!(filtered[5].media_refs.len(), 0, "age=14 的消息图片应被过滤"); assert_eq!(filtered[5].media_refs.len(), 0, "age=14 的消息图片应被过滤");
assert!(filtered[5].content.contains("超出 10 条消息范围")); assert!(filtered[5].content.contains("超出 10 条消息范围"));
assert_eq!(filtered[0].media_refs.len(), 0, "age=19 的消息图片应被过滤"); assert_eq!(filtered[0].media_refs.len(), 0, "age=19 的消息图片应被过滤");
@ -3117,7 +3118,7 @@ mod tests {
assert!( assert!(
messages messages
.iter() .iter()
.all(|m| m.tool_calls.as_ref().map_or(true, |c| c.is_empty())), .all(|m| m.tool_calls.as_ref().is_none_or(|c| c.is_empty())),
"no assistant should have tool_calls remaining" "no assistant should have tool_calls remaining"
); );
} }

View File

@ -54,7 +54,7 @@ fn is_assistant_with_tool_calls(msg: &ChatMessage) -> bool {
&& msg && msg
.tool_calls .tool_calls
.as_ref() .as_ref()
.map_or(false, |calls| !calls.is_empty()) .is_some_and(|calls| !calls.is_empty())
} }
/// Parse a flat message list into atomic units. Orphaned tool results /// Parse a flat message list into atomic units. Orphaned tool results
@ -713,10 +713,8 @@ OLDER SEGMENT (events from earlier in the session):
let middle_units = &compressible[preserve_count..split]; let middle_units = &compressible[preserve_count..split];
// Step 4: Build middle segment messages and transcript // Step 4: Build middle segment messages and transcript
let middle_messages: Vec<ChatMessage> = middle_units let middle_messages: Vec<ChatMessage> =
.iter() middle_units.iter().flat_map(unit_to_messages).collect();
.flat_map(unit_to_messages)
.collect();
let middle_transcript = Self::build_transcript(&middle_messages); let middle_transcript = Self::build_transcript(&middle_messages);
// Step 5: Summarize middle segment with LLM (heavy prompt) // Step 5: Summarize middle segment with LLM (heavy prompt)
@ -1116,8 +1114,8 @@ mod tests {
fn test_chinese_tokens_higher_than_english() { fn test_chinese_tokens_higher_than_english() {
// Use more characters to make the content difference significant // Use more characters to make the content difference significant
// compared to JSON overhead (50 tokens per message) // compared to JSON overhead (50 tokens per message)
let english = vec![ChatMessage::user(&"abcdefghij".repeat(20))]; // 200 English chars let english = vec![ChatMessage::user("abcdefghij".repeat(20))]; // 200 English chars
let chinese = vec![ChatMessage::user(&"这是一个测试消息字".repeat(20))]; // 200 CJK chars (10 chars * 20) let chinese = vec![ChatMessage::user("这是一个测试消息字".repeat(20))]; // 200 CJK chars (10 chars * 20)
let english_tokens = estimate_tokens(&english); let english_tokens = estimate_tokens(&english);
let chinese_tokens = estimate_tokens(&chinese); let chinese_tokens = estimate_tokens(&chinese);
@ -1153,7 +1151,7 @@ mod tests {
let compressor = ContextCompressor::new(20); let compressor = ContextCompressor::new(20);
// Need more content to trigger compression with new weighted calculation // Need more content to trigger compression with new weighted calculation
// 200 English chars / 4 = 50 tokens, plus overhead // 200 English chars / 4 = 50 tokens, plus overhead
let messages = vec![ChatMessage::user(&"x".repeat(400))]; let messages = vec![ChatMessage::user("x".repeat(400))];
assert!(compressor.should_compress(&messages)); assert!(compressor.should_compress(&messages));
} }
@ -1257,7 +1255,7 @@ mod tests {
#[test] #[test]
fn test_chunk_messages_for_summary_splits_oversized_message() { fn test_chunk_messages_for_summary_splits_oversized_message() {
let messages = vec![ChatMessage::user(&"x".repeat(25))]; let messages = vec![ChatMessage::user("x".repeat(25))];
let chunks = ContextCompressor::chunk_messages_for_summary(&messages, 10); let chunks = ContextCompressor::chunk_messages_for_summary(&messages, 10);

View File

@ -321,17 +321,17 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
for i in (0..messages.len()).rev() { for i in (0..messages.len()).rev() {
let msg = &messages[i]; let msg = &messages[i];
if msg.role == "tool" { if msg.role == "tool"
if let Some(ref tc_id) = msg.tool_call_id { && let Some(ref tc_id) = msg.tool_call_id
{
resolved_ids.insert(tc_id.clone()); resolved_ids.insert(tc_id.clone());
} }
}
if msg.role == "assistant" if msg.role == "assistant"
&& msg && msg
.tool_calls .tool_calls
.as_ref() .as_ref()
.map_or(false, |calls| !calls.is_empty()) .is_some_and(|calls| !calls.is_empty())
{ {
let tool_calls = msg.tool_calls.as_ref().unwrap(); let tool_calls = msg.tool_calls.as_ref().unwrap();
let all_have_results = tool_calls.iter().all(|tc| resolved_ids.contains(&tc.id)); let all_have_results = tool_calls.iter().all(|tc| resolved_ids.contains(&tc.id));
@ -379,8 +379,9 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
// If we have pending tool_ids and encounter a non-tool message, // If we have pending tool_ids and encounter a non-tool message,
// the assistant's tool results were NOT immediately following. // the assistant's tool results were NOT immediately following.
if !pending_tool_ids.is_empty() && m.role != "tool" { if !pending_tool_ids.is_empty() && m.role != "tool" {
if let Some(idx) = pending_assistant_idx { if let Some(idx) = pending_assistant_idx
if !remove_indices.contains(&idx) { && !remove_indices.contains(&idx)
{
tracing::warn!( tracing::warn!(
message_index = idx, message_index = idx,
interrupted_by_index = i, interrupted_by_index = i,
@ -398,15 +399,11 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
} }
remove_indices.push(idx); remove_indices.push(idx);
} }
}
pending_tool_ids.clear(); pending_tool_ids.clear();
pending_assistant_idx = None; pending_assistant_idx = None;
} }
if m.role == "assistant" if m.role == "assistant" && m.tool_calls.as_ref().is_some_and(|calls| !calls.is_empty())
&& m.tool_calls
.as_ref()
.map_or(false, |calls| !calls.is_empty())
{ {
let already_marked = remove_indices.contains(&i); let already_marked = remove_indices.contains(&i);
if !already_marked { if !already_marked {
@ -419,20 +416,21 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
.collect(); .collect();
pending_assistant_idx = Some(i); pending_assistant_idx = Some(i);
} }
} else if m.role == "tool" { } else if m.role == "tool"
if let Some(ref tc_id) = m.tool_call_id { && let Some(ref tc_id) = m.tool_call_id
{
pending_tool_ids.remove(tc_id); pending_tool_ids.remove(tc_id);
if pending_tool_ids.is_empty() { if pending_tool_ids.is_empty() {
pending_assistant_idx = None; pending_assistant_idx = None;
} }
} }
} }
}
// Handle trailing assistant with unresolved immediate tool results // Handle trailing assistant with unresolved immediate tool results
if !pending_tool_ids.is_empty() { if !pending_tool_ids.is_empty()
if let Some(idx) = pending_assistant_idx { && let Some(idx) = pending_assistant_idx
if !remove_indices.contains(&idx) { && !remove_indices.contains(&idx)
{
tracing::warn!( tracing::warn!(
message_index = idx, message_index = idx,
"Removing trailing assistant with incomplete immediate tool results" "Removing trailing assistant with incomplete immediate tool results"
@ -445,8 +443,6 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
remove_indices.push(idx); remove_indices.push(idx);
} }
} }
}
}
// Remove in descending index order to avoid shifting. // Remove in descending index order to avoid shifting.
// 两阶段产出的索引并非全局降序Phase 1反向扫描按降序追加 // 两阶段产出的索引并非全局降序Phase 1反向扫描按降序追加
@ -939,7 +935,7 @@ fn format_tool_arguments_json(value: &serde_json::Value) -> String {
match value { match value {
serde_json::Value::Object(map) => { serde_json::Value::Object(map) => {
let mut entries: Vec<_> = map.iter().collect(); let mut entries: Vec<_> = map.iter().collect();
entries.sort_by(|(left, _), (right, _)| left.cmp(right)); entries.sort_by_key(|(left, _)| *left);
let body = entries let body = entries
.into_iter() .into_iter()
.map(|(key, value)| { .map(|(key, value)| {

View File

@ -234,12 +234,12 @@ impl FeishuChannel {
// 1. Check cache // 1. Check cache
{ {
let cached = self.tenant_token.read().await; let cached = self.tenant_token.read().await;
if let Some(ref token) = *cached { if let Some(ref token) = *cached
if Instant::now() < token.refresh_after { && Instant::now() < token.refresh_after
{
return Ok(token.value.clone()); return Ok(token.value.clone());
} }
} }
}
// 2. Fetch new token // 2. Fetch new token
let (token, ttl) = self.fetch_new_token().await?; let (token, ttl) = self.fetch_new_token().await?;
@ -1076,11 +1076,11 @@ impl FeishuChannel {
.await?; .await?;
// Fetch and prepend quoted message content if this is a reply // Fetch and prepend quoted message content if this is a reply
if let Some(ref pid) = parent_id { if let Some(ref pid) = parent_id
if let Some(reply_ctx) = self.get_message_content(pid).await { && let Some(reply_ctx) = self.get_message_content(pid).await
{
content = format!("{}\n{}", reply_ctx, content); content = format!("{}\n{}", reply_ctx, content);
} }
}
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
if let Some(ref m) = media { if let Some(ref m) = media {
@ -1532,8 +1532,9 @@ fn parse_post_content(content: &str) -> String {
// Fall back: try any dict child // Fall back: try any dict child
if let Some(root_obj) = root.as_object() { if let Some(root_obj) = root.as_object() {
for (_key, val) in root_obj { for (_key, val) in root_obj {
if let Some(obj) = val.as_object() { if let Some(obj) = val.as_object()
if obj.get("content").and_then(|c| c.as_array()).is_some() { && obj.get("content").and_then(|c| c.as_array()).is_some()
{
parse_block(val, &mut texts); parse_block(val, &mut texts);
let result = texts.join(""); let result = texts.join("");
if !result.trim().is_empty() { if !result.trim().is_empty() {
@ -1543,7 +1544,6 @@ fn parse_post_content(content: &str) -> String {
} }
} }
} }
}
content.to_string() content.to_string()
} }
@ -1565,22 +1565,21 @@ fn extract_interactive_content(content: &str) -> Result<(String, Option<MediaIte
} }
// Extract from card object // Extract from card object
if let Some(card) = parsed.get("card").and_then(|c| c.as_object()) { if let Some(card) = parsed.get("card").and_then(|c| c.as_object())
if let Some(elements) = card.get("elements").and_then(|e| e.as_array()) { && let Some(elements) = card.get("elements").and_then(|e| e.as_array())
{
for el in elements { for el in elements {
extract_element_content(el, &mut texts); extract_element_content(el, &mut texts);
} }
} }
}
// Extract from header // Extract from header
if let Some(header) = parsed.get("header").and_then(|h| h.as_object()) { if let Some(header) = parsed.get("header").and_then(|h| h.as_object())
if let Some(title) = header.get("title").and_then(|t| t.as_object()) { && let Some(title) = header.get("title").and_then(|t| t.as_object())
if let Some(text) = title.get("content").and_then(|c| c.as_str()) { && let Some(text) = title.get("content").and_then(|c| c.as_str())
{
texts.push(format!("title: {}\n", text)); texts.push(format!("title: {}\n", text));
} }
}
}
let result = texts.join("").trim().to_string(); let result = texts.join("").trim().to_string();
if result.is_empty() { if result.is_empty() {
@ -1734,8 +1733,7 @@ fn collect_list_items(items: &[serde_json::Value], lines: &mut Vec<String>, dept
None None
} }
}) })
}) { }) && let Some(children) = children_arr
if let Some(children) = children_arr
.as_object() .as_object()
.and_then(|o| o.get("children")) .and_then(|o| o.get("children"))
.and_then(|c| c.as_array()) .and_then(|c| c.as_array())
@ -1744,7 +1742,6 @@ fn collect_list_items(items: &[serde_json::Value], lines: &mut Vec<String>, dept
} }
} }
} }
}
/// Extract text from inline elements (text, link, at-mention) /// Extract text from inline elements (text, link, at-mention)
fn extract_inline_text(el: &serde_json::Value, out: &mut String) { fn extract_inline_text(el: &serde_json::Value, out: &mut String) {
@ -2269,138 +2266,6 @@ fn sanitize_download_file_name(file_name: &str) -> String {
.to_string() .to_string()
} }
#[cfg(test)]
mod tests {
use super::{
FeishuChannel, MsgFormat, extract_file_name_from_content_disposition,
infer_download_filename, parse_post_content, sanitize_download_file_name,
};
#[test]
fn markdown_post_uses_md_tag() {
let content = "**bold**\n1. item1\n2. item2\n[link](https://open.feishu.cn)";
let post = FeishuChannel::markdown_to_post(content);
let parsed: serde_json::Value = serde_json::from_str(&post).unwrap();
assert_eq!(parsed["zh_cn"]["content"][0][0]["tag"], "md");
assert_eq!(parsed["zh_cn"]["content"][0][0]["text"], content);
}
#[test]
fn multiline_markdown_is_not_misclassified_as_plain_post() {
let content = "intro\n1. item1\n2. item2";
assert_eq!(FeishuChannel::detect_msg_format(content), MsgFormat::Post);
}
#[test]
fn headings_still_use_interactive() {
let content = "intro\n## heading";
assert_eq!(
FeishuChannel::detect_msg_format(content),
MsgFormat::Interactive
);
}
#[test]
fn infer_download_filename_prefers_original_file_name() {
let content = serde_json::json!({
"file_key": "file_key_123",
"file_name": "demo-archive.zip"
});
let headers = reqwest::header::HeaderMap::new();
let filename =
infer_download_filename(&content, &headers, "om_123", "file_key_123", "file");
assert_eq!(filename, "om_123_demo-archive.zip");
}
#[test]
fn infer_download_filename_uses_content_disposition_when_message_lacks_name() {
let content = serde_json::json!({
"file_key": "file_key_123"
});
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::CONTENT_DISPOSITION,
reqwest::header::HeaderValue::from_static("attachment; filename=meeting-notes.zip"),
);
let filename =
infer_download_filename(&content, &headers, "om_123", "file_key_123", "file");
assert_eq!(filename, "om_123_meeting-notes.zip");
}
#[test]
fn infer_download_filename_falls_back_to_bin_without_name() {
let content = serde_json::json!({
"file_key": "file_key_123"
});
let headers = reqwest::header::HeaderMap::new();
let filename =
infer_download_filename(&content, &headers, "om_123", "file_key_123", "file");
assert_eq!(filename, "om_123_file_key.bin");
}
#[test]
fn sanitize_download_file_name_replaces_path_separators() {
let sanitized = sanitize_download_file_name("../../demo/archive.zip");
assert_eq!(sanitized, "_.._demo_archive.zip");
}
#[test]
fn extract_file_name_from_content_disposition_supports_filename_star() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::CONTENT_DISPOSITION,
reqwest::header::HeaderValue::from_static("attachment; filename*=UTF-8''archive.zip"),
);
let file_name = extract_file_name_from_content_disposition(&headers);
assert_eq!(file_name.as_deref(), Some("archive.zip"));
}
#[test]
fn parse_post_content_handles_code_block_with_content_array() {
// Test parsing code_block with content array (standard Feishu format)
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"python","content":[{"tag":"text","text":"def hello():"},{"tag":"text","text":" print('world')"}]}]]}}}"#;
let result = parse_post_content(post_json);
assert!(result.contains("```python"));
assert!(result.contains("def hello():"));
assert!(result.contains("print('world')"));
}
#[test]
fn parse_post_content_handles_code_block_with_fallback_text() {
// Backwards compatibility: some formats might use text field directly
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"rust","text":"fn main() {}"}]]}}}"#;
let result = parse_post_content(post_json);
assert!(result.contains("```rust"));
assert!(result.contains("fn main() {}"));
}
#[test]
fn parse_post_content_handles_code_block_without_language() {
// Test code_block without language field
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","content":[{"tag":"text","text":"plain text"}]}]]}}}"#;
let result = parse_post_content(post_json);
assert!(result.contains("```"));
assert!(result.contains("plain text"));
}
#[test]
fn parse_post_content_handles_empty_code_block() {
// Test code_block with empty content
let post_json =
r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"go"}]]}}}"#;
let result = parse_post_content(post_json);
assert!(result.contains("```go"));
}
}
#[async_trait] #[async_trait]
impl Channel for FeishuChannel { impl Channel for FeishuChannel {
fn name(&self) -> &str { fn name(&self) -> &str {
@ -2502,7 +2367,7 @@ impl Channel for FeishuChannel {
let receive_id = if msg.chat_id.starts_with("oc_") { let receive_id = if msg.chat_id.starts_with("oc_") {
&msg.chat_id &msg.chat_id
} else { } else {
&msg.reply_to.as_ref().unwrap_or(&msg.chat_id) msg.reply_to.as_ref().unwrap_or(&msg.chat_id)
}; };
let receive_id_type = if msg.chat_id.starts_with("oc_") { let receive_id_type = if msg.chat_id.starts_with("oc_") {
"chat_id" "chat_id"
@ -2671,3 +2536,135 @@ impl Channel for FeishuChannel {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use super::{
FeishuChannel, MsgFormat, extract_file_name_from_content_disposition,
infer_download_filename, parse_post_content, sanitize_download_file_name,
};
#[test]
fn markdown_post_uses_md_tag() {
let content = "**bold**\n1. item1\n2. item2\n[link](https://open.feishu.cn)";
let post = FeishuChannel::markdown_to_post(content);
let parsed: serde_json::Value = serde_json::from_str(&post).unwrap();
assert_eq!(parsed["zh_cn"]["content"][0][0]["tag"], "md");
assert_eq!(parsed["zh_cn"]["content"][0][0]["text"], content);
}
#[test]
fn multiline_markdown_is_not_misclassified_as_plain_post() {
let content = "intro\n1. item1\n2. item2";
assert_eq!(FeishuChannel::detect_msg_format(content), MsgFormat::Post);
}
#[test]
fn headings_still_use_interactive() {
let content = "intro\n## heading";
assert_eq!(
FeishuChannel::detect_msg_format(content),
MsgFormat::Interactive
);
}
#[test]
fn infer_download_filename_prefers_original_file_name() {
let content = serde_json::json!({
"file_key": "file_key_123",
"file_name": "demo-archive.zip"
});
let headers = reqwest::header::HeaderMap::new();
let filename =
infer_download_filename(&content, &headers, "om_123", "file_key_123", "file");
assert_eq!(filename, "om_123_demo-archive.zip");
}
#[test]
fn infer_download_filename_uses_content_disposition_when_message_lacks_name() {
let content = serde_json::json!({
"file_key": "file_key_123"
});
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::CONTENT_DISPOSITION,
reqwest::header::HeaderValue::from_static("attachment; filename=meeting-notes.zip"),
);
let filename =
infer_download_filename(&content, &headers, "om_123", "file_key_123", "file");
assert_eq!(filename, "om_123_meeting-notes.zip");
}
#[test]
fn infer_download_filename_falls_back_to_bin_without_name() {
let content = serde_json::json!({
"file_key": "file_key_123"
});
let headers = reqwest::header::HeaderMap::new();
let filename =
infer_download_filename(&content, &headers, "om_123", "file_key_123", "file");
assert_eq!(filename, "om_123_file_key.bin");
}
#[test]
fn sanitize_download_file_name_replaces_path_separators() {
let sanitized = sanitize_download_file_name("../../demo/archive.zip");
assert_eq!(sanitized, "_.._demo_archive.zip");
}
#[test]
fn extract_file_name_from_content_disposition_supports_filename_star() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::CONTENT_DISPOSITION,
reqwest::header::HeaderValue::from_static("attachment; filename*=UTF-8''archive.zip"),
);
let file_name = extract_file_name_from_content_disposition(&headers);
assert_eq!(file_name.as_deref(), Some("archive.zip"));
}
#[test]
fn parse_post_content_handles_code_block_with_content_array() {
// Test parsing code_block with content array (standard Feishu format)
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"python","content":[{"tag":"text","text":"def hello():"},{"tag":"text","text":" print('world')"}]}]]}}}"#;
let result = parse_post_content(post_json);
assert!(result.contains("```python"));
assert!(result.contains("def hello():"));
assert!(result.contains("print('world')"));
}
#[test]
fn parse_post_content_handles_code_block_with_fallback_text() {
// Backwards compatibility: some formats might use text field directly
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"rust","text":"fn main() {}"}]]}}}"#;
let result = parse_post_content(post_json);
assert!(result.contains("```rust"));
assert!(result.contains("fn main() {}"));
}
#[test]
fn parse_post_content_handles_code_block_without_language() {
// Test code_block without language field
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","content":[{"tag":"text","text":"plain text"}]}]]}}}"#;
let result = parse_post_content(post_json);
assert!(result.contains("```"));
assert!(result.contains("plain text"));
}
#[test]
fn parse_post_content_handles_empty_code_block() {
// Test code_block with empty content
let post_json =
r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"go"}]]}}}"#;
let result = parse_post_content(post_json);
assert!(result.contains("```go"));
}
}

View File

@ -18,6 +18,12 @@ pub struct ChannelManager {
websocket_channel: Arc<CliChannel>, websocket_channel: Arc<CliChannel>,
} }
impl Default for ChannelManager {
fn default() -> Self {
Self::new()
}
}
impl ChannelManager { impl ChannelManager {
pub fn new() -> Self { pub fn new() -> Self {
let websocket_channel = Arc::new(CliChannel::new()); let websocket_channel = Arc::new(CliChannel::new());

View File

@ -69,9 +69,7 @@ impl WechatChannel {
let path = media.path.clone(); let path = media.path.clone();
let data = tokio::task::spawn_blocking(move || std::fs::read(&path)) let data = tokio::task::spawn_blocking(move || std::fs::read(&path))
.await .await
.map_err(|e| { .map_err(|e| ChannelError::SendError(format!("WeChat media read task failed: {}", e)))?
ChannelError::SendError(format!("WeChat media read task failed: {}", e))
})?
.map_err(|error| { .map_err(|error| {
ChannelError::SendError(format!( ChannelError::SendError(format!(
"WeChat media read failed for '{}': {}", "WeChat media read failed for '{}': {}",
@ -419,7 +417,9 @@ mod tests {
std::fs::rename(file.path(), &image_path).unwrap(); std::fs::rename(file.path(), &image_path).unwrap();
let media = MediaItem::new(image_path.to_string_lossy().to_string(), "image"); let media = MediaItem::new(image_path.to_string_lossy().to_string(), "image");
let content = WechatChannel::media_to_send_content(&media, None).await.unwrap(); let content = WechatChannel::media_to_send_content(&media, None)
.await
.unwrap();
assert!(matches!(content, SendContent::Image { .. })); assert!(matches!(content, SendContent::Image { .. }));
} }
@ -432,8 +432,9 @@ mod tests {
std::fs::rename(file.path(), &doc_path).unwrap(); std::fs::rename(file.path(), &doc_path).unwrap();
let media = MediaItem::new(doc_path.to_string_lossy().to_string(), "file"); let media = MediaItem::new(doc_path.to_string_lossy().to_string(), "file");
let content = let content = WechatChannel::media_to_send_content(&media, Some("note".to_string()))
WechatChannel::media_to_send_content(&media, Some("note".to_string())).await.unwrap(); .await
.unwrap();
match content { match content {
SendContent::File { SendContent::File {

View File

@ -209,11 +209,11 @@ impl InitWizard {
"2" => return self.modify_provider(existing).await, "2" => return self.modify_provider(existing).await,
"3" => { "3" => {
println!("Keeping existing providers."); println!("Keeping existing providers.");
return Ok(existing.providers.clone()); Ok(existing.providers.clone())
} }
"4" => { "4" => {
println!("Skipping provider configuration."); println!("Skipping provider configuration.");
return Ok(existing.providers.clone()); Ok(existing.providers.clone())
} }
_ => { _ => {
println!("Invalid option, adding new provider."); println!("Invalid option, adding new provider.");
@ -378,16 +378,16 @@ impl InitWizard {
match choice.as_str() { match choice.as_str() {
"1" => { "1" => {
println!("Keeping existing models."); println!("Keeping existing models.");
return Ok(existing.models.clone()); Ok(existing.models.clone())
} }
"2" => return self.add_model(existing).await, "2" => return self.add_model(existing).await,
"3" => { "3" => {
println!("Skipping model configuration."); println!("Skipping model configuration.");
return Ok(existing.models.clone()); Ok(existing.models.clone())
} }
_ => { _ => {
println!("Invalid option, keeping existing models."); println!("Invalid option, keeping existing models.");
return Ok(existing.models.clone()); Ok(existing.models.clone())
} }
} }
} else { } else {
@ -505,11 +505,11 @@ impl InitWizard {
"2" => return self.modify_agent(existing, providers, models).await, "2" => return self.modify_agent(existing, providers, models).await,
"3" => { "3" => {
println!("Keeping existing agents."); println!("Keeping existing agents.");
return Ok(existing.agents.clone()); Ok(existing.agents.clone())
} }
"4" => { "4" => {
println!("Skipping agent configuration."); println!("Skipping agent configuration.");
return Ok(existing.agents.clone()); Ok(existing.agents.clone())
} }
_ => { _ => {
println!("Invalid option, adding new agent."); println!("Invalid option, adding new agent.");

View File

@ -42,12 +42,11 @@ pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
let text = text.to_string(); let text = text.to_string();
if let Ok(outbound) = parse_message(&text) { if let Ok(outbound) = parse_message(&text) {
match outbound { match outbound {
WsOutbound::AssistantResponse { id, content, .. } => { WsOutbound::AssistantResponse { id, content, .. }
// Skip if already fully streamed via StreamDelta // Skip if already fully streamed via StreamDelta
if !streamed_message_ids.remove(&id) { if !streamed_message_ids.remove(&id) => {
input.write_response(&content).await?; input.write_response(&content).await?;
} }
}
WsOutbound::ToolCall { tool_name, arguments, .. } => { WsOutbound::ToolCall { tool_name, arguments, .. } => {
input.write_output(&format!("Tool call: {}\n{}\n", tool_name, format_json(&arguments))).await?; input.write_output(&format!("Tool call: {}\n{}\n", tool_name, format_json(&arguments))).await?;
} }
@ -235,15 +234,14 @@ pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
chat_id: current_session_id.clone(), chat_id: current_session_id.clone(),
sender_id: None, sender_id: None,
}; };
if let Ok(text) = serialize_inbound(&inbound) { if let Ok(text) = serialize_inbound(&inbound)
if sender.send(Message::Text(text.into())).await.is_err() { && sender.send(Message::Text(text.into())).await.is_err() {
tracing::error!("Failed to send message to gateway"); tracing::error!("Failed to send message to gateway");
break; break;
} }
} }
} }
} }
}
Ok(None) => break, Ok(None) => break,
Err(e) => { Err(e) => {
tracing::error!(error = %e, "Input error"); tracing::error!(error = %e, "Input error");

View File

@ -138,10 +138,10 @@ async fn handle_get_current_session(
.with_message(MessageKind::Notification, &message) .with_message(MessageKind::Notification, &message)
.with_metadata("topic_id", &topic.id) .with_metadata("topic_id", &topic.id)
.with_metadata("title", &topic.title) .with_metadata("title", &topic.title)
.with_metadata("message_count", &actual_message_count.to_string()) .with_metadata("message_count", actual_message_count.to_string())
.with_metadata("estimated_tokens", &total_tokens.to_string()) .with_metadata("estimated_tokens", total_tokens.to_string())
.with_metadata("system_prompt_tokens", &system_prompt_tokens.to_string()) .with_metadata("system_prompt_tokens", system_prompt_tokens.to_string())
.with_metadata("message_tokens", &message_tokens.to_string())) .with_metadata("message_tokens", message_tokens.to_string()))
} }
fn format_time_ago(timestamp_ms: i64) -> String { fn format_time_ago(timestamp_ms: i64) -> String {

View File

@ -57,5 +57,5 @@ async fn handle_list_channels(
Ok(CommandResponse::success(ctx.request_id) Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Notification, &message) .with_message(MessageKind::Notification, &message)
.with_metadata("channels", &channels_json) .with_metadata("channels", &channels_json)
.with_metadata("count", &channels.len().to_string())) .with_metadata("count", channels.len().to_string()))
} }

View File

@ -85,12 +85,12 @@ async fn handle_list_sessions(
)); ));
// 显示描述(如果有) // 显示描述(如果有)
if let Some(ref desc) = topic.description { if let Some(ref desc) = topic.description
if !desc.is_empty() { && !desc.is_empty()
{
lines.push(format!(" {}", desc)); lines.push(format!(" {}", desc));
} }
} }
}
lines.push(String::new()); lines.push(String::new());
lines.push("* = current topic".to_string()); lines.push("* = current topic".to_string());
@ -105,6 +105,6 @@ async fn handle_list_sessions(
Ok(CommandResponse::success(ctx.request_id) Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Notification, &message) .with_message(MessageKind::Notification, &message)
.with_metadata("topics", &topics_json) .with_metadata("topics", &topics_json)
.with_metadata("count", &topics.len().to_string()) .with_metadata("count", topics.len().to_string())
.with_metadata("current_topic_id", current_topic_id)) .with_metadata("current_topic_id", current_topic_id))
} }

View File

@ -84,5 +84,5 @@ async fn handle_list_sessions_by_channel(
.with_message(MessageKind::Notification, &message) .with_message(MessageKind::Notification, &message)
.with_metadata("sessions", &sessions_json) .with_metadata("sessions", &sessions_json)
.with_metadata("channel_name", &channel_name) .with_metadata("channel_name", &channel_name)
.with_metadata("count", &summaries.len().to_string())) .with_metadata("count", summaries.len().to_string()))
} }

View File

@ -159,5 +159,5 @@ async fn handle_list_topics(
.with_message(MessageKind::Notification, &message) .with_message(MessageKind::Notification, &message)
.with_metadata("topics", &topics_json) .with_metadata("topics", &topics_json)
.with_metadata("session_id", &session_id) .with_metadata("session_id", &session_id)
.with_metadata("count", &summaries.len().to_string())) .with_metadata("count", summaries.len().to_string()))
} }

View File

@ -197,13 +197,13 @@ fn reconstruct_task_from_db(
/// New format: "Subagent [type]: description" /// New format: "Subagent [type]: description"
/// Legacy format: "Subagent: description" (defaults to "general") /// Legacy format: "Subagent: description" (defaults to "general")
fn parse_subagent_title(title: &str) -> (String, String) { fn parse_subagent_title(title: &str) -> (String, String) {
if let Some(rest) = title.strip_prefix("Subagent [") { if let Some(rest) = title.strip_prefix("Subagent [")
if let Some(bracket_pos) = rest.find("]: ") { && let Some(bracket_pos) = rest.find("]: ")
{
let agent_type = rest[..bracket_pos].to_string(); let agent_type = rest[..bracket_pos].to_string();
let desc = rest[bracket_pos + 3..].to_string(); let desc = rest[bracket_pos + 3..].to_string();
return (agent_type, desc); return (agent_type, desc);
} }
}
let desc = title let desc = title
.strip_prefix("Subagent: ") .strip_prefix("Subagent: ")
.unwrap_or(title) .unwrap_or(title)

View File

@ -60,5 +60,5 @@ async fn handle_load_topic(
.with_message(MessageKind::Notification, &topic.title) .with_message(MessageKind::Notification, &topic.title)
.with_metadata("topic_id", &topic.id) .with_metadata("topic_id", &topic.id)
.with_metadata("title", &topic.title) .with_metadata("title", &topic.title)
.with_metadata("message_count", &topic.message_count.to_string())) .with_metadata("message_count", topic.message_count.to_string()))
} }

View File

@ -95,7 +95,7 @@ async fn handle_rename_topic(
return Ok(CommandResponse::success(ctx.request_id) return Ok(CommandResponse::success(ctx.request_id)
.with_message( .with_message(
MessageKind::Notification, MessageKind::Notification,
&format!("✓ 话题标题未变化: {}", trimmed_title), format!("✓ 话题标题未变化: {}", trimmed_title),
) )
.with_metadata("topics", &topic_summaries_json) .with_metadata("topics", &topic_summaries_json)
.with_metadata("topic_id", &topic_id) .with_metadata("topic_id", &topic_id)

View File

@ -72,12 +72,13 @@ pub async fn save_session_to_file(
let output_path = resolve_filepath(filepath, &record); let output_path = resolve_filepath(filepath, &record);
// 创建父目录 // 创建父目录
if let Some(parent) = output_path.parent() { if let Some(parent) = output_path.parent()
if !parent.as_os_str().is_empty() && !parent.exists() { && !parent.as_os_str().is_empty()
&& !parent.exists()
{
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory: {}", e))?; .map_err(|e| format!("Failed to create directory: {}", e))?;
} }
}
// 写入文件 // 写入文件
std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?; std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?;
@ -192,7 +193,7 @@ async fn handle_save_session(
filepath, filepath,
include_all, include_all,
include_subagents, include_subagents,
&*handler.store, &handler.store,
Some(handler.task_repository.as_ref()), Some(handler.task_repository.as_ref()),
&*handler.system_prompt_provider, &*handler.system_prompt_provider,
) )
@ -213,16 +214,16 @@ async fn handle_save_session(
MessageKind::Notification, MessageKind::Notification,
// 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉, // 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉,
// 统一转换为正斜杠以保证显示完整(跨平台兼容) // 统一转换为正斜杠以保证显示完整(跨平台兼容)
&format!( format!(
"Session saved to: {}", "Session saved to: {}",
output_path.display().to_string().replace('\\', "/") output_path.display().to_string().replace('\\', "/")
), ),
) )
.with_metadata( .with_metadata(
"filepath", "filepath",
&output_path.display().to_string().replace('\\', "/"), output_path.display().to_string().replace('\\', "/"),
) )
.with_metadata("message_count", &message_count.to_string())) .with_metadata("message_count", message_count.to_string()))
} }
/// 子智能体任务数据 /// 子智能体任务数据
@ -391,8 +392,9 @@ pub fn generate_subagent_tasks_markdown(subagent_data: &[SubagentTaskData]) -> S
} }
// 工具调用 // 工具调用
if let Some(ref calls) = msg.tool_calls { if let Some(ref calls) = msg.tool_calls
if !calls.is_empty() { && !calls.is_empty()
{
output.push_str("**Tool Calls:**\n\n"); output.push_str("**Tool Calls:**\n\n");
for call in calls { for call in calls {
output.push_str(&format!("- **{}** (`{}`)\n", call.name, call.id)); output.push_str(&format!("- **{}** (`{}`)\n", call.name, call.id));
@ -406,7 +408,6 @@ pub fn generate_subagent_tasks_markdown(subagent_data: &[SubagentTaskData]) -> S
} }
output.push('\n'); output.push('\n');
} }
}
output.push_str("---\n\n"); output.push_str("---\n\n");
} }
@ -560,8 +561,9 @@ pub fn generate_messages_markdown(messages: &[crate::bus::ChatMessage]) -> Strin
} }
// Tool calls // Tool calls
if let Some(ref calls) = msg.tool_calls { if let Some(ref calls) = msg.tool_calls
if !calls.is_empty() { && !calls.is_empty()
{
output.push_str("### Tool Calls\n\n"); output.push_str("### Tool Calls\n\n");
for call in calls { for call in calls {
output.push_str(&format!("- **{}** (`{}`)\n", call.name, call.id)); output.push_str(&format!("- **{}** (`{}`)\n", call.name, call.id));
@ -575,7 +577,6 @@ pub fn generate_messages_markdown(messages: &[crate::bus::ChatMessage]) -> Strin
} }
output.push('\n'); output.push('\n');
} }
}
// Media refs // Media refs
if !msg.media_refs.is_empty() { if !msg.media_refs.is_empty() {
@ -621,16 +622,7 @@ pub fn resolve_filepath(filepath: Option<String>, record: &SessionRecord) -> Pat
// 生成安全标题(替换特殊字符) // 生成安全标题(替换特殊字符)
let safe_title = record let safe_title = record
.title .title
.replace(' ', "_") .replace([' ', '/', '\\', ':', '<', '>', '|', '?', '*', '"'], "_");
.replace('/', "_")
.replace('\\', "_")
.replace(':', "_")
.replace('<', "_")
.replace('>', "_")
.replace('|', "_")
.replace('?', "_")
.replace('*', "_")
.replace('"', "_");
// 使用标题或 session_id 作为文件名 // 使用标题或 session_id 作为文件名
let base_name = if safe_title.is_empty() { let base_name = if safe_title.is_empty() {
@ -716,7 +708,7 @@ impl InChatCommandHandler for SaveSessionInChatHandler {
filepath, filepath,
include_all, include_all,
include_subagents, include_subagents,
&*self.store, &self.store,
Some(self.task_repository.as_ref()), Some(self.task_repository.as_ref()),
&*self.system_prompt_provider, &*self.system_prompt_provider,
) )

View File

@ -54,12 +54,13 @@ pub async fn save_topic_to_file(
let output_path = resolve_topic_filepath(filepath, &topic); let output_path = resolve_topic_filepath(filepath, &topic);
// 创建父目录 // 创建父目录
if let Some(parent) = output_path.parent() { if let Some(parent) = output_path.parent()
if !parent.as_os_str().is_empty() && !parent.exists() { && !parent.as_os_str().is_empty()
&& !parent.exists()
{
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory: {}", e))?; .map_err(|e| format!("Failed to create directory: {}", e))?;
} }
}
// 写入文件 // 写入文件
std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?; std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?;
@ -138,16 +139,7 @@ fn resolve_topic_filepath(filepath: Option<String>, topic: &TopicRecord) -> Path
None => { None => {
let safe_title = topic let safe_title = topic
.title .title
.replace(' ', "_") .replace([' ', '/', '\\', ':', '<', '>', '|', '?', '*', '"'], "_");
.replace('/', "_")
.replace('\\', "_")
.replace(':', "_")
.replace('<', "_")
.replace('>', "_")
.replace('|', "_")
.replace('?', "_")
.replace('*', "_")
.replace('"', "_");
let base_name = if safe_title.is_empty() { let base_name = if safe_title.is_empty() {
format!("topic_{}", &topic.id[..8.min(topic.id.len())]) format!("topic_{}", &topic.id[..8.min(topic.id.len())])
@ -267,7 +259,7 @@ async fn handle_save_topic(
topic_id, topic_id,
filepath, filepath,
include_subagents, include_subagents,
&*handler.store, &handler.store,
Some(handler.task_repository.as_ref()), Some(handler.task_repository.as_ref()),
&*handler.system_prompt_provider, &*handler.system_prompt_provider,
&messages, &messages,
@ -282,14 +274,14 @@ async fn handle_save_topic(
MessageKind::Notification, MessageKind::Notification,
// 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉, // 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉,
// 统一转换为正斜杠以保证显示完整(跨平台兼容) // 统一转换为正斜杠以保证显示完整(跨平台兼容)
&format!( format!(
"Topic saved to: {}", "Topic saved to: {}",
output_path.display().to_string().replace('\\', "/") output_path.display().to_string().replace('\\', "/")
), ),
) )
.with_metadata( .with_metadata(
"filepath", "filepath",
&output_path.display().to_string().replace('\\', "/"), output_path.display().to_string().replace('\\', "/"),
) )
.with_metadata("message_count", &message_count.to_string())) .with_metadata("message_count", message_count.to_string()))
} }

View File

@ -94,14 +94,14 @@ async fn handle_create_session(
.ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?; .ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?;
// 如果有 SessionManager自动切换到新话题 // 如果有 SessionManager自动切换到新话题
if let Some(ref session_manager) = handler.session_manager { if let Some(ref session_manager) = handler.session_manager
if let Some(session) = session_manager.get(&ctx.channel_name).await { && let Some(session) = session_manager.get(&ctx.channel_name).await
{
let mut session_guard = session.lock().await; let mut session_guard = session.lock().await;
session_guard session_guard
.switch_topic(chat_id, &topic.id) .switch_topic(chat_id, &topic.id)
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?;
} }
}
// Query the full topic list so the frontend sidebar can update // Query the full topic list so the frontend sidebar can update
let topics = handler let topics = handler
@ -119,7 +119,7 @@ async fn handle_create_session(
.with_metadata("topics", &topics_json) .with_metadata("topics", &topics_json)
.with_metadata("topic_id", &topic.id) .with_metadata("topic_id", &topic.id)
.with_metadata("session_id", &topic.session_id) .with_metadata("session_id", &topic.session_id)
.with_metadata("message_count", &topic.message_count.to_string())) .with_metadata("message_count", topic.message_count.to_string()))
} }
#[cfg(test)] #[cfg(test)]

View File

@ -108,10 +108,7 @@ impl CommandHandler for StopExecutionCommandHandler {
if cancelled || cancelled_subagents > 0 { if cancelled || cancelled_subagents > 0 {
let msg = if cancelled && cancelled_subagents > 0 { let msg = if cancelled && cancelled_subagents > 0 {
format!( format!("正在停止当前任务及 {} 个后台子代理...", cancelled_subagents)
"正在停止当前任务及 {} 个后台子代理...",
cancelled_subagents
)
} else if cancelled { } else if cancelled {
"正在停止当前任务...".to_string() "正在停止当前任务...".to_string()
} else { } else {

View File

@ -103,14 +103,14 @@ async fn handle_switch_topic(
})?; })?;
// 如果有 SessionManager实际切换话题历史 // 如果有 SessionManager实际切换话题历史
if let Some(ref session_manager) = handler.session_manager { if let Some(ref session_manager) = handler.session_manager
if let Some(session) = session_manager.get(&ctx.channel_name).await { && let Some(session) = session_manager.get(&ctx.channel_name).await
{
let mut session_guard = session.lock().await; let mut session_guard = session.lock().await;
session_guard session_guard
.switch_topic(chat_id, &target_topic_id) .switch_topic(chat_id, &target_topic_id)
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?;
} }
}
// 使用辅助方法获取消息数量 // 使用辅助方法获取消息数量
let msg_count = handler let msg_count = handler
@ -127,5 +127,5 @@ async fn handle_switch_topic(
.with_message(MessageKind::Notification, &message) .with_message(MessageKind::Notification, &message)
.with_metadata("topic_id", &topic.id) .with_metadata("topic_id", &topic.id)
.with_metadata("title", &topic.title) .with_metadata("title", &topic.title)
.with_metadata("message_count", &msg_count.to_string())) .with_metadata("message_count", msg_count.to_string()))
} }

View File

@ -128,7 +128,7 @@ impl Default for CompactionConfig {
} }
/// 可观测性配置日志格式、metrics 开关等) /// 可观测性配置日志格式、metrics 开关等)
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ObservabilityConfig { pub struct ObservabilityConfig {
/// 日志输出格式text默认或 json。 /// 日志输出格式text默认或 json。
/// json 格式便于接入 ELK/Loki 等日志聚合系统。 /// json 格式便于接入 ELK/Loki 等日志聚合系统。
@ -136,14 +136,6 @@ pub struct ObservabilityConfig {
pub log_format: LogFormat, pub log_format: LogFormat,
} }
impl Default for ObservabilityConfig {
fn default() -> Self {
Self {
log_format: LogFormat::default(),
}
}
}
/// 日志输出格式 /// 日志输出格式
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)] #[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@ -631,6 +623,11 @@ pub struct GatewayConfig {
/// 前端通过 Authorization: Bearer <token> 头或 WS query param ?token=<token> 携带。 /// 前端通过 Authorization: Bearer <token> 头或 WS query param ?token=<token> 携带。
#[serde(default, rename = "auth_token")] #[serde(default, rename = "auth_token")]
pub auth_token: Option<String>, pub auth_token: Option<String>,
/// 远程部署时允许的跨域来源白名单(如 ["https://bot.example.com"])。
/// 绑定非 loopback 时生效:配置后 CORS 仅放行列出的 origin
/// 未配置则允许任意 origin此时由 auth_token 提供保护,启动时会打 warn 日志提示加固)。
#[serde(default, rename = "allowed_origins")]
pub allowed_origins: Option<Vec<String>>,
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
@ -941,6 +938,7 @@ impl Default for GatewayConfig {
max_concurrent_requests: default_max_concurrent_requests(), max_concurrent_requests: default_max_concurrent_requests(),
session_ttl_hours: Some(24), session_ttl_hours: Some(24),
auth_token: None, auth_token: None,
allowed_origins: None,
} }
} }
} }
@ -2305,25 +2303,33 @@ mod tests {
#[test] #[test]
fn test_scheduler_schedule_validation_rejects_invalid_values() { fn test_scheduler_schedule_validation_rejects_invalid_values() {
assert!(SchedulerSchedule::Delay { seconds: 0 } assert!(
SchedulerSchedule::Delay { seconds: 0 }
.validate("delay.job") .validate("delay.job")
.is_err()); .is_err()
assert!(SchedulerSchedule::Interval { );
assert!(
SchedulerSchedule::Interval {
seconds: 0, seconds: 0,
startup_delay_secs: 0, startup_delay_secs: 0,
} }
.validate("interval.job") .validate("interval.job")
.is_err()); .is_err()
assert!(SchedulerSchedule::At { );
assert!(
SchedulerSchedule::At {
timestamp: "bad timestamp".to_string(), timestamp: "bad timestamp".to_string(),
} }
.validate("at.job") .validate("at.job")
.is_err()); .is_err()
assert!(SchedulerSchedule::Cron { );
assert!(
SchedulerSchedule::Cron {
expression: "bad cron".to_string(), expression: "bad cron".to_string(),
} }
.validate("cron.job") .validate("cron.job")
.is_err()); .is_err()
);
} }
#[test] #[test]

View File

@ -63,14 +63,14 @@ impl CapabilityPolicy {
/// 校验指定子代理是否被允许。返回 Err 时附带拒绝原因。 /// 校验指定子代理是否被允许。返回 Err 时附带拒绝原因。
pub fn check_subagent_allowed(&self, name: &str) -> Result<(), String> { pub fn check_subagent_allowed(&self, name: &str) -> Result<(), String> {
if let Some(list) = &self.allowed_subagents { if let Some(list) = &self.allowed_subagents
if !list.iter().any(|s| s == name) { && !list.iter().any(|s| s == name)
{
return Err(format!( return Err(format!(
"subagent '{}' is not in the allowed_subagents whitelist", "subagent '{}' is not in the allowed_subagents whitelist",
name name
)); ));
} }
}
if self.denied_subagents.iter().any(|s| s == name) { if self.denied_subagents.iter().any(|s| s == name) {
return Err(format!( return Err(format!(
"subagent '{}' is in the denied_subagents blacklist", "subagent '{}' is in the denied_subagents blacklist",

View File

@ -1,12 +1,12 @@
use crate::config::ExpertsConfig; use crate::config::ExpertsConfig;
use crate::domain::CapabilityPolicy; use crate::domain::CapabilityPolicy;
use crate::platform::{atomic_rename, home_dir as platform_home_dir}; use crate::platform::{atomic_rename, home_dir as platform_home_dir};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use parking_lot::RwLock;
#[cfg(test)] #[cfg(test)]
static EXPERT_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); static EXPERT_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
@ -294,18 +294,13 @@ impl ExpertRuntime {
/// Re-discover experts from the filesystem. /// Re-discover experts from the filesystem.
pub fn reload(&self) -> Result<ExpertCatalog, String> { pub fn reload(&self) -> Result<ExpertCatalog, String> {
let config = self let config = self.config.read().clone();
.config
.read()
.clone();
let catalog = ExpertCatalog::discover_with_state( let catalog = ExpertCatalog::discover_with_state(
&config, &config,
&self.cwd, &self.cwd,
Some(&load_expert_disable_state(&self.cwd)), Some(&load_expert_disable_state(&self.cwd)),
); );
let mut guard = self let mut guard = self.catalog.write();
.catalog
.write();
*guard = catalog.clone(); *guard = catalog.clone();
Ok(catalog) Ok(catalog)
} }
@ -323,18 +318,12 @@ impl ExpertRuntime {
/// List enabled experts (disabled ones are filtered out). /// List enabled experts (disabled ones are filtered out).
pub fn list_experts(&self) -> Vec<Expert> { pub fn list_experts(&self) -> Vec<Expert> {
self.catalog self.catalog.read().experts.clone()
.read()
.experts
.clone()
} }
/// List all discovered experts including disabled ones, with their disabled scopes. /// List all discovered experts including disabled ones, with their disabled scopes.
pub fn list_experts_with_status(&self) -> Vec<ExpertWithStatus> { pub fn list_experts_with_status(&self) -> Vec<ExpertWithStatus> {
let config = self let config = self.config.read().clone();
.config
.read()
.clone();
let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd); let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd);
let disable_state = load_expert_disable_state(&self.cwd); let disable_state = load_expert_disable_state(&self.cwd);
@ -361,10 +350,7 @@ impl ExpertRuntime {
} }
pub fn get_expert(&self, name: &str) -> Option<Expert> { pub fn get_expert(&self, name: &str) -> Option<Expert> {
self.catalog self.catalog.read().find_expert(name).cloned()
.read()
.find_expert(name)
.cloned()
} }
pub fn create_expert( pub fn create_expert(
@ -474,10 +460,7 @@ impl ExpertRuntime {
pub fn has_expert_definition(&self, name: &str) -> Result<bool, String> { pub fn has_expert_definition(&self, name: &str) -> Result<bool, String> {
validate_expert_name(name)?; validate_expert_name(name)?;
let config = self let config = self.config.read().clone();
.config
.read()
.clone();
let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd); let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd);
Ok(catalog.find_expert(name).is_some()) Ok(catalog.find_expert(name).is_some())
} }
@ -509,9 +492,7 @@ impl ExpertRuntime {
// update in-memory disable_state // update in-memory disable_state
{ {
let mut state = self let mut state = self.disable_state.write();
.disable_state
.write();
match scope { match scope {
ExpertScope::User => { ExpertScope::User => {
if enabled { if enabled {
@ -533,9 +514,7 @@ impl ExpertRuntime {
// refresh catalog so list_experts / get_expert reflect the change // refresh catalog so list_experts / get_expert reflect the change
let _ = self.reload()?; let _ = self.reload()?;
let state = self let state = self.disable_state.read();
.disable_state
.read();
let disabled_in_scopes = state.disabled_scopes_for(name); let disabled_in_scopes = state.disabled_scopes_for(name);
Ok(ExpertAvailabilityChange { Ok(ExpertAvailabilityChange {
@ -558,9 +537,7 @@ impl ExpertRuntime {
} }
{ {
let mut sessions = self let mut sessions = self.session_experts.write();
.session_experts
.write();
sessions.insert(session_id.to_string(), expert_name.to_string()); sessions.insert(session_id.to_string(), expert_name.to_string());
} }
persist_session_experts(&self.cwd, |state| { persist_session_experts(&self.cwd, |state| {
@ -573,9 +550,7 @@ impl ExpertRuntime {
/// Clear the selected expert for a session. /// Clear the selected expert for a session.
pub fn clear_expert(&self, session_id: &str) -> Result<(), String> { pub fn clear_expert(&self, session_id: &str) -> Result<(), String> {
{ {
let mut sessions = self let mut sessions = self.session_experts.write();
.session_experts
.write();
sessions.remove(session_id); sessions.remove(session_id);
} }
persist_session_experts(&self.cwd, |state| { persist_session_experts(&self.cwd, |state| {
@ -586,16 +561,12 @@ impl ExpertRuntime {
/// Returns the expert selected for a session, or None if none selected / disabled / not found. /// Returns the expert selected for a session, or None if none selected / disabled / not found.
pub fn selected_expert_for(&self, session_id: &str) -> Option<Expert> { pub fn selected_expert_for(&self, session_id: &str) -> Option<Expert> {
let name = { let name = {
let sessions = self let sessions = self.session_experts.read();
.session_experts
.read();
sessions.get(session_id).cloned() sessions.get(session_id).cloned()
}?; }?;
// Filter out disabled experts. // Filter out disabled experts.
let state = self let state = self.disable_state.read();
.disable_state
.read();
if state.is_disabled(&name) { if state.is_disabled(&name) {
return None; return None;
} }

View File

@ -3,7 +3,9 @@ use std::sync::Arc;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::agent::context_compressor::ContextCompressor; use crate::agent::context_compressor::ContextCompressor;
use crate::agent::{AgentError, AgentLoop, AgentRuntimeConfig, CompositeSystemPromptProvider, SystemPromptProvider}; use crate::agent::{
AgentError, AgentLoop, AgentRuntimeConfig, CompositeSystemPromptProvider, SystemPromptProvider,
};
use crate::config::{CompactionConfig, LLMProviderConfig, ModelResolver}; use crate::config::{CompactionConfig, LLMProviderConfig, ModelResolver};
use crate::domain::CapabilityPolicy; use crate::domain::CapabilityPolicy;
use crate::experts::ExpertPromptProvider; use crate::experts::ExpertPromptProvider;
@ -14,10 +16,10 @@ use crate::gateway::tool_prompt_provider::ToolPromptProvider;
use crate::observability::Observer; use crate::observability::Observer;
use crate::skills::{SkillPromptProvider, SkillRuntime}; use crate::skills::{SkillPromptProvider, SkillRuntime};
use crate::storage::PromptInjectionRepository; use crate::storage::PromptInjectionRepository;
use crate::storage::persistent_session_id;
use crate::storage::SessionStore; use crate::storage::SessionStore;
use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime}; use crate::storage::persistent_session_id;
use crate::tools::task::SubagentResult; use crate::tools::task::SubagentResult;
use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime};
use crate::tools::{ToolContext, ToolRegistry, WaitCoordinator}; use crate::tools::{ToolContext, ToolRegistry, WaitCoordinator};
/// 构建与 Agent 实际使用的完全一致的组合系统提示词 Provider。 /// 构建与 Agent 实际使用的完全一致的组合系统提示词 Provider。
@ -133,7 +135,10 @@ impl AgentFactory {
/// 构造 ContextCompressor参数内聚到 ContextCompressorCompactionConfig 注入)。 /// 构造 ContextCompressor参数内聚到 ContextCompressorCompactionConfig 注入)。
/// AgentLoopin-loop 压缩)和 Sessionsync 兜底压缩)共用此方法, /// AgentLoopin-loop 压缩)和 Sessionsync 兜底压缩)共用此方法,
/// 确保两条压缩路径使用同一套用户配置的压缩参数。 /// 确保两条压缩路径使用同一套用户配置的压缩参数。
pub(crate) fn build_compressor(&self, runtime_config: &AgentRuntimeConfig) -> ContextCompressor { pub(crate) fn build_compressor(
&self,
runtime_config: &AgentRuntimeConfig,
) -> ContextCompressor {
ContextCompressor::with_compaction_config( ContextCompressor::with_compaction_config(
runtime_config.context_window_tokens, runtime_config.context_window_tokens,
runtime_config.context_summary_char_budget, runtime_config.context_summary_char_budget,
@ -201,14 +206,17 @@ impl AgentFactory {
// 物化:命中 session 级选择且话题无固化值时,将解析后的具体 // 物化:命中 session 级选择且话题无固化值时,将解析后的具体
// (provider, model) 写入 topics 行(持久化 + 内存缓存) // (provider, model) 写入 topics 行(持久化 + 内存缓存)
if !from_topic { if !from_topic && let Some(tid) = request.topic_id.as_deref() {
if let Some(tid) = request.topic_id.as_deref() {
let provider = resolved.name.clone(); let provider = resolved.name.clone();
let model = resolved.model_id.clone(); let model = resolved.model_id.clone();
self.topic_model_selections self.topic_model_selections.set(
.set(tid, Some(provider.clone()), Some(model.clone())); tid,
Some(provider.clone()),
Some(model.clone()),
);
if let Err(err) = if let Err(err) =
self.store.update_topic_model(tid, Some(&provider), Some(&model)) self.store
.update_topic_model(tid, Some(&provider), Some(&model))
{ {
tracing::warn!( tracing::warn!(
error = %err, error = %err,
@ -217,7 +225,6 @@ impl AgentFactory {
); );
} }
} }
}
tracing::info!( tracing::info!(
instance_id = self.instance_id, instance_id = self.instance_id,
@ -289,7 +296,7 @@ impl AgentFactory {
// 供 wait_for_subagents 工具传递给 coordinator.wait() 的 select!。 // 供 wait_for_subagents 工具传递给 coordinator.wait() 的 select!。
// watch::Receiver::clone() 创建共享同一 sender 的新 receiver // watch::Receiver::clone() 创建共享同一 sender 的新 receiver
// 各 receiver 的 has_changed()/changed() 状态独立,互不影响。 // 各 receiver 的 has_changed()/changed() 状态独立,互不影响。
let cancel_rx_for_context = request.cancel_token.as_ref().map(|rx| rx.clone()); let cancel_rx_for_context = request.cancel_token.clone();
let runtime_config = AgentRuntimeConfig::from(effective_provider_config.clone()); let runtime_config = AgentRuntimeConfig::from(effective_provider_config.clone());
let compressor = Arc::new(self.build_compressor(&runtime_config)); let compressor = Arc::new(self.build_compressor(&runtime_config));

View File

@ -42,8 +42,9 @@ impl AgentPromptProvider {
/// 记录注入事件 /// 记录注入事件
fn record_injection(&self, context: &SystemPromptContext) { fn record_injection(&self, context: &SystemPromptContext) {
if let Some(session_id) = &context.session_id { if let Some(session_id) = &context.session_id
if let Err(e) = self.repository.mark_agent_prompt_reinjected(session_id) { && let Err(e) = self.repository.mark_agent_prompt_reinjected(session_id)
{
tracing::warn!( tracing::warn!(
session_id = ?session_id, session_id = ?session_id,
error = %e, error = %e,
@ -52,7 +53,6 @@ impl AgentPromptProvider {
} }
} }
} }
}
impl SystemPromptProvider for AgentPromptProvider { impl SystemPromptProvider for AgentPromptProvider {
fn build(&self, context: &SystemPromptContext) -> Option<SystemPrompt> { fn build(&self, context: &SystemPromptContext) -> Option<SystemPrompt> {

View File

@ -1,16 +1,17 @@
//! 网关认证与访问控制。 //! 网关认证与访问控制。
//! //!
//! 设计目标(第一性原理): //! 设计目标(第一性原理):
//! - 本地单机部署host 为 loopback免认证仅靠 CORS 防御 DNS rebinding / CSRF。 //! - 本地单机部署host 为 loopback免认证靠 Host 头 loopback 校验(防 DNS rebinding
//! + CORS loopback origin 白名单(防跨域读取)+ WS Origin loopback 校验(防 CSWSH
//! - 远程访问host 非 loopback必须配置 `auth_token`,所有 `/api/*` 与 `/ws` 强制校验。 //! - 远程访问host 非 loopback必须配置 `auth_token`,所有 `/api/*` 与 `/ws` 强制校验。
//! - token 通过 `Authorization: Bearer <token>`HTTP或 `?token=<token>`WS传递。 //! - token 通过 `Authorization: Bearer <token>`HTTP或 `?token=<token>`WS传递。
//! - 校验使用常量时间比较,避免计时侧信道。 //! - 校验使用常量时间比较,避免计时侧信道。
use axum::Json;
use axum::extract::Request; use axum::extract::Request;
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode, header};
use axum::middleware::Next; use axum::middleware::Next;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json; use serde_json::json;
use subtle::ConstantTimeEq; use subtle::ConstantTimeEq;
@ -88,11 +89,7 @@ pub fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
/// 仅在 `requires_auth` 为 true 时挂载。 /// 仅在 `requires_auth` 为 true 时挂载。
/// `/health`、`/ws`、静态资源放行;`/ws` 的 token 校验在 ws_handler 内完成。 /// `/health`、`/ws`、静态资源放行;`/ws` 的 token 校验在 ws_handler 内完成。
/// `/metrics` 包含运行时指标provider/model/耗时/token 用量),远程部署时需保护。 /// `/metrics` 包含运行时指标provider/model/耗时/token 用量),远程部署时需保护。
pub async fn require_bearer_auth( pub async fn require_bearer_auth(headers: HeaderMap, request: Request, next: Next) -> Response {
headers: HeaderMap,
request: Request,
next: Next,
) -> Response {
let path = request.uri().path(); let path = request.uri().path();
// /api/* 和 /metrics 需要认证;其余放行 // /api/* 和 /metrics 需要认证;其余放行
@ -126,6 +123,85 @@ pub struct AuthConfig {
pub token: Option<String>, pub token: Option<String>,
} }
/// 提取 Origin 头的 authorityhost[:port])部分。
/// 格式如 `https://bot.example.com:8443/path` -> `bot.example.com:8443`。
/// 无法解析(如隐私上下文下的 `Origin: null`)时返回 None。
pub(crate) fn origin_authority(origin: &str) -> Option<&str> {
let rest = origin.split_once("://").map(|(_, r)| r)?;
let authority = rest.split(['/', '?', '#']).next()?.trim();
if authority.is_empty() {
None
} else {
Some(authority)
}
}
/// 判定 `host[:port]` 形式的主机值是否为 loopback供 Host 头 / Origin authority 复用)。
/// - `127.0.0.1:19876` / `localhost:19876` -> true
/// - `[::1]:19876` -> true
/// - `evil.com:19876` -> false
/// - 无括号的裸 IPv6如 `::1`)按最后一个冒号拆分,行为保守(浏览器 Host 头恒带括号)
pub fn host_port_is_loopback(host_value: &str) -> bool {
let host_value = host_value.trim();
let host_part = if let Some(rest) = host_value.strip_prefix('[') {
match rest.split_once(']') {
Some((inner, _)) => inner,
None => return false,
}
} else {
match host_value.rsplit_once(':') {
Some((h, port_like)) if port_like.chars().all(|c| c.is_ascii_digit()) => h,
_ => host_value,
}
};
is_loopback_host(host_part)
}
/// 判定 Origin 值是否为 loopback 来源authority 的主机部分属于 loopback 段,端口不限)。
/// `Origin: null` 或无法解析时返回 false。
pub fn origin_is_loopback(origin: &str) -> bool {
origin_authority(origin.trim())
.map(host_port_is_loopback)
.unwrap_or(false)
}
/// 校验 WebSocket 升级请求的 Origin防跨站 WebSocket 劫持 CSWSH
/// 仅在无 token 认证保护时loopback 免认证模式)调用:
///
/// - 无 Origin 头:放行(非浏览器客户端不发送 Origin
/// - Origin 为 loopback 来源放行覆盖同源、localhost↔127.0.0.1、vite dev 等合法场景)
/// - 其余(公网域名、`Origin: null`):拒绝。
///
/// 注意不能用 Origin==Host 相等判定DNS rebinding 下两者一致,
/// 而合法开发场景中两者常常不同localhost:5173 → 127.0.0.1:19876
pub fn ws_origin_loopback(headers: &HeaderMap) -> bool {
let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) else {
return true;
};
origin_is_loopback(origin)
}
/// axum 中间件:强制 Host 头为 loopback仅在 loopback 免认证模式下挂载)。
/// 阻断 DNS rebinding重绑定后浏览器发送的 Host 为攻击者域名,直接拒绝。
/// 无 Host 头的请求同样拒绝(浏览器恒发送 Host
pub async fn require_loopback_host(headers: HeaderMap, request: Request, next: Next) -> Response {
let host_ok = headers
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.map(host_port_is_loopback)
.unwrap_or(false);
if host_ok {
next.run(request).await
} else {
tracing::warn!("Request rejected: Host header is not loopback (possible DNS rebinding)");
(
StatusCode::FORBIDDEN,
Json(json!({ "error": "forbidden", "message": "Host header must be loopback" })),
)
.into_response()
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -242,4 +318,88 @@ mod tests {
assert_eq!(extract_bearer_token(&make_auth_header("Bearer")), None); assert_eq!(extract_bearer_token(&make_auth_header("Bearer")), None);
assert_eq!(extract_bearer_token(&make_auth_header("Bearer ")), Some("")); assert_eq!(extract_bearer_token(&make_auth_header("Bearer ")), Some(""));
} }
fn make_origin_host_headers(origin: Option<&str>, host: Option<&str>) -> HeaderMap {
let mut h = HeaderMap::new();
if let Some(o) = origin {
h.insert(
axum::http::header::ORIGIN,
axum::http::HeaderValue::from_str(o).unwrap(),
);
}
if let Some(host) = host {
h.insert(
axum::http::header::HOST,
axum::http::HeaderValue::from_str(host).unwrap(),
);
}
h
}
#[test]
fn host_port_loopback_detection() {
assert!(host_port_is_loopback("127.0.0.1:19876"));
assert!(host_port_is_loopback("127.0.0.1"));
assert!(host_port_is_loopback("localhost:19876"));
assert!(host_port_is_loopback("[::1]:19876"));
assert!(host_port_is_loopback(" localhost:19876 "));
assert!(!host_port_is_loopback("evil.com:19876"));
assert!(!host_port_is_loopback("evil.com"));
assert!(!host_port_is_loopback("192.168.1.1:19876"));
assert!(!host_port_is_loopback(""));
// 未闭合括号:拒绝
assert!(!host_port_is_loopback("[::1:19876"));
}
#[test]
fn origin_loopback_detection() {
assert!(origin_is_loopback("http://127.0.0.1:19876"));
assert!(origin_is_loopback("http://localhost:5173"));
assert!(origin_is_loopback("http://[::1]:3000"));
// 公网域名:拒绝
assert!(!origin_is_loopback("http://evil.com"));
assert!(!origin_is_loopback("https://evil.com:8443/path"));
// Origin: null隐私上下文拒绝
assert!(!origin_is_loopback("null"));
assert!(!origin_is_loopback(""));
}
#[test]
fn ws_origin_check() {
// 无 Origin非浏览器客户端放行
assert!(ws_origin_loopback(&make_origin_host_headers(
None,
Some("127.0.0.1:19876")
)));
// 同源:放行
assert!(ws_origin_loopback(&make_origin_host_headers(
Some("http://127.0.0.1:19876"),
Some("127.0.0.1:19876")
)));
// vite dev 场景localhost:5173 → 127.0.0.1:19876双方都是 loopback放行
assert!(ws_origin_loopback(&make_origin_host_headers(
Some("http://localhost:5173"),
Some("127.0.0.1:19876")
)));
// localhost ↔ 127.0.0.1 混用:放行
assert!(ws_origin_loopback(&make_origin_host_headers(
Some("http://localhost:19876"),
Some("127.0.0.1:19876")
)));
// 恶意页面直连 127.0.0.1Origin 为公网域名,拒绝
assert!(!ws_origin_loopback(&make_origin_host_headers(
Some("http://evil.com"),
Some("127.0.0.1:19876")
)));
// DNS rebindingHost/Origin 同为攻击者域名,拒绝
assert!(!ws_origin_loopback(&make_origin_host_headers(
Some("http://evil.com:19876"),
Some("evil.com:19876")
)));
// Origin: null拒绝
assert!(!ws_origin_loopback(&make_origin_host_headers(
Some("null"),
Some("127.0.0.1:19876")
)));
}
} }

View File

@ -155,7 +155,7 @@ impl AgentExecutionService {
// 直接比较 current_topic(chat_id) 与 original_topic_id // 直接比较 current_topic(chat_id) 与 original_topic_id
// 这比"检查内存历史最新消息"更可靠,且天然处理"切走又切回"的 case // 这比"检查内存历史最新消息"更可靠,且天然处理"切走又切回"的 case
let is_current_turn = match request.original_topic_id.as_deref() { let is_current_turn = match request.original_topic_id.as_deref() {
Some(orig_tid) => session.current_topic(request.chat_id).as_deref() == Some(orig_tid), Some(orig_tid) => session.current_topic(request.chat_id) == Some(orig_tid),
None => true, // 无 topic 时总是视为当前回合 None => true, // 无 topic 时总是视为当前回合
}; };
@ -419,7 +419,11 @@ impl AgentExecutionService {
); );
let result = agent let result = agent
.process(history, Some(&system_prompt_context), Some(&compaction_sink)) .process(
history,
Some(&system_prompt_context),
Some(&compaction_sink),
)
.await?; .await?;
let mut metadata = HashMap::new(); let mut metadata = HashMap::new();
// 把用户消息的 UUID 回传给前端,前端用此更新本地消息 ID使 todo 点击跳转能匹配 // 把用户消息的 UUID 回传给前端,前端用此更新本地消息 ID使 todo 点击跳转能匹配
@ -605,7 +609,11 @@ impl AgentExecutionService {
); );
let result = agent let result = agent
.process(history, Some(&system_prompt_context), Some(&compaction_sink)) .process(
history,
Some(&system_prompt_context),
Some(&compaction_sink),
)
.await?; .await?;
let outbound_messages = self let outbound_messages = self

View File

@ -65,20 +65,20 @@ fn mask_config(config: &Config) -> Config {
} }
} }
for channel in masked.channels.values_mut() { for channel in masked.channels.values_mut() {
if let Some(feishu) = channel.as_feishu_mut() { if let Some(feishu) = channel.as_feishu_mut()
if !feishu.app_secret.is_empty() { && !feishu.app_secret.is_empty()
{
let visible: String = feishu.app_secret.chars().take(4).collect(); let visible: String = feishu.app_secret.chars().take(4).collect();
feishu.app_secret = format!("{}{}", visible, API_KEY_MASK); feishu.app_secret = format!("{}{}", visible, API_KEY_MASK);
} }
} }
}
// 掩码网关认证 token避免通过 /api/config 泄露) // 掩码网关认证 token避免通过 /api/config 泄露)
if let Some(ref token) = masked.gateway.auth_token { if let Some(ref token) = masked.gateway.auth_token
if !token.is_empty() { && !token.is_empty()
{
let visible: String = token.chars().take(4).collect(); let visible: String = token.chars().take(4).collect();
masked.gateway.auth_token = Some(format!("{}{}", visible, API_KEY_MASK)); masked.gateway.auth_token = Some(format!("{}{}", visible, API_KEY_MASK));
} }
}
masked masked
} }
@ -116,29 +116,27 @@ pub async fn save_config(
{ {
let cfg = state.config.read().await; let cfg = state.config.read().await;
for (name, provider) in new_config.providers.iter_mut() { for (name, provider) in new_config.providers.iter_mut() {
if is_masked_key(&provider.api_key) { if is_masked_key(&provider.api_key)
if let Some(original) = cfg.providers.get(name) { && let Some(original) = cfg.providers.get(name)
{
provider.api_key = original.api_key.clone(); provider.api_key = original.api_key.clone();
} }
} }
}
for (name, channel) in new_config.channels.iter_mut() { for (name, channel) in new_config.channels.iter_mut() {
if let Some(feishu) = channel.as_feishu_mut() { if let Some(feishu) = channel.as_feishu_mut()
if is_masked_key(&feishu.app_secret) { && is_masked_key(&feishu.app_secret)
if let Some(original_channel) = cfg.channels.get(name) { && let Some(original_channel) = cfg.channels.get(name)
if let Some(original_feishu) = original_channel.as_feishu() { && let Some(original_feishu) = original_channel.as_feishu()
{
feishu.app_secret = original_feishu.app_secret.clone(); feishu.app_secret = original_feishu.app_secret.clone();
} }
} }
}
}
}
// 保留原始 auth_token若提交的是掩码值 // 保留原始 auth_token若提交的是掩码值
if let Some(ref submitted) = new_config.gateway.auth_token { if let Some(ref submitted) = new_config.gateway.auth_token
if is_masked_key(submitted) { && is_masked_key(submitted)
{
new_config.gateway.auth_token = cfg.gateway.auth_token.clone(); new_config.gateway.auth_token = cfg.gateway.auth_token.clone();
} }
}
} // read lock released here } // read lock released here
// Validate timezone // Validate timezone
@ -243,9 +241,7 @@ pub async fn list_executions(State(state): State<Arc<GatewayState>>) -> Json<Exe
/// GET /metrics — Prometheus metrics 端点 /// GET /metrics — Prometheus metrics 端点
/// ///
/// 返回 Prometheus 格式的 metrics 文本。若 recorder 未安装则返回 503。 /// 返回 Prometheus 格式的 metrics 文本。若 recorder 未安装则返回 503。
pub async fn metrics_handler( pub async fn metrics_handler(State(state): State<Arc<GatewayState>>) -> (StatusCode, String) {
State(state): State<Arc<GatewayState>>,
) -> (StatusCode, String) {
match &state.prometheus_handle { match &state.prometheus_handle {
Some(handle) => (StatusCode::OK, handle.render()), Some(handle) => (StatusCode::OK, handle.render()),
None => ( None => (
@ -1160,8 +1156,9 @@ pub async fn session_select_model(
// 校验provider/model 名必须在 config 的 providers/models 表中存在 // 校验provider/model 名必须在 config 的 providers/models 表中存在
// (与 AgentFactory::create 中的解析失败行为对齐,提前反馈错误) // (与 AgentFactory::create 中的解析失败行为对齐,提前反馈错误)
let config = state.config.read().await; let config = state.config.read().await;
if let Some(name) = provider.as_ref() { if let Some(name) = provider.as_ref()
if !config.providers.contains_key(name) { && !config.providers.contains_key(name)
{
return ( return (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Json(SelectModelResponse { Json(SelectModelResponse {
@ -1170,9 +1167,9 @@ pub async fn session_select_model(
}), }),
); );
} }
} if let Some(name) = model.as_ref()
if let Some(name) = model.as_ref() { && !config.models.contains_key(name)
if !config.models.contains_key(name) { {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Json(SelectModelResponse { Json(SelectModelResponse {
@ -1181,7 +1178,6 @@ pub async fn session_select_model(
}), }),
); );
} }
}
drop(config); drop(config);
state.model_selections.set(&req.session_id, provider, model); state.model_selections.set(&req.session_id, provider, model);
@ -1274,8 +1270,9 @@ pub async fn topic_select_model(
// 校验provider/model 名必须在 config 的 providers/models 表中存在 // 校验provider/model 名必须在 config 的 providers/models 表中存在
let config = state.config.read().await; let config = state.config.read().await;
if let Some(name) = provider.as_ref() { if let Some(name) = provider.as_ref()
if !config.providers.contains_key(name) { && !config.providers.contains_key(name)
{
return ( return (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Json(SelectModelResponse { Json(SelectModelResponse {
@ -1284,9 +1281,9 @@ pub async fn topic_select_model(
}), }),
); );
} }
} if let Some(name) = model.as_ref()
if let Some(name) = model.as_ref() { && !config.models.contains_key(name)
if !config.models.contains_key(name) { {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Json(SelectModelResponse { Json(SelectModelResponse {
@ -1295,7 +1292,6 @@ pub async fn topic_select_model(
}), }),
); );
} }
}
drop(config); drop(config);
let is_clear = provider.is_none() && model.is_none(); let is_clear = provider.is_none() && model.is_none();
@ -1320,7 +1316,9 @@ pub async fn topic_select_model(
if is_clear { if is_clear {
state.model_selections.set(&topic_session_id, None, None); state.model_selections.set(&topic_session_id, None, None);
} else { } else {
state.model_selections.set(&topic_session_id, provider, model); state
.model_selections
.set(&topic_session_id, provider, model);
} }
( (

View File

@ -707,15 +707,15 @@ pub(crate) fn validate_memory_maintenance_output(
} }
// 检查目标 namespace 是否与源一致 // 检查目标 namespace 是否与源一致
if let Some(src_ns) = source_namespaces.iter().next() { if let Some(src_ns) = source_namespaces.iter().next()
if *src_ns != merge.namespace { && *src_ns != merge.namespace
{
return Err(format!( return Err(format!(
"跨 namespace 合并被禁止: {} → {}", "跨 namespace 合并被禁止: {} → {}",
src_ns, merge.namespace src_ns, merge.namespace
)); ));
} }
} }
}
// 验证 3: 总体合并比例 // 验证 3: 总体合并比例
let merged_ids: HashSet<&str> = output let merged_ids: HashSet<&str> = output
@ -768,7 +768,7 @@ pub(crate) fn apply_memory_maintenance_output(
min_memories_to_keep, min_memories_to_keep,
max_merge_per_group, max_merge_per_group,
) )
.map_err(|e| AgentError::Other(e))?; .map_err(AgentError::Other)?;
let all_candidates = plan.candidates.clone(); let all_candidates = plan.candidates.clone();
@ -834,8 +834,9 @@ pub(crate) fn apply_memory_maintenance_output(
} }
for memory_id in &output.low_value_ids { for memory_id in &output.low_value_ids {
if let Some(candidate) = candidates_by_id.get(memory_id.as_str()) { if let Some(candidate) = candidates_by_id.get(memory_id.as_str())
if deleted_ids.insert(candidate.id.clone()) { && deleted_ids.insert(candidate.id.clone())
{
store store
.delete_memory("user", scope_key, &candidate.namespace, &candidate.key) .delete_memory("user", scope_key, &candidate.namespace, &candidate.key)
.map_err(|err| { .map_err(|err| {
@ -843,7 +844,6 @@ pub(crate) fn apply_memory_maintenance_output(
})?; })?;
} }
} }
}
// 新增:记录整理完成时间 // 新增:记录整理完成时间
let now = chrono::Utc::now().timestamp(); let now = chrono::Utc::now().timestamp();

View File

@ -31,6 +31,8 @@ pub mod tool_registry_factory;
pub mod wait_coordinator; pub mod wait_coordinator;
pub mod ws; pub mod ws;
use axum::extract::DefaultBodyLimit;
use axum::http::{HeaderName, HeaderValue, header};
use axum::{Router, middleware, routing}; use axum::{Router, middleware, routing};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
@ -110,8 +112,15 @@ impl GatewayState {
mcp_servers: config.mcp_servers.clone(), mcp_servers: config.mcp_servers.clone(),
}; };
let (session_manager, task_repository, mcp_manager, subagent_runtime, model_selections, topic_model_selections, subagent_executor) = let (
build_session_manager_with_sender( session_manager,
task_repository,
mcp_manager,
subagent_runtime,
model_selections,
topic_model_selections,
subagent_executor,
) = build_session_manager_with_sender(
agent_prompt_reinject_every, agent_prompt_reinject_every,
show_tool_results, show_tool_results,
config.time.timezone.clone(), config.time.timezone.clone(),
@ -204,6 +213,57 @@ impl GatewayState {
} }
} }
/// HTTP 请求体大小上限(显式声明,与 axum 默认一致,防止隐式依赖默认值)。
const HTTP_BODY_LIMIT: usize = 2 * 1024 * 1024;
/// 内容安全策略:
/// - script/object/base/frame 全部限制同源或禁用,缓解 XSS 与点击劫持
/// - style-src 'unsafe-inline'React 行内 style 属性需要
/// - img-src 含 data:/blob::聊天附件以 base64 data URL 渲染、下载走 blob URL
/// - connect-src 放开 ws:/wss:/http:/https::前端支持用户自定义网关地址(跨源连接属产品特性)
const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
script-src 'self'; \
style-src 'self' 'unsafe-inline'; \
img-src 'self' data: blob:; \
font-src 'self' data:; \
connect-src 'self' ws: wss: http: https:; \
object-src 'none'; \
base-uri 'self'; \
frame-ancestors 'none'";
fn insert_header_if_absent(
headers: &mut axum::http::HeaderMap,
name: HeaderName,
value: &'static str,
) {
headers
.entry(name)
.or_insert(HeaderValue::from_static(value));
}
/// 安全响应头中间件:为所有响应补充防御性 HTTP 头(已存在的头不覆盖)。
async fn security_headers(
request: axum::extract::Request,
next: middleware::Next,
) -> axum::response::Response {
let mut response = next.run(request).await;
let headers = response.headers_mut();
insert_header_if_absent(headers, header::X_CONTENT_TYPE_OPTIONS, "nosniff");
insert_header_if_absent(headers, header::X_FRAME_OPTIONS, "DENY");
insert_header_if_absent(headers, header::REFERRER_POLICY, "no-referrer");
insert_header_if_absent(
headers,
header::CONTENT_SECURITY_POLICY,
CONTENT_SECURITY_POLICY,
);
insert_header_if_absent(
headers,
HeaderName::from_static("permissions-policy"),
"camera=(), microphone=(), geolocation=()",
);
response
}
pub async fn run( pub async fn run(
host: Option<String>, host: Option<String>,
port: Option<u16>, port: Option<u16>,
@ -226,7 +286,11 @@ pub async fn run(
// 将 pending_subagents 表中所有 status='running' 的记录标记为 'interrupted'。 // 将 pending_subagents 表中所有 status='running' 的记录标记为 'interrupted'。
// 下次 wait_for_subagents 调用时,这些 task_id 不会出现在 pending 列表中, // 下次 wait_for_subagents 调用时,这些 task_id 不会出现在 pending 列表中,
// agent 可据此判断子代理未正常完成。 // agent 可据此判断子代理未正常完成。
match state.session_manager.store().mark_all_running_as_interrupted() { match state
.session_manager
.store()
.mark_all_running_as_interrupted()
{
Ok(0) => { Ok(0) => {
tracing::info!("Crash recovery: no interrupted subagents to recover"); tracing::info!("Crash recovery: no interrupted subagents to recover");
} }
@ -252,7 +316,7 @@ pub async fn run(
// Initialize and start channels // Initialize and start channels
state state
.channel_manager .channel_manager
.init(&*cfg, provider_config.clone()) .init(&cfg, provider_config.clone())
.await?; .await?;
drop(cfg); drop(cfg);
state.channel_manager.start_all().await?; state.channel_manager.start_all().await?;
@ -282,11 +346,16 @@ pub async fn run(
} }
// CLI args override config file values // CLI args override config file values
let (bind_host, bind_port, auth_token) = { let (bind_host, bind_port, auth_token, allowed_origins) = {
let cfg = state.config.read().await; let cfg = state.config.read().await;
let h = host.unwrap_or_else(|| cfg.gateway.host.clone()); let h = host.unwrap_or_else(|| cfg.gateway.host.clone());
let p = port.unwrap_or(cfg.gateway.port); let p = port.unwrap_or(cfg.gateway.port);
(h, p, cfg.gateway.auth_token.clone()) (
h,
p,
cfg.gateway.auth_token.clone(),
cfg.gateway.allowed_origins.clone(),
)
}; };
// 安全校验:绑定到非 loopback 地址时必须配置 auth_token // 安全校验:绑定到非 loopback 地址时必须配置 auth_token
@ -400,22 +469,62 @@ pub async fn run(
app.layer(axum::Extension(auth_config)) app.layer(axum::Extension(auth_config))
.layer(middleware::from_fn(auth::require_bearer_auth)) .layer(middleware::from_fn(auth::require_bearer_auth))
} else { } else {
app // loopback 免认证模式:强制 Host 头为 loopback阻断 DNS rebinding
// (重绑定后浏览器发送的 Host 为攻击者域名,会被直接拒绝)。
app.layer(middleware::from_fn(auth::require_loopback_host))
}; };
// CORSloopback 下宽松(仅同源);非 loopback 下允许任意来源(由 auth_token 保护)。 // CORSloopback 下仅允许 loopback 来源(防恶意网页跨域读取本地网关);
// 不论哪种情况都显式设置以避免浏览器默认行为差异 // 非 loopback 下优先使用 allowed_origins 白名单
let cors = if auth::is_loopback_host(&bind_host) { let cors = if auth::is_loopback_host(&bind_host) {
// 本地开发:同源即可,阻止跨域(防 DNS rebinding // 本地:放行 localhost/127.0.0.1 任意端口(覆盖同源与 vite dev 等场景),
// 拒绝公网 origin——mirror_request 会回显任意 Origin反而允许跨域读取不可用。
CorsLayer::new() CorsLayer::new()
.allow_origin(tower_http::cors::AllowOrigin::mirror_request()) .allow_origin(tower_http::cors::AllowOrigin::predicate(
|origin: &HeaderValue, _parts: &axum::http::request::Parts| {
origin
.to_str()
.map(auth::origin_is_loopback)
.unwrap_or(false)
},
))
.allow_methods(Any) .allow_methods(Any)
.allow_headers(Any) .allow_headers(Any)
} else { } else {
// 远程访问:允许跨域,但由 token 保护 // 远程访问:优先 origin 白名单;未配置时允许任意来源(由 token 保护)
let origins: Vec<HeaderValue> = allowed_origins
.iter()
.flatten()
.filter_map(|o| match o.parse::<HeaderValue>() {
Ok(v) => Some(v),
Err(_) => {
tracing::warn!(origin = %o, "gateway.allowed_origins: 非法 origin 已忽略");
None
}
})
.collect();
if origins.is_empty() {
tracing::warn!(
"未配置 gateway.allowed_origins远程部署将允许任意来源跨域访问由 auth_token 保护)。\
config.json gateway "
);
CorsLayer::permissive() CorsLayer::permissive()
} else {
tracing::info!(
origins = origins.len(),
"CORS origin whitelist enabled for remote access"
);
CorsLayer::new()
.allow_origin(origins)
.allow_methods(Any)
.allow_headers(Any)
}
}; };
let app = app.layer(cors); // 层序:后加的在外层。安全响应头最外层,覆盖所有响应(含 401/CORS 预检)。
let app = app
.layer(cors)
.layer(DefaultBodyLimit::max(HTTP_BODY_LIMIT))
.layer(middleware::from_fn(security_headers));
let addr: std::net::SocketAddr = format!("{}:{}", bind_host, bind_port).parse()?; let addr: std::net::SocketAddr = format!("{}:{}", bind_host, bind_port).parse()?;
let listener = { let listener = {

View File

@ -1,5 +1,5 @@
use std::collections::HashMap;
use parking_lot::RwLock; use parking_lot::RwLock;
use std::collections::HashMap;
/// per-session 的用户模型覆盖选择存储。 /// per-session 的用户模型覆盖选择存储。
/// ///
@ -17,9 +17,7 @@ impl ModelSelectionStore {
/// 设置 session 的用户模型覆盖。provider 和 model 均为 None 时清除该 session 的选择。 /// 设置 session 的用户模型覆盖。provider 和 model 均为 None 时清除该 session 的选择。
pub fn set(&self, session_id: &str, provider: Option<String>, model: Option<String>) { pub fn set(&self, session_id: &str, provider: Option<String>, model: Option<String>) {
let mut selections = self let mut selections = self.selections.write();
.selections
.write();
if provider.is_none() && model.is_none() { if provider.is_none() && model.is_none() {
selections.remove(session_id); selections.remove(session_id);
} else { } else {
@ -29,10 +27,7 @@ impl ModelSelectionStore {
/// 读取 session 的用户模型覆盖。 /// 读取 session 的用户模型覆盖。
pub fn get(&self, session_id: &str) -> Option<(Option<String>, Option<String>)> { pub fn get(&self, session_id: &str) -> Option<(Option<String>, Option<String>)> {
self.selections self.selections.read().get(session_id).cloned()
.read()
.get(session_id)
.cloned()
} }
} }

View File

@ -77,30 +77,18 @@ impl OutboundDispatcher {
/// sender task 生命周期与 dispatcher 一致dispatcher `run()` 退出时 /// sender task 生命周期与 dispatcher 一致dispatcher `run()` 退出时
/// 通过 cancel token 终止所有 sender task。 /// 通过 cancel token 终止所有 sender task。
pub async fn register_channel(&self, name: &str, channel: Arc<dyn Channel + Send + Sync>) { pub async fn register_channel(&self, name: &str, channel: Arc<dyn Channel + Send + Sync>) {
let (high_tx, high_rx) = let (high_tx, high_rx) = mpsc::channel::<OutboundMessage>(HIGH_PRIORITY_QUEUE_CAPACITY);
mpsc::channel::<OutboundMessage>(HIGH_PRIORITY_QUEUE_CAPACITY); let (low_tx, low_rx) = mpsc::channel::<OutboundMessage>(LOW_PRIORITY_QUEUE_CAPACITY);
let (low_tx, low_rx) =
mpsc::channel::<OutboundMessage>(LOW_PRIORITY_QUEUE_CAPACITY);
let cancel = CancellationToken::new(); let cancel = CancellationToken::new();
let channel_name = name.to_string(); let channel_name = name.to_string();
let cancel_for_task = cancel.clone(); let cancel_for_task = cancel.clone();
tokio::spawn(async move { tokio::spawn(async move {
Self::run_sender_task( Self::run_sender_task(&channel_name, channel, high_rx, low_rx, cancel_for_task).await;
&channel_name,
channel,
high_rx,
low_rx,
cancel_for_task,
)
.await;
}); });
self.channels self.channels.write().await.insert(
.write()
.await
.insert(
name.to_string(), name.to_string(),
ChannelSink { ChannelSink {
high_tx, high_tx,
@ -166,11 +154,7 @@ impl OutboundDispatcher {
} }
/// 发送单条消息,处理重试结果日志。 /// 发送单条消息,处理重试结果日志。
async fn send_one( async fn send_one(channel: &dyn Channel, channel_name: &str, msg: OutboundMessage) {
channel: &dyn Channel,
channel_name: &str,
msg: OutboundMessage,
) {
let msg_chat_id = msg.chat_id.clone(); let msg_chat_id = msg.chat_id.clone();
let msg_trace_id = msg.trace_id.clone(); let msg_trace_id = msg.trace_id.clone();
match Self::send_with_retry(channel, msg).await { match Self::send_with_retry(channel, msg).await {
@ -419,7 +403,7 @@ mod tests {
return Err(ChannelError::ChannelFull); return Err(ChannelError::ChannelFull);
} }
if (count as u32) < self.fail_first_n { if count < self.fail_first_n {
return Err(ChannelError::SendError("simulated failure".to_string())); return Err(ChannelError::SendError("simulated failure".to_string()));
} }
@ -479,19 +463,42 @@ mod tests {
let error = make_error_message("c", "chat", "agent failed"); let error = make_error_message("c", "chat", "agent failed");
let tool_call = make_low_message("c", "chat", "calling tool"); let tool_call = make_low_message("c", "chat", "calling tool");
let tool_result = OutboundMessage::tool_result( let tool_result = OutboundMessage::tool_result(
"c", "chat", None, "id", "tool", "result", None, "c",
"chat",
None,
"id",
"tool",
"result",
None,
std::collections::HashMap::new(), std::collections::HashMap::new(),
); );
let exec_done = OutboundMessage::execution_completed( let exec_done = OutboundMessage::execution_completed(
"c", "chat", None, "c",
"chat",
None,
std::collections::HashMap::new(), std::collections::HashMap::new(),
); );
assert!(is_high_priority(&assistant), "AssistantResponse should be high priority"); assert!(
assert!(is_high_priority(&error), "ErrorNotification should be high priority"); is_high_priority(&assistant),
assert!(!is_high_priority(&tool_call), "ToolCall should be low priority"); "AssistantResponse should be high priority"
assert!(!is_high_priority(&tool_result), "ToolResult should be low priority"); );
assert!(!is_high_priority(&exec_done), "ExecutionCompleted should be low priority"); assert!(
is_high_priority(&error),
"ErrorNotification should be high priority"
);
assert!(
!is_high_priority(&tool_call),
"ToolCall should be low priority"
);
assert!(
!is_high_priority(&tool_result),
"ToolResult should be low priority"
);
assert!(
!is_high_priority(&exec_done),
"ExecutionCompleted should be low priority"
);
} }
#[tokio::test] #[tokio::test]
@ -514,8 +521,12 @@ mod tests {
}); });
// 先发一条 slow500ms 延迟),紧接着发一条 fast // 先发一条 slow500ms 延迟),紧接着发一条 fast
bus.publish_outbound(make_message("slow", "chat-1", "slow-msg")).await.unwrap(); bus.publish_outbound(make_message("slow", "chat-1", "slow-msg"))
bus.publish_outbound(make_message("fast", "chat-2", "fast-msg")).await.unwrap(); .await
.unwrap();
bus.publish_outbound(make_message("fast", "chat-2", "fast-msg"))
.await
.unwrap();
// 等待 fast 消息被投递(远早于 slow 完成) // 等待 fast 消息被投递(远早于 slow 完成)
tokio::time::timeout(Duration::from_millis(200), async { tokio::time::timeout(Duration::from_millis(200), async {
@ -524,7 +535,9 @@ mod tests {
} }
}) })
.await .await
.expect("fast channel should receive message within 200ms, but was blocked by slow channel"); .expect(
"fast channel should receive message within 200ms, but was blocked by slow channel",
);
// 等待 slow 消息完成 // 等待 slow 消息完成
tokio::time::timeout(Duration::from_secs(2), async { tokio::time::timeout(Duration::from_secs(2), async {
@ -568,8 +581,12 @@ mod tests {
}); });
// 先发 flaky会重试 3 秒),紧接着发 stable // 先发 flaky会重试 3 秒),紧接着发 stable
bus.publish_outbound(make_message("flaky", "chat-1", "flaky-msg")).await.unwrap(); bus.publish_outbound(make_message("flaky", "chat-1", "flaky-msg"))
bus.publish_outbound(make_message("stable", "chat-2", "stable-msg")).await.unwrap(); .await
.unwrap();
bus.publish_outbound(make_message("stable", "chat-2", "stable-msg"))
.await
.unwrap();
// stable 应在 200ms 内收到,远早于 flaky 的 3 秒重试完成 // stable 应在 200ms 内收到,远早于 flaky 的 3 秒重试完成
tokio::time::timeout(Duration::from_millis(200), async { tokio::time::timeout(Duration::from_millis(200), async {
@ -820,7 +837,10 @@ mod tests {
.await .await
.expect("high priority should succeed within extended retry budget"); .expect("high priority should succeed within extended retry budget");
assert!(result.is_ok(), "high priority should succeed after 4 attempts"); assert!(
result.is_ok(),
"high priority should succeed after 4 attempts"
);
assert_eq!( assert_eq!(
call_count.load(Ordering::SeqCst), call_count.load(Ordering::SeqCst),
4, 4,

View File

@ -1,7 +1,7 @@
use std::collections::HashSet;
use std::sync::Arc;
use futures_util::FutureExt; use futures_util::FutureExt;
use parking_lot::Mutex; use parking_lot::Mutex;
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
@ -28,8 +28,8 @@ use crate::providers::{ProviderRuntimeConfig, create_provider};
use crate::storage::persistent_session_id; use crate::storage::persistent_session_id;
use crate::topic_description::generate_topic_description; use crate::topic_description::generate_topic_description;
use super::session::{BusToolCallEmitter, SessionManager};
use super::message_prepare::enrich_user_content_with_media_refs; use super::message_prepare::enrich_user_content_with_media_refs;
use super::session::{BusToolCallEmitter, SessionManager};
#[derive(Clone)] #[derive(Clone)]
pub struct InboundProcessor { pub struct InboundProcessor {
@ -180,8 +180,7 @@ impl InboundProcessor {
let chat_id_for_span = inbound.chat_id.clone(); let chat_id_for_span = inbound.chat_id.clone();
let session_id_for_span = let session_id_for_span =
crate::storage::persistent_session_id(&inbound.channel, &inbound.chat_id); crate::storage::persistent_session_id(&inbound.channel, &inbound.chat_id);
tokio::spawn( tokio::spawn(crate::observability::tracing_ctx::traced(
crate::observability::tracing_ctx::traced(
&trace_id, &trace_id,
&chat_id_for_span, &chat_id_for_span,
&session_id_for_span, &session_id_for_span,
@ -211,8 +210,7 @@ impl InboundProcessor {
} }
} }
}, },
), ));
);
} }
} }
@ -280,8 +278,8 @@ impl InboundProcessor {
} }
} }
} }
} else if let Some(error) = response.error { } else if let Some(error) = response.error
if let Err(e) = self && let Err(e) = self
.bus .bus
.publish_outbound( .publish_outbound(
OutboundMessage::assistant( OutboundMessage::assistant(
@ -305,7 +303,6 @@ impl InboundProcessor {
} }
} }
} }
}
return Ok(()); return Ok(());
} }
@ -326,8 +323,9 @@ impl InboundProcessor {
// //
// 安全性is_waiting 在持锁状态下检查wait_coordinator 清除 is_waiting 需先重获取锁, // 安全性is_waiting 在持锁状态下检查wait_coordinator 清除 is_waiting 需先重获取锁,
// 两者互斥,无 TOCTOU。 // 两者互斥,无 TOCTOU。
if let Some(ref topic_id) = current_topic { if let Some(ref topic_id) = current_topic
if let Some(session) = self.session_manager.get(&inbound.channel).await { && let Some(session) = self.session_manager.get(&inbound.channel).await
{
let lock_key = topic_id.clone(); let lock_key = topic_id.clone();
// 获取 serial_lock Arc短暂持有 session 锁) // 获取 serial_lock Arc短暂持有 session 锁)
@ -362,20 +360,12 @@ impl InboundProcessor {
g.ensure_chat_loaded(&inbound.chat_id, Some(&lock_key))?; g.ensure_chat_loaded(&inbound.chat_id, Some(&lock_key))?;
// 构造用户消息(与 prepare_and_execute_message 一致的处理流程) // 构造用户消息(与 prepare_and_execute_message 一致的处理流程)
let media_refs: Vec<String> = inbound let media_refs: Vec<String> =
.media inbound.media.iter().map(|m| m.path.clone()).collect();
.iter()
.map(|m| m.path.clone())
.collect();
let enriched_content = let enriched_content =
enrich_user_content_with_media_refs(&inbound.content, &media_refs)?; enrich_user_content_with_media_refs(&inbound.content, &media_refs)?;
let user_message = let user_message = g.create_user_message(&enriched_content, media_refs);
g.create_user_message(&enriched_content, media_refs); g.append_persisted_message(&inbound.chat_id, Some(&lock_key), user_message)?;
g.append_persisted_message(
&inbound.chat_id,
Some(&lock_key),
user_message,
)?;
// 获取 wakeup 信号 // 获取 wakeup 信号
g.wait_wakeup(&lock_key) g.wait_wakeup(&lock_key)
@ -393,7 +383,6 @@ impl InboundProcessor {
} }
// is_waiting=false_inject_guard drop 释放锁,走正常 handle_message 路径 // is_waiting=false_inject_guard drop 释放锁,走正常 handle_message 路径
} }
}
let live_emitter = Arc::new(PersistingEmittedMessageHandler::new( let live_emitter = Arc::new(PersistingEmittedMessageHandler::new(
BusToolCallEmitter::new( BusToolCallEmitter::new(
@ -461,18 +450,27 @@ impl InboundProcessor {
// 异步生成 topic 描述(仅当描述为空且没有正在进行的生成任务时触发) // 异步生成 topic 描述(仅当描述为空且没有正在进行的生成任务时触发)
if let Some(ref topic_id) = current_topic { if let Some(ref topic_id) = current_topic {
let store = self.session_manager.store(); let store = self.session_manager.store();
if let Ok(Some(topic)) = store.get_topic(topic_id) { // SQLite 是同步 I/O放到 blocking 线程池,避免阻塞 tokio worker
if topic.description.is_none() let store_for_lookup = store.clone();
let topic_id_for_lookup = topic_id.clone();
let topic_row = tokio::task::spawn_blocking(move || {
store_for_lookup.get_topic(&topic_id_for_lookup)
})
.await
.ok()
.and_then(|r| r.ok())
.unwrap_or(None);
if let Some(topic) = topic_row
&& (topic.description.is_none()
|| topic || topic
.description .description
.as_ref() .as_ref()
.map(|d| d.is_empty()) .map(|d| d.is_empty())
.unwrap_or(true) .unwrap_or(true))
{ {
// 检查并设置"生成中"守卫,防止竞态条件导致重复生成 // 检查并设置"生成中"守卫,防止竞态条件导致重复生成
let should_generate = { let should_generate = {
let mut in_flight = let mut in_flight = self.description_generation_in_flight.lock();
self.description_generation_in_flight.lock();
if in_flight.contains(topic_id) { if in_flight.contains(topic_id) {
false false
} else { } else {
@ -488,14 +486,17 @@ impl InboundProcessor {
let in_flight = self.description_generation_in_flight.clone(); let in_flight = self.description_generation_in_flight.clone();
tokio::spawn(async move { tokio::spawn(async move {
// 从 DB 查询该 topic 的第一条用户消息作为描述生成的依据 // 定向查询该 topic 的第一条用户消息DB 侧 LIMIT 1
let first_user_message = store_clone // 不再全量加载整个话题历史),并放到 blocking 线程池执行
.load_messages_for_topic_full(&topic_id_clone, None) let store_for_query = store_clone.clone();
.ok() let topic_id_for_query = topic_id_clone.clone();
.and_then(|msgs| { let first_user_message = tokio::task::spawn_blocking(move || {
msgs.into_iter().find(|m| m.role == "user") store_for_query.first_user_message_content(&topic_id_for_query)
}) })
.map(|m| m.content); .await
.ok()
.and_then(|r| r.ok())
.unwrap_or(None);
let message_content = match first_user_message { let message_content = match first_user_message {
Some(content) => content, Some(content) => content,
@ -506,8 +507,7 @@ impl InboundProcessor {
} }
}; };
let runtime_config: ProviderRuntimeConfig = let runtime_config: ProviderRuntimeConfig = provider_config.into();
provider_config.into();
if let Ok(provider) = create_provider(runtime_config) { if let Ok(provider) = create_provider(runtime_config) {
match generate_topic_description( match generate_topic_description(
provider.as_ref(), provider.as_ref(),
@ -516,16 +516,28 @@ impl InboundProcessor {
.await .await
{ {
Ok(description) => { Ok(description) => {
if let Err(e) = store_clone let store_for_update = store_clone.clone();
.update_topic_description( let topic_id_for_update = topic_id_clone.clone();
&topic_id_clone, let description_for_update = description.clone();
&description, let update_result =
tokio::task::spawn_blocking(move || {
store_for_update.update_topic_description(
&topic_id_for_update,
&description_for_update,
) )
{ })
tracing::error!(error = %e, topic_id = %topic_id_clone, "Failed to update topic description"); .await;
} else { match update_result {
Ok(Ok(())) => {
tracing::info!(topic_id = %topic_id_clone, description = %description, "Topic description generated"); tracing::info!(topic_id = %topic_id_clone, description = %description, "Topic description generated");
} }
Ok(Err(e)) => {
tracing::error!(error = %e, topic_id = %topic_id_clone, "Failed to update topic description");
}
Err(e) => {
tracing::error!(error = %e, topic_id = %topic_id_clone, "Topic description update task panicked");
}
}
} }
Err(e) => { Err(e) => {
tracing::error!(error = %e, topic_id = %topic_id_clone, "Failed to generate topic description"); tracing::error!(error = %e, topic_id = %topic_id_clone, "Failed to generate topic description");
@ -539,7 +551,6 @@ impl InboundProcessor {
} }
} }
} }
}
Err(error) => { Err(error) => {
tracing::error!( tracing::error!(
error = %crate::utils::format_error_chain(&error), error = %crate::utils::format_error_chain(&error),
@ -588,10 +599,15 @@ impl InboundProcessor {
// 恢复路径:下一条用户消息触发新的 process_one → 加载 history → // 恢复路径:下一条用户消息触发新的 process_one → 加载 history →
// LLM 看到 "running" 占位 → 调用 wait_for_subagents → 消费 sub_done_q 结果。 // LLM 看到 "running" 占位 → 调用 wait_for_subagents → 消费 sub_done_q 结果。
let has_pending_subagents = if let Some(ref topic_id) = current_topic { let has_pending_subagents = if let Some(ref topic_id) = current_topic {
let pending = self // SQLite 是同步 I/O放到 blocking 线程池,避免阻塞 tokio worker
.session_manager let store = self.session_manager.store();
.store() let topic_id_for_query = topic_id.clone();
.list_pending_subagents(topic_id, Some("running")) let pending = tokio::task::spawn_blocking(move || {
store.list_pending_subagents(&topic_id_for_query, Some("running"))
})
.await
.ok()
.and_then(|r| r.ok())
.unwrap_or_default(); .unwrap_or_default();
if !pending.is_empty() { if !pending.is_empty() {
tracing::debug!( tracing::debug!(

View File

@ -1,4 +1,6 @@
use crate::agent::{AgentError, AgentLoop, AgentRuntimeConfig, ContextCompressor, EmittedMessageHandler}; use crate::agent::{
AgentError, AgentLoop, AgentRuntimeConfig, ContextCompressor, EmittedMessageHandler,
};
#[cfg(test)] #[cfg(test)]
use crate::bus::SYSTEM_CONTEXT_SCHEDULED_PROMPT; use crate::bus::SYSTEM_CONTEXT_SCHEDULED_PROMPT;
use crate::bus::{ChatMessage, MessageBus, OutboundMessage}; use crate::bus::{ChatMessage, MessageBus, OutboundMessage};
@ -12,10 +14,10 @@ use crate::storage::{
SkillEventRepository, SkillEventRepository,
}; };
use crate::tools::ToolRegistry; use crate::tools::ToolRegistry;
use crate::tools::WaitCoordinator;
use crate::tools::task::SubagentResult;
use crate::tools::task::repository::TaskRepository; use crate::tools::task::repository::TaskRepository;
use crate::tools::task::runtime::SubagentRuntime; use crate::tools::task::runtime::SubagentRuntime;
use crate::tools::task::SubagentResult;
use crate::tools::WaitCoordinator;
use async_trait::async_trait; use async_trait::async_trait;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
@ -138,9 +140,29 @@ impl EmittedMessageHandler for BusToolCallEmitter {
} }
} }
// 拦截 todo_write 结果:即时持久化到 SQLite // 拦截 todo_write 结果:即时持久化到 SQLite。
// SQLite 是同步 I/O放到 blocking 线程池,避免阻塞 tokio worker
// await 保持与先前同步实现一致的顺序语义(同一 emitter 的连续
// todo_write 不会乱序覆盖)。
if message.tool_name.as_deref() == Some("todo_write") { if message.tool_name.as_deref() == Some("todo_write") {
self.persist_todo_write_result(&message); let store = self.store.clone();
let channel_name = self.channel_name.clone();
let chat_id = self.chat_id.clone();
let metadata = self.metadata.clone();
let message_clone = message.clone();
if let Err(e) = tokio::task::spawn_blocking(move || {
Self::persist_todo_write_result_sync(
&store,
&channel_name,
&chat_id,
&metadata,
&message_clone,
)
})
.await
{
tracing::warn!(error = %e, "todo_write persistence task failed");
}
} }
} }
@ -193,8 +215,14 @@ impl EmittedMessageHandler for BusToolCallEmitter {
} }
impl BusToolCallEmitter { impl BusToolCallEmitter {
/// 从 todo_write 工具结果中提取 todos 并持久化 /// 从 todo_write 工具结果中提取 todos 并持久化(同步实现,供 spawn_blocking 调用)
fn persist_todo_write_result(&self, message: &ChatMessage) { fn persist_todo_write_result_sync(
store: &Arc<SessionStore>,
channel_name: &str,
chat_id: &str,
metadata: &HashMap<String, String>,
message: &ChatMessage,
) {
let parsed: serde_json::Value = match serde_json::from_str(&message.content) { let parsed: serde_json::Value = match serde_json::from_str(&message.content) {
Ok(v) => v, Ok(v) => v,
Err(_) => return, Err(_) => return,
@ -204,20 +232,15 @@ impl BusToolCallEmitter {
return; return;
}; };
let session_id = crate::storage::persistent_session_id(&self.channel_name, &self.chat_id); let session_id = crate::storage::persistent_session_id(channel_name, chat_id);
// 优先用 topic_id与 list_todos handler 和 tool 内存状态保持一致) // 优先用 topic_id与 list_todos handler 和 tool 内存状态保持一致)
let scope_key = self let scope_key = metadata
.metadata
.get("topic_id") .get("topic_id")
.filter(|t| !t.is_empty()) .filter(|t| !t.is_empty())
.cloned() .cloned()
.unwrap_or_else(|| session_id.clone()); .unwrap_or_else(|| session_id.clone());
let topic_id = self let topic_id = metadata.get("topic_id").filter(|t| !t.is_empty()).cloned();
.metadata
.get("topic_id")
.filter(|t| !t.is_empty())
.cloned();
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@ -225,7 +248,7 @@ impl BusToolCallEmitter {
.as_secs() as i64; .as_secs() as i64;
// 读取现有 DB 记录,独立对比决定 created_by_message_id 是否更新 // 读取现有 DB 记录,独立对比决定 created_by_message_id 是否更新
let existing = self.store.list_todos(&scope_key).unwrap_or_default(); let existing = store.list_todos(&scope_key).unwrap_or_default();
let existing_map: std::collections::HashMap<&str, &crate::storage::TodoRecord> = let existing_map: std::collections::HashMap<&str, &crate::storage::TodoRecord> =
existing.iter().map(|r| (r.id.as_str(), r)).collect(); existing.iter().map(|r| (r.id.as_str(), r)).collect();
@ -272,7 +295,7 @@ impl BusToolCallEmitter {
"BusToolCallEmitter: persisting todo_write result" "BusToolCallEmitter: persisting todo_write result"
); );
if let Err(e) = self.store.replace_todos(&scope_key, &records) { if let Err(e) = store.replace_todos(&scope_key, &records) {
tracing::warn!(error = %e, %scope_key, "Failed to persist todo list from BusToolCallEmitter"); tracing::warn!(error = %e, %scope_key, "Failed to persist todo list from BusToolCallEmitter");
} }
} }
@ -557,11 +580,11 @@ impl Session {
} }
// 更新 topic 的最后活跃时间 // 更新 topic 的最后活跃时间
if let Some(ref topic_id) = topic_id { if let Some(ref topic_id) = topic_id
if let Err(e) = self.store.touch_topic(topic_id) { && let Err(e) = self.store.touch_topic(topic_id)
{
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to touch topic"); tracing::warn!(error = %e, topic_id = %topic_id, "Failed to touch topic");
} }
}
Ok(()) Ok(())
} }
@ -1130,9 +1153,16 @@ impl SessionManager {
// 如果内存中没有当前话题,从数据库恢复最近活跃的话题 // 如果内存中没有当前话题,从数据库恢复最近活跃的话题
if guard.current_topic(chat_id).is_none() { if guard.current_topic(chat_id).is_none() {
let session_id = guard.persistent_session_id(chat_id); let session_id = guard.persistent_session_id(chat_id);
let topics = self // SQLite 是同步 I/O放到 blocking 线程池,避免阻塞 tokio worker。
.store // session 互斥锁继续持有tokio Mutex 允许跨 await保证恢复/创建
.list_topics(&session_id) // 话题的原子性语义不变。
let store_for_query = self.store.clone();
let session_id_for_query = session_id.clone();
let topics = tokio::task::spawn_blocking(move || {
store_for_query.list_topics(&session_id_for_query)
})
.await
.map_err(|e| AgentError::Other(format!("Topic query task failed: {}", e)))?
.map_err(|e| AgentError::Other(format!("Failed to list topics: {}", e)))?; .map_err(|e| AgentError::Other(format!("Failed to list topics: {}", e)))?;
if let Some(latest_topic) = topics.first() { if let Some(latest_topic) = topics.first() {
@ -1147,8 +1177,14 @@ impl SessionManager {
} else { } else {
// 数据库中也没有话题,自动创建默认话题 // 数据库中也没有话题,自动创建默认话题
let title = format!("话题 {}", chrono::Local::now().format("%m/%d %H:%M")); let title = format!("话题 {}", chrono::Local::now().format("%m/%d %H:%M"));
match self.store.create_topic(&session_id, &title, None) { let store_for_create = self.store.clone();
Ok(topic) => { let session_id_for_create = session_id.clone();
let create_result = tokio::task::spawn_blocking(move || {
store_for_create.create_topic(&session_id_for_create, &title, None)
})
.await;
match create_result {
Ok(Ok(topic)) => {
guard.set_current_topic(chat_id, Some(topic.id.clone())); guard.set_current_topic(chat_id, Some(topic.id.clone()));
tracing::info!( tracing::info!(
chat_id = %chat_id, chat_id = %chat_id,
@ -1158,13 +1194,20 @@ impl SessionManager {
"Auto-created default topic for new chat" "Auto-created default topic for new chat"
); );
} }
Err(e) => { Ok(Err(e)) => {
tracing::error!( tracing::error!(
error = %e, error = %e,
session_id = %session_id, session_id = %session_id,
"Failed to auto-create default topic" "Failed to auto-create default topic"
); );
} }
Err(e) => {
tracing::error!(
error = %e,
session_id = %session_id,
"Topic creation task failed"
);
}
} }
} }
} }
@ -1270,6 +1313,28 @@ impl SessionManager {
} }
} }
#[async_trait]
impl crate::scheduler::MaintenanceExecutor for SessionManager {
async fn cleanup_expired_sessions(&self) -> usize {
self.cleanup_expired_sessions().await
}
async fn run_memory_maintenance_for_all_scopes(
&self,
) -> anyhow::Result<Vec<crate::scheduler::MaintenanceRunSummary>> {
match self.run_memory_maintenance_for_all_scopes().await {
Ok(Some(result)) => Ok(vec![crate::scheduler::MaintenanceRunSummary {
scope_key: result.scope_key,
merges: result.output.merges.len(),
conflicts: result.output.conflicts.len(),
low_value: result.output.low_value_ids.len(),
}]),
Ok(None) => Ok(vec![]),
Err(error) => Err(anyhow::anyhow!(error.to_string())),
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -3033,25 +3098,3 @@ mod tests {
assert!(contents.contains(&"习惯先问方案再要代码".to_string())); assert!(contents.contains(&"习惯先问方案再要代码".to_string()));
} }
} }
#[async_trait]
impl crate::scheduler::MaintenanceExecutor for SessionManager {
async fn cleanup_expired_sessions(&self) -> usize {
self.cleanup_expired_sessions().await
}
async fn run_memory_maintenance_for_all_scopes(
&self,
) -> anyhow::Result<Vec<crate::scheduler::MaintenanceRunSummary>> {
match self.run_memory_maintenance_for_all_scopes().await {
Ok(Some(result)) => Ok(vec![crate::scheduler::MaintenanceRunSummary {
scope_key: result.scope_key,
merges: result.output.merges.len(),
conflicts: result.output.conflicts.len(),
low_value: result.output.low_value_ids.len(),
}]),
Ok(None) => Ok(vec![]),
Err(error) => Err(anyhow::anyhow!(error.to_string())),
}
}
}

View File

@ -76,11 +76,7 @@ impl SessionHistory {
} }
// 收集当前活跃 topic 集合 // 收集当前活跃 topic 集合
let active: HashSet<&str> = self let active: HashSet<&str> = self.chat_topic_ids.values().map(|s| s.as_str()).collect();
.chat_topic_ids
.values()
.map(|s| s.as_str())
.collect();
// 找一个非活跃 topic 驱逐 // 找一个非活跃 topic 驱逐
let to_evict = self.topic_histories.keys().find(|tid| { let to_evict = self.topic_histories.keys().find(|tid| {
@ -104,11 +100,11 @@ impl SessionHistory {
// 检查是否有活跃 agent 任务serial lock 被持有) // 检查是否有活跃 agent 任务serial lock 被持有)
// try_lock 成功 = 锁空闲 = 无活跃任务 = 可驱逐 // try_lock 成功 = 锁空闲 = 无活跃任务 = 可驱逐
// try_lock 失败 = 锁被持有 = 有活跃任务 = 不驱逐 // try_lock 失败 = 锁被持有 = 有活跃任务 = 不驱逐
if let Some(lock) = self.topic_serial_locks.get(*tid) { if let Some(lock) = self.topic_serial_locks.get(*tid)
if lock.try_lock().is_err() { && lock.try_lock().is_err()
{
return false; return false;
} }
}
true true
}); });
@ -176,7 +172,10 @@ impl SessionHistory {
/// 获取该 topic 的 sub_done 队列 sender用于后台子代理发送结果 /// 获取该 topic 的 sub_done 队列 sender用于后台子代理发送结果
/// 调用前应已通过 `ensure_sub_done_channel` 创建队列。 /// 调用前应已通过 `ensure_sub_done_channel` 创建队列。
pub(crate) fn sub_done_sender(&mut self, topic_id: &str) -> Option<mpsc::Sender<SubagentResult>> { pub(crate) fn sub_done_sender(
&mut self,
topic_id: &str,
) -> Option<mpsc::Sender<SubagentResult>> {
self.ensure_sub_done_channel(topic_id); self.ensure_sub_done_channel(topic_id);
self.sub_done_senders.get(topic_id).cloned() self.sub_done_senders.get(topic_id).cloned()
} }
@ -334,15 +333,15 @@ impl SessionHistory {
chat_id: &str, chat_id: &str,
topic_id: Option<&str>, topic_id: Option<&str>,
) -> Result<(), AgentError> { ) -> Result<(), AgentError> {
if let Some(tid) = topic_id { if let Some(tid) = topic_id
if let Some(history) = self.topic_histories.get_mut(tid) { && let Some(history) = self.topic_histories.get_mut(tid)
{
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
let len = history.len(); let len = history.len();
history.clear(); history.clear();
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
tracing::debug!(topic_id = %tid, previous_len = len, "Topic history cleared"); tracing::debug!(topic_id = %tid, previous_len = len, "Topic history cleared");
} }
}
self.conversations self.conversations
.clear_messages(&self.persistent_session_id(chat_id)) .clear_messages(&self.persistent_session_id(chat_id))

View File

@ -149,7 +149,7 @@ mod tests {
text: Some("hello".to_string()), text: Some("hello".to_string()),
// 使用临时目录确保跨平台兼容 // 使用临时目录确保跨平台兼容
attachments: vec![MediaItem::new( attachments: vec![MediaItem::new(
&std::env::temp_dir().join("demo.png").display().to_string(), std::env::temp_dir().join("demo.png").display().to_string(),
"image", "image",
)], )],
}, },

View File

@ -34,15 +34,15 @@ pub async fn static_handler(uri: Uri) -> Response<Body> {
None => { None => {
// 对于 SPA 应用,如果请求的是页面路由(不是静态资源),返回 index.html // 对于 SPA 应用,如果请求的是页面路由(不是静态资源),返回 index.html
// 静态资源通常包含 . (如 .js, .css, .png) // 静态资源通常包含 . (如 .js, .css, .png)
if !path.contains('.') { if !path.contains('.')
if let Some(index) = StaticAssets::get("index.html") { && let Some(index) = StaticAssets::get("index.html")
{
return Response::builder() return Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html") .header(header::CONTENT_TYPE, "text/html")
.body(Body::from(index.data.into_owned())) .body(Body::from(index.data.into_owned()))
.unwrap(); .unwrap();
} }
}
Response::builder() Response::builder()
.status(StatusCode::NOT_FOUND) .status(StatusCode::NOT_FOUND)

View File

@ -11,6 +11,12 @@ use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
/// - 两者独立演化:新增工具只需在此处加常量,不碰代理身份配置 /// - 两者独立演化:新增工具只需在此处加常量,不碰代理身份配置
pub struct ToolPromptProvider; pub struct ToolPromptProvider;
impl Default for ToolPromptProvider {
fn default() -> Self {
Self::new()
}
}
impl ToolPromptProvider { impl ToolPromptProvider {
pub fn new() -> Self { pub fn new() -> Self {
Self Self

View File

@ -111,8 +111,9 @@ impl ToolRegistryFactory {
if self.is_enabled("memory_manage") { if self.is_enabled("memory_manage") {
registry.register(MemoryManageTool::new(self.memories.clone())); registry.register(MemoryManageTool::new(self.memories.clone()));
} }
if self.is_enabled("todo_write") { if self.is_enabled("todo_write")
if let Some(ref state) = self.todo_state { && let Some(ref state) = self.todo_state
{
registry.register(TodoWriteTool::new( registry.register(TodoWriteTool::new(
state.clone(), state.clone(),
self.todo_repository.clone(), self.todo_repository.clone(),
@ -122,7 +123,6 @@ impl ToolRegistryFactory {
self.todo_repository.clone(), self.todo_repository.clone(),
)); ));
} }
}
if self.is_enabled("session_send") { if self.is_enabled("session_send") {
registry.register(SessionSendTool::new(self.session_message_sender.clone())); registry.register(SessionSendTool::new(self.session_message_sender.clone()));
} }
@ -157,8 +157,10 @@ impl ToolRegistryFactory {
} }
// 注册 Task 工具(如果启用且有 subagent_runtime // 注册 Task 工具(如果启用且有 subagent_runtime
if self.is_enabled("task") && self.task_config.enabled { if self.is_enabled("task")
if let Some(runtime) = &self.subagent_runtime { && self.task_config.enabled
&& let Some(runtime) = &self.subagent_runtime
{
registry.register(TaskTool::new(runtime.clone(), None)); registry.register(TaskTool::new(runtime.clone(), None));
// 注册 wait_for_subagents 工具(仅主 agent用于等待异步子代理完成 // 注册 wait_for_subagents 工具(仅主 agent用于等待异步子代理完成
// 默认超时从配置读取LLM 可通过 timeout_secs 参数覆盖 // 默认超时从配置读取LLM 可通过 timeout_secs 参数覆盖
@ -166,7 +168,6 @@ impl ToolRegistryFactory {
self.task_config.wait_default_timeout_secs, self.task_config.wait_default_timeout_secs,
)); ));
} }
}
registry registry
} }
@ -230,8 +231,9 @@ impl ToolRegistryFactory {
} }
// Todo 追踪工具 // Todo 追踪工具
if self.is_enabled("todo_write") { if self.is_enabled("todo_write")
if let Some(ref state) = self.todo_state { && let Some(ref state) = self.todo_state
{
registry.register(TodoWriteTool::new( registry.register(TodoWriteTool::new(
state.clone(), state.clone(),
self.todo_repository.clone(), self.todo_repository.clone(),
@ -241,7 +243,6 @@ impl ToolRegistryFactory {
self.todo_repository.clone(), self.todo_repository.clone(),
)); ));
} }
}
// 注册 MCP 工具(如果提供) // 注册 MCP 工具(如果提供)
if let Some(mcp_tools) = mcp_tools { if let Some(mcp_tools) = mcp_tools {

View File

@ -97,11 +97,7 @@ impl WaitCoordinator for SessionWaitCoordinator {
results results
} }
async fn wait( async fn wait(&self, timeout: Duration, cancel_rx: Option<watch::Receiver<()>>) -> WaitEvent {
&self,
timeout: Duration,
cancel_rx: Option<watch::Receiver<()>>,
) -> WaitEvent {
// 1. 设置 waiting=true // 1. 设置 waiting=true
{ {
let mut session = self.session.lock().await; let mut session = self.session.lock().await;

View File

@ -34,7 +34,7 @@ use crate::utils::current_timestamp;
use axum::extract::Query; use axum::extract::Query;
use axum::extract::State; use axum::extract::State;
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::http::StatusCode; use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use base64::{Engine as _, engine::general_purpose::STANDARD}; use base64::{Engine as _, engine::general_purpose::STANDARD};
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
@ -46,6 +46,13 @@ use tokio_util::sync::CancellationToken;
const WS_CHANNEL_NAME: &str = "websocket"; const WS_CHANNEL_NAME: &str = "websocket";
/// WebSocket 单条消息大小上限。
/// 前端附件上传上限 50MBbase64 编码后约 67MB此处留余量取 80MiB
/// 显式设定边界(而非依赖 tungstenite 默认 64MB防止超大消息耗尽内存。
const WS_MAX_MESSAGE_SIZE: usize = 80 * 1024 * 1024;
/// WebSocket 单帧大小上限(消息可由多帧组成)。
const WS_MAX_FRAME_SIZE: usize = 16 * 1024 * 1024;
/// Default media directory for WebSocket uploads /// Default media directory for WebSocket uploads
fn default_ws_media_dir() -> PathBuf { fn default_ws_media_dir() -> PathBuf {
let home = crate::platform::picobot_home_dir(); let home = crate::platform::picobot_home_dir();
@ -137,22 +144,37 @@ pub struct WsAuthQuery {
pub async fn ws_handler( pub async fn ws_handler(
ws: WebSocketUpgrade, ws: WebSocketUpgrade,
headers: HeaderMap,
State(state): State<Arc<GatewayState>>, State(state): State<Arc<GatewayState>>,
Query(query): Query<WsAuthQuery>, Query(query): Query<WsAuthQuery>,
auth_cfg: Option<axum::Extension<crate::gateway::auth::AuthConfig>>, auth_cfg: Option<axum::Extension<crate::gateway::auth::AuthConfig>>,
) -> Response { ) -> Response {
// 若启用了认证auth_cfg 存在且 token 已配置),校验 query param 中的 token // 若启用了认证auth_cfg 存在且 token 已配置),校验 query param 中的 token
if let Some(axum::Extension(cfg)) = auth_cfg { let mut token_verified = false;
if let Some(ref expected) = cfg.token { if let Some(axum::Extension(cfg)) = auth_cfg
&& let Some(ref expected) = cfg.token
{
let provided = query.token.as_deref(); let provided = query.token.as_deref();
if !crate::gateway::auth::token_matches(provided, &Some(expected.clone())) { if !crate::gateway::auth::token_matches(provided, &Some(expected.clone())) {
tracing::warn!("WebSocket connection rejected: missing or invalid token"); tracing::warn!("WebSocket connection rejected: missing or invalid token");
return (StatusCode::UNAUTHORIZED, "missing or invalid token").into_response(); return (StatusCode::UNAUTHORIZED, "missing or invalid token").into_response();
} }
} token_verified = true;
} }
ws.on_upgrade(|socket| async { // 无 token 认证保护时loopback 免认证模式),要求 Origin 为 loopback 来源,
// 阻断恶意网页直连/DNS rebinding 发起的跨站 WebSocket 劫持CSWSH
// token 校验通过的连接无需检查(攻击者无法从跨域页面拿到 token
if !token_verified && !crate::gateway::auth::ws_origin_loopback(&headers) {
tracing::warn!(
"WebSocket connection rejected: non-loopback Origin (possible CSWSH/DNS rebinding)"
);
return (StatusCode::FORBIDDEN, "origin not allowed").into_response();
}
ws.max_message_size(WS_MAX_MESSAGE_SIZE)
.max_frame_size(WS_MAX_FRAME_SIZE)
.on_upgrade(|socket| async {
handle_socket(socket, state).await; handle_socket(socket, state).await;
}) })
} }
@ -450,7 +472,7 @@ async fn handle_inbound(
.await .await
.get_provider_config("default") .get_provider_config("default")
.map_err(|e| AgentError::Other(e.to_string()))?; .map_err(|e| AgentError::Other(e.to_string()))?;
let prompt_repository = state.session_manager.store().clone(); let prompt_repository = state.session_manager.store();
// 与 AgentFactory::create 共享同一构建逻辑,确保 /save、/save-session、 // 与 AgentFactory::create 共享同一构建逻辑,确保 /save、/save-session、
// /current 保存/展示的系统提示词与 LLM 实际接收的完全一致 // /current 保存/展示的系统提示词与 LLM 实际接收的完全一致
@ -653,26 +675,24 @@ async fn handle_inbound(
} }
// 处理定时任务列表 // 处理定时任务列表
if let Some(jobs_json) = response.metadata.get("scheduler_jobs") { if let Some(jobs_json) = response.metadata.get("scheduler_jobs")
if let Ok(jobs) = && let Ok(jobs) =
serde_json::from_str::<Vec<crate::protocol::SchedulerJobSummary>>(jobs_json) serde_json::from_str::<Vec<crate::protocol::SchedulerJobSummary>>(jobs_json)
{ {
let _ = sender.send(WsOutbound::SchedulerJobList { jobs }).await; let _ = sender.send(WsOutbound::SchedulerJobList { jobs }).await;
} }
}
// 处理技能列表 // 处理技能列表
if let Some(skills_json) = response.metadata.get("skills") { if let Some(skills_json) = response.metadata.get("skills")
if let Ok(skills) = && let Ok(skills) =
serde_json::from_str::<Vec<crate::protocol::SkillSummary>>(skills_json) serde_json::from_str::<Vec<crate::protocol::SkillSummary>>(skills_json)
{ {
let _ = sender.send(WsOutbound::SkillList { skills }).await; let _ = sender.send(WsOutbound::SkillList { skills }).await;
} }
}
// 处理 Todo 列表 // 处理 Todo 列表
if let Some(todos_json) = response.metadata.get("todos") { if let Some(todos_json) = response.metadata.get("todos")
if let Ok(todos) = && let Ok(todos) =
serde_json::from_str::<Vec<crate::protocol::TodoItemSummary>>(todos_json) serde_json::from_str::<Vec<crate::protocol::TodoItemSummary>>(todos_json)
{ {
let scope_key = response let scope_key = response
@ -683,20 +703,18 @@ async fn handle_inbound(
tracing::debug!(todo_count = todos.len(), %scope_key, "list_todos command response"); tracing::debug!(todo_count = todos.len(), %scope_key, "list_todos command response");
let _ = sender.send(WsOutbound::TodoList { todos, scope_key }).await; let _ = sender.send(WsOutbound::TodoList { todos, scope_key }).await;
} }
}
// 处理记忆列表 // 处理记忆列表
if let Some(memories_json) = response.metadata.get("memories") { if let Some(memories_json) = response.metadata.get("memories")
if let Ok(memories) = && let Ok(memories) =
serde_json::from_str::<Vec<crate::protocol::MemorySummary>>(memories_json) serde_json::from_str::<Vec<crate::protocol::MemorySummary>>(memories_json)
{ {
let _ = sender.send(WsOutbound::MemoryList { memories }).await; let _ = sender.send(WsOutbound::MemoryList { memories }).await;
} }
}
// 记忆 CRUD 后自动刷新列表 // 记忆 CRUD 后自动刷新列表
if response.metadata.get("memory_updated").map(|v| v.as_str()) == Some("true") { if response.metadata.get("memory_updated").map(|v| v.as_str()) == Some("true")
if let Ok(records) = && let Ok(records) =
store.list_memories_for_scope("user", crate::storage::GLOBAL_SCOPE_KEY) store.list_memories_for_scope("user", crate::storage::GLOBAL_SCOPE_KEY)
{ {
let memories: Vec<crate::protocol::MemorySummary> = records let memories: Vec<crate::protocol::MemorySummary> = records
@ -713,7 +731,6 @@ async fn handle_inbound(
.collect(); .collect();
let _ = sender.send(WsOutbound::MemoryList { memories }).await; let _ = sender.send(WsOutbound::MemoryList { memories }).await;
} }
}
// 处理加载聊天消息请求 // 处理加载聊天消息请求
if let Some(load_chat_id) = response.metadata.get("load_chat_id") { if let Some(load_chat_id) = response.metadata.get("load_chat_id") {
@ -738,11 +755,10 @@ async fn handle_inbound(
} }
} }
if current_topic_id.is_none() { if current_topic_id.is_none()
if let Some(topics_json) = response.metadata.get("topics") { && let Some(topics_json) = response.metadata.get("topics")
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>( {
topics_json, match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
) {
Ok(topics) => { Ok(topics) => {
if let Some(first_topic) = topics.first() { if let Some(first_topic) = topics.first() {
let topic_id = first_topic.topic_id.clone(); let topic_id = first_topic.topic_id.clone();
@ -765,7 +781,6 @@ async fn handle_inbound(
} }
} }
} }
}
} else if let Some(ref error) = response.error { } else if let Some(ref error) = response.error {
tracing::warn!( tracing::warn!(
error_code = %error.code, error_code = %error.code,
@ -820,12 +835,12 @@ async fn send_topic_history(
let mut tool_call_ids_with_results: std::collections::HashSet<String> = let mut tool_call_ids_with_results: std::collections::HashSet<String> =
std::collections::HashSet::new(); std::collections::HashSet::new();
for msg in &messages { for msg in &messages {
if msg.role == "tool" { if msg.role == "tool"
if let Some(ref tcid) = msg.tool_call_id { && let Some(ref tcid) = msg.tool_call_id
{
tool_call_ids_with_results.insert(tcid.clone()); tool_call_ids_with_results.insert(tcid.clone());
} }
} }
}
// 将消息转换为 WsOutbound 并发送 // 将消息转换为 WsOutbound 并发送
for msg in messages { for msg in messages {
@ -894,7 +909,8 @@ fn reconcile_running_in_messages(
topic_id: &str, topic_id: &str,
) { ) {
let has_running_placeholder = messages.iter().any(|m| { let has_running_placeholder = messages.iter().any(|m| {
m.role == "tool" && crate::gateway::session::extract_task_id_from_content(&m.content).is_some() m.role == "tool"
&& crate::gateway::session::extract_task_id_from_content(&m.content).is_some()
}); });
if !has_running_placeholder { if !has_running_placeholder {
return; // 无需查询 DB return; // 无需查询 DB
@ -916,13 +932,15 @@ fn reconcile_running_in_messages(
if msg.role != "tool" { if msg.role != "tool" {
continue; continue;
} }
let Some((task_id, is_json)) = crate::gateway::session::extract_task_id_from_content(&msg.content) let Some((task_id, is_json)) =
crate::gateway::session::extract_task_id_from_content(&msg.content)
else { else {
continue; continue;
}; };
match status_map.get(task_id.as_str()) { match status_map.get(task_id.as_str()) {
Some(&status) if status != "running" => { Some(&status) if status != "running" => {
msg.content = crate::gateway::session::format_reconciled_content(&task_id, status, is_json); msg.content =
crate::gateway::session::format_reconciled_content(&task_id, status, is_json);
} }
_ => {} // 不存在(已清理)或仍在运行:保留原占位 _ => {} // 不存在(已清理)或仍在运行:保留原占位
} }
@ -945,12 +963,12 @@ async fn send_task_messages(
let mut tool_call_ids_with_results: std::collections::HashSet<String> = let mut tool_call_ids_with_results: std::collections::HashSet<String> =
std::collections::HashSet::new(); std::collections::HashSet::new();
for msg in &messages { for msg in &messages {
if msg.role == "tool" { if msg.role == "tool"
if let Some(ref tcid) = msg.tool_call_id { && let Some(ref tcid) = msg.tool_call_id
{
tool_call_ids_with_results.insert(tcid.clone()); tool_call_ids_with_results.insert(tcid.clone());
} }
} }
}
for msg in messages { for msg in messages {
let mut outbounds = chat_message_to_ws_outbound(&msg); let mut outbounds = chat_message_to_ws_outbound(&msg);
@ -1041,11 +1059,11 @@ fn set_subagent_task_id(outbound: &mut WsOutbound, task_id: &str) {
fn extract_parent_task_id(task: &crate::tools::task::types::TaskSession) -> Option<String> { fn extract_parent_task_id(task: &crate::tools::task::types::TaskSession) -> Option<String> {
let parent = &task.parent_session_id; let parent = &task.parent_session_id;
// 仅当父会话是子智能体会话时才提取(格式: "sub:...:task:{uuid}" // 仅当父会话是子智能体会话时才提取(格式: "sub:...:task:{uuid}"
if parent.starts_with("sub:") { if parent.starts_with("sub:")
if let Some(pos) = parent.find(":task:") { && let Some(pos) = parent.find(":task:")
{
return Some(parent[pos + 1..].to_string()); // "task:{uuid}" return Some(parent[pos + 1..].to_string()); // "task:{uuid}"
} }
}
None None
} }

View File

@ -3,7 +3,7 @@ use chrono_tz::Tz;
use std::path::PathBuf; use std::path::PathBuf;
use tracing_appender::rolling::{RollingFileAppender, Rotation}; use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::{ use tracing_subscriber::{
fmt, fmt::time::FormatTime, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer, EnvFilter, Layer, fmt, fmt::time::FormatTime, layer::SubscriberExt, util::SubscriberInitExt,
}; };
use crate::config::LogFormat; use crate::config::LogFormat;
@ -61,15 +61,15 @@ pub fn init_logging(timezone: Tz, log_format: LogFormat) {
let log_dir = get_default_log_dir(); let log_dir = get_default_log_dir();
// Create log directory if it doesn't exist // Create log directory if it doesn't exist
if !log_dir.exists() { if !log_dir.exists()
if let Err(e) = std::fs::create_dir_all(&log_dir) { && let Err(e) = std::fs::create_dir_all(&log_dir)
{
eprintln!( eprintln!(
"Warning: Failed to create log directory {}: {}", "Warning: Failed to create log directory {}: {}",
log_dir.display(), log_dir.display(),
e e
); );
} }
}
// Create file appender with daily rotation // Create file appender with daily rotation
let file_appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, "picobot.log"); let file_appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, "picobot.log");

View File

@ -6,9 +6,9 @@
//! - Connects to MCP servers asynchronously //! - Connects to MCP servers asynchronously
//! - Dynamically registers MCP tools via the Tool trait adapter //! - Dynamically registers MCP tools via the Tool trait adapter
use parking_lot::Mutex;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use parking_lot::Mutex;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use http::{HeaderName, HeaderValue}; use http::{HeaderName, HeaderValue};

View File

@ -102,7 +102,7 @@ impl McpServerConfig {
command, command,
args: self.args.clone().unwrap_or_default(), args: self.args.clone().unwrap_or_default(),
env: self.env.clone().unwrap_or_default(), env: self.env.clone().unwrap_or_default(),
cwd: self.cwd.as_ref().map(|s| std::path::PathBuf::from(s)), cwd: self.cwd.as_ref().map(std::path::PathBuf::from),
}) })
} }
"http" | "streamableHttp" => { "http" | "streamableHttp" => {

View File

@ -111,10 +111,7 @@ impl PicoBotTool for McpToolWrapper {
.call_tool(&self.server_key, &self.tool_name, args); .call_tool(&self.server_key, &self.tool_name, args);
let result = if self.timeout_secs > 0 { let result = if self.timeout_secs > 0 {
tokio::time::timeout( tokio::time::timeout(std::time::Duration::from_secs(self.timeout_secs), call)
std::time::Duration::from_secs(self.timeout_secs),
call,
)
.await .await
.map_err(|_| { .map_err(|_| {
tracing::warn!( tracing::warn!(
@ -183,12 +180,8 @@ pub async fn register_mcp_tools(
let all_tools = manager.all_tools().await; let all_tools = manager.all_tools().await;
for (server_key, tool_info) in all_tools { for (server_key, tool_info) in all_tools {
let wrapper = McpToolWrapper::new( let wrapper =
manager.clone(), McpToolWrapper::new(manager.clone(), server_key.clone(), tool_info, timeout_secs);
server_key.clone(),
tool_info,
timeout_secs,
);
tracing::info!( tracing::info!(
name = %wrapper.name(), name = %wrapper.name(),

View File

@ -40,7 +40,8 @@ pub const MESSAGE_PROCESSING_ERRORS: &str = "picobot_message_processing_errors_t
/// 幂等:首次调用安装 recorder 并缓存 handle后续调用含热重启返回缓存的 handle。 /// 幂等:首次调用安装 recorder 并缓存 handle后续调用含热重启返回缓存的 handle。
/// 这避免了热重启后 `install_recorder()` 因 recorder 已安装而失败、导致 `/metrics` 返回 503 的问题。 /// 这避免了热重启后 `install_recorder()` 因 recorder 已安装而失败、导致 `/metrics` 返回 503 的问题。
/// 返回 None 表示安装失败非致命metrics 静默降级)。 /// 返回 None 表示安装失败非致命metrics 静默降级)。
static PROMETHEUS_HANDLE: std::sync::OnceLock<Option<PrometheusHandle>> = std::sync::OnceLock::new(); static PROMETHEUS_HANDLE: std::sync::OnceLock<Option<PrometheusHandle>> =
std::sync::OnceLock::new();
pub fn init_recorder() -> Option<PrometheusHandle> { pub fn init_recorder() -> Option<PrometheusHandle> {
PROMETHEUS_HANDLE PROMETHEUS_HANDLE

View File

@ -342,7 +342,7 @@ pub fn home_dir() -> Option<PathBuf> {
// Windows: support USERPROFILE // Windows: support USERPROFILE
env::var_os("USERPROFILE").map(PathBuf::from) env::var_os("USERPROFILE").map(PathBuf::from)
}) })
.or_else(|| dirs::home_dir()) .or_else(dirs::home_dir)
} }
/// 返回 PicoBot 主目录,若无法确定则回退到当前目录 `"."`。 /// 返回 PicoBot 主目录,若无法确定则回退到当前目录 `"."`。

View File

@ -125,7 +125,6 @@ pub struct AnthropicProvider {
api_key: String, api_key: String,
base_url: String, base_url: String,
extra_headers: HashMap<String, String>, extra_headers: HashMap<String, String>,
#[cfg_attr(not(debug_assertions), allow(dead_code))]
llm_timeout_secs: u64, llm_timeout_secs: u64,
model_id: String, model_id: String,
temperature: Option<f32>, temperature: Option<f32>,
@ -316,15 +315,15 @@ impl LLMProvider for AnthropicProvider {
req_builder = req_builder.header(key.as_str(), value.as_str()); req_builder = req_builder.header(key.as_str(), value.as_str());
} }
let resp = req_builder.json(&body).send().await.map_err(|e| { let resp = req_builder.json(&body).send().await.inspect_err(|e| {
tracing::error!( tracing::error!(
provider = %self.name, provider = %self.name,
model = %self.model_id, model = %self.model_id,
url = %url, url = %url,
error = %format_error_chain(&e), timeout_secs = self.llm_timeout_secs,
error = %format_error_chain(e),
"Anthropic: HTTP request failed" "Anthropic: HTTP request failed"
); );
e
})?; })?;
let status = resp.status(); let status = resp.status();
let text = resp.text().await?; let text = resp.text().await?;
@ -635,7 +634,7 @@ mod tests {
#[test] #[test]
fn test_format_error_chain_single() { fn test_format_error_chain_single() {
let err = std::io::Error::new(std::io::ErrorKind::Other, "single error"); let err = std::io::Error::other("single error");
let chain = format_error_chain(&err); let chain = format_error_chain(&err);
assert_eq!(chain, "single error"); assert_eq!(chain, "single error");
} }
@ -649,7 +648,7 @@ mod tests {
#[test] #[test]
fn test_format_error_chain_nested() { fn test_format_error_chain_nested() {
let inner = std::io::Error::new(std::io::ErrorKind::Other, "root cause"); let inner = std::io::Error::other("root cause");
let outer = OuterError::Wrapped(inner); let outer = OuterError::Wrapped(inner);
let chain = format_error_chain(&outer); let chain = format_error_chain(&outer);
assert!(chain.contains("outer wrapper")); assert!(chain.contains("outer wrapper"));

View File

@ -63,23 +63,20 @@ impl StreamingAccumulator {
name: Option<&str>, name: Option<&str>,
arguments: Option<&str>, arguments: Option<&str>,
) { ) {
let entry = self let entry = self.tool_calls.entry(index).or_default();
.tool_calls
.entry(index)
.or_insert_with(StreamingToolCall::default);
// 只在 id 非空时才更新,防止流式响应中后续 chunk 的空 id 覆盖之前的值 // 只在 id 非空时才更新,防止流式响应中后续 chunk 的空 id 覆盖之前的值
if let Some(id) = id { if let Some(id) = id
if !id.is_empty() { && !id.is_empty()
{
entry.id = id.to_string(); entry.id = id.to_string();
} }
}
// 只在 name 非空时才更新,防止流式响应中后续 chunk 的 None 覆盖之前的值 // 只在 name 非空时才更新,防止流式响应中后续 chunk 的 None 覆盖之前的值
if let Some(name) = name { if let Some(name) = name
if !name.is_empty() { && !name.is_empty()
{
entry.name = name.to_string(); entry.name = name.to_string();
} }
}
if let Some(args) = arguments { if let Some(args) = arguments {
entry.arguments.push_str(args); entry.arguments.push_str(args);
} }
@ -107,8 +104,8 @@ impl StreamingAccumulator {
.into_iter() .into_iter()
.filter(|(_, call)| !call.id.is_empty() && !call.name.is_empty()) .filter(|(_, call)| !call.id.is_empty() && !call.name.is_empty())
.map(|(_, call)| { .map(|(_, call)| {
let arguments = serde_json::from_str(&call.arguments) let arguments =
.unwrap_or_else(|_| serde_json::Value::Null); serde_json::from_str(&call.arguments).unwrap_or(serde_json::Value::Null);
ToolCall { ToolCall {
id: call.id, id: call.id,
name: call.name, name: call.name,
@ -218,26 +215,24 @@ fn convert_content_blocks(
} }
// 如果只有一个文本块且没有通知,返回字符串形式 // 如果只有一个文本块且没有通知,返回字符串形式
if converted_blocks.len() == 1 { if converted_blocks.len() == 1
if let Some(block) = converted_blocks.first() { && let Some(block) = converted_blocks.first()
if block.get("type").and_then(|t| t.as_str()) == Some("text") { && block.get("type").and_then(|t| t.as_str()) == Some("text")
if let Some(text) = block.get("text").and_then(|t| t.as_str()) { && let Some(text) = block.get("text").and_then(|t| t.as_str())
{
return Value::String(text.to_string()); return Value::String(text.to_string());
} }
}
}
}
return Value::Array(converted_blocks); return Value::Array(converted_blocks);
} }
} }
// 原有逻辑 - 模型支持图片,正常转换 // 原有逻辑 - 模型支持图片,正常转换
if blocks.len() == 1 { if blocks.len() == 1
if let ContentBlock::Text { text } = &blocks[0] { && let ContentBlock::Text { text } = &blocks[0]
{
return Value::String(text.clone()); return Value::String(text.clone());
} }
}
Value::Array( Value::Array(
blocks blocks
.iter() .iter()
@ -481,15 +476,13 @@ impl OpenAIProvider {
} }
// 提取流式末帧的 usagestream_options.include_usage=true 时返回) // 提取流式末帧的 usagestream_options.include_usage=true 时返回)
if let Some(usage_val) = json.get("usage") { if let Some(usage_val) = json.get("usage")
if !usage_val.is_null() { && !usage_val.is_null()
if let Ok(u) = && let Ok(u) =
serde_json::from_value::<OpenAIUsage>(usage_val.clone()) serde_json::from_value::<OpenAIUsage>(usage_val.clone())
{ {
accumulator.set_usage(u); accumulator.set_usage(u);
} }
}
}
// 提取 choices // 提取 choices
if let Some(choices) = json.get("choices").and_then(|c| c.as_array()) { if let Some(choices) = json.get("choices").and_then(|c| c.as_array()) {
@ -605,14 +598,12 @@ impl OpenAIProvider {
} }
// 提取流式末帧的 usage与主循环一致 // 提取流式末帧的 usage与主循环一致
if let Some(usage_val) = json.get("usage") { if let Some(usage_val) = json.get("usage")
if !usage_val.is_null() { && !usage_val.is_null()
if let Ok(u) = serde_json::from_value::<OpenAIUsage>(usage_val.clone()) && let Ok(u) = serde_json::from_value::<OpenAIUsage>(usage_val.clone())
{ {
accumulator.set_usage(u); accumulator.set_usage(u);
} }
}
}
if let Some(choices) = json.get("choices").and_then(|c| c.as_array()) { if let Some(choices) = json.get("choices").and_then(|c| c.as_array()) {
for choice in choices { for choice in choices {
@ -684,8 +675,10 @@ impl OpenAIProvider {
// 回退:当流式解析未获取到任何内容且无 tool call 时, // 回退:当流式解析未获取到任何内容且无 tool call 时,
// 服务器可能返回的是非 SSE 格式的纯 JSON尝试直接反序列化整个响应体 // 服务器可能返回的是非 SSE 格式的纯 JSON尝试直接反序列化整个响应体
if response.content.is_empty() && response.tool_calls.is_empty() { if response.content.is_empty()
if let Ok(openai_resp) = serde_json::from_str::<OpenAIResponse>(&raw_body) { && response.tool_calls.is_empty()
&& let Ok(openai_resp) = serde_json::from_str::<OpenAIResponse>(&raw_body)
{
let fallback_content = openai_resp let fallback_content = openai_resp
.choices .choices
.first() .first()
@ -732,7 +725,6 @@ impl OpenAIProvider {
}; };
} }
} }
}
tracing::debug!( tracing::debug!(
content_len = response.content.len(), content_len = response.content.len(),
@ -761,15 +753,16 @@ impl OpenAIProvider {
std::collections::HashSet::new(); std::collections::HashSet::new();
for (i, m) in request.messages.iter().enumerate().rev() { for (i, m) in request.messages.iter().enumerate().rev() {
if m.role == "tool" { if m.role == "tool"
if let Some(ref tc_id) = m.tool_call_id { && let Some(ref tc_id) = m.tool_call_id
{
resolved_tool_ids.insert(tc_id.as_str()); resolved_tool_ids.insert(tc_id.as_str());
} }
}
if m.role == "assistant" { if m.role == "assistant"
if let Some(ref calls) = m.tool_calls { && let Some(ref calls) = m.tool_calls
if !calls.is_empty() { && !calls.is_empty()
{
let all_resolved = calls let all_resolved = calls
.iter() .iter()
.all(|tc| resolved_tool_ids.contains(tc.id.as_str())); .all(|tc| resolved_tool_ids.contains(tc.id.as_str()));
@ -782,8 +775,6 @@ impl OpenAIProvider {
} }
} }
} }
}
}
// Forward-order check: verify tool messages IMMEDIATELY follow the // Forward-order check: verify tool messages IMMEDIATELY follow the
// assistant(tool_calls). If any non-tool message appears between the // assistant(tool_calls). If any non-tool message appears between the
@ -827,25 +818,27 @@ impl OpenAIProvider {
} }
if m.role == "assistant" { if m.role == "assistant" {
if let Some(ref calls) = m.tool_calls { if let Some(ref calls) = m.tool_calls
if !calls.is_empty() && !skip_assistant_indices.contains(&i) { && !calls.is_empty()
&& !skip_assistant_indices.contains(&i)
{
pending_tool_ids = calls.iter().map(|tc| tc.id.as_str()).collect(); pending_tool_ids = calls.iter().map(|tc| tc.id.as_str()).collect();
pending_assistant_idx = Some(i); pending_assistant_idx = Some(i);
} }
} } else if m.role == "tool"
} else if m.role == "tool" { && let Some(ref tc_id) = m.tool_call_id
if let Some(ref tc_id) = m.tool_call_id { {
pending_tool_ids.remove(tc_id.as_str()); pending_tool_ids.remove(tc_id.as_str());
if pending_tool_ids.is_empty() { if pending_tool_ids.is_empty() {
pending_assistant_idx = None; pending_assistant_idx = None;
} }
} }
} }
}
// Handle trailing assistant with unresolved immediate tool results // Handle trailing assistant with unresolved immediate tool results
if !pending_tool_ids.is_empty() { if !pending_tool_ids.is_empty()
if let Some(idx) = pending_assistant_idx { && let Some(idx) = pending_assistant_idx
{
skip_assistant_indices.insert(idx); skip_assistant_indices.insert(idx);
tracing::warn!( tracing::warn!(
message_index = idx, message_index = idx,
@ -860,7 +853,6 @@ impl OpenAIProvider {
} }
} }
} }
}
// valid_tool_call_parent_ids = with_parent (assistant tool_call_ids // valid_tool_call_parent_ids = with_parent (assistant tool_call_ids
// whose parent assistant has ALL results after it) // whose parent assistant has ALL results after it)
@ -956,11 +948,10 @@ impl OpenAIProvider {
"content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i) "content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i)
}); });
if m.role == "assistant" { if m.role == "assistant"
if let Some(reasoning_content) = &m.reasoning_content { && let Some(reasoning_content) = &m.reasoning_content {
message["reasoning_content"] = Value::String(reasoning_content.clone()); message["reasoning_content"] = Value::String(reasoning_content.clone());
} }
}
Some(message) Some(message)
} }
@ -1150,8 +1141,8 @@ impl LLMProvider for OpenAIProvider {
for (i, msg) in msgs.iter().enumerate() { for (i, msg) in msgs.iter().enumerate() {
if let Some(content) = msg.get("content").and_then(|c| c.as_array()) { if let Some(content) = msg.get("content").and_then(|c| c.as_array()) {
for (j, item) in content.iter().enumerate() { for (j, item) in content.iter().enumerate() {
if item.get("type").and_then(|t| t.as_str()) == Some("image_url") { if item.get("type").and_then(|t| t.as_str()) == Some("image_url")
if let Some(url_str) = item && let Some(url_str) = item
.get("image_url") .get("image_url")
.and_then(|u| u.get("url")) .and_then(|u| u.get("url"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@ -1164,7 +1155,6 @@ impl LLMProvider for OpenAIProvider {
} }
} }
} }
}
let mut req_builder = self let mut req_builder = self
.client .client

View File

@ -734,14 +734,14 @@ impl RuntimeJob {
return Ok(()); return Ok(());
} }
if let Some(max_runs) = self.max_runs { if let Some(max_runs) = self.max_runs
if self.run_count >= max_runs { && self.run_count >= max_runs
{
self.state = SchedulerJobState::Completed; self.state = SchedulerJobState::Completed;
self.next_fire_at = None; self.next_fire_at = None;
self.completed_at = Some(now.timestamp_millis()); self.completed_at = Some(now.timestamp_millis());
return Ok(()); return Ok(());
} }
}
let reference_ms = self.next_fire_at.or(self.last_fired_at); let reference_ms = self.next_fire_at.or(self.last_fired_at);
self.state = SchedulerJobState::Scheduled; self.state = SchedulerJobState::Scheduled;

View File

@ -1,13 +1,13 @@
use crate::platform::{ use crate::platform::{
atomic_rename, home_dir as platform_home_dir, path_to_uri, xml_escape as platform_xml_escape, atomic_rename, home_dir as platform_home_dir, path_to_uri, xml_escape as platform_xml_escape,
}; };
use parking_lot::RwLock;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use parking_lot::RwLock;
#[cfg(test)] #[cfg(test)]
static SKILL_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); static SKILL_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
@ -143,9 +143,7 @@ impl SkillRuntime {
} }
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.catalog self.catalog.read().is_empty()
.read()
.is_empty()
} }
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
@ -153,9 +151,7 @@ impl SkillRuntime {
} }
pub fn system_index_prompt(&self) -> Option<String> { pub fn system_index_prompt(&self) -> Option<String> {
self.catalog self.catalog.read().system_index_prompt()
.read()
.system_index_prompt()
} }
/// 按白/黑名单过滤后的技能索引。供专家/子代理按 `CapabilityPolicy` 过滤技能可见性。 /// 按白/黑名单过滤后的技能索引。供专家/子代理按 `CapabilityPolicy` 过滤技能可见性。
@ -170,34 +166,23 @@ impl SkillRuntime {
} }
pub fn discovery_event_payload(&self) -> serde_json::Value { pub fn discovery_event_payload(&self) -> serde_json::Value {
self.catalog self.catalog.read().discovery_event_payload()
.read()
.discovery_event_payload()
} }
pub fn offered_event_payload(&self) -> serde_json::Value { pub fn offered_event_payload(&self) -> serde_json::Value {
self.catalog self.catalog.read().offered_event_payload()
.read()
.offered_event_payload()
} }
pub fn activation_payload(&self, name: &str) -> Result<String, String> { pub fn activation_payload(&self, name: &str) -> Result<String, String> {
self.catalog self.catalog.read().activation_payload(name)
.read()
.activation_payload(name)
} }
pub fn activation_event_payload(&self, name: &str) -> Result<serde_json::Value, String> { pub fn activation_event_payload(&self, name: &str) -> Result<serde_json::Value, String> {
self.catalog self.catalog.read().activation_event_payload(name)
.read()
.activation_event_payload(name)
} }
pub fn list_skills(&self) -> Vec<Skill> { pub fn list_skills(&self) -> Vec<Skill> {
self.catalog self.catalog.read().skills.clone()
.read()
.skills
.clone()
} }
/// List all discovered skills including disabled ones, with their disabled scopes. /// List all discovered skills including disabled ones, with their disabled scopes.
@ -226,10 +211,7 @@ impl SkillRuntime {
} }
pub fn get_skill(&self, name: &str) -> Option<Skill> { pub fn get_skill(&self, name: &str) -> Option<Skill> {
self.catalog self.catalog.read().find_skill(name).cloned()
.read()
.find_skill(name)
.cloned()
} }
pub fn create_skill( pub fn create_skill(
@ -450,7 +432,7 @@ impl SkillCatalog {
// Load from least specific to most specific so later sources win on conflicts. // Load from least specific to most specific so later sources win on conflicts.
for source in source_order(&config.sources) { for source in source_order(&config.sources) {
sources_seen += 1; sources_seen += 1;
let root = source_root(&source, &cwd); let root = source_root(&source, cwd);
let Some(root) = root else { continue }; let Some(root) = root else { continue };
for skill in load_skills_from_root(&root, source.clone()) { for skill in load_skills_from_root(&root, source.clone()) {
@ -519,7 +501,7 @@ impl SkillCatalog {
.filter(|s| { .filter(|s| {
allowed_set allowed_set
.as_ref() .as_ref()
.map_or(true, |set| set.contains(s.name.as_str())) .is_none_or(|set| set.contains(s.name.as_str()))
}) })
.collect(); .collect();

View File

@ -299,7 +299,7 @@ pub(super) fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError>
} }
// Column migration: add created_by_message_id if it doesn't exist // Column migration: add created_by_message_id if it doesn't exist
let has_column = has_column(&conn, "todos", "created_by_message_id")?; let has_column = has_column(conn, "todos", "created_by_message_id")?;
if !has_column { if !has_column {
tracing::info!("Adding created_by_message_id column to todos table"); tracing::info!("Adding created_by_message_id column to todos table");
conn.execute( conn.execute(

View File

@ -574,9 +574,7 @@ impl SessionStore {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, provider, model FROM topics WHERE provider IS NOT NULL OR model IS NOT NULL", "SELECT id, provider, model FROM topics WHERE provider IS NOT NULL OR model IS NOT NULL",
)?; )?;
let rows = stmt.query_map([], |row| { let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
})?;
let mut result = Vec::new(); let mut result = Vec::new();
for row in rows { for row in rows {
result.push(row?); result.push(row?);
@ -1027,7 +1025,7 @@ impl SessionStore {
new_messages.iter().partition(|m| { new_messages.iter().partition(|m| {
m.system_context m.system_context
.as_deref() .as_deref()
.map_or(false, |sc| sc.starts_with("history_compaction")) .is_some_and(|sc| sc.starts_with("history_compaction"))
}); });
// 先删除该 topic 下已有的旧压缩摘要system_context LIKE 'history_compaction%')。 // 先删除该 topic 下已有的旧压缩摘要system_context LIKE 'history_compaction%')。
@ -1787,6 +1785,33 @@ impl SessionStore {
} }
} }
/// 定向查询指定话题的第一条 user 消息内容。
///
/// 数据库侧 `LIMIT 1`,避免为取单条消息全量加载并反序列化整个话题历史
/// (话题越长,全量加载的 CPU/内存浪费越大)。
pub fn first_user_message_content(
&self,
topic_id: &str,
) -> Result<Option<String>, StorageError> {
let conn = self.pool.get()?;
let mut stmt = conn.prepare(
"
SELECT content
FROM messages
WHERE topic_id = ?1 AND role = 'user'
AND (system_context IS NULL OR system_context NOT LIKE 'history_compaction%')
ORDER BY seq ASC
LIMIT 1
",
)?;
let mut rows = stmt.query_map(params![topic_id], |row| row.get::<_, String>(0))?;
match rows.next() {
Some(Ok(content)) => Ok(Some(content)),
Some(Err(e)) => Err(e.into()),
None => Ok(None),
}
}
/// 获取指定话题的消息数量。 /// 获取指定话题的消息数量。
/// ///
/// 使用 `SELECT COUNT(*)` 在数据库侧计数,避免将所有消息 /// 使用 `SELECT COUNT(*)` 在数据库侧计数,避免将所有消息

View File

@ -36,6 +36,10 @@ pub trait ConversationRepository: Send + Sync + 'static {
session_id: Option<&str>, session_id: Option<&str>,
) -> Result<Vec<ChatMessage>, StorageError>; ) -> Result<Vec<ChatMessage>, StorageError>;
/// 定向查询指定话题的第一条 user 消息内容(数据库侧 LIMIT 1
/// 避免为取单条消息而全量加载并反序列化整个话题历史。
fn first_user_message_content(&self, topic_id: &str) -> Result<Option<String>, StorageError>;
fn append_message(&self, session_id: &str, message: &ChatMessage) -> Result<(), StorageError>; fn append_message(&self, session_id: &str, message: &ChatMessage) -> Result<(), StorageError>;
fn append_message_with_topic( fn append_message_with_topic(
@ -298,6 +302,10 @@ impl ConversationRepository for super::SessionStore {
super::SessionStore::load_messages_for_topic_full(self, topic_id, session_id) super::SessionStore::load_messages_for_topic_full(self, topic_id, session_id)
} }
fn first_user_message_content(&self, topic_id: &str) -> Result<Option<String>, StorageError> {
super::SessionStore::first_user_message_content(self, topic_id)
}
fn compact_topic_history( fn compact_topic_history(
&self, &self,
session_id: &str, session_id: &str,

View File

@ -186,7 +186,9 @@ pub struct MemoryUpsert {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum SchedulerJobState { pub enum SchedulerJobState {
#[default]
Scheduled, Scheduled,
Running, Running,
Paused, Paused,
@ -241,12 +243,6 @@ impl SchedulerJobStatus {
} }
} }
impl Default for SchedulerJobState {
fn default() -> Self {
Self::Scheduled
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchedulerJobRecord { pub struct SchedulerJobRecord {
pub id: String, pub id: String,

View File

@ -13,10 +13,16 @@ use tokio::time::{Instant, sleep_until};
use crate::platform::{ShellInfo, dangerous_command_patterns}; use crate::platform::{ShellInfo, dangerous_command_patterns};
use crate::tools::shell_session::ShellSessionManager; use crate::tools::shell_session::ShellSessionManager;
use crate::tools::traits::{Tool, ToolResult}; use crate::tools::traits::{Tool, ToolResult};
use crate::tools::{check_null_args, extract_bool, extract_u64}; use crate::tools::{check_null_args, extract_u64};
const MAX_TIMEOUT_SECS: u64 = 600; const MAX_TIMEOUT_SECS: u64 = 600;
const MAX_OUTPUT_CHARS: usize = 50_000; const MAX_OUTPUT_CHARS: usize = 50_000;
/// 运行时单流输出缓冲上限(字节):超出后保留头尾、丢弃中段,
/// 防止长输出命令(或交互式会话的 drain 任务)无限增长吃满内存。
const MAX_RUNTIME_BUFFER_BYTES: usize = 1024 * 1024;
/// pending 短语增量检测的尾部窗口(字节):交互提示只出现在输出尾部,
/// 只需"新 chunk + 尾部窗口"即可捕获(窗口 ≥ 最长短语长度,覆盖跨 chunk 边界)。
const PENDING_WINDOW_BYTES: usize = 2048;
/// 子进程退出后,等待 read_stream 把管道残余输出排空的最长时间。 /// 子进程退出后,等待 read_stream 把管道残余输出排空的最长时间。
/// ///
/// 不能无界等待 EOF若子进程派生了继承 stdout 管道的守护进程 /// 不能无界等待 EOF若子进程派生了继承 stdout 管道的守护进程
@ -29,6 +35,79 @@ const INTERACTIVE_HINT: &str =
const NON_INTERACTIVE_HINT: &str = const NON_INTERACTIVE_HINT: &str =
"该命令正在等待你完成外部操作。完成后请告诉我继续,或重新运行后续检查命令。"; "该命令正在等待你完成外部操作。完成后请告诉我继续,或重新运行后续检查命令。";
/// "等待用户操作"检测短语(全部小写;检测前对输出做 to_lowercase
/// 新增短语时保持小写,并确保长度不超过 PENDING_WINDOW_BYTES。
const PENDING_USER_ACTION_PHRASES: &[&str] = &[
// 中文 — 原有
"等待用户授权",
"等待授权",
"等待你授权",
"在浏览器中打开以下链接进行认证",
// 中文 — 新增lark-cli 等工具的常见提示)
"请在浏览器中",
"请打开以下链接",
"打开以下链接",
"打开链接",
"访问以下",
"访问此链接",
"复制链接",
"输入验证码",
"输入授权码",
"完成认证",
"完成授权",
"请登录",
"正在等待",
"等待用户",
"手动授权",
// 英文 — 原有
"open the following link",
"waiting for authorization",
"waiting for user authorization",
"waiting for approval",
"device/verify",
"user_code=",
// 英文 — 新增
"visit the following url",
"visit this url",
"open the following url",
"browser to authenticate",
"browser to complete",
"enter the code",
"enter code",
"verification code",
"authorization code",
"one-time code",
"device code",
"oauth",
"go to the following",
"navigate to the following",
"paste the code",
];
/// 在小写化文本中检测 pending 短语(调用方需先 to_lowercase
fn contains_pending_phrase(lowercase_text: &str) -> bool {
PENDING_USER_ACTION_PHRASES
.iter()
.any(|phrase| lowercase_text.contains(phrase))
}
/// 缓冲超限时保留头尾(头 1/2 + 尾 1/4在字符边界处截断。
/// 头尾之外的中段对最终 truncate_output头+尾各 25K 字符)已无贡献。
pub(crate) fn cap_output_buffer(buf: &mut String) {
if buf.len() <= MAX_RUNTIME_BUFFER_BYTES {
return;
}
let head_len = MAX_RUNTIME_BUFFER_BYTES / 2;
let tail_len = MAX_RUNTIME_BUFFER_BYTES / 4;
let head_end = buf.floor_char_boundary(head_len);
let tail_start = buf.floor_char_boundary(buf.len().saturating_sub(tail_len));
let mut capped = String::with_capacity(head_end + tail_len + 64);
capped.push_str(&buf[..head_end]);
capped.push_str("\n[... output trimmed in memory (buffer limit reached) ...]\n");
capped.push_str(&buf[tail_start..]);
*buf = capped;
}
/// Shell 类型枚举,支持跨平台 /// Shell 类型枚举,支持跨平台
/// ///
/// 这是 ShellInfo 的兼容包装,提供更方便的 API。 /// 这是 ShellInfo 的兼容包装,提供更方便的 API。
@ -92,7 +171,7 @@ impl ShellKind {
let info = self.to_info(); let info = self.to_info();
info.args info.args
.iter() .iter()
.map(|s| *s) .copied()
.chain(std::iter::once(command)) .chain(std::iter::once(command))
.collect() .collect()
} }
@ -121,7 +200,9 @@ impl ShellKind {
pub struct BashTool { pub struct BashTool {
timeout_secs: u64, timeout_secs: u64,
working_dir: Option<String>, working_dir: Option<String>,
deny_patterns: Vec<String>, /// 危险命令拦截正则:构造时预编译(模式串在构造后不变),
/// 避免每次执行命令都重新编译全部正则。
deny_patterns: Vec<regex::Regex>,
shell: ShellKind, shell: ShellKind,
session_manager: Arc<ShellSessionManager>, session_manager: Arc<ShellSessionManager>,
} }
@ -131,7 +212,16 @@ impl BashTool {
Self { Self {
timeout_secs: 60, timeout_secs: 60,
working_dir: None, working_dir: None,
deny_patterns: dangerous_command_patterns(), deny_patterns: dangerous_command_patterns()
.into_iter()
.filter_map(|p| match regex::Regex::new(&p) {
Ok(re) => Some(re),
Err(e) => {
tracing::warn!(pattern = %p, error = %e, "Invalid deny pattern skipped");
None
}
})
.collect(),
shell: ShellKind::detect(), shell: ShellKind::detect(),
session_manager, session_manager,
} }
@ -154,15 +244,11 @@ impl BashTool {
fn guard_command(&self, command: &str) -> Option<String> { fn guard_command(&self, command: &str) -> Option<String> {
let lower = command.to_lowercase(); let lower = command.to_lowercase();
for pattern in &self.deny_patterns { for re in &self.deny_patterns {
if regex::Regex::new(pattern) if re.is_match(&lower) {
.ok()
.map(|re| re.is_match(&lower))
.unwrap_or(false)
{
return Some(format!( return Some(format!(
"Command blocked by safety guard (dangerous pattern: {})", "Command blocked by safety guard (dangerous pattern: {})",
pattern re.as_str()
)); ));
} }
} }
@ -208,63 +294,6 @@ impl BashTool {
PENDING_USER_ACTION_MARKER, session_line, hint, output_section PENDING_USER_ACTION_MARKER, session_line, hint, output_section
) )
} }
fn should_return_pending(&self, _interactive: bool, output: &str) -> bool {
let normalized = output.to_lowercase();
let has_auth_phrase = [
// 中文 — 原有
"等待用户授权",
"等待授权",
"等待你授权",
"在浏览器中打开以下链接进行认证",
// 中文 — 新增lark-cli 等工具的常见提示)
"请在浏览器中",
"请打开以下链接",
"打开以下链接",
"打开链接",
"访问以下",
"访问此链接",
"复制链接",
"输入验证码",
"输入授权码",
"完成认证",
"完成授权",
"请登录",
"正在等待",
"等待用户",
"手动授权",
// 英文 — 原有
"open the following link",
"waiting for authorization",
"waiting for user authorization",
"waiting for approval",
"device/verify",
"user_code=",
// 英文 — 新增
"visit the following url",
"visit this url",
"open the following url",
"browser to authenticate",
"browser to complete",
"enter the code",
"enter code",
"verification code",
"authorization code",
"one-time code",
"device code",
"oauth",
"go to the following",
"navigate to the following",
"paste the code",
]
.iter()
.any(|pattern| normalized.contains(pattern));
// 仅 auth 短语命中才转 pending超时前早退不构成 deadline 绕过)。
// 此前的 `|| (interactive && !output.trim().is_empty())` 会在 interactive=true
// 时对任意输出转 pending绕过超时与严格硬超时冲突已移除。
has_auth_phrase
}
} }
async fn drain_available_chunks( async fn drain_available_chunks(
@ -279,6 +308,10 @@ async fn drain_available_chunks(
stdout_buf.lock().await.push_str(&chunk); stdout_buf.lock().await.push_str(&chunk);
} }
} }
let mut stdout_guard = stdout_buf.lock().await;
cap_output_buffer(&mut stdout_guard);
let mut stderr_guard = stderr_buf.lock().await;
cap_output_buffer(&mut stderr_guard);
} }
impl Default for BashTool { impl Default for BashTool {
@ -380,18 +413,14 @@ impl Tool for BashTool {
let timeout_secs = extract_u64(&args, "timeout") let timeout_secs = extract_u64(&args, "timeout")
.unwrap_or(self.timeout_secs) .unwrap_or(self.timeout_secs)
.clamp(1, MAX_TIMEOUT_SECS); // 下界 1 防止 timeout:0 误杀刚 spawn 的子进程;上界 600s与 schema minimum/maximum 对齐) .clamp(1, MAX_TIMEOUT_SECS); // 下界 1 防止 timeout:0 误杀刚 spawn 的子进程;上界 600s与 schema minimum/maximum 对齐)
let interactive = extract_bool(&args, "interactive").unwrap_or(false);
let cwd = self let cwd = self
.working_dir .working_dir
.as_ref() .as_ref()
.map(|d| Path::new(d)) .map(Path::new)
.unwrap_or_else(|| Path::new(".")); .unwrap_or_else(|| Path::new("."));
match self match self.run_command(command, cwd, timeout_secs).await {
.run_command(command, cwd, timeout_secs, interactive)
.await
{
Ok(output) => Ok(ToolResult { Ok(output) => Ok(ToolResult {
success: true, success: true,
output, output,
@ -410,15 +439,17 @@ impl BashTool {
/// 强制终止子进程并回收,避免 `wait()` 永久挂起导致超时未生效。 /// 强制终止子进程并回收,避免 `wait()` 永久挂起导致超时未生效。
/// ///
/// `start_kill` 在 Unix 发 SIGKILL、在 Windows 调 TerminateProcess均为强制终止 /// `start_kill` 在 Unix 发 SIGKILL、在 Windows 调 TerminateProcess均为强制终止
/// 用 `timeout` 包裹 `wait()` 防止 reap 在异常情况下永久挂起5s 内未回收则再 /// 用 `timeout` 包裹 `wait()` 防止 reap 在异常情况下永久挂起5s 内未回收则
/// `kill().await`(重发信号并等待)+ 3s 兜底,保证工具调用必然返回。 /// 重发终止信号(`start_kill` 非阻塞;不用 `kill().await`——其内部无超时地
/// await wait(),在进程被外部挂起/保护时会永久阻塞),再等 3s 兜底,
/// 保证工具调用必然返回。
async fn kill_and_reap(child: &mut tokio::process::Child) { async fn kill_and_reap(child: &mut tokio::process::Child) {
let _ = child.start_kill(); let _ = child.start_kill();
if tokio::time::timeout(Duration::from_secs(5), child.wait()) if tokio::time::timeout(Duration::from_secs(5), child.wait())
.await .await
.is_err() .is_err()
{ {
let _ = child.kill().await; let _ = child.start_kill();
let _ = tokio::time::timeout(Duration::from_secs(3), child.wait()).await; let _ = tokio::time::timeout(Duration::from_secs(3), child.wait()).await;
} }
} }
@ -428,7 +459,6 @@ impl BashTool {
command: &str, command: &str,
cwd: &Path, cwd: &Path,
timeout_secs: u64, timeout_secs: u64,
interactive: bool,
) -> Result<String, String> { ) -> Result<String, String> {
let mut cmd = Command::new(self.shell.executable()); let mut cmd = Command::new(self.shell.executable());
cmd.args(self.shell.command_args(command)) cmd.args(self.shell.command_args(command))
@ -457,6 +487,9 @@ impl BashTool {
let stdout_buf = Arc::new(Mutex::new(String::new())); let stdout_buf = Arc::new(Mutex::new(String::new()));
let stderr_buf = Arc::new(Mutex::new(String::new())); let stderr_buf = Arc::new(Mutex::new(String::new()));
// pending 短语增量检测窗口:仅保留最近输出的小写化尾部,
// 每个 chunk 只扫描"窗口 + 新 chunk"O(chunk)不再全量重扫O(累计输出))。
let mut pending_window = String::new();
let deadline = Instant::now() + Duration::from_secs(timeout_secs); let deadline = Instant::now() + Duration::from_secs(timeout_secs);
loop { loop {
@ -481,13 +514,19 @@ impl BashTool {
}, },
) )
.await; .await;
let mut stdout_guard = stdout_buf.lock().await;
cap_output_buffer(&mut stdout_guard);
let mut stderr_guard = stderr_buf.lock().await;
cap_output_buffer(&mut stderr_guard);
// 终止可能仍阻塞在 reader.read() 上的 read_stream 任务,避免泄漏。 // 终止可能仍阻塞在 reader.read() 上的 read_stream 任务,避免泄漏。
for t in &read_tasks { for t in &read_tasks {
t.abort(); t.abort();
} }
// 注意:直接复用上方已持有的 guard。tokio::sync::Mutex 不可重入,
// 此处若再次 lock().await 会自死锁(同一任务持锁等待自身释放)。
let output = format_command_output( let output = format_command_output(
&stdout_buf.lock().await, &stdout_guard,
&stderr_buf.lock().await, &stderr_guard,
Some(status.code().unwrap_or(-1)), Some(status.code().unwrap_or(-1)),
); );
return Ok(self.truncate_output(&output)); return Ok(self.truncate_output(&output));
@ -498,14 +537,25 @@ impl BashTool {
None => std::future::pending().await, None => std::future::pending().await,
} }
} => { } => {
if is_stderr { {
stderr_buf.lock().await.push_str(&chunk); let mut buf = if is_stderr {
stderr_buf.lock().await
} else { } else {
stdout_buf.lock().await.push_str(&chunk); stdout_buf.lock().await
};
buf.push_str(&chunk);
cap_output_buffer(&mut buf);
} }
let combined = format_command_output(&stdout_buf.lock().await, &stderr_buf.lock().await, None); // 增量 pending 检测:交互提示只出现在输出尾部,只扫描
if self.should_return_pending(interactive, &combined) { // "尾部窗口 + 新 chunk",避免对全量输出做 O(n²) 重扫
pending_window.push_str(&chunk.to_lowercase());
if pending_window.len() > PENDING_WINDOW_BYTES * 2 {
let cut = pending_window
.floor_char_boundary(pending_window.len() - PENDING_WINDOW_BYTES);
pending_window.drain(..cut);
}
if contains_pending_phrase(&pending_window) {
let mut rx_val = rx.take().unwrap(); let mut rx_val = rx.take().unwrap();
drain_available_chunks(&mut rx_val, &stdout_buf, &stderr_buf).await; drain_available_chunks(&mut rx_val, &stdout_buf, &stderr_buf).await;
let combined = format_command_output(&stdout_buf.lock().await, &stderr_buf.lock().await, None); let combined = format_command_output(&stdout_buf.lock().await, &stderr_buf.lock().await, None);
@ -629,7 +679,7 @@ fn format_command_output(stdout: &str, stderr: &str, exit_code: Option<i32>) ->
if !stderr.trim().is_empty() { if !stderr.trim().is_empty() {
if !output.is_empty() { if !output.is_empty() {
output.push_str("\n"); output.push('\n');
} }
output.push_str("STDERR:\n"); output.push_str("STDERR:\n");
output.push_str(stderr); output.push_str(stderr);

View File

@ -432,7 +432,9 @@ fn calc_evaluate(args: &serde_json::Value) -> Result<String, String> {
// 表达式可产生非有限结果(如 "1/0" → inf、"0/0" → NaN // 表达式可产生非有限结果(如 "1/0" → inf、"0/0" → NaN
// 与 extract_values/extract_f64 的边界策略保持一致:拒绝输出。 // 与 extract_values/extract_f64 的边界策略保持一致:拒绝输出。
if !n.is_finite() { if !n.is_finite() {
return Err(format!("Expression result is not a finite number: {expression}")); return Err(format!(
"Expression result is not a finite number: {expression}"
));
} }
Ok(format_num(n)) Ok(format_num(n))
}) })
@ -873,10 +875,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert!(ok.success); assert!(ok.success);
assert_eq!( assert_eq!(ok.output, "295232799039604140847618609643520000000");
ok.output,
"295232799039604140847618609643520000000"
);
} }
#[tokio::test] #[tokio::test]

View File

@ -140,17 +140,16 @@ impl Tool for FileWriteTool {
}; };
// Create parent directories if needed // Create parent directories if needed
if let Some(parent) = resolved.parent() { if let Some(parent) = resolved.parent()
if !parent.exists() { && !parent.exists()
if let Err(e) = std::fs::create_dir_all(parent) { && let Err(e) = std::fs::create_dir_all(parent)
{
return Ok(ToolResult { return Ok(ToolResult {
success: false, success: false,
output: String::new(), output: String::new(),
error: Some(format!("Failed to create parent directory: {}", e)), error: Some(format!("Failed to create parent directory: {}", e)),
}); });
} }
}
}
match std::fs::write(&resolved, content) { match std::fs::write(&resolved, content) {
Ok(_) => Ok(ToolResult { Ok(_) => Ok(ToolResult {

View File

@ -1,17 +1,22 @@
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use futures_util::StreamExt;
use reqwest::header::HeaderMap; use reqwest::header::HeaderMap;
use serde_json::json; use serde_json::json;
use crate::text::take_prefix_chars; use crate::text::take_prefix_chars;
use crate::tools::traits::{Tool, ToolResult}; use crate::tools::traits::{Tool, ToolResult};
/// 未配置响应大小限制时的硬性下载上限(防止无限响应打满内存)。
const HARD_DOWNLOAD_CAP_BYTES: usize = 32 * 1024 * 1024;
pub struct HttpRequestTool { pub struct HttpRequestTool {
allowed_domains: Vec<String>, allowed_domains: Vec<String>,
max_response_size: usize, max_response_size: usize,
timeout_secs: u64,
allow_private_hosts: bool, allow_private_hosts: bool,
/// 长生命周期 HTTP 客户端(连接池 + TLS 上下文 + 超时配置),构造一次全程复用。
client: reqwest::Client,
} }
impl HttpRequestTool { impl HttpRequestTool {
@ -21,11 +26,16 @@ impl HttpRequestTool {
timeout_secs: u64, timeout_secs: u64,
allow_private_hosts: bool, allow_private_hosts: bool,
) -> Self { ) -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("valid HTTP client configuration");
Self { Self {
allowed_domains: normalize_domains(allowed_domains), allowed_domains: normalize_domains(allowed_domains),
max_response_size, max_response_size,
timeout_secs,
allow_private_hosts, allow_private_hosts,
client,
} }
} }
@ -76,15 +86,14 @@ impl HttpRequestTool {
if let Some(obj) = headers.as_object() { if let Some(obj) = headers.as_object() {
for (key, value) in obj { for (key, value) in obj {
if let Some(str_val) = value.as_str() { if let Some(str_val) = value.as_str()
if let Ok(name) = reqwest::header::HeaderName::from_bytes(key.as_bytes()) { && let Ok(name) = reqwest::header::HeaderName::from_bytes(key.as_bytes())
if let Ok(val) = reqwest::header::HeaderValue::from_str(str_val) { && let Ok(val) = reqwest::header::HeaderValue::from_str(str_val)
{
header_map.insert(name, val); header_map.insert(name, val);
} }
} }
} }
}
}
header_map header_map
} }
@ -103,6 +112,41 @@ impl HttpRequestTool {
text.to_string() text.to_string()
} }
} }
/// 下载字节上限:字符上限 × 4UTF-8 单字符最多 4 字节)保证字符截断前必然读够;
/// 未配置字符上限时使用硬性上限,任何情况下下载量都有界。
fn download_byte_limit(&self) -> usize {
if self.max_response_size == 0 {
HARD_DOWNLOAD_CAP_BYTES
} else {
self.max_response_size
.saturating_mul(4)
.min(HARD_DOWNLOAD_CAP_BYTES)
}
}
}
/// 流式读取响应体,累计达到 `max_bytes` 即提前中止下载。
/// 限制在下载过程中生效(而非全量载入后截断),防止超大响应耗尽内存。
async fn read_body_limited(
response: reqwest::Response,
max_bytes: usize,
) -> Result<String, String> {
let mut stream = response.bytes_stream();
let mut body: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Failed to read response body: {}", e))?;
let remaining = max_bytes.saturating_sub(body.len());
if remaining == 0 {
break;
}
if chunk.len() > remaining {
body.extend_from_slice(&chunk[..remaining]);
break;
}
body.extend_from_slice(&chunk);
}
Ok(String::from_utf8_lossy(&body).into_owned())
} }
fn normalize_domains(domains: Vec<String>) -> Vec<String> { fn normalize_domains(domains: Vec<String>) -> Vec<String> {
@ -309,22 +353,7 @@ impl Tool for HttpRequestTool {
let headers = self.parse_headers(&headers_val); let headers = self.parse_headers(&headers_val);
let client = match reqwest::Client::builder() let mut request = self.client.request(method, &url).headers(headers);
.timeout(Duration::from_secs(self.timeout_secs))
.redirect(reqwest::redirect::Policy::none())
.build()
{
Ok(c) => c,
Err(e) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Failed to create HTTP client: {}", e)),
});
}
};
let mut request = client.request(method, &url).headers(headers);
if let Some(body_str) = body { if let Some(body_str) = body {
request = request.body(body_str.to_string()); request = request.body(body_str.to_string());
@ -335,11 +364,12 @@ impl Tool for HttpRequestTool {
let status = response.status(); let status = response.status();
let status_code = status.as_u16(); let status_code = status.as_u16();
let response_text = response // 流式限长读取:下载量在读取过程中即被约束,超限提前中止
.text() let response_text =
.await match read_body_limited(response, self.download_byte_limit()).await {
.map(|t| self.truncate_response(&t)) Ok(text) => self.truncate_response(&text),
.unwrap_or_else(|_| "[Failed to read response body]".to_string()); Err(_) => "[Failed to read response body]".to_string(),
};
let output = format!( let output = format!(
"Status: {} {}\n\nResponse Body:\n{}", "Status: {} {}\n\nResponse Body:\n{}",

View File

@ -55,11 +55,8 @@ pub fn extract_string(args: &serde_json::Value, key: &str) -> Option<String> {
args.get(key).and_then(|v| { args.get(key).and_then(|v| {
if let Some(s) = v.as_str() { if let Some(s) = v.as_str() {
Some(s.to_string()) Some(s.to_string())
} else if let Some(n) = v.as_number() {
// Handle case where LLM sends a number but we need a string
Some(n.to_string())
} else { } else {
None v.as_number().map(|n| n.to_string())
} }
}) })
} }

View File

@ -1,6 +1,6 @@
use parking_lot::RwLock;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use parking_lot::RwLock;
use crate::domain::tools::{Tool, ToolFunction}; use crate::domain::tools::{Tool, ToolFunction};
@ -24,20 +24,13 @@ impl ToolRegistry {
} }
pub fn get(&self, name: &str) -> Option<Arc<dyn ToolTrait>> { pub fn get(&self, name: &str) -> Option<Arc<dyn ToolTrait>> {
self.tools self.tools.read().get(name).cloned()
.read()
.get(name)
.cloned()
} }
/// Get all registered tools. /// Get all registered tools.
/// Used for concurrent tool execution when we need to look up tools by name. /// Used for concurrent tool execution when we need to look up tools by name.
pub fn get_all(&self) -> Vec<Arc<dyn ToolTrait>> { pub fn get_all(&self) -> Vec<Arc<dyn ToolTrait>> {
self.tools self.tools.read().values().cloned().collect()
.read()
.values()
.cloned()
.collect()
} }
pub fn get_definitions(&self) -> Vec<Tool> { pub fn get_definitions(&self) -> Vec<Tool> {
@ -56,18 +49,11 @@ impl ToolRegistry {
} }
pub fn has_tools(&self) -> bool { pub fn has_tools(&self) -> bool {
!self !self.tools.read().is_empty()
.tools
.read()
.is_empty()
} }
pub fn tool_names(&self) -> Vec<String> { pub fn tool_names(&self) -> Vec<String> {
self.tools self.tools.read().keys().cloned().collect()
.read()
.keys()
.cloned()
.collect()
} }
/// 创建一个排除指定工具的新 registry 副本 /// 创建一个排除指定工具的新 registry 副本
@ -80,9 +66,7 @@ impl ToolRegistry {
.map(|(k, v)| (k.clone(), v.clone())) .map(|(k, v)| (k.clone(), v.clone()))
.collect(); .collect();
let new_registry = ToolRegistry::new(); let new_registry = ToolRegistry::new();
*new_registry *new_registry.tools.write() = filtered;
.tools
.write() = filtered;
new_registry new_registry
} }
@ -97,9 +81,7 @@ impl ToolRegistry {
.map(|(k, v)| (k.clone(), v.clone())) .map(|(k, v)| (k.clone(), v.clone()))
.collect(); .collect();
let new_registry = ToolRegistry::new(); let new_registry = ToolRegistry::new();
*new_registry *new_registry.tools.write() = filtered;
.tools
.write() = filtered;
new_registry new_registry
} }
} }

View File

@ -338,8 +338,8 @@ fn enrich_target_from_context(
_ => return target, _ => return target,
}; };
if !has_non_empty_string(&object, "channel") { if !has_non_empty_string(&object, "channel")
if let Some(channel_name) = context && let Some(channel_name) = context
.channel_name .channel_name
.as_ref() .as_ref()
.filter(|value| !value.trim().is_empty()) .filter(|value| !value.trim().is_empty())
@ -349,10 +349,9 @@ fn enrich_target_from_context(
serde_json::Value::String(channel_name.clone()), serde_json::Value::String(channel_name.clone()),
); );
} }
}
if !has_non_empty_string(&object, "chat_id") { if !has_non_empty_string(&object, "chat_id")
if let Some(chat_id) = context && let Some(chat_id) = context
.chat_id .chat_id
.as_ref() .as_ref()
.filter(|value| !value.trim().is_empty()) .filter(|value| !value.trim().is_empty())
@ -362,7 +361,6 @@ fn enrich_target_from_context(
serde_json::Value::String(chat_id.clone()), serde_json::Value::String(chat_id.clone()),
); );
} }
}
serde_json::Value::Object(object) serde_json::Value::Object(object)
} }

View File

@ -114,11 +114,12 @@ impl SchemaCleanr {
anyhow::bail!("Schema missing required 'type' field"); anyhow::bail!("Schema missing required 'type' field");
} }
if let Some(Value::String(t)) = obj.get("type") { if let Some(Value::String(t)) = obj.get("type")
if t == "object" && !obj.contains_key("properties") { && t == "object"
&& !obj.contains_key("properties")
{
tracing::warn!("Object schema without 'properties' field may cause issues"); tracing::warn!("Object schema without 'properties' field may cause issues");
} }
}
Ok(()) Ok(())
} }
@ -173,11 +174,11 @@ impl SchemaCleanr {
} }
// Handle anyOf/oneOf simplification // Handle anyOf/oneOf simplification
if obj.contains_key("anyOf") || obj.contains_key("oneOf") { if (obj.contains_key("anyOf") || obj.contains_key("oneOf"))
if let Some(simplified) = Self::try_simplify_union(&obj, defs, strategy, ref_stack) { && let Some(simplified) = Self::try_simplify_union(&obj, defs, strategy, ref_stack)
{
return simplified; return simplified;
} }
}
// Build cleaned object // Build cleaned object
let mut cleaned = Map::new(); let mut cleaned = Map::new();
@ -244,14 +245,14 @@ impl SchemaCleanr {
return Self::preserve_meta(obj, Value::Object(Map::new())); return Self::preserve_meta(obj, Value::Object(Map::new()));
} }
if let Some(def_name) = Self::parse_local_ref(ref_value) { if let Some(def_name) = Self::parse_local_ref(ref_value)
if let Some(definition) = defs.get(def_name.as_str()) { && let Some(definition) = defs.get(def_name.as_str())
{
ref_stack.insert(ref_value.to_string()); ref_stack.insert(ref_value.to_string());
let cleaned = Self::clean_with_defs(definition.clone(), defs, strategy, ref_stack); let cleaned = Self::clean_with_defs(definition.clone(), defs, strategy, ref_stack);
ref_stack.remove(ref_value); ref_stack.remove(ref_value);
return Self::preserve_meta(obj, cleaned); return Self::preserve_meta(obj, cleaned);
} }
}
tracing::warn!("Cannot resolve $ref: {}", ref_value); tracing::warn!("Cannot resolve $ref: {}", ref_value);
Self::preserve_meta(obj, Value::Object(Map::new())) Self::preserve_meta(obj, Value::Object(Map::new()))
@ -342,17 +343,18 @@ impl SchemaCleanr {
if let Some(Value::Null) = obj.get("const") { if let Some(Value::Null) = obj.get("const") {
return true; return true;
} }
if let Some(Value::Array(arr)) = obj.get("enum") { if let Some(Value::Array(arr)) = obj.get("enum")
if arr.len() == 1 && matches!(arr[0], Value::Null) { && arr.len() == 1
&& matches!(arr[0], Value::Null)
{
return true; return true;
} }
} if let Some(Value::String(t)) = obj.get("type")
if let Some(Value::String(t)) = obj.get("type") { && t == "null"
if t == "null" { {
return true; return true;
} }
} }
}
false false
} }

View File

@ -19,6 +19,8 @@ use tokio::time::Instant;
use uuid::Uuid; use uuid::Uuid;
use crate::tools::bash::cap_output_buffer;
const SESSION_TIMEOUT_SECS: u64 = 300; // 5 minutes const SESSION_TIMEOUT_SECS: u64 = 300; // 5 minutes
const OUTPUT_WAIT_MS: u64 = 2000; const OUTPUT_WAIT_MS: u64 = 2000;
@ -72,14 +74,20 @@ impl ShellSessionManager {
let stderr_buf = Arc::new(Mutex::new(initial_stderr)); let stderr_buf = Arc::new(Mutex::new(initial_stderr));
// Spawn a background task that drains the channel into buffers. // Spawn a background task that drains the channel into buffers.
// Buffers are capped in size (head+tail retained) so a long-lived
// chatty process cannot grow memory without bound during the session TTL.
let stdout_clone = stdout_buf.clone(); let stdout_clone = stdout_buf.clone();
let stderr_clone = stderr_buf.clone(); let stderr_clone = stderr_buf.clone();
let drain_task = tokio::spawn(async move { let drain_task = tokio::spawn(async move {
while let Some((is_stderr, chunk)) = rx.recv().await { while let Some((is_stderr, chunk)) = rx.recv().await {
if is_stderr { if is_stderr {
stderr_clone.lock().await.push_str(&chunk); let mut buf = stderr_clone.lock().await;
buf.push_str(&chunk);
cap_output_buffer(&mut buf);
} else { } else {
stdout_clone.lock().await.push_str(&chunk); let mut buf = stdout_clone.lock().await;
buf.push_str(&chunk);
cap_output_buffer(&mut buf);
} }
} }
}); });
@ -134,7 +142,9 @@ impl ShellSessionManager {
return Err("Session stdin is closed".to_string()); return Err("Session stdin is closed".to_string());
} }
// Record output length before wait // Record output length before wait (byte offsets — buffers only grow via
// push_str, so a previous end is always a valid char boundary, unless a
// buffer-cap trim happened in between, which is handled below).
let prev_stdout_len = session.stdout_buf.lock().await.len(); let prev_stdout_len = session.stdout_buf.lock().await.len();
let prev_stderr_len = session.stderr_buf.lock().await.len(); let prev_stderr_len = session.stderr_buf.lock().await.len();
@ -154,11 +164,23 @@ impl ShellSessionManager {
} }
} }
let stdout = session.stdout_buf.lock().await.clone(); // 按字节偏移在锁内直接切片取新增输出:避免对整个缓冲做全量克隆,
let stderr = session.stderr_buf.lock().await.clone(); // 也修复了旧实现"字节长度当字符数 skip"导致多字节输出丢失的问题。
let new_stdout = {
let new_stdout: String = stdout.chars().skip(prev_stdout_len).collect(); let buf = session.stdout_buf.lock().await;
let new_stderr: String = stderr.chars().skip(prev_stderr_len).collect(); match buf.get(prev_stdout_len..) {
Some(new_part) => new_part.to_string(),
// 偏移失效(缓冲被上限截断过):退化为返回截断后的全部内容
None => buf.clone(),
}
};
let new_stderr = {
let buf = session.stderr_buf.lock().await;
match buf.get(prev_stderr_len..) {
Some(new_part) => new_part.to_string(),
None => buf.clone(),
}
};
let mut result = String::new(); let mut result = String::new();
if !new_stdout.is_empty() { if !new_stdout.is_empty() {

View File

@ -211,11 +211,9 @@ impl Tool for SkillManageTool {
Err(err) => return Ok(error_result(&err)), Err(err) => return Ok(error_result(&err)),
} }
} }
if reload { if reload && let Err(err) = self.skills.reload() {
if let Err(err) = self.skills.reload() {
return Ok(error_result(&err)); return Ok(error_result(&err));
} }
}
json!({ json!({
"status": "disabled", "status": "disabled",

View File

@ -1,8 +1,8 @@
use parking_lot::RwLock;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use parking_lot::RwLock;
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
@ -535,7 +535,11 @@ impl DefaultSubAgentRuntime {
let inherited = session let inherited = session
.parent_topic_id .parent_topic_id
.as_deref() .as_deref()
.and_then(|tid| self.topic_model_selections.as_ref().and_then(|s| s.get(tid))) .and_then(|tid| {
self.topic_model_selections
.as_ref()
.and_then(|s| s.get(tid))
})
.or_else(|| { .or_else(|| {
self.model_selections self.model_selections
.as_ref() .as_ref()
@ -877,11 +881,11 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
// 2. 校验父智能体的子代理策略(白/黑名单),再查找子代理定义。 // 2. 校验父智能体的子代理策略(白/黑名单),再查找子代理定义。
// 与 find_subagent_def 的"def 不可用即拒绝"安全范式一致:策略不通过即拒绝, // 与 find_subagent_def 的"def 不可用即拒绝"安全范式一致:策略不通过即拒绝,
// 防止 LLM 通过选择被禁子代理绕过限制。 // 防止 LLM 通过选择被禁子代理绕过限制。
if let Some(cap) = &parent_context.parent_capability { if let Some(cap) = &parent_context.parent_capability
if let Err(msg) = cap.check_subagent_allowed(&task.subagent_type.name) { && let Err(msg) = cap.check_subagent_allowed(&task.subagent_type.name)
{
return Err(TaskError::InvalidArguments(msg)); return Err(TaskError::InvalidArguments(msg));
} }
}
// 3. 查找子代理定义 // 3. 查找子代理定义
let def = self let def = self
@ -1097,8 +1101,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
.unwrap_or_default(), .unwrap_or_default(),
}; };
let _ = sub_done_sender.send(result).await; let _ = sub_done_sender.send(result).await;
let _ = let _ = store.update_pending_subagent_status(&task_id_for_spawn, "failed");
store.update_pending_subagent_status(&task_id_for_spawn, "failed");
// _registry_guard drop 时清理 registry 条目 // _registry_guard drop 时清理 registry 条目
return; return;
} }
@ -1140,11 +1143,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
String::new(), String::new(),
"cancelled".to_string(), "cancelled".to_string(),
), ),
Err(e) => ( Err(e) => (SubagentStatus::Failed, String::new(), e.to_string()),
SubagentStatus::Failed,
String::new(),
e.to_string(),
),
}; };
// 查询同 topic 下仍未完成的子代理列表 // 查询同 topic 下仍未完成的子代理列表
@ -1181,7 +1180,8 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
SubagentStatus::Timeout => "timeout", SubagentStatus::Timeout => "timeout",
SubagentStatus::Cancelled => "cancelled", SubagentStatus::Cancelled => "cancelled",
}; };
if let Err(e) = store.update_pending_subagent_status(&task_id_for_spawn, status_str) { if let Err(e) = store.update_pending_subagent_status(&task_id_for_spawn, status_str)
{
tracing::warn!( tracing::warn!(
error = %e, error = %e,
task_id = %task_id_for_spawn, task_id = %task_id_for_spawn,
@ -1211,7 +1211,13 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
if let Err(e) = task_repository.save_task_session(&session_done).await { 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"); 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; publish_subagent_error(
&bus,
&session_done,
&e.to_string(),
&trace_id_owned,
)
.await;
} }
} }
// _registry_guard 在此 drop确定性清理 cancel_registry 条目 // _registry_guard 在此 drop确定性清理 cancel_registry 条目
@ -1236,7 +1242,9 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
// ===== 同步路径(子代理嵌套或无 sub_done_sender ===== // ===== 同步路径(子代理嵌套或无 sub_done_sender =====
// 9. 执行任务并处理结果 // 9. 执行任务并处理结果
let result = self.execute_task(agent, &session, &def, task.prompt.clone()).await; let result = self
.execute_task(agent, &session, &def, task.prompt.clone())
.await;
match result { match result {
Ok(tool_result) => { Ok(tool_result) => {
@ -1303,11 +1311,11 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
// 4.1 校验父智能体的子代理策略(白/黑名单)。 // 4.1 校验父智能体的子代理策略(白/黑名单)。
// 安全要求:与 spawn 一致,防止 resume 绕过白名单。若用户切换到不允许 // 安全要求:与 spawn 一致,防止 resume 绕过白名单。若用户切换到不允许
// 该子代理的专家resume 应失败(与 def 被删除即失败的安全语义一致)。 // 该子代理的专家resume 应失败(与 def 被删除即失败的安全语义一致)。
if let Some(cap) = &parent_context.parent_capability { if let Some(cap) = &parent_context.parent_capability
if let Err(msg) = cap.check_subagent_allowed(&session.subagent_type) { && let Err(msg) = cap.check_subagent_allowed(&session.subagent_type)
{
return Err(TaskError::InvalidArguments(msg)); return Err(TaskError::InvalidArguments(msg));
} }
}
// 4.2 重新解析 def 以应用工具过滤。 // 4.2 重新解析 def 以应用工具过滤。
// 安全要求def 被删除/禁用时必须失败恢复,而不是降级为完整工具集—— // 安全要求def 被删除/禁用时必须失败恢复,而不是降级为完整工具集——
@ -1406,10 +1414,11 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
// token 不在 registry 中(可能已完成但 DB 状态未更新,或进程重启后丢失) // token 不在 registry 中(可能已完成但 DB 状态未更新,或进程重启后丢失)
// 不变量 1条件 UPDATE仅在 status='running' 时转为 cancelled // 不变量 1条件 UPDATE仅在 status='running' 时转为 cancelled
// 避免 spawn 已完成的终态被覆盖completed → cancelled 是非法转换) // 避免 spawn 已完成的终态被覆盖completed → cancelled 是非法转换)
match self match self.store.try_update_pending_subagent_status(
.store &record.task_id,
.try_update_pending_subagent_status(&record.task_id, "running", "cancelled") "running",
{ "cancelled",
) {
Ok(true) => { Ok(true) => {
tracing::info!( tracing::info!(
task_id = %record.task_id, task_id = %record.task_id,
@ -1753,24 +1762,15 @@ impl SubagentRuntime {
/// (生产环境两者相同,但测试场景使用临时目录时必须用 `self.cwd`)。 /// (生产环境两者相同,但测试场景使用临时目录时必须用 `self.cwd`)。
pub fn reload(&self) -> Result<(), String> { pub fn reload(&self) -> Result<(), String> {
let new_catalog = SubagentCatalog::discover_with_cwd(&self.config, &self.cwd); let new_catalog = SubagentCatalog::discover_with_cwd(&self.config, &self.cwd);
let mut guard = self let mut guard = self.catalog.write();
.catalog
.write()
;
*guard = new_catalog; *guard = new_catalog;
Ok(()) Ok(())
} }
/// 列出所有子代理(含禁用项),带 disabled_in_scopes /// 列出所有子代理(含禁用项),带 disabled_in_scopes
pub fn list_with_status(&self) -> Vec<SubagentWithStatus> { pub fn list_with_status(&self) -> Vec<SubagentWithStatus> {
let state = self let state = self.disable_state.read();
.disable_state let catalog = self.catalog.read();
.read()
;
let catalog = self
.catalog
.read()
;
let mut items: Vec<SubagentWithStatus> = catalog let mut items: Vec<SubagentWithStatus> = catalog
.all() .all()
.iter() .iter()
@ -1794,14 +1794,8 @@ impl SubagentRuntime {
/// 可用子代理名称(过滤禁用项) /// 可用子代理名称(过滤禁用项)
pub fn available_names(&self) -> Vec<String> { pub fn available_names(&self) -> Vec<String> {
let state = self let state = self.disable_state.read();
.disable_state let catalog = self.catalog.read();
.read()
;
let catalog = self
.catalog
.read()
;
catalog catalog
.names() .names()
.into_iter() .into_iter()
@ -1811,30 +1805,17 @@ impl SubagentRuntime {
/// 查找可用子代理(过滤禁用项) /// 查找可用子代理(过滤禁用项)
pub fn find_available(&self, name: &str) -> Option<SubagentDef> { pub fn find_available(&self, name: &str) -> Option<SubagentDef> {
let state = self let state = self.disable_state.read();
.disable_state
.read()
;
if state.is_disabled(name) { if state.is_disabled(name) {
return None; return None;
} }
self.catalog self.catalog.read().find(name).cloned()
.read()
.find(name)
.cloned()
} }
/// 生成过滤后的系统索引提示词 /// 生成过滤后的系统索引提示词
pub fn system_index_prompt_filtered(&self) -> Option<String> { pub fn system_index_prompt_filtered(&self) -> Option<String> {
let state = self let state = self.disable_state.read();
.disable_state let catalog = self.catalog.read();
.read()
;
let catalog = self
.catalog
.read()
;
let available_defs: Vec<&SubagentDef> = catalog let available_defs: Vec<&SubagentDef> = catalog
.all() .all()
.into_iter() .into_iter()
@ -1872,14 +1853,8 @@ impl SubagentRuntime {
allowed: Option<&[String]>, allowed: Option<&[String]>,
denied: &[String], denied: &[String],
) -> Option<String> { ) -> Option<String> {
let state = self let state = self.disable_state.read();
.disable_state let catalog = self.catalog.read();
.read()
;
let catalog = self
.catalog
.read()
;
let available_defs: Vec<&SubagentDef> = catalog let available_defs: Vec<&SubagentDef> = catalog
.all() .all()
.into_iter() .into_iter()
@ -1942,13 +1917,7 @@ impl SubagentRuntime {
enabled: bool, enabled: bool,
) -> Result<SubagentAvailabilityChange, String> { ) -> Result<SubagentAvailabilityChange, String> {
// 校验子代理存在 // 校验子代理存在
if self if self.catalog.read().find(name).is_none() {
.catalog
.read()
.find(name)
.is_none()
{
return Err(format!("subagent '{}' not found", name)); return Err(format!("subagent '{}' not found", name));
} }
@ -1969,10 +1938,7 @@ impl SubagentRuntime {
// 更新内存中的 disable_state // 更新内存中的 disable_state
{ {
let mut state = self let mut state = self.disable_state.write();
.disable_state
.write()
;
match scope { match scope {
SubagentScope::User => { SubagentScope::User => {
if enabled { if enabled {
@ -1992,10 +1958,7 @@ impl SubagentRuntime {
} }
// 计算新的 disabled_in_scopes // 计算新的 disabled_in_scopes
let state = self let state = self.disable_state.read();
.disable_state
.read()
;
let disabled_in_scopes = state.disabled_scopes_for(name); let disabled_in_scopes = state.disabled_scopes_for(name);
Ok(SubagentAvailabilityChange { Ok(SubagentAvailabilityChange {
@ -2023,10 +1986,7 @@ impl SubagentRuntime {
reload: bool, reload: bool,
) -> Result<SubagentDef, String> { ) -> Result<SubagentDef, String> {
let def = { let def = {
let catalog = self let catalog = self.catalog.read();
.catalog
.read()
;
catalog catalog
.find(name) .find(name)
.ok_or_else(|| format!("subagent '{}' not found", name))? .ok_or_else(|| format!("subagent '{}' not found", name))?
@ -2089,10 +2049,7 @@ impl SubagentRuntime {
) -> Result<SubagentDef, String> { ) -> Result<SubagentDef, String> {
validate_subagent_name(name)?; validate_subagent_name(name)?;
{ {
let catalog = self let catalog = self.catalog.read();
.catalog
.read()
;
if catalog.find(name).is_some() { if catalog.find(name).is_some() {
return Err(format!("subagent '{}' already exists", name)); return Err(format!("subagent '{}' already exists", name));
} }
@ -2136,17 +2093,10 @@ impl SubagentRuntime {
/// 对齐 `ExpertRuntime::delete_expert`。 /// 对齐 `ExpertRuntime::delete_expert`。
/// - builtin 子代理path 为 None禁止删除。 /// - builtin 子代理path 为 None禁止删除。
/// - 仅当目录内除 SUBAGENT.md 外无其他文件时才删除目录,避免误删用户附件。 /// - 仅当目录内除 SUBAGENT.md 外无其他文件时才删除目录,避免误删用户附件。
pub fn delete_subagent( pub fn delete_subagent(&self, name: &str, reload: bool) -> Result<PathBuf, String> {
&self,
name: &str,
reload: bool,
) -> Result<PathBuf, String> {
validate_subagent_name(name)?; validate_subagent_name(name)?;
let path = { let path = {
let catalog = self let catalog = self.catalog.read();
.catalog
.read()
;
let def = catalog let def = catalog
.find(name) .find(name)
.ok_or_else(|| format!("subagent '{}' not found", name))?; .ok_or_else(|| format!("subagent '{}' not found", name))?;
@ -2201,11 +2151,7 @@ fn validate_subagent_name(name: &str) -> Result<(), String> {
/// 获取指定 scope 下某子代理的 SUBAGENT.md 路径。 /// 获取指定 scope 下某子代理的 SUBAGENT.md 路径。
/// 对齐 `expert_file_path`。 /// 对齐 `expert_file_path`。
fn subagent_file_path( fn subagent_file_path(scope: SubagentScope, name: &str, cwd: &Path) -> Result<PathBuf, String> {
scope: SubagentScope,
name: &str,
cwd: &Path,
) -> Result<PathBuf, String> {
let root = match scope { let root = match scope {
SubagentScope::User => dirs::home_dir() SubagentScope::User => dirs::home_dir()
.map(|p| p.join(".picobot").join("subagents")) .map(|p| p.join(".picobot").join("subagents"))
@ -2632,7 +2578,7 @@ mod tests {
// 禁用后 prompt 不应包含 general无可用子代理时返回 None // 禁用后 prompt 不应包含 general无可用子代理时返回 None
let prompt = runtime.system_index_prompt_filtered(); let prompt = runtime.system_index_prompt_filtered();
assert!(prompt.map_or(true, |p| !p.contains("<name>general</name>"))); assert!(prompt.is_none_or(|p| !p.contains("<name>general</name>")));
} }
#[test] #[test]
@ -3142,10 +3088,7 @@ mod tests {
let item = items.iter().find(|i| i.name == "demo-create").unwrap(); let item = items.iter().find(|i| i.name == "demo-create").unwrap();
assert_eq!(item.description, "demo create agent"); assert_eq!(item.description, "demo create agent");
assert_eq!(item.body.as_deref(), Some("demo body content")); assert_eq!(item.body.as_deref(), Some("demo body content"));
assert_eq!( assert_eq!(item.capability.denied_skills, vec!["skill_x".to_string()]);
item.capability.denied_skills,
vec!["skill_x".to_string()]
);
} }
#[test] #[test]
@ -3288,7 +3231,8 @@ mod tests {
"directory should be preserved when it has other files" "directory should be preserved when it has other files"
); );
assert!( assert!(
!temp.path() !temp
.path()
.join(".picobot") .join(".picobot")
.join("subagents") .join("subagents")
.join("mixed") .join("mixed")

View File

@ -103,7 +103,7 @@ impl Tool for TaskTool {
// 2. 验证描述长度 // 2. 验证描述长度
let word_count = task_args.description.split_whitespace().count(); let word_count = task_args.description.split_whitespace().count();
if task_args.description.len() > 50 || word_count > 7 || word_count < 1 { if task_args.description.len() > 50 || !(1..=7).contains(&word_count) {
return Ok(ToolResult { return Ok(ToolResult {
success: false, success: false,
output: String::new(), output: String::new(),
@ -136,8 +136,9 @@ impl Tool for TaskTool {
// 4. 深度校验仅对嵌套场景生效None = 不限制) // 4. 深度校验仅对嵌套场景生效None = 不限制)
// Some(N) 表示允许最多 N 层嵌套depth=1 的 agent 可创建 depth=2但 depth=2 不能再创建 // Some(N) 表示允许最多 N 层嵌套depth=1 的 agent 可创建 depth=2但 depth=2 不能再创建
if let Some(max_depth) = self.max_nesting_depth { if let Some(max_depth) = self.max_nesting_depth
if context.nesting_depth > max_depth { && context.nesting_depth > max_depth
{
return Ok(ToolResult { return Ok(ToolResult {
success: false, success: false,
output: String::new(), output: String::new(),
@ -147,7 +148,6 @@ impl Tool for TaskTool {
)), )),
}); });
} }
}
// 5. 执行任务 // 5. 执行任务
let result = if let Some(task_id) = task_args.task_id { let result = if let Some(task_id) = task_args.task_id {

View File

@ -8,8 +8,10 @@ use crate::utils::current_timestamp;
/// 子代理会话状态 /// 子代理会话状态
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum TaskSessionState { pub enum TaskSessionState {
/// 正在执行 /// 正在执行
#[default]
Running, Running,
/// 已完成 /// 已完成
Completed, Completed,
@ -23,12 +25,6 @@ pub enum TaskSessionState {
Unknown, Unknown,
} }
impl Default for TaskSessionState {
fn default() -> Self {
Self::Running
}
}
impl TaskSessionState { impl TaskSessionState {
pub fn as_str(&self) -> &'static str { pub fn as_str(&self) -> &'static str {
match self { match self {

View File

@ -88,12 +88,12 @@ impl Tool for TodoReadTool {
// 2. 读锁查内存 // 2. 读锁查内存
{ {
let guard = self.state.read().await; let guard = self.state.read().await;
if let Some(items) = guard.get(&scope_key) { if let Some(items) = guard.get(&scope_key)
if !items.is_empty() { && !items.is_empty()
{
return Ok(success_result(items, &scope_key, "memory")); return Ok(success_result(items, &scope_key, "memory"));
} }
} }
}
// 3. 内存为空 → 查 SQLite 并回填 // 3. 内存为空 → 查 SQLite 并回填
let records = match self.repository.list_todos(&scope_key) { let records = match self.repository.list_todos(&scope_key) {

View File

@ -1,5 +1,5 @@
use std::time::Duration;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use tokio::sync::{mpsc, watch}; use tokio::sync::{mpsc, watch};
@ -58,11 +58,7 @@ pub trait WaitCoordinator: Send + Sync + 'static {
/// select! 立即返回 `WaitEvent::Cancelled`,并完成完整的状态清理 /// select! 立即返回 `WaitEvent::Cancelled`,并完成完整的状态清理
///(重获取锁、回填 guard、清除 is_waiting、归还 receiver ///(重获取锁、回填 guard、清除 is_waiting、归还 receiver
/// 为 None 时退化为不检查取消(向后兼容,子代理场景)。 /// 为 None 时退化为不检查取消(向后兼容,子代理场景)。
async fn wait( async fn wait(&self, timeout: Duration, cancel_rx: Option<watch::Receiver<()>>) -> WaitEvent;
&self,
timeout: Duration,
cancel_rx: Option<watch::Receiver<()>>,
) -> WaitEvent;
} }
#[derive(Clone, Default)] #[derive(Clone, Default)]

View File

@ -156,9 +156,7 @@ impl Tool for WaitForSubagentsTool {
// 传入 cancel_rx 使 /stop 命令能立即中断等待。 // 传入 cancel_rx 使 /stop 命令能立即中断等待。
// coordinator 在 select! 中以 biased 优先级处理: // coordinator 在 select! 中以 biased 优先级处理:
// 子代理结果 > 用户消息 > 取消信号 > 超时 // 子代理结果 > 用户消息 > 取消信号 > 超时
let event = coordinator let event = coordinator.wait(timeout, context.cancel_rx.clone()).await;
.wait(timeout, context.cancel_rx.clone())
.await;
// 5. 格式化返回结果 // 5. 格式化返回结果
let output = match event { let output = match event {

View File

@ -18,7 +18,7 @@ fn test_message_special_characters() {
/// Test that multi-line system prompt is preserved /// Test that multi-line system prompt is preserved
#[test] #[test]
fn test_multiline_system_prompt() { fn test_multiline_system_prompt() {
let messages = vec![ let messages = [
Message::system( Message::system(
"You are a helpful assistant.\n\nFollow these rules:\n1. Be kind\n2. Be accurate", "You are a helpful assistant.\n\nFollow these rules:\n1. Be kind\n2. Be accurate",
), ),

View File

@ -423,6 +423,16 @@ function App() {
sendMessage({ type: 'command', payload: JSON.stringify(cmd) }); sendMessage({ type: 'command', payload: JSON.stringify(cmd) });
}, [sendMessage, handleCommand, handleStop]); }, [sendMessage, handleCommand, handleStop]);
// 稳定引用:只读视图(子智能体/定时任务)下的空发送回调,
// 避免内联箭头函数每次渲染产生新引用、破坏下游 memo 化。
const noopSendMessage = useCallback(() => {}, []);
// 稳定引用:打开专家设置页
const openExpertsSettings = useCallback(() => {
setConfigInitialTab('experts');
setConfigPageOpen(true);
}, []);
const handleCreateTopic = useCallback(() => { const handleCreateTopic = useCallback(() => {
if (isReadOnly || !sessionId) { if (isReadOnly || !sessionId) {
return; return;
@ -1007,7 +1017,7 @@ function App() {
channels.find((c) => c.id === selectedChannel)?.name ?? channels.find((c) => c.id === selectedChannel)?.name ??
'PicoBot') 'PicoBot')
} }
onSendMessage={subAgentView || schedulerView ? () => {} : handleSendMessage} onSendMessage={subAgentView || schedulerView ? noopSendMessage : handleSendMessage}
onNavigateToSubAgent={handleNavigateToSubAgent} onNavigateToSubAgent={handleNavigateToSubAgent}
onStop={handleStopExecution} onStop={handleStopExecution}
showThinking={showThinking} showThinking={showThinking}
@ -1015,10 +1025,7 @@ function App() {
highlightedMessageId={highlightedMessageId} highlightedMessageId={highlightedMessageId}
sessionId={sessionId} sessionId={sessionId}
settingsClosedTick={settingsClosedTick} settingsClosedTick={settingsClosedTick}
onOpenSettings={() => { onOpenSettings={openExpertsSettings}
setConfigInitialTab('experts');
setConfigPageOpen(true);
}}
/> />
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useCallback } from 'react';
import { MessageList } from './MessageList'; import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput'; import { MessageInput } from './MessageInput';
import { ExpertSelector } from './ExpertSelector'; import { ExpertSelector } from './ExpertSelector';
@ -55,6 +55,13 @@ export function ChatContainer({
model: string; model: string;
} | null>(null); } | null>(null);
// 稳定引用,避免内联箭头破坏下游 memo 化
const handleModelSelectionChange = useCallback(
(effective: { provider: string; model: string }) =>
setEffectiveModel({ provider: effective.provider, model: effective.model }),
[],
);
const selectors = ( const selectors = (
<div className="flex flex-wrap items-center gap-1 px-3 pt-2"> <div className="flex flex-wrap items-center gap-1 px-3 pt-2">
<ExpertSelector <ExpertSelector
@ -67,9 +74,7 @@ export function ChatContainer({
sessionId={sessionId ?? null} sessionId={sessionId ?? null}
topicId={topicId ?? null} topicId={topicId ?? null}
settingsClosedTick={settingsClosedTick} settingsClosedTick={settingsClosedTick}
onSelectionChange={(effective) => onSelectionChange={handleModelSelectionChange}
setEffectiveModel({ provider: effective.provider, model: effective.model })
}
/> />
</div> </div>
); );

View File

@ -132,6 +132,24 @@ function formatDuration(ms: number): string {
return `${minutes}m ${seconds}s`; return `${minutes}m ${seconds}s`;
} }
/**
* base64 Blob
* atob + + Uint8Array
* 50MB MB Blob
* atob +
*/
function base64ToBlob(base64: string, mimeType: string): Blob {
const byteChars = atob(base64);
const total = byteChars.length;
const CHUNK_SIZE = 0x8000; // 32K
const parts: Uint8Array<ArrayBuffer>[] = [];
for (let offset = 0; offset < total; offset += CHUNK_SIZE) {
const slice = byteChars.slice(offset, offset + CHUNK_SIZE);
parts.push(Uint8Array.from(slice, (c) => c.charCodeAt(0)));
}
return new Blob(parts, { type: mimeType });
}
function AttachmentCard({ attachment }: { attachment: Attachment }) { function AttachmentCard({ attachment }: { attachment: Attachment }) {
const fileName = attachment.file_name || getFileName(attachment.path); const fileName = attachment.file_name || getFileName(attachment.path);
@ -140,13 +158,7 @@ function AttachmentCard({ attachment }: { attachment: Attachment }) {
e.preventDefault(); e.preventDefault();
const mimeType = attachment.mime_type || 'application/octet-stream'; const mimeType = attachment.mime_type || 'application/octet-stream';
const byteChars = atob(attachment.content_base64); const blob = base64ToBlob(attachment.content_base64, mimeType);
const byteNums = new Array(byteChars.length);
for (let i = 0; i < byteChars.length; i++) {
byteNums[i] = byteChars.charCodeAt(i);
}
const byteArr = new Uint8Array(byteNums);
const blob = new Blob([byteArr], { type: mimeType });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@ -210,13 +222,7 @@ function ImageLightbox({
const handleDownload = (e: React.MouseEvent) => { const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
const byteChars = atob(src); const blob = base64ToBlob(src, mimeType);
const byteNums = new Array(byteChars.length);
for (let i = 0; i < byteChars.length; i++) {
byteNums[i] = byteChars.charCodeAt(i);
}
const byteArr = new Uint8Array(byteNums);
const blob = new Blob([byteArr], { type: mimeType });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;

View File

@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react'; import { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { MessageBubble } from './MessageBubble'; import { MessageBubble } from './MessageBubble';
import type { ChatMessage } from '../../types/protocol'; import type { ChatMessage } from '../../types/protocol';
@ -55,8 +55,13 @@ export function MessageList({
: undefined, : undefined,
}); });
// 消息 id → virtualizer index 映射,用于 highlight 滚动定位 // 消息 id → virtualizer index 映射,用于 highlight 滚动定位。
const messageIdToIndex = useRef<Map<string, number>>(new Map()); // useMemo 化:仅在 messages 变化时重建,而非每次渲染(流式期间每帧一次)都全量重建。
const messageIdToIndex = useMemo(() => {
const map = new Map<string, number>();
messages.forEach((m, i) => map.set(m.id, i));
return map;
}, [messages]);
// ---- scroll helpers ---- // ---- scroll helpers ----
@ -167,7 +172,7 @@ export function MessageList({
useEffect(() => { useEffect(() => {
if (!highlightedMessageId) return; if (!highlightedMessageId) return;
const idx = messageIdToIndex.current.get(highlightedMessageId); const idx = messageIdToIndex.get(highlightedMessageId);
if (idx === undefined) return; if (idx === undefined) return;
virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' }); virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
@ -183,7 +188,7 @@ export function MessageList({
targetElement.classList.remove('todo-highlight'); targetElement.classList.remove('todo-highlight');
}, 2000); }, 2000);
}); });
}, [highlightedMessageId, virtualizer]); }, [highlightedMessageId, messageIdToIndex, virtualizer]);
// ---- 行高强制重测(修复 tanstack virtual-core 3.17.x 陈旧高度导致行重叠)---- // ---- 行高强制重测(修复 tanstack virtual-core 3.17.x 陈旧高度导致行重叠)----
// 3.17.x 在滚动状态会跳过同步测量、对缓冲区外的行跳过 RO 更新并复用缓存高度; // 3.17.x 在滚动状态会跳过同步测量、对缓冲区外的行跳过 RO 更新并复用缓存高度;
@ -241,10 +246,6 @@ export function MessageList({
); );
} }
// 构建消息 id → index 映射(每次渲染更新,供 highlight 查找)
messageIdToIndex.current.clear();
messages.forEach((m, i) => messageIdToIndex.current.set(m.id, i));
// ---- main render ---- // ---- main render ----
const virtualItems = virtualizer.getVirtualItems(); const virtualItems = virtualizer.getVirtualItems();

View File

@ -24,7 +24,9 @@ export function getGatewaySettings(): GatewaySettings {
} }
export function buildWsUrl(settings: GatewaySettings): string { export function buildWsUrl(settings: GatewaySettings): string {
const base = `ws://${settings.host}:${settings.port}/ws`; // HTTPS 页面下浏览器禁止混合内容ws://),需使用 wss://
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const base = `${protocol}://${settings.host}:${settings.port}/ws`;
// 远程访问时需要携带认证 token浏览器原生 WebSocket 不支持自定义 header // 远程访问时需要携带认证 token浏览器原生 WebSocket 不支持自定义 header
const token = getAuthToken(); const token = getAuthToken();
return token ? `${base}?token=${encodeURIComponent(token)}` : base; return token ? `${base}?token=${encodeURIComponent(token)}` : base;

View File

@ -55,6 +55,99 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
const subAgentStackRef = useRef<SubAgentView[]>([]); const subAgentStackRef = useRef<SubAgentView[]>([]);
const pendingTaskNavsRef = useRef<Map<string, string>>(new Map()); const pendingTaskNavsRef = useRef<Map<string, string>>(new Map());
// ---- 流式 delta 批处理(镜像主视图 useMessages 的 ref 累加 + rAF flush 范式)----
// 每个 stream_delta 只累加到 refrAF 批量合并为一次 setState
// 避免逐 token 触发整棵子树重渲染与双层数组拷贝。
interface PendingSubAgentDelta {
/** 'top' 表示栈顶层;否则按 taskId 定位栈中层 */
target: 'top' | string;
id: string;
contentChunks: string[];
reasoningChunks: string[];
/** 首个 delta 到达时创建的消息壳(含首段 content后续 delta 追加到其后 */
shell: ChatMessage | null;
}
const pendingDeltasRef = useRef<Map<string, PendingSubAgentDelta>>(new Map());
const deltaRafRef = useRef<number | null>(null);
const deltaRafScheduledRef = useRef(false);
const flushSubAgentDeltas = useCallback(() => {
deltaRafScheduledRef.current = false;
const pending = pendingDeltasRef.current;
if (pending.size === 0) return;
const entries = Array.from(pending.values());
pending.clear();
setSubAgentStack((prev) => {
if (prev.length === 0) return prev;
let stack = prev;
for (const entry of entries) {
const layerIdx =
entry.target === 'top'
? stack.length - 1
: stack.findIndex((v) => v.taskId === entry.target);
if (layerIdx < 0) continue;
const layer = stack[layerIdx];
const deltaContent = entry.contentChunks.join('');
const deltaReasoning =
entry.reasoningChunks.length > 0 ? entry.reasoningChunks.join('') : null;
const idx = layer.messages.findIndex((m) => m.id === entry.id && m.type === 'message');
const layerCopy = { ...layer };
if (idx >= 0) {
const updated = [...layer.messages];
const existing = updated[idx];
updated[idx] = {
...existing,
content: existing.content + deltaContent,
reasoningContent: deltaReasoning
? (existing.reasoningContent || '') + deltaReasoning
: existing.reasoningContent,
};
layerCopy.messages = updated;
} else if (entry.shell) {
layerCopy.messages = [
...layer.messages,
{
...entry.shell,
content: entry.shell.content + deltaContent,
reasoningContent: deltaReasoning
? (entry.shell.reasoningContent || '') + deltaReasoning || undefined
: entry.shell.reasoningContent,
},
];
} else {
continue;
}
const next = [...stack];
next[layerIdx] = layerCopy;
stack = next;
}
return stack;
});
}, []);
const scheduleSubAgentDeltaFlush = useCallback(() => {
if (deltaRafScheduledRef.current) return;
deltaRafScheduledRef.current = true;
deltaRafRef.current = requestAnimationFrame(() => flushSubAgentDeltas());
}, [flushSubAgentDeltas]);
/** 立即落盘待处理 delta取消已排队的 rAF。非 delta 消息处理前调用以保证顺序。 */
const flushSubAgentDeltasSync = useCallback(() => {
if (deltaRafRef.current !== null) {
cancelAnimationFrame(deltaRafRef.current);
deltaRafRef.current = null;
}
flushSubAgentDeltas();
}, [flushSubAgentDeltas]);
// 卸载时取消未执行的 flush
useEffect(
() => () => {
if (deltaRafRef.current !== null) cancelAnimationFrame(deltaRafRef.current);
},
[],
);
// ref 同步:确保回调中读到最新值 // ref 同步:确保回调中读到最新值
useEffect(() => { useEffect(() => {
subAgentViewRef.current = subAgentView; subAgentViewRef.current = subAgentView;
@ -70,33 +163,24 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
if (message.type === 'assistant_response') { if (message.type === 'assistant_response') {
bumpTopicRefreshTrigger(); bumpTopicRefreshTrigger();
} }
// stream_delta: accumulate into existing message by ID, or create new // stream_delta: 累加到批处理缓冲区rAF 统一 flush不再逐 token setState
if (message.type === 'stream_delta') { if (message.type === 'stream_delta') {
const msg = message as StreamDelta; const msg = message as StreamDelta;
setSubAgentStack((prev) => { const pending = pendingDeltasRef.current;
if (prev.length === 0) return prev; const entry = pending.get(msg.id);
const top = prev[prev.length - 1]; if (entry) {
const existingIdx = top.messages.findIndex((m) => m.id === msg.id && m.type === 'message'); if (msg.delta) entry.contentChunks.push(msg.delta);
if (existingIdx >= 0) { if (msg.reasoning_delta) entry.reasoningChunks.push(msg.reasoning_delta);
const updated = [...top.messages]; } else {
const existing = updated[existingIdx]; pending.set(msg.id, {
updated[existingIdx] = { target: 'top',
...existing, id: msg.id,
content: existing.content + msg.delta, contentChunks: [],
reasoningContent: msg.reasoning_delta reasoningChunks: [],
? (existing.reasoningContent || '') + msg.reasoning_delta shell: serverMessageToChatMessage(message),
: existing.reasoningContent,
};
const newStack = [...prev];
newStack[newStack.length - 1] = { ...top, messages: updated };
return newStack;
}
const chatMsg = serverMessageToChatMessage(message);
if (!chatMsg) return prev;
const newStack = [...prev];
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] };
return newStack;
}); });
}
scheduleSubAgentDeltaFlush();
return; return;
} }
// stream_end: no-op, assistant_response will replace // stream_end: no-op, assistant_response will replace
@ -168,10 +252,31 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
return newStack; return newStack;
}); });
} }
}, [bumpTopicRefreshTrigger]); }, [bumpTopicRefreshTrigger, scheduleSubAgentDeltaFlush]);
// 追加消息到栈中非栈顶的匹配层(按 taskId 匹配) // 追加消息到栈中非栈顶的匹配层(按 taskId 匹配)
const appendToSubAgentLayerMessage = useCallback((taskId: string, message: WsOutbound) => { const appendToSubAgentLayerMessage = useCallback(
(taskId: string, message: WsOutbound) => {
// stream_delta 在 setState 之外累加到批处理缓冲区updater 内不允许副作用)
if (message.type === 'stream_delta') {
const msg = message as StreamDelta;
const pending = pendingDeltasRef.current;
const entry = pending.get(msg.id);
if (entry) {
if (msg.delta) entry.contentChunks.push(msg.delta);
if (msg.reasoning_delta) entry.reasoningChunks.push(msg.reasoning_delta);
} else {
pending.set(msg.id, {
target: taskId,
id: msg.id,
contentChunks: [],
reasoningChunks: [],
shell: serverMessageToChatMessage(message),
});
}
scheduleSubAgentDeltaFlush();
return;
}
setSubAgentStack((prev) => { setSubAgentStack((prev) => {
const idx = prev.findIndex((v) => v.taskId === taskId); const idx = prev.findIndex((v) => v.taskId === taskId);
if (idx < 0) return prev; if (idx < 0) return prev;
@ -192,32 +297,11 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
type: 'message', type: 'message',
}; };
const newStack = [...prev]; const newStack = [...prev];
newStack[idx] = { ...layer, status: 'error', messages: [...layer.messages, errorChatMsg] }; newStack[idx] = {
return newStack; ...layer,
} status: 'error',
if (message.type === 'stream_delta') { messages: [...layer.messages, errorChatMsg],
const msg = message as StreamDelta;
const existingIdx = layer.messages.findIndex(
(m) => m.id === msg.id && m.type === 'message',
);
if (existingIdx >= 0) {
const updated = [...layer.messages];
const existing = updated[existingIdx];
updated[existingIdx] = {
...existing,
content: existing.content + msg.delta,
reasoningContent: msg.reasoning_delta
? (existing.reasoningContent || '') + msg.reasoning_delta
: existing.reasoningContent,
}; };
const newStack = [...prev];
newStack[idx] = { ...layer, messages: updated };
return newStack;
}
const chatMsg = serverMessageToChatMessage(message);
if (!chatMsg) return prev;
const newStack = [...prev];
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] };
return newStack; return newStack;
} }
if (message.type === 'stream_end') return prev; if (message.type === 'stream_end') return prev;
@ -239,14 +323,18 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
message.type === 'tool_result' || message.type === 'tool_result' ||
message.type === 'tool_pending' message.type === 'tool_pending'
) { ) {
const exists = layer.messages.some((m) => m.id === chatMsg.id && m.type === chatMsg.type); const exists = layer.messages.some(
(m) => m.id === chatMsg.id && m.type === chatMsg.type,
);
if (exists) return prev; if (exists) return prev;
} }
const newStack = [...prev]; const newStack = [...prev];
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] }; newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] };
return newStack; return newStack;
}); });
}, []); },
[scheduleSubAgentDeltaFlush],
);
const enterSubAgentView = useCallback( const enterSubAgentView = useCallback(
(taskId: string, description: string, subagentType?: string): Command => { (taskId: string, description: string, subagentType?: string): Command => {
@ -267,6 +355,8 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
); );
const exitSubAgentView = useCallback((): Command | null => { const exitSubAgentView = useCallback((): Command | null => {
// 栈变更前先落盘待处理 delta避免丢失或写入清空后的新栈
flushSubAgentDeltasSync();
const current = subAgentStackRef.current; const current = subAgentStackRef.current;
if (current.length <= 1) { if (current.length <= 1) {
subAgentViewRef.current = null; subAgentViewRef.current = null;
@ -282,9 +372,12 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
subAgentStackRef.current = clearedStack; subAgentStackRef.current = clearedStack;
setSubAgentStack(clearedStack); setSubAgentStack(clearedStack);
return { type: 'load_task_messages', task_id: newTop.taskId }; return { type: 'load_task_messages', task_id: newTop.taskId };
}, []); }, [flushSubAgentDeltasSync]);
const navigateToSubAgentLevel = useCallback((index: number): Command | null => { const navigateToSubAgentLevel = useCallback(
(index: number): Command | null => {
// 栈变更前先落盘待处理 delta避免丢失或写入清空后的新栈
flushSubAgentDeltasSync();
const current = subAgentStackRef.current; const current = subAgentStackRef.current;
if (index < 0) { if (index < 0) {
subAgentViewRef.current = null; subAgentViewRef.current = null;
@ -301,7 +394,9 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
subAgentStackRef.current = clearedStack; subAgentStackRef.current = clearedStack;
setSubAgentStack(clearedStack); setSubAgentStack(clearedStack);
return { type: 'load_task_messages', task_id: newTop.taskId }; return { type: 'load_task_messages', task_id: newTop.taskId };
}, []); },
[flushSubAgentDeltasSync],
);
/** Tier 2 路由:子智能体视图激活时处理消息,返回是否已处理 */ /** Tier 2 路由:子智能体视图激活时处理消息,返回是否已处理 */
const handleSubAgentMessage = useCallback( const handleSubAgentMessage = useCallback(
@ -309,6 +404,11 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
const currentSubAgentView = subAgentViewRef.current; const currentSubAgentView = subAgentViewRef.current;
if (!currentSubAgentView) return false; if (!currentSubAgentView) return false;
// 非 delta 消息处理前先落盘待处理的 delta保证消息顺序与内容完整性
if (message.type !== 'stream_delta' && pendingDeltasRef.current.size > 0) {
flushSubAgentDeltasSync();
}
if (message.type === 'task_messages_loaded') { if (message.type === 'task_messages_loaded') {
const msg = message as TaskMessagesLoaded; const msg = message as TaskMessagesLoaded;
setSubAgentStack((prev) => { setSubAgentStack((prev) => {
@ -431,6 +531,7 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
appendToSubAgentLayerMessage, appendToSubAgentLayerMessage,
sendCommand, sendCommand,
requestSubAgentTodoList, requestSubAgentTodoList,
flushSubAgentDeltasSync,
], ],
); );