diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_project_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_project_test.go index 774689c1c82..951d6ce8052 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_project_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/doctor/checks_project_test.go @@ -431,6 +431,45 @@ func TestCheckAgentDefinitionValid_InlineWithoutFile_Passes( require.Contains(t, got.Message, "agent definition valid") } +func TestCheckAgentDefinitionValid_InlineInvalidKind_Fails( + t *testing.T, +) { + t.Parallel() + + props, err := structpb.NewStruct(map[string]any{ + "kind": "nonsense", + "name": "echo-agent", + }) + require.NoError(t, err) + + client := newTestAzdClient(t, + &fakeProjectServer{resp: &azdext.GetProjectResponse{ + Project: &azdext.ProjectConfig{ + Path: t.TempDir(), + Services: map[string]*azdext.ServiceConfig{ + "echo-agent": { + Name: "echo-agent", + Host: agentHost, + RelativePath: "src/agent", + AdditionalProperties: props, + }, + }, + }, + }}, + &fakeEnvironmentServer{}) + check := newCheckAgentDefinitionValid( + Dependencies{AzdClient: client}, + ) + + got := check.Fn(t.Context(), Options{}, nil) + + require.Equal(t, StatusFail, got.Status) + failures, ok := got.Details["failures"].([]string) + require.True(t, ok) + require.Len(t, failures, 1) + require.Contains(t, failures[0], "template.kind must be one of") +} + func TestCheckAgentDefinitionValid_InlineWinsOverStaleFile( t *testing.T, ) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go index 0506dbd1c47..c9e6af5a4c5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -5,7 +5,6 @@ package project import ( "fmt" - "log" "maps" "os" "sync" @@ -594,6 +593,9 @@ func agentDefinitionFromStruct(s *structpb.Struct, coreImage string) (agent_yaml } if inline.Kind != agent_yaml.AgentKindHosted { + if err := validateAgentServiceDefinition(s.AsMap()); err != nil { + return agent_yaml.ContainerAgent{}, false, err + } return agent_yaml.ContainerAgent{}, false, nil } @@ -608,21 +610,8 @@ func agentDefinitionFromStruct(s *structpb.Struct, coreImage string) (agent_yaml ca := inline.toContainerAgent(cfg.Container, coreImage) - // Validate the inline definition with the same rules the on-disk agent.yaml - // path uses (kind, name format, policies), so an inline definition cannot - // silently bypass validation. Marshal back to YAML so ValidateAgentDefinition - // sees the same shape it expects from disk. - if defBytes, marshalErr := yaml.Marshal(ca); marshalErr != nil { - // A ContainerAgent should always marshal; log at debug so a regression - // here is visible during troubleshooting rather than silently skipping - // validation. - log.Printf("[debug] skipping inline agent definition validation: marshal to YAML failed: %v", marshalErr) - } else if err := agent_yaml.ValidateAgentDefinition(defBytes); err != nil { - return agent_yaml.ContainerAgent{}, false, exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - fmt.Sprintf("agent service definition is not valid: %s", err), - "fix the agent service entry in azure.yaml or re-run `azd ai agent init`", - ) + if err := validateAgentServiceDefinition(ca); err != nil { + return agent_yaml.ContainerAgent{}, false, err } if ca.Image != "" && !containerImageRefRe.MatchString(ca.Image) { @@ -636,6 +625,28 @@ func agentDefinitionFromStruct(s *structpb.Struct, coreImage string) (agent_yaml return ca, true, nil } +func validateAgentServiceDefinition(definition any) error { + defBytes, err := yaml.Marshal(definition) + if err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf( + "agent service definition is not valid: failed to marshal: %s", + err, + ), + "fix the agent service entry in azure.yaml or re-run `azd ai agent init`", + ) + } + if err := agent_yaml.ValidateAgentDefinition(defBytes); err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("agent service definition is not valid: %s", err), + "fix the agent service entry in azure.yaml or re-run `azd ai agent init`", + ) + } + return nil +} + // agentDefinitionFromDisk reads a legacy agent.yaml/agent.yml from the service // directory. This is the deprecation fallback for projects written before the // definition moved into azure.yaml. diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go index 2a719fe5ee7..9316af7e4c3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go @@ -230,6 +230,81 @@ func TestAgentDefinitionFromService_InvalidDefinition(t *testing.T) { require.Error(t, err) } +func TestLoadAgentDefinition_ResolvedKindValidation(t *testing.T) { + tests := []struct { + name string + kind agent_yaml.AgentKind + useFileRef bool + wantError bool + }{ + { + name: "inline invalid kind", + kind: "nonsense", + wantError: true, + }, + { + name: "valid workflow", + kind: agent_yaml.AgentKindWorkflow, + }, + { + name: "referenced invalid kind", + kind: "nonsense", + useFileRef: true, + wantError: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + propsValues := map[string]any{ + "kind": string(test.kind), + "name": "kind-test-agent", + } + if test.useFileRef { + require.NoError(t, os.WriteFile( + filepath.Join(projectRoot, "definition.yaml"), + []byte( + "kind: "+string(test.kind)+"\n"+ + "name: kind-test-agent\n", + ), + 0o600, + )) + propsValues = map[string]any{ + "$ref": "./definition.yaml", + } + } + + props, err := structpb.NewStruct(propsValues) + require.NoError(t, err) + svc := &azdext.ServiceConfig{ + Name: "kind-test-agent", + Host: "azure.ai.agent", + AdditionalProperties: props, + } + + _, isHosted, source, err := LoadAgentDefinition( + svc, + projectRoot, + ) + + if test.wantError { + require.ErrorContains( + t, + err, + "template.kind must be one of", + ) + } else { + require.NoError(t, err) + } + require.False(t, isHosted) + require.Equal(t, AgentDefinitionSourceInline, source) + }) + } +} + func TestLoadAgentDefinition_ToolboxServiceReference(t *testing.T) { t.Parallel()