mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 18:31:59 +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 并注明理由
186 lines
7.4 KiB
Python
186 lines
7.4 KiB
Python
"""三道"配了、显示了、却没人执行"的护栏。
|
|
|
|
- max_run_seconds:配置里有,没有任何代码执行它。一个挂住的模型或工具能把运行
|
|
和它占着的并发名额一直拖着。
|
|
- 模型调用超时:ModelSpec.timeout 从没被赋过值,所有调用都走 SDK 默认的 10 分钟
|
|
外加重试。
|
|
- manifest 哈希:只写不验,而且是在 run.finished 落库之前算的——清单里恰恰少了
|
|
那条宣布"跑完了、成果是什么"的事件。
|
|
|
|
显示成生效却没生效,比明说没有更危险:它让人以为有东西在兜底。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlalchemy import select, update
|
|
|
|
from app.core.artifact_store import manifest_hash
|
|
from app.core.config import settings
|
|
from app.core.crypto import encrypt
|
|
from app.db.base import SessionLocal
|
|
from app.db.models import Provider, Run, RunEvent
|
|
from app.engine import compiler
|
|
from app.engine.runner import run_manager, verify_manifest
|
|
from app.engine.schema import NodeType
|
|
from app.main import app
|
|
from app.providers.factory import ModelSpec, build_chat_model
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
async def engine_up():
|
|
await run_manager.setup()
|
|
yield
|
|
await run_manager.shutdown()
|
|
|
|
|
|
def node(nid, ntype, **config):
|
|
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0},
|
|
"data": {"label": nid, "config": config}}
|
|
|
|
|
|
def chain(*nodes):
|
|
return {"nodes": list(nodes),
|
|
"edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
|
|
|
|
|
|
SIMPLE = chain(
|
|
node("start", "input", fields=[{"name": "question"}]),
|
|
node("t", "transform", mode="template", template="好", assign_to="x"),
|
|
node("out", "output", fields=[{"name": "结果", "value": "{{ vars.x }}"}]),
|
|
)
|
|
|
|
|
|
async def _run(graph, *, timeout=20.0) -> Run:
|
|
run = await run_manager.start(graph=graph, input_payload={"question": "随便"})
|
|
deadline = asyncio.get_event_loop().time() + timeout
|
|
while asyncio.get_event_loop().time() < deadline:
|
|
async with SessionLocal() as session:
|
|
row = await session.get(Run, run.id)
|
|
# 终态先落库、清单随后另一个事务封上(runner._finalize):只等状态的话,负载一高就会
|
|
# 读到「跑完了但还没封」的那一瞬。中断态不封存
|
|
if row and (row.status == "interrupted"
|
|
or row.status in ("succeeded", "failed", "cancelled") and row.manifest_seq is not None):
|
|
return row
|
|
await asyncio.sleep(0.05)
|
|
raise AssertionError("运行没有按时结束")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# max_run_seconds
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def test_a_run_past_its_time_limit_is_stopped(monkeypatch):
|
|
async def stuck(state, ctx):
|
|
await asyncio.sleep(30) # 一个不响应的模型或工具
|
|
return {}
|
|
|
|
monkeypatch.setitem(compiler.RUNNERS, NodeType.TRANSFORM, stuck)
|
|
monkeypatch.setattr(settings, "max_run_seconds", 1)
|
|
|
|
started = time.perf_counter()
|
|
run = await _run(SIMPLE)
|
|
assert run.status == "failed"
|
|
assert "1 秒的时长上限" in (run.error or "")
|
|
assert time.perf_counter() - started < 10, "上限没起作用,等到了节点自己结束"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 模型调用超时
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_models_get_a_timeout_even_when_the_node_sets_none():
|
|
provider = Provider(name="t", kind="anthropic", api_key=encrypt("sk-test"), models=[],
|
|
default_model="claude-sonnet-5", enabled=True, extra={})
|
|
model = build_chat_model(provider, ModelSpec())
|
|
assert model.default_request_timeout == settings.model_timeout_seconds
|
|
# 节点显式配了的照旧优先
|
|
assert build_chat_model(provider, ModelSpec(timeout=42)).default_request_timeout == 42
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# manifest
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def _events(run_id):
|
|
async with SessionLocal() as session:
|
|
return list((await session.execute(
|
|
select(RunEvent).where(RunEvent.run_id == run_id).order_by(RunEvent.seq)
|
|
)).scalars())
|
|
|
|
|
|
async def test_the_seal_covers_run_finished_and_verifies():
|
|
run = await _run(SIMPLE)
|
|
assert run.status == "succeeded", run.error
|
|
events = await _events(run.id)
|
|
finished = next(e for e in events if e.type == "run.finished")
|
|
assert run.manifest_seq == finished.seq, "清单没有封到 run.finished"
|
|
|
|
report = await verify_manifest(run.id)
|
|
assert report["sealed"] and report["ok"], report
|
|
|
|
|
|
async def test_tampering_with_the_output_is_caught():
|
|
"""改成果——以前 run.finished 不在清单里,这一改清单照样对得上。"""
|
|
run = await _run(SIMPLE)
|
|
async with SessionLocal() as session:
|
|
await session.execute(
|
|
update(RunEvent)
|
|
.where(RunEvent.run_id == run.id, RunEvent.type == "run.finished")
|
|
.values(data={"output": {"结果": "被改过的结论"}})
|
|
)
|
|
await session.commit()
|
|
report = await verify_manifest(run.id)
|
|
assert report["ok"] is False
|
|
|
|
|
|
async def test_events_appended_after_the_seal_are_not_tampering():
|
|
"""封存之后追加的事件(复核批注之类)不在范围内,不该让核对失败。"""
|
|
run = await _run(SIMPLE)
|
|
async with SessionLocal() as session:
|
|
row = await session.get(Run, run.id)
|
|
session.add(RunEvent(run_id=run.id, seq=row.manifest_seq + 5, type="log", ts=time.time(),
|
|
node_id=None, data={"message": "事后追加的批注"}))
|
|
await session.commit()
|
|
assert (await verify_manifest(run.id))["ok"] is True
|
|
|
|
|
|
async def test_runs_sealed_before_manifest_seq_existed_verify_by_the_old_cut():
|
|
"""老运行的哈希是在 run.finished 落库之前算的,没有 manifest_seq——按当时的口径核。"""
|
|
run = await _run(SIMPLE)
|
|
rows = [(e.seq, e.type, e.node_id, e.data) for e in await _events(run.id)
|
|
if e.type != "run.finished"]
|
|
async with SessionLocal() as session:
|
|
await session.execute(update(Run).where(Run.id == run.id)
|
|
.values(manifest_hash=manifest_hash(rows), manifest_seq=None))
|
|
await session.commit()
|
|
report = await verify_manifest(run.id)
|
|
assert report["ok"] is True and report["legacy"] is True
|
|
|
|
|
|
async def test_a_run_waiting_for_a_human_is_not_sealed_yet():
|
|
graph = chain(
|
|
node("start", "input", fields=[{"name": "question"}]),
|
|
node("h", "human", mode="approve", title="批吗"),
|
|
node("out", "output", fields=[]),
|
|
)
|
|
run = await _run(graph)
|
|
assert run.status == "interrupted"
|
|
report = await verify_manifest(run.id)
|
|
assert report["sealed"] is False and report["ok"] is None
|
|
|
|
|
|
async def test_verify_through_the_api():
|
|
run = await _run(SIMPLE)
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
ok = await client.get(f"/api/runs/{run.id}/verify")
|
|
assert ok.status_code == 200 and ok.json()["ok"] is True
|
|
assert (await client.get("/api/runs/没有这个/verify")).status_code == 404
|