修复: 工作流推进链路与执行入口优化

This commit is contained in:
lxy
2026-08-10 08:04:29 +08:00
parent 2473dcfe68
commit dedb9a8862
3 changed files with 244 additions and 198 deletions
+173 -196
View File
@@ -1021,146 +1021,14 @@ pub(crate) async fn run_agentic_loop(
// 不再从用户消息规则提取,也不依赖 LLM 输出结构化标记。
// 推理结果存入 pinned_goals: Vec<GoalEntry>,含 text+status 状态追踪。
// 新目标加入时自动标记之前的 active 为 completed。
let mut system_prompt = system_prompt;
if GOAL_PIN_ENABLED {
let goals: Vec<super::GoalEntry> = {
let session = session_arc.lock().await;
session
.conv_read(&conv_id)
.map(|c| c.pinned_goals.clone())
.unwrap_or_default()
};
// 只把 active 目标注入 system_prompt(completed 不干扰 LLM 注意力)
let active_goals: Vec<&super::GoalEntry> = goals.iter().filter(|g| matches!(g.status, super::GoalStatus::Active)).collect();
if !active_goals.is_empty() {
let goal_lines: Vec<String> = active_goals.iter().enumerate().map(|(i, g)| {
let trimmed = g.text.trim();
let truncated: String = trimmed.chars().take(GOAL_MAX_CHARS).collect();
let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS {
format!("{}", truncated)
} else {
truncated
};
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(),
first_goal = %goals.first().map(|g| &g.text[..std::cmp::min(120, g.text.len())]).unwrap_or(""),
"[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt",
goals.len()
);
}
}
// T5: WorkingContext L1 注入(L1a 常驻 + L1b 条件注入)
// 读取工作上下文,构建 L1 注入文本并拼入 system_prompt
{
let session = session_arc.lock().await;
if let Some(conv) = session.conv_read(&conv_id) {
let injection = conv.working_context.build_injection(3); // dirty_window=3
if !injection.is_empty() {
system_prompt = format!("{}\n\n## 工作上下文\n{}", system_prompt, injection);
}
}
}
// EnvSnapshot 环境感知:入口把当前平台的真实环境(OS/shell/工具版本)拼进 system_prompt 尾部。
//
// 治「LLM 跨平台命令幻觉」:LLM 训练数据 Unix 多,易生成 macOS/Linux 语法命令(PowerShell 5
// 不支持 `&&`、Windows 路径分隔符 `\`、GBK 终端中文乱码),把真实环境塞 prompt 即可锚定
// 输出平台一致性。detect() 是 OnceLock 全局缓存(启动时探一次,后续零开销),与 G1 一样
// 是 loop 不变量(整个会话不重探),与目标钉扎拼接次序无强约束(放其后,语义自然)
let env_prompt = df_execute::EnvSnapshot::detect().await.to_prompt();
let behavior_prompt = concat!(
"\n## AI 定位\n",
"你是 DevFlow 的 AI 助手,拥有完整的工具链。用户只负责提需求和审批,",
"所有执行由你完成——读写文件、运行命令、创建项目、搜索代码等都是你直接调用工具完成的。",
"**绝不输出请在终端执行以下命令这类指令——你自己用 run_command 工具执行即可。**\n",
"\n## 行为准则\n",
"- 所有操作都通过工具完成,用户不参与执行\n",
"- 优先使用开发工具 IPC,非必要不写独立脚本\n",
"- 脚本需要审批通过才执行,会拖慢工作流\n",
"- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定\n",
"- 如果现有工具无法完成任务,告知用户缺少什么能力,建议向 DevFlow 反馈以开发新工具\n",
"- **直接回答用户问题,不要以评论开头**——严禁用好问题等无信息量的开场白,直接输出答案或执行操作\n",
"- **及时收敛**:工具执行完目标达成后立即输出结果,不要继续调工具做无关操作",
);
system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt);
// 技能清单注入:把本机扫描到的技能(名称+描述)追加到 system_prompt,让 LLM 感知可用技能。
//
// 治「AI 不知道有技能可引导」:skills.rs 联想已做(前端 / 浮层),但 AI 本身对技能无感知,
// 用户需求命中某技能(如"发布flux")时 AI 不会引导。机制化——只注入清单(不执行技能逻辑,
// 执行仍由用户在 Claude Code 侧 / 技能名 触发),LLM 据此提示用户可用技能。
// 清单来自 skills_cached() 进程内缓存(懒扫描一次,后续零开销);截断条数+描述防占预算。
if SKILL_LIST_INJECT_ENABLED {
let skills = crate::commands::ai::skills::skills_cached().await;
if !skills.is_empty() {
let lines: Vec<String> = skills
.iter()
.take(SKILL_LIST_MAX_ITEMS)
.map(|s| {
let desc_flat = s.description.replace('\n', " ");
let desc: String = desc_flat.chars().take(SKILL_DESC_MAX_CHARS).collect();
let desc = if desc_flat.chars().count() > SKILL_DESC_MAX_CHARS {
format!("{}", desc)
} else {
desc
};
format!("- {}: {}", s.name, desc)
})
.collect();
if !lines.is_empty() {
system_prompt = format!(
"{}\n\n## 可用技能\n以下为本机 Claude 技能/命令(名称: 描述)。用户请求命中某技能用途时,提示用户输入 /技能名 触发,或引导其按需使用:\n{}",
system_prompt,
lines.join("\n")
);
tracing::debug!(
conv_id = %conv_id,
count = lines.len(),
"[ai] 技能清单注入:已追加 {} 条技能到 system_prompt",
lines.len()
);
}
}
}
// T4: 工作流 DAG 注入 — 当会话关联工作流时,将活跃路径注入 system prompt
{
let session = session_arc.lock().await;
if let Some(conv) = session.conv_read(&conv_id) {
if let Some(ref dag_summary) = conv.workflow_dag_summary {
system_prompt = format!("{}[工作流]\n{}\n", system_prompt, dag_summary);
tracing::debug!(
conv_id = %conv_id,
workflow_id = ?conv.workflow_id,
"[ai] T4: 已注入工作流 DAG 上下文"
);
}
}
}
// ── 多 Agent 并行执行:Coordinator 分解(plan_execution_enabled 时) ──
// 对话透明化 L1:拍快照供 AiCompleted 事件携带(coordinator 路径出口也用)
// 只取 text(前端不需要状态信息)
let pinned_goals_snapshot: Vec<super::GoalEntry> = {
let session = session_arc.lock().await;
session
.conv_read(&conv_id)
.map(|c| c.pinned_goals.clone())
.unwrap_or_default()
};
// 入口 system_prompt 五段拼接(G1 目标钉扎 / T5 工作上下文 / Env+behavior / 技能清单 /
// T4 工作流 DAG)收敛至 build_runtime_prompt helper:一次锁读 per_conv 三字段
// (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, "",
).await;
// ── Plan-driven Phase 1:LLM 规划端(AICHAT_PLAN_ENABLED 门控) ──
//
@@ -1312,14 +1180,7 @@ pub(crate) async fn run_agentic_loop(
// 落库 + generating 复位 + emit AiCompleted(统一走 finish_round_exit,行为零变更)
// Coordinator 路径:save(None,None) + 不 spawn_title + emit(None,None,publish=true)
// emit_usage=tokens 快照(本路径无 round, tokens 为空, total=0 对齐原 tokens.total())
let coord_usage = df_ai::provider::TokenUsage {
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
prompt_cache_hit_tokens: tokens.cache_hit(),
prompt_cache_miss_tokens: tokens.cache_miss(),
reasoning_tokens: tokens.reasoning(),
};
let coord_usage = tokens_snapshot(&tokens);
finish_round_exit(
&session_arc, &db, &conv_id,
None, None,
@@ -1364,14 +1225,7 @@ pub(crate) async fn run_agentic_loop(
if stop_flag.load(Ordering::SeqCst) || loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
// save_usage 增量(自上次 save 后新增,首轮即停为 0);emit_usage 累计快照
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let emit_usage = df_ai::provider::TokenUsage {
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
prompt_cache_hit_tokens: tokens.cache_hit(),
prompt_cache_miss_tokens: tokens.cache_miss(),
reasoning_tokens: tokens.reasoning(),
};
let emit_usage = tokens_snapshot(&tokens);
// 入口 stop:本轮可能尚未 stream(首轮即停),不记 model——避免把未实际生成的 model 写入 models 数组
// 统一走 finish_round_exit:save(Some usage, None model) + spawn_title + emit(None,None,publish=true)
// (stale 时 finish_round_exit 内部仅 disarm 跳过 save/reset/emit,不干扰新 loop)
@@ -1884,14 +1738,7 @@ pub(crate) async fn run_agentic_loop(
// MidStream 分叉:emit_incomplete=Some(true)(前端标不完整),publish_incomplete=None(总线消费方),
// do_publish=true(publish 走总线)。spawn_title=true(后台标题,失败 extract 兜底)。
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let emit_usage = df_ai::provider::TokenUsage {
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
prompt_cache_hit_tokens: tokens.cache_hit(),
prompt_cache_miss_tokens: tokens.cache_miss(),
reasoning_tokens: tokens.reasoning(),
};
let emit_usage = tokens_snapshot(&tokens);
finish_round_exit(
&session_arc, &db, &conv_id,
Some(&save_usage), Some(&resolved_model),
@@ -1968,14 +1815,7 @@ pub(crate) async fn run_agentic_loop(
if stop_flag.load(Ordering::SeqCst) || loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
// save_usage 增量(自上次 save 后新增);emit_usage 累计快照(前端展示本 loop 总量)
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let emit_usage = df_ai::provider::TokenUsage {
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
prompt_cache_hit_tokens: tokens.cache_hit(),
prompt_cache_miss_tokens: tokens.cache_miss(),
reasoning_tokens: tokens.reasoning(),
};
let emit_usage = tokens_snapshot(&tokens);
// 统一走 finish_round_exit:save(Some usage, Some model) + spawn_title + emit(None,None,publish=true)
finish_round_exit(
&session_arc, &db, &conv_id,
@@ -2094,14 +1934,7 @@ pub(crate) async fn run_agentic_loop(
);
// save_usage 增量;emit_usage 累计快照(前端展示本 loop 总量)
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let emit_usage = df_ai::provider::TokenUsage {
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
prompt_cache_hit_tokens: tokens.cache_hit(),
prompt_cache_miss_tokens: tokens.cache_miss(),
reasoning_tokens: tokens.reasoning(),
};
let emit_usage = tokens_snapshot(&tokens);
// 统一走 finish_round_exit:save(Some usage, Some model) + 不 spawn_title(对齐原无 title) +
// emit(Some(true), publish=false)。**do_publish=false 保留原 max_iterations 不 publish 行为**
// (与其他 5 路径不一致是历史现状,本次仅收敛重复代码不改 publish 策略,语义零变更)。
@@ -2131,7 +1964,6 @@ pub(crate) async fn run_agentic_loop(
// save_usage 增量(自上次 save 后新增,正常收敛时上轮 save 已落,此处通常为 0);
// emit_usage 用 tokens 累计快照。normal_usage.total 直接取 tokens.total() 保持三字段一致。
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let usage_total = tokens.total();
{
let session_arc = session_arc.clone();
let db = db.clone();
@@ -2161,14 +1993,7 @@ pub(crate) async fn run_agentic_loop(
// generating 复位后再 emit Completed:落库/标题/提炼已在后台,前端立即感知完成
// (正常完成路径 save+extract+title 已在上方 spawn 异步,此处仅 emit,故直接调 emit_ai_completed_once)
// emit_usage=tokens 快照(prompt/completion/total 全从 tokens 取,对齐原 usage_total=tokens.total())
let normal_usage = df_ai::provider::TokenUsage {
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: usage_total,
prompt_cache_hit_tokens: tokens.cache_hit(),
prompt_cache_miss_tokens: tokens.cache_miss(),
reasoning_tokens: tokens.reasoning(),
};
let normal_usage = tokens_snapshot(&tokens);
// F1 并发 epoch:emit 前最后一次 stale 检查(guard.reset 已幂等,此处防 emit 重复 AiCompleted)。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
return;
@@ -2704,6 +2529,165 @@ async fn emit_ai_completed_once(
}
}
// ── build_runtime_prompt: run_agentic_loop 入口 system_prompt 五段拼接 ──
//
// 抽自原 L1024-1152(G1 目标钉扎 / T5 工作上下文 / Env+behavior / 技能清单 / T4 工作流 DAG)。
// **行为逐字节等价**:拼接顺序与内容不变,开关(GOAL_PIN_ENABLED / SKILL_LIST_INJECT_ENABLED /
// GOAL_INJECT_BANNER)与截断(GOAL_MAX_CHARS / SKILL_LIST_MAX_ITEMS / SKILL_DESC_MAX_CHARS)常量原样引用。
//
// **关键重构(消除 pinned_goals 二次读)**:原代码 G1/T5/T4 各自独立加锁读 per_conv,且
// pinned_goals 被 G1(L1026 拼接)与调用方 snapshot(L1157 AiCompleted 携带)读两遍。本 helper
// 一次锁内取 pinned_goals / working_context.build_injection / workflow_dag_summary / workflow_id,
// 释放锁后再做 Env(.await OnceLock)/技能(.await 进程缓存)拼接——后两者无锁需求,不在锁内 await
// (避免持锁 await 死锁/长持锁)。返回 (拼好的 system_prompt, pinned_goals),调用方复用 pinned_goals
// 做 snapshot,消除原 L1157 第二次 lock 读。
//
// 注:`_lang` 参数为未来 i18n 行为准则预留(当前 system_prompt 拼接未用 lang),保持调用点签名稳定。
async fn build_runtime_prompt(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
base_prompt: String,
_lang: &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 短路。
let (goals, working_injection, workflow_dag_summary, workflow_id) = {
let session = session_arc.lock().await;
match session.conv_read(conv_id) {
Some(conv) => (
conv.pinned_goals.clone(),
conv.working_context.build_injection(3),
conv.workflow_dag_summary.clone(),
conv.workflow_id.clone(),
),
None => (Vec::new(), String::new(), None, None),
}
};
let mut system_prompt = base_prompt;
// G1 目标钉扎:仅 active 目标拼进 system_prompt(completed 不干扰 LLM 注意力)。
if GOAL_PIN_ENABLED {
let active_goals: Vec<&super::GoalEntry> = goals.iter().filter(|g| matches!(g.status, super::GoalStatus::Active)).collect();
if !active_goals.is_empty() {
let goal_lines: Vec<String> = active_goals.iter().enumerate().map(|(i, g)| {
let trimmed = g.text.trim();
let truncated: String = trimmed.chars().take(GOAL_MAX_CHARS).collect();
let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS {
format!("{}", truncated)
} else {
truncated
};
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(),
first_goal = %goals.first().map(|g| &g.text[..std::cmp::min(120, g.text.len())]).unwrap_or(""),
"[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt",
goals.len()
);
}
}
// T5: WorkingContext L1 注入(dirty_window=3,空 injection 跳过)。
if !working_injection.is_empty() {
system_prompt = format!("{}\n\n## 工作上下文\n{}", system_prompt, working_injection);
}
// EnvSnapshot 环境感知:detect() 是 OnceLock 全局缓存(启动探一次,后续零开销),与 G1 一样是
// loop 不变量,与目标钉扎拼接次序无强约束(放其后,语义自然)。无锁需求。
let env_prompt = df_execute::EnvSnapshot::detect().await.to_prompt();
let behavior_prompt = concat!(
"\n## AI 定位\n",
"你是 DevFlow 的 AI 助手,拥有完整的工具链。用户只负责提需求和审批,",
"所有执行由你完成——读写文件、运行命令、创建项目、搜索代码等都是你直接调用工具完成的。",
"**绝不输出请在终端执行以下命令这类指令——你自己用 run_command 工具执行即可。**\n",
"\n## 行为准则\n",
"- 所有操作都通过工具完成,用户不参与执行\n",
"- 优先使用开发工具 IPC,非必要不写独立脚本\n",
"- 脚本需要审批通过才执行,会拖慢工作流\n",
"- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定\n",
"- 如果现有工具无法完成任务,告知用户缺少什么能力,建议向 DevFlow 反馈以开发新工具\n",
"- **直接回答用户问题,不要以评论开头**——严禁用好问题等无信息量的开场白,直接输出答案或执行操作\n",
"- **及时收敛**:工具执行完目标达成后立即输出结果,不要继续调工具做无关操作",
);
system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt);
// 技能清单注入:skills_cached() 进程内缓存(懒扫描一次,后续零开销),无锁需求。
if SKILL_LIST_INJECT_ENABLED {
let skills = crate::commands::ai::skills::skills_cached().await;
if !skills.is_empty() {
let lines: Vec<String> = skills
.iter()
.take(SKILL_LIST_MAX_ITEMS)
.map(|s| {
let desc_flat = s.description.replace('\n', " ");
let desc: String = desc_flat.chars().take(SKILL_DESC_MAX_CHARS).collect();
let desc = if desc_flat.chars().count() > SKILL_DESC_MAX_CHARS {
format!("{}", desc)
} else {
desc
};
format!("- {}: {}", s.name, desc)
})
.collect();
if !lines.is_empty() {
system_prompt = format!(
"{}\n\n## 可用技能\n以下为本机 Claude 技能/命令(名称: 描述)。用户请求命中某技能用途时,提示用户输入 /技能名 触发,或引导其按需使用:\n{}",
system_prompt,
lines.join("\n")
);
tracing::debug!(
conv_id = %conv_id,
count = lines.len(),
"[ai] 技能清单注入:已追加 {} 条技能到 system_prompt",
lines.len()
);
}
}
}
// T4: 工作流 DAG 注入 — 当会话关联工作流时,将活跃路径注入 system prompt。
if let Some(ref dag_summary) = workflow_dag_summary {
system_prompt = format!("{}[工作流]\n{}\n", system_prompt, dag_summary);
tracing::debug!(
conv_id = %conv_id,
workflow_id = ?workflow_id,
"[ai] T4: 已注入工作流 DAG 上下文"
);
}
(system_prompt, goals)
}
// ── tokens_snapshot: TokenAccumulator → TokenUsage 累计快照 helper ──
//
// 抽自 run_agentic_loop 内 6 处(Coordinator 路径/入口 stop/MidStream/push 后 stop/
// max_iterations/正常完成)重复的 TokenUsage{prompt/completion/total/cache_hit/cache_miss/
// reasoning} 字面量组装。total = tokens.total() = prompt.saturating_add(completion)
// (TokenAccumulator::total 语义),与原 6 处逐字节等价。usage_delta_since 内部「取 cur 快照」
// 也复用本 helper(cur 字段同构)。
fn tokens_snapshot(tokens: &TokenAccumulator) -> df_ai::provider::TokenUsage {
df_ai::provider::TokenUsage {
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
prompt_cache_hit_tokens: tokens.cache_hit(),
prompt_cache_miss_tokens: tokens.cache_miss(),
reasoning_tokens: tokens.reasoning(),
}
}
// ── usage_delta_since: 会话级 token 落库「增量」口径 helper ──
//
// save_conversation 对 usage 做 old+add(accumulate_tokens),故每次 save 只能传
@@ -2714,14 +2698,7 @@ fn usage_delta_since(
tokens: &TokenAccumulator,
last_saved: &mut df_ai::provider::TokenUsage,
) -> df_ai::provider::TokenUsage {
let cur = df_ai::provider::TokenUsage {
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
prompt_cache_hit_tokens: tokens.cache_hit(),
prompt_cache_miss_tokens: tokens.cache_miss(),
reasoning_tokens: tokens.reasoning(),
};
let cur = tokens_snapshot(tokens);
let delta = df_ai::provider::TokenUsage {
prompt_tokens: cur.prompt_tokens.saturating_sub(last_saved.prompt_tokens),
completion_tokens: cur.completion_tokens.saturating_sub(last_saved.completion_tokens),
+10
View File
@@ -292,6 +292,16 @@ pub async fn run_workflow_inner(
.lock()
.await
.insert(exec_id.clone(), executor.state_machine());
// P0-A: 把 task_id 注入 config,使 AiSelfReviewNode(ctx.config.get("task_id")) 与
// AiNode parse_params 兜底(ctx.config.get("task_id") 读 task 生成默认 prompt) 可读。
// config 是 serde_json::Value,仅 object 时 insert 安全;task_id None 跳过(向后兼容)。
// executor.run 之前的 config 是 move 进 run 的,此处 mutate 后整体 move,零额外开销。
let mut config = config;
if let Some(tid) = cb_task_id.as_ref() {
if let Some(obj) = config.as_object_mut() {
obj.insert("task_id".to_string(), serde_json::Value::String(tid.clone()));
}
}
let result = executor.run(&runtime_dag, config).await;
let (status, error) = match &result {