mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:51:59 +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 更新。
228 lines
9.8 KiB
Python
228 lines
9.8 KiB
Python
"""自定义工具没保存也能试跑;试跑和运行时走同一套参数校验;工具自己报的失败算失败。
|
|
|
|
以前编辑器里的「试跑」只能跑已保存的版本:新建时要先保存才能试,改了代码要先
|
|
保存再试——而保存就意味着节点里立刻能用上一个还没试过的工具。
|
|
|
|
另外两件:试跑以前不按参数 schema 校验,运行时却校验,于是试跑通过的参数到了
|
|
工作流里被拒;沙箱里代码报错、地址被拦时,结果照样标「成功」,再附一段报错 JSON。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlalchemy import func, select
|
|
|
|
from app.db.base import SessionLocal
|
|
from app.db.models import CustomTool
|
|
from app.main import app
|
|
from app.sandbox.base import ExecResult
|
|
|
|
|
|
@pytest.fixture
|
|
async def client():
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
|
|
|
|
class _FakeSandbox:
|
|
def __init__(self, result: ExecResult) -> None:
|
|
self.result = result
|
|
self.codes: list[str] = []
|
|
|
|
async def run(self, code, **_kw): # noqa: ANN001, ANN003, ANN201
|
|
self.codes.append(code)
|
|
return self.result
|
|
|
|
|
|
def _sandbox(monkeypatch, **result) -> _FakeSandbox:
|
|
import app.tools.custom as custom
|
|
|
|
fake = _FakeSandbox(ExecResult(**{"ok": True, "exit_code": 0, "stdout": "", "stderr": "", **result}))
|
|
monkeypatch.setattr(custom, "sandbox_manager", fake)
|
|
return fake
|
|
|
|
|
|
async def _tool_count() -> int:
|
|
async with SessionLocal() as session:
|
|
return int((await session.execute(select(func.count(CustomTool.id)))).scalar_one())
|
|
|
|
|
|
PARAMS = {"type": "object", "properties": {"store": {"type": "string"}, "days": {"type": "integer"}},
|
|
"required": ["store"]}
|
|
|
|
|
|
async def test_an_unsaved_python_tool_can_be_tried(client, monkeypatch):
|
|
fake = _sandbox(monkeypatch, stdout='{"orders": 42}\n')
|
|
before = await _tool_count()
|
|
r = await client.post("/api/custom-tools/test", json={
|
|
"kind": "python", "parameters": PARAMS,
|
|
"config": {"code": "print(json.dumps({'orders': 42}))"},
|
|
"args": {"store": "north", "days": "7"},
|
|
})
|
|
body = r.json()
|
|
assert body["ok"] is True, body
|
|
assert body["result"] == {"orders": 42}
|
|
assert body["duration_ms"] >= 0
|
|
# 参数和运行时一样先过 schema:字符串 "7" 被纠正成整数 7(代码前导里是转义过的 JSON)
|
|
assert '\\"days\\": 7' in fake.codes[0] and '\\"store\\": \\"north\\"' in fake.codes[0]
|
|
assert await _tool_count() == before, "试跑不该落库"
|
|
|
|
|
|
async def test_arguments_that_the_schema_rejects_fail_like_they_would_at_runtime(client, monkeypatch):
|
|
_sandbox(monkeypatch, stdout="1")
|
|
r = await client.post("/api/custom-tools/test", json={
|
|
"kind": "python", "parameters": PARAMS, "config": {"code": "print(1)"}, "args": {"days": 3},
|
|
})
|
|
body = r.json()
|
|
assert body["ok"] is False
|
|
assert "store" in body["error"], "缺了哪个参数得说出来"
|
|
|
|
|
|
async def test_code_that_crashes_is_reported_as_a_failure(client, monkeypatch):
|
|
_sandbox(monkeypatch, ok=False, exit_code=1,
|
|
stderr="Traceback (most recent call last):\n ...\nNameError: name 'x' is not defined")
|
|
r = await client.post("/api/custom-tools/test", json={
|
|
"kind": "python", "config": {"code": "print(x)"}, "args": {},
|
|
})
|
|
body = r.json()
|
|
assert body["ok"] is False, "沙箱里报错了,不能标成功"
|
|
assert "退出码 1" in body["error"]
|
|
assert "NameError" in body["detail"], "原始报错要留着,用户得照着改代码"
|
|
assert body["hint"]
|
|
|
|
|
|
async def test_an_http_tool_to_a_private_address_is_blocked_and_says_so(client):
|
|
r = await client.post("/api/custom-tools/test", json={
|
|
"kind": "http", "config": {"url": "http://127.0.0.1:9/orders", "method": "GET"}, "args": {},
|
|
})
|
|
body = r.json()
|
|
assert body["ok"] is False
|
|
assert "拦截" in body["error"] or "内网" in body["error"], body
|
|
|
|
|
|
async def test_an_http_tool_renders_its_template_with_the_args(client, monkeypatch):
|
|
import app.tools.custom as custom
|
|
|
|
seen: dict = {}
|
|
|
|
async def fake_request(method, url, **kw):
|
|
seen.update(method=method, url=url, **kw)
|
|
return {"status": 200, "body": '{"count": 3}'}
|
|
|
|
monkeypatch.setattr(custom, "safe_request", fake_request)
|
|
r = await client.post("/api/custom-tools/test", json={
|
|
"kind": "http", "parameters": PARAMS,
|
|
"config": {"url": "https://api.example.com/orders?store={{ store }}", "method": "GET",
|
|
"parse_json": True},
|
|
"args": {"store": "north"},
|
|
})
|
|
body = r.json()
|
|
assert body["ok"] is True, body
|
|
assert seen["url"] == "https://api.example.com/orders?store=north"
|
|
assert body["result"] == {"status": 200, "data": {"count": 3}}
|
|
|
|
|
|
@pytest.mark.parametrize("draft, words", [
|
|
({"kind": "http", "config": {}}, "接口地址"),
|
|
({"kind": "python", "config": {"code": " "}}, "代码"),
|
|
({"kind": "graphql", "config": {}}, "graphql"),
|
|
])
|
|
async def test_an_incomplete_draft_says_what_is_missing(client, draft, words):
|
|
body = (await client.post("/api/custom-tools/test", json={**draft, "args": {}})).json()
|
|
assert body["ok"] is False and words in body["error"], body
|
|
|
|
|
|
async def test_a_misspelt_argument_name_is_run_but_not_kept_quiet(client, monkeypatch):
|
|
"""参数名写错、只有一个候选时,运行时替它改名跑通,但一定会说出来(fix_note)。
|
|
|
|
试跑以前把这句说明丢了:{"cuont": 3} 照样 ok,结果里也看不出哪里不对,
|
|
用户照着这份参数去配工作流,错的名字就一直留着。
|
|
"""
|
|
fake = _sandbox(monkeypatch, stdout='{"count": 3}')
|
|
r = await client.post("/api/custom-tools/test", json={
|
|
"kind": "python", "config": {"code": "print(json.dumps(args))"},
|
|
"parameters": {"type": "object", "properties": {"count": {"type": "integer"}},
|
|
"required": ["count"]},
|
|
"args": {"cuont": 3},
|
|
})
|
|
body = r.json()
|
|
assert body["ok"] is True, body
|
|
assert '\\"count\\": 3' in fake.codes[0], "改名之后照样执行"
|
|
assert "cuont" in body["note"] and "count" in body["note"], body
|
|
|
|
# 名字写对了就不多嘴
|
|
r = await client.post("/api/custom-tools/test", json={
|
|
"kind": "python", "config": {"code": "print(1)"},
|
|
"parameters": {"type": "object", "properties": {"count": {"type": "integer"}}},
|
|
"args": {"count": 3},
|
|
})
|
|
assert "note" not in r.json(), r.json()
|
|
|
|
|
|
async def test_the_tool_library_run_also_says_when_it_renamed_an_argument(client):
|
|
"""工具库的「执行」和试跑是同一种形状,纠正说明也一样要带上。"""
|
|
r = await client.post("/api/tools/memory_recall/run", json={"args": {"query": "口径", "limt": 3}})
|
|
body = r.json()
|
|
assert body["ok"] is True, body
|
|
assert "limt" in body["note"] and "limit" in body["note"], body
|
|
|
|
|
|
@pytest.mark.parametrize("parameters, words", [
|
|
({"properties": "bad"}, "properties"),
|
|
({"properties": {"n": "int"}}, "n"),
|
|
({"properties": {"n": {"type": "int"}}}, "int"),
|
|
({"properties": {"n": {"type": ["integer", "null"]}}}, "n"),
|
|
({"properties": {"n": {"type": "integer"}}, "required": "n"}, "required"),
|
|
])
|
|
async def test_a_malformed_parameter_schema_is_the_users_to_fix(client, monkeypatch, parameters, words):
|
|
"""参数定义是用户手写的 JSON。写坏了是配置问题,不能说成「后端内部出错了」。"""
|
|
fake = _sandbox(monkeypatch, stdout="1")
|
|
body = (await client.post("/api/custom-tools/test", json={
|
|
"kind": "python", "config": {"code": "print(1)"}, "parameters": parameters, "args": {},
|
|
})).json()
|
|
assert body["ok"] is False, body
|
|
assert "参数定义" in body["error"] and words in body["error"], body
|
|
assert "内部" not in body["error"] and "维护者" not in (body["hint"] or ""), body
|
|
assert not fake.codes, "参数定义都不对,就别去沙箱里跑了"
|
|
|
|
|
|
async def test_the_saved_tool_trial_follows_the_same_rules(client, monkeypatch):
|
|
_sandbox(monkeypatch, ok=False, exit_code=2, stderr="boom")
|
|
r = await client.post("/api/custom-tools", json={
|
|
"name": "trial_saved", "kind": "python", "parameters": PARAMS, "config": {"code": "print(1)"},
|
|
})
|
|
tool = r.json()
|
|
try:
|
|
body = (await client.post(f"/api/custom-tools/{tool['id']}/test",
|
|
json={"args": {"store": "north"}})).json()
|
|
assert body["ok"] is False and "退出码 2" in body["error"]
|
|
body = (await client.post(f"/api/custom-tools/{tool['id']}/test", json={"args": {}})).json()
|
|
assert body["ok"] is False and "store" in body["error"]
|
|
finally:
|
|
await client.delete(f"/api/custom-tools/{tool['id']}")
|
|
|
|
|
|
async def test_at_runtime_a_crash_still_reaches_the_model_as_an_error_payload(monkeypatch):
|
|
"""运行时的行为不变:失败以 {"error": …} 的 JSON 交给模型,由它决定怎么办。"""
|
|
import json
|
|
|
|
from app.tools.custom import build_custom_tools
|
|
from app.tools.registry import ToolContext
|
|
|
|
_sandbox(monkeypatch, ok=False, exit_code=1, stderr="boom")
|
|
async with SessionLocal() as session:
|
|
session.add(CustomTool(name="trial_runtime", kind="python", parameters={},
|
|
config={"code": "raise SystemExit(1)"}))
|
|
await session.commit()
|
|
try:
|
|
tools = await build_custom_tools(["trial_runtime"], ToolContext(run_id="r", node_id="n"),
|
|
session)
|
|
out = json.loads(await tools[0].coroutine(input=""))
|
|
assert out["exit_code"] == 1 and out["error"]
|
|
finally:
|
|
row = (await session.execute(
|
|
select(CustomTool).where(CustomTool.name == "trial_runtime"))).scalar_one()
|
|
await session.delete(row)
|
|
await session.commit()
|