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/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
@@ -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