test_recursive_replan_context.py 14 KB

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