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.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index d5838ff6769..a5409085adc 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,37 @@ 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. + // Code deploy is currently only supported for Python projects. + if _, ok := agentManifest.Template.(agent_yaml.ContainerAgent); ok { + 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) + } + a.isCodeDeploy = (deployMode == "code") + + if a.isCodeDeploy { + // Prompt for code configuration and update the manifest + codeConfig, err := promptCodeConfig(ctx, a.azdClient, targetDir, a.flags.noPrompt) + if err != nil { + return fmt.Errorf("prompting for code configuration: %w", err) + } + + 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 + } + } + } + // Model configuration: prompt user for "use existing" vs "deploy new" agentManifest, err = a.configureModelChoice(ctx, agentManifest) if err != nil { @@ -792,6 +824,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 +873,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 +966,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 @@ -1453,6 +1488,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 { @@ -1558,11 +1603,16 @@ 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 + agentConfig.StartupCommand = deriveStartupCommand(a.projectConfig.Path, targetDir) + } 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 +1627,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, + } } } @@ -1837,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/cmd/init_foundry_resources_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go index 285de31c6d4..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" @@ -308,6 +309,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 +317,7 @@ func configureFoundryProjectEnv( envName string, project FoundryProjectInfo, subscriptionId string, + skipACR bool, ) error { resourceId := project.ResourceId if resourceId == "" { @@ -365,7 +368,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 { @@ -379,8 +384,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 { @@ -999,6 +1006,7 @@ func selectFoundryProject( envName string, subscriptionId string, projectResourceId string, + skipACR bool, ) (*FoundryProjectInfo, error) { spinnerText := "Searching for Foundry projects in your subscription..." if projectResourceId != "" { @@ -1036,6 +1044,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, project.CodeDeployRegions) + }) + } + if len(projects) == 0 { return nil, nil } @@ -1095,7 +1110,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 c6d14c9b4a5..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 @@ -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,27 @@ 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) + // Code deploy is only available for Python projects + srcDir := a.flags.src + if srcDir == "" { + srcDir, _ = os.Getwd() + } + 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 + var codeConfig *agent_yaml.CodeConfiguration + if deployMode == "code" { + codeConfig, err = a.promptCodeConfiguration(ctx, a.flags.src) + 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 { @@ -539,7 +561,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 } @@ -587,7 +609,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,9 +816,20 @@ 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 { + // 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 agentConfig.Container = &project.ContainerSettings{ Resources: &project.ResourceSettings{ Memory: project.DefaultMemory, @@ -805,29 +839,42 @@ func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, 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 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 + agentConfig.StartupCommand = deriveStartupCommand(a.projectConfig.Path, targetDir) } - 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 +887,24 @@ 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, srcDir string) (*agent_yaml.CodeConfiguration, error) { + return promptCodeConfig(ctx, a.azdClient, srcDir, a.flags.noPrompt) +} + +// 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 + } + } + return "python main.py" +} + // protocolInfo pairs a protocol name with the default version used when generating agent.yaml. type protocolInfo struct { Name string @@ -962,3 +1027,154 @@ 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. +// 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 Image (Docker)", Value: "container"}, + {Label: "Source Code (ZIP upload)", Value: "code"}, + } + + if noPrompt { + return "container", nil + } + + 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?", + 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 +} + +// 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.11", Value: "python_3_11"}, + {Label: "Python 3.12", Value: "python_3_12"}, + {Label: "Python 3.13", Value: "python_3_13"}, + } + + 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) + } + 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" + } + + 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) + } + entryPoint = entryPointResp.Value + } + + // Prompt for dependency resolution + depResChoices := []*azdext.SelectChoice{ + {Label: "Remote build (dependencies installed on server during deployment)", Value: "remote_build"}, + {Label: "Bundled (dependencies pre-installed locally and included in ZIP)", Value: "bundled"}, + } + + 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) + } + depResolution = depResChoices[*depResResp.Value].Value + } + + return &agent_yaml.CodeConfiguration{ + Runtime: runtime, + EntryPoint: entryPoint, + 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/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index 3a1714c6929..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" @@ -136,6 +137,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..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 @@ -106,19 +106,74 @@ type WorkflowDefinition struct { Trigger map[string]any `json:"trigger,omitempty"` } -// HostedAgentDefinition represents a hosted agent +// 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"` +} + +// 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 - ContainerProtocolVersions []ProtocolVersionRecord `json:"container_protocol_versions"` - CPU string `json:"cpu"` - Memory string `json:"memory"` - EnvironmentVariables map[string]string `json:"environment_variables,omitempty"` -} + 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"` + 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 + } -// ImageBasedHostedAgentDefinition represents an image-based hosted agent -type ImageBasedHostedAgentDefinition struct { - HostedAgentDefinition - Image string `json:"image"` + *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 @@ -178,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/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_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go index ea474f0e215..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 @@ -9,7 +9,9 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" + "net/textproto" "net/url" "strconv" "time" @@ -356,6 +358,124 @@ 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). +// 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, + 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 + writer := multipart.NewWriter(&body) + + // Part 1: metadata (JSON) + metadataJSON, err := json.Marshal(metadata) + if err != nil { + return nil, fmt.Errorf("failed to marshal metadata: %w", err) + } + + 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) + } + if _, err := metadataPart.Write(metadataJSON); err != nil { + return nil, fmt.Errorf("failed to write metadata: %w", err) + } + + // Part 2: code (ZIP) + 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) + } + + 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) + } + + 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) + } + + // 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) @@ -385,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/pkg/agents/agent_api/operations_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go index d190958934d..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 @@ -4,8 +4,12 @@ package agent_api import ( + "bytes" + "context" "encoding/json" "io" + "mime" + "mime/multipart" "net/http" "strings" "testing" @@ -287,3 +291,104 @@ 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()) + require.Equal(t, "application/json", part1.Header.Get("Content-Type")) + part1Data, _ := io.ReadAll(part1) + var parsedMeta map[string]any + 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/pkg/agents/agent_yaml/map.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go index 21a752dad4a..06cd03ba68b 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 @@ -339,10 +339,6 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB } } - if imageURL == "" { - return nil, fmt.Errorf("image URL is required for hosted agents - specify image in agent.yaml or use WithImageURL") - } - // Map protocol versions from the hosted agent definition protocolVersions := make([]agent_api.ProtocolVersionRecord, 0) if len(hostedAgent.Protocols) > 0 { @@ -359,23 +355,50 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB } } - hostedDef := agent_api.HostedAgentDefinition{ + // Code deploy path + if hostedAgent.CodeConfiguration != nil { + entryPoint := []string{"python", hostedAgent.CodeConfiguration.EntryPoint} + depRes := "" + if hostedAgent.CodeConfiguration.DependencyResolution != nil { + depRes = *hostedAgent.CodeConfiguration.DependencyResolution + } + + codeDef := agent_api.HostedAgentDefinition{ + 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 + if imageURL == "" { + return nil, fmt.Errorf("image URL is required for hosted agents - use WithImageURL build option or specify in container.image") + } + + imageDef := agent_api.HostedAgentDefinition{ AgentDefinition: agent_api.AgentDefinition{ Kind: agent_api.AgentKindHosted, }, - ContainerProtocolVersions: protocolVersions, - CPU: cpu, - Memory: memory, - EnvironmentVariables: envVars, - } - - // Set the image from build configuration or container definition - imageHostedDef := agent_api.ImageBasedHostedAgentDefinition{ - HostedAgentDefinition: hostedDef, - Image: imageURL, + ProtocolVersions: protocolVersions, + CPU: cpu, + Memory: memory, + EnvironmentVariables: envVars, + 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 e4d9c82b16c..f2e00e98090 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") } @@ -976,7 +976,7 @@ func TestCreateHostedAgentAPIRequest_UsesAgentImage(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - imgDef := req.Definition.(agent_api.ImageBasedHostedAgentDefinition) + imgDef := req.Definition.(agent_api.HostedAgentDefinition) if imgDef.Image != "myregistry.azurecr.io/agent-image:v1" { t.Errorf("Image = %q", imgDef.Image) } @@ -1000,7 +1000,7 @@ func TestCreateHostedAgentAPIRequest_BuildConfigImageOverridesAgentImage(t *test t.Fatalf("unexpected error: %v", err) } - imgDef := req.Definition.(agent_api.ImageBasedHostedAgentDefinition) + imgDef := req.Definition.(agent_api.HostedAgentDefinition) if imgDef.Image != "myregistry.azurecr.io/published:v2" { t.Errorf("Image = %q", imgDef.Image) } @@ -1060,9 +1060,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) } } @@ -1104,7 +1104,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/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go index 44edbbcda81..b7122d17d7e 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. @@ -187,6 +196,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/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 ff7acbf41b9..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 @@ -4,19 +4,29 @@ package project import ( + "archive/zip" "context" + "crypto/sha256" "encoding/base64" + "encoding/hex" + "errors" "fmt" + "io" + "io/fs" + "net/http" "os" "path/filepath" "regexp" + "slices" "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" @@ -367,6 +377,29 @@ 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") + 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 + } + agentDef, isContainerAgent, err := p.loadContainerAgentDefinition() if err != nil { return nil, err @@ -447,6 +480,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 preBuiltArtifact := findPreBuiltImageArtifact(serviceContext.Package); preBuiltArtifact != nil { progress("Using pre-built container image, skipping publish") return &azdext.ServicePublishResult{ @@ -642,6 +680,11 @@ func (p *AgentServiceTargetProvider) Deploy( ) } + // 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) } @@ -685,16 +728,49 @@ func (p *AgentServiceTargetProvider) shouldUsePreBuiltImage( return resp.Value != nil && choices[*resp.Value].Value == "prebuilt", nil } -// deployHostedAgent deploys a container-based hosted agent to the Foundry service. -func (p *AgentServiceTargetProvider) deployHostedAgent( - ctx context.Context, +// 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 +} + +// 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, @@ -703,47 +779,11 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( ) } - progress("Deploying hosted agent") - - fullImageURL := "" - if preBuiltArtifact := findPreBuiltImageArtifactInContext(serviceContext); preBuiltArtifact != nil { - fullImageURL = preBuiltArtifact.Location - } else if !hasContainerArtifact(serviceContext.Publish) { - usePreBuiltImage, err := p.shouldUsePreBuiltImage(ctx, agentDef) - if err != nil { - return nil, err - } - if usePreBuiltImage { - fullImageURL = agentDef.Image - } - } - - if fullImageURL != "" { - progress(fmt.Sprintf("Using pre-built container image: %s", fullImageURL)) - } else { - 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 and no pre-built image was specified", - "either set 'image' in agent.yaml, "+ - "or run 'azd package' and 'azd publish' to build from a Dockerfile", - ) - } - } - 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 { @@ -751,7 +791,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( @@ -764,24 +804,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 { @@ -792,22 +830,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 { @@ -816,14 +840,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, @@ -834,6 +877,388 @@ 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") + + fullImageURL := "" + if preBuiltArtifact := findPreBuiltImageArtifactInContext(serviceContext); preBuiltArtifact != nil { + fullImageURL = preBuiltArtifact.Location + } else if !hasContainerArtifact(serviceContext.Publish) { + usePreBuiltImage, err := p.shouldUsePreBuiltImage(ctx, agentDef) + if err != nil { + return nil, err + } + if usePreBuiltImage { + fullImageURL = agentDef.Image + } + } + + if fullImageURL != "" { + progress(fmt.Sprintf("Using pre-built container image: %s", fullImageURL)) + } else { + 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 and no pre-built image was specified", + "either set 'image' in agent.yaml, "+ + "or run 'azd package' and 'azd publish' to build from a Dockerfile", + ) + } + } + + 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) { + // 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) { + progress("Deploying hosted agent (code deploy)") + + // Validate that the Foundry project's region supports code deploy. + projectLocation := strings.ToLower(strings.TrimSpace(azdEnv["AZURE_LOCATION"])) + 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, ", "), + ), + "select a Foundry project in a supported region or use container deploy instead", + ) + } + + // 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 package' to produce the code ZIP artifact", + ) + } + + 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) + + prep, err := p.prepareDeploy(serviceConfig, agentDef, azdEnv, nil) + if err != nil { + return nil, err + } + + 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 := "remote_build" + if agentDef.CodeConfiguration.DependencyResolution != nil { + depRes = *agentDef.CodeConfiguration.DependencyResolution + } + fmt.Fprintf(os.Stderr, "Packaging: %s\n", depRes) + } + + // Display agent information + p.displayAgentInfo(prep.request) + + // Build the metadata for multipart upload + versionRequest := &agent_api.CreateAgentVersionRequest{ + Description: prep.request.Description, + Metadata: prep.request.Metadata, + Definition: prep.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) + 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 + 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 + depRes := "remote_build" + 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 + 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" { + 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, + errMsg, + ) + } + 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. + if prep.request.AgentEndpoint != nil || prep.request.AgentCard != nil { + patchRequest := &agent_api.PatchAgentRequest{ + AgentEndpoint: prep.request.AgentEndpoint, + AgentCard: prep.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") + } + + return p.finalizeDeploy(ctx, progress, serviceConfig, azdEnv, latestVersion, prep.protocols) +} + // deployArtifacts constructs the artifacts list for deployment results. // It produces one endpoint artifact per displayable protocol. func (p *AgentServiceTargetProvider) deployArtifacts( @@ -1043,11 +1468,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) }