优化: 子代理体验与纪律注入

- 工具纪律段抽共享同源注入
- 子代理轨迹浮层与完成卡身份
- 分离窗会话切换修复
- 960s 统一出口报告不再蒸发
This commit is contained in:
lxy
2026-10-04 16:27:31 +08:00
parent b2d44f85e9
commit 01d231c39d
12 changed files with 1150 additions and 152 deletions
+497 -47
View File
@@ -17,7 +17,8 @@
//! 统一出口(`run_sub_agent_loop`)摘除拦截后调一次 `save_conversation`,整条子轨迹经
//! 既有 save 链路持久化,origin='sub_agent' 随首插落 `ai_conversations.origin`(V44),
//! 桌面侧栏/miniapp 列表按此过滤。Truncated/Failed 轨迹同样落库(调试与轨迹树数据源);
//! 父删除级联命中(deleted_convs 被级联重打/per_conv 已清)时放弃落库,防空壳复活。
//! 父删除级联命中(deleted_convs 被级联重打,见 cascade_stop_children_locked Delete 形态)
//! 时放弃落库,防空壳复活。
//!
//! ## 循环拼装(全部复用既有积木,不另起炉灶)
//! - provider 解析:AiProviderRepo.list_all + ProviderPool::select(override 亲和)+ 候选链;
@@ -33,9 +34,12 @@
//!
//! ## 事件与展示边界
//! 仅在结束时挂父会话 emit 一条 AiAgentFinished(前端展示二期消费);不 emit AiCompleted /
//! AiMaxRounds 终态事件、不 spawn 标题/知识提炼。stream_round 复用带来的残留:其 Fatal 分支
//! 会向子 conv emit AiError、MidStream 分支会触发一次子 conv 的后台标题任务(写库被上者拦,
//! 仅一次无效 LLM 调用),均为网络异常罕见路径,接受并记录于此。
//! AiMaxRounds 终态事件、不 spawn 标题/知识提炼。stream_round 复用带来的残留(均为网络异常
//! 罕见路径,消费方按「未知 conversation_id 丢弃」自我过滤,完整清单记录于此):
//! - Fatal 分支会向子 conv emit AiError;
//! - MidStream 分支会触发一次子 conv 的后台标题任务(写库被零落库拦截,仅一次无效 LLM 调用);
//! - MidStream 分支 teardown_round 还会向子 conv emit 一条完整 AiCompleted(incomplete=true,
//! stream_relay.rs MidStream 保文分支实证)。
use std::collections::{BTreeMap, HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
@@ -58,9 +62,11 @@ use crate::commands::ai::agentic::{
STALL_BREAKER_ENABLED,
};
use crate::commands::ai::audit::{process_tool_calls, ToolExecPolicy};
use crate::commands::ai::conversation::{save_conversation, truncate_for_persist, TokenAccumulator};
use crate::commands::ai::conversation::{
save_conversation_meta, truncate_for_persist, SaveMetaOverride, TokenAccumulator,
};
use crate::commands::ai::event_bus::emit_ai_chat_event;
use crate::commands::ai::AiChatEvent;
use crate::commands::ai::{AiChatEvent, ToolCallDraft};
use crate::state::LlmConcurrency;
use super::guard::GeneratingGuard;
@@ -94,6 +100,17 @@ pub(crate) const SUB_AGENT_MAX_TOTAL_TOKENS: u64 = 200_000;
/// 外层超时对 spawn_agent 取同值,保证外层不先于子循环自腰斩。
pub(crate) const SUB_AGENT_WALL_TIMEOUT_SECS: u64 = 900;
/// 工具批执行的最小剩余预算(秒)。单件工具受 execute_with_heartbeat 外层超时封顶
/// (Low 工具默认 60s),并行 join_all 总时长 ≈ 单件上限;剩余挂钟不足一个批上限加
/// 收尾缓冲时不开新批(P1-1),本轮 tool_calls 按 P3-3 同款补 cancelled tool_result
/// 后截断退出,保证子循环总能在父侧外层超时(SUB_AGENT_WALL_TIMEOUT_SECS+60)前
/// 自达统一出口。
pub(crate) const SUB_AGENT_TOOL_BATCH_HEADROOM_SECS: u64 = 65;
/// 出口报告内联诊断文本的封顶(字符数)。Fatal 的 provider 原始错误串可能携带超长
/// 堆栈/响应体,父会话报告只需头部即可定位(401/404/参数错),超长部分截断省略。
const DIAG_TEXT_MAX_CHARS: usize = 400;
// ============================================================
// 结果类型
// ============================================================
@@ -119,6 +136,41 @@ impl SubAgentStatus {
}
}
/// 子代理停止类别(为前端备料:AiAgentFinished.stop_kind,snake_case 字符串)。
/// 与 status 正交:status 表终态成败,stop_kind 表「为什么停」(取消/预算/错误/收敛)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SubAgentStopKind {
/// 用户停止级联(own stop / 父停止 / 父删除)
Cancelled,
/// 挂钟预算(900s)耗尽
WallClock,
/// 轮次预算(max_rounds)耗尽
MaxRounds,
/// token 预算耗尽(累计 200K 上限;单轮 max_tokens 截断同映射至此——
/// 事件枚举无独立 max_tokens 值,截断本质即 token 预算触顶)
TokenBudget,
/// 末轮空回复收敛(未产出报告文本)
EmptyConvergence,
/// 运行期错误(熔断/Fatal/意外审批挂起/流中断)
Error,
/// 正常收敛(完成)
Converged,
}
impl SubAgentStopKind {
pub(crate) fn as_str(self) -> &'static str {
match self {
SubAgentStopKind::Cancelled => "cancelled",
SubAgentStopKind::WallClock => "wall_clock",
SubAgentStopKind::MaxRounds => "max_rounds",
SubAgentStopKind::TokenBudget => "token_budget",
SubAgentStopKind::EmptyConvergence => "empty_convergence",
SubAgentStopKind::Error => "error",
SubAgentStopKind::Converged => "converged",
}
}
}
/// 子代理执行结果(经 spawn_agent 工具返回值交付给父 LLM)。
#[derive(Debug, Clone)]
pub(crate) struct SubAgentOutcome {
@@ -129,6 +181,8 @@ pub(crate) struct SubAgentOutcome {
/// 运行期被拒工具汇总(工具名→次数,BTreeMap 序确定性)。S2 报告协议:父会话须能
/// 看见子代理被拒了什么,而非只拿到一份不知缺口的报告;空 Vec=本次零拒绝。
pub(crate) rejected_tools: Vec<(String, u32)>,
/// 停止类别(终态事件 stop_kind 数据源);启动失败=Error,正常收敛=Converged。
pub(crate) stop_kind: Option<SubAgentStopKind>,
}
impl SubAgentOutcome {
@@ -140,6 +194,7 @@ impl SubAgentOutcome {
rounds: 0,
tokens: 0,
rejected_tools: Vec::new(),
stop_kind: Some(SubAgentStopKind::Error),
}
}
}
@@ -185,22 +240,45 @@ pub(crate) fn sub_agents_children(reg: &SubAgentRegistry, parent: &str) -> Vec<S
.unwrap_or_default()
}
/// 级联取消形态(P2-2):区分「停止」与「删除」两种父侧动作。
///
/// Delete 形态须同锁内对每个 child 补 deleted_convs 标记——否则子循环经停止检查点
/// 以「父会话已不存在」退出时,出口 alive 判定(child 不在 deleted_convs 且 per_conv
/// 存在)为真,会把父已删除的孤儿 sub_agent 轨迹照常 INSERT 落库(空壳复活,与模块
/// 头文档声明相反)。Stop 形态(ai_chat_stop / ai_chat_force_send)不打删除标记,
/// 子循环退出后轨迹照常落库。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CascadeStopForm {
/// 父会话停止/强制发送:仅置 child stop_flag。
Stop,
/// 父会话删除:置 stop_flag + 补 deleted_convs(出口 alive 判定即放弃落库)。
Delete,
}
/// 级联取消:父会话被停止/强制发送/删除时遍历 children 置 stop_flag + notify。
///
/// 调用方(ai_chat_stop / ai_chat_force_send / ai_conversation_delete)须已持 session 锁,
/// 本函数短临界区纯内存操作(无 await),与「禁止持 session 锁跨 await」纪律兼容。
/// 仅触碰已存在的子会话条目(不惰性重建,防取消动作反而制造空条目残留);
/// 子循环停止检查点读 own stop_flag 即退出,父侧条目消失/父 stop 是兜底判据。
/// Delete 形态的 deleted_convs 标记对 child 不回收(与直接删除的标记同生命周期):
/// 防在途 save 于出口清理之后复活空壳,单条 ~40B 量级可忽略。
pub(crate) fn cascade_stop_children_locked(
session: &mut crate::commands::ai::AiSession,
reg: &SubAgentRegistry,
parent: &str,
form: CascadeStopForm,
) {
for child in sub_agents_children(reg, parent) {
if let Some(conv) = session.per_conv.get_mut(&child) {
conv.stop_flag.store(true, Ordering::SeqCst);
conv.notify.notify_one();
}
// 删除形态:child 补删除标记须与父删除同锁(与子循环出口的 alive 判定互斥,
// 防标记与出口读之间竞态)。child 的 per_conv 不动:退出路径仍要读消息做报告兜底。
if form == CascadeStopForm::Delete {
session.deleted_convs.insert(child);
}
}
}
@@ -260,27 +338,90 @@ pub(crate) fn clamp_tool_whitelist(
(allowed, trimmed)
}
/// 单轮预算熔断判定(prepare 段前调用)。返回 Some(原因) 即应截断退出,None 继续。
/// 纯函数抽出供单测锁定三路熔断语义(挂钟超时 / token 上限 / 轮次上限)。
/// 单轮预算熔断判定(prepare 段前调用)。返回 Some((原因, 停止类别)) 即应截断退出,
/// None 继续。纯函数抽出供单测锁定三路熔断语义(挂钟超时 / token 上限 / 轮次上限);
/// 停止类别随原因同点产出,供 AiAgentFinished.stop_kind 结构化透出。
pub(crate) fn round_budget_stop(
round: u32,
max_rounds: u32,
tokens_total: u64,
deadline: tokio::time::Instant,
now: tokio::time::Instant,
) -> Option<String> {
) -> Option<(String, SubAgentStopKind)> {
if now >= deadline {
return Some(format!("挂钟超时({}s)", SUB_AGENT_WALL_TIMEOUT_SECS));
return Some((
format!("挂钟超时({}s)", SUB_AGENT_WALL_TIMEOUT_SECS),
SubAgentStopKind::WallClock,
));
}
if tokens_total >= SUB_AGENT_MAX_TOTAL_TOKENS {
return Some(format!("累计 token 达上限({})", SUB_AGENT_MAX_TOTAL_TOKENS));
return Some((
format!("累计 token 达上限({})", SUB_AGENT_MAX_TOTAL_TOKENS),
SubAgentStopKind::TokenBudget,
));
}
if round >= max_rounds {
return Some(format!("达最大轮次上限({})", max_rounds));
return Some((
format!("达最大轮次上限({})", max_rounds),
SubAgentStopKind::MaxRounds,
));
}
None
}
/// 工具批可开工判定(P1-1,纯函数供单测):剩余挂钟不足一个工具批上限加收尾缓冲
/// 时不开新批,防批执行把统一出口顶出父侧外层超时窗口。
pub(crate) fn tool_batch_affordable(
remaining: std::time::Duration,
) -> bool {
remaining >= std::time::Duration::from_secs(SUB_AGENT_TOOL_BATCH_HEADROOM_SECS)
}
/// 收敛轮终态判定(P3-7 空白降级 + P2-1 max_tokens 截断标记,纯函数供单测):
/// 末轮无 tool_calls 时,full_text 即报告。
/// - 空白(模型空回复)降级 Truncated——Completed+空报告让父会话拿到「成功却无内容」的失真终态;
/// - max_tokens 截断:报告在句中被腰斩,同样降级 Truncated 并在报告尾追加标注,
/// 不冒充正常完成(父会话据此判断报告完整性);stop_kind 映射 TokenBudget(事件
/// 枚举无独立 max_tokens 值,截断本质即单轮 token 预算触顶)。
/// 返回 (status, report, stop_reason 补充, stop_kind)。
pub(crate) fn convergence_round(
full_text: String,
truncated_by_max_tokens: bool,
) -> (SubAgentStatus, String, Option<String>, SubAgentStopKind) {
if full_text.trim().is_empty() {
(
SubAgentStatus::Truncated,
String::new(),
Some("末轮空回复收敛,未产出报告文本".to_string()),
SubAgentStopKind::EmptyConvergence,
)
} else if truncated_by_max_tokens {
let marked = format!(
"{}\n\n[输出因 max_tokens 截断,可能不完整]",
full_text.trim_end()
);
(
SubAgentStatus::Truncated,
marked,
Some("收敛报告因 max_tokens 截断".to_string()),
SubAgentStopKind::TokenBudget,
)
} else {
(SubAgentStatus::Completed, full_text, None, SubAgentStopKind::Converged)
}
}
/// 诊断文本封顶(P2-4):provider 原始错误串进父会话报告前截到 max_chars(按字符计,
/// 防多字节串截出半字符),保留头部(401/404/参数错等关键信息都在头部)。纯函数可单测。
pub(crate) fn cap_diag_text(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
return s.to_string();
}
let mut out: String = s.chars().take(max_chars).collect();
out.push('…');
out
}
/// 判定 tool_result 内容是否为子代理拒绝 envelope(S2 报告协议解析核心,纯函数可单测)。
/// 仅识别结构化 JSON 的 status=rejected + reason=sub_agent_denied;非 JSON/正常结果/
/// 其他拒绝(如主会话 path_blacklist)一律不算,防被拒清单误计。
@@ -314,19 +455,35 @@ pub(crate) fn append_rejected_section(summary: String, rejected: &[(String, u32)
}
/// 子代理专用 system prompt(要点:面向父任务执行、最终回复即结论报告、无审批通道)。
pub(crate) fn sub_agent_system_prompt() -> String {
"你是 DevFlow 主会话派生的子代理,在隔离上下文中独立执行父任务交办的任务书。\n\
## 定位\n\
- 面向父任务执行:只做任务书范围内的事,用户看不到你的过程,无法与你交互或审批\n\
- 探索/检索/核对类工作用工具完成,结论写进最终回复\n\
- 你没有审批通道:路径授权申请与中高风险工具会被直接拒绝并返回失败结果,\n\
遇到被拒绝的操作改用已授权方式完成,或在报告中说明缺口\n\
- 控制轮次:默认最多 6 轮,收敛即停,不做任务书之外的无关探索\n\
## 输出要求\n\
- 最终回复就是交付给父任务的结论报告,不要寒暄、不要向用户提问\n\
- 报告结构:结论先行,然后列关键发现/证据/数据,最后列未尽事项与建议\n\
- 若有工具调用被拒绝:在未尽事项中说明哪些子任务因工具权限受限未完成,父会话据此决定接手方式"
.to_string()
///
/// `max_rounds` 为本次 spawn 实际生效值(P2-3):预算三值(轮次/token/挂钟)动态注入,
/// 替换「默认最多 6 轮」类硬编码文案——父可传 1..20,子代理必须按真实预算规划探索节奏,
/// 预算将尽时主动收尾交卷,而非被截断后拿末轮过程性独白当报告。
/// 工具纪律段经 prompt::sub_agent_tool_discipline_section 同源拼接(P1-1)。
pub(crate) fn sub_agent_system_prompt(max_rounds: u32) -> String {
let mut p = format!(
"你是 DevFlow 主会话派生的子代理,在隔离上下文中独立执行父任务交办的任务书。\n\
## 定位\n\
- 面向父任务执行:只做任务书范围内的事,用户看不到你的过程,无法与你交互或审批\n\
- 探索/检索/核对类工作用工具完成,结论写进最终回复\n\
- 你没有审批通道:路径授权申请与中高风险工具会被直接拒绝并返回失败结果,\n\
遇到被拒绝的操作改用已授权方式完成,或在报告中说明缺口\n\
- 本轮预算(实际生效值):最多 {max_rounds} 轮 / 累计 token 上限 {max_tokens} / \
挂钟上限 {wall_secs} 秒(15 分钟)。收敛即停,不做任务书之外的无关探索;\n\
预算将尽(最后 1 轮)必须直接输出结论报告,不要留下未收尾的探索\n\
## 输出要求\n\
- 最终回复就是交付给父任务的结论报告,不要寒暄、不要向用户提问\n\
- 报告结构:结论先行,然后列关键发现/证据/数据,最后列未尽事项与建议\n\
- 若有工具调用被拒绝:在未尽事项中说明哪些子任务因工具权限受限未完成,父会话据此决定接手方式",
max_rounds = max_rounds,
max_tokens = SUB_AGENT_MAX_TOTAL_TOKENS,
wall_secs = SUB_AGENT_WALL_TIMEOUT_SECS,
);
// 工具纪律段与父会话 system prompt 同源(read_symbol 优先/重复调用检测/URL 三工具
// 纪律/禁臆造 URL/查配置用 get_app_config);obscura 安装引导等交互条款已在共享函数
// 内裁剪,不适用于子代理。
p.push_str(&crate::commands::ai::prompt::sub_agent_tool_discipline_section("zh"));
p
}
// ============================================================
@@ -348,14 +505,20 @@ pub(crate) async fn run_sub_agent_loop(
system_prompt: String,
task_prompt: String,
tool_defs: Vec<ToolDefinition>,
// 执行期工具白名单(P2-1):spawn 入口钳制产物,随子循环传入 process_tool_calls,
// 幻觉调用被裁剪面外的工具(含 Low)在执行期被就地拒绝,而不仅在 prompt 层承诺。
allowed: Vec<String>,
max_rounds: u32,
model_override: Option<String>,
llm_concurrency: LlmConcurrency,
// 父会话 save 元数据快照(P3-5):出口落库显式传入,防继承当前活跃会话的
// provider/created_at。
parent_save_meta: SaveMetaOverride,
) -> SubAgentOutcome {
let outcome = run_sub_agent_loop_inner(
session_arc.clone(), tools_arc, db.clone(), app_handle.clone(),
parent_conv_id.clone(), child_conv_id.clone(),
system_prompt, task_prompt, tool_defs,
system_prompt, task_prompt, tool_defs, allowed,
max_rounds, model_override, llm_concurrency.clone(),
).await;
@@ -373,6 +536,8 @@ pub(crate) async fn run_sub_agent_loop(
} else {
Some(format_rejected_tools(&outcome.rejected_tools))
},
// 停止类别(结构化枚举,前端按「为什么停」差异化展示;正常收敛=converged)
stop_kind: outcome.stop_kind.map(|k| k.as_str().to_string()),
});
// 子轨迹落库(二期池①):摘除零落库拦截,整条轨迹经既有 save 链路持久化。
@@ -388,7 +553,9 @@ pub(crate) async fn run_sub_agent_loop(
alive
};
if child_persistable {
save_conversation(&session_arc, &db, &child_conv_id, None, None, false).await;
// P3-5:显式传父会话快照元数据,不走 save 内部的 session.active_* 读法
// (子代理后台长跑收尾时用户可能已切走会话,active 值已是别的会话的)。
save_conversation_meta(&session_arc, &db, &child_conv_id, None, None, false, parent_save_meta).await;
}
// 清理子会话内存条目(轨迹已持久化或已随级联丢弃;origin 标记随 per_conv 一并回收)
@@ -415,6 +582,7 @@ async fn run_sub_agent_loop_inner(
system_prompt: String,
task_prompt: String,
tool_defs: Vec<ToolDefinition>,
allowed: Vec<String>,
max_rounds: u32,
model_override: Option<String>,
llm_concurrency: LlmConcurrency,
@@ -423,6 +591,16 @@ async fn run_sub_agent_loop_inner(
let deadline = tokio::time::Instant::now()
+ std::time::Duration::from_secs(SUB_AGENT_WALL_TIMEOUT_SECS);
// P2-2 目标钉扎(与主循环 G1 同构):任务书并入 system prompt。主循环把钉扎目标拼进
// system prompt 尾部对抗失焦;子会话的任务书是首条 user 消息,超预算裁剪
// (build_for_request 保尾弃头)时第一个被逐出,完成标准/约束随之蒸发。system 是
// 循环不变量(sys_tokens 一次估算缓存),不受消息淘汰影响;消息流首条 user 仍保留,
// 被淘汰也不丢目标。system_prompt 参数在此定形,后续全循环复用。
let system_prompt = format!(
"{}\n## 任务书钉扎(全程有效,上下文裁剪不可逐出)\n<task>\n{}\n</task>\n",
system_prompt, task_prompt
);
// 并发份额:global + per_conv(child) 双非阻塞 try_acquire,任一耗尽即 failed
// (子代理不阻塞等待,防占满父 loop 的执行槽与全局配额)
let (_global_permit, _conv_permit) = match (
@@ -515,9 +693,11 @@ async fn run_sub_agent_loop_inner(
let (mut stall_count, mut stall_warned, mut loop_count, mut loop_warned) = (0u32, false, 0u32, false);
let mut rounds_done: u32 = 0;
// for 耗尽(每轮都有 tool_calls 未收敛)默认截断;各 break 点按语义覆写
// for 耗尽(每轮都有 tool_calls 未收敛)默认截断;各 break 点按语义覆写。
// stop_kind 缺省 MaxRounds 与默认截断原因(达最大轮次上限)同点对齐。
let mut status = SubAgentStatus::Truncated;
let mut stop_reason = String::new();
let mut stop_kind = Some(SubAgentStopKind::MaxRounds);
let mut report = String::new();
// S2 报告协议:被拒工具累计(工具名→次数,BTreeMap 序确定性),出口汇总进报告尾+outcome
let mut rejected_tools: BTreeMap<String, u32> = BTreeMap::new();
@@ -527,12 +707,14 @@ async fn run_sub_agent_loop_inner(
if let Some(reason) = cancellation_reason(&session_arc, &child_conv_id, &parent_conv_id).await {
status = SubAgentStatus::Truncated;
stop_reason = format!("已被取消({})", reason);
stop_kind = Some(SubAgentStopKind::Cancelled);
break;
}
let total_now = tokens_snapshot(&tokens).total_tokens as u64;
if let Some(reason) = round_budget_stop(round, max_rounds, total_now, deadline, tokio::time::Instant::now()) {
if let Some((reason, kind)) = round_budget_stop(round, max_rounds, total_now, deadline, tokio::time::Instant::now()) {
status = SubAgentStatus::Truncated;
stop_reason = reason;
stop_kind = Some(kind);
break;
}
@@ -544,6 +726,7 @@ async fn run_sub_agent_loop_inner(
let Some(conv) = session.conv_read(&child_conv_id) else {
status = SubAgentStatus::Failed;
stop_reason = "子会话条目已消失".to_string();
stop_kind = Some(SubAgentStopKind::Error);
break;
};
let (mut history_msgs, _trimmed) = conv.messages.build_for_request(sys_tokens);
@@ -582,7 +765,13 @@ async fn run_sub_agent_loop_inner(
estimated_context: estimated_prompt as usize,
tier: None,
};
match stream_round(
// P1-1 deadline 下沉进轮内:流式段整体以「距 deadline 剩余」封顶,超时即 drop
// stream_round future 走正常截断路径退出统一出口。此前 deadline 检查只在轮与轮
// 之间,单轮流式+重试无上界,外层 execute_with_heartbeat 超时会先到并 drop 整个
// 子循环 future(报告蒸发+注册表泄漏+子会话滞留)。stream_round 内部短锁段无跨
// await 持锁、mid-flight 无消息写,drop 安全(guard 由本函数持有,出口统一复位)。
let stream_budget = deadline.saturating_duration_since(tokio::time::Instant::now());
match tokio::time::timeout(stream_budget, stream_round(
&session_arc, &db, &child_conv_id,
&messages, &tool_defs, &app_handle,
&child_stop, &child_notify,
@@ -594,8 +783,8 @@ async fn run_sub_agent_loop_inner(
// 子会话无目标钉扎,空快照(该参数仅供主 loop AiCompleted 快照透传)
&[],
&child_epoch, my_epoch,
).await {
RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated } => {
)).await {
Ok(RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated, truncated_by_max_tokens }) => {
rounds_done += 1;
tokens.add_usage(&round_usage);
let has_tool_calls = !tool_calls.is_empty();
@@ -613,16 +802,38 @@ async fn run_sub_agent_loop_inner(
);
}
}
// 流后复查取消:停止信号到达时优先按截断处理,不把半截文本当完成报告
// 流后复查取消:停止信号到达时优先按截断处理,不把半截文本当完成报告。
// P3-3:本轮 tool_calls 尚未执行也未拒绝,补写 cancelled tool_result 再退,
// 防轨迹悬挂(带 tool_calls 却无对应结果的消息)。
if let Some(reason) = cancellation_reason(&session_arc, &child_conv_id, &parent_conv_id).await {
push_cancelled_tool_results(&session_arc, &child_conv_id, &tool_calls).await;
status = SubAgentStatus::Truncated;
stop_reason = format!("已被取消({})", reason);
stop_kind = Some(SubAgentStopKind::Cancelled);
break;
}
if !has_tool_calls {
// 收敛轮:末轮 full_text 即报告自摘要
status = SubAgentStatus::Completed;
report = full_text;
// 收敛轮:末轮 full_text 即报告自摘要(P3-7 空白降级 / P2-1 截断标记)
let (st, rep, extra_reason, kind) = convergence_round(full_text, truncated_by_max_tokens);
status = st;
report = rep;
stop_kind = Some(kind);
if let Some(r) = extra_reason {
stop_reason = r;
}
break;
}
// P1-1 工具批预算闸:剩余预算不足一个批上限(单件外层超时封顶)不开批,
// 按 P3-3 同款补 cancelled tool_result 后截断,保证统一出口在预算内必达。
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if !tool_batch_affordable(remaining) {
push_cancelled_tool_results(&session_arc, &child_conv_id, &tool_calls).await;
status = SubAgentStatus::Truncated;
stop_reason = format!(
"挂钟超时({}s),剩余预算不足以执行本轮工具批",
SUB_AGENT_WALL_TIMEOUT_SECS
);
stop_kind = Some(SubAgentStopKind::WallClock);
break;
}
// ── 工具执行(SubAgent 策略:NeedsAuth/中高风险自动 deny,零待审批挂起)──
@@ -634,6 +845,8 @@ async fn run_sub_agent_loop_inner(
let pending = process_tool_calls(
&session_arc, tool_calls, &tools_arc, &db, &app_handle, &child_conv_id,
ToolExecPolicy::SubAgent,
// P2-1 执行期白名单:Low 工具不在名单同样就地拒绝(layer=whitelist-clamp)
Some(&allowed),
).await;
// S2 报告协议:收集本轮被拒清单(deny 单点已写入结构化 envelope,此处只读解析)
collect_rejected_tools(&session_arc, &child_conv_id, &round_calls, &mut rejected_tools).await;
@@ -641,6 +854,7 @@ async fn run_sub_agent_loop_inner(
// 防御:SubAgent 策略下不应产生挂起;出现即终止(子代理无审批恢复通道)
status = SubAgentStatus::Failed;
stop_reason = format!("子代理策略下出现意外审批挂起({} 项),终止", pending);
stop_kind = Some(SubAgentStopKind::Error);
break;
}
// L1 失败断路器(读子会话尾部消息,机制与主 loop 一致)
@@ -652,6 +866,7 @@ async fn run_sub_agent_loop_inner(
"连续同类工具失败 {} 次(如 {}),已熔断",
max_count, sample_key
);
stop_kind = Some(SubAgentStopKind::Error);
break;
}
// G2 探索熔断 + 写删回环熔断(读子会话尾部签名,机制与主 loop 一致)
@@ -666,23 +881,38 @@ async fn run_sub_agent_loop_inner(
if STALL_BREAKER_ENABLED && tripped {
status = SubAgentStatus::Failed;
stop_reason = "重复工具调用/写删回环探索熔断".to_string();
stop_kind = Some(SubAgentStopKind::Error);
break;
}
}
// stream_round 异常路径已完成其既有收尾(见模块头"事件与展示边界"残留说明)
RoundOutcome::Fatal => {
// stream_round 异常路径已完成其既有收尾(见模块头"事件与展示边界"残留说明)。
// P2-4:Fatal 携带最后诊断文本,出口报告写明真实原因(封顶防超长污染)。
Ok(RoundOutcome::Fatal { error }) => {
status = SubAgentStatus::Failed;
stop_reason = "LLM 调用失败(全候选 provider 耗尽或致命错误)".to_string();
stop_reason = format!(
"LLM 调用失败({})",
cap_diag_text(&error, DIAG_TEXT_MAX_CHARS)
);
stop_kind = Some(SubAgentStopKind::Error);
break;
}
RoundOutcome::MidStreamSaved => {
Ok(RoundOutcome::MidStreamSaved) => {
status = SubAgentStatus::Truncated;
stop_reason = "流中途中断,已保文".to_string();
stop_kind = Some(SubAgentStopKind::Error);
break;
}
RoundOutcome::StaleAfterStream => {
Ok(RoundOutcome::StaleAfterStream) => {
status = SubAgentStatus::Failed;
stop_reason = "子会话状态被外部接管".to_string();
stop_kind = Some(SubAgentStopKind::Error);
break;
}
// P1-1:流式段触顶 deadline,drop 未返回任何消息(无悬挂 tool_calls),截断退出
Err(_elapsed) => {
status = SubAgentStatus::Truncated;
stop_reason = format!("挂钟超时({}s)", SUB_AGENT_WALL_TIMEOUT_SECS);
stop_kind = Some(SubAgentStopKind::WallClock);
break;
}
}
@@ -734,6 +964,37 @@ async fn run_sub_agent_loop_inner(
rounds: rounds_done,
tokens: total_tokens,
rejected_tools: rejected,
stop_kind,
}
}
/// 流后取消/预算截断窗口(P3-3)兜底:本轮已 push 的 assistant 消息带 tool_calls,
/// 但工具既未执行也未拒绝即退出,补写 cancelled tool_result 使轨迹闭合
/// (peek/TaskDrawer 不出现无结果卡)。id 生成与 push_assistant_message 同款
/// (tool_call_id_or_fallback 兜底空 id),保证 tool_result 与消息内 tool_call 可配对。
/// 条目不存在(级联删除等)时静默跳过,不惰性重建。
async fn push_cancelled_tool_results(
session_arc: &Arc<Mutex<crate::commands::ai::AiSession>>,
child_id: &str,
tool_calls: &HashMap<u32, ToolCallDraft>,
) {
if tool_calls.is_empty() {
return;
}
let cancelled = serde_json::json!({
"status": "cancelled",
"reason": "子代理在工具执行前被停止或预算截断",
}).to_string();
let mut session = session_arc.lock().await;
let Some(conv) = session.per_conv.get_mut(child_id) else {
return;
};
let mut order: Vec<u32> = tool_calls.keys().copied().collect();
order.sort_unstable();
for (pos, idx) in order.into_iter().enumerate() {
let draft = &tool_calls[&idx];
let id = df_ai::provider::tool_call_id_or_fallback(&draft.id, pos, "gen_stream");
conv.messages.push(ChatMessage::tool_result(id, cancelled.clone()));
}
}
@@ -857,7 +1118,7 @@ mod tests {
assert!(trimmed.is_empty(), "缺省请求不产生裁剪尾注");
}
/// max_rounds 熔断:轮次/token/挂钟三路均能触发且带可读原因;预算内返 None。
/// max_rounds 熔断:轮次/token/挂钟三路均能触发且带可读原因与结构化停止类别;预算内返 None。
#[test]
fn round_budget_trips_on_rounds_tokens_and_deadline() {
let now = tokio::time::Instant::now();
@@ -865,15 +1126,18 @@ mod tests {
// 预算内:继续
assert_eq!(round_budget_stop(0, 6, 0, deadline, now), None);
// 轮次熔断
let r = round_budget_stop(6, 6, 0, deadline, now).unwrap();
let (r, kind) = round_budget_stop(6, 6, 0, deadline, now).unwrap();
assert!(r.contains("轮次"), "轮次原因: {}", r);
assert_eq!(kind, SubAgentStopKind::MaxRounds);
// token 熔断
let r = round_budget_stop(1, 6, SUB_AGENT_MAX_TOTAL_TOKENS, deadline, now).unwrap();
let (r, kind) = round_budget_stop(1, 6, SUB_AGENT_MAX_TOTAL_TOKENS, deadline, now).unwrap();
assert!(r.contains("token"), "token 原因: {}", r);
assert_eq!(kind, SubAgentStopKind::TokenBudget);
// 挂钟超时熔断(优先级最高)
let past = now - std::time::Duration::from_secs(1);
let r = round_budget_stop(1, 6, 0, past, now).unwrap();
let (r, kind) = round_budget_stop(1, 6, 0, past, now).unwrap();
assert!(r.contains("超时"), "超时原因: {}", r);
assert_eq!(kind, SubAgentStopKind::WallClock);
}
/// registry 级联:注册幂等追加、children 可查、注销移除且父条目空时整条清掉。
@@ -913,14 +1177,200 @@ mod tests {
assert_ne!(a, b, "同父连续生成的 child_id 必须唯一");
}
/// 子代理 system prompt:含面向父任务与最终回复即报告的关键约束。
/// P2-2 级联形态:Delete 形态对 child 补 deleted_convs(出口 alive 判定即放弃落库,
/// 防孤儿 sub_agent 轨迹 INSERT),Stop 形态只置 stop_flag 不打删除标记(轨迹照常落库)。
#[test]
fn cascade_delete_marks_children_deleted() {
let child = "p::agent-1".to_string();
let reg = new_registry();
sub_agents_register(&reg, "p", &child);
let mut session = crate::commands::ai::AiSession::new();
let stop_flag = {
let conv = session.conv(&child);
conv.stop_flag.clone()
};
cascade_stop_children_locked(&mut session, &reg, "p", CascadeStopForm::Delete);
assert!(stop_flag.load(Ordering::SeqCst), "两形态都须置 stop_flag");
assert!(
session.deleted_convs.contains(&child),
"Delete 形态须补 deleted_convs,防孤儿轨迹落库"
);
// Stop 形态对照:不标删除(子循环退出后轨迹照常落库)
let child2 = "p2::agent-1".to_string();
let reg2 = new_registry();
sub_agents_register(&reg2, "p2", &child2);
let mut session2 = crate::commands::ai::AiSession::new();
let _ = session2.conv(&child2);
cascade_stop_children_locked(&mut session2, &reg2, "p2", CascadeStopForm::Stop);
assert!(
!session2.deleted_convs.contains(&child2),
"Stop 形态不得打删除标记"
);
}
/// P3-7 空收敛降级 + P2-1 max_tokens 截断标记:空白 → Truncated(原因=空收敛);
/// 截断 → Truncated + 报告尾标注;正常文本 → Completed 且报告原样。
#[test]
fn convergence_round_empty_text_degrades_to_truncated() {
let (st, rep, reason, kind) = convergence_round("结论:已完成检索".to_string(), false);
assert_eq!(st, SubAgentStatus::Completed);
assert_eq!(rep, "结论:已完成检索");
assert!(reason.is_none(), "正常收敛无补充原因");
assert_eq!(kind, SubAgentStopKind::Converged);
let (st, rep, reason, kind) = convergence_round(" \n\t".to_string(), false);
assert_eq!(st, SubAgentStatus::Truncated, "空白报告不得标 Completed(终态失真)");
assert!(rep.is_empty());
let reason = reason.expect("空收敛须带截断原因");
assert!(reason.contains("空"), "原因应可读标注空收敛: {}", reason);
assert_eq!(kind, SubAgentStopKind::EmptyConvergence);
}
/// P2-1 收敛报告被 max_tokens 腰斩:降级 Truncated(不冒充 Completed),报告尾
/// 追加截断标注,stop_kind 映射 TokenBudget(单轮 token 预算触顶)。
#[test]
fn convergence_round_max_tokens_truncation_marks_report() {
let (st, rep, reason, kind) =
convergence_round("结论:分析如下,核心证据包括 1".to_string(), true);
assert_eq!(st, SubAgentStatus::Truncated, "截断报告不得标 Completed");
assert!(rep.starts_with("结论:分析如下"), "报告本体保留: {}", rep);
assert!(rep.contains("[输出因 max_tokens 截断,可能不完整]"), "须带截断标注: {}", rep);
assert_eq!(reason.as_deref(), Some("收敛报告因 max_tokens 截断"));
assert_eq!(kind, SubAgentStopKind::TokenBudget);
}
/// P2-4 诊断文本封顶:短文本原样,超长按字符截断加省略号(多字节串不截半字符)。
#[test]
fn cap_diag_text_caps_long_error() {
assert_eq!(cap_diag_text("401 Unauthorized", 100), "401 Unauthorized");
let long = "错".repeat(500);
let capped = cap_diag_text(&long, DIAG_TEXT_MAX_CHARS);
assert_eq!(capped.chars().count(), DIAG_TEXT_MAX_CHARS + 1, "含省略号");
assert!(capped.ends_with('…'));
assert!(capped.starts_with("错错错"));
}
/// P1-1 工具批预算闸:剩余预算低于批上限(headroom)不开批,充足则放行。
#[test]
fn tool_batch_headroom_gate() {
assert!(!tool_batch_affordable(std::time::Duration::from_secs(0)));
assert!(!tool_batch_affordable(std::time::Duration::from_secs(
SUB_AGENT_TOOL_BATCH_HEADROOM_SECS - 1
)), "差 1s 也不开批(硬闸)");
assert!(tool_batch_affordable(std::time::Duration::from_secs(
SUB_AGENT_TOOL_BATCH_HEADROOM_SECS
)));
}
/// P3-3 取消窗口补 envelope:本轮 tool_calls 补写 cancelled tool_result 且与 assistant
/// 消息的 tool_call id 可配对;conv 缺失时静默跳过不惰性重建。
#[tokio::test]
async fn cancelled_tool_results_close_the_trajectory() {
use df_ai::provider::ToolCall;
let session_arc = Arc::new(Mutex::new(crate::commands::ai::AiSession::new()));
let child = "p::agent-1".to_string();
{
let mut s = session_arc.lock().await;
let conv = s.conv(&child);
conv.messages.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("call_a", "read_file", "{}")],
));
}
let mut tool_calls: HashMap<u32, ToolCallDraft> = HashMap::new();
tool_calls.insert(0u32, ToolCallDraft {
id: "call_a".to_string(),
name: "read_file".to_string(),
args: "{}".to_string(),
});
push_cancelled_tool_results(&session_arc, &child, &tool_calls).await;
{
let s = session_arc.lock().await;
let msgs = s.conv_read(&child).unwrap().messages.all_messages_clone();
let last = msgs.last().expect("应已追加 cancelled tool_result");
assert!(matches!(last.role, MessageRole::Tool));
assert_eq!(last.tool_call_id.as_deref(), Some("call_a"), "须与消息内 tool_call 配对");
assert!(last.content.contains("\"cancelled\""), "envelope 内容: {}", last.content);
}
// 缺失条目:静默跳过,不惰性重建空壳
push_cancelled_tool_results(&session_arc, "ghost::agent-9", &tool_calls).await;
let s = session_arc.lock().await;
assert!(s.conv_read("ghost::agent-9").is_none(), "不得惰性重建缺失条目");
}
/// 子代理 system prompt:含面向父任务与最终回复即报告的关键约束 + 动态预算三值 +
/// 同源工具纪律段(P1-1)。
#[test]
fn sub_agent_prompt_mentions_report_contract() {
let p = sub_agent_system_prompt();
let p = sub_agent_system_prompt(6);
assert!(p.contains("子代理"));
assert!(p.contains("最终回复"));
assert!(p.contains("审批通道"), "应说明无审批通道(防挂死设计的前提告知)");
assert!(p.contains("工具权限受限"), "S2 报告协议:应要求申报因工具受限未完成的子任务");
// P2-3 预算三值动态注入(无硬编码 6 轮文案,实际生效值可见)
assert!(p.contains("最多 6 轮"), "轮次预算动态注入: {}", p);
assert!(p.contains(&SUB_AGENT_MAX_TOTAL_TOKENS.to_string()), "token 上限注入");
assert!(p.contains(&SUB_AGENT_WALL_TIMEOUT_SECS.to_string()), "挂钟上限注入");
assert!(!p.contains("默认最多 6 轮"), "硬编码缺省文案应已移除: {}", p);
// 父设小预算时按真实值注入(防按 6 轮规划被截断)
assert!(sub_agent_system_prompt(3).contains("最多 3 轮"));
// P1-1 工具纪律段同源拼接
assert!(p.contains("read_symbol"), "read_symbol 优先指引须注入: {}", p);
assert!(p.contains("重复调用检测"), "重复调用检测须注入");
assert!(p.contains("严禁臆造 URL"), "禁臆造 URL 须注入");
assert!(p.contains("get_app_config"), "查配置正解须注入");
assert!(p.contains("失败 2 次必换方案"), "render=true 铁律须注入");
assert!(!p.contains("要我帮你装吗"), "obscura 交互条款不进子代理");
}
/// P2-4 停止类别枚举序列化为 snake_case 字符串(事件面消费契约)。
#[test]
fn stop_kind_serializes_snake_case() {
assert_eq!(SubAgentStopKind::Cancelled.as_str(), "cancelled");
assert_eq!(SubAgentStopKind::WallClock.as_str(), "wall_clock");
assert_eq!(SubAgentStopKind::MaxRounds.as_str(), "max_rounds");
assert_eq!(SubAgentStopKind::TokenBudget.as_str(), "token_budget");
assert_eq!(SubAgentStopKind::EmptyConvergence.as_str(), "empty_convergence");
assert_eq!(SubAgentStopKind::Error.as_str(), "error");
assert_eq!(SubAgentStopKind::Converged.as_str(), "converged");
}
/// AiAgentFinished.stop_kind serde 序列化:Some 序列化为字符串字段,None 保留 null
/// (事件多字段向后兼容,前端不消费不破坏)。
#[test]
fn agent_finished_event_serializes_stop_kind() {
use crate::commands::ai::AiChatEvent;
let ev = AiChatEvent::AiAgentFinished {
agent_id: "p::agent-1".to_string(),
conversation_id: Some("p".to_string()),
summary: "结论:已完成".to_string(),
status: "truncated".to_string(),
rounds: 3,
tokens: 1200,
rejected_tools: None,
stop_kind: Some(SubAgentStopKind::MaxRounds.as_str().to_string()),
};
let json = serde_json::to_string(&ev).expect("事件须可序列化");
assert!(json.contains("\"stop_kind\":\"max_rounds\""), "stop_kind 须按字符串序列化: {}", json);
assert!(json.contains("\"type\":\"AiAgentFinished\""), "tag 字段: {}", json);
let ev_none = AiChatEvent::AiAgentFinished {
agent_id: "p::agent-2".to_string(),
conversation_id: None,
summary: "ok".to_string(),
status: "completed".to_string(),
rounds: 1,
tokens: 10,
rejected_tools: None,
stop_kind: None,
};
let json_none = serde_json::to_string(&ev_none).expect("事件须可序列化");
assert!(
json_none.contains(r#""stop_kind":null"#),
"None 序列化为 null(字段保留): {}",
json_none
);
}
/// S2 报告协议:deny envelope 识别——结构化 JSON 判定,非 JSON/正常结果/其他拒绝不误计。
+44 -14
View File
@@ -724,6 +724,14 @@ pub(crate) async fn spawn_agentic_session(
other => (other, false),
};
// 纯发图(无文本无技能)且模型非视觉:降级后无任何有效内容,原「[图片已忽略…]」占位串
// 会被当 user 消息喂模型(回复跑偏且浪费一轮 LLM 调用)。此处直接拒绝发送,错误经前端
// 发送失败通道透出;图文混合(有文本/技能)维持占位后缀现状。置于锁与消息 push 之前,
// 拒绝零副作用(不落库不占生成态)。
if degraded && message.trim().is_empty() && skill.is_none() {
return Err("当前模型不支持图片,无法发送纯图片消息".to_string());
}
// 原子检查并占用生成标志,防止并发双发;同步追加用户消息,按需自动创建对话
// F-260616-09 B 批4(决策 e 真并发上线):
// - 目标 conv 取入参 conversation_id(前端传 activeConversationId),None/空时 fallback
@@ -790,12 +798,12 @@ pub(crate) async fn spawn_agentic_session(
} else {
format!("{} [图片已忽略:当前模型不支持图片]", base)
}
} else if message.trim().is_empty() {
if let Some(ref skill_name) = skill {
format!("/{}", skill_name)
} else {
message.clone()
}
} else if let Some(ref skill_name) = skill {
// 技能调用留痕(问题3):带文本的技能调用落库加 /技能名 前缀,否则技能名只在
// system prompt 注入、会话历史零痕迹(用户无从得知是否用了技能)。前缀同时是
// regenerate/edit 的 backtrack_leading_skill 回溯依据(非空文本此前无前缀,
// 重生成时回溯不到即丢技能注入——本修复顺带治好该链)。
format!("/{} {}", skill_name, message)
} else {
message.clone()
};
@@ -1135,6 +1143,12 @@ pub async fn ai_approve(
let mut session = state.ai_session.lock().await;
if let Some(ref cid) = conv_id {
session.conv(cid).messages.replace_tool_result_content(&id, &result_val.to_string());
// 写后失效(审批执行路径,第4接入点):审批通过执行成功的非只读工具改变世界状态,
// 推进该会话只读缓存失效下界,防 LLM 写后重读命中改前缓存。replace 不改消息条数,
// floor 取当前 len 正确覆盖占位位之前的全部历史;仅 completed 失效(口径同其余三段)。
if audit_status == "completed" {
crate::commands::ai::audit::mark_readonly_cache_stale(&mut session.conv(cid), &approval.tool_name);
}
}
let ev_completed = AiChatEvent::AiToolCallCompleted {
@@ -1287,7 +1301,7 @@ pub async fn ai_authorize_dir(
decision: String,
) -> Result<String, String> {
// F-260620 卡死已根治(路径授权审批链闭环)。原 [AI-AUTHZ] eprintln 降级为 tracing::debug,
// 避免污染 stderr(用户可见);authz_debug 文件日志保留(读 authz-debug.log 定位,排障仍可用)。
// 避免污染 stderr(用户可见)。
tracing::debug!(
tool_call_id = %tool_call_id,
decision = %decision,
@@ -2014,6 +2028,12 @@ pub async fn ai_chat_force_send(
other => (other, false),
};
// 纯发图(无文本无技能)且模型非视觉:同 spawn_agentic_session 的拒绝闸(占位串不喂模型),
// 置于锁与消息 push 之前,拒绝零副作用。
if degraded && message.trim().is_empty() && skill.is_none() {
return Err("当前模型不支持图片,无法发送纯图片消息".to_string());
}
// 原子"复位 + 占用":同一把锁内先清目标 conv 的旧生成态,再占用 generating + 追加用户消息。
// stop_flag 置 true(清旧)随即 false(新 loop 起跑)在锁内瞬变,无人能观察中间态;
// 关键是复位与占用之间无锁释放窗口,杜绝并发 send IPC 抢占 generating。
@@ -2072,8 +2092,10 @@ pub async fn ai_chat_force_send(
// spawn_agent 级联取消:强制发送复位父 loop,其派生的子代理同步置停。
// force_send 随后会把父 stop_flag 复位 false(新 loop 起跑),子循环不能依赖父
// flag 长期置位,own stop_flag 是子循环主判据(父条目消失为兜底)。
// Stop 形态:不打删除标记,子循环退出后轨迹照常落库。
crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked(
&mut session, &state.sub_agent_registry, &target,
crate::commands::ai::agentic::sub_agent::CascadeStopForm::Stop,
);
// ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send,无生成态拦截——
// 这是"强制"语义本身,前面已主动复位)
@@ -2102,12 +2124,11 @@ pub async fn ai_chat_force_send(
} else {
format!("{} [图片已忽略:当前模型不支持图片]", base)
}
} else if message.trim().is_empty() {
if let Some(ref skill_name) = skill {
format!("/{}", skill_name)
} else {
message.clone()
}
} else if let Some(ref skill_name) = skill {
// 技能调用留痕(同 ai_chat_send,问题3):带文本的技能调用落库加 /技能名 前缀,
// 会话历史可见技能使用痕迹 + regenerate/edit 回溯依据。非空文本此前无前缀,
// 强制重发后重生成时回溯不到即丢技能注入。
format!("/{} {}", skill_name, message)
} else {
message.clone()
};
@@ -2279,10 +2300,17 @@ pub async fn ai_chat_stop(
is_estimated: false,
incomplete: None,
incomplete_reason: None,
conversation_id: Some(target),
conversation_id: Some(target.clone()),
pinned_goals,
};
emit_ai_chat_event(&app, ev);
// 安全停靠(设计 §四):挂起后取消(审批等待态用户主动停止)= 五路非正常终局之一。
// 上方 AiCompleted 收敛前端流式态,此处补 AiTaskParked 留停靠记录(挂起家族统一出口)。
crate::commands::ai::emit_task_parked(
&app, &target,
"任务已停止(审批挂起中被取消):现场已保存,重发消息或补充指令可继续",
"cancelled",
);
return Ok(());
}
// 流式生成中:置位让目标 conv 的 loop 自行收尾
@@ -2294,8 +2322,10 @@ pub async fn ai_chat_stop(
// spawn_agent 级联取消:父 loop 收到停止,其派生的子代理同步置停
// (子循环停止检查点读 own stop_flag 即退出,不等挂钟超时)。
// 短临界区纯内存操作(无 await),不延长 session 持锁。
// Stop 形态:不打删除标记,子循环退出后轨迹照常落库。
crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked(
&mut session, &state.sub_agent_registry, &target,
crate::commands::ai::agentic::sub_agent::CascadeStopForm::Stop,
);
// 置位后立即 notify_one 唤醒阻塞在 stream.next() 的 select!,
// 不再等 30s 心跳 tick 或 120s idle timeout 才轮到 stop_flag 检查。
+10 -1
View File
@@ -322,7 +322,8 @@ fn normalize_provider_type_for_fetch(provider_type: &str) -> String {
/// 绝不进日志/返回值/错误信息)
/// 3. provider_type 归一(anthropic→anthropic_compat)+ base_url + api_key 调
/// df_ai::model_fetch::fetch_and_probe → Vec<ModelConfig>(每个模型名已探测出 4 维度)
/// 4. 写回 AiProviderRecord.model_configs(update_full)→ 返回 Vec<ModelConfig>
/// 4. 写回 AiProviderRecord.model_configs(update_full)→ 返回 Vec<ModelConfig>;
/// probed 为空(远端空列表)时跳过写回,返回空列表(防清空用户模型池)
///
/// ModelConfig 本就无 api_key 字段,返回值天然不含密钥(FR-S1 闭环)。
#[tauri::command]
@@ -345,6 +346,14 @@ pub async fn ai_fetch_models(
.await
.map_err(err_str)?;
// 守卫:远端 200 + 空列表(或探测结果全被过滤)时 probed 为空——若照常 merge 后
// update_full,空 merged 会覆盖 model_configs,用户模型池连同 weight/enabled/手动
// 标注静默清空。跳过写回保留现有配置,返回空列表作提示信号(前端据 length==0
// 提示「拉取到 0 个模型,已保留现有配置」)。对照 ai_test_connection 不写库无此问题。
if probed.is_empty() {
return Ok(Vec::new());
}
// 合并:新探测 configs 为主(更新能力维度 modalities/capabilities/cost_tier/intelligence/
// context_window/probe_source),按 model_id 保留用户在 Settings 调过的 weight/enabled/label。
// 否则每次「测试连接/拉取模型」覆盖回探测默认 weight(50),权重失效致 ProviderPool/router
+163 -63
View File
@@ -81,57 +81,68 @@ fn env_profile_date_volatile() -> String {
format!("今天是 {today}")
}
/// 按语言返回系统提示词的 (固定前缀, 项目上下文标题, 任务上下文标题)
fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str) {
/// 按语言返回系统提示词的 (固定前缀, 项目上下文标题, 任务上下文标题)。
///
/// prefix 内的 read_symbol 优先与重复调用检测两条经 [`read_symbol_guidance`] /
/// [`duplicate_call_guidance`] 注入(与子代理工具纪律段同源,零复制)。
fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
match lang {
"en" => (
"You are DevFlow's AI assistant. You can manage projects, tasks, ideas, and workflows, as well as analyze code, read/write files, and execute commands.\n\
Please respond in English.\n\n\
## Capabilities\n\
You can perform the following actions via tool calls:\n\
- Create/query projects, tasks, and ideas\n\
- Run workflows\n\
- Read file contents, list directories, create/write files\n\
- To understand/locate a code symbol (function/struct/class) prefer read_symbol(path, symbol): returns a skeleton (signature + line range + internal calls, minimal and high-density), avoiding read_file dumping the whole file into the prompt; pass full=true for the full definition body; read_symbol supports .rs/.ts/.tsx/.js/.jsx/.vue — fall back to read_file only for other languages or when the symbol is not found\n\
- Get file metadata (size, line count, binary detection)\n\
- Append content to files, search files by name pattern\n\n\
## Guidelines\n\
- Briefly explain your intent before executing actions\n\
- Ask for clarification if the user's intent is unclear\n\
- Prefer using tools to complete actions rather than just describing steps\n\
- When a tool call fails, clearly tell the user it failed and why. Never disguise a fallback action as the original intent's success (e.g. don't write to description to fake a directory binding), and never falsely report success\n\
- **Self-check before claiming completion**: Before announcing a task done, verify the result matches the original intent (grep key references / read back field values / cross-check). Fix discrepancies before declaring success; never claim all done without verification (repeatedly observed: missed reference renames, fields silently overwritten by later ops, forcing the user to manually verify)\n\
- **Duplicate call detection**: If a tool (e.g. read_file/search_files) has already been called with identical arguments and returned successfully, do not call it again — the result is already in the conversation history; review the context instead of re-executing\n\
## Focus\n\
- Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\
- When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\
- Give the conclusion or action first, then add only necessary explanation; omit tangential information unrelated to the current request.\n\
- Do not proactively expand context (files/data) that is irrelevant to the current request.\n",
format!(
"You are DevFlow's AI assistant. You can manage projects, tasks, ideas, and workflows, as well as analyze code, read/write files, and execute commands.\n\
Please respond in English.\n\n\
## Capabilities\n\
You can perform the following actions via tool calls:\n\
- Create/query projects, tasks, and ideas\n\
- Run workflows\n\
- Read file contents, list directories, create/write files\n\
- {}\n\
- Get file metadata (size, line count, binary detection)\n\
- Append content to files, search files by name pattern\n\n\
## Guidelines\n\
- Briefly explain your intent before executing actions\n\
- Ask for clarification if the user's intent is unclear\n\
- Prefer using tools to complete actions rather than just describing steps\n\
- When a tool call fails, clearly tell the user it failed and why. Never disguise a fallback action as the original intent's success (e.g. don't write to description to fake a directory binding), and never falsely report success\n\
- **Self-check before claiming completion**: Before announcing a task done, verify the result matches the original intent (grep key references / read back field values / cross-check). Fix discrepancies before declaring success; never claim all done without verification (repeatedly observed: missed reference renames, fields silently overwritten by later ops, forcing the user to manually verify)\n\
- {}\n\
## Focus\n\
- Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\
- When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\
- Give the conclusion or action first, then add only necessary explanation; omit tangential information unrelated to the current request.\n\
- Do not proactively expand context (files/data) that is irrelevant to the current request.\n",
read_symbol_guidance(lang),
duplicate_call_guidance(lang),
),
"\n## Current Projects\n",
"\n## Current Tasks\n",
),
_ => (
"你是 DevFlow 桌面应用的 AI 助手。你可以操作项目/任务/灵感/工作流,也可分析代码、读写文件、执行命令。\n\
必须使用简体中文回复,禁止使用繁体中文字符。\n\n\
## 当前能力\n\
你可以通过工具调用执行以下操作:\n\
- 创建/查询项目、任务、灵感\n\
- 运行工作流\n\
- 读取文件内容、列出目录、创建/写入文件\n\
- 理解/定位代码符号(函数/结构体/类等)优先用 read_symbol(path, symbol):返回骨架(签名+行范围+内部调用,极小高密度),避免 read_file 全文回灌撑爆 prompt;需完整定义体传 full=true;read_symbol 支持 .rs/.ts/.tsx/.js/.jsx/.vue,其他语言或未找到符号时再退回 read_file\n\
- 获取文件元信息(大小、行数、二进制检测)\n\
- 追加写入文件、按名称模式搜索文件\n\n\
## 行为准则\n\
- 执行操作前简要说明你的意图\n\
- 如果不确定用户意图,先提问\n\
- 优先使用工具完成操作,而不是只描述步骤\n\
- 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n\
- **重复调用检测**:如果某个工具(如 read_file/search_files)已用相同参数调用过且返回成功,不要重复调用——会话历史里已有结果,回顾上下文而非重复执行\n\
## 聚焦准则\n\
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
- 回答先给结论/动作,再补必要的解释;与当前请求无关的扩展信息省略。\n\
- 需要的上下文(文件/数据)若与当前请求无关,不要主动展开。\n",
format!(
"你是 DevFlow 桌面应用的 AI 助手。你可以操作项目/任务/灵感/工作流,也可分析代码、读写文件、执行命令。\n\
必须使用简体中文回复,禁止使用繁体中文字符。\n\n\
## 当前能力\n\
你可以通过工具调用执行以下操作:\n\
- 创建/查询项目、任务、灵感\n\
- 运行工作流\n\
- 读取文件内容、列出目录、创建/写入文件\n\
- {}\n\
- 获取文件元信息(大小、行数、二进制检测)\n\
- 追加写入文件、按名称模式搜索文件\n\n\
## 行为准则\n\
- 执行操作前简要说明你的意图\n\
- 如果不确定用户意图,先提问\n\
- 优先使用工具完成操作,而不是只描述步骤\n\
- 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n\
- {}\n\
## 聚焦准则\n\
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
- 回答先给结论/动作,再补必要的解释;与当前请求无关的扩展信息省略。\n\
- 需要的上下文(文件/数据)若与当前请求无关,不要主动展开。\n",
read_symbol_guidance(lang),
duplicate_call_guidance(lang),
),
"\n## 当前项目\n",
"\n## 当前任务\n",
),
@@ -153,7 +164,56 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
/// AI 看到 hint 即自主提示用户「装了能 JS 渲染 SPA/反爬」,征得同意后用 run_command 帮装。
///
/// 仅段加在 prefix 末尾(聚焦段后),不破坏现有结构。
fn doc_exploration_strategy_section(lang: &str) -> &'static str {
///
/// 段体拆为两块:核心纪律(URL 三工具分工/探索流程/禁臆造 URL/render=true 铁律)与
/// obscura 安装引导(涉用户交互与 run_command,父侧专用)。子代理工具纪律段
/// ([`sub_agent_tool_discipline_section`])只拼核心块。
fn doc_exploration_strategy_section(lang: &str) -> String {
format!(
"{}{}",
doc_strategy_core_section(lang),
doc_strategy_obscura_install_section(lang)
)
}
/// read_symbol 优先指引(中/英,不含行首 "- ")。父会话能力清单与子代理工具纪律段
/// 同源拼接(DRY,治指引真空:工具下发了纪律没下发,实测翻车坑在子代理原样复发)。
fn read_symbol_guidance(lang: &str) -> &'static str {
match lang {
"en" => "To understand/locate a code symbol (function/struct/class) prefer read_symbol(path, symbol): returns a skeleton (signature + line range + internal calls, minimal and high-density), avoiding read_file dumping the whole file into the prompt; pass full=true for the full definition body; read_symbol supports .rs/.ts/.tsx/.js/.jsx/.vue — fall back to read_file only for other languages or when the symbol is not found",
_ => "理解/定位代码符号(函数/结构体/类等)优先用 read_symbol(path, symbol):返回骨架(签名+行范围+内部调用,极小高密度),避免 read_file 全文回灌撑爆 prompt;需完整定义体传 full=true;read_symbol 支持 .rs/.ts/.tsx/.js/.jsx/.vue,其他语言或未找到符号时再退回 read_file",
}
}
/// 重复调用检测指引(中/英,不含行首 "- ")。同源拼接见 read_symbol_guidance。
fn duplicate_call_guidance(lang: &str) -> &'static str {
match lang {
"en" => "**Duplicate call detection**: If a tool (e.g. read_file/search_files) has already been called with identical arguments and returned successfully, do not call it again — the result is already in the conversation history; review the context instead of re-executing",
_ => "**重复调用检测**:如果某个工具(如 read_file/search_files)已用相同参数调用过且返回成功,不要重复调用——会话历史里已有结果,回顾上下文而非重复执行",
}
}
/// 子代理工具纪律段(P1-1):与父会话 system prompt 同源拼接的工具使用纪律 ——
/// read_symbol 优先 / 重复调用检测 / URL 三工具纪律与禁臆造 URL / render=true 失败
/// 2 次必换方案 / 查配置用 get_app_config。子代理白名单缺省下发全量 Low 工具,
/// 纪律必须随工具同源下发,否则父会话实测蒸馏的防坑经验在子循环原样复发。
/// 裁剪不适用条款:obscura 安装引导(涉用户交互,子代理无交互面)、聚焦准则与
/// 用户交互类行为准则(执行前说明意图/向用户提问)。
pub(crate) fn sub_agent_tool_discipline_section(lang: &str) -> String {
format!(
"\n## 工具纪律\n\
- {}\n\
- {}\n{}{}",
read_symbol_guidance(lang),
duplicate_call_guidance(lang),
doc_strategy_core_section(lang),
app_config_query_guidance_section(lang),
)
}
/// 文档探索策略核心段(中/英):URL 三工具分工 + 嗅探→识别→提取→验证→应用流程 +
/// 禁臆造 URL + render=true 失败重试铁律。父会话与子代理同源拼接块。
fn doc_strategy_core_section(lang: &str) -> &'static str {
match lang {
"en" => "\n\
## Documentation Exploration Strategy\n\
@@ -189,17 +249,7 @@ fn doc_exploration_strategy_section(lang: &str) -> &'static str {
1. **Switch after 2 failures**: if `render=true` fails twice in a row (any failure signal) → **immediately stop retrying that URL**. Pick an alternative: (a) use `http_request` to hit the API endpoint directly; (b) ask the user to provide content/screenshots manually; (c) work with the data you already have and produce output now — don't wait for a perfect render.\n\
2. **Stop after the first success**: if the first `render=true` succeeds and returns markdown → **use the data you have immediately**; do NOT re-render the same URL to top up more content (what you got is what you use).\n\
3. **Never re-render the same URL**: once a URL's render result (success or failure) is known, never fire render=true at it again (history/cache is enough); for new content, switch URL or switch tactic.\n\
\n\
### JS-rendered docs & the obscura install hint\n\
`fetch_url` defaults to a static fetch (no JS). For SPA / anti-scrape / JS-rendered docs, the user (or you) can pass `render=true`. When obscura (the JS-rendering backend, github.com/h4ckf0r0day/obscura) is **not installed**, the result silently falls back to static and the returned JSON carries `render_mode: \"static_fallback\"` plus a `hint` field pointing to the install page. Watch the **`hint` field** specifically (not just `render_mode`) — it is the install signal:\n\
1. **Notice**: if the `fetch_url` result has `hint` set (or `render_mode` is `static_fallback`), obscura is missing and the page may be under-rendered.\n\
2. **Offer**: proactively tell the user — \"obscura is not installed; installing it lets fetch_url render JS-heavy SPA / anti-scrape docs. Want me to install it for you?\" Don't silently continue with the static fallback when the user clearly needs JS rendering.\n\
3. **Install on consent**: once the user agrees, run the install yourself via `run_command` and stream the output so the user sees progress — pick whichever path fits the user's environment:\n\
- Rust toolchain present: `cargo install obscura`\n\
- otherwise: download the matching prebuilt binary from github.com/h4ckf0r0day/obscura releases and place it on PATH\n\
(`run_command` is high-risk and requires user approval — the user's \"yes\" above is that approval; still announce the exact command before running.)\n\
4. **Re-fetch**: after install succeeds, tell the user \"obscura is ready\" and retry `fetch_url` with `render=true` to get the properly rendered markdown.\n\
This is a pure conversational guide — there is no UI button for it; you detect the hint and drive the install through dialogue.\n",
\n",
_ => "\n\
## 文档探索策略\n\
### 先选对 URL 工具(三者别混用)\n\
@@ -234,8 +284,28 @@ fn doc_exploration_strategy_section(lang: &str) -> &'static str {
1. **失败 2 次必换方案**:`render=true` 连续 2 次失败(任意失败信号) → **立即停止重试同一 URL**,改走以下任一:(a) 改用 `http_request` 直接打 API 端点;(b) 提示用户手动提供内容/截图;(c) 用已拿到的数据先整理输出,别等完美渲染。\n\
2. **第 1 次成功即收手**:`render=true` 第 1 次成功拿到 markdown → **立即用已有数据整理输出**,不要重复 render 同 URL 去「补充」更多内容(已拿即用)。\n\
3. **同 URL 不重复 render=true**:同一 URL 的 render 结果(成功或失败)已知,后续不再对它重复发 render=true(缓存/历史即用);需要新内容请换 URL 或换方案。\n\
\n\
### JS 渲染文档与 obscura 安装引导\n\
\n",
}
}
/// obscura 安装引导段(中/英,父侧专用):涉用户交互(征得同意帮装)与 run_command 高风险
/// 工具,子代理无用户交互面,不进子代理工具纪律段([`sub_agent_tool_discipline_section`])。
/// 与核心段拼接 = 原 [`doc_exploration_strategy_section`] 完整段(逐字节等价)。
fn doc_strategy_obscura_install_section(lang: &str) -> &'static str {
match lang {
// 注:核心段末尾已保留空行(…tactic.\n\n / …换方案。\n\n),本段自标题起拼,
// 拼接 = 原完整段逐字节等价,不再补前导换行。
"en" => "### JS-rendered docs & the obscura install hint\n\
`fetch_url` defaults to a static fetch (no JS). For SPA / anti-scrape / JS-rendered docs, the user (or you) can pass `render=true`. When obscura (the JS-rendering backend, github.com/h4ckf0r0day/obscura) is **not installed**, the result silently falls back to static and the returned JSON carries `render_mode: \"static_fallback\"` plus a `hint` field pointing to the install page. Watch the **`hint` field** specifically (not just `render_mode`) — it is the install signal:\n\
1. **Notice**: if the `fetch_url` result has `hint` set (or `render_mode` is `static_fallback`), obscura is missing and the page may be under-rendered.\n\
2. **Offer**: proactively tell the user — \"obscura is not installed; installing it lets fetch_url render JS-heavy SPA / anti-scrape docs. Want me to install it for you?\" Don't silently continue with the static fallback when the user clearly needs JS rendering.\n\
3. **Install on consent**: once the user agrees, run the install yourself via `run_command` and stream the output so the user sees progress — pick whichever path fits the user's environment:\n\
- Rust toolchain present: `cargo install obscura`\n\
- otherwise: download the matching prebuilt binary from github.com/h4ckf0r0day/obscura releases and place it on PATH\n\
(`run_command` is high-risk and requires user approval — the user's \"yes\" above is that approval; still announce the exact command before running.)\n\
4. **Re-fetch**: after install succeeds, tell the user \"obscura is ready\" and retry `fetch_url` with `render=true` to get the properly rendered markdown.\n\
This is a pure conversational guide — there is no UI button for it; you detect the hint and drive the install through dialogue.\n",
_ => "### JS 渲染文档与 obscura 安装引导\n\
`fetch_url` 默认走静态抓取(不跑 JS)。遇到 SPA / 反爬 / JS 渲染的文档,用户(或你)可传 `render=true`。当 obscura(JS 渲染后端,github.com/h4ckf0r0day/obscura)**未安装**时,结果会静默回退静态,返回的 JSON 带 `render_mode: \"static_fallback\"` 和一个指向安装页的 `hint` 字段。请**重点看 `hint` 字段**(而非只看 `render_mode`)——它就是安装信号:\n\
1. **察觉**:若 `fetch_url` 结果带 `hint`(或 `render_mode` 为 `static_fallback`),说明 obscura 缺失,页面可能没渲染完整。\n\
2. **提议**:主动告诉用户——「obscura 未安装,装了之后 fetch_url 就能渲染 JS 重的 SPA / 反爬文档。要我帮你装吗?」当用户明显需要 JS 渲染时,不要默默用静态回退糊弄过去。\n\
@@ -412,9 +482,9 @@ pub(crate) async fn build_system_prompt_with_excluded(
}
let (prefix, _, _) = system_prompt_parts(lang);
let mut prompt = env_profile_line();
prompt.push_str(prefix);
prompt.push_str(&prefix);
// 文档探索策略段(URL→fetch_url 嗅探→识别→提取→验证→应用,治盲目 http_request 瞎试)
prompt.push_str(doc_exploration_strategy_section(lang));
prompt.push_str(&doc_exploration_strategy_section(lang));
// 查 DevFlow 自身配置引导(治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败)
prompt.push_str(app_config_query_guidance_section(lang));
@@ -556,8 +626,8 @@ pub(crate) async fn build_system_prompt_parts(
// 易变段(日期 + 项目/任务清单)挪到消息流末尾,不失效 system 缓存。
let (prefix, _, _) = system_prompt_parts(lang);
let mut stable = env_profile_stable_body();
stable.push_str(prefix);
stable.push_str(doc_exploration_strategy_section(lang));
stable.push_str(&prefix);
stable.push_str(&doc_exploration_strategy_section(lang));
stable.push_str(app_config_query_guidance_section(lang));
stable.push_str(&build_custom_section(state).await);
@@ -847,6 +917,36 @@ mod tests {
assert!(en.contains("Do **NOT**"));
}
// P1-1 子代理工具纪律段:与父会话同源(read_symbol/重复调用检测/URL 纪律/查配置),
// 且裁剪掉用户交互条款(obscura 安装引导,子代理无交互面)。
#[test]
fn sub_agent_tool_discipline_section_shares_parent_text() {
let zh = sub_agent_tool_discipline_section("zh-CN");
assert!(zh.contains("## 工具纪律"));
assert!(zh.contains(read_symbol_guidance("zh-CN")), "read_symbol 指引须与父会话同源");
assert!(zh.contains(duplicate_call_guidance("zh-CN")), "重复调用检测须与父会话同源");
assert!(zh.contains("## 文档探索策略"), "URL 纪律核心段须注入");
assert!(zh.contains("严禁臆造 URL"));
assert!(zh.contains("失败 2 次必换方案"));
assert!(zh.contains("get_app_config"));
assert!(
!zh.contains("要我帮你装吗"),
"obscura 安装引导为用户交互条款,子代理不得注入: {}",
zh
);
assert!(!zh.contains("## 聚焦准则"), "会话面准则不进子代理");
// 父会话 prefix 与共享函数同字同源(DRY 锁:改共享函数父侧同步生效)
let (prefix, _, _) = system_prompt_parts("zh-CN");
assert!(prefix.contains(read_symbol_guidance("zh-CN")));
assert!(prefix.contains(duplicate_call_guidance("zh-CN")));
// 父侧完整文档探索段 = 核心 + obscura 引导(拆分不丢段)
let parent_doc = doc_exploration_strategy_section("zh-CN");
assert!(parent_doc.contains("失败 2 次必换方案"));
assert!(parent_doc.contains("要我帮你装吗"));
}
// 项目/任务清单注明语(中/英)存在
#[test]
fn listed_notes_present_zh_en() {
+16 -1
View File
@@ -32,6 +32,7 @@ use crate::commands::ai::agentic::sub_agent::{
SUB_AGENT_DEFAULT_MAX_ROUNDS, SUB_AGENT_MAX_ROUNDS_CAP,
};
use crate::commands::ai::AiSession;
use crate::commands::ai::conversation::SaveMetaOverride;
use crate::state::LlmConcurrency;
use df_storage::db::Database;
@@ -253,7 +254,19 @@ async fn spawn_agent_handler(
let child_id = next_child_id(&parent_conv_id);
let task_prompt = build_task_prompt(&task, &allowed, &trimmed);
let system_prompt = sub_agent_system_prompt();
// P2-3:系统提示词注入实际生效的预算值(父可传 1..20,子代理按真实预算规划)
let system_prompt = sub_agent_system_prompt(max_rounds);
// P3-5:spawn 时刻快照父会话 save 元数据(active provider/created_at)。子代理后台
// 长跑收尾时用户可能已切走会话,出口落库若读 active_* 会继承别的会话元数据;
// 快照随子循环传入,出口经 save_conversation_meta 显式落库。
let parent_save_meta = {
let session = ctx.session.lock().await;
SaveMetaOverride {
provider_id: session.active_provider_id.clone(),
created_at: session.active_conv_created_at.clone(),
}
};
// 级联注册(父停止/强制发送/删除会遍历 children 级联取消)
sub_agents_register(&ctx.sub_agents, &parent_conv_id, &child_id);
@@ -287,9 +300,11 @@ async fn spawn_agent_handler(
system_prompt,
task_prompt,
tool_defs,
allowed,
max_rounds,
child_model,
ctx.llm_concurrency.clone(),
parent_save_meta,
)
.await;