mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 21:21:59 +00:00
964eeaa829
在画布上拖拽编排 agent 工作流,并实时看着它执行。 后端 FastAPI + LangGraph:把画布 JSON 编译成 StateGraph,15 种节点 (模型/Agent/多Agent、工具/沙箱、分支/循环/子图、记忆/检索/整形、 人工介入/结构校验)。选 LangGraph 是因为可视化编排真正难的是暂停、 恢复和回放,而 checkpointer + interrupt() 正好把这几件事做在了底层。 前端 React + React Flow:节点元数据驱动属性面板与连接桩,运行时 节点高亮、边流动画、卡片内流式 token 全部由同一份事件流驱动。 关键实现: - Agent 内工具审批用 @task 缓存模型调用,interrupt 重放时不重复计费 - 沙箱保持只读根文件系统,代码经 exec+base64 写入(put_archive 会被 只读容器拒绝),断网、非 root、内存/CPU/超时限额 - 检索为向量与 BM25 混合,本地哈希 embedding 零配置可用 - 分支条件走 AST 白名单求值器;HTTP 工具逐 IP 拦截内网与元数据地址 - Mock provider 让无 API Key 也能跑通完整链路 7 个内置模板全部冒烟通过,含真实模型端到端验证。
218 lines
7.6 KiB
Python
218 lines
7.6 KiB
Python
from __future__ import annotations
|
|
|
|
from enum import StrEnum
|
|
from typing import Any, Literal
|
|
|
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
|
|
|
|
class NodeType(StrEnum):
|
|
"""画布上可用的节点类型。新增类型 = 加一个枚举值 + 一个执行器 + 一个前端渲染。"""
|
|
|
|
INPUT = "input"
|
|
OUTPUT = "output"
|
|
|
|
LLM = "llm" # 单次模型调用
|
|
AGENT = "agent" # 带工具循环的 ReAct agent
|
|
SUPERVISOR = "supervisor" # 多 agent 调度
|
|
|
|
TOOL = "tool" # 直接调用一个工具
|
|
CODE = "code" # 沙箱里跑代码
|
|
|
|
BRANCH = "branch" # 条件分支
|
|
LOOP = "loop" # 循环 / 迭代
|
|
SUBGRAPH = "subgraph" # 嵌套另一个工作流
|
|
|
|
MEMORY = "memory" # 长期记忆读写
|
|
RETRIEVE = "retrieve" # 知识库检索
|
|
TRANSFORM = "transform" # 数据整形
|
|
|
|
HUMAN = "human" # 人工介入
|
|
VALIDATE = "validate" # 结构化校验 + 自动重试
|
|
|
|
|
|
class Position(BaseModel):
|
|
x: float = 0
|
|
y: float = 0
|
|
|
|
|
|
class NodeData(BaseModel):
|
|
"""节点上的可编辑内容。config 保持松散 dict,各执行器自己解析成强类型模型,
|
|
这样前端加字段不会让整张图反序列化失败。"""
|
|
|
|
label: str = ""
|
|
description: str = ""
|
|
config: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class GraphNode(BaseModel):
|
|
id: str
|
|
type: NodeType
|
|
position: Position = Field(default_factory=Position)
|
|
data: NodeData = Field(default_factory=NodeData)
|
|
|
|
@field_validator("id")
|
|
@classmethod
|
|
def _valid_id(cls, v: str) -> str:
|
|
if not v or not v.replace("_", "").replace("-", "").isalnum():
|
|
raise ValueError(f"节点 id 只能包含字母数字、下划线和连字符:{v!r}")
|
|
# LangGraph 保留了这两个名字
|
|
if v in ("__start__", "__end__"):
|
|
raise ValueError(f"{v!r} 是保留节点名")
|
|
return v
|
|
|
|
@property
|
|
def config(self) -> dict[str, Any]:
|
|
return self.data.config
|
|
|
|
@property
|
|
def title(self) -> str:
|
|
return self.data.label or self.id
|
|
|
|
|
|
class GraphEdge(BaseModel):
|
|
id: str = ""
|
|
source: str
|
|
target: str
|
|
# 分支节点用 sourceHandle 区分走哪条路:"true"/"false" 或自定义 case key
|
|
sourceHandle: str | None = None
|
|
targetHandle: str | None = None
|
|
label: str = ""
|
|
|
|
@model_validator(mode="after")
|
|
def _fill_id(self) -> "GraphEdge":
|
|
if not self.id:
|
|
self.id = f"{self.source}:{self.sourceHandle or ''}->{self.target}"
|
|
return self
|
|
|
|
@property
|
|
def branch_key(self) -> str | None:
|
|
return self.sourceHandle if self.sourceHandle not in (None, "out", "source") else None
|
|
|
|
|
|
class GraphSpec(BaseModel):
|
|
"""一整张可执行的图。前端 React Flow 的画布状态直接序列化成它。"""
|
|
|
|
nodes: list[GraphNode] = Field(default_factory=list)
|
|
edges: list[GraphEdge] = Field(default_factory=list)
|
|
viewport: dict[str, Any] = Field(default_factory=dict)
|
|
# 图级别的默认值,节点没配 provider/model 时回退到这里
|
|
defaults: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
def node_map(self) -> dict[str, GraphNode]:
|
|
return {n.id: n for n in self.nodes}
|
|
|
|
def outgoing(self, node_id: str) -> list[GraphEdge]:
|
|
return [e for e in self.edges if e.source == node_id]
|
|
|
|
def incoming(self, node_id: str) -> list[GraphEdge]:
|
|
return [e for e in self.edges if e.target == node_id]
|
|
|
|
def entry_nodes(self) -> list[GraphNode]:
|
|
"""入口 = input 节点;没有 input 节点时退化为所有无入边的节点。"""
|
|
inputs = [n for n in self.nodes if n.type == NodeType.INPUT]
|
|
if inputs:
|
|
return inputs
|
|
return [n for n in self.nodes if not self.incoming(n.id)]
|
|
|
|
def terminal_nodes(self) -> list[GraphNode]:
|
|
outputs = [n for n in self.nodes if n.type == NodeType.OUTPUT]
|
|
if outputs:
|
|
return outputs
|
|
return [n for n in self.nodes if not self.outgoing(n.id)]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 校验
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
class ValidationIssue(BaseModel):
|
|
level: Literal["error", "warning"] = "error"
|
|
node_id: str | None = None
|
|
edge_id: str | None = None
|
|
message: str
|
|
|
|
|
|
class ValidationResult(BaseModel):
|
|
ok: bool = True
|
|
issues: list[ValidationIssue] = Field(default_factory=list)
|
|
|
|
def add(self, message: str, *, level: str = "error", node_id: str | None = None,
|
|
edge_id: str | None = None) -> None:
|
|
self.issues.append(
|
|
ValidationIssue(level=level, message=message, node_id=node_id, edge_id=edge_id) # type: ignore[arg-type]
|
|
)
|
|
if level == "error":
|
|
self.ok = False
|
|
|
|
|
|
def validate_graph(spec: GraphSpec) -> ValidationResult:
|
|
"""编译前的静态检查。错误会挡住运行,警告只在画布上提示。"""
|
|
result = ValidationResult()
|
|
nodes = spec.node_map()
|
|
|
|
if not spec.nodes:
|
|
result.add("图是空的,先拖一个节点进来")
|
|
return result
|
|
|
|
# 引用完整性
|
|
for edge in spec.edges:
|
|
if edge.source not in nodes:
|
|
result.add(f"边指向了不存在的源节点 {edge.source!r}", edge_id=edge.id)
|
|
if edge.target not in nodes:
|
|
result.add(f"边指向了不存在的目标节点 {edge.target!r}", edge_id=edge.id)
|
|
|
|
if not spec.entry_nodes():
|
|
result.add("找不到入口:每个节点都有入边,图里存在环且没有起点")
|
|
|
|
# 节点级必填项
|
|
for node in spec.nodes:
|
|
cfg = node.config
|
|
if node.type == NodeType.BRANCH:
|
|
cases = cfg.get("cases") or []
|
|
if not cases:
|
|
result.add("分支节点至少要配一个条件分支", node_id=node.id)
|
|
handles = {e.sourceHandle for e in spec.outgoing(node.id)}
|
|
for case in cases:
|
|
key = case.get("key")
|
|
if key and key not in handles:
|
|
result.add(
|
|
f"分支 {key!r} 没有连出去的边", level="warning", node_id=node.id
|
|
)
|
|
if "default" not in handles:
|
|
result.add(
|
|
"建议给分支节点连一条 default 边,兜住所有条件都不满足的情况",
|
|
level="warning",
|
|
node_id=node.id,
|
|
)
|
|
elif node.type == NodeType.TOOL:
|
|
if not cfg.get("tool"):
|
|
result.add("工具节点还没选工具", node_id=node.id)
|
|
elif node.type == NodeType.SUBGRAPH:
|
|
if not cfg.get("workflow_id"):
|
|
result.add("子图节点还没选要嵌套的工作流", node_id=node.id)
|
|
elif node.type == NodeType.VALIDATE:
|
|
if not cfg.get("schema"):
|
|
result.add("校验节点需要一个 JSON Schema", node_id=node.id)
|
|
elif node.type == NodeType.LOOP:
|
|
if not spec.outgoing(node.id):
|
|
result.add("循环节点没有循环体", node_id=node.id)
|
|
|
|
if node.type in (NodeType.LLM, NodeType.AGENT, NodeType.SUPERVISOR):
|
|
if not (cfg.get("model") or spec.defaults.get("model")):
|
|
result.add(
|
|
"没有指定模型,将回退到默认 provider",
|
|
level="warning",
|
|
node_id=node.id,
|
|
)
|
|
|
|
# 孤儿节点
|
|
for node in spec.nodes:
|
|
if node.type in (NodeType.INPUT, NodeType.OUTPUT):
|
|
continue
|
|
if not spec.incoming(node.id) and not spec.outgoing(node.id):
|
|
result.add("节点没有任何连线,不会被执行", level="warning", node_id=node.id)
|
|
|
|
return result
|