agent-tool-resilience 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_tool_resilience/__init__.py +32 -0
- agent_tool_resilience/circuit_breaker.py +246 -0
- agent_tool_resilience/fallback.py +188 -0
- agent_tool_resilience/rate_limit.py +261 -0
- agent_tool_resilience/resilient_tool.py +393 -0
- agent_tool_resilience/retry.py +215 -0
- agent_tool_resilience/tracer.py +319 -0
- agent_tool_resilience/validator.py +217 -0
- agent_tool_resilience-0.1.0.dist-info/METADATA +184 -0
- agent_tool_resilience-0.1.0.dist-info/RECORD +12 -0
- agent_tool_resilience-0.1.0.dist-info/WHEEL +5 -0
- agent_tool_resilience-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Result validation for tool outputs.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Callable, Optional, Sequence, Union
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ValidationError(Exception):
|
|
11
|
+
"""Raised when tool output fails validation."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, result: Any, errors: list[str]):
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.result = result
|
|
16
|
+
self.errors = errors
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ResultValidator:
|
|
21
|
+
"""
|
|
22
|
+
Validates tool outputs against schemas and conditions.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
schema: JSON Schema dict for validation (requires jsonschema package)
|
|
26
|
+
conditions: List of predicate functions that must all return True
|
|
27
|
+
required_keys: Keys that must be present in dict results
|
|
28
|
+
type_check: Expected type for the result
|
|
29
|
+
on_invalid: Callback when validation fails
|
|
30
|
+
"""
|
|
31
|
+
schema: Optional[dict] = None
|
|
32
|
+
conditions: Sequence[Callable[[Any], bool]] = field(default_factory=list)
|
|
33
|
+
required_keys: Sequence[str] = field(default_factory=list)
|
|
34
|
+
type_check: Optional[type] = None
|
|
35
|
+
on_invalid: Optional[Callable[[Any, list[str]], None]] = None
|
|
36
|
+
|
|
37
|
+
def validate(self, result: Any) -> Any:
|
|
38
|
+
"""
|
|
39
|
+
Validate a result, raising ValidationError if invalid.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
result: The result to validate
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The result if valid
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
ValidationError: If validation fails
|
|
49
|
+
"""
|
|
50
|
+
errors: list[str] = []
|
|
51
|
+
|
|
52
|
+
# Type check
|
|
53
|
+
if self.type_check is not None:
|
|
54
|
+
if not isinstance(result, self.type_check):
|
|
55
|
+
errors.append(
|
|
56
|
+
f"Expected type {self.type_check.__name__}, "
|
|
57
|
+
f"got {type(result).__name__}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Required keys check
|
|
61
|
+
if self.required_keys and isinstance(result, dict):
|
|
62
|
+
missing = [k for k in self.required_keys if k not in result]
|
|
63
|
+
if missing:
|
|
64
|
+
errors.append(f"Missing required keys: {missing}")
|
|
65
|
+
|
|
66
|
+
# JSON Schema validation
|
|
67
|
+
if self.schema is not None:
|
|
68
|
+
schema_errors = self._validate_schema(result)
|
|
69
|
+
errors.extend(schema_errors)
|
|
70
|
+
|
|
71
|
+
# Custom condition checks
|
|
72
|
+
for i, condition in enumerate(self.conditions):
|
|
73
|
+
try:
|
|
74
|
+
if not condition(result):
|
|
75
|
+
errors.append(f"Condition {i} failed")
|
|
76
|
+
except Exception as e:
|
|
77
|
+
errors.append(f"Condition {i} raised exception: {e}")
|
|
78
|
+
|
|
79
|
+
if errors:
|
|
80
|
+
if self.on_invalid:
|
|
81
|
+
self.on_invalid(result, errors)
|
|
82
|
+
raise ValidationError(
|
|
83
|
+
f"Validation failed with {len(errors)} error(s)",
|
|
84
|
+
result=result,
|
|
85
|
+
errors=errors
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
def _validate_schema(self, result: Any) -> list[str]:
|
|
91
|
+
"""Validate against JSON Schema."""
|
|
92
|
+
errors = []
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
import jsonschema
|
|
96
|
+
|
|
97
|
+
validator = jsonschema.Draft7Validator(self.schema)
|
|
98
|
+
for error in validator.iter_errors(result):
|
|
99
|
+
path = ".".join(str(p) for p in error.path) or "root"
|
|
100
|
+
errors.append(f"Schema error at {path}: {error.message}")
|
|
101
|
+
except ImportError:
|
|
102
|
+
# Fall back to basic validation if jsonschema not installed
|
|
103
|
+
errors.extend(self._basic_schema_validate(result, self.schema))
|
|
104
|
+
|
|
105
|
+
return errors
|
|
106
|
+
|
|
107
|
+
def _basic_schema_validate(
|
|
108
|
+
self,
|
|
109
|
+
result: Any,
|
|
110
|
+
schema: dict,
|
|
111
|
+
path: str = "root"
|
|
112
|
+
) -> list[str]:
|
|
113
|
+
"""Basic schema validation without jsonschema package."""
|
|
114
|
+
errors = []
|
|
115
|
+
|
|
116
|
+
# Type validation
|
|
117
|
+
schema_type = schema.get("type")
|
|
118
|
+
if schema_type:
|
|
119
|
+
type_map = {
|
|
120
|
+
"string": str,
|
|
121
|
+
"number": (int, float),
|
|
122
|
+
"integer": int,
|
|
123
|
+
"boolean": bool,
|
|
124
|
+
"array": list,
|
|
125
|
+
"object": dict,
|
|
126
|
+
"null": type(None),
|
|
127
|
+
}
|
|
128
|
+
expected = type_map.get(schema_type)
|
|
129
|
+
if expected and not isinstance(result, expected):
|
|
130
|
+
errors.append(
|
|
131
|
+
f"Schema error at {path}: expected {schema_type}, "
|
|
132
|
+
f"got {type(result).__name__}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Required fields for objects
|
|
136
|
+
if isinstance(result, dict):
|
|
137
|
+
required = schema.get("required", [])
|
|
138
|
+
for key in required:
|
|
139
|
+
if key not in result:
|
|
140
|
+
errors.append(
|
|
141
|
+
f"Schema error at {path}: missing required key '{key}'"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Validate properties
|
|
145
|
+
properties = schema.get("properties", {})
|
|
146
|
+
for key, prop_schema in properties.items():
|
|
147
|
+
if key in result:
|
|
148
|
+
errors.extend(
|
|
149
|
+
self._basic_schema_validate(
|
|
150
|
+
result[key],
|
|
151
|
+
prop_schema,
|
|
152
|
+
f"{path}.{key}"
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Validate array items
|
|
157
|
+
if isinstance(result, list):
|
|
158
|
+
items_schema = schema.get("items")
|
|
159
|
+
if items_schema:
|
|
160
|
+
for i, item in enumerate(result):
|
|
161
|
+
errors.extend(
|
|
162
|
+
self._basic_schema_validate(
|
|
163
|
+
item,
|
|
164
|
+
items_schema,
|
|
165
|
+
f"{path}[{i}]"
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return errors
|
|
170
|
+
|
|
171
|
+
def is_valid(self, result: Any) -> bool:
|
|
172
|
+
"""Check if a result is valid without raising an exception."""
|
|
173
|
+
try:
|
|
174
|
+
self.validate(result)
|
|
175
|
+
return True
|
|
176
|
+
except ValidationError:
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# Common validators
|
|
181
|
+
def not_none() -> ResultValidator:
|
|
182
|
+
"""Validate that result is not None."""
|
|
183
|
+
return ResultValidator(
|
|
184
|
+
conditions=[lambda r: r is not None]
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def not_empty() -> ResultValidator:
|
|
189
|
+
"""Validate that result is not empty (for strings, lists, dicts)."""
|
|
190
|
+
return ResultValidator(
|
|
191
|
+
conditions=[lambda r: bool(r)]
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def has_keys(*keys: str) -> ResultValidator:
|
|
196
|
+
"""Validate that result dict has specific keys."""
|
|
197
|
+
return ResultValidator(required_keys=list(keys))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def matches_type(expected_type: type) -> ResultValidator:
|
|
201
|
+
"""Validate that result is of a specific type."""
|
|
202
|
+
return ResultValidator(type_check=expected_type)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def in_range(
|
|
206
|
+
min_val: Optional[Union[int, float]] = None,
|
|
207
|
+
max_val: Optional[Union[int, float]] = None
|
|
208
|
+
) -> ResultValidator:
|
|
209
|
+
"""Validate that numeric result is within a range."""
|
|
210
|
+
conditions = []
|
|
211
|
+
|
|
212
|
+
if min_val is not None:
|
|
213
|
+
conditions.append(lambda r: r >= min_val)
|
|
214
|
+
if max_val is not None:
|
|
215
|
+
conditions.append(lambda r: r <= max_val)
|
|
216
|
+
|
|
217
|
+
return ResultValidator(conditions=conditions)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-tool-resilience
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Production-grade resilience for AI agent tool calls: retries, fallbacks, circuit breakers, and validation
|
|
5
|
+
Author-email: Korah Stone <korahcomm@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/KorahStone/agent-tool-resilience
|
|
8
|
+
Project-URL: Documentation, https://github.com/KorahStone/agent-tool-resilience#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/KorahStone/agent-tool-resilience.git
|
|
10
|
+
Project-URL: Issues, https://github.com/KorahStone/agent-tool-resilience/issues
|
|
11
|
+
Keywords: ai,agents,llm,resilience,retry,circuit-breaker,fallback,tools,langchain,openai
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Provides-Extra: jsonschema
|
|
25
|
+
Requires-Dist: jsonschema>=4.0.0; extra == "jsonschema"
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
30
|
+
Provides-Extra: all
|
|
31
|
+
Requires-Dist: agent-tool-resilience[dev,jsonschema]; extra == "all"
|
|
32
|
+
|
|
33
|
+
# agent-tool-resilience
|
|
34
|
+
|
|
35
|
+
A Python library for making AI agent tool calls resilient, with smart retries, fallbacks, circuit breakers, and result validation.
|
|
36
|
+
|
|
37
|
+
## Why?
|
|
38
|
+
|
|
39
|
+
AI agents fail silently when tools break. This library provides production-grade resilience patterns:
|
|
40
|
+
|
|
41
|
+
- **Smart Retries**: Exponential backoff with jitter, respects rate limits
|
|
42
|
+
- **Fallbacks**: Graceful degradation when primary tools fail
|
|
43
|
+
- **Circuit Breakers**: Prevent cascade failures by stopping calls to broken services
|
|
44
|
+
- **Result Validation**: Ensure tool outputs meet expected schemas/conditions
|
|
45
|
+
- **Observability**: Full visibility into what happened during tool execution
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install agent-tool-resilience
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Quick Start
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from agent_tool_resilience import ResilientTool, RetryPolicy, CircuitBreaker
|
|
57
|
+
|
|
58
|
+
# Wrap any tool with resilience
|
|
59
|
+
@ResilientTool(
|
|
60
|
+
retry=RetryPolicy(max_attempts=3, backoff="exponential"),
|
|
61
|
+
circuit_breaker=CircuitBreaker(failure_threshold=5, reset_timeout=60),
|
|
62
|
+
fallback=lambda *args, **kwargs: {"error": "service unavailable", "cached": True}
|
|
63
|
+
)
|
|
64
|
+
def call_weather_api(location: str) -> dict:
|
|
65
|
+
return requests.get(f"https://api.weather.com/{location}").json()
|
|
66
|
+
|
|
67
|
+
# Use it normally - resilience is automatic
|
|
68
|
+
result = call_weather_api("NYC")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Features
|
|
72
|
+
|
|
73
|
+
### Retry Policies
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from agent_tool_resilience import RetryPolicy
|
|
77
|
+
|
|
78
|
+
# Exponential backoff with jitter
|
|
79
|
+
policy = RetryPolicy(
|
|
80
|
+
max_attempts=5,
|
|
81
|
+
backoff="exponential",
|
|
82
|
+
base_delay=1.0,
|
|
83
|
+
max_delay=60.0,
|
|
84
|
+
jitter=True,
|
|
85
|
+
retry_on=[TimeoutError, ConnectionError, RateLimitError]
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Circuit Breakers
|
|
90
|
+
|
|
91
|
+
Prevent cascade failures by temporarily stopping calls to failing services:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from agent_tool_resilience import CircuitBreaker
|
|
95
|
+
|
|
96
|
+
breaker = CircuitBreaker(
|
|
97
|
+
failure_threshold=5, # Open after 5 failures
|
|
98
|
+
success_threshold=2, # Close after 2 successes
|
|
99
|
+
reset_timeout=60, # Try again after 60 seconds
|
|
100
|
+
half_open_max_calls=3 # Limited calls in half-open state
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Fallback Strategies
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from agent_tool_resilience import FallbackChain
|
|
108
|
+
|
|
109
|
+
fallbacks = FallbackChain([
|
|
110
|
+
lambda loc: call_backup_weather_api(loc),
|
|
111
|
+
lambda loc: get_cached_weather(loc),
|
|
112
|
+
lambda loc: {"status": "unavailable", "location": loc}
|
|
113
|
+
])
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Result Validation
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from agent_tool_resilience import ResultValidator
|
|
120
|
+
|
|
121
|
+
validator = ResultValidator(
|
|
122
|
+
schema={"type": "object", "required": ["temperature", "humidity"]},
|
|
123
|
+
conditions=[
|
|
124
|
+
lambda r: r.get("temperature") is not None,
|
|
125
|
+
lambda r: -100 < r.get("temperature", 0) < 150
|
|
126
|
+
]
|
|
127
|
+
)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Observability
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from agent_tool_resilience import ToolExecutionTracer
|
|
134
|
+
|
|
135
|
+
tracer = ToolExecutionTracer()
|
|
136
|
+
|
|
137
|
+
@ResilientTool(tracer=tracer)
|
|
138
|
+
def my_tool():
|
|
139
|
+
...
|
|
140
|
+
|
|
141
|
+
# After execution
|
|
142
|
+
print(tracer.get_execution_log())
|
|
143
|
+
# [
|
|
144
|
+
# {"tool": "my_tool", "attempt": 1, "status": "failed", "error": "timeout", "duration_ms": 5023},
|
|
145
|
+
# {"tool": "my_tool", "attempt": 2, "status": "success", "duration_ms": 234}
|
|
146
|
+
# ]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Rate Limit Awareness
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from agent_tool_resilience import RateLimitHandler
|
|
153
|
+
|
|
154
|
+
handler = RateLimitHandler(
|
|
155
|
+
requests_per_minute=60,
|
|
156
|
+
respect_retry_after=True,
|
|
157
|
+
auto_throttle=True
|
|
158
|
+
)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Integration with Agent Frameworks
|
|
162
|
+
|
|
163
|
+
Works with any Python agent framework:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
# LangChain
|
|
167
|
+
from langchain.tools import Tool
|
|
168
|
+
from agent_tool_resilience import ResilientTool
|
|
169
|
+
|
|
170
|
+
@ResilientTool(retry=RetryPolicy(max_attempts=3))
|
|
171
|
+
def search(query: str) -> str:
|
|
172
|
+
...
|
|
173
|
+
|
|
174
|
+
langchain_tool = Tool(name="search", func=search, description="Search the web")
|
|
175
|
+
|
|
176
|
+
# OpenAI Function Calling
|
|
177
|
+
@ResilientTool(circuit_breaker=CircuitBreaker(failure_threshold=3))
|
|
178
|
+
def get_stock_price(symbol: str) -> dict:
|
|
179
|
+
...
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
agent_tool_resilience/__init__.py,sha256=7rXh6-kxCQlZrFnAT26GquZ4_eLg7o6TSkGK_gLXeL4,950
|
|
2
|
+
agent_tool_resilience/circuit_breaker.py,sha256=ejC1L9qmfRpaB2AUnrJ2fvjip_llrkxYrdU3kyCROKs,8566
|
|
3
|
+
agent_tool_resilience/fallback.py,sha256=QSsYIwyS6UZOUzU2ISnWqydlx9Jp9YEdfrvFWzPurzw,5915
|
|
4
|
+
agent_tool_resilience/rate_limit.py,sha256=w5Ak05Of5CC2uKXr74IeP6CXYLkiOOsXcCxQxqKKVkc,8787
|
|
5
|
+
agent_tool_resilience/resilient_tool.py,sha256=bUTNMSoqViA8jqT9sjf8ttD8RtnA2fnFKPJtftyFREU,13773
|
|
6
|
+
agent_tool_resilience/retry.py,sha256=_b_KVmjWS5-p2UPhhj206Da1mZeN1vF3RkOge8DJbE0,6774
|
|
7
|
+
agent_tool_resilience/tracer.py,sha256=YRQi9naD66oRbrP8KxT7D-H82dslwH0XVoOQ59CsxFQ,10299
|
|
8
|
+
agent_tool_resilience/validator.py,sha256=-3SeWs5z89-v9QJ_O66u4pGBylRsIOrsXI94YB4Q3dU,7078
|
|
9
|
+
agent_tool_resilience-0.1.0.dist-info/METADATA,sha256=ntSPXS-hzw7t5ZLe8ua_w0_TXgaAiNlclDjQdojnokw,5344
|
|
10
|
+
agent_tool_resilience-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
11
|
+
agent_tool_resilience-0.1.0.dist-info/top_level.txt,sha256=2iwiEMf33mMYIhEGThg6xhl-k69BGa_8SKMSh95umOM,22
|
|
12
|
+
agent_tool_resilience-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agent_tool_resilience
|