test_task_progress.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  1. import asyncio
  2. from copy import deepcopy
  3. import tempfile
  4. import unittest
  5. from pydantic import ValidationError
  6. from cyber_agent.core.context_policy import (
  7. add_context_snapshot,
  8. create_context_snapshot,
  9. normalize_root_task_anchor,
  10. persist_root_task_anchor,
  11. replace_context_access,
  12. )
  13. from cyber_agent.core.resource_budget import (
  14. RESOURCE_BUDGET_CONTEXT_KEY,
  15. ResourceBudget,
  16. ResourceBudgetController,
  17. )
  18. from cyber_agent.core.run_snapshot import (
  19. RunConfigSnapshotV1,
  20. persist_run_config_snapshot,
  21. )
  22. from cyber_agent.core.runner import AgentRunner, RunConfig
  23. from cyber_agent.core.task_protocol import (
  24. Blocker,
  25. Finding,
  26. Hypothesis,
  27. Question,
  28. TaskProgress,
  29. TaskBrief,
  30. WorkItem,
  31. append_task_progress_revision,
  32. current_task_progress,
  33. initialize_task_progress,
  34. new_task_protocol,
  35. replace_task_brief,
  36. rewind_task_progress,
  37. task_contract_ref,
  38. task_progress_readiness_error,
  39. )
  40. from cyber_agent.core.task_protocol_service import TaskProtocolService
  41. from cyber_agent.tools.builtin.task_protocol import submit_task_report
  42. from cyber_agent.tools.builtin.subagent import _load_or_create_task_report
  43. from cyber_agent.core.validation import (
  44. ValidationPolicy,
  45. ValidatorSettings,
  46. persist_validation_policy,
  47. )
  48. from cyber_agent.trace.api import get_task_progress, set_trace_store
  49. from cyber_agent.trace.models import Message, Trace
  50. from cyber_agent.trace.store import FileSystemTraceStore
  51. ANCHOR = {
  52. "objective": "produce a verified result",
  53. "completion_criteria": ["the result is verified"],
  54. "constraints": ["cite evidence"],
  55. }
  56. def ready_progress() -> TaskProgress:
  57. return TaskProgress(
  58. phase="ready_to_submit",
  59. questions=[Question(item_id="q1", text="What is true?", state="answered", answer="12%")],
  60. findings=[Finding(item_id="f1", statement="The value is 12%", basis="official evidence")],
  61. hypotheses=[Hypothesis(item_id="h1", statement="The value is 12%", state="supported", rationale="official evidence")],
  62. work_items=[WorkItem(item_id="w1", description="verify value", state="done", result_summary="verified")],
  63. decision_rationale="Use the supported value.",
  64. )
  65. class TaskProgressModelTest(unittest.TestCase):
  66. def test_strict_models_reject_duplicate_ids_and_forbidden_fields(self):
  67. with self.assertRaises(ValidationError):
  68. TaskProgress.model_validate({"phase": "completed"})
  69. with self.assertRaises(ValidationError):
  70. TaskProgress.model_validate({"phase": "planning", "target": "copy"})
  71. with self.assertRaisesRegex(ValidationError, "globally unique"):
  72. TaskProgress(
  73. phase="planning",
  74. questions=[Question(item_id="same", text="q")],
  75. blockers=[Blocker(item_id="same", text="b")],
  76. )
  77. def test_item_state_requires_its_structured_result(self):
  78. with self.assertRaisesRegex(ValidationError, "requires answer"):
  79. Question(item_id="q", text="q", state="answered")
  80. with self.assertRaisesRegex(ValidationError, "requires resolution"):
  81. Blocker(item_id="b", text="b", state="resolved")
  82. with self.assertRaisesRegex(ValidationError, "requires rationale"):
  83. Hypothesis(item_id="h", statement="h", state="supported")
  84. with self.assertRaisesRegex(ValidationError, "requires result_summary"):
  85. WorkItem(item_id="w", description="w", state="done")
  86. def test_readiness_is_derived_without_copying_terminal_status(self):
  87. state = new_task_protocol()
  88. initialize_task_progress(state, root_task_anchor_hash="a" * 64)
  89. self.assertIn("ready_to_submit", task_progress_readiness_error(state))
  90. append_task_progress_revision(
  91. state,
  92. ready_progress(),
  93. expected_revision=1,
  94. effective_at_sequence=3,
  95. contract_ref=task_contract_ref(
  96. state,
  97. root_task_anchor_hash="a" * 64,
  98. ),
  99. )
  100. self.assertIsNone(task_progress_readiness_error(state))
  101. def test_rewind_follows_ancestry_not_largest_sequence(self):
  102. state = new_task_protocol()
  103. initialize_task_progress(state, root_task_anchor_hash="a" * 64)
  104. contract = task_contract_ref(state, root_task_anchor_hash="a" * 64)
  105. r2 = append_task_progress_revision(
  106. state,
  107. TaskProgress(phase="investigating"),
  108. expected_revision=1,
  109. effective_at_sequence=6,
  110. contract_ref=contract,
  111. )
  112. self.assertEqual(2, r2.revision)
  113. state["task_report_progress_revision"] = r2.revision
  114. rewind_task_progress(state, 4)
  115. self.assertIsNone(state["task_report_progress_revision"])
  116. r3 = append_task_progress_revision(
  117. state,
  118. TaskProgress(phase="executing"),
  119. expected_revision=1,
  120. effective_at_sequence=10,
  121. contract_ref=contract,
  122. )
  123. self.assertEqual(3, r3.revision)
  124. self.assertEqual(1, r3.parent_revision)
  125. rewind_task_progress(state, 7)
  126. self.assertEqual(1, current_task_progress(state).revision)
  127. self.assertEqual(3, len(state["task_progress_revisions"]))
  128. def test_new_brief_starts_empty_planning_revision_without_erasing_history(self):
  129. first_brief = TaskBrief(
  130. objective="check the first claim",
  131. reason="the parent needs evidence",
  132. completion_criteria=["reach a checked conclusion"],
  133. expected_outputs=["an evidence note"],
  134. )
  135. state = new_task_protocol(first_brief)
  136. initialize_task_progress(state, root_task_anchor_hash="a" * 64)
  137. append_task_progress_revision(
  138. state,
  139. TaskProgress(
  140. phase="investigating",
  141. findings=[Finding(
  142. item_id="old-finding",
  143. statement="old claim",
  144. basis="old evidence",
  145. )],
  146. ),
  147. expected_revision=1,
  148. effective_at_sequence=4,
  149. contract_ref=task_contract_ref(state),
  150. )
  151. replace_task_brief(
  152. state,
  153. first_brief.model_copy(update={
  154. "objective": "check the corrected claim",
  155. }),
  156. effective_at_sequence=8,
  157. )
  158. current = current_task_progress(state)
  159. self.assertEqual(3, current.revision)
  160. self.assertEqual(2, current.parent_revision)
  161. self.assertEqual("planning", current.phase)
  162. self.assertEqual([], current.findings)
  163. self.assertEqual(2, current.task_contract_ref.task_brief_version)
  164. self.assertEqual(3, len(state["task_progress_revisions"]))
  165. def test_validation_plan_hash_binds_exact_progress_revision(self):
  166. state = new_task_protocol()
  167. first = initialize_task_progress(
  168. state,
  169. root_task_anchor_hash="a" * 64,
  170. )
  171. second = append_task_progress_revision(
  172. state,
  173. TaskProgress(phase="investigating"),
  174. expected_revision=1,
  175. effective_at_sequence=2,
  176. contract_ref=task_contract_ref(
  177. state,
  178. root_task_anchor_hash="a" * 64,
  179. ),
  180. )
  181. policy = ValidationPolicy()
  182. common = {
  183. "task_brief": None,
  184. "task_brief_version": 0,
  185. "root_task_anchor": ANCHOR,
  186. "task_report": None,
  187. "candidate_output": "candidate",
  188. "evaluated_head_sequence": 3,
  189. "materials": [],
  190. "material_issues": [],
  191. "model_by_scope": {"root": "fake"},
  192. "root": True,
  193. }
  194. first_plan = policy.compile_plan(task_progress=first, **common)
  195. second_plan = policy.compile_plan(task_progress=second, **common)
  196. self.assertNotEqual(first_plan.plan_hash, second_plan.plan_hash)
  197. self.assertEqual(1, first_plan.task_progress_revision)
  198. self.assertEqual(2, second_plan.task_progress_revision)
  199. class TaskProtocolServiceTest(unittest.IsolatedAsyncioTestCase):
  200. async def asyncSetUp(self):
  201. self.temp_dir = tempfile.TemporaryDirectory()
  202. self.store = FileSystemTraceStore(self.temp_dir.name)
  203. context = {
  204. "agent_mode": "recursive",
  205. "agent_mode_revision": 3,
  206. "root_trace_id": "root",
  207. "task_protocol": new_task_protocol(),
  208. }
  209. anchor = normalize_root_task_anchor(ANCHOR)
  210. persist_root_task_anchor(context, anchor)
  211. initialize_task_progress(
  212. context["task_protocol"],
  213. root_task_anchor_hash=context["root_task_anchor_hash"],
  214. )
  215. replace_context_access(
  216. context,
  217. [],
  218. root_task_anchor=anchor,
  219. task_brief=None,
  220. )
  221. await self.store.create_trace(Trace(
  222. trace_id="root",
  223. mode="agent",
  224. task="test",
  225. uid="user-1",
  226. context=context,
  227. ))
  228. self.service = TaskProtocolService(self.store)
  229. async def prepare_resume_contract(self) -> tuple[AgentRunner, RunConfig]:
  230. trace = await self.store.get_trace("root")
  231. budget = ResourceBudget()
  232. trace.context[RESOURCE_BUDGET_CONTEXT_KEY] = budget.to_dict()
  233. persist_validation_policy(
  234. trace.context,
  235. ValidationPolicy(),
  236. ValidatorSettings(),
  237. )
  238. config = RunConfig(
  239. trace_id="root",
  240. model="fake",
  241. uid="user-1",
  242. tools=[],
  243. tool_groups=[],
  244. enable_research_flow=False,
  245. )
  246. persist_run_config_snapshot(
  247. trace.context,
  248. RunConfigSnapshotV1.from_run_config(
  249. config,
  250. memory_identity=None,
  251. ),
  252. )
  253. await self.store.update_trace(
  254. "root",
  255. context=trace.context,
  256. status="stopped",
  257. )
  258. await ResourceBudgetController(self.store).initialize("root", budget)
  259. return (
  260. AgentRunner(trace_store=self.store, llm_call=lambda **_: None),
  261. config,
  262. )
  263. async def asyncTearDown(self):
  264. self.temp_dir.cleanup()
  265. async def test_concurrent_same_revision_has_one_winner(self):
  266. results = await asyncio.gather(
  267. self.service.update_progress(
  268. "root",
  269. expected_revision=1,
  270. progress=TaskProgress(phase="investigating"),
  271. effective_at_sequence=2,
  272. ),
  273. self.service.update_progress(
  274. "root",
  275. expected_revision=1,
  276. progress=TaskProgress(phase="executing"),
  277. effective_at_sequence=2,
  278. ),
  279. return_exceptions=True,
  280. )
  281. self.assertEqual(1, sum(not isinstance(item, Exception) for item in results))
  282. self.assertEqual(1, sum("stale" in str(item) for item in results if isinstance(item, Exception)))
  283. trace = await self.store.get_trace("root")
  284. state = trace.context["task_protocol"]
  285. self.assertEqual(2, len(state["task_progress_revisions"]))
  286. async def test_progress_and_report_share_one_atomic_command_boundary(self):
  287. root = await self.store.get_trace("root")
  288. brief = TaskBrief(
  289. objective="prepare one partial result",
  290. reason="exercise concurrent protocol commands",
  291. completion_criteria=["record current state"],
  292. expected_outputs=["partial report"],
  293. )
  294. child_context = deepcopy(root.context)
  295. child_context["task_protocol"] = new_task_protocol(brief)
  296. initialize_task_progress(
  297. child_context["task_protocol"],
  298. effective_at_sequence=1,
  299. )
  300. await self.store.create_trace(Trace(
  301. trace_id="child",
  302. mode="agent",
  303. task="child",
  304. uid=root.uid,
  305. parent_trace_id="root",
  306. context=child_context,
  307. ))
  308. progress_result, report_result = await asyncio.gather(
  309. self.service.update_progress(
  310. "child",
  311. expected_revision=1,
  312. progress=TaskProgress(
  313. phase="investigating",
  314. decision_rationale="recorded before partial submission",
  315. ),
  316. effective_at_sequence=2,
  317. ),
  318. submit_task_report(
  319. task_report={
  320. "summary": "partial result",
  321. "outcome": "partial",
  322. "validation": {
  323. "hard_passed": False,
  324. "open_issues": ["work remains"],
  325. },
  326. "next_step_suggestion": {
  327. "direction": "NONE",
  328. "reason": "parent should decide after partial submission",
  329. },
  330. "remaining_issues": ["work remains"],
  331. },
  332. context={
  333. "store": self.store,
  334. "task_protocol_service": self.service,
  335. "trace_id": "child",
  336. "sequence": 3,
  337. },
  338. ),
  339. return_exceptions=True,
  340. )
  341. persisted = await self.store.get_trace("child")
  342. state = persisted.context["task_protocol"]
  343. if isinstance(progress_result, Exception):
  344. self.assertEqual(1, state["task_progress_head_revision"])
  345. else:
  346. self.assertEqual(2, state["task_progress_head_revision"])
  347. self.assertEqual(2, len(state["task_progress_revisions"]))
  348. if not isinstance(report_result, Exception):
  349. self.assertEqual("completed", report_result["status"], report_result)
  350. self.assertEqual(
  351. state["task_progress_head_revision"],
  352. state["task_report_progress_revision"],
  353. )
  354. async def test_progress_and_framework_report_cannot_overwrite_each_other(self):
  355. root = await self.store.get_trace("root")
  356. child_context = deepcopy(root.context)
  357. child_context["task_protocol"] = new_task_protocol(TaskBrief(
  358. objective="record a recoverable result",
  359. reason="exercise framework report convergence",
  360. completion_criteria=["preserve the latest progress revision"],
  361. expected_outputs=["one report"],
  362. ))
  363. initialize_task_progress(
  364. child_context["task_protocol"],
  365. effective_at_sequence=1,
  366. )
  367. await self.store.create_trace(Trace(
  368. trace_id="framework-child",
  369. mode="agent",
  370. uid=root.uid,
  371. parent_trace_id="root",
  372. context=child_context,
  373. ))
  374. progress_result, report = await asyncio.gather(
  375. self.service.update_progress(
  376. "framework-child",
  377. expected_revision=1,
  378. progress=TaskProgress(
  379. phase="investigating",
  380. decision_rationale="must not be lost by framework recovery",
  381. ),
  382. effective_at_sequence=2,
  383. ),
  384. _load_or_create_task_report(
  385. self.store,
  386. "framework-child",
  387. "simulated child interruption",
  388. "failed",
  389. 3,
  390. task_protocol_service=self.service,
  391. ),
  392. return_exceptions=True,
  393. )
  394. persisted = await self.store.get_trace("framework-child")
  395. state = persisted.context["task_protocol"]
  396. self.assertEqual("protocol_error", report.outcome)
  397. if isinstance(progress_result, Exception):
  398. self.assertEqual(1, state["task_progress_head_revision"])
  399. else:
  400. self.assertEqual(2, state["task_progress_head_revision"])
  401. self.assertEqual(2, len(state["task_progress_revisions"]))
  402. self.assertEqual(
  403. state["task_progress_head_revision"],
  404. state["task_report_progress_revision"],
  405. )
  406. async def test_read_projection_hides_other_protocol_state(self):
  407. set_trace_store(self.store)
  408. response = await get_task_progress("root", include_history=False)
  409. self.assertEqual(3, response.protocol_revision)
  410. self.assertEqual(1, response.revision_count)
  411. self.assertIsNone(response.revisions)
  412. self.assertNotIn("pending_reviews", response.model_dump())
  413. async def test_progress_rejects_context_ref_from_another_owner(self):
  414. trace = await self.store.get_trace("root")
  415. anchor = normalize_root_task_anchor(ANCHOR)
  416. snapshot = create_context_snapshot(
  417. kind="task_brief",
  418. summary="authorized evidence",
  419. source_trace_id="source",
  420. root_trace_id="root",
  421. uid="user-1",
  422. content={"claim": "12%"},
  423. granted_at_sequence=1,
  424. )
  425. ref = add_context_snapshot(
  426. trace.context,
  427. snapshot,
  428. root_task_anchor=anchor,
  429. task_brief=None,
  430. )
  431. await self.store.update_trace(
  432. "root",
  433. context=trace.context,
  434. uid="another-user",
  435. )
  436. with self.assertRaisesRegex(Exception, "another tree or owner"):
  437. await self.service.update_progress(
  438. "root",
  439. expected_revision=1,
  440. progress=TaskProgress(
  441. phase="investigating",
  442. findings=[Finding(
  443. item_id="finding",
  444. statement="The value is 12%",
  445. basis="authorized evidence",
  446. context_refs=[ref],
  447. )],
  448. ),
  449. effective_at_sequence=2,
  450. )
  451. persisted = await self.store.get_trace("root")
  452. self.assertEqual(
  453. 1,
  454. persisted.context["task_protocol"]["task_progress_head_revision"],
  455. )
  456. async def test_resume_rolls_back_progress_without_persisted_tool_result(self):
  457. runner, config = await self.prepare_resume_contract()
  458. await self.store.add_message(Message.create(
  459. trace_id="root",
  460. role="assistant",
  461. sequence=1,
  462. content={"tool_calls": [{"function": {"name": "update_task_progress"}}]},
  463. ))
  464. await self.service.update_progress(
  465. "root",
  466. expected_revision=1,
  467. progress=TaskProgress(phase="investigating"),
  468. effective_at_sequence=2,
  469. )
  470. await runner._prepare_existing_trace(config)
  471. persisted = await self.store.get_trace("root")
  472. state = persisted.context["task_protocol"]
  473. self.assertEqual(1, state["task_progress_head_revision"])
  474. self.assertEqual(2, len(state["task_progress_revisions"]))
  475. async def test_revision_three_resume_rejects_missing_or_partial_protocol(self):
  476. runner, config = await self.prepare_resume_contract()
  477. trace = await self.store.get_trace("root")
  478. trace.context.pop("task_protocol")
  479. await self.store.update_trace("root", context=trace.context)
  480. with self.assertRaisesRegex(ValueError, "task_protocol is missing"):
  481. await runner._prepare_existing_trace(config)
  482. persisted = await self.store.get_trace("root")
  483. self.assertNotIn("task_protocol", persisted.context)
  484. self.assertEqual("stopped", persisted.status)
  485. trace.context["task_protocol"] = new_task_protocol()
  486. trace.context["task_protocol"].pop("task_progress_revisions")
  487. await self.store.update_trace("root", context=trace.context)
  488. with self.assertRaisesRegex(ValueError, "missing required fields"):
  489. await runner._prepare_existing_trace(config)
  490. async def test_resume_rejects_report_bound_to_abandoned_progress_branch(self):
  491. runner, config = await self.prepare_resume_contract()
  492. trace = await self.store.get_trace("root")
  493. state = trace.context["task_protocol"]
  494. contract = task_contract_ref(
  495. state,
  496. root_task_anchor_hash=trace.context["root_task_anchor_hash"],
  497. )
  498. abandoned = append_task_progress_revision(
  499. state,
  500. TaskProgress(phase="investigating"),
  501. expected_revision=1,
  502. effective_at_sequence=1,
  503. contract_ref=contract,
  504. )
  505. rewind_task_progress(state, 0)
  506. active = append_task_progress_revision(
  507. state,
  508. TaskProgress(phase="executing"),
  509. expected_revision=1,
  510. effective_at_sequence=1,
  511. contract_ref=contract,
  512. )
  513. state["task_report"] = {"summary": "corrupt binding"}
  514. state["task_report_progress_revision"] = abandoned.revision
  515. await self.store.update_trace("root", context=trace.context)
  516. await self.store.add_message(Message.create(
  517. trace_id="root",
  518. role="assistant",
  519. sequence=1,
  520. content="persisted active branch",
  521. ))
  522. with self.assertRaisesRegex(ValueError, "outside the current revision ancestry"):
  523. await runner._prepare_existing_trace(config)
  524. self.assertEqual(3, active.revision)
  525. if __name__ == "__main__":
  526. unittest.main()