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,108 @@
1
+ """``--baseline`` comparison helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Dict, List, Optional
8
+
9
+ from agentops.core.results import (
10
+ ComparisonInfo,
11
+ ComparisonMetric,
12
+ ComparisonRow,
13
+ RunResult,
14
+ )
15
+
16
+
17
+ def load_baseline(path: Path) -> RunResult:
18
+ """Load a previous ``results.json`` for comparison."""
19
+ if not path.exists():
20
+ raise FileNotFoundError(f"baseline file not found: {path}")
21
+ with path.open("r", encoding="utf-8") as handle:
22
+ payload = json.load(handle)
23
+ return RunResult.model_validate(payload)
24
+
25
+
26
+ def _direction(current: Optional[float], baseline: Optional[float]) -> str:
27
+ if current is None or baseline is None:
28
+ return "unchanged"
29
+ if current > baseline:
30
+ return "improved"
31
+ if current < baseline:
32
+ return "regressed"
33
+ return "unchanged"
34
+
35
+
36
+ def _row_passed(row_metrics: List[Dict[str, float | None]]) -> bool:
37
+ """Best-effort proxy: a row is "passing" when no metric reports an error."""
38
+ return all("error" not in metric or not metric["error"] for metric in row_metrics)
39
+
40
+
41
+ def build_comparison(
42
+ *,
43
+ current: RunResult,
44
+ baseline: RunResult,
45
+ baseline_path: Path,
46
+ ) -> ComparisonInfo:
47
+ metrics: List[ComparisonMetric] = []
48
+ metric_names = sorted(set(current.aggregate_metrics) | set(baseline.aggregate_metrics))
49
+ for name in metric_names:
50
+ current_value = current.aggregate_metrics.get(name)
51
+ baseline_value = baseline.aggregate_metrics.get(name)
52
+ delta = (
53
+ current_value - baseline_value
54
+ if current_value is not None and baseline_value is not None
55
+ else None
56
+ )
57
+ metrics.append(
58
+ ComparisonMetric(
59
+ metric=name,
60
+ current=current_value,
61
+ baseline=baseline_value,
62
+ delta=delta,
63
+ direction=_direction(current_value, baseline_value),
64
+ )
65
+ )
66
+
67
+ rows: List[ComparisonRow] = []
68
+ baseline_by_index = {row.row_index: row for row in baseline.rows}
69
+ for row in current.rows:
70
+ baseline_row = baseline_by_index.get(row.row_index)
71
+ current_pass = row.error is None and all(
72
+ m.value is not None or m.error is None for m in row.metrics
73
+ )
74
+ if baseline_row is None:
75
+ rows.append(
76
+ ComparisonRow(
77
+ row_index=row.row_index,
78
+ current_passed=current_pass,
79
+ baseline_passed=None,
80
+ direction="new",
81
+ )
82
+ )
83
+ continue
84
+ baseline_pass = baseline_row.error is None and all(
85
+ m.value is not None or m.error is None for m in baseline_row.metrics
86
+ )
87
+ if current_pass and not baseline_pass:
88
+ direction = "improved"
89
+ elif baseline_pass and not current_pass:
90
+ direction = "regressed"
91
+ else:
92
+ direction = "unchanged"
93
+ rows.append(
94
+ ComparisonRow(
95
+ row_index=row.row_index,
96
+ current_passed=current_pass,
97
+ baseline_passed=baseline_pass,
98
+ direction=direction,
99
+ )
100
+ )
101
+
102
+ return ComparisonInfo(
103
+ baseline_path=str(baseline_path),
104
+ baseline_started_at=baseline.started_at,
105
+ baseline_overall_passed=baseline.summary.overall_passed,
106
+ metrics=metrics,
107
+ rows=rows,
108
+ )
@@ -0,0 +1,51 @@
1
+ """Shared runtime diagnostics for pipeline errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+
8
+ _TENANT_MISMATCH_MARKERS = (
9
+ "Tenant provided in token does not match resource token",
10
+ "Tenant provided in token does not match resource tenant",
11
+ "does not match resource tenant",
12
+ )
13
+
14
+ _TENANT_MISMATCH_GUIDANCE = (
15
+ " Check that `az login` is using the same tenant as the Foundry project, "
16
+ "or run `az login --tenant <tenant-id>`."
17
+ )
18
+
19
+
20
+ def with_tenant_mismatch_guidance(message: str) -> str:
21
+ """Append actionable Azure tenant guidance to matching error messages."""
22
+ if "az login --tenant" in message:
23
+ return message
24
+ if any(marker in message for marker in _TENANT_MISMATCH_MARKERS):
25
+ return f"{message}{_TENANT_MISMATCH_GUIDANCE}"
26
+ return message
27
+
28
+
29
+ def summarize_exception(exc: BaseException) -> str:
30
+ """Return a concise, user-facing summary for known noisy SDK errors."""
31
+ message = with_tenant_mismatch_guidance(str(exc))
32
+ validation_messages = _extract_foundry_validation_messages(message)
33
+ if validation_messages:
34
+ joined = "; ".join(validation_messages)
35
+ return f"Foundry cloud validation failed: {joined}"
36
+ return message
37
+
38
+
39
+ def _extract_foundry_validation_messages(message: str) -> list[str]:
40
+ if "Evaluation failed validation" not in message:
41
+ return []
42
+ matches = re.findall(r"Message:\s*(.+?)(?=\\n\}|\n\}|$)", message)
43
+ cleaned: list[str] = []
44
+ seen: set[str] = set()
45
+ for raw in matches:
46
+ text = raw.strip().strip("'\"")
47
+ if not text or text in seen:
48
+ continue
49
+ seen.add(text)
50
+ cleaned.append(text)
51
+ return cleaned
@@ -0,0 +1,535 @@
1
+ """Target invocation backends for AgentOps 1.0.
2
+
3
+ Each backend is a single function with the signature::
4
+
5
+ invoke(
6
+ target: TargetResolution,
7
+ config: AgentOpsConfig,
8
+ row: dict[str, Any],
9
+ *,
10
+ timeout: float,
11
+ ) -> InvocationResult
12
+
13
+ The orchestrator dispatches based on :attr:`TargetResolution.kind`. All Azure
14
+ SDK imports are lazy so the package imports without optional dependencies.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import time
22
+ import urllib.error
23
+ import urllib.request
24
+ from dataclasses import dataclass, field
25
+ from typing import Any, Dict, List, Optional
26
+
27
+ from agentops.core.agentops_config import AgentOpsConfig, TargetResolution
28
+ from agentops.pipeline.diagnostics import with_tenant_mismatch_guidance
29
+
30
+
31
+ @dataclass
32
+ class InvocationResult:
33
+ """Outcome of invoking the target on one dataset row."""
34
+
35
+ response: str
36
+ latency_seconds: float
37
+ tool_calls: Optional[List[Any]] = None
38
+ metadata: Dict[str, Any] = field(default_factory=dict)
39
+
40
+
41
+ # Maximum number of follow-up calls when running the tool-execution loop
42
+ # against a Foundry hosted/prompt agent. Most agents resolve in 1–2 hops;
43
+ # the cap exists to bound retries against pathological multi-step plans.
44
+ _MAX_TOOL_ITERATIONS = 4
45
+
46
+ # Generic stub returned to the agent for every function call during the
47
+ # tool-execution loop. The toolkit cannot run project-specific tool
48
+ # implementations, so a uniform "ok" stub keeps the loop fully generic
49
+ # while letting the agent produce its final natural-language reply.
50
+ _TOOL_STUB_OUTPUT = '{"status": "ok"}'
51
+
52
+
53
+ def _summarise_tool_calls(calls: List[Any]) -> str:
54
+ """Build a short, human-readable summary of executed tool calls.
55
+
56
+ Used as a last-resort fallback when the agent never produces
57
+ assistant text - quality evaluators still need a non-empty
58
+ ``response`` string to score.
59
+ """
60
+ parts: List[str] = []
61
+ for call in calls:
62
+ if not isinstance(call, dict):
63
+ continue
64
+ name = call.get("name") or "tool"
65
+ args = call.get("arguments")
66
+ parts.append(f"[Called {name}({args})]" if args else f"[Called {name}]")
67
+ return " ".join(parts) if parts else "[tool_call]"
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Helpers
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ def _credential() -> Any:
76
+ """Return a cached ``DefaultAzureCredential`` singleton.
77
+
78
+ Caching matters: each row invocation needs a token, and constructing a
79
+ fresh ``DefaultAzureCredential`` per call walks the full credential
80
+ chain (Azure CLI / PowerShell subprocesses included), which is both
81
+ slow and prone to transient subprocess failures on the first try.
82
+ Caching the credential lets the SDK reuse its internal token cache
83
+ across rows.
84
+ """
85
+ global _CREDENTIAL_SINGLETON
86
+ if _CREDENTIAL_SINGLETON is None:
87
+ from azure.identity import DefaultAzureCredential # noqa: WPS433
88
+
89
+ _CREDENTIAL_SINGLETON = DefaultAzureCredential(exclude_developer_cli_credential=True, process_timeout=30)
90
+ return _CREDENTIAL_SINGLETON
91
+
92
+
93
+ _CREDENTIAL_SINGLETON: Any = None
94
+
95
+
96
+ def _get_token(scope: str) -> str:
97
+ """Acquire a token for ``scope``, retrying once on transient failures.
98
+
99
+ The first credential-chain walk on Windows occasionally fails because
100
+ the Azure CLI / PowerShell subprocess is slow to spawn. Retrying once
101
+ after the credential is warmed up almost always succeeds.
102
+ """
103
+ try:
104
+ return _credential().get_token(scope).token
105
+ except Exception: # noqa: BLE001 - retry once on any transient failure
106
+ global _CREDENTIAL_SINGLETON
107
+ _CREDENTIAL_SINGLETON = None # force a fresh chain walk
108
+ return _credential().get_token(scope).token
109
+
110
+
111
+ def _project_endpoint(config: AgentOpsConfig) -> str:
112
+ endpoint = config.project_endpoint or os.getenv("AZURE_AI_FOUNDRY_PROJECT_ENDPOINT")
113
+ if not endpoint:
114
+ raise RuntimeError(
115
+ "Foundry targets require a project endpoint URL. Set "
116
+ "'project_endpoint' in agentops.yaml or set the "
117
+ "AZURE_AI_FOUNDRY_PROJECT_ENDPOINT environment variable."
118
+ )
119
+ return endpoint.rstrip("/")
120
+
121
+
122
+ def _row_input(row: Dict[str, Any]) -> str:
123
+ value = row.get("input")
124
+ if value is None:
125
+ raise ValueError("dataset row is missing required 'input' field")
126
+ return str(value)
127
+
128
+
129
+ def _http_request_json(
130
+ *,
131
+ method: str,
132
+ url: str,
133
+ headers: Dict[str, str],
134
+ body: Optional[Dict[str, Any]] = None,
135
+ timeout: float,
136
+ ) -> Dict[str, Any]:
137
+ encoded = json.dumps(body or {}).encode("utf-8") if method != "GET" else None
138
+ request = urllib.request.Request(
139
+ url=url, data=encoded, method=method, headers=headers
140
+ )
141
+ last_exc: Optional[BaseException] = None
142
+ for attempt in range(1, 4):
143
+ try:
144
+ with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
145
+ payload = response.read().decode("utf-8")
146
+ break
147
+ except urllib.error.HTTPError as exc:
148
+ detail = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
149
+ transient = exc.code >= 500 or exc.code == 429
150
+ if transient and attempt < 3:
151
+ time.sleep(2 ** attempt)
152
+ last_exc = exc
153
+ continue
154
+ message = f"HTTP {exc.code} from {url}: {detail or exc.reason}"
155
+ raise RuntimeError(with_tenant_mismatch_guidance(message)) from exc
156
+ except urllib.error.URLError as exc:
157
+ if attempt < 3:
158
+ time.sleep(2 ** attempt)
159
+ last_exc = exc
160
+ continue
161
+ raise
162
+ else: # pragma: no cover - loop exits via break/raise
163
+ raise RuntimeError(f"HTTP request to {url} failed: {last_exc!r}")
164
+ if not payload:
165
+ return {}
166
+ return json.loads(payload)
167
+
168
+
169
+ def _dot_path(payload: Any, path: str) -> Any:
170
+ """Resolve ``a.b.c`` or ``a.0.b`` against a JSON-like object."""
171
+ current = payload
172
+ for token in path.split("."):
173
+ if current is None:
174
+ return None
175
+ if isinstance(current, list):
176
+ try:
177
+ current = current[int(token)]
178
+ except (ValueError, IndexError):
179
+ return None
180
+ continue
181
+ if isinstance(current, dict):
182
+ current = current.get(token)
183
+ continue
184
+ return None
185
+ return current
186
+
187
+
188
+ def _extract_responses_text(payload: Dict[str, Any]) -> str:
189
+ """Pull assistant text from a Foundry/Responses-API payload.
190
+
191
+ Returns an empty string when the response only contains tool/function
192
+ calls (the caller must submit ``function_call_output`` items via a
193
+ follow-up call to obtain the final natural-language reply).
194
+ """
195
+ direct = payload.get("output_text")
196
+ if isinstance(direct, str) and direct.strip():
197
+ return direct.strip()
198
+
199
+ output = payload.get("output")
200
+ if isinstance(output, list):
201
+ parts: List[str] = []
202
+ for item in output:
203
+ if not isinstance(item, dict):
204
+ continue
205
+ if (
206
+ item.get("type") in {"message", "assistant_message"}
207
+ or item.get("role") == "assistant"
208
+ ):
209
+ content = item.get("content")
210
+ if isinstance(content, str):
211
+ parts.append(content)
212
+ elif isinstance(content, list):
213
+ for chunk in content:
214
+ if isinstance(chunk, dict):
215
+ text = chunk.get("text") or chunk.get("output_text")
216
+ if isinstance(text, str):
217
+ parts.append(text)
218
+ elif isinstance(chunk, str):
219
+ parts.append(chunk)
220
+ if parts:
221
+ return "\n".join(parts).strip()
222
+ return ""
223
+
224
+ return ""
225
+
226
+
227
+ def _extract_responses_tool_calls(payload: Dict[str, Any]) -> Optional[List[Any]]:
228
+ output = payload.get("output")
229
+ if not isinstance(output, list):
230
+ return None
231
+ calls: List[Any] = []
232
+ for item in output:
233
+ if isinstance(item, dict) and item.get("type") in {
234
+ "tool_call",
235
+ "function_call",
236
+ }:
237
+ calls.append(item)
238
+ return calls or None
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Backends
243
+ # ---------------------------------------------------------------------------
244
+
245
+
246
+ def _invoke_model_direct(
247
+ target: TargetResolution,
248
+ config: AgentOpsConfig,
249
+ row: Dict[str, Any],
250
+ *,
251
+ timeout: float, # noqa: ARG001
252
+ ) -> InvocationResult:
253
+ from azure.ai.projects import AIProjectClient # noqa: WPS433
254
+
255
+ project_endpoint = _project_endpoint(config)
256
+ client = AIProjectClient(endpoint=project_endpoint, credential=_credential())
257
+ openai_client = client.get_openai_client()
258
+
259
+ assert target.deployment is not None
260
+ started = time.perf_counter()
261
+ last_exc: Optional[BaseException] = None
262
+ response = None
263
+ for attempt in range(1, 4):
264
+ try:
265
+ response = openai_client.chat.completions.create(
266
+ model=target.deployment,
267
+ messages=[{"role": "user", "content": _row_input(row)}],
268
+ )
269
+ break
270
+ except Exception as exc: # noqa: BLE001
271
+ status = getattr(exc, "status_code", None)
272
+ transient = status is None or status >= 500 or status == 429
273
+ if transient and attempt < 3:
274
+ time.sleep(2 ** attempt)
275
+ last_exc = exc
276
+ continue
277
+ raise
278
+ if response is None:
279
+ raise RuntimeError(f"model_direct invocation failed after retries: {last_exc!r}")
280
+ elapsed = time.perf_counter() - started
281
+
282
+ text = ""
283
+ if response.choices:
284
+ message = response.choices[0].message
285
+ if message and message.content:
286
+ text = message.content.strip()
287
+ if not text:
288
+ raise RuntimeError("model_direct invocation returned empty content")
289
+
290
+ return InvocationResult(response=text, latency_seconds=elapsed)
291
+
292
+
293
+ def _run_responses_tool_loop(
294
+ *,
295
+ url: str,
296
+ headers: Dict[str, str],
297
+ initial_body: Dict[str, Any],
298
+ timeout: float,
299
+ follow_up_extras: Optional[Dict[str, Any]] = None,
300
+ ) -> tuple[str, List[Any], float]:
301
+ """Drive a Foundry/Responses-API tool-execution loop.
302
+
303
+ Sends ``initial_body`` to ``url``, then repeatedly submits stub
304
+ ``function_call_output`` items back via ``previous_response_id`` until
305
+ the agent emits assistant text or the iteration cap is reached.
306
+
307
+ ``follow_up_extras`` is merged into every follow-up request body
308
+ (e.g. ``agent_reference`` for prompt agents).
309
+
310
+ Returns ``(text, aggregated_tool_calls, elapsed_seconds)``.
311
+ """
312
+ started = time.perf_counter()
313
+ aggregated_tool_calls: List[Any] = []
314
+ text = ""
315
+ body = initial_body
316
+
317
+ for _iteration in range(_MAX_TOOL_ITERATIONS):
318
+ payload = _http_request_json(
319
+ method="POST",
320
+ url=url,
321
+ headers=headers,
322
+ body=body,
323
+ timeout=timeout,
324
+ )
325
+
326
+ iteration_calls = _extract_responses_tool_calls(payload) or []
327
+ aggregated_tool_calls.extend(iteration_calls)
328
+
329
+ text = _extract_responses_text(payload)
330
+ if text or not iteration_calls:
331
+ break
332
+
333
+ previous_response_id = payload.get("id")
334
+ follow_up_input: List[Dict[str, Any]] = []
335
+ for call in iteration_calls:
336
+ call_id = call.get("call_id") or call.get("id")
337
+ if not call_id:
338
+ continue
339
+ follow_up_input.append(
340
+ {
341
+ "type": "function_call_output",
342
+ "call_id": call_id,
343
+ "output": _TOOL_STUB_OUTPUT,
344
+ }
345
+ )
346
+
347
+ if not follow_up_input or not previous_response_id:
348
+ break
349
+
350
+ body = {
351
+ "input": follow_up_input,
352
+ "previous_response_id": previous_response_id,
353
+ }
354
+ if follow_up_extras:
355
+ body.update(follow_up_extras)
356
+
357
+ elapsed = time.perf_counter() - started
358
+ return text, aggregated_tool_calls, elapsed
359
+
360
+
361
+ def _invoke_foundry_prompt(
362
+ target: TargetResolution,
363
+ config: AgentOpsConfig,
364
+ row: Dict[str, Any],
365
+ *,
366
+ timeout: float,
367
+ ) -> InvocationResult:
368
+ project_endpoint = _project_endpoint(config)
369
+ token = _get_token("https://ai.azure.com/.default")
370
+ headers = {
371
+ "Content-Type": "application/json",
372
+ "Authorization": f"Bearer {token}",
373
+ }
374
+
375
+ assert target.name is not None and target.version is not None
376
+ url = f"{project_endpoint}/openai/v1/responses"
377
+ agent_reference = {
378
+ "type": "agent_reference",
379
+ "name": target.name,
380
+ "version": target.version,
381
+ }
382
+ initial_body: Dict[str, Any] = {
383
+ "input": [{"role": "user", "content": _row_input(row)}],
384
+ "agent_reference": agent_reference,
385
+ }
386
+
387
+ text, aggregated_tool_calls, elapsed = _run_responses_tool_loop(
388
+ url=url,
389
+ headers=headers,
390
+ initial_body=initial_body,
391
+ timeout=timeout,
392
+ follow_up_extras={"agent_reference": agent_reference},
393
+ )
394
+
395
+ if not text:
396
+ if aggregated_tool_calls:
397
+ text = _summarise_tool_calls(aggregated_tool_calls)
398
+ else:
399
+ raise ValueError(
400
+ "Foundry response did not include assistant output text"
401
+ )
402
+
403
+ return InvocationResult(
404
+ response=text,
405
+ latency_seconds=elapsed,
406
+ tool_calls=aggregated_tool_calls or None,
407
+ )
408
+
409
+
410
+ def _invoke_foundry_hosted(
411
+ target: TargetResolution,
412
+ config: AgentOpsConfig,
413
+ row: Dict[str, Any],
414
+ *,
415
+ timeout: float,
416
+ ) -> InvocationResult:
417
+ assert target.url is not None
418
+ token = _get_token("https://ai.azure.com/.default")
419
+ headers = {
420
+ "Content-Type": "application/json",
421
+ "Authorization": f"Bearer {token}",
422
+ **config.headers,
423
+ }
424
+
425
+ if target.protocol == "responses":
426
+ url = target.url.rstrip("/")
427
+ if not url.endswith("/responses"):
428
+ url = f"{url}/responses"
429
+ initial_body = {"input": [{"role": "user", "content": _row_input(row)}]}
430
+
431
+ text, aggregated_tool_calls, elapsed = _run_responses_tool_loop(
432
+ url=url,
433
+ headers=headers,
434
+ initial_body=initial_body,
435
+ timeout=timeout,
436
+ )
437
+
438
+ if not text:
439
+ if aggregated_tool_calls:
440
+ text = _summarise_tool_calls(aggregated_tool_calls)
441
+ else:
442
+ raise ValueError(
443
+ "Foundry response did not include assistant output text"
444
+ )
445
+
446
+ return InvocationResult(
447
+ response=text,
448
+ latency_seconds=elapsed,
449
+ tool_calls=aggregated_tool_calls or None,
450
+ )
451
+
452
+ return _invoke_http_json(target, config, row, timeout=timeout)
453
+
454
+
455
+ def _invoke_http_json(
456
+ target: TargetResolution,
457
+ config: AgentOpsConfig,
458
+ row: Dict[str, Any],
459
+ *,
460
+ timeout: float,
461
+ ) -> InvocationResult:
462
+ assert target.url is not None
463
+ headers: Dict[str, str] = {"Content-Type": "application/json", **config.headers}
464
+ if config.auth_header_env:
465
+ token = os.getenv(config.auth_header_env)
466
+ if not token:
467
+ raise RuntimeError(
468
+ f"auth_header_env {config.auth_header_env!r} is set in config but "
469
+ "the environment variable is empty"
470
+ )
471
+ headers["Authorization"] = f"Bearer {token}"
472
+
473
+ request_field = config.request_field or "message"
474
+ body: Dict[str, Any] = {request_field: _row_input(row)}
475
+
476
+ started = time.perf_counter()
477
+ payload = _http_request_json(
478
+ method="POST",
479
+ url=target.url,
480
+ headers=headers,
481
+ body=body,
482
+ timeout=timeout,
483
+ )
484
+ elapsed = time.perf_counter() - started
485
+
486
+ response_path = config.response_field or "text"
487
+ response_text = _dot_path(payload, response_path)
488
+ if response_text is None:
489
+ for fallback in ("response", "output", "content", "message", "text"):
490
+ response_text = payload.get(fallback)
491
+ if response_text:
492
+ break
493
+ if response_text is None:
494
+ raise ValueError(
495
+ f"HTTP/JSON response did not contain field {response_path!r}; "
496
+ f"got top-level keys: {sorted(payload.keys())}"
497
+ )
498
+ if not isinstance(response_text, str):
499
+ response_text = json.dumps(response_text, ensure_ascii=False)
500
+
501
+ tool_calls: Optional[List[Any]] = None
502
+ if config.tool_calls_field:
503
+ extracted = _dot_path(payload, config.tool_calls_field)
504
+ if isinstance(extracted, list):
505
+ tool_calls = extracted
506
+
507
+ return InvocationResult(
508
+ response=response_text.strip(),
509
+ latency_seconds=elapsed,
510
+ tool_calls=tool_calls,
511
+ )
512
+
513
+
514
+ # ---------------------------------------------------------------------------
515
+ # Dispatch
516
+ # ---------------------------------------------------------------------------
517
+
518
+
519
+ def invoke(
520
+ target: TargetResolution,
521
+ config: AgentOpsConfig,
522
+ row: Dict[str, Any],
523
+ *,
524
+ timeout: float,
525
+ ) -> InvocationResult:
526
+ """Dispatch to the right backend based on the resolved target kind."""
527
+ if target.kind == "model_direct":
528
+ return _invoke_model_direct(target, config, row, timeout=timeout)
529
+ if target.kind == "foundry_prompt":
530
+ return _invoke_foundry_prompt(target, config, row, timeout=timeout)
531
+ if target.kind == "foundry_hosted":
532
+ return _invoke_foundry_hosted(target, config, row, timeout=timeout)
533
+ if target.kind == "http_json":
534
+ return _invoke_http_json(target, config, row, timeout=timeout)
535
+ raise ValueError(f"unknown target kind: {target.kind}")