agenthandler 0.92.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.
Potentially problematic release.
This version of agenthandler might be problematic. Click here for more details.
- agenthandler/__init__.py +163 -0
- agenthandler/approval.py +135 -0
- agenthandler/audit.py +144 -0
- agenthandler/auth.py +188 -0
- agenthandler/budget.py +94 -0
- agenthandler/builder.py +178 -0
- agenthandler/circuit_breaker.py +88 -0
- agenthandler/connectors.py +425 -0
- agenthandler/context.py +410 -0
- agenthandler/errors.py +130 -0
- agenthandler/guardrails/__init__.py +79 -0
- agenthandler/guardrails/deterministic.py +472 -0
- agenthandler/guardrails/llm_based.py +549 -0
- agenthandler/guardrails/types.py +83 -0
- agenthandler/observe.py +525 -0
- agenthandler/openclaw.py +370 -0
- agenthandler/pipeline.py +532 -0
- agenthandler/pipeline_poml.py +593 -0
- agenthandler/policy.py +251 -0
- agenthandler/py.typed +0 -0
- agenthandler/redact.py +260 -0
- agenthandler/reflection.py +371 -0
- agenthandler/replay.py +225 -0
- agenthandler/server.py +752 -0
- agenthandler/session.py +391 -0
- agenthandler/skill.py +265 -0
- agenthandler/store.py +341 -0
- agenthandler/streams.py +339 -0
- agenthandler/supervisor.py +882 -0
- agenthandler/triggers.py +471 -0
- agenthandler-0.92.0.dist-info/METADATA +1355 -0
- agenthandler-0.92.0.dist-info/RECORD +34 -0
- agenthandler-0.92.0.dist-info/WHEEL +4 -0
- agenthandler-0.92.0.dist-info/licenses/LICENSE +201 -0
agenthandler/context.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""Context compression — supervision-anchored context window management.
|
|
2
|
+
|
|
3
|
+
Supervised agents can compress more aggressively than unsupervised ones because
|
|
4
|
+
safety-critical state (policies, budgets, circuit breakers) is enforced by the
|
|
5
|
+
supervisor, not by the LLM's memory. This module reconstructs anchored state
|
|
6
|
+
fresh each turn and compresses older history safely.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from agenthandler import Supervisor, Policy, ContextWindow
|
|
10
|
+
|
|
11
|
+
supervisor = Supervisor(Policy(max_iterations=5, token_budget=10000))
|
|
12
|
+
ctx = ContextWindow(supervisor, max_recent_turns=10)
|
|
13
|
+
|
|
14
|
+
ctx.add_turn("user", "Search for quantum computing")
|
|
15
|
+
ctx.add_turn("assistant", "I'll search for that.", tool_calls=[...])
|
|
16
|
+
|
|
17
|
+
prompt = ctx.to_prompt() # anchored state + compressed history + recent turns
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import threading
|
|
22
|
+
from dataclasses import asdict, dataclass, field
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from typing import Any, Callable, Coroutine, Dict, List, Optional
|
|
25
|
+
|
|
26
|
+
from .budget import BudgetSnapshot
|
|
27
|
+
from .supervisor import Supervisor
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Turn:
|
|
32
|
+
"""A single turn in the conversation history."""
|
|
33
|
+
|
|
34
|
+
role: str # "user", "assistant", "tool"
|
|
35
|
+
content: str
|
|
36
|
+
tool_calls: Optional[List[Dict[str, Any]]] = None
|
|
37
|
+
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
38
|
+
token_estimate: int = 0
|
|
39
|
+
skill_name: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
if self.token_estimate == 0:
|
|
43
|
+
self.token_estimate = len(self.content) // 4
|
|
44
|
+
if self.tool_calls:
|
|
45
|
+
for tc in self.tool_calls:
|
|
46
|
+
self.token_estimate += len(str(tc)) // 4
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
49
|
+
d = asdict(self)
|
|
50
|
+
return {k: v for k, v in d.items() if v is not None and v != 0}
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Turn":
|
|
54
|
+
return cls(
|
|
55
|
+
role=d["role"],
|
|
56
|
+
content=d.get("content", ""),
|
|
57
|
+
tool_calls=d.get("tool_calls"),
|
|
58
|
+
timestamp=d.get("timestamp", ""),
|
|
59
|
+
token_estimate=d.get("token_estimate", 0),
|
|
60
|
+
skill_name=d.get("skill_name"),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class AnchoredState:
|
|
66
|
+
"""Safety-critical state reconstructed fresh from the supervisor every turn.
|
|
67
|
+
|
|
68
|
+
Never compressed — always accurate because it's read from the supervisor's
|
|
69
|
+
live state, not from LLM memory.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
budget_snapshot: BudgetSnapshot
|
|
73
|
+
circuit_states: Dict[str, str]
|
|
74
|
+
policy_summary: Dict[str, Any]
|
|
75
|
+
confirmed_tools: List[str]
|
|
76
|
+
active_skill: Optional[str] = None
|
|
77
|
+
|
|
78
|
+
def to_prompt(self) -> str:
|
|
79
|
+
lines = ["[SUPERVISION STATE]"]
|
|
80
|
+
b = self.budget_snapshot
|
|
81
|
+
budget_str = f"{b.tokens_used}"
|
|
82
|
+
if b.token_limit is not None:
|
|
83
|
+
budget_str += f"/{b.token_limit}"
|
|
84
|
+
else:
|
|
85
|
+
budget_str += " (no limit)"
|
|
86
|
+
lines.append(
|
|
87
|
+
f"Budget: {budget_str} tokens used, {b.iterations}/{b.iteration_limit} iterations"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if self.circuit_states:
|
|
91
|
+
states = ", ".join(f"{name}={state}" for name, state in self.circuit_states.items())
|
|
92
|
+
lines.append(f"Circuit breakers: {states}")
|
|
93
|
+
|
|
94
|
+
ps = self.policy_summary
|
|
95
|
+
parts = []
|
|
96
|
+
if "max_iterations" in ps:
|
|
97
|
+
parts.append(f"max_iterations={ps['max_iterations']}")
|
|
98
|
+
if "tool_timeout" in ps:
|
|
99
|
+
parts.append(f"tool_timeout={ps['tool_timeout']}s")
|
|
100
|
+
if "token_budget" in ps and ps["token_budget"] is not None:
|
|
101
|
+
parts.append(f"token_budget={ps['token_budget']}")
|
|
102
|
+
if parts:
|
|
103
|
+
lines.append(f"Policy: {', '.join(parts)}")
|
|
104
|
+
|
|
105
|
+
if self.confirmed_tools:
|
|
106
|
+
lines.append(f"Confirmation required: {', '.join(self.confirmed_tools)}")
|
|
107
|
+
|
|
108
|
+
if self.active_skill:
|
|
109
|
+
lines.append(f"Active skill: {self.active_skill}")
|
|
110
|
+
|
|
111
|
+
return "\n".join(lines)
|
|
112
|
+
|
|
113
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
114
|
+
d: Dict[str, Any] = {
|
|
115
|
+
"budget_snapshot": asdict(self.budget_snapshot),
|
|
116
|
+
"circuit_states": self.circuit_states,
|
|
117
|
+
"policy_summary": self.policy_summary,
|
|
118
|
+
"confirmed_tools": self.confirmed_tools,
|
|
119
|
+
}
|
|
120
|
+
if self.active_skill:
|
|
121
|
+
d["active_skill"] = self.active_skill
|
|
122
|
+
return d
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class CompressedHistory:
|
|
127
|
+
"""Lossy but safe summary of older conversation turns."""
|
|
128
|
+
|
|
129
|
+
summary: str
|
|
130
|
+
turn_count: int
|
|
131
|
+
tool_call_summaries: List[Dict[str, Any]]
|
|
132
|
+
original_turn_range: str = ""
|
|
133
|
+
|
|
134
|
+
def to_prompt(self) -> str:
|
|
135
|
+
lines = [f"[CONVERSATION HISTORY (turns {self.original_turn_range}, compressed)]"]
|
|
136
|
+
lines.append(self.summary)
|
|
137
|
+
return "\n".join(lines)
|
|
138
|
+
|
|
139
|
+
def token_estimate(self) -> int:
|
|
140
|
+
return len(self.to_prompt()) // 4
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class CompressedContext:
|
|
145
|
+
"""The full compressed context ready for injection into LLM prompts."""
|
|
146
|
+
|
|
147
|
+
anchored: AnchoredState
|
|
148
|
+
history: Optional[CompressedHistory]
|
|
149
|
+
recent_turns: List[Turn]
|
|
150
|
+
|
|
151
|
+
def to_prompt(self) -> str:
|
|
152
|
+
parts = [self.anchored.to_prompt()]
|
|
153
|
+
if self.history:
|
|
154
|
+
parts.append(self.history.to_prompt())
|
|
155
|
+
if self.recent_turns:
|
|
156
|
+
lines = ["[RECENT CONVERSATION]"]
|
|
157
|
+
for turn in self.recent_turns:
|
|
158
|
+
lines.append(f"[{turn.role}]: {turn.content}")
|
|
159
|
+
if turn.tool_calls:
|
|
160
|
+
for tc in turn.tool_calls:
|
|
161
|
+
name = tc.get("tool_name", "unknown")
|
|
162
|
+
params = tc.get("params", {})
|
|
163
|
+
result = tc.get("result_summary", "")
|
|
164
|
+
param_str = ", ".join(f"{k}={repr(v)}" for k, v in params.items())
|
|
165
|
+
line = f" -> {name}({param_str})"
|
|
166
|
+
if result:
|
|
167
|
+
line += f" = {result}"
|
|
168
|
+
lines.append(line)
|
|
169
|
+
parts.append("\n".join(lines))
|
|
170
|
+
return "\n\n".join(parts)
|
|
171
|
+
|
|
172
|
+
def token_estimate(self) -> int:
|
|
173
|
+
return len(self.to_prompt()) // 4
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class ContextWindow:
|
|
177
|
+
"""Supervision-anchored context window with automatic compression.
|
|
178
|
+
|
|
179
|
+
Maintains a sliding window of conversation turns. Older turns are compressed
|
|
180
|
+
using tool call summaries and optional LLM summarization. Safety-critical
|
|
181
|
+
state is always reconstructed fresh from the supervisor.
|
|
182
|
+
|
|
183
|
+
Usage:
|
|
184
|
+
ctx = ContextWindow(supervisor, max_recent_turns=10)
|
|
185
|
+
ctx.add_turn("user", "Search for quantum computing")
|
|
186
|
+
prompt = ctx.to_prompt()
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def __init__(
|
|
190
|
+
self,
|
|
191
|
+
supervisor: Supervisor,
|
|
192
|
+
max_recent_turns: int = 10,
|
|
193
|
+
max_summary_tokens: int = 500,
|
|
194
|
+
summarizer: Optional[Callable[[str], Coroutine[Any, Any, str]]] = None,
|
|
195
|
+
skill_name: Optional[str] = None,
|
|
196
|
+
):
|
|
197
|
+
self._supervisor = supervisor
|
|
198
|
+
self._max_recent_turns = max_recent_turns
|
|
199
|
+
self._max_summary_tokens = max_summary_tokens
|
|
200
|
+
self._summarizer = summarizer
|
|
201
|
+
self._skill_name = skill_name
|
|
202
|
+
self._turns: List[Turn] = []
|
|
203
|
+
self._compressed: Optional[CompressedHistory] = None
|
|
204
|
+
self._lock = threading.Lock()
|
|
205
|
+
|
|
206
|
+
def add_turn(
|
|
207
|
+
self,
|
|
208
|
+
role: str,
|
|
209
|
+
content: str,
|
|
210
|
+
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
|
211
|
+
skill_name: Optional[str] = None,
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Add a turn to the conversation history."""
|
|
214
|
+
turn = Turn(
|
|
215
|
+
role=role,
|
|
216
|
+
content=content,
|
|
217
|
+
tool_calls=tool_calls,
|
|
218
|
+
skill_name=skill_name or self._skill_name,
|
|
219
|
+
)
|
|
220
|
+
with self._lock:
|
|
221
|
+
self._turns.append(turn)
|
|
222
|
+
|
|
223
|
+
def get_anchored_state(self) -> AnchoredState:
|
|
224
|
+
"""Reconstruct safety-critical state fresh from the supervisor."""
|
|
225
|
+
budget = self._supervisor.budget()
|
|
226
|
+
circuits = self._supervisor.circuit_breaker_states()
|
|
227
|
+
policy = self._supervisor.policy
|
|
228
|
+
return AnchoredState(
|
|
229
|
+
budget_snapshot=budget,
|
|
230
|
+
circuit_states=circuits,
|
|
231
|
+
policy_summary={
|
|
232
|
+
"max_iterations": policy.max_iterations,
|
|
233
|
+
"tool_timeout": policy.tool_timeout,
|
|
234
|
+
"token_budget": policy.token_budget,
|
|
235
|
+
"require_confirm": list(policy.require_confirm),
|
|
236
|
+
},
|
|
237
|
+
confirmed_tools=list(policy.require_confirm),
|
|
238
|
+
active_skill=self._skill_name,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def _compress_turns(self, turns: List[Turn]) -> CompressedHistory:
|
|
242
|
+
"""Compress turns using audit-based extraction (no LLM needed)."""
|
|
243
|
+
groups: List[Dict[str, Any]] = []
|
|
244
|
+
current_skill: Optional[str] = None
|
|
245
|
+
current_group: List[Turn] = []
|
|
246
|
+
|
|
247
|
+
for turn in turns:
|
|
248
|
+
skill = turn.skill_name or "general"
|
|
249
|
+
if skill != current_skill and current_group:
|
|
250
|
+
groups.append({"skill": current_skill, "turns": current_group})
|
|
251
|
+
current_group = []
|
|
252
|
+
current_skill = skill
|
|
253
|
+
current_group.append(turn)
|
|
254
|
+
if current_group:
|
|
255
|
+
groups.append({"skill": current_skill, "turns": current_group})
|
|
256
|
+
|
|
257
|
+
summary_parts = []
|
|
258
|
+
all_tool_summaries: List[Dict[str, Any]] = []
|
|
259
|
+
|
|
260
|
+
for group in groups:
|
|
261
|
+
skill = group["skill"]
|
|
262
|
+
group_turns: List[Turn] = group["turns"]
|
|
263
|
+
tool_counts: Dict[str, int] = {}
|
|
264
|
+
tool_params: Dict[str, str] = {}
|
|
265
|
+
user_snippets: List[str] = []
|
|
266
|
+
|
|
267
|
+
for turn in group_turns:
|
|
268
|
+
if turn.tool_calls:
|
|
269
|
+
for tc in turn.tool_calls:
|
|
270
|
+
name = tc.get("tool_name", "unknown")
|
|
271
|
+
tool_counts[name] = tool_counts.get(name, 0) + 1
|
|
272
|
+
if name not in tool_params:
|
|
273
|
+
params = tc.get("params", {})
|
|
274
|
+
tool_params[name] = str(params)[:80]
|
|
275
|
+
all_tool_summaries.append(tc)
|
|
276
|
+
elif turn.role == "user":
|
|
277
|
+
user_snippets.append(turn.content[:100])
|
|
278
|
+
|
|
279
|
+
parts = []
|
|
280
|
+
if tool_counts:
|
|
281
|
+
tool_strs = []
|
|
282
|
+
for name, count in tool_counts.items():
|
|
283
|
+
s = f"{name}({tool_params.get(name, '')})"
|
|
284
|
+
if count > 1:
|
|
285
|
+
s += f" x{count}"
|
|
286
|
+
tool_strs.append(s)
|
|
287
|
+
parts.append("called " + ", ".join(tool_strs))
|
|
288
|
+
if user_snippets:
|
|
289
|
+
parts.append("User: " + "; ".join(user_snippets))
|
|
290
|
+
|
|
291
|
+
line = f"[{skill}]"
|
|
292
|
+
if parts:
|
|
293
|
+
line += " -- " + ". ".join(parts)
|
|
294
|
+
summary_parts.append(line)
|
|
295
|
+
|
|
296
|
+
summary = "\n".join(summary_parts)
|
|
297
|
+
max_chars = self._max_summary_tokens * 4
|
|
298
|
+
if len(summary) > max_chars:
|
|
299
|
+
summary = summary[: max_chars - 3] + "..."
|
|
300
|
+
|
|
301
|
+
start = 1
|
|
302
|
+
if self._compressed:
|
|
303
|
+
start = self._compressed.turn_count + 1
|
|
304
|
+
end = start + len(turns) - 1
|
|
305
|
+
|
|
306
|
+
return CompressedHistory(
|
|
307
|
+
summary=summary,
|
|
308
|
+
turn_count=(self._compressed.turn_count if self._compressed else 0) + len(turns),
|
|
309
|
+
tool_call_summaries=(self._compressed.tool_call_summaries if self._compressed else [])
|
|
310
|
+
+ all_tool_summaries,
|
|
311
|
+
original_turn_range=f"1-{end}",
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
async def compress(self) -> CompressedContext:
|
|
315
|
+
"""Compress older turns and return the full context.
|
|
316
|
+
|
|
317
|
+
Keeps the last max_recent_turns at full fidelity. Older turns are
|
|
318
|
+
compressed into a summary. If a summarizer is provided, it's used
|
|
319
|
+
for a richer summary; otherwise, audit-based extraction is used.
|
|
320
|
+
"""
|
|
321
|
+
with self._lock:
|
|
322
|
+
if len(self._turns) <= self._max_recent_turns:
|
|
323
|
+
return CompressedContext(
|
|
324
|
+
anchored=self.get_anchored_state(),
|
|
325
|
+
history=self._compressed,
|
|
326
|
+
recent_turns=list(self._turns),
|
|
327
|
+
)
|
|
328
|
+
to_compress = self._turns[: -self._max_recent_turns]
|
|
329
|
+
recent = self._turns[-self._max_recent_turns :]
|
|
330
|
+
self._turns = recent
|
|
331
|
+
|
|
332
|
+
compressed = self._compress_turns(to_compress)
|
|
333
|
+
|
|
334
|
+
if self._summarizer:
|
|
335
|
+
try:
|
|
336
|
+
llm_summary = await self._summarizer(compressed.summary)
|
|
337
|
+
compressed.summary = llm_summary
|
|
338
|
+
except Exception:
|
|
339
|
+
pass # fall back to audit-based summary
|
|
340
|
+
|
|
341
|
+
with self._lock:
|
|
342
|
+
if self._compressed:
|
|
343
|
+
compressed.summary = self._compressed.summary + "\n" + compressed.summary
|
|
344
|
+
max_chars = self._max_summary_tokens * 4
|
|
345
|
+
if len(compressed.summary) > max_chars:
|
|
346
|
+
compressed.summary = compressed.summary[: max_chars - 3] + "..."
|
|
347
|
+
self._compressed = compressed
|
|
348
|
+
|
|
349
|
+
return CompressedContext(
|
|
350
|
+
anchored=self.get_anchored_state(),
|
|
351
|
+
history=self._compressed,
|
|
352
|
+
recent_turns=list(recent),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def compress_sync(self) -> CompressedContext:
|
|
356
|
+
"""Synchronous version of compress(). Works when no summarizer is set."""
|
|
357
|
+
try:
|
|
358
|
+
loop = asyncio.get_event_loop()
|
|
359
|
+
if loop.is_running():
|
|
360
|
+
import concurrent.futures
|
|
361
|
+
|
|
362
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
363
|
+
future = pool.submit(asyncio.run, self.compress())
|
|
364
|
+
return future.result(timeout=30)
|
|
365
|
+
else:
|
|
366
|
+
return loop.run_until_complete(self.compress())
|
|
367
|
+
except RuntimeError:
|
|
368
|
+
return asyncio.run(self.compress())
|
|
369
|
+
|
|
370
|
+
def to_prompt(self) -> str:
|
|
371
|
+
"""Render the current context as a prompt string.
|
|
372
|
+
|
|
373
|
+
Does NOT trigger compression — just renders the current state.
|
|
374
|
+
Call compress() first if you want to compress older turns.
|
|
375
|
+
"""
|
|
376
|
+
anchored = self.get_anchored_state()
|
|
377
|
+
with self._lock:
|
|
378
|
+
compressed = self._compressed
|
|
379
|
+
turns = list(self._turns)
|
|
380
|
+
|
|
381
|
+
parts = [anchored.to_prompt()]
|
|
382
|
+
if compressed:
|
|
383
|
+
parts.append(compressed.to_prompt())
|
|
384
|
+
if turns:
|
|
385
|
+
lines = ["[RECENT CONVERSATION]"]
|
|
386
|
+
for turn in turns:
|
|
387
|
+
lines.append(f"[{turn.role}]: {turn.content}")
|
|
388
|
+
if turn.tool_calls:
|
|
389
|
+
for tc in turn.tool_calls:
|
|
390
|
+
name = tc.get("tool_name", "unknown")
|
|
391
|
+
params = tc.get("params", {})
|
|
392
|
+
result = tc.get("result_summary", "")
|
|
393
|
+
param_str = ", ".join(f"{k}={repr(v)}" for k, v in params.items())
|
|
394
|
+
line = f" -> {name}({param_str})"
|
|
395
|
+
if result:
|
|
396
|
+
line += f" = {result}"
|
|
397
|
+
lines.append(line)
|
|
398
|
+
parts.append("\n".join(lines))
|
|
399
|
+
return "\n\n".join(parts)
|
|
400
|
+
|
|
401
|
+
def token_estimate(self) -> int:
|
|
402
|
+
"""Rough token estimate for the current context."""
|
|
403
|
+
return len(self.to_prompt()) // 4
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def turn_count(self) -> int:
|
|
407
|
+
"""Total turns including compressed."""
|
|
408
|
+
with self._lock:
|
|
409
|
+
compressed_count = self._compressed.turn_count if self._compressed else 0
|
|
410
|
+
return compressed_count + len(self._turns)
|
agenthandler/errors.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""AgentHandler error types — every error has a user_message()."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AgentHandlerError(Exception):
|
|
5
|
+
"""Base error for all agenthandler supervision failures."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, kind: str, message: str, **details: object) -> None:
|
|
8
|
+
self.kind = kind
|
|
9
|
+
self.details = details
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
|
|
12
|
+
def user_message(self) -> str:
|
|
13
|
+
return str(self)
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def timeout(timeout_ms: int) -> "AgentHandlerError":
|
|
17
|
+
return AgentHandlerError(
|
|
18
|
+
"timeout",
|
|
19
|
+
f"Tool call timed out after {timeout_ms}ms",
|
|
20
|
+
timeout_ms=timeout_ms,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def budget_exceeded(used: int, limit: int) -> "AgentHandlerError":
|
|
25
|
+
return AgentHandlerError(
|
|
26
|
+
"budget_exceeded",
|
|
27
|
+
f"Token budget exceeded: {used} / {limit}",
|
|
28
|
+
used=used,
|
|
29
|
+
limit=limit,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def max_iterations(iterations: int, max_iter: int) -> "AgentHandlerError":
|
|
34
|
+
return AgentHandlerError(
|
|
35
|
+
"max_iterations",
|
|
36
|
+
f"Maximum iterations reached: {iterations} / {max_iter}",
|
|
37
|
+
iterations=iterations,
|
|
38
|
+
max=max_iter,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def circuit_open(tool: str, consecutive_failures: int) -> "AgentHandlerError":
|
|
43
|
+
return AgentHandlerError(
|
|
44
|
+
"circuit_open",
|
|
45
|
+
f"Circuit breaker open for '{tool}': {consecutive_failures} consecutive failures",
|
|
46
|
+
tool=tool,
|
|
47
|
+
consecutive_failures=consecutive_failures,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def tool_error(message: str) -> "AgentHandlerError":
|
|
52
|
+
return AgentHandlerError("tool_error", message)
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def dead_man_switch(silence_ms: int) -> "AgentHandlerError":
|
|
56
|
+
return AgentHandlerError(
|
|
57
|
+
"dead_man_switch",
|
|
58
|
+
f"Dead man's switch: no activity for {silence_ms}ms",
|
|
59
|
+
silence_ms=silence_ms,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def policy_denied(reason: str) -> "AgentHandlerError":
|
|
64
|
+
return AgentHandlerError("policy_denied", f"Policy denied: {reason}", reason=reason)
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def scope_denied(tool: str, detail: str) -> "AgentHandlerError":
|
|
68
|
+
return AgentHandlerError(
|
|
69
|
+
"scope_denied",
|
|
70
|
+
f"Scope violation for '{tool}': {detail}",
|
|
71
|
+
tool=tool,
|
|
72
|
+
detail=detail,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def rate_limited(tool: str, limit: int) -> "AgentHandlerError":
|
|
77
|
+
return AgentHandlerError(
|
|
78
|
+
"rate_limited",
|
|
79
|
+
f"Rate limit exceeded for '{tool}': max {limit} calls/minute",
|
|
80
|
+
tool=tool,
|
|
81
|
+
limit=limit,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def approval_pending(tool_name: str, approval_id: str) -> "AgentHandlerError":
|
|
86
|
+
return AgentHandlerError(
|
|
87
|
+
"approval_pending",
|
|
88
|
+
f"Tool '{tool_name}' requires approval (id: {approval_id})",
|
|
89
|
+
tool_name=tool_name,
|
|
90
|
+
approval_id=approval_id,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def approval_denied(tool_name: str, reason: str = "") -> "AgentHandlerError":
|
|
95
|
+
msg = f"Tool '{tool_name}' was denied"
|
|
96
|
+
if reason:
|
|
97
|
+
msg += f": {reason}"
|
|
98
|
+
return AgentHandlerError(
|
|
99
|
+
"approval_denied",
|
|
100
|
+
msg,
|
|
101
|
+
tool_name=tool_name,
|
|
102
|
+
reason=reason,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def agent_paused(session_id: str) -> "AgentHandlerError":
|
|
107
|
+
return AgentHandlerError(
|
|
108
|
+
"agent_paused",
|
|
109
|
+
f"Agent is paused (session {session_id})",
|
|
110
|
+
session_id=session_id,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def session_not_found(session_id: str) -> "AgentHandlerError":
|
|
115
|
+
return AgentHandlerError(
|
|
116
|
+
"session_not_found",
|
|
117
|
+
f"Session not found: {session_id}",
|
|
118
|
+
session_id=session_id,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def max_resumes_exceeded(session_id: str, resume_count: int, limit: int) -> "AgentHandlerError":
|
|
123
|
+
return AgentHandlerError(
|
|
124
|
+
"max_resumes_exceeded",
|
|
125
|
+
f"Session {session_id} has been resumed {resume_count} times "
|
|
126
|
+
f"(limit: {limit}). Possible crash loop — session marked as failed.",
|
|
127
|
+
session_id=session_id,
|
|
128
|
+
resume_count=resume_count,
|
|
129
|
+
limit=limit,
|
|
130
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""AgentHandler guardrails — deterministic and LLM-backed safety checks.
|
|
2
|
+
|
|
3
|
+
Guardrails hook into Supervisor.call() as pre-call and post-call checks.
|
|
4
|
+
Pre-call checks can block a tool from executing. Post-call checks can
|
|
5
|
+
block the result from reaching the agent.
|
|
6
|
+
|
|
7
|
+
Two flavors:
|
|
8
|
+
- Deterministic: rule-based, fast, zero-overhead when disabled
|
|
9
|
+
- Non-deterministic: LLM-backed, bring-your-own-LLM, opt-in
|
|
10
|
+
|
|
11
|
+
Deterministic:
|
|
12
|
+
- SchemaValidator — enforce JSON schema on tool outputs
|
|
13
|
+
- CategoryRateLimit — rate limit across tool categories
|
|
14
|
+
- RecursionDetector — detect tool call loops
|
|
15
|
+
- IdempotencyGuard — dedupe retry calls
|
|
16
|
+
- ResourceQuota — CPU/memory/time caps
|
|
17
|
+
- OutputLengthLimit — block oversized outputs
|
|
18
|
+
- UrlGuard — allowlist/blocklist URLs
|
|
19
|
+
- ActiveHours — time-of-day restrictions
|
|
20
|
+
|
|
21
|
+
Non-deterministic (bring-your-own-LLM):
|
|
22
|
+
- PromptInjectionDetector — catch injection attempts
|
|
23
|
+
- SafetyClassifier — toxic content / bias
|
|
24
|
+
- IntentDriftDetector — off-task detection
|
|
25
|
+
- SemanticPiiRedactor — NLP-based PII
|
|
26
|
+
- AnomalyScorer — LLM anomaly scoring
|
|
27
|
+
- GoalVerifier — "is this on-task?"
|
|
28
|
+
- ExplanationRequired — force justification
|
|
29
|
+
- EnsembleDecision — multi-LLM agreement
|
|
30
|
+
|
|
31
|
+
All guardrails return a GuardrailResult — (allowed, reason) — so the
|
|
32
|
+
Supervisor can uniformly block or allow.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from .deterministic import (
|
|
36
|
+
ActiveHours,
|
|
37
|
+
CategoryRateLimit,
|
|
38
|
+
IdempotencyGuard,
|
|
39
|
+
OutputLengthLimit,
|
|
40
|
+
RecursionDetector,
|
|
41
|
+
ResourceQuota,
|
|
42
|
+
SchemaValidator,
|
|
43
|
+
UrlGuard,
|
|
44
|
+
)
|
|
45
|
+
from .llm_based import (
|
|
46
|
+
AnomalyScorer,
|
|
47
|
+
EnsembleDecision,
|
|
48
|
+
ExplanationRequired,
|
|
49
|
+
GoalVerifier,
|
|
50
|
+
IntentDriftDetector,
|
|
51
|
+
PromptInjectionDetector,
|
|
52
|
+
SafetyClassifier,
|
|
53
|
+
SemanticPiiRedactor,
|
|
54
|
+
)
|
|
55
|
+
from .types import GuardrailResult, PostCallGuardrail, PreCallGuardrail
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"GuardrailResult",
|
|
59
|
+
"PreCallGuardrail",
|
|
60
|
+
"PostCallGuardrail",
|
|
61
|
+
# Deterministic
|
|
62
|
+
"SchemaValidator",
|
|
63
|
+
"CategoryRateLimit",
|
|
64
|
+
"RecursionDetector",
|
|
65
|
+
"IdempotencyGuard",
|
|
66
|
+
"ResourceQuota",
|
|
67
|
+
"OutputLengthLimit",
|
|
68
|
+
"UrlGuard",
|
|
69
|
+
"ActiveHours",
|
|
70
|
+
# LLM-based
|
|
71
|
+
"PromptInjectionDetector",
|
|
72
|
+
"SafetyClassifier",
|
|
73
|
+
"IntentDriftDetector",
|
|
74
|
+
"SemanticPiiRedactor",
|
|
75
|
+
"AnomalyScorer",
|
|
76
|
+
"GoalVerifier",
|
|
77
|
+
"ExplanationRequired",
|
|
78
|
+
"EnsembleDecision",
|
|
79
|
+
]
|