test_recursive_subtree_cancellation.py 13 KB

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