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,430 @@
1
+ """Production telemetry queries for the local AgentOps cockpit.
2
+
3
+ Pulls live signals (invocation count, error rate, p95 latency, token
4
+ spend) from the Application Insights resource that the Foundry project
5
+ endpoint resolves to, and reshapes them as cockpit cards.
6
+
7
+ All work is best-effort:
8
+
9
+ * If the App Insights connection string is not discoverable, the
10
+ cockpit skips this section silently.
11
+ * If the API call fails (auth, network, resource not found, etc.), the
12
+ module returns an empty payload - the rest of the cockpit keeps
13
+ rendering.
14
+
15
+ The KQL hits ``https://api.applicationinsights.io/v1/apps/<appId>/query``
16
+ directly with a ``DefaultAzureCredential`` bearer token, which means no
17
+ extra Azure SDK dependency beyond what the ``[agent]`` extra already
18
+ installs (``azure-identity``).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import re
25
+ import time
26
+ from typing import Any, Dict, List, Optional, Tuple
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+ # In-process cache so /api/production and the HTML render don't hammer the
31
+ # App Insights API on every cockpit refresh (default refresh: 15s).
32
+ _CACHE_TTL_SECONDS = 60.0
33
+ _cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
34
+
35
+
36
+ def _humanize_token_error(exc: Exception) -> str:
37
+ """Convert a verbose ``DefaultAzureCredential`` failure into a short,
38
+ actionable message suitable for the cockpit's error tile.
39
+
40
+ The Azure SDK concatenates the failure reason of every credential
41
+ in the chain into a single multi-line string ("EnvironmentCredential:
42
+ ... WorkloadIdentityCredential: ... AzureCliCredential: ..."). The
43
+ full text is technically accurate but useless for a user staring at
44
+ a cockpit. Detect the common failure shapes and surface a
45
+ one-sentence remediation instead.
46
+ """
47
+ text = str(exc)
48
+ lower = text.lower()
49
+ # The Azure CLI sign-in is the path users on dev machines actually
50
+ # take, so prioritize that hint when its sub-credential failed.
51
+ cli_failed = (
52
+ "azureclicredential: failed to invoke the azure cli" in lower
53
+ or "no accounts were found in the cache" in lower
54
+ )
55
+ if cli_failed:
56
+ return (
57
+ "Not signed in to Azure. Run `az login` in the same shell "
58
+ "you launched `agentops cockpit` from, then refresh."
59
+ )
60
+ if "defaultazurecredential failed to retrieve a token" in lower:
61
+ return (
62
+ "Azure authentication failed: DefaultAzureCredential could "
63
+ "not acquire a token. On a dev machine the usual fix is "
64
+ "`az login`. See cockpit logs for the full credential "
65
+ "chain."
66
+ )
67
+ # Truncate generic exceptions so the tile stays readable.
68
+ snippet = text.splitlines()[0].strip()
69
+ if len(snippet) > 240:
70
+ snippet = snippet[:237] + "..."
71
+ return f"Token acquisition failed: {snippet}"
72
+
73
+
74
+ def extract_application_id(connection_string: Optional[str]) -> Optional[str]:
75
+ """Pull the ``ApplicationId=<guid>`` segment out of an App Insights
76
+ connection string. Returns ``None`` when absent (older format)."""
77
+ if not connection_string:
78
+ return None
79
+ m = re.search(r"ApplicationId=([0-9a-fA-F-]+)", connection_string)
80
+ return m.group(1) if m else None
81
+
82
+
83
+ def collect_production_metrics(
84
+ application_id: Optional[str],
85
+ *,
86
+ lookback_hours: int = 24,
87
+ ) -> Dict[str, Any]:
88
+ """Return a cockpit-ready payload of live telemetry cards.
89
+
90
+ Always returns a dict with ``has_data`` and ``cards`` keys; values
91
+ populate when the App Insights query succeeds. The ``lookback_hours``
92
+ parameter is substituted into the KQL templates so the cockpit
93
+ time-range picker can drive how far back each card looks.
94
+ """
95
+ empty = {"has_data": False, "cards": [], "diagnostics": {}}
96
+ if not application_id:
97
+ empty["diagnostics"] = {"reason": "no ApplicationId in connection string"}
98
+ return empty
99
+
100
+ cache_key = f"{application_id}:{lookback_hours}"
101
+ cached = _cache.get(cache_key)
102
+ now = time.time()
103
+ if cached and now - cached[0] < _CACHE_TTL_SECONDS:
104
+ return cached[1]
105
+
106
+ try:
107
+ bearer = _acquire_token()
108
+ except Exception as exc: # noqa: BLE001
109
+ log.debug("token acquisition failed: %s", exc)
110
+ empty["diagnostics"] = {"reason": _humanize_token_error(exc)}
111
+ return empty
112
+
113
+ # Pick a sensible bucket size: 1h for short windows, 6h for ~30d.
114
+ bucket = "1h" if lookback_hours <= 48 else "6h"
115
+
116
+ # Fire all four queries in parallel - sequential round-trips to App
117
+ # Insights were the single biggest source of cockpit latency
118
+ # (~4s vs ~1s after this change).
119
+ from concurrent.futures import ThreadPoolExecutor
120
+ with ThreadPoolExecutor(max_workers=4) as ex:
121
+ fut_summary = ex.submit(
122
+ _run_query, application_id, bearer, _KQL_SUMMARY.format(hours=lookback_hours),
123
+ )
124
+ fut_invocations = ex.submit(
125
+ _run_query, application_id, bearer,
126
+ _KQL_HOURLY_INVOCATIONS.format(hours=lookback_hours, bucket=bucket),
127
+ )
128
+ fut_latency = ex.submit(
129
+ _run_query, application_id, bearer,
130
+ _KQL_HOURLY_LATENCY.format(hours=lookback_hours, bucket=bucket),
131
+ )
132
+ fut_tokens = ex.submit(
133
+ _run_query, application_id, bearer,
134
+ _KQL_TOKENS.format(hours=lookback_hours),
135
+ )
136
+ summary = fut_summary.result()
137
+ invocations_buckets = fut_invocations.result() or {}
138
+ latency_buckets = fut_latency.result() or {}
139
+ tokens = fut_tokens.result() or {}
140
+
141
+ if summary is None:
142
+ empty["diagnostics"] = {
143
+ "reason": "Application Insights query failed (auth, network, "
144
+ "or KQL error). See `agentops cockpit` console logs for the "
145
+ "exact message."
146
+ }
147
+ return empty
148
+
149
+ cards = _build_cards(summary, invocations_buckets, latency_buckets, tokens, lookback_hours)
150
+ payload = {"has_data": bool(cards), "cards": cards, "diagnostics": {}}
151
+ # Only cache populated payloads. Caching empty results masks
152
+ # transient failures (token expiry, App Insights 5xx, etc.) for up
153
+ # to a minute — exactly the case the user notices as "the
154
+ # cockpit suddenly stopped showing telemetry". A subsequent
155
+ # refresh will retry the query immediately.
156
+ if cards:
157
+ _cache[cache_key] = (now, payload)
158
+ else:
159
+ payload["diagnostics"] = {
160
+ "reason": "App Insights returned 0 invocations for the "
161
+ "selected window. If you expect data here, widen the time "
162
+ "range or verify that traces are being emitted."
163
+ }
164
+ return payload
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # KQL queries
169
+ # ---------------------------------------------------------------------------
170
+
171
+
172
+ _KQL_SUMMARY = """
173
+ union dependencies, requests
174
+ | where timestamp > ago({hours}h)
175
+ | where name has "invoke_agent" or name has "chat " or name has "RUN "
176
+ | summarize
177
+ invocations = count(),
178
+ errors = countif(success == false),
179
+ avg_ms = avg(duration),
180
+ p95_ms = percentile(duration, 95)
181
+ """
182
+
183
+ _KQL_HOURLY_INVOCATIONS = """
184
+ union dependencies, requests
185
+ | where timestamp > ago({hours}h)
186
+ | where name has "invoke_agent" or name has "chat "
187
+ | summarize count = count() by bin(timestamp, {bucket})
188
+ | order by timestamp asc
189
+ """
190
+
191
+ _KQL_HOURLY_LATENCY = """
192
+ dependencies
193
+ | where timestamp > ago({hours}h)
194
+ | where name has "invoke_agent" or name has "chat "
195
+ | summarize p95_ms = percentile(duration, 95) by bin(timestamp, {bucket})
196
+ | order by timestamp asc
197
+ """
198
+
199
+ _KQL_TOKENS = """
200
+ dependencies
201
+ | where timestamp > ago({hours}h)
202
+ | extend input_t = toint(coalesce(
203
+ customDimensions["gen_ai.usage.input_tokens"],
204
+ customDimensions["llm.usage.input_tokens"]
205
+ ))
206
+ | extend output_t = toint(coalesce(
207
+ customDimensions["gen_ai.usage.output_tokens"],
208
+ customDimensions["llm.usage.output_tokens"]
209
+ ))
210
+ | extend model_name = tostring(coalesce(
211
+ customDimensions["gen_ai.request.model"],
212
+ customDimensions["gen_ai.response.model"],
213
+ customDimensions["llm.request.model"],
214
+ customDimensions["llm.response.model"],
215
+ "unknown"
216
+ ))
217
+ | where isnotnull(input_t) or isnotnull(output_t)
218
+ | summarize
219
+ input_tokens = sum(input_t),
220
+ output_tokens = sum(output_t)
221
+ by model_name
222
+ | order by (input_tokens + output_tokens) desc
223
+ """
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # HTTP helpers
228
+ # ---------------------------------------------------------------------------
229
+
230
+
231
+ def _acquire_token() -> str:
232
+ """Acquire an OAuth bearer token for the App Insights API.
233
+
234
+ Cached in-process for 5 minutes to avoid re-running the expensive
235
+ DefaultAzureCredential chain (IMDS timeouts on non-Azure boxes are
236
+ the single biggest source of cockpit latency).
237
+ """
238
+ cached = _token_cache.get("bearer")
239
+ now = time.time()
240
+ if cached and now - cached[0] < _TOKEN_CACHE_TTL_SECONDS:
241
+ return cached[1]
242
+
243
+ from azure.identity import DefaultAzureCredential
244
+ credential = DefaultAzureCredential(exclude_developer_cli_credential=True, process_timeout=30)
245
+ token = credential.get_token("https://api.applicationinsights.io/.default")
246
+ _token_cache["bearer"] = (now, token.token)
247
+ return token.token
248
+
249
+
250
+ _TOKEN_CACHE_TTL_SECONDS = 5 * 60
251
+ _token_cache: Dict[str, Tuple[float, str]] = {}
252
+
253
+
254
+ def _run_query(app_id: str, bearer: str, kql: str) -> Optional[Dict[str, Any]]:
255
+ """POST a KQL query to the App Insights REST endpoint.
256
+
257
+ Returns ``None`` on any failure (HTTP error, network issue, or an
258
+ Application Insights query error returned with HTTP 200). The
259
+ caller treats ``None`` as a recoverable failure and surfaces the
260
+ reason via diagnostics rather than caching a misleading empty
261
+ result.
262
+ """
263
+ try:
264
+ # urllib stays in stdlib - avoids dragging requests as a dep.
265
+ import json as _json
266
+ from urllib import error, request
267
+
268
+ body = _json.dumps({"query": kql}).encode("utf-8")
269
+ req = request.Request(
270
+ url=f"https://api.applicationinsights.io/v1/apps/{app_id}/query",
271
+ data=body,
272
+ headers={
273
+ "Authorization": f"Bearer {bearer}",
274
+ "Content-Type": "application/json",
275
+ },
276
+ method="POST",
277
+ )
278
+ with request.urlopen(req, timeout=10) as resp: # noqa: S310
279
+ data = resp.read()
280
+ parsed = _json.loads(data)
281
+ # App Insights surfaces query failures with HTTP 200 and an
282
+ # ``error`` object — surface those as failures so the caller
283
+ # does not mistake them for "no data".
284
+ if isinstance(parsed, dict) and parsed.get("error"):
285
+ err = parsed["error"]
286
+ msg = err.get("message") if isinstance(err, dict) else str(err)
287
+ log.debug("app insights query reported error: %s", msg)
288
+ return None
289
+ return _flatten_first_table(parsed)
290
+ except (error.URLError, ValueError, KeyError) as exc:
291
+ log.debug("app insights query failed: %s", exc)
292
+ return None
293
+ except Exception as exc: # noqa: BLE001
294
+ log.debug("app insights query failed unexpectedly: %s", exc)
295
+ return None
296
+
297
+
298
+ def _flatten_first_table(parsed: Dict[str, Any]) -> Dict[str, Any]:
299
+ """Reduce the Kusto REST response into a list of column->value rows."""
300
+ tables = parsed.get("tables") or []
301
+ if not tables:
302
+ return {"rows": []}
303
+ table = tables[0]
304
+ columns = [c.get("name") for c in (table.get("columns") or [])]
305
+ rows = []
306
+ for raw in table.get("rows") or []:
307
+ rows.append(dict(zip(columns, raw)))
308
+ return {"columns": columns, "rows": rows}
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # Card builders
313
+ # ---------------------------------------------------------------------------
314
+
315
+
316
+ def _build_cards(
317
+ summary: Dict[str, Any],
318
+ invocations_buckets: Dict[str, Any],
319
+ latency_buckets: Dict[str, Any],
320
+ tokens: Dict[str, Any],
321
+ lookback_hours: int = 24,
322
+ ) -> List[Dict[str, Any]]:
323
+ cards: List[Dict[str, Any]] = []
324
+ rows = summary.get("rows") or []
325
+ if not rows:
326
+ return cards
327
+ row = rows[0]
328
+
329
+ invocations = int(row.get("invocations") or 0)
330
+ errors = int(row.get("errors") or 0)
331
+ error_rate = (errors / invocations) if invocations else 0.0
332
+ p95_ms = row.get("p95_ms")
333
+ p95_seconds = (float(p95_ms) / 1000.0) if p95_ms is not None else None
334
+
335
+ inv_series, inv_labels = _hourly_series(invocations_buckets, "count")
336
+ lat_series, lat_labels = _hourly_series(latency_buckets, "p95_ms", scale=1 / 1000.0)
337
+
338
+ window_label = _window_label(lookback_hours)
339
+
340
+ cards.append({
341
+ "key": "prod_errors",
342
+ "label": f"Error rate ({window_label})",
343
+ "value": f"{int(error_rate * 100)}%",
344
+ "unit": f"{errors} errors",
345
+ "series": inv_series or [0.0],
346
+ "labels": inv_labels,
347
+ "badge": _error_rate_badge(error_rate),
348
+ "help": (
349
+ "Share of invocations whose dependency telemetry reported "
350
+ "success = false."
351
+ "\n\nBadge tiers:"
352
+ "\nâ€ĸ 0% - healthy"
353
+ "\nâ€ĸ under 5% - watch"
354
+ "\nâ€ĸ 5% or more - unhealthy"
355
+ ),
356
+ "source": "Share of invocations that reported a failure status.",
357
+ })
358
+
359
+ cards.append({
360
+ "key": "prod_p95",
361
+ "label": f"P95 latency ({window_label})",
362
+ "value": f"{p95_seconds:.2f}" if p95_seconds is not None else " - ",
363
+ "unit": "s",
364
+ "series": lat_series or ([p95_seconds] if p95_seconds is not None else [0.0]),
365
+ "labels": lat_labels,
366
+ "badge": _latency_badge(p95_seconds),
367
+ "help": (
368
+ "95th percentile end-to-end agent latency over the window. "
369
+ "Includes invoke_agent spans (full agent turn with tool "
370
+ "calls) and chat spans (direct model calls)."
371
+ "\n\nBadge tiers:"
372
+ "\nâ€ĸ under 2s - snappy"
373
+ "\nâ€ĸ 2 to 5s - acceptable"
374
+ "\nâ€ĸ over 5s - sluggish"
375
+ ),
376
+ "source": "95th-percentile end-to-end duration of agent and chat spans.",
377
+ })
378
+
379
+ return cards
380
+
381
+
382
+ def _window_label(hours: int) -> str:
383
+ if hours <= 24:
384
+ return "24h"
385
+ if hours == 24 * 7:
386
+ return "7d"
387
+ if hours == 24 * 30:
388
+ return "30d"
389
+ days = hours // 24
390
+ return f"{days}d" if days else f"{hours}h"
391
+
392
+
393
+ def _hourly_series(buckets: Dict[str, Any], value_key: str, *, scale: float = 1.0) -> Tuple[List[float], List[str]]:
394
+ rows = (buckets or {}).get("rows") or []
395
+ series: List[float] = []
396
+ labels: List[str] = []
397
+ for r in rows:
398
+ v = r.get(value_key)
399
+ if v is None:
400
+ continue
401
+ series.append(float(v) * scale)
402
+ ts = r.get("timestamp") or ""
403
+ labels.append(str(ts)[:16].replace("T", " "))
404
+ return series, labels
405
+
406
+
407
+ def _error_rate_badge(rate: float) -> Dict[str, str]:
408
+ if rate <= 0.01:
409
+ return {"label": "healthy", "tone": "ok"}
410
+ if rate <= 0.05:
411
+ return {"label": "watch", "tone": "warn"}
412
+ return {"label": "elevated", "tone": "crit"}
413
+
414
+
415
+ def _latency_badge(seconds: Optional[float]) -> Dict[str, str]:
416
+ if seconds is None:
417
+ return {"label": "no data", "tone": "muted"}
418
+ if seconds <= 5:
419
+ return {"label": "snappy", "tone": "ok"}
420
+ if seconds <= 15:
421
+ return {"label": "ok", "tone": "info"}
422
+ return {"label": "slow", "tone": "warn"}
423
+
424
+
425
+ def _format_tokens(n: int) -> str:
426
+ if n >= 1_000_000:
427
+ return f"{n / 1_000_000:.1f}M"
428
+ if n >= 1_000:
429
+ return f"{n / 1_000:.1f}k"
430
+ return str(n)
@@ -0,0 +1,207 @@
1
+ """Markdown renderer for watchdog agent findings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from typing import Dict, List
8
+
9
+ from agentops.agent.analyzer import AnalysisResult
10
+ from agentops.agent.findings import Category, Finding, Severity, severity_emoji
11
+ from agentops.agent.knowledge import find_waf_item
12
+
13
+ _CATEGORY_ORDER: List[Category] = [
14
+ Category.QUALITY,
15
+ Category.PERFORMANCE,
16
+ Category.RELIABILITY,
17
+ Category.OPERATIONAL_EXCELLENCE,
18
+ Category.SECURITY,
19
+ Category.RESPONSIBLE_AI,
20
+ ]
21
+
22
+ _CATEGORY_LABEL: Dict[Category, str] = {
23
+ Category.QUALITY: "Quality",
24
+ Category.PERFORMANCE: "Performance Efficiency",
25
+ Category.RELIABILITY: "Reliability",
26
+ Category.OPERATIONAL_EXCELLENCE: "Operational Excellence",
27
+ Category.SECURITY: "Security",
28
+ Category.RESPONSIBLE_AI: "Responsible AI",
29
+ }
30
+
31
+ _CATEGORY_FOOTER: Dict[Category, str] = {
32
+ Category.SECURITY: (
33
+ "_Audit reference: Microsoft Well-Architected Framework for AI "
34
+ "workloads - Security pillar - "
35
+ "https://learn.microsoft.com/azure/well-architected/ai/security_"
36
+ ),
37
+ }
38
+
39
+
40
+ def _format_diagnostics_row(name: str, diagnostics: dict) -> str:
41
+ status = diagnostics.get("status", "unknown")
42
+ detail = diagnostics.get("reason") or diagnostics.get("runs_loaded") or ""
43
+ return f"| `{name}` | `{status}` | {detail} |"
44
+
45
+
46
+ def _format_finding_row(finding: Finding) -> str:
47
+ return (
48
+ f"| {severity_emoji(finding.severity)} `{finding.severity.value}` "
49
+ f"| `{finding.id}` | {finding.title} | `{finding.source}` |"
50
+ )
51
+
52
+
53
+ def _verdict_banner(result: AnalysisResult) -> str:
54
+ if not result.findings:
55
+ return "## Verdict: ✅ No issues detected"
56
+ max_sev = result.max_severity
57
+ if max_sev == Severity.CRITICAL:
58
+ return "## Verdict: 🚨 CRITICAL issues found"
59
+ if max_sev == Severity.WARNING:
60
+ return "## Verdict: âš ī¸ Warnings found"
61
+ return "## Verdict: â„šī¸ Informational findings"
62
+
63
+
64
+ def _group_by_category(findings: List[Finding]) -> Dict[Category, List[Finding]]:
65
+ grouped: Dict[Category, List[Finding]] = {}
66
+ for f in findings:
67
+ grouped.setdefault(f.category, []).append(f)
68
+ return grouped
69
+
70
+
71
+ def _render_finding_detail(lines: List[str], finding: Finding, workspace=None) -> None:
72
+ lines.append(
73
+ f"#### {severity_emoji(finding.severity)} `{finding.id}` - {finding.title}"
74
+ )
75
+ lines.append("")
76
+ lines.append(f"- **Severity:** `{finding.severity.value}`")
77
+ lines.append(f"- **Category:** `{finding.category.value}`")
78
+ lines.append(f"- **Source:** `{finding.source}`")
79
+ waf = find_waf_item(finding.id, workspace=workspace)
80
+ if waf is not None:
81
+ lines.append(
82
+ f"- **WAF:** `{waf.pillar}` / `{waf.area}` - [{waf.item_id}]({waf.reference_url})"
83
+ )
84
+ lines.append("")
85
+ lines.append(finding.summary)
86
+ lines.append("")
87
+ lines.append(f"**Recommendation:** {finding.recommendation}")
88
+ lines.append("")
89
+ if finding.evidence:
90
+ lines.append("**Evidence:**")
91
+ lines.append("")
92
+ lines.append("```json")
93
+ lines.append(json.dumps(finding.evidence, indent=2, default=str))
94
+ lines.append("```")
95
+ lines.append("")
96
+
97
+
98
+ def render_report(result: AnalysisResult) -> str:
99
+ lines: List[str] = []
100
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
101
+ lines.append("# AgentOps Doctor Report")
102
+ lines.append("")
103
+ lines.append(f"_Generated: {now}_")
104
+ lines.append("")
105
+ lines.append(_verdict_banner(result))
106
+ lines.append("")
107
+
108
+ # Summary counts
109
+ sev_counts = {Severity.CRITICAL: 0, Severity.WARNING: 0, Severity.INFO: 0}
110
+ cat_counts: Dict[Category, int] = {c: 0 for c in _CATEGORY_ORDER}
111
+ for f in result.findings:
112
+ sev_counts[f.severity] += 1
113
+ cat_counts[f.category] = cat_counts.get(f.category, 0) + 1
114
+
115
+ lines.append("## Summary")
116
+ lines.append("")
117
+ lines.append("| Severity | Count |")
118
+ lines.append("|---|---|")
119
+ lines.append(f"| 🚨 Critical | {sev_counts[Severity.CRITICAL]} |")
120
+ lines.append(f"| âš ī¸ Warning | {sev_counts[Severity.WARNING]} |")
121
+ lines.append(f"| â„šī¸ Info | {sev_counts[Severity.INFO]} |")
122
+ lines.append("")
123
+ lines.append("| Category | Count |")
124
+ lines.append("|---|---|")
125
+ for cat in _CATEGORY_ORDER:
126
+ lines.append(f"| {_CATEGORY_LABEL[cat]} | {cat_counts.get(cat, 0)} |")
127
+ lines.append("")
128
+
129
+ # Sources
130
+ lines.append("## Sources")
131
+ lines.append("")
132
+ lines.append("| Source | Status | Detail |")
133
+ lines.append("|---|---|---|")
134
+ for name, diag in result.diagnostics.items():
135
+ lines.append(_format_diagnostics_row(name, diag))
136
+ lines.append("")
137
+
138
+ # Findings grouped by category
139
+ if result.findings:
140
+ grouped = _group_by_category(result.findings)
141
+ lines.append("## Findings")
142
+ lines.append("")
143
+ for cat in _CATEGORY_ORDER:
144
+ bucket = grouped.get(cat)
145
+ if not bucket:
146
+ continue
147
+ lines.append(f"### {_CATEGORY_LABEL[cat]}")
148
+ lines.append("")
149
+ lines.append("| Severity | ID | Title | Source |")
150
+ lines.append("|---|---|---|---|")
151
+ for f in bucket:
152
+ lines.append(_format_finding_row(f))
153
+ lines.append("")
154
+ for f in bucket:
155
+ _render_finding_detail(lines, f, workspace=result.workspace)
156
+ footer = _CATEGORY_FOOTER.get(cat)
157
+ if footer:
158
+ lines.append(footer)
159
+ lines.append("")
160
+ else:
161
+ lines.append("## Findings")
162
+ lines.append("")
163
+ lines.append("_No findings - all configured checks passed._")
164
+ lines.append("")
165
+
166
+ # History appendix
167
+ if result.history and result.history.runs:
168
+ lines.append("## Recent runs")
169
+ lines.append("")
170
+ lines.append("| Run ID | Timestamp | Items pass | Run pass |")
171
+ lines.append("|---|---|---|---|")
172
+ for run in result.history.runs[-10:]:
173
+ ts = run.timestamp.strftime("%Y-%m-%d %H:%M") if run.timestamp else "-"
174
+ items = (
175
+ f"{run.items_passed_all}/{run.items_total}"
176
+ if run.items_total
177
+ else "-"
178
+ )
179
+ run_pass = (
180
+ "✅" if run.run_pass else "❌" if run.run_pass is False else "-"
181
+ )
182
+ lines.append(f"| `{run.run_id}` | {ts} | {items} | {run_pass} |")
183
+ lines.append("")
184
+
185
+ return "\n".join(lines).rstrip() + "\n"
186
+
187
+
188
+ def short_chat_summary(result: AnalysisResult) -> str:
189
+ """Compact one-screen summary used by the Copilot Extension server."""
190
+ if not result.findings:
191
+ return "✅ No issues detected by the AgentOps doctor."
192
+ counts = {Severity.CRITICAL: 0, Severity.WARNING: 0, Severity.INFO: 0}
193
+ for f in result.findings:
194
+ counts[f.severity] += 1
195
+ parts = [
196
+ f"AgentOps doctor found {len(result.findings)} finding(s): "
197
+ f"🚨 {counts[Severity.CRITICAL]} critical, "
198
+ f"âš ī¸ {counts[Severity.WARNING]} warning, "
199
+ f"â„šī¸ {counts[Severity.INFO]} info."
200
+ ]
201
+ parts.append("")
202
+ parts.append("Top items:")
203
+ for f in result.findings[:5]:
204
+ parts.append(
205
+ f"- {severity_emoji(f.severity)} **{f.id}** - `{f.category.value}` - {f.title}"
206
+ )
207
+ return "\n".join(parts)
@@ -0,0 +1 @@
1
+ """FastAPI Copilot Extension server for the watchdog agent."""