mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:51:58 +00:00
fcdcc30d4e
一、可点击证据第二期
取数链路
- 智能体、工具节点、代码节点、知识检索取数时都记进证据台账;报告目录按台账编出 Q1… / K1…
- 报告可以引用快照里的一格([[v:Q1.r0.gmv]]),也可以由系统从快照生成整张表([[table:Q1 cols=… rows=0-4]]),
表里每一格都是可点的片段;取值一律以快照为准,取回快照时复验哈希
- 口径卡表达式新增 cell(x, row, column),lineage 精确到单元格
- 智能体开 cite_fields(配合 output_schema)后,循环结束做一次结构化抽取,每个字段标出取自哪次查询的哪一格,
再到快照里核对:一致记 verified;不一致以快照为准并告警;查不到的记为空值,永远不兜底成 0
- 代码节点新增 evidence_role(source / compute)和 code_sha;沙箱算出来的数不能被报告直接引用,要进口径卡
- 查询快照记下列类型,文本列按文本渲染(年份 "2026" 不再显示成「2,026」)
- 升级前发起的运行(停在审批上的)一律走旧逻辑:新的 task 只追加在循环之后,恢复时不会错号
证据接口与面板
- 片段链接上查询步骤:SQL、被引用的行加前后各 2 行、高亮到格;数据源可设 mask_columns 遮罩列,
遮罩在查询时记进快照,数据源事后改名或删除也照样遮罩(在有身份体系之前,遮罩只减少暴露,不是安全边界)
- 输入来源写明核对结果(与快照一致 / 模型报的值和快照不一致 / 没查到记为空);口径卡缺输入时也给出完整的链
- 受管出具要在契约里声明 cells 才能引用单元格;受管与否记在所钉版本上,改画布不再让受管版本的正式运行降级
- 口径卡可以用 caliber_from 钉住另一个已发布工作流里的口径卡,上游有新版本时按 upgrade_policy 处置,
和子工作流同一套规则;子工作流节点也补上了升版处置的配置项
Copilot、校验与报错
- 问数据的答案里有数字时,搭 input → 智能体 → 报告 → 出口,报告直接引用查过的单元格
- 写 SQL 前核对数据源的表结构,SQL 里出现表结构里没有的列会被打回
- 不再搭「智能体文字 → 转换节点解析 JSON」这种图;转换节点的 JSON 报错分清是上游模型写坏了、
模板里裸插了模型文字(给出 | json 的改法),还是模板本身写错了
- 工具节点遇到「查询失败」「SQL 被拒绝」判为失败;发起运行前检查绑定的工具是否存在,不存在时直接拒绝并给出入口
二、发布前自动修复
- 问题带上机器编号(code)和修复 id;新增只读接口 POST /api/workflows/{id}/publish-check 和 /autofix
- 修复分三类:答案唯一、不降低要求的一键修复;要人拿主意的给选项(不替人选);结构性问题交给 Copilot
- 一律先预览,不自动保存、不自动发布;删节点、删契约、放宽审批、关掉 strict、改级别等降低要求的改动一律拒绝,
Copilot 的方案也要过这一关;应用后重跑检查,错误必须变少且不能冒出新的
- 发布弹窗打开就先检查;问题面板新增「发布前检查」,平时就能看到、就地修掉
- 门禁修正:契约有 report_from 时不再要求 narrative(模板⑨可以发成受管);全图默认审批为「全部放行」时同样拦下
- 修掉画布保存会抹掉全图默认(defaults)的老问题:以前任何一次保存都会把它清空,审批 always 因此被悄悄放宽
验证(沙箱):tsc 干净,生产构建通过,pytest 2268 passed(两遍),check-all 17 项检查、3915 项全过
(新增 check-publish);关键行为做了变异验证、全部抓到。
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
|