mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:21:58 +00:00
587bf7e27c
起因是一次探索运行:Copilot 改节点时把工具绑定整份抹掉,agent 没有工具只能写「假设调用」, 校验节点的自动修复编出 total_count=0 让 schema 通过,协作成员把工具调用标记当文字输出了四轮, 轮数用完后节点把原话当成功结果交出去——运行标成已完成,数据库一次都没查。 平台可信度(先写失败测试) - Copilot 修改节点改为合并:没写的字段保留,null 才删除;协作成员按名字合并。前后端同一套规则, 工具集合变小时自查报警、改图回执列出变化 - 新校验规则:提示词点名了某个工具而节点没有绑定它,判错误,Copilot 自查会交回去修 - 校验修复只许改格式:冒出原文没有的值就判修复无效;修复这次模型调用计入用量 - 识别模型把工具调用写成文字的情况(DSML、<tool_call> 等标记):纠正一次,仍然如此就判失败并说明原因 - 协作团队用完轮数不再按成功交付:默认失败,可选降档,点出一次都没派到的成员 - 审批恢复后,同一节点里已经执行过的工具调用复用结果,不再重复执行 后端 - 查询时限交给数据库执行(Oracle call_timeout、PG statement_timeout、MySQL max_execution_time、 SQLite progress handler),可按数据源配置;超时报错与实际耗时一致 - 等审批的运行可以真正取消;会话列表带最后一轮状态;测连接结果持久化;探查结构可只看不存; 自定义工具可草稿试跑、写坏的参数定义保存时拒绝;MCP 服务改动后作废旧工具缓存; 检索命中带贡献分解;重建索引在后台跑并报进度;报错统一收进 core/errors 前端 - 公共层:管理页共用件、失败说明(lib/explain)、健康状态进公共库;目录按表各自加载并有超时, 一张表卡住不再拖垮整站;上传显示真实字节进度;离线横幅只播报不变的句子 - 画布:卡片只说游标所在的那一次执行;等审批时可放弃运行;团队收尾判定与未完成说清楚; 回放用量取那一刻的值 - 编排页:Copilot 合并语义与工具变化回执;「用完轮数时」选项;正式运行期间锁住所有改图入口 - 回答流 / 问数据:新失败类型说人话并给下一步;画布与右栏互相定位;数据源范围、回收站、重新核对 - 记录页:「航迹」页签复用画布时间轴,可拖动回放、列出关键时刻;列表行带执行 / 等人 / 失败缩略条 - 外壳:统一的离开前确认(data router),导航、⌘K、快捷键、后退都会先问 验证(沙箱):tsc 干净,pytest 838 passed(两遍),14 个检查脚本中 13 个全绿; check-ui 余 1 项旧断言(按旧日期格式找运行行),3c 更新。
209 lines
9.0 KiB
Python
209 lines
9.0 KiB
Python
"""校验和报错写给人看:用界面上的叫法,说清原因和下一步,不露 Python 内部名字。
|
||
|
||
- 分支 case 的 key 用了保留名 default:和兜底出口撞成同一个 id,跑完两条一起点亮,
|
||
看不出实际走了哪条。重复 key、空 key 更糟——那个 case 根本路由不到。
|
||
- 门禁文案写「受管模板不允许 supervisor 节点」「approval=never」「该是个 llm 节点」,
|
||
界面上它们叫「多 Agent 协作」「全部自动放行」「模型调用」,报错引用了用户从没见过的词。
|
||
- 运行失败的首行写着「工具 db_query__shop 执行失败:_make_query_tool.<locals>._run()
|
||
got an unexpected keyword argument 'query'」,恢复失败写「当前状态是 succeeded」。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
|
||
import pytest
|
||
from sqlalchemy import select
|
||
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import Run, RunEvent
|
||
from app.engine import compiler
|
||
from app.engine.governance import lint_for_publish
|
||
from app.engine.runner import run_manager
|
||
from app.engine.schema import GraphSpec, NodeType, validate_graph
|
||
|
||
|
||
def node(nid, ntype, label=None, **config):
|
||
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0},
|
||
"data": {"label": label or nid, "config": config}}
|
||
|
||
|
||
def _branch(cases: list[dict]) -> GraphSpec:
|
||
return GraphSpec.model_validate({
|
||
"nodes": [
|
||
node("start", "input"),
|
||
node("br", "branch", label="走哪条", mode="expression", cases=cases),
|
||
node("a", "output"), node("b", "output"),
|
||
],
|
||
"edges": [
|
||
{"source": "start", "target": "br"},
|
||
{"source": "br", "target": "a", "sourceHandle": "fast"},
|
||
{"source": "br", "target": "b", "sourceHandle": "default"},
|
||
],
|
||
})
|
||
|
||
|
||
def _issues(spec: GraphSpec, level: str) -> list[str]:
|
||
return [i.message for i in validate_graph(spec).issues if i.level == level and i.node_id == "br"]
|
||
|
||
|
||
def test_case_key_default_is_flagged_as_the_reserved_fallback():
|
||
spec = _branch([{"key": "fast", "condition": "true"},
|
||
{"key": "default", "condition": "true", "label": "协作模式"}])
|
||
warnings = _issues(spec, "warning")
|
||
assert any("default" in m and "保留" in m for m in warnings), warnings
|
||
# 现存的 ⑥ 模板就是这么写的:条件为 true 的 default 等价于兜底,运行语义自洽。
|
||
# 升成 error 会让它和所有从它复制出来的图直接不能跑
|
||
assert validate_graph(spec).ok
|
||
|
||
|
||
def test_duplicate_case_keys_are_an_error():
|
||
spec = _branch([{"key": "fast", "condition": "vars.a"}, {"key": "fast", "condition": "vars.b"}])
|
||
errors = _issues(spec, "error")
|
||
assert any("fast" in m and "重复" in m for m in errors), errors
|
||
assert not validate_graph(spec).ok
|
||
|
||
|
||
def test_blank_case_key_is_an_error():
|
||
spec = _branch([{"key": "fast", "condition": "vars.a"}, {"key": " ", "condition": "vars.b"}])
|
||
assert any("标识" in m for m in _issues(spec, "error")), _issues(spec, "error")
|
||
|
||
|
||
def test_validation_uses_the_names_on_the_canvas():
|
||
spec = GraphSpec.model_validate({
|
||
"nodes": [node("start", "input"), node("sub", "subgraph"), node("t", "tool"),
|
||
node("v", "validate"), node("out", "output")],
|
||
"edges": [{"source": "start", "target": "sub"}, {"source": "sub", "target": "t"},
|
||
{"source": "t", "target": "v"}, {"source": "v", "target": "out"}],
|
||
})
|
||
messages = " ".join(i.message for i in validate_graph(spec).issues)
|
||
assert "子工作流" in messages and "子图节点" not in messages
|
||
assert "「调用工具」" in messages
|
||
assert "结构校验" in messages
|
||
|
||
|
||
def _governed(*nodes) -> str:
|
||
spec = GraphSpec.model_validate({
|
||
"nodes": [node("start", "input"), *nodes],
|
||
"edges": [{"source": "start", "target": n["id"]} for n in nodes],
|
||
})
|
||
return " ".join(i.message for i in lint_for_publish(spec, level="governed").issues)
|
||
|
||
|
||
def test_governance_speaks_the_ui_vocabulary():
|
||
text = _governed(
|
||
node("team", "supervisor", label="协作团队"),
|
||
node("ag", "agent", label="取数员", tools=["web_search"], approval="never"),
|
||
node("bare", "agent", label="空手的"),
|
||
node("c", "code", label="算一下", network=True),
|
||
)
|
||
for internal in ("supervisor", "approval=never", "llm 节点", "contract"):
|
||
assert internal not in text, f"门禁文案里还有内部词 {internal!r}:{text}"
|
||
assert "「协作团队」" in text and "多 Agent 协作" in text
|
||
assert "审批策略" in text and "全部自动放行" in text
|
||
assert "模型调用" in text
|
||
assert "出具契约" in text
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 运行期报错
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture
|
||
async def engine_up():
|
||
await run_manager.setup()
|
||
yield
|
||
await run_manager.shutdown()
|
||
|
||
|
||
async def _finish(run_id: str) -> Run:
|
||
for _ in range(300):
|
||
async with SessionLocal() as session:
|
||
row = await session.get(Run, run_id)
|
||
if row.status in ("succeeded", "failed", "cancelled", "interrupted"):
|
||
return row
|
||
await asyncio.sleep(0.03)
|
||
raise AssertionError("等超时了")
|
||
|
||
|
||
RAW = "_make_query_tool.<locals>._run() got an unexpected keyword argument 'query'"
|
||
|
||
|
||
async def test_a_python_error_reads_like_a_sentence(engine_up, monkeypatch):
|
||
async def broken(state, ctx):
|
||
raise TypeError(RAW)
|
||
|
||
monkeypatch.setitem(compiler.RUNNERS, NodeType.TRANSFORM, broken)
|
||
graph = {
|
||
"nodes": [node("start", "input"), node("shape", "transform", label="整理数据",
|
||
mode="template", template="x")],
|
||
"edges": [{"source": "start", "target": "shape"}],
|
||
}
|
||
run = await run_manager.start(graph=graph, input_payload={})
|
||
row = await _finish(run.id)
|
||
assert row.status == "failed"
|
||
for text in (row.error or "",):
|
||
assert "TypeError" not in text and "<locals>" not in text, text
|
||
assert "query" in text, "原因要说到点子上:哪个参数对不上"
|
||
assert row.error_node_id == "shape"
|
||
|
||
async with SessionLocal() as session:
|
||
events = list((await session.execute(
|
||
select(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq)
|
||
)).scalars())
|
||
failed_node = next(e for e in events if e.type == "node.failed")
|
||
assert "TypeError" not in failed_node.data["error"]
|
||
assert "TypeError" in failed_node.data["detail"], "原始异常要留着,给排查的人看"
|
||
failed_run = next(e for e in events if e.type == "run.failed")
|
||
assert failed_run.data["node_id"] == "shape"
|
||
assert failed_run.data["label"] == "整理数据"
|
||
assert "TypeError" not in failed_run.data["error"]
|
||
|
||
|
||
async def test_resume_refusal_does_not_leak_the_status_enum(engine_up):
|
||
async with SessionLocal() as session:
|
||
run = Run(workflow_name="x", status="succeeded", graph={"nodes": [], "edges": []}, input={})
|
||
session.add(run)
|
||
await session.commit()
|
||
with pytest.raises(ValueError) as caught:
|
||
await run_manager.resume(run.id, {"approved": True})
|
||
assert "succeeded" not in str(caught.value)
|
||
assert "已完成" in str(caught.value)
|
||
|
||
|
||
def test_exception_wording_helper():
|
||
from app.core.errors import describe_exception
|
||
|
||
assert "query" in describe_exception(TypeError(RAW))
|
||
assert "TypeError" not in describe_exception(TypeError(RAW))
|
||
timeout = describe_exception(TimeoutError())
|
||
assert timeout and "Timeout" not in timeout
|
||
generic = describe_exception(RuntimeError("Integer exceeds 64-bit range"))
|
||
assert "RuntimeError" not in generic and "64-bit" in generic
|
||
nested = describe_exception(RuntimeError("连接失败:ConnectError: All connection attempts failed"))
|
||
assert "Error" not in nested and "All connection attempts failed" in nested
|
||
|
||
|
||
async def test_issuance_event_says_why_it_was_downgraded(engine_up):
|
||
"""只有 gaps 时判成降档,事件里却不带 gaps:时间线上只剩一个光秃秃的「降档出具」,
|
||
横幅还说「请对照下方声明」,下方什么都没有。"""
|
||
graph = {
|
||
"nodes": [
|
||
node("start", "input"),
|
||
node("out", "output", fields=[{"name": "结论", "value": "本周 12 单"}],
|
||
contract={"metrics_from": "没有这个口径卡", "narrative": "{{ output.结论 }}"}),
|
||
],
|
||
"edges": [{"source": "start", "target": "out"}],
|
||
}
|
||
run = await run_manager.start(graph=graph, input_payload={})
|
||
row = await _finish(run.id)
|
||
assert row.status == "succeeded", row.error
|
||
async with SessionLocal() as session:
|
||
issuance = (await session.execute(
|
||
select(RunEvent).where(RunEvent.run_id == run.id, RunEvent.type == "issuance")
|
||
)).scalars().one()
|
||
assert issuance.data["tier"] == "degraded"
|
||
assert any("没有这个口径卡" in g for g in issuance.data["gaps"]), issuance.data
|
||
assert issuance.data["metrics_checked"] == 0
|