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,281 @@
1
+ """Budget-based kill trigger for autonomous AI agents.
2
+
3
+ Monitors cost accumulation and triggers kill switches when budget
4
+ thresholds are exceeded. Supports configurable alert thresholds
5
+ and automatic PAUSE or KILL on budget exhaustion.
6
+
7
+ Thread-safe: All state is protected by a reentrant lock.
8
+ No external dependencies: Uses only Python stdlib.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import threading
15
+ from collections.abc import Callable
16
+ from typing import Any
17
+
18
+ from .enums import KillLevel, KillScope
19
+ from .models import BudgetStatus
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class BudgetKillTrigger:
25
+ """Monitor agent costs and kill when budget is exceeded.
26
+
27
+ Tracks cost accumulation per agent and fires alerts at configurable
28
+ thresholds. When the budget is exhausted, automatically activates
29
+ the kill switch (PAUSE or KILL, configurable).
30
+
31
+ Example::
32
+
33
+ from agent_killswitch import KillSwitch, BudgetKillTrigger
34
+
35
+ ks = KillSwitch()
36
+ budget = BudgetKillTrigger(
37
+ killswitch=ks,
38
+ default_budget=50.0, # $50 per agent
39
+ on_alert=lambda agent_id, pct: print(f"{agent_id}: {pct * 100:.0f}% used"),
40
+ )
41
+
42
+ budget.set_budget("agent-1", 50.0)
43
+ budget.track_cost("agent-1", 10.0) # $10 spent
44
+ budget.track_cost("agent-1", 35.0) # $45 total -> 90% alert fires
45
+
46
+ Args:
47
+ killswitch: Optional KillSwitch instance for auto-kill on exceed.
48
+ default_budget: Default budget limit for new agents. Default 0 (unlimited).
49
+ alert_thresholds: Budget utilization thresholds for alerts.
50
+ Default [0.50, 0.80, 0.95].
51
+ kill_on_exceed: If True, activate KILL when budget exceeded.
52
+ If False, activate PAUSE. Default True.
53
+ on_alert: Callback when a threshold is reached.
54
+ Signature: (agent_id: str, utilization: float) -> None
55
+ on_budget_exceeded: Callback when budget is fully exceeded.
56
+ Signature: (agent_id: str, current_cost: float, limit: float) -> None
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ killswitch: Any = None,
62
+ default_budget: float = 0.0,
63
+ alert_thresholds: list[float] | None = None,
64
+ kill_on_exceed: bool = True,
65
+ on_alert: Callable[[str, float], None] | None = None,
66
+ on_budget_exceeded: Callable[[str, float, float], None] | None = None,
67
+ ) -> None:
68
+ self._killswitch = killswitch
69
+ self._default_budget = default_budget
70
+ self._alert_thresholds = sorted(alert_thresholds or [0.50, 0.80, 0.95])
71
+ self._kill_on_exceed = kill_on_exceed
72
+ self._on_alert = on_alert
73
+ self._on_budget_exceeded = on_budget_exceeded
74
+ self._lock = threading.RLock()
75
+
76
+ # Per-agent budget tracking
77
+ self._budgets: dict[str, BudgetStatus] = {}
78
+
79
+ def set_budget(self, agent_id: str, limit: float) -> None:
80
+ """Set the budget limit for an agent.
81
+
82
+ Args:
83
+ agent_id: The agent to set the budget for.
84
+ limit: Maximum allowed cost. Use 0 for unlimited.
85
+ """
86
+ with self._lock:
87
+ if agent_id in self._budgets:
88
+ self._budgets[agent_id].budget_limit = limit
89
+ else:
90
+ self._budgets[agent_id] = BudgetStatus(
91
+ agent_id=agent_id,
92
+ budget_limit=limit,
93
+ alert_thresholds=list(self._alert_thresholds),
94
+ )
95
+
96
+ logger.info(
97
+ "budget_set",
98
+ extra={"agent_id": agent_id, "limit": limit},
99
+ )
100
+
101
+ def track_cost(self, agent_id: str, amount: float) -> BudgetStatus:
102
+ """Record a cost for an agent.
103
+
104
+ Updates the accumulated cost and checks thresholds. If the budget
105
+ is exceeded, triggers the kill switch.
106
+
107
+ Args:
108
+ agent_id: The agent incurring the cost.
109
+ amount: The cost amount to add.
110
+
111
+ Returns:
112
+ Updated BudgetStatus for the agent.
113
+ """
114
+ with self._lock:
115
+ if agent_id not in self._budgets:
116
+ self._budgets[agent_id] = BudgetStatus(
117
+ agent_id=agent_id,
118
+ budget_limit=self._default_budget,
119
+ alert_thresholds=list(self._alert_thresholds),
120
+ )
121
+
122
+ status = self._budgets[agent_id]
123
+ status.current_cost += amount
124
+
125
+ # Calculate utilization
126
+ if status.budget_limit > 0:
127
+ status.utilization = status.current_cost / status.budget_limit
128
+ else:
129
+ status.utilization = 0.0
130
+
131
+ # Check alert thresholds
132
+ if status.budget_limit > 0:
133
+ for threshold in status.alert_thresholds:
134
+ if status.utilization >= threshold and threshold not in status.alerts_fired:
135
+ status.alerts_fired.add(threshold)
136
+ logger.warning(
137
+ "budget_alert",
138
+ extra={
139
+ "agent_id": agent_id,
140
+ "threshold": threshold,
141
+ "utilization": round(status.utilization, 4),
142
+ "current_cost": status.current_cost,
143
+ "budget_limit": status.budget_limit,
144
+ },
145
+ )
146
+ if self._on_alert is not None:
147
+ try:
148
+ self._on_alert(agent_id, status.utilization)
149
+ except Exception as exc:
150
+ logger.error(
151
+ "on_alert callback error",
152
+ extra={"error": str(exc)},
153
+ )
154
+
155
+ # Check if budget exceeded
156
+ if status.utilization >= 1.0:
157
+ self._handle_budget_exceeded(agent_id, status)
158
+
159
+ # Return a copy
160
+ return BudgetStatus(
161
+ agent_id=status.agent_id,
162
+ budget_limit=status.budget_limit,
163
+ current_cost=status.current_cost,
164
+ utilization=status.utilization,
165
+ alert_thresholds=list(status.alert_thresholds),
166
+ alerts_fired=set(status.alerts_fired),
167
+ )
168
+
169
+ def _handle_budget_exceeded(self, agent_id: str, status: BudgetStatus) -> None:
170
+ """Handle budget exceeded by triggering kill switch.
171
+
172
+ Args:
173
+ agent_id: The agent that exceeded its budget.
174
+ status: The current budget status.
175
+ """
176
+ logger.critical(
177
+ "budget_exceeded",
178
+ extra={
179
+ "agent_id": agent_id,
180
+ "current_cost": status.current_cost,
181
+ "budget_limit": status.budget_limit,
182
+ "utilization": round(status.utilization, 4),
183
+ },
184
+ )
185
+
186
+ # Fire callback
187
+ if self._on_budget_exceeded is not None:
188
+ try:
189
+ self._on_budget_exceeded(agent_id, status.current_cost, status.budget_limit)
190
+ except Exception as exc:
191
+ logger.error(
192
+ "on_budget_exceeded callback error",
193
+ extra={"error": str(exc)},
194
+ )
195
+
196
+ # Trigger kill switch
197
+ if self._killswitch is not None:
198
+ level = KillLevel.KILL if self._kill_on_exceed else KillLevel.PAUSE
199
+ try:
200
+ self._killswitch.activate(
201
+ level=level,
202
+ scope=KillScope.AGENT,
203
+ scope_target=agent_id,
204
+ reason=f"Budget exceeded: ${status.current_cost:.2f} / ${status.budget_limit:.2f}",
205
+ activated_by="budget_kill_trigger",
206
+ )
207
+ except Exception as exc:
208
+ logger.error(
209
+ "budget_kill_activation_error",
210
+ extra={"agent_id": agent_id, "error": str(exc)},
211
+ )
212
+
213
+ def get_status(self, agent_id: str) -> BudgetStatus | None:
214
+ """Get the budget status for an agent.
215
+
216
+ Args:
217
+ agent_id: The agent to check.
218
+
219
+ Returns:
220
+ BudgetStatus or None if no budget tracked.
221
+ """
222
+ with self._lock:
223
+ status = self._budgets.get(agent_id)
224
+ if status is None:
225
+ return None
226
+ return BudgetStatus(
227
+ agent_id=status.agent_id,
228
+ budget_limit=status.budget_limit,
229
+ current_cost=status.current_cost,
230
+ utilization=status.utilization,
231
+ alert_thresholds=list(status.alert_thresholds),
232
+ alerts_fired=set(status.alerts_fired),
233
+ )
234
+
235
+ def get_all_statuses(self) -> dict[str, BudgetStatus]:
236
+ """Get budget status for all tracked agents.
237
+
238
+ Returns:
239
+ Dictionary of agent_id -> BudgetStatus.
240
+ """
241
+ with self._lock:
242
+ return {
243
+ agent_id: BudgetStatus(
244
+ agent_id=s.agent_id,
245
+ budget_limit=s.budget_limit,
246
+ current_cost=s.current_cost,
247
+ utilization=s.utilization,
248
+ alert_thresholds=list(s.alert_thresholds),
249
+ alerts_fired=set(s.alerts_fired),
250
+ )
251
+ for agent_id, s in self._budgets.items()
252
+ }
253
+
254
+ def reset_cost(self, agent_id: str) -> None:
255
+ """Reset the accumulated cost for an agent.
256
+
257
+ Args:
258
+ agent_id: The agent to reset.
259
+ """
260
+ with self._lock:
261
+ if agent_id in self._budgets:
262
+ self._budgets[agent_id].current_cost = 0.0
263
+ self._budgets[agent_id].utilization = 0.0
264
+ self._budgets[agent_id].alerts_fired.clear()
265
+
266
+ logger.info("budget_cost_reset", extra={"agent_id": agent_id})
267
+
268
+ def remove_agent(self, agent_id: str) -> None:
269
+ """Remove all budget tracking for an agent.
270
+
271
+ Args:
272
+ agent_id: The agent to remove.
273
+ """
274
+ with self._lock:
275
+ self._budgets.pop(agent_id, None)
276
+
277
+ @property
278
+ def tracked_agents(self) -> int:
279
+ """Number of agents with budget tracking."""
280
+ with self._lock:
281
+ return len(self._budgets)
@@ -0,0 +1,280 @@
1
+ """Cascading termination for agent hierarchies.
2
+
3
+ When a parent agent is killed, all children cascade. Supports
4
+ configurable cascade delay and orphan detection/cleanup.
5
+
6
+ Thread-safe: All state is protected by a reentrant lock.
7
+ No external dependencies: Uses only Python stdlib.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import threading
14
+ import time
15
+ from collections.abc import Callable
16
+ from typing import Any
17
+
18
+ from .enums import KillLevel, KillScope
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class CascadingTerminator:
24
+ """Manages cascading termination through agent hierarchies.
25
+
26
+ When a parent agent is killed, the CascadingTerminator propagates
27
+ the kill to all children (and their children, recursively) with
28
+ a configurable delay between each cascade level.
29
+
30
+ Example::
31
+
32
+ from agent_killswitch import KillSwitch, CascadingTerminator
33
+
34
+ ks = KillSwitch()
35
+ cascade = CascadingTerminator(killswitch=ks, cascade_delay=0.5)
36
+
37
+ # Build hierarchy
38
+ ks.register_agent("parent")
39
+ ks.register_agent("child-1", parent_id="parent")
40
+ ks.register_agent("child-2", parent_id="parent")
41
+ ks.register_agent("grandchild", parent_id="child-1")
42
+
43
+ # Kill parent -> children and grandchild cascade
44
+ cascade.cascade_kill("parent", level=KillLevel.KILL, reason="runaway")
45
+
46
+ Args:
47
+ killswitch: The KillSwitch instance that manages agent state.
48
+ cascade_delay: Seconds to wait between cascade levels. Default 0.0.
49
+ on_cascade: Callback when a kill cascades to a child.
50
+ Signature: (parent_id, child_id, level) -> None
51
+ on_orphan_detected: Callback when an orphan agent is detected.
52
+ Signature: (agent_id,) -> None
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ killswitch: Any,
58
+ cascade_delay: float = 0.0,
59
+ on_cascade: Callable[[str, str, KillLevel], None] | None = None,
60
+ on_orphan_detected: Callable[[str], None] | None = None,
61
+ ) -> None:
62
+ self._ks = killswitch
63
+ self._cascade_delay = cascade_delay
64
+ self._on_cascade = on_cascade
65
+ self._on_orphan_detected = on_orphan_detected
66
+ self._lock = threading.RLock()
67
+
68
+ def cascade_kill(
69
+ self,
70
+ agent_id: str,
71
+ level: KillLevel = KillLevel.KILL,
72
+ reason: str = "",
73
+ activated_by: str = "cascade",
74
+ ) -> list[str]:
75
+ """Kill an agent and cascade to all descendants.
76
+
77
+ Activates the kill switch on the specified agent, then
78
+ recursively cascades to all children with optional delay.
79
+
80
+ Args:
81
+ agent_id: The root agent to kill.
82
+ level: Kill level to apply. Default KILL.
83
+ reason: Reason for the kill.
84
+ activated_by: Who initiated the cascade.
85
+
86
+ Returns:
87
+ List of all agent IDs that were killed (including root).
88
+ """
89
+ killed: list[str] = []
90
+
91
+ # Kill the root agent
92
+ try:
93
+ self._ks.activate(
94
+ level=level,
95
+ scope=KillScope.AGENT,
96
+ scope_target=agent_id,
97
+ reason=reason,
98
+ activated_by=activated_by,
99
+ )
100
+ killed.append(agent_id)
101
+ except Exception as exc:
102
+ logger.error(
103
+ "cascade_kill_root_failed",
104
+ extra={"agent_id": agent_id, "error": str(exc)},
105
+ )
106
+ return killed
107
+
108
+ # Cascade to children
109
+ children = self._ks.get_children(agent_id)
110
+ for child_id in children:
111
+ if self._cascade_delay > 0:
112
+ time.sleep(self._cascade_delay)
113
+
114
+ cascade_reason = f"Cascade from parent {agent_id}: {reason}"
115
+
116
+ try:
117
+ self._ks.activate(
118
+ level=level,
119
+ scope=KillScope.AGENT,
120
+ scope_target=child_id,
121
+ reason=cascade_reason,
122
+ activated_by=f"cascade:{agent_id}",
123
+ )
124
+ killed.append(child_id)
125
+
126
+ logger.info(
127
+ "cascade_kill_child",
128
+ extra={
129
+ "parent_id": agent_id,
130
+ "child_id": child_id,
131
+ "level": level.value,
132
+ },
133
+ )
134
+
135
+ # Fire callback
136
+ if self._on_cascade is not None:
137
+ try:
138
+ self._on_cascade(agent_id, child_id, level)
139
+ except Exception as exc:
140
+ logger.error(
141
+ "on_cascade callback error",
142
+ extra={"error": str(exc)},
143
+ )
144
+
145
+ # Recurse into grandchildren
146
+ grandchildren_killed = self.cascade_kill(
147
+ child_id,
148
+ level=level,
149
+ reason=cascade_reason,
150
+ activated_by=f"cascade:{agent_id}",
151
+ )
152
+ # Avoid duplicates (child_id already added)
153
+ for gid in grandchildren_killed:
154
+ if gid not in killed:
155
+ killed.append(gid)
156
+
157
+ except Exception as exc:
158
+ logger.error(
159
+ "cascade_kill_child_failed",
160
+ extra={"child_id": child_id, "error": str(exc)},
161
+ )
162
+
163
+ logger.info(
164
+ "cascade_kill_complete",
165
+ extra={
166
+ "root_agent": agent_id,
167
+ "total_killed": len(killed),
168
+ "killed_agents": killed,
169
+ },
170
+ )
171
+
172
+ return killed
173
+
174
+ def detect_orphans(self) -> list[str]:
175
+ """Detect orphaned agents (parent is dead/unregistered).
176
+
177
+ An orphan is an agent whose parent_id is set but the parent
178
+ is not registered in the kill switch.
179
+
180
+ Returns:
181
+ List of orphaned agent IDs.
182
+ """
183
+ orphans: list[str] = []
184
+
185
+ with self._lock:
186
+ # Access the killswitch's agent registry
187
+ if not hasattr(self._ks, "_agents"):
188
+ return orphans
189
+
190
+ agents = dict(self._ks._agents)
191
+
192
+ for agent_id, registration in agents.items():
193
+ if registration.parent_id is not None and registration.parent_id not in agents:
194
+ orphans.append(agent_id)
195
+
196
+ if self._on_orphan_detected is not None:
197
+ try:
198
+ self._on_orphan_detected(agent_id)
199
+ except Exception as exc:
200
+ logger.error(
201
+ "on_orphan_detected callback error",
202
+ extra={"error": str(exc)},
203
+ )
204
+
205
+ if orphans:
206
+ logger.warning(
207
+ "orphan_agents_detected",
208
+ extra={"count": len(orphans), "agents": orphans},
209
+ )
210
+
211
+ return orphans
212
+
213
+ def cleanup_orphans(
214
+ self,
215
+ level: KillLevel = KillLevel.STOP,
216
+ reason: str = "Orphan cleanup",
217
+ ) -> list[str]:
218
+ """Detect and kill orphaned agents.
219
+
220
+ Args:
221
+ level: Kill level to apply to orphans. Default STOP.
222
+ reason: Reason for the cleanup.
223
+
224
+ Returns:
225
+ List of killed orphan agent IDs.
226
+ """
227
+ orphans = self.detect_orphans()
228
+ killed: list[str] = []
229
+
230
+ for agent_id in orphans:
231
+ try:
232
+ self._ks.activate(
233
+ level=level,
234
+ scope=KillScope.AGENT,
235
+ scope_target=agent_id,
236
+ reason=reason,
237
+ activated_by="orphan_cleanup",
238
+ )
239
+ killed.append(agent_id)
240
+ except Exception as exc:
241
+ logger.error(
242
+ "orphan_cleanup_failed",
243
+ extra={"agent_id": agent_id, "error": str(exc)},
244
+ )
245
+
246
+ return killed
247
+
248
+ def get_hierarchy(self, root_id: str | None = None) -> dict[str, Any]:
249
+ """Get the agent hierarchy as a nested dictionary.
250
+
251
+ Args:
252
+ root_id: If specified, return the subtree rooted at this agent.
253
+ If None, return all root agents.
254
+
255
+ Returns:
256
+ Dictionary representing the hierarchy tree.
257
+ """
258
+ if not hasattr(self._ks, "_agents"):
259
+ return {}
260
+
261
+ agents = dict(self._ks._agents)
262
+
263
+ def build_tree(agent_id: str) -> dict[str, Any]:
264
+ children = [aid for aid, reg in agents.items() if reg.parent_id == agent_id]
265
+ return {
266
+ "agent_id": agent_id,
267
+ "children": [build_tree(child) for child in children],
268
+ }
269
+
270
+ if root_id is not None:
271
+ if root_id in agents:
272
+ return build_tree(root_id)
273
+ return {}
274
+
275
+ # Find all roots (no parent)
276
+ roots = [aid for aid, reg in agents.items() if reg.parent_id is None]
277
+ return {
278
+ "roots": [build_tree(root) for root in roots],
279
+ "total_agents": len(agents),
280
+ }