feat: 添加记忆维护的错误处理逻辑,优化传输错误的上下文信息

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
ooodc 2026-04-28 18:10:23 +08:00
parent 891830779f
commit 04fc2c0710
3 changed files with 170 additions and 3 deletions

View File

@ -238,8 +238,18 @@ impl MemoryMaintenanceService {
let mut results = Vec::new(); let mut results = Vec::new();
for scope_key in scope_keys { for scope_key in scope_keys {
let Some(output) = self.run_for_scope(&scope_key).await? else { let output = match self.run_for_scope(&scope_key).await {
continue; 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 }); results.push(MemoryMaintenanceScopeResult { scope_key, output });
@ -319,6 +329,10 @@ pub(crate) fn is_recoverable_maintenance_llm_error(error: &str) -> bool {
|| normalized.contains("timeout") || 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 { pub(crate) fn strip_json_code_fence(content: &str) -> &str {
let trimmed = content.trim(); let trimmed = content.trim();
if let Some(rest) = trimmed.strip_prefix("```json") { if let Some(rest) = trimmed.strip_prefix("```json") {

View File

@ -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] #[tokio::test]
async fn test_summarize_memory_maintenance_retries_recoverable_provider_errors() { async fn test_summarize_memory_maintenance_retries_recoverable_provider_errors() {
let mock_response_content = serde_json::to_string(&json!({ let mock_response_content = serde_json::to_string(&json!({
@ -1249,6 +1310,63 @@ mod tests {
assert!(results.is_empty()); 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] #[test]
fn test_apply_memory_maintenance_output_merges_and_deletes_low_value_records() { fn test_apply_memory_maintenance_output_merges_and_deletes_low_value_records() {
let store = SessionStore::in_memory().unwrap(); let store = SessionStore::in_memory().unwrap();

View File

@ -23,6 +23,23 @@ fn format_error_chain(error: &(dyn std::error::Error + 'static)) -> String {
details.join("\ncaused by: ") 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 { fn convert_content_blocks(blocks: &[ContentBlock]) -> Value {
if blocks.len() == 1 { if blocks.len() == 1 {
if let ContentBlock::Text { text } = &blocks[0] { 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()); 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 status = resp.status();
let text = resp.text().await?; let text = resp.text().await?;