修复: 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:
lxy
2026-06-27 22:02:41 +08:00
parent 9c3f27f4ca
commit 6e1485e4f9
8 changed files with 120 additions and 105 deletions
@@ -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};
+51 -43
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()