test_pipeline.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669
  1. """
  2. Tests for engine.pipeline — 产物 Gate 逐级验收流水线 (P0+P1 骨架).
  3. 覆盖:
  4. 1. 静态校验(forward-ref consumes、duplicate id)
  5. 2. 受限 gate DSL 求值器(功能 + 安全:拒绝属性/import/裸名)
  6. 3. resolve_artifacts 运行时契约校验(required 缺失)
  7. 4. run_pipeline 端到端(fake executor):通过 / 多上游 consumes / gate 失败 retry→halt / skip
  8. 5. 每阶段 verdict + manifest + write-ahead state
  9. 6. resume_pipeline 跳过已通过阶段
  10. """
  11. import json
  12. import os
  13. import shutil
  14. import tempfile
  15. import pytest
  16. from agentpaas.engine.pipeline import (
  17. load_pipeline, validate_pipeline, eval_rule_gate, resolve_artifacts,
  18. run_pipeline, resume_pipeline, _parse_judge_response,
  19. estimate_pipeline_cost, build_judge_term, term_judge, JudgeRequest,
  20. Stage, ArtifactSpec, ResolvedArtifact, GateError, PipelineError,
  21. StageStatus, PipelineStatus, StageExecResult, JudgeResult,
  22. )
  23. _REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  24. _DEMO_PRESET = os.path.join(_REPO, "agentexample", "pipelines", "research-demo.yml")
  25. def _ollama_up() -> bool:
  26. import urllib.request
  27. try:
  28. urllib.request.urlopen("http://127.0.0.1:11434/api/tags", timeout=2)
  29. return True
  30. except Exception:
  31. return False
  32. @pytest.fixture
  33. def agent_dir():
  34. d = tempfile.mkdtemp(prefix="test_pipeline_")
  35. yield d
  36. shutil.rmtree(d, ignore_errors=True)
  37. # ============================================================
  38. # helpers — fake executors that write artifacts (no LLM)
  39. # ============================================================
  40. def _writer(files: dict):
  41. """Return a stage executor that writes {relpath: content} into the stage ws."""
  42. def _exec(ec):
  43. for rel, content in files.items():
  44. p = os.path.join(ec.stage_ws, rel)
  45. os.makedirs(os.path.dirname(p), exist_ok=True)
  46. with open(p, "w", encoding="utf-8") as f:
  47. f.write(content)
  48. return StageExecResult(output="ok", status="completed")
  49. return _exec
  50. # ============================================================
  51. # 1. Static validation
  52. # ============================================================
  53. class TestStaticValidation:
  54. def test_valid_pipeline(self):
  55. cfg = {"pipeline": {"id": "p", "stages": [
  56. {"id": "a", "produces": [{"name": "x.md", "path": "final/x.md"}],
  57. "gate": {"kind": "rule", "rule": 'has("x.md")'}},
  58. {"id": "b", "consumes": ["a"], "gate": {"kind": "rule", "rule": "True"}},
  59. ]}}
  60. p = load_pipeline(cfg)
  61. assert validate_pipeline(p) == []
  62. def test_forward_consumes_rejected(self):
  63. cfg = {"pipeline": {"id": "p", "stages": [
  64. {"id": "a", "consumes": ["b"]}, # b not yet declared
  65. {"id": "b"},
  66. ]}}
  67. errs = validate_pipeline(load_pipeline(cfg))
  68. assert any("forward" in e for e in errs)
  69. def test_duplicate_stage_id_rejected(self):
  70. cfg = {"pipeline": {"id": "p", "stages": [{"id": "a"}, {"id": "a"}]}}
  71. errs = validate_pipeline(load_pipeline(cfg))
  72. assert any("duplicate" in e for e in errs)
  73. def test_run_rejects_invalid(self, agent_dir):
  74. cfg = {"pipeline": {"id": "p", "stages": [{"id": "a", "consumes": ["z"]}]}}
  75. with pytest.raises(PipelineError):
  76. run_pipeline(cfg, "in", agent_dir, stage_executor=_writer({}))
  77. def test_unsafe_rule_caught_statically(self, agent_dir):
  78. """§15 P2-8: a banned gate expression fails validation BEFORE the executor runs."""
  79. ran = {"n": 0}
  80. def exec_spy(ec):
  81. ran["n"] += 1
  82. return StageExecResult(output="ok", status="completed")
  83. cfg = {"pipeline": {"id": "p", "stages": [
  84. {"id": "a", "gate": {"kind": "rule", "rule": 'open("/etc/passwd")'}},
  85. ]}}
  86. errs = validate_pipeline(load_pipeline(cfg))
  87. assert any("not allowed" in e for e in errs)
  88. with pytest.raises(PipelineError):
  89. run_pipeline(cfg, "in", agent_dir, stage_executor=exec_spy)
  90. assert ran["n"] == 0 # executor never ran
  91. # ============================================================
  92. # 2. Bounded gate DSL evaluator
  93. # ============================================================
  94. class TestGateEvaluator:
  95. def _resolved(self, tmp, name="plan.md", content="contains hypothesis here"):
  96. p = os.path.join(tmp, name)
  97. with open(p, "w") as f:
  98. f.write(content)
  99. return {name: ResolvedArtifact(name=name, type="md", path=p, exists=True,
  100. size_bytes=len(content))}
  101. def test_has_and_contains_pass(self, agent_dir):
  102. r = self._resolved(agent_dir)
  103. passed, reasons = eval_rule_gate('has("plan.md") and contains("plan.md", "hypothesis")', r)
  104. assert passed is True
  105. def test_contains_fail(self, agent_dir):
  106. r = self._resolved(agent_dir, content="no keyword")
  107. passed, _ = eval_rule_gate('contains("plan.md", "hypothesis")', r)
  108. assert passed is False
  109. def test_json_len(self, agent_dir):
  110. p = os.path.join(agent_dir, "refs.json")
  111. with open(p, "w") as f:
  112. json.dump(list(range(12)), f)
  113. r = {"refs.json": ResolvedArtifact("refs.json", "json", p, True, os.path.getsize(p))}
  114. assert eval_rule_gate('json_len("refs.json") >= 10', r)[0] is True
  115. assert eval_rule_gate('json_len("refs.json") >= 100', r)[0] is False
  116. def test_count(self, agent_dir):
  117. r = self._resolved(agent_dir)
  118. assert eval_rule_gate("count() >= 1", r)[0] is True
  119. # --- safety: dangerous expressions must raise GateError, not execute ---
  120. @pytest.mark.parametrize("expr", [
  121. '__import__("os").system("echo pwned")',
  122. 'open("/etc/passwd")',
  123. 'has.__class__',
  124. 'count().__class__.__bases__',
  125. '[].append(1)',
  126. 'lambda: 1',
  127. ])
  128. def test_unsafe_expr_rejected(self, expr):
  129. with pytest.raises(GateError):
  130. eval_rule_gate(expr, {})
  131. # ============================================================
  132. # 3. Runtime artifact contract validation
  133. # ============================================================
  134. class TestResolveArtifacts:
  135. def test_missing_required(self, agent_dir):
  136. st = Stage(id="a", produces=[ArtifactSpec(name="x.md", path="final/x.md", required=True)])
  137. resolved, problems = resolve_artifacts(st, agent_dir)
  138. assert len(problems) == 1 and problems[0].startswith("x.md:")
  139. assert resolved["x.md"].exists is False
  140. def test_resolved_with_hash(self, agent_dir):
  141. os.makedirs(os.path.join(agent_dir, "final"))
  142. with open(os.path.join(agent_dir, "final", "x.md"), "w") as f:
  143. f.write("data")
  144. st = Stage(id="a", produces=[ArtifactSpec(name="x.md", path="final/x.md")])
  145. resolved, missing = resolve_artifacts(st, agent_dir)
  146. assert missing == []
  147. assert resolved["x.md"].exists is True
  148. assert resolved["x.md"].sha256 is not None
  149. def test_path_breakout_rejected(self, agent_dir):
  150. """§15 P1-1: absolute / .. paths must not escape the stage workspace."""
  151. for bad in ("/etc/passwd", "../../../../etc/passwd", "final/../../secret"):
  152. st = Stage(id="a", produces=[ArtifactSpec(name="x", path=bad, required=True)])
  153. resolved, problems = resolve_artifacts(st, agent_dir)
  154. assert problems, f"unsafe path not rejected: {bad}"
  155. assert resolved["x"].exists is False
  156. def test_invalid_json_type_fails(self, agent_dir):
  157. """§15 P2-E: type=json that doesn't parse must NOT count as a valid artifact."""
  158. os.makedirs(os.path.join(agent_dir, "results"))
  159. with open(os.path.join(agent_dir, "results", "refs.json"), "w") as f:
  160. f.write("not valid json {{{")
  161. st = Stage(id="a", produces=[ArtifactSpec(name="refs.json", type="json",
  162. path="results/refs.json", required=True)])
  163. resolved, problems = resolve_artifacts(st, agent_dir)
  164. assert problems # invalid json → contract problem
  165. assert resolved["refs.json"].exists is False
  166. # ============================================================
  167. # 4-5. End-to-end run
  168. # ============================================================
  169. class TestRunPipeline:
  170. def _two_stage_cfg(self):
  171. return {"pipeline": {"id": "research", "defaults": {"retry": 1}, "stages": [
  172. {"id": "plan", "produces": [{"name": "plan.md", "path": "final/plan.md"}],
  173. "gate": {"kind": "rule", "rule": 'has("plan.md") and contains("plan.md", "hypothesis")'}},
  174. {"id": "lit", "consumes": ["plan"],
  175. "produces": [{"name": "refs.json", "path": "results/refs.json", "type": "json"}],
  176. "gate": {"kind": "rule", "rule": 'json_len("refs.json") >= 3'}},
  177. ]}}
  178. def test_completes_and_persists(self, agent_dir):
  179. execs = {
  180. "plan": _writer({"final/plan.md": "our hypothesis is X"}),
  181. "lit": _writer({"results/refs.json": json.dumps([1, 2, 3, 4])}),
  182. }
  183. result = run_pipeline(self._two_stage_cfg(), "topic", agent_dir,
  184. stage_executor=lambda ec: execs[ec.stage.id](ec))
  185. assert result.status == PipelineStatus.COMPLETED
  186. assert [s.status for s in result.stages] == [StageStatus.PASSED, StageStatus.PASSED]
  187. # verdict + manifest + state persisted
  188. plan_ws = os.path.join(result.workspace_path, "plan")
  189. assert os.path.isfile(os.path.join(plan_ws, "verdict.json"))
  190. assert os.path.isfile(os.path.join(plan_ws, "manifest.json"))
  191. assert os.path.isfile(os.path.join(result.workspace_path, "pipeline_state.json"))
  192. def test_multi_upstream_consumes(self, agent_dir):
  193. """stage 3 consumes two non-adjacent upstream stages via the artifact store."""
  194. seen = {}
  195. def s3_exec(ec):
  196. # read upstream artifacts through the store-backed consumed view
  197. seen["consumed_keys"] = sorted(ec.consumed.keys())
  198. seen["plan_path"] = ec.consumed["plan"]["plan.md"].path
  199. with open(os.path.join(ec.stage_ws, "final", "paper.md"), "w") as f:
  200. f.write("done")
  201. return StageExecResult(output="ok", status="completed")
  202. cfg = {"pipeline": {"id": "p", "stages": [
  203. {"id": "plan", "produces": [{"name": "plan.md", "path": "final/plan.md"}],
  204. "gate": {"kind": "rule", "rule": 'has("plan.md")'}},
  205. {"id": "lit", "consumes": ["plan"], "produces": [{"name": "s.md", "path": "final/s.md"}],
  206. "gate": {"kind": "rule", "rule": 'has("s.md")'}},
  207. {"id": "paper", "consumes": ["plan", "lit"],
  208. "produces": [{"name": "paper.md", "path": "final/paper.md"}],
  209. "gate": {"kind": "rule", "rule": 'has("paper.md")'}},
  210. ]}}
  211. execs = {
  212. "plan": _writer({"final/plan.md": "h"}),
  213. "lit": _writer({"final/s.md": "survey"}),
  214. "paper": s3_exec,
  215. }
  216. result = run_pipeline(cfg, "x", agent_dir, stage_executor=lambda ec: execs[ec.stage.id](ec))
  217. assert result.status == PipelineStatus.COMPLETED
  218. assert seen["consumed_keys"] == ["lit", "plan"] # both non-adjacent upstreams visible
  219. assert seen["plan_path"].endswith("plan.md")
  220. def test_retry_then_halts(self, agent_dir):
  221. """on_fail=retry + retry=2 → 1+2 attempts, then FAILED/HALTED."""
  222. calls = {"n": 0}
  223. def failing(ec):
  224. calls["n"] += 1
  225. return StageExecResult(output="", status="completed") # never writes artifact
  226. cfg = {"pipeline": {"id": "p", "stages": [
  227. {"id": "a", "retry": 2, "on_fail": "retry",
  228. "produces": [{"name": "x.md", "path": "final/x.md", "required": True}],
  229. "gate": {"kind": "rule", "rule": 'has("x.md")'}},
  230. ]}}
  231. result = run_pipeline(cfg, "x", agent_dir, stage_executor=failing)
  232. assert result.status == PipelineStatus.HALTED
  233. assert result.stages[0].status == StageStatus.FAILED
  234. assert calls["n"] == 3 # 1 + retry(2)
  235. def test_halt_does_not_retry(self, agent_dir):
  236. """§15 P1-4: on_fail=halt ignores retry budget — single attempt, immediate halt."""
  237. calls = {"n": 0}
  238. def failing(ec):
  239. calls["n"] += 1
  240. return StageExecResult(output="", status="completed")
  241. cfg = {"pipeline": {"id": "p", "stages": [
  242. {"id": "a", "retry": 2, "on_fail": "halt", # retry=2 must be ignored
  243. "produces": [{"name": "x.md", "path": "final/x.md", "required": True}],
  244. "gate": {"kind": "rule", "rule": 'has("x.md")'}},
  245. ]}}
  246. result = run_pipeline(cfg, "x", agent_dir, stage_executor=failing)
  247. assert result.status == PipelineStatus.HALTED
  248. assert calls["n"] == 1 # halt = no retry
  249. def test_skip_on_fail_continues(self, agent_dir):
  250. cfg = {"pipeline": {"id": "p", "stages": [
  251. {"id": "a", "on_fail": "skip",
  252. "produces": [{"name": "x.md", "path": "final/x.md", "required": True}],
  253. "gate": {"kind": "rule", "rule": 'has("x.md")'}},
  254. {"id": "b", "produces": [{"name": "y.md", "path": "final/y.md"}],
  255. "gate": {"kind": "rule", "rule": 'has("y.md")'}},
  256. ]}}
  257. execs = {"a": _writer({}), "b": _writer({"final/y.md": "ok"})}
  258. result = run_pipeline(cfg, "x", agent_dir, stage_executor=lambda ec: execs[ec.stage.id](ec))
  259. assert result.status == PipelineStatus.COMPLETED
  260. assert result.stages[0].status == StageStatus.SKIPPED
  261. assert result.stages[1].status == StageStatus.PASSED
  262. # ============================================================
  263. # 6. Resume
  264. # ============================================================
  265. class TestResume:
  266. def test_resume_skips_passed(self, agent_dir):
  267. cfg = {"pipeline": {"id": "p", "stages": [
  268. {"id": "a", "produces": [{"name": "x.md", "path": "final/x.md"}],
  269. "gate": {"kind": "rule", "rule": 'has("x.md")'}},
  270. {"id": "b", "produces": [{"name": "y.md", "path": "final/y.md"}],
  271. "gate": {"kind": "rule", "rule": 'has("y.md")'}},
  272. ]}}
  273. # First run: stage a passes, stage b halts (writes nothing)
  274. run_counts = {"a": 0, "b": 0}
  275. def exec1(ec):
  276. run_counts[ec.stage.id] += 1
  277. if ec.stage.id == "a":
  278. with open(os.path.join(ec.stage_ws, "final", "x.md"), "w") as f:
  279. f.write("ok")
  280. return StageExecResult(output="ok", status="completed")
  281. cfg["pipeline"]["stages"][1]["on_fail"] = "halt"
  282. r1 = run_pipeline(cfg, "x", agent_dir, stage_executor=exec1)
  283. assert r1.status == PipelineStatus.HALTED
  284. assert run_counts == {"a": 1, "b": 1}
  285. # Resume: stage a should be skipped (already passed); only b re-runs
  286. def exec2(ec):
  287. run_counts[ec.stage.id] += 1
  288. with open(os.path.join(ec.stage_ws, "final", "y.md"), "w") as f:
  289. f.write("ok")
  290. return StageExecResult(output="ok", status="completed")
  291. r2 = resume_pipeline(r1.workspace_path, cfg, stage_executor=exec2)
  292. assert r2.status == PipelineStatus.COMPLETED
  293. assert run_counts["a"] == 1 # NOT re-run
  294. assert run_counts["b"] == 2 # re-run once on resume
  295. def test_resume_after_retry_pass_uses_correct_dir(self, agent_dir):
  296. """§15 P1-2: stage passes on attempt 2 (_a2 dir); downstream after resume must
  297. consume that attempt's artifact, not the empty base stage dir."""
  298. # stage a fails attempt 1, passes attempt 2; stage b halts first run, then passes on resume.
  299. a_calls = {"n": 0}
  300. def a_exec(ec):
  301. a_calls["n"] += 1
  302. if a_calls["n"] >= 2: # succeed only on the 2nd attempt
  303. with open(os.path.join(ec.stage_ws, "final", "x.md"), "w") as f:
  304. f.write("content-from-attempt-2")
  305. return StageExecResult(output="ok", status="completed")
  306. consumed_path = {}
  307. def b_exec(ec):
  308. # capture the path a's artifact resolves to through the store
  309. ra = ec.consumed.get("a", {}).get("x.md")
  310. consumed_path["p"] = ra.path if ra else None
  311. return StageExecResult(output="", status="completed") # fail → halt first time
  312. cfg = {"pipeline": {"id": "p", "stages": [
  313. {"id": "a", "retry": 2, "on_fail": "retry",
  314. "produces": [{"name": "x.md", "path": "final/x.md", "required": True}],
  315. "gate": {"kind": "rule", "rule": 'has("x.md")'}},
  316. {"id": "b", "consumes": ["a"], "on_fail": "halt",
  317. "produces": [{"name": "y.md", "path": "final/y.md", "required": True}],
  318. "gate": {"kind": "rule", "rule": 'has("y.md")'}},
  319. ]}}
  320. r1 = run_pipeline(cfg, "x", agent_dir,
  321. stage_executor=lambda ec: (a_exec if ec.stage.id == "a" else b_exec)(ec))
  322. assert r1.status == PipelineStatus.HALTED
  323. # a passed on attempt 2 → its artifact lives in the _a2 dir
  324. assert consumed_path["p"] and consumed_path["p"].endswith("x.md")
  325. assert "_a2" in consumed_path["p"]
  326. # resume: a skipped (uses persisted _a2 workspace_path); b passes
  327. def b_exec2(ec):
  328. ra = ec.consumed.get("a", {}).get("x.md")
  329. consumed_path["resume"] = ra.path if ra else None
  330. with open(os.path.join(ec.stage_ws, "final", "y.md"), "w") as f:
  331. f.write("ok")
  332. return StageExecResult(output="ok", status="completed")
  333. r2 = resume_pipeline(r1.workspace_path, cfg,
  334. stage_executor=lambda ec: b_exec2(ec))
  335. assert r2.status == PipelineStatus.COMPLETED
  336. # after resume, a's artifact still resolves from the correct (_a2) dir
  337. assert consumed_path["resume"] and "_a2" in consumed_path["resume"]
  338. # ============================================================
  339. # 7. LLM-judge gate
  340. # ============================================================
  341. class TestLLMJudgeGate:
  342. @pytest.mark.parametrize("text,expected", [
  343. ('{"score": 0.9, "reasons": ["good"]}', 0.9),
  344. ('Here is my verdict: {"score":0.4,"reasons":["meh"]} thanks', 0.4),
  345. ('score: 0.75 overall', 0.75),
  346. ('1.0', 1.0),
  347. ('total garbage, no number at all', 0.0),
  348. ('{"score": 5}', 1.0), # clamped to [0,1]
  349. ])
  350. def test_parse_judge_response(self, text, expected):
  351. score, _ = _parse_judge_response(text)
  352. assert abs(score - expected) < 1e-6
  353. def test_judge_pass_injected(self, agent_dir):
  354. captured = {}
  355. def fake_judge(req):
  356. captured["rubric"] = req.rubric
  357. captured["text"] = req.artifacts_text
  358. return JudgeResult(score=0.85, passed=True, reasons=["covers everything"],
  359. usage={"provider": "fake", "input_tokens": 10, "output_tokens": 5})
  360. cfg = {"pipeline": {"id": "p", "stages": [
  361. {"id": "a", "produces": [{"name": "essay.md", "path": "final/essay.md"}],
  362. "gate": {"kind": "llm_judge", "judge": {"rubric": "states a hypothesis", "threshold": 0.7}}},
  363. ]}}
  364. r = run_pipeline(cfg, "x", agent_dir,
  365. stage_executor=_writer({"final/essay.md": "our hypothesis is X"}),
  366. judge=fake_judge)
  367. assert r.status == PipelineStatus.COMPLETED
  368. v = r.stages[0].verdict
  369. assert v.passed is True and v.score == 0.85
  370. assert any("score=0.85" in s for s in v.reasons)
  371. assert any("judge_usage" in s for s in v.reasons)
  372. assert "hypothesis" in captured["text"] # judge actually saw the artifact content
  373. def test_judge_fail_halts(self, agent_dir):
  374. def low_judge(req):
  375. return JudgeResult(score=0.30, passed=False, reasons=["too thin"])
  376. cfg = {"pipeline": {"id": "p", "stages": [
  377. {"id": "a", "on_fail": "halt",
  378. "produces": [{"name": "essay.md", "path": "final/essay.md"}],
  379. "gate": {"kind": "llm_judge", "judge": {"rubric": "deep analysis", "threshold": 0.7}}},
  380. ]}}
  381. r = run_pipeline(cfg, "x", agent_dir,
  382. stage_executor=_writer({"final/essay.md": "thin"}),
  383. judge=low_judge)
  384. assert r.status == PipelineStatus.HALTED
  385. assert r.stages[0].status == StageStatus.FAILED
  386. assert r.stages[0].verdict.score == 0.30
  387. @pytest.mark.skipif(not _ollama_up(), reason="ollama not reachable on :11434")
  388. def test_judge_live_ollama(self, agent_dir):
  389. """Real local-ollama judge — backs the self-hosted '逐级验收' selling point."""
  390. cfg = {"pipeline": {"id": "p", "stages": [
  391. {"id": "a", "on_fail": "halt",
  392. "produces": [{"name": "essay.md", "path": "final/essay.md"}],
  393. "gate": {"kind": "llm_judge", "judge": {
  394. "model": {"provider": "ollama", "name": "qwen2.5:7b"},
  395. "rubric": "The text must clearly state a research hypothesis.",
  396. "threshold": 0.5}}},
  397. ]}}
  398. good = ("Our central hypothesis is that cost-graded effect types can predict "
  399. "an agent's resource consumption before execution. We test it on 835 configs.")
  400. r = run_pipeline(cfg, "topic", agent_dir,
  401. stage_executor=_writer({"final/essay.md": good}))
  402. v = r.stages[0].verdict
  403. assert v is not None and v.score is not None
  404. assert 0.0 <= v.score <= 1.0
  405. assert r.status in (PipelineStatus.COMPLETED, PipelineStatus.HALTED)
  406. # ============================================================
  407. # 8. Cost estimation (②: judge as term, Paper34 prediction side)
  408. # ============================================================
  409. class TestCostEstimate:
  410. def test_estimate_pipeline_cost_includes_judge(self):
  411. """estimate_pipeline_cost folds agent + judge-term cost; llm_judge stage > 0."""
  412. cfg = {"pipeline": {"id": "p", "stages": [
  413. {"id": "a",
  414. "agent": {"type": "simple",
  415. "model": {"provider": "ollama", "name": "qwen2.5:7b"},
  416. "systemPrompt": "x"},
  417. "produces": [{"name": "a.md", "path": "final/a.md"}],
  418. "gate": {"kind": "llm_judge",
  419. "judge": {"model": {"provider": "ollama", "name": "qwen2.5:7b"},
  420. "rubric": "r", "threshold": 0.5}}},
  421. ]}}
  422. total, breakdown = estimate_pipeline_cost(load_pipeline(cfg))
  423. assert len(breakdown) == 1
  424. assert breakdown[0][0] == "a"
  425. assert total.tokens > 0 # judge term contributes a token upper bound
  426. assert 0 < total.probability <= 1
  427. def test_build_judge_term_is_a_term(self):
  428. from lambdagent.cost_grade import estimate_cost
  429. term = build_judge_term({"rubric": "r", "model": {"provider": "ollama", "name": "qwen2.5:7b"}})
  430. g = estimate_cost(term) # judge is in the term graph → cost computable
  431. assert g.tokens > 0
  432. # ============================================================
  433. # 9. Live ollama: term judge + research demo end-to-end
  434. # ============================================================
  435. class TestLiveOllama:
  436. @pytest.mark.skipif(not _ollama_up(), reason="ollama not reachable on :11434")
  437. def test_term_judge_live(self, agent_dir):
  438. req = JudgeRequest(
  439. rubric="The text must clearly state a research hypothesis.",
  440. threshold=0.5, model={"provider": "ollama", "name": "qwen2.5:7b"},
  441. artifacts_text="### x\nOur hypothesis is that graded cost predicts agent resource use.")
  442. jr = term_judge(req)
  443. assert 0.0 <= jr.score <= 1.0
  444. assert jr.usage and jr.usage.get("in_term_graph") is True
  445. @pytest.mark.skipif(not _ollama_up(), reason="ollama not reachable on :11434")
  446. def test_research_demo_end_to_end(self, agent_dir):
  447. """① Real plan→lit run on local ollama via the research-demo preset.
  448. Asserts ORCHESTRATION invariants (terminal status, stage order, state +
  449. verdict persisted), not the small model's exact output — a live local-LLM
  450. e2e must not flake on model nondeterminism or transient ollama hiccups.
  451. """
  452. assert os.path.isfile(_DEMO_PRESET)
  453. r = run_pipeline(_DEMO_PRESET, "形式化资源语义在 AI agent 调度中的应用", agent_dir)
  454. # orchestration reached a terminal state and ran in declared order
  455. assert r.status in (PipelineStatus.COMPLETED, PipelineStatus.HALTED)
  456. assert [s.stage_id for s in r.stages][:1] == ["plan"]
  457. assert os.path.isfile(os.path.join(r.workspace_path, "pipeline_state.json"))
  458. # when the plan stage passes, the artifact bridge must have produced plan.md
  459. plan = r.stages[0]
  460. if plan.status == StageStatus.PASSED:
  461. assert os.path.isfile(os.path.join(plan.workspace_path, "final", "plan.md"))
  462. # ============================================================
  463. # Human gate: YIELD + resolve_human_gate(§6.3,P3 完成版)
  464. # ============================================================
  465. from agentpaas.engine.pipeline import resolve_human_gate
  466. class TestHumanGate:
  467. def _cfg(self, on_fail="halt"):
  468. """s1 规则门 → s2 人工门 → s3 规则门(验证断点前后都正确)。"""
  469. return {"pipeline": {"id": "hg", "stages": [
  470. {"id": "s1",
  471. "produces": [{"name": "plan", "type": "md", "path": "plan.md"}],
  472. "gate": {"kind": "rule", "rule": 'has("plan")'}},
  473. {"id": "s2", "consumes": ["s1"],
  474. "produces": [{"name": "draft", "type": "md", "path": "draft.md"}],
  475. "gate": {"kind": "human", "prompt": "请导师确认草稿方向"},
  476. "on_fail": on_fail, "retry": 1},
  477. {"id": "s3", "consumes": ["s2"],
  478. "produces": [{"name": "final", "type": "md", "path": "final.md"}],
  479. "gate": {"kind": "rule", "rule": 'has("final")'}},
  480. ]}}
  481. def _execs(self, counter):
  482. def make(files):
  483. def _exec(ec):
  484. counter[ec.stage.id] = counter.get(ec.stage.id, 0) + 1
  485. for rel, content in files.items():
  486. p = os.path.join(ec.stage_ws, rel)
  487. os.makedirs(os.path.dirname(p) or ec.stage_ws, exist_ok=True)
  488. with open(p, "w", encoding="utf-8") as f:
  489. f.write(content)
  490. return StageExecResult(output="ok", status="completed")
  491. return _exec
  492. table = {"s1": make({"plan.md": "the plan"}),
  493. "s2": make({"draft.md": "the draft"}),
  494. "s3": make({"final.md": "the final"})}
  495. return lambda ec: table[ec.stage.id](ec)
  496. def test_yields_at_human_gate(self, agent_dir):
  497. n = {}
  498. r = run_pipeline(self._cfg(), "topic", agent_dir, stage_executor=self._execs(n))
  499. assert r.status == PipelineStatus.AWAITING_HUMAN
  500. assert n == {"s1": 1, "s2": 1} # s3 没跑
  501. # state 落盘正确,verdict 是 pending 占位
  502. state = json.load(open(os.path.join(r.workspace_path, "pipeline_state.json")))
  503. s2 = state["stages"]["s2"]
  504. assert s2["status"] == "awaiting_human"
  505. assert s2["gate_prompt"] == "请导师确认草稿方向"
  506. v = json.load(open(os.path.join(s2["workspace_path"], "verdict.json")))
  507. assert v["passed"] is False and "PENDING_HUMAN" in v["reasons"][0]
  508. def test_approved_resumes_to_completion(self, agent_dir):
  509. n = {}
  510. ex = self._execs(n)
  511. r = run_pipeline(self._cfg(), "topic", agent_dir, stage_executor=ex)
  512. r2 = resolve_human_gate(self._cfg(), r.workspace_path, "approved",
  513. note="方向没问题", stage_executor=ex)
  514. assert r2.status == PipelineStatus.COMPLETED
  515. # s1/s2 没重算,只补跑了 s3
  516. assert n == {"s1": 1, "s2": 1, "s3": 1}
  517. # human verdict 覆盖了 pending 占位
  518. state = json.load(open(os.path.join(r.workspace_path, "pipeline_state.json")))
  519. s2 = state["stages"]["s2"]
  520. assert s2["status"] == "passed"
  521. v = json.load(open(s2["verdict_path"]))
  522. assert v["passed"] is True and "方向没问题" in v["reasons"][0]
  523. def test_rejected_halt(self, agent_dir):
  524. n = {}
  525. ex = self._execs(n)
  526. r = run_pipeline(self._cfg(on_fail="halt"), "topic", agent_dir, stage_executor=ex)
  527. r2 = resolve_human_gate(self._cfg(on_fail="halt"), r.workspace_path,
  528. "rejected", note="重写", stage_executor=ex)
  529. assert r2.status == PipelineStatus.HALTED
  530. assert n == {"s1": 1, "s2": 1} # 不重跑
  531. state = json.load(open(os.path.join(r.workspace_path, "pipeline_state.json")))
  532. assert state["stages"]["s2"]["status"] == "failed"
  533. def test_rejected_retry_redoes_stage_then_awaits_again(self, agent_dir):
  534. n = {}
  535. ex = self._execs(n)
  536. r = run_pipeline(self._cfg(on_fail="retry"), "topic", agent_dir, stage_executor=ex)
  537. r2 = resolve_human_gate(self._cfg(on_fail="retry"), r.workspace_path,
  538. "rejected", note="再来一版", stage_executor=ex)
  539. # 重做后再次停在人工门
  540. assert r2.status == PipelineStatus.AWAITING_HUMAN
  541. assert n == {"s1": 1, "s2": 2} # s2 重跑一次, s1 不重算
  542. # 再批准 → 跑完
  543. r3 = resolve_human_gate(self._cfg(on_fail="retry"), r.workspace_path,
  544. "approved", stage_executor=ex)
  545. assert r3.status == PipelineStatus.COMPLETED
  546. assert n == {"s1": 1, "s2": 2, "s3": 1}
  547. def test_rejected_skip_continues(self, agent_dir):
  548. n = {}
  549. ex = self._execs(n)
  550. r = run_pipeline(self._cfg(on_fail="skip"), "topic", agent_dir, stage_executor=ex)
  551. r2 = resolve_human_gate(self._cfg(on_fail="skip"), r.workspace_path,
  552. "rejected", stage_executor=ex)
  553. assert r2.status == PipelineStatus.COMPLETED
  554. state = json.load(open(os.path.join(r.workspace_path, "pipeline_state.json")))
  555. assert state["stages"]["s2"]["status"] == "skipped"
  556. assert n == {"s1": 1, "s2": 1, "s3": 1}
  557. def test_bad_decision_and_no_awaiting(self, agent_dir):
  558. n = {}
  559. ex = self._execs(n)
  560. r = run_pipeline(self._cfg(), "topic", agent_dir, stage_executor=ex)
  561. with pytest.raises(PipelineError):
  562. resolve_human_gate(self._cfg(), r.workspace_path, "maybe")
  563. resolve_human_gate(self._cfg(), r.workspace_path, "approved", stage_executor=ex)
  564. with pytest.raises(PipelineError): # 已无 awaiting 阶段
  565. resolve_human_gate(self._cfg(), r.workspace_path, "approved", stage_executor=ex)