test_recursive_child_scheduling.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. import asyncio
  2. import tempfile
  3. import unittest
  4. from cyber_agent.core.agent_mode import (
  5. RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY,
  6. RECURSIVE_CHILD_EXECUTION_MODE_CONTEXT_KEY,
  7. RECURSIVE_MAX_PARALLEL_CHILDREN_CONTEXT_KEY,
  8. )
  9. from cyber_agent.core.runner import AgentRunner
  10. from cyber_agent.tools.builtin.subagent import agent
  11. from cyber_agent.trace.goal_models import GoalTree
  12. from cyber_agent.trace.models import Trace
  13. from cyber_agent.trace.store import FileSystemTraceStore
  14. class SchedulingToolRegistry:
  15. def get_tool_names(self, current_url=None, groups=None):
  16. return ["agent", "read_file"]
  17. class CountingRunner(AgentRunner):
  18. def __init__(self, store, delays=None):
  19. super().__init__(
  20. trace_store=store,
  21. tool_registry=SchedulingToolRegistry(),
  22. llm_call=lambda **kwargs: None,
  23. )
  24. self.delays = delays or {}
  25. self.active = 0
  26. self.max_active = 0
  27. self.start_order = []
  28. self.finish_order = []
  29. self.configs = []
  30. async def run_result(self, messages, config, on_event=None):
  31. self.configs.append(config)
  32. self.start_order.append(config.name)
  33. self.active += 1
  34. self.max_active = max(self.max_active, self.active)
  35. try:
  36. await asyncio.sleep(self.delays.get(config.name, 0.01))
  37. self.finish_order.append(config.name)
  38. await self.trace_store.update_trace(config.trace_id, status="completed")
  39. return {
  40. "status": "completed",
  41. "summary": f"result:{config.name}",
  42. "saved_knowledge_ids": [],
  43. "stats": {"total_messages": 1, "total_tokens": 1, "total_cost": 0.0},
  44. }
  45. finally:
  46. self.active -= 1
  47. event = self._cancel_events.get(config.trace_id)
  48. self.release_recursive_trace(config.trace_id, event)
  49. class RecursiveChildSchedulingTest(unittest.IsolatedAsyncioTestCase):
  50. async def asyncSetUp(self):
  51. self.temp_dir = tempfile.TemporaryDirectory()
  52. self.store = FileSystemTraceStore(self.temp_dir.name)
  53. async def asyncTearDown(self):
  54. self.temp_dir.cleanup()
  55. async def create_root(self, trace_id, mode="recursive"):
  56. await self.store.create_trace(Trace(
  57. trace_id=trace_id,
  58. mode="agent",
  59. task=trace_id,
  60. uid="user-1",
  61. model="fake",
  62. context={
  63. "agent_mode": mode,
  64. "agent_mode_revision": 1,
  65. "agent_depth": 0,
  66. "root_trace_id": trace_id,
  67. },
  68. ))
  69. await self.store.update_goal_tree(trace_id, GoalTree(mission=trace_id))
  70. def context(self, runner, trace_id, mode=None, maximum=None):
  71. context = {
  72. "store": self.store,
  73. "trace_id": trace_id,
  74. "runner": runner,
  75. RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY: ["agent", "read_file"],
  76. }
  77. if mode is not None:
  78. context[RECURSIVE_CHILD_EXECUTION_MODE_CONTEXT_KEY] = mode
  79. if maximum is not None:
  80. context[RECURSIVE_MAX_PARALLEL_CHILDREN_CONTEXT_KEY] = maximum
  81. return context
  82. async def test_recursive_defaults_to_strict_sequential(self):
  83. await self.create_root("sequential-root")
  84. runner = CountingRunner(self.store)
  85. result = await agent(
  86. task=["task-1", "task-2", "task-3"],
  87. context=self.context(runner, "sequential-root"),
  88. )
  89. self.assertEqual("completed", result["status"])
  90. self.assertEqual(1, runner.max_active)
  91. self.assertEqual(["task-1", "task-2", "task-3"], runner.start_order)
  92. async def test_parallel_mode_never_exceeds_two_and_keeps_input_order(self):
  93. await self.create_root("parallel-root")
  94. delays = {f"task-{index}": (7 - index) * 0.005 for index in range(1, 7)}
  95. runner = CountingRunner(self.store, delays)
  96. tasks = [f"task-{index}" for index in range(1, 7)]
  97. result = await agent(
  98. task=tasks,
  99. context=self.context(runner, "parallel-root", "parallel", 2),
  100. )
  101. self.assertEqual("completed", result["status"])
  102. self.assertEqual(2, runner.max_active)
  103. self.assertNotEqual(tasks, runner.finish_order)
  104. positions = [result["summary"].index(f"result:{task}") for task in tasks]
  105. self.assertEqual(sorted(positions), positions)
  106. self.assertTrue(all(config.child_execution_mode == "parallel" for config in runner.configs))
  107. self.assertTrue(all(config.max_parallel_children == 2 for config in runner.configs))
  108. async def test_invalid_recursive_settings_fail_before_trace_creation(self):
  109. invalid_values = [
  110. ("invalid", 2),
  111. ("parallel", 0),
  112. ("parallel", -1),
  113. ("parallel", True),
  114. ("parallel", 3),
  115. ]
  116. for index, (mode, maximum) in enumerate(invalid_values):
  117. root_id = f"invalid-{index}"
  118. await self.create_root(root_id)
  119. runner = CountingRunner(self.store)
  120. result = await agent(
  121. task=["one", "two"],
  122. context=self.context(runner, root_id, mode, maximum),
  123. )
  124. self.assertEqual("failed", result["status"])
  125. children = await self.store.list_traces(parent_trace_id=root_id, limit=10)
  126. self.assertEqual([], children)
  127. async def test_legacy_ignores_new_settings_and_keeps_batch_parallel(self):
  128. await self.create_root("legacy-root", mode="legacy")
  129. runner = CountingRunner(self.store)
  130. result = await agent(
  131. task=["one", "two", "three"],
  132. context=self.context(runner, "legacy-root", "invalid", 99),
  133. )
  134. self.assertEqual("completed", result["status"])
  135. self.assertEqual(3, runner.max_active)
  136. if __name__ == "__main__":
  137. unittest.main()