agenttel 0.3.0a1__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.
- agenttel/__init__.py +13 -0
- agenttel/agent/__init__.py +18 -0
- agenttel/agent/auth.py +134 -0
- agenttel/agent/context.py +86 -0
- agenttel/agent/health.py +142 -0
- agenttel/agent/identity.py +104 -0
- agenttel/agent/incident.py +146 -0
- agenttel/agent/mcp_server.py +320 -0
- agenttel/agent/remediation.py +167 -0
- agenttel/agent/reporting.py +140 -0
- agenttel/agentic/__init__.py +5 -0
- agenttel/agentic/cost.py +54 -0
- agenttel/agentic/guardrail.py +85 -0
- agenttel/agentic/orchestration.py +160 -0
- agenttel/agentic/quality.py +129 -0
- agenttel/agentic/scopes.py +467 -0
- agenttel/agentic/tracer.py +161 -0
- agenttel/agentic_attributes.py +112 -0
- agenttel/anomaly/__init__.py +6 -0
- agenttel/anomaly/detector.py +59 -0
- agenttel/anomaly/patterns.py +68 -0
- agenttel/attributes.py +84 -0
- agenttel/baseline/__init__.py +13 -0
- agenttel/baseline/composite.py +31 -0
- agenttel/baseline/provider.py +42 -0
- agenttel/baseline/rolling.py +148 -0
- agenttel/causality/__init__.py +5 -0
- agenttel/causality/tracker.py +148 -0
- agenttel/config.py +197 -0
- agenttel/engine.py +161 -0
- agenttel/enums.py +198 -0
- agenttel/error/__init__.py +5 -0
- agenttel/error/classifier.py +71 -0
- agenttel/events.py +113 -0
- agenttel/fastapi/__init__.py +24 -0
- agenttel/fastapi/config.py +63 -0
- agenttel/fastapi/decorators.py +91 -0
- agenttel/fastapi/middleware.py +114 -0
- agenttel/genai/__init__.py +33 -0
- agenttel/genai/anthropic.py +145 -0
- agenttel/genai/attributes.py +43 -0
- agenttel/genai/bedrock.py +125 -0
- agenttel/genai/cost.py +91 -0
- agenttel/genai/langchain.py +168 -0
- agenttel/genai/openai.py +153 -0
- agenttel/genai/span_builder.py +127 -0
- agenttel/models.py +146 -0
- agenttel/processor.py +190 -0
- agenttel/slo/__init__.py +5 -0
- agenttel/slo/tracker.py +108 -0
- agenttel/topology/__init__.py +5 -0
- agenttel/topology/registry.py +86 -0
- agenttel-0.3.0a1.dist-info/METADATA +228 -0
- agenttel-0.3.0a1.dist-info/RECORD +56 -0
- agenttel-0.3.0a1.dist-info/WHEEL +4 -0
- agenttel-0.3.0a1.dist-info/entry_points.txt +2 -0
agenttel/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""AgentTel Python SDK — AI-native telemetry for Python services."""
|
|
2
|
+
|
|
3
|
+
from agenttel.config import AgentTelConfig
|
|
4
|
+
from agenttel.engine import AgentTelEngine
|
|
5
|
+
from agenttel.processor import AgentTelSpanProcessor
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AgentTelConfig",
|
|
9
|
+
"AgentTelEngine",
|
|
10
|
+
"AgentTelSpanProcessor",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
__version__ = "0.3.0-alpha"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Agent interface for AgentTel (MCP server, health, identity, incident)."""
|
|
2
|
+
|
|
3
|
+
from agenttel.agent.auth import (
|
|
4
|
+
AuthConfig,
|
|
5
|
+
ToolPermission,
|
|
6
|
+
ToolPermissionRegistry as AuthPermissionRegistry,
|
|
7
|
+
)
|
|
8
|
+
from agenttel.agent.health import ServiceHealthAggregator
|
|
9
|
+
from agenttel.agent.identity import AgentIdentity, ToolPermissionRegistry
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AuthConfig",
|
|
13
|
+
"AuthPermissionRegistry",
|
|
14
|
+
"ServiceHealthAggregator",
|
|
15
|
+
"AgentIdentity",
|
|
16
|
+
"ToolPermission",
|
|
17
|
+
"ToolPermissionRegistry",
|
|
18
|
+
]
|
agenttel/agent/auth.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""API key authentication and role-based permissions for the MCP server.
|
|
2
|
+
|
|
3
|
+
Opt-in: when no AuthConfig is provided, all checks are skipped
|
|
4
|
+
and the server behaves as before (backward compatible).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ToolPermission(Enum):
|
|
15
|
+
"""Permission levels for MCP tool access."""
|
|
16
|
+
|
|
17
|
+
READ = "read"
|
|
18
|
+
DIAGNOSE = "diagnose"
|
|
19
|
+
REMEDIATE = "remediate"
|
|
20
|
+
ADMIN = "admin"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class AgentIdentity:
|
|
25
|
+
"""Identifies an authenticated agent making MCP requests."""
|
|
26
|
+
|
|
27
|
+
agent_id: str
|
|
28
|
+
role: str
|
|
29
|
+
session_id: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class AuthConfig:
|
|
34
|
+
"""Configuration for API key authentication.
|
|
35
|
+
|
|
36
|
+
Provide either a static api_keys map, a custom validate_key function,
|
|
37
|
+
or both. When both are provided, the static map is checked first.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
api_keys: dict[str, AgentIdentity] | None = None
|
|
41
|
+
validate_key: Callable[[str], AgentIdentity | None] | None = None
|
|
42
|
+
|
|
43
|
+
def resolve_key(self, key: str) -> AgentIdentity | None:
|
|
44
|
+
"""Resolve a bearer token to an agent identity.
|
|
45
|
+
|
|
46
|
+
Returns None if the key is invalid or not found.
|
|
47
|
+
"""
|
|
48
|
+
trimmed = key.strip()
|
|
49
|
+
if not trimmed:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
if self.api_keys:
|
|
53
|
+
identity = self.api_keys.get(trimmed)
|
|
54
|
+
if identity is not None:
|
|
55
|
+
return identity
|
|
56
|
+
|
|
57
|
+
if self.validate_key:
|
|
58
|
+
return self.validate_key(trimmed)
|
|
59
|
+
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Default role-to-permission mappings
|
|
64
|
+
_DEFAULT_ROLE_PERMISSIONS: dict[str, set[ToolPermission]] = {
|
|
65
|
+
"observer": {ToolPermission.READ},
|
|
66
|
+
"diagnostician": {ToolPermission.READ, ToolPermission.DIAGNOSE},
|
|
67
|
+
"remediator": {ToolPermission.READ, ToolPermission.DIAGNOSE, ToolPermission.REMEDIATE},
|
|
68
|
+
"admin": {ToolPermission.READ, ToolPermission.DIAGNOSE, ToolPermission.REMEDIATE, ToolPermission.ADMIN},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Default tool-to-required-permission mappings
|
|
72
|
+
_DEFAULT_TOOL_PERMISSIONS: dict[str, ToolPermission] = {
|
|
73
|
+
# READ tools
|
|
74
|
+
"get_service_health": ToolPermission.READ,
|
|
75
|
+
"get_operation_baselines": ToolPermission.READ,
|
|
76
|
+
"get_anomalies": ToolPermission.READ,
|
|
77
|
+
"get_slo_status": ToolPermission.READ,
|
|
78
|
+
"get_dependency_health": ToolPermission.READ,
|
|
79
|
+
"get_topology": ToolPermission.READ,
|
|
80
|
+
"get_incident_context": ToolPermission.READ,
|
|
81
|
+
"get_error_classification": ToolPermission.READ,
|
|
82
|
+
"get_deployment_info": ToolPermission.READ,
|
|
83
|
+
"get_slo_report": ToolPermission.READ,
|
|
84
|
+
"get_executive_summary": ToolPermission.READ,
|
|
85
|
+
"get_trend_analysis": ToolPermission.READ,
|
|
86
|
+
"get_playbook": ToolPermission.READ,
|
|
87
|
+
"get_session": ToolPermission.READ,
|
|
88
|
+
# DIAGNOSE tools
|
|
89
|
+
"verify_remediation_effect": ToolPermission.DIAGNOSE,
|
|
90
|
+
"create_session": ToolPermission.DIAGNOSE,
|
|
91
|
+
"add_session_entry": ToolPermission.DIAGNOSE,
|
|
92
|
+
"suggest_remediation": ToolPermission.DIAGNOSE,
|
|
93
|
+
"get_change_correlation": ToolPermission.DIAGNOSE,
|
|
94
|
+
# REMEDIATE tools
|
|
95
|
+
"execute_remediation": ToolPermission.REMEDIATE,
|
|
96
|
+
"list_remediation_actions": ToolPermission.REMEDIATE,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ToolPermissionRegistry:
|
|
101
|
+
"""Maps roles to granted permissions and tools to required permission levels.
|
|
102
|
+
|
|
103
|
+
Pre-populated with sensible defaults:
|
|
104
|
+
observer -> [READ]
|
|
105
|
+
diagnostician -> [READ, DIAGNOSE]
|
|
106
|
+
remediator -> [READ, DIAGNOSE, REMEDIATE]
|
|
107
|
+
admin -> [READ, DIAGNOSE, REMEDIATE, ADMIN]
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self) -> None:
|
|
111
|
+
self._role_permissions: dict[str, set[ToolPermission]] = {
|
|
112
|
+
k: set(v) for k, v in _DEFAULT_ROLE_PERMISSIONS.items()
|
|
113
|
+
}
|
|
114
|
+
self._tool_permissions: dict[str, ToolPermission] = dict(_DEFAULT_TOOL_PERMISSIONS)
|
|
115
|
+
|
|
116
|
+
def set_role_permissions(self, role: str, perms: list[ToolPermission]) -> None:
|
|
117
|
+
"""Set the permissions granted to a role, replacing any existing mapping."""
|
|
118
|
+
self._role_permissions[role.lower()] = set(perms)
|
|
119
|
+
|
|
120
|
+
def set_tool_permission(self, tool_name: str, perm: ToolPermission) -> None:
|
|
121
|
+
"""Set the required permission level for a specific tool."""
|
|
122
|
+
self._tool_permissions[tool_name] = perm
|
|
123
|
+
|
|
124
|
+
def is_allowed(self, identity: AgentIdentity, tool_name: str) -> bool:
|
|
125
|
+
"""Check whether the given identity has permission to invoke the named tool.
|
|
126
|
+
|
|
127
|
+
Unknown tools default to READ. Unknown roles get observer permissions.
|
|
128
|
+
"""
|
|
129
|
+
role = identity.role.lower()
|
|
130
|
+
granted = self._role_permissions.get(
|
|
131
|
+
role, self._role_permissions.get("observer", {ToolPermission.READ})
|
|
132
|
+
)
|
|
133
|
+
required = self._tool_permissions.get(tool_name, ToolPermission.READ)
|
|
134
|
+
return required in granted
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Agent context provider for LLM consumption."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from agenttel.agent.health import ServiceHealthAggregator
|
|
8
|
+
from agenttel.anomaly.detector import AnomalyDetector
|
|
9
|
+
from agenttel.baseline.composite import CompositeBaselineProvider
|
|
10
|
+
from agenttel.causality.tracker import CausalityTracker
|
|
11
|
+
from agenttel.slo.tracker import SloTracker
|
|
12
|
+
from agenttel.topology.registry import TopologyRegistry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AgentContextProvider:
|
|
16
|
+
"""Provides structured context for AI agent consumption."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
topology: TopologyRegistry,
|
|
21
|
+
health_aggregator: ServiceHealthAggregator,
|
|
22
|
+
baseline_provider: CompositeBaselineProvider,
|
|
23
|
+
anomaly_detector: AnomalyDetector,
|
|
24
|
+
slo_tracker: SloTracker,
|
|
25
|
+
causality_tracker: CausalityTracker,
|
|
26
|
+
) -> None:
|
|
27
|
+
self._topology = topology
|
|
28
|
+
self._health = health_aggregator
|
|
29
|
+
self._baselines = baseline_provider
|
|
30
|
+
self._anomaly = anomaly_detector
|
|
31
|
+
self._slo = slo_tracker
|
|
32
|
+
self._causality = causality_tracker
|
|
33
|
+
|
|
34
|
+
def get_full_context(self) -> dict[str, Any]:
|
|
35
|
+
"""Get full service context for incident diagnosis."""
|
|
36
|
+
summary = self._health.get_summary()
|
|
37
|
+
return {
|
|
38
|
+
"service": {
|
|
39
|
+
"name": summary.service_name,
|
|
40
|
+
"status": summary.status,
|
|
41
|
+
"team": self._topology.team,
|
|
42
|
+
"tier": self._topology.tier,
|
|
43
|
+
"domain": self._topology.domain,
|
|
44
|
+
"on_call_channel": self._topology.on_call_channel,
|
|
45
|
+
},
|
|
46
|
+
"operations": [
|
|
47
|
+
{
|
|
48
|
+
"name": op.operation_name,
|
|
49
|
+
"request_count": op.request_count,
|
|
50
|
+
"error_count": op.error_count,
|
|
51
|
+
"error_rate": op.error_count / op.request_count
|
|
52
|
+
if op.request_count > 0
|
|
53
|
+
else 0,
|
|
54
|
+
"latency_p50_ms": op.latency_p50_ms,
|
|
55
|
+
"latency_p99_ms": op.latency_p99_ms,
|
|
56
|
+
}
|
|
57
|
+
for op in summary.operations
|
|
58
|
+
],
|
|
59
|
+
"dependencies": [
|
|
60
|
+
{
|
|
61
|
+
"name": d.name,
|
|
62
|
+
"type": d.type.value,
|
|
63
|
+
"criticality": d.criticality.value,
|
|
64
|
+
"state": d.state.value,
|
|
65
|
+
"latency_ms": d.latency_ms,
|
|
66
|
+
"error_rate": d.error_rate,
|
|
67
|
+
}
|
|
68
|
+
for d in summary.dependencies
|
|
69
|
+
],
|
|
70
|
+
"slo_statuses": [
|
|
71
|
+
{
|
|
72
|
+
"name": s.name,
|
|
73
|
+
"target": s.target,
|
|
74
|
+
"budget_remaining": s.budget_remaining,
|
|
75
|
+
"burn_rate": s.burn_rate,
|
|
76
|
+
"alert_level": s.alert_level.value,
|
|
77
|
+
}
|
|
78
|
+
for s in self._slo.get_all_statuses()
|
|
79
|
+
],
|
|
80
|
+
"causality": None,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Add causal chain if available
|
|
84
|
+
chain = self._causality.build_causal_chain()
|
|
85
|
+
if chain:
|
|
86
|
+
pass
|
agenttel/agent/health.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Service health aggregation from span data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections import deque
|
|
8
|
+
|
|
9
|
+
from agenttel.enums import DependencyState
|
|
10
|
+
from agenttel.models import (
|
|
11
|
+
DependencyHealth,
|
|
12
|
+
OperationStats,
|
|
13
|
+
ServiceHealthSummary,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ServiceHealthAggregator:
|
|
18
|
+
"""Aggregates per-operation and dependency health from span data.
|
|
19
|
+
|
|
20
|
+
Called from the SpanProcessor on_end callback.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, service_name: str = "", window_size: int = 1000) -> None:
|
|
24
|
+
self._service_name = service_name
|
|
25
|
+
self._window_size = window_size
|
|
26
|
+
self._lock = threading.Lock()
|
|
27
|
+
|
|
28
|
+
# Per-operation tracking
|
|
29
|
+
self._op_latencies: dict[str, deque[float]] = {}
|
|
30
|
+
self._op_request_count: dict[str, int] = {}
|
|
31
|
+
self._op_error_count: dict[str, int] = {}
|
|
32
|
+
|
|
33
|
+
# Dependency tracking
|
|
34
|
+
self._dep_health: dict[str, DependencyHealth] = {}
|
|
35
|
+
|
|
36
|
+
def record_operation(
|
|
37
|
+
self,
|
|
38
|
+
operation_name: str,
|
|
39
|
+
latency_ms: float,
|
|
40
|
+
is_error: bool = False,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Record an operation execution."""
|
|
43
|
+
with self._lock:
|
|
44
|
+
if operation_name not in self._op_latencies:
|
|
45
|
+
self._op_latencies[operation_name] = deque(maxlen=self._window_size)
|
|
46
|
+
self._op_request_count[operation_name] = 0
|
|
47
|
+
self._op_error_count[operation_name] = 0
|
|
48
|
+
|
|
49
|
+
self._op_latencies[operation_name].append(latency_ms)
|
|
50
|
+
self._op_request_count[operation_name] += 1
|
|
51
|
+
if is_error:
|
|
52
|
+
self._op_error_count[operation_name] += 1
|
|
53
|
+
|
|
54
|
+
def record_dependency_health(self, health: DependencyHealth) -> None:
|
|
55
|
+
"""Update dependency health status."""
|
|
56
|
+
with self._lock:
|
|
57
|
+
self._dep_health[health.name] = health
|
|
58
|
+
|
|
59
|
+
def update_dependency_state(
|
|
60
|
+
self,
|
|
61
|
+
name: str,
|
|
62
|
+
state: DependencyState,
|
|
63
|
+
latency_ms: float = 0.0,
|
|
64
|
+
error_rate: float = 0.0,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Update a dependency's state."""
|
|
67
|
+
with self._lock:
|
|
68
|
+
existing = self._dep_health.get(name)
|
|
69
|
+
if existing:
|
|
70
|
+
existing.state = state
|
|
71
|
+
existing.latency_ms = latency_ms
|
|
72
|
+
existing.error_rate = error_rate
|
|
73
|
+
existing.last_check = time.time()
|
|
74
|
+
|
|
75
|
+
def get_summary(self) -> ServiceHealthSummary:
|
|
76
|
+
"""Get the current service health summary."""
|
|
77
|
+
with self._lock:
|
|
78
|
+
operations = []
|
|
79
|
+
for op_name in self._op_latencies:
|
|
80
|
+
latencies = sorted(self._op_latencies[op_name])
|
|
81
|
+
n = len(latencies)
|
|
82
|
+
if n == 0:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
mean = sum(latencies) / n
|
|
86
|
+
operations.append(
|
|
87
|
+
OperationStats(
|
|
88
|
+
operation_name=op_name,
|
|
89
|
+
request_count=self._op_request_count.get(op_name, 0),
|
|
90
|
+
error_count=self._op_error_count.get(op_name, 0),
|
|
91
|
+
latency_p50_ms=_percentile(latencies, 0.50),
|
|
92
|
+
latency_p95_ms=_percentile(latencies, 0.95),
|
|
93
|
+
latency_p99_ms=_percentile(latencies, 0.99),
|
|
94
|
+
latency_mean_ms=mean,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
dependencies = list(self._dep_health.values())
|
|
99
|
+
|
|
100
|
+
# Determine overall status
|
|
101
|
+
status = "HEALTHY"
|
|
102
|
+
has_errors = any(
|
|
103
|
+
op.error_count > op.request_count * 0.1
|
|
104
|
+
for op in operations
|
|
105
|
+
if op.request_count > 0
|
|
106
|
+
)
|
|
107
|
+
has_failing_deps = any(
|
|
108
|
+
d.state == DependencyState.FAILING for d in dependencies
|
|
109
|
+
)
|
|
110
|
+
if has_failing_deps or has_errors:
|
|
111
|
+
status = "CRITICAL"
|
|
112
|
+
elif any(d.state == DependencyState.DEGRADED for d in dependencies):
|
|
113
|
+
status = "DEGRADED"
|
|
114
|
+
|
|
115
|
+
return ServiceHealthSummary(
|
|
116
|
+
service_name=self._service_name,
|
|
117
|
+
status=status,
|
|
118
|
+
operations=operations,
|
|
119
|
+
dependencies=dependencies,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def reset(self) -> None:
|
|
123
|
+
"""Reset all aggregated data."""
|
|
124
|
+
with self._lock:
|
|
125
|
+
self._op_latencies.clear()
|
|
126
|
+
self._op_request_count.clear()
|
|
127
|
+
self._op_error_count.clear()
|
|
128
|
+
self._dep_health.clear()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _percentile(sorted_data: list[float], p: float) -> float:
|
|
132
|
+
"""Calculate percentile using linear interpolation."""
|
|
133
|
+
n = len(sorted_data)
|
|
134
|
+
if n == 0:
|
|
135
|
+
return 0.0
|
|
136
|
+
if n == 1:
|
|
137
|
+
return sorted_data[0]
|
|
138
|
+
idx = p * (n - 1)
|
|
139
|
+
lower = int(idx)
|
|
140
|
+
upper = min(lower + 1, n - 1)
|
|
141
|
+
frac = idx - lower
|
|
142
|
+
return sorted_data[lower] + frac * (sorted_data[upper] - sorted_data[lower])
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Agent identity and permission management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from agenttel.enums import AgentRole
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class AgentIdentity:
|
|
14
|
+
"""Represents an AI agent's identity for MCP access."""
|
|
15
|
+
|
|
16
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
17
|
+
role: AgentRole = AgentRole.OBSERVER
|
|
18
|
+
session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
19
|
+
name: str = ""
|
|
20
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_headers(cls, headers: dict[str, str]) -> AgentIdentity:
|
|
24
|
+
"""Extract agent identity from HTTP request headers."""
|
|
25
|
+
return cls(
|
|
26
|
+
id=headers.get("x-agent-id", str(uuid.uuid4())),
|
|
27
|
+
role=AgentRole(headers.get("x-agent-role", "observer")),
|
|
28
|
+
session_id=headers.get("x-agent-session-id", str(uuid.uuid4())),
|
|
29
|
+
name=headers.get("x-agent-name", ""),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Default tool permissions per role
|
|
34
|
+
_DEFAULT_PERMISSIONS: dict[AgentRole, set[str]] = {
|
|
35
|
+
AgentRole.OBSERVER: {
|
|
36
|
+
"get_service_health",
|
|
37
|
+
"get_operation_baselines",
|
|
38
|
+
"get_anomalies",
|
|
39
|
+
"get_slo_status",
|
|
40
|
+
"get_dependency_health",
|
|
41
|
+
"get_topology",
|
|
42
|
+
"get_error_classification",
|
|
43
|
+
"get_deployment_info",
|
|
44
|
+
},
|
|
45
|
+
AgentRole.DIAGNOSTICIAN: {
|
|
46
|
+
"get_service_health",
|
|
47
|
+
"get_operation_baselines",
|
|
48
|
+
"get_anomalies",
|
|
49
|
+
"get_slo_status",
|
|
50
|
+
"get_dependency_health",
|
|
51
|
+
"get_topology",
|
|
52
|
+
"get_incident_context",
|
|
53
|
+
"get_error_classification",
|
|
54
|
+
"get_deployment_info",
|
|
55
|
+
"get_change_correlation",
|
|
56
|
+
"get_slo_report",
|
|
57
|
+
"get_trend_analysis",
|
|
58
|
+
"get_executive_summary",
|
|
59
|
+
"suggest_remediation",
|
|
60
|
+
},
|
|
61
|
+
AgentRole.REMEDIATOR: {
|
|
62
|
+
"get_service_health",
|
|
63
|
+
"get_operation_baselines",
|
|
64
|
+
"get_anomalies",
|
|
65
|
+
"get_slo_status",
|
|
66
|
+
"get_dependency_health",
|
|
67
|
+
"get_topology",
|
|
68
|
+
"get_incident_context",
|
|
69
|
+
"get_error_classification",
|
|
70
|
+
"get_deployment_info",
|
|
71
|
+
"get_change_correlation",
|
|
72
|
+
"get_slo_report",
|
|
73
|
+
"get_trend_analysis",
|
|
74
|
+
"get_executive_summary",
|
|
75
|
+
"suggest_remediation",
|
|
76
|
+
"execute_remediation",
|
|
77
|
+
},
|
|
78
|
+
AgentRole.ADMIN: set(), # Empty = all tools allowed
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ToolPermissionRegistry:
|
|
83
|
+
"""Manages role-based tool access for MCP."""
|
|
84
|
+
|
|
85
|
+
def __init__(self) -> None:
|
|
86
|
+
self._permissions: dict[AgentRole, set[str]] = dict(_DEFAULT_PERMISSIONS)
|
|
87
|
+
|
|
88
|
+
def is_allowed(self, role: AgentRole, tool_name: str) -> bool:
|
|
89
|
+
"""Check if a role has permission to use a tool."""
|
|
90
|
+
allowed = self._permissions.get(role)
|
|
91
|
+
if allowed is not None and len(allowed) == 0:
|
|
92
|
+
return True # Empty set for admin = all allowed
|
|
93
|
+
return tool_name in (allowed or set())
|
|
94
|
+
|
|
95
|
+
def grant(self, role: AgentRole, tool_name: str) -> None:
|
|
96
|
+
"""Grant a tool permission to a role."""
|
|
97
|
+
if role not in self._permissions:
|
|
98
|
+
self._permissions[role] = set()
|
|
99
|
+
self._permissions[role].add(tool_name)
|
|
100
|
+
|
|
101
|
+
def revoke(self, role: AgentRole, tool_name: str) -> None:
|
|
102
|
+
"""Revoke a tool permission from a role."""
|
|
103
|
+
if role in self._permissions:
|
|
104
|
+
self._permissions[role].discard(tool_name)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Incident context builder for AI agent diagnosis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from agenttel.agent.health import ServiceHealthAggregator
|
|
10
|
+
from agenttel.causality.tracker import CausalityTracker
|
|
11
|
+
from agenttel.models import AnomalyResult, SloStatus
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class IncidentContext:
|
|
16
|
+
"""Full incident context for AI agent consumption."""
|
|
17
|
+
|
|
18
|
+
service_name: str
|
|
19
|
+
status: str
|
|
20
|
+
timestamp: float = field(default_factory=time.time)
|
|
21
|
+
anomalies: list[dict[str, Any]] = field(default_factory=list)
|
|
22
|
+
slo_violations: list[dict[str, Any]] = field(default_factory=list)
|
|
23
|
+
failing_dependencies: list[str] = field(default_factory=list)
|
|
24
|
+
causal_chain: dict[str, Any] | None = None
|
|
25
|
+
recent_changes: list[dict[str, Any]] = field(default_factory=list)
|
|
26
|
+
suggested_actions: list[str] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class IncidentContextBuilder:
|
|
30
|
+
"""Builds comprehensive incident context from system state."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
health_aggregator: ServiceHealthAggregator,
|
|
35
|
+
causality_tracker: CausalityTracker,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._health = health_aggregator
|
|
38
|
+
self._causality = causality_tracker
|
|
39
|
+
|
|
40
|
+
def build(
|
|
41
|
+
self,
|
|
42
|
+
anomalies: list[AnomalyResult] | None = None,
|
|
43
|
+
slo_statuses: list[SloStatus] | None = None,
|
|
44
|
+
) -> IncidentContext:
|
|
45
|
+
"""Build incident context from current state."""
|
|
46
|
+
summary = self._health.get_summary()
|
|
47
|
+
|
|
48
|
+
# Build anomaly list
|
|
49
|
+
anomaly_list = []
|
|
50
|
+
if anomalies:
|
|
51
|
+
for a in anomalies:
|
|
52
|
+
if a.is_anomaly:
|
|
53
|
+
anomaly_list.append({
|
|
54
|
+
"score": a.score,
|
|
55
|
+
"z_score": a.z_score,
|
|
56
|
+
"pattern": a.pattern.value if a.pattern else "unknown",
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
# Build SLO violations
|
|
60
|
+
slo_violations = []
|
|
61
|
+
if slo_statuses:
|
|
62
|
+
for s in slo_statuses:
|
|
63
|
+
if s.alert_level.value != "ok":
|
|
64
|
+
slo_violations.append({
|
|
65
|
+
"name": s.name,
|
|
66
|
+
"target": s.target,
|
|
67
|
+
"budget_remaining": s.budget_remaining,
|
|
68
|
+
"burn_rate": s.burn_rate,
|
|
69
|
+
"alert_level": s.alert_level.value,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
# Get failing dependencies
|
|
73
|
+
failing = self._causality.get_failing_dependencies()
|
|
74
|
+
|
|
75
|
+
# Get causal chain
|
|
76
|
+
chain = self._causality.build_causal_chain()
|
|
77
|
+
causal_data = None
|
|
78
|
+
if chain:
|
|
79
|
+
causal_data = {
|
|
80
|
+
"root_cause": chain.root_cause,
|
|
81
|
+
"cause_category": chain.cause_category.value,
|
|
82
|
+
"affected_dependencies": chain.affected_dependencies,
|
|
83
|
+
"business_impact": chain.business_impact.value,
|
|
84
|
+
"impact_scope": chain.impact_scope.value,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return IncidentContext(
|
|
88
|
+
service_name=summary.service_name,
|
|
89
|
+
status=summary.status,
|
|
90
|
+
anomalies=anomaly_list,
|
|
91
|
+
slo_violations=slo_violations,
|
|
92
|
+
failing_dependencies=failing,
|
|
93
|
+
causal_chain=causal_data,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ContextFormatter:
|
|
98
|
+
"""Formats incident context as structured text for LLM consumption."""
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def format_for_llm(context: IncidentContext) -> str:
|
|
102
|
+
"""Format incident context as readable text for LLM."""
|
|
103
|
+
lines = [
|
|
104
|
+
f"# Incident Context: {context.service_name}",
|
|
105
|
+
f"Status: {context.status}",
|
|
106
|
+
f"Timestamp: {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(context.timestamp))}",
|
|
107
|
+
"",
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
if context.anomalies:
|
|
111
|
+
lines.append("## Anomalies Detected")
|
|
112
|
+
for a in context.anomalies:
|
|
113
|
+
lines.append(f"- Pattern: {a['pattern']}, Score: {a['score']:.2f}, Z-Score: {a['z_score']:.2f}")
|
|
114
|
+
lines.append("")
|
|
115
|
+
|
|
116
|
+
if context.slo_violations:
|
|
117
|
+
lines.append("## SLO Violations")
|
|
118
|
+
for s in context.slo_violations:
|
|
119
|
+
lines.append(
|
|
120
|
+
f"- {s['name']}: target={s['target']}, "
|
|
121
|
+
f"budget_remaining={s['budget_remaining']:.1%}, "
|
|
122
|
+
f"alert={s['alert_level']}"
|
|
123
|
+
)
|
|
124
|
+
lines.append("")
|
|
125
|
+
|
|
126
|
+
if context.failing_dependencies:
|
|
127
|
+
lines.append("## Failing Dependencies")
|
|
128
|
+
for dep in context.failing_dependencies:
|
|
129
|
+
lines.append(f"- {dep}")
|
|
130
|
+
lines.append("")
|
|
131
|
+
|
|
132
|
+
if context.causal_chain:
|
|
133
|
+
c = context.causal_chain
|
|
134
|
+
lines.append("## Causal Analysis")
|
|
135
|
+
lines.append(f"Root Cause: {c['root_cause']}")
|
|
136
|
+
lines.append(f"Category: {c['cause_category']}")
|
|
137
|
+
lines.append(f"Impact: {c['business_impact']} ({c['impact_scope']})")
|
|
138
|
+
lines.append(f"Affected: {', '.join(c['affected_dependencies'])}")
|
|
139
|
+
lines.append("")
|
|
140
|
+
|
|
141
|
+
if context.suggested_actions:
|
|
142
|
+
lines.append("## Suggested Actions")
|
|
143
|
+
for i, action in enumerate(context.suggested_actions, 1):
|
|
144
|
+
lines.append(f"{i}. {action}")
|
|
145
|
+
|
|
146
|
+
return "\n".join(lines)
|