test_recursive_replan_context.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. import tempfile
  2. import unittest
  3. from pydantic import ValidationError
  4. from cyber_agent.core.context_policy import (
  5. ContextPolicyError,
  6. normalize_task_brief,
  7. task_briefs_match,
  8. )
  9. from cyber_agent.core.task_protocol import (
  10. TaskBrief,
  11. TaskReport,
  12. TaskReview,
  13. Validation,
  14. NextStepSuggestion,
  15. ensure_task_protocol,
  16. new_task_protocol,
  17. pending_review_entry,
  18. rebuild_pending_replans,
  19. )
  20. from cyber_agent.core.validation import ValidationResult
  21. from cyber_agent.tools.builtin.task_protocol import review_task_result
  22. from cyber_agent.trace.goal_models import Goal, GoalTree
  23. from cyber_agent.trace.models import Trace
  24. from cyber_agent.trace.store import FileSystemTraceStore
  25. BRIEF = {
  26. "objective": "Check one concrete claim",
  27. "reason": "The parent needs independent evidence",
  28. "completion_criteria": ["Reach one checked conclusion"],
  29. "expected_outputs": ["A short evidence note"],
  30. "parent_findings": ["The claim is currently unverified"],
  31. "context": {"claim": "example"},
  32. "constraints": ["Do not guess"],
  33. }
  34. def task_report(child_trace_id: str) -> TaskReport:
  35. return TaskReport(
  36. child_trace_id=child_trace_id,
  37. summary="Checked result",
  38. outcome="satisfied",
  39. validation=Validation(hard_passed=True),
  40. next_step_suggestion=NextStepSuggestion(
  41. direction="NONE",
  42. reason="No extra task suggested",
  43. ),
  44. outputs=[{"kind": "note", "value": "result"}],
  45. evidence=[{"kind": "test", "value": "passed"}],
  46. )
  47. def validation_result(
  48. child_trace_id: str,
  49. *,
  50. outcome: str = "failed",
  51. retry_from: str | None = "hypothesis",
  52. ) -> ValidationResult:
  53. return ValidationResult(
  54. validator_trace_id=f"{child_trace_id}@validator",
  55. evaluated_trace_id=child_trace_id,
  56. outcome=outcome,
  57. scope="task",
  58. reason=(
  59. "Independent evidence is still missing"
  60. if outcome != "passed"
  61. else "All criteria passed"
  62. ),
  63. issues=[] if outcome == "passed" else ["Missing independent evidence"],
  64. retry_from=retry_from,
  65. )
  66. class ContextPolicyTest(unittest.TestCase):
  67. def test_task_brief_requires_explicit_reason_and_outputs(self):
  68. with self.assertRaises(ValidationError):
  69. TaskBrief.model_validate({
  70. "objective": "Too vague",
  71. "completion_criteria": ["Done"],
  72. })
  73. def test_normalization_inherits_only_hard_constraints(self):
  74. parent = {
  75. **BRIEF,
  76. "context": {"parent-only": "must not leak"},
  77. "constraints": ["Parent rule", "Shared rule"],
  78. }
  79. child = {
  80. **BRIEF,
  81. "parent_findings": ["Child-visible finding"],
  82. "constraints": ["Shared rule", "Child rule", "Child rule"],
  83. }
  84. normalized = normalize_task_brief(child, parent_task_brief=parent)
  85. self.assertEqual(
  86. ["Parent rule", "Shared rule", "Child rule"],
  87. normalized.constraints,
  88. )
  89. self.assertEqual(["Child-visible finding"], normalized.parent_findings)
  90. self.assertNotIn("parent-only", normalized.context)
  91. self.assertTrue(task_briefs_match(
  92. child,
  93. normalized,
  94. parent_task_brief=parent,
  95. ))
  96. def test_normalization_rejects_invalid_json_and_oversized_brief(self):
  97. with self.assertRaises(ContextPolicyError):
  98. normalize_task_brief({**BRIEF, "context": {"bad": object()}})
  99. with self.assertRaises(ContextPolicyError):
  100. normalize_task_brief(
  101. {**BRIEF, "context": {"large": "x" * 100}},
  102. max_chars=50,
  103. )
  104. class ReplanCurrentTest(unittest.IsolatedAsyncioTestCase):
  105. async def asyncSetUp(self):
  106. self.temp_dir = tempfile.TemporaryDirectory()
  107. self.store = FileSystemTraceStore(self.temp_dir.name)
  108. self.parent_id = "parent"
  109. await self.store.create_trace(Trace(
  110. trace_id=self.parent_id,
  111. mode="agent",
  112. task="parent",
  113. uid="user-1",
  114. model="fake",
  115. context={
  116. "agent_mode": "recursive",
  117. "agent_mode_revision": 2,
  118. "agent_depth": 1,
  119. "root_trace_id": "root",
  120. "task_protocol": new_task_protocol(BRIEF),
  121. },
  122. ))
  123. self.tree = GoalTree(
  124. mission="parent",
  125. goals=[Goal(id="1", description="Verify", status="pending_review")],
  126. current_id=None,
  127. )
  128. await self.store.update_goal_tree(self.parent_id, self.tree)
  129. async def asyncTearDown(self):
  130. self.temp_dir.cleanup()
  131. async def add_child(
  132. self,
  133. child_id: str,
  134. *,
  135. validation_outcome: str = "failed",
  136. retry_from: str | None = "hypothesis",
  137. ) -> None:
  138. await self.store.create_trace(Trace(
  139. trace_id=child_id,
  140. mode="agent",
  141. task="child",
  142. parent_trace_id=self.parent_id,
  143. parent_goal_id="1",
  144. uid="user-1",
  145. model="fake",
  146. context={
  147. "agent_mode": "recursive",
  148. "agent_mode_revision": 2,
  149. "agent_depth": 2,
  150. "root_trace_id": "root",
  151. "task_protocol": new_task_protocol(BRIEF),
  152. },
  153. ))
  154. parent = await self.store.get_trace(self.parent_id)
  155. state = ensure_task_protocol(parent.context)
  156. entry = pending_review_entry(
  157. goal_id="1",
  158. report=task_report(child_id),
  159. validation_result=validation_result(
  160. child_id,
  161. outcome=validation_outcome,
  162. retry_from=retry_from,
  163. ).model_dump(),
  164. received_at_sequence=4,
  165. )
  166. state["pending_reviews"][child_id] = entry
  167. await self.store.update_trace(self.parent_id, context=parent.context)
  168. def context(self):
  169. return {
  170. "store": self.store,
  171. "trace_id": self.parent_id,
  172. "goal_id": "1",
  173. "goal_tree": self.tree,
  174. "sequence": 8,
  175. }
  176. async def test_replan_reactivates_only_the_direct_parent_goal(self):
  177. await self.add_child("child-1")
  178. result = await review_task_result(
  179. child_trace_id="child-1",
  180. decision="REPLAN_CURRENT",
  181. reason="Recheck the parent's hypothesis",
  182. context=self.context(),
  183. )
  184. self.assertEqual("completed", result["status"])
  185. self.assertEqual("hypothesis", result["task_review"]["retry_from"])
  186. parent = await self.store.get_trace(self.parent_id)
  187. state = ensure_task_protocol(parent.context)
  188. self.assertEqual([], state["next_actions"])
  189. self.assertEqual([], state["pending_replans"])
  190. self.assertEqual("REPLAN_CURRENT", state["reviews"][-1]["decision"])
  191. tree = await self.store.get_goal_tree(self.parent_id)
  192. self.assertEqual("in_progress", tree.find("1").status)
  193. self.assertEqual("1", tree.current_id)
  194. async def test_grandchild_cannot_jump_directly_to_grandparent(self):
  195. await self.add_child("child-1")
  196. await self.store.create_trace(Trace(
  197. trace_id="grandchild",
  198. mode="agent",
  199. task="grandchild",
  200. parent_trace_id="child-1",
  201. uid="user-1",
  202. model="fake",
  203. context={
  204. "agent_mode": "recursive",
  205. "agent_mode_revision": 2,
  206. },
  207. ))
  208. parent = await self.store.get_trace(self.parent_id)
  209. state = ensure_task_protocol(parent.context)
  210. entry = pending_review_entry(
  211. goal_id="1",
  212. report=task_report("grandchild"),
  213. validation_result=validation_result("grandchild").model_dump(),
  214. received_at_sequence=5,
  215. )
  216. state["pending_reviews"]["grandchild"] = entry
  217. await self.store.update_trace(self.parent_id, context=parent.context)
  218. result = await review_task_result(
  219. child_trace_id="grandchild",
  220. decision="REPLAN_CURRENT",
  221. reason="Attempt to skip one level",
  222. context=self.context(),
  223. )
  224. self.assertEqual("failed", result["status"])
  225. self.assertIn("direct child", result["error"])
  226. async def test_replan_rejects_a_pending_entry_for_the_wrong_parent_goal(self):
  227. await self.add_child("child-1")
  228. tree = await self.store.get_goal_tree(self.parent_id)
  229. tree.goals.append(Goal(id="2", description="Other branch"))
  230. await self.store.update_goal_tree(self.parent_id, tree)
  231. parent = await self.store.get_trace(self.parent_id)
  232. entry = ensure_task_protocol(parent.context)["pending_reviews"]["child-1"]
  233. entry["goal_id"] = "2"
  234. await self.store.update_trace(self.parent_id, context=parent.context)
  235. result = await review_task_result(
  236. child_trace_id="child-1",
  237. decision="REPLAN_CURRENT",
  238. reason="Attempt to redirect the retry",
  239. context=self.context(),
  240. )
  241. self.assertEqual("failed", result["status"])
  242. self.assertIn("originating Goal", result["error"])
  243. persisted = await self.store.get_goal_tree(self.parent_id)
  244. self.assertEqual("pending_review", persisted.find("1").status)
  245. self.assertEqual("pending", persisted.find("2").status)
  246. async def test_validation_matrix_controls_replan(self):
  247. await self.add_child(
  248. "child-passed",
  249. validation_outcome="passed",
  250. retry_from=None,
  251. )
  252. passed = await review_task_result(
  253. child_trace_id="child-passed",
  254. decision="REPLAN_CURRENT",
  255. reason="Passed validation cannot force a retry location",
  256. context=self.context(),
  257. )
  258. self.assertEqual("failed", passed["status"])
  259. parent = await self.store.get_trace(self.parent_id)
  260. ensure_task_protocol(parent.context)["pending_reviews"].clear()
  261. await self.store.update_trace(self.parent_id, context=parent.context)
  262. await self.add_child(
  263. "child-error",
  264. validation_outcome="error",
  265. retry_from=None,
  266. )
  267. errored = await review_task_result(
  268. child_trace_id="child-error",
  269. decision="REPLAN_CURRENT",
  270. reason="Validator errors cannot select a reliable retry location",
  271. context=self.context(),
  272. )
  273. self.assertEqual("failed", errored["status"])
  274. async def test_batch_replan_wins_over_later_ascend(self):
  275. await self.add_child("child-1")
  276. await self.add_child(
  277. "child-2",
  278. validation_outcome="passed",
  279. retry_from=None,
  280. )
  281. first = await review_task_result(
  282. child_trace_id="child-1",
  283. decision="REPLAN_CURRENT",
  284. reason="Replan after the rest of the batch is reviewed",
  285. context=self.context(),
  286. )
  287. self.assertEqual(1, first["pending_review_count"])
  288. parent = await self.store.get_trace(self.parent_id)
  289. self.assertEqual(1, len(ensure_task_protocol(parent.context)["pending_replans"]))
  290. final = await review_task_result(
  291. child_trace_id="child-2",
  292. decision="ASCEND",
  293. reason="This child itself passed",
  294. context=self.context(),
  295. )
  296. self.assertEqual("completed", final["status"])
  297. tree = await self.store.get_goal_tree(self.parent_id)
  298. self.assertEqual("in_progress", tree.find("1").status)
  299. self.assertEqual("1", tree.current_id)
  300. parent = await self.store.get_trace(self.parent_id)
  301. self.assertEqual([], ensure_task_protocol(parent.context)["pending_replans"])
  302. async def test_final_fail_overrides_queued_replan(self):
  303. await self.add_child("child-1")
  304. await self.add_child(
  305. "child-2",
  306. validation_outcome="error",
  307. retry_from=None,
  308. )
  309. await review_task_result(
  310. child_trace_id="child-1",
  311. decision="REPLAN_CURRENT",
  312. reason="Queue replan",
  313. context=self.context(),
  314. )
  315. result = await review_task_result(
  316. child_trace_id="child-2",
  317. decision="FAIL",
  318. reason="Validator failure makes this branch unrecoverable",
  319. context=self.context(),
  320. )
  321. self.assertEqual("completed", result["status"])
  322. tree = await self.store.get_goal_tree(self.parent_id)
  323. self.assertEqual("failed", tree.find("1").status)
  324. parent = await self.store.get_trace(self.parent_id)
  325. self.assertEqual([], ensure_task_protocol(parent.context)["pending_replans"])
  326. async def test_replan_state_can_be_rebuilt_after_rewind(self):
  327. await self.add_child("child-1")
  328. await self.add_child("child-2")
  329. await review_task_result(
  330. child_trace_id="child-1",
  331. decision="REPLAN_CURRENT",
  332. reason="Queue replan before rewind",
  333. context=self.context(),
  334. )
  335. parent = await self.store.get_trace(self.parent_id)
  336. state = ensure_task_protocol(parent.context)
  337. state["pending_replans"] = []
  338. rebuilt = rebuild_pending_replans(state)
  339. self.assertEqual(1, len(rebuilt))
  340. self.assertEqual("child-1", rebuilt[0]["child_trace_id"])
  341. self.assertEqual("hypothesis", rebuilt[0]["retry_from"])
  342. if __name__ == "__main__":
  343. unittest.main()