test_candidate_service.py 16 KB

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