agentic-exception-sdk 1.1.3__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.
- agentic_exception_sdk/__init__.py +308 -0
- agentic_exception_sdk/__init__.pyi +38 -0
- agentic_exception_sdk/budget/__init__.py +14 -0
- agentic_exception_sdk/budget/__init__.pyi +5 -0
- agentic_exception_sdk/budget/models.py +48 -0
- agentic_exception_sdk/budget/models.pyi +14 -0
- agentic_exception_sdk/budget/streaming.py +144 -0
- agentic_exception_sdk/budget/streaming.pyi +27 -0
- agentic_exception_sdk/budget/watchdog.py +285 -0
- agentic_exception_sdk/budget/watchdog.pyi +24 -0
- agentic_exception_sdk/bundle.py +102 -0
- agentic_exception_sdk/bundle.pyi +37 -0
- agentic_exception_sdk/config/__init__.py +10 -0
- agentic_exception_sdk/config/__init__.pyi +3 -0
- agentic_exception_sdk/config/validate.py +74 -0
- agentic_exception_sdk/config/validate.pyi +11 -0
- agentic_exception_sdk/escalation/__init__.py +22 -0
- agentic_exception_sdk/escalation/__init__.pyi +5 -0
- agentic_exception_sdk/escalation/checkpoint.py +116 -0
- agentic_exception_sdk/escalation/checkpoint.pyi +13 -0
- agentic_exception_sdk/escalation/handlers.py +27 -0
- agentic_exception_sdk/escalation/handlers.pyi +7 -0
- agentic_exception_sdk/escalation/router.py +137 -0
- agentic_exception_sdk/escalation/router.pyi +22 -0
- agentic_exception_sdk/multi_agent/__init__.py +32 -0
- agentic_exception_sdk/multi_agent/__init__.pyi +6 -0
- agentic_exception_sdk/multi_agent/consensus.py +139 -0
- agentic_exception_sdk/multi_agent/consensus.pyi +18 -0
- agentic_exception_sdk/multi_agent/parallel.py +204 -0
- agentic_exception_sdk/multi_agent/parallel.pyi +35 -0
- agentic_exception_sdk/multi_agent/router.py +131 -0
- agentic_exception_sdk/multi_agent/router.pyi +14 -0
- agentic_exception_sdk/multi_agent/sla.py +63 -0
- agentic_exception_sdk/multi_agent/sla.pyi +10 -0
- agentic_exception_sdk/noop.py +27 -0
- agentic_exception_sdk/noop.pyi +5 -0
- agentic_exception_sdk/observability/__init__.py +24 -0
- agentic_exception_sdk/observability/__init__.pyi +6 -0
- agentic_exception_sdk/observability/metrics.py +189 -0
- agentic_exception_sdk/observability/metrics.pyi +36 -0
- agentic_exception_sdk/observability/otel.py +211 -0
- agentic_exception_sdk/observability/otel.pyi +9 -0
- agentic_exception_sdk/observability/sink.py +68 -0
- agentic_exception_sdk/observability/sink.pyi +12 -0
- agentic_exception_sdk/observability/structured_log.py +110 -0
- agentic_exception_sdk/observability/structured_log.pyi +8 -0
- agentic_exception_sdk/persistence/__init__.py +27 -0
- agentic_exception_sdk/persistence/__init__.pyi +35 -0
- agentic_exception_sdk/persistence/attestation.py +36 -0
- agentic_exception_sdk/persistence/attestation.pyi +8 -0
- agentic_exception_sdk/persistence/availability.py +14 -0
- agentic_exception_sdk/persistence/availability.pyi +7 -0
- agentic_exception_sdk/persistence/null_provider.py +71 -0
- agentic_exception_sdk/persistence/null_provider.pyi +19 -0
- agentic_exception_sdk/persistence/provider.py +70 -0
- agentic_exception_sdk/persistence/provider.pyi +33 -0
- agentic_exception_sdk/propagation/__init__.py +39 -0
- agentic_exception_sdk/propagation/__init__.pyi +5 -0
- agentic_exception_sdk/propagation/bus.py +162 -0
- agentic_exception_sdk/propagation/bus.pyi +30 -0
- agentic_exception_sdk/propagation/dlq.py +173 -0
- agentic_exception_sdk/propagation/dlq.pyi +28 -0
- agentic_exception_sdk/propagation/protocol.py +129 -0
- agentic_exception_sdk/propagation/protocol.pyi +10 -0
- agentic_exception_sdk/py.typed +0 -0
- agentic_exception_sdk/resilience/__init__.py +59 -0
- agentic_exception_sdk/resilience/__init__.pyi +7 -0
- agentic_exception_sdk/resilience/circuit_breaker.py +650 -0
- agentic_exception_sdk/resilience/circuit_breaker.pyi +58 -0
- agentic_exception_sdk/resilience/compensating.py +97 -0
- agentic_exception_sdk/resilience/compensating.pyi +9 -0
- agentic_exception_sdk/resilience/fallback.py +88 -0
- agentic_exception_sdk/resilience/fallback.pyi +16 -0
- agentic_exception_sdk/resilience/recovery.py +50 -0
- agentic_exception_sdk/resilience/recovery.pyi +11 -0
- agentic_exception_sdk/resilience/retry.py +455 -0
- agentic_exception_sdk/resilience/retry.pyi +62 -0
- agentic_exception_sdk/resilience/wrap.py +1073 -0
- agentic_exception_sdk/resilience/wrap.pyi +28 -0
- agentic_exception_sdk/taxonomy/__init__.py +56 -0
- agentic_exception_sdk/taxonomy/__init__.pyi +7 -0
- agentic_exception_sdk/taxonomy/classifier.py +216 -0
- agentic_exception_sdk/taxonomy/classifier.pyi +8 -0
- agentic_exception_sdk/taxonomy/enums.py +77 -0
- agentic_exception_sdk/taxonomy/enums.pyi +22 -0
- agentic_exception_sdk/taxonomy/envelope.py +223 -0
- agentic_exception_sdk/taxonomy/envelope.pyi +29 -0
- agentic_exception_sdk/taxonomy/errors.py +173 -0
- agentic_exception_sdk/taxonomy/errors.pyi +34 -0
- agentic_exception_sdk/taxonomy/owasp.py +55 -0
- agentic_exception_sdk/taxonomy/owasp.pyi +19 -0
- agentic_exception_sdk/validation/__init__.py +27 -0
- agentic_exception_sdk/validation/__init__.pyi +6 -0
- agentic_exception_sdk/validation/gates.py +93 -0
- agentic_exception_sdk/validation/gates.pyi +16 -0
- agentic_exception_sdk/validation/guard_rails.py +114 -0
- agentic_exception_sdk/validation/guard_rails.pyi +18 -0
- agentic_exception_sdk/validation/rules_version.py +5 -0
- agentic_exception_sdk/validation/rules_version.pyi +3 -0
- agentic_exception_sdk/validation/trust_boundary.py +420 -0
- agentic_exception_sdk/validation/trust_boundary.pyi +18 -0
- agentic_exception_sdk-1.1.3.dist-info/METADATA +291 -0
- agentic_exception_sdk-1.1.3.dist-info/RECORD +106 -0
- agentic_exception_sdk-1.1.3.dist-info/WHEEL +5 -0
- agentic_exception_sdk-1.1.3.dist-info/licenses/LICENSE +21 -0
- agentic_exception_sdk-1.1.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Budget watchdog — enforces agent resource budgets with concurrency-safe reservations.
|
|
2
|
+
|
|
3
|
+
Budget consumption semantics (fixed):
|
|
4
|
+
- consume_call(): once per resilient()/async_resilient() invocation.
|
|
5
|
+
- failure_budget: incremented once per failed attempt, including retries.
|
|
6
|
+
- Token/cost: check-and-reserve is one lock-protected atomic operation.
|
|
7
|
+
Calls that cannot reserve capacity fail before starting upstream work,
|
|
8
|
+
preventing N * delta overspend under contention.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
from agentic_exception_sdk.budget.models import AgentBudget
|
|
18
|
+
from agentic_exception_sdk.taxonomy.errors import BudgetExhaustedError
|
|
19
|
+
|
|
20
|
+
_log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
__all__ = ["BudgetWatchdog"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BudgetWatchdog:
|
|
26
|
+
"""Tracks and enforces agent resource budgets with concurrency-safe atomic reservations.
|
|
27
|
+
|
|
28
|
+
Sync and async paths share a threading.RLock so mixed-concurrency callers
|
|
29
|
+
cannot race with each other.
|
|
30
|
+
|
|
31
|
+
All check-and-reserve operations are atomic within their respective lock so
|
|
32
|
+
that concurrent async_resilient() executions cannot overspend the budget
|
|
33
|
+
through races (N * delta overspend prevention).
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
budget: The AgentBudget configuration specifying ceiling values.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, budget: AgentBudget) -> None:
|
|
40
|
+
self._budget = budget
|
|
41
|
+
self._start_time: float = time.monotonic()
|
|
42
|
+
self._call_count: int = 0
|
|
43
|
+
self._failure_count: int = 0
|
|
44
|
+
self._input_tokens_used: int = 0
|
|
45
|
+
self._output_tokens_used: int = 0
|
|
46
|
+
self._cost_micros_used: int = 0
|
|
47
|
+
self._lock = threading.RLock()
|
|
48
|
+
|
|
49
|
+
def consume_call(self) -> None:
|
|
50
|
+
"""Record one tool call invocation and check max_tool_calls ceiling.
|
|
51
|
+
|
|
52
|
+
This must be called once per resilient()/async_resilient() invocation.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
BudgetExhaustedError: If max_tool_calls ceiling is exceeded.
|
|
56
|
+
BudgetWarningError: Not currently raised here (implemented at soft-limit).
|
|
57
|
+
"""
|
|
58
|
+
with self._lock:
|
|
59
|
+
self._call_count += 1
|
|
60
|
+
if (
|
|
61
|
+
self._budget.max_tool_calls is not None
|
|
62
|
+
and self._call_count > self._budget.max_tool_calls
|
|
63
|
+
):
|
|
64
|
+
raise BudgetExhaustedError(
|
|
65
|
+
f"tool call budget exhausted: {self._call_count} > "
|
|
66
|
+
f"{self._budget.max_tool_calls} max_tool_calls"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def record_failure(self) -> None:
|
|
70
|
+
"""Increment failure count once per failed attempt including retries.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
BudgetExhaustedError: If failure_budget ceiling is exceeded.
|
|
74
|
+
"""
|
|
75
|
+
with self._lock:
|
|
76
|
+
self._failure_count += 1
|
|
77
|
+
if (
|
|
78
|
+
self._budget.failure_budget is not None
|
|
79
|
+
and self._failure_count >= self._budget.failure_budget
|
|
80
|
+
):
|
|
81
|
+
raise BudgetExhaustedError(
|
|
82
|
+
f"failure budget exhausted: {self._failure_count} failures >= "
|
|
83
|
+
f"{self._budget.failure_budget} failure_budget"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def check_time(self) -> None:
|
|
87
|
+
"""Check wall-clock time budget.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
BudgetExhaustedError: If max_seconds ceiling has been exceeded.
|
|
91
|
+
"""
|
|
92
|
+
if self._budget.max_seconds is None:
|
|
93
|
+
return
|
|
94
|
+
elapsed = time.monotonic() - self._start_time
|
|
95
|
+
if elapsed > self._budget.max_seconds:
|
|
96
|
+
raise BudgetExhaustedError(
|
|
97
|
+
f"time budget exhausted: {elapsed:.2f}s > {self._budget.max_seconds}s max_seconds"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def check_before_sleep(self, sleep_seconds: float) -> None:
|
|
101
|
+
"""Check whether the remaining time budget can accommodate a proposed sleep.
|
|
102
|
+
|
|
103
|
+
Called by the retry policy before each backoff sleep.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
sleep_seconds: Proposed sleep duration in seconds.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
BudgetExhaustedError: If the remaining time budget cannot cover the sleep.
|
|
110
|
+
"""
|
|
111
|
+
if self._budget.max_seconds is None:
|
|
112
|
+
return
|
|
113
|
+
elapsed = time.monotonic() - self._start_time
|
|
114
|
+
remaining = self._budget.max_seconds - elapsed
|
|
115
|
+
if sleep_seconds > remaining:
|
|
116
|
+
raise BudgetExhaustedError(
|
|
117
|
+
f"retry sleep ({sleep_seconds:.2f}s) would exceed remaining time budget "
|
|
118
|
+
f"({remaining:.2f}s)"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def reserve_tokens(
|
|
122
|
+
self,
|
|
123
|
+
*,
|
|
124
|
+
input_tokens: int = 0,
|
|
125
|
+
output_tokens: int = 0,
|
|
126
|
+
cost_micros_usd: int = 0,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Atomically check and reserve token/cost capacity before an LLM call.
|
|
129
|
+
|
|
130
|
+
Check-and-reserve is one lock-protected operation to prevent N * delta
|
|
131
|
+
overspend under concurrent async calls.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
input_tokens: Input tokens to reserve.
|
|
135
|
+
output_tokens: Output tokens to reserve.
|
|
136
|
+
cost_micros_usd: Cost in integer micro-USD to reserve.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
BudgetExhaustedError: If any ceiling would be exceeded.
|
|
140
|
+
"""
|
|
141
|
+
with self._lock:
|
|
142
|
+
self._check_token_ceilings(input_tokens, output_tokens, cost_micros_usd)
|
|
143
|
+
self._input_tokens_used += input_tokens
|
|
144
|
+
self._output_tokens_used += output_tokens
|
|
145
|
+
self._cost_micros_used += cost_micros_usd
|
|
146
|
+
|
|
147
|
+
def _check_token_ceilings(
|
|
148
|
+
self,
|
|
149
|
+
input_tokens: int,
|
|
150
|
+
output_tokens: int,
|
|
151
|
+
cost_micros_usd: int,
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Check token/cost ceilings. Must be called while holding the lock."""
|
|
154
|
+
if (
|
|
155
|
+
self._budget.max_input_tokens is not None
|
|
156
|
+
and self._input_tokens_used + input_tokens > self._budget.max_input_tokens
|
|
157
|
+
):
|
|
158
|
+
raise BudgetExhaustedError(
|
|
159
|
+
f"input token budget exhausted: "
|
|
160
|
+
f"{self._input_tokens_used + input_tokens} > {self._budget.max_input_tokens}"
|
|
161
|
+
)
|
|
162
|
+
if (
|
|
163
|
+
self._budget.max_output_tokens is not None
|
|
164
|
+
and self._output_tokens_used + output_tokens > self._budget.max_output_tokens
|
|
165
|
+
):
|
|
166
|
+
raise BudgetExhaustedError(
|
|
167
|
+
f"output token budget exhausted: "
|
|
168
|
+
f"{self._output_tokens_used + output_tokens} > {self._budget.max_output_tokens}"
|
|
169
|
+
)
|
|
170
|
+
if (
|
|
171
|
+
self._budget.max_total_cost_micros_usd is not None
|
|
172
|
+
and self._cost_micros_used + cost_micros_usd > self._budget.max_total_cost_micros_usd
|
|
173
|
+
):
|
|
174
|
+
raise BudgetExhaustedError(
|
|
175
|
+
f"cost budget exhausted: "
|
|
176
|
+
f"{self._cost_micros_used + cost_micros_usd} > "
|
|
177
|
+
f"{self._budget.max_total_cost_micros_usd} micro-USD"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
async def async_reserve_tokens(
|
|
181
|
+
self,
|
|
182
|
+
*,
|
|
183
|
+
input_tokens: int = 0,
|
|
184
|
+
output_tokens: int = 0,
|
|
185
|
+
cost_micros_usd: int = 0,
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Async-safe version of reserve_tokens using the shared thread lock.
|
|
188
|
+
|
|
189
|
+
Check-and-reserve is one asyncio.Lock-protected operation.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
input_tokens: Input tokens to reserve.
|
|
193
|
+
output_tokens: Output tokens to reserve.
|
|
194
|
+
cost_micros_usd: Cost in integer micro-USD to reserve.
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
BudgetExhaustedError: If any ceiling would be exceeded.
|
|
198
|
+
"""
|
|
199
|
+
with self._lock:
|
|
200
|
+
self._check_token_ceilings(input_tokens, output_tokens, cost_micros_usd)
|
|
201
|
+
self._input_tokens_used += input_tokens
|
|
202
|
+
self._output_tokens_used += output_tokens
|
|
203
|
+
self._cost_micros_used += cost_micros_usd
|
|
204
|
+
|
|
205
|
+
def account_stream_delta(
|
|
206
|
+
self,
|
|
207
|
+
*,
|
|
208
|
+
input_tokens: int = 0,
|
|
209
|
+
output_tokens: int = 0,
|
|
210
|
+
cost_micros_usd: int = 0,
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Update token/cost from a streaming LLM response delta.
|
|
213
|
+
|
|
214
|
+
Call this from the streaming consumer on each chunk. Raises
|
|
215
|
+
BudgetExhaustedError immediately if the incremental delta would exceed
|
|
216
|
+
the remaining reservation — aborting the stream early rather than waiting
|
|
217
|
+
for the full response.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
input_tokens: Incremental input token delta.
|
|
221
|
+
output_tokens: Incremental output token delta.
|
|
222
|
+
cost_micros_usd: Incremental cost in integer micro-USD.
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
BudgetExhaustedError: If any ceiling is exceeded mid-stream.
|
|
226
|
+
"""
|
|
227
|
+
with self._lock:
|
|
228
|
+
self._check_token_ceilings(input_tokens, output_tokens, cost_micros_usd)
|
|
229
|
+
self._input_tokens_used += input_tokens
|
|
230
|
+
self._output_tokens_used += output_tokens
|
|
231
|
+
self._cost_micros_used += cost_micros_usd
|
|
232
|
+
|
|
233
|
+
def snapshot(self) -> dict[str, int | float | None]:
|
|
234
|
+
"""Return a thread-safe snapshot of counters and ceilings.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
A plain dict of current usage counters, elapsed seconds, and budget
|
|
238
|
+
ceilings. Monetary values are integer micro-USD.
|
|
239
|
+
"""
|
|
240
|
+
with self._lock:
|
|
241
|
+
elapsed = time.monotonic() - self._start_time
|
|
242
|
+
return {
|
|
243
|
+
"call_count": self._call_count,
|
|
244
|
+
"failure_count": self._failure_count,
|
|
245
|
+
"input_tokens_used": self._input_tokens_used,
|
|
246
|
+
"output_tokens_used": self._output_tokens_used,
|
|
247
|
+
"cost_micros_used": self._cost_micros_used,
|
|
248
|
+
"elapsed_seconds": elapsed,
|
|
249
|
+
"max_tool_calls": self._budget.max_tool_calls,
|
|
250
|
+
"failure_budget": self._budget.failure_budget,
|
|
251
|
+
"max_input_tokens": self._budget.max_input_tokens,
|
|
252
|
+
"max_output_tokens": self._budget.max_output_tokens,
|
|
253
|
+
"max_total_cost_micros_usd": self._budget.max_total_cost_micros_usd,
|
|
254
|
+
"max_seconds": self._budget.max_seconds,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def call_count(self) -> int:
|
|
259
|
+
"""Total tool call invocations recorded."""
|
|
260
|
+
with self._lock:
|
|
261
|
+
return self._call_count
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def failure_count(self) -> int:
|
|
265
|
+
"""Total failed attempts recorded."""
|
|
266
|
+
with self._lock:
|
|
267
|
+
return self._failure_count
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def input_tokens_used(self) -> int:
|
|
271
|
+
"""Total input tokens consumed."""
|
|
272
|
+
with self._lock:
|
|
273
|
+
return self._input_tokens_used
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def output_tokens_used(self) -> int:
|
|
277
|
+
"""Total output tokens consumed."""
|
|
278
|
+
with self._lock:
|
|
279
|
+
return self._output_tokens_used
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def cost_micros_used(self) -> int:
|
|
283
|
+
"""Total cost consumed in integer micro-USD."""
|
|
284
|
+
with self._lock:
|
|
285
|
+
return self._cost_micros_used
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from agentic_exception_sdk.budget.models import AgentBudget
|
|
2
|
+
|
|
3
|
+
__all__ = ['BudgetWatchdog']
|
|
4
|
+
|
|
5
|
+
class BudgetWatchdog:
|
|
6
|
+
def __init__(self, budget: AgentBudget) -> None: ...
|
|
7
|
+
def consume_call(self) -> None: ...
|
|
8
|
+
def record_failure(self) -> None: ...
|
|
9
|
+
def check_time(self) -> None: ...
|
|
10
|
+
def check_before_sleep(self, sleep_seconds: float) -> None: ...
|
|
11
|
+
def reserve_tokens(self, *, input_tokens: int = 0, output_tokens: int = 0, cost_micros_usd: int = 0) -> None: ...
|
|
12
|
+
async def async_reserve_tokens(self, *, input_tokens: int = 0, output_tokens: int = 0, cost_micros_usd: int = 0) -> None: ...
|
|
13
|
+
def account_stream_delta(self, *, input_tokens: int = 0, output_tokens: int = 0, cost_micros_usd: int = 0) -> None: ...
|
|
14
|
+
def snapshot(self) -> dict[str, int | float | None]: ...
|
|
15
|
+
@property
|
|
16
|
+
def call_count(self) -> int: ...
|
|
17
|
+
@property
|
|
18
|
+
def failure_count(self) -> int: ...
|
|
19
|
+
@property
|
|
20
|
+
def input_tokens_used(self) -> int: ...
|
|
21
|
+
@property
|
|
22
|
+
def output_tokens_used(self) -> int: ...
|
|
23
|
+
@property
|
|
24
|
+
def cost_micros_used(self) -> int: ...
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""ResilienceBundle — injectable container for all SDK components.
|
|
2
|
+
|
|
3
|
+
All fields default to safe NoOp implementations so a bundle with defaults is
|
|
4
|
+
immediately usable without any configuration. Host projects replace individual
|
|
5
|
+
components via constructor injection — never via global state or monkey-patching.
|
|
6
|
+
|
|
7
|
+
Zero host imports: this module only imports from within the SDK.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from agentic_exception_sdk.budget.models import AgentBudget
|
|
16
|
+
from agentic_exception_sdk.budget.watchdog import BudgetWatchdog
|
|
17
|
+
from agentic_exception_sdk.escalation.router import NoOpEscalationRouter
|
|
18
|
+
from agentic_exception_sdk.observability.metrics import (
|
|
19
|
+
MetricsCollector,
|
|
20
|
+
NoOpMetricsCollector,
|
|
21
|
+
)
|
|
22
|
+
from agentic_exception_sdk.observability.sink import NoOpSink
|
|
23
|
+
from agentic_exception_sdk.propagation.bus import InMemoryBus
|
|
24
|
+
from agentic_exception_sdk.propagation.dlq import InMemoryDLQ
|
|
25
|
+
from agentic_exception_sdk.resilience.circuit_breaker import (
|
|
26
|
+
NoOpCircuitBreaker,
|
|
27
|
+
)
|
|
28
|
+
from agentic_exception_sdk.resilience.retry import NoOpRetry
|
|
29
|
+
from agentic_exception_sdk.taxonomy.classifier import ExceptionClassifier
|
|
30
|
+
from agentic_exception_sdk.validation.gates import NoOpGate
|
|
31
|
+
from agentic_exception_sdk.validation.guard_rails import NoOpGuardRails
|
|
32
|
+
from agentic_exception_sdk.validation.trust_boundary import TrustBoundaryValidator
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from opentelemetry.metrics import MeterProvider
|
|
36
|
+
|
|
37
|
+
__all__ = ["ResilienceBundle"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ResilienceBundle:
|
|
42
|
+
"""All SDK components in one injectable container.
|
|
43
|
+
|
|
44
|
+
Defaults every component to a safe no-op implementation. Host projects
|
|
45
|
+
replace individual fields to enable specific capabilities:
|
|
46
|
+
|
|
47
|
+
bundle = ResilienceBundle(
|
|
48
|
+
circuit_breaker=InMemoryCircuitBreaker(failure_threshold=3),
|
|
49
|
+
exception_sink=OTelExceptionAdapter(tracer_provider),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
classifier: Maps exceptions to (class, source, level) tuples.
|
|
54
|
+
trust_boundary: Sanitizes all host-provided strings and snapshots.
|
|
55
|
+
guard_rails: Enforces tool call allowlist. NoOpGuardRails by default.
|
|
56
|
+
agent_budget: Tracks and enforces resource budgets. Unlimited by default.
|
|
57
|
+
retry_policy: Retry strategy. Single-attempt (no retry) by default.
|
|
58
|
+
circuit_breaker: Sync circuit breaker. NoOp (pass-through) by default.
|
|
59
|
+
output_validation_gate: Validates tool outputs. Pass-through by default.
|
|
60
|
+
exception_sink: Observability sink. Discards all envelopes by default.
|
|
61
|
+
metrics_collector: Metrics collector. NoOp by default.
|
|
62
|
+
meter_provider: Optional OTel MeterProvider for tool latency histograms.
|
|
63
|
+
propagation_bus: Propagation bus. Bounded in-memory (1000 envelopes) by default.
|
|
64
|
+
dlq: Dead-letter queue for HARD_KILL envelopes. InMemoryDLQ by default.
|
|
65
|
+
escalation_router: Routes envelopes to handlers. NoOp by default.
|
|
66
|
+
recovery_policy: Optional post-classification recovery policy. None by default.
|
|
67
|
+
async_circuit_breaker: Optional async circuit breaker. None by default,
|
|
68
|
+
meaning async_resilient() falls back to bundle.circuit_breaker.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
classifier: ExceptionClassifier = field(default_factory=ExceptionClassifier)
|
|
72
|
+
trust_boundary: TrustBoundaryValidator = field(
|
|
73
|
+
default_factory=TrustBoundaryValidator
|
|
74
|
+
)
|
|
75
|
+
guard_rails: Any = field(default_factory=NoOpGuardRails)
|
|
76
|
+
agent_budget: BudgetWatchdog = field(
|
|
77
|
+
default_factory=lambda: BudgetWatchdog(AgentBudget())
|
|
78
|
+
)
|
|
79
|
+
retry_policy: Any = field(default_factory=NoOpRetry)
|
|
80
|
+
circuit_breaker: Any = field(default_factory=NoOpCircuitBreaker)
|
|
81
|
+
output_validation_gate: Any = field(default_factory=NoOpGate)
|
|
82
|
+
exception_sink: Any = field(default_factory=NoOpSink)
|
|
83
|
+
metrics_collector: MetricsCollector = field(default_factory=NoOpMetricsCollector)
|
|
84
|
+
meter_provider: MeterProvider | None = None
|
|
85
|
+
propagation_bus: Any = field(default_factory=InMemoryBus)
|
|
86
|
+
dlq: Any = field(default_factory=InMemoryDLQ)
|
|
87
|
+
escalation_router: Any = field(default_factory=NoOpEscalationRouter)
|
|
88
|
+
recovery_policy: Any | None = None
|
|
89
|
+
async_circuit_breaker: Any | None = None
|
|
90
|
+
_latency_histogram: Any | None = field(init=False, repr=False, default=None)
|
|
91
|
+
|
|
92
|
+
def __post_init__(self) -> None:
|
|
93
|
+
"""Cache optional OTel instruments once at bundle construction."""
|
|
94
|
+
if self.meter_provider is None:
|
|
95
|
+
self._latency_histogram = None
|
|
96
|
+
return
|
|
97
|
+
meter = self.meter_provider.get_meter("agentic_exception_sdk")
|
|
98
|
+
self._latency_histogram = meter.create_histogram(
|
|
99
|
+
"agent_tool_call_duration_seconds",
|
|
100
|
+
unit="s",
|
|
101
|
+
description="Per-call tool duration by agent and tool name",
|
|
102
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from _typeshed import Incomplete
|
|
2
|
+
from agentic_exception_sdk.budget.watchdog import BudgetWatchdog
|
|
3
|
+
from agentic_exception_sdk.escalation.router import NoOpEscalationRouter
|
|
4
|
+
from agentic_exception_sdk.observability.metrics import MetricsCollector, NoOpMetricsCollector
|
|
5
|
+
from agentic_exception_sdk.observability.sink import NoOpSink
|
|
6
|
+
from agentic_exception_sdk.propagation.bus import InMemoryBus
|
|
7
|
+
from agentic_exception_sdk.propagation.dlq import InMemoryDLQ
|
|
8
|
+
from agentic_exception_sdk.resilience.circuit_breaker import NoOpCircuitBreaker
|
|
9
|
+
from agentic_exception_sdk.resilience.retry import NoOpRetry
|
|
10
|
+
from agentic_exception_sdk.taxonomy.classifier import ExceptionClassifier
|
|
11
|
+
from agentic_exception_sdk.validation.gates import NoOpGate
|
|
12
|
+
from agentic_exception_sdk.validation.guard_rails import NoOpGuardRails
|
|
13
|
+
from agentic_exception_sdk.validation.trust_boundary import TrustBoundaryValidator
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from opentelemetry.metrics import MeterProvider
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
__all__ = ['ResilienceBundle']
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ResilienceBundle:
|
|
22
|
+
classifier: ExceptionClassifier = field(default_factory=ExceptionClassifier)
|
|
23
|
+
trust_boundary: TrustBoundaryValidator = field(default_factory=TrustBoundaryValidator)
|
|
24
|
+
guard_rails: Any = field(default_factory=NoOpGuardRails)
|
|
25
|
+
agent_budget: BudgetWatchdog = field(default_factory=Incomplete)
|
|
26
|
+
retry_policy: Any = field(default_factory=NoOpRetry)
|
|
27
|
+
circuit_breaker: Any = field(default_factory=NoOpCircuitBreaker)
|
|
28
|
+
output_validation_gate: Any = field(default_factory=NoOpGate)
|
|
29
|
+
exception_sink: Any = field(default_factory=NoOpSink)
|
|
30
|
+
metrics_collector: MetricsCollector = field(default_factory=NoOpMetricsCollector)
|
|
31
|
+
meter_provider: MeterProvider | None = ...
|
|
32
|
+
propagation_bus: Any = field(default_factory=InMemoryBus)
|
|
33
|
+
dlq: Any = field(default_factory=InMemoryDLQ)
|
|
34
|
+
escalation_router: Any = field(default_factory=NoOpEscalationRouter)
|
|
35
|
+
recovery_policy: Any | None = ...
|
|
36
|
+
async_circuit_breaker: Any | None = ...
|
|
37
|
+
def __post_init__(self) -> None: ...
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Startup validation for ResilienceBundle configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agentic_exception_sdk.bundle import ResilienceBundle
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"BundleValidationError",
|
|
9
|
+
"validate_bundle",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BundleValidationError(ValueError):
|
|
14
|
+
"""Raised when validate_bundle finds one or more configuration failures."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, failures: list[str]) -> None:
|
|
17
|
+
self.failures = list(failures)
|
|
18
|
+
self.report = "\n".join(self.failures)
|
|
19
|
+
super().__init__(self.report)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate_bundle(bundle: ResilienceBundle) -> None:
|
|
23
|
+
"""Validate a bundle at startup without mutating it.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
bundle: The configured ResilienceBundle to inspect.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
BundleValidationError: If one or more configuration failures are found.
|
|
30
|
+
"""
|
|
31
|
+
failures: list[str] = []
|
|
32
|
+
_check_redis_circuit_breaker(bundle, failures)
|
|
33
|
+
_check_guard_rails(bundle, failures)
|
|
34
|
+
_check_budget(bundle, failures)
|
|
35
|
+
if failures:
|
|
36
|
+
raise BundleValidationError(failures)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _check_redis_circuit_breaker(
|
|
40
|
+
bundle: ResilienceBundle,
|
|
41
|
+
failures: list[str],
|
|
42
|
+
) -> None:
|
|
43
|
+
redis_url = getattr(bundle.circuit_breaker, "redis_url", None)
|
|
44
|
+
if redis_url is None:
|
|
45
|
+
return
|
|
46
|
+
if not isinstance(redis_url, str) or not redis_url.startswith("rediss://"):
|
|
47
|
+
failures.append("RedisCircuitBreaker: URL must use rediss:// (TLS required)")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _check_guard_rails(bundle: ResilienceBundle, failures: list[str]) -> None:
|
|
51
|
+
guard_rails = bundle.guard_rails
|
|
52
|
+
allowlist = getattr(guard_rails, "allowlist", None)
|
|
53
|
+
if allowlist is not None and len(allowlist) == 0:
|
|
54
|
+
failures.append("GuardRailPolicy: allowlist is empty - all tool calls will be blocked")
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
allowlisted_operations = getattr(guard_rails, "_allowlisted_operations", None)
|
|
58
|
+
canonical = getattr(allowlisted_operations, "canonical", None)
|
|
59
|
+
if canonical is not None and len(canonical) == 0:
|
|
60
|
+
failures.append("GuardRailPolicy: allowlist is empty - all tool calls will be blocked")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _check_budget(bundle: ResilienceBundle, failures: list[str]) -> None:
|
|
64
|
+
budget = getattr(bundle.agent_budget, "budget", None)
|
|
65
|
+
if budget is None:
|
|
66
|
+
budget = getattr(bundle.agent_budget, "_budget", None)
|
|
67
|
+
if budget is None:
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
max_cost = getattr(budget, "max_cost_micros", None)
|
|
71
|
+
if max_cost is None:
|
|
72
|
+
max_cost = getattr(budget, "max_total_cost_micros_usd", None)
|
|
73
|
+
if max_cost is not None and max_cost <= 0:
|
|
74
|
+
failures.append("AgentBudget: max_cost_micros must be positive")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from _typeshed import Incomplete
|
|
2
|
+
from agentic_exception_sdk.bundle import ResilienceBundle
|
|
3
|
+
|
|
4
|
+
__all__ = ['BundleValidationError', 'validate_bundle']
|
|
5
|
+
|
|
6
|
+
class BundleValidationError(ValueError):
|
|
7
|
+
failures: Incomplete
|
|
8
|
+
report: Incomplete
|
|
9
|
+
def __init__(self, failures: list[str]) -> None: ...
|
|
10
|
+
|
|
11
|
+
def validate_bundle(bundle: ResilienceBundle) -> None: ...
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Escalation: routing, handlers, and checkpoint store."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agentic_exception_sdk.escalation.checkpoint import CheckpointStore, InMemoryCheckpointStore
|
|
6
|
+
from agentic_exception_sdk.escalation.handlers import NoOpHandler
|
|
7
|
+
from agentic_exception_sdk.escalation.router import (
|
|
8
|
+
EscalationHandlerLike,
|
|
9
|
+
EscalationRouter,
|
|
10
|
+
NoOpEscalationRouter,
|
|
11
|
+
RecoveryDirective,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CheckpointStore",
|
|
16
|
+
"EscalationHandlerLike",
|
|
17
|
+
"EscalationRouter",
|
|
18
|
+
"InMemoryCheckpointStore",
|
|
19
|
+
"NoOpEscalationRouter",
|
|
20
|
+
"NoOpHandler",
|
|
21
|
+
"RecoveryDirective",
|
|
22
|
+
]
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
from agentic_exception_sdk.escalation.checkpoint import CheckpointStore as CheckpointStore, InMemoryCheckpointStore as InMemoryCheckpointStore
|
|
2
|
+
from agentic_exception_sdk.escalation.handlers import NoOpHandler as NoOpHandler
|
|
3
|
+
from agentic_exception_sdk.escalation.router import EscalationHandlerLike as EscalationHandlerLike, EscalationRouter as EscalationRouter, NoOpEscalationRouter as NoOpEscalationRouter, RecoveryDirective as RecoveryDirective
|
|
4
|
+
|
|
5
|
+
__all__ = ['CheckpointStore', 'EscalationHandlerLike', 'EscalationRouter', 'InMemoryCheckpointStore', 'NoOpEscalationRouter', 'NoOpHandler', 'RecoveryDirective']
|