From 47a75a4d49a20e2b37f7b02a07ffaecd2e5f614e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Thu, 1 Oct 2026 02:57:08 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E9=87=87=E6=A0=B7=E4=B8=8E=E5=81=9C=E6=BB=9E=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E7=BA=AF=E5=87=BD=E6=95=B0=E6=8F=90=E5=8F=96=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 循环健康判定函数群外移,行为零变化 --- src-tauri/src/commands/ai/agentic/mod.rs | 284 +---------------- .../src/commands/ai/agentic/stall_detector.rs | 289 ++++++++++++++++++ 2 files changed, 295 insertions(+), 278 deletions(-) create mode 100644 src-tauri/src/commands/ai/agentic/stall_detector.rs diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 8ed8eb3..5245562 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -14,10 +14,7 @@ use df_ai::context::ContextManager; // 改进3 B: 压缩失败兜底关键词摘要(纯函数 extract_keyword_summary)。 // 改进4: 工具结果 view-only 摘要(should_summarize_tool_result / extract_key_info)。 // extract_keyword_summary 随压缩逻辑迁至 context_lifecycle.rs(改进3 B 关键词兑底)。 -use df_ai::context_helpers::{ - extract_key_info, should_summarize_tool_result, - PLACEHOLDER_INTEGRITY_ENABLED, -}; +use df_ai::context_helpers::PLACEHOLDER_INTEGRITY_ENABLED; // 改进2 B:意图收敛工具(LLM 可见 tool_defs 按 intent 过滤,执行路径仍走完整 registry) // B 路线 Phase 1:plan_hint 接入主 loop——filter_tool_defs_planned 在 filter_tool_defs // 收敛的扁平子集之上叠加 plan_hint 编排(并行组同批聚拢/顺序依赖源在前),供 LLM 看到 @@ -359,7 +356,12 @@ pub mod conv_state; pub mod command_lock; pub(crate) mod helpers; pub(crate) mod router_setup; +pub(crate) mod stall_detector; pub(crate) use router_setup::resolve_model_with_override; +pub(crate) use stall_detector::{ + check_retry_exhausted, count_recent_failures, last_round_has_match_evidence, + summarize_tool_results, +}; pub(crate) use helpers::try_continue_agent_loop; pub(crate) use helpers::infer_goal_from_tool_calls; pub(crate) use helpers::{ @@ -911,37 +913,6 @@ async fn stream_round( } } -// ── check_retry_exhausted: 判断 InitFailed Retryable 是否已耗尽重试预算 ── -// 返回 Some(error) 表示已耗尽(调用方应返回 InitFailedExhausted);None 表示可继续重试。 -fn check_retry_exhausted( - conv_id: &str, - candidate: &AiProviderRecord, - retry_attempt: usize, - max_retries: usize, - retry_deadline: tokio::time::Instant, - error: &str, -) -> Option { - let is_last = retry_attempt >= max_retries; - let now = tokio::time::Instant::now(); - let budget_exhausted = now >= retry_deadline; - if !is_last && !budget_exhausted { - return None; - } - // 本 candidate 重试耗尽 / 预算耗尽 → 交外层切下一 candidate。 - // 不在此 emit AiError(可能切下一 candidate 成功,emit 会留残留气泡); - // 携带 error 交外层「全 candidate 耗尽」时 emit 最后一条 AiError。 - let reason = if budget_exhausted { "预算耗尽" } else { "耗尽" }; - tracing::warn!( - conv_id = %conv_id, - provider = %candidate.name, - attempt = retry_attempt + 1, - budget_exhausted = budget_exhausted, - "[ai] 候选 {} 流前失败重试{}({}次),切下一 provider", - candidate.name, reason, max_retries + 1, - ); - Some(error.to_string()) -} - // ── emit_retry_attempt: 退避日志 + emit AiStreamRetry + sleep ── // 前置:未耗尽重试预算(check_retry_exhausted 已返 None)。本函数完成退避后返回。 async fn emit_retry_attempt( @@ -2223,122 +2194,6 @@ async fn heartbeat_loop( } } -// ── count_recent_failures: L1 断路器失败计数(扁平抽自原嵌套 5 层块) ── -// 读末尾连续 Tool 消息,失败包按结构化信号(tool_name + 错误类别)归一 key, -// 追加进滚动窗口 fail_window(超窗 CIRCUIT_BREAKER_WINDOW 淘汰最旧)。 -// 返回 (max_count, sample_key):窗口内同 key 最高频次 + 该 key;max_count=0 表示无失败。 -// -// 两处相对原实现的关键修正(原 key=内容前 40 字符 + 全 loop 累加不衰减): -// 1. key 归一:失败 envelope 内容(不同路径/参数)前 40 字符易把同类失败拆成多 key -// (治不住)或异类失败误聚成一类(误熔断)。改读结构化信号—— -// is_failure_content 已结构化判成败,本函数按 tool_name + 错误类别(exit_code/错误关键词 -// 哈希)归一 key,同一工具的同类错误稳定聚到同一 key。 -// 2. 滚动窗口:全 loop 累加会让早期偶发失败与后期叠加触发误熔断。VecDeque 仅保留最近 N 条, -// 计数只反映最近失败密度——长任务中途偶发 1~2 次不触发,真正连续死循环才触发。 -async fn count_recent_failures( - session_arc: &Arc>, - conv_id: &str, - fail_window: &mut std::collections::VecDeque, -) -> (u32, String) { - // BE2只克隆尾部 BREAKER_TAIL_N 条,避免 all_messages_clone 全量深克隆 - // (长对话每轮 O(n) clone 浪费)。窗口覆盖末尾 Tool 结果与其前驱 assistant tool_calls - // (同轮工具回填通常 1~5 条,40 足富余;超窗工具映射退化为 unknown_tool,仅失败分组更粗,不误熔断)。 - let messages = { - let session = session_arc.lock().await; - match session.conv_read(conv_id) { - Some(conv) => conv.messages.recent_messages(BREAKER_TAIL_N), - None => Vec::new(), - } - }; - // 末尾连续 Tool 消息(本轮工具回填结果)。 - let recent_tool_results: Vec<&ChatMessage> = messages - .iter().rev() - .take_while(|m| matches!(m.role, MessageRole::Tool)) - .collect(); - if recent_tool_results.is_empty() { - return (0u32, String::new()); - } - // tool_call_id → tool_name 反查表:遍历 Assistant 消息的 tool_calls(开销与消息数线性)。 - // tool_result 消息只带 tool_call_id,工具名在其前驱 Assistant 头里。 - let mut id_to_name: std::collections::HashMap<&str, &str> = std::collections::HashMap::new(); - for m in &messages { - if let MessageRole::Assistant = m.role { - if let Some(calls) = m.tool_calls.as_ref() { - for c in calls { - id_to_name.insert(c.id.as_str(), c.function.name.as_str()); - } - } - } - } - // 本轮失败的归一 key 追加进窗口(顺序:消息正序,即工具执行顺序)。 - for m in recent_tool_results.into_iter().rev() { - if is_failure_content(&m.content) { - let tool_name = m - .tool_call_id - .as_deref() - .and_then(|id| id_to_name.get(id).copied()) - .unwrap_or("unknown_tool"); - let key = failure_key(tool_name, &m.content); - fail_window.push_back(key); - while fail_window.len() > CIRCUIT_BREAKER_WINDOW { - fail_window.pop_front(); - } - } - } - // 窗口内同 key 频次最高者 = 熔断候选。 - let mut freq: std::collections::HashMap<&str, u32> = std::collections::HashMap::new(); - for k in fail_window.iter() { - *freq.entry(k.as_str()).or_insert(0) += 1; - } - freq.into_iter() - .max_by_key(|(_, v)| *v) - .map(|(k, v)| (v, k.to_string())) - .unwrap_or((0u32, String::new())) -} - -/// 归一失败 key:tool_name + 错误类别。保证同一工具的同类错误稳定映射到同一 key。 -/// -/// - run_command 等带 exit_code → `tool_name::exit=N`(非零退出码即失败类别)。 -/// - status=failed/error envelope → `tool_name::error:<错误文本稳定哈希>`: -/// 用 fxhash 风格简单 a 哈希避免裸截内容前缀(前 40 字符易把同类失败拆多 key -/// 或异类误聚)。哈希值非敏感信息(仅断路器分组用),无碰撞顾虑(N=20 窗口内几无碰撞)。 -/// - 内容解析失败(非 JSON)→ is_failure_content 已返回 false 不会进本函数, -/// 此处兜底返回 `tool_name::unknown` 防御。 -fn failure_key(tool_name: &str, content: &str) -> String { - let Ok(v) = serde_json::from_str::(content) else { - return format!("{}::unknown", tool_name); - }; - if let Some(exit) = v.get("exit_code").and_then(|x| x.as_i64()) { - return format!("{}::exit={}", tool_name, exit); - } - // 取 error 字段文本做稳定哈希;无 error 字段则按 status 兜底。 - let bucket = match v.get("status").and_then(|s| s.as_str()) { - Some("failed") => "failed", - Some("error") => "error", - _ => "other", - }; - let err_text = v - .get("error") - .and_then(|e| e.as_str()) - .unwrap_or("") - .trim(); - if err_text.is_empty() { - return format!("{}::{}", tool_name, bucket); - } - let h = fnv1a_32(err_text.as_bytes()); - format!("{}::{}:{:08x}", tool_name, bucket, h) -} - -/// a 32-bit 哈希(零依赖,纯函数,断路器 key 归一专用;非密码学用途)。 -fn fnv1a_32(bytes: &[u8]) -> u32 { - let mut hash: u32 = 0x811c9dc5; - for &b in bytes { - hash ^= b as u32; - hash = hash.wrapping_mul(0x0100_0193); - } - hash -} - // ============================================================ // 目标验证续轮(GOAL_VERIFY_ENABLED 门控)——治"过早宣称"的机制化兜底 // ============================================================ @@ -2351,40 +2206,6 @@ fn fnv1a_32(bytes: &[u8]) -> u32 { // 主入口保障:三态兜底(applied 字段缺失/解析异常 → 视为无证据,不误伤也不阻断)、 // 上限熔断(goal_verify_rounds 达 MAX 强制收敛)、调工具重置计数(LLM 在行动非空转)。 -/// 检查本轮(末尾连续 Tool 消息)是否有 applied.matched_target=true 的验证证据。 -/// -/// 读尾部 BREAKER_TAIL_N 条消息,取末尾连续 Tool 消息,解析 content JSON 的 -/// `applied.matched_target` 字段。任一为 true 即返 true(本轮有达成证据)。 -/// 三态兜底:字段缺失/非 JSON/解析异常 → 返 false(无证据),不阻断也不误判。 -async fn last_round_has_match_evidence( - session_arc: &Arc>, - conv_id: &str, -) -> bool { - let messages = { - let session = session_arc.lock().await; - match session.conv_read(conv_id) { - Some(conv) => conv.messages.recent_messages(BREAKER_TAIL_N), - None => Vec::new(), - } - }; - for m in messages.iter().rev() { - if !matches!(m.role, MessageRole::Tool) { - break; - } - let Ok(v) = serde_json::from_str::(&m.content) else { - continue; - }; - if v.get("applied") - .and_then(|a| a.get("matched_target")) - .and_then(|x| x.as_bool()) - .unwrap_or(false) - { - return true; - } - } - false -} - /// 注入目标验证续轮 nudge(强制续轮,非软提示)。 /// /// 文案随 goal_verify_rounds 递进:第 1 次请验证,第 2 次(将达上限)明确最后机会。 @@ -2418,99 +2239,6 @@ async fn insert_goal_verify_nudge( } } -// ── is_failure_content: 纯结构化判定(只读字段,绝不解析内容文本) ── -// 原理:工具成败是执行层的结构化事实(exit_code / status),断路器只读字段。内容文本(无论含 -// error/失败/任何词)绝不参与判定 —— 这样读含 error 字样的代码、搜"失败"的结果等成功工具内容 -// 永远不会被判失败(根除 conv 09e7abfa 的误熔断)。 -// 失败 content 由 audit/mod.rs 包成 JSON envelope {"status":"failed","error":...}; -// 成功 content 是 result JSON(run_command 含 exit_code,其他工具无 status 字段即成功)或 namespace:// 占位符。 -// 非 JSON(namespace 占位符 / 重试 guard skip 纯文本)→ 无结构化失败信号 → 不判(false)。 -fn is_failure_content(content: &str) -> bool { - let Ok(v) = serde_json::from_str::(content) else { - return false; // namespace:// 占位符 / 重试 guard skip 等 → 无结构化失败信号,不判 - }; - if let Some(exit) = v.get("exit_code").and_then(|x| x.as_i64()) { - return exit != 0; // run_command 等:非零退出 = 真失败 - } - matches!(v.get("status").and_then(|s| s.as_str()), Some("failed") | Some("error")) - || v.get("error").is_some() -} - -// ── summarize_tool_results: tool_result view-only 摘要压缩(扁平抽自原嵌套 map 块) ── -// 超阈值的 Tool 消息用 extract_key_info 压缩 content;非 Tool / 未超阈值 原样返回。 -// 仅改入参 Vec(clone),不改 ContextManager 持久化(下次 build_for_request 从 DB 全量重建)。 -fn summarize_tool_results( - messages: Vec, - history_tokens_snapshot: u32, - conv_id: &str, - iteration: usize, -) -> Vec { - let est = TokenEstimator::default(); - let mut compressed_bytes: usize = 0; - let mut original_bytes: usize = 0; - let mut summarized_count: usize = 0; - - // 构建 tool_call_id -> tool_name 映射:role=Tool 消息只带 tool_call_id(调用 ID), - // 真实工具名(read_file/run_command/...)在前序 role=Assistant 消息的 tool_calls[].function.name。 - // 旧实现误把 tool_call_id(如 call_f892)当 tool_name 传给 extract_key_info,导致: - // 1) 摘要头注释显示 call_f892 而非 read_file(显示 bug); - // 2) extract_key_info 的 read_file 豁免永远命中不了(传入的不是真实名)。 - // 用 owned String(非 &str 借用)避开与 messages.into_iter() 的所有权冲突; - // tool_call 数量极少(单轮几次),克隆开销可忽略。 - let mut id_to_name: std::collections::HashMap = std::collections::HashMap::new(); - for m in &messages { - if let Some(calls) = m.tool_calls.as_ref() { - for c in calls { - id_to_name.insert(c.id.clone(), c.function.name.clone()); - } - } - } - - let msgs: Vec = messages - .into_iter() - .map(|mut m| { - if !matches!(m.role, df_ai::provider::MessageRole::Tool) { - return m; - } - let content_len = m.content.len(); - let content_tokens = est.estimate_text(&m.content); - if !should_summarize_tool_result(content_len, history_tokens_snapshot, content_tokens) { - return m; - } - // 解析真实工具名:tool_call_id 查表;查不到(异常/老数据)退化为 "tool"。 - let resolved_name = m - .tool_call_id - .as_ref() - .and_then(|id| id_to_name.get(id)) - .map(|s| s.as_str()) - .unwrap_or("tool"); - // read_file 豁免压缩:AI 定向读代码,压缩 content 阉割代码分析能力。 - // read_file handler 自带 limit 硬上限 2000 行,无爆 prompt 风险。 - if resolved_name == "read_file" { - tracing::debug!( - conv_id = %conv_id, iteration, - content_len, - "[ai] read_file 工具结果豁免压缩(AI 定向读代码)" - ); - return m; - } - original_bytes += content_len; - let compressed = extract_key_info(&m.content, resolved_name); - compressed_bytes += compressed.len(); - summarized_count += 1; - m.content = compressed; - m - }) - .collect(); - if summarized_count > 0 { - tracing::info!( - conv_id = %conv_id, iteration, summarized_count, original_bytes, compressed_bytes, - "[ai] tool_result view-only 摘要压缩(不改持久化)" - ); - } - msgs -} - // ── emit_circuit_breaker_tripped: L1 断路器熔断统一 emit(扁平抽自原嵌套块) ── // 开关 CIRCUIT_BREAKER_HELP_EVENT: true=求助卡(默认), false=AiError 错误气泡(兜底回退)。 fn emit_circuit_breaker_tripped(app_handle: &AppHandle, conv_id: &str, max_count: u32, sample_key: &str) { diff --git a/src-tauri/src/commands/ai/agentic/stall_detector.rs b/src-tauri/src/commands/ai/agentic/stall_detector.rs new file mode 100644 index 0000000..7ef0c32 --- /dev/null +++ b/src-tauri/src/commands/ai/agentic/stall_detector.rs @@ -0,0 +1,289 @@ +//! 失败采样与停滞判定 — 断路器计数/失败归一/目标验证证据/重试耗尽判定(自 agentic/mod.rs 巨石拆解外移) + +use std::sync::Arc; + +use tokio::sync::Mutex; + +use df_ai::context::TokenEstimator; +use df_ai::context_helpers::{extract_key_info, should_summarize_tool_result}; +use df_ai::provider::{ChatMessage, MessageRole}; + +use df_storage::models::AiProviderRecord; + +use crate::commands::ai::AiSession; + +use super::{BREAKER_TAIL_N, CIRCUIT_BREAKER_WINDOW}; + +// ── check_retry_exhausted: 判断 InitFailed Retryable 是否已耗尽重试预算 ── +// 返回 Some(error) 表示已耗尽(调用方应返回 InitFailedExhausted);None 表示可继续重试。 +pub(crate) fn check_retry_exhausted( + conv_id: &str, + candidate: &AiProviderRecord, + retry_attempt: usize, + max_retries: usize, + retry_deadline: tokio::time::Instant, + error: &str, +) -> Option { + let is_last = retry_attempt >= max_retries; + let now = tokio::time::Instant::now(); + let budget_exhausted = now >= retry_deadline; + if !is_last && !budget_exhausted { + return None; + } + // 本 candidate 重试耗尽 / 预算耗尽 → 交外层切下一 candidate。 + // 不在此 emit AiError(可能切下一 candidate 成功,emit 会留残留气泡); + // 携带 error 交外层「全 candidate 耗尽」时 emit 最后一条 AiError。 + let reason = if budget_exhausted { "预算耗尽" } else { "耗尽" }; + tracing::warn!( + conv_id = %conv_id, + provider = %candidate.name, + attempt = retry_attempt + 1, + budget_exhausted = budget_exhausted, + "[ai] 候选 {} 流前失败重试{}({}次),切下一 provider", + candidate.name, reason, max_retries + 1, + ); + Some(error.to_string()) +} + +// ── count_recent_failures: L1 断路器失败计数(扁平抽自原嵌套 5 层块) ── +// 读末尾连续 Tool 消息,失败包按结构化信号(tool_name + 错误类别)归一 key, +// 追加进滚动窗口 fail_window(超窗 CIRCUIT_BREAKER_WINDOW 淘汰最旧)。 +// 返回 (max_count, sample_key):窗口内同 key 最高频次 + 该 key;max_count=0 表示无失败。 +// +// 两处相对原实现的关键修正(原 key=内容前 40 字符 + 全 loop 累加不衰减): +// 1. key 归一:失败 envelope 内容(不同路径/参数)前 40 字符易把同类失败拆成多 key +// (治不住)或异类失败误聚成一类(误熔断)。改读结构化信号—— +// is_failure_content 已结构化判成败,本函数按 tool_name + 错误类别(exit_code/错误关键词 +// 哈希)归一 key,同一工具的同类错误稳定聚到同一 key。 +// 2. 滚动窗口:全 loop 累加会让早期偶发失败与后期叠加触发误熔断。VecDeque 仅保留最近 N 条, +// 计数只反映最近失败密度——长任务中途偶发 1~2 次不触发,真正连续死循环才触发。 +pub(crate) async fn count_recent_failures( + session_arc: &Arc>, + conv_id: &str, + fail_window: &mut std::collections::VecDeque, +) -> (u32, String) { + // BE2只克隆尾部 BREAKER_TAIL_N 条,避免 all_messages_clone 全量深克隆 + // (长对话每轮 O(n) clone 浪费)。窗口覆盖末尾 Tool 结果与其前驱 assistant tool_calls + // (同轮工具回填通常 1~5 条,40 足富余;超窗工具映射退化为 unknown_tool,仅失败分组更粗,不误熔断)。 + let messages = { + let session = session_arc.lock().await; + match session.conv_read(conv_id) { + Some(conv) => conv.messages.recent_messages(BREAKER_TAIL_N), + None => Vec::new(), + } + }; + // 末尾连续 Tool 消息(本轮工具回填结果)。 + let recent_tool_results: Vec<&ChatMessage> = messages + .iter().rev() + .take_while(|m| matches!(m.role, MessageRole::Tool)) + .collect(); + if recent_tool_results.is_empty() { + return (0u32, String::new()); + } + // tool_call_id → tool_name 反查表:遍历 Assistant 消息的 tool_calls(开销与消息数线性)。 + // tool_result 消息只带 tool_call_id,工具名在其前驱 Assistant 头里。 + let mut id_to_name: std::collections::HashMap<&str, &str> = std::collections::HashMap::new(); + for m in &messages { + if let MessageRole::Assistant = m.role { + if let Some(calls) = m.tool_calls.as_ref() { + for c in calls { + id_to_name.insert(c.id.as_str(), c.function.name.as_str()); + } + } + } + } + // 本轮失败的归一 key 追加进窗口(顺序:消息正序,即工具执行顺序)。 + for m in recent_tool_results.into_iter().rev() { + if is_failure_content(&m.content) { + let tool_name = m + .tool_call_id + .as_deref() + .and_then(|id| id_to_name.get(id).copied()) + .unwrap_or("unknown_tool"); + let key = failure_key(tool_name, &m.content); + fail_window.push_back(key); + while fail_window.len() > CIRCUIT_BREAKER_WINDOW { + fail_window.pop_front(); + } + } + } + // 窗口内同 key 频次最高者 = 熔断候选。 + let mut freq: std::collections::HashMap<&str, u32> = std::collections::HashMap::new(); + for k in fail_window.iter() { + *freq.entry(k.as_str()).or_insert(0) += 1; + } + freq.into_iter() + .max_by_key(|(_, v)| *v) + .map(|(k, v)| (v, k.to_string())) + .unwrap_or((0u32, String::new())) +} + +/// 归一失败 key:tool_name + 错误类别。保证同一工具的同类错误稳定映射到同一 key。 +/// +/// - run_command 等带 exit_code → `tool_name::exit=N`(非零退出码即失败类别)。 +/// - status=failed/error envelope → `tool_name::error:<错误文本稳定哈希>`: +/// 用 fxhash 风格简单 a 哈希避免裸截内容前缀(前 40 字符易把同类失败拆多 key +/// 或异类误聚)。哈希值非敏感信息(仅断路器分组用),无碰撞顾虑(N=20 窗口内几无碰撞)。 +/// - 内容解析失败(非 JSON)→ is_failure_content 已返回 false 不会进本函数, +/// 此处兜底返回 `tool_name::unknown` 防御。 +fn failure_key(tool_name: &str, content: &str) -> String { + let Ok(v) = serde_json::from_str::(content) else { + return format!("{}::unknown", tool_name); + }; + if let Some(exit) = v.get("exit_code").and_then(|x| x.as_i64()) { + return format!("{}::exit={}", tool_name, exit); + } + // 取 error 字段文本做稳定哈希;无 error 字段则按 status 兜底。 + let bucket = match v.get("status").and_then(|s| s.as_str()) { + Some("failed") => "failed", + Some("error") => "error", + _ => "other", + }; + let err_text = v + .get("error") + .and_then(|e| e.as_str()) + .unwrap_or("") + .trim(); + if err_text.is_empty() { + return format!("{}::{}", tool_name, bucket); + } + let h = fnv1a_32(err_text.as_bytes()); + format!("{}::{}:{:08x}", tool_name, bucket, h) +} + +/// a 32-bit 哈希(零依赖,纯函数,断路器 key 归一专用;非密码学用途)。 +fn fnv1a_32(bytes: &[u8]) -> u32 { + let mut hash: u32 = 0x811c9dc5; + for &b in bytes { + hash ^= b as u32; + hash = hash.wrapping_mul(0x0100_0193); + } + hash +} + +/// 检查本轮(末尾连续 Tool 消息)是否有 applied.matched_target=true 的验证证据。 +/// +/// 读尾部 BREAKER_TAIL_N 条消息,取末尾连续 Tool 消息,解析 content JSON 的 +/// `applied.matched_target` 字段。任一为 true 即返 true(本轮有达成证据)。 +/// 三态兜底:字段缺失/非 JSON/解析异常 → 返 false(无证据),不阻断也不误判。 +pub(crate) async fn last_round_has_match_evidence( + session_arc: &Arc>, + conv_id: &str, +) -> bool { + let messages = { + let session = session_arc.lock().await; + match session.conv_read(conv_id) { + Some(conv) => conv.messages.recent_messages(BREAKER_TAIL_N), + None => Vec::new(), + } + }; + for m in messages.iter().rev() { + if !matches!(m.role, MessageRole::Tool) { + break; + } + let Ok(v) = serde_json::from_str::(&m.content) else { + continue; + }; + if v.get("applied") + .and_then(|a| a.get("matched_target")) + .and_then(|x| x.as_bool()) + .unwrap_or(false) + { + return true; + } + } + false +} + +// ── is_failure_content: 纯结构化判定(只读字段,绝不解析内容文本) ── +// 原理:工具成败是执行层的结构化事实(exit_code / status),断路器只读字段。内容文本(无论含 +// error/失败/任何词)绝不参与判定 —— 这样读含 error 字样的代码、搜"失败"的结果等成功工具内容 +// 永远不会被判失败(根除 conv 09e7abfa 的误熔断)。 +// 失败 content 由 audit/mod.rs 包成 JSON envelope {"status":"failed","error":...}; +// 成功 content 是 result JSON(run_command 含 exit_code,其他工具无 status 字段即成功)或 namespace:// 占位符。 +// 非 JSON(namespace 占位符 / 重试 guard skip 纯文本)→ 无结构化失败信号 → 不判(false)。 +fn is_failure_content(content: &str) -> bool { + let Ok(v) = serde_json::from_str::(content) else { + return false; // namespace:// 占位符 / 重试 guard skip 等 → 无结构化失败信号,不判 + }; + if let Some(exit) = v.get("exit_code").and_then(|x| x.as_i64()) { + return exit != 0; // run_command 等:非零退出 = 真失败 + } + matches!(v.get("status").and_then(|s| s.as_str()), Some("failed") | Some("error")) + || v.get("error").is_some() +} + +// ── summarize_tool_results: tool_result view-only 摘要压缩(扁平抽自原嵌套 map 块) ── +// 超阈值的 Tool 消息用 extract_key_info 压缩 content;非 Tool / 未超阈值 原样返回。 +// 仅改入参 Vec(clone),不改 ContextManager 持久化(下次 build_for_request 从 DB 全量重建)。 +pub(crate) fn summarize_tool_results( + messages: Vec, + history_tokens_snapshot: u32, + conv_id: &str, + iteration: usize, +) -> Vec { + let est = TokenEstimator::default(); + let mut compressed_bytes: usize = 0; + let mut original_bytes: usize = 0; + let mut summarized_count: usize = 0; + + // 构建 tool_call_id -> tool_name 映射:role=Tool 消息只带 tool_call_id(调用 ID), + // 真实工具名(read_file/run_command/...)在前序 role=Assistant 消息的 tool_calls[].function.name。 + // 旧实现误把 tool_call_id(如 call_f892)当 tool_name 传给 extract_key_info,导致: + // 1) 摘要头注释显示 call_f892 而非 read_file(显示 bug); + // 2) extract_key_info 的 read_file 豁免永远命中不了(传入的不是真实名)。 + // 用 owned String(非 &str 借用)避开与 messages.into_iter() 的所有权冲突; + // tool_call 数量极少(单轮几次),克隆开销可忽略。 + let mut id_to_name: std::collections::HashMap = std::collections::HashMap::new(); + for m in &messages { + if let Some(calls) = m.tool_calls.as_ref() { + for c in calls { + id_to_name.insert(c.id.clone(), c.function.name.clone()); + } + } + } + + let msgs: Vec = messages + .into_iter() + .map(|mut m| { + if !matches!(m.role, df_ai::provider::MessageRole::Tool) { + return m; + } + let content_len = m.content.len(); + let content_tokens = est.estimate_text(&m.content); + if !should_summarize_tool_result(content_len, history_tokens_snapshot, content_tokens) { + return m; + } + // 解析真实工具名:tool_call_id 查表;查不到(异常/老数据)退化为 "tool"。 + let resolved_name = m + .tool_call_id + .as_ref() + .and_then(|id| id_to_name.get(id)) + .map(|s| s.as_str()) + .unwrap_or("tool"); + // read_file 豁免压缩:AI 定向读代码,压缩 content 阉割代码分析能力。 + // read_file handler 自带 limit 硬上限 2000 行,无爆 prompt 风险。 + if resolved_name == "read_file" { + tracing::debug!( + conv_id = %conv_id, iteration, + content_len, + "[ai] read_file 工具结果豁免压缩(AI 定向读代码)" + ); + return m; + } + original_bytes += content_len; + let compressed = extract_key_info(&m.content, resolved_name); + compressed_bytes += compressed.len(); + summarized_count += 1; + m.content = compressed; + m + }) + .collect(); + if summarized_count > 0 { + tracing::info!( + conv_id = %conv_id, iteration, summarized_count, original_bytes, compressed_bytes, + "[ai] tool_result view-only 摘要压缩(不改持久化)" + ); + } + msgs +}