test_recursive_validator_lifecycle_complex.py 22 KB

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