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,173 @@
1
+ """Data models for agent-killswitch.
2
+
3
+ Defines the core data structures used throughout the library.
4
+ All models are dataclasses for zero-dependency operation (no Pydantic required).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime, timezone
11
+ from typing import Any
12
+
13
+ from .enums import CircuitState, HeartbeatStatus, KillLevel, KillScope
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class KillEvent:
18
+ """Record of a kill switch activation event.
19
+
20
+ Immutable record that captures all details of a kill switch
21
+ activation for audit trail purposes.
22
+
23
+ Attributes:
24
+ event_id: Unique identifier for this event.
25
+ level: The kill level activated (PAUSE/STOP/KILL).
26
+ scope: The scope of the activation.
27
+ scope_target: Target identifier (agent_id, crew_id, etc.).
28
+ reason: Human-readable reason for the activation.
29
+ activated_by: Identifier of who/what activated the switch.
30
+ timestamp: UTC timestamp of the activation.
31
+ latency_ms: Activation latency in milliseconds.
32
+ metadata: Additional metadata for audit logging.
33
+ """
34
+
35
+ event_id: str
36
+ level: KillLevel
37
+ scope: KillScope
38
+ scope_target: str | None
39
+ reason: str
40
+ activated_by: str
41
+ timestamp: datetime
42
+ latency_ms: float
43
+ metadata: dict[str, Any] = field(default_factory=dict)
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class KillStatus:
48
+ """Current status of a kill switch check.
49
+
50
+ Returned by KillSwitch.check() to provide full status information.
51
+
52
+ Attributes:
53
+ is_killed: Whether any kill switch is active.
54
+ level: The active kill level, or None if not killed.
55
+ scope: The scope that matched, or None.
56
+ scope_target: The target that matched, or None.
57
+ reason: The reason for the kill, or None.
58
+ activated_at: When the kill was activated, or None.
59
+ """
60
+
61
+ is_killed: bool
62
+ level: KillLevel | None = None
63
+ scope: KillScope | None = None
64
+ scope_target: str | None = None
65
+ reason: str | None = None
66
+ activated_at: datetime | None = None
67
+
68
+ @staticmethod
69
+ def safe() -> KillStatus:
70
+ """Return a safe (not killed) status."""
71
+ return KillStatus(is_killed=False)
72
+
73
+ @staticmethod
74
+ def killed(
75
+ level: KillLevel,
76
+ scope: KillScope,
77
+ scope_target: str | None = None,
78
+ reason: str = "",
79
+ activated_at: datetime | None = None,
80
+ ) -> KillStatus:
81
+ """Return a killed status with details."""
82
+ return KillStatus(
83
+ is_killed=True,
84
+ level=level,
85
+ scope=scope,
86
+ scope_target=scope_target,
87
+ reason=reason,
88
+ activated_at=activated_at,
89
+ )
90
+
91
+
92
+ @dataclass
93
+ class AgentRegistration:
94
+ """Registration record for an agent in the kill switch system.
95
+
96
+ Attributes:
97
+ agent_id: Unique identifier for the agent.
98
+ parent_id: ID of the parent agent (for cascading), or None if root.
99
+ registered_at: UTC timestamp of registration.
100
+ metadata: Additional metadata about the agent.
101
+ """
102
+
103
+ agent_id: str
104
+ parent_id: str | None = None
105
+ registered_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
106
+ metadata: dict[str, Any] = field(default_factory=dict)
107
+
108
+
109
+ @dataclass
110
+ class HeartbeatRecord:
111
+ """Record of an agent's heartbeat state.
112
+
113
+ Attributes:
114
+ agent_id: The agent being monitored.
115
+ last_heartbeat: UTC timestamp of last heartbeat.
116
+ status: Current health status.
117
+ miss_count: Number of consecutive missed heartbeats.
118
+ interval_seconds: Expected heartbeat interval.
119
+ """
120
+
121
+ agent_id: str
122
+ last_heartbeat: datetime | None = None
123
+ status: HeartbeatStatus = HeartbeatStatus.UNKNOWN
124
+ miss_count: int = 0
125
+ interval_seconds: float = 5.0
126
+
127
+
128
+ @dataclass
129
+ class CircuitBreakerState:
130
+ """State of a circuit breaker.
131
+
132
+ Attributes:
133
+ name: Name/identifier of the circuit breaker.
134
+ state: Current circuit state.
135
+ failure_count: Consecutive failure count.
136
+ success_count: Consecutive success count in HALF_OPEN state.
137
+ last_failure_time: When the last failure occurred.
138
+ last_state_change: When the state last changed.
139
+ failure_threshold: Number of failures to trip the breaker.
140
+ recovery_timeout: Seconds to wait before trying HALF_OPEN.
141
+ half_open_max_calls: Max calls allowed in HALF_OPEN state.
142
+ """
143
+
144
+ name: str
145
+ state: CircuitState = CircuitState.CLOSED
146
+ failure_count: int = 0
147
+ success_count: int = 0
148
+ last_failure_time: float | None = None
149
+ last_state_change: float = field(default_factory=lambda: 0.0)
150
+ failure_threshold: int = 5
151
+ recovery_timeout: float = 30.0
152
+ half_open_max_calls: int = 1
153
+
154
+
155
+ @dataclass
156
+ class BudgetStatus:
157
+ """Budget tracking status for an agent.
158
+
159
+ Attributes:
160
+ agent_id: The agent being tracked.
161
+ budget_limit: Maximum allowed cost.
162
+ current_cost: Current accumulated cost.
163
+ utilization: Budget utilization as a fraction (0.0 to 1.0+).
164
+ alert_thresholds: Thresholds that trigger alerts.
165
+ alerts_fired: Thresholds that have already fired alerts.
166
+ """
167
+
168
+ agent_id: str
169
+ budget_limit: float = 0.0
170
+ current_cost: float = 0.0
171
+ utilization: float = 0.0
172
+ alert_thresholds: list[float] = field(default_factory=lambda: [0.50, 0.80, 0.95])
173
+ alerts_fired: set[float] = field(default_factory=set)
@@ -0,0 +1,302 @@
1
+ """Decorators for easy kill switch integration.
2
+
3
+ Provides decorator-based APIs for common patterns:
4
+ - @killswitch_protected: Check kill switch before/after function execution
5
+ - @with_heartbeat: Send heartbeats during long-running operations
6
+ - @circuit_breaker: Wrap functions with circuit breaker protection
7
+
8
+ All decorators support both sync and async functions.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import functools
14
+ import inspect
15
+ import logging
16
+ import threading
17
+ from collections.abc import Callable
18
+ from typing import Any, TypeVar, Union
19
+
20
+ from .core.circuit_breaker import CircuitBreaker
21
+ from .core.enums import KillScope
22
+ from .core.killswitch import KillSwitch
23
+ from .core.models import KillStatus
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ F = TypeVar("F", bound=Callable[..., Any])
28
+
29
+
30
+ class KillSwitchTriggered(Exception):
31
+ """Raised when a kill switch check finds an active kill.
32
+
33
+ Attributes:
34
+ status: The KillStatus that triggered the exception.
35
+ """
36
+
37
+ def __init__(self, status: KillStatus) -> None:
38
+ self.status = status
39
+ level_str = status.level.value if status.level else "unknown"
40
+ super().__init__(
41
+ f"Kill switch triggered: level={level_str}, "
42
+ f"scope={status.scope}, target={status.scope_target}, "
43
+ f"reason={status.reason}"
44
+ )
45
+
46
+
47
+ def killswitch_protected(
48
+ killswitch: KillSwitch | None = None,
49
+ scope: Union[str, KillScope] = KillScope.AGENT,
50
+ agent_id: str | None = None,
51
+ crew_id: str | None = None,
52
+ on_killed: Callable[[KillStatus], None] | None = None,
53
+ raise_on_kill: bool = True,
54
+ ) -> Callable[[F], F]:
55
+ """Decorator that checks the kill switch before and after execution.
56
+
57
+ If the kill switch is active, either raises KillSwitchTriggered
58
+ or calls the on_killed callback (depending on raise_on_kill).
59
+
60
+ Example::
61
+
62
+ ks = KillSwitch()
63
+
64
+
65
+ @killswitch_protected(killswitch=ks, agent_id="my-agent")
66
+ async def my_agent_task():
67
+ # ... agent work ...
68
+ pass
69
+
70
+ Args:
71
+ killswitch: The KillSwitch instance to check. If None, the
72
+ function runs without protection (useful for testing).
73
+ scope: The scope to check. Default AGENT.
74
+ agent_id: The agent ID for scope checks.
75
+ crew_id: The crew ID for crew scope checks.
76
+ on_killed: Callback when kill is detected.
77
+ raise_on_kill: If True, raise KillSwitchTriggered. Default True.
78
+
79
+ Returns:
80
+ Decorator function.
81
+ """
82
+ if isinstance(scope, str):
83
+ scope = KillScope(scope)
84
+
85
+ def decorator(func: F) -> F:
86
+ if inspect.iscoroutinefunction(func):
87
+
88
+ @functools.wraps(func)
89
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
90
+ if killswitch is not None:
91
+ status = killswitch.check(
92
+ scope_target=agent_id,
93
+ crew_id=crew_id,
94
+ )
95
+ if status.is_killed:
96
+ if on_killed is not None:
97
+ on_killed(status)
98
+ if raise_on_kill:
99
+ raise KillSwitchTriggered(status)
100
+ return None
101
+
102
+ result = await func(*args, **kwargs)
103
+
104
+ if killswitch is not None:
105
+ status = killswitch.check(
106
+ scope_target=agent_id,
107
+ crew_id=crew_id,
108
+ )
109
+ if status.is_killed:
110
+ if on_killed is not None:
111
+ on_killed(status)
112
+ if raise_on_kill:
113
+ raise KillSwitchTriggered(status)
114
+
115
+ return result
116
+
117
+ return async_wrapper # type: ignore[return-value]
118
+ else:
119
+
120
+ @functools.wraps(func)
121
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
122
+ if killswitch is not None:
123
+ status = killswitch.check(
124
+ scope_target=agent_id,
125
+ crew_id=crew_id,
126
+ )
127
+ if status.is_killed:
128
+ if on_killed is not None:
129
+ on_killed(status)
130
+ if raise_on_kill:
131
+ raise KillSwitchTriggered(status)
132
+ return None
133
+
134
+ result = func(*args, **kwargs)
135
+
136
+ if killswitch is not None:
137
+ status = killswitch.check(
138
+ scope_target=agent_id,
139
+ crew_id=crew_id,
140
+ )
141
+ if status.is_killed:
142
+ if on_killed is not None:
143
+ on_killed(status)
144
+ if raise_on_kill:
145
+ raise KillSwitchTriggered(status)
146
+
147
+ return result
148
+
149
+ return sync_wrapper # type: ignore[return-value]
150
+
151
+ return decorator
152
+
153
+
154
+ def with_heartbeat(
155
+ monitor: Any = None,
156
+ agent_id: str | None = None,
157
+ interval: float = 5.0,
158
+ ) -> Callable[[F], F]:
159
+ """Decorator that sends heartbeats during function execution.
160
+
161
+ Starts a background thread that sends periodic heartbeats to the
162
+ HeartbeatMonitor while the decorated function is running.
163
+
164
+ Example::
165
+
166
+ from agent_killswitch import HeartbeatMonitor
167
+
168
+ monitor = HeartbeatMonitor()
169
+
170
+
171
+ @with_heartbeat(monitor=monitor, agent_id="my-agent", interval=5.0)
172
+ async def long_running_agent():
173
+ # heartbeats are sent automatically
174
+ await do_work()
175
+
176
+ Args:
177
+ monitor: HeartbeatMonitor instance. If None, no heartbeats sent.
178
+ agent_id: The agent ID for heartbeats.
179
+ interval: Heartbeat interval in seconds. Default 5.0.
180
+
181
+ Returns:
182
+ Decorator function.
183
+ """
184
+
185
+ def decorator(func: F) -> F:
186
+ if inspect.iscoroutinefunction(func):
187
+
188
+ @functools.wraps(func)
189
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
190
+ if monitor is None or agent_id is None:
191
+ return await func(*args, **kwargs)
192
+
193
+ monitor.start(agent_id, interval_seconds=interval)
194
+ stop_event = threading.Event()
195
+
196
+ def heartbeat_loop() -> None:
197
+ while not stop_event.is_set():
198
+ monitor.heartbeat(agent_id)
199
+ stop_event.wait(interval)
200
+
201
+ thread = threading.Thread(
202
+ target=heartbeat_loop,
203
+ daemon=True,
204
+ name=f"heartbeat-{agent_id}",
205
+ )
206
+ thread.start()
207
+
208
+ try:
209
+ return await func(*args, **kwargs)
210
+ finally:
211
+ stop_event.set()
212
+ thread.join(timeout=interval + 1.0)
213
+ monitor.stop(agent_id)
214
+
215
+ return async_wrapper # type: ignore[return-value]
216
+ else:
217
+
218
+ @functools.wraps(func)
219
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
220
+ if monitor is None or agent_id is None:
221
+ return func(*args, **kwargs)
222
+
223
+ monitor.start(agent_id, interval_seconds=interval)
224
+ stop_event = threading.Event()
225
+
226
+ def heartbeat_loop() -> None:
227
+ while not stop_event.is_set():
228
+ monitor.heartbeat(agent_id)
229
+ stop_event.wait(interval)
230
+
231
+ thread = threading.Thread(
232
+ target=heartbeat_loop,
233
+ daemon=True,
234
+ name=f"heartbeat-{agent_id}",
235
+ )
236
+ thread.start()
237
+
238
+ try:
239
+ return func(*args, **kwargs)
240
+ finally:
241
+ stop_event.set()
242
+ thread.join(timeout=interval + 1.0)
243
+ monitor.stop(agent_id)
244
+
245
+ return sync_wrapper # type: ignore[return-value]
246
+
247
+ return decorator
248
+
249
+
250
+ def circuit_breaker(
251
+ name: str = "default",
252
+ failure_threshold: int = 5,
253
+ recovery_timeout: float = 30.0,
254
+ excluded_exceptions: tuple[type[BaseException], ...] | None = None,
255
+ ) -> Callable[[F], F]:
256
+ """Decorator that wraps a function with circuit breaker protection.
257
+
258
+ Creates a CircuitBreaker instance per decorated function and
259
+ routes all calls through it.
260
+
261
+ Example::
262
+
263
+ @circuit_breaker(name="openai", failure_threshold=3, recovery_timeout=60)
264
+ async def call_openai(prompt: str) -> str:
265
+ return await openai.chat(prompt)
266
+
267
+ Args:
268
+ name: Circuit breaker name.
269
+ failure_threshold: Failures before opening.
270
+ recovery_timeout: Seconds before trying half-open.
271
+ excluded_exceptions: Exceptions that don't count as failures.
272
+
273
+ Returns:
274
+ Decorator function.
275
+ """
276
+ breaker = CircuitBreaker(
277
+ name=name,
278
+ failure_threshold=failure_threshold,
279
+ recovery_timeout=recovery_timeout,
280
+ excluded_exceptions=excluded_exceptions,
281
+ )
282
+
283
+ def decorator(func: F) -> F:
284
+ if inspect.iscoroutinefunction(func):
285
+
286
+ @functools.wraps(func)
287
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
288
+ return await breaker.async_call(func, *args, **kwargs)
289
+
290
+ # Expose the breaker for inspection
291
+ async_wrapper._circuit_breaker = breaker # type: ignore[attr-defined]
292
+ return async_wrapper # type: ignore[return-value]
293
+ else:
294
+
295
+ @functools.wraps(func)
296
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
297
+ return breaker.call(func, *args, **kwargs)
298
+
299
+ sync_wrapper._circuit_breaker = breaker # type: ignore[attr-defined]
300
+ return sync_wrapper # type: ignore[return-value]
301
+
302
+ return decorator
@@ -0,0 +1,17 @@
1
+ """Framework integrations for agent-killswitch.
2
+
3
+ Each integration is a thin adapter that connects the core kill switch
4
+ to a specific AI agent framework. Integrations are lazy-loaded to
5
+ avoid requiring the framework as a dependency.
6
+
7
+ Available integrations:
8
+ - langchain: LangChain callback handler
9
+ - langgraph: LangGraph node and conditional edge
10
+ - crewai: CrewAI pre-action hook
11
+ - openai_agents: OpenAI Agents SDK guardrail
12
+ - asyncio_tasks: Generic asyncio.Task cancellation
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ __all__: list[str] = []
@@ -0,0 +1,211 @@
1
+ """asyncio integration for agent-killswitch.
2
+
3
+ Provides utilities for managing asyncio.Task lifecycle with kill switch
4
+ integration. Supports task group cancellation and periodic kill switch
5
+ checks during async operations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import logging
13
+ from collections.abc import Callable, Coroutine
14
+ from typing import Any
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class KillSwitchTaskGroup:
20
+ """Managed group of asyncio tasks with kill switch integration.
21
+
22
+ Wraps asyncio tasks and periodically checks the kill switch.
23
+ If the kill switch activates, all tasks in the group are cancelled.
24
+
25
+ Example::
26
+
27
+ from agent_killswitch import KillSwitch
28
+ from agent_killswitch.integrations.asyncio_tasks import KillSwitchTaskGroup
29
+
30
+ ks = KillSwitch()
31
+
32
+
33
+ async def worker(name: str):
34
+ while True:
35
+ await asyncio.sleep(1)
36
+ print(f"{name} working...")
37
+
38
+
39
+ async def main():
40
+ group = KillSwitchTaskGroup(
41
+ killswitch=ks,
42
+ agent_id="async-agents",
43
+ check_interval=1.0,
44
+ )
45
+ group.add_task(worker("a"))
46
+ group.add_task(worker("b"))
47
+
48
+ await group.run() # Blocks until kill switch or all done
49
+
50
+ Args:
51
+ killswitch: The KillSwitch instance.
52
+ agent_id: Agent ID for scope checks.
53
+ crew_id: Optional crew ID.
54
+ check_interval: How often to check kill switch (seconds). Default 1.0.
55
+ on_cancelled: Callback when tasks are cancelled by kill switch.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ killswitch: Any,
61
+ agent_id: str | None = None,
62
+ crew_id: str | None = None,
63
+ check_interval: float = 1.0,
64
+ on_cancelled: Callable[[], None] | None = None,
65
+ ) -> None:
66
+ self._ks = killswitch
67
+ self._agent_id = agent_id
68
+ self._crew_id = crew_id
69
+ self._check_interval = check_interval
70
+ self._on_cancelled = on_cancelled
71
+ self._coros: list[Coroutine[Any, Any, Any]] = []
72
+ self._tasks: list[asyncio.Task[Any]] = []
73
+ self._killed = False
74
+
75
+ def add_task(self, coro: Coroutine[Any, Any, Any]) -> None:
76
+ """Add a coroutine to the task group.
77
+
78
+ Args:
79
+ coro: The coroutine to add. Must not have been awaited yet.
80
+ """
81
+ self._coros.append(coro)
82
+
83
+ async def run(self) -> list[Any]:
84
+ """Run all tasks with kill switch monitoring.
85
+
86
+ Starts all coroutines as tasks and monitors the kill switch.
87
+ Returns when all tasks complete or kill switch activates.
88
+
89
+ Returns:
90
+ List of results from completed tasks. Results may be
91
+ asyncio.CancelledError for tasks cancelled by kill switch.
92
+ """
93
+ asyncio.get_event_loop()
94
+
95
+ # Start all tasks
96
+ for coro in self._coros:
97
+ task = asyncio.ensure_future(coro)
98
+ self._tasks.append(task)
99
+ self._coros.clear()
100
+
101
+ # Start monitor
102
+ monitor = asyncio.ensure_future(self._monitor_loop())
103
+
104
+ # Wait for all tasks to complete (or be cancelled)
105
+ results: list[Any] = []
106
+ try:
107
+ done, _pending = await asyncio.wait(
108
+ self._tasks,
109
+ return_when=asyncio.ALL_COMPLETED,
110
+ )
111
+ for task in done:
112
+ try:
113
+ results.append(task.result())
114
+ except (asyncio.CancelledError, Exception) as exc:
115
+ results.append(exc)
116
+ except asyncio.CancelledError:
117
+ results = [asyncio.CancelledError() for _ in self._tasks]
118
+ finally:
119
+ monitor.cancel()
120
+ with contextlib.suppress(asyncio.CancelledError):
121
+ await monitor
122
+
123
+ return results
124
+
125
+ async def _monitor_loop(self) -> None:
126
+ """Periodically check kill switch and cancel tasks if active."""
127
+ while True:
128
+ await asyncio.sleep(self._check_interval)
129
+
130
+ status = self._ks.check(
131
+ scope_target=self._agent_id,
132
+ crew_id=self._crew_id,
133
+ )
134
+
135
+ if status.is_killed and not self._killed:
136
+ self._killed = True
137
+ level_str = status.level.value if status.level else "unknown"
138
+ logger.warning(
139
+ "asyncio_task_group_kill",
140
+ extra={
141
+ "agent_id": self._agent_id,
142
+ "level": level_str,
143
+ "task_count": len(self._tasks),
144
+ },
145
+ )
146
+
147
+ # Cancel all running tasks
148
+ for task in self._tasks:
149
+ if not task.done():
150
+ task.cancel()
151
+
152
+ if self._on_cancelled is not None:
153
+ try:
154
+ self._on_cancelled()
155
+ except Exception as exc:
156
+ logger.error(
157
+ "on_cancelled callback error",
158
+ extra={"error": str(exc)},
159
+ )
160
+
161
+ break
162
+
163
+ async def cancel_all(self) -> None:
164
+ """Manually cancel all tasks in the group."""
165
+ for task in self._tasks:
166
+ if not task.done():
167
+ task.cancel()
168
+
169
+ @property
170
+ def is_killed(self) -> bool:
171
+ """Whether the task group was killed by the kill switch."""
172
+ return self._killed
173
+
174
+
175
+ async def run_with_killswitch(
176
+ coro: Coroutine[Any, Any, Any],
177
+ killswitch: Any,
178
+ agent_id: str | None = None,
179
+ crew_id: str | None = None,
180
+ check_interval: float = 1.0,
181
+ ) -> Any:
182
+ """Run a single coroutine with kill switch monitoring.
183
+
184
+ Convenience function for running a single async operation with
185
+ periodic kill switch checks.
186
+
187
+ Args:
188
+ coro: The coroutine to run.
189
+ killswitch: The KillSwitch instance.
190
+ agent_id: Agent ID for scope checks.
191
+ crew_id: Optional crew ID.
192
+ check_interval: Kill switch check interval in seconds.
193
+
194
+ Returns:
195
+ The result of the coroutine.
196
+
197
+ Raises:
198
+ asyncio.CancelledError: If killed by kill switch.
199
+ """
200
+ group = KillSwitchTaskGroup(
201
+ killswitch=killswitch,
202
+ agent_id=agent_id,
203
+ crew_id=crew_id,
204
+ check_interval=check_interval,
205
+ )
206
+ group.add_task(coro)
207
+ results = await group.run()
208
+
209
+ if results and isinstance(results[0], BaseException):
210
+ raise results[0]
211
+ return results[0] if results else None