优化: 弱模型工具行为治理(项目/任务清单补id + 缓存命中警告 + read_symbol 提示)
This commit is contained in:
@@ -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::<crate::state::AppState>().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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> = None;
|
||||
let mut hit_count: u32 = 0;
|
||||
for msg in msgs.iter().rev() {
|
||||
if !matches!(msg.role, MessageRole::Assistant) {
|
||||
continue;
|
||||
@@ -113,12 +144,11 @@ pub(crate) async fn find_cached_high_risk_result(
|
||||
continue;
|
||||
};
|
||||
if canonical_args_key(&old_args) == new_args_key {
|
||||
if prev_tool_call_id.is_none() {
|
||||
prev_tool_call_id = Some(tc.id.clone());
|
||||
break;
|
||||
}
|
||||
hit_count += 1;
|
||||
}
|
||||
if prev_tool_call_id.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
) -> 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<String> = None;
|
||||
let mut hit_count: u32 = 0;
|
||||
for msg in msgs.iter().rev() {
|
||||
if !matches!(msg.role, MessageRole::Assistant) {
|
||||
continue;
|
||||
@@ -226,12 +258,11 @@ pub(crate) async fn find_cached_readonly_result(
|
||||
continue;
|
||||
};
|
||||
if canonical_args_key(&old_args) == new_args_key {
|
||||
if prev_tool_call_id.is_none() {
|
||||
prev_tool_call_id = Some(tc.id.clone());
|
||||
break;
|
||||
}
|
||||
hit_count += 1;
|
||||
}
|
||||
if prev_tool_call_id.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
/// 可缓存的只读幂等工具白名单。
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user