mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 09:01:58 +00:00
121 lines
6.4 KiB
Python
121 lines
6.4 KiB
Python
"""流程相关的检查(engine/lint_process.py,设计文档 §3.13)。
|
|
|
|
human.*:人工节点的流程配置写得不成立,运行时没法照着执行——validate_graph 报错误,各级发布、发起运行都挡。
|
|
process.*:图能运行,按流程使用才出事——发布门禁里一律警告,应用把它作为流程钉住时由应用检查升成错误。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import timedelta
|
|
|
|
import pytest
|
|
|
|
from app.db.base import SessionLocal
|
|
from app.db.models import Workflow, WorkflowVersion
|
|
from app.engine.governance import lint_for_publish, publish_issues
|
|
from app.engine.lint_process import load_subgraphs, parse_duration, with_subgraphs
|
|
from app.engine.schema import GraphSpec, validate_graph
|
|
|
|
|
|
def node(nid, ntype, **config):
|
|
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
|
|
|
|
|
|
def spec(*nodes) -> GraphSpec:
|
|
nodes = [node("start", "input", fields=[{"name": "q"}]), *nodes, node("out", "output", fields=[])]
|
|
return GraphSpec.model_validate({"nodes": nodes, "edges": [
|
|
{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]})
|
|
|
|
|
|
def codes(result) -> dict[str, str]:
|
|
return {i.code: i.level for i in result.issues if i.code and i.code.startswith(("human.", "process."))}
|
|
|
|
|
|
def test_parse_duration():
|
|
assert parse_duration("PT4H") == timedelta(hours=4)
|
|
assert parse_duration("P2D") == timedelta(days=2)
|
|
assert parse_duration("P1DT30M") == timedelta(days=1, minutes=30)
|
|
assert parse_duration("pt90s") == timedelta(seconds=90)
|
|
for bad in ("4h", "P", "PT", "P1M", "PT0H", "", None, 3):
|
|
assert parse_duration(bad) is None, bad
|
|
|
|
|
|
@pytest.mark.parametrize("config, code", [
|
|
({"due": "4 小时"}, "human.due_invalid"),
|
|
({"on_timeout": {"action": "remind"}}, "human.due_invalid"), # 有超时处理却没写时限
|
|
({"mode": "input", "policy": "all"}, "human.policy_mode"),
|
|
({"mode": "edit", "policy": {"quorum": 2}}, "human.policy_mode"),
|
|
({"due": "PT1H", "on_timeout": {"action": "escalate"}}, "human.escalate_slot"),
|
|
({"due": "PT1H", "on_timeout": {"action": "approve"}}, "human.timeout_invalid"), # 没有「自动批准」
|
|
({"policy": "most"}, "human.policy_invalid"),
|
|
({"assignee": {"kind": "slot"}}, "human.assignee_invalid"),
|
|
({"form": "不是对象"}, "human.form_invalid"),
|
|
({"form_from": "{{ nodes.a.form }}"}, "human.form_from_invalid"),
|
|
])
|
|
def test_bad_human_config_is_an_error(config, code):
|
|
result = validate_graph(spec(node("h", "human", **{"mode": "approve", **config})))
|
|
assert codes(result).get(code) == "error", [i.model_dump() for i in result.issues]
|
|
assert not result.ok
|
|
gate = publish_issues(spec(node("h", "human", **{"mode": "approve", **config})), level="published")
|
|
assert any(i.code == code and i.level == "error" for i in gate)
|
|
|
|
|
|
def test_form_is_checked_against_the_catalog(monkeypatch):
|
|
from app.apps import forms
|
|
|
|
monkeypatch.setattr(forms, "validate_form_schema", lambda form: ["不支持 pattern"])
|
|
result = validate_graph(spec(node("h", "human", mode="input", form={"type": "object", "properties": {}})))
|
|
assert codes(result) == {"human.form_invalid": "error"}
|
|
monkeypatch.setattr(forms, "validate_form_schema", lambda form: [])
|
|
assert validate_graph(spec(node("h", "human", mode="input", form={"type": "object", "properties": {}}))).ok
|
|
|
|
|
|
def test_good_human_config_passes():
|
|
result = validate_graph(spec(node("h", "human", mode="approve", assignee={"kind": "slot", "slot": "finance"},
|
|
policy={"quorum": 2}, due="PT1H",
|
|
on_timeout={"action": "escalate", "to_slot": "director"})))
|
|
assert result.ok and codes(result) == {}
|
|
|
|
|
|
def test_agent_that_may_ask_mid_way_is_warned():
|
|
agent = node("bot", "agent", prompt="查", tools=["mcp:erp/get_order", "current_time"])
|
|
assert codes(lint_for_publish(spec(agent), level="governed")) == {"process.wait_mid_agent": "warning"}
|
|
quiet = node("bot", "agent", prompt="查", tools=["mcp:erp/get_order"], approval="never")
|
|
assert "process.wait_mid_agent" not in codes(lint_for_publish(spec(quiet), level="published"))
|
|
reads = node("bot", "agent", prompt="查", tools=["current_time", "db_query__shop"])
|
|
assert "process.wait_mid_agent" not in codes(lint_for_publish(spec(reads), level="published"))
|
|
team = node("team", "supervisor", goal="x", agents=[{"name": "r", "tools": ["mcp:erp/pay"]}])
|
|
assert codes(lint_for_publish(spec(team), level="published")) == {"process.wait_mid_agent": "warning"}
|
|
|
|
|
|
def test_code_with_network_is_warned():
|
|
code = node("c", "code", code="print(1)", network=True)
|
|
assert codes(lint_for_publish(spec(code), level="governed")).get("process.code_network") == "warning"
|
|
|
|
|
|
async def test_human_inside_a_subgraph_is_warned_when_subgraphs_are_loaded():
|
|
inner = {"nodes": [node("start", "input"), node("h", "human", mode="approve"), node("out", "output")],
|
|
"edges": [{"source": "start", "target": "h"}, {"source": "h", "target": "out"}]}
|
|
async with SessionLocal() as session:
|
|
wf = Workflow(name="带审批的子流程", graph=inner)
|
|
session.add(wf)
|
|
await session.flush()
|
|
session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=inner))
|
|
await session.commit()
|
|
outer = spec(node("sub", "subgraph", workflow_id=wf.id, workflow_version=1))
|
|
graphs = await load_subgraphs(session, outer)
|
|
assert "process.human_in_subgraph" not in codes(lint_for_publish(outer, level="published")) # 没读就不查
|
|
with with_subgraphs(graphs):
|
|
assert codes(lint_for_publish(outer, level="published")) == {"process.human_in_subgraph": "warning"}
|
|
|
|
|
|
def test_seeded_expense_template_passes_validation_and_the_gate():
|
|
from app.seed import TEMPLATES
|
|
|
|
template = next(t for t in TEMPLATES if t["name"] == "⑩ 报销审批(会签)")
|
|
graph = GraphSpec.model_validate({"nodes": template["nodes"], "edges": template["edges"]})
|
|
assert validate_graph(graph).ok
|
|
assert not [i for i in publish_issues(graph, level="published") if i.level == "error"]
|
|
review = next(n for n in template["nodes"] if n["type"] == "human")["data"]["config"]
|
|
assert review["assignee"] == {"kind": "slot", "slot": "finance"} and review["policy"] == "all"
|
|
assert review["due"] == "PT1H" and review["on_timeout"] == {"action": "escalate", "to_slot": "director"}
|