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.
- agentops/__init__.py +10 -0
- agentops/__main__.py +6 -0
- agentops/agent/__init__.py +12 -0
- agentops/agent/_legacy_ids.py +92 -0
- agentops/agent/analyzer.py +207 -0
- agentops/agent/checks/__init__.py +1 -0
- agentops/agent/checks/catalog.py +880 -0
- agentops/agent/checks/errors.py +279 -0
- agentops/agent/checks/foundry_config.py +75 -0
- agentops/agent/checks/latency.py +84 -0
- agentops/agent/checks/opex.py +157 -0
- agentops/agent/checks/opex_workspace.py +874 -0
- agentops/agent/checks/posture.py +36 -0
- agentops/agent/checks/posture_rules/__init__.py +53 -0
- agentops/agent/checks/posture_rules/content_filter.py +59 -0
- agentops/agent/checks/posture_rules/diagnostics.py +74 -0
- agentops/agent/checks/posture_rules/local_auth.py +55 -0
- agentops/agent/checks/posture_rules/managed_identity.py +59 -0
- agentops/agent/checks/posture_rules/network.py +68 -0
- agentops/agent/checks/regression.py +78 -0
- agentops/agent/checks/release_readiness.py +182 -0
- agentops/agent/checks/safety.py +247 -0
- agentops/agent/checks/spec_conformance.py +375 -0
- agentops/agent/cockpit.py +5159 -0
- agentops/agent/config.py +240 -0
- agentops/agent/findings.py +113 -0
- agentops/agent/history.py +142 -0
- agentops/agent/knowledge/__init__.py +182 -0
- agentops/agent/knowledge/waf-checklist.csv +39 -0
- agentops/agent/llm_assist/__init__.py +16 -0
- agentops/agent/llm_assist/_base.py +124 -0
- agentops/agent/llm_assist/_bundle_rule.py +154 -0
- agentops/agent/llm_assist/_client.py +347 -0
- agentops/agent/llm_assist/_dataset_rules.py +191 -0
- agentops/agent/llm_assist/_engine.py +106 -0
- agentops/agent/llm_assist/_prompt_rules.py +291 -0
- agentops/agent/llm_assist/_spec_rules.py +235 -0
- agentops/agent/production_telemetry.py +430 -0
- agentops/agent/report.py +207 -0
- agentops/agent/server/__init__.py +1 -0
- agentops/agent/server/app.py +84 -0
- agentops/agent/server/auth.py +94 -0
- agentops/agent/server/chat.py +44 -0
- agentops/agent/server/protocol.py +72 -0
- agentops/agent/sources/__init__.py +1 -0
- agentops/agent/sources/azure_monitor.py +523 -0
- agentops/agent/sources/azure_resources.py +602 -0
- agentops/agent/sources/foundry_control.py +174 -0
- agentops/agent/sources/results_history.py +494 -0
- agentops/agent/sources/spec_detectors/__init__.py +42 -0
- agentops/agent/sources/spec_detectors/_base.py +58 -0
- agentops/agent/sources/spec_detectors/agents_md.py +75 -0
- agentops/agent/sources/spec_detectors/spec_kit.py +172 -0
- agentops/agent/time_range.py +117 -0
- agentops/cli/__init__.py +1 -0
- agentops/cli/app.py +4823 -0
- agentops/core/__init__.py +1 -0
- agentops/core/agentops_config.py +592 -0
- agentops/core/config_loader.py +22 -0
- agentops/core/evaluators.py +480 -0
- agentops/core/release_evidence.py +56 -0
- agentops/core/results.py +117 -0
- agentops/mcp/__init__.py +10 -0
- agentops/mcp/server.py +232 -0
- agentops/pipeline/__init__.py +8 -0
- agentops/pipeline/cloud_results.py +189 -0
- agentops/pipeline/cloud_runner.py +901 -0
- agentops/pipeline/comparison.py +108 -0
- agentops/pipeline/diagnostics.py +51 -0
- agentops/pipeline/invocations.py +535 -0
- agentops/pipeline/official_eval.py +414 -0
- agentops/pipeline/orchestrator.py +775 -0
- agentops/pipeline/prompt_deploy.py +377 -0
- agentops/pipeline/publisher.py +121 -0
- agentops/pipeline/reporter.py +202 -0
- agentops/pipeline/runtime.py +409 -0
- agentops/pipeline/thresholds.py +84 -0
- agentops/services/__init__.py +1 -0
- agentops/services/cicd.py +720 -0
- agentops/services/eval_analysis.py +848 -0
- agentops/services/evidence_pack.py +757 -0
- agentops/services/initializer.py +86 -0
- agentops/services/preflight.py +470 -0
- agentops/services/setup_wizard.py +709 -0
- agentops/services/skills.py +643 -0
- agentops/services/trace_promotion.py +300 -0
- agentops/services/workflow_analysis.py +1129 -0
- agentops/templates/.gitignore +15 -0
- agentops/templates/__init__.py +1 -0
- agentops/templates/agent-server/Dockerfile +23 -0
- agentops/templates/agent-server/README.md +61 -0
- agentops/templates/agent-server/main.bicep +94 -0
- agentops/templates/agent.yaml +87 -0
- agentops/templates/agentops.yaml +58 -0
- agentops/templates/foundry.svg +71 -0
- agentops/templates/icon.png +0 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-dev-azd.yml +118 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-dev.yml +73 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-prod-azd.yml +141 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-prod.yml +94 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-prompt-agent.yml +167 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-qa-azd.yml +118 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-qa.yml +68 -0
- agentops/templates/pipelines/azuredevops/agentops-pr-prompt-agent.yml +210 -0
- agentops/templates/pipelines/azuredevops/agentops-pr.yml +155 -0
- agentops/templates/pipelines/azuredevops/agentops-watchdog.yml +106 -0
- agentops/templates/project.gitignore +36 -0
- agentops/templates/sample-traces.jsonl +3 -0
- agentops/templates/skills/agentops-agent/SKILL.md +137 -0
- agentops/templates/skills/agentops-config/SKILL.md +113 -0
- agentops/templates/skills/agentops-dataset/SKILL.md +84 -0
- agentops/templates/skills/agentops-eval/SKILL.md +189 -0
- agentops/templates/skills/agentops-report/SKILL.md +71 -0
- agentops/templates/skills/agentops-workflow/SKILL.md +471 -0
- agentops/templates/smoke.jsonl +3 -0
- agentops/templates/waf-checklist.README.md +84 -0
- agentops/templates/waf-checklist.csv +22 -0
- agentops/templates/workflows/agentops-deploy-dev-azd.yml +166 -0
- agentops/templates/workflows/agentops-deploy-dev.yml +187 -0
- agentops/templates/workflows/agentops-deploy-prod-azd.yml +183 -0
- agentops/templates/workflows/agentops-deploy-prod.yml +171 -0
- agentops/templates/workflows/agentops-deploy-prompt-agent.yml +197 -0
- agentops/templates/workflows/agentops-deploy-qa-azd.yml +156 -0
- agentops/templates/workflows/agentops-deploy-qa.yml +145 -0
- agentops/templates/workflows/agentops-pr-prompt-agent.yml +210 -0
- agentops/templates/workflows/agentops-pr.yml +148 -0
- agentops/templates/workflows/agentops-watchdog.yml +122 -0
- agentops/utils/__init__.py +1 -0
- agentops/utils/azd_env.py +435 -0
- agentops/utils/azure_endpoints.py +62 -0
- agentops/utils/colors.py +47 -0
- agentops/utils/dotenv_loader.py +105 -0
- agentops/utils/foundry_discovery.py +229 -0
- agentops/utils/logging.py +59 -0
- agentops/utils/telemetry.py +554 -0
- agentops/utils/yaml.py +36 -0
- agentops_accelerator-0.3.0.dist-info/METADATA +278 -0
- agentops_accelerator-0.3.0.dist-info/RECORD +142 -0
- agentops_accelerator-0.3.0.dist-info/WHEEL +5 -0
- agentops_accelerator-0.3.0.dist-info/entry_points.txt +2 -0
- agentops_accelerator-0.3.0.dist-info/licenses/LICENSE +21 -0
- 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:]
|