重构: aichat agent 能力系统化(L1元能力+L2/L3后端+list去重)

L1 agent 元能力层(治痛①死循环零交付):
- env_profile 环境姿势注入 + shell 默认 PowerShell(防引号地狱)
- 断路器:同类工具失败≥3熔断 + guard.reset
- detect_environment 主动探测工具(python/node/shell)
- 求助协议 AiHelpRequired 事件 + 前端求助卡

L2 统一状态机后端(治痛②,前端批2):
- ConvState enum 5态 + 合法转换守卫(conv_state.rs)
- GeneratingGuard 接入视图层(guard.rs)

L3 事件总线后端骨架(治痛③④⑤,接入批2):
- EventBus pub-sub + AiBusEvent 8变体(event_bus.rs)

list 工具调用重复治理第一步:
- build_system_prompt_with_excluded 去重被@实体 + 清单注明语
This commit is contained in:
lxy
2026-06-22 00:04:14 +08:00
parent bd6a41fe6e
commit d2cada97cd
16 changed files with 1752 additions and 31 deletions
@@ -0,0 +1,468 @@
//! L2 统一状态机 — `ConvState` enum(单一真相源)+ 合法转换守卫。
//!
//! 来源:`generating状态机加固-2026-06-15.md` §3(轻量状态机决策)+ `aichat体验与
//! agent能力系统化重构-2026-06-21.md` §3(L2 统一状态机)。
//!
//! # 背景(为什么单独建这个 enum)
//!
//! 旧实现会话状态由三个标志组合隐式表达(`generating: bool` + `stop_flag: AtomicBool`
//! + `pending_approvals` HashMap),判别逻辑散落 `try_continue_agent_loop` /
//! `ai_chat_stop` / `ai_conversation_create` 三处(写侧散布 + 读侧散布双根因,见
//! generating状态机加固-2026-06-15.md §2.3)。本模块提供**显式的、单一真相的**状态枚举:
//!
//! - **写侧收敛**:`ConvState` 仅经 [`ConvState::transition_to`] 守卫方法迁移,非法转换
//! 直接拒绝(`Err(InvalidTransition)`),调用方无法绕过守卫写入非法组合。
//! - **读侧收敛**:停止按钮三态(可停 / 停中 / 停失败可重试)、MaxRoundsCard 是否弹等
//! 判别逻辑由 `ConvState` 变体直接表达,不再靠多变量组合反推。
//!
//! # 渐进第一步(本批范围)
//!
//! 本模块是**纯逻辑、无 IO**的 enum + 转换守卫,不替代 `PerConvState.generating` 等
//! 字段(渐进不一次全换)。guard 接入点(`GeneratingGuard` set/reset 时同步 `ConvState`
//! 转换)作为视图层;开关 [`CONV_STATE_ENABLED`](constant.CONV_STATE_ENABLED.html)
//! 默认 on,off 降级纯旧 bool 行为(可回退)。
//!
//! 后续(批2+):把散落判别点(`try_continue` / `ai_chat_stop` / MaxRoundsCard)逐个迁到
//! 读 `ConvState`,并视情况把 `generating` bool 退役(状态字段由 enum 单一持有)。
use serde::{Deserialize, Serialize};
// ============================================================
// 开关
// ============================================================
/// L2 状态机总开关(默认 on)。
///
/// - **on(默认)**:`GeneratingGuard` set/reset 时同步迁移 [`ConvState`](enum.ConvState.html)
/// 作视图层,前后端可读统一状态;guard 失败兜底仍走旧 bool 复位(`generating=false`),
/// 不影响语义(状态机层失败不阻断核心复位)。
/// - **off(降级)**:guard 不写 `ConvState`,纯旧 `generating` bool 行为(排障 / 对比 / 临时
/// 回退用)。设为 false 即可观察无状态机接入的效果,语义等价改动前。
///
/// 机制优先 prompt 说教:每改进配开关 + 兜底,分阶段可回退(对齐 AI 改进方案三原则)。
pub const CONV_STATE_ENABLED: bool = true;
// ============================================================
// ConvState enum
// ============================================================
/// 对话生命周期状态(单一真相源,5 态)。
///
/// 设计取舍(对齐 generating状态机加固-2026-06-15.md §3.2 「轻量状态机,不引入框架」):
///
/// - **不引入状态机框架**(codegen / FSM 库):5 态 7 边转换简单,枚举 + 显式 `transition_to`
/// 守卫即可表达,过度封装得不偿失。
/// - **派生关系明确**:`Compressed` 是 `Generating`/`Idle` 期间的瞬时派生态(压缩跑完
/// 回到原态),非终端态;`Error` 可经用户「重试」回到 `Idle` 再 `Generating`。
/// - **与读视图 [`SessionState`](../enum.SessionState.html) 区分**:`SessionState`
/// (Streaming/AwaitingApproval/Idle)是面向「是否有审批挂起」的只读视图(读侧收敛①),
/// `ConvState` 是面向「生成生命周期」的写侧真相(写收敛)。两者正交:如 `Generating` 态
/// 同时有审批挂起时,`ConvState=Generating` 而 `SessionState=AwaitingApproval`。
///
/// 序列化(`Serialize`/`Deserialize`):批3 前端经事件总线读 enum 视图时使用(本批未接,
/// 预留,避免后续改动序列化兼容性)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConvState {
/// 空闲:无 agentic loop 活跃,可接新请求。
///
/// 终态目标:所有正常退出路径(完成 / 停止完成 / 错误恢复)的归处。
Idle,
/// 生成中:agentic loop 流式生成 + 工具执行活跃中(`generating=true`)。
///
/// 审批挂起期间 `ConvState` 仍为 `Generating`(审批是 loop 内的暂停点,非独立状态)。
Generating,
/// 停止中:用户点了停止(`stop_flag` 已置位),loop 检测中或正在收尾退出。
///
/// 瞬态:loop 检测到 `stop_flag` 后迁移到 `Idle`(完成收尾),或停止过程出错到 `Error`。
Stopping,
/// 错误态:生成过程出错(provider 失败 / Fatal / 断路器熔断等),loop 已退出。
///
/// 非终态:用户可「重试」从 `Error` → `Idle` → `Generating` 重新发起,或继续空闲。
Error,
/// 压缩派生态:loop 期间触发上下文压缩(LLM 摘要),生成流程的瞬时派生。
///
/// 派生而非独立态:压缩完成(成功 / 失败兜底)后回到原 `Generating`(loop 内压缩)或
/// `Idle`(手动压缩 IPC 触发)。前端可据此展示「压缩中」spinner(本批未接)。
Compressed,
}
impl Default for ConvState {
/// 新会话默认 `Idle`(无 loop 活跃)。
fn default() -> Self {
ConvState::Idle
}
}
impl ConvState {
/// 尝试迁移到目标态。合法返回 `Ok(新态)`,非法拒绝返回 `Err(InvalidTransition)`。
///
/// 调用方持锁后经本方法迁移状态(写收敛):不允许直接写字段绕过守卫。
/// 非法转换(如 `Idle → Stopping`,无活跃 loop 不可能停止)直接拒绝——调用方应视为
/// 逻辑 bug 并记录 warn 日志,不静默忽略(状态机完整性优先)。
///
/// # 合法转换表(7 边)
///
/// | from | to | 触发场景 |
/// |------|----|---------|
/// | Idle | Generating | `run_agentic_loop` 入口,guard set |
/// | Generating | Stopping | 用户点停止,置 `stop_flag` |
/// | Generating | Idle | loop 正常收敛退出,guard reset |
/// | Generating | Error | provider Fatal / 断路器熔断等 |
/// | Generating | Compressed | loop 内自动压缩派生 |
/// | Stopping | Idle | stop 收尾完成,guard reset |
/// | Stopping | Error | 停止过程出错 |
/// | Error | Idle | 用户重试前置位(准备再 `Generating`) |
/// | Error | Generating | 直接从错误态重新生成(重试) |
/// | Compressed | Generating | 压缩完成,loop 内继续 |
/// | Compressed | Idle | 手动压缩 IPC 完成(无 loop 活跃) |
///
/// 自环(同态 → 同态)允许(幂等写,如 guard 多次 set 同态),返回 `Ok(self)`。
pub fn transition_to(self, target: ConvState) -> Result<ConvState, InvalidTransition> {
// 自环幂等:同态迁移直接通过(防调用方重复 set 报错)。
if self == target {
return Ok(target);
}
// 合法转换白名单(显式列举,非 `match _ => Ok` 兜底——新加态必须显式补边,
// 编译器不会漏)。
let valid = matches!(
(self, target),
// Idle 起步
(ConvState::Idle, ConvState::Generating)
// Generating 分流:停 / 完成 / 错误 / 压缩派生
| (ConvState::Generating, ConvState::Stopping)
| (ConvState::Generating, ConvState::Idle)
| (ConvState::Generating, ConvState::Error)
| (ConvState::Generating, ConvState::Compressed)
// Stopping 收尾:完成 / 出错
| (ConvState::Stopping, ConvState::Idle)
| (ConvState::Stopping, ConvState::Error)
// Error 恢复:重试前置位 / 直接重新生成
| (ConvState::Error, ConvState::Idle)
| (ConvState::Error, ConvState::Generating)
// Compressed 派生回退:loop 继续 / 手动压缩完成
| (ConvState::Compressed, ConvState::Generating)
| (ConvState::Compressed, ConvState::Idle)
);
if valid {
Ok(target)
} else {
Err(InvalidTransition { from: self, to: target })
}
}
/// 是否处于活跃生成态(`Generating` / 压缩派生 `Compressed`)。
///
/// 读侧便利方法:压缩派生期间 loop 仍活跃(只是临时跑压缩 LLM),归「活跃」。
/// 审批挂起(读视图 `SessionState::AwaitingApproval`)期间 `ConvState` 仍是 `Generating`,
/// 故本方法返回 true——审批挂起不算「停止生成」。
///
/// 预留:批2+ 读侧迁移(try_continue / ai_chat_stop / 前端停止三态)消费。本批仅建状态机 +
/// 入口校验,无消费方,标 `allow(dead_code)` 保留(对齐零调用方区分真死 vs 预留原则)。
#[allow(dead_code)]
pub fn is_active(self) -> bool {
matches!(self, ConvState::Generating | ConvState::Compressed)
}
/// 是否可接受新请求(非活跃、非停止中、非压缩派生)。
///
/// 读侧便利方法:用于 `ai_conversation_create` / `ai_chat_send` 等入口判断
/// 「能否接新请求」。`Error` 态视为可接(用户重试即从 Error 起步)。
///
/// 预留:批2+ 读侧迁移(入口能否接新请求判断)消费。本批无消费方,标 `allow(dead_code)` 保留。
#[allow(dead_code)]
pub fn can_accept_request(self) -> bool {
matches!(self, ConvState::Idle | ConvState::Error)
}
}
// ============================================================
// InvalidTransition(非法转换错误)
// ============================================================
/// 状态机非法转换错误。
///
/// 携带 `from` / `to` 供调用方记录诊断日志(不 panic:状态机完整性违反应记 warn +
/// 兜底走旧 bool 行为,不阻断核心生成流程——对齐「机制优先,失败兜底」原则)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InvalidTransition {
pub from: ConvState,
pub to: ConvState,
}
impl std::fmt::Display for InvalidTransition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "ConvState 非法转换:{:?} → {:?}", self.from, self.to)
}
}
impl std::error::Error for InvalidTransition {}
// ============================================================
// 单元测试(纯逻辑无 IO)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
// ---- 合法转换通过 ----
#[test]
fn test_idle_to_generating() {
assert_eq!(
ConvState::Idle.transition_to(ConvState::Generating),
Ok(ConvState::Generating)
);
}
#[test]
fn test_generating_to_stopping() {
assert_eq!(
ConvState::Generating.transition_to(ConvState::Stopping),
Ok(ConvState::Stopping)
);
}
#[test]
fn test_generating_to_idle_normal_complete() {
// loop 正常收敛退出
assert_eq!(
ConvState::Generating.transition_to(ConvState::Idle),
Ok(ConvState::Idle)
);
}
#[test]
fn test_generating_to_error_provider_fatal() {
// provider Fatal / 断路器熔断
assert_eq!(
ConvState::Generating.transition_to(ConvState::Error),
Ok(ConvState::Error)
);
}
#[test]
fn test_generating_to_compressed_loop_auto_compress() {
// loop 内自动压缩派生
assert_eq!(
ConvState::Generating.transition_to(ConvState::Compressed),
Ok(ConvState::Compressed)
);
}
#[test]
fn test_stopping_to_idle_complete() {
// stop 收尾完成
assert_eq!(
ConvState::Stopping.transition_to(ConvState::Idle),
Ok(ConvState::Idle)
);
}
#[test]
fn test_stopping_to_error() {
assert_eq!(
ConvState::Stopping.transition_to(ConvState::Error),
Ok(ConvState::Error)
);
}
#[test]
fn test_error_to_idle_retry_prep() {
// 用户重试前置位
assert_eq!(
ConvState::Error.transition_to(ConvState::Idle),
Ok(ConvState::Idle)
);
}
#[test]
fn test_error_to_generating_retry() {
// 直接从错误态重新生成
assert_eq!(
ConvState::Error.transition_to(ConvState::Generating),
Ok(ConvState::Generating)
);
}
#[test]
fn test_compressed_to_generating_loop_continue() {
// 压缩完成 loop 内继续
assert_eq!(
ConvState::Compressed.transition_to(ConvState::Generating),
Ok(ConvState::Generating)
);
}
#[test]
fn test_compressed_to_idle_manual_compress_done() {
// 手动压缩 IPC 完成(无 loop 活跃)
assert_eq!(
ConvState::Compressed.transition_to(ConvState::Idle),
Ok(ConvState::Idle)
);
}
// ---- 自环幂等 ----
#[test]
fn test_self_transition_idempotent() {
// 同态迁移直接通过(防调用方重复 set 报错)
for s in [
ConvState::Idle,
ConvState::Generating,
ConvState::Stopping,
ConvState::Error,
ConvState::Compressed,
] {
assert_eq!(s.transition_to(s), Ok(s), "{:?} 自环应通过", s);
}
}
// ---- 非法转换拒绝 ----
#[test]
fn test_idle_to_stopping_rejected() {
// 无活跃 loop 不可能停止
let err = ConvState::Idle
.transition_to(ConvState::Stopping)
.unwrap_err();
assert_eq!(err.from, ConvState::Idle);
assert_eq!(err.to, ConvState::Stopping);
}
#[test]
fn test_idle_to_error_rejected() {
// Idle 直接到 Error 无意义(错误必经活跃态产生)
assert!(ConvState::Idle.transition_to(ConvState::Error).is_err());
}
#[test]
fn test_idle_to_compressed_rejected() {
// 无 loop 活跃不会触发压缩派生(手动压缩 IPC 不经状态机派生)
assert!(ConvState::Idle.transition_to(ConvState::Compressed).is_err());
}
#[test]
fn test_stopping_to_generating_rejected() {
// 停止中不能直接回生成(须先回 Idle 再起)
assert!(ConvState::Stopping
.transition_to(ConvState::Generating)
.is_err());
}
#[test]
fn test_stopping_to_compressed_rejected() {
assert!(ConvState::Stopping
.transition_to(ConvState::Compressed)
.is_err());
}
#[test]
fn test_error_to_stopping_rejected() {
// 错误态已停止,不能再停
assert!(ConvState::Error.transition_to(ConvState::Stopping).is_err());
}
#[test]
fn test_error_to_compressed_rejected() {
assert!(ConvState::Error.transition_to(ConvState::Compressed).is_err());
}
#[test]
fn test_compressed_to_stopping_rejected() {
// 压缩派生期间不直接到停止(压缩完成后由原态分流)
assert!(ConvState::Compressed
.transition_to(ConvState::Stopping)
.is_err());
}
#[test]
fn test_compressed_to_error_rejected() {
assert!(ConvState::Compressed
.transition_to(ConvState::Error)
.is_err());
}
// ---- 便利方法 ----
#[test]
fn test_is_active() {
assert!(!ConvState::Idle.is_active());
assert!(ConvState::Generating.is_active());
assert!(!ConvState::Stopping.is_active());
assert!(!ConvState::Error.is_active());
// 压缩派生期间 loop 仍活跃(临时跑压缩 LLM)
assert!(ConvState::Compressed.is_active());
}
#[test]
fn test_can_accept_request() {
assert!(ConvState::Idle.can_accept_request());
assert!(!ConvState::Generating.can_accept_request());
assert!(!ConvState::Stopping.can_accept_request());
// Error 态可接(用户重试即从 Error 起步)
assert!(ConvState::Error.can_accept_request());
assert!(!ConvState::Compressed.can_accept_request());
}
#[test]
fn test_default_is_idle() {
assert_eq!(ConvState::default(), ConvState::Idle);
}
// ---- 完整生命周期链 ----
#[test]
fn test_full_lifecycle_normal_complete() {
// 正常生命周期:Idle → Generating → Idle
let s = ConvState::Idle;
let s = s.transition_to(ConvState::Generating).unwrap();
let s = s.transition_to(ConvState::Idle).unwrap();
assert_eq!(s, ConvState::Idle);
}
#[test]
fn test_full_lifecycle_with_stop() {
// 停止生命周期:Idle → Generating → Stopping → Idle
let s = ConvState::Idle;
let s = s.transition_to(ConvState::Generating).unwrap();
let s = s.transition_to(ConvState::Stopping).unwrap();
let s = s.transition_to(ConvState::Idle).unwrap();
assert_eq!(s, ConvState::Idle);
}
#[test]
fn test_full_lifecycle_with_compress() {
// 压缩派生:Idle → Generating → Compressed → Generating → Idle
let s = ConvState::Idle;
let s = s.transition_to(ConvState::Generating).unwrap();
let s = s.transition_to(ConvState::Compressed).unwrap();
let s = s.transition_to(ConvState::Generating).unwrap();
let s = s.transition_to(ConvState::Idle).unwrap();
assert_eq!(s, ConvState::Idle);
}
#[test]
fn test_full_lifecycle_error_retry() {
// 错误恢复:Idle → Generating → Error → Generating → Idle
let s = ConvState::Idle;
let s = s.transition_to(ConvState::Generating).unwrap();
let s = s.transition_to(ConvState::Error).unwrap();
let s = s.transition_to(ConvState::Generating).unwrap();
let s = s.transition_to(ConvState::Idle).unwrap();
assert_eq!(s, ConvState::Idle);
}
#[test]
fn test_invalid_transition_error_display() {
// Display 包含 from / to,供日志诊断
let err = ConvState::Idle
.transition_to(ConvState::Stopping)
.unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("Idle"), "Display 应含 from: {}", msg);
assert!(msg.contains("Stopping"), "Display 应含 to: {}", msg);
}
}
+119 -1
View File
@@ -9,6 +9,8 @@ use tokio::sync::Mutex;
use crate::commands::ai::AiSession;
use super::conv_state::{self, ConvState};
/// generating 复位 RAII guard,取代散布的手动 `session.generating = false`。
///
/// 两路复位:
@@ -22,32 +24,87 @@ use crate::commands::ai::AiSession;
/// F-260616-09 B 批4:guard 持 `conv_id`,复位改写 `session.conv(&conv_id).generating = false`
/// (per-conv 唯一真相源)。顶层 `session.generating` 字段已在批4 删除,reset/Drop 仅写 per_conv;
/// IPC(ai_is_generating/ai_chat_send 等)亦改读 per_conv,无需双写桥接。
///
/// L2 统一状态机(渐进第一步,2026-06-21):guard 内嵌 `ConvState` 作**写收敛视图层**。
///
/// - `new` 时按 `conv_state::CONV_STATE_ENABLED` 门控,把内部 `state` 从 `Idle` 经守卫迁移到
/// `Generating`(非法转换记 warn 不阻断,对齐「机制优先,失败兜底」原则)。
/// - `reset` / `drop` 收尾时同步迁移 `Generating → Idle`(状态机视图与 `generating` bool 双轨,
/// bool 仍是核心真相源,enum 仅视图)。off 降级:guard 不持/不迁 enum,纯旧 bool 行为(可回退)。
///
/// **渐进取舍(为什么 enum 放 guard 而非 PerConvState 字段)**:guard 是 loop 生命周期的天然
/// 边界(set/reset/Drop 成对),把 enum 绑 guard 即覆盖全部生成态进入/退出点,无需改 PerConvState
/// 字段(改字段会牵动所有读写点,属批2+ 范围)。本批 enum 仅作 guard 内部视图,不对外持久化;
/// 批2+ 把 enum 提升到 PerConvState.conv_state 字段后,guard 仍可保留作写收敛入口(枚举源切换)。
pub(super) struct GeneratingGuard {
session: Arc<Mutex<AiSession>>,
/// guard 所属会话(loop 启动时快照的 conv_id,来自 run_agentic_loop 入参)。
conv_id: String,
done: bool,
/// L2 状态机视图(CONV_STATE_ENABLED 门控)。None = 开关 off 降级,guard 不持视图。
/// 仅 guard 生命周期内的本地视图,不持久化(批2+ 提升至 PerConvState 字段)。
state: Option<ConvState>,
}
impl GeneratingGuard {
pub(super) fn new(session: Arc<Mutex<AiSession>>, conv_id: String) -> Self {
Self { session, conv_id, done: false }
// CONV_STATE_ENABLED 门控:off → state=None,guard 不持/不迁 enum(纯旧 bool 行为)。
// on → state 从 Idle 起,入口即经守卫迁移到 Generating(写收敛:状态机入口校验)。
let mut state = if conv_state::CONV_STATE_ENABLED {
Some(ConvState::Idle)
} else {
None
};
if let Some(s) = state.as_mut() {
// Idle → Generating 守卫迁移。正常路径(self-loop 幂等 / 首次进入)合法;理论非法不发生
// (新 guard 必 Idle),失败记 warn 不阻断(对齐失败兜底原则)。
match s.transition_to(ConvState::Generating) {
Ok(new_state) => *s = new_state,
Err(e) => {
tracing::warn!(
conv_id = %conv_id,
error = %e,
"[ai] guard.new ConvState Idle→Generating 非法(状态机视图层,不阻断核心生成)"
);
// 状态机层失败:enum 保留 Idle(不写非法态),核心 generating 仍由调用方 set true。
// 即 enum 视图与 bool 暂时不同步,但 enum 仅视图不影响核心复位语义。
}
}
}
Self { session, conv_id, done: false, state }
}
/// 显式复位 generating=false。emit 前调用保证顺序。幂等。
///
/// 仅写 per_conv.conv_id.generating(唯一真相源)。
/// L2:同步迁移 `ConvState → Idle`(状态机视图,CONV_STATE_ENABLED 门控)。
pub(super) async fn reset(&mut self) {
if !self.done {
let mut session = self.session.lock().await;
session.conv(&self.conv_id).generating = false;
self.done = true;
}
// 状态机视图同步:Generating → Idle(正常收敛退出)。仅记日志验证迁移合法(批2+ 接入
// PerConvState.conv_state 字段后此处写字段持久化)。done=true 后再次 reset 幂等,enum 已 Idle
// 自环通过。开关 off → state=None 跳过(纯旧 bool 行为)。
if let Some(s) = self.state.as_mut() {
match s.transition_to(ConvState::Idle) {
Ok(new_state) => *s = new_state,
Err(e) => tracing::warn!(
conv_id = %self.conv_id,
error = %e,
"[ai] guard.reset ConvState →Idle 非法(状态机视图层,不阻断核心复位)"
),
}
}
}
/// 解除 Drop 兜底复位但不复位 generating。审批等待 return 路径调用:
/// 保持 generating=true 留 try_continue 续生成,同时 Drop 因 done=true 跳过复位 spawn。
/// (B-260615-26: 修复审批执行后对话不续生成回归)
///
/// L2:disarm 不迁移 ConvState(审批等待是 Generating 内的暂停点,ConvState 仍 Generating,
/// 与「generating 保持 true」语义一致)。续跑时新 guard.new 重新迁移(Generating 自环幂等)。
pub(super) fn disarm(&mut self) {
self.done = true;
}
@@ -55,7 +112,19 @@ impl GeneratingGuard {
impl Drop for GeneratingGuard {
fn drop(&mut self) {
// 状态机视图同步(panic/异常退出路径):未 done 即异常退出,enum 应从 Generating→Idle 收敛。
// 仅记日志(无 await 上下文,不写持久化——批2+ 接入字段后此处可写)。
// 开关 off / done=true(正常 reset/disarm 已走) → 跳过。
if !self.done {
if let Some(s) = self.state.as_mut() {
if let Err(e) = s.transition_to(ConvState::Idle) {
tracing::warn!(
conv_id = %self.conv_id,
error = %e,
"[ai] guard.drop ConvState →Idle 非法(异常退出兜底,核心 generating 仍 spawn 复位)"
);
}
}
let session = self.session.clone();
let conv_id = self.conv_id.clone();
tauri::async_runtime::spawn(async move {
@@ -65,3 +134,52 @@ impl Drop for GeneratingGuard {
}
}
}
// ============================================================
// 单元测试(guard 内嵌 ConvState 视图层)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
/// guard.new 默认迁移 Idle→Generating,内部视图应为 Generating(CONV_STATE_ENABLED on)。
///
/// 注:不构造真实 AiSession(lock/Mutex 建会话状态成本高),仅校验 state 字段的迁移语义——
/// new 内部 transition_to 是纯函数,直接断言逻辑路径。此处用相同的纯函数路径验证 guard 语义。
#[test]
fn test_guard_new_migrates_to_generating() {
// 模拟 guard.new 内部状态机迁移路径(纯逻辑,无 session):
// CONV_STATE_ENABLED on → Idle → Generating。
assert!(conv_state::CONV_STATE_ENABLED, "开关默认 on,测试假设");
let mut s = ConvState::Idle;
s = s.transition_to(ConvState::Generating).unwrap();
assert_eq!(s, ConvState::Generating);
}
/// reset 迁移 Generating→Idle(正常收敛),验证迁移合法。
#[test]
fn test_guard_reset_migrates_to_idle() {
let mut s = ConvState::Generating;
s = s.transition_to(ConvState::Idle).unwrap();
assert_eq!(s, ConvState::Idle);
}
/// 异常 Drop 路径同样 Generating→Idle(兜底),验证迁移合法。
#[test]
fn test_guard_drop_path_migrates_to_idle() {
// guard.drop 异常路径(!done)迁移 Generating→Idle。
let mut s = ConvState::Generating;
s = s.transition_to(ConvState::Idle).unwrap();
assert_eq!(s, ConvState::Idle);
}
/// disarm 不迁移状态(审批等待保持 Generating)。
/// 验证:disarm 后 enum 仍 Generating(下次 new 时 Generating 自环幂等通过)。
#[test]
fn test_guard_disarm_keeps_generating_then_self_loop_on_new() {
let s = ConvState::Generating;
// disarm 不动 enum;续跑新 guard.new 时 Generating→Generating 自环通过。
assert_eq!(s.transition_to(ConvState::Generating), Ok(ConvState::Generating));
}
}
+139 -1
View File
@@ -21,7 +21,7 @@ use df_ai::context_helpers::{
// 收敛的扁平子集之上叠加 plan_hint 编排(并行组同批聚拢/顺序依赖源在前),供 LLM 看到
// 一份按编排意图排序的工具列表。feature flag PLANNING_ENABLED(false 默认关)门控接入。
use df_ai::intent::{filter_tool_defs, filter_tool_defs_planned, IntentRecognizer};
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
// CR-30-1: 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类)
// 实现流前失败重试退避对齐(决策 F-260616-07 a1),避免重写退避逻辑。
use df_ai::retry;
@@ -106,6 +106,30 @@ pub const TOOL_RESULT_COMPRESS_ENABLED: bool = true;
/// 保守:双高置信才标(任一 topic None 不标),不强制 LLM(软提示非硬约束)。
pub const TOPIC_MARKER_ENABLED: bool = true;
/// L1 断路器:连续同类工具失败熔断阈值(治 kms 会话 53 轮 0 产出死循环)。
///
/// 背景:agent 无止损,某工具反复同类失败(权限拒绝/路径错误等)仍每轮重试,
/// 耗尽 max_iterations 前 0 产出。机制(非 prompt 教 AI):每轮 process_tool_calls
/// 后取末尾连续 Tool 消息,失败内容前 40 字符归一为 key 计数,同一 key 累计达此阈值 →
/// guard.reset + emit AiError + return 强制熔断,逼用户换思路或人工介入。
///
/// 阈值 3:同类失败 3 次足以判死循环(去重后仍累加,不同错误各自计数互不干扰)。
pub const CIRCUIT_BREAKER_THRESHOLD: u32 = 3;
/// 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;
// 阶段2(path_auth 审批链重构):占位配对完整性开关(解 400 orphan)。
//
// 单一真相源:`df_ai::context_helpers::PLACEHOLDER_INTEGRITY_ENABLED`(本模块顶部已 use)。
@@ -131,6 +155,22 @@ pub const TOPIC_MARKER_ENABLED: bool = true;
mod guard;
use guard::GeneratingGuard;
// ============================================================
// L2 统一状态机(渐进第一步,2026-06-21):ConvState enum + 转换守卫。
// 设计:generating状态机加固-2026-06-15.md §3 + aichat体验与agent能力系统化重构-2026-06-21.md §3。
//
// 本批范围(渐进):
// - 新建 conv_state.rs(纯逻辑 enum + 守卫 + 单测)。
// - run_agentic_loop 入口桥接:guard 置 generating=true 时同步迁移 ConvState
// (Idle→Generating / Error→Generating),作视图层。CONV_STATE_ENABLED 开关门控。
// - guard.reset 同步 ConvState→Idle(正常退出路径)。
// - **不替换** PerConvState.generating bool(渐进不一次全换,批2+ 逐步迁读侧)。
//
// 开关 CONV_STATE_ENABLED(默认 on):off 降级纯旧 bool 行为(可回退)。
// 兜底:状态机层迁移失败(非法转换)记 warn 不 panic,核心 generating 复位仍走旧 bool。
// ============================================================
pub mod conv_state;
// ============================================================
// F-260614-04 / F-260614-04b: 单 Provider 流式结果 + fallback 辅助
// ============================================================
@@ -374,6 +414,11 @@ pub(crate) async fn run_agentic_loop(
//
// 单 loop 安全性:批2 阶段是单 active_conversation_id(决策 e 真并发批3+ 落地),无并发 loop 抢
// per_conv 覆盖。批3+ 多 loop 并发时,每 conv 各自 per_conv 条目,互不干扰(本桥接无需改)。
//
// L2 状态机视图层:ConvState 的写收敛已收敛到 `guard`(GeneratingGuard::new 已在上方 L399 创建,
// new 内部 Idle→Generating 迁移 + reset/drop Generating→Idle 迁移,CONV_STATE_ENABLED 门控)。
// 此处不再重复迁移 enum,仅写核心 generating bool(唯一真相源,enum 是其视图)。批2+ 把 enum
// 提升到 PerConvState.conv_state 字段后,此 bool 写入与 enum 迁移由 guard 统一收敛。
{
let mut session = session_arc.lock().await;
let conv = session.conv(&conv_id);
@@ -601,6 +646,10 @@ pub(crate) async fn run_agentic_loop(
// 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常
let mut converged = false;
// L1 断路器:连续同类工具失败计数器(key=失败内容前 40 字符,value=累计次数)。
// loop 生命周期内累加,每轮 process_tool_calls 后检查。达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。
let mut fail_counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
// BUG-260617-12: DeepSeek thinking 模式推理内容跨轮透传
let mut last_reasoning_content: Option<String> = None;
@@ -1240,6 +1289,95 @@ pub(crate) async fn run_agentic_loop(
"[AI-DIRAUTH-DIAG] agentic loop 收到 pending"
);
// L1 断路器:连续同类工具失败熔断(治 agent 无止损死循环,机制非 prompt 说教)。
// CIRCUIT_BREAKER_ENABLED=false → 整段跳过降级 max_iterations 旧行为(开关 + 兜底)。
// 仅检查自动执行(Low)的工具结果——pending_count>0(待审批)交给下方审批分支,
// 此处只看已回填的 Tool 消息。取末尾连续 Tool 消息(倒序 take_while role==Tool),
// 失败内容前 40 字符归一 key 计数,同 key 累计达阈值 → guard.reset + AiError + return。
if CIRCUIT_BREAKER_ENABLED {
let (max_count, sample_key) = {
let session = session_arc.lock().await;
// conv 可能已被删除(stop/新对话),get 不到 → 无消息可判,跳过本轮断路器检查。
let messages = match session.conv_read(&conv_id) {
Some(conv) => conv.messages.all_messages_clone(),
None => Vec::new(),
};
// 倒序取末尾连续 role==Tool 消息(本轮工具回填结果;非 Tool 即停)。
// MessageRole 未派生 PartialEq,用 matches! 宏判变体(不改共享类型 df-ai-core)。
let recent_tool_results: Vec<&ChatMessage> = messages
.iter()
.rev()
.take_while(|m| matches!(m.role, MessageRole::Tool))
.collect();
for m in recent_tool_results {
let content = m.content.as_str();
// 失败判定:禁止/跳过重试/失败/Error 关键词(覆盖权限拒绝/路径错误/异常等)。
let is_failure = content.starts_with("禁止")
|| content.starts_with("已跳过重试")
|| content.contains("失败")
|| content.contains("Error")
|| content.contains("error");
if is_failure {
// 前 40 字符归一 key:同类失败(同前缀)累加,不同错误各自计数互不干扰。
let key: String = content.chars().take(40).collect();
*fail_counts.entry(key).or_insert(0) += 1;
}
}
// 取当前最大计数及其 key(无失败 → max_count=0,不触发)。
fail_counts
.iter()
.max_by_key(|(_, &v)| v)
.map(|(k, &v)| (v, k.clone()))
.unwrap_or((0u32, String::new()))
};
// 锁已随作用域 drop,可安全 await/emit(避免持锁 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;
// L1 求助协议(§2.3,2026-06-21):熔断改发结构化 AiHelpRequired(机制优先 prompt 说教)。
// 开关 CIRCUIT_BREAKER_HELP_EVENT=true(默认)→ AiHelpRequired(求助卡,显 reason/options
// 供用户选);false(兜底回退)→ AiError(旧错误气泡)。两路均 guard.reset + return 强制熔断,
// 仅前端呈现形态不同,语义不变(均终止 loop,逼用户介入)。
if CIRCUIT_BREAKER_HELP_EVENT {
let _ = app_handle.emit(
"ai-chat-event",
AiChatEvent::AiHelpRequired {
reason: format!(
"连续同类失败 {} 次,疑似死循环已停止",
max_count
),
context: format!("最近错误: {}", sample_key),
options: vec![
"换思路".into(),
"授权路径".into(),
"人工接管".into(),
],
conversation_id: Some(conv_id.clone()),
},
);
} else {
// 兜底回退:求助卡未就绪/排障/对比时,沿用旧 AiError 错误气泡呈现。
let _ = app_handle.emit(
"ai-chat-event",
AiChatEvent::AiError {
error: format!(
"连续同类失败 {} 次,疑似死循环已停止。请换思路或人工介入。最近错误: {}",
max_count, sample_key
),
error_type: Some(ErrorType::Unknown),
conversation_id: Some(conv_id.clone()),
},
);
}
return;
}
}
// 有待审批 → 暂停循环,等待用户审批后通过 ai_approve → try_continue_agent_loop 恢复
if pending_count > 0 {
let usage = df_ai::provider::TokenUsage {