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.
- apache_aiops/__init__.py +8 -0
- apache_aiops/cli/__init__.py +9 -0
- apache_aiops/cli/_common.py +118 -0
- apache_aiops/cli/_root.py +206 -0
- apache_aiops/cli/undo.py +66 -0
- apache_aiops/config.py +74 -0
- apache_aiops/connection.py +222 -0
- apache_aiops/governance/__init__.py +50 -0
- apache_aiops/governance/audit.py +526 -0
- apache_aiops/governance/budget.py +222 -0
- apache_aiops/governance/decorators.py +599 -0
- apache_aiops/governance/outcome.py +102 -0
- apache_aiops/governance/paths.py +23 -0
- apache_aiops/governance/patterns.py +396 -0
- apache_aiops/governance/policy.py +67 -0
- apache_aiops/governance/sanitize.py +79 -0
- apache_aiops/governance/undo.py +259 -0
- apache_aiops/mcp_server/__init__.py +1 -0
- apache_aiops/mcp_server/_annotations.py +66 -0
- apache_aiops/mcp_server/_shared.py +67 -0
- apache_aiops/mcp_server/server.py +14 -0
- apache_aiops/mcp_server/tools/__init__.py +1 -0
- apache_aiops/mcp_server/tools/httpd.py +143 -0
- apache_aiops/mcp_server/tools/undo.py +204 -0
- apache_aiops/ops/__init__.py +1 -0
- apache_aiops/ops/httpd.py +81 -0
- apache_aiops-0.2.0.dist-info/METADATA +50 -0
- apache_aiops-0.2.0.dist-info/RECORD +31 -0
- apache_aiops-0.2.0.dist-info/WHEEL +4 -0
- apache_aiops-0.2.0.dist-info/entry_points.txt +3 -0
- apache_aiops-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|