Compare commits

..
3 Commits
Author SHA1 Message Date
lxy 7548896f6d 文档: 登记目标污染与停滞判定实证待办 2026-10-13 13:29:17 +08:00
lxy 5faaa88302 新增: 知识路标投影进系统提示词
- published 且验证或高复用条目按项目优先进投影稳定段
- 段尾固定引导工具故障先检索路标,治有经验不想查
- 与规则文件投影共用会话级缓存,空库零回归
2026-10-13 13:29:16 +08:00
lxy 8c4aa5dc5b 新增: 工具失败连败升级与熔断停靠
- 同工具连败三级提示:再败软提示,三败强制换路并引导检索经验
- 连拦五次强制安全停靠,不给无视熔断的余地
- 连击按会话计数防参数抖动绕过,成功即清零
2026-10-13 13:29:15 +08:00
5 changed files with 659 additions and 29 deletions
+169
View File
@@ -1178,6 +1178,74 @@ impl KnowledgeRepo {
.await
.map_err(storage_err)?
}
/// 知识路标读侧(devflow.md 投影通道):已发布且(人工验证 或 复用达标)的条目选摘。
///
/// 与检索族(search/search_keywords)的差别:路标无查询词,是「库里有什么」的静态选摘,
/// 供 system prompt 路标段铺开让 agent 知晓库藏、想起检索。入选门槛 = published 且
/// (verified = 1 OR reuse_count >= 3);排序 = 复用降序(唯一客观信号,对齐 search/top_used),
/// 同复用按 created_at 降序定稳定次序。verified 只作入选不作排序(复用是更强的实战信号)。
///
/// 两档拼接:项目档(source_project = project_id,上限 project_limit)在前——会话归属
/// 项目的知识最相关;跨项目补充档(非本项目含无归属条目,上限 global_limit)在后。
/// 两档 WHERE 互斥(= pid vs IS NULL/≠ pid),结果天然无重复。
/// project_id = None → 仅补充档(会话未解析出归属项目,项目档无从谈起)。
pub async fn list_waymark(
&self,
project_id: Option<&str>,
project_limit: usize,
global_limit: usize,
) -> Result<Vec<KnowledgeRecord>> {
let conn = self.conn.clone();
let project_id = project_id.map(|s| s.to_owned());
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// 入选门槛与排序两档共用(verified 在 SQLite 存 0/1;复用达标压线 3 即入选)
let qualify = "status = 'published' AND (verified = 1 OR reuse_count >= 3)";
let order = " ORDER BY reuse_count DESC, created_at DESC LIMIT ";
let mut results = Vec::new();
// 项目档:会话归属项目内的合格条目(复用降序,上限 project_limit)
if let Some(pid) = &project_id {
let sql = format!(
"SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE {qualify} AND source_project = ?1{order}?2"
);
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = stmt
.query_map(params![pid, project_limit as i64], knowledge_from_row)
.map_err(storage_err)?;
for r in rows {
results.push(r.map_err(storage_err)?);
}
}
// 跨项目补充档:非本项目(含无归属全局条目)按复用降序补足 global_limit;
// WHERE 与项目档互斥 → 天然去重。project_id = None 时无排他条件,即全量合格条目。
// LIMIT 占位符编号随排他条件有无而变(?1 空档排他时归 LIMIT,有排他时排他占 ?1、LIMIT 占 ?2)
let exclude = if project_id.is_some() {
" AND (source_project IS NULL OR source_project != ?1)"
} else {
""
};
let lim_n = if project_id.is_some() { 2 } else { 1 };
let sql = format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE {qualify}{exclude}{order}?{lim_n}");
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = match &project_id {
Some(pid) => stmt
.query_map(params![pid, global_limit as i64], knowledge_from_row)
.map_err(storage_err)?,
None => stmt
.query_map(params![global_limit as i64], knowledge_from_row)
.map_err(storage_err)?,
};
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
}
impl_repo!(
@@ -1715,6 +1783,107 @@ mod tests {
assert_eq!(hits[0].id, "k1");
}
// ---------- 知识路标读侧(list_waymark,devflow.md 投影通道) ----------
/// 入选门槛:published 且(verified 或 复用达标);candidate/archived/复用不足均淘汰
#[tokio::test]
async fn waymark_qualifies_verified_or_reused_published_only() {
let repo = setup_repo().await;
// verified=true 复用 0 → 入选(验证豁免复用门槛)
let mut v = krec("v", "已验证条目", "...", "lesson", "published", None, 0);
v.verified = true;
repo.insert(v).await.unwrap();
// 未验证复用 3 → 入选(压线达标)
repo.insert(krec("r3", "复用达标条目", "...", "lesson", "published", None, 3))
.await
.unwrap();
// 未验证复用 2 → 淘汰
repo.insert(krec("r2", "复用不足条目", "...", "lesson", "published", None, 2))
.await
.unwrap();
// 复用再高,状态非 published(candidate)→ 淘汰
repo.insert(krec("cand", "候选未发布", "...", "lesson", "candidate", None, 99))
.await
.unwrap();
// archived → 淘汰
repo.insert(krec("arch", "已归档", "...", "lesson", "archived", None, 99))
.await
.unwrap();
let hits = repo.list_waymark(None, 20, 10).await.unwrap();
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
assert_eq!(ids.len(), 2, "只应入选 已验证 + 复用达标 两条");
assert!(ids.contains(&"v") && ids.contains(&"r3"));
}
/// 两档序:项目档在前(复用降序,verified 豁免复用但仍按复用参与排序),
/// 跨项目补充档在后(复用降序);异项目高复用压不过同项目低复用(档位优先于复用)
#[tokio::test]
async fn waymark_project_tier_first_global_supplement_after() {
let repo = setup_repo().await;
repo.insert(krec("p9", "同项目复用9", "...", "lesson", "published", None, 9))
.await
.unwrap();
repo.insert(krec("p5", "同项目复用5", "...", "lesson", "published", None, 5))
.await
.unwrap();
// 同项目 verified 但零复用:入选项目档,排序落在复用条目之后
let mut pv = krec("pv", "同项目已验证零复用", "...", "lesson", "published", None, 0);
pv.verified = true;
repo.insert(pv).await.unwrap();
// 异项目高复用 → 补充档
let mut f = krec("f99", "异项目复用99", "...", "lesson", "published", None, 99);
f.source_project = Some("proj-2".to_string());
repo.insert(f).await.unwrap();
// 无归属全局条目 → 补充档
let mut g = krec("g10", "全局复用10", "...", "lesson", "published", None, 10);
g.source_project = None;
repo.insert(g).await.unwrap();
let hits = repo.list_waymark(Some("proj-1"), 20, 10).await.unwrap();
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
assert_eq!(
ids,
vec!["p9", "p5", "pv", "f99", "g10"],
"项目档在前(复用降序),补充档在后(复用降序:异项目99 > 全局10)"
);
// 两档 WHERE 互斥的去重属性:任一条目恰好出现一次
assert_eq!(ids.iter().filter(|i| **i == "p9").count(), 1, "同项目条目不得因两档重复出现");
}
/// 各档上限独立生效:项目档取复用最高的 project_limit 条,补充档另计 global_limit 条
/// (条目复用全部压线达标,确保裁剪只来自 LIMIT 而非入选门槛)
#[tokio::test]
async fn waymark_limits_cap_tiers_independently() {
let repo = setup_repo().await;
for reuse in 3..=5 {
repo.insert(krec(&format!("p{reuse}"), "同项目", "...", "lesson", "published", None, reuse))
.await
.unwrap();
}
for reuse in 3..=5 {
let mut g = krec(&format!("g{reuse}"), "全局", "...", "lesson", "published", None, reuse);
g.source_project = None;
repo.insert(g).await.unwrap();
}
let hits = repo.list_waymark(Some("proj-1"), 2, 2).await.unwrap();
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
assert_eq!(
ids,
vec!["p5", "p4", "g5", "g4"],
"项目档取复用最高的 2 条,补充档独立取全局最高的 2 条"
);
}
/// 空库 → 空结果(路标子块整体缺席的前提,投影侧据此零追加)
#[tokio::test]
async fn waymark_empty_db_returns_empty() {
let repo = setup_repo().await;
assert!(repo.list_waymark(Some("proj-1"), 20, 10).await.unwrap().is_empty());
assert!(repo.list_waymark(None, 20, 10).await.unwrap().is_empty());
}
// ---------- 复用即信任(auto_publish_if_reused) ----------
#[tokio::test]
+5
View File
@@ -640,6 +640,11 @@
### B5 无人值守续作引擎(2026-10-06 用户实证"总停下确定性任务"的根治,挂机治理核心缺口)
- [ ] **B5a 挂钟闸分任务定级**:确定性任务(看板任务/用户拍板项)挂钟闸放宽(如 120min)或按预估时长配置;闸到点改「检查点+续跑」(存进度不停工),仅非确定性/失控疑似才硬停靠。
- [ ] **目标提取质量闸**(2026-10-12 目标丢失事故实锤:用户抱怨句「丢失 会话上下文了 找回目标」被注册成任务目标、「继续」单字成目标、半截命令「ssh-proxy upload --name i」以 active 驱动续跑):写入侧把关——短文本/纯沟通词/疑问句不入 goals,含参数的命令目标须完整形态;恢复侧放行前过同闸
- [ ] **恢复消息递归套娃**(2026-10-12 实锤:重启恢复消息整段被当用户输入再提取,目标里嵌套上一次恢复全文,层层放大):恢复消息注入走旁路,不进目标提取源
- [ ] **G2 停滞判定叠输出变化维度**(2026-10-12 OSS迁移会话实证:轮询 grep DONE 同参数连发5次被防卡死拦截,但输出 skip 8000→8500 在变=有进展硬证据):样本键叠加输出内容哈希,输出仍在变不算停滞(真死循环输出也不变,非放松防线);stall_detector.rs 签名族
- [ ] **恢复目标完整性守卫**(同上实证:残缺目标「ssh-proxy upload --name i」进快照被恢复引导放行,AI 强行续跑):R4 恢复消息对参数明显不完整的目标标注「须先向用户确认参数」;偏产品语义,与快照线同族
- [ ] **压缩失败路径召回句柄核查**(同上实证:压缩失败兜底摘要+archive jsonl 归档正常,但截断参数仍丢失):核查④压缩可召回句柄行在压缩失败路径是否注入/AI 是否被引导召回;与知识路标层同族问题
- [ ] **B5b 停靠自动续作**:停靠/parked/批收敛后自动回读看板——有 parked 可续任务→自动 resume;有未完批次→自动领单续作;真需决策才停(附决策项)。无人值守的"永动"机制,CLAUDE.md 领单纪律的运行时化。
- [ ] **B5c 审批无人值守策略**:挂机中审批拦截按用户预设策略处置(低风险自动批+留痕,高风险停下+通知),策略 yml 可配。验收:挂机 4h 连续推进模拟(确定性批次队列),零人工干预零停顿;超大任务挂钟闸到点走检查点续跑。
+19 -1
View File
@@ -39,7 +39,7 @@ use df_storage::models::AiProviderRecord;
// ProviderAcquire 已随 stream_round 迁至 stream_relay.rs。
use crate::state::{AppState, LlmConcurrency};
use super::audit::{process_tool_calls, ToolExecPolicy};
use super::audit::{process_tool_calls, take_tool_fail_park, ToolExecPolicy};
use super::event_bus::emit_ai_chat_event;
// compress_via_llm 已随压缩逻辑迁至 context_lifecycle.rs(maybe_auto_compress 内调用)。
use super::conversation::{save_conversation, TokenAccumulator};
@@ -2417,6 +2417,24 @@ async fn process_round_tools(
return ToolsOutcome::Exit;
}
// B5d 工具连败熔断终局:同会话失败记忆拦截连击达阈且期间无成功 → 强制停靠。
// 拦截计数在 audit 层(execute_with_heartbeat)累积,停靠在 loop 层执行:guard 复位
// 与 emit_task_parked 正典序列只有本层触达,audit 层只判定不执行。停靠即终局,
// 不再给模型无视 blocked 提示继续重试的机会(实证:拦截后模型仍原样重试 10+ 次)。
if take_tool_fail_park(conv_id) {
tracing::warn!(
conv_id = %conv_id,
"[ai] 工具连续失败熔断:失败记忆拦截连击达阈,任务强制停靠"
);
guard.reset().await;
crate::commands::ai::emit_task_parked(
app_handle, conv_id,
"工具连续失败熔断(同一调用被失败记忆反复拦截仍重试),任务安全停靠;已完成进度已保存,调整思路后重发消息可继续",
"circuit_breaker",
);
return ToolsOutcome::Exit;
}
// AC-2 ①:单轮工具调用数超限 → 注入软提示(机制化,非静默,弱模型能感知)。
// 治单轮并行调用爆炸:LLM 看不到自己发了多少调用,必须在消息流里显式警告。
if is_tool_call_over_limit(round_tool_call_count) {
+165 -9
View File
@@ -228,6 +228,76 @@ fn sub_agent_tool_clamped(allowed: Option<&[String]>, tool_name: &str) -> bool {
}
}
// B5d 工具连败升级(快速放弃+换路引导)。三级全部复用既有失败记忆(tool_failure_memory)
// 的连败计数,不另造计数轨道:首败=裸错误原样;第 2 次失败=错误包附软提示(勿原样重试);
// 第 3 次起=入口拦截包升级为强制换路通牒(换工具/查知识库/向用户说明)。拦截在本会话
// 连续达 PARK_AFTER_BLOCKS 且期间无任何工具成功 → take_tool_fail_park 请求 loop 层停靠
// (2026-10-12 实证:拦截后模型仍原样重试 10+ 次,软提示拦不住,须有终局)。
/// 第 2 次失败附带的软提示:提醒而非禁止,给模型自行调参换路的机会。
const HINT_AVOID_RETRY: &str = "已连续失败 2 次,勿盲目重试同命令;考虑调整参数或换路径";
/// 第 3 次起拦截包附带的强制换路提示(终局通牒,含知识库检索引导)。
const HINT_MUST_SWITCH: &str = "连续失败≥3次,禁止重试同命令,必须:①换工具或换路径 ②调 knowledge_search 查既有经验 ③仍无法绕过则向用户说明并停手";
/// 同会话失败记忆拦截连续达此值(且期间无任何工具成功)→ 请求 loop 层强制停靠。
const PARK_AFTER_BLOCKS: u32 = 5;
/// 同会话失败记忆拦截连击计数:键=conv_id,值=自该会话最近一次成功执行以来的拦截次数。
/// 与失败记忆分开记:拦截不触发工具执行,失败记忆的连败数停在阈值不再增长,连击数
/// 只能另记;按会话而非按调用键记,参数抖动绕开调用键时连击依旧成立。
static BLOCK_STREAKS: std::sync::OnceLock<std::sync::Mutex<HashMap<String, u32>>> =
std::sync::OnceLock::new();
fn bump_block_streak(conv_id: &str) -> u32 {
let streaks = BLOCK_STREAKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let mut m = streaks.lock().unwrap_or_else(|p| p.into_inner());
let n = m.entry(conv_id.to_string()).or_insert(0);
*n += 1;
*n
}
/// 任一工具成功即打断连击(会话有产出,不再是纯挣扎状态)。
fn clear_block_streak(conv_id: &str) {
if let Some(m) = BLOCK_STREAKS.get() {
m.lock().unwrap_or_else(|p| p.into_inner()).remove(conv_id);
}
}
/// loop 层消费停靠请求:连击达 PARK_AFTER_BLOCKS 且未被成功清零 → 取走标志返 true。
pub(crate) fn take_tool_fail_park(conv_id: &str) -> bool {
let Some(m) = BLOCK_STREAKS.get() else { return false };
let mut map = m.lock().unwrap_or_else(|p| p.into_inner());
match map.get(conv_id) {
Some(&n) if n >= PARK_AFTER_BLOCKS => {
map.remove(conv_id);
true
}
_ => false,
}
}
/// 第 2 次失败的错误包附软提示;首败原样透传。
fn with_streak_hint(err: anyhow::Error, streak_hit: bool) -> anyhow::Error {
if streak_hit {
anyhow::anyhow!("{err}\n{HINT_AVOID_RETRY}")
} else {
err
}
}
/// 拦截包升级:hint 字段换为强制换路通牒,status/tool/consecutive_failures/last_error
/// 保留不丢。提示文本跨次恒定(不带递增计数):L1 断路器按错误文本哈希归一 key,
/// 变化文本会打散其连败累计;连击数走 tracing 日志与停靠判定,不进模型可见包。
fn upgrade_blocked_hint(blocked: &str) -> String {
let mut v: serde_json::Value = serde_json::from_str(blocked)
.unwrap_or_else(|_| serde_json::json!({ "status": "blocked_by_failure_memory" }));
if let Some(obj) = v.as_object_mut() {
obj.insert("hint".into(), serde_json::json!(HINT_MUST_SWITCH));
}
v.to_string()
}
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
/// 返回待审批的工具数量(0 = 全部自动执行完成)
///
@@ -261,17 +331,20 @@ pub(crate) async fn execute_with_heartbeat(
use tokio::time::Duration;
// ── B5d 工具失败记忆:执行前拦截「同 (conv,tool,args) 连败 ≥2 次」的重复调用 ──
// 治「404 连环十几次无退避换路」:拦截时回填结构化换路提示(改参数/换工具/报告放弃),
// 治「404 连环十几次无退避换路」:拦截时回填结构化换路提示(强制换路通牒,升级版),
// 引导 LLM 换路而非原样重试。成功即清零(record 侧),防误杀。
// 拦截直接返 Err(错误 tool_result),不执行工具——process_tool_calls 侧按既有错误路径处理。
// 拦截同时累加会话连击,达 PARK_AFTER_BLOCKS 由 loop 层(process_round_tools)停靠收场。
if let Some(blocked) = super::tool_failure_memory::precheck(name, &args) {
let blocks = bump_block_streak(conv_id);
tracing::warn!(
conv_id = %conv_id,
tool = %name,
tool_call_id = %tool_call_id,
"[failure-memory] 同调用连败达标,执行前拦截"
blocks,
"[failure-memory] 同调用连败达标,执行前拦截(本会话累计第 {blocks} 次)"
);
return Err(anyhow::anyhow!(blocked));
return Err(anyhow::anyhow!(upgrade_blocked_hint(&blocked)));
}
// record 侧需 args 计算 memory_key,但下方 tools.execute(name, args) 会 move args。
// 此处留一份 clone 供执行后 record 使用(仅工具执行路径,非热循环,开销可接受)。
@@ -516,13 +589,22 @@ pub(crate) async fn execute_with_heartbeat(
exec_result
};
// ── B5d 工具失败记忆:执行后回写(成功清零 / 失败递增并记最近错误) ──
// 与入口 precheck 配对:成功即删记忆(知识保鲜,防误杀);失败累加计数,
// ── B5d 工具失败记忆:执行后回写 + 连败升级提示 ──
// 与入口 precheck 配对:成功即删记忆(知识保鲜,防误杀)并打断拦截连击;失败累加计数,
// 达阈值后下次 precheck 拦截。error 摘要取 Err 文案(超时/提前退出/工具自身错误同源)。
match &exec_result {
Ok(_) => super::tool_failure_memory::record(name, &args_for_memory, true, None),
Err(e) => super::tool_failure_memory::record(name, &args_for_memory, false, Some(e.to_string())),
}
// 失败侧回探 precheck:命中即本次为第 2 次失败,错误包附软提示(第 3 次起由入口拦截包接管)。
let exec_result = match exec_result {
Ok(v) => {
super::tool_failure_memory::record(name, &args_for_memory, true, None);
clear_block_streak(conv_id);
Ok(v)
}
Err(e) => {
super::tool_failure_memory::record(name, &args_for_memory, false, Some(e.to_string()));
let streak_hit = super::tool_failure_memory::precheck(name, &args_for_memory).is_some();
Err(with_streak_hint(e, streak_hit))
}
};
exec_result
}
@@ -1726,4 +1808,78 @@ mod tests {
let (reason_0, _) = approval_timeout_park_parts(0);
assert!(reason_0.contains("0"), "任意分钟数可格式化(0=禁用超时不会到点,防御)");
}
/// B5d 连败升级三级:首败裸错误 / 第 2 次失败附软提示 / 第 3 次起拦截包升级强制换路
/// 通牒(含 knowledge_search 引导);成功一次即清零,再败回到首败无提示。
/// 走真失败记忆(进程级静态),键取本测试专属路径防并行互踩。
#[test]
fn failure_streak_hint_escalates_three_levels() {
use crate::commands::ai::tool_failure_memory::{precheck, record};
let args = serde_json::json!({ "path": "/tmp/b5d-escalation-check.txt" });
// 首败:未达拦截阈值,无提示
record("read_file", &args, false, Some("not found".into()));
assert!(!precheck("read_file", &args).is_some(), "首败不拦截");
// 第 2 次失败:记录后回探预检命中,错误包附软提示
record("read_file", &args, false, Some("not found".into()));
let streak_hit = precheck("read_file", &args).is_some();
assert!(streak_hit, "两连败后回探应命中");
let hinted = with_streak_hint(anyhow::anyhow!("工具执行失败"), streak_hit).to_string();
assert!(hinted.contains(HINT_AVOID_RETRY), "第 2 次失败附软提示: {hinted}");
assert!(hinted.contains("工具执行失败"), "原始错误保留: {hinted}");
// 第 3 次起:拦截包升级为强制换路通牒,状态字段保留,文本跨次恒定(L1 断路器
// 按错误文本哈希归 key,变化文本会打散其累计)
let blocked = precheck("read_file", &args).unwrap();
let upgraded = upgrade_blocked_hint(&blocked);
let upgraded_again = upgrade_blocked_hint(&blocked);
assert!(upgraded.contains("blocked_by_failure_memory"), "状态字段保留: {upgraded}");
assert!(upgraded.contains(HINT_MUST_SWITCH), "拦截包附强制换路通牒: {upgraded}");
assert!(upgraded.contains("knowledge_search"), "通牒含知识库检索引导: {upgraded}");
assert_eq!(upgraded, upgraded_again, "升级文本跨次恒定");
// 成功一次即清零:再败回到首败,无提示
record("read_file", &args, true, None);
record("read_file", &args, false, Some("not found".into()));
assert!(!precheck("read_file", &args).is_some(), "成功清零后首败无提示");
assert!(
!with_streak_hint(anyhow::anyhow!("x"), precheck("read_file", &args).is_some())
.to_string()
.contains(HINT_AVOID_RETRY),
"首败错误包不附提示"
);
}
/// B5d 熔断终局:同会话失败记忆拦截连击达 PARK_AFTER_BLOCKS 触发停靠,取走即消费
/// (不重复触发);未达阈不触发。
#[test]
fn blocked_streak_parks_after_five() {
let conv = "conv-b5d-park-check";
clear_block_streak(conv);
for _ in 0..(PARK_AFTER_BLOCKS - 1) {
bump_block_streak(conv);
}
assert!(!take_tool_fail_park(conv), "未达阈不停靠");
bump_block_streak(conv);
assert!(take_tool_fail_park(conv), "拦截连击达 5 触发停靠");
assert!(!take_tool_fail_park(conv), "停靠取走即消费,不重复触发");
}
/// B5d 成功打断连击:达阈前任一工具成功即清零,连击从头再计(会话有产出不停靠)。
#[test]
fn tool_success_clears_block_streak() {
let conv = "conv-b5d-success-clear";
clear_block_streak(conv);
for _ in 0..(PARK_AFTER_BLOCKS - 1) {
bump_block_streak(conv);
}
clear_block_streak(conv);
assert!(!take_tool_fail_park(conv), "成功打断连击,不停靠");
for _ in 0..PARK_AFTER_BLOCKS {
bump_block_streak(conv);
}
assert!(take_tool_fail_park(conv), "清零后重新连击达阈仍停靠");
clear_block_streak(conv);
}
}
+301 -19
View File
@@ -1,24 +1,35 @@
//! devflow.md / CLAUDE.md 项目认知投影(项目规则文件 → system prompt 稳定尾段)。
//! devflow.md / CLAUDE.md 项目认知投影(项目规则文件 + 知识库路标 → system prompt 稳定尾段)。
//!
//! 定位:对标 CLAUDE.md 的"启动读一遍静态规则文件",本土化为双文件并读——
//! - `devflow.md`:agent 生态自己的项目认知文件,v1 取两个分区(规范纪律 / 领单);
//! - `CLAUDE.md`:既有通用约定,同目录存在则并入(增强非依赖)。
//! 两文件并存时 devflow.md 优先(段内排序在前 + 显式冲突注明)。
//!
//! 知识路标子段(知识投影 v2):规则文件只说"该怎么做",路标补"库里有啥"——已发布且
//! (人工验证或复用达标)的知识条目按「同项目档 + 跨项目补充档」铺开成紧凑行,
//! 让 agent 知晓知识库藏、在工具故障/重复失败时想起用 knowledge_search 检索,
//! 而不是死磕。读侧走 df-storage [`KnowledgeRepo::list_waymark`],会话项目归属复用
//! [`resolve_source_project`] 既有机制,不自写 SQL 不造双轨。
//!
//! 缓存红线:投影段纯追加在 system prompt 全部既有段之后,内容按会话缓存——
//! 同一会话(含其派生的子代理)只在首次组装时读一次盘,此后复用同一文本,
//! 同一会话(含其派生的子代理)只在首次组装时读一次盘 + 查一次库,此后复用同一文本,
//! 保证段内容会话内逐字节不变,不击穿 provider 的 prompt 缓存前缀。
//! 会话粒度取 conv_id 作缓存键:新会话重新读盘(拿到文件最新内容),旧会话不受扰动。
//! 会话粒度取 conv_id 作缓存键:新会话重新组装(拿到文件与库的最新内容),旧会话不受扰动。
//!
//! 海纳川流:两文件缺失 / 读取失败 / 内容为空 → 投影段为空,prompt 与不接本模块
//! 逐字一致(规则文件是增强输入,缺失不影响内生运作)。
//! 海纳川流:两文件缺失 / 读取失败 / 内容为空,且知识库无合格条目 → 投影段为空,
//! prompt 与不接本模块逐字一致(规则文件与路标都是增强输入,缺失不影响内生运作)。
//!
//! 裁剪纪律:注入的 token 才是成本——按分区截取 + 字符上限,禁全量灌入。
//! 超限按 Unicode 字符截断(中文一字一符,按字节会截断 UTF-8 序列产生乱码)并注明。
//! 裁剪纪律:注入的 token 才是成本——规则分区按字符上限截断,路标条目数被查询
//! LIMIT 封顶 + 行内标题截断,整体体量有界。超限按 Unicode 字符截断
//! (中文一字一符,按字节会截断 UTF-8 序列产生乱码)并注明。
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::sync::{Arc, Mutex, OnceLock};
use df_storage::crud::KnowledgeRepo;
use df_storage::db::Database;
use df_storage::models::KnowledgeRecord;
use crate::state::AllowedDirs;
@@ -39,6 +50,17 @@ const SECTION_MAX_CHARS: usize = 6000;
/// (清空后下次组装重读盘,内容只会更新不会错,正确性不受损)。
const SECTION_CACHE_MAX: usize = 64;
// ── 知识路标子段参数 ──
//
// 条目数由查询 LIMIT 封顶(项目档 + 补充档),行内标题截断注省略号——双层结构上界
// 使路标块体量有界(约 30 行 × 行宽上限),无须再设整块字符闸。
/// 会话归属项目档上限(条)
const WAYMARK_PROJECT_MAX: usize = 20;
/// 跨项目补充档上限(条)
const WAYMARK_GLOBAL_MAX: usize = 10;
/// 路标行标题上限(字符):超长截断防长标题破坏紧凑行形态
const WAYMARK_TITLE_MAX_CHARS: usize = 60;
/// devflow.md 规范分区识别的标题关键字(领单优先判,防"领单纪律"被纪律关键字截胡)
const CLAIM_HEADING_KEY: &str = "领单";
const DISCIPLINE_HEADING_KEYS: [&str; 2] = ["纪律", "规范"];
@@ -49,6 +71,16 @@ fn section_cache() -> &'static Mutex<HashMap<String, String>> {
SECTION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 知识库读侧句柄:进程级 AppHandle → AppState.db(生产装配点,与 spawn_agent 全局
/// 同款"注册期无法捕获、运行期读取"形态)。AppHandle 未注入 / AppState 未托管
/// (早期启动、单元测试)→ None,路标子块整体缺席,其余组装零扰动。
fn knowledge_db() -> Option<Arc<Database>> {
use tauri::Manager;
let app = crate::commands::ai::tools::spawn_agent::app_handle()?;
let state = app.try_state::<crate::state::AppState>()?;
Some(state.db.clone())
}
/// 工作目录解析:首个持久授权目录(排序取首稳定)。
///
/// 与主链被监视工作区取同一约定,保证主会话与子代理对"项目根"的认知一致;
@@ -64,17 +96,19 @@ pub(crate) fn work_dir_of(allowed: &AllowedDirs) -> Option<String> {
/// 无投影内容时原样返回(调用方 prompt 逐字不变);有则 `\n\n` 接续,
/// 调用方既有段内容不受任何影响(稳定尾段语义)。
pub(crate) async fn append_projection(prompt: String, work_dir: Option<&str>, conv_id: &str) -> String {
match projection_section(work_dir, conv_id).await {
let kb = knowledge_db();
match projection_section(work_dir, conv_id, kb).await {
Some(seg) => format!("{}\n\n{}", prompt, seg),
None => prompt,
}
}
/// 取会话的投影段:缓存命中直接复用(同会话零重复读盘);未命中读盘一次并入缓存。
/// 取会话的投影段:缓存命中直接复用(同会话零重复读盘查库);未命中组装一次并入缓存。
///
/// 缓存值空串 = 本会话已知无投影(文件缺失),同样走缓存避免每轮白读盘。
/// 缓存值空串 = 本会话已知无投影(文件缺失且库无合格条目),同样走缓存避免每轮白查。
/// 子代理以父会话 conv_id 作键,天然复用父会话已读内容。
async fn projection_section(work_dir: Option<&str>, conv_id: &str) -> Option<String> {
/// `kb`:知识库读侧句柄(None = 单元测试/未托管态,路标子块整体缺席)。
async fn projection_section(work_dir: Option<&str>, conv_id: &str, kb: Option<Arc<Database>>) -> Option<String> {
{
let cache = section_cache().lock().unwrap_or_else(|p| p.into_inner());
if let Some(hit) = cache.get(conv_id) {
@@ -82,15 +116,23 @@ async fn projection_section(work_dir: Option<&str>, conv_id: &str) -> Option<Str
}
}
let dir = work_dir.map(PathBuf::from);
// 文件读放阻塞线程池(约定与 system prompt 指纹查询一致),失败按缺失处理
let (devflow, claude) = tokio::task::spawn_blocking(move || read_pair(dir))
.await
.unwrap_or_else(|_| (None, None));
let conv = conv_id.to_string();
// 规则文件读盘(阻塞线程池,失败按缺失处理)与知识路标查库相互独立,并发取
let (files, waymark) = tokio::join!(
tokio::task::spawn_blocking(move || read_pair(dir)),
async {
match kb {
Some(db) => build_waymark(&db, &conv).await,
None => None,
}
}
);
let (devflow, claude) = files.unwrap_or_else(|_| (None, None));
let (discipline, claiming) = devflow
.as_deref()
.map(extract_partitions)
.unwrap_or((String::new(), String::new()));
let section = build_section(&discipline, &claiming, claude.as_deref());
let section = combine_sections(build_section(&discipline, &claiming, claude.as_deref()), waymark);
let mut cache = section_cache().lock().unwrap_or_else(|p| p.into_inner());
if cache.len() >= SECTION_CACHE_MAX {
cache.clear();
@@ -99,6 +141,59 @@ async fn projection_section(work_dir: Option<&str>, conv_id: &str) -> Option<Str
section
}
/// 规则文件投影 + 知识路标子块的拼装(纯函数):路标恒居规则投影之后(同一稳定尾段内);
/// 双双缺席 → None(调用方零追加,零回归)。仅路标在场 → 路标独占投影段(规则文件头语不出现)。
fn combine_sections(rules: Option<String>, waymark: Option<String>) -> Option<String> {
match (rules, waymark) {
(Some(mut rules), Some(wm)) => {
rules.push_str("\n\n");
rules.push_str(&wm);
Some(rules)
}
(Some(rules), None) => Some(rules),
(None, Some(wm)) => Some(format!("## 项目认知投影\n\n{wm}")),
(None, None) => None,
}
}
/// 组装知识路标子块:解析会话归属项目(既有机制) → 两档查询 → 渲染紧凑行。
/// 查询失败降级缺席(warn 留痕,不阻断组装:路标是增强输入,故障不该影响 prompt 主链)。
async fn build_waymark(db: &Arc<Database>, conv_id: &str) -> Option<String> {
let project = crate::commands::ai::knowledge_inject::resolve_source_project(db, conv_id, &[]).await;
match KnowledgeRepo::new(db)
.list_waymark(project.as_deref(), WAYMARK_PROJECT_MAX, WAYMARK_GLOBAL_MAX)
.await
{
Ok(list) if !list.is_empty() => Some(render_waymark(&list)),
Ok(_) => None,
Err(e) => {
tracing::warn!(error = %e, conv_id, "[devflow-md] 知识路标查询失败,子块缺席");
None
}
}
}
/// 渲染路标子块(纯函数):段首说明 + 紧凑行 + 段尾固定提示。
/// 标题压平空白(库内标题可能含换行,直拼会破坏单行形态)并截断注省略号;
/// 空白标题条目跳过(渲染不出可读行的脏数据不入段)。
fn render_waymark(entries: &[KnowledgeRecord]) -> String {
let mut wm = String::from("### 知识库路标\n项目知识库路标(详情用 knowledge_search 按标题关键词检索)");
for e in entries {
let flat = e.title.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.is_empty() {
continue;
}
let count = flat.chars().count();
let mut title: String = flat.chars().take(WAYMARK_TITLE_MAX_CHARS).collect();
if count > WAYMARK_TITLE_MAX_CHARS {
title.push('…');
}
wm.push_str(&format!("\n- {title} | 复用{}次", e.reuse_count));
}
wm.push_str("\n上述经验条目可直接复用;遇到工具故障/重复失败先检索路标");
wm
}
/// 读工作目录下的两份规则文件,内容 trim 后返回;目录缺失 / 读失败 / 空白内容 → None。
fn read_pair(dir: Option<PathBuf>) -> (Option<String>, Option<String>) {
let Some(dir) = dir else {
@@ -114,8 +209,8 @@ fn read_trimmed(path: &Path) -> Option<String> {
.filter(|s| !s.is_empty())
}
/// 拼装投影段(纯函数):分区截取、上限截断、优先级注明都在这里定形。
/// 三路输入全空 → None(调用方零追加)。
/// 拼装规则文件投影段(纯函数):分区截取、上限截断、优先级注明都在这里定形。
/// 三路输入全空 → None(路标子块在场时由 combine_sections 单独成段)。
fn build_section(discipline: &str, claiming: &str, claude: Option<&str>) -> Option<String> {
let claude = claude.map(str::trim).filter(|c| !c.is_empty());
if discipline.is_empty() && claiming.is_empty() && claude.is_none() {
@@ -417,4 +512,191 @@ mod tests {
assert!(!only_claim.contains("### 规范纪律"), "空分区不得产生空小节");
assert!(!only_claim.contains("冲突以"), "单文件不得注明双文件优先级");
}
// ============================================================
// 知识路标子段(kb 句柄经 projection_section 显式注入,不经进程级全局:
// 单测并发零串扰,append_projection 生产路径的句柄解析另由 app_handle 缺席态天然覆盖)
// ============================================================
use df_storage::crud::{AiConversationRepo, ProjectRepo};
use df_storage::models::{AiConversationRecord, KnowledgeRecord};
/// 知识条目 fixture(published,未验证,归属可选)
fn kentry(id: &str, title: &str, reuse: i32, verified: bool, project: Option<&str>) -> KnowledgeRecord {
KnowledgeRecord {
id: id.to_string(),
kind: "lesson".to_string(),
title: title.to_string(),
content: "正文".to_string(),
tags: Some("[]".to_string()),
status: "published".to_string(),
confidence: None,
reuse_count: reuse,
verified,
source_project: project.map(|p| p.to_string()),
source_ref: None,
reasoning: None,
embedding_status: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}
}
/// 插入测试项目(同 knowledge_inject 测试口径)
async fn ins_project(db: &Database, id: &str, path: &str) {
ProjectRepo::new(db)
.insert(df_storage::models::ProjectRecord {
id: id.into(),
name: "路标项目".into(),
description: String::new(),
status: df_types::types::ProjectStatus::Planning,
idea_id: None,
path: Some(path.into()),
stack: None,
archived: false,
created_at: "2026-01-01".into(),
updated_at: "2026-01-01".into(),
})
.await
.unwrap();
}
/// 插入测试会话,带 boundary_json(dir_allowlist 单目录 → 级 a 归属解析用)
async fn ins_conv_with_boundary(db: &Database, conv_id: &str, allow_dir: &str) {
let boundary = crate::commands::ai::boundary::TaskBoundary {
risk_ceiling: df_ai::ai_tools::RiskLevel::Medium,
dir_allowlist: vec![allow_dir.to_string()],
forbid_destructive: true,
budget: None,
};
AiConversationRepo::new(db)
.insert(AiConversationRecord {
id: conv_id.into(),
title: Some("t".into()),
messages: "[]".into(),
provider_id: None,
model: None,
models: None,
archived: false,
pinned: false,
prompt_tokens: None,
completion_tokens: None,
pinned_goals: None,
pending_approvals: None,
created_at: "2026-01-01".into(),
updated_at: "2026-01-01".into(),
origin: None,
boundary_json: Some(serde_json::to_string(&boundary).unwrap()),
loop_state: None,
status: "active".into(),
working_context_json: None,
})
.await
.unwrap();
}
async fn memory_kb() -> Arc<Database> {
Arc::new(Database::open_in_memory().await.expect("内存库"))
}
// ── 空库零回归:注入空库句柄,投影段与无库时逐字一致 ──
#[tokio::test]
async fn empty_kb_zero_regression() {
let db = memory_kb().await;
let dir = temp_project_dir();
// 无规则文件 + 空库 → None(prompt 逐字不变)
let none = projection_section(Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag()), Some(db.clone())).await;
assert_eq!(none, None, "空库且无规则文件,投影段必须缺席");
// 有规则文件 + 空库 → 规则照常注入,路标子块零出现(其余行为零变化)
write_file(&dir, "CLAUDE.md", "- 规则条目");
let rules_only = projection_section(Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag()), Some(db)).await;
let sec = rules_only.expect("有规则文件必有段");
assert!(sec.contains("### 项目规范(CLAUDE.md)"));
assert!(!sec.contains("知识库路标"), "空库不得出现路标子段");
std::fs::remove_dir_all(&dir).ok();
}
// ── 入选过滤:verified 或复用达标才入段,渲染为紧凑行 + 段首说明 + 段尾固定提示 ──
#[tokio::test]
async fn waymark_selects_verified_or_reused() {
let db = memory_kb().await;
let repo = KnowledgeRepo::new(&db);
repo.insert(kentry("ok-v", "已验证经验", 0, true, None)).await.unwrap();
repo.insert(kentry("ok-r", "复用达标经验", 3, false, None)).await.unwrap();
repo.insert(kentry("no-r", "复用不足应淘汰", 2, false, None)).await.unwrap();
let mut cand = kentry("no-c", "候选未发布应淘汰", 99, false, None);
cand.status = "candidate".to_string();
repo.insert(cand).await.unwrap();
let sec = projection_section(None, &format!("conv-{}", unique_tag()), Some(db))
.await
.expect("有合格条目必有段");
assert!(sec.starts_with("## 项目认知投影"), "仅路标在场时须独占投影段");
assert!(sec.contains("项目知识库路标(详情用 knowledge_search 按标题关键词检索)"), "段首说明行");
assert!(sec.contains("\n- 已验证经验 | 复用0次"), "verified 豁免复用门槛入段");
assert!(sec.contains("\n- 复用达标经验 | 复用3次"), "复用压线达标入段");
assert!(!sec.contains("复用不足应淘汰"), "复用不足不入段");
assert!(!sec.contains("候选未发布应淘汰"), "非 published 不入段");
assert!(
sec.ends_with("上述经验条目可直接复用;遇到工具故障/重复失败先检索路标"),
"段尾固定提示行"
);
}
// ── 项目优先:boundary 解析出归属项目后,项目档在前,跨项目补充档按复用降序 ──
#[tokio::test]
async fn waymark_project_tier_first() {
let db = memory_kb().await;
ins_project(&db, "p-way", "e:/wk-lab/waymark-proj").await;
ins_conv_with_boundary(&db, "conv-way", "e:/wk-lab/waymark-proj").await;
let repo = KnowledgeRepo::new(&db);
repo.insert(kentry("same1", "同项目条目", 5, false, Some("p-way"))).await.unwrap();
repo.insert(kentry("foreign", "异项目高复用条目", 99, false, Some("p-other"))).await.unwrap();
repo.insert(kentry("global1", "全局无归属条目", 10, false, None)).await.unwrap();
let sec = projection_section(None, "conv-way", Some(db)).await.expect("必有段");
let same = sec.find("同项目条目").expect("同项目条目须入项目档");
let global = sec.find("全局无归属条目").expect("全局条目须入补充档");
let foreign = sec.find("异项目高复用条目").expect("异项目条目须入补充档");
assert!(
same < foreign && foreign < global,
"项目档在前;补充档内复用降序(异项目99 先于全局10)"
);
}
// ── 会话缓存:同会话复用首查结果,库内追加不影响;新会话查到新条目 ──
#[tokio::test]
async fn waymark_cached_per_conversation() {
let db = memory_kb().await;
let repo = KnowledgeRepo::new(&db);
repo.insert(kentry("a1", "首批经验", 3, false, None)).await.unwrap();
let conv = format!("conv-{}", unique_tag());
let first = projection_section(None, &conv, Some(db.clone())).await.expect("必有段");
assert!(first.contains("首批经验"));
repo.insert(kentry("b2", "次批经验", 3, false, None)).await.unwrap();
let again = projection_section(None, &conv, Some(db.clone())).await.expect("必有段");
assert_eq!(first, again, "同会话必须复用缓存(库内追加不影响)");
assert!(!again.contains("次批经验"));
let fresh = projection_section(None, &format!("{conv}-new"), Some(db)).await.expect("必有段");
assert!(fresh.contains("次批经验"), "新会话须重查库拿到新条目");
}
// ── 渲染纯函数:标题压平换行 + 截断注省略号 + 空白标题跳过 ──
#[test]
fn render_waymark_flattens_and_caps_title() {
let mut long = kentry("long", "", 7, false, None);
long.title = format!("长{}标题", "词".repeat(60)); // 63 字,超 60 上限
let mut multiline = kentry("multi", "含\n换行\t与制表", 2, true, None);
multiline.verified = true;
let blank = kentry("blank", " \n ", 1, true, None);
let out = render_waymark(&[long, multiline, blank]);
let line = out.lines().find(|l| l.starts_with("- 长")).expect("截断标题行在场");
assert!(line.ends_with("… | 复用7次"), "超长标题须截断注省略号");
assert!(line.matches('词').count() <= WAYMARK_TITLE_MAX_CHARS, "标题不得超上限");
assert!(out.contains("- 含 换行 与制表 | 复用2次"), "换行/制表压平为单空格");
assert!(!out.contains("- |"), "空白标题条目须跳过");
}
}