Skip checkpoint evaluation when a turn is interrupted so cancellation rows remain durable without urging continued execution. The existing minimal-agent interrupt regression also avoids dereferencing an absent iteration budget. Consolidate the warning coverage into two invariants, including real SQLite readback and dispatcher/child scope controls. Cold-start tool availability between construction cases to model independent worker processes. Place ratio normalization beside the existing iteration budget instead of growing init. Real cancelled-tool A/B against current main, draft, and fix: three cancelled rows and zero writes on all arms; persisted checkpoint notices 0 / 1 / 0. Repeated scripted HTTP/SQLite loop A/B preserves completion opportunity, ordinary default-off behavior, and blocked/two-failure exhaustion behavior. Local targeted run initially passed 15 cases with one fixture cache-isolation failure; corrected target and inherited affected suites remain queued behind the campaign lock. This commit is not a CI-green or merge-ready claim.
60 lines
1.8 KiB
Python
60 lines
1.8 KiB
Python
"""Per-agent iteration budget — thread-safe consume/refund counter.
|
|
|
|
Each ``AIAgent`` (parent or subagent) holds its own :class:`IterationBudget`: the parent's
|
|
cap is ``max_iterations`` (default 500), each subagent's ``delegation.max_iterations``
|
|
(default 50), so total iterations across parent + subagents can exceed the parent's cap.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import threading
|
|
|
|
|
|
def normalize_budget_warning_ratio(value) -> float | None:
|
|
"""A finite ratio strictly between zero and one, or None (feature off)."""
|
|
if value is None or isinstance(value, bool):
|
|
return None
|
|
try:
|
|
ratio = float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return ratio if math.isfinite(ratio) and 0 < ratio < 1 else None
|
|
|
|
|
|
class IterationBudget:
|
|
"""Thread-safe iteration counter; ``execute_code`` (programmatic tool calling)
|
|
iterations are refunded via :meth:`refund` so they don't eat into the budget."""
|
|
|
|
def __init__(self, max_total: int):
|
|
self.max_total = max_total
|
|
self._used = 0
|
|
self._lock = threading.Lock()
|
|
|
|
def consume(self) -> bool:
|
|
"""Try to consume one iteration. Returns True if allowed."""
|
|
with self._lock:
|
|
if self._used >= self.max_total:
|
|
return False
|
|
self._used += 1
|
|
return True
|
|
|
|
def refund(self) -> None:
|
|
"""Give back one iteration (e.g. for execute_code turns)."""
|
|
with self._lock:
|
|
if self._used > 0:
|
|
self._used -= 1
|
|
|
|
@property
|
|
def used(self) -> int:
|
|
with self._lock:
|
|
return self._used
|
|
|
|
@property
|
|
def remaining(self) -> int:
|
|
with self._lock:
|
|
return max(0, self.max_total - self._used)
|
|
|
|
|
|
__all__ = ["IterationBudget"]
|