test_recursive_validation_core.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886
  1. import json
  2. import tempfile
  3. import unittest
  4. from pydantic import ValidationError
  5. from cyber_agent.core.validation import (
  6. LLMValidator,
  7. ScopeValidationResult,
  8. ValidationCheck,
  9. ValidationPolicy,
  10. ValidatorSettings,
  11. aggregate_validation_results,
  12. build_validation_packet,
  13. parse_scope_validation_result,
  14. scope_validation_error,
  15. persist_validation_policy,
  16. require_validation_policy,
  17. )
  18. from cyber_agent.core.artifacts import MaterialIssue
  19. from cyber_agent.core.validator_web import (
  20. ValidatorToolLimits,
  21. ValidatorToolSession,
  22. )
  23. from cyber_agent.trace.models import Message, Trace
  24. from cyber_agent.trace.store import FileSystemTraceStore
  25. BRIEF = {
  26. "objective": "verify one claim",
  27. "reason": "parent needs a checked fact",
  28. "completion_criteria": ["the claim is checked"],
  29. "expected_outputs": ["one conclusion"],
  30. "constraints": [],
  31. "validation_scopes": [],
  32. }
  33. ANCHOR = {
  34. "objective": "publish a reliable answer",
  35. "completion_criteria": ["all facts are supported"],
  36. "constraints": ["do not invent sources"],
  37. }
  38. def plan_for(*, scopes=None, root=False, head=2):
  39. brief = dict(BRIEF, validation_scopes=scopes or [])
  40. policy = ValidationPolicy()
  41. return policy.compile_plan(
  42. task_brief=brief,
  43. task_brief_version=1,
  44. root_task_anchor=ANCHOR,
  45. task_report={"summary": "done"},
  46. candidate_output="candidate" if root else None,
  47. evaluated_head_sequence=head,
  48. materials=[],
  49. material_issues=[],
  50. model_by_scope={scope: "fake" for scope in (
  51. "evidence", "hypothesis", "output", "task", "root"
  52. )},
  53. root=root,
  54. )
  55. def passed_scope_response(plan, scope, *, evidence_refs=None):
  56. return json.dumps({
  57. "scope": scope,
  58. "outcome": "passed",
  59. "checks": [
  60. {
  61. "check_id": check.check_id,
  62. "status": "passed",
  63. "evidence_refs": list(evidence_refs or []),
  64. "issue": None,
  65. }
  66. for check in plan.checks_for_scope(scope)
  67. ],
  68. "reason": "every planned check passed",
  69. "retry_from": None,
  70. })
  71. class ValidationPolicyTest(unittest.TestCase):
  72. def test_policy_version_invalidates_old_checkpoints(self):
  73. self.assertEqual("recursive-validator-2.2", ValidationPolicy().policy_version)
  74. def test_scope_order_and_mandatory_task_are_stable(self):
  75. plan = plan_for(scopes=["output", "evidence", "output"])
  76. self.assertEqual(["evidence", "output", "task"], plan.effective_scopes)
  77. self.assertTrue(any(item.check_id == "task.criterion.1" for item in plan.checks))
  78. same = plan_for(scopes=["evidence", "output"])
  79. self.assertEqual(plan.plan_hash, same.plan_hash)
  80. changed = plan_for(scopes=["evidence", "output"], head=3)
  81. self.assertNotEqual(plan.plan_hash, changed.plan_hash)
  82. def test_root_is_framework_owned(self):
  83. self.assertEqual(["root"], plan_for(root=True).effective_scopes)
  84. with self.assertRaises(ValueError):
  85. ValidationPolicy().compile_plan(
  86. task_brief=dict(BRIEF, validation_scopes=["root"]),
  87. task_brief_version=1,
  88. root_task_anchor=ANCHOR,
  89. task_report={},
  90. candidate_output=None,
  91. evaluated_head_sequence=1,
  92. materials=[],
  93. material_issues=[],
  94. model_by_scope={},
  95. root=False,
  96. )
  97. def test_parser_binds_plan_checks_and_framework_ids(self):
  98. plan = plan_for()
  99. result = parse_scope_validation_result(
  100. passed_scope_response(plan, "task"),
  101. plan=plan,
  102. expected_scope="task",
  103. validator_trace_id="validator-1",
  104. )
  105. self.assertEqual("validator-1", result.validator_trace_id)
  106. forged = json.loads(passed_scope_response(plan, "task"))
  107. forged["checks"].append({
  108. "check_id": "task.forged",
  109. "status": "passed",
  110. "evidence_refs": [],
  111. "issue": None,
  112. })
  113. with self.assertRaisesRegex(ValueError, "do not match plan"):
  114. parse_scope_validation_result(
  115. json.dumps(forged),
  116. plan=plan,
  117. expected_scope="task",
  118. validator_trace_id="validator-1",
  119. )
  120. def test_evidence_pass_requires_opened_source_ids(self):
  121. plan = plan_for(scopes=["evidence"])
  122. content = passed_scope_response(plan, "evidence", evidence_refs=["src-1"])
  123. with self.assertRaisesRegex(ValueError, "opened"):
  124. parse_scope_validation_result(
  125. content,
  126. plan=plan,
  127. expected_scope="evidence",
  128. validator_trace_id="validator",
  129. opened_source_ids=set(),
  130. )
  131. result = parse_scope_validation_result(
  132. content,
  133. plan=plan,
  134. expected_scope="evidence",
  135. validator_trace_id="validator",
  136. opened_source_ids={"src-1"},
  137. )
  138. self.assertEqual("passed", result.outcome)
  139. def test_aggregate_priority_and_retry_are_framework_owned(self):
  140. plan = plan_for(scopes=["evidence", "output"])
  141. results = []
  142. for scope, outcome in zip(plan.effective_scopes, ["failed", "unknown", "passed"]):
  143. results.append(ScopeValidationResult(
  144. validator_trace_id=f"validator-{scope}",
  145. scope=scope,
  146. outcome=outcome,
  147. checks=[ValidationCheck(
  148. check_id=check.check_id,
  149. status=outcome,
  150. issue=None if outcome == "passed" else f"{scope} gap",
  151. ) for check in plan.checks_for_scope(scope)],
  152. reason=f"{scope} result",
  153. retry_from=(
  154. None if outcome == "passed"
  155. else "evidence" if scope == "evidence"
  156. else "output" if scope == "output"
  157. else "task_definition"
  158. ),
  159. plan_hash=plan.plan_hash,
  160. ))
  161. aggregate = aggregate_validation_results(
  162. evaluated_trace_id="child",
  163. plan=plan,
  164. scope_results=results,
  165. )
  166. self.assertEqual("failed", aggregate.outcome)
  167. self.assertEqual("evidence", aggregate.retry_from)
  168. errored = list(results)
  169. errored[-1] = scope_validation_error(
  170. validator_trace_id="validator-task",
  171. scope="task",
  172. plan=plan,
  173. reason="invalid JSON",
  174. )
  175. aggregate = aggregate_validation_results(
  176. evaluated_trace_id="child",
  177. plan=plan,
  178. scope_results=errored,
  179. )
  180. self.assertEqual("error", aggregate.outcome)
  181. self.assertIsNone(aggregate.retry_from)
  182. def test_packet_keeps_contract_and_newest_main_path(self):
  183. trajectory = [
  184. {
  185. "sequence": index,
  186. "role": "tool",
  187. "name": "read_file",
  188. "content": f"marker-{index}-" + ("x" * 300),
  189. }
  190. for index in range(1, 8)
  191. ]
  192. trajectory.append({
  193. "sequence": 99,
  194. "role": "assistant",
  195. "content": "side-secret",
  196. "branch_type": "compression",
  197. })
  198. packet = build_validation_packet(
  199. validation_scope="task",
  200. validation_plan=plan_for(),
  201. task_brief=BRIEF,
  202. task_report={"summary": "report-kept"},
  203. trajectory=trajectory,
  204. max_chars=2_500,
  205. )
  206. self.assertLessEqual(len(packet), 2_500)
  207. self.assertIn("report-kept", packet)
  208. self.assertIn("marker-7", packet)
  209. self.assertNotIn("marker-1", packet)
  210. self.assertNotIn("side-secret", packet)
  211. def test_each_scope_has_fixed_rubric_and_task_has_dynamic_checks(self):
  212. plan = plan_for(scopes=["evidence", "hypothesis", "output"])
  213. for scope in ("evidence", "hypothesis", "output", "task"):
  214. self.assertTrue(any(
  215. item.scope == scope and item.check_id.startswith(f"{scope}.policy.")
  216. for item in plan.checks
  217. ))
  218. self.assertTrue(any(
  219. item.check_id == "task.criterion.1"
  220. and item.method == "llm"
  221. for item in plan.checks
  222. ))
  223. self.assertTrue(any(
  224. item.check_id == "task.expected_output.1"
  225. and item.method == "llm"
  226. for item in plan.checks
  227. ))
  228. self.assertNotIn(
  229. "deterministic_and_llm",
  230. {item.method for item in plan.checks},
  231. )
  232. root_plan = plan_for(root=True)
  233. self.assertTrue(all(
  234. item.method == "llm"
  235. for item in root_plan.checks
  236. if item.check_id.startswith(("root.criterion.", "root.constraint."))
  237. ))
  238. def test_only_material_resolution_failures_compile_as_deterministic(self):
  239. issue = MaterialIssue(
  240. artifact_id="missing-output",
  241. outcome="failed",
  242. reason="artifact does not exist",
  243. scopes=["output"],
  244. )
  245. plan = ValidationPolicy().compile_plan(
  246. task_brief=dict(BRIEF, validation_scopes=["output"]),
  247. task_brief_version=1,
  248. root_task_anchor=ANCHOR,
  249. task_report={"summary": "done"},
  250. candidate_output=None,
  251. evaluated_head_sequence=1,
  252. materials=[],
  253. material_issues=[issue],
  254. model_by_scope={"output": "fake", "task": "fake"},
  255. root=False,
  256. )
  257. deterministic = [
  258. item for item in plan.checks if item.method == "deterministic"
  259. ]
  260. self.assertEqual(["output.material.1"], [item.check_id for item in deterministic])
  261. def test_plan_hash_changes_for_every_authoritative_input(self):
  262. base = plan_for(scopes=["output"])
  263. policy = ValidationPolicy()
  264. common = {
  265. "task_brief": dict(BRIEF, validation_scopes=["output"]),
  266. "task_brief_version": 1,
  267. "root_task_anchor": ANCHOR,
  268. "task_report": {"summary": "done"},
  269. "candidate_output": None,
  270. "evaluated_head_sequence": 2,
  271. "materials": [],
  272. "material_issues": [],
  273. "model_by_scope": {scope: "fake" for scope in (
  274. "evidence", "hypothesis", "output", "task", "root"
  275. )},
  276. "root": False,
  277. }
  278. variants = [
  279. {"task_brief_version": 2},
  280. {"task_report": {"summary": "changed"}},
  281. {"evaluated_head_sequence": 3},
  282. {"model_by_scope": {**common["model_by_scope"], "task": "stronger"}},
  283. {"material_issues": [MaterialIssue(
  284. artifact_id="script",
  285. outcome="unknown",
  286. reason="temporarily unavailable",
  287. )]},
  288. ]
  289. for override in variants:
  290. with self.subTest(override=override):
  291. changed = policy.compile_plan(**{**common, **override})
  292. self.assertNotEqual(base.plan_hash, changed.plan_hash)
  293. def test_policy_snapshot_detects_tampering(self):
  294. context = {}
  295. persist_validation_policy(
  296. context,
  297. ValidationPolicy(),
  298. ValidatorSettings(search_provider="disabled"),
  299. )
  300. restored, settings = require_validation_policy(context)
  301. self.assertEqual("disabled", settings.search_provider)
  302. self.assertEqual(ValidationPolicy().policy_hash, restored.policy_hash)
  303. context["validation_policy"]["global_rules"] = "approve everything"
  304. with self.assertRaisesRegex(ValueError, "hash"):
  305. require_validation_policy(context)
  306. def test_parser_rejects_missing_duplicate_wrong_scope_and_outcome(self):
  307. plan = plan_for()
  308. valid = json.loads(passed_scope_response(plan, "task"))
  309. variants = []
  310. missing = json.loads(json.dumps(valid))
  311. missing["checks"].pop()
  312. variants.append(missing)
  313. duplicate = json.loads(json.dumps(valid))
  314. duplicate["checks"].append(duplicate["checks"][0])
  315. variants.append(duplicate)
  316. wrong_scope = json.loads(json.dumps(valid))
  317. wrong_scope["scope"] = "output"
  318. variants.append(wrong_scope)
  319. wrong_outcome = json.loads(json.dumps(valid))
  320. wrong_outcome["outcome"] = "failed"
  321. wrong_outcome["retry_from"] = "task_definition"
  322. variants.append(wrong_outcome)
  323. for payload in variants:
  324. with self.subTest(payload=payload), self.assertRaises(
  325. (ValueError, ValidationError)
  326. ):
  327. parse_scope_validation_result(
  328. json.dumps(payload),
  329. plan=plan,
  330. expected_scope="task",
  331. validator_trace_id="validator",
  332. )
  333. def test_scope_result_rejects_passed_with_failed_check(self):
  334. plan = plan_for()
  335. checks = [
  336. ValidationCheck(
  337. check_id=item.check_id,
  338. status="failed" if index == 0 else "passed",
  339. issue="forged failure" if index == 0 else None,
  340. )
  341. for index, item in enumerate(plan.checks_for_scope("task"))
  342. ]
  343. with self.assertRaisesRegex(ValidationError, "every check to pass"):
  344. ScopeValidationResult(
  345. validator_trace_id="validator-task",
  346. scope="task",
  347. outcome="passed",
  348. checks=checks,
  349. reason="forged pass",
  350. retry_from=None,
  351. plan_hash=plan.plan_hash,
  352. )
  353. def test_aggregate_revalidates_mutated_scope_outcome(self):
  354. plan = plan_for()
  355. checks = [
  356. ValidationCheck(
  357. check_id=item.check_id,
  358. status="failed" if index == 0 else "passed",
  359. issue="real failure" if index == 0 else None,
  360. )
  361. for index, item in enumerate(plan.checks_for_scope("task"))
  362. ]
  363. forged = ScopeValidationResult(
  364. validator_trace_id="validator-task",
  365. scope="task",
  366. outcome="failed",
  367. checks=checks,
  368. reason="real failure",
  369. retry_from="task_definition",
  370. plan_hash=plan.plan_hash,
  371. )
  372. # 模拟持久化边界之外绕过 Pydantic 的内存篡改;聚合仍必须失败关闭。
  373. object.__setattr__(forged, "outcome", "passed")
  374. object.__setattr__(forged, "retry_from", None)
  375. with self.assertRaisesRegex(ValueError, "every check to pass"):
  376. aggregate_validation_results(
  377. evaluated_trace_id="child",
  378. plan=plan,
  379. scope_results=[forged],
  380. )
  381. def test_unknown_aggregate_preserves_recoverable_retry(self):
  382. plan = plan_for()
  383. scope = ScopeValidationResult(
  384. validator_trace_id="validator-task",
  385. scope="task",
  386. outcome="unknown",
  387. checks=[ValidationCheck(
  388. check_id=check.check_id,
  389. status="unknown",
  390. issue="required material is unavailable",
  391. ) for check in plan.checks_for_scope("task")],
  392. reason="insufficient material",
  393. retry_from="task_definition",
  394. plan_hash=plan.plan_hash,
  395. )
  396. aggregate = aggregate_validation_results(
  397. evaluated_trace_id="child",
  398. plan=plan,
  399. scope_results=[scope],
  400. )
  401. self.assertEqual("unknown", aggregate.outcome)
  402. self.assertEqual("task_definition", aggregate.retry_from)
  403. class FakeLLM:
  404. def __init__(self, responses):
  405. self.responses = list(responses)
  406. self.calls = []
  407. async def __call__(self, **kwargs):
  408. self.calls.append(kwargs)
  409. response = self.responses.pop(0)
  410. if isinstance(response, Exception):
  411. raise response
  412. return response
  413. def response(content="", tool_calls=None):
  414. return {
  415. "content": content,
  416. "tool_calls": tool_calls,
  417. "prompt_tokens": 10,
  418. "completion_tokens": 5,
  419. "cost": 0.01,
  420. "finish_reason": "tool_calls" if tool_calls else "stop",
  421. }
  422. class LLMValidatorTest(unittest.IsolatedAsyncioTestCase):
  423. async def asyncSetUp(self):
  424. self.temp = tempfile.TemporaryDirectory()
  425. self.store = FileSystemTraceStore(self.temp.name)
  426. self.evaluated = Trace(
  427. trace_id="root@delegate-child",
  428. mode="agent",
  429. task="child task",
  430. uid="user-1",
  431. model="fake",
  432. current_goal_id="1",
  433. context={
  434. "agent_mode": "recursive",
  435. "agent_mode_revision": 2,
  436. "root_trace_id": "root",
  437. "agent_depth": 1,
  438. },
  439. )
  440. await self.store.create_trace(self.evaluated)
  441. self.trajectory = [Message.create(
  442. trace_id=self.evaluated.trace_id,
  443. role="tool",
  444. sequence=1,
  445. content={"tool_name": "read_file", "result": "actual output"},
  446. )]
  447. async def asyncTearDown(self):
  448. self.temp.cleanup()
  449. async def test_task_scope_is_one_tool_free_call(self):
  450. plan = plan_for()
  451. llm = FakeLLM([response(passed_scope_response(plan, "task"))])
  452. validator = LLMValidator(
  453. llm_call=llm,
  454. trace_store=self.store,
  455. policy=ValidationPolicy(),
  456. )
  457. run = await validator.validate_plan(
  458. evaluated_trace=self.evaluated,
  459. trajectory=self.trajectory,
  460. plan=plan,
  461. root_task_anchor=ANCHOR,
  462. task_brief=BRIEF,
  463. task_report={"summary": "done"},
  464. candidate_output=None,
  465. materials=[],
  466. material_issues=[],
  467. model_by_scope={"task": "fake"},
  468. )
  469. self.assertEqual("passed", run.result.outcome, run.result.model_dump())
  470. self.assertEqual(1, len(llm.calls))
  471. self.assertEqual([], llm.calls[0]["tools"])
  472. trace = await self.store.get_trace(run.trace_id)
  473. self.assertEqual("completed", trace.status)
  474. self.assertEqual("task", trace.context["validation_scope"])
  475. async def test_evidence_scope_runs_private_search_and_open_loop(self):
  476. plan = plan_for(scopes=["evidence"])
  477. class Provider:
  478. async def search(self, query, max_results):
  479. return [{
  480. "title": "Official",
  481. "link": "https://example.com/fact",
  482. "snippet": "official page",
  483. }]
  484. async def page_fetcher(url, resolver=None):
  485. del resolver
  486. return {
  487. "source_id": "src-official",
  488. "url": url,
  489. "final_url": url,
  490. "title": "Official",
  491. "content_type": "text/plain",
  492. "retrieved_at": "2026-01-01T00:00:00+00:00",
  493. "content_sha256": "b" * 64,
  494. "text": "The official figure is 12%.",
  495. "truncated": False,
  496. "untrusted_material": True,
  497. }
  498. def session_factory(scope, allowed_urls, trace_id):
  499. del scope, trace_id
  500. return ValidatorToolSession(
  501. provider=Provider(),
  502. allowed_urls=allowed_urls,
  503. limits=ValidatorToolLimits(5, 10, 15),
  504. page_fetcher=page_fetcher,
  505. )
  506. llm = FakeLLM([
  507. response(tool_calls=[{
  508. "id": "search-1",
  509. "type": "function",
  510. "function": {
  511. "name": "validator_web_search",
  512. "arguments": json.dumps({"query": "official figure"}),
  513. },
  514. }]),
  515. response(tool_calls=[{
  516. "id": "open-1",
  517. "type": "function",
  518. "function": {
  519. "name": "validator_open_url",
  520. "arguments": json.dumps({"url": "https://example.com/fact"}),
  521. },
  522. }]),
  523. response(passed_scope_response(
  524. plan,
  525. "evidence",
  526. evidence_refs=["src-official"],
  527. )),
  528. response(passed_scope_response(plan, "task")),
  529. ])
  530. validator = LLMValidator(
  531. llm_call=llm,
  532. trace_store=self.store,
  533. policy=ValidationPolicy(),
  534. tool_session_factory=session_factory,
  535. )
  536. run = await validator.validate_plan(
  537. evaluated_trace=self.evaluated,
  538. trajectory=self.trajectory,
  539. plan=plan,
  540. root_task_anchor=ANCHOR,
  541. task_brief=dict(BRIEF, validation_scopes=["evidence"]),
  542. task_report={"summary": "done"},
  543. candidate_output=None,
  544. materials=[],
  545. material_issues=[],
  546. model_by_scope={"evidence": "fake", "task": "fake"},
  547. )
  548. self.assertEqual("passed", run.result.outcome, run.result.model_dump())
  549. self.assertEqual(2, len(run.trace_ids))
  550. evidence_messages = await self.store.get_trace_messages(run.trace_ids[0])
  551. self.assertEqual(
  552. ["system", "user", "assistant", "tool", "assistant", "tool", "assistant"],
  553. [item.role for item in evidence_messages],
  554. )
  555. self.assertEqual(
  556. {"validator_web_search", "validator_open_url"},
  557. {
  558. item["function"]["name"]
  559. for item in (await self.store.get_trace(run.trace_ids[0])).tools
  560. },
  561. )
  562. async def test_invalid_output_fails_without_format_correction(self):
  563. plan = plan_for()
  564. llm = FakeLLM([response("not-json")])
  565. validator = LLMValidator(
  566. llm_call=llm,
  567. trace_store=self.store,
  568. policy=ValidationPolicy(),
  569. )
  570. run = await validator.validate_plan(
  571. evaluated_trace=self.evaluated,
  572. trajectory=[],
  573. plan=plan,
  574. root_task_anchor=ANCHOR,
  575. task_brief=BRIEF,
  576. task_report={"summary": "done"},
  577. candidate_output=None,
  578. materials=[],
  579. material_issues=[],
  580. model_by_scope={"task": "fake"},
  581. )
  582. self.assertEqual("error", run.result.outcome)
  583. self.assertEqual(1, len(llm.calls))
  584. scope_result = run.result.scope_results[0]
  585. self.assertEqual(
  586. {item.check_id for item in plan.checks_for_scope("task")},
  587. {item.check_id for item in scope_result.checks},
  588. )
  589. self.assertTrue(all(item.status == "unknown" for item in scope_result.checks))
  590. async def test_input_packet_failure_uses_only_all_planned_check_ids(self):
  591. plan = plan_for()
  592. llm = FakeLLM([])
  593. validator = LLMValidator(
  594. llm_call=llm,
  595. trace_store=self.store,
  596. policy=ValidationPolicy(),
  597. max_input_chars=8,
  598. )
  599. run = await validator.validate_plan(
  600. evaluated_trace=self.evaluated,
  601. trajectory=self.trajectory,
  602. plan=plan,
  603. root_task_anchor=ANCHOR,
  604. task_brief=BRIEF,
  605. task_report={"summary": "done"},
  606. candidate_output=None,
  607. materials=[],
  608. material_issues=[],
  609. model_by_scope={"task": "fake"},
  610. )
  611. self.assertEqual([], llm.calls)
  612. self.assertEqual("unknown", run.result.outcome)
  613. scope_result = run.result.scope_results[0]
  614. self.assertEqual(
  615. [item.check_id for item in plan.checks_for_scope("task")],
  616. [item.check_id for item in scope_result.checks],
  617. )
  618. self.assertTrue(all(item.status == "unknown" for item in scope_result.checks))
  619. self.assertFalse(any(
  620. item.check_id.endswith("input_limit") for item in scope_result.checks
  621. ))
  622. async def test_deterministic_material_failure_skips_all_llm_calls(self):
  623. plan = ValidationPolicy().compile_plan(
  624. task_brief=dict(BRIEF, validation_scopes=["output"]),
  625. task_brief_version=1,
  626. root_task_anchor=ANCHOR,
  627. task_report={"summary": "claimed output"},
  628. candidate_output=None,
  629. evaluated_head_sequence=1,
  630. materials=[],
  631. material_issues=[MaterialIssue(
  632. artifact_id="script:missing",
  633. outcome="failed",
  634. reason="artifact does not exist",
  635. )],
  636. model_by_scope={"output": "fake", "task": "fake"},
  637. root=False,
  638. )
  639. llm = FakeLLM([])
  640. validator = LLMValidator(
  641. llm_call=llm,
  642. trace_store=self.store,
  643. policy=ValidationPolicy(),
  644. )
  645. run = await validator.validate_plan(
  646. evaluated_trace=self.evaluated,
  647. trajectory=[],
  648. plan=plan,
  649. root_task_anchor=ANCHOR,
  650. task_brief=dict(BRIEF, validation_scopes=["output"]),
  651. task_report={"summary": "claimed output"},
  652. candidate_output=None,
  653. materials=[],
  654. material_issues=[MaterialIssue(
  655. artifact_id="script:missing",
  656. outcome="failed",
  657. reason="artifact does not exist",
  658. )],
  659. model_by_scope={"output": "fake", "task": "fake"},
  660. )
  661. self.assertEqual("failed", run.result.outcome)
  662. self.assertEqual(2, len(run.trace_ids))
  663. self.assertEqual([], llm.calls)
  664. for trace_id in run.trace_ids:
  665. self.assertEqual("failed", (await self.store.get_trace(trace_id)).status)
  666. for result in run.result.scope_results:
  667. self.assertEqual(
  668. {item.check_id for item in plan.checks_for_scope(result.scope)},
  669. {item.check_id for item in result.checks},
  670. )
  671. self.assertEqual(1, sum(item.status == "failed" for item in result.checks))
  672. async def test_material_failure_only_skips_affected_scopes(self):
  673. brief = dict(BRIEF, validation_scopes=["hypothesis", "output"])
  674. issue = MaterialIssue(
  675. artifact_id="script:missing",
  676. outcome="failed",
  677. reason="script does not exist",
  678. scopes=["output", "task", "root"],
  679. )
  680. plan = ValidationPolicy().compile_plan(
  681. task_brief=brief,
  682. task_brief_version=1,
  683. root_task_anchor=ANCHOR,
  684. task_report={"summary": "claimed output"},
  685. candidate_output=None,
  686. evaluated_head_sequence=1,
  687. materials=[],
  688. material_issues=[issue],
  689. model_by_scope={
  690. "hypothesis": "fake", "output": "fake", "task": "fake",
  691. },
  692. root=False,
  693. )
  694. llm = FakeLLM([response(passed_scope_response(plan, "hypothesis"))])
  695. validator = LLMValidator(
  696. llm_call=llm,
  697. trace_store=self.store,
  698. policy=ValidationPolicy(),
  699. )
  700. run = await validator.validate_plan(
  701. evaluated_trace=self.evaluated,
  702. trajectory=[],
  703. plan=plan,
  704. root_task_anchor=ANCHOR,
  705. task_brief=brief,
  706. task_report={"summary": "claimed output"},
  707. candidate_output=None,
  708. materials=[],
  709. material_issues=[issue],
  710. model_by_scope={
  711. "hypothesis": "fake", "output": "fake", "task": "fake",
  712. },
  713. )
  714. self.assertEqual(1, len(llm.calls))
  715. self.assertEqual(
  716. ["passed", "failed", "failed"],
  717. [item.outcome for item in run.result.scope_results],
  718. )
  719. for result in run.result.scope_results:
  720. self.assertEqual(
  721. {item.check_id for item in plan.checks_for_scope(result.scope)},
  722. {item.check_id for item in result.checks},
  723. )
  724. async def test_matching_scope_checkpoint_is_not_run_again(self):
  725. plan = plan_for(scopes=["output"])
  726. output_result = ScopeValidationResult(
  727. validator_trace_id="existing-validator-output",
  728. scope="output",
  729. outcome="passed",
  730. checks=[ValidationCheck(
  731. check_id=item.check_id,
  732. status="passed",
  733. ) for item in plan.checks_for_scope("output")],
  734. reason="already checked",
  735. retry_from=None,
  736. plan_hash=plan.plan_hash,
  737. )
  738. llm = FakeLLM([response(passed_scope_response(plan, "task"))])
  739. validator = LLMValidator(
  740. llm_call=llm,
  741. trace_store=self.store,
  742. policy=ValidationPolicy(),
  743. )
  744. run = await validator.validate_plan(
  745. evaluated_trace=self.evaluated,
  746. trajectory=[],
  747. plan=plan,
  748. root_task_anchor=ANCHOR,
  749. task_brief=dict(BRIEF, validation_scopes=["output"]),
  750. task_report={"summary": "done"},
  751. candidate_output=None,
  752. materials=[],
  753. material_issues=[],
  754. model_by_scope={"output": "fake", "task": "fake"},
  755. resume_scope_results=[output_result],
  756. )
  757. self.assertEqual("passed", run.result.outcome)
  758. self.assertEqual(1, len(llm.calls))
  759. self.assertEqual(
  760. ["existing-validator-output", run.trace_ids[1]],
  761. run.trace_ids,
  762. )
  763. async def test_checkpoint_revalidates_mutated_scope_outcome(self):
  764. plan = plan_for()
  765. checks = [
  766. ValidationCheck(
  767. check_id=item.check_id,
  768. status="failed" if index == 0 else "passed",
  769. issue="real failure" if index == 0 else None,
  770. )
  771. for index, item in enumerate(plan.checks_for_scope("task"))
  772. ]
  773. forged = ScopeValidationResult(
  774. validator_trace_id="existing-validator-task",
  775. scope="task",
  776. outcome="failed",
  777. checks=checks,
  778. reason="real failure",
  779. retry_from="task_definition",
  780. plan_hash=plan.plan_hash,
  781. )
  782. object.__setattr__(forged, "outcome", "passed")
  783. object.__setattr__(forged, "retry_from", None)
  784. llm = FakeLLM([])
  785. validator = LLMValidator(
  786. llm_call=llm,
  787. trace_store=self.store,
  788. policy=ValidationPolicy(),
  789. )
  790. with self.assertRaisesRegex(ValueError, "every check to pass"):
  791. await validator.validate_plan(
  792. evaluated_trace=self.evaluated,
  793. trajectory=[],
  794. plan=plan,
  795. root_task_anchor=ANCHOR,
  796. task_brief=BRIEF,
  797. task_report={"summary": "done"},
  798. candidate_output=None,
  799. materials=[],
  800. material_issues=[],
  801. model_by_scope={"task": "fake"},
  802. resume_scope_results=[forged],
  803. )
  804. self.assertEqual([], llm.calls)
  805. async def test_task_scope_forged_private_tool_is_an_error(self):
  806. plan = plan_for()
  807. llm = FakeLLM([response(tool_calls=[{
  808. "id": "forged",
  809. "type": "function",
  810. "function": {
  811. "name": "validator_web_search",
  812. "arguments": json.dumps({"query": "should not run"}),
  813. },
  814. }])])
  815. validator = LLMValidator(
  816. llm_call=llm,
  817. trace_store=self.store,
  818. policy=ValidationPolicy(),
  819. )
  820. run = await validator.validate_plan(
  821. evaluated_trace=self.evaluated,
  822. trajectory=[],
  823. plan=plan,
  824. root_task_anchor=ANCHOR,
  825. task_brief=BRIEF,
  826. task_report={"summary": "done"},
  827. candidate_output=None,
  828. materials=[],
  829. material_issues=[],
  830. model_by_scope={"task": "fake"},
  831. )
  832. self.assertEqual("error", run.result.outcome)
  833. self.assertIn("unavailable tool", run.result.issues[0])
  834. if __name__ == "__main__":
  835. unittest.main()