resource_budget.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795
  1. """Recursive Agent 任务树的单进程资源预算。
  2. Runner 创建根 Trace 时只读取一次环境配置,将 ResourceBudget 快照固化在根 Trace;
  3. Runner 的模型调用、Sub-Agent 创建和 Validator 沿用同一快照。动态用量由 TraceStore
  4. 单独持久化,ResourceBudgetController 以每个根 Trace 一把进程内异步锁保护。
  5. """
  6. from __future__ import annotations
  7. import asyncio
  8. from dataclasses import asdict, dataclass
  9. from datetime import datetime, timezone
  10. import math
  11. import os
  12. from typing import Any, Callable, Literal, Mapping
  13. from weakref import WeakValueDictionary
  14. from cyber_agent.trace.protocols import TraceStore
  15. RESOURCE_BUDGET_CONTEXT_KEY = "resource_budget"
  16. RESOURCE_BUDGET_ENABLED_ENV = "AGENT_RESOURCE_BUDGET_ENABLED"
  17. MAX_TOTAL_AGENTS_ENV = "AGENT_MAX_TOTAL_AGENTS"
  18. MAX_LLM_CALLS_ENV = "AGENT_MAX_LLM_CALLS"
  19. MAX_TOTAL_TOKENS_ENV = "AGENT_MAX_TOTAL_TOKENS"
  20. MAX_TOTAL_COST_USD_ENV = "AGENT_MAX_TOTAL_COST_USD"
  21. MAX_DURATION_SECONDS_ENV = "AGENT_MAX_DURATION_SECONDS"
  22. RESERVED_FINAL_CALLS_ENV = "AGENT_RESERVED_FINAL_CALLS"
  23. MAX_VALIDATION_TOOL_CALLS_ENV = "AGENT_MAX_VALIDATION_TOOL_CALLS"
  24. MAX_VALIDATION_MATERIAL_CHARS_ENV = "AGENT_MAX_VALIDATION_MATERIAL_CHARS"
  25. DEFAULT_MAX_TOTAL_AGENTS = 50
  26. DEFAULT_MAX_LLM_CALLS = 150
  27. DEFAULT_MAX_TOTAL_TOKENS = 1_500_000
  28. DEFAULT_MAX_TOTAL_COST_USD = 15.0
  29. DEFAULT_MAX_DURATION_SECONDS = 3_600
  30. DEFAULT_RESERVED_FINAL_CALLS = 8
  31. DEFAULT_MAX_VALIDATION_TOOL_CALLS = 300
  32. DEFAULT_MAX_VALIDATION_MATERIAL_CHARS = 1_000_000
  33. BudgetDimension = Literal[
  34. "agents",
  35. "llm_calls",
  36. "tokens",
  37. "cost_usd",
  38. "duration_seconds",
  39. "validation_tool_calls",
  40. "validation_material_chars",
  41. ]
  42. LLMCallPurpose = Literal["ordinary", "root_validator"]
  43. def _utc_now() -> datetime:
  44. return datetime.now(timezone.utc)
  45. def _strict_bool(environ: Mapping[str, str], name: str, default: bool) -> bool:
  46. raw = environ.get(name)
  47. if raw is None:
  48. return default
  49. normalized = raw.strip().lower()
  50. if normalized == "true":
  51. return True
  52. if normalized == "false":
  53. return False
  54. raise ValueError(f"{name} must be 'true' or 'false', got {raw!r}")
  55. def _positive_int(environ: Mapping[str, str], name: str, default: int) -> int:
  56. raw = environ.get(name)
  57. if raw is None:
  58. return default
  59. try:
  60. value = int(raw)
  61. except (TypeError, ValueError) as exc:
  62. raise ValueError(f"{name} must be a positive integer, got {raw!r}") from exc
  63. if value <= 0:
  64. raise ValueError(f"{name} must be a positive integer, got {raw!r}")
  65. return value
  66. def _positive_float(
  67. environ: Mapping[str, str],
  68. name: str,
  69. default: float,
  70. ) -> float:
  71. raw = environ.get(name)
  72. if raw is None:
  73. return default
  74. try:
  75. value = float(raw)
  76. except (TypeError, ValueError) as exc:
  77. raise ValueError(f"{name} must be a positive number, got {raw!r}") from exc
  78. if not math.isfinite(value) or value <= 0:
  79. raise ValueError(f"{name} must be a positive number, got {raw!r}")
  80. return value
  81. @dataclass(frozen=True)
  82. class ResourceBudget:
  83. """一棵 Recursive 任务树不可变的资源上限快照。
  84. `AgentRunner._prepare_new_trace` 从环境创建它并写入根 Trace,子孙执行始终从根 Trace 恢复。
  85. """
  86. enabled: bool = True
  87. max_total_agents: int = DEFAULT_MAX_TOTAL_AGENTS
  88. max_llm_calls: int = DEFAULT_MAX_LLM_CALLS
  89. max_total_tokens: int = DEFAULT_MAX_TOTAL_TOKENS
  90. max_total_cost_usd: float = DEFAULT_MAX_TOTAL_COST_USD
  91. max_duration_seconds: int = DEFAULT_MAX_DURATION_SECONDS
  92. reserved_final_calls: int = DEFAULT_RESERVED_FINAL_CALLS
  93. max_validation_tool_calls: int = DEFAULT_MAX_VALIDATION_TOOL_CALLS
  94. max_validation_material_chars: int = DEFAULT_MAX_VALIDATION_MATERIAL_CHARS
  95. def __post_init__(self) -> None:
  96. if not isinstance(self.enabled, bool):
  97. raise ValueError("enabled must be a boolean")
  98. integer_limits = {
  99. "max_total_agents": self.max_total_agents,
  100. "max_llm_calls": self.max_llm_calls,
  101. "max_total_tokens": self.max_total_tokens,
  102. "max_duration_seconds": self.max_duration_seconds,
  103. "reserved_final_calls": self.reserved_final_calls,
  104. "max_validation_tool_calls": self.max_validation_tool_calls,
  105. "max_validation_material_chars": self.max_validation_material_chars,
  106. }
  107. for name, value in integer_limits.items():
  108. if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
  109. raise ValueError(f"{name} must be a positive integer")
  110. if (
  111. isinstance(self.max_total_cost_usd, bool)
  112. or not isinstance(self.max_total_cost_usd, (int, float))
  113. or not math.isfinite(float(self.max_total_cost_usd))
  114. or self.max_total_cost_usd <= 0
  115. ):
  116. raise ValueError("max_total_cost_usd must be a positive number")
  117. if self.reserved_final_calls >= self.max_llm_calls:
  118. raise ValueError("reserved_final_calls must be less than max_llm_calls")
  119. @classmethod
  120. def from_environment(
  121. cls,
  122. environ: Mapping[str, str] | None = None,
  123. ) -> "ResourceBudget":
  124. """在新建 Recursive 根 Trace 时解析并校验预算环境变量。
  125. 只由 Runner 的根 Trace 初始化阶段调用,Legacy 和既有 Trace 不会重新读取。
  126. """
  127. values = os.environ if environ is None else environ
  128. return cls(
  129. enabled=_strict_bool(values, RESOURCE_BUDGET_ENABLED_ENV, True),
  130. max_total_agents=_positive_int(
  131. values, MAX_TOTAL_AGENTS_ENV, DEFAULT_MAX_TOTAL_AGENTS
  132. ),
  133. max_llm_calls=_positive_int(
  134. values, MAX_LLM_CALLS_ENV, DEFAULT_MAX_LLM_CALLS
  135. ),
  136. max_total_tokens=_positive_int(
  137. values, MAX_TOTAL_TOKENS_ENV, DEFAULT_MAX_TOTAL_TOKENS
  138. ),
  139. max_total_cost_usd=_positive_float(
  140. values, MAX_TOTAL_COST_USD_ENV, DEFAULT_MAX_TOTAL_COST_USD
  141. ),
  142. max_duration_seconds=_positive_int(
  143. values, MAX_DURATION_SECONDS_ENV, DEFAULT_MAX_DURATION_SECONDS
  144. ),
  145. reserved_final_calls=_positive_int(
  146. values, RESERVED_FINAL_CALLS_ENV, DEFAULT_RESERVED_FINAL_CALLS
  147. ),
  148. max_validation_tool_calls=_positive_int(
  149. values,
  150. MAX_VALIDATION_TOOL_CALLS_ENV,
  151. DEFAULT_MAX_VALIDATION_TOOL_CALLS,
  152. ),
  153. max_validation_material_chars=_positive_int(
  154. values,
  155. MAX_VALIDATION_MATERIAL_CHARS_ENV,
  156. DEFAULT_MAX_VALIDATION_MATERIAL_CHARS,
  157. ),
  158. )
  159. @classmethod
  160. def from_dict(cls, value: Mapping[str, Any]) -> "ResourceBudget":
  161. """从根 Trace context 恢复已固化的预算快照。
  162. Runner 在每次 Recursive 模型调用或子 Agent 预留前调用,字段缺失或多余都会失败。
  163. """
  164. if not isinstance(value, Mapping):
  165. raise ValueError("resource budget snapshot must be an object")
  166. expected = {field.name for field in cls.__dataclass_fields__.values()}
  167. unknown = set(value) - expected
  168. missing = expected - set(value)
  169. if unknown or missing:
  170. detail = []
  171. if missing:
  172. detail.append(f"missing={sorted(missing)}")
  173. if unknown:
  174. detail.append(f"unknown={sorted(unknown)}")
  175. raise ValueError("invalid resource budget snapshot: " + ", ".join(detail))
  176. return cls(**dict(value))
  177. def to_dict(self) -> dict[str, Any]:
  178. return asdict(self)
  179. @dataclass
  180. class ResourceUsage:
  181. """一棵 Recursive 任务树已持久化的累计用量。
  182. ResourceBudgetController 每次预留或记账时从 TraceStore 读取、校验并原子替换该快照。
  183. """
  184. total_agents: int
  185. llm_calls: int
  186. prompt_tokens: int
  187. completion_tokens: int
  188. total_tokens: int
  189. total_cost_usd: float
  190. validation_tool_calls: int
  191. validation_material_chars: int
  192. started_at: str
  193. last_denial: dict[str, Any] | None = None
  194. exhausted_reason: str | None = None
  195. @classmethod
  196. def new(cls, *, total_agents: int = 1, started_at: datetime | None = None) -> "ResourceUsage":
  197. if total_agents < 0:
  198. raise ValueError("total_agents must not be negative")
  199. return cls(
  200. total_agents=total_agents,
  201. llm_calls=0,
  202. prompt_tokens=0,
  203. completion_tokens=0,
  204. total_tokens=0,
  205. total_cost_usd=0.0,
  206. validation_tool_calls=0,
  207. validation_material_chars=0,
  208. started_at=(started_at or _utc_now()).isoformat(),
  209. )
  210. @classmethod
  211. def from_dict(cls, value: Mapping[str, Any]) -> "ResourceUsage":
  212. if not isinstance(value, Mapping):
  213. raise ResourceBudgetStateError("resource usage must be an object")
  214. expected = {field.name for field in cls.__dataclass_fields__.values()}
  215. unknown = set(value) - expected
  216. missing = expected - set(value)
  217. if unknown or missing:
  218. raise ResourceBudgetStateError(
  219. "invalid resource usage fields: "
  220. f"missing={sorted(missing)}, unknown={sorted(unknown)}"
  221. )
  222. try:
  223. usage = cls(**dict(value))
  224. usage.validate()
  225. return usage
  226. except (TypeError, ValueError) as exc:
  227. raise ResourceBudgetStateError(f"invalid resource usage: {exc}") from exc
  228. def validate(self) -> None:
  229. counters = {
  230. "total_agents": self.total_agents,
  231. "llm_calls": self.llm_calls,
  232. "prompt_tokens": self.prompt_tokens,
  233. "completion_tokens": self.completion_tokens,
  234. "total_tokens": self.total_tokens,
  235. "validation_tool_calls": self.validation_tool_calls,
  236. "validation_material_chars": self.validation_material_chars,
  237. }
  238. for name, value in counters.items():
  239. if isinstance(value, bool) or not isinstance(value, int) or value < 0:
  240. raise ValueError(f"{name} must be a non-negative integer")
  241. if self.total_tokens != self.prompt_tokens + self.completion_tokens:
  242. raise ValueError("total_tokens must equal prompt_tokens + completion_tokens")
  243. if (
  244. isinstance(self.total_cost_usd, bool)
  245. or not isinstance(self.total_cost_usd, (int, float))
  246. or not math.isfinite(float(self.total_cost_usd))
  247. or self.total_cost_usd < 0
  248. ):
  249. raise ValueError("total_cost_usd must be a non-negative number")
  250. try:
  251. datetime.fromisoformat(self.started_at)
  252. except (TypeError, ValueError) as exc:
  253. raise ValueError("started_at must be an ISO datetime") from exc
  254. if self.last_denial is not None and not isinstance(self.last_denial, dict):
  255. raise ValueError("last_denial must be an object or null")
  256. valid_dimensions = {
  257. "agents",
  258. "llm_calls",
  259. "tokens",
  260. "cost_usd",
  261. "duration_seconds",
  262. "validation_tool_calls",
  263. "validation_material_chars",
  264. }
  265. if self.last_denial is not None:
  266. if set(self.last_denial) != {"dimension", "detail", "denied_at"}:
  267. raise ValueError("last_denial has invalid fields")
  268. if self.last_denial["dimension"] not in valid_dimensions:
  269. raise ValueError("last_denial has an invalid dimension")
  270. if not isinstance(self.last_denial["detail"], str):
  271. raise ValueError("last_denial detail must be a string")
  272. try:
  273. datetime.fromisoformat(self.last_denial["denied_at"])
  274. except (TypeError, ValueError) as exc:
  275. raise ValueError("last_denial denied_at must be an ISO datetime") from exc
  276. valid_reasons = {
  277. f"budget_exhausted:{dimension}" for dimension in valid_dimensions
  278. }
  279. if self.exhausted_reason is not None and self.exhausted_reason not in valid_reasons:
  280. raise ValueError("exhausted_reason is invalid")
  281. def to_dict(self) -> dict[str, Any]:
  282. self.validate()
  283. return asdict(self)
  284. class ResourceBudgetStateError(RuntimeError):
  285. """任务树持久化用量缺失或损坏,执行必须失败关闭。"""
  286. class ResourceBudgetExceeded(RuntimeError):
  287. """某项任务树资源预留被拒绝,或响应记账后已超限。"""
  288. def __init__(
  289. self,
  290. dimension: BudgetDimension,
  291. message: str,
  292. *,
  293. usage: ResourceUsage,
  294. budget: ResourceBudget,
  295. ) -> None:
  296. super().__init__(message)
  297. self.dimension = dimension
  298. self.usage = usage
  299. self.budget = budget
  300. _ROOT_LOCKS: WeakValueDictionary[
  301. tuple[asyncio.AbstractEventLoop, str], asyncio.Lock
  302. ] = WeakValueDictionary()
  303. class ResourceBudgetController:
  304. """根据持久化用量执行单进程原子预留和记账。
  305. AgentRunner 持有该控制器:模型/Validator 调用由 Runner 记账,子 Agent 数由 `agent()` 创建阶段预留。
  306. """
  307. def __init__(
  308. self,
  309. trace_store: TraceStore,
  310. *,
  311. now: Callable[[], datetime] | None = None,
  312. ) -> None:
  313. self.trace_store = trace_store
  314. self._now = now or _utc_now
  315. def _lock(self, root_trace_id: str) -> asyncio.Lock:
  316. key = (asyncio.get_running_loop(), root_trace_id)
  317. lock = _ROOT_LOCKS.get(key)
  318. if lock is None:
  319. lock = asyncio.Lock()
  320. _ROOT_LOCKS[key] = lock
  321. return lock
  322. async def initialize(
  323. self,
  324. root_trace_id: str,
  325. budget: ResourceBudget,
  326. *,
  327. initial_agents: int = 1,
  328. ) -> ResourceUsage:
  329. """为新 Recursive 根 Trace 初始化用量,并将根 Agent 计入总数。
  330. `AgentRunner._prepare_new_trace` 在根 Trace 和预算快照落库后调用;已有用量时幂等返回。
  331. """
  332. if (
  333. isinstance(initial_agents, bool)
  334. or not isinstance(initial_agents, int)
  335. or initial_agents <= 0
  336. ):
  337. raise ValueError("initial_agents must be a positive integer")
  338. async with self._lock(root_trace_id):
  339. try:
  340. existing = await self.trace_store.get_resource_usage(root_trace_id)
  341. except Exception as exc:
  342. raise ResourceBudgetStateError(
  343. f"resource usage cannot be read for root Trace {root_trace_id!r}: {exc}"
  344. ) from exc
  345. if existing is not None:
  346. return ResourceUsage.from_dict(existing)
  347. usage = ResourceUsage.new(total_agents=initial_agents, started_at=self._now())
  348. if budget.enabled and initial_agents > budget.max_total_agents:
  349. usage = await self._deny_locked(
  350. root_trace_id, usage, "agents", "initial Agent count exceeds the tree budget"
  351. )
  352. raise self._exceeded("agents", usage, budget)
  353. await self.trace_store.replace_resource_usage(root_trace_id, usage.to_dict())
  354. return usage
  355. async def get_usage(self, root_trace_id: str) -> ResourceUsage:
  356. """读取并严格校验根 Trace 的当前累计用量。
  357. 所有预留、记账与运行状态查询共用该入口,文件缺失或损坏时 fail closed。
  358. """
  359. try:
  360. raw = await self.trace_store.get_resource_usage(root_trace_id)
  361. except Exception as exc:
  362. raise ResourceBudgetStateError(
  363. f"resource usage cannot be read for root Trace {root_trace_id!r}: {exc}"
  364. ) from exc
  365. if raw is None:
  366. raise ResourceBudgetStateError(
  367. f"resource usage is missing for root Trace {root_trace_id!r}"
  368. )
  369. return ResourceUsage.from_dict(raw)
  370. async def reserve_agents(
  371. self,
  372. root_trace_id: str,
  373. budget: ResourceBudget,
  374. count: int,
  375. ) -> ResourceUsage:
  376. """在创建一批本地业务子 Trace 前原子预留 Agent 数。
  377. Sub-Agent `_run_agents` 在六孩子临界区内调用,超限时整批拒绝且不创建 Trace。
  378. """
  379. if isinstance(count, bool) or not isinstance(count, int) or count <= 0:
  380. raise ValueError("count must be a positive integer")
  381. async with self._lock(root_trace_id):
  382. usage = await self.get_usage(root_trace_id)
  383. self._raise_if_terminal(usage, budget)
  384. await self._check_time_locked(root_trace_id, usage, budget)
  385. proposed = usage.total_agents + count
  386. if budget.enabled and proposed > budget.max_total_agents:
  387. usage = await self._deny_locked(
  388. root_trace_id,
  389. usage,
  390. "agents",
  391. f"reserving {count} Agent(s) would reach {proposed}",
  392. )
  393. raise self._exceeded("agents", usage, budget)
  394. usage.total_agents = proposed
  395. await self.trace_store.replace_resource_usage(root_trace_id, usage.to_dict())
  396. return usage
  397. async def release_agents(
  398. self,
  399. root_trace_id: str,
  400. budget: ResourceBudget,
  401. count: int,
  402. ) -> ResourceUsage:
  403. """回滚批次预留中尚未创建 Trace 的 Agent 数。
  404. 仅由 Sub-Agent 初始化失败时调用;已创建、停止、失败或回溯的 Agent 都不退还。
  405. """
  406. del budget # The persisted count is rolled back even after another limit expires.
  407. if isinstance(count, bool) or not isinstance(count, int) or count <= 0:
  408. raise ValueError("count must be a positive integer")
  409. async with self._lock(root_trace_id):
  410. usage = await self.get_usage(root_trace_id)
  411. proposed = usage.total_agents - count
  412. if proposed < 1:
  413. raise ResourceBudgetStateError(
  414. "cannot release the root Agent or more Agents than were reserved"
  415. )
  416. usage.total_agents = proposed
  417. await self.trace_store.replace_resource_usage(root_trace_id, usage.to_dict())
  418. return usage
  419. async def reserve_llm_call(
  420. self,
  421. root_trace_id: str,
  422. budget: ResourceBudget,
  423. *,
  424. purpose: LLMCallPurpose = "ordinary",
  425. ) -> ResourceUsage:
  426. """在模型请求发出前预留一次调用,必要时保留根验收槽位。
  427. `AgentRunner.call_recursive_llm` 为普通 Agent、子 Validator 和根 Validator 统一调用该入口。
  428. """
  429. if purpose not in {"ordinary", "root_validator"}:
  430. raise ValueError("purpose must be 'ordinary' or 'root_validator'")
  431. async with self._lock(root_trace_id):
  432. usage = await self.get_usage(root_trace_id)
  433. self._raise_if_terminal(usage, budget)
  434. await self._check_time_locked(root_trace_id, usage, budget)
  435. ceiling = budget.max_llm_calls
  436. if purpose == "ordinary":
  437. ceiling -= budget.reserved_final_calls
  438. proposed = usage.llm_calls + 1
  439. if budget.enabled and proposed > ceiling:
  440. usage = await self._deny_locked(
  441. root_trace_id,
  442. usage,
  443. "llm_calls",
  444. f"{purpose} call would reach {proposed}; available ceiling is {ceiling}",
  445. )
  446. raise self._exceeded("llm_calls", usage, budget)
  447. usage.llm_calls = proposed
  448. await self.trace_store.replace_resource_usage(root_trace_id, usage.to_dict())
  449. return usage
  450. async def record_llm_usage(
  451. self,
  452. root_trace_id: str,
  453. budget: ResourceBudget,
  454. *,
  455. prompt_tokens: int,
  456. completion_tokens: int,
  457. cost_usd: float,
  458. ) -> ResourceUsage:
  459. """在框架模型响应返回后登记 Token 和成本。
  460. Runner 在已预留调用次数后使用;响应造成超限时会先持久化实际用量再报错。
  461. """
  462. self._validate_reported_usage(prompt_tokens, completion_tokens, cost_usd)
  463. async with self._lock(root_trace_id):
  464. usage = await self.get_usage(root_trace_id)
  465. return await self._record_usage_locked(
  466. root_trace_id,
  467. budget,
  468. usage,
  469. prompt_tokens=prompt_tokens,
  470. completion_tokens=completion_tokens,
  471. cost_usd=cost_usd,
  472. increment_llm_calls=False,
  473. )
  474. async def record_external_llm_usage(
  475. self,
  476. root_trace_id: str,
  477. budget: ResourceBudget,
  478. *,
  479. prompt_tokens: int,
  480. completion_tokens: int,
  481. cost_usd: float,
  482. ) -> ResourceUsage:
  483. """事后登记工具内部不能预留的模型调用。
  484. Runner 仅在工具返回 `tool_usage` 时调用;总调用数、Token 和成本都会先落库再检查超限。
  485. """
  486. self._validate_reported_usage(prompt_tokens, completion_tokens, cost_usd)
  487. async with self._lock(root_trace_id):
  488. usage = await self.get_usage(root_trace_id)
  489. return await self._record_usage_locked(
  490. root_trace_id,
  491. budget,
  492. usage,
  493. prompt_tokens=prompt_tokens,
  494. completion_tokens=completion_tokens,
  495. cost_usd=cost_usd,
  496. increment_llm_calls=True,
  497. llm_call_ceiling=(
  498. budget.max_llm_calls - budget.reserved_final_calls
  499. ),
  500. )
  501. async def record_validation_usage(
  502. self,
  503. root_trace_id: str,
  504. budget: ResourceBudget,
  505. *,
  506. tool_calls: int = 0,
  507. material_chars: int = 0,
  508. provider_cost_usd: float = 0.0,
  509. ) -> ResourceUsage:
  510. """原子预留 Validator 工具调用或登记可信材料字符。
  511. 搜索/打开在真实外部调用前用 ``tool_calls=1`` 预留;网页正文、
  512. Artifact字符和 Provider 报告的成本事后登记,超限先落实际消耗。
  513. """
  514. for name, value in {
  515. "tool_calls": tool_calls,
  516. "material_chars": material_chars,
  517. }.items():
  518. if isinstance(value, bool) or not isinstance(value, int) or value < 0:
  519. raise ValueError(f"{name} must be a non-negative integer")
  520. if (
  521. isinstance(provider_cost_usd, bool)
  522. or not isinstance(provider_cost_usd, (int, float))
  523. or not math.isfinite(float(provider_cost_usd))
  524. or provider_cost_usd < 0
  525. ):
  526. raise ValueError("provider_cost_usd must be a non-negative number")
  527. if not tool_calls and not material_chars and not provider_cost_usd:
  528. return await self.get_usage(root_trace_id)
  529. async with self._lock(root_trace_id):
  530. usage = await self.get_usage(root_trace_id)
  531. self._raise_if_terminal(usage, budget)
  532. await self._check_time_locked(root_trace_id, usage, budget)
  533. proposed_calls = usage.validation_tool_calls + tool_calls
  534. if budget.enabled and proposed_calls > budget.max_validation_tool_calls:
  535. usage = await self._deny_locked(
  536. root_trace_id,
  537. usage,
  538. "validation_tool_calls",
  539. f"Validator tool calls would reach {proposed_calls}",
  540. )
  541. raise self._exceeded("validation_tool_calls", usage, budget)
  542. usage.validation_tool_calls = proposed_calls
  543. usage.validation_material_chars += material_chars
  544. usage.total_cost_usd += float(provider_cost_usd)
  545. exceeded: BudgetDimension | None = None
  546. if (
  547. budget.enabled
  548. and usage.validation_material_chars
  549. > budget.max_validation_material_chars
  550. ):
  551. exceeded = "validation_material_chars"
  552. elif budget.enabled and usage.total_cost_usd > budget.max_total_cost_usd:
  553. exceeded = "cost_usd"
  554. if exceeded:
  555. usage = await self._deny_locked(
  556. root_trace_id,
  557. usage,
  558. exceeded,
  559. (
  560. "Validator material characters reached "
  561. f"{usage.validation_material_chars}"
  562. if exceeded == "validation_material_chars"
  563. else "Validator provider cost reached "
  564. f"{usage.total_cost_usd}"
  565. ),
  566. )
  567. else:
  568. await self.trace_store.replace_resource_usage(
  569. root_trace_id,
  570. usage.to_dict(),
  571. )
  572. if exceeded:
  573. raise self._exceeded(exceeded, usage, budget)
  574. return usage
  575. @staticmethod
  576. def _validate_reported_usage(
  577. prompt_tokens: int,
  578. completion_tokens: int,
  579. cost_usd: float,
  580. ) -> None:
  581. for name, value in {
  582. "prompt_tokens": prompt_tokens,
  583. "completion_tokens": completion_tokens,
  584. }.items():
  585. if isinstance(value, bool) or not isinstance(value, int) or value < 0:
  586. raise ValueError(f"{name} must be a non-negative integer")
  587. if (
  588. isinstance(cost_usd, bool)
  589. or not isinstance(cost_usd, (int, float))
  590. or not math.isfinite(float(cost_usd))
  591. or cost_usd < 0
  592. ):
  593. raise ValueError("cost_usd must be a non-negative number")
  594. async def _record_usage_locked(
  595. self,
  596. root_trace_id: str,
  597. budget: ResourceBudget,
  598. usage: ResourceUsage,
  599. *,
  600. prompt_tokens: int,
  601. completion_tokens: int,
  602. cost_usd: float,
  603. increment_llm_calls: bool,
  604. llm_call_ceiling: int | None = None,
  605. ) -> ResourceUsage:
  606. if increment_llm_calls:
  607. usage.llm_calls += 1
  608. usage.prompt_tokens += prompt_tokens
  609. usage.completion_tokens += completion_tokens
  610. usage.total_tokens = usage.prompt_tokens + usage.completion_tokens
  611. usage.total_cost_usd += float(cost_usd)
  612. exceeded: BudgetDimension | None = None
  613. detail = ""
  614. effective_call_ceiling = (
  615. budget.max_llm_calls
  616. if llm_call_ceiling is None
  617. else llm_call_ceiling
  618. )
  619. if budget.enabled and usage.llm_calls > effective_call_ceiling:
  620. exceeded = "llm_calls"
  621. detail = (
  622. f"recorded LLM calls reached {usage.llm_calls}; "
  623. f"available ceiling is {effective_call_ceiling}"
  624. )
  625. elif budget.enabled and usage.total_tokens > budget.max_total_tokens:
  626. exceeded = "tokens"
  627. detail = f"recorded total tokens reached {usage.total_tokens}"
  628. elif budget.enabled and usage.total_cost_usd > budget.max_total_cost_usd:
  629. exceeded = "cost_usd"
  630. detail = f"recorded total cost reached {usage.total_cost_usd}"
  631. if exceeded:
  632. usage = await self._deny_locked(root_trace_id, usage, exceeded, detail)
  633. else:
  634. await self.trace_store.replace_resource_usage(root_trace_id, usage.to_dict())
  635. await self._check_time_locked(root_trace_id, usage, budget)
  636. if exceeded:
  637. raise self._exceeded(exceeded, usage, budget)
  638. return usage
  639. async def check_time(
  640. self,
  641. root_trace_id: str,
  642. budget: ResourceBudget,
  643. ) -> ResourceUsage:
  644. async with self._lock(root_trace_id):
  645. usage = await self.get_usage(root_trace_id)
  646. self._raise_if_terminal(usage, budget)
  647. await self._check_time_locked(root_trace_id, usage, budget)
  648. return usage
  649. async def _check_time_locked(
  650. self,
  651. root_trace_id: str,
  652. usage: ResourceUsage,
  653. budget: ResourceBudget,
  654. ) -> None:
  655. if not budget.enabled:
  656. return
  657. started_at = datetime.fromisoformat(usage.started_at)
  658. now = self._now()
  659. if started_at.tzinfo is None:
  660. started_at = started_at.replace(tzinfo=timezone.utc)
  661. if now.tzinfo is None:
  662. now = now.replace(tzinfo=timezone.utc)
  663. elapsed = (now - started_at).total_seconds()
  664. if elapsed > budget.max_duration_seconds:
  665. usage = await self._deny_locked(
  666. root_trace_id,
  667. usage,
  668. "duration_seconds",
  669. f"elapsed duration reached {elapsed:.3f} seconds",
  670. )
  671. raise self._exceeded("duration_seconds", usage, budget)
  672. async def _deny_locked(
  673. self,
  674. root_trace_id: str,
  675. usage: ResourceUsage,
  676. dimension: BudgetDimension,
  677. detail: str,
  678. ) -> ResourceUsage:
  679. usage.last_denial = {
  680. "dimension": dimension,
  681. "detail": detail,
  682. "denied_at": self._now().isoformat(),
  683. }
  684. usage.exhausted_reason = f"budget_exhausted:{dimension}"
  685. await self.trace_store.replace_resource_usage(root_trace_id, usage.to_dict())
  686. return usage
  687. @staticmethod
  688. def _raise_if_terminal(usage: ResourceUsage, budget: ResourceBudget) -> None:
  689. """响应已造成 Token、成本或时长超限后,拒绝新工作。
  690. Agent 数超限只禁止新建孩子;普通调用耗尽仍保留明确预留的根 Validator 槽位。
  691. """
  692. prefix = "budget_exhausted:"
  693. reason = usage.exhausted_reason or ""
  694. if not budget.enabled or not reason.startswith(prefix):
  695. return
  696. dimension = reason.removeprefix(prefix)
  697. terminal_dimensions: dict[str, BudgetDimension] = {
  698. "tokens": "tokens",
  699. "cost_usd": "cost_usd",
  700. "duration_seconds": "duration_seconds",
  701. "validation_tool_calls": "validation_tool_calls",
  702. "validation_material_chars": "validation_material_chars",
  703. }
  704. if dimension in terminal_dimensions:
  705. typed_dimension = terminal_dimensions[dimension]
  706. raise ResourceBudgetExceeded(
  707. typed_dimension,
  708. f"Recursive Agent tree resource budget already exceeded: {dimension}",
  709. usage=usage,
  710. budget=budget,
  711. )
  712. @staticmethod
  713. def _exceeded(
  714. dimension: BudgetDimension,
  715. usage: ResourceUsage,
  716. budget: ResourceBudget,
  717. ) -> ResourceBudgetExceeded:
  718. return ResourceBudgetExceeded(
  719. dimension,
  720. f"Recursive Agent tree resource budget exceeded: {dimension}",
  721. usage=usage,
  722. budget=budget,
  723. )