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,709 @@
1
+ """Interactive setup wizard for AgentOps (``agentops init``).
2
+
3
+ The wizard asks the user one question at a time for the values AgentOps
4
+ needs to evaluate, observe, and analyze a Foundry agent — the project
5
+ endpoint, the agent identifier, and the dataset path.
6
+
7
+ Storage model:
8
+
9
+ * ``agent`` and ``dataset`` are declarative project config and stay in
10
+ ``agentops.yaml``. They are version-controlled and rarely change
11
+ between environments.
12
+ * ``AZURE_AI_FOUNDRY_PROJECT_ENDPOINT`` is environment-specific and lands
13
+ in the AgentOps-owned ``.agentops/.env`` by default. If the workspace
14
+ already has an active azd environment, or the user explicitly passes
15
+ ``--azd-env``, AgentOps writes the same value to ``.azure/<env>/.env``
16
+ instead.
17
+ * ``APPLICATIONINSIGHTS_CONNECTION_STRING`` can still be saved to the same
18
+ selected env file when supplied non-interactively, but the interactive
19
+ wizard does not ask for it; runtime commands can discover it from the
20
+ Foundry project later.
21
+ * Canonical Azure variable names are preserved so the Azure SDKs and
22
+ ``azd`` templates can read them directly.
23
+
24
+ The design intentionally mirrors ``azd``: simple sequential prompts, each
25
+ showing the *current* effective value as the default, with empty-input
26
+ meaning "keep current". A non-TTY environment (CI, redirected stdin)
27
+ falls back to a clear error so the wizard never hangs in pipelines.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import os
33
+ import re
34
+ from dataclasses import dataclass, field
35
+ from pathlib import Path
36
+ from typing import Callable, Collection, List, Optional
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Question prompts
41
+ # ---------------------------------------------------------------------------
42
+
43
+ PROJECT_ENDPOINT_TITLE = "Foundry project endpoint"
44
+ PROJECT_ENDPOINT_HELP = (
45
+ "The HTTPS URL of your Microsoft Foundry project. Used by `agentops eval "
46
+ "run`, `agentops doctor`, and the cockpit to discover the workspace.\n"
47
+ "Example: https://acct.services.ai.azure.com/api/projects/proj-default"
48
+ )
49
+
50
+ AGENT_TITLE = "Agent (name:version, model:deployment, or URL)"
51
+ AGENT_HELP = (
52
+ "What you are evaluating. One of:\n"
53
+ " * <name>:<version> — Foundry prompt agent (e.g. quickstart-agent:2)\n"
54
+ " * model:<deployment> — Foundry model deployment\n"
55
+ " * https://... — a Foundry hosted endpoint or any HTTP/JSON agent"
56
+ )
57
+
58
+ DATASET_TITLE = "Dataset path (JSONL file with `input` / `expected` rows)"
59
+ DATASET_HELP = (
60
+ "Path to the JSONL dataset, relative to the project root.\n"
61
+ "Default: .agentops/data/smoke.jsonl"
62
+ )
63
+
64
+ # Canonical environment-variable names AgentOps reads. We never rename
65
+ # variables that the Azure SDKs and azd templates expect — only AgentOps-
66
+ # specific knobs get the ``AGENTOPS_`` prefix.
67
+ ENV_KEY_PROJECT_ENDPOINT = "AZURE_AI_FOUNDRY_PROJECT_ENDPOINT"
68
+ ENV_KEY_APPINSIGHTS = "APPLICATIONINSIGHTS_CONNECTION_STRING"
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Data models
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ @dataclass
77
+ class WizardAnswers:
78
+ """User answers collected by the wizard."""
79
+
80
+ project_endpoint: Optional[str] = None
81
+ agent: Optional[str] = None
82
+ dataset: Optional[str] = None
83
+ appinsights_connection_string: Optional[str] = None
84
+
85
+
86
+ @dataclass
87
+ class WizardResult:
88
+ """What changed on disk after running the wizard."""
89
+
90
+ yaml_path: Path
91
+ env_path: Optional[Path]
92
+ yaml_updated: bool = False
93
+ env_updated: bool = False
94
+ yaml_fields: List[str] = field(default_factory=list)
95
+ env_keys: List[str] = field(default_factory=list)
96
+ azd_env_name: Optional[str] = None
97
+ azd_env_created: bool = False
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Defaults discovery
102
+ # ---------------------------------------------------------------------------
103
+
104
+
105
+ def discover_defaults(workspace: Path) -> WizardAnswers:
106
+ """Read existing values from agentops.yaml + azd env + process env.
107
+
108
+ Returns the *current effective values* the wizard should pre-fill as
109
+ defaults. Empty fields mean "no current value, ask the user fresh".
110
+ """
111
+ workspace = workspace.resolve()
112
+ yaml_data = _read_agentops_yaml(workspace)
113
+ env_values = _read_active_env(workspace)
114
+
115
+ project_endpoint = (
116
+ env_values.get(ENV_KEY_PROJECT_ENDPOINT)
117
+ or os.environ.get(ENV_KEY_PROJECT_ENDPOINT)
118
+ or _as_str(yaml_data.get("project_endpoint"))
119
+ )
120
+ agent = _as_str(yaml_data.get("agent"))
121
+ dataset = _as_str(yaml_data.get("dataset"))
122
+ appinsights = (
123
+ env_values.get(ENV_KEY_APPINSIGHTS)
124
+ or os.environ.get(ENV_KEY_APPINSIGHTS)
125
+ )
126
+
127
+ return WizardAnswers(
128
+ project_endpoint=project_endpoint,
129
+ agent=agent,
130
+ dataset=dataset,
131
+ appinsights_connection_string=appinsights,
132
+ )
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Validation
137
+ # ---------------------------------------------------------------------------
138
+
139
+
140
+ _URL_RE = re.compile(r"^https?://[^\s]+$")
141
+ _AGENT_REF_RE = re.compile(r"^[A-Za-z0-9._\-]+:[A-Za-z0-9._\-]+$")
142
+
143
+
144
+ def validate_project_endpoint(value: str) -> Optional[str]:
145
+ """Return an error string if ``value`` is not a usable endpoint."""
146
+ if not value:
147
+ return None # empty = skip
148
+ if not _URL_RE.match(value):
149
+ return "Project endpoint must start with https:// or http://."
150
+ return None
151
+
152
+
153
+ def validate_agent(value: str) -> Optional[str]:
154
+ if not value:
155
+ return None
156
+ if _URL_RE.match(value):
157
+ return None
158
+ if _AGENT_REF_RE.match(value):
159
+ return None
160
+ return (
161
+ "Agent must be one of: <name>:<version>, model:<deployment>, or "
162
+ "an https:// URL."
163
+ )
164
+
165
+
166
+ def validate_dataset(value: str, workspace: Path) -> Optional[str]:
167
+ if not value:
168
+ return None
169
+ candidate = (workspace / value).resolve()
170
+ if not candidate.exists():
171
+ return f"Dataset file does not exist: {candidate}"
172
+ return None
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # Persistence
177
+ # ---------------------------------------------------------------------------
178
+
179
+
180
+ def apply_answers(
181
+ workspace: Path,
182
+ answers: WizardAnswers,
183
+ *,
184
+ default_env_name: str = "dev",
185
+ azd_env_name: Optional[str] = None,
186
+ bootstrap_azd_env: bool = False,
187
+ ) -> WizardResult:
188
+ """Write the user's answers to ``agentops.yaml`` and the selected env file.
189
+
190
+ Behavior:
191
+
192
+ * ``agent`` and ``dataset`` are persisted to ``agentops.yaml`` only.
193
+ * ``project_endpoint`` and ``appinsights_connection_string`` are
194
+ persisted as environment variables in the active azd environment when
195
+ one exists, or in ``.agentops/.env`` otherwise.
196
+ * ``azd_env_name`` explicitly opts into creating/updating
197
+ ``.azure/<env>/.env``. ``bootstrap_azd_env`` preserves the old internal
198
+ behavior for callers that deliberately want to create an azd env.
199
+
200
+ Only fields that the user actually provided (non-empty, non-``None``)
201
+ are touched. Existing values not covered by an answer are preserved.
202
+ """
203
+ from agentops.utils.azd_env import ( # noqa: PLC0415
204
+ AzdEnvLocation,
205
+ discover_azd_env,
206
+ ensure_azd_env,
207
+ set_default_azd_env,
208
+ set_env_values,
209
+ )
210
+
211
+ workspace = workspace.resolve()
212
+ yaml_path = workspace / "agentops.yaml"
213
+ result = WizardResult(yaml_path=yaml_path, env_path=None)
214
+
215
+ # --- agentops.yaml --------------------------------------------------
216
+ yaml_data = _read_agentops_yaml(workspace)
217
+
218
+ def _changed(field_name: str, new_value: Optional[str]) -> bool:
219
+ if new_value is None:
220
+ return False
221
+ current = _as_str(yaml_data.get(field_name))
222
+ return current != new_value
223
+
224
+ yaml_dirty = False
225
+ if _changed("agent", answers.agent):
226
+ yaml_data["agent"] = answers.agent
227
+ result.yaml_fields.append("agent")
228
+ yaml_dirty = True
229
+ if _changed("dataset", answers.dataset):
230
+ yaml_data["dataset"] = answers.dataset
231
+ result.yaml_fields.append("dataset")
232
+ yaml_dirty = True
233
+
234
+ if yaml_dirty:
235
+ if "version" not in yaml_data:
236
+ yaml_data["version"] = 1
237
+ _write_agentops_yaml(yaml_path, yaml_data)
238
+ result.yaml_updated = True
239
+
240
+ # --- environment file ----------------------------------------------
241
+ env_updates: dict[str, str] = {}
242
+ if answers.project_endpoint is not None:
243
+ env_updates[ENV_KEY_PROJECT_ENDPOINT] = answers.project_endpoint
244
+ if answers.appinsights_connection_string is not None:
245
+ env_updates[ENV_KEY_APPINSIGHTS] = answers.appinsights_connection_string
246
+
247
+ if not env_updates:
248
+ return result
249
+
250
+ location: Optional[AzdEnvLocation] = None
251
+ if azd_env_name:
252
+ azd_env_path = workspace / ".azure" / azd_env_name / ".env"
253
+ azd_env_preexisted = azd_env_path.is_file()
254
+ location = ensure_azd_env(workspace, azd_env_name)
255
+ set_default_azd_env(workspace, azd_env_name)
256
+ result.azd_env_created = not azd_env_preexisted
257
+ else:
258
+ discovered = discover_azd_env(workspace)
259
+ if discovered.found:
260
+ location = discovered
261
+ elif bootstrap_azd_env:
262
+ if discovered.status == "ambiguous":
263
+ raise RuntimeError(
264
+ "Multiple azd environments found but no default is set. "
265
+ "Set AZURE_ENV_NAME or write defaultEnvironment to "
266
+ ".azure/config.json, then re-run `agentops init`."
267
+ )
268
+ env_name = discovered.name or default_env_name
269
+ location = ensure_azd_env(workspace, env_name)
270
+ result.azd_env_created = True
271
+ elif discovered.status == "ambiguous":
272
+ location = None
273
+
274
+ if location is not None:
275
+ if not location.found and location.status == "ambiguous":
276
+ raise RuntimeError(
277
+ "Multiple azd environments found but no default is set. "
278
+ "Set AZURE_ENV_NAME or write defaultEnvironment to "
279
+ ".azure/config.json, then re-run `agentops init`."
280
+ )
281
+ assert location.env_path is not None # narrowing for type checkers
282
+ result.env_path = location.env_path
283
+ result.azd_env_name = location.name
284
+ else:
285
+ result.env_path = ensure_agentops_env(workspace)
286
+
287
+ changed_keys = set_env_values(result.env_path, env_updates)
288
+ if changed_keys:
289
+ result.env_updated = True
290
+ result.env_keys.extend(sorted(changed_keys))
291
+
292
+ return result
293
+
294
+
295
+ # ---------------------------------------------------------------------------
296
+ # Internals
297
+ # ---------------------------------------------------------------------------
298
+
299
+
300
+ def _as_str(value: object) -> Optional[str]:
301
+ if value is None:
302
+ return None
303
+ text = str(value).strip()
304
+ return text or None
305
+
306
+
307
+ def _read_agentops_yaml(workspace: Path) -> dict:
308
+ path = workspace / "agentops.yaml"
309
+ if not path.exists():
310
+ return {}
311
+ try:
312
+ import yaml # noqa: PLC0415
313
+ except Exception: # noqa: BLE001
314
+ return {}
315
+ try:
316
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
317
+ except Exception: # noqa: BLE001
318
+ return {}
319
+ return data if isinstance(data, dict) else {}
320
+
321
+
322
+ def _write_agentops_yaml(path: Path, data: dict) -> None:
323
+ import yaml # noqa: PLC0415
324
+
325
+ path.parent.mkdir(parents=True, exist_ok=True)
326
+ # Preserve simple field order for readability: version, agent, dataset,
327
+ # project_endpoint (legacy, only kept if already present), then
328
+ # everything else.
329
+ ordered_keys = ["version", "agent", "dataset", "project_endpoint"]
330
+ ordered: dict = {}
331
+ for key in ordered_keys:
332
+ if key in data:
333
+ ordered[key] = data[key]
334
+ for key, value in data.items():
335
+ if key not in ordered:
336
+ ordered[key] = value
337
+ path.write_text(
338
+ yaml.safe_dump(ordered, sort_keys=False, default_flow_style=False),
339
+ encoding="utf-8",
340
+ )
341
+
342
+
343
+ _AGENTOPS_ENV_HEADER = (
344
+ "# Managed by AgentOps. Run `agentops init` to update values here.\n"
345
+ "# Local environment values in this file are git-ignored via .agentops/.gitignore.\n"
346
+ )
347
+
348
+
349
+ def ensure_agentops_env(workspace: Path) -> Path:
350
+ """Create the AgentOps-owned local env file and protect it from git."""
351
+ agentops_dir = workspace.resolve() / ".agentops"
352
+ agentops_dir.mkdir(parents=True, exist_ok=True)
353
+ ensure_agentops_gitignore(agentops_dir)
354
+ env_path = agentops_dir / ".env"
355
+ if not env_path.exists():
356
+ env_path.write_text(_AGENTOPS_ENV_HEADER, encoding="utf-8")
357
+ return env_path
358
+
359
+
360
+ def ensure_agentops_gitignore(agentops_dir: Path) -> bool:
361
+ """Ensure ``.agentops/.gitignore`` excludes the local env file."""
362
+ agentops_dir.mkdir(parents=True, exist_ok=True)
363
+ gitignore = agentops_dir / ".gitignore"
364
+ needed = ".env"
365
+ if gitignore.is_file():
366
+ try:
367
+ existing = gitignore.read_text(encoding="utf-8")
368
+ except OSError:
369
+ existing = ""
370
+ stripped = {
371
+ ln.strip() for ln in existing.splitlines() if ln.strip() and not ln.startswith("#")
372
+ }
373
+ if needed in stripped or ".env*" in stripped or "*.env" in stripped:
374
+ return False
375
+ with gitignore.open("a", encoding="utf-8") as fh:
376
+ if existing and not existing.endswith("\n"):
377
+ fh.write("\n")
378
+ fh.write(".env\n")
379
+ return True
380
+ gitignore.write_text(
381
+ "# Generated by agentops init\n"
382
+ "# Keep local AgentOps environment values out of source control\n"
383
+ ".env\n"
384
+ ".env.*\n"
385
+ "!.env.example\n"
386
+ "results/**\n"
387
+ "official-eval/**\n"
388
+ ".resolved/**\n",
389
+ encoding="utf-8",
390
+ )
391
+ return True
392
+
393
+
394
+ def _read_active_env(workspace: Path) -> dict[str, str]:
395
+ """Read the active env file (azd env first, then AgentOps local env)."""
396
+ from agentops.utils.azd_env import discover_azd_env, parse_env_file # noqa: PLC0415
397
+
398
+ location = discover_azd_env(workspace)
399
+ if location.found and location.env_path is not None:
400
+ return parse_env_file(location.env_path)
401
+ agentops_env = workspace / ".agentops" / ".env"
402
+ if agentops_env.is_file():
403
+ return parse_env_file(agentops_env)
404
+ return {}
405
+
406
+
407
+ # ---------------------------------------------------------------------------
408
+ # Prompt loop (Typer-friendly)
409
+ # ---------------------------------------------------------------------------
410
+
411
+
412
+ PromptFn = Callable[[str, Optional[str]], str]
413
+ OnAnswerFn = Callable[[str, str], None]
414
+
415
+
416
+ def _mask_secret(value: str) -> str:
417
+ """Show only the tail of a secret so the user can recognise it without leaking."""
418
+ if not value:
419
+ return ""
420
+ if len(value) <= 8:
421
+ return "•" * len(value)
422
+ return "•" * 8 + value[-4:]
423
+
424
+
425
+ def _can_encode(text: str) -> bool:
426
+ """Return True if the active stdout encoding can render ``text``.
427
+
428
+ Used to choose between Unicode glyphs (✓, •) and ASCII fallbacks (*, .)
429
+ so the wizard does not crash on legacy Windows code pages (cp1252).
430
+ """
431
+ import sys # noqa: PLC0415
432
+
433
+ encoding = getattr(sys.stdout, "encoding", None) or "utf-8"
434
+ try:
435
+ text.encode(encoding)
436
+ except (UnicodeEncodeError, LookupError):
437
+ return False
438
+ return True
439
+
440
+
441
+ def run_wizard(
442
+ workspace: Path,
443
+ prompt: PromptFn,
444
+ echo: Callable[[str], None],
445
+ *,
446
+ on_answer: Optional[OnAnswerFn] = None,
447
+ reconfigure: bool = False,
448
+ force_prompt_fields: Optional[Collection[str]] = None,
449
+ ) -> WizardAnswers:
450
+ """Drive the interactive question loop.
451
+
452
+ ``prompt`` is called as ``prompt(question, default)`` and must return
453
+ the user's answer (empty string = keep current). ``echo`` prints
454
+ explanatory text between questions. Both are injected so the function
455
+ is unit-testable without touching the real terminal.
456
+
457
+ ``on_answer`` is invoked as ``on_answer(field_name, value)`` after
458
+ each new (non-empty, changed, validated) answer. The CLI uses it to
459
+ persist values to disk immediately, so a Ctrl+C mid-wizard does not
460
+ discard answers the user already provided.
461
+
462
+ When ``reconfigure`` is ``False`` (the default), any value that is
463
+ already configured — read from ``agentops.yaml``, the active azd
464
+ environment, or the process env — is reused silently with a single
465
+ confirmation line. Set ``reconfigure=True`` to force the wizard to
466
+ re-ask every question even when defaults are present.
467
+
468
+ ``force_prompt_fields`` is narrower than ``reconfigure``: it re-asks only
469
+ selected fields while still reusing other existing defaults. The CLI uses
470
+ this on a first interactive run so starter ``agentops.yaml`` values remain
471
+ visible defaults instead of being accepted as real user choices.
472
+ """
473
+ defaults = discover_defaults(workspace)
474
+ answers = WizardAnswers()
475
+ skipped: list[str] = []
476
+ forced_fields = set(force_prompt_fields or ())
477
+ unicode_ok = _can_encode("✓•")
478
+ ok_glyph = "✓" if unicode_ok else "*"
479
+
480
+ def _should_prompt(field_name: str, value: Optional[str]) -> bool:
481
+ return reconfigure or field_name in forced_fields or not value
482
+
483
+ def _persist(field_name: str, value: str) -> None:
484
+ if on_answer is not None:
485
+ try:
486
+ on_answer(field_name, value)
487
+ except Exception as exc: # noqa: BLE001
488
+ echo(f" ! could not persist {field_name}: {exc}")
489
+
490
+ def _confirm_existing(label: str, value: str, secret: bool = False) -> None:
491
+ """Acknowledge a pre-existing value without re-prompting."""
492
+ display = _mask_secret(value) if secret else value
493
+ if not unicode_ok and secret:
494
+ # Fall back to plain bullets so cp1252 stdouts do not crash.
495
+ display = "*" * 8 + value[-4:] if len(value) > 8 else "*" * len(value)
496
+ echo(f" {ok_glyph} {label}: {display}")
497
+
498
+ # 1) Foundry project endpoint
499
+ if not _should_prompt("project_endpoint", defaults.project_endpoint):
500
+ _confirm_existing(PROJECT_ENDPOINT_TITLE, defaults.project_endpoint or "")
501
+ skipped.append("project_endpoint")
502
+ else:
503
+ echo("")
504
+ echo(PROJECT_ENDPOINT_TITLE)
505
+ echo(_indent(PROJECT_ENDPOINT_HELP))
506
+ while True:
507
+ raw = prompt("Foundry project endpoint", defaults.project_endpoint)
508
+ value = raw.strip()
509
+ if not value:
510
+ break # keep current / leave blank
511
+ err = validate_project_endpoint(value)
512
+ if err:
513
+ echo(" ! " + err)
514
+ continue
515
+ if value != (defaults.project_endpoint or ""):
516
+ answers.project_endpoint = value
517
+ _persist("project_endpoint", value)
518
+ break
519
+
520
+ # 2) Agent
521
+ if not _should_prompt("agent", defaults.agent):
522
+ _confirm_existing(AGENT_TITLE, defaults.agent or "")
523
+ skipped.append("agent")
524
+ else:
525
+ echo("")
526
+ echo(AGENT_TITLE)
527
+ echo(_indent(AGENT_HELP))
528
+ while True:
529
+ raw = prompt("Agent", defaults.agent)
530
+ value = raw.strip()
531
+ if not value:
532
+ break
533
+ err = validate_agent(value)
534
+ if err:
535
+ echo(" ! " + err)
536
+ continue
537
+ if value != (defaults.agent or ""):
538
+ answers.agent = value
539
+ _persist("agent", value)
540
+ break
541
+
542
+ # 3) Dataset
543
+ if not _should_prompt("dataset", defaults.dataset):
544
+ _confirm_existing(DATASET_TITLE, defaults.dataset or "")
545
+ skipped.append("dataset")
546
+ else:
547
+ echo("")
548
+ echo(DATASET_TITLE)
549
+ echo(_indent(DATASET_HELP))
550
+ while True:
551
+ raw = prompt("Dataset path", defaults.dataset or ".agentops/data/smoke.jsonl")
552
+ value = raw.strip()
553
+ if not value:
554
+ break
555
+ err = validate_dataset(value, workspace)
556
+ if err:
557
+ echo(" ! " + err)
558
+ continue
559
+ if value != (defaults.dataset or ""):
560
+ answers.dataset = value
561
+ _persist("dataset", value)
562
+ break
563
+
564
+ # Surface a hint only when EVERY managed value was already set, so the
565
+ # user knows how to edit values without thinking the wizard "did nothing".
566
+ expected = ["project_endpoint", "agent", "dataset"]
567
+ if not reconfigure and set(skipped) == set(expected):
568
+ echo("")
569
+ echo("All values already configured. Re-run with --reconfigure to change them.")
570
+
571
+ return answers
572
+
573
+
574
+ def _indent(text: str, prefix: str = " ") -> str:
575
+ return "\n".join(prefix + line for line in text.splitlines())
576
+
577
+
578
+ # ---------------------------------------------------------------------------
579
+ # `agentops init show` — inspect the current setup
580
+ # ---------------------------------------------------------------------------
581
+
582
+
583
+ @dataclass
584
+ class SetupSnapshotVar:
585
+ """One row in the ``agentops init show`` output."""
586
+
587
+ key: str
588
+ value: Optional[str]
589
+ source: str # "azd-env" | "agentops-env" | "process-env" | "default" | "not set"
590
+ secret: bool = False
591
+ required: bool = False
592
+ description: str = ""
593
+
594
+
595
+ @dataclass
596
+ class SetupSnapshot:
597
+ """The full ``agentops init show`` payload."""
598
+
599
+ workspace: Path
600
+ azd_env_name: Optional[str]
601
+ azd_env_path: Optional[Path]
602
+ azd_status: str
603
+ azd_reason: Optional[str]
604
+ agentops_env_path: Optional[Path]
605
+ yaml_path: Path
606
+ yaml_present: bool
607
+ yaml_agent: Optional[str]
608
+ yaml_dataset: Optional[str]
609
+ yaml_project_endpoint: Optional[str]
610
+ variables: List[SetupSnapshotVar] = field(default_factory=list)
611
+ legacy_env_path: Optional[Path] = None
612
+
613
+ @property
614
+ def missing_required(self) -> List[str]:
615
+ return [v.key for v in self.variables if v.required and not v.value]
616
+
617
+
618
+ # Registry of variables the wizard manages, used by `setup show`.
619
+ # Order matters: this is how they show up in the report.
620
+ _MANAGED_VARS: tuple[tuple[str, bool, bool, str], ...] = (
621
+ (
622
+ ENV_KEY_PROJECT_ENDPOINT,
623
+ False,
624
+ True,
625
+ "Foundry project endpoint used by Doctor, Cockpit, and eval run.",
626
+ ),
627
+ (
628
+ ENV_KEY_APPINSIGHTS,
629
+ True,
630
+ False,
631
+ "Application Insights connection string for tracing and Cockpit telemetry.",
632
+ ),
633
+ (
634
+ "AGENTOPS_FOUNDRY_MODE",
635
+ False,
636
+ False,
637
+ "Foundry execution mode (`cloud` or `local`). AgentOps-specific.",
638
+ ),
639
+ )
640
+
641
+
642
+ def collect_snapshot(workspace: Path) -> SetupSnapshot:
643
+ """Snapshot the current AgentOps configuration for display."""
644
+ from agentops.utils.azd_env import discover_azd_env, parse_env_file # noqa: PLC0415
645
+
646
+ workspace = workspace.resolve()
647
+ yaml_data = _read_agentops_yaml(workspace)
648
+ yaml_path = workspace / "agentops.yaml"
649
+
650
+ location = discover_azd_env(workspace)
651
+ env_values: dict[str, str] = {}
652
+ if location.found and location.env_path is not None:
653
+ env_values = parse_env_file(location.env_path)
654
+
655
+ agentops_env = workspace / ".agentops" / ".env"
656
+ agentops_env_path: Optional[Path] = agentops_env if agentops_env.is_file() else None
657
+ agentops_values = parse_env_file(agentops_env) if agentops_env_path else {}
658
+
659
+ variables: List[SetupSnapshotVar] = []
660
+ for key, is_secret, is_required, description in _MANAGED_VARS:
661
+ proc_value = os.environ.get(key)
662
+ env_value = env_values.get(key) or agentops_values.get(key)
663
+ # Process env wins only when it actually differs from the file —
664
+ # otherwise we attribute the value to the (more durable) env file.
665
+ if proc_value is not None and proc_value != env_value:
666
+ value, source = proc_value, "process-env"
667
+ elif env_value:
668
+ value, source = env_value, "azd-env" if env_values.get(key) else "agentops-env"
669
+ elif proc_value:
670
+ value, source = proc_value, "process-env"
671
+ elif key == "AGENTOPS_FOUNDRY_MODE":
672
+ value, source = "cloud", "default"
673
+ else:
674
+ value, source = None, "not set"
675
+ variables.append(
676
+ SetupSnapshotVar(
677
+ key=key,
678
+ value=value,
679
+ source=source,
680
+ secret=is_secret,
681
+ required=is_required,
682
+ description=description,
683
+ )
684
+ )
685
+
686
+ return SetupSnapshot(
687
+ workspace=workspace,
688
+ azd_env_name=location.name,
689
+ azd_env_path=location.env_path,
690
+ azd_status=location.status,
691
+ azd_reason=location.reason,
692
+ agentops_env_path=agentops_env_path,
693
+ yaml_path=yaml_path,
694
+ yaml_present=yaml_path.exists(),
695
+ yaml_agent=_as_str(yaml_data.get("agent")),
696
+ yaml_dataset=_as_str(yaml_data.get("dataset")),
697
+ yaml_project_endpoint=_as_str(yaml_data.get("project_endpoint")),
698
+ variables=variables,
699
+ legacy_env_path=None,
700
+ )
701
+
702
+
703
+ def mask_secret(value: Optional[str]) -> str:
704
+ """Return a UI-safe rendering of a secret value."""
705
+ if not value:
706
+ return "(not set)"
707
+ if len(value) <= 8:
708
+ return "***"
709
+ return value[:4] + "***" + value[-4:]