Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,9 @@ type Dependencies struct {

// 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
// checks 4-6 (agent service detected, project endpoint set, agent
// definition valid). Phase 5 C9 appends the manual env check.
// 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).
Expand All @@ -169,7 +170,7 @@ func NewLocalChecks(deps Dependencies) []Check {
newCheckEnvironmentSelected(deps),
newCheckAgentServiceDetected(deps),
newCheckProjectEndpointSet(deps),
newCheckAgentYAMLValid(deps),
newCheckAgentDefinitionValid(deps),
newCheckManualEnvVars(deps),
newCheckToolboxes(deps),
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,7 @@ 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.manual-env-vars", "manual env vars set", false},
{"local.toolboxes", "Manifest toolboxes have endpoint env vars set", false},
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ 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 skipped",
}
}
if priorBlocked(prior, "local.environment-selected") {
// Without an azd env, AssembleState's detectMissingVars
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 failed")
}

func TestCheckManualEnvVars_PriorAgentYAMLSkipped_AlsoSkips(t *testing.T) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,11 @@ package doctor
import (
"context"
"fmt"
"os"
"slices"
"sort"
"strings"

"azureaiagent/internal/pkg/agents/agent_yaml"
"azureaiagent/internal/pkg/paths"
"azureaiagent/internal/project"

"github.com/azure/azure-dev/cli/azd/pkg/azdext"
)
Expand Down Expand Up @@ -154,20 +153,16 @@ func newCheckProjectEndpointSet(deps Dependencies) Check {
}
}

// newCheckAgentYAMLValid produces Check `local.agent-yaml-valid`. For
// each agent service in azure.yaml, it reads `<projectPath>/<svc.RelativePath>/agent.yaml`
// and parses it as `agent_yaml.ContainerAgent`. Fails when any service's
// file is missing, unreadable, or fails to parse — collecting all errors
// rather than short-circuiting so multi-service projects get a single
// actionable report.
// newCheckAgentDefinitionValid produces Check
// `local.agent-yaml-valid`. It resolves each agent definition through
// the same path used by run and deploy.
//
// Skips when the gRPC client is unavailable or when
// `local.agent-service-detected` failed (no services to validate). The
// suggestion mirrors the spec's "fix YAML" guidance.
func newCheckAgentYAMLValid(deps Dependencies) Check {
// `local.agent-service-detected` failed (no services to validate).
func newCheckAgentDefinitionValid(deps Dependencies) Check {
return Check{
ID: "local.agent-yaml-valid",
Name: "agent.yaml valid (per service)",
Name: "agent definition valid (per service)",
Fn: func(ctx context.Context, _ Options, prior []Result) Result {
if deps.AzdClient == nil {
return Result{Status: StatusSkip, Message: "skipped: azd extension not reachable"}
Expand Down Expand Up @@ -196,80 +191,74 @@ func newCheckAgentYAMLValid(deps Dependencies) Check {
}
}

projectPath := resp.Project.Path
// Collect agent service entries in a stable order. protobuf
// `Services` is a map, so iteration order is non-deterministic
// — sorting by service name keeps the failure list (and the
// validatedPaths Detail) reproducible.
type agentSvc struct {
name string
rel string
}
var agents []agentSvc
for _, s := range resp.Project.Services {
if s == nil || s.Host != agentHost {
continue
}
agents = append(agents, agentSvc{name: s.Name, rel: s.RelativePath})
}
sort.Slice(agents, func(i, j int) bool { return agents[i].name < agents[j].name })

var validatedPaths []string
agents := collectSortedAgentServices(resp.Project.Services)
validatedServices := make([]string, 0, len(agents))
var failures []string
for _, a := range agents {
yamlPath, err := paths.JoinAllowRoot(projectPath, a.rel, "agent.yaml")
for _, svc := range agents {
_, _, _, err := project.LoadAgentDefinition(
svc,
resp.Project.Path,
)
Comment thread
huimiu marked this conversation as resolved.
if err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", a.name, err))
continue
}
if pathErr := validateAgentYAML(yamlPath); pathErr != nil {
failures = append(failures, fmt.Sprintf("%s: %v", a.name, pathErr))
failures = append(
failures,
fmt.Sprintf("%s: %v", svc.Name, err),
)
continue
}
validatedPaths = append(validatedPaths, yamlPath)

validatedServices = append(validatedServices, svc.Name)
}

if len(failures) > 0 {
return Result{
Status: StatusFail,
Message: fmt.Sprintf(
"agent.yaml validation failed for %d service(s): %s",
"agent definition validation failed for %d service(s): %s",
len(failures), strings.Join(failures, "; ")),
Suggestion: "Fix the YAML syntax or ensure agent.yaml exists in each service directory.",
Suggestion: "Fix the agent definition at its source " +
"(azure.yaml, a referenced file, or a legacy " +
"agent.yaml/agent.yml), or re-run " +
"`azd ai agent init`.",
Details: map[string]any{
"failures": failures,
"validatedPaths": validatedPaths,
"failures": failures,
"validatedServices": validatedServices,
},
}
}

return Result{
Status: StatusPass,
Message: fmt.Sprintf("agent.yaml valid for %d service(s)", len(validatedPaths)),
Status: StatusPass,
Message: fmt.Sprintf(
"agent definition valid for %d service(s)",
len(validatedServices),
),
Details: map[string]any{
"validatedPaths": validatedPaths,
"validatedServices": validatedServices,
},
}
},
}
}

// validateAgentYAML reads the file at path and runs the same validation
// (`agent_yaml.ValidateAgentDefinition`) that the deploy path uses, so a
// PASS here implies the manifest will not be rejected by deploy for any
// of: missing/invalid `kind`, missing/invalid `name`, or kind-specific
// structural problems. Returns the underlying read/validate error
// verbatim so the caller can attribute it to the offending service.
func validateAgentYAML(path string) error {
//nolint:gosec // path is validated under the project root before this helper is called.
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
if err := agent_yaml.ValidateAgentDefinition(data); err != nil {
return fmt.Errorf("validate %s: %w", path, err)
func collectSortedAgentServices(
services map[string]*azdext.ServiceConfig,
) []*azdext.ServiceConfig {
var agents []*azdext.ServiceConfig
for _, svc := range services {
if svc == nil || svc.Host != agentHost {
continue
}
agents = append(agents, svc)
}
return nil
sortAgentServices(agents)
return agents
}

func sortAgentServices(services []*azdext.ServiceConfig) {
slices.SortFunc(services, func(a, b *azdext.ServiceConfig) int {
return strings.Compare(a.Name, b.Name)
})
}

// priorBlocked reports whether the prior results contain a Fail or Skip
Expand Down
Loading
Loading