From 04fc2c0710401e3baf00e539fbd31262cf994aad Mon Sep 17 00:00:00 2001 From: ooodc <549496103@qq.com> Date: Tue, 28 Apr 2026 18:10:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=AE=B0=E5=BF=86?= =?UTF-8?q?=E7=BB=B4=E6=8A=A4=E7=9A=84=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BC=98=E5=8C=96=E4=BC=A0=E8=BE=93?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=9A=84=E4=B8=8A=E4=B8=8B=E6=96=87=E4=BF=A1?= =?UTF-8?q?=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- src/gateway/memory_maintenance.rs | 18 ++++- src/gateway/session.rs | 118 ++++++++++++++++++++++++++++++ src/providers/openai.rs | 37 +++++++++- 3 files changed, 170 insertions(+), 3 deletions(-) diff --git a/src/gateway/memory_maintenance.rs b/src/gateway/memory_maintenance.rs index 78c9508..5c8b3d3 100644 --- a/src/gateway/memory_maintenance.rs +++ b/src/gateway/memory_maintenance.rs @@ -238,8 +238,18 @@ impl MemoryMaintenanceService { let mut results = Vec::new(); for scope_key in scope_keys { - let Some(output) = self.run_for_scope(&scope_key).await? else { - continue; + let output = match self.run_for_scope(&scope_key).await { + Ok(Some(output)) => output, + Ok(None) => continue, + Err(error) if is_recoverable_maintenance_scope_error(&error) => { + tracing::warn!( + scope_key = %scope_key, + error = %error, + "Memory maintenance skipped scope after recoverable model failure" + ); + continue; + } + Err(error) => return Err(error), }; results.push(MemoryMaintenanceScopeResult { scope_key, output }); @@ -319,6 +329,10 @@ pub(crate) fn is_recoverable_maintenance_llm_error(error: &str) -> bool { || normalized.contains("timeout") } +fn is_recoverable_maintenance_scope_error(error: &AgentError) -> bool { + is_recoverable_maintenance_llm_error(&error.to_string()) +} + pub(crate) fn strip_json_code_fence(content: &str) -> &str { let trimmed = content.trim(); if let Some(rest) = trimmed.strip_prefix("```json") { diff --git a/src/gateway/session.rs b/src/gateway/session.rs index 6da2d00..e059ba1 100644 --- a/src/gateway/session.rs +++ b/src/gateway/session.rs @@ -1059,6 +1059,67 @@ mod tests { ); } + #[tokio::test] + async fn test_summarize_memory_maintenance_transport_error_includes_provider_context() { + let provider_config = LLMProviderConfig { + provider_type: "openai".to_string(), + name: "maintenance-provider".to_string(), + base_url: "https://example.invalid/v1".to_string(), + api_key: "test-key".to_string(), + extra_headers: HashMap::new(), + model_id: "maintenance-model".to_string(), + temperature: Some(0.0), + max_tokens: Some(256), + context_window_tokens: None, + model_extra: HashMap::new(), + max_tool_iterations: 1, + llm_timeout_secs: 1, + tool_result_max_chars: 20_000, + context_tool_result_trim_chars: 20_000, + }; + + let session_manager = SessionManager::new( + 4, + 100, + false, + "Asia/Shanghai".to_string(), + provider_config.clone(), + HashMap::from([("default".to_string(), provider_config)]), + Arc::new(SkillRuntime::default()), + ) + .unwrap(); + + session_manager + .store() + .put_memory(&crate::storage::MemoryUpsert { + scope_kind: "user".to_string(), + scope_key: "feishu:user-1".to_string(), + namespace: "profile".to_string(), + memory_key: "work".to_string(), + content: "用户在做AI产品".to_string(), + source_type: "message".to_string(), + source_session_id: None, + source_message_id: None, + source_message_seq: None, + source_channel_name: None, + source_chat_id: None, + }) + .unwrap(); + + let error = session_manager + .summarize_memory_maintenance_for_scope("feishu:user-1") + .await + .unwrap_err() + .to_string(); + + assert!(error.contains("memory maintenance model error: transport error:")); + assert!(error.contains("provider=maintenance-provider")); + assert!(error.contains("model=maintenance-model")); + assert!(error.contains("url=https://example.invalid/v1/chat/completions")); + assert!(error.contains("timeout_secs=1")); + assert!(error.contains("error sending request for url")); + } + #[tokio::test] async fn test_summarize_memory_maintenance_retries_recoverable_provider_errors() { let mock_response_content = serde_json::to_string(&json!({ @@ -1249,6 +1310,63 @@ mod tests { assert!(results.is_empty()); } + #[tokio::test] + async fn test_run_memory_maintenance_for_all_scopes_skips_recoverable_transport_failures() { + let provider_config = LLMProviderConfig { + provider_type: "openai".to_string(), + name: "maintenance-provider".to_string(), + base_url: "https://example.invalid/v1".to_string(), + api_key: "test-key".to_string(), + extra_headers: HashMap::new(), + model_id: "maintenance-model".to_string(), + temperature: Some(0.0), + max_tokens: Some(256), + context_window_tokens: None, + model_extra: HashMap::new(), + max_tool_iterations: 1, + llm_timeout_secs: 1, + tool_result_max_chars: 20_000, + context_tool_result_trim_chars: 20_000, + }; + + let session_manager = SessionManager::new( + 4, + 100, + false, + "Asia/Shanghai".to_string(), + provider_config.clone(), + HashMap::from([("default".to_string(), provider_config)]), + Arc::new(SkillRuntime::default()), + ) + .unwrap(); + + for scope_key in ["feishu:user-1", "feishu:user-2"] { + session_manager + .store() + .put_memory(&crate::storage::MemoryUpsert { + scope_kind: "user".to_string(), + scope_key: scope_key.to_string(), + namespace: "profile".to_string(), + memory_key: "work".to_string(), + content: format!("{} 在做AI产品", scope_key), + source_type: "message".to_string(), + source_session_id: None, + source_message_id: None, + source_message_seq: None, + source_channel_name: None, + source_chat_id: None, + }) + .unwrap(); + } + + let results = session_manager + .run_memory_maintenance_for_all_scopes(None) + .await + .unwrap(); + + assert!(results.is_empty()); + } + #[test] fn test_apply_memory_maintenance_output_merges_and_deletes_low_value_records() { let store = SessionStore::in_memory().unwrap(); diff --git a/src/providers/openai.rs b/src/providers/openai.rs index fa1329a..cb30668 100644 --- a/src/providers/openai.rs +++ b/src/providers/openai.rs @@ -23,6 +23,23 @@ fn format_error_chain(error: &(dyn std::error::Error + 'static)) -> String { details.join("\ncaused by: ") } +fn format_transport_error_context( + provider_name: &str, + model_id: &str, + url: &str, + timeout_secs: u64, + error: &(dyn std::error::Error + 'static), +) -> String { + format!( + "transport error: provider={} model={} url={} timeout_secs={} details={}", + provider_name, + model_id, + url, + timeout_secs, + format_error_chain(error) + ) +} + fn convert_content_blocks(blocks: &[ContentBlock]) -> Value { if blocks.len() == 1 { if let ContentBlock::Text { text } = &blocks[0] { @@ -294,7 +311,25 @@ impl LLMProvider for OpenAIProvider { req_builder = req_builder.header(key.as_str(), value.as_str()); } - let resp = req_builder.json(&body).send().await?; + let resp = req_builder.json(&body).send().await.map_err(|err| { + let error_context = format_transport_error_context( + &self.name, + &self.model_id, + &url, + self.llm_timeout_secs, + &err, + ); + tracing::error!( + provider = %self.name, + model = %self.model_id, + url = %url, + base_url = %self.base_url, + timeout_secs = self.llm_timeout_secs, + error = %error_context, + "OpenAI-compatible API transport request failed" + ); + error_context + })?; let status = resp.status(); let text = resp.text().await?;