test_candidate_service.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  1. import asyncio
  2. import json
  3. import tempfile
  4. import unittest
  5. from unittest.mock import patch
  6. from pydantic import ValidationError
  7. from cyber_agent.application import (
  8. AgentApplication,
  9. AgentRole,
  10. ApplicationRegistry,
  11. ApplicationServices,
  12. ProviderRef,
  13. )
  14. from cyber_agent.application.candidate import (
  15. CandidateAdoptionReceipt,
  16. CandidateLedger,
  17. CandidatePointer,
  18. CandidateReviewAction,
  19. )
  20. from cyber_agent.application.quality import CandidateValidationRecord
  21. from cyber_agent.application.candidate_service import (
  22. CandidateService,
  23. CandidateStateError,
  24. )
  25. from cyber_agent.core.artifacts import (
  26. ArtifactRef,
  27. ValidationMaterial,
  28. material_content_hash,
  29. )
  30. from cyber_agent.core.task_protocol import TaskBrief, new_task_protocol
  31. from cyber_agent.core.validation import (
  32. ScopeValidationResult,
  33. ValidationCheck,
  34. ValidationResult,
  35. )
  36. from cyber_agent.tools.registry import ToolRegistry
  37. from cyber_agent.trace.models import Trace
  38. from cyber_agent.trace.store import (
  39. FileSystemTraceStore,
  40. TraceStoreCorruptionError,
  41. )
  42. class MemoryCandidateRepository:
  43. def __init__(self):
  44. self.contents = {}
  45. self.put_calls = 0
  46. self.merge_calls = 0
  47. self.bad_artifact = False
  48. self.adoptions = {}
  49. self.adoption_calls = 0
  50. async def put_version(self, request):
  51. self.put_calls += 1
  52. return self._save(request)
  53. async def merge(self, request):
  54. self.merge_calls += 1
  55. return self._save(request)
  56. def _save(self, request):
  57. key = request.operation_id
  58. self.contents.setdefault(key, request)
  59. persisted = self.contents[key]
  60. digest = material_content_hash(persisted.content)
  61. if self.bad_artifact:
  62. digest = "0" * 64
  63. return ArtifactRef(
  64. artifact_id=(
  65. f"candidate:{persisted.candidate_id}:{persisted.revision}"
  66. ),
  67. version=str(persisted.revision),
  68. content_hash=digest,
  69. kind="candidate.output",
  70. mime_type="application/json",
  71. )
  72. async def resolve(self, ref, root_trace_id, uid):
  73. request = next(
  74. item
  75. for item in self.contents.values()
  76. if (
  77. ref.artifact_id
  78. == f"candidate:{item.candidate_id}:{item.revision}"
  79. )
  80. )
  81. return ValidationMaterial(
  82. **ref.model_dump(),
  83. root_trace_id=root_trace_id,
  84. uid=uid,
  85. content=request.content,
  86. )
  87. async def load_version(self, candidate_ref):
  88. return next(
  89. item.content
  90. for item in self.contents.values()
  91. if item.candidate_id == candidate_ref.candidate_id
  92. and item.revision == candidate_ref.revision
  93. )
  94. async def commit_adoption(self, request):
  95. self.adoption_calls += 1
  96. self.adoptions.setdefault(request.operation_id, request.candidate_ref)
  97. return CandidateAdoptionReceipt(
  98. operation_id=request.operation_id,
  99. external_id=f"published:{request.candidate_ref.candidate_id}",
  100. )
  101. def application():
  102. return AgentApplication(
  103. application_id="candidate.test",
  104. application_version="1",
  105. root_role="writer",
  106. roles=(AgentRole(
  107. role_id="writer",
  108. model="fake",
  109. system_prompt="write",
  110. ),),
  111. artifact_resolver_ref=ProviderRef(
  112. provider_id="artifacts",
  113. provider_version="1",
  114. ),
  115. candidate_repository_ref=ProviderRef(
  116. provider_id="candidates",
  117. provider_version="1",
  118. ),
  119. )
  120. class CandidateServiceTest(unittest.IsolatedAsyncioTestCase):
  121. async def asyncSetUp(self):
  122. self.temp = tempfile.TemporaryDirectory()
  123. self.store = FileSystemTraceStore(self.temp.name)
  124. self.repository = MemoryCandidateRepository()
  125. self.binding = ApplicationRegistry().register(
  126. application(),
  127. ApplicationServices(
  128. tool_registry=ToolRegistry(),
  129. artifact_resolver=self.repository,
  130. candidate_repository=self.repository,
  131. ),
  132. )
  133. self.service = CandidateService(
  134. store=self.store,
  135. application_binding=self.binding,
  136. repository=self.repository,
  137. artifact_resolver=self.repository,
  138. )
  139. await self._create_root("root-a")
  140. async def asyncTearDown(self):
  141. self.temp.cleanup()
  142. def _context(self, root_trace_id, *, task_brief=None):
  143. return {
  144. "agent_mode": "recursive",
  145. "agent_mode_revision": 3,
  146. "root_trace_id": root_trace_id,
  147. "root_task_anchor_hash": "a" * 64,
  148. "application_ref": self.binding.application_ref.model_dump(mode="json"),
  149. "application_role_id": "writer",
  150. "application_role_hash": self.binding.role("writer").role_hash,
  151. "task_protocol": new_task_protocol(task_brief),
  152. }
  153. async def _create_root(self, trace_id, *, uid="user-1"):
  154. await self.store.create_trace(Trace(
  155. trace_id=trace_id,
  156. mode="agent",
  157. agent_type="writer",
  158. uid=uid,
  159. context=self._context(trace_id),
  160. ))
  161. async def _create_child(self, trace_id, root_trace_id="root-a"):
  162. await self.store.create_trace(Trace(
  163. trace_id=trace_id,
  164. mode="agent",
  165. agent_type="writer",
  166. uid="user-1",
  167. parent_trace_id=root_trace_id,
  168. context=self._context(
  169. root_trace_id,
  170. task_brief=TaskBrief(
  171. objective="write candidate",
  172. reason="the root needs an option",
  173. completion_criteria=["candidate is complete"],
  174. expected_outputs=["one candidate"],
  175. ),
  176. ),
  177. ))
  178. async def _record_validation(self, trace_id, candidate, outcome="passed"):
  179. plan_hash = material_content_hash({
  180. "candidate": candidate.model_dump(mode="json"),
  181. "outcome": outcome,
  182. })
  183. status = "passed" if outcome == "passed" else "failed"
  184. scope = ScopeValidationResult(
  185. validator_trace_id=f"{trace_id}@validator",
  186. scope="output",
  187. outcome=outcome,
  188. checks=[ValidationCheck(
  189. check_id="quality.rule",
  190. status=status,
  191. issue=None if status == "passed" else "needs revision",
  192. )],
  193. reason="checked",
  194. retry_from=None if outcome == "passed" else "output",
  195. plan_hash=plan_hash,
  196. )
  197. result = ValidationResult(
  198. evaluated_trace_id=trace_id,
  199. outcome=outcome,
  200. scope_results=[scope],
  201. issues=[] if outcome == "passed" else ["needs revision"],
  202. retry_from=None if outcome == "passed" else "output",
  203. plan_hash=plan_hash,
  204. )
  205. await self.service.record_validation(
  206. trace_id,
  207. CandidateValidationRecord(
  208. candidate_ref=candidate,
  209. plan_hash=plan_hash,
  210. validation_result=result.model_dump(mode="json"),
  211. validated_at_sequence=candidate.created_at_sequence,
  212. ),
  213. )
  214. async def test_create_fork_merge_and_idempotent_recovery(self):
  215. first, duplicate = await asyncio.gather(
  216. self.service.manage(
  217. "root-a",
  218. operation="create",
  219. content={"text": "A"},
  220. parent_refs=[],
  221. effective_at_sequence=1,
  222. ),
  223. self.service.manage(
  224. "root-a",
  225. operation="create",
  226. content={"text": "A"},
  227. parent_refs=[],
  228. effective_at_sequence=1,
  229. ),
  230. )
  231. self.assertEqual(first, duplicate)
  232. self.assertEqual(1, self.repository.put_calls)
  233. second = await self.service.manage(
  234. "root-a",
  235. operation="create",
  236. content={"text": "B"},
  237. parent_refs=[],
  238. effective_at_sequence=2,
  239. )
  240. fork = await self.service.manage(
  241. "root-a",
  242. operation="fork",
  243. content={"text": "A2"},
  244. parent_refs=[CandidatePointer(
  245. candidate_id=first.candidate_id,
  246. revision=first.revision,
  247. )],
  248. effective_at_sequence=3,
  249. )
  250. self.assertEqual(first.candidate_id, fork.candidate_id)
  251. self.assertEqual(2, fork.revision)
  252. merged = await self.service.manage(
  253. "root-a",
  254. operation="merge",
  255. content={"text": "AB"},
  256. parent_refs=[
  257. CandidatePointer(
  258. candidate_id=fork.candidate_id,
  259. revision=fork.revision,
  260. ),
  261. CandidatePointer(
  262. candidate_id=second.candidate_id,
  263. revision=second.revision,
  264. ),
  265. ],
  266. effective_at_sequence=4,
  267. )
  268. self.assertNotIn(
  269. merged.candidate_id,
  270. {first.candidate_id, second.candidate_id},
  271. )
  272. self.assertEqual(2, len(merged.parent_refs))
  273. ledger = CandidateLedger.model_validate(
  274. await self.store.get_candidate_ledger("root-a")
  275. )
  276. self.assertEqual(4, len(ledger.candidates))
  277. self.assertTrue(all(
  278. ledger.current_state(item) == "proposed"
  279. for item in ledger.candidates
  280. ))
  281. async def test_cross_root_and_cross_task_parent_access_is_rejected(self):
  282. first = await self.service.manage(
  283. "root-a",
  284. operation="create",
  285. content={"text": "A"},
  286. parent_refs=[],
  287. effective_at_sequence=1,
  288. )
  289. await self._create_root("root-b")
  290. with self.assertRaisesRegex(CandidateStateError, "not found"):
  291. await self.service.manage(
  292. "root-b",
  293. operation="fork",
  294. content={"text": "stolen"},
  295. parent_refs=[CandidatePointer(
  296. candidate_id=first.candidate_id,
  297. revision=first.revision,
  298. )],
  299. effective_at_sequence=2,
  300. )
  301. await self._create_child("child-a")
  302. with self.assertRaisesRegex(CandidateStateError, "another application, root, or Task"):
  303. await self.service.manage(
  304. "child-a",
  305. operation="fork",
  306. content={"text": "stolen"},
  307. parent_refs=[CandidatePointer(
  308. candidate_id=first.candidate_id,
  309. revision=first.revision,
  310. )],
  311. effective_at_sequence=3,
  312. )
  313. async def test_bad_artifact_fails_without_registering_candidate(self):
  314. self.repository.bad_artifact = True
  315. with self.assertRaisesRegex(CandidateStateError, "invalid ArtifactRef"):
  316. await self.service.manage(
  317. "root-a",
  318. operation="create",
  319. content={"text": "bad"},
  320. parent_refs=[],
  321. effective_at_sequence=1,
  322. )
  323. ledger = CandidateLedger.model_validate(
  324. await self.store.get_candidate_ledger("root-a")
  325. )
  326. self.assertEqual(0, len(ledger.candidates))
  327. self.assertEqual("failed", ledger.operations[0].status)
  328. async def test_report_validation_rejects_tamper_and_wrong_owner(self):
  329. candidate = await self.service.manage(
  330. "root-a",
  331. operation="create",
  332. content={"text": "A"},
  333. parent_refs=[],
  334. effective_at_sequence=1,
  335. )
  336. await self.service.validate_report_refs("root-a", [candidate])
  337. tampered = candidate.model_copy(update={
  338. "artifact_ref": candidate.artifact_ref.model_copy(update={
  339. "version": "tampered",
  340. }),
  341. })
  342. with self.assertRaisesRegex(CandidateStateError, "does not match"):
  343. await self.service.validate_report_refs("root-a", [tampered])
  344. await self._create_child("child-a")
  345. with self.assertRaisesRegex(CandidateStateError, "another application, root, or Task"):
  346. await self.service.validate_report_refs("child-a", [candidate])
  347. async def test_ledger_rejects_future_lineage_and_store_reports_corruption(self):
  348. candidate = await self.service.manage(
  349. "root-a",
  350. operation="create",
  351. content={"text": "A"},
  352. parent_refs=[],
  353. effective_at_sequence=1,
  354. )
  355. raw = (await self.store.get_candidate_ledger("root-a"))
  356. future = candidate.model_copy(update={
  357. "revision": 2,
  358. "parent_refs": (CandidatePointer(
  359. candidate_id=candidate.candidate_id,
  360. revision=3,
  361. ),),
  362. })
  363. raw["candidates"].append(future.model_dump(mode="json"))
  364. with self.assertRaisesRegex(ValidationError, "unknown parent"):
  365. CandidateLedger.model_validate(raw)
  366. ledger_path = self.store._get_candidate_ledger_file("root-a")
  367. ledger_path.write_text("{broken", encoding="utf-8")
  368. with self.assertRaisesRegex(TraceStoreCorruptionError, "candidate ledger"):
  369. await self.store.get_candidate_ledger("root-a")
  370. async def test_cyclic_lineage_is_rejected_before_repository_side_effect(self):
  371. first = await self.service.manage(
  372. "root-a",
  373. operation="create",
  374. content={"text": "A"},
  375. parent_refs=[],
  376. effective_at_sequence=1,
  377. )
  378. raw = await self.store.get_candidate_ledger("root-a")
  379. second = first.model_copy(update={
  380. "candidate_id": "candidate-b",
  381. "parent_refs": (CandidatePointer(
  382. candidate_id=first.candidate_id,
  383. revision=first.revision,
  384. ),),
  385. })
  386. raw["candidates"][0]["parent_refs"] = [{
  387. "candidate_id": second.candidate_id,
  388. "revision": second.revision,
  389. }]
  390. raw["candidates"].append(second.model_dump(mode="json"))
  391. ledger_path = self.store._get_candidate_ledger_file("root-a")
  392. ledger_path.write_text(json.dumps(raw), encoding="utf-8")
  393. put_calls = self.repository.put_calls
  394. with self.assertRaisesRegex(ValidationError, "lineage cycle"):
  395. await self.service.manage(
  396. "root-a",
  397. operation="create",
  398. content={"text": "must not be persisted"},
  399. parent_refs=[],
  400. effective_at_sequence=2,
  401. )
  402. self.assertEqual(put_calls, self.repository.put_calls)
  403. async def test_review_adoption_is_idempotent_and_creates_rewind_barrier(self):
  404. await self._create_child("child-a")
  405. candidate = await self.service.manage(
  406. "child-a",
  407. operation="create",
  408. content={"text": "A"},
  409. parent_refs=[],
  410. effective_at_sequence=4,
  411. )
  412. await self._record_validation("child-a", candidate)
  413. action = CandidateReviewAction(
  414. action="adopt",
  415. candidate_ref=candidate,
  416. reason="selected exact revision",
  417. )
  418. first = await self.service.apply_review_actions(
  419. "root-a",
  420. "child-a",
  421. report_refs=[candidate],
  422. actions=[action],
  423. effective_at_sequence=9,
  424. )
  425. second = await self.service.apply_review_actions(
  426. "root-a",
  427. "child-a",
  428. report_refs=[candidate],
  429. actions=[action],
  430. effective_at_sequence=9,
  431. )
  432. self.assertEqual(first, second)
  433. self.assertEqual(1, self.repository.adoption_calls)
  434. self.assertEqual(1, len(self.repository.adoptions))
  435. with self.assertRaisesRegex(CandidateStateError, "committed"):
  436. await self.service.assert_rewind_allowed("root-a", 8)
  437. await self.service.assert_rewind_allowed("root-a", 9)
  438. with self.assertRaisesRegex(CandidateStateError, "terminal"):
  439. await self.service.apply_review_actions(
  440. "root-a",
  441. "child-a",
  442. report_refs=[candidate],
  443. actions=[CandidateReviewAction(
  444. action="discard",
  445. candidate_ref=candidate,
  446. reason="too late",
  447. )],
  448. effective_at_sequence=10,
  449. )
  450. async def test_descendant_adoption_blocks_ancestor_rewind_without_mapping(self):
  451. await self._create_child("child-a")
  452. await self.store.create_trace(Trace(
  453. trace_id="grandchild-a",
  454. mode="agent",
  455. agent_type="writer",
  456. uid="user-1",
  457. parent_trace_id="child-a",
  458. context=self._context(
  459. "root-a",
  460. task_brief=TaskBrief(
  461. objective="write descendant candidate",
  462. reason="the child needs an option",
  463. completion_criteria=["candidate is complete"],
  464. expected_outputs=["one candidate"],
  465. ),
  466. ),
  467. ))
  468. candidate = await self.service.manage(
  469. "grandchild-a",
  470. operation="create",
  471. content={"text": "nested candidate"},
  472. parent_refs=[],
  473. effective_at_sequence=4,
  474. )
  475. await self._record_validation("grandchild-a", candidate)
  476. await self.service.apply_review_actions(
  477. "child-a",
  478. "grandchild-a",
  479. report_refs=[candidate],
  480. actions=[CandidateReviewAction(
  481. action="adopt",
  482. candidate_ref=candidate,
  483. reason="commit descendant output",
  484. )],
  485. effective_at_sequence=9,
  486. )
  487. with self.assertRaisesRegex(CandidateStateError, "committed"):
  488. await self.service.assert_rewind_allowed("root-a", 100)
  489. async def test_failed_candidate_can_only_be_revised_or_discarded(self):
  490. await self._create_child("child-a")
  491. candidate = await self.service.manage(
  492. "child-a",
  493. operation="create",
  494. content={"text": "placeholder"},
  495. parent_refs=[],
  496. effective_at_sequence=4,
  497. )
  498. await self._record_validation("child-a", candidate, outcome="failed")
  499. with self.assertRaisesRegex(CandidateStateError, "passed"):
  500. await self.service.apply_review_actions(
  501. "root-a",
  502. "child-a",
  503. report_refs=[candidate],
  504. actions=[CandidateReviewAction(
  505. action="adopt",
  506. candidate_ref=candidate,
  507. reason="invalid",
  508. )],
  509. effective_at_sequence=9,
  510. )
  511. records = await self.service.apply_review_actions(
  512. "root-a",
  513. "child-a",
  514. report_refs=[candidate],
  515. actions=[CandidateReviewAction(
  516. action="revise",
  517. candidate_ref=candidate,
  518. reason="remove placeholder",
  519. )],
  520. effective_at_sequence=10,
  521. )
  522. self.assertEqual("discarded", records[0].state)
  523. self.assertIn("retry_from=output", records[0].reason)
  524. async def test_adoption_replays_same_operation_after_finalize_crash(self):
  525. await self._create_child("child-a")
  526. candidate = await self.service.manage(
  527. "child-a",
  528. operation="create",
  529. content={"text": "A"},
  530. parent_refs=[],
  531. effective_at_sequence=4,
  532. )
  533. await self._record_validation("child-a", candidate)
  534. action = CandidateReviewAction(
  535. action="adopt",
  536. candidate_ref=candidate,
  537. reason="publish once",
  538. )
  539. original_replace = self.store.replace_candidate_ledger
  540. calls = 0
  541. async def fail_finalize(root_trace_id, ledger):
  542. nonlocal calls
  543. calls += 1
  544. if calls == 2:
  545. raise OSError("crash before framework finalize")
  546. await original_replace(root_trace_id, ledger)
  547. with patch.object(
  548. self.store,
  549. "replace_candidate_ledger",
  550. side_effect=fail_finalize,
  551. ):
  552. with self.assertRaisesRegex(OSError, "finalize"):
  553. await self.service.apply_review_actions(
  554. "root-a",
  555. "child-a",
  556. report_refs=[candidate],
  557. actions=[action],
  558. effective_at_sequence=9,
  559. )
  560. pending = CandidateLedger.model_validate(
  561. await self.store.get_candidate_ledger("root-a")
  562. )
  563. self.assertEqual("adoption_pending", pending.current_state(candidate))
  564. await self.service.apply_review_actions(
  565. "root-a",
  566. "child-a",
  567. report_refs=[candidate],
  568. actions=[action],
  569. effective_at_sequence=9,
  570. )
  571. self.assertEqual(2, self.repository.adoption_calls)
  572. self.assertEqual(1, len(self.repository.adoptions))
  573. completed = CandidateLedger.model_validate(
  574. await self.store.get_candidate_ledger("root-a")
  575. )
  576. self.assertEqual("adopted", completed.current_state(candidate))
  577. if __name__ == "__main__":
  578. unittest.main()