修复: BUG-260620-05 工程内路径弹窗 + G1 多目标累积 + 测试编译债

- BUG-260620-05: reload_allowed_dirs 无条件插入 workspace_root,工程内路径免授权
- G1: pinned_goal → pinned_goals Vec<String>,多目标追加去重 + MAX_GOALS=5
- tool_registry.rs 测试 4 处补 data_dir 形参
- 更新 todo.md / docs/todo.md 状态
This commit is contained in:
2026-06-27 22:02:19 +08:00
parent 9c3f27f4ca
commit 6e1485e4f9
8 changed files with 120 additions and 105 deletions

View File

@@ -27,10 +27,10 @@
//! # 与目标钉扎(G1)的衔接(2026-06-26)
//!
//! `ConvState` 管**生成生命周期**(Idle/Generating/Stopping/Error/Compressed 5 态 7 边);
//! 目标 / 进度等**内容态**挂 [`PerConvState`](../mod.rs) 兄弟字段(如 `pinned_goal`),
//! 目标 / 进度等**内容态**挂 [`PerConvState`](../mod.rs) 兄弟字段(如 `pinned_goals`),
//! 两者**正交**。**不要把目标塞进 `ConvState` 变体** —— 否则 5 态会膨胀成
//! `GeneratingWithGoal` / `IdleWithGoal` 爆炸组合,违反「轻量状态机不引入框架」原则。
//! 目标钉扎字段(G1)与本 enum 互不感知:G1 改 `PerConvState.pinned_goal`,
//! 目标钉扎字段(G1)与本 enum 互不感知:G1 改 `PerConvState.pinned_goals`,
//! 本文件 enum/impl/transition_to 守卫/guard.rs 零改动。
use serde::{Deserialize, Serialize};

View File

