Files
DevFlow/src-tauri/src/commands/ai/agentic/mod.rs
T
lxy 8c06076873 新增: B5 续作引擎完整批: 自领+停靠恢复+监控推送+提交冻结
- B5 引擎: 看板轮询自动领单(G2 门禁 fail-closed, 领单原子写), parked 自动恢复优先, 收敛回读续作, 3 次熔断自停, KV 配置默认关
- 引擎配套: 提交冻结同步(df-commit-frozen), 工具结果外载过期清理, 工具失败快速记忆, 自监控快照飞书推送(df-monitor-chat-id)
- 领单判据收紧: 正文以 🔴 开头才判高危(正文提及字样不误判)
- 28 注入末轮竞态: 补充指令落末轮时顺延轮次预算(封顶 8)
- 停靠卡继续伪失败根修: ai_continue_loop 非 active 时分流 parked 恢复内核(recover_parked_inner 单一事实源)
- 实测: 写板 70 秒自动领单, 领单→执行→销账→回读→领下一单 全链闭环, 2050 测试过
2026-10-07 18:32:43 +08:00

3522 lines
192 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Agentic 循环 — 流式接收 → 工具执行 → 结果回传 LLM → 循环
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::Mutex;
use df_ai::ai_tools::AiToolRegistry;
use df_ai::context::TokenEstimator;
// 占位配对完整性:agentic 出口第二道防线断言(深度防御)。
use df_ai::context::ContextManager;
// 改进3 B: 压缩失败兜底关键词摘要(纯函数 extract_keyword_summary)。
// 改进4: 工具结果 view-only 摘要(should_summarize_tool_result / extract_key_info)。
// extract_keyword_summary 随压缩逻辑迁至 context_lifecycle.rs(改进3 B 关键词兑底)。
use df_ai::context_helpers::PLACEHOLDER_INTEGRITY_ENABLED;
// 改进2 B:意图收敛工具(LLM 可见 tool_defs 按 intent 过滤,执行路径仍走完整 registry)
// B 路线 Phase 1:plan_hint 接入主 loop——filter_tool_defs_planned 在 filter_tool_defs
// 收敛的扁平子集之上叠加 plan_hint 编排(并行组同批聚拢/顺序依赖源在前),供 LLM 看到
// 一份按编排意图排序的工具列表。feature flag PLANNING_ENABLED(false 默认关)门控接入。
use df_ai::intent::{filter_tool_defs, filter_tool_defs_planned, suggested_model_tier, IntentRecognizer, Intent};
use df_ai::coordinator::{Coordinator, ExecutionResult};
use df_ai::persona::PersonaRegistry;
use df_ai::provider::{ChatMessage, LlmProvider, MessageRole};
// 流式调用 + 重试退避已随 stream 系函数迁至 stream_relay.rs(retry/stream_recv 其 use 同迁)。
// 智能路由 helper + TaskRequirements + 维度枚举。
// 调用点构造 TaskRequirements(主对话:needs_tool_use=true,
// 当前仅 Text 模态;后续多模态接入时检测消息内 Part/Image 追加 Vision),
// 经 select_model_id 在 provider.model_configs 池中选最优;池空/无匹配兜底 default_model。
// 注:路由已解耦,cost_tier/intelligence 不再参与硬路由。
use df_ai::router::{
select_model_id, Modality, TaskRequirements,
};
use df_storage::db::Database;
use df_storage::models::AiProviderRecord;
// ProviderAcquire 已随 stream_round 迁至 stream_relay.rs。
use crate::state::{AppState, LlmConcurrency};
use super::audit::{process_tool_calls, ToolExecPolicy};
use super::event_bus::emit_ai_chat_event;
// compress_via_llm 已随压缩逻辑迁至 context_lifecycle.rs(maybe_auto_compress 内调用)。
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};
// ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。
/// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。
///
/// 用于把 provider 解析块(list_all + select + resolve + ensure + build)包入
/// `tokio::time::timeout` 后的内部 Err 路由——区分 ensure_resolved_key 失败(走 Auth
/// 错误)与整体超时(走 Unknown 错误)。超时由外层 timeout 的 Err(Elapsed) 单独匹配。
enum ProviderResolveError {
/// ensure_resolved_key 失败(key 缺失/钥匙串损坏):走 Auth 错误分支(对齐原 :412 处理)。
EnsureKeyFailed(String),
}
/// Agentic 循环默认最大迭代次数(可配置项的默认值)
///
/// 默认 10 轮。已接入配置:AppState.agent_max_iterations(Arc<AtomicUsize>) +
/// ai_set_agent_max_iterations command + Settings.vue 数字配置。调用方在 loop 入口
/// load AtomicUsize 快照后透传 `max_iterations: usize` 形参,当前 loop 锁定边界,
/// 热改下次发消息生效(与 llm_concurrency 传 Arc 实时反映的区别)。
pub const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
/// 压缩保护区条数 + 压缩关键词兑底开关已随压缩逻辑迁至 context_lifecycle.rs
/// (PROTECT_COUNT / KEYWORD_FALLBACK_ENABLED,在该模块内部为私有 const)。
///
///
/// 默认 3 次(初次 + 2 次重试)。复用 retry::backoff_delay 退避(1s→2s→4s+jitter) +
/// retry::is_status_retryable Fatal 分类(4xx 非429 立即放弃) + 30s 总预算。
/// 只重试流前失败(Init Err:未输出任何 token);流中途失败(MidStream:已输出
/// partial_text)不重试——保文入库 + AiCompleted(incomplete=true) + 系统提示网络中断。
pub const DEFAULT_MAX_AGENT_RETRIES: usize = 3;
/// 防瞬时抖动(G4.1,2026-08-05):主 provider per-provider 信号量连续饱和阈值。
///
/// 候选循环内 `try_acquire_for_provider` 返 `Exhausted` 时该 provider 连续饱和计数 +1
/// (跨迭代累计);`Acquired` 时清零(已恢复)。链首(当前主 provider)连续 N 次饱和
/// → 候选链构建时降级到链尾,让 fallback 先试。
///
/// **动机**:单次瞬时饱和(其他对话标题/压缩短暂占满)不应让主 provider 被长期跳过。
/// N=3 对齐 DEFAULT_MAX_AGENT_RETRIES 量级,3 轮连续饱和才判持续过载,防抖动误降级。
pub const PROVIDER_SATURATION_DEMOTE_THRESHOLD: usize = 3;
/// 无人值守进度可观测(设计 §4.3):主 loop 每 N 轮 emit AiTaskProgress 一次。
/// 5=设计拍板点 4 建议 A;每轮 emit 噪音大,不做则牺牲静默期可观测。实测后可调。
pub const PROGRESS_EVERY_N_ROUNDS: usize = 5;
/// 跨轮任务级重入(深查报告 P1-4):单轮流前重试共享 30s 挂钟预算,一次 >30s 的网络故障
/// (WiFi 切换/VPN 重连/上游限流窗口)即整任务失败。本层加「轮间」耐受力:轮失败不立即
/// 终局,指数退避后从当前轮重入(上下文已落库,每轮 save 天然支持;iteration 不推进,
/// 不烧 max_rounds 预算)。连续重入 ROUND_REENTRY_MAX 次,退避 1min/5min/15min,
/// 仍失败转停靠(fatal)。单轮内 30s 预算不动(防死循环),只加轮间容忍。
pub const ROUND_REENTRY_MAX: usize = 3;
pub const ROUND_REENTRY_BACKOFF_SECS: [u64; ROUND_REENTRY_MAX] = [60, 300, 900];
/// H2 token 预算三闸(无人值守可靠性专项 B2,抄 sub_agent round_budget_stop 语义):
/// 无人值守长任务(max_iterations=0 不限轮次)缺 token/挂钟兜底——模型死循环级输出
/// 白烧预算无人踩闸。三闸在每轮头检查(轮间粒度,单轮内不中断):
/// ①挂钟:超 UNATTENDED_WALL_SECS(默认 30min,对齐委托任务超时底座)停靠 wall_clock;
/// ②累计 token:超 UNATTENDED_MAX_TOKENS(默认 5M)停靠 token_budget;
/// ③轮次:max_iterations>0 既有 for 上界 + 达上限停靠分支,不重复设闸。
/// 超限走既有 emit_task_parked——前端词表已预留,零新契约。
pub const UNATTENDED_WALL_SECS: u64 = 30 * 60;
pub const UNATTENDED_MAX_TOKENS: u64 = 5_000_000;
/// 改进3 B 常量开关 KEYWORD_FALLBACK_ENABLED 已随压缩逻辑迁至 context_lifecycle.rs
/// (在该模块内部为私有 const,文档见 context_lifecycle.rs 顶部)。
///
/// 改进4 常量开关:tool_result 大输出是否做 view-only 摘要压缩(默认 true)。
///
/// true(默认):build_for_request 后送 stream 前,遍历 history 中 tool_result,
/// 超阈值(content >2KB 或占历史 token >40%)的 content 应用 extract_key_info
/// 替换(保留错误行 + 首尾各 5 行)。**仅 messages clone 视图,不改 ContextManager
/// 持久化**(对齐 sanitize_messages:DB 原始 tool_result 完整保留)。
/// false(关闭):tool_result 原样送 LLM(旧行为)。排障/对比用。
pub const TOOL_RESULT_COMPRESS_ENABLED: bool = true;
/// 改进5 常量开关:是否启用主题切换系统标记(默认 true)。
///
/// true(默认):push 时末两条 user 消息 topic 都非 None 且不同(双高置信)→
/// pending_topic_marker 置位 → agentic loop 顶部读取并 insert 一条 system 软提示
/// `"── 用户已切换话题(从「{old}」到「{new}」),请以新话题为准 ──"`(软提示,不强制 LLM)。
/// false(关闭):loop 顶部跳过读取/insert(置 false 即可观察无主题标记效果)。
/// 保守:双高置信才标(任一 topic None 不标),不强制 LLM(软提示非硬约束)。
pub const TOPIC_MARKER_ENABLED: bool = true;
// ============================================================
// G1 目标钉扎(治 R1 目标消息被压缩出局 + R3 重锚定 + R5 prompt 无锚点,2026-06-26)
//
// 机制:run_agentic_loop 每轮从 LLM 工具调用推理目标(infer_goal_from_tool_calls),
// 存 PerConvState.pinned_goals(Vec<GoalEntry>,内容态字段,绕过 sanitize step0 is_active 过滤),
// run_agentic_loop 入口拼 active 目标进 system_prompt 尾部。
// system_prompt 是 loop 不变量 + build_for_request 不裁剪,故目标天然免疫压缩/裁剪,
// 彻底治 R1(目标消息物理出局)/R5(prompt 说教无锚点),无 insert_at(0) 的连续 System
// 1214/首位锚点稀释/小预算被裁三重风险。
// ============================================================
/// G1 总开关:目标钉扎是否启用(默认 true)。
///
/// true(默认):run_agentic_loop 每轮从 LLM 工具调用推理目标(infer_goal_from_tool_calls),
/// 存入 PerConvState.pinned_goals(Vec<GoalEntry>) + loop 入口拼 active 目标进 system_prompt。
/// false(回退):提取跳过 + loop 入口拼接跳过,pinned_goals 永远空 Vec,system_prompt 零变化,
/// 完全退回改动前行为(目标靠压缩摘要 + topic marker + 聚焦准则 prompt 续命)。单点回退,
/// 不影响 G2/G4/G5(对齐 KEYWORD_FALLBACK_ENABLED 模式:每改配开关 + 兜底降级旧行为)。
/// 2026-07-03 改:提取从 chat.rs 用户消息规则 → run_agentic_loop 工具调用推理(helpers.rs)。
pub const GOAL_PIN_ENABLED: bool = true;
/// G1 banner 开关:目标拼进 system_prompt 时是否加「## 当前目标」分隔标题(默认 true)。
///
/// true(默认):目标文本前置 `## 当前目标(全程锚定,所有动作须服务于它)` 标题 + 目标内容,
/// 显式分隔防与行为准则混淆,提升 LLM 注意力分配。false(裸拼):目标文本直接 append 到
/// system_prompt,无标题分隔(紧凑,排障/对比用)。
pub const GOAL_INJECT_BANNER: bool = true;
/// G1 截断长度:每条目标文本截断上限(默认 500 字符)。
///
/// 防 R1 反向风险:长 user 消息(粘贴需求文档/长 bug 描述)每轮占 system_prompt 预算。
/// system_prompt 虽不被裁剪但仍计 sys_tokens 占预算,故截断防长目标撑爆。500 保守(首版,
/// 可调),足够覆盖正常一句话目标。截断后追加「…」省略号标识。
pub const GOAL_MAX_CHARS: usize = 500;
/// G1 多目标上限:最多累积目标数(默认 5)。
///
/// 防无限膨胀:每次发消息提取的目标追加到 pinned_goals vec,超上限时淘汰最早目标。
pub const MAX_GOALS: usize = 5;
/// 技能清单注入开关:system_prompt 是否追加本机技能清单(名称+描述),让 LLM 感知可用技能(默认 true)。
///
/// 机制化:LLM 只读清单,用户请求命中某技能用途时能引导触发(/技能名),不做复杂执行逻辑。
/// 清单来自 skills.rs 进程内缓存(skills/commands/plugins 三类去重)。false(回退)→ 整块跳过,
/// system_prompt 零变化,退回改动前行为(单点回退,不影响 augmentation 的 / 联想)。
pub const SKILL_LIST_INJECT_ENABLED: bool = true;
/// 技能清单注入最大条数(默认 120)。
///
/// 防本机大量技能全量进 system_prompt 占 sys_tokens 预算;取前 N 条(name 去重序),截断描述。
/// 自动触发(方案3):本机实扫 38 home skills + 1 command + 72 plugins ≈ 111 个技能,
/// 原上限 20 会把 90+ 个技能对 LLM 完全隐藏(publish-moyu-uat 之后全部不可见),
/// "任务命中技能 → AI 自主 read_skill"的自动触发链路因清单不可见而断裂。
/// 清单单条为「名称+80字描述」索引卡片(约 30 token/条),120 条 ≈ 3.6K token,
/// 且注入位于 system 稳定段(跨轮缓存命中,只付一次成本),120 上限兼顾覆盖与预算。
pub const SKILL_LIST_MAX_ITEMS: usize = 120;
/// 技能清单单条描述截断长度(默认 80 字符)。
///
/// 清单只做提示,截断防长描述(部分技能 description 数百字)撑爆 prompt,截断后接省略号。
pub const SKILL_DESC_MAX_CHARS: usize = 80;
/// 派工纪律母本(N1 通用能力内置沉淀):主 agent 派工纪律 8 行,措辞可微调不可删项(用户拍板)。
///
/// 为什么内置而非放知识库:知识库是分发面数据——新实例/新机器/知识库为空时纪律即失传(分发裸奔);
/// 编译进二进制随代码分发,零配置生效,且位于 system 稳定段(跨轮缓存命中,只付一次 token 成本)。
/// 双路径同源:build_runtime_prompt(主会话,派工方)+ sub_agent_system_prompt(子代理,被派方)
/// 均拼接本母本,两侧共享同一套纪律文本,禁在别处另抄副本(改纪律只改这里)。
pub(crate) const DISPATCH_DISCIPLINE: &str = r#"## 派工纪律
1. 派工先估颗粒:单任务一轮内自证完成,装不下就拆批;独立子任务并行派。
2. 任务书三要素:目标文件边界(热点文件单点编辑权)+预期时限+标准验证命令。
3. 验收认证据:子代理须交"命令+结果",不收"我做完了";主代理独立抽查裁决。
4. 子代理报告限长(约4千字),超长落盘只给指针;主代理消化后紧凑透出,禁全量转贴。
5. 超时处置:到点发收敛令→无响应试探存活→确认死则主代理砍维度亲自收尾,勿重派同颗粒。
6. 改代码禁全量验证:单文件快检,阶段收官统一验证一次。
7. 派工前查重在途执行体,同类故障只委托一头。
8. 结果透出结论先行,证据随附,过程不铺陈。"#;
/// G4 目标感知降级:话题标记 insert 当 pinned_goals 存在时跳过(默认 true)。
///
/// 治 R2(话题标记反向误导):G1 目标钉扎生效后每轮 system_prompt 已含目标,topic marker 的
/// 「请以新话题为准」软提示成冗余且与目标矛盾(诊断 §三双锚点稀释)。true(默认)= 当
/// pinned_goal 存在时 take_topic_marker 后丢弃跳过 insert(防 marker 累积 + 消双锚点稀释);
/// false(回退)= 无视 pinned_goal 照常 insert,完全退回原双锚点行为(排障/对比用)。
///
/// 与 TOPIC_MARKER_ENABLED 独立:TOPIC_MARKER_ENABLED 是整段总开关(false=整段跳过),
/// TOPIC_MARKER_GOAL_AWARE 是 goal 感知前置 guard(仅 GOAL_AWARE=true 且 pinned_goal
/// 存在时跳过 insert)。双关默认 true,各自单点回退不耦合(G1 关闭时 GOAL_AWARE 仍生效,
/// 但 pinned_goal 永远 None → 不跳过 → 退原 topic 行为,双层兜底)。
pub const TOPIC_MARKER_GOAL_AWARE: bool = true;
// ============================================================
// G2 探索熔断(治 R4 探索无进展死循环 + token 失控)
//
// 判定范式(2026-08-01 根本性重构):
// - 旧(废弃):连续 N 轮工具结果全空(is_empty_tool_result 关键词判空成功)。错误代理指标,
// grep 无匹配是有效排除信号非漂移,误熔断正常排除式搜索(实证 ac448296)。
// - 新(当前):连续 N 轮工具调用签名重复(is_repetitive_exploration 判 tool_call_signature)。
// 漂移本质 = AI 卡住反复同样调用;正常探索(换词/换路径/换工具)签名不同,真死循环签名重复。
// 红绿迭代(同验证命令反复跑+间插写调用)按写间隔豁免不算漂移,见 is_repetitive_exploration。
// 红绿迭代豁免:同验证命令反复跑但间插写类调用(patch/write)不算漂移,见 is_repetitive_exploration 写间隔论证。
//
// 机制(非 prompt 说教,骨架沿用):连续 N 轮判定签名重复 → 两段式(WARN_FIRST 先警示再熔断):
// 警示注入下轮 prompt 给 LLM 自纠机会,熔断(guard.reset + AiHelpRequired)逼用户换思路/人工介入。
// 与 L1 断路器(CIRCUIT_BREAKER_*)互补:L1 治反复同类失败(失败信号归一 key),
// G2 治反复同类调用(调用签名归一),两者独立计数互不干扰。
// ============================================================
/// G2 总开关:探索熔断是否启用(默认 true)。
///
/// true(默认):process_tool_calls 后取最近 N 个 assistant tool_calls 签名,喂
/// is_repetitive_exploration 判重复,重复累计 stall_count,达 STALL_BREAKER_THRESHOLD → 警示/熔断。
/// false(回退):整段跳过,stall_count 永远 0,退 max_iterations 旧行为(可能继续游荡但行为不变,
/// 单点回退)。与 CIRCUIT_BREAKER_ENABLED 独立。
pub const STALL_BREAKER_ENABLED: bool = true;
/// 纯问候工具门(默认 true):纯问候/社交短文本消息 → LLM 可见工具置空(tools=None),
/// 模型只能纯文本回复。false(回退)→ 走既有 intent 过滤(可能回全量),排障/对比用。
pub const PURE_GREETING_TOOL_GATE: bool = true;
/// G2 连续重复阈值(默认 3)。
///
/// 对齐 CIRCUIT_BREAKER_THRESHOLD=3:连续 3 轮判定签名重复足以判漂移。新范式(签名重复)比
/// 旧范式(结果空)更精准——签名重复是漂移的直接证据,无关键词漏判风险。阈值 3 在
/// 两段式 WARN_FIRST(== THRESHOLD-1 即 2 轮先警示)容错下,误杀面收敛到「连续 3 轮同一组调用」
/// 的真实死循环场景,最坏退 max_iterations 兜底。
pub const STALL_BREAKER_THRESHOLD: u32 = 3;
/// G2 两段式开关:熔断前是否先警示一轮(默认 true)。
///
/// true(默认):stall_count == THRESHOLD-1 时注入「⚠ 检测到重复的工具调用 ...」到下轮
/// system_prompt(给 LLM 自纠机会);stall_count >= THRESHOLD 才熔断。
/// 降误杀:偶发的相似调用(如分段读后回读同一处确认)先警示再熔断。false(激进):直接熔断不警示,
/// 确认无误杀场景用。
pub const STALL_BREAKER_WARN_FIRST: bool = true;
/// 目标验证续轮开关(默认 false,灰度放开)。
///
/// 治"过早宣称":末轮无 tool_calls 时,若 pinned_goals 非空且无 applied.matched_target
/// 证据 → 不收敛,注入 nudge 消息强制续轮验证。机制非 prompt 说教(实证无效)。
/// false(默认,回退):整块跳过,converged 判定逐字节等价改动前(只看末轮有无 tool_calls)。
/// 主入口保障:默认关,自测+历史会话回放验证不误伤后再默认开。
pub const GOAL_VERIFY_ENABLED: bool = false;
/// 目标验证续轮上限(默认 2)。
///
/// 每强制续轮 +1,达上限无条件强制收敛 + AiCompleted 标注 goal_unverified。
/// 保证 loop 必然终止,绝不死循环。LLM 续轮内调了工具(无论成败)计数器重置(说明在行动)。
pub const GOAL_VERIFY_MAX_ROUNDS: u32 = 2;
/// G2 警示是否回顾目标(默认 true,需 G1 goal 字段)。
///
/// true(默认):警示文本引用 pinned_goal(若存在)提示「回顾目标: {goal}」,精准;
/// false:警示泛化(不引 goal 文本)。G2 不强依赖 G1(熔断不读 goal 也能工作,仅警示泛化)。
pub const STALL_BREAKER_GOAL_REMIND: bool = true;
/// G2 重复检测样本量:check_stall_breaker 扫历史 assistant tool_calls 取最近多少个签名喂判定。
///
/// 12 = 覆盖 2-3 轮多工具调用(单轮可并行多个 grep/read),既能让 is_repetitive_exploration 的
/// 「最小样本 6」+「单点 3 次」阈值有意义,又不至于扫太长历史稀释近期漂移信号。
pub const STALL_BREAKER_SAMPLE_SIZE: usize = 12;
/// AC-2 ①:单轮工具调用数上限(默认 8)。
///
/// 治弱模型单轮一次性发超多工具调用(实证 09e7abfa:同一目录列 3 次 + run_command 绕行,
/// 单轮十几个 grep/read 并行爆炸)。LLM 看不到"自己本轮发了多少调用",必须在消息流里
/// 显式警告才能感知(机制优先 prompt 说教)。超限即 insert 一条 system 软提示到
/// conv.messages(非静默),下轮 build_for_request 时 LLM 可见,促其聚焦关键操作。
pub const MAX_TOOL_CALLS_PER_ROUND: usize = 8;
/// L1 断路器:连续同类工具失败熔断阈值(治 kms 会话 53 轮 0 产出死循环)。
///
/// 背景:agent 无止损,某工具反复同类失败(权限拒绝/路径错误等)仍每轮重试,
/// 耗尽 max_iterations 前 0 产出。机制(非 prompt 教 AI):每轮 process_tool_calls
/// 后取末尾连续 Tool 消息,失败包按结构化信号(tool_name + 错误类别)归一为 key,
/// 入滚动窗口(CIRCUIT_BREAKER_WINDOW)统计,同一 key 在窗口内累计达此阈值 →
/// guard.reset + emit AiError + return 强制熔断,逼用户换思路或人工介入。
///
/// 阈值 3:同类失败 3 次足以判死循环(去重后仍累加,不同错误各自计数互不干扰)。
pub const CIRCUIT_BREAKER_THRESHOLD: u32 = 3;
/// L1 断路器滑动窗口大小:仅保留最近 N 条失败记录参与计数。
///
/// 治"长任务偶发失败误熔断":全 loop 累加会让早期偶发失败与后期同类失败叠加触发。
/// 滚动窗口让计数只反映最近的失败密度——长任务中途偶发 1~2 次同类失败不触发,
/// 真正的连续死循环(N 条全同 key)才触发。N=20:对齐 max_iterations 量级,既覆盖
/// 单轮并行失败爆发,又足够长以容忍偶发抖动。
pub const CIRCUIT_BREAKER_WINDOW: usize = 20;
/// BE2 断路器/探索熔断读消息的尾部窗口大小。
///
/// count_recent_failures / check_stall_breaker 只关心最近若干条消息(末尾工具结果 + 其前驱
/// assistant tool_calls / 最近几轮工具签名),用 ContextManager::recent_messages 只 clone 尾部
/// 切片,避免 all_messages_clone 每轮全量深克隆(长对话每轮 O(n) clone 浪费)。
/// 40:同轮工具回填通常 1~5 条,富余覆盖其定义调用与最近多轮;超窗工具映射退化为
/// unknown_tool(仅失败分组更粗)/ 签名样本截断(仍达 STALL_BREAKER_SAMPLE_SIZE=12),不误熔断。
pub const BREAKER_TAIL_N: usize = 40;
/// L1 断路器总开关(默认 true)。false → 跳过断路器检查,降级为纯 max_iterations
/// 旧行为(排障/对比/临时关闭用)。机制优先 prompt 说教,每改配开关 + 兜底(关降级旧行为)。
pub const CIRCUIT_BREAKER_ENABLED: bool = true;
/// L1 断路器熔断时是否发结构化求助(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21)。
///
/// true(默认):熔断 emit AiHelpRequired(结构化求助卡:reason + context + options),
/// 引导用户换思路/授权路径/人工接管(机制优先 prompt 说教,非教 AI 自己止损)。
/// false(兜底回退):熔断仍 emit AiError(旧行为,前端错误气泡),用于求助卡未就绪/
/// 排障/对比。两路保留 guard.reset + return 强制熔断语义不变,仅换前端呈现形态。
/// 配合 CIRCUIT_BREAKER_ENABLED:CIRCUIT_BREAKER_ENABLED=false 时断路器整段跳过,
/// 本开关无意义;CIRCUIT_BREAKER_ENABLED=true 时本开关决定呈现形态。
pub const CIRCUIT_BREAKER_HELP_EVENT: bool = true;
// 占位配对完整性开关(解 400 orphan)。
//
// 单一真相源:`df_ai::context_helpers::PLACEHOLDER_INTEGRITY_ENABLED`(本模块顶部已 use)。
// 删除本地副本(B 路线改进:避免与 df-ai 同名 const 双源,改一处易漏改另一处)。
//
// 根因:审批挂起占位 tool_result(内容 audit/cache.rs:pending_placeholder_for,带
// `__PENDING__:tc_id` 标记)与其 tool_call 头经 sanitize/裁剪后可能丢配对头 → orphan
// tool_result → deepseek-v4-pro 等端点 400。df-ai 的 sanitize_messages step3.5(反向
// orphan)已豁免保留占位,build_for_request 出口已用 assert_placeholder_pairing 自愈补头。
//
// 该开关控制 agentic loop 末尾(build_for_request + tool_result 压缩后)的**第二道防线**
// 出口断言:在 messages 送 stream 前再过一次 assert_placeholder_pairing(depth-defense,
// 防 build_for_request 到送 stream 之间的转换引入新 orphan)。
//
// true(默认):agentic 出口再断言一次占位配对,失败自愈补头。
// false(回退):agentic 出口不断言(仅依赖 df-ai build_for_request 内部一次自愈,旧行为)。
// 兜底:flag 关→等价改动前(仅 df-ai 内部自愈);view-only 不改持久化。
// ============================================================
// 重构第一批(2026-06-19):GeneratingGuard 抽离到 guard.rs(纯结构搬迁,行为零变更)。
// run_agentic_loop 内仍 `GeneratingGuard::new(...)`,路径从本模块改 super::guard。
// ============================================================
mod guard;
use guard::GeneratingGuard;
/// 审批超时取消(由 try_continue_agent_loop 入口调用)
mod approval_timeout;
// ============================================================
// 重构:上下文/标题/知识生命周期抽取(行为零变更)。
//
// - context_lifecycle:F-15 自动压缩逻辑,从 run_agentic_loop 内联块抽取为
// maybe_auto_compress 函数(mod.rs 调用,返回 bool 表达原 early-return)。
// - title_lifecycle / knowledge_lifecycle:标题/知识逻辑早已模块化(title.rs /
// knowledge_inject.rs),mod.rs 仅单行调用,无可抽取的内联代码,文件作文档占位。
// ============================================================
mod context_lifecycle;
use context_lifecycle::maybe_auto_compress;
#[allow(unused_imports)]
mod title_lifecycle;
#[allow(unused_imports)]
mod knowledge_lifecycle;
pub(crate) mod workflow_context;
// ============================================================
// L2 统一状态机(ConvState enum + 转换守卫,单一真相源)。
// 设计:generating 状态机加固 + aichat 体验与 agent 能力系统化重构。
//
// 当前架构:
// - conv_state.rs:纯逻辑 enum + 守卫 + 单测。
// - run_agentic_loop 入口桥接:迁移 ConvState(Idle→Generating / Error→Generating)。
// - guard.reset/drop 同步 ConvState→Idle(正常退出 + 异常兜底)。
// - ConvState 是生成态唯一真相源(无条件迁移 + emit)。
// - 兜底:状态机层迁移失败(非法转换)记 warn 不 panic,核心生成态复位仍经 enum 迁移收敛。
// ============================================================
pub mod conv_state;
pub mod command_lock;
pub(crate) mod helpers;
pub(crate) mod router_setup;
pub(crate) mod stall_detector;
// ============================================================
// 单轮流式中继外移(stream_relay):candidate 链 + 流式调用 + 流前重试 + MidStream 保文
// (纯搬运语义等价,锁边界/emit 顺序逐字保留,见 stream_relay.rs 文件头)。
// ============================================================
pub(crate) mod stream_relay;
// 子代理循环(spawn_agent v1):纯内存子会话 + 拼装既有积木的独立 agentic 循环。
// 声明对齐兄弟模块风格;模块间积木(stream_round/process_tool_calls 等)经 super 复用。
pub(crate) mod sub_agent;
pub(crate) use stream_relay::{merge_volatile_tail, stream_round, RoundOutcome};
pub(crate) use router_setup::resolve_model_with_override;
pub(crate) use stall_detector::{
check_retry_exhausted, count_recent_failures, last_round_has_match_evidence,
summarize_tool_results,
};
pub(crate) use helpers::try_continue_agent_loop;
pub(crate) use helpers::infer_goal_from_tool_calls;
pub(crate) use helpers::extract_update_goals_block;
pub(crate) use helpers::{
tool_call_signature, is_repetitive_exploration, is_write_evidence_tool, is_pure_greeting, is_tool_call_over_limit,
DestructiveLoopSample, destructive_loop_sample, detect_destructive_loop,
};
/// 末轮注入扩轮单 loop 累计上限(todo.md 28):每次末轮注入顺延 1 轮,封顶防注入洪水把
/// 轮次预算无限放大(每扩轮都是真实 LLM 开销);打满上限后注入仍入消息,由下一轮或收尾轮可见。
const INJECT_EXTRA_ROUNDS_CAP: usize = 8;
/// 末轮注入扩轮的终点判定纯函数(todo.md 28):loop 迭代到达动态轮次终点时,按注入侧顺延
/// 余量决定「续跑几轮 or 收尾」。抽纯函数便于单测锁定「末轮注入→有续轮」契约(完整 loop
/// 依赖 Tauri 运行时,集成测试成本高;契约由本函数与注入侧 injection_hits_final_round
/// (commands/ai/commands/chat.rs)分半锁定)。调用方已保证「iteration 已达动态终点」。
///
/// 返回 Some(take)=消费 take 轮余量继续;None=收尾(break,等价原 for 自然结束)。
fn inject_extra_rounds_decision(
effective_max: usize,
consumed_extra: usize,
granted: usize,
) -> Option<usize> {
// 不限轮次 loop(max_iterations=0)到不了终点,判定分支不可达;防御返回收尾
if effective_max == usize::MAX {
return None;
}
// 余量封顶:单 loop 累计扩轮不超过 INJECT_EXTRA_ROUNDS_CAP
let room = INJECT_EXTRA_ROUNDS_CAP.saturating_sub(consumed_extra);
if granted == 0 || room == 0 {
return None;
}
Some(granted.min(room))
}
/// Agentic 循环:流式接收 → 工具执行 → 结果回传 LLM → 循环
///
/// 退出条件:
/// - LLM 只返回文本(无 tool_calls)→ 正常结束
/// - 有工具需要审批 → 暂停循环(generating 保持 true),等 ai_approve 恢复
/// - 达到最大迭代次数 → 正常结束
pub(crate) async fn run_agentic_loop(
session_arc: Arc<Mutex<AiSession>>,
tools_arc: Arc<AiToolRegistry>,
db: Arc<Database>,
app_handle: AppHandle,
provider_config: AiProviderRecord,
system_prompt: String,
// Anthropic prompt caching 易变尾段(缓存模式:日期/清单/augmentation/知识,追加到消息流末尾;
// None = 开关关,保持现状行为)。
volatile_tail: Option<String>,
conv_id: String,
knowledge_config: crate::state::KnowledgeConfig,
llm_concurrency: LlmConcurrency,
max_iterations: usize,
max_retries: usize,
start_iteration: usize,
model_override: Option<String>,
// F1 并发 epoch(2026-08-05):调用方 spawn 前 `per_conv.loop_epoch.fetch_add(1)` 后传入,
// loop 捕获自己的 epoch。退出 / Guard 复位 / emit 只认 owner(epoch 匹配),否则是旧 loop。
loop_epoch: u64,
// H3 外部干预感知:被监视的工作区目录(调用方从 allowed_dirs.persistent 取,None=无授权
// 目录,探测静默跳过)。传目录而非 HEAD:loop 启动时自行取基线,续跑循环自动重置基线。
h3_watch_dir: Option<String>,
) {
// F1 并发 epoch + F2 存活心跳:一次锁内取 per_conv 关键 Arc + 建 guard。
// guard.new 同步(transition 经 ConvStateStore 无 await),可在此锁内调。
// conv_read 不惰性建(与旧 stop_flag/notify 取用段一致):conv 已删(per_conv 无条目)
// → 旧 loop 直接退出(ConvStateStore 条目应已被 conversation_delete 同步清)。
let (stop_flag, notify, loop_epoch_arc, last_heartbeat, mut guard) = {
let mut session = session_arc.lock().await;
if session.conv_read(&conv_id).is_none() {
tracing::warn!(stale_conv = %conv_id, "[ai] loop 入口 conv 已删,退出");
return;
}
let conv = session.conv(&conv_id);
// 末轮注入扩轮基准(todo.md 28):写本 loop 的轮次预算快照(注入侧凭它判「末轮」;
// 热改 agent_max_iterations 不影响在跑 loop)+ 清零上 loop 残留的注入顺延余量。
conv.loop_max_rounds = max_iterations;
conv.round_budget_extra = 0;
(
conv.stop_flag.clone(),
conv.notify.clone(),
conv.loop_epoch.clone(),
conv.last_heartbeat.clone(),
GeneratingGuard::new(conv_id.clone(), conv.loop_epoch.clone(), loop_epoch, app_handle.clone()),
)
};
let my_epoch = loop_epoch;
// 入口即报活(loop 存活证据)+ 陈旧检查:spawn 后已被新 loop 接管(epoch 已变)→ 立即退出。
last_heartbeat.store(df_types::now_millis() as u64, Ordering::SeqCst);
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
tracing::info!(
conv_id = %conv_id,
my_epoch,
current_epoch = loop_epoch_arc.load(Ordering::SeqCst),
"[ai] loop 入口即被新 loop 接管(epoch 不匹配),退出"
);
guard.disarm();
return;
}
// V49 断点续跑底座:loop 启动置 loop_state='running'(fire-and-forget,不阻塞启动路径;
// 正常收敛出口 emit_ai_completed_once 清理;进程重启时残留 running 由启动扫描改写 parked)。
{
let db2 = db.clone();
let cid = conv_id.clone();
tokio::spawn(async move {
let repo = df_storage::crud::AiConversationRepo::new(&db2);
let _ = repo.set_loop_state(&cid, Some("running")).await;
});
}
// 治本:LLM 生成间隙向前端发 AiHeartbeat,防前端 watchdog 误断流。
// 后端 emit AiHeartbeat → useAiEvents.ts resetStreamWatchdog(convId) → per-conv timer 重置。
// 解决:LLM 重试循环(~60s)期间无事件到达前端 → watchdog 45s/90s 到期误杀。
// shutdown_tx 在函数作用域结束(Drop)时通知 heartbeat 任务退出。
// F2(2026-08-05):heartbeat_loop 同时更新 per_conv.last_heartbeat(loop 存活证据),
// 供 ai_chat_stop 兜底判断 loop 是否真死(长工具执行/流式/压缩期间心跳持续 → 豁免不误迁 Idle)。
let (_heartbeat_tx, mut heartbeat_rx) = tokio::sync::watch::channel(());
let hb_app = app_handle.clone();
let hb_conv_id = conv_id.clone();
let hb_heartbeat = last_heartbeat.clone();
tokio::spawn(async move {
heartbeat_loop(&mut heartbeat_rx, &hb_app, &hb_conv_id, &hb_heartbeat).await;
});
// 入口桥接:loop 启动前确保 per_conv 存在(已存在则保留累积,不存在则建)。
//
// 所有调用方(IPC commands.rs 写路径 + agentic/mod.rs loop + audit.rs process_tool_calls +
// conversation.rs save + title.rs + knowledge_inject.rs)统一以 per_conv 为真相源。IPC 在 spawn
// loop 前已通过 `session.conv(active_conversation_id).*` 建立 per_conv 并写入初始状态
// (messages push user / generating=true / stop_flag=false / iteration_used=0 等),故 loop
// 入口只需确保 per_conv 存在(防御性 conv() 惰性建,正常路径下已存在)。
//
// 已存在的 per_conv(同 conv 上一轮 loop 留下,如审批暂停后续跑)**保留累积状态**:loop 上一轮
// 在 per_conv.messages 累积的 assistant tool_calls / tool_result 不会因桥接抹掉。审批拒绝/通过
// 时 IPC 直接写 per_conv.messages.replace_tool_result_content,续跑 loop 读 per_conv 拿到正确状态。
//
// guard 语义:loop 启动占用生成态,per_conv.generating=true(IPC spawn 前已置 true,此处幂等确认)。
//
// 单 loop 安全性:单 active_conversation_id 阶段无并发 loop 抢 per_conv 覆盖。多 loop 并发时,
// 每 conv 各自 per_conv 条目,互不干扰。
//
// 入口:ConvState 迁移由 guard.new 在无锁 ConvStateStore 上完成。
// 多 Provider 负载均衡池 — 选主 + fallback 候选列表。
//
// 流程:list_all → ProviderPool::select(按 模型亲和 > weight > is_default 排序)→ 有序 Vec。
// 单 provider 场景:池仅 1 enabled provider → select 返回单元素 Vec → 首位 = 唯一 provider,
// 行为同 F-01 前(零变化)。空池(0 enabled)→ fallback 入参 provider_config(保启动行为)。
//
// b:select 返回的**完整有序 Vec** 即 fallback 序(主→备用)。本轮接入真实切换:
// 流式重试块外层包 `for candidate in &candidates`,主 candidate InitFailed{retryable=true}
// 耗尽 → continue 切下一 candidate(stream_one_provider 内重建 build_provider + resolved_model
// 在新 candidate.model_configs 上重算 select_model_id);Fatal(4xx 非429)立即放弃整轮。
//
// 主候选的 model_configs 用于路由,其 provider_config 用于 build_provider。
// compress_via_llm / 标题 / 后台 spawn 沿用主 candidate(非 fallback 范围,见各调用点注释)。
//
// AiProviderRepo::new 仅 clone Arc<Database>(廉价),不复用 AppState.ai_providers
// (run_agentic_loop 签名只传 Arc<Database>,改签名会牵动 3 调用点 + try_continue)。
let provider_repo = df_storage::crud::AiProviderRepo::new(&db);
// L1 补丁:provider 解析(list_all + select + resolve + ensure + build)整体包 30s timeout。
// 原实现无超时,数据库/keyring 卡死时 run_agentic_loop 入口卡住,generating 永真 + 前端看门狗
// 超时静默吞消息。超时走 AiError 分支(对齐 :412 ensure_resolved_key 失败处理),guard.reset 复位。
// 内部 list_all 失败仍容忍(空池兜底,行为不变);仅整体超时(如 DB 挂死无响应)才走 Err 分支。
let provider_resolve = tokio::time::timeout(
std::time::Duration::from_secs(30),
async {
let pool_providers: Vec<AiProviderRecord> = match provider_repo.list_all().await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "[ai] list_all providers 失败,负载均衡池退化为入参默认 provider(空池兜底)");
Vec::new()
}
};
let ranked_candidates: Vec<AiProviderRecord> = super::provider_pool::ProviderPool::select(
&pool_providers,
// specify 模式(用户指定 model):传 override 作亲和键,ProviderPool 优先选
// 「池中含该 model 的 provider」作 primary,打破下方「router 选模型需 provider_config」
// 的鸡生蛋——override 此时已知(入参 ← session.model_override),无须等 router。
// auto 模式(override=None)→ 全亲和纯权重排序,行为不变(向后兼容)。
model_override.as_deref(),
);
let (primary_provider, candidates): (AiProviderRecord, Vec<AiProviderRecord>) =
match ranked_candidates.split_first() {
Some((first, rest)) => (first.clone(), rest.to_vec()),
None => (provider_config.clone(), Vec::new()),
};
// resolve→ensure_resolved_key(空 key 早失败)→build_provider 三步统一走工厂
// 空 key 早失败(逻辑见 secret::ensure_resolved_key 单测):避免空 key 发请求吃 401,错误伪装成"API Key 无效"
//
// resolve 一次复用——原实现 build_provider_for 成功后又独立调 resolve_provider_secret
// 取 key_len(重复 keyring resolve)。现 resolve 一次:既供 key_len 诊断日志,又供 build_provider,
// 去重复 keyring resolve 调用。逻辑等价于 secret::build_provider_for(resolve→ensure→build 三步),
// 仅因 build_provider_for 隐藏 resolved key 无法复用而在此内联(未改 secret.rs 锁边界)。
let resolved_key = super::secret::resolve_provider_secret(&primary_provider);
let normalized_key = match super::secret::ensure_resolved_key(
&primary_provider.name, &resolved_key,
) {
Ok(k) => k,
Err(msg) => return Err(ProviderResolveError::EnsureKeyFailed(msg)),
};
// key_len 用归一化后长度(剥引号/trim 后),与实际发往 provider 的 key 一致
let key_len = normalized_key.len();
let provider: Box<dyn LlmProvider> = df_ai::build_provider(
&primary_provider.provider_type,
&primary_provider.base_url,
&normalized_key,
&primary_provider.default_model,
);
Ok::<_, ProviderResolveError>((primary_provider, candidates, provider, key_len))
},
).await;
let (primary_provider, candidates, provider, key_len) = match provider_resolve {
Ok(Ok((pc, cands, prov, kl))) => (pc, cands, prov, kl),
Ok(Err(ProviderResolveError::EnsureKeyFailed(msg))) => {
guard.reset().await;
// F1:旧 loop 不 emit(被新 loop 接管时,错误由 owner loop 负责)。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
return;
}
// G1.1(2026-08-05):入口早退也落库 user 消息(调用方已 push 内存),防重启/切会话丢末条 user。
// resolved_model 此处尚未算出,model 传 None(仅持久化 user 消息,无 assistant 消息可标)。
// #10:loop 即将退出,清 loop_state(spawn 已置 running——若本次 save 懒创建继承了
// running,残留会让重启扫描误判「可恢复」,实际 loop 已死无任务可续)。
save_conversation(&session_arc, &db, &conv_id, None, None, true).await;
{
let repo = df_storage::crud::AiConversationRepo::new(&db);
let _ = repo.set_loop_state(&conv_id, None).await;
}
// ensure_resolved_key 失败 = key 缺失/钥匙串损坏,归 Auth。
// 双写收敛经 emit_ai_chat_event;error_type 两端统一 Auth(原 publish 端漏传 None 属漂移,顺带修)。
emit_ai_chat_event(&app_handle, AiChatEvent::AiError {
error: msg,
error_type: Some(ErrorType::Auth),
conversation_id: Some(conv_id.clone()),
});
return;
}
Err(_elapsed) => {
// L1 补丁:provider 解析 30s 超时(DB list_all / keyring resolve 卡死)。
// 走 AiError 分支复位 generating,对齐 ensure_resolved_key 失败处理口径。
guard.reset().await;
// F1:旧 loop 不 emit(被新 loop 接管时,错误由 owner loop 负责)。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
return;
}
// G1.1(2026-08-05):入口超时早退也落库 user 消息(同上,model None)。
// #10:loop 即将退出,清 loop_state(同 Auth 早退分支)。
save_conversation(&session_arc, &db, &conv_id, None, None, true).await;
{
let repo = df_storage::crud::AiConversationRepo::new(&db);
let _ = repo.set_loop_state(&conv_id, None).await;
}
tracing::error!(conv_id = %conv_id, "[ai] provider 解析超时(30s),可能 DB/keyring 卡死");
let err_msg = "Provider 解析超时(30s),请检查数据库/钥匙串状态后重试".to_string();
// 双写收敛经 emit_ai_chat_event;error_type 两端统一 Unknown(原 publish 端漏传 None 属漂移,顺带修)。
emit_ai_chat_event(&app_handle, AiChatEvent::AiError {
error: err_msg,
error_type: Some(ErrorType::Unknown),
conversation_id: Some(conv_id.clone()),
});
return;
}
};
// 用主候选覆盖入参 provider_config(下游 build_provider / 路由 / 日志均用此)。
// mut:b 切换 candidate 后更新为实际成功所用 provider(供后续 push/save/标题 spawn)。
let mut provider_config = primary_provider;
// 诊断日志:401/错误时据此定位是 url/type/model/key 哪项问题(只记长度不记明文)
tracing::info!(
provider = %provider_config.name,
provider_type = %provider_config.provider_type,
base_url = %provider_config.base_url,
model = %provider_config.default_model,
key_len = key_len,
"[ai] 发起 LLM 请求"
);
// F1 并发 epoch:provider 解析(30s 窗口)期间被新 loop 接管 → 立即退出,
// 不执行 pre-loop 的 plan/coordinator 等冗余工作。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
guard.disarm();
return;
}
// 主对话路由 — TaskRequirements(needs_tool_use=true)。
// 模态当前仅 Text(图像消息类型未实现,后续多模态接入时检测 Part/Image 追加 Vision)。
// select_model_id None(池空/无匹配)→ 兜底 default_model,行为与接入前一致。
//
// **子项 1+2 根因修复**:此处是 pre-loop 初始路由(算 resolved_model 兜底用),estimated_context=0
// (loop 内 messages 此处尚未构建,无法估值)+ tier=None(intent 在 739 行之后才识别)。
// 真实 estimated_context + tier 在 loop 内(line ~1395,estimated_prompt 算出后)重建 agentic_req
// shadow 此绑定,candidate chain 用 loop 内的真实估值版本(主路由路径)。
let agentic_req = TaskRequirements {
modalities: vec![Modality::Text],
needs_tool_use: true,
estimated_context: 0,
tier: None,
};
let resolved_model = select_model_id(&agentic_req, &provider_config.model_configs)
.unwrap_or_else(|| provider_config.default_model.clone());
// 用户指定模型 override 穿透(仅主对话生效,标题/扫描/灵感仍走路由)。
// 兜底原则:override 非空且在该 provider model_configs 池中 → 用 override;否则用 resolved_model。
// 绝不让 override 导致无模型(空/不在池 → 落回路由结果,行为不变)。
// mut:b 切换 candidate 后由 stream_one_provider 返回的 Success.resolved_model 覆盖
// (新 candidate 模型池不同,必须重选);此后 push/save 均用迭代最新值。
let mut resolved_model = match model_override.as_deref() {
Some(id) if !id.is_empty()
&& provider_config.model_configs.iter().any(|m| m.model_id == id && m.enabled) =>
{
id.to_string()
}
_ => resolved_model,
};
// 改进2 B:意图收敛工具(LLM 可见 tool_defs 按 intent 过滤,执行路径仍走完整 registry)
//
// 机制化收敛跑题:取末条 active user 消息 → IntentRecognizer 识别 → 置信 ≥ 阈值
// 则按 intent domain 过滤工具子集(减少 LLM 在无关工具上分心/误用)。
// 三重 fallback(改进2 B §可靠):
// 1. 置信 < INTENT_CONF_THRESHOLD(0.7)→ 全量
// 2. subset 空(Chat/Unknown/Conversation)→ filter_tool_defs 内部回全量
// 3. 过滤后 < 3 条(疑似漂移/误收敛)→ 回全量
// 关键安全:filter 仅改 LLM 可见 tool_defs,**不改执行**——audit 走 tools_arc.get/execute
// 完整 registry,LLM 即使幻觉一个被滤掉的工具名,audit 也能查到/拒绝。
//
// INTENT_CONF_THRESHOLD(常量开关):阈值,低于此值不收敛(回全量)。
// 注意:conf 截断到 1.0(intent.rs),单关键词 weight=1.0 即达 conf=1.0,故阈值=1.0
// 时 conf>=1.0 仍过滤(非关闭)。真关闭收敛:置 >1.0(如 1.1)。调低 = 更激进收敛。
let user_text: String = {
let session = session_arc.lock().await;
session
.conv_read(&conv_id)
.and_then(|c| {
let msgs = c.messages.all_messages_clone();
msgs.iter()
.rev()
.find(|m| matches!(m.role, df_ai::provider::MessageRole::User))
.map(|m| m.content.clone())
})
.unwrap_or_default()
};
let (intent, conf) = IntentRecognizer::recognize(&user_text);
// 纯问候护栏:纯社交短文本 → 工具全隐藏(tools=None),治弱模型把问候当指令擅自调工具
// (会话 b4d6b4e0 实证:"你好"→list_project_modules("DevFlow")+探索源码)。
// 机制非说教:空 tool_defs → provider tools=None → 模型物理上只能纯文本回复。
// 仅 Chat 意图 + is_pure_greeting 双条件(含动作词/实体引用放行,如"你好,看看moyu"正常暴露工具)。
let pure_greeting = PURE_GREETING_TOOL_GATE && matches!(intent, Intent::Chat) && is_pure_greeting(&user_text);
const INTENT_CONF_THRESHOLD: f32 = 0.7;
let all_defs = tools_arc.tool_definitions();
let total = all_defs.len(); // 提前记录全量数(all_defs 将 move 进 tool_defs)
// B 路线 Phase 1 接入:PLANNING_ENABLED(false 默认关)门控 plan_hint 编排。
//
// **零行为变更(关时)**:flag 关时走 filter_tool_defs(intent 收敛扁平子集),
// 与 Phase 1 接入前完全一致——现有 intent/agentic 测试全绿无回归。
//
// **开启时**:调 filter_tool_defs_planned,它内部先 filter_tool_defs 收敛再叠加
// plan_hint 编排(并行组同批聚拢/顺序依赖源在前)。三重 fallback 与 filter_tool_defs
// 同语义(plan_hint 空/非法/registry 漂移均退 filter_tool_defs 扁平结果)。
//
// 关键安全(与 filter_tool_defs 同):本段只改 LLM 可见 tool_defs 的可见性/顺序,
// 不改执行(audit 走 tools_arc 完整 registry)。PLAN_HINT_ENABLED(plan_hint 函数开关,
// Phase0a 就绪 true)与 PLANNING_ENABLED(planner.rs 主 loop 规划开关,本批仍是 false)
// 分离:即使将来 PLANNING_ENABLED 翻 true,plan_hint 内部 PLAN_HINT_ENABLED 关闭时
// filter_tool_defs_planned 仍退扁平(双层开关,任一关闭均退旧行为)。
let tool_defs: Vec<df_ai::provider::ToolDefinition> = if pure_greeting {
Vec::new() // 纯问候 → 空工具集(tools=None,强制纯文本回复;仅作用当轮,不写冻结集)
} else {
let computed: Vec<df_ai::provider::ToolDefinition> = if conf >= INTENT_CONF_THRESHOLD {
let filtered = if df_ai::planner::PLANNING_ENABLED {
// Phase 1:plan_hint 编排排序。intent_label 供 plan_hint 备用(当前规则纯关键词驱动)。
filter_tool_defs_planned(&all_defs, &intent, intent.as_str(), &user_text)
} else {
// 旧行为:intent 收敛扁平子集(零行为变更,flag 默认关走此路)。
filter_tool_defs(&all_defs, &intent)
};
if filtered.len() < 3 {
all_defs.clone() // 兜底:过滤<3(漂移/误收敛)回全量
} else {
filtered
}
} else {
all_defs.clone() // 低置信 fallback 全量
};
// 跨轮工具集冻结(前缀稳定):tools 数组在请求中位于 system 之后、消息之前,
// 每轮按末条 user 消息重新裁剪会让数组跨轮变化 → 前缀字节断,自动前缀缓存
// (DeepSeek/GLM/Qwen)与 Anthropic 显式缓存全部失效。改按会话冻结:首次裁剪结果
// 入冻结集,后续轮只增不减(新意图带来的工具并入,已并入的不移除)。
// 顺序稳定:冻结集是 all_defs 的子集过滤,all_defs 顺序恒定 → 数组字节只增不改位。
let mut frozen_names = {
let session = session_arc.lock().await;
session
.conv_read(&conv_id)
.and_then(|c| c.frozen_tool_names.clone())
};
let mut changed = false;
match frozen_names.as_mut() {
Some(prev) => {
for d in &computed {
if !prev.contains(&d.function.name) {
prev.push(d.function.name.clone());
changed = true;
}
}
}
None => {
if !computed.is_empty() {
frozen_names = Some(computed.iter().map(|d| d.function.name.clone()).collect());
changed = true;
}
}
}
if changed {
let mut session = session_arc.lock().await;
// contains_key 守卫防 conv() 惰性建(循环入口已桥接,正常恒存在,防御性保留)。
if session.per_conv.contains_key(&conv_id) {
session.conv(&conv_id).frozen_tool_names = frozen_names.clone();
}
}
match &frozen_names {
Some(names) => all_defs
.into_iter()
.filter(|d| names.contains(&d.function.name))
.collect(),
None => computed,
}
};
tracing::info!(
conv_id = %conv_id,
intent = intent.as_str(),
conf,
filtered = tool_defs.len(),
total,
planning_enabled = df_ai::planner::PLANNING_ENABLED,
"[ai] 意图收敛工具"
);
// 停止信号副本 / notify / loop_epoch / last_heartbeat 已在 loop 入口(L593)一次锁内取用
// (stop_flag/notify 供 stream_llm 与每轮迭代共享,loop_epoch 供 owner 判定,last_heartbeat 供存活心跳)。
// token 累加器:loop 生命周期内各轮叠加,退出时传 save_conversation(累加模式落库)
let mut tokens = TokenAccumulator::default();
// 会话级 token 落库口径:save_conversation 内部做 old+add,各 save 传「自上次 save 的增量」
// (usage_delta_since)。传累计快照会被同 loop 多次 save 重复累加(双计);增量口径同轮幂等,
// 审批暂停→恢复跨 loop 实例仍由 old+add 兜住(新实例 snapshot 归零)。
let mut saved_token_snapshot = df_ai::provider::TokenUsage::default();
// 收敛标志:仅当 LLM 末轮无 tool_calls 自行 break(正常收敛)时置 true;
// 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常
let mut converged = false;
// L1 断路器:连续同类工具失败滚动窗口(VecDeque<key>,长度封顶 CIRCUIT_BREAKER_WINDOW)。
// key=结构化信号(tool_name + 错误类别)。每轮 process_tool_calls 后追加末尾失败 key,
// 超窗自动淘汰最旧。窗口内同 key 计数达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。
// 治"全 loop 累加不衰减":早期偶发失败不会与后期叠加误熔断。
let mut fail_window: std::collections::VecDeque<String> = std::collections::VecDeque::new();
// L1 断路器两段式警示位(对齐 G2 WARN_FIRST):同 key 失败达 THRESHOLD-1 时注入一次
// 系统警示给 LLM 自纠机会,达 THRESHOLD 才熔断。置位后本 loop 不重复警示。
let mut breaker_warned: bool = false;
// G2 探索熔断:重复探索计数器(loop 生命周期累计,与 fail_window 同生命周期)。
// 每轮 process_tool_calls 后 check_stall_breaker 取最近 N 个 assistant tool_calls 签名,
// 喂 is_repetitive_exploration 判重复,重复 stall_count+=1,非重复重置 0。
// 达 STALL_BREAKER_THRESHOLD → 警示/熔断(治 R4 游荡死循环 + token 失控)。
// 注:2026-08-01 从「末尾 Tool 结果是否空(is_empty_tool_result)」改为「调用签名重复」,
// 治旧范式误熔断正常排除式搜索(实证会话 ac448296)。
let mut stall_count: u32 = 0;
let mut stall_warned: bool = false;
// G2 破坏性回环计数器(独立于 stall_count,互不干扰):写删同路径回环对检出累计,
// 治「写完就删」自毁循环(签名范式对写删交替漏网)。警示/阈值复用 STALL_BREAKER_*。
let mut loop_count: u32 = 0;
let mut loop_warned: bool = false;
// 目标验证续轮计数(GOAL_VERIFY_ENABLED 门控):末轮无 tool_calls 但 pinned_goals 未达成
// 且无 applied.matched_target 证据时强制续轮,每续 +1。达 GOAL_VERIFY_MAX_ROUNDS 强制收敛
// + AiCompleted 标注 goal_unverified。续轮内调了工具则重置(说明 LLM 在行动非空转)。
let mut goal_verify_rounds: u32 = 0;
// DeepSeek thinking 模式推理内容跨轮透传
let mut last_reasoning_content: Option<String> = None;
// G4.3(2026-08-05):记录本轮 token 用量是否估算值(round_usage.prompt_tokens==0 →
// estimated_prompt 兜底处打标)。loop 内各轮更新,退出路径(MidStream/stop 等在 loop 内
// 直接用 round_usage 判;max_iterations/正常完成在 loop 后用本字段)透传 AiCompleted 仅展示,
// 不参与 TokenAccumulator/save 累加。
let mut last_round_estimated: bool = false;
// G1 目标钉扎:入口把 PerConvState.pinned_goals 拼进 system_prompt 尾部(一次拼好整个 loop 复用)。
//
// 治 R1(目标消息被压缩出局)/R5(prompt 说教无锚点):system_prompt 是 loop 不变量 + build_for_request
// 从不裁剪它,故目标天然免疫压缩/裁剪/sanitize。本块是治 R1 的结构性根因(目标进 prompt 字符串非
// messages 流,无 insert_at(0) 的连续 System 1214/首位锚点稀释/小预算被裁三重风险)。
//
// 单次 lock 读 pinned_goals clone(复用 stop_flag 取用模式,同一 lock 块);非空 → 逐条截断到
// GOAL_MAX_CHARS,按 GOAL_INJECT_BANNER 拼 banner+编号列表。GOAL_PIN_ENABLED=false → 整块跳过,
// pinned_goals 永远空 Vec(单点回退等价改动前)。拼接在 sys_tokens 估算前。
//
// 注:仅 run_agentic_loop 入口注入。手动压缩/标题/提炼等路径不注入目标。
//
// 目标提取方式(2026-07-03 改):从 LLM 本轮工具调用推理目标描述(工具名+路径),
// 不再从用户消息规则提取,也不依赖 LLM 输出结构化标记。
// 推理结果存入 pinned_goals: Vec<GoalEntry>,含 text+status 状态追踪。
// 新目标加入时自动标记之前的 active 为 completed。
//
// 入口 system_prompt 六段拼接(G1 目标钉扎 / T5 工作上下文 / Env+behavior / 派工纪律 /
// 技能清单 / T4 工作流 DAG)收敛至 build_runtime_prompt helper:一次锁读 per_conv 三字段
// (pinned_goals / working_context / workflow_dag_summary)消除原 G1/T5/T4 三处独立 lock,
// 且返回 pinned_goals 复用作 AiCompleted 快照(消除原 L1157 二次读)。拼接顺序+内容逐字节等价。
let (system_prompt, pinned_goals_snapshot) = build_runtime_prompt(
&session_arc, &conv_id, system_prompt, "",
).await;
// ── Plan-driven Phase 1:LLM 规划端(AICHAT_PLAN_ENABLED 门控) ──
//
// 对齐 docs/02-架构设计/专项设计/aichat-plan-driven-设计-2026-08-01.md Phase 1:
// 开启时由 LLM 生成 Plan JSON(替代 coordinator.decompose 关键词匹配),成功后 emit
// AiPlanCreated 事件(步骤 + 状态 pending)供前端展示(前端卡片 Phase 3 后续,本步只 emit)。
//
// **职责边界(对齐设计文档)**:
// - Phase 1 只治「Plan 从哪来」(LLM 出 Plan)+ 事件可见性,**不改执行逻辑**;
// - 执行调度(Plan→to_layers→并行/串行 dispatch)是 Phase 2 范围,留 plan_executor 接通后做;
// - 故本块 emit 后**继续走 ReAct 主链**(不 early return),Plan 仅作规划可见性,执行仍单链 ReAct。
//
// **开关默认关 + 兜底回退**(memory ai-improvement-principles):
// - AICHAT_PLAN_ENABLED=false(默认)→ 整块跳过,零行为变更(现有 ReAct 不受影响);
// - LLM 调用失败/JSON 解析失败/validate 失败 → decompose_with_llm 返 None → 整块跳过,
// 继续走 ReAct(不阻断,三重兜底);
// - 与 plan_executor::PLAN_EXECUTION_ENABLED 正交:执行开关独立治 dispatch,本块不碰。
//
// LLM 调用复用主对话 provider + resolved_model(已解析,无需二次 build);available_tools
// 喂 LLM 限定 tool_hint 取值域防幻觉(取 tool_defs 名清单,即 LLM 可见工具子集)。
if df_ai::coordinator::aichat_plan_enabled() && conf >= INTENT_CONF_THRESHOLD {
let coord = Coordinator::new(PersonaRegistry::new());
// 可用工具名清单(供 LLM 填 tool_hint,防幻觉不存在的工具名)
let available_tools: Vec<String> = tool_defs
.iter()
.map(|d| d.function.name.clone())
.collect();
// LLM 调用包 30s 超时(对齐 compress_via_llm 60s / title 20s 量级):规划属独立 LLM
// 调用,超时返 None 回退 ReAct,不拖垮 agentic loop。
let plan_result = tokio::time::timeout(
std::time::Duration::from_secs(30),
coord.decompose_with_llm(
&*provider,
&resolved_model,
intent.as_str(),
&user_text,
&available_tools,
),
)
.await;
match plan_result {
Err(_) => {
tracing::warn!(
conv_id = %conv_id,
"[PLAN-LLM] LLM 规划调用超时(30s),回退纯 ReAct"
);
}
Ok(Some(decompose_result)) => {
// emit AiPlanCreated(步骤 + 状态 pending)供前端展示(Phase 3 前端卡片后续)
let layers_payload = build_plan_layers_payload(&decompose_result.plan, &coord);
let plan_id = format!("plan-{}", ulid_like_id());
tracing::info!(
conv_id = %conv_id,
plan_id = %plan_id,
layer_count = layers_payload.len(),
task_count = decompose_result.subtasks.len(),
"[PLAN-LLM] Plan 创建,emit AiPlanCreated"
);
// 双写收敛经 emit_ai_chat_event:同一份事件值双路(原 publish 端重建 payload 的
// 写法随之消失,build_plan_layers_payload 只调一次,值等价)。
emit_ai_chat_event(&app_handle, AiChatEvent::AiPlanCreated {
plan_id,
layers: layers_payload,
conversation_id: Some(conv_id.clone()),
});
// Phase 1:emit 后继续走 ReAct 主链(执行调度是 Phase 2)。
// Plan 仅作规划可见性 + 前端展示;执行仍单链 ReAct,不改 dispatch 逻辑。
}
Ok(None) => {
// 三重兜底已触发(LLM 错/解析错/validate 错),decompose_with_llm 内已记 warn。
// 静默继续 ReAct(不阻断)。
tracing::info!(
conv_id = %conv_id,
"[PLAN-LLM] LLM 规划未产出有效 Plan,继续走 ReAct"
);
}
}
}
if df_ai::plan_executor::plan_execution_enabled() && conf >= INTENT_CONF_THRESHOLD {
let coord = Coordinator::new(PersonaRegistry::new());
let decompose_result = coord.decompose(intent.as_str(), &user_text);
tracing::info!(
conv_id = %conv_id,
intent = intent.as_str(),
subtask_count = decompose_result.subtasks.len(),
has_plan = !decompose_result.plan.is_empty(),
"[COORDINATOR] 意图分解完成"
);
if !decompose_result.plan.is_empty() {
// 串行执行子任务(Phase 1 简单路径,后续可升级并行 dispatch_with_budget)
let results = coord.dispatch(&decompose_result.plan, |subtask, persona| async move {
ExecutionResult {
subtask_id: subtask.id.clone(),
persona_id: persona.id.clone(),
output: format!(
"### 子任务: {}({})\n\n意图: {}",
subtask.intent, persona.name, subtask.intent
),
success: true,
}
}).await;
// 合并子任务结果
let merge_result = coord.merge(&results);
// 将合并产出推回主对话(单条可展开 assistant 消息)
// 消息级 token:Coordinator 路径无本轮 LLM(子任务已各自计入其会话/或未计入),
// 此处为合成汇总非直接 LLM 输出,与下方 emit coord_usage(空 tokens 快照)对齐 → 0。
{
let mut session = session_arc.lock().await;
let mut merge_msg = ChatMessage::assistant(&format!(
"## 多 Agent 执行完成\n\n共执行 {} 个子任务,成功 {} 个。\n\n{}",
results.len(),
results.iter().filter(|r| r.success).count(),
merge_result.merged_output
));
merge_msg.prompt_tokens = Some(0);
merge_msg.completion_tokens = Some(0);
merge_msg.prompt_cache_hit_tokens = Some(0);
merge_msg.prompt_cache_miss_tokens = Some(0);
merge_msg.reasoning_tokens = Some(0);
session.conv(&conv_id).messages.push(merge_msg);
if !merge_result.conflicts.is_empty() {
let mut conflict_msg = ChatMessage::assistant(&format!(
"### 冲突检测\n\n检测到 {} 个文件冲突:\n{}",
merge_result.conflicts.len(),
merge_result.conflicts.iter()
.map(|c| format!("- {}: {}", c.file, c.description))
.collect::<Vec<_>>()
.join("\n")
));
conflict_msg.prompt_tokens = Some(0);
conflict_msg.completion_tokens = Some(0);
conflict_msg.prompt_cache_hit_tokens = Some(0);
conflict_msg.prompt_cache_miss_tokens = Some(0);
conflict_msg.reasoning_tokens = Some(0);
session.conv(&conv_id).messages.push(conflict_msg);
}
}
// 落库 + generating 复位 + emit AiCompleted(统一走 teardown_round,行为零变更)
// Coordinator 路径:save(None,None) + 不 spawn_title + emit(None,None,publish=true)
// emit_usage=tokens 快照(本路径无 round, tokens 为空, total=0 对齐原 tokens.total())
let coord_usage = tokens_snapshot(&tokens);
teardown_round(
&session_arc, &db, &conv_id,
None, None,
false,
&provider_config, &llm_concurrency,
&mut guard,
&coord_usage,
// Coordinator 路径无 round(未 stream),无估算语义 → false。
false,
None, None, None, true,
&pinned_goals_snapshot,
&app_handle,
&loop_epoch_arc, my_epoch,
true,
).await;
return;
}
}
// system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变),
// 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
// 并发限流策略(用户决策「不设并发会话上限」):
// - global 不再由 loop 入口持整 loop(原会话数上限 N=3 已废),多对话 loop 并发不限。
// - per_conv 由 loop 入口(_conv_per_conv_permit)整 loop 持有(含工具执行/审批等待/重试),
// permits=2 防单对话内并发 LLM 调用失控。
// - permit 绑 guard(函数返回)Drop 自动释放——各 return 点退出即释放槽位。
// retry 同 loop 内,持 per_conv 合理。
//
// acquire 包 30s 超时:阻塞式 acquire 在同 conv 配额被占满时永久 parked(如竞态双 spawn 的
// 第三个 loop),而 heartbeat 任务已在此前启动持续喂前端看门狗 → UI 永远转圈无终止事件。
// 超时走 AiError 收敛,范式对齐上方 provider 解析 30s 超时:guard.reset → epoch 检查 →
// 落库 user 消息 → emit → return。
let _conv_per_conv_permit = match tokio::time::timeout(
std::time::Duration::from_secs(30),
llm_concurrency.acquire_per_conv(&conv_id),
).await {
Ok(permit) => permit,
Err(_elapsed) => {
guard.reset().await;
// 旧 loop 不 emit(被新 loop 接管时,错误由 owner loop 负责)。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
return;
}
// 入口超时早退也落库 user 消息(调用方已 push 内存,防重启/切会话丢末条 user)。
save_conversation(&session_arc, &db, &conv_id, None, None, true).await;
tracing::error!(conv_id = %conv_id, "[ai] 等待 per_conv 并发配额超时(30s),同对话 loop 疑似积压");
let err_msg = "等待对话并发配额超时(30s),请稍后重试".to_string();
emit_ai_chat_event(&app_handle, AiChatEvent::AiError {
error: err_msg,
error_type: Some(ErrorType::Unknown),
conversation_id: Some(conv_id.clone()),
});
return;
}
};
// 防瞬时抖动(G4.1):per-provider 连续饱和计数(跨迭代累计)。
// 候选循环内 Exhausted +1 / Acquired 清零;链首(主 provider)连续
// PROVIDER_SATURATION_DEMOTE_THRESHOLD 次饱和 → 候选链构建时降级到链尾(见链构建处)。
// 仅 per-provider 层非阻塞降级,不影响 global/per_conv 阻塞限流。
let mut provider_saturation: HashMap<String, usize> = HashMap::new();
// 跨轮任务级重入(ROUND_REENTRY_MAX):连续轮失败计数,Continue 成功即归零
// (只认连续失败,偶发一轮网络故障不消耗后续耐受力)。
let mut consecutive_round_failures: usize = 0;
// 0 = 不限:effective_max=usize::MAX,for 到不了上界,靠 stop_flag/收敛/审批退出(下方达上限暂停分支不触发)
let effective_max = if max_iterations == 0 { usize::MAX } else { max_iterations };
// 末轮注入扩轮(todo.md 28):for 上界放宽 CAP,真实终点由轮顶动态判定(下方);
// 无注入扩轮时动态判定与原上界等价,行为零变化。扩轮总数封顶防注入洪水无限续跑。
let mut injected_extra_rounds: usize = 0;
// H2 token 三闸基点:loop 启动挂钟起点(轮间粒度检查,单轮不中断)
let loop_started = tokio::time::Instant::now();
// H3 外部干预感知基点:loop 启动时在被监视工作区取 git HEAD 快照(轮间粒度比对)。
// 设计口径:loop_epoch 管「引擎内接管」,H3 管「引擎外干预」——用户/另一 AI 会话在 loop
// 运行期改写了工作区 HEAD(切分支/commit/reset),当前任务的工作前提已失效,继续跑有破坏面。
// 无监视目录/非 git 仓/命令失败=无法感知,静默跳过(探测是增益非阻断)。
let h3_baseline_head = if let Some(dir) = h3_watch_dir.as_deref() {
let (out, ok) = crate::commands::ai::tools::git::git_exec(
"rev-parse", &["HEAD"], dir,
).await;
if ok { out.trim().to_string() } else { String::new() }
} else {
String::new()
};
// panic 防护:loop 主体(for + 达上限收尾 + 正常完成收尾)整体包 catch_unwind。
// 背景:本函数由 IPC 层 tauri::async_runtime::spawn 托管,主体 panic 时任务静默死亡,
// conv 卡 Generating(无终态事件,前端无法恢复)。捕获后经 unwind_to_result 把 panic 转
// 受控 Err,走既有 teardown_round 正典出口补发终态;ConvState 复位由 guard 的 Drop 兜底
// 在 unwind 时完成,此处补齐 save 落库与 AiCompleted emit。
// 借用结构:async 块非 move,guard/tokens/converged 等仍由外层函数持有,unwind 销毁的是
// 块帧,外层变量完好,panic 分支才能用同一份 guard/tokens 走 teardown_round。
// 范围:仅 loop 主体;provider 解析/配额获取等已有独立错误分支的段落不包,避免语义重叠。
// 依赖 panic=unwind(tokio 默认),panic=abort 构建下 catch_unwind 不生效(尽力兜底)。
let outcome = unwind_to_result(async {
for iteration in start_iteration..effective_max.saturating_add(INJECT_EXTRA_ROUNDS_CAP) {
// ── 末轮注入扩轮判定(todo.md 28):到原轮次终点时消费注入侧顺延余量,有则续跑
// (补充指令获得带工具的完整消费轮),无则 break 与原 for 自然结束等价,落下方
// 既有 max_rounds 收尾分支(收敛出口零改动)。判定收敛到纯函数,单测锁契约。
if iteration >= effective_max.saturating_add(injected_extra_rounds) {
let granted = {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&conv_id) {
let c = session.conv(&conv_id);
std::mem::take(&mut c.round_budget_extra)
} else {
0
}
};
match inject_extra_rounds_decision(effective_max, injected_extra_rounds, granted) {
Some(take) => {
injected_extra_rounds += take;
tracing::info!(
conv_id = %conv_id,
extra_rounds = injected_extra_rounds,
"[ai] 末轮注入触发扩轮,轮次预算顺延 {} 轮", take
);
}
None => break,
}
}
// ── H2 三闸(B2):轮间粒度检查,超限安全停靠(轮内不中断,下一轮头生效) ──
{
let elapsed = loop_started.elapsed().as_secs();
let total_tokens = tokens.total() as u64;
let over = if elapsed >= UNATTENDED_WALL_SECS {
Some((
"wall_clock",
format!("任务运行超 {} 分钟,安全停靠:已完成进度已保存,点继续或重发消息可续跑", UNATTENDED_WALL_SECS / 60),
))
} else if total_tokens >= UNATTENDED_MAX_TOKENS {
Some((
"token_budget",
format!("任务累计消耗达 {} tokens 上限,安全停靠:防无人值守预算失控,点继续或重发消息可追加授权续跑", UNATTENDED_MAX_TOKENS),
))
} else {
None
};
if let Some((reason, message)) = over { tracing::warn!(conv_id = %conv_id, elapsed, total_tokens, "[ai] H2 闸触发({reason}),任务安全停靠");
crate::commands::ai::emit_task_parked(&app_handle, &conv_id, &message, reason);
// 走 teardown 正典收尾(save + guard 复位 + AiCompleted(incomplete))再退
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let emit_usage = tokens_snapshot(&tokens);
teardown_round(
&session_arc, &db, &conv_id,
Some(&save_usage), Some(&resolved_model),
false, &provider_config, &llm_concurrency, &mut guard,
&emit_usage, last_round_estimated,
Some(true), None, Some(reason), true,
&pinned_goals_snapshot, &app_handle, &loop_epoch_arc, my_epoch, false,
).await;
return;
}
// H3 外部干预感知(B4):每 5 轮探测一次 git HEAD(轮间粒度,非 git 仓跳过)。
// HEAD≠基线=有人动了工作区(切分支/reset/外部 commit),任务工作前提失效,
// 安全停靠交还用户决策。轻量 rev-parse ~10ms,5 轮一次可忽略。
if !h3_baseline_head.is_empty()
&& iteration % PROGRESS_EVERY_N_ROUNDS == 0
{
let head_now = crate::commands::ai::tools::git::git_exec(
"rev-parse", &["HEAD"], h3_watch_dir.as_deref().unwrap_or("."),
).await;
if head_now.1 && head_now.0.trim() != h3_baseline_head {
tracing::warn!(
conv_id = %conv_id,
baseline = %h3_baseline_head,
current = %head_now.0.trim(),
"[ai] H3 检测到 git HEAD 被外部改写,任务安全停靠"
);
crate::commands::ai::emit_task_parked(
&app_handle, &conv_id,
"检测到代码仓库 HEAD 在任务运行期间被外部修改(可能是切分支/回滚/其他会话提交),任务工作前提已变化,已安全停靠:请确认仓库状态后决定继续或重新安排任务",
"external_change",
); let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let emit_usage = tokens_snapshot(&tokens);
teardown_round(
&session_arc, &db, &conv_id,
Some(&save_usage), Some(&resolved_model),
false, &provider_config, &llm_concurrency, &mut guard,
&emit_usage, last_round_estimated,
Some(true), None, Some("external_change"), true,
&pinned_goals_snapshot, &app_handle, &loop_epoch_arc, my_epoch, false,
).await;
return;
}
}
}
// ── 进度可观测段(设计 §4.3):每 N 轮 emit AiTaskProgress ──
// round 1 起算对齐 AiAgentRound 口径;max_rounds=0=不限轮次(前端按无上限处理)。
// owner 守卫:被接管的旧 loop 不发(防串会话进度)。事件即逝不落库,终局不补发。
if (iteration % PROGRESS_EVERY_N_ROUNDS == 0 || iteration == start_iteration)
&& loop_epoch_arc.load(Ordering::SeqCst) == my_epoch
{
emit_ai_chat_event(&app_handle, AiChatEvent::AiTaskProgress {
conversation_id: Some(conv_id.clone()),
round: (iteration + 1) as u32,
max_rounds: (max_iterations + injected_extra_rounds) as u32,
tokens_used: tokens.total(),
});
}
// ── 轮次准备段:入口 stop/stale 收尾闸门 → 主题标记 → conv 一致性(iteration_used/epoch/心跳)
// → 轮通知 → 自动压缩 → 请求消息构建(build_for_request + namespace 展开 + 易变尾段
// + tool_result 摘要 + 占位断言)→ estimated_prompt + agentic_req 重建 ──
// (实现见下方 prepare_round_inputs,Exit → 本轮终止 return,与原 early-return 等价)
let (messages, estimated_prompt, agentic_req) = match prepare_round_inputs(
&session_arc, &db, &app_handle, &conv_id, iteration,
&stop_flag, &loop_epoch_arc, my_epoch,
&mut guard, &mut tokens, &mut saved_token_snapshot,
last_round_estimated, &provider_config, &llm_concurrency,
&pinned_goals_snapshot, sys_tokens, &system_prompt, &volatile_tail, &intent,
).await {
PreparedRound::Ready { messages, estimated_prompt, agentic_req } => {
(messages, estimated_prompt, agentic_req)
}
PreparedRound::Exit => return,
};
// ── 流式消费段:单轮流式调用(candidate 链 + MidStream 保文 + 跨轮重入)──
// 实现见 stream_relay::stream_round。RoundOutcome 四态:Continue 携输出继续;
// StaleAfterStream(流后 epoch 变,guard 已 disarm)/ MidStreamSaved(保文已完整收尾)/
// Fatal(候选 Fatal 或全耗尽,guard.reset + emit + save 已完成)。
// 'stream_round 重入层(ROUND_REENTRY_MAX):Fatal 不再立即终局,指数退避后从
// 当前轮重入(stream_relay 每 per-permit 重取重入获全新 30s 预算 + 全新候选链)。
let (full_text, tool_calls_acc, round_usage, round_is_estimated, round_truncated) = 'stream_round: loop {
match stream_round(
&session_arc, &db, &conv_id,
&messages, &tool_defs, &app_handle,
&stop_flag, &notify,
max_retries, &model_override, &agentic_req, estimated_prompt,
&mut last_round_estimated, &mut last_reasoning_content,
&mut resolved_model, &mut provider_config,
&mut provider_saturation,
&mut tokens, &mut saved_token_snapshot, &mut guard,
&candidates, &llm_concurrency,
&pinned_goals_snapshot,
&loop_epoch_arc, my_epoch,
).await {
RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated, truncated_by_max_tokens } => {
// 本轮成功:连续失败计数归零(重入只认连续失败)。
consecutive_round_failures = 0;
break (full_text, tool_calls, round_usage, round_is_estimated, truncated_by_max_tokens);
}
RoundOutcome::StaleAfterStream => return,
RoundOutcome::MidStreamSaved => {
// 安全停靠(设计 §四):MidStream 原裸 return=整任务静默终止(保文
// AiCompleted(incomplete) 无停靠语义,托盘零感知)。对齐 Fatal 路径补
// AiTaskParked + NeedAction 通知。teardown/save/guard 复位已由
// stream_relay 保文路径完成(stop_reason="network"),此处不重复 save。
crate::commands::ai::emit_task_parked(
&app_handle, &conv_id,
"响应流中途断开,已接收的部分内容已保存;任务安全停靠,重发消息可继续",
"midstream",
);
return;
}
RoundOutcome::Fatal { error } => {
// P2-4:error 已由 stream_round emit 错误气泡,此处仅留诊断日志
// (真实原因不再只进子会话事件面,排查有据可查)。
consecutive_round_failures += 1;
if consecutive_round_failures > ROUND_REENTRY_MAX {
// 连续重入预算耗尽:转停靠(网络故障已超出轮间退避可覆盖范围)。
tracing::warn!(
conv_id = %conv_id,
failures = consecutive_round_failures,
error = %error,
"[ai] 连续 {} 轮失败,重入预算耗尽,任务停靠",
consecutive_round_failures,
);
crate::commands::ai::emit_task_parked(
&app_handle, &conv_id,
&format!("模型调用连续 {} 轮失败(重入退避后仍未恢复),任务安全停靠:{}", consecutive_round_failures, error),
"fatal",
);
return;
}
let delay = ROUND_REENTRY_BACKOFF_SECS[consecutive_round_failures - 1];
tracing::warn!(
conv_id = %conv_id,
attempt = consecutive_round_failures,
max_attempts = ROUND_REENTRY_MAX,
delay_secs = delay,
error = %error,
"[ai] 本轮失败,{}s 后从当前轮重入({}/{}): {}",
delay, consecutive_round_failures, ROUND_REENTRY_MAX, error,
);
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
// 退避后不显式查 stop/epoch:stream_round 内部闸门已兜
// (stop_flag→停止路径由 finalize 收尾、epoch 后校验→StaleAfterStream),
// 重入即被正确接管,无需重复判定。
continue 'stream_round;
}
}
};
// global/per_conv permit 已上移 loop 入口整 loop 持有,
// stream 后不再立即释放(会话级并发语义:工具执行期间也占槽)。per-provider permit
// (_provider_permit)仍在 candidate 循环内随作用域 Drop 自动释放。
// ── 轮收尾段:token 累加 + assistant push(+ G1 目标提取)+ 落库 + stop 收尾退出
// + 收敛/目标验证续轮判定 ──
// (实现见下方 finalize_round_output;Converged → 正常收敛,置 converged 后 break)
match finalize_round_output(
&session_arc, &db, &app_handle, &conv_id, iteration,
&stop_flag, &loop_epoch_arc, my_epoch,
&mut guard, &mut tokens, &mut saved_token_snapshot,
last_round_estimated, &resolved_model, &provider_config, &llm_concurrency,
&pinned_goals_snapshot,
&full_text, &tool_calls_acc, &round_usage, round_is_estimated,
&last_reasoning_content,
&mut goal_verify_rounds,
round_truncated,
).await {
// RunTools → 继续工具调用段(工具调用数由该段内部取,与原时序一致)
RoundEpilogue::RunTools => {}
RoundEpilogue::Converged => {
// 无工具调用且无续轮需求 → 正常收敛退出(原 converged = true; break,语义不变)
converged = true;
break;
}
RoundEpilogue::Exit => return,
};
// ── 工具调用段:工具执行(Low 自动 / Medium+High 待审批)+ 三熔断(L1 失败/G2 探索/
// 写删回环)+ 审批暂停 ──
// (实现见下方 process_round_tools,Exit → 本轮终止 return)
if let ToolsOutcome::Exit = process_round_tools(
&session_arc, &tools_arc, &db, &app_handle, &conv_id, iteration,
&loop_epoch_arc, my_epoch,
&mut guard, &mut tokens, &mut saved_token_snapshot,
&resolved_model,
tool_calls_acc,
&mut fail_window, &mut breaker_warned,
&mut stall_count, &mut stall_warned, &mut loop_count, &mut loop_warned,
).await {
return;
}
// 全部自动执行完成 → 继续下一轮
}
// 达 MAX 未收敛(LLM 末轮仍想调工具被截断,末轮 tool_result 不再回传 LLM):自动完成收尾。
// 历史为暂停态(emit AiMaxRoundsReached + 保持 generating=true 仿审批等待,等用户点继续/
// 停止),因操作卡不可达时 generating 永真无声卡死,已改为 guard 复位 + teardown_round 发
// AiCompleted(incomplete=true) 自动终止;前端续/停操作卡(pendingMaxRounds)随之成死链。
// ai_continue_loop/ai_stop_loop IPC 仍保留,try_continue 续跑 iteration 由调用方传
// start_iteration 决定:审批续跑累计(防多次审批反复跑满 max 致 token 失控,传 session.iteration_used),
// 达 max 续跑重计(用户点继续=授权重来,传 0 + 重置 iteration_used)。
// 注:max_iterations=0(不限)时 effective_max=usize::MAX,for 不会正常结束至此,不触发本分支(靠 stop/收敛/审批退出)
if !converged {
tracing::warn!(
conv_id = %conv_id,
max_iter = max_iterations,
"[ai] agentic 循环达最大轮次仍未收敛,补一次无工具收尾轮再自动完成(incomplete=true)",
);
// ── 熔断收尾轮(2026-10-06):max_rounds 打满是常态,旧逻辑直接截断时父任务/
// 消费方拿到的是「末轮思考独白甚至半截工具调用」,而非任务报告。修法:补一次
// 无工具(tools=None)收尾请求——LLM 只能输出文本,即最终总结报告。失败(全候选
// 耗尽/网络断)不重试不停靠,静默落回原截断收尾(报告是增益非闸门)。
{
let (wrapping, est, req) = match prepare_round_inputs(
&session_arc, &db, &app_handle, &conv_id, effective_max.saturating_sub(1),
&stop_flag, &loop_epoch_arc, my_epoch,
&mut guard, &mut tokens, &mut saved_token_snapshot,
last_round_estimated, &provider_config, &llm_concurrency,
&pinned_goals_snapshot, sys_tokens, &system_prompt, &volatile_tail, &intent,
).await {
PreparedRound::Ready { messages, estimated_prompt, agentic_req } => {
(messages, estimated_prompt, agentic_req)
}
// 收尾轮入口闸门不过(stop/stale):guard/收尾已由 prepare 内部处理,直接退
PreparedRound::Exit => return,
};
// 注入收尾指令:明确告知「这是最后一轮,输出任务总结报告」(无工具可调)
let mut wrap_msgs = wrapping;
wrap_msgs.push(ChatMessage::system(
"【任务收尾】本轮次预算已用完,这是最后一轮。请基于以上全部工作输出最终任务总结报告:已完成事项/关键结论/遗留问题与建议。不要再尝试调用任何工具。",
));
match stream_round(
&session_arc, &db, &conv_id,
&wrap_msgs, &[], &app_handle,
&stop_flag, &notify,
0, &model_override, &req, est,
&mut last_round_estimated, &mut last_reasoning_content,
&mut resolved_model, &mut provider_config,
&mut provider_saturation,
&mut tokens, &mut saved_token_snapshot, &mut guard,
&candidates, &llm_concurrency,
&pinned_goals_snapshot,
&loop_epoch_arc, my_epoch,
).await {
// 收尾轮成功:push 报告文本 + 落库(报告进会话,消费方可见)。无工具调用,
// finalize 走收敛分支不会再起轮;此处手动 push(不经 finalize,其会 emit 终态)。
RoundOutcome::Continue { full_text, .. } => {
let text = full_text.trim();
if !text.is_empty() {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&conv_id) {
let conv = session.conv(&conv_id);
let mut msg = ChatMessage::assistant(text);
msg.model = Some(resolved_model.clone());
conv.messages.push(msg);
}
drop(session);
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
save_conversation(&session_arc, &db, &conv_id, Some(&save_usage), Some(&resolved_model), false).await;
}
}
// 收尾轮任何失败(MidStream/Fatal/Stale):不重试不停靠,
// 落回下方原截断收尾路径(guard 可能已被 stream 内部处理,teardown 幂等)。
_ => {
tracing::warn!(conv_id = %conv_id, "[ai] 熔断收尾轮失败,落回截断收尾");
}
}
}
// save_usage 增量;emit_usage 累计快照(前端展示本 loop 总量)
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let emit_usage = tokens_snapshot(&tokens);
// P1 bg 化:达轮次上限属需介入(等用户点继续或停止),系统通知补位(条件发送)。
// 本出口 teardown 通知关(false)防同一事件双弹。
crate::commands::ai::notify::notify_session_event(
&app_handle,
crate::commands::ai::notify::NotifyKind::NeedAction,
&conv_id,
"已达最大轮次",
"AI 循环未收敛,已达最大轮次,已自动收尾",
);
// 统一走 teardown_round:save(Some usage, Some model) + 不 spawn_title(对齐原无 title) +
// emit(Some(true), publish=false)。**do_publish=false 保留原 max_iterations 不 publish 行为**
// (与其他 5 路径不一致是历史现状,本次仅收敛重复代码不改 publish 策略,语义零变更)。
teardown_round(
&session_arc, &db, &conv_id,
Some(&save_usage), Some(&resolved_model),
false,
&provider_config, &llm_concurrency,
&mut guard,
&emit_usage,
last_round_estimated,
Some(true), None, Some("max_rounds"), false,
&pinned_goals_snapshot,
&app_handle,
&loop_epoch_arc, my_epoch,
false,
).await;
// 安全停靠(设计 §四):预算(轮次)耗尽=五路非正常终局之一。teardown 已完成
// save + generating 复位 + AiCompleted(incomplete)+ NeedAction 通知(5s 节流
// 与停靠通知同组合并不会刷屏),此处补结构化 AiTaskParked 供前端操作卡消费。
crate::commands::ai::emit_task_parked(
&app_handle, &conv_id,
&format!("已达最大轮次({}),任务安全停靠:未完成事项已随会话保存,点继续或重发消息可追加授权续跑", max_iterations),
"max_rounds",
);
return;
}
// 正常完成
// F1 并发 epoch:正常完成前若已被新 loop 接管,不 spawn 后台 save/标题/提炼、不 reset/emit
// (owner loop 负责收尾),直接退出。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
guard.disarm();
return;
}
// save_usage 增量(自上次 save 后新增,正常收敛时上轮 save 已落,此处通常为 0);
// emit_usage 用 tokens 累计快照。normal_usage.total 直接取 tokens.total() 保持三字段一致。
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
{
let session_arc = session_arc.clone();
let db = db.clone();
let conv_id = conv_id.clone();
let provider_config = provider_config.clone();
let knowledge_config = knowledge_config.clone();
let app_handle = app_handle.clone();
let llm_concurrency = llm_concurrency.clone();
let resolved_model = resolved_model.clone();
tauri::async_runtime::spawn(async move {
save_conversation(&session_arc, &db, &conv_id, Some(&save_usage), Some(&resolved_model), true).await;
// 知识提炼:需读已落库的对话消息,故在 save 之后
if let Err(e) = maybe_spawn_extraction(&session_arc, &db, &conv_id, &provider_config, &knowledge_config, llm_concurrency.clone()).await {
tracing::warn!("知识提炼触发失败(非阻断): {}", e);
}
ensure_conversation_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, llm_concurrency).await;
});
}
// F1 并发 epoch:emit 前 final stale 检查(防上方后台 spawn 块期间被 force_send 接管后仍 emit
// 重复 AiCompleted)。guard.reset 本身 epoch-aware(stale 跳过迁移),此处主要防 emit。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
guard.disarm();
return;
}
guard.reset().await;
// generating 复位后再 emit Completed:落库/标题/提炼已在后台,前端立即感知完成
// (正常完成路径 save+extract+title 已在上方 spawn 异步,此处仅 emit,故直接调 emit_ai_completed_once)
// emit_usage=tokens 快照(prompt/completion/total 全从 tokens 取,对齐原 usage_total=tokens.total())
let normal_usage = tokens_snapshot(&tokens);
// F1 并发 epoch:emit 前最后一次 stale 检查(guard.reset 已幂等,此处防 emit 重复 AiCompleted)。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
return;
}
emit_ai_completed_once(
&app_handle, &conv_id, &normal_usage,
last_round_estimated,
None, None, None, true,
&pinned_goals_snapshot,
true,
).await;
}).await;
// panic 分支:记 error(conv_id + panic 消息,不取全 backtrace),再走与既有异常分支
// (达 MAX 自动收尾)同构的 teardown_round。emit 字段对齐 try_continue 正典:AiCompleted
// 收敛信号(incomplete 三元 None + do_publish=true + notify=false,非裸 emit),职责是
// 解卡前端并清占位 tool_result 与审批残留,不代表内容真实完成。旧 loop 被 force_send
// 接管的场景由 teardown_round 内部 epoch 守卫兜住(只 disarm 不 save/emit)。
if let Err(panic_msg) = outcome {
tracing::error!(conv_id = %conv_id, "[ai] agentic loop panic,走受控收尾: {}", panic_msg);
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let emit_usage = tokens_snapshot(&tokens);
teardown_round(
&session_arc, &db, &conv_id,
Some(&save_usage), Some(&resolved_model),
false,
&provider_config, &llm_concurrency,
&mut guard,
&emit_usage,
last_round_estimated,
None, None, None, true,
&pinned_goals_snapshot,
&app_handle,
&loop_epoch_arc, my_epoch,
false,
).await;
}
}
// panic 载荷转受控错误消息:只认 String 与 &'static str 两种常见载荷,其余类型走兜底文案
// (不放全 backtrace,定位靠 conv_id + 消息;载荷不可字符串化时保语义不保细节)。
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<String>()
.cloned()
.or_else(|| payload.downcast_ref::<&'static str>().map(|s| s.to_string()))
.unwrap_or_else(|| "agentic loop panic(非字符串载荷)".to_string())
}
// loop 主体 panic 防护薄包装:Future 正常完成走 Ok,panic 转受控 Err(消息)。
// 纯函数层无 Tauri/session 依赖,场景回放见 panic_guard_tests;生产侧 run_agentic_loop 把
// loop 主体 async 块传入,Err 分支走 teardown_round 正典出口。未经过 tokio spawn,不触碰
// JoinError 语义,外层 spawn 边界行为不变。
async fn unwind_to_result<F: std::future::Future>(fut: F) -> Result<F::Output, String> {
use std::panic::AssertUnwindSafe;
match futures::FutureExt::catch_unwind(AssertUnwindSafe(fut)).await {
Ok(v) => Ok(v),
Err(p) => Err(panic_message(&*p)),
}
}
// ============================================================
// run_agentic_loop 主循环体拆解(纯搬运,行为零变更,2026-10-01)
//
// for 体原为 ~500 行内联块,按职责拆为四段:轮次准备(prepare_round_inputs)/
// 流式消费(stream_relay::stream_round,前批已外移)/ 轮收尾(finalize_round_output)/
// 工具调用(process_round_tools)。各段原 `return`/`break` 语义经返回枚举由 loop 主体
// 表达,控制流逐路径等价;锁边界、guard.reset/disarm 时序、emit/save 顺序逐字保留。
// ============================================================
/// 轮次准备段结果(原 loop 体内联 early-return 语义的函数化表达)。
enum PreparedRound {
/// 继续本轮:携请求消息 + 输入 token 估值 + 路由需求。
Ready {
messages: Vec<ChatMessage>,
estimated_prompt: u32,
agentic_req: TaskRequirements,
},
/// 本轮终止,loop 直接 return(收尾已在段内完成或依赖 guard Drop,与原 early-return 等价)。
Exit,
}
/// 轮收尾段结果。
enum RoundEpilogue {
/// 继续工具执行段(目标验证续轮空工具也走此路);本轮工具调用数由工具调用段内部取。
RunTools,
/// 无工具且无续轮需求:正常收敛(loop 主体置 converged 后 break,与原语义一致)。
Converged,
/// 本轮终止,loop 直接 return(收尾已在段内完成或依赖 guard Drop)。
Exit,
}
/// 工具调用段结果。
enum ToolsOutcome {
/// 全部自动执行完成(无待审批、未熔断)→ 继续下一轮。
NextRound,
/// 熔断/stale/审批暂停 → loop 直接 return(收尾已在段内完成或依赖 guard Drop)。
Exit,
}
// ── 轮次准备段:入口 stop/stale 收尾闸门 → 主题标记 → conv 一致性(iteration_used/epoch/心跳)
// → 轮通知 → 自动压缩 → 请求消息构建(build_for_request + namespace 展开 + system prompt +
// 易变尾段)→ tool_result 摘要 → 占位配对断言 → estimated_prompt → agentic_req 重建。──
// 原 `return` → `PreparedRound::Exit`;锁边界(标记读锁/一致性锁/build_for_request 锁/
// history_tokens 锁)与语句顺序逐字保留。
#[allow(clippy::too_many_arguments)]
async fn prepare_round_inputs(
session_arc: &Arc<Mutex<AiSession>>,
db: &Arc<Database>,
app_handle: &AppHandle,
conv_id: &str,
iteration: usize,
stop_flag: &std::sync::atomic::AtomicBool,
loop_epoch_arc: &std::sync::Arc<std::sync::atomic::AtomicU64>,
my_epoch: u64,
guard: &mut GeneratingGuard,
tokens: &mut TokenAccumulator,
saved_token_snapshot: &mut df_ai::provider::TokenUsage,
last_round_estimated: bool,
provider_config: &AiProviderRecord,
llm_concurrency: &LlmConcurrency,
pinned_goals_snapshot: &[super::GoalEntry],
sys_tokens: u32,
system_prompt: &str,
volatile_tail: &Option<String>,
intent: &Intent,
) -> PreparedRound {
// 用户请求停止 或 已被新 loop 接管(stale)→ 收尾退出(已生成文本已在上一轮入库)。
// F1:stale 判定(epoch 不匹配)让旧 loop 在新 loop 启动后立即在此退出,不再继续跑。
if stop_flag.load(Ordering::SeqCst) || loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
// save_usage 增量(自上次 save 后新增,首轮即停为 0);emit_usage 累计快照
let save_usage = usage_delta_since(tokens, saved_token_snapshot);
let emit_usage = tokens_snapshot(tokens);
// 入口 stop:本轮可能尚未 stream(首轮即停),不记 model——避免把未实际生成的 model 写入 models 数组
// 统一走 teardown_round:save(Some usage, None model) + spawn_title + emit(None,None,publish=true)
// (stale 时 teardown_round 内部仅 disarm 跳过 save/reset/emit,不干扰新 loop)
// 用户主动 stop 不发系统通知(false)
teardown_round(
session_arc, db, conv_id,
Some(&save_usage), None,
true,
provider_config, llm_concurrency,
guard,
&emit_usage,
// 入口 stop:本轮尚未 stream,last_round_estimated 仍为初始 false(无估算)。
last_round_estimated,
None, None, None, true,
pinned_goals_snapshot,
app_handle,
loop_epoch_arc, my_epoch,
false,
).await;
return PreparedRound::Exit;
}
// 改进5: 主题切换系统标记(保守,双高置信才标,软提示非强制)。
//
// push 时若末两条 user 消息的 topic 都非 None 且不同(Intent 高置信推断的双 topic),
// ContextManager 已置位 pending_topic_marker("old|new")。loop 顶部读取并消费
// (take 一次性清空,防重复 insert),insert 一条 system 软提示告知 LLM 用户已切换话题。
//
// 保守设计:
// - 双高置信:两条 topic 都非 None(都达 0.7 阈值)才标,任一 None(低置信未标)不标。
// - 软提示:仅 insert 一条 system 消息,不强制 LLM 行为(LLM 仍可按自己理解响应)。
// - TOPIC_MARKER_ENABLED(常量开关)false → 跳过(排障/对比用)。
// topic 不参与裁剪/压缩(只供检测),insert_at(0) 同压缩摘要定位(首位 system)。
if TOPIC_MARKER_ENABLED {
let topic_marker_raw: Option<String> = {
let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(conv_id) {
tracing::warn!(
stale_conv = %conv_id,
"[ai] conv 已删除,loop 退出(主题标记段入口)"
);
return PreparedRound::Exit;
}
let conv = session.conv(conv_id);
// G4 目标感知降级:总是 take_topic_marker(防 marker 累积),但若 pinned_goals 非空
// 且 TOPIC_MARKER_GOAL_AWARE → 丢弃 take 结果(返 None 跳过 insert)。
// 一次 lock 同读 pinned_goals(避免额外加锁)。take 后丢弃不影响下一轮(marker 每 push
// user 重检测生成,丢弃一次不残留)。GOAL_AWARE=false → 原样返回 marker(退旧行为)。
let goal_active = TOPIC_MARKER_GOAL_AWARE && !conv.pinned_goals.is_empty();
let marker = conv.messages.take_topic_marker();
if goal_active && marker.is_some() {
tracing::info!(
conv_id = %conv_id,
iteration,
"[ai] G4 目标钉扎已启用,跳过话题标记 insert 避免双锚点稀释"
);
None
} else {
marker
}
};
if let Some((old_topic, new_topic)) = topic_marker_raw.and_then(|s| {
// 解析 "old|new" 格式;splitn 防 topic 名内含 '|' 误切(仅切首 '|' 一次)。
let mut parts = s.splitn(2, '|');
let old = parts.next()?.to_string();
let new = parts.next()?.to_string();
Some((old, new))
}) {
let marker_text = format!(
"── 用户已切换话题(从「{}」到「{}」),请以新话题为准 ──",
old_topic, new_topic
);
// 尾部追加而非 insert_at(0):头部插入位移整条前缀破坏 prompt 缓存,
// 语义「提示当前轮」放末尾同样生效(紧邻本轮内容,LLM 必见)。
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
conv.messages.push(ChatMessage::system(&marker_text));
tracing::info!(
conv_id = %conv_id,
iteration,
old_topic = %old_topic,
new_topic = %new_topic,
"[ai] 主题切换标记已 insert(软提示,保守双高置信)"
);
}
}
}
// 旧 loop 污染防护——每轮开始校验对话一致性。
// 用户新建/切换对话后 active_conversation_id 变更,本 loop(conv_id 快照)成陈旧,
// 继续跑会往新对话 push 消息/pending 造成污染。检测到即退出(guard Drop 复位 generating)。
//
// 退出判据:!per_conv.contains_key(conv_id)(conv 存在性)。旧 loop 跑自己的 conv
// 不污染他人,active_conversation_id 切换不应让旧 loop 退出;仅当 conv 被删(clear/delete)
// 才退出。单 loop 阶段 conv 不会被删,此校验主要保留语义对齐(真并发场景生效)。
// conv_id 来源:run_agentic_loop 入参(与 guard/stop_flag 取用同源)。
{
let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(conv_id) {
tracing::warn!(
stale_conv = %conv_id,
"[ai] conv 已删除,旧 loop 退出"
);
return PreparedRound::Exit;
}
// 累计 iteration 计数(「下一轮起算值」= 当前轮+1)。
// 审批等待/达 max 暂停退出时,本字段即「当前轮+1」;审批续跑 ai_approve 读此值作
// start_iteration 透传,实现 iteration 累计不重置(防多次审批反复跑满 max 致 token 失控)。
// per_conv.iteration_used 唯一真相源。
let conv = session.conv(conv_id);
conv.iteration_used = iteration + 1;
// F1 并发 epoch:每轮顶部 stale 检查(防旧 loop 在 topic/compress 等长段期间被接管后继续跑)。
// F2 存活心跳:每轮更新(与 heartbeat_loop 20s 心跳互补,报活更即时)。
if conv.loop_epoch.load(Ordering::SeqCst) != my_epoch {
guard.disarm();
return PreparedRound::Exit;
}
conv.last_heartbeat.store(df_types::now_millis() as u64, Ordering::SeqCst);
}
// 新一轮通知前端(第二轮起),前端需新建 assistant 消息
if iteration > 0 {
let round_n = (iteration + 1) as u32;
// 双写收敛经 emit_ai_chat_event(EVENT_BUS_ENABLED 门控在 publish 内)。
emit_ai_chat_event(app_handle, AiChatEvent::AiAgentRound {
round: round_n,
conversation_id: Some(conv_id.to_string()),
});
}
// 自动压缩(智能裁剪)——已抽取至 context_lifecycle::maybe_auto_compress。
//
// 抽取自本函数原内联块。返回 true = conv 已删除,loop 应立即 return
// (对齐原内联块入口的 early-return);返回 false = 正常结束,继续后续流程。
// 实现细节(触发条件 / 延迟 mutate 口径 / 关键词兜底)见
// context_lifecycle.rs 顶部文档与函数体内注释。
// L0-3(2026-08-09):压缩 LLM 调用已后台化(fire-and-forget),本调用不再同步阻塞本轮,
// 返回 false 让当前轮用未压缩上下文正常走;压缩结果下一轮生效(见 context_lifecycle.rs)。
if maybe_auto_compress(
session_arc,
conv_id,
app_handle,
provider_config,
llm_concurrency,
iteration,
sys_tokens,
).await {
return PreparedRound::Exit;
}
// 构建请求消息(超预算时自动裁剪旧消息,保护工具调用三元组 + 最近 6 条)
// sys_tokens 已在 loop 外缓存;本块构建的 messages 在本轮重试循环中复用
// (本轮 stream_llm 不持 session_arc、不改 messages,重试无 push 发生,重建等价于复用)。
// build_for_request 读 per_conv.messages。
// conv_id 来源:run_agentic_loop 入参。
let messages = {
let session = session_arc.lock().await;
let conv = match session.conv_read(conv_id) {
Some(c) => c,
None => {
tracing::warn!(stale_conv = %conv_id, "[ai] conv 已删除,loop 退出(build_for_request 入口)");
return PreparedRound::Exit;
}
};
let (mut history_msgs, _trimmed) = conv.messages.build_for_request(sys_tokens);
// T2-修复(方案A):发 LLM 前展开 namespace 引用为真实内容(view-only,仅本轮请求视图)。
// push 时(audit/mod.rs)read_file/list_directory/grep/diff_files 等大结果被
// namespace_store 引用化进 messages;持久化(conversation.rs)已展开写 DB,但发 LLM 的
// 实时请求此前未展开 → LLM 每轮只看到 "namespace://..." 占位符,AI 实际拿不到工具结果。
// 此处展开,namespace 退化为内存/持久化层优化,LLM 永远看真实内容。
// read_only None(条目被 LRU 淘汰 / 重启后 namespace 清空 / 跨会话残留引用)→
// 替换为 EVICTED_PLACEHOLDER 提示文案(非保留无意义 namespace:// URI)+ warn 日志。
for m in &mut history_msgs {
if df_ai::namespace_store::is_namespace_ref(&m.content) {
let path = m.content.clone();
if let Some(original) = session.namespace_store.read_only(&path) {
m.content = original.to_string();
} else {
tracing::warn!(
conv_id = %conv_id,
path = %path,
"[namespace] 引用已淘汰,替换为提示文案"
);
m.content = session
.namespace_store
.eviction_rich_notice(&path)
.unwrap_or_else(|| df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string());
}
}
}
let mut msgs = vec![ChatMessage::system(system_prompt)];
msgs.extend(history_msgs);
// Anthropic prompt caching:把易变尾段(日期/清单/augmentation/知识)追加到消息流末尾。
// 放末尾而非 system:易变内容不进缓存前缀,每日/每请求变化不失效 system 缓存(跨天仍命中)。
if let Some(tail) = volatile_tail {
merge_volatile_tail(&mut msgs, tail);
}
msgs
};
// 改进4: tool_result view-only 摘要压缩(build_for_request 后,送 stream 前)。
//
// 遍历 messages(history clone,已含 system prompt + sanitize 后历史)中的 role=Tool 消息,
// 超阈值(content >2KB 或占历史 token >40%)的 content 应用 extract_key_info 替换:
// 保留错误行(error/panic/失败/.rs:N)+ 首/尾各 5 行,中间省略。
//
// **view-only**:messages 是 build_for_request 返回的 clone,改它只影响本轮 LLM 请求视图,
// 不改 ContextManager 持久化(对齐 sanitize_messages:DB 原始 tool_result 完整保留)。
// 故即使摘要有误/过度压缩,下次 build_for_request 仍从 DB 全量重建,可自愈。
//
// TOOL_RESULT_COMPRESS_ENABLED=false(常量开关)→ 跳过(原样送 LLM,排障/对比用)。
// 压缩决策经 per_conv.tool_view_decisions 冻结:首次判定后沿用,不再随阈值翻转
// (防同一条历史跨轮在全文/摘要间翻转致前缀断)。写回仅在 conv 存活时做(防惰性建)。
let messages: Vec<ChatMessage> = if TOOL_RESULT_COMPRESS_ENABLED {
let (history_tokens_snapshot, mut view_decisions, mut compress_cache) = {
let session = session_arc.lock().await;
let conv = session.conv_read(conv_id);
(
conv.map(|c| c.messages.history_tokens()).unwrap_or(0),
conv.map(|c| c.tool_view_decisions.clone()).unwrap_or_default(),
conv.map(|c| c.tool_compress_cache.clone()).unwrap_or_default(),
)
};
let messages = summarize_tool_results(
messages, history_tokens_snapshot, &mut view_decisions, &mut compress_cache, conv_id, iteration,
);
if !view_decisions.is_empty() || !compress_cache.is_empty() {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
conv.tool_view_decisions = view_decisions;
conv.tool_compress_cache = compress_cache;
}
}
messages
} else {
messages
};
// 占位配对完整性(第二道防线,depth-defense):build_for_request 已在 df-ai 内部
// 自愈一次,此处 tool_result 压缩/系统提示插入后再断言一次,防转换引入新 orphan。
// view-only:messages 是 clone,assert_placeholder_pairing 仅改本 Vec,不改 ContextManager 持久化。
let messages = ContextManager::assert_placeholder_pairing(messages, PLACEHOLDER_INTEGRITY_ENABLED);
// 预估输入 token(兜底:部分 provider 如 GLM 流式 usage 不报 prompt_tokens,后段用它补)
// 注:stream_one_provider 内每次重试重建 request(因 provider.stream 消费 body),
// 此处不再预构建 request(旧 request 变量已废弃),仅保留 messages 供 estimated_prompt。
// 语义说明:每轮对全量历史重估并累加(GLM 类无 usage provider 多轮 prompt 总和偏高),
// 但「多轮累计」本就接近真实总量且标估算展示,保留现状(增量估算收益低,不动)。
let estimated_prompt: u32 = {
let est = TokenEstimator::default();
messages.iter().map(|m| est.estimate_message(m)).sum()
};
// 子项 1+2 根因修复:loop 内重建 agentic_req,真实 estimated_context + 意图 tier。
//
// **子项 1(estimated_context 死代码)**:原 pre-loop agentic_req 传 0,
// 上下文窗口过滤维度(context_window >= estimated_context)恒过,失效。此处用本轮
// estimated_prompt(system+history 全量 token 估值)作 estimated_context → 窗口过滤生效
// (小窗口模型如 8K 被大上下文任务正确滤掉,不再误选)。
//
// **子项 2(weight tier tiebreak)**:tier 接 intent→ModelTier(Code/Debug→Heavy,
// Chat→Fast 等),同 weight 候选间按 tier tiebreak(满足档位下限的候选胜,见 router.rs)。
// intent 在 loop 外(786 行)已识别,loop 内每轮复用同一 intent(用户末条 active 消息
// 在单轮 LLM 调用内不变;多轮对话 intent 演化由用户后续消息触发,下轮 recognize 更新)。
//
// **变量 shadow**:此绑定覆盖 pre-loop 的 agentic_req(line ~744 算初始 resolved_model
// 用过),candidate chain(下方 stream_one_provider)取此 loop 内版本。Rust shadow 安全:
// pre-loop 版本在 line 744 用完即弃,resolved_model 已落到 mut 变量。
let agentic_req = TaskRequirements {
modalities: vec![Modality::Text],
needs_tool_use: true,
estimated_context: estimated_prompt as usize,
tier: suggested_model_tier(intent),
};
PreparedRound::Ready { messages, estimated_prompt, agentic_req }
}
// ── 轮收尾段:token 累加 + assistant 消息 push(+ G1 目标提取)+ P0-2 立即落库
// + stop/stale 收尾退出(teardown_round)+ 无工具收敛判定(目标验证续轮 GOAL_VERIFY_ENABLED)
// + 续轮计数重置。──
// 原 `return` → `RoundEpilogue::Exit`,原 `converged = true; break` → `RoundEpilogue::Converged`
//(loop 主体置 converged 后 break);push 锁边界与 teardown_round 调用序列逐字保留。
#[allow(clippy::too_many_arguments)]
async fn finalize_round_output(
session_arc: &Arc<Mutex<AiSession>>,
db: &Arc<Database>,
app_handle: &AppHandle,
conv_id: &str,
iteration: usize,
stop_flag: &std::sync::atomic::AtomicBool,
loop_epoch_arc: &std::sync::Arc<std::sync::atomic::AtomicU64>,
my_epoch: u64,
guard: &mut GeneratingGuard,
tokens: &mut TokenAccumulator,
saved_token_snapshot: &mut df_ai::provider::TokenUsage,
last_round_estimated: bool,
resolved_model: &str,
provider_config: &AiProviderRecord,
llm_concurrency: &LlmConcurrency,
pinned_goals_snapshot: &[super::GoalEntry],
full_text: &str,
tool_calls_acc: &std::collections::HashMap<u32, super::ToolCallDraft>,
round_usage: &df_ai::provider::TokenUsage,
round_is_estimated: bool,
last_reasoning_content: &Option<String>,
goal_verify_rounds: &mut u32,
// 本轮流因 provider max_tokens 上限截断(P2-1),收敛时据实提示不冒充完整
round_truncated_by_max_tokens: bool,
) -> RoundEpilogue {
// 累加本轮 token:用 provider 真实 usage。GLM 等流式不报 completion_tokens 时为 0,如实反映(不预估)。
// 分项 token(2026-08-02):cache/reasoning 透传 round。三真相源收口:prompt 估算兜底 +
// estimated 判定已由 stream_round 内 round_usage_effective 单点派生,本处直接消费返回值,
// 不再字面量重组(原 round_prompt/round_usage_full 双条件分支已删,逐字段等价)。
tokens.add_usage(round_usage);
// 追加 assistant 消息到历史 + G1 目标提取(扁平重构,原嵌套 8 层 → 3 层)
let has_tool_calls = !tool_calls_acc.is_empty();
{
let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(conv_id) {
tracing::warn!(
stale_conv = %conv_id,
"[ai] stream 后 conv 已删除,丢弃本轮 push"
);
return RoundEpilogue::Exit;
}
// UPDATE_GOALS 剥离(先于 push):LLM 输出含 [UPDATE_GOALS] 块时,用户看到的
// assistant 消息用剥离后的干净文本(标记是实现细节,不入历史/不推前端)。
let (goals_override, text_to_push): (Option<Vec<(String, bool)>>, String) =
match extract_update_goals_block(full_text) {
Some((goals, cleaned)) => (Some(goals), cleaned),
None => (None, full_text.to_string()),
};
push_assistant_message(
&mut session, conv_id, has_tool_calls, tool_calls_acc,
&text_to_push, resolved_model, last_reasoning_content,
round_usage.prompt_tokens, round_usage.completion_tokens,
round_usage.prompt_cache_hit_tokens, round_usage.prompt_cache_miss_tokens,
round_usage.reasoning_tokens,
round_is_estimated,
);
if GOAL_PIN_ENABLED {
// UPDATE_GOALS 优先:LLM 显式修正标记存在 → **替换** pinned_goals(纠偏语义),
// 跳过工具推理追加(追加会与本轮显式修正打架)。
if let Some(goals) = goals_override {
let goal_conv = session.conv(conv_id);
goal_conv.pinned_goals = goals
.into_iter()
.map(|(text, active)| {
let mut e = super::GoalEntry::new(text);
if !active {
e.status = super::GoalStatus::Completed;
}
e
})
.collect();
} else {
update_pinned_goals(&mut session, conv_id, tool_calls_acc);
}
}
}
// P0-2 根治:LLM 回复 push 后、工具执行前立即落库。
// 背景:save_conversation 原仅在 loop 出口调用,process_tool_calls 卡住时(60s timeout
// 或 session lock 竞争)永远到不了出口,用户重启后上轮回复丢失。此处出 push 锁作用域后
// 立即 save,幂等(每轮重复覆盖落库),即使后续工具卡住本轮消息已持久化。
{
// 落库传「自上次 save 的增量」而非累计快照(累计会被同 loop 多次 save 重复累加双计)
let usage = usage_delta_since(tokens, saved_token_snapshot);
save_conversation(session_arc, db, conv_id, Some(&usage), Some(resolved_model), true).await;
}
// 停止信号 或 已被新 loop 接管(stale):已生成文本入库后退出,不再执行后续工具调用。
// F1:stale 时旧 loop 在此退出,不执行工具(防重复工具执行);teardown_round 内部跳过 emit。
if stop_flag.load(Ordering::SeqCst) || loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
// 本轮 tool_calls 尚未执行,而 assistant 头(含 tool_calls)已 push 且 P0-2 已落库,
// 直接退出会给历史留下无 result 的悬空 tool_calls(sanitize 每请求告警的确定性供给)。
// 退出前逐个补跳过型 tool_result 闭环三元组,随后走既有 teardown save 落库。
// 短锁段纯写,锁边界与 epoch 语义不变:stale 时 teardown 因 epoch 不匹配跳过 save,
// 补的 result 留在内存,由 owner loop 的下一次 save 收敛落库。
if !tool_calls_acc.is_empty() {
let mut drafts: Vec<(u32, &super::ToolCallDraft)> =
tool_calls_acc.iter().map(|(k, d)| (*k, d)).collect();
drafts.sort_by_key(|(k, _)| *k);
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
for (_, d) in drafts {
conv.messages.push(ChatMessage::tool_result(
&d.id, "用户停止或被接管,工具未执行"));
}
}
}
// save_usage 增量(自上次 save 后新增);emit_usage 累计快照(前端展示本 loop 总量)
let save_usage = usage_delta_since(tokens, saved_token_snapshot);
let emit_usage = tokens_snapshot(tokens);
// 统一走 teardown_round:save(Some usage, Some model) + spawn_title + emit(None,None,publish=true)
// 用户主动 stop 不发系统通知(false)
teardown_round(
session_arc, db, conv_id,
Some(&save_usage), Some(resolved_model),
true,
provider_config, llm_concurrency,
guard,
&emit_usage,
last_round_estimated,
None, None, None, true,
pinned_goals_snapshot,
app_handle,
loop_epoch_arc, my_epoch,
false,
).await;
return RoundEpilogue::Exit;
}
// 无工具调用 → 最终文本响应。正常收敛退出,或目标验证续轮(GOAL_VERIFY_ENABLED)。
if !has_tool_calls {
// 目标验证续轮:开关开 + 有钉扎目标 + 本轮无 applied.matched_target 证据 + 未达上限
// → 不收敛,注入 nudge 强制续轮(治过早宣称,机制非 prompt 说教)。
// 任一条件不满足 → 正常收敛(逐字节等价改动前)。
let needs_verify = GOAL_VERIFY_ENABLED
&& !pinned_goals_snapshot.is_empty()
&& *goal_verify_rounds < GOAL_VERIFY_MAX_ROUNDS
&& !last_round_has_match_evidence(session_arc, conv_id).await;
if needs_verify {
*goal_verify_rounds += 1;
insert_goal_verify_nudge(session_arc, conv_id, iteration, *goal_verify_rounds).await;
// 不 break,续轮。下轮 LLM 见 nudge → 调工具验证(计数重置)或诚实收敛。
// 续轮走 loop 顶部正常流程(build_for_request + stream),无特殊路径。
} else {
// 达上限强制收敛(GOAL_VERIFY_ENABLED && goal_verify_rounds >= MAX):
// LLM 经 N 轮 nudge 仍空手停,目标未验证。标注 warn 供事后核查主入口行为。
// (未达上限/开关关/无目标/有证据 → 正常收敛,无额外日志。)
if GOAL_VERIFY_ENABLED
&& !pinned_goals_snapshot.is_empty()
&& *goal_verify_rounds >= GOAL_VERIFY_MAX_ROUNDS
{
tracing::warn!(
conv_id = %conv_id, iteration, goal_verify_rounds = *goal_verify_rounds,
"[GOAL-VERIFY] 达续轮上限强制收敛,目标未验证(LLM 经 nudge 仍空手停)"
);
}
// 正常收敛(loop 主体置 converged 后 break)
// P2-1 主会话侧最小修复:收敛文本若被 max_tokens 腰斩,追加系统提示消息
// (对齐 MidStream 保文的系统提示机制),用户/下轮请求能看见截断而非当完整回复。
if round_truncated_by_max_tokens {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
let mut notice = ChatMessage::system(
"⚠ 回复因 max_tokens 上限被截断,以上内容可能不完整;可要求模型继续输出。",
);
notice.model = Some(resolved_model.to_string());
conv.messages.push(notice);
}
tracing::warn!(conv_id = %conv_id, "[ai] 收敛回复因 max_tokens 截断,已追加系统提示");
}
return RoundEpilogue::Converged;
}
}
// 续轮内 LLM 调了工具(无论成败)→ 目标验证计数重置(说明在行动非空转,非卡死)。
// 紧跟在 converged 判定之后、process_tool_calls 之前:本轮 has_tool_calls=true 到这里,
// 说明 LLM 对 nudge 作出了工具响应,清零给后续验证留额度。
if GOAL_VERIFY_ENABLED && *goal_verify_rounds > 0 && has_tool_calls {
*goal_verify_rounds = 0;
}
// AC-2 ①:本轮工具调用数由工具调用段内部取(process_tool_calls 会 move 走 acc,收尾段只借)。
RoundEpilogue::RunTools
}
// ── 工具调用段:process_tool_calls 执行(Low 自动执行 / Medium+High 待审批)→ stale 检查
// → AC-2 单轮超限软提示 → L1 断路器(连续同类失败)→ G2 探索熔断/写删回环熔断 → 审批暂停。──
// 原 `return` → `ToolsOutcome::Exit`;熔断计数器(fail_window/stall_*/loop_*)经 &mut 传入传出,
// 锁边界与 emit/guard.reset 顺序逐字保留。
#[allow(clippy::too_many_arguments)]
async fn process_round_tools(
session_arc: &Arc<Mutex<AiSession>>,
tools_arc: &Arc<AiToolRegistry>,
db: &Arc<Database>,
app_handle: &AppHandle,
conv_id: &str,
iteration: usize,
loop_epoch_arc: &std::sync::Arc<std::sync::atomic::AtomicU64>,
my_epoch: u64,
guard: &mut GeneratingGuard,
tokens: &mut TokenAccumulator,
saved_token_snapshot: &mut df_ai::provider::TokenUsage,
resolved_model: &str,
tool_calls_acc: std::collections::HashMap<u32, super::ToolCallDraft>,
fail_window: &mut std::collections::VecDeque<String>,
breaker_warned: &mut bool,
stall_count: &mut u32,
stall_warned: &mut bool,
loop_count: &mut u32,
loop_warned: &mut bool,
) -> ToolsOutcome {
// AC-2 ①:记录本轮工具调用数(供超限警告判定;process_tool_calls 会 move 走 tool_calls_acc)。
let round_tool_call_count = tool_calls_acc.len();
// 处理工具调用(Low 自动执行 / Medium+High 待审批;主对话恒 Normal 策略,子循环走 SubAgent)
let pending_count = process_tool_calls(session_arc, tool_calls_acc, tools_arc, db, app_handle, conv_id, ToolExecPolicy::Normal, None).await;
// DIRAUTH 审批链已闭环。原 eprintln 诊断降级为 tracing::debug,
// 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。
tracing::debug!(
conv_id = %conv_id,
pending_count,
"[AI-DIRAUTH-DIAG] agentic loop 收到 pending"
);
// F1 并发 epoch:工具执行期间被新 loop 接管(force_send)后立即退出,
// 不熔断/不停审批/不进下一轮(避免旧 loop 继续污染新 loop 会话)。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
guard.disarm();
return ToolsOutcome::Exit;
}
// AC-2 ①:单轮工具调用数超限 → 注入软提示(机制化,非静默,弱模型能感知)。
// 治单轮并行调用爆炸:LLM 看不到自己发了多少调用,必须在消息流里显式警告。
if is_tool_call_over_limit(round_tool_call_count) {
insert_tool_call_over_limit_warning(session_arc, conv_id, iteration, round_tool_call_count).await;
}
// L1 断路器:连续同类工具失败熔断(治 agent 无止损死循环,机制非 prompt 说教)。
// count_recent_failures 读末尾连续 Tool 消息,失败包按结构化信号归一 key 入滚动窗口计数。
if CIRCUIT_BREAKER_ENABLED {
let (max_count, sample_key) = count_recent_failures(session_arc, conv_id, fail_window).await;
// 锁已随作用域 drop,可安全 await/emit(避免持锁 await 死锁)。
// 两段式(对齐 G2 WARN_FIRST 骨架):达 THRESHOLD-1 先经 insert_system_notice 注入
// 警示(尾部追加不破前缀),带失败样本 key 与建议动作,给 LLM 查环境/换工具/调参数的
// 自纠机会;达 THRESHOLD 才熔断。
if max_count == CIRCUIT_BREAKER_THRESHOLD.saturating_sub(1) && !*breaker_warned {
*breaker_warned = true;
insert_circuit_breaker_warning(session_arc, conv_id, iteration, &sample_key).await;
}
if max_count >= CIRCUIT_BREAKER_THRESHOLD {
tracing::warn!(
conv_id = %conv_id,
max_count,
sample_key = %sample_key,
"[ai] L1 断路器熔断:连续同类失败 {} 次,疑似死循环停止", max_count
);
guard.reset().await;
// F1:旧 loop 不 emit 熔断求助卡(owner loop 负责呈现)。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
return ToolsOutcome::Exit;
}
emit_circuit_breaker_tripped(app_handle, conv_id, max_count, &sample_key);
// 安全停靠(设计 §四):熔断即终局,补发 AiTaskParked(原状只 emit 求助卡,
// 托盘场景无独立事件;guard.reset 已在上方完成,现场已 save 于前一轮)。
crate::commands::ai::emit_task_parked(
app_handle, conv_id,
&format!("连续同类失败 {} 次已熔断,任务安全停靠;已完成进度已保存,调整思路后重发消息可继续", max_count),
"circuit_breaker",
);
return ToolsOutcome::Exit;
}
}
// G2 探索熔断:连续空结果无进展检查。抽至 check_stall_breaker helper(扁平重构)。
// 返回 true=已熔断(loop 应 return),false=继续。stall_count/stall_warned 传入传出。
if STALL_BREAKER_ENABLED {
let (tripped, new_count, new_warned, new_loop_count, new_loop_warned) = check_stall_breaker(
session_arc, app_handle, conv_id, iteration,
*stall_count, *stall_warned, *loop_count, *loop_warned,
).await;
*stall_count = new_count;
*stall_warned = new_warned;
*loop_count = new_loop_count;
*loop_warned = new_loop_warned;
if tripped {
guard.reset().await;
// 安全停靠(设计 §四):探索熔断从「等用户」改「存档+通知」,语义对齐五路统一出口。
crate::commands::ai::emit_task_parked(
app_handle, conv_id,
"探索熔断(连续无进展的重复探索),任务安全停靠;已完成进度已保存,补充新方向后重发消息可继续",
"stall_breaker",
);
return ToolsOutcome::Exit;
}
}
// 有待审批 → 暂停循环,等待用户审批后通过 ai_approve → try_continue_agent_loop 恢复
if pending_count > 0 {
// F1:旧 loop 被接管后不停审批(force_send 已清 pending),直接退出。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
guard.disarm();
return ToolsOutcome::Exit;
}
// 落库传「自上次 save 的增量」;审批暂停→恢复跨 loop 实例由 save_conversation old+add 兜住
let usage = usage_delta_since(tokens, saved_token_snapshot);
save_conversation(session_arc, db, conv_id, Some(&usage), Some(resolved_model), true).await;
// 审批等待 return 前 disarm guard——保持 generating=true 留 try_continue 续生成,
// 同时 Drop 因 done=true 跳过复位 spawn(避免误复位审批态 generating 致 ai_approve→try_continue 不续)
guard.disarm();
return ToolsOutcome::Exit; // generating 保持 true
}
ToolsOutcome::NextRound
}
// ── heartbeat_loop: 后台心跳任务(emit AiHeartbeat 防前端 watchdog 误杀) ──
// 退出条件:heartbeat_rx 发送端 Drop(function 作用域结束)。每 20s emit 一次。
// F2(2026-08-05):同时更新 per_conv.last_heartbeat(loop 存活证据)——loop 全程(含流式/工具
// 执行/压缩/provider 解析)每 20s 刷新,供 ai_chat_stop 兜底判断 loop 是否真死。
async fn heartbeat_loop(
heartbeat_rx: &mut tokio::sync::watch::Receiver<()>,
app: &AppHandle,
conv_id: &str,
last_heartbeat: &std::sync::Arc<std::sync::atomic::AtomicU64>,
) {
loop {
tokio::select! {
_ = heartbeat_rx.changed() => break,
_ = tokio::time::sleep(std::time::Duration::from_secs(20)) => {
last_heartbeat.store(df_types::now_millis() as u64, std::sync::atomic::Ordering::SeqCst);
// DRY #1:双写正典(miniapp 端靠心跳判活,只 emit 端看不到)
emit_ai_chat_event(
app,
AiChatEvent::AiHeartbeat { conversation_id: Some(conv_id.to_string()) },
);
}
}
}
}
// ============================================================
// 目标验证续轮(GOAL_VERIFY_ENABLED 门控)——治"过早宣称"的机制化兜底
// ============================================================
//
// 根因:loop `converged` 判定只看末轮有无 tool_calls(形态),不看目标是否达成(事实)。
// LLM 调完 advance_task 就停 → 被判 converged → 过早宣称。本机制在收敛闸门接事实维度:
// 末轮无 tool_calls 时,若 pinned_goals 非空且本轮无 applied.matched_target 证据 → 不收敛,
// 注入 nudge 强制续轮。复用 G2 insert_stall_warning 范式 + count_recent_failures 读尾消息模式。
//
// 主入口保障:三态兜底(applied 字段缺失/解析异常 → 视为无证据,不误伤也不阻断)、
// 上限熔断(goal_verify_rounds 达 MAX 强制收敛)、调工具重置计数(LLM 在行动非空转)。
/// 注入目标验证续轮 nudge(强制续轮,非软提示)。
///
/// 文案随 goal_verify_rounds 递进:第 1 次请验证,第 2 次(将达上限)明确最后机会。
/// 复用 insert_stall_warning 的尾部追加 system 范式 + fetch_goal_summary 拼目标。
async fn insert_goal_verify_nudge(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
iteration: usize,
verify_rounds: u32,
) {
let goals = fetch_goal_summary(session_arc, conv_id).await;
let nudge_text = if verify_rounds + 1 >= GOAL_VERIFY_MAX_ROUNDS {
format!(
"⚠ 这是最后一次验证机会。目标{}仍未给出完成证据。要么立即用工具验证目标已达成,要么诚实说明哪些未完成、为何未完成,不得再无证据宣称完成。",
goals
)
} else {
format!(
"⚠ 你停下了工具调用,但目标{}尚未给出完成证据。在宣称完成前,必须用工具(读取/核对/查询)验证结果与目标一致;若实际未完成,诚实说明现状,不得宣称完成。",
goals
)
};
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
// 尾部追加而非 insert_at(0):nudge 紧邻本轮内容语义不变,且不位移既有前缀字节
// (insert_at(0) 会把整条历史后移,自动前缀缓存/Anthropic 显式缓存全失效)。
conv.messages.push(ChatMessage::system(&nudge_text));
tracing::info!(
conv_id = %conv_id, iteration, verify_rounds,
"[GOAL-VERIFY] 目标未验证,注入续轮 nudge(强制续轮)"
);
}
}
// ── emit_circuit_breaker_tripped: L1 断路器熔断统一 emit(扁平抽自原嵌套块) ──
// 开关 CIRCUIT_BREAKER_HELP_EVENT: true=求助卡(默认), false=AiError 错误气泡(兜底回退)。
fn emit_circuit_breaker_tripped(app_handle: &AppHandle, conv_id: &str, max_count: u32, sample_key: &str) {
let ev = if CIRCUIT_BREAKER_HELP_EVENT {
AiChatEvent::AiHelpRequired {
reason: format!("连续同类失败 {} 次,疑似死循环已停止", max_count),
context: format!("最近错误: {}", sample_key),
options: vec!["换思路".into(), "授权路径".into(), "人工接管".into()],
conversation_id: Some(conv_id.to_string()),
}
} else {
AiChatEvent::AiError {
error: format!(
"连续同类失败 {} 次,疑似死循环已停止。请换思路或人工介入。最近错误: {}",
max_count, sample_key
),
error_type: Some(ErrorType::Unknown),
conversation_id: Some(conv_id.to_string()),
}
};
// DRY #1(2026-10-04):裸 emit 换正典双写——原只 emit 未 publish,miniapp 端收不到熔断事件。
emit_ai_chat_event(app_handle, ev);
// P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默)
crate::commands::ai::notify::notify_session_event(
app_handle,
crate::commands::ai::notify::NotifyKind::NeedAction,
conv_id,
"疑似死循环已熔断",
&format!("连续同类失败 {} 次,已停止,需人工介入", max_count),
);
}
// ── check_stall_breaker: G2 探索熔断检查(扁平抽自原嵌套 6 层块) ──
// 返回 (tripped, new_stall_count, new_stall_warned, new_loop_count, new_loop_warned):
// - tripped=true → 已 emit AiHelpRequired 且 loop 应 return(调用方需先 guard.reset 再 return)
// - tripped=false → 正常继续,新 stall/loop 计数与警示位供调用方回写。
//
// 2026-08-01 根本性重构:判定从「末尾 Tool 结果是否空(is_empty_tool_result)」改为
// 「最近 N 个 assistant tool_calls 签名是否重复(tool_call_signature + is_repetitive_exploration)」。
// 治旧范式误熔断正常排除式搜索(grep 无匹配是有效排除信号,非漂移)。熔断骨架(计数/警示/
// 阈值/emit/return)不变,只换判定输入。
//
// 2026-09-29 补破坏性回环检测:签名范式对「写完就删」自毁循环漏网(write_file/delete_file
// 走签名兜底分支,写删交替每步签名都不同,唯一率≈0.83 不触发)。采样处并行维护
// (工具, 路径) 结构化缓冲喂 detect_destructive_loop,独立计数器与 stall 并存互不干扰,
// 两段式(警示/阈值)复用 STALL_BREAKER_WARN_FIRST/THRESHOLD。
//
// 嵌套 ≤ 3 层:取签名→计数/警示→达阈值 emit,各段早返回。
async fn check_stall_breaker(
session_arc: &Arc<Mutex<AiSession>>,
app_handle: &AppHandle,
conv_id: &str,
iteration: usize,
stall_count: u32,
stall_warned: bool,
loop_count: u32,
loop_warned: bool,
) -> (bool, u32, bool, u32, bool) {
// 1) 取最近 N 个 assistant tool_calls 签名:倒序扫 messages,遇 Assistant 且有 tool_calls
// 就抽取每工具签名(name + arguments JSON parse),累积到 STALL_BREAKER_SAMPLE_SIZE 个。
// arguments 非合法 JSON 时 to_string 兜底(签名仍可比,只是兜底全量)。
// 同时并行抽取回环样本(写/删工具记路径,其他占位),两缓冲同序位对齐。
// 签名样本带写证据标记(写类调用 true),喂 is_repetitive_exploration 做红绿迭代豁免。
// 写类调用同时打写证据标记(is_write_evidence_tool),供 G2 单点死磕的写间隔豁免:
// 红绿迭代(同验证命令反复跑+间插写)=验证行为不算漂移,根治脚本失败致执行中断。
let (signatures, loop_samples): (Vec<(String, bool)>, Vec<DestructiveLoopSample>) = {
let session = session_arc.lock().await;
// BE2只取尾部 BREAKER_TAIL_N 条(签名抽取只需最近几轮工具调用,
// 全量 clone 纯浪费;STALL_BREAKER_SAMPLE_SIZE=12 ≪ 40,样本充足)。
let messages = match session.conv_read(conv_id) {
Some(conv) => conv.messages.recent_messages(BREAKER_TAIL_N),
None => Vec::new(),
};
let mut sigs: Vec<(String, bool)> = Vec::new();
let mut loops: Vec<DestructiveLoopSample> = Vec::new();
for m in messages.iter().rev() {
if sigs.len() >= STALL_BREAKER_SAMPLE_SIZE {
break;
}
if !matches!(m.role, MessageRole::Assistant) {
continue;
}
if let Some(tool_calls) = m.tool_calls.as_ref() {
// 单 assistant 消息内多个 tool_call 按声明顺序取,倒序累积(近期在前)。
for tc in tool_calls.iter().rev() {
if sigs.len() >= STALL_BREAKER_SAMPLE_SIZE {
break;
}
let args: serde_json::Value =
serde_json::from_str(&tc.function.arguments).unwrap_or(serde_json::Value::Null);
sigs.push((tool_call_signature(&tc.function.name, &args), is_write_evidence_tool(&tc.function.name)));
loops.push(destructive_loop_sample(&tc.function.name, &args));
}
}
}
// 倒序收集的签名需反转为时间正序,保持 is_repetitive_exploration 的语义稳定
// (虽纯函数对顺序不敏感,但反转后日志/调试更直观)。回环检测依赖序位间隔,必须正序。
sigs.reverse();
loops.reverse();
(sigs, loops)
};
// 本轮无工具调用(纯文本轮或首轮)→ 不计入也不重置,保持 stall_count/loop_count。
if signatures.is_empty() {
return (false, stall_count, stall_warned, loop_count, loop_warned);
}
// 2) 签名重复判定 → 更新 stall_count / stall_warned(沿用原 reset 语义)
let (mut new_count, mut new_warned) = (stall_count, stall_warned);
if is_repetitive_exploration(&signatures) {
new_count += 1;
} else {
new_count = 0;
new_warned = false;
}
// 2.5) 破坏性回环判定(独立计数器):窗口内有同路径写删回环对 → 累计,无 → 清零重来。
let (mut new_loop_count, mut new_loop_warned) = (loop_count, loop_warned);
let loop_hit_path = detect_destructive_loop(&loop_samples);
if loop_hit_path.is_some() {
new_loop_count += 1;
} else {
new_loop_count = 0;
new_loop_warned = false;
}
// 3) 两段式警示:达 THRESHOLD-1 首次警示(给 LLM 自纠机会)
if STALL_BREAKER_WARN_FIRST
&& new_count == STALL_BREAKER_THRESHOLD.saturating_sub(1)
&& !new_warned
{
new_warned = true;
insert_stall_warning(session_arc, conv_id, iteration, new_count).await;
}
// 3.5) 回环两段式警示(对齐 stall WARN_FIRST,独立警示位)
if STALL_BREAKER_WARN_FIRST
&& new_loop_count == STALL_BREAKER_THRESHOLD.saturating_sub(1)
&& !new_loop_warned
{
new_loop_warned = true;
insert_destructive_loop_warning(session_arc, conv_id, iteration, new_loop_count, loop_hit_path.as_deref()).await;
}
// 4) 回环熔断:独立达 THRESHOLD → emit(与 stall 熔断谁先达谁触发)
if new_loop_count >= STALL_BREAKER_THRESHOLD {
tracing::warn!(
conv_id = %conv_id,
loop_count = new_loop_count,
hit_path = %loop_hit_path.as_deref().unwrap_or("?"),
sample_size = loop_samples.len(),
"[ai] G2 破坏性回环熔断:连续 {} 轮检测到同路径写删回环,疑似自毁循环停止", new_loop_count
);
let path_desc = loop_hit_path.as_deref().unwrap_or("未知路径");
// DRY #1:双写正典(熔断求助卡跨端可见)
emit_ai_chat_event(
app_handle,
AiChatEvent::AiHelpRequired {
reason: format!("连续 {} 轮检测到对同一文件({})反复写删,疑似「写完就删」自毁循环已停止", new_loop_count, path_desc),
context: "写文件后又删除同一文件的紧凑交替,可能在反复销毁自己的工作成果而无进展。".into(),
options: vec!["回顾目标".into(), "换思路".into(), "停止".into()],
conversation_id: Some(conv_id.to_string()),
},
);
// P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默)
crate::commands::ai::notify::notify_session_event(
app_handle,
crate::commands::ai::notify::NotifyKind::NeedAction,
conv_id,
"疑似自毁循环已熔断",
&format!("对 {} 反复写删 {} 轮,已停止,需人工介入", path_desc, new_loop_count),
);
return (true, new_count, new_warned, new_loop_count, new_loop_warned);
}
// 5) stall 熔断:达 THRESHOLD → emit AiHelpRequired + 返回 tripped=true
if new_count < STALL_BREAKER_THRESHOLD {
return (false, new_count, new_warned, new_loop_count, new_loop_warned);
}
tracing::warn!(
conv_id = %conv_id,
stall_count = new_count,
sample_size = signatures.len(),
"[ai] G2 探索熔断:连续 {} 次检测到重复工具调用,疑似卡住停止", new_count
);
// DRY #1:双写正典(熔断求助卡跨端可见)
emit_ai_chat_event(
app_handle,
AiChatEvent::AiHelpRequired {
reason: format!("连续 {} 次检测到重复的工具调用(同样的 grep/读文件反复),疑似卡住已停止", new_count),
context: "工具调用签名高度重复,可能在反复做同一件事而无进展。".into(),
options: vec!["回顾目标".into(), "换思路".into(), "停止".into()],
conversation_id: Some(conv_id.to_string()),
},
);
// P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默)
crate::commands::ai::notify::notify_session_event(
app_handle,
crate::commands::ai::notify::NotifyKind::NeedAction,
conv_id,
"疑似卡住已熔断",
&format!("连续 {} 次重复同类工具调用,已停止,需人工介入", new_count),
);
(true, new_count, new_warned, new_loop_count, new_loop_warned)
}
// ── insert_destructive_loop_warning: 破坏性回环软提示 insert system 消息(对齐 insert_stall_warning) ──
async fn insert_destructive_loop_warning(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
iteration: usize,
loop_count: u32,
path: Option<&str>,
) {
let goal_text = if STALL_BREAKER_GOAL_REMIND {
fetch_goal_summary(session_arc, conv_id).await
} else {
String::new()
};
let path_desc = path.unwrap_or("同一文件");
let warn_text = format!(
"⚠ 检测到对 {} 反复写删(写完又删的回环),可能在销毁自己的工作成果{},请回顾目标停止删除或换思路。",
path_desc, goal_text
);
helpers::insert_system_notice(
session_arc,
conv_id,
&warn_text,
&format!(
"G2 破坏性回环:写删回环警示已 insert(软提示,给 LLM 自纠机会) iteration={} loop_count={}",
iteration, loop_count
),
)
.await;
}
// ── insert_stall_warning: G2 软提示 insert system 消息(给 LLM 自纠机会) ──
async fn insert_stall_warning(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
iteration: usize,
stall_count: u32,
) {
let goal_text = if STALL_BREAKER_GOAL_REMIND {
fetch_goal_summary(session_arc, conv_id).await
} else {
String::new()
};
let warn_text = format!(
"⚠ 检测到重复的工具调用(同样的 grep/读文件反复),可能卡住{},请回顾目标换思路或停止探索。",
goal_text
);
helpers::insert_system_notice(
session_arc,
conv_id,
&warn_text,
&format!(
"G2 探索熔断:重复调用警示已 insert(软提示,给 LLM 自纠机会) iteration={} stall_count={}",
iteration, stall_count
),
)
.await;
}
// L1 断路器两段式警示:同 key 失败达 THRESHOLD-1 时注入系统软提示(尾部追加,不破前缀)。
// 文本带失败样本 key(工具与参数指纹、错误指纹)与建议动作,给 LLM 自纠机会,熔断前先警示。
async fn insert_circuit_breaker_warning(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
iteration: usize,
sample_key: &str,
) {
let warn_text = format!(
"⚠ 工具连续失败(样本: {})。同一做法再试大概率仍失败,建议先查环境(路径/权限/依赖是否就绪),再换用其他工具或调整参数与方案;若确认无法继续,直接向用户说明阻塞点。",
sample_key
);
helpers::insert_system_notice(
session_arc,
conv_id,
&warn_text,
&format!(
"L1 断路器:同类失败警示已 insert(软提示,给 LLM 自纠机会) iteration={} sample_key={}",
iteration, sample_key
),
)
.await;
}
// ── fetch_goal_summary: 读 pinned_goals 拼接为 "(当前目标: a; b)" 或空串 ──
async fn fetch_goal_summary(session_arc: &Arc<Mutex<AiSession>>, conv_id: &str) -> String {
let goals = session_arc
.lock().await
.conv_read(conv_id)
.map(|c| c.pinned_goals.clone())
.unwrap_or_default();
if goals.is_empty() {
return String::new();
}
let mut items: Vec<String> = goals.iter()
.map(|g| g.text.trim().to_string())
.filter(|g| !g.is_empty())
.collect();
// 超过 3 条截断:全量目标已在 system_prompt「## 当前目标」段锚定,警示里只做提醒,
// 全列会让文案膨胀(实测 5 条目标拼接占满整块警示气泡)。
if items.len() > 3 {
let total = items.len();
items.truncate(3);
items.push(format!("…等共 {} 条", total));
}
format!("(当前目标: {})", items.join("; "))
}
// ── insert_tool_call_over_limit_warning: AC-2 ① 单轮工具调用数超限软提示 ──
// 机制化(非 prompt 说教):超限即 insert system 警告到 conv.messages 首位(对齐 G2
// insert_stall_warning 模式),下轮 build_for_request 时 LLM 可见,促其聚焦关键操作。
async fn insert_tool_call_over_limit_warning(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
iteration: usize,
count: usize,
) {
let warn_text = format!(
"⚠ 本轮工具调用达 {} 个,超过单轮上限 {} 个。请聚焦关键操作,减少并行调用,勿重复探索同一区域。",
count, MAX_TOOL_CALLS_PER_ROUND
);
helpers::insert_system_notice(
session_arc,
conv_id,
&warn_text,
&format!(
"AC-2 单轮工具调用数超限:注入软提示 iteration={} count={} limit={}",
iteration, count, MAX_TOOL_CALLS_PER_ROUND
),
)
.await;
}
// ── classify_permanent_provider_error: 可判定的永久性 provider 错误识别(词法,仅此一处) ──
// 命中 → ProviderConfig(前端隐藏「重试」改显「去设置」CTA,重试必再败不再循环);
// 未命中维持 Network(现状口径,可重试)。只认高置信特征,宁漏勿误(误判会把可恢复错误
// 锁死成「不可重试」)。输入是 fmt_diag 聚合诊断([provider] 场景(status): body),
// 关键词对 provider body 原文匹配,大小写不敏感。
fn classify_permanent_provider_error(error: &str) -> bool {
let lower = error.to_lowercase();
// 模型不存在: "model is not found" / "The model `x` does not exist" / 中文「模型不存在」
let model_missing = lower.contains("model")
&& (lower.contains("not found")
|| lower.contains("not_found")
|| lower.contains("does not exist")
|| lower.contains("不存在"));
// 令牌无权访问模型: 「该令牌无权访问模型」/ no permission / access denied
let no_access = lower.contains("无权")
|| lower.contains("无权限")
|| lower.contains("no permission")
|| lower.contains("access denied")
|| lower.contains("permission denied");
// 参数拒绝类 400: sensenova "field Temperature invalid" 实锤 / OpenAI invalid_request_error
let param_reject = lower.contains("invalid_request_error")
|| ((lower.contains("invalid") || lower.contains("非法") || lower.contains("无效"))
&& (lower.contains("temperature")
|| lower.contains("parameter")
|| lower.contains("field")
|| lower.contains("request")));
model_missing || no_access || param_reject
}
// ── emit_fatal_error: 致命错误统一 emit AiError(扁平抽自原 Fatal/Exhausted 双分支重复代码) ──
// 双写经 emit_ai_chat_event。error_type: 可判定的永久性 provider 错误(模型不存在/令牌无权/
// 参数拒绝类 400)归 ProviderConfig,其余维持 Network 现状(2026-10-01 两端统一时的口径)。
pub(crate) async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, error: &str) {
let error_type = if classify_permanent_provider_error(error) {
ErrorType::ProviderConfig
} else {
ErrorType::Network
};
emit_ai_chat_event(app_handle, AiChatEvent::AiError {
error: error.to_string(),
error_type: Some(error_type),
conversation_id: Some(conv_id.to_string()),
});
// #23 修复:错误路径也广播 AiAgentFinished(status=error)——原只发 AiError,飞书
// completion_watcher 只消费 AiAgentFinished → 错误任务永不收敛(watcher 空等到二次
// 超时弃守,且无失败终态)。单一事实源在引擎层,订阅方(飞书 watcher/桌面任务卡)各自消费。
app_handle.state::<AppState>().ai_event_bus.publish_event(
AiChatEvent::AiAgentFinished {
agent_id: conv_id.to_string(),
conversation_id: Some(conv_id.to_string()),
summary: format!("任务失败: {}", error.chars().take(300).collect::<String>()),
status: "error".to_string(),
rounds: 0,
tokens: 0,
rejected_tools: None,
stop_kind: Some("error".to_string()),
},
);
// P1 bg 化:失败系统通知(条件发送,失败静默)。正文截断防长堆栈撑爆系统气泡。
let brief: String = error.chars().take(160).collect();
crate::commands::ai::notify::notify_session_event(
app_handle,
crate::commands::ai::notify::NotifyKind::Failed,
conv_id,
"任务失败",
&brief,
);
}
// ── emit_ai_completed_once: AiCompleted 事件 emit+publish 双写 helper(对齐 emit_fatal_error 模式) ──
//
// 抽自 6 处退出路径(stop_flag 入口 / Coordinator merge / MidStream 保文 / push 后 stop /
// max_iterations / 正常完成)中「emit AiCompleted + publish_event AiCompleted」近重复代码。
//
// **incomplete 语义分叉**(memory: 语义不变):
// MidStream 保文路径 emit 端 incomplete=Some(true)(前端据 标记不完整),publish 端 incomplete=None
// (事件总线消费方无需此标记);max_iterations 路径仅 emit 不 publish(原实现即无 publish,保留)。
// 其余 4 路径 emit/publish 的 incomplete 一致(均 None 或均 Some)。
// 故本 helper 拆 emit_incomplete / publish_incomplete / do_publish 三参,精确镜像原各路径差异。
//
// usage 字段(对齐 run_agentic_loop 各退出点用法):emit 端的 token 三元组从 `usage` 取;
// prompt/completion 与 usage 一致(各退出点原样从 tokens 或 round-derived usage 传入)。
//
// is_estimated(G4.3, 2026-08-05):本轮 token 用量是否估算值(round_usage.prompt_tokens==0 →
// estimated_prompt 处打标)。透传前端仅作展示标注,不参与 TokenAccumulator/save 累加。
//
// G2.2(2026-08-05):本 helper 是 run_agentic_loop 所有正常出口的统一 emit 点——
// AiCompleted 发出时同步清该 conv 的审批残留(终态化占位 + 移除 pending_approvals),
// 防占位 tool_result 残留 messages 下次发送喂给 LLM。仅清目标 conv(retain 保留其他 conv)。
//
// pinned_goals:前端直接读取刷新(G1 目标钉扎),原样透传快照(不 clone,借用调用方)。
//
// notify(P1 bg 化):true 时 emit 后发完成类系统通知。本 helper 是所有完成出口的统一
// emit 单点(teardown_round 各出口 + 正常完成直调),通知收敛此处避免双写;
// 用户主动 stop 出口(teardown_round 转发)传 false 关闭。通知内部条件发送
// (notify.rs:主窗可见聚焦不发 + 同类 5 秒节流 + 失败静默)。
// pub(crate):conv 删除(ai_conversation_delete)在 epoch 递增后单点补发删除终态,
// loop 侧各退出路径因非 owner 静默,删除侧是唯一发出者(调用方无 epoch 守卫,见其注释)。
pub(crate) async fn emit_ai_completed_once(
app_handle: &AppHandle,
conv_id: &str,
usage: &df_ai::provider::TokenUsage,
is_estimated: bool,
emit_incomplete: Option<bool>,
publish_incomplete: Option<bool>,
incomplete_reason: Option<&str>,
do_publish: bool,
pinned_goals: &[super::GoalEntry],
notify: bool,
) {
// G2.2:清审批残留收敛此处(所有正常出口都经本 helper 单点 emit)。锁内纯写改短 lock 段。
{
// app_handle.state() 返回临时引用,直接 .ai_session.lock() 借用临时值 → E0716 悬垂。
// 先绑定 state(守卫整块生命周期),再经 DerefMut 方法调用。
let app_state = app_handle.state::<AppState>();
let mut session_guard = app_state.ai_session.lock().await;
super::commands::chat::finalize_pending_placeholders(&mut *session_guard, conv_id, "生成完成");
session_guard.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(conv_id));
}
// V49:正常收敛出口清理 loop_state(fire-and-forget 独立 spawn,不与上面的锁段耦合;
// 进程内 loop 死亡的残留 running 由启动扫描兜底)。
{
let app_state = app_handle.state::<AppState>();
let db2 = app_state.db.clone();
let cid = conv_id.to_string();
tokio::spawn(async move {
let repo = df_storage::crud::AiConversationRepo::new(&db2);
let _ = repo.set_loop_state(&cid, None).await;
});
}
// B5 无人值守续作引擎收敛回读钩子:引擎在途会话到达本出口即清其标志并唤醒轮询器
// 提前扫板续作(非引擎会话到达时引擎侧比对 conv 不匹配零动作;残留兜底在引擎轮内
// can_accept_request 判据)。本 helper 是全部正常出口统一单点,钩子挂此处即全覆盖。
crate::commands::ai::unattended::notify_batch_done(conv_id);
// emit 端(前端通道):incomplete 用 emit_incomplete(MidStream 传 Some(true))。
// token 分项(2026-08-02):cache_hit/cache_miss/reasoning 透传前端分计费展示。
// publish 端:do_publish=false(max_iterations)跳过 publish(保留原无 publish 行为);
// MidStream incomplete 分叉(emit=Some(true)/publish=None)事件值不同,不能并成单次
// emit_ai_chat_event 调用,分路构造(其余 5 路径两端同值,走单次双写)。
let ev = completed_event(usage, is_estimated, emit_incomplete, incomplete_reason, conv_id, pinned_goals);
if do_publish && emit_incomplete == publish_incomplete {
emit_ai_chat_event(app_handle, ev);
// P0 修复(2026-10-05):正常完成恒走此早退,完成通知桥必须在此投递——
// 否则等待方(编排节点/飞书渠道)永远收不到完成通知(实证:等待方 30 分钟超时)。
crate::commands::ai::commands::chat::deliver_completion_report_if_waited(app_handle, conv_id).await;
return;
}
// 有意不走 emit_ai_chat_event(emit/publish 两端 incomplete 标志分叉,见上注释)——
// 此分支语义就是单路 emit 不 publish,裸写是正确形态,勿"收口"。
let _ = app_handle.emit("ai-chat-event", ev);
if do_publish {
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(
completed_event(usage, is_estimated, publish_incomplete, None, conv_id, pinned_goals),
);
}
// P1 bg 化:完成出口系统通知(条件发送,失败静默;stop 出口经调用方传 false 关闭)
if notify {
crate::commands::ai::notify::notify_session_event(
app_handle,
crate::commands::ai::notify::NotifyKind::Completed,
conv_id,
"任务完成",
"AI 回复已就绪,点击托盘图标查看",
);
}
// 编排会话完成通知桥:工作流 AiAgentNode 挂起的等待方在此投递最终报告。
// 本函数是全部正常出口(5 类 teardown_round 出口 + 正常完成直调)的统一单点,
// 到达者必为 owner loop(epoch 守卫在各调用方:teardown_round 入口 / 正常完成双检),
// force_send 接管后的旧 loop 不触发。无等待方(普通用户会话)has 判空短路,零会话锁开销。
// review ④:内联块收敛到 deliver_completion_report_if_waited 单实现(与上方早退分支同源)。
crate::commands::ai::commands::chat::deliver_completion_report_if_waited(app_handle, conv_id).await;
}
/// 取会话末条 active assistant 消息文本(编排会话最终报告投递用)。
///
/// conv 不存在 / 无 assistant 消息返空串,原样投递,空文本由节点侧判「无输出」失败
/// (用户 stop 在任何输出前的场景)。读内存 per_conv 非常规出口:正常完成路径 save 落库
/// 在后台 spawn 里,时序上可能晚于本函数,内存消息是唯一已确定就绪的真相源。
pub(crate) async fn last_active_assistant_text(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
) -> String {
let session = session_arc.lock().await;
session
.conv_read(conv_id)
.map(|c| c.messages.all_messages_clone())
.unwrap_or_default()
.into_iter()
.rev()
.find(|m| matches!(m.role, MessageRole::Assistant) && m.is_active())
.map(|m| m.content)
.unwrap_or_default()
}
/// 构造 AiCompleted 事件(emit_ai_completed_once 内部去重用,字段口径见其文档)。
/// incomplete_reason 与 incomplete 配对:emit 侧事件随 emit_incomplete 取值,publish 侧
/// 分叉事件(与 emit 不一致时)incomplete 为 None,reason 同步 None。
fn completed_event(
usage: &df_ai::provider::TokenUsage,
is_estimated: bool,
incomplete: Option<bool>,
incomplete_reason: Option<&str>,
conv_id: &str,
pinned_goals: &[super::GoalEntry],
) -> AiChatEvent {
AiChatEvent::AiCompleted {
total_tokens: usage.total_tokens,
prompt_tokens: usage.prompt_tokens,
completion_tokens: usage.completion_tokens,
prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
reasoning_tokens: usage.reasoning_tokens,
is_estimated,
incomplete,
incomplete_reason: incomplete_reason.map(str::to_string),
conversation_id: Some(conv_id.to_string()),
pinned_goals: pinned_goals.to_vec(),
}
}
// ── build_runtime_prompt: run_agentic_loop 入口 system_prompt 六段拼接 ──
//
// 抽自原 L1024-1152(G1 目标钉扎 / T5 工作上下文 / Env+behavior / 技能清单 / T4 工作流 DAG)。
// N1 增量:新增派工纪律段(DISPATCH_DISCIPLINE 母本内置,拼在 behavior 后),非原五段内容。
// **行为逐字节等价**:拼接顺序与内容不变,开关(GOAL_PIN_ENABLED / SKILL_LIST_INJECT_ENABLED /
// GOAL_INJECT_BANNER)与截断(GOAL_MAX_CHARS / SKILL_LIST_MAX_ITEMS / SKILL_DESC_MAX_CHARS)常量原样引用。
//
// **关键重构(消除 pinned_goals 二次读)**:原代码 G1/T5/T4 各自独立加锁读 per_conv,且
// pinned_goals 被 G1(L1026 拼接)与调用方 snapshot(L1157 AiCompleted 携带)读两遍。本 helper
// 一次锁内取 pinned_goals / working_context.build_injection / workflow_dag_summary / workflow_id,
// 释放锁后再做 Env(.await OnceLock)/技能(.await 进程缓存)拼接——后两者无锁需求,不在锁内 await
// (避免持锁 await 死锁/长持锁)。返回 (拼好的 system_prompt, pinned_goals),调用方复用 pinned_goals
// 做 snapshot,消除原 L1157 第二次 lock 读。
//
// 注:`_lang` 参数为未来 i18n 行为准则预留(当前 system_prompt 拼接未用 lang),保持调用点签名稳定。
async fn build_runtime_prompt(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
base_prompt: String,
_lang: &str,
) -> (String, Vec<super::GoalEntry>) {
// 一次锁读四字段(pinned_goals / working_injection / workflow_dag_summary / workflow_id)。
// conv 已删(None)→ goals=空 / injection=空 / dag=None,等价原各块 conv_read.unwrap_or_default 短路。
let (goals, working_injection, workflow_dag_summary, workflow_id) = {
let session = session_arc.lock().await;
match session.conv_read(conv_id) {
Some(conv) => (
conv.pinned_goals.clone(),
conv.working_context.build_injection(3),
conv.workflow_dag_summary.clone(),
conv.workflow_id.clone(),
),
None => (Vec::new(), String::new(), None, None),
}
};
let mut system_prompt = base_prompt;
// G1 目标钉扎:仅 active 目标拼进 system_prompt(completed 不干扰 LLM 注意力)。
if GOAL_PIN_ENABLED {
let active_goals: Vec<&super::GoalEntry> = goals.iter().filter(|g| matches!(g.status, super::GoalStatus::Active)).collect();
if !active_goals.is_empty() {
let goal_lines: Vec<String> = active_goals.iter().enumerate().map(|(i, g)| {
let trimmed = g.text.trim();
let truncated: String = trimmed.chars().take(GOAL_MAX_CHARS).collect();
let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS {
format!("{}…", truncated)
} else {
truncated
};
format!("{}. {}", i + 1, truncated)
}).collect();
let goals_text = goal_lines.join("\n");
system_prompt = if GOAL_INJECT_BANNER {
format!(
"{}\n\n## 当前目标(全程锚定,所有动作须服务于它们)\n{}",
system_prompt, goals_text
)
} else {
format!("{}\n\n{}", system_prompt, goals_text)
};
tracing::info!(
conv_id = %conv_id,
count = goals.len(),
first_goal = %goals.first().map(|g| &g.text[..std::cmp::min(120, g.text.len())]).unwrap_or(""),
"[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt",
goals.len()
);
}
}
// T5: WorkingContext L1 注入(dirty_window=3,空 injection 跳过)。
if !working_injection.is_empty() {
system_prompt = format!("{}\n\n## 工作上下文\n{}", system_prompt, working_injection);
}
// EnvSnapshot 环境感知:detect() 是 OnceLock 全局缓存(启动探一次,后续零开销),与 G1 一样是
// loop 不变量,与目标钉扎拼接次序无强约束(放其后,语义自然)。无锁需求。
let env_prompt = df_execute::EnvSnapshot::detect().await.to_prompt();
let behavior_prompt = concat!(
"\n## AI 定位\n",
"你是 DevFlow 的 AI 助手,拥有完整的工具链。用户只负责提需求和审批,",
"所有执行由你完成——读写文件、运行命令、创建项目、搜索代码等都是你直接调用工具完成的。",
"**绝不输出请在终端执行以下命令这类指令——你自己用 run_command 工具执行即可。**\n",
"\n## 行为准则\n",
"- 所有操作都通过工具完成,用户不参与执行\n",
"- 优先使用开发工具 IPC,非必要不写独立脚本\n",
"- 脚本需要审批通过才执行,会拖慢工作流\n",
"- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定\n",
"- 如果现有工具无法完成任务,告知用户缺少什么能力,建议向 DevFlow 反馈以开发新工具\n",
"- **直接回答用户问题,不要以评论开头**——严禁用好问题等无信息量的开场白,直接输出答案或执行操作\n",
"- **及时收敛**:工具执行完目标达成后立即输出结果,不要继续调工具做无关操作",
);
system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt);
// N1 派工纪律内置注入:母本编译进二进制(知识库为分发面数据,新实例知识库为空即裸奔,
// 内置零配置生效)。与 sub_agent_system_prompt 同源引用 DISPATCH_DISCIPLINE,
// 主 agent(派工方)受约束,子代理(被派方)同样知晓纪律。
system_prompt = format!("{}\n\n{}", system_prompt, DISPATCH_DISCIPLINE);
// 技能清单注入:skills_cached() 进程内缓存(懒扫描一次,后续零开销),无锁需求。
if SKILL_LIST_INJECT_ENABLED {
let skills = crate::commands::ai::skills::skills_cached().await;
if !skills.is_empty() {
let lines: Vec<String> = skills
.iter()
.take(SKILL_LIST_MAX_ITEMS)
.map(|s| {
let desc_flat = s.description.replace('\n', " ");
let desc: String = desc_flat.chars().take(SKILL_DESC_MAX_CHARS).collect();
let desc = if desc_flat.chars().count() > SKILL_DESC_MAX_CHARS {
format!("{}…", desc)
} else {
desc
};
format!("- {}: {}", s.name, desc)
})
.collect();
if !lines.is_empty() {
system_prompt = format!(
"{}\n\n## 可用技能\n以下为本机 Claude 技能/命令(名称: 描述,已去重,上限 {} 条)。**自动触发规则**:用户请求(含普通任务指令)与某技能用途匹配时,你应先调 read_skill 工具读取该技能规程全文,按其流程直接执行,无需等待用户显式输入 /技能名;仅当技能涉及高风险操作或不确定用户意图时,才提示用户输入 /技能名 确认。未列出的技能也可用 read_skill 按名查询:\n{}",
system_prompt,
SKILL_LIST_MAX_ITEMS,
lines.join("\n")
);
tracing::debug!(
conv_id = %conv_id,
count = lines.len(),
"[ai] 技能清单注入:已追加 {} 条技能到 system_prompt",
lines.len()
);
}
}
}
// T4: 工作流 DAG 注入 — 当会话关联工作流时,将活跃路径注入 system prompt。
if let Some(ref dag_summary) = workflow_dag_summary {
system_prompt = format!("{}[工作流]\n{}\n", system_prompt, dag_summary);
tracing::debug!(
conv_id = %conv_id,
workflow_id = ?workflow_id,
"[ai] T4: 已注入工作流 DAG 上下文"
);
}
(system_prompt, goals)
}
// ── tokens_snapshot: TokenAccumulator → TokenUsage 累计快照 helper ──
//
// 抽自 run_agentic_loop 内 6 处(Coordinator 路径/入口 stop/MidStream/push 后 stop/
// max_iterations/正常完成)重复的 TokenUsage{prompt/completion/total/cache_hit/cache_miss/
// reasoning} 字面量组装。total = tokens.total() = prompt.saturating_add(completion)
// (TokenAccumulator::total 语义),与原 6 处逐字节等价。usage_delta_since 内部「取 cur 快照」
// 也复用本 helper(cur 字段同构)。
pub(crate) fn tokens_snapshot(tokens: &TokenAccumulator) -> df_ai::provider::TokenUsage {
df_ai::provider::TokenUsage {
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
prompt_cache_hit_tokens: tokens.cache_hit(),
prompt_cache_miss_tokens: tokens.cache_miss(),
reasoning_tokens: tokens.reasoning(),
}
}
// ── usage_delta_since: 会话级 token 落库「增量」口径 helper ──
//
// save_conversation 对 usage 做 old+add(accumulate_tokens),故每次 save 只能传
// 「自上次 save 以来的增量」——传累计快照会被同 loop 多次 save(每轮 + 各退出路径)
// 重复累加致 ai_conversations token 双计。本函数取 tokens 当前值减上次快照得增量,
// 并推进快照,同轮多次 save 幂等(第二次增量=0)。
pub(crate) fn usage_delta_since(
tokens: &TokenAccumulator,
last_saved: &mut df_ai::provider::TokenUsage,
) -> df_ai::provider::TokenUsage {
let cur = tokens_snapshot(tokens);
let delta = df_ai::provider::TokenUsage {
prompt_tokens: cur.prompt_tokens.saturating_sub(last_saved.prompt_tokens),
completion_tokens: cur.completion_tokens.saturating_sub(last_saved.completion_tokens),
total_tokens: 0, // 下方按 prompt+completion 增量重算,保持三字段一致
prompt_cache_hit_tokens: cur
.prompt_cache_hit_tokens
.saturating_sub(last_saved.prompt_cache_hit_tokens),
prompt_cache_miss_tokens: cur
.prompt_cache_miss_tokens
.saturating_sub(last_saved.prompt_cache_miss_tokens),
reasoning_tokens: cur.reasoning_tokens.saturating_sub(last_saved.reasoning_tokens),
};
*last_saved = cur;
df_ai::provider::TokenUsage {
total_tokens: delta.prompt_tokens.saturating_add(delta.completion_tokens),
..delta
}
}
// ── teardown_round: run_agentic_loop 收尾统一入口(抽自 5 处退出路径重复代码) ──
//
// 收敛各退出点的「save_conversation + spawn_ensure_title + guard.reset + emit AiCompleted」序列。
// 退出点(行为零变更):
// - stop_flag 入口:save(Some usage, None model) + spawn_title + reset + emit(None,None,publish=true)
// - Coordinator merge:save(None, None) + no title + reset + emit(None,None,publish=true)
// - MidStream 保文:save(Some usage, Some model) + spawn_title + reset + emit(Some(true),None,publish=true)
// - push 后 stop:save(Some usage, Some model) + spawn_title + reset + emit(None,None,publish=true)
// - max_iterations:save(Some usage, Some model) + no title + reset + emit(Some(true),-,publish=false)
//
// **不在范围**(模式异构,保留原状):
// - 正常完成(spawn 异步 save+extract+ensure_title 阻塞式,与 spawn_ensure_title 后台式不同):
// 仅复用 emit_ai_completed_once,不走本 helper。
// - Fatal / Exhausted:emit AiError(已由 emit_fatal_error 统一),不走本 helper。
// - stale_conv return:无 emit/guard 显式 reset(依赖 guard Drop),不走本 helper。
//
// 参数口径(对齐各退出点原代码):
// - save_usage/save_model:透传 save_conversation;Coordinator 传 (None, None) 仍会调 save(幂等)
// - emit_usage:emit AiCompleted 的 token 三元组来源(各退出点经 tokens.add/round-derived 后传入)
// - emit_estimated(G4.3, 2026-08-05):本轮 token 用量是否估算值,透传 emit_ai_completed_once 仅展示
// - spawn_title:true → spawn_ensure_title(provider_config 后台生成,失败有 extract_title 兜底)
// - emit_incomplete/publish_incomplete/do_publish:透传 emit_ai_completed_once(见该 helper 文档)
// - loop_epoch_arc/my_epoch:F1 并发 epoch——旧 loop(epoch 不匹配)只 disarm 跳过 save/reset/emit,
// 不干扰新 loop 收尾(防「双完成 + 双 AiCompleted + guard 互踩」)。
// - notify(P1 bg 化):透传 emit_ai_completed_once 的系统通知开关。完成类出口传 true,
// 用户主动 stop 出口传 false 不打扰(stale 出口在 epoch 守卫处已提前返回,天然不通知)。
pub(crate) async fn teardown_round(
session_arc: &Arc<Mutex<AiSession>>,
db: &Arc<Database>,
conv_id: &str,
save_usage: Option<&df_ai::provider::TokenUsage>,
save_model: Option<&str>,
spawn_title: bool,
provider_config: &AiProviderRecord,
llm_concurrency: &LlmConcurrency,
guard: &mut GeneratingGuard,
emit_usage: &df_ai::provider::TokenUsage,
emit_estimated: bool,
emit_incomplete: Option<bool>,
publish_incomplete: Option<bool>,
// 不完整原因(emit 侧,与 emit_incomplete 配对):Some("max_rounds")=达最大轮次自动收尾,
// Some("network")=流中途网络中断保文,None=完整/未分类。口径见 AiCompleted 字段文档。
incomplete_reason: Option<&str>,
do_publish: bool,
pinned_goals: &[super::GoalEntry],
app_handle: &AppHandle,
loop_epoch_arc: &std::sync::Arc<std::sync::atomic::AtomicU64>,
my_epoch: u64,
notify: bool,
) {
// F1 并发 epoch 收尾守卫:旧 loop(被 force_send/新 loop 接管,epoch 不匹配)不 save/reset/emit,
// 只 disarm guard 退出——owner loop 负责收尾,防「双完成 + 双 AiCompleted + guard 互踩」。
if loop_epoch_arc.load(std::sync::atomic::Ordering::SeqCst) != my_epoch {
tracing::debug!(
conv_id = %conv_id,
my_epoch,
current_epoch = loop_epoch_arc.load(std::sync::atomic::Ordering::SeqCst),
"[ai] 旧 loop 收尾跳过(已被新 loop 接管,不 save/reset/emit)"
);
guard.disarm();
return;
}
// 落库:save_conversation 幂等(每轮重复覆盖落库),Coordinator 传 (None,None) 也调用(对齐原 :1126)
save_conversation(session_arc, db, conv_id, save_usage, save_model, true).await;
// 标题生成后台化(不阻塞 Completed emit):Coordinator / max_iterations 不 spawn(对齐原行为)
if spawn_title {
spawn_ensure_title(provider_config, db, conv_id, app_handle, session_arc, llm_concurrency);
}
// generating 复位(guard.reset 幂等):保证前端收 AiCompleted 时后端已 Idle(发送队列续发不被「正在生成中」拒绝)
guard.reset().await;
// generating 复位后再 emit Completed(对齐原各路径顺序)
emit_ai_completed_once(
app_handle, conv_id, emit_usage, emit_estimated,
emit_incomplete, publish_incomplete, incomplete_reason, do_publish,
pinned_goals,
notify,
).await;
}
// ── push_assistant_message: 轮结束后向 session.messages 推入 assistant 消息(扁平抽自原嵌套块) ──
// has_tool_calls=true: assistant_with_tools(文本+工具调用占位), false 且文本非空: assistant(纯文本)。
// 两分支都不为空且都不需 push 时(no tool + empty text),返回(no-op)。
// 入参 session 需外层调用方持锁;本函数只做纯内存 mutate,无 await/emit,不会死锁。
// prompt_tokens/completion_tokens: 本轮 LLM 调用 token 用量(消息级持久化,解 reload/压缩/切会话后
// 历史 assistant 消息 token 不显)。两构造分支都设。
// 分项 token(2026-08-02):cache_hit/cache_miss/reasoning 透传自 round_usage,前端分计费展示。
// estimated:本轮 prompt 是否估算兜底(round_usage.prompt_tokens==0),落库供 reload 逐条回显。
fn push_assistant_message(
session: &mut AiSession,
conv_id: &str,
has_tool_calls: bool,
tool_calls_acc: &std::collections::HashMap<u32, super::ToolCallDraft>,
full_text: &str,
resolved_model: &str,
last_reasoning_content: &Option<String>,
prompt_tokens: u32,
completion_tokens: u32,
cache_hit: u32,
cache_miss: u32,
reasoning: u32,
estimated: bool,
) {
// 根治「空工具轮 assistant 消息落库」:LLM 仅返回 tool_calls 无文本时 full_text 可能为
// 空/纯空白(assistant("")/assistant("\n") 均合法落库),前端渲染空气泡。入口统一 trim:
// - 纯空白 → content 归并为 ""(非 whitespace),前端 !content 判断可命中;有 tool_calls 仍 push(工具卡有效)
// - 纯空白且无 tool_calls → 下方 !full_text.is_empty() 为 false,跳过 push(空轮无任何信息不落库)
// - 真实文本仅去首尾空白,不影响内容
let full_text = full_text.trim();
if has_tool_calls {
let mut order: Vec<u32> = tool_calls_acc.keys().copied().collect();
order.sort_unstable();
let ai_tool_calls: Vec<df_ai::provider::ToolCall> = order.iter()
.enumerate()
.map(|(pos, i)| {
let draft = &tool_calls_acc[i];
// CR-空 id 最终防御:流式 chunk 解析点(openai_helpers)已对 Some("") 生成
// fallback,但若 provider 流式完全不发 id 字段(chunk id=None,accumulate
// 不覆盖),draft.id 残留空串。此处按 sorted order 位置兜底
// `gen_stream_{pos}`,保证 push 到 messages 的 assistant tool_call.id 非空唯一
// (下游 audit/mod.rs:203 seen_ids 去重 + 工具结果按 tool_call_id 路由依赖此)。
// 非空 id 原样(与 chunk 解析点 fallback 共用 helper,DRY)。
let id = df_ai::provider::tool_call_id_or_fallback(&draft.id, pos, "gen_stream");
df_ai::provider::ToolCall::new(id, &draft.name, &draft.args)
})
.collect();
let mut msg = ChatMessage::assistant_with_tools(full_text, ai_tool_calls);
msg.model = Some(resolved_model.to_string());
msg.reasoning_content = last_reasoning_content.clone();
msg.prompt_tokens = Some(prompt_tokens);
msg.completion_tokens = Some(completion_tokens);
msg.prompt_cache_hit_tokens = Some(cache_hit);
msg.prompt_cache_miss_tokens = Some(cache_miss);
msg.reasoning_tokens = Some(reasoning);
msg.is_estimated = Some(estimated);
session.conv(conv_id).messages.push(msg);
} else if !full_text.is_empty() {
let mut msg = ChatMessage::assistant(full_text);
msg.model = Some(resolved_model.to_string());
msg.reasoning_content = last_reasoning_content.clone();
msg.prompt_tokens = Some(prompt_tokens);
msg.completion_tokens = Some(completion_tokens);
msg.prompt_cache_hit_tokens = Some(cache_hit);
msg.prompt_cache_miss_tokens = Some(cache_miss);
msg.reasoning_tokens = Some(reasoning);
msg.is_estimated = Some(estimated);
session.conv(conv_id).messages.push(msg);
}
}
// ── update_pinned_goals: G1 目标钉扎提取+裁剪(扁平抽自原嵌套块,原 8 层 → 3 层) ──
// 从工具调用推理新目标 → 去重 add → 标记旧 active 为 completed → 超上限时淘汰最早 completed。
// 入参 session 需外层调用方持锁;纯内存 mutate,无 await。
fn update_pinned_goals(
session: &mut AiSession,
conv_id: &str,
tool_calls_acc: &std::collections::HashMap<u32, super::ToolCallDraft>,
) {
let new_goals = infer_goal_from_tool_calls(tool_calls_acc);
if new_goals.is_empty() {
return;
}
let goal_conv = session.conv(conv_id);
let mut added = false;
for g in &new_goals {
let is_dup = goal_conv.pinned_goals.iter().any(|e|
e.text.contains(g.as_str()) || g.contains(&e.text)
);
if !is_dup {
goal_conv.pinned_goals.push(super::GoalEntry::new(g.clone()));
added = true;
}
}
// 有新目标 → 标记之前的 active 为 completed(LLM 进入下一任务)
if added && goal_conv.pinned_goals.len() > new_goals.len() {
let active_until = goal_conv.pinned_goals.len() - new_goals.len();
for entry in goal_conv.pinned_goals.iter_mut().take(active_until) {
if matches!(entry.status, super::GoalStatus::Active) {
entry.status = super::GoalStatus::Completed;
}
}
}
// 超上限时保留 active,淘汰最早 completed
if goal_conv.pinned_goals.len() > MAX_GOALS {
let active: Vec<super::GoalEntry> = goal_conv.pinned_goals.iter()
.filter(|g| matches!(g.status, super::GoalStatus::Active))
.cloned().collect();
if active.len() <= MAX_GOALS {
let keep = MAX_GOALS - active.len();
let completed: Vec<super::GoalEntry> = goal_conv.pinned_goals.iter()
.filter(|g| matches!(g.status, super::GoalStatus::Completed))
.rev().take(keep).cloned().collect();
let mut merged = active;
merged.extend(completed.into_iter().rev());
goal_conv.pinned_goals = merged;
} else {
goal_conv.pinned_goals.truncate(MAX_GOALS);
}
}
}
// ── Plan-driven Phase 1:Plan→事件载荷 + plan_id 生成辅助 ──
//
// 对齐 docs/02-架构设计/专项设计/aichat-plan-driven-设计-2026-08-01.md Phase 1:
// LLM 出 Plan 后 emit AiPlanCreated(步骤 + 状态 pending)。Plan 是 DAG(kahn to_layers
// 分层),事件载荷按 layer 分组(同层并行,层间串行),前端据此画 DAG 卡片(Phase 3)。
/// 生成 plan_id(时间戳 + 计数器,无需 ulid 依赖)。
///
/// 形如 `plan-1757000000000-42`,单调递增 + 进程内唯一。与 df-ai-core 的 new_message_id
/// 同思路(AtomicU64 计数),但本处 plan_id 仅供事件载荷标识,无落库无溯源需求。
fn ulid_like_id() -> String {
use std::sync::atomic::{AtomicU64, Ordering as O};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let n = COUNTER.fetch_add(1, O::SeqCst);
format!("{}-{}", ts, n)
}
/// 把 Plan(DAG)转为 AiPlanCreated 事件载荷(按 layer 分组)。
///
/// - 调 `plan.to_layers()`(kahn 拓扑分层)得层级顺序;
/// - 每层任务映射为 SubTaskInfo(id / persona_id / intent / status="pending");
/// - persona_id 由 `registry.recommend_for_intent` 推荐(对齐 dispatch 路径);
/// - to_layers 失败(环,正常不会发生——decompose_with_llm 已 validate 兜底)→
/// fallback 单层平铺(不阻断事件 emit)。
fn build_plan_layers_payload(
plan: &df_ai::planner::Plan,
coord: &Coordinator,
) -> Vec<super::PlanLayerInfo> {
let layers = plan.to_layers().unwrap_or_else(|_| {
// 环(unreachable:decompose_with_llm 已 validate 兜底)→ fallback 单层平铺
vec![plan.tasks.clone()]
});
layers
.into_iter()
.map(|layer| super::PlanLayerInfo {
items: layer
.into_iter()
.map(|t| {
let persona_id = coord.recommend_persona_id(&t.intent);
super::SubTaskInfo {
id: t.id,
persona_id,
intent: t.intent,
status: "pending".to_string(),
}
})
.collect(),
})
.collect()
}
// panic 防护场景回放:构造 loop 体 panic 的最小等价场景,验证 unwind_to_result 把 panic
// 转为受控 Err、外层状态不随 unwind 丢失、panic 前构造的 RAII 在 unwind 时执行 Drop
// (对应生产侧 GeneratingGuard Drop 复位 ConvState 的兜底)。先于实现落盘,红转绿。
#[cfg(test)]
mod panic_guard_tests {
use super::*;
// 正常完成:Ok 直通,包装层不得吞输出或改变返回值
#[tokio::test]
async fn catch_unwind_ok_path_unchanged() {
let r = unwind_to_result(async { 41 + 1 }).await;
assert_eq!(r, Ok(42));
}
// String 载荷 panic 转受控 Err,消息原样保留(生产侧凭此记 tracing::error)
#[tokio::test]
async fn catch_unwind_string_panic_to_controlled_err() {
let r = unwind_to_result(async { panic!("boom {}", 7); }).await;
assert_eq!(r, Err("boom 7".to_string()));
}
// &'static str 载荷同样收敛
#[tokio::test]
async fn catch_unwind_str_panic_to_controlled_err() {
let r = unwind_to_result(async { panic!("static boom"); }).await;
assert_eq!(r, Err("static boom".to_string()));
}
// 非字符串载荷(panic_any)收敛为兜底文案,终态不因载荷类型丢失
#[tokio::test]
async fn catch_unwind_nonstring_panic_to_fallback_err() {
let r = unwind_to_result(async { std::panic::panic_any(42u32); }).await;
assert!(matches!(r, Err(ref m) if m.contains("panic")), "应走兜底文案: {:?}", r);
}
// 无泄漏断言:panic 前 mutate 的共享状态对包装层外可见(生产侧对应 panic 分支仍能用
// 外层 guard/tokens 走 teardown);panic 前构造的 RAII 在 unwind 时执行 Drop
// (对应 GeneratingGuard 在 unwind 路径复位 ConvState 的既有兜底)
#[tokio::test]
async fn catch_unwind_outer_state_survives_and_drop_runs_on_panic() {
struct DropFlag(std::sync::Arc<std::sync::atomic::AtomicBool>);
impl Drop for DropFlag {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
let outer = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let dropped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let outer_in_fut = outer.clone();
let dropped_in_fut = dropped.clone();
let r = unwind_to_result(async move {
let _raii = DropFlag(dropped_in_fut);
outer_in_fut.store(true, std::sync::atomic::Ordering::SeqCst);
panic!("mid teardown");
}).await;
assert!(r.is_err());
assert!(outer.load(std::sync::atomic::Ordering::SeqCst), "panic 前的外层 mutate 应保留");
assert!(dropped.load(std::sync::atomic::Ordering::SeqCst), "unwind 时 RAII Drop 应执行");
}
}
// 跨轮任务级重入(深查 P1-4)退避调度契约:重入循环按 `BACKOFF_SECS[failures - 1]` 取值,
// 表长必须覆盖 ROUND_REENTRY_MAX(越界 panic 面)且单调递增(指数退避语义)。
#[cfg(test)]
mod round_reentry_tests {
use super::{ROUND_REENTRY_BACKOFF_SECS, ROUND_REENTRY_MAX};
#[test]
fn backoff_schedule_covers_max_attempts_and_ascending() {
assert_eq!(
ROUND_REENTRY_BACKOFF_SECS.len(),
ROUND_REENTRY_MAX,
"退避表长须覆盖重入上限(取值索引 failures-1 不越界)",
);
for w in ROUND_REENTRY_BACKOFF_SECS.windows(2) {
assert!(w[0] < w[1], "退避须单调递增(指数退避语义): {:?}", w);
}
// 深查处方锚定:1min/5min/15min,覆盖 WiFi 切换/VPN 重连/上游限流窗口量级
assert_eq!(ROUND_REENTRY_BACKOFF_SECS, [60, 300, 900]);
}
}
// 末轮注入扩轮(todo.md 28)loop 侧契约:到达原轮次终点时,注入侧顺延的余量必须被消费
// 续跑(「末轮注入→有续轮」),无余量则收尾(与原 for 自然结束等价,零行为变化)。
// 与注入侧判定 injection_hits_final_round(commands/ai/commands/chat.rs)分半锁同一契约。
#[cfg(test)]
mod inject_extra_rounds_tests {
use super::{inject_extra_rounds_decision, INJECT_EXTRA_ROUNDS_CAP};
#[test]
fn final_round_with_grant_continues() {
// 末轮注入→有续轮:原终点处有余量,续 1 轮(核心契约)
assert_eq!(inject_extra_rounds_decision(10, 0, 1), Some(1));
// 多次末轮注入余量叠加,一次全消费(上限内)
assert_eq!(inject_extra_rounds_decision(10, 0, 3), Some(3));
}
#[test]
fn final_round_without_grant_ends() {
// 无注入:终点处无余量,收尾(行为与原 for 自然结束逐路径等价)
assert_eq!(inject_extra_rounds_decision(10, 0, 0), None);
}
#[test]
fn extra_rounds_capped() {
// 累计扩轮封顶 INJECT_EXTRA_ROUNDS_CAP:room 不足只消费剩余额度,打满后不再扩轮
assert_eq!(
inject_extra_rounds_decision(10, INJECT_EXTRA_ROUNDS_CAP - 1, 3),
Some(1),
"room=1 时只消费 1 轮",
);
assert_eq!(
inject_extra_rounds_decision(10, INJECT_EXTRA_ROUNDS_CAP, 2),
None,
"打满上限后不再扩轮",
);
}
#[test]
fn unlimited_loop_never_extends() {
// max_iterations=0 不限轮次:effective_max=usize::MAX,终点分支不可达,防御收尾
assert_eq!(inject_extra_rounds_decision(usize::MAX, 0, 5), None);
}
}