mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 19:01: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 更新。
161 lines
7.5 KiB
Python
161 lines
7.5 KiB
Python
"""Copilot 搭完图先自查:和运行时同一套规则,有问题交回去改,改好再交付。
|
|
|
|
以前校验结果只是附在 final 上交给前端,error 级的问题照样交付、照样自动开跑,
|
|
跑到那一步才炸——开发库里四次运行就死在条件表达式上,前面的步骤全白跑。
|
|
|
|
这里守四件事:写错了会被交回去改、改好的图才自动运行;改不好也照实交付但不自动
|
|
运行;没问题的图不多花一次调用;只是多套了 {{ }} 的不算错,不为它返工。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.main import app
|
|
|
|
|
|
@pytest.fixture
|
|
async def client():
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
|
|
|
|
def _graph_ops(condition: str) -> list[str]:
|
|
"""一张 while 循环的图,条件由参数给。"""
|
|
ops = [
|
|
{"op": "plan", "summary": "数一数"},
|
|
{"op": "add_node", "node": {"id": "start", "type": "input", "label": "输入",
|
|
"config": {"fields": [{"name": "question"}]}}},
|
|
{"op": "add_node", "node": {"id": "init", "type": "transform", "label": "准备",
|
|
"config": {"mode": "expression", "expression": "[1, 2]",
|
|
"assign_to": "items"}}},
|
|
{"op": "add_edge", "edge": {"source": "start", "target": "init"}},
|
|
{"op": "add_node", "node": {"id": "lp", "type": "loop", "label": "循环",
|
|
"config": {"mode": "while", "condition": condition,
|
|
"max_iterations": 2}}},
|
|
{"op": "add_edge", "edge": {"source": "init", "target": "lp"}},
|
|
{"op": "add_node", "node": {"id": "body", "type": "transform", "label": "循环体",
|
|
"config": {"mode": "expression", "expression": "1"}}},
|
|
{"op": "add_edge", "edge": {"source": "lp", "target": "body", "sourceHandle": "body"}},
|
|
{"op": "add_edge", "edge": {"source": "body", "target": "lp"}},
|
|
{"op": "add_node", "node": {"id": "out", "type": "output", "label": "成果",
|
|
"config": {"fields": [{"name": "结果", "value": "{{ vars.items }}"}]}}},
|
|
{"op": "add_edge", "edge": {"source": "lp", "target": "out", "sourceHandle": "done"}},
|
|
{"op": "done", "explanation": "数一数", "run": True},
|
|
]
|
|
return [json.dumps(o, ensure_ascii=False) for o in ops]
|
|
|
|
|
|
def _fix_ops(condition: str) -> list[str]:
|
|
return [json.dumps(o, ensure_ascii=False) for o in (
|
|
{"op": "plan", "summary": "改条件"},
|
|
{"op": "update_node", "id": "lp",
|
|
"config": {"mode": "while", "condition": condition, "max_iterations": 2}},
|
|
{"op": "done", "explanation": "改好了"},
|
|
)]
|
|
|
|
|
|
class _Scripted:
|
|
"""第一轮吐 first;收到自查请求(human 里有"上线前自查")时吐 fix。"""
|
|
|
|
def __init__(self, first: list[str], fix: list[str]) -> None:
|
|
self.first, self.fix = first, fix
|
|
self.calls: list[str] = []
|
|
|
|
async def astream(self, messages):
|
|
from langchain_core.messages import AIMessageChunk
|
|
|
|
human = next(text for role, text in messages if role == "human")
|
|
self.calls.append(human)
|
|
for line in (self.fix if "上线前自查" in human else self.first):
|
|
yield AIMessageChunk(content=line + "\n")
|
|
|
|
|
|
async def _generate(client, monkeypatch, model: _Scripted) -> list[dict]:
|
|
import app.api.copilot as copilot
|
|
|
|
async def _model(*_a, **_k):
|
|
return model, "mock-fast"
|
|
|
|
monkeypatch.setattr(copilot, "get_chat_model", _model)
|
|
events: list[dict] = []
|
|
async with client.stream("POST", "/api/copilot/generate-stream",
|
|
json={"instruction": "数一数"}) as r:
|
|
async for line in r.aiter_lines():
|
|
if line.startswith("data: "):
|
|
events.append(json.loads(line[6:]))
|
|
return events
|
|
|
|
|
|
def _condition(final: dict) -> str:
|
|
return next(n for n in final["graph"]["nodes"] if n["id"] == "lp")["data"]["config"]["condition"]
|
|
|
|
|
|
async def test_a_broken_condition_is_sent_back_and_fixed_before_delivery(client, monkeypatch):
|
|
model = _Scripted(_graph_ops("{{ vars.items | length }} > 5"), _fix_ops("len(vars.items) > 5"))
|
|
events = await _generate(client, monkeypatch, model)
|
|
|
|
checks = [e for e in events if e["op"] == "check"]
|
|
assert [c["status"] for c in checks] == ["repairing", "passed"], checks
|
|
assert any("len(x)" in i["message"] for i in checks[0]["issues"]), "交回去的问题里得带着能照改的提示"
|
|
assert "len(x)" in model.calls[1], "自查请求没把问题原文交给模型"
|
|
|
|
final = events[-1]
|
|
assert final["op"] == "final"
|
|
assert _condition(final) == "len(vars.items) > 5"
|
|
assert final["autorun"] is True
|
|
assert not [i for i in final["issues"] if i["level"] == "error"]
|
|
|
|
|
|
async def test_when_the_fix_does_not_take_it_is_delivered_but_not_run(client, monkeypatch):
|
|
model = _Scripted(_graph_ops("foo(vars.items)"), _fix_ops("bar(vars.items)"))
|
|
events = await _generate(client, monkeypatch, model)
|
|
|
|
checks = [e for e in events if e["op"] == "check"]
|
|
assert [c["status"] for c in checks] == ["repairing", "repairing", "failed"], checks
|
|
assert len(model.calls) == 3, "最多交回去改两轮"
|
|
final = events[-1]
|
|
assert final["autorun"] is False, "明知跑不起来的图不能自动开跑"
|
|
assert [i for i in final["issues"] if i["level"] == "error"]
|
|
|
|
|
|
async def test_a_clean_graph_costs_no_extra_call(client, monkeypatch):
|
|
model = _Scripted(_graph_ops("len(vars.items) > 5"), [])
|
|
events = await _generate(client, monkeypatch, model)
|
|
assert len(model.calls) == 1
|
|
assert [e["status"] for e in events if e["op"] == "check"] == ["passed"]
|
|
assert events[-1]["autorun"] is True
|
|
|
|
|
|
async def test_template_braces_are_accepted_without_a_rework(client, monkeypatch):
|
|
"""多套了 {{ }} 意思没有歧义,照样能跑——提示一句就行,不为它返工一轮。"""
|
|
model = _Scripted(_graph_ops("{{ vars.items }} != []"), [])
|
|
events = await _generate(client, monkeypatch, model)
|
|
assert len(model.calls) == 1
|
|
final = events[-1]
|
|
assert final["autorun"] is True
|
|
assert any("是多余的" in i["message"] for i in final["issues"])
|
|
|
|
|
|
async def test_check_issues_point_at_the_node_and_the_field(client, monkeypatch):
|
|
"""自查的问题是对象,不是拼好的一行字:界面要靠 node_id 定位到卡片、靠 field
|
|
落到检查器里具体的输入框。以前是「「lp」循环条件写错了」,节点 id 只能从字里抠。"""
|
|
model = _Scripted(_graph_ops("foo(vars.items)"), _fix_ops("bar(vars.items)"))
|
|
events = await _generate(client, monkeypatch, model)
|
|
|
|
checks = [e for e in events if e["op"] == "check"]
|
|
assert [c["status"] for c in checks] == ["repairing", "repairing", "failed"], checks
|
|
for check in checks:
|
|
assert check["issues"], check
|
|
for issue in check["issues"]:
|
|
assert isinstance(issue, dict), f"自查问题还是一行字:{issue!r}"
|
|
assert issue["node_id"] == "lp" and issue["field"] == "condition", issue
|
|
assert issue["level"] == "error"
|
|
assert issue["message"] and not issue["message"].startswith("「"), \
|
|
"节点另有 node_id,消息里不该再拼一遍"
|
|
# 交回模型的修正请求照旧是能读的一行一条,节点 id 在前
|
|
assert "「lp」" in model.calls[1]
|