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/budget.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Token and iteration budget tracking — thread-safe."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from .errors import AgentHandlerError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class BudgetSnapshot:
|
|
12
|
+
"""Point-in-time snapshot of budget usage."""
|
|
13
|
+
|
|
14
|
+
tokens_used: int
|
|
15
|
+
token_limit: Optional[int]
|
|
16
|
+
iterations: int
|
|
17
|
+
iteration_limit: int
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def tokens_remaining(self) -> Optional[int]:
|
|
21
|
+
if self.token_limit is None:
|
|
22
|
+
return None
|
|
23
|
+
return max(0, self.token_limit - self.tokens_used)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def iterations_remaining(self) -> int:
|
|
27
|
+
return max(0, self.iteration_limit - self.iterations)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def is_over_token_budget(self) -> bool:
|
|
31
|
+
if self.token_limit is None:
|
|
32
|
+
return False
|
|
33
|
+
return self.tokens_used >= self.token_limit
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def is_over_iteration_limit(self) -> bool:
|
|
37
|
+
return self.iterations >= self.iteration_limit
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BudgetTracker:
|
|
41
|
+
"""Thread-safe token and iteration budget tracker.
|
|
42
|
+
|
|
43
|
+
Usage:
|
|
44
|
+
tracker = BudgetTracker(token_limit=10000, iteration_limit=5)
|
|
45
|
+
tracker.record_tokens(500) # raises AgentHandlerError if over
|
|
46
|
+
tracker.record_iteration() # raises AgentHandlerError if over
|
|
47
|
+
snap = tracker.snapshot() # read current state
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, token_limit: Optional[int] = None, iteration_limit: int = 5):
|
|
51
|
+
self._token_limit = token_limit
|
|
52
|
+
self._iteration_limit = iteration_limit
|
|
53
|
+
self._tokens_used = 0
|
|
54
|
+
self._iterations = 0
|
|
55
|
+
self._lock = threading.Lock()
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_policy(cls, policy: Any) -> "BudgetTracker":
|
|
59
|
+
return cls(
|
|
60
|
+
token_limit=policy.token_budget,
|
|
61
|
+
iteration_limit=policy.max_iterations,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def record_tokens(self, tokens: int) -> int:
|
|
65
|
+
"""Record token usage. Returns new total. Raises if over budget."""
|
|
66
|
+
with self._lock:
|
|
67
|
+
self._tokens_used += tokens
|
|
68
|
+
if self._token_limit is not None and self._tokens_used > self._token_limit:
|
|
69
|
+
raise AgentHandlerError.budget_exceeded(self._tokens_used, self._token_limit)
|
|
70
|
+
return self._tokens_used
|
|
71
|
+
|
|
72
|
+
def record_iteration(self) -> int:
|
|
73
|
+
"""Record an iteration. Returns new count. Raises if over limit."""
|
|
74
|
+
with self._lock:
|
|
75
|
+
self._iterations += 1
|
|
76
|
+
if self._iterations > self._iteration_limit:
|
|
77
|
+
raise AgentHandlerError.max_iterations(self._iterations, self._iteration_limit)
|
|
78
|
+
return self._iterations
|
|
79
|
+
|
|
80
|
+
def snapshot(self) -> BudgetSnapshot:
|
|
81
|
+
"""Get a point-in-time snapshot."""
|
|
82
|
+
with self._lock:
|
|
83
|
+
return BudgetSnapshot(
|
|
84
|
+
tokens_used=self._tokens_used,
|
|
85
|
+
token_limit=self._token_limit,
|
|
86
|
+
iterations=self._iterations,
|
|
87
|
+
iteration_limit=self._iteration_limit,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def reset(self) -> None:
|
|
91
|
+
"""Reset all counters."""
|
|
92
|
+
with self._lock:
|
|
93
|
+
self._tokens_used = 0
|
|
94
|
+
self._iterations = 0
|
agenthandler/builder.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""SkillBuilder — generate skills from natural language descriptions.
|
|
2
|
+
|
|
3
|
+
Bring your own LLM. Pass any async callable that takes a prompt string
|
|
4
|
+
and returns a string. Works with OpenAI, Anthropic, local models, whatever.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from agenthandler import SkillBuilder, SkillStore
|
|
8
|
+
|
|
9
|
+
async def my_llm(prompt: str) -> str:
|
|
10
|
+
return await openai.chat(messages=[{"role": "user", "content": prompt}])
|
|
11
|
+
|
|
12
|
+
builder = SkillBuilder(my_llm)
|
|
13
|
+
skill = await builder.create("A skill that researches topics on the web")
|
|
14
|
+
|
|
15
|
+
store = SkillStore("~/.agenthandler/skills")
|
|
16
|
+
store.save(skill)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any, Callable, Coroutine, Dict, Optional
|
|
22
|
+
|
|
23
|
+
from .skill import Skill
|
|
24
|
+
|
|
25
|
+
# The prompt that teaches the LLM how to produce a valid skill definition.
|
|
26
|
+
# Kept as a module constant so users can inspect/override it.
|
|
27
|
+
SKILL_GENERATION_PROMPT = """\
|
|
28
|
+
You are a skill builder for AI agents. Given a natural language description of what \
|
|
29
|
+
a skill should do, produce a complete skill definition as JSON.
|
|
30
|
+
|
|
31
|
+
The JSON must have this structure (all fields shown — omit any that aren't relevant):
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"name": "snake_case_name",
|
|
36
|
+
"description": "One sentence describing what this skill does",
|
|
37
|
+
"triggers": ["keyword1", "keyword2"],
|
|
38
|
+
"version": "1.0",
|
|
39
|
+
"role": "You are a ... (persona for the agent)",
|
|
40
|
+
"task": "When the user asks ... (what the agent should do step by step)",
|
|
41
|
+
"instructions": [
|
|
42
|
+
"First instruction",
|
|
43
|
+
"Second instruction"
|
|
44
|
+
],
|
|
45
|
+
"examples": [
|
|
46
|
+
{"user": "Example user message", "assistant": "Example response"}
|
|
47
|
+
],
|
|
48
|
+
"output_format": "Optional template for response structure",
|
|
49
|
+
"tools": [
|
|
50
|
+
{
|
|
51
|
+
"name": "tool_name",
|
|
52
|
+
"description": "What the tool does",
|
|
53
|
+
"parameters": [
|
|
54
|
+
{"name": "param_name", "type": "string", "description": "...", "required": true}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
],
|
|
58
|
+
"supervision": {
|
|
59
|
+
"max_iterations": 5,
|
|
60
|
+
"tool_timeout": 30,
|
|
61
|
+
"token_budget": 10000,
|
|
62
|
+
"on_timeout": "return_error",
|
|
63
|
+
"on_budget_exceeded": "respond_with_best_effort",
|
|
64
|
+
"require_confirm": ["dangerous_tool_name"]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Rules:
|
|
70
|
+
- name must be snake_case, short, descriptive
|
|
71
|
+
- triggers are keywords/phrases that should activate this skill
|
|
72
|
+
- tools: only include tools the skill actually needs. If no tools needed, omit the field
|
|
73
|
+
- supervision: always include sensible limits. Think about what could go wrong:
|
|
74
|
+
- How many iterations could this reasonably need?
|
|
75
|
+
- How long should each tool call take?
|
|
76
|
+
- What's a reasonable token budget?
|
|
77
|
+
- Which tools are dangerous enough to need confirmation?
|
|
78
|
+
- instructions: be specific and actionable. Include safety rules
|
|
79
|
+
- on_timeout options: "return_error", "respond_with_best_effort", "kill"
|
|
80
|
+
- on_budget_exceeded options: same as above
|
|
81
|
+
- require_confirm: list tool names that should need human approval before running
|
|
82
|
+
|
|
83
|
+
Respond with ONLY the JSON object. No markdown fences, no explanation."""
|
|
84
|
+
|
|
85
|
+
SKILL_REFINEMENT_PROMPT = """\
|
|
86
|
+
You are refining an existing skill definition based on user feedback.
|
|
87
|
+
|
|
88
|
+
Current skill definition:
|
|
89
|
+
```json
|
|
90
|
+
{current_skill}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The user wants the following changes:
|
|
94
|
+
{feedback}
|
|
95
|
+
|
|
96
|
+
Apply the requested changes and return the complete updated skill definition as JSON.
|
|
97
|
+
Follow the same schema as the original. Respond with ONLY the JSON object."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _extract_json(text: str) -> Dict[str, Any]:
|
|
101
|
+
"""Extract a JSON object from LLM output, handling markdown fences."""
|
|
102
|
+
# Strip markdown code fences if present
|
|
103
|
+
text = text.strip()
|
|
104
|
+
text = re.sub(r"^```(?:json)?\s*\n?", "", text)
|
|
105
|
+
text = re.sub(r"\n?```\s*$", "", text)
|
|
106
|
+
result: Dict[str, Any] = json.loads(text.strip())
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class SkillBuilder:
|
|
111
|
+
"""Build skills from natural language using any LLM.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
llm: An async callable that takes a prompt string and returns a string.
|
|
115
|
+
This is your LLM — OpenAI, Anthropic, local model, whatever.
|
|
116
|
+
system_prompt: Override the default generation prompt if you want.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
llm: Callable[[str], Coroutine[Any, Any, str]],
|
|
122
|
+
system_prompt: Optional[str] = None,
|
|
123
|
+
):
|
|
124
|
+
self._llm = llm
|
|
125
|
+
self._system_prompt = system_prompt or SKILL_GENERATION_PROMPT
|
|
126
|
+
|
|
127
|
+
async def create(self, description: str) -> Skill:
|
|
128
|
+
"""Generate a skill from a natural language description.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
description: What you want the skill to do, in plain English.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
A Skill object ready to save, inspect, or export.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
ValueError: If the LLM output can't be parsed as a valid skill.
|
|
138
|
+
"""
|
|
139
|
+
prompt = f"{self._system_prompt}\n\nUser's description:\n{description}"
|
|
140
|
+
raw = await self._llm(prompt)
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
data = _extract_json(raw)
|
|
144
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
145
|
+
raise ValueError(f"LLM returned invalid JSON. Raw output:\n{raw[:500]}") from e
|
|
146
|
+
|
|
147
|
+
if "name" not in data:
|
|
148
|
+
raise ValueError("LLM output missing required 'name' field")
|
|
149
|
+
|
|
150
|
+
return Skill.from_dict(data)
|
|
151
|
+
|
|
152
|
+
async def refine(self, skill: Skill, feedback: str) -> Skill:
|
|
153
|
+
"""Refine an existing skill based on natural language feedback.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
skill: The current skill to modify.
|
|
157
|
+
feedback: What to change, in plain English.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
An updated Skill object.
|
|
161
|
+
"""
|
|
162
|
+
prompt = SKILL_REFINEMENT_PROMPT.format(
|
|
163
|
+
current_skill=skill.to_json(),
|
|
164
|
+
feedback=feedback,
|
|
165
|
+
)
|
|
166
|
+
raw = await self._llm(prompt)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
data = _extract_json(raw)
|
|
170
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"LLM returned invalid JSON during refinement. Raw output:\n{raw[:500]}"
|
|
173
|
+
) from e
|
|
174
|
+
|
|
175
|
+
if "name" not in data:
|
|
176
|
+
raise ValueError("LLM output missing required 'name' field")
|
|
177
|
+
|
|
178
|
+
return Skill.from_dict(data)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Circuit breaker — stops calling tools that keep failing."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .errors import AgentHandlerError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CircuitState(Enum):
|
|
12
|
+
CLOSED = "closed" # Normal operation
|
|
13
|
+
OPEN = "open" # Blocking calls (too many failures)
|
|
14
|
+
HALF_OPEN = "half_open" # Allowing one test call
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CircuitBreaker:
|
|
18
|
+
"""Per-tool circuit breaker. Thread-safe.
|
|
19
|
+
|
|
20
|
+
States:
|
|
21
|
+
CLOSED → failures < threshold, calls pass through
|
|
22
|
+
OPEN → failures >= threshold, calls blocked until reset_after
|
|
23
|
+
HALF_OPEN → reset_after elapsed, allow one test call
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
cb = CircuitBreaker(threshold=3, reset_after=60.0)
|
|
27
|
+
cb.check("web_search") # raises if circuit open
|
|
28
|
+
try:
|
|
29
|
+
result = call_tool()
|
|
30
|
+
cb.record_success()
|
|
31
|
+
except:
|
|
32
|
+
cb.record_failure()
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, threshold: int = 5, reset_after: float = 60.0):
|
|
36
|
+
self._threshold = threshold
|
|
37
|
+
self._reset_after = reset_after
|
|
38
|
+
self._consecutive_failures = 0
|
|
39
|
+
self._state = CircuitState.CLOSED
|
|
40
|
+
self._opened_at: Optional[float] = None
|
|
41
|
+
self._lock = threading.Lock()
|
|
42
|
+
|
|
43
|
+
def check(self, tool_name: str) -> None:
|
|
44
|
+
"""Check if the circuit allows a call. Raises AgentHandlerError if open."""
|
|
45
|
+
with self._lock:
|
|
46
|
+
if self._state == CircuitState.CLOSED:
|
|
47
|
+
return
|
|
48
|
+
if self._state == CircuitState.OPEN:
|
|
49
|
+
# Check if enough time has passed to try again
|
|
50
|
+
if self._opened_at and (time.monotonic() - self._opened_at) >= self._reset_after:
|
|
51
|
+
self._state = CircuitState.HALF_OPEN
|
|
52
|
+
return
|
|
53
|
+
raise AgentHandlerError.circuit_open(tool_name, self._consecutive_failures)
|
|
54
|
+
# HALF_OPEN: allow one test call
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
def record_success(self) -> None:
|
|
58
|
+
"""Record a successful call. Resets the breaker."""
|
|
59
|
+
with self._lock:
|
|
60
|
+
self._consecutive_failures = 0
|
|
61
|
+
self._state = CircuitState.CLOSED
|
|
62
|
+
self._opened_at = None
|
|
63
|
+
|
|
64
|
+
def record_failure(self) -> None:
|
|
65
|
+
"""Record a failed call. May trip the breaker."""
|
|
66
|
+
with self._lock:
|
|
67
|
+
self._consecutive_failures += 1
|
|
68
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
69
|
+
# Test call failed, reopen
|
|
70
|
+
self._state = CircuitState.OPEN
|
|
71
|
+
self._opened_at = time.monotonic()
|
|
72
|
+
elif self._consecutive_failures >= self._threshold:
|
|
73
|
+
self._state = CircuitState.OPEN
|
|
74
|
+
self._opened_at = time.monotonic()
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def state(self) -> CircuitState:
|
|
78
|
+
with self._lock:
|
|
79
|
+
# Check for auto-transition to half-open
|
|
80
|
+
if self._state == CircuitState.OPEN and self._opened_at:
|
|
81
|
+
if (time.monotonic() - self._opened_at) >= self._reset_after:
|
|
82
|
+
self._state = CircuitState.HALF_OPEN
|
|
83
|
+
return self._state
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def consecutive_failures(self) -> int:
|
|
87
|
+
with self._lock:
|
|
88
|
+
return self._consecutive_failures
|