mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:31:58 +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 并注明理由
165 lines
7.0 KiB
Python
165 lines
7.0 KiB
Python
"""循环跑满自己声明的轮数,不被全局步数上限半路掐断。
|
||
|
||
开发库里的「测试 1」:while 循环 `vars.state.count <= 1000`,max_iterations 配了
|
||
1005。每轮是「循环节点 + 循环体」两步,而整次运行的步数上限(max_graph_steps)
|
||
是 200——跑到 100 轮上下 LangGraph 抛 GraphRecursionError,报错是一段英文,
|
||
叫人去调一个界面上根本碰不到的 recursion_limit。校验是通过的:这张图没写错,
|
||
是平台自己的两个上限在打架。
|
||
|
||
全局上限防的是没人把关的环;loop 节点的 max_iterations 是作者明说的轮数上限。
|
||
所以循环按自己的轮数另记一份预算(loop_steps),全局上限照旧管其余的环。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
|
||
import pytest
|
||
|
||
from app.core.config import settings
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import Run
|
||
from app.engine.runner import run_manager
|
||
from app.engine.schema import GraphSpec, loop_steps
|
||
|
||
|
||
def node(nid, ntype, **config):
|
||
return {"id": nid, "type": ntype, "data": {"label": nid, "config": config}}
|
||
|
||
|
||
def edge(source, target, handle=None):
|
||
return {"source": source, "target": target, "sourceHandle": handle}
|
||
|
||
|
||
def counting_loop(rounds: int, cap: int) -> dict:
|
||
"""「测试 1」的骨架:while 数到 rounds,循环体一个节点。"""
|
||
return {
|
||
"nodes": [
|
||
node("start", "input", fields=[]),
|
||
node("init", "transform", expression="0", assign_to="n"),
|
||
node("lp", "loop", mode="while", condition=f"vars.n < {rounds}", max_iterations=cap),
|
||
node("inc", "transform", expression="vars.n + 1", assign_to="n"),
|
||
node("out", "output", fields=[{"name": "结果", "value": "{{ vars.n }}"}]),
|
||
],
|
||
"edges": [edge("start", "init"), edge("init", "lp"), edge("lp", "inc", "body"),
|
||
edge("inc", "lp"), edge("lp", "out", "done")],
|
||
}
|
||
|
||
|
||
def test_no_loop_no_extra_budget():
|
||
spec = GraphSpec.model_validate({
|
||
"nodes": [node("a", "input"), node("b", "output")], "edges": [edge("a", "b")]})
|
||
assert loop_steps(spec) == 0
|
||
|
||
|
||
def test_budget_covers_the_declared_rounds():
|
||
# 1005 轮 × 每轮 2 步,外加收尾的一次判定
|
||
assert loop_steps(GraphSpec.model_validate(counting_loop(1000, 1005))) >= 1005 * 2 + 1
|
||
|
||
|
||
def test_nested_loops_multiply():
|
||
"""内层循环每一轮外层都要从头跑一遍,预算得乘起来,不是加起来。"""
|
||
spec = GraphSpec.model_validate({
|
||
"nodes": [node("start", "input"),
|
||
node("outer", "loop", mode="foreach", items="[1,2,3]", max_iterations=3),
|
||
node("inner", "loop", mode="foreach", items="[1,2,3,4]", max_iterations=4),
|
||
node("work", "transform", expression="1"),
|
||
node("out", "output")],
|
||
"edges": [edge("start", "outer"), edge("outer", "inner", "body"),
|
||
edge("inner", "work", "body"), edge("work", "inner"),
|
||
edge("inner", "outer", "done"), edge("outer", "out", "done")],
|
||
})
|
||
# 实际:外层 4 次判定 + 3 轮 ×(内层 5 次判定 + 4 次干活)= 31 步
|
||
assert loop_steps(spec) >= 31
|
||
|
||
|
||
def test_nodes_after_the_loop_are_not_charged_per_round():
|
||
"""done 出口之后的节点只跑一次,不该按轮数记账——否则预算虚高,等于没有上限。"""
|
||
spec = GraphSpec.model_validate(counting_loop(3, 3))
|
||
assert loop_steps(spec) == (3 + 1) * 2 # 只有 lp 和 inc
|
||
|
||
|
||
@pytest.fixture
|
||
async def engine_up():
|
||
await run_manager.setup()
|
||
yield
|
||
await run_manager.shutdown()
|
||
|
||
|
||
async def _finish(graph) -> Run:
|
||
run = await run_manager.start(graph=graph, input_payload={})
|
||
for _ in range(600):
|
||
async with SessionLocal() as session:
|
||
row = await session.get(Run, run.id)
|
||
if row.status in ("succeeded", "failed", "cancelled", "interrupted"):
|
||
return row
|
||
await asyncio.sleep(0.05)
|
||
raise AssertionError(f"没跑完:{row.status}")
|
||
|
||
|
||
async def test_a_loop_runs_past_the_global_step_limit(engine_up, monkeypatch):
|
||
"""全局上限压到 20 步,循环照样跑满它声明的 30 轮。"""
|
||
monkeypatch.setattr(settings, "max_graph_steps", 20)
|
||
row = await _finish(counting_loop(30, 35))
|
||
assert row.status == "succeeded", row.error
|
||
assert row.output["结果"] == "30"
|
||
|
||
|
||
async def test_an_unguarded_cycle_still_stops_and_says_why(engine_up, monkeypatch):
|
||
"""分支连回上游、没有 loop 节点把关的环,照旧由全局上限拦住——报错得是人话。"""
|
||
monkeypatch.setattr(settings, "max_graph_steps", 12)
|
||
row = await _finish({
|
||
"nodes": [node("start", "input", fields=[]),
|
||
node("t", "transform", expression="1"),
|
||
node("br", "branch", cases=[{"key": "again", "condition": "true"}]),
|
||
node("out", "output", fields=[])],
|
||
"edges": [edge("start", "t"), edge("t", "br"), edge("br", "t", "again"),
|
||
edge("br", "out", "default")],
|
||
})
|
||
assert row.status == "failed"
|
||
assert "已执行 12 步" in row.error and "「循环」节点" in row.error, row.error
|
||
assert "recursion_limit" not in row.error
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 子图:自己的循环自己算预算;撞上限时报子图自己的名字和步数
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def _parent_of(sub_graph: dict, name: str) -> dict:
|
||
from app.db.models import Workflow
|
||
|
||
async with SessionLocal() as session:
|
||
wf = Workflow(name=name, graph=sub_graph)
|
||
session.add(wf)
|
||
await session.commit()
|
||
wf_id = wf.id
|
||
return {
|
||
"nodes": [node("start", "input", fields=[]),
|
||
node("sub", "subgraph", workflow_id=wf_id),
|
||
node("out", "output", fields=[{"name": "结果", "value": "{{ nodes.sub.output.结果 }}"}])],
|
||
"edges": [edge("start", "sub"), edge("sub", "out")],
|
||
}
|
||
|
||
|
||
async def test_a_loop_inside_a_subgraph_gets_its_own_budget(engine_up, monkeypatch):
|
||
monkeypatch.setattr(settings, "max_graph_steps", 20)
|
||
row = await _finish(await _parent_of(counting_loop(30, 35), "数到 30"))
|
||
assert row.status == "succeeded", row.error
|
||
assert row.output["结果"] == "30"
|
||
|
||
|
||
async def test_an_unguarded_cycle_inside_a_subgraph_says_which_one(engine_up, monkeypatch):
|
||
monkeypatch.setattr(settings, "max_graph_steps", 12)
|
||
spinning = {
|
||
"nodes": [node("start", "input", fields=[]),
|
||
node("t", "transform", expression="1"),
|
||
node("br", "branch", cases=[{"key": "again", "condition": "true"}]),
|
||
node("out", "output", fields=[])],
|
||
"edges": [edge("start", "t"), edge("t", "br"), edge("br", "t", "again"),
|
||
edge("br", "out", "default")],
|
||
}
|
||
row = await _finish(await _parent_of(spinning, "空转的子图"))
|
||
assert row.status == "failed"
|
||
assert "子工作流「空转的子图」已执行 12 步" in row.error, row.error
|
||
assert "recursion_limit" not in row.error
|