test_recursive_subtree_cancellation.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. import asyncio
  2. import json
  3. import os
  4. import tempfile
  5. import unittest
  6. from unittest.mock import AsyncMock, patch
  7. from cyber_agent.core.runner import AgentRunner, RunConfig
  8. from cyber_agent.core.task_protocol import ensure_task_protocol
  9. from cyber_agent.tools import get_tool_registry
  10. from cyber_agent.tools.builtin.subagent import _load_or_create_task_report
  11. from cyber_agent.trace import run_api
  12. from cyber_agent.trace.models import Trace
  13. from cyber_agent.trace.store import FileSystemTraceStore
  14. ROOT_ANCHOR = {
  15. "objective": "exercise recursive subtree cancellation",
  16. "completion_criteria": ["all stopped descendants are accounted for"],
  17. }
  18. def brief(index):
  19. return {
  20. "objective": f"child-{index}",
  21. "reason": "exercise subtree cancellation",
  22. "completion_criteria": [f"finish child-{index}"],
  23. "expected_outputs": [f"result for child-{index}"],
  24. }
  25. class RecursiveSubtreeCancellationTest(unittest.IsolatedAsyncioTestCase):
  26. async def asyncSetUp(self):
  27. self.temp_dir = tempfile.TemporaryDirectory()
  28. self.store = FileSystemTraceStore(self.temp_dir.name)
  29. async def asyncTearDown(self):
  30. self.temp_dir.cleanup()
  31. async def test_stop_child_only_cancels_its_descendants(self):
  32. runner = AgentRunner(
  33. trace_store=self.store,
  34. llm_call=AsyncMock(),
  35. )
  36. root_event = asyncio.Event()
  37. runner._cancel_events["root"] = root_event
  38. runner._recursive_active_traces["root"] = root_event
  39. child_event = runner.register_recursive_child("root", "child")
  40. grandchild_event = runner.register_recursive_child("child", "grandchild")
  41. sibling_event = runner.register_recursive_child("root", "sibling")
  42. self.assertTrue(await runner.stop("child"))
  43. self.assertTrue(child_event.is_set())
  44. self.assertTrue(grandchild_event.is_set())
  45. self.assertFalse(root_event.is_set())
  46. self.assertFalse(sibling_event.is_set())
  47. async def test_legacy_stop_keeps_single_trace_behavior(self):
  48. runner = AgentRunner(trace_store=self.store, llm_call=AsyncMock())
  49. root_event = asyncio.Event()
  50. child_event = asyncio.Event()
  51. runner._cancel_events.update({"legacy-root": root_event, "legacy-child": child_event})
  52. self.assertTrue(await runner.stop("legacy-root"))
  53. self.assertTrue(root_event.is_set())
  54. self.assertFalse(child_event.is_set())
  55. async def test_legacy_stop_during_llm_keeps_preexisting_completion_behavior(self):
  56. llm_started = asyncio.Event()
  57. release_llm = asyncio.Event()
  58. late_tool_calls = 0
  59. async def legacy_late_tool():
  60. nonlocal late_tool_calls
  61. late_tool_calls += 1
  62. return "called"
  63. registry = get_tool_registry()
  64. registry.register(legacy_late_tool, groups=["legacy-cancel-test"])
  65. async def fake_llm(**kwargs):
  66. llm_started.set()
  67. await release_llm.wait()
  68. return {
  69. "content": "legacy completed",
  70. "tool_calls": [{
  71. "id": "legacy-late-call",
  72. "type": "function",
  73. "function": {"name": "legacy_late_tool", "arguments": "{}"},
  74. }],
  75. "finish_reason": "tool_calls",
  76. }
  77. runner = AgentRunner(
  78. trace_store=self.store,
  79. tool_registry=registry,
  80. llm_call=fake_llm,
  81. )
  82. with patch.dict(os.environ, {"AGENT_MODE": "legacy"}, clear=False):
  83. task = asyncio.create_task(runner.run_result(
  84. messages=[{"role": "user", "content": "legacy"}],
  85. config=RunConfig(enable_research_flow=False),
  86. ))
  87. await asyncio.wait_for(llm_started.wait(), timeout=1)
  88. root = (await self.store.list_traces(limit=10))[0]
  89. self.assertTrue(await runner.stop(root.trace_id))
  90. release_llm.set()
  91. result = await asyncio.wait_for(task, timeout=2)
  92. self.assertEqual("stopped", result["status"])
  93. self.assertEqual("legacy completed", result["summary"])
  94. self.assertEqual(1, late_tool_calls)
  95. self.assertEqual("stopped", (await self.store.get_trace(root.trace_id)).status)
  96. async def test_stop_during_llm_discards_returned_tool_call(self):
  97. dangerous_calls = 0
  98. llm_started = asyncio.Event()
  99. release_llm = asyncio.Event()
  100. registry = get_tool_registry()
  101. async def fake_llm(**kwargs):
  102. llm_started.set()
  103. await release_llm.wait()
  104. return {
  105. "content": "late response",
  106. "tool_calls": [{
  107. "id": "late-tool",
  108. "type": "function",
  109. "function": {"name": "dangerous_cancel_test", "arguments": "{}"},
  110. }],
  111. "finish_reason": "tool_calls",
  112. }
  113. async def dangerous_cancel_test():
  114. nonlocal dangerous_calls
  115. dangerous_calls += 1
  116. return "called"
  117. registry.register(dangerous_cancel_test, groups=["cancel-test"])
  118. runner = AgentRunner(
  119. trace_store=self.store,
  120. tool_registry=registry,
  121. llm_call=fake_llm,
  122. )
  123. with patch.dict(os.environ, {"AGENT_MODE": "recursive"}, clear=False):
  124. task = asyncio.create_task(runner.run_result(
  125. messages=[{"role": "user", "content": "cancel while llm runs"}],
  126. config=RunConfig(
  127. tools=["dangerous_cancel_test"],
  128. tool_groups=[],
  129. enable_research_flow=False,
  130. root_task_anchor=ROOT_ANCHOR,
  131. ),
  132. ))
  133. await asyncio.wait_for(llm_started.wait(), timeout=1)
  134. traces = await self.store.list_traces(limit=10)
  135. root_id = traces[0].trace_id
  136. self.assertTrue(await runner.stop(root_id))
  137. release_llm.set()
  138. result = await asyncio.wait_for(task, timeout=2)
  139. self.assertEqual("stopped", result["status"])
  140. self.assertEqual("Agent execution stopped.", result["summary"])
  141. self.assertEqual(0, dangerous_calls)
  142. self.assertEqual("stopped", (await self.store.get_trace(root_id)).status)
  143. async def test_root_stop_cancels_running_and_queued_children_with_reports(self):
  144. child_llm_started = 0
  145. two_children_started = asyncio.Event()
  146. release_children = asyncio.Event()
  147. async def fake_llm(**kwargs):
  148. nonlocal child_llm_started
  149. messages = kwargs["messages"]
  150. user_text = "\n".join(
  151. str(message.get("content", ""))
  152. for message in messages
  153. if message.get("role") == "user"
  154. )
  155. if "root-cancel-batch" in user_text:
  156. return {
  157. "content": "",
  158. "tool_calls": [{
  159. "id": "delegate-batch",
  160. "type": "function",
  161. "function": {
  162. "name": "agent",
  163. "arguments": json.dumps({
  164. "task_brief": [brief(index) for index in range(4)],
  165. }),
  166. },
  167. }],
  168. "finish_reason": "tool_calls",
  169. }
  170. child_llm_started += 1
  171. if child_llm_started == 2:
  172. two_children_started.set()
  173. await release_children.wait()
  174. return {"content": "late child result", "finish_reason": "stop"}
  175. runner = AgentRunner(
  176. trace_store=self.store,
  177. tool_registry=get_tool_registry(),
  178. llm_call=fake_llm,
  179. )
  180. with patch.dict(os.environ, {"AGENT_MODE": "recursive"}, clear=False):
  181. task = asyncio.create_task(runner.run_result(
  182. messages=[{"role": "user", "content": "root-cancel-batch"}],
  183. config=RunConfig(
  184. tools=["agent"],
  185. tool_groups=[],
  186. child_execution_mode="parallel",
  187. max_parallel_children=2,
  188. enable_research_flow=False,
  189. root_task_anchor=ROOT_ANCHOR,
  190. ),
  191. ))
  192. await asyncio.wait_for(two_children_started.wait(), timeout=2)
  193. traces = await self.store.list_traces(limit=10)
  194. root = next(trace for trace in traces if trace.parent_trace_id is None)
  195. self.assertTrue(await runner.stop(root.trace_id))
  196. release_children.set()
  197. result = await asyncio.wait_for(task, timeout=3)
  198. traces = await self.store.list_traces(limit=10)
  199. children = [trace for trace in traces if trace.parent_trace_id == root.trace_id]
  200. self.assertEqual(4, len(children))
  201. self.assertEqual(2, child_llm_started)
  202. self.assertTrue(all(child.status == "stopped" for child in children))
  203. self.assertEqual(2, sum(child.total_messages == 0 for child in children))
  204. self.assertEqual("stopped", result["status"])
  205. persisted_root = await self.store.get_trace(root.trace_id)
  206. self.assertEqual("stopped", persisted_root.status)
  207. pending = ensure_task_protocol(persisted_root.context)["pending_reviews"]
  208. self.assertEqual(sorted(child.trace_id for child in children), list(pending))
  209. self.assertTrue(all(
  210. entry["task_report"]["outcome"] == "failed"
  211. for entry in pending.values()
  212. ))
  213. self.assertEqual({}, runner._cancel_events)
  214. self.assertEqual({}, runner._recursive_active_traces)
  215. self.assertEqual({}, runner._active_children)
  216. self.assertEqual({}, runner._active_parents)
  217. async def test_stopped_report_replaces_satisfied_report_only_once(self):
  218. child_id = "stale-report-child"
  219. context = {
  220. "agent_mode": "recursive",
  221. "agent_mode_revision": 2,
  222. "agent_depth": 1,
  223. "root_trace_id": "root",
  224. }
  225. state = ensure_task_protocol(context)
  226. state["task_report"] = {
  227. "child_trace_id": child_id,
  228. "summary": "previously satisfied",
  229. "outcome": "satisfied",
  230. "validation": {"hard_passed": True, "open_issues": []},
  231. "next_step_suggestion": {
  232. "direction": "NONE",
  233. "reason": "nothing else",
  234. },
  235. "outputs": [],
  236. "evidence": [],
  237. "remaining_issues": [],
  238. }
  239. await self.store.create_trace(Trace(
  240. trace_id=child_id,
  241. mode="agent",
  242. task="child",
  243. parent_trace_id="root",
  244. status="stopped",
  245. context=context,
  246. ))
  247. for _ in range(2):
  248. report = await _load_or_create_task_report(
  249. self.store,
  250. child_id,
  251. "stopped by parent",
  252. "stopped",
  253. 10,
  254. )
  255. self.assertEqual("failed", report.outcome)
  256. persisted = await self.store.get_trace(child_id)
  257. self.assertEqual(
  258. 1,
  259. len(ensure_task_protocol(persisted.context)["report_history"]),
  260. )
  261. async def test_stop_api_uses_the_runner_that_started_the_root(self):
  262. trace_id = "example-root"
  263. actual_runner = AsyncMock()
  264. actual_runner.trace_store = None
  265. actual_runner.stop.return_value = True
  266. run_api._running_runners[trace_id] = actual_runner
  267. try:
  268. response = await run_api.stop_trace(trace_id)
  269. finally:
  270. run_api._running_runners.pop(trace_id, None)
  271. run_api._running_tasks.pop(trace_id, None)
  272. actual_runner.stop.assert_awaited_once_with(trace_id)
  273. self.assertEqual("stopping", response.status)
  274. async def test_immediate_resume_waits_for_old_event_cleanup(self):
  275. trace_id = "resume-root"
  276. runner = AgentRunner(trace_store=self.store, llm_call=AsyncMock())
  277. old_event = asyncio.Event()
  278. old_event.set()
  279. runner._cancel_events[trace_id] = old_event
  280. runner._recursive_active_traces[trace_id] = old_event
  281. started = asyncio.Event()
  282. async def old_run():
  283. started.set()
  284. try:
  285. await asyncio.Event().wait()
  286. finally:
  287. runner.release_recursive_trace(trace_id, old_event)
  288. if run_api._running_runners.get(trace_id) is runner:
  289. run_api._running_runners.pop(trace_id, None)
  290. old_task = asyncio.create_task(old_run())
  291. run_api._running_tasks[trace_id] = old_task
  292. run_api._running_runners[trace_id] = runner
  293. await started.wait()
  294. await run_api._cancel_and_wait_for_running_task(trace_id)
  295. new_event = runner._cancel_events.setdefault(trace_id, asyncio.Event())
  296. self.assertIsNot(old_event, new_event)
  297. self.assertFalse(new_event.is_set())
  298. self.assertNotIn(trace_id, run_api._running_tasks)
  299. self.assertNotIn(trace_id, run_api._running_runners)
  300. runner._cancel_events.pop(trace_id, None)
  301. if __name__ == "__main__":
  302. unittest.main()