新增: AI 对话目标钉扎+探索熔断+话题标记降级
治 AI Chat 目标丢失/意图丢失(实测会话 480万token,目标消息被压缩出局,LLM 尾部盲搜死循环): - G1 目标钉扎:首条 user 目标提取存 PerConvState.pinned_goal,run_agentic_loop 入口拼进 system_prompt 尾部(loop 不变量,免疫 F-15 压缩/裁剪/sanitize,治目标物理出局) - G2 探索熔断:连续 N 次空结果无进展 → 两段式警示 + AiHelpRequired 熔断(治游荡死循环) - G4 话题标记降级:pinned_goal 存在时跳过 topic marker insert(治反向误导+双锚点稀释) - G3 衔接:pinned_goal 内容态与 ConvState 生命周期态正交,不进 enum 各改进配开关 flag(GOAL_PIN_ENABLED/STALL_BREAKER_*/TOPIC_MARKER_GOAL_AWARE)可单点回退。 诊断报告见 docs/02-架构设计/专项设计/AI对话目标丢失诊断-2026-06-26.md。
This commit is contained in:
@@ -23,6 +23,15 @@
|
||||
//!
|
||||
//! 散落判别点(`try_continue` / `ai_chat_stop` / MaxRoundsCard)已逐个迁到读
|
||||
//! `ConvState`(`is_active()` / `can_accept_request()`),不再回退旧 bool。
|
||||
//!
|
||||
//! # 与目标钉扎(G1)的衔接(2026-06-26)
|
||||
//!
|
||||
//! `ConvState` 管**生成生命周期**(Idle/Generating/Stopping/Error/Compressed 5 态 7 边);
|
||||
//! 目标 / 进度等**内容态**挂 [`PerConvState`](../mod.rs) 兄弟字段(如 `pinned_goal`),
|
||||
//! 两者**正交**。**不要把目标塞进 `ConvState` 变体** —— 否则 5 态会膨胀成
|
||||
//! `GeneratingWithGoal` / `IdleWithGoal` 爆炸组合,违反「轻量状态机不引入框架」原则。
|
||||
//! 目标钉扎字段(G1)与本 enum 互不感知:G1 改 `PerConvState.pinned_goal`,
|
||||
//! 本文件 enum/impl/transition_to 守卫/guard.rs 零改动。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
@@ -107,6 +107,90 @@ pub const TOOL_RESULT_COMPRESS_ENABLED: bool = true;
|
||||
/// 保守:双高置信才标(任一 topic None 不标),不强制 LLM(软提示非硬约束)。
|
||||
pub const TOPIC_MARKER_ENABLED: bool = true;
|
||||
|
||||
// ============================================================
|
||||
// G1 目标钉扎(治 R1 目标消息被压缩出局 + R3 重锚定 + R5 prompt 无锚点,2026-06-26)
|
||||
//
|
||||
// 机制:首条 active user 消息提取目标存 PerConvState.pinned_goal(内容态字段,绕过
|
||||
// sanitize step0 is_active 过滤),run_agentic_loop 入口拼进 system_prompt 尾部。
|
||||
// system_prompt 是 loop 不变量 + build_for_request 不裁剪,故目标天然免疫压缩/裁剪,
|
||||
// 彻底治 R1(目标消息物理出局)/R5(prompt 说教无锚点),无 insert_at(0) 的连续 System
|
||||
// 1214/首位锚点稀释/小预算被裁三重风险。
|
||||
// ============================================================
|
||||
|
||||
/// G1 总开关:目标钉扎是否启用(默认 true)。
|
||||
///
|
||||
/// true(默认):chat.rs 两处 push 后提取首条 user 目标存 pinned_goal + loop 入口拼 system_prompt。
|
||||
/// false(回退):两处提取跳过 + loop 入口拼接跳过,pinned_goal 永远 None,system_prompt 零变化,
|
||||
/// 完全退回改动前行为(目标靠压缩摘要 + topic marker + 聚焦准则 prompt 续命)。单点回退,
|
||||
/// 不影响 G2/G4/G5(对齐 KEYWORD_FALLBACK_ENABLED 模式:每改配开关 + 兜底降级旧行为)。
|
||||
pub const GOAL_PIN_ENABLED: bool = true;
|
||||
|
||||
/// G1 banner 开关:目标拼进 system_prompt 时是否加「## 当前目标」分隔标题(默认 true)。
|
||||
///
|
||||
/// true(默认):目标文本前置 `## 当前目标(全程锚定,所有动作须服务于它)` 标题 + 目标内容,
|
||||
/// 显式分隔防与行为准则混淆,提升 LLM 注意力分配。false(裸拼):目标文本直接 append 到
|
||||
/// system_prompt,无标题分隔(紧凑,排障/对比用)。
|
||||
pub const GOAL_INJECT_BANNER: bool = true;
|
||||
|
||||
/// G1 截断长度:目标文本截断上限(默认 500 字符)。
|
||||
///
|
||||
/// 防 R1 反向风险:长 user 消息(粘贴需求文档/长 bug 描述)每轮占 system_prompt 预算。
|
||||
/// system_prompt 虽不被裁剪但仍计 sys_tokens 占预算,故截断防长目标撑爆。500 保守(首版,
|
||||
/// 可调),足够覆盖正常一句话目标。截断后追加「…」省略号标识。
|
||||
pub const GOAL_MAX_CHARS: usize = 500;
|
||||
|
||||
/// G4 目标感知降级:话题标记 insert 当 pinned_goal 存在时跳过(默认 true)。
|
||||
///
|
||||
/// 治 R2(话题标记反向误导):G1 目标钉扎生效后每轮 system_prompt 已含目标,topic marker 的
|
||||
/// 「请以新话题为准」软提示成冗余且与目标矛盾(诊断 §三双锚点稀释)。true(默认)= 当
|
||||
/// pinned_goal 存在时 take_topic_marker 后丢弃跳过 insert(防 marker 累积 + 消双锚点稀释);
|
||||
/// false(回退)= 无视 pinned_goal 照常 insert,完全退回原双锚点行为(排障/对比用)。
|
||||
///
|
||||
/// 与 TOPIC_MARKER_ENABLED 独立:TOPIC_MARKER_ENABLED 是整段总开关(false=整段跳过),
|
||||
/// TOPIC_MARKER_GOAL_AWARE 是 goal 感知前置 guard(仅 GOAL_AWARE=true 且 pinned_goal
|
||||
/// 存在时跳过 insert)。双关默认 true,各自单点回退不耦合(G1 关闭时 GOAL_AWARE 仍生效,
|
||||
/// 但 pinned_goal 永远 None → 不跳过 → 退原 topic 行为,双层兜底)。
|
||||
pub const TOPIC_MARKER_GOAL_AWARE: bool = true;
|
||||
|
||||
// ============================================================
|
||||
// G2 探索熔断(治 R4 连续空结果无进展死循环 + token 失控,2026-06-26)
|
||||
//
|
||||
// 机制(非 prompt 说教):连续 N 轮工具结果全空(空成功,如 search_files/grep 无命中) →
|
||||
// 两段式处理(STALL_BREAKER_WARN_FIRST):先警示注入下轮 prompt 给 LLM 自纠机会,
|
||||
// 再熔断(guard.reset + AiHelpRequired)逼用户换思路/人工介入。
|
||||
// 与 L1 断路器(CIRCUIT_BREAKER_*)互补:L1 治反复失败(禁止/失败/Error 关键词),
|
||||
// G2 治反复空成功(total:0/无匹配),两者独立计数互不干扰。
|
||||
// 直接治实测 seq287-307 连续 10+ 次 search_files 空结果死循环(阈值 3 提前 7 次熔断,省巨量 token)。
|
||||
// ============================================================
|
||||
|
||||
/// G2 总开关:探索熔断是否启用(默认 true)。
|
||||
///
|
||||
/// true(默认):process_tool_calls 后取末尾连续 Tool 消息判空结果,全空累计 stall_count,
|
||||
/// 达 STALL_BREAKER_THRESHOLD → 警示/熔断。false(回退):整段跳过,stall_count 永远 0,
|
||||
/// 退 max_iterations 旧行为(可能继续游荡但行为不变,单点回退)。与 CIRCUIT_BREAKER_ENABLED 独立。
|
||||
pub const STALL_BREAKER_ENABLED: bool = true;
|
||||
|
||||
/// G2 连续空结果阈值(默认 3)。
|
||||
///
|
||||
/// 评审一致建议 3 而非 5(对齐 CIRCUIT_BREAKER_THRESHOLD=3):空成功比硬失败更隐蔽,
|
||||
/// 阈值应更激进。治实测连续 10+ 次空结果(提前 7 次熔断)。is_empty_tool_result 关键词
|
||||
/// 漏判风险由 threshold=3 + 两段式 WARN_FIRST 容错:多轮漏判才误熔断,最坏退 max_iterations 兜底。
|
||||
pub const STALL_BREAKER_THRESHOLD: u32 = 3;
|
||||
|
||||
/// G2 两段式开关:熔断前是否先警示一轮(默认 true)。
|
||||
///
|
||||
/// true(默认):stall_count == THRESHOLD-1 时注入「⚠ 已连续 N 次空结果,可能偏离目标,
|
||||
/// 请回顾目标换思路或停止」到下轮 system_prompt(给 LLM 自纠机会);stall_count >= THRESHOLD 才熔断。
|
||||
/// 降误杀:用户正当探索(如确认无相关文件)先警示再熔断。false(激进):直接熔断不警示,
|
||||
/// 确认无误杀场景用。
|
||||
pub const STALL_BREAKER_WARN_FIRST: bool = true;
|
||||
|
||||
/// G2 警示是否回顾目标(默认 true,需 G1 goal 字段)。
|
||||
///
|
||||
/// true(默认):警示文本引用 pinned_goal(若存在)提示「回顾目标: {goal}」,精准;
|
||||
/// false:警示泛化(不引 goal 文本)。G2 不强依赖 G1(熔断不读 goal 也能工作,仅警示泛化)。
|
||||
pub const STALL_BREAKER_GOAL_REMIND: bool = true;
|
||||
|
||||
/// L1 断路器:连续同类工具失败熔断阈值(治 kms 会话 53 轮 0 产出死循环)。
|
||||
///
|
||||
/// 背景:agent 无止损,某工具反复同类失败(权限拒绝/路径错误等)仍每轮重试,
|
||||
@@ -675,9 +759,61 @@ pub(crate) async fn run_agentic_loop(
|
||||
// loop 生命周期内累加,每轮 process_tool_calls 后检查。达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。
|
||||
let mut fail_counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
|
||||
|
||||
// G2 探索熔断:连续空结果无进展计数器(loop 生命周期累计,与 fail_counts 同生命周期)。
|
||||
// 每轮 process_tool_calls 后取末尾连续 Tool 消息判 is_empty_tool_result,全空 stall_count+=1,
|
||||
// 任一非空重置 0。达 STALL_BREAKER_THRESHOLD → 警示/熔断(治 R4 游荡死循环 + token 失控)。
|
||||
let mut stall_count: u32 = 0;
|
||||
let mut stall_warned: bool = false;
|
||||
|
||||
// BUG-260617-12: DeepSeek thinking 模式推理内容跨轮透传
|
||||
let mut last_reasoning_content: Option<String> = None;
|
||||
|
||||
// G1 目标钉扎:入口把 PerConvState.pinned_goal 拼进 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)。
|
||||
//
|
||||
// 注:仅 run_agentic_loop 入口注入。手动压缩(ai_chat_compress_context IPC)/标题/提炼等路径不注入
|
||||
// 目标(对齐 openQuestions 决策:首版仅 agentic loop 入口拼,其他路径不动)。
|
||||
let mut system_prompt = system_prompt;
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal_opt: Option<String> = {
|
||||
let session = session_arc.lock().await;
|
||||
session
|
||||
.conv_read(&conv_id)
|
||||
.and_then(|c| c.pinned_goal.clone())
|
||||
};
|
||||
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)
|
||||
} else {
|
||||
goal_text
|
||||
};
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// F-260616-13: system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变),
|
||||
// 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。
|
||||
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
|
||||
@@ -743,7 +879,27 @@ pub(crate) async fn run_agentic_loop(
|
||||
return;
|
||||
}
|
||||
let conv = session.conv(&conv_id);
|
||||
conv.messages.take_topic_marker()
|
||||
// G4 目标感知降级:总是 take_topic_marker(防 marker 累积),但若 pinned_goal 存在
|
||||
// 且 TOPIC_MARKER_GOAL_AWARE → 丢弃 take 结果(返 None 跳过 insert)。
|
||||
// 一次 lock 同读 pinned_goal(避免额外加锁)。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 marker = conv.messages.take_topic_marker();
|
||||
if goal_active && marker.is_some() {
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
iteration,
|
||||
"[ai] G4 目标钉扎已启用,跳过话题标记 insert 避免双锚点稀释"
|
||||
);
|
||||
None
|
||||
} else {
|
||||
marker
|
||||
}
|
||||
};
|
||||
if let Some((old_topic, new_topic)) = topic_marker_raw.and_then(|s| {
|
||||
// 解析 "old|new" 格式;splitn 防 topic 名内含 '|' 误切(仅切首 '|' 一次)。
|
||||
@@ -1457,6 +1613,116 @@ pub(crate) async fn run_agentic_loop(
|
||||
}
|
||||
}
|
||||
|
||||
// G2 探索熔断:连续空结果无进展检查(治 R4 游荡死循环 + token 失控,2026-06-26)。
|
||||
//
|
||||
// STALL_BREAKER_ENABLED=false → 整段跳过降级 max_iterations 旧行为(开关 + 兜底)。
|
||||
// 仅检查已回填 Tool 消息(同 L1):pending_count>0(待审批)交给上方审批分支,此处看已回填结果。
|
||||
// 取末尾连续 Tool 消息(倒序 take_while role==Tool),逐条 is_empty_tool_result:
|
||||
// - 任一非空 → stall_count=0 重置 + stall_warned=false(有进展,清警示态);
|
||||
// - 全空 → stall_count+=1。
|
||||
// 两段式(STALL_BREAKER_WARN_FIRST):stall_count == THRESHOLD-1 且未警示过 → 置 warned 标志,
|
||||
// 警示文本拼进 system_prompt(本轮 LLM 已 stream 完,实际下轮生效——因 system_prompt 不变量,
|
||||
// 下轮重新 stream 时会读最新 system_prompt;此处通过 insert 一条一次性软提示消息保本轮自纠)。
|
||||
// stall_count >= THRESHOLD → guard.reset + AiHelpRequired + return 强制熔断。
|
||||
// 与 L1 独立:is_empty_tool_result(空成功)不与 L1 is_failure(失败关键词)重叠。
|
||||
if STALL_BREAKER_ENABLED {
|
||||
let all_empty = {
|
||||
let session = session_arc.lock().await;
|
||||
let messages = match session.conv_read(&conv_id) {
|
||||
Some(conv) => conv.messages.all_messages_clone(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
// 倒序取末尾连续 role==Tool 消息(本轮工具回填结果)。
|
||||
let recent_tool_results: Vec<&ChatMessage> = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.take_while(|m| matches!(m.role, MessageRole::Tool))
|
||||
.collect();
|
||||
if recent_tool_results.is_empty() {
|
||||
// 本轮无工具回填(非工具轮)→ 不计入也不重置,保持 stall_count(不干扰已有计数)。
|
||||
None::<bool>
|
||||
} else {
|
||||
// 任一非空 → 重置;全空 → Some(true)。
|
||||
let any_non_empty = recent_tool_results
|
||||
.iter()
|
||||
.any(|m| !is_empty_tool_result(&m.content));
|
||||
Some(!any_non_empty)
|
||||
}
|
||||
};
|
||||
// 锁已 drop,可安全 await/emit。
|
||||
if let Some(all_empty_flag) = all_empty {
|
||||
if all_empty_flag {
|
||||
stall_count += 1;
|
||||
} else {
|
||||
stall_count = 0;
|
||||
stall_warned = false;
|
||||
}
|
||||
// 两段式警示:THRESHOLD-1 次(如 THRESHOLD=3 即第 2 次)首次警示。
|
||||
if STALL_BREAKER_WARN_FIRST
|
||||
&& stall_count == STALL_BREAKER_THRESHOLD.saturating_sub(1)
|
||||
&& !stall_warned
|
||||
{
|
||||
stall_warned = true;
|
||||
let goal_text = if STALL_BREAKER_GOAL_REMIND {
|
||||
let g = session_arc
|
||||
.lock()
|
||||
.await
|
||||
.conv_read(&conv_id)
|
||||
.and_then(|c| c.pinned_goal.clone())
|
||||
.unwrap_or_default();
|
||||
if g.trim().is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("(当前目标: {})", g.trim())
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let warn_text = format!(
|
||||
"⚠ 已连续 {} 次工具返回空结果,可能偏离目标{},请回顾目标换思路或停止探索。",
|
||||
stall_count, goal_text
|
||||
);
|
||||
let mut session = session_arc.lock().await;
|
||||
if session.per_conv.contains_key(&conv_id) {
|
||||
let conv = session.conv(&conv_id);
|
||||
conv.messages.insert_at(0, ChatMessage::system(&warn_text));
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
iteration,
|
||||
stall_count,
|
||||
"[ai] G2 探索熔断:连续空结果警示已 insert(软提示,给 LLM 自纠机会)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// 熔断:达 THRESHOLD 强制停止。
|
||||
if stall_count >= STALL_BREAKER_THRESHOLD {
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
stall_count,
|
||||
"[ai] G2 探索熔断:连续 {} 次空结果无进展,疑似目标漂移停止", stall_count
|
||||
);
|
||||
guard.reset().await;
|
||||
let _ = app_handle.emit(
|
||||
"ai-chat-event",
|
||||
AiChatEvent::AiHelpRequired {
|
||||
reason: format!(
|
||||
"连续 {} 次工具返回空结果无进展,疑似目标漂移已停止",
|
||||
stall_count
|
||||
),
|
||||
context: "连续探索无产出,可能偏离原始目标或无可用数据。".into(),
|
||||
options: vec![
|
||||
"回顾目标".into(),
|
||||
"换思路".into(),
|
||||
"停止".into(),
|
||||
],
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 有待审批 → 暂停循环,等待用户审批后通过 ai_approve → try_continue_agent_loop 恢复
|
||||
if pending_count > 0 {
|
||||
let usage = df_ai::provider::TokenUsage {
|
||||
@@ -1550,6 +1816,60 @@ pub(crate) async fn run_agentic_loop(
|
||||
});
|
||||
}
|
||||
|
||||
/// G2 探索熔断:判定工具结果是否为「空结果」(空成功,非失败)。
|
||||
///
|
||||
/// 关键词表**对齐真实 tool_result 格式**(audit.rs tool 执行经 `serde_json::Value::to_string()`
|
||||
/// 序列化为紧凑 JSON,无空格):
|
||||
/// - search_files / grep 空 → `{"total":0,...}` → 命中 `"total":0`(紧凑,冒号后无空格)。
|
||||
/// - list_directory 空 → `{"entries":[],...}` → 命中 `"entries":[]`。
|
||||
/// - read_symbol 未找到 → 含「未找到」回退提示。
|
||||
/// - 通用空 → `"matches":[]` / `"results":[]` / `No matches` / `无匹配` / `未找到` / `没有找到`。
|
||||
///
|
||||
/// 与 L1 is_failure(禁止/失败/Error)**不重叠**:空结果「total:0」不含失败关键词,L1 不命中。
|
||||
/// 漏判风险由 STALL_BREAKER_THRESHOLD=3 + 两段式 WARN_FIRST 容错(多轮漏判才误熔断,最坏退 max_iterations)。
|
||||
pub(crate) fn is_empty_tool_result(content: &str) -> bool {
|
||||
let trimmed = content.trim();
|
||||
if trimmed.is_empty() {
|
||||
return true;
|
||||
}
|
||||
// 紧凑 JSON 空结果标记(对齐 serde_json::Value::to_string() 输出,冒号后无空格)。
|
||||
// search_files/grep:"total":0 ;list_directory:"entries":[];通用空数组 matches/results/files。
|
||||
const EMPTY_MARKERS: &[&str] = &[
|
||||
"\"total\":0",
|
||||
"\"entries\":[]",
|
||||
"\"matches\":[]",
|
||||
"\"results\":[]",
|
||||
"\"files\":[]",
|
||||
];
|
||||
for marker in EMPTY_MARKERS {
|
||||
if trimmed.contains(marker) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// 中文 / 英文「未找到」类提示(read_symbol 等回退文本)。
|
||||
const EMPTY_TEXT: &[&str] = &[
|
||||
"未找到",
|
||||
"没有找到",
|
||||
"无匹配",
|
||||
"没有匹配",
|
||||
"未匹配",
|
||||
"未发现",
|
||||
"无记录",
|
||||
"No matches",
|
||||
"no matches",
|
||||
"0 results",
|
||||
"0 matches",
|
||||
"没有数据",
|
||||
"没有符合",
|
||||
];
|
||||
for marker in EMPTY_TEXT {
|
||||
if trimmed.contains(marker) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
|
||||
///
|
||||
/// B-260615-08:所有静默 return 点显式 emit 收尾事件,避免前端 streaming=true 永久卡。
|
||||
|
||||
@@ -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};
|
||||
use super::super::agentic::{run_agentic_loop, try_continue_agent_loop, GOAL_MAX_CHARS, GOAL_PIN_ENABLED};
|
||||
// 双轨收口批1:读侧入口拦截用 ConvState(can_accept_request 的 unwrap_or 兜底初值)。
|
||||
use super::super::agentic::conv_state::ConvState;
|
||||
use super::super::audit::{audit_finalize, emit_data_changed};
|
||||
@@ -72,6 +72,78 @@ pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSessio
|
||||
}
|
||||
}
|
||||
|
||||
/// G1 目标钉扎:从 user 消息文本提取目标(纯函数,无 IO / 无额外 LLM 请求)。
|
||||
///
|
||||
/// 治 R1(目标消息被压缩出局)的提取侧:首条 active user 消息即原始目标(push 时还 active),
|
||||
/// 提取后存 `PerConvState.pinned_goal` 绕过消息 active 状态过滤,即使原消息出局 goal 仍在。
|
||||
///
|
||||
/// 处理(零额外请求,对齐 GLM 限额口径 1 请求=1 次):
|
||||
/// 1. strip 所有 `[kind:...]` mention 段(augmentation 已投影,目标文本去噪)。对齐 intent.rs
|
||||
/// KINDS(项目/任务/想法/技能 + project/task/idea/skill),全文扫描移除(实测诊断 seq5 目标
|
||||
/// `分析灵感模块不足 [项目: DevFlow]` mention 在**末尾**,非仅行首),冒号兼容全角:/半角:。
|
||||
/// 2. trim 空白。
|
||||
/// 3. 截断到 GOAL_MAX_CHARS(防长需求文档撑爆 system_prompt 占预算)。
|
||||
///
|
||||
/// 返回空串(纯 mention / 空消息)→ 调用方判非空才写入 pinned_goal(避免空目标注入)。
|
||||
/// 默认用原文非 LLM 提取(消息锚点派 keepIdeas:零额外请求)。
|
||||
pub(crate) fn extract_pinned_goal(text: &str) -> String {
|
||||
// strip 所有 [kind:...] mention 段(对齐 intent.rs strip_mention_tags/try_match_mention 逻辑,
|
||||
// 单一真相源:KINDS 表与 intent.rs 一致)。全文扫描,非仅行首。
|
||||
const KINDS: &[&str] = &["项目", "任务", "想法", "技能", "project", "task", "idea", "skill"];
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let n = chars.len();
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
if chars[i] == '[' {
|
||||
// 探测 [kind:...],命中则跳过整段(含 ])。
|
||||
if let Some(close) = find_mention_close(&chars, i, KINDS) {
|
||||
i = close + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
let cleaned = out.trim();
|
||||
// 截断到 GOAL_MAX_CHARS(字符级,非字节级,中文友好)
|
||||
cleaned.chars().take(GOAL_MAX_CHARS).collect()
|
||||
}
|
||||
|
||||
/// 探测 `chars[open]=='['` 起的 mention 段 `[kind:...]`,返回匹配 `]` 的索引。
|
||||
///
|
||||
/// kind 大小写不敏感(ASCII)+ 中文直比;冒号接受全角:/半角:;kind 后允许空白;
|
||||
/// 未闭合 `]` 返 None(不当 mention,保留原样 `[`)。对齐 intent.rs try_match_mention。
|
||||
fn find_mention_close(chars: &[char], open: usize, kinds: &[&str]) -> Option<usize> {
|
||||
let after = open + 1;
|
||||
for k in kinds {
|
||||
let kc: Vec<char> = k.chars().collect();
|
||||
if chars.len() < after + kc.len() {
|
||||
continue;
|
||||
}
|
||||
let slice = &chars[after..after + kc.len()];
|
||||
if !kc.iter().zip(slice.iter()).all(|(a, b)| a.eq_ignore_ascii_case(b)) {
|
||||
continue;
|
||||
}
|
||||
let mut j = after + kc.len();
|
||||
while j < chars.len() && chars[j].is_whitespace() {
|
||||
j += 1;
|
||||
}
|
||||
if j >= chars.len() || (chars[j] != ':' && chars[j] != ':') {
|
||||
continue;
|
||||
}
|
||||
j += 1;
|
||||
while j < chars.len() {
|
||||
if chars[j] == ']' {
|
||||
return Some(j);
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
return None; // 未闭合 ] → 不当 mention
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 统一解析「/ 技能」+「@ mention 区间」为 augmentation 注入段。
|
||||
///
|
||||
/// 把 4 调用方(send / force_send / regenerate / edit)原本各拼一段的 skill 注入收敛至此,
|
||||
@@ -391,6 +463,14 @@ 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(仅 None 时写,连续追问视为同目标)。
|
||||
// GOAL_PIN_ENABLED=false → 跳过(单点回退);extract_pinned_goal 返空(纯 mention 前缀)→ 不写入。
|
||||
if GOAL_PIN_ENABLED && conv.pinned_goal.is_none() {
|
||||
let goal = extract_pinned_goal(&user_content);
|
||||
if !goal.is_empty() {
|
||||
conv.pinned_goal = Some(goal);
|
||||
}
|
||||
}
|
||||
(target, user_msg_id)
|
||||
};
|
||||
|
||||
@@ -1451,6 +1531,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(仅 None 时写,与 send 路径一致)。
|
||||
// force_send 是用户新指令,审批续跑会复用此目标(对齐 G1 changes:审批期间用户发新消息 →
|
||||
// pinned_goal 已更新,续跑用新目标)。
|
||||
if GOAL_PIN_ENABLED && conv.pinned_goal.is_none() {
|
||||
let goal = extract_pinned_goal(&user_content);
|
||||
if !goal.is_empty() {
|
||||
conv.pinned_goal = Some(goal);
|
||||
}
|
||||
}
|
||||
(was_gen.then_some(target.clone()), target, user_msg_id)
|
||||
};
|
||||
|
||||
|
||||
@@ -757,6 +757,18 @@ mod tests_f09_per_conv {
|
||||
/// 读写,AiSession 顶层单例字段已全部删除。
|
||||
///
|
||||
/// 字段初值逐字对齐 [`AiSession::new`](#method.new)。
|
||||
///
|
||||
/// # 字段正交性(G1 目标钉扎 / G3 状态机收敛衔接,2026-06-26)
|
||||
///
|
||||
/// 本 struct 同时持有两类正交状态:
|
||||
/// - **生命周期态**(`conv_state: ConvState`):loop 是否在跑(Idle/Generating/Stopping/...),
|
||||
/// 归 `ConvState` enum 管(写收敛见 [`ConvState::transition_to`])。
|
||||
/// - **内容态**(`pinned_goal` 等):用户想达成什么 / 会话进度等内容信息,挂本 struct 兄弟字段。
|
||||
///
|
||||
/// 两者**正交共存**于同一 struct,不互相进变体:目标**绝不**塞进 `ConvState` enum(否则
|
||||
/// 5 态会膨胀成 `GeneratingWithGoalA`/`GeneratingWithGoalB` 爆炸组合,违反 conv-state-convergence
|
||||
/// 分支「轻量状态机不引入框架」原则,见 agentic/conv_state.rs 文件头)。目标钉扎字段随会话
|
||||
/// 销毁不落库(对齐 `knowledge_extracted` 语义,PerConvState 无 serde derive)。
|
||||
pub struct PerConvState {
|
||||
/// 对话历史(ContextManager:会话级消息真相源,裁剪仅影响发送视图)
|
||||
pub messages: ContextManager,
|
||||
@@ -764,6 +776,17 @@ pub struct PerConvState {
|
||||
/// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。
|
||||
/// 批3 双轨收口:generating bool 已退役,此 enum 成为生成态唯一真相源。
|
||||
pub conv_state: ConvState,
|
||||
/// G1 目标钉扎真相源:用户首条 active 消息提取的目标(内容态字段,与生命周期态正交)。
|
||||
///
|
||||
/// 治 R1(目标消息被压缩/compressed 物理出局,sanitize step0 过滤 is_active 致目标丢失):
|
||||
/// 目标存本字段绕过消息 active 状态过滤,即使原始 user 消息出局,goal 字段仍在。
|
||||
/// run_agentic_loop 入口把它拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然
|
||||
/// 免疫压缩/裁剪/sanitize,见 agentic/mod.rs:683)。
|
||||
///
|
||||
/// 写:chat.rs send/force_send push 后提取(仅 None 时写首条);读:loop 入口拼接。
|
||||
/// GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远 None(单点回退等价改动前)。
|
||||
/// 随会话销毁不落库(对齐 knowledge_extracted L795 语义,PerConvState 无 serde derive)。
|
||||
pub pinned_goal: Option<String>,
|
||||
/// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出
|
||||
pub stop_flag: Arc<AtomicBool>,
|
||||
/// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select!
|
||||
@@ -810,10 +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 目标钉扎,新会话未提取目标)
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
messages: ContextManager::new(ContextConfig::default()),
|
||||
conv_state: ConvState::Idle,
|
||||
pinned_goal: None,
|
||||
stop_flag: Arc::new(AtomicBool::new(false)),
|
||||
notify: Arc::new(tokio::sync::Notify::new()),
|
||||
iteration_used: 0,
|
||||
|
||||
Reference in New Issue
Block a user