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