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
42 changes: 32 additions & 10 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
huimiu marked this conversation as resolved.
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
Expand Down Expand Up @@ -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
Expand Down
113 changes: 89 additions & 24 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -34,53 +35,113 @@ 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 <pointer>` 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
}
}

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.
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading