test_candidate_quality.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. import json
  2. import tempfile
  3. import unittest
  4. from unittest.mock import patch
  5. from cyber_agent.application import (
  6. AgentApplication,
  7. AgentRole,
  8. ApplicationRegistry,
  9. ApplicationRuntime,
  10. ApplicationServices,
  11. ProviderRef,
  12. QualityRuleSpec,
  13. )
  14. from cyber_agent.application.candidate import CandidateLedger
  15. from cyber_agent.application.quality import QualityCheckOutcome
  16. from cyber_agent.core.artifacts import (
  17. ArtifactRef,
  18. ValidationMaterial,
  19. material_content_hash,
  20. )
  21. from cyber_agent.tools.registry import ToolRegistry
  22. from cyber_agent.trace.store import FileSystemTraceStore
  23. class CandidateRepository:
  24. def __init__(self):
  25. self.requests = {}
  26. async def put_version(self, request):
  27. self.requests.setdefault(request.operation_id, request)
  28. saved = self.requests[request.operation_id]
  29. return ArtifactRef(
  30. artifact_id=f"candidate:{saved.candidate_id}:{saved.revision}",
  31. version=str(saved.revision),
  32. content_hash=material_content_hash(saved.content),
  33. kind="candidate.output",
  34. mime_type="application/json",
  35. )
  36. async def merge(self, request):
  37. return await self.put_version(request)
  38. async def load_version(self, candidate_ref):
  39. return next(
  40. item.content for item in self.requests.values()
  41. if item.candidate_id == candidate_ref.candidate_id
  42. and item.revision == candidate_ref.revision
  43. )
  44. async def resolve(self, ref, root_trace_id, uid):
  45. request = next(
  46. item for item in self.requests.values()
  47. if ref.artifact_id == f"candidate:{item.candidate_id}:{item.revision}"
  48. )
  49. return ValidationMaterial(
  50. **ref.model_dump(mode="json"),
  51. root_trace_id=root_trace_id,
  52. uid=uid,
  53. content=request.content,
  54. )
  55. async def commit_adoption(self, request):
  56. raise NotImplementedError
  57. class QualityProvider:
  58. def __init__(self):
  59. self.mode = "normal"
  60. self.calls = 0
  61. async def check(self, request):
  62. self.calls += 1
  63. outcomes = []
  64. for rule in request.rules:
  65. failed = "{{placeholder}}" in request.material.content["text"]
  66. outcomes.append(QualityCheckOutcome(
  67. rule_id=rule.rule_id,
  68. rule_version=rule.version,
  69. status="failed" if failed else "passed",
  70. issue="placeholder remains" if failed else None,
  71. ))
  72. if self.mode == "missing":
  73. return []
  74. if self.mode == "extra":
  75. outcomes.append(QualityCheckOutcome(
  76. rule_id="unexpected",
  77. rule_version="1",
  78. status="passed",
  79. ))
  80. if self.mode == "error":
  81. raise RuntimeError("provider unavailable")
  82. return outcomes
  83. class CandidateQualityTest(unittest.IsolatedAsyncioTestCase):
  84. async def asyncSetUp(self):
  85. self.temp = tempfile.TemporaryDirectory()
  86. self.store = FileSystemTraceStore(self.temp.name)
  87. self.repository = CandidateRepository()
  88. self.quality = QualityProvider()
  89. application = AgentApplication(
  90. application_id="quality.reference",
  91. application_version="1",
  92. root_role="writer",
  93. roles=(AgentRole(
  94. role_id="writer",
  95. model="validator-model",
  96. system_prompt="write",
  97. ),),
  98. artifact_resolver_ref=ProviderRef(
  99. provider_id="artifacts",
  100. provider_version="1",
  101. ),
  102. candidate_repository_ref=ProviderRef(
  103. provider_id="candidates",
  104. provider_version="1",
  105. ),
  106. quality_provider_ref=ProviderRef(
  107. provider_id="quality",
  108. provider_version="1",
  109. ),
  110. quality_rules=(QualityRuleSpec(
  111. rule_id="no_placeholder",
  112. version="1",
  113. criterion="Candidate contains no placeholder text",
  114. ),),
  115. )
  116. registry = ApplicationRegistry()
  117. self.binding = registry.register(
  118. application,
  119. ApplicationServices(
  120. tool_registry=ToolRegistry(),
  121. artifact_resolver=self.repository,
  122. candidate_repository=self.repository,
  123. quality_provider=self.quality,
  124. ),
  125. )
  126. self.llm_calls = 0
  127. async def llm_call(**kwargs):
  128. self.llm_calls += 1
  129. packet = json.loads(kwargs["messages"][-1]["content"])
  130. scope = packet["validation_scope"]
  131. return {
  132. "content": json.dumps({
  133. "scope": scope,
  134. "outcome": "passed",
  135. "checks": [
  136. {
  137. "check_id": item["check_id"],
  138. "status": "passed",
  139. "evidence_refs": [],
  140. "issue": None,
  141. }
  142. for item in packet["validation_plan"]["checks"]
  143. ],
  144. "reason": "candidate output is complete",
  145. "retry_from": None,
  146. }),
  147. "tool_calls": [],
  148. }
  149. runtime = ApplicationRuntime(
  150. registry=registry,
  151. trace_store=self.store,
  152. llm_call=llm_call,
  153. )
  154. self.runner, config = runtime.new_run(
  155. "quality.reference",
  156. "1",
  157. uid="user-1",
  158. root_task_anchor={
  159. "objective": "write",
  160. "completion_criteria": ["complete"],
  161. "constraints": [],
  162. },
  163. )
  164. with patch.dict("os.environ", {"AGENT_MODE": "recursive"}, clear=False):
  165. self.trace, _goal, _sequence = await self.runner._prepare_new_trace(
  166. [{"role": "user", "content": "start"}],
  167. config,
  168. )
  169. async def asyncTearDown(self):
  170. self.temp.cleanup()
  171. async def _candidate(self, text, sequence):
  172. return await self.runner.candidate_service.manage(
  173. self.trace.trace_id,
  174. operation="create",
  175. content={"text": text},
  176. parent_refs=[],
  177. effective_at_sequence=sequence,
  178. )
  179. async def test_deterministic_and_llm_checks_are_separate_and_cached(self):
  180. candidate = await self._candidate("finished", 2)
  181. first = await self.runner.validate_recursive_trace(
  182. self.trace.trace_id,
  183. candidate_ref=candidate,
  184. )
  185. checks = first.result.scope_results[0].checks
  186. self.assertEqual("passed", first.result.outcome)
  187. self.assertIn("quality.no_placeholder", [item.check_id for item in checks])
  188. self.assertTrue(any(item.check_id.startswith("output.policy") for item in checks))
  189. self.assertEqual(1, self.quality.calls)
  190. self.assertEqual(1, self.llm_calls)
  191. second = await self.runner.validate_recursive_trace(
  192. self.trace.trace_id,
  193. candidate_ref=candidate,
  194. )
  195. self.assertTrue(second.cached)
  196. self.assertEqual(1, self.quality.calls)
  197. self.assertEqual(1, self.llm_calls)
  198. ledger = CandidateLedger.model_validate(
  199. await self.store.get_candidate_ledger(self.trace.trace_id)
  200. )
  201. self.assertEqual(1, len(ledger.validations))
  202. async def test_failed_candidate_does_not_call_llm_or_pollute_peer(self):
  203. good = await self._candidate("finished", 2)
  204. bad = await self._candidate("{{placeholder}}", 3)
  205. good_run = await self.runner.validate_recursive_trace(
  206. self.trace.trace_id,
  207. candidate_ref=good,
  208. )
  209. bad_run = await self.runner.validate_recursive_trace(
  210. self.trace.trace_id,
  211. candidate_ref=bad,
  212. )
  213. self.assertEqual("passed", good_run.result.outcome)
  214. self.assertEqual("failed", bad_run.result.outcome)
  215. self.assertEqual(1, self.llm_calls)
  216. ledger = CandidateLedger.model_validate(
  217. await self.store.get_candidate_ledger(self.trace.trace_id)
  218. )
  219. self.assertEqual(2, len(ledger.validations))
  220. async def test_provider_shape_and_exception_fail_closed_with_complete_plan(self):
  221. for index, mode in enumerate(("missing", "extra", "error"), start=2):
  222. with self.subTest(mode=mode):
  223. self.quality.mode = mode
  224. candidate = await self._candidate(f"finished-{mode}", index)
  225. run = await self.runner.validate_recursive_trace(
  226. self.trace.trace_id,
  227. candidate_ref=candidate,
  228. )
  229. result = run.result.scope_results[0]
  230. self.assertEqual("error", result.outcome)
  231. self.assertEqual(
  232. {
  233. item.check_id
  234. for item in result.checks
  235. },
  236. {
  237. "quality.no_placeholder",
  238. *{
  239. item.check_id for item in result.checks
  240. if item.check_id.startswith("output.policy")
  241. },
  242. },
  243. )
  244. self.assertTrue(all(
  245. item.status == "unknown" for item in result.checks
  246. ))
  247. self.assertEqual(0, self.llm_calls)