test_recursive_resource_budget.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  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(8, budget.reserved_final_calls)
  30. self.assertEqual(300, budget.max_validation_tool_calls)
  31. self.assertEqual(1_000_000, budget.max_validation_material_chars)
  32. def test_environment_is_strict_and_snapshot_round_trips(self):
  33. environ = {
  34. "AGENT_RESOURCE_BUDGET_ENABLED": "false",
  35. "AGENT_MAX_TOTAL_AGENTS": "8",
  36. "AGENT_MAX_LLM_CALLS": "12",
  37. "AGENT_MAX_TOTAL_TOKENS": "2000",
  38. "AGENT_MAX_TOTAL_COST_USD": "1.25",
  39. "AGENT_MAX_DURATION_SECONDS": "90",
  40. "AGENT_RESERVED_FINAL_CALLS": "2",
  41. "AGENT_MAX_VALIDATION_TOOL_CALLS": "18",
  42. "AGENT_MAX_VALIDATION_MATERIAL_CHARS": "9000",
  43. }
  44. budget = ResourceBudget.from_environment(environ)
  45. self.assertEqual(budget, ResourceBudget.from_dict(budget.to_dict()))
  46. self.assertEqual(18, budget.max_validation_tool_calls)
  47. self.assertEqual(9000, budget.max_validation_material_chars)
  48. invalid = dict(environ, AGENT_RESOURCE_BUDGET_ENABLED="yes")
  49. with self.assertRaisesRegex(ValueError, "must be 'true' or 'false'"):
  50. ResourceBudget.from_environment(invalid)
  51. for name, value in (
  52. ("AGENT_MAX_TOTAL_AGENTS", "0"),
  53. ("AGENT_MAX_LLM_CALLS", "1.2"),
  54. ("AGENT_MAX_TOTAL_TOKENS", "-1"),
  55. ("AGENT_MAX_TOTAL_COST_USD", "nan"),
  56. ("AGENT_MAX_DURATION_SECONDS", "none"),
  57. ("AGENT_RESERVED_FINAL_CALLS", "0"),
  58. ("AGENT_MAX_VALIDATION_TOOL_CALLS", "0"),
  59. ("AGENT_MAX_VALIDATION_MATERIAL_CHARS", "-1"),
  60. ):
  61. with self.subTest(name=name):
  62. invalid = dict(environ, **{name: value})
  63. with self.assertRaises(ValueError):
  64. ResourceBudget.from_environment(invalid)
  65. def test_reserved_calls_must_fit(self):
  66. with self.assertRaisesRegex(ValueError, "less than max_llm_calls"):
  67. ResourceBudget(max_llm_calls=2, reserved_final_calls=2)
  68. def test_resource_usage_rejects_inconsistent_or_unknown_state(self):
  69. usage = ResourceUsage.new(total_agents=1)
  70. data = usage.to_dict()
  71. data["total_tokens"] = 1
  72. with self.assertRaisesRegex(ResourceBudgetStateError, "total_tokens"):
  73. ResourceUsage.from_dict(data)
  74. data = usage.to_dict()
  75. data["future_field"] = True
  76. with self.assertRaisesRegex(ResourceBudgetStateError, "unknown"):
  77. ResourceUsage.from_dict(data)
  78. class ResourceBudgetControllerTest(unittest.IsolatedAsyncioTestCase):
  79. async def asyncSetUp(self):
  80. self.temp_dir = tempfile.TemporaryDirectory()
  81. self.store = FileSystemTraceStore(self.temp_dir.name)
  82. self.clock = MutableClock()
  83. self.controller = ResourceBudgetController(self.store, now=self.clock)
  84. self.root_trace_id = "root-budget-test"
  85. async def asyncTearDown(self):
  86. self.temp_dir.cleanup()
  87. async def test_initialization_is_idempotent_and_file_is_atomic_json(self):
  88. budget = ResourceBudget()
  89. first = await self.controller.initialize(self.root_trace_id, budget)
  90. second = await self.controller.initialize(
  91. self.root_trace_id, budget, initial_agents=9
  92. )
  93. self.assertEqual(first, second)
  94. self.assertEqual(1, second.total_agents)
  95. path = Path(self.temp_dir.name) / self.root_trace_id / "resource_usage.json"
  96. self.assertEqual(second.to_dict(), json.loads(path.read_text(encoding="utf-8")))
  97. self.assertEqual([], list(path.parent.glob(".resource_usage.*.tmp")))
  98. with self.assertRaises(TypeError):
  99. await self.store.replace_resource_usage(
  100. self.root_trace_id, {"not_json_serializable": object()}
  101. )
  102. self.assertEqual(second.to_dict(), json.loads(path.read_text(encoding="utf-8")))
  103. self.assertEqual([], list(path.parent.glob(".resource_usage.*.tmp")))
  104. async def test_missing_or_corrupt_usage_fails_closed(self):
  105. with self.assertRaisesRegex(ResourceBudgetStateError, "missing"):
  106. await self.controller.get_usage("missing-root")
  107. root_dir = Path(self.temp_dir.name) / self.root_trace_id
  108. root_dir.mkdir()
  109. (root_dir / "resource_usage.json").write_text("not-json", encoding="utf-8")
  110. with self.assertRaisesRegex(ResourceBudgetStateError, "cannot be read"):
  111. await self.controller.get_usage(self.root_trace_id)
  112. async def test_agent_batch_is_all_or_nothing(self):
  113. budget = ResourceBudget(max_total_agents=3)
  114. await self.controller.initialize(self.root_trace_id, budget)
  115. usage = await self.controller.reserve_agents(self.root_trace_id, budget, 2)
  116. self.assertEqual(3, usage.total_agents)
  117. with self.assertRaises(ResourceBudgetExceeded) as caught:
  118. await self.controller.reserve_agents(self.root_trace_id, budget, 1)
  119. self.assertEqual("agents", caught.exception.dimension)
  120. usage = await self.controller.get_usage(self.root_trace_id)
  121. self.assertEqual(3, usage.total_agents)
  122. self.assertEqual("budget_exhausted:agents", usage.exhausted_reason)
  123. async def test_uncreated_agent_reservation_can_be_released_only_to_root(self):
  124. budget = ResourceBudget(max_total_agents=5)
  125. await self.controller.initialize(self.root_trace_id, budget)
  126. await self.controller.reserve_agents(self.root_trace_id, budget, 3)
  127. usage = await self.controller.release_agents(self.root_trace_id, budget, 2)
  128. self.assertEqual(2, usage.total_agents)
  129. with self.assertRaises(ResourceBudgetStateError):
  130. await self.controller.release_agents(self.root_trace_id, budget, 2)
  131. async def test_concurrent_agent_reservations_never_cross_limit(self):
  132. budget = ResourceBudget(max_total_agents=4)
  133. await self.controller.initialize(self.root_trace_id, budget)
  134. results = await asyncio.gather(
  135. *(self.controller.reserve_agents(self.root_trace_id, budget, 1) for _ in range(8)),
  136. return_exceptions=True,
  137. )
  138. successes = [result for result in results if isinstance(result, ResourceUsage)]
  139. failures = [result for result in results if isinstance(result, ResourceBudgetExceeded)]
  140. self.assertEqual(3, len(successes))
  141. self.assertEqual(5, len(failures))
  142. self.assertEqual(4, (await self.controller.get_usage(self.root_trace_id)).total_agents)
  143. async def test_ordinary_calls_preserve_final_validator_slot(self):
  144. budget = ResourceBudget(max_llm_calls=4, reserved_final_calls=1)
  145. await self.controller.initialize(self.root_trace_id, budget)
  146. for _ in range(3):
  147. await self.controller.reserve_llm_call(self.root_trace_id, budget)
  148. with self.assertRaises(ResourceBudgetExceeded) as caught:
  149. await self.controller.reserve_llm_call(self.root_trace_id, budget)
  150. self.assertEqual("llm_calls", caught.exception.dimension)
  151. usage = await self.controller.reserve_llm_call(
  152. self.root_trace_id, budget, purpose="root_validator"
  153. )
  154. self.assertEqual(4, usage.llm_calls)
  155. with self.assertRaises(ResourceBudgetExceeded):
  156. await self.controller.reserve_llm_call(
  157. self.root_trace_id, budget, purpose="root_validator"
  158. )
  159. async def test_post_response_usage_is_persisted_before_exceeded(self):
  160. budget = ResourceBudget(
  161. max_total_tokens=10,
  162. max_total_cost_usd=1.0,
  163. )
  164. await self.controller.initialize(self.root_trace_id, budget)
  165. await self.controller.reserve_llm_call(self.root_trace_id, budget)
  166. with self.assertRaises(ResourceBudgetExceeded) as caught:
  167. await self.controller.record_llm_usage(
  168. self.root_trace_id,
  169. budget,
  170. prompt_tokens=8,
  171. completion_tokens=4,
  172. cost_usd=0.5,
  173. )
  174. self.assertEqual("tokens", caught.exception.dimension)
  175. usage = await self.controller.get_usage(self.root_trace_id)
  176. self.assertEqual(12, usage.total_tokens)
  177. self.assertEqual(0.5, usage.total_cost_usd)
  178. self.assertEqual("budget_exhausted:tokens", usage.exhausted_reason)
  179. with self.assertRaises(ResourceBudgetExceeded):
  180. await self.controller.reserve_agents(self.root_trace_id, budget, 1)
  181. with self.assertRaises(ResourceBudgetExceeded):
  182. await self.controller.reserve_llm_call(self.root_trace_id, budget)
  183. async def test_cost_and_duration_are_enforced(self):
  184. budget = ResourceBudget(max_total_cost_usd=0.1, max_duration_seconds=5)
  185. await self.controller.initialize(self.root_trace_id, budget)
  186. with self.assertRaises(ResourceBudgetExceeded) as caught:
  187. await self.controller.record_llm_usage(
  188. self.root_trace_id,
  189. budget,
  190. prompt_tokens=0,
  191. completion_tokens=0,
  192. cost_usd=0.2,
  193. )
  194. self.assertEqual("cost_usd", caught.exception.dimension)
  195. other = "duration-root"
  196. await self.controller.initialize(other, budget)
  197. self.clock.current += timedelta(seconds=6)
  198. with self.assertRaises(ResourceBudgetExceeded) as caught:
  199. await self.controller.check_time(other, budget)
  200. self.assertEqual("duration_seconds", caught.exception.dimension)
  201. async def test_external_llm_usage_is_recorded_before_call_limit_error(self):
  202. budget = ResourceBudget(max_llm_calls=2, reserved_final_calls=1)
  203. await self.controller.initialize(self.root_trace_id, budget)
  204. await self.controller.record_external_llm_usage(
  205. self.root_trace_id,
  206. budget,
  207. prompt_tokens=3,
  208. completion_tokens=2,
  209. cost_usd=0.01,
  210. )
  211. with self.assertRaises(ResourceBudgetExceeded) as caught:
  212. await self.controller.record_external_llm_usage(
  213. self.root_trace_id,
  214. budget,
  215. prompt_tokens=7,
  216. completion_tokens=4,
  217. cost_usd=0.02,
  218. )
  219. self.assertEqual("llm_calls", caught.exception.dimension)
  220. usage = await self.controller.get_usage(self.root_trace_id)
  221. self.assertEqual(2, usage.llm_calls)
  222. self.assertEqual(16, usage.total_tokens)
  223. self.assertAlmostEqual(0.03, usage.total_cost_usd)
  224. other = "external-then-validator"
  225. await self.controller.initialize(other, budget)
  226. await self.controller.record_external_llm_usage(
  227. other,
  228. budget,
  229. prompt_tokens=1,
  230. completion_tokens=1,
  231. cost_usd=0,
  232. )
  233. usage = await self.controller.reserve_llm_call(
  234. other, budget, purpose="root_validator"
  235. )
  236. self.assertEqual(2, usage.llm_calls)
  237. async def test_disabled_budget_records_usage_without_denial(self):
  238. budget = ResourceBudget(
  239. enabled=False,
  240. max_total_agents=1,
  241. max_llm_calls=2,
  242. max_total_tokens=1,
  243. max_total_cost_usd=0.01,
  244. max_duration_seconds=1,
  245. reserved_final_calls=1,
  246. )
  247. await self.controller.initialize(self.root_trace_id, budget)
  248. await self.controller.reserve_agents(self.root_trace_id, budget, 5)
  249. for _ in range(4):
  250. await self.controller.reserve_llm_call(self.root_trace_id, budget)
  251. await self.controller.record_llm_usage(
  252. self.root_trace_id,
  253. budget,
  254. prompt_tokens=100,
  255. completion_tokens=100,
  256. cost_usd=5.0,
  257. )
  258. self.clock.current += timedelta(seconds=100)
  259. usage = await self.controller.check_time(self.root_trace_id, budget)
  260. self.assertEqual(6, usage.total_agents)
  261. self.assertEqual(4, usage.llm_calls)
  262. self.assertEqual(200, usage.total_tokens)
  263. self.assertIsNone(usage.exhausted_reason)
  264. async def test_validation_tool_calls_are_atomically_limited(self):
  265. budget = ResourceBudget(max_validation_tool_calls=3)
  266. await self.controller.initialize(self.root_trace_id, budget)
  267. results = await asyncio.gather(
  268. *(
  269. self.controller.record_validation_usage(
  270. self.root_trace_id,
  271. budget,
  272. tool_calls=1,
  273. )
  274. for _ in range(6)
  275. ),
  276. return_exceptions=True,
  277. )
  278. self.assertEqual(3, sum(isinstance(item, ResourceUsage) for item in results))
  279. self.assertEqual(
  280. 3,
  281. (await self.controller.get_usage(self.root_trace_id)).validation_tool_calls,
  282. )
  283. self.assertTrue(any(
  284. isinstance(item, ResourceBudgetExceeded)
  285. and item.dimension == "validation_tool_calls"
  286. for item in results
  287. ))
  288. async def test_validation_material_overage_is_recorded_before_denial(self):
  289. budget = ResourceBudget(max_validation_material_chars=10)
  290. await self.controller.initialize(self.root_trace_id, budget)
  291. await self.controller.record_validation_usage(
  292. self.root_trace_id,
  293. budget,
  294. material_chars=8,
  295. )
  296. with self.assertRaises(ResourceBudgetExceeded) as caught:
  297. await self.controller.record_validation_usage(
  298. self.root_trace_id,
  299. budget,
  300. material_chars=5,
  301. )
  302. self.assertEqual("validation_material_chars", caught.exception.dimension)
  303. usage = await self.controller.get_usage(self.root_trace_id)
  304. self.assertEqual(13, usage.validation_material_chars)
  305. self.assertEqual(
  306. "budget_exhausted:validation_material_chars",
  307. usage.exhausted_reason,
  308. )
  309. async def test_validation_counters_do_not_consume_llm_or_agent_counts(self):
  310. budget = ResourceBudget()
  311. await self.controller.initialize(self.root_trace_id, budget)
  312. usage = await self.controller.record_validation_usage(
  313. self.root_trace_id,
  314. budget,
  315. tool_calls=2,
  316. material_chars=500,
  317. )
  318. self.assertEqual(1, usage.total_agents)
  319. self.assertEqual(0, usage.llm_calls)
  320. self.assertEqual(2, usage.validation_tool_calls)
  321. self.assertEqual(500, usage.validation_material_chars)
  322. async def test_provider_cost_uses_the_tree_cost_budget(self):
  323. budget = ResourceBudget(max_total_cost_usd=0.02)
  324. await self.controller.initialize(self.root_trace_id, budget)
  325. with self.assertRaises(ResourceBudgetExceeded) as caught:
  326. await self.controller.record_validation_usage(
  327. self.root_trace_id,
  328. budget,
  329. provider_cost_usd=0.03,
  330. )
  331. self.assertEqual("cost_usd", caught.exception.dimension)
  332. usage = await self.controller.get_usage(self.root_trace_id)
  333. self.assertAlmostEqual(0.03, usage.total_cost_usd)
  334. self.assertEqual("budget_exhausted:cost_usd", usage.exhausted_reason)
  335. async def test_disabled_budget_still_observes_validation_usage(self):
  336. budget = ResourceBudget(
  337. enabled=False,
  338. max_validation_tool_calls=1,
  339. max_validation_material_chars=1,
  340. )
  341. await self.controller.initialize(self.root_trace_id, budget)
  342. usage = await self.controller.record_validation_usage(
  343. self.root_trace_id,
  344. budget,
  345. tool_calls=3,
  346. material_chars=100,
  347. )
  348. self.assertEqual(3, usage.validation_tool_calls)
  349. self.assertEqual(100, usage.validation_material_chars)
  350. self.assertIsNone(usage.exhausted_reason)
  351. if __name__ == "__main__":
  352. unittest.main()