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.
Files changed (55) hide show
  1. agentomatic/__init__.py +59 -0
  2. agentomatic/_version.py +5 -0
  3. agentomatic/cli/__init__.py +7 -0
  4. agentomatic/cli/commands.py +715 -0
  5. agentomatic/cli/templates.py +188 -0
  6. agentomatic/config/__init__.py +3 -0
  7. agentomatic/config/defaults.py +10 -0
  8. agentomatic/config/settings.py +117 -0
  9. agentomatic/core/__init__.py +31 -0
  10. agentomatic/core/lifespan.py +102 -0
  11. agentomatic/core/manifest.py +100 -0
  12. agentomatic/core/platform.py +571 -0
  13. agentomatic/core/registry.py +198 -0
  14. agentomatic/core/router_factory.py +541 -0
  15. agentomatic/core/state.py +63 -0
  16. agentomatic/middleware/__init__.py +18 -0
  17. agentomatic/middleware/auth.py +53 -0
  18. agentomatic/middleware/feedback.py +207 -0
  19. agentomatic/middleware/logging.py +40 -0
  20. agentomatic/middleware/metrics.py +93 -0
  21. agentomatic/middleware/rate_limit.py +70 -0
  22. agentomatic/observability/__init__.py +11 -0
  23. agentomatic/observability/concurrency.py +109 -0
  24. agentomatic/observability/metrics.py +101 -0
  25. agentomatic/observability/telemetry.py +316 -0
  26. agentomatic/optimize/__init__.py +112 -0
  27. agentomatic/optimize/dataset.py +142 -0
  28. agentomatic/optimize/loop.py +870 -0
  29. agentomatic/optimize/metrics.py +781 -0
  30. agentomatic/optimize/optimizer.py +891 -0
  31. agentomatic/optimize/report.py +774 -0
  32. agentomatic/optimize/runner.py +261 -0
  33. agentomatic/optimize/strategies.py +592 -0
  34. agentomatic/optimize/synthesizer.py +729 -0
  35. agentomatic/prompts/__init__.py +7 -0
  36. agentomatic/prompts/manager.py +59 -0
  37. agentomatic/protocols/__init__.py +3 -0
  38. agentomatic/protocols/decorators.py +75 -0
  39. agentomatic/providers/__init__.py +3 -0
  40. agentomatic/providers/embeddings.py +44 -0
  41. agentomatic/providers/llm.py +116 -0
  42. agentomatic/py.typed +1 -0
  43. agentomatic/storage/__init__.py +40 -0
  44. agentomatic/storage/base.py +192 -0
  45. agentomatic/storage/memory.py +167 -0
  46. agentomatic/storage/models.py +129 -0
  47. agentomatic/storage/sqlalchemy.py +317 -0
  48. agentomatic/ui/.chainlit/config.toml +14 -0
  49. agentomatic/ui/__init__.py +50 -0
  50. agentomatic/ui/chat.py +198 -0
  51. agentomatic-0.1.0.dist-info/METADATA +363 -0
  52. agentomatic-0.1.0.dist-info/RECORD +55 -0
  53. agentomatic-0.1.0.dist-info/WHEEL +4 -0
  54. agentomatic-0.1.0.dist-info/entry_points.txt +2 -0
  55. 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,11 @@
1
+ """Observability (metrics, health, concurrency, telemetry)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .telemetry import get_tracer, setup_telemetry, traced
6
+
7
+ __all__ = [
8
+ "get_tracer",
9
+ "setup_telemetry",
10
+ "traced",
11
+ ]
@@ -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()