test_recursive_validation_core.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703
  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_scope_order_and_mandatory_task_are_stable(self):
  73. plan = plan_for(scopes=["output", "evidence", "output"])
  74. self.assertEqual(["evidence", "output", "task"], plan.effective_scopes)
  75. self.assertTrue(any(item.check_id == "task.criterion.1" for item in plan.checks))
  76. same = plan_for(scopes=["evidence", "output"])
  77. self.assertEqual(plan.plan_hash, same.plan_hash)
  78. changed = plan_for(scopes=["evidence", "output"], head=3)
  79. self.assertNotEqual(plan.plan_hash, changed.plan_hash)
  80. def test_root_is_framework_owned(self):
  81. self.assertEqual(["root"], plan_for(root=True).effective_scopes)
  82. with self.assertRaises(ValueError):
  83. ValidationPolicy().compile_plan(
  84. task_brief=dict(BRIEF, validation_scopes=["root"]),
  85. task_brief_version=1,
  86. root_task_anchor=ANCHOR,
  87. task_report={},
  88. candidate_output=None,
  89. evaluated_head_sequence=1,
  90. materials=[],
  91. material_issues=[],
  92. model_by_scope={},
  93. root=False,
  94. )
  95. def test_parser_binds_plan_checks_and_framework_ids(self):
  96. plan = plan_for()
  97. result = parse_scope_validation_result(
  98. passed_scope_response(plan, "task"),
  99. plan=plan,
  100. expected_scope="task",
  101. validator_trace_id="validator-1",
  102. )
  103. self.assertEqual("validator-1", result.validator_trace_id)
  104. forged = json.loads(passed_scope_response(plan, "task"))
  105. forged["checks"].append({
  106. "check_id": "task.forged",
  107. "status": "passed",
  108. "evidence_refs": [],
  109. "issue": None,
  110. })
  111. with self.assertRaisesRegex(ValueError, "do not match plan"):
  112. parse_scope_validation_result(
  113. json.dumps(forged),
  114. plan=plan,
  115. expected_scope="task",
  116. validator_trace_id="validator-1",
  117. )
  118. def test_evidence_pass_requires_opened_source_ids(self):
  119. plan = plan_for(scopes=["evidence"])
  120. content = passed_scope_response(plan, "evidence", evidence_refs=["src-1"])
  121. with self.assertRaisesRegex(ValueError, "opened"):
  122. parse_scope_validation_result(
  123. content,
  124. plan=plan,
  125. expected_scope="evidence",
  126. validator_trace_id="validator",
  127. opened_source_ids=set(),
  128. )
  129. result = parse_scope_validation_result(
  130. content,
  131. plan=plan,
  132. expected_scope="evidence",
  133. validator_trace_id="validator",
  134. opened_source_ids={"src-1"},
  135. )
  136. self.assertEqual("passed", result.outcome)
  137. def test_aggregate_priority_and_retry_are_framework_owned(self):
  138. plan = plan_for(scopes=["evidence", "output"])
  139. results = []
  140. for scope, outcome in zip(plan.effective_scopes, ["failed", "unknown", "passed"]):
  141. results.append(ScopeValidationResult(
  142. validator_trace_id=f"validator-{scope}",
  143. scope=scope,
  144. outcome=outcome,
  145. checks=[ValidationCheck(
  146. check_id=f"{scope}.policy.1",
  147. status=outcome,
  148. issue=None if outcome == "passed" else f"{scope} gap",
  149. )],
  150. reason=f"{scope} result",
  151. retry_from=(
  152. None if outcome == "passed"
  153. else "evidence" if scope == "evidence"
  154. else "output" if scope == "output"
  155. else "task_definition"
  156. ),
  157. plan_hash=plan.plan_hash,
  158. ))
  159. aggregate = aggregate_validation_results(
  160. evaluated_trace_id="child",
  161. plan=plan,
  162. scope_results=results,
  163. )
  164. self.assertEqual("failed", aggregate.outcome)
  165. self.assertEqual("evidence", aggregate.retry_from)
  166. errored = list(results)
  167. errored[-1] = scope_validation_error(
  168. validator_trace_id="validator-task",
  169. scope="task",
  170. plan_hash=plan.plan_hash,
  171. reason="invalid JSON",
  172. )
  173. aggregate = aggregate_validation_results(
  174. evaluated_trace_id="child",
  175. plan=plan,
  176. scope_results=errored,
  177. )
  178. self.assertEqual("error", aggregate.outcome)
  179. self.assertIsNone(aggregate.retry_from)
  180. def test_packet_keeps_contract_and_newest_main_path(self):
  181. trajectory = [
  182. {
  183. "sequence": index,
  184. "role": "tool",
  185. "name": "read_file",
  186. "content": f"marker-{index}-" + ("x" * 300),
  187. }
  188. for index in range(1, 8)
  189. ]
  190. trajectory.append({
  191. "sequence": 99,
  192. "role": "assistant",
  193. "content": "side-secret",
  194. "branch_type": "compression",
  195. })
  196. packet = build_validation_packet(
  197. validation_scope="task",
  198. validation_plan=plan_for(),
  199. task_brief=BRIEF,
  200. task_report={"summary": "report-kept"},
  201. trajectory=trajectory,
  202. max_chars=2_500,
  203. )
  204. self.assertLessEqual(len(packet), 2_500)
  205. self.assertIn("report-kept", packet)
  206. self.assertIn("marker-7", packet)
  207. self.assertNotIn("marker-1", packet)
  208. self.assertNotIn("side-secret", packet)
  209. def test_each_scope_has_fixed_rubric_and_task_has_dynamic_checks(self):
  210. plan = plan_for(scopes=["evidence", "hypothesis", "output"])
  211. for scope in ("evidence", "hypothesis", "output", "task"):
  212. self.assertTrue(any(
  213. item.scope == scope and item.check_id.startswith(f"{scope}.policy.")
  214. for item in plan.checks
  215. ))
  216. self.assertTrue(any(
  217. item.check_id == "task.criterion.1"
  218. and item.method == "deterministic_and_llm"
  219. for item in plan.checks
  220. ))
  221. self.assertTrue(any(
  222. item.check_id == "task.expected_output.1"
  223. for item in plan.checks
  224. ))
  225. def test_plan_hash_changes_for_every_authoritative_input(self):
  226. base = plan_for(scopes=["output"])
  227. policy = ValidationPolicy()
  228. common = {
  229. "task_brief": dict(BRIEF, validation_scopes=["output"]),
  230. "task_brief_version": 1,
  231. "root_task_anchor": ANCHOR,
  232. "task_report": {"summary": "done"},
  233. "candidate_output": None,
  234. "evaluated_head_sequence": 2,
  235. "materials": [],
  236. "material_issues": [],
  237. "model_by_scope": {scope: "fake" for scope in (
  238. "evidence", "hypothesis", "output", "task", "root"
  239. )},
  240. "root": False,
  241. }
  242. variants = [
  243. {"task_brief_version": 2},
  244. {"task_report": {"summary": "changed"}},
  245. {"evaluated_head_sequence": 3},
  246. {"model_by_scope": {**common["model_by_scope"], "task": "stronger"}},
  247. {"material_issues": [MaterialIssue(
  248. artifact_id="script",
  249. outcome="unknown",
  250. reason="temporarily unavailable",
  251. )]},
  252. ]
  253. for override in variants:
  254. with self.subTest(override=override):
  255. changed = policy.compile_plan(**{**common, **override})
  256. self.assertNotEqual(base.plan_hash, changed.plan_hash)
  257. def test_policy_snapshot_detects_tampering(self):
  258. context = {}
  259. persist_validation_policy(
  260. context,
  261. ValidationPolicy(),
  262. ValidatorSettings(search_provider="disabled"),
  263. )
  264. restored, settings = require_validation_policy(context)
  265. self.assertEqual("disabled", settings.search_provider)
  266. self.assertEqual(ValidationPolicy().policy_hash, restored.policy_hash)
  267. context["validation_policy"]["global_rules"] = "approve everything"
  268. with self.assertRaisesRegex(ValueError, "hash"):
  269. require_validation_policy(context)
  270. def test_parser_rejects_missing_duplicate_wrong_scope_and_outcome(self):
  271. plan = plan_for()
  272. valid = json.loads(passed_scope_response(plan, "task"))
  273. variants = []
  274. missing = json.loads(json.dumps(valid))
  275. missing["checks"].pop()
  276. variants.append(missing)
  277. duplicate = json.loads(json.dumps(valid))
  278. duplicate["checks"].append(duplicate["checks"][0])
  279. variants.append(duplicate)
  280. wrong_scope = json.loads(json.dumps(valid))
  281. wrong_scope["scope"] = "output"
  282. variants.append(wrong_scope)
  283. wrong_outcome = json.loads(json.dumps(valid))
  284. wrong_outcome["outcome"] = "failed"
  285. wrong_outcome["retry_from"] = "task_definition"
  286. variants.append(wrong_outcome)
  287. for payload in variants:
  288. with self.subTest(payload=payload), self.assertRaises(
  289. (ValueError, ValidationError)
  290. ):
  291. parse_scope_validation_result(
  292. json.dumps(payload),
  293. plan=plan,
  294. expected_scope="task",
  295. validator_trace_id="validator",
  296. )
  297. def test_unknown_aggregate_preserves_recoverable_retry(self):
  298. plan = plan_for()
  299. scope = ScopeValidationResult(
  300. validator_trace_id="validator-task",
  301. scope="task",
  302. outcome="unknown",
  303. checks=[ValidationCheck(
  304. check_id="task.policy.1",
  305. status="unknown",
  306. issue="required material is unavailable",
  307. )],
  308. reason="insufficient material",
  309. retry_from="task_definition",
  310. plan_hash=plan.plan_hash,
  311. )
  312. aggregate = aggregate_validation_results(
  313. evaluated_trace_id="child",
  314. plan=plan,
  315. scope_results=[scope],
  316. )
  317. self.assertEqual("unknown", aggregate.outcome)
  318. self.assertEqual("task_definition", aggregate.retry_from)
  319. class FakeLLM:
  320. def __init__(self, responses):
  321. self.responses = list(responses)
  322. self.calls = []
  323. async def __call__(self, **kwargs):
  324. self.calls.append(kwargs)
  325. response = self.responses.pop(0)
  326. if isinstance(response, Exception):
  327. raise response
  328. return response
  329. def response(content="", tool_calls=None):
  330. return {
  331. "content": content,
  332. "tool_calls": tool_calls,
  333. "prompt_tokens": 10,
  334. "completion_tokens": 5,
  335. "cost": 0.01,
  336. "finish_reason": "tool_calls" if tool_calls else "stop",
  337. }
  338. class LLMValidatorTest(unittest.IsolatedAsyncioTestCase):
  339. async def asyncSetUp(self):
  340. self.temp = tempfile.TemporaryDirectory()
  341. self.store = FileSystemTraceStore(self.temp.name)
  342. self.evaluated = Trace(
  343. trace_id="root@delegate-child",
  344. mode="agent",
  345. task="child task",
  346. uid="user-1",
  347. model="fake",
  348. current_goal_id="1",
  349. context={
  350. "agent_mode": "recursive",
  351. "agent_mode_revision": 2,
  352. "root_trace_id": "root",
  353. "agent_depth": 1,
  354. },
  355. )
  356. await self.store.create_trace(self.evaluated)
  357. self.trajectory = [Message.create(
  358. trace_id=self.evaluated.trace_id,
  359. role="tool",
  360. sequence=1,
  361. content={"tool_name": "read_file", "result": "actual output"},
  362. )]
  363. async def asyncTearDown(self):
  364. self.temp.cleanup()
  365. async def test_task_scope_is_one_tool_free_call(self):
  366. plan = plan_for()
  367. llm = FakeLLM([response(passed_scope_response(plan, "task"))])
  368. validator = LLMValidator(
  369. llm_call=llm,
  370. trace_store=self.store,
  371. policy=ValidationPolicy(),
  372. )
  373. run = await validator.validate_plan(
  374. evaluated_trace=self.evaluated,
  375. trajectory=self.trajectory,
  376. plan=plan,
  377. root_task_anchor=ANCHOR,
  378. task_brief=BRIEF,
  379. task_report={"summary": "done"},
  380. candidate_output=None,
  381. materials=[],
  382. material_issues=[],
  383. model_by_scope={"task": "fake"},
  384. )
  385. self.assertEqual("passed", run.result.outcome, run.result.model_dump())
  386. self.assertEqual(1, len(llm.calls))
  387. self.assertEqual([], llm.calls[0]["tools"])
  388. trace = await self.store.get_trace(run.trace_id)
  389. self.assertEqual("completed", trace.status)
  390. self.assertEqual("task", trace.context["validation_scope"])
  391. async def test_evidence_scope_runs_private_search_and_open_loop(self):
  392. plan = plan_for(scopes=["evidence"])
  393. class Provider:
  394. async def search(self, query, max_results):
  395. return [{
  396. "title": "Official",
  397. "link": "https://example.com/fact",
  398. "snippet": "official page",
  399. }]
  400. async def page_fetcher(url, resolver=None):
  401. del resolver
  402. return {
  403. "source_id": "src-official",
  404. "url": url,
  405. "final_url": url,
  406. "title": "Official",
  407. "content_type": "text/plain",
  408. "retrieved_at": "2026-01-01T00:00:00+00:00",
  409. "content_sha256": "b" * 64,
  410. "text": "The official figure is 12%.",
  411. "truncated": False,
  412. "untrusted_material": True,
  413. }
  414. def session_factory(scope, allowed_urls, trace_id):
  415. del scope, trace_id
  416. return ValidatorToolSession(
  417. provider=Provider(),
  418. allowed_urls=allowed_urls,
  419. limits=ValidatorToolLimits(5, 10, 15),
  420. page_fetcher=page_fetcher,
  421. )
  422. llm = FakeLLM([
  423. response(tool_calls=[{
  424. "id": "search-1",
  425. "type": "function",
  426. "function": {
  427. "name": "validator_web_search",
  428. "arguments": json.dumps({"query": "official figure"}),
  429. },
  430. }]),
  431. response(tool_calls=[{
  432. "id": "open-1",
  433. "type": "function",
  434. "function": {
  435. "name": "validator_open_url",
  436. "arguments": json.dumps({"url": "https://example.com/fact"}),
  437. },
  438. }]),
  439. response(passed_scope_response(
  440. plan,
  441. "evidence",
  442. evidence_refs=["src-official"],
  443. )),
  444. response(passed_scope_response(plan, "task")),
  445. ])
  446. validator = LLMValidator(
  447. llm_call=llm,
  448. trace_store=self.store,
  449. policy=ValidationPolicy(),
  450. tool_session_factory=session_factory,
  451. )
  452. run = await validator.validate_plan(
  453. evaluated_trace=self.evaluated,
  454. trajectory=self.trajectory,
  455. plan=plan,
  456. root_task_anchor=ANCHOR,
  457. task_brief=dict(BRIEF, validation_scopes=["evidence"]),
  458. task_report={"summary": "done"},
  459. candidate_output=None,
  460. materials=[],
  461. material_issues=[],
  462. model_by_scope={"evidence": "fake", "task": "fake"},
  463. )
  464. self.assertEqual("passed", run.result.outcome, run.result.model_dump())
  465. self.assertEqual(2, len(run.trace_ids))
  466. evidence_messages = await self.store.get_trace_messages(run.trace_ids[0])
  467. self.assertEqual(
  468. ["system", "user", "assistant", "tool", "assistant", "tool", "assistant"],
  469. [item.role for item in evidence_messages],
  470. )
  471. self.assertEqual(
  472. {"validator_web_search", "validator_open_url"},
  473. {
  474. item["function"]["name"]
  475. for item in (await self.store.get_trace(run.trace_ids[0])).tools
  476. },
  477. )
  478. async def test_invalid_output_fails_without_format_correction(self):
  479. plan = plan_for()
  480. llm = FakeLLM([response("not-json")])
  481. validator = LLMValidator(
  482. llm_call=llm,
  483. trace_store=self.store,
  484. policy=ValidationPolicy(),
  485. )
  486. run = await validator.validate_plan(
  487. evaluated_trace=self.evaluated,
  488. trajectory=[],
  489. plan=plan,
  490. root_task_anchor=ANCHOR,
  491. task_brief=BRIEF,
  492. task_report={"summary": "done"},
  493. candidate_output=None,
  494. materials=[],
  495. material_issues=[],
  496. model_by_scope={"task": "fake"},
  497. )
  498. self.assertEqual("error", run.result.outcome)
  499. self.assertEqual(1, len(llm.calls))
  500. async def test_deterministic_material_failure_skips_all_llm_calls(self):
  501. plan = ValidationPolicy().compile_plan(
  502. task_brief=dict(BRIEF, validation_scopes=["output"]),
  503. task_brief_version=1,
  504. root_task_anchor=ANCHOR,
  505. task_report={"summary": "claimed output"},
  506. candidate_output=None,
  507. evaluated_head_sequence=1,
  508. materials=[],
  509. material_issues=[MaterialIssue(
  510. artifact_id="script:missing",
  511. outcome="failed",
  512. reason="artifact does not exist",
  513. )],
  514. model_by_scope={"output": "fake", "task": "fake"},
  515. root=False,
  516. )
  517. llm = FakeLLM([])
  518. validator = LLMValidator(
  519. llm_call=llm,
  520. trace_store=self.store,
  521. policy=ValidationPolicy(),
  522. )
  523. run = await validator.validate_plan(
  524. evaluated_trace=self.evaluated,
  525. trajectory=[],
  526. plan=plan,
  527. root_task_anchor=ANCHOR,
  528. task_brief=dict(BRIEF, validation_scopes=["output"]),
  529. task_report={"summary": "claimed output"},
  530. candidate_output=None,
  531. materials=[],
  532. material_issues=[MaterialIssue(
  533. artifact_id="script:missing",
  534. outcome="failed",
  535. reason="artifact does not exist",
  536. )],
  537. model_by_scope={"output": "fake", "task": "fake"},
  538. )
  539. self.assertEqual("failed", run.result.outcome)
  540. self.assertEqual(2, len(run.trace_ids))
  541. self.assertEqual([], llm.calls)
  542. for trace_id in run.trace_ids:
  543. self.assertEqual("failed", (await self.store.get_trace(trace_id)).status)
  544. async def test_material_failure_only_skips_affected_scopes(self):
  545. brief = dict(BRIEF, validation_scopes=["hypothesis", "output"])
  546. issue = MaterialIssue(
  547. artifact_id="script:missing",
  548. outcome="failed",
  549. reason="script does not exist",
  550. scopes=["output", "task", "root"],
  551. )
  552. plan = ValidationPolicy().compile_plan(
  553. task_brief=brief,
  554. task_brief_version=1,
  555. root_task_anchor=ANCHOR,
  556. task_report={"summary": "claimed output"},
  557. candidate_output=None,
  558. evaluated_head_sequence=1,
  559. materials=[],
  560. material_issues=[issue],
  561. model_by_scope={
  562. "hypothesis": "fake", "output": "fake", "task": "fake",
  563. },
  564. root=False,
  565. )
  566. llm = FakeLLM([response(passed_scope_response(plan, "hypothesis"))])
  567. validator = LLMValidator(
  568. llm_call=llm,
  569. trace_store=self.store,
  570. policy=ValidationPolicy(),
  571. )
  572. run = await validator.validate_plan(
  573. evaluated_trace=self.evaluated,
  574. trajectory=[],
  575. plan=plan,
  576. root_task_anchor=ANCHOR,
  577. task_brief=brief,
  578. task_report={"summary": "claimed output"},
  579. candidate_output=None,
  580. materials=[],
  581. material_issues=[issue],
  582. model_by_scope={
  583. "hypothesis": "fake", "output": "fake", "task": "fake",
  584. },
  585. )
  586. self.assertEqual(1, len(llm.calls))
  587. self.assertEqual(
  588. ["passed", "failed", "failed"],
  589. [item.outcome for item in run.result.scope_results],
  590. )
  591. async def test_matching_scope_checkpoint_is_not_run_again(self):
  592. plan = plan_for(scopes=["output"])
  593. output_result = ScopeValidationResult(
  594. validator_trace_id="existing-validator-output",
  595. scope="output",
  596. outcome="passed",
  597. checks=[ValidationCheck(
  598. check_id=item.check_id,
  599. status="passed",
  600. ) for item in plan.checks_for_scope("output")],
  601. reason="already checked",
  602. retry_from=None,
  603. plan_hash=plan.plan_hash,
  604. )
  605. llm = FakeLLM([response(passed_scope_response(plan, "task"))])
  606. validator = LLMValidator(
  607. llm_call=llm,
  608. trace_store=self.store,
  609. policy=ValidationPolicy(),
  610. )
  611. run = await validator.validate_plan(
  612. evaluated_trace=self.evaluated,
  613. trajectory=[],
  614. plan=plan,
  615. root_task_anchor=ANCHOR,
  616. task_brief=dict(BRIEF, validation_scopes=["output"]),
  617. task_report={"summary": "done"},
  618. candidate_output=None,
  619. materials=[],
  620. material_issues=[],
  621. model_by_scope={"output": "fake", "task": "fake"},
  622. resume_scope_results=[output_result],
  623. )
  624. self.assertEqual("passed", run.result.outcome)
  625. self.assertEqual(1, len(llm.calls))
  626. self.assertEqual(
  627. ["existing-validator-output", run.trace_ids[1]],
  628. run.trace_ids,
  629. )
  630. async def test_task_scope_forged_private_tool_is_an_error(self):
  631. plan = plan_for()
  632. llm = FakeLLM([response(tool_calls=[{
  633. "id": "forged",
  634. "type": "function",
  635. "function": {
  636. "name": "validator_web_search",
  637. "arguments": json.dumps({"query": "should not run"}),
  638. },
  639. }])])
  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=BRIEF,
  651. task_report={"summary": "done"},
  652. candidate_output=None,
  653. materials=[],
  654. material_issues=[],
  655. model_by_scope={"task": "fake"},
  656. )
  657. self.assertEqual("error", run.result.outcome)
  658. self.assertIn("unavailable tool", run.result.issues[0])
  659. if __name__ == "__main__":
  660. unittest.main()