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,435 @@
1
+ """azd environment integration for AgentOps.
2
+
3
+ AgentOps can share wizard-collected configuration with ``azd`` when a workspace
4
+ already has, or explicitly requests, an azd environment. Fresh AgentOps-only
5
+ workspaces use ``.agentops/.env`` instead. This module is the single source of
6
+ truth for:
7
+
8
+ * **Discovery** — find the active ``azd`` environment using the same rules
9
+ Doctor already uses (``AZURE_ENV_NAME`` env var first, then
10
+ ``.azure/config.json``'s ``defaultEnvironment``, then the single
11
+ environment folder when only one exists).
12
+ * **Reading** — parse the ``.env`` file with the same lenient
13
+ ``KEY=VALUE`` rules as our :mod:`agentops.utils.dotenv_loader`.
14
+ * **Writing** — line-preserving updates that keep comments, ordering, and
15
+ untouched keys intact. Newly added keys go to the end of the file.
16
+ * **Bootstrap** — when the caller deliberately opts into azd, create the
17
+ minimal layout (``.azure/<name>/.env`` + ``.azure/config.json`` +
18
+ ``.azure/.gitignore``) so AgentOps never writes secrets to a git-tracked
19
+ file.
20
+
21
+ Notes for reviewers:
22
+
23
+ * **No new dependencies.** The parser/writer here use the same micro-format
24
+ rules as ``dotenv_loader.parse_env_file``.
25
+ * **azd-compatible, not azd-required.** If ``azd`` is installed we can call its
26
+ CLI to mutate envs; if not, we fall back to direct line-preserving edits.
27
+ * **Secret safety.** Whenever we create the ``.azure/`` directory we also
28
+ drop a ``.gitignore`` that excludes ``*/.env`` so the wizard cannot leak
29
+ ``APPLICATIONINSIGHTS_CONNECTION_STRING`` into source control even if
30
+ the user has not run ``azd init`` themselves.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import os
37
+ import shutil
38
+ import subprocess
39
+ from dataclasses import dataclass, field
40
+ from pathlib import Path
41
+ from typing import Dict, List, Optional, Tuple
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Discovery
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ @dataclass
50
+ class AzdEnvLocation:
51
+ """Where (and how) an azd environment was discovered."""
52
+
53
+ name: Optional[str]
54
+ env_path: Optional[Path]
55
+ azure_dir: Path
56
+ status: str # "ok" | "missing_env_file" | "ambiguous" | "not_found"
57
+ reason: Optional[str] = None
58
+ candidates: List[str] = field(default_factory=list)
59
+
60
+ @property
61
+ def found(self) -> bool:
62
+ return self.status == "ok" and self.env_path is not None
63
+
64
+
65
+ def discover_azd_env(workspace: Path) -> AzdEnvLocation:
66
+ """Resolve which azd environment AgentOps should target.
67
+
68
+ Order of precedence:
69
+
70
+ 1. ``AZURE_ENV_NAME`` from the process environment.
71
+ 2. ``defaultEnvironment`` from ``.azure/config.json``.
72
+ 3. The only sub-directory of ``.azure/`` that contains a ``.env`` file
73
+ (when exactly one exists).
74
+
75
+ Returns an :class:`AzdEnvLocation` describing what was found. The
76
+ caller is expected to look at ``found`` / ``status`` to decide
77
+ whether to prompt for bootstrap.
78
+ """
79
+ workspace = workspace.resolve()
80
+ azure_dir = workspace / ".azure"
81
+
82
+ if not azure_dir.is_dir():
83
+ return AzdEnvLocation(
84
+ name=None,
85
+ env_path=None,
86
+ azure_dir=azure_dir,
87
+ status="not_found",
88
+ reason="workspace has no .azure directory",
89
+ )
90
+
91
+ env_name: Optional[str] = os.environ.get("AZURE_ENV_NAME") or None
92
+ config_path = azure_dir / "config.json"
93
+ if not env_name and config_path.is_file():
94
+ try:
95
+ raw = json.loads(config_path.read_text(encoding="utf-8"))
96
+ if isinstance(raw, dict):
97
+ candidate = raw.get("defaultEnvironment")
98
+ if isinstance(candidate, str) and candidate.strip():
99
+ env_name = candidate.strip()
100
+ except (json.JSONDecodeError, OSError):
101
+ pass
102
+
103
+ if not env_name:
104
+ candidates = sorted(
105
+ p.name for p in azure_dir.iterdir()
106
+ if p.is_dir() and (p / ".env").is_file()
107
+ )
108
+ if len(candidates) == 1:
109
+ env_name = candidates[0]
110
+ elif candidates:
111
+ return AzdEnvLocation(
112
+ name=None,
113
+ env_path=None,
114
+ azure_dir=azure_dir,
115
+ status="ambiguous",
116
+ reason=(
117
+ "multiple azd environments found; set AZURE_ENV_NAME "
118
+ "or .azure/config.json defaultEnvironment"
119
+ ),
120
+ candidates=candidates,
121
+ )
122
+
123
+ if not env_name:
124
+ return AzdEnvLocation(
125
+ name=None,
126
+ env_path=None,
127
+ azure_dir=azure_dir,
128
+ status="not_found",
129
+ reason="no azd environment selected",
130
+ )
131
+
132
+ env_path = azure_dir / env_name / ".env"
133
+ if not env_path.is_file():
134
+ return AzdEnvLocation(
135
+ name=env_name,
136
+ env_path=env_path,
137
+ azure_dir=azure_dir,
138
+ status="missing_env_file",
139
+ reason=f"{env_path} does not exist",
140
+ )
141
+
142
+ return AzdEnvLocation(
143
+ name=env_name,
144
+ env_path=env_path,
145
+ azure_dir=azure_dir,
146
+ status="ok",
147
+ )
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Reading
152
+ # ---------------------------------------------------------------------------
153
+
154
+
155
+ def parse_env_file(path: Path) -> Dict[str, str]:
156
+ """Parse a ``KEY=VALUE`` file. Returns ``{}`` on any error.
157
+
158
+ Matches the lenient rules of ``dotenv_loader.parse_env_file``:
159
+ skips blank lines and ``#`` comments, tolerates ``export`` prefixes,
160
+ strips matching single or double quotes from values.
161
+ """
162
+ if not path.exists() or not path.is_file():
163
+ return {}
164
+ try:
165
+ text = path.read_text(encoding="utf-8")
166
+ except OSError:
167
+ return {}
168
+ out: Dict[str, str] = {}
169
+ for raw_line in text.splitlines():
170
+ line = raw_line.strip()
171
+ if not line or line.startswith("#"):
172
+ continue
173
+ if line.startswith("export "):
174
+ line = line[len("export "):].lstrip()
175
+ if "=" not in line:
176
+ continue
177
+ key, _, value = line.partition("=")
178
+ key = key.strip()
179
+ if not key or not key.replace("_", "").isalnum():
180
+ continue
181
+ value = value.strip()
182
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
183
+ value = value[1:-1]
184
+ out[key] = value
185
+ return out
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Bootstrap
190
+ # ---------------------------------------------------------------------------
191
+
192
+
193
+ _GITIGNORE_LINES = (
194
+ "# Created by AgentOps to keep azd environment secrets out of git.",
195
+ "# azd creates the same rule by default; this file is harmless when",
196
+ "# both tools are used together.",
197
+ "*/.env",
198
+ )
199
+
200
+
201
+ def ensure_azure_gitignore(azure_dir: Path) -> bool:
202
+ """Write ``.azure/.gitignore`` so per-env ``.env`` files are excluded.
203
+
204
+ Returns ``True`` when the file was created or augmented. Idempotent —
205
+ if a usable ignore pattern is already present, returns ``False``.
206
+ """
207
+ azure_dir.mkdir(parents=True, exist_ok=True)
208
+ gitignore = azure_dir / ".gitignore"
209
+ needed = "*/.env"
210
+ if gitignore.is_file():
211
+ try:
212
+ existing = gitignore.read_text(encoding="utf-8")
213
+ except OSError:
214
+ existing = ""
215
+ stripped = {
216
+ ln.strip() for ln in existing.splitlines() if ln.strip() and not ln.startswith("#")
217
+ }
218
+ if needed in stripped or ".env" in stripped or "**/.env" in stripped:
219
+ return False
220
+ with gitignore.open("a", encoding="utf-8") as fh:
221
+ if not existing.endswith("\n"):
222
+ fh.write("\n")
223
+ fh.write(needed + "\n")
224
+ return True
225
+ gitignore.write_text("\n".join(_GITIGNORE_LINES) + "\n", encoding="utf-8")
226
+ return True
227
+
228
+
229
+ def ensure_azd_env(
230
+ workspace: Path,
231
+ env_name: str,
232
+ *,
233
+ set_default: bool = True,
234
+ ) -> AzdEnvLocation:
235
+ """Create ``.azure/<env_name>/.env`` if missing and protect it from git.
236
+
237
+ Idempotent. If the environment already exists, returns its current
238
+ location. Otherwise it:
239
+
240
+ 1. Creates ``.azure/`` and ``.azure/<env_name>/``.
241
+ 2. Writes ``.azure/.gitignore`` so ``*/.env`` is excluded from git.
242
+ 3. Creates an empty ``.env`` with a single-line header.
243
+ 4. When ``set_default`` is ``True`` and ``.azure/config.json`` is
244
+ absent or missing a ``defaultEnvironment``, writes that field.
245
+
246
+ Returns the resulting :class:`AzdEnvLocation`.
247
+ """
248
+ workspace = workspace.resolve()
249
+ azure_dir = workspace / ".azure"
250
+ env_dir = azure_dir / env_name
251
+ env_path = env_dir / ".env"
252
+
253
+ env_dir.mkdir(parents=True, exist_ok=True)
254
+ ensure_azure_gitignore(azure_dir)
255
+
256
+ if not env_path.exists():
257
+ env_path.write_text(
258
+ "# Managed alongside azd. Run `agentops init` or `azd env set`\n"
259
+ "# to update values here. Secrets in this file are git-ignored\n"
260
+ "# via .azure/.gitignore.\n",
261
+ encoding="utf-8",
262
+ )
263
+
264
+ if set_default:
265
+ config_path = azure_dir / "config.json"
266
+ config: Dict[str, object] = {}
267
+ if config_path.is_file():
268
+ try:
269
+ data = json.loads(config_path.read_text(encoding="utf-8"))
270
+ if isinstance(data, dict):
271
+ config = data
272
+ except (json.JSONDecodeError, OSError):
273
+ config = {}
274
+ if not isinstance(config.get("defaultEnvironment"), str) or not config["defaultEnvironment"]:
275
+ config.setdefault("version", 1)
276
+ config["defaultEnvironment"] = env_name
277
+ config_path.write_text(
278
+ json.dumps(config, indent=2) + "\n",
279
+ encoding="utf-8",
280
+ )
281
+
282
+ return AzdEnvLocation(
283
+ name=env_name,
284
+ env_path=env_path,
285
+ azure_dir=azure_dir,
286
+ status="ok",
287
+ )
288
+
289
+
290
+ def set_default_azd_env(workspace: Path, env_name: str) -> Path:
291
+ """Force ``.azure/config.json``'s ``defaultEnvironment`` to ``env_name``.
292
+
293
+ Unlike :func:`ensure_azd_env`, this *always* writes the field even if
294
+ a different default was already configured. Use it when the user has
295
+ explicitly named an azd environment (for example, ``--azd-env qa``)
296
+ and that intent should override any previous default.
297
+
298
+ Returns the path to ``.azure/config.json``.
299
+ """
300
+ workspace = workspace.resolve()
301
+ azure_dir = workspace / ".azure"
302
+ azure_dir.mkdir(parents=True, exist_ok=True)
303
+ config_path = azure_dir / "config.json"
304
+ config: Dict[str, object] = {}
305
+ if config_path.is_file():
306
+ try:
307
+ data = json.loads(config_path.read_text(encoding="utf-8"))
308
+ if isinstance(data, dict):
309
+ config = data
310
+ except (json.JSONDecodeError, OSError):
311
+ config = {}
312
+ config.setdefault("version", 1)
313
+ config["defaultEnvironment"] = env_name
314
+ config_path.write_text(
315
+ json.dumps(config, indent=2) + "\n",
316
+ encoding="utf-8",
317
+ )
318
+ return config_path
319
+
320
+
321
+ # ---------------------------------------------------------------------------
322
+ # Writing — line-preserving
323
+ # ---------------------------------------------------------------------------
324
+
325
+
326
+ def set_env_values(env_path: Path, updates: Dict[str, str]) -> List[str]:
327
+ """Update the ``.env`` file with ``updates`` (line-preserving).
328
+
329
+ For each key in ``updates``:
330
+
331
+ * If the file already contains a non-commented assignment for that
332
+ key, rewrite the matching line in place — preserving comments and
333
+ ordering for every other line.
334
+ * Otherwise, append ``KEY=VALUE`` to the end of the file.
335
+
336
+ Returns the list of keys that were actually written (i.e. the value
337
+ changed compared to what was already on disk).
338
+ """
339
+ env_path.parent.mkdir(parents=True, exist_ok=True)
340
+
341
+ if env_path.exists():
342
+ try:
343
+ text = env_path.read_text(encoding="utf-8")
344
+ except OSError:
345
+ text = ""
346
+ else:
347
+ text = ""
348
+ lines = text.splitlines()
349
+ trailing_newline = text.endswith("\n") if text else True
350
+
351
+ current = parse_env_file(env_path) if env_path.exists() else {}
352
+
353
+ changed: List[str] = []
354
+ remaining_updates = dict(updates)
355
+
356
+ for idx, raw_line in enumerate(lines):
357
+ stripped = raw_line.strip()
358
+ if not stripped or stripped.startswith("#"):
359
+ continue
360
+ key_part = stripped[len("export "):] if stripped.startswith("export ") else stripped
361
+ if "=" not in key_part:
362
+ continue
363
+ key = key_part.split("=", 1)[0].strip()
364
+ if key in remaining_updates:
365
+ new_value = remaining_updates.pop(key)
366
+ if current.get(key) != new_value:
367
+ lines[idx] = f"{key}={_format_value(new_value)}"
368
+ changed.append(key)
369
+ # If unchanged we still want to skip the append-fallback below,
370
+ # so we leave the line untouched and remove the key.
371
+
372
+ for key, new_value in remaining_updates.items():
373
+ # Key was not present in the file — append it.
374
+ if current.get(key) == new_value:
375
+ # Nothing to do; ``current`` reflects what parse_env_file
376
+ # would see, so this means the value really matches.
377
+ continue
378
+ lines.append(f"{key}={_format_value(new_value)}")
379
+ changed.append(key)
380
+
381
+ if changed or not env_path.exists():
382
+ new_text = "\n".join(lines)
383
+ if trailing_newline or not new_text.endswith("\n"):
384
+ new_text += "\n"
385
+ env_path.write_text(new_text, encoding="utf-8")
386
+
387
+ return changed
388
+
389
+
390
+ def _format_value(value: str) -> str:
391
+ if value == "":
392
+ return ""
393
+ if any(ch in value for ch in (" ", "#", "=", "\t")):
394
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
395
+ return value
396
+
397
+
398
+ # ---------------------------------------------------------------------------
399
+ # azd CLI integration (optional preferred path)
400
+ # ---------------------------------------------------------------------------
401
+
402
+
403
+ def azd_cli_available() -> bool:
404
+ """Return ``True`` when the ``azd`` CLI is on ``PATH``.
405
+
406
+ On Windows the binary may be ``azd.cmd``; ``shutil.which`` handles
407
+ both via ``PATHEXT``.
408
+ """
409
+ return shutil.which("azd") is not None
410
+
411
+
412
+ def azd_env_set(workspace: Path, env_name: str, key: str, value: str) -> Tuple[bool, str]:
413
+ """Call ``azd env set KEY VALUE`` for the given workspace.
414
+
415
+ Returns ``(success, message)``. The wizard prefers this path when the
416
+ azd CLI is installed because it gives azd a chance to apply its own
417
+ formatting and triggers any update hooks. We fall back to direct file
418
+ edits via :func:`set_env_values` when azd is not available or fails.
419
+ """
420
+ if not azd_cli_available():
421
+ return False, "azd CLI not found on PATH"
422
+ try:
423
+ result = subprocess.run( # noqa: S603,S607
424
+ ["azd", "env", "set", key, value, "-e", env_name],
425
+ cwd=str(workspace),
426
+ capture_output=True,
427
+ text=True,
428
+ timeout=30,
429
+ check=False,
430
+ )
431
+ except (OSError, subprocess.TimeoutExpired) as exc:
432
+ return False, f"azd env set failed to launch: {exc}"
433
+ if result.returncode != 0:
434
+ return False, (result.stderr or result.stdout or "azd env set failed").strip()
435
+ return True, ""
@@ -0,0 +1,62 @@
1
+ """Helpers for normalizing Azure OpenAI / AI Services endpoint URLs.
2
+
3
+ Users commonly copy the Azure OpenAI endpoint straight from the Foundry
4
+ portal, which displays it with the inference-path suffix appended
5
+ (``https://<resource>.openai.azure.com/openai/v1``). The
6
+ ``azure-ai-evaluation`` SDK and the ``openai`` SDK both want the
7
+ *base* endpoint without that suffix, and would otherwise produce a
8
+ confusing ``404`` or ``ResourceNotFound`` error.
9
+
10
+ This module strips the well-known inference-path suffixes so the user
11
+ can paste whichever URL the portal showed and have AgentOps work
12
+ transparently.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import Optional
19
+
20
+ # Ordered from most- to least-specific so the longest match wins.
21
+ # The match is case-insensitive and tolerant of trailing slashes.
22
+ _KNOWN_SUFFIXES = (
23
+ "/openai/v1",
24
+ "/openai/deployments",
25
+ "/openai",
26
+ )
27
+
28
+
29
+ _SUFFIX_RE = re.compile(
30
+ r"(" + "|".join(re.escape(s) for s in _KNOWN_SUFFIXES) + r")/*\Z",
31
+ re.IGNORECASE,
32
+ )
33
+
34
+
35
+ def normalize_azure_openai_endpoint(value: Optional[str]) -> Optional[str]:
36
+ """Return ``value`` with well-known inference-path suffixes removed.
37
+
38
+ Examples
39
+ --------
40
+ ``https://x.openai.azure.com/openai/v1`` -> ``https://x.openai.azure.com``
41
+ ``https://x.openai.azure.com/openai/`` -> ``https://x.openai.azure.com``
42
+ ``https://x.openai.azure.com`` -> ``https://x.openai.azure.com``
43
+ ``None`` / ``""`` -> returned unchanged
44
+
45
+ The helper is deliberately conservative:
46
+
47
+ * Only the exact suffixes in :data:`_KNOWN_SUFFIXES` are stripped.
48
+ * The scheme, host, port, and any earlier path segments are
49
+ preserved exactly.
50
+ * The result has no trailing slash so downstream SDKs build
51
+ consistent URLs.
52
+ """
53
+ if value is None:
54
+ return None
55
+ stripped = value.strip()
56
+ if not stripped:
57
+ return stripped
58
+ # Drop the known suffix when it appears at the very end of the URL.
59
+ rewritten = _SUFFIX_RE.sub("", stripped)
60
+ # Trim any straggling trailing slash so callers building paths
61
+ # never get a doubled ``//``.
62
+ return rewritten.rstrip("/")
@@ -0,0 +1,47 @@
1
+ """Tiny ANSI color helpers for CLI progress output.
2
+
3
+ Colors are automatically disabled when stdout is not a TTY, when the
4
+ ``NO_COLOR`` environment variable is set (https://no-color.org/), or when
5
+ ``AGENTOPS_NO_COLOR`` is set. No emojis, no extended unicode - only plain
6
+ ASCII text wrapped in standard ANSI SGR escape codes that all modern
7
+ terminals (Windows Terminal, ConEmu, VS Code, macOS, Linux) understand.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+
15
+ _RESET = "\x1b[0m"
16
+ _CODES = {
17
+ "dim": "\x1b[2m",
18
+ "bold": "\x1b[1m",
19
+ "red": "\x1b[31m",
20
+ "green": "\x1b[32m",
21
+ "yellow": "\x1b[33m",
22
+ "blue": "\x1b[34m",
23
+ "magenta": "\x1b[35m",
24
+ "cyan": "\x1b[36m",
25
+ }
26
+
27
+
28
+ def _enabled() -> bool:
29
+ if os.environ.get("NO_COLOR"):
30
+ return False
31
+ if os.environ.get("AGENTOPS_NO_COLOR"):
32
+ return False
33
+ stream = sys.stdout
34
+ try:
35
+ return bool(stream.isatty())
36
+ except Exception: # noqa: BLE001
37
+ return False
38
+
39
+
40
+ def style(text: str, *names: str) -> str:
41
+ """Wrap ``text`` in the given ANSI styles (e.g. ``"green"``, ``"bold"``)."""
42
+ if not _enabled() or not names:
43
+ return text
44
+ prefix = "".join(_CODES.get(name, "") for name in names)
45
+ if not prefix:
46
+ return text
47
+ return f"{prefix}{text}{_RESET}"
@@ -0,0 +1,105 @@
1
+ """Lightweight ``.env`` loader for the AgentOps workspace.
2
+
3
+ AgentOps auto-loads the workspace env file at startup so contributors do not
4
+ have to ``export`` env vars in every shell session. New AgentOps-only
5
+ workspaces use ``.agentops/.env``; repositories that already have an active
6
+ ``azd`` environment keep using ``.azure/<active-env>/.env``.
7
+
8
+ Lookup order (first file that contributes at least one new variable wins):
9
+
10
+ 1. ``.azure/<active-env>/.env`` — when an azd environment is already active.
11
+ 2. ``.agentops/.env`` — AgentOps-owned local env file.
12
+ 3. ``./.env`` — project-root fallback for hand-managed setups.
13
+
14
+ Design choices:
15
+
16
+ * **No new dependency.** We parse the file ourselves — only
17
+ ``KEY=VALUE`` lines, blank lines, and ``#`` comments. No shell escapes
18
+ or variable interpolation. This keeps the loader tiny and predictable.
19
+ * **Never override.** Variables already present in ``os.environ`` win.
20
+ The ``.env`` files are a fallback, not an override — that matches how
21
+ azd, dotenv, and direnv all behave.
22
+ * **Silent on failure.** Missing or malformed files do not crash
23
+ AgentOps; startup must remain fast and robust.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ from pathlib import Path
30
+ from typing import Iterable, Tuple
31
+
32
+
33
+ def _candidate_paths(workspace: Path) -> Iterable[Path]:
34
+ """Return the ordered list of ``.env`` files AgentOps will look at."""
35
+ workspace = workspace.resolve()
36
+ # Prefer the active azd environment when one is configured.
37
+ try:
38
+ from agentops.utils.azd_env import discover_azd_env # noqa: PLC0415
39
+
40
+ location = discover_azd_env(workspace)
41
+ if location.found and location.env_path is not None:
42
+ yield location.env_path
43
+ except Exception: # noqa: BLE001
44
+ # Discovery is best-effort; never crash startup on a malformed
45
+ # ``.azure/`` layout.
46
+ pass
47
+ # Legacy fallback: pre-azd-refactor workspaces.
48
+ yield workspace / ".agentops" / ".env"
49
+ # Also support a project-root .env so users who already keep secrets
50
+ # there benefit from the same auto-load (without overwriting OS env).
51
+ yield workspace / ".env"
52
+
53
+
54
+ def parse_env_file(path: Path) -> dict[str, str]:
55
+ """Parse a ``KEY=VALUE`` file. Returns an empty dict on any error."""
56
+ if not path.exists() or not path.is_file():
57
+ return {}
58
+ try:
59
+ text = path.read_text(encoding="utf-8")
60
+ except OSError:
61
+ return {}
62
+ out: dict[str, str] = {}
63
+ for raw_line in text.splitlines():
64
+ line = raw_line.strip()
65
+ if not line or line.startswith("#"):
66
+ continue
67
+ if line.startswith("export "):
68
+ line = line[len("export ") :].lstrip()
69
+ if "=" not in line:
70
+ continue
71
+ key, _, value = line.partition("=")
72
+ key = key.strip()
73
+ if not key or not key.replace("_", "").isalnum():
74
+ continue
75
+ value = value.strip()
76
+ # Strip surrounding matching quotes (single or double).
77
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
78
+ value = value[1:-1]
79
+ out[key] = value
80
+ return out
81
+
82
+
83
+ def load_workspace_dotenv(workspace: Path | None = None) -> Tuple[Path, int] | None:
84
+ """Load the active workspace ``.env`` file into ``os.environ``.
85
+
86
+ Tries the active azd environment file first, then AgentOps' local
87
+ ``.agentops/.env``, then project-root ``.env``. Values already set in
88
+ the process environment win — this loader only *adds* missing keys.
89
+ Returns ``(path, count)`` for the file that contributed at least one new
90
+ variable, or ``None`` when nothing was loaded.
91
+ """
92
+ base = (workspace or Path.cwd()).resolve()
93
+ for path in _candidate_paths(base):
94
+ parsed = parse_env_file(path)
95
+ if not parsed:
96
+ continue
97
+ added = 0
98
+ for key, value in parsed.items():
99
+ if key in os.environ:
100
+ continue
101
+ os.environ[key] = value
102
+ added += 1
103
+ if added:
104
+ return path, added
105
+ return None