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.
Files changed (142) hide show
  1. agentops/__init__.py +10 -0
  2. agentops/__main__.py +6 -0
  3. agentops/agent/__init__.py +12 -0
  4. agentops/agent/_legacy_ids.py +92 -0
  5. agentops/agent/analyzer.py +207 -0
  6. agentops/agent/checks/__init__.py +1 -0
  7. agentops/agent/checks/catalog.py +880 -0
  8. agentops/agent/checks/errors.py +279 -0
  9. agentops/agent/checks/foundry_config.py +75 -0
  10. agentops/agent/checks/latency.py +84 -0
  11. agentops/agent/checks/opex.py +157 -0
  12. agentops/agent/checks/opex_workspace.py +874 -0
  13. agentops/agent/checks/posture.py +36 -0
  14. agentops/agent/checks/posture_rules/__init__.py +53 -0
  15. agentops/agent/checks/posture_rules/content_filter.py +59 -0
  16. agentops/agent/checks/posture_rules/diagnostics.py +74 -0
  17. agentops/agent/checks/posture_rules/local_auth.py +55 -0
  18. agentops/agent/checks/posture_rules/managed_identity.py +59 -0
  19. agentops/agent/checks/posture_rules/network.py +68 -0
  20. agentops/agent/checks/regression.py +78 -0
  21. agentops/agent/checks/release_readiness.py +182 -0
  22. agentops/agent/checks/safety.py +247 -0
  23. agentops/agent/checks/spec_conformance.py +375 -0
  24. agentops/agent/cockpit.py +5159 -0
  25. agentops/agent/config.py +240 -0
  26. agentops/agent/findings.py +113 -0
  27. agentops/agent/history.py +142 -0
  28. agentops/agent/knowledge/__init__.py +182 -0
  29. agentops/agent/knowledge/waf-checklist.csv +39 -0
  30. agentops/agent/llm_assist/__init__.py +16 -0
  31. agentops/agent/llm_assist/_base.py +124 -0
  32. agentops/agent/llm_assist/_bundle_rule.py +154 -0
  33. agentops/agent/llm_assist/_client.py +347 -0
  34. agentops/agent/llm_assist/_dataset_rules.py +191 -0
  35. agentops/agent/llm_assist/_engine.py +106 -0
  36. agentops/agent/llm_assist/_prompt_rules.py +291 -0
  37. agentops/agent/llm_assist/_spec_rules.py +235 -0
  38. agentops/agent/production_telemetry.py +430 -0
  39. agentops/agent/report.py +207 -0
  40. agentops/agent/server/__init__.py +1 -0
  41. agentops/agent/server/app.py +84 -0
  42. agentops/agent/server/auth.py +94 -0
  43. agentops/agent/server/chat.py +44 -0
  44. agentops/agent/server/protocol.py +72 -0
  45. agentops/agent/sources/__init__.py +1 -0
  46. agentops/agent/sources/azure_monitor.py +523 -0
  47. agentops/agent/sources/azure_resources.py +602 -0
  48. agentops/agent/sources/foundry_control.py +174 -0
  49. agentops/agent/sources/results_history.py +494 -0
  50. agentops/agent/sources/spec_detectors/__init__.py +42 -0
  51. agentops/agent/sources/spec_detectors/_base.py +58 -0
  52. agentops/agent/sources/spec_detectors/agents_md.py +75 -0
  53. agentops/agent/sources/spec_detectors/spec_kit.py +172 -0
  54. agentops/agent/time_range.py +117 -0
  55. agentops/cli/__init__.py +1 -0
  56. agentops/cli/app.py +4823 -0
  57. agentops/core/__init__.py +1 -0
  58. agentops/core/agentops_config.py +592 -0
  59. agentops/core/config_loader.py +22 -0
  60. agentops/core/evaluators.py +480 -0
  61. agentops/core/release_evidence.py +56 -0
  62. agentops/core/results.py +117 -0
  63. agentops/mcp/__init__.py +10 -0
  64. agentops/mcp/server.py +232 -0
  65. agentops/pipeline/__init__.py +8 -0
  66. agentops/pipeline/cloud_results.py +189 -0
  67. agentops/pipeline/cloud_runner.py +901 -0
  68. agentops/pipeline/comparison.py +108 -0
  69. agentops/pipeline/diagnostics.py +51 -0
  70. agentops/pipeline/invocations.py +535 -0
  71. agentops/pipeline/official_eval.py +414 -0
  72. agentops/pipeline/orchestrator.py +775 -0
  73. agentops/pipeline/prompt_deploy.py +377 -0
  74. agentops/pipeline/publisher.py +121 -0
  75. agentops/pipeline/reporter.py +202 -0
  76. agentops/pipeline/runtime.py +409 -0
  77. agentops/pipeline/thresholds.py +84 -0
  78. agentops/services/__init__.py +1 -0
  79. agentops/services/cicd.py +720 -0
  80. agentops/services/eval_analysis.py +848 -0
  81. agentops/services/evidence_pack.py +757 -0
  82. agentops/services/initializer.py +86 -0
  83. agentops/services/preflight.py +470 -0
  84. agentops/services/setup_wizard.py +709 -0
  85. agentops/services/skills.py +643 -0
  86. agentops/services/trace_promotion.py +300 -0
  87. agentops/services/workflow_analysis.py +1129 -0
  88. agentops/templates/.gitignore +15 -0
  89. agentops/templates/__init__.py +1 -0
  90. agentops/templates/agent-server/Dockerfile +23 -0
  91. agentops/templates/agent-server/README.md +61 -0
  92. agentops/templates/agent-server/main.bicep +94 -0
  93. agentops/templates/agent.yaml +87 -0
  94. agentops/templates/agentops.yaml +58 -0
  95. agentops/templates/foundry.svg +71 -0
  96. agentops/templates/icon.png +0 -0
  97. agentops/templates/pipelines/azuredevops/agentops-deploy-dev-azd.yml +118 -0
  98. agentops/templates/pipelines/azuredevops/agentops-deploy-dev.yml +73 -0
  99. agentops/templates/pipelines/azuredevops/agentops-deploy-prod-azd.yml +141 -0
  100. agentops/templates/pipelines/azuredevops/agentops-deploy-prod.yml +94 -0
  101. agentops/templates/pipelines/azuredevops/agentops-deploy-prompt-agent.yml +167 -0
  102. agentops/templates/pipelines/azuredevops/agentops-deploy-qa-azd.yml +118 -0
  103. agentops/templates/pipelines/azuredevops/agentops-deploy-qa.yml +68 -0
  104. agentops/templates/pipelines/azuredevops/agentops-pr-prompt-agent.yml +210 -0
  105. agentops/templates/pipelines/azuredevops/agentops-pr.yml +155 -0
  106. agentops/templates/pipelines/azuredevops/agentops-watchdog.yml +106 -0
  107. agentops/templates/project.gitignore +36 -0
  108. agentops/templates/sample-traces.jsonl +3 -0
  109. agentops/templates/skills/agentops-agent/SKILL.md +137 -0
  110. agentops/templates/skills/agentops-config/SKILL.md +113 -0
  111. agentops/templates/skills/agentops-dataset/SKILL.md +84 -0
  112. agentops/templates/skills/agentops-eval/SKILL.md +189 -0
  113. agentops/templates/skills/agentops-report/SKILL.md +71 -0
  114. agentops/templates/skills/agentops-workflow/SKILL.md +471 -0
  115. agentops/templates/smoke.jsonl +3 -0
  116. agentops/templates/waf-checklist.README.md +84 -0
  117. agentops/templates/waf-checklist.csv +22 -0
  118. agentops/templates/workflows/agentops-deploy-dev-azd.yml +166 -0
  119. agentops/templates/workflows/agentops-deploy-dev.yml +187 -0
  120. agentops/templates/workflows/agentops-deploy-prod-azd.yml +183 -0
  121. agentops/templates/workflows/agentops-deploy-prod.yml +171 -0
  122. agentops/templates/workflows/agentops-deploy-prompt-agent.yml +197 -0
  123. agentops/templates/workflows/agentops-deploy-qa-azd.yml +156 -0
  124. agentops/templates/workflows/agentops-deploy-qa.yml +145 -0
  125. agentops/templates/workflows/agentops-pr-prompt-agent.yml +210 -0
  126. agentops/templates/workflows/agentops-pr.yml +148 -0
  127. agentops/templates/workflows/agentops-watchdog.yml +122 -0
  128. agentops/utils/__init__.py +1 -0
  129. agentops/utils/azd_env.py +435 -0
  130. agentops/utils/azure_endpoints.py +62 -0
  131. agentops/utils/colors.py +47 -0
  132. agentops/utils/dotenv_loader.py +105 -0
  133. agentops/utils/foundry_discovery.py +229 -0
  134. agentops/utils/logging.py +59 -0
  135. agentops/utils/telemetry.py +554 -0
  136. agentops/utils/yaml.py +36 -0
  137. agentops_accelerator-0.3.0.dist-info/METADATA +278 -0
  138. agentops_accelerator-0.3.0.dist-info/RECORD +142 -0
  139. agentops_accelerator-0.3.0.dist-info/WHEEL +5 -0
  140. agentops_accelerator-0.3.0.dist-info/entry_points.txt +2 -0
  141. agentops_accelerator-0.3.0.dist-info/licenses/LICENSE +21 -0
  142. 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
+ ]