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.

@@ -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
+ ]