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,775 @@
1
+ """End-to-end evaluation orchestrator for AgentOps 1.0.
2
+
3
+ This is the single entry point exercised by ``agentops eval``. It loads the
4
+ flat config, classifies the target, infers evaluators from the dataset shape,
5
+ invokes the target row-by-row, runs each evaluator, applies thresholds, and
6
+ writes ``results.json`` and ``report.md``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import statistics
14
+ import sys
15
+ import time
16
+ import os
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Any, Callable, Dict, Iterable, List, Optional
21
+
22
+ from agentops.core.agentops_config import AgentOpsConfig, Threshold, classify_agent
23
+ from agentops.core.evaluators import (
24
+ detect_dataset_shape,
25
+ merge_thresholds,
26
+ select_evaluators,
27
+ )
28
+ from agentops.core.results import (
29
+ RowMetric,
30
+ RowResult,
31
+ RunResult,
32
+ RunSummary,
33
+ TargetInfo,
34
+ )
35
+ from agentops.pipeline import comparison as comparison_module
36
+ from agentops.pipeline import invocations, publisher, reporter, runtime, thresholds
37
+ from agentops.utils import telemetry
38
+ from agentops.utils.colors import style
39
+
40
+ logger = logging.getLogger("agentops.pipeline")
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Public entry point
45
+ # ---------------------------------------------------------------------------
46
+
47
+
48
+ @dataclass
49
+ class RunOptions:
50
+ config_path: Path
51
+ output_dir: Path
52
+ baseline_path: Optional[Path] = None
53
+ timeout_seconds: float = 120.0
54
+ dataset_override: Optional[Path] = None
55
+ agent_override: Optional[str] = None
56
+ # Optional callback invoked with progress messages during a run. The
57
+ # CLI wires this to ``typer.echo`` so users see per-row progress
58
+ # ("invoking", "scored", ...) instead of long unexplained pauses.
59
+ # Library callers can leave it as ``None`` to keep runs silent.
60
+ progress: Optional[Callable[[str], None]] = field(default=None, repr=False)
61
+
62
+
63
+ def run_evaluation(
64
+ config: AgentOpsConfig,
65
+ *,
66
+ options: RunOptions,
67
+ ) -> RunResult:
68
+ """Run a full evaluation and persist artifacts. Returns the RunResult."""
69
+ telemetry.init_tracing()
70
+ try:
71
+ return _run_evaluation(config, options=options)
72
+ finally:
73
+ telemetry.shutdown()
74
+
75
+
76
+ def _run_evaluation(
77
+ config: AgentOpsConfig,
78
+ *,
79
+ options: RunOptions,
80
+ ) -> RunResult:
81
+ """Run a full evaluation after optional telemetry has been initialized."""
82
+ if options.baseline_path is not None and not options.baseline_path.exists():
83
+ raise FileNotFoundError(
84
+ f"baseline file not found: {options.baseline_path}. "
85
+ "Run `agentops eval run` once without `--baseline` first, then copy "
86
+ "`.agentops/results/latest/results.json` to the baseline path."
87
+ )
88
+
89
+ if config.execution == "cloud":
90
+ return _run_evaluation_cloud(config, options=options)
91
+ return _run_evaluation_local(config, options=options)
92
+
93
+
94
+ def _run_evaluation_local(
95
+ config: AgentOpsConfig,
96
+ *,
97
+ options: RunOptions,
98
+ ) -> RunResult:
99
+ """Local execution: AgentOps invokes the agent + evaluators row-by-row."""
100
+
101
+ started_at = datetime.now(timezone.utc)
102
+ started_perf = time.perf_counter()
103
+
104
+ target = classify_agent(
105
+ options.agent_override or config.agent,
106
+ config.protocol,
107
+ )
108
+
109
+ dataset_path = options.dataset_override or _resolve_dataset_path(config, options)
110
+ shape = detect_dataset_shape(dataset_path)
111
+
112
+ overrides = (
113
+ [override.name for override in config.evaluators] if config.evaluators else None
114
+ )
115
+ presets = select_evaluators(
116
+ target,
117
+ shape,
118
+ overrides=overrides,
119
+ threshold_metrics=config.thresholds.keys(),
120
+ )
121
+ user_thresholds = [
122
+ Threshold.from_expression(metric, expr)
123
+ for metric, expr in config.thresholds.items()
124
+ ]
125
+ threshold_rules = merge_thresholds(presets, user_thresholds)
126
+
127
+ evaluator_runtimes = runtime.load_evaluators(presets)
128
+
129
+ progress = options.progress or (lambda _msg: None)
130
+
131
+ dataset_rows = list(_iter_dataset(dataset_path))
132
+ total = len(dataset_rows)
133
+ from agentops import __version__ as _agentops_version
134
+ py = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
135
+ progress(
136
+ f"{style('agentops', 'bold', 'cyan')} {style(_agentops_version, 'cyan')} "
137
+ f"{style('|', 'dim')} python {py} "
138
+ f"{style('|', 'dim')} config: {style(options.config_path.name, 'cyan')}"
139
+ )
140
+ progress(
141
+ f"Loaded {style(str(total), 'bold')} row(s) from "
142
+ f"{style(dataset_path.name, 'cyan')}; running "
143
+ f"{style(str(len(presets)), 'bold')} evaluator(s) against "
144
+ f"{_friendly_target_kind(target.kind)}: {style(target.raw, 'bold')}."
145
+ )
146
+
147
+ with telemetry.eval_run_span(
148
+ bundle_name=options.config_path.stem,
149
+ dataset_name=dataset_path.name,
150
+ backend_type=target.kind,
151
+ target=target.raw,
152
+ model=target.deployment,
153
+ agent_id=target.raw if target.kind.startswith("foundry") else None,
154
+ ) as run_span:
155
+ rows: List[RowResult] = []
156
+ rules_by_metric = {rule.metric: rule for rule in threshold_rules}
157
+ for index, row in enumerate(dataset_rows):
158
+ rows.append(
159
+ _evaluate_row(
160
+ row=row,
161
+ index=index,
162
+ total=total,
163
+ target=target,
164
+ config=config,
165
+ evaluators=evaluator_runtimes,
166
+ timeout=options.timeout_seconds,
167
+ progress=progress,
168
+ rules_by_metric=rules_by_metric,
169
+ )
170
+ )
171
+
172
+ aggregate = _aggregate_metrics(rows)
173
+ threshold_results = thresholds.evaluate(threshold_rules, aggregate)
174
+ summary = _summarize(rows, threshold_results)
175
+ telemetry.set_eval_run_result(
176
+ run_span,
177
+ passed=summary.overall_passed,
178
+ items_total=summary.items_total,
179
+ items_passed=summary.items_passed_all,
180
+ )
181
+
182
+ finished_at = datetime.now(timezone.utc)
183
+ duration = time.perf_counter() - started_perf
184
+
185
+ result = RunResult(
186
+ started_at=started_at.isoformat(),
187
+ finished_at=finished_at.isoformat(),
188
+ duration_seconds=duration,
189
+ target=TargetInfo(
190
+ kind=target.kind,
191
+ raw=target.raw,
192
+ protocol=target.protocol,
193
+ name=target.name,
194
+ version=target.version,
195
+ url=target.url,
196
+ deployment=target.deployment,
197
+ ),
198
+ dataset_path=str(dataset_path),
199
+ evaluators=[preset.name for preset in presets],
200
+ rows=rows,
201
+ aggregate_metrics=aggregate,
202
+ thresholds=threshold_results,
203
+ summary=summary,
204
+ config={
205
+ "version": config.version,
206
+ "agent": config.agent,
207
+ "thresholds": dict(config.thresholds),
208
+ },
209
+ )
210
+
211
+ if options.baseline_path is not None:
212
+ baseline = comparison_module.load_baseline(options.baseline_path)
213
+ result.comparison = comparison_module.build_comparison(
214
+ current=result,
215
+ baseline=baseline,
216
+ baseline_path=options.baseline_path,
217
+ )
218
+
219
+ _persist(result, options.output_dir)
220
+
221
+ # Local execution only ever publishes to Classic Foundry. Cloud
222
+ # execution goes through _run_evaluation_cloud and never reaches here.
223
+ if config.publish_target() == "foundry":
224
+ _publish_to_foundry_safely(result, config, options.output_dir, progress=progress)
225
+
226
+ return result
227
+
228
+
229
+ def _run_evaluation_cloud(
230
+ config: AgentOpsConfig,
231
+ *,
232
+ options: RunOptions,
233
+ ) -> RunResult:
234
+ """Cloud execution: Foundry invokes the agent + evaluators server-side.
235
+
236
+ The agent is invoked exactly once - on Foundry's side. AgentOps does
237
+ not run the row-by-row local loop. After the cloud run completes we
238
+ download the per-row ``output_items`` and reshape them into the same
239
+ :class:`RunResult` schema that local execution produces, so
240
+ ``report.md`` and ``--baseline`` work identically.
241
+ """
242
+ started_at = datetime.now(timezone.utc)
243
+ started_perf = time.perf_counter()
244
+
245
+ target = classify_agent(
246
+ options.agent_override or config.agent,
247
+ config.protocol,
248
+ )
249
+ if target.kind != "foundry_prompt":
250
+ raise ValueError(
251
+ "execution: cloud only supports Foundry prompt agents "
252
+ f"('name:version'); got target.kind={target.kind!r}."
253
+ )
254
+
255
+ dataset_path = options.dataset_override or _resolve_dataset_path(config, options)
256
+ shape = detect_dataset_shape(dataset_path)
257
+ overrides = (
258
+ [override.name for override in config.evaluators] if config.evaluators else None
259
+ )
260
+ all_presets = select_evaluators(
261
+ target,
262
+ shape,
263
+ overrides=overrides,
264
+ threshold_metrics=config.thresholds.keys(),
265
+ )
266
+
267
+ # Cloud execution runs server-side, so client-side runtime evaluators
268
+ # (e.g. avg_latency_seconds) cannot be measured. Excluding them is the
269
+ # right choice - otherwise their default thresholds would mark the run
270
+ # FAILED for a metric we never had a chance to observe.
271
+ presets = [p for p in all_presets if "runtime" not in p.categories]
272
+ skipped_runtime = [p.name for p in all_presets if "runtime" in p.categories]
273
+
274
+ user_thresholds = [
275
+ Threshold.from_expression(metric, expr)
276
+ for metric, expr in config.thresholds.items()
277
+ # Drop user-specified thresholds for runtime metrics too - they
278
+ # would otherwise fail with actual="missing".
279
+ if metric not in {p.score_key for p in all_presets if "runtime" in p.categories}
280
+ ]
281
+ threshold_rules = merge_thresholds(presets, user_thresholds)
282
+
283
+ # Build a "shell" result that carries just enough metadata for the
284
+ # cloud publisher to map evaluator class names onto Azure AI evaluator
285
+ # testing criteria.
286
+ shell_target = TargetInfo(
287
+ kind=target.kind,
288
+ raw=target.raw,
289
+ protocol=target.protocol,
290
+ name=target.name,
291
+ version=target.version,
292
+ url=target.url,
293
+ deployment=target.deployment,
294
+ )
295
+
296
+ progress = options.progress or (lambda _msg: None)
297
+ from agentops import __version__ as _agentops_version
298
+ py = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
299
+ progress(
300
+ f"{style('agentops', 'bold', 'cyan')} {style(_agentops_version, 'cyan')} "
301
+ f"{style('|', 'dim')} python {py} "
302
+ f"{style('|', 'dim')} config: {style(options.config_path.name, 'cyan')}"
303
+ )
304
+ progress(
305
+ f"execution: {style('cloud', 'bold')} - Foundry will run the agent "
306
+ f"and {style(str(len(presets)), 'bold')} evaluator(s) server-side. "
307
+ f"Agent: {style(target.raw, 'bold')}."
308
+ )
309
+ if skipped_runtime:
310
+ progress(
311
+ f" (skipped client-side runtime evaluators: "
312
+ f"{', '.join(skipped_runtime)} - not measurable in cloud mode)"
313
+ )
314
+
315
+ shell_result = RunResult(
316
+ started_at=started_at.isoformat(),
317
+ finished_at=started_at.isoformat(),
318
+ duration_seconds=0.0,
319
+ target=shell_target,
320
+ dataset_path=str(dataset_path),
321
+ evaluators=[preset.name for preset in presets],
322
+ rows=[],
323
+ aggregate_metrics={},
324
+ thresholds=[],
325
+ summary=_summarize([], []),
326
+ config={
327
+ "version": config.version,
328
+ "agent": config.agent,
329
+ "thresholds": dict(config.thresholds),
330
+ },
331
+ )
332
+
333
+ endpoint = config.project_endpoint or os.getenv("AZURE_AI_FOUNDRY_PROJECT_ENDPOINT")
334
+ if not endpoint:
335
+ raise ValueError(
336
+ "execution: cloud requires either 'project_endpoint' in "
337
+ "agentops.yaml or the AZURE_AI_FOUNDRY_PROJECT_ENDPOINT env var."
338
+ )
339
+
340
+ from agentops.pipeline import cloud_runner
341
+ from agentops.pipeline import cloud_results
342
+
343
+ with telemetry.eval_run_span(
344
+ bundle_name=options.config_path.stem,
345
+ dataset_name=dataset_path.name,
346
+ backend_type="foundry_cloud",
347
+ target=target.raw,
348
+ model=target.deployment,
349
+ agent_id=target.raw,
350
+ ) as run_span:
351
+ published = cloud_runner.run_on_foundry_cloud(
352
+ shell_result,
353
+ dataset_path=dataset_path,
354
+ project_endpoint=endpoint,
355
+ dataset_sync=config.dataset_sync,
356
+ progress=progress,
357
+ )
358
+
359
+ if run_span is not None:
360
+ run_span.set_attribute("agentops.eval.execution", "cloud")
361
+ run_span.set_attribute("agentops.eval.cloud.eval_id", published.eval_id)
362
+ run_span.set_attribute("agentops.eval.cloud.run_id", published.run_id)
363
+ run_span.set_attribute("agentops.eval.cloud.status", published.status)
364
+ if published.report_url:
365
+ run_span.set_attribute(
366
+ "agentops.eval.cloud.report_url",
367
+ published.report_url,
368
+ )
369
+ if published.dataset:
370
+ run_span.set_attribute(
371
+ "agentops.eval.cloud.dataset.mode",
372
+ str(published.dataset.get("mode") or ""),
373
+ )
374
+ dataset_id = published.dataset.get("id")
375
+ if dataset_id:
376
+ run_span.set_attribute(
377
+ "agentops.eval.cloud.dataset.id",
378
+ str(dataset_id),
379
+ )
380
+
381
+ rows = cloud_results.rows_from_cloud_output_items(published.output_items)
382
+ aggregate = _aggregate_metrics(rows)
383
+ threshold_results = thresholds.evaluate(threshold_rules, aggregate)
384
+ summary = _summarize(rows, threshold_results)
385
+ telemetry.set_eval_run_result(
386
+ run_span,
387
+ passed=summary.overall_passed,
388
+ items_total=summary.items_total,
389
+ items_passed=summary.items_passed_all,
390
+ )
391
+
392
+ finished_at = datetime.now(timezone.utc)
393
+ duration = time.perf_counter() - started_perf
394
+
395
+ result = RunResult(
396
+ started_at=started_at.isoformat(),
397
+ finished_at=finished_at.isoformat(),
398
+ duration_seconds=duration,
399
+ target=shell_target,
400
+ dataset_path=str(dataset_path),
401
+ evaluators=[preset.name for preset in presets],
402
+ rows=rows,
403
+ aggregate_metrics=aggregate,
404
+ thresholds=threshold_results,
405
+ summary=summary,
406
+ config={
407
+ "version": config.version,
408
+ "agent": config.agent,
409
+ "thresholds": dict(config.thresholds),
410
+ "execution": "cloud",
411
+ "cloud_evaluation": {
412
+ "mode": "cloud",
413
+ "evaluation_name": published.evaluation_name,
414
+ "eval_id": published.eval_id,
415
+ "run_id": published.run_id,
416
+ "status": published.status,
417
+ "report_url": published.report_url,
418
+ "dataset": published.dataset,
419
+ },
420
+ },
421
+ )
422
+
423
+ if options.baseline_path is not None:
424
+ baseline = comparison_module.load_baseline(options.baseline_path)
425
+ result.comparison = comparison_module.build_comparison(
426
+ current=result,
427
+ baseline=baseline,
428
+ baseline_path=options.baseline_path,
429
+ )
430
+
431
+ _persist(result, options.output_dir)
432
+
433
+ # Write cloud_evaluation.json next to the other artifacts for parity
434
+ # with the (now-removed) post-run cloud publish path.
435
+ cloud_meta_path = options.output_dir / "cloud_evaluation.json"
436
+ cloud_meta_path.write_text(
437
+ json.dumps(result.config["cloud_evaluation"], indent=2),
438
+ encoding="utf-8",
439
+ )
440
+
441
+ progress(
442
+ f"Submitted to {style('New Foundry Evaluations', 'bold')}: "
443
+ f"{style(published.report_url or '(no portal URL)', 'cyan')}"
444
+ )
445
+ progress(
446
+ f" eval_id={published.eval_id} run_id={published.run_id} "
447
+ f"status={style(published.status, 'green' if published.status == 'completed' else 'yellow')} "
448
+ f"rows={len(rows)}"
449
+ )
450
+
451
+ if not rows:
452
+ progress(
453
+ f"{style('WARNING', 'yellow')}: no per-row results were "
454
+ f"downloaded from Foundry; report.md will be minimal. The "
455
+ f"canonical view is the Foundry portal."
456
+ )
457
+
458
+ return result
459
+
460
+
461
+ def _publish_to_foundry_safely(
462
+ result: RunResult,
463
+ config: AgentOpsConfig,
464
+ output_dir: Path,
465
+ *,
466
+ progress: Optional[Callable[[str], None]] = None,
467
+ ) -> None:
468
+ """Best-effort Classic Foundry publish. Failures are logged, never fatal."""
469
+ if config.publish_target() != "foundry":
470
+ return
471
+
472
+ notify = progress or (lambda _msg: None)
473
+
474
+ try:
475
+ published = publisher.publish_to_foundry(
476
+ result,
477
+ project_endpoint=config.project_endpoint,
478
+ )
479
+ except Exception as exc: # noqa: BLE001
480
+ logger.debug("foundry publish failed: %s", exc)
481
+ notify(
482
+ f"{style('publish foundry FAILED', 'red')}: {exc}. "
483
+ f"Local results.json is the source of truth."
484
+ )
485
+ return
486
+
487
+ cloud_meta_path = output_dir / "cloud_evaluation.json"
488
+ cloud_meta_path.write_text(
489
+ json.dumps(
490
+ {
491
+ "mode": "classic",
492
+ "evaluation_name": published.evaluation_name,
493
+ "report_url": published.studio_url,
494
+ },
495
+ indent=2,
496
+ ),
497
+ encoding="utf-8",
498
+ )
499
+ notify(
500
+ f"Published to {style('Classic Foundry Evaluations', 'bold')}: "
501
+ f"{style(published.studio_url, 'cyan')}"
502
+ )
503
+ notify(
504
+ f"Tip: to run server-side in the {style('New Foundry', 'bold')} "
505
+ f"experience, set 'execution: cloud' + 'publish: true' (preview)."
506
+ )
507
+
508
+
509
+ def exit_code_from(result: RunResult) -> int:
510
+ """Translate a run's outcome into the ``agentops`` CLI contract.
511
+
512
+ * ``0`` - success, all thresholds passed.
513
+ * ``2`` - invocations succeeded but a threshold failed.
514
+ * ``1`` - runtime errors are raised as exceptions before this is called.
515
+ """
516
+ return 0 if result.summary.overall_passed else 2
517
+
518
+
519
+ # ---------------------------------------------------------------------------
520
+ # Dataset
521
+ # ---------------------------------------------------------------------------
522
+
523
+
524
+ def _resolve_dataset_path(config: AgentOpsConfig, options: RunOptions) -> Path:
525
+ candidate = config.dataset
526
+ if candidate.is_absolute() and candidate.exists():
527
+ return candidate
528
+ base = options.config_path.parent
529
+ resolved = (base / candidate).resolve()
530
+ if not resolved.exists():
531
+ raise FileNotFoundError(f"dataset not found: {resolved}")
532
+ return resolved
533
+
534
+
535
+ _FRIENDLY_KIND = {
536
+ "foundry_prompt": "foundry agent",
537
+ "foundry_hosted": "foundry agent (hosted)",
538
+ "http_json": "http endpoint",
539
+ "model_direct": "model deployment",
540
+ }
541
+
542
+
543
+ def _friendly_target_kind(kind: str) -> str:
544
+ return _FRIENDLY_KIND.get(kind, kind)
545
+
546
+
547
+ def _iter_dataset(path: Path) -> Iterable[Dict[str, Any]]:
548
+ with path.open("r", encoding="utf-8") as handle:
549
+ for line_number, line in enumerate(handle, start=1):
550
+ stripped = line.strip()
551
+ if not stripped:
552
+ continue
553
+ try:
554
+ row = json.loads(stripped)
555
+ except json.JSONDecodeError as exc:
556
+ raise ValueError(
557
+ f"{path}: invalid JSON on line {line_number}: {exc}"
558
+ ) from exc
559
+ if not isinstance(row, dict):
560
+ raise ValueError(
561
+ f"{path}: line {line_number} is not a JSON object"
562
+ )
563
+ yield row
564
+
565
+
566
+ # ---------------------------------------------------------------------------
567
+ # Per-row execution
568
+ # ---------------------------------------------------------------------------
569
+
570
+
571
+ def _metric_passes(rule: Threshold, value: float) -> bool:
572
+ if rule.value is None or rule.criteria in {"true", "false"}:
573
+ return True
574
+ target_v = float(rule.value)
575
+ c = rule.criteria
576
+ if c == ">=":
577
+ return value >= target_v
578
+ if c == ">":
579
+ return value > target_v
580
+ if c == "<=":
581
+ return value <= target_v
582
+ if c == "<":
583
+ return value < target_v
584
+ if c == "==":
585
+ return value == target_v
586
+ return True
587
+
588
+
589
+ def _evaluate_row(
590
+ *,
591
+ row: Dict[str, Any],
592
+ index: int,
593
+ total: int,
594
+ target,
595
+ config: AgentOpsConfig,
596
+ evaluators: List[runtime.EvaluatorRuntime],
597
+ timeout: float,
598
+ progress: Callable[[str], None],
599
+ rules_by_metric: Optional[Dict[str, Threshold]] = None,
600
+ ) -> RowResult:
601
+ label = style(f"[{index + 1}/{total}]", "dim")
602
+ preview = str(row.get("input", "")).strip().replace("\n", " ")
603
+ if len(preview) > 80:
604
+ preview = preview[:77] + "..."
605
+ progress(f"{label} invoking target: {preview!r}")
606
+ expected = row.get("expected")
607
+ expected_text = str(expected) if expected is not None else None
608
+
609
+ with telemetry.eval_item_span(
610
+ row_index=index,
611
+ input_text=str(row.get("input", "")),
612
+ expected_text=expected_text,
613
+ ) as item_span:
614
+ try:
615
+ with telemetry.agent_invoke_span(
616
+ target="agent" if target.kind.startswith("foundry") else "model",
617
+ model=target.deployment,
618
+ agent_id=target.raw if target.kind.startswith("foundry") else None,
619
+ agent_name=target.name,
620
+ agent_version=target.version,
621
+ ) as invoke_span:
622
+ invocation = invocations.invoke(target, config, row, timeout=timeout)
623
+ telemetry.set_agent_invoke_result(
624
+ invoke_span,
625
+ response_model=target.deployment,
626
+ )
627
+ except Exception as exc: # noqa: BLE001
628
+ telemetry.set_eval_item_result(item_span, passed=False)
629
+ logger.debug("row %d invocation failed: %s", index, exc)
630
+ progress(f"{label} {style('invocation FAILED', 'bold', 'red')}: {exc}")
631
+ return RowResult(
632
+ row_index=index,
633
+ input=str(row.get("input", "")),
634
+ expected=row.get("expected"),
635
+ response="",
636
+ context=row.get("context"),
637
+ error=str(exc),
638
+ )
639
+
640
+ tool_count = len(invocation.tool_calls) if invocation.tool_calls else 0
641
+ progress(
642
+ f"{label} replied in {style(f'{invocation.latency_seconds:.2f}s', 'cyan')} "
643
+ f"({tool_count} tool call(s)); scoring..."
644
+ )
645
+
646
+ metrics: List[RowMetric] = []
647
+ for evaluator in evaluators:
648
+ metric = runtime.run_evaluator(
649
+ evaluator,
650
+ row=row,
651
+ response=invocation.response,
652
+ latency_seconds=invocation.latency_seconds,
653
+ actual_tool_calls=invocation.tool_calls,
654
+ )
655
+ metrics.append(metric)
656
+
657
+ rule = (rules_by_metric or {}).get(metric.name)
658
+ metric_passed = (
659
+ None
660
+ if metric.value is None or rule is None
661
+ else _metric_passes(rule, float(metric.value))
662
+ )
663
+ telemetry.record_evaluator_span(
664
+ evaluator_name=evaluator.preset.name,
665
+ builtin_name=metric.name,
666
+ source=(
667
+ "local"
668
+ if evaluator.preset.class_name == "_latency"
669
+ else "azure-ai-evaluation"
670
+ ),
671
+ score=float(metric.value) if metric.value is not None else 0.0,
672
+ threshold=rule.value if rule is not None else None,
673
+ criteria=rule.criteria if rule is not None else None,
674
+ passed=metric_passed,
675
+ )
676
+
677
+ telemetry.set_eval_item_result(
678
+ item_span,
679
+ passed=all(metric.error is None for metric in metrics),
680
+ )
681
+
682
+ rules = rules_by_metric or {}
683
+
684
+ def _format_metric(m: RowMetric) -> str:
685
+ if isinstance(m.value, (int, float)):
686
+ rule = rules.get(m.name)
687
+ text = f"{m.value:.2f}"
688
+ if rule is None:
689
+ # No user threshold for this metric: keep value neutral
690
+ # so the line stays readable.
691
+ return f"{m.name}={text}"
692
+ color = "green" if _metric_passes(rule, float(m.value)) else "red"
693
+ return f"{m.name}={style(text, color)}"
694
+ if m.error:
695
+ return f"{m.name}={style('ERR', 'red')}"
696
+ return f"{m.name}={style('n/a', 'dim')}"
697
+
698
+ scored = ", ".join(_format_metric(m) for m in metrics)
699
+ progress(f"{label} scored: {scored}")
700
+
701
+ return RowResult(
702
+ row_index=index,
703
+ input=str(row.get("input", "")),
704
+ expected=row.get("expected"),
705
+ response=invocation.response,
706
+ context=row.get("context"),
707
+ latency_seconds=invocation.latency_seconds,
708
+ tool_calls=invocation.tool_calls,
709
+ metrics=metrics,
710
+ )
711
+
712
+
713
+ # ---------------------------------------------------------------------------
714
+ # Aggregation
715
+ # ---------------------------------------------------------------------------
716
+
717
+
718
+ def _aggregate_metrics(rows: List[RowResult]) -> Dict[str, float]:
719
+ by_metric: Dict[str, List[float]] = {}
720
+ for row in rows:
721
+ for metric in row.metrics:
722
+ if metric.value is None:
723
+ continue
724
+ by_metric.setdefault(metric.name, []).append(metric.value)
725
+ aggregate: Dict[str, float] = {}
726
+ for name, values in by_metric.items():
727
+ if values:
728
+ aggregate[name] = statistics.fmean(values)
729
+ return aggregate
730
+
731
+
732
+ def _summarize(
733
+ rows: List[RowResult],
734
+ threshold_results,
735
+ ) -> RunSummary:
736
+ items_total = len(rows)
737
+ items_passed_all = sum(
738
+ 1
739
+ for row in rows
740
+ if row.error is None and all(m.error is None for m in row.metrics)
741
+ )
742
+ items_pass_rate = items_passed_all / items_total if items_total else 0.0
743
+ thresholds_total = len(threshold_results)
744
+ thresholds_passed = sum(1 for t in threshold_results if t.passed)
745
+ threshold_pass_rate = (
746
+ thresholds_passed / thresholds_total if thresholds_total else 1.0
747
+ )
748
+ overall = items_total > 0 and threshold_pass_rate == 1.0 and items_passed_all > 0
749
+ return RunSummary(
750
+ items_total=items_total,
751
+ items_passed_all=items_passed_all,
752
+ items_pass_rate=items_pass_rate,
753
+ thresholds_total=thresholds_total,
754
+ thresholds_passed=thresholds_passed,
755
+ threshold_pass_rate=threshold_pass_rate,
756
+ overall_passed=overall,
757
+ )
758
+
759
+
760
+ # ---------------------------------------------------------------------------
761
+ # Persistence
762
+ # ---------------------------------------------------------------------------
763
+
764
+
765
+ def _persist(result: RunResult, output_dir: Path) -> None:
766
+ output_dir.mkdir(parents=True, exist_ok=True)
767
+ results_path = output_dir / "results.json"
768
+ report_path = output_dir / "report.md"
769
+
770
+ payload = result.model_dump(mode="json")
771
+ results_path.write_text(
772
+ json.dumps(payload, indent=2, ensure_ascii=False),
773
+ encoding="utf-8",
774
+ )
775
+ report_path.write_text(reporter.render(result), encoding="utf-8")