Files
DevFlow/src-tauri/src/commands/ai/mod.rs
绝尘 7c2e3b23c1 新增: AST 代码智能 read_symbol 三态(治 prompt 爆,信息密度驱动)
tree-sitter 语法层符号解析,治 aichat read_file 全文回灌 prompt 爆(e46f5605 360K/8dfe0b94 5M)。
核心思想:信息密度≠压缩,read_symbol 按语义层级返符号骨架/全文,非物理读全文件。
- code_intel.rs: grammar_for 集中 lookup(Rust/TS/JS/Vue借TS,3 grammar 4 类)+ read_symbol 三态(骨架默认/全文)+ 不报错兜底(无grammar/解析失败/未找到→grep提示)
- tool_registry 注册 read_symbol(对齐 read_file handler 闭包模式)
- Cargo.toml 加 tree-sitter 0.25 + rust/typescript/javascript grammar
- AST 设计文档 grammar 策略章节定稿(静态编译+lookup,不动态不trait)
实测: code_intel.rs 全文 21851B vs read_symbol 骨架 897B,降 24.4x(达设计目标一个量级+)。单测 14 全过。
2026-06-24 02:49:48 +08:00

963 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! AI 聊天命令模块 — 流式对话、工具调用、审批门控、提供商管理
//!
//! 由原单文件 ai.rs(2663 行)按职责拆分为 15 个子模块。
//!
//! 模块布局:
//! - [`commands`] — 所有 `#[tauri::command]` IPC 函数
//! - [`agentic`] — run_agentic_loop / try_continue_agent_loop / DEFAULT_MAX_AGENT_ITERATIONS
//! - [`stream_recv`] — stream_llm 流式接收
//! - [`conversation`] — save_conversation / TokenAccumulator / accumulate_tokens
//! - [`title`] — 对话标题生成
//! - [`audit`] — 工具调用审计 + pending 审批恢复 + 工具调用处理
//! - [`skills`] — 本机 Claude 技能扫描
//! - [`prompt`] — 系统提示词构建
//! - [`provider_pool`] — F-260614-04 多 Provider 负载均衡池选择(纯逻辑)
//! - [`compress`] — F-15 上下文压缩 LLM 摘要(compress_via_llm 公共函数)
//! - [`event_bus`] — L3 全局事件数据总线 pub-sub 骨架(broadcast,渐进第一步,未接入 emit)
//! - [`tool_registry`] — AI 工具注册表构建 + 文件路径校验
//! - [`knowledge_inject`] — 知识库注入 + 提炼
//! - [`augmentation`] — 上下文增强
//! - [`http`] — HTTP 调用封装
//! - [`secret`] — 密钥/凭证管理
//!
//! 事件协议:通过 app.emit("ai-chat-event", payload) 流式推送到前端
//! - AiTextDelta: 流式文本片段
//! - AiToolCallStarted/Completed: 工具调用生命周期
//! - AiApprovalRequired: 需要人工审批
//! - AiCompleted/AiError: 完成/错误
pub mod agentic;
pub mod augmentation;
pub mod audit;
pub mod code_intel;
pub mod commands;
pub mod compress;
pub mod conversation;
pub mod event_bus;
pub mod http;
pub mod knowledge_inject;
pub mod prompt;
pub mod provider_pool;
pub mod remote_bridge;
pub mod secret;
pub mod skills;
pub mod stream_recv;
pub mod title;
pub mod tool_registry;
use agentic::conv_state::ConvState;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use serde::{Deserialize, Serialize};
// RiskLevel 不在此 import:PendingApproval.risk_level 删后 mod.rs 不再用(audit 流程在 audit.rs 自 import)
use df_ai::context::ContextManager;
use df_ai::context::ContextConfig;
// F-#95 扩展:AiMessageHistory 事件载荷 Vec<ChatMessage>(历史消息同步)。
use df_ai::provider::ChatMessage;
// ============================================================
// 重导出 — 保路径不变(state.rs / lib.rs / knowledge.rs 在用)
// ============================================================
// commands 子模块(glob 重导出) — `#[tauri::command]` 宏生成的命令函数 +
// 内部符号(__cmd__xxx / __tauri_command_name_xxx)同源同模块定义,
// glob 把它们全部拉到 commands::ai 路径,使 generate_handler! 能解析到。
// 静默 unused_imports:glob 重导出用于跨模块路径解析,本文件不引用这些符号。
#[allow(unused_imports)]
pub use self::commands::*;
// 子模块的非命令 pub 项,逐个保路径
#[allow(unused_imports)]
pub use self::audit::restore_pending_approvals;
#[allow(unused_imports)]
pub use self::knowledge_inject::{retry_failed_embeddings, spawn_embedding_for_knowledge, trigger_extraction_now};
#[allow(unused_imports)]
pub use self::tool_registry::build_ai_tool_registry;
// Input Augmentation 层(核心设计2):ResolverRegistry 构建 helper(state.rs init 调用)
#[allow(unused_imports)]
pub use self::augmentation::build_resolver_registry;
// ============================================================
// 事件载荷类型(放 mod.rs,各子文件经 use super::* 拿到)
// ============================================================
/// AI 错误分类(B-260615-42):供前端按错误源差异化提示(如 auth 引导填 key,
/// network 提示检查连接,provider_config 引导检查 base_url/model)。
///
/// 设计:`Option<ErrorType>` 向后兼容——旧 emit 点若难精确分类填 `None`,
/// 旧前端消费方忽略未填值;新点尽量精确填并在注释标注映射理由。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ErrorType {
/// 鉴权失败:API key 缺失/无效/钥匙串损坏(resolve_provider_secret / ensure_resolved_key 失败)
Auth,
/// 网络错误:连接失败/DNS 解析失败/流中途断(SSE 传输断)
Network,
/// 超时:连接超时/idle timeout(120s 无 chunk)
Timeout,
/// Provider 配置错误:base_url/model/provider_type 错或无可用 provider(配置丢失/全删)
ProviderConfig,
/// 未分类(达最大轮次/provider 流式错误事件等难精确归类的旧点)
Unknown,
}
/// AI 聊天事件(推送到前端)
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")]
pub enum AiChatEvent {
/// 流式文本片段
AiTextDelta { delta: String, conversation_id: Option<String> },
/// 工具调用开始
AiToolCallStarted { id: String, name: String, args: serde_json::Value, conversation_id: Option<String> },
/// 工具调用完成
AiToolCallCompleted { id: String, result: serde_json::Value, conversation_id: Option<String> },
/// 会话级信任自动放行AE-2025-04 Session Trust
/// 同会话已批准过同类操作(同工具+同目录),下次命中自动放行,前端显示轻量 toast。
/// 与 AiToolCallStarted/Completed 正交——这三个事件描述一次信任放行调用生命周期:
/// Started → AutoApprovedtoast 提示用户已自动放行)→ Completed。
AiToolAutoApproved { id: String, tool: String, dir: String, conversation_id: Option<String> },
/// 需要人工审批
/// diffAE-2025-03路径 B仅 write_file 注入(旧文件 vs 新内容行级 unified diff
/// 供审批卡即时预览。旧文件不存在(新建)或非 write_file 工具为 None。
AiApprovalRequired { id: String, name: String, args: serde_json::Value, reason: String, diff: Option<String>, conversation_id: Option<String> },
/// 审批结果
AiApprovalResult { id: String, approved: bool, conversation_id: Option<String> },
/// AI 响应完成
///
/// `incomplete`(可选,默认 None=false):UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1——
/// 流中途失败(MidStream)保文路径置 Some(true),标记此回复不完整。前端可据此差异化
/// 展示(如附系统提示「⚠ 响应因网络中断不完整」)。正常完成/用户停止均 None(完整)。
AiCompleted {
total_tokens: u32,
prompt_tokens: u32,
completion_tokens: u32,
/// 不完整标记(可选):Some(true)=网络中断保文,None 或 Some(false)=完整回复
incomplete: Option<bool>,
conversation_id: Option<String>,
},
/// 错误
///
/// `error_type` 为错误源分类(B-260615-42),`None` 表示未分类(向后兼容旧 emit 点)。
AiError {
error: String,
/// 错误源分类(可选,便于前端差异化提示);旧点/难精确分类填 None
error_type: Option<ErrorType>,
conversation_id: Option<String>,
},
/// F-260622-02 跨端用户消息同步(miniapp→device→桌面 user 气泡渲染):
///
/// 触发:remote_bridge `route_send_message` 放行后(调 ai_chat_send 前)`publish_event`,
/// 把 miniapp 发来的用户消息广播到 ai_event_bus。语义:远程入口(miniapp)发来的用户消息,
/// 专供桌面 useAiEvents 收到并补 user 气泡(桌面本地 sendMessage 是乐观渲染不发此事件)。
///
/// 根因(为何需要此事件):ai_chat_send 内部只 push session.messages,全程不 emit 用户消息,
/// 桌面用户气泡是纯本地乐观渲染(useAiSend.sendMessage 本地 push,不依赖事件)。
/// miniapp 发消息时桌面用户没在自己输入框敲字 → 桌面 store 没有这条 user 气泡 →
/// 桌面只看到 assistant 响应气泡(经 AiAgentRound/AiTextDelta 流式建),前面缺对应 user prompt
/// → **孤立响应气泡**。此事件填补该缺口。
///
/// 去重(防双气泡):前端 handleEvent 收到此事件时,若末条已是同 content 的 user(本地乐观
/// push 的),跳过。miniapp 自身乐观 push 也会收到此事件回灌,同样去重处理。
AiUserMessage {
message: String,
conversation_id: Option<String>,
},
/// Agent 循环新一轮(前端需新建 assistant 消息)
AiAgentRound { round: u32, conversation_id: Option<String> },
/// 流式心跳(静默期报活,前端 watchdog reset区分「LLM 在跑」与「真断」)
AiHeartbeat { conversation_id: Option<String> },
/// 达最大轮次仍未收敛F-260616-03转暂停态询问用户继续/停止)
///
/// 触发时 generating 仍为 true仿审批等待前端展示独立操作卡
/// - 点继续 → ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮
/// - 点停止 → ai_stop_loop → 走完成流程save + AiCompleted + generating=false
AiMaxRoundsReached { conversation_id: Option<String> },
/// 流式调用自动重试提示F-260616-07流前失败重试中
///
/// 每次重试前 emit前端可在错误气泡内显示「重试 n/m」。
/// attempt 从 1 开始(首次失败后第一次重试=attempt 1)max_attempts = max_retries + 1。
AiStreamRetry { attempt: u32, max_attempts: u32, conversation_id: Option<String> },
/// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,挂起 loop 等用户决定)。
///
/// 触发:resolve_workspace_path 预校验路径未命中 persistent/session 白名单(且非黑名单)。
/// 复用审批挂起架构(PendingApproval.kind=Path 标记路径授权挂起,阶段3a 单真相源合并),
/// 用户经 ai_authorize_dir IPC 选择"仅本次/未来都允许/拒绝",恢复 loop。
/// `dir`:规范化后的待授权目录(父目录,目录粒度对齐 session_trust)。
AiDirAuthRequired {
id: String,
tool: String,
path: String,
dir: String,
conversation_id: Option<String>,
},
/// F-15 阶段2 手动上下文分段完成:会话归档不删(archived_segment 标记),
/// 前端可据此刷新消息视图(归档段折叠/隐藏)。
AiContextCleared { conversation_id: Option<String> },
/// F-15 阶段2 手动 LLM 压缩开始:前端展示压缩中态(spinner/禁用按钮防重入)。
AiCompressing { conversation_id: Option<String> },
/// F-15 阶段2 手动 LLM 压缩完成:摘要已插入消息首位,前端可展示摘要 + 移除压缩中态。
AiCompressed { conversation_id: Option<String>, summary: String },
/// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
///
/// agent 断路器(§2.2)熔断 / 自省(识别"我反复失败")时发,区别于 prompt 教 AI
/// 「失败就问用户」——LLM 不可靠,故用结构化事件 + 前端求助卡(机制优先 prompt 说教)。
///
/// 字段语义:
/// - `reason`:求助原因(死循环/环境未知/权限不足等可读摘要,前端直接展示)
/// - `context`:已试情况(失败次数/最近错误 sample/卡在哪),供用户判断如何介入
/// - `options`:建议选项(如 [换思路/授权路径/人工接管]),前端渲染为按钮供用户选
/// - `conversation_id`:多对话路由(与其他事件一致)
///
/// 与 AiApprovalRequired 区分:审批=工具执行前确认;求助=AI 主动「我搞不定」。
/// 与 AiMaxRoundsReached 区分:达 max 是被动截断;求助是主动识别卡点。
/// 兜底:开关 CIRCUIT_BREAKER_ENABLED=false 或求助消费方未接 → 后端保留 AiError 终态分支兜底。
AiHelpRequired {
reason: String,
context: String,
options: Vec<String>,
conversation_id: Option<String>,
},
/// F-#95 跨端会话列表同步(Phase3,2026-06-22):device 响应 miniapp `list_conversations`
/// 命令,把本地 AiConversationRepo 列表摘要(不含消息体,省带宽)推回 miniapp。
///
/// 触发:remote_bridge `list_conversations` 路由 → `list_all()` → 映射 ConvSummary →
/// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。
///
/// 双职责:
/// 1. miniapp 会话列表渲染(conversations/index.vue 替换本地空表)
/// 2. device 在线探测(miniapp 收到此响应 = device 在线,chat/index.vue 状态文案切「已连接桌面端」)
///
/// 兼容:relay 纯透传不广播 device presence,故「device 是否在线」靠此响应隐式判定
/// (device 离线则 miniapp 发 list_conversations 无响应,文案保持「已连接中继」诚实表述)。
AiConversationList {
/// 会话摘要列表(已按 updated_at 倒序,不含 messages 全文)
conversations: Vec<ConvSummary>,
},
/// F-#95 扩展:miniapp 历史消息同步(device→miniapp 推消息列表)。
///
/// 触发:remote_bridge `load_messages` 路由 → `ai_messages.list_by_conversation` →
/// 映射 `ChatMessage`(record_to_message,对齐 ai_conversation_switch 读路径) →
/// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。
///
/// 职责:miniapp 点进对话时加载历史消息(替代 switch_conversation 后端忽略致空白)。
/// miniapp 据 conversation_id 匹配 activeConversationId 才替换,防串会话。
AiMessageHistory {
/// 对话 ID
conversation_id: String,
/// 历史消息数组(ORDER BY seq ASC,含 role/content/tool_calls 等完整字段)
messages: Vec<ChatMessage>,
},
/// L2 统一状态机(批2 1b,2026-06-22):对话生命周期状态变更通知。
///
/// 后端 `ConvState`(Idle/Generating/Stopping/Error/Compressed)经写收敛
/// (入口/guard reset/drop 迁移)后 emit 此事件,前端 status union 据此更新展示态
/// (替代散布的 generating bool 派生判断,收敛状态机读侧到事件流)。
///
/// 渐进/兜底:`CONV_STATE_ENABLED` 开关门控 —— off 时不 emit(前端按旧 bool 行为派生,
/// 向后兼容);on 时此事件与既有 generating 行为双轨,前端可按需切换消费源。
/// `conv_state` 序列化为 snake_case(idle/generating/stopping/error/compressed),
/// 供前端 union discriminator 直接使用。
AiConvStateChanged {
conv_state: ConvState,
conversation_id: Option<String>,
},
/// F-#95 跨端技能列表同步(Phase3,2026-06-23):device 响应 miniapp `list_skills` 命令,
/// 把本机 Claude 技能(skills/commands/plugins 三类,`ai_list_skills` 进程内缓存)推回 miniapp。
///
/// 触发:remote_bridge `list_skills` 路由 → `ai_list_skills().await` →
/// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。
///
/// 职责:miniapp 技能联想(输入框 `/` 触发浮层),对齐桌面端 `/` 联想体验。
/// 内嵌 `SkillInfo` 自身序列化字段(name/description/argument_hint?/source/path/duplicates?),
/// 变体整体无额外 rename_all 需求(照 AiConversationList 变体模式)。
AiSkillList {
/// 本机技能列表(ai_list_skills 返回的进程内缓存,已按 skills>commands>plugins 优先级去重)
skills: Vec<crate::commands::ai::skills::SkillInfo>,
},
/// F-#95 跨端实体列表同步(Phase3,2026-06-23):device 响应 miniapp `list_entities` 命令,
/// 把本地项目/任务/灵感全量列表(list_projects/list_tasks/list_ideas 全量等价路径)推回 miniapp。
///
/// 触发:remote_bridge `list_entities` 路由 → 并行调三 Tauri command(query/project_id/status
/// 均传 None 走 list_active/list_all 全量) → 合并打包 → `publish_event` 跨端透传。
///
/// 职责:miniapp `@` 实体联想(输入框 `@` 触发浮层选项目/任务/灵感),对齐桌面端 @ 体验。
/// 全量字段(对齐桌面端 src/api/types.ts,带宽敏感裁剪留后续批;Record 类型是
/// #[tauri::command] 返回值,必然 impl Serialize)。
AiEntityList {
/// 项目全量列表(list_active,deleted_at IS NULL)
projects: Vec<df_storage::models::ProjectRecord>,
/// 任务全量列表(list_active 等价,未删任务)
tasks: Vec<df_storage::models::TaskRecord>,
/// 灵感全量列表(list_by_query 空 query,等价 list_all)
ideas: Vec<df_storage::models::IdeaRecord>,
},
}
// ============================================================
// F-#95 跨端会话列表同步:会话摘要结构
// ============================================================
/// 会话摘要(miniapp 会话列表渲染用,对齐 apps/df-miniapp/src/types/events.ts:78 Conversation)。
///
/// 与 `AiConversationRecord` 区别:仅含列表展示必需字段,**不含 messages 全文**(省带宽,
/// miniapp MVP 不在列表展示历史消息,进入会话时按需同步)。`updated_at` 用 i64(ms 数值)
/// 便于前端直接比较排序;record 里是 ms 字符串,routing 层 parse 转。
///
/// `#[serde(rename_all = "camelCase")]` 对齐 miniapp Conversation 字段名
/// (id/title/lastMessage/updatedAt/createdAt),TS 侧无需 snake→camel 转换。
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConvSummary {
pub id: String,
pub title: String,
/// 最近一条消息摘要(MVP 暂不取,留 None;后续批可解析 messages 末条)
pub last_message: Option<String>,
/// 最近更新时间(ms 数值)
pub updated_at: Option<i64>,
/// 创建时间(ms 数值,可选展示)
pub created_at: Option<i64>,
}
// ============================================================
// 会话状态(放 mod.rs,各子文件经 use super::* 拿到)
// ============================================================
/// AI 会话读侧状态视图(只读枚举,由 [`AiSession::session_state`] 推导)
///
/// 这是 **只读视图**,不替代 `AiSession` 上 `generating` / `pending_approvals` /
/// `stop_flag` 等字段定义——字段仍是唯一真相源,调用方写状态时继续写字段,
/// 读状态时统一走 `session_state()` 收敛判断逻辑(避免散布的 `if generating` /
/// `if !pending_approvals.is_empty()` 三路组合在各调用点各自实现)。
///
/// 优先级:`pending_approvals` 非空(有挂起审批优先报 AwaitingApproval
/// 即使同时在流式)> `generating`Streaming> 都不满足Idle
// SW-260618-21 b:原预留读视图(0 消费者)。path_auth 审批链阶段1 接入后有消费者
// (try_continue_agent_loop / ai_chat_stop 经 session_state 替代手写 has_pending),不再 dead_code。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionState {
/// 空闲:既未生成、也无挂起审批
Idle,
/// 流式生成中generating 为 true 且无挂起审批
Streaming,
/// 等待审批pending_approvals 非空
AwaitingApproval,
}
// ============================================================
// AE-2025-04 会话级信任Session Trust
//
// 核心:信任上下文相关。同会话批准过同类操作(同工具+同目录)后自动放行;
// 换会话new/switch清空重审session_trust 随会话销毁,不落库)。
//
// TrustKey 按 操作 + 目录 粒度非文件级——文件级粒度过细LLM 每写一个文件都要重审,
// 信任面太窄失去意义;目录级让"批准过同目录写入"覆盖该目录后续写入,对齐"写→跑→看→改"
// 闭环最高频场景。首批write_file + run_command。
//
// FR-S1 安全边界TrustKey 只含 tool + dir**绝不**含 api_key / 文件内容 / 命令串等敏感
// 参数。dir 经 resolve_workspace_path/canonicalize 规范化(去 .. / symlink 逃逸)。
// ============================================================
/// 会话级信任键:按「操作类型 + 目录」粒度唯一标识一类已批准操作。
///
/// - `Write { dir }`write_file 在 `dir` 目录下(路径父目录归一化)
/// - `Execute { dir }`run_command 在 `dir` 工作目录下
///
/// 命中即自动放行,跳过 pending 审批 + 二次确认AE-05不写 audit 仍记一条
/// `decided_by=auto_trust` 的审计记录留痕。
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub enum TrustKey {
Write { dir: String },
Execute { dir: String },
}
/// 由「工具名 + args」推算 TrustKey若该工具不在首批信任名单内返 None走原审批流程
///
/// 目录归一化策略(与 tool_registry.rs 各 handler 的路径锚定一致):
/// - write_file取 args.pathresolve_workspace_path → 父目录;解析失败回退 workspace_root
/// - run_command取 args.working_dir缺省回退 workspace_root不做越界校验
/// run_command handler 自身仅 validate_path 不限定 workspace信任目录用 LLM 传入或
/// workspace_root 默认值作为 key 字面量,与 handler 实际 working_dir 对齐)
///
/// FR-S1不读 api_key / content / command 等敏感字段,只取 path/working_dir 归一化为 dir。
pub fn trust_key_for(tool: &str, args: &serde_json::Value) -> Option<TrustKey> {
match tool {
"write_file" => {
let path = args.get("path").and_then(|v| v.as_str())?;
let dir = dir_of_path_normalized(path);
Some(TrustKey::Write { dir })
}
"run_command" => {
let dir = match args.get("working_dir").and_then(|v| v.as_str()) {
Some(d) => normalize_dir_key(d),
None => workspace_root_str(),
};
Some(TrustKey::Execute { dir })
}
_ => None,
}
}
/// write_file 路径 → 父目录规范化字符串(作 HashSet key 用)。
///
/// resolve_workspace_path 失败(路径越界/含 .. )回退 workspace_root —— 失败的工具调用本身
/// 不会被执行handler 内校验拦截),但 trust_key 计算不应 panic失败回退保守默认。
fn dir_of_path_normalized(path: &str) -> String {
match self::tool_registry::resolve_workspace_path_pub(path) {
Ok(resolved) => {
let parent = resolved
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
normalize_dir_key(&parent.to_string_lossy())
}
Err(_) => workspace_root_str(),
}
}
/// run_command / write_file 共用的目录 key 规范化:
/// 尝试 canonicalize去 symlink / .. / 大小写归一),失败回退原字面量 trim。
fn normalize_dir_key(dir: &str) -> String {
let p = std::path::Path::new(dir);
match p.canonicalize() {
Ok(canon) => canon.to_string_lossy().to_string(),
Err(_) => dir.trim_end_matches(['/', '\\']).to_string(),
}
}
/// workspace_root 字符串(作 run_command working_dir 缺省时的 trust key 回退)。
///
/// 与 tool_registry.rs workspace_root() 同源CARGO_MANIFEST_DIR 上两级canonicalize 后
/// 保证与 run_command handler 默认 working_dir = workspace_root().to_string() 生成的 key 一致。
fn workspace_root_str() -> String {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
root.canonicalize()
.map(|c| c.to_string_lossy().to_string())
.unwrap_or_else(|_| root.to_string_lossy().to_string())
}
// 引入 PathBuf 供 workspace_root_str / dir_of_path_normalized 使用
use std::path::PathBuf;
/// AI 会话内状态Mutex 保护)
///
/// F-260616-09 B 批4(决策 e 真并发上线):会话级状态全部迁入 [`per_conv`](Self::per_conv)
/// HashMap(按 conversation_id 切分),顶层单例字段(messages/generating/stop_flag/notify/
/// iteration_used/agent_language/model_override/session_trust)已退役删除。批2 双写桥接代码
/// (IPC 写路径的顶层双写行 + 各文件 fallback 读)在批4 全部清理完毕,per_conv 是唯一真相源。
pub struct AiSession {
/// 当前提供商 ID
pub active_provider_id: Option<String>,
/// 当前活跃对话 ID(应用级路由键:当前展示的 conv;切走即变,不影响后台 loop)
pub active_conversation_id: Option<String>,
/// 活跃对话创建时间(懒创建:首条消息落库前仅存内存,upsert 时用作 created_at)
pub active_conv_created_at: Option<String>,
/// 挂起的审批(阶段3a 单真相源合并:原 path_auth_pending + risk_pending 双 HashMap)。
///
/// 决策1(状态层合 + 决策层分):状态层把两类挂起合并进单 HashMap,
/// `PendingApproval.kind` 字段区分语义(`ApprovalKind::Path` vs `ApprovalKind::Risk`);
/// 决策层仍双轨 —— `ai_authorize_dir` 只消费 `kind==Path`(once/always 写持久白名单),
/// `ai_approve` 只消费 `kind==Risk`(一次性 approve/reject),入口 kind 校验防误调。
/// 键为 `tool_call_id`(路由键不变),`conversation_id` 是业务语义(哪个对话产生)。
///
/// 兜底/回退:本合并是结构性的,回退走 git revert(plan 风险表「分 3a/3b 各 revert」)。
/// 兼容靠 kind 字段默认 Risk(老构造点不传 kind 即默认 Risk,语义不变)。
pub pending_approvals: HashMap<String, PendingApproval>,
/// F-260616-09 B 阶段 多会话并发架构 — 会话级状态按 conversation_id 切分。
///
/// **批4 状态(per_conv 唯一真相源)**:批2-批4 已迁移所有调用方(agentic/mod.rs loop +
/// GeneratingGuard + try_continue + commands.rs IPC 写路径 + audit.rs process_tool_calls +
/// conversation.rs save + title.rs + knowledge_inject.rs + lib.rs L0)读写
/// `session.conv(conv_id).*` / `session.conv_read(conv_id)`。顶层单例会话级字段已全部删除。
pub per_conv: HashMap<String, PerConvState>,
}
impl AiSession {
pub fn new() -> Self {
Self {
active_provider_id: None,
active_conversation_id: None,
active_conv_created_at: None,
pending_approvals: HashMap::new(),
per_conv: HashMap::new(),
}
}
/// 推导指定会话读侧状态视图(只读,不写任何字段)。
///
/// 优先级:该 conv 有 pending 审批 → [`SessionState::AwaitingApproval`]
/// 否则 per_conv.generating → [`SessionState::Streaming`];否则 → [`SessionState::Idle`]。
///
/// 收敛点:调用方读「某会话处于什么阶段」时统一走本方法,替代散布的
/// `if pending / if generating` 三路组合。
///
/// F-260616-09 B 批4:per_conv 化后接 conv_id 参数(顶层 generating 已删除);
/// pending 判断改为按 conv_id 过滤(决策 e 真并发下各 conv 独立)。
///
/// path_auth 审批链阶段1(本批)接入:`try_continue_agent_loop`(agentic/mod.rs) +
/// `ai_chat_stop`(commands/chat.rs) 改调本方法替代各自手写的 has_pending,
/// 收敛两处状态机判定到单一入口。
///
/// 兜底/回退:若本方法判定异常需快速回退手写 has_pending,调用方注释已保留原
/// 三路组合(generating + path_auth + risk)字面量,改一行即可切回。
pub fn session_state(&self, conv_id: &str) -> SessionState {
// 阶段3a 单真相源合并后:两类挂起(path_auth + risk)合一进 pending_approvals,
// kind 字段区分语义但状态机只看「有无挂起」,单表 any 一次即可(消除原双表 OR 合并判断)。
let has_pending = self.pending_approvals.values()
.any(|a| a.conversation_id.as_deref() == Some(conv_id));
if has_pending {
SessionState::AwaitingApproval
} else if self.conv_read(conv_id).map(|c| c.generating).unwrap_or(false) {
SessionState::Streaming
} else {
SessionState::Idle
}
}
// ============================================================
// F-260616-09 B 阶段批1 PerConvState 访问器(设计 §2.1.2)
//
// 批4 已迁移完成:所有 `session.messages` / `session.generating` 等读写已全部改走
// `session.conv(conv_id).*`(写)/ `session.conv_read(conv_id)`(读),
// per_conv 现为唯一真相源,顶层单例字段已删除。
// ============================================================
/// 取或惰性创建指定会话的可变状态(设计 §2.1.2)。
///
/// 不存在则 `entry().or_insert_with(PerConvState::new)` 创建一份默认状态,
/// 初值对齐 [`AiSession::new`] / [`PerConvState::new`]。
/// 已存在则返回同一实例引用(同 conv_id 复用)。
pub fn conv(&mut self, conv_id: &str) -> &mut PerConvState {
self.per_conv
.entry(conv_id.to_string())
.or_insert_with(PerConvState::new)
}
/// 只读取指定会话状态(不创建)。
///
/// 未创建的 conv_id 返 `None`,已创建返 `Some(&PerConvState)`。用于读取路径(如
/// 列举/查询)避免误触发惰性创建。
pub fn conv_read(&self, conv_id: &str) -> Option<&PerConvState> {
self.per_conv.get(conv_id)
}
}
#[cfg(test)]
mod tests_f09_per_conv {
use super::*;
/// 验证 PerConvState::new() 各字段初值正确(对齐 AiSession::new 基线)。
#[test]
fn test_per_conv_state_new() {
let s = PerConvState::new();
assert!(!s.generating, "generating 初值应为 false");
assert_eq!(s.iteration_used, 0, "iteration_used 初值应为 0");
assert!(s.agent_language.is_none(), "agent_language 初值应为 None");
assert!(s.model_override.is_none(), "model_override 初值应为 None");
assert!(s.session_trust.is_empty(), "session_trust 初值应为空 HashSet");
assert!(s.created_at.is_none(), "created_at 初值应为 None");
// P1 修复(提炼重复触发):knowledge_extracted 初值应为 false(新会话未提炼)
assert!(
!s.knowledge_extracted,
"knowledge_extracted 初值应为 false(新会话未提炼)"
);
// stop_flag 初值 false(与 AiSession::new 对齐)
assert!(
!s.stop_flag.load(std::sync::atomic::Ordering::SeqCst),
"stop_flag 初值应为 false"
);
// notify 是 Arc<Notify>,无可见状态,仅验证不 panic(构造即视为对齐)
// messages 是 ContextManager,内部状态;仅验证能取到引用(初值对齐由 ContextManager::new 保证)
let _messages_ref: &ContextManager = &s.messages;
}
/// 验证 conv() 首次创建 + 二次复用同一实例。
#[test]
fn test_conv_lazy_create() {
let mut session = AiSession::new();
assert!(session.per_conv.is_empty(), "新建 session 的 per_conv 应为空");
// 首次 conv() 创建
let ptr_first = {
let s = session.conv("conv-a");
assert!(!s.generating);
assert_eq!(s.iteration_used, 0);
s as *const PerConvState
};
assert_eq!(session.per_conv.len(), 1, "首次 conv() 后 per_conv 应有 1 项");
// 二次 conv(同 conv_id) 返回同一实例(指针相等)
let ptr_second = session.conv("conv-a") as *const PerConvState;
assert_eq!(
ptr_first, ptr_second,
"同 conv_id 二次 conv() 应返回同一实例"
);
assert_eq!(session.per_conv.len(), 1, "二次 conv(同 id) 不应新增项");
// 不同 conv_id 创建独立实例
let _s_b = session.conv("conv-b");
assert_eq!(session.per_conv.len(), 2, "不同 conv_id 应新建独立实例");
}
/// 验证 conv_read() 未创建的 conv_id 返 None(只读,不惰性创建)。
#[test]
fn test_conv_read_none() {
let session = AiSession::new();
// 未创建的 conv_id
assert!(
session.conv_read("never-created").is_none(),
"未创建的 conv_id conv_read() 应返 None"
);
// conv_read 不触发创建(per_conv 仍空)
assert!(
session.per_conv.is_empty(),
"conv_read 不应触发惰性创建"
);
// 已创建的 conv_id 返 Some(只读访问)
let mut session = session;
let _ = session.conv("exists");
assert!(
session.conv_read("exists").is_some(),
"已创建的 conv_id conv_read() 应返 Some"
);
}
// ============================================================
// P1 修复(提炼重复触发): knowledge_extracted 去重标志生命周期测试
//
// 锁定标志三态语义:
// 1) 新会话初值 false → 自动提炼可触发
// 2) 成功提炼后置 true → maybe_spawn_extraction 入口判重跳过
// 3) trigger_extraction_now 清位 → 允许手动重提炼
// ============================================================
/// 新会话 knowledge_extracted 默认 false(自动提炼可触发)
#[test]
fn test_knowledge_extracted_default_false() {
let session = AiSession::new();
// 未创建的 conv_id → conv_read None → maybe_spawn_extraction 视为 false(可触发)
assert!(
session
.conv_read("new-conv")
.map(|c| c.knowledge_extracted)
.unwrap_or(false)
== false,
"新会话 knowledge_extracted 应为 false"
);
}
/// 成功提炼后置位 true(自动路径去重标志)
#[test]
fn test_knowledge_extracted_set_after_extraction() {
let mut session = AiSession::new();
// 模拟提炼成功后置位(extract_knowledge_from_conversation Ok(>0) → maybe_spawn_extraction 置位)
session.conv("conv-done").knowledge_extracted = true;
assert!(
session.conv_read("conv-done").unwrap().knowledge_extracted,
"提炼成功后 knowledge_extracted 应为 true"
);
}
/// maybe_spawn_extraction 入口判重语义:已置位 → 视为已提炼(跳过)
#[test]
fn test_knowledge_extracted_dedup_guard() {
let mut session = AiSession::new();
session.conv("conv-dedup").knowledge_extracted = true;
// 模拟 maybe_spawn_extraction 入口判重读(already_extracted)
let already_extracted = session
.conv_read("conv-dedup")
.map(|c| c.knowledge_extracted)
.unwrap_or(false);
assert!(
already_extracted,
"已置位会话入口判重应为 true(跳过自动提炼)"
);
}
/// trigger_extraction_now 清位语义:手动路径强制清位,允许重提炼
#[test]
fn test_knowledge_extracted_manual_clear() {
let mut session = AiSession::new();
session.active_conversation_id = Some("conv-manual".to_string());
session.conv("conv-manual").knowledge_extracted = true;
// 模拟 trigger_extraction_now 入口清位逻辑:
// active_conversation_id clone(脱离 immutable borrow)后再 mutable borrow conv()
let cid = session.active_conversation_id.clone();
if let Some(cid) = cid.as_deref() {
session.conv(cid).knowledge_extracted = false;
}
assert!(
!session
.conv_read("conv-manual")
.unwrap()
.knowledge_extracted,
"手动触发后 knowledge_extracted 应被清位(允许重提炼)"
);
}
/// 不同会话标志互相独立(P1 去重按 conv_id 切分)
#[test]
fn test_knowledge_extracted_per_conv_independent() {
let mut session = AiSession::new();
session.conv("conv-a").knowledge_extracted = true;
// conv-b 未提炼,标志应独立为 false
assert!(
session.conv("conv-a").knowledge_extracted,
"conv-a 已提炼标志 true"
);
assert!(
!session.conv("conv-b").knowledge_extracted,
"conv-b 未提炼标志 false(各 conv 独立)"
);
}
}
// ============================================================
// F-260616-09 B 阶段 多会话并发架构 — PerConvState(批1 纯新增)
//
// 设计文档: docs/02-架构设计/已编号方案/F-09-多会话并发架构设计-2026-06-19.md §2.1.2
//
// 批1 范围(纯新增无行为变化):
// - 仅新增 PerConvState struct + new() + AiSession.per_conv 字段 + conv()/conv_read() 访问器
// - **不迁移**现有单例字段(messages/generating/stop_flag/notify/iteration_used/
// agent_language/model_override/session_trust 保留原样),不迁移现有调用方(批2 承接)
// - per_conv HashMap 初始空,旧调用路径继续读写 AiSession 顶层单例字段,完全无感
//
// 共存期(已结束):批1 后顶层单例字段(原唯一真相源)与 per_conv(全空,无写入)曾并存,行为不变。
// 批2-批4 迁移:把所有调用方从 session.messages / session.generating 等改为 session.conv(conv_id).*,
// 迁移完成后顶层单例字段已废弃删除(批3)。现 per_conv 为唯一真相源。
// ============================================================
/// 单会话级状态(F-260616-09 B 阶段批1 纯新增,设计 §2.1.2)。
///
/// 持有当前会话(按 conversation_id 切分)私有的可变状态。批4 已迁移完成,本结构现为
/// **唯一真相源**:所有调用方(agentic loop / IPC / audit / conversation / title /
/// knowledge_inject 等约 60+ 处)均经 `session.conv(conv_id).*` / `session.conv_read(conv_id)`
/// 读写,AiSession 顶层单例字段已全部删除。
///
/// 字段初值逐字对齐 [`AiSession::new`](#method.new)。
pub struct PerConvState {
/// 对话历史(ContextManager:会话级消息真相源,裁剪仅影响发送视图)
pub messages: ContextManager,
/// 是否正在生成
pub generating: bool,
/// L2 统一状态机:对话生命周期状态(单一真相源,批2 持久化供读侧/前端消费)。
/// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。
/// 与 generating bool 双轨过渡(bool 核心复位,enum 视图),批2+ 读侧迁移后 enum 主。
pub conv_state: ConvState,
/// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出
pub stop_flag: Arc<AtomicBool>,
/// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select!
pub notify: Arc<tokio::sync::Notify>,
/// agentic loop 累计已跑 iteration 计数(语义同 AiSession.iteration_used)
pub iteration_used: usize,
/// 当前 agent 循环的语言设置(用于审批后恢复循环)
pub agent_language: Option<String>,
/// 用户指定模型 override(主对话专用,语义同 AiSession.model_override)
pub model_override: Option<String>,
/// 会话级信任(Session Trust):随会话销毁,不落库
pub session_trust: HashSet<TrustKey>,
/// 会话创建时间(懒创建:首条消息落库前仅存内存,预留 upsert 时用作 created_at)。
///
/// 预留字段:构造期写入但当前 upsert 路径未读取(cargo 报 never read),标 allow 保留;
/// 待 upsert 接入会话创建时间回填时消费。符合零调用方≠垃圾(预留)。
#[allow(dead_code)]
pub created_at: Option<String>,
/// 知识提炼去重标志(P1 修复-提炼重复触发):本会话已成功提炼(至少 insert 1 条 candidate)后置位。
///
/// 背景:审批续跑 try_continue → 重 spawn run_agentic_loop → 正常完成块 →
/// maybe_spawn_extraction 二次触发,无去重致同知识点重复 candidate 刷屏。
///
/// 守卫语义:
/// - maybe_spawn_extraction 入口判重:已置位则跳过 + warn(防重复触发刷 candidate)
/// - extract_knowledge_from_conversation 成功(inserted ≥ 1)后由调用方置位
/// - trigger_extraction_now(手动按钮)强制清位:允许用户手动重提炼(语义=手动覆盖自动去重)
///
/// 随会话销毁(per_conv 内存,不落库):会话切换/删除后该标志消失,新会话从 false 开始。
pub knowledge_extracted: bool,
}
impl PerConvState {
/// 新建默认会话级状态。
///
/// 各字段初值**逐字对齐 [`AiSession::new`]** —— 批2 迁移调用方时行为不变。
/// - messages: ContextManager::new(ContextConfig::default())
/// - generating: false
/// - stop_flag: Arc::new(AtomicBool::new(false))
/// - notify: Arc::new(tokio::sync::Notify::new())
/// - iteration_used: 0
/// - agent_language: None
/// - model_override: None
/// - session_trust: HashSet::new()
/// - created_at: None(批1 新增字段,AiSession 现有 active_conv_created_at 同语义)
/// - knowledge_extracted: false(新会话未提炼,P1 去重标志)
pub fn new() -> Self {
Self {
messages: ContextManager::new(ContextConfig::default()),
generating: false,
conv_state: ConvState::Idle,
stop_flag: Arc::new(AtomicBool::new(false)),
notify: Arc::new(tokio::sync::Notify::new()),
iteration_used: 0,
agent_language: None,
model_override: None,
session_trust: HashSet::new(),
created_at: None,
knowledge_extracted: false,
}
}
}
impl Default for PerConvState {
fn default() -> Self {
Self::new()
}
}
/// 待审批的工具调用
///
/// 阶段3a(单真相源合并):新增 `kind: ApprovalKind` 字段区分两类挂起(Path/Risk),
/// 原顶层 `diff` / `path_auth` 字段下沉进 `ApprovalKind` enum 变体(决策1:状态层合 +
/// 决策层分 —— kind 在 PendingApproval 内携带语义,但 ai_approve/ai_authorize_dir
/// 各只消费自己 kind,入口校验防误调)。
#[derive(Debug, Clone)]
pub struct PendingApproval {
pub tool_call_id: String,
pub tool_name: String,
pub arguments: serde_json::Value,
pub conversation_id: Option<String>,
/// 重启恢复的积压审批:无 live loop 持有 session.messages,审批后不 save(防空 messages 污染老对话)、不续跑
pub recovered: bool,
/// 阶段3a:审批类型(Path 路径授权挂起 / Risk 普通 RiskLevel 审批)。
/// 老构造点不传 kind 时默认 Risk(兼容:原 risk_pending 路径行为不变)。
pub kind: ApprovalKind,
/// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):同 tc_id 重试计数。
///
/// 触发:同 tool_call_id 在审计表已有一条 executed/failed/rejected 落定记录(即该
/// tc_id 已被执行/审批过一次),LLM 又用同 id 重试(process_tool_calls 再次 insert pending)。
/// 插入时查审计表落定记录推算 retry_count;≥1 时跳过执行,emit AiToolCallCompleted 带
/// "已跳过重试"提示,防 LLM 死循环重试同卡死工具(超时/权限错等)。
///
/// 兜底/回退:flag 关 → 不查审计表、retry_count 恒 0,等价原行为(单次审批执行)。
//
// dead_code 说明:当前断路决策在 insert 点(detect_retry_count 查审计表),不读存储的
// retry_count;字段保留作审计/未来"允许多次重试阈值"扩展(u32 暂仅 0/1),对齐 Risk{diff}
// 变体同款 allow 处理。
#[allow(dead_code)]
pub retry_count: u32,
}
/// 阶段3a:审批类型枚举(决策层分离,kind 区分 ai_approve vs ai_authorize_dir 消费)。
///
/// - `Path(req)`:F-260619-03 Phase B 路径授权挂起,由 `ai_authorize_dir` 消费(once/always/deny)。
/// - `Risk { diff }`:普通 Med/High RiskLevel 审批,由 `ai_approve` 消费(一次性 approve/reject)。
/// `diff`:AE-2025-03(路径 B)write_file 行级 unified diff,供前端审批卡预览;
/// 旧文件不存在(新建)或非 write_file / recovered 审批为 None。
#[derive(Debug, Clone)]
pub enum ApprovalKind {
/// F-260619-03 Phase B: 路径授权挂起(携带待授权目录列表)。
Path(PathAuthRequest),
/// 普通 RiskLevel 审批(Med/High 风险工具)。
/// diff 仅 write_file 挂起审批前预读注入;其余工具 / recovered 审批为 None。
#[allow(dead_code)] // IPC 活跃(useAiEvents:252+ToolCard·UX-260618-06)·cargo Rust never-read 误报
Risk { diff: Option<String> },
}
/// F-260619-03 Phase B: 路径授权挂起请求(ApprovalKind::Path 标记)。
#[derive(Debug, Clone)]
pub struct PathAuthRequest {
/// 待授权目录列表(规范化父目录,对齐 session_trust 目录粒度)。
/// L1 补丁(rename_file 双路径漏校):收集所有未授权父目录,rename_file 的 from+to
/// 若分别属于不同未授权目录,都需挂起授权(原仅收首个致 to 路径漏校)。单路径工具通常 1 项。
pub dirs: Vec<std::path::PathBuf>,
/// 原始路径参数(read_file.path / write_file.path / rename_file.from+to / ...)。
pub raw_paths: Vec<String>,
}
/// 工具调用草稿(流式收集时的临时结构)
#[derive(Debug, Clone, Default)]
pub(crate) struct ToolCallDraft {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) args: String,
}
// ============================================================
// B-260617-01 决策 a(方案 a2)run_workflow 工具经审批后的真实执行入口
//
// 背景run_workflow handler(tool_registry.rs)仅持 db:Arc<Database>,无 AppHandle/State
// (registry/event_bus/workflows Repo),经 ai_tools.execute 必然 Err。ai_approve 识别 run_workflow
// 后改调本函数(同 invoke('run_workflow') IPC 入口 workflow.rs::run_workflow,持有完整 State),
// 真正触发工作流引擎执行 + 联动任务推进,把 execution_id 回填 tool_result 让 LLM 收成功结果停止重试。
//
// 选后端分支(a2)而非前端拦截(a1):ai_approve 已持 state+app,后端统一更简,无需新增前端 IPC +
// 不把工作流知识泄漏到前端。本函数仅在 ai_approve 内被调用(单一调用方)。
// ============================================================
/// 把 run_workflow 工具调用的 AI args 转调工作流执行函数,返回 execution_id(供回填 tool_result)。
///
/// 从 args 取 task_id + target_status(两者必填),传空 DagDef(由 workflow.rs 按 target_status
/// 自动选推进链模板,见 task_workflow_templates::template_for),config 传空 Object。
/// 任一参数缺失返 Err(回填 failed tool_result,LLM 据此修参重发)。
///
/// 不在此推进任务状态 —— workflow.rs::run_workflow 内部已按 task_id+target_status 自动联动
/// (成功推进到 target / 失败按 regression_target 退回),此处不重复。
pub(crate) async fn execute_run_workflow_for_tool(
app: &tauri::AppHandle,
state: &crate::state::AppState,
args: &serde_json::Value,
) -> anyhow::Result<serde_json::Value> {
let task_id = args
.get("task_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("缺少 task_id 参数"))?
.to_string();
let target_status = args
.get("target_status")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("缺少 target_status 参数"))?
.to_string();
// 空 DagDef + target_status:workflow.rs:84-92 自动按 target_status 选推进链模板
// (AiNode 自审 / HumanNode 核对闸门),无需本函数感知模板知识。
let dag = df_workflow::dag_def::DagDef::new();
let config = serde_json::Value::Object(serde_json::Map::new());
let name = format!("AI 推进任务 {}{}", task_id, target_status);
// 转调工作流执行核心(B-260617-01:run_workflow_inner 是 run_workflow 命令的共用核心,
// 同 invoke('run_workflow') IPC 入口的执行逻辑,持 &AppState 非 tauri::State,绕开
// tauri::State 在非命令上下文不可构造的限制)。
// CR-52: 本路径是 AI 工具经 ai_approve 审批后执行 → triggered_by="ai"(区别命令层 manual)。
// 返回 execution_id 字符串;失败(模板不存在/DAG 校验失败/DB 写入失败)上抛 Err。
let execution_id = crate::commands::workflow::run_workflow_inner(
app,
state,
name,
dag,
config,
Some(task_id.clone()),
Some(target_status.clone()),
"ai",
)
.await
.map_err(|e| anyhow::anyhow!("工作流执行失败: {}", e))?;
// 回填 tool_result:返回 execution_id + 联动语义说明,让 LLM 知工作流已触发(后台异步执行,
// 完成后自动推进任务状态),不再重试同 tool_call。
Ok(serde_json::json!({
"task_id": task_id,
"target_status": target_status,
"execution_id": execution_id,
"status": "running",
"note": "工作流已触发(后台异步执行,完成后自动推进任务状态,失败按退回态回滚)。前端 workflow-event 跟踪进度。"
}))
}