mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:21:57 +00:00
8905fe5e60
多 agent 节点原本是个黑盒:卡片转三十秒圈,然后吐一段文本。"它同时派了三个 人"、"谁还在跑"、"并行到底省了多少"全都要点开右栏才知道,而右栏是线性的 时间线,看不出并发的形状。 现在它跑起来会在卡片里展开一块协作矩阵。三条设计上的选择: - 花名册固定在原地,不按派单顺序重排——几轮下来眼睛知道谁在哪一行 - 条形长度按**这一轮里最慢的那个人**归一化。三个人并排三条长短不一的条, 墙钟为什么等于最长那条、而不是三条之和,不用解释就看懂了;页脚给出 并行一共省下多少 - 本轮没被派到的人不显示成"待命",而是留着上一轮的成绩(压暗)——它已经 交回来了,说它待命是错的,看的人会以为它还没干活 矩阵和右栏泳道读的是同一份翻译:把 decodeRun 里那段攒泳道的逻辑抽成 reduceTeam(不可变,每次返回新对象,画布那份挂在 zustand 上需要新引用)。 两边各写一遍的话,同一次运行在右栏和画布上会给出不同的并行度——而"几个人 同时在跑、省了多少"正是这个节点唯一值得看的东西。抽完之后 check-decode 一条没红。 其余节点按同一套语汇动:边框上转一圈光弧 + 扫描线 = 正在跑,绿色扫一遍 = 刚跑完(只扫一次——常驻动画会让整张画布一直在动),短促抖动 = 刚失败, 琥珀呼吸 = 在等人。循环卡片显示"第 1/5 轮";分支跑完点亮命中的出口、压暗 其余,实际走过的边留在画布上,那就是这次的执行路径。连线上的虚线换成顺着 路径跑的光点:虚线只让人觉得"在动",光点能看出往哪走,速度按路径长度归一化 (长边上的光点走得慢会像卡住了)。 原来那个往外扩的声纳圈(pulse-ring)删掉了。同一个意思说两遍只会更吵,而且 声纳扩出去会和邻居卡片的边框糊在一起。 一个前提先验证过:offset-path 在 SVG 子元素上能不能对齐路径。测下来和 getPointAtLength 的位置差 0px,才敢用它。 排版要按**展开后**的高度给多 agent 节点留位置(layout.py 的 _height 按名册 人数算)。不留的话它一跑起来就盖住下面那张卡,而且运行结束后矩阵不会收起来 (那正是要回看的东西),重叠是永久的。 前庭敏感的人由 prefers-reduced-motion 兜住:光弧、扫描线、流光条、光点整体 不绘制,而不是把时长压到 0.01ms——那样它们会停在动画的最后一帧,光弧卡在 角落、光点全堆在起点,看着像渲染坏了。静态下"谁在跑"由实心圆点和"进行中" 三个字承担。 新增 scripts/check-canvas-fx.mjs:把脚本化的事件灌进 store(走的是页面上那一份 applyEvent / NodeCard / FlowEdge,只有事件来源是脚本),断言协作矩阵真的长出来、 并行度算对、条形长度和耗时成正比、分支命中的出口被点亮、关掉动效时光效确实不 绘制。真跑一张多 agent 的图要连模型、要等十几秒,而且撞不上"三个人同时还在跑" 那一瞬间——那样的检查会变成偶发失败。另外用仓库里 fixtures.json 那份真实导出 的事件验过一遍,含老运行那种只有中文消息串的 log(走正则兜底)。 顺手修掉检查脚本互相下绊子:check-canvas-fx 原来会留下一张没坐标的工作流 (节点全叠在原点),而 check-ui 打开 /studio 会落到"最近更新"的那张上,于是 点节点被挡住。现在落库前先让后端排好版。 292 passed(新增 2 条:展开的多 agent 卡片必须在排版里留出位置)。
226 lines
8.8 KiB
Python
226 lines
8.8 KiB
Python
"""自动排版。
|
||
|
||
以前只有"按最长路径分层、同层按 id 字典序竖排"两条规则:并行的分支谁上谁下
|
||
是随机的(`out_no` 排在 `out_yes` 上面,因为 "n" < "y"),层间距写死 300,
|
||
两个节点扇出到同一个目标时连线还会叠在一起。这些测试守的是重写之后的四条
|
||
不变量:并行同层、分支按语义顺序、同列不重叠、走廊按车道需求留宽。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from app.engine.layout import NODE_H, NODE_W, auto_layout
|
||
from app.engine.schema import GraphSpec
|
||
|
||
|
||
def _graph(nodes, edges=()):
|
||
return GraphSpec.model_validate({
|
||
"nodes": [
|
||
{"id": n["id"], "type": n["type"], "position": {"x": 0, "y": 0},
|
||
"data": {"label": n.get("label", ""), "config": n.get("config", {})}}
|
||
for n in nodes
|
||
],
|
||
"edges": [
|
||
{"source": e[0], "target": e[1], **({"sourceHandle": e[2]} if len(e) > 2 else {})}
|
||
for e in edges
|
||
],
|
||
})
|
||
|
||
|
||
def _parallel_graph():
|
||
"""一个入口扇出三条并行的 agent,再汇进同一个节点。"""
|
||
return _graph(
|
||
[
|
||
{"id": "in", "type": "input"},
|
||
{"id": "a1", "type": "agent"},
|
||
{"id": "a2", "type": "agent"},
|
||
{"id": "a3", "type": "agent"},
|
||
{"id": "join", "type": "llm"},
|
||
],
|
||
[("in", "a1"), ("in", "a2"), ("in", "a3"), ("a1", "join"), ("a2", "join"), ("a3", "join")],
|
||
)
|
||
|
||
|
||
def test_parallel_branches_share_a_column() -> None:
|
||
"""并行的三条支路必须落在同一列:它们之间没有依赖,谁也不该排到谁后面。"""
|
||
spec = auto_layout(_parallel_graph())
|
||
xs = {n.id: n.position.x for n in spec.nodes}
|
||
assert xs["a1"] == xs["a2"] == xs["a3"]
|
||
assert xs["in"] < xs["a1"] < xs["join"]
|
||
|
||
|
||
def test_parallel_branches_are_stacked_without_overlap() -> None:
|
||
spec = auto_layout(_parallel_graph())
|
||
ys = sorted(n.position.y for n in spec.nodes if n.id.startswith("a"))
|
||
assert len(set(ys)) == 3
|
||
# 同列相邻节点的纵向间距要够一个节点高度,否则卡片会叠在一起
|
||
assert ys[1] - ys[0] >= 60
|
||
assert ys[2] - ys[1] >= 60
|
||
|
||
|
||
def test_branch_cases_keep_their_declared_order() -> None:
|
||
"""分支的 yes/no 是语义顺序,不能按 id 字典序排("no" < "yes" 会反过来)。"""
|
||
spec = _graph(
|
||
[
|
||
{"id": "gate", "type": "branch",
|
||
"config": {"cases": [{"key": "yes"}, {"key": "no"}]}},
|
||
{"id": "out_no", "type": "output"},
|
||
{"id": "out_yes", "type": "output"},
|
||
],
|
||
[("gate", "out_yes", "yes"), ("gate", "out_no", "no")],
|
||
)
|
||
laid = auto_layout(spec)
|
||
pos = {n.id: n.position for n in laid.nodes}
|
||
assert pos["out_yes"].y < pos["out_no"].y
|
||
assert pos["out_yes"].x == pos["out_no"].x
|
||
|
||
|
||
def test_loop_body_stays_right_of_the_loop_node() -> None:
|
||
"""回边不能被当成普通边参与分层——那样循环体会跑到循环节点左边去。"""
|
||
spec = _graph(
|
||
[
|
||
{"id": "start", "type": "input"},
|
||
{"id": "each", "type": "loop"},
|
||
{"id": "body", "type": "llm"},
|
||
{"id": "collect", "type": "transform"},
|
||
{"id": "done", "type": "output"},
|
||
],
|
||
[
|
||
("start", "each"),
|
||
("each", "body", "body"),
|
||
("body", "collect"),
|
||
("collect", "each"),
|
||
("each", "done", "done"),
|
||
],
|
||
)
|
||
laid = auto_layout(spec)
|
||
pos = {n.id: n.position for n in laid.nodes}
|
||
assert pos["start"].x < pos["each"].x < pos["body"].x < pos["collect"].x
|
||
# 循环体出口排在结束出口上面
|
||
assert pos["body"].y < pos["done"].y
|
||
|
||
|
||
def test_corridor_grows_with_lane_demand() -> None:
|
||
"""扇出的线越多,走廊越宽——宽度不够,前端的车道就铺不开,线会叠回去。"""
|
||
narrow = auto_layout(_parallel_graph())
|
||
wide = auto_layout(_graph(
|
||
[{"id": "in", "type": "input"}, {"id": "join", "type": "llm"}]
|
||
+ [{"id": f"m{i}", "type": "llm"} for i in range(8)],
|
||
[("in", f"m{i}") for i in range(8)] + [(f"m{i}", "join") for i in range(8)],
|
||
))
|
||
|
||
def corridor(spec) -> float:
|
||
xs = sorted({n.position.x for n in spec.nodes})
|
||
return min(b - a for a, b in zip(xs, xs[1:])) - NODE_W
|
||
|
||
assert corridor(wide) > corridor(narrow)
|
||
|
||
|
||
def test_long_edges_do_not_widen_a_single_corridor_forever() -> None:
|
||
"""走廊有上限。20 路扇出也不该把图撑到屏幕外面去。"""
|
||
spec = auto_layout(_graph(
|
||
[{"id": "in", "type": "input"}]
|
||
+ [{"id": f"m{i}", "type": "llm"} for i in range(20)],
|
||
[("in", f"m{i}") for i in range(20)],
|
||
))
|
||
xs = sorted({n.position.x for n in spec.nodes})
|
||
assert xs[1] - xs[0] <= NODE_W + 460
|
||
|
||
|
||
def test_same_column_never_overlaps() -> None:
|
||
"""一整列节点两两之间必须留出至少一个卡片的高度。"""
|
||
spec = auto_layout(_graph(
|
||
[{"id": "in", "type": "input"}] + [{"id": f"n{i}", "type": "llm"} for i in range(12)],
|
||
[("in", f"n{i}") for i in range(12)],
|
||
))
|
||
ys = sorted(n.position.y for n in spec.nodes if n.id != "in")
|
||
gaps = [b - a for a, b in zip(ys, ys[1:])]
|
||
assert gaps and min(gaps) >= 60
|
||
|
||
|
||
def test_layout_is_stable_and_idempotent() -> None:
|
||
"""同一张图排两次必须得到同一份坐标,否则保存后一刷新就跳。"""
|
||
first = auto_layout(_parallel_graph()).model_dump(mode="json")
|
||
second = auto_layout(GraphSpec.model_validate(first)).model_dump(mode="json")
|
||
assert [(n["id"], n["position"]) for n in first["nodes"]] == \
|
||
[(n["id"], n["position"]) for n in second["nodes"]]
|
||
|
||
|
||
def test_layout_does_not_touch_the_graph_itself() -> None:
|
||
"""排版只改坐标:节点、边、配置一个都不能丢,也不能多出来。"""
|
||
before = _parallel_graph()
|
||
ids = [(n.id, str(n.type)) for n in before.nodes]
|
||
edges = [(e.source, e.target, e.sourceHandle or "") for e in before.edges]
|
||
after = auto_layout(before)
|
||
assert [(n.id, str(n.type)) for n in after.nodes] == ids
|
||
assert [(e.source, e.target, e.sourceHandle or "") for e in after.edges] == edges
|
||
|
||
|
||
def test_cycle_does_not_hang_or_collapse() -> None:
|
||
"""没有任何入口的纯环图也得排完,不能死循环。"""
|
||
spec = _graph(
|
||
[{"id": "x", "type": "llm"}, {"id": "y", "type": "llm"}],
|
||
[("x", "y"), ("y", "x")],
|
||
)
|
||
laid = auto_layout(spec)
|
||
assert {n.id for n in laid.nodes} == {"x", "y"}
|
||
|
||
|
||
def test_dangling_edges_and_empty_graph_are_survivable() -> None:
|
||
assert auto_layout(GraphSpec()).nodes == []
|
||
spec = auto_layout(_graph(
|
||
[{"id": "a", "type": "llm"}],
|
||
[("a", "ghost"), ("ghost", "a")],
|
||
))
|
||
assert spec.nodes[0].position.x >= 0
|
||
|
||
|
||
def test_self_loop_is_kept() -> None:
|
||
spec = auto_layout(_graph([{"id": "a", "type": "llm"}], [("a", "a")]))
|
||
assert spec.nodes[0].position.x >= 0
|
||
assert len(spec.edges) == 1
|
||
|
||
|
||
|
||
#: 前端 TeamMatrix 展开后的卡片高度:标题 34 + 摘要 28 + 表头 19 + n 行 21
|
||
#: + 页脚 25 + 内外边距 14 + 在跑那人的任务行 26
|
||
def _expanded_height(agents: int) -> float:
|
||
return 146 + 21 * agents + 26
|
||
|
||
|
||
def _two_rows(agents: int) -> tuple[float, float]:
|
||
"""同一列里放一个多 agent 节点和一个普通节点,返回两者的中心 y。"""
|
||
spec = auto_layout(_graph(
|
||
[
|
||
{"id": "team", "type": "supervisor",
|
||
"config": {"agents": [{"name": f"a{i}"} for i in range(agents)]}},
|
||
{"id": "peer", "type": "llm"},
|
||
],
|
||
[],
|
||
))
|
||
return tuple(sorted(n.position.y for n in spec.nodes)) # type: ignore[return-value]
|
||
|
||
|
||
def test_supervisor_reserves_room_for_its_expanded_matrix() -> None:
|
||
"""多 agent 节点跑起来会在卡片里展开协作矩阵,排版要按展开后的高度留位置。
|
||
|
||
不然它一跑起来就盖住下面那张卡;而且运行结束后矩阵不会收起来(那正是要
|
||
回看的东西),重叠是永久的。
|
||
"""
|
||
for agents in (2, 4, 6):
|
||
top, bottom = _two_rows(agents)
|
||
# 展开后的那张卡占的区间,必须和下面那张卡留出间距
|
||
gap = (bottom - NODE_H / 2) - (top + _expanded_height(agents) / 2)
|
||
assert gap >= 50, f"{agents} 人矩阵会压到下面那张卡,只剩 {gap:.0f}px"
|
||
|
||
# 名册越大留得越多;没有成员就不该白占地方
|
||
assert _two_rows(6)[1] - _two_rows(6)[0] > _two_rows(2)[1] - _two_rows(2)[0]
|
||
|
||
|
||
def test_plain_nodes_keep_the_compact_row_gap() -> None:
|
||
"""没有展开行为的节点不该跟着变高,否则整张图会白白被撑开。"""
|
||
ys = sorted(n.position.y for n in auto_layout(_graph(
|
||
[{"id": "a", "type": "llm"}, {"id": "b", "type": "llm"}, {"id": "c", "type": "llm"}],
|
||
[],
|
||
)).nodes)
|
||
assert ys[1] - ys[0] <= 140, f"普通节点之间隔了 {ys[1] - ys[0]:.0f}px"
|