multi-agent-lab / tests /test_governor.py
agharsallah
feat: add structured reason to BudgetExceeded + Governor.snapshot
ac3ed3b
Raw
History Blame Contribute Delete
5.89 kB
from __future__ import annotations
import pytest
from src.core.governor import BudgetExceeded, Governor
class TestGovernor:
def test_allows_within_budget(self):
g = Governor(max_turns=10, max_calls_per_turn=5, max_total_calls=100)
g.begin_turn(1)
g.check(1) # should not raise
def test_raises_on_turn_exceeded(self):
g = Governor(max_turns=5)
with pytest.raises(BudgetExceeded):
g.check(6)
def test_raises_on_total_calls_exceeded(self):
g = Governor(max_total_calls=3)
g.begin_turn(1)
g.record_call()
g.record_call()
g.record_call()
with pytest.raises(BudgetExceeded):
g.check(1)
def test_raises_on_per_turn_calls_exceeded(self):
g = Governor(max_calls_per_turn=2)
g.begin_turn(1)
g.record_call()
g.record_call()
with pytest.raises(BudgetExceeded):
g.check(1)
def test_per_turn_resets_on_new_turn(self):
g = Governor(max_calls_per_turn=2)
g.begin_turn(1)
g.record_call()
g.record_call()
g.begin_turn(2) # resets per-turn count
g.check(2) # should not raise
def test_stats_reflect_calls(self):
g = Governor()
g.begin_turn(3)
g.record_call()
g.record_call()
assert g.stats["total_calls"] == 2
assert g.stats["calls_this_turn"] == 2
assert g.stats["current_turn"] == 3
class TestGovernorTokensAndCost:
def test_record_call_no_args_still_works(self):
g = Governor()
g.begin_turn(1)
g.record_call() # backward-compatible: no tokens/cost
assert g.stats["total_calls"] == 1
assert g.stats["total_tokens"] == 0
def test_tokens_accumulate(self):
g = Governor()
g.begin_turn(1)
g.record_call(tokens=120)
g.record_call(tokens=80)
assert g.stats["total_tokens"] == 200
def test_raises_on_token_cap(self):
g = Governor(max_total_tokens=100)
g.begin_turn(1)
g.record_call(tokens=150)
with pytest.raises(BudgetExceeded):
g.check(1)
def test_raises_on_spend_cap(self):
g = Governor(hourly_budget_usd=0.01)
g.begin_turn(1)
g.record_call(cost_usd=0.05)
with pytest.raises(BudgetExceeded):
g.check(1)
def test_reset_clears_counters_keeps_limits(self):
g = Governor(max_turns=7, max_total_tokens=999)
g.begin_turn(1)
g.record_call(tokens=50, cost_usd=0.2)
g.reset()
assert g.stats["total_calls"] == 0
assert g.stats["total_tokens"] == 0
assert g.stats["spend_usd"] == 0.0
assert g.max_turns == 7 # limits survive reset
assert g.max_total_tokens == 999
class TestBudgetExceededReason:
def test_reason_is_max_turns(self):
g = Governor(max_turns=5)
with pytest.raises(BudgetExceeded) as excinfo:
g.check(6)
assert excinfo.value.reason == "max_turns"
def test_reason_is_max_total_calls(self):
# Per-turn cap kept high so only the total-calls bound trips.
g = Governor(max_total_calls=2, max_calls_per_turn=100)
g.begin_turn(1)
g.record_call()
g.record_call()
with pytest.raises(BudgetExceeded) as excinfo:
g.check(1)
assert excinfo.value.reason == "max_total_calls"
def test_reason_is_max_calls_per_turn(self):
# Total-calls cap kept high so only the per-turn bound trips.
g = Governor(max_calls_per_turn=2, max_total_calls=100)
g.begin_turn(1)
g.record_call()
g.record_call()
with pytest.raises(BudgetExceeded) as excinfo:
g.check(1)
assert excinfo.value.reason == "max_calls_per_turn"
def test_reason_is_max_total_tokens(self):
g = Governor(max_total_tokens=100)
g.begin_turn(1)
g.record_call(tokens=150)
with pytest.raises(BudgetExceeded) as excinfo:
g.check(1)
assert excinfo.value.reason == "max_total_tokens"
def test_reason_is_hourly_budget_usd(self):
g = Governor(hourly_budget_usd=0.01)
g.begin_turn(1)
g.record_call(cost_usd=0.05)
with pytest.raises(BudgetExceeded) as excinfo:
g.check(1)
assert excinfo.value.reason == "hourly_budget_usd"
def test_remains_runtime_error_subclass(self):
# The sibling UI unit catches generically; type hierarchy must not change.
assert issubclass(BudgetExceeded, RuntimeError)
g = Governor(max_turns=1)
with pytest.raises(RuntimeError):
g.check(2)
def test_message_stays_human_readable(self):
g = Governor(max_turns=5)
with pytest.raises(BudgetExceeded) as excinfo:
g.check(6)
assert "Turn cap 5 reached" in str(excinfo.value)
def test_reason_defaults_to_none_when_constructed_directly(self):
exc = BudgetExceeded("manual raise")
assert exc.reason is None
assert str(exc) == "manual raise"
class TestGovernorSnapshot:
def test_snapshot_includes_counters_and_limits(self):
g = Governor(max_turns=7, max_total_calls=50, max_total_tokens=999, hourly_budget_usd=1.5)
g.begin_turn(2)
g.record_call(tokens=40, cost_usd=0.25)
snap = g.snapshot
assert snap["total_calls"] == 1
assert snap["total_tokens"] == 40
assert snap["current_turn"] == 2
assert snap["max_turns"] == 7
assert snap["max_total_calls"] == 50
assert snap["max_total_tokens"] == 999
assert snap["hourly_budget_usd"] == 1.5
def test_snapshot_optional_limits_default_none(self):
g = Governor()
snap = g.snapshot
assert snap["max_total_tokens"] is None
assert snap["hourly_budget_usd"] is None