test_agent_mode.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. import os
  2. import tempfile
  3. import unittest
  4. from unittest.mock import AsyncMock, patch
  5. from cyber_agent.core.agent_mode import (
  6. AgentMode,
  7. assert_removed_config_absent,
  8. policy_from_context,
  9. policy_from_environment,
  10. )
  11. from cyber_agent.core.runner import AgentRunner, RunConfig
  12. from cyber_agent.tools.builtin.knowledge import KnowledgeConfig
  13. from cyber_agent.trace.goal_tool import goal_tool
  14. from cyber_agent.trace.goal_models import GoalTree
  15. from cyber_agent.trace.models import Trace
  16. from cyber_agent.trace.store import FileSystemTraceStore
  17. class AgentModePolicyTest(unittest.IsolatedAsyncioTestCase):
  18. async def asyncSetUp(self):
  19. self.temp_dir = tempfile.TemporaryDirectory()
  20. self.store = FileSystemTraceStore(self.temp_dir.name)
  21. self.runner = AgentRunner(trace_store=self.store)
  22. self.previous = {
  23. name: os.environ.get(name)
  24. for name in ("AGENT_MODE", "AGENT_RECURSION_ENABLED")
  25. }
  26. os.environ.pop("AGENT_MODE", None)
  27. os.environ.pop("AGENT_RECURSION_ENABLED", None)
  28. async def asyncTearDown(self):
  29. for name, value in self.previous.items():
  30. if value is None:
  31. os.environ.pop(name, None)
  32. else:
  33. os.environ[name] = value
  34. self.temp_dir.cleanup()
  35. async def _prepare_new(self, mode=None):
  36. if mode is None:
  37. os.environ.pop("AGENT_MODE", None)
  38. else:
  39. os.environ["AGENT_MODE"] = mode
  40. return await self.runner._prepare_trace(
  41. [{"role": "user", "content": "test"}],
  42. RunConfig(
  43. name="mode test",
  44. root_task_anchor={
  45. "objective": "return a checked answer",
  46. "completion_criteria": ["return a checked answer"],
  47. },
  48. ),
  49. )
  50. async def test_unset_defaults_to_legacy(self):
  51. trace, _, _ = await self._prepare_new()
  52. self.assertEqual("legacy", trace.context["agent_mode"])
  53. self.assertEqual(1, trace.context["agent_mode_revision"])
  54. self.assertEqual(0, trace.context["agent_depth"])
  55. self.assertEqual(trace.trace_id, trace.context["root_trace_id"])
  56. async def test_legacy_and_recursive_are_parsed_explicitly(self):
  57. self.assertIs(AgentMode.LEGACY, policy_from_environment().mode)
  58. os.environ["AGENT_MODE"] = "recursive"
  59. self.assertIs(AgentMode.RECURSIVE, policy_from_environment().mode)
  60. async def test_legacy_ignores_invalid_recursive_budget_environment(self):
  61. with patch.dict(
  62. os.environ,
  63. {
  64. "AGENT_MODE": "legacy",
  65. "AGENT_MAX_TOTAL_AGENTS": "not-a-number",
  66. "AGENT_RESOURCE_BUDGET_ENABLED": "not-a-boolean",
  67. "AGENT_MAX_VALIDATION_TOOL_CALLS": "not-a-number",
  68. "AGENT_VALIDATOR_SEARCH_PROVIDER": "unsupported",
  69. },
  70. clear=False,
  71. ):
  72. trace, _, _ = await self._prepare_new("legacy")
  73. self.assertEqual("legacy", trace.context["agent_mode"])
  74. self.assertNotIn("resource_budget", trace.context)
  75. async def test_recursive_rejects_invalid_validator_provider_before_trace(self):
  76. with patch.dict(
  77. os.environ,
  78. {
  79. "AGENT_MODE": "recursive",
  80. "AGENT_VALIDATOR_SEARCH_PROVIDER": "unsupported",
  81. },
  82. clear=False,
  83. ):
  84. with self.assertRaisesRegex(ValueError, "search_provider"):
  85. await self._prepare_new("recursive")
  86. self.assertEqual([], await self.store.list_traces(limit=10))
  87. async def test_invalid_mode_does_not_create_trace(self):
  88. with self.assertRaisesRegex(ValueError, "Invalid AGENT_MODE"):
  89. await self._prepare_new("unsupported")
  90. traces = await self.store.list_traces(limit=10)
  91. self.assertEqual([], traces)
  92. async def test_invalid_mode_is_rejected_before_task_name_generation(self):
  93. os.environ["AGENT_MODE"] = "unsupported"
  94. with patch.object(
  95. self.runner,
  96. "_generate_task_name",
  97. new=AsyncMock(return_value="should not run"),
  98. ) as generate_name:
  99. with self.assertRaisesRegex(ValueError, "Invalid AGENT_MODE"):
  100. await self.runner._prepare_trace(
  101. [{"role": "user", "content": "test"}],
  102. RunConfig(),
  103. )
  104. generate_name.assert_not_awaited()
  105. async def test_removed_recursion_variable_fails_explicitly(self):
  106. os.environ["AGENT_RECURSION_ENABLED"] = "true"
  107. with self.assertRaisesRegex(ValueError, "has been removed"):
  108. assert_removed_config_absent()
  109. with self.assertRaisesRegex(ValueError, "AGENT_MODE"):
  110. await self._prepare_new("recursive")
  111. async def test_existing_trace_keeps_persisted_mode_after_env_switch(self):
  112. trace, _, _ = await self._prepare_new("recursive")
  113. self.assertEqual(3, trace.context["agent_mode_revision"])
  114. self.assertTrue(policy_from_context(trace.context).requires_task_progress)
  115. self.assertIn("task_protocol", trace.context)
  116. os.environ["AGENT_MODE"] = "legacy"
  117. continued, _, _ = await self.runner._prepare_trace(
  118. [],
  119. RunConfig(trace_id=trace.trace_id, name="continue"),
  120. )
  121. self.assertEqual("recursive", continued.context["agent_mode"])
  122. persisted = await self.store.get_trace(trace.trace_id)
  123. self.assertEqual("recursive", persisted.context["agent_mode"])
  124. async def test_recursive_revision_two_is_read_only_but_still_parseable(self):
  125. trace = Trace(
  126. trace_id="recursive-v2-read-only",
  127. mode="agent",
  128. task="historical",
  129. context={"agent_mode": "recursive", "agent_mode_revision": 2},
  130. )
  131. await self.store.create_trace(trace)
  132. persisted = await self.store.get_trace(trace.trace_id)
  133. self.assertTrue(policy_from_context(persisted.context).is_read_only)
  134. with self.assertRaisesRegex(ValueError, "recursive_revision_2_read_only"):
  135. await self.runner._prepare_trace(
  136. [],
  137. RunConfig(trace_id=trace.trace_id, name="continue"),
  138. )
  139. async def test_unknown_persisted_revisions_fail_closed(self):
  140. for mode, revision in (("recursive", 4), ("recursive", 99), ("legacy", 2)):
  141. with self.subTest(mode=mode, revision=revision):
  142. context = {
  143. "agent_mode": mode,
  144. "agent_mode_revision": revision,
  145. }
  146. with self.assertRaisesRegex(
  147. ValueError,
  148. "unsupported_agent_mode_revision",
  149. ):
  150. policy_from_context(context)
  151. for revision in (4, 99):
  152. trace = Trace(
  153. trace_id=f"unknown-recursive-{revision}",
  154. mode="agent",
  155. task="must remain untouched",
  156. status="stopped",
  157. context={
  158. "agent_mode": "recursive",
  159. "agent_mode_revision": revision,
  160. },
  161. )
  162. await self.store.create_trace(trace)
  163. with self.assertRaisesRegex(
  164. ValueError,
  165. "unsupported_agent_mode_revision",
  166. ):
  167. await self.runner._prepare_trace(
  168. [],
  169. RunConfig(trace_id=trace.trace_id),
  170. )
  171. persisted = await self.store.get_trace(trace.trace_id)
  172. self.assertEqual("stopped", persisted.status)
  173. self.assertNotIn("run_config_snapshot", persisted.context)
  174. async def test_only_recursive_revision_three_requires_task_progress(self):
  175. self.assertFalse(policy_from_context({
  176. "agent_mode": "recursive",
  177. "agent_mode_revision": 1,
  178. }).requires_task_progress)
  179. self.assertFalse(policy_from_context({
  180. "agent_mode": "recursive",
  181. "agent_mode_revision": 2,
  182. }).requires_task_progress)
  183. self.assertTrue(policy_from_context({
  184. "agent_mode": "recursive",
  185. "agent_mode_revision": 3,
  186. }).requires_task_progress)
  187. async def test_historical_trace_is_persisted_as_legacy_on_continue(self):
  188. trace = Trace(trace_id="historical", mode="agent", task="old", context={})
  189. await self.store.create_trace(trace)
  190. os.environ["AGENT_MODE"] = "recursive"
  191. continued, _, _ = await self.runner._prepare_trace(
  192. [],
  193. RunConfig(trace_id=trace.trace_id, name="continue"),
  194. )
  195. self.assertEqual("legacy", continued.context["agent_mode"])
  196. self.assertIs(AgentMode.LEGACY, policy_from_context(continued.context).mode)
  197. async def test_legacy_goal_tree_keeps_automatic_sibling_and_parent_progress(self):
  198. tree = GoalTree(mission="legacy goals")
  199. parent = tree.add_goals(["parent"])[0]
  200. first, second = tree.add_goals(["first", "second"], parent_id=parent.id)
  201. tree.focus(parent.id)
  202. tree.focus(first.id)
  203. tree.complete(first.id, "done")
  204. self.assertEqual(second.id, tree.current_id)
  205. self.assertEqual("in_progress", parent.status)
  206. tree.complete(second.id, "done")
  207. self.assertEqual("completed", parent.status)
  208. self.assertEqual("所有子目标已完成", parent.summary)
  209. async def test_recursive_revision_2_focus_skips_remote_knowledge(self):
  210. trace_id = "recursive-v2-knowledge"
  211. await self.store.create_trace(Trace(
  212. trace_id=trace_id,
  213. mode="agent",
  214. task="recursive task",
  215. context={
  216. "agent_mode": "recursive",
  217. "agent_mode_revision": 2,
  218. },
  219. ))
  220. tree = GoalTree(mission="recursive goals")
  221. focused = tree.add_goals(["research one fact"])[0]
  222. await self.store.update_goal_tree(trace_id, tree)
  223. remote = AsyncMock(return_value={
  224. "status": "completed",
  225. "summary": "[knowledge-forged]",
  226. })
  227. with patch(
  228. "cyber_agent.tools.builtin.subagent._run_remote_agent",
  229. new=remote,
  230. ):
  231. result = await goal_tool(
  232. tree=tree,
  233. store=self.store,
  234. trace_id=trace_id,
  235. focus="1",
  236. knowledge_config=KnowledgeConfig(enable_injection=True),
  237. context={"sequence": 1},
  238. )
  239. self.assertIn("切换焦点", result)
  240. self.assertNotIn("已注入", result)
  241. self.assertEqual([], focused.knowledge)
  242. remote.assert_not_awaited()
  243. async def test_legacy_focus_keeps_remote_knowledge_injection(self):
  244. trace_id = "legacy-knowledge"
  245. await self.store.create_trace(Trace(
  246. trace_id=trace_id,
  247. mode="agent",
  248. task="legacy task",
  249. context={
  250. "agent_mode": "legacy",
  251. "agent_mode_revision": 1,
  252. },
  253. ))
  254. tree = GoalTree(mission="legacy goals")
  255. focused = tree.add_goals(["research one fact"])[0]
  256. await self.store.update_goal_tree(trace_id, tree)
  257. remote = AsyncMock(return_value={
  258. "status": "completed",
  259. "summary": "Use [knowledge-official] as evidence",
  260. })
  261. with patch(
  262. "cyber_agent.tools.builtin.subagent._run_remote_agent",
  263. new=remote,
  264. ):
  265. result = await goal_tool(
  266. tree=tree,
  267. store=self.store,
  268. trace_id=trace_id,
  269. focus="1",
  270. knowledge_config=KnowledgeConfig(enable_injection=True),
  271. context={"sequence": 1},
  272. )
  273. self.assertIn("已注入 1 条", result)
  274. self.assertEqual([{"id": "knowledge-official"}], focused.knowledge)
  275. remote.assert_awaited_once()
  276. if __name__ == "__main__":
  277. unittest.main()