agharsallah Codex commited on
Commit
ac3ed3b
·
1 Parent(s): b9afb6f

feat: add structured reason to BudgetExceeded + Governor.snapshot

Browse files

Name which bound tripped (.reason) on BudgetExceeded and expose a read-only
Governor.snapshot, so the UI can show a precise stop message. Backward
compatible: same raise sites, thresholds, and BudgetExceeded(RuntimeError) type.

Co-Authored-By: Codex <codex@openai.com>

Files changed (2) hide show
  1. src/core/governor.py +34 -6
  2. tests/test_governor.py +83 -0
src/core/governor.py CHANGED
@@ -35,15 +35,18 @@ class Governor:
35
 
36
  def check(self, turn: int) -> None:
37
  if turn > self.max_turns:
38
- raise BudgetExceeded(f"Turn cap {self.max_turns} reached")
39
  if self._total_calls >= self.max_total_calls:
40
- raise BudgetExceeded(f"Total call cap {self.max_total_calls} reached")
41
  if self._calls_this_turn >= self.max_calls_per_turn:
42
- raise BudgetExceeded(f"Per-turn call cap {self.max_calls_per_turn} reached on turn {turn}")
 
 
 
43
  if self.max_total_tokens is not None and self._total_tokens >= self.max_total_tokens:
44
- raise BudgetExceeded(f"Total token cap {self.max_total_tokens} reached")
45
  if self.hourly_budget_usd is not None and self._spend_usd >= self.hourly_budget_usd:
46
- raise BudgetExceeded(f"Spend cap ${self.hourly_budget_usd:.2f} reached")
47
 
48
  def record_call(self, tokens: int = 0, cost_usd: float = 0.0) -> None:
49
  self._calls_this_turn += 1
@@ -72,6 +75,31 @@ class Governor:
72
  "spend_usd": round(self._spend_usd, 4),
73
  }
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
  class BudgetExceeded(RuntimeError):
77
- pass
 
 
 
 
 
 
 
 
 
 
 
35
 
36
  def check(self, turn: int) -> None:
37
  if turn > self.max_turns:
38
+ raise BudgetExceeded(f"Turn cap {self.max_turns} reached", reason="max_turns")
39
  if self._total_calls >= self.max_total_calls:
40
+ raise BudgetExceeded(f"Total call cap {self.max_total_calls} reached", reason="max_total_calls")
41
  if self._calls_this_turn >= self.max_calls_per_turn:
42
+ raise BudgetExceeded(
43
+ f"Per-turn call cap {self.max_calls_per_turn} reached on turn {turn}",
44
+ reason="max_calls_per_turn",
45
+ )
46
  if self.max_total_tokens is not None and self._total_tokens >= self.max_total_tokens:
47
+ raise BudgetExceeded(f"Total token cap {self.max_total_tokens} reached", reason="max_total_tokens")
48
  if self.hourly_budget_usd is not None and self._spend_usd >= self.hourly_budget_usd:
49
+ raise BudgetExceeded(f"Spend cap ${self.hourly_budget_usd:.2f} reached", reason="hourly_budget_usd")
50
 
51
  def record_call(self, tokens: int = 0, cost_usd: float = 0.0) -> None:
52
  self._calls_this_turn += 1
 
75
  "spend_usd": round(self._spend_usd, 4),
76
  }
77
 
78
+ @property
79
+ def snapshot(self) -> dict[str, int | float | None]:
80
+ """Read-only view of the live counters alongside their configured limits.
81
+
82
+ Handy for UI surfaces that want to show "X of Y calls used" without
83
+ reaching into private fields."""
84
+ return {
85
+ **self.stats,
86
+ "max_turns": self.max_turns,
87
+ "max_calls_per_turn": self.max_calls_per_turn,
88
+ "max_total_calls": self.max_total_calls,
89
+ "max_total_tokens": self.max_total_tokens,
90
+ "hourly_budget_usd": self.hourly_budget_usd,
91
+ }
92
+
93
 
94
  class BudgetExceeded(RuntimeError):
95
+ """Raised when a Governor bound trips.
96
+
97
+ Carries a structured ``reason`` naming which bound tripped (one of
98
+ ``max_turns`` / ``max_total_calls`` / ``max_calls_per_turn`` /
99
+ ``max_total_tokens`` / ``hourly_budget_usd``) while ``str(exc)`` stays a
100
+ human-readable message. Remains a ``RuntimeError`` subclass so existing
101
+ generic handlers keep working."""
102
+
103
+ def __init__(self, message: str, *, reason: str | None = None) -> None:
104
+ super().__init__(message)
105
+ self.reason = reason
tests/test_governor.py CHANGED
@@ -90,3 +90,86 @@ class TestGovernorTokensAndCost:
90
  assert g.stats["spend_usd"] == 0.0
