feat: 完善企业场景多线路由与执行稳定性
补齐平台模板与场景 DSL、预算控制、执行看板和企业场景脚本,增强 Windows 启动/迁移与前端代理和聊天会话记忆,修复执行创建阶段 500 与异步链路排障体验。 Made-with: Cursor
This commit is contained in:
@@ -3,7 +3,7 @@ Agent管理API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import logging
|
||||
@@ -35,6 +35,7 @@ class AgentCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
workflow_config: Dict[str, Any] # 包含nodes和edges
|
||||
budget_config: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class AgentUpdate(BaseModel):
|
||||
@@ -43,6 +44,28 @@ class AgentUpdate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
workflow_config: Optional[Dict[str, Any]] = None
|
||||
status: Optional[str] = None
|
||||
budget_config: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class SceneTemplateItem(BaseModel):
|
||||
"""场景模板列表项(元数据)"""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
category: Optional[str] = None
|
||||
default_temperature: Optional[float] = None
|
||||
parameter_hints: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentFromSceneTemplateCreate(BaseModel):
|
||||
"""从场景模板创建 Agent"""
|
||||
|
||||
template_id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
parameters: Dict[str, Any] = Field(default_factory=dict)
|
||||
budget_config: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class AgentResponse(BaseModel):
|
||||
@@ -51,6 +74,7 @@ class AgentResponse(BaseModel):
|
||||
name: str
|
||||
description: Optional[str]
|
||||
workflow_config: Dict[str, Any]
|
||||
budget_config: Optional[Dict[str, Any]] = None
|
||||
version: int
|
||||
status: str
|
||||
user_id: Optional[str] # 允许为None
|
||||
@@ -125,6 +149,7 @@ async def get_agents(
|
||||
"name": agent.name,
|
||||
"description": agent.description,
|
||||
"workflow_config": agent.workflow_config,
|
||||
"budget_config": agent.budget_config,
|
||||
"version": agent.version,
|
||||
"status": agent.status,
|
||||
"user_id": agent.user_id if agent.user_id else None,
|
||||
@@ -171,6 +196,7 @@ async def create_agent(
|
||||
name=agent_data.name,
|
||||
description=agent_data.description,
|
||||
workflow_config=agent_data.workflow_config,
|
||||
budget_config=agent_data.budget_config,
|
||||
user_id=current_user.id,
|
||||
status="draft"
|
||||
)
|
||||
@@ -259,6 +285,9 @@ async def update_agent(
|
||||
if agent_data.status not in valid_statuses:
|
||||
raise ValidationError(f"无效的状态: {agent_data.status}")
|
||||
agent.status = agent_data.status
|
||||
|
||||
if agent_data.budget_config is not None:
|
||||
agent.budget_config = agent_data.budget_config
|
||||
|
||||
db.commit()
|
||||
db.refresh(agent)
|
||||
@@ -403,6 +432,9 @@ async def duplicate_agent(
|
||||
name=new_name,
|
||||
description=original_agent.description,
|
||||
workflow_config=new_workflow_config,
|
||||
budget_config=copy.deepcopy(original_agent.budget_config)
|
||||
if original_agent.budget_config is not None
|
||||
else None,
|
||||
user_id=current_user.id,
|
||||
status="draft", # 复制的Agent状态为草稿
|
||||
version=1 # 版本号从1开始
|
||||
@@ -442,6 +474,7 @@ async def export_agent(
|
||||
"name": agent.name,
|
||||
"description": agent.description,
|
||||
"workflow_config": workflow_config,
|
||||
"budget_config": agent.budget_config,
|
||||
"version": agent.version,
|
||||
"status": agent.status,
|
||||
"exported_at": datetime.utcnow().isoformat()
|
||||
@@ -507,6 +540,7 @@ async def import_agent(
|
||||
counter += 1
|
||||
|
||||
# 创建Agent
|
||||
bc = agent_data.get("budget_config")
|
||||
agent = Agent(
|
||||
name=name,
|
||||
description=description,
|
||||
@@ -514,6 +548,7 @@ async def import_agent(
|
||||
"nodes": nodes,
|
||||
"edges": edges
|
||||
},
|
||||
budget_config=bc if isinstance(bc, dict) else None,
|
||||
user_id=current_user.id,
|
||||
status="draft", # 导入的Agent默认为草稿状态
|
||||
version=1
|
||||
|
||||
@@ -38,6 +38,114 @@ class ExecutionLogResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
def _has_execution_permission(db: Session, execution: Execution, current_user: User) -> bool:
|
||||
if getattr(current_user, "role", None) == "admin":
|
||||
return True
|
||||
if execution.workflow_id:
|
||||
workflow = db.query(Workflow).filter(Workflow.id == execution.workflow_id).first()
|
||||
return bool(workflow and workflow.user_id == current_user.id)
|
||||
if execution.agent_id:
|
||||
agent = db.query(Agent).filter(Agent.id == execution.agent_id).first()
|
||||
return bool(agent and (agent.user_id == current_user.id or agent.status in ["published", "running"]))
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/executions/{execution_id}/chain")
|
||||
async def get_execution_chain(
|
||||
execution_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取父子执行链路(当前执行 + 递归子执行)。"""
|
||||
root = db.query(Execution).filter(Execution.id == execution_id).first()
|
||||
if not root:
|
||||
raise NotFoundError("执行记录", execution_id)
|
||||
if not _has_execution_permission(db, root, current_user):
|
||||
raise NotFoundError("执行记录", execution_id)
|
||||
|
||||
all_execs = db.query(Execution).all()
|
||||
by_parent: Dict[Optional[str], List[Execution]] = {}
|
||||
for e in all_execs:
|
||||
by_parent.setdefault(e.parent_execution_id, []).append(e)
|
||||
|
||||
def _build_tree(e: Execution) -> Dict[str, Any]:
|
||||
children = by_parent.get(e.id, [])
|
||||
children.sort(key=lambda x: x.created_at or datetime.min)
|
||||
return {
|
||||
"id": e.id,
|
||||
"status": e.status,
|
||||
"workflow_id": e.workflow_id,
|
||||
"agent_id": e.agent_id,
|
||||
"depth": e.depth,
|
||||
"parent_execution_id": e.parent_execution_id,
|
||||
"execution_time": e.execution_time,
|
||||
"created_at": e.created_at.isoformat() if e.created_at else None,
|
||||
"children": [_build_tree(c) for c in children],
|
||||
}
|
||||
|
||||
return _build_tree(root)
|
||||
|
||||
|
||||
@router.get("/executions/{execution_id}/chain/summary")
|
||||
async def get_execution_chain_summary(
|
||||
execution_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取父子执行链路汇总(节点数、成功失败数、总耗时)。"""
|
||||
root = db.query(Execution).filter(Execution.id == execution_id).first()
|
||||
if not root:
|
||||
raise NotFoundError("执行记录", execution_id)
|
||||
if not _has_execution_permission(db, root, current_user):
|
||||
raise NotFoundError("执行记录", execution_id)
|
||||
|
||||
all_execs = db.query(Execution).all()
|
||||
by_parent: Dict[Optional[str], List[Execution]] = {}
|
||||
by_id: Dict[str, Execution] = {}
|
||||
for e in all_execs:
|
||||
by_parent.setdefault(e.parent_execution_id, []).append(e)
|
||||
by_id[e.id] = e
|
||||
|
||||
stack = [root.id]
|
||||
chain_ids: List[str] = []
|
||||
while stack:
|
||||
cur = stack.pop()
|
||||
chain_ids.append(cur)
|
||||
for c in by_parent.get(cur, []):
|
||||
stack.append(c.id)
|
||||
|
||||
chain_execs = [by_id[i] for i in chain_ids if i in by_id]
|
||||
status_count: Dict[str, int] = {
|
||||
"pending": 0,
|
||||
"running": 0,
|
||||
"completed": 0,
|
||||
"failed": 0,
|
||||
"awaiting_approval": 0,
|
||||
}
|
||||
total_time = 0
|
||||
for e in chain_execs:
|
||||
st = (e.status or "").lower()
|
||||
if st in status_count:
|
||||
status_count[st] += 1
|
||||
if isinstance(e.execution_time, int):
|
||||
total_time += e.execution_time
|
||||
|
||||
max_depth = 0
|
||||
for e in chain_execs:
|
||||
try:
|
||||
max_depth = max(max_depth, int(e.depth or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
return {
|
||||
"root_execution_id": root.id,
|
||||
"total_executions": len(chain_execs),
|
||||
"status_count": status_count,
|
||||
"total_execution_time_ms": total_time,
|
||||
"max_depth": max_depth,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/executions/{execution_id}", response_model=List[ExecutionLogResponse])
|
||||
async def get_execution_logs(
|
||||
execution_id: str,
|
||||
@@ -58,17 +166,7 @@ async def get_execution_logs(
|
||||
raise NotFoundError("执行记录", execution_id)
|
||||
|
||||
# 验证权限:检查workflow或agent的所有权
|
||||
has_permission = False
|
||||
if execution.workflow_id:
|
||||
workflow = db.query(Workflow).filter(Workflow.id == execution.workflow_id).first()
|
||||
if workflow and workflow.user_id == current_user.id:
|
||||
has_permission = True
|
||||
elif execution.agent_id:
|
||||
agent = db.query(Agent).filter(Agent.id == execution.agent_id).first()
|
||||
if agent and (agent.user_id == current_user.id or agent.status in ["published", "running"]):
|
||||
has_permission = True
|
||||
|
||||
if not has_permission:
|
||||
if not _has_execution_permission(db, execution, current_user):
|
||||
raise NotFoundError("执行记录", execution_id)
|
||||
|
||||
# 构建查询
|
||||
@@ -104,17 +202,7 @@ async def get_execution_log_summary(
|
||||
raise NotFoundError("执行记录", execution_id)
|
||||
|
||||
# 验证权限:检查workflow或agent的所有权
|
||||
has_permission = False
|
||||
if execution.workflow_id:
|
||||
workflow = db.query(Workflow).filter(Workflow.id == execution.workflow_id).first()
|
||||
if workflow and workflow.user_id == current_user.id:
|
||||
has_permission = True
|
||||
elif execution.agent_id:
|
||||
agent = db.query(Agent).filter(Agent.id == execution.agent_id).first()
|
||||
if agent and (agent.user_id == current_user.id or agent.status in ["published", "running"]):
|
||||
has_permission = True
|
||||
|
||||
if not has_permission:
|
||||
if not _has_execution_permission(db, execution, current_user):
|
||||
raise NotFoundError("执行记录", execution_id)
|
||||
|
||||
# 统计各级别日志数量
|
||||
@@ -184,17 +272,7 @@ async def get_execution_performance(
|
||||
raise NotFoundError("执行记录", execution_id)
|
||||
|
||||
# 验证权限:检查workflow或agent的所有权
|
||||
has_permission = False
|
||||
if execution.workflow_id:
|
||||
workflow = db.query(Workflow).filter(Workflow.id == execution.workflow_id).first()
|
||||
if workflow and workflow.user_id == current_user.id:
|
||||
has_permission = True
|
||||
elif execution.agent_id:
|
||||
agent = db.query(Agent).filter(Agent.id == execution.agent_id).first()
|
||||
if agent and (agent.user_id == current_user.id or agent.status in ["published", "running"]):
|
||||
has_permission = True
|
||||
|
||||
if not has_permission:
|
||||
if not _has_execution_permission(db, execution, current_user):
|
||||
raise NotFoundError("执行记录", execution_id)
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
执行管理API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional, Dict, Any
|
||||
from typing import List, Optional, Dict, Any, Literal
|
||||
from datetime import datetime
|
||||
from app.core.database import get_db
|
||||
from app.models.execution import Execution
|
||||
@@ -12,8 +13,7 @@ from app.models.workflow import Workflow
|
||||
from app.models.agent import Agent
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.workflow_engine import WorkflowEngine
|
||||
from app.tasks.workflow_tasks import execute_workflow_task
|
||||
from app.tasks.workflow_tasks import execute_workflow_task, resume_workflow_task
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
@@ -22,11 +22,56 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v1/executions", tags=["executions"])
|
||||
|
||||
|
||||
def _enqueue_workflow_task_safe(
|
||||
db: Session,
|
||||
execution: Execution,
|
||||
workflow_label: str,
|
||||
workflow_data: dict,
|
||||
input_data: dict,
|
||||
) -> Execution:
|
||||
"""
|
||||
将工作流执行任务投递到 Celery。若 Redis/Worker 不可用,回写执行记录为 failed 并抛出 503。
|
||||
"""
|
||||
try:
|
||||
task = execute_workflow_task.delay(
|
||||
str(execution.id),
|
||||
workflow_label,
|
||||
workflow_data,
|
||||
input_data,
|
||||
)
|
||||
execution.task_id = task.id
|
||||
db.commit()
|
||||
db.refresh(execution)
|
||||
return execution
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Celery 任务入队失败 execution_id=%s workflow_label=%s",
|
||||
execution.id,
|
||||
workflow_label,
|
||||
)
|
||||
msg = (
|
||||
"异步任务入队失败:无法连接消息队列或 Celery Worker。"
|
||||
"请确认 Redis 已启动、.env 中 REDIS_URL 正确,并已运行 Celery Worker(如 celery -A app.core.celery_app worker)。"
|
||||
f" 原始错误: {e!s}"
|
||||
)
|
||||
try:
|
||||
execution.status = "failed"
|
||||
execution.error_message = msg[:2000]
|
||||
db.commit()
|
||||
db.refresh(execution)
|
||||
except Exception as e2:
|
||||
logger.exception("回写执行失败状态时出错: %s", e2)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=503, detail=msg[:2000]) from e
|
||||
|
||||
|
||||
class ExecutionCreate(BaseModel):
|
||||
"""执行创建模型"""
|
||||
workflow_id: Optional[str] = None
|
||||
agent_id: Optional[str] = None
|
||||
input_data: Dict[str, Any]
|
||||
parent_execution_id: Optional[str] = None
|
||||
depth: Optional[int] = 0
|
||||
|
||||
|
||||
class ExecutionResponse(BaseModel):
|
||||
@@ -40,12 +85,57 @@ class ExecutionResponse(BaseModel):
|
||||
error_message: Optional[str]
|
||||
execution_time: Optional[int]
|
||||
task_id: Optional[str]
|
||||
parent_execution_id: Optional[str]
|
||||
depth: int
|
||||
pause_state: Optional[Dict[str, Any]] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
def _execution_to_response(ex: Execution) -> ExecutionResponse:
|
||||
return ExecutionResponse(
|
||||
id=str(ex.id),
|
||||
workflow_id=str(ex.workflow_id) if ex.workflow_id is not None else None,
|
||||
agent_id=str(ex.agent_id) if ex.agent_id is not None else None,
|
||||
input_data=ex.input_data,
|
||||
output_data=ex.output_data,
|
||||
status=ex.status,
|
||||
error_message=ex.error_message,
|
||||
execution_time=ex.execution_time,
|
||||
task_id=str(ex.task_id) if ex.task_id is not None else None,
|
||||
parent_execution_id=(
|
||||
str(ex.parent_execution_id) if ex.parent_execution_id is not None else None
|
||||
),
|
||||
depth=int(ex.depth) if ex.depth is not None else 0,
|
||||
pause_state=ex.pause_state,
|
||||
created_at=ex.created_at,
|
||||
)
|
||||
|
||||
|
||||
class ResumeExecutionBody(BaseModel):
|
||||
"""恢复挂起的执行(审批)"""
|
||||
decision: Literal["approved", "rejected"]
|
||||
comment: Optional[str] = None
|
||||
|
||||
|
||||
def _can_view_execution(
|
||||
db: Session, current_user: User, execution: Execution
|
||||
) -> bool:
|
||||
if getattr(current_user, "role", None) == "admin":
|
||||
return True
|
||||
if execution.workflow_id:
|
||||
wf = db.query(Workflow).filter(Workflow.id == execution.workflow_id).first()
|
||||
if wf and wf.user_id == current_user.id:
|
||||
return True
|
||||
if execution.agent_id:
|
||||
ag = db.query(Agent).filter(Agent.id == execution.agent_id).first()
|
||||
if ag and ag.user_id == current_user.id:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@router.post("", response_model=ExecutionResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_execution(
|
||||
execution_data: ExecutionCreate,
|
||||
@@ -68,7 +158,9 @@ async def create_execution(
|
||||
execution = Execution(
|
||||
workflow_id=execution_data.workflow_id,
|
||||
input_data=execution_data.input_data,
|
||||
status="pending"
|
||||
status="pending",
|
||||
parent_execution_id=execution_data.parent_execution_id,
|
||||
depth=max(0, int(execution_data.depth or 0)),
|
||||
)
|
||||
db.add(execution)
|
||||
db.commit()
|
||||
@@ -79,20 +171,15 @@ async def create_execution(
|
||||
'nodes': workflow.nodes,
|
||||
'edges': workflow.edges
|
||||
}
|
||||
task = execute_workflow_task.delay(
|
||||
str(execution.id),
|
||||
ex = _enqueue_workflow_task_safe(
|
||||
db,
|
||||
execution,
|
||||
execution_data.workflow_id,
|
||||
workflow_data,
|
||||
execution_data.input_data
|
||||
execution_data.input_data,
|
||||
)
|
||||
|
||||
# 更新执行记录的task_id
|
||||
execution.task_id = task.id
|
||||
db.commit()
|
||||
db.refresh(execution)
|
||||
|
||||
return execution
|
||||
|
||||
return _execution_to_response(ex)
|
||||
|
||||
elif execution_data.agent_id:
|
||||
agent = db.query(Agent).filter(Agent.id == execution_data.agent_id).first()
|
||||
|
||||
@@ -111,7 +198,9 @@ async def create_execution(
|
||||
execution = Execution(
|
||||
agent_id=execution_data.agent_id,
|
||||
input_data=execution_data.input_data,
|
||||
status="pending"
|
||||
status="pending",
|
||||
parent_execution_id=execution_data.parent_execution_id,
|
||||
depth=max(0, int(execution_data.depth or 0)),
|
||||
)
|
||||
db.add(execution)
|
||||
db.commit()
|
||||
@@ -129,20 +218,15 @@ async def create_execution(
|
||||
if node.get('type') == 'llm':
|
||||
node_data = node.get('data', {})
|
||||
logger.debug(f"[rjb] LLM节点: node_id={node.get('id')}, data keys={list(node_data.keys())}, api_key={'已配置' if node_data.get('api_key') else '未配置'}")
|
||||
task = execute_workflow_task.delay(
|
||||
str(execution.id),
|
||||
f"agent_{agent.id}", # 使用agent ID作为workflow_id标识
|
||||
ex = _enqueue_workflow_task_safe(
|
||||
db,
|
||||
execution,
|
||||
f"agent_{agent.id}",
|
||||
workflow_data,
|
||||
execution_data.input_data
|
||||
execution_data.input_data,
|
||||
)
|
||||
|
||||
# 更新执行记录的task_id
|
||||
execution.task_id = task.id
|
||||
db.commit()
|
||||
db.refresh(execution)
|
||||
|
||||
return execution
|
||||
|
||||
return _execution_to_response(ex)
|
||||
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="必须提供workflow_id或agent_id")
|
||||
|
||||
@@ -152,6 +236,7 @@ async def get_executions(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
workflow_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -164,20 +249,34 @@ async def get_executions(
|
||||
skip: 跳过记录数(分页)
|
||||
limit: 每页记录数(分页,最大100)
|
||||
workflow_id: 工作流ID筛选
|
||||
status: 状态筛选(pending, running, completed, failed)
|
||||
status: 状态筛选(pending, running, completed, failed, awaiting_approval)
|
||||
search: 搜索关键词(搜索执行ID、工作流ID、任务ID)
|
||||
"""
|
||||
# 限制每页最大记录数
|
||||
limit = min(limit, 100)
|
||||
|
||||
# 构建基础查询:只查询当前用户的工作流/智能体的执行记录
|
||||
query = db.query(Execution).join(Workflow, Execution.workflow_id == Workflow.id).filter(
|
||||
Workflow.user_id == current_user.id
|
||||
)
|
||||
|
||||
|
||||
# 管理员可看全部;普通用户:自己拥有的工作流或 Agent 上的执行记录(含纯 Agent 执行)
|
||||
if getattr(current_user, "role", None) == "admin":
|
||||
query = db.query(Execution)
|
||||
else:
|
||||
query = (
|
||||
db.query(Execution)
|
||||
.outerjoin(Workflow, Execution.workflow_id == Workflow.id)
|
||||
.outerjoin(Agent, Execution.agent_id == Agent.id)
|
||||
.filter(
|
||||
or_(
|
||||
Workflow.user_id == current_user.id,
|
||||
Agent.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# 工作流ID筛选
|
||||
if workflow_id:
|
||||
query = query.filter(Execution.workflow_id == workflow_id)
|
||||
|
||||
if agent_id:
|
||||
query = query.filter(Execution.agent_id == agent_id)
|
||||
|
||||
# 状态筛选
|
||||
if status:
|
||||
@@ -189,12 +288,13 @@ async def get_executions(
|
||||
query = query.filter(
|
||||
(Execution.id.like(search_pattern)) |
|
||||
(Execution.workflow_id.like(search_pattern)) |
|
||||
(Execution.agent_id.like(search_pattern)) |
|
||||
(Execution.task_id.like(search_pattern))
|
||||
)
|
||||
|
||||
# 排序和分页
|
||||
executions = query.order_by(Execution.created_at.desc()).offset(skip).limit(limit).all()
|
||||
return executions
|
||||
return [_execution_to_response(e) for e in executions]
|
||||
|
||||
|
||||
@router.get("/{execution_id}/status", response_model=Dict[str, Any])
|
||||
@@ -328,6 +428,41 @@ async def get_execution_status(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{execution_id}/resume", response_model=ExecutionResponse)
|
||||
async def resume_execution(
|
||||
execution_id: str,
|
||||
body: ResumeExecutionBody,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""在审批挂起(awaiting_approval)后恢复执行。"""
|
||||
execution = db.query(Execution).filter(Execution.id == execution_id).first()
|
||||
if not execution:
|
||||
raise HTTPException(status_code=404, detail="执行记录不存在")
|
||||
if not _can_view_execution(db, current_user, execution):
|
||||
raise HTTPException(status_code=403, detail="无权访问")
|
||||
if execution.status != "awaiting_approval":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"当前状态不可恢复: {execution.status}",
|
||||
)
|
||||
if not execution.pause_state:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="缺少挂起快照,无法恢复",
|
||||
)
|
||||
|
||||
task = resume_workflow_task.delay(
|
||||
str(execution.id),
|
||||
body.decision,
|
||||
body.comment,
|
||||
)
|
||||
execution.task_id = task.id
|
||||
db.commit()
|
||||
db.refresh(execution)
|
||||
return execution
|
||||
|
||||
|
||||
@router.get("/{execution_id}", response_model=ExecutionResponse)
|
||||
async def get_execution(
|
||||
execution_id: str,
|
||||
@@ -339,11 +474,8 @@ async def get_execution(
|
||||
|
||||
if not execution:
|
||||
raise HTTPException(status_code=404, detail="执行记录不存在")
|
||||
|
||||
# 验证权限
|
||||
if execution.workflow_id:
|
||||
workflow = db.query(Workflow).filter(Workflow.id == execution.workflow_id).first()
|
||||
if workflow and workflow.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="无权访问")
|
||||
|
||||
return execution
|
||||
|
||||
if not _can_view_execution(db, current_user, execution):
|
||||
raise HTTPException(status_code=403, detail="无权访问")
|
||||
|
||||
return _execution_to_response(execution)
|
||||
|
||||
72
backend/app/api/platform_templates.py
Normal file
72
backend/app/api/platform_templates.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
场景模板 API(独立路由,避免与 /api/v1/agents/{agent_id} 在部分部署中的匹配顺序问题)。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
import logging
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.agent import Agent
|
||||
from app.core.exceptions import ValidationError, ConflictError
|
||||
from app.services.workflow_validator import validate_workflow
|
||||
from app.services.scene_templates import build_workflow_for_template, list_scene_template_meta
|
||||
from app.api.agents import AgentResponse, SceneTemplateItem, AgentFromSceneTemplateCreate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/platform", tags=["platform-templates"])
|
||||
|
||||
|
||||
@router.get("/scene-templates", response_model=List[SceneTemplateItem])
|
||||
async def list_scene_templates_v1(current_user: User = Depends(get_current_user)):
|
||||
_ = current_user
|
||||
return list_scene_template_meta()
|
||||
|
||||
|
||||
@router.post("/agents/from-template", response_model=AgentResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_agent_from_template_v1(
|
||||
body: AgentFromSceneTemplateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
try:
|
||||
workflow_config = build_workflow_for_template(
|
||||
body.template_id, body.parameters or {}
|
||||
)
|
||||
except ValueError as e:
|
||||
raise ValidationError(str(e))
|
||||
|
||||
validation_result = validate_workflow(
|
||||
workflow_config.get("nodes", []), workflow_config.get("edges", [])
|
||||
)
|
||||
if not validation_result["valid"]:
|
||||
raise ValidationError(
|
||||
"工作流配置验证失败: " + ", ".join(validation_result["errors"])
|
||||
)
|
||||
|
||||
existing_agent = db.query(Agent).filter(
|
||||
Agent.name == body.name,
|
||||
Agent.user_id == current_user.id,
|
||||
).first()
|
||||
if existing_agent:
|
||||
raise ConflictError(f"Agent名称 '{body.name}' 已存在")
|
||||
|
||||
desc = body.description or f"自场景模板 {body.template_id} 创建"
|
||||
agent = Agent(
|
||||
name=body.name,
|
||||
description=desc,
|
||||
workflow_config=workflow_config,
|
||||
budget_config=body.budget_config,
|
||||
user_id=current_user.id,
|
||||
status="draft",
|
||||
)
|
||||
db.add(agent)
|
||||
db.commit()
|
||||
db.refresh(agent)
|
||||
logger.info(
|
||||
f"用户 {current_user.username} 从模板 {body.template_id} 创建 Agent: {agent.name} ({agent.id})"
|
||||
)
|
||||
return agent
|
||||
@@ -52,6 +52,18 @@ class Settings(BaseSettings):
|
||||
JWT_SECRET_KEY: str = "dev-jwt-secret-key-change-in-production"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# Celery 工作流任务:对**非业务节点失败**(非 WorkflowExecutionError)的退避重试次数,0 表示不重试
|
||||
WORKFLOW_TASK_MAX_RETRIES: int = 0
|
||||
|
||||
# 单执行预算:主循环每执行一个节点计 1 步,超过则熔断(防止死循环/失控)
|
||||
WORKFLOW_MAX_STEPS_PER_RUN: int = 2000
|
||||
|
||||
# 单执行 LLM 节点调用上限(llm / template 节点每执行一次计 1)
|
||||
WORKFLOW_MAX_LLM_INVOCATIONS_PER_RUN: int = 200
|
||||
|
||||
# 单执行工具实际执行次数上限(LLM function calling 每执行一个工具计 1)
|
||||
WORKFLOW_MAX_TOOL_CALLS_PER_RUN: int = 500
|
||||
|
||||
class Config:
|
||||
env_file = str(_ENV_PATH)
|
||||
|
||||
@@ -16,7 +16,11 @@ engine = create_engine(
|
||||
)
|
||||
|
||||
# 创建会话工厂
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
# expire_on_commit=False:commit 后仍可读取已加载标量,避免 FastAPI 在序列化 ExecutionResponse 时
|
||||
# 因会话已关闭而再次触发懒加载,从而出现「仅 HTTP 报 DATABASE_ERROR、TestClient 正常」的现象。
|
||||
SessionLocal = sessionmaker(
|
||||
autocommit=False, autoflush=False, bind=engine, expire_on_commit=False
|
||||
)
|
||||
|
||||
# 创建基础模型类
|
||||
Base = declarative_base()
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastapi.responses import JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from app.core.exceptions import BaseAPIException
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,14 +51,28 @@ async def api_exception_handler(request: Request, exc: BaseAPIException):
|
||||
|
||||
async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError):
|
||||
"""处理数据库错误"""
|
||||
logger.error(f"数据库错误: {str(exc)}", exc_info=True)
|
||||
|
||||
orig = getattr(exc, "orig", None)
|
||||
detail = str(orig) if orig is not None else str(exc)
|
||||
logger.error(f"数据库错误: {detail}", exc_info=True)
|
||||
|
||||
user_msg = "数据库操作失败,请稍后重试"
|
||||
# MySQL 1054 / 常见 DDL 滞后:模型已增列但库未 alembic upgrade
|
||||
if "Unknown column" in detail or "(1054," in detail or "1054" in detail:
|
||||
user_msg = (
|
||||
"数据库表结构与当前代码不一致(常见为缺少 executions 新列等)。"
|
||||
"请在 backend 目录执行:alembic upgrade head,然后重启 API 与 Celery。"
|
||||
)
|
||||
|
||||
payload: dict = {
|
||||
"error": "DATABASE_ERROR",
|
||||
"message": user_msg,
|
||||
}
|
||||
if settings.DEBUG:
|
||||
payload["detail"] = detail[:4000]
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"error": "DATABASE_ERROR",
|
||||
"message": "数据库操作失败,请稍后重试"
|
||||
}
|
||||
content=payload,
|
||||
)
|
||||
|
||||
|
||||
@@ -65,11 +80,15 @@ async def general_exception_handler(request: Request, exc: Exception):
|
||||
"""处理通用异常"""
|
||||
logger.error(f"未处理的异常: {str(exc)}", exc_info=True)
|
||||
logger.error(f"异常堆栈: {traceback.format_exc()}")
|
||||
|
||||
|
||||
payload: dict = {
|
||||
"error": "INTERNAL_ERROR",
|
||||
"message": "服务器内部错误,请稍后重试",
|
||||
}
|
||||
if settings.DEBUG:
|
||||
payload["detail"] = str(exc)[:4000]
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"error": "INTERNAL_ERROR",
|
||||
"message": "服务器内部错误,请稍后重试"
|
||||
}
|
||||
content=payload,
|
||||
)
|
||||
|
||||
@@ -84,3 +84,11 @@ class WorkflowExecutionError(BaseAPIException):
|
||||
detail=detail,
|
||||
error_code="WORKFLOW_EXECUTION_ERROR"
|
||||
)
|
||||
|
||||
|
||||
class WorkflowPaused(Exception):
|
||||
"""工作流在审批节点挂起,需恢复执行(非 API 异常,由 Celery 捕获并落库)。"""
|
||||
|
||||
def __init__(self, snapshot: dict):
|
||||
self.snapshot = snapshot
|
||||
super().__init__("workflow_awaiting_approval")
|
||||
|
||||
@@ -201,7 +201,7 @@ async def startup_event():
|
||||
# 不抛出异常,允许应用继续启动
|
||||
|
||||
# 注册路由
|
||||
from app.api import auth, workflows, executions, websocket, execution_logs, data_sources, agents, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools
|
||||
from app.api import auth, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(workflows.router)
|
||||
@@ -210,6 +210,7 @@ app.include_router(websocket.router)
|
||||
app.include_router(execution_logs.router)
|
||||
app.include_router(data_sources.router)
|
||||
app.include_router(agents.router)
|
||||
app.include_router(platform_templates.router)
|
||||
app.include_router(model_configs.router)
|
||||
app.include_router(webhooks.router)
|
||||
app.include_router(template_market.router)
|
||||
|
||||
@@ -16,6 +16,11 @@ class Agent(Base):
|
||||
name = Column(String(100), nullable=False, comment="智能体名称")
|
||||
description = Column(Text, comment="描述")
|
||||
workflow_config = Column(JSON, nullable=False, comment="工作流配置")
|
||||
budget_config = Column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
comment="执行预算:max_steps/max_llm_invocations/max_tool_calls(可选,覆盖全局默认)",
|
||||
)
|
||||
version = Column(Integer, default=1, comment="版本号")
|
||||
status = Column(String(20), default="draft", comment="状态: draft/published/running/stopped")
|
||||
user_id = Column(CHAR(36), ForeignKey("users.id"), comment="创建者ID")
|
||||
|
||||
@@ -17,10 +17,19 @@ class Execution(Base):
|
||||
workflow_id = Column(CHAR(36), ForeignKey("workflows.id"), nullable=True, comment="工作流ID")
|
||||
input_data = Column(JSON, comment="输入数据")
|
||||
output_data = Column(JSON, comment="输出数据")
|
||||
status = Column(String(20), nullable=False, comment="状态: pending/running/completed/failed")
|
||||
status = Column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
comment="状态: pending/running/completed/failed/awaiting_approval",
|
||||
)
|
||||
error_message = Column(Text, comment="错误信息")
|
||||
execution_time = Column(Integer, comment="执行时间(ms)")
|
||||
task_id = Column(String(100), comment="Celery任务ID")
|
||||
parent_execution_id = Column(
|
||||
CHAR(36), ForeignKey("executions.id"), nullable=True, comment="父执行ID"
|
||||
)
|
||||
depth = Column(Integer, default=0, nullable=False, comment="执行深度(根为0)")
|
||||
pause_state = Column(JSON, nullable=True, comment="挂起快照(审批节点 HITL,恢复时消费)")
|
||||
created_at = Column(DateTime, default=func.now(), comment="创建时间")
|
||||
|
||||
# 关系
|
||||
|
||||
45
backend/app/services/execution_budget.py
Normal file
45
backend/app/services/execution_budget.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
执行预算:全局默认 + Agent.budget_config 合并,供 WorkflowEngine 与任务入口使用。
|
||||
"""
|
||||
from typing import Dict, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.agent import Agent
|
||||
from app.models.execution import Execution
|
||||
|
||||
|
||||
def merge_budget_for_execution(db: Session, execution: Optional[Execution]) -> Dict[str, int]:
|
||||
"""
|
||||
返回合并后的整数预算(至少为 1)。
|
||||
keys: max_steps, max_llm_invocations, max_tool_calls
|
||||
"""
|
||||
out: Dict[str, int] = {
|
||||
"max_steps": max(1, int(getattr(settings, "WORKFLOW_MAX_STEPS_PER_RUN", 2000) or 2000)),
|
||||
"max_llm_invocations": max(
|
||||
1, int(getattr(settings, "WORKFLOW_MAX_LLM_INVOCATIONS_PER_RUN", 200) or 200)
|
||||
),
|
||||
"max_tool_calls": max(
|
||||
1, int(getattr(settings, "WORKFLOW_MAX_TOOL_CALLS_PER_RUN", 500) or 500)
|
||||
),
|
||||
}
|
||||
if not execution or not execution.agent_id:
|
||||
return out
|
||||
ag = db.query(Agent).filter(Agent.id == execution.agent_id).first()
|
||||
if not ag or not isinstance(ag.budget_config, dict):
|
||||
return out
|
||||
bc = ag.budget_config
|
||||
mapping = {
|
||||
"max_steps": "max_steps",
|
||||
"max_llm_invocations": "max_llm_invocations",
|
||||
"max_tool_calls": "max_tool_calls",
|
||||
}
|
||||
for json_key, out_key in mapping.items():
|
||||
if json_key not in bc or bc[json_key] is None:
|
||||
continue
|
||||
try:
|
||||
out[out_key] = max(1, int(bc[json_key]))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
LLM服务 - 处理各种LLM提供商的调用
|
||||
"""
|
||||
from typing import Dict, Any, Optional, List
|
||||
from typing import Dict, Any, Optional, List, Callable, Awaitable
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -550,6 +550,7 @@ class LLMService:
|
||||
max_iterations: int = 5,
|
||||
execution_logger = None,
|
||||
tool_choice: Optional[str] = None,
|
||||
on_tool_executed: Optional[Callable[[str], Awaitable[None]]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
调用OpenAI API,支持工具调用
|
||||
@@ -725,6 +726,9 @@ class LLMService:
|
||||
)
|
||||
tool_result = json.dumps({"error": str(tool_error)}, ensure_ascii=False)
|
||||
|
||||
if on_tool_executed:
|
||||
await on_tool_executed(tool_name)
|
||||
|
||||
messages.append(
|
||||
{"role": "tool", "tool_call_id": tool_call_id, "content": tool_result}
|
||||
)
|
||||
@@ -749,6 +753,7 @@ class LLMService:
|
||||
max_iterations: int = 5,
|
||||
execution_logger = None,
|
||||
tool_choice: Optional[str] = None,
|
||||
on_tool_executed: Optional[Callable[[str], Awaitable[None]]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
调用DeepSeek API,支持工具调用(DeepSeek兼容OpenAI API格式)
|
||||
@@ -765,6 +770,7 @@ class LLMService:
|
||||
max_iterations=max_iterations,
|
||||
execution_logger=execution_logger,
|
||||
tool_choice=tool_choice,
|
||||
on_tool_executed=on_tool_executed,
|
||||
)
|
||||
|
||||
async def call_llm_with_tools(
|
||||
@@ -777,6 +783,7 @@ class LLMService:
|
||||
max_tokens: Optional[int] = None,
|
||||
execution_logger = None,
|
||||
tool_choice: Optional[str] = None,
|
||||
on_tool_executed: Optional[Callable[[str], Awaitable[None]]] = None,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""
|
||||
@@ -805,6 +812,7 @@ class LLMService:
|
||||
max_tokens=max_tokens,
|
||||
execution_logger=execution_logger,
|
||||
tool_choice=tool_choice,
|
||||
on_tool_executed=on_tool_executed,
|
||||
**kwargs
|
||||
)
|
||||
elif provider == "deepseek":
|
||||
@@ -818,6 +826,7 @@ class LLMService:
|
||||
max_tokens=max_tokens,
|
||||
execution_logger=execution_logger,
|
||||
tool_choice=tool_choice,
|
||||
on_tool_executed=on_tool_executed,
|
||||
**kwargs
|
||||
)
|
||||
else:
|
||||
|
||||
39
backend/app/services/scenario_dsl.py
Normal file
39
backend/app/services/scenario_dsl.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
统一场景 DSL(阶段 3):场景可编程输入契约,供入口 / 模板 / 外部系统对齐。
|
||||
"""
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ScenarioDSL(BaseModel):
|
||||
"""标准输入:目标、约束、产物、验收 + 业务载荷。"""
|
||||
|
||||
version: str = "1"
|
||||
scene: str = Field(default="", description="场景标识,如 customer_service / dev_codegen")
|
||||
goal: str = Field(default="", description="业务目标")
|
||||
constraints: List[str] = Field(default_factory=list, description="硬约束")
|
||||
deliverables: List[str] = Field(default_factory=list, description="期望产出")
|
||||
acceptance: List[str] = Field(default_factory=list, description="验收标准")
|
||||
payload: dict[str, Any] = Field(default_factory=dict, description="扩展键值")
|
||||
|
||||
|
||||
def validate_scenario_dsl(raw: Any) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
校验场景 DSL。根须为 object,字段通过 Pydantic 校验。
|
||||
|
||||
Returns:
|
||||
(是否通过, 错误文案列表)
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return False, ["DSL 根须为 JSON object"]
|
||||
try:
|
||||
ScenarioDSL.model_validate(raw)
|
||||
except Exception as e:
|
||||
return False, [str(e)]
|
||||
return True, []
|
||||
|
||||
|
||||
def normalize_scenario_dsl(raw: dict) -> Dict[str, Any]:
|
||||
"""校验后得到规范 dict,便于写入节点上下文。"""
|
||||
return ScenarioDSL.model_validate(raw).model_dump()
|
||||
171
backend/app/services/scene_templates.py
Normal file
171
backend/app/services/scene_templates.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
场景模板注册:路线图「客服 / 研发 / 运维」三类最小可运行工作流,供 API 与脚本一键创建 Agent。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
PromptBuilder = Callable[[Dict[str, Any]], str]
|
||||
|
||||
|
||||
def _default_prompt_cs(params: Dict[str, Any]) -> str:
|
||||
extra = (params.get("extra_instructions") or "").strip()
|
||||
base = """你是企业客服场景 Agent。根据用户问题给出清晰、可执行的回答;不确定时先澄清。
|
||||
用户输入可能包含:{{input}} 或来自前序节点的合并字段。保持礼貌、简洁。"""
|
||||
if extra:
|
||||
return f"{base}\n\n【额外说明】\n{extra}"
|
||||
return base
|
||||
|
||||
|
||||
def _default_prompt_dev(params: Dict[str, Any]) -> str:
|
||||
extra = (params.get("extra_instructions") or "").strip()
|
||||
lang = params.get("preferred_language") or "任意合适语言"
|
||||
base = f"""你是研发辅助 Agent,负责代码与设计说明。优先给出可运行示例与步骤;涉及安全/生产变更时明确风险。
|
||||
偏好语言/栈:{lang}。
|
||||
用户诉求:{{input}}"""
|
||||
if extra:
|
||||
return f"{base}\n\n【额外说明】\n{extra}"
|
||||
return base
|
||||
|
||||
|
||||
def _default_prompt_ops(params: Dict[str, Any]) -> str:
|
||||
extra = (params.get("extra_instructions") or "").strip()
|
||||
base = """你是运维/日志分析场景 Agent。帮助用户解读日志片段、定位可能原因与下一步排查;不要编造未提供的日志内容。
|
||||
用户输入:{{input}}"""
|
||||
if extra:
|
||||
return f"{base}\n\n【额外说明】\n{extra}"
|
||||
return base
|
||||
|
||||
|
||||
def _build_minimal_workflow(
|
||||
prompt: str,
|
||||
*,
|
||||
temperature: float = 0.3,
|
||||
enable_tools: bool = False,
|
||||
tools: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
tools = tools or []
|
||||
return {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start-1",
|
||||
"type": "start",
|
||||
"position": {"x": 80, "y": 120},
|
||||
"data": {},
|
||||
},
|
||||
{
|
||||
"id": "llm-1",
|
||||
"type": "llm",
|
||||
"position": {"x": 320, "y": 120},
|
||||
"data": {
|
||||
"prompt": prompt,
|
||||
"temperature": float(temperature),
|
||||
"enable_tools": enable_tools,
|
||||
"tools": tools,
|
||||
"selected_tools": tools,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "end-1",
|
||||
"type": "end",
|
||||
"position": {"x": 560, "y": 120},
|
||||
"data": {},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "e_start_llm",
|
||||
"source": "start-1",
|
||||
"target": "llm-1",
|
||||
"sourceHandle": "right",
|
||||
"targetHandle": "left",
|
||||
},
|
||||
{
|
||||
"id": "e_llm_end",
|
||||
"source": "llm-1",
|
||||
"target": "end-1",
|
||||
"sourceHandle": "right",
|
||||
"targetHandle": "left",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_workflow_for_template(template_id: str, parameters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
根据模板 ID 与参数生成 workflow_config(nodes+edges)。
|
||||
|
||||
通用参数:temperature, enable_tools, tools(list[str]), extra_instructions, preferred_language(dev)
|
||||
"""
|
||||
parameters = dict(parameters or {})
|
||||
meta = SCENE_TEMPLATE_REGISTRY.get(template_id)
|
||||
if not meta:
|
||||
raise ValueError(f"未知模板: {template_id}")
|
||||
|
||||
temperature = float(parameters.get("temperature", meta.get("default_temperature", 0.3)))
|
||||
enable_tools = bool(parameters.get("enable_tools", False))
|
||||
tools = parameters.get("tools")
|
||||
if tools is not None and not isinstance(tools, list):
|
||||
tools = []
|
||||
elif tools is None:
|
||||
tools = list(meta.get("default_tools") or [])
|
||||
|
||||
prompt_fn: PromptBuilder = meta["prompt_builder"]
|
||||
prompt = prompt_fn(parameters)
|
||||
return _build_minimal_workflow(
|
||||
prompt,
|
||||
temperature=temperature,
|
||||
enable_tools=enable_tools,
|
||||
tools=tools if enable_tools else [],
|
||||
)
|
||||
|
||||
|
||||
SCENE_TEMPLATE_REGISTRY: Dict[str, Dict[str, Any]] = {
|
||||
"template_customer_service": {
|
||||
"title": "客服场景",
|
||||
"description": "通用客服问答与澄清(最小 LLM 链)。",
|
||||
"category": "customer_service",
|
||||
"default_temperature": 0.35,
|
||||
"default_tools": [],
|
||||
"prompt_builder": _default_prompt_cs,
|
||||
},
|
||||
"template_dev_codegen": {
|
||||
"title": "研发 / 代码助手",
|
||||
"description": "代码与设计说明辅助(最小 LLM 链)。",
|
||||
"category": "dev",
|
||||
"default_temperature": 0.25,
|
||||
"default_tools": [],
|
||||
"prompt_builder": _default_prompt_dev,
|
||||
},
|
||||
"template_ops_log_analysis": {
|
||||
"title": "运维 / 日志分析",
|
||||
"description": "日志解读与排查建议(最小 LLM 链)。",
|
||||
"category": "ops",
|
||||
"default_temperature": 0.3,
|
||||
"default_tools": [],
|
||||
"prompt_builder": _default_prompt_ops,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def list_scene_template_meta() -> List[Dict[str, Any]]:
|
||||
"""供 GET 接口返回(不含 prompt_builder)。"""
|
||||
out: List[Dict[str, Any]] = []
|
||||
for tid, meta in SCENE_TEMPLATE_REGISTRY.items():
|
||||
out.append(
|
||||
{
|
||||
"id": tid,
|
||||
"title": meta["title"],
|
||||
"description": meta["description"],
|
||||
"category": meta.get("category"),
|
||||
"default_temperature": meta.get("default_temperature"),
|
||||
"parameter_hints": [
|
||||
"temperature",
|
||||
"enable_tools",
|
||||
"tools",
|
||||
"extra_instructions",
|
||||
"preferred_language(仅研发模板)",
|
||||
],
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -14,10 +14,14 @@ from datetime import datetime as _datetime_class
|
||||
from app.services.llm_service import llm_service
|
||||
from app.services.condition_parser import condition_parser
|
||||
from app.services.data_transformer import data_transformer
|
||||
from app.core.exceptions import WorkflowExecutionError
|
||||
from app.core.exceptions import WorkflowExecutionError, WorkflowPaused
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.agent import Agent
|
||||
from app.models.execution import Execution
|
||||
from app.models.workflow import Workflow
|
||||
from app.services.execution_logger import ExecutionLogger
|
||||
from app.core.config import settings
|
||||
from app.services.scenario_dsl import normalize_scenario_dsl, validate_scenario_dsl
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -81,7 +85,14 @@ _CODE_NODE_SAFE_BUILTINS = {
|
||||
class WorkflowEngine:
|
||||
"""工作流执行引擎"""
|
||||
|
||||
def __init__(self, workflow_id: str, workflow_data: Dict[str, Any], logger=None, db=None):
|
||||
def __init__(
|
||||
self,
|
||||
workflow_id: str,
|
||||
workflow_data: Dict[str, Any],
|
||||
logger=None,
|
||||
db=None,
|
||||
budget_limits: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""
|
||||
初始化工作流引擎
|
||||
|
||||
@@ -99,11 +110,74 @@ class WorkflowEngine:
|
||||
self.logger = logger
|
||||
self.db = db
|
||||
self._persist_scope_cache: Optional[Tuple[Optional[str], Optional[str]]] = None
|
||||
self._initial_input_data: Optional[Dict[str, Any]] = None
|
||||
self._steps_used: int = 0
|
||||
self._llm_invocations: int = 0
|
||||
self._tool_calls_used: int = 0
|
||||
self.budget_limits: Dict[str, Any] = dict(budget_limits or {})
|
||||
self._cap_steps: int = max(
|
||||
1, int(getattr(settings, "WORKFLOW_MAX_STEPS_PER_RUN", 2000) or 2000)
|
||||
)
|
||||
self._cap_llm: int = max(
|
||||
1, int(getattr(settings, "WORKFLOW_MAX_LLM_INVOCATIONS_PER_RUN", 200) or 200)
|
||||
)
|
||||
self._cap_tool: int = max(
|
||||
1, int(getattr(settings, "WORKFLOW_MAX_TOOL_CALLS_PER_RUN", 500) or 500)
|
||||
)
|
||||
for key, attr in (
|
||||
("max_steps", "_cap_steps"),
|
||||
("max_llm_invocations", "_cap_llm"),
|
||||
("max_tool_calls", "_cap_tool"),
|
||||
):
|
||||
v = self.budget_limits.get(key)
|
||||
if v is None:
|
||||
continue
|
||||
try:
|
||||
setattr(self, attr, max(1, int(v)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
# 任意入口创建引擎时确保内置工具已注册(Celery / 节点测试 / 脚本,不依赖仅 import workflow_tasks)
|
||||
from app.core.tools_bootstrap import ensure_builtin_tools_registered
|
||||
|
||||
ensure_builtin_tools_registered()
|
||||
|
||||
def _json_safe_copy(self, obj: Any) -> Any:
|
||||
"""将对象转为 JSON 可序列化结构再还原,避免挂起快照中的类型问题。"""
|
||||
try:
|
||||
return json.loads(json.dumps(obj, default=str))
|
||||
except Exception:
|
||||
return obj
|
||||
|
||||
def _build_pause_snapshot(
|
||||
self,
|
||||
pending_node_id: str,
|
||||
active_edges: List[Dict[str, Any]],
|
||||
executed_nodes: set,
|
||||
execution_sequence: List[str],
|
||||
results: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"pending_node_id": pending_node_id,
|
||||
"node_outputs": self._json_safe_copy(self.node_outputs),
|
||||
"active_edges": self._json_safe_copy(active_edges),
|
||||
"executed_nodes": list(executed_nodes),
|
||||
"execution_sequence": list(execution_sequence),
|
||||
"initial_input_data": self._json_safe_copy(self._initial_input_data or {}),
|
||||
"steps_used": self._steps_used,
|
||||
"llm_invocations": self._llm_invocations,
|
||||
"tool_calls_used": self._tool_calls_used,
|
||||
"node_results_partial": self._json_safe_copy(results),
|
||||
}
|
||||
|
||||
async def _on_tool_executed_budget(self, tool_name: str) -> None:
|
||||
"""LLM function calling 每执行一次工具时回调,计入预算。"""
|
||||
_ = tool_name
|
||||
self._tool_calls_used += 1
|
||||
if self._tool_calls_used > self._cap_tool:
|
||||
raise WorkflowExecutionError(
|
||||
detail=f"已超过工具调用预算({self._cap_tool} 次)",
|
||||
)
|
||||
|
||||
def _get_persist_scope(self) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""(scope_kind, scope_id) 或 (None, None),用于持久化 user_memory_*。"""
|
||||
if self._persist_scope_cache is None:
|
||||
@@ -112,6 +186,67 @@ class WorkflowEngine:
|
||||
self._persist_scope_cache = parse_memory_scope(self.workflow_id)
|
||||
return self._persist_scope_cache
|
||||
|
||||
def _build_subworkflow_input(
|
||||
self, input_data: Dict[str, Any], input_mapping: Any
|
||||
) -> Dict[str, Any]:
|
||||
"""根据 mapping 组装子工作流输入。"""
|
||||
if not isinstance(input_mapping, dict):
|
||||
return input_data if isinstance(input_data, dict) else {"input": input_data}
|
||||
|
||||
sub_input: Dict[str, Any] = {}
|
||||
for k, v in input_mapping.items():
|
||||
if isinstance(v, str) and isinstance(input_data, dict):
|
||||
# 支持字段名、{field}、以及嵌套路径
|
||||
vv = (
|
||||
input_data.get(v)
|
||||
or input_data.get(v.strip("{}"))
|
||||
or self._get_nested_value(input_data, v.strip("{}"))
|
||||
)
|
||||
sub_input[k] = vv if vv is not None else v
|
||||
else:
|
||||
sub_input[k] = v
|
||||
return sub_input
|
||||
|
||||
def _resolve_subworkflow_target(
|
||||
self, node_data: Dict[str, Any]
|
||||
) -> Tuple[str, str, Dict[str, Any]]:
|
||||
"""
|
||||
解析子工作流目标,返回 (target_type, target_id, workflow_data)。
|
||||
target_type: workflow | agent
|
||||
"""
|
||||
workflow_id = str(node_data.get("workflow_id") or "").strip()
|
||||
agent_id = str(
|
||||
node_data.get("agent_id")
|
||||
or node_data.get("target_agent_id")
|
||||
or ""
|
||||
).strip()
|
||||
if not workflow_id and not agent_id:
|
||||
raise ValueError("subworkflow 节点缺少 workflow_id 或 agent_id")
|
||||
|
||||
db = self.db or SessionLocal()
|
||||
own_db = self.db is None
|
||||
try:
|
||||
if agent_id:
|
||||
agent = db.query(Agent).filter(Agent.id == agent_id).first()
|
||||
if not agent:
|
||||
raise ValueError(f"目标 Agent 不存在: {agent_id}")
|
||||
cfg = agent.workflow_config or {}
|
||||
return "agent", agent_id, {
|
||||
"nodes": cfg.get("nodes", []),
|
||||
"edges": cfg.get("edges", []),
|
||||
}
|
||||
|
||||
wf = db.query(Workflow).filter(Workflow.id == workflow_id).first()
|
||||
if not wf:
|
||||
raise ValueError(f"目标工作流不存在: {workflow_id}")
|
||||
return "workflow", workflow_id, {
|
||||
"nodes": wf.nodes or [],
|
||||
"edges": wf.edges or [],
|
||||
}
|
||||
finally:
|
||||
if own_db:
|
||||
db.close()
|
||||
|
||||
def _looks_like_vector_upsert_payload(self, d: Any) -> bool:
|
||||
"""判断是否为向量写入/upsert 返回的元数据(非用户可见话术)。"""
|
||||
if not isinstance(d, dict):
|
||||
@@ -1090,8 +1225,64 @@ class WorkflowEngine:
|
||||
duration = int((time.time() - start_time) * 1000)
|
||||
self.logger.log_node_complete(node_id, node_type, result.get('output'), duration)
|
||||
return result
|
||||
|
||||
elif node_type == 'approval':
|
||||
# 人工审批(HITL):无 __hil_decision 时挂起,由 Celery 落库 awaiting_approval
|
||||
nd = node.get('data', {}) or {}
|
||||
message = nd.get('message', '需要人工审批')
|
||||
approved_handle = nd.get('approved_branch', 'approved')
|
||||
rejected_handle = nd.get('rejected_branch', 'rejected')
|
||||
root = self._initial_input_data if isinstance(self._initial_input_data, dict) else {}
|
||||
merged: Dict[str, Any] = {**root}
|
||||
if isinstance(input_data, dict):
|
||||
merged = {**merged, **input_data}
|
||||
decision = merged.get('__hil_decision')
|
||||
comment = merged.get('__hil_comment')
|
||||
if decision == 'approved':
|
||||
out = {
|
||||
'approved': True,
|
||||
'message': message,
|
||||
'comment': comment,
|
||||
'input': input_data,
|
||||
}
|
||||
if self.logger:
|
||||
duration = int((time.time() - start_time) * 1000)
|
||||
self.logger.log_node_complete(node_id, node_type, out, duration)
|
||||
return {
|
||||
'output': out,
|
||||
'status': 'success',
|
||||
'branch': approved_handle,
|
||||
}
|
||||
if decision == 'rejected':
|
||||
out = {
|
||||
'approved': False,
|
||||
'message': message,
|
||||
'comment': comment,
|
||||
'input': input_data,
|
||||
}
|
||||
if self.logger:
|
||||
duration = int((time.time() - start_time) * 1000)
|
||||
self.logger.log_node_complete(node_id, node_type, out, duration)
|
||||
return {
|
||||
'output': out,
|
||||
'status': 'success',
|
||||
'branch': rejected_handle,
|
||||
}
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
f"审批节点等待人工决策: {message}",
|
||||
node_id=node_id,
|
||||
node_type='approval',
|
||||
)
|
||||
return {'status': 'awaiting_approval'}
|
||||
|
||||
elif node_type == 'llm' or node_type == 'template':
|
||||
self._llm_invocations += 1
|
||||
if self._llm_invocations > self._cap_llm:
|
||||
raise WorkflowExecutionError(
|
||||
detail=f"已超过 LLM 节点调用预算({self._cap_llm} 次)",
|
||||
node_id=node_id,
|
||||
)
|
||||
# LLM节点:调用AI模型
|
||||
node_data = node.get('data', {})
|
||||
logger.debug(f"[rjb] LLM节点执行: node_id={node_id}, input_data={input_data}, input_data type={type(input_data)}")
|
||||
@@ -1406,6 +1597,16 @@ class WorkflowEngine:
|
||||
_tool_choice = node_data.get("tool_choice")
|
||||
if not (isinstance(_tool_choice, str) and _tool_choice.strip()):
|
||||
_tool_choice = None
|
||||
# 单次执行内工具多轮迭代(默认 5,见 llm_service);节点可配 max_tool_iterations
|
||||
_tool_extra: Dict[str, Any] = {}
|
||||
_mi = node_data.get("max_tool_iterations") or node_data.get(
|
||||
"max_tool_call_rounds"
|
||||
)
|
||||
if _mi is not None:
|
||||
try:
|
||||
_tool_extra["max_iterations"] = max(1, min(int(_mi), 64))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
result = await llm_service.call_llm_with_tools(
|
||||
prompt=prompt,
|
||||
tools=tools,
|
||||
@@ -1415,6 +1616,8 @@ class WorkflowEngine:
|
||||
max_tokens=max_tokens,
|
||||
execution_logger=self.logger,
|
||||
tool_choice=_tool_choice,
|
||||
on_tool_executed=self._on_tool_executed_budget,
|
||||
**_tool_extra,
|
||||
)
|
||||
result = self._enrich_llm_json_user_profile(result, input_data)
|
||||
else:
|
||||
@@ -4434,30 +4637,106 @@ class WorkflowEngine:
|
||||
'error': f'Excel处理失败: {str(e)}。注意:需要安装openpyxl或pandas库(pip install openpyxl pandas)'
|
||||
}
|
||||
|
||||
elif node_type == 'subworkflow':
|
||||
# 子工作流节点:调用其他工作流
|
||||
elif node_type == 'subworkflow' or node_type == 'invoke_agent':
|
||||
# 子工作流/委派节点:调用其他工作流或 Agent
|
||||
node_data = node.get('data', {})
|
||||
workflow_id = node_data.get('workflow_id', '')
|
||||
input_mapping = node_data.get('input_mapping', {})
|
||||
try:
|
||||
# 将当前输入根据映射转换为子工作流输入
|
||||
sub_input = {}
|
||||
if isinstance(input_mapping, dict):
|
||||
for k, v in input_mapping.items():
|
||||
if isinstance(v, str) and isinstance(input_data, dict):
|
||||
sub_input[k] = input_data.get(v) or input_data.get(v.strip('{}')) or v
|
||||
else:
|
||||
sub_input[k] = v
|
||||
else:
|
||||
sub_input = input_data
|
||||
max_depth = int(node_data.get('max_subworkflow_depth', 2) or 2)
|
||||
if max_depth < 1:
|
||||
max_depth = 1
|
||||
cur_depth = 0
|
||||
if isinstance(input_data, dict):
|
||||
try:
|
||||
cur_depth = int(input_data.get('__subworkflow_depth', 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
cur_depth = 0
|
||||
if cur_depth >= max_depth:
|
||||
raise ValueError(
|
||||
f"子工作流调用深度超限: current={cur_depth}, max={max_depth}"
|
||||
)
|
||||
|
||||
# 将当前输入根据映射转换为子工作流输入
|
||||
sub_input = self._build_subworkflow_input(input_data, input_mapping)
|
||||
if isinstance(sub_input, dict):
|
||||
sub_input['__subworkflow_depth'] = cur_depth + 1
|
||||
|
||||
if node_type == 'invoke_agent' and not node_data.get('agent_id'):
|
||||
_aid = node_data.get('target_agent_id')
|
||||
if _aid:
|
||||
node_data = {**node_data, 'agent_id': _aid}
|
||||
|
||||
target_type, target_id, sub_workflow_data = self._resolve_subworkflow_target(node_data)
|
||||
sub_workflow_id = (
|
||||
f"agent_{target_id}" if target_type == "agent" else target_id
|
||||
)
|
||||
child_execution = None
|
||||
child_logger = self.logger
|
||||
sub_started_at = time.time()
|
||||
parent_execution_id = None
|
||||
if self.logger and getattr(self.logger, "execution_id", None):
|
||||
parent_execution_id = str(self.logger.execution_id)
|
||||
|
||||
if self.db is not None:
|
||||
child_execution = Execution(
|
||||
workflow_id=target_id if target_type == "workflow" else None,
|
||||
agent_id=target_id if target_type == "agent" else None,
|
||||
input_data=sub_input,
|
||||
status="running",
|
||||
parent_execution_id=parent_execution_id,
|
||||
depth=cur_depth + 1,
|
||||
)
|
||||
self.db.add(child_execution)
|
||||
self.db.commit()
|
||||
self.db.refresh(child_execution)
|
||||
child_logger = ExecutionLogger(str(child_execution.id), self.db)
|
||||
child_logger.info(
|
||||
f"子工作流开始执行: target_type={target_type}, target_id={target_id}",
|
||||
node_id=node_id,
|
||||
node_type=node_type,
|
||||
data={"parent_execution_id": parent_execution_id, "depth": cur_depth + 1},
|
||||
)
|
||||
|
||||
from app.services.execution_budget import merge_budget_for_execution
|
||||
|
||||
if self.db is not None and child_execution is not None:
|
||||
child_budget = merge_budget_for_execution(self.db, child_execution)
|
||||
else:
|
||||
child_budget = self.budget_limits
|
||||
child_engine = WorkflowEngine(
|
||||
sub_workflow_id,
|
||||
sub_workflow_data,
|
||||
logger=child_logger,
|
||||
db=self.db,
|
||||
budget_limits=child_budget,
|
||||
)
|
||||
try:
|
||||
child_result = await child_engine.execute(sub_input)
|
||||
if child_execution is not None:
|
||||
child_execution.status = "completed"
|
||||
child_execution.output_data = child_result
|
||||
child_execution.execution_time = int(
|
||||
(time.time() - sub_started_at) * 1000
|
||||
)
|
||||
self.db.commit()
|
||||
except Exception as sub_e:
|
||||
if child_execution is not None:
|
||||
child_execution.status = "failed"
|
||||
child_execution.error_message = str(sub_e)
|
||||
child_execution.execution_time = int(
|
||||
(time.time() - sub_started_at) * 1000
|
||||
)
|
||||
self.db.commit()
|
||||
raise
|
||||
|
||||
# 实际调用子工作流的执行,这里简化为回传映射后的输入
|
||||
# TODO: 集成 WorkflowEngine 执行指定 workflow_id
|
||||
result = {
|
||||
'workflow_id': workflow_id,
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'child_execution_id': str(child_execution.id) if child_execution is not None else None,
|
||||
'input': sub_input,
|
||||
'status': 'success',
|
||||
'note': '子工作流执行框架占位,需集成实际调用'
|
||||
'status': child_result.get('status', 'completed'),
|
||||
'result': child_result.get('result'),
|
||||
'node_results': child_result.get('node_results'),
|
||||
}
|
||||
exec_result = {'output': result, 'status': 'success'}
|
||||
if self.logger:
|
||||
@@ -4784,29 +5063,63 @@ class WorkflowEngine:
|
||||
}
|
||||
|
||||
|
||||
async def execute(self, input_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
async def execute(
|
||||
self,
|
||||
input_data: Dict[str, Any],
|
||||
resume_snapshot: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
执行完整工作流
|
||||
|
||||
Args:
|
||||
input_data: 初始输入数据
|
||||
input_data: 初始输入数据(恢复执行时须包含 __hil_decision 等)
|
||||
resume_snapshot: 从挂起快照恢复(与 pause_state 一致)
|
||||
|
||||
Returns:
|
||||
执行结果
|
||||
"""
|
||||
if not resume_snapshot and isinstance(input_data, dict):
|
||||
dsl_box = input_data.get("scenario_dsl")
|
||||
if dsl_box is None and isinstance(input_data.get("scenario"), dict):
|
||||
dsl_box = input_data["scenario"]
|
||||
if dsl_box is not None:
|
||||
ok, errs = validate_scenario_dsl(dsl_box)
|
||||
if not ok:
|
||||
raise WorkflowExecutionError(
|
||||
detail="scenario_dsl 校验失败: " + "; ".join(errs),
|
||||
)
|
||||
norm = normalize_scenario_dsl(dsl_box)
|
||||
input_data = {**input_data, "_scenario": norm}
|
||||
|
||||
self._initial_input_data = input_data
|
||||
# 记录工作流开始执行
|
||||
if self.logger:
|
||||
self.logger.info("工作流开始执行", data={"input": input_data})
|
||||
|
||||
# 初始化节点输出
|
||||
self.node_outputs = {}
|
||||
active_edges = self.edges.copy() # 活跃的边列表
|
||||
executed_nodes = set() # 已执行的节点
|
||||
execution_sequence: List[str] = [] # 实际执行顺序(用于最终输出节点选择)
|
||||
self.logger.info(
|
||||
"工作流开始执行",
|
||||
data={"input": input_data, "resume": bool(resume_snapshot)},
|
||||
)
|
||||
|
||||
if resume_snapshot:
|
||||
self.node_outputs = self._json_safe_copy(resume_snapshot.get("node_outputs", {}))
|
||||
active_edges = list(resume_snapshot.get("active_edges", []))
|
||||
executed_nodes = set(resume_snapshot.get("executed_nodes", []))
|
||||
execution_sequence = list(resume_snapshot.get("execution_sequence", []))
|
||||
self._steps_used = int(resume_snapshot.get("steps_used", 0))
|
||||
self._llm_invocations = int(resume_snapshot.get("llm_invocations", 0))
|
||||
self._tool_calls_used = int(resume_snapshot.get("tool_calls_used", 0))
|
||||
results = self._json_safe_copy(resume_snapshot.get("node_results_partial", {}))
|
||||
else:
|
||||
# 初始化节点输出
|
||||
self.node_outputs = {}
|
||||
active_edges = self.edges.copy() # 活跃的边列表
|
||||
executed_nodes = set() # 已执行的节点
|
||||
execution_sequence = [] # 实际执行顺序(用于最终输出节点选择)
|
||||
self._steps_used = 0
|
||||
self._llm_invocations = 0
|
||||
self._tool_calls_used = 0
|
||||
results = {}
|
||||
|
||||
# 按拓扑顺序执行节点(动态构建执行图)
|
||||
results = {}
|
||||
|
||||
while True:
|
||||
# 构建当前活跃的执行图
|
||||
execution_order = self.build_execution_graph(active_edges)
|
||||
@@ -4853,8 +5166,10 @@ class WorkflowEngine:
|
||||
break # 没有更多节点可执行
|
||||
|
||||
node = self.nodes[next_node_id]
|
||||
executed_nodes.add(next_node_id)
|
||||
execution_sequence.append(next_node_id)
|
||||
is_approval = node.get("type") == "approval"
|
||||
if not is_approval:
|
||||
executed_nodes.add(next_node_id)
|
||||
execution_sequence.append(next_node_id)
|
||||
|
||||
# 调试:检查节点数据结构
|
||||
if node.get('type') == 'llm':
|
||||
@@ -4873,9 +5188,29 @@ class WorkflowEngine:
|
||||
logger.info(f"[rjb] LLM节点输入: node_id={next_node_id}, node_input={node_input}, node_outputs keys={list(self.node_outputs.keys())}")
|
||||
if 'start-1' in self.node_outputs:
|
||||
logger.info(f"[rjb] Start节点输出内容: {self.node_outputs['start-1']}")
|
||||
|
||||
|
||||
# 单执行步数预算(每执行一个节点计 1 步)
|
||||
self._steps_used += 1
|
||||
if self._steps_used > self._cap_steps:
|
||||
raise WorkflowExecutionError(
|
||||
detail=f"已超过单执行预算上限({self._cap_steps} 步),已熔断",
|
||||
node_id=next_node_id,
|
||||
)
|
||||
|
||||
# 执行节点
|
||||
result = await self.execute_node(node, node_input)
|
||||
|
||||
if result.get("status") == "awaiting_approval":
|
||||
self._steps_used -= 1
|
||||
snap = self._build_pause_snapshot(
|
||||
next_node_id, active_edges, executed_nodes, execution_sequence, results
|
||||
)
|
||||
raise WorkflowPaused(snap)
|
||||
|
||||
if is_approval:
|
||||
executed_nodes.add(next_node_id)
|
||||
execution_sequence.append(next_node_id)
|
||||
|
||||
results[next_node_id] = result
|
||||
|
||||
# 保存节点输出
|
||||
@@ -4988,6 +5323,31 @@ class WorkflowEngine:
|
||||
node_type='switch',
|
||||
data=filter_info
|
||||
)
|
||||
|
||||
elif node.get('type') == 'approval':
|
||||
branch = result.get('branch', 'approved')
|
||||
logger.info(f"[rjb] Approval节点分支过滤: node_id={next_node_id}, branch={branch}")
|
||||
edges_to_keep = []
|
||||
removed_source_nodes = set()
|
||||
for edge in active_edges:
|
||||
if edge['source'] == next_node_id:
|
||||
edge_handle = edge.get('sourceHandle')
|
||||
if edge_handle == branch:
|
||||
edges_to_keep.append(edge)
|
||||
else:
|
||||
removed_source_nodes.add(edge.get('target'))
|
||||
else:
|
||||
edges_to_keep.append(edge)
|
||||
for edge in list(edges_to_keep):
|
||||
if edge['source'] in removed_source_nodes:
|
||||
edges_to_keep.remove(edge)
|
||||
active_edges = edges_to_keep
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
f"Approval节点分支过滤: branch={branch}",
|
||||
node_id=next_node_id,
|
||||
node_type='approval',
|
||||
)
|
||||
|
||||
# 如果是循环节点,跳过循环体的节点(循环体已在节点内部执行)
|
||||
if node.get('type') in ['loop', 'foreach']:
|
||||
|
||||
@@ -71,7 +71,7 @@ class WorkflowValidator:
|
||||
node_type = node.get('type')
|
||||
if not node_type:
|
||||
self.errors.append(f"节点 {node_id} 缺少类型")
|
||||
elif node_type not in ['start', 'input', 'llm', 'condition', 'transform', 'output', 'end', 'default', 'loop', 'foreach', 'loop_end', 'agent', 'http', 'request', 'database', 'db', 'file', 'file_operation', 'schedule', 'delay', 'timer', 'webhook', 'email', 'mail', 'message_queue', 'mq', 'rabbitmq', 'kafka', 'switch', 'merge', 'wait', 'json', 'text', 'cache', 'vector_db', 'log', 'error_handler', 'csv', 'object_storage', 'slack', 'dingtalk', 'dingding', 'wechat_work', 'wecom', 'sms', 'pdf', 'image', 'excel', 'subworkflow', 'code', 'oauth', 'validator', 'batch']:
|
||||
elif node_type not in ['start', 'input', 'llm', 'condition', 'transform', 'output', 'end', 'default', 'loop', 'foreach', 'loop_end', 'agent', 'http', 'request', 'database', 'db', 'file', 'file_operation', 'schedule', 'delay', 'timer', 'webhook', 'email', 'mail', 'message_queue', 'mq', 'rabbitmq', 'kafka', 'switch', 'merge', 'wait', 'json', 'text', 'cache', 'vector_db', 'log', 'error_handler', 'csv', 'object_storage', 'slack', 'dingtalk', 'dingding', 'wechat_work', 'wecom', 'sms', 'pdf', 'image', 'excel', 'subworkflow', 'invoke_agent', 'code', 'oauth', 'validator', 'batch', 'approval']:
|
||||
self.warnings.append(f"节点 {node_id} 使用了未知类型: {node_type}")
|
||||
|
||||
def _validate_edges(self):
|
||||
|
||||
@@ -11,12 +11,16 @@ from app.services.workflow_engine import WorkflowEngine
|
||||
from app.services.execution_logger import ExecutionLogger
|
||||
from app.services.alert_service import AlertService
|
||||
from app.core.database import SessionLocal
|
||||
from app.core.config import settings
|
||||
from app.core.exceptions import WorkflowExecutionError, WorkflowPaused
|
||||
# 导入所有相关模型,确保关系可以正确解析
|
||||
from app.models.execution import Execution
|
||||
from app.models.agent import Agent
|
||||
from app.models.workflow import Workflow
|
||||
from app.services.execution_budget import merge_budget_for_execution
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def _format_task_error(e: Exception) -> str:
|
||||
@@ -30,22 +34,30 @@ def _format_task_error(e: Exception) -> str:
|
||||
return s if s else repr(e)
|
||||
|
||||
|
||||
def _snapshot_to_jsonable(snapshot: dict) -> dict:
|
||||
import json as _json
|
||||
|
||||
return _json.loads(_json.dumps(snapshot, default=str))
|
||||
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def execute_workflow_task(
|
||||
self,
|
||||
execution_id: str,
|
||||
workflow_id: str,
|
||||
workflow_data: dict,
|
||||
input_data: dict
|
||||
input_data: dict,
|
||||
resume_snapshot: Optional[dict] = None,
|
||||
):
|
||||
"""
|
||||
执行工作流任务
|
||||
|
||||
Args:
|
||||
Args:
|
||||
execution_id: 执行记录ID
|
||||
workflow_id: 工作流ID
|
||||
workflow_data: 工作流数据(nodes和edges)
|
||||
input_data: 输入数据
|
||||
resume_snapshot: 从挂起恢复时的快照(与 Execution.pause_state 一致)
|
||||
"""
|
||||
db = SessionLocal()
|
||||
start_time = time.time()
|
||||
@@ -65,11 +77,59 @@ def execute_workflow_task(
|
||||
execution_logger = ExecutionLogger(execution_id, db)
|
||||
execution_logger.info("工作流任务开始执行")
|
||||
|
||||
# 创建工作流引擎(传入logger和db)
|
||||
engine = WorkflowEngine(workflow_id, workflow_data, logger=execution_logger, db=db)
|
||||
|
||||
# 执行工作流(异步)
|
||||
result = asyncio.run(engine.execute(input_data))
|
||||
# 创建工作流引擎(传入logger、db、合并后的执行预算)
|
||||
budget = merge_budget_for_execution(db, execution) if execution else {}
|
||||
engine = WorkflowEngine(
|
||||
workflow_id,
|
||||
workflow_data,
|
||||
logger=execution_logger,
|
||||
db=db,
|
||||
budget_limits=budget,
|
||||
)
|
||||
|
||||
max_retries = max(0, int(getattr(settings, "WORKFLOW_TASK_MAX_RETRIES", 0) or 0))
|
||||
result: Optional[dict] = None
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
result = asyncio.run(
|
||||
engine.execute(input_data, resume_snapshot=resume_snapshot)
|
||||
)
|
||||
break
|
||||
except WorkflowPaused as paused:
|
||||
execution = db.query(Execution).filter(Execution.id == execution_id).first()
|
||||
if execution:
|
||||
execution.status = "awaiting_approval"
|
||||
execution.pause_state = _snapshot_to_jsonable(paused.snapshot)
|
||||
execution.error_message = None
|
||||
db.commit()
|
||||
execution_logger.info(
|
||||
"工作流在审批节点挂起,等待人工决策",
|
||||
data={"pending_node_id": paused.snapshot.get("pending_node_id")},
|
||||
)
|
||||
return {
|
||||
"status": "awaiting_approval",
|
||||
"execution_id": execution_id,
|
||||
"pending_node_id": paused.snapshot.get("pending_node_id"),
|
||||
}
|
||||
except WorkflowExecutionError:
|
||||
raise
|
||||
except Exception as run_e:
|
||||
if attempt >= max_retries:
|
||||
raise
|
||||
delay = min(30.0, 1.5**attempt)
|
||||
if execution_logger:
|
||||
execution_logger.warn(
|
||||
f"工作流执行异常,将退避重试 ({attempt + 1}/{max_retries}): {_format_task_error(run_e)}",
|
||||
data={
|
||||
"error_code": "WORKFLOW_TASK_TRANSIENT_RETRY",
|
||||
"attempt": attempt + 1,
|
||||
"max_retries": max_retries,
|
||||
"delay_sec": round(delay, 2),
|
||||
},
|
||||
)
|
||||
time.sleep(delay)
|
||||
if result is None:
|
||||
raise RuntimeError("工作流执行未返回结果")
|
||||
|
||||
# 计算执行时间
|
||||
execution_time = int((time.time() - start_time) * 1000)
|
||||
@@ -79,6 +139,7 @@ def execute_workflow_task(
|
||||
execution.status = "completed"
|
||||
execution.output_data = result
|
||||
execution.execution_time = execution_time
|
||||
execution.pause_state = None
|
||||
db.commit()
|
||||
|
||||
# 记录执行完成日志
|
||||
@@ -103,8 +164,18 @@ def execute_workflow_task(
|
||||
|
||||
# 记录错误日志
|
||||
err_text = _format_task_error(e)
|
||||
err_code = getattr(e, "error_code", None)
|
||||
if not err_code:
|
||||
err_code = (
|
||||
"WORKFLOW_EXECUTION_ERROR"
|
||||
if isinstance(e, WorkflowExecutionError)
|
||||
else "WORKFLOW_TASK_ERROR"
|
||||
)
|
||||
if execution_logger:
|
||||
execution_logger.error(f"工作流任务执行失败: {err_text}", data={"error_type": type(e).__name__})
|
||||
execution_logger.error(
|
||||
f"工作流任务执行失败: {err_text}",
|
||||
data={"error_type": type(e).__name__, "error_code": err_code},
|
||||
)
|
||||
|
||||
# 更新执行记录为失败
|
||||
execution = db.query(Execution).filter(Execution.id == execution_id).first()
|
||||
@@ -127,3 +198,177 @@ def execute_workflow_task(
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def resume_workflow_task(
|
||||
self,
|
||||
execution_id: str,
|
||||
decision: str,
|
||||
comment: Optional[str] = None,
|
||||
):
|
||||
"""在 awaiting_approval 时恢复执行(审批通过/拒绝)。"""
|
||||
db = SessionLocal()
|
||||
start_time = time.time()
|
||||
execution_logger = None
|
||||
|
||||
try:
|
||||
execution = db.query(Execution).filter(Execution.id == execution_id).first()
|
||||
if not execution:
|
||||
return {"status": "error", "detail": "执行记录不存在"}
|
||||
if execution.status != "awaiting_approval":
|
||||
return {
|
||||
"status": "error",
|
||||
"detail": f"执行状态不是 awaiting_approval: {execution.status}",
|
||||
}
|
||||
if not execution.pause_state:
|
||||
return {"status": "error", "detail": "缺少 pause_state"}
|
||||
|
||||
if decision not in ("approved", "rejected"):
|
||||
return {"status": "error", "detail": "decision 须为 approved 或 rejected"}
|
||||
|
||||
snapshot = execution.pause_state
|
||||
base_input: Dict[str, Any] = dict(execution.input_data or {})
|
||||
base_input["__hil_decision"] = decision
|
||||
if comment:
|
||||
base_input["__hil_comment"] = comment
|
||||
|
||||
execution.status = "running"
|
||||
execution.error_message = None
|
||||
execution.input_data = base_input
|
||||
db.commit()
|
||||
|
||||
execution_logger = ExecutionLogger(execution_id, db)
|
||||
execution_logger.info("审批恢复执行", data={"decision": decision})
|
||||
|
||||
workflow_data: dict
|
||||
wf_key: str
|
||||
if execution.workflow_id:
|
||||
wf = db.query(Workflow).filter(Workflow.id == execution.workflow_id).first()
|
||||
if not wf:
|
||||
raise RuntimeError("工作流不存在")
|
||||
workflow_data = {"nodes": wf.nodes, "edges": wf.edges}
|
||||
wf_key = str(execution.workflow_id)
|
||||
elif execution.agent_id:
|
||||
ag = db.query(Agent).filter(Agent.id == execution.agent_id).first()
|
||||
if not ag or not ag.workflow_config:
|
||||
raise RuntimeError("Agent 或工作流配置不存在")
|
||||
workflow_data = {
|
||||
"nodes": ag.workflow_config.get("nodes", []),
|
||||
"edges": ag.workflow_config.get("edges", []),
|
||||
}
|
||||
wf_key = f"agent_{execution.agent_id}"
|
||||
else:
|
||||
raise RuntimeError("执行未关联工作流或 Agent")
|
||||
|
||||
self.update_state(state="PROGRESS", meta={"progress": 0, "status": "running"})
|
||||
|
||||
budget = merge_budget_for_execution(db, execution) if execution else {}
|
||||
engine = WorkflowEngine(
|
||||
wf_key,
|
||||
workflow_data,
|
||||
logger=execution_logger,
|
||||
db=db,
|
||||
budget_limits=budget,
|
||||
)
|
||||
max_retries = max(0, int(getattr(settings, "WORKFLOW_TASK_MAX_RETRIES", 0) or 0))
|
||||
result: Optional[dict] = None
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
result = asyncio.run(
|
||||
engine.execute(base_input, resume_snapshot=snapshot)
|
||||
)
|
||||
break
|
||||
except WorkflowPaused as paused:
|
||||
ex2 = db.query(Execution).filter(Execution.id == execution_id).first()
|
||||
if ex2:
|
||||
ex2.status = "awaiting_approval"
|
||||
ex2.pause_state = _snapshot_to_jsonable(paused.snapshot)
|
||||
ex2.error_message = None
|
||||
db.commit()
|
||||
if execution_logger:
|
||||
execution_logger.info(
|
||||
"工作流再次在审批节点挂起",
|
||||
data={"pending_node_id": paused.snapshot.get("pending_node_id")},
|
||||
)
|
||||
return {
|
||||
"status": "awaiting_approval",
|
||||
"execution_id": execution_id,
|
||||
"pending_node_id": paused.snapshot.get("pending_node_id"),
|
||||
}
|
||||
except WorkflowExecutionError:
|
||||
raise
|
||||
except Exception as run_e:
|
||||
if attempt >= max_retries:
|
||||
raise
|
||||
delay = min(30.0, 1.5**attempt)
|
||||
if execution_logger:
|
||||
execution_logger.warn(
|
||||
f"恢复执行异常,将退避重试 ({attempt + 1}/{max_retries}): {_format_task_error(run_e)}",
|
||||
data={
|
||||
"error_code": "WORKFLOW_TASK_TRANSIENT_RETRY",
|
||||
"attempt": attempt + 1,
|
||||
"max_retries": max_retries,
|
||||
"delay_sec": round(delay, 2),
|
||||
},
|
||||
)
|
||||
time.sleep(delay)
|
||||
if result is None:
|
||||
raise RuntimeError("工作流执行未返回结果")
|
||||
|
||||
execution_time = int((time.time() - start_time) * 1000)
|
||||
ex3 = db.query(Execution).filter(Execution.id == execution_id).first()
|
||||
if ex3:
|
||||
ex3.status = "completed"
|
||||
ex3.output_data = result
|
||||
ex3.execution_time = execution_time
|
||||
ex3.pause_state = None
|
||||
db.commit()
|
||||
|
||||
if execution_logger:
|
||||
execution_logger.info(f"审批后工作流执行完成,耗时: {execution_time}ms")
|
||||
|
||||
if ex3:
|
||||
try:
|
||||
asyncio.run(AlertService.check_alerts_for_execution(db, ex3))
|
||||
except Exception as e:
|
||||
if execution_logger:
|
||||
execution_logger.warn(f"告警检测失败: {str(e)}")
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"result": result,
|
||||
"execution_time": execution_time,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
execution_time = int((time.time() - start_time) * 1000)
|
||||
err_text = _format_task_error(e)
|
||||
err_code = getattr(e, "error_code", None)
|
||||
if not err_code:
|
||||
err_code = (
|
||||
"WORKFLOW_EXECUTION_ERROR"
|
||||
if isinstance(e, WorkflowExecutionError)
|
||||
else "WORKFLOW_TASK_ERROR"
|
||||
)
|
||||
if execution_logger:
|
||||
execution_logger.error(
|
||||
f"审批恢复执行失败: {err_text}",
|
||||
data={"error_type": type(e).__name__, "error_code": err_code},
|
||||
)
|
||||
execution = db.query(Execution).filter(Execution.id == execution_id).first()
|
||||
if execution:
|
||||
execution.status = "failed"
|
||||
execution.error_message = err_text
|
||||
execution.execution_time = execution_time
|
||||
db.commit()
|
||||
if execution:
|
||||
try:
|
||||
asyncio.run(AlertService.check_alerts_for_execution(db, execution))
|
||||
except Exception as e2:
|
||||
if execution_logger:
|
||||
execution_logger.warn(f"告警检测失败: {str(e2)}")
|
||||
raise
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
Reference in New Issue
Block a user