test_recursive_replan_context.py 17 KB

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