test_task_progress.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. import asyncio
  2. import tempfile
  3. import unittest
  4. from pydantic import ValidationError
  5. from cyber_agent.core.context_policy import (
  6. add_context_snapshot,
  7. create_context_snapshot,
  8. normalize_root_task_anchor,
  9. persist_root_task_anchor,
  10. replace_context_access,
  11. )
  12. from cyber_agent.core.resource_budget import (
  13. RESOURCE_BUDGET_CONTEXT_KEY,
  14. ResourceBudget,
  15. ResourceBudgetController,
  16. )
  17. from cyber_agent.core.run_snapshot import (
  18. RunConfigSnapshotV1,
  19. persist_run_config_snapshot,
  20. )
  21. from cyber_agent.core.runner import AgentRunner, RunConfig
  22. from cyber_agent.core.task_protocol import (
  23. Blocker,
  24. Finding,
  25. Hypothesis,
  26. Question,
  27. TaskProgress,
  28. TaskBrief,
  29. WorkItem,
  30. append_task_progress_revision,
  31. current_task_progress,
  32. initialize_task_progress,
  33. new_task_protocol,
  34. replace_task_brief,
  35. rewind_task_progress,
  36. task_contract_ref,
  37. task_progress_readiness_error,
  38. )
  39. from cyber_agent.core.task_protocol_service import TaskProtocolService
  40. from cyber_agent.core.validation import (
  41. ValidationPolicy,
  42. ValidatorSettings,
  43. persist_validation_policy,
  44. )
  45. from cyber_agent.trace.api import get_task_progress, set_trace_store
  46. from cyber_agent.trace.models import Message, Trace
  47. from cyber_agent.trace.store import FileSystemTraceStore
  48. ANCHOR = {
  49. "objective": "produce a verified result",
  50. "completion_criteria": ["the result is verified"],
  51. "constraints": ["cite evidence"],
  52. }
  53. def ready_progress() -> TaskProgress:
  54. return TaskProgress(
  55. phase="ready_to_submit",
  56. questions=[Question(item_id="q1", text="What is true?", state="answered", answer="12%")],
  57. findings=[Finding(item_id="f1", statement="The value is 12%", basis="official evidence")],
  58. hypotheses=[Hypothesis(item_id="h1", statement="The value is 12%", state="supported", rationale="official evidence")],
  59. work_items=[WorkItem(item_id="w1", description="verify value", state="done", result_summary="verified")],
  60. decision_rationale="Use the supported value.",
  61. )
  62. class TaskProgressModelTest(unittest.TestCase):
  63. def test_strict_models_reject_duplicate_ids_and_forbidden_fields(self):
  64. with self.assertRaises(ValidationError):
  65. TaskProgress.model_validate({"phase": "completed"})
  66. with self.assertRaises(ValidationError):
  67. TaskProgress.model_validate({"phase": "planning", "target": "copy"})
  68. with self.assertRaisesRegex(ValidationError, "globally unique"):
  69. TaskProgress(
  70. phase="planning",
  71. questions=[Question(item_id="same", text="q")],
  72. blockers=[Blocker(item_id="same", text="b")],
  73. )
  74. def test_item_state_requires_its_structured_result(self):
  75. with self.assertRaisesRegex(ValidationError, "requires answer"):
  76. Question(item_id="q", text="q", state="answered")
  77. with self.assertRaisesRegex(ValidationError, "requires resolution"):
  78. Blocker(item_id="b", text="b", state="resolved")
  79. with self.assertRaisesRegex(ValidationError, "requires rationale"):
  80. Hypothesis(item_id="h", statement="h", state="supported")
  81. with self.assertRaisesRegex(ValidationError, "requires result_summary"):
  82. WorkItem(item_id="w", description="w", state="done")
  83. def test_readiness_is_derived_without_copying_terminal_status(self):
  84. state = new_task_protocol()
  85. initialize_task_progress(state, root_task_anchor_hash="a" * 64)
  86. self.assertIn("ready_to_submit", task_progress_readiness_error(state))
  87. append_task_progress_revision(
  88. state,
  89. ready_progress(),
  90. expected_revision=1,
  91. effective_at_sequence=3,
  92. contract_ref=task_contract_ref(
  93. state,
  94. root_task_anchor_hash="a" * 64,
  95. ),
  96. )
  97. self.assertIsNone(task_progress_readiness_error(state))
  98. def test_rewind_follows_ancestry_not_largest_sequence(self):
  99. state = new_task_protocol()
  100. initialize_task_progress(state, root_task_anchor_hash="a" * 64)
  101. contract = task_contract_ref(state, root_task_anchor_hash="a" * 64)
  102. r2 = append_task_progress_revision(
  103. state,
  104. TaskProgress(phase="investigating"),
  105. expected_revision=1,
  106. effective_at_sequence=6,
  107. contract_ref=contract,
  108. )
  109. self.assertEqual(2, r2.revision)
  110. state["task_report_progress_revision"] = r2.revision
  111. rewind_task_progress(state, 4)
  112. self.assertIsNone(state["task_report_progress_revision"])
  113. r3 = append_task_progress_revision(
  114. state,
  115. TaskProgress(phase="executing"),
  116. expected_revision=1,
  117. effective_at_sequence=10,
  118. contract_ref=contract,
  119. )
  120. self.assertEqual(3, r3.revision)
  121. self.assertEqual(1, r3.parent_revision)
  122. rewind_task_progress(state, 7)
  123. self.assertEqual(1, current_task_progress(state).revision)
  124. self.assertEqual(3, len(state["task_progress_revisions"]))
  125. def test_new_brief_starts_empty_planning_revision_without_erasing_history(self):
  126. first_brief = TaskBrief(
  127. objective="check the first claim",
  128. reason="the parent needs evidence",
  129. completion_criteria=["reach a checked conclusion"],
  130. expected_outputs=["an evidence note"],
  131. )
  132. state = new_task_protocol(first_brief)
  133. initialize_task_progress(state, root_task_anchor_hash="a" * 64)
  134. append_task_progress_revision(
  135. state,
  136. TaskProgress(
  137. phase="investigating",
  138. findings=[Finding(
  139. item_id="old-finding",
  140. statement="old claim",
  141. basis="old evidence",
  142. )],
  143. ),
  144. expected_revision=1,
  145. effective_at_sequence=4,
  146. contract_ref=task_contract_ref(state),
  147. )
  148. replace_task_brief(
  149. state,
  150. first_brief.model_copy(update={
  151. "objective": "check the corrected claim",
  152. }),
  153. effective_at_sequence=8,
  154. )
  155. current = current_task_progress(state)
  156. self.assertEqual(3, current.revision)
  157. self.assertEqual(2, current.parent_revision)
  158. self.assertEqual("planning", current.phase)
  159. self.assertEqual([], current.findings)
  160. self.assertEqual(2, current.task_contract_ref.task_brief_version)
  161. self.assertEqual(3, len(state["task_progress_revisions"]))
  162. def test_validation_plan_hash_binds_exact_progress_revision(self):
  163. state = new_task_protocol()
  164. first = initialize_task_progress(
  165. state,
  166. root_task_anchor_hash="a" * 64,
  167. )
  168. second = append_task_progress_revision(
  169. state,
  170. TaskProgress(phase="investigating"),
  171. expected_revision=1,
  172. effective_at_sequence=2,
  173. contract_ref=task_contract_ref(
  174. state,
  175. root_task_anchor_hash="a" * 64,
  176. ),
  177. )
  178. policy = ValidationPolicy()
  179. common = {
  180. "task_brief": None,
  181. "task_brief_version": 0,
  182. "root_task_anchor": ANCHOR,
  183. "task_report": None,
  184. "candidate_output": "candidate",
  185. "evaluated_head_sequence": 3,
  186. "materials": [],
  187. "material_issues": [],
  188. "model_by_scope": {"root": "fake"},
  189. "root": True,
  190. }
  191. first_plan = policy.compile_plan(task_progress=first, **common)
  192. second_plan = policy.compile_plan(task_progress=second, **common)
  193. self.assertNotEqual(first_plan.plan_hash, second_plan.plan_hash)
  194. self.assertEqual(1, first_plan.task_progress_revision)
  195. self.assertEqual(2, second_plan.task_progress_revision)
  196. class TaskProtocolServiceTest(unittest.IsolatedAsyncioTestCase):
  197. async def asyncSetUp(self):
  198. self.temp_dir = tempfile.TemporaryDirectory()
  199. self.store = FileSystemTraceStore(self.temp_dir.name)
  200. context = {
  201. "agent_mode": "recursive",
  202. "agent_mode_revision": 3,
  203. "root_trace_id": "root",
  204. "task_protocol": new_task_protocol(),
  205. }
  206. anchor = normalize_root_task_anchor(ANCHOR)
  207. persist_root_task_anchor(context, anchor)
  208. initialize_task_progress(
  209. context["task_protocol"],
  210. root_task_anchor_hash=context["root_task_anchor_hash"],
  211. )
  212. replace_context_access(
  213. context,
  214. [],
  215. root_task_anchor=anchor,
  216. task_brief=None,
  217. )
  218. await self.store.create_trace(Trace(
  219. trace_id="root",
  220. mode="agent",
  221. task="test",
  222. uid="user-1",
  223. context=context,
  224. ))
  225. self.service = TaskProtocolService(self.store)
  226. async def prepare_resume_contract(self) -> tuple[AgentRunner, RunConfig]:
  227. trace = await self.store.get_trace("root")
  228. budget = ResourceBudget()
  229. trace.context[RESOURCE_BUDGET_CONTEXT_KEY] = budget.to_dict()
  230. persist_validation_policy(
  231. trace.context,
  232. ValidationPolicy(),
  233. ValidatorSettings(),
  234. )
  235. config = RunConfig(
  236. trace_id="root",
  237. model="fake",
  238. uid="user-1",
  239. tools=[],
  240. tool_groups=[],
  241. enable_research_flow=False,
  242. )
  243. persist_run_config_snapshot(
  244. trace.context,
  245. RunConfigSnapshotV1.from_run_config(
  246. config,
  247. memory_identity=None,
  248. ),
  249. )
  250. await self.store.update_trace(
  251. "root",
  252. context=trace.context,
  253. status="stopped",
  254. )
  255. await ResourceBudgetController(self.store).initialize("root", budget)
  256. return (
  257. AgentRunner(trace_store=self.store, llm_call=lambda **_: None),
  258. config,
  259. )
  260. async def asyncTearDown(self):
  261. self.temp_dir.cleanup()
  262. async def test_concurrent_same_revision_has_one_winner(self):
  263. results = await asyncio.gather(
  264. self.service.update_progress(
  265. "root",
  266. expected_revision=1,
  267. progress=TaskProgress(phase="investigating"),
  268. effective_at_sequence=2,
  269. ),
  270. self.service.update_progress(
  271. "root",
  272. expected_revision=1,
  273. progress=TaskProgress(phase="executing"),
  274. effective_at_sequence=2,
  275. ),
  276. return_exceptions=True,
  277. )
  278. self.assertEqual(1, sum(not isinstance(item, Exception) for item in results))
  279. self.assertEqual(1, sum("stale" in str(item) for item in results if isinstance(item, Exception)))
  280. trace = await self.store.get_trace("root")
  281. state = trace.context["task_protocol"]
  282. self.assertEqual(2, len(state["task_progress_revisions"]))
  283. async def test_read_projection_hides_other_protocol_state(self):
  284. set_trace_store(self.store)
  285. response = await get_task_progress("root", include_history=False)
  286. self.assertEqual(3, response.protocol_revision)
  287. self.assertEqual(1, response.revision_count)
  288. self.assertIsNone(response.revisions)
  289. self.assertNotIn("pending_reviews", response.model_dump())
  290. async def test_progress_rejects_context_ref_from_another_owner(self):
  291. trace = await self.store.get_trace("root")
  292. anchor = normalize_root_task_anchor(ANCHOR)
  293. snapshot = create_context_snapshot(
  294. kind="task_brief",
  295. summary="authorized evidence",
  296. source_trace_id="source",
  297. root_trace_id="root",
  298. uid="user-1",
  299. content={"claim": "12%"},
  300. granted_at_sequence=1,
  301. )
  302. ref = add_context_snapshot(
  303. trace.context,
  304. snapshot,
  305. root_task_anchor=anchor,
  306. task_brief=None,
  307. )
  308. await self.store.update_trace(
  309. "root",
  310. context=trace.context,
  311. uid="another-user",
  312. )
  313. with self.assertRaisesRegex(Exception, "another tree or owner"):
  314. await self.service.update_progress(
  315. "root",
  316. expected_revision=1,
  317. progress=TaskProgress(
  318. phase="investigating",
  319. findings=[Finding(
  320. item_id="finding",
  321. statement="The value is 12%",
  322. basis="authorized evidence",
  323. context_refs=[ref],
  324. )],
  325. ),
  326. effective_at_sequence=2,
  327. )
  328. persisted = await self.store.get_trace("root")
  329. self.assertEqual(
  330. 1,
  331. persisted.context["task_protocol"]["task_progress_head_revision"],
  332. )
  333. async def test_resume_rolls_back_progress_without_persisted_tool_result(self):
  334. runner, config = await self.prepare_resume_contract()
  335. await self.store.add_message(Message.create(
  336. trace_id="root",
  337. role="assistant",
  338. sequence=1,
  339. content={"tool_calls": [{"function": {"name": "update_task_progress"}}]},
  340. ))
  341. await self.service.update_progress(
  342. "root",
  343. expected_revision=1,
  344. progress=TaskProgress(phase="investigating"),
  345. effective_at_sequence=2,
  346. )
  347. await runner._prepare_existing_trace(config)
  348. persisted = await self.store.get_trace("root")
  349. state = persisted.context["task_protocol"]
  350. self.assertEqual(1, state["task_progress_head_revision"])
  351. self.assertEqual(2, len(state["task_progress_revisions"]))
  352. async def test_resume_rejects_report_bound_to_abandoned_progress_branch(self):
  353. runner, config = await self.prepare_resume_contract()
  354. trace = await self.store.get_trace("root")
  355. state = trace.context["task_protocol"]
  356. contract = task_contract_ref(
  357. state,
  358. root_task_anchor_hash=trace.context["root_task_anchor_hash"],
  359. )
  360. abandoned = append_task_progress_revision(
  361. state,
  362. TaskProgress(phase="investigating"),
  363. expected_revision=1,
  364. effective_at_sequence=1,
  365. contract_ref=contract,
  366. )
  367. rewind_task_progress(state, 0)
  368. active = append_task_progress_revision(
  369. state,
  370. TaskProgress(phase="executing"),
  371. expected_revision=1,
  372. effective_at_sequence=1,
  373. contract_ref=contract,
  374. )
  375. state["task_report"] = {"summary": "corrupt binding"}
  376. state["task_report_progress_revision"] = abandoned.revision
  377. await self.store.update_trace("root", context=trace.context)
  378. await self.store.add_message(Message.create(
  379. trace_id="root",
  380. role="assistant",
  381. sequence=1,
  382. content="persisted active branch",
  383. ))
  384. with self.assertRaisesRegex(ValueError, "outside the current revision ancestry"):
  385. await runner._prepare_existing_trace(config)
  386. self.assertEqual(3, active.revision)
  387. if __name__ == "__main__":
  388. unittest.main()