From fddc3c42a3256f0bbf10a0cd034b4a0bb2cef66d Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Mon, 11 May 2026 16:17:39 +0800 Subject: [PATCH 01/18] feat(agents): add code deploy (ZIP upload) support for hosted agents Implements code-based deployment as a complementary mode to container deploy. Agents with code_configuration in agent.yaml are deployed via multipart ZIP upload instead of Docker/ACR, eliminating permission complexity. --- .../internal/exterrors/codes.go | 1 + .../internal/pkg/agents/agent_api/models.go | 18 + .../pkg/agents/agent_api/operations.go | 108 +++++ .../internal/pkg/agents/agent_yaml/map.go | 36 +- .../internal/pkg/agents/agent_yaml/yaml.go | 10 + .../internal/project/service_target_agent.go | 385 +++++++++++++++++- 6 files changed, 553 insertions(+), 5 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index 3a1714c6929..02c8dcf2c1a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -136,6 +136,7 @@ const ( CodeCognitiveServicesClientFailed = "cognitiveservices_client_failed" CodeContainerStartFailed = "container_start_failed" CodeContainerStartTimeout = "container_start_timeout" + CodeAgentCreateFailed = "agent_create_failed" ) // Operation names for [ServiceFromAzure] errors. diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go index cb3bbe3e4fe..f3814138dfc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go @@ -121,6 +121,24 @@ type ImageBasedHostedAgentDefinition struct { Image string `json:"image"` } +// CodeConfigurationAPI represents the code_configuration block in the API request +type CodeConfigurationAPI struct { + Runtime string `json:"runtime"` + EntryPoint []string `json:"entry_point"` + DependencyResolution string `json:"dependency_resolution,omitempty"` +} + +// CodeBasedHostedAgentDefinition represents a code-deploy hosted agent. +// Uses protocol_versions (not container_protocol_versions). +type CodeBasedHostedAgentDefinition struct { + AgentDefinition + ProtocolVersions []ProtocolVersionRecord `json:"protocol_versions"` + CPU string `json:"cpu"` + Memory string `json:"memory"` + EnvironmentVariables map[string]string `json:"environment_variables,omitempty"` + CodeConfiguration CodeConfigurationAPI `json:"code_configuration"` +} + // CreateAgentVersionRequest represents a request to create an agent version type CreateAgentVersionRequest struct { Description *string `json:"description,omitempty"` diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go index ea474f0e215..2eb19649a6a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go @@ -356,6 +356,114 @@ func (c *AgentClient) CreateAgentVersion(ctx context.Context, agentName string, return &agentVersion, nil } +// CreateAgentFromZip creates a hosted agent via multipart/form-data ZIP code deploy. +// POST /agents?api-version=... +func (c *AgentClient) CreateAgentFromZip( + ctx context.Context, + agentName string, + metadata *CreateAgentVersionRequest, + zipData []byte, + sha256Hex string, + apiVersion string, +) (*AgentObject, error) { + reqURL := fmt.Sprintf("%s/agents?api-version=%s", c.endpoint, apiVersion) + // For create, include "name" in the metadata JSON (spec requirement) + createMeta := &CreateAgentRequest{ + Name: agentName, + CreateAgentVersionRequest: *metadata, + } + return c.zipDeployRequest(ctx, reqURL, agentName, createMeta, zipData, sha256Hex) +} + +// UpdateAgentFromZip updates an existing hosted agent via multipart/form-data ZIP code deploy. +// POST /agents/{name}?api-version=... +func (c *AgentClient) UpdateAgentFromZip( + ctx context.Context, + agentName string, + metadata *CreateAgentVersionRequest, + zipData []byte, + sha256Hex string, + apiVersion string, +) (*AgentObject, error) { + reqURL := fmt.Sprintf("%s/agents/%s?api-version=%s", c.endpoint, agentName, apiVersion) + return c.zipDeployRequest(ctx, reqURL, "", metadata, zipData, sha256Hex) +} + +// zipDeployRequest performs the multipart ZIP deploy request (shared by create and update). +func (c *AgentClient) zipDeployRequest( + ctx context.Context, + reqURL string, + agentName string, // if non-empty, sent as x-ms-agent-name header (create only) + metadata any, + zipData []byte, + sha256Hex string, +) (*AgentObject, error) { + // Build multipart body + var body bytes.Buffer + boundary := "----azd-code-deploy-boundary" + + // Part 1: metadata (JSON) + metadataJSON, err := json.Marshal(metadata) + if err != nil { + return nil, fmt.Errorf("failed to marshal metadata: %w", err) + } + + body.WriteString("--" + boundary + "\r\n") + body.WriteString("Content-Disposition: form-data; name=\"metadata\"\r\n") + body.WriteString("Content-Type: application/json\r\n\r\n") + body.Write(metadataJSON) + body.WriteString("\r\n") + + // Part 2: code (ZIP) + body.WriteString("--" + boundary + "\r\n") + body.WriteString("Content-Disposition: form-data; name=\"code\"; filename=\"agent.zip\"\r\n") + body.WriteString("Content-Type: application/zip\r\n\r\n") + body.Write(zipData) + body.WriteString("\r\n") + + // End boundary + body.WriteString("--" + boundary + "--\r\n") + + req, err := runtime.NewRequest(ctx, http.MethodPost, reqURL) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + contentType := "multipart/form-data; boundary=" + boundary + if err := req.SetBody(streaming.NopCloser(bytes.NewReader(body.Bytes())), contentType); err != nil { + return nil, fmt.Errorf("failed to set request body: %w", err) + } + + // Required headers + req.Raw().Header.Set("Foundry-Features", "CodeAgents=V1Preview,HostedAgents=V1Preview") + req.Raw().Header.Set("x-ms-code-zip-sha256", sha256Hex) + if agentName != "" { + req.Raw().Header.Set("x-ms-agent-name", agentName) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var agentObj AgentObject + if err := json.Unmarshal(respBody, &agentObj); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &agentObj, nil +} + // GetAgentVersion retrieves a specific version of an agent func (c *AgentClient) GetAgentVersion(ctx context.Context, agentName, agentVersion, apiVersion string) (*AgentVersionObject, error) { url := fmt.Sprintf("%s/agents/%s/versions/%s?api-version=%s", c.endpoint, agentName, agentVersion, apiVersion) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go index 79bfe232b47..0a750283ac3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go @@ -340,10 +340,6 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB } } - if imageURL == "" { - return nil, fmt.Errorf("image URL is required for hosted agents - use WithImageURL build option or specify in container.image") - } - // Map protocol versions from the hosted agent definition protocolVersions := make([]agent_api.ProtocolVersionRecord, 0) if len(hostedAgent.Protocols) > 0 { @@ -360,6 +356,38 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB } } + // Code deploy path: use CodeBasedHostedAgentDefinition + if hostedAgent.CodeConfiguration != nil { + entryPoint := []string{"python", hostedAgent.CodeConfiguration.EntryPoint} + depRes := "" + if hostedAgent.CodeConfiguration.DependencyResolution != nil { + depRes = *hostedAgent.CodeConfiguration.DependencyResolution + } + + codeDef := agent_api.CodeBasedHostedAgentDefinition{ + AgentDefinition: agent_api.AgentDefinition{ + Kind: agent_api.AgentKindHosted, + }, + ProtocolVersions: protocolVersions, + CPU: cpu, + Memory: memory, + EnvironmentVariables: envVars, + CodeConfiguration: agent_api.CodeConfigurationAPI{ + Runtime: hostedAgent.CodeConfiguration.Runtime, + EntryPoint: entryPoint, + DependencyResolution: depRes, + }, + } + + return createAgentAPIRequest(hostedAgent.AgentDefinition, codeDef, + hostedAgent.AgentEndpoint, hostedAgent.AgentCard) + } + + // Container/image deploy path (existing) + if imageURL == "" { + return nil, fmt.Errorf("image URL is required for hosted agents - use WithImageURL build option or specify in container.image") + } + hostedDef := agent_api.HostedAgentDefinition{ AgentDefinition: agent_api.AgentDefinition{ Kind: agent_api.AgentKindHosted, diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go index e4f5c4ac16e..8ac5ea6eaa9 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go @@ -169,6 +169,15 @@ type ContainerResources struct { Memory string `json:"memory" yaml:"memory"` } +// CodeConfiguration represents the code deploy configuration for a hosted agent. +// When present in a ContainerAgent, it signals code deploy mode (ZIP upload) +// instead of container/image-based deploy. +type CodeConfiguration struct { + Runtime string `json:"runtime" yaml:"runtime"` + EntryPoint string `json:"entryPoint" yaml:"entry_point"` + DependencyResolution *string `json:"dependencyResolution,omitempty" yaml:"dependency_resolution,omitempty"` +} + // ContainerAgent This represents a container based agent hosted by the provider/publisher. // The intent is to represent a container application that the user wants to run // in a hosted environment that the provider manages. @@ -179,6 +188,7 @@ type ContainerAgent struct { EnvironmentVariables *[]EnvironmentVariable `json:"environmentVariables,omitempty" yaml:"environment_variables,omitempty"` AgentEndpoint *AgentEndpoint `json:"agentEndpoint,omitempty" yaml:"agentEndpoint,omitempty"` AgentCard *AgentCard `json:"agentCard,omitempty" yaml:"agentCard,omitempty"` + CodeConfiguration *CodeConfiguration `json:"codeConfiguration,omitempty" yaml:"code_configuration,omitempty"` } // AgentManifest The following represents a manifest that can be used to create agents dynamically. diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 0c77ca6c8cc..3a059e7b21b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -4,12 +4,18 @@ package project import ( + "archive/zip" + "bytes" "context" + "crypto/sha256" "encoding/base64" + "encoding/hex" "fmt" + "io/fs" "os" "path/filepath" "strings" + "time" "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_api" @@ -355,6 +361,40 @@ func (p *AgentServiceTargetProvider) Package( serviceContext *azdext.ServiceContext, progress azdext.ProgressReporter, ) (*azdext.ServicePackageResult, error) { + // Code deploy: ZIP the source directory + if p.isCodeDeployAgent() { + progress("Packaging code") + zipData, sha256Hex, err := p.packageCodeDeploy(serviceConfig) + if err != nil { + return nil, exterrors.Internal(exterrors.OpContainerPackage, fmt.Sprintf("code packaging failed: %s", err)) + } + + // Store zip data and hash as artifacts via a temp file + tmpFile, err := os.CreateTemp("", "azd-code-deploy-*.zip") + if err != nil { + return nil, fmt.Errorf("failed to create temp file for ZIP: %w", err) + } + if _, err := tmpFile.Write(zipData); err != nil { + tmpFile.Close() + return nil, fmt.Errorf("failed to write ZIP to temp file: %w", err) + } + tmpFile.Close() + + return &azdext.ServicePackageResult{ + Artifacts: []*azdext.Artifact{ + { + Kind: azdext.ArtifactKind_ARTIFACT_KIND_ARCHIVE, + Location: tmpFile.Name(), + LocationKind: azdext.LocationKind_LOCATION_KIND_LOCAL, + Metadata: map[string]string{ + "type": "code-zip", + "sha256": sha256Hex, + }, + }, + }, + }, nil + } + if !p.isContainerAgent() { return &azdext.ServicePackageResult{}, nil } @@ -420,6 +460,11 @@ func (p *AgentServiceTargetProvider) Publish( publishOptions *azdext.PublishOptions, progress azdext.ProgressReporter, ) (*azdext.ServicePublishResult, error) { + // Code deploy skips Publish (no ACR needed) + if p.isCodeDeployAgent() { + return &azdext.ServicePublishResult{}, nil + } + if !p.isContainerAgent() { return &azdext.ServicePublishResult{}, nil } @@ -533,6 +578,10 @@ func (p *AgentServiceTargetProvider) Deploy( "fix the agent.yaml to match the hosted agent schema", ) } + // Branch: code deploy vs container deploy + if agentDef.CodeConfiguration != nil { + return p.deployHostedCodeAgent(ctx, serviceConfig, serviceContext, progress, agentDef, azdEnv) + } return p.deployHostedAgent(ctx, serviceConfig, serviceContext, progress, agentDef, azdEnv) default: return nil, exterrors.Validation( @@ -565,7 +614,41 @@ func (p *AgentServiceTargetProvider) isContainerAgent() bool { return false } - return kind == string(agent_yaml.AgentKindHosted) + if kind != string(agent_yaml.AgentKindHosted) { + return false + } + + // If code_configuration is present, this is a code deploy agent (not container) + if _, hasCodeConfig := genericTemplate["code_configuration"]; hasCodeConfig { + return false + } + + return true +} + +// isCodeDeployAgent returns true if the agent.yaml has code_configuration (code deploy mode) +func (p *AgentServiceTargetProvider) isCodeDeployAgent() bool { + data, err := os.ReadFile(p.agentDefinitionPath) + if err != nil { + return false + } + + var genericTemplate map[string]any + if err := yaml.Unmarshal(data, &genericTemplate); err != nil { + return false + } + + kind, ok := genericTemplate["kind"].(string) + if !ok { + return false + } + + if kind != string(agent_yaml.AgentKindHosted) { + return false + } + + _, hasCodeConfig := genericTemplate["code_configuration"] + return hasCodeConfig } // deployHostedAgent deploys a container-based hosted agent to the Foundry service. @@ -700,6 +783,306 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( }, nil } +// packageCodeDeploy creates a ZIP archive of the agent source code and computes its SHA-256. +func (p *AgentServiceTargetProvider) packageCodeDeploy(serviceConfig *azdext.ServiceConfig) ([]byte, string, error) { + // Source directory is the service's relative path + srcDir := filepath.Dir(p.agentDefinitionPath) + + // Exclusion patterns + excludeDirs := map[string]bool{ + "__pycache__": true, + ".venv": true, + "venv": true, + ".git": true, + "node_modules": true, + ".mypy_cache": true, + ".pytest_cache": true, + } + excludeExts := map[string]bool{ + ".pyc": true, + ".pyo": true, + } + + var buf bytes.Buffer + zipWriter := zip.NewWriter(&buf) + + err := filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Get relative path + relPath, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + + // Skip root + if relPath == "." { + return nil + } + + // Normalize to forward slashes for ZIP + relPath = filepath.ToSlash(relPath) + + // Check directory exclusions + if d.IsDir() { + if excludeDirs[d.Name()] { + return filepath.SkipDir + } + return nil + } + + // Check file extension exclusions + if excludeExts[filepath.Ext(path)] { + return nil + } + + // Skip agent.yaml itself from the ZIP (metadata is sent separately) + if d.Name() == "agent.yaml" { + return nil + } + + // Add file to ZIP + fileData, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read %s: %w", relPath, err) + } + + writer, err := zipWriter.Create(relPath) + if err != nil { + return fmt.Errorf("failed to create ZIP entry %s: %w", relPath, err) + } + + if _, err := writer.Write(fileData); err != nil { + return fmt.Errorf("failed to write ZIP entry %s: %w", relPath, err) + } + + return nil + }) + + if err != nil { + return nil, "", fmt.Errorf("failed to walk source directory: %w", err) + } + + if err := zipWriter.Close(); err != nil { + return nil, "", fmt.Errorf("failed to close ZIP: %w", err) + } + + zipData := buf.Bytes() + hash := sha256.Sum256(zipData) + sha256Hex := hex.EncodeToString(hash[:]) + + return zipData, sha256Hex, nil +} + +// deployHostedCodeAgent deploys a code-based hosted agent via multipart ZIP upload. +func (p *AgentServiceTargetProvider) deployHostedCodeAgent( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + progress azdext.ProgressReporter, + agentDef agent_yaml.ContainerAgent, + azdEnv map[string]string, +) (*azdext.ServiceDeployResult, error) { + if azdEnv["AZURE_AI_PROJECT_ENDPOINT"] == "" { + return nil, exterrors.Dependency( + exterrors.CodeMissingAiProjectEndpoint, + "AZURE_AI_PROJECT_ENDPOINT is required: environment variable was not found in the current azd environment", + "run 'azd provision' or connect to an existing project via 'azd ai agent init --project-id '", + ) + } + + progress("Deploying hosted agent (code deploy)") + + // Find the ZIP artifact from Package phase + var zipPath, sha256Hex string + for _, artifact := range serviceContext.Package { + if artifact.Metadata != nil && artifact.Metadata["type"] == "code-zip" { + zipPath = artifact.Location + sha256Hex = artifact.Metadata["sha256"] + break + } + } + if zipPath == "" { + return nil, exterrors.Dependency( + exterrors.CodeMissingPublishedContainer, + "code ZIP artifact not found: no code-zip artifact was found in service package artifacts", + "run 'azd deploy' to package and deploy the agent", + ) + } + + zipData, err := os.ReadFile(zipPath) + if err != nil { + return nil, fmt.Errorf("failed to read ZIP artifact: %w", err) + } + // Clean up temp file + defer os.Remove(zipPath) + + fmt.Fprintf(os.Stderr, "Loaded configuration from: %s\n", p.agentDefinitionPath) + fmt.Fprintf(os.Stderr, "Using endpoint: %s\n", azdEnv["AZURE_AI_PROJECT_ENDPOINT"]) + fmt.Fprintf(os.Stderr, "Agent Name: %s\n", agentDef.Name) + fmt.Fprintf(os.Stderr, "Runtime: %s\n", agentDef.CodeConfiguration.Runtime) + fmt.Fprintf(os.Stderr, "Entry Point: [\"python\", \"%s\"]\n", agentDef.CodeConfiguration.EntryPoint) + depRes := "bundled" + if agentDef.CodeConfiguration.DependencyResolution != nil { + depRes = *agentDef.CodeConfiguration.DependencyResolution + } + fmt.Fprintf(os.Stderr, "Packaging: %s\n", depRes) + + // Resolve environment variables + resolvedEnvVars := make(map[string]string) + if agentDef.EnvironmentVariables != nil { + for _, envVar := range *agentDef.EnvironmentVariables { + resolvedEnvVars[envVar.Name] = p.resolveEnvironmentVariables(envVar.Value, azdEnv) + } + } + + // Parse service config for cpu/memory + var foundryAgentConfig *ServiceTargetAgentConfig + if err := UnmarshalStruct(serviceConfig.Config, &foundryAgentConfig); err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("failed to parse foundry agent config: %s", err), + "check the service configuration in azure.yaml", + ) + } + + cpu := "1" + memory := "2Gi" + if foundryAgentConfig != nil && foundryAgentConfig.Container != nil && foundryAgentConfig.Container.Resources != nil { + if foundryAgentConfig.Container.Resources.Cpu != "" { + cpu = foundryAgentConfig.Container.Resources.Cpu + } + if foundryAgentConfig.Container.Resources.Memory != "" { + memory = foundryAgentConfig.Container.Resources.Memory + } + } + + // Build the API request definition + options := []agent_yaml.AgentBuildOption{ + agent_yaml.WithCPU(cpu), + agent_yaml.WithMemory(memory), + agent_yaml.WithEnvironmentVariables(resolvedEnvVars), + } + + request, err := agent_yaml.CreateAgentAPIRequestFromDefinition(agentDef, options...) + if err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentRequest, + fmt.Sprintf("failed to create agent request from definition: %s", err), + "verify the agent.yaml definition is correct", + ) + } + + // Set experience metadata + applyAgentMetadata(request) + + fmt.Fprintf(os.Stderr, "CPU: %s Memory: %s\n", cpu, memory) + + // Build the metadata for multipart upload + versionRequest := &agent_api.CreateAgentVersionRequest{ + Description: request.Description, + Metadata: request.Metadata, + Definition: request.Definition, + } + + // Create agent client + agentClient := agent_api.NewAgentClient( + azdEnv["AZURE_AI_PROJECT_ENDPOINT"], + p.credential, + ) + + // Check if agent already exists (GET /agents/{name}) + progress("Creating agent") + _, err = agentClient.GetAgent(ctx, agentDef.Name, agentAPIVersion) + var agentResp *agent_api.AgentObject + + if err != nil { + // Agent doesn't exist — create + fmt.Fprintf(os.Stderr, "Creating new agent: %s\n", agentDef.Name) + agentResp, err = agentClient.CreateAgentFromZip(ctx, agentDef.Name, versionRequest, zipData, sha256Hex, agentAPIVersion) + if err != nil { + return nil, exterrors.Internal( + exterrors.CodeAgentCreateFailed, + fmt.Sprintf("failed to create agent from ZIP: %s; check the agent definition and try again", err), + ) + } + } else { + // Agent exists — update + fmt.Fprintf(os.Stderr, "Updating existing agent: %s\n", agentDef.Name) + agentResp, err = agentClient.UpdateAgentFromZip(ctx, agentDef.Name, versionRequest, zipData, sha256Hex, agentAPIVersion) + if err != nil { + return nil, exterrors.Internal( + exterrors.CodeAgentCreateFailed, + fmt.Sprintf("failed to update agent from ZIP: %s; check the agent definition and try again", err), + ) + } + } + + // Poll for status if remote build + latestVersion := &agentResp.Versions.Latest + if depRes == "remote_build" && latestVersion.Status == "creating" { + fmt.Fprintf(os.Stderr, "Waiting for remote build to complete...\n") + pollTimeout := 5 * time.Minute + pollInterval := 5 * time.Second + deadline := time.Now().Add(pollTimeout) + + for time.Now().Before(deadline) { + time.Sleep(pollInterval) + versionResp, err := agentClient.GetAgentVersion(ctx, agentDef.Name, latestVersion.Version, agentAPIVersion) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: poll failed: %s\n", err) + continue + } + latestVersion = versionResp + if versionResp.Status == "active" { + fmt.Fprintf(os.Stderr, "Agent is active!\n") + break + } else if versionResp.Status == "failed" { + return nil, exterrors.Internal( + exterrors.CodeAgentCreateFailed, + "agent deployment failed during remote build; check agent logs or try local packaging (dependency_resolution: bundled)", + ) + } + fmt.Fprintf(os.Stderr, " Status: %s...\n", versionResp.Status) + } + + if latestVersion.Status != "active" { + return nil, exterrors.Internal( + exterrors.CodeAgentCreateFailed, + "agent deployment timed out waiting for remote build; check agent status manually or try local packaging", + ) + } + } + + // Register environment variables + progress("Registering agent environment variables") + protocols := agentDef.Protocols + if len(protocols) == 0 { + protocols = []agent_yaml.ProtocolVersionRecord{ + {Protocol: string(agent_api.AgentProtocolResponses), Version: "1.0.0"}, + } + } + + err = p.registerAgentEnvironmentVariables(ctx, azdEnv, serviceConfig, latestVersion, protocols) + if err != nil { + return nil, err + } + + artifacts := p.deployArtifacts( + latestVersion.Name, + latestVersion.Version, + azdEnv["AZURE_AI_PROJECT_ID"], + azdEnv["AZURE_AI_PROJECT_ENDPOINT"], + protocols, + ) + + return &azdext.ServiceDeployResult{ + Artifacts: artifacts, + }, nil +} + // deployArtifacts constructs the artifacts list for deployment results. // It produces one endpoint artifact per displayable protocol. func (p *AgentServiceTargetProvider) deployArtifacts( From 346b9722e4678ee9ad9fd087f07978cba95c33bf Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Mon, 11 May 2026 21:52:34 +0800 Subject: [PATCH 02/18] feat(agents): add code deploy mode to 'azd ai agent init --from-code' Add deploy mode prompt (code vs container) to the init flow. When code deploy is selected, prompts for runtime, entry_point, and dependency_resolution, then generates agent.yaml with code_configuration and azure.yaml with language: python (no Docker). --- .../internal/cmd/init_from_code.go | 173 ++++++++++++++++-- 1 file changed, 156 insertions(+), 17 deletions(-) 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 c6d14c9b4a5..ce45f1331f7 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 @@ -118,7 +118,8 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error { } // Add the agent to the azd project (azure.yaml) services - if err := a.addToProject(ctx, srcDir, localDefinition.Name); err != nil { + isCodeDeploy := localDefinition.CodeConfiguration != nil + if err := a.addToProject(ctx, srcDir, localDefinition.Name, isCodeDeploy); err != nil { return fmt.Errorf("failed to add agent to azure.yaml: %w", err) } @@ -468,6 +469,42 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) // TODO: Prompt user for agent kind agentKind := agent_yaml.AgentKindHosted + // Prompt user for deploy mode (container vs code) + deployModeChoices := []*azdext.SelectChoice{ + {Label: "Code (ZIP upload - no Docker required)", Value: "code"}, + {Label: "Container (Dockerfile + ACR)", Value: "container"}, + } + + var deployMode string + if a.flags.noPrompt { + deployMode = "code" // default to code deploy + } else { + defaultIdx := int32(0) + deployModeResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "How would you like to deploy your agent?", + Choices: deployModeChoices, + SelectedIndex: &defaultIdx, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("deploy mode selection was cancelled") + } + return nil, fmt.Errorf("failed to prompt for deploy mode: %w", err) + } + deployMode = deployModeChoices[*deployModeResp.Value].Value + } + + // If code deploy, prompt for code configuration details + var codeConfig *agent_yaml.CodeConfiguration + if deployMode == "code" { + codeConfig, err = a.promptCodeConfiguration(ctx) + if err != nil { + return nil, err + } + } + // Prompt user for supported protocols protocols, err := promptProtocols(ctx, a.azdClient.Prompt(), a.flags.noPrompt, a.flags.protocols) if err != nil { @@ -587,7 +624,8 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) Name: agentName, Kind: agentKind, }, - Protocols: protocols, + Protocols: protocols, + CodeConfiguration: codeConfig, } // Add model resource if a model was selected @@ -793,41 +831,53 @@ func (a *InitFromCodeAction) writeDefinitionToSrcDir(definition *agent_yaml.Cont return definitionPath, nil } -func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, agentName string) error { +func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, agentName string, isCodeDeploy bool) error { var agentConfig = project.ServiceTargetAgentConfig{} - agentConfig.Container = &project.ContainerSettings{ - Resources: &project.ResourceSettings{ - Memory: project.DefaultMemory, - Cpu: project.DefaultCpu, - }, + if !isCodeDeploy { + agentConfig.Container = &project.ContainerSettings{ + Resources: &project.ResourceSettings{ + Memory: project.DefaultMemory, + Cpu: project.DefaultCpu, + }, + } } agentConfig.Deployments = a.deploymentDetails - // Detect startup command from the project source directory - startupCmd, err := resolveStartupCommandForInit(ctx, a.azdClient, a.projectConfig.Path, targetDir, a.flags.noPrompt) - if err != nil { - return err + // Detect startup command from the project source directory (container mode only) + if !isCodeDeploy { + startupCmd, err := resolveStartupCommandForInit(ctx, a.azdClient, a.projectConfig.Path, targetDir, a.flags.noPrompt) + if err != nil { + return err + } + agentConfig.StartupCommand = startupCmd } - agentConfig.StartupCommand = startupCmd var agentConfigStruct *structpb.Struct + var err error if agentConfigStruct, err = project.MarshalStruct(&agentConfig); err != nil { return fmt.Errorf("failed to marshal agent config: %w", err) } + language := "python" + if !isCodeDeploy { + language = "docker" + } + serviceConfig := &azdext.ServiceConfig{ Name: strings.ReplaceAll(agentName, " ", ""), RelativePath: targetDir, Host: AiAgentHost, - Language: "docker", + Language: language, Config: agentConfigStruct, } - // For hosted (container-based) agents, set remoteBuild to true by default - serviceConfig.Docker = &azdext.DockerProjectOptions{ - RemoteBuild: true, + // For hosted container-based agents, set remoteBuild to true by default + if !isCodeDeploy { + serviceConfig.Docker = &azdext.DockerProjectOptions{ + RemoteBuild: true, + } } req := &azdext.AddServiceRequest{Service: serviceConfig} @@ -840,6 +890,95 @@ func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, return nil } +// promptCodeConfiguration prompts the user for code deploy configuration settings. +func (a *InitFromCodeAction) promptCodeConfiguration(ctx context.Context) (*agent_yaml.CodeConfiguration, error) { + // Prompt for runtime + runtimeChoices := []*azdext.SelectChoice{ + {Label: "Python 3.11", Value: "python_3_11"}, + {Label: "Python 3.10", Value: "python_3_10"}, + } + + var runtime string + if a.flags.noPrompt { + runtime = "python_3_11" + } else { + defaultIdx := int32(0) + runtimeResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select the runtime for your agent", + Choices: runtimeChoices, + SelectedIndex: &defaultIdx, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("runtime selection was cancelled") + } + return nil, fmt.Errorf("failed to prompt for runtime: %w", err) + } + runtime = runtimeChoices[*runtimeResp.Value].Value + } + + // Prompt for entry point + defaultEntryPoint := "main.py" + // Try to detect entry point from common patterns + if _, err := os.Stat("app.py"); err == nil { + defaultEntryPoint = "app.py" + } + + var entryPoint string + if a.flags.noPrompt { + entryPoint = defaultEntryPoint + } else { + entryPointResp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter the entry point file for your agent", + DefaultValue: defaultEntryPoint, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("entry point prompt was cancelled") + } + return nil, fmt.Errorf("failed to prompt for entry point: %w", err) + } + entryPoint = entryPointResp.Value + } + + // Prompt for dependency resolution + depResChoices := []*azdext.SelectChoice{ + {Label: "Remote build (server installs dependencies)", Value: "remote_build"}, + {Label: "Bundled (pre-install dependencies locally)", Value: "bundled"}, + } + + var depResolution string + if a.flags.noPrompt { + depResolution = "remote_build" + } else { + defaultIdx := int32(0) + depResResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "How should dependencies be resolved?", + Choices: depResChoices, + SelectedIndex: &defaultIdx, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("dependency resolution selection was cancelled") + } + return nil, fmt.Errorf("failed to prompt for dependency resolution: %w", err) + } + depResolution = depResChoices[*depResResp.Value].Value + } + + return &agent_yaml.CodeConfiguration{ + Runtime: runtime, + EntryPoint: entryPoint, + DependencyResolution: &depResolution, + }, nil +} + // protocolInfo pairs a protocol name with the default version used when generating agent.yaml. type protocolInfo struct { Name string From 5b610166d8cc67bafaecf6b6fbf980e4a83cf327 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Tue, 12 May 2026 12:05:37 +0800 Subject: [PATCH 03/18] fix(agents): address CI lint errors and align with spec #164 defaults - Fix gosec G304 warnings with nolint annotations for safe file reads - Fix gosec G104 by properly handling tmpFile.Close() errors - Fix gofmt formatting in excludeDirs map - Update runtime choices to python_3_12/3_11/3_13 per spec - Change --no-prompt deploy mode default to container (backward compat) - Change --no-prompt runtime default to python_3_12 - Align prompt labels with spec wording - Add container.resources + startupCommand to azure.yaml for code deploy --- .../internal/cmd/init_from_code.go | 38 ++++++++++++------- .../internal/project/service_target_agent.go | 22 ++++++----- 2 files changed, 37 insertions(+), 23 deletions(-) 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 ce45f1331f7..2bc6a4a4683 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 @@ -471,13 +471,13 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) // Prompt user for deploy mode (container vs code) deployModeChoices := []*azdext.SelectChoice{ - {Label: "Code (ZIP upload - no Docker required)", Value: "code"}, - {Label: "Container (Dockerfile + ACR)", Value: "container"}, + {Label: "Code-based (Python source, no Docker/ACR)", Value: "code"}, + {Label: "Container-based (Dockerfile + ACR)", Value: "container"}, } var deployMode string if a.flags.noPrompt { - deployMode = "code" // default to code deploy + deployMode = "container" // default to container for backward compatibility } else { defaultIdx := int32(0) deployModeResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ @@ -834,24 +834,35 @@ func (a *InitFromCodeAction) writeDefinitionToSrcDir(definition *agent_yaml.Cont func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, agentName string, isCodeDeploy bool) error { var agentConfig = project.ServiceTargetAgentConfig{} - if !isCodeDeploy { - agentConfig.Container = &project.ContainerSettings{ - Resources: &project.ResourceSettings{ - Memory: project.DefaultMemory, - Cpu: project.DefaultCpu, - }, - } + // Both code and container modes need container resources for local run + agentConfig.Container = &project.ContainerSettings{ + Resources: &project.ResourceSettings{ + Memory: project.DefaultMemory, + Cpu: project.DefaultCpu, + }, } agentConfig.Deployments = a.deploymentDetails - // Detect startup command from the project source directory (container mode only) + // Detect startup command from the project source directory (container mode only for prompt) if !isCodeDeploy { startupCmd, err := resolveStartupCommandForInit(ctx, a.azdClient, a.projectConfig.Path, targetDir, a.flags.noPrompt) if err != nil { return err } agentConfig.StartupCommand = startupCmd + } else { + // For code deploy, auto-derive startupCommand from entry point in agent.yaml + agentYamlPath := filepath.Join(a.projectConfig.Path, targetDir, "agent.yaml") + if data, err := os.ReadFile(agentYamlPath); err == nil { + var agentDef agent_yaml.ContainerAgent + if err := yaml.Unmarshal(data, &agentDef); err == nil && agentDef.CodeConfiguration != nil { + agentConfig.StartupCommand = "python " + agentDef.CodeConfiguration.EntryPoint + } + } + if agentConfig.StartupCommand == "" { + agentConfig.StartupCommand = "python main.py" + } } var agentConfigStruct *structpb.Struct @@ -894,13 +905,14 @@ func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, func (a *InitFromCodeAction) promptCodeConfiguration(ctx context.Context) (*agent_yaml.CodeConfiguration, error) { // Prompt for runtime runtimeChoices := []*azdext.SelectChoice{ + {Label: "Python 3.12", Value: "python_3_12"}, {Label: "Python 3.11", Value: "python_3_11"}, - {Label: "Python 3.10", Value: "python_3_10"}, + {Label: "Python 3.13", Value: "python_3_13"}, } var runtime string if a.flags.noPrompt { - runtime = "python_3_11" + runtime = "python_3_12" } else { defaultIdx := int32(0) runtimeResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 3a059e7b21b..a0a71f4da87 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -375,10 +375,12 @@ func (p *AgentServiceTargetProvider) Package( return nil, fmt.Errorf("failed to create temp file for ZIP: %w", err) } if _, err := tmpFile.Write(zipData); err != nil { - tmpFile.Close() + _ = tmpFile.Close() return nil, fmt.Errorf("failed to write ZIP to temp file: %w", err) } - tmpFile.Close() + if err := tmpFile.Close(); err != nil { + return nil, fmt.Errorf("failed to close temp file: %w", err) + } return &azdext.ServicePackageResult{ Artifacts: []*azdext.Artifact{ @@ -790,12 +792,12 @@ func (p *AgentServiceTargetProvider) packageCodeDeploy(serviceConfig *azdext.Ser // Exclusion patterns excludeDirs := map[string]bool{ - "__pycache__": true, - ".venv": true, - "venv": true, - ".git": true, - "node_modules": true, - ".mypy_cache": true, + "__pycache__": true, + ".venv": true, + "venv": true, + ".git": true, + "node_modules": true, + ".mypy_cache": true, ".pytest_cache": true, } excludeExts := map[string]bool{ @@ -844,7 +846,7 @@ func (p *AgentServiceTargetProvider) packageCodeDeploy(serviceConfig *azdext.Ser } // Add file to ZIP - fileData, err := os.ReadFile(path) + fileData, err := os.ReadFile(path) //nolint:gosec // path is constructed from filepath.WalkDir within the service directory if err != nil { return fmt.Errorf("failed to read %s: %w", relPath, err) } @@ -912,7 +914,7 @@ func (p *AgentServiceTargetProvider) deployHostedCodeAgent( ) } - zipData, err := os.ReadFile(zipPath) + zipData, err := os.ReadFile(zipPath) //nolint:gosec // zipPath comes from the artifact location set during packaging if err != nil { return nil, fmt.Errorf("failed to read ZIP artifact: %w", err) } From 1e5650bbd7a3f6dac58a7fb6f46f2e4cd4ea5ad4 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Tue, 12 May 2026 12:17:40 +0800 Subject: [PATCH 04/18] fix(agents): address Copilot review feedback on code deploy - Add .azure and .env/.env.* to ZIP exclusion list to prevent uploading secrets - Skip symlinks in WalkDir to avoid including files outside agent directory - Stream ZIP directly to temp file with io.MultiWriter for SHA-256, reducing memory usage - Clean up temp file on all error paths using deferred cleanup - Only fall back to create agent on 404; propagate auth/5xx/network errors - Use context-aware select/time.After in polling loop for responsive cancellation - Add dedicated CodeMissingCodeZipArtifact error code - Fix entry point auto-detection to use srcDir instead of cwd - Replace hardcoded multipart boundary with mime/multipart.Writer - Add unit tests for zipDeployRequest multipart format and headers --- .../internal/cmd/init_from_code.go | 12 +- .../internal/exterrors/codes.go | 1 + .../pkg/agents/agent_api/operations.go | 35 +++--- .../pkg/agents/agent_api/operations_test.go | 104 ++++++++++++++++++ .../internal/project/service_target_agent.go | 96 ++++++++++------ 5 files changed, 198 insertions(+), 50 deletions(-) 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 2bc6a4a4683..db106ecfba9 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 @@ -499,7 +499,7 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) // If code deploy, prompt for code configuration details var codeConfig *agent_yaml.CodeConfiguration if deployMode == "code" { - codeConfig, err = a.promptCodeConfiguration(ctx) + codeConfig, err = a.promptCodeConfiguration(ctx, a.flags.src) if err != nil { return nil, err } @@ -902,7 +902,11 @@ func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, } // promptCodeConfiguration prompts the user for code deploy configuration settings. -func (a *InitFromCodeAction) promptCodeConfiguration(ctx context.Context) (*agent_yaml.CodeConfiguration, error) { +func (a *InitFromCodeAction) promptCodeConfiguration(ctx context.Context, srcDir string) (*agent_yaml.CodeConfiguration, error) { + if srcDir == "" { + srcDir = "." + } + // Prompt for runtime runtimeChoices := []*azdext.SelectChoice{ {Label: "Python 3.12", Value: "python_3_12"}, @@ -933,8 +937,8 @@ func (a *InitFromCodeAction) promptCodeConfiguration(ctx context.Context) (*agen // Prompt for entry point defaultEntryPoint := "main.py" - // Try to detect entry point from common patterns - if _, err := os.Stat("app.py"); err == nil { + // Try to detect entry point from common patterns in source directory + if _, err := os.Stat(filepath.Join(srcDir, "app.py")); err == nil { defaultEntryPoint = "app.py" } diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index 02c8dcf2c1a..9edf2b45f6e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -32,6 +32,7 @@ const ( CodeLocationMismatch = "location_mismatch" CodeTenantMismatch = "tenant_mismatch" CodeMissingPublishedContainer = "missing_published_container_artifact" + CodeMissingCodeZipArtifact = "missing_code_zip_artifact" CodeModelDeploymentNotFound = "model_deployment_not_found" CodeConflictingArguments = "conflicting_arguments" CodeInvalidPositionalArg = "invalid_positional_arg" diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go index 2eb19649a6a..e3c98e9f8ab 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" "net/url" "strconv" @@ -400,7 +401,7 @@ func (c *AgentClient) zipDeployRequest( ) (*AgentObject, error) { // Build multipart body var body bytes.Buffer - boundary := "----azd-code-deploy-boundary" + writer := multipart.NewWriter(&body) // Part 1: metadata (JSON) metadataJSON, err := json.Marshal(metadata) @@ -408,29 +409,33 @@ func (c *AgentClient) zipDeployRequest( return nil, fmt.Errorf("failed to marshal metadata: %w", err) } - body.WriteString("--" + boundary + "\r\n") - body.WriteString("Content-Disposition: form-data; name=\"metadata\"\r\n") - body.WriteString("Content-Type: application/json\r\n\r\n") - body.Write(metadataJSON) - body.WriteString("\r\n") + metadataPart, err := writer.CreateFormField("metadata") + if err != nil { + return nil, fmt.Errorf("failed to create metadata part: %w", err) + } + if _, err := metadataPart.Write(metadataJSON); err != nil { + return nil, fmt.Errorf("failed to write metadata: %w", err) + } // Part 2: code (ZIP) - body.WriteString("--" + boundary + "\r\n") - body.WriteString("Content-Disposition: form-data; name=\"code\"; filename=\"agent.zip\"\r\n") - body.WriteString("Content-Type: application/zip\r\n\r\n") - body.Write(zipData) - body.WriteString("\r\n") + codePart, err := writer.CreateFormFile("code", "agent.zip") + if err != nil { + return nil, fmt.Errorf("failed to create code part: %w", err) + } + if _, err := codePart.Write(zipData); err != nil { + return nil, fmt.Errorf("failed to write ZIP data: %w", err) + } - // End boundary - body.WriteString("--" + boundary + "--\r\n") + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("failed to close multipart writer: %w", err) + } req, err := runtime.NewRequest(ctx, http.MethodPost, reqURL) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - contentType := "multipart/form-data; boundary=" + boundary - if err := req.SetBody(streaming.NopCloser(bytes.NewReader(body.Bytes())), contentType); err != nil { + if err := req.SetBody(streaming.NopCloser(bytes.NewReader(body.Bytes())), writer.FormDataContentType()); err != nil { return nil, fmt.Errorf("failed to set request body: %w", err) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go index d190958934d..fbfaa38e4ae 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go @@ -4,8 +4,12 @@ package agent_api import ( + "bytes" + "context" "encoding/json" "io" + "mime" + "mime/multipart" "net/http" "strings" "testing" @@ -287,3 +291,103 @@ func TestPatchAgent_OmitsNilFields(t *testing.T) { require.NotContains(t, s, `"agent_card"`) require.NotContains(t, s, `"definition"`) } + +// capturingTransport captures the last HTTP request and returns a canned JSON response. +type capturingTransport struct { + lastReq *http.Request + lastBody []byte + statusCode int + respBody string +} + +func (c *capturingTransport) Do(req *http.Request) (*http.Response, error) { + c.lastReq = req + if req.Body != nil { + body, _ := io.ReadAll(req.Body) + c.lastBody = body + req.Body.Close() + } + return &http.Response{ + StatusCode: c.statusCode, + Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(c.respBody)), + Request: req, + }, nil +} + +func TestZipDeployRequest_MultipartFormat(t *testing.T) { + agentResp := `{"name":"test-agent","versions":{"latest":{"version":"1","status":"active"}}}` + transport := &capturingTransport{statusCode: http.StatusCreated, respBody: agentResp} + client := newTestClient("https://test.example.com/api/projects/proj", transport) + + desc := "test desc" + metadata := &CreateAgentVersionRequest{ + Description: &desc, + } + zipData := []byte("PK\x03\x04fake-zip-content") + sha256Hex := "abcdef1234567890" + + _, err := client.zipDeployRequest( + context.Background(), + "https://test.example.com/api/projects/proj/agents", + "test-agent", + metadata, + zipData, + sha256Hex, + ) + require.NoError(t, err) + + // Verify required headers + require.Equal(t, "CodeAgents=V1Preview,HostedAgents=V1Preview", transport.lastReq.Header.Get("Foundry-Features")) + require.Equal(t, sha256Hex, transport.lastReq.Header.Get("x-ms-code-zip-sha256")) + require.Equal(t, "test-agent", transport.lastReq.Header.Get("x-ms-agent-name")) + + // Verify multipart content type with boundary + contentType := transport.lastReq.Header.Get("Content-Type") + mediaType, params, err := mime.ParseMediaType(contentType) + require.NoError(t, err) + require.Equal(t, "multipart/form-data", mediaType) + require.NotEmpty(t, params["boundary"]) + + // Parse multipart body and verify parts + reader := multipart.NewReader(bytes.NewReader(transport.lastBody), params["boundary"]) + + // Part 1: metadata + part1, err := reader.NextPart() + require.NoError(t, err) + require.Equal(t, "metadata", part1.FormName()) + part1Data, _ := io.ReadAll(part1) + var parsedMeta map[string]interface{} + require.NoError(t, json.Unmarshal(part1Data, &parsedMeta)) + require.Equal(t, "test desc", parsedMeta["description"]) + + // Part 2: code ZIP + part2, err := reader.NextPart() + require.NoError(t, err) + require.Equal(t, "code", part2.FormName()) + require.Equal(t, "agent.zip", part2.FileName()) + part2Data, _ := io.ReadAll(part2) + require.Equal(t, zipData, part2Data) +} + +func TestZipDeployRequest_NoAgentNameHeader_OnUpdate(t *testing.T) { + agentResp := `{"name":"test-agent","versions":{"latest":{"version":"2","status":"active"}}}` + transport := &capturingTransport{statusCode: http.StatusOK, respBody: agentResp} + client := newTestClient("https://test.example.com/api/projects/proj", transport) + + _, err := client.zipDeployRequest( + context.Background(), + "https://test.example.com/api/projects/proj/agents/test-agent", + "", // empty = update, no x-ms-agent-name header + &CreateAgentVersionRequest{}, + []byte("zip"), + "sha", + ) + require.NoError(t, err) + + // x-ms-agent-name should NOT be set for updates + require.Empty(t, transport.lastReq.Header.Get("x-ms-agent-name")) + // But other required headers should still be present + require.Equal(t, "CodeAgents=V1Preview,HostedAgents=V1Preview", transport.lastReq.Header.Get("Foundry-Features")) + require.Equal(t, "sha", transport.lastReq.Header.Get("x-ms-code-zip-sha256")) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index a0a71f4da87..53dad3be1e0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -5,13 +5,15 @@ package project import ( "archive/zip" - "bytes" "context" "crypto/sha256" "encoding/base64" "encoding/hex" + "errors" "fmt" + "io" "io/fs" + "net/http" "os" "path/filepath" "strings" @@ -22,6 +24,7 @@ import ( "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/azure" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" @@ -364,29 +367,16 @@ func (p *AgentServiceTargetProvider) Package( // Code deploy: ZIP the source directory if p.isCodeDeployAgent() { progress("Packaging code") - zipData, sha256Hex, err := p.packageCodeDeploy(serviceConfig) + zipPath, sha256Hex, err := p.packageCodeDeploy(serviceConfig) if err != nil { return nil, exterrors.Internal(exterrors.OpContainerPackage, fmt.Sprintf("code packaging failed: %s", err)) } - // Store zip data and hash as artifacts via a temp file - tmpFile, err := os.CreateTemp("", "azd-code-deploy-*.zip") - if err != nil { - return nil, fmt.Errorf("failed to create temp file for ZIP: %w", err) - } - if _, err := tmpFile.Write(zipData); err != nil { - _ = tmpFile.Close() - return nil, fmt.Errorf("failed to write ZIP to temp file: %w", err) - } - if err := tmpFile.Close(); err != nil { - return nil, fmt.Errorf("failed to close temp file: %w", err) - } - return &azdext.ServicePackageResult{ Artifacts: []*azdext.Artifact{ { Kind: azdext.ArtifactKind_ARTIFACT_KIND_ARCHIVE, - Location: tmpFile.Name(), + Location: zipPath, LocationKind: azdext.LocationKind_LOCATION_KIND_LOCAL, Metadata: map[string]string{ "type": "code-zip", @@ -785,8 +775,9 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( }, nil } -// packageCodeDeploy creates a ZIP archive of the agent source code and computes its SHA-256. -func (p *AgentServiceTargetProvider) packageCodeDeploy(serviceConfig *azdext.ServiceConfig) ([]byte, string, error) { +// packageCodeDeploy creates a ZIP archive of the agent source code, writes it to a temp file, +// and computes its SHA-256. Returns the temp file path and SHA-256 hex string. +func (p *AgentServiceTargetProvider) packageCodeDeploy(serviceConfig *azdext.ServiceConfig) (string, string, error) { // Source directory is the service's relative path srcDir := filepath.Dir(p.agentDefinitionPath) @@ -799,16 +790,37 @@ func (p *AgentServiceTargetProvider) packageCodeDeploy(serviceConfig *azdext.Ser "node_modules": true, ".mypy_cache": true, ".pytest_cache": true, + ".azure": true, } excludeExts := map[string]bool{ ".pyc": true, ".pyo": true, } + excludeFiles := map[string]bool{ + ".env": true, + } - var buf bytes.Buffer - zipWriter := zip.NewWriter(&buf) + // Create temp file and write ZIP directly to it while computing SHA-256 + tmpFile, err := os.CreateTemp("", "azd-code-deploy-*.zip") + if err != nil { + return "", "", fmt.Errorf("failed to create temp file for ZIP: %w", err) + } + tmpPath := tmpFile.Name() + + // Clean up on error + success := false + defer func() { + if !success { + tmpFile.Close() + os.Remove(tmpPath) + } + }() + + hasher := sha256.New() + multiWriter := io.MultiWriter(tmpFile, hasher) + zipWriter := zip.NewWriter(multiWriter) - err := filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, err error) error { + err = filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } @@ -835,11 +847,21 @@ func (p *AgentServiceTargetProvider) packageCodeDeploy(serviceConfig *azdext.Ser return nil } + // Skip symlinks to avoid including files outside the agent directory + if d.Type()&fs.ModeSymlink != 0 { + return nil + } + // Check file extension exclusions if excludeExts[filepath.Ext(path)] { return nil } + // Check file name exclusions (.env, .env.*) + if excludeFiles[d.Name()] || strings.HasPrefix(d.Name(), ".env.") { + return nil + } + // Skip agent.yaml itself from the ZIP (metadata is sent separately) if d.Name() == "agent.yaml" { return nil @@ -864,18 +886,21 @@ func (p *AgentServiceTargetProvider) packageCodeDeploy(serviceConfig *azdext.Ser }) if err != nil { - return nil, "", fmt.Errorf("failed to walk source directory: %w", err) + return "", "", fmt.Errorf("failed to walk source directory: %w", err) } if err := zipWriter.Close(); err != nil { - return nil, "", fmt.Errorf("failed to close ZIP: %w", err) + return "", "", fmt.Errorf("failed to close ZIP: %w", err) } - zipData := buf.Bytes() - hash := sha256.Sum256(zipData) - sha256Hex := hex.EncodeToString(hash[:]) + if err := tmpFile.Close(); err != nil { + return "", "", fmt.Errorf("failed to close temp file: %w", err) + } - return zipData, sha256Hex, nil + sha256Hex := hex.EncodeToString(hasher.Sum(nil)) + success = true + + return tmpPath, sha256Hex, nil } // deployHostedCodeAgent deploys a code-based hosted agent via multipart ZIP upload. @@ -908,7 +933,7 @@ func (p *AgentServiceTargetProvider) deployHostedCodeAgent( } if zipPath == "" { return nil, exterrors.Dependency( - exterrors.CodeMissingPublishedContainer, + exterrors.CodeMissingCodeZipArtifact, "code ZIP artifact not found: no code-zip artifact was found in service package artifacts", "run 'azd deploy' to package and deploy the agent", ) @@ -997,10 +1022,15 @@ func (p *AgentServiceTargetProvider) deployHostedCodeAgent( // Check if agent already exists (GET /agents/{name}) progress("Creating agent") - _, err = agentClient.GetAgent(ctx, agentDef.Name, agentAPIVersion) + _, getErr := agentClient.GetAgent(ctx, agentDef.Name, agentAPIVersion) var agentResp *agent_api.AgentObject - if err != nil { + if getErr != nil { + // Only fall back to create on 404; propagate other errors (auth, 5xx, network) + var respErr *azcore.ResponseError + if !errors.As(getErr, &respErr) || respErr.StatusCode != http.StatusNotFound { + return nil, fmt.Errorf("failed to check if agent exists: %w", getErr) + } // Agent doesn't exist — create fmt.Fprintf(os.Stderr, "Creating new agent: %s\n", agentDef.Name) agentResp, err = agentClient.CreateAgentFromZip(ctx, agentDef.Name, versionRequest, zipData, sha256Hex, agentAPIVersion) @@ -1031,7 +1061,11 @@ func (p *AgentServiceTargetProvider) deployHostedCodeAgent( deadline := time.Now().Add(pollTimeout) for time.Now().Before(deadline) { - time.Sleep(pollInterval) + select { + case <-ctx.Done(): + return nil, fmt.Errorf("deployment cancelled: %w", ctx.Err()) + case <-time.After(pollInterval): + } versionResp, err := agentClient.GetAgentVersion(ctx, agentDef.Name, latestVersion.Version, agentAPIVersion) if err != nil { fmt.Fprintf(os.Stderr, "Warning: poll failed: %s\n", err) From 026b598e3d2920c55ca490dfd9615c4e9a41e7ea Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Tue, 12 May 2026 12:37:20 +0800 Subject: [PATCH 05/18] fix(agents): apply agent_endpoint/agent_card via PatchAgent for code deploy and set metadata Content-Type - Add PatchAgent call after code deploy create/update to apply agent_endpoint and agent_card fields (matching container deploy behavior) - Use CreatePart with explicit Content-Type: application/json for the metadata multipart part instead of CreateFormField - Update unit test to verify metadata part Content-Type header --- .../pkg/agents/agent_api/operations.go | 6 +++++- .../pkg/agents/agent_api/operations_test.go | 1 + .../internal/project/service_target_agent.go | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go index e3c98e9f8ab..95286167b4b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go @@ -11,6 +11,7 @@ import ( "io" "mime/multipart" "net/http" + "net/textproto" "net/url" "strconv" "time" @@ -409,7 +410,10 @@ func (c *AgentClient) zipDeployRequest( return nil, fmt.Errorf("failed to marshal metadata: %w", err) } - metadataPart, err := writer.CreateFormField("metadata") + metadataPart, err := writer.CreatePart(textproto.MIMEHeader{ + "Content-Disposition": {`form-data; name="metadata"`}, + "Content-Type": {"application/json"}, + }) if err != nil { return nil, fmt.Errorf("failed to create metadata part: %w", err) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go index fbfaa38e4ae..bcc8a7505eb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go @@ -356,6 +356,7 @@ func TestZipDeployRequest_MultipartFormat(t *testing.T) { part1, err := reader.NextPart() require.NoError(t, err) require.Equal(t, "metadata", part1.FormName()) + require.Equal(t, "application/json", part1.Header.Get("Content-Type")) part1Data, _ := io.ReadAll(part1) var parsedMeta map[string]interface{} require.NoError(t, json.Unmarshal(part1Data, &parsedMeta)) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 53dad3be1e0..6f6b124965d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -1092,6 +1092,25 @@ func (p *AgentServiceTargetProvider) deployHostedCodeAgent( } } + // Patch agent-level fields (agent_endpoint, agent_card) if present. + // These are agent-level properties, not version-level, so they require + // a separate PatchAgent call after version creation. + if request.AgentEndpoint != nil || request.AgentCard != nil { + patchRequest := &agent_api.PatchAgentRequest{ + AgentEndpoint: request.AgentEndpoint, + AgentCard: request.AgentCard, + } + + _, err := agentClient.PatchAgent(ctx, agentDef.Name, patchRequest, agentAPIVersion) + if err != nil { + fmt.Fprintf(os.Stderr, + "WARNING: Agent was created/updated, but patching agent endpoint/card failed: %s\n", err, + ) + return nil, exterrors.ServiceFromAzure(err, exterrors.OpCreateAgent) + } + fmt.Fprintf(os.Stderr, "Agent endpoint/card updated.\n") + } + // Register environment variables progress("Registering agent environment variables") protocols := agentDef.Protocols From 26c8087febd31c7a8ec5e485815a1f8d630aae8a Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Tue, 12 May 2026 12:42:24 +0800 Subject: [PATCH 06/18] fix(agents): resolve CI lint and cspell errors - Add //nolint:gosec to os.ReadFile in init_from_code.go - Handle req.Body.Close() return value in test transport - Suppress Close/Remove errors in deferred cleanup with _ = - Add 'mypy' to cspell.yaml words list --- cli/azd/extensions/azure.ai.agents/cspell.yaml | 1 + .../extensions/azure.ai.agents/internal/cmd/init_from_code.go | 2 +- .../internal/pkg/agents/agent_api/operations_test.go | 2 +- .../azure.ai.agents/internal/project/service_target_agent.go | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index 08483e3b4d5..8dfa8421459 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -42,6 +42,7 @@ words: - mcpservertoolalwaysrequireapprovalmode - mcpservertoolneverrequireapprovalmode - mcpservertoolspecifyapprovalmode + - mypy - myregistry - normalises - openapitool 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 db106ecfba9..10d4d53a499 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 @@ -854,7 +854,7 @@ func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, } else { // For code deploy, auto-derive startupCommand from entry point in agent.yaml agentYamlPath := filepath.Join(a.projectConfig.Path, targetDir, "agent.yaml") - if data, err := os.ReadFile(agentYamlPath); err == nil { + if data, err := os.ReadFile(agentYamlPath); err == nil { //nolint:gosec // path is constructed from project config var agentDef agent_yaml.ContainerAgent if err := yaml.Unmarshal(data, &agentDef); err == nil && agentDef.CodeConfiguration != nil { agentConfig.StartupCommand = "python " + agentDef.CodeConfiguration.EntryPoint diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go index bcc8a7505eb..d32fc262434 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go @@ -305,7 +305,7 @@ func (c *capturingTransport) Do(req *http.Request) (*http.Response, error) { if req.Body != nil { body, _ := io.ReadAll(req.Body) c.lastBody = body - req.Body.Close() + _ = req.Body.Close() } return &http.Response{ StatusCode: c.statusCode, diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 6f6b124965d..854054af98f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -811,8 +811,8 @@ func (p *AgentServiceTargetProvider) packageCodeDeploy(serviceConfig *azdext.Ser success := false defer func() { if !success { - tmpFile.Close() - os.Remove(tmpPath) + _ = tmpFile.Close() + _ = os.Remove(tmpPath) } }() From 510ed08742268e18ae78df89f36d3d77d58949ef Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Tue, 12 May 2026 12:46:23 +0800 Subject: [PATCH 07/18] style: use map[string]any instead of map[string]interface{} --- .../internal/pkg/agents/agent_api/operations_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go index d32fc262434..fa86f9c8ce3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go @@ -358,7 +358,7 @@ func TestZipDeployRequest_MultipartFormat(t *testing.T) { require.Equal(t, "metadata", part1.FormName()) require.Equal(t, "application/json", part1.Header.Get("Content-Type")) part1Data, _ := io.ReadAll(part1) - var parsedMeta map[string]interface{} + var parsedMeta map[string]any require.NoError(t, json.Unmarshal(part1Data, &parsedMeta)) require.Equal(t, "test desc", parsedMeta["description"]) From 905d3aca2fcfbc15b20d4ad72cc1d12d93bd5042 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Tue, 12 May 2026 14:59:41 +0800 Subject: [PATCH 08/18] feat(agents): add code deploy support to template init flow Add deploy mode prompt (code vs container) to the template init flow, allowing users to choose code deploy when initializing from a template. Skip ACR configuration when code deploy is selected. Auto-derive startup command from entry_point for code deploy instead of prompting. --- .../azure.ai.agents/internal/cmd/init.go | 57 +++++++-- .../cmd/init_foundry_resources_helpers.go | 9 +- .../internal/cmd/init_from_code.go | 110 +++++++++++++++++- 3 files changed, 166 insertions(+), 10 deletions(-) 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 d5838ff6769..10db32d122a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -74,6 +74,7 @@ type InitAction struct { deploymentDetails []project.Deployment containerSettings *project.ContainerSettings + isCodeDeploy bool // true when user selects code deploy mode; skips ACR config httpClient *http.Client serviceNameOverride string // when set, addToProject uses this instead of the manifest name } @@ -570,6 +571,27 @@ func (a *InitAction) Run(ctx context.Context) error { return fmt.Errorf("downloading agent.yaml: %w", err) } + // Prompt for deploy mode (code vs container) for hosted agents + if _, ok := agentManifest.Template.(agent_yaml.ContainerAgent); ok { + deployMode, err := promptDeployMode(ctx, a.azdClient, a.flags.noPrompt) + if err != nil { + return fmt.Errorf("prompting for deploy mode: %w", err) + } + a.isCodeDeploy = (deployMode == "code") + + if a.isCodeDeploy { + // Prompt for code configuration and update the manifest + codeConfig, err := promptCodeConfigurationShared(ctx, a.azdClient, targetDir) + if err != nil { + return fmt.Errorf("prompting for code configuration: %w", err) + } + + hostedAgent := agentManifest.Template.(agent_yaml.ContainerAgent) + hostedAgent.CodeConfiguration = codeConfig + agentManifest.Template = hostedAgent + } + } + // Model configuration: prompt user for "use existing" vs "deploy new" agentManifest, err = a.configureModelChoice(ctx, agentManifest) if err != nil { @@ -792,6 +814,7 @@ func (a *InitAction) configureModelChoice( selectedProject, err := selectFoundryProject( ctx, a.azdClient, a.credential, a.azureContext, a.environment.Name, a.azureContext.Scope.SubscriptionId, a.flags.projectResourceId, + a.isCodeDeploy, ) if err != nil { return nil, err @@ -840,6 +863,7 @@ func (a *InitAction) configureModelChoice( selectedProject, err := selectFoundryProject( ctx, a.azdClient, a.credential, a.azureContext, a.environment.Name, a.azureContext.Scope.SubscriptionId, "", + a.isCodeDeploy, ) if err != nil { return nil, err @@ -932,6 +956,7 @@ func (a *InitAction) configureModelChoice( selectedProject, err := selectFoundryProject( ctx, a.azdClient, a.credential, a.azureContext, a.environment.Name, a.azureContext.Scope.SubscriptionId, a.flags.projectResourceId, + a.isCodeDeploy, ) if err != nil { return nil, err @@ -1558,11 +1583,25 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa } // Detect startup command from the project source directory - startupCmd, err := resolveStartupCommandForInit(ctx, a.azdClient, a.projectConfig.Path, targetDir, a.flags.noPrompt) - if err != nil { - return err + if a.isCodeDeploy { + // For code deploy, auto-derive startupCommand from entry point in agent.yaml + agentYamlPath := filepath.Join(a.projectConfig.Path, targetDir, "agent.yaml") + if data, readErr := os.ReadFile(agentYamlPath); readErr == nil { //nolint:gosec // path is constructed from project config + var containerAgent agent_yaml.ContainerAgent + if yamlErr := yaml.Unmarshal(data, &containerAgent); yamlErr == nil && containerAgent.CodeConfiguration != nil { + agentConfig.StartupCommand = "python " + containerAgent.CodeConfiguration.EntryPoint + } + } + if agentConfig.StartupCommand == "" { + agentConfig.StartupCommand = "python main.py" + } + } else { + startupCmd, err := resolveStartupCommandForInit(ctx, a.azdClient, a.projectConfig.Path, targetDir, a.flags.noPrompt) + if err != nil { + return err + } + agentConfig.StartupCommand = startupCmd } - agentConfig.StartupCommand = startupCmd var agentConfigStruct *structpb.Struct if agentConfigStruct, err = project.MarshalStruct(&agentConfig); err != nil { @@ -1577,10 +1616,14 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa Config: agentConfigStruct, } - // For hosted (container-based) agents, set remoteBuild to true by default + // For hosted agents, configure Docker or code deploy settings if agentDef.Kind == agent_yaml.AgentKindHosted { - serviceConfig.Docker = &azdext.DockerProjectOptions{ - RemoteBuild: true, + if a.isCodeDeploy { + serviceConfig.Language = "python" + } else { + serviceConfig.Docker = &azdext.DockerProjectOptions{ + RemoteBuild: true, + } } } 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 285de31c6d4..a75ea7a9789 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 @@ -308,6 +308,7 @@ func lookupAcrResourceId( // configureFoundryProjectEnv sets all Foundry project environment variables and discovers // ACR and AppInsights connections. This is the shared implementation used by both init flows. +// When skipACR is true, ACR connection discovery and configuration is skipped (used for code deploy). func configureFoundryProjectEnv( ctx context.Context, azdClient *azdext.AzdClient, @@ -315,6 +316,7 @@ func configureFoundryProjectEnv( envName string, project FoundryProjectInfo, subscriptionId string, + skipACR bool, ) error { resourceId := project.ResourceId if resourceId == "" { @@ -365,7 +367,9 @@ func configureFoundryProjectEnv( for _, conn := range connections { switch conn.Type { case azure.ConnectionTypeContainerRegistry: - acrConnections = append(acrConnections, conn) + if !skipACR { + acrConnections = append(acrConnections, conn) + } case azure.ConnectionTypeAppInsights: connWithCreds, err := foundryClient.GetConnectionWithCredentials(ctx, conn.Name) if err != nil { @@ -999,6 +1003,7 @@ func selectFoundryProject( envName string, subscriptionId string, projectResourceId string, + skipACR bool, ) (*FoundryProjectInfo, error) { spinnerText := "Searching for Foundry projects in your subscription..." if projectResourceId != "" { @@ -1095,7 +1100,7 @@ func selectFoundryProject( } // Configure all Foundry project environment variables - if err := configureFoundryProjectEnv(ctx, azdClient, credential, envName, selectedProject, subscriptionId); err != nil { + if err := configureFoundryProjectEnv(ctx, azdClient, credential, envName, selectedProject, subscriptionId, skipACR); err != nil { return nil, fmt.Errorf("failed to configure Foundry project environment: %w", err) } 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 10d4d53a499..cf58a693c53 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 @@ -576,7 +576,7 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) a.credential = newCred // Select a Foundry project - selectedProject, err := selectFoundryProject(ctx, a.azdClient, a.credential, a.azureContext, a.environment.Name, a.azureContext.Scope.SubscriptionId, a.flags.projectResourceId) + selectedProject, err := selectFoundryProject(ctx, a.azdClient, a.credential, a.azureContext, a.environment.Name, a.azureContext.Scope.SubscriptionId, a.flags.projectResourceId, deployMode == "code") if err != nil { return nil, err } @@ -1117,3 +1117,111 @@ func knownProtocolNames() string { } return strings.Join(names, ", ") } + +// promptDeployMode asks the user to choose between code deploy and container deploy. +// When noPrompt is true, defaults to "container" for backward compatibility. +func promptDeployMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt bool) (string, error) { + deployModeChoices := []*azdext.SelectChoice{ + {Label: "Code-based (Python source, no Docker/ACR)", Value: "code"}, + {Label: "Container-based (Dockerfile + ACR)", Value: "container"}, + } + + if noPrompt { + return "container", nil + } + + defaultIdx := int32(0) + deployModeResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "How would you like to deploy your agent?", + Choices: deployModeChoices, + SelectedIndex: &defaultIdx, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return "", exterrors.Cancelled("deploy mode selection was cancelled") + } + return "", fmt.Errorf("failed to prompt for deploy mode: %w", err) + } + return deployModeChoices[*deployModeResp.Value].Value, nil +} + +// promptCodeConfigurationShared prompts for code deploy configuration (runtime, entry point, +// dependency resolution). This is the standalone version used by the template init flow. +func promptCodeConfigurationShared(ctx context.Context, azdClient *azdext.AzdClient, srcDir string) (*agent_yaml.CodeConfiguration, error) { + if srcDir == "" { + srcDir = "." + } + + // Prompt for runtime + runtimeChoices := []*azdext.SelectChoice{ + {Label: "Python 3.12", Value: "python_3_12"}, + {Label: "Python 3.11", Value: "python_3_11"}, + {Label: "Python 3.13", Value: "python_3_13"}, + } + + defaultIdx := int32(0) + runtimeResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select the runtime for your agent", + Choices: runtimeChoices, + SelectedIndex: &defaultIdx, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("runtime selection was cancelled") + } + return nil, fmt.Errorf("failed to prompt for runtime: %w", err) + } + runtime := runtimeChoices[*runtimeResp.Value].Value + + // Prompt for entry point + defaultEntryPoint := "main.py" + if _, statErr := os.Stat(filepath.Join(srcDir, "app.py")); statErr == nil { + defaultEntryPoint = "app.py" + } + + entryPointResp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter the entry point file for your agent", + DefaultValue: defaultEntryPoint, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("entry point prompt was cancelled") + } + return nil, fmt.Errorf("failed to prompt for entry point: %w", err) + } + entryPoint := entryPointResp.Value + + // Prompt for dependency resolution + depResChoices := []*azdext.SelectChoice{ + {Label: "Remote build (server installs dependencies)", Value: "remote_build"}, + {Label: "Bundled (pre-install dependencies locally)", Value: "bundled"}, + } + + depDefaultIdx := int32(0) + depResResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "How should dependencies be resolved?", + Choices: depResChoices, + SelectedIndex: &depDefaultIdx, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("dependency resolution selection was cancelled") + } + return nil, fmt.Errorf("failed to prompt for dependency resolution: %w", err) + } + depResolution := depResChoices[*depResResp.Value].Value + + return &agent_yaml.CodeConfiguration{ + Runtime: runtime, + EntryPoint: entryPoint, + DependencyResolution: &depResolution, + }, nil +} From a5463b4a343be2af9749f898817019f4a4ace77a Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Tue, 12 May 2026 15:03:25 +0800 Subject: [PATCH 09/18] fix(agents): improve deploy mode prompt and clean agent.yaml on mode switch - Default to Container (Docker) for backward compatibility - Use clearer labels: 'Container (Docker)' / 'Code deploy (ZIP upload)' - Remove code_configuration from agent.yaml when switching to container mode --- .../extensions/azure.ai.agents/internal/cmd/init.go | 8 ++++++++ .../azure.ai.agents/internal/cmd/init_from_code.go | 12 ++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) 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 10db32d122a..e885984ba9b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -589,6 +589,14 @@ func (a *InitAction) Run(ctx context.Context) error { hostedAgent := agentManifest.Template.(agent_yaml.ContainerAgent) hostedAgent.CodeConfiguration = codeConfig agentManifest.Template = hostedAgent + } else { + // Container mode: ensure any pre-existing code_configuration is removed + // (e.g. when switching from code deploy back to container) + hostedAgent := agentManifest.Template.(agent_yaml.ContainerAgent) + if hostedAgent.CodeConfiguration != nil { + hostedAgent.CodeConfiguration = nil + agentManifest.Template = hostedAgent + } } } 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 cf58a693c53..f539af9e722 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 @@ -471,15 +471,15 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) // Prompt user for deploy mode (container vs code) deployModeChoices := []*azdext.SelectChoice{ - {Label: "Code-based (Python source, no Docker/ACR)", Value: "code"}, - {Label: "Container-based (Dockerfile + ACR)", Value: "container"}, + {Label: "Container (Docker)", Value: "container"}, + {Label: "Code deploy (ZIP upload)", Value: "code"}, } var deployMode string if a.flags.noPrompt { deployMode = "container" // default to container for backward compatibility } else { - defaultIdx := int32(0) + defaultIdx := int32(0) // Container is the default for backward compatibility deployModeResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ Options: &azdext.SelectOptions{ Message: "How would you like to deploy your agent?", @@ -1122,15 +1122,15 @@ func knownProtocolNames() string { // When noPrompt is true, defaults to "container" for backward compatibility. func promptDeployMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt bool) (string, error) { deployModeChoices := []*azdext.SelectChoice{ - {Label: "Code-based (Python source, no Docker/ACR)", Value: "code"}, - {Label: "Container-based (Dockerfile + ACR)", Value: "container"}, + {Label: "Container (Docker)", Value: "container"}, + {Label: "Code deploy (ZIP upload)", Value: "code"}, } if noPrompt { return "container", nil } - defaultIdx := int32(0) + defaultIdx := int32(0) // Container is the default for backward compatibility deployModeResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ Options: &azdext.SelectOptions{ Message: "How would you like to deploy your agent?", From 550b844739f231ad5c6c9c8f349bffcb636b8df7 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Tue, 12 May 2026 15:14:40 +0800 Subject: [PATCH 10/18] fix(agents): guard configureAcrConnection with skipACR check When skipACR is true (code deploy mode), skip the configureAcrConnection call entirely to prevent prompting users for ACR configuration. --- .../internal/cmd/init_foundry_resources_helpers.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 a75ea7a9789..bf0a3165dc9 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 @@ -383,8 +383,10 @@ func configureFoundryProjectEnv( } } - if err := configureAcrConnection(ctx, azdClient, credential, envName, subscriptionId, acrConnections); err != nil { - return err + if !skipACR { + if err := configureAcrConnection(ctx, azdClient, credential, envName, subscriptionId, acrConnections); err != nil { + return err + } } if err := configureAppInsightsConnection(ctx, azdClient, envName, appInsightsConnections); err != nil { From cb8833c51c5e9b281f71f76c469ec3dc88e1a488 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Tue, 12 May 2026 17:59:59 +0800 Subject: [PATCH 11/18] fix(agents): resolve project path when init runs from subdirectory When running 'azd ai agent init' from a subdirectory with an existing agent.manifest.yaml, the addToProject function received targetDir='.' which wrote 'project: .' into azure.yaml. Since azure.yaml resolves paths relative to the project root, this caused the service to point to the wrong directory. Fix: resolve the actual relative path from project root to cwd when targetDir is '.', so azure.yaml gets the correct project path (e.g. 'src/hello-world-python-invocations' instead of '.'). --- .../extensions/azure.ai.agents/internal/cmd/init.go | 10 ++++++++++ .../azure.ai.agents/internal/cmd/init_from_code.go | 10 ++++++++++ 2 files changed, 20 insertions(+) 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 e885984ba9b..68d81e05b2f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1486,6 +1486,16 @@ func writeAgentDefinitionFile(targetDir string, agentManifest *agent_yaml.AgentM } func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentManifest *agent_yaml.AgentManifest) error { + // If targetDir is ".", resolve the actual relative path from the project root to cwd. + // This ensures azure.yaml gets the correct "project:" value when init is run from a subdirectory. + if targetDir == "." { + if cwd, err := os.Getwd(); err == nil && a.projectConfig != nil && a.projectConfig.Path != "" { + if relPath, err := filepath.Rel(a.projectConfig.Path, cwd); err == nil && relPath != "." { + targetDir = filepath.ToSlash(relPath) + } + } + } + // Convert the template to bytes templateBytes, err := json.Marshal(agentManifest.Template) if err != nil { 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 f539af9e722..85f33af99a6 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 @@ -832,6 +832,16 @@ func (a *InitFromCodeAction) writeDefinitionToSrcDir(definition *agent_yaml.Cont } func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, agentName string, isCodeDeploy bool) error { + // If targetDir is ".", resolve the actual relative path from the project root to cwd. + // This ensures azure.yaml gets the correct "project:" value when init is run from a subdirectory. + if targetDir == "." { + if cwd, err := os.Getwd(); err == nil && a.projectConfig != nil && a.projectConfig.Path != "" { + if relPath, err := filepath.Rel(a.projectConfig.Path, cwd); err == nil && relPath != "." { + targetDir = filepath.ToSlash(relPath) + } + } + } + var agentConfig = project.ServiceTargetAgentConfig{} // Both code and container modes need container resources for local run From 09a94dcfeeb3e1e09a09f74de0728bd008622642 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Wed, 13 May 2026 11:25:06 +0800 Subject: [PATCH 12/18] fix(agents): address PR #8146 review feedback (13 items) - T1: Hide code deploy for non-Python projects (isPythonProject check) - T2: Add TODO for region validation in code deploy - T3: Reorder runtime list to 3.11, 3.12, 3.13 - T4: Update entry point prompt wording - T6: Add descriptions to bundled/remote_build choices - J1: Consolidate promptCodeConfig into single shared function - J3: Use errors.AsType[*azcore.ResponseError] per Go 1.26 - J4: Extract shared deriveStartupCommand helper - V1: Rename to 'Container Image (Docker)' / 'Source Code (ZIP upload)' - V2: Unify HostedAgentDefinition with custom JSON marshal/unmarshal - V4: Fix error message to reference 'azd package' - V5: Extract prepareDeploy/finalizeDeploy shared helpers - C1+C3: Verify ZIP exclusions (.azure, .env) and temp file cleanup --- .../azure.ai.agents/internal/cmd/init.go | 19 +- .../internal/cmd/init_from_code.go | 275 +++++++---------- .../internal/pkg/agents/agent_api/models.go | 77 +++-- .../pkg/agents/agent_api/models_test.go | 29 +- .../internal/pkg/agents/agent_yaml/map.go | 27 +- .../pkg/agents/agent_yaml/map_test.go | 38 +-- .../internal/project/service_target_agent.go | 279 ++++++++---------- 7 files changed, 342 insertions(+), 402 deletions(-) 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 68d81e05b2f..74c826755cf 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -571,9 +571,11 @@ func (a *InitAction) Run(ctx context.Context) error { return fmt.Errorf("downloading agent.yaml: %w", err) } - // Prompt for deploy mode (code vs container) for hosted agents + // Prompt for deploy mode (code vs container) for hosted agents. + // Code deploy is currently only supported for Python projects. if _, ok := agentManifest.Template.(agent_yaml.ContainerAgent); ok { - deployMode, err := promptDeployMode(ctx, a.azdClient, a.flags.noPrompt) + isPython := isPythonProject(targetDir) + deployMode, err := promptDeployMode(ctx, a.azdClient, a.flags.noPrompt, isPython) if err != nil { return fmt.Errorf("prompting for deploy mode: %w", err) } @@ -581,7 +583,7 @@ func (a *InitAction) Run(ctx context.Context) error { if a.isCodeDeploy { // Prompt for code configuration and update the manifest - codeConfig, err := promptCodeConfigurationShared(ctx, a.azdClient, targetDir) + codeConfig, err := promptCodeConfig(ctx, a.azdClient, targetDir, a.flags.noPrompt) if err != nil { return fmt.Errorf("prompting for code configuration: %w", err) } @@ -1603,16 +1605,7 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa // Detect startup command from the project source directory if a.isCodeDeploy { // For code deploy, auto-derive startupCommand from entry point in agent.yaml - agentYamlPath := filepath.Join(a.projectConfig.Path, targetDir, "agent.yaml") - if data, readErr := os.ReadFile(agentYamlPath); readErr == nil { //nolint:gosec // path is constructed from project config - var containerAgent agent_yaml.ContainerAgent - if yamlErr := yaml.Unmarshal(data, &containerAgent); yamlErr == nil && containerAgent.CodeConfiguration != nil { - agentConfig.StartupCommand = "python " + containerAgent.CodeConfiguration.EntryPoint - } - } - if agentConfig.StartupCommand == "" { - agentConfig.StartupCommand = "python main.py" - } + agentConfig.StartupCommand = deriveStartupCommand(a.projectConfig.Path, targetDir) } else { startupCmd, err := resolveStartupCommandForInit(ctx, a.azdClient, a.projectConfig.Path, targetDir, a.flags.noPrompt) if err != nil { 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 85f33af99a6..91fb94283ab 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 @@ -470,30 +470,15 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) agentKind := agent_yaml.AgentKindHosted // Prompt user for deploy mode (container vs code) - deployModeChoices := []*azdext.SelectChoice{ - {Label: "Container (Docker)", Value: "container"}, - {Label: "Code deploy (ZIP upload)", Value: "code"}, + // Code deploy is only available for Python projects + srcDir := a.flags.src + if srcDir == "" { + srcDir, _ = os.Getwd() } - - var deployMode string - if a.flags.noPrompt { - deployMode = "container" // default to container for backward compatibility - } else { - defaultIdx := int32(0) // Container is the default for backward compatibility - deployModeResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ - Options: &azdext.SelectOptions{ - Message: "How would you like to deploy your agent?", - Choices: deployModeChoices, - SelectedIndex: &defaultIdx, - }, - }) - if err != nil { - if exterrors.IsCancellation(err) { - return nil, exterrors.Cancelled("deploy mode selection was cancelled") - } - return nil, fmt.Errorf("failed to prompt for deploy mode: %w", err) - } - deployMode = deployModeChoices[*deployModeResp.Value].Value + showCodeDeploy := isPythonProject(srcDir) + deployMode, err := promptDeployMode(ctx, a.azdClient, a.flags.noPrompt, showCodeDeploy) + if err != nil { + return nil, err } // If code deploy, prompt for code configuration details @@ -863,16 +848,7 @@ func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, agentConfig.StartupCommand = startupCmd } else { // For code deploy, auto-derive startupCommand from entry point in agent.yaml - agentYamlPath := filepath.Join(a.projectConfig.Path, targetDir, "agent.yaml") - if data, err := os.ReadFile(agentYamlPath); err == nil { //nolint:gosec // path is constructed from project config - var agentDef agent_yaml.ContainerAgent - if err := yaml.Unmarshal(data, &agentDef); err == nil && agentDef.CodeConfiguration != nil { - agentConfig.StartupCommand = "python " + agentDef.CodeConfiguration.EntryPoint - } - } - if agentConfig.StartupCommand == "" { - agentConfig.StartupCommand = "python main.py" - } + agentConfig.StartupCommand = deriveStartupCommand(a.projectConfig.Path, targetDir) } var agentConfigStruct *structpb.Struct @@ -913,96 +889,20 @@ func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, // promptCodeConfiguration prompts the user for code deploy configuration settings. func (a *InitFromCodeAction) promptCodeConfiguration(ctx context.Context, srcDir string) (*agent_yaml.CodeConfiguration, error) { - if srcDir == "" { - srcDir = "." - } - - // Prompt for runtime - runtimeChoices := []*azdext.SelectChoice{ - {Label: "Python 3.12", Value: "python_3_12"}, - {Label: "Python 3.11", Value: "python_3_11"}, - {Label: "Python 3.13", Value: "python_3_13"}, - } - - var runtime string - if a.flags.noPrompt { - runtime = "python_3_12" - } else { - defaultIdx := int32(0) - runtimeResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ - Options: &azdext.SelectOptions{ - Message: "Select the runtime for your agent", - Choices: runtimeChoices, - SelectedIndex: &defaultIdx, - }, - }) - if err != nil { - if exterrors.IsCancellation(err) { - return nil, exterrors.Cancelled("runtime selection was cancelled") - } - return nil, fmt.Errorf("failed to prompt for runtime: %w", err) - } - runtime = runtimeChoices[*runtimeResp.Value].Value - } - - // Prompt for entry point - defaultEntryPoint := "main.py" - // Try to detect entry point from common patterns in source directory - if _, err := os.Stat(filepath.Join(srcDir, "app.py")); err == nil { - defaultEntryPoint = "app.py" - } + return promptCodeConfig(ctx, a.azdClient, srcDir, a.flags.noPrompt) +} - var entryPoint string - if a.flags.noPrompt { - entryPoint = defaultEntryPoint - } else { - entryPointResp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ - Options: &azdext.PromptOptions{ - Message: "Enter the entry point file for your agent", - DefaultValue: defaultEntryPoint, - }, - }) - if err != nil { - if exterrors.IsCancellation(err) { - return nil, exterrors.Cancelled("entry point prompt was cancelled") - } - return nil, fmt.Errorf("failed to prompt for entry point: %w", err) +// deriveStartupCommand derives the startup command for code deploy from the agent.yaml +// entry point. Falls back to "python main.py" if the entry point cannot be determined. +func deriveStartupCommand(projectPath, targetDir string) string { + agentYamlPath := filepath.Join(projectPath, targetDir, "agent.yaml") + if data, err := os.ReadFile(agentYamlPath); err == nil { //nolint:gosec // path is constructed from project config + var agentDef agent_yaml.ContainerAgent + if err := yaml.Unmarshal(data, &agentDef); err == nil && agentDef.CodeConfiguration != nil { + return "python " + agentDef.CodeConfiguration.EntryPoint } - entryPoint = entryPointResp.Value - } - - // Prompt for dependency resolution - depResChoices := []*azdext.SelectChoice{ - {Label: "Remote build (server installs dependencies)", Value: "remote_build"}, - {Label: "Bundled (pre-install dependencies locally)", Value: "bundled"}, } - - var depResolution string - if a.flags.noPrompt { - depResolution = "remote_build" - } else { - defaultIdx := int32(0) - depResResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ - Options: &azdext.SelectOptions{ - Message: "How should dependencies be resolved?", - Choices: depResChoices, - SelectedIndex: &defaultIdx, - }, - }) - if err != nil { - if exterrors.IsCancellation(err) { - return nil, exterrors.Cancelled("dependency resolution selection was cancelled") - } - return nil, fmt.Errorf("failed to prompt for dependency resolution: %w", err) - } - depResolution = depResChoices[*depResResp.Value].Value - } - - return &agent_yaml.CodeConfiguration{ - Runtime: runtime, - EntryPoint: entryPoint, - DependencyResolution: &depResolution, - }, nil + return "python main.py" } // protocolInfo pairs a protocol name with the default version used when generating agent.yaml. @@ -1130,10 +1030,15 @@ func knownProtocolNames() string { // promptDeployMode asks the user to choose between code deploy and container deploy. // When noPrompt is true, defaults to "container" for backward compatibility. -func promptDeployMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt bool) (string, error) { +// When showCodeDeploy is false, code deploy is not offered (e.g. for non-Python languages). +func promptDeployMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt bool, showCodeDeploy bool) (string, error) { + if !showCodeDeploy { + return "container", nil + } + deployModeChoices := []*azdext.SelectChoice{ - {Label: "Container (Docker)", Value: "container"}, - {Label: "Code deploy (ZIP upload)", Value: "code"}, + {Label: "Container Image (Docker)", Value: "container"}, + {Label: "Source Code (ZIP upload)", Value: "code"}, } if noPrompt { @@ -1157,35 +1062,40 @@ func promptDeployMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt return deployModeChoices[*deployModeResp.Value].Value, nil } -// promptCodeConfigurationShared prompts for code deploy configuration (runtime, entry point, -// dependency resolution). This is the standalone version used by the template init flow. -func promptCodeConfigurationShared(ctx context.Context, azdClient *azdext.AzdClient, srcDir string) (*agent_yaml.CodeConfiguration, error) { +// promptCodeConfig prompts for code deploy configuration (runtime, entry point, +// dependency resolution). When noPrompt is true, defaults are used without prompting. +func promptCodeConfig(ctx context.Context, azdClient *azdext.AzdClient, srcDir string, noPrompt bool) (*agent_yaml.CodeConfiguration, error) { if srcDir == "" { srcDir = "." } // Prompt for runtime runtimeChoices := []*azdext.SelectChoice{ - {Label: "Python 3.12", Value: "python_3_12"}, {Label: "Python 3.11", Value: "python_3_11"}, + {Label: "Python 3.12", Value: "python_3_12"}, {Label: "Python 3.13", Value: "python_3_13"}, } - defaultIdx := int32(0) - runtimeResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ - Options: &azdext.SelectOptions{ - Message: "Select the runtime for your agent", - Choices: runtimeChoices, - SelectedIndex: &defaultIdx, - }, - }) - if err != nil { - if exterrors.IsCancellation(err) { - return nil, exterrors.Cancelled("runtime selection was cancelled") + var runtime string + if noPrompt { + runtime = "python_3_12" + } else { + defaultIdx := int32(1) // Python 3.12 is the default + runtimeResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select the runtime for your agent", + Choices: runtimeChoices, + SelectedIndex: &defaultIdx, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("runtime selection was cancelled") + } + return nil, fmt.Errorf("failed to prompt for runtime: %w", err) } - return nil, fmt.Errorf("failed to prompt for runtime: %w", err) + runtime = runtimeChoices[*runtimeResp.Value].Value } - runtime := runtimeChoices[*runtimeResp.Value].Value // Prompt for entry point defaultEntryPoint := "main.py" @@ -1193,41 +1103,51 @@ func promptCodeConfigurationShared(ctx context.Context, azdClient *azdext.AzdCli defaultEntryPoint = "app.py" } - entryPointResp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ - Options: &azdext.PromptOptions{ - Message: "Enter the entry point file for your agent", - DefaultValue: defaultEntryPoint, - }, - }) - if err != nil { - if exterrors.IsCancellation(err) { - return nil, exterrors.Cancelled("entry point prompt was cancelled") + var entryPoint string + if noPrompt { + entryPoint = defaultEntryPoint + } else { + entryPointResp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter the file path for the entry point of the agent", + DefaultValue: defaultEntryPoint, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("entry point prompt was cancelled") + } + return nil, fmt.Errorf("failed to prompt for entry point: %w", err) } - return nil, fmt.Errorf("failed to prompt for entry point: %w", err) + entryPoint = entryPointResp.Value } - entryPoint := entryPointResp.Value // Prompt for dependency resolution depResChoices := []*azdext.SelectChoice{ - {Label: "Remote build (server installs dependencies)", Value: "remote_build"}, - {Label: "Bundled (pre-install dependencies locally)", Value: "bundled"}, + {Label: "Remote build (dependencies installed on server during deployment)", Value: "remote_build"}, + {Label: "Bundled (dependencies pre-installed locally and included in ZIP)", Value: "bundled"}, } - depDefaultIdx := int32(0) - depResResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ - Options: &azdext.SelectOptions{ - Message: "How should dependencies be resolved?", - Choices: depResChoices, - SelectedIndex: &depDefaultIdx, - }, - }) - if err != nil { - if exterrors.IsCancellation(err) { - return nil, exterrors.Cancelled("dependency resolution selection was cancelled") + var depResolution string + if noPrompt { + depResolution = "remote_build" + } else { + depDefaultIdx := int32(0) + depResResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "How should dependencies be resolved?", + Choices: depResChoices, + SelectedIndex: &depDefaultIdx, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("dependency resolution selection was cancelled") + } + return nil, fmt.Errorf("failed to prompt for dependency resolution: %w", err) } - return nil, fmt.Errorf("failed to prompt for dependency resolution: %w", err) + depResolution = depResChoices[*depResResp.Value].Value } - depResolution := depResChoices[*depResResp.Value].Value return &agent_yaml.CodeConfiguration{ Runtime: runtime, @@ -1235,3 +1155,26 @@ func promptCodeConfigurationShared(ctx context.Context, azdClient *azdext.AzdCli DependencyResolution: &depResolution, }, nil } + +// isPythonProject returns true if the directory appears to be a Python project, +// determined by the presence of requirements.txt or any .py file. +func isPythonProject(dir string) bool { + if dir == "" { + dir = "." + } + // Check for requirements.txt + if _, err := os.Stat(filepath.Join(dir, "requirements.txt")); err == nil { + return true + } + // Check for any .py file (shallow scan) + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".py") { + return true + } + } + return false +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go index f3814138dfc..6b66602d5ed 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go @@ -106,21 +106,6 @@ type WorkflowDefinition struct { Trigger map[string]any `json:"trigger,omitempty"` } -// HostedAgentDefinition represents a hosted agent -type HostedAgentDefinition struct { - AgentDefinition - ContainerProtocolVersions []ProtocolVersionRecord `json:"container_protocol_versions"` - CPU string `json:"cpu"` - Memory string `json:"memory"` - EnvironmentVariables map[string]string `json:"environment_variables,omitempty"` -} - -// ImageBasedHostedAgentDefinition represents an image-based hosted agent -type ImageBasedHostedAgentDefinition struct { - HostedAgentDefinition - Image string `json:"image"` -} - // CodeConfigurationAPI represents the code_configuration block in the API request type CodeConfigurationAPI struct { Runtime string `json:"runtime"` @@ -128,15 +113,67 @@ type CodeConfigurationAPI struct { DependencyResolution string `json:"dependency_resolution,omitempty"` } -// CodeBasedHostedAgentDefinition represents a code-deploy hosted agent. -// Uses protocol_versions (not container_protocol_versions). -type CodeBasedHostedAgentDefinition struct { +// HostedAgentDefinition represents a hosted agent that can be either container-based +// (with Image) or code-based (with CodeConfiguration). The protocol versions JSON +// field name differs: container uses "container_protocol_versions" while code uses +// "protocol_versions". Custom marshaling handles this automatically. +type HostedAgentDefinition struct { AgentDefinition - ProtocolVersions []ProtocolVersionRecord `json:"protocol_versions"` + ProtocolVersions []ProtocolVersionRecord `json:"-"` // marshaled dynamically based on deploy mode CPU string `json:"cpu"` Memory string `json:"memory"` EnvironmentVariables map[string]string `json:"environment_variables,omitempty"` - CodeConfiguration CodeConfigurationAPI `json:"code_configuration"` + Image string `json:"image,omitempty"` // container deploy only + CodeConfiguration *CodeConfigurationAPI `json:"code_configuration,omitempty"` // code deploy only +} + +// MarshalJSON implements custom JSON marshaling for HostedAgentDefinition. +// Code deploy agents use "protocol_versions"; container agents use "container_protocol_versions". +func (d HostedAgentDefinition) MarshalJSON() ([]byte, error) { + type Alias HostedAgentDefinition + + if d.CodeConfiguration != nil { + // Code deploy: use protocol_versions + return json.Marshal(struct { + Alias + ProtocolVersions []ProtocolVersionRecord `json:"protocol_versions"` + }{ + Alias: Alias(d), + ProtocolVersions: d.ProtocolVersions, + }) + } + + // Container deploy: use container_protocol_versions + return json.Marshal(struct { + Alias + ContainerProtocolVersions []ProtocolVersionRecord `json:"container_protocol_versions"` + }{ + Alias: Alias(d), + ContainerProtocolVersions: d.ProtocolVersions, + }) +} + +// UnmarshalJSON implements custom JSON unmarshaling for HostedAgentDefinition. +// It reads protocol versions from either "protocol_versions" or "container_protocol_versions". +func (d *HostedAgentDefinition) UnmarshalJSON(data []byte) error { + type Alias HostedAgentDefinition + + var raw struct { + Alias + ProtocolVersions []ProtocolVersionRecord `json:"protocol_versions"` + ContainerProtocolVersions []ProtocolVersionRecord `json:"container_protocol_versions"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + *d = HostedAgentDefinition(raw.Alias) + if len(raw.ProtocolVersions) > 0 { + d.ProtocolVersions = raw.ProtocolVersions + } else { + d.ProtocolVersions = raw.ContainerProtocolVersions + } + return nil } // CreateAgentVersionRequest represents a request to create an agent version diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go index 01ed1ed0126..f004bcb583c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go @@ -112,7 +112,7 @@ func TestHostedAgentDefinition_RoundTrip(t *testing.T) { original := HostedAgentDefinition{ AgentDefinition: AgentDefinition{Kind: AgentKindHosted}, - ContainerProtocolVersions: []ProtocolVersionRecord{ + ProtocolVersions: []ProtocolVersionRecord{ {Protocol: AgentProtocolResponses, Version: "2024-07-01"}, }, CPU: "1.0", @@ -142,27 +142,25 @@ func TestHostedAgentDefinition_RoundTrip(t *testing.T) { if got.Kind != AgentKindHosted { t.Errorf("Kind = %q, want %q", got.Kind, AgentKindHosted) } - if len(got.ContainerProtocolVersions) != 1 || got.ContainerProtocolVersions[0].Version != "2024-07-01" { - t.Error("ContainerProtocolVersions mismatch") + if len(got.ProtocolVersions) != 1 || got.ProtocolVersions[0].Version != "2024-07-01" { + t.Error("ProtocolVersions mismatch") } if got.EnvironmentVariables["LOG_LEVEL"] != "debug" { t.Error("EnvironmentVariables mismatch") } } -func TestImageBasedHostedAgentDefinition_RoundTrip(t *testing.T) { +func TestHostedAgentDefinition_ContainerImage_RoundTrip(t *testing.T) { t.Parallel() - original := ImageBasedHostedAgentDefinition{ - HostedAgentDefinition: HostedAgentDefinition{ - AgentDefinition: AgentDefinition{Kind: AgentKindHosted}, - ContainerProtocolVersions: []ProtocolVersionRecord{ - {Protocol: AgentProtocolActivityProtocol, Version: "1.0"}, - }, - CPU: "0.5", - Memory: "1Gi", + original := HostedAgentDefinition{ + AgentDefinition: AgentDefinition{Kind: AgentKindHosted}, + ProtocolVersions: []ProtocolVersionRecord{ + {Protocol: AgentProtocolActivityProtocol, Version: "1.0"}, }, - Image: "myregistry.azurecr.io/agent:latest", + CPU: "0.5", + Memory: "1Gi", + Image: "myregistry.azurecr.io/agent:latest", } data, err := json.Marshal(original) @@ -174,8 +172,11 @@ func TestImageBasedHostedAgentDefinition_RoundTrip(t *testing.T) { if !strings.Contains(s, `"image"`) { t.Error("expected JSON to contain \"image\"") } + if !strings.Contains(s, `"container_protocol_versions"`) { + t.Error("expected JSON to contain \"container_protocol_versions\"") + } - var got ImageBasedHostedAgentDefinition + var got HostedAgentDefinition if err := json.Unmarshal(data, &got); err != nil { t.Fatalf("unmarshal: %v", err) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go index 0a750283ac3..d1050d6f3f7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go @@ -356,7 +356,7 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB } } - // Code deploy path: use CodeBasedHostedAgentDefinition + // Code deploy path if hostedAgent.CodeConfiguration != nil { entryPoint := []string{"python", hostedAgent.CodeConfiguration.EntryPoint} depRes := "" @@ -364,7 +364,7 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB depRes = *hostedAgent.CodeConfiguration.DependencyResolution } - codeDef := agent_api.CodeBasedHostedAgentDefinition{ + codeDef := agent_api.HostedAgentDefinition{ AgentDefinition: agent_api.AgentDefinition{ Kind: agent_api.AgentKindHosted, }, @@ -372,7 +372,7 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB CPU: cpu, Memory: memory, EnvironmentVariables: envVars, - CodeConfiguration: agent_api.CodeConfigurationAPI{ + CodeConfiguration: &agent_api.CodeConfigurationAPI{ Runtime: hostedAgent.CodeConfiguration.Runtime, EntryPoint: entryPoint, DependencyResolution: depRes, @@ -383,28 +383,23 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB hostedAgent.AgentEndpoint, hostedAgent.AgentCard) } - // Container/image deploy path (existing) + // Container/image deploy path if imageURL == "" { return nil, fmt.Errorf("image URL is required for hosted agents - use WithImageURL build option or specify in container.image") } - hostedDef := agent_api.HostedAgentDefinition{ + imageDef := agent_api.HostedAgentDefinition{ AgentDefinition: agent_api.AgentDefinition{ Kind: agent_api.AgentKindHosted, }, - ContainerProtocolVersions: protocolVersions, - CPU: cpu, - Memory: memory, - EnvironmentVariables: envVars, + ProtocolVersions: protocolVersions, + CPU: cpu, + Memory: memory, + EnvironmentVariables: envVars, + Image: imageURL, } - // Set the image from build configuration or container definition - imageHostedDef := agent_api.ImageBasedHostedAgentDefinition{ - HostedAgentDefinition: hostedDef, - Image: imageURL, - } - - return createAgentAPIRequest(hostedAgent.AgentDefinition, imageHostedDef, + return createAgentAPIRequest(hostedAgent.AgentDefinition, imageDef, hostedAgent.AgentEndpoint, hostedAgent.AgentCard) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go index 2ac9151f280..415e6ba3a22 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go @@ -878,9 +878,9 @@ func TestCreateHostedAgentAPIRequest_FullConfig(t *testing.T) { t.Errorf("Description mismatch") } - imgDef, ok := req.Definition.(agent_api.ImageBasedHostedAgentDefinition) + imgDef, ok := req.Definition.(agent_api.HostedAgentDefinition) if !ok { - t.Fatalf("expected ImageBasedHostedAgentDefinition, got %T", req.Definition) + t.Fatalf("expected HostedAgentDefinition, got %T", req.Definition) } if imgDef.Kind != agent_api.AgentKindHosted { t.Errorf("Kind = %q", imgDef.Kind) @@ -899,14 +899,14 @@ func TestCreateHostedAgentAPIRequest_FullConfig(t *testing.T) { } // Verify protocol versions - if len(imgDef.ContainerProtocolVersions) != 2 { - t.Fatalf("expected 2 protocol versions, got %d", len(imgDef.ContainerProtocolVersions)) + if len(imgDef.ProtocolVersions) != 2 { + t.Fatalf("expected 2 protocol versions, got %d", len(imgDef.ProtocolVersions)) } - if imgDef.ContainerProtocolVersions[0].Protocol != "responses" { - t.Errorf("protocol[0] = %q", imgDef.ContainerProtocolVersions[0].Protocol) + if imgDef.ProtocolVersions[0].Protocol != "responses" { + t.Errorf("protocol[0] = %q", imgDef.ProtocolVersions[0].Protocol) } - if imgDef.ContainerProtocolVersions[0].Version != "2.0.0" { - t.Errorf("version[0] = %q", imgDef.ContainerProtocolVersions[0].Version) + if imgDef.ProtocolVersions[0].Version != "2.0.0" { + t.Errorf("version[0] = %q", imgDef.ProtocolVersions[0].Version) } } @@ -925,15 +925,15 @@ func TestCreateHostedAgentAPIRequest_DefaultProtocols(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - imgDef := req.Definition.(agent_api.ImageBasedHostedAgentDefinition) - if len(imgDef.ContainerProtocolVersions) != 1 { - t.Fatalf("expected 1 default protocol, got %d", len(imgDef.ContainerProtocolVersions)) + imgDef := req.Definition.(agent_api.HostedAgentDefinition) + if len(imgDef.ProtocolVersions) != 1 { + t.Fatalf("expected 1 default protocol, got %d", len(imgDef.ProtocolVersions)) } - if imgDef.ContainerProtocolVersions[0].Protocol != agent_api.AgentProtocolResponses { - t.Errorf("default protocol = %q", imgDef.ContainerProtocolVersions[0].Protocol) + if imgDef.ProtocolVersions[0].Protocol != agent_api.AgentProtocolResponses { + t.Errorf("default protocol = %q", imgDef.ProtocolVersions[0].Protocol) } - if imgDef.ContainerProtocolVersions[0].Version != "1.0.0" { - t.Errorf("default version = %q", imgDef.ContainerProtocolVersions[0].Version) + if imgDef.ProtocolVersions[0].Version != "1.0.0" { + t.Errorf("default version = %q", imgDef.ProtocolVersions[0].Version) } } @@ -952,7 +952,7 @@ func TestCreateHostedAgentAPIRequest_DefaultCPUAndMemory(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - imgDef := req.Definition.(agent_api.ImageBasedHostedAgentDefinition) + imgDef := req.Definition.(agent_api.HostedAgentDefinition) if imgDef.CPU != "1" { t.Errorf("default CPU = %q, want %q", imgDef.CPU, "1") } @@ -1015,9 +1015,9 @@ func TestCreateAgentAPIRequestFromDefinition_HostedAgent(t *testing.T) { t.Errorf("Name = %q", req.Name) } - _, ok := req.Definition.(agent_api.ImageBasedHostedAgentDefinition) + _, ok := req.Definition.(agent_api.HostedAgentDefinition) if !ok { - t.Fatalf("expected ImageBasedHostedAgentDefinition, got %T", req.Definition) + t.Fatalf("expected HostedAgentDefinition, got %T", req.Definition) } } @@ -1059,7 +1059,7 @@ func TestCreateAgentAPIRequestFromDefinition_HostedWithBuildOptions(t *testing.T t.Fatalf("unexpected error: %v", err) } - imgDef := req.Definition.(agent_api.ImageBasedHostedAgentDefinition) + imgDef := req.Definition.(agent_api.HostedAgentDefinition) if imgDef.Image != "myimg:v2" { t.Errorf("Image = %q", imgDef.Image) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 854054af98f..5b83bd7c87d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -643,16 +643,24 @@ func (p *AgentServiceTargetProvider) isCodeDeployAgent() bool { return hasCodeConfig } -// deployHostedAgent deploys a container-based hosted agent to the Foundry service. -func (p *AgentServiceTargetProvider) deployHostedAgent( - ctx context.Context, +// deployPrepResult holds the common outputs from prepareDeploy, used by both +// container and code deploy paths. +type deployPrepResult struct { + resolvedEnvVars map[string]string + request *agent_api.CreateAgentRequest + protocols []agent_yaml.ProtocolVersionRecord +} + +// prepareDeploy handles the common pre-deploy logic shared by container and code +// deploy: endpoint validation, environment variable resolution, service config +// parsing, and API request building. The caller provides extra build options +// (e.g. WithImageURL for container, WithCPU/WithMemory for code). +func (p *AgentServiceTargetProvider) prepareDeploy( serviceConfig *azdext.ServiceConfig, - serviceContext *azdext.ServiceContext, - progress azdext.ProgressReporter, agentDef agent_yaml.ContainerAgent, azdEnv map[string]string, -) (*azdext.ServiceDeployResult, error) { - // Check if environment variable is set + extraOptions []agent_yaml.AgentBuildOption, +) (*deployPrepResult, error) { if azdEnv["AZURE_AI_PROJECT_ENDPOINT"] == "" { return nil, exterrors.Dependency( exterrors.CodeMissingAiProjectEndpoint, @@ -661,30 +669,11 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( ) } - progress("Deploying hosted agent") - - // Step 1: Build container image - var fullImageURL string - for _, artifact := range serviceContext.Publish { - if artifact.Kind == azdext.ArtifactKind_ARTIFACT_KIND_CONTAINER && - artifact.LocationKind == azdext.LocationKind_LOCATION_KIND_REMOTE { - fullImageURL = artifact.Location - break - } - } - if fullImageURL == "" { - return nil, exterrors.Dependency( - exterrors.CodeMissingPublishedContainer, - "published container artifact not found: no remote container artifact was found in service publish artifacts", - "run 'azd package' and 'azd publish' (or 'azd deploy') to produce container artifacts", - ) - } - fmt.Fprintf(os.Stderr, "Loaded configuration from: %s\n", p.agentDefinitionPath) fmt.Fprintf(os.Stderr, "Using endpoint: %s\n", azdEnv["AZURE_AI_PROJECT_ENDPOINT"]) fmt.Fprintf(os.Stderr, "Agent Name: %s\n", agentDef.Name) - // Step 2: Resolve environment variables from YAML using azd environment values + // Resolve environment variables from YAML using azd environment values resolvedEnvVars := make(map[string]string) if agentDef.EnvironmentVariables != nil { for _, envVar := range *agentDef.EnvironmentVariables { @@ -692,7 +681,7 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( } } - // Step 3: Create agent request with image URL and resolved environment variables + // Parse service config for container resource overrides var foundryAgentConfig *ServiceTargetAgentConfig if err := UnmarshalStruct(serviceConfig.Config, &foundryAgentConfig); err != nil { return nil, exterrors.Validation( @@ -705,24 +694,22 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( warnDeprecatedScaleSettings(serviceConfig.Config) var cpu, memory string - if foundryAgentConfig.Container != nil && foundryAgentConfig.Container.Resources != nil { + if foundryAgentConfig != nil && foundryAgentConfig.Container != nil && foundryAgentConfig.Container.Resources != nil { cpu = foundryAgentConfig.Container.Resources.Cpu memory = foundryAgentConfig.Container.Resources.Memory } - // Build options list starting with required options + // Build options: env vars + cpu/memory (if set) + caller-provided extras options := []agent_yaml.AgentBuildOption{ - agent_yaml.WithImageURL(fullImageURL), agent_yaml.WithEnvironmentVariables(resolvedEnvVars), } - - // Conditionally add CPU and memory options if they're not empty if cpu != "" { options = append(options, agent_yaml.WithCPU(cpu)) } if memory != "" { options = append(options, agent_yaml.WithMemory(memory)) } + options = append(options, extraOptions...) request, err := agent_yaml.CreateAgentAPIRequestFromDefinition(agentDef, options...) if err != nil { @@ -733,22 +720,8 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( ) } - // Set experience metadata on the request applyAgentMetadata(request) - // Display agent information - p.displayAgentInfo(request) - - // Step 4: Create agent - progress("Creating agent") - agentVersionResponse, err := p.createAgent(ctx, request, azdEnv) - if err != nil { - return nil, err - } - - // Register agent info in environment - progress("Registering agent environment variables") - // Default to "responses" protocol when none specified in agent.yaml. protocols := agentDef.Protocols if len(protocols) == 0 { @@ -757,14 +730,33 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( } } - err = p.registerAgentEnvironmentVariables(ctx, azdEnv, serviceConfig, agentVersionResponse, protocols) + return &deployPrepResult{ + resolvedEnvVars: resolvedEnvVars, + request: request, + protocols: protocols, + }, nil +} + +// finalizeDeploy handles the common post-deploy logic: registering environment +// variables and building the deploy result artifacts. +func (p *AgentServiceTargetProvider) finalizeDeploy( + ctx context.Context, + progress azdext.ProgressReporter, + serviceConfig *azdext.ServiceConfig, + azdEnv map[string]string, + agentVersion *agent_api.AgentVersionObject, + protocols []agent_yaml.ProtocolVersionRecord, +) (*azdext.ServiceDeployResult, error) { + progress("Registering agent environment variables") + + err := p.registerAgentEnvironmentVariables(ctx, azdEnv, serviceConfig, agentVersion, protocols) if err != nil { return nil, err } artifacts := p.deployArtifacts( - agentVersionResponse.Name, - agentVersionResponse.Version, + agentVersion.Name, + agentVersion.Version, azdEnv["AZURE_AI_PROJECT_ID"], azdEnv["AZURE_AI_PROJECT_ENDPOINT"], protocols, @@ -775,6 +767,54 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( }, nil } +// deployHostedAgent deploys a container-based hosted agent to the Foundry service. +func (p *AgentServiceTargetProvider) deployHostedAgent( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + progress azdext.ProgressReporter, + agentDef agent_yaml.ContainerAgent, + azdEnv map[string]string, +) (*azdext.ServiceDeployResult, error) { + progress("Deploying hosted agent") + + // Find container image URL from publish artifacts + var fullImageURL string + for _, artifact := range serviceContext.Publish { + if artifact.Kind == azdext.ArtifactKind_ARTIFACT_KIND_CONTAINER && + artifact.LocationKind == azdext.LocationKind_LOCATION_KIND_REMOTE { + fullImageURL = artifact.Location + break + } + } + if fullImageURL == "" { + return nil, exterrors.Dependency( + exterrors.CodeMissingPublishedContainer, + "published container artifact not found: no remote container artifact was found in service publish artifacts", + "run 'azd package' and 'azd publish' (or 'azd deploy') to produce container artifacts", + ) + } + + prep, err := p.prepareDeploy(serviceConfig, agentDef, azdEnv, []agent_yaml.AgentBuildOption{ + agent_yaml.WithImageURL(fullImageURL), + }) + if err != nil { + return nil, err + } + + // Display agent information + p.displayAgentInfo(prep.request) + + // Create agent + progress("Creating agent") + agentVersionResponse, err := p.createAgent(ctx, prep.request, azdEnv) + if err != nil { + return nil, err + } + + return p.finalizeDeploy(ctx, progress, serviceConfig, azdEnv, agentVersionResponse, prep.protocols) +} + // packageCodeDeploy creates a ZIP archive of the agent source code, writes it to a temp file, // and computes its SHA-256. Returns the temp file path and SHA-256 hex string. func (p *AgentServiceTargetProvider) packageCodeDeploy(serviceConfig *azdext.ServiceConfig) (string, string, error) { @@ -912,16 +952,11 @@ func (p *AgentServiceTargetProvider) deployHostedCodeAgent( agentDef agent_yaml.ContainerAgent, azdEnv map[string]string, ) (*azdext.ServiceDeployResult, error) { - if azdEnv["AZURE_AI_PROJECT_ENDPOINT"] == "" { - return nil, exterrors.Dependency( - exterrors.CodeMissingAiProjectEndpoint, - "AZURE_AI_PROJECT_ENDPOINT is required: environment variable was not found in the current azd environment", - "run 'azd provision' or connect to an existing project via 'azd ai agent init --project-id '", - ) - } - progress("Deploying hosted agent (code deploy)") + // TODO: Add region validation for code deploy — verify that the Foundry project's + // region supports code deploy before attempting the upload. + // Find the ZIP artifact from Package phase var zipPath, sha256Hex string for _, artifact := range serviceContext.Package { @@ -935,7 +970,7 @@ func (p *AgentServiceTargetProvider) deployHostedCodeAgent( return nil, exterrors.Dependency( exterrors.CodeMissingCodeZipArtifact, "code ZIP artifact not found: no code-zip artifact was found in service package artifacts", - "run 'azd deploy' to package and deploy the agent", + "run 'azd package' to produce the code ZIP artifact", ) } @@ -946,72 +981,29 @@ func (p *AgentServiceTargetProvider) deployHostedCodeAgent( // Clean up temp file defer os.Remove(zipPath) - fmt.Fprintf(os.Stderr, "Loaded configuration from: %s\n", p.agentDefinitionPath) - fmt.Fprintf(os.Stderr, "Using endpoint: %s\n", azdEnv["AZURE_AI_PROJECT_ENDPOINT"]) - fmt.Fprintf(os.Stderr, "Agent Name: %s\n", agentDef.Name) - fmt.Fprintf(os.Stderr, "Runtime: %s\n", agentDef.CodeConfiguration.Runtime) - fmt.Fprintf(os.Stderr, "Entry Point: [\"python\", \"%s\"]\n", agentDef.CodeConfiguration.EntryPoint) - depRes := "bundled" - if agentDef.CodeConfiguration.DependencyResolution != nil { - depRes = *agentDef.CodeConfiguration.DependencyResolution - } - fmt.Fprintf(os.Stderr, "Packaging: %s\n", depRes) - - // Resolve environment variables - resolvedEnvVars := make(map[string]string) - if agentDef.EnvironmentVariables != nil { - for _, envVar := range *agentDef.EnvironmentVariables { - resolvedEnvVars[envVar.Name] = p.resolveEnvironmentVariables(envVar.Value, azdEnv) - } - } - - // Parse service config for cpu/memory - var foundryAgentConfig *ServiceTargetAgentConfig - if err := UnmarshalStruct(serviceConfig.Config, &foundryAgentConfig); err != nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - fmt.Sprintf("failed to parse foundry agent config: %s", err), - "check the service configuration in azure.yaml", - ) + prep, err := p.prepareDeploy(serviceConfig, agentDef, azdEnv, nil) + if err != nil { + return nil, err } - cpu := "1" - memory := "2Gi" - if foundryAgentConfig != nil && foundryAgentConfig.Container != nil && foundryAgentConfig.Container.Resources != nil { - if foundryAgentConfig.Container.Resources.Cpu != "" { - cpu = foundryAgentConfig.Container.Resources.Cpu - } - if foundryAgentConfig.Container.Resources.Memory != "" { - memory = foundryAgentConfig.Container.Resources.Memory + if agentDef.CodeConfiguration != nil { + fmt.Fprintf(os.Stderr, "Runtime: %s\n", agentDef.CodeConfiguration.Runtime) + fmt.Fprintf(os.Stderr, "Entry Point: [\"python\", \"%s\"]\n", agentDef.CodeConfiguration.EntryPoint) + depRes := "bundled" + if agentDef.CodeConfiguration.DependencyResolution != nil { + depRes = *agentDef.CodeConfiguration.DependencyResolution } + fmt.Fprintf(os.Stderr, "Packaging: %s\n", depRes) } - // Build the API request definition - options := []agent_yaml.AgentBuildOption{ - agent_yaml.WithCPU(cpu), - agent_yaml.WithMemory(memory), - agent_yaml.WithEnvironmentVariables(resolvedEnvVars), - } - - request, err := agent_yaml.CreateAgentAPIRequestFromDefinition(agentDef, options...) - if err != nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidAgentRequest, - fmt.Sprintf("failed to create agent request from definition: %s", err), - "verify the agent.yaml definition is correct", - ) - } - - // Set experience metadata - applyAgentMetadata(request) - - fmt.Fprintf(os.Stderr, "CPU: %s Memory: %s\n", cpu, memory) + // Display agent information + p.displayAgentInfo(prep.request) // Build the metadata for multipart upload versionRequest := &agent_api.CreateAgentVersionRequest{ - Description: request.Description, - Metadata: request.Metadata, - Definition: request.Definition, + Description: prep.request.Description, + Metadata: prep.request.Metadata, + Definition: prep.request.Definition, } // Create agent client @@ -1027,8 +1019,7 @@ func (p *AgentServiceTargetProvider) deployHostedCodeAgent( if getErr != nil { // Only fall back to create on 404; propagate other errors (auth, 5xx, network) - var respErr *azcore.ResponseError - if !errors.As(getErr, &respErr) || respErr.StatusCode != http.StatusNotFound { + if respErr, ok := errors.AsType[*azcore.ResponseError](getErr); !ok || respErr.StatusCode != http.StatusNotFound { return nil, fmt.Errorf("failed to check if agent exists: %w", getErr) } // Agent doesn't exist — create @@ -1054,6 +1045,10 @@ func (p *AgentServiceTargetProvider) deployHostedCodeAgent( // Poll for status if remote build latestVersion := &agentResp.Versions.Latest + depRes := "bundled" + if agentDef.CodeConfiguration != nil && agentDef.CodeConfiguration.DependencyResolution != nil { + depRes = *agentDef.CodeConfiguration.DependencyResolution + } if depRes == "remote_build" && latestVersion.Status == "creating" { fmt.Fprintf(os.Stderr, "Waiting for remote build to complete...\n") pollTimeout := 5 * time.Minute @@ -1093,12 +1088,10 @@ func (p *AgentServiceTargetProvider) deployHostedCodeAgent( } // Patch agent-level fields (agent_endpoint, agent_card) if present. - // These are agent-level properties, not version-level, so they require - // a separate PatchAgent call after version creation. - if request.AgentEndpoint != nil || request.AgentCard != nil { + if prep.request.AgentEndpoint != nil || prep.request.AgentCard != nil { patchRequest := &agent_api.PatchAgentRequest{ - AgentEndpoint: request.AgentEndpoint, - AgentCard: request.AgentCard, + AgentEndpoint: prep.request.AgentEndpoint, + AgentCard: prep.request.AgentCard, } _, err := agentClient.PatchAgent(ctx, agentDef.Name, patchRequest, agentAPIVersion) @@ -1111,31 +1104,7 @@ func (p *AgentServiceTargetProvider) deployHostedCodeAgent( fmt.Fprintf(os.Stderr, "Agent endpoint/card updated.\n") } - // Register environment variables - progress("Registering agent environment variables") - protocols := agentDef.Protocols - if len(protocols) == 0 { - protocols = []agent_yaml.ProtocolVersionRecord{ - {Protocol: string(agent_api.AgentProtocolResponses), Version: "1.0.0"}, - } - } - - err = p.registerAgentEnvironmentVariables(ctx, azdEnv, serviceConfig, latestVersion, protocols) - if err != nil { - return nil, err - } - - artifacts := p.deployArtifacts( - latestVersion.Name, - latestVersion.Version, - azdEnv["AZURE_AI_PROJECT_ID"], - azdEnv["AZURE_AI_PROJECT_ENDPOINT"], - protocols, - ) - - return &azdext.ServiceDeployResult{ - Artifacts: artifacts, - }, nil + return p.finalizeDeploy(ctx, progress, serviceConfig, azdEnv, latestVersion, prep.protocols) } // deployArtifacts constructs the artifacts list for deployment results. @@ -1347,11 +1316,13 @@ func (p *AgentServiceTargetProvider) displayAgentInfo(request *agent_api.CreateA fmt.Fprintf(os.Stderr, "Description: %s\n", description) // Display agent-specific information - if imageHostedDef, ok := request.Definition.(agent_api.ImageBasedHostedAgentDefinition); ok { - fmt.Fprintf(os.Stderr, "Image: %s\n", imageHostedDef.Image) - fmt.Fprintf(os.Stderr, "CPU: %s\n", imageHostedDef.CPU) - fmt.Fprintf(os.Stderr, "Memory: %s\n", imageHostedDef.Memory) - fmt.Fprintf(os.Stderr, "Protocol Versions: %+v\n", imageHostedDef.ContainerProtocolVersions) + if hostedDef, ok := request.Definition.(agent_api.HostedAgentDefinition); ok { + if hostedDef.Image != "" { + fmt.Fprintf(os.Stderr, "Image: %s\n", hostedDef.Image) + } + fmt.Fprintf(os.Stderr, "CPU: %s\n", hostedDef.CPU) + fmt.Fprintf(os.Stderr, "Memory: %s\n", hostedDef.Memory) + fmt.Fprintf(os.Stderr, "Protocol Versions: %+v\n", hostedDef.ProtocolVersions) } fmt.Fprintln(os.Stderr) } From 10d08a8dd37a11758e445d8b1b59836982697e18 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Wed, 13 May 2026 11:35:42 +0800 Subject: [PATCH 13/18] fix(agents): add TODO for streaming ZIP in zipDeployRequest (C2) --- .../internal/pkg/agents/agent_api/operations.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go index 95286167b4b..acb7950f3d7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go @@ -392,6 +392,9 @@ func (c *AgentClient) UpdateAgentFromZip( } // zipDeployRequest performs the multipart ZIP deploy request (shared by create and update). +// TODO: Stream the ZIP file directly from disk instead of buffering zipData []byte in memory +// to reduce memory usage for large agent projects. The 250MB ZIP limit makes OOM unlikely +// in practice, but streaming would halve peak memory consumption. func (c *AgentClient) zipDeployRequest( ctx context.Context, reqURL string, From 7ee295effc2682515f6a774095f55f450496cbe1 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Wed, 13 May 2026 12:18:43 +0800 Subject: [PATCH 14/18] fix: remove unused isContainerAgent method and accidental files --- .../internal/project/service_target_agent.go | 34 ------------------- 1 file changed, 34 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 23677d25601..e0260c94119 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -727,40 +727,6 @@ func (p *AgentServiceTargetProvider) shouldUsePreBuiltImage( return resp.Value != nil && choices[*resp.Value].Value == "prebuilt", nil } -// isContainerAgent checks if the agent.yaml describes a container-based hosted agent. -func (p *AgentServiceTargetProvider) isContainerAgent() bool { - data, err := os.ReadFile(p.agentDefinitionPath) - if err != nil { - return false - } - - err = agent_yaml.ValidateAgentDefinition(data) - if err != nil { - return false - } - - var genericTemplate map[string]any - if err := yaml.Unmarshal(data, &genericTemplate); err != nil { - return false - } - - kind, ok := genericTemplate["kind"].(string) - if !ok { - return false - } - - if kind != string(agent_yaml.AgentKindHosted) { - return false - } - - // If code_configuration is present, this is a code deploy agent (not container) - if _, hasCodeConfig := genericTemplate["code_configuration"]; hasCodeConfig { - return false - } - - return true -} - // isCodeDeployAgent returns true if the agent.yaml has code_configuration (code deploy mode) func (p *AgentServiceTargetProvider) isCodeDeployAgent() bool { data, err := os.ReadFile(p.agentDefinitionPath) From bae1bd6ddb4736f4aed5622dd9d568b759abe735 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Wed, 13 May 2026 12:19:02 +0800 Subject: [PATCH 15/18] chore: remove accidentally committed files --- ...adazdcliprcomments8146pr-body-original.md" | 274 --- ...osted-agentzipuploadazdclitestssta.go.tmp" | 1512 ----------------- 2 files changed, 1786 deletions(-) delete mode 100644 "D\357\200\272jwshareadc-hosted-agentzipuploadazdcliprcomments8146pr-body-original.md" delete mode 100644 "D\357\200\272jwshareadc-hosted-agentzipuploadazdclitestssta.go.tmp" diff --git "a/D\357\200\272jwshareadc-hosted-agentzipuploadazdcliprcomments8146pr-body-original.md" "b/D\357\200\272jwshareadc-hosted-agentzipuploadazdcliprcomments8146pr-body-original.md" deleted file mode 100644 index 06baf36ba17..00000000000 --- "a/D\357\200\272jwshareadc-hosted-agentzipuploadazdcliprcomments8146pr-body-original.md" +++ /dev/null @@ -1,274 +0,0 @@ -## Summary - -Adds **code deploy (ZIP upload)** support for hosted agents as an alternative to container-based deployment. This enables deploying Python agent source code directly without requiring Docker/ACR. - -### Init Flow: 4 Supported Paths - -The deploy mode prompt appears in all init flows. Users can re-run `azd ai agent init` at any time to switch between code and container modes. Template files (Dockerfile, etc.) are never deleted. - -#### 1. New Project (Template) -> Container Deploy -``` -azd ai agent init # empty dir -> template selection --> Language -> Python --> Template -> select template --> Deploy mode -> Container deploy (Docker image) --> Model -> Use existing model deployment(s) --> Subscription -> select --> Foundry Project -> select --> App Insights -> optional --> Model deployment -> select --> ACR -> select --> Container resources -> select --> Writes agent.yaml (no code_configuration), azure.yaml (with docker block) -``` - -#### 2. New Project (Template) -> Code Deploy -``` -azd ai agent init # empty dir -> template selection --> Language -> Python --> Template -> select template --> Deploy mode -> Code deploy (ZIP upload) --> Runtime -> Python 3.12 --> Entry point -> main.py --> Dependency resolution -> Remote build / Bundled --> Model -> Use existing model deployment(s) --> Subscription -> select --> Foundry Project -> select --> App Insights -> optional --> Model deployment -> select --> Container resources -> select (for local run compatibility) --> Writes agent.yaml (with code_configuration), azure.yaml (no docker block) --> Template Dockerfile preserved (unused) -``` - -#### 3. Existing Code -> Container Deploy -``` -azd ai agent init # non-empty dir with code --> "Use the code in the current directory" --> Deploy mode -> Container deploy (Docker image) --> Protocols / agent name / description --> Model -> Use existing model deployment(s) --> Subscription -> select --> Foundry Project -> select --> ACR -> select --> Writes agent.yaml (no code_configuration), azure.yaml (with docker block) -``` - -#### 4. Existing Code -> Code Deploy -``` -azd ai agent init # non-empty dir with code --> "Use the code in the current directory" --> Deploy mode -> Code deploy (ZIP upload) --> Runtime -> Python 3.12 --> Entry point -> main.py --> Dependency resolution -> Remote build / Bundled --> Protocols / agent name / description --> Model -> Use existing model deployment(s) --> Subscription -> select --> Foundry Project -> select --> Writes agent.yaml (with code_configuration), azure.yaml (no docker block) -``` - -#### 5. Re-init with Existing Manifest (mode switch) -``` -azd ai agent init # dir with agent.manifest.yaml --> "An existing agent manifest was found at agent.manifest.yaml. Use it?" -> Yes --> Deploy mode -> Code deploy (ZIP upload) / Container deploy --> Runtime -> Python 3.12 (code deploy only) --> Entry point -> main.py (code deploy only) --> Dependency resolution -> Remote build / Bundled (code deploy only) --> Model -> reuses environment values --> App Insights -> optional --> Model deployment -> select --> Container resources -> select -``` - -### Deploy Path Changes - -- **Deploy path** (`azd deploy`): New `packageCodeDeploy()` creates a ZIP archive of agent source, `deployHostedCodeAgent()` uploads via multipart form-data POST with SHA-256 verification. Auto-detected from `code_configuration` in `agent.yaml`. -- **API integration**: Uses `Foundry-Features: CodeAgents=V1Preview,HostedAgents=V1Preview` header, `x-ms-code-zip-sha256` for integrity, `dependency_resolution` field (string enum). -- **azure.yaml**: Code deploy uses `language: python` (no docker block), but still includes `container.resources` and `startupCommand` for `azd ai agent run` compatibility. - -### Files Modified - -| File | Change | -|------|--------| -| `init_from_code.go` | Deploy mode prompt, `promptCodeConfiguration()`, updated `addToProject()` path resolution | -| `init.go` | Deploy mode prompt in template flow, skip ACR for code deploy, fixed `addToProject()` path resolution for subdirectory re-init | -| `init_foundry_resources_helpers.go` | `configureFoundryProjectEnv` / `selectFoundryProject` accept `skipACR` param | -| `codes.go` | Added `CodeAgentCreateFailed`, `CodeMissingCodeZipArtifact` error codes | -| `models.go` | Added `CodeConfigurationAPI`, `CodeBasedHostedAgentDefinition` structs | -| `operations.go` | Added `CreateAgentFromZip`, `UpdateAgentFromZip`, `zipDeployRequest` | -| `operations_test.go` | 2 tests for zip deploy request multipart format + headers | -| `map.go` | Branched `CreateHostedAgentAPIRequest` for code deploy | -| `yaml.go` | Added `CodeConfiguration` struct | -| `service_target_agent.go` | `isCodeDeployAgent()`, `packageCodeDeploy()`, `deployHostedCodeAgent()` | -| `cspell.yaml` | Added `mypy` to words list | - ---- - -## How to Build - -```bash -cd cli/azd/extensions/azure.ai.agents -go build ./... -go vet ./... -azd x build -``` - ---- - -## Manual Test Steps - -### Prerequisites -- Azure subscription with a Foundry project -- A model deployment (e.g. `gpt-4o`) in your Foundry project -- `azd` CLI installed, logged in (`azd auth login`) -- Build the extension: `azd x build --cwd /cli/azd/extensions/azure.ai.agents` - -### Test 1: Code Deploy with Remote Build - -```powershell -# 1. Create a fresh test directory -mkdir test-code-deploy -cd test-code-deploy - -# 2. Init (interactive) -azd ai agent init -# Prompts (expected order): -# 1. Language -> Python -# 2. Template -> Hello World agent (Invocations, without a framework, Python) -# 3. Deploy mode -> Code deploy (ZIP upload) -# 4. Runtime -> Python 3.12 -# 5. Entry point -> main.py -# 6. Dependency resolution -> Remote build (server installs dependencies) -# 7. Model -> Use existing model deployment(s) from a Foundry project -# 8. Subscription -> -# 9. Foundry Project -> / -# 10. App Insights -> leave blank (press Enter) -# 11. Model deployment -> -# 9. Container resources -> select - -# 7. Verify agent.yaml shows bundled -Get-Content agent.yaml | Select-String "dependency_resolution" -# Expect: dependency_resolution: bundled - -# 8. Verify azure.yaml project path (bug fix validation) -cd ..\.. -Get-Content azure.yaml | Select-String "project:" -# Expect: project: src/hello-world-python-invocations (NOT "project: .") - -# 9. Install dependencies for Linux into source directory -cd src\hello-world-python-invocations -pip install -r requirements.txt ` - -t . ` - --platform manylinux_2_17_x86_64 ` - --platform linux_x86_64 ` - --platform any ` - --python-version 3.12 ` - --implementation cp ` - --only-binary=:all: ` - --upgrade - -# 10. Deploy (bundled) -cd ..\.. -azd deploy hello-world-python-invocations -# Expect: "Packaging code" -> "Creating agent" -> done (no "Waiting for remote build") - -# 11. Verify version incremented & Invoke -$TOKEN = (az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) - -# Check version (expect version: 2, dependency_resolution: bundled) -curl.exe -s "$EP/agents/$AGENT`?api-version=2025-11-15-preview" -H "Authorization: Bearer $TOKEN" | python -m json.tool - -# Invoke (wait ~30s for new version to activate) -Start-Sleep -Seconds 30 -curl.exe -s -N -X POST "$EP/agents/$AGENT/endpoint/protocols/invocations`?api-version=2025-11-15-preview" ` - -H "Authorization: Bearer $TOKEN" ` - -H "Content-Type: application/json" ` - -d '{"message":"hello bundled mode"}' -# Expect: streaming SSE response with model reply - -# 12. Cleanup (optional) -curl.exe -s -X DELETE "$EP/agents/$AGENT`?api-version=2025-11-15-preview" -H "Authorization: Bearer $TOKEN" -``` - ---- - -## Test Results - -All configurations passed (deploy + invoke verified): - -| # | Protocol | Package Mode | Deploy | Invoke | -|---|----------|-------------|:---:|:---:| -| 1 | invocations | remote_build | PASS | PASS (SSE) | -| 2 | invocations | bundled | PASS | PASS (SSE) | -| 3 | responses | remote_build | PASS | PASS (JSON) | -| 4 | responses | bundled | PASS | PASS (JSON) | - ---- - -## Notes - -- **Auto-detection**: `azd deploy` reads `agent.yaml` -- if `code_configuration` is present, code deploy; otherwise container deploy. No flags needed. -- **No impact on container deploy**: All container deploy paths are unchanged. The `skipACR` parameter defaults to `false` at all existing call sites. -- **Mode switching**: Re-run `azd ai agent init` to switch modes. Template files (Dockerfile, .dockerignore) are never deleted. -- **`--no-prompt` defaults**: Deploy mode -> container (backward compatible). Runtime -> `python_3_12`. Entry point -> `main.py`. Dependency resolution -> `remote_build`. -- **Known issue (pre-existing)**: The `postdeploy` hook looks up agent by azure.yaml service name instead of agent.yaml name, causing a 404 after successful deploy. Not related to this PR. - ---- - -## Related - -- Spec: [Azure/foundrysdk-specs#164](https://github.com/Azure/foundrysdk-specs/pull/164) -- CLI spec for code deployment - -Fixes #7430 - diff --git "a/D\357\200\272jwshareadc-hosted-agentzipuploadazdclitestssta.go.tmp" "b/D\357\200\272jwshareadc-hosted-agentzipuploadazdclitestssta.go.tmp" deleted file mode 100644 index 854054af98f..00000000000 --- "a/D\357\200\272jwshareadc-hosted-agentzipuploadazdclitestssta.go.tmp" +++ /dev/null @@ -1,1512 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package project - -import ( - "archive/zip" - "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "errors" - "fmt" - "io" - "io/fs" - "net/http" - "os" - "path/filepath" - "strings" - "time" - - "azureaiagent/internal/exterrors" - "azureaiagent/internal/pkg/agents/agent_api" - "azureaiagent/internal/pkg/agents/agent_yaml" - "azureaiagent/internal/pkg/azure" - - "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" - "github.com/Azure/azure-sdk-for-go/sdk/azidentity" - "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/azure/azure-dev/cli/azd/pkg/output" - "github.com/braydonk/yaml" - "github.com/drone/envsubst" - "github.com/fatih/color" - "github.com/google/uuid" - "google.golang.org/protobuf/types/known/structpb" -) - -// Reference implementation - -// agentAPIVersion is the API version used for agent endpoint invocation URLs. -const agentAPIVersion = "2025-11-15-preview" - -// displayableProtocolEntry defines a protocol that produces user-visible invocation endpoints. -type displayableProtocolEntry struct { - Protocol agent_api.AgentProtocol - URLPath string // path suffix in the invocation URL - EnvSuffix string // suffix used in AGENT_{KEY}_{SUFFIX}_ENDPOINT env vars -} - -// displayableProtocols is the single source of truth for protocols that produce -// user-facing invocation endpoints and env vars. -var displayableProtocols = []displayableProtocolEntry{ - {Protocol: agent_api.AgentProtocolResponses, URLPath: "openai/responses", EnvSuffix: "RESPONSES"}, - {Protocol: agent_api.AgentProtocolInvocations, URLPath: "invocations", EnvSuffix: "INVOCATIONS"}, -} - -// ProtocolEnvSuffix pairs a user-facing label with the env var suffix -// used in AGENT_{KEY}_{SUFFIX}_ENDPOINT variables. -type ProtocolEnvSuffix struct { - Label string // e.g. "Responses" - Suffix string // e.g. "RESPONSES" -} - -// DisplayableProtocolEnvSuffixes returns the label/suffix pairs for all -// displayable protocols. This is the single source of truth shared by -// deployment (registerAgentEnvironmentVariables) and the show command. -func DisplayableProtocolEnvSuffixes() []ProtocolEnvSuffix { - result := make([]ProtocolEnvSuffix, len(displayableProtocols)) - for i, dp := range displayableProtocols { - result[i] = ProtocolEnvSuffix{ - Label: string(dp.Protocol), - Suffix: dp.EnvSuffix, - } - } - return result -} - -// Ensure AgentServiceTargetProvider implements ServiceTargetProvider interface -var _ azdext.ServiceTargetProvider = &AgentServiceTargetProvider{} - -// AgentServiceTargetProvider is a minimal implementation of ServiceTargetProvider for demonstration -type AgentServiceTargetProvider struct { - azdClient *azdext.AzdClient - serviceConfig *azdext.ServiceConfig - agentDefinitionPath string - credential *azidentity.AzureDeveloperCLICredential - tenantId string - env *azdext.Environment - foundryProject *arm.ResourceID -} - -// NewAgentServiceTargetProvider creates a new AgentServiceTargetProvider instance -func NewAgentServiceTargetProvider(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { - return &AgentServiceTargetProvider{ - azdClient: azdClient, - } -} - -// Initialize initializes the service target by looking for the agent definition file -func (p *AgentServiceTargetProvider) Initialize(ctx context.Context, serviceConfig *azdext.ServiceConfig) error { - if p.agentDefinitionPath != "" { - // Already initialized - return nil - } - - p.serviceConfig = serviceConfig - - proj, err := p.azdClient.Project().Get(ctx, nil) - if err != nil { - return exterrors.Dependency( - exterrors.CodeProjectNotFound, - fmt.Sprintf("failed to get project: %s", err), - "run 'azd init' to initialize your project", - ) - } - servicePath := serviceConfig.RelativePath - fullPath := filepath.Join(proj.Project.Path, servicePath) - - // Get and store environment - azdEnvClient := p.azdClient.Environment() - currEnv, err := azdEnvClient.GetCurrent(ctx, nil) - if err != nil { - return exterrors.Dependency( - exterrors.CodeEnvironmentNotFound, - fmt.Sprintf("failed to get current environment: %s", err), - "run 'azd env new' to create an environment", - ) - } - p.env = currEnv.Environment - - // Get subscription ID from environment - resp, err := azdEnvClient.GetValue(ctx, &azdext.GetEnvRequest{ - EnvName: p.env.Name, - Key: "AZURE_SUBSCRIPTION_ID", - }) - if err != nil { - return fmt.Errorf("failed to get AZURE_SUBSCRIPTION_ID: %w", err) - } - - subscriptionId := resp.Value - if subscriptionId == "" { - return exterrors.Dependency( - exterrors.CodeMissingAzureSubscription, - "AZURE_SUBSCRIPTION_ID is required: environment variable was not found in the current azd environment", - "run 'azd env get-values' to verify environment values, or initialize/project-bind "+ - "with 'azd ai agent init --project-id ...'", - ) - } - - // Get the tenant ID - tenantResponse, err := p.azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{ - SubscriptionId: subscriptionId, - }) - if err != nil { - return exterrors.Auth( - exterrors.CodeTenantLookupFailed, - fmt.Sprintf("failed to get tenant ID for subscription %s: %s", subscriptionId, err), - "verify your Azure login with 'azd auth login' and that you have access to this subscription", - ) - } - p.tenantId = tenantResponse.TenantId - - // Create Azure credential - cred, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ - TenantID: p.tenantId, - AdditionallyAllowedTenants: []string{"*"}, - }) - if err != nil { - return exterrors.Auth( - exterrors.CodeCredentialCreationFailed, - fmt.Sprintf("failed to create Azure credential: %s", err), - "run 'azd auth login' to authenticate", - ) - } - p.credential = cred - - fmt.Fprintf(os.Stderr, "Project path: %s, Service path: %s\n", proj.Project.Path, fullPath) - - // Check if user has specified agent definition path via environment variable - if envPath := os.Getenv("AGENT_DEFINITION_PATH"); envPath != "" { - // Verify the file exists and has correct extension - //nolint:gosec // env path is an explicit user override; existence check is intentional - if _, err := os.Stat(envPath); os.IsNotExist(err) { - return exterrors.Validation( - exterrors.CodeAgentDefinitionNotFound, - fmt.Sprintf("agent definition file specified in AGENT_DEFINITION_PATH does not exist: %s", envPath), - "verify the path set in AGENT_DEFINITION_PATH points to a valid agent.yaml file", - ) - } - - ext := strings.ToLower(filepath.Ext(envPath)) - if ext != ".yaml" && ext != ".yml" { - return exterrors.Validation( - exterrors.CodeAgentDefinitionNotFound, - fmt.Sprintf("agent definition file must be a YAML file (.yaml or .yml), got: %s", envPath), - "provide a file with .yaml or .yml extension", - ) - } - - p.agentDefinitionPath = envPath - fmt.Printf("Using agent definition from environment variable: %s\n", color.New(color.FgHiGreen).Sprint(envPath)) - return nil - } - - // Look for agent.yaml or agent.yml in the service directory root - agentYamlPath := filepath.Join(fullPath, "agent.yaml") - agentYmlPath := filepath.Join(fullPath, "agent.yml") - - if _, err := os.Stat(agentYamlPath); err == nil { - p.agentDefinitionPath = agentYamlPath - fmt.Printf("Using agent definition: %s\n", color.New(color.FgHiGreen).Sprint(agentYamlPath)) - return nil - } - - if _, err := os.Stat(agentYmlPath); err == nil { - p.agentDefinitionPath = agentYmlPath - fmt.Printf("Using agent definition: %s\n", color.New(color.FgHiGreen).Sprint(agentYmlPath)) - return nil - } - - return exterrors.Dependency( - exterrors.CodeAgentDefinitionNotFound, - fmt.Sprintf("agent definition file not found: no agent.yaml or agent.yml found in %s", fullPath), - "add an agent.yaml/agent.yml file to the service directory or set AGENT_DEFINITION_PATH", - ) -} - -// getServiceKey converts a service name into a standardized environment variable key format -func (p *AgentServiceTargetProvider) getServiceKey(serviceName string) string { - serviceKey := strings.ReplaceAll(serviceName, " ", "_") - serviceKey = strings.ReplaceAll(serviceKey, "-", "_") - return strings.ToUpper(serviceKey) -} - -// Endpoints returns endpoints exposed by the agent service -func (p *AgentServiceTargetProvider) Endpoints( - ctx context.Context, - serviceConfig *azdext.ServiceConfig, - targetResource *azdext.TargetResource, -) ([]string, error) { - // Get all environment values - resp, err := p.azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ - Name: p.env.Name, - }) - if err != nil { - return nil, exterrors.Dependency( - exterrors.CodeEnvironmentValuesFailed, - fmt.Sprintf("failed to get environment values: %s", err), - "run 'azd env get-values' to verify environment state", - ) - } - - azdEnv := make(map[string]string, len(resp.KeyValues)) - for _, kval := range resp.KeyValues { - azdEnv[kval.Key] = kval.Value - } - - // Check if required environment variables are set - if azdEnv["AZURE_AI_PROJECT_ENDPOINT"] == "" { - return nil, exterrors.Dependency( - exterrors.CodeMissingAiProjectEndpoint, - "AZURE_AI_PROJECT_ENDPOINT is required: environment variable was not found in the current azd environment", - "run 'azd provision' or connect to an existing project via 'azd ai agent init --project-id '", - ) - } - - serviceKey := p.getServiceKey(serviceConfig.Name) - agentNameKey := fmt.Sprintf("AGENT_%s_NAME", serviceKey) - agentVersionKey := fmt.Sprintf("AGENT_%s_VERSION", serviceKey) - - if azdEnv[agentNameKey] == "" || azdEnv[agentVersionKey] == "" { - return nil, exterrors.Dependency( - exterrors.CodeMissingAgentEnvVars, - fmt.Sprintf("%s and %s environment variables are required", agentNameKey, agentVersionKey), - "run 'azd deploy' to deploy the agent and set these variables", - ) - } - - // Collect per-protocol endpoint env vars - var endpoints []string - for _, dp := range displayableProtocols { - key := fmt.Sprintf("AGENT_%s_%s_ENDPOINT", serviceKey, dp.EnvSuffix) - if val := azdEnv[key]; val != "" { - endpoints = append(endpoints, val) - } - } - - if len(endpoints) == 0 { - return nil, exterrors.Dependency( - exterrors.CodeMissingAgentEnvVars, - fmt.Sprintf("no agent endpoint variables found for service %s", serviceKey), - "run 'azd deploy' to deploy the agent and set these variables", - ) - } - - return endpoints, nil -} - -// GetTargetResource returns a custom target resource for the agent service -func (p *AgentServiceTargetProvider) GetTargetResource( - ctx context.Context, - subscriptionId string, - serviceConfig *azdext.ServiceConfig, - defaultResolver func() (*azdext.TargetResource, error), -) (*azdext.TargetResource, error) { - // Ensure Foundry project is loaded - if err := p.ensureFoundryProject(ctx); err != nil { - return nil, err - } - - // Extract account name from parent resource ID - if p.foundryProject.Parent == nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidFoundryResourceId, - "invalid resource ID: missing parent account", - "verify the AZURE_AI_PROJECT_ID is a valid Microsoft Foundry project resource ID", - ) - } - - accountName := p.foundryProject.Parent.Name - projectName := p.foundryProject.Name - - // Create Cognitive Services Projects client - projectsClient, err := armcognitiveservices.NewProjectsClient( - p.foundryProject.SubscriptionID, p.credential, azure.NewArmClientOptions()) - if err != nil { - return nil, exterrors.Internal( - exterrors.CodeCognitiveServicesClientFailed, - fmt.Sprintf("failed to create Cognitive Services Projects client: %s", err)) - } - - // Get the Microsoft Foundry project - projectResp, err := projectsClient.Get(ctx, p.foundryProject.ResourceGroupName, accountName, projectName, nil) - if err != nil { - return nil, exterrors.ServiceFromAzure(err, exterrors.OpGetFoundryProject) - } - - // Construct the target resource - targetResource := &azdext.TargetResource{ - SubscriptionId: p.foundryProject.SubscriptionID, - ResourceGroupName: p.foundryProject.ResourceGroupName, - ResourceName: projectName, - ResourceType: "Microsoft.CognitiveServices/accounts/projects", - Metadata: map[string]string{ - "accountName": accountName, - "projectName": projectName, - }, - } - - // Add location if available - if projectResp.Location != nil { - targetResource.Metadata["location"] = *projectResp.Location - } - - return targetResource, nil -} - -// Package performs packaging for the agent service -func (p *AgentServiceTargetProvider) Package( - ctx context.Context, - serviceConfig *azdext.ServiceConfig, - serviceContext *azdext.ServiceContext, - progress azdext.ProgressReporter, -) (*azdext.ServicePackageResult, error) { - // Code deploy: ZIP the source directory - if p.isCodeDeployAgent() { - progress("Packaging code") - zipPath, sha256Hex, err := p.packageCodeDeploy(serviceConfig) - if err != nil { - return nil, exterrors.Internal(exterrors.OpContainerPackage, fmt.Sprintf("code packaging failed: %s", err)) - } - - return &azdext.ServicePackageResult{ - Artifacts: []*azdext.Artifact{ - { - Kind: azdext.ArtifactKind_ARTIFACT_KIND_ARCHIVE, - Location: zipPath, - LocationKind: azdext.LocationKind_LOCATION_KIND_LOCAL, - Metadata: map[string]string{ - "type": "code-zip", - "sha256": sha256Hex, - }, - }, - }, - }, nil - } - - if !p.isContainerAgent() { - return &azdext.ServicePackageResult{}, nil - } - - var packageArtifact *azdext.Artifact - var newArtifacts []*azdext.Artifact - - progress("Packaging container") - for _, artifact := range serviceContext.Package { - if artifact.Kind == azdext.ArtifactKind_ARTIFACT_KIND_CONTAINER { - packageArtifact = artifact - break - } - } - - if packageArtifact == nil { - var buildArtifact *azdext.Artifact - for _, artifact := range serviceContext.Build { - if artifact.Kind == azdext.ArtifactKind_ARTIFACT_KIND_CONTAINER { - buildArtifact = artifact - break - } - } - - if buildArtifact == nil { - buildResponse, err := p.azdClient. - Container(). - Build(ctx, &azdext.ContainerBuildRequest{ - ServiceName: serviceConfig.Name, - ServiceContext: serviceContext, - }) - if err != nil { - return nil, exterrors.Internal(exterrors.OpContainerBuild, fmt.Sprintf("container build failed: %s", err)) - } - - serviceContext.Build = append(serviceContext.Build, buildResponse.Result.Artifacts...) - } - - packageResponse, err := p.azdClient. - Container(). - Package(ctx, &azdext.ContainerPackageRequest{ - ServiceName: serviceConfig.Name, - ServiceContext: serviceContext, - }) - if err != nil { - return nil, exterrors.Internal(exterrors.OpContainerPackage, fmt.Sprintf("container package failed: %s", err)) - } - - newArtifacts = append(newArtifacts, packageResponse.Result.Artifacts...) - } - - return &azdext.ServicePackageResult{ - Artifacts: newArtifacts, - }, nil -} - -// Publish performs the publish operation for the agent service -func (p *AgentServiceTargetProvider) Publish( - ctx context.Context, - serviceConfig *azdext.ServiceConfig, - serviceContext *azdext.ServiceContext, - targetResource *azdext.TargetResource, - publishOptions *azdext.PublishOptions, - progress azdext.ProgressReporter, -) (*azdext.ServicePublishResult, error) { - // Code deploy skips Publish (no ACR needed) - if p.isCodeDeployAgent() { - return &azdext.ServicePublishResult{}, nil - } - - if !p.isContainerAgent() { - return &azdext.ServicePublishResult{}, nil - } - - progress("Publishing container") - publishResponse, err := p.azdClient. - Container(). - Publish(ctx, &azdext.ContainerPublishRequest{ - ServiceName: serviceConfig.Name, - ServiceContext: serviceContext, - }) - - if err != nil { - return nil, exterrors.Internal(exterrors.OpContainerPublish, fmt.Sprintf("container publish failed: %s", err)) - } - - return &azdext.ServicePublishResult{ - Artifacts: publishResponse.Result.Artifacts, - }, nil -} - -// Deploy performs the deployment operation for the agent service -func (p *AgentServiceTargetProvider) Deploy( - ctx context.Context, - serviceConfig *azdext.ServiceConfig, - serviceContext *azdext.ServiceContext, - targetResource *azdext.TargetResource, - progress azdext.ProgressReporter, -) (*azdext.ServiceDeployResult, error) { - // Ensure Foundry project is loaded - if err := p.ensureFoundryProject(ctx); err != nil { - return nil, err - } - - // Get environment variables from azd - resp, err := p.azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ - Name: p.env.Name, - }) - if err != nil { - return nil, exterrors.Dependency( - exterrors.CodeEnvironmentValuesFailed, - fmt.Sprintf("failed to get environment values: %s", err), - "run 'azd env get-values' to verify environment state", - ) - } - - azdEnv := make(map[string]string, len(resp.KeyValues)) - for _, kval := range resp.KeyValues { - azdEnv[kval.Key] = kval.Value - } - - var serviceTargetConfig *ServiceTargetAgentConfig - if err := UnmarshalStruct(serviceConfig.Config, &serviceTargetConfig); err != nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidServiceConfig, - fmt.Sprintf("failed to parse service target config: %s", err), - "check the service configuration in azure.yaml", - ) - } - - if serviceTargetConfig != nil { - fmt.Println("Loaded custom service target configuration") - } - - warnDeprecatedScaleSettings(serviceConfig.Config) - - // Load and validate the agent manifest - data, err := os.ReadFile(p.agentDefinitionPath) - if err != nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - fmt.Sprintf("failed to read agent manifest file: %s", err), - "verify the agent.yaml file exists and is readable", - ) - } - - err = agent_yaml.ValidateAgentDefinition(data) - if err != nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - fmt.Sprintf("agent.yaml is not valid: %s", err), - "fix the agent.yaml file according to the schema", - ) - } - - var genericTemplate map[string]any - if err := yaml.Unmarshal(data, &genericTemplate); err != nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - fmt.Sprintf("YAML content is not valid for deploy: %s", err), - "verify the agent.yaml has valid YAML syntax", - ) - } - - kind, ok := genericTemplate["kind"].(string) - if !ok { - return nil, exterrors.Validation( - exterrors.CodeMissingAgentKind, - "kind field is missing or not a valid string in agent.yaml", - "add a valid 'kind' field (e.g., 'hosted') to agent.yaml", - ) - } - - switch kind { - case string(agent_yaml.AgentKindHosted): - var agentDef agent_yaml.ContainerAgent - if err := yaml.Unmarshal(data, &agentDef); err != nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - fmt.Sprintf("YAML content is not valid for hosted agent deploy: %s", err), - "fix the agent.yaml to match the hosted agent schema", - ) - } - // Branch: code deploy vs container deploy - if agentDef.CodeConfiguration != nil { - return p.deployHostedCodeAgent(ctx, serviceConfig, serviceContext, progress, agentDef, azdEnv) - } - return p.deployHostedAgent(ctx, serviceConfig, serviceContext, progress, agentDef, azdEnv) - default: - return nil, exterrors.Validation( - exterrors.CodeUnsupportedAgentKind, - fmt.Sprintf("unsupported agent kind: %s", kind), - "use a supported kind: 'hosted'", - ) - } -} - -func (p *AgentServiceTargetProvider) isContainerAgent() bool { - // Load and validate the agent manifest - data, err := os.ReadFile(p.agentDefinitionPath) - if err != nil { - return false - } - - err = agent_yaml.ValidateAgentDefinition(data) - if err != nil { - return false - } - - var genericTemplate map[string]any - if err := yaml.Unmarshal(data, &genericTemplate); err != nil { - return false - } - - kind, ok := genericTemplate["kind"].(string) - if !ok { - return false - } - - if kind != string(agent_yaml.AgentKindHosted) { - return false - } - - // If code_configuration is present, this is a code deploy agent (not container) - if _, hasCodeConfig := genericTemplate["code_configuration"]; hasCodeConfig { - return false - } - - return true -} - -// isCodeDeployAgent returns true if the agent.yaml has code_configuration (code deploy mode) -func (p *AgentServiceTargetProvider) isCodeDeployAgent() bool { - data, err := os.ReadFile(p.agentDefinitionPath) - if err != nil { - return false - } - - var genericTemplate map[string]any - if err := yaml.Unmarshal(data, &genericTemplate); err != nil { - return false - } - - kind, ok := genericTemplate["kind"].(string) - if !ok { - return false - } - - if kind != string(agent_yaml.AgentKindHosted) { - return false - } - - _, hasCodeConfig := genericTemplate["code_configuration"] - return hasCodeConfig -} - -// deployHostedAgent deploys a container-based hosted agent to the Foundry service. -func (p *AgentServiceTargetProvider) deployHostedAgent( - ctx context.Context, - serviceConfig *azdext.ServiceConfig, - serviceContext *azdext.ServiceContext, - progress azdext.ProgressReporter, - agentDef agent_yaml.ContainerAgent, - azdEnv map[string]string, -) (*azdext.ServiceDeployResult, error) { - // Check if environment variable is set - if azdEnv["AZURE_AI_PROJECT_ENDPOINT"] == "" { - return nil, exterrors.Dependency( - exterrors.CodeMissingAiProjectEndpoint, - "AZURE_AI_PROJECT_ENDPOINT is required: environment variable was not found in the current azd environment", - "run 'azd provision' or connect to an existing project via 'azd ai agent init --project-id '", - ) - } - - progress("Deploying hosted agent") - - // Step 1: Build container image - var fullImageURL string - for _, artifact := range serviceContext.Publish { - if artifact.Kind == azdext.ArtifactKind_ARTIFACT_KIND_CONTAINER && - artifact.LocationKind == azdext.LocationKind_LOCATION_KIND_REMOTE { - fullImageURL = artifact.Location - break - } - } - if fullImageURL == "" { - return nil, exterrors.Dependency( - exterrors.CodeMissingPublishedContainer, - "published container artifact not found: no remote container artifact was found in service publish artifacts", - "run 'azd package' and 'azd publish' (or 'azd deploy') to produce container artifacts", - ) - } - - fmt.Fprintf(os.Stderr, "Loaded configuration from: %s\n", p.agentDefinitionPath) - fmt.Fprintf(os.Stderr, "Using endpoint: %s\n", azdEnv["AZURE_AI_PROJECT_ENDPOINT"]) - fmt.Fprintf(os.Stderr, "Agent Name: %s\n", agentDef.Name) - - // Step 2: Resolve environment variables from YAML using azd environment values - resolvedEnvVars := make(map[string]string) - if agentDef.EnvironmentVariables != nil { - for _, envVar := range *agentDef.EnvironmentVariables { - resolvedEnvVars[envVar.Name] = p.resolveEnvironmentVariables(envVar.Value, azdEnv) - } - } - - // Step 3: Create agent request with image URL and resolved environment variables - var foundryAgentConfig *ServiceTargetAgentConfig - if err := UnmarshalStruct(serviceConfig.Config, &foundryAgentConfig); err != nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - fmt.Sprintf("failed to parse foundry agent config: %s", err), - "check the service configuration in azure.yaml", - ) - } - - warnDeprecatedScaleSettings(serviceConfig.Config) - - var cpu, memory string - if foundryAgentConfig.Container != nil && foundryAgentConfig.Container.Resources != nil { - cpu = foundryAgentConfig.Container.Resources.Cpu - memory = foundryAgentConfig.Container.Resources.Memory - } - - // Build options list starting with required options - options := []agent_yaml.AgentBuildOption{ - agent_yaml.WithImageURL(fullImageURL), - agent_yaml.WithEnvironmentVariables(resolvedEnvVars), - } - - // Conditionally add CPU and memory options if they're not empty - if cpu != "" { - options = append(options, agent_yaml.WithCPU(cpu)) - } - if memory != "" { - options = append(options, agent_yaml.WithMemory(memory)) - } - - request, err := agent_yaml.CreateAgentAPIRequestFromDefinition(agentDef, options...) - if err != nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidAgentRequest, - fmt.Sprintf("failed to create agent request from definition: %s", err), - "verify the agent.yaml definition is correct", - ) - } - - // Set experience metadata on the request - applyAgentMetadata(request) - - // Display agent information - p.displayAgentInfo(request) - - // Step 4: Create agent - progress("Creating agent") - agentVersionResponse, err := p.createAgent(ctx, request, azdEnv) - if err != nil { - return nil, err - } - - // Register agent info in environment - progress("Registering agent environment variables") - - // Default to "responses" protocol when none specified in agent.yaml. - protocols := agentDef.Protocols - if len(protocols) == 0 { - protocols = []agent_yaml.ProtocolVersionRecord{ - {Protocol: string(agent_api.AgentProtocolResponses), Version: "1.0.0"}, - } - } - - err = p.registerAgentEnvironmentVariables(ctx, azdEnv, serviceConfig, agentVersionResponse, protocols) - if err != nil { - return nil, err - } - - artifacts := p.deployArtifacts( - agentVersionResponse.Name, - agentVersionResponse.Version, - azdEnv["AZURE_AI_PROJECT_ID"], - azdEnv["AZURE_AI_PROJECT_ENDPOINT"], - protocols, - ) - - return &azdext.ServiceDeployResult{ - Artifacts: artifacts, - }, nil -} - -// packageCodeDeploy creates a ZIP archive of the agent source code, writes it to a temp file, -// and computes its SHA-256. Returns the temp file path and SHA-256 hex string. -func (p *AgentServiceTargetProvider) packageCodeDeploy(serviceConfig *azdext.ServiceConfig) (string, string, error) { - // Source directory is the service's relative path - srcDir := filepath.Dir(p.agentDefinitionPath) - - // Exclusion patterns - excludeDirs := map[string]bool{ - "__pycache__": true, - ".venv": true, - "venv": true, - ".git": true, - "node_modules": true, - ".mypy_cache": true, - ".pytest_cache": true, - ".azure": true, - } - excludeExts := map[string]bool{ - ".pyc": true, - ".pyo": true, - } - excludeFiles := map[string]bool{ - ".env": true, - } - - // Create temp file and write ZIP directly to it while computing SHA-256 - tmpFile, err := os.CreateTemp("", "azd-code-deploy-*.zip") - if err != nil { - return "", "", fmt.Errorf("failed to create temp file for ZIP: %w", err) - } - tmpPath := tmpFile.Name() - - // Clean up on error - success := false - defer func() { - if !success { - _ = tmpFile.Close() - _ = os.Remove(tmpPath) - } - }() - - hasher := sha256.New() - multiWriter := io.MultiWriter(tmpFile, hasher) - zipWriter := zip.NewWriter(multiWriter) - - err = filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - - // Get relative path - relPath, err := filepath.Rel(srcDir, path) - if err != nil { - return err - } - - // Skip root - if relPath == "." { - return nil - } - - // Normalize to forward slashes for ZIP - relPath = filepath.ToSlash(relPath) - - // Check directory exclusions - if d.IsDir() { - if excludeDirs[d.Name()] { - return filepath.SkipDir - } - return nil - } - - // Skip symlinks to avoid including files outside the agent directory - if d.Type()&fs.ModeSymlink != 0 { - return nil - } - - // Check file extension exclusions - if excludeExts[filepath.Ext(path)] { - return nil - } - - // Check file name exclusions (.env, .env.*) - if excludeFiles[d.Name()] || strings.HasPrefix(d.Name(), ".env.") { - return nil - } - - // Skip agent.yaml itself from the ZIP (metadata is sent separately) - if d.Name() == "agent.yaml" { - return nil - } - - // Add file to ZIP - fileData, err := os.ReadFile(path) //nolint:gosec // path is constructed from filepath.WalkDir within the service directory - if err != nil { - return fmt.Errorf("failed to read %s: %w", relPath, err) - } - - writer, err := zipWriter.Create(relPath) - if err != nil { - return fmt.Errorf("failed to create ZIP entry %s: %w", relPath, err) - } - - if _, err := writer.Write(fileData); err != nil { - return fmt.Errorf("failed to write ZIP entry %s: %w", relPath, err) - } - - return nil - }) - - if err != nil { - return "", "", fmt.Errorf("failed to walk source directory: %w", err) - } - - if err := zipWriter.Close(); err != nil { - return "", "", fmt.Errorf("failed to close ZIP: %w", err) - } - - if err := tmpFile.Close(); err != nil { - return "", "", fmt.Errorf("failed to close temp file: %w", err) - } - - sha256Hex := hex.EncodeToString(hasher.Sum(nil)) - success = true - - return tmpPath, sha256Hex, nil -} - -// deployHostedCodeAgent deploys a code-based hosted agent via multipart ZIP upload. -func (p *AgentServiceTargetProvider) deployHostedCodeAgent( - ctx context.Context, - serviceConfig *azdext.ServiceConfig, - serviceContext *azdext.ServiceContext, - progress azdext.ProgressReporter, - agentDef agent_yaml.ContainerAgent, - azdEnv map[string]string, -) (*azdext.ServiceDeployResult, error) { - if azdEnv["AZURE_AI_PROJECT_ENDPOINT"] == "" { - return nil, exterrors.Dependency( - exterrors.CodeMissingAiProjectEndpoint, - "AZURE_AI_PROJECT_ENDPOINT is required: environment variable was not found in the current azd environment", - "run 'azd provision' or connect to an existing project via 'azd ai agent init --project-id '", - ) - } - - progress("Deploying hosted agent (code deploy)") - - // Find the ZIP artifact from Package phase - var zipPath, sha256Hex string - for _, artifact := range serviceContext.Package { - if artifact.Metadata != nil && artifact.Metadata["type"] == "code-zip" { - zipPath = artifact.Location - sha256Hex = artifact.Metadata["sha256"] - break - } - } - if zipPath == "" { - return nil, exterrors.Dependency( - exterrors.CodeMissingCodeZipArtifact, - "code ZIP artifact not found: no code-zip artifact was found in service package artifacts", - "run 'azd deploy' to package and deploy the agent", - ) - } - - zipData, err := os.ReadFile(zipPath) //nolint:gosec // zipPath comes from the artifact location set during packaging - if err != nil { - return nil, fmt.Errorf("failed to read ZIP artifact: %w", err) - } - // Clean up temp file - defer os.Remove(zipPath) - - fmt.Fprintf(os.Stderr, "Loaded configuration from: %s\n", p.agentDefinitionPath) - fmt.Fprintf(os.Stderr, "Using endpoint: %s\n", azdEnv["AZURE_AI_PROJECT_ENDPOINT"]) - fmt.Fprintf(os.Stderr, "Agent Name: %s\n", agentDef.Name) - fmt.Fprintf(os.Stderr, "Runtime: %s\n", agentDef.CodeConfiguration.Runtime) - fmt.Fprintf(os.Stderr, "Entry Point: [\"python\", \"%s\"]\n", agentDef.CodeConfiguration.EntryPoint) - depRes := "bundled" - if agentDef.CodeConfiguration.DependencyResolution != nil { - depRes = *agentDef.CodeConfiguration.DependencyResolution - } - fmt.Fprintf(os.Stderr, "Packaging: %s\n", depRes) - - // Resolve environment variables - resolvedEnvVars := make(map[string]string) - if agentDef.EnvironmentVariables != nil { - for _, envVar := range *agentDef.EnvironmentVariables { - resolvedEnvVars[envVar.Name] = p.resolveEnvironmentVariables(envVar.Value, azdEnv) - } - } - - // Parse service config for cpu/memory - var foundryAgentConfig *ServiceTargetAgentConfig - if err := UnmarshalStruct(serviceConfig.Config, &foundryAgentConfig); err != nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - fmt.Sprintf("failed to parse foundry agent config: %s", err), - "check the service configuration in azure.yaml", - ) - } - - cpu := "1" - memory := "2Gi" - if foundryAgentConfig != nil && foundryAgentConfig.Container != nil && foundryAgentConfig.Container.Resources != nil { - if foundryAgentConfig.Container.Resources.Cpu != "" { - cpu = foundryAgentConfig.Container.Resources.Cpu - } - if foundryAgentConfig.Container.Resources.Memory != "" { - memory = foundryAgentConfig.Container.Resources.Memory - } - } - - // Build the API request definition - options := []agent_yaml.AgentBuildOption{ - agent_yaml.WithCPU(cpu), - agent_yaml.WithMemory(memory), - agent_yaml.WithEnvironmentVariables(resolvedEnvVars), - } - - request, err := agent_yaml.CreateAgentAPIRequestFromDefinition(agentDef, options...) - if err != nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidAgentRequest, - fmt.Sprintf("failed to create agent request from definition: %s", err), - "verify the agent.yaml definition is correct", - ) - } - - // Set experience metadata - applyAgentMetadata(request) - - fmt.Fprintf(os.Stderr, "CPU: %s Memory: %s\n", cpu, memory) - - // Build the metadata for multipart upload - versionRequest := &agent_api.CreateAgentVersionRequest{ - Description: request.Description, - Metadata: request.Metadata, - Definition: request.Definition, - } - - // Create agent client - agentClient := agent_api.NewAgentClient( - azdEnv["AZURE_AI_PROJECT_ENDPOINT"], - p.credential, - ) - - // Check if agent already exists (GET /agents/{name}) - progress("Creating agent") - _, getErr := agentClient.GetAgent(ctx, agentDef.Name, agentAPIVersion) - var agentResp *agent_api.AgentObject - - if getErr != nil { - // Only fall back to create on 404; propagate other errors (auth, 5xx, network) - var respErr *azcore.ResponseError - if !errors.As(getErr, &respErr) || respErr.StatusCode != http.StatusNotFound { - return nil, fmt.Errorf("failed to check if agent exists: %w", getErr) - } - // Agent doesn't exist — create - fmt.Fprintf(os.Stderr, "Creating new agent: %s\n", agentDef.Name) - agentResp, err = agentClient.CreateAgentFromZip(ctx, agentDef.Name, versionRequest, zipData, sha256Hex, agentAPIVersion) - if err != nil { - return nil, exterrors.Internal( - exterrors.CodeAgentCreateFailed, - fmt.Sprintf("failed to create agent from ZIP: %s; check the agent definition and try again", err), - ) - } - } else { - // Agent exists — update - fmt.Fprintf(os.Stderr, "Updating existing agent: %s\n", agentDef.Name) - agentResp, err = agentClient.UpdateAgentFromZip(ctx, agentDef.Name, versionRequest, zipData, sha256Hex, agentAPIVersion) - if err != nil { - return nil, exterrors.Internal( - exterrors.CodeAgentCreateFailed, - fmt.Sprintf("failed to update agent from ZIP: %s; check the agent definition and try again", err), - ) - } - } - - // Poll for status if remote build - latestVersion := &agentResp.Versions.Latest - if depRes == "remote_build" && latestVersion.Status == "creating" { - fmt.Fprintf(os.Stderr, "Waiting for remote build to complete...\n") - pollTimeout := 5 * time.Minute - pollInterval := 5 * time.Second - deadline := time.Now().Add(pollTimeout) - - for time.Now().Before(deadline) { - select { - case <-ctx.Done(): - return nil, fmt.Errorf("deployment cancelled: %w", ctx.Err()) - case <-time.After(pollInterval): - } - versionResp, err := agentClient.GetAgentVersion(ctx, agentDef.Name, latestVersion.Version, agentAPIVersion) - if err != nil { - fmt.Fprintf(os.Stderr, "Warning: poll failed: %s\n", err) - continue - } - latestVersion = versionResp - if versionResp.Status == "active" { - fmt.Fprintf(os.Stderr, "Agent is active!\n") - break - } else if versionResp.Status == "failed" { - return nil, exterrors.Internal( - exterrors.CodeAgentCreateFailed, - "agent deployment failed during remote build; check agent logs or try local packaging (dependency_resolution: bundled)", - ) - } - fmt.Fprintf(os.Stderr, " Status: %s...\n", versionResp.Status) - } - - if latestVersion.Status != "active" { - return nil, exterrors.Internal( - exterrors.CodeAgentCreateFailed, - "agent deployment timed out waiting for remote build; check agent status manually or try local packaging", - ) - } - } - - // Patch agent-level fields (agent_endpoint, agent_card) if present. - // These are agent-level properties, not version-level, so they require - // a separate PatchAgent call after version creation. - if request.AgentEndpoint != nil || request.AgentCard != nil { - patchRequest := &agent_api.PatchAgentRequest{ - AgentEndpoint: request.AgentEndpoint, - AgentCard: request.AgentCard, - } - - _, err := agentClient.PatchAgent(ctx, agentDef.Name, patchRequest, agentAPIVersion) - if err != nil { - fmt.Fprintf(os.Stderr, - "WARNING: Agent was created/updated, but patching agent endpoint/card failed: %s\n", err, - ) - return nil, exterrors.ServiceFromAzure(err, exterrors.OpCreateAgent) - } - fmt.Fprintf(os.Stderr, "Agent endpoint/card updated.\n") - } - - // Register environment variables - progress("Registering agent environment variables") - protocols := agentDef.Protocols - if len(protocols) == 0 { - protocols = []agent_yaml.ProtocolVersionRecord{ - {Protocol: string(agent_api.AgentProtocolResponses), Version: "1.0.0"}, - } - } - - err = p.registerAgentEnvironmentVariables(ctx, azdEnv, serviceConfig, latestVersion, protocols) - if err != nil { - return nil, err - } - - artifacts := p.deployArtifacts( - latestVersion.Name, - latestVersion.Version, - azdEnv["AZURE_AI_PROJECT_ID"], - azdEnv["AZURE_AI_PROJECT_ENDPOINT"], - protocols, - ) - - return &azdext.ServiceDeployResult{ - Artifacts: artifacts, - }, nil -} - -// deployArtifacts constructs the artifacts list for deployment results. -// It produces one endpoint artifact per displayable protocol. -func (p *AgentServiceTargetProvider) deployArtifacts( - agentName string, - agentVersion string, - projectResourceID string, - projectEndpoint string, - protocols []agent_yaml.ProtocolVersionRecord, -) []*azdext.Artifact { - artifacts := []*azdext.Artifact{} - - // Add playground URL - if projectResourceID != "" { - playgroundUrl, err := AgentPlaygroundURL(projectResourceID, agentName, agentVersion) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to generate agent playground link") - } else if playgroundUrl != "" { - artifacts = append(artifacts, &azdext.Artifact{ - Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT, - Location: playgroundUrl, - LocationKind: azdext.LocationKind_LOCATION_KIND_REMOTE, - Metadata: map[string]string{ - "label": "Agent playground (portal)", - }, - }) - } - } - - // Add agent endpoint(s) — one per displayable protocol - if projectEndpoint != "" { - endpoints := agentInvocationEndpoints(projectEndpoint, agentName, protocols) - for _, ep := range endpoints { - artifacts = append(artifacts, &azdext.Artifact{ - Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT, - Location: ep.URL, - LocationKind: azdext.LocationKind_LOCATION_KIND_REMOTE, - Metadata: map[string]string{ - "agentName": agentName, - "agentVersion": agentVersion, - "label": fmt.Sprintf("Agent endpoint (%s)", ep.Protocol), - "clickable": "false", - }, - }) - } - - // Attach the informational note to the last endpoint only, to avoid repetition. - if len(endpoints) > 0 { - last := artifacts[len(artifacts)-1] - last.Metadata["note"] = "For information on invoking the agent, see " + output.WithLinkFormat( - "https://aka.ms/azd-agents-invoke") - } - } - - return artifacts -} - -// protocolEndpointInfo holds a displayable protocol label and its invocation URL. -type protocolEndpointInfo struct { - Protocol string - URL string -} - -// protocolPath maps an agent protocol to its URL path suffix. -// Returns empty string for protocols that should not be displayed. -func protocolPath(protocol string) string { - for _, dp := range displayableProtocols { - if agent_api.AgentProtocol(protocol) == dp.Protocol { - return dp.URLPath - } - } - return "" -} - -// agentInvocationEndpoints builds the list of displayable invocation endpoints -// from the agent's protocols. -func agentInvocationEndpoints( - projectEndpoint string, - agentName string, - protocols []agent_yaml.ProtocolVersionRecord, -) []protocolEndpointInfo { - var endpoints []protocolEndpointInfo - for _, p := range protocols { - path := protocolPath(p.Protocol) - if path == "" { - continue - } - endpoints = append(endpoints, protocolEndpointInfo{ - Protocol: p.Protocol, - URL: fmt.Sprintf( - "%s/agents/%s/endpoint/protocols/%s?api-version=%s", - projectEndpoint, agentName, path, agentAPIVersion), - }) - } - return endpoints -} - -// AgentPlaygroundURL constructs a URL to the agent playground in the Foundry portal. -// It parses the ARM resource ID to extract subscription, resource group, account, and project info. -func AgentPlaygroundURL(projectResourceID, agentName, agentVersion string) (string, error) { - resourceId, err := arm.ParseResourceID(projectResourceID) - if err != nil { - return "", fmt.Errorf("failed to parse project resource ID: %w", err) - } - - // Encode subscription ID as base64 without padding for URL - subscriptionId := resourceId.SubscriptionID - encodedSubscriptionId, err := encodeSubscriptionID(subscriptionId) - if err != nil { - return "", fmt.Errorf("failed to encode subscription ID: %w", err) - } - - resourceGroup := resourceId.ResourceGroupName - - // Validate that the resource ID represents a Foundry project (has a parent account). - // Account-level IDs (no /projects/ child) would produce malformed playground URLs. - // For project-level IDs, Parent.Name is the account; for account-level IDs, - // Parent.Name is the resource group — we distinguish by checking ResourceType. - if resourceId.Parent == nil || - !strings.Contains(string(resourceId.ResourceType.Type), "/") { - return "", fmt.Errorf( - "resource ID does not represent a Foundry project (missing parent account): %s", - projectResourceID, - ) - } - - accountName := resourceId.Parent.Name - projectName := resourceId.Name - - url := fmt.Sprintf( - "https://ai.azure.com/nextgen/r/%s,%s,,%s,%s/build/agents/%s/build?version=%s", - encodedSubscriptionId, resourceGroup, accountName, projectName, - agentName, agentVersion, - ) - return url, nil -} - -// createAgent creates a new version of the agent using the API -func (p *AgentServiceTargetProvider) createAgent( - ctx context.Context, - request *agent_api.CreateAgentRequest, - azdEnv map[string]string, -) (*agent_api.AgentVersionObject, error) { - // Create agent client - agentClient := agent_api.NewAgentClient( - azdEnv["AZURE_AI_PROJECT_ENDPOINT"], - p.credential, - ) - - // Extract CreateAgentVersionRequest from CreateAgentRequest - versionRequest := &agent_api.CreateAgentVersionRequest{ - Description: request.Description, - Metadata: request.Metadata, - Definition: request.Definition, - } - - // Create agent version - agentVersionResponse, err := agentClient.CreateAgentVersion(ctx, request.Name, versionRequest, agentAPIVersion) - if err != nil { - return nil, exterrors.ServiceFromAzure(err, exterrors.OpCreateAgent) - } - - fmt.Fprintf(os.Stderr, "Agent version '%s' created successfully!\n", agentVersionResponse.Name) - - // Patch agent-level fields (agent_endpoint, agent_card) if present. - // These are agent-level properties, not version-level, so they require - // a separate PatchAgent call after version creation. - if request.AgentEndpoint != nil || request.AgentCard != nil { - patchRequest := &agent_api.PatchAgentRequest{ - AgentEndpoint: request.AgentEndpoint, - AgentCard: request.AgentCard, - } - - _, err := agentClient.PatchAgent( - ctx, request.Name, patchRequest, agentAPIVersion, - ) - if err != nil { - fmt.Fprintf(os.Stderr, - "WARNING: Agent version '%s' (version %s) was created, "+ - "but updating agent endpoint/card failed.\n", - agentVersionResponse.Name, - agentVersionResponse.Version, - ) - return nil, exterrors.ServiceFromAzure( - err, exterrors.OpCreateAgent, - ) - } - - fmt.Fprintf(os.Stderr, - "Agent endpoint and card updated successfully!\n", - ) - } - - return agentVersionResponse, nil -} - -// displayAgentInfo displays information about the agent being deployed -func (p *AgentServiceTargetProvider) displayAgentInfo(request *agent_api.CreateAgentRequest) { - description := "No description" - if request.Description != nil { - desc := *request.Description - if len(desc) > 50 { - description = desc[:50] + "..." - } else { - description = desc - } - } - fmt.Fprintf(os.Stderr, "Description: %s\n", description) - - // Display agent-specific information - if imageHostedDef, ok := request.Definition.(agent_api.ImageBasedHostedAgentDefinition); ok { - fmt.Fprintf(os.Stderr, "Image: %s\n", imageHostedDef.Image) - fmt.Fprintf(os.Stderr, "CPU: %s\n", imageHostedDef.CPU) - fmt.Fprintf(os.Stderr, "Memory: %s\n", imageHostedDef.Memory) - fmt.Fprintf(os.Stderr, "Protocol Versions: %+v\n", imageHostedDef.ContainerProtocolVersions) - } - fmt.Fprintln(os.Stderr) -} - -// registerAgentEnvironmentVariables registers agent information as azd environment variables. -// Per-protocol endpoint vars are set (e.g. AGENT_{KEY}_RESPONSES_ENDPOINT). -// The base agent endpoint (AGENT_{KEY}_ENDPOINT) is set to /agents/ -// for session management. -func (p *AgentServiceTargetProvider) registerAgentEnvironmentVariables( - ctx context.Context, - azdEnv map[string]string, - serviceConfig *azdext.ServiceConfig, - agentVersionResponse *agent_api.AgentVersionObject, - protocols []agent_yaml.ProtocolVersionRecord, -) error { - if agentVersionResponse.Name == "" { - return fmt.Errorf("agent name is empty; cannot register environment variables") - } - if agentVersionResponse.Version == "" { - return fmt.Errorf("agent version is empty; cannot register environment variables") - } - - serviceKey := p.getServiceKey(serviceConfig.Name) - envVars := map[string]string{ - fmt.Sprintf("AGENT_%s_NAME", serviceKey): agentVersionResponse.Name, - fmt.Sprintf("AGENT_%s_VERSION", serviceKey): agentVersionResponse.Version, - } - - // Set the base agent endpoint used for session management (not protocol-specific). - baseEndpointKey := fmt.Sprintf("AGENT_%s_ENDPOINT", serviceKey) - projectEndpoint := strings.TrimRight(azdEnv["AZURE_AI_PROJECT_ENDPOINT"], "/") - envVars[baseEndpointKey] = fmt.Sprintf( - "%s/agents/%s/versions/%s", projectEndpoint, agentVersionResponse.Name, agentVersionResponse.Version, - ) - - endpoints := agentInvocationEndpoints( - azdEnv["AZURE_AI_PROJECT_ENDPOINT"], - agentVersionResponse.Name, - protocols, - ) - for _, ep := range endpoints { - suffix := strings.ToUpper(ep.Protocol) - key := fmt.Sprintf("AGENT_%s_%s_ENDPOINT", serviceKey, suffix) - envVars[key] = ep.URL - } - - for key, value := range envVars { - _, err := p.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ - EnvName: p.env.Name, - Key: key, - Value: value, - }) - if err != nil { - return fmt.Errorf("failed to set environment variable %s: %w", key, err) - } - } - - return nil -} - -// resolveEnvironmentVariables resolves ${ENV_VAR} style references in value using azd environment variables. -// Supports default values (e.g., "${VAR:-default}") and multiple expressions (e.g., "${VAR1}-${VAR2}"). -func (p *AgentServiceTargetProvider) resolveEnvironmentVariables(value string, azdEnv map[string]string) string { - resolved, err := envsubst.Eval(value, func(varName string) string { - return azdEnv[varName] - }) - if err != nil { - // If resolution fails, return original value - return value - } - return resolved -} - -// ensureFoundryProject ensures the Foundry project resource ID is parsed and stored. -// Checks for AZURE_AI_PROJECT_ID environment variable. -func (p *AgentServiceTargetProvider) ensureFoundryProject(ctx context.Context) error { - if p.foundryProject != nil { - return nil - } - - // Get all environment values - resp, err := p.azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ - EnvName: p.env.Name, - Key: "AZURE_AI_PROJECT_ID", - }) - if err != nil { - return exterrors.Dependency( - exterrors.CodeEnvironmentValuesFailed, - fmt.Sprintf("failed to get AZURE_AI_PROJECT_ID: %s", err), - "run 'azd env get-values' to verify environment state", - ) - } - - // Check for Microsoft Foundry project resource ID (try both env var names) - foundryResourceID := resp.Value - if foundryResourceID == "" { - return exterrors.Dependency( - exterrors.CodeMissingAiProjectId, - "Microsoft Foundry project ID is required: AZURE_AI_PROJECT_ID is not set", - "run 'azd provision' or connect to an existing project via 'azd ai agent init --project-id '", - ) - } - - // Parse the resource ID - parsedResource, err := arm.ParseResourceID(foundryResourceID) - if err != nil { - return exterrors.Validation( - exterrors.CodeInvalidAiProjectId, - fmt.Sprintf("failed to parse Microsoft Foundry project ID: %s", err), - "verify the AZURE_AI_PROJECT_ID is a valid ARM resource ID", - ) - } - - p.foundryProject = parsedResource - return nil -} - -// encodeSubscriptionID encodes a subscription ID GUID as base64 without padding -func encodeSubscriptionID(subscriptionID string) (string, error) { - guid, err := uuid.Parse(subscriptionID) - if err != nil { - return "", fmt.Errorf("invalid subscription ID format: %w", err) - } - - // Convert GUID to bytes (MarshalBinary never returns an error for uuid.UUID) - guidBytes, _ := guid.MarshalBinary() - - // Encode as base64 and remove padding - encoded := base64.URLEncoding.EncodeToString(guidBytes) - return strings.TrimRight(encoded, "="), nil -} - -// applyAgentMetadata sets the enableVnextExperience metadata on the request. -// The "enableVnextExperience" key is a server-side API contract. -func applyAgentMetadata(request *agent_api.CreateAgentRequest) { - if request.Metadata == nil { - request.Metadata = make(map[string]string) - } - request.Metadata["enableVnextExperience"] = "true" -} - -// warnDeprecatedScaleSettings prints a user-visible warning if the raw service config -// contains a container.scale section, which is no longer supported. -func warnDeprecatedScaleSettings(config *structpb.Struct) { - if config == nil { - return - } - containerVal, ok := config.Fields["container"] - if !ok || containerVal.GetStructValue() == nil { - return - } - if _, hasScale := containerVal.GetStructValue().Fields["scale"]; hasScale { - fmt.Printf("%s\n", output.WithWarningFormat( - "WARNING: container.scale settings (minReplicas/maxReplicas) are no longer supported and will be ignored. "+ - "Remove the container.scale section from your azure.yaml service configuration.", - )) - } -} From 50577dc8bf8f60ce0b1664f191c6d1e2dd135c19 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Wed, 13 May 2026 14:08:22 +0800 Subject: [PATCH 16/18] fix(agents): restrict code deploy to supported regions and fix depRes default Add region validation for code deploy at both init-time (filter project list) and deploy-time (fail early with clear error). Supported regions: westus2, canadacentral, northcentralus. Unify dependency_resolution fallback default to 'remote_build' to match --no-prompt behavior. --- .../cmd/init_foundry_resources_helpers.go | 11 ++++++++++ .../internal/project/service_target_agent.go | 21 +++++++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) 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 bf0a3165dc9..1b5e0fc5412 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 @@ -24,6 +24,10 @@ import ( "google.golang.org/grpc/status" ) +// codeDeployRegions lists the regions that currently support code deploy (ZIP upload). +// Projects outside these regions are filtered out when code deploy mode is selected. +var codeDeployRegions = []string{"westus2", "canadacentral", "northcentralus"} + // FoundryProjectInfo holds information about a discovered or parsed Foundry project. // This is the unified type used by both init flows. type FoundryProjectInfo struct { @@ -1043,6 +1047,13 @@ func selectFoundryProject( return nil, fmt.Errorf("failed to list Foundry projects: %w", err) } + // When code deploy is selected, restrict to regions that support it. + if skipACR { + projects = slices.DeleteFunc(projects, func(p FoundryProjectInfo) bool { + return !locationAllowed(p.Location, codeDeployRegions) + }) + } + if len(projects) == 0 { return nil, nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index e0260c94119..b23cf662e6d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -17,6 +17,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "strings" "time" @@ -1080,8 +1081,20 @@ func (p *AgentServiceTargetProvider) deployHostedCodeAgent( ) (*azdext.ServiceDeployResult, error) { progress("Deploying hosted agent (code deploy)") - // TODO: Add region validation for code deploy — verify that the Foundry project's - // region supports code deploy before attempting the upload. + // Validate that the Foundry project's region supports code deploy. + codeDeployRegions := []string{"westus2", "canadacentral", "northcentralus"} + projectLocation := strings.ToLower(strings.TrimSpace(azdEnv["AZURE_LOCATION"])) + if !slices.Contains(codeDeployRegions, projectLocation) { + return nil, exterrors.Dependency( + exterrors.CodeAgentCreateFailed, + fmt.Sprintf( + "code deploy is not supported in region %q; supported regions: %s", + azdEnv["AZURE_LOCATION"], + strings.Join(codeDeployRegions, ", "), + ), + "select a Foundry project in a supported region or use container deploy instead", + ) + } // Find the ZIP artifact from Package phase var zipPath, sha256Hex string @@ -1115,7 +1128,7 @@ func (p *AgentServiceTargetProvider) deployHostedCodeAgent( if agentDef.CodeConfiguration != nil { fmt.Fprintf(os.Stderr, "Runtime: %s\n", agentDef.CodeConfiguration.Runtime) fmt.Fprintf(os.Stderr, "Entry Point: [\"python\", \"%s\"]\n", agentDef.CodeConfiguration.EntryPoint) - depRes := "bundled" + depRes := "remote_build" if agentDef.CodeConfiguration.DependencyResolution != nil { depRes = *agentDef.CodeConfiguration.DependencyResolution } @@ -1171,7 +1184,7 @@ func (p *AgentServiceTargetProvider) deployHostedCodeAgent( // Poll for status if remote build latestVersion := &agentResp.Versions.Latest - depRes := "bundled" + depRes := "remote_build" if agentDef.CodeConfiguration != nil && agentDef.CodeConfiguration.DependencyResolution != nil { depRes = *agentDef.CodeConfiguration.DependencyResolution } From 437bcb1b221353f28d8e93170b69474e9286f2d1 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Wed, 13 May 2026 14:11:45 +0800 Subject: [PATCH 17/18] fix(agents): deduplicate codeDeployRegions and handle empty AZURE_LOCATION Move CodeDeployRegions to project.config.go as a shared exported var, referenced by both init filtering and deploy validation. Add explicit check for empty AZURE_LOCATION with actionable error message. --- .../internal/cmd/init_foundry_resources_helpers.go | 7 ++----- .../azure.ai.agents/internal/project/config.go | 3 +++ .../internal/project/service_target_agent.go | 12 +++++++++--- 3 files changed, 14 insertions(+), 8 deletions(-) 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 1b5e0fc5412..dc52eb3f7fd 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 @@ -6,6 +6,7 @@ package cmd import ( "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/azure" + "azureaiagent/internal/project" "context" "fmt" "regexp" @@ -24,10 +25,6 @@ import ( "google.golang.org/grpc/status" ) -// codeDeployRegions lists the regions that currently support code deploy (ZIP upload). -// Projects outside these regions are filtered out when code deploy mode is selected. -var codeDeployRegions = []string{"westus2", "canadacentral", "northcentralus"} - // FoundryProjectInfo holds information about a discovered or parsed Foundry project. // This is the unified type used by both init flows. type FoundryProjectInfo struct { @@ -1050,7 +1047,7 @@ func selectFoundryProject( // When code deploy is selected, restrict to regions that support it. if skipACR { projects = slices.DeleteFunc(projects, func(p FoundryProjectInfo) bool { - return !locationAllowed(p.Location, codeDeployRegions) + return !locationAllowed(p.Location, project.CodeDeployRegions) }) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/config.go b/cli/azd/extensions/azure.ai.agents/internal/project/config.go index eea77c5d730..bb82f3e49e5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/config.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/config.go @@ -17,6 +17,9 @@ const ( DefaultCpu = "0.25" ) +// CodeDeployRegions lists the regions that currently support code deploy (ZIP upload). +var CodeDeployRegions = []string{"westus2", "canadacentral", "northcentralus"} + // ResourceTier defines a preset CPU and memory allocation for container resources. type ResourceTier struct { Cpu string diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index b23cf662e6d..c9f4b0be11f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -1082,15 +1082,21 @@ func (p *AgentServiceTargetProvider) deployHostedCodeAgent( progress("Deploying hosted agent (code deploy)") // Validate that the Foundry project's region supports code deploy. - codeDeployRegions := []string{"westus2", "canadacentral", "northcentralus"} projectLocation := strings.ToLower(strings.TrimSpace(azdEnv["AZURE_LOCATION"])) - if !slices.Contains(codeDeployRegions, projectLocation) { + if projectLocation == "" { + return nil, exterrors.Dependency( + exterrors.CodeAgentCreateFailed, + "AZURE_LOCATION is not set; the Foundry project region is required for code deploy", + "run 'azd provision' or 'azd ai agent init' to set the project location", + ) + } + if !slices.Contains(CodeDeployRegions, projectLocation) { return nil, exterrors.Dependency( exterrors.CodeAgentCreateFailed, fmt.Sprintf( "code deploy is not supported in region %q; supported regions: %s", azdEnv["AZURE_LOCATION"], - strings.Join(codeDeployRegions, ", "), + strings.Join(CodeDeployRegions, ", "), ), "select a Foundry project in a supported region or use container deploy instead", ) From 04b59bec6a8740c60fb33edd580d80abb1db2c9f Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Wed, 13 May 2026 15:57:03 +0800 Subject: [PATCH 18/18] fix(agents): improve deploy failure diagnostics and rename resource prompt - Include service error code/message and x-request-id in remote build failure errors - Rename 'container resource allocation' prompt to 'Select resources (CPU and Memory)' --- cli/azd/extensions/azure.ai.agents/internal/cmd/init.go | 2 +- .../internal/pkg/agents/agent_api/models.go | 9 +++++++++ .../internal/pkg/agents/agent_api/operations.go | 5 +++++ .../internal/project/service_target_agent.go | 9 ++++++++- 4 files changed, 23 insertions(+), 2 deletions(-) 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 74c826755cf..a5409085adc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1891,7 +1891,7 @@ func (a *InitAction) populateContainerSettings( resp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ Options: &azdext.SelectOptions{ - Message: "Select container resource allocation (CPU and Memory) for your agent. You can adjust these settings later in the azure.yaml file if needed.", + Message: "Select resources (CPU and Memory) for your agent. You can adjust these settings later in the azure.yaml file if needed.", Choices: choices, SelectedIndex: &defaultIndex, }, diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go index 6b66602d5ed..bc599ec43f8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go @@ -233,10 +233,19 @@ type AgentVersionObject struct { CreatedAt int64 `json:"created_at"` Definition any `json:"definition"` // Can be any of the agent definition types Status string `json:"status,omitempty"` + Error *AgentVersionError `json:"error,omitempty"` InstanceIdentity *AgentIdentityInfo `json:"instance_identity,omitempty"` Blueprint *BlueprintInfo `json:"blueprint,omitempty"` BlueprintReference *BlueprintReference `json:"blueprint_reference,omitempty"` AgentGUID string `json:"agent_guid,omitempty"` + // RequestID is populated from the x-request-id response header (not from JSON). + RequestID string `json:"-"` +} + +// AgentVersionError represents an error returned by the service for a failed agent version. +type AgentVersionError struct { + Code string `json:"code"` + Message string `json:"message"` } // AgentObject represents an agent diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go index acb7950f3d7..50ff019b4fa 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go @@ -505,6 +505,11 @@ func (c *AgentClient) GetAgentVersion(ctx context.Context, agentName, agentVersi return nil, fmt.Errorf("failed to parse response: %w", err) } + // Capture request ID from response header for diagnostics. + if reqID := resp.Header.Get("x-request-id"); reqID != "" { + version.RequestID = reqID + } + return &version, nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index c9f4b0be11f..fbaac59af18 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -1216,9 +1216,16 @@ func (p *AgentServiceTargetProvider) deployHostedCodeAgent( fmt.Fprintf(os.Stderr, "Agent is active!\n") break } else if versionResp.Status == "failed" { + errMsg := "agent deployment failed during remote build; check agent logs or try local packaging (dependency_resolution: bundled)" + if versionResp.Error != nil { + errMsg = fmt.Sprintf("agent deployment failed: [%s] %s", versionResp.Error.Code, versionResp.Error.Message) + } + if versionResp.RequestID != "" { + errMsg += fmt.Sprintf(" (request-id: %s)", versionResp.RequestID) + } return nil, exterrors.Internal( exterrors.CodeAgentCreateFailed, - "agent deployment failed during remote build; check agent logs or try local packaging (dependency_resolution: bundled)", + errMsg, ) } fmt.Fprintf(os.Stderr, " Status: %s...\n", versionResp.Status)