agent-killswitch 0.1.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.
- agent_killswitch/__init__.py +76 -0
- agent_killswitch/_version.py +5 -0
- agent_killswitch/backends/__init__.py +21 -0
- agent_killswitch/backends/base.py +95 -0
- agent_killswitch/backends/memory.py +120 -0
- agent_killswitch/backends/redis.py +230 -0
- agent_killswitch/core/__init__.py +36 -0
- agent_killswitch/core/budget_kill.py +281 -0
- agent_killswitch/core/cascading.py +280 -0
- agent_killswitch/core/circuit_breaker.py +323 -0
- agent_killswitch/core/enums.py +112 -0
- agent_killswitch/core/heartbeat.py +313 -0
- agent_killswitch/core/killswitch.py +581 -0
- agent_killswitch/core/models.py +173 -0
- agent_killswitch/decorators.py +302 -0
- agent_killswitch/integrations/__init__.py +17 -0
- agent_killswitch/integrations/asyncio_tasks.py +211 -0
- agent_killswitch/integrations/crewai.py +151 -0
- agent_killswitch/integrations/langchain.py +135 -0
- agent_killswitch/integrations/langgraph.py +143 -0
- agent_killswitch/integrations/openai_agents.py +151 -0
- agent_killswitch/py.typed +0 -0
- agent_killswitch-0.1.0.dist-info/METADATA +392 -0
- agent_killswitch-0.1.0.dist-info/RECORD +26 -0
- agent_killswitch-0.1.0.dist-info/WHEEL +4 -0
- agent_killswitch-0.1.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Circuit breaker pattern for protecting against cascading failures.
|
|
2
|
+
|
|
3
|
+
Implements the circuit breaker pattern with three states:
|
|
4
|
+
- CLOSED: Normal operation, requests pass through
|
|
5
|
+
- OPEN: Too many failures, requests are rejected immediately
|
|
6
|
+
- HALF_OPEN: Recovery testing, limited requests pass through
|
|
7
|
+
|
|
8
|
+
Thread-safe: All state is protected by a reentrant lock.
|
|
9
|
+
No external dependencies: Uses only Python stdlib.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import logging
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from typing import Any, TypeVar
|
|
20
|
+
|
|
21
|
+
from .enums import CircuitState
|
|
22
|
+
from .models import CircuitBreakerState
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CircuitBreakerOpen(Exception):
|
|
30
|
+
"""Raised when a call is rejected because the circuit breaker is open.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
breaker_name: Name of the circuit breaker that rejected the call.
|
|
34
|
+
time_until_half_open: Seconds until the breaker will try HALF_OPEN.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, breaker_name: str, time_until_half_open: float) -> None:
|
|
38
|
+
self.breaker_name = breaker_name
|
|
39
|
+
self.time_until_half_open = time_until_half_open
|
|
40
|
+
super().__init__(
|
|
41
|
+
f"Circuit breaker '{breaker_name}' is OPEN. Try again in {time_until_half_open:.1f}s."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CircuitBreaker:
|
|
46
|
+
"""Circuit breaker for protecting against cascading failures.
|
|
47
|
+
|
|
48
|
+
Wraps function calls and tracks failures. When failures exceed
|
|
49
|
+
the threshold, the breaker opens and rejects calls immediately
|
|
50
|
+
(fail-fast) instead of waiting for timeouts.
|
|
51
|
+
|
|
52
|
+
Example::
|
|
53
|
+
|
|
54
|
+
from agent_killswitch import CircuitBreaker
|
|
55
|
+
|
|
56
|
+
breaker = CircuitBreaker(
|
|
57
|
+
name="openai-api",
|
|
58
|
+
failure_threshold=5,
|
|
59
|
+
recovery_timeout=30.0,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
result = breaker.call(my_api_function, arg1, arg2)
|
|
64
|
+
except CircuitBreakerOpen:
|
|
65
|
+
print("API is down, using fallback")
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
name: Identifier for this circuit breaker.
|
|
69
|
+
failure_threshold: Consecutive failures to trip the breaker.
|
|
70
|
+
recovery_timeout: Seconds to wait before trying HALF_OPEN.
|
|
71
|
+
half_open_max_calls: Max calls allowed in HALF_OPEN state.
|
|
72
|
+
on_state_change: Callback on state transitions.
|
|
73
|
+
Signature: (name, old_state, new_state) -> None
|
|
74
|
+
on_open: Callback when breaker opens.
|
|
75
|
+
Signature: (name, failure_count) -> None
|
|
76
|
+
excluded_exceptions: Exception types that should NOT count as failures.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
name: str = "default",
|
|
82
|
+
failure_threshold: int = 5,
|
|
83
|
+
recovery_timeout: float = 30.0,
|
|
84
|
+
half_open_max_calls: int = 1,
|
|
85
|
+
on_state_change: Callable[[str, CircuitState, CircuitState], None] | None = None,
|
|
86
|
+
on_open: Callable[[str, int], None] | None = None,
|
|
87
|
+
excluded_exceptions: tuple[type[BaseException], ...] | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
self._state = CircuitBreakerState(
|
|
90
|
+
name=name,
|
|
91
|
+
failure_threshold=failure_threshold,
|
|
92
|
+
recovery_timeout=recovery_timeout,
|
|
93
|
+
half_open_max_calls=half_open_max_calls,
|
|
94
|
+
last_state_change=time.monotonic(),
|
|
95
|
+
)
|
|
96
|
+
self._on_state_change = on_state_change
|
|
97
|
+
self._on_open = on_open
|
|
98
|
+
self._excluded = excluded_exceptions or ()
|
|
99
|
+
self._lock = threading.RLock()
|
|
100
|
+
self._half_open_calls = 0
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def name(self) -> str:
|
|
104
|
+
"""Name of this circuit breaker."""
|
|
105
|
+
return self._state.name
|
|
106
|
+
|
|
107
|
+
def get_state(self) -> CircuitState:
|
|
108
|
+
"""Get the current circuit breaker state.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Current CircuitState.
|
|
112
|
+
"""
|
|
113
|
+
with self._lock:
|
|
114
|
+
self._maybe_transition_to_half_open()
|
|
115
|
+
return self._state.state
|
|
116
|
+
|
|
117
|
+
def _maybe_transition_to_half_open(self) -> None:
|
|
118
|
+
"""Check if it's time to transition from OPEN to HALF_OPEN."""
|
|
119
|
+
if self._state.state != CircuitState.OPEN:
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
elapsed = time.monotonic() - self._state.last_state_change
|
|
123
|
+
if elapsed >= self._state.recovery_timeout:
|
|
124
|
+
self._transition(CircuitState.HALF_OPEN)
|
|
125
|
+
|
|
126
|
+
def _transition(self, new_state: CircuitState) -> None:
|
|
127
|
+
"""Transition to a new state and fire callbacks."""
|
|
128
|
+
old_state = self._state.state
|
|
129
|
+
if old_state == new_state:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
self._state.state = new_state
|
|
133
|
+
self._state.last_state_change = time.monotonic()
|
|
134
|
+
|
|
135
|
+
if new_state == CircuitState.CLOSED:
|
|
136
|
+
self._state.failure_count = 0
|
|
137
|
+
self._state.success_count = 0
|
|
138
|
+
self._half_open_calls = 0
|
|
139
|
+
elif new_state == CircuitState.HALF_OPEN:
|
|
140
|
+
self._state.success_count = 0
|
|
141
|
+
self._half_open_calls = 0
|
|
142
|
+
|
|
143
|
+
logger.info(
|
|
144
|
+
"circuit_breaker_state_change",
|
|
145
|
+
extra={
|
|
146
|
+
"name": self._state.name,
|
|
147
|
+
"old_state": old_state.value,
|
|
148
|
+
"new_state": new_state.value,
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if self._on_state_change is not None:
|
|
153
|
+
try:
|
|
154
|
+
self._on_state_change(self._state.name, old_state, new_state)
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
logger.error(
|
|
157
|
+
"on_state_change callback error",
|
|
158
|
+
extra={"error": str(exc)},
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _record_failure(self) -> None:
|
|
162
|
+
"""Record a failure and possibly open the circuit."""
|
|
163
|
+
self._state.failure_count += 1
|
|
164
|
+
self._state.last_failure_time = time.monotonic()
|
|
165
|
+
self._state.success_count = 0
|
|
166
|
+
|
|
167
|
+
if self._state.state == CircuitState.HALF_OPEN:
|
|
168
|
+
# Any failure in HALF_OPEN immediately reopens
|
|
169
|
+
self._transition(CircuitState.OPEN)
|
|
170
|
+
if self._on_open is not None:
|
|
171
|
+
with contextlib.suppress(Exception):
|
|
172
|
+
self._on_open(self._state.name, self._state.failure_count)
|
|
173
|
+
elif self._state.failure_count >= self._state.failure_threshold:
|
|
174
|
+
self._transition(CircuitState.OPEN)
|
|
175
|
+
if self._on_open is not None:
|
|
176
|
+
with contextlib.suppress(Exception):
|
|
177
|
+
self._on_open(self._state.name, self._state.failure_count)
|
|
178
|
+
|
|
179
|
+
def _record_success(self) -> None:
|
|
180
|
+
"""Record a success and possibly close the circuit."""
|
|
181
|
+
self._state.success_count += 1
|
|
182
|
+
|
|
183
|
+
if self._state.state == CircuitState.HALF_OPEN:
|
|
184
|
+
if self._state.success_count >= self._state.half_open_max_calls:
|
|
185
|
+
self._transition(CircuitState.CLOSED)
|
|
186
|
+
elif self._state.state == CircuitState.CLOSED:
|
|
187
|
+
# Reset failure count on success
|
|
188
|
+
self._state.failure_count = 0
|
|
189
|
+
|
|
190
|
+
def call(self, func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
|
191
|
+
"""Execute a function with circuit breaker protection.
|
|
192
|
+
|
|
193
|
+
If the circuit is OPEN, raises CircuitBreakerOpen immediately.
|
|
194
|
+
If the circuit is CLOSED or HALF_OPEN, executes the function
|
|
195
|
+
and records the result.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
func: The function to execute.
|
|
199
|
+
*args: Positional arguments for the function.
|
|
200
|
+
**kwargs: Keyword arguments for the function.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
The return value of the function.
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
CircuitBreakerOpen: If the circuit is OPEN.
|
|
207
|
+
Exception: Any exception raised by the function.
|
|
208
|
+
"""
|
|
209
|
+
with self._lock:
|
|
210
|
+
self._maybe_transition_to_half_open()
|
|
211
|
+
state = self._state.state
|
|
212
|
+
|
|
213
|
+
if state == CircuitState.OPEN:
|
|
214
|
+
time_remaining = max(
|
|
215
|
+
0.0,
|
|
216
|
+
self._state.recovery_timeout
|
|
217
|
+
- (time.monotonic() - self._state.last_state_change),
|
|
218
|
+
)
|
|
219
|
+
raise CircuitBreakerOpen(self._state.name, time_remaining)
|
|
220
|
+
|
|
221
|
+
if state == CircuitState.HALF_OPEN:
|
|
222
|
+
if self._half_open_calls >= self._state.half_open_max_calls:
|
|
223
|
+
time_remaining = max(
|
|
224
|
+
0.0,
|
|
225
|
+
self._state.recovery_timeout
|
|
226
|
+
- (time.monotonic() - self._state.last_state_change),
|
|
227
|
+
)
|
|
228
|
+
raise CircuitBreakerOpen(self._state.name, time_remaining)
|
|
229
|
+
self._half_open_calls += 1
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
result = func(*args, **kwargs)
|
|
233
|
+
except BaseException as exc:
|
|
234
|
+
if isinstance(exc, self._excluded):
|
|
235
|
+
raise
|
|
236
|
+
with self._lock:
|
|
237
|
+
self._record_failure()
|
|
238
|
+
raise
|
|
239
|
+
else:
|
|
240
|
+
with self._lock:
|
|
241
|
+
self._record_success()
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
async def async_call(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
|
245
|
+
"""Execute an async function with circuit breaker protection.
|
|
246
|
+
|
|
247
|
+
Same semantics as call() but for async functions.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
func: The async function to execute.
|
|
251
|
+
*args: Positional arguments.
|
|
252
|
+
**kwargs: Keyword arguments.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
The return value of the async function.
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
CircuitBreakerOpen: If the circuit is OPEN.
|
|
259
|
+
"""
|
|
260
|
+
with self._lock:
|
|
261
|
+
self._maybe_transition_to_half_open()
|
|
262
|
+
state = self._state.state
|
|
263
|
+
|
|
264
|
+
if state == CircuitState.OPEN:
|
|
265
|
+
time_remaining = max(
|
|
266
|
+
0.0,
|
|
267
|
+
self._state.recovery_timeout
|
|
268
|
+
- (time.monotonic() - self._state.last_state_change),
|
|
269
|
+
)
|
|
270
|
+
raise CircuitBreakerOpen(self._state.name, time_remaining)
|
|
271
|
+
|
|
272
|
+
if state == CircuitState.HALF_OPEN:
|
|
273
|
+
if self._half_open_calls >= self._state.half_open_max_calls:
|
|
274
|
+
time_remaining = max(
|
|
275
|
+
0.0,
|
|
276
|
+
self._state.recovery_timeout
|
|
277
|
+
- (time.monotonic() - self._state.last_state_change),
|
|
278
|
+
)
|
|
279
|
+
raise CircuitBreakerOpen(self._state.name, time_remaining)
|
|
280
|
+
self._half_open_calls += 1
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
result = await func(*args, **kwargs)
|
|
284
|
+
except BaseException as exc:
|
|
285
|
+
if isinstance(exc, self._excluded):
|
|
286
|
+
raise
|
|
287
|
+
with self._lock:
|
|
288
|
+
self._record_failure()
|
|
289
|
+
raise
|
|
290
|
+
else:
|
|
291
|
+
with self._lock:
|
|
292
|
+
self._record_success()
|
|
293
|
+
return result
|
|
294
|
+
|
|
295
|
+
def reset(self) -> None:
|
|
296
|
+
"""Manually reset the circuit breaker to CLOSED state."""
|
|
297
|
+
with self._lock:
|
|
298
|
+
self._transition(CircuitState.CLOSED)
|
|
299
|
+
|
|
300
|
+
def trip(self) -> None:
|
|
301
|
+
"""Manually trip (open) the circuit breaker."""
|
|
302
|
+
with self._lock:
|
|
303
|
+
self._transition(CircuitState.OPEN)
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def failure_count(self) -> int:
|
|
307
|
+
"""Current consecutive failure count."""
|
|
308
|
+
with self._lock:
|
|
309
|
+
return self._state.failure_count
|
|
310
|
+
|
|
311
|
+
@property
|
|
312
|
+
def state_info(self) -> dict[str, Any]:
|
|
313
|
+
"""Full state information for debugging."""
|
|
314
|
+
with self._lock:
|
|
315
|
+
self._maybe_transition_to_half_open()
|
|
316
|
+
return {
|
|
317
|
+
"name": self._state.name,
|
|
318
|
+
"state": self._state.state.value,
|
|
319
|
+
"failure_count": self._state.failure_count,
|
|
320
|
+
"success_count": self._state.success_count,
|
|
321
|
+
"failure_threshold": self._state.failure_threshold,
|
|
322
|
+
"recovery_timeout": self._state.recovery_timeout,
|
|
323
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Enumerations for agent-killswitch.
|
|
2
|
+
|
|
3
|
+
Defines the core enums used throughout the library:
|
|
4
|
+
- KillLevel: Tiered severity (PAUSE/STOP/KILL)
|
|
5
|
+
- KillScope: Breadth of impact (GLOBAL/CREW/AGENT/TASK)
|
|
6
|
+
- HeartbeatStatus: Agent health states
|
|
7
|
+
- CircuitState: Circuit breaker states
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from enum import Enum
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class KillLevel(str, Enum):
|
|
16
|
+
"""Kill switch activation tiers with latency guarantees.
|
|
17
|
+
|
|
18
|
+
Each tier escalates the severity of the kill switch:
|
|
19
|
+
- PAUSE: Graceful -- stop new tasks, let in-flight complete (<200ms)
|
|
20
|
+
- STOP: Forceful -- checkpoint in-flight, pause all (<300ms)
|
|
21
|
+
- KILL: Immediate -- terminate everything (<500ms)
|
|
22
|
+
|
|
23
|
+
Numeric ordering: PAUSE < STOP < KILL (higher = more severe).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
PAUSE = "pause"
|
|
27
|
+
STOP = "stop"
|
|
28
|
+
KILL = "kill"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def severity(self) -> int:
|
|
32
|
+
"""Return numeric severity for comparison.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Integer severity: PAUSE=1, STOP=2, KILL=3.
|
|
36
|
+
"""
|
|
37
|
+
return _SEVERITY_MAP[self]
|
|
38
|
+
|
|
39
|
+
def __gt__(self, other: object) -> bool:
|
|
40
|
+
if not isinstance(other, KillLevel):
|
|
41
|
+
return NotImplemented
|
|
42
|
+
return self.severity > other.severity
|
|
43
|
+
|
|
44
|
+
def __ge__(self, other: object) -> bool:
|
|
45
|
+
if not isinstance(other, KillLevel):
|
|
46
|
+
return NotImplemented
|
|
47
|
+
return self.severity >= other.severity
|
|
48
|
+
|
|
49
|
+
def __lt__(self, other: object) -> bool:
|
|
50
|
+
if not isinstance(other, KillLevel):
|
|
51
|
+
return NotImplemented
|
|
52
|
+
return self.severity < other.severity
|
|
53
|
+
|
|
54
|
+
def __le__(self, other: object) -> bool:
|
|
55
|
+
if not isinstance(other, KillLevel):
|
|
56
|
+
return NotImplemented
|
|
57
|
+
return self.severity <= other.severity
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_SEVERITY_MAP: dict[KillLevel, int] = {
|
|
61
|
+
KillLevel.PAUSE: 1,
|
|
62
|
+
KillLevel.STOP: 2,
|
|
63
|
+
KillLevel.KILL: 3,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Latency guarantees per level (milliseconds)
|
|
67
|
+
LEVEL_LATENCY_GUARANTEE_MS: dict[KillLevel, float] = {
|
|
68
|
+
KillLevel.PAUSE: 200.0,
|
|
69
|
+
KillLevel.STOP: 300.0,
|
|
70
|
+
KillLevel.KILL: 500.0,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class KillScope(str, Enum):
|
|
75
|
+
"""Kill switch scope -- how broadly the kill switch applies.
|
|
76
|
+
|
|
77
|
+
Priority order for checking: GLOBAL > CREW > AGENT > TASK.
|
|
78
|
+
A global KILL overrides a crew-level PAUSE.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
GLOBAL = "global"
|
|
82
|
+
CREW = "crew"
|
|
83
|
+
AGENT = "agent"
|
|
84
|
+
TASK = "task"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class HeartbeatStatus(str, Enum):
|
|
88
|
+
"""Health status of an agent based on heartbeat monitoring.
|
|
89
|
+
|
|
90
|
+
- ALIVE: Heartbeat received within expected interval
|
|
91
|
+
- SUSPECT: One or more missed heartbeats, not yet dead
|
|
92
|
+
- DEAD: Exceeded miss threshold, considered unresponsive
|
|
93
|
+
- UNKNOWN: No heartbeat data available
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
ALIVE = "alive"
|
|
97
|
+
SUSPECT = "suspect"
|
|
98
|
+
DEAD = "dead"
|
|
99
|
+
UNKNOWN = "unknown"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class CircuitState(str, Enum):
|
|
103
|
+
"""Circuit breaker states.
|
|
104
|
+
|
|
105
|
+
- CLOSED: Normal operation, requests pass through
|
|
106
|
+
- OPEN: Failing, requests are rejected immediately
|
|
107
|
+
- HALF_OPEN: Testing recovery, limited requests pass through
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
CLOSED = "closed"
|
|
111
|
+
OPEN = "open"
|
|
112
|
+
HALF_OPEN = "half_open"
|