test_candidate_quality.py 16 KB

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