test_application_reference.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660
  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. self.assertIn(
  241. "candidate-create",
  242. {item.command_id for item in ledger.operations},
  243. )
  244. self.assertIn(
  245. "adopt-review",
  246. {item.command_id for item in ledger.operations},
  247. )
  248. async def test_local_candidate_retry_preserves_fact_and_peer_validation(self):
  249. with tempfile.TemporaryDirectory() as temp_dir:
  250. store = FileSystemTraceStore(temp_dir)
  251. components = build_reference_components()
  252. registry = ApplicationRegistry()
  253. binding = registry.register(
  254. components.application,
  255. components.services,
  256. )
  257. llm_calls = 0
  258. async def llm_call(**kwargs):
  259. nonlocal llm_calls
  260. llm_calls += 1
  261. packet = json.loads(kwargs["messages"][-1]["content"])
  262. scope = packet["validation_scope"]
  263. return {
  264. "content": json.dumps({
  265. "scope": scope,
  266. "outcome": "passed",
  267. "checks": [
  268. {
  269. "check_id": item["check_id"],
  270. "status": "passed",
  271. "evidence_refs": [],
  272. "issue": None,
  273. }
  274. for item in packet["validation_plan"]["checks"]
  275. ],
  276. "reason": "reference candidate is complete",
  277. "retry_from": None,
  278. }),
  279. "tool_calls": [],
  280. }
  281. runtime = ApplicationRuntime(
  282. registry=registry,
  283. trace_store=store,
  284. llm_call=llm_call,
  285. )
  286. runner, config = runtime.new_run(
  287. "application_reference",
  288. "1",
  289. uid="reference-user",
  290. root_task_anchor={
  291. "objective": "Create an evidence-grounded explanation",
  292. "completion_criteria": ["Use verified facts and finished copy"],
  293. "constraints": ["Do not publish placeholder text"],
  294. },
  295. )
  296. with patch.dict("os.environ", {"AGENT_MODE": "recursive"}, clear=False):
  297. root, _goal, _sequence = await runner._prepare_new_trace(
  298. [{"role": "user", "content": "write the explanation"}],
  299. config,
  300. )
  301. await store.add_message(Message.create(
  302. trace_id=root.trace_id,
  303. role="user",
  304. sequence=1,
  305. content="write the explanation",
  306. ))
  307. await store.add_message(Message.create(
  308. trace_id=root.trace_id,
  309. role="assistant",
  310. sequence=2,
  311. parent_sequence=1,
  312. content="drafting",
  313. ))
  314. await store.update_trace(root.trace_id, head_sequence=2)
  315. async def execute_tool(trace_id, tool_name, arguments, sequence):
  316. tool_call_id = f"reference-{tool_name}-{sequence}"
  317. result = await binding.tool_registry.execute(
  318. tool_name,
  319. arguments,
  320. uid=root.uid,
  321. context={
  322. "store": store,
  323. "trace_id": trace_id,
  324. "sequence": sequence,
  325. "tool_call_id": tool_call_id,
  326. "task_protocol_service": runner.task_protocol_service,
  327. "candidate_service": runner.candidate_service,
  328. "event_service": runner.event_service,
  329. },
  330. allowed_tool_names={tool_name},
  331. )
  332. if tool_name == "manage_candidate":
  333. trace = await store.get_trace(trace_id)
  334. await store.add_message(Message.create(
  335. trace_id=trace_id,
  336. role="tool",
  337. sequence=sequence,
  338. parent_sequence=(trace.head_sequence or None),
  339. tool_call_id=tool_call_id,
  340. content={"tool_name": tool_name, "result": "registered"},
  341. ))
  342. await store.update_trace(trace_id, head_sequence=sequence)
  343. if isinstance(result, str):
  344. return json.loads(result)
  345. text = result.get("text") if isinstance(result, dict) else None
  346. return json.loads(text) if isinstance(text, str) else result
  347. async def create_child(trace_id, role_id, brief):
  348. context = deepcopy(root.context)
  349. context.pop("run_config_snapshot", None)
  350. context["application_role_id"] = role_id
  351. context["application_role_hash"] = binding.role(role_id).role_hash
  352. context["agent_depth"] = 1
  353. state = new_task_protocol(brief)
  354. initialize_task_progress(state, effective_at_sequence=1)
  355. context["task_protocol"] = state
  356. child_config = RunConfig(
  357. trace_id=trace_id,
  358. uid=root.uid,
  359. parent_trace_id=root.trace_id,
  360. context=context,
  361. enable_research_flow=False,
  362. )
  363. binding.configure_run_config(child_config, role_id)
  364. context["effective_run_limits"] = dict(
  365. child_config.effective_run_limits
  366. )
  367. child_config.context = context
  368. persist_run_config_snapshot(
  369. context,
  370. RunConfigSnapshotV2.from_run_config(
  371. child_config,
  372. memory_identity=None,
  373. ),
  374. )
  375. child = Trace(
  376. trace_id=trace_id,
  377. mode="agent",
  378. agent_type=role_id,
  379. uid=root.uid,
  380. model=binding.role(role_id).role.model,
  381. parent_trace_id=root.trace_id,
  382. context=context,
  383. )
  384. await store.create_trace(child)
  385. return child
  386. fact_child = await create_child(
  387. "reference-fact",
  388. "fact_checker",
  389. TaskBrief(
  390. objective="Verify what an agent framework supplies",
  391. reason="The content needs one traceable fact",
  392. completion_criteria=["Return one sourced finding"],
  393. expected_outputs=["fact artifact"],
  394. validation_scopes=["evidence"],
  395. ),
  396. )
  397. raw_fact_result = await binding.tool_registry.execute(
  398. "verify_fact",
  399. {"question": "What does an agent framework supply?"},
  400. uid=root.uid,
  401. context={
  402. "trace_id": fact_child.trace_id,
  403. "store": store,
  404. },
  405. allowed_tool_names={"verify_fact"},
  406. )
  407. fact_ref = ArtifactRef.model_validate(
  408. json.loads(raw_fact_result)["artifact_ref"]
  409. )
  410. await runner.task_protocol_service.update_progress(
  411. fact_child.trace_id,
  412. expected_revision=1,
  413. progress=TaskProgress(
  414. phase="ready_to_submit",
  415. questions=[Question(
  416. item_id="fact-question",
  417. text="What does the framework supply?",
  418. state="answered",
  419. answer="Reusable execution contracts",
  420. artifact_refs=[fact_ref],
  421. )],
  422. findings=[Finding(
  423. item_id="fact-finding",
  424. statement="The framework supplies reusable execution contracts.",
  425. basis="reference fixture",
  426. artifact_refs=[fact_ref],
  427. )],
  428. hypotheses=[Hypothesis(
  429. item_id="fact-hypothesis",
  430. statement="The explanation can distinguish framework from business code.",
  431. state="supported",
  432. rationale="The verified definition supports the distinction.",
  433. artifact_refs=[fact_ref],
  434. )],
  435. work_items=[WorkItem(
  436. item_id="fact-work",
  437. description="verify the definition",
  438. state="done",
  439. result_summary="one fact artifact recorded",
  440. artifact_refs=[fact_ref],
  441. )],
  442. decision_rationale="The question is answered by one stable fixture.",
  443. ),
  444. effective_at_sequence=2,
  445. )
  446. writer = await create_child(
  447. "reference-writer",
  448. "writer",
  449. TaskBrief(
  450. objective="Produce two candidate explanations",
  451. reason="The editor needs alternatives",
  452. completion_criteria=["Both candidates use the verified fact"],
  453. expected_outputs=["candidate A", "candidate B"],
  454. validation_scopes=["output"],
  455. ),
  456. )
  457. candidate_a = CandidateRef.model_validate((await execute_tool(
  458. writer.trace_id,
  459. "manage_candidate",
  460. {"request": {
  461. "operation": "create",
  462. "content": {
  463. "text": "A finished explanation of reusable contracts."
  464. },
  465. "parent_refs": [],
  466. }},
  467. 4,
  468. ))["candidate_ref"])
  469. candidate_b = CandidateRef.model_validate((await execute_tool(
  470. writer.trace_id,
  471. "manage_candidate",
  472. {"request": {
  473. "operation": "create",
  474. "content": {"text": "B says {{placeholder}}."},
  475. "parent_refs": [],
  476. }},
  477. 5,
  478. ))["candidate_ref"])
  479. await runner.task_protocol_service.update_progress(
  480. writer.trace_id,
  481. expected_revision=1,
  482. progress=TaskProgress(
  483. phase="ready_to_submit",
  484. findings=[Finding(
  485. item_id="writer-finding",
  486. statement="Both candidates derive from the verified definition.",
  487. basis="fact artifact",
  488. artifact_refs=[fact_ref],
  489. )],
  490. work_items=[WorkItem(
  491. item_id="writer-work",
  492. description="draft two alternatives",
  493. state="done",
  494. result_summary="A and B registered",
  495. )],
  496. decision_rationale="Both candidate revisions are ready for validation.",
  497. ),
  498. effective_at_sequence=6,
  499. )
  500. validation_a = await runner.validate_recursive_trace(
  501. writer.trace_id,
  502. candidate_ref=candidate_a,
  503. )
  504. validation_b = await runner.validate_recursive_trace(
  505. writer.trace_id,
  506. candidate_ref=candidate_b,
  507. )
  508. self.assertEqual("passed", validation_a.result.outcome)
  509. self.assertEqual("failed", validation_b.result.outcome)
  510. self.assertEqual(1, components.facts.calls)
  511. self.assertEqual(
  512. 1,
  513. binding.tool_registry.get_stats("verify_fact")[
  514. "verify_fact"
  515. ]["call_count"],
  516. )
  517. self.assertEqual(1, llm_calls)
  518. frozen_a = validation_a.result.model_dump(mode="json")
  519. fresh_root = await store.get_trace(root.trace_id)
  520. fresh_writer = await store.get_trace(writer.trace_id)
  521. await runner._mark_trace_stopped(
  522. root.trace_id,
  523. fresh_root.head_sequence,
  524. )
  525. await runner._mark_trace_stopped(
  526. writer.trace_id,
  527. fresh_writer.head_sequence,
  528. )
  529. restarted_components = build_reference_components(
  530. artifacts=components.artifacts,
  531. candidates=components.candidates,
  532. projector=components.projector,
  533. )
  534. restarted_registry = ApplicationRegistry()
  535. restarted_registry.register(
  536. restarted_components.application,
  537. restarted_components.services,
  538. )
  539. restarted_runtime = ApplicationRuntime(
  540. registry=restarted_registry,
  541. trace_store=store,
  542. llm_call=llm_call,
  543. )
  544. restored_runner, restored_config = await restarted_runtime.restore(
  545. writer.trace_id
  546. )
  547. self.assertEqual("writer", restored_config.role_id)
  548. self.assertEqual(0, restarted_components.context.list_calls)
  549. self.assertEqual(0, restarted_components.context.resolve_calls)
  550. await restored_runner.candidate_service.apply_review_actions(
  551. root.trace_id,
  552. writer.trace_id,
  553. report_refs=[candidate_a, candidate_b],
  554. actions=[CandidateReviewAction(
  555. action="revise",
  556. candidate_ref=candidate_b,
  557. reason="remove the placeholder without repeating fact research",
  558. )],
  559. effective_at_sequence=7,
  560. )
  561. runner = restored_runner
  562. binding = restarted_registry.resolve("application_reference", "1")
  563. candidate_b2 = CandidateRef.model_validate((await execute_tool(
  564. writer.trace_id,
  565. "manage_candidate",
  566. {"request": {
  567. "operation": "fork",
  568. "content": {
  569. "text": (
  570. "B2 is a finished explanation of reusable contracts."
  571. )
  572. },
  573. "parent_refs": [CandidatePointer(
  574. candidate_id=candidate_b.candidate_id,
  575. revision=candidate_b.revision,
  576. ).model_dump(mode="json")],
  577. }},
  578. 8,
  579. ))["candidate_ref"])
  580. validation_b2 = await restored_runner.validate_recursive_trace(
  581. writer.trace_id,
  582. candidate_ref=candidate_b2,
  583. )
  584. self.assertEqual("passed", validation_b2.result.outcome)
  585. self.assertEqual(2, llm_calls)
  586. self.assertEqual(
  587. 1,
  588. components.facts.calls + restarted_components.facts.calls,
  589. )
  590. self.assertEqual(
  591. frozen_a,
  592. (await restored_runner.validate_recursive_trace(
  593. writer.trace_id,
  594. candidate_ref=candidate_a,
  595. )).result.model_dump(mode="json"),
  596. )
  597. self.assertEqual(2, llm_calls)
  598. messages = await store.get_trace_messages(root.trace_id)
  599. cutoff = min(item.sequence for item in messages)
  600. await restored_runner._rewind(root.trace_id, cutoff, None)
  601. await restored_runner.candidate_service.apply_review_actions(
  602. root.trace_id,
  603. writer.trace_id,
  604. report_refs=[candidate_a, candidate_b2],
  605. actions=[CandidateReviewAction(
  606. action="adopt",
  607. candidate_ref=candidate_b2,
  608. reason="publish the corrected exact revision",
  609. )],
  610. effective_at_sequence=20,
  611. )
  612. self.assertEqual(1, components.candidates.adoption_calls)
  613. self.assertEqual(1, len(components.candidates.adoptions))
  614. await restored_runner.event_service.try_pump(root.trace_id)
  615. before_replay = dict(components.projector.rows)
  616. await restarted_runtime.reconcile_events(root.trace_id)
  617. await restarted_runtime.reconcile_events(root.trace_id)
  618. self.assertEqual(before_replay, components.projector.rows)
  619. adopted_keys = [
  620. key for key in components.projector.rows
  621. if key.endswith(":adopted")
  622. ]
  623. self.assertEqual(1, len(adopted_keys))
  624. with self.assertRaisesRegex(ValueError, "committed candidate adoption"):
  625. await restored_runner._rewind(root.trace_id, cutoff, None)
  626. ledger = CandidateLedger.model_validate(
  627. await store.get_candidate_ledger(root.trace_id)
  628. )
  629. self.assertEqual(3, len(ledger.candidates))
  630. self.assertEqual(3, len(ledger.validations))
  631. self.assertEqual(
  632. [(candidate_b.candidate_id, candidate_b.revision)],
  633. [
  634. (item.candidate_id, item.revision)
  635. for item in candidate_b2.parent_refs
  636. ],
  637. )