test_recursive_validator_lifecycle_complex.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652
  1. import asyncio
  2. import json
  3. import tempfile
  4. import unittest
  5. from cyber_agent.core.artifacts import (
  6. ArtifactRef,
  7. ValidationMaterial,
  8. canonical_material_content,
  9. material_content_hash,
  10. )
  11. from cyber_agent.core.context_policy import persist_root_task_anchor
  12. from cyber_agent.core.resource_budget import (
  13. RESOURCE_BUDGET_CONTEXT_KEY,
  14. ResourceBudget,
  15. ResourceBudgetController,
  16. )
  17. from cyber_agent.core.run_snapshot import (
  18. RunConfigSnapshotV1,
  19. persist_run_config_snapshot,
  20. )
  21. from cyber_agent.core.runner import AgentRunner, RunConfig
  22. from cyber_agent.core.task_protocol import (
  23. TaskReport,
  24. ensure_task_protocol,
  25. initialize_task_progress,
  26. new_task_protocol,
  27. pending_review_entry,
  28. replace_task_brief,
  29. )
  30. from cyber_agent.core.validation import (
  31. ValidationPolicy,
  32. ValidatorSettings,
  33. persist_validation_policy,
  34. )
  35. from cyber_agent.tools.builtin.knowledge import KnowledgeConfig
  36. from cyber_agent.tools.builtin.task_protocol import (
  37. review_task_result,
  38. update_task_progress,
  39. )
  40. from cyber_agent.tools.models import ToolResult
  41. from cyber_agent.tools.registry import ToolRegistry
  42. from cyber_agent.trace.goal_models import Goal, GoalTree
  43. from cyber_agent.trace.models import Message, Trace
  44. from cyber_agent.trace.store import FileSystemTraceStore
  45. ANCHOR = {
  46. "objective": "发布只使用官方数字的完整建议",
  47. "completion_criteria": ["必须使用官方12%", "必须说明30%无官方支持"],
  48. "constraints": ["不得把转载当成官方来源"],
  49. }
  50. def brief(depth, *, evidence=False, version=1):
  51. value = "12%" if version == 2 else "30%"
  52. return {
  53. "objective": f"depth-{depth} 核实局部数字 {value}",
  54. "reason": f"depth-{depth - 1} 需要直属子任务的证据",
  55. "completion_criteria": [f"确认 {value} 是否有官方支持"],
  56. "expected_outputs": ["一条可追溯结论"],
  57. "constraints": ["不得猜测"],
  58. "validation_scopes": ["evidence"] if evidence else [],
  59. }
  60. def report(trace_id, summary):
  61. return TaskReport.model_validate({
  62. "child_trace_id": trace_id,
  63. "summary": summary,
  64. "outcome": "satisfied",
  65. "validation": {"hard_passed": True, "open_issues": []},
  66. "next_step_suggestion": {
  67. "direction": "ASCEND",
  68. "reason": "当前局部任务已完成",
  69. },
  70. "outputs": [{"conclusion": summary}],
  71. "evidence": [{"source": "validator-will-check"}],
  72. "remaining_issues": [],
  73. })
  74. def validation_packet(messages):
  75. return next(
  76. json.loads(message["content"])
  77. for message in messages
  78. if message.get("role") == "user"
  79. and "recursive_validation_protocol" in str(message.get("content", ""))
  80. )
  81. def check_response(packet, outcome="passed", issue=None, evidence_refs=None):
  82. scope = packet["validation_scope"]
  83. return {
  84. "content": json.dumps({
  85. "scope": scope,
  86. "outcome": outcome,
  87. "checks": [
  88. {
  89. "check_id": item["check_id"],
  90. "status": outcome,
  91. "evidence_refs": list(evidence_refs or []),
  92. "issue": None if outcome == "passed" else issue,
  93. }
  94. for item in packet["validation_plan"]["checks"]
  95. if item["scope"] == scope
  96. ],
  97. "reason": "验收通过" if outcome == "passed" else issue,
  98. "retry_from": None if outcome == "passed" else (
  99. "task_definition" if scope in {"task", "root"} else scope
  100. ),
  101. }, ensure_ascii=False),
  102. "tool_calls": [],
  103. "finish_reason": "stop",
  104. "prompt_tokens": 3,
  105. "completion_tokens": 2,
  106. "cost": 0.0001,
  107. }
  108. def tool_call(name, arguments):
  109. return {
  110. "content": "",
  111. "tool_calls": [{
  112. "id": f"{name}-call",
  113. "type": "function",
  114. "function": {
  115. "name": name,
  116. "arguments": json.dumps(arguments, ensure_ascii=False),
  117. },
  118. }],
  119. "finish_reason": "tool_calls",
  120. "prompt_tokens": 3,
  121. "completion_tokens": 2,
  122. "cost": 0.0001,
  123. }
  124. class SearchProvider:
  125. async def search(self, _query, max_results):
  126. return [
  127. {
  128. "title": "官方数字",
  129. "link": "https://official.example/12",
  130. "snippet": "12%",
  131. },
  132. {
  133. "title": "二次转载",
  134. "link": "https://repost.example/30",
  135. "snippet": "30%",
  136. },
  137. ][:max_results]
  138. async def page_fetcher(url, resolver=None):
  139. del resolver
  140. return {
  141. "source_id": "src-official-12",
  142. "url": url,
  143. "final_url": url,
  144. "title": "官方数字",
  145. "content_type": "text/plain",
  146. "retrieved_at": "2026-07-17T00:00:00+00:00",
  147. "content_sha256": "d" * 64,
  148. "text": "官方页面只支持12%,没有30%。",
  149. "truncated": False,
  150. "untrusted_material": True,
  151. }
  152. class FiveLevelValidatorLifecycleTest(unittest.IsolatedAsyncioTestCase):
  153. async def asyncSetUp(self):
  154. self.temp = tempfile.TemporaryDirectory()
  155. self.store = FileSystemTraceStore(self.temp.name)
  156. self.root_id = "complex-root"
  157. self.ids = [self.root_id, *[f"depth-{depth}" for depth in range(1, 6)]]
  158. budget = ResourceBudget()
  159. root_context = {
  160. "agent_mode": "recursive",
  161. "agent_mode_revision": 3,
  162. "agent_depth": 0,
  163. "root_trace_id": self.root_id,
  164. RESOURCE_BUDGET_CONTEXT_KEY: budget.to_dict(),
  165. "task_protocol": new_task_protocol(),
  166. }
  167. persist_root_task_anchor(root_context, ANCHOR)
  168. initialize_task_progress(
  169. ensure_task_protocol(root_context),
  170. root_task_anchor_hash=root_context["root_task_anchor_hash"],
  171. )
  172. persist_validation_policy(
  173. root_context,
  174. ValidationPolicy(),
  175. ValidatorSettings(search_provider="serper"),
  176. )
  177. await self.store.create_trace(Trace(
  178. trace_id=self.root_id,
  179. mode="agent",
  180. task="root",
  181. uid="user-1",
  182. model="fake",
  183. context=root_context,
  184. ))
  185. await self.store.add_message(Message.create(
  186. trace_id=self.root_id,
  187. role="user",
  188. sequence=1,
  189. content="生成可发布建议",
  190. ))
  191. await self.store.update_trace(self.root_id, head_sequence=1)
  192. for depth in range(1, 6):
  193. context = {
  194. "agent_mode": "recursive",
  195. "agent_mode_revision": 3,
  196. "agent_depth": depth,
  197. "root_trace_id": self.root_id,
  198. "created_by_tool": "agent",
  199. "task_protocol": new_task_protocol(
  200. brief(depth, evidence=(depth == 5))
  201. ),
  202. }
  203. persist_root_task_anchor(context, ANCHOR)
  204. initialize_task_progress(
  205. ensure_task_protocol(context),
  206. root_task_anchor_hash=context["root_task_anchor_hash"],
  207. )
  208. await self.store.create_trace(Trace(
  209. trace_id=self.ids[depth],
  210. mode="agent",
  211. task=f"depth-{depth}",
  212. parent_trace_id=self.ids[depth - 1],
  213. parent_goal_id="1",
  214. uid="user-1",
  215. model="fake",
  216. context=context,
  217. ))
  218. await self.store.add_message(Message.create(
  219. trace_id=self.ids[depth],
  220. role="user",
  221. sequence=1,
  222. content=f"execute depth-{depth}",
  223. ))
  224. await self.store.update_trace(self.ids[depth], head_sequence=1)
  225. await ResourceBudgetController(self.store).initialize(
  226. self.root_id,
  227. budget,
  228. initial_agents=6,
  229. )
  230. async def asyncTearDown(self):
  231. self.temp.cleanup()
  232. async def test_deepest_evidence_failure_revises_only_direct_child(self):
  233. async def llm_call(**kwargs):
  234. messages = kwargs["messages"]
  235. packet = validation_packet(messages)
  236. scope = packet["validation_scope"]
  237. if scope == "evidence":
  238. tool_names = [
  239. message.get("name")
  240. for message in messages
  241. if message.get("role") == "tool"
  242. ]
  243. if not tool_names:
  244. return tool_call(
  245. "validator_web_search",
  246. {"query": "official 30% 12%", "max_results": 5},
  247. )
  248. if tool_names[-1] == "validator_web_search":
  249. return tool_call(
  250. "validator_open_url",
  251. {"url": "https://official.example/12"},
  252. )
  253. summary = packet["task_report"]["summary"]
  254. if "30%" in summary:
  255. return check_response(
  256. packet,
  257. outcome="failed",
  258. issue="官方来源只支持12%,不支持30%",
  259. evidence_refs=["src-official-12"],
  260. )
  261. return check_response(
  262. packet,
  263. evidence_refs=["src-official-12"],
  264. )
  265. return check_response(packet)
  266. runner = AgentRunner(
  267. trace_store=self.store,
  268. llm_call=llm_call,
  269. validator_search_provider=SearchProvider(),
  270. validator_page_fetcher=page_fetcher,
  271. )
  272. child_id = self.ids[5]
  273. parent_id = self.ids[4]
  274. first_report = report(child_id, "官方支持30%")
  275. child = await self.store.get_trace(child_id)
  276. child_state = ensure_task_protocol(child.context)
  277. child_state["task_report"] = first_report.model_dump()
  278. child_state["task_report_progress_revision"] = child_state[
  279. "task_progress_head_revision"
  280. ]
  281. await self.store.update_trace(child_id, context=child.context)
  282. first = await runner.validate_recursive_trace(
  283. child_id,
  284. task_report=first_report.model_dump(mode="json"),
  285. )
  286. self.assertEqual("failed", first.result.outcome)
  287. self.assertEqual(
  288. ["failed", "passed"],
  289. [item.outcome for item in first.result.scope_results],
  290. )
  291. parent = await self.store.get_trace(parent_id)
  292. parent_state = ensure_task_protocol(parent.context)
  293. parent_state["pending_reviews"][child_id] = pending_review_entry(
  294. goal_id="1",
  295. report=first_report,
  296. validation_result=first.result.model_dump(mode="json"),
  297. received_at_sequence=10,
  298. )
  299. await self.store.update_trace(parent_id, context=parent.context)
  300. tree = GoalTree(
  301. mission="depth-4",
  302. goals=[Goal(id="1", description="verify depth-5", status="pending_review")],
  303. current_id=None,
  304. )
  305. await self.store.update_goal_tree(parent_id, tree)
  306. reviewed = await review_task_result(
  307. child_trace_id=child_id,
  308. decision="REVISE_CHILD",
  309. reason="改为官方可支持的12%",
  310. approved_next_task=brief(5, evidence=True, version=2),
  311. context={
  312. "store": self.store,
  313. "trace_id": parent_id,
  314. "goal_id": "1",
  315. "goal_tree": tree,
  316. "sequence": 11,
  317. },
  318. )
  319. self.assertEqual("completed", reviewed["status"])
  320. child = await self.store.get_trace(child_id)
  321. child_state = ensure_task_protocol(child.context)
  322. self.assertIsNone(child_state["task_report"])
  323. self.assertIsNone(child_state["task_report_validation"])
  324. self.assertEqual(1, len(child_state["report_history"]))
  325. parent = await self.store.get_trace(parent_id)
  326. parent_state = ensure_task_protocol(parent.context)
  327. self.assertEqual("REVISE_CHILD", parent_state["next_actions"][0]["decision"])
  328. revised_brief = parent_state["next_actions"][0]["task_brief"]
  329. replace_task_brief(child_state, revised_brief, effective_at_sequence=12)
  330. second_report = report(child_id, "官方只支持12%")
  331. child_state["task_report"] = second_report.model_dump()
  332. child_state["task_report_progress_revision"] = child_state[
  333. "task_progress_head_revision"
  334. ]
  335. parent_state["next_actions"].clear()
  336. await self.store.update_trace(child_id, context=child.context)
  337. await self.store.update_trace(parent_id, context=parent.context)
  338. second = await runner.validate_recursive_trace(
  339. child_id,
  340. task_report=second_report.model_dump(mode="json"),
  341. )
  342. self.assertEqual("passed", second.result.outcome)
  343. self.assertNotEqual(first.result.plan_hash, second.result.plan_hash)
  344. self.assertEqual(
  345. 4,
  346. len(await self.store.list_traces(
  347. parent_trace_id=child_id,
  348. created_by_tool="validator",
  349. limit=10,
  350. )),
  351. )
  352. usage = await ResourceBudgetController(self.store).get_usage(self.root_id)
  353. self.assertEqual(6, usage.total_agents)
  354. async def test_parent_stop_during_validator_page_fetch_discards_tool_result(self):
  355. page_started = asyncio.Event()
  356. release_page = asyncio.Event()
  357. page_text = "官方页面只支持12%,没有30%。"
  358. async def blocking_page_fetcher(url, resolver=None):
  359. del resolver
  360. page_started.set()
  361. await release_page.wait()
  362. return {
  363. "source_id": "src-official-12",
  364. "url": url,
  365. "final_url": url,
  366. "title": "官方数字",
  367. "content_type": "text/plain",
  368. "retrieved_at": "2026-07-17T00:00:00+00:00",
  369. "content_sha256": "d" * 64,
  370. "text": page_text,
  371. "truncated": False,
  372. "untrusted_material": True,
  373. }
  374. llm_calls = 0
  375. async def llm_call(**kwargs):
  376. nonlocal llm_calls
  377. llm_calls += 1
  378. packet = validation_packet(kwargs["messages"])
  379. self.assertEqual("evidence", packet["validation_scope"])
  380. return tool_call(
  381. "validator_open_url",
  382. {"url": "https://official.example/12"},
  383. )
  384. runner = AgentRunner(
  385. trace_store=self.store,
  386. llm_call=llm_call,
  387. validator_page_fetcher=blocking_page_fetcher,
  388. )
  389. root_event = asyncio.Event()
  390. runner._cancel_events[self.root_id] = root_event
  391. runner._recursive_active_traces[self.root_id] = root_event
  392. registered = []
  393. for parent_id, child_id in zip(self.ids, self.ids[1:]):
  394. registered.append((
  395. child_id,
  396. runner.register_recursive_child(parent_id, child_id),
  397. ))
  398. child_id = self.ids[5]
  399. payload = report(child_id, "官方只支持12%").model_dump(mode="json")
  400. payload["source_urls"] = ["https://official.example/12"]
  401. child = await self.store.get_trace(child_id)
  402. child_state = ensure_task_protocol(child.context)
  403. child_state["task_report_progress_revision"] = child_state[
  404. "task_progress_head_revision"
  405. ]
  406. await self.store.update_trace(child_id, context=child.context)
  407. task = asyncio.create_task(runner.validate_recursive_trace(
  408. child_id,
  409. task_report=payload,
  410. ))
  411. await asyncio.wait_for(page_started.wait(), timeout=1)
  412. self.assertTrue(await runner.stop(self.root_id))
  413. release_page.set()
  414. run = await asyncio.wait_for(task, timeout=2)
  415. self.assertEqual("error", run.result.outcome)
  416. self.assertEqual(1, llm_calls)
  417. validators = await self.store.list_traces(
  418. parent_trace_id=child_id,
  419. created_by_tool="validator",
  420. limit=10,
  421. )
  422. evidence_trace = next(
  423. trace for trace in validators
  424. if trace.context["validation_scope"] == "evidence"
  425. )
  426. messages = await self.store.get_trace_messages(evidence_trace.trace_id)
  427. self.assertNotIn("tool", [message.role for message in messages])
  428. child = await self.store.get_trace(child_id)
  429. cache = ensure_task_protocol(child.context)["task_report_validation"]
  430. self.assertEqual("error", cache["aggregate_result"]["outcome"])
  431. usage = await ResourceBudgetController(self.store).get_usage(self.root_id)
  432. self.assertEqual(len(page_text), usage.validation_material_chars)
  433. for trace_id, event in reversed(registered):
  434. runner.release_recursive_trace(trace_id, event)
  435. runner.release_recursive_trace(self.root_id, root_event)
  436. async def test_all_children_pass_but_root_must_revise_its_own_output(self):
  437. root_validation_attempt = 0
  438. ordinary_calls = 0
  439. validated_versions = []
  440. scripts = {
  441. "v1": "建议使用官方12%。",
  442. "v2": "建议使用官方12%;30%只见于转载,无官方支持。",
  443. }
  444. refs = {
  445. version: ArtifactRef(
  446. artifact_id="script:final",
  447. version=version,
  448. content_hash=material_content_hash(content),
  449. kind="script",
  450. mime_type="text/plain",
  451. )
  452. for version, content in scripts.items()
  453. }
  454. class ScriptResolver:
  455. async def resolve(self, ref, root_trace_id, uid):
  456. return ValidationMaterial(
  457. **ref.model_dump(mode="json"),
  458. root_trace_id=root_trace_id,
  459. uid=uid,
  460. content=scripts[ref.version],
  461. )
  462. registry = ToolRegistry()
  463. async def publish_script(version: str):
  464. return ToolResult(
  465. title=f"script {version}",
  466. output=scripts[version],
  467. artifact_refs=[refs[version].model_dump(mode="json")],
  468. )
  469. registry.register(publish_script)
  470. registry.register(
  471. update_task_progress,
  472. hidden_params=["context"],
  473. groups=["core"],
  474. )
  475. async def llm_call(**kwargs):
  476. nonlocal root_validation_attempt, ordinary_calls
  477. messages = kwargs["messages"]
  478. if messages and "recursive_validation_protocol" in messages[0]["content"]:
  479. packet = validation_packet(messages)
  480. if packet["validation_scope"] == "root":
  481. root_validation_attempt += 1
  482. validated_versions.append([
  483. item["version"] for item in packet["materials"]
  484. ])
  485. if root_validation_attempt == 1:
  486. return check_response(
  487. packet,
  488. outcome="failed",
  489. issue="根输出遗漏了30%无官方支持的风险说明",
  490. )
  491. return check_response(packet)
  492. ordinary_calls += 1
  493. if ordinary_calls in {1, 4}:
  494. version = "v1" if ordinary_calls == 1 else "v2"
  495. return tool_call("publish_script", {"version": version})
  496. if ordinary_calls == 2:
  497. return tool_call(
  498. "update_task_progress",
  499. {
  500. "expected_revision": 1,
  501. "progress": {
  502. "phase": "ready_to_submit",
  503. "questions": [],
  504. "blockers": [],
  505. "findings": [],
  506. "hypotheses": [],
  507. "work_items": [],
  508. "decision_rationale": "The root output is ready for validation.",
  509. },
  510. },
  511. )
  512. return {
  513. "content": scripts["v1" if ordinary_calls == 3 else "v2"],
  514. "tool_calls": [],
  515. "finish_reason": "stop",
  516. "prompt_tokens": 3,
  517. "completion_tokens": 2,
  518. "cost": 0.0001,
  519. }
  520. runner = AgentRunner(
  521. trace_store=self.store,
  522. tool_registry=registry,
  523. llm_call=llm_call,
  524. artifact_resolver=ScriptResolver(),
  525. )
  526. for depth in range(1, 6):
  527. trace_id = self.ids[depth]
  528. child = await self.store.get_trace(trace_id)
  529. child_state = ensure_task_protocol(child.context)
  530. if depth == 5:
  531. replace_task_brief(
  532. child_state,
  533. brief(depth, evidence=False),
  534. effective_at_sequence=2,
  535. )
  536. child_report = report(trace_id, f"depth-{depth} 局部交付准确")
  537. child_state["task_report"] = child_report.model_dump()
  538. child_state["task_report_progress_revision"] = child_state[
  539. "task_progress_head_revision"
  540. ]
  541. await self.store.update_trace(trace_id, context=child.context)
  542. run = await runner.validate_recursive_trace(
  543. trace_id,
  544. task_report=child_report.model_dump(mode="json"),
  545. )
  546. self.assertEqual("passed", run.result.outcome)
  547. resume_config = RunConfig(
  548. trace_id=self.root_id,
  549. model="fake",
  550. uid="user-1",
  551. tools=["publish_script", "update_task_progress"],
  552. tool_groups=[],
  553. enable_research_flow=False,
  554. knowledge=KnowledgeConfig(
  555. enable_extraction=False,
  556. enable_completion_extraction=False,
  557. enable_injection=False,
  558. ),
  559. )
  560. root = await self.store.get_trace(self.root_id)
  561. persist_run_config_snapshot(
  562. root.context,
  563. RunConfigSnapshotV1.from_run_config(
  564. resume_config,
  565. memory_identity=None,
  566. ),
  567. )
  568. await self.store.update_trace(self.root_id, context=root.context)
  569. result = await runner.run_result([], resume_config)
  570. self.assertEqual("completed", result["status"])
  571. root = await self.store.get_trace(self.root_id)
  572. state = ensure_task_protocol(root.context)
  573. self.assertEqual(2, state["root_validation_attempts"])
  574. self.assertEqual(
  575. ["failed", "passed"],
  576. [item["outcome"] for item in state["root_validation_history"]],
  577. )
  578. self.assertTrue(state["root_validation_passed"])
  579. self.assertEqual(5, ordinary_calls)
  580. self.assertEqual(2, root_validation_attempt)
  581. self.assertEqual([["v1"], ["v1", "v2"]], validated_versions)
  582. usage = await ResourceBudgetController(self.store).get_usage(self.root_id)
  583. self.assertEqual(6, usage.total_agents)
  584. expected_material_chars = (
  585. 2 * len(canonical_material_content(scripts["v1"]))
  586. + len(canonical_material_content(scripts["v2"]))
  587. )
  588. self.assertEqual(expected_material_chars, usage.validation_material_chars)
  589. restarted_runner = AgentRunner(
  590. trace_store=self.store,
  591. tool_registry=registry,
  592. llm_call=llm_call,
  593. artifact_resolver=ScriptResolver(),
  594. )
  595. cached = await restarted_runner.validate_recursive_trace(
  596. self.root_id,
  597. scope="root",
  598. candidate_output=scripts["v2"],
  599. root_validator=True,
  600. )
  601. self.assertTrue(cached.cached)
  602. after_cache = await ResourceBudgetController(self.store).get_usage(self.root_id)
  603. self.assertEqual(
  604. expected_material_chars,
  605. after_cache.validation_material_chars,
  606. )
  607. if __name__ == "__main__":
  608. unittest.main()