修复: 模型停用无效根因 + 上下文归位/压缩守卫
- 模型停用无效根因: get_active_provider 两条路径均不校验 enabled(显式活跃直返/默认只挑 is_default) → 收敛为启用集合解析,无启用 provider 明确报错 - 压缩/标题兜底模型不校验 enabled → 新增 resolve_fallback_model 纯函数(title 复用同一实现,单一源无双轨) - 上下文多夹层归位修复: gather_tool_responses 聚拢该头全部响应(此前第二个夹层之后的响应仍被判未闭合剥除) - 压缩后台任务加内容版本哨兵(取首条消息 id,否掉 len:压缩期间 loop 正常 append 会致守卫永不生效) + panic 兜底复位标志 - 902 超时类失败与其它 Fatal 区分停靠文案(点继续可续跑) - 子代理事件按 conv 改挂(route_scope) + 运行期事件推父会话视图 - 飞书 13a 真根因: run_stream_refresher 死代码(提前 drop)+静默钟被心跳重置 → 帧通道+仅内容事件重置 - 项目详情文件查看: F22 目录不存在误显非 Git 仓库 / F24 模块为仓库子目录时 git 徽标全失效(scope_git_path 按 show-prefix 剥前缀) - 更新徽章通道错配(Tauri 事件 vs window CustomEvent 同名不同通道,徽章此前永不触发) - ProjectCard 卡片根 keydown 顶替内嵌按钮(补 e.target 守卫) - i18n 坏键 ai.aiTool.rejectedHint → aiTool.rejectedHint(双缺会渲染裸 key) - miniapp 三处: 会话列表刷新漏网/看门狗节流致新一轮无兜底/newConversation 漏清看门狗
This commit is contained in:
@@ -98,6 +98,7 @@ pub async fn cleanup_expired_approvals(app: &AppHandle, state: &AppState, conv_i
|
||||
&tc_id,
|
||||
"rejected",
|
||||
Some(format!("审批超时({}分钟)自动取消", timeout_minutes)),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ use std::sync::Arc;
|
||||
use tauri::AppHandle;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_ai::context::ContextManager;
|
||||
use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
|
||||
use df_ai::provider::ChatMessage;
|
||||
use df_storage::models::AiProviderRecord;
|
||||
@@ -59,6 +60,32 @@ const PROTECT_COUNT: usize = 6;
|
||||
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
|
||||
const KEYWORD_FALLBACK_ENABLED: bool = true;
|
||||
|
||||
/// 会话消息列表的内容版本哨兵(939⑪):首条消息的全局唯一 id(空列表 = None)。
|
||||
///
|
||||
/// 压缩**触发时**快照本值,后台 task **提交前**重新读取比对:相等才允许提交。
|
||||
/// 首条消息 id 由 `new_message_id()` 全局唯一——loop 只 append(不改首条),而 `clear()`
|
||||
/// 重建后首条必换新 id,故它是「上下文是否被整体替换」的准确且零成本哨兵。
|
||||
/// 不用 len:loop 后台压缩期间正常 append 会使 len 增长,以 len 相等为守卫会误判版本
|
||||
/// 不符致后台压缩永不生效。
|
||||
fn head_id_of(messages: &mut ContextManager) -> Option<String> {
|
||||
messages.messages_mut().first().and_then(|t| t.message.id.clone())
|
||||
}
|
||||
|
||||
/// 内容版本是否仍匹配(939⑪):快照与当前首条 id 相等才允许提交。
|
||||
fn version_matches(snapshot: &Option<String>, current: &Option<String>) -> bool {
|
||||
snapshot == current
|
||||
}
|
||||
|
||||
/// panic 兜底复位(939⑫):清 is_compressing 防其永真致压缩永久停摆。
|
||||
///
|
||||
/// conv 已删(per_conv 无条目)跳过——标志随 conv 销毁无意义(对齐各正常出口语义)。
|
||||
async fn reset_compressing_after_panic(session_arc: &Arc<Mutex<AiSession>>, conv_id: &str) {
|
||||
let mut session = session_arc.lock().await;
|
||||
if session.per_conv.contains_key(conv_id) {
|
||||
session.conv(conv_id).messages.set_compressing(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
|
||||
///
|
||||
/// **L0-3(2026-08-09)后台化**:压缩 LLM 调用不再同步阻塞本轮 loop(非流式 LLM 最长 60s)。
|
||||
@@ -114,7 +141,7 @@ pub(super) async fn maybe_auto_compress(
|
||||
emit_ai_chat_event(app_handle, AiChatEvent::AiCompressing {
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
let (mut active_msgs, lang) = {
|
||||
let (mut active_msgs, lang, head_id) = {
|
||||
let mut session = session_arc.lock().await;
|
||||
let conv = session.conv(&conv_id);
|
||||
conv.messages.set_compressing(true);
|
||||
@@ -123,6 +150,9 @@ pub(super) async fn maybe_auto_compress(
|
||||
// 切片前按当时实际长度钳制,防 index out of bounds panic
|
||||
// (后续 compress_old_messages 内部自带 min(len) 钳制,口径一致)。
|
||||
let msgs = conv.messages.messages_mut();
|
||||
// 939⑪ 内容版本哨兵:快照触发时首条消息 id,后台 task 提交前比对
|
||||
// (clear() 重建后闭包内提交会落到新上下文,靠此守卫丢弃)。
|
||||
let head_id = msgs.first().and_then(|t| t.message.id.clone());
|
||||
let cut = protect_start.min(msgs.len());
|
||||
let active_msgs: Vec<ChatMessage> = msgs[..cut]
|
||||
.iter()
|
||||
@@ -131,7 +161,7 @@ pub(super) async fn maybe_auto_compress(
|
||||
.collect();
|
||||
let lang = conv.agent_language.clone()
|
||||
.unwrap_or_else(|| "zh-CN".to_string());
|
||||
(active_msgs, lang)
|
||||
(active_msgs, lang, head_id)
|
||||
};
|
||||
|
||||
// T2-修复(方案A):展开 namespace 引用为真实内容,再喂压缩 LLM/关键词摘要。
|
||||
@@ -203,7 +233,15 @@ pub(super) async fn maybe_auto_compress(
|
||||
let conv_id = conv_id.to_string();
|
||||
let provider_config = provider_config.clone();
|
||||
let llm_concurrency = llm_concurrency.clone();
|
||||
// 939⑫ panic 兜底句柄:内层 async move 会消费 session_arc/conv_id,panic 分支
|
||||
// 需另持一份用于复位 is_compressing(防其永真致压缩永久停摆)。
|
||||
let panic_session = session_arc.clone();
|
||||
let panic_conv_id = conv_id.clone();
|
||||
tokio::spawn(async move {
|
||||
// 939⑫ 整个后台 body 包 catch_unwind(复用 mod.rs::unwind_to_result 薄包装):
|
||||
// panic 以 Err 返回 → 复位 is_compressing + warn,防压缩永久停摆。依赖
|
||||
// panic=unwind(tokio 默认);panic=abort 构建下退化为进程终止(尽力兜底)。
|
||||
let outcome = super::unwind_to_result(async move {
|
||||
// 后台重建 provider(secret::build_provider_for 三步打包:resolve→ensure→build)。
|
||||
// 失败 → 同 LLM 失败降级:复位 is_compressing + 关键词摘要兜底(标 compressed 释放
|
||||
// token + 插关键词),保持「失败→兜底→扣 token」语义——否则 history_tokens 未降,
|
||||
@@ -227,6 +265,7 @@ pub(super) async fn maybe_auto_compress(
|
||||
&keyword_fallback,
|
||||
pre_compress_tokens,
|
||||
"压缩 provider 重建失败",
|
||||
&head_id,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
@@ -256,6 +295,16 @@ pub(super) async fn maybe_auto_compress(
|
||||
return;
|
||||
}
|
||||
let conv = session.conv(&conv_id);
|
||||
// 939⑪ 内容版本守卫:提交前比对触发时快照,不符(期间 clear() 重建致
|
||||
// 上下文整体替换)→ 丢弃压缩结果。不复位 is_compressing(clear 已复位;
|
||||
// 若新会话已启新压缩,此处复位会误伤新压缩信令)。
|
||||
if !version_matches(&head_id, &head_id_of(&mut conv.messages)) {
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
"[ai] 压缩完成但上下文版本已变(疑似 clear 重建),丢弃旧摘要"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let _compressed = conv.messages.compress_old_messages(protect_start);
|
||||
// NL 摘要尾部追加(而非 insert_at(0) 头部插入):头部插入会位移整条
|
||||
// 前缀字节,使 DeepSeek 等自动前缀缓存的 provider 全部缓存失效
|
||||
@@ -284,7 +333,11 @@ pub(super) async fn maybe_auto_compress(
|
||||
// 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。
|
||||
let mut session = session_arc.lock().await;
|
||||
if session.per_conv.contains_key(&conv_id) {
|
||||
session.conv(&conv_id).messages.set_compressing(false);
|
||||
let conv = session.conv(&conv_id);
|
||||
// 939⑪ 版本守卫:上下文已被整体替换 → 不复位(否则误伤新会话信令)。
|
||||
if version_matches(&head_id, &head_id_of(&mut conv.messages)) {
|
||||
conv.messages.set_compressing(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -316,10 +369,21 @@ pub(super) async fn maybe_auto_compress(
|
||||
&keyword_fallback,
|
||||
pre_compress_tokens,
|
||||
"压缩失败",
|
||||
&head_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}) // 闭 catch_unwind 内 async move
|
||||
.await;
|
||||
if let Err(msg) = outcome {
|
||||
reset_compressing_after_panic(&panic_session, &panic_conv_id).await;
|
||||
tracing::warn!(
|
||||
conv_id = %panic_conv_id,
|
||||
error = %msg,
|
||||
"[ai] 后台压缩 task panic,已复位 is_compressing(防压缩永久停摆)"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -341,6 +405,10 @@ pub(super) async fn maybe_auto_compress(
|
||||
///
|
||||
/// conv 已删(per_conv 无条目)→ 直接返回(标志随 conv 销毁无需复位;对齐原 return 语义:
|
||||
/// 防惰性重建污染新 conv)。
|
||||
///
|
||||
/// **内容版本守卫(939⑪)**:`head_id` 为触发时快照的首条消息 id;提交前比对不符(期间
|
||||
/// `clear()` 重建致上下文整体替换)→ 直接返回:既不标 compressed / 不插旧关键词摘要污染
|
||||
/// 新上下文,也不复位 is_compressing(clear 已复位;复位会误伤新会话可能已启的新压缩信令)。
|
||||
async fn keyword_fallback_commit(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
@@ -348,6 +416,7 @@ async fn keyword_fallback_commit(
|
||||
keyword_fallback: &str,
|
||||
pre_tokens: u32,
|
||||
log_head: &str,
|
||||
head_id: &Option<String>,
|
||||
) {
|
||||
let inserted = {
|
||||
let mut session = session_arc.lock().await;
|
||||
@@ -355,6 +424,15 @@ async fn keyword_fallback_commit(
|
||||
return;
|
||||
}
|
||||
let conv = session.conv(conv_id);
|
||||
// 939⑪ 内容版本守卫:上下文已被整体替换 → 丢弃兜底提交(标志亦不复位)。
|
||||
if !version_matches(head_id, &head_id_of(&mut conv.messages)) {
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
"[ai] {}兜底提交前上下文版本已变(疑似 clear 重建),丢弃",
|
||||
log_head
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 复位与提交同锁原子(909①-补):消「锁外复位→再取锁提交」之间的并发二次压缩窗口。
|
||||
conv.messages.set_compressing(false);
|
||||
if !KEYWORD_FALLBACK_ENABLED {
|
||||
@@ -382,3 +460,123 @@ async fn keyword_fallback_commit(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 建 conv 并灌 n 条 active 消息,置 is_compressing(true)(模拟触发时已置位)。
|
||||
/// 返回触发时的内容版本哨兵(首条消息 id),供 keyword_fallback_commit 的版本守卫比对。
|
||||
async fn seed_compressing(arc: &Arc<Mutex<AiSession>>, conv_id: &str, n: usize) -> Option<String> {
|
||||
let mut session = arc.lock().await;
|
||||
let conv = session.conv(conv_id);
|
||||
for i in 0..n {
|
||||
conv.messages.push(if i % 2 == 0 {
|
||||
ChatMessage::user(format!("用户消息{i}"))
|
||||
} else {
|
||||
ChatMessage::assistant(format!("助手回复{i}"))
|
||||
});
|
||||
}
|
||||
conv.messages.set_compressing(true);
|
||||
head_id_of(&mut conv.messages)
|
||||
}
|
||||
|
||||
/// 非空关键词:复位 is_compressing 与兜底提交(标 compressed + 尾部插锚点)一并生效。
|
||||
#[tokio::test]
|
||||
async fn keyword_fallback_commit_resets_and_inserts() {
|
||||
let arc = Arc::new(Mutex::new(AiSession::new()));
|
||||
let head = seed_compressing(&arc, "c1", 4).await;
|
||||
|
||||
keyword_fallback_commit(&arc, "c1", 4, "关键词锚点", 0, "压缩失败", &head).await;
|
||||
|
||||
let session = arc.lock().await;
|
||||
let conv = session.conv_read("c1").expect("conv 应存在");
|
||||
assert!(!conv.messages.is_compressing(), "提交后 is_compressing 应复位 false");
|
||||
let msgs = conv.messages.all_messages_clone();
|
||||
assert_eq!(msgs.len(), 5, "压缩后应尾部追加 1 条关键词 system 锚点");
|
||||
for (i, m) in msgs[..4].iter().enumerate() {
|
||||
assert!(!m.is_active(), "第 {i} 条原消息应被标 compressed(释放 token)");
|
||||
}
|
||||
assert_eq!(msgs[4].content, "关键词锚点", "末条应为关键词锚点正文");
|
||||
assert!(
|
||||
matches!(msgs[4].role, df_ai::provider::MessageRole::System),
|
||||
"锚点应为 system 角色"
|
||||
);
|
||||
}
|
||||
|
||||
/// 空关键词:复位不能丢(否则 is_compressing 永真 → 压缩永久停摆),但不插锚点。
|
||||
#[tokio::test]
|
||||
async fn keyword_fallback_commit_empty_keeps_reset() {
|
||||
let arc = Arc::new(Mutex::new(AiSession::new()));
|
||||
let head = seed_compressing(&arc, "c2", 4).await;
|
||||
|
||||
keyword_fallback_commit(&arc, "c2", 4, "", 0, "压缩 provider 重建失败", &head).await;
|
||||
|
||||
let session = arc.lock().await;
|
||||
let conv = session.conv_read("c2").expect("conv 应存在");
|
||||
assert!(!conv.messages.is_compressing(), "关键词为空仍应复位 is_compressing");
|
||||
assert_eq!(conv.messages.len(), 4, "关键词为空不应插锚点,len 不变");
|
||||
for m in conv.messages.all_messages_clone() {
|
||||
assert!(!m.is_active(), "空关键词路径仍应标 compressed 扣 token");
|
||||
}
|
||||
assert_eq!(conv.messages.history_tokens(), 0, "无锚点插入,token 应清零");
|
||||
}
|
||||
|
||||
/// conv 已删(per_conv 无条目):早返不惰性重建(防污染新 conv)。
|
||||
#[tokio::test]
|
||||
async fn keyword_fallback_commit_missing_conv_noop() {
|
||||
let arc = Arc::new(Mutex::new(AiSession::new()));
|
||||
|
||||
keyword_fallback_commit(&arc, "gone", 4, "锚点", 0, "压缩失败", &None).await;
|
||||
|
||||
let session = arc.lock().await;
|
||||
assert!(
|
||||
session.per_conv.is_empty(),
|
||||
"conv 已删不应惰性重建 per_conv 条目"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 939⑪⑫ 后台压缩守卫契约:纯函数级断言(版本不符→丢弃 / panic→标志复位)。
|
||||
#[cfg(test)]
|
||||
mod guard_tests {
|
||||
use super::{head_id_of, reset_compressing_after_panic, version_matches, AiSession};
|
||||
use df_ai::context::{ContextConfig, ContextManager};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
// 939⑪:内容版本不符(首条 id 变化 / 列表被清空 / 空→非空)即判定丢弃。
|
||||
#[test]
|
||||
fn version_guard_discards_on_head_change() {
|
||||
let a = Some("msg_a".to_string());
|
||||
let b = Some("msg_b".to_string());
|
||||
assert!(version_matches(&a, &a), "同版本必须放行");
|
||||
assert!(!version_matches(&a, &b), "首条 id 变(clear 重建)必须丢弃");
|
||||
assert!(!version_matches(&a, &None), "列表被清空必须丢弃");
|
||||
assert!(!version_matches(&None, &a), "空→非空(新会话)必须丢弃");
|
||||
}
|
||||
|
||||
// 939⑪:空列表的内容版本哨兵为 None。
|
||||
#[test]
|
||||
fn head_id_of_empty_list_is_none() {
|
||||
let mut mgr = ContextManager::new(ContextConfig::default());
|
||||
assert_eq!(head_id_of(&mut mgr), None);
|
||||
}
|
||||
|
||||
// 939⑫:后台 body panic → is_compressing 被复位,压缩不停摆。
|
||||
#[tokio::test]
|
||||
async fn panic_resets_compressing_flag() {
|
||||
let session = Arc::new(Mutex::new(AiSession::new()));
|
||||
let conv_id = "conv-panic";
|
||||
session.lock().await.conv(conv_id).messages.set_compressing(true);
|
||||
// 模拟后台 body panic,经 catch_unwind 薄包装转受控 Err
|
||||
let r = super::super::unwind_to_result(async { panic!("boom in compress task") }).await;
|
||||
assert!(r.is_err(), "panic 应被捕获为 Err");
|
||||
// panic 分支:复位 is_compressing
|
||||
reset_compressing_after_panic(&session, conv_id).await;
|
||||
assert!(
|
||||
!session.lock().await.conv_read(conv_id).unwrap().messages.is_compressing(),
|
||||
"panic 后 is_compressing 必须复位,否则压缩永久停摆"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ use super::conversation::{save_conversation, TokenAccumulator};
|
||||
use super::knowledge_inject::{maybe_spawn_extraction};
|
||||
use super::title::{ensure_conversation_title, spawn_ensure_title};
|
||||
use super::{AiChatEvent, AiSession, ErrorType};
|
||||
// B5a 挂钟闸分任务定级:轮间闸读会话策略(未注册落 legacy 硬停,确定性任务检查点续跑)。
|
||||
use super::unattended::{wall_clock_policy_for, WallClockAction};
|
||||
// ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。
|
||||
|
||||
/// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。
|
||||
@@ -102,6 +104,8 @@ pub const ROUND_REENTRY_BACKOFF_SECS: [u64; ROUND_REENTRY_MAX] = [60, 300, 900];
|
||||
/// 无人值守长任务(max_iterations=0 不限轮次)缺 token/挂钟兜底——模型死循环级输出
|
||||
/// 白烧预算无人踩闸。三闸在每轮头检查(轮间粒度,单轮内不中断):
|
||||
/// ①挂钟:超 UNATTENDED_WALL_SECS(默认 30min,对齐委托任务超时底座)停靠 wall_clock;
|
||||
/// 未注册分档的会话落此 legacy 阈值;已注册会话由 unattended::wall_clock_policy_for 的
|
||||
/// 策略决定(确定性任务放宽+检查点续跑,探索性/失控疑似硬停),见下方挂钟闸;
|
||||
/// ②累计 token:超 UNATTENDED_MAX_TOKENS(默认 5M)停靠 token_budget;
|
||||
/// ③轮次:max_iterations>0 既有 for 上界 + 达上限停靠分支,不重复设闸。
|
||||
/// 超限走既有 emit_task_parked——前端词表已预留,零新契约。
|
||||
@@ -1163,8 +1167,9 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 无注入扩轮时动态判定与原上界等价,行为零变化。扩轮总数封顶防注入洪水无限续跑。
|
||||
let mut injected_extra_rounds: usize = 0;
|
||||
|
||||
// H2 token 三闸基点:loop 启动挂钟起点(轮间粒度检查,单轮不中断)
|
||||
let loop_started = tokio::time::Instant::now();
|
||||
// H2 token 三闸基点:loop 启动挂钟起点(轮间粒度检查,单轮不中断)。
|
||||
// 可变:确定性任务到点走检查点续跑时顺延起点(见下方挂钟闸)。
|
||||
let mut loop_started = tokio::time::Instant::now();
|
||||
|
||||
// H3 外部干预感知基点:loop 启动时在被监视工作区取 git HEAD 快照(轮间粒度比对)。
|
||||
// 设计口径:loop_epoch 管「引擎内接管」,H3 管「引擎外干预」——用户/另一 AI 会话在 loop
|
||||
@@ -1219,10 +1224,30 @@ pub(crate) async fn run_agentic_loop(
|
||||
{
|
||||
let elapsed = loop_started.elapsed().as_secs();
|
||||
let total_tokens = tokens.total() as u64;
|
||||
let over = if elapsed >= UNATTENDED_WALL_SECS {
|
||||
// ── 挂钟闸(B5a):读会话策略决定阈值与到点动作 ──
|
||||
// None(未注册/分档关)→ legacy 30min 硬停,零行为变更;
|
||||
// CheckpointContinue(确定性任务)→ 到点存进度 + 顺延挂钟起点后本轮续跑,不停工;
|
||||
// HardPark(探索性/失控疑似)→ 维持原 emit_task_parked(wall_clock) + teardown。
|
||||
let wall_policy = wall_clock_policy_for(&conv_id);
|
||||
let wall_deadline = wall_policy.map_or(UNATTENDED_WALL_SECS, |p| p.deadline_secs);
|
||||
let mut wall_exceeded = elapsed >= wall_deadline;
|
||||
if wall_exceeded
|
||||
&& matches!(wall_policy.map(|p| p.action), Some(WallClockAction::CheckpointContinue))
|
||||
{
|
||||
// 检查点续跑:落库当前进度(不等 loop 出口),挂钟起点顺延,本轮继续。
|
||||
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
|
||||
save_conversation(&session_arc, &db, &conv_id, Some(&save_usage), Some(&resolved_model), true).await;
|
||||
loop_started = tokio::time::Instant::now();
|
||||
wall_exceeded = false;
|
||||
tracing::info!(
|
||||
conv_id = %conv_id, elapsed, deadline = wall_deadline,
|
||||
"[ai] 挂钟到点,检查点续跑(确定性任务)"
|
||||
);
|
||||
}
|
||||
let over = if wall_exceeded {
|
||||
Some((
|
||||
"wall_clock",
|
||||
format!("任务运行超 {} 分钟,安全停靠:已完成进度已保存,点继续或重发消息可续跑", UNATTENDED_WALL_SECS / 60),
|
||||
format!("任务运行超 {} 分钟,安全停靠:已完成进度已保存,点继续或重发消息可续跑", wall_deadline / 60),
|
||||
))
|
||||
} else if total_tokens >= UNATTENDED_MAX_TOKENS {
|
||||
Some((
|
||||
@@ -1364,9 +1389,13 @@ pub(crate) async fn run_agentic_loop(
|
||||
"[ai] 连续 {} 轮失败,重入预算耗尽,任务停靠",
|
||||
consecutive_round_failures,
|
||||
);
|
||||
// 停靠语义:超时类(上游收了请求不响应,非确定性)与其他 Fatal 走
|
||||
// 不同文案——超时突出「可继续」,由停靠卡「继续」一键续跑而非中断;
|
||||
// 其余 Fatal 维持原连续失败文案(确定性错误,续跑无益)。
|
||||
let reason = fatal_park_reason(consecutive_round_failures, &error);
|
||||
crate::commands::ai::emit_task_parked(
|
||||
&app_handle, &conv_id,
|
||||
&format!("模型调用连续 {} 轮失败(重入退避后仍未恢复),任务安全停靠:{}", consecutive_round_failures, error),
|
||||
&reason,
|
||||
"fatal",
|
||||
);
|
||||
return;
|
||||
@@ -2778,6 +2807,34 @@ fn classify_permanent_provider_error(error: &str) -> bool {
|
||||
model_missing || no_access || param_reject
|
||||
}
|
||||
|
||||
// ── is_timeout_error: 超时类失败判定(首字节超时/流式超时/调用超时) ──
|
||||
// 超时是「上游收了请求却不响应」的非确定性失败:重入退避后仍未恢复时,续跑可能因上游恢复
|
||||
// 而成功,故落停靠语义给一键「继续」,而非让用户只见裸错误文案后中断。与之相对,4xx/鉴权/
|
||||
// 参数类 Fatal 是确定性错误(改配置才有解,续跑无益),不套用本判定——判定只读错误文本的
|
||||
// 超时特征(引擎各超时出口文案含「超时」或 diag 包装的 timeout 分类)。
|
||||
fn is_timeout_error(error: &str) -> bool {
|
||||
error.to_lowercase().contains("timeout") || error.contains("超时")
|
||||
}
|
||||
|
||||
/// 重入预算耗尽时的停靠文案决策(纯函数,便于单测锁定「超时类 → 停靠」契约)。
|
||||
///
|
||||
/// 超时类走「超时 + 可续跑」文案(点明可一键继续);其余 Fatal 维持原「连续失败」文案。
|
||||
/// stop_kind 均用既有 "fatal"(不新增停靠族:前端 stop_kind 词表/i18n 未同步,新值会退化
|
||||
/// 为中性兜底标签;超时与其他 Fatal 的区分落在文案语义,卡片「继续」按钮两者皆已具备)。
|
||||
fn fatal_park_reason(failures: usize, error: &str) -> String {
|
||||
if is_timeout_error(error) {
|
||||
format!(
|
||||
"模型响应连续 {} 轮超时(上游收了请求未及时响应,重入退避后仍未恢复),任务安全停靠:已完成进度已保存,点「继续」可基于当前上下文续跑",
|
||||
failures
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"模型调用连续 {} 轮失败(重入退避后仍未恢复),任务安全停靠:{}",
|
||||
failures, error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── emit_fatal_error: 致命错误统一 emit AiError(扁平抽自原 Fatal/Exhausted 双分支重复代码) ──
|
||||
// 双写经 emit_ai_chat_event。error_type: 可判定的永久性 provider 错误(模型不存在/令牌无权/
|
||||
// 参数拒绝类 400)归 ProviderConfig,其余维持 Network 现状(2026-10-01 两端统一时的口径)。
|
||||
@@ -3502,6 +3559,56 @@ mod round_reentry_tests {
|
||||
}
|
||||
}
|
||||
|
||||
// 902 契约:超时类失败在轮次重入预算耗尽时落停靠语义(而非只留裸错误文案中断)。
|
||||
// 停靠文案由纯函数 fatal_park_reason 决策,本测试锁「超时类 → 超时+可续跑文案 /
|
||||
// 非超时 Fatal → 原连续失败文案」两分支(完整 loop 依赖 Tauri 运行时,契约由纯函数锁定)。
|
||||
#[cfg(test)]
|
||||
mod fatal_park_tests {
|
||||
use super::{fatal_park_reason, is_timeout_error};
|
||||
|
||||
#[test]
|
||||
fn timeout_class_recognized() {
|
||||
// 首字节超时(stream_recv 出口文案,全角括号)
|
||||
assert!(is_timeout_error(
|
||||
"流式响应超时(10秒未收到首字节,服务无响应或请求体过大)"
|
||||
));
|
||||
// 专用线程 120s 硬超时
|
||||
assert!(is_timeout_error("LLM 调用超时(120s)"));
|
||||
// diag 包装的英文分类(timeout)
|
||||
assert!(is_timeout_error(
|
||||
"[u-switch] 流式接收错误(timeout): request timed out"
|
||||
));
|
||||
// 非超时:4xx/鉴权/模型不存在不误判
|
||||
assert!(!is_timeout_error(
|
||||
"[x] AI 调用失败(HTTP 401): unauthorized"
|
||||
));
|
||||
assert!(!is_timeout_error("model is not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_exhaustion_parks_with_continue_guidance() {
|
||||
let reason = fatal_park_reason(4, "LLM 调用超时(120s)");
|
||||
assert!(reason.contains("超时"), "超时类停靠文案须点明超时: {reason}");
|
||||
assert!(
|
||||
reason.contains("继续"),
|
||||
"超时类停靠文案须给一键「继续」指引: {reason}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_timeout_fatal_keeps_generic_reason() {
|
||||
let reason = fatal_park_reason(4, "[x] AI 调用失败(HTTP 404): model not found");
|
||||
assert!(
|
||||
reason.contains("连续 4 轮失败"),
|
||||
"非超时 Fatal 维持原连续失败文案: {reason}"
|
||||
);
|
||||
assert!(
|
||||
reason.contains("model not found"),
|
||||
"原诊断文本须保留: {reason}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 末轮注入扩轮(todo.md 28)loop 侧契约:到达原轮次终点时,注入侧顺延的余量必须被消费
|
||||
// 续跑(「末轮注入→有续轮」),无余量则收尾(与原 for 自然结束等价,零行为变化)。
|
||||
// 与注入侧判定 injection_hits_final_round(commands/ai/commands/chat.rs)分半锁同一契约。
|
||||
|
||||
@@ -182,7 +182,7 @@ pub(super) async fn insert_pending_approval(
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
emit_ai_chat_event(&app_handle, ev);
|
||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), current_message_id).await;
|
||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), current_message_id, None).await;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ pub(super) async fn insert_pending_approval(
|
||||
"等待工具审批",
|
||||
&format!("工具 {} 已暂停,批准后继续执行", draft.name),
|
||||
);
|
||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await;
|
||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id, None).await;
|
||||
}
|
||||
|
||||
/// (容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 (会话, 工具, 参数) 重试计数。
|
||||
@@ -366,7 +366,7 @@ pub(super) async fn handle_approval_tool(
|
||||
};
|
||||
emit_ai_chat_event(&app_handle, ev);
|
||||
// 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,;decided_by=auto_dedup),不进 pending
|
||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(warned_content), Some("auto_dedup"), current_message_id).await;
|
||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(warned_content), Some("auto_dedup"), current_message_id, None).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! re-export 经 audit/mod.rs `pub(crate) use finalize::audit_finalize;` 保持
|
||||
//! `commands::ai::audit::*` 路径透明(调用方零变更)。
|
||||
|
||||
use crate::commands::ai::tool_result_offload;
|
||||
use crate::commands::now_millis;
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -12,7 +13,13 @@ use crate::state::AppState;
|
||||
/// 走专用 find_by_tool_call_id —— 通用 query 宏硬编码 ORDER BY created_at DESC,
|
||||
/// 而 ai_tool_executions 无该列,调用会报 "no such column: created_at" 被 unwrap_or_default 吞掉,
|
||||
/// 导致审批后审计记录永久卡 pending。
|
||||
pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status: &str, result: Option<String>) {
|
||||
pub(crate) async fn audit_finalize(
|
||||
state: &AppState,
|
||||
tool_call_id: &str,
|
||||
status: &str,
|
||||
result: Option<String>,
|
||||
duration_ms: Option<i64>,
|
||||
) {
|
||||
// 区分 Err(DB 故障)与 Ok(None)(真无记录):原 unwrap_or_default 把 Err 压成 None,
|
||||
// DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索。
|
||||
let mut rec = match state.ai_tool_executions.find_by_tool_call_id(tool_call_id).await {
|
||||
@@ -29,8 +36,13 @@ pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status:
|
||||
rec.status = status.to_string();
|
||||
rec.decided_by = Some("human".to_string());
|
||||
rec.executed_at = Some(now_millis());
|
||||
// V50 纯执行耗时:审批路径由调用方(ai_approve/ai_authorize_dir)在真执行窗口测量后传入,
|
||||
// 与「requested_at→executed_at」(含等待人工批准)解耦。拒绝/超时未执行传 None。
|
||||
rec.duration_ms = duration_ms;
|
||||
if let Some(r) = result {
|
||||
rec.result = Some(r);
|
||||
// 与 audit_tool_call 的写侧一致:超阈值结果外置落盘,DB 只留句柄,防审批路径大结果
|
||||
// 绕过外置直接内联落库(工具真正执行发生在审批通过之后,这条才是大结果主入口)。
|
||||
rec.result = Some(tool_result_offload::offload_result(tool_call_id, &rec.tool_name, &r));
|
||||
}
|
||||
if let Err(e) = state.ai_tool_executions.update_full(&rec).await {
|
||||
tracing::error!("audit_finalize: 回填 tool_call_id={} 审计状态失败: {}", tool_call_id, e);
|
||||
|
||||
@@ -164,7 +164,7 @@ async fn deny_sub_agent_tool(
|
||||
};
|
||||
emit_ai_chat_event(app_handle, ev);
|
||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
"rejected", risk_level, Some(msg), Some("sub_agent_denied"), current_message_id).await;
|
||||
"rejected", risk_level, Some(msg), Some("sub_agent_denied"), current_message_id, None).await;
|
||||
}
|
||||
|
||||
/// 任务边界拒绝单点(无人值守,设计 §三):结构化失败 tool_result + emit Completed + 审计留痕。
|
||||
@@ -212,7 +212,7 @@ async fn deny_boundary_tool(
|
||||
};
|
||||
emit_ai_chat_event(app_handle, ev);
|
||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
"rejected", risk_level, Some(msg), Some(layer), current_message_id).await;
|
||||
"rejected", risk_level, Some(msg), Some(layer), current_message_id, None).await;
|
||||
}
|
||||
|
||||
/// P2-1 执行期白名单判定(纯函数可单测):SubAgent 策略下工具是否被白名单裁掉。
|
||||
@@ -615,6 +615,7 @@ async fn cancel_expired_approval(
|
||||
tc_id,
|
||||
"rejected",
|
||||
Some(format!("审批超时({}分钟)自动取消", timeout_minutes)),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
// 5. 落库会话状态变更(占位消息替换后需持久化,防重启后丢失)
|
||||
@@ -747,7 +748,7 @@ pub(crate) async fn process_tool_calls(
|
||||
emit_ai_chat_event(&app_handle, ev);
|
||||
let risk = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
"failed", risk, Some(err_payload.clone()), Some("auto_resolve_fail"), current_message_id).await;
|
||||
"failed", risk, Some(err_payload.clone()), Some("auto_resolve_fail"), current_message_id, None).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -893,7 +894,7 @@ pub(crate) async fn process_tool_calls(
|
||||
audit_tool_call(
|
||||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
"rejected", risk_level, Some(err_msg), Some("auto_blacklist"),
|
||||
current_message_id,
|
||||
current_message_id, None,
|
||||
).await;
|
||||
}
|
||||
|
||||
@@ -952,7 +953,7 @@ pub(crate) async fn process_tool_calls(
|
||||
audit_tool_call(
|
||||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
"skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"),
|
||||
current_message_id,
|
||||
current_message_id, None,
|
||||
).await;
|
||||
continue;
|
||||
}
|
||||
@@ -991,7 +992,7 @@ pub(crate) async fn process_tool_calls(
|
||||
audit_tool_call(
|
||||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
"pending", risk_level, None, None,
|
||||
current_message_id,
|
||||
current_message_id, None,
|
||||
).await;
|
||||
}
|
||||
|
||||
@@ -1126,13 +1127,16 @@ pub(crate) async fn process_tool_calls(
|
||||
// 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间。P0 重构后锁外并行,根本消除阻塞。
|
||||
// join_all 保序——结果顺序 = trust_hits 输入顺序 = tc_list 原始 index 顺序,不额外 sort
|
||||
if !trust_hits.is_empty() {
|
||||
let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>)> =
|
||||
let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>, i64)> =
|
||||
futures::future::join_all(trust_hits.into_iter().map(|(draft, args, _dir_label, risk_level)| {
|
||||
let tools = tools_arc.clone();
|
||||
let app_clone = app_handle.clone();
|
||||
let conv_clone = conv_id.to_string();
|
||||
async move {
|
||||
// V50 纯执行耗时:本闭包内逐工具测真执行墙钟(并行下各工具独立,非批 wall)。
|
||||
let exec_started = std::time::Instant::now();
|
||||
let exec_result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await;
|
||||
let exec_ms = exec_started.elapsed().as_millis() as i64;
|
||||
match exec_result {
|
||||
Ok(val) => {
|
||||
let content = val.to_string();
|
||||
@@ -1146,7 +1150,7 @@ pub(crate) async fn process_tool_calls(
|
||||
// 数据变更类工具执行成功后 emit df-data-changed 联动刷新
|
||||
// (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop)
|
||||
emit_data_changed(&app_clone, &draft.name);
|
||||
(draft, risk_level, Ok(content))
|
||||
(draft, risk_level, Ok(content), exec_ms)
|
||||
}
|
||||
Err(e) => {
|
||||
let content = e.to_string();
|
||||
@@ -1157,7 +1161,7 @@ pub(crate) async fn process_tool_calls(
|
||||
conversation_id: Some(conv_clone),
|
||||
};
|
||||
emit_ai_chat_event(&app_clone, ev);
|
||||
(draft, risk_level, Err(content))
|
||||
(draft, risk_level, Err(content), exec_ms)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1165,7 +1169,7 @@ pub(crate) async fn process_tool_calls(
|
||||
|
||||
|
||||
// 串行回填 tool_result + 审计(短 lock push + 锁外 audit)
|
||||
for (draft, risk_level, outcome) in results {
|
||||
for (draft, risk_level, outcome, exec_ms) in results {
|
||||
let (status, content) = match outcome {
|
||||
Ok(c) => ("completed", c),
|
||||
Err(c) => ("failed", c),
|
||||
@@ -1193,7 +1197,7 @@ pub(crate) async fn process_tool_calls(
|
||||
}
|
||||
}
|
||||
// 锁外:审计(trust 放行仍记一条,decided_by=auto_trust),留痕可追溯
|
||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some("auto_trust"), current_message_id).await;
|
||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some("auto_trust"), current_message_id, Some(exec_ms)).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1256,7 +1260,7 @@ pub(crate) async fn process_tool_calls(
|
||||
audit_tool_call(
|
||||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
"completed", risk_level, Some(warned_content), Some("cache_hit"),
|
||||
current_message_id,
|
||||
current_message_id, None,
|
||||
).await;
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
@@ -1272,13 +1276,16 @@ pub(crate) async fn process_tool_calls(
|
||||
}
|
||||
let low_risk = low_risk_uncached;
|
||||
// 携带 args + 原始 JSON result(create_idea source 补全需解析 result.id + args.source)。
|
||||
let results: Vec<(ToolCallDraft, serde_json::Value, RiskLevel, Result<String, String>)> =
|
||||
let results: Vec<(ToolCallDraft, serde_json::Value, RiskLevel, Result<String, String>, i64)> =
|
||||
futures::future::join_all(low_risk.into_iter().map(|(draft, args, risk_level)| {
|
||||
let tools = tools_arc.clone();
|
||||
let app_clone = app_handle.clone();
|
||||
let conv_clone = conv_id.to_string();
|
||||
async move {
|
||||
// V50 纯执行耗时:闭包内逐工具测真执行墙钟(并行各自独立)。
|
||||
let exec_started = std::time::Instant::now();
|
||||
let result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await;
|
||||
let exec_ms = exec_started.elapsed().as_millis() as i64;
|
||||
match result {
|
||||
Ok(val) => {
|
||||
// L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。
|
||||
@@ -1291,7 +1298,7 @@ pub(crate) async fn process_tool_calls(
|
||||
// AR-11(方案A):数据变更工具执行成功后 emit df-data-changed,
|
||||
// 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。
|
||||
emit_data_changed(&app_clone, &draft.name);
|
||||
(draft, val.clone(), risk_level, Ok(val.to_string()))
|
||||
(draft, val.clone(), risk_level, Ok(val.to_string()), exec_ms)
|
||||
}
|
||||
Err(e) => {
|
||||
// AR-6(定向B):Low 工具失败不 emit AiError。
|
||||
@@ -1307,7 +1314,7 @@ pub(crate) async fn process_tool_calls(
|
||||
conversation_id: Some(conv_clone),
|
||||
};
|
||||
emit_ai_chat_event(&app_clone, ev);
|
||||
(draft, serde_json::Value::Null, risk_level, Err(err_msg))
|
||||
(draft, serde_json::Value::Null, risk_level, Err(err_msg), exec_ms)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1319,7 +1326,7 @@ pub(crate) async fn process_tool_calls(
|
||||
// 砍 N 次串行 INSERT 尾巴(治 aichat 效率走查 T1-1)。审计失败不阻断主流程(记日志)。
|
||||
let mut audit_records: Vec<df_storage::models::AiToolExecutionRecord> =
|
||||
Vec::with_capacity(results.len());
|
||||
for (draft, raw_result, risk_level, outcome) in results {
|
||||
for (draft, raw_result, risk_level, outcome, exec_ms) in results {
|
||||
let (status, content) = match outcome {
|
||||
Ok(c) => ("completed", c),
|
||||
Err(c) => ("failed", c),
|
||||
@@ -1370,7 +1377,7 @@ pub(crate) async fn process_tool_calls(
|
||||
}
|
||||
audit_records.push(build_audit_record(
|
||||
conv_id, &draft.id, &draft.name, &draft.args, status, risk_level,
|
||||
Some(content), Some(decided_by), current_message_id,
|
||||
Some(content), Some(decided_by), current_message_id, Some(exec_ms),
|
||||
));
|
||||
}
|
||||
// 单事务批量 INSERT(一次 spawn_blocking,砍 N 次串行尾巴;失败仅记日志不阻断)。
|
||||
@@ -1422,7 +1429,7 @@ pub(crate) async fn process_tool_calls(
|
||||
dupe_audit_records.push(build_audit_record(
|
||||
conv_id, &draft.id, &draft.name, &draft.args,
|
||||
"completed", risk_level, Some(warned_content), Some("cache_hit"),
|
||||
current_message_id,
|
||||
current_message_id, None,
|
||||
));
|
||||
}
|
||||
if !dupe_audit_records.is_empty() {
|
||||
@@ -1450,13 +1457,17 @@ pub(crate) async fn process_tool_calls(
|
||||
"[ai] 任务边界放行 {} 个工具进入并行执行(decided_by=boundary_auto)",
|
||||
boundary_auto.len()
|
||||
);
|
||||
let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>)> =
|
||||
let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>, i64)> =
|
||||
futures::future::join_all(boundary_auto.into_iter().map(|(draft, args, risk_level)| {
|
||||
let tools = tools_arc.clone();
|
||||
let app_clone = app_handle.clone();
|
||||
let conv_clone = conv_id.to_string();
|
||||
async move {
|
||||
match execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await {
|
||||
// V50 纯执行耗时:闭包内逐工具测真执行墙钟。
|
||||
let exec_started = std::time::Instant::now();
|
||||
let exec_result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await;
|
||||
let exec_ms = exec_started.elapsed().as_millis() as i64;
|
||||
match exec_result {
|
||||
Ok(val) => {
|
||||
// L3 emit 双写:边界放行工具执行成功 emit Completed 双路发布。
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
@@ -1466,7 +1477,7 @@ pub(crate) async fn process_tool_calls(
|
||||
};
|
||||
emit_ai_chat_event(&app_clone, ev);
|
||||
emit_data_changed(&app_clone, &draft.name);
|
||||
(draft, risk_level, Ok(val.to_string()))
|
||||
(draft, risk_level, Ok(val.to_string()), exec_ms)
|
||||
}
|
||||
Err(e) => {
|
||||
// AR-6 同款:失败包进 tool_result 让 LLM 自纠,不 emit AiError 不断轮。
|
||||
@@ -1477,13 +1488,13 @@ pub(crate) async fn process_tool_calls(
|
||||
conversation_id: Some(conv_clone),
|
||||
};
|
||||
emit_ai_chat_event(&app_clone, ev);
|
||||
(draft, risk_level, Err(err_msg))
|
||||
(draft, risk_level, Err(err_msg), exec_ms)
|
||||
}
|
||||
}
|
||||
}
|
||||
})).await;
|
||||
|
||||
for (draft, risk_level, outcome) in results {
|
||||
for (draft, risk_level, outcome, exec_ms) in results {
|
||||
let (status, content) = match outcome {
|
||||
Ok(c) => ("completed", c),
|
||||
Err(c) => ("failed", c),
|
||||
@@ -1507,7 +1518,7 @@ pub(crate) async fn process_tool_calls(
|
||||
// 锁外审计:risk 真实等级透传(F-#97 防失真),decided_by=boundary_auto
|
||||
// 标注授权来自任务边界(无人值守事后可全量追溯)。
|
||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
status, risk_level, Some(content), Some("boundary_auto"), current_message_id).await;
|
||||
status, risk_level, Some(content), Some("boundary_auto"), current_message_id, Some(exec_ms)).await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ pub(crate) fn build_audit_record(
|
||||
result: Option<String>,
|
||||
decided_by: Option<&str>,
|
||||
message_id: Option<&str>,
|
||||
duration_ms: Option<i64>,
|
||||
) -> AiToolExecutionRecord {
|
||||
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
|
||||
// D2a 结果外置:超 8KB 的审计 result 落盘留句柄,治 DB 膨胀(devflow.db 193.7MB 实证)。
|
||||
@@ -66,6 +67,10 @@ pub(crate) fn build_audit_record(
|
||||
requested_at: now_millis(),
|
||||
executed_at,
|
||||
decided_by: decided_by.map(|s| s.to_string()),
|
||||
// V50 纯执行耗时:由调用方在真执行路径测量后传入(执行前→执行后墙钟);
|
||||
// 未真执行(deny/pending/缓存命中)传 None。与 requested_at→executed_at 解耦,
|
||||
// 后者在审批路径混入等待人工批准时长。
|
||||
duration_ms,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,10 +93,11 @@ pub(crate) async fn audit_tool_call(
|
||||
result: Option<String>,
|
||||
decided_by: Option<&str>,
|
||||
message_id: Option<&str>,
|
||||
duration_ms: Option<i64>,
|
||||
) {
|
||||
let record = build_audit_record(
|
||||
conv_id, tool_call_id, tool_name, arguments,
|
||||
status, risk_level, result, decided_by, message_id,
|
||||
status, risk_level, result, decided_by, message_id, duration_ms,
|
||||
);
|
||||
if let Err(e) = repo.insert(record).await {
|
||||
tracing::error!(
|
||||
@@ -119,7 +125,8 @@ pub(crate) async fn record_audit(
|
||||
decided_by: Option<&str>,
|
||||
message_id: Option<&str>,
|
||||
) {
|
||||
audit_tool_call(repo, conv_id, tool_call_id, tool_name, arguments, status, risk_level, result, decided_by, message_id).await;
|
||||
// 事件总线旁路(无真执行计时),duration_ms 传 None。
|
||||
audit_tool_call(repo, conv_id, tool_call_id, tool_name, arguments, status, risk_level, result, decided_by, message_id, None).await;
|
||||
}
|
||||
|
||||
/// 查询审计历史记录(分页,按 requested_at 倒序)。
|
||||
@@ -159,6 +166,10 @@ pub struct ToolExecutionDto {
|
||||
pub executed_at: Option<String>,
|
||||
/// human/auto,None 表示尚未决策
|
||||
pub decided_by: Option<String>,
|
||||
/// 纯执行耗时(毫秒,V50):真执行墙钟,与审批等待解耦;None=未执行/老数据。
|
||||
/// 前端审计面板可用此替代 executed_at-requested_at(后者含审批等待)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration_ms: Option<i64>,
|
||||
}
|
||||
|
||||
/// 审批历史查询入参(前端透传,空值=不过滤)。
|
||||
@@ -247,6 +258,7 @@ pub async fn list_tool_executions(
|
||||
requested_at: r.requested_at,
|
||||
executed_at: r.executed_at,
|
||||
decided_by: r.decided_by,
|
||||
duration_ms: r.duration_ms,
|
||||
})
|
||||
.collect();
|
||||
let has_more = (offset as i64 + items.len() as i64) < total;
|
||||
@@ -522,6 +534,7 @@ mod tests {
|
||||
requested_at: t.to_string(),
|
||||
executed_at: None,
|
||||
decided_by: None,
|
||||
duration_ms: None,
|
||||
})
|
||||
.await
|
||||
.expect("测试数据插入应成功");
|
||||
@@ -591,6 +604,7 @@ mod tests {
|
||||
requested_at: t.to_string(),
|
||||
executed_at: Some(t.to_string()),
|
||||
decided_by: Some("auto".to_string()),
|
||||
duration_ms: None,
|
||||
})
|
||||
.await
|
||||
.expect("测试数据插入应成功");
|
||||
@@ -618,6 +632,7 @@ mod tests {
|
||||
origin: origin.map(|s| s.to_string()),
|
||||
boundary_json: None,
|
||||
loop_state: None,
|
||||
status: "active".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("测试会话插入应成功");
|
||||
|
||||
@@ -14,7 +14,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
|
||||
// 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。
|
||||
// 池空/无匹配兜底 default_model(与 title.rs 一致,行为可预期)。
|
||||
use df_ai::router::{
|
||||
is_likely_image_model, select_model_id, Modality, TaskRequirements,
|
||||
is_likely_image_model, select_model_id, Modality, ModelConfig, TaskRequirements,
|
||||
};
|
||||
use df_storage::models::AiProviderRecord;
|
||||
|
||||
@@ -72,15 +72,20 @@ pub(crate) async fn compress_via_llm(
|
||||
let active_msgs = df_ai::context::ContextManager::sanitize_messages(active_msgs);
|
||||
|
||||
// 路由选模型:无 tool_use(纯文本摘要)。
|
||||
// None(池空/无匹配)→ 兜底 default_model(行为不变)。
|
||||
// None(池空/无匹配)→ 兜底 default_model,但须校验其 enabled(停用则回落池内启用模型,
|
||||
// 见 resolve_fallback_model);池内无任何启用模型 → Err 让调用方降级,不再静默用停用模型。
|
||||
let compress_req = TaskRequirements {
|
||||
modalities: vec![Modality::Text],
|
||||
needs_tool_use: false,
|
||||
estimated_context: 0,
|
||||
tier: None,
|
||||
};
|
||||
let model = select_model_id(&compress_req, &provider_config.model_configs)
|
||||
.unwrap_or_else(|| provider_config.default_model.clone());
|
||||
let model = match select_model_id(&compress_req, &provider_config.model_configs) {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
resolve_fallback_model(&provider_config.model_configs, &provider_config.default_model)?
|
||||
}
|
||||
};
|
||||
// 防御:选中/兜底模型是图像模型时返 Err(走关键词摘要兜底,避免文本任务 404 刷屏)。
|
||||
if is_likely_image_model(&model, &provider_config.model_configs) {
|
||||
return Err(format!(
|
||||
@@ -150,6 +155,46 @@ pub(crate) async fn compress_via_llm(
|
||||
})
|
||||
}
|
||||
|
||||
/// `select_model_id` 返 None(池空/无匹配)时的**兜底模型解析**(治 929 次生缺口)。
|
||||
///
|
||||
/// 原兜底直接 `provider_config.default_model.clone()`,不校验该模型的 `enabled` ——
|
||||
/// 用户在设置里停用 default_model 后,压缩/标题仍照用被停用的模型(与 agentic 侧 override 路径
|
||||
/// `resolve_model_with_override` 同源缺口:停用只挡路由,不挡兜底)。此处在 enabled 口径下解析:
|
||||
///
|
||||
/// - **default_model 在池中且启用** → 采用(正常兜底);
|
||||
/// - **default_model 不在池中**(未拉取模型/池空/配置未同步)→ 无法校验,采用 default_model
|
||||
/// (不因无法校验而阻断,保持旧行为);
|
||||
/// - **default_model 在池中被显式停用** → 回落池中任一 enabled 模型(与 `select_model_id`
|
||||
/// 的 enabled 过滤同口径);
|
||||
/// - **池内无任何 enabled 模型** → Err(明确报错,不再静默沿用停用模型,让用户感知配置问题)。
|
||||
///
|
||||
/// 与 `select_model_id` 分工:后者按「需求匹配 + weight」选优;本函数只解决「兜底候选是否被停用」
|
||||
/// 的校验与回落,不重复路由逻辑。抽为纯函数便于单测(零 IO)。
|
||||
pub(crate) fn resolve_fallback_model(
|
||||
pool: &[ModelConfig],
|
||||
default_model: &str,
|
||||
) -> Result<String, String> {
|
||||
// 池中有该条目 → 以其 enabled 为准;无该条目 → 无法校验,不阻断(采用 default_model)。
|
||||
let default_available = pool
|
||||
.iter()
|
||||
.find(|m| m.model_id == default_model)
|
||||
.map(|m| m.enabled)
|
||||
.unwrap_or(true);
|
||||
if default_available {
|
||||
return Ok(default_model.to_string());
|
||||
}
|
||||
// 回落:池中任一 enabled 模型(与 select_model_id 的 enabled 过滤同口径)。
|
||||
pool.iter()
|
||||
.find(|m| m.enabled)
|
||||
.map(|m| m.model_id.clone())
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"无可用模型:default_model「{}」已在模型配置中停用,且池内无任何启用模型(请在设置中启用模型)",
|
||||
default_model
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// 阶段2(占位配对完整性):检测压缩段是否含未闭合占位 tool_result。
|
||||
///
|
||||
/// 占位 tool_result(content 带 __PENDING__ 标记,审批挂起)若无对应 tool_call 头(头被裁/丢)
|
||||
@@ -445,4 +490,47 @@ mod tests {
|
||||
];
|
||||
assert!(!compress_segment_has_unclosed_placeholder(&msgs));
|
||||
}
|
||||
|
||||
// ── 929 次生缺口:resolve_fallback_model(兜底模型须校验 enabled) ──
|
||||
|
||||
/// 构造池内模型(enabled 可控,其余用默认)。
|
||||
fn mc(id: &str, enabled: bool) -> ModelConfig {
|
||||
ModelConfig { enabled, ..ModelConfig::with_defaults(id) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_default_enabled_in_pool_used() {
|
||||
// default_model 在池中且启用 → 采用 default_model
|
||||
let pool = vec![mc("deepseek-chat", true), mc("glm-4-flash", true)];
|
||||
assert_eq!(
|
||||
resolve_fallback_model(&pool, "deepseek-chat").unwrap(),
|
||||
"deepseek-chat"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_default_absent_from_pool_used() {
|
||||
// default_model 不在池中(未拉取/池空)→ 无法校验,不阻断,采用 default_model
|
||||
let empty: Vec<ModelConfig> = vec![];
|
||||
assert_eq!(resolve_fallback_model(&empty, "legacy-model").unwrap(), "legacy-model");
|
||||
let pool = vec![mc("glm-4-flash", true)];
|
||||
assert_eq!(resolve_fallback_model(&pool, "legacy-model").unwrap(), "legacy-model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_default_disabled_falls_back_to_enabled_pool_model() {
|
||||
// default_model 在池中被停用 → 回落池中任一 enabled 模型(不静默沿用停用模型)
|
||||
let pool = vec![mc("stopped-model", false), mc("glm-4-flash", true)];
|
||||
let got = resolve_fallback_model(&pool, "stopped-model").unwrap();
|
||||
assert_eq!(got, "glm-4-flash", "停用的 default_model 应回落启用模型");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_default_disabled_and_no_enabled_errors() {
|
||||
// default_model 停用 + 池内无任何启用模型 → 明确报错(而非静默用停用模型)
|
||||
let pool = vec![mc("stopped-model", false), mc("also-stopped", false)];
|
||||
let err = resolve_fallback_model(&pool, "stopped-model").unwrap_err();
|
||||
assert!(err.contains("stopped-model"), "错误信息应含被停用的 default_model 名便于定位");
|
||||
assert!(err.contains("停用"), "错误信息应说明停用原因");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,10 +84,69 @@ pub const DEFAULT_BUS_CAPACITY: usize = 256;
|
||||
/// 事件值语义:emit 与 publish 收到**同一份事件值**(内部 clone),对齐原手抄 `ev.clone()` 两行式。
|
||||
/// 消费 ev(调用方无需再 clone)。
|
||||
pub(crate) fn emit_ai_chat_event(app: &AppHandle, ev: AiChatEvent) {
|
||||
// 子代理运行期事件改挂(见 CONV_ROUTE_OVERRIDE):作用域内命中来源会话的展示类事件
|
||||
// 改挂目标会话再双写;未进作用域或未命中时保持原双写,常规路径零行为变化。
|
||||
if let Ok((from, to)) = CONV_ROUTE_OVERRIDE.try_with(|r| r.clone()) {
|
||||
if let Some(v) = route_conv_event(&ev, &from, &to) {
|
||||
let _ = app.emit("ai-chat-event", v.clone());
|
||||
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish(v);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
|
||||
// 子代理运行期事件路由覆写(2026-10-10)。
|
||||
//
|
||||
// spawn_agent 子循环运行期,子会话(from)的运行事件默认挂子会话 conv 推送,父会话视图收不
|
||||
// 到子代理的实时进展(切回父会话只见 spawn 卡停在旧态,出现「已完成」幽灵态)。本 task-local
|
||||
// 在子循环作用域内把 from 会话的**展示类**事件改挂父会话(to)conv 再推送,复用同一 emit 双写
|
||||
// 出口(不开旁路);终态/状态机/审批/求助类事件不改写,避免污染父会话生成态与生命周期。
|
||||
tokio::task_local! {
|
||||
static CONV_ROUTE_OVERRIDE: (String, String);
|
||||
}
|
||||
|
||||
/// 在「把 from 会话的展示类事件改挂 to 会话」的作用域内执行 future(子代理运行期包裹子循环
|
||||
/// 的流式与工具调用;作用域外事件路由不变)。
|
||||
pub(crate) async fn route_scope<F, R>(from: String, to: String, fut: F) -> R
|
||||
where
|
||||
F: std::future::Future<Output = R>,
|
||||
{
|
||||
CONV_ROUTE_OVERRIDE.scope((from, to), fut).await
|
||||
}
|
||||
|
||||
/// 展示类事件白名单:仅这些随子代理运行改挂父会话视图(轮次/文本/心跳/重试/工具生命周期/
|
||||
/// 命令输出)。负例(AiCompleted/AiError/AiAgentFinished/AiConvStateChanged/审批/求助/停靠等)
|
||||
/// 承载父或子各自的会话生命周期,改写会污染父会话生成态,故不改写。
|
||||
const ROUTABLE_EVENT_TYPES: &[&str] = &[
|
||||
"AiAgentRound",
|
||||
"AiTextDelta",
|
||||
"AiHeartbeat",
|
||||
"AiStreamRetry",
|
||||
"AiToolCallStarted",
|
||||
"AiToolCallCompleted",
|
||||
"AiToolAutoApproved",
|
||||
"AiCommandOutput",
|
||||
];
|
||||
|
||||
/// 展示类且 conversation_id 命中 from → 返回改挂 to 后的 JSON 值;否则 None(调用方走原双写)。
|
||||
///
|
||||
/// 用序列化探针取 type/conversation_id,避免为 40+ 事件变体逐一手写 match(改一处漏一处的
|
||||
/// 漂移源)。命中率极低(仅子代理运行期),开销可忽略。
|
||||
fn route_conv_event(ev: &AiChatEvent, from: &str, to: &str) -> Option<serde_json::Value> {
|
||||
let mut v = serde_json::to_value(ev).ok()?;
|
||||
let ty = v.get("type").and_then(|t| t.as_str())?;
|
||||
if !ROUTABLE_EVENT_TYPES.contains(&ty) {
|
||||
return None;
|
||||
}
|
||||
if v.get("conversation_id").and_then(|c| c.as_str()) != Some(from) {
|
||||
return None;
|
||||
}
|
||||
v["conversation_id"] = serde_json::Value::String(to.to_string());
|
||||
Some(v)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EventBus — 基于 tokio::sync::broadcast 的 pub-sub 总线(载荷 serde_json::Value)
|
||||
//
|
||||
@@ -337,4 +396,64 @@ mod tests {
|
||||
assert_eq!(received, expected.clone(), "每个变体应原样透传(全透传不丢)");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// route_conv_event — 子代理运行期事件改挂(白名单 × from 命中)
|
||||
//
|
||||
// 锁定三态:① 白名单内 + conversation_id==from → 改挂 to 返回 Some;
|
||||
// ② 非白名单 → None(不改挂,避免污染父会话生成态);
|
||||
// ③ conversation_id != from / None → None(不误改他会话事件)。
|
||||
// ============================================================
|
||||
|
||||
/// 白名单内且 conversation_id 命中 from → 返回改挂 to 后的值(仅 conversation_id 变,type/其余字段原样)。
|
||||
#[test]
|
||||
fn route_conv_event_rewrites_from_to() {
|
||||
let ev = AiChatEvent::AiTextDelta {
|
||||
delta: "hi".into(),
|
||||
conversation_id: Some("sub".into()),
|
||||
};
|
||||
let v = route_conv_event(&ev, "sub", "parent")
|
||||
.expect("白名单内且命中 from 应返回 Some");
|
||||
assert_eq!(v["type"], "AiTextDelta", "type 判别字段应保留");
|
||||
assert_eq!(v["conversation_id"], "parent", "conversation_id 应改挂到 to");
|
||||
assert_eq!(v["delta"], "hi", "其余字段应原样透传");
|
||||
}
|
||||
|
||||
/// 非白名单事件(承载父/子会话生命周期)→ None,不改写。
|
||||
#[test]
|
||||
fn route_conv_event_rejects_non_whitelisted() {
|
||||
// AiMaxRoundsReached 不在 ROUTABLE_EVENT_TYPES,改写会污染父会话状态机
|
||||
let ev = AiChatEvent::AiMaxRoundsReached {
|
||||
conversation_id: Some("sub".into()),
|
||||
};
|
||||
assert!(
|
||||
route_conv_event(&ev, "sub", "parent").is_none(),
|
||||
"非白名单事件不应改挂"
|
||||
);
|
||||
}
|
||||
|
||||
/// conversation_id 非 from(他会话来源)→ None,不动。
|
||||
#[test]
|
||||
fn route_conv_event_skips_unmatched_from() {
|
||||
let ev = AiChatEvent::AiAgentRound {
|
||||
round: 2,
|
||||
conversation_id: Some("other".into()),
|
||||
};
|
||||
assert!(
|
||||
route_conv_event(&ev, "sub", "parent").is_none(),
|
||||
"conversation_id 非 from 不应改挂"
|
||||
);
|
||||
}
|
||||
|
||||
/// conversation_id 缺省(None)→ 不命中 → None。
|
||||
#[test]
|
||||
fn route_conv_event_skips_none_conv() {
|
||||
let ev = AiChatEvent::AiHeartbeat {
|
||||
conversation_id: None,
|
||||
};
|
||||
assert!(
|
||||
route_conv_event(&ev, "sub", "parent").is_none(),
|
||||
"conversation_id 缺省不应改挂"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,30 +9,46 @@ use df_storage::models::AiProviderRecord;
|
||||
use crate::commands::err_str;
|
||||
use crate::state::{AppState, SettingsNorm};
|
||||
|
||||
/// 获取当前活跃提供商配置
|
||||
/// 获取当前活跃提供商配置。
|
||||
///
|
||||
/// **停用须生效(2026-10-10 用户报「模型配置停用无效」根因)**:原实现显式活跃 provider 直接返回、
|
||||
/// 默认 provider 只按 `is_default` 挑,**全程不看 `enabled`** → 用户在设置里停用某 provider 后
|
||||
/// 会话仍照用(停用形同虚设)。现两条路径都收敛到「启用集合」:显式活跃项被停用 → 改按启用集合解析
|
||||
/// (留 warn 便于定位);无任何启用 provider → 明确报错而非静默使用被停用项。
|
||||
pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRecord, String> {
|
||||
let session = state.ai_session.lock().await;
|
||||
if let Some(ref pid) = session.active_provider_id {
|
||||
let provider = state
|
||||
.ai_providers
|
||||
.get_by_id(pid)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("活跃提供商不存在: {}", pid))?;
|
||||
Ok(provider)
|
||||
} else {
|
||||
// 查找默认提供商
|
||||
drop(session);
|
||||
let providers = state
|
||||
.ai_providers
|
||||
.list_all()
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
let default = providers.iter().find(|p| p.is_default).cloned();
|
||||
default
|
||||
.or_else(|| providers.into_iter().next())
|
||||
.ok_or_else(|| "未配置 AI 提供商,请先在设置中添加".to_string())
|
||||
if let Some(provider) = state.ai_providers.get_by_id(pid).await.map_err(err_str)? {
|
||||
if provider.enabled {
|
||||
return Ok(provider);
|
||||
}
|
||||
tracing::warn!(
|
||||
provider_id = %pid,
|
||||
provider_name = %provider.name,
|
||||
"[ai] 活跃 provider 已被停用,改按启用集合解析默认 provider"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(provider_id = %pid, "[ai] 活跃 provider 不存在,改按启用集合解析");
|
||||
}
|
||||
}
|
||||
drop(session);
|
||||
resolve_enabled_provider(state).await
|
||||
}
|
||||
|
||||
/// 在**启用**的 provider 中解析:`is_default` 优先,否则取列表首个(与旧行为同序,仅多一道 enabled 过滤)。
|
||||
async fn resolve_enabled_provider(state: &AppState) -> Result<AiProviderRecord, String> {
|
||||
let providers = state
|
||||
.ai_providers
|
||||
.list_all()
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
let enabled: Vec<AiProviderRecord> = providers.into_iter().filter(|p| p.enabled).collect();
|
||||
enabled
|
||||
.iter()
|
||||
.find(|p| p.is_default)
|
||||
.cloned()
|
||||
.or_else(|| enabled.into_iter().next())
|
||||
.ok_or_else(|| "未启用任何 AI 提供商,请在设置中启用一个(或新增)".to_string())
|
||||
}
|
||||
|
||||
/// 当前运行环境画像(注入 system prompt):日期 + OS + shell 执行姿势 + 可用解释器。
|
||||
@@ -45,7 +61,10 @@ pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRe
|
||||
/// - 可用解释器:Windows=python(无 python3);Linux·macOS=python3;node 均有
|
||||
///
|
||||
/// 跨设备:cfg! 分支,平台各自正确姿势。补充探测见 detect_environment 工具(失败自愈)。
|
||||
fn env_profile_line() -> String {
|
||||
///
|
||||
/// pub(crate):子代理 system prompt 同源注入(见 sub_agent::sub_agent_system_prompt),
|
||||
/// 子代理同样需要日期(不能自行获取)与 shell 执行姿势。
|
||||
pub(crate) fn env_profile_line() -> String {
|
||||
let today = chrono::Local::now().format("%Y-%m-%d");
|
||||
format!("当前日期: {today} | {}", env_profile_stable_body())
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use df_storage::models::AiProviderRecord;
|
||||
|
||||
use crate::state::LlmConcurrency;
|
||||
|
||||
use super::compress::resolve_fallback_model;
|
||||
use super::AiSession;
|
||||
|
||||
/// 对话完成后按需生成智能标题(仅 title 为空时触发一次,不覆盖用户改名)
|
||||
@@ -120,15 +121,25 @@ pub(crate) async fn ensure_conversation_title(
|
||||
return;
|
||||
}
|
||||
};
|
||||
// F-01 阶段5: 标题生成路由 — select_model_id None(池空/无匹配)→ 兜底 default_model。
|
||||
// F-01 阶段5: 标题生成路由 — select_model_id None(池空/无匹配)→ 兜底 default_model,
|
||||
// 但须校验其 enabled(停用则回落池内启用模型,见 compress::resolve_fallback_model);
|
||||
// 池内无任何启用模型 → 跳过 LLM 保留 extract_title 兜底,不再静默用停用模型。
|
||||
let title_req = TaskRequirements {
|
||||
modalities: vec![Modality::Text],
|
||||
needs_tool_use: false,
|
||||
estimated_context: 0,
|
||||
tier: None,
|
||||
};
|
||||
let title_model = select_model_id(&title_req, &provider_config.model_configs)
|
||||
.unwrap_or_else(|| provider_config.default_model.clone());
|
||||
let title_model = match select_model_id(&title_req, &provider_config.model_configs) {
|
||||
Some(m) => m,
|
||||
None => match resolve_fallback_model(&provider_config.model_configs, &provider_config.default_model) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!("标题 LLM 跳过(无可用模型): {}", e);
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
// 防御:选中/兜底模型是图像模型时跳过 LLM(避免文本任务 404 刷屏,治 default_model 配错为图像模型)。
|
||||
// 跳过则保留上方 extract_title 兜底,不锁定差标题。
|
||||
if is_likely_image_model(&title_model, &provider_config.model_configs) {
|
||||
|
||||
Reference in New Issue
Block a user