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,76 @@
1
+ """agent-killswitch: Operational safety controls for autonomous AI agents.
2
+
3
+ Zero-dependency kill switch, heartbeat monitor, circuit breaker, and
4
+ budget controls for AI agents. Works with any framework (LangChain,
5
+ LangGraph, CrewAI, OpenAI Agents SDK, asyncio) or standalone.
6
+
7
+ Quick start::
8
+
9
+ from agent_killswitch import KillSwitch, KillLevel, KillScope
10
+
11
+ ks = KillSwitch()
12
+ ks.activate(KillLevel.KILL, KillScope.GLOBAL, reason="emergency")
13
+ status = ks.check()
14
+ assert status.is_killed
15
+
16
+ Core classes:
17
+ KillSwitch: Main kill switch with tiered levels and scoped targets.
18
+ HeartbeatMonitor: Track agent health via periodic heartbeats.
19
+ CircuitBreaker: Protect against cascading failures.
20
+ BudgetKillTrigger: Kill agents that exceed their budget.
21
+ CascadingTerminator: Propagate kills through agent hierarchies.
22
+
23
+ Decorators:
24
+ killswitch_protected: Check kill switch before/after function calls.
25
+ with_heartbeat: Send heartbeats during long-running operations.
26
+ circuit_breaker: Wrap functions with circuit breaker protection.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from ._version import __version__
32
+ from .core.budget_kill import BudgetKillTrigger
33
+ from .core.cascading import CascadingTerminator
34
+ from .core.circuit_breaker import CircuitBreaker, CircuitBreakerOpen
35
+ from .core.enums import CircuitState, HeartbeatStatus, KillLevel, KillScope
36
+ from .core.heartbeat import HeartbeatMonitor
37
+ from .core.killswitch import KillSwitch
38
+ from .core.models import (
39
+ AgentRegistration,
40
+ BudgetStatus,
41
+ CircuitBreakerState,
42
+ HeartbeatRecord,
43
+ KillEvent,
44
+ KillStatus,
45
+ )
46
+ from .decorators import KillSwitchTriggered, killswitch_protected, with_heartbeat
47
+ from .decorators import circuit_breaker as circuit_breaker_decorator
48
+
49
+ __all__ = [
50
+ "AgentRegistration",
51
+ "BudgetKillTrigger",
52
+ "BudgetStatus",
53
+ "CascadingTerminator",
54
+ "CircuitBreaker",
55
+ "CircuitBreakerOpen",
56
+ "CircuitBreakerState",
57
+ "CircuitState",
58
+ "HeartbeatMonitor",
59
+ "HeartbeatRecord",
60
+ "HeartbeatStatus",
61
+ # Models
62
+ "KillEvent",
63
+ # Enums
64
+ "KillLevel",
65
+ "KillScope",
66
+ "KillStatus",
67
+ # Core classes
68
+ "KillSwitch",
69
+ "KillSwitchTriggered",
70
+ # Version
71
+ "__version__",
72
+ "circuit_breaker_decorator",
73
+ # Decorators
74
+ "killswitch_protected",
75
+ "with_heartbeat",
76
+ ]
@@ -0,0 +1,5 @@
1
+ """Version information for agent-killswitch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,21 @@
1
+ """Backend implementations for kill switch state storage.
2
+
3
+ Available backends:
4
+ - InMemoryBackend: Zero-dependency, thread-safe, dict-based (default)
5
+ - RedisBackend: Distributed, Redis-backed (optional dependency)
6
+ - KillSwitchBackend: Protocol for custom backends
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .base import KillSwitchBackend
12
+ from .memory import InMemoryBackend
13
+
14
+ __all__ = [
15
+ "InMemoryBackend",
16
+ "KillSwitchBackend",
17
+ ]
18
+
19
+ # RedisBackend is intentionally not imported here to avoid
20
+ # requiring the redis dependency. Import it directly:
21
+ # from agent_killswitch.backends.redis import RedisBackend
@@ -0,0 +1,95 @@
1
+ """Abstract backend protocol for kill switch state storage.
2
+
3
+ Backends provide the storage layer for kill switch state. The protocol
4
+ defines the minimal interface that any backend must implement.
5
+
6
+ Two implementations are provided:
7
+ - InMemoryBackend: Zero-dependency, dict-based (default)
8
+ - RedisBackend: Distributed, Redis-backed (optional)
9
+
10
+ Custom backends can be created by implementing this protocol.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Protocol, runtime_checkable
16
+
17
+
18
+ @runtime_checkable
19
+ class KillSwitchBackend(Protocol):
20
+ """Protocol for kill switch state backends.
21
+
22
+ All methods are synchronous. For async usage, wrap in an executor
23
+ or use an async-native backend.
24
+
25
+ The backend MUST be fail-closed: if any operation fails, the caller
26
+ should treat the agent as KILLED for safety.
27
+ """
28
+
29
+ def get(self, key: str) -> str | None:
30
+ """Get a value by key.
31
+
32
+ Args:
33
+ key: The storage key.
34
+
35
+ Returns:
36
+ The stored value as a string, or None if not found.
37
+ """
38
+ ...
39
+
40
+ def set(self, key: str, value: str) -> None:
41
+ """Set a value by key. No TTL -- explicit deletion required.
42
+
43
+ Args:
44
+ key: The storage key.
45
+ value: The value to store.
46
+ """
47
+ ...
48
+
49
+ def delete(self, key: str) -> None:
50
+ """Delete a key.
51
+
52
+ Args:
53
+ key: The storage key to delete.
54
+ """
55
+ ...
56
+
57
+ def get_many(self, keys: list[str]) -> list[str | None]:
58
+ """Get multiple values in a single operation.
59
+
60
+ Args:
61
+ keys: List of storage keys.
62
+
63
+ Returns:
64
+ List of values (or None) in the same order as keys.
65
+ """
66
+ ...
67
+
68
+ def keys(self, pattern: str) -> list[str]:
69
+ """Find keys matching a pattern.
70
+
71
+ The pattern uses simple prefix matching (not glob).
72
+ Pass "prefix*" to match all keys starting with "prefix".
73
+
74
+ Args:
75
+ pattern: Key pattern to match (prefix with trailing *).
76
+
77
+ Returns:
78
+ List of matching keys.
79
+ """
80
+ ...
81
+
82
+ def exists(self, key: str) -> bool:
83
+ """Check if a key exists.
84
+
85
+ Args:
86
+ key: The storage key.
87
+
88
+ Returns:
89
+ True if the key exists.
90
+ """
91
+ ...
92
+
93
+ def clear(self) -> None:
94
+ """Remove all keys. Use with caution -- primarily for testing."""
95
+ ...
@@ -0,0 +1,120 @@
1
+ """In-memory backend for kill switch state storage.
2
+
3
+ Zero-dependency, thread-safe, dict-based backend. This is the default
4
+ backend and is suitable for:
5
+ - Single-process applications
6
+ - Testing and development
7
+ - Applications that don't need distributed state
8
+
9
+ For distributed systems, use the RedisBackend instead.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import threading
15
+
16
+
17
+ class InMemoryBackend:
18
+ """Thread-safe in-memory backend using a dictionary.
19
+
20
+ All operations are protected by a reentrant lock for thread safety.
21
+ This backend is the default when no external backend is configured.
22
+
23
+ Example::
24
+
25
+ from agent_killswitch.backends.memory import InMemoryBackend
26
+
27
+ backend = InMemoryBackend()
28
+ backend.set("killswitch:global", "kill")
29
+ assert backend.get("killswitch:global") == "kill"
30
+ """
31
+
32
+ def __init__(self) -> None:
33
+ """Initialize the in-memory backend."""
34
+ self._store: dict[str, str] = {}
35
+ self._lock = threading.RLock()
36
+
37
+ def get(self, key: str) -> str | None:
38
+ """Get a value by key.
39
+
40
+ Args:
41
+ key: The storage key.
42
+
43
+ Returns:
44
+ The stored value, or None if not found.
45
+ """
46
+ with self._lock:
47
+ return self._store.get(key)
48
+
49
+ def set(self, key: str, value: str) -> None:
50
+ """Set a value by key.
51
+
52
+ Args:
53
+ key: The storage key.
54
+ value: The value to store.
55
+ """
56
+ with self._lock:
57
+ self._store[key] = value
58
+
59
+ def delete(self, key: str) -> None:
60
+ """Delete a key.
61
+
62
+ Args:
63
+ key: The storage key to delete.
64
+ """
65
+ with self._lock:
66
+ self._store.pop(key, None)
67
+
68
+ def get_many(self, keys: list[str]) -> list[str | None]:
69
+ """Get multiple values atomically.
70
+
71
+ Args:
72
+ keys: List of storage keys.
73
+
74
+ Returns:
75
+ List of values in the same order as keys.
76
+ """
77
+ with self._lock:
78
+ return [self._store.get(k) for k in keys]
79
+
80
+ def keys(self, pattern: str) -> list[str]:
81
+ """Find keys matching a prefix pattern.
82
+
83
+ Args:
84
+ pattern: Key pattern. If it ends with '*', matches prefix.
85
+ Otherwise, matches exact key.
86
+
87
+ Returns:
88
+ List of matching keys.
89
+ """
90
+ with self._lock:
91
+ if pattern.endswith("*"):
92
+ prefix = pattern[:-1]
93
+ return [k for k in self._store if k.startswith(prefix)]
94
+ return [k for k in self._store if k == pattern]
95
+
96
+ def exists(self, key: str) -> bool:
97
+ """Check if a key exists.
98
+
99
+ Args:
100
+ key: The storage key.
101
+
102
+ Returns:
103
+ True if the key exists.
104
+ """
105
+ with self._lock:
106
+ return key in self._store
107
+
108
+ def clear(self) -> None:
109
+ """Remove all keys."""
110
+ with self._lock:
111
+ self._store.clear()
112
+
113
+ def __len__(self) -> int:
114
+ """Return the number of stored keys."""
115
+ with self._lock:
116
+ return len(self._store)
117
+
118
+ def __repr__(self) -> str:
119
+ with self._lock:
120
+ return f"InMemoryBackend(keys={len(self._store)})"
@@ -0,0 +1,230 @@
1
+ """Redis backend for distributed kill switch state storage.
2
+
3
+ Requires the `redis` optional dependency:
4
+ pip install agent-killswitch[redis]
5
+
6
+ This backend is suitable for:
7
+ - Multi-process applications
8
+ - Distributed systems
9
+ - Production deployments where multiple services need shared kill state
10
+
11
+ The backend uses Redis pipelines for efficient multi-key operations
12
+ and falls back to fail-closed behavior on connection errors.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from typing import Any
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class RedisBackend:
24
+ """Redis-backed storage for kill switch state.
25
+
26
+ Uses Redis for sub-millisecond flag checks across distributed systems.
27
+ All keys use a configurable prefix (default: "killswitch:") to avoid
28
+ collisions with other Redis users.
29
+
30
+ Fail-closed behavior: If Redis is unreachable, operations raise
31
+ exceptions. The KillSwitch class interprets backend failures as
32
+ KILLED state for safety.
33
+
34
+ Example::
35
+
36
+ import redis
37
+ from agent_killswitch.backends.redis import RedisBackend
38
+
39
+ client = redis.Redis(host="localhost", port=6379, db=0)
40
+ backend = RedisBackend(client, prefix="myapp:killswitch:")
41
+
42
+ Args:
43
+ redis_client: A redis.Redis or redis.StrictRedis instance.
44
+ prefix: Key prefix for all kill switch keys.
45
+ Defaults to "killswitch:".
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ redis_client: Any,
51
+ prefix: str = "killswitch:",
52
+ ) -> None:
53
+ """Initialize the Redis backend.
54
+
55
+ Args:
56
+ redis_client: A Redis client instance. Must support get, set,
57
+ delete, pipeline, scan_iter, and exists methods.
58
+ prefix: Key prefix for namespacing. Defaults to "killswitch:".
59
+ """
60
+ self._redis = redis_client
61
+ self._prefix = prefix
62
+
63
+ def _prefixed(self, key: str) -> str:
64
+ """Add prefix to a key if not already prefixed.
65
+
66
+ Args:
67
+ key: The raw key.
68
+
69
+ Returns:
70
+ The prefixed key.
71
+ """
72
+ if key.startswith(self._prefix):
73
+ return key
74
+ return f"{self._prefix}{key}"
75
+
76
+ def _unprefixed(self, key: str) -> str:
77
+ """Remove prefix from a key.
78
+
79
+ Args:
80
+ key: The prefixed key.
81
+
82
+ Returns:
83
+ The raw key without prefix.
84
+ """
85
+ if key.startswith(self._prefix):
86
+ return key[len(self._prefix) :]
87
+ return key
88
+
89
+ def get(self, key: str) -> str | None:
90
+ """Get a value from Redis.
91
+
92
+ Args:
93
+ key: The storage key.
94
+
95
+ Returns:
96
+ The stored value as a string, or None if not found.
97
+
98
+ Raises:
99
+ Exception: On Redis connection failure (fail-closed).
100
+ """
101
+ try:
102
+ val = self._redis.get(self._prefixed(key))
103
+ if val is not None and isinstance(val, bytes):
104
+ return val.decode("utf-8")
105
+ return val
106
+ except Exception:
107
+ logger.error("Redis GET failed for key=%s", key, exc_info=True)
108
+ raise
109
+
110
+ def set(self, key: str, value: str) -> None:
111
+ """Set a value in Redis. No TTL -- explicit deletion required.
112
+
113
+ Args:
114
+ key: The storage key.
115
+ value: The value to store.
116
+
117
+ Raises:
118
+ Exception: On Redis connection failure.
119
+ """
120
+ try:
121
+ self._redis.set(self._prefixed(key), value)
122
+ except Exception:
123
+ logger.error("Redis SET failed for key=%s", key, exc_info=True)
124
+ raise
125
+
126
+ def delete(self, key: str) -> None:
127
+ """Delete a key from Redis.
128
+
129
+ Args:
130
+ key: The storage key to delete.
131
+
132
+ Raises:
133
+ Exception: On Redis connection failure.
134
+ """
135
+ try:
136
+ self._redis.delete(self._prefixed(key))
137
+ except Exception:
138
+ logger.error("Redis DELETE failed for key=%s", key, exc_info=True)
139
+ raise
140
+
141
+ def get_many(self, keys: list[str]) -> list[str | None]:
142
+ """Get multiple values using a Redis pipeline (single round-trip).
143
+
144
+ Args:
145
+ keys: List of storage keys.
146
+
147
+ Returns:
148
+ List of values (or None) in the same order as keys.
149
+
150
+ Raises:
151
+ Exception: On Redis connection failure.
152
+ """
153
+ try:
154
+ pipe = self._redis.pipeline(transaction=False)
155
+ for key in keys:
156
+ pipe.get(self._prefixed(key))
157
+ results = pipe.execute()
158
+ return [r.decode("utf-8") if isinstance(r, bytes) else r for r in results]
159
+ except Exception:
160
+ logger.error("Redis pipeline GET failed for keys=%s", keys, exc_info=True)
161
+ raise
162
+
163
+ def keys(self, pattern: str) -> list[str]:
164
+ """Find keys matching a pattern using Redis SCAN.
165
+
166
+ Args:
167
+ pattern: Key pattern. If it ends with '*', uses SCAN with
168
+ the prefixed pattern. Otherwise matches exact key.
169
+
170
+ Returns:
171
+ List of matching keys (without prefix).
172
+
173
+ Raises:
174
+ Exception: On Redis connection failure.
175
+ """
176
+ try:
177
+ if pattern.endswith("*"):
178
+ redis_pattern = self._prefixed(pattern)
179
+ found: list[str] = []
180
+ for key in self._redis.scan_iter(match=redis_pattern):
181
+ if isinstance(key, bytes):
182
+ key = key.decode("utf-8")
183
+ found.append(self._unprefixed(key))
184
+ return found
185
+ else:
186
+ prefixed = self._prefixed(pattern)
187
+ if self._redis.exists(prefixed):
188
+ return [pattern]
189
+ return []
190
+ except Exception:
191
+ logger.error("Redis KEYS failed for pattern=%s", pattern, exc_info=True)
192
+ raise
193
+
194
+ def exists(self, key: str) -> bool:
195
+ """Check if a key exists in Redis.
196
+
197
+ Args:
198
+ key: The storage key.
199
+
200
+ Returns:
201
+ True if the key exists.
202
+
203
+ Raises:
204
+ Exception: On Redis connection failure.
205
+ """
206
+ try:
207
+ return bool(self._redis.exists(self._prefixed(key)))
208
+ except Exception:
209
+ logger.error("Redis EXISTS failed for key=%s", key, exc_info=True)
210
+ raise
211
+
212
+ def clear(self) -> None:
213
+ """Remove all keys with our prefix. Use with caution.
214
+
215
+ Raises:
216
+ Exception: On Redis connection failure.
217
+ """
218
+ try:
219
+ pattern = f"{self._prefix}*"
220
+ keys_to_delete: list[Any] = []
221
+ for key in self._redis.scan_iter(match=pattern):
222
+ keys_to_delete.append(key)
223
+ if keys_to_delete:
224
+ self._redis.delete(*keys_to_delete)
225
+ except Exception:
226
+ logger.error("Redis CLEAR failed", exc_info=True)
227
+ raise
228
+
229
+ def __repr__(self) -> str:
230
+ return f"RedisBackend(prefix={self._prefix!r})"
@@ -0,0 +1,36 @@
1
+ """Core kill switch components.
2
+
3
+ This package contains the core functionality:
4
+ - killswitch: Main KillSwitch class (unified API)
5
+ - heartbeat: HeartbeatMonitor for agent health tracking
6
+ - circuit_breaker: Circuit breaker pattern
7
+ - budget_kill: Budget-based kill triggers
8
+ - cascading: Cascading termination for agent hierarchies
9
+ - models: Data models
10
+ - enums: Enumerations
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .enums import CircuitState, HeartbeatStatus, KillLevel, KillScope
16
+ from .models import (
17
+ AgentRegistration,
18
+ BudgetStatus,
19
+ CircuitBreakerState,
20
+ HeartbeatRecord,
21
+ KillEvent,
22
+ KillStatus,
23
+ )
24
+
25
+ __all__ = [
26
+ "AgentRegistration",
27
+ "BudgetStatus",
28
+ "CircuitBreakerState",
29
+ "CircuitState",
30
+ "HeartbeatRecord",
31
+ "HeartbeatStatus",
32
+ "KillEvent",
33
+ "KillLevel",
34
+ "KillScope",
35
+ "KillStatus",
36
+ ]