新增: 任务工作流 AiAgentNode 直连会话编排
- ai 节点 mode agent 开关派发,默认旧路径零回归 - 完成通知桥注册触发,收敛出口单点投递报告 - 节点超时取消兜底,结果落任务产出 - 会话编排走独立新会话,不劫持活跃面板
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
//! 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<Database> 同款。
|
||||
|
||||
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<String>);
|
||||
|
||||
/// 会话启动 future(Err = 启动失败,如 provider 缺失/应用句柄未就绪)。
|
||||
pub type SpawnFuture = Pin<Box<dyn Future<Output = Result<SpawnedSession, String>> + Send>>;
|
||||
|
||||
/// 会话启动通道:入参任务文本,由 src-tauri 节点工厂注入实现(见 chat.rs
|
||||
/// orchestration_session_spawner)。
|
||||
pub type SpawnFn = Arc<dyn Fn(String) -> SpawnFuture + Send + Sync>;
|
||||
|
||||
/// 默认编排超时(秒)。1800s = 30min,与 HumanNode 默认审批窗口同量级;
|
||||
/// agentic 会话含多轮工具调用,给宽值,到点 fail 退回任务状态。
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 1800;
|
||||
|
||||
/// AI 编排节点
|
||||
///
|
||||
/// 持 Arc<Database> 落 task.output_json(有 task_id 时,与 AiNode 同模式),
|
||||
/// 持 SpawnFn 启动独立编排会话。
|
||||
pub struct AiAgentNode {
|
||||
db: Arc<Database>,
|
||||
spawn: SpawnFn,
|
||||
}
|
||||
|
||||
impl AiAgentNode {
|
||||
/// 构造节点(注册表工厂调用,注入数据库句柄与会话启动通道)
|
||||
pub fn new(db: Arc<Database>, 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<Database> {
|
||||
Arc::new(Database::open_in_memory().await.expect("open_in_memory"))
|
||||
}
|
||||
|
||||
/// 内存 DB helper:插父项目 + 指定 task,返回 (db, task_id)。
|
||||
async fn setup_task_db() -> (Arc<Database>, 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,
|
||||
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,
|
||||
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::<String>();
|
||||
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::<String>();
|
||||
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::<String>();
|
||||
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::<String>();
|
||||
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)");
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,8 @@ pub(crate) async fn resolve_and_parse(
|
||||
///
|
||||
/// 无 task_id / 查询失败 / task 不存在 → None,调用方降级到 `default_fallback_prompt`。
|
||||
/// description 空只取 title;非空拼「标题(描述)」。truncate 防 token 滥用。
|
||||
async fn resolve_fallback_prompt(db: &Arc<Database>, config: &serde_json::Value) -> Option<String> {
|
||||
/// pub(crate):AiAgentNode 复用同款任务书兜底(与 AiNode 取值语义一致)。
|
||||
pub(crate) async fn resolve_fallback_prompt(db: &Arc<Database>, config: &serde_json::Value) -> Option<String> {
|
||||
let task_id = config.get("task_id").and_then(|v| v.as_str())?;
|
||||
if task_id.is_empty() {
|
||||
return None;
|
||||
@@ -130,7 +131,8 @@ async fn resolve_fallback_prompt(db: &Arc<Database>, config: &serde_json::Value)
|
||||
}
|
||||
|
||||
/// P0-B 通用兜底 prompt:无 task_id 时用(独立 AiNode 调用 / config 无 task 关联)。
|
||||
fn default_fallback_prompt() -> String {
|
||||
/// pub(crate):AiAgentNode 复用同款通用兜底。
|
||||
pub(crate) fn default_fallback_prompt() -> String {
|
||||
"请根据上下文完成当前任务,产出可执行结果并自检。若需更多信息请说明。".to_string()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! df-nodes: 内置节点集合 — AI、脚本、人工审批、Git、Docker、HTTP、通知
|
||||
|
||||
pub mod ai_agent_node;
|
||||
pub mod ai_node;
|
||||
pub mod ai_self_review_node;
|
||||
mod ai_node_helpers;
|
||||
|
||||
@@ -37,14 +37,17 @@ pub fn template_for(target_status: &str) -> Option<DagDef> {
|
||||
/// todo → in_progress:单 AiNode 执行任务。
|
||||
///
|
||||
/// 拓扑:1 个 ai 节点。完成回调(②-3)推进 status 到 in_progress。
|
||||
///
|
||||
/// mode:"agent" → ai 节点工厂构造 AiAgentNode(直连 agentic 会话编排执行,多轮工具
|
||||
/// 调用后取最终报告);缺省/"ask" 走旧 AiNode(单次补全,旧调用方零回归)。
|
||||
/// 其余运行时参数(prompt/provider/task_id)仍由 run_workflow 全局 config 注入
|
||||
/// (④-1 deep_merge 合并),模板只描述拓扑 + 执行模式。
|
||||
fn in_progress_template() -> DagDef {
|
||||
let mut dag = DagDef::new();
|
||||
// config 留空:prompt / provider / task_id 均由 run_workflow 全局 config 注入
|
||||
// (④-1 deep_merge 合并),模板只描述拓扑。
|
||||
dag.add_node(
|
||||
"ai_execute",
|
||||
"ai",
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({ "mode": "agent" }),
|
||||
);
|
||||
dag
|
||||
}
|
||||
@@ -120,10 +123,24 @@ mod tests {
|
||||
assert!(dag.edges.is_empty(), "in_progress: 无边");
|
||||
let node = dag.nodes.get("ai_execute").expect("ai_execute 节点存在");
|
||||
assert_eq!(node.node_type, "ai");
|
||||
// config 留空(运行时注入)
|
||||
// mode:"agent" → 工作流编排执行(AiAgentNode 直连 agentic 会话);
|
||||
// 其余运行时参数仍留空(由 run_workflow 全局 config 注入)。
|
||||
assert_eq!(
|
||||
node.config.get("mode").and_then(|v| v.as_str()),
|
||||
Some("agent"),
|
||||
"ai_execute 应以 mode=agent 走编排节点"
|
||||
);
|
||||
let non_mode_keys: Vec<&String> = node
|
||||
.config
|
||||
.as_object()
|
||||
.expect("config 应为对象")
|
||||
.keys()
|
||||
.filter(|k| *k != "mode")
|
||||
.collect();
|
||||
assert!(
|
||||
node.config.as_object().map(|o| o.is_empty()).unwrap_or(true),
|
||||
"ai_execute config 应为空对象"
|
||||
non_mode_keys.is_empty(),
|
||||
"ai_execute 除 mode 外 config 应留空(运行时注入),实际: {:?}",
|
||||
non_mode_keys
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,8 @@ pub(crate) fn init_app_handle(app: tauri::AppHandle) {
|
||||
}
|
||||
|
||||
/// handler 读取 AppHandle(未初始化返 None,handler 报明确错误)。
|
||||
fn app_handle() -> Option<tauri::AppHandle> {
|
||||
/// pub(crate):编排会话启动通道(chat.rs orchestration_session_spawner)同款复用。
|
||||
pub(crate) fn app_handle() -> Option<tauri::AppHandle> {
|
||||
GLOBAL_APP.get().cloned()
|
||||
}
|
||||
|
||||
|
||||
+74
-2
@@ -201,6 +201,14 @@ pub struct AppState {
|
||||
/// guard/reset/ai_is_generating 全部零锁操作。PerConvState.conv_state 字段已删。
|
||||
/// 治卡死连环(AiCompleted 延迟 / 工具后中断 / 第二条进队列同源根因)。
|
||||
pub conv_states: Arc<crate::commands::ai::agentic::conv_state::ConvStateStore>,
|
||||
/// 编排会话完成通知桥(conv_id → 完成信号 oneshot 发送端,值=最终 assistant 报告文本)。
|
||||
///
|
||||
/// 工作流 AiAgentNode 经编排会话启动通道(chat.rs orchestration_session_spawner)
|
||||
/// 在 spawn 前注册等待;agentic loop 收敛统一出口(agentic/mod.rs emit_ai_completed_once,
|
||||
/// epoch 守卫已由调用方保证,仅 owner loop 到达)投递并移除条目。
|
||||
/// std Mutex:临界区为纯内存 insert/remove,无 await,不持锁跨等待点。
|
||||
pub agent_completion_bridge:
|
||||
Arc<std::sync::Mutex<HashMap<String, tokio::sync::oneshot::Sender<String>>>>,
|
||||
// ── 知识库 ──
|
||||
/// 知识库 Repo
|
||||
pub knowledge: KnowledgeRepo,
|
||||
@@ -396,6 +404,8 @@ impl AppState {
|
||||
// B-Phase1: ConvStateStore 无锁存储(空构造;双轨期无写点,B-Phase2 写侧切入)。
|
||||
// 复用上方提前构建的 Arc(spawn_agent ctx 注入同一实例)。
|
||||
conv_states,
|
||||
// 编排会话完成通知桥(空表起步,AiAgentNode 会话启动时注册,loop 收敛出口投递)。
|
||||
agent_completion_bridge: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
// Phase3 Layer1:tunnel 客户端未连接实例,lib.rs setup 内 connect
|
||||
tunnel: std::sync::Arc::new(df_tunnel::WsTunnelClient::new()),
|
||||
knowledge: KnowledgeRepo::new(&db),
|
||||
@@ -685,6 +695,46 @@ impl AppState {
|
||||
pub async fn clear_once_allowed_dirs(&self) {
|
||||
self.allowed_dirs.write().await.once.clear();
|
||||
}
|
||||
|
||||
/// 注册编排会话完成等待(工作流 AiAgentNode 会话 spawn 前调用)。
|
||||
///
|
||||
/// 同 conv 重复注册覆盖旧条目(旧 oneshot 发送端随值 drop,等待方收 Err 判定信号丢失)。
|
||||
/// 锁中毒降级 into_inner 取数据继续(桥内只有内存条目,无一致性风险)。
|
||||
pub fn register_agent_completion(&self, conv_id: String, tx: tokio::sync::oneshot::Sender<String>) {
|
||||
let mut map = self
|
||||
.agent_completion_bridge
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner());
|
||||
map.insert(conv_id, tx);
|
||||
}
|
||||
|
||||
/// 是否存在挂起的编排会话等待方(收敛出口短路判据,普通用户会话零后续开销)。
|
||||
pub fn has_agent_completion_waiter(&self, conv_id: &str) -> bool {
|
||||
let map = self
|
||||
.agent_completion_bridge
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner());
|
||||
map.contains_key(conv_id)
|
||||
}
|
||||
|
||||
/// 投递编排会话完成通知并移除条目(无等待方 no-op)。
|
||||
///
|
||||
/// 条目无论投递成败都移除:接收端已 drop(节点超时/取消)时 send 失败静默,不残留。
|
||||
/// 返回是否找到等待方(供调用方日志)。
|
||||
pub fn notify_agent_completion(&self, conv_id: &str, report: String) -> bool {
|
||||
let taken = self
|
||||
.agent_completion_bridge
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
.remove(conv_id);
|
||||
match taken {
|
||||
Some(tx) => {
|
||||
let _ = tx.send(report);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建节点注册表 — 注册内置节点
|
||||
@@ -706,9 +756,31 @@ fn build_registry(db: Arc<Database>) -> NodeRegistry {
|
||||
// AiNode(df_nodes::ai_node, impl Node trait):
|
||||
// 决策 a(AiNode 自审闭环)步骤②:AiNode 持 Arc<Database>,execute 完成后若有 task_id
|
||||
// 则把产出落 task.output_json。工厂闭包 move 捕获 db 句柄(Arc clone 廉价)。
|
||||
// mode 开关(工作流 AiAgentNode 直连 agentic 会话):config.mode == "agent" → 构造
|
||||
// AiAgentNode(编排会话),缺省/"ask" 走旧 AiNode(单次补全,旧调用方零回归)。
|
||||
// 工厂期 AppHandle 经进程级 OnceLock 读取(spawn_agent 同款,lib.rs setup 已注入,
|
||||
// 工厂调用点在 run_workflow → build_dag,时序晚于 setup,句柄必已就绪)。
|
||||
let ai_db = db.clone();
|
||||
registry.register("ai", move |_config| {
|
||||
Box::new(df_nodes::ai_node::AiNode::new(ai_db.clone()))
|
||||
registry.register("ai", move |config| {
|
||||
if config.get("mode").and_then(|v| v.as_str()) == Some("agent") {
|
||||
Box::new(df_nodes::ai_agent_node::AiAgentNode::new(
|
||||
ai_db.clone(),
|
||||
crate::commands::ai::commands::chat::orchestration_session_spawner(),
|
||||
))
|
||||
} else {
|
||||
Box::new(df_nodes::ai_node::AiNode::new(ai_db.clone()))
|
||||
}
|
||||
});
|
||||
// AiAgentNode(df_nodes::ai_agent_node, impl Node trait):
|
||||
// 独立注册类型:节点 execute 时以编程方式起独立 agentic 会话跑任务,收敛后取最终
|
||||
// assistant 报告回写。spawn 通道经 orchestration_session_spawner 注入(内部经
|
||||
// AppHandle OnceLock 取 AppState),db 句柄供 task_id 落 output_json(ai_db 同款)。
|
||||
let agent_db = db.clone();
|
||||
registry.register("ai_agent", move |_config| {
|
||||
Box::new(df_nodes::ai_agent_node::AiAgentNode::new(
|
||||
agent_db.clone(),
|
||||
crate::commands::ai::commands::chat::orchestration_session_spawner(),
|
||||
))
|
||||
});
|
||||
// AiSelfReviewNode(df_nodes::ai_self_review_node, impl Node trait):
|
||||
// 决策 a 步骤③:四维度自审(prompt 固定+JSON 解析兜底),写回 output_json 加 review 子字段,
|
||||
|
||||
Reference in New Issue
Block a user