test_recursive_replan_context.py 17 KB

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