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,1129 @@
1
+ """Read-only CI/CD analysis for Azure AI accelerator repositories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import textwrap
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Iterable, List, Optional, Sequence
10
+
11
+ from agentops.core.agentops_config import classify_agent
12
+ from agentops.pipeline.official_eval import (
13
+ AGENTOPS_CLOUD_RUNNER,
14
+ AGENTOPS_LOCAL_RUNNER,
15
+ OFFICIAL_EVAL_RUNNER,
16
+ analyze_official_eval_support,
17
+ official_eval_action_ref,
18
+ )
19
+ from agentops.utils.yaml import load_yaml
20
+
21
+ _TEXT_LIMIT = 200_000
22
+ _SCAN_LIMIT = 80
23
+ _TEXT_WRAP_WIDTH = 92
24
+ _IGNORE_PARTS = {
25
+ ".agentops",
26
+ ".azure",
27
+ ".git",
28
+ ".github",
29
+ ".mypy_cache",
30
+ ".pytest_cache",
31
+ ".ruff_cache",
32
+ ".venv",
33
+ "__pycache__",
34
+ "build",
35
+ "dist",
36
+ "node_modules",
37
+ "site-packages",
38
+ }
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class WorkflowSignal:
43
+ """A local file-system signal used to classify CI/CD shape."""
44
+
45
+ key: str
46
+ label: str
47
+ detail: str
48
+ path: Optional[str] = None
49
+ confidence: str = "high"
50
+
51
+ def to_dict(self) -> Dict[str, str]:
52
+ data = {
53
+ "key": self.key,
54
+ "label": self.label,
55
+ "detail": self.detail,
56
+ "confidence": self.confidence,
57
+ }
58
+ if self.path:
59
+ data["path"] = self.path
60
+ return data
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class WorkflowStage:
65
+ """Recommended pipeline stage."""
66
+
67
+ name: str
68
+ owner: str
69
+ purpose: str
70
+ commands: List[str] = field(default_factory=list)
71
+ notes: List[str] = field(default_factory=list)
72
+
73
+ def to_dict(self) -> Dict[str, Any]:
74
+ return {
75
+ "name": self.name,
76
+ "owner": self.owner,
77
+ "purpose": self.purpose,
78
+ "commands": list(self.commands),
79
+ "notes": list(self.notes),
80
+ }
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class WorkflowAnalysis:
85
+ """Stable result contract for `agentops workflow analyze`."""
86
+
87
+ version: int
88
+ directory: str
89
+ classification: str
90
+ recommended_deploy_mode: str
91
+ recommended_eval_runner: str
92
+ deployment_strategy: str
93
+ eval_strategy: str
94
+ complexity: str
95
+ requires_copilot_adaptation: bool
96
+ copilot_skills_installed: bool
97
+ copilot_prompt: Optional[str] = None
98
+ official_eval_reasons: List[str] = field(default_factory=list)
99
+ official_evaluators: List[str] = field(default_factory=list)
100
+ signals: List[WorkflowSignal] = field(default_factory=list)
101
+ warnings: List[str] = field(default_factory=list)
102
+ recommended_commands: List[str] = field(default_factory=list)
103
+ stages: List[WorkflowStage] = field(default_factory=list)
104
+ next_steps: List[str] = field(default_factory=list)
105
+
106
+ def to_dict(self) -> Dict[str, Any]:
107
+ return {
108
+ "version": self.version,
109
+ "directory": self.directory,
110
+ "classification": self.classification,
111
+ "recommended_deploy_mode": self.recommended_deploy_mode,
112
+ "recommended_eval_runner": self.recommended_eval_runner,
113
+ "deployment_strategy": self.deployment_strategy,
114
+ "eval_strategy": self.eval_strategy,
115
+ "complexity": self.complexity,
116
+ "requires_copilot_adaptation": self.requires_copilot_adaptation,
117
+ "copilot_skills_installed": self.copilot_skills_installed,
118
+ "copilot_prompt": self.copilot_prompt,
119
+ "official_eval_reasons": list(self.official_eval_reasons),
120
+ "official_evaluators": list(self.official_evaluators),
121
+ "signals": [signal.to_dict() for signal in self.signals],
122
+ "warnings": list(self.warnings),
123
+ "recommended_commands": list(self.recommended_commands),
124
+ "stages": [stage.to_dict() for stage in self.stages],
125
+ "next_steps": list(self.next_steps),
126
+ }
127
+
128
+
129
+ def analyze_workflow_project(directory: Path) -> WorkflowAnalysis:
130
+ """Analyze a copied accelerator/app repo and recommend CI/CD shape.
131
+
132
+ This is intentionally local-only: it does not call Azure, `azd`, GitHub, or
133
+ Foundry. The goal is to give a coding agent and the user a grounded plan
134
+ before generating or adapting workflow files.
135
+ """
136
+
137
+ root = directory.resolve()
138
+ signals: List[WorkflowSignal] = []
139
+ warnings: List[str] = []
140
+
141
+ azure_yaml = root / "azure.yaml"
142
+ has_azd = azure_yaml.exists()
143
+ if has_azd:
144
+ signals.append(
145
+ WorkflowSignal(
146
+ "azd_project",
147
+ "Azure Developer CLI project",
148
+ "azure.yaml found; prefer azd for provision/deploy lifecycle.",
149
+ _rel(root, azure_yaml),
150
+ )
151
+ )
152
+
153
+ agentops = _agentops_signal(root)
154
+ prompt_agent = bool(agentops.get("prompt_agent"))
155
+ if agentops:
156
+ signals.append(agentops["signal"])
157
+ if agentops.get("prompt_file"):
158
+ signals.append(
159
+ WorkflowSignal(
160
+ "prompt_file",
161
+ "Source-controlled prompt file",
162
+ "Prompt-agent CI/CD can create a candidate Foundry version from this file.",
163
+ str(agentops["prompt_file"]),
164
+ )
165
+ )
166
+
167
+ bicep_files = _find_files(root, "*.bicep")
168
+ if bicep_files:
169
+ signals.append(
170
+ WorkflowSignal(
171
+ "bicep_infra",
172
+ "Bicep infrastructure",
173
+ f"Found {len(bicep_files)} Bicep file(s); keep infra changes behind azd/Bicep review.",
174
+ _rel(root, bicep_files[0]),
175
+ )
176
+ )
177
+
178
+ manifest = _read_json(root / "manifest.json")
179
+ ailz_manifest = isinstance(manifest, dict) and any(
180
+ key in manifest for key in ("ailz_tag", "ailz_version", "components")
181
+ )
182
+ if ailz_manifest:
183
+ signals.append(
184
+ WorkflowSignal(
185
+ "ailz_manifest",
186
+ "AI Landing Zone manifest",
187
+ "manifest.json pins landing-zone/components; treat it as release input.",
188
+ "manifest.json",
189
+ )
190
+ )
191
+
192
+ ailz_preflight = has_ailz_preflight(root)
193
+ if ailz_preflight:
194
+ signals.append(
195
+ WorkflowSignal(
196
+ "ailz_preflight",
197
+ "AI Landing Zone preflight",
198
+ "Invoke-PreflightChecks.ps1 found; run it before azd provision in CI/CD.",
199
+ "scripts/Invoke-PreflightChecks.ps1",
200
+ )
201
+ )
202
+
203
+ infra_text = "\n".join(_read_text(path) for path in _infra_scan_files(root, bicep_files))
204
+ readme_text = _read_text(root / "README.md")
205
+ readme_lower = readme_text.lower()
206
+
207
+ network_isolated = _has_network_isolation(infra_text)
208
+ if network_isolated:
209
+ signals.append(
210
+ WorkflowSignal(
211
+ "network_isolation",
212
+ "Network-isolated Azure AI topology",
213
+ "Structural infra mentions private endpoints, VNet/jumpbox, firewall, or NETWORK_ISOLATION.",
214
+ confidence="high",
215
+ )
216
+ )
217
+ warnings.append(
218
+ "Network-isolated deployments often cannot complete all data-plane "
219
+ "steps from GitHub-hosted runners. Use azd hooks plus a self-hosted "
220
+ "runner in the VNet, jumpbox handoff, or ACR Tasks agent pool for "
221
+ "private build/deploy/post-provision work."
222
+ )
223
+ elif "network isolation" in readme_lower or "private endpoint" in readme_lower:
224
+ signals.append(
225
+ WorkflowSignal(
226
+ "network_isolation_hint",
227
+ "Network isolation mentioned",
228
+ "README mentions network isolation/private endpoints; verify infra before choosing runner topology.",
229
+ "README.md",
230
+ confidence="medium",
231
+ )
232
+ )
233
+
234
+ if _looks_like_container_app(root, infra_text, readme_lower):
235
+ signals.append(
236
+ WorkflowSignal(
237
+ "container_app",
238
+ "Containerized Azure app",
239
+ "Container App/Docker signals found; build and deploy steps are project-specific.",
240
+ confidence="high",
241
+ )
242
+ )
243
+
244
+ accelerator_hint = _accelerator_hint(readme_lower)
245
+ if accelerator_hint:
246
+ signals.append(accelerator_hint)
247
+
248
+ existing_ci = _existing_ci_signal(root)
249
+ if existing_ci:
250
+ signals.append(existing_ci)
251
+
252
+ if not (root / "agentops.yaml").exists():
253
+ warnings.append(
254
+ "No agentops.yaml found. Run `agentops init` and prove `agentops eval run` locally before making the pipeline blocking."
255
+ )
256
+
257
+ official_eval_reasons: List[str] = []
258
+ official_evaluators: List[str] = []
259
+ recommended_eval_runner = AGENTOPS_LOCAL_RUNNER
260
+ if (root / "agentops.yaml").exists():
261
+ official_support = analyze_official_eval_support(root / "agentops.yaml")
262
+ official_eval_reasons = list(official_support.reasons)
263
+ official_evaluators = list(official_support.official_evaluators)
264
+ if official_support.eligible:
265
+ recommended_eval_runner = AGENTOPS_CLOUD_RUNNER
266
+ signals.append(
267
+ WorkflowSignal(
268
+ "agentops_cloud_evaluation",
269
+ "AgentOps cloud eval runner",
270
+ "prompt agent and dataset are compatible; CI can run Foundry cloud eval with AgentOps threshold enforcement.",
271
+ "agentops.yaml",
272
+ )
273
+ )
274
+ warnings.extend(official_support.warnings)
275
+ else:
276
+ warnings.append(
277
+ "Microsoft Foundry AI Agent Evaluation not selected: "
278
+ + " ".join(official_support.reasons)
279
+ )
280
+
281
+ recommended_deploy_mode = _recommended_deploy_mode(has_azd, prompt_agent)
282
+ classification = _classification(has_azd, prompt_agent, network_isolated, ailz_manifest, accelerator_hint)
283
+ complexity = _complexity(network_isolated, has_azd, bicep_files, accelerator_hint, ailz_manifest)
284
+ deployment_strategy = _deployment_strategy(recommended_deploy_mode, network_isolated, ailz_preflight)
285
+ eval_strategy = _eval_strategy(recommended_eval_runner)
286
+ requires_copilot = (
287
+ recommended_deploy_mode == "placeholder"
288
+ or network_isolated
289
+ or bool(accelerator_hint)
290
+ or len(bicep_files) > 5
291
+ )
292
+
293
+ skills_installed = _skills_installed(root)
294
+ copilot_prompt = _copilot_prompt(classification, recommended_deploy_mode, network_isolated) if requires_copilot else None
295
+ recommended_commands = [
296
+ "agentops workflow analyze --format markdown",
297
+ f"agentops workflow generate --kinds pr,dev,qa,prod --deploy-mode {recommended_deploy_mode} --force",
298
+ ]
299
+ if ailz_preflight:
300
+ recommended_commands.insert(1, "pwsh ./scripts/Invoke-PreflightChecks.ps1 -Strict")
301
+ if requires_copilot and not skills_installed:
302
+ recommended_commands.insert(1, "agentops skills install --platform copilot")
303
+ if has_azd:
304
+ recommended_commands.append("azd provision")
305
+ recommended_commands.append("azd deploy")
306
+
307
+ stages = _stages(
308
+ recommended_deploy_mode,
309
+ recommended_eval_runner,
310
+ network_isolated,
311
+ prompt_agent,
312
+ ailz_preflight,
313
+ )
314
+ next_steps = _next_steps(
315
+ recommended_deploy_mode,
316
+ recommended_eval_runner,
317
+ requires_copilot,
318
+ network_isolated,
319
+ skills_installed,
320
+ ailz_preflight,
321
+ )
322
+
323
+ return WorkflowAnalysis(
324
+ version=1,
325
+ directory=str(root),
326
+ classification=classification,
327
+ recommended_deploy_mode=recommended_deploy_mode,
328
+ recommended_eval_runner=recommended_eval_runner,
329
+ deployment_strategy=deployment_strategy,
330
+ eval_strategy=eval_strategy,
331
+ complexity=complexity,
332
+ requires_copilot_adaptation=requires_copilot,
333
+ copilot_skills_installed=skills_installed,
334
+ copilot_prompt=copilot_prompt,
335
+ official_eval_reasons=official_eval_reasons,
336
+ official_evaluators=official_evaluators,
337
+ signals=signals,
338
+ warnings=warnings,
339
+ recommended_commands=recommended_commands,
340
+ stages=stages,
341
+ next_steps=next_steps,
342
+ )
343
+
344
+
345
+ def recommended_deploy_mode(directory: Path) -> str:
346
+ """Return the same deploy-mode decision used by workflow generation."""
347
+ return analyze_workflow_project(directory).recommended_deploy_mode
348
+
349
+
350
+ def recommended_eval_runner(directory: Path) -> str:
351
+ """Return the same eval-runner decision used by workflow generation."""
352
+ return analyze_workflow_project(directory).recommended_eval_runner
353
+
354
+
355
+ def _display_eval_runner(eval_runner: str) -> str:
356
+ if eval_runner == AGENTOPS_CLOUD_RUNNER:
357
+ return "AgentOps cloud eval in Foundry"
358
+ if eval_runner == OFFICIAL_EVAL_RUNNER:
359
+ return "Microsoft Foundry AI Agent Evaluation"
360
+ if eval_runner == AGENTOPS_LOCAL_RUNNER:
361
+ return "AgentOps local eval"
362
+ return eval_runner
363
+
364
+
365
+ def has_ailz_preflight(directory: Path) -> bool:
366
+ """Return True when the official AI Landing Zone preflight script exists."""
367
+ root = directory.resolve()
368
+ return (root / "scripts" / "Invoke-PreflightChecks.ps1").exists()
369
+
370
+
371
+ def render_workflow_analysis(analysis: WorkflowAnalysis, output_format: str = "text") -> str:
372
+ """Render analysis as text, Markdown, or JSON."""
373
+ if output_format == "json":
374
+ return json.dumps(analysis.to_dict(), indent=2) + "\n"
375
+ if output_format == "markdown":
376
+ return _render_markdown(analysis)
377
+ if output_format == "text":
378
+ return _render_text(analysis)
379
+ raise ValueError("output_format must be text, markdown, or json")
380
+
381
+
382
+ def _render_text(analysis: WorkflowAnalysis) -> str:
383
+ lines = [
384
+ "AgentOps workflow analysis",
385
+ f"Workspace: {analysis.directory}",
386
+ f"Project: {analysis.classification}",
387
+ "",
388
+ "Recommendation",
389
+ ]
390
+ lines.extend(_render_text_recommendation(analysis))
391
+ lines.append("")
392
+ lines.append("Signals")
393
+ lines.extend(_render_text_signal_rows(_signal_rows(analysis)))
394
+ if analysis.warnings:
395
+ lines.append("")
396
+ lines.append("Warnings")
397
+ for warning in analysis.warnings:
398
+ lines.extend(_wrapped_status_line("warn", "warning", warning))
399
+ if analysis.official_eval_reasons:
400
+ lines.append("")
401
+ lines.append("Foundry eval")
402
+ lines.extend(_render_text_foundry_eval_rows(_foundry_eval_rows(analysis)))
403
+ if analysis.copilot_prompt:
404
+ lines.append("")
405
+ lines.append("Copilot handoff")
406
+ lines.extend(_wrapped_status_line("todo", "copy/paste", analysis.copilot_prompt))
407
+ lines.append("")
408
+ lines.append("Pipeline plan")
409
+ for index, stage in enumerate(analysis.stages, start=1):
410
+ lines.append(f" {index}. {stage.name}")
411
+ lines.extend(_wrap_text(stage.purpose, indent=" "))
412
+ commands = _text_commands(analysis.recommended_commands)
413
+ if commands:
414
+ lines.append("")
415
+ lines.append("Commands")
416
+ lines.extend(f" {command}" for command in commands)
417
+ lines.append("")
418
+ lines.append("Next")
419
+ for index, step in enumerate(analysis.next_steps, start=1):
420
+ lines.extend(_wrapped_numbered_step(index, step))
421
+ return "\n".join(lines) + "\n"
422
+
423
+
424
+ def _render_markdown(analysis: WorkflowAnalysis) -> str:
425
+ lines = [
426
+ "# AgentOps workflow analysis",
427
+ "",
428
+ f"- **Directory:** `{analysis.directory}`",
429
+ f"- **Classification:** {analysis.classification}",
430
+ "",
431
+ "## Workflow decision checklist",
432
+ "",
433
+ ]
434
+ lines.extend(_render_markdown_table(("Check", "Status", "Explanation"), _decision_checklist_rows(analysis)))
435
+ lines.extend(
436
+ [
437
+ "",
438
+ "## Detected signals",
439
+ "",
440
+ ]
441
+ )
442
+ lines.extend(_render_markdown_table(("Status", "Type", "Finding", "Evidence"), _signal_rows(analysis)))
443
+ if analysis.warnings:
444
+ lines.extend(["", "## Warnings", ""])
445
+ lines.extend(f"- {warning}" for warning in analysis.warnings)
446
+ if analysis.official_eval_reasons:
447
+ lines.extend(["", "## Foundry eval checks", ""])
448
+ lines.extend(_render_markdown_table(("Status", "Check", "Explanation"), _foundry_eval_rows(analysis)))
449
+ if analysis.copilot_prompt:
450
+ lines.extend(["", "## Copilot handoff", ""])
451
+ lines.extend(["Copy/paste this into Copilot:", "", "```text", analysis.copilot_prompt, "```"])
452
+ lines.extend(["", "## Recommended commands", ""])
453
+ lines.extend(f"```bash\n{command}\n```" for command in analysis.recommended_commands)
454
+ lines.extend(["", "## Pipeline stages", ""])
455
+ for stage in analysis.stages:
456
+ lines.append(f"### {stage.name}")
457
+ lines.append("")
458
+ lines.append(f"- **Owner:** {stage.owner}")
459
+ lines.append(f"- **Purpose:** {stage.purpose}")
460
+ if stage.commands:
461
+ lines.append("- **Commands:** " + ", ".join(f"`{c}`" for c in stage.commands))
462
+ for note in stage.notes:
463
+ lines.append(f"- {note}")
464
+ lines.append("")
465
+ lines.extend(["## Next steps", ""])
466
+ lines.extend(f"- {step}" for step in analysis.next_steps)
467
+ return "\n".join(lines).rstrip() + "\n"
468
+
469
+
470
+ def _render_text_recommendation(analysis: WorkflowAnalysis) -> List[str]:
471
+ adaptation_value = (
472
+ "needed - review project-specific build/deploy steps"
473
+ if analysis.requires_copilot_adaptation
474
+ else "not needed - generated workflow should work as-is"
475
+ )
476
+ skills_value = (
477
+ "installed - available for workflow adaptation handoff"
478
+ if analysis.copilot_skills_installed
479
+ else (
480
+ "missing - run `agentops skills install --platform copilot` for handoff"
481
+ if analysis.requires_copilot_adaptation
482
+ else "not needed - no Copilot handoff for this project shape"
483
+ )
484
+ )
485
+ return _render_text_fields(
486
+ [
487
+ ("deploy", analysis.recommended_deploy_mode),
488
+ ("evaluate", _display_eval_runner(analysis.recommended_eval_runner)),
489
+ ("workflow edits", adaptation_value),
490
+ ("Copilot skills", skills_value),
491
+ ]
492
+ )
493
+
494
+
495
+ def _text_commands(commands: Sequence[str]) -> List[str]:
496
+ return [command for command in commands if command != "agentops workflow analyze --format markdown"]
497
+
498
+
499
+ def _render_text_fields(rows: Sequence[tuple[str, str]]) -> List[str]:
500
+ width = max(len(label) for label, _ in rows)
501
+ lines: List[str] = []
502
+ for label, value in rows:
503
+ lines.extend(_wrap_text(value, indent=f" {label.ljust(width)} "))
504
+ return lines
505
+
506
+
507
+ def _render_text_signal_rows(rows: Sequence[Sequence[str]]) -> List[str]:
508
+ lines: List[str] = []
509
+ for status, signal_type, _finding, evidence in rows:
510
+ marker, _ = _split_status_value(str(status))
511
+ detail = _soften_text(str(evidence))
512
+ lines.extend(_wrapped_status_line(_status_word(marker), str(signal_type), detail))
513
+ return lines
514
+
515
+
516
+ def _render_text_foundry_eval_rows(rows: Sequence[Sequence[str]]) -> List[str]:
517
+ lines: List[str] = []
518
+ for status, check, explanation in rows:
519
+ marker, _ = _split_status_value(str(status))
520
+ lines.extend(
521
+ _wrapped_status_line(
522
+ _status_word(marker),
523
+ str(check),
524
+ _friendly_foundry_eval_text(str(check), str(explanation)),
525
+ )
526
+ )
527
+ return lines
528
+
529
+
530
+ def _split_status_value(status: str) -> tuple[str, str]:
531
+ if status.startswith("[") and "]" in status:
532
+ marker, _, value = status.partition(" ")
533
+ return marker, value.strip()
534
+ return status, ""
535
+
536
+
537
+ def _status_word(marker: str) -> str:
538
+ if marker == "[x]":
539
+ return "ok"
540
+ if marker == "[?]":
541
+ return "hint"
542
+ if marker == "[ ]":
543
+ return "todo"
544
+ return marker.strip("[]").lower() or "info"
545
+
546
+
547
+ def _wrapped_status_line(status: str, label: str, text: str) -> List[str]:
548
+ prefix = f" {status.ljust(4)} {label.ljust(13)} "
549
+ wrapped = textwrap.wrap(
550
+ text,
551
+ width=_TEXT_WRAP_WIDTH,
552
+ initial_indent=prefix,
553
+ subsequent_indent=" " * len(prefix),
554
+ break_long_words=False,
555
+ break_on_hyphens=False,
556
+ )
557
+ return wrapped or [prefix.rstrip()]
558
+
559
+
560
+ def _wrapped_numbered_step(index: int, text: str) -> List[str]:
561
+ prefix = f" {index}. "
562
+ wrapped = textwrap.wrap(
563
+ text,
564
+ width=_TEXT_WRAP_WIDTH,
565
+ initial_indent=prefix,
566
+ subsequent_indent=" " * len(prefix),
567
+ break_long_words=False,
568
+ break_on_hyphens=False,
569
+ )
570
+ return wrapped or [prefix.rstrip()]
571
+
572
+
573
+ def _friendly_foundry_eval_text(check: str, text: str) -> str:
574
+ if check == "Agent target":
575
+ return "Foundry prompt agent (`name:version`)."
576
+ if check == "Evaluators":
577
+ return _friendly_evaluator_list(text.split(", "))
578
+ return _soften_text(text)
579
+
580
+
581
+ def _friendly_evaluator_list(evaluators: Iterable[str]) -> str:
582
+ return ", ".join(
583
+ evaluator.removeprefix("builtin.").replace("_", " ")
584
+ for evaluator in evaluators
585
+ if evaluator
586
+ )
587
+
588
+
589
+ def _soften_text(text: str) -> str:
590
+ return text.replace("foundry_prompt", "Foundry prompt agent")
591
+
592
+
593
+ def _wrap_text(text: str, *, indent: str) -> List[str]:
594
+ return textwrap.wrap(
595
+ text,
596
+ width=_TEXT_WRAP_WIDTH,
597
+ initial_indent=indent,
598
+ subsequent_indent=indent,
599
+ break_long_words=False,
600
+ break_on_hyphens=False,
601
+ ) or [indent.rstrip()]
602
+
603
+
604
+ def _render_markdown_table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> List[str]:
605
+ normalized = [[_escape_markdown_cell(str(cell)) for cell in row] for row in rows]
606
+ if not normalized:
607
+ normalized = [["-" for _ in headers]]
608
+ header_line = "| " + " | ".join(_escape_markdown_cell(str(header)) for header in headers) + " |"
609
+ separator = "| " + " | ".join("---" for _ in headers) + " |"
610
+ body = ["| " + " | ".join(row) + " |" for row in normalized]
611
+ return [header_line, separator, *body]
612
+
613
+
614
+ def _escape_markdown_cell(value: str) -> str:
615
+ return value.replace("|", "\\|")
616
+
617
+
618
+ def _decision_checklist_rows(analysis: WorkflowAnalysis) -> List[tuple[str, str, str]]:
619
+ if analysis.requires_copilot_adaptation:
620
+ adaptation_status = "[ ] needs edits"
621
+ adaptation_detail = (
622
+ "Detected project-specific topology or deploy signals; review generated "
623
+ "workflow before making it blocking."
624
+ )
625
+ else:
626
+ adaptation_status = "[x] not needed"
627
+ adaptation_detail = "Generated workflow should be directly usable for this project shape."
628
+
629
+ if analysis.requires_copilot_adaptation:
630
+ skills_status = "[x] installed" if analysis.copilot_skills_installed else "[ ] missing"
631
+ skills_detail = "Needed only for the Copilot workflow-adaptation handoff."
632
+ else:
633
+ skills_status = "[x] not required"
634
+ skills_detail = "No workflow handoff is required for the current recommendation."
635
+
636
+ return [
637
+ (
638
+ "Deploy mode",
639
+ f"[x] {analysis.recommended_deploy_mode}",
640
+ _deploy_mode_check_detail(analysis.recommended_deploy_mode),
641
+ ),
642
+ (
643
+ "Eval runner",
644
+ f"[x] {_display_eval_runner(analysis.recommended_eval_runner)}",
645
+ _eval_runner_check_detail(analysis.recommended_eval_runner),
646
+ ),
647
+ ("Complexity", "[x] " + analysis.complexity, "Used to decide whether CI needs extra review."),
648
+ ("Workflow adaptation", adaptation_status, adaptation_detail),
649
+ ("Copilot skills", skills_status, skills_detail),
650
+ ]
651
+
652
+
653
+ def _deploy_mode_check_detail(mode: str) -> str:
654
+ if mode == "azd":
655
+ return "Use azd for provision/deploy; AgentOps supplies gates and evidence."
656
+ if mode == "prompt-agent":
657
+ return "Stage and evaluate a Foundry prompt candidate, then record deployment."
658
+ return "Generate CI placeholders; add the project-specific build/deploy steps."
659
+
660
+
661
+ def _eval_runner_check_detail(eval_runner: str) -> str:
662
+ if eval_runner == AGENTOPS_CLOUD_RUNNER:
663
+ return "Foundry executes the eval; AgentOps downloads results, applies thresholds, and writes evidence."
664
+ if eval_runner == OFFICIAL_EVAL_RUNNER:
665
+ return "Prompt agent plus dataset fit Foundry eval; AgentOps keeps evidence."
666
+ return "AgentOps runs local eval and writes normalized results/report artifacts."
667
+
668
+
669
+ def _signal_rows(analysis: WorkflowAnalysis) -> List[tuple[str, str, str, str]]:
670
+ if not analysis.signals:
671
+ return [
672
+ (
673
+ "[ ]",
674
+ "Signals",
675
+ "No strong project signals",
676
+ "No accelerator, azd, AgentOps, or CI files were detected.",
677
+ )
678
+ ]
679
+ return [
680
+ (
681
+ "[x]" if signal.confidence == "high" else "[?]",
682
+ _signal_type(signal.key),
683
+ signal.label,
684
+ signal.detail + (f" ({signal.path})" if signal.path else ""),
685
+ )
686
+ for signal in analysis.signals
687
+ ]
688
+
689
+
690
+ def _foundry_eval_rows(analysis: WorkflowAnalysis) -> List[tuple[str, str, str]]:
691
+ selected = analysis.recommended_eval_runner in {AGENTOPS_CLOUD_RUNNER, OFFICIAL_EVAL_RUNNER}
692
+ if selected:
693
+ rows = [
694
+ (
695
+ "[x]",
696
+ "Agent target",
697
+ analysis.official_eval_reasons[0]
698
+ if analysis.official_eval_reasons
699
+ else "Foundry prompt agent.",
700
+ ),
701
+ (
702
+ "[x]",
703
+ "Dataset",
704
+ analysis.official_eval_reasons[1]
705
+ if len(analysis.official_eval_reasons) > 1
706
+ else "Compatible with Foundry cloud eval.",
707
+ ),
708
+ ]
709
+ if analysis.official_evaluators:
710
+ rows.append(("[x]", "Evaluators", ", ".join(analysis.official_evaluators)))
711
+ return rows
712
+
713
+ return [
714
+ ("[ ]", "Microsoft Foundry eval", reason)
715
+ for reason in analysis.official_eval_reasons
716
+ ]
717
+
718
+
719
+ def _signal_type(key: str) -> str:
720
+ return {
721
+ "agentops_config": "Config",
722
+ "official_ai_agent_evaluation": "Eval runner",
723
+ "agentops_cloud_evaluation": "Eval runner",
724
+ "azd_project": "Deploy mode",
725
+ "prompt_file": "Prompt source",
726
+ "bicep_infra": "Infrastructure",
727
+ "ailz_manifest": "Landing zone",
728
+ "ailz_preflight": "Preflight",
729
+ "network_isolation": "Runner topology",
730
+ "network_isolation_hint": "Runner topology",
731
+ "container_app": "Application host",
732
+ "accelerator_hint": "Accelerator",
733
+ "existing_ci": "Existing CI",
734
+ }.get(key, "Signal")
735
+
736
+
737
+ def _agentops_signal(root: Path) -> Dict[str, Any]:
738
+ path = root / "agentops.yaml"
739
+ if not path.exists():
740
+ return {}
741
+ try:
742
+ data = load_yaml(path)
743
+ target = classify_agent(str(data.get("agent", "") or ""), data.get("protocol"))
744
+ except Exception as exc:
745
+ return {
746
+ "signal": WorkflowSignal(
747
+ "agentops_config",
748
+ "AgentOps config",
749
+ f"agentops.yaml exists but could not be classified: {exc}",
750
+ "agentops.yaml",
751
+ confidence="medium",
752
+ )
753
+ }
754
+ prompt_file = data.get("prompt_file") if isinstance(data, dict) else None
755
+ return {
756
+ "prompt_agent": target.kind == "foundry_prompt",
757
+ "prompt_file": prompt_file,
758
+ "signal": WorkflowSignal(
759
+ "agentops_config",
760
+ "AgentOps config",
761
+ f"agentops.yaml targets {target.kind}.",
762
+ "agentops.yaml",
763
+ ),
764
+ }
765
+
766
+
767
+ def _find_files(root: Path, pattern: str) -> List[Path]:
768
+ found: List[Path] = []
769
+ for path in root.rglob(pattern):
770
+ if _ignored(path, root):
771
+ continue
772
+ found.append(path)
773
+ if len(found) >= _SCAN_LIMIT:
774
+ break
775
+ return found
776
+
777
+
778
+ def _infra_scan_files(root: Path, bicep_files: Iterable[Path]) -> List[Path]:
779
+ candidates = [
780
+ root / "azure.yaml",
781
+ root / "main.parameters.json",
782
+ root / "infra" / "main.parameters.json",
783
+ root / "manifest.json",
784
+ ]
785
+ candidates.extend(list(bicep_files)[:_SCAN_LIMIT])
786
+ return [path for path in candidates if path.exists() and not _ignored(path, root)]
787
+
788
+
789
+ def _ignored(path: Path, root: Path) -> bool:
790
+ try:
791
+ rel = path.relative_to(root)
792
+ except ValueError:
793
+ return True
794
+ return any(part in _IGNORE_PARTS for part in rel.parts)
795
+
796
+
797
+ def _read_text(path: Path) -> str:
798
+ try:
799
+ if not path.exists() or path.stat().st_size > _TEXT_LIMIT:
800
+ return ""
801
+ return path.read_text(encoding="utf-8", errors="ignore")
802
+ except OSError:
803
+ return ""
804
+
805
+
806
+ def _read_json(path: Path) -> Any:
807
+ text = _read_text(path)
808
+ if not text:
809
+ return None
810
+ try:
811
+ return json.loads(text)
812
+ except json.JSONDecodeError:
813
+ return None
814
+
815
+
816
+ def _has_network_isolation(text: str) -> bool:
817
+ lowered = text.lower()
818
+ structural_terms = (
819
+ "network_isolation",
820
+ "networkisolation",
821
+ "privateendpoint",
822
+ "private endpoint",
823
+ "microsoft.network/privatednszones",
824
+ "azurefirewall",
825
+ "azure firewall",
826
+ "bastion",
827
+ "jumpbox",
828
+ "acr_task_agent_pool",
829
+ "acr task",
830
+ "egressnexthopip",
831
+ )
832
+ return any(term in lowered for term in structural_terms)
833
+
834
+
835
+ def _looks_like_container_app(root: Path, infra_text: str, readme_lower: str) -> bool:
836
+ docker = bool(_find_files(root, "Dockerfile"))
837
+ infra_lower = infra_text.lower()
838
+ return (
839
+ "microsoft.app/containerapps" in infra_lower
840
+ or "container app" in readme_lower
841
+ or "containerapp" in infra_lower
842
+ or docker
843
+ )
844
+
845
+
846
+ def _accelerator_hint(readme_lower: str) -> Optional[WorkflowSignal]:
847
+ if "gpt-rag" in readme_lower or "retrieval-augmented generation" in readme_lower:
848
+ return WorkflowSignal(
849
+ "accelerator_hint",
850
+ "Azure AI accelerator hint",
851
+ "README looks like a RAG accelerator; expect app-specific data/index seeding and eval datasets.",
852
+ "README.md",
853
+ confidence="medium",
854
+ )
855
+ if "live voice" in readme_lower or "voice live" in readme_lower:
856
+ return WorkflowSignal(
857
+ "accelerator_hint",
858
+ "Azure AI accelerator hint",
859
+ "README looks like a voice accelerator; expect real-time app deploy plus scenario/rubric evaluation assets.",
860
+ "README.md",
861
+ confidence="medium",
862
+ )
863
+ if "ai landing zone" in readme_lower or "landing zone" in readme_lower:
864
+ return WorkflowSignal(
865
+ "accelerator_hint",
866
+ "AI Landing Zone hint",
867
+ "README mentions AI Landing Zone; verify topology and runner access before CI/CD rollout.",
868
+ "README.md",
869
+ confidence="medium",
870
+ )
871
+ return None
872
+
873
+
874
+ def _existing_ci_signal(root: Path) -> Optional[WorkflowSignal]:
875
+ github = root / ".github" / "workflows"
876
+ ado = root / ".azuredevops" / "pipelines"
877
+ if github.is_dir():
878
+ return WorkflowSignal(
879
+ "existing_ci",
880
+ "Existing GitHub Actions workflows",
881
+ "Existing workflows found; prefer updating generated AgentOps files rather than creating parallel pipeline names.",
882
+ ".github/workflows",
883
+ )
884
+ if ado.is_dir():
885
+ return WorkflowSignal(
886
+ "existing_ci",
887
+ "Existing Azure DevOps pipelines",
888
+ "Existing pipelines found; prefer updating generated AgentOps files rather than creating parallel pipeline names.",
889
+ ".azuredevops/pipelines",
890
+ )
891
+ return None
892
+
893
+
894
+ def _recommended_deploy_mode(has_azd: bool, prompt_agent: bool) -> str:
895
+ if has_azd:
896
+ return "azd"
897
+ if prompt_agent:
898
+ return "prompt-agent"
899
+ return "placeholder"
900
+
901
+
902
+ def _classification(
903
+ has_azd: bool,
904
+ prompt_agent: bool,
905
+ network_isolated: bool,
906
+ ailz_manifest: bool,
907
+ accelerator_hint: Optional[WorkflowSignal],
908
+ ) -> str:
909
+ if network_isolated or ailz_manifest:
910
+ return "Azure AI accelerator / landing-zone application"
911
+ if has_azd and accelerator_hint:
912
+ return "azd-managed Azure AI accelerator"
913
+ if has_azd:
914
+ return "azd-managed Azure AI application"
915
+ if prompt_agent:
916
+ return "Foundry prompt-agent project"
917
+ return "custom AI application"
918
+
919
+
920
+ def _complexity(
921
+ network_isolated: bool,
922
+ has_azd: bool,
923
+ bicep_files: List[Path],
924
+ accelerator_hint: Optional[WorkflowSignal],
925
+ ailz_manifest: bool,
926
+ ) -> str:
927
+ if network_isolated:
928
+ return "high - network-isolated deployment topology"
929
+ if ailz_manifest:
930
+ return "medium - AI Landing Zone deployment path"
931
+ if len(bicep_files) > 5 or (has_azd and accelerator_hint):
932
+ return "medium - accelerator or multi-resource Azure app"
933
+ if has_azd:
934
+ return "medium - azd-managed app"
935
+ return "low - simple AgentOps workflow scaffold"
936
+
937
+
938
+ def _deployment_strategy(mode: str, network_isolated: bool, ailz_preflight: bool) -> str:
939
+ if mode == "azd":
940
+ suffix = (
941
+ " Use private runner/jumpbox/ACR Tasks for private data-plane steps."
942
+ if network_isolated
943
+ else ""
944
+ )
945
+ preflight = (
946
+ " Run the AI Landing Zone preflight before provision."
947
+ if ailz_preflight
948
+ else ""
949
+ )
950
+ return "AgentOps gates; azd owns provision/deploy and hooks." + preflight + suffix
951
+ if mode == "prompt-agent":
952
+ return "AgentOps stages a Foundry prompt candidate, evaluates it, then records the deployed version."
953
+ return "AgentOps writes gates/placeholders; Copilot must adapt project-specific build/deploy steps."
954
+
955
+
956
+ def _eval_strategy(eval_runner: str) -> str:
957
+ if eval_runner == AGENTOPS_CLOUD_RUNNER:
958
+ return (
959
+ "Use AgentOps cloud eval so Foundry executes the prompt-agent eval "
960
+ "and AgentOps applies thresholds to normalized results."
961
+ )
962
+ if eval_runner == OFFICIAL_EVAL_RUNNER:
963
+ return (
964
+ "Use Microsoft Foundry AI Agent Evaluation for prompt-agent execution, "
965
+ "then keep AgentOps Doctor/evidence as advisory provenance."
966
+ )
967
+ return "Use AgentOps local eval as the CI gate and normalized results artifact."
968
+
969
+
970
+ def _eval_stage(eval_runner: str) -> WorkflowStage:
971
+ if eval_runner == AGENTOPS_CLOUD_RUNNER:
972
+ return WorkflowStage(
973
+ "PR evaluation gate",
974
+ "Foundry + AgentOps",
975
+ "Run Foundry cloud evaluation and let AgentOps enforce thresholds before merge.",
976
+ ["agentops eval run --config <cloud-eval-config> --output .agentops/results/latest"],
977
+ )
978
+ if eval_runner == OFFICIAL_EVAL_RUNNER:
979
+ return WorkflowStage(
980
+ "PR evaluation gate",
981
+ "Microsoft Foundry + AgentOps",
982
+ "Run Microsoft Foundry AI Agent Evaluation as advisory provenance.",
983
+ [
984
+ "python -m agentops.pipeline.official_eval prepare",
985
+ official_eval_action_ref(),
986
+ ],
987
+ )
988
+ return WorkflowStage(
989
+ "PR evaluation gate",
990
+ "AgentOps",
991
+ "Run repeatable evals before merge and publish report artifacts.",
992
+ ["agentops eval run"],
993
+ )
994
+
995
+
996
+ def _stages(
997
+ mode: str,
998
+ eval_runner: str,
999
+ network_isolated: bool,
1000
+ prompt_agent: bool,
1001
+ ailz_preflight: bool,
1002
+ ) -> List[WorkflowStage]:
1003
+ stages = [
1004
+ _eval_stage(eval_runner),
1005
+ WorkflowStage(
1006
+ "Operational readiness",
1007
+ "AgentOps Doctor",
1008
+ "Run repo, CI/CD, telemetry, and Foundry readiness checks.",
1009
+ ["agentops doctor"],
1010
+ ),
1011
+ ]
1012
+ if mode == "azd" and ailz_preflight:
1013
+ stages.append(
1014
+ WorkflowStage(
1015
+ "AI Landing Zone preflight",
1016
+ "AI Landing Zone + azd",
1017
+ "Validate topology, parameters, CIDRs, BYO resources, and observability wiring before ARM deployment.",
1018
+ ["pwsh ./scripts/Invoke-PreflightChecks.ps1 -Strict"],
1019
+ )
1020
+ )
1021
+ if mode == "azd":
1022
+ notes = ["Use azd hooks for pre/post provision or deploy customization."]
1023
+ if prompt_agent:
1024
+ notes.append("Keep prompt-agent evaluation in AgentOps even though azd owns app deployment.")
1025
+ if network_isolated:
1026
+ notes.append("Run private data-plane work from a runner with VNet/private endpoint access.")
1027
+ stages.append(
1028
+ WorkflowStage(
1029
+ "DEV/QA/PROD deploy",
1030
+ "azd",
1031
+ "Provision and deploy accelerator infrastructure/application.",
1032
+ ["azd provision", "azd deploy"],
1033
+ notes,
1034
+ )
1035
+ )
1036
+ elif mode == "prompt-agent":
1037
+ stages.append(
1038
+ WorkflowStage(
1039
+ "Foundry prompt candidate deploy",
1040
+ "Foundry + AgentOps",
1041
+ "Create/reuse candidate prompt-agent version, evaluate it, then record deployment.",
1042
+ [
1043
+ "python -m agentops.pipeline.prompt_deploy stage",
1044
+ (
1045
+ "python -m agentops.pipeline.official_eval prepare"
1046
+ if eval_runner == OFFICIAL_EVAL_RUNNER
1047
+ else "agentops eval run --config <cloud-eval-config>"
1048
+ if eval_runner == AGENTOPS_CLOUD_RUNNER
1049
+ else "agentops eval run"
1050
+ ),
1051
+ ],
1052
+ )
1053
+ )
1054
+ else:
1055
+ stages.append(
1056
+ WorkflowStage(
1057
+ "Project-specific deploy",
1058
+ "Copilot + project tooling",
1059
+ "Replace placeholders with the repo's build/deploy primitives.",
1060
+ notes=["Prefer azd if the project can be converted to azure.yaml."],
1061
+ )
1062
+ )
1063
+ return stages
1064
+
1065
+
1066
+ def _next_steps(
1067
+ mode: str,
1068
+ eval_runner: str,
1069
+ requires_copilot: bool,
1070
+ network_isolated: bool,
1071
+ skills_installed: bool,
1072
+ ailz_preflight: bool,
1073
+ ) -> List[str]:
1074
+ steps = [
1075
+ "Run `agentops eval run` locally and commit agentops.yaml plus datasets.",
1076
+ f"Generate workflows with `agentops workflow generate --deploy-mode {mode}`.",
1077
+ ]
1078
+ if eval_runner == AGENTOPS_CLOUD_RUNNER:
1079
+ steps.insert(
1080
+ 1,
1081
+ "Set AZURE_OPENAI_DEPLOYMENT so Foundry cloud eval can judge responses, then review AgentOps results.json/report.md after the run.",
1082
+ )
1083
+ elif eval_runner == OFFICIAL_EVAL_RUNNER:
1084
+ steps.insert(
1085
+ 1,
1086
+ "Set AZURE_OPENAI_DEPLOYMENT so Microsoft Foundry AI Agent Evaluation can judge responses.",
1087
+ )
1088
+ if ailz_preflight:
1089
+ steps.insert(0, "Run `pwsh ./scripts/Invoke-PreflightChecks.ps1 -Strict` before provisioning the AI Landing Zone.")
1090
+ if requires_copilot:
1091
+ if not skills_installed:
1092
+ steps.append("Install the AgentOps Copilot skills first: `agentops skills install --platform copilot`.")
1093
+ steps.append(
1094
+ "Copy/paste the Copilot handoff prompt shown below to inspect project-specific build/deploy hooks and adapt the workflow."
1095
+ )
1096
+ if network_isolated:
1097
+ steps.append(
1098
+ "Decide where private-network deploy steps run: self-hosted runner, jumpbox, or ACR Tasks agent pool."
1099
+ )
1100
+ steps.append("Configure environment approvals and Azure federated identity/service connection before making gates required.")
1101
+ return steps
1102
+
1103
+
1104
+ def _skills_installed(root: Path) -> bool:
1105
+ return (
1106
+ (root / ".github" / "skills" / "agentops-workflow" / "SKILL.md").exists()
1107
+ or (root / ".claude" / "commands" / "agentops-workflow.md").exists()
1108
+ )
1109
+
1110
+
1111
+ def _copilot_prompt(classification: str, mode: str, network_isolated: bool) -> str:
1112
+ network_note = (
1113
+ " This repo appears network-isolated; plan self-hosted runner, jumpbox handoff, or ACR Tasks for private steps."
1114
+ if network_isolated
1115
+ else ""
1116
+ )
1117
+ return (
1118
+ "/agentops-workflow Use the AgentOps workflow analysis above to adapt this "
1119
+ f"{classification} pipeline. Keep AgentOps as eval/Doctor/Cockpit gate, use deploy mode {mode}, "
1120
+ "and preserve existing azd/Bicep/project deploy hooks instead of inventing a parallel deployment path."
1121
+ + network_note
1122
+ )
1123
+
1124
+
1125
+ def _rel(root: Path, path: Path) -> str:
1126
+ try:
1127
+ return str(path.relative_to(root))
1128
+ except ValueError:
1129
+ return str(path)