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,313 @@
|
|
|
1
|
+
"""Heartbeat monitor for tracking agent health.
|
|
2
|
+
|
|
3
|
+
Provides a HeartbeatMonitor that tracks periodic heartbeats from agents
|
|
4
|
+
and detects when agents become unresponsive. Designed to work with the
|
|
5
|
+
KillSwitch for automatic agent termination on death detection.
|
|
6
|
+
|
|
7
|
+
Thread-safe: All state is protected by a reentrant lock.
|
|
8
|
+
No external dependencies: Uses only Python stdlib (threading, time).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
|
|
19
|
+
from .enums import HeartbeatStatus
|
|
20
|
+
from .models import HeartbeatRecord
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HeartbeatMonitor:
|
|
26
|
+
"""Monitor agent health via periodic heartbeats.
|
|
27
|
+
|
|
28
|
+
Agents send periodic heartbeats to indicate they are alive.
|
|
29
|
+
The monitor tracks heartbeats and detects dead agents based
|
|
30
|
+
on a configurable miss threshold.
|
|
31
|
+
|
|
32
|
+
Example::
|
|
33
|
+
|
|
34
|
+
from agent_killswitch import HeartbeatMonitor
|
|
35
|
+
|
|
36
|
+
monitor = HeartbeatMonitor(
|
|
37
|
+
interval_seconds=5.0,
|
|
38
|
+
miss_threshold=3,
|
|
39
|
+
on_dead=lambda agent_id: print(f"DEAD: {agent_id}"),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Start monitoring
|
|
43
|
+
monitor.start("agent-1")
|
|
44
|
+
|
|
45
|
+
# Agent sends heartbeats periodically
|
|
46
|
+
monitor.heartbeat("agent-1")
|
|
47
|
+
|
|
48
|
+
# Check health
|
|
49
|
+
health = monitor.check_health()
|
|
50
|
+
for agent_id, status in health.items():
|
|
51
|
+
print(f"{agent_id}: {status}")
|
|
52
|
+
|
|
53
|
+
# Stop monitoring
|
|
54
|
+
monitor.stop("agent-1")
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
interval_seconds: Expected heartbeat interval. Default 5.0s.
|
|
58
|
+
miss_threshold: Number of missed intervals before DEAD. Default 3.
|
|
59
|
+
on_dead: Callback when an agent is detected as dead.
|
|
60
|
+
Signature: (agent_id: str) -> None
|
|
61
|
+
on_suspect: Callback when an agent becomes suspect (missed 1+).
|
|
62
|
+
Signature: (agent_id: str, miss_count: int) -> None
|
|
63
|
+
auto_check_interval: If > 0, starts a background thread that
|
|
64
|
+
checks health periodically. Default 0 (disabled).
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
interval_seconds: float = 5.0,
|
|
70
|
+
miss_threshold: int = 3,
|
|
71
|
+
on_dead: Callable[[str], None] | None = None,
|
|
72
|
+
on_suspect: Callable[[str, int], None] | None = None,
|
|
73
|
+
auto_check_interval: float = 0.0,
|
|
74
|
+
) -> None:
|
|
75
|
+
self._interval = interval_seconds
|
|
76
|
+
self._miss_threshold = miss_threshold
|
|
77
|
+
self._on_dead = on_dead
|
|
78
|
+
self._on_suspect = on_suspect
|
|
79
|
+
self._lock = threading.RLock()
|
|
80
|
+
|
|
81
|
+
# Agent heartbeat records
|
|
82
|
+
self._records: dict[str, HeartbeatRecord] = {}
|
|
83
|
+
|
|
84
|
+
# Track which agents have already been reported as dead
|
|
85
|
+
# (to avoid repeated callbacks)
|
|
86
|
+
self._dead_reported: set[str] = set()
|
|
87
|
+
|
|
88
|
+
# Background checker
|
|
89
|
+
self._checker_thread: threading.Thread | None = None
|
|
90
|
+
self._checker_stop = threading.Event()
|
|
91
|
+
self._auto_check_interval = auto_check_interval
|
|
92
|
+
|
|
93
|
+
if auto_check_interval > 0:
|
|
94
|
+
self._start_checker()
|
|
95
|
+
|
|
96
|
+
def _start_checker(self) -> None:
|
|
97
|
+
"""Start the background health check thread."""
|
|
98
|
+
self._checker_stop.clear()
|
|
99
|
+
self._checker_thread = threading.Thread(
|
|
100
|
+
target=self._checker_loop,
|
|
101
|
+
daemon=True,
|
|
102
|
+
name="killswitch-heartbeat-checker",
|
|
103
|
+
)
|
|
104
|
+
self._checker_thread.start()
|
|
105
|
+
|
|
106
|
+
def _checker_loop(self) -> None:
|
|
107
|
+
"""Background loop that checks health periodically."""
|
|
108
|
+
while not self._checker_stop.is_set():
|
|
109
|
+
try:
|
|
110
|
+
self.check_health()
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
logger.error(
|
|
113
|
+
"Heartbeat checker error",
|
|
114
|
+
extra={"error": str(exc)},
|
|
115
|
+
)
|
|
116
|
+
self._checker_stop.wait(self._auto_check_interval)
|
|
117
|
+
|
|
118
|
+
def shutdown(self) -> None:
|
|
119
|
+
"""Stop the background checker and clean up."""
|
|
120
|
+
self._checker_stop.set()
|
|
121
|
+
if self._checker_thread is not None:
|
|
122
|
+
self._checker_thread.join(timeout=5.0)
|
|
123
|
+
self._checker_thread = None
|
|
124
|
+
|
|
125
|
+
def start(
|
|
126
|
+
self,
|
|
127
|
+
agent_id: str,
|
|
128
|
+
interval_seconds: float | None = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Start monitoring an agent.
|
|
131
|
+
|
|
132
|
+
Records the initial heartbeat and begins tracking.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
agent_id: Unique identifier of the agent to monitor.
|
|
136
|
+
interval_seconds: Override the default interval for this agent.
|
|
137
|
+
"""
|
|
138
|
+
now = datetime.now(timezone.utc)
|
|
139
|
+
record = HeartbeatRecord(
|
|
140
|
+
agent_id=agent_id,
|
|
141
|
+
last_heartbeat=now,
|
|
142
|
+
status=HeartbeatStatus.ALIVE,
|
|
143
|
+
miss_count=0,
|
|
144
|
+
interval_seconds=interval_seconds or self._interval,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
with self._lock:
|
|
148
|
+
self._records[agent_id] = record
|
|
149
|
+
self._dead_reported.discard(agent_id)
|
|
150
|
+
|
|
151
|
+
logger.info(
|
|
152
|
+
"heartbeat_monitoring_started",
|
|
153
|
+
extra={
|
|
154
|
+
"agent_id": agent_id,
|
|
155
|
+
"interval_seconds": record.interval_seconds,
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def stop(self, agent_id: str) -> None:
|
|
160
|
+
"""Stop monitoring an agent.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
agent_id: The agent to stop monitoring.
|
|
164
|
+
"""
|
|
165
|
+
with self._lock:
|
|
166
|
+
self._records.pop(agent_id, None)
|
|
167
|
+
self._dead_reported.discard(agent_id)
|
|
168
|
+
|
|
169
|
+
logger.info("heartbeat_monitoring_stopped", extra={"agent_id": agent_id})
|
|
170
|
+
|
|
171
|
+
def heartbeat(self, agent_id: str) -> bool:
|
|
172
|
+
"""Record a heartbeat from an agent.
|
|
173
|
+
|
|
174
|
+
Resets the miss count and marks the agent as ALIVE.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
agent_id: The agent sending the heartbeat.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
True if the heartbeat was recorded, False if agent not monitored.
|
|
181
|
+
"""
|
|
182
|
+
with self._lock:
|
|
183
|
+
record = self._records.get(agent_id)
|
|
184
|
+
if record is None:
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
record.last_heartbeat = datetime.now(timezone.utc)
|
|
188
|
+
record.miss_count = 0
|
|
189
|
+
record.status = HeartbeatStatus.ALIVE
|
|
190
|
+
self._dead_reported.discard(agent_id)
|
|
191
|
+
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
def check_health(self) -> dict[str, HeartbeatStatus]:
|
|
195
|
+
"""Check health of all monitored agents.
|
|
196
|
+
|
|
197
|
+
Calculates how many intervals have been missed since the last
|
|
198
|
+
heartbeat and updates the status accordingly.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Dictionary of agent_id -> HeartbeatStatus.
|
|
202
|
+
"""
|
|
203
|
+
time.monotonic()
|
|
204
|
+
now_dt = datetime.now(timezone.utc)
|
|
205
|
+
results: dict[str, HeartbeatStatus] = {}
|
|
206
|
+
|
|
207
|
+
with self._lock:
|
|
208
|
+
for agent_id, record in list(self._records.items()):
|
|
209
|
+
if record.last_heartbeat is None:
|
|
210
|
+
record.status = HeartbeatStatus.UNKNOWN
|
|
211
|
+
results[agent_id] = HeartbeatStatus.UNKNOWN
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
elapsed = (now_dt - record.last_heartbeat).total_seconds()
|
|
215
|
+
missed = int(elapsed / record.interval_seconds)
|
|
216
|
+
|
|
217
|
+
if missed <= 0:
|
|
218
|
+
record.miss_count = 0
|
|
219
|
+
record.status = HeartbeatStatus.ALIVE
|
|
220
|
+
else:
|
|
221
|
+
record.miss_count = missed
|
|
222
|
+
|
|
223
|
+
if missed >= self._miss_threshold:
|
|
224
|
+
record.status = HeartbeatStatus.DEAD
|
|
225
|
+
|
|
226
|
+
# Fire dead callback (once per death event)
|
|
227
|
+
if agent_id not in self._dead_reported:
|
|
228
|
+
self._dead_reported.add(agent_id)
|
|
229
|
+
if self._on_dead is not None:
|
|
230
|
+
try:
|
|
231
|
+
self._on_dead(agent_id)
|
|
232
|
+
except Exception as exc:
|
|
233
|
+
logger.error(
|
|
234
|
+
"on_dead callback error",
|
|
235
|
+
extra={
|
|
236
|
+
"agent_id": agent_id,
|
|
237
|
+
"error": str(exc),
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
record.status = HeartbeatStatus.SUSPECT
|
|
242
|
+
|
|
243
|
+
# Fire suspect callback
|
|
244
|
+
if self._on_suspect is not None:
|
|
245
|
+
try:
|
|
246
|
+
self._on_suspect(agent_id, missed)
|
|
247
|
+
except Exception as exc:
|
|
248
|
+
logger.error(
|
|
249
|
+
"on_suspect callback error",
|
|
250
|
+
extra={
|
|
251
|
+
"agent_id": agent_id,
|
|
252
|
+
"error": str(exc),
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
results[agent_id] = record.status
|
|
257
|
+
|
|
258
|
+
return results
|
|
259
|
+
|
|
260
|
+
def get_dead_agents(self) -> list[str]:
|
|
261
|
+
"""Return IDs of all agents currently detected as dead.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
List of dead agent IDs.
|
|
265
|
+
"""
|
|
266
|
+
health = self.check_health()
|
|
267
|
+
return [agent_id for agent_id, status in health.items() if status == HeartbeatStatus.DEAD]
|
|
268
|
+
|
|
269
|
+
def get_record(self, agent_id: str) -> HeartbeatRecord | None:
|
|
270
|
+
"""Get the heartbeat record for an agent.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
agent_id: The agent to look up.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
The HeartbeatRecord, or None if not monitored.
|
|
277
|
+
"""
|
|
278
|
+
with self._lock:
|
|
279
|
+
record = self._records.get(agent_id)
|
|
280
|
+
if record is None:
|
|
281
|
+
return None
|
|
282
|
+
# Return a copy to prevent external mutation
|
|
283
|
+
return HeartbeatRecord(
|
|
284
|
+
agent_id=record.agent_id,
|
|
285
|
+
last_heartbeat=record.last_heartbeat,
|
|
286
|
+
status=record.status,
|
|
287
|
+
miss_count=record.miss_count,
|
|
288
|
+
interval_seconds=record.interval_seconds,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def get_all_records(self) -> dict[str, HeartbeatRecord]:
|
|
292
|
+
"""Get heartbeat records for all monitored agents.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
Dictionary of agent_id -> HeartbeatRecord (copies).
|
|
296
|
+
"""
|
|
297
|
+
with self._lock:
|
|
298
|
+
return {
|
|
299
|
+
agent_id: HeartbeatRecord(
|
|
300
|
+
agent_id=rec.agent_id,
|
|
301
|
+
last_heartbeat=rec.last_heartbeat,
|
|
302
|
+
status=rec.status,
|
|
303
|
+
miss_count=rec.miss_count,
|
|
304
|
+
interval_seconds=rec.interval_seconds,
|
|
305
|
+
)
|
|
306
|
+
for agent_id, rec in self._records.items()
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def monitored_count(self) -> int:
|
|
311
|
+
"""Number of agents currently being monitored."""
|
|
312
|
+
with self._lock:
|
|
313
|
+
return len(self._records)
|