test_candidate_service.py 19 KB

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