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,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
|