agentops-accelerator 0.3.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.
- agentops/__init__.py +10 -0
- agentops/__main__.py +6 -0
- agentops/agent/__init__.py +12 -0
- agentops/agent/_legacy_ids.py +92 -0
- agentops/agent/analyzer.py +207 -0
- agentops/agent/checks/__init__.py +1 -0
- agentops/agent/checks/catalog.py +880 -0
- agentops/agent/checks/errors.py +279 -0
- agentops/agent/checks/foundry_config.py +75 -0
- agentops/agent/checks/latency.py +84 -0
- agentops/agent/checks/opex.py +157 -0
- agentops/agent/checks/opex_workspace.py +874 -0
- agentops/agent/checks/posture.py +36 -0
- agentops/agent/checks/posture_rules/__init__.py +53 -0
- agentops/agent/checks/posture_rules/content_filter.py +59 -0
- agentops/agent/checks/posture_rules/diagnostics.py +74 -0
- agentops/agent/checks/posture_rules/local_auth.py +55 -0
- agentops/agent/checks/posture_rules/managed_identity.py +59 -0
- agentops/agent/checks/posture_rules/network.py +68 -0
- agentops/agent/checks/regression.py +78 -0
- agentops/agent/checks/release_readiness.py +182 -0
- agentops/agent/checks/safety.py +247 -0
- agentops/agent/checks/spec_conformance.py +375 -0
- agentops/agent/cockpit.py +5159 -0
- agentops/agent/config.py +240 -0
- agentops/agent/findings.py +113 -0
- agentops/agent/history.py +142 -0
- agentops/agent/knowledge/__init__.py +182 -0
- agentops/agent/knowledge/waf-checklist.csv +39 -0
- agentops/agent/llm_assist/__init__.py +16 -0
- agentops/agent/llm_assist/_base.py +124 -0
- agentops/agent/llm_assist/_bundle_rule.py +154 -0
- agentops/agent/llm_assist/_client.py +347 -0
- agentops/agent/llm_assist/_dataset_rules.py +191 -0
- agentops/agent/llm_assist/_engine.py +106 -0
- agentops/agent/llm_assist/_prompt_rules.py +291 -0
- agentops/agent/llm_assist/_spec_rules.py +235 -0
- agentops/agent/production_telemetry.py +430 -0
- agentops/agent/report.py +207 -0
- agentops/agent/server/__init__.py +1 -0
- agentops/agent/server/app.py +84 -0
- agentops/agent/server/auth.py +94 -0
- agentops/agent/server/chat.py +44 -0
- agentops/agent/server/protocol.py +72 -0
- agentops/agent/sources/__init__.py +1 -0
- agentops/agent/sources/azure_monitor.py +523 -0
- agentops/agent/sources/azure_resources.py +602 -0
- agentops/agent/sources/foundry_control.py +174 -0
- agentops/agent/sources/results_history.py +494 -0
- agentops/agent/sources/spec_detectors/__init__.py +42 -0
- agentops/agent/sources/spec_detectors/_base.py +58 -0
- agentops/agent/sources/spec_detectors/agents_md.py +75 -0
- agentops/agent/sources/spec_detectors/spec_kit.py +172 -0
- agentops/agent/time_range.py +117 -0
- agentops/cli/__init__.py +1 -0
- agentops/cli/app.py +4823 -0
- agentops/core/__init__.py +1 -0
- agentops/core/agentops_config.py +592 -0
- agentops/core/config_loader.py +22 -0
- agentops/core/evaluators.py +480 -0
- agentops/core/release_evidence.py +56 -0
- agentops/core/results.py +117 -0
- agentops/mcp/__init__.py +10 -0
- agentops/mcp/server.py +232 -0
- agentops/pipeline/__init__.py +8 -0
- agentops/pipeline/cloud_results.py +189 -0
- agentops/pipeline/cloud_runner.py +901 -0
- agentops/pipeline/comparison.py +108 -0
- agentops/pipeline/diagnostics.py +51 -0
- agentops/pipeline/invocations.py +535 -0
- agentops/pipeline/official_eval.py +414 -0
- agentops/pipeline/orchestrator.py +775 -0
- agentops/pipeline/prompt_deploy.py +377 -0
- agentops/pipeline/publisher.py +121 -0
- agentops/pipeline/reporter.py +202 -0
- agentops/pipeline/runtime.py +409 -0
- agentops/pipeline/thresholds.py +84 -0
- agentops/services/__init__.py +1 -0
- agentops/services/cicd.py +720 -0
- agentops/services/eval_analysis.py +848 -0
- agentops/services/evidence_pack.py +757 -0
- agentops/services/initializer.py +86 -0
- agentops/services/preflight.py +470 -0
- agentops/services/setup_wizard.py +709 -0
- agentops/services/skills.py +643 -0
- agentops/services/trace_promotion.py +300 -0
- agentops/services/workflow_analysis.py +1129 -0
- agentops/templates/.gitignore +15 -0
- agentops/templates/__init__.py +1 -0
- agentops/templates/agent-server/Dockerfile +23 -0
- agentops/templates/agent-server/README.md +61 -0
- agentops/templates/agent-server/main.bicep +94 -0
- agentops/templates/agent.yaml +87 -0
- agentops/templates/agentops.yaml +58 -0
- agentops/templates/foundry.svg +71 -0
- agentops/templates/icon.png +0 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-dev-azd.yml +118 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-dev.yml +73 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-prod-azd.yml +141 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-prod.yml +94 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-prompt-agent.yml +167 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-qa-azd.yml +118 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-qa.yml +68 -0
- agentops/templates/pipelines/azuredevops/agentops-pr-prompt-agent.yml +210 -0
- agentops/templates/pipelines/azuredevops/agentops-pr.yml +155 -0
- agentops/templates/pipelines/azuredevops/agentops-watchdog.yml +106 -0
- agentops/templates/project.gitignore +36 -0
- agentops/templates/sample-traces.jsonl +3 -0
- agentops/templates/skills/agentops-agent/SKILL.md +137 -0
- agentops/templates/skills/agentops-config/SKILL.md +113 -0
- agentops/templates/skills/agentops-dataset/SKILL.md +84 -0
- agentops/templates/skills/agentops-eval/SKILL.md +189 -0
- agentops/templates/skills/agentops-report/SKILL.md +71 -0
- agentops/templates/skills/agentops-workflow/SKILL.md +471 -0
- agentops/templates/smoke.jsonl +3 -0
- agentops/templates/waf-checklist.README.md +84 -0
- agentops/templates/waf-checklist.csv +22 -0
- agentops/templates/workflows/agentops-deploy-dev-azd.yml +166 -0
- agentops/templates/workflows/agentops-deploy-dev.yml +187 -0
- agentops/templates/workflows/agentops-deploy-prod-azd.yml +183 -0
- agentops/templates/workflows/agentops-deploy-prod.yml +171 -0
- agentops/templates/workflows/agentops-deploy-prompt-agent.yml +197 -0
- agentops/templates/workflows/agentops-deploy-qa-azd.yml +156 -0
- agentops/templates/workflows/agentops-deploy-qa.yml +145 -0
- agentops/templates/workflows/agentops-pr-prompt-agent.yml +210 -0
- agentops/templates/workflows/agentops-pr.yml +148 -0
- agentops/templates/workflows/agentops-watchdog.yml +122 -0
- agentops/utils/__init__.py +1 -0
- agentops/utils/azd_env.py +435 -0
- agentops/utils/azure_endpoints.py +62 -0
- agentops/utils/colors.py +47 -0
- agentops/utils/dotenv_loader.py +105 -0
- agentops/utils/foundry_discovery.py +229 -0
- agentops/utils/logging.py +59 -0
- agentops/utils/telemetry.py +554 -0
- agentops/utils/yaml.py +36 -0
- agentops_accelerator-0.3.0.dist-info/METADATA +278 -0
- agentops_accelerator-0.3.0.dist-info/RECORD +142 -0
- agentops_accelerator-0.3.0.dist-info/WHEEL +5 -0
- agentops_accelerator-0.3.0.dist-info/entry_points.txt +2 -0
- agentops_accelerator-0.3.0.dist-info/licenses/LICENSE +21 -0
- agentops_accelerator-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Foundry control-plane source.
|
|
2
|
+
|
|
3
|
+
Lazy-imports ``azure.ai.projects`` to read agent metadata and recent
|
|
4
|
+
runs. Fails open: missing config or SDK is reported via diagnostics.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from agentops.agent.config import FoundryControlSourceConfig
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class FoundryAgentSummary:
|
|
21
|
+
agent_id: str
|
|
22
|
+
name: Optional[str] = None
|
|
23
|
+
model: Optional[str] = None
|
|
24
|
+
updated_at: Optional[str] = None
|
|
25
|
+
instructions: Optional[str] = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class EvaluationRuleSummary:
|
|
30
|
+
"""Continuous evaluation rule attached to a Foundry agent.
|
|
31
|
+
|
|
32
|
+
The Foundry SDK exposes ``evaluation_rules`` (online evaluation
|
|
33
|
+
policies) but does **not** today expose a dedicated "Guardrails"
|
|
34
|
+
config API. Content-filter posture is therefore observed via the
|
|
35
|
+
``azure_resources`` source instead.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
rule_id: str
|
|
39
|
+
name: Optional[str] = None
|
|
40
|
+
agent: Optional[str] = None
|
|
41
|
+
enabled: Optional[bool] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class FoundryControlPayload:
|
|
46
|
+
agents: List[FoundryAgentSummary] = field(default_factory=list)
|
|
47
|
+
evaluation_rules: List[EvaluationRuleSummary] = field(default_factory=list)
|
|
48
|
+
failed_runs: int = 0
|
|
49
|
+
total_runs: int = 0
|
|
50
|
+
diagnostics: Dict[str, Any] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def failure_rate(self) -> Optional[float]:
|
|
54
|
+
if self.total_runs <= 0:
|
|
55
|
+
return None
|
|
56
|
+
return self.failed_runs / self.total_runs
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _resolve_endpoint(config: FoundryControlSourceConfig) -> Optional[str]:
|
|
60
|
+
if config.project_endpoint:
|
|
61
|
+
return config.project_endpoint
|
|
62
|
+
if config.project_endpoint_env:
|
|
63
|
+
return os.environ.get(config.project_endpoint_env)
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def collect_foundry_control(
|
|
68
|
+
config: FoundryControlSourceConfig,
|
|
69
|
+
) -> FoundryControlPayload:
|
|
70
|
+
diagnostics: Dict[str, Any] = {"enabled": config.enabled}
|
|
71
|
+
|
|
72
|
+
if not config.enabled:
|
|
73
|
+
diagnostics["status"] = "disabled"
|
|
74
|
+
return FoundryControlPayload(diagnostics=diagnostics)
|
|
75
|
+
|
|
76
|
+
endpoint = _resolve_endpoint(config)
|
|
77
|
+
if not endpoint:
|
|
78
|
+
diagnostics["status"] = "skipped"
|
|
79
|
+
diagnostics["reason"] = (
|
|
80
|
+
"no project_endpoint configured "
|
|
81
|
+
f"(env var: {config.project_endpoint_env})"
|
|
82
|
+
)
|
|
83
|
+
return FoundryControlPayload(diagnostics=diagnostics)
|
|
84
|
+
|
|
85
|
+
diagnostics["endpoint"] = endpoint
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
from azure.ai.projects import AIProjectClient
|
|
89
|
+
from azure.identity import DefaultAzureCredential
|
|
90
|
+
except ImportError as exc:
|
|
91
|
+
diagnostics["status"] = "skipped"
|
|
92
|
+
diagnostics["reason"] = (
|
|
93
|
+
"azure-ai-projects / azure-identity not installed "
|
|
94
|
+
"(install agentops-accelerator[foundry])"
|
|
95
|
+
)
|
|
96
|
+
log.info("azure-ai-projects unavailable: %s", exc)
|
|
97
|
+
return FoundryControlPayload(diagnostics=diagnostics)
|
|
98
|
+
|
|
99
|
+
payload = FoundryControlPayload(diagnostics=diagnostics)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
credential = DefaultAzureCredential(exclude_developer_cli_credential=True, process_timeout=30)
|
|
103
|
+
client = AIProjectClient(endpoint=endpoint, credential=credential)
|
|
104
|
+
except Exception as exc: # pragma: no cover
|
|
105
|
+
diagnostics["status"] = "error"
|
|
106
|
+
diagnostics["reason"] = f"client init failed: {exc}"
|
|
107
|
+
return payload
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
agents_iter = getattr(client, "agents", None)
|
|
111
|
+
if agents_iter is not None:
|
|
112
|
+
list_agents = getattr(agents_iter, "list_agents", None) or getattr(
|
|
113
|
+
agents_iter, "list", None
|
|
114
|
+
)
|
|
115
|
+
if list_agents:
|
|
116
|
+
for raw in list_agents():
|
|
117
|
+
aid = str(getattr(raw, "id", "") or getattr(raw, "name", ""))
|
|
118
|
+
if config.agent_ids and aid not in config.agent_ids:
|
|
119
|
+
continue
|
|
120
|
+
payload.agents.append(
|
|
121
|
+
FoundryAgentSummary(
|
|
122
|
+
agent_id=aid,
|
|
123
|
+
name=getattr(raw, "name", None),
|
|
124
|
+
model=getattr(raw, "model", None),
|
|
125
|
+
updated_at=str(getattr(raw, "updated_at", "") or "")
|
|
126
|
+
or None,
|
|
127
|
+
instructions=getattr(raw, "instructions", None),
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
except Exception as exc: # pragma: no cover
|
|
131
|
+
log.warning("Foundry agents listing failed: %s", exc)
|
|
132
|
+
diagnostics["agents_error"] = str(exc)
|
|
133
|
+
|
|
134
|
+
# Best-effort: continuous evaluation rules attached to agents.
|
|
135
|
+
# The exact accessor varies by SDK version; we try a few attribute
|
|
136
|
+
# paths and silently skip if none are present.
|
|
137
|
+
try:
|
|
138
|
+
rules_accessor = (
|
|
139
|
+
getattr(client, "evaluation_rules", None)
|
|
140
|
+
or getattr(client, "evaluations", None)
|
|
141
|
+
)
|
|
142
|
+
list_rules = None
|
|
143
|
+
if rules_accessor is not None:
|
|
144
|
+
list_rules = (
|
|
145
|
+
getattr(rules_accessor, "list", None)
|
|
146
|
+
or getattr(rules_accessor, "list_evaluation_rules", None)
|
|
147
|
+
)
|
|
148
|
+
if list_rules is not None:
|
|
149
|
+
for raw in list_rules():
|
|
150
|
+
rid = str(getattr(raw, "id", "") or getattr(raw, "name", "") or "")
|
|
151
|
+
if not rid:
|
|
152
|
+
continue
|
|
153
|
+
enabled = getattr(raw, "enabled", None)
|
|
154
|
+
if enabled is None:
|
|
155
|
+
enabled = getattr(raw, "is_enabled", None)
|
|
156
|
+
payload.evaluation_rules.append(
|
|
157
|
+
EvaluationRuleSummary(
|
|
158
|
+
rule_id=rid,
|
|
159
|
+
name=getattr(raw, "name", None),
|
|
160
|
+
agent=getattr(raw, "agent_name", None)
|
|
161
|
+
or getattr(raw, "agent_id", None),
|
|
162
|
+
enabled=bool(enabled) if enabled is not None else None,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
diagnostics["evaluation_rules_count"] = len(payload.evaluation_rules)
|
|
166
|
+
else:
|
|
167
|
+
diagnostics["evaluation_rules_status"] = "unavailable"
|
|
168
|
+
except Exception as exc: # pragma: no cover - SDK shape varies
|
|
169
|
+
log.info("Foundry evaluation_rules listing skipped: %s", exc)
|
|
170
|
+
diagnostics["evaluation_rules_warning"] = str(exc)
|
|
171
|
+
|
|
172
|
+
diagnostics["status"] = "ok"
|
|
173
|
+
diagnostics["agents_count"] = len(payload.agents)
|
|
174
|
+
return payload
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"""AgentOps evaluation history source.
|
|
2
|
+
|
|
3
|
+
Reads ``.agentops/results/*/results.json`` and produces a normalized
|
|
4
|
+
list of run summaries ordered oldest-to-newest. When local history is
|
|
5
|
+
missing or too short, it can also fail-open to Foundry cloud evaluation
|
|
6
|
+
runs so Doctor still has signal in freshly cloned workspaces.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
17
|
+
|
|
18
|
+
from agentops.agent.config import FoundryControlSourceConfig, ResultsHistorySourceConfig
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class RunSummary:
|
|
25
|
+
"""One historical AgentOps run."""
|
|
26
|
+
|
|
27
|
+
run_id: str
|
|
28
|
+
timestamp: Optional[datetime]
|
|
29
|
+
metrics: Dict[str, float]
|
|
30
|
+
run_pass: Optional[bool]
|
|
31
|
+
items_total: int
|
|
32
|
+
items_passed_all: int
|
|
33
|
+
raw_path: Path
|
|
34
|
+
item_evaluations: List[Dict[str, Any]] = field(default_factory=list)
|
|
35
|
+
source: str = "local"
|
|
36
|
+
portal_url: Optional[str] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ResultsHistory:
|
|
41
|
+
"""Aggregated results-history payload."""
|
|
42
|
+
|
|
43
|
+
runs: List[RunSummary]
|
|
44
|
+
diagnostics: Dict[str, Any] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _coerce_timestamp(raw: Any) -> Optional[datetime]:
|
|
48
|
+
if raw is None:
|
|
49
|
+
return None
|
|
50
|
+
if isinstance(raw, datetime):
|
|
51
|
+
return raw if raw.tzinfo else raw.replace(tzinfo=timezone.utc)
|
|
52
|
+
if isinstance(raw, (int, float)):
|
|
53
|
+
try:
|
|
54
|
+
return datetime.fromtimestamp(float(raw), tz=timezone.utc)
|
|
55
|
+
except (OSError, OverflowError, ValueError):
|
|
56
|
+
return None
|
|
57
|
+
if isinstance(raw, str):
|
|
58
|
+
candidate = raw.replace("Z", "+00:00")
|
|
59
|
+
try:
|
|
60
|
+
ts = datetime.fromisoformat(candidate)
|
|
61
|
+
except ValueError:
|
|
62
|
+
return None
|
|
63
|
+
return ts if ts.tzinfo else ts.replace(tzinfo=timezone.utc)
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _summarize(path: Path) -> Optional[RunSummary]:
|
|
68
|
+
try:
|
|
69
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
70
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
71
|
+
log.warning("Skipping unreadable results.json at %s: %s", path, exc)
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
if not isinstance(data, dict):
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
metrics_raw = data.get("metrics") or data.get("run_metrics") or {}
|
|
78
|
+
metrics: Dict[str, float] = {}
|
|
79
|
+
if isinstance(metrics_raw, dict):
|
|
80
|
+
for key, value in metrics_raw.items():
|
|
81
|
+
try:
|
|
82
|
+
metrics[str(key)] = float(value)
|
|
83
|
+
except (TypeError, ValueError):
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
summary = data.get("summary") or {}
|
|
87
|
+
run_pass: Optional[bool] = None
|
|
88
|
+
if isinstance(summary, dict) and "run_pass" in summary:
|
|
89
|
+
run_pass = bool(summary["run_pass"])
|
|
90
|
+
elif "run_pass" in metrics_raw:
|
|
91
|
+
try:
|
|
92
|
+
run_pass = bool(float(metrics_raw["run_pass"]))
|
|
93
|
+
except (TypeError, ValueError):
|
|
94
|
+
run_pass = None
|
|
95
|
+
|
|
96
|
+
items_total = 0
|
|
97
|
+
items_passed_all = 0
|
|
98
|
+
if isinstance(summary, dict):
|
|
99
|
+
items_total = int(summary.get("items_total", 0) or 0)
|
|
100
|
+
items_passed_all = int(summary.get("items_passed_all", 0) or 0)
|
|
101
|
+
|
|
102
|
+
item_evaluations = data.get("item_evaluations") or []
|
|
103
|
+
if not isinstance(item_evaluations, list):
|
|
104
|
+
item_evaluations = []
|
|
105
|
+
|
|
106
|
+
timestamp_raw = (
|
|
107
|
+
data.get("timestamp")
|
|
108
|
+
or data.get("created_at")
|
|
109
|
+
or (summary.get("timestamp") if isinstance(summary, dict) else None)
|
|
110
|
+
)
|
|
111
|
+
timestamp = _coerce_timestamp(timestamp_raw)
|
|
112
|
+
if timestamp is None:
|
|
113
|
+
# Fall back to file mtime so ordering still works.
|
|
114
|
+
try:
|
|
115
|
+
timestamp = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
|
|
116
|
+
except OSError:
|
|
117
|
+
timestamp = None
|
|
118
|
+
|
|
119
|
+
run_id = str(data.get("run_id") or path.parent.name)
|
|
120
|
+
|
|
121
|
+
return RunSummary(
|
|
122
|
+
run_id=run_id,
|
|
123
|
+
timestamp=timestamp,
|
|
124
|
+
metrics=metrics,
|
|
125
|
+
run_pass=run_pass,
|
|
126
|
+
items_total=items_total,
|
|
127
|
+
items_passed_all=items_passed_all,
|
|
128
|
+
raw_path=path,
|
|
129
|
+
item_evaluations=item_evaluations,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def collect_results_history(
|
|
134
|
+
workspace: Path,
|
|
135
|
+
config: ResultsHistorySourceConfig,
|
|
136
|
+
*,
|
|
137
|
+
foundry_config: Optional[FoundryControlSourceConfig] = None,
|
|
138
|
+
) -> ResultsHistory:
|
|
139
|
+
"""Walk local and, when needed, cloud results to build an ordered history."""
|
|
140
|
+
diagnostics: Dict[str, Any] = {
|
|
141
|
+
"enabled": config.enabled,
|
|
142
|
+
"path": str(config.path),
|
|
143
|
+
}
|
|
144
|
+
if not config.enabled:
|
|
145
|
+
diagnostics["status"] = "disabled"
|
|
146
|
+
return ResultsHistory(runs=[], diagnostics=diagnostics)
|
|
147
|
+
|
|
148
|
+
summaries = _collect_local_runs(workspace, config, diagnostics)
|
|
149
|
+
diagnostics["local_runs_loaded"] = len(summaries)
|
|
150
|
+
|
|
151
|
+
if len(summaries) < config.lookback_runs and foundry_config is not None:
|
|
152
|
+
cloud_runs, cloud_diag = _collect_foundry_eval_runs(
|
|
153
|
+
foundry_config,
|
|
154
|
+
limit=max(config.lookback_runs, 10),
|
|
155
|
+
)
|
|
156
|
+
diagnostics["cloud"] = cloud_diag
|
|
157
|
+
if cloud_runs:
|
|
158
|
+
summaries = _merge_runs(summaries, cloud_runs)
|
|
159
|
+
diagnostics["cloud_runs_loaded"] = len(cloud_runs)
|
|
160
|
+
|
|
161
|
+
summaries.sort(
|
|
162
|
+
key=lambda s: s.timestamp or datetime.fromtimestamp(0, tz=timezone.utc)
|
|
163
|
+
)
|
|
164
|
+
if config.lookback_runs > 0:
|
|
165
|
+
summaries = summaries[-config.lookback_runs :]
|
|
166
|
+
|
|
167
|
+
diagnostics["status"] = "ok" if summaries else diagnostics.get("status", "missing")
|
|
168
|
+
diagnostics["runs_loaded"] = len(summaries)
|
|
169
|
+
diagnostics["sources_used"] = sorted({run.source for run in summaries})
|
|
170
|
+
return ResultsHistory(runs=summaries, diagnostics=diagnostics)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _collect_local_runs(
|
|
174
|
+
workspace: Path,
|
|
175
|
+
config: ResultsHistorySourceConfig,
|
|
176
|
+
diagnostics: Dict[str, Any],
|
|
177
|
+
) -> List[RunSummary]:
|
|
178
|
+
base = (workspace / config.path).resolve()
|
|
179
|
+
diagnostics["resolved_path"] = str(base)
|
|
180
|
+
|
|
181
|
+
if not base.exists():
|
|
182
|
+
diagnostics["local_status"] = "missing"
|
|
183
|
+
diagnostics["local_reason"] = f"results directory not found at {base}"
|
|
184
|
+
return []
|
|
185
|
+
|
|
186
|
+
candidates: List[Path] = []
|
|
187
|
+
for child in base.iterdir():
|
|
188
|
+
if not child.is_dir():
|
|
189
|
+
continue
|
|
190
|
+
if child.name == "latest":
|
|
191
|
+
continue
|
|
192
|
+
target = child / "results.json"
|
|
193
|
+
if target.is_file():
|
|
194
|
+
candidates.append(target)
|
|
195
|
+
|
|
196
|
+
summaries: List[RunSummary] = []
|
|
197
|
+
for path in candidates:
|
|
198
|
+
summary = _summarize(path)
|
|
199
|
+
if summary is not None:
|
|
200
|
+
summaries.append(summary)
|
|
201
|
+
|
|
202
|
+
summaries.sort(
|
|
203
|
+
key=lambda s: s.timestamp or datetime.fromtimestamp(0, tz=timezone.utc)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
diagnostics["local_status"] = "ok"
|
|
207
|
+
return summaries
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _merge_runs(local_runs: List[RunSummary], cloud_runs: List[RunSummary]) -> List[RunSummary]:
|
|
211
|
+
merged: Dict[str, RunSummary] = {}
|
|
212
|
+
for run in cloud_runs:
|
|
213
|
+
merged[run.run_id] = run
|
|
214
|
+
for run in local_runs:
|
|
215
|
+
merged[run.run_id] = run
|
|
216
|
+
return list(merged.values())
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _collect_foundry_eval_runs(
|
|
220
|
+
config: FoundryControlSourceConfig,
|
|
221
|
+
*,
|
|
222
|
+
limit: int,
|
|
223
|
+
) -> tuple[List[RunSummary], Dict[str, Any]]:
|
|
224
|
+
diagnostics: Dict[str, Any] = {"enabled": config.enabled}
|
|
225
|
+
if not config.enabled:
|
|
226
|
+
diagnostics["status"] = "disabled"
|
|
227
|
+
return [], diagnostics
|
|
228
|
+
|
|
229
|
+
endpoint = _resolve_foundry_endpoint(config)
|
|
230
|
+
if not endpoint:
|
|
231
|
+
diagnostics["status"] = "skipped"
|
|
232
|
+
diagnostics["reason"] = (
|
|
233
|
+
"no Foundry project endpoint configured; set "
|
|
234
|
+
f"`{config.project_endpoint_env}` or "
|
|
235
|
+
"`sources.foundry_control.project_endpoint`"
|
|
236
|
+
)
|
|
237
|
+
return [], diagnostics
|
|
238
|
+
diagnostics["endpoint"] = endpoint
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
from azure.ai.projects import AIProjectClient
|
|
242
|
+
from azure.identity import DefaultAzureCredential
|
|
243
|
+
except ImportError as exc:
|
|
244
|
+
diagnostics["status"] = "skipped"
|
|
245
|
+
diagnostics["reason"] = (
|
|
246
|
+
"azure-ai-projects / azure-identity not installed "
|
|
247
|
+
"(install agentops-accelerator[foundry])"
|
|
248
|
+
)
|
|
249
|
+
log.info("Foundry cloud eval history unavailable: %s", exc)
|
|
250
|
+
return [], diagnostics
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
credential = DefaultAzureCredential(
|
|
254
|
+
exclude_developer_cli_credential=True,
|
|
255
|
+
process_timeout=30,
|
|
256
|
+
)
|
|
257
|
+
project_client = AIProjectClient(endpoint=endpoint, credential=credential)
|
|
258
|
+
openai_client = project_client.get_openai_client()
|
|
259
|
+
except Exception as exc: # pragma: no cover - SDK/auth shape varies
|
|
260
|
+
diagnostics["status"] = "skipped"
|
|
261
|
+
diagnostics["reason"] = f"could not create Foundry OpenAI client: {exc}"
|
|
262
|
+
return [], diagnostics
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
runs = _list_cloud_eval_runs(openai_client, limit=limit)
|
|
266
|
+
except Exception as exc: # pragma: no cover - SDK shape varies
|
|
267
|
+
diagnostics["status"] = "skipped"
|
|
268
|
+
diagnostics["reason"] = f"could not list cloud evaluation runs: {exc}"
|
|
269
|
+
return [], diagnostics
|
|
270
|
+
|
|
271
|
+
diagnostics["status"] = "ok"
|
|
272
|
+
diagnostics["runs_loaded"] = len(runs)
|
|
273
|
+
return runs, diagnostics
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _resolve_foundry_endpoint(config: FoundryControlSourceConfig) -> Optional[str]:
|
|
277
|
+
if config.project_endpoint:
|
|
278
|
+
return config.project_endpoint
|
|
279
|
+
if config.project_endpoint_env:
|
|
280
|
+
import os
|
|
281
|
+
|
|
282
|
+
return os.environ.get(config.project_endpoint_env)
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _list_cloud_eval_runs(openai_client: Any, *, limit: int) -> List[RunSummary]:
|
|
287
|
+
evals_api = getattr(openai_client, "evals", None)
|
|
288
|
+
if evals_api is None:
|
|
289
|
+
return []
|
|
290
|
+
evals_page = _call_list(getattr(evals_api, "list", None), limit=limit)
|
|
291
|
+
summaries: List[RunSummary] = []
|
|
292
|
+
for eval_obj in _iter_page(evals_page):
|
|
293
|
+
eval_id = str(getattr(eval_obj, "id", "") or _get_dict(eval_obj, "id") or "")
|
|
294
|
+
if not eval_id:
|
|
295
|
+
continue
|
|
296
|
+
runs_api = getattr(evals_api, "runs", None)
|
|
297
|
+
list_runs = getattr(runs_api, "list", None) if runs_api is not None else None
|
|
298
|
+
runs_page = _call_list(list_runs, eval_id=eval_id, limit=limit)
|
|
299
|
+
for run_obj in _iter_page(runs_page):
|
|
300
|
+
summary = _summarize_cloud_run(openai_client, eval_id, run_obj)
|
|
301
|
+
if summary is not None:
|
|
302
|
+
summaries.append(summary)
|
|
303
|
+
summaries.sort(
|
|
304
|
+
key=lambda s: s.timestamp or datetime.fromtimestamp(0, tz=timezone.utc)
|
|
305
|
+
)
|
|
306
|
+
return summaries[-limit:] if limit > 0 else summaries
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _call_list(fn: Any, **kwargs: Any) -> Any:
|
|
310
|
+
if not callable(fn):
|
|
311
|
+
return []
|
|
312
|
+
try:
|
|
313
|
+
return fn(**kwargs)
|
|
314
|
+
except TypeError:
|
|
315
|
+
safe_kwargs = {k: v for k, v in kwargs.items() if k != "limit"}
|
|
316
|
+
return fn(**safe_kwargs)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _iter_page(page: Any) -> List[Any]:
|
|
320
|
+
if page is None:
|
|
321
|
+
return []
|
|
322
|
+
data = getattr(page, "data", None)
|
|
323
|
+
if data is not None:
|
|
324
|
+
return list(data)
|
|
325
|
+
if isinstance(page, list):
|
|
326
|
+
return page
|
|
327
|
+
try:
|
|
328
|
+
return list(page)
|
|
329
|
+
except TypeError:
|
|
330
|
+
return []
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _summarize_cloud_run(
|
|
334
|
+
openai_client: Any,
|
|
335
|
+
eval_id: str,
|
|
336
|
+
run_obj: Any,
|
|
337
|
+
) -> Optional[RunSummary]:
|
|
338
|
+
run_id = str(getattr(run_obj, "id", "") or _get_dict(run_obj, "id") or "")
|
|
339
|
+
if not run_id:
|
|
340
|
+
return None
|
|
341
|
+
timestamp = _coerce_timestamp(
|
|
342
|
+
getattr(run_obj, "created_at", None)
|
|
343
|
+
or _get_dict(run_obj, "created_at")
|
|
344
|
+
or getattr(run_obj, "createdAt", None)
|
|
345
|
+
or _get_dict(run_obj, "createdAt")
|
|
346
|
+
)
|
|
347
|
+
output_items = _list_cloud_output_items(openai_client, eval_id=eval_id, run_id=run_id)
|
|
348
|
+
metrics = _metrics_from_cloud_run(run_obj, output_items)
|
|
349
|
+
items_total = len(output_items)
|
|
350
|
+
items_passed = _count_cloud_passed_items(output_items)
|
|
351
|
+
status = str(getattr(run_obj, "status", "") or _get_dict(run_obj, "status") or "")
|
|
352
|
+
run_pass = None
|
|
353
|
+
if status:
|
|
354
|
+
run_pass = status.lower() in {"succeeded", "completed", "passed"}
|
|
355
|
+
return RunSummary(
|
|
356
|
+
run_id=run_id,
|
|
357
|
+
timestamp=timestamp,
|
|
358
|
+
metrics=metrics,
|
|
359
|
+
run_pass=run_pass,
|
|
360
|
+
items_total=items_total,
|
|
361
|
+
items_passed_all=items_passed,
|
|
362
|
+
raw_path=Path("foundry") / eval_id / run_id,
|
|
363
|
+
item_evaluations=output_items,
|
|
364
|
+
source="foundry_cloud",
|
|
365
|
+
portal_url=_extract_report_url(run_obj),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _list_cloud_output_items(
|
|
370
|
+
openai_client: Any,
|
|
371
|
+
*,
|
|
372
|
+
eval_id: str,
|
|
373
|
+
run_id: str,
|
|
374
|
+
) -> List[Dict[str, Any]]:
|
|
375
|
+
try:
|
|
376
|
+
output_items_api = openai_client.evals.runs.output_items
|
|
377
|
+
page = output_items_api.list(eval_id=eval_id, run_id=run_id)
|
|
378
|
+
except Exception:
|
|
379
|
+
return []
|
|
380
|
+
items: List[Dict[str, Any]] = []
|
|
381
|
+
for raw in _iter_page(page):
|
|
382
|
+
item = _coerce_output_item_to_dict(raw)
|
|
383
|
+
if item is not None:
|
|
384
|
+
items.append(item)
|
|
385
|
+
return items
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _coerce_output_item_to_dict(raw: Any) -> Optional[Dict[str, Any]]:
|
|
389
|
+
if isinstance(raw, dict):
|
|
390
|
+
return raw
|
|
391
|
+
for method in ("model_dump", "to_dict", "dict"):
|
|
392
|
+
fn = getattr(raw, method, None)
|
|
393
|
+
if callable(fn):
|
|
394
|
+
try:
|
|
395
|
+
value = fn()
|
|
396
|
+
if isinstance(value, dict):
|
|
397
|
+
return value
|
|
398
|
+
except Exception:
|
|
399
|
+
continue
|
|
400
|
+
keys = ("id", "status", "datasource_item", "sample", "results")
|
|
401
|
+
if any(hasattr(raw, key) for key in keys):
|
|
402
|
+
return {key: getattr(raw, key, None) for key in keys}
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _metrics_from_cloud_run(run_obj: Any, output_items: List[Dict[str, Any]]) -> Dict[str, float]:
|
|
407
|
+
metrics = _extract_metric_dict(run_obj)
|
|
408
|
+
if metrics:
|
|
409
|
+
return metrics
|
|
410
|
+
series: Dict[str, List[float]] = {}
|
|
411
|
+
for item in output_items:
|
|
412
|
+
for name, value in _extract_item_scores(item).items():
|
|
413
|
+
series.setdefault(name, []).append(value)
|
|
414
|
+
return {
|
|
415
|
+
name: sum(values) / len(values)
|
|
416
|
+
for name, values in series.items()
|
|
417
|
+
if values
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _extract_metric_dict(raw: Any) -> Dict[str, float]:
|
|
422
|
+
metrics: Dict[str, float] = {}
|
|
423
|
+
for container_name in ("metrics", "run_metrics", "results", "scores"):
|
|
424
|
+
container = getattr(raw, container_name, None) or _get_dict(raw, container_name)
|
|
425
|
+
if not isinstance(container, dict):
|
|
426
|
+
continue
|
|
427
|
+
for key, value in container.items():
|
|
428
|
+
score = _coerce_score(value)
|
|
429
|
+
if score is not None:
|
|
430
|
+
metrics[str(key)] = score
|
|
431
|
+
return metrics
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _extract_item_scores(item: Dict[str, Any]) -> Dict[str, float]:
|
|
435
|
+
scores: Dict[str, float] = {}
|
|
436
|
+
results = item.get("results")
|
|
437
|
+
if isinstance(results, dict):
|
|
438
|
+
iterator: Iterable[tuple[Any, Any]] = results.items()
|
|
439
|
+
elif isinstance(results, list):
|
|
440
|
+
iterator = ((_get_dict(entry, "name") or _get_dict(entry, "key") or "", entry) for entry in results)
|
|
441
|
+
else:
|
|
442
|
+
iterator = ()
|
|
443
|
+
for raw_name, raw_value in iterator:
|
|
444
|
+
name = str(raw_name or "").strip()
|
|
445
|
+
if not name:
|
|
446
|
+
continue
|
|
447
|
+
score = _coerce_score(raw_value)
|
|
448
|
+
if score is not None:
|
|
449
|
+
scores[name] = score
|
|
450
|
+
return scores
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _coerce_score(value: Any) -> Optional[float]:
|
|
454
|
+
if isinstance(value, (int, float)):
|
|
455
|
+
return float(value)
|
|
456
|
+
if isinstance(value, dict):
|
|
457
|
+
for key in ("score", "value", "result", "grade"):
|
|
458
|
+
if key in value:
|
|
459
|
+
score = _coerce_score(value[key])
|
|
460
|
+
if score is not None:
|
|
461
|
+
return score
|
|
462
|
+
for attr in ("score", "value", "result", "grade"):
|
|
463
|
+
if hasattr(value, attr):
|
|
464
|
+
score = _coerce_score(getattr(value, attr))
|
|
465
|
+
if score is not None:
|
|
466
|
+
return score
|
|
467
|
+
return None
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _count_cloud_passed_items(output_items: List[Dict[str, Any]]) -> int:
|
|
471
|
+
passed = 0
|
|
472
|
+
for item in output_items:
|
|
473
|
+
status = str(item.get("status") or "").lower()
|
|
474
|
+
if status in {"pass", "passed", "succeeded", "completed"}:
|
|
475
|
+
passed += 1
|
|
476
|
+
return passed
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _extract_report_url(run: Any) -> Optional[str]:
|
|
480
|
+
for attr in ("report_url", "reportUrl"):
|
|
481
|
+
value = getattr(run, attr, None) or _get_dict(run, attr)
|
|
482
|
+
if isinstance(value, str) and value:
|
|
483
|
+
return value
|
|
484
|
+
metadata = getattr(run, "metadata", None) or _get_dict(run, "metadata")
|
|
485
|
+
if isinstance(metadata, dict):
|
|
486
|
+
for key in ("report_url", "reportUrl"):
|
|
487
|
+
value = metadata.get(key)
|
|
488
|
+
if isinstance(value, str) and value:
|
|
489
|
+
return value
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _get_dict(raw: Any, key: str) -> Any:
|
|
494
|
+
return raw.get(key) if isinstance(raw, dict) else None
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Spec-conformance detectors.
|
|
2
|
+
|
|
3
|
+
Each detector inspects a workspace for a particular spec format and,
|
|
4
|
+
if present, returns a :class:`SpecDocument` describing the captured
|
|
5
|
+
capabilities, tasks, and references. The doctor runs every registered
|
|
6
|
+
detector that matches; ``checks/spec_conformance.py`` consumes the
|
|
7
|
+
results.
|
|
8
|
+
|
|
9
|
+
Detectors are intentionally light:
|
|
10
|
+
|
|
11
|
+
* No LLM calls — heuristics only (headings, bullets, checklists,
|
|
12
|
+
backticked identifiers).
|
|
13
|
+
* No network I/O.
|
|
14
|
+
* Safe on partial workspaces — return ``None`` whenever the format
|
|
15
|
+
isn't present rather than raising.
|
|
16
|
+
|
|
17
|
+
Adding a new spec format means dropping a new module in this package
|
|
18
|
+
and registering it in :data:`DETECTORS`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from agentops.agent.sources.spec_detectors._base import (
|
|
24
|
+
Detector,
|
|
25
|
+
SpecDocument,
|
|
26
|
+
SpecTask,
|
|
27
|
+
)
|
|
28
|
+
from agentops.agent.sources.spec_detectors.agents_md import AgentsMdDetector
|
|
29
|
+
from agentops.agent.sources.spec_detectors.spec_kit import SpecKitDetector
|
|
30
|
+
|
|
31
|
+
DETECTORS: tuple[Detector, ...] = (
|
|
32
|
+
SpecKitDetector(),
|
|
33
|
+
AgentsMdDetector(),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"DETECTORS",
|
|
39
|
+
"Detector",
|
|
40
|
+
"SpecDocument",
|
|
41
|
+
"SpecTask",
|
|
42
|
+
]
|