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.
@@ -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