mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 17:32:00 +00:00
e10c4f23fc
来自 2026-09 的 UI/UX 审查(142 条)。本提交是前两波,第三波(记录页接入时间轴、跨页互链、check-ui 更新、全站扫尾)另行提交。 地基 - 设计令牌:全局规则和 .btn/.field/.chip 放进 CSS 层,Tailwind 工具类重新生效,选中态、开启态不再被吞掉;字号阶梯; 辅助文字在所有底色上 >= 4.5:1;状态色专用,节点类型色与之拉开色相;「跟随系统」真的跟随,首帧不闪深色;新字标与 favicon - 基础组件:confirmDialog / promptDialog 取代原生对话框;toast 出错不自动消失、可复制、读屏可播报;离线横幅与断线恢复; 九种剪影的状态徽标;唯一一份状态文案(lib/status)、格式化(lib/format)、术语(lib/terms)、报错人话(lib/errors) - 运行态内核:事件折成航迹(run/trace.ts),可投影到任意时刻;取消 / 失败 / 服务重启后统一收尾,不再永远转圈; run.status 随事件回写;token 事件不再堆进 events - 后端:协作成员逐个完成即发事件,调度者决策有事件;时长分执行 / 墙钟 / 等人;API 时间带时区; 中断的运行也发 stream.end;运行列表可筛选翻页;删除运行 / 工作流有保护;运行默认值接通;可测未保存的配置; 报错说清发生了什么、为什么、怎么办 功能 - 画布:状态四重编码、固定高度遥测槽、按缩放三档、运行胶囊与 HUD、航迹时间轴与回放、执行轨迹边、 等待闸门、跟随执行、正式运行只读、一次性时刻(开场点亮、回扫、阻断级联、出具印章) - 编辑:撤销重做、版本历史、问题面板可定位、三栏可收起、模板与表达式字段区分、变量血缘、快捷键、带搜索的选择器 - 回答流 / 问数据:贴底才跟随滚动、工件下钻、出具横幅列出缺口、答案可复制导出、按会话的忙闲、 答案不再在 2000 字处截断、重启后这一轮会收尾 - 记录页:待审批 / 运行中 / 失败页签、筛选翻页、实时接流、排错路径指到节点、封存凭证常驻 - 管理页:新的「数据」页、写操作失败都有反馈、弹窗里先测连接、统一持久的连接状态 - 外壳:导航改版、⌘K 命令面板、? 快捷键帮助、404、页面标题与 favicon 状态点、后台提醒 验证(沙箱):tsc 干净,pytest 579 passed,14 个检查脚本中 13 个全绿;check-ui 剩 1 项旧断言(按旧日期格式找运行行),第三波更新。
230 lines
9.9 KiB
Python
230 lines
9.9 KiB
Python
"""协作团队的实时性:谁先做完谁先报到,调度者在想什么也要有事件。
|
|
|
|
以前成员的 agent.step.end 要等整轮 gather 结束才按派活顺序统一发出——并行时先做完
|
|
的成员一直显示"进行中",直到最慢的那个做完;矩阵声称能回答的"谁还在跑"其实答
|
|
不了。调度者那次结构化模型调用前后没有任何事件,一个 74 秒的团队节点里约 66 秒
|
|
是调度者在决策,界面上是个黑盒。
|
|
|
|
用量也一样:agent 节点、成员、调度者的模型调用都不发 llm.end,界面只能等终态
|
|
才知道花了多少 token。补上之后必须守住另一头:run.finished 的 usage 是后端累计
|
|
的权威值,事件只是把同一笔账逐次摊开,加起来要对得上,不能记两遍。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
import pytest
|
|
from langchain_core.messages import AIMessage, AIMessageChunk
|
|
from langchain_core.outputs import ChatGenerationChunk
|
|
from sqlalchemy import select
|
|
|
|
from app.db.base import SessionLocal
|
|
from app.db.models import Run, RunEvent
|
|
from app.engine.runner import run_manager
|
|
|
|
SLOW, FAST = 0.6, 0.05
|
|
ROUTE_SECONDS = 0.15
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
async def engine_up():
|
|
await run_manager.setup()
|
|
yield
|
|
await run_manager.shutdown()
|
|
|
|
|
|
def _team_graph() -> dict:
|
|
agents = [
|
|
{"name": n, "description": f"{n} 的职责", "system": f"你是 {n}", "max_steps": 1}
|
|
for n in ("researcher", "analyst")
|
|
]
|
|
return {
|
|
"nodes": [
|
|
{"id": "in", "type": "input", "data": {"config": {}}},
|
|
{"id": "team", "type": "supervisor", "data": {"label": "协作团队", "config": {
|
|
"goal": "比较两种向量库", "agents": agents, "max_rounds": 3, "max_parallel": 2,
|
|
}}},
|
|
{"id": "out", "type": "output", "data": {"config": {}}},
|
|
],
|
|
"edges": [{"source": "in", "target": "team"}, {"source": "team", "target": "out"}],
|
|
}
|
|
|
|
|
|
def _usage(messages, reply: str) -> dict:
|
|
text = " ".join(str(getattr(m, "content", m)) for m in messages)
|
|
return {"input_tokens": 10 + len(text) // 50, "output_tokens": 3 + len(reply) // 20,
|
|
"total_tokens": 0}
|
|
|
|
|
|
def _script(monkeypatch, decisions: list[dict]) -> None:
|
|
"""调度者按剧本派活;成员 researcher 慢、analyst 快;每次调用都报 token。
|
|
|
|
调度者走 with_structured_output(include_raw=True):真实供应商会连原始消息
|
|
一起给回来,用量就在那条消息上。mock 默认只给解析结果,这里补齐这层行为。
|
|
"""
|
|
from app.providers import mock_model
|
|
|
|
turns = {"n": 0}
|
|
|
|
def _decide(self, messages):
|
|
if self.response_format:
|
|
i = turns["n"]
|
|
turns["n"] += 1
|
|
payload = decisions[i] if i < len(decisions) else {"assignments": [], "done": True}
|
|
return AIMessage(content=json.dumps(payload, ensure_ascii=False))
|
|
return AIMessage(content="(干完了)")
|
|
|
|
async def _agenerate(self, messages, stop=None, run_manager=None, **kw):
|
|
text = " ".join(str(getattr(m, "content", "")) for m in messages)
|
|
if self.response_format:
|
|
await asyncio.sleep(ROUTE_SECONDS)
|
|
else:
|
|
await asyncio.sleep(FAST if "快活" in text else SLOW)
|
|
result = self._generate(messages, stop, **kw)
|
|
msg = result.generations[0].message
|
|
msg.usage_metadata = _usage(messages, str(msg.content))
|
|
return result
|
|
|
|
def _structured(self, schema, *, include_raw=False, **kw):
|
|
bound = self.model_copy(update={"response_format": schema})
|
|
|
|
async def parse(msg):
|
|
parsed = json.loads(str(msg.content))
|
|
return {"raw": msg, "parsed": parsed, "parsing_error": None} if include_raw else parsed
|
|
|
|
return bound | parse
|
|
|
|
monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide)
|
|
monkeypatch.setattr(mock_model.MockChatModel, "_agenerate", _agenerate)
|
|
monkeypatch.setattr(mock_model.MockChatModel, "with_structured_output", _structured)
|
|
|
|
|
|
async def _run(graph: dict) -> tuple[list[RunEvent], Run]:
|
|
run = await run_manager.start(graph=graph, input_payload={"question": "比较"})
|
|
for _ in range(400):
|
|
await asyncio.sleep(0.05)
|
|
async with SessionLocal() as session:
|
|
row = await session.get(Run, run.id)
|
|
if row.status in ("succeeded", "failed", "cancelled"):
|
|
break
|
|
else:
|
|
pytest.fail("运行没有在 20 秒内结束")
|
|
async with SessionLocal() as session:
|
|
events = list((await session.execute(
|
|
select(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq)
|
|
)).scalars())
|
|
return events, row
|
|
|
|
|
|
PARALLEL_ROUND = [{
|
|
"assignments": [
|
|
{"agent": "researcher", "instruction": "慢活"},
|
|
{"agent": "analyst", "instruction": "快活"},
|
|
],
|
|
"reason": "两边互不相干",
|
|
}, {"assignments": [], "done": True, "reason": "够了"}]
|
|
|
|
|
|
async def test_members_report_done_in_the_order_they_actually_finish(monkeypatch):
|
|
_script(monkeypatch, PARALLEL_ROUND)
|
|
events, run = await _run(_team_graph())
|
|
assert run.status == "succeeded", run.error
|
|
|
|
ends = [e for e in events if e.type == "agent.step.end"]
|
|
assert [e.data["agent"] for e in ends] == ["analyst", "researcher"], \
|
|
"快的那个先做完,就该先报到——而不是按派活顺序等整轮结束再一起报"
|
|
|
|
start = next(e for e in events if e.type == "agent.step.start")
|
|
fast_end = ends[0]
|
|
# 快的那个结束事件的时刻要贴着它自己做完的时刻,而不是被慢的那个拖到整轮结束
|
|
assert fast_end.ts - start.ts < (SLOW + FAST) / 2, \
|
|
f"analyst {FAST}s 就做完了,结束事件却在 {fast_end.ts - start.ts:.2f}s 才出现"
|
|
# 字段不变:界面照旧按这些字段画矩阵
|
|
for e in ends:
|
|
assert {"agent", "duration_ms", "round", "parallel", "preview"} <= set(e.data)
|
|
|
|
|
|
async def test_the_coordinator_is_not_a_black_box(monkeypatch):
|
|
_script(monkeypatch, PARALLEL_ROUND)
|
|
events, run = await _run(_team_graph())
|
|
assert run.status == "succeeded", run.error
|
|
|
|
starts = [e for e in events if e.type == "agent.route.start"]
|
|
ends = [e for e in events if e.type == "agent.route.end"]
|
|
assert [e.data["round"] for e in starts] == [0, 1]
|
|
assert [e.data["round"] for e in ends] == [0, 1]
|
|
assert all(e.node_id == "team" for e in starts + ends)
|
|
|
|
first, last = ends
|
|
assert first.data["agents"] == ["researcher", "analyst"]
|
|
assert first.data["parallel"] == 2
|
|
assert first.data["done"] is False
|
|
assert first.data["reason"] == "两边互不相干"
|
|
assert last.data["done"] is True and last.data["agents"] == []
|
|
assert last.data["reason"] == "够了"
|
|
for e in ends:
|
|
assert e.data["duration_ms"] >= ROUTE_SECONDS * 1000 * 0.8, e.data
|
|
|
|
# 先"开始想",再"想好了",然后才派人出去
|
|
seq = {e.type + str(e.data.get("round", "")): e.seq for e in events}
|
|
first_dispatch = next(e.seq for e in events if e.type == "agent.step.start")
|
|
assert seq["agent.route.start0"] < seq["agent.route.end0"] < first_dispatch
|
|
|
|
|
|
async def test_every_model_call_reports_usage_and_the_sum_matches_the_run(monkeypatch):
|
|
_script(monkeypatch, PARALLEL_ROUND)
|
|
events, run = await _run(_team_graph())
|
|
assert run.status == "succeeded", run.error
|
|
|
|
calls = [e for e in events if e.type == "llm.end" and e.node_id == "team"]
|
|
by_agent: dict[str, list[dict]] = {}
|
|
for e in calls:
|
|
by_agent.setdefault(e.data["agent"], []).append(e.data)
|
|
assert set(by_agent) == {"调度者", "researcher", "analyst"}, by_agent.keys()
|
|
assert len(by_agent["调度者"]) == 2, "调度了两次,就是两次模型调用"
|
|
for d in (d for items in by_agent.values() for d in items):
|
|
assert {"model", "input_tokens", "output_tokens", "cost_usd"} <= set(d), d
|
|
assert d["input_tokens"] > 0 and d["output_tokens"] > 0, d
|
|
|
|
finished = next(e for e in events if e.type == "run.finished")
|
|
usage = finished.data["usage"]
|
|
# 权威值是后端累计;逐次事件加起来必须等于它——多一笔少一笔都说明记重或漏记了
|
|
assert usage["input_tokens"] == sum(e.data["input_tokens"] for e in calls)
|
|
assert usage["output_tokens"] == sum(e.data["output_tokens"] for e in calls)
|
|
assert usage["calls"] == len(calls)
|
|
assert run.usage["input_tokens"] == usage["input_tokens"]
|
|
|
|
|
|
async def test_agent_node_reports_each_model_call(monkeypatch):
|
|
"""agent 节点以前只有 llm.start 没有 llm.end:用量要等整个节点结束才知道。"""
|
|
from app.providers import mock_model
|
|
|
|
async def _astream(self, messages, stop=None, run_manager=None, **kw):
|
|
reply = "查完了,结论如下。"
|
|
await asyncio.sleep(0.01)
|
|
yield ChatGenerationChunk(message=AIMessageChunk(
|
|
content=reply, usage_metadata=_usage(messages, reply)))
|
|
|
|
monkeypatch.setattr(mock_model.MockChatModel, "_astream", _astream)
|
|
graph = {
|
|
"nodes": [
|
|
{"id": "in", "type": "input", "data": {"config": {}}},
|
|
{"id": "ag", "type": "agent", "data": {"label": "分析员", "config": {
|
|
"prompt": "{{ input.question }}", "tools": [], "max_steps": 3}}},
|
|
{"id": "out", "type": "output", "data": {"config": {}}},
|
|
],
|
|
"edges": [{"source": "in", "target": "ag"}, {"source": "ag", "target": "out"}],
|
|
}
|
|
events, run = await _run(graph)
|
|
assert run.status == "succeeded", run.error
|
|
|
|
starts = [e for e in events if e.type == "llm.start" and e.node_id == "ag"]
|
|
ends = [e for e in events if e.type == "llm.end" and e.node_id == "ag"]
|
|
assert len(ends) == len(starts) >= 1
|
|
assert all(e.data["agent"] == "分析员" for e in ends)
|
|
usage = next(e for e in events if e.type == "run.finished").data["usage"]
|
|
assert usage["input_tokens"] == sum(e.data["input_tokens"] for e in ends) > 0
|
|
assert usage["output_tokens"] == sum(e.data["output_tokens"] for e in ends) > 0
|