diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index eebd8d81776..155a40005fc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1056,10 +1056,10 @@ func newInitCommand(extCtx *azdext.ExtensionContext) *cobra.Command { Long: `Initialize a new AI agent project. When -m points at a sample's unified azure.yaml (a project manifest that -declares services with host: azure.ai.project / azure.ai.agent / ...), that -azure.yaml is adopted as the project manifest and its referenced files are -placed at the project root. When -m points at an agent manifest instead, the -project's azure.yaml is generated from it. +declares a service with host: azure.ai.agent), that azure.yaml is adopted as +the project manifest and its referenced files are placed at the project root. +When -m points at an agent manifest instead, the project's azure.yaml is +generated from it. The agent name written to agent.yaml is the Foundry agent identity. Foundry agents are unique by name within a project, so deploying with an existing name @@ -1307,16 +1307,38 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, // (generate the project). For private GitHub URLs, the detector // falls back to the authenticated gh CLI download path before // deciding whether this is a unified azure.yaml. See #8798. + manifestRoot := "" + if isLocalFilePath(flags.manifestPointer) { + manifestRoot = filepath.Dir(flags.manifestPointer) + } if content, ok := readManifestContentForInitDetection( ctx, azdClient, flags.manifestPointer, httpClient, - ); ok && looksLikeFoundryAzureYaml(content) { - if err := runInitFromAzureYaml(ctx, flags, azdClient, httpClient, content); err != nil { - if exterrors.IsCancellation(err) { - return exterrors.Cancelled("initialization was cancelled") - } + ); ok { + manifestInfo, err := inspectAzureYaml(content, manifestRoot) + if err != nil { return err } - return ejectInfraAfterInit(infraProvider) + if manifestInfo.hasServices { + if manifestInfo.hasAgentService || + manifestInfo.hasUnresolvedRefs { + if err := runInitFromAzureYaml( + ctx, + flags, + azdClient, + httpClient, + content, + ); err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled( + "initialization was cancelled", + ) + } + return err + } + return ejectInfraAfterInit(infraProvider) + } + return missingAgentServiceError(flags.manifestPointer) + } } // Resolve the agent name BEFORE creating the project folder diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index 858ba630703..24eddacfc7c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -25,6 +25,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/foundry" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/output" @@ -34,46 +35,73 @@ import ( "gopkg.in/yaml.v3" ) -// foundryServiceHosts are the azure.yaml service `host` values that identify a -// unified Microsoft Foundry project manifest. The legacy `microsoft.foundry` -// host is included for backward compatibility with older non-split files. -var foundryServiceHosts = map[string]struct{}{ - "azure.ai.agent": {}, - "azure.ai.project": {}, - "azure.ai.connection": {}, - "azure.ai.toolbox": {}, - "microsoft.foundry": {}, +type azureYamlManifestInfo struct { + hasServices bool + hasAgentService bool + hasUnresolvedRefs bool } -// looksLikeFoundryAzureYaml reports whether the given YAML content is a unified -// Foundry `azure.yaml` project manifest rather than an agent manifest. +// inspectAzureYaml identifies unified manifests and Agent services. // -// It returns true when the document has a top-level `services:` map in which at -// least one service declares a Foundry `host:`. Agent manifests have a top-level -// `template:` and no `services:`, so they never match. This lets `azd ai agent -// init -m ` route a unified `azure.yaml` to the adoption path and an -// agent manifest to the legacy generate path unambiguously. -func looksLikeFoundryAzureYaml(content []byte) bool { +// Local references are resolved against projectRoot. Remote references +// wait for the sample directory download. +func inspectAzureYaml(content []byte, projectRoot string) (azureYamlManifestInfo, error) { + var info azureYamlManifestInfo var top map[string]any if err := yaml.Unmarshal(content, &top); err != nil { - return false + return info, nil } services, ok := top["services"].(map[string]any) if !ok { - return false + return info, nil } + info.hasServices = true - for _, svc := range services { + for serviceName, svc := range services { svcMap, ok := svc.(map[string]any) if !ok { continue } - host, ok := svcMap["host"].(string) - if !ok { - continue + + if hasAzureYamlFileRef(svcMap) { + if projectRoot == "" { + info.hasUnresolvedRefs = true + } else { + resolved, err := foundry.ResolveFileRefs(svcMap, projectRoot) + if err != nil { + return info, fmt.Errorf( + "resolving $ref includes for service %q: %w", + serviceName, + err, + ) + } + svcMap = resolved + } } - if _, isFoundry := foundryServiceHosts[host]; isFoundry { + + host, _ := svcMap["host"].(string) + if host == AiAgentHost { + info.hasAgentService = true + } + } + + return info, nil +} + +func hasAzureYamlFileRef(value any) bool { + switch typed := value.(type) { + case map[string]any: + if _, ok := typed["$ref"]; ok { + return true + } + for _, child := range typed { + if hasAzureYamlFileRef(child) { + return true + } + } + case []any: + if slices.ContainsFunc(typed, hasAzureYamlFileRef) { return true } } @@ -81,6 +109,39 @@ func looksLikeFoundryAzureYaml(content []byte) bool { return false } +func missingAgentServiceError(manifestPointer string) error { + return exterrors.Validation( + exterrors.CodeInvalidManifestPointer, + fmt.Sprintf( + "manifest %q is a unified azure.yaml but does not declare an agent service", + manifestPointer, + ), + fmt.Sprintf( + "add a service with host: %s, or pass an agent manifest", + AiAgentHost, + ), + ) +} + +func validateStagedAzureYaml(stagingDir, manifestPointer string) error { + manifestPath := filepath.Join(stagingDir, "azure.yaml") + //nolint:gosec // stagingDir is created or selected by the init flow + content, err := os.ReadFile(manifestPath) + if err != nil { + return fmt.Errorf("reading staged azure.yaml: %w", err) + } + + info, err := inspectAzureYaml(content, stagingDir) + if err != nil { + return err + } + if !info.hasServices || !info.hasAgentService { + return missingAgentServiceError(manifestPointer) + } + + return nil +} + // foundryProjectName returns the top-level `name:` of a unified azure.yaml, used // to derive the project folder name. Returns "" when the name is absent or the // content cannot be parsed. @@ -857,6 +918,10 @@ func runInitFromAzureYaml( } defer cleanup() + if err := validateStagedAzureYaml(stagingDir, flags.manifestPointer); err != nil { + return err + } + fmt.Println(output.WithGrayFormat("Adopting the sample's azure.yaml as your project manifest...")) envName := deriveEnvName(flags, targetDir) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go index aa697dbd69c..54f659132ff 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "testing" + "azureaiagent/internal/exterrors" "azureaiagent/internal/project" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -18,11 +19,12 @@ import ( "google.golang.org/protobuf/types/known/structpb" ) -func TestLooksLikeFoundryAzureYaml(t *testing.T) { +func TestInspectAzureYaml(t *testing.T) { tests := []struct { - name string - content string - want bool + name string + content string + wantServices bool + wantAgentService bool }{ { name: "unified azure.yaml with split foundry hosts", @@ -34,16 +36,34 @@ services: host: azure.ai.agent kind: hosted `, - want: true, + wantServices: true, + wantAgentService: true, }, { - name: "legacy microsoft.foundry host", + name: "unsupported microsoft.foundry host", content: `name: foundry-legacy services: agents: host: microsoft.foundry `, - want: true, + wantServices: true, + }, + { + name: "unified azure.yaml with only sibling Foundry hosts", + content: `name: foundry-resources +services: + ai-project: + host: azure.ai.project + search-connection: + host: azure.ai.connection + toolbox: + host: azure.ai.toolbox + summarize: + host: azure.ai.skill + daily-report: + host: azure.ai.routine +`, + wantServices: true, }, { name: "agent manifest with top-level template", @@ -54,7 +74,7 @@ template: parameters: {} resources: [] `, - want: false, + wantServices: false, }, { name: "azure.yaml with only non-foundry services", @@ -64,24 +84,21 @@ services: host: containerapp language: js `, - want: false, + wantServices: true, }, { name: "empty content", content: "", - want: false, }, { name: "malformed yaml", content: "name: [unterminated", - want: false, }, { name: "services present but not a map", content: `name: broken services: just-a-string `, - want: false, }, { name: "service without host", @@ -90,17 +107,136 @@ services: ai-project: deployments: [] `, - want: false, + wantServices: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - require.Equal(t, tt.want, looksLikeFoundryAzureYaml([]byte(tt.content))) + info, err := inspectAzureYaml([]byte(tt.content), "") + require.NoError(t, err) + require.Equal(t, tt.wantServices, info.hasServices) + require.Equal(t, tt.wantAgentService, info.hasAgentService) }) } } +func TestDeclaresAgentService_LocalServiceRef(t *testing.T) { + root := t.TempDir() + refPath := filepath.Join(root, "services", "agent.yaml") + require.NoError(t, os.MkdirAll(filepath.Dir(refPath), 0700)) + require.NoError(t, os.WriteFile(refPath, []byte("host: azure.ai.agent\nkind: hosted\n"), 0600)) + + content := []byte(`name: foundry-ref +services: + ai-project: + host: azure.ai.project + assistant: + $ref: ./services/agent.yaml +`) + + info, err := inspectAzureYaml(content, root) + require.NoError(t, err) + require.True(t, info.hasServices) + require.True(t, info.hasAgentService) + require.False(t, info.hasUnresolvedRefs) +} + +func TestInspectAzureYaml_LocalServiceRefValidation(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "services"), 0700)) + require.NoError(t, os.WriteFile( + filepath.Join(root, "services", "project.yaml"), + []byte("host: azure.ai.project\n"), + 0600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(root, "services", "agent.yaml"), + []byte("host: azure.ai.agent\n"), + 0600, + )) + + t.Run("non-Agent ref", func(t *testing.T) { + info, err := inspectAzureYaml([]byte(`services: + project: + $ref: ./services/project.yaml +`), root) + require.NoError(t, err) + require.True(t, info.hasServices) + require.False(t, info.hasAgentService) + }) + + t.Run("inline host overrides referenced host", func(t *testing.T) { + info, err := inspectAzureYaml([]byte(`services: + project: + $ref: ./services/agent.yaml + host: azure.ai.project +`), root) + require.NoError(t, err) + require.True(t, info.hasServices) + require.False(t, info.hasAgentService) + }) + + t.Run("missing ref is returned", func(t *testing.T) { + _, err := inspectAzureYaml([]byte(`services: + agent: + $ref: ./services/missing.yaml +`), root) + require.ErrorContains(t, err, "cannot read") + }) + + t.Run("remote ref is returned", func(t *testing.T) { + _, err := inspectAzureYaml([]byte(`services: + agent: + $ref: https://example.com/agent.yaml +`), root) + require.ErrorContains(t, err, "remote includes are not supported") + }) +} + +func TestInspectAzureYaml_RemoteServiceRefIsDeferred(t *testing.T) { + info, err := inspectAzureYaml([]byte(`services: + agent: + $ref: ./services/agent.yaml +`), "") + require.NoError(t, err) + require.True(t, info.hasServices) + require.False(t, info.hasAgentService) + require.True(t, info.hasUnresolvedRefs) +} + +func TestValidateStagedAzureYamlRequiresAgentService(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "azure.yaml"), + []byte("services:\n project:\n host: azure.ai.project\n"), + 0600, + )) + + err := validateStagedAzureYaml(root, filepath.Join(root, "azure.yaml")) + require.Error(t, err) + var localErr *azdext.LocalError + require.ErrorAs(t, err, &localErr) + require.Equal(t, exterrors.CodeInvalidManifestPointer, localErr.Code) + require.Contains(t, localErr.Message, "does not declare an agent service") +} + +func TestValidateStagedAzureYamlReturnsRefErrors(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "azure.yaml"), + []byte(`services: + agent: + $ref: ./services/missing.yaml +`), + 0600, + )) + + err := validateStagedAzureYaml(root, filepath.Join(root, "azure.yaml")) + require.ErrorContains(t, err, "cannot read") + require.ErrorContains(t, err, "missing.yaml") +} + func TestFoundryProjectName(t *testing.T) { tests := []struct { name string diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go index 54b87aeb4c5..de72c8cecab 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go @@ -266,8 +266,8 @@ func foundryAzureYamlServiceHost(service *yaml.Node) (string, bool) { return "", false } - _, knownHost := foundryServiceHosts[host.Value] - if !knownHost && !strings.HasPrefix(host.Value, "azure.ai.") { + if host.Value != "microsoft.foundry" && + !strings.HasPrefix(host.Value, "azure.ai.") { return "", false } return host.Value, true diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go index 41018fcb2df..53d25c3160e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go @@ -237,6 +237,20 @@ services: `, want: nil, }, + { + name: "deprecated Foundry host refs are scanned", + content: `name: sample +services: + project: + host: microsoft.foundry + network: + agentSubnet: + vnet: ${FOUNDRY_HOST_NETWORK} +`, + want: []azureYamlEnvironmentReference{ + {Name: "FOUNDRY_HOST_NETWORK"}, + }, + }, { name: "deprecated toolbox and routine config fields are scanned", content: `name: sample