apache-aiops 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ """Runaway-loop safety guard + opt-in per-process call/time ceiling.
2
+
3
+ A safety backstop, NOT authorization: it never looks at whether an operation is
4
+ a read or a write, only at whether the same call is being hammered pointlessly.
5
+ An LLM agent that gets stuck looping a tool (polling a long task, retrying a
6
+ flaky call) otherwise consumes unbounded calls and wall-time — exactly the
7
+ failure mode behind the "deleting one snapshot burned 26k tokens over 30 min"
8
+ incident. A stuck agent cannot self-correct by definition, so this stops it.
9
+ Two layers, both enforced from the ``@governed_tool`` pre-check:
10
+
11
+ 1. **Hard ceilings (opt-in via env).** Total tool calls and cumulative
12
+ wall-time per process. ``APACHE_MAX_TOOL_CALLS`` / ``APACHE_MAX_TOOL_SECONDS``.
13
+ Unset (the default) means no ceiling.
14
+
15
+ 2. **Runaway breaker (on by default, disable with ``APACHE_RUNAWAY_MAX=0``).**
16
+ The same ``(tool, params)`` called more than ``APACHE_RUNAWAY_MAX`` times
17
+ within ``APACHE_RUNAWAY_WINDOW_SEC`` trips a short cooldown. Catches a tight
18
+ poll/retry loop without affecting normal, varied tool use. Defaults are
19
+ generous (25 identical calls in 120s), so ordinary use never trips it.
20
+
21
+ Exceeding any limit raises :class:`BudgetExceeded`, carrying a teaching message.
22
+ The exception is a *hard stop*: it forces the agent to break out of the loop
23
+ rather than keep spending, and the harness audits it as a stopped call.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import threading
31
+ import time
32
+ from collections import deque
33
+ from dataclasses import dataclass, field
34
+
35
+ __all__ = ["BudgetExceeded", "BudgetTracker", "get_budget", "reset_budget"]
36
+
37
+
38
+ # Defaults — chosen so normal use never trips them, but a runaway loop does.
39
+ _DEFAULT_RUNAWAY_MAX = 25
40
+ _DEFAULT_RUNAWAY_WINDOW_SEC = 120
41
+
42
+
43
+ class BudgetExceeded(Exception):
44
+ """Raised when a tool call would exceed a ceiling or trip the runaway guard.
45
+
46
+ Its own type (not a policy denial) so callers can catch a runaway/budget
47
+ stop distinctly. Carries ``reason`` (a teaching message) and ``rule`` (which
48
+ limit tripped) for the audit row.
49
+ """
50
+
51
+ def __init__(self, reason: str, rule: str = "budget") -> None:
52
+ self.reason = reason
53
+ self.rule = rule
54
+ super().__init__(reason)
55
+
56
+
57
+ def _env_int(name: str, default: int | None) -> int | None:
58
+ """Read an int budget limit from env, falling back to ``default``."""
59
+ raw = os.environ.get(name)
60
+ if raw is None or raw == "":
61
+ return default
62
+ try:
63
+ return int(raw)
64
+ except ValueError:
65
+ return default
66
+
67
+
68
+ @dataclass
69
+ class _State:
70
+ total_calls: int = 0
71
+ total_seconds: float = 0.0
72
+ # fingerprint -> sliding window of epoch-second timestamps
73
+ windows: dict[str, deque] = field(default_factory=dict)
74
+
75
+
76
+ class BudgetTracker:
77
+ """Thread-safe per-process budget + runaway guard.
78
+
79
+ Limits are read from the environment on every check so they can be raised
80
+ mid-session (e.g. an operator sets ``APACHE_RUNAWAY_MAX`` higher) without a
81
+ restart. State is in-memory and per process — it resets when the MCP server
82
+ restarts, which is the correct scope for a single agent session.
83
+ """
84
+
85
+ def __init__(self) -> None:
86
+ self._lock = threading.Lock()
87
+ self._state = _State()
88
+
89
+ # ── Limits (read live from env) ───────────────────────────────────
90
+
91
+ @staticmethod
92
+ def _max_calls() -> int | None:
93
+ return _env_int("APACHE_MAX_TOOL_CALLS", None)
94
+
95
+ @staticmethod
96
+ def _max_seconds() -> int | None:
97
+ return _env_int("APACHE_MAX_TOOL_SECONDS", None)
98
+
99
+ @staticmethod
100
+ def _runaway_max() -> int:
101
+ # 0 or negative disables the runaway breaker.
102
+ return _env_int("APACHE_RUNAWAY_MAX", _DEFAULT_RUNAWAY_MAX) or 0
103
+
104
+ @staticmethod
105
+ def _runaway_window() -> int:
106
+ return _env_int("APACHE_RUNAWAY_WINDOW_SEC", _DEFAULT_RUNAWAY_WINDOW_SEC) or 0
107
+
108
+ # ── Enforcement ───────────────────────────────────────────────────
109
+
110
+ def check_and_record(self, tool: str, params: dict | None) -> None:
111
+ """Enforce ceilings + runaway guard, then record this call.
112
+
113
+ Raises :class:`BudgetExceeded` before the call proceeds when a limit
114
+ would be crossed. Only called for operations the policy engine already
115
+ allowed, so denied calls do not count against the budget.
116
+ """
117
+ now = time.time()
118
+ fp = _fingerprint(tool, params)
119
+ with self._lock:
120
+ # 1. Hard call ceiling
121
+ max_calls = self._max_calls()
122
+ if max_calls is not None and self._state.total_calls >= max_calls:
123
+ raise BudgetExceeded(
124
+ f"Tool-call budget reached: {self._state.total_calls} calls "
125
+ f"(APACHE_MAX_TOOL_CALLS={max_calls}). Stop and summarize what "
126
+ f"is done; raise the ceiling only if the work genuinely needs "
127
+ f"more calls.",
128
+ rule="budget_calls",
129
+ )
130
+
131
+ # 2. Hard wall-time ceiling (cumulative across all tool calls)
132
+ max_seconds = self._max_seconds()
133
+ if max_seconds is not None and self._state.total_seconds >= max_seconds:
134
+ raise BudgetExceeded(
135
+ f"Cumulative tool wall-time budget reached: "
136
+ f"{self._state.total_seconds:.0f}s "
137
+ f"(APACHE_MAX_TOOL_SECONDS={max_seconds}). Stop and report "
138
+ f"progress rather than continuing.",
139
+ rule="budget_seconds",
140
+ )
141
+
142
+ # 3. Runaway breaker — same (tool, params) hammered in a short window
143
+ runaway_max = self._runaway_max()
144
+ window = self._runaway_window()
145
+ if runaway_max > 0 and window > 0:
146
+ dq = self._state.windows.get(fp)
147
+ if dq is None:
148
+ dq = deque()
149
+ self._state.windows[fp] = dq
150
+ cutoff = now - window
151
+ while dq and dq[0] < cutoff:
152
+ dq.popleft()
153
+ if len(dq) >= runaway_max:
154
+ raise BudgetExceeded(
155
+ f"Runaway guard tripped: '{tool}' called {len(dq)} times "
156
+ f"with identical arguments in {window}s. This usually means "
157
+ f"a poll/retry loop is stuck. Stop re-calling — check the "
158
+ f"operation's status once, or wait, instead of looping. "
159
+ f"(Raise APACHE_RUNAWAY_MAX if this is genuinely intended.)",
160
+ rule="budget_runaway",
161
+ )
162
+ dq.append(now)
163
+
164
+ self._state.total_calls += 1
165
+
166
+ def add_duration(self, seconds: float) -> None:
167
+ """Accumulate a completed call's wall-time toward the time ceiling."""
168
+ if seconds <= 0:
169
+ return
170
+ with self._lock:
171
+ self._state.total_seconds += seconds
172
+
173
+ def snapshot(self) -> dict:
174
+ """Return a copy of current counters (for CLI / introspection / tests)."""
175
+ with self._lock:
176
+ return {
177
+ "total_calls": self._state.total_calls,
178
+ "total_seconds": round(self._state.total_seconds, 1),
179
+ "tracked_fingerprints": len(self._state.windows),
180
+ "limits": {
181
+ "max_calls": self._max_calls(),
182
+ "max_seconds": self._max_seconds(),
183
+ "runaway_max": self._runaway_max(),
184
+ "runaway_window_sec": self._runaway_window(),
185
+ },
186
+ }
187
+
188
+
189
+ def _fingerprint(tool: str, params: dict | None) -> str:
190
+ """Stable identity for a (tool, params) pair used by the runaway breaker.
191
+
192
+ Uses already-redacted params (secrets are '***'), so the fingerprint is
193
+ stable and safe. Falls back to repr on non-serializable values.
194
+ """
195
+ try:
196
+ body = json.dumps(params or {}, sort_keys=True, default=str)
197
+ except Exception: # noqa: BLE001 — fingerprint must never raise
198
+ body = repr(params)
199
+ return f"{tool}|{body}"
200
+
201
+
202
+ # ── Singleton ──────────────────────────────────────────────────────────
203
+
204
+ _tracker: BudgetTracker | None = None
205
+ _tracker_lock = threading.Lock()
206
+
207
+
208
+ def get_budget() -> BudgetTracker:
209
+ """Return the global per-process BudgetTracker singleton."""
210
+ global _tracker
211
+ if _tracker is None:
212
+ with _tracker_lock:
213
+ if _tracker is None:
214
+ _tracker = BudgetTracker()
215
+ return _tracker
216
+
217
+
218
+ def reset_budget() -> None:
219
+ """Reset the singleton. Tests use this between cases."""
220
+ global _tracker
221
+ with _tracker_lock:
222
+ _tracker = None