test_recursive_resource_budget.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. import asyncio
  2. from datetime import datetime, timedelta, timezone
  3. import json
  4. from pathlib import Path
  5. import tempfile
  6. import unittest
  7. from cyber_agent.core.resource_budget import (
  8. ResourceBudget,
  9. ResourceBudgetController,
  10. ResourceBudgetExceeded,
  11. ResourceBudgetStateError,
  12. ResourceUsage,
  13. )
  14. from cyber_agent.trace.store import FileSystemTraceStore
  15. class MutableClock:
  16. def __init__(self) -> None:
  17. self.current = datetime(2026, 1, 1, tzinfo=timezone.utc)
  18. def __call__(self) -> datetime:
  19. return self.current
  20. class ResourceBudgetModelTest(unittest.TestCase):
  21. def test_environment_defaults_are_demo_limits(self):
  22. budget = ResourceBudget.from_environment({})
  23. self.assertTrue(budget.enabled)
  24. self.assertEqual(50, budget.max_total_agents)
  25. self.assertEqual(150, budget.max_llm_calls)
  26. self.assertEqual(1_500_000, budget.max_total_tokens)
  27. self.assertEqual(15.0, budget.max_total_cost_usd)
  28. self.assertEqual(3_600, budget.max_duration_seconds)
  29. self.assertEqual(1, budget.reserved_final_calls)
  30. def test_environment_is_strict_and_snapshot_round_trips(self):
  31. environ = {
  32. "AGENT_RESOURCE_BUDGET_ENABLED": "false",
  33. "AGENT_MAX_TOTAL_AGENTS": "8",
  34. "AGENT_MAX_LLM_CALLS": "12",
  35. "AGENT_MAX_TOTAL_TOKENS": "2000",
  36. "AGENT_MAX_TOTAL_COST_USD": "1.25",
  37. "AGENT_MAX_DURATION_SECONDS": "90",
  38. "AGENT_RESERVED_FINAL_CALLS": "2",
  39. }
  40. budget = ResourceBudget.from_environment(environ)
  41. self.assertEqual(budget, ResourceBudget.from_dict(budget.to_dict()))
  42. invalid = dict(environ, AGENT_RESOURCE_BUDGET_ENABLED="yes")
  43. with self.assertRaisesRegex(ValueError, "must be 'true' or 'false'"):
  44. ResourceBudget.from_environment(invalid)
  45. for name, value in (
  46. ("AGENT_MAX_TOTAL_AGENTS", "0"),
  47. ("AGENT_MAX_LLM_CALLS", "1.2"),
  48. ("AGENT_MAX_TOTAL_TOKENS", "-1"),
  49. ("AGENT_MAX_TOTAL_COST_USD", "nan"),
  50. ("AGENT_MAX_DURATION_SECONDS", "none"),
  51. ("AGENT_RESERVED_FINAL_CALLS", "0"),
  52. ):
  53. with self.subTest(name=name):
  54. invalid = dict(environ, **{name: value})
  55. with self.assertRaises(ValueError):
  56. ResourceBudget.from_environment(invalid)
  57. def test_reserved_calls_must_fit(self):
  58. with self.assertRaisesRegex(ValueError, "less than max_llm_calls"):
  59. ResourceBudget(max_llm_calls=2, reserved_final_calls=2)
  60. def test_resource_usage_rejects_inconsistent_or_unknown_state(self):
  61. usage = ResourceUsage.new(total_agents=1)
  62. data = usage.to_dict()
  63. data["total_tokens"] = 1
  64. with self.assertRaisesRegex(ResourceBudgetStateError, "total_tokens"):
  65. ResourceUsage.from_dict(data)
  66. data = usage.to_dict()
  67. data["future_field"] = True
  68. with self.assertRaisesRegex(ResourceBudgetStateError, "unknown"):
  69. ResourceUsage.from_dict(data)
  70. class ResourceBudgetControllerTest(unittest.IsolatedAsyncioTestCase):
  71. async def asyncSetUp(self):
  72. self.temp_dir = tempfile.TemporaryDirectory()
  73. self.store = FileSystemTraceStore(self.temp_dir.name)
  74. self.clock = MutableClock()
  75. self.controller = ResourceBudgetController(self.store, now=self.clock)
  76. self.root_trace_id = "root-budget-test"
  77. async def asyncTearDown(self):
  78. self.temp_dir.cleanup()
  79. async def test_initialization_is_idempotent_and_file_is_atomic_json(self):
  80. budget = ResourceBudget()
  81. first = await self.controller.initialize(self.root_trace_id, budget)
  82. second = await self.controller.initialize(
  83. self.root_trace_id, budget, initial_agents=9
  84. )
  85. self.assertEqual(first, second)
  86. self.assertEqual(1, second.total_agents)
  87. path = Path(self.temp_dir.name) / self.root_trace_id / "resource_usage.json"
  88. self.assertEqual(second.to_dict(), json.loads(path.read_text(encoding="utf-8")))
  89. self.assertEqual([], list(path.parent.glob(".resource_usage.*.tmp")))
  90. with self.assertRaises(TypeError):
  91. await self.store.replace_resource_usage(
  92. self.root_trace_id, {"not_json_serializable": object()}
  93. )
  94. self.assertEqual(second.to_dict(), json.loads(path.read_text(encoding="utf-8")))
  95. self.assertEqual([], list(path.parent.glob(".resource_usage.*.tmp")))
  96. async def test_missing_or_corrupt_usage_fails_closed(self):
  97. with self.assertRaisesRegex(ResourceBudgetStateError, "missing"):
  98. await self.controller.get_usage("missing-root")
  99. root_dir = Path(self.temp_dir.name) / self.root_trace_id
  100. root_dir.mkdir()
  101. (root_dir / "resource_usage.json").write_text("not-json", encoding="utf-8")
  102. with self.assertRaisesRegex(ResourceBudgetStateError, "cannot be read"):
  103. await self.controller.get_usage(self.root_trace_id)
  104. async def test_agent_batch_is_all_or_nothing(self):
  105. budget = ResourceBudget(max_total_agents=3)
  106. await self.controller.initialize(self.root_trace_id, budget)
  107. usage = await self.controller.reserve_agents(self.root_trace_id, budget, 2)
  108. self.assertEqual(3, usage.total_agents)
  109. with self.assertRaises(ResourceBudgetExceeded) as caught:
  110. await self.controller.reserve_agents(self.root_trace_id, budget, 1)
  111. self.assertEqual("agents", caught.exception.dimension)
  112. usage = await self.controller.get_usage(self.root_trace_id)
  113. self.assertEqual(3, usage.total_agents)
  114. self.assertEqual("budget_exhausted:agents", usage.exhausted_reason)
  115. async def test_uncreated_agent_reservation_can_be_released_only_to_root(self):
  116. budget = ResourceBudget(max_total_agents=5)
  117. await self.controller.initialize(self.root_trace_id, budget)
  118. await self.controller.reserve_agents(self.root_trace_id, budget, 3)
  119. usage = await self.controller.release_agents(self.root_trace_id, budget, 2)
  120. self.assertEqual(2, usage.total_agents)
  121. with self.assertRaises(ResourceBudgetStateError):
  122. await self.controller.release_agents(self.root_trace_id, budget, 2)
  123. async def test_concurrent_agent_reservations_never_cross_limit(self):
  124. budget = ResourceBudget(max_total_agents=4)
  125. await self.controller.initialize(self.root_trace_id, budget)
  126. results = await asyncio.gather(
  127. *(self.controller.reserve_agents(self.root_trace_id, budget, 1) for _ in range(8)),
  128. return_exceptions=True,
  129. )
  130. successes = [result for result in results if isinstance(result, ResourceUsage)]
  131. failures = [result for result in results if isinstance(result, ResourceBudgetExceeded)]
  132. self.assertEqual(3, len(successes))
  133. self.assertEqual(5, len(failures))
  134. self.assertEqual(4, (await self.controller.get_usage(self.root_trace_id)).total_agents)
  135. async def test_ordinary_calls_preserve_final_validator_slot(self):
  136. budget = ResourceBudget(max_llm_calls=4, reserved_final_calls=1)
  137. await self.controller.initialize(self.root_trace_id, budget)
  138. for _ in range(3):
  139. await self.controller.reserve_llm_call(self.root_trace_id, budget)
  140. with self.assertRaises(ResourceBudgetExceeded) as caught:
  141. await self.controller.reserve_llm_call(self.root_trace_id, budget)
  142. self.assertEqual("llm_calls", caught.exception.dimension)
  143. usage = await self.controller.reserve_llm_call(
  144. self.root_trace_id, budget, purpose="root_validator"
  145. )
  146. self.assertEqual(4, usage.llm_calls)
  147. with self.assertRaises(ResourceBudgetExceeded):
  148. await self.controller.reserve_llm_call(
  149. self.root_trace_id, budget, purpose="root_validator"
  150. )
  151. async def test_post_response_usage_is_persisted_before_exceeded(self):
  152. budget = ResourceBudget(
  153. max_total_tokens=10,
  154. max_total_cost_usd=1.0,
  155. )
  156. await self.controller.initialize(self.root_trace_id, budget)
  157. await self.controller.reserve_llm_call(self.root_trace_id, budget)
  158. with self.assertRaises(ResourceBudgetExceeded) as caught:
  159. await self.controller.record_llm_usage(
  160. self.root_trace_id,
  161. budget,
  162. prompt_tokens=8,
  163. completion_tokens=4,
  164. cost_usd=0.5,
  165. )
  166. self.assertEqual("tokens", caught.exception.dimension)
  167. usage = await self.controller.get_usage(self.root_trace_id)
  168. self.assertEqual(12, usage.total_tokens)
  169. self.assertEqual(0.5, usage.total_cost_usd)
  170. self.assertEqual("budget_exhausted:tokens", usage.exhausted_reason)
  171. with self.assertRaises(ResourceBudgetExceeded):
  172. await self.controller.reserve_agents(self.root_trace_id, budget, 1)
  173. with self.assertRaises(ResourceBudgetExceeded):
  174. await self.controller.reserve_llm_call(self.root_trace_id, budget)
  175. async def test_cost_and_duration_are_enforced(self):
  176. budget = ResourceBudget(max_total_cost_usd=0.1, max_duration_seconds=5)
  177. await self.controller.initialize(self.root_trace_id, budget)
  178. with self.assertRaises(ResourceBudgetExceeded) as caught:
  179. await self.controller.record_llm_usage(
  180. self.root_trace_id,
  181. budget,
  182. prompt_tokens=0,
  183. completion_tokens=0,
  184. cost_usd=0.2,
  185. )
  186. self.assertEqual("cost_usd", caught.exception.dimension)
  187. other = "duration-root"
  188. await self.controller.initialize(other, budget)
  189. self.clock.current += timedelta(seconds=6)
  190. with self.assertRaises(ResourceBudgetExceeded) as caught:
  191. await self.controller.check_time(other, budget)
  192. self.assertEqual("duration_seconds", caught.exception.dimension)
  193. async def test_external_llm_usage_is_recorded_before_call_limit_error(self):
  194. budget = ResourceBudget(max_llm_calls=2, reserved_final_calls=1)
  195. await self.controller.initialize(self.root_trace_id, budget)
  196. await self.controller.record_external_llm_usage(
  197. self.root_trace_id,
  198. budget,
  199. prompt_tokens=3,
  200. completion_tokens=2,
  201. cost_usd=0.01,
  202. )
  203. with self.assertRaises(ResourceBudgetExceeded) as caught:
  204. await self.controller.record_external_llm_usage(
  205. self.root_trace_id,
  206. budget,
  207. prompt_tokens=7,
  208. completion_tokens=4,
  209. cost_usd=0.02,
  210. )
  211. self.assertEqual("llm_calls", caught.exception.dimension)
  212. usage = await self.controller.get_usage(self.root_trace_id)
  213. self.assertEqual(2, usage.llm_calls)
  214. self.assertEqual(16, usage.total_tokens)
  215. self.assertAlmostEqual(0.03, usage.total_cost_usd)
  216. other = "external-then-validator"
  217. await self.controller.initialize(other, budget)
  218. await self.controller.record_external_llm_usage(
  219. other,
  220. budget,
  221. prompt_tokens=1,
  222. completion_tokens=1,
  223. cost_usd=0,
  224. )
  225. usage = await self.controller.reserve_llm_call(
  226. other, budget, purpose="root_validator"
  227. )
  228. self.assertEqual(2, usage.llm_calls)
  229. async def test_disabled_budget_records_usage_without_denial(self):
  230. budget = ResourceBudget(
  231. enabled=False,
  232. max_total_agents=1,
  233. max_llm_calls=2,
  234. max_total_tokens=1,
  235. max_total_cost_usd=0.01,
  236. max_duration_seconds=1,
  237. )
  238. await self.controller.initialize(self.root_trace_id, budget)
  239. await self.controller.reserve_agents(self.root_trace_id, budget, 5)
  240. for _ in range(4):
  241. await self.controller.reserve_llm_call(self.root_trace_id, budget)
  242. await self.controller.record_llm_usage(
  243. self.root_trace_id,
  244. budget,
  245. prompt_tokens=100,
  246. completion_tokens=100,
  247. cost_usd=5.0,
  248. )
  249. self.clock.current += timedelta(seconds=100)
  250. usage = await self.controller.check_time(self.root_trace_id, budget)
  251. self.assertEqual(6, usage.total_agents)
  252. self.assertEqual(4, usage.llm_calls)
  253. self.assertEqual(200, usage.total_tokens)
  254. self.assertIsNone(usage.exhausted_reason)
  255. if __name__ == "__main__":
  256. unittest.main()