test_application_reference.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  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 CandidateLedger, CandidatePointer
  12. from cyber_agent.core.task_protocol import (
  13. Finding,
  14. Hypothesis,
  15. Question,
  16. TaskBrief,
  17. TaskProgress,
  18. WorkItem,
  19. initialize_task_progress,
  20. new_task_protocol,
  21. )
  22. from cyber_agent.trace.models import Message, Trace
  23. from cyber_agent.trace.store import FileSystemTraceStore
  24. from examples.application_reference import build_reference_components
  25. class ApplicationReferenceEndToEndTest(unittest.IsolatedAsyncioTestCase):
  26. async def test_local_candidate_retry_preserves_fact_and_peer_validation(self):
  27. with tempfile.TemporaryDirectory() as temp_dir:
  28. store = FileSystemTraceStore(temp_dir)
  29. components = build_reference_components()
  30. registry = ApplicationRegistry()
  31. binding = registry.register(
  32. components.application,
  33. components.services,
  34. )
  35. llm_calls = 0
  36. async def llm_call(**kwargs):
  37. nonlocal llm_calls
  38. llm_calls += 1
  39. packet = json.loads(kwargs["messages"][-1]["content"])
  40. scope = packet["validation_scope"]
  41. return {
  42. "content": json.dumps({
  43. "scope": scope,
  44. "outcome": "passed",
  45. "checks": [
  46. {
  47. "check_id": item["check_id"],
  48. "status": "passed",
  49. "evidence_refs": [],
  50. "issue": None,
  51. }
  52. for item in packet["validation_plan"]["checks"]
  53. ],
  54. "reason": "reference candidate is complete",
  55. "retry_from": None,
  56. }),
  57. "tool_calls": [],
  58. }
  59. runtime = ApplicationRuntime(
  60. registry=registry,
  61. trace_store=store,
  62. llm_call=llm_call,
  63. )
  64. runner, config = runtime.new_run(
  65. "application_reference",
  66. "1",
  67. uid="reference-user",
  68. root_task_anchor={
  69. "objective": "Create an evidence-grounded explanation",
  70. "completion_criteria": ["Use verified facts and finished copy"],
  71. "constraints": ["Do not publish placeholder text"],
  72. },
  73. )
  74. with patch.dict("os.environ", {"AGENT_MODE": "recursive"}, clear=False):
  75. root, _goal, _sequence = await runner._prepare_new_trace(
  76. [{"role": "user", "content": "write the explanation"}],
  77. config,
  78. )
  79. await store.add_message(Message.create(
  80. trace_id=root.trace_id,
  81. role="user",
  82. sequence=1,
  83. content="write the explanation",
  84. ))
  85. await store.add_message(Message.create(
  86. trace_id=root.trace_id,
  87. role="assistant",
  88. sequence=2,
  89. parent_sequence=1,
  90. content="drafting",
  91. ))
  92. await store.update_trace(root.trace_id, head_sequence=2)
  93. async def create_child(trace_id, role_id, brief):
  94. context = deepcopy(root.context)
  95. context.pop("run_config_snapshot", None)
  96. context["application_role_id"] = role_id
  97. context["application_role_hash"] = binding.role(role_id).role_hash
  98. context["agent_depth"] = 1
  99. state = new_task_protocol(brief)
  100. initialize_task_progress(state, effective_at_sequence=1)
  101. context["task_protocol"] = state
  102. child = Trace(
  103. trace_id=trace_id,
  104. mode="agent",
  105. agent_type=role_id,
  106. uid=root.uid,
  107. model=binding.role(role_id).role.model,
  108. parent_trace_id=root.trace_id,
  109. context=context,
  110. )
  111. await store.create_trace(child)
  112. return child
  113. fact_child = await create_child(
  114. "reference-fact",
  115. "fact_checker",
  116. TaskBrief(
  117. objective="Verify what an agent framework supplies",
  118. reason="The content needs one traceable fact",
  119. completion_criteria=["Return one sourced finding"],
  120. expected_outputs=["fact artifact"],
  121. validation_scopes=["evidence"],
  122. ),
  123. )
  124. fact_ref = await components.facts.verify(
  125. question="What does an agent framework supply?",
  126. root_trace_id=root.trace_id,
  127. uid=root.uid,
  128. )
  129. await runner.task_protocol_service.update_progress(
  130. fact_child.trace_id,
  131. expected_revision=1,
  132. progress=TaskProgress(
  133. phase="ready_to_submit",
  134. questions=[Question(
  135. item_id="fact-question",
  136. text="What does the framework supply?",
  137. state="answered",
  138. answer="Reusable execution contracts",
  139. artifact_refs=[fact_ref],
  140. )],
  141. findings=[Finding(
  142. item_id="fact-finding",
  143. statement="The framework supplies reusable execution contracts.",
  144. basis="reference fixture",
  145. artifact_refs=[fact_ref],
  146. )],
  147. hypotheses=[Hypothesis(
  148. item_id="fact-hypothesis",
  149. statement="The explanation can distinguish framework from business code.",
  150. state="supported",
  151. rationale="The verified definition supports the distinction.",
  152. artifact_refs=[fact_ref],
  153. )],
  154. work_items=[WorkItem(
  155. item_id="fact-work",
  156. description="verify the definition",
  157. state="done",
  158. result_summary="one fact artifact recorded",
  159. artifact_refs=[fact_ref],
  160. )],
  161. decision_rationale="The question is answered by one stable fixture.",
  162. ),
  163. effective_at_sequence=2,
  164. )
  165. writer = await create_child(
  166. "reference-writer",
  167. "writer",
  168. TaskBrief(
  169. objective="Produce two candidate explanations",
  170. reason="The editor needs alternatives",
  171. completion_criteria=["Both candidates use the verified fact"],
  172. expected_outputs=["candidate A", "candidate B"],
  173. validation_scopes=["output"],
  174. ),
  175. )
  176. candidate_a = await runner.candidate_service.manage(
  177. writer.trace_id,
  178. operation="create",
  179. content={"text": "A finished explanation of reusable contracts."},
  180. parent_refs=[],
  181. effective_at_sequence=4,
  182. )
  183. candidate_b = await runner.candidate_service.manage(
  184. writer.trace_id,
  185. operation="create",
  186. content={"text": "B says {{placeholder}}."},
  187. parent_refs=[],
  188. effective_at_sequence=5,
  189. )
  190. await runner.task_protocol_service.update_progress(
  191. writer.trace_id,
  192. expected_revision=1,
  193. progress=TaskProgress(
  194. phase="ready_to_submit",
  195. findings=[Finding(
  196. item_id="writer-finding",
  197. statement="Both candidates derive from the verified definition.",
  198. basis="fact artifact",
  199. artifact_refs=[fact_ref],
  200. )],
  201. work_items=[WorkItem(
  202. item_id="writer-work",
  203. description="draft two alternatives",
  204. state="done",
  205. result_summary="A and B registered",
  206. )],
  207. decision_rationale="Both candidate revisions are ready for validation.",
  208. ),
  209. effective_at_sequence=6,
  210. )
  211. validation_a = await runner.validate_recursive_trace(
  212. writer.trace_id,
  213. candidate_ref=candidate_a,
  214. )
  215. validation_b = await runner.validate_recursive_trace(
  216. writer.trace_id,
  217. candidate_ref=candidate_b,
  218. )
  219. self.assertEqual("passed", validation_a.result.outcome)
  220. self.assertEqual("failed", validation_b.result.outcome)
  221. self.assertEqual(1, components.facts.calls)
  222. self.assertEqual(1, llm_calls)
  223. frozen_a = validation_a.result.model_dump(mode="json")
  224. restored_runner, _restored_config = await runtime.restore(root.trace_id)
  225. await restored_runner.candidate_service.apply_review_actions(
  226. root.trace_id,
  227. writer.trace_id,
  228. report_refs=[candidate_a, candidate_b],
  229. actions=[CandidateReviewAction(
  230. action="revise",
  231. candidate_ref=candidate_b,
  232. reason="remove the placeholder without repeating fact research",
  233. )],
  234. effective_at_sequence=7,
  235. )
  236. candidate_b2 = await restored_runner.candidate_service.manage(
  237. writer.trace_id,
  238. operation="fork",
  239. content={"text": "B2 is a finished explanation of reusable contracts."},
  240. parent_refs=[CandidatePointer(
  241. candidate_id=candidate_b.candidate_id,
  242. revision=candidate_b.revision,
  243. )],
  244. effective_at_sequence=8,
  245. )
  246. validation_b2 = await restored_runner.validate_recursive_trace(
  247. writer.trace_id,
  248. candidate_ref=candidate_b2,
  249. )
  250. self.assertEqual("passed", validation_b2.result.outcome)
  251. self.assertEqual(2, llm_calls)
  252. self.assertEqual(1, components.facts.calls)
  253. self.assertEqual(
  254. frozen_a,
  255. (await restored_runner.validate_recursive_trace(
  256. writer.trace_id,
  257. candidate_ref=candidate_a,
  258. )).result.model_dump(mode="json"),
  259. )
  260. self.assertEqual(2, llm_calls)
  261. messages = await store.get_trace_messages(root.trace_id)
  262. cutoff = min(item.sequence for item in messages)
  263. await restored_runner._rewind(root.trace_id, cutoff, None)
  264. await restored_runner.candidate_service.apply_review_actions(
  265. root.trace_id,
  266. writer.trace_id,
  267. report_refs=[candidate_a, candidate_b2],
  268. actions=[CandidateReviewAction(
  269. action="adopt",
  270. candidate_ref=candidate_b2,
  271. reason="publish the corrected exact revision",
  272. )],
  273. effective_at_sequence=20,
  274. )
  275. self.assertEqual(1, components.candidates.adoption_calls)
  276. self.assertEqual(1, len(components.candidates.adoptions))
  277. before_replay = dict(components.projector.rows)
  278. await runtime.reconcile_events(root.trace_id)
  279. await runtime.reconcile_events(root.trace_id)
  280. self.assertEqual(before_replay, components.projector.rows)
  281. adopted_keys = [
  282. key for key in components.projector.rows
  283. if key.endswith(":adopted")
  284. ]
  285. self.assertEqual(1, len(adopted_keys))
  286. with self.assertRaisesRegex(ValueError, "committed candidate adoption"):
  287. await restored_runner._rewind(root.trace_id, cutoff, None)
  288. ledger = CandidateLedger.model_validate(
  289. await store.get_candidate_ledger(root.trace_id)
  290. )
  291. self.assertEqual(3, len(ledger.candidates))
  292. self.assertEqual(3, len(ledger.validations))
  293. self.assertEqual(
  294. [(candidate_b.candidate_id, candidate_b.revision)],
  295. [
  296. (item.candidate_id, item.revision)
  297. for item in candidate_b2.parent_refs
  298. ],
  299. )