diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 1c7ac29..fb0c6cf 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -337,15 +337,14 @@ fn filter_images_by_age_and_count( .count(); let content = if original_image_count > filtered_image_count { - let notice = if exceeds_age_limit { + if exceeds_age_limit { format!( "{} [图片已过期:超出 {} 条消息范围]", message.content, max_age_rounds ) } else { format!("{} [图片已过期:超出最大图片数量限制]", message.content) - }; - notice + } } else { message.content.clone() }; @@ -614,7 +613,7 @@ impl LoopDetector { .count(); // 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!( "注意: 工具 '{}' 已连续执行 {} 次,参数相同。如果任务没有进展,请尝试其他方法。", last.name, consecutive @@ -1139,7 +1138,7 @@ impl AgentLoop { // 避免每轮 serde_json::to_string 全量序列化工具定义。 let tools_tokens = tools .as_ref() - .map(|t| estimate_tokens_from_serialized_json(t)) + .map(estimate_tokens_from_serialized_json) .unwrap_or_default(); for iteration in 0..self.max_iterations { @@ -1513,71 +1512,67 @@ impl AgentLoop { .and_then(|m| m.usage.as_ref()) .map(|u| u.prompt_tokens); - if let Some(prompt_tokens) = last_prompt_tokens { - if compressor.should_compress_by_usage(prompt_tokens) { - // 阶段 1a:工程化压缩(截断非子代理 tool 结果,仅改内存) - // 参数内聚到 ContextCompressor,AgentLoop 不持有截断 token 数 - compressor.truncate_tool_results(&mut messages); - engineering_compaction_applied = true; + if let Some(prompt_tokens) = last_prompt_tokens + && compressor.should_compress_by_usage(prompt_tokens) + { + // 阶段 1a:工程化压缩(截断非子代理 tool 结果,仅改内存) + // 参数内聚到 ContextCompressor,AgentLoop 不持有截断 token 数 + compressor.truncate_tool_results(&mut messages); + engineering_compaction_applied = true; + tracing::info!( + iteration, + prompt_tokens, + threshold = compressor.threshold(), + "Engineering compaction applied (tool results truncated)" + ); + + // 阶段 1b:重新估算,判断是否需要 LLM 压缩(30% 阈值) + let estimated = crate::agent::context_compressor::estimate_tokens(&messages); + if estimated > compressor.llm_compaction_threshold() { tracing::info!( iteration, - prompt_tokens, - threshold = compressor.threshold(), - "Engineering compaction applied (tool results truncated)" + estimated_tokens = estimated, + llm_threshold = compressor.llm_compaction_threshold(), + "LLM compaction triggered (still above 30% after engineering compaction)" ); - - // 阶段 1b:重新估算,判断是否需要 LLM 压缩(30% 阈值) - let estimated = - crate::agent::context_compressor::estimate_tokens(&messages); - if estimated > compressor.llm_compaction_threshold() { - tracing::info!( - iteration, - estimated_tokens = estimated, - llm_threshold = compressor.llm_compaction_threshold(), - "LLM compaction triggered (still above 30% after engineering compaction)" - ); - // LLM 压缩失败时降级为仅工程化压缩,不中断 agent loop - match compressor - .compress_two_segment_with_provider( - &messages, - self.provider.as_ref(), - ) - .await - { - Ok(compressed) => { - // sink 失败时记日志但不中断——内存已压缩,DB 未更新 - // 下次 process 从 DB 加载时会重新触发压缩 - if let Some(sink) = compaction_sink { - if let Err(e) = sink.compact(&compressed).await { - tracing::error!( - error = %e, - iteration, - "CompactionSink compact failed; \ - in-memory messages still replaced, DB will be re-compacted next round" - ); - } - } - messages = compressed; - compaction_performed = true; - } - Err(e) => { - tracing::warn!( + // LLM 压缩失败时降级为仅工程化压缩,不中断 agent loop + match compressor + .compress_two_segment_with_provider(&messages, self.provider.as_ref()) + .await + { + Ok(compressed) => { + // sink 失败时记日志但不中断——内存已压缩,DB 未更新 + // 下次 process 从 DB 加载时会重新触发压缩 + if let Some(sink) = compaction_sink + && let Err(e) = sink.compact(&compressed).await + { + tracing::error!( error = %e, iteration, - "LLM compaction failed; \ - falling back to engineering-only compaction (in-memory truncated messages retained)" + "CompactionSink compact failed; \ + in-memory messages still replaced, DB will be re-compacted next round" ); - // 不设置 compaction_performed,messages 保持工程化压缩后的状态 } + messages = compressed; + compaction_performed = true; + } + Err(e) => { + tracing::warn!( + error = %e, + iteration, + "LLM compaction failed; \ + falling back to engineering-only compaction (in-memory truncated messages retained)" + ); + // 不设置 compaction_performed,messages 保持工程化压缩后的状态 } - } else { - tracing::info!( - iteration, - estimated_tokens = estimated, - llm_threshold = compressor.llm_compaction_threshold(), - "Engineering compaction sufficient (under 30%), skipping LLM compaction" - ); } + } else { + tracing::info!( + iteration, + estimated_tokens = estimated, + llm_threshold = compressor.llm_compaction_threshold(), + "Engineering compaction sufficient (under 30%), skipping LLM compaction" + ); } } } @@ -2319,14 +2314,14 @@ mod tests { fn test_should_execute_in_parallel_single_tool() { // Would need a proper setup with AgentLoop to test fully // For now, just verify the logic: single tool should return false - let calls = vec![ToolCall { + let calls = [ToolCall { id: "1".to_string(), name: "test".to_string(), arguments: serde_json::json!({}), }]; // If there's only 1 tool, should return false regardless - assert_eq!(calls.len() <= 1, true); + assert!(calls.len() <= 1); } #[test] @@ -2619,9 +2614,15 @@ mod tests { let filtered = filter_images_by_age_and_count(&messages, 10, 3); // 检查结果 - assert!(filtered[19].media_refs.len() > 0, "最新消息应保留图片"); - assert!(filtered[15].media_refs.len() > 0, "age=4 的消息应保留图片"); - assert!(filtered[10].media_refs.len() > 0, "age=9 的消息应保留图片"); + assert!(!filtered[19].media_refs.is_empty(), "最新消息应保留图片"); + assert!( + !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!(filtered[5].content.contains("超出 10 条消息范围")); assert_eq!(filtered[0].media_refs.len(), 0, "age=19 的消息图片应被过滤"); @@ -3117,7 +3118,7 @@ mod tests { assert!( messages .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" ); } diff --git a/src/agent/context_compressor.rs b/src/agent/context_compressor.rs index b8160e0..f1e8d6f 100644 --- a/src/agent/context_compressor.rs +++ b/src/agent/context_compressor.rs @@ -54,7 +54,7 @@ fn is_assistant_with_tool_calls(msg: &ChatMessage) -> bool { && msg .tool_calls .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 @@ -713,10 +713,8 @@ OLDER SEGMENT (events from earlier in the session): let middle_units = &compressible[preserve_count..split]; // Step 4: Build middle segment messages and transcript - let middle_messages: Vec = middle_units - .iter() - .flat_map(unit_to_messages) - .collect(); + let middle_messages: Vec = + middle_units.iter().flat_map(unit_to_messages).collect(); let middle_transcript = Self::build_transcript(&middle_messages); // Step 5: Summarize middle segment with LLM (heavy prompt) @@ -1116,8 +1114,8 @@ mod tests { fn test_chinese_tokens_higher_than_english() { // Use more characters to make the content difference significant // compared to JSON overhead (50 tokens per message) - 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 english = vec![ChatMessage::user("abcdefghij".repeat(20))]; // 200 English chars + let chinese = vec![ChatMessage::user("这是一个测试消息字".repeat(20))]; // 200 CJK chars (10 chars * 20) let english_tokens = estimate_tokens(&english); let chinese_tokens = estimate_tokens(&chinese); @@ -1153,7 +1151,7 @@ mod tests { let compressor = ContextCompressor::new(20); // Need more content to trigger compression with new weighted calculation // 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)); } @@ -1257,7 +1255,7 @@ mod tests { #[test] 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); diff --git a/src/bus/message.rs b/src/bus/message.rs index 66a3e91..6443417 100644 --- a/src/bus/message.rs +++ b/src/bus/message.rs @@ -321,17 +321,17 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec String { match value { serde_json::Value::Object(map) => { let mut entries: Vec<_> = map.iter().collect(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + entries.sort_by_key(|(left, _)| *left); let body = entries .into_iter() .map(|(key, value)| { diff --git a/src/channels/feishu.rs b/src/channels/feishu.rs index efb2bc4..3e78319 100644 --- a/src/channels/feishu.rs +++ b/src/channels/feishu.rs @@ -234,10 +234,10 @@ impl FeishuChannel { // 1. Check cache { let cached = self.tenant_token.read().await; - if let Some(ref token) = *cached { - if Instant::now() < token.refresh_after { - return Ok(token.value.clone()); - } + if let Some(ref token) = *cached + && Instant::now() < token.refresh_after + { + return Ok(token.value.clone()); } } @@ -1076,10 +1076,10 @@ impl FeishuChannel { .await?; // Fetch and prepend quoted message content if this is a reply - if let Some(ref pid) = parent_id { - if let Some(reply_ctx) = self.get_message_content(pid).await { - content = format!("{}\n{}", reply_ctx, content); - } + if let Some(ref pid) = parent_id + && let Some(reply_ctx) = self.get_message_content(pid).await + { + content = format!("{}\n{}", reply_ctx, content); } #[cfg(debug_assertions)] @@ -1532,15 +1532,15 @@ fn parse_post_content(content: &str) -> String { // Fall back: try any dict child if let Some(root_obj) = root.as_object() { for (_key, val) in root_obj { - if let Some(obj) = val.as_object() { - if obj.get("content").and_then(|c| c.as_array()).is_some() { - parse_block(val, &mut texts); - let result = texts.join(""); - if !result.trim().is_empty() { - return result.trim().to_string(); - } - texts.clear(); + if let Some(obj) = val.as_object() + && obj.get("content").and_then(|c| c.as_array()).is_some() + { + parse_block(val, &mut texts); + let result = texts.join(""); + if !result.trim().is_empty() { + return result.trim().to_string(); } + texts.clear(); } } } @@ -1565,21 +1565,20 @@ fn extract_interactive_content(content: &str) -> Result<(String, Option, dept None } }) - }) { - if let Some(children) = children_arr - .as_object() - .and_then(|o| o.get("children")) - .and_then(|c| c.as_array()) - { - collect_list_items(children, lines, depth + 1); - } + }) && let Some(children) = children_arr + .as_object() + .and_then(|o| o.get("children")) + .and_then(|c| c.as_array()) + { + collect_list_items(children, lines, depth + 1); } } } @@ -2269,138 +2266,6 @@ fn sanitize_download_file_name(file_name: &str) -> 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] impl Channel for FeishuChannel { fn name(&self) -> &str { @@ -2502,7 +2367,7 @@ impl Channel for FeishuChannel { let receive_id = if msg.chat_id.starts_with("oc_") { &msg.chat_id } 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_") { "chat_id" @@ -2671,3 +2536,135 @@ impl Channel for FeishuChannel { 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")); + } +} diff --git a/src/channels/manager.rs b/src/channels/manager.rs index 5da4b35..b5c44a5 100644 --- a/src/channels/manager.rs +++ b/src/channels/manager.rs @@ -18,6 +18,12 @@ pub struct ChannelManager { websocket_channel: Arc, } +impl Default for ChannelManager { + fn default() -> Self { + Self::new() + } +} + impl ChannelManager { pub fn new() -> Self { let websocket_channel = Arc::new(CliChannel::new()); diff --git a/src/channels/wechat.rs b/src/channels/wechat.rs index c3e3c31..fb52ffb 100644 --- a/src/channels/wechat.rs +++ b/src/channels/wechat.rs @@ -69,9 +69,7 @@ impl WechatChannel { let path = media.path.clone(); let data = tokio::task::spawn_blocking(move || std::fs::read(&path)) .await - .map_err(|e| { - ChannelError::SendError(format!("WeChat media read task failed: {}", e)) - })? + .map_err(|e| ChannelError::SendError(format!("WeChat media read task failed: {}", e)))? .map_err(|error| { ChannelError::SendError(format!( "WeChat media read failed for '{}': {}", @@ -419,7 +417,9 @@ mod tests { std::fs::rename(file.path(), &image_path).unwrap(); let media = MediaItem::new(image_path.to_string_lossy().to_string(), "image"); - let content = WechatChannel::media_to_send_content(&media, None).await.unwrap(); + let content = WechatChannel::media_to_send_content(&media, None) + .await + .unwrap(); assert!(matches!(content, SendContent::Image { .. })); } @@ -432,8 +432,9 @@ mod tests { std::fs::rename(file.path(), &doc_path).unwrap(); let media = MediaItem::new(doc_path.to_string_lossy().to_string(), "file"); - let content = - WechatChannel::media_to_send_content(&media, Some("note".to_string())).await.unwrap(); + let content = WechatChannel::media_to_send_content(&media, Some("note".to_string())) + .await + .unwrap(); match content { SendContent::File { diff --git a/src/cli/init.rs b/src/cli/init.rs index 933b61a..7d277ec 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -209,11 +209,11 @@ impl InitWizard { "2" => return self.modify_provider(existing).await, "3" => { println!("Keeping existing providers."); - return Ok(existing.providers.clone()); + Ok(existing.providers.clone()) } "4" => { println!("Skipping provider configuration."); - return Ok(existing.providers.clone()); + Ok(existing.providers.clone()) } _ => { println!("Invalid option, adding new provider."); @@ -378,16 +378,16 @@ impl InitWizard { match choice.as_str() { "1" => { println!("Keeping existing models."); - return Ok(existing.models.clone()); + Ok(existing.models.clone()) } "2" => return self.add_model(existing).await, "3" => { println!("Skipping model configuration."); - return Ok(existing.models.clone()); + Ok(existing.models.clone()) } _ => { println!("Invalid option, keeping existing models."); - return Ok(existing.models.clone()); + Ok(existing.models.clone()) } } } else { @@ -505,11 +505,11 @@ impl InitWizard { "2" => return self.modify_agent(existing, providers, models).await, "3" => { println!("Keeping existing agents."); - return Ok(existing.agents.clone()); + Ok(existing.agents.clone()) } "4" => { println!("Skipping agent configuration."); - return Ok(existing.agents.clone()); + Ok(existing.agents.clone()) } _ => { println!("Invalid option, adding new agent."); diff --git a/src/client/mod.rs b/src/client/mod.rs index dd39afe..d3eeb4d 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -42,12 +42,11 @@ pub async fn run(gateway_url: &str) -> Result<(), Box> { let text = text.to_string(); if let Ok(outbound) = parse_message(&text) { match outbound { - WsOutbound::AssistantResponse { id, content, .. } => { + WsOutbound::AssistantResponse { id, content, .. } // Skip if already fully streamed via StreamDelta - if !streamed_message_ids.remove(&id) { + if !streamed_message_ids.remove(&id) => { input.write_response(&content).await?; } - } WsOutbound::ToolCall { tool_name, arguments, .. } => { input.write_output(&format!("Tool call: {}\n{}\n", tool_name, format_json(&arguments))).await?; } @@ -235,12 +234,11 @@ pub async fn run(gateway_url: &str) -> Result<(), Box> { chat_id: current_session_id.clone(), sender_id: None, }; - if let Ok(text) = serialize_inbound(&inbound) { - if sender.send(Message::Text(text.into())).await.is_err() { + if let Ok(text) = serialize_inbound(&inbound) + && sender.send(Message::Text(text.into())).await.is_err() { tracing::error!("Failed to send message to gateway"); break; } - } } } } diff --git a/src/command/handlers/get_current.rs b/src/command/handlers/get_current.rs index a9ae551..5893e46 100644 --- a/src/command/handlers/get_current.rs +++ b/src/command/handlers/get_current.rs @@ -138,10 +138,10 @@ async fn handle_get_current_session( .with_message(MessageKind::Notification, &message) .with_metadata("topic_id", &topic.id) .with_metadata("title", &topic.title) - .with_metadata("message_count", &actual_message_count.to_string()) - .with_metadata("estimated_tokens", &total_tokens.to_string()) - .with_metadata("system_prompt_tokens", &system_prompt_tokens.to_string()) - .with_metadata("message_tokens", &message_tokens.to_string())) + .with_metadata("message_count", actual_message_count.to_string()) + .with_metadata("estimated_tokens", total_tokens.to_string()) + .with_metadata("system_prompt_tokens", system_prompt_tokens.to_string()) + .with_metadata("message_tokens", message_tokens.to_string())) } fn format_time_ago(timestamp_ms: i64) -> String { diff --git a/src/command/handlers/list_channels.rs b/src/command/handlers/list_channels.rs index 621d37b..1f0b00c 100644 --- a/src/command/handlers/list_channels.rs +++ b/src/command/handlers/list_channels.rs @@ -57,5 +57,5 @@ async fn handle_list_channels( Ok(CommandResponse::success(ctx.request_id) .with_message(MessageKind::Notification, &message) .with_metadata("channels", &channels_json) - .with_metadata("count", &channels.len().to_string())) + .with_metadata("count", channels.len().to_string())) } diff --git a/src/command/handlers/list_sessions.rs b/src/command/handlers/list_sessions.rs index 9514847..cc112d3 100644 --- a/src/command/handlers/list_sessions.rs +++ b/src/command/handlers/list_sessions.rs @@ -85,10 +85,10 @@ async fn handle_list_sessions( )); // 显示描述(如果有) - if let Some(ref desc) = topic.description { - if !desc.is_empty() { - lines.push(format!(" {}", desc)); - } + if let Some(ref desc) = topic.description + && !desc.is_empty() + { + lines.push(format!(" {}", desc)); } } @@ -105,6 +105,6 @@ async fn handle_list_sessions( Ok(CommandResponse::success(ctx.request_id) .with_message(MessageKind::Notification, &message) .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)) } diff --git a/src/command/handlers/list_sessions_by_channel.rs b/src/command/handlers/list_sessions_by_channel.rs index b3dd703..0983b1a 100644 --- a/src/command/handlers/list_sessions_by_channel.rs +++ b/src/command/handlers/list_sessions_by_channel.rs @@ -84,5 +84,5 @@ async fn handle_list_sessions_by_channel( .with_message(MessageKind::Notification, &message) .with_metadata("sessions", &sessions_json) .with_metadata("channel_name", &channel_name) - .with_metadata("count", &summaries.len().to_string())) + .with_metadata("count", summaries.len().to_string())) } diff --git a/src/command/handlers/list_topics.rs b/src/command/handlers/list_topics.rs index 078220e..848c3b8 100644 --- a/src/command/handlers/list_topics.rs +++ b/src/command/handlers/list_topics.rs @@ -159,5 +159,5 @@ async fn handle_list_topics( .with_message(MessageKind::Notification, &message) .with_metadata("topics", &topics_json) .with_metadata("session_id", &session_id) - .with_metadata("count", &summaries.len().to_string())) + .with_metadata("count", summaries.len().to_string())) } diff --git a/src/command/handlers/load_task_messages.rs b/src/command/handlers/load_task_messages.rs index 33589ec..f14e37e 100644 --- a/src/command/handlers/load_task_messages.rs +++ b/src/command/handlers/load_task_messages.rs @@ -197,12 +197,12 @@ fn reconstruct_task_from_db( /// New format: "Subagent [type]: description" /// Legacy format: "Subagent: description" (defaults to "general") fn parse_subagent_title(title: &str) -> (String, String) { - if let Some(rest) = title.strip_prefix("Subagent [") { - if let Some(bracket_pos) = rest.find("]: ") { - let agent_type = rest[..bracket_pos].to_string(); - let desc = rest[bracket_pos + 3..].to_string(); - return (agent_type, desc); - } + if let Some(rest) = title.strip_prefix("Subagent [") + && let Some(bracket_pos) = rest.find("]: ") + { + let agent_type = rest[..bracket_pos].to_string(); + let desc = rest[bracket_pos + 3..].to_string(); + return (agent_type, desc); } let desc = title .strip_prefix("Subagent: ") diff --git a/src/command/handlers/load_topic.rs b/src/command/handlers/load_topic.rs index d0dc62d..5df65e0 100644 --- a/src/command/handlers/load_topic.rs +++ b/src/command/handlers/load_topic.rs @@ -60,5 +60,5 @@ async fn handle_load_topic( .with_message(MessageKind::Notification, &topic.title) .with_metadata("topic_id", &topic.id) .with_metadata("title", &topic.title) - .with_metadata("message_count", &topic.message_count.to_string())) + .with_metadata("message_count", topic.message_count.to_string())) } diff --git a/src/command/handlers/rename_topic.rs b/src/command/handlers/rename_topic.rs index 6d83d4f..3a999a4 100644 --- a/src/command/handlers/rename_topic.rs +++ b/src/command/handlers/rename_topic.rs @@ -95,7 +95,7 @@ async fn handle_rename_topic( return Ok(CommandResponse::success(ctx.request_id) .with_message( MessageKind::Notification, - &format!("✓ 话题标题未变化: {}", trimmed_title), + format!("✓ 话题标题未变化: {}", trimmed_title), ) .with_metadata("topics", &topic_summaries_json) .with_metadata("topic_id", &topic_id) diff --git a/src/command/handlers/save_session.rs b/src/command/handlers/save_session.rs index 915696a..365cb89 100644 --- a/src/command/handlers/save_session.rs +++ b/src/command/handlers/save_session.rs @@ -72,11 +72,12 @@ pub async fn save_session_to_file( let output_path = resolve_filepath(filepath, &record); // 创建父目录 - if let Some(parent) = output_path.parent() { - if !parent.as_os_str().is_empty() && !parent.exists() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create directory: {}", e))?; - } + if let Some(parent) = output_path.parent() + && !parent.as_os_str().is_empty() + && !parent.exists() + { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create directory: {}", e))?; } // 写入文件 @@ -192,7 +193,7 @@ async fn handle_save_session( filepath, include_all, include_subagents, - &*handler.store, + &handler.store, Some(handler.task_repository.as_ref()), &*handler.system_prompt_provider, ) @@ -213,16 +214,16 @@ async fn handle_save_session( MessageKind::Notification, // 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉, // 统一转换为正斜杠以保证显示完整(跨平台兼容) - &format!( + format!( "Session saved to: {}", output_path.display().to_string().replace('\\', "/") ), ) .with_metadata( "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,21 +392,21 @@ pub fn generate_subagent_tasks_markdown(subagent_data: &[SubagentTaskData]) -> S } // 工具调用 - if let Some(ref calls) = msg.tool_calls { - if !calls.is_empty() { - output.push_str("**Tool Calls:**\n\n"); - for call in calls { - output.push_str(&format!("- **{}** (`{}`)\n", call.name, call.id)); - output.push_str(" ```json\n"); - let args_json = serde_json::to_string_pretty(&call.arguments) - .unwrap_or_else(|_| call.arguments.to_string()); - for line in args_json.lines() { - output.push_str(&format!(" {}\n", line)); - } - output.push_str(" ```\n"); + if let Some(ref calls) = msg.tool_calls + && !calls.is_empty() + { + output.push_str("**Tool Calls:**\n\n"); + for call in calls { + output.push_str(&format!("- **{}** (`{}`)\n", call.name, call.id)); + output.push_str(" ```json\n"); + let args_json = serde_json::to_string_pretty(&call.arguments) + .unwrap_or_else(|_| call.arguments.to_string()); + for line in args_json.lines() { + output.push_str(&format!(" {}\n", line)); } - output.push('\n'); + output.push_str(" ```\n"); } + output.push('\n'); } output.push_str("---\n\n"); @@ -560,21 +561,21 @@ pub fn generate_messages_markdown(messages: &[crate::bus::ChatMessage]) -> Strin } // Tool calls - if let Some(ref calls) = msg.tool_calls { - if !calls.is_empty() { - output.push_str("### Tool Calls\n\n"); - for call in calls { - output.push_str(&format!("- **{}** (`{}`)\n", call.name, call.id)); - output.push_str(" ```json\n"); - let args_json = serde_json::to_string_pretty(&call.arguments) - .unwrap_or_else(|_| call.arguments.to_string()); - for line in args_json.lines() { - output.push_str(&format!(" {}\n", line)); - } - output.push_str(" ```\n"); + if let Some(ref calls) = msg.tool_calls + && !calls.is_empty() + { + output.push_str("### Tool Calls\n\n"); + for call in calls { + output.push_str(&format!("- **{}** (`{}`)\n", call.name, call.id)); + output.push_str(" ```json\n"); + let args_json = serde_json::to_string_pretty(&call.arguments) + .unwrap_or_else(|_| call.arguments.to_string()); + for line in args_json.lines() { + output.push_str(&format!(" {}\n", line)); } - output.push('\n'); + output.push_str(" ```\n"); } + output.push('\n'); } // Media refs @@ -621,16 +622,7 @@ pub fn resolve_filepath(filepath: Option, record: &SessionRecord) -> Pat // 生成安全标题(替换特殊字符) let safe_title = record .title - .replace(' ', "_") - .replace('/', "_") - .replace('\\', "_") - .replace(':', "_") - .replace('<', "_") - .replace('>', "_") - .replace('|', "_") - .replace('?', "_") - .replace('*', "_") - .replace('"', "_"); + .replace([' ', '/', '\\', ':', '<', '>', '|', '?', '*', '"'], "_"); // 使用标题或 session_id 作为文件名 let base_name = if safe_title.is_empty() { @@ -716,7 +708,7 @@ impl InChatCommandHandler for SaveSessionInChatHandler { filepath, include_all, include_subagents, - &*self.store, + &self.store, Some(self.task_repository.as_ref()), &*self.system_prompt_provider, ) diff --git a/src/command/handlers/save_topic.rs b/src/command/handlers/save_topic.rs index 6e0636d..491bc77 100644 --- a/src/command/handlers/save_topic.rs +++ b/src/command/handlers/save_topic.rs @@ -54,11 +54,12 @@ pub async fn save_topic_to_file( let output_path = resolve_topic_filepath(filepath, &topic); // 创建父目录 - if let Some(parent) = output_path.parent() { - if !parent.as_os_str().is_empty() && !parent.exists() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create directory: {}", e))?; - } + if let Some(parent) = output_path.parent() + && !parent.as_os_str().is_empty() + && !parent.exists() + { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create directory: {}", e))?; } // 写入文件 @@ -138,16 +139,7 @@ fn resolve_topic_filepath(filepath: Option, topic: &TopicRecord) -> Path None => { let safe_title = topic .title - .replace(' ', "_") - .replace('/', "_") - .replace('\\', "_") - .replace(':', "_") - .replace('<', "_") - .replace('>', "_") - .replace('|', "_") - .replace('?', "_") - .replace('*', "_") - .replace('"', "_"); + .replace([' ', '/', '\\', ':', '<', '>', '|', '?', '*', '"'], "_"); let base_name = if safe_title.is_empty() { format!("topic_{}", &topic.id[..8.min(topic.id.len())]) @@ -267,7 +259,7 @@ async fn handle_save_topic( topic_id, filepath, include_subagents, - &*handler.store, + &handler.store, Some(handler.task_repository.as_ref()), &*handler.system_prompt_provider, &messages, @@ -282,14 +274,14 @@ async fn handle_save_topic( MessageKind::Notification, // 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉, // 统一转换为正斜杠以保证显示完整(跨平台兼容) - &format!( + format!( "Topic saved to: {}", output_path.display().to_string().replace('\\', "/") ), ) .with_metadata( "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())) } diff --git a/src/command/handlers/session.rs b/src/command/handlers/session.rs index 8368c93..bdcc51b 100644 --- a/src/command/handlers/session.rs +++ b/src/command/handlers/session.rs @@ -94,13 +94,13 @@ async fn handle_create_session( .ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?; // 如果有 SessionManager,自动切换到新话题 - if let Some(ref session_manager) = handler.session_manager { - if let Some(session) = session_manager.get(&ctx.channel_name).await { - let mut session_guard = session.lock().await; - session_guard - .switch_topic(chat_id, &topic.id) - .map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?; - } + if let Some(ref session_manager) = handler.session_manager + && let Some(session) = session_manager.get(&ctx.channel_name).await + { + let mut session_guard = session.lock().await; + session_guard + .switch_topic(chat_id, &topic.id) + .map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?; } // Query the full topic list so the frontend sidebar can update @@ -119,7 +119,7 @@ async fn handle_create_session( .with_metadata("topics", &topics_json) .with_metadata("topic_id", &topic.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)] diff --git a/src/command/handlers/stop_execution.rs b/src/command/handlers/stop_execution.rs index b79451d..9f4604a 100644 --- a/src/command/handlers/stop_execution.rs +++ b/src/command/handlers/stop_execution.rs @@ -108,10 +108,7 @@ impl CommandHandler for StopExecutionCommandHandler { if cancelled || cancelled_subagents > 0 { let msg = if cancelled && cancelled_subagents > 0 { - format!( - "正在停止当前任务及 {} 个后台子代理...", - cancelled_subagents - ) + format!("正在停止当前任务及 {} 个后台子代理...", cancelled_subagents) } else if cancelled { "正在停止当前任务...".to_string() } else { diff --git a/src/command/handlers/switch_topic.rs b/src/command/handlers/switch_topic.rs index 959b892..37c6603 100644 --- a/src/command/handlers/switch_topic.rs +++ b/src/command/handlers/switch_topic.rs @@ -103,13 +103,13 @@ async fn handle_switch_topic( })?; // 如果有 SessionManager,实际切换话题历史 - if let Some(ref session_manager) = handler.session_manager { - if let Some(session) = session_manager.get(&ctx.channel_name).await { - let mut session_guard = session.lock().await; - session_guard - .switch_topic(chat_id, &target_topic_id) - .map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?; - } + if let Some(ref session_manager) = handler.session_manager + && let Some(session) = session_manager.get(&ctx.channel_name).await + { + let mut session_guard = session.lock().await; + session_guard + .switch_topic(chat_id, &target_topic_id) + .map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?; } // 使用辅助方法获取消息数量 @@ -127,5 +127,5 @@ async fn handle_switch_topic( .with_message(MessageKind::Notification, &message) .with_metadata("topic_id", &topic.id) .with_metadata("title", &topic.title) - .with_metadata("message_count", &msg_count.to_string())) + .with_metadata("message_count", msg_count.to_string())) } diff --git a/src/config/mod.rs b/src/config/mod.rs index 9a2a834..3a382a3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -128,7 +128,7 @@ impl Default for CompactionConfig { } /// 可观测性配置(日志格式、metrics 开关等) -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct ObservabilityConfig { /// 日志输出格式:text(默认)或 json。 /// json 格式便于接入 ELK/Loki 等日志聚合系统。 @@ -136,14 +136,6 @@ pub struct ObservabilityConfig { pub log_format: LogFormat, } -impl Default for ObservabilityConfig { - fn default() -> Self { - Self { - log_format: LogFormat::default(), - } - } -} - /// 日志输出格式 #[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)] #[serde(rename_all = "lowercase")] @@ -1984,7 +1976,7 @@ mod tests { timezone: "Asia/Shanghai".to_string(), }); assert_eq!(effective_jobs.len(), 3); // 2个内置 + 1个自定义 - // 第一个作业:内存维护(被覆盖为禁用) + // 第一个作业:内存维护(被覆盖为禁用) assert_eq!(effective_jobs[0].id, BUILTIN_MEMORY_MAINTENANCE_JOB_ID); assert!(!effective_jobs[0].enabled); assert_eq!( @@ -2305,25 +2297,33 @@ mod tests { #[test] fn test_scheduler_schedule_validation_rejects_invalid_values() { - assert!(SchedulerSchedule::Delay { seconds: 0 } - .validate("delay.job") - .is_err()); - assert!(SchedulerSchedule::Interval { - seconds: 0, - startup_delay_secs: 0, - } - .validate("interval.job") - .is_err()); - assert!(SchedulerSchedule::At { - timestamp: "bad timestamp".to_string(), - } - .validate("at.job") - .is_err()); - assert!(SchedulerSchedule::Cron { - expression: "bad cron".to_string(), - } - .validate("cron.job") - .is_err()); + assert!( + SchedulerSchedule::Delay { seconds: 0 } + .validate("delay.job") + .is_err() + ); + assert!( + SchedulerSchedule::Interval { + seconds: 0, + startup_delay_secs: 0, + } + .validate("interval.job") + .is_err() + ); + assert!( + SchedulerSchedule::At { + timestamp: "bad timestamp".to_string(), + } + .validate("at.job") + .is_err() + ); + assert!( + SchedulerSchedule::Cron { + expression: "bad cron".to_string(), + } + .validate("cron.job") + .is_err() + ); } #[test] diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 2af9e6b..d23be1e 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -63,13 +63,13 @@ impl CapabilityPolicy { /// 校验指定子代理是否被允许。返回 Err 时附带拒绝原因。 pub fn check_subagent_allowed(&self, name: &str) -> Result<(), String> { - if let Some(list) = &self.allowed_subagents { - if !list.iter().any(|s| s == name) { - return Err(format!( - "subagent '{}' is not in the allowed_subagents whitelist", - name - )); - } + if let Some(list) = &self.allowed_subagents + && !list.iter().any(|s| s == name) + { + return Err(format!( + "subagent '{}' is not in the allowed_subagents whitelist", + name + )); } if self.denied_subagents.iter().any(|s| s == name) { return Err(format!( diff --git a/src/experts/mod.rs b/src/experts/mod.rs index 3b07ed8..d69ad7d 100644 --- a/src/experts/mod.rs +++ b/src/experts/mod.rs @@ -1,12 +1,12 @@ use crate::config::ExpertsConfig; use crate::domain::CapabilityPolicy; use crate::platform::{atomic_rename, home_dir as platform_home_dir}; +use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; -use parking_lot::RwLock; #[cfg(test)] static EXPERT_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); @@ -294,18 +294,13 @@ impl ExpertRuntime { /// Re-discover experts from the filesystem. pub fn reload(&self) -> Result { - let config = self - .config - .read() - .clone(); + let config = self.config.read().clone(); let catalog = ExpertCatalog::discover_with_state( &config, &self.cwd, Some(&load_expert_disable_state(&self.cwd)), ); - let mut guard = self - .catalog - .write(); + let mut guard = self.catalog.write(); *guard = catalog.clone(); Ok(catalog) } @@ -323,18 +318,12 @@ impl ExpertRuntime { /// List enabled experts (disabled ones are filtered out). pub fn list_experts(&self) -> Vec { - self.catalog - .read() - .experts - .clone() + self.catalog.read().experts.clone() } /// List all discovered experts including disabled ones, with their disabled scopes. pub fn list_experts_with_status(&self) -> Vec { - let config = self - .config - .read() - .clone(); + let config = self.config.read().clone(); let catalog = ExpertCatalog::discover_without_state(&config, &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 { - self.catalog - .read() - .find_expert(name) - .cloned() + self.catalog.read().find_expert(name).cloned() } pub fn create_expert( @@ -474,10 +460,7 @@ impl ExpertRuntime { pub fn has_expert_definition(&self, name: &str) -> Result { validate_expert_name(name)?; - let config = self - .config - .read() - .clone(); + let config = self.config.read().clone(); let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd); Ok(catalog.find_expert(name).is_some()) } @@ -509,9 +492,7 @@ impl ExpertRuntime { // update in-memory disable_state { - let mut state = self - .disable_state - .write(); + let mut state = self.disable_state.write(); match scope { ExpertScope::User => { if enabled { @@ -533,9 +514,7 @@ impl ExpertRuntime { // refresh catalog so list_experts / get_expert reflect the change let _ = self.reload()?; - let state = self - .disable_state - .read(); + let state = self.disable_state.read(); let disabled_in_scopes = state.disabled_scopes_for(name); Ok(ExpertAvailabilityChange { @@ -558,9 +537,7 @@ impl ExpertRuntime { } { - let mut sessions = self - .session_experts - .write(); + let mut sessions = self.session_experts.write(); sessions.insert(session_id.to_string(), expert_name.to_string()); } persist_session_experts(&self.cwd, |state| { @@ -573,9 +550,7 @@ impl ExpertRuntime { /// Clear the selected expert for a session. pub fn clear_expert(&self, session_id: &str) -> Result<(), String> { { - let mut sessions = self - .session_experts - .write(); + let mut sessions = self.session_experts.write(); sessions.remove(session_id); } 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. pub fn selected_expert_for(&self, session_id: &str) -> Option { let name = { - let sessions = self - .session_experts - .read(); + let sessions = self.session_experts.read(); sessions.get(session_id).cloned() }?; // Filter out disabled experts. - let state = self - .disable_state - .read(); + let state = self.disable_state.read(); if state.is_disabled(&name) { return None; } diff --git a/src/gateway/agent_factory.rs b/src/gateway/agent_factory.rs index b363eda..987b966 100644 --- a/src/gateway/agent_factory.rs +++ b/src/gateway/agent_factory.rs @@ -3,7 +3,9 @@ use std::sync::Arc; use tokio::sync::mpsc; 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::domain::CapabilityPolicy; use crate::experts::ExpertPromptProvider; @@ -14,10 +16,10 @@ use crate::gateway::tool_prompt_provider::ToolPromptProvider; use crate::observability::Observer; use crate::skills::{SkillPromptProvider, SkillRuntime}; use crate::storage::PromptInjectionRepository; -use crate::storage::persistent_session_id; use crate::storage::SessionStore; -use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime}; +use crate::storage::persistent_session_id; use crate::tools::task::SubagentResult; +use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime}; use crate::tools::{ToolContext, ToolRegistry, WaitCoordinator}; /// 构建与 Agent 实际使用的完全一致的组合系统提示词 Provider。 @@ -133,7 +135,10 @@ impl AgentFactory { /// 构造 ContextCompressor(参数内聚到 ContextCompressor,CompactionConfig 注入)。 /// AgentLoop(in-loop 压缩)和 Session(sync 兜底压缩)共用此方法, /// 确保两条压缩路径使用同一套用户配置的压缩参数。 - pub(crate) fn build_compressor(&self, runtime_config: &AgentRuntimeConfig) -> ContextCompressor { + pub(crate) fn build_compressor( + &self, + runtime_config: &AgentRuntimeConfig, + ) -> ContextCompressor { ContextCompressor::with_compaction_config( runtime_config.context_window_tokens, runtime_config.context_summary_char_budget, @@ -201,21 +206,23 @@ impl AgentFactory { // 物化:命中 session 级选择且话题无固化值时,将解析后的具体 // (provider, model) 写入 topics 行(持久化 + 内存缓存) - if !from_topic { - if let Some(tid) = request.topic_id.as_deref() { - let provider = resolved.name.clone(); - let model = resolved.model_id.clone(); - self.topic_model_selections - .set(tid, Some(provider.clone()), Some(model.clone())); - if let Err(err) = - self.store.update_topic_model(tid, Some(&provider), Some(&model)) - { - tracing::warn!( - error = %err, - topic_id = %tid, - "AgentFactory: failed to materialize topic model selection" - ); - } + if !from_topic && let Some(tid) = request.topic_id.as_deref() { + let provider = resolved.name.clone(); + let model = resolved.model_id.clone(); + self.topic_model_selections.set( + tid, + Some(provider.clone()), + Some(model.clone()), + ); + if let Err(err) = + self.store + .update_topic_model(tid, Some(&provider), Some(&model)) + { + tracing::warn!( + error = %err, + topic_id = %tid, + "AgentFactory: failed to materialize topic model selection" + ); } } @@ -289,7 +296,7 @@ impl AgentFactory { // 供 wait_for_subagents 工具传递给 coordinator.wait() 的 select!。 // watch::Receiver::clone() 创建共享同一 sender 的新 receiver, // 各 receiver 的 has_changed()/changed() 状态独立,互不影响。 - let cancel_rx_for_context = request.cancel_token.as_ref().map(|rx| rx.clone()); + let cancel_rx_for_context = request.cancel_token.clone(); let runtime_config = AgentRuntimeConfig::from(effective_provider_config.clone()); let compressor = Arc::new(self.build_compressor(&runtime_config)); diff --git a/src/gateway/agent_prompt_provider.rs b/src/gateway/agent_prompt_provider.rs index 7e60811..2332ef4 100644 --- a/src/gateway/agent_prompt_provider.rs +++ b/src/gateway/agent_prompt_provider.rs @@ -42,14 +42,14 @@ impl AgentPromptProvider { /// 记录注入事件 fn record_injection(&self, context: &SystemPromptContext) { - if let Some(session_id) = &context.session_id { - if let Err(e) = self.repository.mark_agent_prompt_reinjected(session_id) { - tracing::warn!( - session_id = ?session_id, - error = %e, - "Failed to mark agent prompt reinjected; injection counter may be inaccurate" - ); - } + if let Some(session_id) = &context.session_id + && let Err(e) = self.repository.mark_agent_prompt_reinjected(session_id) + { + tracing::warn!( + session_id = ?session_id, + error = %e, + "Failed to mark agent prompt reinjected; injection counter may be inaccurate" + ); } } } diff --git a/src/gateway/auth.rs b/src/gateway/auth.rs index c748da6..66a6e02 100644 --- a/src/gateway/auth.rs +++ b/src/gateway/auth.rs @@ -6,11 +6,11 @@ //! - token 通过 `Authorization: Bearer `(HTTP)或 `?token=`(WS)传递。 //! - 校验使用常量时间比较,避免计时侧信道。 +use axum::Json; use axum::extract::Request; use axum::http::{HeaderMap, StatusCode}; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; -use axum::Json; use serde_json::json; use subtle::ConstantTimeEq; @@ -88,11 +88,7 @@ pub fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> { /// 仅在 `requires_auth` 为 true 时挂载。 /// `/health`、`/ws`、静态资源放行;`/ws` 的 token 校验在 ws_handler 内完成。 /// `/metrics` 包含运行时指标(provider/model/耗时/token 用量),远程部署时需保护。 -pub async fn require_bearer_auth( - headers: HeaderMap, - request: Request, - next: Next, -) -> Response { +pub async fn require_bearer_auth(headers: HeaderMap, request: Request, next: Next) -> Response { let path = request.uri().path(); // /api/* 和 /metrics 需要认证;其余放行 diff --git a/src/gateway/execution.rs b/src/gateway/execution.rs index fc50b0b..cca446e 100644 --- a/src/gateway/execution.rs +++ b/src/gateway/execution.rs @@ -155,7 +155,7 @@ impl AgentExecutionService { // 直接比较 current_topic(chat_id) 与 original_topic_id // 这比"检查内存历史最新消息"更可靠,且天然处理"切走又切回"的 case 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 时总是视为当前回合 }; @@ -419,7 +419,11 @@ impl AgentExecutionService { ); let result = agent - .process(history, Some(&system_prompt_context), Some(&compaction_sink)) + .process( + history, + Some(&system_prompt_context), + Some(&compaction_sink), + ) .await?; let mut metadata = HashMap::new(); // 把用户消息的 UUID 回传给前端,前端用此更新本地消息 ID,使 todo 点击跳转能匹配 @@ -605,7 +609,11 @@ impl AgentExecutionService { ); let result = agent - .process(history, Some(&system_prompt_context), Some(&compaction_sink)) + .process( + history, + Some(&system_prompt_context), + Some(&compaction_sink), + ) .await?; let outbound_messages = self diff --git a/src/gateway/http.rs b/src/gateway/http.rs index 8525e6e..9dad97c 100644 --- a/src/gateway/http.rs +++ b/src/gateway/http.rs @@ -65,19 +65,19 @@ fn mask_config(config: &Config) -> Config { } } for channel in masked.channels.values_mut() { - if let Some(feishu) = channel.as_feishu_mut() { - if !feishu.app_secret.is_empty() { - let visible: String = feishu.app_secret.chars().take(4).collect(); - feishu.app_secret = format!("{}{}", visible, API_KEY_MASK); - } + if let Some(feishu) = channel.as_feishu_mut() + && !feishu.app_secret.is_empty() + { + let visible: String = feishu.app_secret.chars().take(4).collect(); + feishu.app_secret = format!("{}{}", visible, API_KEY_MASK); } } // 掩码网关认证 token(避免通过 /api/config 泄露) - if let Some(ref token) = masked.gateway.auth_token { - if !token.is_empty() { - let visible: String = token.chars().take(4).collect(); - masked.gateway.auth_token = Some(format!("{}{}", visible, API_KEY_MASK)); - } + if let Some(ref token) = masked.gateway.auth_token + && !token.is_empty() + { + let visible: String = token.chars().take(4).collect(); + masked.gateway.auth_token = Some(format!("{}{}", visible, API_KEY_MASK)); } masked } @@ -116,28 +116,26 @@ pub async fn save_config( { let cfg = state.config.read().await; for (name, provider) in new_config.providers.iter_mut() { - if is_masked_key(&provider.api_key) { - if let Some(original) = cfg.providers.get(name) { - provider.api_key = original.api_key.clone(); - } + if is_masked_key(&provider.api_key) + && let Some(original) = cfg.providers.get(name) + { + provider.api_key = original.api_key.clone(); } } for (name, channel) in new_config.channels.iter_mut() { - if let Some(feishu) = channel.as_feishu_mut() { - if is_masked_key(&feishu.app_secret) { - if let Some(original_channel) = cfg.channels.get(name) { - if let Some(original_feishu) = original_channel.as_feishu() { - feishu.app_secret = original_feishu.app_secret.clone(); - } - } - } + if let Some(feishu) = channel.as_feishu_mut() + && is_masked_key(&feishu.app_secret) + && let Some(original_channel) = cfg.channels.get(name) + && let Some(original_feishu) = original_channel.as_feishu() + { + feishu.app_secret = original_feishu.app_secret.clone(); } } // 保留原始 auth_token(若提交的是掩码值) - if let Some(ref submitted) = new_config.gateway.auth_token { - if is_masked_key(submitted) { - new_config.gateway.auth_token = cfg.gateway.auth_token.clone(); - } + if let Some(ref submitted) = new_config.gateway.auth_token + && is_masked_key(submitted) + { + new_config.gateway.auth_token = cfg.gateway.auth_token.clone(); } } // read lock released here @@ -243,9 +241,7 @@ pub async fn list_executions(State(state): State>) -> Json>, -) -> (StatusCode, String) { +pub async fn metrics_handler(State(state): State>) -> (StatusCode, String) { match &state.prometheus_handle { Some(handle) => (StatusCode::OK, handle.render()), None => ( @@ -1160,27 +1156,27 @@ pub async fn session_select_model( // 校验:provider/model 名必须在 config 的 providers/models 表中存在 // (与 AgentFactory::create 中的解析失败行为对齐,提前反馈错误) let config = state.config.read().await; - if let Some(name) = provider.as_ref() { - if !config.providers.contains_key(name) { - return ( - StatusCode::BAD_REQUEST, - Json(SelectModelResponse { - success: false, - error: Some(format!("provider '{}' not found in config", name)), - }), - ); - } + if let Some(name) = provider.as_ref() + && !config.providers.contains_key(name) + { + return ( + StatusCode::BAD_REQUEST, + Json(SelectModelResponse { + success: false, + error: Some(format!("provider '{}' not found in config", name)), + }), + ); } - if let Some(name) = model.as_ref() { - if !config.models.contains_key(name) { - return ( - StatusCode::BAD_REQUEST, - Json(SelectModelResponse { - success: false, - error: Some(format!("model '{}' not found in config", name)), - }), - ); - } + if let Some(name) = model.as_ref() + && !config.models.contains_key(name) + { + return ( + StatusCode::BAD_REQUEST, + Json(SelectModelResponse { + success: false, + error: Some(format!("model '{}' not found in config", name)), + }), + ); } drop(config); @@ -1274,27 +1270,27 @@ pub async fn topic_select_model( // 校验:provider/model 名必须在 config 的 providers/models 表中存在 let config = state.config.read().await; - if let Some(name) = provider.as_ref() { - if !config.providers.contains_key(name) { - return ( - StatusCode::BAD_REQUEST, - Json(SelectModelResponse { - success: false, - error: Some(format!("provider '{}' not found in config", name)), - }), - ); - } + if let Some(name) = provider.as_ref() + && !config.providers.contains_key(name) + { + return ( + StatusCode::BAD_REQUEST, + Json(SelectModelResponse { + success: false, + error: Some(format!("provider '{}' not found in config", name)), + }), + ); } - if let Some(name) = model.as_ref() { - if !config.models.contains_key(name) { - return ( - StatusCode::BAD_REQUEST, - Json(SelectModelResponse { - success: false, - error: Some(format!("model '{}' not found in config", name)), - }), - ); - } + if let Some(name) = model.as_ref() + && !config.models.contains_key(name) + { + return ( + StatusCode::BAD_REQUEST, + Json(SelectModelResponse { + success: false, + error: Some(format!("model '{}' not found in config", name)), + }), + ); } drop(config); @@ -1320,7 +1316,9 @@ pub async fn topic_select_model( if is_clear { state.model_selections.set(&topic_session_id, None, None); } else { - state.model_selections.set(&topic_session_id, provider, model); + state + .model_selections + .set(&topic_session_id, provider, model); } ( diff --git a/src/gateway/memory_maintenance.rs b/src/gateway/memory_maintenance.rs index ba828a8..87fe271 100644 --- a/src/gateway/memory_maintenance.rs +++ b/src/gateway/memory_maintenance.rs @@ -707,13 +707,13 @@ pub(crate) fn validate_memory_maintenance_output( } // 检查目标 namespace 是否与源一致 - if let Some(src_ns) = source_namespaces.iter().next() { - if *src_ns != merge.namespace { - return Err(format!( - "跨 namespace 合并被禁止: {} → {}", - src_ns, merge.namespace - )); - } + if let Some(src_ns) = source_namespaces.iter().next() + && *src_ns != merge.namespace + { + return Err(format!( + "跨 namespace 合并被禁止: {} → {}", + src_ns, merge.namespace + )); } } @@ -768,7 +768,7 @@ pub(crate) fn apply_memory_maintenance_output( min_memories_to_keep, max_merge_per_group, ) - .map_err(|e| AgentError::Other(e))?; + .map_err(AgentError::Other)?; let all_candidates = plan.candidates.clone(); @@ -834,14 +834,14 @@ pub(crate) fn apply_memory_maintenance_output( } for memory_id in &output.low_value_ids { - if let Some(candidate) = candidates_by_id.get(memory_id.as_str()) { - if deleted_ids.insert(candidate.id.clone()) { - store - .delete_memory("user", scope_key, &candidate.namespace, &candidate.key) - .map_err(|err| { - AgentError::Other(format!("delete low value memory error: {}", err)) - })?; - } + if let Some(candidate) = candidates_by_id.get(memory_id.as_str()) + && deleted_ids.insert(candidate.id.clone()) + { + store + .delete_memory("user", scope_key, &candidate.namespace, &candidate.key) + .map_err(|err| { + AgentError::Other(format!("delete low value memory error: {}", err)) + })?; } } diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 5773ddf..beabfcd 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -110,27 +110,34 @@ impl GatewayState { mcp_servers: config.mcp_servers.clone(), }; - let (session_manager, task_repository, mcp_manager, subagent_runtime, model_selections, topic_model_selections, subagent_executor) = - build_session_manager_with_sender( - agent_prompt_reinject_every, - show_tool_results, - config.time.timezone.clone(), - provider_config, - provider_configs, - skills.clone(), - experts.clone(), - Arc::new(BusSessionMessageSender::new(bus.clone())), - std::collections::HashSet::new(), - config.tools.task.clone(), - config.subagents.clone(), - config.memory_maintenance.clone(), - session_ttl_hours, - mcp_config, - config.mcp_tool_timeout_secs, - Some(bus.clone()), - Arc::new(crate::config::ModelResolver::from_config(&config)), - config.compaction.clone(), - )?; + let ( + session_manager, + task_repository, + mcp_manager, + subagent_runtime, + model_selections, + topic_model_selections, + subagent_executor, + ) = build_session_manager_with_sender( + agent_prompt_reinject_every, + show_tool_results, + config.time.timezone.clone(), + provider_config, + provider_configs, + skills.clone(), + experts.clone(), + Arc::new(BusSessionMessageSender::new(bus.clone())), + std::collections::HashSet::new(), + config.tools.task.clone(), + config.subagents.clone(), + config.memory_maintenance.clone(), + session_ttl_hours, + mcp_config, + config.mcp_tool_timeout_secs, + Some(bus.clone()), + Arc::new(crate::config::ModelResolver::from_config(&config)), + config.compaction.clone(), + )?; // 诊断日志:记录新 GatewayState 的创建(用于排查重启后是否使用了新状态) tracing::info!( @@ -226,7 +233,11 @@ pub async fn run( // 将 pending_subagents 表中所有 status='running' 的记录标记为 'interrupted'。 // 下次 wait_for_subagents 调用时,这些 task_id 不会出现在 pending 列表中, // agent 可据此判断子代理未正常完成。 - match state.session_manager.store().mark_all_running_as_interrupted() { + match state + .session_manager + .store() + .mark_all_running_as_interrupted() + { Ok(0) => { tracing::info!("Crash recovery: no interrupted subagents to recover"); } @@ -252,7 +263,7 @@ pub async fn run( // Initialize and start channels state .channel_manager - .init(&*cfg, provider_config.clone()) + .init(&cfg, provider_config.clone()) .await?; drop(cfg); state.channel_manager.start_all().await?; diff --git a/src/gateway/model_selection.rs b/src/gateway/model_selection.rs index 426ce42..19aa1b5 100644 --- a/src/gateway/model_selection.rs +++ b/src/gateway/model_selection.rs @@ -1,5 +1,5 @@ -use std::collections::HashMap; use parking_lot::RwLock; +use std::collections::HashMap; /// per-session 的用户模型覆盖选择存储。 /// @@ -17,9 +17,7 @@ impl ModelSelectionStore { /// 设置 session 的用户模型覆盖。provider 和 model 均为 None 时清除该 session 的选择。 pub fn set(&self, session_id: &str, provider: Option, model: Option) { - let mut selections = self - .selections - .write(); + let mut selections = self.selections.write(); if provider.is_none() && model.is_none() { selections.remove(session_id); } else { @@ -29,10 +27,7 @@ impl ModelSelectionStore { /// 读取 session 的用户模型覆盖。 pub fn get(&self, session_id: &str) -> Option<(Option, Option)> { - self.selections - .read() - .get(session_id) - .cloned() + self.selections.read().get(session_id).cloned() } } diff --git a/src/gateway/outbound_dispatcher.rs b/src/gateway/outbound_dispatcher.rs index d96cc74..2d67b65 100644 --- a/src/gateway/outbound_dispatcher.rs +++ b/src/gateway/outbound_dispatcher.rs @@ -77,37 +77,25 @@ impl OutboundDispatcher { /// sender task 生命周期与 dispatcher 一致:dispatcher `run()` 退出时 /// 通过 cancel token 终止所有 sender task。 pub async fn register_channel(&self, name: &str, channel: Arc) { - let (high_tx, high_rx) = - mpsc::channel::(HIGH_PRIORITY_QUEUE_CAPACITY); - let (low_tx, low_rx) = - mpsc::channel::(LOW_PRIORITY_QUEUE_CAPACITY); + let (high_tx, high_rx) = mpsc::channel::(HIGH_PRIORITY_QUEUE_CAPACITY); + let (low_tx, low_rx) = mpsc::channel::(LOW_PRIORITY_QUEUE_CAPACITY); let cancel = CancellationToken::new(); let channel_name = name.to_string(); let cancel_for_task = cancel.clone(); tokio::spawn(async move { - Self::run_sender_task( - &channel_name, - channel, - high_rx, - low_rx, - cancel_for_task, - ) - .await; + Self::run_sender_task(&channel_name, channel, high_rx, low_rx, cancel_for_task).await; }); - self.channels - .write() - .await - .insert( - name.to_string(), - ChannelSink { - high_tx, - low_tx, - cancel, - }, - ); + self.channels.write().await.insert( + name.to_string(), + ChannelSink { + high_tx, + low_tx, + cancel, + }, + ); } /// sender task:优先消费 high 队列(最终响应),再消费 low 队列(中间过程), @@ -166,11 +154,7 @@ impl OutboundDispatcher { } /// 发送单条消息,处理重试结果日志。 - async fn send_one( - channel: &dyn Channel, - channel_name: &str, - msg: OutboundMessage, - ) { + async fn send_one(channel: &dyn Channel, channel_name: &str, msg: OutboundMessage) { let msg_chat_id = msg.chat_id.clone(); let msg_trace_id = msg.trace_id.clone(); match Self::send_with_retry(channel, msg).await { @@ -419,7 +403,7 @@ mod tests { 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())); } @@ -479,19 +463,42 @@ mod tests { let error = make_error_message("c", "chat", "agent failed"); let tool_call = make_low_message("c", "chat", "calling tool"); let tool_result = OutboundMessage::tool_result( - "c", "chat", None, "id", "tool", "result", None, + "c", + "chat", + None, + "id", + "tool", + "result", + None, std::collections::HashMap::new(), ); let exec_done = OutboundMessage::execution_completed( - "c", "chat", None, + "c", + "chat", + None, std::collections::HashMap::new(), ); - assert!(is_high_priority(&assistant), "AssistantResponse should be high priority"); - assert!(is_high_priority(&error), "ErrorNotification should be high priority"); - assert!(!is_high_priority(&tool_call), "ToolCall should be low priority"); - assert!(!is_high_priority(&tool_result), "ToolResult should be low priority"); - assert!(!is_high_priority(&exec_done), "ExecutionCompleted should be low priority"); + assert!( + is_high_priority(&assistant), + "AssistantResponse should be high priority" + ); + assert!( + is_high_priority(&error), + "ErrorNotification should be high priority" + ); + assert!( + !is_high_priority(&tool_call), + "ToolCall should be low priority" + ); + assert!( + !is_high_priority(&tool_result), + "ToolResult should be low priority" + ); + assert!( + !is_high_priority(&exec_done), + "ExecutionCompleted should be low priority" + ); } #[tokio::test] @@ -514,8 +521,12 @@ mod tests { }); // 先发一条 slow(500ms 延迟),紧接着发一条 fast - bus.publish_outbound(make_message("slow", "chat-1", "slow-msg")).await.unwrap(); - bus.publish_outbound(make_message("fast", "chat-2", "fast-msg")).await.unwrap(); + bus.publish_outbound(make_message("slow", "chat-1", "slow-msg")) + .await + .unwrap(); + bus.publish_outbound(make_message("fast", "chat-2", "fast-msg")) + .await + .unwrap(); // 等待 fast 消息被投递(远早于 slow 完成) tokio::time::timeout(Duration::from_millis(200), async { @@ -524,7 +535,9 @@ mod tests { } }) .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 消息完成 tokio::time::timeout(Duration::from_secs(2), async { @@ -568,8 +581,12 @@ mod tests { }); // 先发 flaky(会重试 3 秒),紧接着发 stable - bus.publish_outbound(make_message("flaky", "chat-1", "flaky-msg")).await.unwrap(); - bus.publish_outbound(make_message("stable", "chat-2", "stable-msg")).await.unwrap(); + bus.publish_outbound(make_message("flaky", "chat-1", "flaky-msg")) + .await + .unwrap(); + bus.publish_outbound(make_message("stable", "chat-2", "stable-msg")) + .await + .unwrap(); // stable 应在 200ms 内收到,远早于 flaky 的 3 秒重试完成 tokio::time::timeout(Duration::from_millis(200), async { @@ -820,7 +837,10 @@ mod tests { .await .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!( call_count.load(Ordering::SeqCst), 4, diff --git a/src/gateway/processor.rs b/src/gateway/processor.rs index c9b083c..8439bd5 100644 --- a/src/gateway/processor.rs +++ b/src/gateway/processor.rs @@ -1,7 +1,7 @@ -use std::collections::HashSet; -use std::sync::Arc; use futures_util::FutureExt; use parking_lot::Mutex; +use std::collections::HashSet; +use std::sync::Arc; use tokio::sync::Semaphore; @@ -28,8 +28,8 @@ use crate::providers::{ProviderRuntimeConfig, create_provider}; use crate::storage::persistent_session_id; use crate::topic_description::generate_topic_description; -use super::session::{BusToolCallEmitter, SessionManager}; use super::message_prepare::enrich_user_content_with_media_refs; +use super::session::{BusToolCallEmitter, SessionManager}; #[derive(Clone)] pub struct InboundProcessor { @@ -180,39 +180,37 @@ impl InboundProcessor { let chat_id_for_span = inbound.chat_id.clone(); let session_id_for_span = crate::storage::persistent_session_id(&inbound.channel, &inbound.chat_id); - tokio::spawn( - crate::observability::tracing_ctx::traced( - &trace_id, - &chat_id_for_span, - &session_id_for_span, - async move { - let _permit = permit; // 持有 permit 直到任务完成 - // catch_unwind 将 panic 归一化为错误:否则工具/历史清理中的 - // panic 只会终止任务并打 panic hook 日志,跳过错误日志与指标, - // 用户消息被静默吞掉。参考 channels/wechat.rs 的同类用法。 - let result = std::panic::AssertUnwindSafe(processor.process_one(inbound)) - .catch_unwind() - .await; - match result { - Ok(Ok(())) => {} - Ok(Err(e)) => { - tracing::error!( - error = %crate::utils::format_error_chain(&e), - "Message processing failed" - ); - crate::observability::metrics::record_message_processing_error(); - } - Err(payload) => { - tracing::error!( - error = %crate::utils::panic_payload_message(&payload), - "Message processing panicked" - ); - crate::observability::metrics::record_message_processing_error(); - } + tokio::spawn(crate::observability::tracing_ctx::traced( + &trace_id, + &chat_id_for_span, + &session_id_for_span, + async move { + let _permit = permit; // 持有 permit 直到任务完成 + // catch_unwind 将 panic 归一化为错误:否则工具/历史清理中的 + // panic 只会终止任务并打 panic hook 日志,跳过错误日志与指标, + // 用户消息被静默吞掉。参考 channels/wechat.rs 的同类用法。 + let result = std::panic::AssertUnwindSafe(processor.process_one(inbound)) + .catch_unwind() + .await; + match result { + Ok(Ok(())) => {} + Ok(Err(e)) => { + tracing::error!( + error = %crate::utils::format_error_chain(&e), + "Message processing failed" + ); + crate::observability::metrics::record_message_processing_error(); } - }, - ), - ); + Err(payload) => { + tracing::error!( + error = %crate::utils::panic_payload_message(&payload), + "Message processing panicked" + ); + crate::observability::metrics::record_message_processing_error(); + } + } + }, + )); } } @@ -280,8 +278,8 @@ impl InboundProcessor { } } } - } else if let Some(error) = response.error { - if let Err(e) = self + } else if let Some(error) = response.error + && let Err(e) = self .bus .publish_outbound( OutboundMessage::assistant( @@ -295,14 +293,13 @@ impl InboundProcessor { .with_trace_id(&inbound.trace_id), ) .await - { - match e { - crate::bus::BusError::Dropped => { - tracing::warn!(error = %e, "Outbound dropped (bus full)"); - } - crate::bus::BusError::Closed => { - tracing::error!(error = %e, "Failed to publish error response"); - } + { + match e { + crate::bus::BusError::Dropped => { + tracing::warn!(error = %e, "Outbound dropped (bus full)"); + } + crate::bus::BusError::Closed => { + tracing::error!(error = %e, "Failed to publish error response"); } } } @@ -326,73 +323,65 @@ impl InboundProcessor { // // 安全性:is_waiting 在持锁状态下检查,wait_coordinator 清除 is_waiting 需先重获取锁, // 两者互斥,无 TOCTOU。 - if let Some(ref topic_id) = current_topic { - if let Some(session) = self.session_manager.get(&inbound.channel).await { - let lock_key = topic_id.clone(); + if let Some(ref topic_id) = current_topic + && let Some(session) = self.session_manager.get(&inbound.channel).await + { + let lock_key = topic_id.clone(); - // 获取 serial_lock Arc(短暂持有 session 锁) - let serial_lock = { + // 获取 serial_lock Arc(短暂持有 session 锁) + let serial_lock = { + let mut g = session.lock().await; + g.ensure_sub_done_channel(&lock_key); + g.topic_serial_lock(&lock_key) + }; + + // 阻塞获取 serial_lock + // - agent 正常运行:阻塞至其完成(天然串行化) + // - agent 在 wait 中:wait 已释放锁,可立即获取 + let _inject_guard = serial_lock.clone().lock_owned().await; + + // 检查 is_waiting(持锁状态下安全) + let is_waiting = { + let g = session.lock().await; + g.is_waiting(&lock_key) + }; + + if is_waiting { + // Agent 正在 wait_for_subagents 中等待 → 注入用户消息 + 唤醒 + tracing::info!( + topic_id = %lock_key, + "Topic is in waiting state, injecting user message and waking up agent" + ); + + let wakeup = { let mut g = session.lock().await; - g.ensure_sub_done_channel(&lock_key); - g.topic_serial_lock(&lock_key) + // 确保 session 和 chat 已加载 + g.ensure_persistent_session(&inbound.chat_id)?; + g.ensure_chat_loaded(&inbound.chat_id, Some(&lock_key))?; + + // 构造用户消息(与 prepare_and_execute_message 一致的处理流程) + let media_refs: Vec = + inbound.media.iter().map(|m| m.path.clone()).collect(); + let enriched_content = + enrich_user_content_with_media_refs(&inbound.content, &media_refs)?; + let user_message = g.create_user_message(&enriched_content, media_refs); + g.append_persisted_message(&inbound.chat_id, Some(&lock_key), user_message)?; + + // 获取 wakeup 信号 + g.wait_wakeup(&lock_key) }; - // 阻塞获取 serial_lock - // - agent 正常运行:阻塞至其完成(天然串行化) - // - agent 在 wait 中:wait 已释放锁,可立即获取 - let _inject_guard = serial_lock.clone().lock_owned().await; + // 唤醒等待中的 agent(wait_coordinator 的 select! 会捕获此通知) + wakeup.notify_one(); - // 检查 is_waiting(持锁状态下安全) - let is_waiting = { - let g = session.lock().await; - g.is_waiting(&lock_key) - }; - - if is_waiting { - // Agent 正在 wait_for_subagents 中等待 → 注入用户消息 + 唤醒 - tracing::info!( - topic_id = %lock_key, - "Topic is in waiting state, injecting user message and waking up agent" - ); - - let wakeup = { - let mut g = session.lock().await; - // 确保 session 和 chat 已加载 - g.ensure_persistent_session(&inbound.chat_id)?; - g.ensure_chat_loaded(&inbound.chat_id, Some(&lock_key))?; - - // 构造用户消息(与 prepare_and_execute_message 一致的处理流程) - let media_refs: Vec = inbound - .media - .iter() - .map(|m| m.path.clone()) - .collect(); - let enriched_content = - enrich_user_content_with_media_refs(&inbound.content, &media_refs)?; - let user_message = - g.create_user_message(&enriched_content, media_refs); - g.append_persisted_message( - &inbound.chat_id, - Some(&lock_key), - user_message, - )?; - - // 获取 wakeup 信号 - g.wait_wakeup(&lock_key) - }; - - // 唤醒等待中的 agent(wait_coordinator 的 select! 会捕获此通知) - wakeup.notify_one(); - - // _inject_guard 在此处 drop → 释放 serial_lock - // wait_coordinator 重获取锁后继续处理(history 已包含新用户消息) - // - // 跳过 handle_message / cancel 注册 / execution_completed, - // 因为等待中的 agent 会处理这条消息。 - return Ok(()); - } - // is_waiting=false:_inject_guard drop 释放锁,走正常 handle_message 路径 + // _inject_guard 在此处 drop → 释放 serial_lock + // wait_coordinator 重获取锁后继续处理(history 已包含新用户消息) + // + // 跳过 handle_message / cancel 注册 / execution_completed, + // 因为等待中的 agent 会处理这条消息。 + return Ok(()); } + // is_waiting=false:_inject_guard drop 释放锁,走正常 handle_message 路径 } let live_emitter = Arc::new(PersistingEmittedMessageHandler::new( @@ -461,81 +450,74 @@ impl InboundProcessor { // 异步生成 topic 描述(仅当描述为空且没有正在进行的生成任务时触发) if let Some(ref topic_id) = current_topic { let store = self.session_manager.store(); - if let Ok(Some(topic)) = store.get_topic(topic_id) { - if topic.description.is_none() + if let Ok(Some(topic)) = store.get_topic(topic_id) + && (topic.description.is_none() || topic .description .as_ref() .map(|d| d.is_empty()) - .unwrap_or(true) - { - // 检查并设置"生成中"守卫,防止竞态条件导致重复生成 - let should_generate = { - let mut in_flight = - self.description_generation_in_flight.lock(); - if in_flight.contains(topic_id) { - false - } else { - in_flight.insert(topic_id.clone()); - true - } - }; + .unwrap_or(true)) + { + // 检查并设置"生成中"守卫,防止竞态条件导致重复生成 + let should_generate = { + let mut in_flight = self.description_generation_in_flight.lock(); + if in_flight.contains(topic_id) { + false + } else { + in_flight.insert(topic_id.clone()); + true + } + }; - if should_generate { - let provider_config = self.provider_config.clone(); - let topic_id_clone = topic_id.clone(); - let store_clone = store.clone(); - let in_flight = self.description_generation_in_flight.clone(); + if should_generate { + let provider_config = self.provider_config.clone(); + let topic_id_clone = topic_id.clone(); + let store_clone = store.clone(); + let in_flight = self.description_generation_in_flight.clone(); - tokio::spawn(async move { - // 从 DB 查询该 topic 的第一条用户消息作为描述生成的依据 - let first_user_message = store_clone - .load_messages_for_topic_full(&topic_id_clone, None) - .ok() - .and_then(|msgs| { - msgs.into_iter().find(|m| m.role == "user") - }) - .map(|m| m.content); + tokio::spawn(async move { + // 从 DB 查询该 topic 的第一条用户消息作为描述生成的依据 + let first_user_message = store_clone + .load_messages_for_topic_full(&topic_id_clone, None) + .ok() + .and_then(|msgs| msgs.into_iter().find(|m| m.role == "user")) + .map(|m| m.content); - let message_content = match first_user_message { - Some(content) => content, - None => { - tracing::warn!(topic_id = %topic_id_clone, "No user message found for topic, skipping description generation"); - in_flight.lock().remove(&topic_id_clone); - return; + let message_content = match first_user_message { + Some(content) => content, + None => { + tracing::warn!(topic_id = %topic_id_clone, "No user message found for topic, skipping description generation"); + in_flight.lock().remove(&topic_id_clone); + return; + } + }; + + let runtime_config: ProviderRuntimeConfig = provider_config.into(); + if let Ok(provider) = create_provider(runtime_config) { + match generate_topic_description( + provider.as_ref(), + &message_content, + ) + .await + { + Ok(description) => { + if let Err(e) = store_clone.update_topic_description( + &topic_id_clone, + &description, + ) { + tracing::error!(error = %e, topic_id = %topic_id_clone, "Failed to update topic description"); + } else { + tracing::info!(topic_id = %topic_id_clone, description = %description, "Topic description generated"); + } } - }; - - let runtime_config: ProviderRuntimeConfig = - provider_config.into(); - if let Ok(provider) = create_provider(runtime_config) { - match generate_topic_description( - provider.as_ref(), - &message_content, - ) - .await - { - Ok(description) => { - if let Err(e) = store_clone - .update_topic_description( - &topic_id_clone, - &description, - ) - { - tracing::error!(error = %e, topic_id = %topic_id_clone, "Failed to update topic description"); - } else { - tracing::info!(topic_id = %topic_id_clone, description = %description, "Topic description generated"); - } - } - Err(e) => { - tracing::error!(error = %e, topic_id = %topic_id_clone, "Failed to generate topic description"); - } + Err(e) => { + tracing::error!(error = %e, topic_id = %topic_id_clone, "Failed to generate topic description"); } } - // 无论成功失败,释放生成守卫 - in_flight.lock().remove(&topic_id_clone); - }); - } + } + // 无论成功失败,释放生成守卫 + in_flight.lock().remove(&topic_id_clone); + }); } } } diff --git a/src/gateway/session.rs b/src/gateway/session.rs index 16308ba..34013f9 100644 --- a/src/gateway/session.rs +++ b/src/gateway/session.rs @@ -1,4 +1,6 @@ -use crate::agent::{AgentError, AgentLoop, AgentRuntimeConfig, ContextCompressor, EmittedMessageHandler}; +use crate::agent::{ + AgentError, AgentLoop, AgentRuntimeConfig, ContextCompressor, EmittedMessageHandler, +}; #[cfg(test)] use crate::bus::SYSTEM_CONTEXT_SCHEDULED_PROMPT; use crate::bus::{ChatMessage, MessageBus, OutboundMessage}; @@ -12,10 +14,10 @@ use crate::storage::{ SkillEventRepository, }; use crate::tools::ToolRegistry; +use crate::tools::WaitCoordinator; +use crate::tools::task::SubagentResult; use crate::tools::task::repository::TaskRepository; use crate::tools::task::runtime::SubagentRuntime; -use crate::tools::task::SubagentResult; -use crate::tools::WaitCoordinator; use async_trait::async_trait; use std::collections::HashMap; use std::sync::Arc; @@ -557,10 +559,10 @@ impl Session { } // 更新 topic 的最后活跃时间 - if let Some(ref topic_id) = topic_id { - if let Err(e) = self.store.touch_topic(topic_id) { - tracing::warn!(error = %e, topic_id = %topic_id, "Failed to touch topic"); - } + if let Some(ref topic_id) = topic_id + && let Err(e) = self.store.touch_topic(topic_id) + { + tracing::warn!(error = %e, topic_id = %topic_id, "Failed to touch topic"); } Ok(()) @@ -1270,6 +1272,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> { + 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)] mod tests { use super::*; @@ -3033,25 +3057,3 @@ mod tests { 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> { - 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())), - } - } -} diff --git a/src/gateway/session_history.rs b/src/gateway/session_history.rs index aa9600c..0930991 100644 --- a/src/gateway/session_history.rs +++ b/src/gateway/session_history.rs @@ -76,11 +76,7 @@ impl SessionHistory { } // 收集当前活跃 topic 集合 - let active: HashSet<&str> = self - .chat_topic_ids - .values() - .map(|s| s.as_str()) - .collect(); + let active: HashSet<&str> = self.chat_topic_ids.values().map(|s| s.as_str()).collect(); // 找一个非活跃 topic 驱逐 let to_evict = self.topic_histories.keys().find(|tid| { @@ -104,10 +100,10 @@ impl SessionHistory { // 检查是否有活跃 agent 任务(serial lock 被持有) // try_lock 成功 = 锁空闲 = 无活跃任务 = 可驱逐 // try_lock 失败 = 锁被持有 = 有活跃任务 = 不驱逐 - if let Some(lock) = self.topic_serial_locks.get(*tid) { - if lock.try_lock().is_err() { - return false; - } + if let Some(lock) = self.topic_serial_locks.get(*tid) + && lock.try_lock().is_err() + { + return false; } true }); @@ -176,7 +172,10 @@ impl SessionHistory { /// 获取该 topic 的 sub_done 队列 sender(用于后台子代理发送结果)。 /// 调用前应已通过 `ensure_sub_done_channel` 创建队列。 - pub(crate) fn sub_done_sender(&mut self, topic_id: &str) -> Option> { + pub(crate) fn sub_done_sender( + &mut self, + topic_id: &str, + ) -> Option> { self.ensure_sub_done_channel(topic_id); self.sub_done_senders.get(topic_id).cloned() } @@ -334,14 +333,14 @@ impl SessionHistory { chat_id: &str, topic_id: Option<&str>, ) -> Result<(), AgentError> { - if let Some(tid) = topic_id { - if let Some(history) = self.topic_histories.get_mut(tid) { - #[cfg(debug_assertions)] - let len = history.len(); - history.clear(); - #[cfg(debug_assertions)] - tracing::debug!(topic_id = %tid, previous_len = len, "Topic history cleared"); - } + if let Some(tid) = topic_id + && let Some(history) = self.topic_histories.get_mut(tid) + { + #[cfg(debug_assertions)] + let len = history.len(); + history.clear(); + #[cfg(debug_assertions)] + tracing::debug!(topic_id = %tid, previous_len = len, "Topic history cleared"); } self.conversations diff --git a/src/gateway/session_message_sender.rs b/src/gateway/session_message_sender.rs index c1c7125..8516266 100644 --- a/src/gateway/session_message_sender.rs +++ b/src/gateway/session_message_sender.rs @@ -149,7 +149,7 @@ mod tests { text: Some("hello".to_string()), // 使用临时目录确保跨平台兼容 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", )], }, diff --git a/src/gateway/static_files.rs b/src/gateway/static_files.rs index 55ce6e2..f5da169 100644 --- a/src/gateway/static_files.rs +++ b/src/gateway/static_files.rs @@ -34,14 +34,14 @@ pub async fn static_handler(uri: Uri) -> Response { None => { // 对于 SPA 应用,如果请求的是页面路由(不是静态资源),返回 index.html // 静态资源通常包含 . (如 .js, .css, .png) - if !path.contains('.') { - if let Some(index) = StaticAssets::get("index.html") { - return Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "text/html") - .body(Body::from(index.data.into_owned())) - .unwrap(); - } + if !path.contains('.') + && let Some(index) = StaticAssets::get("index.html") + { + return Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html") + .body(Body::from(index.data.into_owned())) + .unwrap(); } Response::builder() diff --git a/src/gateway/tool_prompt_provider.rs b/src/gateway/tool_prompt_provider.rs index a7b5577..cf692d5 100644 --- a/src/gateway/tool_prompt_provider.rs +++ b/src/gateway/tool_prompt_provider.rs @@ -11,6 +11,12 @@ use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider}; /// - 两者独立演化:新增工具只需在此处加常量,不碰代理身份配置 pub struct ToolPromptProvider; +impl Default for ToolPromptProvider { + fn default() -> Self { + Self::new() + } +} + impl ToolPromptProvider { pub fn new() -> Self { Self diff --git a/src/gateway/tool_registry_factory.rs b/src/gateway/tool_registry_factory.rs index 2d660ae..48a4f2e 100644 --- a/src/gateway/tool_registry_factory.rs +++ b/src/gateway/tool_registry_factory.rs @@ -111,17 +111,17 @@ impl ToolRegistryFactory { if self.is_enabled("memory_manage") { registry.register(MemoryManageTool::new(self.memories.clone())); } - if self.is_enabled("todo_write") { - if let Some(ref state) = self.todo_state { - registry.register(TodoWriteTool::new( - state.clone(), - self.todo_repository.clone(), - )); - registry.register(TodoReadTool::new( - state.clone(), - self.todo_repository.clone(), - )); - } + if self.is_enabled("todo_write") + && let Some(ref state) = self.todo_state + { + registry.register(TodoWriteTool::new( + state.clone(), + self.todo_repository.clone(), + )); + registry.register(TodoReadTool::new( + state.clone(), + self.todo_repository.clone(), + )); } if self.is_enabled("session_send") { registry.register(SessionSendTool::new(self.session_message_sender.clone())); @@ -157,15 +157,16 @@ impl ToolRegistryFactory { } // 注册 Task 工具(如果启用且有 subagent_runtime) - if self.is_enabled("task") && self.task_config.enabled { - if let Some(runtime) = &self.subagent_runtime { - registry.register(TaskTool::new(runtime.clone(), None)); - // 注册 wait_for_subagents 工具(仅主 agent,用于等待异步子代理完成) - // 默认超时从配置读取,LLM 可通过 timeout_secs 参数覆盖 - registry.register(WaitForSubagentsTool::new( - self.task_config.wait_default_timeout_secs, - )); - } + if self.is_enabled("task") + && self.task_config.enabled + && let Some(runtime) = &self.subagent_runtime + { + registry.register(TaskTool::new(runtime.clone(), None)); + // 注册 wait_for_subagents 工具(仅主 agent,用于等待异步子代理完成) + // 默认超时从配置读取,LLM 可通过 timeout_secs 参数覆盖 + registry.register(WaitForSubagentsTool::new( + self.task_config.wait_default_timeout_secs, + )); } registry @@ -230,17 +231,17 @@ impl ToolRegistryFactory { } // Todo 追踪工具 - if self.is_enabled("todo_write") { - if let Some(ref state) = self.todo_state { - registry.register(TodoWriteTool::new( - state.clone(), - self.todo_repository.clone(), - )); - registry.register(TodoReadTool::new( - state.clone(), - self.todo_repository.clone(), - )); - } + if self.is_enabled("todo_write") + && let Some(ref state) = self.todo_state + { + registry.register(TodoWriteTool::new( + state.clone(), + self.todo_repository.clone(), + )); + registry.register(TodoReadTool::new( + state.clone(), + self.todo_repository.clone(), + )); } // 注册 MCP 工具(如果提供) diff --git a/src/gateway/wait_coordinator.rs b/src/gateway/wait_coordinator.rs index c1cfd4c..eb84e50 100644 --- a/src/gateway/wait_coordinator.rs +++ b/src/gateway/wait_coordinator.rs @@ -97,11 +97,7 @@ impl WaitCoordinator for SessionWaitCoordinator { results } - async fn wait( - &self, - timeout: Duration, - cancel_rx: Option>, - ) -> WaitEvent { + async fn wait(&self, timeout: Duration, cancel_rx: Option>) -> WaitEvent { // 1. 设置 waiting=true { let mut session = self.session.lock().await; diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index 88e28a9..cc90d4e 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -142,13 +142,13 @@ pub async fn ws_handler( auth_cfg: Option>, ) -> Response { // 若启用了认证(auth_cfg 存在且 token 已配置),校验 query param 中的 token - if let Some(axum::Extension(cfg)) = auth_cfg { - if let Some(ref expected) = cfg.token { - let provided = query.token.as_deref(); - if !crate::gateway::auth::token_matches(provided, &Some(expected.clone())) { - tracing::warn!("WebSocket connection rejected: missing or invalid token"); - return (StatusCode::UNAUTHORIZED, "missing or invalid token").into_response(); - } + if let Some(axum::Extension(cfg)) = auth_cfg + && let Some(ref expected) = cfg.token + { + let provided = query.token.as_deref(); + if !crate::gateway::auth::token_matches(provided, &Some(expected.clone())) { + tracing::warn!("WebSocket connection rejected: missing or invalid token"); + return (StatusCode::UNAUTHORIZED, "missing or invalid token").into_response(); } } @@ -653,66 +653,61 @@ async fn handle_inbound( } // 处理定时任务列表 - if let Some(jobs_json) = response.metadata.get("scheduler_jobs") { - if let Ok(jobs) = + if let Some(jobs_json) = response.metadata.get("scheduler_jobs") + && let Ok(jobs) = serde_json::from_str::>(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 Ok(skills) = + if let Some(skills_json) = response.metadata.get("skills") + && let Ok(skills) = serde_json::from_str::>(skills_json) - { - let _ = sender.send(WsOutbound::SkillList { skills }).await; - } + { + let _ = sender.send(WsOutbound::SkillList { skills }).await; } // 处理 Todo 列表 - if let Some(todos_json) = response.metadata.get("todos") { - if let Ok(todos) = + if let Some(todos_json) = response.metadata.get("todos") + && let Ok(todos) = serde_json::from_str::>(todos_json) - { - let scope_key = response - .metadata - .get("todos_scope_key") - .cloned() - .unwrap_or_default(); - tracing::debug!(todo_count = todos.len(), %scope_key, "list_todos command response"); - let _ = sender.send(WsOutbound::TodoList { todos, scope_key }).await; - } + { + let scope_key = response + .metadata + .get("todos_scope_key") + .cloned() + .unwrap_or_default(); + tracing::debug!(todo_count = todos.len(), %scope_key, "list_todos command response"); + let _ = sender.send(WsOutbound::TodoList { todos, scope_key }).await; } // 处理记忆列表 - if let Some(memories_json) = response.metadata.get("memories") { - if let Ok(memories) = + if let Some(memories_json) = response.metadata.get("memories") + && let Ok(memories) = serde_json::from_str::>(memories_json) - { - let _ = sender.send(WsOutbound::MemoryList { memories }).await; - } + { + let _ = sender.send(WsOutbound::MemoryList { memories }).await; } // 记忆 CRUD 后自动刷新列表 - if response.metadata.get("memory_updated").map(|v| v.as_str()) == Some("true") { - if let Ok(records) = + if response.metadata.get("memory_updated").map(|v| v.as_str()) == Some("true") + && let Ok(records) = store.list_memories_for_scope("user", crate::storage::GLOBAL_SCOPE_KEY) - { - let memories: Vec = records - .into_iter() - .filter(|m| m.namespace != "_meta") - .map(|m| crate::protocol::MemorySummary { - id: m.id, - namespace: m.namespace, - memory_key: m.memory_key, - content: m.content, - created_at: m.created_at, - updated_at: m.updated_at, - }) - .collect(); - let _ = sender.send(WsOutbound::MemoryList { memories }).await; - } + { + let memories: Vec = records + .into_iter() + .filter(|m| m.namespace != "_meta") + .map(|m| crate::protocol::MemorySummary { + id: m.id, + namespace: m.namespace, + memory_key: m.memory_key, + content: m.content, + created_at: m.created_at, + updated_at: m.updated_at, + }) + .collect(); + let _ = sender.send(WsOutbound::MemoryList { memories }).await; } // 处理加载聊天消息请求 @@ -738,31 +733,29 @@ async fn handle_inbound( } } - if current_topic_id.is_none() { - if let Some(topics_json) = response.metadata.get("topics") { - match serde_json::from_str::>( - topics_json, - ) { - Ok(topics) => { - if let Some(first_topic) = topics.first() { - let topic_id = first_topic.topic_id.clone(); - *current_topic_id = Some(topic_id.clone()); - if let Err(e) = send_topic_history( - &store, - current_session_id, - &topic_id, - sender, - &state.task_repository, - ) - .await - { - tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send initial topic history"); - } + if current_topic_id.is_none() + && let Some(topics_json) = response.metadata.get("topics") + { + match serde_json::from_str::>(topics_json) { + Ok(topics) => { + if let Some(first_topic) = topics.first() { + let topic_id = first_topic.topic_id.clone(); + *current_topic_id = Some(topic_id.clone()); + if let Err(e) = send_topic_history( + &store, + current_session_id, + &topic_id, + sender, + &state.task_repository, + ) + .await + { + tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send initial topic history"); } } - Err(e) => { - tracing::warn!(error = %e, "Failed to parse topics metadata for initial history"); - } + } + Err(e) => { + tracing::warn!(error = %e, "Failed to parse topics metadata for initial history"); } } } @@ -820,10 +813,10 @@ async fn send_topic_history( let mut tool_call_ids_with_results: std::collections::HashSet = std::collections::HashSet::new(); for msg in &messages { - if msg.role == "tool" { - if let Some(ref tcid) = msg.tool_call_id { - tool_call_ids_with_results.insert(tcid.clone()); - } + if msg.role == "tool" + && let Some(ref tcid) = msg.tool_call_id + { + tool_call_ids_with_results.insert(tcid.clone()); } } @@ -894,7 +887,8 @@ fn reconcile_running_in_messages( topic_id: &str, ) { 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 { return; // 无需查询 DB @@ -916,13 +910,15 @@ fn reconcile_running_in_messages( if msg.role != "tool" { 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 { continue; }; match status_map.get(task_id.as_str()) { 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,10 +941,10 @@ async fn send_task_messages( let mut tool_call_ids_with_results: std::collections::HashSet = std::collections::HashSet::new(); for msg in &messages { - if msg.role == "tool" { - if let Some(ref tcid) = msg.tool_call_id { - tool_call_ids_with_results.insert(tcid.clone()); - } + if msg.role == "tool" + && let Some(ref tcid) = msg.tool_call_id + { + tool_call_ids_with_results.insert(tcid.clone()); } } @@ -1041,10 +1037,10 @@ fn set_subagent_task_id(outbound: &mut WsOutbound, task_id: &str) { fn extract_parent_task_id(task: &crate::tools::task::types::TaskSession) -> Option { let parent = &task.parent_session_id; // 仅当父会话是子智能体会话时才提取(格式: "sub:...:task:{uuid}") - if parent.starts_with("sub:") { - if let Some(pos) = parent.find(":task:") { - return Some(parent[pos + 1..].to_string()); // "task:{uuid}" - } + if parent.starts_with("sub:") + && let Some(pos) = parent.find(":task:") + { + return Some(parent[pos + 1..].to_string()); // "task:{uuid}" } None } diff --git a/src/logging.rs b/src/logging.rs index 851fbc9..0332e71 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -3,7 +3,7 @@ use chrono_tz::Tz; use std::path::PathBuf; use tracing_appender::rolling::{RollingFileAppender, Rotation}; use tracing_subscriber::{ - fmt, fmt::time::FormatTime, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer, + EnvFilter, Layer, fmt, fmt::time::FormatTime, layer::SubscriberExt, util::SubscriberInitExt, }; use crate::config::LogFormat; @@ -61,14 +61,14 @@ pub fn init_logging(timezone: Tz, log_format: LogFormat) { let log_dir = get_default_log_dir(); // Create log directory if it doesn't exist - if !log_dir.exists() { - if let Err(e) = std::fs::create_dir_all(&log_dir) { - eprintln!( - "Warning: Failed to create log directory {}: {}", - log_dir.display(), - e - ); - } + if !log_dir.exists() + && let Err(e) = std::fs::create_dir_all(&log_dir) + { + eprintln!( + "Warning: Failed to create log directory {}: {}", + log_dir.display(), + e + ); } // Create file appender with daily rotation diff --git a/src/mcp/client.rs b/src/mcp/client.rs index e800f93..019b6f5 100644 --- a/src/mcp/client.rs +++ b/src/mcp/client.rs @@ -6,9 +6,9 @@ //! - Connects to MCP servers asynchronously //! - Dynamically registers MCP tools via the Tool trait adapter +use parking_lot::Mutex; use std::collections::HashMap; use std::sync::Arc; -use parking_lot::Mutex; use tokio::sync::RwLock; use http::{HeaderName, HeaderValue}; diff --git a/src/mcp/config.rs b/src/mcp/config.rs index b4c21ae..27431fc 100644 --- a/src/mcp/config.rs +++ b/src/mcp/config.rs @@ -102,7 +102,7 @@ impl McpServerConfig { command, args: self.args.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" => { diff --git a/src/mcp/tool_adapter.rs b/src/mcp/tool_adapter.rs index 3943746..9e483c4 100644 --- a/src/mcp/tool_adapter.rs +++ b/src/mcp/tool_adapter.rs @@ -111,25 +111,22 @@ impl PicoBotTool for McpToolWrapper { .call_tool(&self.server_key, &self.tool_name, args); let result = if self.timeout_secs > 0 { - tokio::time::timeout( - std::time::Duration::from_secs(self.timeout_secs), - call, - ) - .await - .map_err(|_| { - tracing::warn!( - server_key = %self.server_key, - tool = %self.tool_name, - timeout_secs = self.timeout_secs, - "MCP tool call timed out" - ); - anyhow::anyhow!( - "MCP tool '{}' on server '{}' timed out after {}s", - self.tool_name, - self.server_key, - self.timeout_secs - ) - })?? + tokio::time::timeout(std::time::Duration::from_secs(self.timeout_secs), call) + .await + .map_err(|_| { + tracing::warn!( + server_key = %self.server_key, + tool = %self.tool_name, + timeout_secs = self.timeout_secs, + "MCP tool call timed out" + ); + anyhow::anyhow!( + "MCP tool '{}' on server '{}' timed out after {}s", + self.tool_name, + self.server_key, + self.timeout_secs + ) + })?? } else { call.await? }; @@ -183,12 +180,8 @@ pub async fn register_mcp_tools( let all_tools = manager.all_tools().await; for (server_key, tool_info) in all_tools { - let wrapper = McpToolWrapper::new( - manager.clone(), - server_key.clone(), - tool_info, - timeout_secs, - ); + let wrapper = + McpToolWrapper::new(manager.clone(), server_key.clone(), tool_info, timeout_secs); tracing::info!( name = %wrapper.name(), diff --git a/src/observability/metrics.rs b/src/observability/metrics.rs index 4d0008a..5b8f225 100644 --- a/src/observability/metrics.rs +++ b/src/observability/metrics.rs @@ -40,7 +40,8 @@ pub const MESSAGE_PROCESSING_ERRORS: &str = "picobot_message_processing_errors_t /// 幂等:首次调用安装 recorder 并缓存 handle;后续调用(含热重启)返回缓存的 handle。 /// 这避免了热重启后 `install_recorder()` 因 recorder 已安装而失败、导致 `/metrics` 返回 503 的问题。 /// 返回 None 表示安装失败(非致命,metrics 静默降级)。 -static PROMETHEUS_HANDLE: std::sync::OnceLock> = std::sync::OnceLock::new(); +static PROMETHEUS_HANDLE: std::sync::OnceLock> = + std::sync::OnceLock::new(); pub fn init_recorder() -> Option { PROMETHEUS_HANDLE diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 0e9fcff..f6e87c7 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -342,7 +342,7 @@ pub fn home_dir() -> Option { // Windows: support USERPROFILE env::var_os("USERPROFILE").map(PathBuf::from) }) - .or_else(|| dirs::home_dir()) + .or_else(dirs::home_dir) } /// 返回 PicoBot 主目录,若无法确定则回退到当前目录 `"."`。 diff --git a/src/providers/anthropic.rs b/src/providers/anthropic.rs index 67e01e5..59cd021 100644 --- a/src/providers/anthropic.rs +++ b/src/providers/anthropic.rs @@ -125,7 +125,6 @@ pub struct AnthropicProvider { api_key: String, base_url: String, extra_headers: HashMap, - #[cfg_attr(not(debug_assertions), allow(dead_code))] llm_timeout_secs: u64, model_id: String, temperature: Option, @@ -316,15 +315,15 @@ impl LLMProvider for AnthropicProvider { req_builder = req_builder.header(key.as_str(), value.as_str()); } - let resp = req_builder.json(&body).send().await.map_err(|e| { + let resp = req_builder.json(&body).send().await.inspect_err(|e| { tracing::error!( provider = %self.name, model = %self.model_id, url = %url, - error = %format_error_chain(&e), + timeout_secs = self.llm_timeout_secs, + error = %format_error_chain(e), "Anthropic: HTTP request failed" ); - e })?; let status = resp.status(); let text = resp.text().await?; @@ -635,7 +634,7 @@ mod tests { #[test] 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); assert_eq!(chain, "single error"); } @@ -649,7 +648,7 @@ mod tests { #[test] 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 chain = format_error_chain(&outer); assert!(chain.contains("outer wrapper")); diff --git a/src/providers/openai.rs b/src/providers/openai.rs index d17b7d8..ce53bc4 100644 --- a/src/providers/openai.rs +++ b/src/providers/openai.rs @@ -63,22 +63,19 @@ impl StreamingAccumulator { name: Option<&str>, arguments: Option<&str>, ) { - let entry = self - .tool_calls - .entry(index) - .or_insert_with(StreamingToolCall::default); + let entry = self.tool_calls.entry(index).or_default(); // 只在 id 非空时才更新,防止流式响应中后续 chunk 的空 id 覆盖之前的值 - if let Some(id) = id { - if !id.is_empty() { - entry.id = id.to_string(); - } + if let Some(id) = id + && !id.is_empty() + { + entry.id = id.to_string(); } // 只在 name 非空时才更新,防止流式响应中后续 chunk 的 None 覆盖之前的值 - if let Some(name) = name { - if !name.is_empty() { - entry.name = name.to_string(); - } + if let Some(name) = name + && !name.is_empty() + { + entry.name = name.to_string(); } if let Some(args) = arguments { entry.arguments.push_str(args); @@ -107,8 +104,8 @@ impl StreamingAccumulator { .into_iter() .filter(|(_, call)| !call.id.is_empty() && !call.name.is_empty()) .map(|(_, call)| { - let arguments = serde_json::from_str(&call.arguments) - .unwrap_or_else(|_| serde_json::Value::Null); + let arguments = + serde_json::from_str(&call.arguments).unwrap_or(serde_json::Value::Null); ToolCall { id: call.id, name: call.name, @@ -218,14 +215,12 @@ fn convert_content_blocks( } // 如果只有一个文本块且没有通知,返回字符串形式 - if converted_blocks.len() == 1 { - if let Some(block) = converted_blocks.first() { - if block.get("type").and_then(|t| t.as_str()) == Some("text") { - if let Some(text) = block.get("text").and_then(|t| t.as_str()) { - return Value::String(text.to_string()); - } - } - } + if converted_blocks.len() == 1 + && let Some(block) = converted_blocks.first() + && block.get("type").and_then(|t| t.as_str()) == Some("text") + && let Some(text) = block.get("text").and_then(|t| t.as_str()) + { + return Value::String(text.to_string()); } return Value::Array(converted_blocks); @@ -233,10 +228,10 @@ fn convert_content_blocks( } // 原有逻辑 - 模型支持图片,正常转换 - if blocks.len() == 1 { - if let ContentBlock::Text { text } = &blocks[0] { - return Value::String(text.clone()); - } + if blocks.len() == 1 + && let ContentBlock::Text { text } = &blocks[0] + { + return Value::String(text.clone()); } Value::Array( blocks @@ -481,14 +476,12 @@ impl OpenAIProvider { } // 提取流式末帧的 usage(stream_options.include_usage=true 时返回) - if let Some(usage_val) = json.get("usage") { - if !usage_val.is_null() { - if let Ok(u) = - serde_json::from_value::(usage_val.clone()) - { - accumulator.set_usage(u); - } - } + if let Some(usage_val) = json.get("usage") + && !usage_val.is_null() + && let Ok(u) = + serde_json::from_value::(usage_val.clone()) + { + accumulator.set_usage(u); } // 提取 choices @@ -605,13 +598,11 @@ impl OpenAIProvider { } // 提取流式末帧的 usage(与主循环一致) - if let Some(usage_val) = json.get("usage") { - if !usage_val.is_null() { - if let Ok(u) = serde_json::from_value::(usage_val.clone()) - { - accumulator.set_usage(u); - } - } + if let Some(usage_val) = json.get("usage") + && !usage_val.is_null() + && let Ok(u) = serde_json::from_value::(usage_val.clone()) + { + accumulator.set_usage(u); } if let Some(choices) = json.get("choices").and_then(|c| c.as_array()) { @@ -684,53 +675,54 @@ impl OpenAIProvider { // 回退:当流式解析未获取到任何内容且无 tool call 时, // 服务器可能返回的是非 SSE 格式的纯 JSON,尝试直接反序列化整个响应体 - if response.content.is_empty() && response.tool_calls.is_empty() { - if let Ok(openai_resp) = serde_json::from_str::(&raw_body) { - let fallback_content = openai_resp + if response.content.is_empty() + && response.tool_calls.is_empty() + && let Ok(openai_resp) = serde_json::from_str::(&raw_body) + { + let fallback_content = openai_resp + .choices + .first() + .and_then(|c| c.message.content.as_deref()) + .unwrap_or("") + .to_string(); + if !fallback_content.is_empty() { + tracing::debug!( + model = %self.model_id, + "Streaming accumulator empty, falling back to non-SSE JSON parsing" + ); + response.content = fallback_content; + response.reasoning_content = openai_resp .choices .first() - .and_then(|c| c.message.content.as_deref()) - .unwrap_or("") - .to_string(); - if !fallback_content.is_empty() { - tracing::debug!( - model = %self.model_id, - "Streaming accumulator empty, falling back to non-SSE JSON parsing" - ); - response.content = fallback_content; - response.reasoning_content = openai_resp - .choices - .first() - .and_then(|c| c.message.reasoning_content.clone()); - response.tool_calls = openai_resp - .choices - .first() - .map(|c| { - c.message - .tool_calls - .iter() - .map(|tc| ToolCall { - id: tc.id.clone(), - name: tc.function.name.clone(), - arguments: match &tc.function.arguments { - OAIFunctionArguments::Json(args) => args.clone(), - OAIFunctionArguments::String(args) => { - serde_json::from_str(args) - .unwrap_or(serde_json::Value::Null) - } - }, - }) - .collect() - }) - .unwrap_or_default(); - // 回退场景下也从非流式响应提取 usage - response.usage = Usage { - prompt_tokens: openai_resp.usage.prompt_tokens, - completion_tokens: openai_resp.usage.completion_tokens, - total_tokens: openai_resp.usage.total_tokens, - cached_tokens: openai_resp.usage.cached_tokens(), - }; - } + .and_then(|c| c.message.reasoning_content.clone()); + response.tool_calls = openai_resp + .choices + .first() + .map(|c| { + c.message + .tool_calls + .iter() + .map(|tc| ToolCall { + id: tc.id.clone(), + name: tc.function.name.clone(), + arguments: match &tc.function.arguments { + OAIFunctionArguments::Json(args) => args.clone(), + OAIFunctionArguments::String(args) => { + serde_json::from_str(args) + .unwrap_or(serde_json::Value::Null) + } + }, + }) + .collect() + }) + .unwrap_or_default(); + // 回退场景下也从非流式响应提取 usage + response.usage = Usage { + prompt_tokens: openai_resp.usage.prompt_tokens, + completion_tokens: openai_resp.usage.completion_tokens, + total_tokens: openai_resp.usage.total_tokens, + cached_tokens: openai_resp.usage.cached_tokens(), + }; } } @@ -761,26 +753,25 @@ impl OpenAIProvider { std::collections::HashSet::new(); for (i, m) in request.messages.iter().enumerate().rev() { - if m.role == "tool" { - if let Some(ref tc_id) = m.tool_call_id { - resolved_tool_ids.insert(tc_id.as_str()); - } + if m.role == "tool" + && let Some(ref tc_id) = m.tool_call_id + { + resolved_tool_ids.insert(tc_id.as_str()); } - if m.role == "assistant" { - if let Some(ref calls) = m.tool_calls { - if !calls.is_empty() { - let all_resolved = calls - .iter() - .all(|tc| resolved_tool_ids.contains(tc.id.as_str())); - if all_resolved { - for tc in calls { - with_parent.insert(tc.id.as_str()); - } - } else { - skip_assistant_indices.insert(i); - } + if m.role == "assistant" + && let Some(ref calls) = m.tool_calls + && !calls.is_empty() + { + let all_resolved = calls + .iter() + .all(|tc| resolved_tool_ids.contains(tc.id.as_str())); + if all_resolved { + for tc in calls { + with_parent.insert(tc.id.as_str()); } + } else { + skip_assistant_indices.insert(i); } } } @@ -827,36 +818,37 @@ impl OpenAIProvider { } if m.role == "assistant" { - if let Some(ref calls) = m.tool_calls { - if !calls.is_empty() && !skip_assistant_indices.contains(&i) { - pending_tool_ids = calls.iter().map(|tc| tc.id.as_str()).collect(); - pending_assistant_idx = Some(i); - } + if let Some(ref calls) = m.tool_calls + && !calls.is_empty() + && !skip_assistant_indices.contains(&i) + { + pending_tool_ids = calls.iter().map(|tc| tc.id.as_str()).collect(); + pending_assistant_idx = Some(i); } - } else if m.role == "tool" { - if let Some(ref tc_id) = m.tool_call_id { - pending_tool_ids.remove(tc_id.as_str()); - if pending_tool_ids.is_empty() { - pending_assistant_idx = None; - } + } else if m.role == "tool" + && let Some(ref tc_id) = m.tool_call_id + { + pending_tool_ids.remove(tc_id.as_str()); + if pending_tool_ids.is_empty() { + pending_assistant_idx = None; } } } // Handle trailing assistant with unresolved immediate tool results - if !pending_tool_ids.is_empty() { - if let Some(idx) = pending_assistant_idx { - skip_assistant_indices.insert(idx); - tracing::warn!( - message_index = idx, - pending_tool_call_count = pending_tool_ids.len(), - "build_request_body: trailing assistant tool_calls without \ + if !pending_tool_ids.is_empty() + && let Some(idx) = pending_assistant_idx + { + skip_assistant_indices.insert(idx); + tracing::warn!( + message_index = idx, + pending_tool_call_count = pending_tool_ids.len(), + "build_request_body: trailing assistant tool_calls without \ immediately following tool results — stripping tool_calls" - ); - if let Some(calls) = &request.messages[idx].tool_calls { - for tc in calls.iter() { - with_parent.remove(tc.id.as_str()); - } + ); + if let Some(calls) = &request.messages[idx].tool_calls { + for tc in calls.iter() { + with_parent.remove(tc.id.as_str()); } } } @@ -956,11 +948,10 @@ impl OpenAIProvider { "content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i) }); - if m.role == "assistant" { - if let Some(reasoning_content) = &m.reasoning_content { + if m.role == "assistant" + && let Some(reasoning_content) = &m.reasoning_content { message["reasoning_content"] = Value::String(reasoning_content.clone()); } - } Some(message) } @@ -1150,15 +1141,14 @@ impl LLMProvider for OpenAIProvider { for (i, msg) in msgs.iter().enumerate() { if let Some(content) = msg.get("content").and_then(|c| c.as_array()) { for (j, item) in content.iter().enumerate() { - if item.get("type").and_then(|t| t.as_str()) == Some("image_url") { - if let Some(url_str) = item + if item.get("type").and_then(|t| t.as_str()) == Some("image_url") + && let Some(url_str) = item .get("image_url") .and_then(|u| u.get("url")) .and_then(|v| v.as_str()) - { - let prefix: String = url_str.chars().take(20).collect(); - tracing::debug!(msg_idx = i, item_idx = j, image_prefix = %prefix, image_url_len = %url_str.len(), "Image in LLM request (first 20 bytes shown)"); - } + { + let prefix: String = url_str.chars().take(20).collect(); + tracing::debug!(msg_idx = i, item_idx = j, image_prefix = %prefix, image_url_len = %url_str.len(), "Image in LLM request (first 20 bytes shown)"); } } } diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs index 4bdfac9..9738262 100644 --- a/src/scheduler/mod.rs +++ b/src/scheduler/mod.rs @@ -734,13 +734,13 @@ impl RuntimeJob { return Ok(()); } - if let Some(max_runs) = self.max_runs { - if self.run_count >= max_runs { - self.state = SchedulerJobState::Completed; - self.next_fire_at = None; - self.completed_at = Some(now.timestamp_millis()); - return Ok(()); - } + if let Some(max_runs) = self.max_runs + && self.run_count >= max_runs + { + self.state = SchedulerJobState::Completed; + self.next_fire_at = None; + self.completed_at = Some(now.timestamp_millis()); + return Ok(()); } let reference_ms = self.next_fire_at.or(self.last_fired_at); diff --git a/src/skills/mod.rs b/src/skills/mod.rs index 48d0303..3b28137 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -1,13 +1,13 @@ use crate::platform::{ 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_json::json; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; -use parking_lot::RwLock; #[cfg(test)] static SKILL_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); @@ -143,9 +143,7 @@ impl SkillRuntime { } pub fn is_empty(&self) -> bool { - self.catalog - .read() - .is_empty() + self.catalog.read().is_empty() } pub fn len(&self) -> usize { @@ -153,9 +151,7 @@ impl SkillRuntime { } pub fn system_index_prompt(&self) -> Option { - self.catalog - .read() - .system_index_prompt() + self.catalog.read().system_index_prompt() } /// 按白/黑名单过滤后的技能索引。供专家/子代理按 `CapabilityPolicy` 过滤技能可见性。 @@ -170,34 +166,23 @@ impl SkillRuntime { } pub fn discovery_event_payload(&self) -> serde_json::Value { - self.catalog - .read() - .discovery_event_payload() + self.catalog.read().discovery_event_payload() } pub fn offered_event_payload(&self) -> serde_json::Value { - self.catalog - .read() - .offered_event_payload() + self.catalog.read().offered_event_payload() } pub fn activation_payload(&self, name: &str) -> Result { - self.catalog - .read() - .activation_payload(name) + self.catalog.read().activation_payload(name) } pub fn activation_event_payload(&self, name: &str) -> Result { - self.catalog - .read() - .activation_event_payload(name) + self.catalog.read().activation_event_payload(name) } pub fn list_skills(&self) -> Vec { - self.catalog - .read() - .skills - .clone() + self.catalog.read().skills.clone() } /// 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 { - self.catalog - .read() - .find_skill(name) - .cloned() + self.catalog.read().find_skill(name).cloned() } pub fn create_skill( @@ -450,7 +432,7 @@ impl SkillCatalog { // Load from least specific to most specific so later sources win on conflicts. for source in source_order(&config.sources) { sources_seen += 1; - let root = source_root(&source, &cwd); + let root = source_root(&source, cwd); let Some(root) = root else { continue }; for skill in load_skills_from_root(&root, source.clone()) { @@ -519,7 +501,7 @@ impl SkillCatalog { .filter(|s| { allowed_set .as_ref() - .map_or(true, |set| set.contains(s.name.as_str())) + .is_none_or(|set| set.contains(s.name.as_str())) }) .collect(); diff --git a/src/storage/migrations.rs b/src/storage/migrations.rs index 659942c..13e33b2 100644 --- a/src/storage/migrations.rs +++ b/src/storage/migrations.rs @@ -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 - 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 { tracing::info!("Adding created_by_message_id column to todos table"); conn.execute( diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 8360b6f..32f8f08 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -574,9 +574,7 @@ impl SessionStore { let mut stmt = conn.prepare( "SELECT id, provider, model FROM topics WHERE provider IS NOT NULL OR model IS NOT NULL", )?; - let rows = stmt.query_map([], |row| { - Ok((row.get(0)?, row.get(1)?, row.get(2)?)) - })?; + let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?; let mut result = Vec::new(); for row in rows { result.push(row?); @@ -1027,7 +1025,7 @@ impl SessionStore { new_messages.iter().partition(|m| { m.system_context .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%')。 diff --git a/src/storage/records.rs b/src/storage/records.rs index c9900f1..3a95a02 100644 --- a/src/storage/records.rs +++ b/src/storage/records.rs @@ -186,7 +186,9 @@ pub struct MemoryUpsert { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] +#[derive(Default)] pub enum SchedulerJobState { + #[default] Scheduled, Running, Paused, @@ -241,12 +243,6 @@ impl SchedulerJobStatus { } } -impl Default for SchedulerJobState { - fn default() -> Self { - Self::Scheduled - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SchedulerJobRecord { pub id: String, diff --git a/src/tools/bash.rs b/src/tools/bash.rs index 98d07f6..7b07ff2 100644 --- a/src/tools/bash.rs +++ b/src/tools/bash.rs @@ -92,7 +92,7 @@ impl ShellKind { let info = self.to_info(); info.args .iter() - .map(|s| *s) + .copied() .chain(std::iter::once(command)) .collect() } @@ -385,7 +385,7 @@ impl Tool for BashTool { let cwd = self .working_dir .as_ref() - .map(|d| Path::new(d)) + .map(Path::new) .unwrap_or_else(|| Path::new(".")); match self @@ -629,7 +629,7 @@ fn format_command_output(stdout: &str, stderr: &str, exit_code: Option) -> if !stderr.trim().is_empty() { if !output.is_empty() { - output.push_str("\n"); + output.push('\n'); } output.push_str("STDERR:\n"); output.push_str(stderr); diff --git a/src/tools/calculator.rs b/src/tools/calculator.rs index 3a9ad33..7950ac8 100644 --- a/src/tools/calculator.rs +++ b/src/tools/calculator.rs @@ -432,7 +432,9 @@ fn calc_evaluate(args: &serde_json::Value) -> Result { // 表达式可产生非有限结果(如 "1/0" → inf、"0/0" → NaN), // 与 extract_values/extract_f64 的边界策略保持一致:拒绝输出。 if !n.is_finite() { - return Err(format!("Expression result is not a finite number: {expression}")); + return Err(format!( + "Expression result is not a finite number: {expression}" + )); } Ok(format_num(n)) }) @@ -873,10 +875,7 @@ mod tests { .await .unwrap(); assert!(ok.success); - assert_eq!( - ok.output, - "295232799039604140847618609643520000000" - ); + assert_eq!(ok.output, "295232799039604140847618609643520000000"); } #[tokio::test] diff --git a/src/tools/file_write.rs b/src/tools/file_write.rs index df8cf09..ed05d7f 100644 --- a/src/tools/file_write.rs +++ b/src/tools/file_write.rs @@ -140,16 +140,15 @@ impl Tool for FileWriteTool { }; // Create parent directories if needed - if let Some(parent) = resolved.parent() { - if !parent.exists() { - if let Err(e) = std::fs::create_dir_all(parent) { - return Ok(ToolResult { - success: false, - output: String::new(), - error: Some(format!("Failed to create parent directory: {}", e)), - }); - } - } + if let Some(parent) = resolved.parent() + && !parent.exists() + && let Err(e) = std::fs::create_dir_all(parent) + { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Failed to create parent directory: {}", e)), + }); } match std::fs::write(&resolved, content) { diff --git a/src/tools/http_request.rs b/src/tools/http_request.rs index f484b8f..e0cc1cc 100644 --- a/src/tools/http_request.rs +++ b/src/tools/http_request.rs @@ -76,12 +76,11 @@ impl HttpRequestTool { if let Some(obj) = headers.as_object() { for (key, value) in obj { - if let Some(str_val) = value.as_str() { - if let Ok(name) = reqwest::header::HeaderName::from_bytes(key.as_bytes()) { - if let Ok(val) = reqwest::header::HeaderValue::from_str(str_val) { - header_map.insert(name, val); - } - } + if let Some(str_val) = value.as_str() + && let Ok(name) = reqwest::header::HeaderName::from_bytes(key.as_bytes()) + && let Ok(val) = reqwest::header::HeaderValue::from_str(str_val) + { + header_map.insert(name, val); } } } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index c874e88..de2752f 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -55,11 +55,8 @@ pub fn extract_string(args: &serde_json::Value, key: &str) -> Option { args.get(key).and_then(|v| { if let Some(s) = v.as_str() { 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 { - None + v.as_number().map(|n| n.to_string()) } }) } diff --git a/src/tools/registry.rs b/src/tools/registry.rs index e4b93db..0a0d3ca 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -1,6 +1,6 @@ +use parking_lot::RwLock; use std::collections::HashMap; use std::sync::Arc; -use parking_lot::RwLock; use crate::domain::tools::{Tool, ToolFunction}; @@ -24,20 +24,13 @@ impl ToolRegistry { } pub fn get(&self, name: &str) -> Option> { - self.tools - .read() - .get(name) - .cloned() + self.tools.read().get(name).cloned() } /// Get all registered tools. /// Used for concurrent tool execution when we need to look up tools by name. pub fn get_all(&self) -> Vec> { - self.tools - .read() - .values() - .cloned() - .collect() + self.tools.read().values().cloned().collect() } pub fn get_definitions(&self) -> Vec { @@ -56,18 +49,11 @@ impl ToolRegistry { } pub fn has_tools(&self) -> bool { - !self - .tools - .read() - .is_empty() + !self.tools.read().is_empty() } pub fn tool_names(&self) -> Vec { - self.tools - .read() - .keys() - .cloned() - .collect() + self.tools.read().keys().cloned().collect() } /// 创建一个排除指定工具的新 registry 副本 @@ -80,9 +66,7 @@ impl ToolRegistry { .map(|(k, v)| (k.clone(), v.clone())) .collect(); let new_registry = ToolRegistry::new(); - *new_registry - .tools - .write() = filtered; + *new_registry.tools.write() = filtered; new_registry } @@ -97,9 +81,7 @@ impl ToolRegistry { .map(|(k, v)| (k.clone(), v.clone())) .collect(); let new_registry = ToolRegistry::new(); - *new_registry - .tools - .write() = filtered; + *new_registry.tools.write() = filtered; new_registry } } diff --git a/src/tools/scheduler_manage.rs b/src/tools/scheduler_manage.rs index 4141621..3967252 100644 --- a/src/tools/scheduler_manage.rs +++ b/src/tools/scheduler_manage.rs @@ -338,30 +338,28 @@ fn enrich_target_from_context( _ => return target, }; - if !has_non_empty_string(&object, "channel") { - if let Some(channel_name) = context + if !has_non_empty_string(&object, "channel") + && let Some(channel_name) = context .channel_name .as_ref() .filter(|value| !value.trim().is_empty()) - { - object.insert( - "channel".to_string(), - serde_json::Value::String(channel_name.clone()), - ); - } + { + object.insert( + "channel".to_string(), + serde_json::Value::String(channel_name.clone()), + ); } - if !has_non_empty_string(&object, "chat_id") { - if let Some(chat_id) = context + if !has_non_empty_string(&object, "chat_id") + && let Some(chat_id) = context .chat_id .as_ref() .filter(|value| !value.trim().is_empty()) - { - object.insert( - "chat_id".to_string(), - serde_json::Value::String(chat_id.clone()), - ); - } + { + object.insert( + "chat_id".to_string(), + serde_json::Value::String(chat_id.clone()), + ); } serde_json::Value::Object(object) diff --git a/src/tools/schema.rs b/src/tools/schema.rs index 89649f1..015d7eb 100644 --- a/src/tools/schema.rs +++ b/src/tools/schema.rs @@ -114,10 +114,11 @@ impl SchemaCleanr { anyhow::bail!("Schema missing required 'type' field"); } - if let Some(Value::String(t)) = obj.get("type") { - if t == "object" && !obj.contains_key("properties") { - tracing::warn!("Object schema without 'properties' field may cause issues"); - } + if let Some(Value::String(t)) = obj.get("type") + && t == "object" + && !obj.contains_key("properties") + { + tracing::warn!("Object schema without 'properties' field may cause issues"); } Ok(()) @@ -173,10 +174,10 @@ impl SchemaCleanr { } // Handle anyOf/oneOf simplification - if obj.contains_key("anyOf") || obj.contains_key("oneOf") { - if let Some(simplified) = Self::try_simplify_union(&obj, defs, strategy, ref_stack) { - return simplified; - } + if (obj.contains_key("anyOf") || obj.contains_key("oneOf")) + && let Some(simplified) = Self::try_simplify_union(&obj, defs, strategy, ref_stack) + { + return simplified; } // Build cleaned object @@ -244,13 +245,13 @@ impl SchemaCleanr { return Self::preserve_meta(obj, Value::Object(Map::new())); } - if let Some(def_name) = Self::parse_local_ref(ref_value) { - if let Some(definition) = defs.get(def_name.as_str()) { - ref_stack.insert(ref_value.to_string()); - let cleaned = Self::clean_with_defs(definition.clone(), defs, strategy, ref_stack); - ref_stack.remove(ref_value); - return Self::preserve_meta(obj, cleaned); - } + if let Some(def_name) = Self::parse_local_ref(ref_value) + && let Some(definition) = defs.get(def_name.as_str()) + { + ref_stack.insert(ref_value.to_string()); + let cleaned = Self::clean_with_defs(definition.clone(), defs, strategy, ref_stack); + ref_stack.remove(ref_value); + return Self::preserve_meta(obj, cleaned); } tracing::warn!("Cannot resolve $ref: {}", ref_value); @@ -342,15 +343,16 @@ impl SchemaCleanr { if let Some(Value::Null) = obj.get("const") { return true; } - if let Some(Value::Array(arr)) = obj.get("enum") { - if arr.len() == 1 && matches!(arr[0], Value::Null) { - return true; - } + if let Some(Value::Array(arr)) = obj.get("enum") + && arr.len() == 1 + && matches!(arr[0], Value::Null) + { + return true; } - if let Some(Value::String(t)) = obj.get("type") { - if t == "null" { - return true; - } + if let Some(Value::String(t)) = obj.get("type") + && t == "null" + { + return true; } } false diff --git a/src/tools/skill_manage.rs b/src/tools/skill_manage.rs index 4bcbd6d..92476bf 100644 --- a/src/tools/skill_manage.rs +++ b/src/tools/skill_manage.rs @@ -211,10 +211,8 @@ impl Tool for SkillManageTool { Err(err) => return Ok(error_result(&err)), } } - if reload { - if let Err(err) = self.skills.reload() { - return Ok(error_result(&err)); - } + if reload && let Err(err) = self.skills.reload() { + return Ok(error_result(&err)); } json!({ diff --git a/src/tools/task/runtime.rs b/src/tools/task/runtime.rs index da65a71..8f9652f 100644 --- a/src/tools/task/runtime.rs +++ b/src/tools/task/runtime.rs @@ -1,8 +1,8 @@ +use parking_lot::RwLock; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; -use parking_lot::RwLock; use std::time::Duration; use async_trait::async_trait; @@ -535,7 +535,11 @@ impl DefaultSubAgentRuntime { let inherited = session .parent_topic_id .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(|| { self.model_selections .as_ref() @@ -877,10 +881,10 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { // 2. 校验父智能体的子代理策略(白/黑名单),再查找子代理定义。 // 与 find_subagent_def 的"def 不可用即拒绝"安全范式一致:策略不通过即拒绝, // 防止 LLM 通过选择被禁子代理绕过限制。 - if let Some(cap) = &parent_context.parent_capability { - if let Err(msg) = cap.check_subagent_allowed(&task.subagent_type.name) { - return Err(TaskError::InvalidArguments(msg)); - } + if let Some(cap) = &parent_context.parent_capability + && let Err(msg) = cap.check_subagent_allowed(&task.subagent_type.name) + { + return Err(TaskError::InvalidArguments(msg)); } // 3. 查找子代理定义 @@ -1097,8 +1101,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { .unwrap_or_default(), }; let _ = sub_done_sender.send(result).await; - let _ = - store.update_pending_subagent_status(&task_id_for_spawn, "failed"); + let _ = store.update_pending_subagent_status(&task_id_for_spawn, "failed"); // _registry_guard drop 时清理 registry 条目 return; } @@ -1140,11 +1143,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { String::new(), "cancelled".to_string(), ), - Err(e) => ( - SubagentStatus::Failed, - String::new(), - e.to_string(), - ), + Err(e) => (SubagentStatus::Failed, String::new(), e.to_string()), }; // 查询同 topic 下仍未完成的子代理列表 @@ -1181,7 +1180,8 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { SubagentStatus::Timeout => "timeout", 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!( error = %e, 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 { 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 条目 @@ -1236,7 +1242,9 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { // ===== 同步路径(子代理嵌套或无 sub_done_sender) ===== // 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 { Ok(tool_result) => { @@ -1303,10 +1311,10 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { // 4.1 校验父智能体的子代理策略(白/黑名单)。 // 安全要求:与 spawn 一致,防止 resume 绕过白名单。若用户切换到不允许 // 该子代理的专家,resume 应失败(与 def 被删除即失败的安全语义一致)。 - if let Some(cap) = &parent_context.parent_capability { - if let Err(msg) = cap.check_subagent_allowed(&session.subagent_type) { - return Err(TaskError::InvalidArguments(msg)); - } + if let Some(cap) = &parent_context.parent_capability + && let Err(msg) = cap.check_subagent_allowed(&session.subagent_type) + { + return Err(TaskError::InvalidArguments(msg)); } // 4.2 重新解析 def 以应用工具过滤。 @@ -1406,10 +1414,11 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { // token 不在 registry 中(可能已完成但 DB 状态未更新,或进程重启后丢失) // 不变量 1:条件 UPDATE,仅在 status='running' 时转为 cancelled, // 避免 spawn 已完成的终态被覆盖(completed → cancelled 是非法转换) - match self - .store - .try_update_pending_subagent_status(&record.task_id, "running", "cancelled") - { + match self.store.try_update_pending_subagent_status( + &record.task_id, + "running", + "cancelled", + ) { Ok(true) => { tracing::info!( task_id = %record.task_id, @@ -1753,24 +1762,15 @@ impl SubagentRuntime { /// (生产环境两者相同,但测试场景使用临时目录时必须用 `self.cwd`)。 pub fn reload(&self) -> Result<(), String> { let new_catalog = SubagentCatalog::discover_with_cwd(&self.config, &self.cwd); - let mut guard = self - .catalog - .write() -; + let mut guard = self.catalog.write(); *guard = new_catalog; Ok(()) } /// 列出所有子代理(含禁用项),带 disabled_in_scopes pub fn list_with_status(&self) -> Vec { - let state = self - .disable_state - .read() - ; - let catalog = self - .catalog - .read() -; + let state = self.disable_state.read(); + let catalog = self.catalog.read(); let mut items: Vec = catalog .all() .iter() @@ -1794,14 +1794,8 @@ impl SubagentRuntime { /// 可用子代理名称(过滤禁用项) pub fn available_names(&self) -> Vec { - let state = self - .disable_state - .read() - ; - let catalog = self - .catalog - .read() -; + let state = self.disable_state.read(); + let catalog = self.catalog.read(); catalog .names() .into_iter() @@ -1811,30 +1805,17 @@ impl SubagentRuntime { /// 查找可用子代理(过滤禁用项) pub fn find_available(&self, name: &str) -> Option { - let state = self - .disable_state - .read() - ; + let state = self.disable_state.read(); if state.is_disabled(name) { return None; } - self.catalog - .read() - - .find(name) - .cloned() + self.catalog.read().find(name).cloned() } /// 生成过滤后的系统索引提示词 pub fn system_index_prompt_filtered(&self) -> Option { - let state = self - .disable_state - .read() - ; - let catalog = self - .catalog - .read() -; + let state = self.disable_state.read(); + let catalog = self.catalog.read(); let available_defs: Vec<&SubagentDef> = catalog .all() .into_iter() @@ -1872,14 +1853,8 @@ impl SubagentRuntime { allowed: Option<&[String]>, denied: &[String], ) -> Option { - let state = self - .disable_state - .read() - ; - let catalog = self - .catalog - .read() -; + let state = self.disable_state.read(); + let catalog = self.catalog.read(); let available_defs: Vec<&SubagentDef> = catalog .all() .into_iter() @@ -1942,13 +1917,7 @@ impl SubagentRuntime { enabled: bool, ) -> Result { // 校验子代理存在 - if self - .catalog - .read() - - .find(name) - .is_none() - { + if self.catalog.read().find(name).is_none() { return Err(format!("subagent '{}' not found", name)); } @@ -1969,10 +1938,7 @@ impl SubagentRuntime { // 更新内存中的 disable_state { - let mut state = self - .disable_state - .write() - ; + let mut state = self.disable_state.write(); match scope { SubagentScope::User => { if enabled { @@ -1992,10 +1958,7 @@ impl SubagentRuntime { } // 计算新的 disabled_in_scopes - let state = self - .disable_state - .read() - ; + let state = self.disable_state.read(); let disabled_in_scopes = state.disabled_scopes_for(name); Ok(SubagentAvailabilityChange { @@ -2023,10 +1986,7 @@ impl SubagentRuntime { reload: bool, ) -> Result { let def = { - let catalog = self - .catalog - .read() - ; + let catalog = self.catalog.read(); catalog .find(name) .ok_or_else(|| format!("subagent '{}' not found", name))? @@ -2089,10 +2049,7 @@ impl SubagentRuntime { ) -> Result { validate_subagent_name(name)?; { - let catalog = self - .catalog - .read() - ; + let catalog = self.catalog.read(); if catalog.find(name).is_some() { return Err(format!("subagent '{}' already exists", name)); } @@ -2136,17 +2093,10 @@ impl SubagentRuntime { /// 对齐 `ExpertRuntime::delete_expert`。 /// - builtin 子代理(path 为 None)禁止删除。 /// - 仅当目录内除 SUBAGENT.md 外无其他文件时才删除目录,避免误删用户附件。 - pub fn delete_subagent( - &self, - name: &str, - reload: bool, - ) -> Result { + pub fn delete_subagent(&self, name: &str, reload: bool) -> Result { validate_subagent_name(name)?; let path = { - let catalog = self - .catalog - .read() - ; + let catalog = self.catalog.read(); let def = catalog .find(name) .ok_or_else(|| format!("subagent '{}' not found", name))?; @@ -2201,11 +2151,7 @@ fn validate_subagent_name(name: &str) -> Result<(), String> { /// 获取指定 scope 下某子代理的 SUBAGENT.md 路径。 /// 对齐 `expert_file_path`。 -fn subagent_file_path( - scope: SubagentScope, - name: &str, - cwd: &Path, -) -> Result { +fn subagent_file_path(scope: SubagentScope, name: &str, cwd: &Path) -> Result { let root = match scope { SubagentScope::User => dirs::home_dir() .map(|p| p.join(".picobot").join("subagents")) @@ -2632,7 +2578,7 @@ mod tests { // 禁用后 prompt 不应包含 general(无可用子代理时返回 None) let prompt = runtime.system_index_prompt_filtered(); - assert!(prompt.map_or(true, |p| !p.contains("general"))); + assert!(prompt.is_none_or(|p| !p.contains("general"))); } #[test] @@ -3142,10 +3088,7 @@ mod tests { let item = items.iter().find(|i| i.name == "demo-create").unwrap(); assert_eq!(item.description, "demo create agent"); assert_eq!(item.body.as_deref(), Some("demo body content")); - assert_eq!( - item.capability.denied_skills, - vec!["skill_x".to_string()] - ); + assert_eq!(item.capability.denied_skills, vec!["skill_x".to_string()]); } #[test] @@ -3288,7 +3231,8 @@ mod tests { "directory should be preserved when it has other files" ); assert!( - !temp.path() + !temp + .path() .join(".picobot") .join("subagents") .join("mixed") diff --git a/src/tools/task/tool.rs b/src/tools/task/tool.rs index ef4f3cf..eacea7c 100644 --- a/src/tools/task/tool.rs +++ b/src/tools/task/tool.rs @@ -103,7 +103,7 @@ impl Tool for TaskTool { // 2. 验证描述长度 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 { success: false, output: String::new(), @@ -136,17 +136,17 @@ impl Tool for TaskTool { // 4. 深度校验(仅对嵌套场景生效,None = 不限制) // Some(N) 表示允许最多 N 层嵌套:depth=1 的 agent 可创建 depth=2,但 depth=2 不能再创建 - if let Some(max_depth) = self.max_nesting_depth { - if context.nesting_depth > max_depth { - return Ok(ToolResult { - success: false, - output: String::new(), - error: Some(format!( - "Cannot create nested subagent: max nesting depth ({}) reached", - max_depth - )), - }); - } + if let Some(max_depth) = self.max_nesting_depth + && context.nesting_depth > max_depth + { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "Cannot create nested subagent: max nesting depth ({}) reached", + max_depth + )), + }); } // 5. 执行任务 diff --git a/src/tools/task/types.rs b/src/tools/task/types.rs index f3fbcaf..796642f 100644 --- a/src/tools/task/types.rs +++ b/src/tools/task/types.rs @@ -8,8 +8,10 @@ use crate::utils::current_timestamp; /// 子代理会话状态 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] +#[derive(Default)] pub enum TaskSessionState { /// 正在执行 + #[default] Running, /// 已完成 Completed, @@ -23,12 +25,6 @@ pub enum TaskSessionState { Unknown, } -impl Default for TaskSessionState { - fn default() -> Self { - Self::Running - } -} - impl TaskSessionState { pub fn as_str(&self) -> &'static str { match self { diff --git a/src/tools/todo_read.rs b/src/tools/todo_read.rs index 7623826..112692b 100644 --- a/src/tools/todo_read.rs +++ b/src/tools/todo_read.rs @@ -88,10 +88,10 @@ impl Tool for TodoReadTool { // 2. 读锁查内存 { let guard = self.state.read().await; - if let Some(items) = guard.get(&scope_key) { - if !items.is_empty() { - return Ok(success_result(items, &scope_key, "memory")); - } + if let Some(items) = guard.get(&scope_key) + && !items.is_empty() + { + return Ok(success_result(items, &scope_key, "memory")); } } diff --git a/src/tools/traits.rs b/src/tools/traits.rs index af98442..19551fe 100644 --- a/src/tools/traits.rs +++ b/src/tools/traits.rs @@ -1,5 +1,5 @@ -use std::time::Duration; use std::sync::Arc; +use std::time::Duration; use async_trait::async_trait; use tokio::sync::{mpsc, watch}; @@ -58,11 +58,7 @@ pub trait WaitCoordinator: Send + Sync + 'static { /// select! 立即返回 `WaitEvent::Cancelled`,并完成完整的状态清理 ///(重获取锁、回填 guard、清除 is_waiting、归还 receiver)。 /// 为 None 时退化为不检查取消(向后兼容,子代理场景)。 - async fn wait( - &self, - timeout: Duration, - cancel_rx: Option>, - ) -> WaitEvent; + async fn wait(&self, timeout: Duration, cancel_rx: Option>) -> WaitEvent; } #[derive(Clone, Default)] diff --git a/src/tools/wait_tool.rs b/src/tools/wait_tool.rs index 54eca29..b89063f 100644 --- a/src/tools/wait_tool.rs +++ b/src/tools/wait_tool.rs @@ -156,9 +156,7 @@ impl Tool for WaitForSubagentsTool { // 传入 cancel_rx 使 /stop 命令能立即中断等待。 // coordinator 在 select! 中以 biased 优先级处理: // 子代理结果 > 用户消息 > 取消信号 > 超时 - let event = coordinator - .wait(timeout, context.cancel_rx.clone()) - .await; + let event = coordinator.wait(timeout, context.cancel_rx.clone()).await; // 5. 格式化返回结果 let output = match event { diff --git a/tests/test_request_format.rs b/tests/test_request_format.rs index 3045f8d..eac4cb8 100644 --- a/tests/test_request_format.rs +++ b/tests/test_request_format.rs @@ -18,7 +18,7 @@ fn test_message_special_characters() { /// Test that multi-line system prompt is preserved #[test] fn test_multiline_system_prompt() { - let messages = vec![ + let messages = [ Message::system( "You are a helpful assistant.\n\nFollow these rules:\n1. Be kind\n2. Be accurate", ),