agentomatic 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.
- agentomatic/__init__.py +59 -0
- agentomatic/_version.py +5 -0
- agentomatic/cli/__init__.py +7 -0
- agentomatic/cli/commands.py +715 -0
- agentomatic/cli/templates.py +188 -0
- agentomatic/config/__init__.py +3 -0
- agentomatic/config/defaults.py +10 -0
- agentomatic/config/settings.py +117 -0
- agentomatic/core/__init__.py +31 -0
- agentomatic/core/lifespan.py +102 -0
- agentomatic/core/manifest.py +100 -0
- agentomatic/core/platform.py +571 -0
- agentomatic/core/registry.py +198 -0
- agentomatic/core/router_factory.py +541 -0
- agentomatic/core/state.py +63 -0
- agentomatic/middleware/__init__.py +18 -0
- agentomatic/middleware/auth.py +53 -0
- agentomatic/middleware/feedback.py +207 -0
- agentomatic/middleware/logging.py +40 -0
- agentomatic/middleware/metrics.py +93 -0
- agentomatic/middleware/rate_limit.py +70 -0
- agentomatic/observability/__init__.py +11 -0
- agentomatic/observability/concurrency.py +109 -0
- agentomatic/observability/metrics.py +101 -0
- agentomatic/observability/telemetry.py +316 -0
- agentomatic/optimize/__init__.py +112 -0
- agentomatic/optimize/dataset.py +142 -0
- agentomatic/optimize/loop.py +870 -0
- agentomatic/optimize/metrics.py +781 -0
- agentomatic/optimize/optimizer.py +891 -0
- agentomatic/optimize/report.py +774 -0
- agentomatic/optimize/runner.py +261 -0
- agentomatic/optimize/strategies.py +592 -0
- agentomatic/optimize/synthesizer.py +729 -0
- agentomatic/prompts/__init__.py +7 -0
- agentomatic/prompts/manager.py +59 -0
- agentomatic/protocols/__init__.py +3 -0
- agentomatic/protocols/decorators.py +75 -0
- agentomatic/providers/__init__.py +3 -0
- agentomatic/providers/embeddings.py +44 -0
- agentomatic/providers/llm.py +116 -0
- agentomatic/py.typed +1 -0
- agentomatic/storage/__init__.py +40 -0
- agentomatic/storage/base.py +192 -0
- agentomatic/storage/memory.py +167 -0
- agentomatic/storage/models.py +129 -0
- agentomatic/storage/sqlalchemy.py +317 -0
- agentomatic/ui/.chainlit/config.toml +14 -0
- agentomatic/ui/__init__.py +50 -0
- agentomatic/ui/chat.py +198 -0
- agentomatic-0.1.0.dist-info/METADATA +363 -0
- agentomatic-0.1.0.dist-info/RECORD +55 -0
- agentomatic-0.1.0.dist-info/WHEEL +4 -0
- agentomatic-0.1.0.dist-info/entry_points.txt +2 -0
- agentomatic-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Feedback collection middleware and decorator.
|
|
2
|
+
|
|
3
|
+
Auto-adds feedback endpoints to every agent and provides
|
|
4
|
+
a decorator for automatic input/output recording.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
# In agent code:
|
|
9
|
+
from agentomatic.middleware.feedback import collect_feedback
|
|
10
|
+
|
|
11
|
+
@collect_feedback(store=True)
|
|
12
|
+
async def invoke(state):
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
# Via platform:
|
|
16
|
+
platform = AgentPlatform.from_folder(
|
|
17
|
+
"agents/",
|
|
18
|
+
enable_feedback=True,
|
|
19
|
+
store=MemoryStore(),
|
|
20
|
+
)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import functools
|
|
27
|
+
import uuid
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from datetime import UTC
|
|
31
|
+
from typing import Any, TypeVar, cast
|
|
32
|
+
|
|
33
|
+
from loguru import logger
|
|
34
|
+
|
|
35
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class FeedbackRecord:
|
|
40
|
+
"""A feedback entry."""
|
|
41
|
+
|
|
42
|
+
feedback_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
|
43
|
+
agent_name: str = ""
|
|
44
|
+
user_id: str = ""
|
|
45
|
+
thread_id: str | None = None
|
|
46
|
+
message_id: int | None = None
|
|
47
|
+
query: str = ""
|
|
48
|
+
response: str = ""
|
|
49
|
+
rating: int | None = None # 1 (thumbs down) or 5 (thumbs up)
|
|
50
|
+
comment: str | None = None # Free-text comment
|
|
51
|
+
correction: str | None = None # User-provided correct answer
|
|
52
|
+
feedback_type: str = "thumbs" # thumbs, rating, correction, comment
|
|
53
|
+
timestamp: str = ""
|
|
54
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, Any]:
|
|
57
|
+
return {k: v for k, v in self.__dict__.items() if v is not None and v != "" and v != {}}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class FeedbackCollector:
|
|
61
|
+
"""Async feedback collector with background storage.
|
|
62
|
+
|
|
63
|
+
Can be used standalone or auto-attached to agents.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, store: Any = None, buffer_size: int = 100):
|
|
67
|
+
self._store = store
|
|
68
|
+
self._buffer: list[FeedbackRecord] = []
|
|
69
|
+
self._buffer_size = buffer_size
|
|
70
|
+
self._lock = asyncio.Lock()
|
|
71
|
+
|
|
72
|
+
async def record(
|
|
73
|
+
self,
|
|
74
|
+
agent_name: str,
|
|
75
|
+
user_id: str = "",
|
|
76
|
+
*,
|
|
77
|
+
query: str = "",
|
|
78
|
+
response: str = "",
|
|
79
|
+
rating: int | None = None,
|
|
80
|
+
comment: str | None = None,
|
|
81
|
+
correction: str | None = None,
|
|
82
|
+
feedback_type: str = "thumbs",
|
|
83
|
+
thread_id: str | None = None,
|
|
84
|
+
metadata: dict[str, Any] | None = None,
|
|
85
|
+
) -> FeedbackRecord:
|
|
86
|
+
"""Record a feedback entry."""
|
|
87
|
+
from datetime import datetime
|
|
88
|
+
|
|
89
|
+
record = FeedbackRecord(
|
|
90
|
+
agent_name=agent_name,
|
|
91
|
+
user_id=user_id,
|
|
92
|
+
query=query,
|
|
93
|
+
response=response,
|
|
94
|
+
rating=rating,
|
|
95
|
+
comment=comment,
|
|
96
|
+
correction=correction,
|
|
97
|
+
feedback_type=feedback_type,
|
|
98
|
+
thread_id=thread_id,
|
|
99
|
+
timestamp=datetime.now(UTC).isoformat(),
|
|
100
|
+
metadata=metadata or {},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Store via backend
|
|
104
|
+
if self._store and hasattr(self._store, "add_feedback"):
|
|
105
|
+
try:
|
|
106
|
+
await self._store.add_feedback(
|
|
107
|
+
thread_id=thread_id or "",
|
|
108
|
+
user_id=user_id,
|
|
109
|
+
agent_name=agent_name,
|
|
110
|
+
rating=rating,
|
|
111
|
+
comment=comment,
|
|
112
|
+
feedback_type=feedback_type,
|
|
113
|
+
)
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
logger.warning(f"Failed to store feedback: {exc}")
|
|
116
|
+
|
|
117
|
+
# Buffer for batch export
|
|
118
|
+
async with self._lock:
|
|
119
|
+
self._buffer.append(record)
|
|
120
|
+
if len(self._buffer) > self._buffer_size:
|
|
121
|
+
self._buffer = self._buffer[-self._buffer_size :]
|
|
122
|
+
|
|
123
|
+
logger.debug(f"📝 Feedback recorded for {agent_name} (rating={rating})")
|
|
124
|
+
return record
|
|
125
|
+
|
|
126
|
+
async def get_feedback(
|
|
127
|
+
self,
|
|
128
|
+
agent_name: str | None = None,
|
|
129
|
+
limit: int = 50,
|
|
130
|
+
) -> list[dict[str, Any]]:
|
|
131
|
+
"""Get stored feedback."""
|
|
132
|
+
if self._store and hasattr(self._store, "get_feedback"):
|
|
133
|
+
return cast(
|
|
134
|
+
list[dict[str, Any]],
|
|
135
|
+
await self._store.get_feedback(
|
|
136
|
+
agent_name=agent_name,
|
|
137
|
+
limit=limit,
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
# Fall back to buffer
|
|
141
|
+
async with self._lock:
|
|
142
|
+
items = self._buffer
|
|
143
|
+
if agent_name:
|
|
144
|
+
items = [f for f in items if f.agent_name == agent_name]
|
|
145
|
+
return [f.to_dict() for f in items[-limit:]]
|
|
146
|
+
|
|
147
|
+
async def export_jsonl(self, agent_name: str | None = None) -> str:
|
|
148
|
+
"""Export feedback as JSONL string (for optimization datasets)."""
|
|
149
|
+
import json
|
|
150
|
+
|
|
151
|
+
records = await self.get_feedback(agent_name=agent_name, limit=10000)
|
|
152
|
+
lines = []
|
|
153
|
+
for r in records:
|
|
154
|
+
# Convert to optimization-friendly format
|
|
155
|
+
entry = {
|
|
156
|
+
"query": r.get("query", ""),
|
|
157
|
+
"expected_answer": r.get("correction") or r.get("response", ""),
|
|
158
|
+
"metadata": {
|
|
159
|
+
"rating": r.get("rating"),
|
|
160
|
+
"comment": r.get("comment"),
|
|
161
|
+
"feedback_type": r.get("feedback_type"),
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
if entry["query"]: # Only include entries with queries
|
|
165
|
+
lines.append(json.dumps(entry))
|
|
166
|
+
return "\n".join(lines)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def collect_feedback(
|
|
170
|
+
store: bool = True,
|
|
171
|
+
log: bool = True,
|
|
172
|
+
) -> Callable[[F], F]:
|
|
173
|
+
"""Decorator to auto-record agent inputs/outputs for feedback.
|
|
174
|
+
|
|
175
|
+
Records every invocation's query and response.
|
|
176
|
+
Useful for building optimization datasets from production traffic.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def decorator(fn: F) -> F:
|
|
180
|
+
@functools.wraps(fn)
|
|
181
|
+
async def wrapper(state: dict, *args, **kwargs):
|
|
182
|
+
result = await fn(state, *args, **kwargs)
|
|
183
|
+
if log:
|
|
184
|
+
query = state.get("current_query", "")
|
|
185
|
+
response = result.get("response", "") if isinstance(result, dict) else str(result)
|
|
186
|
+
logger.info(f"📊 [feedback] Q={query[:50]}... A={response[:50]}...")
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
return wrapper # type: ignore
|
|
190
|
+
|
|
191
|
+
return decorator
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# Module-level singleton
|
|
195
|
+
_collector: FeedbackCollector | None = None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def get_collector() -> FeedbackCollector:
|
|
199
|
+
global _collector
|
|
200
|
+
if _collector is None:
|
|
201
|
+
_collector = FeedbackCollector()
|
|
202
|
+
return _collector
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def set_collector(collector: FeedbackCollector) -> None:
|
|
206
|
+
global _collector
|
|
207
|
+
_collector = collector
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Request logging middleware with X-Request-ID."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
10
|
+
from starlette.requests import Request
|
|
11
|
+
from starlette.responses import Response
|
|
12
|
+
|
|
13
|
+
_SKIP_PATHS = {"/health", "/healthz", "/readiness", "/metrics", "/favicon.ico"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LoggingMiddleware(BaseHTTPMiddleware):
|
|
17
|
+
"""Logs every request with timing and request ID."""
|
|
18
|
+
|
|
19
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
20
|
+
"""Process request with logging and timing."""
|
|
21
|
+
if request.url.path in _SKIP_PATHS:
|
|
22
|
+
response: Response = await call_next(request)
|
|
23
|
+
return response
|
|
24
|
+
|
|
25
|
+
request_id = request.headers.get("X-Request-ID", uuid.uuid4().hex[:12])
|
|
26
|
+
t0 = time.perf_counter()
|
|
27
|
+
|
|
28
|
+
logger.info(f"→ {request.method} {request.url.path} [{request_id}]")
|
|
29
|
+
|
|
30
|
+
response = await call_next(request)
|
|
31
|
+
|
|
32
|
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
33
|
+
logger.info(
|
|
34
|
+
f"← {request.method} {request.url.path} → {response.status_code} "
|
|
35
|
+
f"({elapsed_ms:.1f}ms) [{request_id}]"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
response.headers["X-Request-ID"] = request_id
|
|
39
|
+
response.headers["X-Process-Time-Ms"] = f"{elapsed_ms:.1f}"
|
|
40
|
+
return response
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Prometheus metrics middleware.
|
|
2
|
+
|
|
3
|
+
Enabled via ``FEATURES__ENABLE_METRICS=true``.
|
|
4
|
+
Automatically tracks request count, latency histogram, and active requests.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
12
|
+
from starlette.requests import Request
|
|
13
|
+
from starlette.responses import Response
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Gauge, Histogram, generate_latest
|
|
17
|
+
|
|
18
|
+
HAS_PROMETHEUS = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
HAS_PROMETHEUS = False
|
|
21
|
+
|
|
22
|
+
_SKIP_PATHS = {"/health", "/healthz", "/readiness", "/metrics"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MetricsMiddleware(BaseHTTPMiddleware):
|
|
26
|
+
"""Prometheus metrics collection per request."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, app, *, prefix: str = "agentomatic") -> None:
|
|
29
|
+
super().__init__(app)
|
|
30
|
+
self._requests: Counter | None = None
|
|
31
|
+
self._duration: Histogram | None = None
|
|
32
|
+
self._active: Gauge | None = None
|
|
33
|
+
if HAS_PROMETHEUS:
|
|
34
|
+
self._requests = Counter(
|
|
35
|
+
f"{prefix}_http_requests_total",
|
|
36
|
+
"Total HTTP requests",
|
|
37
|
+
["method", "path", "status"],
|
|
38
|
+
)
|
|
39
|
+
self._duration = Histogram(
|
|
40
|
+
f"{prefix}_http_request_duration_seconds",
|
|
41
|
+
"HTTP request duration",
|
|
42
|
+
["method", "path"],
|
|
43
|
+
buckets=(0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
|
|
44
|
+
)
|
|
45
|
+
self._active = Gauge(
|
|
46
|
+
f"{prefix}_http_requests_active",
|
|
47
|
+
"Active HTTP requests",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
51
|
+
if request.url.path in _SKIP_PATHS:
|
|
52
|
+
# Serve /metrics endpoint
|
|
53
|
+
if request.url.path == "/metrics" and HAS_PROMETHEUS:
|
|
54
|
+
from starlette.responses import Response as StarletteResponse
|
|
55
|
+
|
|
56
|
+
body = generate_latest()
|
|
57
|
+
return StarletteResponse(
|
|
58
|
+
content=body,
|
|
59
|
+
media_type=CONTENT_TYPE_LATEST,
|
|
60
|
+
)
|
|
61
|
+
response: Response = await call_next(request)
|
|
62
|
+
return response
|
|
63
|
+
|
|
64
|
+
if self._active:
|
|
65
|
+
self._active.inc()
|
|
66
|
+
|
|
67
|
+
t0 = time.perf_counter()
|
|
68
|
+
response = await call_next(request)
|
|
69
|
+
duration = time.perf_counter() - t0
|
|
70
|
+
|
|
71
|
+
# Normalize path for cardinality control
|
|
72
|
+
path = request.url.path
|
|
73
|
+
# Collapse UUIDs and hex IDs to reduce cardinality
|
|
74
|
+
parts = path.split("/")
|
|
75
|
+
normalized = "/".join(
|
|
76
|
+
"{id}" if (len(p) > 8 and any(c.isdigit() for c in p)) else p for p in parts
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if self._requests:
|
|
80
|
+
self._requests.labels(
|
|
81
|
+
method=request.method,
|
|
82
|
+
path=normalized,
|
|
83
|
+
status=str(response.status_code),
|
|
84
|
+
).inc()
|
|
85
|
+
if self._duration:
|
|
86
|
+
self._duration.labels(
|
|
87
|
+
method=request.method,
|
|
88
|
+
path=normalized,
|
|
89
|
+
).observe(duration)
|
|
90
|
+
if self._active:
|
|
91
|
+
self._active.dec()
|
|
92
|
+
|
|
93
|
+
return response
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""In-memory sliding-window rate limiter.
|
|
2
|
+
|
|
3
|
+
Enabled via ``FEATURES__ENABLE_RATE_LIMIT=true``.
|
|
4
|
+
Configured via ``RATE_LIMIT__REQUESTS`` and ``RATE_LIMIT__WINDOW_SECONDS``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
|
|
12
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
13
|
+
from starlette.requests import Request
|
|
14
|
+
from starlette.responses import JSONResponse, Response
|
|
15
|
+
|
|
16
|
+
_SKIP_PATHS = {"/health", "/healthz", "/readiness"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
20
|
+
"""Token-bucket style rate limiter per client IP.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
app: ASGI application.
|
|
24
|
+
max_requests: Maximum requests per window.
|
|
25
|
+
window_seconds: Sliding window duration.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
app,
|
|
31
|
+
*,
|
|
32
|
+
max_requests: int = 100,
|
|
33
|
+
window_seconds: int = 60,
|
|
34
|
+
) -> None:
|
|
35
|
+
super().__init__(app)
|
|
36
|
+
self._max = max_requests
|
|
37
|
+
self._window = window_seconds
|
|
38
|
+
self._hits: dict[str, list[float]] = defaultdict(list)
|
|
39
|
+
|
|
40
|
+
def _client_key(self, request: Request) -> str:
|
|
41
|
+
forwarded = request.headers.get("X-Forwarded-For")
|
|
42
|
+
if forwarded:
|
|
43
|
+
return forwarded.split(",")[0].strip()
|
|
44
|
+
return request.client.host if request.client else "unknown"
|
|
45
|
+
|
|
46
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
47
|
+
if request.url.path in _SKIP_PATHS:
|
|
48
|
+
response: Response = await call_next(request)
|
|
49
|
+
return response
|
|
50
|
+
|
|
51
|
+
key = self._client_key(request)
|
|
52
|
+
now = time.monotonic()
|
|
53
|
+
|
|
54
|
+
# Purge expired entries
|
|
55
|
+
self._hits[key] = [t for t in self._hits[key] if now - t < self._window]
|
|
56
|
+
|
|
57
|
+
if len(self._hits[key]) >= self._max:
|
|
58
|
+
retry_after = int(self._window - (now - self._hits[key][0]))
|
|
59
|
+
return JSONResponse(
|
|
60
|
+
{"detail": "Rate limit exceeded", "retry_after": max(retry_after, 1)},
|
|
61
|
+
status_code=429,
|
|
62
|
+
headers={"Retry-After": str(max(retry_after, 1))},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
self._hits[key].append(now)
|
|
66
|
+
response = await call_next(request)
|
|
67
|
+
remaining = self._max - len(self._hits[key])
|
|
68
|
+
response.headers["X-RateLimit-Limit"] = str(self._max)
|
|
69
|
+
response.headers["X-RateLimit-Remaining"] = str(max(remaining, 0))
|
|
70
|
+
return response
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Circuit breaker and concurrency control."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CircuitState(Enum):
|
|
14
|
+
"""States for the circuit breaker."""
|
|
15
|
+
|
|
16
|
+
CLOSED = "closed"
|
|
17
|
+
OPEN = "open"
|
|
18
|
+
HALF_OPEN = "half_open"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CircuitBreakerOpen(Exception):
|
|
22
|
+
"""Raised when circuit breaker is open."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CircuitBreaker:
|
|
26
|
+
"""Async circuit breaker for protecting external services."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
name: str = "default",
|
|
31
|
+
failure_threshold: int = 5,
|
|
32
|
+
reset_timeout: float = 60.0,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.name = name
|
|
35
|
+
self.failure_threshold = failure_threshold
|
|
36
|
+
self.reset_timeout = reset_timeout
|
|
37
|
+
self._state = CircuitState.CLOSED
|
|
38
|
+
self._failure_count = 0
|
|
39
|
+
self._last_failure_time: float = 0
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def state(self) -> CircuitState:
|
|
43
|
+
"""Get current state, auto-transitioning from OPEN to HALF_OPEN after timeout."""
|
|
44
|
+
if self._state == CircuitState.OPEN:
|
|
45
|
+
if time.monotonic() - self._last_failure_time >= self.reset_timeout:
|
|
46
|
+
self._state = CircuitState.HALF_OPEN
|
|
47
|
+
return self._state
|
|
48
|
+
|
|
49
|
+
@asynccontextmanager
|
|
50
|
+
async def __call__(self):
|
|
51
|
+
"""Use as an async context manager around protected calls."""
|
|
52
|
+
if self.state == CircuitState.OPEN:
|
|
53
|
+
raise CircuitBreakerOpen(f"Circuit breaker '{self.name}' is open")
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
yield
|
|
57
|
+
self._on_success()
|
|
58
|
+
except Exception:
|
|
59
|
+
self._on_failure()
|
|
60
|
+
raise
|
|
61
|
+
|
|
62
|
+
def _on_success(self) -> None:
|
|
63
|
+
"""Record a successful call."""
|
|
64
|
+
self._failure_count = 0
|
|
65
|
+
self._state = CircuitState.CLOSED
|
|
66
|
+
|
|
67
|
+
def _on_failure(self) -> None:
|
|
68
|
+
"""Record a failed call and potentially open the circuit."""
|
|
69
|
+
self._failure_count += 1
|
|
70
|
+
self._last_failure_time = time.monotonic()
|
|
71
|
+
if self._failure_count >= self.failure_threshold:
|
|
72
|
+
self._state = CircuitState.OPEN
|
|
73
|
+
logger.warning(
|
|
74
|
+
f"Circuit breaker '{self.name}' opened after {self._failure_count} failures"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AgentSemaphore:
|
|
79
|
+
"""Global semaphore to limit concurrent agent invocations."""
|
|
80
|
+
|
|
81
|
+
_instance: AgentSemaphore | None = None
|
|
82
|
+
|
|
83
|
+
def __init__(self, max_concurrent: int = 10) -> None:
|
|
84
|
+
self._semaphore = asyncio.Semaphore(max_concurrent)
|
|
85
|
+
self._max = max_concurrent
|
|
86
|
+
self._active = 0
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def get(cls, max_concurrent: int = 10) -> AgentSemaphore:
|
|
90
|
+
"""Get or create the singleton semaphore."""
|
|
91
|
+
if cls._instance is None:
|
|
92
|
+
cls._instance = cls(max_concurrent)
|
|
93
|
+
return cls._instance
|
|
94
|
+
|
|
95
|
+
@asynccontextmanager
|
|
96
|
+
async def acquire(self):
|
|
97
|
+
"""Acquire the semaphore, limiting concurrency."""
|
|
98
|
+
await self._semaphore.acquire()
|
|
99
|
+
self._active += 1
|
|
100
|
+
try:
|
|
101
|
+
yield
|
|
102
|
+
finally:
|
|
103
|
+
self._active -= 1
|
|
104
|
+
self._semaphore.release()
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def active(self) -> int:
|
|
108
|
+
"""Number of currently active acquisitions."""
|
|
109
|
+
return self._active
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Prometheus metrics with graceful fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from prometheus_client import Counter, Gauge, Histogram
|
|
10
|
+
|
|
11
|
+
HAS_PROMETHEUS = True
|
|
12
|
+
except ImportError:
|
|
13
|
+
HAS_PROMETHEUS = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _DummyMetric:
|
|
17
|
+
"""No-op metric when prometheus_client is not installed."""
|
|
18
|
+
|
|
19
|
+
def labels(self, **kw: Any) -> _DummyMetric:
|
|
20
|
+
return self
|
|
21
|
+
|
|
22
|
+
def inc(self, amount: float = 1) -> None:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
def dec(self, amount: float = 1) -> None:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
def set(self, value: float) -> None:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
def observe(self, value: float) -> None:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
def info(self, val: dict) -> None:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _counter(name: str, doc: str, labels: list[str] | None = None) -> Any:
|
|
39
|
+
"""Create a Prometheus Counter or dummy fallback."""
|
|
40
|
+
if HAS_PROMETHEUS:
|
|
41
|
+
return Counter(name, doc, labels or [])
|
|
42
|
+
return _DummyMetric()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _histogram(name: str, doc: str, labels: list[str] | None = None, buckets: Any = None) -> Any:
|
|
46
|
+
"""Create a Prometheus Histogram or dummy fallback."""
|
|
47
|
+
if HAS_PROMETHEUS:
|
|
48
|
+
kw = {"buckets": buckets} if buckets else {}
|
|
49
|
+
return Histogram(name, doc, labels or [], **kw)
|
|
50
|
+
return _DummyMetric()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _gauge(name: str, doc: str, labels: list[str] | None = None) -> Any:
|
|
54
|
+
"""Create a Prometheus Gauge or dummy fallback."""
|
|
55
|
+
if HAS_PROMETHEUS:
|
|
56
|
+
return Gauge(name, doc, labels or [])
|
|
57
|
+
return _DummyMetric()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Counters
|
|
61
|
+
REQUEST_COUNT = _counter(
|
|
62
|
+
"agentomatic_requests_total", "Total requests", ["method", "endpoint", "status_code"]
|
|
63
|
+
)
|
|
64
|
+
AGENT_INVOCATION_COUNT = _counter(
|
|
65
|
+
"agentomatic_agent_invocations_total", "Agent invocations", ["agent_name", "status"]
|
|
66
|
+
)
|
|
67
|
+
ERROR_COUNT = _counter("agentomatic_errors_total", "Errors", ["error_type", "agent_name"])
|
|
68
|
+
|
|
69
|
+
# Histograms
|
|
70
|
+
REQUEST_DURATION = _histogram(
|
|
71
|
+
"agentomatic_request_duration_seconds", "Request duration", ["method", "endpoint"]
|
|
72
|
+
)
|
|
73
|
+
AGENT_DURATION = _histogram("agentomatic_agent_duration_seconds", "Agent duration", ["agent_name"])
|
|
74
|
+
LLM_DURATION = _histogram(
|
|
75
|
+
"agentomatic_llm_duration_seconds", "LLM call duration", ["provider", "model"]
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Gauges
|
|
79
|
+
ACTIVE_REQUESTS = _gauge("agentomatic_active_requests", "Active requests")
|
|
80
|
+
ACTIVE_AGENTS = _gauge("agentomatic_active_agents", "Active agent invocations")
|
|
81
|
+
REGISTERED_AGENTS = _gauge("agentomatic_registered_agents", "Registered agents")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@asynccontextmanager
|
|
85
|
+
async def track_agent_invocation(agent_name: str):
|
|
86
|
+
"""Context manager to track agent invocation metrics."""
|
|
87
|
+
import time
|
|
88
|
+
|
|
89
|
+
ACTIVE_AGENTS.inc()
|
|
90
|
+
AGENT_INVOCATION_COUNT.labels(agent_name=agent_name, status="started").inc()
|
|
91
|
+
t0 = time.perf_counter()
|
|
92
|
+
try:
|
|
93
|
+
yield
|
|
94
|
+
AGENT_INVOCATION_COUNT.labels(agent_name=agent_name, status="success").inc()
|
|
95
|
+
except Exception:
|
|
96
|
+
AGENT_INVOCATION_COUNT.labels(agent_name=agent_name, status="error").inc()
|
|
97
|
+
raise
|
|
98
|
+
finally:
|
|
99
|
+
duration = time.perf_counter() - t0
|
|
100
|
+
AGENT_DURATION.labels(agent_name=agent_name).observe(duration)
|
|
101
|
+
ACTIVE_AGENTS.dec()
|