test_candidate_quality.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  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.core.task_protocol import ensure_task_protocol
  22. from cyber_agent.tools.registry import ToolRegistry
  23. from cyber_agent.trace.store import FileSystemTraceStore
  24. class CandidateRepository:
  25. def __init__(self):
  26. self.requests = {}
  27. async def put_version(self, request):
  28. self.requests.setdefault(request.operation_id, request)
  29. saved = self.requests[request.operation_id]
  30. return ArtifactRef(
  31. artifact_id=f"candidate:{saved.candidate_id}:{saved.revision}",
  32. version=str(saved.revision),
  33. content_hash=material_content_hash(saved.content),
  34. kind="candidate.output",
  35. mime_type="application/json",
  36. )
  37. async def merge(self, request):
  38. return await self.put_version(request)
  39. async def load_version(self, candidate_ref):
  40. return next(
  41. item.content for item in self.requests.values()
  42. if item.candidate_id == candidate_ref.candidate_id
  43. and item.revision == candidate_ref.revision
  44. )
  45. async def resolve(self, ref, root_trace_id, uid):
  46. request = next(
  47. item for item in self.requests.values()
  48. if ref.artifact_id == f"candidate:{item.candidate_id}:{item.revision}"
  49. )
  50. return ValidationMaterial(
  51. **ref.model_dump(mode="json"),
  52. root_trace_id=root_trace_id,
  53. uid=uid,
  54. content=request.content,
  55. )
  56. async def commit_adoption(self, request):
  57. raise NotImplementedError
  58. class QualityProvider:
  59. def __init__(self):
  60. self.mode = "normal"
  61. self.calls = 0
  62. async def check(self, request):
  63. self.calls += 1
  64. outcomes = []
  65. for rule in request.rules:
  66. failed = "{{placeholder}}" in json.dumps(
  67. request.material.content,
  68. ensure_ascii=False,
  69. )
  70. outcomes.append(QualityCheckOutcome(
  71. rule_id=rule.rule_id,
  72. rule_version=rule.version,
  73. status="failed" if failed else "passed",
  74. issue="placeholder remains" if failed else None,
  75. ))
  76. if self.mode == "missing":
  77. return []
  78. if self.mode == "extra":
  79. outcomes.append(QualityCheckOutcome(
  80. rule_id="unexpected",
  81. rule_version="1",
  82. status="passed",
  83. ))
  84. if self.mode == "error":
  85. raise RuntimeError("provider unavailable")
  86. return outcomes
  87. class CandidateQualityTest(unittest.IsolatedAsyncioTestCase):
  88. async def asyncSetUp(self):
  89. self.temp = tempfile.TemporaryDirectory()
  90. self.store = FileSystemTraceStore(self.temp.name)
  91. self.repository = CandidateRepository()
  92. self.quality = QualityProvider()
  93. application = AgentApplication(
  94. application_id="quality.reference",
  95. application_version="1",
  96. root_role="writer",
  97. roles=(AgentRole(
  98. role_id="writer",
  99. model="validator-model",
  100. system_prompt="write",
  101. ),),
  102. artifact_resolver_ref=ProviderRef(
  103. provider_id="artifacts",
  104. provider_version="1",
  105. ),
  106. candidate_repository_ref=ProviderRef(
  107. provider_id="candidates",
  108. provider_version="1",
  109. ),
  110. quality_provider_ref=ProviderRef(
  111. provider_id="quality",
  112. provider_version="1",
  113. ),
  114. quality_rules=(QualityRuleSpec(
  115. rule_id="no_placeholder",
  116. version="1",
  117. criterion="Candidate contains no placeholder text",
  118. subject_types=("trace", "candidate"),
  119. ),),
  120. )
  121. registry = ApplicationRegistry()
  122. self.binding = registry.register(
  123. application,
  124. ApplicationServices(
  125. tool_registry=ToolRegistry(),
  126. artifact_resolver=self.repository,
  127. candidate_repository=self.repository,
  128. quality_provider=self.quality,
  129. ),
  130. )
  131. self.llm_calls = 0
  132. async def llm_call(**kwargs):
  133. self.llm_calls += 1
  134. packet = json.loads(kwargs["messages"][-1]["content"])
  135. scope = packet["validation_scope"]
  136. return {
  137. "content": json.dumps({
  138. "scope": scope,
  139. "outcome": "passed",
  140. "checks": [
  141. {
  142. "check_id": item["check_id"],
  143. "status": "passed",
  144. "evidence_refs": [],
  145. "issue": None,
  146. }
  147. for item in packet["validation_plan"]["checks"]
  148. if item["scope"] == scope
  149. ],
  150. "reason": "candidate output is complete",
  151. "retry_from": None,
  152. }),
  153. "tool_calls": [],
  154. }
  155. runtime = ApplicationRuntime(
  156. registry=registry,
  157. trace_store=self.store,
  158. llm_call=llm_call,
  159. )
  160. self.runner, config = runtime.new_run(
  161. "quality.reference",
  162. "1",
  163. uid="user-1",
  164. root_task_anchor={
  165. "objective": "write",
  166. "completion_criteria": ["complete"],
  167. "constraints": [],
  168. },
  169. )
  170. with patch.dict("os.environ", {"AGENT_MODE": "recursive"}, clear=False):
  171. self.trace, _goal, _sequence = await self.runner._prepare_new_trace(
  172. [{"role": "user", "content": "start"}],
  173. config,
  174. )
  175. async def asyncTearDown(self):
  176. self.temp.cleanup()
  177. async def _candidate(self, text, sequence):
  178. return await self.runner.candidate_service.manage(
  179. self.trace.trace_id,
  180. operation="create",
  181. content={"text": text},
  182. parent_refs=[],
  183. effective_at_sequence=sequence,
  184. )
  185. async def _bind_current_progress_to_report(self):
  186. trace = await self.store.get_trace(self.trace.trace_id)
  187. state = ensure_task_protocol(trace.context)
  188. state["task_report_progress_revision"] = state[
  189. "task_progress_head_revision"
  190. ]
  191. await self.store.update_trace(trace.trace_id, context=trace.context)
  192. async def test_deterministic_and_llm_checks_are_separate_and_cached(self):
  193. candidate = await self._candidate("finished", 2)
  194. first = await self.runner.validate_recursive_trace(
  195. self.trace.trace_id,
  196. candidate_ref=candidate,
  197. )
  198. checks = first.result.scope_results[0].checks
  199. self.assertEqual("passed", first.result.outcome)
  200. self.assertIn("quality.no_placeholder", [item.check_id for item in checks])
  201. self.assertTrue(any(item.check_id.startswith("output.policy") for item in checks))
  202. self.assertEqual(1, self.quality.calls)
  203. self.assertEqual(1, self.llm_calls)
  204. second = await self.runner.validate_recursive_trace(
  205. self.trace.trace_id,
  206. candidate_ref=candidate,
  207. )
  208. self.assertTrue(second.cached)
  209. self.assertEqual(1, self.quality.calls)
  210. self.assertEqual(1, self.llm_calls)
  211. ledger = CandidateLedger.model_validate(
  212. await self.store.get_candidate_ledger(self.trace.trace_id)
  213. )
  214. self.assertEqual(1, len(ledger.validations))
  215. async def test_failed_candidate_does_not_call_llm_or_pollute_peer(self):
  216. good = await self._candidate("finished", 2)
  217. bad = await self._candidate("{{placeholder}}", 3)
  218. good_run = await self.runner.validate_recursive_trace(
  219. self.trace.trace_id,
  220. candidate_ref=good,
  221. )
  222. bad_run = await self.runner.validate_recursive_trace(
  223. self.trace.trace_id,
  224. candidate_ref=bad,
  225. )
  226. self.assertEqual("passed", good_run.result.outcome)
  227. self.assertEqual("failed", bad_run.result.outcome)
  228. self.assertEqual(1, self.llm_calls)
  229. ledger = CandidateLedger.model_validate(
  230. await self.store.get_candidate_ledger(self.trace.trace_id)
  231. )
  232. self.assertEqual(2, len(ledger.validations))
  233. async def test_provider_shape_and_exception_fail_closed_with_complete_plan(self):
  234. for index, mode in enumerate(("missing", "extra", "error"), start=2):
  235. with self.subTest(mode=mode):
  236. self.quality.mode = mode
  237. candidate = await self._candidate(f"finished-{mode}", index)
  238. run = await self.runner.validate_recursive_trace(
  239. self.trace.trace_id,
  240. candidate_ref=candidate,
  241. )
  242. result = run.result.scope_results[0]
  243. self.assertEqual("error", result.outcome)
  244. self.assertEqual(
  245. {
  246. item.check_id
  247. for item in result.checks
  248. },
  249. {
  250. "quality.no_placeholder",
  251. *{
  252. item.check_id for item in result.checks
  253. if item.check_id.startswith("output.policy")
  254. },
  255. },
  256. )
  257. self.assertTrue(all(
  258. item.status == "unknown" for item in result.checks
  259. ))
  260. self.assertEqual(0, self.llm_calls)
  261. async def test_trace_quality_failure_only_short_circuits_its_output_scope(self):
  262. await self._bind_current_progress_to_report()
  263. run = await self.runner.validate_recursive_trace(
  264. self.trace.trace_id,
  265. task_brief={
  266. "objective": "produce checked copy",
  267. "reason": "the root needs a complete output",
  268. "completion_criteria": ["copy is complete"],
  269. "expected_outputs": ["copy"],
  270. "validation_scopes": ["output"],
  271. },
  272. task_report={
  273. "summary": "draft",
  274. "outputs": [{"text": "{{placeholder}}"}],
  275. "evidence": [],
  276. },
  277. )
  278. by_scope = {item.scope: item for item in run.result.scope_results}
  279. self.assertEqual("failed", by_scope["output"].outcome)
  280. self.assertEqual("passed", by_scope["task"].outcome)
  281. self.assertEqual(1, self.quality.calls)
  282. self.assertEqual(1, self.llm_calls)
  283. async def test_trace_rule_is_skipped_when_scope_is_not_effective(self):
  284. await self._bind_current_progress_to_report()
  285. run = await self.runner.validate_recursive_trace(
  286. self.trace.trace_id,
  287. scope="task",
  288. task_report={"summary": "{{placeholder}}"},
  289. )
  290. self.assertEqual("passed", run.result.outcome)
  291. self.assertEqual(0, self.quality.calls)
  292. self.assertEqual(1, self.llm_calls)
  293. async def test_candidate_scope_checkpoint_survives_final_publish_crash(self):
  294. candidate = await self._candidate("finished", 2)
  295. original = self.runner.candidate_service.record_validation
  296. attempts = 0
  297. async def fail_final_publish_once(*args, **kwargs):
  298. nonlocal attempts
  299. attempts += 1
  300. if attempts == 1:
  301. raise OSError("final candidate validation publish failed")
  302. return await original(*args, **kwargs)
  303. with patch.object(
  304. self.runner.candidate_service,
  305. "record_validation",
  306. side_effect=fail_final_publish_once,
  307. ):
  308. with self.assertRaisesRegex(OSError, "final candidate validation"):
  309. await self.runner.validate_recursive_trace(
  310. self.trace.trace_id,
  311. candidate_ref=candidate,
  312. )
  313. recovered = await self.runner.validate_recursive_trace(
  314. self.trace.trace_id,
  315. candidate_ref=candidate,
  316. )
  317. self.assertEqual("passed", recovered.result.outcome)
  318. self.assertEqual(1, self.quality.calls)
  319. self.assertEqual(1, self.llm_calls)
  320. ledger = CandidateLedger.model_validate(
  321. await self.store.get_candidate_ledger(self.trace.trace_id)
  322. )
  323. self.assertEqual(1, len(ledger.validation_checkpoints))
  324. self.assertEqual(1, len(ledger.validations))
  325. async def test_candidate_material_usage_is_idempotent_across_checkpoint_crash(self):
  326. candidate = await self._candidate("finished", 2)
  327. root_id = self.trace.trace_id
  328. before = await self.runner.resource_budget.get_usage(root_id)
  329. original = self.runner.candidate_service.update_validation_checkpoint
  330. failed_once = False
  331. async def fail_material_checkpoint_once(*args, **kwargs):
  332. nonlocal failed_once
  333. if kwargs.get("material_usage_recorded") and not failed_once:
  334. failed_once = True
  335. raise OSError("material checkpoint publish failed")
  336. return await original(*args, **kwargs)
  337. with patch.object(
  338. self.runner.candidate_service,
  339. "update_validation_checkpoint",
  340. side_effect=fail_material_checkpoint_once,
  341. ):
  342. with self.assertRaisesRegex(OSError, "material checkpoint"):
  343. await self.runner.validate_recursive_trace(
  344. self.trace.trace_id,
  345. candidate_ref=candidate,
  346. )
  347. after_failure = await self.runner.resource_budget.get_usage(root_id)
  348. recovered = await self.runner.validate_recursive_trace(
  349. self.trace.trace_id,
  350. candidate_ref=candidate,
  351. )
  352. after_retry = await self.runner.resource_budget.get_usage(root_id)
  353. self.assertEqual("passed", recovered.result.outcome)
  354. self.assertGreater(
  355. after_failure.validation_material_chars,
  356. before.validation_material_chars,
  357. )
  358. self.assertEqual(
  359. after_failure.validation_material_chars,
  360. after_retry.validation_material_chars,
  361. )
  362. self.assertEqual(1, self.quality.calls)
  363. self.assertEqual(1, self.llm_calls)