mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 14:42:00 +00:00
66cc96b463
多条边汇入同一个节点时,编译器对每条入边各 add_edge 一次。LangGraph 对这种 接法的语义是"任一上游跑完就触发一次":两条支路一样长时,两次触发落在同一步 被合并,看不出问题;一长一短时,汇合节点先拿短支路的结果跑一次,长支路到了 再跑一次——模型调用、工具副作用、下游全部翻倍。循环体里更糟:每轮汇合两次, 循环节点就多推进一格,done 出口在循环跑到一半时就放行了,成果节点跑了 3 次。 README 写的"自动等待汇聚"只在支路等长时成立。 修法是给汇合节点加 defer(等图里其余待执行的任务都跑完再跑一次),而不是 屏障 add_edge([a, b], j):分支之后的二选一汇合,没走的那条永远不会到,屏障 会一直等,而 LangGraph 没有可执行任务时会安静结束——汇合节点和下游就被无声 跳过。代价写在 _join_nodes 的注释和 README 里:它也会等图里无关、恰好在跑的支路。 汇合 = 两个以上前向上游,循环回边不算。回边识别因此从 layout.py 挪到 schema.back_edges,加了 roots 参数:执行语义要从入口起 DFS,否则循环体里的 前向边可能被误判成回边;排版照旧按节点顺序起算,行为不变(check-canvas-layout 全过)。 新增 test_fan_in.py 5 例:长短支路、循环体内汇合(修前分别跑了 2 次和 4 次); 二选一汇合、被分支剪掉一路的汇合(守着别把修法做成屏障);回边不算汇合。 292 → 297 passed。内置模板里 ③④ 的成果节点是二选一汇合,行为不变。
391 lines
16 KiB
Python
391 lines
16 KiB
Python
"""画布自动排版。
|
||
|
||
模型生成的图只有拓扑、没有坐标,全堆在原点就没法看;用户手拖过之后点
|
||
「自动排版」也得重新摆一遍。这里做的是分层布局(Sugiyama 那条路)的简化版,
|
||
四步:
|
||
|
||
1. **分层**:按最长路径定层。环上的回边先剔掉——不剔的话 loop 节点的回边
|
||
会把整张图压成一层,循环体跑到循环节点左边去。
|
||
2. **层内排序**:中位数启发式上下扫,取交叉最少的那一版。并行分支因此会按
|
||
上游的相对位置排好,而不是按 id 字典序乱排(以前 `out_no` 排在 `out_yes`
|
||
上面,因为 "n" < "y"——分支的 yes/no 就反着画)。
|
||
3. **定 y**:先均匀堆叠,再按邻居重心迭代松弛。这一步是"同一列的节点和上下游
|
||
对齐"的关键,也是长边能走直、少拐弯的前提。
|
||
4. **定 x**:层与层之间留一条走廊,走廊宽度按穿过它的连线在竖直方向的最大
|
||
重叠数算。前端拿这个宽度给每条线分配独立的竖直车道,线才不会叠在一起。
|
||
——走廊留窄了,前端再聪明也只能把线叠起来。
|
||
|
||
坐标的约定和前端 `canvas/routing.ts` 是一份契约,改这里要同步改那边:
|
||
节点宽 238(NodeCard 写死的),同列节点最小间距 ≥ MIN_ROW_GAP,走廊里
|
||
竖直车道间距 LANE。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections import defaultdict, deque
|
||
from statistics import median
|
||
|
||
from app.engine.schema import GraphNode, GraphSpec, NodeType, back_edges
|
||
|
||
#: 画布节点的宽度。前端 NodeCard 的 `width: 238` 是写死的,这里必须跟着它——
|
||
#: 排版算出来的走廊是给连线用的,宽度估错了走廊就白留。
|
||
NODE_W = 238
|
||
#: 节点高度估算:标题栏 + 两行摘要。真实高度由前端量,这里只要不低估到让
|
||
#: 同列节点撞上就行(前端按实测高度铺端口,能自己吸收这点误差)。
|
||
NODE_H = 76
|
||
#: 同列相邻节点的最小空隙。除了不撞,还要给横向走线留出能穿过去的空档。
|
||
MIN_ROW_GAP = 56
|
||
#: 连线离开节点后的第一段水平长度。
|
||
STUB = 14
|
||
#: 同一条走廊里相邻竖直车道的间距。
|
||
LANE = 18
|
||
#: 竖直区间重叠多少以内算"同一条道"。共用扇出点的两条线首尾相接,不该各占一条。
|
||
LANE_OVERLAP_TOLERANCE = 6.0
|
||
#: 走廊宽度下限/上限。上限是防止一个 20 路扇出的节点把整张图撑到屏幕外;
|
||
#: 顶到上限时前端会按可用宽度压缩车道间距。
|
||
CORRIDOR_MIN = 96
|
||
CORRIDOR_MAX = 420
|
||
X_ORIGIN = 80
|
||
|
||
#: 分支节点的兜底出口,和前端 `sourceHandles()` 里的写法一致
|
||
BRANCH_FALLBACK = "default"
|
||
|
||
|
||
def _height(node: GraphNode) -> float:
|
||
"""节点高度估算。
|
||
|
||
多 agent 节点跑起来会在卡片里展开一块协作矩阵(前端 canvas/TeamMatrix):
|
||
名册有几个人就多几行。排版必须按**展开后**的高度留位置——它会盖住下面那张
|
||
卡,而且运行结束后矩阵不会收起来(那正是要回看的东西),重叠是永久的。
|
||
"""
|
||
if node.type == NodeType.SUPERVISOR:
|
||
n = len(node.config.get("agents") or [])
|
||
if n:
|
||
# 标题 34 + 摘要 28 + 矩阵(表头 19 + n 行 21 + 页脚 25 + 内外边距 14)
|
||
# + 正在跑的那个人多半还会带一行当前任务 26
|
||
return 146 + 21 * n + 26
|
||
return NODE_H
|
||
|
||
|
||
def _dedupe_edges(spec: GraphSpec, nodes: dict[str, GraphNode]) -> list:
|
||
"""丢掉悬空边和重复边,顺便给每条边定一个稳定的身份。
|
||
|
||
自环(source == target)留到最后单独处理,它不参与分层。
|
||
"""
|
||
seen: set[tuple[str, str, str]] = set()
|
||
out = []
|
||
for e in spec.edges:
|
||
if e.source not in nodes or e.target not in nodes:
|
||
continue
|
||
key = (e.source, e.target, e.sourceHandle or "")
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
out.append(e)
|
||
return out
|
||
|
||
|
||
def _ranks(
|
||
nodes: dict[str, GraphNode], edges: list, back: set[tuple[str, str, str]],
|
||
) -> dict[str, int]:
|
||
"""最长路径分层。并行分支会落在同一层,串行的才会被推开。"""
|
||
forward = [e for e in edges if (e.source, e.target, e.sourceHandle or "") not in back]
|
||
succ: dict[str, list[str]] = defaultdict(list)
|
||
indeg: dict[str, int] = dict.fromkeys(nodes, 0)
|
||
for e in forward:
|
||
succ[e.source].append(e.target)
|
||
indeg[e.target] += 1
|
||
|
||
rank = dict.fromkeys(nodes, 0)
|
||
queue = deque(sorted(n for n in nodes if indeg[n] == 0))
|
||
while queue:
|
||
node_id = queue.popleft()
|
||
for target in succ[node_id]:
|
||
rank[target] = max(rank[target], rank[node_id] + 1)
|
||
indeg[target] -= 1
|
||
if indeg[target] == 0:
|
||
queue.append(target)
|
||
return rank
|
||
|
||
|
||
def _handle_order(node: GraphNode) -> list[str]:
|
||
"""节点出口在画布上从上到下的顺序。分支的 yes/no 就该是这个顺序。
|
||
|
||
前端 `sourceHandles()` 决定出口怎么画,这里决定它们怎么排。两份必须一致,
|
||
否则分支标签的上下关系会和连线对不上。
|
||
"""
|
||
cfg = node.config
|
||
if node.type == NodeType.BRANCH:
|
||
keys = [
|
||
str(c.get("key"))
|
||
for c in (cfg.get("cases") or [])
|
||
if isinstance(c, dict) and c.get("key")
|
||
]
|
||
return keys + [BRANCH_FALLBACK]
|
||
if node.type == NodeType.LOOP:
|
||
return ["body", "done"]
|
||
if node.type == NodeType.HUMAN and cfg.get("mode", "approve") == "approve":
|
||
return ["approved", "rejected"]
|
||
return ["out"]
|
||
|
||
|
||
def _initial_orders(
|
||
spec: GraphSpec, nodes: dict[str, GraphNode], rank: dict[str, int], edges: list,
|
||
) -> list[list[str]]:
|
||
"""第 0 层先按声明顺序(入口排前面),后面的层按上游位置摆一遍。"""
|
||
max_rank = max(rank.values(), default=0)
|
||
orders: list[list[str]] = [[] for _ in range(max_rank + 1)]
|
||
for node in spec.nodes:
|
||
if node.id in rank:
|
||
orders[rank[node.id]].append(node.id)
|
||
|
||
preds: dict[str, list[str]] = defaultdict(list)
|
||
for e in edges:
|
||
preds[e.target].append(e.source)
|
||
|
||
for r in range(1, max_rank + 1):
|
||
pos = {nid: i for i, nid in enumerate(orders[r - 1])}
|
||
here = {nid: i for i, nid in enumerate(orders[r])}
|
||
def key(nid: str) -> tuple[float, int]:
|
||
ps = [pos[p] for p in preds[nid] if p in pos]
|
||
return (median(ps) if ps else here[nid], here[nid])
|
||
orders[r].sort(key=key)
|
||
return orders
|
||
|
||
|
||
def _crossings(orders: list[list[str]], rank: dict[str, int], edges: list) -> int:
|
||
"""相邻两层之间的连线交叉数。只数跨一层的边——跨多层的长边没有虚拟节点,
|
||
数不准,不如不数,免得把排序带偏。"""
|
||
total = 0
|
||
by_rank: dict[int, list] = defaultdict(list)
|
||
for e in edges:
|
||
rs, rt = rank[e.source], rank[e.target]
|
||
if rt == rs + 1:
|
||
by_rank[rs].append(e)
|
||
for r, es in by_rank.items():
|
||
if r + 1 >= len(orders):
|
||
continue
|
||
pos_s = {nid: i for i, nid in enumerate(orders[r])}
|
||
pos_t = {nid: i for i, nid in enumerate(orders[r + 1])}
|
||
pairs = [(pos_s[e.source], pos_t[e.target]) for e in es]
|
||
for i in range(len(pairs)):
|
||
for j in range(i + 1, len(pairs)):
|
||
a, b = pairs[i], pairs[j]
|
||
if (a[0] - b[0]) * (a[1] - b[1]) < 0:
|
||
total += 1
|
||
return total
|
||
|
||
|
||
def _sweep(
|
||
orders: list[list[str]], neigh: dict[str, list[str]], down: bool,
|
||
) -> list[list[str]]:
|
||
"""一轮中位数扫描。没邻居的节点按当前位置排,不被扫到队尾去。"""
|
||
result = [list(ids) for ids in orders]
|
||
seq = range(1, len(result)) if down else range(len(result) - 2, -1, -1)
|
||
for r in seq:
|
||
ref_rank = r - 1 if down else r + 1
|
||
pos = {nid: i for i, nid in enumerate(result[ref_rank])}
|
||
here = {nid: i for i, nid in enumerate(result[r])}
|
||
|
||
def key(nid: str) -> tuple[float, int]:
|
||
ps = [pos[p] for p in neigh[nid] if p in pos]
|
||
return (median(ps) if ps else here[nid], here[nid])
|
||
|
||
result[r].sort(key=key)
|
||
return result
|
||
|
||
|
||
def _minimize_crossings(
|
||
orders: list[list[str]], rank: dict[str, int], edges: list,
|
||
preds: dict[str, list[str]], succs: dict[str, list[str]],
|
||
) -> list[list[str]]:
|
||
best = [list(ids) for ids in orders]
|
||
best_score = _crossings(best, rank, edges)
|
||
for iteration in range(6):
|
||
orders = _sweep(orders, preds if iteration % 2 == 0 else succs, iteration % 2 == 0)
|
||
score = _crossings(orders, rank, edges)
|
||
if score < best_score:
|
||
best, best_score = [list(ids) for ids in orders], score
|
||
if best_score == 0:
|
||
break
|
||
return best
|
||
|
||
|
||
def _apply_handle_order(
|
||
orders: list[list[str]], spec: GraphSpec, nodes: dict[str, GraphNode],
|
||
rank: dict[str, int],
|
||
) -> None:
|
||
"""分支/循环的出口有语义顺序,交叉再少也得让 yes 排在 no 上面。
|
||
|
||
做法是"占位重排":这些后继本来占着层里的哪几个格子,就把它们按出口顺序
|
||
填回那几个格子——层里其他节点的相对次序不受影响。
|
||
"""
|
||
outgoing: dict[str, list] = defaultdict(list)
|
||
for e in spec.edges:
|
||
outgoing[e.source].append(e)
|
||
|
||
for node in spec.nodes:
|
||
handles = _handle_order(node)
|
||
if len(handles) < 2 or node.id not in nodes:
|
||
continue
|
||
outs = outgoing.get(node.id, [])
|
||
if not outs:
|
||
continue
|
||
groups: list[str] = []
|
||
for handle in handles:
|
||
for e in outs:
|
||
if e.target not in nodes or e.target == node.id:
|
||
continue
|
||
if (e.sourceHandle or handles[0]) == handle and e.target not in groups:
|
||
groups.append(e.target)
|
||
# 同一层里按出口顺序重排;跨层的没法重排,跳过
|
||
by_rank: dict[int, list[str]] = defaultdict(list)
|
||
for target in groups:
|
||
by_rank[rank[target]].append(target)
|
||
for r, targets in by_rank.items():
|
||
if len(targets) < 2 or r >= len(orders):
|
||
continue
|
||
slots = sorted(orders[r].index(t) for t in targets if t in orders[r])
|
||
if len(slots) != len(targets):
|
||
continue
|
||
for slot, target in zip(slots, targets):
|
||
orders[r][slot] = target
|
||
|
||
|
||
def _stack(orders: list[list[str]], nodes: dict[str, GraphNode], row_gap: float) -> dict[str, float]:
|
||
"""每层先均匀堆叠,整层以 y=0 为中心。"""
|
||
y: dict[str, float] = {}
|
||
for ids in orders:
|
||
heights = [_height(nodes[i]) for i in ids]
|
||
total = sum(heights) + row_gap * (len(ids) - 1)
|
||
cursor = -total / 2
|
||
for node_id, h in zip(ids, heights):
|
||
y[node_id] = cursor + h / 2
|
||
cursor += h + row_gap
|
||
return y
|
||
|
||
|
||
def _relax(
|
||
y: dict[str, float], orders: list[list[str]], nodes: dict[str, GraphNode],
|
||
preds: dict[str, list[str]], succs: dict[str, list[str]], row_gap: float,
|
||
rounds: int = 6,
|
||
) -> None:
|
||
"""按邻居重心松弛,再把同层节点推开到互不重叠。
|
||
|
||
推开的正向扫描会把整层往下带,所以每层收尾要整体平移回重心——平移不改变
|
||
层内间距,重叠约束依然成立。
|
||
"""
|
||
for iteration in range(rounds):
|
||
down = iteration % 2 == 0
|
||
neigh = preds if down else succs
|
||
seq = range(len(orders)) if down else range(len(orders) - 1, -1, -1)
|
||
for r in seq:
|
||
ids = orders[r]
|
||
if not ids:
|
||
continue
|
||
desired = []
|
||
for node_id in ids:
|
||
ys = [y[p] for p in neigh[node_id] if p in y]
|
||
desired.append(sum(ys) / len(ys) if ys else y[node_id])
|
||
cursor: float | None = None
|
||
for i, node_id in enumerate(ids):
|
||
if cursor is None:
|
||
cursor = desired[i]
|
||
else:
|
||
gap = _height(nodes[ids[i - 1]]) / 2 + row_gap + _height(nodes[node_id]) / 2
|
||
cursor = max(desired[i], cursor + gap)
|
||
y[node_id] = cursor
|
||
shift = sum(desired) / len(desired) - sum(y[i] for i in ids) / len(ids)
|
||
for node_id in ids:
|
||
y[node_id] += shift
|
||
|
||
|
||
def _lane_count(intervals: list[tuple[float, float]]) -> int:
|
||
"""竖直走线至少要几条车道。
|
||
|
||
就是区间图上的首次适应染色,按起点排序即最优。前端 `routing.ts` 用的是
|
||
同一套贪心,所以两边算出来的车道数一致——否则走廊留窄了,线还是会叠。
|
||
"""
|
||
lanes: list[float] = []
|
||
for start, end in sorted((min(a, b), max(a, b)) for a, b in intervals):
|
||
for i, lane_end in enumerate(lanes):
|
||
# 首尾相接(共用扇出点)不算冲突:那正是一条总线的样子。
|
||
# 真叠在一起(重叠超过 TOLERANCE)才换一条道。
|
||
if start >= lane_end - LANE_OVERLAP_TOLERANCE:
|
||
lanes[i] = max(lane_end, end)
|
||
break
|
||
else:
|
||
lanes.append(end)
|
||
return len(lanes)
|
||
|
||
|
||
def _corridor_width(
|
||
boundary: int, rank: dict[str, int], edges: list, y: dict[str, float],
|
||
) -> float:
|
||
"""第 boundary 层和第 boundary+1 层之间要留多宽。
|
||
|
||
穿过这条走廊的边在竖直方向最多重叠几条,就至少要几条车道。
|
||
"""
|
||
intervals = [
|
||
(y[e.source], y[e.target])
|
||
for e in edges
|
||
if rank[e.source] <= boundary < rank[e.target]
|
||
]
|
||
lanes = _lane_count(intervals)
|
||
if lanes <= 1:
|
||
return CORRIDOR_MIN
|
||
width = STUB * 2 + LANE * (lanes - 1) + 28
|
||
return float(min(CORRIDOR_MAX, max(CORRIDOR_MIN, width)))
|
||
|
||
|
||
def auto_layout(
|
||
spec: GraphSpec, *, x_gap: int | None = None, y_gap: int | None = None,
|
||
) -> GraphSpec:
|
||
"""给整张图重新分配坐标,返回同一个 spec(原地改坐标)。
|
||
|
||
`x_gap` / `y_gap` 是旧接口留下的旋钮,现在只当"走廊最窄多少、同列最少隔
|
||
多少"用:实际间距由拓扑算出来的车道需求决定,不再是固定 300/150。
|
||
"""
|
||
if not spec.nodes:
|
||
return spec
|
||
|
||
nodes = spec.node_map()
|
||
edges = _dedupe_edges(spec, nodes)
|
||
# 自环不参与分层:它不跨层,留着只会让"自己的前驱是自己"这种噪音渗进排序
|
||
edges = [e for e in edges if e.source != e.target]
|
||
|
||
back = back_edges(nodes, edges)
|
||
rank = _ranks(nodes, edges, back)
|
||
|
||
preds: dict[str, list[str]] = defaultdict(list)
|
||
succs: dict[str, list[str]] = defaultdict(list)
|
||
for e in edges:
|
||
preds[e.target].append(e.source)
|
||
succs[e.source].append(e.target)
|
||
|
||
orders = _initial_orders(spec, nodes, rank, edges)
|
||
orders = _minimize_crossings(orders, rank, edges, preds, succs)
|
||
_apply_handle_order(orders, spec, nodes, rank)
|
||
|
||
row_gap = float(y_gap) if y_gap else MIN_ROW_GAP
|
||
corridor_min = max(CORRIDOR_MIN, float(x_gap) - NODE_W) if x_gap else CORRIDOR_MIN
|
||
|
||
y = _stack(orders, nodes, row_gap)
|
||
_relax(y, orders, nodes, preds, succs, row_gap)
|
||
|
||
# 每个节点都有层,也就都在这两个表里;.get 的兜底是防"层算漏了"这种
|
||
# 不该发生的情况——真漏了也把节点摆在图末尾,而不是让它掉在原点压住别人
|
||
x: dict[str, float] = {}
|
||
cursor = float(X_ORIGIN)
|
||
for r, ids in enumerate(orders):
|
||
for node_id in ids:
|
||
x[node_id] = cursor
|
||
if r < len(orders) - 1:
|
||
width = _corridor_width(r, rank, edges, y)
|
||
cursor += NODE_W + max(width, corridor_min)
|
||
|
||
for node in spec.nodes:
|
||
node.position.x = round(x.get(node.id, cursor), 1)
|
||
node.position.y = round(y.get(node.id, 0.0), 1)
|
||
return spec
|