test_application_reference.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. from copy import deepcopy
  2. import json
  3. import tempfile
  4. import unittest
  5. from unittest.mock import patch
  6. from cyber_agent.application import (
  7. ApplicationRegistry,
  8. ApplicationRuntime,
  9. CandidateReviewAction,
  10. )
  11. from cyber_agent.application.candidate import (
  12. CandidateLedger,
  13. CandidatePointer,
  14. CandidateRef,
  15. )
  16. from cyber_agent.core.artifacts import ArtifactRef
  17. from cyber_agent.core.run_snapshot import (
  18. RunConfigSnapshotV2,
  19. persist_run_config_snapshot,
  20. )
  21. from cyber_agent.core.runner import RunConfig
  22. from cyber_agent.tools.builtin.knowledge import KnowledgeConfig
  23. from cyber_agent.core.task_protocol import (
  24. Finding,
  25. Hypothesis,
  26. Question,
  27. TaskBrief,
  28. TaskProgress,
  29. WorkItem,
  30. initialize_task_progress,
  31. new_task_protocol,
  32. )
  33. from cyber_agent.trace.models import Message, Trace
  34. from cyber_agent.trace.store import FileSystemTraceStore
  35. from cyber_agent.tools.builtin.subagent import _record_pending_task_reports
  36. from examples.application_reference import build_reference_components
  37. def _tool_call(call_id, name, arguments):
  38. return {
  39. "content": "",
  40. "tool_calls": [{
  41. "id": call_id,
  42. "type": "function",
  43. "function": {
  44. "name": name,
  45. "arguments": json.dumps(arguments),
  46. },
  47. }],
  48. "finish_reason": "tool_calls",
  49. }
  50. def _has_tool_result(messages, name):
  51. return any(
  52. item.get("role") == "tool" and item.get("name") == name
  53. for item in messages
  54. )
  55. def _tool_names(schemas):
  56. return {item["function"]["name"] for item in schemas or []}
  57. class ApplicationReferenceEndToEndTest(unittest.IsolatedAsyncioTestCase):
  58. async def test_real_agent_candidate_report_review_and_adoption_chain(self):
  59. with tempfile.TemporaryDirectory() as temp_dir:
  60. store = FileSystemTraceStore(temp_dir)
  61. components = build_reference_components()
  62. registry = ApplicationRegistry()
  63. registry.register(components.application, components.services)
  64. delegated = False
  65. reviewed = False
  66. async def llm_call(**kwargs):
  67. nonlocal delegated, reviewed
  68. messages = kwargs["messages"]
  69. if any(
  70. "recursive_validation_protocol" in str(item.get("content", ""))
  71. for item in messages
  72. if item.get("role") == "system"
  73. ):
  74. packet = next(
  75. json.loads(item["content"])
  76. for item in messages
  77. if item.get("role") == "user"
  78. and "recursive_validation_protocol"
  79. in str(item.get("content", ""))
  80. )
  81. scope = packet["validation_scope"]
  82. return {
  83. "content": json.dumps({
  84. "scope": scope,
  85. "outcome": "passed",
  86. "checks": [{
  87. "check_id": item["check_id"],
  88. "status": "passed",
  89. "evidence_refs": [],
  90. "issue": None,
  91. } for item in packet["validation_plan"]["checks"]
  92. if item["scope"] == scope],
  93. "reason": "reference output is complete",
  94. "retry_from": None,
  95. }),
  96. "tool_calls": [],
  97. "finish_reason": "stop",
  98. }
  99. system = "\n".join(
  100. str(item.get("content", ""))
  101. for item in messages
  102. if item.get("role") == "system"
  103. )
  104. names = _tool_names(kwargs.get("tools"))
  105. if "Create replaceable candidate versions" in system:
  106. if not _has_tool_result(messages, "manage_candidate"):
  107. self.assertIn("manage_candidate", names)
  108. self.assertIn("submit_task_report", names)
  109. return _tool_call("candidate-create", "manage_candidate", {
  110. "request": {
  111. "operation": "create",
  112. "content": {"text": "A finished candidate."},
  113. "parent_refs": [],
  114. },
  115. })
  116. candidate_message = next(
  117. item for item in reversed(messages)
  118. if item.get("role") == "tool"
  119. and item.get("name") == "manage_candidate"
  120. )
  121. candidate_ref = json.loads(
  122. candidate_message["content"]
  123. )["candidate_ref"]
  124. if not _has_tool_result(messages, "update_task_progress"):
  125. return _tool_call("writer-progress", "update_task_progress", {
  126. "expected_revision": 1,
  127. "progress": {
  128. "phase": "ready_to_submit",
  129. "questions": [],
  130. "blockers": [],
  131. "findings": [],
  132. "hypotheses": [],
  133. "work_items": [],
  134. "decision_rationale": "The exact candidate is registered.",
  135. },
  136. })
  137. if not _has_tool_result(messages, "submit_task_report"):
  138. return _tool_call("writer-report", "submit_task_report", {
  139. "task_report": {
  140. "summary": "One complete candidate is ready.",
  141. "outcome": "satisfied",
  142. "validation": {
  143. "hard_passed": True,
  144. "open_issues": [],
  145. },
  146. "next_step_suggestion": {
  147. "direction": "NONE",
  148. "reason": "Parent may adopt the exact revision.",
  149. },
  150. "outputs": [],
  151. "evidence": [],
  152. "remaining_issues": [],
  153. "candidate_refs": [candidate_ref],
  154. },
  155. })
  156. return {"content": "writer submitted", "tool_calls": []}
  157. if names == {"review_task_result"}:
  158. root = next(
  159. item for item in await store.list_traces(limit=20)
  160. if item.parent_trace_id is None
  161. )
  162. child_id, pending = next(iter(
  163. root.context["task_protocol"]["pending_reviews"].items()
  164. ))
  165. candidate_ref = pending["task_report"]["candidate_refs"][0]
  166. reviewed = True
  167. return _tool_call("adopt-review", "review_task_result", {
  168. "child_trace_id": child_id,
  169. "decision": "ASCEND",
  170. "reason": "Adopt the validated exact candidate revision.",
  171. "candidate_actions": [{
  172. "action": "adopt",
  173. "candidate_ref": candidate_ref,
  174. "reason": "The candidate passed independent validation.",
  175. }],
  176. })
  177. if not delegated:
  178. delegated = True
  179. return _tool_call("delegate-writer", "agent", {
  180. "agent_type": "writer",
  181. "task_brief": {
  182. "objective": "Produce one finished candidate",
  183. "reason": "The editor needs an adoptable output",
  184. "completion_criteria": ["No placeholder remains"],
  185. "expected_outputs": ["One candidate revision"],
  186. "validation_scopes": ["output"],
  187. },
  188. })
  189. if reviewed and not _has_tool_result(
  190. messages,
  191. "update_task_progress",
  192. ):
  193. return _tool_call("root-progress", "update_task_progress", {
  194. "expected_revision": 1,
  195. "progress": {
  196. "phase": "ready_to_submit",
  197. "questions": [],
  198. "blockers": [],
  199. "findings": [],
  200. "hypotheses": [],
  201. "work_items": [],
  202. "decision_rationale": "The validated candidate was adopted.",
  203. },
  204. })
  205. return {"content": "Final adopted content", "tool_calls": []}
  206. runtime = ApplicationRuntime(
  207. registry=registry,
  208. trace_store=store,
  209. llm_call=llm_call,
  210. )
  211. runner, config = runtime.new_run(
  212. "application_reference",
  213. "1",
  214. uid="reference-user",
  215. root_task_anchor={
  216. "objective": "Create one complete output",
  217. "completion_criteria": ["Adopt one validated candidate"],
  218. "constraints": ["No placeholders"],
  219. },
  220. )
  221. config.knowledge = KnowledgeConfig(
  222. enable_extraction=False,
  223. enable_completion_extraction=False,
  224. enable_injection=False,
  225. )
  226. with patch.dict("os.environ", {"AGENT_MODE": "recursive"}, clear=False):
  227. result = await runner.run_result(
  228. [{"role": "user", "content": "Create the output"}],
  229. config,
  230. )
  231. self.assertEqual("completed", result["status"])
  232. self.assertTrue(reviewed)
  233. self.assertEqual(1, len(components.candidates.versions))
  234. self.assertEqual(1, components.candidates.adoption_calls)
  235. root = await store.get_trace(result["trace_id"])
  236. ledger = CandidateLedger.model_validate(
  237. await store.get_candidate_ledger(root.trace_id)
  238. )
  239. self.assertEqual("adopted", ledger.current_state(ledger.candidates[0]))
  240. async def test_local_candidate_retry_preserves_fact_and_peer_validation(self):
  241. with tempfile.TemporaryDirectory() as temp_dir:
  242. store = FileSystemTraceStore(temp_dir)
  243. components = build_reference_components()
  244. registry = ApplicationRegistry()
  245. binding = registry.register(
  246. components.application,
  247. components.services,
  248. )
  249. llm_calls = 0
  250. async def llm_call(**kwargs):
  251. nonlocal llm_calls
  252. llm_calls += 1
  253. packet = json.loads(kwargs["messages"][-1]["content"])
  254. scope = packet["validation_scope"]
  255. return {
  256. "content": json.dumps({
  257. "scope": scope,
  258. "outcome": "passed",
  259. "checks": [
  260. {
  261. "check_id": item["check_id"],
  262. "status": "passed",
  263. "evidence_refs": [],
  264. "issue": None,
  265. }
  266. for item in packet["validation_plan"]["checks"]
  267. ],
  268. "reason": "reference candidate is complete",
  269. "retry_from": None,
  270. }),
  271. "tool_calls": [],
  272. }
  273. runtime = ApplicationRuntime(
  274. registry=registry,
  275. trace_store=store,
  276. llm_call=llm_call,
  277. )
  278. runner, config = runtime.new_run(
  279. "application_reference",
  280. "1",
  281. uid="reference-user",
  282. root_task_anchor={
  283. "objective": "Create an evidence-grounded explanation",
  284. "completion_criteria": ["Use verified facts and finished copy"],
  285. "constraints": ["Do not publish placeholder text"],
  286. },
  287. )
  288. with patch.dict("os.environ", {"AGENT_MODE": "recursive"}, clear=False):
  289. root, _goal, _sequence = await runner._prepare_new_trace(
  290. [{"role": "user", "content": "write the explanation"}],
  291. config,
  292. )
  293. await store.add_message(Message.create(
  294. trace_id=root.trace_id,
  295. role="user",
  296. sequence=1,
  297. content="write the explanation",
  298. ))
  299. await store.add_message(Message.create(
  300. trace_id=root.trace_id,
  301. role="assistant",
  302. sequence=2,
  303. parent_sequence=1,
  304. content="drafting",
  305. ))
  306. await store.update_trace(root.trace_id, head_sequence=2)
  307. async def execute_tool(trace_id, tool_name, arguments, sequence):
  308. result = await binding.tool_registry.execute(
  309. tool_name,
  310. arguments,
  311. uid=root.uid,
  312. context={
  313. "store": store,
  314. "trace_id": trace_id,
  315. "sequence": sequence,
  316. "task_protocol_service": runner.task_protocol_service,
  317. "candidate_service": runner.candidate_service,
  318. "event_service": runner.event_service,
  319. },
  320. allowed_tool_names={tool_name},
  321. )
  322. if isinstance(result, str):
  323. return json.loads(result)
  324. text = result.get("text") if isinstance(result, dict) else None
  325. return json.loads(text) if isinstance(text, str) else result
  326. async def create_child(trace_id, role_id, brief):
  327. context = deepcopy(root.context)
  328. context.pop("run_config_snapshot", None)
  329. context["application_role_id"] = role_id
  330. context["application_role_hash"] = binding.role(role_id).role_hash
  331. context["agent_depth"] = 1
  332. state = new_task_protocol(brief)
  333. initialize_task_progress(state, effective_at_sequence=1)
  334. context["task_protocol"] = state
  335. child_config = RunConfig(
  336. trace_id=trace_id,
  337. uid=root.uid,
  338. parent_trace_id=root.trace_id,
  339. context=context,
  340. enable_research_flow=False,
  341. )
  342. binding.configure_run_config(child_config, role_id)
  343. context["effective_run_limits"] = dict(
  344. child_config.effective_run_limits
  345. )
  346. child_config.context = context
  347. persist_run_config_snapshot(
  348. context,
  349. RunConfigSnapshotV2.from_run_config(
  350. child_config,
  351. memory_identity=None,
  352. ),
  353. )
  354. child = Trace(
  355. trace_id=trace_id,
  356. mode="agent",
  357. agent_type=role_id,
  358. uid=root.uid,
  359. model=binding.role(role_id).role.model,
  360. parent_trace_id=root.trace_id,
  361. context=context,
  362. )
  363. await store.create_trace(child)
  364. return child
  365. fact_child = await create_child(
  366. "reference-fact",
  367. "fact_checker",
  368. TaskBrief(
  369. objective="Verify what an agent framework supplies",
  370. reason="The content needs one traceable fact",
  371. completion_criteria=["Return one sourced finding"],
  372. expected_outputs=["fact artifact"],
  373. validation_scopes=["evidence"],
  374. ),
  375. )
  376. raw_fact_result = await binding.tool_registry.execute(
  377. "verify_fact",
  378. {"question": "What does an agent framework supply?"},
  379. uid=root.uid,
  380. context={
  381. "trace_id": fact_child.trace_id,
  382. "store": store,
  383. },
  384. allowed_tool_names={"verify_fact"},
  385. )
  386. fact_ref = ArtifactRef.model_validate(
  387. json.loads(raw_fact_result)["artifact_ref"]
  388. )
  389. await runner.task_protocol_service.update_progress(
  390. fact_child.trace_id,
  391. expected_revision=1,
  392. progress=TaskProgress(
  393. phase="ready_to_submit",
  394. questions=[Question(
  395. item_id="fact-question",
  396. text="What does the framework supply?",
  397. state="answered",
  398. answer="Reusable execution contracts",
  399. artifact_refs=[fact_ref],
  400. )],
  401. findings=[Finding(
  402. item_id="fact-finding",
  403. statement="The framework supplies reusable execution contracts.",
  404. basis="reference fixture",
  405. artifact_refs=[fact_ref],
  406. )],
  407. hypotheses=[Hypothesis(
  408. item_id="fact-hypothesis",
  409. statement="The explanation can distinguish framework from business code.",
  410. state="supported",
  411. rationale="The verified definition supports the distinction.",
  412. artifact_refs=[fact_ref],
  413. )],
  414. work_items=[WorkItem(
  415. item_id="fact-work",
  416. description="verify the definition",
  417. state="done",
  418. result_summary="one fact artifact recorded",
  419. artifact_refs=[fact_ref],
  420. )],
  421. decision_rationale="The question is answered by one stable fixture.",
  422. ),
  423. effective_at_sequence=2,
  424. )
  425. writer = await create_child(
  426. "reference-writer",
  427. "writer",
  428. TaskBrief(
  429. objective="Produce two candidate explanations",
  430. reason="The editor needs alternatives",
  431. completion_criteria=["Both candidates use the verified fact"],
  432. expected_outputs=["candidate A", "candidate B"],
  433. validation_scopes=["output"],
  434. ),
  435. )
  436. candidate_a = CandidateRef.model_validate((await execute_tool(
  437. writer.trace_id,
  438. "manage_candidate",
  439. {"request": {
  440. "operation": "create",
  441. "content": {
  442. "text": "A finished explanation of reusable contracts."
  443. },
  444. "parent_refs": [],
  445. }},
  446. 4,
  447. ))["candidate_ref"])
  448. candidate_b = CandidateRef.model_validate((await execute_tool(
  449. writer.trace_id,
  450. "manage_candidate",
  451. {"request": {
  452. "operation": "create",
  453. "content": {"text": "B says {{placeholder}}."},
  454. "parent_refs": [],
  455. }},
  456. 5,
  457. ))["candidate_ref"])
  458. await runner.task_protocol_service.update_progress(
  459. writer.trace_id,
  460. expected_revision=1,
  461. progress=TaskProgress(
  462. phase="ready_to_submit",
  463. findings=[Finding(
  464. item_id="writer-finding",
  465. statement="Both candidates derive from the verified definition.",
  466. basis="fact artifact",
  467. artifact_refs=[fact_ref],
  468. )],
  469. work_items=[WorkItem(
  470. item_id="writer-work",
  471. description="draft two alternatives",
  472. state="done",
  473. result_summary="A and B registered",
  474. )],
  475. decision_rationale="Both candidate revisions are ready for validation.",
  476. ),
  477. effective_at_sequence=6,
  478. )
  479. validation_a = await runner.validate_recursive_trace(
  480. writer.trace_id,
  481. candidate_ref=candidate_a,
  482. )
  483. validation_b = await runner.validate_recursive_trace(
  484. writer.trace_id,
  485. candidate_ref=candidate_b,
  486. )
  487. self.assertEqual("passed", validation_a.result.outcome)
  488. self.assertEqual("failed", validation_b.result.outcome)
  489. self.assertEqual(1, components.facts.calls)
  490. self.assertEqual(
  491. 1,
  492. binding.tool_registry.get_stats("verify_fact")[
  493. "verify_fact"
  494. ]["call_count"],
  495. )
  496. self.assertEqual(1, llm_calls)
  497. frozen_a = validation_a.result.model_dump(mode="json")
  498. await runner._mark_trace_stopped(root.trace_id, root.head_sequence)
  499. await runner._mark_trace_stopped(writer.trace_id, writer.head_sequence)
  500. restarted_components = build_reference_components(
  501. artifacts=components.artifacts,
  502. candidates=components.candidates,
  503. projector=components.projector,
  504. )
  505. restarted_registry = ApplicationRegistry()
  506. restarted_registry.register(
  507. restarted_components.application,
  508. restarted_components.services,
  509. )
  510. restarted_runtime = ApplicationRuntime(
  511. registry=restarted_registry,
  512. trace_store=store,
  513. llm_call=llm_call,
  514. )
  515. restored_runner, restored_config = await restarted_runtime.restore(
  516. writer.trace_id
  517. )
  518. self.assertEqual("writer", restored_config.role_id)
  519. self.assertEqual(0, restarted_components.context.list_calls)
  520. self.assertEqual(0, restarted_components.context.resolve_calls)
  521. await restored_runner.candidate_service.apply_review_actions(
  522. root.trace_id,
  523. writer.trace_id,
  524. report_refs=[candidate_a, candidate_b],
  525. actions=[CandidateReviewAction(
  526. action="revise",
  527. candidate_ref=candidate_b,
  528. reason="remove the placeholder without repeating fact research",
  529. )],
  530. effective_at_sequence=7,
  531. )
  532. runner = restored_runner
  533. binding = restarted_registry.resolve("application_reference", "1")
  534. candidate_b2 = CandidateRef.model_validate((await execute_tool(
  535. writer.trace_id,
  536. "manage_candidate",
  537. {"request": {
  538. "operation": "fork",
  539. "content": {
  540. "text": (
  541. "B2 is a finished explanation of reusable contracts."
  542. )
  543. },
  544. "parent_refs": [CandidatePointer(
  545. candidate_id=candidate_b.candidate_id,
  546. revision=candidate_b.revision,
  547. ).model_dump(mode="json")],
  548. }},
  549. 8,
  550. ))["candidate_ref"])
  551. validation_b2 = await restored_runner.validate_recursive_trace(
  552. writer.trace_id,
  553. candidate_ref=candidate_b2,
  554. )
  555. self.assertEqual("passed", validation_b2.result.outcome)
  556. self.assertEqual(2, llm_calls)
  557. self.assertEqual(
  558. 1,
  559. components.facts.calls + restarted_components.facts.calls,
  560. )
  561. self.assertEqual(
  562. frozen_a,
  563. (await restored_runner.validate_recursive_trace(
  564. writer.trace_id,
  565. candidate_ref=candidate_a,
  566. )).result.model_dump(mode="json"),
  567. )
  568. self.assertEqual(2, llm_calls)
  569. messages = await store.get_trace_messages(root.trace_id)
  570. cutoff = min(item.sequence for item in messages)
  571. await restored_runner._rewind(root.trace_id, cutoff, None)
  572. await restored_runner.candidate_service.apply_review_actions(
  573. root.trace_id,
  574. writer.trace_id,
  575. report_refs=[candidate_a, candidate_b2],
  576. actions=[CandidateReviewAction(
  577. action="adopt",
  578. candidate_ref=candidate_b2,
  579. reason="publish the corrected exact revision",
  580. )],
  581. effective_at_sequence=20,
  582. )
  583. self.assertEqual(1, components.candidates.adoption_calls)
  584. self.assertEqual(1, len(components.candidates.adoptions))
  585. await restored_runner.event_service.try_pump(root.trace_id)
  586. before_replay = dict(components.projector.rows)
  587. await restarted_runtime.reconcile_events(root.trace_id)
  588. await restarted_runtime.reconcile_events(root.trace_id)
  589. self.assertEqual(before_replay, components.projector.rows)
  590. adopted_keys = [
  591. key for key in components.projector.rows
  592. if key.endswith(":adopted")
  593. ]
  594. self.assertEqual(1, len(adopted_keys))
  595. with self.assertRaisesRegex(ValueError, "committed candidate adoption"):
  596. await restored_runner._rewind(root.trace_id, cutoff, None)
  597. ledger = CandidateLedger.model_validate(
  598. await store.get_candidate_ledger(root.trace_id)
  599. )
  600. self.assertEqual(3, len(ledger.candidates))
  601. self.assertEqual(3, len(ledger.validations))
  602. self.assertEqual(
  603. [(candidate_b.candidate_id, candidate_b.revision)],
  604. [
  605. (item.candidate_id, item.revision)
  606. for item in candidate_b2.parent_refs
  607. ],
  608. )