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,581 @@
|
|
|
1
|
+
"""Main KillSwitch class -- the unified API for agent safety controls.
|
|
2
|
+
|
|
3
|
+
Provides a single entry point for:
|
|
4
|
+
- Activating/deactivating kill switches at tiered levels
|
|
5
|
+
- Checking kill status with scope priority resolution
|
|
6
|
+
- Registering agents in a hierarchy for cascading termination
|
|
7
|
+
- Audit trail of all kill events
|
|
8
|
+
|
|
9
|
+
The kill switch operates OUTSIDE the agent reasoning path -- it is checked
|
|
10
|
+
at the infrastructure/middleware level, not in the agent's prompt or
|
|
11
|
+
decision loop.
|
|
12
|
+
|
|
13
|
+
Fail-closed: If the backend is unreachable, the kill switch defaults
|
|
14
|
+
to KILLED state. Safety first.
|
|
15
|
+
|
|
16
|
+
Key scheme (configurable prefix):
|
|
17
|
+
{prefix}global -> level
|
|
18
|
+
{prefix}crew:{scope_target} -> level
|
|
19
|
+
{prefix}agent:{scope_target} -> level
|
|
20
|
+
{prefix}task:{scope_target} -> level
|
|
21
|
+
|
|
22
|
+
Priority order: GLOBAL > CREW > AGENT > TASK
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import contextlib
|
|
28
|
+
import logging
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from typing import Any
|
|
34
|
+
from uuid import uuid4
|
|
35
|
+
|
|
36
|
+
from ..backends.memory import InMemoryBackend
|
|
37
|
+
from .enums import LEVEL_LATENCY_GUARANTEE_MS, KillLevel, KillScope
|
|
38
|
+
from .models import AgentRegistration, KillEvent, KillStatus
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
# Scope priority: higher index = lower priority
|
|
43
|
+
_SCOPE_PRIORITY: list[KillScope] = [
|
|
44
|
+
KillScope.GLOBAL,
|
|
45
|
+
KillScope.CREW,
|
|
46
|
+
KillScope.AGENT,
|
|
47
|
+
KillScope.TASK,
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
# Key templates per scope
|
|
51
|
+
_KEY_TEMPLATES: dict[KillScope, str] = {
|
|
52
|
+
KillScope.GLOBAL: "global",
|
|
53
|
+
KillScope.CREW: "crew:{scope_target}",
|
|
54
|
+
KillScope.AGENT: "agent:{scope_target}",
|
|
55
|
+
KillScope.TASK: "task:{scope_target}",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class KillSwitch:
|
|
60
|
+
"""Operational safety kill switch for autonomous AI agents.
|
|
61
|
+
|
|
62
|
+
The KillSwitch provides tiered emergency controls (PAUSE/STOP/KILL)
|
|
63
|
+
across configurable scopes (GLOBAL/CREW/AGENT/TASK). It is designed
|
|
64
|
+
to be checked at the infrastructure layer before every agent action.
|
|
65
|
+
|
|
66
|
+
Thread-safe: All internal state is protected by a reentrant lock.
|
|
67
|
+
Fail-closed: Backend failures result in KILLED status for safety.
|
|
68
|
+
|
|
69
|
+
Example::
|
|
70
|
+
|
|
71
|
+
from agent_killswitch import KillSwitch, KillLevel, KillScope
|
|
72
|
+
|
|
73
|
+
ks = KillSwitch()
|
|
74
|
+
|
|
75
|
+
# Register an agent
|
|
76
|
+
ks.register_agent("agent-1")
|
|
77
|
+
|
|
78
|
+
# Check status (returns KillStatus)
|
|
79
|
+
status = ks.check("agent-1")
|
|
80
|
+
if status.is_killed:
|
|
81
|
+
print(f"Agent killed: {status.level}")
|
|
82
|
+
|
|
83
|
+
# Activate kill switch
|
|
84
|
+
ks.activate(KillLevel.STOP, KillScope.AGENT, "agent-1", reason="runaway")
|
|
85
|
+
|
|
86
|
+
# Deactivate
|
|
87
|
+
ks.deactivate(KillScope.AGENT, "agent-1")
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
backend: Storage backend. Defaults to InMemoryBackend.
|
|
91
|
+
prefix: Key prefix for backend storage. Defaults to "".
|
|
92
|
+
on_kill: Optional callback invoked on every activation.
|
|
93
|
+
Signature: (event: KillEvent) -> None
|
|
94
|
+
fail_closed: If True (default), backend errors result in KILLED.
|
|
95
|
+
If False, backend errors result in safe (not killed).
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
backend: Any = None,
|
|
101
|
+
prefix: str = "",
|
|
102
|
+
on_kill: Callable[[KillEvent], None] | None = None,
|
|
103
|
+
fail_closed: bool = True,
|
|
104
|
+
) -> None:
|
|
105
|
+
self._backend = backend if backend is not None else InMemoryBackend()
|
|
106
|
+
self._prefix = prefix
|
|
107
|
+
self._on_kill = on_kill
|
|
108
|
+
self._fail_closed = fail_closed
|
|
109
|
+
self._lock = threading.RLock()
|
|
110
|
+
|
|
111
|
+
# Agent registry
|
|
112
|
+
self._agents: dict[str, AgentRegistration] = {}
|
|
113
|
+
|
|
114
|
+
# Audit trail
|
|
115
|
+
self._events: list[KillEvent] = []
|
|
116
|
+
|
|
117
|
+
# Activation reasons (stored alongside level)
|
|
118
|
+
self._reasons: dict[str, str] = {}
|
|
119
|
+
self._activation_times: dict[str, datetime] = {}
|
|
120
|
+
|
|
121
|
+
logger.info(
|
|
122
|
+
"KillSwitch initialized",
|
|
123
|
+
extra={
|
|
124
|
+
"backend": type(self._backend).__name__,
|
|
125
|
+
"prefix": prefix,
|
|
126
|
+
"fail_closed": fail_closed,
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# -- Key management --
|
|
131
|
+
|
|
132
|
+
def _make_key(self, scope: KillScope, scope_target: str | None = None) -> str:
|
|
133
|
+
"""Build the storage key for a given scope.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
scope: The kill scope.
|
|
137
|
+
scope_target: Required for non-GLOBAL scopes.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
The full storage key including prefix.
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
ValueError: If scope requires scope_target but none provided.
|
|
144
|
+
"""
|
|
145
|
+
if scope != KillScope.GLOBAL and not scope_target:
|
|
146
|
+
raise ValueError(f"scope_target required for {scope.value} scope")
|
|
147
|
+
|
|
148
|
+
template = _KEY_TEMPLATES[scope]
|
|
149
|
+
raw_key = template.format(scope_target=scope_target) if scope_target else template
|
|
150
|
+
return f"{self._prefix}{raw_key}"
|
|
151
|
+
|
|
152
|
+
# -- Core operations --
|
|
153
|
+
|
|
154
|
+
def activate(
|
|
155
|
+
self,
|
|
156
|
+
level: KillLevel,
|
|
157
|
+
scope: KillScope,
|
|
158
|
+
scope_target: str | None = None,
|
|
159
|
+
reason: str = "",
|
|
160
|
+
activated_by: str = "system",
|
|
161
|
+
metadata: dict[str, Any] | None = None,
|
|
162
|
+
) -> KillEvent:
|
|
163
|
+
"""Activate the kill switch at the specified level and scope.
|
|
164
|
+
|
|
165
|
+
Sets the backend flag and returns a KillEvent with activation
|
|
166
|
+
details. The latency is measured and logged.
|
|
167
|
+
|
|
168
|
+
Latency guarantees: PAUSE <200ms, STOP <300ms, KILL <500ms.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
level: The kill level (PAUSE, STOP, or KILL).
|
|
172
|
+
scope: The scope (GLOBAL, CREW, AGENT, or TASK).
|
|
173
|
+
scope_target: Target identifier (required for non-GLOBAL).
|
|
174
|
+
reason: Human-readable reason for the activation.
|
|
175
|
+
activated_by: Identifier of who/what activated the switch.
|
|
176
|
+
metadata: Additional metadata for audit logging.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
KillEvent with activation details including latency.
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
ValueError: If scope requires scope_target but none provided.
|
|
183
|
+
"""
|
|
184
|
+
start = time.monotonic()
|
|
185
|
+
|
|
186
|
+
key = self._make_key(scope, scope_target)
|
|
187
|
+
|
|
188
|
+
with self._lock:
|
|
189
|
+
try:
|
|
190
|
+
self._backend.set(key, level.value)
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
logger.error(
|
|
193
|
+
"KillSwitch backend SET failed, recording locally",
|
|
194
|
+
extra={"key": key, "error": str(exc)},
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Store reason and activation time
|
|
198
|
+
self._reasons[key] = reason
|
|
199
|
+
self._activation_times[key] = datetime.now(timezone.utc)
|
|
200
|
+
|
|
201
|
+
elapsed_ms = (time.monotonic() - start) * 1000
|
|
202
|
+
|
|
203
|
+
event = KillEvent(
|
|
204
|
+
event_id=uuid4().hex,
|
|
205
|
+
level=level,
|
|
206
|
+
scope=scope,
|
|
207
|
+
scope_target=scope_target,
|
|
208
|
+
reason=reason,
|
|
209
|
+
activated_by=activated_by,
|
|
210
|
+
timestamp=datetime.now(timezone.utc),
|
|
211
|
+
latency_ms=elapsed_ms,
|
|
212
|
+
metadata=metadata or {},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
with self._lock:
|
|
216
|
+
self._events.append(event)
|
|
217
|
+
|
|
218
|
+
# Log at appropriate severity
|
|
219
|
+
log_extra = {
|
|
220
|
+
"event_id": event.event_id,
|
|
221
|
+
"level": level.value,
|
|
222
|
+
"scope": scope.value,
|
|
223
|
+
"scope_target": scope_target,
|
|
224
|
+
"latency_ms": round(elapsed_ms, 2),
|
|
225
|
+
"activated_by": activated_by,
|
|
226
|
+
"reason": reason,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if level == KillLevel.KILL:
|
|
230
|
+
logger.critical("kill_switch_KILL_activated", extra=log_extra)
|
|
231
|
+
elif level == KillLevel.STOP:
|
|
232
|
+
logger.warning("kill_switch_STOP_activated", extra=log_extra)
|
|
233
|
+
else:
|
|
234
|
+
logger.info("kill_switch_PAUSE_activated", extra=log_extra)
|
|
235
|
+
|
|
236
|
+
# Check latency guarantee
|
|
237
|
+
guarantee = LEVEL_LATENCY_GUARANTEE_MS[level]
|
|
238
|
+
if elapsed_ms > guarantee:
|
|
239
|
+
logger.error(
|
|
240
|
+
"kill_switch_latency_exceeded",
|
|
241
|
+
extra={
|
|
242
|
+
"level": level.value,
|
|
243
|
+
"guarantee_ms": guarantee,
|
|
244
|
+
"actual_ms": round(elapsed_ms, 2),
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# Fire callback
|
|
249
|
+
if self._on_kill is not None:
|
|
250
|
+
try:
|
|
251
|
+
self._on_kill(event)
|
|
252
|
+
except Exception as exc:
|
|
253
|
+
logger.error(
|
|
254
|
+
"kill_switch on_kill callback error",
|
|
255
|
+
extra={"error": str(exc)},
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
return event
|
|
259
|
+
|
|
260
|
+
def deactivate(
|
|
261
|
+
self,
|
|
262
|
+
scope: KillScope,
|
|
263
|
+
scope_target: str | None = None,
|
|
264
|
+
deactivated_by: str = "system",
|
|
265
|
+
) -> None:
|
|
266
|
+
"""Deactivate the kill switch for the specified scope.
|
|
267
|
+
|
|
268
|
+
Requires explicit deactivation -- there is no auto-expire TTL
|
|
269
|
+
to prevent accidental resumption of compromised agents.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
scope: The scope to deactivate.
|
|
273
|
+
scope_target: Required for non-GLOBAL scopes.
|
|
274
|
+
deactivated_by: Identifier of who/what deactivated.
|
|
275
|
+
|
|
276
|
+
Raises:
|
|
277
|
+
ValueError: If scope requires scope_target but none provided.
|
|
278
|
+
"""
|
|
279
|
+
key = self._make_key(scope, scope_target)
|
|
280
|
+
|
|
281
|
+
with self._lock:
|
|
282
|
+
try:
|
|
283
|
+
self._backend.delete(key)
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
logger.error(
|
|
286
|
+
"KillSwitch backend DELETE failed",
|
|
287
|
+
extra={"key": key, "error": str(exc)},
|
|
288
|
+
)
|
|
289
|
+
self._reasons.pop(key, None)
|
|
290
|
+
self._activation_times.pop(key, None)
|
|
291
|
+
|
|
292
|
+
logger.info(
|
|
293
|
+
"kill_switch_deactivated",
|
|
294
|
+
extra={
|
|
295
|
+
"scope": scope.value,
|
|
296
|
+
"scope_target": scope_target,
|
|
297
|
+
"deactivated_by": deactivated_by,
|
|
298
|
+
},
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def check(
|
|
302
|
+
self,
|
|
303
|
+
scope_target: str | None = None,
|
|
304
|
+
crew_id: str | None = None,
|
|
305
|
+
) -> KillStatus:
|
|
306
|
+
"""Check if any kill switch is active for the given target.
|
|
307
|
+
|
|
308
|
+
Checks backend flags in priority order: GLOBAL > CREW > AGENT > TASK.
|
|
309
|
+
Returns the highest-priority active kill switch level.
|
|
310
|
+
|
|
311
|
+
Fail-closed: If the backend is unreachable and fail_closed is True,
|
|
312
|
+
returns KILLED status.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
scope_target: The agent_id or task_id to check. Also used for
|
|
316
|
+
AGENT and TASK scope lookups.
|
|
317
|
+
crew_id: Optional crew_id for CREW scope lookup.
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
KillStatus with is_killed=True/False and details.
|
|
321
|
+
"""
|
|
322
|
+
# Build keys to check in priority order
|
|
323
|
+
keys: list[tuple[KillScope, str | None, str]] = []
|
|
324
|
+
|
|
325
|
+
# Global always checked
|
|
326
|
+
try:
|
|
327
|
+
global_key = self._make_key(KillScope.GLOBAL)
|
|
328
|
+
keys.append((KillScope.GLOBAL, None, global_key))
|
|
329
|
+
except ValueError:
|
|
330
|
+
pass
|
|
331
|
+
|
|
332
|
+
# Crew scope
|
|
333
|
+
if crew_id:
|
|
334
|
+
try:
|
|
335
|
+
crew_key = self._make_key(KillScope.CREW, crew_id)
|
|
336
|
+
keys.append((KillScope.CREW, crew_id, crew_key))
|
|
337
|
+
except ValueError:
|
|
338
|
+
pass
|
|
339
|
+
|
|
340
|
+
# Agent scope
|
|
341
|
+
if scope_target:
|
|
342
|
+
try:
|
|
343
|
+
agent_key = self._make_key(KillScope.AGENT, scope_target)
|
|
344
|
+
keys.append((KillScope.AGENT, scope_target, agent_key))
|
|
345
|
+
except ValueError:
|
|
346
|
+
pass
|
|
347
|
+
|
|
348
|
+
# Task scope
|
|
349
|
+
try:
|
|
350
|
+
task_key = self._make_key(KillScope.TASK, scope_target)
|
|
351
|
+
keys.append((KillScope.TASK, scope_target, task_key))
|
|
352
|
+
except ValueError:
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
if not keys:
|
|
356
|
+
return KillStatus.safe()
|
|
357
|
+
|
|
358
|
+
# Get all values in one operation
|
|
359
|
+
raw_keys = [k[2] for k in keys]
|
|
360
|
+
try:
|
|
361
|
+
values = self._backend.get_many(raw_keys)
|
|
362
|
+
except Exception as exc:
|
|
363
|
+
logger.error(
|
|
364
|
+
"KillSwitch backend GET_MANY failed",
|
|
365
|
+
extra={"keys": raw_keys, "error": str(exc)},
|
|
366
|
+
)
|
|
367
|
+
if self._fail_closed:
|
|
368
|
+
return KillStatus.killed(
|
|
369
|
+
level=KillLevel.KILL,
|
|
370
|
+
scope=KillScope.GLOBAL,
|
|
371
|
+
reason=f"Backend failure (fail-closed): {exc}",
|
|
372
|
+
)
|
|
373
|
+
return KillStatus.safe()
|
|
374
|
+
|
|
375
|
+
# Return first (highest-priority) active level
|
|
376
|
+
for (scope, target, key), value in zip(keys, values, strict=False):
|
|
377
|
+
if value is not None:
|
|
378
|
+
try:
|
|
379
|
+
level = KillLevel(value)
|
|
380
|
+
except ValueError:
|
|
381
|
+
logger.warning(
|
|
382
|
+
"Invalid kill level in backend",
|
|
383
|
+
extra={"key": key, "value": value},
|
|
384
|
+
)
|
|
385
|
+
continue
|
|
386
|
+
|
|
387
|
+
with self._lock:
|
|
388
|
+
reason = self._reasons.get(key, "")
|
|
389
|
+
activated_at = self._activation_times.get(key)
|
|
390
|
+
|
|
391
|
+
return KillStatus.killed(
|
|
392
|
+
level=level,
|
|
393
|
+
scope=scope,
|
|
394
|
+
scope_target=target,
|
|
395
|
+
reason=reason,
|
|
396
|
+
activated_at=activated_at,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
return KillStatus.safe()
|
|
400
|
+
|
|
401
|
+
# -- Agent registration --
|
|
402
|
+
|
|
403
|
+
def register_agent(
|
|
404
|
+
self,
|
|
405
|
+
agent_id: str,
|
|
406
|
+
parent_id: str | None = None,
|
|
407
|
+
metadata: dict[str, Any] | None = None,
|
|
408
|
+
) -> AgentRegistration:
|
|
409
|
+
"""Register an agent in the kill switch hierarchy.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
agent_id: Unique identifier for the agent.
|
|
413
|
+
parent_id: ID of the parent agent for cascading, or None.
|
|
414
|
+
metadata: Optional metadata about the agent.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
The AgentRegistration record.
|
|
418
|
+
|
|
419
|
+
Raises:
|
|
420
|
+
ValueError: If parent_id is specified but not registered.
|
|
421
|
+
"""
|
|
422
|
+
if parent_id is not None:
|
|
423
|
+
with self._lock:
|
|
424
|
+
if parent_id not in self._agents:
|
|
425
|
+
raise ValueError(f"Parent agent not registered: {parent_id}")
|
|
426
|
+
|
|
427
|
+
registration = AgentRegistration(
|
|
428
|
+
agent_id=agent_id,
|
|
429
|
+
parent_id=parent_id,
|
|
430
|
+
metadata=metadata or {},
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
with self._lock:
|
|
434
|
+
self._agents[agent_id] = registration
|
|
435
|
+
|
|
436
|
+
logger.info(
|
|
437
|
+
"agent_registered",
|
|
438
|
+
extra={
|
|
439
|
+
"agent_id": agent_id,
|
|
440
|
+
"parent_id": parent_id,
|
|
441
|
+
},
|
|
442
|
+
)
|
|
443
|
+
return registration
|
|
444
|
+
|
|
445
|
+
def unregister_agent(self, agent_id: str) -> None:
|
|
446
|
+
"""Remove an agent from the hierarchy.
|
|
447
|
+
|
|
448
|
+
Also cleans up any agent-scoped kill switch for this agent.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
agent_id: The agent to unregister.
|
|
452
|
+
"""
|
|
453
|
+
with self._lock:
|
|
454
|
+
self._agents.pop(agent_id, None)
|
|
455
|
+
|
|
456
|
+
# Clean up agent-scoped kill switch
|
|
457
|
+
with contextlib.suppress(ValueError, Exception):
|
|
458
|
+
self.deactivate(KillScope.AGENT, agent_id, deactivated_by="unregister")
|
|
459
|
+
|
|
460
|
+
logger.info("agent_unregistered", extra={"agent_id": agent_id})
|
|
461
|
+
|
|
462
|
+
def get_children(self, agent_id: str) -> list[str]:
|
|
463
|
+
"""Get IDs of all direct children of an agent.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
agent_id: The parent agent ID.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
List of child agent IDs.
|
|
470
|
+
"""
|
|
471
|
+
with self._lock:
|
|
472
|
+
return [reg.agent_id for reg in self._agents.values() if reg.parent_id == agent_id]
|
|
473
|
+
|
|
474
|
+
def get_all_descendants(self, agent_id: str) -> list[str]:
|
|
475
|
+
"""Get IDs of all descendants (children, grandchildren, etc.).
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
agent_id: The root agent ID.
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
List of all descendant agent IDs in breadth-first order.
|
|
482
|
+
"""
|
|
483
|
+
descendants: list[str] = []
|
|
484
|
+
queue = self.get_children(agent_id)
|
|
485
|
+
|
|
486
|
+
while queue:
|
|
487
|
+
child = queue.pop(0)
|
|
488
|
+
descendants.append(child)
|
|
489
|
+
queue.extend(self.get_children(child))
|
|
490
|
+
|
|
491
|
+
return descendants
|
|
492
|
+
|
|
493
|
+
# -- Status and audit --
|
|
494
|
+
|
|
495
|
+
def get_status(self) -> dict[str, Any]:
|
|
496
|
+
"""Return all active kill switches across all scopes.
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
Dictionary with active kill switches and counts.
|
|
500
|
+
"""
|
|
501
|
+
status: dict[str, Any] = {
|
|
502
|
+
"global": None,
|
|
503
|
+
"crews": {},
|
|
504
|
+
"agents": {},
|
|
505
|
+
"tasks": {},
|
|
506
|
+
"total_active": 0,
|
|
507
|
+
"registered_agents": 0,
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
# Check global
|
|
511
|
+
try:
|
|
512
|
+
global_key = self._make_key(KillScope.GLOBAL)
|
|
513
|
+
global_val = self._backend.get(global_key)
|
|
514
|
+
if global_val:
|
|
515
|
+
status["global"] = global_val
|
|
516
|
+
status["total_active"] += 1
|
|
517
|
+
except (ValueError, Exception):
|
|
518
|
+
pass
|
|
519
|
+
|
|
520
|
+
# Scan for scoped keys
|
|
521
|
+
for scope_name, scope_enum in [
|
|
522
|
+
("crews", KillScope.CREW),
|
|
523
|
+
("agents", KillScope.AGENT),
|
|
524
|
+
("tasks", KillScope.TASK),
|
|
525
|
+
]:
|
|
526
|
+
try:
|
|
527
|
+
pattern = f"{self._prefix}{scope_enum.value}:*"
|
|
528
|
+
matched_keys = self._backend.keys(pattern)
|
|
529
|
+
for key in matched_keys:
|
|
530
|
+
val = self._backend.get(key)
|
|
531
|
+
if val:
|
|
532
|
+
# Extract target from key
|
|
533
|
+
parts = key.split(":", 1)
|
|
534
|
+
target = parts[1] if len(parts) > 1 else key
|
|
535
|
+
status[scope_name][target] = val
|
|
536
|
+
status["total_active"] += 1
|
|
537
|
+
except Exception:
|
|
538
|
+
pass
|
|
539
|
+
|
|
540
|
+
with self._lock:
|
|
541
|
+
status["registered_agents"] = len(self._agents)
|
|
542
|
+
|
|
543
|
+
return status
|
|
544
|
+
|
|
545
|
+
def get_events(self, limit: int = 100) -> list[KillEvent]:
|
|
546
|
+
"""Return the most recent kill events.
|
|
547
|
+
|
|
548
|
+
Args:
|
|
549
|
+
limit: Maximum number of events to return.
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
List of KillEvent records, most recent first.
|
|
553
|
+
"""
|
|
554
|
+
with self._lock:
|
|
555
|
+
return list(reversed(self._events[-limit:]))
|
|
556
|
+
|
|
557
|
+
def get_event_count(self) -> dict[str, int]:
|
|
558
|
+
"""Return count of events by level.
|
|
559
|
+
|
|
560
|
+
Returns:
|
|
561
|
+
Dictionary of level -> count.
|
|
562
|
+
"""
|
|
563
|
+
counts: dict[str, int] = {"pause": 0, "stop": 0, "kill": 0}
|
|
564
|
+
with self._lock:
|
|
565
|
+
for event in self._events:
|
|
566
|
+
counts[event.level.value] += 1
|
|
567
|
+
return counts
|
|
568
|
+
|
|
569
|
+
@property
|
|
570
|
+
def is_active(self) -> bool:
|
|
571
|
+
"""Check if any kill switch is currently active globally.
|
|
572
|
+
|
|
573
|
+
Returns:
|
|
574
|
+
True if the global kill switch is active.
|
|
575
|
+
"""
|
|
576
|
+
try:
|
|
577
|
+
key = self._make_key(KillScope.GLOBAL)
|
|
578
|
+
val = self._backend.get(key)
|
|
579
|
+
return val is not None
|
|
580
|
+
except Exception:
|
|
581
|
+
return self._fail_closed
|