test_recursive_tool_capabilities.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. import json
  2. import os
  3. import tempfile
  4. import unittest
  5. from unittest.mock import patch
  6. from cyber_agent.core.agent_mode import RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY
  7. from cyber_agent.core.runner import AgentRunner, RunConfig
  8. from cyber_agent.tools.builtin.subagent import agent
  9. from cyber_agent.tools.registry import ToolRegistry
  10. from cyber_agent.trace.goal_models import GoalTree
  11. from cyber_agent.trace.models import Trace
  12. from cyber_agent.trace.store import FileSystemTraceStore
  13. class FakeToolRegistry:
  14. TOOL_NAMES = [
  15. "agent",
  16. "evaluate",
  17. "bash_command",
  18. "read_file",
  19. "write_file",
  20. "goal",
  21. ]
  22. def get_tool_names(self):
  23. return list(self.TOOL_NAMES)
  24. class RecordingRunner:
  25. def __init__(self, store):
  26. self.store = store
  27. self.tools = FakeToolRegistry()
  28. self.debug = False
  29. self.stdin_check = None
  30. self._cancel_events = {}
  31. self.configs = []
  32. async def run_result(self, messages, config, on_event=None):
  33. self.configs.append(config)
  34. await self.store.update_trace(config.trace_id, status="completed")
  35. return {
  36. "status": "completed",
  37. "summary": "done",
  38. "saved_knowledge_ids": [],
  39. "stats": {"total_messages": 1, "total_tokens": 1, "total_cost": 0.0},
  40. }
  41. class RecursiveCapabilityInheritanceTest(unittest.IsolatedAsyncioTestCase):
  42. async def asyncSetUp(self):
  43. self.temp_dir = tempfile.TemporaryDirectory()
  44. self.store = FileSystemTraceStore(self.temp_dir.name)
  45. self.runner = RecordingRunner(self.store)
  46. self.root_id = "capability-root"
  47. await self.store.create_trace(Trace(
  48. trace_id=self.root_id,
  49. mode="agent",
  50. task="root",
  51. uid="user-1",
  52. model="fake",
  53. context={
  54. "agent_mode": "recursive",
  55. "agent_mode_revision": 1,
  56. "agent_depth": 0,
  57. "root_trace_id": self.root_id,
  58. },
  59. ))
  60. await self.store.update_goal_tree(self.root_id, GoalTree(mission="root"))
  61. async def asyncTearDown(self):
  62. self.temp_dir.cleanup()
  63. def context(self, trace_id, capabilities=None):
  64. context = {
  65. "store": self.store,
  66. "trace_id": trace_id,
  67. "runner": self.runner,
  68. }
  69. if capabilities is not None:
  70. context[RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY] = capabilities
  71. return context
  72. async def test_child_and_grandchild_only_inherit_parent_capabilities(self):
  73. parent_capabilities = ["agent", "read_file"]
  74. child_result = await agent(
  75. task="child",
  76. context=self.context(self.root_id, parent_capabilities),
  77. )
  78. child_config = self.runner.configs[-1]
  79. self.assertEqual({"agent", "read_file"}, set(child_config.tools))
  80. self.assertNotIn("write_file", child_config.tools)
  81. grandchild_result = await agent(
  82. task="grandchild",
  83. context=self.context(child_result["sub_trace_id"], child_config.tools),
  84. )
  85. grandchild_config = self.runner.configs[-1]
  86. self.assertEqual({"agent", "read_file"}, set(grandchild_config.tools))
  87. self.assertNotIn("write_file", grandchild_config.tools)
  88. self.assertEqual(
  89. child_result["sub_trace_id"],
  90. (await self.store.get_trace(grandchild_result["sub_trace_id"])).parent_trace_id,
  91. )
  92. async def test_missing_recursive_capability_snapshot_fails_before_spawn(self):
  93. result = await agent(task="child", context=self.context(self.root_id))
  94. self.assertEqual("failed", result["status"])
  95. self.assertIn("capability context", result["error"])
  96. children = await self.store.list_traces(parent_trace_id=self.root_id, limit=10)
  97. self.assertEqual([], children)
  98. class RecursiveRuntimeDispatchTest(unittest.IsolatedAsyncioTestCase):
  99. async def test_registry_rejects_before_counting_or_calling(self):
  100. registry = ToolRegistry()
  101. calls = 0
  102. async def dangerous():
  103. nonlocal calls
  104. calls += 1
  105. return "called"
  106. registry.register(dangerous)
  107. result = json.loads(await registry.execute(
  108. "dangerous",
  109. {},
  110. allowed_tool_names=set(),
  111. ))
  112. self.assertIn("not allowed", result["error"])
  113. self.assertEqual(0, calls)
  114. self.assertEqual(
  115. 0,
  116. registry.get_stats("dangerous")["dangerous"]["call_count"],
  117. )
  118. async def test_forged_tool_call_is_blocked_in_serial_and_parallel_dispatch(self):
  119. for parallel in (False, True):
  120. with self.subTest(parallel=parallel):
  121. temp_dir = tempfile.TemporaryDirectory()
  122. self.addCleanup(temp_dir.cleanup)
  123. store = FileSystemTraceStore(temp_dir.name)
  124. registry = ToolRegistry()
  125. dangerous_calls = 0
  126. async def safe():
  127. return "safe"
  128. async def dangerous():
  129. nonlocal dangerous_calls
  130. dangerous_calls += 1
  131. return "dangerous"
  132. registry.register(safe, groups=["test"])
  133. registry.register(dangerous, groups=["test"])
  134. llm_calls = 0
  135. async def fake_llm(**kwargs):
  136. nonlocal llm_calls
  137. llm_calls += 1
  138. if llm_calls == 1:
  139. return {
  140. "content": "",
  141. "tool_calls": [{
  142. "id": "forged-call",
  143. "type": "function",
  144. "function": {"name": "dangerous", "arguments": "{}"},
  145. }],
  146. "finish_reason": "tool_calls",
  147. }
  148. return {"content": "done", "finish_reason": "stop"}
  149. runner = AgentRunner(
  150. trace_store=store,
  151. tool_registry=registry,
  152. llm_call=fake_llm,
  153. )
  154. with patch.dict(os.environ, {"AGENT_MODE": "recursive"}, clear=False):
  155. result = await runner.run_result(
  156. messages=[{"role": "user", "content": "test"}],
  157. config=RunConfig(
  158. tools=["safe"],
  159. tool_groups=[],
  160. parallel_tool_execution=parallel,
  161. enable_research_flow=False,
  162. ),
  163. )
  164. self.assertEqual("completed", result["status"])
  165. self.assertEqual(0, dangerous_calls)
  166. self.assertEqual(
  167. 0,
  168. registry.get_stats("dangerous")["dangerous"]["call_count"],
  169. )
  170. if __name__ == "__main__":
  171. unittest.main()