test_candidate_service.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  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. CandidateLedger,
  14. CandidatePointer,
  15. )
  16. from cyber_agent.application.candidate_service import (
  17. CandidateService,
  18. CandidateStateError,
  19. )
  20. from cyber_agent.core.artifacts import (
  21. ArtifactRef,
  22. ValidationMaterial,
  23. material_content_hash,
  24. )
  25. from cyber_agent.core.task_protocol import TaskBrief, new_task_protocol
  26. from cyber_agent.tools.registry import ToolRegistry
  27. from cyber_agent.trace.models import Trace
  28. from cyber_agent.trace.store import (
  29. FileSystemTraceStore,
  30. TraceStoreCorruptionError,
  31. )
  32. class MemoryCandidateRepository:
  33. def __init__(self):
  34. self.contents = {}
  35. self.put_calls = 0
  36. self.merge_calls = 0
  37. self.bad_artifact = False
  38. async def put_version(self, request):
  39. self.put_calls += 1
  40. return self._save(request)
  41. async def merge(self, request):
  42. self.merge_calls += 1
  43. return self._save(request)
  44. def _save(self, request):
  45. key = request.operation_id
  46. self.contents.setdefault(key, request)
  47. persisted = self.contents[key]
  48. digest = material_content_hash(persisted.content)
  49. if self.bad_artifact:
  50. digest = "0" * 64
  51. return ArtifactRef(
  52. artifact_id=(
  53. f"candidate:{persisted.candidate_id}:{persisted.revision}"
  54. ),
  55. version=str(persisted.revision),
  56. content_hash=digest,
  57. kind="candidate.output",
  58. mime_type="application/json",
  59. )
  60. async def resolve(self, ref, root_trace_id, uid):
  61. request = next(
  62. item
  63. for item in self.contents.values()
  64. if (
  65. ref.artifact_id
  66. == f"candidate:{item.candidate_id}:{item.revision}"
  67. )
  68. )
  69. return ValidationMaterial(
  70. **ref.model_dump(),
  71. root_trace_id=root_trace_id,
  72. uid=uid,
  73. content=request.content,
  74. )
  75. async def load_version(self, candidate_ref):
  76. return next(
  77. item.content
  78. for item in self.contents.values()
  79. if item.candidate_id == candidate_ref.candidate_id
  80. and item.revision == candidate_ref.revision
  81. )
  82. async def commit_adoption(self, request):
  83. raise NotImplementedError
  84. def application():
  85. return AgentApplication(
  86. application_id="candidate.test",
  87. application_version="1",
  88. root_role="writer",
  89. roles=(AgentRole(
  90. role_id="writer",
  91. model="fake",
  92. system_prompt="write",
  93. ),),
  94. artifact_resolver_ref=ProviderRef(
  95. provider_id="artifacts",
  96. provider_version="1",
  97. ),
  98. candidate_repository_ref=ProviderRef(
  99. provider_id="candidates",
  100. provider_version="1",
  101. ),
  102. )
  103. class CandidateServiceTest(unittest.IsolatedAsyncioTestCase):
  104. async def asyncSetUp(self):
  105. self.temp = tempfile.TemporaryDirectory()
  106. self.store = FileSystemTraceStore(self.temp.name)
  107. self.repository = MemoryCandidateRepository()
  108. self.binding = ApplicationRegistry().register(
  109. application(),
  110. ApplicationServices(
  111. tool_registry=ToolRegistry(),
  112. artifact_resolver=self.repository,
  113. candidate_repository=self.repository,
  114. ),
  115. )
  116. self.service = CandidateService(
  117. store=self.store,
  118. application_binding=self.binding,
  119. repository=self.repository,
  120. artifact_resolver=self.repository,
  121. )
  122. await self._create_root("root-a")
  123. async def asyncTearDown(self):
  124. self.temp.cleanup()
  125. def _context(self, root_trace_id, *, task_brief=None):
  126. return {
  127. "agent_mode": "recursive",
  128. "agent_mode_revision": 3,
  129. "root_trace_id": root_trace_id,
  130. "root_task_anchor_hash": "a" * 64,
  131. "application_ref": self.binding.application_ref.model_dump(mode="json"),
  132. "application_role_id": "writer",
  133. "application_role_hash": self.binding.role("writer").role_hash,
  134. "task_protocol": new_task_protocol(task_brief),
  135. }
  136. async def _create_root(self, trace_id, *, uid="user-1"):
  137. await self.store.create_trace(Trace(
  138. trace_id=trace_id,
  139. mode="agent",
  140. agent_type="writer",
  141. uid=uid,
  142. context=self._context(trace_id),
  143. ))
  144. async def _create_child(self, trace_id, root_trace_id="root-a"):
  145. await self.store.create_trace(Trace(
  146. trace_id=trace_id,
  147. mode="agent",
  148. agent_type="writer",
  149. uid="user-1",
  150. parent_trace_id=root_trace_id,
  151. context=self._context(
  152. root_trace_id,
  153. task_brief=TaskBrief(
  154. objective="write candidate",
  155. reason="the root needs an option",
  156. completion_criteria=["candidate is complete"],
  157. expected_outputs=["one candidate"],
  158. ),
  159. ),
  160. ))
  161. async def test_create_fork_merge_and_idempotent_recovery(self):
  162. first, duplicate = await asyncio.gather(
  163. self.service.manage(
  164. "root-a",
  165. operation="create",
  166. content={"text": "A"},
  167. parent_refs=[],
  168. effective_at_sequence=1,
  169. ),
  170. self.service.manage(
  171. "root-a",
  172. operation="create",
  173. content={"text": "A"},
  174. parent_refs=[],
  175. effective_at_sequence=1,
  176. ),
  177. )
  178. self.assertEqual(first, duplicate)
  179. self.assertEqual(1, self.repository.put_calls)
  180. second = await self.service.manage(
  181. "root-a",
  182. operation="create",
  183. content={"text": "B"},
  184. parent_refs=[],
  185. effective_at_sequence=2,
  186. )
  187. fork = await self.service.manage(
  188. "root-a",
  189. operation="fork",
  190. content={"text": "A2"},
  191. parent_refs=[CandidatePointer(
  192. candidate_id=first.candidate_id,
  193. revision=first.revision,
  194. )],
  195. effective_at_sequence=3,
  196. )
  197. self.assertEqual(first.candidate_id, fork.candidate_id)
  198. self.assertEqual(2, fork.revision)
  199. merged = await self.service.manage(
  200. "root-a",
  201. operation="merge",
  202. content={"text": "AB"},
  203. parent_refs=[
  204. CandidatePointer(
  205. candidate_id=fork.candidate_id,
  206. revision=fork.revision,
  207. ),
  208. CandidatePointer(
  209. candidate_id=second.candidate_id,
  210. revision=second.revision,
  211. ),
  212. ],
  213. effective_at_sequence=4,
  214. )
  215. self.assertNotIn(
  216. merged.candidate_id,
  217. {first.candidate_id, second.candidate_id},
  218. )
  219. self.assertEqual(2, len(merged.parent_refs))
  220. ledger = CandidateLedger.model_validate(
  221. await self.store.get_candidate_ledger("root-a")
  222. )
  223. self.assertEqual(4, len(ledger.candidates))
  224. self.assertTrue(all(
  225. ledger.current_state(item) == "proposed"
  226. for item in ledger.candidates
  227. ))
  228. async def test_cross_root_and_cross_task_parent_access_is_rejected(self):
  229. first = await self.service.manage(
  230. "root-a",
  231. operation="create",
  232. content={"text": "A"},
  233. parent_refs=[],
  234. effective_at_sequence=1,
  235. )
  236. await self._create_root("root-b")
  237. with self.assertRaisesRegex(CandidateStateError, "not found"):
  238. await self.service.manage(
  239. "root-b",
  240. operation="fork",
  241. content={"text": "stolen"},
  242. parent_refs=[CandidatePointer(
  243. candidate_id=first.candidate_id,
  244. revision=first.revision,
  245. )],
  246. effective_at_sequence=2,
  247. )
  248. await self._create_child("child-a")
  249. with self.assertRaisesRegex(CandidateStateError, "another application, root, or Task"):
  250. await self.service.manage(
  251. "child-a",
  252. operation="fork",
  253. content={"text": "stolen"},
  254. parent_refs=[CandidatePointer(
  255. candidate_id=first.candidate_id,
  256. revision=first.revision,
  257. )],
  258. effective_at_sequence=3,
  259. )
  260. async def test_bad_artifact_fails_without_registering_candidate(self):
  261. self.repository.bad_artifact = True
  262. with self.assertRaisesRegex(CandidateStateError, "invalid ArtifactRef"):
  263. await self.service.manage(
  264. "root-a",
  265. operation="create",
  266. content={"text": "bad"},
  267. parent_refs=[],
  268. effective_at_sequence=1,
  269. )
  270. ledger = CandidateLedger.model_validate(
  271. await self.store.get_candidate_ledger("root-a")
  272. )
  273. self.assertEqual(0, len(ledger.candidates))
  274. self.assertEqual("failed", ledger.operations[0].status)
  275. async def test_report_validation_rejects_tamper_and_wrong_owner(self):
  276. candidate = await self.service.manage(
  277. "root-a",
  278. operation="create",
  279. content={"text": "A"},
  280. parent_refs=[],
  281. effective_at_sequence=1,
  282. )
  283. await self.service.validate_report_refs("root-a", [candidate])
  284. tampered = candidate.model_copy(update={
  285. "artifact_ref": candidate.artifact_ref.model_copy(update={
  286. "version": "tampered",
  287. }),
  288. })
  289. with self.assertRaisesRegex(CandidateStateError, "does not match"):
  290. await self.service.validate_report_refs("root-a", [tampered])
  291. await self._create_child("child-a")
  292. with self.assertRaisesRegex(CandidateStateError, "another application, root, or Task"):
  293. await self.service.validate_report_refs("child-a", [candidate])
  294. async def test_ledger_rejects_future_lineage_and_store_reports_corruption(self):
  295. candidate = await self.service.manage(
  296. "root-a",
  297. operation="create",
  298. content={"text": "A"},
  299. parent_refs=[],
  300. effective_at_sequence=1,
  301. )
  302. raw = (await self.store.get_candidate_ledger("root-a"))
  303. future = candidate.model_copy(update={
  304. "revision": 2,
  305. "parent_refs": (CandidatePointer(
  306. candidate_id=candidate.candidate_id,
  307. revision=3,
  308. ),),
  309. })
  310. raw["candidates"].append(future.model_dump(mode="json"))
  311. with self.assertRaisesRegex(ValidationError, "unknown parent"):
  312. CandidateLedger.model_validate(raw)
  313. ledger_path = self.store._get_candidate_ledger_file("root-a")
  314. ledger_path.write_text("{broken", encoding="utf-8")
  315. with self.assertRaisesRegex(TraceStoreCorruptionError, "candidate ledger"):
  316. await self.store.get_candidate_ledger("root-a")
  317. if __name__ == "__main__":
  318. unittest.main()