//! AI 编排节点 — 直连 agentic 会话执行工作流任务 //! AI 编排节点 — 直连 agentic 会话执行工作流任务 //! //! 与 AiNode(单次 LLM 补全)的区别:AiAgentNode 在节点 execute 时以编程方式启动一个 //! 独立 agentic 会话(新 conv_id,与用户活跃会话隔离,防 epoch 互踩),由 run_agentic_loop //! 完整驱动(多轮迭代 + 工具调用),会话收敛后取最终 assistant 报告作为节点输出。 //! //! 阻塞语义与 HumanNode 同构:tokio::select! 三路等待 //! - 会话完成通知(AppState.agent_completion_bridge 的 oneshot,由 agentic loop 收敛 //! 统一出口 emit_ai_completed_once 投递,epoch 守卫保证仅 owner loop 触发) //! - deadline 超时(config.timeout_secs,默认 1800s)→ Err(工作流 failed → 推进链退回) //! - 500ms tick 轮询 is_cancelled //! //! spawn 通道经 [`SpawnFn`] 注入(src-tauri 节点工厂提供,内部取 AppState 起会话), //! 本 crate 不依赖 src-tauri 类型,依赖方向与 AiNode 持 Arc 同款。 use std::future::Future; use std::pin::Pin; use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; use df_storage::crud::TaskRepo; use df_storage::db::Database; use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema}; // 任务文本兜底 prompt(与 AiNode 同款,经 ai_node_helpers 复用) use crate::ai_node_helpers::{default_fallback_prompt, resolve_fallback_prompt}; /// 会话启动结果:(conv_id, 完成通知接收端)。接收端值 = 最终 assistant 报告文本。 pub type SpawnedSession = (String, tokio::sync::oneshot::Receiver); /// 会话启动 future(Err = 启动失败,如 provider 缺失/应用句柄未就绪)。 pub type SpawnFuture = Pin> + Send>>; /// 会话启动通道:入参任务文本,由 src-tauri 节点工厂注入实现(见 chat.rs /// orchestration_session_spawner)。 pub type SpawnFn = Arc SpawnFuture + Send + Sync>; /// 默认编排超时(秒)。1800s = 30min,与 HumanNode 默认审批窗口同量级; /// agentic 会话含多轮工具调用,给宽值,到点 fail 退回任务状态。 const DEFAULT_TIMEOUT_SECS: u64 = 1800; /// AI 编排节点 /// /// 持 Arc 落 task.output_json(有 task_id 时,与 AiNode 同模式), /// 持 SpawnFn 启动独立编排会话。 pub struct AiAgentNode { db: Arc, spawn: SpawnFn, } impl AiAgentNode { /// 构造节点(注册表工厂调用,注入数据库句柄与会话启动通道) pub fn new(db: Arc, spawn: SpawnFn) -> Self { Self { db, spawn } } } #[async_trait] impl Node for AiAgentNode { async fn execute(&self, ctx: NodeContext) -> NodeResult { tracing::info!("AiAgentNode 执行: node_id={}", ctx.node_id); // 任务文本取值优先级与 AiNode 一致:上游 prompt 输出 > config.prompt > // task_id 兜底(读 TaskRecord 生成) > 通用默认。推进链模板 config 无 prompt, // 兜底链保证单节点 DAG 也能拿到任务书。 let task = match ctx .inputs .get("prompt") .and_then(|o| o.data.as_str()) .map(String::from) .or_else(|| { ctx.config .get("prompt") .and_then(|v| v.as_str()) .map(String::from) }) { Some(t) if !t.trim().is_empty() => t, _ => { resolve_fallback_prompt(&self.db, &ctx.config) .await .unwrap_or_else(default_fallback_prompt) } }; let timeout_secs = ctx .config .get("timeout_secs") .and_then(|v| v.as_u64()) .unwrap_or(DEFAULT_TIMEOUT_SECS); let task_id = ctx .config .get("task_id") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); // 启动独立编排会话(内部生成新 conv_id 并注册完成通知桥,注册先于 spawn) let (conv_id, mut done_rx) = (self.spawn)(task) .await .map_err(|e| anyhow::anyhow!("AI 编排会话启动失败: {}", e))?; // select! 骨架与 HumanNode 同构:完成通知 / deadline 超时 / 500ms 取消轮询。 // oneshot 接收端在 loop 里重 poll,须借 &mut(移动进 select! 会破坏循环重入)。 let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs); let mut cancel_tick = tokio::time::interval(Duration::from_millis(500)); cancel_tick.tick().await; // 丢弃首个立即触发的 tick let report = loop { tokio::select! { done = &mut done_rx => match done { Ok(text) => break text, Err(_) => { // 发送端被 drop 且未投递:会话条目被覆盖注册或异常清除,无报告可取 return Err(anyhow::anyhow!( "AI 编排会话完成信号丢失(conv_id={})", conv_id )); } }, _ = tokio::time::sleep_until(deadline) => { // 超时 fail:工作流 failed → 推进链按 regression_target 退回任务状态 return Err(anyhow::anyhow!( "AI 编排会话超时({}s), conv_id={}", timeout_secs, conv_id )); } _ = cancel_tick.tick() => { if ctx.node_status.is_cancelled(&ctx.node_id) { return Err(anyhow::anyhow!("AI 编排会话被取消")); } } } }; // 空报告判失败:stop 在任何输出前 / 会话被清理的场景,不作为有效产出推进任务 if report.trim().is_empty() { return Err(anyhow::anyhow!( "AI 编排会话无输出(会话被停止或未产出报告), conv_id={}", conv_id )); } let output = serde_json::json!({ "text": report, "conv_id": conv_id, "task_id": task_id, }); // 有 task_id 落 task.output_json(与 AiNode 同模式:通用 update_field 白名单字段, // 落库失败不阻断节点返回,产出已在内存工作流可继续)。 if !task_id.is_empty() { if let Ok(json_str) = serde_json::to_string(&output) { let repo = TaskRepo::new(&self.db); if let Err(e) = repo.update_field(&task_id, "output_json", &json_str).await { tracing::warn!( task_id = task_id, error = %e, "AiAgentNode 落 output_json 失败(产出已在内存,节点继续)" ); } } } Ok(NodeOutput::from_value(output)) } fn schema(&self) -> NodeSchema { NodeSchema { params: serde_json::json!({ "type": "object", "properties": { "prompt": { "type": "string", "description": "任务书(缺省依次取上游 prompt 输出 / 按 task_id 读任务生成 / 通用默认)" }, "timeout_secs": { "type": "integer", "description": "编排超时秒数(默认 1800,到点节点 fail 触发工作流退回)" }, "task_id": { "type": "string", "description": "关联任务 ID(run_workflow 注入;有值时产出落 task.output_json)" } }, "required": [] }), output: serde_json::json!({ "type": "object", "properties": { "text": { "type": "string", "description": "最终 assistant 报告文本" }, "conv_id": { "type": "string", "description": "编排会话 ID(独立于用户活跃会话)" }, "task_id": { "type": "string" } } }), } } fn is_blocking(&self) -> bool { true } fn node_type(&self) -> &str { "ai_agent" } } #[cfg(test)] mod tests { use super::*; use df_storage::crud::ProjectRepo; use df_storage::models::{ProjectRecord, TaskRecord}; use df_types::types::{ProjectStatus, TaskStatus}; use df_workflow::eventbus::EventBus; use df_workflow::state::StateMachine; use serde_json::json; use std::collections::HashMap; /// 构造 NodeContext:自定义 config,共享指定状态机(取消轮询测试用)。 fn make_ctx_with(config: serde_json::Value, sm: StateMachine) -> NodeContext { NodeContext { node_id: "node-a".to_string(), inputs: HashMap::new(), config, execution_id: "exec-1".into(), event_bus: EventBus::new(), node_status: sm, } } fn make_ctx(config: serde_json::Value) -> NodeContext { make_ctx_with(config, StateMachine::new()) } /// 内存 DB(无表数据;task_id 为空时节点不触库)。 async fn empty_db() -> Arc { Arc::new(Database::open_in_memory().await.expect("open_in_memory")) } /// 内存 DB helper:插父项目 + 指定 task,返回 (db, task_id)。 async fn setup_task_db() -> (Arc, String) { let db = Database::open_in_memory().await.expect("open_in_memory"); ProjectRepo::new(&db) .insert(ProjectRecord { id: "p1".to_string(), name: "proj".to_string(), description: "".to_string(), status: ProjectStatus::Planning, idea_id: None, path: None, stack: None, archived: false, created_at: "0".to_string(), updated_at: "0".to_string(), }) .await .expect("insert project"); TaskRepo::new(&db) .insert(TaskRecord { id: "t1".to_string(), project_id: "p1".to_string(), title: "实现登录接口".to_string(), description: "含前端表单与后端校验".to_string(), status: TaskStatus::Todo, priority: 2, branch_name: None, assignee: None, workflow_def_id: None, base_branch: None, review_rounds: 0, output_json: None, idea_id: None, module_id: None, queue: "todo".to_string(), parent_id: None, content_json: None, archived: false, created_at: "0".to_string(), updated_at: "0".to_string(), }) .await .expect("insert task"); (Arc::new(db), "t1".to_string()) } /// spawn 桩:启动后 50ms 投递指定报告文本。 fn spawn_delivering(report: &'static str) -> SpawnFn { Arc::new(move |_task: String| { Box::pin(async move { let (tx, rx) = tokio::sync::oneshot::channel::(); tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(50)).await; let _ = tx.send(report.to_string()); }); Ok(("conv-1".to_string(), rx)) }) }) } /// spawn 桩:发送端保活不投递(模拟会话长时间未收敛)。 fn spawn_silent() -> SpawnFn { Arc::new(|_task: String| { Box::pin(async move { let (tx, rx) = tokio::sync::oneshot::channel::(); tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(5)).await; let _ = tx.send("迟到的报告".to_string()); }); Ok(("conv-silent".to_string(), rx)) }) }) } /// 完成通知桥触发:spawn 后收到投递 → Ok 输出 {text, conv_id, task_id}。 #[tokio::test] async fn completion_notify_returns_report() { let node = AiAgentNode::new(empty_db().await, spawn_delivering("任务完成报告")); let ctx = make_ctx(json!({ "task_id": "t1" })); let out = node.execute(ctx).await.unwrap(); assert_eq!(out.data["text"], json!("任务完成报告")); assert_eq!(out.data["conv_id"], json!("conv-1")); assert_eq!(out.data["task_id"], json!("t1")); } /// deadline 超时:发送端保活不投递 + 短超时 → Err 超时(工作流 failed 退回路径)。 #[tokio::test] async fn timeout_fails_node() { let node = AiAgentNode::new(empty_db().await, spawn_silent()); let ctx = make_ctx(json!({ "timeout_secs": 1 })); let err = node.execute(ctx).await.unwrap_err().to_string(); assert!(err.contains("超时"), "应报超时, 实际: {}", err); assert!(err.contains("conv-silent"), "超时信息应带 conv_id, 实际: {}", err); } /// 取消轮询:500ms tick 命中 is_cancelled → Err 取消(状态保持 Cancelled 由 executor 保证)。 #[tokio::test] async fn cancelled_poll_fails_node() { let sm = StateMachine::new(); let sm_external = sm.clone(); let node = AiAgentNode::new(empty_db().await, spawn_silent()); let handle = tokio::spawn(async move { node.execute(make_ctx_with(json!({}), sm)).await }); // 等 execute 进入 select! 轮询后再取消(过早 set 会因 tick 未启动而漏检一轮,仍会被下轮命中) tokio::time::sleep(Duration::from_millis(50)).await; sm_external.set_cancelled("node-a".to_string()); let err = handle.await.unwrap().unwrap_err().to_string(); assert!(err.contains("取消"), "应报取消, 实际: {}", err); } /// 启动失败:spawn 返 Err(provider 缺失等)→ 节点 Err 带原因。 #[tokio::test] async fn spawn_failure_fails_node() { let spawn: SpawnFn = Arc::new(|_task: String| { Box::pin(async { Err("未配置 AI Provider".to_string()) }) }); let node = AiAgentNode::new(empty_db().await, spawn); let err = node.execute(make_ctx(json!({}))).await.unwrap_err().to_string(); assert!(err.contains("启动失败"), "应报启动失败, 实际: {}", err); assert!(err.contains("未配置 AI Provider"), "应携带原因, 实际: {}", err); } /// 信号丢失:发送端被 drop 且未投递 → Err 信号丢失(不误判为成功)。 #[tokio::test] async fn dropped_sender_fails_node() { let spawn: SpawnFn = Arc::new(|_task: String| { Box::pin(async move { let (tx, rx) = tokio::sync::oneshot::channel::(); drop(tx); Ok(("conv-drop".to_string(), rx)) }) }); let node = AiAgentNode::new(empty_db().await, spawn); let err = node.execute(make_ctx(json!({}))).await.unwrap_err().to_string(); assert!(err.contains("信号丢失"), "应报信号丢失, 实际: {}", err); } /// 空报告:投递了纯空白文本 → Err 无输出(不作为有效产出推进任务)。 #[tokio::test] async fn blank_report_fails_node() { let spawn: SpawnFn = Arc::new(|_task: String| { Box::pin(async move { let (tx, rx) = tokio::sync::oneshot::channel::(); let _ = tx.send(" ".to_string()); Ok(("conv-blank".to_string(), rx)) }) }); let node = AiAgentNode::new(empty_db().await, spawn); let err = node.execute(make_ctx(json!({}))).await.unwrap_err().to_string(); assert!(err.contains("无输出"), "应报无输出, 实际: {}", err); } /// 有 task_id 落 task.output_json(与 AiNode 同一条 update_field 白名单路径), /// 且任务书兜底链生效:config 无 prompt 时按 task_id 读任务生成。 #[tokio::test] async fn task_output_json_written_and_fallback_prompt_used() { let (db, task_id) = setup_task_db().await; let node = AiAgentNode::new(db.clone(), spawn_delivering("登录接口已完成")); // config 只有 task_id(推进链模板形态),prompt 走兜底生成 let ctx = make_ctx(json!({ "task_id": task_id })); let out = node.execute(ctx).await.unwrap(); assert_eq!(out.data["text"], json!("登录接口已完成")); let after = TaskRepo::new(&db) .get_by_id("t1") .await .unwrap() .expect("task 存在"); let saved = after.output_json.expect("output_json 应被写入"); let parsed: serde_json::Value = serde_json::from_str(&saved).unwrap(); assert_eq!(parsed["text"], json!("登录接口已完成")); assert_eq!(parsed["task_id"], json!("t1")); assert!(parsed["conv_id"].as_str().is_some(), "conv_id 应落 output_json"); } /// 默认编排超时 1800s(30min),与 HumanNode 默认审批窗口同量级。 #[test] fn default_timeout_is_1800_secs() { assert_eq!(DEFAULT_TIMEOUT_SECS, 1800, "默认编排超时应为 1800s(30min)"); } }