diff --git a/src-tauri/src/commands/ai/audit/approval.rs b/src-tauri/src/commands/ai/audit/approval.rs index b9d9a5f..25e4557 100644 --- a/src-tauri/src/commands/ai/audit/approval.rs +++ b/src-tauri/src/commands/ai/audit/approval.rs @@ -15,7 +15,7 @@ use df_ai::provider::ChatMessage; use df_storage::crud::AiToolExecutionRepo; use df_storage::db::Database; -use super::cache::{find_cached_high_risk_result, pending_placeholder_for}; +use super::cache::{cache_hit_warning, find_cached_high_risk_result, pending_placeholder_for}; use super::diff::build_write_file_diff; use super::reason::build_approval_reason; use super::record::audit_tool_call; @@ -320,29 +320,34 @@ pub(super) async fn handle_approval_tool( // ── Step 3: F-05 高危去重缓存(仅 High) ── if matches!(risk_level, RiskLevel::High) { // find_cached_high_risk_result 内部短 lock 读 messages + 锁外 await DB 查 status, - // 返回 (cached_content, status) 时不持锁 - if let Some((cached, status)) = find_cached_high_risk_result(session_arc, conv_id, audit_repo, &draft.name, &args).await { - // 命中:把缓存结果作为新 tool_call_id 的 tool_result 回传,跳过审批 + // 返回 (cached_content, status, hit_count) 时不持锁 + if let Some((cached, status, hit_count)) = find_cached_high_risk_result(session_arc, conv_id, audit_repo, &draft.name, &args).await { + // 命中:把缓存结果作为新 tool_call_id 的 tool_result 回传,跳过审批。 + // AC-1 根治:同只读缓存,前置「重复调用拦截」警告头,弱模型才知道这是缓存结果, + // 不会再次重试同命令(防「超时→重试→重新审批」循环反复触发)。 + let warned_content = format!("{}{}", cache_hit_warning(&draft.name, hit_count), cached); tracing::info!( tool = %draft.name, new_tool_call_id = %draft.id, - "[F-05] 高危工具去重命中:LLM 重试同命令,复用缓存结果跳过审批(断循环)" + hit_count, + "[F-05] 高危工具去重命中(第 {} 次):LLM 重试同命令,复用缓存结果跳过审批(断循环)", + hit_count ); // 短 lock 段:push tool_result(纯写,无 await) { let mut session = session_arc.lock().await; - session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &cached)); + session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &warned_content)); } // L3 emit 双写:高危去重命中复用缓存 emit Completed 双路发布。 let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), - result: serde_json::Value::String(cached.clone()), + result: serde_json::Value::String(warned_content.clone()), conversation_id: Some(conv_id.to_string()), }; let _ = app_handle.emit("ai-chat-event", ev.clone()); let _ = app_handle.state::().ai_event_bus.publish_event(ev); // 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,SW-260618-16;decided_by=auto_dedup),不进 pending - audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(cached), Some("auto_dedup"), current_message_id).await; + audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(warned_content), Some("auto_dedup"), current_message_id).await; return; } } diff --git a/src-tauri/src/commands/ai/audit/cache.rs b/src-tauri/src/commands/ai/audit/cache.rs index de9d9f1..3bfd3b4 100644 --- a/src-tauri/src/commands/ai/audit/cache.rs +++ b/src-tauri/src/commands/ai/audit/cache.rs @@ -46,6 +46,35 @@ pub(crate) fn is_pending_placeholder(content: &str) -> bool { df_ai::context_helpers::is_pending_placeholder(content) } +/// 缓存命中时前置的「重复调用拦截」警告头。 +/// +/// 治弱模型(尤其小参数模型)死循环重调同参工具:LLM 只能看到 tool_result, +/// prompt 层说教无效,必须在结果里机制化显式警告。仅真命中缓存才加(首次真执行不加), +/// 命中次数 hit_count 累计「第 N 次」,数字递增对弱模型更有威慑,促其换参数/换工具。 +/// 返回的字符串以换行结尾,调用方拼接缓存结果原样附后。 +pub(crate) fn cache_hit_warning(tool_name: &str, hit_count: u32) -> String { + format!( + "[重复调用拦截] 工具 {} 相同参数此前已成功执行过(第 {} 次命中缓存),以下为缓存结果。不要重复调用相同参数的工具,如需新信息请换参数或换工具。\n\n", + tool_name, hit_count + ) +} + +/// namespace 引用展开为真实内容(缓存回填前置)。 +/// +/// 大工具结果(read_file/list_directory/grep 等)存 namespace 引用(ns://tool/key)于消息, +/// 发 LLM 前由 agentic 侧按「内容以 namespace:// 开头」展开。缓存命中回填要前置警告头, +/// 若仍原样带引用拼在警告后,前缀判定失效致 LLM 只看到无意义 URI。故此处先展开: +/// 引用 → 真实内容;引用被 LRU 淘汰 → EVICTED_PLACEHOLDER 提示;非引用 → 原样。 +fn resolve_namespace_content(session: &AiSession, content: &str) -> String { + if df_ai::namespace_store::is_namespace_ref(content) { + session.namespace_store.read_only(content) + .map(str::to_owned) + .unwrap_or_else(|| df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string()) + } else { + content.to_string() + } +} + /// 高危工具去重(根治 run_command 超时→重试→重新审批循环)。 /// /// ⚠ 性能注记:本函数在 session lock 持有期间对每个 high risk 工具 @@ -78,7 +107,7 @@ pub(crate) async fn find_cached_high_risk_result( audit_repo: &AiToolExecutionRepo, tool_name: &str, args: &serde_json::Value, -) -> Option<(String, String)> { +) -> Option<(String, String, u32)> { use df_ai::provider::MessageRole; // 规范化新调用的 args 为可比字符串(排序键,键序无关) @@ -87,7 +116,7 @@ pub(crate) async fn find_cached_high_risk_result( // 短 lock 段读 messages + 找旧 tool_call_id + 旧 tool_result content, // drop 锁后再锁外 await DB 查 status(原代码持锁 await audit_repo,违反持锁 await 慢操作禁令)。 // 第一步:锁内(async block 包裹,出 block 自动 drop guard)反向扫描,定位旧 tool_call_id 与对应 tool_result content - let cached: Option<(String, String)> = (async { + let cached: Option<(String, String, u32)> = (async { let session = session_arc.lock().await; // 读 per_conv.messages。process_tool_calls 调用前 loop 入口已桥接建立 per_conv, // 故 conv_read 必命中;防御性 None 时返 None(无缓存命中,走原审批流程)。 @@ -96,9 +125,11 @@ pub(crate) async fn find_cached_high_risk_result( // 单对话消息量小(百级),collect 开销可忽略。 let msgs: Vec<&ChatMessage> = conv.messages.iter().collect(); - // 1) 反向扫描 assistant tool_calls,找最近一条同名同参的 High 工具调用 → 拿到旧 tool_call_id + // 1) 反向扫描 assistant tool_calls,找最近一条同名同参的 High 工具调用 → 拿到旧 tool_call_id, + // 同时累计同名同参调用总数(hit_count 供「第 N 次命中缓存」威慑)。 // 反向:循环是「最近一次超时→重试」,命中通常是末尾附近,反向先停省全扫。 let mut prev_tool_call_id: Option = None; + let mut hit_count: u32 = 0; for msg in msgs.iter().rev() { if !matches!(msg.role, MessageRole::Assistant) { continue; @@ -113,13 +144,12 @@ pub(crate) async fn find_cached_high_risk_result( continue; }; if canonical_args_key(&old_args) == new_args_key { - prev_tool_call_id = Some(tc.id.clone()); - break; + if prev_tool_call_id.is_none() { + prev_tool_call_id = Some(tc.id.clone()); + } + hit_count += 1; } } - if prev_tool_call_id.is_some() { - break; - } } // 2) 用旧 tool_call_id 找对应 tool_result。注意:审批拒绝/超时失败也属「已落定」, @@ -141,21 +171,21 @@ pub(crate) async fn find_cached_high_risk_result( if is_pending_placeholder(&msg.content) { return None; } - return Some((old_id, msg.content.clone())); + return Some((old_id, resolve_namespace_content(&session, &msg.content), hit_count)); } None }).await; // 锁外:查审计表拿缓存来源真实 status(completed/rejected/failed),透传给 // audit_tool_call 而非固定 completed(审计语义与结果内容一致,防"rejected/failed 结果 // 记 completed"误导安全追溯)。审计记录缺失/查询失败 fallback completed(不阻塞去重,降级原行为)。 - let (old_id, content) = cached?; + let (old_id, content, hit_count) = cached?; let status = audit_repo .find_by_tool_call_id(old_id.as_str()) .await .ok() .and_then(|opt| opt.map(|rec| rec.status)) .unwrap_or_else(|| "completed".to_string()); - Some((content, status)) + Some((content, status, hit_count)) } /// 把 JSON args 规范化为可比字符串:对象键按字典序排序后序列化, @@ -195,7 +225,7 @@ pub(crate) async fn find_cached_readonly_result( audit_repo: &AiToolExecutionRepo, tool_name: &str, args: &serde_json::Value, -) -> Option { +) -> Option<(String, u32)> { use df_ai::provider::MessageRole; // 白名单:只读幂等工具才缓存。写工具/有副作用工具永不缓存。 @@ -206,13 +236,15 @@ pub(crate) async fn find_cached_readonly_result( let new_args_key = canonical_args_key(args); // 短 lock 段读 messages + 锁外 await DB 查 status(原代码持锁 await) - let cached: Option<(String, String)> = (async { + let cached: Option<(String, String, u32)> = (async { let session = session_arc.lock().await; let Some(conv) = session.conv_read(conv_id) else { return None }; let msgs: Vec<&ChatMessage> = conv.messages.iter().collect(); - // 1) 反向扫描 assistant tool_calls,找最近一条同名同参调用 → 拿到旧 tool_call_id + // 1) 反向扫描 assistant tool_calls,找最近一条同名同参调用 → 拿到旧 tool_call_id, + // 同时累计同名同参调用总数(hit_count 供「第 N 次命中缓存」威慑)。 let mut prev_tool_call_id: Option = None; + let mut hit_count: u32 = 0; for msg in msgs.iter().rev() { if !matches!(msg.role, MessageRole::Assistant) { continue; @@ -226,13 +258,12 @@ pub(crate) async fn find_cached_readonly_result( continue; }; if canonical_args_key(&old_args) == new_args_key { - prev_tool_call_id = Some(tc.id.clone()); - break; + if prev_tool_call_id.is_none() { + prev_tool_call_id = Some(tc.id.clone()); + } + hit_count += 1; } } - if prev_tool_call_id.is_some() { - break; - } } // 2) 用旧 tool_call_id 找对应 tool_result @@ -248,12 +279,12 @@ pub(crate) async fn find_cached_readonly_result( if is_pending_placeholder(&msg.content) { return None; } - return Some((old_id, msg.content.clone())); + return Some((old_id, resolve_namespace_content(&session, &msg.content), hit_count)); } None }).await; // 锁外:查审计表确认是 completed 成功结果(失败/拒绝不缓存,给 LLM 重试机会) - let (old_id, content) = cached?; + let (old_id, content, hit_count) = cached?; let status = audit_repo .find_by_tool_call_id(old_id.as_str()) .await @@ -263,7 +294,7 @@ pub(crate) async fn find_cached_readonly_result( if status != "completed" { return None; } - Some(content) + Some((content, hit_count)) } /// 可缓存的只读幂等工具白名单。 diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index d026ccd..50a5b46 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -67,7 +67,7 @@ pub(crate) use finalize::audit_finalize; // cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。 // 第三批从本文件抽离,行为零变更。 mod cache; -pub(super) use cache::{find_cached_readonly_result, pending_placeholder_for}; +pub(super) use cache::{cache_hit_warning, find_cached_readonly_result, pending_placeholder_for}; // data_change(audit/data_change.rs):AR-11 数据变更联动刷新。 // 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见 @@ -586,13 +586,17 @@ pub(crate) async fn process_tool_calls( let mut low_risk_uncached: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::with_capacity(low_risk.len()); for (draft, args, risk_level) in low_risk { let cached = find_cached_readonly_result(session_arc, conv_id, &audit_repo, &draft.name, &args).await; - if let Some(cached_content) = cached { + if let Some((cached_content, hit_count)) = cached { // 缓存命中:直接 push tool_result + 审计(decided_by=cache_hit 标记缓存来源), // 不走真执行 + 不重emit Started/Completed(避免误导前端工具又执行了一次)。 + // AC-1 根治:弱模型不知道结果来自缓存,仍死循环重调同参工具。此处给回填的 + // tool_result 前置「重复调用拦截」警告头(机制化提示,LLM 能看到 tool_result), + // 命中次数递增威慑,告知勿再重复调用相同参数的工具。 + let warned_content = format!("{}{}", cache_hit_warning(&draft.name, hit_count), cached_content); // emit Completed 携带缓存结果供前端折叠卡片展示(与 find_cached_high_risk_result 一致)。 let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), - result: serde_json::Value::String(cached_content.clone()), + result: serde_json::Value::String(warned_content.clone()), conversation_id: Some(conv_id.to_string()), }; let _ = app_handle.emit("ai-chat-event", ev.clone()); @@ -600,18 +604,20 @@ pub(crate) async fn process_tool_calls( // 短 lock 段:push tool_result(纯写,无 await) { let mut session = session_arc.lock().await; - session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &cached_content)); + session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &warned_content)); } audit_tool_call( &audit_repo, conv_id, &draft.id, &draft.name, &draft.args, - "completed", risk_level, Some(cached_content), Some("cache_hit"), + "completed", risk_level, Some(warned_content), Some("cache_hit"), current_message_id, ).await; tracing::info!( conv_id = %conv_id, tool = %draft.name, tc_id = %draft.id, - "[ai] 只读工具缓存命中,跳过真执行(治 LLM 死循环重调)" + hit_count, + "[ai] 只读工具缓存命中(第 {} 次),已加警告头回填,跳过真执行(治 LLM 死循环重调)", + hit_count ); } else { low_risk_uncached.push((draft, args, risk_level)); diff --git a/src-tauri/src/commands/ai/prompt.rs b/src-tauri/src/commands/ai/prompt.rs index 8488393..e549db6 100644 --- a/src-tauri/src/commands/ai/prompt.rs +++ b/src-tauri/src/commands/ai/prompt.rs @@ -249,8 +249,8 @@ fn app_config_query_guidance_section(lang: &str) -> &'static str { /// 构建系统提示词(环境信息 + 固定前缀 + 当前项目/任务**全局清单**) /// -/// 本函数注入"全貌"清单:最近 20 项目 + 20 任务的 name/status/description(无 path), -/// 供 LLM 知道当前存在哪些实体(非精准引用)。 +/// 本函数注入"全貌"清单:最近 20 项目 + 20 任务的 id/name/status/description(无 path), +/// 供 LLM 知道当前存在哪些实体并拿到各自 id(清单含 id,引用时用 id 而非名称)。 /// /// **被@实体的精准投影**(含 path 脱敏、剥 frontmatter 的 skill 正文)由 chat.rs 的 /// augmentation 层(`ResolverRegistry::resolve_all` + `build_augmentation_segment`)处理, @@ -301,7 +301,7 @@ pub(crate) async fn build_system_prompt_with_excluded( if excluded_project_ids.iter().any(|id| id == &p.id) { continue; } - prompt.push_str(&format!("- {} ({}): {}\n", p.name, p.status.as_str(), p.description)); + prompt.push_str(&format!("- {} (id: {}) ({}): {}\n", p.name, p.id, p.status.as_str(), p.description)); if let Some(ref dir) = p.path { prompt.push_str(&format!(" 目录: {}\n", dir)); } @@ -320,7 +320,7 @@ pub(crate) async fn build_system_prompt_with_excluded( if excluded_task_ids.iter().any(|id| id == &tk.id) { continue; } - prompt.push_str(&format!("- {} ({}): {}\n", tk.title, tk.status.as_str(), tk.description)); + prompt.push_str(&format!("- {} (id: {}) ({}): {}\n", tk.title, tk.id, tk.status.as_str(), tk.description)); } // 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks prompt.push_str(&tasks_listed_note(lang)); @@ -349,11 +349,11 @@ pub(crate) async fn build_system_prompt_with_excluded( fn projects_listed_note(lang: &str, shown: usize) -> String { match lang { "en" => format!( - "(Listed {} active projects above; the active set is shown, no need to call list_projects again.)\n", + "(Listed {} active projects above; the active set is shown, no need to call list_projects again. Each entry carries its id — reference projects by id, not by name.)\n", shown ), _ => format!( - "(以上为活跃项目清单,无需重复调用 list_projects)\n" + "(以上为活跃项目清单,无需重复调用 list_projects;每条已含 id,引用项目时用 id 而非名称)\n" ), } } @@ -364,8 +364,8 @@ fn projects_listed_note(lang: &str, shown: usize) -> String { /// 而反复调 list_tasks 校验(实测单会话 20 次)。 fn tasks_listed_note(lang: &str) -> String { match lang { - "en" => "(Only the 20 most recent tasks are shown. For the full list or filtered by project, use the list_tasks tool.)\n".to_string(), - _ => "(仅显示最近 20 条任务,全量或按项目查询请用 list_tasks 工具)\n".to_string(), + "en" => "(Only the 20 most recent tasks are shown. For the full list or filtered by project, use the list_tasks tool. Each entry carries its id — reference tasks by id, not by title/name.)\n".to_string(), + _ => "(仅显示最近 20 条任务,全量或按项目查询请用 list_tasks 工具;每条已含 id,引用任务时用 id 而非名称)\n".to_string(), } } diff --git a/src-tauri/src/commands/ai/tools/file.rs b/src-tauri/src/commands/ai/tools/file.rs index c979fd1..569e0f7 100644 --- a/src-tauri/src/commands/ai/tools/file.rs +++ b/src-tauri/src/commands/ai/tools/file.rs @@ -182,8 +182,21 @@ pub fn register( consumed, )) } else { None }; + // 机制提示(AC-4,治弱模型 read_file 全量回灌):内容读取成功且扩展名在 read_symbol + // 支持语言集合内,则末尾追加一句引导提示改用 read_symbol 精确提取符号,避免读全文件 + // 浪费上下文。复用 code_intel::is_supported_ext 单真相源(与 read_symbol handler 语言 + // 集合一致,不重复定义)。仅首次读取(无 offset)追加,offset 分页续读不重复提示防噪音。 + let content_field = if offset_used.is_none() + && crate::commands::ai::code_intel::is_supported_ext( + path.rsplit('.').next().unwrap_or("").to_lowercase().as_str(), + ) + { + format!("{result}\n[提示] 如需定位该文件的函数/类/符号,请改用 read_symbol(路径, 符号名) 精确提取,避免读全文件浪费上下文。") + } else { + result + }; Ok(serde_json::json!({ - "path": path, "content": result, "size": metadata.len(), "file_hash": file_hash, "lines": line_count, + "path": path, "content": content_field, "size": metadata.len(), "file_hash": file_hash, "lines": line_count, "offset": offset_used, "returned_lines": returned_lines, "truncated": has_more,