test_recursive_subtree_cancellation.py 13 KB

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