From 01d231c39d61a7a37074418cdbf34760ee37e358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sun, 4 Oct 2026 16:05:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E5=AD=90=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E4=BD=93=E9=AA=8C=E4=B8=8E=E7=BA=AA=E5=BE=8B=E6=B3=A8?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工具纪律段抽共享同源注入 - 子代理轨迹浮层与完成卡身份 - 分离窗会话切换修复 - 960s 统一出口报告不再蒸发 --- .../src/commands/ai/agentic/sub_agent.rs | 544 ++++++++++++++++-- src-tauri/src/commands/ai/commands/chat.rs | 58 +- .../src/commands/ai/commands/provider.rs | 11 +- src-tauri/src/commands/ai/prompt.rs | 226 ++++++-- .../src/commands/ai/tools/spawn_agent.rs | 17 +- src/components/AiChat.vue | 11 + src/components/ai/MessageItem.vue | 36 +- src/components/ai/MessageList.vue | 260 ++++++++- src/components/task/TaskDrawer.vue | 28 +- src/composables/ai/aiShared.ts | 54 ++ src/composables/ai/useAiWindow.ts | 52 +- src/composables/ai/useToolCardHeader.ts | 5 + 12 files changed, 1150 insertions(+), 152 deletions(-) diff --git a/src-tauri/src/commands/ai/agentic/sub_agent.rs b/src-tauri/src/commands/ai/agentic/sub_agent.rs index 612c199..3778e6e 100644 --- a/src-tauri/src/commands/ai/agentic/sub_agent.rs +++ b/src-tauri/src/commands/ai/agentic/sub_agent.rs @@ -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, } 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 Option { +) -> 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, 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, + // 执行期工具白名单(P2-1):spawn 入口钳制产物,随子循环传入 process_tool_calls, + // 幻觉调用被裁剪面外的工具(含 Low)在执行期被就地拒绝,而不仅在 prompt 层承诺。 + allowed: Vec, max_rounds: u32, model_override: Option, 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, + allowed: Vec, max_rounds: u32, model_override: Option, 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\n{}\n\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 = 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>, + child_id: &str, + tool_calls: &HashMap, +) { + 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 = 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(®, "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, ®, "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(®2, "p2", &child2); + let mut session2 = crate::commands::ai::AiSession::new(); + let _ = session2.conv(&child2); + cascade_stop_children_locked(&mut session2, ®2, "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 = 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/正常结果/其他拒绝不误计。 diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 20f55a6..2a724f2 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -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 { // 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 检查。 diff --git a/src-tauri/src/commands/ai/commands/provider.rs b/src-tauri/src/commands/ai/commands/provider.rs index ad16f19..557eee0 100644 --- a/src-tauri/src/commands/ai/commands/provider.rs +++ b/src-tauri/src/commands/ai/commands/provider.rs @@ -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(每个模型名已探测出 4 维度) -/// 4. 写回 AiProviderRecord.model_configs(update_full)→ 返回 Vec +/// 4. 写回 AiProviderRecord.model_configs(update_full)→ 返回 Vec; +/// 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 diff --git a/src-tauri/src/commands/ai/prompt.rs b/src-tauri/src/commands/ai/prompt.rs index 3e7e8be..40bc197 100644 --- a/src-tauri/src/commands/ai/prompt.rs +++ b/src-tauri/src/commands/ai/prompt.rs @@ -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() { diff --git a/src-tauri/src/commands/ai/tools/spawn_agent.rs b/src-tauri/src/commands/ai/tools/spawn_agent.rs index ef44493..b0ca9bd 100644 --- a/src-tauri/src/commands/ai/tools/spawn_agent.rs +++ b/src-tauri/src/commands/ai/tools/spawn_agent.rs @@ -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; diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index e993dfa..68c6164 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -46,6 +46,8 @@
+ + + + + +