Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/azd/.vscode/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ words:
- protoimpl
- protojson
- protoreflect
- projectconfig
- SNAPPROCESS
- structpb
- subtest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ type foundryConnectionsProbeFn func(
) ([]string, error)

// newCheckConnections produces Check `remote.connections` (P5.1
// C15). For each `ConnectionResource` declared in any service's
// `agent.manifest.yaml` (collected by the C2 manifest walker), the
// C15). For each connection declared in azure.yaml, the
// check queries the Foundry project's connection list and verifies a
// connection with the matching name exists. The check Passes when
// every manifest-declared connection has a corresponding entry;
Expand Down Expand Up @@ -75,7 +74,7 @@ type foundryConnectionsProbeFn func(
func newCheckConnections(deps Dependencies) Check {
return Check{
ID: "remote.connections",
Name: "Manifest connections exist on Foundry project",
Name: "Configured connections exist on Foundry project",
Remote: true,
Fn: func(ctx context.Context, _ Options, prior []Result) Result {
if deps.AzdClient == nil {
Expand Down Expand Up @@ -132,10 +131,28 @@ func newCheckConnections(deps Dependencies) Check {
Suggestion: "Re-run `azd ai agent doctor`; the state assembly returned nil unexpectedly.",
}
}
if len(state.ConnectionLoadErrors) > 0 {
return Result{
Status: StatusFail,
Message: fmt.Sprintf(
"could not load connection configuration: %s",
strings.Join(
state.ConnectionLoadErrors,
"; ",
),
),
Suggestion: "Fix the connection services in " +
"azure.yaml or the agent manifest, then retry.",
Details: map[string]any{
"loadErrors": state.ConnectionLoadErrors,
},
}
}
if !state.HasConnections {
return Result{
Status: StatusSkip,
Message: "skipped: no connection resources declared in any service's agent.manifest.yaml.",
Status: StatusSkip,
Message: "skipped: no connection resources " +
"declared in azure.yaml.",
}
}

Expand Down Expand Up @@ -285,14 +302,16 @@ func classifyConnections(
}
}

suggestion := "Run `azd provision` to create the missing " +
"connection(s), or update their azure.yaml service names."
Comment on lines +305 to +306

return Result{
Status: StatusFail,
Message: fmt.Sprintf(
"%d connection(s) referenced by agent.manifest.yaml are missing on project %s: %s",
"%d connection(s) referenced by azure.yaml are "+
"missing on project %s: %s",
len(missing), project, sb.String()),
Suggestion: "Run `azd provision` to create the missing connection(s), " +
"or update the agent.manifest.yaml `resources[].name` entries to " +
"match connections that already exist on the Foundry project.",
Suggestion: suggestion,
Details: map[string]any{
"missingConnections": missing,
"matchedCount": matched,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,53 @@ func TestCheckConnections_FailsWithMissing(t *testing.T) {
require.EqualValues(t, 1, res.Details["matchedCount"])
}

func TestCheckConnections_SplitResourceUsesProvision(t *testing.T) {
t.Parallel()
state := &nextstep.State{
HasConnections: true,
Connections: []nextstep.ResourceRef{
{
Name: "search-conn",
ServiceName: "search-conn",
},
},
}
deps := Dependencies{
assembleState: fixedAssembler(state),
readProjectResourceIDFn: fixedProjectIDReader(validProjectResourceID, nil),
probeFoundryConnections: fixedConnectionsProbe(nil, nil, nil),
}

res := runConnectionsCheck(
t,
deps,
healthyConnectionsPrior(),
)

require.Equal(t, StatusFail, res.Status)
require.Contains(t, res.Suggestion, "azd provision")
require.NotContains(t, res.Suggestion, "azd deploy")
}

func TestCheckConnections_FailsOnIncompleteState(t *testing.T) {
t.Parallel()
deps := Dependencies{
assembleState: fixedAssembler(&nextstep.State{
ConnectionLoadErrors: []string{"missing connection ref"},
}),
}

res := runConnectionsCheck(
t,
deps,
healthyConnectionsPrior(),
)

require.Equal(t, StatusFail, res.Status)
require.Contains(t, res.Message, "missing connection ref")
require.Contains(t, res.Suggestion, "azure.yaml")
}

func TestCheckConnections_FailsWhenAllMissing(t *testing.T) {
t.Parallel()
state := &nextstep.State{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,8 @@ type Dependencies struct {
) ([]string, error)
}

// NewLocalChecks returns the canonical sequence of local doctor checks
// in execution order. Phase 4.2 covered checks 1-3; Phase 4.3 added
// checks 4-6 (agent service detected, project endpoint set, agent.yaml
// valid). Phase 5 C9 appends check 7 (manual env vars set). Phase 5
// C14 appends check 8 (`local.toolboxes`) which reads per-toolbox MCP
// endpoint env vars; it is local because it does not call ARM /
// Foundry (only the active azd environment).
// NewLocalChecks returns local doctor checks in execution order.
// Resource configuration checks run before checks that consume them.
func NewLocalChecks(deps Dependencies) []Check {
return []Check{
newCheckGRPCAndVersion(deps),
Expand All @@ -170,6 +165,7 @@ func NewLocalChecks(deps Dependencies) []Check {
newCheckAgentServiceDetected(deps),
newCheckProjectEndpointSet(deps),
newCheckAgentYAMLValid(deps),
newCheckModels(deps),
newCheckManualEnvVars(deps),
newCheckToolboxes(deps),
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ func TestNewLocalChecks_OrderAndIDs(t *testing.T) {
t.Parallel()

checks := NewLocalChecks(Dependencies{})
require.Len(t, checks, 8)
require.Len(t, checks, 9)

want := []struct {
id string
Expand All @@ -455,9 +455,10 @@ func TestNewLocalChecks_OrderAndIDs(t *testing.T) {
{"local.environment-selected", "azd environment selected", false},
{"local.agent-service-detected", "agent service in azure.yaml", false},
{"local.project-endpoint-set", "FOUNDRY_PROJECT_ENDPOINT set", false},
{"local.agent-yaml-valid", "agent.yaml valid (per service)", false},
{"local.agent-yaml-valid", "agent definition valid (per service)", false},
{"local.models", "azure.yaml model configuration valid", false},
{"local.manual-env-vars", "manual env vars set", false},
{"local.toolboxes", "Manifest toolboxes have endpoint env vars set", false},
{"local.toolboxes", "azure.yaml toolboxes have endpoint env vars set", false},
}
for i, w := range want {
require.Equal(t, w.id, checks[i].ID, "index %d", i)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ import (
// newCheckManualEnvVars produces Check `local.manual-env-vars` — the
// "manual config values not set" diagnostic.
//
// "Manual" env vars are values referenced by `${...}` syntax inside an
// agent.yaml whose names are NOT declared as outputs of the project's
// "Manual" env vars use `${...}` in agent configuration and are not
// declared as outputs of the project's
// infrastructure (Bicep / Terraform). They are operator-supplied:
// third-party API keys, model deployment names, hand-rolled connection
// strings. They have to be set in the active azd environment before
// `azd ai agent run` (local) or `azd deploy` (Azure) can resolve the
// agent.yaml — otherwise the running agent sees the literal `${KEY}`
// configuration. Otherwise the agent sees the literal `${KEY}`
// string and almost certainly fails on first use.
//
// The classification of "manual" vs "infra" lives in nextstep's
Expand All @@ -42,7 +42,7 @@ import (
// - deps.AzdClient is nil (gRPC channel unavailable). Check
// `local.grpc-extension` will already have failed with the actionable
// error.
// - `local.agent-yaml-valid` failed or was skipped. A broken agent.yaml
// - `local.agent-yaml-valid` failed or was skipped.
// produces an empty MissingManualVars (the classifier can't extract
// references it can't parse), which would mislead the user into
// thinking nothing was missing. This guard transitively covers the
Expand Down Expand Up @@ -74,15 +74,20 @@ func newCheckManualEnvVars(deps Dependencies) Check {
return Result{Status: StatusSkip, Message: "skipped: azd extension not reachable"}
}
if priorBlocked(prior, "local.agent-yaml-valid") {
return Result{Status: StatusSkip, Message: "skipped: agent.yaml check failed or skipped"}
return Result{
Status: StatusSkip,
Message: "skipped: agent definition check " +
"failed or was skipped",
}
}
if priorBlocked(prior, "local.environment-selected") {
// Without an azd env, AssembleState's detectMissingVars
// block is skipped (state.go:258), so MissingManualVars
// would be empty and the check would falsely Pass.
return Result{
Status: StatusSkip,
Message: "skipped: no azd environment selected (cannot resolve agent.yaml variables)",
Status: StatusSkip,
Message: "skipped: no azd environment selected " +
"(cannot resolve agent environment variables)",
}
}

Expand Down Expand Up @@ -132,7 +137,8 @@ func newCheckManualEnvVars(deps Dependencies) Check {
return Result{
Status: StatusFail,
Message: fmt.Sprintf(
"%d manual env var(s) referenced by agent.yaml are not set in the azd environment: %s",
"%d manual env var(s) referenced by azure.yaml "+
"are not set in the azd environment: %s",
len(missing), strings.Join(missing, ", ")),
Suggestion: suggestion,
Details: map[string]any{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func TestCheckManualEnvVars_PriorAgentYAMLFailed_Skips(t *testing.T) {
got := check.Fn(t.Context(), Options{}, prior)

require.Equal(t, StatusSkip, got.Status)
require.Contains(t, got.Message, "agent.yaml check failed")
require.Contains(t, got.Message, "agent definition check")
}

func TestCheckManualEnvVars_PriorAgentYAMLSkipped_AlsoSkips(t *testing.T) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package doctor

import (
"context"
"fmt"
"strings"

"azureaiagent/internal/cmd/nextstep"

"github.com/azure/azure-dev/cli/azd/pkg/azdext"
)

// newCheckModels validates local model resource configuration.
func newCheckModels(deps Dependencies) Check {
return Check{
ID: "local.models",
Name: "azure.yaml model configuration valid",
Fn: func(
ctx context.Context,
_ Options,
prior []Result,
) Result {
if deps.AzdClient == nil {
return Result{
Status: StatusSkip,
Message: "skipped: azd extension not reachable.",
}
}
if priorBlocked(prior, "local.azure-yaml") ||
priorBlocked(prior, "local.agent-service-detected") {
return Result{
Status: StatusSkip,
Message: "skipped: project configuration " +
"checks did not succeed.",
}
}

assembler := deps.assembleState
if assembler == nil {
assembler = func(
ctx context.Context,
client *azdext.AzdClient,
) (*nextstep.State, []error) {
return nextstep.AssembleState(ctx, client)
}
}
state, errs := assembler(ctx, deps.AzdClient)
if state == nil {
cause := "unknown error"
if len(errs) > 0 {
cause = errs[0].Error()
}
return Result{
Status: StatusFail,
Message: fmt.Sprintf(
"failed to assemble agent state: %s",
cause,
),
Suggestion: "Fix azure.yaml and retry.",
}
}
if len(state.ModelLoadErrors) > 0 {
return Result{
Status: StatusFail,
Message: fmt.Sprintf(
"could not load model configuration: %s",
strings.Join(state.ModelLoadErrors, "; "),
),
Suggestion: "Fix the model configuration in " +
"azure.yaml or the agent manifest, then retry.",
Details: map[string]any{
"loadErrors": state.ModelLoadErrors,
},
}
}
if !state.HasModels {
return Result{
Status: StatusSkip,
Message: "skipped: no model resources " +
"declared in azure.yaml.",
}
}
return Result{
Status: StatusPass,
Message: "model resource configuration loaded.",
Comment on lines +86 to +88
}
},
}
}
Loading
Loading