修复: AI 对话/工具可靠性(sanitize 三元组 + G2 签名重复 + handshake 不杀 loop + 空 tool_call id 兜底)
治 5 个对话停止/工具失败根因:sanitize 三元组按 id 配对治 400;G2 探索熔断从结果空 改签名重复判定(治误停正常探索);handshake 删越权强杀活 loop(generating 归 guard 单源); 空 tool_call id 兜底 gen_<index>(治 SenseNova 工具结果路由错位)。
This commit is contained in:
@@ -118,6 +118,29 @@ impl ToolCall {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 解析点统一兜底:tool_call.id 空 → 生成唯一 fallback,非空原样。
|
||||||
|
///
|
||||||
|
/// 根因(实证会话 01f05167 SenseNova flash-lite):某些 provider(SenseNova 兼容缺陷)
|
||||||
|
/// 返回空 `tool_call.id`("")。OpenAI 协议要求 id 唯一。DevFlow 多 tool_call 按 id
|
||||||
|
/// 路由结果,id 空时所有结果落到同一 key(`audit/mod.rs:203` 的 `seen_ids` 去重把空 id
|
||||||
|
/// 视为相同,只留首个 tool_call)→ AI 看到「所有调用同一结果」,工具全失败。
|
||||||
|
///
|
||||||
|
/// 兜底在**解析点**生成 fallback id:raw 非空用 raw,空用 `format!("{prefix}_{index}")`
|
||||||
|
/// (index 取 tool_call 在数组中的位置,保证同 assistant 内多 tool_call id 唯一)。
|
||||||
|
/// 下游(工具执行 / tool 结果回填 tool_call_id)从解析后的 `ToolCall.id` 取,不重复生成,
|
||||||
|
/// 确保 assistant tool_call.id 与 tool 结果 tool_call_id 匹配(防 sanitize 三元组断裂)。
|
||||||
|
///
|
||||||
|
/// 三处解析点共用本 helper(DRY):OpenAI 同步 `parse_tool_calls`(prefix=`gen_tool`)、
|
||||||
|
/// OpenAI 流式 chunk(prefix=`gen_stream`)、Anthropic 同步 + 流式(prefix=`gen_anthropic` /
|
||||||
|
/// `gen_anthropic_stream`)。正常 provider(OpenAI/Claude/GLM id 非空)原样透传零介入。
|
||||||
|
pub fn tool_call_id_or_fallback(raw: &str, index: usize, prefix: &str) -> String {
|
||||||
|
if !raw.is_empty() {
|
||||||
|
raw.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{prefix}_{index}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// LLM Provider trait
|
/// LLM Provider trait
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait LlmProvider: Send + Sync {
|
pub trait LlmProvider: Send + Sync {
|
||||||
@@ -435,4 +458,37 @@ mod tests {
|
|||||||
let deserialized: CompletionResponse = serde_json::from_str(&json).unwrap();
|
let deserialized: CompletionResponse = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(deserialized.reasoning_content, Some("r1 thought".to_string()));
|
assert_eq!(deserialized.reasoning_content, Some("r1 thought".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CR-空 id:tool_call_id_or_fallback 共享 helper —— 空 raw → fallback,非空原样。
|
||||||
|
#[test]
|
||||||
|
fn tool_call_id_or_fallback_non_empty_passthrough() {
|
||||||
|
assert_eq!(tool_call_id_or_fallback("call_abc", 0, "gen_tool"), "call_abc");
|
||||||
|
assert_eq!(tool_call_id_or_fallback("x", 5, "p"), "x");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_call_id_or_fallback_empty_generates_with_index() {
|
||||||
|
assert_eq!(tool_call_id_or_fallback("", 0, "gen_tool"), "gen_tool_0");
|
||||||
|
assert_eq!(tool_call_id_or_fallback("", 1, "gen_tool"), "gen_tool_1");
|
||||||
|
assert_eq!(tool_call_id_or_fallback("", 7, "gen_stream"), "gen_stream_7");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_call_id_or_fallback_empty_unique_per_index() {
|
||||||
|
// 同 prefix 不同 index → 不同 fallback(保证同 assistant 多 tool_call id 唯一)
|
||||||
|
let ids: Vec<String> = (0..3).map(|i| tool_call_id_or_fallback("", i, "gen_tool")).collect();
|
||||||
|
let mut sorted = ids.clone();
|
||||||
|
sorted.sort();
|
||||||
|
sorted.dedup();
|
||||||
|
assert_eq!(ids.len(), sorted.len(), "各 index fallback 应唯一: {:?}", ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_call_id_or_fallback_prefix_distinguishes_sources() {
|
||||||
|
// 不同 prefix 区分来源(同步 gen_tool / 流式 gen_stream / anthropic gen_anthropic)
|
||||||
|
assert_ne!(
|
||||||
|
tool_call_id_or_fallback("", 0, "gen_tool"),
|
||||||
|
tool_call_id_or_fallback("", 0, "gen_stream")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use std::time::Duration;
|
|||||||
use tracing::{debug, error, warn};
|
use tracing::{debug, error, warn};
|
||||||
|
|
||||||
use crate::provider::{
|
use crate::provider::{
|
||||||
CompletionRequest, CompletionResponse, LlmProvider, MessageRole,
|
tool_call_id_or_fallback, CompletionRequest, CompletionResponse, LlmProvider, MessageRole,
|
||||||
StreamResult, TokenUsage, ToolCall,
|
StreamResult, TokenUsage, ToolCall,
|
||||||
};
|
};
|
||||||
// ChatMessage 仅单测构造 CompletionRequest 用,避免非 test 构建的 unused import 警告。
|
// ChatMessage 仅单测构造 CompletionRequest 用,避免非 test 构建的 unused import 警告。
|
||||||
@@ -549,6 +549,9 @@ impl LlmProvider for AnthropicCompatProvider {
|
|||||||
// content 块中拼接 text,收集 tool_use
|
// content 块中拼接 text,收集 tool_use
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
let mut tool_calls: Vec<ToolCall> = Vec::new();
|
let mut tool_calls: Vec<ToolCall> = Vec::new();
|
||||||
|
// CR-空 id:按 tool_use 块在数组中的顺序计数(仅 tool_use 递增),用于 fallback index。
|
||||||
|
// 用独立计数器而非 for enumerate,避免 text/unknown 块占用 index 致 fallback 编号跳号。
|
||||||
|
let mut tool_use_idx: usize = 0;
|
||||||
for block in resp.content {
|
for block in resp.content {
|
||||||
match block.block_type.as_str() {
|
match block.block_type.as_str() {
|
||||||
"text" => {
|
"text" => {
|
||||||
@@ -557,16 +560,20 @@ impl LlmProvider for AnthropicCompatProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"tool_use" => {
|
"tool_use" => {
|
||||||
let id = match block.id {
|
// CR-空 id:原逻辑空 id 直接 continue 跳过整个块(丢工具调用)。
|
||||||
Some(id) if !id.is_empty() => id,
|
// 改为兜底:id 非空原样,空 → `gen_anthropic_{idx}` fallback(DRY 共用
|
||||||
_ => {
|
// tool_call_id_or_fallback)。Anthropic 一般非空,此为兼容缺陷兜底。
|
||||||
|
// 不再 warn+continue(continue 会丢工具调用致 LLM 拿不到结果)。
|
||||||
|
let raw_id = block.id.unwrap_or_default();
|
||||||
|
let id = tool_call_id_or_fallback(&raw_id, tool_use_idx, "gen_anthropic");
|
||||||
|
if raw_id.is_empty() {
|
||||||
warn!(
|
warn!(
|
||||||
|
fallback_id = %id,
|
||||||
name = ?block.name,
|
name = ?block.name,
|
||||||
"Anthropic tool_use 块缺少 id,已跳过(空 id 会回传空 tool_use_id 触发 500)"
|
"Anthropic tool_use 块 id 为空,已生成 fallback id(原 continue 跳过会丢工具调用)"
|
||||||
);
|
);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
};
|
tool_use_idx += 1;
|
||||||
let name = block.name.unwrap_or_default();
|
let name = block.name.unwrap_or_default();
|
||||||
let args = block
|
let args = block
|
||||||
.input
|
.input
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{error, warn};
|
use tracing::{error, warn};
|
||||||
|
|
||||||
use crate::provider::{StreamChunk, TokenUsage, ToolCallDelta};
|
use crate::provider::{tool_call_id_or_fallback, StreamChunk, TokenUsage, ToolCallDelta};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Anthropic API 请求/响应结构体
|
// Anthropic API 请求/响应结构体
|
||||||
@@ -174,15 +174,16 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
|||||||
if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
|
if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
|
||||||
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
|
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
|
||||||
let name = cb.get("name").and_then(|t| t.as_str()).map(|s| s.to_string());
|
let name = cb.get("name").and_then(|t| t.as_str()).map(|s| s.to_string());
|
||||||
// id 缺失时用占位 id 兜底:流式后续 input_json_delta 按 index 累加,
|
// CR-空 id:id 缺失/空时用 fallback 兜底(流式后续 input_json_delta 按 index 累加,
|
||||||
// 中途无法整体跳过;占位 id 保证回传的 tool_use_id 非空,避免 GLM 500。
|
// 中途无法整体跳过)。与同步路径 + OpenAI 路径共用 tool_call_id_or_fallback(DRY),
|
||||||
let id = match cb.get("id").and_then(|t| t.as_str()).map(|s| s.to_string()) {
|
// prefix=`gen_anthropic_stream` 区分来源。非空原样。
|
||||||
Some(id) if !id.is_empty() => Some(id),
|
let raw_id = cb.get("id").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
_ => {
|
let id = if raw_id.is_empty() {
|
||||||
let placeholder = format!("tool_missing_{}", idx);
|
let fallback = tool_call_id_or_fallback(raw_id, idx as usize, "gen_anthropic_stream");
|
||||||
warn!(%placeholder, name = ?name, "Anthropic 流式 tool_use 块缺少 id,已填占位 id(原样回传会触发 GLM 500)");
|
warn!(%fallback, name = ?name, "Anthropic 流式 tool_use 块 id 为空,已生成 fallback id(原样回传会触发 GLM 500)");
|
||||||
Some(placeholder)
|
Some(fallback)
|
||||||
}
|
} else {
|
||||||
|
Some(raw_id.to_string())
|
||||||
};
|
};
|
||||||
return StreamChunk {
|
return StreamChunk {
|
||||||
delta: String::new(),
|
delta: String::new(),
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ use reqwest::Client;
|
|||||||
use tracing::{debug, error, warn};
|
use tracing::{debug, error, warn};
|
||||||
|
|
||||||
use crate::provider::{
|
use crate::provider::{
|
||||||
CompletionRequest, CompletionResponse, LlmProvider, StreamResult, TokenUsage, ToolCall,
|
tool_call_id_or_fallback, CompletionRequest, CompletionResponse, LlmProvider, StreamResult,
|
||||||
|
TokenUsage, ToolCall,
|
||||||
};
|
};
|
||||||
// ChatMessage 仅单测构造 CompletionRequest 用,避免非 test 构建的 unused import 警告。
|
// ChatMessage 仅单测构造 CompletionRequest 用,避免非 test 构建的 unused import 警告。
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -182,30 +183,25 @@ impl OpenAICompatProvider {
|
|||||||
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
|
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
|
||||||
Self::ensure_leading_user(&mut messages);
|
Self::ensure_leading_user(&mut messages);
|
||||||
|
|
||||||
// 治 DeepSeek 400「insufficient tool messages」:扫描所有 assistant 消息,
|
// 治 DeepSeek/OpenAI 400(三元组完整性 P0)。OpenAI 协议铁律:
|
||||||
// 若某条 assistant 含 tool_calls 但下一条不是 tool,则剥离其 tool_calls。
|
// (a) assistant 的每个 tool_call.id 必须有后续 tool(role=tool, tool_call_id 匹配)响应,
|
||||||
// 正常流程 tool 结果先于下一轮 LLM 请求推入历史,此守卫仅兜底异常截断/恢复场景的残末尾。
|
// 否则 "insufficient tool messages" 400(assistant 调了工具但无结果)。
|
||||||
// 注意:合法的三元组形如:assistant(tc=[a]) → tool(a) → assistant(tc=[b]) → tool(b)。
|
// (b) 反之,每条 tool 消息必须紧跟一个含 tool_calls(同 tool_call_id)的 assistant,
|
||||||
// 若最后一条是 assistant(tc=...) 也无下一条 tool,同样剥离。
|
// 否则 "Messages with role tool must be a response to a preceding message
|
||||||
for i in 0..messages.len() {
|
// with tool_calls" 400(tool 无配对头)。
|
||||||
let role = messages[i].role.clone();
|
//
|
||||||
if role != "assistant" {
|
// 旧逻辑只检查「下一条 role 是否为 tool」(粗粒度),漏两类 orphan:
|
||||||
continue;
|
// 1) 部分 tool_call 无响应:assistant(tc=[a,b]) → tool(a)(b 丢失)→ 旧逻辑因下一条是
|
||||||
}
|
// tool 不剥 → 发出未闭合的 b → 400。修法:按 tool_call_id 精确配对,剥未闭合 id。
|
||||||
let has_tc = messages[i].tool_calls.is_some();
|
// 2) orphan tool_result(tool 无前置 assistant tool_calls 配对):DB/直构造路径绕过
|
||||||
if !has_tc {
|
// ContextManager::sanitize_messages(标题/知识注入/工作流节点),tool 残留无头 →
|
||||||
continue;
|
// 旧逻辑不处理 → 400。修法:剥 assistant tool_calls 时同步丢弃同 id 的 orphan
|
||||||
}
|
// tool(一致性:不留无头 result),并对独立 orphan tool(全程无配对头)直接丢弃。
|
||||||
let next_is_tool = i + 1 < messages.len()
|
//
|
||||||
&& matches!(messages[i + 1].role.as_str(), "tool");
|
// 正常三元组形如:assistant(tc=[a]) → tool(a) → assistant(tc=[b]) → tool(b),各 id 闭合,
|
||||||
if !next_is_tool {
|
// 本守卫零介入。仅异常截断/恢复/直构造路径触发(防 400 兜底)。
|
||||||
messages[i].tool_calls = None;
|
// view-only:仅改发送视图(本函数消费 req.messages 所有权),持久化由调用方/上层 sanitize 全量保留。
|
||||||
tracing::warn!(
|
sanitize_openai_triplets(&mut messages);
|
||||||
"[openai] assistant(#{} role={}) 含 tool_calls 但下一条非 tool,已自动剥离(防 400)",
|
|
||||||
i, role,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let tools = req.tools.map(|defs| {
|
let tools = req.tools.map(|defs| {
|
||||||
defs.into_iter()
|
defs.into_iter()
|
||||||
@@ -304,15 +300,149 @@ impl OpenAICompatProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 解析同步响应中的工具调用
|
/// 解析同步响应中的工具调用。
|
||||||
|
///
|
||||||
|
/// 兜底(CR-空 id):id 空时按数组 index 生成 `gen_tool_{index}` fallback。
|
||||||
|
/// SenseNova 等兼容缺陷 provider 发空 id,多 tool_call 同 id 致结果路由全落首个。
|
||||||
|
/// 详见 `tool_call_id_or_fallback`。正常 provider id 非空原样透传。
|
||||||
fn parse_tool_calls(calls: Vec<OpenAiToolCallResp>) -> Vec<ToolCall> {
|
fn parse_tool_calls(calls: Vec<OpenAiToolCallResp>) -> Vec<ToolCall> {
|
||||||
calls
|
calls
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|c| ToolCall::new(c.id, c.function.name, c.function.arguments))
|
.enumerate()
|
||||||
|
.map(|(i, c)| {
|
||||||
|
let id = tool_call_id_or_fallback(&c.id, i, "gen_tool");
|
||||||
|
ToolCall::new(id, c.function.name, c.function.arguments)
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从 OpenAiMessage 的 tool_calls 数组里取每个 call 的 id(tool_calls 形如
|
||||||
|
/// [{id, type, function:{name, arguments}}, ...])。非数组 / 缺 id 的条目跳过。
|
||||||
|
fn extract_tool_call_ids(msg: &OpenAiMessage) -> Vec<String> {
|
||||||
|
let Some(arr) = msg.tool_calls.as_ref() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|tc| tc.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 三元组一致性自愈(view-only,发送视图):保证 OpenAI 协议 tool_call/tool_result
|
||||||
|
/// 双向闭合,防 DeepSeek/OpenAI 400。详见 [`OpenAICompatProvider::convert_request`] 调用处注释。
|
||||||
|
///
|
||||||
|
/// 两轮扫描:
|
||||||
|
/// 1) 收集 resolved_ids = 所有 tool 消息的 tool_call_id(这些 id 有 result 响应)。
|
||||||
|
/// 2) assistant(tool_calls):剥未在 resolved_ids 内的 call.id;剥空则 tool_calls=None。
|
||||||
|
/// (头被剥后,其 tool_call.id 不再进 head_ids,故 step3 会同步丢弃对应 orphan tool。)
|
||||||
|
/// 3) tool:tool_call_id 不在任何保留 assistant 头(任意 assistant 仍含此 id)→ orphan
|
||||||
|
/// tool_result,丢弃。这覆盖「头被剥后残留的 tool」与「全程无配对头的 tool」两类。
|
||||||
|
///
|
||||||
|
/// 一致性:剥 assistant tool_call → 该 id 不进 head_ids → 对应 tool 在 step3 被丢;
|
||||||
|
/// 反之剥 orphan tool 不动 assistant(若 assistant 的所有 id 都被剥则 tool_calls=None)。
|
||||||
|
/// 正常三元组(各 id 闭合)零介入。
|
||||||
|
///
|
||||||
|
/// 设计取舍:OpenAI 协议 assistant(tool_calls) 需有效函数结构,补头(像 Anthropic
|
||||||
|
/// TOOL_MISSING_PREFIX)风险高于丢弃——故选「丢弃 orphan」而非「补头」。Anthropic
|
||||||
|
/// 路径由 drop_reverse_orphans 补头自愈(保留 LLM 可见的工具结果);OpenAI 路径走丢弃,
|
||||||
|
/// 二者各自适配协议特性(Anthropic 严格交替 + 补头可行;OpenAI tool 必须紧跟 tool_calls)。
|
||||||
|
fn sanitize_openai_triplets(messages: &mut Vec<OpenAiMessage>) {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
// step 1:resolved_ids = 所有 tool 消息提供的 tool_call_id(有 result 响应的 id)。
|
||||||
|
let resolved_ids: HashSet<String> = messages
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.role == "tool")
|
||||||
|
.filter_map(|m| m.tool_call_id.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut stripped_heads = 0u32;
|
||||||
|
let mut total_stripped = 0u32;
|
||||||
|
|
||||||
|
// step 2:assistant 剥未闭合 tool_call(无对应 tool result 响应)。
|
||||||
|
for m in messages.iter_mut() {
|
||||||
|
if m.role != "assistant" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(calls) = m.tool_calls.as_ref() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if calls.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let kept: Vec<serde_json::Value> = calls
|
||||||
|
.iter()
|
||||||
|
.filter(|tc| {
|
||||||
|
tc.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.is_some_and(|id| resolved_ids.contains(id))
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let stripped_count = calls.len() - kept.len();
|
||||||
|
if stripped_count == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
m.tool_calls = if kept.is_empty() { None } else { Some(kept) };
|
||||||
|
stripped_heads += 1;
|
||||||
|
total_stripped += stripped_count as u32;
|
||||||
|
tracing::warn!(
|
||||||
|
stripped_count,
|
||||||
|
"[openai] assistant 含未闭合 tool_calls(无对应 tool result),已剥离 {} 个(防 insufficient tool messages 400)",
|
||||||
|
stripped_count,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// step 3:head_ids = step2 后仍保留在任意 assistant 头的 id(有头配对的 tool 才保留)。
|
||||||
|
let head_ids: HashSet<String> = messages
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.role == "assistant")
|
||||||
|
.flat_map(extract_tool_call_ids)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let original_len = messages.len();
|
||||||
|
let mut dropped_orphan_tools = 0u32;
|
||||||
|
messages.retain(|m| {
|
||||||
|
if m.role != "tool" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let id = match m.tool_call_id.as_deref() {
|
||||||
|
None => {
|
||||||
|
// 无 tool_call_id 的 tool 消息(异常数据):无法配对,丢弃(发出去必 400)。
|
||||||
|
dropped_orphan_tools += 1;
|
||||||
|
tracing::warn!(
|
||||||
|
"[openai] tool 消息缺少 tool_call_id,已丢弃(无 id 无法配对 assistant tool_calls,防 400)"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Some(id) => id,
|
||||||
|
};
|
||||||
|
if head_ids.contains(id) {
|
||||||
|
// 有配对头 → 保留(正常三元组)。
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 无配对头(id 不在任何保留 assistant 头内)→ orphan tool_result,丢弃。
|
||||||
|
// 含两类:(a) assistant 头被 step2 剥后残留的 tool;(b) 全程无配对头的直构造/DB 残留。
|
||||||
|
dropped_orphan_tools += 1;
|
||||||
|
tracing::warn!(
|
||||||
|
tool_call_id = %id,
|
||||||
|
"[openai] orphan tool result(无配对 assistant tool_calls),已丢弃(防 'tool must be response to preceding tool_calls' 400)",
|
||||||
|
);
|
||||||
|
false
|
||||||
|
});
|
||||||
|
|
||||||
|
if stripped_heads > 0 || dropped_orphan_tools > 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
stripped_heads,
|
||||||
|
total_stripped,
|
||||||
|
dropped_orphan_tools,
|
||||||
|
before = original_len,
|
||||||
|
after = messages.len(),
|
||||||
|
"[openai] tool_call 三元组自愈(view-only, 持久化不受影响)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LlmProvider for OpenAICompatProvider {
|
impl LlmProvider for OpenAICompatProvider {
|
||||||
/// 文本嵌入: POST /v1/embeddings(OpenAI 兼容,智谱/阿里百炼/OpenAI 通用)
|
/// 文本嵌入: POST /v1/embeddings(OpenAI 兼容,智谱/阿里百炼/OpenAI 通用)
|
||||||
@@ -801,4 +931,315 @@ mod tests {
|
|||||||
assert_eq!(out.messages.len(), 2, "正常序列不补占位");
|
assert_eq!(out.messages.len(), 2, "正常序列不补占位");
|
||||||
assert_eq!(out.messages[0].role.as_str(), "user");
|
assert_eq!(out.messages[0].role.as_str(), "user");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 三元组一致性自愈(P0:治 DeepSeek/OpenAI 400) ----------
|
||||||
|
|
||||||
|
/// 辅助:取 assistant 消息的 tool_call id 列表(发出去的形态)。
|
||||||
|
fn openai_tool_call_ids(m: &OpenAiMessage) -> Vec<String> {
|
||||||
|
m.tool_calls
|
||||||
|
.as_ref()
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|tc| tc.get("id").and_then(|v| v.as_str()).map(String::from))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 正常三元组(各 id 闭合)零介入:assistant(tc=[a]) → tool(a) → assistant(tc=[b]) → tool(b)。
|
||||||
|
/// 约束铁律:不破正常三元组。
|
||||||
|
#[test]
|
||||||
|
fn openai_sanitize_keeps_closed_triplets() {
|
||||||
|
let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat");
|
||||||
|
let req = CompletionRequest {
|
||||||
|
model: "deepseek-chat".into(),
|
||||||
|
messages: vec![
|
||||||
|
ChatMessage::user("查天气"),
|
||||||
|
ChatMessage::assistant_with_tools(
|
||||||
|
"调用中",
|
||||||
|
vec![ToolCall::new("call_a", "get_weather", "{}")],
|
||||||
|
),
|
||||||
|
ChatMessage::tool_result("call_a", "晴"),
|
||||||
|
ChatMessage::assistant_with_tools(
|
||||||
|
"再查",
|
||||||
|
vec![ToolCall::new("call_b", "get_weather", "{}")],
|
||||||
|
),
|
||||||
|
ChatMessage::tool_result("call_b", "雨"),
|
||||||
|
],
|
||||||
|
temperature: None,
|
||||||
|
max_tokens: None,
|
||||||
|
stream: false,
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
reasoning_content: None,
|
||||||
|
};
|
||||||
|
let out = provider.convert_request(req);
|
||||||
|
// 5 条全保留(正常三元组不剥不丢)。
|
||||||
|
assert_eq!(out.messages.len(), 5, "正常三元组零介入,不应剥/丢任何消息");
|
||||||
|
// 两个 assistant 头的 tool_calls 完整保留。
|
||||||
|
let heads: Vec<&OpenAiMessage> = out
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.role == "assistant")
|
||||||
|
.collect();
|
||||||
|
assert_eq!(openai_tool_call_ids(heads[0]), vec!["call_a".to_string()]);
|
||||||
|
assert_eq!(openai_tool_call_ids(heads[1]), vec!["call_b".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 末尾 assistant tool_calls 无 result(残末尾)→ 剥离 tool_calls(保留 assistant 文本)。
|
||||||
|
/// 防 "insufficient tool messages" 400。
|
||||||
|
#[test]
|
||||||
|
fn openai_sanitize_strips_tail_unresolved_tool_calls() {
|
||||||
|
let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat");
|
||||||
|
let req = CompletionRequest {
|
||||||
|
model: "deepseek-chat".into(),
|
||||||
|
messages: vec![
|
||||||
|
ChatMessage::user("查天气"),
|
||||||
|
ChatMessage::assistant_with_tools(
|
||||||
|
"调工具但 result 还没回来",
|
||||||
|
vec![ToolCall::new("call_x", "get_weather", "{}")],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
temperature: None,
|
||||||
|
max_tokens: None,
|
||||||
|
stream: false,
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
reasoning_content: None,
|
||||||
|
};
|
||||||
|
let out = provider.convert_request(req);
|
||||||
|
// assistant 保留(content 不丢),但 tool_calls 被剥。
|
||||||
|
let asst = out
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.role == "assistant")
|
||||||
|
.expect("assistant 应保留");
|
||||||
|
assert!(
|
||||||
|
asst.tool_calls.is_none(),
|
||||||
|
"未闭合 tool_calls 应被剥离"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// orphan tool_result(无配对 assistant tool_calls 头)→ 丢弃。
|
||||||
|
/// 直构造/DB 残留路径绕过 ContextManager::sanitize_messages 时由本守卫兜底。
|
||||||
|
/// 防 "Messages with role tool must be a response to a preceding message with tool_calls" 400。
|
||||||
|
#[test]
|
||||||
|
fn openai_sanitize_drops_orphan_tool_result_no_head() {
|
||||||
|
let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat");
|
||||||
|
let req = CompletionRequest {
|
||||||
|
model: "deepseek-chat".into(),
|
||||||
|
messages: vec![
|
||||||
|
ChatMessage::user("问"),
|
||||||
|
// 无头的 orphan tool_result(头被裁剪/丢失)。
|
||||||
|
ChatMessage::tool_result("orphan_id", "结果"),
|
||||||
|
ChatMessage::assistant("回复"),
|
||||||
|
],
|
||||||
|
temperature: None,
|
||||||
|
max_tokens: None,
|
||||||
|
stream: false,
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
reasoning_content: None,
|
||||||
|
};
|
||||||
|
let out = provider.convert_request(req);
|
||||||
|
// orphan tool 被丢弃,剩 user + assistant。
|
||||||
|
let tools: Vec<&OpenAiMessage> = out
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.role == "tool")
|
||||||
|
.collect();
|
||||||
|
assert!(tools.is_empty(), "无配对头的 orphan tool_result 应丢弃, 实际 {:?}", tools);
|
||||||
|
assert_eq!(out.messages.len(), 2, "应剩 user + assistant");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// assistant tool_calls 剥离后,对应 orphan tool_result 同步丢弃(一致性)。
|
||||||
|
/// 场景:assistant(tc=[a,b]) → tool(a)(b 的 result 丢失)。旧逻辑因下一条是 tool
|
||||||
|
/// 不剥 → 发出未闭合 b → 400。新逻辑按 id 精确配对:剥 b(保留 a),tool(a) 保留。
|
||||||
|
#[test]
|
||||||
|
fn openai_sanitize_partial_triplet_strips_unresolved_id() {
|
||||||
|
let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat");
|
||||||
|
let req = CompletionRequest {
|
||||||
|
model: "deepseek-chat".into(),
|
||||||
|
messages: vec![
|
||||||
|
ChatMessage::user("问"),
|
||||||
|
ChatMessage::assistant_with_tools(
|
||||||
|
"调两工具",
|
||||||
|
vec![
|
||||||
|
ToolCall::new("call_a", "tool_a", "{}"),
|
||||||
|
ToolCall::new("call_b", "tool_b", "{}"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// 只回了 call_a,call_b 的 result 丢失。
|
||||||
|
ChatMessage::tool_result("call_a", "a 结果"),
|
||||||
|
],
|
||||||
|
temperature: None,
|
||||||
|
max_tokens: None,
|
||||||
|
stream: false,
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
reasoning_content: None,
|
||||||
|
};
|
||||||
|
let out = provider.convert_request(req);
|
||||||
|
let asst = out
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.role == "assistant")
|
||||||
|
.expect("assistant 应保留");
|
||||||
|
// 只保留 call_a(已闭合),剥 call_b(未闭合)。
|
||||||
|
assert_eq!(
|
||||||
|
openai_tool_call_ids(asst),
|
||||||
|
vec!["call_a".to_string()],
|
||||||
|
"部分闭合头应只留已闭合 call_a, 剥未闭合 call_b"
|
||||||
|
);
|
||||||
|
// tool(call_a) 保留(有配对头)。
|
||||||
|
let tools: Vec<&OpenAiMessage> = out
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.role == "tool")
|
||||||
|
.collect();
|
||||||
|
assert_eq!(tools.len(), 1, "call_a 的 tool_result 应保留");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 全未闭合三元组:assistant(tc=[a]) 但全程无 tool(a) → 剥 tool_calls,
|
||||||
|
/// 且不残留任何 orphan tool(本就无 tool 消息)。
|
||||||
|
#[test]
|
||||||
|
fn openai_sanitize_fully_unresolved_strips_all() {
|
||||||
|
let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat");
|
||||||
|
let req = CompletionRequest {
|
||||||
|
model: "deepseek-chat".into(),
|
||||||
|
messages: vec![
|
||||||
|
ChatMessage::user("问"),
|
||||||
|
ChatMessage::assistant_with_tools(
|
||||||
|
"调工具无结果",
|
||||||
|
vec![
|
||||||
|
ToolCall::new("call_y", "tool_y", "{}"),
|
||||||
|
ToolCall::new("call_z", "tool_z", "{}"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ChatMessage::assistant("纯文本续"),
|
||||||
|
],
|
||||||
|
temperature: None,
|
||||||
|
max_tokens: None,
|
||||||
|
stream: false,
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
reasoning_content: None,
|
||||||
|
};
|
||||||
|
let out = provider.convert_request(req);
|
||||||
|
let heads: Vec<&OpenAiMessage> = out
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.role == "assistant")
|
||||||
|
.collect();
|
||||||
|
// 第一个 assistant(原含 tool_calls)应被剥空;第二个纯文本不变。
|
||||||
|
assert!(
|
||||||
|
heads[0].tool_calls.is_none(),
|
||||||
|
"全未闭合 tool_calls 应全部剥离"
|
||||||
|
);
|
||||||
|
assert!(heads[1].tool_calls.is_none(), "纯文本 assistant 无 tool_calls");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 无 tool_call_id 的 tool 消息(异常数据)→ 丢弃(发出去必 400)。
|
||||||
|
#[test]
|
||||||
|
fn openai_sanitize_drops_tool_without_call_id() {
|
||||||
|
let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat");
|
||||||
|
let mut bad_tool = ChatMessage::tool_result("temp", "结果");
|
||||||
|
bad_tool.tool_call_id = None; // 异常:无 id
|
||||||
|
let req = CompletionRequest {
|
||||||
|
model: "deepseek-chat".into(),
|
||||||
|
messages: vec![ChatMessage::user("问"), bad_tool],
|
||||||
|
temperature: None,
|
||||||
|
max_tokens: None,
|
||||||
|
stream: false,
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
reasoning_content: None,
|
||||||
|
};
|
||||||
|
let out = provider.convert_request(req);
|
||||||
|
let tools: Vec<&OpenAiMessage> = out
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.role == "tool")
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
tools.is_empty(),
|
||||||
|
"无 tool_call_id 的 tool 消息应丢弃, 实际 {:?}", tools
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CR-空 id:parse_tool_calls 对空 id 按 index 生成 gen_tool_{i} fallback,非空原样。
|
||||||
|
/// 根因:SenseNova 等兼容缺陷 provider 发空 tool_call.id,多 tool_call 同 id(空串)
|
||||||
|
/// 致 audit/mod.rs:203 seen_ids 去重只留首个 → 所有工具结果路由到首个。
|
||||||
|
#[test]
|
||||||
|
fn openai_parse_tool_calls_empty_id_fallback_unique() {
|
||||||
|
let calls = vec![
|
||||||
|
OpenAiToolCallResp {
|
||||||
|
id: String::new(),
|
||||||
|
call_type: "function".into(),
|
||||||
|
function: OpenAiFunctionResp { name: "list_dir".into(), arguments: r#"{"path":"docs"}"#.into() },
|
||||||
|
},
|
||||||
|
OpenAiToolCallResp {
|
||||||
|
id: String::new(),
|
||||||
|
call_type: "function".into(),
|
||||||
|
function: OpenAiFunctionResp { name: "list_dir".into(), arguments: r#"{"path":"crates"}"#.into() },
|
||||||
|
},
|
||||||
|
OpenAiToolCallResp {
|
||||||
|
id: "call_abc123".into(),
|
||||||
|
call_type: "function".into(),
|
||||||
|
function: OpenAiFunctionResp { name: "read_file".into(), arguments: r#"{"path":"根"}"#.into() },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let parsed = OpenAICompatProvider::parse_tool_calls(calls);
|
||||||
|
assert_eq!(parsed.len(), 3);
|
||||||
|
// 空 id → fallback(按 index),保证唯一
|
||||||
|
assert_eq!(parsed[0].id, "gen_tool_0");
|
||||||
|
assert_eq!(parsed[1].id, "gen_tool_1");
|
||||||
|
// 非空 id 原样透传
|
||||||
|
assert_eq!(parsed[2].id, "call_abc123");
|
||||||
|
// name/args 透传无损
|
||||||
|
assert_eq!(parsed[0].function.name, "list_dir");
|
||||||
|
assert_eq!(parsed[1].function.arguments, r#"{"path":"crates"}"#);
|
||||||
|
// 关键:所有 id 互异(去重后不丢工具)
|
||||||
|
let mut ids: Vec<&str> = parsed.iter().map(|c| c.id.as_str()).collect();
|
||||||
|
ids.sort();
|
||||||
|
let unique: Vec<&str> = {
|
||||||
|
let mut u = ids.clone();
|
||||||
|
u.dedup();
|
||||||
|
u
|
||||||
|
};
|
||||||
|
assert_eq!(ids.len(), unique.len(), "id 应全部唯一,实际 {:?}", ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CR-空 id 流式:SSE chunk 携带 `"id":""`(SenseNova 兼容缺陷)→ ToolCallDelta.id
|
||||||
|
/// 转为 `gen_stream_{index}` fallback(非 None),保证下游 accumulate_tool_calls 写入
|
||||||
|
/// draft.id 非空。chunk 完全无 id 字段(None)保持 None(OpenAI 协议:仅首 chunk 有 id,
|
||||||
|
/// 后续 chunk 无 id 不应覆盖首 chunk 权威 id),由 agentic 转换点兜底。
|
||||||
|
#[test]
|
||||||
|
fn openai_stream_chunk_empty_id_fallback() {
|
||||||
|
let mut acc: Option<TokenUsage> = None;
|
||||||
|
// chunk 1: tool_call index=0, id="" → fallback gen_stream_0
|
||||||
|
let data1 = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"list_dir","arguments":"{\"path\":\"docs\"}"}}]}}]}"#;
|
||||||
|
let c1 = apply_openai_sse(data1, &mut acc);
|
||||||
|
let tc1 = c1.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap();
|
||||||
|
assert_eq!(tc1.index, 0);
|
||||||
|
assert_eq!(tc1.id.as_deref(), Some("gen_stream_0"), "空 id 应转 fallback");
|
||||||
|
|
||||||
|
// chunk 2: tool_call index=1, id="" → fallback gen_stream_1(与 index=0 不同,唯一)
|
||||||
|
let data2 = r#"{"choices":[{"delta":{"tool_calls":[{"index":1,"id":"","type":"function","function":{"name":"read_file","arguments":""}}]}}]}"#;
|
||||||
|
let c2 = apply_openai_sse(data2, &mut acc);
|
||||||
|
let tc2 = c2.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap();
|
||||||
|
assert_eq!(tc2.id.as_deref(), Some("gen_stream_1"), "不同 index fallback 应不同");
|
||||||
|
|
||||||
|
// chunk 3: tool_call index=0, 无 id 字段(None)→ 保持 None(不覆盖首 chunk)
|
||||||
|
let data3 = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"更多参数"}}]}}]}"#;
|
||||||
|
let c3 = apply_openai_sse(data3, &mut acc);
|
||||||
|
let tc3 = c3.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap();
|
||||||
|
assert!(tc3.id.is_none(), "无 id 字段 chunk 应保持 None,不覆盖首 chunk 权威 id");
|
||||||
|
|
||||||
|
// chunk 4: tool_call 非空 id → 原样透传
|
||||||
|
let data4 = r#"{"choices":[{"delta":{"tool_calls":[{"index":2,"id":"call_xyz","type":"function","function":{"name":"write"}}]}}]}"#;
|
||||||
|
let c4 = apply_openai_sse(data4, &mut acc);
|
||||||
|
let tc4 = c4.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap();
|
||||||
|
assert_eq!(tc4.id.as_deref(), Some("call_xyz"), "非空 id 原样透传");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
|
|
||||||
use crate::provider::{StreamChunk, TokenUsage, ToolCallDelta};
|
use crate::provider::{tool_call_id_or_fallback, StreamChunk, TokenUsage, ToolCallDelta};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// OpenAI API 请求/响应结构体
|
// OpenAI API 请求/响应结构体
|
||||||
@@ -211,11 +211,20 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
|
|||||||
|
|
||||||
let tool_calls = choice.delta.tool_calls.map(|tcs| {
|
let tool_calls = choice.delta.tool_calls.map(|tcs| {
|
||||||
tcs.into_iter()
|
tcs.into_iter()
|
||||||
.map(|tc| ToolCallDelta {
|
.map(|tc| {
|
||||||
|
// CR-空 id:流式 chunk 的 id 可能为 Some("")(SenseNova 兼容缺陷)。
|
||||||
|
// 仅对「provider 显式给了 id 字段」的 chunk 做兜底——None(OpenAI
|
||||||
|
// 协议:仅首 chunk 携带 id,后续 chunk 无 id)保持 None,避免
|
||||||
|
// 覆盖首 chunk 的权威 id。Some("") → `gen_stream_{index}` fallback,
|
||||||
|
// Some(非空) → 原样。下游 stream_recv 按 index 累积,draft.id 透传
|
||||||
|
// 至 ToolCall.id(accumulate_tool_calls 仅 Some 覆盖,None 不动)。
|
||||||
|
let id = tc.id.map(|raw| tool_call_id_or_fallback(&raw, tc.index as usize, "gen_stream"));
|
||||||
|
ToolCallDelta {
|
||||||
index: tc.index,
|
index: tc.index,
|
||||||
id: tc.id,
|
id,
|
||||||
function_name: tc.function.as_ref().and_then(|f| f.name.clone()),
|
function_name: tc.function.as_ref().and_then(|f| f.name.clone()),
|
||||||
function_arguments: tc.function.and_then(|f| f.arguments),
|
function_arguments: tc.function.and_then(|f| f.arguments),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -196,37 +196,280 @@ pub(crate) fn infer_goal_from_tool_calls(tool_calls: &std::collections::HashMap<
|
|||||||
goals
|
goals
|
||||||
}
|
}
|
||||||
|
|
||||||
/// G2 探索熔断:判定工具结果是否为「空结果」(空成功,非失败)。
|
// ============================================================
|
||||||
pub(crate) fn is_empty_tool_result(content: &str) -> bool {
|
// G2 探索熔断(2026-08-01 根本性重构:从「结果空」判漂移 → 「调用签名重复」判漂移)
|
||||||
let trimmed = content.trim();
|
//
|
||||||
if trimmed.is_empty() { return true; }
|
// 旧范式(is_empty_tool_result)用关键词(`"matches":[]`/`"total":0`/未找到...)判
|
||||||
const EMPTY_MARKERS: &[&str] = &[
|
// 「空成功」,是**错误代理指标**:grep 无匹配是有效排除信号(AI 换词定位/排除路径),
|
||||||
"\"total\":0", "\"entries\":[]", "\"matches\":[]", "\"results\":[]", "\"files\":[]",
|
// 非漂移。实测会话 ac448296 系统 grep 多关键词(部分无匹配)→ 整轮全空 stall+=1 →
|
||||||
];
|
// 连续 3 轮误熔断 → 对话莫名停止(详见 memory `devflow-g2-stall-false-positive`)。
|
||||||
for marker in EMPTY_MARKERS {
|
//
|
||||||
if trimmed.contains(marker) { return true; }
|
// 新范式:漂移的本质 = AI 卡住**反复做同样的工具调用**。正常探索(换词/换路径/换工具)
|
||||||
|
// 签名不同;真死循环(同调用反复)签名重复。判「签名重复」直接命中漂移本质,不再误杀
|
||||||
|
// 正常排除式搜索。
|
||||||
|
//
|
||||||
|
// 调用点:check_stall_breaker(agentic/mod.rs) 取最近 N 个 assistant tool_calls 签名,
|
||||||
|
// 喂 is_repetitive_exploration 判定,重复 → stall_count+=1(沿用熔断骨架不变)。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 从 args JSON Value 取字符串字段,缺失/非字符串 → 空串(归一兜底,签名不 panic)。
|
||||||
|
fn arg_str(args: &serde_json::Value, key: &str) -> String {
|
||||||
|
args.get(key)
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
const EMPTY_TEXT: &[&str] = &[
|
|
||||||
"未找到", "没有找到", "无匹配", "没有匹配", "未匹配", "未发现", "无记录",
|
/// G2 签名归一化:把每工具「决定意图」的参数压成一个可比对字符串 `"name:k1=v1,k2=v2"`。
|
||||||
"No matches", "no matches", "0 results", "0 matches", "没有数据", "没有符合",
|
///
|
||||||
];
|
/// 选「决定意图」参数(决定这次调用"去哪儿查什么"的参数),非决定参数(如 case_sensitive/
|
||||||
for marker in EMPTY_TEXT {
|
/// show_line_numbers/timeout 等开关/格式选项)忽略——它们变体不构成漂移。
|
||||||
if trimmed.contains(marker) { return true; }
|
///
|
||||||
|
/// 归一规则(参数从 args JSON 取,缺失用空串):
|
||||||
|
/// - grep/search_files/search: `pattern`(或 `query`)+ `path`(或 `glob`)
|
||||||
|
/// — 同 path 换 pattern 是正常换词;同 pattern 同 path 才算重复。
|
||||||
|
/// - read_file: `path` + `offset` + `limit`
|
||||||
|
/// — **同段反复才算重复**;不同 offset = 正常分段读大文件(不算)。
|
||||||
|
/// - run_command: `command`(整条命令,含参数)。
|
||||||
|
/// - list_dir/list_directory: `path`。
|
||||||
|
/// - http_request: `url` + `method`。
|
||||||
|
/// - fetch_url: `url`。
|
||||||
|
/// - 其他/兜底: `name` + args 全 JSON 序列化(无明确语义时保守全量,避免漏判)。
|
||||||
|
///
|
||||||
|
/// 返回 `"name:k1=v1,k2=v2"` 形式。输入 args 通常来自 LLM 的 tool_call function.arguments
|
||||||
|
/// (JSON 字符串),调用方先 from_str 成 Value 再传入。
|
||||||
|
pub(crate) fn tool_call_signature(name: &str, args: &serde_json::Value) -> String {
|
||||||
|
let pair = |k: &str, v: &str| format!("{}={}", k, v);
|
||||||
|
let sig = match name {
|
||||||
|
"grep" | "search_files" | "search" => {
|
||||||
|
let q = if args.get("pattern").and_then(|v| v.as_str()).is_some() {
|
||||||
|
arg_str(args, "pattern")
|
||||||
|
} else {
|
||||||
|
arg_str(args, "query")
|
||||||
|
};
|
||||||
|
let p = if args.get("path").and_then(|v| v.as_str()).is_some() {
|
||||||
|
arg_str(args, "path")
|
||||||
|
} else {
|
||||||
|
arg_str(args, "glob")
|
||||||
|
};
|
||||||
|
format!("{},{}", pair("pattern", &q), pair("path", &p))
|
||||||
}
|
}
|
||||||
false
|
"read_file" => {
|
||||||
|
// offset/limit 数值字段:as_str 不通,先取再转字符串(缺失→"")。
|
||||||
|
let offset = args.get("offset").map(|v| v.to_string()).unwrap_or_default();
|
||||||
|
let limit = args.get("limit").map(|v| v.to_string()).unwrap_or_default();
|
||||||
|
let path = arg_str(args, "path");
|
||||||
|
format!("{},{},{}", pair("path", &path), pair("offset", &offset), pair("limit", &limit))
|
||||||
|
}
|
||||||
|
"run_command" => pair("command", &arg_str(args, "command")),
|
||||||
|
"list_dir" | "list_directory" => pair("path", &arg_str(args, "path")),
|
||||||
|
"http_request" => {
|
||||||
|
let url = arg_str(args, "url");
|
||||||
|
let method = arg_str(args, "method");
|
||||||
|
format!("{},{}", pair("url", &url), pair("method", &method))
|
||||||
|
}
|
||||||
|
"fetch_url" => pair("url", &arg_str(args, "url")),
|
||||||
|
_ => {
|
||||||
|
// 兜底:工具名 + args 全 JSON 序列化(保守,无明确语义时全量比对)。
|
||||||
|
format!("{},{}", pair("name", name), pair("args", &args.to_string()))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
format!("{}:{}", name, sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// G2 重复检测纯函数:判定最近 N 个工具调用签名是否构成「卡住反复」。
|
||||||
|
///
|
||||||
|
/// 策略组合(两者任一命中即 true,注释论证稳健性):
|
||||||
|
/// - 样本不足(len < `REPETITION_MIN_SAMPLE`=6)→ false(不判,小样本误杀风险高)。
|
||||||
|
/// - **唯一签名数 / 总数 < 0.4**(超 60% 重复)→ true。
|
||||||
|
/// 覆盖「多个签名轮换但整体高度重复」(如 a/b/c/a/b/c/d/a/b),唯一率低 = 没有新探索方向。
|
||||||
|
/// - **或:某签名出现次数 >= 3** → true。
|
||||||
|
/// 覆盖「单点反复」(如 a,a,a,b,c),唯一率 3/5=0.6 不触发上条,但 a 已 3 次死磕 = 漂移。
|
||||||
|
///
|
||||||
|
/// 两条互补:唯一率治整体游荡不前进,单点计数治单点死磕。组合后覆盖真实漂移的两种形态,
|
||||||
|
/// 且对正常探索(签名持续翻新)宽松——换词 grep + 不同文件 read 各一两次,唯一率高不触发。
|
||||||
|
pub(crate) fn is_repetitive_exploration(signatures: &[String]) -> bool {
|
||||||
|
/// 最小样本量:不足此数不判定(避免早期误杀,如刚启动 2-3 个 grep 全不同不应熔断)。
|
||||||
|
const REPETITION_MIN_SAMPLE: usize = 6;
|
||||||
|
/// 单签名出现次数阈值:达此即判单点死磕漂移。
|
||||||
|
const REPETITION_SINGLE_MAX: usize = 3;
|
||||||
|
/// 唯一签名占比阈值:低于此(重复超 60%)判整体游荡不前进。
|
||||||
|
const REPETITION_UNIQUE_RATIO: f64 = 0.4;
|
||||||
|
|
||||||
|
if signatures.len() < REPETITION_MIN_SAMPLE {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let total = signatures.len();
|
||||||
|
let unique = {
|
||||||
|
let mut s: Vec<&String> = signatures.iter().collect();
|
||||||
|
s.sort();
|
||||||
|
s.dedup();
|
||||||
|
s.len()
|
||||||
|
};
|
||||||
|
let unique_ratio = unique as f64 / total as f64;
|
||||||
|
if unique_ratio < REPETITION_UNIQUE_RATIO {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 单点死磕:统计最高频签名出现次数。HashMap 避免重复 sort 计数,O(n)。
|
||||||
|
let mut counts: std::collections::HashMap<&String, usize> = std::collections::HashMap::new();
|
||||||
|
for s in signatures {
|
||||||
|
*counts.entry(s).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
counts.values().any(|&c| c >= REPETITION_SINGLE_MAX)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
// ── tool_call_signature 归一化测试 ──
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_empty_tool_result() {
|
fn sig_grep_takes_pattern_and_path() {
|
||||||
assert!(is_empty_tool_result(""));
|
let args = serde_json::json!({"pattern": "MAX", "path": "src/lib.rs", "case_sensitive": true});
|
||||||
assert!(is_empty_tool_result(" "));
|
assert_eq!(tool_call_signature("grep", &args), "grep:pattern=MAX,path=src/lib.rs");
|
||||||
assert!(is_empty_tool_result(r#"{"total":0}"#));
|
// 非决定参数(case_sensitive)不进签名
|
||||||
assert!(is_empty_tool_result(r#"{"entries":[]}"#));
|
}
|
||||||
assert!(is_empty_tool_result("未找到相关文件"));
|
|
||||||
assert!(!is_empty_tool_result(r#"{"total":5}"#));
|
#[test]
|
||||||
assert!(!is_empty_tool_result(r#"{"entries":["a.txt"]}"#));
|
fn sig_search_falls_back_to_query_and_glob() {
|
||||||
|
let args = serde_json::json!({"query": "TODO", "glob": "**/*.rs"});
|
||||||
|
assert_eq!(tool_call_signature("search_files", &args), "search_files:pattern=TODO,path=**/*.rs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sig_read_file_includes_offset_limit() {
|
||||||
|
let args = serde_json::json!({"path": "big.log", "offset": 50, "limit": 100});
|
||||||
|
assert_eq!(tool_call_signature("read_file", &args), "read_file:path=big.log,offset=50,limit=100");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sig_run_command_takes_command() {
|
||||||
|
let args = serde_json::json!({"command": "ls -la", "timeout": 5000});
|
||||||
|
assert_eq!(tool_call_signature("run_command", &args), "run_command:command=ls -la");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sig_list_dir_takes_path() {
|
||||||
|
let args = serde_json::json!({"path": "/tmp"});
|
||||||
|
assert_eq!(tool_call_signature("list_dir", &args), "list_dir:path=/tmp");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sig_http_request_takes_url_method() {
|
||||||
|
let args = serde_json::json!({"url": "https://x.io", "method": "GET", "headers": {}});
|
||||||
|
assert_eq!(tool_call_signature("http_request", &args), "http_request:url=https://x.io,method=GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sig_fetch_url_takes_url() {
|
||||||
|
let args = serde_json::json!({"url": "https://y.io", "raw": false});
|
||||||
|
assert_eq!(tool_call_signature("fetch_url", &args), "fetch_url:url=https://y.io");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sig_unknown_falls_back_to_full_args() {
|
||||||
|
let args = serde_json::json!({"x": 1, "y": "z"});
|
||||||
|
let sig = tool_call_signature("custom_tool", &args);
|
||||||
|
assert!(sig.starts_with("custom_tool:name=custom_tool,args="));
|
||||||
|
assert!(sig.contains("\"x\":1"));
|
||||||
|
assert!(sig.contains("\"y\":\"z\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sig_missing_args_default_empty() {
|
||||||
|
// 无任何字段,grep 兜底 pattern/path 都空,签名仍可构造不 panic。
|
||||||
|
let args = serde_json::json!({});
|
||||||
|
assert_eq!(tool_call_signature("grep", &args), "grep:pattern=,path=");
|
||||||
|
assert_eq!(tool_call_signature("read_file", &args), "read_file:path=,offset=,limit=");
|
||||||
|
assert_eq!(tool_call_signature("run_command", &args), "run_command:command=");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── is_repetitive_exploration 场景测试 ──
|
||||||
|
|
||||||
|
/// 场景 ac448296 实证:正常代码审查序列——换词 grep(MAX/truncate/fetch_url)+
|
||||||
|
/// 不同文件 read_file,12 个签名全不同。**核心回归**:旧 is_empty_tool_result 误熔断此场景,
|
||||||
|
/// 新签名判定应返回 false(不熔断)。
|
||||||
|
#[test]
|
||||||
|
fn scenario_normal_code_review_not_repetitive() {
|
||||||
|
let sigs = vec![
|
||||||
|
tool_call_signature("grep", &serde_json::json!({"pattern": "MAX", "path": "src"})),
|
||||||
|
tool_call_signature("grep", &serde_json::json!({"pattern": "truncate", "path": "src"})),
|
||||||
|
tool_call_signature("grep", &serde_json::json!({"pattern": "fetch_url", "path": "src"})),
|
||||||
|
tool_call_signature("grep", &serde_json!({"pattern": "MessageRole", "path": "crates"})),
|
||||||
|
tool_call_signature("grep", &serde_json!({"pattern": "pub enum", "path": "src"})),
|
||||||
|
tool_call_signature("read_file", &serde_json::json!({"path": "a.rs", "offset": 0, "limit": 50})),
|
||||||
|
tool_call_signature("read_file", &serde_json!({"path": "b.rs", "offset": 0, "limit": 50})),
|
||||||
|
tool_call_signature("read_file", &serde_json!({"path": "c.rs", "offset": 0, "limit": 50})),
|
||||||
|
tool_call_signature("read_file", &serde_json!({"path": "d.rs", "offset": 100, "limit": 50})),
|
||||||
|
tool_call_signature("list_dir", &serde_json!({"path": "src-tauri"})),
|
||||||
|
tool_call_signature("grep", &serde_json!({"pattern": "STALL", "path": "src"})),
|
||||||
|
tool_call_signature("read_file", &serde_json!({"path": "e.rs", "offset": 0, "limit": 50})),
|
||||||
|
];
|
||||||
|
assert_eq!(sigs.len(), 12);
|
||||||
|
assert!(!is_repetitive_exploration(&sigs), "正常代码审查不应判重复");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 场景死循环:同 grep 同 path 反复 8 次。判 true(熔断)。
|
||||||
|
#[test]
|
||||||
|
fn scenario_real_deadloop_repetitive() {
|
||||||
|
let one = tool_call_signature("grep", &serde_json::json!({"pattern": "foo", "path": "x"}));
|
||||||
|
let sigs = vec![one; 8];
|
||||||
|
assert!(is_repetitive_exploration(&sigs), "同调用反复应判重复");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 场景分段读大文件:同 path 不同 offset=0/50/100/150/200/250,6 次。判 false(签名不同)。
|
||||||
|
#[test]
|
||||||
|
fn scenario_paginated_read_not_repetitive() {
|
||||||
|
let offsets = [0, 50, 100, 150, 200, 250];
|
||||||
|
let sigs: Vec<String> = offsets.iter()
|
||||||
|
.map(|&o| tool_call_signature("read_file", &serde_json::json!({"path": "big.log", "offset": o, "limit": 50})))
|
||||||
|
.collect();
|
||||||
|
assert!(!is_repetitive_exploration(&sigs), "分段读不同 offset 不应判重复");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 场景反复读同段:同 path 同 offset+limit 4 次(+ 其他 2 个不同凑足样本)。判 true(漂移)。
|
||||||
|
#[test]
|
||||||
|
fn scenario_repeat_same_chunk_repetitive() {
|
||||||
|
let same = tool_call_signature("read_file", &serde_json::json!({"path": "a.rs", "offset": 0, "limit": 50}));
|
||||||
|
let other1 = tool_call_signature("grep", &serde_json::json!({"pattern": "x", "path": "y"}));
|
||||||
|
let other2 = tool_call_signature("list_dir", &serde_json::json!({"path": "z"}));
|
||||||
|
let sigs = vec![same.clone(), same.clone(), same.clone(), same, other1, other2];
|
||||||
|
assert!(is_repetitive_exploration(&sigs), "反复读同段应判重复");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 场景样本不足:仅 3 个签名(即使全同)。判 false(不判)。
|
||||||
|
#[test]
|
||||||
|
fn scenario_insufficient_sample_not_repetitive() {
|
||||||
|
let one = tool_call_signature("grep", &serde_json::json!({"pattern": "foo", "path": "x"}));
|
||||||
|
let sigs = vec![one; 3];
|
||||||
|
assert!(!is_repetitive_exploration(&sigs), "样本不足不应判");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 边界:恰好 6 个全同 → true(达最小样本 + 单点 6 >= 3)。
|
||||||
|
#[test]
|
||||||
|
fn boundary_exact_min_sample_all_same() {
|
||||||
|
let one = tool_call_signature("grep", &serde_json::json!({"pattern": "foo", "path": "x"}));
|
||||||
|
let sigs = vec![one; 6];
|
||||||
|
assert!(is_repetitive_exploration(&sigs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 边界:6 个签名两两循环(a,b,a,b,a,b)→ 唯一率 2/6≈0.33 < 0.4 → true(整体游荡)。
|
||||||
|
#[test]
|
||||||
|
fn boundary_two_alternating_below_ratio() {
|
||||||
|
let a = tool_call_signature("grep", &serde_json::json!({"pattern": "a", "path": "x"}));
|
||||||
|
let b = tool_call_signature("grep", &serde_json::json!({"pattern": "b", "path": "x"}));
|
||||||
|
let sigs = vec![a, b.clone(), a.clone(), b.clone(), a, b];
|
||||||
|
assert!(is_repetitive_exploration(&sigs), "两签名交替唯一率低应判重复");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 边界:6 个签名全不同 → false(正常探索)。
|
||||||
|
#[test]
|
||||||
|
fn boundary_six_unique_not_repetitive() {
|
||||||
|
let patterns = ["a", "b", "c", "d", "e", "f"];
|
||||||
|
let sigs: Vec<String> = patterns.iter()
|
||||||
|
.map(|p| tool_call_signature("grep", &serde_json::json!({"pattern": p, "path": "x"})))
|
||||||
|
.collect();
|
||||||
|
assert!(!is_repetitive_exploration(&sigs));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,35 +154,41 @@ pub const MAX_GOALS: usize = 5;
|
|||||||
pub const TOPIC_MARKER_GOAL_AWARE: bool = true;
|
pub const TOPIC_MARKER_GOAL_AWARE: bool = true;
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// G2 探索熔断(治 R4 连续空结果无进展死循环 + token 失控,2026-06-26)
|
// G2 探索熔断(治 R4 探索无进展死循环 + token 失控)
|
||||||
//
|
//
|
||||||
// 机制(非 prompt 说教):连续 N 轮工具结果全空(空成功,如 search_files/grep 无命中) →
|
// 判定范式(2026-08-01 根本性重构):
|
||||||
// 两段式处理(STALL_BREAKER_WARN_FIRST):先警示注入下轮 prompt 给 LLM 自纠机会,
|
// - 旧(废弃):连续 N 轮工具结果全空(is_empty_tool_result 关键词判空成功)。错误代理指标,
|
||||||
// 再熔断(guard.reset + AiHelpRequired)逼用户换思路/人工介入。
|
// grep 无匹配是有效排除信号非漂移,误熔断正常排除式搜索(实证 ac448296)。
|
||||||
// 与 L1 断路器(CIRCUIT_BREAKER_*)互补:L1 治反复失败(禁止/失败/Error 关键词),
|
// - 新(当前):连续 N 轮工具调用签名重复(is_repetitive_exploration 判 tool_call_signature)。
|
||||||
// G2 治反复空成功(total:0/无匹配),两者独立计数互不干扰。
|
// 漂移本质 = AI 卡住反复同样调用;正常探索(换词/换路径/换工具)签名不同,真死循环签名重复。
|
||||||
// 直接治实测 seq287-307 连续 10+ 次 search_files 空结果死循环(阈值 3 提前 7 次熔断,省巨量 token)。
|
//
|
||||||
|
// 机制(非 prompt 说教,骨架沿用):连续 N 轮判定签名重复 → 两段式(WARN_FIRST 先警示再熔断):
|
||||||
|
// 警示注入下轮 prompt 给 LLM 自纠机会,熔断(guard.reset + AiHelpRequired)逼用户换思路/人工介入。
|
||||||
|
// 与 L1 断路器(CIRCUIT_BREAKER_*)互补:L1 治反复同类失败(失败信号归一 key),
|
||||||
|
// G2 治反复同类调用(调用签名归一),两者独立计数互不干扰。
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/// G2 总开关:探索熔断是否启用(默认 true)。
|
/// G2 总开关:探索熔断是否启用(默认 true)。
|
||||||
///
|
///
|
||||||
/// true(默认):process_tool_calls 后取末尾连续 Tool 消息判空结果,全空累计 stall_count,
|
/// true(默认):process_tool_calls 后取最近 N 个 assistant tool_calls 签名,喂
|
||||||
/// 达 STALL_BREAKER_THRESHOLD → 警示/熔断。false(回退):整段跳过,stall_count 永远 0,
|
/// is_repetitive_exploration 判重复,重复累计 stall_count,达 STALL_BREAKER_THRESHOLD → 警示/熔断。
|
||||||
/// 退 max_iterations 旧行为(可能继续游荡但行为不变,单点回退)。与 CIRCUIT_BREAKER_ENABLED 独立。
|
/// false(回退):整段跳过,stall_count 永远 0,退 max_iterations 旧行为(可能继续游荡但行为不变,
|
||||||
|
/// 单点回退)。与 CIRCUIT_BREAKER_ENABLED 独立。
|
||||||
pub const STALL_BREAKER_ENABLED: bool = true;
|
pub const STALL_BREAKER_ENABLED: bool = true;
|
||||||
|
|
||||||
/// G2 连续空结果阈值(默认 3)。
|
/// G2 连续重复阈值(默认 3)。
|
||||||
///
|
///
|
||||||
/// 评审一致建议 3 而非 5(对齐 CIRCUIT_BREAKER_THRESHOLD=3):空成功比硬失败更隐蔽,
|
/// 对齐 CIRCUIT_BREAKER_THRESHOLD=3:连续 3 轮判定签名重复足以判漂移。新范式(签名重复)比
|
||||||
/// 阈值应更激进。治实测连续 10+ 次空结果(提前 7 次熔断)。is_empty_tool_result 关键词
|
/// 旧范式(结果空)更精准——签名重复是漂移的直接证据,无关键词漏判风险。阈值 3 在
|
||||||
/// 漏判风险由 threshold=3 + 两段式 WARN_FIRST 容错:多轮漏判才误熔断,最坏退 max_iterations 兜底。
|
/// 两段式 WARN_FIRST(== THRESHOLD-1 即 2 轮先警示)容错下,误杀面收敛到「连续 3 轮同一组调用」
|
||||||
|
/// 的真实死循环场景,最坏退 max_iterations 兜底。
|
||||||
pub const STALL_BREAKER_THRESHOLD: u32 = 3;
|
pub const STALL_BREAKER_THRESHOLD: u32 = 3;
|
||||||
|
|
||||||
/// G2 两段式开关:熔断前是否先警示一轮(默认 true)。
|
/// G2 两段式开关:熔断前是否先警示一轮(默认 true)。
|
||||||
///
|
///
|
||||||
/// true(默认):stall_count == THRESHOLD-1 时注入「⚠ 已连续 N 次空结果,可能偏离目标,
|
/// true(默认):stall_count == THRESHOLD-1 时注入「⚠ 检测到重复的工具调用 ...」到下轮
|
||||||
/// 请回顾目标换思路或停止」到下轮 system_prompt(给 LLM 自纠机会);stall_count >= THRESHOLD 才熔断。
|
/// system_prompt(给 LLM 自纠机会);stall_count >= THRESHOLD 才熔断。
|
||||||
/// 降误杀:用户正当探索(如确认无相关文件)先警示再熔断。false(激进):直接熔断不警示,
|
/// 降误杀:偶发的相似调用(如分段读后回读同一处确认)先警示再熔断。false(激进):直接熔断不警示,
|
||||||
/// 确认无误杀场景用。
|
/// 确认无误杀场景用。
|
||||||
pub const STALL_BREAKER_WARN_FIRST: bool = true;
|
pub const STALL_BREAKER_WARN_FIRST: bool = true;
|
||||||
|
|
||||||
@@ -192,6 +198,12 @@ pub const STALL_BREAKER_WARN_FIRST: bool = true;
|
|||||||
/// false:警示泛化(不引 goal 文本)。G2 不强依赖 G1(熔断不读 goal 也能工作,仅警示泛化)。
|
/// false:警示泛化(不引 goal 文本)。G2 不强依赖 G1(熔断不读 goal 也能工作,仅警示泛化)。
|
||||||
pub const STALL_BREAKER_GOAL_REMIND: bool = true;
|
pub const STALL_BREAKER_GOAL_REMIND: bool = true;
|
||||||
|
|
||||||
|
/// G2 重复检测样本量:check_stall_breaker 扫历史 assistant tool_calls 取最近多少个签名喂判定。
|
||||||
|
///
|
||||||
|
/// 12 = 覆盖 2-3 轮多工具调用(单轮可并行多个 grep/read),既能让 is_repetitive_exploration 的
|
||||||
|
/// 「最小样本 6」+「单点 3 次」阈值有意义,又不至于扫太长历史稀释近期漂移信号。
|
||||||
|
pub const STALL_BREAKER_SAMPLE_SIZE: usize = 12;
|
||||||
|
|
||||||
/// L1 断路器:连续同类工具失败熔断阈值(治 kms 会话 53 轮 0 产出死循环)。
|
/// L1 断路器:连续同类工具失败熔断阈值(治 kms 会话 53 轮 0 产出死循环)。
|
||||||
///
|
///
|
||||||
/// 背景:agent 无止损,某工具反复同类失败(权限拒绝/路径错误等)仍每轮重试,
|
/// 背景:agent 无止损,某工具反复同类失败(权限拒绝/路径错误等)仍每轮重试,
|
||||||
@@ -285,7 +297,7 @@ pub mod command_lock;
|
|||||||
pub(crate) mod helpers;
|
pub(crate) mod helpers;
|
||||||
pub(crate) use helpers::try_continue_agent_loop;
|
pub(crate) use helpers::try_continue_agent_loop;
|
||||||
pub(crate) use helpers::infer_goal_from_tool_calls;
|
pub(crate) use helpers::infer_goal_from_tool_calls;
|
||||||
pub(crate) use helpers::is_empty_tool_result;
|
pub(crate) use helpers::{tool_call_signature, is_repetitive_exploration};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 单 Provider 流式结果 + fallback 辅助
|
// 单 Provider 流式结果 + fallback 辅助
|
||||||
@@ -843,9 +855,12 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
// 治"全 loop 累加不衰减":早期偶发失败不会与后期叠加误熔断。
|
// 治"全 loop 累加不衰减":早期偶发失败不会与后期叠加误熔断。
|
||||||
let mut fail_window: std::collections::VecDeque<String> = std::collections::VecDeque::new();
|
let mut fail_window: std::collections::VecDeque<String> = std::collections::VecDeque::new();
|
||||||
|
|
||||||
// G2 探索熔断:连续空结果无进展计数器(loop 生命周期累计,与 fail_window 同生命周期)。
|
// G2 探索熔断:重复探索计数器(loop 生命周期累计,与 fail_window 同生命周期)。
|
||||||
// 每轮 process_tool_calls 后取末尾连续 Tool 消息判 is_empty_tool_result,全空 stall_count+=1,
|
// 每轮 process_tool_calls 后 check_stall_breaker 取最近 N 个 assistant tool_calls 签名,
|
||||||
// 任一非空重置 0。达 STALL_BREAKER_THRESHOLD → 警示/熔断(治 R4 游荡死循环 + token 失控)。
|
// 喂 is_repetitive_exploration 判重复,重复 stall_count+=1,非重复重置 0。
|
||||||
|
// 达 STALL_BREAKER_THRESHOLD → 警示/熔断(治 R4 游荡死循环 + token 失控)。
|
||||||
|
// 注:2026-08-01 从「末尾 Tool 结果是否空(is_empty_tool_result)」改为「调用签名重复」,
|
||||||
|
// 治旧范式误熔断正常排除式搜索(实证会话 ac448296)。
|
||||||
let mut stall_count: u32 = 0;
|
let mut stall_count: u32 = 0;
|
||||||
let mut stall_warned: bool = false;
|
let mut stall_warned: bool = false;
|
||||||
|
|
||||||
@@ -1932,6 +1947,23 @@ fn summarize_tool_results(
|
|||||||
let mut compressed_bytes: usize = 0;
|
let mut compressed_bytes: usize = 0;
|
||||||
let mut original_bytes: usize = 0;
|
let mut original_bytes: usize = 0;
|
||||||
let mut summarized_count: 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<String, String> = 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<ChatMessage> = messages
|
let msgs: Vec<ChatMessage> = messages
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|mut m| {
|
.map(|mut m| {
|
||||||
@@ -1943,9 +1975,25 @@ fn summarize_tool_results(
|
|||||||
if !should_summarize_tool_result(content_len, history_tokens_snapshot, content_tokens) {
|
if !should_summarize_tool_result(content_len, history_tokens_snapshot, content_tokens) {
|
||||||
return m;
|
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;
|
original_bytes += content_len;
|
||||||
let tool_name = m.tool_call_id.clone().unwrap_or_else(|| "tool".to_string());
|
let compressed = extract_key_info(&m.content, resolved_name);
|
||||||
let compressed = extract_key_info(&m.content, &tool_name);
|
|
||||||
compressed_bytes += compressed.len();
|
compressed_bytes += compressed.len();
|
||||||
summarized_count += 1;
|
summarized_count += 1;
|
||||||
m.content = compressed;
|
m.content = compressed;
|
||||||
@@ -1988,7 +2036,13 @@ fn emit_circuit_breaker_tripped(app_handle: &AppHandle, conv_id: &str, max_count
|
|||||||
// 返回 (tripped, new_stall_count, new_stall_warned):
|
// 返回 (tripped, new_stall_count, new_stall_warned):
|
||||||
// - tripped=true → 已 emit AiHelpRequired 且 loop 应 return(调用方需先 guard.reset 再 return)
|
// - tripped=true → 已 emit AiHelpRequired 且 loop 应 return(调用方需先 guard.reset 再 return)
|
||||||
// - tripped=false → 正常继续,新 stall_count/warned 供调用方回写。
|
// - tripped=false → 正常继续,新 stall_count/warned 供调用方回写。
|
||||||
// 嵌套 ≤ 3 层:全空判→计数/警示→达阈值 emit,各段早返回。
|
//
|
||||||
|
// 2026-08-01 根本性重构:判定从「末尾 Tool 结果是否空(is_empty_tool_result)」改为
|
||||||
|
// 「最近 N 个 assistant tool_calls 签名是否重复(tool_call_signature + is_repetitive_exploration)」。
|
||||||
|
// 治旧范式误熔断正常排除式搜索(grep 无匹配是有效排除信号,非漂移)。熔断骨架(计数/警示/
|
||||||
|
// 阈值/emit/return)不变,只换判定输入。
|
||||||
|
//
|
||||||
|
// 嵌套 ≤ 3 层:取签名→计数/警示→达阈值 emit,各段早返回。
|
||||||
async fn check_stall_breaker(
|
async fn check_stall_breaker(
|
||||||
session_arc: &Arc<Mutex<AiSession>>,
|
session_arc: &Arc<Mutex<AiSession>>,
|
||||||
app_handle: &AppHandle,
|
app_handle: &AppHandle,
|
||||||
@@ -1997,31 +2051,49 @@ async fn check_stall_breaker(
|
|||||||
stall_count: u32,
|
stall_count: u32,
|
||||||
stall_warned: bool,
|
stall_warned: bool,
|
||||||
) -> (bool, u32, bool) {
|
) -> (bool, u32, bool) {
|
||||||
// 1) 读取末尾连续 Tool 消息(本轮工具回填结果),判断全空或任一非空
|
// 1) 取最近 N 个 assistant tool_calls 签名:倒序扫 messages,遇 Assistant 且有 tool_calls
|
||||||
let all_empty: Option<bool> = {
|
// 就抽取每工具签名(name + arguments JSON parse),累积到 STALL_BREAKER_SAMPLE_SIZE 个。
|
||||||
|
// arguments 非合法 JSON 时 to_string 兜底(签名仍可比,只是兜底全量)。
|
||||||
|
let signatures: Vec<String> = {
|
||||||
let session = session_arc.lock().await;
|
let session = session_arc.lock().await;
|
||||||
let messages = match session.conv_read(conv_id) {
|
let messages = match session.conv_read(conv_id) {
|
||||||
Some(conv) => conv.messages.all_messages_clone(),
|
Some(conv) => conv.messages.all_messages_clone(),
|
||||||
None => Vec::new(),
|
None => Vec::new(),
|
||||||
};
|
};
|
||||||
let recent_tool_results: Vec<&ChatMessage> = messages
|
let mut sigs: Vec<String> = Vec::new();
|
||||||
.iter().rev()
|
for m in messages.iter().rev() {
|
||||||
.take_while(|m| matches!(m.role, MessageRole::Tool))
|
if sigs.len() >= STALL_BREAKER_SAMPLE_SIZE {
|
||||||
.collect();
|
break;
|
||||||
if recent_tool_results.is_empty() {
|
|
||||||
None // 本轮无工具回填(非工具轮)→ 不计入也不重置
|
|
||||||
} else {
|
|
||||||
let any_non_empty = recent_tool_results
|
|
||||||
.iter().any(|m| !is_empty_tool_result(&m.content));
|
|
||||||
Some(!any_non_empty)
|
|
||||||
}
|
}
|
||||||
|
if !matches!(m.role, MessageRole::Assistant) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(tool_calls) = m.tool_calls.as_ref() {
|
||||||
|
// 单 assistant 消息内多个 tool_call 按声明顺序取,倒序累积(近期在前)。
|
||||||
|
for tc in tool_calls.iter().rev() {
|
||||||
|
if sigs.len() >= STALL_BREAKER_SAMPLE_SIZE {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let args: serde_json::Value =
|
||||||
|
serde_json::from_str(&tc.function.arguments).unwrap_or(serde_json::Value::Null);
|
||||||
|
sigs.push(tool_call_signature(&tc.function.name, &args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 倒序收集的签名需反转为时间正序,保持 is_repetitive_exploration 的语义稳定
|
||||||
|
// (虽纯函数对顺序不敏感,但反转后日志/调试更直观)。
|
||||||
|
sigs.reverse();
|
||||||
|
sigs
|
||||||
};
|
};
|
||||||
|
|
||||||
let all_empty_flag = match all_empty { Some(v) => v, None => return (false, stall_count, stall_warned) };
|
// 本轮无工具调用(纯文本轮或首轮)→ 不计入也不重置,保持 stall_count。
|
||||||
|
if signatures.is_empty() {
|
||||||
|
return (false, stall_count, stall_warned);
|
||||||
|
}
|
||||||
|
|
||||||
// 2) 更新 stall_count / stall_warned
|
// 2) 签名重复判定 → 更新 stall_count / stall_warned(沿用原 reset 语义)
|
||||||
let (mut new_count, mut new_warned) = (stall_count, stall_warned);
|
let (mut new_count, mut new_warned) = (stall_count, stall_warned);
|
||||||
if all_empty_flag {
|
if is_repetitive_exploration(&signatures) {
|
||||||
new_count += 1;
|
new_count += 1;
|
||||||
} else {
|
} else {
|
||||||
new_count = 0;
|
new_count = 0;
|
||||||
@@ -2044,13 +2116,14 @@ async fn check_stall_breaker(
|
|||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
conv_id = %conv_id,
|
conv_id = %conv_id,
|
||||||
stall_count = new_count,
|
stall_count = new_count,
|
||||||
"[ai] G2 探索熔断:连续 {} 次空结果无进展,疑似目标漂移停止", new_count
|
sample_size = signatures.len(),
|
||||||
|
"[ai] G2 探索熔断:连续 {} 次检测到重复工具调用,疑似卡住停止", new_count
|
||||||
);
|
);
|
||||||
let _ = app_handle.emit(
|
let _ = app_handle.emit(
|
||||||
"ai-chat-event",
|
"ai-chat-event",
|
||||||
AiChatEvent::AiHelpRequired {
|
AiChatEvent::AiHelpRequired {
|
||||||
reason: format!("连续 {} 次工具返回空结果无进展,疑似目标漂移已停止", new_count),
|
reason: format!("连续 {} 次检测到重复的工具调用(同样的 grep/读文件反复),疑似卡住已停止", new_count),
|
||||||
context: "连续探索无产出,可能偏离原始目标或无可用数据。".into(),
|
context: "工具调用签名高度重复,可能在反复做同一件事而无进展。".into(),
|
||||||
options: vec!["回顾目标".into(), "换思路".into(), "停止".into()],
|
options: vec!["回顾目标".into(), "换思路".into(), "停止".into()],
|
||||||
conversation_id: Some(conv_id.to_string()),
|
conversation_id: Some(conv_id.to_string()),
|
||||||
},
|
},
|
||||||
@@ -2071,8 +2144,8 @@ async fn insert_stall_warning(
|
|||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
let warn_text = format!(
|
let warn_text = format!(
|
||||||
"⚠ 已连续 {} 次工具返回空结果,可能偏离目标{},请回顾目标换思路或停止探索。",
|
"⚠ 检测到重复的工具调用(同样的 grep/读文件反复),可能卡住{},请回顾目标换思路或停止探索。",
|
||||||
stall_count, goal_text
|
goal_text
|
||||||
);
|
);
|
||||||
let mut session = session_arc.lock().await;
|
let mut session = session_arc.lock().await;
|
||||||
if session.per_conv.contains_key(conv_id) {
|
if session.per_conv.contains_key(conv_id) {
|
||||||
@@ -2080,7 +2153,7 @@ async fn insert_stall_warning(
|
|||||||
conv.messages.insert_at(0, ChatMessage::system(&warn_text));
|
conv.messages.insert_at(0, ChatMessage::system(&warn_text));
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
conv_id = %conv_id, iteration, stall_count,
|
conv_id = %conv_id, iteration, stall_count,
|
||||||
"[ai] G2 探索熔断:连续空结果警示已 insert(软提示,给 LLM 自纠机会)"
|
"[ai] G2 探索熔断:重复调用警示已 insert(软提示,给 LLM 自纠机会)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2136,9 +2209,17 @@ fn push_assistant_message(
|
|||||||
let mut order: Vec<u32> = tool_calls_acc.keys().copied().collect();
|
let mut order: Vec<u32> = tool_calls_acc.keys().copied().collect();
|
||||||
order.sort_unstable();
|
order.sort_unstable();
|
||||||
let ai_tool_calls: Vec<df_ai::provider::ToolCall> = order.iter()
|
let ai_tool_calls: Vec<df_ai::provider::ToolCall> = order.iter()
|
||||||
.map(|i| {
|
.enumerate()
|
||||||
|
.map(|(pos, i)| {
|
||||||
let draft = &tool_calls_acc[i];
|
let draft = &tool_calls_acc[i];
|
||||||
df_ai::provider::ToolCall::new(&draft.id, &draft.name, &draft.args)
|
// CR-空 id 最终防御:流式 chunk 解析点(openai_helpers)已对 Some("") 生成
|
||||||
|
// fallback,但若 provider 流式完全不发 id 字段(chunk id=None,accumulate
|
||||||
|
// 不覆盖),draft.id 残留空串。此处按 sorted order 位置兜底
|
||||||
|
// `gen_stream_{pos}`,保证 push 到 messages 的 assistant tool_call.id 非空唯一
|
||||||
|
// (下游 audit/mod.rs:203 seen_ids 去重 + 工具结果按 tool_call_id 路由依赖此)。
|
||||||
|
// 非空 id 原样(与 chunk 解析点 fallback 共用 helper,DRY)。
|
||||||
|
let id = df_ai::provider::tool_call_id_or_fallback(&draft.id, pos, "gen_stream");
|
||||||
|
df_ai::provider::ToolCall::new(id, &draft.name, &draft.args)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut msg = ChatMessage::assistant_with_tools(full_text, ai_tool_calls);
|
let mut msg = ChatMessage::assistant_with_tools(full_text, ai_tool_calls);
|
||||||
|
|||||||
+28
-60
@@ -100,79 +100,47 @@ pub fn run() {
|
|||||||
*last = std::time::Instant::now();
|
*last = std::time::Instant::now();
|
||||||
}
|
}
|
||||||
let mut session = session_arc.lock().await;
|
let mut session = session_arc.lock().await;
|
||||||
// F-260616-09 B 批8(设计 §3 batch8 + §5.2):遍历 per_conv(HashMap)清多 conv
|
|
||||||
// 残留 generating(HMR/dev 热载场景多 conv 并发跑 loop 致多 conv 卡 generating)。
|
|
||||||
// 批4:per_conv 唯一真相源,删顶层 session.generating 双写复位(顶层字段已退役)。
|
|
||||||
// 读点(B-Phase2):活跃 conv 列表改读无锁 conv_states(零锁竞争);不再 iter per_conv
|
|
||||||
// 判 conv_state.is_active()。
|
|
||||||
// BUG-2026-07-08: L0 握手清理 dirty conv 前,必须先 stop 旧 loop。
|
|
||||||
// 原逻辑直接把 ConvState 改 Idle,但后台 run_agentic_loop 仍在跑(等 LLM 响应)。
|
|
||||||
// 改 Idle 后用户重发 → can_accept_request 放行 → 新旧两个 loop 同操作一个 conv →
|
|
||||||
// 消息覆盖/guard 冲突/generating 紊乱。
|
|
||||||
// 修复:先设 stop_flag + notify_one()(旧 loop 的 stream_llm select! 即时唤醒)
|
|
||||||
// + 再改 ConvState。notify_one 让阻塞在 stream.next() 的 loop 即时检查 stop_flag 退出。
|
|
||||||
let app_state_ref = app_h.state::<AppState>();
|
let app_state_ref = app_h.state::<AppState>();
|
||||||
|
// 职责分离(根治 BUG-2026-07-08 误杀活 loop):
|
||||||
|
// 生产环境前端 WebSocket 重连 / webview reload 会触发 ai-client-ready(非用户重发),
|
||||||
|
// 旧握手无脑强杀正在跑的 agentic loop(stop_flag + transition Idle + emit AiCompleted)
|
||||||
|
// → AI 中断不回复 → 用户「莫名其妙停止」(实证 cf5f44ed:seq26 后 loop 等下次 LLM 被杀)。
|
||||||
|
//
|
||||||
|
// 三道防线已就绪,握手无需也不应再强杀 loop:
|
||||||
|
// 1. guard.rs GeneratingGuard RAII:new→Generating,reset→Idle(正常),Drop→Idle(panic 兜底)。
|
||||||
|
// loop 自己复位 generating,握手强杀是多余且有害的旁路。
|
||||||
|
// 2. conv_state.rs can_accept_request 只 Idle|Error 可接 → generating 时 ai_chat_send
|
||||||
|
// 拒新消息 → 防「用户重发双 loop」(原 BUG-2026-07-08 的真实诉求)。
|
||||||
|
// 3. max_iterations 超时兜底 loop 卡死。
|
||||||
|
//
|
||||||
|
// 故握手职责收窄为「同步真实状态给前端」:读 conv_states 当前状态逐个 emit,
|
||||||
|
// 不改写 conv_states、不设 stop_flag、不收尾。loop 在跑则前端拿到 Generating,
|
||||||
|
// loop 已退出(guard 已复位)则拿到 Idle —— 前端状态与后端一致即可。
|
||||||
let dirty_convs: Vec<String> = app_state_ref.conv_states.active_convs();
|
let dirty_convs: Vec<String> = app_state_ref.conv_states.active_convs();
|
||||||
let was_generating = !dirty_convs.is_empty();
|
let was_generating = !dirty_convs.is_empty();
|
||||||
// 先对每个 dirty conv 设 stop_flag + notify 让旧 loop 退出(防双 loop 并发)。
|
|
||||||
for cid in &dirty_convs {
|
|
||||||
if let Some(c) = session.per_conv.get_mut(cid) {
|
|
||||||
c.stop_flag.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
c.notify.notify_one();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 复位每个残留生成态的 conv(批8 多 conv 全覆盖)。
|
|
||||||
// B-Phase3:conv_state 写切 ConvStateStore 单源。
|
|
||||||
for cid in &dirty_convs {
|
|
||||||
if let Err(e) = app_state_ref.conv_states.transition(
|
|
||||||
cid,
|
|
||||||
crate::commands::ai::agentic::conv_state::ConvState::Idle,
|
|
||||||
) {
|
|
||||||
tracing::warn!(
|
|
||||||
conv_id = %cid,
|
|
||||||
error = %e,
|
|
||||||
"[ai] HMR 热载 conv_states→Idle 非法(不阻断热载)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 对话透明化 L1:收集每个 dirty conv 的 pinned_goals 快照(供 emit AiCompleted 携带)
|
|
||||||
let pinned_goals_map: std::collections::HashMap<String, Vec<commands::ai::GoalEntry>> = dirty_convs
|
|
||||||
.iter()
|
|
||||||
.filter_map(|cid| {
|
|
||||||
session.per_conv.get(cid).map(|c| (cid.clone(), c.pinned_goals.clone()))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
// BUG-260619-06 修复: clear 致冷启动 restore 重建审批丢失(restore 填充后 clear 无条件清空,
|
// BUG-260619-06 修复: clear 致冷启动 restore 重建审批丢失(restore 填充后 clear 无条件清空,
|
||||||
// 重启后待审批工具全丢)。改 retain 仅清非 recovered(本次会话/HMR 死 pending),
|
// 重启后待审批工具全丢)。改 retain 仅清非 recovered(本次会话/HMR 死 pending),
|
||||||
// 保留 restore 重建(recovered=true,audit.rs:331),对齐 switchConversation retain 保护意图。
|
// 保留 restore 重建(recovered=true,audit.rs:331),对齐 switchConversation retain 保护意图。
|
||||||
// 阶段3a 单真相源合并:单表按 !recovered retain(kind 不区分,path 审批恢复恒无)。
|
// 阶段3a 单真相源合并:单表按 !recovered retain(kind 不区分,path 审批恢复恒无)。
|
||||||
session.pending_approvals.retain(|_, a| !a.recovered);
|
session.pending_approvals.retain(|_, a| !a.recovered);
|
||||||
// 在 drop(session) 前快照 active_conversation_id,供下方 idle emit 用。
|
// 握手需推状态的 conv 集合:dirty conv(后端已知活跃)+ 当前 active_conv(用户正看的)。
|
||||||
|
// dirty_convs 已含活跃 conv;active_conv 若非活跃(Idling/Eror)也补推一次让前端同步。
|
||||||
let active_conv = session.active_conversation_id.clone();
|
let active_conv = session.active_conversation_id.clone();
|
||||||
drop(session);
|
let mut convs_to_sync: Vec<String> = dirty_convs.clone();
|
||||||
// 根治:不论是否有 dirty conv,HMR 重连后始终向前端推 idle 事件,
|
|
||||||
// 防前端残留 streaming=true 导致发送按钮变停止、消息排队不发。
|
|
||||||
// dirty conv 走 AiCompleted 收尾路由(idle conv 无历史需收尾无需气泡)。
|
|
||||||
for cid in &dirty_convs {
|
|
||||||
let _ = app_h.emit(
|
|
||||||
"ai-chat-event",
|
|
||||||
commands::ai::AiChatEvent::AiCompleted {
|
|
||||||
total_tokens: 0,
|
|
||||||
prompt_tokens: 0,
|
|
||||||
completion_tokens: 0,
|
|
||||||
incomplete: None,
|
|
||||||
conversation_id: Some(cid.clone()),
|
|
||||||
pinned_goals: pinned_goals_map.get(cid).cloned().unwrap_or_default(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// 始终推 AiConvStateChanged{idle} 通知前端同步(即使后端已是 idle)。
|
|
||||||
// 前端 handleConvStateEvent → setConvState(convId, 'idle') → convStates 删项。
|
|
||||||
if let Some(ref cid) = active_conv {
|
if let Some(ref cid) = active_conv {
|
||||||
|
if !convs_to_sync.iter().any(|c| c == cid) {
|
||||||
|
convs_to_sync.push(cid.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(session);
|
||||||
|
// 逐个 conv 读 conv_states 真实状态推给前端(loop 在跑推 Generating/Compressed,
|
||||||
|
// 已退出推 Idle)。ConvStateStore.get 同步无锁,不竞争 session lock。
|
||||||
|
for cid in &convs_to_sync {
|
||||||
|
let real_state = app_state_ref.conv_states.get(cid);
|
||||||
let _ = app_h.emit(
|
let _ = app_h.emit(
|
||||||
"ai-chat-event",
|
"ai-chat-event",
|
||||||
commands::ai::AiChatEvent::AiConvStateChanged {
|
commands::ai::AiChatEvent::AiConvStateChanged {
|
||||||
conv_state: crate::commands::ai::agentic::conv_state::ConvState::Idle,
|
conv_state: real_state,
|
||||||
conversation_id: Some(cid.clone()),
|
conversation_id: Some(cid.clone()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user