新增: 项目规则文件投影进系统提示词

- 会话启动读 devflow.md 纪律与领单两分区,CLAUDE.md 同目录自动并入
- system prompt 稳定尾纯追加,主会话与子代理双路径同源
- 分区字符上限裁剪,会话级缓存同会话只读一次盘
- 文件缺失零回归,缓存前缀零击穿
This commit is contained in:
lxy
2026-10-12 18:12:36 +08:00
parent 9e26d6b19c
commit 9142e30714
7 changed files with 447 additions and 3 deletions
+9 -1
View File
@@ -969,7 +969,7 @@ pub(crate) async fn run_agentic_loop(
// (pinned_goals / working_context / workflow_dag_summary)消除原 G1/T5/T4 三处独立 lock,
// 且返回 pinned_goals 复用作 AiCompleted 快照(消除原 L1157 二次读)。拼接顺序+内容逐字节等价。
let (system_prompt, pinned_goals_snapshot) = build_runtime_prompt(
&session_arc, &conv_id, system_prompt, "",
&session_arc, &conv_id, system_prompt, "", h3_watch_dir.as_deref(),
).await;
// ── Plan-driven Phase 1:LLM 规划端(AICHAT_PLAN_ENABLED 门控) ──
@@ -3238,11 +3238,13 @@ fn completed_event(
// 做 snapshot,消除原 L1157 第二次 lock 读。
//
// 注:`_lang` 参数为未来 i18n 行为准则预留(当前 system_prompt 拼接未用 lang),保持调用点签名稳定。
// 注:`work_dir` 供项目规则文件投影定位工作区(与被监视工作区同一目录),None=无授权目录。
async fn build_runtime_prompt(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
base_prompt: String,
_lang: &str,
work_dir: Option<&str>,
) -> (String, Vec<super::GoalEntry>) {
// 一次锁读四字段(pinned_goals / working_injection / workflow_dag_summary / workflow_id)。
// conv 已删(None)→ goals=空 / injection=空 / dag=None,等价原各块 conv_read.unwrap_or_default 短路。
@@ -3368,6 +3370,12 @@ async fn build_runtime_prompt(
);
}
// 项目规则文件投影(devflow.md/CLAUDE.md):稳定尾段纯追加 —— 拼在全部既有段之后、
// 返回之前。内容经 devflow_md 模块按会话缓存(同会话只读一次盘),loop 内为不变量,
// 不击穿 provider prompt 缓存前缀;工作目录无规则文件时原样返回(缺失不影响既有组装)。
let system_prompt =
crate::commands::ai::devflow_md::append_projection(system_prompt, work_dir, conv_id).await;
(system_prompt, goals)
}
+420
View File
@@ -0,0 +1,420 @@
//! devflow.md / CLAUDE.md 项目认知投影(项目规则文件 → system prompt 稳定尾段)。
//!
//! 定位:对标 CLAUDE.md 的"启动读一遍静态规则文件",本土化为双文件并读——
//! - `devflow.md`:agent 生态自己的项目认知文件,v1 取两个分区(规范纪律 / 领单);
//! - `CLAUDE.md`:既有通用约定,同目录存在则并入(增强非依赖)。
//! 两文件并存时 devflow.md 优先(段内排序在前 + 显式冲突注明)。
//!
//! 缓存红线:投影段纯追加在 system prompt 全部既有段之后,内容按会话缓存——
//! 同一会话(含其派生的子代理)只在首次组装时读一次盘,此后复用同一文本,
//! 保证段内容会话内逐字节不变,不击穿 provider 的 prompt 缓存前缀。
//! 会话粒度取 conv_id 作缓存键:新会话重新读盘(拿到文件最新内容),旧会话不受扰动。
//!
//! 海纳川流:两文件缺失 / 读取失败 / 内容为空 → 投影段为空,prompt 与不接本模块
//! 逐字一致(规则文件是增强输入,缺失不影响内生运作)。
//!
//! 裁剪纪律:注入的 token 才是成本——按分区截取 + 字符上限,禁全量灌入。
//! 超限按 Unicode 字符截断(中文一字一符,按字节会截断 UTF-8 序列产生乱码)并注明。
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use crate::state::AllowedDirs;
// ── 裁剪上限(字符数,中文一字一符) ──
//
// 分区上限兜住单分区体量,总上限兜住整段体量;取值按"规范纪律最厚、领单次之、
// CLAUDE.md 与纪律区同量级"的内容形态分配,叠加后总上限仍可能触发二次截断。
/// 规范纪律分区上限
const DISCIPLINE_MAX_CHARS: usize = 4000;
/// 领单分区上限
const CLAIM_MAX_CHARS: usize = 2000;
/// CLAUDE.md 并入段上限
const CLAUDE_MD_MAX_CHARS: usize = 4000;
/// 投影段总上限(各分区叠加后的二次硬闸)
const SECTION_MAX_CHARS: usize = 6000;
/// 投影段缓存上限(条目)。会话粒度缓存,长跑进程防内存膨胀;超限整体清空
/// (清空后下次组装重读盘,内容只会更新不会错,正确性不受损)。
const SECTION_CACHE_MAX: usize = 64;
/// devflow.md 规范分区识别的标题关键字(领单优先判,防"领单纪律"被纪律关键字截胡)
const CLAIM_HEADING_KEY: &str = "领单";
const DISCIPLINE_HEADING_KEYS: [&str; 2] = ["纪律", "规范"];
static SECTION_CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
fn section_cache() -> &'static Mutex<HashMap<String, String>> {
SECTION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 工作目录解析:首个持久授权目录(排序取首稳定)。
///
/// 与主链被监视工作区取同一约定,保证主会话与子代理对"项目根"的认知一致;
/// HashSet 迭代序不定,必须排序后取首才是稳定值。
pub(crate) fn work_dir_of(allowed: &AllowedDirs) -> Option<String> {
let mut dirs: Vec<PathBuf> = allowed.persistent.iter().cloned().collect();
dirs.sort();
dirs.first().map(|p| p.to_string_lossy().to_string())
}
/// 把投影段纯追加到 system prompt 尾部(两条组装路径的统一入口)。
///
/// 无投影内容时原样返回(调用方 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 {
Some(seg) => format!("{}\n\n{}", prompt, seg),
None => prompt,
}
}
/// 取会话的投影段:缓存命中直接复用(同会话零重复读盘);未命中读盘一次并入缓存。
///
/// 缓存值空串 = 本会话已知无投影(文件缺失),同样走缓存避免每轮白读盘。
/// 子代理以父会话 conv_id 作键,天然复用父会话已读内容。
async fn projection_section(work_dir: Option<&str>, conv_id: &str) -> Option<String> {
{
let cache = section_cache().lock().unwrap_or_else(|p| p.into_inner());
if let Some(hit) = cache.get(conv_id) {
return (!hit.is_empty()).then(|| hit.clone());
}
}
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 (discipline, claiming) = devflow
.as_deref()
.map(extract_partitions)
.unwrap_or((String::new(), String::new()));
let section = build_section(&discipline, &claiming, claude.as_deref());
let mut cache = section_cache().lock().unwrap_or_else(|p| p.into_inner());
if cache.len() >= SECTION_CACHE_MAX {
cache.clear();
}
cache.insert(conv_id.to_string(), section.clone().unwrap_or_default());
section
}
/// 读工作目录下的两份规则文件,内容 trim 后返回;目录缺失 / 读失败 / 空白内容 → None。
fn read_pair(dir: Option<PathBuf>) -> (Option<String>, Option<String>) {
let Some(dir) = dir else {
return (None, None);
};
(read_trimmed(&dir.join("devflow.md")), read_trimmed(&dir.join("CLAUDE.md")))
}
fn read_trimmed(path: &Path) -> Option<String> {
std::fs::read_to_string(path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// 拼装投影段(纯函数):分区截取、上限截断、优先级注明都在这里定形。
/// 三路输入全空 → None(调用方零追加)。
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() {
return None;
}
let has_devflow = !discipline.is_empty() || !claiming.is_empty();
let mut sec = String::from("## 项目认知投影\n以下规则来自项目仓库文件(会话启动时读取)");
// 优先级只在双文件并存时注明,单文件无冲突可注明
if has_devflow && claude.is_some() {
sec.push_str(",devflow.md 与 CLAUDE.md 并存,冲突以 devflow.md 为准");
}
sec.push(':');
if !discipline.is_empty() {
sec.push_str("\n\n### 规范纪律(devflow.md)\n");
sec.push_str(&cap_chars(discipline, DISCIPLINE_MAX_CHARS));
}
if !claiming.is_empty() {
sec.push_str("\n\n### 领单纪律(devflow.md)\n");
sec.push_str(&cap_chars(claiming, CLAIM_MAX_CHARS));
}
if let Some(cla) = claude {
sec.push_str("\n\n### 项目规范(CLAUDE.md)\n");
sec.push_str(&cap_chars(cla, CLAUDE_MD_MAX_CHARS));
}
// 总上限二次硬闸:各分区上限叠加后仍可能超,整段再截一次
if sec.chars().count() > SECTION_MAX_CHARS {
let cut: String = sec.chars().take(SECTION_MAX_CHARS).collect();
return Some(format!("{}\n…(投影段超出总上限已截断)", cut));
}
Some(sec)
}
/// 按字符数截断并注明(纯函数):超 `max` 截断追加标注,否则原样返回。
fn cap_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let cut: String = s.chars().take(max).collect();
format!("{}\n…(超上限已截断)", cut)
}
// ── devflow.md 分区解析 ──
//
// v1 取两个分区:规范纪律 / 领单。识别方式 = markdown 标题关键字命中,命中块的
// 更深层子标题视为块内子结构一并保留;未命中关键标题的块不注入(状态/警示类
// 分区不属 v1 范围,天然被裁掉)。全文无命中分区的扁平写法回退为整文件按
// 规范纪律处理——规则文件形态与 CLAUDE.md 同构,不因没写小节标题而失传。
#[derive(Clone, Copy, PartialEq)]
enum Partition {
Discipline,
Claim,
}
/// 识别 markdown 标题行,返回 (级别, 标题文本);非标题 / 纯装饰线 → None。
fn heading_of(line: &str) -> Option<(usize, &str)> {
let t = line.trim_start();
let level = t.len() - t.trim_start_matches('#').len();
if level == 0 || level > 6 {
return None;
}
let text = t[level..].trim();
if text.is_empty() {
return None;
}
Some((level, text))
}
fn classify_heading(text: &str) -> Option<Partition> {
if text.contains(CLAIM_HEADING_KEY) {
return Some(Partition::Claim);
}
if DISCIPLINE_HEADING_KEYS.iter().any(|k| text.contains(k)) {
return Some(Partition::Discipline);
}
None
}
/// 从 devflow.md 全文提取 (规范纪律, 领单) 两个分区正文(纯函数,未截断)。
fn extract_partitions(content: &str) -> (String, String) {
let mut discipline: Vec<String> = Vec::new();
let mut claiming: Vec<String> = Vec::new();
// 当前块:(分区, 起始标题级别, 正文);正文不含起始标题行(投影段会重加小标题)
let mut cur: Option<(Partition, usize, String)> = None;
for line in content.lines() {
let heading = heading_of(line);
// 块边界:块内遇到不深于起始层级的标题 → 收块,由新标题决定是否开下一块
let boundary = matches!((&cur, &heading), (Some((_, lv, _)), Some((h_lv, _))) if h_lv <= lv);
if boundary {
let (kind, _, body) = cur.take().expect("boundary 判定保证 cur 存在");
push_block(kind, body, &mut discipline, &mut claiming);
} else if let Some((_, _, body)) = cur.as_mut() {
// 更深层级标题属块内子结构,连行保留
body.push_str(line);
body.push('\n');
continue;
}
if let Some((lv, text)) = heading {
if let Some(kind) = classify_heading(text) {
cur = Some((kind, lv, String::new()));
}
}
}
if let Some((kind, _, body)) = cur.take() {
push_block(kind, body, &mut discipline, &mut claiming);
}
let dev = discipline.join("\n").trim().to_string();
let claim = claiming.join("\n").trim().to_string();
if dev.is_empty() && claim.is_empty() {
return (content.trim().to_string(), String::new());
}
(dev, claim)
}
fn push_block(kind: Partition, body: String, discipline: &mut Vec<String>, claiming: &mut Vec<String>) {
let trimmed = body.trim();
if trimmed.is_empty() {
return;
}
match kind {
Partition::Discipline => discipline.push(trimmed.to_string()),
Partition::Claim => claiming.push(trimmed.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
/// 测试临时目录计数器:进程内唯一,防并发测试互踩 + 防缓存键串扰
static TMP_SEQ: AtomicUsize = AtomicUsize::new(0);
fn unique_tag() -> String {
format!("{}-{}", std::process::id(), TMP_SEQ.fetch_add(1, Ordering::SeqCst))
}
fn temp_project_dir() -> PathBuf {
let dir = std::env::temp_dir().join(format!("devflow-md-test-{}", unique_tag()));
std::fs::create_dir_all(&dir).expect("建临时目录");
dir
}
fn write_file(dir: &Path, name: &str, content: &str) {
std::fs::write(dir.join(name), content).expect("写测试文件");
}
// ── 零回归:无两文件 → prompt 逐字不变 ──
#[tokio::test]
async fn missing_files_leave_prompt_unchanged() {
let base = "BASE PROMPT 段落".to_string();
let dir = temp_project_dir();
let out = append_projection(base.clone(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await;
assert_eq!(out, base, "两文件缺失时 prompt 必须逐字不变");
let out_no_dir = append_projection(base.clone(), None, &format!("conv-{}", unique_tag())).await;
assert_eq!(out_no_dir, base, "无授权目录时 prompt 必须逐字不变");
std::fs::remove_dir_all(&dir).ok();
}
// ── 只有 CLAUDE.md → 并入生效 ──
#[tokio::test]
async fn claude_only_merged() {
let dir = temp_project_dir();
write_file(&dir, "CLAUDE.md", "# 项目约定\n- 提交信息用中文");
let out = append_projection("BASE".to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await;
assert!(out.starts_with("BASE"), "既有段必须是前缀");
assert!(out.contains("提交信息用中文"), "CLAUDE.md 正文须并入");
assert!(out.contains("### 项目规范(CLAUDE.md)"), "并入段须带 CLAUDE.md 标注");
assert!(!out.contains("devflow.md"), "仅 CLAUDE.md 时不得出现 devflow.md 标注");
std::fs::remove_dir_all(&dir).ok();
}
// ── 双文件并存 → 优先级正确(devflow.md 在前 + 冲突注明,双内容齐备) ──
#[tokio::test]
async fn both_files_devflow_priority() {
let dir = temp_project_dir();
write_file(&dir, "devflow.md", "## 规范纪律\n- 禁 sed 改代码\n\n## 领单纪律\n- 领单后改 [→]");
write_file(&dir, "CLAUDE.md", "# 旧约定\n- 旧规则条目");
let out = append_projection("BASE".to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await;
let dev_pos = out.find("禁 sed 改代码").expect("devflow.md 纪律正文须注入");
let claim_pos = out.find("领单后改").expect("领单分区须注入");
let claude_pos = out.find("旧规则条目").expect("CLAUDE.md 正文须并入");
assert!(dev_pos < claude_pos && claim_pos < claude_pos, "devflow.md 内容必须排在 CLAUDE.md 之前(优先级)");
assert!(out.contains("冲突以 devflow.md 为准"), "双文件并存须注明优先级");
assert!(out.contains("### 规范纪律(devflow.md)"));
assert!(out.contains("### 领单纪律(devflow.md)"));
assert!(out.contains("### 项目规范(CLAUDE.md)"));
std::fs::remove_dir_all(&dir).ok();
}
// ── 超长裁剪:分区上限 + 总上限生效并注明 ──
#[test]
fn caps_truncate_with_marker() {
let long = "纪".repeat(DISCIPLINE_MAX_CHARS + 100);
let section = build_section(&long, "", None).expect("非空输入必有段");
let discipline_part = section.split("### 规范纪律(devflow.md)\n").nth(1).unwrap_or("");
assert!(discipline_part.contains("超上限已截断"), "分区超限须注明");
let kept = discipline_part.split("\n…(超上限已截断)").next().unwrap();
assert_eq!(kept.chars().count(), DISCIPLINE_MAX_CHARS, "分区保留长度须恰为上限");
// 总上限:各分区压线 + CLAUDE.md 超量,叠加必超总闸
let discipline = "a".repeat(DISCIPLINE_MAX_CHARS);
let claiming = "b".repeat(CLAIM_MAX_CHARS);
let claude = "c".repeat(CLAUDE_MD_MAX_CHARS);
let section = build_section(&discipline, &claiming, Some(&claude)).expect("非空输入必有段");
assert!(section.chars().count() <= SECTION_MAX_CHARS + "…(投影段超出总上限已截断)".len());
assert!(section.ends_with("…(投影段超出总上限已截断)"), "总超限须整段截断并注明");
}
// ── 稳定尾段:前段逐字保留,投影段独占尾部 ──
#[tokio::test]
async fn stable_tail_append() {
let dir = temp_project_dir();
write_file(&dir, "CLAUDE.md", "- 规则条目");
let base = "## 行为准则\n- 既有段内容\n";
let out = append_projection(base.to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await;
assert!(out.starts_with(base), "既有段必须逐字保留为前缀");
let tail = out.strip_prefix(base).unwrap();
assert!(tail.starts_with("\n\n## 项目认知投影"), "投影段须紧跟既有段之后成稳定尾段");
std::fs::remove_dir_all(&dir).ok();
}
// ── 会话缓存:同会话只读一次盘,文件中途改写不影响本会话;新会话读到新内容 ──
#[tokio::test]
async fn cached_per_conversation() {
let dir = temp_project_dir();
let tag = unique_tag();
write_file(&dir, "CLAUDE.md", "- 第一版规则");
let conv = format!("conv-{}", tag);
let first = append_projection("B".to_string(), Some(dir.to_str().unwrap()), &conv).await;
write_file(&dir, "CLAUDE.md", "- 第二版规则");
let again = append_projection("B".to_string(), Some(dir.to_str().unwrap()), &conv).await;
assert_eq!(first, again, "同会话第二次组装必须复用缓存(文件改写不影响)");
assert!(first.contains("第一版规则"));
let fresh = append_projection("B".to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}-new", tag)).await;
assert!(fresh.contains("第二版规则"), "新会话须重读盘拿到最新内容");
std::fs::remove_dir_all(&dir).ok();
}
// ── 分区解析:关键字命中 / 子标题保留 / 未命中块裁掉 / 扁平回退 ──
#[test]
fn partition_extraction() {
let content = "\
# 项目认知
前言段落应被裁掉。
## 规范纪律
- 纪律条目一
### 提交规范
- 子标题下的纪律条目
## 看板摘要
- 未命中关键字的块应被裁掉
## 领单纪律
- 领单条目一
";
let (dev, claim) = extract_partitions(content);
assert!(dev.contains("纪律条目一"), "规范纪律块须命中");
assert!(dev.contains("子标题下的纪律条目"), "块内子标题结构须保留");
assert!(!dev.contains("看板摘要") && !dev.contains("前言段落"), "未命中块与前言须裁掉");
assert_eq!(claim, "- 领单条目一", "领单分区须独立提取");
// 扁平文件(无命中标题)回退:整文件按规范纪律
let (dev, claim) = extract_partitions("- 扁平规则一\n- 扁平规则二\n");
assert_eq!(dev, "- 扁平规则一\n- 扁平规则二");
assert!(claim.is_empty());
// 深层标题起始的块:同级才截断
let (dev, claim) = extract_partitions("### 领单\n- 深层领单条目\n\n## 其他\n- 其他块内容\n");
assert_eq!(claim, "- 深层领单条目", "深层起始块到同级标题截断");
assert!(!dev.contains("其他块内容"));
}
// ── 工作目录:排序取首稳定(HashSet 迭代序不定,不排序结果不可复现) ──
#[test]
fn work_dir_sorted_first() {
let mut allowed = AllowedDirs::default();
allowed.persistent.insert(PathBuf::from("E:/wk-b/b-project"));
allowed.persistent.insert(PathBuf::from("E:/wk-a/a-project"));
assert_eq!(work_dir_of(&allowed).as_deref(), Some("E:/wk-a/a-project"));
let empty = AllowedDirs::default();
assert_eq!(work_dir_of(&empty), None, "无授权目录返 None");
}
// ── 构建纯函数:全空 None / 单区缺省不产生空小节 ──
#[test]
fn build_section_edge_cases() {
assert!(build_section("", "", None).is_none(), "三路全空须 None");
assert!(build_section("", "", Some(" ")).is_none(), "空白 CLAUDE.md 视同缺失");
let only_claim = build_section("", "- 领单规则", None).expect("单分区非空须有段");
assert!(only_claim.contains("### 领单纪律(devflow.md)"));
assert!(!only_claim.contains("### 规范纪律"), "空分区不得产生空小节");
assert!(!only_claim.contains("冲突以"), "单文件不得注明双文件优先级");
}
}
+2
View File
@@ -36,6 +36,8 @@ pub mod command_stream;
pub mod commands;
pub mod compress;
pub mod conversation;
// 项目规则文件投影(devflow.md/CLAUDE.md):会话启动读取并入 system prompt 稳定尾段
pub(crate) mod devflow_md;
pub mod event_bus;
pub mod http;
pub mod fetch_url;
@@ -1410,6 +1410,7 @@ fn make_test_spawn_agent_ctx(db: &Arc<Database>) -> crate::commands::ai::tools::
crate::commands::ai::agentic::conv_state::ConvStateStore::new(),
),
llm_concurrency: LlmConcurrency::new(3, 2),
allowed_dirs: Arc::new(tokio::sync::RwLock::new(crate::state::AllowedDirs::default())),
db: db.clone(),
sub_agents: crate::commands::ai::agentic::sub_agent::new_registry(),
}
+13 -1
View File
@@ -17,7 +17,7 @@
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::{Mutex, RwLock};
use df_ai::ai_tools::{
AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, SigMeta, SigValKind, ToolMeta,
@@ -52,6 +52,9 @@ pub struct SpawnAgentCtx {
pub conv_states: Arc<ConvStateStore>,
/// LLM 并发控制(子循环 global + per_conv(child) 双份额)
pub llm_concurrency: LlmConcurrency,
/// 文件访问授权白名单(子代理投影段定位工作区:首个持久授权目录;
/// 与 AppState.allowed_dirs 共享同一 Arc)
pub allowed_dirs: Arc<RwLock<crate::state::AllowedDirs>>,
/// 数据库句柄(子循环 provider 解析)
pub db: Arc<Database>,
/// 级联取消注册表(parent→children,与 AppState.sub_agent_registry 共享同一 Arc)
@@ -268,6 +271,15 @@ async fn spawn_agent_handler(
let task_prompt = build_task_prompt(&task, &allowed, &trimmed);
// P2-3:系统提示词注入实际生效的预算值(父可传 1..20,子代理按真实预算规划)
let system_prompt = sub_agent_system_prompt(max_rounds, &parent_lang);
// 项目规则文件投影与主会话同源:父会话工作区的 devflow.md/CLAUDE.md 对子代理同样生效
// (以父会话为缓存键,复用父会话已读内容零重复读盘;无授权目录/无文件时原样返回)。
let work_dir = {
let snap = ctx.allowed_dirs.read().await;
crate::commands::ai::devflow_md::work_dir_of(&snap)
};
let system_prompt = crate::commands::ai::devflow_md::append_projection(
system_prompt, work_dir.as_deref(), &parent_conv_id,
).await;
// P3-5:spawn 时刻快照父会话 save 元数据(active provider/created_at)。子代理后台
// 长跑收尾时用户可能已切走会话,出口落库若读 active_* 会继承别的会话元数据;
+1
View File
@@ -362,6 +362,7 @@ impl AppState {
session: ai_session.clone(),
conv_states: conv_states.clone(),
llm_concurrency: llm_concurrency.clone(),
allowed_dirs: allowed_dirs.clone(),
db: db.clone(),
sub_agents: sub_agent_registry.clone(),
};