mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:02:01 +00:00
c980da65d0
库里 28 条 failed 的构成说明了为什么值得单独开这条路:
模型 id 写错(404) 7
循环条件语法错 3
401 3
工具参数名不对 3
缺必填输入 2
大半是**配置错了**,改一下就能跑。而"改一下再跑"原来意味着整张图从头来
一遍——前面花两分钟查完的表结构、跑完的 SQL 全部重跑,尽管那些结果一直
躺在 checkpoint 里。checkpointer 本来就一直开着(thread_id = run_id),
挡住这条路的只是 resume 里的一行状态判定:它只认 interrupted,failed 进不去。
**带着改过的图续。** 不让改配置的话这功能基本没用:上面那几类续下去会
立刻再撞同一个错。所以 /runs/{id}/continue 接受一张改过的图,画布上点
「接着跑」带的就是当前编辑中的那张——用户正是改完它才点的。
**但只准改配置。** checkpoint 是按节点名存的(编译时 add_node(node.id)),
而节点配置是运行时从 spec 现读的(NodeContext.cfg)。所以只改配置对得上,
增删节点或改连线就对不上——续下去会拿着错位的通道状态跑出一份似是而非的
结果,比直接报错糟得多。加了 schema.topology_of() 做这道判据:节点 id +
类型 + 边,不含任何配置;对不上就 409,让用户重新发起。
这正是 resume 那边踩过的坑的同一类:不看有没有 checkpoint 就发 Command,
LangGraph 拿空 state 从 START 重跑,最后还落成 succeeded。所以这里也先问
一次 aget_state,没有待跑节点就拒绝,而不是硬发一个 None 下去。
时间线上要说清楚是接着跑、保了几步("从「lp」接着跑,前面 2 个节点的结果
保留")——否则用户看到时间线又动起来,分不清这次是续的还是整张图又跑了一遍。
验证:后端 249 passed(新增 test_continue_run.py 7 条,核心那条断言的是
node.started 事件——前面的节点确实没有再出现)。端到端真跑了一遍:
第一遍 start → prep → lp 挂在 lp,改好条件后第二遍只跑 lp → out,
成果里带着 prep 那步的结果;改了结构的图被 409 拒绝;没有失败的运行时
画布上不显示这个按钮。
顺带:_drive 里 resumed 的判定漏了 payload is None 这种(从 checkpoint
直接往下走),run.started 事件会说成不是续跑。
262 lines
10 KiB
Python
262 lines
10 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" # 结构化校验 + 自动重试
|
||
METRICS = "metrics" # 口径卡:受控指标集,叙述层唯一合法的数字来源
|
||
|
||
|
||
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 topology_of(spec: GraphSpec) -> tuple[tuple, tuple]:
|
||
"""图的骨架:节点的 id 与类型、加上所有的边。**不含任何配置。**
|
||
|
||
断点续跑要用它。LangGraph 的 checkpoint 是按节点名存的(编译时
|
||
`builder.add_node(node.id, …)`),而节点配置是运行时从 spec 现读的
|
||
(`NodeContext.cfg`)。所以拿一张只改了配置的图去续,checkpoint 对得上,
|
||
前面跑过的节点结果照样能用;一旦增删节点或改连线,checkpoint 里的
|
||
通道状态就和新图对不上了——续下去跑出来的是一份似是而非的结果,
|
||
比直接报错糟得多。
|
||
|
||
这正是 resume 那边踩过的坑:不看有没有 checkpoint 就发 Command,
|
||
LangGraph 拿空 state 从 START 重跑,最后还落成 succeeded。
|
||
"""
|
||
return (
|
||
tuple(sorted((n.id, str(n.type)) for n in spec.nodes)),
|
||
tuple(sorted((e.source, e.target, e.sourceHandle or "") for e in spec.edges)),
|
||
)
|
||
|
||
|
||
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.METRICS:
|
||
defs = cfg.get("metrics") or []
|
||
if not defs:
|
||
result.add("口径卡还没有定义指标", node_id=node.id)
|
||
for d in defs:
|
||
if not d.get("id") or not d.get("expression"):
|
||
result.add(f"指标定义缺 id 或 expression:{d.get('id') or '(空)'}",
|
||
node_id=node.id)
|
||
elif node.type == NodeType.OUTPUT:
|
||
contract = cfg.get("contract")
|
||
if contract and not contract.get("metrics_from"):
|
||
result.add("出具契约缺 metrics_from(指标来自哪个口径卡节点)",
|
||
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)
|
||
|
||
# 变量问题也进校验结果:模板取不到值只会渲染成空字符串,不报错,
|
||
# 所以拼错一个变量名在运行时是完全静默的——只能在这里说破。
|
||
# 放在最后导入,避免 variables 反过来 import schema 形成环。
|
||
from app.engine.variables import analyze as _analyze_vars
|
||
|
||
for issue in _analyze_vars(spec).issues:
|
||
# info 级是"产出没人用"这类提示,不进校验(它不影响能不能跑)
|
||
if issue.level == "info":
|
||
continue
|
||
result.add(issue.message, level=issue.level, node_id=issue.node_id)
|
||
|
||
return result
|