diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/delegated_projects.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/delegated_projects.go new file mode 100644 index 00000000000..39263bf3656 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/delegated_projects.go @@ -0,0 +1,634 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/project" + "azureaiagent/internal/version" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gopkg.in/yaml.v3" +) + +const delegatedProjectsSchemaVersion = 1 + +const ( + delegatedProjectsSource = "azure.ai.agents/init" + delegatedProjectsInit = "ai project init" + delegatedProjectsAdd = "ai project deployment add" +) + +func delegatedModelName(raw string) string { + raw = strings.TrimSpace(raw) + if slash := strings.IndexByte(raw, '/'); slash >= 0 && + slash < len(raw)-1 { + return raw[slash+1:] + } + return raw +} + +type delegatedProjectTarget struct { + ResourceID string `json:"resourceId,omitempty"` + Endpoint string `json:"endpoint,omitempty"` +} + +type delegatedProjectInfra struct { + EjectProvider string `json:"ejectProvider,omitempty"` +} + +type delegatedProjectRequirements struct { + AllowedLocations []string `json:"allowedLocations,omitempty"` +} + +type delegatedProjectInitRequest struct { + SchemaVersion int `json:"schemaVersion"` + Source string `json:"source"` + SourceVersion string `json:"sourceVersion"` + Project delegatedProjectTarget `json:"project"` + Infra delegatedProjectInfra `json:"infra,omitempty"` + Requirements delegatedProjectRequirements `json:"requirements,omitempty"` + ResolveAzureContext bool `json:"resolveAzureContext"` + Force bool `json:"force"` +} + +type delegatedProjectModel struct { + Name string `json:"name"` + DeploymentName string `json:"deploymentName,omitempty"` + RequiredCapabilities []string `json:"requiredCapabilities,omitempty"` + AllowedLocations []string `json:"allowedLocations,omitempty"` + ExcludedModelNames []string `json:"excludedModelNames,omitempty"` +} + +type delegatedProjectDeploymentRequest struct { + SchemaVersion int `json:"schemaVersion"` + Source string `json:"source"` + SourceVersion string `json:"sourceVersion"` + Model delegatedProjectModel `json:"model"` + SetAsDefault bool `json:"setAsDefault"` + Force bool `json:"force"` +} + +type delegatedProjectState struct { + ServiceName string + Mode string + ResourceID string + Endpoint string + Deployments []project.Deployment +} + +var errDelegatedProjectsUnavailable = errors.New("azure.ai.projects delegated commands are unavailable") + +func validateDelegatedProjectInitRequest(request delegatedProjectInitRequest) error { + if request.SchemaVersion != delegatedProjectsSchemaVersion { + return fmt.Errorf("unsupported delegated project schema version %d", request.SchemaVersion) + } + if request.Source != delegatedProjectsSource || strings.TrimSpace(request.SourceVersion) == "" { + return fmt.Errorf("invalid delegated project source") + } + if request.Project.ResourceID != "" && request.Project.Endpoint != "" { + return fmt.Errorf("project.resourceId and project.endpoint are mutually exclusive") + } + if request.Infra.EjectProvider != "" && + request.Infra.EjectProvider != project.BicepProviderName && + request.Infra.EjectProvider != project.TerraformProviderName { + return fmt.Errorf("unsupported delegated infrastructure provider %q", request.Infra.EjectProvider) + } + if request.Requirements.AllowedLocations != nil && len(request.Requirements.AllowedLocations) == 0 { + return fmt.Errorf("requirements.allowedLocations must contain a location") + } + return nil +} + +func validateDelegatedProjectDeploymentRequest(request delegatedProjectDeploymentRequest) error { + if request.SchemaVersion != delegatedProjectsSchemaVersion { + return fmt.Errorf("unsupported delegated project schema version %d", request.SchemaVersion) + } + if request.Source != delegatedProjectsSource || strings.TrimSpace(request.SourceVersion) == "" { + return fmt.Errorf("invalid delegated project source") + } + if strings.TrimSpace(request.Model.Name) == "" { + return fmt.Errorf("model.name is required") + } + for _, capability := range request.Model.RequiredCapabilities { + if capability != agentsV2ModelCapability { + return fmt.Errorf("unknown required capability %q", capability) + } + } + return nil +} + +func (a *InitAction) delegatedProjectRoot() (string, error) { + root := "" + if a.projectConfig != nil { + root = a.projectConfig.Path + } + if root == "" { + var err error + root, err = os.Getwd() + if err != nil { + return "", fmt.Errorf("resolving project root: %w", err) + } + } + root, err := filepath.Abs(root) + if err != nil { + return "", fmt.Errorf("resolving project root: %w", err) + } + return filepath.Clean(root), nil +} + +func (a *InitAction) delegatedEnvironmentName() string { + if a.flags != nil && a.flags.env != "" { + return a.flags.env + } + if a.environment != nil { + return a.environment.Name + } + return "" +} + +func (a *InitAction) runDelegatedProjectStep( + ctx context.Context, + command []string, + request any, +) error { + root, err := a.delegatedProjectRoot() + if err != nil { + return err + } + tempDir, err := os.MkdirTemp("", "azd-agent-project-*") + if err != nil { + return exterrors.Dependency( + exterrors.CodeProjectInitFailed, + fmt.Sprintf("creating delegated project workspace: %s", err), + "check write permissions on the system temporary directory", + ) + } + defer func() { _ = os.RemoveAll(tempDir) }() + if err := os.Chmod(tempDir, 0700); err != nil { + return fmt.Errorf("protecting delegated project workspace: %w", err) + } + requestPath := filepath.Join(tempDir, "request.json") + if err := writeDelegatedJSON(requestPath, request); err != nil { + return err + } + + args := append([]string{}, command...) + args = append(args, + "--request-file="+requestPath, + "--output=none", + "--cwd="+root, + ) + if environment := a.delegatedEnvironmentName(); environment != "" { + args = append(args, "--environment="+environment) + } + + workflow := &azdext.Workflow{ + Name: "agent-project-delegation", + Steps: []*azdext.WorkflowStep{{ + Command: &azdext.WorkflowCommand{Args: args}, + }}, + } + if _, err := a.azdClient.Workflow().Run(ctx, &azdext.RunWorkflowRequest{Workflow: workflow}); err != nil { + // Older projects extensions do not know these commands. Stage A keeps + // the old path for that case only. + if isDelegatedProjectsUnavailable(err) { + return errDelegatedProjectsUnavailable + } + return err + } + return nil +} + +func isDelegatedProjectsUnavailable(err error) bool { + if err == nil { + return false + } + + if st, ok := status.FromError(err); ok && st.Code() == codes.Unimplemented { + return true + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "unknown command") || + strings.Contains(message, "command not found") || + strings.Contains(message, "not installed") || + strings.Contains(message, "unimplemented") +} + +func writeDelegatedJSON(path string, value any) error { + file, err := os.OpenFile( + path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, osutil.PermissionFileOwnerOnly, + ) + if err != nil { + return fmt.Errorf("creating delegated request file: %w", err) + } + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err := encoder.Encode(value); err != nil { + _ = file.Close() + _ = os.Remove(path) + return fmt.Errorf("writing delegated request file: %w", err) + } + if err := file.Sync(); err != nil { + _ = file.Close() + _ = os.Remove(path) + return fmt.Errorf("flushing delegated request file: %w", err) + } + if err := file.Close(); err != nil { + _ = os.Remove(path) + return fmt.Errorf("closing delegated request file: %w", err) + } + return nil +} + +func (s delegatedProjectState) deployment(name string) (*project.Deployment, error) { + for index := range s.Deployments { + if strings.EqualFold(s.Deployments[index].Name, name) { + return &s.Deployments[index], nil + } + } + return nil, exterrors.Compatibility( + exterrors.CodeIncompatibleAzdVersion, + fmt.Sprintf("project state is missing deployment %q", name), + "upgrade azure.ai.agents and azure.ai.projects to compatible versions", + ) +} + +func (a *InitAction) readDelegatedProjectState( + ctx context.Context, +) (delegatedProjectState, error) { + response, err := a.azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + if isDelegatedProjectsUnavailable(err) { + return delegatedProjectState{}, errDelegatedProjectsUnavailable + } + return delegatedProjectState{}, fmt.Errorf("read delegated project state: %w", err) + } + if response.GetProject() == nil { + return delegatedProjectState{}, exterrors.Dependency( + exterrors.CodeProjectNotFound, + "the azd project disappeared after delegated initialization", + "restore the project manifest and retry", + ) + } + + var services []*azdext.ServiceConfig + for _, service := range response.GetProject().GetServices() { + if service != nil && service.GetHost() == AiProjectHost { + services = append(services, service) + } + } + if len(services) == 0 { + return delegatedProjectState{}, exterrors.Validation( + "project_service_missing", + "delegated initialization completed without an azure.ai.project service", + "upgrade azure.ai.projects and retry", + ) + } + if len(services) > 1 { + return delegatedProjectState{}, exterrors.Validation( + "project_service_ambiguous", + "delegated initialization produced multiple azure.ai.project services", + "keep exactly one azure.ai.project service and retry", + ) + } + + properties := services[0].GetAdditionalProperties() + config := project.ServiceTargetAgentConfig{} + if properties != nil { + if err := project.UnmarshalStruct(properties, &config); err != nil { + return delegatedProjectState{}, fmt.Errorf( + "decode delegated project service state: %w", err, + ) + } + } + values, err := a.readDelegatedEnvironment(ctx) + if err != nil { + return delegatedProjectState{}, err + } + resourceID := strings.TrimSpace(values["AZURE_AI_PROJECT_ID"]) + endpoint := strings.TrimSpace(config.Endpoint) + if endpoint == "" { + endpoint = strings.TrimSpace(values["FOUNDRY_PROJECT_ENDPOINT"]) + } + mode := "new" + if resourceID != "" { + mode = "existing-id" + } else if endpoint != "" { + mode = "existing-endpoint" + } + return delegatedProjectState{ + ServiceName: services[0].GetName(), + Mode: mode, + ResourceID: resourceID, + Endpoint: endpoint, + Deployments: config.Deployments, + }, nil +} + +func (a *InitAction) readDelegatedEnvironment( + ctx context.Context, +) (map[string]string, error) { + envName := a.delegatedEnvironmentName() + if envName == "" { + current, err := a.azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil || current.GetEnvironment() == nil { + return nil, exterrors.Dependency( + exterrors.CodeEnvironmentNotFound, + "no environment is selected for delegated project state", + "select an azd environment and retry", + ) + } + envName = current.GetEnvironment().GetName() + } + response, err := a.azdClient.Environment().GetValues( + ctx, &azdext.GetEnvironmentRequest{Name: envName}, + ) + if err != nil { + return nil, fmt.Errorf("read delegated environment state: %w", err) + } + values := make(map[string]string, len(response.GetKeyValues())) + for _, pair := range response.GetKeyValues() { + if pair != nil { + values[pair.GetKey()] = pair.GetValue() + } + } + return values, nil +} + +func (a *InitAction) delegateProjectInit( + ctx context.Context, + allowedLocations []string, +) (delegatedProjectState, error) { + request := delegatedProjectInitRequest{ + SchemaVersion: delegatedProjectsSchemaVersion, + Source: delegatedProjectsSource, + SourceVersion: version.Version, + Project: delegatedProjectTarget{ResourceID: a.flags.projectResourceId}, + Infra: delegatedProjectInfra{EjectProvider: a.flags.infra}, + Requirements: delegatedProjectRequirements{AllowedLocations: allowedLocations}, + ResolveAzureContext: true, + Force: a.flags.force, + } + if err := validateDelegatedProjectInitRequest(request); err != nil { + return delegatedProjectState{}, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("invalid delegated project init request: %s", err), + "upgrade azure.ai.agents and azure.ai.projects to compatible versions", + ) + } + if err := a.runDelegatedProjectStep(ctx, strings.Fields(delegatedProjectsInit), request); err != nil { + return delegatedProjectState{}, err + } + state, err := a.readDelegatedProjectState(ctx) + if err != nil { + return delegatedProjectState{}, err + } + a.projectServiceName = state.ServiceName + if a.flags != nil { + a.flags.delegatedProjectInit = true + } + return state, nil +} + +func (a *InitAction) delegateProjectDeployment( + ctx context.Context, + model, deploymentName string, + setAsDefault bool, + allowedLocations []string, +) (delegatedProjectState, error) { + if strings.TrimSpace(deploymentName) == "" { + deploymentName = delegatedModelName(model) + } + request := delegatedProjectDeploymentRequest{ + SchemaVersion: delegatedProjectsSchemaVersion, + Source: delegatedProjectsSource, + SourceVersion: version.Version, + Model: delegatedProjectModel{ + Name: model, + DeploymentName: deploymentName, + RequiredCapabilities: []string{agentsV2ModelCapability}, + AllowedLocations: allowedLocations, + }, + SetAsDefault: setAsDefault, + Force: a.flags.force, + } + if err := validateDelegatedProjectDeploymentRequest(request); err != nil { + return delegatedProjectState{}, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("invalid delegated deployment request: %s", err), + "upgrade azure.ai.agents and azure.ai.projects to compatible versions", + ) + } + if err := a.runDelegatedProjectStep(ctx, strings.Fields(delegatedProjectsAdd), request); err != nil { + return delegatedProjectState{}, err + } + state, err := a.readDelegatedProjectState(ctx) + if err != nil { + return delegatedProjectState{}, err + } + if _, err := state.deployment(deploymentName); err != nil { + return delegatedProjectState{}, err + } + a.projectServiceName = state.ServiceName + return state, nil +} + +func (a *InitAction) hostedAgentAllowedLocations(ctx context.Context) ([]string, error) { + if !a.skipACR() { + return nil, nil + } + locations, err := supportedRegionsForInit(ctx) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + fmt.Fprintf(os.Stderr, "warning: failed to resolve hosted-agent regions: %v\n", err) + return nil, nil + } + return locations, nil +} + +func modelResourceFromManifest(resource any) (agent_yaml.ModelResource, bool) { + model, ok := resource.(agent_yaml.ModelResource) + return model, ok +} + +func projectInfoFromDelegatedState( + ctx context.Context, + azdClient *azdext.AzdClient, + envName string, + state delegatedProjectState, +) (*FoundryProjectInfo, error) { + if state.Mode != "existing-id" || state.ResourceID == "" { + return nil, nil + } + projectInfo, err := extractProjectDetails(state.ResourceID) + if err != nil { + return nil, err + } + projectInfo.Location, _ = getEnvValue(ctx, azdClient, envName, "AZURE_LOCATION") + if projectInfo.Location == "" { + projectInfo.Location, _ = getEnvValue(ctx, azdClient, envName, "AZURE_AI_DEPLOYMENTS_LOCATION") + } + return projectInfo, nil +} + +func (a *InitAction) configureDelegatedAgentResources( + ctx context.Context, + projectInfo *FoundryProjectInfo, + mode string, +) error { + if a.environment == nil { + return nil + } + if projectInfo == nil || mode == "new" { + return setACREnvVar(ctx, a.azdClient, a.environment.Name, a.skipACR()) + } + projectInfo.NetworkInjected = foundryAccountNetworkInjected(ctx, a.credential, projectInfo) + a.selectedFoundryProject = projectInfo + if err := configureExistingProjectAgentConnections( + ctx, a.azdClient, a.credential, a.environment.Name, + *projectInfo, projectInfo.SubscriptionId, a.skipACR(), + ); err != nil { + return err + } + return setACREnvVar(ctx, a.azdClient, a.environment.Name, a.skipACR()) +} + +func (a *InitAction) configureModelChoiceDelegated( + ctx context.Context, + agentManifest *agent_yaml.AgentManifest, +) (*agent_yaml.AgentManifest, error) { + allowedLocations, err := a.hostedAgentAllowedLocations(ctx) + if err != nil { + return nil, err + } + initState, err := a.delegateProjectInit(ctx, allowedLocations) + if err != nil { + return nil, err + } + projectInfo, err := projectInfoFromDelegatedState( + ctx, a.azdClient, a.delegatedEnvironmentName(), initState, + ) + if err != nil { + return nil, err + } + + templateBytes, err := yaml.Marshal(agentManifest.Template) + if err != nil { + return nil, fmt.Errorf("marshaling agent template: %w", err) + } + var definition agent_yaml.AgentDefinition + if err := yaml.Unmarshal(templateBytes, &definition); err != nil { + return nil, fmt.Errorf("reading agent definition: %w", err) + } + paramValues := agent_yaml.ParameterValues{} + var firstResolved *project.Deployment + anyModelProcessed := false + anyNewDeployment := false + managedModelIndex := 0 + + for _, rawResource := range agentManifest.Resources { + resource, ok := modelResourceFromManifest(rawResource) + if !ok || definition.Kind != agent_yaml.AgentKindHosted { + continue + } + var ( + modelDeployment *project.Deployment + isNew = a.flags.modelDeployment == "" + ) + if !isNew { + // External deployment references remain an agent operation and are + // verified against Azure before being injected into the manifest. + deployment, _, resolveErr := a.getModelDeploymentDetails( + ctx, agent_yaml.Model{Id: resource.Id}, + ) + if resolveErr != nil { + if errors.Is(resolveErr, errModelSkipped) { + continue + } + return nil, fmt.Errorf("failed to resolve model %q: %w", resource.Id, resolveErr) + } + modelDeployment = deployment + if modelDeployment == nil { + return nil, fmt.Errorf("model deployment %q was not resolved", a.flags.modelDeployment) + } + } else { + modelName := resource.Id + if managedModelIndex == 0 && strings.TrimSpace(a.flags.model) != "" { + modelName = a.flags.model + } + modelDeployment = &project.Deployment{ + Model: project.DeploymentModel{Name: modelName}, + } + managedModelIndex++ + } + anyModelProcessed = true + finalName := modelDeployment.Name + if isNew { + setAsDefault := firstResolved == nil + deploymentState, err := a.delegateProjectDeployment( + ctx, modelDeployment.Model.Name, "", + setAsDefault, allowedLocations, + ) + if err != nil { + return nil, err + } + defaultName := delegatedModelName(modelDeployment.Model.Name) + resolved, err := deploymentState.deployment(defaultName) + if err != nil { + return nil, err + } + finalName = resolved.Name + modelDeployment = resolved + anyNewDeployment = true + } + if firstResolved == nil { + firstResolved = modelDeployment + if !isNew { + if err := setEnvValue( + ctx, a.azdClient, a.environment.Name, + "AZURE_AI_MODEL_DEPLOYMENT_NAME", finalName, + ); err != nil { + return nil, err + } + } + } + paramValues[resource.Name] = finalName + } + + updated, err := agent_yaml.InjectParameterValuesIntoManifest(agentManifest, paramValues) + if err != nil { + return nil, fmt.Errorf("injecting deployment names into manifest: %w", err) + } + if err := a.configureDelegatedAgentResources( + ctx, projectInfo, initState.Mode, + ); err != nil { + return nil, err + } + a.deploymentDetails = nil + if anyModelProcessed { + if err := updatePendingModelDeploymentSignal( + ctx, a.azdClient, a.environment.Name, true, anyNewDeployment, + ); err != nil { + // The signal is advisory and has the same best-effort semantics as + // the legacy model path. + fmt.Fprintf(os.Stderr, "warning: failed to update model deployment signal: %v\n", err) + } + } + return updated, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/delegated_projects_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/delegated_projects_test.go new file mode 100644 index 00000000000..f893fdc4917 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/delegated_projects_test.go @@ -0,0 +1,367 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +type delegatedWorkflowRecorder struct { + azdext.UnimplementedWorkflowServiceServer + azdext.UnimplementedProjectServiceServer + commands [][]string + tempDirs []string + requests []map[string]any + projectMode string + deploymentCount int + service *azdext.ServiceConfig + env *testEnvironmentServiceServer + runErr error +} + +func (s *delegatedWorkflowRecorder) Run( + _ context.Context, + request *azdext.RunWorkflowRequest, +) (*azdext.EmptyResponse, error) { + args := request.GetWorkflow().GetSteps()[0].GetCommand().GetArgs() + s.commands = append(s.commands, args) + + var requestPath string + for _, arg := range args { + switch { + case strings.HasPrefix(arg, "--request-file="): + requestPath = strings.TrimPrefix(arg, "--request-file=") + } + } + s.tempDirs = append(s.tempDirs, filepath.Dir(requestPath)) + if s.runErr != nil { + return nil, s.runErr + } + data, err := os.ReadFile(requestPath) + if err != nil { + return nil, err + } + var envelope map[string]any + if err := json.Unmarshal(data, &envelope); err != nil { + return nil, err + } + s.requests = append(s.requests, envelope) + + if strings.Contains(strings.Join(args, " "), "deployment") { + s.deploymentCount++ + var requestBody delegatedProjectDeploymentRequest + if err := json.Unmarshal(data, &requestBody); err != nil { + return nil, err + } + name := requestBody.Model.DeploymentName + if name == "" { + name = delegatedModelName(requestBody.Model.Name) + } + config := project.ServiceTargetAgentConfig{} + if s.service != nil && s.service.GetAdditionalProperties() != nil { + if err := project.UnmarshalStruct( + s.service.GetAdditionalProperties(), &config, + ); err != nil { + return nil, err + } + } + config.Deployments = append(config.Deployments, project.Deployment{ + Name: name, + Model: project.DeploymentModel{ + Name: requestBody.Model.Name, + Format: "OpenAI", + Version: "2025-04-14", + }, + Sku: project.DeploymentSku{Name: "GlobalStandard", Capacity: 10}, + }) + properties, err := project.MarshalStruct(&config) + if err != nil { + return nil, err + } + s.service = &azdext.ServiceConfig{ + Name: "custom-project", + Host: AiProjectHost, + AdditionalProperties: properties, + } + } else { + mode := s.projectMode + if mode == "" { + mode = "existing-id" + } + config := project.ServiceTargetAgentConfig{} + if mode == "existing-endpoint" { + config.Endpoint = "https://account.services.ai.azure.com/api/projects/chat" + } + properties, err := project.MarshalStruct(&config) + if err != nil { + return nil, err + } + s.service = &azdext.ServiceConfig{ + Name: "custom-project", + Host: AiProjectHost, + AdditionalProperties: properties, + } + if s.env != nil { + if s.env.values == nil { + s.env.values = map[string]map[string]string{} + } + if s.env.values["dev"] == nil { + s.env.values["dev"] = map[string]string{} + } + if mode == "existing-id" { + s.env.values["dev"]["AZURE_AI_PROJECT_ID"] = + "/subscriptions/sub/resourceGroups/rg/providers/" + + "Microsoft.CognitiveServices/accounts/account/projects/chat" + } + } + } + return &azdext.EmptyResponse{}, nil +} + +func (s *delegatedWorkflowRecorder) Get( + _ context.Context, _ *azdext.EmptyRequest, +) (*azdext.GetProjectResponse, error) { + if s.service == nil { + return &azdext.GetProjectResponse{ + Project: &azdext.ProjectConfig{}, + }, nil + } + return &azdext.GetProjectResponse{ + Project: &azdext.ProjectConfig{ + Services: map[string]*azdext.ServiceConfig{ + s.service.GetName(): s.service, + }, + }, + }, nil +} + +func TestConfigureModelChoiceDelegated_MultipleModels(t *testing.T) { + envName := "dev" + envServer := &testEnvironmentServiceServer{ + values: map[string]map[string]string{envName: {}}, + } + recorder := &delegatedWorkflowRecorder{projectMode: "new", env: envServer} + client := newTestAzdClient(t, envServer, recorder) + root := t.TempDir() + action := &InitAction{ + azdClient: client, + projectConfig: &azdext.ProjectConfig{Path: root}, + environment: &azdext.Environment{Name: envName}, + flags: &initFlags{env: envName, noPrompt: true}, + } + manifest := &agent_yaml.AgentManifest{ + Name: "delegated-agent", + Template: agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Name: "delegated-agent", + Kind: agent_yaml.AgentKindHosted, + }, + }, + Resources: []any{ + agent_yaml.ModelResource{ + Resource: agent_yaml.Resource{Name: "chat", Kind: agent_yaml.ResourceKindModel}, + Id: "gpt-4.1", + }, + agent_yaml.ModelResource{ + Resource: agent_yaml.Resource{Name: "embed", Kind: agent_yaml.ResourceKindModel}, + Id: "text-embedding-3-large", + }, + }, + } + + updated, err := action.configureModelChoiceDelegated(t.Context(), manifest) + require.NoError(t, err) + require.Len(t, recorder.commands, 3) + require.Equal(t, 2, recorder.deploymentCount) + require.Equal(t, true, recorder.requests[1]["setAsDefault"]) + require.Equal(t, false, recorder.requests[2]["setAsDefault"]) + require.NotNil(t, updated) +} + +func TestDelegatedProjectWorkflowMappingAndCleanup(t *testing.T) { + envServer := &testEnvironmentServiceServer{ + values: map[string]map[string]string{"dev": {}}, + } + + recorder := &delegatedWorkflowRecorder{env: envServer} + client := newTestAzdClient( + t, + envServer, + recorder, + ) + root := t.TempDir() + action := &InitAction{ + azdClient: client, + projectConfig: &azdext.ProjectConfig{Path: root}, + environment: &azdext.Environment{Name: "dev"}, + flags: &initFlags{ + projectResourceId: "/subscriptions/sub/resourceGroups/rg/providers/" + + "Microsoft.CognitiveServices/accounts/account/projects/chat", + infra: "terraform", + force: true, + env: "dev", + }, + } + + initResult, err := action.delegateProjectInit(t.Context(), []string{"eastus2"}) + require.NoError(t, err) + require.Equal(t, "custom-project", initResult.ServiceName) + require.Equal(t, "custom-project", action.projectServiceName) + + deploymentResult, err := action.delegateProjectDeployment( + t.Context(), "gpt-4.1", "chat", true, []string{"eastus2"}, + ) + require.NoError(t, err) + require.Equal(t, "chat", deploymentResult.Deployments[0].Name) + require.Len(t, recorder.commands, 2) + + for _, args := range recorder.commands { + assertArgContains(t, args, "--output=none") + assertArgContains(t, args, "--cwd="+root) + assertArgContains(t, args, "--environment=dev") + assertArgPrefix(t, args, "--request-file=") + } + require.Equal(t, []string{"ai", "project", "init"}, recorder.commands[0][:3]) + require.Equal(t, []string{"ai", "project", "deployment", "add"}, recorder.commands[1][:4]) + require.Equal(t, float64(1), recorder.requests[0]["schemaVersion"]) + require.Equal(t, "terraform", recorder.requests[0]["infra"].(map[string]any)["ejectProvider"]) + require.Equal(t, true, recorder.requests[0]["force"]) + require.Equal(t, []any{"eastus2"}, + recorder.requests[0]["requirements"].(map[string]any)["allowedLocations"]) + require.Equal(t, "gpt-4.1", recorder.requests[1]["model"].(map[string]any)["name"]) + require.Equal(t, true, recorder.requests[1]["setAsDefault"]) + require.Equal(t, []any{"agentsV2"}, + recorder.requests[1]["model"].(map[string]any)["requiredCapabilities"]) + for _, dir := range recorder.tempDirs { + _, err := os.Stat(dir) + require.ErrorIs(t, err, os.ErrNotExist) + } +} + +func TestDelegatedProjectStateModes(t *testing.T) { + const resourceID = "/subscriptions/sub/resourceGroups/rg/providers/" + + "Microsoft.CognitiveServices/accounts/account/projects/chat" + for _, mode := range []string{"new", "existing-id", "existing-endpoint"} { + t.Run(mode, func(t *testing.T) { + envServer := &testEnvironmentServiceServer{ + values: map[string]map[string]string{"dev": {}}, + } + recorder := &delegatedWorkflowRecorder{ + projectMode: mode, + env: envServer, + } + client := newTestAzdClient(t, envServer, recorder) + action := &InitAction{ + azdClient: client, + environment: &azdext.Environment{Name: "dev"}, + flags: &initFlags{env: "dev"}, + } + state, err := action.delegateProjectInit(t.Context(), nil) + require.NoError(t, err) + require.Equal(t, mode, state.Mode) + if mode == "existing-id" { + require.Equal(t, resourceID, state.ResourceID) + } + if mode == "existing-endpoint" { + require.NotEmpty(t, state.Endpoint) + } + }) + } +} + +func TestDelegatedProjectWorkflowFailureAndCancellation(t *testing.T) { + t.Run("failure", func(t *testing.T) { + recorder := &delegatedWorkflowRecorder{runErr: errors.New("workflow failed")} + client := newTestAzdClient( + t, &testEnvironmentServiceServer{}, recorder, + ) + action := &InitAction{ + azdClient: client, + projectConfig: &azdext.ProjectConfig{Path: t.TempDir()}, + environment: &azdext.Environment{Name: "dev"}, + flags: &initFlags{env: "dev"}, + } + err := action.runDelegatedProjectStep( + t.Context(), strings.Fields(delegatedProjectsInit), + delegatedProjectInitRequest{SchemaVersion: 1}, + ) + require.ErrorContains(t, err, "workflow failed") + require.NotEmpty(t, recorder.tempDirs) + for _, dir := range recorder.tempDirs { + _, statErr := os.Stat(dir) + require.ErrorIs(t, statErr, os.ErrNotExist) + } + }) + + t.Run("cancellation", func(t *testing.T) { + recorder := &delegatedWorkflowRecorder{} + client := newTestAzdClient( + t, &testEnvironmentServiceServer{}, recorder, + ) + action := &InitAction{ + azdClient: client, + projectConfig: &azdext.ProjectConfig{Path: t.TempDir()}, + environment: &azdext.Environment{Name: "dev"}, + flags: &initFlags{env: "dev"}, + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + err := action.runDelegatedProjectStep( + ctx, strings.Fields(delegatedProjectsInit), + delegatedProjectInitRequest{SchemaVersion: 1}, + ) + require.Error(t, err) + require.Empty(t, recorder.tempDirs) + }) +} + +func assertArgContains(t *testing.T, args []string, want string) { + t.Helper() + require.Contains(t, args, want) +} + +func assertArgPrefix(t *testing.T, args []string, prefix string) { + t.Helper() + for _, arg := range args { + if strings.HasPrefix(arg, prefix) { + return + } + } + t.Fatalf("arguments %v do not contain prefix %q", args, prefix) +} + +func TestSetServiceUsesOrderedMerge(t *testing.T) { + t.Parallel() + + server := &recordingProjectServer{ + existing: map[string]*azdext.ServiceConfig{ + "agent": { + Name: "agent", + Uses: []string{"hand-authored", "project"}, + Host: AiAgentHost, + }, + }, + } + client := newProjectRecorderClient(t, server) + + require.NoError(t, setServiceUses( + t.Context(), client, "agent", []string{"project", "connection"}, + )) + + server.mu.Lock() + defer server.mu.Unlock() + require.Equal(t, []string{"hand-authored", "project", "connection"}, server.uses["agent"]) +} 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 48f5c842d2a..b1b25557e06 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -87,6 +87,10 @@ type initFlags struct { // and `--infra=bicep` are explicit. The eject runs after a fresh init or // standalone when azure.yaml already exists. infra string + + // delegatedProjectInit prevents the legacy post-init infrastructure writer + // from running after azure.ai.projects handled --infra. + delegatedProjectInit bool } // AiProjectResourceConfig represents the configuration for an AI project resource @@ -124,6 +128,10 @@ type InitAction struct { // interactively selects a template that resolves to a manifest. When true, // the init flow applies opinionated defaults to minimize interactive prompts. userProvidedManifest bool + + // projectServiceName is returned by delegated project initialization. It is + // intentionally not assumed to be "ai-project". + projectServiceName string } // skipACR returns true when ACR provisioning and configuration should be skipped. @@ -983,26 +991,36 @@ func runInitFromManifest( createdFolderDisplay string, userProvidedManifest bool, ) error { - // Ensure project and environment exist (no subscription/location prompting yet) - projectConfig, err := ensureProject(ctx, flags, azdClient, targetDir) - if err != nil { - return err + // Do not scaffold or mutate the project before the manifest is resolved. + // The delegated projects command owns project creation and environment + // reconciliation. A synthetic config is used until that command runs. + projectResponse, projectErr := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + projectConfig := projectResponse.GetProject() + if projectErr != nil || projectConfig == nil { + projectRoot, absErr := filepath.Abs(targetDir) + if absErr != nil { + return fmt.Errorf("resolving target project root: %w", absErr) + } + projectConfig = &azdext.ProjectConfig{Path: projectRoot} } - // Get or create environment + // Resolve the environment name without creating it. The delegated project + // action creates the environment when this is a new workspace. env := getExistingEnvironment(ctx, flags.env, azdClient) if env == nil { - fmt.Println("Lets create a new default azd environment for your project.") - env, err = createNewEnvironment(ctx, azdClient, flags.env) - if err != nil { - return err + if flags.env == "" { + flags.env = deriveEnvName(flags, targetDir) } + env = &azdext.Environment{Name: flags.env} } // Load whatever Azure context values already exist in the environment azureContext, err := loadAzureContext(ctx, azdClient, env.Name) if err != nil { - return err + azureContext = &azdext.AzureContext{ + Scope: &azdext.AzureScope{}, + Resources: []string{}, + } } // Create credential with whatever tenant is available (may be empty → default tenant) credential, err := azidentity.NewAzureDeveloperCLICredential( @@ -1184,10 +1202,12 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, if gateErr != nil { return gateErr } - if gate.standaloneEject { - // Reject init inputs the eject path would silently ignore - // instead of pretending they were honored. They stay valid - // on the init fall-through, where they do drive the flow. + if gate.standaloneEject && flags.manifestPointer == "" && + flags.src == "" && flags.agentName == "" && + flags.model == "" && flags.modelDeployment == "" && + flags.image == "" && flags.deployMode == "" && + flags.runtime == "" && flags.entryPoint == "" && + flags.depResolution == "" && len(flags.protocols) == 0 { if err := validateStandaloneEjectArgs(cmd, args); err != nil { return err } @@ -1356,7 +1376,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, ); err != nil { return err } - return ejectInfraAfterInit(infraProvider) + return finishInfraEject(flags, infraProvider) } } } @@ -1405,7 +1425,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, if err := runReuseDefinition(ctx, flags, azdClient, httpClient, checkDir, existing); err != nil { return err } - return ejectInfraAfterInit(infraProvider) + return finishInfraEject(flags, infraProvider) } } } @@ -1450,7 +1470,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } return err } - return ejectInfraAfterInit(infraProvider) + return finishInfraEject(flags, infraProvider) } return missingAgentServiceError(flags.manifestPointer) } @@ -1656,7 +1676,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, // wrote azure.yaml, chain the eject step. Skip silently when init // didn't produce a foundry-bearing azure.yaml (cancelled or // non-foundry flow) to avoid a confusing "nothing to eject" error. - return ejectInfraAfterInit(infraProvider) + return finishInfraEject(flags, infraProvider) }, } @@ -1728,11 +1748,17 @@ func (a *InitAction) Run(ctx context.Context) error { // If src path is absolute, convert it to relative path compared to the azd project path if a.flags.src != "" && filepath.IsAbs(a.flags.src) { projectResponse, err := a.azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) - if err != nil { + projectRoot := "" + if err == nil && projectResponse.GetProject() != nil { + projectRoot = projectResponse.GetProject().Path + } else if a.projectConfig != nil { + projectRoot = a.projectConfig.Path + } + if projectRoot == "" { return fmt.Errorf("failed to get project path: %w", err) } - relPath, err := filepath.Rel(projectResponse.Project.Path, a.flags.src) + relPath, err := filepath.Rel(projectRoot, a.flags.src) if err != nil { return fmt.Errorf("failed to convert src path to relative path: %w", err) } @@ -2184,12 +2210,61 @@ func manifestHasModelResources(manifest *agent_yaml.AgentManifest) bool { return false } -// configureModelChoice presents the "use existing / deploy new" model configuration choice -// and establishes the necessary Azure context (subscription, location, project) before -// ProcessModels is called. This defers subscription/location prompting until we know -// which path the user wants. +// configureModelChoice delegates project and managed deployment ownership to +// azure.ai.projects. The legacy implementation remains available only when the +// installed projects extension predates the delegated commands. func (a *InitAction) configureModelChoice( ctx context.Context, agentManifest *agent_yaml.AgentManifest, +) (*agent_yaml.AgentManifest, error) { + updated, err := a.configureModelChoiceDelegated(ctx, agentManifest) + if !errors.Is(err, errDelegatedProjectsUnavailable) { + return updated, err + } + if a.projectConfig != nil { + if err := a.ensureLegacyProjectContext(ctx); err != nil { + return nil, err + } + } + return a.configureModelChoiceLegacy(ctx, agentManifest) +} + +func (a *InitAction) ensureLegacyProjectContext(ctx context.Context) error { + if _, err := a.azdClient.Project().Get(ctx, &azdext.EmptyRequest{}); err != nil { + projectConfig, projectErr := ensureProject( + ctx, a.flags, a.azdClient, a.projectConfig.Path, + ) + if projectErr != nil { + return projectErr + } + a.projectConfig = projectConfig + } + if a.environment == nil || a.environment.Name == "" { + if a.flags.env == "" { + a.flags.env = deriveEnvName(a.flags, a.projectConfig.Path) + } + a.environment = getExistingEnvironment(ctx, a.flags.env, a.azdClient) + if a.environment == nil { + environment, err := createNewEnvironment(ctx, a.azdClient, a.flags.env) + if err != nil { + return err + } + a.environment = environment + } + } + if a.azureContext == nil { + azureContext, err := loadAzureContext(ctx, a.azdClient, a.environment.Name) + if err != nil { + return err + } + a.azureContext = azureContext + } + return nil +} + +// configureModelChoiceLegacy is the Stage A compatibility path for projects +// extension versions that do not expose delegated project commands. +func (a *InitAction) configureModelChoiceLegacy( + ctx context.Context, agentManifest *agent_yaml.AgentManifest, ) (*agent_yaml.AgentManifest, error) { // When no --project-id flag was given, check whether the azd environment already // has a Foundry project configured from a previous init. If so, reuse it so the @@ -3078,12 +3153,9 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa agentConfig.StartupCommand = startupCmd } - // Each Foundry resource is written as its own azure.yaml service entry, so - // the deployments, connections, and toolboxes move out of the agent config - // into sibling azure.ai.project/connection/toolbox services emitted below. - // The agent keeps its container, resources, tool connections, and startup - // command. The provisioning handlers re-source the moved data from the - // sibling services. + // Connections and toolboxes are agent-owned sibling services. Managed model + // declarations are owned by azure.ai.projects and are never copied into the + // agent service or authored here after delegated initialization. resourceDeployments := agentConfig.Deployments resourceConnections := agentConfig.Connections resourceToolboxes := agentConfig.Toolboxes @@ -3119,6 +3191,10 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa Image: preBuiltImage, AdditionalProperties: agentProps, } + preservedUses, err := getServiceUses(ctx, a.azdClient, a.serviceNameOverride) + if err != nil { + return err + } // For hosted agents, configure Docker or code deploy settings if agentDef.Kind == agent_yaml.AgentKindHosted { @@ -3151,17 +3227,30 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa ); err != nil { return err } + if len(preservedUses) > 0 { + if err := setServiceUses(ctx, a.azdClient, a.serviceNameOverride, preservedUses); err != nil { + return err + } + } - // Emit the sibling Foundry resource services (project + deployments, - // connections, toolboxes) and wire the agent's uses: to them. A selected - // existing project contributes its endpoint so provision reuses it. - if err := emitResourceServices( - ctx, a.azdClient, a.serviceNameOverride, - projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), - a.selectedFoundryProject.Endpoint(), - resourceDeployments, resourceConnections, resourceToolboxes, - ); err != nil { - return err + if a.projectServiceName != "" { + if err := emitAgentResourceServices( + ctx, a.azdClient, a.serviceNameOverride, a.projectServiceName, + resourceConnections, resourceToolboxes, + ); err != nil { + return err + } + } else { + // Stage A compatibility for an older projects extension. This branch + // retains the pre-delegation writer only when delegation was unavailable. + if err := emitResourceServices( + ctx, a.azdClient, a.serviceNameOverride, + projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), + a.selectedFoundryProject.Endpoint(), + resourceDeployments, resourceConnections, resourceToolboxes, + ); err != nil { + return err + } } printAgentAddedMessage(agentDef.Name) 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 24eddacfc7c..7ac42adc083 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 @@ -23,6 +23,7 @@ import ( "azureaiagent/internal/project" "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "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" @@ -965,50 +966,21 @@ func runInitFromAzureYaml( return err } - // skipACR is false only for a container deploy whose registry azd - // manages. Code deploy and --image (bring your own registry) both - // skip ACR. - skipACR := !usesContainer || flags.image != "" - - result, err := configureFoundryProject( - ctx, azdClient, azureContext, env.Name, - flags.projectResourceId, flags.noPrompt, - skipACR, + delegated, err := delegateAdoptedProject( + ctx, flags, azdClient, env, azureContext, ) if err != nil { - if exterrors.IsCancellation(err) { - return exterrors.Cancelled("initialization was cancelled") - } - return err - } - - // When an existing project was selected, stamp its endpoint onto the - // azure.ai.project service so the provisioning provider recognizes the - // brownfield signal and reuses the project instead of creating a new one. - if result.FoundryProject != nil { - if err := stampProjectEndpoint(ctx, azdClient, result.FoundryProject); err != nil { - return err - } - if err := confirmAdoptedAgentNameConflicts( - ctx, - azdClient, - env, - result.Credential, - flags.noPrompt, - ); err != nil { + if !errors.Is(err, errDelegatedProjectsUnavailable) { return err } - } - - // --- Model deployment verification --- - // Parse deployments from the azure.yaml and verify them against the - // selected Foundry project. If the user opts to use existing deployments - // or skip, we update the on-disk azure.yaml accordingly. - deploymentEntries := foundryDeployments(content) - if len(deploymentEntries) > 0 && result != nil && result.Credential != nil { - keptEntries, referencedDeployments, deploymentsModified, err := verifyAzureYamlDeployments( - ctx, azdClient, result.Credential, azureContext, env.Name, - deploymentEntries, flags.noPrompt, flags.modelDeployment, flags.model, + delegated = false + } + if !delegated { + skipACR := !usesContainer || flags.image != "" + result, err := configureFoundryProject( + ctx, azdClient, azureContext, env.Name, + flags.projectResourceId, flags.noPrompt, + skipACR, ) if err != nil { if exterrors.IsCancellation(err) { @@ -1017,33 +989,69 @@ func runInitFromAzureYaml( return err } - // Update the azure.yaml if deployments were modified. - if deploymentsModified { - // Group kept deployments by their originating service name. - byService := make(map[string][]project.Deployment) - for _, entry := range deploymentEntries { - // Initialize to empty — ensures services with all removed get an empty list. - if _, ok := byService[entry.ServiceName]; !ok { - byService[entry.ServiceName] = nil - } + // When an existing project was selected, stamp its endpoint onto the + // azure.ai.project service so the provisioning provider recognizes the + // brownfield signal and reuses the project instead of creating a new one. + if result.FoundryProject != nil { + if err := stampProjectEndpoint(ctx, azdClient, result.FoundryProject); err != nil { + return err } - for _, kept := range keptEntries { - byService[kept.ServiceName] = append(byService[kept.ServiceName], kept.Deployment) + if err := confirmAdoptedAgentNameConflicts( + ctx, + azdClient, + env, + result.Credential, + flags.noPrompt, + ); err != nil { + return err } + } - for svcName, deps := range byService { - if err := updateAzureYamlDeployments(ctx, azdClient, svcName, deps); err != nil { - return err + // --- Model deployment verification --- + // Parse deployments from the azure.yaml and verify them against the + // selected Foundry project. If the user opts to use existing deployments + // or skip, we update the on-disk azure.yaml accordingly. + deploymentEntries := foundryDeployments(content) + if len(deploymentEntries) > 0 && result != nil && result.Credential != nil { + keptEntries, referencedDeployments, deploymentsModified, err := verifyAzureYamlDeployments( + ctx, azdClient, result.Credential, azureContext, env.Name, + deploymentEntries, flags.noPrompt, flags.modelDeployment, flags.model, + ) + if err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("initialization was cancelled") } + return err } - } - // Persist the first referenced deployment name as AZURE_AI_MODEL_DEPLOYMENT_NAME. - setEnv := func(ctx context.Context, key, value string) error { - return setEnvValue(ctx, azdClient, env.Name, key, value) - } - if err := persistFirstDeploymentName(ctx, setEnv, referencedDeployments); err != nil { - return fmt.Errorf("failed to set AZURE_AI_MODEL_DEPLOYMENT_NAME: %w", err) + // Update the azure.yaml if deployments were modified. + if deploymentsModified { + // Group kept deployments by their originating service name. + byService := make(map[string][]project.Deployment) + for _, entry := range deploymentEntries { + // Initialize to empty — ensures services with all removed get an empty list. + if _, ok := byService[entry.ServiceName]; !ok { + byService[entry.ServiceName] = nil + } + } + for _, kept := range keptEntries { + byService[kept.ServiceName] = append(byService[kept.ServiceName], kept.Deployment) + } + + for svcName, deps := range byService { + if err := updateAzureYamlDeployments(ctx, azdClient, svcName, deps); err != nil { + return err + } + } + } + + // Persist the first referenced deployment name as AZURE_AI_MODEL_DEPLOYMENT_NAME. + setEnv := func(ctx context.Context, key, value string) error { + return setEnvValue(ctx, azdClient, env.Name, key, value) + } + if err := persistFirstDeploymentName(ctx, setEnv, referencedDeployments); err != nil { + return fmt.Errorf("failed to set AZURE_AI_MODEL_DEPLOYMENT_NAME: %w", err) + } } } @@ -1067,6 +1075,91 @@ func runInitFromAzureYaml( return nil } +func delegateAdoptedProject( + ctx context.Context, + flags *initFlags, + azdClient *azdext.AzdClient, + environment *azdext.Environment, + azureContext *azdext.AzureContext, +) (bool, error) { + projectResponse, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil || projectResponse.Project == nil { + return false, errDelegatedProjectsUnavailable + } + credential, err := azidentity.NewAzureDeveloperCLICredential( + &azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: azureContext.Scope.TenantId, + AdditionallyAllowedTenants: []string{"*"}, + }, + ) + if err != nil { + return false, exterrors.Auth( + exterrors.CodeCredentialCreationFailed, + fmt.Sprintf("failed to create Azure credential: %s", err), + "run 'azd auth login' to authenticate", + ) + } + action := &InitAction{ + azdClient: azdClient, + azureContext: azureContext, + credential: credential, + projectConfig: projectResponse.Project, + environment: environment, + flags: flags, + } + allowedLocations, err := action.hostedAgentAllowedLocations(ctx) + if err != nil { + return false, err + } + state, err := action.delegateProjectInit(ctx, allowedLocations) + if errors.Is(err, errDelegatedProjectsUnavailable) { + return false, nil + } + if err != nil { + return false, err + } + projectInfo, err := projectInfoFromDelegatedState( + ctx, azdClient, environment.Name, state, + ) + if err != nil { + return false, err + } + if err := action.configureDelegatedAgentResources( + ctx, projectInfo, state.Mode, + ); err != nil { + return false, err + } + if err := mergeProjectServiceUses(ctx, azdClient, state.ServiceName); err != nil { + return false, err + } + if err := confirmAdoptedAgentNameConflicts( + ctx, azdClient, environment, credential, flags.noPrompt, + ); err != nil { + return false, err + } + return true, nil +} + +func mergeProjectServiceUses( + ctx context.Context, + azdClient *azdext.AzdClient, + projectServiceName string, +) error { + response, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + return fmt.Errorf("discovering adopted agent services: %w", err) + } + for name, service := range response.GetProject().GetServices() { + if service.GetHost() != AiAgentHost { + continue + } + if err := setServiceUses(ctx, azdClient, name, []string{projectServiceName}); err != nil { + return err + } + } + return nil +} + // adoptTargetDir resolves the directory the adopted project is created in and // the display path for the "created folder" next-step hint. An explicit --src // (or positional directory) wins; otherwise a new folder named after the diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go index 32085a6668f..dc8d02f8a58 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go @@ -509,6 +509,56 @@ func configureExistingProjectAcr( return configureAcrConnection(ctx, azdClient, credential, envName, subscriptionId, acrConnections) } +// configureExistingProjectAgentConnections preserves the agent-owned +// connection selection that runs after project identity has been delegated. +// The projects extension owns only project identity and managed deployments; +// registry and Application Insights choices remain agent concerns. +func configureExistingProjectAgentConnections( + ctx context.Context, + azdClient *azdext.AzdClient, + credential azcore.TokenCredential, + envName string, + project FoundryProjectInfo, + subscriptionId string, + skipACR bool, +) error { + foundryClient, err := azure.NewFoundryProjectsClient(project.AccountName, project.ProjectName, credential) + if err != nil { + return fmt.Errorf("creating Foundry client: %w", err) + } + connections, err := foundryClient.GetAllConnections(ctx) + if err != nil { + fmt.Printf( + "Could not get Microsoft Foundry project connections: %v. "+ + "Please set agent connection environment variables manually.\n", err) + return nil + } + + var acrConnections []azure.Connection + var appInsightsConnections []azure.Connection + for _, connection := range connections { + switch connection.Type { + case azure.ConnectionTypeContainerRegistry: + if !skipACR { + acrConnections = append(acrConnections, connection) + } + case azure.ConnectionTypeAppInsights: + if full, getErr := foundryClient.GetConnectionWithCredentials(ctx, connection.Name); getErr == nil && full != nil { + connection = *full + } + appInsightsConnections = append(appInsightsConnections, connection) + } + } + if !skipACR { + if err := configureAcrConnection( + ctx, azdClient, credential, envName, subscriptionId, acrConnections, + ); err != nil { + return err + } + } + return configureAppInsightsConnection(ctx, azdClient, envName, appInsightsConnections) +} + // configureAcrConnection handles ACR connection selection and env var setting. func configureAcrConnection( ctx context.Context, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers_test.go index 124cbf71442..e76fd9d49ac 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers_test.go @@ -280,6 +280,9 @@ func newTestAzdClient( grpcServer := grpc.NewServer() azdext.RegisterEnvironmentServiceServer(grpcServer, envServer) azdext.RegisterWorkflowServiceServer(grpcServer, workflowServer) + if projectServer, ok := workflowServer.(azdext.ProjectServiceServer); ok { + azdext.RegisterProjectServiceServer(grpcServer, projectServer) + } if len(promptServers) > 0 { azdext.RegisterPromptServiceServer(grpcServer, promptServers[0]) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index de28fc442bc..acb0b1628a5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -9,6 +9,7 @@ import ( "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/project" "context" + "errors" "fmt" "log" "net/http" @@ -40,6 +41,8 @@ type InitFromCodeAction struct { // addToProject can disable remote build for VNET-injected accounts // without issuing a second account read. selectedFoundryProject *FoundryProjectInfo + + projectServiceName string } func (a *InitFromCodeAction) Run(ctx context.Context) error { @@ -125,6 +128,10 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error { } if localDefinition != nil { + if err := a.delegateProjectOwnership(ctx); err != nil && + !errors.Is(err, errDelegatedProjectsUnavailable) { + return err + } // Generate .agentignore. The agent definition is written into the // azure.yaml service entry below, not to an on-disk agent.yaml. @@ -163,6 +170,63 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error { return nil } +func (a *InitFromCodeAction) delegateProjectOwnership(ctx context.Context) error { + delegated := &InitAction{ + azdClient: a.azdClient, + credential: a.credential, + projectConfig: a.projectConfig, + azureContext: a.azureContext, + environment: a.environment, + flags: a.flags, + } + allowedLocations, err := delegated.hostedAgentAllowedLocations(ctx) + if err != nil { + return err + } + initState, err := delegated.delegateProjectInit(ctx, allowedLocations) + if err != nil { + return err + } + projectInfo, err := projectInfoFromDelegatedState( + ctx, a.azdClient, delegated.delegatedEnvironmentName(), initState, + ) + if err != nil { + return err + } + defaultName, _ := getEnvValue(ctx, a.azdClient, a.environment.Name, "AZURE_AI_MODEL_DEPLOYMENT_NAME") + firstManaged := strings.TrimSpace(defaultName) == "" + for _, deployment := range a.deploymentDetails { + deploymentState, err := delegated.delegateProjectDeployment( + ctx, deployment.Model.Name, deployment.Name, firstManaged, allowedLocations, + ) + if err != nil { + return err + } + if firstManaged { + defaultName = deployment.Name + if strings.TrimSpace(defaultName) == "" { + resolved, resolveErr := deploymentState.deployment( + delegatedModelName(deployment.Model.Name), + ) + if resolveErr != nil { + return resolveErr + } + defaultName = resolved.Name + } + firstManaged = false + } + } + if err := delegated.configureDelegatedAgentResources( + ctx, projectInfo, initState.Mode, + ); err != nil { + return err + } + a.deploymentDetails = nil + a.projectServiceName = delegated.projectServiceName + a.selectedFoundryProject = delegated.selectedFoundryProject + return nil +} + func (a *InitFromCodeAction) ensureProject(ctx context.Context) (*azdext.ProjectConfig, error) { projectResponse, err := a.azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) if err != nil { @@ -819,8 +883,9 @@ func (a *InitFromCodeAction) addToProject( agentConfig.StartupCommand = startupCmd } - // Move the model deployments out of the agent config into a sibling - // azure.ai.project service, emitted after the agent service below. + // Managed deployments are delegated to azure.ai.projects before this + // service is authored. The legacy compatibility path still carries the + // selected declarations through the old sibling writer. resourceDeployments := agentConfig.Deployments agentConfig.Deployments = nil @@ -850,6 +915,10 @@ func (a *InitFromCodeAction) addToProject( Image: definition.Image, AdditionalProperties: agentProps, } + preservedUses, err := getServiceUses(ctx, a.azdClient, agentServiceName) + if err != nil { + return err + } // For hosted container-based agents, enable remote build by default. It is // silently disabled when the target Foundry account has VNET network injection @@ -878,17 +947,27 @@ func (a *InitFromCodeAction) addToProject( ); err != nil { return err } + if len(preservedUses) > 0 { + if err := setServiceUses(ctx, a.azdClient, agentServiceName, preservedUses); err != nil { + return err + } + } - // Emit the sibling azure.ai.project service carrying the model deployments - // and wire the agent's uses: to it. A selected existing project contributes - // its endpoint so provision reuses it instead of creating a new project. - if err := emitResourceServices( - ctx, a.azdClient, agentServiceName, - projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), - a.selectedFoundryProject.Endpoint(), - resourceDeployments, nil, nil, - ); err != nil { - return err + if a.projectServiceName != "" { + if err := emitAgentResourceServices( + ctx, a.azdClient, agentServiceName, a.projectServiceName, nil, nil, + ); err != nil { + return err + } + } else { + if err := emitResourceServices( + ctx, a.azdClient, agentServiceName, + projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), + a.selectedFoundryProject.Endpoint(), + resourceDeployments, nil, nil, + ); err != nil { + return err + } } printAgentAddedMessage(agentName) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go index 3143acda96d..d59bb47b1de 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go @@ -470,6 +470,13 @@ func ejectInfraAfterInit(provider string) error { return ejectInfra(projectRoot, provider) } +func finishInfraEject(flags *initFlags, provider string) error { + if flags != nil && flags.delegatedProjectInit { + return nil + } + return ejectInfraAfterInit(provider) +} + // ejectInfra synthesizes infrastructure templates from azure.yaml. A project // that already owns infrastructure is migrated to infra.layers and receives a // dedicated Foundry layer under infra/foundry; a Foundry-only project keeps the diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go index 83b5c8ae13b..97b8f8755a2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go @@ -37,11 +37,9 @@ const ( aiProjectServiceName = "ai-project" ) -// emitResourceServices writes the Foundry resource sibling services that the -// agent depends on (one azure.ai.project carrying the model deployments, one -// azure.ai.connection per connection, one azure.ai.toolbox per toolbox) and -// wires the agent service's uses: list to them for ordering. Each resource is -// its own azure.yaml service entry so a different extension can own each host. +// emitResourceServices is the Stage A compatibility writer used only when an +// older projects extension does not expose delegated commands. The delegated +// path uses emitAgentResourceServices and never authors the project service. // // projectEndpoint, when non-empty, is written as endpoint: on the project // service to mark an existing (brownfield) Foundry project so provision @@ -164,6 +162,76 @@ func emitResourceServices( return nil } +// emitAgentResourceServices writes only the agent-owned sibling resources. The +// project service and its managed deployments are created by azure.ai.projects; +// projectServiceName is returned by that extension and is treated as opaque. +func emitAgentResourceServices( + ctx context.Context, + azdClient *azdext.AzdClient, + agentServiceName string, + projectServiceName string, + connections []project.Connection, + toolboxes []project.Toolbox, +) error { + if projectServiceName == "" { + return fmt.Errorf("delegated project result did not include a service name") + } + siblingUses := []string{projectServiceName} + agentUses := []string{projectServiceName} + usedNames := map[string]string{ + agentServiceName: "agent service", + projectServiceName: "project service", + } + + for i := range connections { + connection := connections[i] + name := sanitizeServiceName(connection.Name) + if name == "" { + fmt.Fprintf(os.Stderr, + "warning: connection %q has no characters usable as an azure.yaml service key; "+ + "skipping it. Rename the connection so it is written to azure.yaml.\n", + connection.Name) + continue + } + if err := reserveServiceName(usedNames, name, fmt.Sprintf("connection %q", connection.Name)); err != nil { + return err + } + config, err := project.MarshalStruct(&connection) + if err != nil { + return fmt.Errorf("marshaling connection service %q config: %w", name, err) + } + if err := addResourceService(ctx, azdClient, name, AiConnectionHost, config, siblingUses); err != nil { + return err + } + agentUses = append(agentUses, name) + } + + for i := range toolboxes { + toolbox := toolboxes[i] + name := sanitizeServiceName(toolbox.Name) + if name == "" { + fmt.Fprintf(os.Stderr, + "warning: toolbox %q has no characters usable as an azure.yaml service key; "+ + "skipping it. Rename the toolbox so it is written to azure.yaml.\n", + toolbox.Name) + continue + } + if err := reserveServiceName(usedNames, name, fmt.Sprintf("toolbox %q", toolbox.Name)); err != nil { + return err + } + config, err := project.MarshalStruct(&toolbox) + if err != nil { + return fmt.Errorf("marshaling toolbox service %q config: %w", name, err) + } + if err := addResourceService(ctx, azdClient, name, AiToolboxHost, config, siblingUses); err != nil { + return err + } + agentUses = append(agentUses, name) + } + + return setServiceUses(ctx, azdClient, agentServiceName, agentUses) +} + // resolveProjectServiceKey picks the azure.yaml service key for the single // azure.ai.project service. Precedence: // @@ -426,8 +494,19 @@ func setServiceEnvironment( // core ServiceConfig field, so it is written via SetServiceConfigValue (a raw // map path) rather than AddService's inlined config map, which cannot carry it. func setServiceUses(ctx context.Context, azdClient *azdext.AzdClient, serviceName string, uses []string) error { - usesItems := make([]any, len(uses)) - for i, u := range uses { + existing, err := getServiceUses(ctx, azdClient, serviceName) + if err != nil { + return fmt.Errorf("reading uses for service %q: %w", serviceName, err) + } + merged := slices.Clone(existing) + for _, use := range uses { + if slices.Contains(merged, use) { + continue + } + merged = append(merged, use) + } + usesItems := make([]any, len(merged)) + for i, u := range merged { usesItems[i] = u } @@ -447,6 +526,25 @@ func setServiceUses(ctx context.Context, azdClient *azdext.AzdClient, serviceNam return nil } +func getServiceUses( + ctx context.Context, + azdClient *azdext.AzdClient, + serviceName string, +) ([]string, error) { + response, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + return nil, err + } + if response.GetProject() == nil { + return nil, nil + } + service, ok := response.GetProject().GetServices()[serviceName] + if !ok || service == nil { + return nil, nil + } + return slices.Clone(service.GetUses()), nil +} + // sanitizeServiceName converts a resource name into an azure.yaml service key by // trimming surrounding whitespace and removing interior spaces, matching how the // agent service name is derived from the agent name. Only spaces are stripped, so diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/delegated_contract.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/delegated_contract.go index 652d2bfb48f..b70db5b238f 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/delegated_contract.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/delegated_contract.go @@ -69,37 +69,6 @@ type projectDeploymentAddRequest struct { type deploymentAddRequest = projectDeploymentAddRequest -type projectInitOutput struct { - SchemaVersion int `json:"schemaVersion"` - ProducerVersion string `json:"producerVersion"` - ServiceName string `json:"serviceName"` - Mode string `json:"mode"` - Mutation string `json:"mutation"` - Endpoint string `json:"endpoint,omitempty"` - ResourceID string `json:"resourceId,omitempty"` -} - -type projectDeploymentAddOutput struct { - SchemaVersion int `json:"schemaVersion"` - ProducerVersion string `json:"producerVersion"` - ServiceName string `json:"serviceName"` - DeploymentName string `json:"deploymentName"` - Model deploymentOutputModel `json:"model"` - SKU deploymentOutputSKU `json:"sku"` - Mutation string `json:"mutation"` -} - -type deploymentOutputModel struct { - Format string `json:"format"` - Name string `json:"name"` - Version string `json:"version"` -} - -type deploymentOutputSKU struct { - Name string `json:"name"` - Capacity int `json:"capacity"` -} - func (r *projectInitRequest) validate() error { if r == nil { return contractValidationError("delegated project init request is empty") diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_add.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_add.go index bf86856c547..ec64e81a142 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_add.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_deployment_add.go @@ -5,9 +5,7 @@ package cmd import ( "context" - "encoding/json" "fmt" - "os" "strings" "azure.ai.projects/internal/exterrors" @@ -232,31 +230,12 @@ func (a *ProjectDeploymentAddAction) Run(ctx context.Context) error { return fmt.Errorf("set default model deployment: %w", err) } } - result := projectDeploymentAddOutput{ - SchemaVersion: delegatedSchemaVersion, - ProducerVersion: delegatedProducerVersion(), - ServiceName: service.Name, - DeploymentName: selected.Deployment.Name, - Model: deploymentOutputModel{ - Format: selected.Deployment.Model.Format, - Name: selected.Deployment.Model.Name, - Version: selected.Deployment.Model.Version, - }, - SKU: deploymentOutputSKU{ - Name: selected.Deployment.Sku.Name, - Capacity: selected.Deployment.Sku.Capacity, - }, - Mutation: string(mutation), - } if request != nil { return nil } if a.flags.output == "none" { return nil } - if a.flags.output == "json" { - return json.NewEncoder(os.Stdout).Encode(result) - } switch mutation { case deploymentUnchanged: fmt.Printf("Managed deployment %q is unchanged.\n", selected.Deployment.Name) diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_init.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_init.go index 9d07700d7ab..eb514924d6d 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_init.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_init.go @@ -236,24 +236,12 @@ func (a *ProjectInitAction) Run(ctx context.Context) error { } } - result := projectInitOutput{ - SchemaVersion: delegatedSchemaVersion, - ProducerVersion: delegatedProducerVersion(), - ServiceName: serviceName, - Mode: string(target.Mode), - Mutation: mutation, - Endpoint: target.Endpoint, - ResourceID: target.ResourceId, - } if request != nil { return nil } if a.flags.output == "none" { return nil } - if a.flags.output == "json" { - return json.NewEncoder(os.Stdout).Encode(result) - } if mutation == "unchanged" { fmt.Printf("Foundry project configuration unchanged (%s).\n", serviceName) } else {