修复: extract_key_info 截断标记统一 + 断路器 key 滑动窗口 + 桌面跨端 4 路由 + dead_code 预留标 allow
This commit is contained in:
@@ -196,12 +196,21 @@ pub const STALL_BREAKER_GOAL_REMIND: bool = true;
|
||||
///
|
||||
/// 背景:agent 无止损,某工具反复同类失败(权限拒绝/路径错误等)仍每轮重试,
|
||||
/// 耗尽 max_iterations 前 0 产出。机制(非 prompt 教 AI):每轮 process_tool_calls
|
||||
/// 后取末尾连续 Tool 消息,失败内容前 40 字符归一为 key 计数,同一 key 累计达此阈值 →
|
||||
/// 后取末尾连续 Tool 消息,失败包按结构化信号(tool_name + 错误类别)归一为 key,
|
||||
/// 入滚动窗口(CIRCUIT_BREAKER_WINDOW)统计,同一 key 在窗口内累计达此阈值 →
|
||||
/// guard.reset + emit AiError + return 强制熔断,逼用户换思路或人工介入。
|
||||
///
|
||||
/// 阈值 3:同类失败 3 次足以判死循环(去重后仍累加,不同错误各自计数互不干扰)。
|
||||
pub const CIRCUIT_BREAKER_THRESHOLD: u32 = 3;
|
||||
|
||||
/// L1 断路器滑动窗口大小:仅保留最近 N 条失败记录参与计数。
|
||||
///
|
||||
/// 治"长任务偶发失败误熔断":全 loop 累加会让早期偶发失败与后期同类失败叠加触发。
|
||||
/// 滚动窗口让计数只反映最近的失败密度——长任务中途偶发 1~2 次同类失败不触发,
|
||||
/// 真正的连续死循环(N 条全同 key)才触发。N=20:对齐 max_iterations 量级,既覆盖
|
||||
/// 单轮并行失败爆发,又足够长以容忍偶发抖动。
|
||||
pub const CIRCUIT_BREAKER_WINDOW: usize = 20;
|
||||
|
||||
/// L1 断路器总开关(默认 true)。false → 跳过断路器检查,降级为纯 max_iterations
|
||||
/// 旧行为(排障/对比/临时关闭用)。机制优先 prompt 说教,每改配开关 + 兜底(关降级旧行为)。
|
||||
pub const CIRCUIT_BREAKER_ENABLED: bool = true;
|
||||
@@ -828,11 +837,13 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常
|
||||
let mut converged = false;
|
||||
|
||||
// L1 断路器:连续同类工具失败计数器(key=失败内容前 40 字符,value=累计次数)。
|
||||
// loop 生命周期内累加,每轮 process_tool_calls 后检查。达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。
|
||||
let mut fail_counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
|
||||
// L1 断路器:连续同类工具失败滚动窗口(VecDeque<key>,长度封顶 CIRCUIT_BREAKER_WINDOW)。
|
||||
// key=结构化信号(tool_name + 错误类别)。每轮 process_tool_calls 后追加末尾失败 key,
|
||||
// 超窗自动淘汰最旧。窗口内同 key 计数达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。
|
||||
// 治"全 loop 累加不衰减":早期偶发失败不会与后期叠加误熔断。
|
||||
let mut fail_window: std::collections::VecDeque<String> = std::collections::VecDeque::new();
|
||||
|
||||
// G2 探索熔断:连续空结果无进展计数器(loop 生命周期累计,与 fail_counts 同生命周期)。
|
||||
// G2 探索熔断:连续空结果无进展计数器(loop 生命周期累计,与 fail_window 同生命周期)。
|
||||
// 每轮 process_tool_calls 后取末尾连续 Tool 消息判 is_empty_tool_result,全空 stall_count+=1,
|
||||
// 任一非空重置 0。达 STALL_BREAKER_THRESHOLD → 警示/熔断(治 R4 游荡死循环 + token 失控)。
|
||||
let mut stall_count: u32 = 0;
|
||||
@@ -1548,9 +1559,9 @@ pub(crate) async fn run_agentic_loop(
|
||||
);
|
||||
|
||||
// L1 断路器:连续同类工具失败熔断(治 agent 无止损死循环,机制非 prompt 说教)。
|
||||
// count_recent_failures 读末尾连续 Tool 消息,失败内容前 40 字符归一 key 计数。
|
||||
// count_recent_failures 读末尾连续 Tool 消息,失败包按结构化信号归一 key 入滚动窗口计数。
|
||||
if CIRCUIT_BREAKER_ENABLED {
|
||||
let (max_count, sample_key) = count_recent_failures(&session_arc, &conv_id, &mut fail_counts).await;
|
||||
let (max_count, sample_key) = count_recent_failures(&session_arc, &conv_id, &mut fail_window).await;
|
||||
// 锁已随作用域 drop,可安全 await/emit(避免持锁 await 死锁)。
|
||||
if max_count >= CIRCUIT_BREAKER_THRESHOLD {
|
||||
tracing::warn!(
|
||||
@@ -1694,12 +1705,21 @@ async fn heartbeat_loop(
|
||||
}
|
||||
|
||||
// ── count_recent_failures: L1 断路器失败计数(扁平抽自原嵌套 5 层块) ──
|
||||
// 读末尾连续 Tool 消息,失败内容前 40 字符归一 key 累入 fail_counts。
|
||||
// 返回 (max_count, sample_key):max_count=0 表示无失败,不触发熔断。
|
||||
// 读末尾连续 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<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
fail_counts: &mut std::collections::HashMap<String, u32>,
|
||||
fail_window: &mut std::collections::VecDeque<String>,
|
||||
) -> (u32, String) {
|
||||
let messages = {
|
||||
let session = session_arc.lock().await;
|
||||
@@ -1708,23 +1728,95 @@ async fn count_recent_failures(
|
||||
None => Vec::new(),
|
||||
}
|
||||
};
|
||||
// 末尾连续 Tool 消息(本轮工具回填结果)。
|
||||
let recent_tool_results: Vec<&ChatMessage> = messages
|
||||
.iter().rev()
|
||||
.take_while(|m| matches!(m.role, MessageRole::Tool))
|
||||
.collect();
|
||||
for m in recent_tool_results {
|
||||
if is_failure_content(&m.content) {
|
||||
let key: String = m.content.chars().take(40).collect();
|
||||
*fail_counts.entry(key).or_insert(0) += 1;
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fail_counts
|
||||
.iter()
|
||||
.max_by_key(|(_, &v)| v)
|
||||
.map(|(k, &v)| (v, k.clone()))
|
||||
// 本轮失败的归一 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 风格简单 FNV-1a 哈希避免裸截内容前缀(前 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::<serde_json::Value>(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)
|
||||
}
|
||||
|
||||
/// FNV-1a 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
|
||||
}
|
||||
|
||||
// ── is_failure_content: 纯结构化判定(只读字段,绝不解析内容文本) ──
|
||||
// 原理:工具成败是执行层的结构化事实(exit_code / status),断路器只读字段。内容文本(无论含
|
||||
// error/失败/任何词)绝不参与判定 —— 这样读含 error 字样的代码、搜"失败"的结果等成功工具内容
|
||||
|
||||
Reference in New Issue
Block a user