Phase G3: ACP Gateway + DAG Multi-Agent - acp_gateway.py (Agent communication layer, 350L), workflow_admin.py upgraded (async parallel execution + agent_node), Workflow.tsx updated (agent node + real-time streaming + WebSocket)

This commit is contained in:
qyyfw committed 2026-07-06 15:48:37 +08:00
1 parent 9b3e15ace6
commit d2e3147fb8
3 files changed
+1073 -88

No files matched your search

+483 -79
View File
@@ -2,13 +2,14 @@
Agent 可视化编排 API(管理员)
Phase 3.6: Agent Visual Orchestration - DAG Workflow Editor
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Body
from fastapi import APIRouter, Depends, HTTPException, Query, Body, WebSocket, WebSocketDisconnect
from sqlalchemy.orm import Session
from sqlalchemy import or_, func
from pydantic import BaseModel
from typing import Optional, List, Any
import json
import uuid
import asyncio
from datetime import datetime
from ...database import get_db, SessionLocal
@@ -25,7 +26,7 @@ router = APIRouter()
class WorkflowNode(BaseModel):
id: str
type: str # start / llm / knowledge / skill / condition / http / code / end
type: str # start / llm / knowledge / skill / condition / http / code / agent / end
position: dict # {x, y}
data: dict # {label, config: {...}}
@@ -147,6 +148,21 @@ NODE_TEMPLATES = {
"code": {"type": "textarea", "label": "Python 代码", "default": "result = input\noutput = result", "help": "可用变量: input, prev_output, context. 必须设置 output 变量"}
}
},
"agent": {
"type": "agent",
"label": "Agent 节点",
"icon": "bot",
"color": "#7C3AED",
"description": "调用平台 AI Agent — 支持并行多 Agent 协作",
"config_schema": {
"agent_id": {"type": "select", "label": "选择 Agent", "options_from": "agents"},
"prompt": {"type": "textarea", "label": "提示词模板", "default": "{input}", "help": "可用变量: {input}, {prev_output}, {context} 中的变量"},
"system_prompt": {"type": "textarea", "label": "系统提示词 (覆盖Agent默认)", "default": "", "help": "留空则使用Agent默认系统提示词"},
"temperature": {"type": "number", "label": "温度 (覆盖默认)", "default": 0.7, "min": 0, "max": 2},
"max_tokens": {"type": "number", "label": "最大Token", "default": 4096},
"output_variable": {"type": "string", "label": "输出变量名", "default": "", "help": "将Agent输出存入context.{变量名},供后续节点使用"}
}
},
"end": {
"type": "end",
"label": "结束",
@@ -216,13 +232,17 @@ async def get_node_templates(
options["models"] = [{"value": m.id, "label": f"{getattr(m, 'display_name', None) or m.name}"} for m in models]
# Knowledge bases
kbs = db.query(KnowledgeBase).all()
kbs = db.query(KnowledgeBase).filter(KnowledgeBase.status == "active").all()
options["knowledge_bases"] = [{"value": kb.id, "label": kb.name} for kb in kbs]
# Skills
skills = db.query(EvolutionSkill).filter(EvolutionSkill.state == "active").all()
options["skills"] = [{"value": s.id, "label": s.skill_name} for s in skills]
# Agents (for multi-agent orchestration - Phase G3)
agents = db.query(MarketApp).filter(MarketApp.type == "agent", MarketApp.status == "published").all()
options["agents"] = [{"value": a.id, "label": a.name} for a in agents]
return {
"code": 0,
"data": {
@@ -496,7 +516,7 @@ async def execute_workflow(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""执行工作流 — 按 DAG 拓扑顺序处理节点"""
"""执行工作流 — 按 DAG 拓扑顺序处理节点,同层节点并行执行 (Phase G3)"""
rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": workflow_id})
if not rows:
raise HTTPException(status_code=404, detail="工作流不存在")
@@ -508,28 +528,55 @@ async def execute_workflow(
if not nodes:
raise HTTPException(status_code=400, detail="工作流没有节点")
# Build adjacency map
# Build structures
node_map = {n["id"]: n for n in nodes}
edge_map = {} # source_id -> [{target, handle}]
edge_map = {}
reverse_edge_map = {} # target -> [sources]
for e in edges:
src = e["source"]
tgt = e["target"]
if src not in edge_map:
edge_map[src] = []
edge_map[src].append({
"target": e["target"],
"sourceHandle": e.get("sourceHandle")
})
edge_map[src].append({"target": tgt, "sourceHandle": e.get("sourceHandle")})
if tgt not in reverse_edge_map:
reverse_edge_map[tgt] = []
reverse_edge_map[tgt].append(src)
# Find start node
# Find start nodes
start_node = None
for n in nodes:
if n["type"] == "start":
start_node = n
start_node = n["id"]
break
if not start_node:
start_node = nodes[0] # fallback to first node
if not start_node and nodes:
start_node = nodes[0]["id"]
# Execute
# Topological sort to assign levels
in_degree = {n["id"]: len(reverse_edge_map.get(n["id"], [])) for n in nodes}
levels = {}
queue = [start_node]
levels[start_node] = 0
while queue:
nid = queue.pop(0)
level = levels[nid]
for edge in edge_map.get(nid, []):
tgt = edge["target"]
if tgt in node_map:
new_level = level + 1
if tgt not in levels or new_level > levels[tgt]:
levels[tgt] = new_level
in_degree[tgt] -= 1
if in_degree[tgt] <= 0:
queue.append(tgt)
# Group nodes by level
max_level = max(levels.values()) if levels else 0
level_groups = {i: [] for i in range(max_level + 1)}
for nid, lvl in levels.items():
level_groups[lvl].append(nid)
# Context
context = {
"input": req.input_text,
"variables": req.variables or {},
@@ -538,74 +585,78 @@ async def execute_workflow(
"log": [],
}
visited = set()
queue = [start_node["id"]]
final_output = ""
abort = False
while queue:
node_id = queue.pop(0)
if node_id in visited:
continue
visited.add(node_id)
node = node_map.get(node_id)
if not node:
continue
node_type = node["type"]
node_config = node.get("data", {}).get("config", {})
node_label = node.get("data", {}).get("label", node_type)
try:
result = _execute_node(node_type, node_config, context, db)
context["results"][node_id] = result
context["prev_output"] = result.get("output", "")
context["log"].append({
"node_id": node_id,
"node_label": node_label,
"node_type": node_type,
"success": True,
"output_preview": str(result.get("output", ""))[:200],
})
if node_type == "end":
final_output = result.get("output", "")
break
# Determine next nodes
next_edges = edge_map.get(node_id, [])
if node_type == "condition":
# Evaluate condition
cond_expr = node_config.get("condition_expr", "True")
try:
cond_result = eval(cond_expr, {"__builtins__": {}}, {
"input": context["input"],
"prev_output": context["prev_output"],
"len": len,
"str": str,
})
except Exception:
cond_result = True
for edge in next_edges:
handle = edge["sourceHandle"]
if (cond_result and handle == "true") or (not cond_result and handle == "false") or handle is None:
queue.append(edge["target"])
else:
for edge in next_edges:
queue.append(edge["target"])
except Exception as e:
context["log"].append({
"node_id": node_id,
"node_label": node_label,
"node_type": node_type,
"success": False,
"error": str(e),
})
# Stop execution on error
# Execute level by level
for lvl in range(max_level + 1):
if abort:
break
level_nodes = level_groups[lvl]
if not level_nodes:
continue
# Execute nodes in this level
tasks = []
for nid in level_nodes:
node = node_map.get(nid)
if not node:
continue
task = _execute_node_async(node, context, db, edge_map, node_map, workflow_id)
tasks.append(task)
# Parallel execution for same-level nodes
results = await asyncio.gather(*tasks, return_exceptions=True)
for res in results:
if isinstance(res, Exception):
context["log"].append({
"node_id": "unknown",
"node_label": "unknown",
"node_type": "unknown",
"success": False,
"error": str(res),
})
abort = True
continue
if res is None:
continue
nid = res["node_id"]
context["results"][nid] = res["result"]
if res["success"]:
context["prev_output"] = res["result"].get("output", "")
context["log"].append({
"node_id": nid,
"node_label": res["node_label"],
"node_type": res["node_type"],
"success": True,
"output_preview": str(res["result"].get("output", ""))[:200],
})
if res["node_type"] == "end":
final_output = res["result"].get("output", "")
abort = True
break
# Handle condition node branching — update context for next level
if res["node_type"] == "condition" and "next_nodes" in res:
# The condition routing is applied by _execute_node_async
pass
else:
context["log"].append({
"node_id": nid,
"node_label": res["node_label"],
"node_type": res["node_type"],
"success": False,
"error": res.get("error", "未知错误"),
})
abort = True
break
if not final_output:
final_output = context["prev_output"]
@@ -619,6 +670,195 @@ async def execute_workflow(
}
@router.websocket("/{workflow_id}/execute-stream")
async def execute_workflow_stream(
websocket: WebSocket,
workflow_id: str,
):
"""WebSocket 端点 — 实时流式推送工作流执行进度 (Phase G3)"""
await websocket.accept()
try:
# Receive execution params
data = await websocket.receive_json()
input_text = data.get("input_text", "")
# Create DB session
db = SessionLocal()
try:
rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": workflow_id})
if not rows:
await websocket.send_json({"type": "workflow_error", "data": {"output": "工作流不存在"}})
await websocket.close()
return
wf = workflow_to_dict(rows[0])
nodes = wf["nodes"]
edges = wf["edges"]
if not nodes:
await websocket.send_json({"type": "workflow_error", "data": {"output": "工作流没有节点"}})
await websocket.close()
return
# Build structures
node_map = {n["id"]: n for n in nodes}
edge_map = {}
reverse_edge_map = {}
for e in edges:
src = e["source"]
tgt = e["target"]
if src not in edge_map:
edge_map[src] = []
edge_map[src].append({"target": tgt, "sourceHandle": e.get("sourceHandle")})
if tgt not in reverse_edge_map:
reverse_edge_map[tgt] = []
reverse_edge_map[tgt].append(src)
# Find start
start_node = None
for n in nodes:
if n["type"] == "start":
start_node = n["id"]
break
if not start_node and nodes:
start_node = nodes[0]["id"]
# Topo sort
in_degree = {n["id"]: len(reverse_edge_map.get(n["id"], [])) for n in nodes}
levels = {start_node: 0}
queue = [start_node]
while queue:
nid = queue.pop(0)
lvl = levels[nid]
for edge in edge_map.get(nid, []):
tgt = edge["target"]
if tgt in node_map:
nl = lvl + 1
if tgt not in levels or nl > levels[tgt]:
levels[tgt] = nl
in_degree[tgt] -= 1
if in_degree[tgt] <= 0:
queue.append(tgt)
max_level = max(levels.values()) if levels else 0
level_groups = {i: [] for i in range(max_level + 1)}
for nid, lvl in levels.items():
level_groups[lvl].append(nid)
context = {
"input": input_text,
"variables": {},
"prev_output": "",
"results": {},
"log": [],
}
final_output = ""
abort = False
for lvl in range(max_level + 1):
if abort:
break
level_nodes = level_groups[lvl]
if not level_nodes:
continue
tasks_list = []
for nid in level_nodes:
node = node_map.get(nid)
if not node:
continue
# Send node_start
await websocket.send_json({
"type": "node_start",
"node_id": nid,
"node_type": node["type"],
"node_label": node.get("data", {}).get("label", node["type"]),
})
task = _execute_node_async(node, context, db, edge_map, node_map, workflow_id)
tasks_list.append(task)
results = await asyncio.gather(*tasks_list, return_exceptions=True)
for res in results:
if isinstance(res, Exception):
await websocket.send_json({
"type": "node_error",
"node_id": "unknown",
"node_label": "unknown",
"node_type": "unknown",
"error": str(res),
})
abort = True
continue
if res is None:
continue
nid = res["node_id"]
context["results"][nid] = res["result"]
if res["success"]:
context["prev_output"] = res["result"].get("output", "")
await websocket.send_json({
"type": "node_complete",
"node_id": nid,
"node_label": res["node_label"],
"node_type": res["node_type"],
"output_preview": str(res["result"].get("output", ""))[:200],
})
if res["node_type"] == "end":
final_output = res["result"].get("output", "")
abort = True
break
else:
await websocket.send_json({
"type": "node_error",
"node_id": nid,
"node_label": res["node_label"],
"node_type": res["node_type"],
"error": res.get("error", "未知错误"),
})
abort = True
break
if not final_output:
final_output = context["prev_output"]
await websocket.send_json({
"type": "workflow_complete",
"data": {
"output": final_output,
"log": context["log"],
}
})
finally:
db.close()
except WebSocketDisconnect:
pass
except Exception as e:
try:
await websocket.send_json({
"type": "workflow_error",
"data": {"output": f"执行失败: {str(e)}"}
})
except Exception:
pass
finally:
try:
await websocket.close()
except Exception:
pass
def _execute_node(node_type: str, config: dict, context: dict, db: Session) -> dict:
"""Execute a single node and return result"""
@@ -643,6 +883,9 @@ def _execute_node(node_type: str, config: dict, context: dict, db: Session) -> d
elif node_type == "code":
return _execute_code_node(config, context)
elif node_type == "agent":
return _execute_agent_node(config, context, db)
elif node_type == "end":
var_name = config.get("output_variable", "output")
return {"output": context.get(var_name, context.get("prev_output", ""))}
@@ -650,6 +893,114 @@ def _execute_node(node_type: str, config: dict, context: dict, db: Session) -> d
return {"output": ""}
async def _execute_node_async(node: dict, context: dict, db: Session, edge_map: dict, node_map: dict, workflow_id: str) -> Optional[dict]:
"""Execute a single node async, returning structured result for parallel orchestration"""
nid = node["id"]
node_type = node["type"]
node_config = node.get("data", {}).get("config", {})
node_label = node.get("data", {}).get("label", node_type)
try:
# Publish node start event
from app.api.admin.acp_gateway import publish_workflow_event
publish_workflow_event(workflow_id, nid, {
"event": "node_start",
"node_type": node_type,
"node_label": node_label,
})
if node_type == "agent":
# Async agent invocation — may take time
result = await _execute_agent_node_async(node_config, context, db, workflow_id, nid)
# Store output in context variable if specified
output_var = node_config.get("output_variable", "")
if output_var and result.get("output"):
context[output_var] = result["output"]
publish_workflow_event(workflow_id, nid, {
"event": "node_complete",
"node_type": node_type,
"node_label": node_label,
"output_preview": str(result.get("output", ""))[:200],
})
else:
# Sync node execution (run in thread)
result = await asyncio.to_thread(_execute_node, node_type, node_config, context, db)
publish_workflow_event(workflow_id, nid, {
"event": "node_complete",
"node_type": node_type,
"node_label": node_label,
"output_preview": str(result.get("output", ""))[:200],
})
return {
"node_id": nid,
"node_label": node_label,
"node_type": node_type,
"success": True,
"result": result,
}
except Exception as e:
from app.api.admin.acp_gateway import publish_workflow_event
publish_workflow_event(workflow_id, nid, {
"event": "node_error",
"node_type": node_type,
"node_label": node_label,
"error": str(e),
})
return {
"node_id": nid,
"node_label": node_label,
"node_type": node_type,
"success": False,
"error": str(e),
}
async def _execute_agent_node_async(config: dict, context: dict, db: Session, workflow_id: str, node_id: str) -> dict:
"""Execute agent node via ACP Gateway — async"""
from app.api.admin.acp_gateway import get_acp_gateway
agent_id = config.get("agent_id")
if not agent_id:
return {"output": "[Agent] 未选择 Agent", "error": "no agent_id"}
# Build prompt from template
prompt = config.get("prompt", "{input}")
prompt = prompt.replace("{input}", str(context.get("input", "")))
prompt = prompt.replace("{prev_output}", str(context.get("prev_output", "")))
# Substitute context variables
for key, val in context.items():
if isinstance(val, str):
prompt = prompt.replace("{" + key + "}", val)
system_prompt = config.get("system_prompt", "")
temperature = float(config.get("temperature", 0.7))
max_tokens = int(config.get("max_tokens", 4096))
gateway = get_acp_gateway()
# Set notification handler for Redis EventBus
from app.api.admin.acp_gateway import publish_workflow_event
gateway.set_notification_handler(
lambda data: publish_workflow_event(workflow_id, node_id, data)
)
result = await gateway.invoke_agent(
agent_id=agent_id,
prompt=prompt,
system_prompt=system_prompt,
temperature=temperature,
max_tokens=max_tokens,
context=context,
db_session=db,
)
return result
def _execute_llm_node(config: dict, context: dict, db: Session) -> dict:
"""Execute LLM call"""
import httpx
@@ -759,6 +1110,59 @@ def _execute_skill_node(config: dict, context: dict, db: Session) -> dict:
}
def _execute_agent_node(config: dict, context: dict, db: Session) -> dict:
"""Execute agent node (sync wrapper) — calls the async version via asyncio.run"""
agent_id = config.get("agent_id")
if not agent_id:
return {"output": "[Agent] 未选择 Agent", "error": "no agent_id"}
prompt = config.get("prompt", "{input}")
prompt = prompt.replace("{input}", str(context.get("input", "")))
prompt = prompt.replace("{prev_output}", str(context.get("prev_output", "")))
system_prompt = config.get("system_prompt", "")
temperature = float(config.get("temperature", 0.7))
max_tokens = int(config.get("max_tokens", 4096))
from .acp_gateway import get_acp_gateway
gateway = get_acp_gateway()
try:
import asyncio
loop = asyncio.get_event_loop()
if loop.is_running():
# In async context — create new loop
new_loop = asyncio.new_event_loop()
result = new_loop.run_until_complete(
gateway.invoke_agent(
agent_id=agent_id,
prompt=prompt,
system_prompt=system_prompt,
temperature=temperature,
max_tokens=max_tokens,
context=context,
db_session=db,
)
)
new_loop.close()
else:
result = loop.run_until_complete(
gateway.invoke_agent(
agent_id=agent_id,
prompt=prompt,
system_prompt=system_prompt,
temperature=temperature,
max_tokens=max_tokens,
context=context,
db_session=db,
)
)
return result
except Exception as e:
return {"output": f"[Agent] 调用失败: {str(e)}", "error": str(e)}
def _execute_http_node(config: dict, context: dict) -> dict:
"""Execute HTTP request"""
import httpx