Files
AgentLab/backend/app/engine/layout.py
T
ThearchyHelios 66cc96b463 汇合节点只跑一次:长短支路不再把它触发两遍,循环的 done 出口也不再被提前放行
多条边汇入同一个节点时,编译器对每条入边各 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。内置模板里 ③④ 的成果节点是二选一汇合,行为不变。
2026-09-25 14:33:56 +08:00

391 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""画布自动排版。
模型生成的图只有拓扑、没有坐标,全堆在原点就没法看;用户手拖过之后点
「自动排版」也得重新摆一遍。这里做的是分层布局(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