feat: 完善企业场景多线路由与执行稳定性

补齐平台模板与场景 DSL、预算控制、执行看板和企业场景脚本,增强 Windows 启动/迁移与前端代理和聊天会话记忆,修复执行创建阶段 500 与异步链路排障体验。

Made-with: Cursor
This commit is contained in:
renjianbo
2026-04-09 21:58:53 +08:00
parent bd3f8be781
commit 0608161c82
58 changed files with 6104 additions and 172 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View 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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -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")

View File

@@ -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)

View File

@@ -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")

View File

@@ -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="创建时间")
# 关系

View 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

View File

@@ -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:

View 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()

View 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

View File

@@ -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']:

View File

@@ -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):

View File

@@ -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()