diff --git a/crates/df-execute/src/env_snapshot.rs b/crates/df-execute/src/env_snapshot.rs index 645914e..d253f81 100644 --- a/crates/df-execute/src/env_snapshot.rs +++ b/crates/df-execute/src/env_snapshot.rs @@ -102,7 +102,9 @@ impl EnvSnapshot { } } - /// 兜底:spawn_blocking panic/join 失败时返回最小可用快照(全 None,字段不空)。 + /// 兜底:spawn_blocking panic/join 失败或外层 5s 超时时返回最小可用快照(全 None,字段不空)。 + /// 注:异常/超时均**冻结**为 fallback 写入 OnceLock(EX-3)——本进程后续 detect() 不再重探, + /// 瞬时挂起(如 Store Alias)会被固化为整进程降级快照;解冻需重启进程。 fn fallback() -> EnvSnapshot { EnvSnapshot { os: std::env::consts::OS.to_string(), @@ -310,7 +312,11 @@ fn extract_codepage(text: &str) -> Option { } } -/// 执行 `tool --version`,解析首行返回版本串。失败/超时返回 None,不阻塞调用方。 +/// 执行 `tool --version`,解析首行返回版本串。失败(非零退出/无首行)返回 None。 +/// +/// 注:单条命令本身**无独立超时**(EX-4)——若 `tool --version` 挂起(如 Windows Store Alias), +/// 本函数会一直阻塞,由外层 [`EnvSnapshot::detect`] 的 5s spawn_blocking 超时兜底整批(届时 +/// 全部工具版本一起回退 fallback),而非逐命令超时。「不阻塞调用方」靠的是外层超时,非本函数。 /// /// 例:python --version 输出 "Python 3.11.5" → 返回 "3.11.5";git --version 输出 /// "git version 2.41.0" → 返回 "2.41.0"。统一抽掉工具名前缀,只保留版本号本身。 diff --git a/crates/df-execute/src/shell.rs b/crates/df-execute/src/shell.rs index 99bfdeb..61f6d39 100644 --- a/crates/df-execute/src/shell.rs +++ b/crates/df-execute/src/shell.rs @@ -320,7 +320,7 @@ pub struct ShellRequest { pub env: std::collections::HashMap, /// 超时时间(秒),None 表示不超时 pub timeout_secs: Option, - /// Shell 类型(默认:Windows→Cmd, 非Windows→Sh) + /// Shell 类型(None 时取 [`ShellType::default`]:Windows→pwsh 可用则 pwsh 否则 PowerShell,非 Windows→Sh) #[serde(default)] pub shell_type: Option, } @@ -419,7 +419,7 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result { // 提交冻结机械闸(39 治理):冻结在场且命令为 git commit → spawn 前拒绝,改动留工作区 crate::commit_freeze::check(&request).map_err(anyhow::Error::msg)?; - // 探测 pwsh(惰性 + OnceLock 全局缓存,只探一次),使后续 ShellType::default() 可读取缓存 + // 探测 pwsh(惰性 + 模块级 PWSH_CACHE,成功才写缓存),使后续 ShellType::default() 可读取缓存 #[cfg(windows)] let _ = probe_pwsh().await; @@ -453,7 +453,11 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result { /// 治 run_command 执行中黑盒:execute() 等 exit 才返回整块 stdout/stderr,长命令(cargo/npm 构建) /// 期间前端只看 Started→Completed,中间进度不可见。本函数 spawn 子进程后并发逐行读两条流, /// 每读到一行回调 `on_output(kind, line)`(调用方可 emit 事件给前端实时展示),仍等进程 exit -/// 后返回完整 ShellResult(与 execute() 同形,调用方无需感知差异)。 +/// 后返回完整 ShellResult。 +/// +/// 与 [`execute`] 的输出差异(EX-2,调用方须知):本函数 stdout/stderr 按行重组——经 +/// `BufReader::lines()` 逐行拼接,行尾统一为 `\n`(剥 \r\n)且补末尾换行;execute 则保留原始字节 +/// (lossy 解码,含 \r\n,无补换行)。二者 ShellResult 字段同形,仅换行形态不同;需字节级一致时用 execute。 /// /// 4性: /// - 合理机制:spawn + BufReader::lines() 逐行,不丢未换行结尾的末段(read_to_end 兜底) diff --git a/crates/df-ideas/src/scoring.rs b/crates/df-ideas/src/scoring.rs index a632e2b..f153bae 100644 --- a/crates/df-ideas/src/scoring.rs +++ b/crates/df-ideas/src/scoring.rs @@ -125,8 +125,6 @@ impl ScoringEngine { } } -/// 统计 text 中命中任一关键词的数量(小写匹配,兼顾中英文) -/// 局限:纯子串匹配,不识别"不复用""无用户增长"等否定前缀,接 LLM 后由语义层修正 /// 单字否定前缀:关键词紧邻这些字时该命中不计(「不复用」不计入「复用」正向命中)。 /// /// 注意「别」单字歧义大(别具/别的/特别 均非否定),故不进单字清单—— diff --git a/crates/df-nodes/src/subflow_node.rs b/crates/df-nodes/src/subflow_node.rs index 4be3b03..b259ce7 100644 --- a/crates/df-nodes/src/subflow_node.rs +++ b/crates/df-nodes/src/subflow_node.rs @@ -1,7 +1,8 @@ -//! 子工作流节点 — 加载一个子 DAG 并在当前执行上下文中递归执行 +//! 子工作流节点 — 加载并校验一个子 DAG,返回其 JSON 快照 //! //! 用于复用通用流程(如"代码审查"可作为任意主流程的子步骤)。 -//! 执行结果不直接展开节点,而是返回子 DAG 的 JSON 快照,由 DagExecutor 消费展开。 +//! 注意:本节点不递归执行子 DAG,只做配置/深度校验后返回子 DAG 快照; +//! 快照的展开执行需调用方自行处理(DagExecutor 当前不会自动执行返回的 subflow)。 use async_trait::async_trait; use df_workflow::dag_def::DagDef; diff --git a/crates/df-project/src/scan_helpers.rs b/crates/df-project/src/scan_helpers.rs index 2a41645..f969205 100644 --- a/crates/df-project/src/scan_helpers.rs +++ b/crates/df-project/src/scan_helpers.rs @@ -114,14 +114,11 @@ pub(crate) fn strip_readme_noise(content: &str) -> String { if trimmed == "---" || trimmed == "+++" { // 开头处的 frontmatter:跳到下一个匹配分隔符 let delim = trimmed; - let mut consumed_any = false; for next in lines.by_ref() { - consumed_any = true; if next.trim() == delim { break; } } - let _ = consumed_any; continue; } // ── HTML 注释块(,可能跨行)── diff --git a/crates/df-types/src/types.rs b/crates/df-types/src/types.rs index e9a6314..2a20768 100644 --- a/crates/df-types/src/types.rs +++ b/crates/df-types/src/types.rs @@ -315,6 +315,10 @@ impl ProjectStatus { /// /// Input Augmentation 层 ProjectResolver/TaskResolver 把记录的 String status 投影成 /// 类型化枚举用。未知字符串返 None。 + /// + /// ⚠️ TYPES-2:本函数返 None 是显式信号,但若调用方用 `unwrap_or_default()` 兜底, + /// 未知值会**静默归 Planning**(ProjectStatus::default),掩盖数据异常。建议调用方对 + /// None 分支记 warn 审计(而非静默兜底),便于发现脏 status。 pub fn from_db_str(s: &str) -> Option { Some(match s { "planning" => ProjectStatus::Planning, diff --git a/src-tauri/src/commands/ai/agentic/sub_agent.rs b/src-tauri/src/commands/ai/agentic/sub_agent.rs index 458de6e..44eb445 100644 --- a/src-tauri/src/commands/ai/agentic/sub_agent.rs +++ b/src-tauri/src/commands/ai/agentic/sub_agent.rs @@ -33,9 +33,12 @@ //! (父侧 ai_chat_stop / ai_chat_force_send / ai_conversation_delete 经 SubAgentRegistry 级联)。 //! //! ## 事件与展示边界 -//! 仅在结束时挂父会话 emit 一条 AiAgentFinished(前端展示二期消费);不 emit AiCompleted / -//! AiMaxRounds 终态事件、不 spawn 标题/知识提炼。stream_round 复用带来的残留(均为网络异常 -//! 罕见路径,消费方按「未知 conversation_id 丢弃」自我过滤,完整清单记录于此): +//! 运行期展示类事件(轮次/流式文本/工具生命周期/命令输出)经 `route_scope` 把子会话 +//! conv 改挂父会话再推送,父会话视图能看到子代理实时进展(治「切回父会话 spawn 卡停在旧态」 +//! 的幽灵态);复用既有 emit 双写出口,不另开旁路。仅结束时挂父会话 emit 一条 AiAgentFinished +//! (前端展示二期消费);不 emit AiCompleted / AiMaxRounds 终态事件、不 spawn 标题/知识提炼。 +//! 非展示类事件(Fatal 的 AiError、MidStream 的 AiConvStateChanged 等)不改写,维持子会话 +//! 路由——消费方按「未知 conversation_id 丢弃」自我过滤,完整残留清单记录于此: //! - Fatal 分支会向子 conv emit AiError; //! - MidStream 分支会触发一次子 conv 的后台标题任务(写库被零落库拦截,仅一次无效 LLM 调用); //! - MidStream 分支 teardown_round 还会向子 conv emit 一条完整 AiCompleted(incomplete=true, @@ -45,11 +48,11 @@ use std::collections::{BTreeMap, HashMap, VecDeque}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use tauri::AppHandle; +use tauri::{AppHandle, State}; use tokio::sync::Mutex; use df_ai::ai_tools::AiToolRegistry; -use df_ai::context::{ContextManager, TokenEstimator}; +use df_ai::context::{ContextConfig, ContextManager, TokenEstimator}; use df_ai::context_helpers::PLACEHOLDER_INTEGRITY_ENABLED; use df_ai::provider::{ChatMessage, MessageRole, ToolDefinition}; use df_ai::router::{Modality, TaskRequirements}; @@ -63,13 +66,14 @@ use crate::commands::ai::agentic::{ }; use crate::commands::ai::audit::{process_tool_calls, ToolExecPolicy}; use crate::commands::ai::conversation::{ - save_conversation_meta, truncate_for_persist, SaveMetaOverride, TokenAccumulator, + save_conversation_meta, SaveMetaOverride, TokenAccumulator, TRUNCATE_THRESHOLD, }; -use crate::commands::ai::event_bus::emit_ai_chat_event; +use crate::commands::ai::event_bus::{emit_ai_chat_event, route_scope}; use crate::commands::ai::{AiChatEvent, ToolCallDraft}; -use crate::state::LlmConcurrency; +use crate::state::{AppState, LlmConcurrency}; use super::guard::GeneratingGuard; +use super::context_lifecycle::maybe_auto_compress; use crate::commands::ai::provider_pool::ProviderPool; use super::router_setup::resolve_model_with_override; use super::{ @@ -109,10 +113,34 @@ pub(crate) const SUB_AGENT_WALL_TIMEOUT_SECS: u64 = 900; /// 自达统一出口。 pub(crate) const SUB_AGENT_TOOL_BATCH_HEADROOM_SECS: u64 = 65; +/// 子代理上下文窗口预算上限(token)。自动压缩触发阈值与 build_for_request 裁剪预算 +/// 的数据源,口径同 [`ContextConfig::max_tokens`]。默认 128_000 对齐 ContextConfig::default +/// ——接入压缩层不改动既有预算(零行为变更);值单点集中于此,后续按需分档只改本常量。 +const SUB_AGENT_CONTEXT_MAX_TOKENS: u32 = 128_000; + +/// 子代理会话的上下文配置(压缩/裁剪预算来源)。仅覆写 max_tokens 为子代理常量, +/// 输出预留与安全系数沿用 ContextConfig::default,与主会话同一压缩链路口径一致。 +fn sub_agent_context_config() -> ContextConfig { + ContextConfig { + max_tokens: SUB_AGENT_CONTEXT_MAX_TOKENS, + ..ContextConfig::default() + } +} + /// 出口报告内联诊断文本的封顶(字符数)。Fatal 的 provider 原始错误串可能携带超长 /// 堆栈/响应体,父会话报告只需头部即可定位(401/404/参数错),超长部分截断省略。 const DIAG_TEXT_MAX_CHARS: usize = 400; +/// 任务书钉扎副本的长度上限(字符)。钉扎进 system prompt 的副本是循环不变量(不受 +/// build_for_request 预算裁剪),超长任务书会在每轮 system prompt 里永续常驻占 token; +/// 首条 user 消息已带完整任务书,钉扎副本只需够覆盖目标/完成标准/约束,超限截尾留标注。 +const SUB_AGENT_TASK_PIN_MAX_CHARS: usize = 4_000; + +/// 子代理报告交付父会话前的超长截断头/尾保留字符数(对齐对话侧落库截断 HEAD/TAIL 量级, +/// 保证头尾语义一致:结论在头、末尾未尽事项在尾)。阈值本身复用 TRUNCATE_THRESHOLD。 +const SUB_AGENT_REPORT_HEAD: usize = 3_072; +const SUB_AGENT_REPORT_TAIL: usize = 3_072; + // ============================================================ // 结果类型 // ============================================================ @@ -185,6 +213,10 @@ pub(crate) struct SubAgentOutcome { pub(crate) rejected_tools: Vec<(String, u32)>, /// 停止类别(终态事件 stop_kind 数据源);启动失败=Error,正常收敛=Converged。 pub(crate) stop_kind: Option, + /// 实际使用的模型 id(candidate 切换/池空回退后的最终值,非请求值)。 + /// 请求 override 落空回退到别的模型时,父会话据此知道子代理「实际用什么跑的」; + /// 启动期失败(未解析出模型)为空串。 + pub(crate) model: String, } impl SubAgentOutcome { @@ -197,6 +229,7 @@ impl SubAgentOutcome { tokens: 0, rejected_tools: Vec::new(), stop_kind: Some(SubAgentStopKind::Error), + model: String::new(), } } } @@ -424,6 +457,50 @@ pub(crate) fn cap_diag_text(s: &str, max_chars: usize) -> String { out } +/// 任务书钉扎副本的长度收敛(治「spawn task 全文永续无瘦身」):超上限时保留头部 +/// (目标/完成标准通常在任务书前部)并附省略标注,完整任务书仍在首条 user 消息里 +/// (钉扎只为把目标锚定在不受裁剪的 system 段,无需常驻全文)。纯函数可单测。 +fn cap_pinned_task(task: &str) -> String { + if task.chars().count() <= SUB_AGENT_TASK_PIN_MAX_CHARS { + return task.to_string(); + } + let head: String = task.chars().take(SUB_AGENT_TASK_PIN_MAX_CHARS).collect(); + format!("{}\n[…任务书过长,钉扎副本已截断;完整任务书见首条消息]…", head) +} + +/// 子代理报告交付父会话前的超长收敛(阈值/头尾语义与对话落库截断一致,省略文案按子代理 +/// 语境改写)。通用 truncate_for_persist 的「完整内容仅在内存态可读」是面向本地 DB 持久化的 +/// 措辞,而子代理报告是交付给父 LLM 的返回值——须明示完整报告随子代理轨迹落库, +/// 父侧不误以为拿不到全文。纯函数可单测。 +fn cap_report_for_parent(content: &str) -> String { + let chars: Vec = content.chars().collect(); + if chars.len() <= TRUNCATE_THRESHOLD { + return content.to_string(); + } + let head: String = chars.iter().take(SUB_AGENT_REPORT_HEAD).collect(); + let tail: String = chars[chars.len() - SUB_AGENT_REPORT_TAIL..].iter().collect(); + let omitted = chars.len() - SUB_AGENT_REPORT_HEAD - SUB_AGENT_REPORT_TAIL; + format!( + "{}\n\n[...报告过长,已省略中间 {} 字符(头尾保留);完整报告随子代理轨迹落库...]\n\n{}", + head, omitted, tail + ) +} + +/// 把子代理采样温度注入 provider 的 config JSON(供应商级温度覆盖的数据源:stream_relay +/// 读 candidate.config 的 "temperature" 字段决定请求 temperature)。保留 config 内既有字段, +/// 仅覆写 temperature;config 缺失/非法时新建仅含 temperature 的对象。钳到 [0,2],防越界值打挂 provider。 +fn inject_temperature(provider: &mut df_storage::models::AiProviderRecord, temperature: f32) { + let t = temperature.clamp(0.0, 2.0) as f64; + let mut obj = provider + .config + .as_deref() + .and_then(|cfg| serde_json::from_str::(cfg).ok()) + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default(); + obj.insert("temperature".to_string(), serde_json::json!(t)); + provider.config = Some(serde_json::Value::Object(obj).to_string()); +} + /// 判定 tool_result 内容是否为子代理拒绝 envelope(S2 报告协议解析核心,纯函数可单测)。 /// 仅识别结构化 JSON 的 status=rejected + reason=sub_agent_denied;非 JSON/正常结果/ /// 其他拒绝(如主会话 path_blacklist)一律不算,防被拒清单误计。 @@ -462,30 +539,60 @@ pub(crate) fn append_rejected_section(summary: String, rejected: &[(String, u32) /// 替换「默认最多 6 轮」类硬编码文案——父可传 1..20,子代理必须按真实预算规划探索节奏, /// 预算将尽时主动收尾交卷,而非被截断后拿末轮过程性独白当报告。 /// 工具纪律段经 prompt::sub_agent_tool_discipline_section 同源拼接(P1-1)。 +/// `lang` 为父会话语言(agent_language,缺省中文):决定本段与工具纪律段语言; +/// 环境画像(日期 + OS/shell 执行姿势)与父会话同源注入(子代理同样无法自行获取日期)。 /// 派工纪律段经 super::DISPATCH_DISCIPLINE 母本同源拼接(N1),与主会话共享同一文本。 -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, - ); +pub(crate) fn sub_agent_system_prompt(max_rounds: u32, lang: &str) -> String { + // lang 只决定本段与工具纪律段语言;派工纪律段(DISPATCH_DISCIPLINE)是跨端共享母本、 + // 文本单份(见 agentic/mod.rs),不随 lang 分叉。 + let body = if lang == "en" { + format!( + "You are a sub-agent spawned by the DevFlow main session, running in an isolated context to carry out the parent's task brief.\n\ + ## Role\n\ + - Execute for the parent task only; the user cannot see your process and cannot interact with or approve you\n\ + - Use tools for exploration/retrieval/verification work; put conclusions in your final reply\n\ + - You have no approval channel: path-authorization requests and medium/high-risk tools are denied outright with a failure result;\n\ + for a denied operation, switch to an authorized way or state the gap in your report\n\ + - This task's budget (actual values): up to {max_rounds} rounds / cumulative token cap {max_tokens} / wall-clock cap {wall_secs}s (15 min). Stop when converged, do no exploration beyond the brief;\n\ + when the budget is nearly exhausted (last round) output the conclusion report directly, leave no exploration unfinished\n\ + ## Output requirements\n\ + - Your final reply IS the conclusion report delivered to the parent; no pleasantries, do not ask the user questions\n\ + - Report structure: conclusion first, then key findings/evidence/data, finally open items and recommendations\n\ + - Preserve file paths, identifiers and error messages verbatim in the report (the parent locates by them; do not rewrite or summarize)\n\ + - If any tool call was denied: state in open items which subtasks were left incomplete due to tool-permission limits, so the parent decides how to take over", + max_rounds = max_rounds, + max_tokens = SUB_AGENT_MAX_TOTAL_TOKENS, + wall_secs = SUB_AGENT_WALL_TIMEOUT_SECS, + ) + } else { + format!( + "你是 DevFlow 主会话派生的子代理,在隔离上下文中独立执行父任务交办的任务书。\n\ + ## 定位\n\ + - 面向父任务执行:只做任务书范围内的事,用户看不到你的过程,无法与你交互或审批\n\ + - 探索/检索/核对类工作用工具完成,结论写进最终回复\n\ + - 你没有审批通道:路径授权申请与中高风险工具会被直接拒绝并返回失败结果,\n\ + 遇到被拒绝的操作改用已授权方式完成,或在报告中说明缺口\n\ + - 本轮预算(实际生效值):最多 {max_rounds} 轮 / 累计 token 上限 {max_tokens} / \ + 挂钟上限 {wall_secs} 秒(15 分钟)。收敛即停,不做任务书之外的无关探索;\n\ + 预算将尽(最后 1 轮)必须直接输出结论报告,不要留下未收尾的探索\n\ + ## 输出要求\n\ + - 最终回复就是交付给父任务的结论报告,不要寒暄、不要向用户提问\n\ + - 报告结构:结论先行,然后列关键发现/证据/数据,最后列未尽事项与建议\n\ + - 报告中的文件路径、标识符、错误信息照原样保留(父会话据此定位,勿改写或概括)\n\ + - 若有工具调用被拒绝:在未尽事项中说明哪些子任务因工具权限受限未完成,父会话据此决定接手方式", + max_rounds = max_rounds, + max_tokens = SUB_AGENT_MAX_TOTAL_TOKENS, + wall_secs = SUB_AGENT_WALL_TIMEOUT_SECS, + ) + }; + // 环境画像(日期 + OS/shell 执行姿势)与父会话同源注入:日期 LLM 无法自行获取, + // 不注入则子代理写文件/报告里的日期全靠猜。 + let mut p = crate::commands::ai::prompt::env_profile_line(); + p.push_str(&body); // 工具纪律段与父会话 system prompt 同源(read_symbol 优先/重复调用检测/URL 三工具 // 纪律/禁臆造 URL/查配置用 get_app_config);obscura 安装引导等交互条款已在共享函数 - // 内裁剪,不适用于子代理。 - p.push_str(&crate::commands::ai::prompt::sub_agent_tool_discipline_section("zh")); + // 内裁剪,不适用于子代理。lang 透传父会话语言,英文会话得英文纪律段。 + p.push_str(&crate::commands::ai::prompt::sub_agent_tool_discipline_section(lang)); // 派工纪律同源注入(N1):引用父模块母本常量,与主会话 build_runtime_prompt 共享同一文本 // (报告限长/结论先行/证据随附直接约束子代理输出形态,若再派工亦同规约束); // 内置于二进制而非知识库,防新实例知识库为空时纪律失传。 @@ -517,6 +624,9 @@ pub(crate) async fn run_sub_agent_loop( allowed: Vec, max_rounds: u32, model_override: Option, + // 采样温度独立覆盖(P3):None=沿用 provider 配置/默认 0.7(默认不变,与主链路一致), + // Some(t)=本次子代理克隆候选注入 t,不影响主会话。 + temperature_override: Option, llm_concurrency: LlmConcurrency, // 父会话 save 元数据快照(P3-5):出口落库显式传入,防继承当前活跃会话的 // provider/created_at。 @@ -526,7 +636,7 @@ pub(crate) async fn run_sub_agent_loop( session_arc.clone(), tools_arc, db.clone(), app_handle.clone(), parent_conv_id.clone(), child_conv_id.clone(), system_prompt, task_prompt, tool_defs, allowed, - max_rounds, model_override, llm_concurrency.clone(), + max_rounds, model_override, temperature_override, llm_concurrency.clone(), ).await; // 统一出口:挂父会话 emit 终态(前端展示二期消费;报告本体经父 tool_result 返回值交付) @@ -600,6 +710,7 @@ async fn run_sub_agent_loop_inner( allowed: Vec, max_rounds: u32, model_override: Option, + temperature_override: Option, llm_concurrency: LlmConcurrency, ) -> SubAgentOutcome { // 挂钟 deadline(三重熔断之三) @@ -611,9 +722,10 @@ async fn run_sub_agent_loop_inner( // (build_for_request 保尾弃头)时第一个被逐出,完成标准/约束随之蒸发。system 是 // 循环不变量(sys_tokens 一次估算缓存),不受消息淘汰影响;消息流首条 user 仍保留, // 被淘汰也不丢目标。system_prompt 参数在此定形,后续全循环复用。 + // 钉扎副本经 cap_pinned_task 收敛长度:超长任务书只钉目标锚,防全文字符串每轮常驻占 token。 let system_prompt = format!( "{}\n## 任务书钉扎(全程有效,上下文裁剪不可逐出)\n\n{}\n\n", - system_prompt, task_prompt + system_prompt, cap_pinned_task(&task_prompt) ); // 并发份额:global + per_conv(child) 双非阻塞 try_acquire,任一耗尽即 failed @@ -644,6 +756,9 @@ async fn run_sub_agent_loop_inner( session.deleted_convs.insert(child_conv_id.clone()); session.conv_origins.insert(child_conv_id.clone(), SUB_AGENT_ORIGIN.to_string()); let conv = session.conv(&child_conv_id); + // 上下文预算单点接入:子会话 ContextManager 用子代理配置(默认值与父会话一致), + // 供压缩触发阈值与 build_for_request 裁剪读取;新条目消息为空,直接替换无损。 + conv.messages = ContextManager::new(sub_agent_context_config()); conv.messages.push(ChatMessage::user(&task_prompt)); conv.stop_flag.store(false, Ordering::SeqCst); conv.iteration_used = 0; @@ -655,7 +770,7 @@ async fn run_sub_agent_loop_inner( GeneratingGuard::new(child_conv_id.clone(), child_epoch.clone(), my_epoch, app_handle.clone()); // ── provider 解析(30s 超时,对齐主 loop 入口;失败走 failed 返回值,不 emit AiError)── - let (primary_provider, candidates) = { + let (primary_provider, mut candidates) = { let resolve = tokio::time::timeout(std::time::Duration::from_secs(30), async { let provider_repo = AiProviderRepo::new(&db); let pool = match provider_repo.list_all().await { @@ -695,6 +810,16 @@ async fn run_sub_agent_loop_inner( let mut resolved_model = resolve_model_with_override(&req_probe, &primary_provider, &model_override); let mut provider_config = primary_provider; + // 子代理采样温度独立覆盖(P3):默认 None 不注入(沿用 provider 配置或 0.7,行为不变)。 + // Some(t) 时把 temperature 写进本次子代理的候选 config JSON——stream_relay 的供应商级 + // 温度覆盖正从 candidate.config 读取,故只改克隆候选即可生效,不触碰主会话/DB 记录。 + if let Some(temp) = temperature_override { + inject_temperature(&mut provider_config, temp); + for c in candidates.iter_mut() { + inject_temperature(c, temp); + } + } + // system_prompt 是整个子循环的不变量,token 估算一次缓存复用(对齐主 loop) let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt); @@ -733,6 +858,35 @@ async fn run_sub_agent_loop_inner( break; } + // 子代理运行期进度可见(父会话视图):每轮起算前挂父会话发 AiAgentRound 建独立气泡, + // 本轮后续流式文本/工具事件经 conv 路由改挂父会话续写该气泡(见 route_scope)。 + emit_ai_chat_event(&app_handle, AiChatEvent::AiAgentRound { + round: round + 1, + conversation_id: Some(parent_conv_id.clone()), + }); + + // ── 自动压缩(智能裁剪):复用父会话同一压缩链路 —— + // 子代理长跑探索/多文件阅读同样累积上下文,缺此层只剩 build_for_request 硬裁剪、 + // 无 LLM 摘要重锚定,主题随裁剪蒸发。provider 沿用本会话当前 candidate(与父 loop + // 同款);压缩后台化(fire-and-forget)不阻塞本轮,结果下一轮生效。返回 true = + // 子会话条目已消失,退出循环(对齐 prepare 段的 conv 缺失处理)。压缩过程产生的 + // 展示类事件(AiCompressing/AiAutoCompressed)挂子会话,消费方按未知 conv 丢弃, + // 与模块头「非展示类事件不改写」残留清单一并说明。 + if maybe_auto_compress( + &session_arc, + &child_conv_id, + &app_handle, + &provider_config, + &llm_concurrency, + round as usize, + sys_tokens, + ).await { + status = SubAgentStatus::Failed; + stop_reason = "子会话条目已消失".to_string(); + stop_kind = Some(SubAgentStopKind::Error); + break; + } + // ── prepare:build_for_request(预算裁剪)+ system prompt + namespace 展开 + // tool_result 视图压缩 + 占位配对断言 + 输入 token 估值(对齐主 loop prepare 段)── let (messages, estimated_prompt) = { @@ -797,18 +951,23 @@ async fn run_sub_agent_loop_inner( // 子循环 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, - DEFAULT_MAX_AGENT_RETRIES, &model_override, &agentic_req, estimated_prompt, - &mut last_round_estimated, &mut last_reasoning_content, - &mut resolved_model, &mut provider_config, &mut provider_saturation, - &mut tokens, &mut saved_token_snapshot, &mut guard, - &candidates, &llm_concurrency, - // 子会话无目标钉扎,空快照(该参数仅供主 loop AiCompleted 快照透传) - &[], - &child_epoch, my_epoch, + // 子代理展示类事件改挂父会话:本轮流式(AiTextDelta/AiHeartbeat/AiStreamRetry 等) + // 经 route_scope 从子会话改挂父会话推送,父会话视图实时可见(终态/状态机事件不改写)。 + match tokio::time::timeout(stream_budget, route_scope( + child_conv_id.clone(), parent_conv_id.clone(), + stream_round( + &session_arc, &db, &child_conv_id, + &messages, &tool_defs, &app_handle, + &child_stop, &child_notify, + DEFAULT_MAX_AGENT_RETRIES, &model_override, &agentic_req, estimated_prompt, + &mut last_round_estimated, &mut last_reasoning_content, + &mut resolved_model, &mut provider_config, &mut provider_saturation, + &mut tokens, &mut saved_token_snapshot, &mut guard, + &candidates, &llm_concurrency, + // 子会话无目标钉扎,空快照(该参数仅供主 loop AiCompleted 快照透传) + &[], + &child_epoch, my_epoch, + ), )).await { Ok(RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated, truncated_by_max_tokens }) => { rounds_done += 1; @@ -868,11 +1027,16 @@ async fn run_sub_agent_loop_inner( .values() .map(|d| (d.id.clone(), d.name.clone())) .collect(); - 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), + // 工具事件(AiToolCallStarted/Completed/AiCommandOutput 等)同样改挂父会话, + // 让父会话视图看到子代理的工具执行实时状态;子会话自身消息写入不受影响。 + let pending = route_scope( + child_conv_id.clone(), parent_conv_id.clone(), + 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; @@ -951,8 +1115,8 @@ async fn run_sub_agent_loop_inner( // ── 报告提取:收敛轮 full_text 优先;截断/失败取末条 assistant 文本附终止说明;再无则用原因 ── let total_tokens = tokens_snapshot(&tokens).total_tokens as u64; let summary = if !report.trim().is_empty() { - // 父侧注入前超长截断复用现有 truncate_for_persist(头尾保留语义一致) - truncate_for_persist(report.trim()) + // 父侧注入前超长截断:用子代理语境文案(非通用落库文案),阈值/头尾语义一致 + cap_report_for_parent(report.trim()) } else { match last_assistant_text(&session_arc, &child_conv_id).await { Some(text) => { @@ -961,7 +1125,7 @@ async fn run_sub_agent_loop_inner( } else { format!("{}\n\n(子代理提前终止:{})", text, stop_reason) }; - truncate_for_persist(combined.trim()) + cap_report_for_parent(combined.trim()) } None if !stop_reason.is_empty() => format!("子代理未产出报告:{}", stop_reason), None => "子代理未产出报告".to_string(), @@ -979,6 +1143,7 @@ async fn run_sub_agent_loop_inner( status = status.as_str(), rounds = rounds_done, tokens = total_tokens, + model = %resolved_model, summary_len = summary.len(), "[sub-agent] 子代理结束" ); @@ -991,6 +1156,8 @@ async fn run_sub_agent_loop_inner( tokens: total_tokens, rejected_tools: rejected, stop_kind, + // 实际生效模型(candidate 切换/回退后的最终值),父会话据此知晓子代理真用了哪个模型 + model: resolved_model.clone(), } } @@ -1088,6 +1255,99 @@ async fn collect_rejected_tools( } } +// ============================================================ +// 完成卡「已消化」标记(KV 持久,app_settings) +// ============================================================ + +/// 完成卡「已消化」标记的 KV key 前缀:key = `agentCardDigested:{parentConvId}`, +/// 值 = 已标记 agentId 的 JSON 字符串数组。完成卡本身是派生态(由子代理轨迹重建), +/// 「是否已消化」是用户偏好,故复用 app_settings KV 而不落 SQLite 新表/新列;轨迹删除后 +/// 孤儿 key 自然失效(重建路径只对轨迹存在的卡应用标记),无需清理任务。 +pub(crate) const AGENT_CARD_DIGESTED_KEY_PREFIX: &str = "agentCardDigested:"; + +/// 组装某父会话的已消化标记 KV key。 +pub(crate) fn digested_kv_key(parent_conv_id: &str) -> String { + format!("{}{}", AGENT_CARD_DIGESTED_KEY_PREFIX, parent_conv_id) +} + +/// 解析 KV 原始串为已消化 agentId 列表。容错:缺省/非 JSON/非数组一律降级为空, +/// 数组内空串与非字符串项跳过;结果去重升序,保证确定性与幂等收口。纯函数可单测。 +pub(crate) fn parse_digested_ids(raw: Option<&str>) -> Vec { + let Some(s) = raw else { + return Vec::new(); + }; + // 结构不符(坏值/旧格式残留)一律空列表兜底,不向调用方抛错——标记是纯偏好,读不动即视为未标记 + let Ok(serde_json::Value::Array(items)) = serde_json::from_str::(s) else { + return Vec::new(); + }; + let mut set: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for it in items { + if let Some(id) = it.as_str().filter(|id| !id.is_empty()) { + set.insert(id.to_string()); + } + } + set.into_iter().collect() +} + +/// 批量置位/清位已消化标记,返回新列表(去重升序)。空 id 忽略;幂等(重复置位/清位收敛)。 +pub(crate) fn apply_digested(list: Vec, agent_ids: &[String], digested: bool) -> Vec { + let mut set: std::collections::BTreeSet = list.into_iter().collect(); + for id in agent_ids.iter().filter(|id| !id.is_empty()) { + if digested { + set.insert(id.clone()); + } else { + set.remove(id); + } + } + set.into_iter().collect() +} + +/// 序列化已消化列表为稳定 KV 值(JSON 字符串数组;空列表序列化为 `[]`,便于清位后覆盖旧值)。 +pub(crate) fn serialize_digested_ids(list: &[String]) -> String { + serde_json::to_string(list).unwrap_or_else(|_| "[]".to_string()) +} + +/// 读某父会话的已消化标记列表(完成卡重建路径读 KV 合并的数据源,只读无副作用)。 +#[tauri::command] +pub async fn ai_sub_agent_digested_list( + state: State<'_, AppState>, + conversation_id: String, +) -> Result, String> { + let raw = state + .settings + .get(&digested_kv_key(&conversation_id)) + .await + .map_err(crate::commands::err_str)?; + Ok(parse_digested_ids(raw.as_deref())) +} + +/// 批量置位/清位完成卡「已消化」标记(digested=true 标记,false 取消),返回更新后的完整列表。 +/// +/// 为什么由后端收口读改写而非前端各自 settings KV 自读自写:同会话多卡批量标记并发时, +/// 前端 read-modify-write 会互相覆盖丢标记;键格式(agentCardDigested:{conv} → string[]) +/// 亦单点约定,前端不散落拼 key。 +#[tauri::command] +pub async fn ai_sub_agent_digest( + state: State<'_, AppState>, + conversation_id: String, + agent_ids: Vec, + digested: bool, +) -> Result, String> { + let key = digested_kv_key(&conversation_id); + let raw = state + .settings + .get(&key) + .await + .map_err(crate::commands::err_str)?; + let list = apply_digested(parse_digested_ids(raw.as_deref()), &agent_ids, digested); + state + .settings + .set(&key, &serialize_digested_ids(&list)) + .await + .map_err(crate::commands::err_str)?; + Ok(list) +} + // ============================================================ // 单元测试:白名单钳制 / max_rounds 熔断 / registry 级联注册移除 // ============================================================ @@ -1329,7 +1589,7 @@ mod tests { /// 同源工具纪律段(P1-1)。 #[test] fn sub_agent_prompt_mentions_report_contract() { - let p = sub_agent_system_prompt(6); + let p = sub_agent_system_prompt(6, "zh-CN"); assert!(p.contains("子代理")); assert!(p.contains("最终回复")); assert!(p.contains("审批通道"), "应说明无审批通道(防挂死设计的前提告知)"); @@ -1339,8 +1599,12 @@ mod tests { 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); + // 环境画像(日期)与父会话同源注入:子代理无法自行获取日期 + assert!(p.contains("当前日期"), "环境画像日期须注入: {}", p); + // 报告约定:文件路径原样保留 + assert!(p.contains("文件路径"), "报告约定须含文件路径原样保留: {}", p); // 父设小预算时按真实值注入(防按 6 轮规划被截断) - assert!(sub_agent_system_prompt(3).contains("最多 3 轮")); + assert!(sub_agent_system_prompt(3, "zh-CN").contains("最多 3 轮")); // P1-1 工具纪律段同源拼接 assert!(p.contains("read_symbol"), "read_symbol 优先指引须注入: {}", p); assert!(p.contains("重复调用检测"), "重复调用检测须注入"); @@ -1348,6 +1612,37 @@ mod tests { assert!(p.contains("get_app_config"), "查配置正解须注入"); assert!(p.contains("失败 2 次必换方案"), "render=true 铁律须注入"); assert!(!p.contains("要我帮你装吗"), "obscura 交互条款不进子代理"); + // lang=en:主体与工具纪律段均转英文(派工纪律段母本单份仍中文,见函数文档) + let en = sub_agent_system_prompt(6, "en"); + assert!(en.contains("sub-agent"), "英文主体: {}", en); + assert!(en.contains("no approval channel")); + assert!(en.contains(DUPLICATE_EN_MARKER), "英文纪律段须随 lang 透传"); + assert!(!en.contains("你没有审批通道")); + } + + /// 英文工具纪律段特征串(间接断言 lang="en" 已透传到 discipline 段)。 + const DUPLICATE_EN_MARKER: &str = "Duplicate call detection"; + + /// 任务书钉扎长度收敛:上限内原样,超限截断并留标注(治全文字符串每轮常驻)。 + #[test] + fn cap_pinned_task_bounds_length() { + let short = "统计 crates 下 TODO 数量"; + assert_eq!(cap_pinned_task(short), short); + let long = "x".repeat(SUB_AGENT_TASK_PIN_MAX_CHARS + 500); + let capped = cap_pinned_task(&long); + assert!(capped.chars().count() < long.chars().count(), "超限须收敛"); + assert!(capped.contains("完整任务书见首条消息"), "须留截断标注"); + } + + /// 子代理报告截断:阈值内原样,超阈截断并用子代理语境文案(不含落库语境的「内存态」)。 + #[test] + fn cap_report_for_parent_uses_subagent_wording() { + let short = "结论:已完成"; + assert_eq!(cap_report_for_parent(short), short); + let long = "y".repeat(TRUNCATE_THRESHOLD + 100); + let capped = cap_report_for_parent(&long); + assert!(capped.contains("随子代理轨迹落库"), "须用子代理语境文案"); + assert!(!capped.contains("内存态"), "不得沿用父侧落库文案"); } /// P2-4 停止类别枚举序列化为 snake_case 字符串(事件面消费契约)。 @@ -1436,4 +1731,39 @@ mod tests { vec!["write_file×3".to_string(), "run_command×1".to_string()] ); } + + /// 完成卡「已消化」标记:KV key 形态 + 容错解析(坏值/空串/非串项) + 批量置位清位幂等 + 序列化往返。 + #[test] + fn digested_marker_kv_roundtrip_and_tolerance() { + assert_eq!(digested_kv_key("conv-1"), "agentCardDigested:conv-1"); + + // 容错解析:缺省/非 JSON/非数组 一律空;去重升序;跳过空串与非串项 + assert!(parse_digested_ids(None).is_empty()); + assert!(parse_digested_ids(Some("not json")).is_empty()); + assert!(parse_digested_ids(Some("{}")).is_empty()); + assert_eq!( + parse_digested_ids(Some(r#"["b","a","a","",1,null]"#)), + vec!["a".to_string(), "b".to_string()] + ); + + // 批量置位(空 id 忽略) + 幂等 + let list = apply_digested( + vec![], + &["p::agent-2".to_string(), "p::agent-1".to_string(), String::new()], + true, + ); + assert_eq!(list, vec!["p::agent-1".to_string(), "p::agent-2".to_string()]); + let list = apply_digested(list, &["p::agent-1".to_string()], true); + assert_eq!(list, vec!["p::agent-1".to_string(), "p::agent-2".to_string()], "重复置位幂等"); + + // 批量清位(含不存在 id,不 panic) + 幂等 + let list = apply_digested(list, &["p::agent-1".to_string(), "ghost".to_string()], false); + assert_eq!(list, vec!["p::agent-2".to_string()]); + assert!(apply_digested(list, &["p::agent-2".to_string()], false).is_empty()); + + // 序列化 → 解析 往返;空列表序列化为 `[]` + let list = vec!["x".to_string(), "y".to_string()]; + assert_eq!(parse_digested_ids(Some(&serialize_digested_ids(&list))), list); + assert_eq!(serialize_digested_ids(&[]), "[]"); + } } diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 7b6b5bb..13a090b 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -27,7 +27,7 @@ use crate::state::{AppState, log_slow_lock}; use crate::commands::{err_str, now_millis}; // chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。 -use super::super::agentic::{run_agentic_loop, try_continue_agent_loop}; +use super::super::agentic::{resolve_model_with_override, run_agentic_loop, try_continue_agent_loop}; use super::super::event_bus::emit_ai_chat_event; #[cfg(test)] use super::super::agentic::GOAL_MAX_CHARS; @@ -866,6 +866,17 @@ pub(crate) async fn spawn_agentic_session( return Err("AI 正在生成中,请等待完成".to_string()); } let conv = session.conv(&target); + // 939 ★③:按目标模型实际上下文窗口重设消息预算(仅在新建空会话生效,收口见 model_context)。 + // 路由口径同主 loop pre-loop(needs_tool_use=true;estimated_context/tier 置零——预算只关心窗口, + // 与 loop 内按真实估计的重路由无关);override 穿透/兜底由 resolve_model_with_override 统一处理。 + let budget_req = df_ai::router::TaskRequirements { + modalities: vec![df_ai::router::Modality::Text], + needs_tool_use: true, + estimated_context: 0, + tier: None, + }; + let budget_model = resolve_model_with_override(&budget_req, &provider_config, &model_override); + conv.apply_model_window(&budget_model, &provider_config.model_configs); // 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。 conv.stop_flag.store(false, Ordering::SeqCst); conv.agent_language = language.clone(); @@ -1256,7 +1267,7 @@ pub async fn ai_approve( save_conversation(&state.ai_session, &state.db, cid, None, None, true).await; } // 审计:拒绝(决策者=human) - audit_finalize(&state, &tool_call_id, "rejected", None).await; + audit_finalize(&state, &tool_call_id, "rejected", None, None).await; // F-260616-11 决策 a: 审批续跑 iteration 累计(不重置)——读 per_conv.iteration_used 透传 // 作 start_iteration,防多次审批反复跑满 max_iterations 致 token 失控。 // F-09 B 批4:per_conv 唯一真相源(conv_id 来源 approval.conversation_id);无 conv_id 时 0。 @@ -1315,6 +1326,8 @@ pub async fn ai_approve( // 仅分支 run_workflow,其余工具审批路径完全不变(含 AE-04 trust_key_for 写 trust / audit 终态 // 回填 / emit_data_changed / save / try_continue)。workflow 联动任务推进由 run_workflow 内部 // 按 task_id+target_status 自动处理(workflow.rs:270-308),此处不重复推进。 + // V50 纯执行耗时:只测真执行窗口(run_workflow / execute_with_heartbeat),不含等待人工批准。 + let exec_started = std::time::Instant::now(); let exec_result: anyhow::Result = if approval.tool_name == "run_workflow" { super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await } else { @@ -1329,6 +1342,7 @@ pub async fn ai_approve( &app, conv_id.as_deref().unwrap_or_default(), &id, ).await }; + let exec_ms = exec_started.elapsed().as_millis() as i64; // 工具失败不 return Err:把错误包成 tool_result,落库 + emit completed + 续循环全走通。 // 否则前端 approveToolCall 的 catch 会回滚 pending_approval,审批按钮卡死无法消除。 let (audit_status, result_val) = match &exec_result { @@ -1336,7 +1350,7 @@ pub async fn ai_approve( Err(e) => ("failed", serde_json::Value::String(e.to_string())), }; // 审计:人工审批后无论成败回填(决策者=human) - audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string())).await; + audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string()), Some(exec_ms)).await; // AR-11(方案A):人工审批通过且工具执行成功(非 failed)后 emit df-data-changed, // 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。 @@ -1607,7 +1621,7 @@ pub async fn ai_authorize_dir( conversation_id: conv_id.clone(), }; emit_ai_chat_event(&app, ev_approval); - audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg)).await; + audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg), None).await; if let Some(ref cid) = conv_id { save_conversation(&state.ai_session, &state.db, cid, None, None, true).await; } @@ -1657,6 +1671,8 @@ pub async fn ai_authorize_dir( tracing::debug!(tool = %approval.tool_name, "[AI-AUTHZ] execute 前"); // 防工具卡死:超时兜底已收敛进 execute_with_heartbeat 的 outer_secs 分档, // execute 不返回时超时返 Err → 走 failed 分支 finalize+emit+try_continue,IPC 必返回不卡。 + // V50 纯执行耗时:只测真执行窗口,不含等待人工批准。 + let exec_started = std::time::Instant::now(); let exec_result: anyhow::Result = if approval.tool_name == "run_workflow" { super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await } else { @@ -1668,6 +1684,7 @@ pub async fn ai_authorize_dir( &app, conv_id.as_deref().unwrap_or_default(), &tool_call_id, ).await }; + let exec_ms = exec_started.elapsed().as_millis() as i64; tracing::debug!( ok = exec_result.is_ok(), status = if exec_result.is_ok() { "completed" } else { "failed" }, @@ -1685,7 +1702,7 @@ pub async fn ai_authorize_dir( if let Some(note) = cross_drive_hint(&approval.tool_name, &req.raw_paths) { result_val = append_cross_drive_note(result_val, ¬e); } - audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string())).await; + audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string()), Some(exec_ms)).await; if exec_result.is_ok() { emit_data_changed(&app, &approval.tool_name); // MED-1(审查): path 绑定工具(Medium)审批执行后 reload 白名单。 diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index ed2ccad..970cfbd 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -348,6 +348,11 @@ pub(crate) async fn recover_parked_inner( return Err("该会话正在生成中,无需恢复".to_string()); } let message = PARKED_RECOVERY_MESSAGE.to_string(); + // V50 终局态复位:恢复即重新进行,清 incomplete(停靠时已标)。loop 侧收尾单点在 agentic 主循环, + // 此处先复位保证续作期间语义正确;续作后正常收敛/再次停靠会再各自覆写。 + if let Err(e) = state.ai_conversations.set_status(conversation_id, "active").await { + tracing::warn!(conv_id = %conversation_id, error = %e, "[ai] 恢复会话复位 status=active 失败"); + } spawn_agentic_session( app, state, @@ -1573,6 +1578,7 @@ mod tests { origin: origin.map(|s| s.to_string()), boundary_json: None, loop_state: None, + status: "active".to_string(), }) .await .expect("测试会话插入应成功"); diff --git a/src-tauri/src/commands/ai/conversation.rs b/src-tauri/src/commands/ai/conversation.rs index 9d7f129..281ddcc 100644 --- a/src-tauri/src/commands/ai/conversation.rs +++ b/src-tauri/src/commands/ai/conversation.rs @@ -546,6 +546,8 @@ async fn save_conversation_inner( .filter(|s| !s.is_empty()), // #10:懒创建继承 loop running(时序根治——spawn 先置标后落库的 UPDATE 0 行丢失) loop_state: if conv_is_active { Some("running".to_string()) } else { None }, + // V50 终局态:新落库默认 active(进行中);终局由收尾路径 set_status 覆写。 + status: "active".to_string(), }; if let Err(e) = conv_repo.insert(rec).await { tracing::warn!("落库对话失败 {conv_id}: {e}"); @@ -575,17 +577,27 @@ async fn save_conversation_inner( .advance_persisted_count(total_len, needs_full_rewrite); } - // 自动 checkpoint(每 20 轮或总 token > 150k 时创建) + // 自动 checkpoint(每 20 条消息为一个快照点) { - let total_tokens: i64 = persist_msgs.len() as i64; - if total_tokens > 0 && total_tokens % 20 == 0 { + let msg_count: i64 = persist_msgs.len() as i64; + if msg_count > 0 && msg_count % 20 == 0 { let snapshot = serde_json::to_string(&persist_msgs).unwrap_or_default(); if !snapshot.is_empty() && snapshot.len() < 1_000_000 { - let ck_id = format!("ck_{}_{}", conv_id.replace('-', ""), total_tokens); + // token_total 语义=真实 token 用量(逐条 prompt+completion 累加),非消息条数。 + // 原误用 persist_msgs.len() 当 token,致统计差 3 个量级(p50=120 vs 数万级)。 + let token_total: i64 = persist_msgs + .iter() + .map(|m| { + m.prompt_tokens.unwrap_or(0) as i64 + + m.completion_tokens.unwrap_or(0) as i64 + }) + .sum(); + // ck_id 仍以消息条数锚定(同轮/同长度重存幂等跳过),与 token_total 语义解耦。 + let ck_id = format!("ck_{}_{}", conv_id.replace('-', ""), msg_count); let ck_now = now_millis().to_string(); // 参数化绑定(走 AiConversationRepo::insert_checkpoint),替代原 format! 拼 SQL, // 防 snapshot 含引号/特殊字符致注入或损坏。 - if let Err(e) = conv_repo.insert_checkpoint(&ck_id, conv_id, &snapshot, total_tokens, &ck_now).await { + if let Err(e) = conv_repo.insert_checkpoint(&ck_id, conv_id, &snapshot, token_total, &ck_now).await { tracing::warn!("checkpoint 写入失败 {conv_id}: {e}"); } } diff --git a/src-tauri/src/commands/ai/fetch_url.rs b/src-tauri/src/commands/ai/fetch_url.rs index 89a3d13..f9319a8 100644 --- a/src-tauri/src/commands/ai/fetch_url.rs +++ b/src-tauri/src/commands/ai/fetch_url.rs @@ -260,7 +260,10 @@ async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result< let started = Instant::now(); // ── 检测 obscura ── - let obscura_path = match find_obscura() { + // find_obscura 内部 std::thread + recv_timeout 阻塞探活(单候选最多 15s),须在阻塞池跑, + // 否则卡住单线程 runtime;join 失败(理论不发生)按"未装"降级。 + let detected = tokio::task::spawn_blocking(find_obscura).await.ok().flatten(); + let obscura_path = match detected { Some(p) => p, None => { // 未装:回退静态 + hint。 diff --git a/src-tauri/src/commands/ai/generate_image.rs b/src-tauri/src/commands/ai/generate_image.rs index 127b229..376ed57 100644 --- a/src-tauri/src/commands/ai/generate_image.rs +++ b/src-tauri/src/commands/ai/generate_image.rs @@ -442,16 +442,14 @@ async fn write_atomic(output_path: &str, data: &[u8]) -> anyhow::Result<()> { .map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?; } let tmp = format!("{}.tmp-img", output_path); - tokio::fs::write(&tmp, data).await - .map_err(|e| { - let _ = std::fs::remove_file(&tmp); - anyhow::anyhow!("写入图片失败: {}", e) - })?; - tokio::fs::rename(&tmp, output_path).await - .map_err(|e| { - let _ = std::fs::remove_file(&tmp); - anyhow::anyhow!("原子替换失败: {}", e) - })?; + if let Err(e) = tokio::fs::write(&tmp, data).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(anyhow::anyhow!("写入图片失败: {}", e)); + } + if let Err(e) = tokio::fs::rename(&tmp, output_path).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(anyhow::anyhow!("原子替换失败: {}", e)); + } Ok(()) } diff --git a/src-tauri/src/commands/ai/http.rs b/src-tauri/src/commands/ai/http.rs index 31eccbf..387079d 100644 --- a/src-tauri/src/commands/ai/http.rs +++ b/src-tauri/src/commands/ai/http.rs @@ -451,16 +451,14 @@ pub(crate) async fn execute_http_request(args: Value, allowed: &AllowedDirs) -> } // 全量写(原子:tmp→rename,避免写一半崩溃留半成品,与 write_file 同思路) let tmp = format!("{}.tmp-http", path); - tokio::fs::write(&tmp, &full_body).await - .map_err(|e| { - let _ = std::fs::remove_file(&tmp); - anyhow::anyhow!("写入 output_file 失败: {}", e) - })?; - tokio::fs::rename(&tmp, &path).await - .map_err(|e| { - let _ = std::fs::remove_file(&tmp); - anyhow::anyhow!("output_file 原子重命名失败: {}", e) - })?; + if let Err(e) = tokio::fs::write(&tmp, &full_body).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(anyhow::anyhow!("写入 output_file 失败: {}", e)); + } + if let Err(e) = tokio::fs::rename(&tmp, &path).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(anyhow::anyhow!("output_file 原子重命名失败: {}", e)); + } return Ok(json!({ "method": method_str, "url": final_url, diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index fa3e785..088db9d 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -96,8 +96,8 @@ @keydown="onQueueEditKeydown" @blur="saveEditQueued" /> - - + + @@ -1024,6 +1024,8 @@ async function initContextEventListeners(): Promise { border-radius: calc(var(--df-radius) - 2px); outline: none; } +/* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */ +.ai-queue-edit-input:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; } /* (.ai-btn-icon:disabled / .ai-btn-spinner / @keyframes ai-btn-spin 已抽 components.css 全局共享) */ diff --git a/src/components/ErrorBoundary.vue b/src/components/ErrorBoundary.vue index eedbc0b..e4c61c1 100644 --- a/src/components/ErrorBoundary.vue +++ b/src/components/ErrorBoundary.vue @@ -5,7 +5,7 @@ 渲染非元素根节点(slot 透传)导致过渡动画警告/卡死(App.vue )。 -->
diff --git a/src/components/ai/ApprovalOverlay.vue b/src/components/ai/ApprovalOverlay.vue index eaf7341..3b4455c 100644 --- a/src/components/ai/ApprovalOverlay.vue +++ b/src/components/ai/ApprovalOverlay.vue @@ -29,6 +29,7 @@