mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 20:01:57 +00:00
03c18c4d78
一、文案(前端约 1600 处,后端传到界面的约 880 处) - 术语统一:工作流层面用「运行」(继续运行、重新运行、试运行),节点和工具层面用「执行」,不再用「跑」; 总时长 / 执行时长 / 等待审批;服务端;助手;Agent;token;工作流;固定版本;模型接入;系统提示;批准 / 驳回;受管级别 - 口语改书面:取不到、没取回来、读不懂、连不上、多半、照样、兜底、揉成这类说法改成「无法 / 未能 / 失败 / 请」;去掉拟人写法 - 不再露出实现细节:界面上不写 checkpoint、同一拍、哈希(凭证处除外),不写改版历史(「以前…」「老数据」); 报错、校验、门禁、自动修复里裸写的配置键名和枚举值换成界面上的中文标签,由新的 backend/app/engine/labels.py 统一提供; 发布弹窗里结论句的选项不再是 off / require_citation - 同一件事只留一种说法:生成阶段文案、出具档位说明、画布锁定提示各合成一份 - 去掉界面上的工业场景示例(MES、ERP、「张工」「王工」)和真实产品名,换成通用示例 二、说错的提示(都有测试证明) - 数据库测连接失败时的专用提示以前从不出现(按报错文字全等比较,永远比不上):改为按错误类型判断,不再拼出重复的前缀 - 「设置 → 供应商」改指「设置 → 模型接入」;「在设置里建数据源」改指「数据」页 - 缺少 API Key、模型接入已停用的报错不再夹英文,写明去哪里填 - 两处原因说错(「这种引用在后续版本支持」「这一版还不支持」)改成真实原因 - 节点说明里不准确的说法改正:代码节点在 microVM 或系统沙箱中执行;报告撰写、口径卡的说明不再说过头 - 导出的证据 CSV 里两列同名,第三列改为「片段编号」 三、前后端对齐 - 前端按后端原文识别日志、报错的地方,新旧两种写法都认得(历史运行里存的是旧文字),逐条补了新旧原文的核对 - 后端交回模型的原指令放进单独的 for_model 字段,前端在解析响应时去掉,不会显示到界面上 四、检查 - 检查脚本里按原文匹配的断言改成新文案;改完会变成永远成立的反向断言逐条修正,并做了变异验证 - 新增 check-copy(静态扫描前端界面字符串和后端上界面的文案):再出现「跑」「后端」「多半」「令牌」、 界面上的 Copilot、裸写的配置键名、半角标点等就报错;确需保留的写进 scripts/copy-allow.json 并注明理由
140 lines
6.6 KiB
Python
140 lines
6.6 KiB
Python
"""发起运行前检查绑定的工具:本机不存在的,发起就拒绝(422),不要等跑到那一步才失败。
|
|
|
|
用户手上真实踩到的:图里的 agent 绑着一个已经删掉的数据源工具,运行跑了几分钟、花了几轮
|
|
模型调用才在半路报错。查的方式和运行时 build_tools 的解析一致:内置、mcp:、
|
|
db_query__ / db_schema__、自定义。MCP 连不上时不拦,只在日志里提示。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlalchemy import delete, select
|
|
|
|
from app.db.base import SessionLocal
|
|
from app.db.models import CustomTool, DataSource, McpServer, Workflow, WorkflowVersion
|
|
from app.engine.runner import run_manager
|
|
from app.main import app
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
async def engine_up():
|
|
await run_manager.setup()
|
|
yield
|
|
await run_manager.shutdown()
|
|
|
|
|
|
@pytest.fixture
|
|
async def client():
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
|
yield c
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
async def catalog(tmp_path):
|
|
"""本机有:数据源 shop(启用)、archive(停用);自定义工具 lookup;MCP 服务 files(连得上)、
|
|
flaky(上次没连上)、off(停用)。别的测试也会建同名的行,这里按名字补齐状态,不删别人的。"""
|
|
rows = [
|
|
(DataSource, "shop", {"kind": "sqlite", "database": str(tmp_path / "shop.db"), "readonly": True,
|
|
"enabled": True}),
|
|
(DataSource, "archive", {"kind": "sqlite", "database": str(tmp_path / "a.db"), "readonly": True,
|
|
"enabled": False}),
|
|
(CustomTool, "lookup", {"kind": "http", "description": "查编码", "parameters": {}, "enabled": True}),
|
|
(McpServer, "files", {"transport": "stdio", "command": "true", "enabled": True, "status": "ok",
|
|
"tools_cache": ["read_file"]}),
|
|
(McpServer, "flaky", {"transport": "stdio", "command": "true", "enabled": True, "status": "error"}),
|
|
(McpServer, "off", {"transport": "stdio", "command": "true", "enabled": False}),
|
|
]
|
|
async with SessionLocal() as session:
|
|
for model, name, fields in rows:
|
|
row = (await session.execute(select(model).where(model.name == name))).scalar_one_or_none()
|
|
if row is None:
|
|
row = model(name=name)
|
|
session.add(row)
|
|
for key, value in fields.items():
|
|
setattr(row, key, value)
|
|
for model, name in ((DataSource, "nope"), (CustomTool, "ghost_tool"), (McpServer, "gone")):
|
|
await session.execute(delete(model).where(model.name == name))
|
|
await session.commit()
|
|
yield
|
|
|
|
|
|
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 graph(*middle):
|
|
nodes = [node("start", "input"), *middle, node("out", "output", fields=[{"name": "r", "value": "{{ last_message }}"}])]
|
|
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
|
|
|
|
|
|
async def start(client, g):
|
|
return await client.post("/api/runs", json={"graph": g})
|
|
|
|
|
|
async def test_a_missing_datasource_tool_is_refused_and_named(client):
|
|
res = await start(client, graph(node("fetch", "tool", "取数", tool="db_query__nope", args={"sql": "SELECT 1"})))
|
|
assert res.status_code == 422, res.text
|
|
body = res.json()
|
|
assert body["code"] == "run_tool_missing"
|
|
assert "「取数」" in body["detail"] and "db_query__nope" in body["detail"]
|
|
assert "请到「数据」页接入,或在节点中重新选择" in body["detail"]
|
|
|
|
|
|
async def test_every_missing_binding_is_listed(client):
|
|
res = await start(client, graph(
|
|
node("bot", "agent", "分析", prompt="查", approval="never",
|
|
tools=["web_search", "db_schema__archive", "db_query__shop", "ghost_tool", "mcp:off/x"]),
|
|
node("team", "supervisor", "协作", goal="g", agents=[
|
|
{"name": "研究员", "tools": ["mcp:gone/read"]}, {"name": "写手", "tools": ["lookup"]}]),
|
|
))
|
|
assert res.status_code == 422, res.text
|
|
detail = res.json()["detail"]
|
|
# 停用的数据源、库里没有的自定义工具、停用的 MCP 服务、没登记的 MCP 服务都算不存在
|
|
for name in ("db_schema__archive", "ghost_tool", "mcp:off/x", "mcp:gone/read"):
|
|
assert name in detail, (name, detail)
|
|
assert "「分析」" in detail and "「协作」的成员「研究员」" in detail
|
|
# 在的不点名
|
|
for name in ("web_search", "db_query__shop", "lookup"):
|
|
assert name not in detail, (name, detail)
|
|
|
|
|
|
async def test_existing_tools_pass(client):
|
|
res = await start(client, graph(
|
|
node("fetch", "tool", "取数", tool="db_query__shop", args={"sql": "SELECT 1"}),
|
|
node("bot", "agent", "分析", prompt="查", approval="never",
|
|
tools=["web_search", "db_schema__shop", "lookup", "mcp:files/read_file", "mcp:files/*"]),
|
|
))
|
|
assert res.status_code == 201, res.text
|
|
|
|
|
|
async def test_an_unreachable_mcp_server_is_not_blocked(client, caplog):
|
|
"""服务登记着、只是上次没连上:可能只是暂时的,发起时不拦,日志里提一句。"""
|
|
with caplog.at_level(logging.WARNING, logger="app.api.runs"):
|
|
res = await start(client, graph(node("bot", "agent", "分析", prompt="查", approval="never",
|
|
tools=["mcp:flaky/search", "mcp:files/not_listed"])))
|
|
assert res.status_code == 201, res.text
|
|
assert "flaky" in caplog.text and "not_listed" in caplog.text
|
|
|
|
|
|
async def test_formal_runs_are_checked_too(client):
|
|
g = graph(node("fetch", "tool", "取数", tool="db_query__nope", args={"sql": "SELECT 1"}))
|
|
async with SessionLocal() as session:
|
|
wf = Workflow(name="周报", graph=g, status="published", published_version=1)
|
|
session.add(wf)
|
|
await session.flush()
|
|
session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=g))
|
|
await session.commit()
|
|
wf_id = wf.id
|
|
res = await client.post("/api/runs", json={"workflow_id": wf_id, "run_class": "formal"})
|
|
assert res.status_code == 422 and "db_query__nope" in res.json()["detail"]
|
|
# 保存过的工作流发起探索运行也查
|
|
res = await client.post("/api/runs", json={"workflow_id": wf_id})
|
|
assert res.status_code == 422
|
|
|
|
|
|
async def test_graphs_without_tools_are_untouched(client):
|
|
res = await start(client, graph(node("fmt", "transform", mode="expression", expression="1")))
|
|
assert res.status_code == 201, res.text
|