@@ -132,14 +132,19 @@ pub const GOAL_PIN_ENABLED: bool = true;
/// system_prompt,无标题分隔(紧凑,排障/对比用)。
pub const GOAL_INJECT_BANNER: bool = true;
/// G1 截断长度:目标文本截断上限(默认 500 字符)。
/// G1 截断长度:每条目标文本截断上限(默认 500 字符)。
///
/// 防 R1 反向风险:长 user 消息(粘贴需求文档/长 bug 描述)每轮占 system_prompt 预算。
/// system_prompt 虽不被裁剪但仍计 sys_tokens 占预算,故截断防长目标撑爆。500 保守(首版,
/// 可调),足够覆盖正常一句话目标。截断后追加「…」省略号标识。
pub const GOAL_MAX_CHARS: usize = 500;
/// G4 目标感知降级:话题标记 insert 当 pinned_goal 存在时跳过(默认 true)。
/// G1 多目标上限:最多累积目标数(默认 5)。
///
/// 防无限膨胀:每次发消息提取的目标追加到 pinned_goals vec,超上限时淘汰最早目标。
pub const MAX_GOALS: usize = 5;
/// G4 目标感知降级:话题标记 insert 当 pinned_goals 存在时跳过(默认 true)。
///
/// 治 R2(话题标记反向误导):G1 目标钉扎生效后每轮 system_prompt 已含目标,topic marker 的
/// 「请以新话题为准」软提示成冗余且与目标矛盾(诊断 §三双锚点稀释)。true(默认)= 当
@@ -768,49 +773,52 @@ pub(crate) async fn run_agentic_loop(
// BUG-260617-12: DeepSeek thinking 模式推理内容跨轮透传
let mut last_reasoning_content: Option<String> = None;
// G1 目标钉扎:入口把 PerConvState.pinned_goal 拼进 system_prompt 尾部(一次拼好整个 loop 复用)。
// G1 目标钉扎:入口把 PerConvState.pinned_goals 拼进 system_prompt 尾部(一次拼好整个 loop 复用)。
//
// 治 R1(目标消息被压缩出局)/R5(prompt 说教无锚点):system_prompt 是 loop 不变量 + build_for_request
// 从不裁剪它,故目标天然免疫压缩/裁剪/sanitize。本块是治 R1 的结构性根因(目标进 prompt 字符串非
// messages 流,无 insert_at(0) 的连续 System 1214/首位锚点稀释/小预算被裁三重风险)。
//
// 单次 lock 读 pinned_goal clone(复用 L693-697 stop_flag 取用模式,同一 lock 块);Some 且非空 →
// 截断到 GOAL_MAX_CHARS,按 GOAL_INJECT_BANNER 拼 banner+目标。GOAL_PIN_ENABLED=false → 整块跳过,
// pinned_goal 永远 None(单点回退等价改动前)。拼接在 sys_tokens 估算前(sys_tokens 估算拼接后的 prompt)
// 单次 lock 读 pinned_goals clone(复用 stop_flag 取用模式,同一 lock 块);非空 → 逐条截断到
// GOAL_MAX_CHARS,按 GOAL_INJECT_BANNER 拼 banner+编号列表。GOAL_PIN_ENABLED=false → 整块跳过,
// pinned_goals 永远空 Vec(单点回退等价改动前)。拼接在 sys_tokens 估算前。
//
// 注:仅 run_agentic_loop 入口注入。手动压缩(ai_chat_compress_context IPC)/标题/提炼等路径不注入
// 目标(对齐 openQuestions 决策:首版仅 agentic loop 入口拼,其他路径不动)。
// 注:仅 run_agentic_loop 入口注入。手动压缩/标题/提炼等路径不注入目标。
let mut system_prompt = system_prompt;
if GOAL_PIN_ENABLED {
let goal_opt: Option<String> = {
let goals: Vec<String> = {
let session = session_arc.lock().await;
session
.conv_read(&conv_id)
.and_then(|c| c.pinned_goal.clone())
.map(|c| c.pinned_goals.clone())
.unwrap_or_default()
};
if let Some(goal) = goal_opt {
let goal_text = goal.trim();
if !goal_text.is_empty() {
let goal_text: String = goal_text.chars().take(GOAL_MAX_CHARS).collect();
let goal_text = if goal_text.chars().count() >= GOAL_MAX_CHARS {
format!("{}", goal_text)
if !goals.is_empty() {
let goal_lines: Vec<String> = goals.iter().enumerate().map(|(i, g)| {
let g = g.trim();
let truncated: String = g.chars().take(GOAL_MAX_CHARS).collect();
let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS {
format!("{}", truncated)
} else {
goal_text
truncated
};
system_prompt = if GOAL_INJECT_BANNER {
format!(
"{}\n\n## 当前目标(全程锚定,所有动作须服务于它)\n{}",
system_prompt, goal_text
)
} else {
format!("{}\n\n{}", system_prompt, goal_text)
};
tracing::info!(
conv_id = %conv_id,
chars = goal_text.chars().count(),
"[ai] G1 目标钉扎:已把 pinned_goal 拼进 system_prompt"
);
}
format!("{}. {}", i + 1, truncated)
}).collect();
let goals_text = goal_lines.join("\n");
system_prompt = if GOAL_INJECT_BANNER {
format!(
"{}\n\n## 当前目标(全程锚定,所有动作须服务于它们)\n{}",
system_prompt, goals_text
)
} else {
format!("{}\n\n{}", system_prompt, goals_text)
};
tracing::info!(
conv_id = %conv_id,
count = goals.len(),
"[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt",
goals.len()
);
}
}
@@ -879,16 +887,11 @@ pub(crate) async fn run_agentic_loop(
return;
}
let conv = session.conv(&conv_id);
// G4 目标感知降级:总是 take_topic_marker(防 marker 累积),但若 pinned_goal 存在
// G4 目标感知降级:总是 take_topic_marker(防 marker 累积),但若 pinned_goals 非空
// 且 TOPIC_MARKER_GOAL_AWARE → 丢弃 take 结果(返 None 跳过 insert)。
// 一次 lock 同读 pinned_goal(避免额外加锁)。take 后丢弃不影响下一轮(marker 每 push
// 一次 lock 同读 pinned_goals(避免额外加锁)。take 后丢弃不影响下一轮(marker 每 push
// user 重检测生成,丢弃一次不残留)。GOAL_AWARE=false → 原样返回 marker(退旧行为)。
let goal_active = TOPIC_MARKER_GOAL_AWARE
&& conv
.pinned_goal
.as_deref()
.map(|g| !g.trim().is_empty())
.unwrap_or(false);
let goal_active = TOPIC_MARKER_GOAL_AWARE && !conv.pinned_goals.is_empty();
let marker = conv.messages.take_topic_marker();
if goal_active && marker.is_some() {
tracing::info!(
@@ -1664,16 +1667,21 @@ pub(crate) async fn run_agentic_loop(
{
stall_warned = true;
let goal_text = if STALL_BREAKER_GOAL_REMIND {
let g = session_arc
let goals = session_arc
.lock()
.await
.conv_read(&conv_id)
.and_then(|c| c.pinned_goal.clone())
.map(|c| c.pinned_goals.clone())
.unwrap_or_default();
if g.trim().is_empty() {
if goals.is_empty() {
String::new()
} else {
format!("(当前目标: {})", g.trim())
let goal_summary = goals.iter()
.map(|g| g.trim())
.filter(|g| !g.is_empty())
.collect::<Vec<_>>()
.join("; ");
format!("(当前目标: {})", goal_summary)
}
} else {
String::new()

View File

@@ -24,7 +24,7 @@ use crate::state::AppState;
use crate::commands::{err_str, now_millis};
// chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。
use super::super::agentic::{run_agentic_loop, try_continue_agent_loop, GOAL_MAX_CHARS, GOAL_PIN_ENABLED};
use super::super::agentic::{run_agentic_loop, try_continue_agent_loop, GOAL_MAX_CHARS, GOAL_PIN_ENABLED, MAX_GOALS};
// 双轨收口批1:读侧入口拦截用 ConvState(can_accept_request 的 unwrap_or 兜底初值)。
use super::super::agentic::conv_state::ConvState;
use super::super::audit::{audit_finalize, emit_data_changed};
@@ -72,10 +72,10 @@ pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSessio
}
}
/// G1 目标钉扎:从 user 消息文本提取目标(纯函数,无 IO / 无额外 LLM 请求)。
/// G1 目标钉扎:从 user 消息文本提取单条目标(纯函数,无 IO / 无额外 LLM 请求)。
///
/// 治 R1(目标消息被压缩出局)的提取侧:首条 active user 消息即原始目标(push 时还 active),
/// 提取后 `PerConvState.pinned_goal` 绕过消息 active 状态过滤,即使原消息出局 goal 仍在。
/// 治 R1(目标消息被压缩出局)的提取侧:每次 user 消息的文本即潜在目标,
/// 提取后追加到 `PerConvState.pinned_goals` Vec(去重),即使原消息出局目标仍在。
///
/// 处理(零额外请求,对齐 GLM 限额口径 1 请求=1 次):
/// 1. strip 所有 `[kind:...]` mention 段(augmentation 已投影,目标文本去噪)。对齐 intent.rs
@@ -84,7 +84,7 @@ pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSessio
/// 2. trim 空白。
/// 3. 截断到 GOAL_MAX_CHARS(防长需求文档撑爆 system_prompt 占预算)。
///
/// 返回空串(纯 mention / 空消息)→ 调用方判非空才写入 pinned_goal(避免空目标注入)。
/// 返回空串(纯 mention / 空消息)→ 调用方判非空才追加到 pinned_goals(避免空目标注入)。
/// 默认用原文非 LLM 提取(消息锚点派 keepIdeas:零额外请求)。
pub(crate) fn extract_pinned_goal(text: &str) -> String {
// strip 所有 [kind:...] mention 段(对齐 intent.rs strip_mention_tags/try_match_mention 逻辑,
@@ -463,12 +463,15 @@ pub async fn ai_chat_send(
// DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id),
// 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。
let user_msg_id = conv.messages.last_user_message_id();
// G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,支持中途换目标)。
// GOAL_PIN_ENABLED=false → 跳过(单点回退);extract_pinned_goal 返空(纯 mention 前缀)→ 不刷新
// G1 目标钉扎:push 后提取本次 user 目标追加到 pinned_goals(去重,支持累积多目标)。
// GOAL_PIN_ENABLED=false → 跳过;extract_pinned_goal 返空 → 不追加
if GOAL_PIN_ENABLED {
let goal = extract_pinned_goal(&user_content);
if !goal.is_empty() {
conv.pinned_goal = Some(goal);
if !goal.is_empty() && !conv.pinned_goals.contains(&goal) {
conv.pinned_goals.push(goal);
if conv.pinned_goals.len() > MAX_GOALS {
conv.pinned_goals.remove(0);
}
}
}
(target, user_msg_id)
@@ -1362,8 +1365,11 @@ pub async fn ai_chat_edit(
// G1 目标钉扎:替换消息后重新提取目标(用户可能通过编辑改变了意图,与 send 路径一致)
if GOAL_PIN_ENABLED {
let goal = extract_pinned_goal(&new_message);
if !goal.is_empty() {
conv.pinned_goal = Some(goal);
if !goal.is_empty() && !conv.pinned_goals.contains(&goal) {
conv.pinned_goals.push(goal);
if conv.pinned_goals.len() > MAX_GOALS {
conv.pinned_goals.remove(0);
}
}
}
}
@@ -1531,13 +1537,15 @@ pub async fn ai_chat_force_send(
}
// F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。
let user_msg_id = conv.messages.last_user_message_id();
// G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,与 send 路径一致,支持中途换目标)。
// force_send 是用户新指令,审批续跑会复用此目标(对齐 G1 changes:审批期间用户发新消息 →
// pinned_goal 已更新,续跑用新目标)。
// G1 目标钉扎:push 后提取本次 user 目标追加到 pinned_goals(去重,与 send/edit 路径一致,支持累积多目标)。
// force_send 是用户新指令,审批续跑会复用已累积目标。
if GOAL_PIN_ENABLED {
let goal = extract_pinned_goal(&user_content);
if !goal.is_empty() {
conv.pinned_goal = Some(goal);
if !goal.is_empty() && !conv.pinned_goals.contains(&goal) {
conv.pinned_goals.push(goal);
if conv.pinned_goals.len() > MAX_GOALS {
conv.pinned_goals.remove(0);
}
}
}
(was_gen.then_some(target.clone()), target, user_msg_id)

View File

@@ -776,17 +776,17 @@ pub struct PerConvState {
/// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。
/// 批3 双轨收口:generating bool 已退役,此 enum 成为生成态唯一真相源。
pub conv_state: ConvState,
/// G1 目标钉扎真相源:用户首条 active 消息提取的目标(内容态字段,与生命周期态正交)。
/// G1 目标钉扎真相源:用户消息提取的目标列表(内容态字段,与生命周期态正交)。
///
/// 治 R1(目标消息被压缩/compressed 物理出局,sanitize step0 过滤 is_active 致目标丢失):
/// 目标存本字段绕过消息 active 状态过滤,即使原始 user 消息出局,goal 字段仍在。
/// run_agentic_loop 入口把它拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然
/// 目标存本字段绕过消息 active 状态过滤,即使原始 user 消息出局,goal 仍在。
/// run_agentic_loop 入口拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然
/// 免疫压缩/裁剪/sanitize,见 agentic/mod.rs:683)。
///
/// 写:chat.rs send/force_send push 后提取(每次覆盖,支持中途换目标);读:loop 入口拼接。
/// GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远 None(单点回退等价改动前)
/// 随会话销毁不落库(对齐 knowledge_extracted L795 语义,PerConvState 无 serde derive)。
pub pinned_goal: Option<String>,
/// 写:chat.rs send/force_send/edit push 后提取目标追加到列表(去重,支持累积多目标);
/// 读:loop 入口拼接全部目标。GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远空 Vec
/// 随会话销毁不落库(对齐 knowledge_extracted 语义,PerConvState 无 serde derive)。
pub pinned_goals: Vec<String>,
/// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出
pub stop_flag: Arc<AtomicBool>,
/// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select!
@@ -833,12 +833,12 @@ impl PerConvState {
/// - session_trust: HashSet::new()
/// - created_at: None(批1 新增字段,AiSession 现有 active_conv_created_at 同语义)
/// - knowledge_extracted: false(新会话未提炼,P1 去重标志)
/// - pinned_goal: None(G1 目标钉扎,新会话未提取目标)
/// - pinned_goals: Vec::new()(G1 目标钉扎,新会话未提取目标)
pub fn new() -> Self {
Self {
messages: ContextManager::new(ContextConfig::default()),
conv_state: ConvState::Idle,
pinned_goal: None,
pinned_goals: Vec::new(),
stop_flag: Arc::new(AtomicBool::new(false)),
notify: Arc::new(tokio::sync::Notify::new()),
iteration_used: 0,

View File

@@ -2963,7 +2963,7 @@ mod tests {
// F-260619-03 Phase A: build_ai_tool_registry 新增 allowed_dirs 形参,
// 测试用 default_with_root(仅 workspace_root),零回归(白名单含 workspace_root)。
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
let registry = build_ai_tool_registry(&db, &allowed_dirs);
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""));
// 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。
// F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。
@@ -3210,7 +3210,7 @@ mod tests {
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let db = Arc::new(db);
let registry = build_ai_tool_registry(&db, &allowed_dirs);
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""));
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
let args = serde_json::json!({ "path": canon_file, "limit": 15 });
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
@@ -3237,7 +3237,7 @@ mod tests {
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let db = Arc::new(db);
let registry = build_ai_tool_registry(&db, &allowed_dirs);
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""));
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
let args = serde_json::json!({ "path": canon_file });
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
@@ -3719,7 +3719,7 @@ mod tests {
.await
.unwrap();
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
let registry = build_ai_tool_registry(&db, &allowed_dirs);
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""));
(db, registry)
}

View File

@@ -436,8 +436,8 @@ pub struct AppState {
///
/// - Phase A:`persistent`(持久化白名单,从 Settings KV `allowed_dirs` 加载,
/// JSON 数组 `["E:/wk-lab/u-abc"]`)。`resolve_workspace_path` 校验时:
/// - workspace_root 始终视为已授权(向后兼容,默认根)
/// - 任一 persistent 目录 starts_with 命中即放行
/// - 任一 persistent 或 session 目录 starts_with 命中即放行
/// - 无任何授权时全部拒绝(引导用户绑定项目或授权目录)
/// - Phase B:`session`(进程级会话临时授权,弹窗"仅本次"写入;切换/新建/删除会话清空)。
/// 单用户桌面应用 active_conversation_id 单全局模型,session 字段随 active 切换清空,
/// 行为等价"当前活跃会话的临时授权"。handler 闭包(read lock 取快照)与
@@ -574,7 +574,7 @@ pub(crate) fn is_in_system_blacklist(path: &Path) -> bool {
/// F-260619-03 Phase B/C: 路径授权预校验(供 process_tool_calls 分类前调)。
///
/// 词法层判定(不 canonicalize,因路径可能不存在 — write_file 新建)。返回三态决策:
/// - 路径规范化(去 .. / 锚定 workspace_root)后,若命中黑名单 → `Denied`
/// - 路径规范化(去 .. / 锚定首个持久授权目录)后,若命中黑名单 → `Denied`
/// - 否则若 `is_authorized(规范化路径)`(persistent + session) → `Authorized`
/// - 否则 → `NeedsAuthorization { dir: 父目录规范化 }`(目录粒度,对齐 session_trust)
///
@@ -843,8 +843,9 @@ impl AppState {
all_dirs.sort();
all_dirs.dedup();
let mut set = HashSet::new();
// 方案①:KV + 项目绑定均空时不再硬塞 workspace_root(分发后该路径无效)。
// 用户首次访问任意目录走弹窗三档授权,授权后落 KV 持久
// 始终插入 workspace_root(工程内路径默认免授权,对齐用户政策 + 注释承诺)。
// BUG-260620-05 修:去掉 all_dirs.is_empty() 条件,无条件插入,不再依赖 KV/project_dirs 是否存在
set.insert(workspace_root_path());
for d in all_dirs {
let d = d.trim();
if d.is_empty() {