test_recursive_validation_core.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. import json
  2. import tempfile
  3. import unittest
  4. from pydantic import ValidationError
  5. from cyber_agent.core.validation import (
  6. LLMValidator,
  7. ValidationResult,
  8. build_validation_packet,
  9. parse_validation_result,
  10. validation_error,
  11. )
  12. from cyber_agent.trace.models import Message, Trace
  13. from cyber_agent.trace.store import FileSystemTraceStore
  14. class FakeLLM:
  15. def __init__(self, response=None, error=None):
  16. self.response = response
  17. self.error = error
  18. self.calls = []
  19. async def __call__(self, **kwargs):
  20. self.calls.append(kwargs)
  21. if self.error:
  22. raise self.error
  23. return self.response
  24. def valid_response(scope="task"):
  25. return {
  26. "content": json.dumps({
  27. "outcome": "passed",
  28. "scope": scope,
  29. "reason": "The persisted evidence satisfies every criterion.",
  30. "issues": [],
  31. "retry_from": None,
  32. }),
  33. "tool_calls": None,
  34. "prompt_tokens": 120,
  35. "completion_tokens": 30,
  36. "cost": 0.012,
  37. "finish_reason": "stop",
  38. }
  39. class ValidationModelTest(unittest.TestCase):
  40. def test_result_enforces_outcome_invariants(self):
  41. passed = ValidationResult(
  42. validator_trace_id="validator",
  43. evaluated_trace_id="child",
  44. outcome="passed",
  45. scope="task",
  46. reason="all criteria passed",
  47. issues=[],
  48. retry_from=None,
  49. )
  50. self.assertEqual("passed", passed.outcome)
  51. invalid = [
  52. {"outcome": "passed", "issues": ["unexpected"], "retry_from": None},
  53. {"outcome": "failed", "issues": [], "retry_from": "evidence"},
  54. {"outcome": "failed", "issues": ["gap"], "retry_from": None},
  55. {"outcome": "error", "issues": ["broken"], "retry_from": "output"},
  56. ]
  57. for fields in invalid:
  58. with self.subTest(fields=fields), self.assertRaises(ValidationError):
  59. ValidationResult(
  60. validator_trace_id="validator",
  61. evaluated_trace_id="child",
  62. scope="task",
  63. reason="invalid combination",
  64. **fields,
  65. )
  66. def test_parser_injects_ids_and_rejects_model_owned_ids(self):
  67. parsed = parse_validation_result(
  68. valid_response()["content"],
  69. validator_trace_id="validator",
  70. evaluated_trace_id="child",
  71. expected_scope="task",
  72. )
  73. self.assertEqual("validator", parsed.validator_trace_id)
  74. self.assertEqual("child", parsed.evaluated_trace_id)
  75. forged = json.loads(valid_response()["content"])
  76. forged["evaluated_trace_id"] = "forged"
  77. with self.assertRaises(ValidationError):
  78. parse_validation_result(
  79. json.dumps(forged),
  80. validator_trace_id="validator",
  81. evaluated_trace_id="child",
  82. expected_scope="task",
  83. )
  84. with self.assertRaisesRegex(ValueError, "expected 'task'"):
  85. parse_validation_result(
  86. valid_response(scope="root")["content"],
  87. validator_trace_id="validator",
  88. evaluated_trace_id="child",
  89. expected_scope="task",
  90. )
  91. def test_error_result_is_deterministic_and_fail_closed(self):
  92. result = validation_error(
  93. validator_trace_id="validator",
  94. evaluated_trace_id="child",
  95. scope="root",
  96. reason=" invalid JSON ",
  97. )
  98. self.assertEqual("error", result.outcome)
  99. self.assertEqual(["invalid JSON"], result.issues)
  100. self.assertIsNone(result.retry_from)
  101. def test_packet_keeps_contract_and_newest_main_path_within_limit(self):
  102. trajectory = [
  103. {
  104. "sequence": index,
  105. "role": "tool",
  106. "name": "read_file",
  107. "content": f"marker-{index}-" + ("x" * 300),
  108. "reasoning_content": "must-not-leak",
  109. }
  110. for index in range(1, 8)
  111. ]
  112. trajectory.insert(6, {
  113. "sequence": 99,
  114. "role": "assistant",
  115. "content": "side-branch-secret",
  116. "branch_type": "compression",
  117. })
  118. packet = build_validation_packet(
  119. validation_scope="task",
  120. task_brief={
  121. "objective": "check result",
  122. "completion_criteria": ["criterion-kept"],
  123. "expected_outputs": ["output-kept"],
  124. },
  125. task_report={"summary": "report-kept"},
  126. trajectory=trajectory,
  127. max_chars=1_000,
  128. )
  129. self.assertLessEqual(len(packet), 1_000)
  130. self.assertIn("criterion-kept", packet)
  131. self.assertIn("output-kept", packet)
  132. self.assertIn("report-kept", packet)
  133. self.assertIn("marker-7", packet)
  134. self.assertNotIn("marker-1", packet)
  135. self.assertNotIn("reasoning_content", packet)
  136. self.assertNotIn("side-branch-secret", packet)
  137. def test_packet_rejects_contract_that_alone_exceeds_limit(self):
  138. with self.assertRaisesRegex(ValueError, "contract exceeds"):
  139. build_validation_packet(
  140. validation_scope="task",
  141. task_brief={"objective": "x" * 1_000},
  142. trajectory=[],
  143. max_chars=100,
  144. )
  145. def test_packet_removes_reasoning_from_persisted_assistant_message(self):
  146. message = Message.create(
  147. trace_id="evaluated",
  148. role="assistant",
  149. sequence=1,
  150. content={
  151. "text": "observable answer",
  152. "reasoning_content": "hidden chain of thought",
  153. },
  154. )
  155. packet = build_validation_packet(
  156. validation_scope="task",
  157. trajectory=[message],
  158. )
  159. self.assertIn("observable answer", packet)
  160. self.assertNotIn("hidden chain of thought", packet)
  161. self.assertNotIn("reasoning_content", packet)
  162. class LLMValidatorTest(unittest.IsolatedAsyncioTestCase):
  163. async def asyncSetUp(self):
  164. self.temp_dir = tempfile.TemporaryDirectory()
  165. self.store = FileSystemTraceStore(self.temp_dir.name)
  166. self.evaluated = Trace(
  167. trace_id="root@delegate-child",
  168. mode="agent",
  169. task="child task",
  170. uid="user-1",
  171. model="fake-model",
  172. current_goal_id="1.1",
  173. context={
  174. "agent_mode": "recursive",
  175. "agent_mode_revision": 2,
  176. "root_trace_id": "root",
  177. "agent_depth": 2,
  178. },
  179. )
  180. await self.store.create_trace(self.evaluated)
  181. self.trajectory = [
  182. Message.create(
  183. trace_id=self.evaluated.trace_id,
  184. role="user",
  185. sequence=1,
  186. content="produce checked evidence",
  187. ),
  188. Message.create(
  189. trace_id=self.evaluated.trace_id,
  190. role="tool",
  191. sequence=2,
  192. parent_sequence=1,
  193. content={"tool_name": "read_file", "result": "actual evidence"},
  194. ),
  195. ]
  196. async def asyncTearDown(self):
  197. self.temp_dir.cleanup()
  198. async def test_single_tool_free_call_persists_validator_trace_and_usage(self):
  199. llm = FakeLLM(valid_response())
  200. validator = LLMValidator(llm_call=llm, trace_store=self.store)
  201. run = await validator.validate(
  202. evaluated_trace=self.evaluated,
  203. trajectory=self.trajectory,
  204. scope="task",
  205. task_brief={
  206. "objective": "check result",
  207. "completion_criteria": ["must cite evidence"],
  208. "expected_outputs": ["one conclusion"],
  209. },
  210. task_report={"summary": "done", "evidence": [{"value": "actual"}]},
  211. validator_trace_id="validator-1",
  212. )
  213. self.assertEqual(1, len(llm.calls))
  214. call = llm.calls[0]
  215. self.assertEqual([], call["tools"])
  216. self.assertEqual(0, call["temperature"])
  217. self.assertEqual("fake-model", call["model"])
  218. self.assertEqual(2, len(call["messages"]))
  219. self.assertEqual("passed", run.result.outcome)
  220. self.assertEqual(120, run.prompt_tokens)
  221. self.assertEqual(30, run.completion_tokens)
  222. self.assertEqual(0.012, run.cost)
  223. trace = await self.store.get_trace("validator-1")
  224. self.assertEqual("completed", trace.status)
  225. self.assertEqual("validator", trace.agent_type)
  226. self.assertEqual(self.evaluated.trace_id, trace.parent_trace_id)
  227. self.assertEqual("validator", trace.context["created_by_tool"])
  228. self.assertEqual("root", trace.context["root_trace_id"])
  229. self.assertEqual(2, trace.context["agent_depth"])
  230. self.assertEqual([], trace.tools)
  231. messages = await self.store.get_main_path_messages("validator-1", 3)
  232. self.assertEqual(["system", "user", "assistant"], [m.role for m in messages])
  233. self.assertIn("actual evidence", messages[1].content)
  234. async def test_invalid_json_returns_error_without_correction_call(self):
  235. response = valid_response()
  236. response["content"] = "```json\n{}\n```"
  237. llm = FakeLLM(response)
  238. validator = LLMValidator(llm_call=llm, trace_store=self.store)
  239. run = await validator.validate(
  240. evaluated_trace=self.evaluated,
  241. trajectory=self.trajectory,
  242. scope="task",
  243. validator_trace_id="validator-invalid",
  244. )
  245. self.assertEqual(1, len(llm.calls))
  246. self.assertEqual("error", run.result.outcome)
  247. trace = await self.store.get_trace(run.trace_id)
  248. self.assertEqual("failed", trace.status)
  249. self.assertIn("Validator failed", trace.error_message)
  250. async def test_tool_call_is_rejected_without_dispatch(self):
  251. response = valid_response()
  252. response["tool_calls"] = [{
  253. "id": "forged",
  254. "type": "function",
  255. "function": {"name": "agent", "arguments": "{}"},
  256. }]
  257. llm = FakeLLM(response)
  258. validator = LLMValidator(llm_call=llm, trace_store=self.store)
  259. run = await validator.validate(
  260. evaluated_trace=self.evaluated,
  261. trajectory=self.trajectory,
  262. scope="task",
  263. )
  264. self.assertEqual("error", run.result.outcome)
  265. self.assertIn("attempted to call tools", run.result.reason)
  266. self.assertEqual(1, len(llm.calls))
  267. async def test_model_exception_is_recorded_once(self):
  268. llm = FakeLLM(error=RuntimeError("provider unavailable"))
  269. validator = LLMValidator(llm_call=llm, trace_store=self.store)
  270. run = await validator.validate(
  271. evaluated_trace=self.evaluated,
  272. trajectory=self.trajectory,
  273. scope="root",
  274. completion_criteria=["root done"],
  275. candidate_output="candidate",
  276. )
  277. self.assertEqual(1, len(llm.calls))
  278. self.assertEqual("error", run.result.outcome)
  279. self.assertIn("provider unavailable", run.result.reason)
  280. async def test_non_success_path_creates_trace_without_calling_llm(self):
  281. llm = FakeLLM(valid_response())
  282. validator = LLMValidator(llm_call=llm, trace_store=self.store)
  283. run = await validator.record_non_success(
  284. evaluated_trace=self.evaluated,
  285. scope="task",
  286. outcome="failed",
  287. reason="child stopped",
  288. issues=["execution stopped before evidence was produced"],
  289. retry_from="evidence",
  290. validator_trace_id="validator-stopped",
  291. )
  292. self.assertEqual([], llm.calls)
  293. self.assertEqual("failed", run.result.outcome)
  294. trace = await self.store.get_trace(run.trace_id)
  295. self.assertEqual("failed", trace.status)
  296. self.assertEqual(0, trace.total_tokens)
  297. self.assertEqual(1, trace.total_messages)
  298. async def test_oversized_fixed_input_fails_before_llm(self):
  299. llm = FakeLLM(valid_response())
  300. validator = LLMValidator(
  301. llm_call=llm,
  302. trace_store=self.store,
  303. max_input_chars=2_000,
  304. )
  305. run = await validator.validate(
  306. evaluated_trace=self.evaluated,
  307. trajectory=[],
  308. scope="task",
  309. task_brief={"objective": "x" * 3_000},
  310. )
  311. self.assertEqual([], llm.calls)
  312. self.assertEqual("error", run.result.outcome)
  313. self.assertIn("input could not be built", run.result.reason)
  314. if __name__ == "__main__":
  315. unittest.main()