Skip to content

feat(eval): support the current azd ai eval command surface - #485

Merged
Paulo Lacerda (placerda) merged 8 commits into
developfrom
feature/azd-ai-eval-surface
Sep 6, 2026
Merged

feat(eval): support the current azd ai eval command surface#485
Paulo Lacerda (placerda) merged 8 commits into
developfrom
feature/azd-ai-eval-surface

Conversation

@placerda

Copy link
Copy Markdown
Collaborator

What

Adds support for the current azd ai eval command surface (azure.ai.evaluations) to execution: azd, alongside the existing azd ai agent eval surface, which is left behaviorally untouched.

Closes #484. Unblocks the VBD Evaluate lab (#483).

Why the recipe decides, not a setting

No new command, flag, or config field. The discovered recipe determines which azd surface runs:

Recipe azd commands Extension
evals/azure.eval.yaml azd ai eval azure.ai.evaluations (preview, azd 1.27.1+)
eval.yaml, src/<agent>/eval.yaml azd ai agent eval azure.ai.agents

Classification is by file content, never filename: a sequence-valued evals: root is current, a mapping-valued agent: or a dataset_reference: key is legacy. When both are discoverable the current surface wins and the run reports which recipe it skipped.

Read this before reviewing: the upstream extension is not published

azure.ai.evaluations exists today only as an unmerged pull request (Azure/azure-dev#9500). It is absent from both registry.json and registry.dev.json, and azd extension install azure.ai.evaluations does not resolve. Verified live on azd 1.31.0.

Every command name, flag, and JSON field in this PR was read from that PR branch's source and formal JSON schema, not guessed. But the surface can still change before it merges, which is why all of it is confined to one module.

Practical consequence: live end-to-end validation is not possible for anyone today. Everything here is verified through a mocked subprocess boundary, which the constitution requires anyway.

Nobody is opted in by accident

This is the property I most want reviewed. The new surface activates only when a current-surface recipe exists.

  • Workspace with a legacy eval.yaml and no evals/: identical behavior, no new output, no new warning, no new error. Guarded by tests/integration/test_azd_surface_compat.py.
  • agentops eval init keeps generating a legacy recipe while the current extension is unavailable. Generating evals/azure.eval.yaml today would produce a workspace whose next command fails on a dependency the user cannot install (FR-016, SC-010).
  • Legacy provenance block is byte-for-byte unchanged: no surface key added. Absent means legacy.

Design decisions worth a reviewer's attention

Submitted with --no-wait, polled by AgentOps. azd's blocking mode has an internal wait budget; when it expires it exits zero with an unfinished run and returns a differently shaped payload. Trusting the exit code would report an incomplete run as success.

Aggregate metrics are computed, not read. The azd run object exposes only counts (result_counts, per_testing_criteria_results). There are no aggregate numeric metrics anywhere on it. Each metric is the mean of its per-sample scores.

The gate is never delegated. No --fail-on is passed. azd collapses extension exit codes, so a gate breach and an operational failure would be indistinguishable. AgentOps thresholds stay the only gate, preserving the 0/2/1 contract.

Threshold binding is two-stage. A threshold naming a metric no evaluator can produce is a configuration error caught before any azd command runs (exit 1, asserted by a zero-invocation test), so a typo never bills a cloud run. A declared metric the run did not emit is a gate failure (exit 2).

Parsing is deliberately tolerant. score accepts number or string; passed is three-valued and null is never collapsed to false; an absent score is excluded from the mean rather than read as 0.0; the always-present error object is tested by message content, not existence.

Fail-closed guarantees

A run never reports a pass when it produced zero samples, no decodable metrics, a non-completed terminal status, a non-empty run-level error, or an unbound threshold. Partial retrieval cannot inflate the pass rate.

Incidental fix: a real extension-detection false positive

azd extension list lists every registry extension, including uninstalled ones marked Not installed. The previous substring scan therefore reported an extension as available before it was installed.

Verified on this machine: 9 extensions installed, 15 in the registry. azure.ai.finetune matches the text scan but is not installed, and the new structured probe correctly answers False.

Detection now parses azd extension list --installed -o json and matches on id, falling back to the text scan only when the structured form is unsupported.

Testing

1480 passed, 1 failed, 1 skipped

The single failure is pre-existing and environmental: test_cockpit.py::test_deployments_diagnostic_no_github_remote fails on git init with missing config value GIT_CONFIG_VALUE_2 in this sandbox, identically on the base commit.

+70 tests. No test requires azd, Azure credentials, or network. 191 lines added to pre-existing test files, 0 removed — no existing test was modified to accommodate this change.

Beyond the mocks, these paths were exercised against the real azd 1.31.0: the unavailable-extension message, the missing-recipe message, and the extension probe against real installed/registry data.

Reviewer notes, including things I would push back on myself

  • The volatility is real. If upstream renames a flag or field before merging, this breaks. It is contained to src/agentops/pipeline/azd_eval_runner.py by design, but that is mitigation, not immunity.
  • Per-sample text extraction is best-effort. The exact keys inside a sample's datasource_item were not verifiable from source. Extraction never affects the gate, since metric scores come from the results list, but rows could show empty input/response text against the real service.
  • run output export was not used. It would collapse two calls into one, but its document shape was unverified. Two individually verified calls were preferred.

Not in this PR

develop is currently 5 commits behind main (never synced after v0.14.0). Per .github/skills/release-management, cutting a release from a stale develop corrupts the CHANGELOG. That needs fixing before the next release, but it is unrelated to this change and deserves its own PR.


Spec Kit artifacts under specs/011-azd-ai-eval-surface/ document the specification, plan, research (including every verified upstream fact and its source), data model, interface contract, and quickstart validation guide.

Adds the current-surface recipe model (evals/azure.eval.yaml, azure.ai.evaluations) alongside the existing legacy one, plus content-based classification and cross-surface discovery.

Recipes are classified by content, never by filename: a sequence-valued 'evals' root is the current surface, a mapping-valued 'agent' or a 'dataset_reference' key is legacy. Discovery precedence is explicit eval_recipe, then the current surface (recording what it skipped), then legacy; more than one candidate within a surface stays ambiguous as before.

find_eval_yaml keeps its legacy-only behavior so the existing adapter is untouched.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
azd extension list lists every registry extension, including uninstalled ones marked 'Not installed', so scanning its text reported an extension as available before the user had installed it. Verified live: this machine has 9 extensions installed but 15 in the registry, and azure.ai.finetune matched the text scan while not being installed.

Detection now parses 'azd extension list --installed -o json' and matches on extension id, falling back to the text scan only when the structured form is unsupported. azd_available() delegates to the new shared probe and keeps its signature and answers.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds the current-surface adapter and dispatches to it from the orchestrator when the resolved recipe belongs to that surface. The legacy adapter keeps serving legacy recipes unchanged.

The adapter reconciles the evaluation, submits with --no-wait, polls to a terminal state under the AgentOps timeout, then reads the run object and every per-sample item. Submitting without waiting is deliberate: azd's blocking mode can exit zero with an unfinished run when its internal wait budget expires, and it returns a differently shaped payload when it does.

The azd run object exposes only counts, so aggregate metrics are computed as the mean of per-sample scores. Absent scores are excluded rather than read as zero, null verdicts are never collapsed to false, and a run with zero samples, no decodable metrics, or a non-completed status can never report a pass. AgentOps never passes a failure-gating flag; the release gate stays here so a gate breach remains distinguishable from an operational failure.

Thresholds bind against the recipe's declared metrics before anything runs, so a misconfigured threshold fails with exit code 1 without consuming a cloud evaluation. A declared metric the run never emitted is recorded as a failed threshold, exit code 2.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ninstallable one

eval analyze now reports the resolved recipe path and its surface for azd workspaces, including which recipe was skipped when both surfaces are present, and turns any resolution error into a reported gap instead of raising.

eval init probes for the current-surface extension and only targets it when it is actually installed. azure.ai.evaluations is preview and absent from the default azd registry, so generating evals/azure.eval.yaml today would produce a workspace whose very next command fails on a dependency the user cannot install. Availability is fail-safe: any inability to determine it means 'not available', which keeps initialization on the legacy surface that is published and installable.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Proves the current surface is strictly opt-in. A workspace with a legacy eval.yaml and no evals/ directory runs and analyzes exactly as before, emits no current-surface text, and dispatches only to azd ai agent eval commands. Legacy normalization stays aggregate-only with no surface marker in its provenance block, so an absent surface key means legacy.

Surface assertions use ordered argv slices because azd ai agent eval and azd ai eval share the ai and eval tokens.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Covers extension ids, azd version floors, discovery precedence, the threshold binding table, and what the current surface adds. States plainly that azure.ai.evaluations is preview and not yet in the default azd extension registry, and that the surface is opt-in so existing workspaces are unaffected.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Specification, implementation plan, phase 0 research, data model, interface contract, quickstart validation guide, and the task breakdown, following the existing specs/001-010 pattern.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The recipe variable was assigned Path in the current-surface branch and Optional[Path] in the legacy branch, so mypy inferred Path from the first assignment and rejected the second. Declares the type up front and narrows the discovered value before assigning.

Verified with the CI toolchain: uv run mypy src/agentops/ --ignore-missing-imports -> no issues in 109 source files.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@placerda
Paulo Lacerda (placerda) merged commit cd584fd into develop Sep 6, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant