test_recursive_tool_capabilities.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  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.core.task_protocol import ensure_task_protocol
  9. from cyber_agent.tools.builtin.subagent import agent
  10. from cyber_agent.tools.registry import ToolRegistry
  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 FakeToolRegistry:
  15. TOOL_NAMES = [
  16. "agent",
  17. "evaluate",
  18. "bash_command",
  19. "read_file",
  20. "write_file",
  21. "goal",
  22. ]
  23. def get_tool_names(self):
  24. return list(self.TOOL_NAMES)
  25. class RecordingRunner:
  26. def __init__(self, store):
  27. self.store = store
  28. self.tools = FakeToolRegistry()
  29. self.debug = False
  30. self.stdin_check = None
  31. self._cancel_events = {}
  32. self.configs = []
  33. async def run_result(self, messages, config, on_event=None):
  34. self.configs.append(config)
  35. await self.store.update_trace(config.trace_id, status="completed")
  36. return {
  37. "status": "completed",
  38. "summary": "done",
  39. "saved_knowledge_ids": [],
  40. "stats": {"total_messages": 1, "total_tokens": 1, "total_cost": 0.0},
  41. }
  42. class RecursiveCapabilityInheritanceTest(unittest.IsolatedAsyncioTestCase):
  43. async def asyncSetUp(self):
  44. self.temp_dir = tempfile.TemporaryDirectory()
  45. self.store = FileSystemTraceStore(self.temp_dir.name)
  46. self.runner = RecordingRunner(self.store)
  47. self.root_id = "capability-root"
  48. await self.store.create_trace(Trace(
  49. trace_id=self.root_id,
  50. mode="agent",
  51. task="root",
  52. uid="user-1",
  53. model="fake",
  54. context={
  55. "agent_mode": "recursive",
  56. "agent_mode_revision": 1,
  57. "agent_depth": 0,
  58. "root_trace_id": self.root_id,
  59. },
  60. ))
  61. await self.store.update_goal_tree(self.root_id, GoalTree(mission="root"))
  62. async def asyncTearDown(self):
  63. self.temp_dir.cleanup()
  64. def context(self, trace_id, capabilities=None):
  65. context = {
  66. "store": self.store,
  67. "trace_id": trace_id,
  68. "runner": self.runner,
  69. }
  70. if capabilities is not None:
  71. context[RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY] = capabilities
  72. return context
  73. async def test_child_and_grandchild_only_inherit_parent_capabilities(self):
  74. parent_capabilities = ["agent", "read_file"]
  75. child_result = await agent(
  76. task="child",
  77. context=self.context(self.root_id, parent_capabilities),
  78. )
  79. child_config = self.runner.configs[-1]
  80. self.assertEqual({"agent", "read_file"}, set(child_config.tools))
  81. self.assertNotIn("write_file", child_config.tools)
  82. grandchild_result = await agent(
  83. task="grandchild",
  84. context=self.context(child_result["sub_trace_id"], child_config.tools),
  85. )
  86. grandchild_config = self.runner.configs[-1]
  87. self.assertEqual({"agent", "read_file"}, set(grandchild_config.tools))
  88. self.assertNotIn("write_file", grandchild_config.tools)
  89. self.assertEqual(
  90. child_result["sub_trace_id"],
  91. (await self.store.get_trace(grandchild_result["sub_trace_id"])).parent_trace_id,
  92. )
  93. async def test_missing_recursive_capability_snapshot_fails_before_spawn(self):
  94. result = await agent(task="child", context=self.context(self.root_id))
  95. self.assertEqual("failed", result["status"])
  96. self.assertIn("capability context", result["error"])
  97. children = await self.store.list_traces(parent_trace_id=self.root_id, limit=10)
  98. self.assertEqual([], children)
  99. class RecursiveRuntimeDispatchTest(unittest.IsolatedAsyncioTestCase):
  100. async def test_recursive_framework_context_fields_cannot_be_overridden(self):
  101. with tempfile.TemporaryDirectory() as directory:
  102. store = FileSystemTraceStore(directory)
  103. runner = AgentRunner(trace_store=store, llm_call=lambda **kwargs: None)
  104. trace = Trace(
  105. trace_id="real-trace",
  106. mode="agent",
  107. task="test",
  108. context={"agent_mode": "recursive", "agent_mode_revision": 2},
  109. )
  110. goal_tree = GoalTree(mission="test")
  111. config = RunConfig(context={
  112. "store": "spoof-store",
  113. "trace_id": "spoof-trace",
  114. "goal_id": "spoof-goal",
  115. "runner": "spoof-runner",
  116. "goal_tree": "spoof-tree",
  117. "sequence": -1,
  118. RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY: ["spoof-tool"],
  119. })
  120. context = runner._build_tool_context(
  121. config=config,
  122. trace=trace,
  123. trace_id=trace.trace_id,
  124. goal_id="real-goal",
  125. goal_tree=goal_tree,
  126. sequence=7,
  127. side_branch_ctx=None,
  128. trigger_event=None,
  129. )
  130. self.assertIs(store, context["store"])
  131. self.assertEqual("real-trace", context["trace_id"])
  132. self.assertEqual("real-goal", context["goal_id"])
  133. self.assertIs(runner, context["runner"])
  134. self.assertIs(goal_tree, context["goal_tree"])
  135. self.assertEqual(7, context["sequence"])
  136. self.assertNotEqual(
  137. ["spoof-tool"],
  138. context[RECURSIVE_CAPABILITY_TOOLS_CONTEXT_KEY],
  139. )
  140. async def test_pending_review_runtime_allowlist_rejects_forged_agent(self):
  141. registry = ToolRegistry()
  142. agent_calls = 0
  143. async def agent():
  144. nonlocal agent_calls
  145. agent_calls += 1
  146. return "called"
  147. async def review_task_result():
  148. return "reviewed"
  149. registry.register(agent)
  150. registry.register(review_task_result)
  151. runner = AgentRunner(tool_registry=registry, llm_call=lambda **kwargs: None)
  152. trace = Trace(
  153. trace_id="pending-root",
  154. mode="agent",
  155. task="pending",
  156. context={"agent_mode": "recursive", "agent_mode_revision": 2},
  157. )
  158. state = ensure_task_protocol(trace.context)
  159. state["pending_reviews"]["child"] = {"task_report": {}}
  160. config = RunConfig(
  161. tools=["agent", "review_task_result"],
  162. tool_groups=[],
  163. )
  164. allowed = runner._get_runtime_tool_names(config, trace)
  165. self.assertEqual({"review_task_result"}, allowed)
  166. result = json.loads(await registry.execute(
  167. "agent",
  168. {},
  169. allowed_tool_names=allowed,
  170. ))
  171. self.assertIn("not allowed", result["error"])
  172. self.assertEqual(0, agent_calls)
  173. self.assertEqual(0, registry.get_stats("agent")["agent"]["call_count"])
  174. async def test_registry_rejects_before_counting_or_calling(self):
  175. registry = ToolRegistry()
  176. calls = 0
  177. async def dangerous():
  178. nonlocal calls
  179. calls += 1
  180. return "called"
  181. registry.register(dangerous)
  182. result = json.loads(await registry.execute(
  183. "dangerous",
  184. {},
  185. allowed_tool_names=set(),
  186. ))
  187. self.assertIn("not allowed", result["error"])
  188. self.assertEqual(0, calls)
  189. self.assertEqual(
  190. 0,
  191. registry.get_stats("dangerous")["dangerous"]["call_count"],
  192. )
  193. async def test_forged_tool_call_is_blocked_in_serial_and_parallel_dispatch(self):
  194. for parallel in (False, True):
  195. with self.subTest(parallel=parallel):
  196. temp_dir = tempfile.TemporaryDirectory()
  197. self.addCleanup(temp_dir.cleanup)
  198. store = FileSystemTraceStore(temp_dir.name)
  199. registry = ToolRegistry()
  200. dangerous_calls = 0
  201. async def safe():
  202. return "safe"
  203. async def dangerous():
  204. nonlocal dangerous_calls
  205. dangerous_calls += 1
  206. return "dangerous"
  207. registry.register(safe, groups=["test"])
  208. registry.register(dangerous, groups=["test"])
  209. llm_calls = 0
  210. async def fake_llm(**kwargs):
  211. nonlocal llm_calls
  212. llm_calls += 1
  213. if llm_calls == 1:
  214. return {
  215. "content": "",
  216. "tool_calls": [{
  217. "id": "forged-call",
  218. "type": "function",
  219. "function": {"name": "dangerous", "arguments": "{}"},
  220. }],
  221. "finish_reason": "tool_calls",
  222. }
  223. return {"content": "done", "finish_reason": "stop"}
  224. runner = AgentRunner(
  225. trace_store=store,
  226. tool_registry=registry,
  227. llm_call=fake_llm,
  228. )
  229. with patch.dict(os.environ, {"AGENT_MODE": "recursive"}, clear=False):
  230. result = await runner.run_result(
  231. messages=[{"role": "user", "content": "test"}],
  232. config=RunConfig(
  233. tools=["safe"],
  234. tool_groups=[],
  235. parallel_tool_execution=parallel,
  236. enable_research_flow=False,
  237. ),
  238. )
  239. self.assertEqual("completed", result["status"])
  240. self.assertEqual(0, dangerous_calls)
  241. self.assertEqual(
  242. 0,
  243. registry.get_stats("dangerous")["dangerous"]["call_count"],
  244. )
  245. if __name__ == "__main__":
  246. unittest.main()