- 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 测试过
3522 lines
192 KiB
Rust
3522 lines
192 KiB
Rust
//! 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, ¬ify,
|
||
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, ¬ify,
|
||
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);
|
||
}
|
||
}
|