91
  assert g.max_turns == 7 # limits survive reset
92
  assert g.max_total_tokens == 999
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  assert g.stats["spend_usd"] == 0.0
91
  assert g.max_turns == 7 # limits survive reset
92
  assert g.max_total_tokens == 999
93
+
94
+
95
+ class TestBudgetExceededReason:
96
+ def test_reason_is_max_turns(self):
97
+ g = Governor(max_turns=5)
98
+ with pytest.raises(BudgetExceeded) as excinfo:
99
+ g.check(6)
100
+ assert excinfo.value.reason == "max_turns"
101
+
102
+ def test_reason_is_max_total_calls(self):
103
+ # Per-turn cap kept high so only the total-calls bound trips.
104
+ g = Governor(max_total_calls=2, max_calls_per_turn=100)
105
+ g.begin_turn(1)
106
+ g.record_call()
107
+ g.record_call()
108
+ with pytest.raises(BudgetExceeded) as excinfo:
109
+ g.check(1)
110
+ assert excinfo.value.reason == "max_total_calls"
111
+
112
+ def test_reason_is_max_calls_per_turn(self):
113
+ # Total-calls cap kept high so only the per-turn bound trips.
114
+ g = Governor(max_calls_per_turn=2, max_total_calls=100)
115
+ g.begin_turn(1)
116
+ g.record_call()
117
+ g.record_call()
118
+ with pytest.raises(BudgetExceeded) as excinfo:
119
+ g.check(1)
120
+ assert excinfo.value.reason == "max_calls_per_turn"
121
+
122
+ def test_reason_is_max_total_tokens(self):
123
+ g = Governor(max_total_tokens=100)
124
+ g.begin_turn(1)
125
+ g.record_call(tokens=150)
126
+ with pytest.raises(BudgetExceeded) as excinfo:
127
+ g.check(1)
128
+ assert excinfo.value.reason == "max_total_tokens"
129
+
130
+ def test_reason_is_hourly_budget_usd(self):
131
+ g = Governor(hourly_budget_usd=0.01)
132
+ g.begin_turn(1)
133
+ g.record_call(cost_usd=0.05)
134
+ with pytest.raises(BudgetExceeded) as excinfo:
135
+ g.check(1)
136
+ assert excinfo.value.reason == "hourly_budget_usd"
137
+
138
+ def test_remains_runtime_error_subclass(self):
139
+ # The sibling UI unit catches generically; type hierarchy must not change.
140
+ assert issubclass(BudgetExceeded, RuntimeError)
141
+ g = Governor(max_turns=1)
142
+ with pytest.raises(RuntimeError):
143
+ g.check(2)
144
+
145
+ def test_message_stays_human_readable(self):
146
+ g = Governor(max_turns=5)
147
+ with pytest.raises(BudgetExceeded) as excinfo:
148
+ g.check(6)
149
+ assert "Turn cap 5 reached" in str(excinfo.value)
150
+
151
+ def test_reason_defaults_to_none_when_constructed_directly(self):
152
+ exc = BudgetExceeded("manual raise")
153
+ assert exc.reason is None
154
+ assert str(exc) == "manual raise"
155
+
156
+
157
+ class TestGovernorSnapshot:
158
+ def test_snapshot_includes_counters_and_limits(self):
159
+ g = Governor(max_turns=7, max_total_calls=50, max_total_tokens=999, hourly_budget_usd=1.5)
160
+ g.begin_turn(2)
161
+ g.record_call(tokens=40, cost_usd=0.25)
162
+ snap = g.snapshot
163
+ assert snap["total_calls"] == 1
164
+ assert snap["total_tokens"] == 40
165
+ assert snap["current_turn"] == 2
166
+ assert snap["max_turns"] == 7
167
+ assert snap["max_total_calls"] == 50
168
+ assert snap["max_total_tokens"] == 999
169
+ assert snap["hourly_budget_usd"] == 1.5
170
+
171
+ def test_snapshot_optional_limits_default_none(self):
172
+ g = Governor()
173
+ snap = g.snapshot
174
+ assert snap["max_total_tokens"] is None
175
+ assert snap["hourly_budget_usd"] is None