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

- 工具纪律段抽共享同源注入
- 子代理轨迹浮层与完成卡身份
- 分离窗会话切换修复
- 960s 统一出口报告不再蒸发
This commit is contained in:
lxy
2026-10-04 16:27:31 +08:00
parent b2d44f85e9
commit 01d231c39d
12 changed files with 1150 additions and 152 deletions
+488 -38
View File
@@ -17,7 +17,8 @@
//! 统一出口(`run_sub_agent_loop`)摘除拦截后调一次 `save_conversation`,整条子轨迹经
//! 既有 save 链路持久化,origin='sub_agent' 随首插落 `ai_conversations.origin`(V44),
//! 桌面侧栏/miniapp 列表按此过滤。Truncated/Failed 轨迹同样落库(调试与轨迹树数据源);
//! 父删除级联命中(deleted_convs 被级联重打/per_conv 已清)时放弃落库,防空壳复活。
//! 父删除级联命中(deleted_convs 被级联重打,见 cascade_stop_children_locked Delete 形态)
//! 时放弃落库,防空壳复活。
//!
//! ## 循环拼装(全部复用既有积木,不另起炉灶)
//! - provider 解析:AiProviderRepo.list_all + ProviderPool::select(override 亲和)+ 候选链;
@@ -33,9 +34,12 @@
//!
//! ## 事件与展示边界
//! 仅在结束时挂父会话 emit 一条 AiAgentFinished(前端展示二期消费);不 emit AiCompleted /
//! AiMaxRounds 终态事件、不 spawn 标题/知识提炼。stream_round 复用带来的残留:其 Fatal 分支
//! 会向子 conv emit AiError、MidStream 分支会触发一次子 conv 的后台标题任务(写库被上者拦,
//! 仅一次无效 LLM 调用),均为网络异常罕见路径,接受并记录于此。
//! AiMaxRounds 终态事件、不 spawn 标题/知识提炼。stream_round 复用带来的残留(均为网络异常
//! 罕见路径,消费方按「未知 conversation_id 丢弃」自我过滤,完整清单记录于此):
//! - Fatal 分支会向子 conv emit AiError;
//! - MidStream 分支会触发一次子 conv 的后台标题任务(写库被零落库拦截,仅一次无效 LLM 调用);
//! - MidStream 分支 teardown_round 还会向子 conv emit 一条完整 AiCompleted(incomplete=true,
//! stream_relay.rs MidStream 保文分支实证)。
use std::collections::{BTreeMap, HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
@@ -58,9 +62,11 @@ use crate::commands::ai::agentic::{
STALL_BREAKER_ENABLED,
};
use crate::commands::ai::audit::{process_tool_calls, ToolExecPolicy};
use crate::commands::ai::conversation::{save_conversation, truncate_for_persist, TokenAccumulator};
use crate::commands::ai::conversation::{
save_conversation_meta, truncate_for_persist, SaveMetaOverride, TokenAccumulator,
};
use crate::commands::ai::event_bus::emit_ai_chat_event;
use crate::commands::ai::AiChatEvent;
use crate::commands::ai::{AiChatEvent, ToolCallDraft};
use crate::state::LlmConcurrency;
use super::guard::GeneratingGuard;
@@ -94,6 +100,17 @@ pub(crate) const SUB_AGENT_MAX_TOTAL_TOKENS: u64 = 200_000;
/// 外层超时对 spawn_agent 取同值,保证外层不先于子循环自腰斩。
pub(crate) const SUB_AGENT_WALL_TIMEOUT_SECS: u64 = 900;
/// 工具批执行的最小剩余预算(秒)。单件工具受 execute_with_heartbeat 外层超时封顶
/// (Low 工具默认 60s),并行 join_all 总时长 ≈ 单件上限;剩余挂钟不足一个批上限加
/// 收尾缓冲时不开新批(P1-1),本轮 tool_calls 按 P3-3 同款补 cancelled tool_result
/// 后截断退出,保证子循环总能在父侧外层超时(SUB_AGENT_WALL_TIMEOUT_SECS+60)前
/// 自达统一出口。
pub(crate) const SUB_AGENT_TOOL_BATCH_HEADROOM_SECS: u64 = 65;
/// 出口报告内联诊断文本的封顶(字符数)。Fatal 的 provider 原始错误串可能携带超长
/// 堆栈/响应体,父会话报告只需头部即可定位(401/404/参数错),超长部分截断省略。
const DIAG_TEXT_MAX_CHARS: usize = 400;
// ============================================================
// 结果类型
// ============================================================
@@ -119,6 +136,41 @@ impl SubAgentStatus {
}
}
/// 子代理停止类别(为前端备料:AiAgentFinished.stop_kind,snake_case 字符串)。
/// 与 status 正交:status 表终态成败,stop_kind 表「为什么停」(取消/预算/错误/收敛)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SubAgentStopKind {
/// 用户停止级联(own stop / 父停止 / 父删除)
Cancelled,
/// 挂钟预算(900s)耗尽
WallClock,
/// 轮次预算(max_rounds)耗尽
MaxRounds,
/// token 预算耗尽(累计 200K 上限;单轮 max_tokens 截断同映射至此——
/// 事件枚举无独立 max_tokens 值,截断本质即 token 预算触顶)
TokenBudget,
/// 末轮空回复收敛(未产出报告文本)
EmptyConvergence,
/// 运行期错误(熔断/Fatal/意外审批挂起/流中断)
Error,
/// 正常收敛(完成)
Converged,
}
impl SubAgentStopKind {
pub(crate) fn as_str(self) -> &'static str {
match self {
SubAgentStopKind::Cancelled => "cancelled",
SubAgentStopKind::WallClock => "wall_clock",
SubAgentStopKind::MaxRounds => "max_rounds",
SubAgentStopKind::TokenBudget => "token_budget",
SubAgentStopKind::EmptyConvergence => "empty_convergence",
SubAgentStopKind::Error => "error",
SubAgentStopKind::Converged => "converged",
}
}
}
/// 子代理执行结果(经 spawn_agent 工具返回值交付给父 LLM)。
#[derive(Debug, Clone)]
pub(crate) struct SubAgentOutcome {
@@ -129,6 +181,8 @@ pub(crate) struct SubAgentOutcome {
/// 运行期被拒工具汇总(工具名→次数,BTreeMap 序确定性)。S2 报告协议:父会话须能
/// 看见子代理被拒了什么,而非只拿到一份不知缺口的报告;空 Vec=本次零拒绝。
pub(crate) rejected_tools: Vec<(String, u32)>,
/// 停止类别(终态事件 stop_kind 数据源);启动失败=Error,正常收敛=Converged。
pub(crate) stop_kind: Option<SubAgentStopKind>,
}
impl SubAgentOutcome {
@@ -140,6 +194,7 @@ impl SubAgentOutcome {
rounds: 0,
tokens: 0,
rejected_tools: Vec::new(),
stop_kind: Some(SubAgentStopKind::Error),
}
}
}
@@ -185,22 +240,45 @@ pub(crate) fn sub_agents_children(reg: &SubAgentRegistry, parent: &str) -> Vec<S
.unwrap_or_default()
}
/// 级联取消形态(P2-2):区分「停止」与「删除」两种父侧动作。
///
/// Delete 形态须同锁内对每个 child 补 deleted_convs 标记——否则子循环经停止检查点
/// 以「父会话已不存在」退出时,出口 alive 判定(child 不在 deleted_convs 且 per_conv
/// 存在)为真,会把父已删除的孤儿 sub_agent 轨迹照常 INSERT 落库(空壳复活,与模块
/// 头文档声明相反)。Stop 形态(ai_chat_stop / ai_chat_force_send)不打删除标记,
/// 子循环退出后轨迹照常落库。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CascadeStopForm {
/// 父会话停止/强制发送:仅置 child stop_flag。
Stop,
/// 父会话删除:置 stop_flag + 补 deleted_convs(出口 alive 判定即放弃落库)。
Delete,
}
/// 级联取消:父会话被停止/强制发送/删除时遍历 children 置 stop_flag + notify。
///
/// 调用方(ai_chat_stop / ai_chat_force_send / ai_conversation_delete)须已持 session 锁,
/// 本函数短临界区纯内存操作(无 await),与「禁止持 session 锁跨 await」纪律兼容。
/// 仅触碰已存在的子会话条目(不惰性重建,防取消动作反而制造空条目残留);
/// 子循环停止检查点读 own stop_flag 即退出,父侧条目消失/父 stop 是兜底判据。
/// Delete 形态的 deleted_convs 标记对 child 不回收(与直接删除的标记同生命周期):
/// 防在途 save 于出口清理之后复活空壳,单条 ~40B 量级可忽略。
pub(crate) fn cascade_stop_children_locked(
session: &mut crate::commands::ai::AiSession,
reg: &SubAgentRegistry,
parent: &str,
form: CascadeStopForm,
) {
for child in sub_agents_children(reg, parent) {
if let Some(conv) = session.per_conv.get_mut(&child) {
conv.stop_flag.store(true, Ordering::SeqCst);
conv.notify.notify_one();
}
// 删除形态:child 补删除标记须与父删除同锁(与子循环出口的 alive 判定互斥,
// 防标记与出口读之间竞态)。child 的 per_conv 不动:退出路径仍要读消息做报告兜底。
if form == CascadeStopForm::Delete {
session.deleted_convs.insert(child);
}
}
}
@@ -260,27 +338,90 @@ pub(crate) fn clamp_tool_whitelist(
(allowed, trimmed)
}
/// 单轮预算熔断判定(prepare 段前调用)。返回 Some(原因) 即应截断退出,None 继续。
/// 纯函数抽出供单测锁定三路熔断语义(挂钟超时 / token 上限 / 轮次上限)。
/// 单轮预算熔断判定(prepare 段前调用)。返回 Some((原因, 停止类别)) 即应截断退出,
/// None 继续。纯函数抽出供单测锁定三路熔断语义(挂钟超时 / token 上限 / 轮次上限);
/// 停止类别随原因同点产出,供 AiAgentFinished.stop_kind 结构化透出。
pub(crate) fn round_budget_stop(
round: u32,
max_rounds: u32,
tokens_total: u64,
deadline: tokio::time::Instant,
now: tokio::time::Instant,
) -> Option<String> {
) -> Option<(String, SubAgentStopKind)> {
if now >= deadline {
return Some(format!("挂钟超时({}s)", SUB_AGENT_WALL_TIMEOUT_SECS));
return Some((
format!("挂钟超时({}s)", SUB_AGENT_WALL_TIMEOUT_SECS),
SubAgentStopKind::WallClock,
));
}
if tokens_total >= SUB_AGENT_MAX_TOTAL_TOKENS {
return Some(format!("累计 token 达上限({})", SUB_AGENT_MAX_TOTAL_TOKENS));
return Some((
format!("累计 token 达上限({})", SUB_AGENT_MAX_TOTAL_TOKENS),
SubAgentStopKind::TokenBudget,
));
}
if round >= max_rounds {
return Some(format!("达最大轮次上限({})", max_rounds));
return Some((
format!("达最大轮次上限({})", max_rounds),
SubAgentStopKind::MaxRounds,
));
}
None
}
/// 工具批可开工判定(P1-1,纯函数供单测):剩余挂钟不足一个工具批上限加收尾缓冲
/// 时不开新批,防批执行把统一出口顶出父侧外层超时窗口。
pub(crate) fn tool_batch_affordable(
remaining: std::time::Duration,
) -> bool {
remaining >= std::time::Duration::from_secs(SUB_AGENT_TOOL_BATCH_HEADROOM_SECS)
}
/// 收敛轮终态判定(P3-7 空白降级 + P2-1 max_tokens 截断标记,纯函数供单测):
/// 末轮无 tool_calls 时,full_text 即报告。
/// - 空白(模型空回复)降级 Truncated——Completed+空报告让父会话拿到「成功却无内容」的失真终态;
/// - max_tokens 截断:报告在句中被腰斩,同样降级 Truncated 并在报告尾追加标注,
/// 不冒充正常完成(父会话据此判断报告完整性);stop_kind 映射 TokenBudget(事件
/// 枚举无独立 max_tokens 值,截断本质即单轮 token 预算触顶)。
/// 返回 (status, report, stop_reason 补充, stop_kind)。
pub(crate) fn convergence_round(
full_text: String,
truncated_by_max_tokens: bool,
) -> (SubAgentStatus, String, Option<String>, SubAgentStopKind) {
if full_text.trim().is_empty() {
(
SubAgentStatus::Truncated,
String::new(),
Some("末轮空回复收敛,未产出报告文本".to_string()),
SubAgentStopKind::EmptyConvergence,
)
} else if truncated_by_max_tokens {
let marked = format!(
"{}\n\n[输出因 max_tokens 截断,可能不完整]",
full_text.trim_end()
);
(
SubAgentStatus::Truncated,
marked,
Some("收敛报告因 max_tokens 截断".to_string()),
SubAgentStopKind::TokenBudget,
)
} else {
(SubAgentStatus::Completed, full_text, None, SubAgentStopKind::Converged)
}
}
/// 诊断文本封顶(P2-4):provider 原始错误串进父会话报告前截到 max_chars(按字符计,
/// 防多字节串截出半字符),保留头部(401/404/参数错等关键信息都在头部)。纯函数可单测。
pub(crate) fn cap_diag_text(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
return s.to_string();
}
let mut out: String = s.chars().take(max_chars).collect();
out.push('…');
out
}
/// 判定 tool_result 内容是否为子代理拒绝 envelope(S2 报告协议解析核心,纯函数可单测)。
/// 仅识别结构化 JSON 的 status=rejected + reason=sub_agent_denied;非 JSON/正常结果/
/// 其他拒绝(如主会话 path_blacklist)一律不算,防被拒清单误计。
@@ -314,19 +455,35 @@ pub(crate) fn append_rejected_section(summary: String, rejected: &[(String, u32)
}
/// 子代理专用 system prompt(要点:面向父任务执行、最终回复即结论报告、无审批通道)。
pub(crate) fn sub_agent_system_prompt() -> String {
///
/// `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\
- 控制轮次:默认最多 6 轮,收敛即停,不做任务书之外的无关探索\n\
- 本轮预算(实际生效值):最多 {max_rounds} 轮 / 累计 token 上限 {max_tokens} / \
挂钟上限 {wall_secs} 秒(15 分钟)。收敛即停,不做任务书之外的无关探索;\n\
预算将尽(最后 1 轮)必须直接输出结论报告,不要留下未收尾的探索\n\
## 输出要求\n\
- 最终回复就是交付给父任务的结论报告,不要寒暄、不要向用户提问\n\
- 报告结构:结论先行,然后列关键发现/证据/数据,最后列未尽事项与建议\n\
- 若有工具调用被拒绝:在未尽事项中说明哪些子任务因工具权限受限未完成,父会话据此决定接手方式"
.to_string()
- 若有工具调用被拒绝:在未尽事项中说明哪些子任务因工具权限受限未完成,父会话据此决定接手方式",
max_rounds = max_rounds,
max_tokens = SUB_AGENT_MAX_TOTAL_TOKENS,
wall_secs = SUB_AGENT_WALL_TIMEOUT_SECS,
);
// 工具纪律段与父会话 system prompt 同源(read_symbol 优先/重复调用检测/URL 三工具
// 纪律/禁臆造 URL/查配置用 get_app_config);obscura 安装引导等交互条款已在共享函数
// 内裁剪,不适用于子代理。
p.push_str(&crate::commands::ai::prompt::sub_agent_tool_discipline_section("zh"));
p
}
// ============================================================
@@ -348,14 +505,20 @@ pub(crate) async fn run_sub_agent_loop(
system_prompt: String,
task_prompt: String,
tool_defs: Vec<ToolDefinition>,
// 执行期工具白名单(P2-1):spawn 入口钳制产物,随子循环传入 process_tool_calls,
// 幻觉调用被裁剪面外的工具(含 Low)在执行期被就地拒绝,而不仅在 prompt 层承诺。
allowed: Vec<String>,
max_rounds: u32,
model_override: Option<String>,
llm_concurrency: LlmConcurrency,
// 父会话 save 元数据快照(P3-5):出口落库显式传入,防继承当前活跃会话的
// provider/created_at。
parent_save_meta: SaveMetaOverride,
) -> SubAgentOutcome {
let outcome = run_sub_agent_loop_inner(
session_arc.clone(), tools_arc, db.clone(), app_handle.clone(),
parent_conv_id.clone(), child_conv_id.clone(),
system_prompt, task_prompt, tool_defs,
system_prompt, task_prompt, tool_defs, allowed,
max_rounds, model_override, llm_concurrency.clone(),
).await;
@@ -373,6 +536,8 @@ pub(crate) async fn run_sub_agent_loop(
} else {
Some(format_rejected_tools(&outcome.rejected_tools))
},
// 停止类别(结构化枚举,前端按「为什么停」差异化展示;正常收敛=converged)
stop_kind: outcome.stop_kind.map(|k| k.as_str().to_string()),
});
// 子轨迹落库(二期池①):摘除零落库拦截,整条轨迹经既有 save 链路持久化。
@@ -388,7 +553,9 @@ pub(crate) async fn run_sub_agent_loop(
alive
};
if child_persistable {
save_conversation(&session_arc, &db, &child_conv_id, None, None, false).await;
// P3-5:显式传父会话快照元数据,不走 save 内部的 session.active_* 读法
// (子代理后台长跑收尾时用户可能已切走会话,active 值已是别的会话的)。
save_conversation_meta(&session_arc, &db, &child_conv_id, None, None, false, parent_save_meta).await;
}
// 清理子会话内存条目(轨迹已持久化或已随级联丢弃;origin 标记随 per_conv 一并回收)
@@ -415,6 +582,7 @@ async fn run_sub_agent_loop_inner(
system_prompt: String,
task_prompt: String,
tool_defs: Vec<ToolDefinition>,
allowed: Vec<String>,
max_rounds: u32,
model_override: Option<String>,
llm_concurrency: LlmConcurrency,
@@ -423,6 +591,16 @@ async fn run_sub_agent_loop_inner(
let deadline = tokio::time::Instant::now()
+ std::time::Duration::from_secs(SUB_AGENT_WALL_TIMEOUT_SECS);
// P2-2 目标钉扎(与主循环 G1 同构):任务书并入 system prompt。主循环把钉扎目标拼进
// system prompt 尾部对抗失焦;子会话的任务书是首条 user 消息,超预算裁剪
// (build_for_request 保尾弃头)时第一个被逐出,完成标准/约束随之蒸发。system 是
// 循环不变量(sys_tokens 一次估算缓存),不受消息淘汰影响;消息流首条 user 仍保留,
// 被淘汰也不丢目标。system_prompt 参数在此定形,后续全循环复用。
let system_prompt = format!(
"{}\n## 任务书钉扎(全程有效,上下文裁剪不可逐出)\n<task>\n{}\n</task>\n",
system_prompt, task_prompt
);
// 并发份额:global + per_conv(child) 双非阻塞 try_acquire,任一耗尽即 failed
// (子代理不阻塞等待,防占满父 loop 的执行槽与全局配额)
let (_global_permit, _conv_permit) = match (
@@ -515,9 +693,11 @@ async fn run_sub_agent_loop_inner(
let (mut stall_count, mut stall_warned, mut loop_count, mut loop_warned) = (0u32, false, 0u32, false);
let mut rounds_done: u32 = 0;
// for 耗尽(每轮都有 tool_calls 未收敛)默认截断;各 break 点按语义覆写
// for 耗尽(每轮都有 tool_calls 未收敛)默认截断;各 break 点按语义覆写。
// stop_kind 缺省 MaxRounds 与默认截断原因(达最大轮次上限)同点对齐。
let mut status = SubAgentStatus::Truncated;
let mut stop_reason = String::new();
let mut stop_kind = Some(SubAgentStopKind::MaxRounds);
let mut report = String::new();
// S2 报告协议:被拒工具累计(工具名→次数,BTreeMap 序确定性),出口汇总进报告尾+outcome
let mut rejected_tools: BTreeMap<String, u32> = BTreeMap::new();
@@ -527,12 +707,14 @@ async fn run_sub_agent_loop_inner(
if let Some(reason) = cancellation_reason(&session_arc, &child_conv_id, &parent_conv_id).await {
status = SubAgentStatus::Truncated;
stop_reason = format!("已被取消({})", reason);
stop_kind = Some(SubAgentStopKind::Cancelled);
break;
}
let total_now = tokens_snapshot(&tokens).total_tokens as u64;
if let Some(reason) = round_budget_stop(round, max_rounds, total_now, deadline, tokio::time::Instant::now()) {
if let Some((reason, kind)) = round_budget_stop(round, max_rounds, total_now, deadline, tokio::time::Instant::now()) {
status = SubAgentStatus::Truncated;
stop_reason = reason;
stop_kind = Some(kind);
break;
}
@@ -544,6 +726,7 @@ async fn run_sub_agent_loop_inner(
let Some(conv) = session.conv_read(&child_conv_id) else {
status = SubAgentStatus::Failed;
stop_reason = "子会话条目已消失".to_string();
stop_kind = Some(SubAgentStopKind::Error);
break;
};
let (mut history_msgs, _trimmed) = conv.messages.build_for_request(sys_tokens);
@@ -582,7 +765,13 @@ async fn run_sub_agent_loop_inner(
estimated_context: estimated_prompt as usize,
tier: None,
};
match stream_round(
// P1-1 deadline 下沉进轮内:流式段整体以「距 deadline 剩余」封顶,超时即 drop
// stream_round future 走正常截断路径退出统一出口。此前 deadline 检查只在轮与轮
// 之间,单轮流式+重试无上界,外层 execute_with_heartbeat 超时会先到并 drop 整个
// 子循环 future(报告蒸发+注册表泄漏+子会话滞留)。stream_round 内部短锁段无跨
// await 持锁、mid-flight 无消息写,drop 安全(guard 由本函数持有,出口统一复位)。
let stream_budget = deadline.saturating_duration_since(tokio::time::Instant::now());
match tokio::time::timeout(stream_budget, stream_round(
&session_arc, &db, &child_conv_id,
&messages, &tool_defs, &app_handle,
&child_stop, &child_notify,
@@ -594,8 +783,8 @@ async fn run_sub_agent_loop_inner(
// 子会话无目标钉扎,空快照(该参数仅供主 loop AiCompleted 快照透传)
&[],
&child_epoch, my_epoch,
).await {
RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated } => {
)).await {
Ok(RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated, truncated_by_max_tokens }) => {
rounds_done += 1;
tokens.add_usage(&round_usage);
let has_tool_calls = !tool_calls.is_empty();
@@ -613,16 +802,38 @@ async fn run_sub_agent_loop_inner(
);
}
}
// 流后复查取消:停止信号到达时优先按截断处理,不把半截文本当完成报告
// 流后复查取消:停止信号到达时优先按截断处理,不把半截文本当完成报告。
// P3-3:本轮 tool_calls 尚未执行也未拒绝,补写 cancelled tool_result 再退,
// 防轨迹悬挂(带 tool_calls 却无对应结果的消息)。
if let Some(reason) = cancellation_reason(&session_arc, &child_conv_id, &parent_conv_id).await {
push_cancelled_tool_results(&session_arc, &child_conv_id, &tool_calls).await;
status = SubAgentStatus::Truncated;
stop_reason = format!("已被取消({})", reason);
stop_kind = Some(SubAgentStopKind::Cancelled);
break;
}
if !has_tool_calls {
// 收敛轮:末轮 full_text 即报告自摘要
status = SubAgentStatus::Completed;
report = full_text;
// 收敛轮:末轮 full_text 即报告自摘要(P3-7 空白降级 / P2-1 截断标记)
let (st, rep, extra_reason, kind) = convergence_round(full_text, truncated_by_max_tokens);
status = st;
report = rep;
stop_kind = Some(kind);
if let Some(r) = extra_reason {
stop_reason = r;
}
break;
}
// P1-1 工具批预算闸:剩余预算不足一个批上限(单件外层超时封顶)不开批,
// 按 P3-3 同款补 cancelled tool_result 后截断,保证统一出口在预算内必达。
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if !tool_batch_affordable(remaining) {
push_cancelled_tool_results(&session_arc, &child_conv_id, &tool_calls).await;
status = SubAgentStatus::Truncated;
stop_reason = format!(
"挂钟超时({}s),剩余预算不足以执行本轮工具批",
SUB_AGENT_WALL_TIMEOUT_SECS
);
stop_kind = Some(SubAgentStopKind::WallClock);
break;
}
// ── 工具执行(SubAgent 策略:NeedsAuth/中高风险自动 deny,零待审批挂起)──
@@ -634,6 +845,8 @@ async fn run_sub_agent_loop_inner(
let pending = process_tool_calls(
&session_arc, tool_calls, &tools_arc, &db, &app_handle, &child_conv_id,
ToolExecPolicy::SubAgent,
// P2-1 执行期白名单:Low 工具不在名单同样就地拒绝(layer=whitelist-clamp)
Some(&allowed),
).await;
// S2 报告协议:收集本轮被拒清单(deny 单点已写入结构化 envelope,此处只读解析)
collect_rejected_tools(&session_arc, &child_conv_id, &round_calls, &mut rejected_tools).await;
@@ -641,6 +854,7 @@ async fn run_sub_agent_loop_inner(
// 防御:SubAgent 策略下不应产生挂起;出现即终止(子代理无审批恢复通道)
status = SubAgentStatus::Failed;
stop_reason = format!("子代理策略下出现意外审批挂起({} 项),终止", pending);
stop_kind = Some(SubAgentStopKind::Error);
break;
}
// L1 失败断路器(读子会话尾部消息,机制与主 loop 一致)
@@ -652,6 +866,7 @@ async fn run_sub_agent_loop_inner(
"连续同类工具失败 {} 次(如 {}),已熔断",
max_count, sample_key
);
stop_kind = Some(SubAgentStopKind::Error);
break;
}
// G2 探索熔断 + 写删回环熔断(读子会话尾部签名,机制与主 loop 一致)
@@ -666,23 +881,38 @@ async fn run_sub_agent_loop_inner(
if STALL_BREAKER_ENABLED && tripped {
status = SubAgentStatus::Failed;
stop_reason = "重复工具调用/写删回环探索熔断".to_string();
stop_kind = Some(SubAgentStopKind::Error);
break;
}
}
// stream_round 异常路径已完成其既有收尾(见模块头"事件与展示边界"残留说明)
RoundOutcome::Fatal => {
// stream_round 异常路径已完成其既有收尾(见模块头"事件与展示边界"残留说明)。
// P2-4:Fatal 携带最后诊断文本,出口报告写明真实原因(封顶防超长污染)。
Ok(RoundOutcome::Fatal { error }) => {
status = SubAgentStatus::Failed;
stop_reason = "LLM 调用失败(全候选 provider 耗尽或致命错误)".to_string();
stop_reason = format!(
"LLM 调用失败({})",
cap_diag_text(&error, DIAG_TEXT_MAX_CHARS)
);
stop_kind = Some(SubAgentStopKind::Error);
break;
}
RoundOutcome::MidStreamSaved => {
Ok(RoundOutcome::MidStreamSaved) => {
status = SubAgentStatus::Truncated;
stop_reason = "流中途中断,已保文".to_string();
stop_kind = Some(SubAgentStopKind::Error);
break;
}
RoundOutcome::StaleAfterStream => {
Ok(RoundOutcome::StaleAfterStream) => {
status = SubAgentStatus::Failed;
stop_reason = "子会话状态被外部接管".to_string();
stop_kind = Some(SubAgentStopKind::Error);
break;
}
// P1-1:流式段触顶 deadline,drop 未返回任何消息(无悬挂 tool_calls),截断退出
Err(_elapsed) => {
status = SubAgentStatus::Truncated;
stop_reason = format!("挂钟超时({}s)", SUB_AGENT_WALL_TIMEOUT_SECS);
stop_kind = Some(SubAgentStopKind::WallClock);
break;
}
}
@@ -734,6 +964,37 @@ async fn run_sub_agent_loop_inner(
rounds: rounds_done,
tokens: total_tokens,
rejected_tools: rejected,
stop_kind,
}
}
/// 流后取消/预算截断窗口(P3-3)兜底:本轮已 push 的 assistant 消息带 tool_calls,
/// 但工具既未执行也未拒绝即退出,补写 cancelled tool_result 使轨迹闭合
/// (peek/TaskDrawer 不出现无结果卡)。id 生成与 push_assistant_message 同款
/// (tool_call_id_or_fallback 兜底空 id),保证 tool_result 与消息内 tool_call 可配对。
/// 条目不存在(级联删除等)时静默跳过,不惰性重建。
async fn push_cancelled_tool_results(
session_arc: &Arc<Mutex<crate::commands::ai::AiSession>>,
child_id: &str,
tool_calls: &HashMap<u32, ToolCallDraft>,
) {
if tool_calls.is_empty() {
return;
}
let cancelled = serde_json::json!({
"status": "cancelled",
"reason": "子代理在工具执行前被停止或预算截断",
}).to_string();
let mut session = session_arc.lock().await;
let Some(conv) = session.per_conv.get_mut(child_id) else {
return;
};
let mut order: Vec<u32> = tool_calls.keys().copied().collect();
order.sort_unstable();
for (pos, idx) in order.into_iter().enumerate() {
let draft = &tool_calls[&idx];
let id = df_ai::provider::tool_call_id_or_fallback(&draft.id, pos, "gen_stream");
conv.messages.push(ChatMessage::tool_result(id, cancelled.clone()));
}
}
@@ -857,7 +1118,7 @@ mod tests {
assert!(trimmed.is_empty(), "缺省请求不产生裁剪尾注");
}
/// max_rounds 熔断:轮次/token/挂钟三路均能触发且带可读原因;预算内返 None。
/// max_rounds 熔断:轮次/token/挂钟三路均能触发且带可读原因与结构化停止类别;预算内返 None。
#[test]
fn round_budget_trips_on_rounds_tokens_and_deadline() {
let now = tokio::time::Instant::now();
@@ -865,15 +1126,18 @@ mod tests {
// 预算内:继续
assert_eq!(round_budget_stop(0, 6, 0, deadline, now), None);
// 轮次熔断
let r = round_budget_stop(6, 6, 0, deadline, now).unwrap();
let (r, kind) = round_budget_stop(6, 6, 0, deadline, now).unwrap();
assert!(r.contains("轮次"), "轮次原因: {}", r);
assert_eq!(kind, SubAgentStopKind::MaxRounds);
// token 熔断
let r = round_budget_stop(1, 6, SUB_AGENT_MAX_TOTAL_TOKENS, deadline, now).unwrap();
let (r, kind) = round_budget_stop(1, 6, SUB_AGENT_MAX_TOTAL_TOKENS, deadline, now).unwrap();
assert!(r.contains("token"), "token 原因: {}", r);
assert_eq!(kind, SubAgentStopKind::TokenBudget);
// 挂钟超时熔断(优先级最高)
let past = now - std::time::Duration::from_secs(1);
let r = round_budget_stop(1, 6, 0, past, now).unwrap();
let (r, kind) = round_budget_stop(1, 6, 0, past, now).unwrap();
assert!(r.contains("超时"), "超时原因: {}", r);
assert_eq!(kind, SubAgentStopKind::WallClock);
}
/// registry 级联:注册幂等追加、children 可查、注销移除且父条目空时整条清掉。
@@ -913,14 +1177,200 @@ mod tests {
assert_ne!(a, b, "同父连续生成的 child_id 必须唯一");
}
/// 子代理 system prompt:含面向父任务与最终回复即报告的关键约束。
/// P2-2 级联形态:Delete 形态对 child 补 deleted_convs(出口 alive 判定即放弃落库,
/// 防孤儿 sub_agent 轨迹 INSERT),Stop 形态只置 stop_flag 不打删除标记(轨迹照常落库)。
#[test]
fn cascade_delete_marks_children_deleted() {
let child = "p::agent-1".to_string();
let reg = new_registry();
sub_agents_register(&reg, "p", &child);
let mut session = crate::commands::ai::AiSession::new();
let stop_flag = {
let conv = session.conv(&child);
conv.stop_flag.clone()
};
cascade_stop_children_locked(&mut session, &reg, "p", CascadeStopForm::Delete);
assert!(stop_flag.load(Ordering::SeqCst), "两形态都须置 stop_flag");
assert!(
session.deleted_convs.contains(&child),
"Delete 形态须补 deleted_convs,防孤儿轨迹落库"
);
// Stop 形态对照:不标删除(子循环退出后轨迹照常落库)
let child2 = "p2::agent-1".to_string();
let reg2 = new_registry();
sub_agents_register(&reg2, "p2", &child2);
let mut session2 = crate::commands::ai::AiSession::new();
let _ = session2.conv(&child2);
cascade_stop_children_locked(&mut session2, &reg2, "p2", CascadeStopForm::Stop);
assert!(
!session2.deleted_convs.contains(&child2),
"Stop 形态不得打删除标记"
);
}
/// P3-7 空收敛降级 + P2-1 max_tokens 截断标记:空白 → Truncated(原因=空收敛);
/// 截断 → Truncated + 报告尾标注;正常文本 → Completed 且报告原样。
#[test]
fn convergence_round_empty_text_degrades_to_truncated() {
let (st, rep, reason, kind) = convergence_round("结论:已完成检索".to_string(), false);
assert_eq!(st, SubAgentStatus::Completed);
assert_eq!(rep, "结论:已完成检索");
assert!(reason.is_none(), "正常收敛无补充原因");
assert_eq!(kind, SubAgentStopKind::Converged);
let (st, rep, reason, kind) = convergence_round(" \n\t".to_string(), false);
assert_eq!(st, SubAgentStatus::Truncated, "空白报告不得标 Completed(终态失真)");
assert!(rep.is_empty());
let reason = reason.expect("空收敛须带截断原因");
assert!(reason.contains("空"), "原因应可读标注空收敛: {}", reason);
assert_eq!(kind, SubAgentStopKind::EmptyConvergence);
}
/// P2-1 收敛报告被 max_tokens 腰斩:降级 Truncated(不冒充 Completed),报告尾
/// 追加截断标注,stop_kind 映射 TokenBudget(单轮 token 预算触顶)。
#[test]
fn convergence_round_max_tokens_truncation_marks_report() {
let (st, rep, reason, kind) =
convergence_round("结论:分析如下,核心证据包括 1".to_string(), true);
assert_eq!(st, SubAgentStatus::Truncated, "截断报告不得标 Completed");
assert!(rep.starts_with("结论:分析如下"), "报告本体保留: {}", rep);
assert!(rep.contains("[输出因 max_tokens 截断,可能不完整]"), "须带截断标注: {}", rep);
assert_eq!(reason.as_deref(), Some("收敛报告因 max_tokens 截断"));
assert_eq!(kind, SubAgentStopKind::TokenBudget);
}
/// P2-4 诊断文本封顶:短文本原样,超长按字符截断加省略号(多字节串不截半字符)。
#[test]
fn cap_diag_text_caps_long_error() {
assert_eq!(cap_diag_text("401 Unauthorized", 100), "401 Unauthorized");
let long = "错".repeat(500);
let capped = cap_diag_text(&long, DIAG_TEXT_MAX_CHARS);
assert_eq!(capped.chars().count(), DIAG_TEXT_MAX_CHARS + 1, "含省略号");
assert!(capped.ends_with('…'));
assert!(capped.starts_with("错错错"));
}
/// P1-1 工具批预算闸:剩余预算低于批上限(headroom)不开批,充足则放行。
#[test]
fn tool_batch_headroom_gate() {
assert!(!tool_batch_affordable(std::time::Duration::from_secs(0)));
assert!(!tool_batch_affordable(std::time::Duration::from_secs(
SUB_AGENT_TOOL_BATCH_HEADROOM_SECS - 1
)), "差 1s 也不开批(硬闸)");
assert!(tool_batch_affordable(std::time::Duration::from_secs(
SUB_AGENT_TOOL_BATCH_HEADROOM_SECS
)));
}
/// P3-3 取消窗口补 envelope:本轮 tool_calls 补写 cancelled tool_result 且与 assistant
/// 消息的 tool_call id 可配对;conv 缺失时静默跳过不惰性重建。
#[tokio::test]
async fn cancelled_tool_results_close_the_trajectory() {
use df_ai::provider::ToolCall;
let session_arc = Arc::new(Mutex::new(crate::commands::ai::AiSession::new()));
let child = "p::agent-1".to_string();
{
let mut s = session_arc.lock().await;
let conv = s.conv(&child);
conv.messages.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("call_a", "read_file", "{}")],
));
}
let mut tool_calls: HashMap<u32, ToolCallDraft> = HashMap::new();
tool_calls.insert(0u32, ToolCallDraft {
id: "call_a".to_string(),
name: "read_file".to_string(),
args: "{}".to_string(),
});
push_cancelled_tool_results(&session_arc, &child, &tool_calls).await;
{
let s = session_arc.lock().await;
let msgs = s.conv_read(&child).unwrap().messages.all_messages_clone();
let last = msgs.last().expect("应已追加 cancelled tool_result");
assert!(matches!(last.role, MessageRole::Tool));
assert_eq!(last.tool_call_id.as_deref(), Some("call_a"), "须与消息内 tool_call 配对");
assert!(last.content.contains("\"cancelled\""), "envelope 内容: {}", last.content);
}
// 缺失条目:静默跳过,不惰性重建空壳
push_cancelled_tool_results(&session_arc, "ghost::agent-9", &tool_calls).await;
let s = session_arc.lock().await;
assert!(s.conv_read("ghost::agent-9").is_none(), "不得惰性重建缺失条目");
}
/// 子代理 system prompt:含面向父任务与最终回复即报告的关键约束 + 动态预算三值 +
/// 同源工具纪律段(P1-1)。
#[test]
fn sub_agent_prompt_mentions_report_contract() {
let p = sub_agent_system_prompt();
let p = sub_agent_system_prompt(6);
assert!(p.contains("子代理"));
assert!(p.contains("最终回复"));
assert!(p.contains("审批通道"), "应说明无审批通道(防挂死设计的前提告知)");
assert!(p.contains("工具权限受限"), "S2 报告协议:应要求申报因工具受限未完成的子任务");
// P2-3 预算三值动态注入(无硬编码 6 轮文案,实际生效值可见)
assert!(p.contains("最多 6 轮"), "轮次预算动态注入: {}", p);
assert!(p.contains(&SUB_AGENT_MAX_TOTAL_TOKENS.to_string()), "token 上限注入");
assert!(p.contains(&SUB_AGENT_WALL_TIMEOUT_SECS.to_string()), "挂钟上限注入");
assert!(!p.contains("默认最多 6 轮"), "硬编码缺省文案应已移除: {}", p);
// 父设小预算时按真实值注入(防按 6 轮规划被截断)
assert!(sub_agent_system_prompt(3).contains("最多 3 轮"));
// P1-1 工具纪律段同源拼接
assert!(p.contains("read_symbol"), "read_symbol 优先指引须注入: {}", p);
assert!(p.contains("重复调用检测"), "重复调用检测须注入");
assert!(p.contains("严禁臆造 URL"), "禁臆造 URL 须注入");
assert!(p.contains("get_app_config"), "查配置正解须注入");
assert!(p.contains("失败 2 次必换方案"), "render=true 铁律须注入");
assert!(!p.contains("要我帮你装吗"), "obscura 交互条款不进子代理");
}
/// P2-4 停止类别枚举序列化为 snake_case 字符串(事件面消费契约)。
#[test]
fn stop_kind_serializes_snake_case() {
assert_eq!(SubAgentStopKind::Cancelled.as_str(), "cancelled");
assert_eq!(SubAgentStopKind::WallClock.as_str(), "wall_clock");
assert_eq!(SubAgentStopKind::MaxRounds.as_str(), "max_rounds");
assert_eq!(SubAgentStopKind::TokenBudget.as_str(), "token_budget");
assert_eq!(SubAgentStopKind::EmptyConvergence.as_str(), "empty_convergence");
assert_eq!(SubAgentStopKind::Error.as_str(), "error");
assert_eq!(SubAgentStopKind::Converged.as_str(), "converged");
}
/// AiAgentFinished.stop_kind serde 序列化:Some 序列化为字符串字段,None 保留 null
/// (事件多字段向后兼容,前端不消费不破坏)。
#[test]
fn agent_finished_event_serializes_stop_kind() {
use crate::commands::ai::AiChatEvent;
let ev = AiChatEvent::AiAgentFinished {
agent_id: "p::agent-1".to_string(),
conversation_id: Some("p".to_string()),
summary: "结论:已完成".to_string(),
status: "truncated".to_string(),
rounds: 3,
tokens: 1200,
rejected_tools: None,
stop_kind: Some(SubAgentStopKind::MaxRounds.as_str().to_string()),
};
let json = serde_json::to_string(&ev).expect("事件须可序列化");
assert!(json.contains("\"stop_kind\":\"max_rounds\""), "stop_kind 须按字符串序列化: {}", json);
assert!(json.contains("\"type\":\"AiAgentFinished\""), "tag 字段: {}", json);
let ev_none = AiChatEvent::AiAgentFinished {
agent_id: "p::agent-2".to_string(),
conversation_id: None,
summary: "ok".to_string(),
status: "completed".to_string(),
rounds: 1,
tokens: 10,
rejected_tools: None,
stop_kind: None,
};
let json_none = serde_json::to_string(&ev_none).expect("事件须可序列化");
assert!(
json_none.contains(r#""stop_kind":null"#),
"None 序列化为 null(字段保留): {}",
json_none
);
}
/// S2 报告协议:deny envelope 识别——结构化 JSON 判定,非 JSON/正常结果/其他拒绝不误计。
+44 -14
View File
@@ -724,6 +724,14 @@ pub(crate) async fn spawn_agentic_session(
other => (other, false),
};
// 纯发图(无文本无技能)且模型非视觉:降级后无任何有效内容,原「[图片已忽略…]」占位串
// 会被当 user 消息喂模型(回复跑偏且浪费一轮 LLM 调用)。此处直接拒绝发送,错误经前端
// 发送失败通道透出;图文混合(有文本/技能)维持占位后缀现状。置于锁与消息 push 之前,
// 拒绝零副作用(不落库不占生成态)。
if degraded && message.trim().is_empty() && skill.is_none() {
return Err("当前模型不支持图片,无法发送纯图片消息".to_string());
}
// 原子检查并占用生成标志,防止并发双发;同步追加用户消息,按需自动创建对话
// F-260616-09 B 批4(决策 e 真并发上线):
// - 目标 conv 取入参 conversation_id(前端传 activeConversationId),None/空时 fallback
@@ -790,12 +798,12 @@ pub(crate) async fn spawn_agentic_session(
} else {
format!("{} [图片已忽略:当前模型不支持图片]", base)
}
} else if message.trim().is_empty() {
if let Some(ref skill_name) = skill {
format!("/{}", skill_name)
} else {
message.clone()
}
} else if let Some(ref skill_name) = skill {
// 技能调用留痕(问题3):带文本的技能调用落库加 /技能名 前缀,否则技能名只在
// system prompt 注入、会话历史零痕迹(用户无从得知是否用了技能)。前缀同时是
// regenerate/edit 的 backtrack_leading_skill 回溯依据(非空文本此前无前缀,
// 重生成时回溯不到即丢技能注入——本修复顺带治好该链)。
format!("/{} {}", skill_name, message)
} else {
message.clone()
};
@@ -1135,6 +1143,12 @@ pub async fn ai_approve(
let mut session = state.ai_session.lock().await;
if let Some(ref cid) = conv_id {
session.conv(cid).messages.replace_tool_result_content(&id, &result_val.to_string());
// 写后失效(审批执行路径,第4接入点):审批通过执行成功的非只读工具改变世界状态,
// 推进该会话只读缓存失效下界,防 LLM 写后重读命中改前缓存。replace 不改消息条数,
// floor 取当前 len 正确覆盖占位位之前的全部历史;仅 completed 失效(口径同其余三段)。
if audit_status == "completed" {
crate::commands::ai::audit::mark_readonly_cache_stale(&mut session.conv(cid), &approval.tool_name);
}
}
let ev_completed = AiChatEvent::AiToolCallCompleted {
@@ -1287,7 +1301,7 @@ pub async fn ai_authorize_dir(
decision: String,
) -> Result<String, String> {
// F-260620 卡死已根治(路径授权审批链闭环)。原 [AI-AUTHZ] eprintln 降级为 tracing::debug,
// 避免污染 stderr(用户可见);authz_debug 文件日志保留(读 authz-debug.log 定位,排障仍可用)。
// 避免污染 stderr(用户可见)。
tracing::debug!(
tool_call_id = %tool_call_id,
decision = %decision,
@@ -2014,6 +2028,12 @@ pub async fn ai_chat_force_send(
other => (other, false),
};
// 纯发图(无文本无技能)且模型非视觉:同 spawn_agentic_session 的拒绝闸(占位串不喂模型),
// 置于锁与消息 push 之前,拒绝零副作用。
if degraded && message.trim().is_empty() && skill.is_none() {
return Err("当前模型不支持图片,无法发送纯图片消息".to_string());
}
// 原子"复位 + 占用":同一把锁内先清目标 conv 的旧生成态,再占用 generating + 追加用户消息。
// stop_flag 置 true(清旧)随即 false(新 loop 起跑)在锁内瞬变,无人能观察中间态;
// 关键是复位与占用之间无锁释放窗口,杜绝并发 send IPC 抢占 generating。
@@ -2072,8 +2092,10 @@ pub async fn ai_chat_force_send(
// spawn_agent 级联取消:强制发送复位父 loop,其派生的子代理同步置停。
// force_send 随后会把父 stop_flag 复位 false(新 loop 起跑),子循环不能依赖父
// flag 长期置位,own stop_flag 是子循环主判据(父条目消失为兜底)。
// Stop 形态:不打删除标记,子循环退出后轨迹照常落库。
crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked(
&mut session, &state.sub_agent_registry, &target,
crate::commands::ai::agentic::sub_agent::CascadeStopForm::Stop,
);
// ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send,无生成态拦截——
// 这是"强制"语义本身,前面已主动复位)
@@ -2102,12 +2124,11 @@ pub async fn ai_chat_force_send(
} else {
format!("{} [图片已忽略:当前模型不支持图片]", base)
}
} else if message.trim().is_empty() {
if let Some(ref skill_name) = skill {
format!("/{}", skill_name)
} else {
message.clone()
}
} else if let Some(ref skill_name) = skill {
// 技能调用留痕(同 ai_chat_send,问题3):带文本的技能调用落库加 /技能名 前缀,
// 会话历史可见技能使用痕迹 + regenerate/edit 回溯依据。非空文本此前无前缀,
// 强制重发后重生成时回溯不到即丢技能注入。
format!("/{} {}", skill_name, message)
} else {
message.clone()
};
@@ -2279,10 +2300,17 @@ pub async fn ai_chat_stop(
is_estimated: false,
incomplete: None,
incomplete_reason: None,
conversation_id: Some(target),
conversation_id: Some(target.clone()),
pinned_goals,
};
emit_ai_chat_event(&app, ev);
// 安全停靠(设计 §四):挂起后取消(审批等待态用户主动停止)= 五路非正常终局之一。
// 上方 AiCompleted 收敛前端流式态,此处补 AiTaskParked 留停靠记录(挂起家族统一出口)。
crate::commands::ai::emit_task_parked(
&app, &target,
"任务已停止(审批挂起中被取消):现场已保存,重发消息或补充指令可继续",
"cancelled",
);
return Ok(());
}
// 流式生成中:置位让目标 conv 的 loop 自行收尾
@@ -2294,8 +2322,10 @@ pub async fn ai_chat_stop(
// spawn_agent 级联取消:父 loop 收到停止,其派生的子代理同步置停
// (子循环停止检查点读 own stop_flag 即退出,不等挂钟超时)。
// 短临界区纯内存操作(无 await),不延长 session 持锁。
// Stop 形态:不打删除标记,子循环退出后轨迹照常落库。
crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked(
&mut session, &state.sub_agent_registry, &target,
crate::commands::ai::agentic::sub_agent::CascadeStopForm::Stop,
);
// 置位后立即 notify_one 唤醒阻塞在 stream.next() 的 select!,
// 不再等 30s 心跳 tick 或 120s idle timeout 才轮到 stop_flag 检查。
+10 -1
View File
@@ -322,7 +322,8 @@ fn normalize_provider_type_for_fetch(provider_type: &str) -> String {
/// 绝不进日志/返回值/错误信息)
/// 3. provider_type 归一(anthropic→anthropic_compat)+ base_url + api_key 调
/// df_ai::model_fetch::fetch_and_probe → Vec<ModelConfig>(每个模型名已探测出 4 维度)
/// 4. 写回 AiProviderRecord.model_configs(update_full)→ 返回 Vec<ModelConfig>
/// 4. 写回 AiProviderRecord.model_configs(update_full)→ 返回 Vec<ModelConfig>;
/// probed 为空(远端空列表)时跳过写回,返回空列表(防清空用户模型池)
///
/// ModelConfig 本就无 api_key 字段,返回值天然不含密钥(FR-S1 闭环)。
#[tauri::command]
@@ -345,6 +346,14 @@ pub async fn ai_fetch_models(
.await
.map_err(err_str)?;
// 守卫:远端 200 + 空列表(或探测结果全被过滤)时 probed 为空——若照常 merge 后
// update_full,空 merged 会覆盖 model_configs,用户模型池连同 weight/enabled/手动
// 标注静默清空。跳过写回保留现有配置,返回空列表作提示信号(前端据 length==0
// 提示「拉取到 0 个模型,已保留现有配置」)。对照 ai_test_connection 不写库无此问题。
if probed.is_empty() {
return Ok(Vec::new());
}
// 合并:新探测 configs 为主(更新能力维度 modalities/capabilities/cost_tier/intelligence/
// context_window/probe_source),按 model_id 保留用户在 Settings 调过的 weight/enabled/label。
// 否则每次「测试连接/拉取模型」覆盖回探测默认 weight(50),权重失效致 ProviderPool/router
+124 -24
View File
@@ -81,10 +81,14 @@ 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" => (
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\
@@ -92,7 +96,7 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
- 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\
- {}\n\
- Get file metadata (size, line count, binary detection)\n\
- Append content to files, search files by name pattern\n\n\
## Guidelines\n\
@@ -101,16 +105,20 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
- 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\
- {}\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",
),
_ => (
format!(
"你是 DevFlow 桌面应用的 AI 助手。你可以操作项目/任务/灵感/工作流,也可分析代码、读写文件、执行命令。\n\
必须使用简体中文回复,禁止使用繁体中文字符。\n\n\
## 当前能力\n\
@@ -118,7 +126,7 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
- 创建/查询项目、任务、灵感\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\
@@ -126,12 +134,15 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
- 如果不确定用户意图,先提问\n\
- 优先使用工具完成操作,而不是只描述步骤\n\
- 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n\
- **重复调用检测**:如果某个工具(如 read_file/search_files)已用相同参数调用过且返回成功,不要重复调用——会话历史里已有结果,回顾上下文而非重复执行\n\
- {}\n\
## 聚焦准则\n\
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
- 回答先给结论/动作,再补必要的解释;与当前请求无关的扩展信息省略。\n\
- 需要的上下文(文件/数据)若与当前请求无关,不要主动展开。\n",
read_symbol_guidance(lang),
duplicate_call_guidance(lang),
),
"\n## 当前项目\n",
"\n## 当前任务\n",
),
@@ -153,7 +164,56 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
/// AI 看到 hint 即自主提示用户「装了能 JS 渲染 SPA/反爬」,征得同意后用 run_command 帮装。
///
/// 仅段加在 prefix 末尾(聚焦段后),不破坏现有结构。
fn doc_exploration_strategy_section(lang: &str) -> &'static str {
///
/// 段体拆为两块:核心纪律(URL 三工具分工/探索流程/禁臆造 URL/render=true 铁律)与
/// obscura 安装引导(涉用户交互与 run_command,父侧专用)。子代理工具纪律段
/// ([`sub_agent_tool_discipline_section`])只拼核心块。
fn doc_exploration_strategy_section(lang: &str) -> String {
format!(
"{}{}",
doc_strategy_core_section(lang),
doc_strategy_obscura_install_section(lang)
)
}
/// read_symbol 优先指引(中/英,不含行首 "- ")。父会话能力清单与子代理工具纪律段
/// 同源拼接(DRY,治指引真空:工具下发了纪律没下发,实测翻车坑在子代理原样复发)。
fn read_symbol_guidance(lang: &str) -> &'static str {
match lang {
"en" => "To understand/locate a code symbol (function/struct/class) prefer read_symbol(path, symbol): returns a skeleton (signature + line range + internal calls, minimal and high-density), avoiding read_file dumping the whole file into the prompt; pass full=true for the full definition body; read_symbol supports .rs/.ts/.tsx/.js/.jsx/.vue — fall back to read_file only for other languages or when the symbol is not found",
_ => "理解/定位代码符号(函数/结构体/类等)优先用 read_symbol(path, symbol):返回骨架(签名+行范围+内部调用,极小高密度),避免 read_file 全文回灌撑爆 prompt;需完整定义体传 full=true;read_symbol 支持 .rs/.ts/.tsx/.js/.jsx/.vue,其他语言或未找到符号时再退回 read_file",
}
}
/// 重复调用检测指引(中/英,不含行首 "- ")。同源拼接见 read_symbol_guidance。
fn duplicate_call_guidance(lang: &str) -> &'static str {
match lang {
"en" => "**Duplicate call detection**: If a tool (e.g. read_file/search_files) has already been called with identical arguments and returned successfully, do not call it again — the result is already in the conversation history; review the context instead of re-executing",
_ => "**重复调用检测**:如果某个工具(如 read_file/search_files)已用相同参数调用过且返回成功,不要重复调用——会话历史里已有结果,回顾上下文而非重复执行",
}
}
/// 子代理工具纪律段(P1-1):与父会话 system prompt 同源拼接的工具使用纪律 ——
/// read_symbol 优先 / 重复调用检测 / URL 三工具纪律与禁臆造 URL / render=true 失败
/// 2 次必换方案 / 查配置用 get_app_config。子代理白名单缺省下发全量 Low 工具,
/// 纪律必须随工具同源下发,否则父会话实测蒸馏的防坑经验在子循环原样复发。
/// 裁剪不适用条款:obscura 安装引导(涉用户交互,子代理无交互面)、聚焦准则与
/// 用户交互类行为准则(执行前说明意图/向用户提问)。
pub(crate) fn sub_agent_tool_discipline_section(lang: &str) -> String {
format!(
"\n## 工具纪律\n\
- {}\n\
- {}\n{}{}",
read_symbol_guidance(lang),
duplicate_call_guidance(lang),
doc_strategy_core_section(lang),
app_config_query_guidance_section(lang),
)
}
/// 文档探索策略核心段(中/英):URL 三工具分工 + 嗅探→识别→提取→验证→应用流程 +
/// 禁臆造 URL + render=true 失败重试铁律。父会话与子代理同源拼接块。
fn doc_strategy_core_section(lang: &str) -> &'static str {
match lang {
"en" => "\n\
## Documentation Exploration Strategy\n\
@@ -189,17 +249,7 @@ fn doc_exploration_strategy_section(lang: &str) -> &'static str {
1. **Switch after 2 failures**: if `render=true` fails twice in a row (any failure signal) → **immediately stop retrying that URL**. Pick an alternative: (a) use `http_request` to hit the API endpoint directly; (b) ask the user to provide content/screenshots manually; (c) work with the data you already have and produce output now — don't wait for a perfect render.\n\
2. **Stop after the first success**: if the first `render=true` succeeds and returns markdown → **use the data you have immediately**; do NOT re-render the same URL to top up more content (what you got is what you use).\n\
3. **Never re-render the same URL**: once a URL's render result (success or failure) is known, never fire render=true at it again (history/cache is enough); for new content, switch URL or switch tactic.\n\
\n\
### JS-rendered docs & the obscura install hint\n\
`fetch_url` defaults to a static fetch (no JS). For SPA / anti-scrape / JS-rendered docs, the user (or you) can pass `render=true`. When obscura (the JS-rendering backend, github.com/h4ckf0r0day/obscura) is **not installed**, the result silently falls back to static and the returned JSON carries `render_mode: \"static_fallback\"` plus a `hint` field pointing to the install page. Watch the **`hint` field** specifically (not just `render_mode`) — it is the install signal:\n\
1. **Notice**: if the `fetch_url` result has `hint` set (or `render_mode` is `static_fallback`), obscura is missing and the page may be under-rendered.\n\
2. **Offer**: proactively tell the user — \"obscura is not installed; installing it lets fetch_url render JS-heavy SPA / anti-scrape docs. Want me to install it for you?\" Don't silently continue with the static fallback when the user clearly needs JS rendering.\n\
3. **Install on consent**: once the user agrees, run the install yourself via `run_command` and stream the output so the user sees progress — pick whichever path fits the user's environment:\n\
- Rust toolchain present: `cargo install obscura`\n\
- otherwise: download the matching prebuilt binary from github.com/h4ckf0r0day/obscura releases and place it on PATH\n\
(`run_command` is high-risk and requires user approval — the user's \"yes\" above is that approval; still announce the exact command before running.)\n\
4. **Re-fetch**: after install succeeds, tell the user \"obscura is ready\" and retry `fetch_url` with `render=true` to get the properly rendered markdown.\n\
This is a pure conversational guide — there is no UI button for it; you detect the hint and drive the install through dialogue.\n",
\n",
_ => "\n\
## 文档探索策略\n\
### 先选对 URL 工具(三者别混用)\n\
@@ -234,8 +284,28 @@ fn doc_exploration_strategy_section(lang: &str) -> &'static str {
1. **失败 2 次必换方案**:`render=true` 连续 2 次失败(任意失败信号) → **立即停止重试同一 URL**,改走以下任一:(a) 改用 `http_request` 直接打 API 端点;(b) 提示用户手动提供内容/截图;(c) 用已拿到的数据先整理输出,别等完美渲染。\n\
2. **第 1 次成功即收手**:`render=true` 第 1 次成功拿到 markdown → **立即用已有数据整理输出**,不要重复 render 同 URL 去「补充」更多内容(已拿即用)。\n\
3. **同 URL 不重复 render=true**:同一 URL 的 render 结果(成功或失败)已知,后续不再对它重复发 render=true(缓存/历史即用);需要新内容请换 URL 或换方案。\n\
\n\
### JS 渲染文档与 obscura 安装引导\n\
\n",
}
}
/// obscura 安装引导段(中/英,父侧专用):涉用户交互(征得同意帮装)与 run_command 高风险
/// 工具,子代理无用户交互面,不进子代理工具纪律段([`sub_agent_tool_discipline_section`])。
/// 与核心段拼接 = 原 [`doc_exploration_strategy_section`] 完整段(逐字节等价)。
fn doc_strategy_obscura_install_section(lang: &str) -> &'static str {
match lang {
// 注:核心段末尾已保留空行(…tactic.\n\n / …换方案。\n\n),本段自标题起拼,
// 拼接 = 原完整段逐字节等价,不再补前导换行。
"en" => "### JS-rendered docs & the obscura install hint\n\
`fetch_url` defaults to a static fetch (no JS). For SPA / anti-scrape / JS-rendered docs, the user (or you) can pass `render=true`. When obscura (the JS-rendering backend, github.com/h4ckf0r0day/obscura) is **not installed**, the result silently falls back to static and the returned JSON carries `render_mode: \"static_fallback\"` plus a `hint` field pointing to the install page. Watch the **`hint` field** specifically (not just `render_mode`) — it is the install signal:\n\
1. **Notice**: if the `fetch_url` result has `hint` set (or `render_mode` is `static_fallback`), obscura is missing and the page may be under-rendered.\n\
2. **Offer**: proactively tell the user — \"obscura is not installed; installing it lets fetch_url render JS-heavy SPA / anti-scrape docs. Want me to install it for you?\" Don't silently continue with the static fallback when the user clearly needs JS rendering.\n\
3. **Install on consent**: once the user agrees, run the install yourself via `run_command` and stream the output so the user sees progress — pick whichever path fits the user's environment:\n\
- Rust toolchain present: `cargo install obscura`\n\
- otherwise: download the matching prebuilt binary from github.com/h4ckf0r0day/obscura releases and place it on PATH\n\
(`run_command` is high-risk and requires user approval — the user's \"yes\" above is that approval; still announce the exact command before running.)\n\
4. **Re-fetch**: after install succeeds, tell the user \"obscura is ready\" and retry `fetch_url` with `render=true` to get the properly rendered markdown.\n\
This is a pure conversational guide — there is no UI button for it; you detect the hint and drive the install through dialogue.\n",
_ => "### JS 渲染文档与 obscura 安装引导\n\
`fetch_url` 默认走静态抓取(不跑 JS)。遇到 SPA / 反爬 / JS 渲染的文档,用户(或你)可传 `render=true`。当 obscura(JS 渲染后端,github.com/h4ckf0r0day/obscura)**未安装**时,结果会静默回退静态,返回的 JSON 带 `render_mode: \"static_fallback\"` 和一个指向安装页的 `hint` 字段。请**重点看 `hint` 字段**(而非只看 `render_mode`)——它就是安装信号:\n\
1. **察觉**:若 `fetch_url` 结果带 `hint`(或 `render_mode` 为 `static_fallback`),说明 obscura 缺失,页面可能没渲染完整。\n\
2. **提议**:主动告诉用户——「obscura 未安装,装了之后 fetch_url 就能渲染 JS 重的 SPA / 反爬文档。要我帮你装吗?」当用户明显需要 JS 渲染时,不要默默用静态回退糊弄过去。\n\
@@ -412,9 +482,9 @@ pub(crate) async fn build_system_prompt_with_excluded(
}
let (prefix, _, _) = system_prompt_parts(lang);
let mut prompt = env_profile_line();
prompt.push_str(prefix);
prompt.push_str(&prefix);
// 文档探索策略段(URL→fetch_url 嗅探→识别→提取→验证→应用,治盲目 http_request 瞎试)
prompt.push_str(doc_exploration_strategy_section(lang));
prompt.push_str(&doc_exploration_strategy_section(lang));
// 查 DevFlow 自身配置引导(治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败)
prompt.push_str(app_config_query_guidance_section(lang));
@@ -556,8 +626,8 @@ pub(crate) async fn build_system_prompt_parts(
// 易变段(日期 + 项目/任务清单)挪到消息流末尾,不失效 system 缓存。
let (prefix, _, _) = system_prompt_parts(lang);
let mut stable = env_profile_stable_body();
stable.push_str(prefix);
stable.push_str(doc_exploration_strategy_section(lang));
stable.push_str(&prefix);
stable.push_str(&doc_exploration_strategy_section(lang));
stable.push_str(app_config_query_guidance_section(lang));
stable.push_str(&build_custom_section(state).await);
@@ -847,6 +917,36 @@ mod tests {
assert!(en.contains("Do **NOT**"));
}
// P1-1 子代理工具纪律段:与父会话同源(read_symbol/重复调用检测/URL 纪律/查配置),
// 且裁剪掉用户交互条款(obscura 安装引导,子代理无交互面)。
#[test]
fn sub_agent_tool_discipline_section_shares_parent_text() {
let zh = sub_agent_tool_discipline_section("zh-CN");
assert!(zh.contains("## 工具纪律"));
assert!(zh.contains(read_symbol_guidance("zh-CN")), "read_symbol 指引须与父会话同源");
assert!(zh.contains(duplicate_call_guidance("zh-CN")), "重复调用检测须与父会话同源");
assert!(zh.contains("## 文档探索策略"), "URL 纪律核心段须注入");
assert!(zh.contains("严禁臆造 URL"));
assert!(zh.contains("失败 2 次必换方案"));
assert!(zh.contains("get_app_config"));
assert!(
!zh.contains("要我帮你装吗"),
"obscura 安装引导为用户交互条款,子代理不得注入: {}",
zh
);
assert!(!zh.contains("## 聚焦准则"), "会话面准则不进子代理");
// 父会话 prefix 与共享函数同字同源(DRY 锁:改共享函数父侧同步生效)
let (prefix, _, _) = system_prompt_parts("zh-CN");
assert!(prefix.contains(read_symbol_guidance("zh-CN")));
assert!(prefix.contains(duplicate_call_guidance("zh-CN")));
// 父侧完整文档探索段 = 核心 + obscura 引导(拆分不丢段)
let parent_doc = doc_exploration_strategy_section("zh-CN");
assert!(parent_doc.contains("失败 2 次必换方案"));
assert!(parent_doc.contains("要我帮你装吗"));
}
// 项目/任务清单注明语(中/英)存在
#[test]
fn listed_notes_present_zh_en() {
+16 -1
View File
@@ -32,6 +32,7 @@ use crate::commands::ai::agentic::sub_agent::{
SUB_AGENT_DEFAULT_MAX_ROUNDS, SUB_AGENT_MAX_ROUNDS_CAP,
};
use crate::commands::ai::AiSession;
use crate::commands::ai::conversation::SaveMetaOverride;
use crate::state::LlmConcurrency;
use df_storage::db::Database;
@@ -253,7 +254,19 @@ async fn spawn_agent_handler(
let child_id = next_child_id(&parent_conv_id);
let task_prompt = build_task_prompt(&task, &allowed, &trimmed);
let system_prompt = sub_agent_system_prompt();
// P2-3:系统提示词注入实际生效的预算值(父可传 1..20,子代理按真实预算规划)
let system_prompt = sub_agent_system_prompt(max_rounds);
// P3-5:spawn 时刻快照父会话 save 元数据(active provider/created_at)。子代理后台
// 长跑收尾时用户可能已切走会话,出口落库若读 active_* 会继承别的会话元数据;
// 快照随子循环传入,出口经 save_conversation_meta 显式落库。
let parent_save_meta = {
let session = ctx.session.lock().await;
SaveMetaOverride {
provider_id: session.active_provider_id.clone(),
created_at: session.active_conv_created_at.clone(),
}
};
// 级联注册(父停止/强制发送/删除会遍历 children 级联取消)
sub_agents_register(&ctx.sub_agents, &parent_conv_id, &child_id);
@@ -287,9 +300,11 @@ async fn spawn_agent_handler(
system_prompt,
task_prompt,
tool_defs,
allowed,
max_rounds,
child_model,
ctx.llm_concurrency.clone(),
parent_save_meta,
)
.await;
+11
View File
@@ -46,6 +46,8 @@
<!-- Input -->
<div class="ai-input-area">
<!-- 无人值守边界配置卡(默认收起=现状零变化):发起时一次授权,生成中锁编辑 -->
<TaskBoundaryCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)
仅 agentic 多轮(streaming 且收到过 AiAgentRound round>0 事件)显示;
单轮问答 agentRound 恒 0,不显示(避免误显)。
@@ -61,6 +63,8 @@
断路器熔断 emit AiHelpRequired → pendingHelp(模块级 ref)→ 本卡显 reason + options 按钮供用户选。
机制优先 prompt 说教:代码强制熔断 + 结构化求助卡(非教 AI 主动问用户)。 -->
<HelpRequiredCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- 无人值守安全停靠卡(AiTaskParked):继续=重新授权续跑,停止归档=不再续跑 -->
<TaskParkedCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起,三选项 once/always/deny)。
path_auth 审批链阶段3b:统一审批开关 df-ai-unified-approval 开(true,默认)时下线——
path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载
@@ -143,6 +147,8 @@ import ChatInput from './ai/ChatInput.vue'
import TopBar from './ai/TopBar.vue'
import MessageList from './ai/MessageList.vue'
import HelpRequiredCard from './ai/HelpRequiredCard.vue'
import TaskParkedCard from './ai/TaskParkedCard.vue'
import TaskBoundaryCard from './ai/TaskBoundaryCard.vue'
import DirAuthDialog from './ai/DirAuthDialog.vue'
import { useConfirm } from '../composables/useConfirm'
import { useToast } from '../composables/useToast'
@@ -326,6 +332,11 @@ function cancelEdit(): void {
// 注:Ctrl+K(UX-06 §3.1)已绑——Tauri WebView2 无浏览器地址栏,聚焦搜索框无冲突
function onGlobalKeydown(e: KeyboardEvent) {
const ctrl = e.ctrlKey || e.metaKey
// 回归修复(2026-10-04 走查):全局 Ctrl 快捷键补输入焦点守卫——事件源在输入控件内时
// Ctrl+N/R/L/K/B 全部让路(输入框打字/组合键不误触新建对话/重生成/清空等)。
// 写法对齐下方 Esc 分支的 closest target 守卫;Esc 无 ctrl 不走此守卫(其分支自带仲裁)。
const el = e.target as HTMLElement | null
if (ctrl && !!el?.closest?.('input, textarea, [contenteditable]')) return
// Ctrl+N: 新建对话(Tauri 下 preventDefault 可靠,无浏览器新窗口冲突)
if (ctrl && (e.key === 'n' || e.key === 'N')) {
e.preventDefault()
+30
View File
@@ -10,8 +10,25 @@
import { useI18n } from 'vue-i18n'
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types'
import { useAiStore } from '../../stores/ai'
const { t } = useI18n()
const store = useAiStore()
/**
* 前导 /技能名 解析(问题3 技能调用留痕的渲染侧):
* 落库文本形如 `/skill-name 正文`(后端 chat.rs user_content 统一加前缀),命中本机
* 已安装技能名单才渲染为技能 chip——普通以 / 开头的文本(路径、markdown)不误判。
* 返回 null = 非技能调用,走原纯文本路径零回归。
*/
function leadingSkill(msg: AiMessage): { name: string; rest: string } | null {
if (msg.role !== 'user') return null
const m = /^\/([A-Za-z0-9_-]+)(?:\s([\s\S]*))?$/.exec(msg.content)
if (!m) return null
const known = store.state.skills.some((s) => s.name === m[1])
if (!known) return null
return { name: m[1], rest: m[2] ?? '' }
}
defineProps<{
msg: AiMessage
@@ -89,10 +106,17 @@ function imgAlt(img: ImageContentPart): string {
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<div class="ai-msg-bubble ai-msg-bubble--user">
<!-- 问题3 技能调用留痕:前导 /技能名 命中已安装技能 → 紫色 chip 标识,替代原文前缀显示 -->
<template v-if="leadingSkill(msg)">
<span class="ai-msg-chip ai-msg-chip--skill" :title="t('aiChat.skillBadgeTip', { name: leadingSkill(msg)!.name })">⚡ /{{ leadingSkill(msg)!.name }}</span>
<template v-if="leadingSkill(msg)!.rest">{{ ' ' + leadingSkill(msg)!.rest }}</template>
</template>
<template v-else>
<template v-for="(seg, idx) in segmentUserContent(msg)" :key="msg.id + '-seg-' + idx">
<span v-if="seg.type === 'text'">{{ seg.text }}</span>
<span v-else class="ai-msg-chip" :class="'ai-msg-chip--' + seg.span.kind">{{ seg.span.label }}</span>
</template>
</template>
<template v-if="msgImageParts(msg).length">
<div class="ai-msg-images">
<img v-for="(img, idx) in msgImageParts(msg)" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" />
@@ -153,6 +177,12 @@ function imgAlt(img: ImageContentPart): string {
background: var(--df-accent-bg, rgba(108,99,255,0.1));
color: var(--df-accent, #6c63ff);
}
/* 技能调用徽章(问题3):与 mention chip 同视觉家族,亮一档紫色底 + ⚡ 前缀区分 */
.ai-msg-chip--skill {
background: var(--df-accent-bg, rgba(108,99,255,0.18));
color: var(--df-accent, #6c63ff);
font-weight: 600;
}
/* 图片 */
.ai-msg-images {
display: flex;
+240 -18
View File
@@ -690,7 +690,8 @@ function canOpenSettings(msg: AiMessage): boolean {
* 错误是否可重试(显隐「重试」按钮)。
* Fatal 类错误(鉴权/Provider 配置错——4xx/401/403/invalid_api_key)重试必再败,
* 隐藏重试按钮,引导用户走「去设置」。network/timeout/unknown 为瞬时错误,可重试。
* retryable 推断依据后端 error_type(本批前端推断;后端 AiStreamRetry/AiError 加 retryable 字段下批 df-ai 增强)。
* error_type 由后端 emit 点分类(2026-10-04 起 emit_fatal_error 对模型不存在/令牌无权/
* 参数拒绝类 400 归 provider_config);后端判不了的真 Unknown 仍可重试,维持现状。
*/
function canRetry(msg: AiMessage): boolean {
// 重试过渡气泡(isRetry)非终态错误,不提供重试入口(终态 AiError 才有重试按钮)
@@ -1077,11 +1078,22 @@ const renderItems = computed<RenderItem[]>(() => {
}
}
flushBucket()
// S1 子代理完成卡追加在消息流末尾:子代理在父轮内运行,完成语义上晚于当轮内容;
// S1 子代理完成卡:按完成时刻插到所属轮次桶尾,不恒贴消息流最底部——恒贴时用户继续对话,
// 早前轮次的卡被顶到后面内容之下,与所属轮次脱钩。归桶判据:finishedAt 落在哪桶的跨度内
// (trace.startTs ≤ finishedAt < 下一桶),卡即插到该桶 trace 卡之后与其同层(spawn 就发生
// 在这个桶里;桶内末条答复的落库时刻必晚于 finishedAt,时间比较天然落在这里)。
// 无时间语义的项(step/timesep/sep/agentFin)不作锚点,防插进时间线分隔的语义缝隙。
// 逐卡一项(key 按 agentId 稳定),同会话多子代理多卡共存,Vue diff 复用不闪。
// 真相源是 aiShared.convAgentFinished(per-conv),切会话重进仍可见,非组件局部状态。
for (const card of getConvAgentFinished(store.state.activeConversationId)) {
items.push({ kind: 'agentFin', key: 'agentfin-' + card.agentId, card })
const cards = [...getConvAgentFinished(store.state.activeConversationId)].sort((a, b) => a.finishedAt - b.finishedAt)
for (const card of cards) {
let at = items.length
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i]
const ts = it.kind === 'trace' ? it.startTs : it.kind === 'msg' ? it.msg.timestamp : 0
if (ts && ts <= card.finishedAt) { at = i + 1; break }
}
items.splice(at, 0, { kind: 'agentFin', key: 'agentfin-' + card.agentId, card })
}
return items
})
@@ -1094,11 +1106,35 @@ function agentFinTone(status: AgentFinishedCard['status']): 'ok' | 'warn' | 'err
if (status === 'failed') return 'err'
return 'ok'
}
// 状态词独立于颜色透出(色弱可用);键与后端 SubAgentStatus::as_str 字面量一一对应
function agentFinStatusText(status: AgentFinishedCard['status']): string {
if (status === 'truncated') return t('ai.agentFinished.truncated')
if (status === 'failed') return t('ai.agentFinished.failed')
// 状态词独立于颜色透出(色弱可用);键与后端 SubAgentStatus::as_str 字面量一一对应。
// stop_kind 结构化优先(根治启发式):后端终态枚举直达语义,截断三细分各自透出;
// 未知值兜底中性 completed 词不误报。无字段(旧后端)回退 summary 启发式:用户停止
// 级联取消子代理时 stop_reason=「已被取消(...)」并经 summary 终止说明透出(后端两处
// 取消检查点均写此中文字面量),按该特征分流为「已取消」;无此特征才是系统截断。
function agentFinStatusText(card: AgentFinishedCard): string {
const k = card.stop_kind
if (k) {
if (k === 'cancelled') return t('ai.agentFinished.cancelled')
if (k === 'max_rounds') return t('ai.agentFinished.truncMaxRounds')
if (k === 'wall_clock') return t('ai.agentFinished.truncWallClock')
if (k === 'token_budget') return t('ai.agentFinished.truncTokenBudget')
if (k === 'empty_convergence') return t('ai.agentFinished.noReport')
if (k === 'error') return t('ai.agentFinished.failed')
return t('ai.agentFinished.completed')
}
if (card.status === 'truncated') {
return card.summary.includes('已被取消')
? t('ai.agentFinished.cancelled')
: t('ai.agentFinished.truncated')
}
if (card.status === 'failed') return t('ai.agentFinished.failed')
return t('ai.agentFinished.completed')
}
// 启动失败型子代理(并发占满/父会话已删/provider 缺失)在进循环前就返回,子会话未落库,
// 点卡 peek 必报「对话不存在」死胡同;rounds=0 是前端可得的最稳特征(进循环后的失败
// 也至少有一轮落库记录,可正常 peek)。
function agentFinNoTrace(card: AgentFinishedCard): boolean {
return card.status === 'failed' && card.rounds === 0
}
// 摘要超长截断:单行细条承载,压平空白后按 160 字截断(对齐轨迹卡 500 字口径的轻量版),
// 全文经 title hover 兜底不丢信息。
@@ -1106,6 +1142,13 @@ function agentFinSummary(summary: string): string {
const s = (summary || '').replace(/\s+/g, ' ').trim()
return s.length > 160 ? s.slice(0, 160) + '…' : s
}
// 被拒工具 chip(P2-1):前 2 枚枚举 + 「+N」收尾防长清单撑爆细条,完整清单经 hover title 查看
const AGENT_FIN_REJECTED_MAX = 2
function agentFinRejectedText(list: string[]): string {
const shown = list.slice(0, AGENT_FIN_REJECTED_MAX).join(' / ')
const rest = list.length - AGENT_FIN_REJECTED_MAX
return t('ai.agentFinished.rejected', { tools: rest > 0 ? `${shown} +${rest}` : shown })
}
// ── S3 子代理轨迹浮层(完成卡点击展开,只读) ──
// 状态全部组件局部:浮层关即弃、不缓存轨迹(每次点开重新 peek,宁少勿预留),
@@ -1114,10 +1157,17 @@ interface AgentPeekMsg {
role: 'user' | 'assistant' | 'tool' | 'system'
text: string
toolNames: string[]
// deny envelope 消息标记:命中子代理拒绝信封的 tool 结果,渲染为红字人话而非原始 JSON
denied?: boolean
}
const agentPeekVisible = ref(false)
const agentPeekLoading = ref(false)
// 加载中的卡 id:spinner 挂在具体完成卡上而非浮层(浮层此时尚未打开)
const agentPeekLoadingId = ref<string | null>(null)
const agentPeekMsgs = ref<AgentPeekMsg[]>([])
// 浮层头身份标识:agent-{n} 尾段 + 子会话标题(IPC title,子代理常为空则不显)+ 任务书首行,
// 多代理场景辨认「在看谁」;关浮层即弃
const agentPeekSubject = ref({ agent: '', convTitle: '', task: '', taskFull: '' })
// 单条内容截断上限:tool 结果(文件全文/长输出)可能巨大,简版渲染按 4000 字截断兜底,
// 完整数据仍在库,不做全文渲染(不要求富文本)。
const AGENT_PEEK_TEXT_MAX = 4000
@@ -1125,40 +1175,104 @@ const AGENT_PEEK_TEXT_MAX = 4000
/**
* 完成卡点击:peek 子会话轨迹并开浮层。
* agentId 即子会话 conv id(形态 {parent}::agent-{n},后端 spawn_agent 落库 origin='sub_agent')。
* 加载中/已开时忽略重复点击(防连点重复 IPC);失败走既有 toast 通道给可见反馈,不开空浮层。
* 启动失败无轨迹卡直接忽略(agentFinNoTrace);加载中/已开时忽略重复点击(防连点重复 IPC);
* 失败走既有 toast 通道给可见反馈,不开空浮层。
*/
async function openAgentTrace(card: AgentFinishedCard): Promise<void> {
if (agentFinNoTrace(card)) return
if (agentPeekVisible.value || agentPeekLoading.value) return
agentPeekLoading.value = true
agentPeekLoadingId.value = card.agentId
try {
const res = await aiApi.peekConversation(card.agentId)
const raw: any[] = typeof res.messages === 'string' ? JSON.parse(res.messages) : (res.messages || [])
// tool_call_id → 工具名映射:tool 消息只带 id,名字在前条 assistant.tool_calls 里
const toolNameById = new Map<string, string>()
for (const m of raw) {
if (Array.isArray(m.tool_calls)) {
for (const tc of m.tool_calls) {
if (tc?.id && tc?.function?.name) toolNameById.set(String(tc.id), String(tc.function.name))
}
}
}
agentPeekMsgs.value = raw.map((m) => {
const text = String(m.content || '')
const rawText = String(m.content || '')
const role = m.role === 'user' || m.role === 'assistant' || m.role === 'tool' || m.role === 'system'
? m.role
: 'assistant'
return {
const msg: AgentPeekMsg = {
role,
text: text.length > AGENT_PEEK_TEXT_MAX ? text.slice(0, AGENT_PEEK_TEXT_MAX) + '…' : text,
text: '',
toolNames: Array.isArray(m.tool_calls)
? m.tool_calls.map((tc: any) => tc?.function?.name || '').filter(Boolean)
: [],
}
if (role === 'tool') {
// 回显工具名 chip(与前条 assistant 消息的调用 chip 对得上,行内可读)
const callName = toolNameById.get(String(m.tool_call_id || ''))
if (callName) msg.toolNames.push(callName)
// deny envelope 先解析后截断(信封很小不会被截),命中转人话不走原文
const deny = parseDenyEnvelope(rawText)
if (deny) {
msg.denied = true
const risk = agentPeekRiskText(deny.risk_level)
msg.text = t('ai.agentTrace.deniedTool', { tool: deny.tool_name || callName || '?' })
+ (risk ? `(${risk})` : '')
+ (deny.message ? ` · ${deny.message}` : '')
return msg
}
}
msg.text = rawText.length > AGENT_PEEK_TEXT_MAX ? rawText.slice(0, AGENT_PEEK_TEXT_MAX) + '…' : rawText
return msg
})
// 任务行取子会话首条 user 消息(即任务书)首行,取不到回退卡摘要;title 挂全文
const firstUser = raw.find((m) => m.role === 'user')
const taskLine = String(firstUser?.content || '').split('\n').map((s: string) => s.trim()).filter(Boolean)[0] || ''
const full = taskLine || (card.summary || '').replace(/\s+/g, ' ').trim()
agentPeekSubject.value = {
agent: card.agentId.split('::').pop() || card.agentId,
convTitle: String(res.title || '').trim(),
task: full.length > 120 ? full.slice(0, 120) + '…' : full,
taskFull: full,
}
agentPeekVisible.value = true
} catch (e) {
console.error('[AI] 拉取子代理轨迹失败:', e)
emit('toast', { msg: t('ai.agentTrace.loadFail'), type: 'error' })
} finally {
agentPeekLoading.value = false
agentPeekLoadingId.value = null
}
}
/** deny envelope 识别:与后端 is_sub_agent_deny_envelope 同构(status=rejected + reason=sub_agent_denied) */
function parseDenyEnvelope(text: string): { tool_name?: string; risk_level?: string; message?: string } | null {
const s = text.trim()
if (!s.startsWith('{')) return null
try {
const v = JSON.parse(s)
if (v?.status === 'rejected' && v?.reason === 'sub_agent_denied') {
return { tool_name: v.tool_name, risk_level: v.risk_level, message: v.message }
}
} catch {
// 截断/非 JSON 内容即非信封,按原文渲染
}
return null
}
/** 风险级别人话化(后端 RiskLevel serde lowercase,字面量 low/medium/high) */
function agentPeekRiskText(level: unknown): string {
if (level === 'low') return t('ai.agentTrace.riskLow')
if (level === 'medium') return t('ai.agentTrace.riskMedium')
if (level === 'high') return t('ai.agentTrace.riskHigh')
return ''
}
/** 关浮层即弃:清空局部消息态,不缓存轨迹 */
function closeAgentTrace(): void {
agentPeekVisible.value = false
agentPeekMsgs.value = []
agentPeekSubject.value = { agent: '', convTitle: '', task: '', taskFull: '' }
}
/** role → 本地化角色名(简版渲染,无头像气泡) */
@@ -1187,6 +1301,8 @@ watch(() => store.state.activeConversationId, () => {
setFollowing(true)
// 切会话重置执行轨迹展开态(展开记忆按会话隔离,防跨会话 key 撞车误展开)
expandedTraceIds.value = new Set()
// 切会话关子代理轨迹浮层(浮层是组件级单例,不关则旧会话轨迹叠在新会话上)
closeAgentTrace()
// BUG-260624-01(消息重叠根治·论证 workflow 完整性高优):MessageList 是持久单实例(AiChat.vue
// 无 :key/v-if 卸载),实例级 streamingBlocks(:69)跨会话/跨轮残留是消息重叠主根因之一。
// 钉在「会话切换」这一确切时机清,根治两条残留路径:① switchConversation(useAiConversations)
@@ -1211,6 +1327,7 @@ watch(() => store.state.activeConversationId, () => {
onBeforeUnmount(() => {
cancelPendingRaf()
document.removeEventListener('click', closeTokenPopoverOnOutsideClick)
document.removeEventListener('keydown', closeAgentTraceOnEsc)
})
// token 详情面板:点击外部关闭(token 区内的 @click.stop 已阻冒泡,故文档级点击必为外部)
@@ -1219,6 +1336,12 @@ function closeTokenPopoverOnOutsideClick(): void {
}
document.addEventListener('click', closeTokenPopoverOnOutsideClick)
// 子代理轨迹浮层 Esc 关闭(与遮罩点击/X 同为关闭出口;仅浮层开着时消费,不干扰其他快捷键)
function closeAgentTraceOnEsc(e: KeyboardEvent): void {
if (e.key === 'Escape' && agentPeekVisible.value) closeAgentTrace()
}
document.addEventListener('keydown', closeAgentTraceOnEsc)
// Markdown 预热(父原 loadMarkdown 在 onMounted 调,子组件同样幂等——useMarkdown 单例,
// 多次调用安全,确保子组件挂载即预热)。
loadMarkdown()
@@ -1321,16 +1444,26 @@ defineExpose({
<div v-else-if="item.kind === 'agentFin'" class="ai-msg-slot">
<div
class="ai-agent-fin"
:class="'ai-agent-fin--' + agentFinTone(item.card.status)"
:class="['ai-agent-fin--' + agentFinTone(item.card.status), { 'ai-agent-fin--notrace': agentFinNoTrace(item.card) }]"
role="button"
:title="$t('ai.agentFinished.viewHint')"
:title="agentFinNoTrace(item.card) ? $t('ai.agentFinished.noTrace') : $t('ai.agentFinished.viewHint')"
@click="openAgentTrace(item.card)"
>
<span class="ai-agent-fin-dot"></span>
<span class="ai-agent-fin-label">{{ $t('ai.agentFinished.title') }}</span>
<span class="ai-agent-fin-status">{{ agentFinStatusText(item.card.status) }}</span>
<span class="ai-agent-fin-status">{{ agentFinStatusText(item.card) }}</span>
<!-- 被拒工具 chip(P2-1):非空才渲染,琥珀警示层与 truncated 同色系;
单枚直显,多枚前 2 枚枚举 + N,完整清单 hover title -->
<span
v-if="item.card.rejected_tools?.length"
class="ai-agent-fin-rejected"
:title="item.card.rejected_tools.join('\n')"
>{{ agentFinRejectedText(item.card.rejected_tools) }}</span>
<span class="ai-agent-fin-summary" :title="item.card.summary">{{ agentFinSummary(item.card.summary) }}</span>
<span class="ai-agent-fin-meta">{{ $t('ai.agentFinished.meta', { rounds: item.card.rounds, tokens: formatTokensK(item.card.tokens) }) }}</span>
<!-- 回补卡 rounds/tokens 未知为 0,双零隐藏 meta 防「0 轮 · 0 tokens」噪音 -->
<span v-if="item.card.rounds || item.card.tokens" class="ai-agent-fin-meta">{{ $t('ai.agentFinished.meta', { rounds: item.card.rounds, tokens: formatTokensK(item.card.tokens) }) }}</span>
<!-- peek 加载微标:IPC 期间转圈,防「点了没反应」;挂在对应卡上,多卡各自反馈 -->
<span v-if="agentPeekLoadingId === item.card.agentId" class="ai-agent-fin-spin" aria-hidden="true"></span>
</div>
</div>
<!-- 居中时间分隔线(UX 密度改造:相邻消息间隔 ≥30 分钟或跨天;当天只 HH:mm,跨天 MM-DD HH:mm) -->
@@ -1584,19 +1717,28 @@ defineExpose({
<div v-if="agentPeekVisible" class="ai-agent-peek-mask" @click.self="closeAgentTrace">
<div class="ai-agent-peek" role="dialog" :aria-label="$t('ai.agentTrace.title')">
<div class="ai-agent-peek-head">
<div class="ai-agent-peek-head-text">
<span class="ai-agent-peek-title">{{ $t('ai.agentTrace.title') }}</span>
<!-- 身份标识行:agent-{n} + 子会话标题 + 任务书首行,多代理场景辨认「在看谁」 -->
<div class="ai-agent-peek-subject" :title="agentPeekSubject.taskFull">
<span class="ai-agent-peek-agent">{{ agentPeekSubject.agent }}</span>
<span v-if="agentPeekSubject.convTitle" class="ai-agent-peek-conv">{{ agentPeekSubject.convTitle }}</span>
<span v-if="agentPeekSubject.task" class="ai-agent-peek-task">{{ agentPeekSubject.task }}</span>
</div>
</div>
<button class="ai-agent-peek-close" :title="$t('common.close')" @click="closeAgentTrace">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="ai-agent-peek-body">
<div v-if="agentPeekMsgs.length === 0" class="ai-agent-peek-empty">{{ $t('ai.agentTrace.empty') }}</div>
<!-- 简版卡片流:role 名 + 工具调用名 chips + 纯文本内容(不做完整 markdown 富渲染) -->
<!-- 简版卡片流:role 名 + 工具调用名 chips + 纯文本内容(不做完整 markdown 富渲染);
deny envelope 消息(denied)红字人话,不再直出原始 JSON -->
<div
v-for="(m, i) in agentPeekMsgs"
:key="i"
class="ai-agent-peek-msg"
:class="'ai-agent-peek-msg--' + m.role"
:class="['ai-agent-peek-msg--' + m.role, { 'ai-agent-peek-msg--denied': m.denied }]"
>
<div class="ai-agent-peek-role">
<span>{{ agentPeekRoleText(m.role) }}</span>
@@ -2450,6 +2592,40 @@ defineExpose({
font-family: var(--df-font-mono);
font-size: 10.5px;
}
/* 被拒工具 chip(P2-1):琥珀警示层与 truncated 同色系;限宽防长清单挤掉摘要行 */
.ai-agent-fin-rejected {
flex-shrink: 0;
max-width: 38%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 6px;
border-radius: 999px;
font-size: 10.5px;
background: color-mix(in srgb, var(--df-warning, #f0c75e) 16%, transparent);
color: var(--df-warning, #f0c75e);
}
/* 无轨迹(启动失败)卡:禁点视觉,hover 不提亮(cursor 维持默认) */
.ai-agent-fin--notrace {
cursor: default;
opacity: 0.72;
}
.ai-agent-fin--notrace:hover {
background: color-mix(in srgb, var(--df-text-secondary) 6%, transparent);
}
/* peek 拉取中的加载微标(挂在具体卡右端) */
.ai-agent-fin-spin {
flex-shrink: 0;
width: 10px;
height: 10px;
border: 1.5px solid color-mix(in srgb, var(--df-text-secondary) 35%, transparent);
border-top-color: var(--df-text-secondary);
border-radius: 50%;
animation: ai-agent-fin-spin 0.8s linear infinite;
}
@keyframes ai-agent-fin-spin {
to { transform: rotate(360deg); }
}
/* ═══ S3 子代理轨迹浮层(只读,关即弃) ═══ */
.ai-agent-peek-mask {
@@ -2552,4 +2728,50 @@ defineExpose({
color: var(--df-text-secondary);
font-size: 11.5px;
}
/* 浮层头身份标识行:置于标题下方,窄容器内单行省略 */
.ai-agent-peek-head-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.ai-agent-peek-subject {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-size: 11px;
color: var(--df-text-secondary);
}
.ai-agent-peek-agent {
flex-shrink: 0;
font-family: var(--df-font-mono);
font-size: 10.5px;
color: var(--df-info);
background: var(--df-info-bg);
border-radius: var(--df-radius-sm);
padding: 1px 6px;
}
.ai-agent-peek-conv {
flex-shrink: 0;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--df-text);
}
.ai-agent-peek-task {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--df-text-dim);
}
/* deny envelope 消息:红字人话,置于 tool/system 弱化规则之后以覆盖其色 */
.ai-agent-peek-msg--denied {
border-left: 2px solid var(--df-danger);
}
.ai-agent-peek-msg--denied .ai-agent-peek-text {
color: var(--df-danger);
}
</style>
+25 -3
View File
@@ -9,6 +9,8 @@
@close="$emit('close')"
header-style="display:none"
>
<!-- 关联会话跳转反馈(P3-7):命中已删会话时抽屉保持打开,toast 就地提示 -->
<div v-if="toast.visible" class="drawer-toast" :class="'drawer-toast--' + toast.type">{{ toast.msg }}</div>
<div class="task-drawer" v-if="task">
<!-- 标题行:标题 + 状态 + 优先级 + 推进按钮 -->
<div class="dr-header">
@@ -205,7 +207,7 @@ const emit = defineEmits<{
const { t } = useI18n()
const router = useRouter()
const store = useProjectStore()
const { showToast } = useToast()
const { toast, showToast } = useToast()
const { openConversation } = useAiConversations()
// 关联会话引用(S4:审计表反查,无关联=空数组 → 入口隐藏)
@@ -299,10 +301,15 @@ async function loadTaskConversations(tid: string) {
} catch { taskConversations.value = [] }
}
/** 点击关联会话:先关抽屉(面板在抽屉下层,不关会被遮挡),再走 openConversation 跳转 */
/** 点击关联会话:跳转成功才关抽屉(面板在抽屉下层,不关会被遮挡);命中已删会话时留原地
* 并 toast(UX走查 P3-7:原实现先关抽屉且静默兜底新建,用户落进空白对话无解释) */
async function handleOpenConversation(convId: string) {
const res = await openConversation(convId)
if (res === 'not_found') {
showToast(t('ai.convDeleted'), 'error')
return
}
emit('close')
await openConversation(convId)
}
async function loadChildren() {
@@ -402,4 +409,19 @@ async function confirmSubtask() {
word-break: break-all;
}
.conv-link:hover { text-decoration: underline; }
/* 抽屉内 toast(P3-7):fixed + 高 z-index 盖过 Arco Drawer 遮罩(内容被 teleport 到 body,作用域样式仍生效) */
.drawer-toast {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
padding: 8px 14px;
border-radius: var(--df-radius);
font-size: 12px;
z-index: 3000;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
max-width: 80%;
overflow-wrap: anywhere;
}
.drawer-toast--error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
</style>
+54
View File
@@ -624,6 +624,14 @@ export interface AgentFinishedCard {
status: AgentFinishedStatus
rounds: number
tokens: number
/** 被拒工具清单("工具名×次数",如 "write_file×3",后端 format_rejected_tools 产出)。
* 字段名保持事件 wire 格式 snake_case(summary 是后端拼好的全文,结构化清单只此一份,
* 卡面 160 字截断裁掉 summary 尾部时渲染端仍可扫描被拒事实);缺省=零拒绝。 */
rejected_tools?: string[]
/** 终止原因结构化字段(后端 AiAgentFinished.stop_kind,cancelled/wall_clock/max_rounds/
* token_budget/empty_convergence/error/converged)。缺省=旧后端未发,渲染端回退
* summary 启发式(向后兼容);实时事件到达时以此覆盖启发式判断。 */
stop_kind?: string
finishedAt: number
}
@@ -661,6 +669,52 @@ export function clearConvAgentFinished(convId: string | null | undefined): void
convAgentFinished.delete(convId)
}
/** ai_sub_agent_list 的单条轨迹元数据(wire shape 归口 api 层返回类型,此处只投影用) */
type SubAgentRef = Awaited<ReturnType<typeof aiApi.listSubAgents>>[number]
/**
* P2-2 完成卡回补:重启/分离窗回收后内存 Map 清零,子代理轨迹成无 UI 入口孤岛。
* 按父 conv 经 ai_sub_agent_list 反查子代理轨迹,重建已完成卡(轨迹存在即视为 completed;
* rejected_tools/rounds/tokens 后端该 IPC 不回,缺省=零拒绝/0,卡面隐藏 0 值 meta)。
*
* 幂等/去重/降级三保证:
* - 幂等:挂载恢复与每次 switchConversation 都会触发,agentId 键控 insert-only,重复调用收敛;
* - 去重:同 agentId 已有卡(实时事件先到/上轮回补过)跳过,不覆盖已有数据;
* 实时事件走 addConvAgentFinished 的 splice 覆盖语义,晚到仍会覆盖回补卡(实时数据优先)。
* - 降级:IPC 失败仅 console.warn 返回,调用方 fire-and-forget,不阻塞会话加载。
*/
export async function backfillConvAgentFinished(convId: string | null | undefined): Promise<void> {
if (!convId) return
let refs: SubAgentRef[]
try {
refs = await aiApi.listSubAgents(convId)
} catch (e) {
console.warn('[AI] 完成卡回补失败(静默降级):', convId, e)
return
}
if (refs.length === 0) return
let arr = convAgentFinished.get(convId)
if (!arr) {
arr = []
convAgentFinished.set(convId, arr)
}
for (const ref of refs) {
const agentId = ref.conversation_id
// agentId 即子会话 conv id({parent}::agent-{n} 全形,与实时事件 agent_id 同源),
// 与既有卡天然同键;无 id 的脏行跳过防空键卡
if (!agentId || arr.some(c => c.agentId === agentId)) continue
arr.push({
agentId,
// title 懒创建常为 None,回退显 agent-{n} 尾段(对齐轨迹浮层身份行切分逻辑)
summary: (ref.title || '').trim() || agentId.split('::').pop() || agentId,
status: 'completed',
rounds: 0,
tokens: 0,
finishedAt: Number(ref.created_at) || Date.now(),
})
}
}
// ── UX-P2-A3 审批超时错误气泡 per-conv 守卫 ──
//
// 背景:startApprovalTimer 到点把「审批超时已自动拒绝」错误气泡直接推进 getMessages()
+51 -1
View File
@@ -1,7 +1,9 @@
//! 窗口分离模式 — detach/reattach/resumeInDetached/closeDetachedWindow/dock/syncToMain/startFollowMain/stopFollowMain
//! + 跨窗切会话(emitDetachedOpenConversation/registerDetachedOpenConvListener)
//!
//! 模块级私有(不进 reactive):
//! - _unlistenMove / _unlistenResize: 主窗口 move/resize 跟随的 unlistener
//! - _openConvListenerRegistered: 跨窗切会话监听注册标记(幂等)
//!
//! 耦合:
//! - resumeInDetached / restoreGeneratingState 调 aiShared.nextMsgId 占位 ai 气泡 +
@@ -35,6 +37,54 @@ const TEXT_KEY_PREFIX = 'df-ai-text-'
function detachedLabel(convId: string): string {
return DETACHED_LABEL_PREFIX + convId
}
/** 分离窗口 label 判定(含旧版单 label 'ai-detached' 兼容) */
function isDetachedLabel(label: string): boolean {
return label === 'ai-detached' || label.startsWith(DETACHED_LABEL_PREFIX)
}
// ── 跨窗切会话(UX走查 2026-10-03 P1-1)──
// 主/分离窗口是独立 JS context(state 互不同步),主窗口点「关联会话」跳转时分离窗不跟随。
// 约定:发起方 emit 全局事件广播到全部 webview,仅分离窗(按 label 判定)注册消费,
// 主窗口 label 'main' 不注册,不会自发自收。
const OPEN_CONV_EVENT = 'ai-open-conversation'
/** 发起方(主窗口)侧:广播跨窗切会话事件;无分离窗存活时事件落空 */
export async function emitDetachedOpenConversation(convId: string): Promise<void> {
try {
const { emit } = await import('@tauri-apps/api/event')
await emit(OPEN_CONV_EVENT, { convId })
} catch (e) {
console.error('[AI] 跨窗切会话事件发送失败:', e)
}
}
/** 承接方(分离窗)侧:注册跨窗切会话监听,切换完成后聚焦本窗口(点击发生在主窗口,
* 焦点须跟到承接会话的窗口,否则用户感知「点了没反应」)。
* handler 由 useAiConversations 注入:切会话逻辑归会话域,避免本模块反向依赖造成模块环。 */
let _openConvListenerRegistered = false
export function registerDetachedOpenConvListener(handler: (convId: string) => Promise<void>): void {
if (_openConvListenerRegistered) return
_openConvListenerRegistered = true
void (async () => {
try {
const [{ listen }, { getCurrentWebviewWindow }] = await Promise.all([
import('@tauri-apps/api/event'),
import('@tauri-apps/api/webviewWindow'),
])
if (!isDetachedLabel(getCurrentWebviewWindow().label)) return
await listen<{ convId?: string }>(OPEN_CONV_EVENT, async (ev) => {
const convId = ev.payload?.convId
if (!convId) return
await handler(convId)
try { await getCurrentWebviewWindow().setFocus() } catch { /* 窗口关闭竞态忽略 */ }
})
} catch (e) {
_openConvListenerRegistered = false
console.error('[AI] 注册跨窗切会话监听失败:', e)
}
})()
}
function genKey(convId: string): string {
return GEN_KEY_PREFIX + convId
}
@@ -125,7 +175,7 @@ async function reattachPanel() {
const all = await WebviewWindow.getAll()
for (const w of all) {
const lbl = w.label
if (lbl === 'ai-detached' || lbl.startsWith(DETACHED_LABEL_PREFIX)) {
if (isDetachedLabel(lbl)) {
try { await w.close() } catch { /* 忽略单个窗口关闭失败 */ }
}
}
+5
View File
@@ -112,6 +112,11 @@ export function toolDisplayName(tc: AiToolCallInfo): string {
const host = httpHost(url)
return host ? `${tGlobal('aiTool.fetchUrlPrefix')} ${host}` : tGlobal('aiTool.fetchUrlFallback')
}
case 'spawn_agent': {
// 任务书截断展示(对齐后端 G1 GoalMeta「派发子代理」Truncate 25 字;shortCmd 顺带折叠多行任务书为单行)
const task = argString(tc.args, 'task')
return task ? tGlobal('aiTool.spawnAgentPrefix', { task: shortCmd(task, 25) }) : formatToolName(tc.name)
}
default: return formatToolName(tc.name)
}
}