From 63f5b705d4a16a2c9f19042cd682775cc09d11ec Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Tue, 12 May 2026 21:23:24 +0800 Subject: [PATCH 1/7] feat: add .NET code deploy support (dotnet_8/9/10 runtimes) --- .../internal/cmd/init_from_code.go | 47 +++++- .../internal/pkg/agents/agent_yaml/map.go | 7 +- .../internal/project/service_target_agent.go | 147 +++++++++++++++++- 3 files changed, 192 insertions(+), 9 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 91fb94283ab..ecae9c72540 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 @@ -860,6 +860,17 @@ func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, language := "python" if !isCodeDeploy { language = "docker" + } else { + // Detect language from agent.yaml runtime + agentYamlPath2 := filepath.Join(a.projectConfig.Path, targetDir, "agent.yaml") + if data, err := os.ReadFile(agentYamlPath2); err == nil { //nolint:gosec // path from project config + var agentDef2 agent_yaml.ContainerAgent + if err := yaml.Unmarshal(data, &agentDef2); err == nil && + agentDef2.CodeConfiguration != nil && + strings.HasPrefix(agentDef2.CodeConfiguration.Runtime, "dotnet_") { + language = "csharp" + } + } } serviceConfig := &azdext.ServiceConfig{ @@ -899,7 +910,11 @@ func deriveStartupCommand(projectPath, targetDir string) string { 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 + cmdPrefix := "python" + if strings.HasPrefix(agentDef.CodeConfiguration.Runtime, "dotnet_") { + cmdPrefix = "dotnet" + } + return cmdPrefix + " " + agentDef.CodeConfiguration.EntryPoint } } return "python main.py" @@ -1062,6 +1077,28 @@ func promptDeployMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt return deployModeChoices[*deployModeResp.Value].Value, nil } +// detectDefaultEntryPoint returns a sensible default entry point based on the runtime and source directory. +func detectDefaultEntryPoint(srcDir, runtime string) string { + if strings.HasPrefix(runtime, "dotnet_") { + // Look for .csproj file and derive DLL name + entries, err := os.ReadDir(srcDir) + if err == nil { + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".csproj") { + return strings.TrimSuffix(e.Name(), ".csproj") + ".dll" + } + } + } + return "App.dll" + } + + // Python default + if _, err := os.Stat(filepath.Join(srcDir, "app.py")); err == nil { + return "app.py" + } + return "main.py" +} + // 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) { @@ -1074,6 +1111,9 @@ func promptCodeConfig(ctx context.Context, azdClient *azdext.AzdClient, srcDir s {Label: "Python 3.11", Value: "python_3_11"}, {Label: "Python 3.12", Value: "python_3_12"}, {Label: "Python 3.13", Value: "python_3_13"}, + {Label: ".NET 9", Value: "dotnet_9"}, + {Label: ".NET 8", Value: "dotnet_8"}, + {Label: ".NET 10", Value: "dotnet_10"}, } var runtime string @@ -1098,10 +1138,7 @@ func promptCodeConfig(ctx context.Context, azdClient *azdext.AzdClient, srcDir s } // Prompt for entry point - defaultEntryPoint := "main.py" - if _, statErr := os.Stat(filepath.Join(srcDir, "app.py")); statErr == nil { - defaultEntryPoint = "app.py" - } + defaultEntryPoint := detectDefaultEntryPoint(srcDir, runtime) var entryPoint string if noPrompt { 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 06cd03ba68b..0cd06548a93 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 @@ -357,7 +357,12 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB // Code deploy path if hostedAgent.CodeConfiguration != nil { - entryPoint := []string{"python", hostedAgent.CodeConfiguration.EntryPoint} + // Derive the command prefix from the runtime (e.g. "python" for python_3_12, "dotnet" for dotnet_9). + cmdPrefix := "python" + if strings.HasPrefix(hostedAgent.CodeConfiguration.Runtime, "dotnet_") { + cmdPrefix = "dotnet" + } + entryPoint := []string{cmdPrefix, hostedAgent.CodeConfiguration.EntryPoint} depRes := "" if hostedAgent.CodeConfiguration.DependencyResolution != nil { depRes = *hostedAgent.CodeConfiguration.DependencyResolution 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 fbaac59af18..a99576a0215 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 @@ -15,6 +15,7 @@ import ( "io/fs" "net/http" "os" + "os/exec" "path/filepath" "regexp" "slices" @@ -948,6 +949,21 @@ func (p *AgentServiceTargetProvider) packageCodeDeploy(serviceConfig *azdext.Ser // Source directory is the service's relative path srcDir := filepath.Dir(p.agentDefinitionPath) + // Load agent.yaml to check runtime and dependency resolution for dotnet bundled mode + if data, err := os.ReadFile(p.agentDefinitionPath); err == nil { //nolint:gosec // path from internal state + var agentDef agent_yaml.ContainerAgent + if err := yaml.Unmarshal(data, &agentDef); err == nil && agentDef.CodeConfiguration != nil { + isDotnet := strings.HasPrefix(agentDef.CodeConfiguration.Runtime, "dotnet_") + isBundled := true // default is bundled + if agentDef.CodeConfiguration.DependencyResolution != nil { + isBundled = *agentDef.CodeConfiguration.DependencyResolution == "bundled" + } + if isDotnet && isBundled { + return p.packageDotnetBundled(srcDir) + } + } + } + // Exclusion patterns excludeDirs := map[string]bool{ "__pycache__": true, @@ -958,10 +974,16 @@ func (p *AgentServiceTargetProvider) packageCodeDeploy(serviceConfig *azdext.Ser ".mypy_cache": true, ".pytest_cache": true, ".azure": true, + // .NET directories (for remote_build, exclude build artifacts) + "bin": true, + "obj": true, + ".vs": true, } excludeExts := map[string]bool{ - ".pyc": true, - ".pyo": true, + ".pyc": true, + ".pyo": true, + ".user": true, + ".suo": true, } excludeFiles := map[string]bool{ ".env": true, @@ -1070,6 +1092,121 @@ func (p *AgentServiceTargetProvider) packageCodeDeploy(serviceConfig *azdext.Ser return tmpPath, sha256Hex, nil } +// packageDotnetBundled runs "dotnet publish" for the .NET project and creates a ZIP of the published output. +func (p *AgentServiceTargetProvider) packageDotnetBundled(srcDir string) (string, string, error) { + // Find the .csproj file + entries, err := os.ReadDir(srcDir) + if err != nil { + return "", "", fmt.Errorf("failed to read source directory: %w", err) + } + + var csprojPath string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".csproj") { + csprojPath = filepath.Join(srcDir, e.Name()) + break + } + } + if csprojPath == "" { + return "", "", fmt.Errorf("no .csproj file found in %s; required for dotnet bundled packaging", srcDir) + } + + // Create temp directory for publish output + publishDir, err := os.MkdirTemp("", "azd-dotnet-publish-*") + if err != nil { + return "", "", fmt.Errorf("failed to create temp dir for dotnet publish: %w", err) + } + defer os.RemoveAll(publishDir) + + // Run dotnet publish targeting linux (hosted agents run on linux) + fmt.Fprintf(os.Stderr, "Running 'dotnet publish' for bundled packaging...\n") + cmd := exec.Command("dotnet", "publish", csprojPath, + "-c", "Release", + "-r", "linux-x64", + "--self-contained", "false", + "-o", publishDir, + ) + cmd.Dir = srcDir + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return "", "", fmt.Errorf("dotnet publish failed: %w", err) + } + + // ZIP the publish output + 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() + + 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(publishDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + relPath, relErr := filepath.Rel(publishDir, path) + if relErr != nil { + return relErr + } + + if relPath == "." { + return nil + } + + relPath = filepath.ToSlash(relPath) + + if d.IsDir() { + return nil + } + + fileData, readErr := os.ReadFile(path) //nolint:gosec // path from WalkDir within temp publish dir + if readErr != nil { + return fmt.Errorf("failed to read %s: %w", relPath, readErr) + } + + w, createErr := zipWriter.Create(relPath) + if createErr != nil { + return fmt.Errorf("failed to create ZIP entry %s: %w", relPath, createErr) + } + + if _, writeErr := w.Write(fileData); writeErr != nil { + return fmt.Errorf("failed to write ZIP entry %s: %w", relPath, writeErr) + } + + return nil + }) + + if err != nil { + return "", "", fmt.Errorf("failed to walk publish 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, @@ -1133,7 +1270,11 @@ 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) + cmdPrefix := "python" + if strings.HasPrefix(agentDef.CodeConfiguration.Runtime, "dotnet_") { + cmdPrefix = "dotnet" + } + fmt.Fprintf(os.Stderr, "Entry Point: [\"%s\", \"%s\"]\n", cmdPrefix, agentDef.CodeConfiguration.EntryPoint) depRes := "remote_build" if agentDef.CodeConfiguration.DependencyResolution != nil { depRes = *agentDef.CodeConfiguration.DependencyResolution From c75c858ead58235e62bb3998fc8d48aeae6164fe Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Tue, 12 May 2026 22:34:52 +0800 Subject: [PATCH 2/7] test: add unit tests for dotnet code deploy (entry point detection, API request mapping) --- .../internal/cmd/init_from_code_test.go | 61 +++++++++++++ .../pkg/agents/agent_yaml/map_test.go | 86 +++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go index 7133b9b6bf5..9c570fd627f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go @@ -817,3 +817,64 @@ func TestPromptProtocols_Interactive(t *testing.T) { }) } } + +func TestDetectDefaultEntryPoint(t *testing.T) { + tests := []struct { + name string + files []string + runtime string + want string + }{ + { + name: "dotnet with csproj", + files: []string{"MyAgent.csproj", "Program.cs"}, + runtime: "dotnet_9", + want: "MyAgent.dll", + }, + { + name: "dotnet_8 with csproj", + files: []string{"EchoAgent.csproj", "Program.cs", "NuGet.config"}, + runtime: "dotnet_8", + want: "EchoAgent.dll", + }, + { + name: "dotnet_10 no csproj fallback", + files: []string{"Program.cs"}, + runtime: "dotnet_10", + want: "App.dll", + }, + { + name: "python with app.py", + files: []string{"app.py", "requirements.txt"}, + runtime: "python_3_12", + want: "app.py", + }, + { + name: "python without app.py", + files: []string{"requirements.txt"}, + runtime: "python_3_12", + want: "main.py", + }, + { + name: "python with main.py", + files: []string{"main.py", "requirements.txt"}, + runtime: "python_3_11", + want: "main.py", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + for _, f := range tt.files { + if err := os.WriteFile(filepath.Join(dir, f), []byte(""), 0600); err != nil { + t.Fatal(err) + } + } + got := detectDefaultEntryPoint(dir, tt.runtime) + if got != tt.want { + t.Errorf("detectDefaultEntryPoint() = %q, want %q", got, tt.want) + } + }) + } +} 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 f2e00e98090..999d7189f89 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 @@ -1350,3 +1350,89 @@ func TestCreateHostedAgentAPIRequest_NoSkillsRejected( t.Errorf("error = %q, want 'at least one skill'", err) } } + +func TestCreateAgentAPIRequest_CodeDeploy_DotnetRuntime(t *testing.T) { + depRes := "remote_build" + agent := ContainerAgent{ + AgentDefinition: AgentDefinition{ + Name: "dotnet-agent", + Kind: AgentKindHosted, + }, + Protocols: []ProtocolVersionRecord{ + {Protocol: "responses", Version: "1.0.0"}, + }, + CodeConfiguration: &CodeConfiguration{ + Runtime: "dotnet_9", + EntryPoint: "MyAgent.dll", + DependencyResolution: &depRes, + }, + } + + req, err := CreateAgentAPIRequestFromDefinition(agent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + codeDef, ok := req.Definition.(agent_api.HostedAgentDefinition) + if !ok { + t.Fatalf("expected CodeBasedHostedAgentDefinition, got %T", req.Definition) + } + + // Verify entry_point is ["dotnet", "MyAgent.dll"] + wantEntryPoint := []string{"dotnet", "MyAgent.dll"} + if len(codeDef.CodeConfiguration.EntryPoint) != 2 || + codeDef.CodeConfiguration.EntryPoint[0] != wantEntryPoint[0] || + codeDef.CodeConfiguration.EntryPoint[1] != wantEntryPoint[1] { + t.Errorf("EntryPoint = %v, want %v", codeDef.CodeConfiguration.EntryPoint, wantEntryPoint) + } + + // Verify runtime is passed through + if codeDef.CodeConfiguration.Runtime != "dotnet_9" { + t.Errorf("Runtime = %q, want %q", codeDef.CodeConfiguration.Runtime, "dotnet_9") + } + + // Verify dependency resolution + if codeDef.CodeConfiguration.DependencyResolution != "remote_build" { + t.Errorf("DependencyResolution = %q, want %q", codeDef.CodeConfiguration.DependencyResolution, "remote_build") + } +} + +func TestCreateAgentAPIRequest_CodeDeploy_PythonRuntime(t *testing.T) { + depRes := "bundled" + agent := ContainerAgent{ + AgentDefinition: AgentDefinition{ + Name: "python-agent", + Kind: AgentKindHosted, + }, + Protocols: []ProtocolVersionRecord{ + {Protocol: "invocations", Version: "1.0.0"}, + }, + CodeConfiguration: &CodeConfiguration{ + Runtime: "python_3_12", + EntryPoint: "main.py", + DependencyResolution: &depRes, + }, + } + + req, err := CreateAgentAPIRequestFromDefinition(agent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + codeDef, ok := req.Definition.(agent_api.HostedAgentDefinition) + if !ok { + t.Fatalf("expected CodeBasedHostedAgentDefinition, got %T", req.Definition) + } + + // Verify entry_point is ["python", "main.py"] + wantEntryPoint := []string{"python", "main.py"} + if len(codeDef.CodeConfiguration.EntryPoint) != 2 || + codeDef.CodeConfiguration.EntryPoint[0] != wantEntryPoint[0] || + codeDef.CodeConfiguration.EntryPoint[1] != wantEntryPoint[1] { + t.Errorf("EntryPoint = %v, want %v", codeDef.CodeConfiguration.EntryPoint, wantEntryPoint) + } + + if codeDef.CodeConfiguration.Runtime != "python_3_12" { + t.Errorf("Runtime = %q, want %q", codeDef.CodeConfiguration.Runtime, "python_3_12") + } +} From f4dbae479b6739d6add233b7f31be10b046c13db Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Wed, 13 May 2026 16:58:23 +0800 Subject: [PATCH 3/7] fix(agents): enforce 250 MB max ZIP size for code deploy Reject code packages exceeding 250 MB with a clear error message suggesting to reduce package size or use remote_build. --- .../internal/project/service_target_agent.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 a99576a0215..26f7ea537b1 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 @@ -1086,6 +1086,19 @@ func (p *AgentServiceTargetProvider) packageCodeDeploy(serviceConfig *azdext.Ser return "", "", fmt.Errorf("failed to close temp file: %w", err) } + // Enforce maximum ZIP size (250 MB) + const maxZipSize = 250 * 1024 * 1024 + fi, err := os.Stat(tmpPath) + if err != nil { + return "", "", fmt.Errorf("failed to stat ZIP file: %w", err) + } + if fi.Size() > maxZipSize { + return "", "", fmt.Errorf( + "code package too large: %d MB (max 250 MB). Reduce package size by excluding unnecessary files or using remote_build for dependency resolution", + fi.Size()/(1024*1024), + ) + } + sha256Hex := hex.EncodeToString(hasher.Sum(nil)) success = true From 039de233fffe618a28ac1ba4ae972fc9872673e1 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Wed, 13 May 2026 17:02:39 +0800 Subject: [PATCH 4/7] fix(agents): suppress gosec G204 for dotnet publish command --- .../azure.ai.agents/internal/project/service_target_agent.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 26f7ea537b1..14b32bfb571 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 @@ -1133,7 +1133,7 @@ func (p *AgentServiceTargetProvider) packageDotnetBundled(srcDir string) (string // Run dotnet publish targeting linux (hosted agents run on linux) fmt.Fprintf(os.Stderr, "Running 'dotnet publish' for bundled packaging...\n") - cmd := exec.Command("dotnet", "publish", csprojPath, + cmd := exec.Command("dotnet", "publish", csprojPath, //nolint:gosec // csprojPath is derived from user's project directory "-c", "Release", "-r", "linux-x64", "--self-contained", "false", From 4a907f6216175684926bab51c9cf96eb803c24f2 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Wed, 13 May 2026 20:19:07 +0800 Subject: [PATCH 5/7] fix: detect dotnet projects for code deploy language and filter runtime options - Fix template init path that hardcoded language=python for all code deploy; now correctly sets language=csharp when agent uses a dotnet runtime - Filter runtime options in promptCodeConfig based on detected project type (Python projects see only Python runtimes, .NET only .NET, mixed sees all) - Add isDotnetProject helper (checks for .csproj/.fsproj files) - Enable code deploy option for .NET projects in both init paths - Capture dotnet publish stdout/stderr in error message for better diagnostics - Add TODO comment to CodeDeployRegions for future dynamic discovery --- .../azure.ai.agents/internal/cmd/init.go | 12 +++- .../internal/cmd/init_from_code.go | 67 +++++++++++++++---- .../internal/project/config.go | 1 + .../internal/project/service_target_agent.go | 8 ++- 4 files changed, 70 insertions(+), 18 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 a5409085adc..d48df6a0a1f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -572,10 +572,10 @@ func (a *InitAction) Run(ctx context.Context) error { } // Prompt for deploy mode (code vs container) for hosted agents. - // Code deploy is currently only supported for Python projects. + // Code deploy is supported for Python and .NET projects. if _, ok := agentManifest.Template.(agent_yaml.ContainerAgent); ok { - isPython := isPythonProject(targetDir) - deployMode, err := promptDeployMode(ctx, a.azdClient, a.flags.noPrompt, isPython) + showCodeDeploy := isPythonProject(targetDir) || isDotnetProject(targetDir) + deployMode, err := promptDeployMode(ctx, a.azdClient, a.flags.noPrompt, showCodeDeploy) if err != nil { return fmt.Errorf("prompting for deploy mode: %w", err) } @@ -1631,6 +1631,12 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa if agentDef.Kind == agent_yaml.AgentKindHosted { if a.isCodeDeploy { serviceConfig.Language = "python" + // If the agent uses a dotnet runtime, set language to csharp + if ca, ok := agentManifest.Template.(agent_yaml.ContainerAgent); ok && + ca.CodeConfiguration != nil && + strings.HasPrefix(ca.CodeConfiguration.Runtime, "dotnet_") { + serviceConfig.Language = "csharp" + } } else { serviceConfig.Docker = &azdext.DockerProjectOptions{ RemoteBuild: true, 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 ecae9c72540..9787e9da045 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,12 +470,12 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) agentKind := agent_yaml.AgentKindHosted // Prompt user for deploy mode (container vs code) - // Code deploy is only available for Python projects + // Code deploy is available for Python and .NET projects srcDir := a.flags.src if srcDir == "" { srcDir, _ = os.Getwd() } - showCodeDeploy := isPythonProject(srcDir) + showCodeDeploy := isPythonProject(srcDir) || isDotnetProject(srcDir) deployMode, err := promptDeployMode(ctx, a.azdClient, a.flags.noPrompt, showCodeDeploy) if err != nil { return nil, err @@ -1106,21 +1106,47 @@ func promptCodeConfig(ctx context.Context, azdClient *azdext.AzdClient, srcDir s 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"}, - {Label: ".NET 9", Value: "dotnet_9"}, - {Label: ".NET 8", Value: "dotnet_8"}, - {Label: ".NET 10", Value: "dotnet_10"}, + // Prompt for runtime — filter choices based on detected project type + var runtimeChoices []*azdext.SelectChoice + isDotnet := isDotnetProject(srcDir) + isPython := isPythonProject(srcDir) + + if isDotnet && !isPython { + runtimeChoices = []*azdext.SelectChoice{ + {Label: ".NET 9", Value: "dotnet_9"}, + {Label: ".NET 8", Value: "dotnet_8"}, + {Label: ".NET 10", Value: "dotnet_10"}, + } + } else if isPython && !isDotnet { + 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"}, + } + } else { + // Mixed or unknown — show all options + 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"}, + {Label: ".NET 9", Value: "dotnet_9"}, + {Label: ".NET 8", Value: "dotnet_8"}, + {Label: ".NET 10", Value: "dotnet_10"}, + } } var runtime string if noPrompt { - runtime = "python_3_12" + if isDotnet { + runtime = "dotnet_9" + } else { + runtime = "python_3_12" + } } else { - defaultIdx := int32(1) // Python 3.12 is the default + defaultIdx := int32(0) // First item in the filtered list + if isPython && !isDotnet { + defaultIdx = 1 // Python 3.12 + } runtimeResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ Options: &azdext.SelectOptions{ Message: "Select the runtime for your agent", @@ -1215,3 +1241,20 @@ func isPythonProject(dir string) bool { } return false } + +// isDotnetProject returns true if the directory contains a .csproj or .fsproj file. +func isDotnetProject(dir string) bool { + if dir == "" { + dir = "." + } + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, e := range entries { + if !e.IsDir() && (strings.HasSuffix(e.Name(), ".csproj") || strings.HasSuffix(e.Name(), ".fsproj")) { + return true + } + } + return false +} 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 bb82f3e49e5..2fd95882e99 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/config.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/config.go @@ -18,6 +18,7 @@ const ( ) // CodeDeployRegions lists the regions that currently support code deploy (ZIP upload). +// TODO: replace with dynamic region discovery API when available. var CodeDeployRegions = []string{"westus2", "canadacentral", "northcentralus"} // ResourceTier defines a preset CPU and memory allocation for container resources. 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 14b32bfb571..d643755c896 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,6 +5,7 @@ package project import ( "archive/zip" + "bytes" "context" "crypto/sha256" "encoding/base64" @@ -1140,10 +1141,11 @@ func (p *AgentServiceTargetProvider) packageDotnetBundled(srcDir string) (string "-o", publishDir, ) cmd.Dir = srcDir - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr + var publishOutput bytes.Buffer + cmd.Stdout = io.MultiWriter(os.Stderr, &publishOutput) + cmd.Stderr = io.MultiWriter(os.Stderr, &publishOutput) if err := cmd.Run(); err != nil { - return "", "", fmt.Errorf("dotnet publish failed: %w", err) + return "", "", fmt.Errorf("dotnet publish failed: %w\nOutput:\n%s", err, publishOutput.String()) } // ZIP the publish output From 3e9d12627d910a04356d9bc83aa783b4c1f207af Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Thu, 14 May 2026 10:11:16 +0800 Subject: [PATCH 6/7] fix: address PR review comments (extract RuntimeCmdPrefix, fix defaults, parse AssemblyName) --- .../internal/cmd/init_from_code.go | 62 ++++++++++++++----- .../internal/pkg/agents/agent_yaml/map.go | 15 +++-- .../internal/project/service_target_agent.go | 17 +++-- 3 files changed, 68 insertions(+), 26 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 9787e9da045..12ee1535d6c 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 @@ -862,12 +862,13 @@ func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, language = "docker" } else { // Detect language from agent.yaml runtime - agentYamlPath2 := filepath.Join(a.projectConfig.Path, targetDir, "agent.yaml") - if data, err := os.ReadFile(agentYamlPath2); err == nil { //nolint:gosec // path from project config - var agentDef2 agent_yaml.ContainerAgent - if err := yaml.Unmarshal(data, &agentDef2); err == nil && - agentDef2.CodeConfiguration != nil && - strings.HasPrefix(agentDef2.CodeConfiguration.Runtime, "dotnet_") { + // Re-read agent.yaml to detect the language for azure.yaml service config + langDetectPath := filepath.Join(a.projectConfig.Path, targetDir, "agent.yaml") + if data, err := os.ReadFile(langDetectPath); err == nil { //nolint:gosec // path from project config + var langDef agent_yaml.ContainerAgent + if err := yaml.Unmarshal(data, &langDef); err == nil && + langDef.CodeConfiguration != nil && + strings.HasPrefix(langDef.CodeConfiguration.Runtime, "dotnet_") { language = "csharp" } } @@ -910,11 +911,7 @@ func deriveStartupCommand(projectPath, targetDir string) string { 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 { - cmdPrefix := "python" - if strings.HasPrefix(agentDef.CodeConfiguration.Runtime, "dotnet_") { - cmdPrefix = "dotnet" - } - return cmdPrefix + " " + agentDef.CodeConfiguration.EntryPoint + return agent_yaml.RuntimeCmdPrefix(agentDef.CodeConfiguration.Runtime) + " " + agentDef.CodeConfiguration.EntryPoint } } return "python main.py" @@ -1078,14 +1075,23 @@ func promptDeployMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt } // detectDefaultEntryPoint returns a sensible default entry point based on the runtime and source directory. +// TODO: reuse this logic in the `run` command (tracked as future work item). func detectDefaultEntryPoint(srcDir, runtime string) string { if strings.HasPrefix(runtime, "dotnet_") { - // Look for .csproj file and derive DLL name + // Look for .csproj file and derive DLL name from or project filename entries, err := os.ReadDir(srcDir) if err == nil { for _, e := range entries { if !e.IsDir() && strings.HasSuffix(e.Name(), ".csproj") { - return strings.TrimSuffix(e.Name(), ".csproj") + ".dll" + dllName := strings.TrimSuffix(e.Name(), ".csproj") + ".dll" + // Try to parse from the csproj + csprojPath := filepath.Join(srcDir, e.Name()) + if data, readErr := os.ReadFile(csprojPath); readErr == nil { //nolint:gosec // path from user project + if asmName := extractAssemblyName(string(data)); asmName != "" { + dllName = asmName + ".dll" + } + } + return dllName } } } @@ -1099,6 +1105,28 @@ func detectDefaultEntryPoint(srcDir, runtime string) string { return "main.py" } +// extractAssemblyName parses the property from a .csproj file content. +// Returns empty string if not found. +func extractAssemblyName(csprojContent string) string { + const startTag = "" + const endTag = "" + start := strings.Index(csprojContent, startTag) + if start < 0 { + return "" + } + start += len(startTag) + end := strings.Index(csprojContent[start:], endTag) + if end < 0 { + return "" + } + name := strings.TrimSpace(csprojContent[start : start+end]) + if name == "" || strings.ContainsAny(name, "$()") { + // Skip MSBuild property references like $(MSBuildProjectName) + return "" + } + return name +} + // 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) { @@ -1137,10 +1165,10 @@ func promptCodeConfig(ctx context.Context, azdClient *azdext.AzdClient, srcDir s var runtime string if noPrompt { - if isDotnet { + if isDotnet && !isPython { runtime = "dotnet_9" } else { - runtime = "python_3_12" + runtime = "python_3_12" // default to python for backward compatibility (including mixed repos) } } else { defaultIdx := int32(0) // First item in the filtered list @@ -1243,6 +1271,8 @@ func isPythonProject(dir string) bool { } // isDotnetProject returns true if the directory contains a .csproj or .fsproj file. +// isDotnetProject returns true if the directory contains a .csproj file. +// NOTE: .fsproj (F#) is not yet supported by the packaging path (packageDotnetBundled/detectDefaultEntryPoint). func isDotnetProject(dir string) bool { if dir == "" { dir = "." @@ -1252,7 +1282,7 @@ func isDotnetProject(dir string) bool { return false } for _, e := range entries { - if !e.IsDir() && (strings.HasSuffix(e.Name(), ".csproj") || strings.HasSuffix(e.Name(), ".fsproj")) { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".csproj") { return true } } 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 0cd06548a93..021d1c67341 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 @@ -14,6 +14,15 @@ import ( "go.yaml.in/yaml/v3" ) +// RuntimeCmdPrefix returns the command prefix for a given runtime string. +// For example, "python_3_12" -> "python", "dotnet_9" -> "dotnet". +func RuntimeCmdPrefix(runtime string) string { + if strings.HasPrefix(runtime, "dotnet_") { + return "dotnet" + } + return "python" +} + // AgentBuildOption represents an option for building agent definitions type AgentBuildOption func(*AgentBuildConfig) @@ -357,11 +366,7 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB // Code deploy path if hostedAgent.CodeConfiguration != nil { - // Derive the command prefix from the runtime (e.g. "python" for python_3_12, "dotnet" for dotnet_9). - cmdPrefix := "python" - if strings.HasPrefix(hostedAgent.CodeConfiguration.Runtime, "dotnet_") { - cmdPrefix = "dotnet" - } + cmdPrefix := RuntimeCmdPrefix(hostedAgent.CodeConfiguration.Runtime) entryPoint := []string{cmdPrefix, hostedAgent.CodeConfiguration.EntryPoint} depRes := "" if hostedAgent.CodeConfiguration.DependencyResolution != 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 d643755c896..d4be6e75d8d 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 @@ -955,7 +955,7 @@ func (p *AgentServiceTargetProvider) packageCodeDeploy(serviceConfig *azdext.Ser var agentDef agent_yaml.ContainerAgent if err := yaml.Unmarshal(data, &agentDef); err == nil && agentDef.CodeConfiguration != nil { isDotnet := strings.HasPrefix(agentDef.CodeConfiguration.Runtime, "dotnet_") - isBundled := true // default is bundled + isBundled := false // default is remote_build (matches promptCodeConfig and deployHostedCodeAgent defaults) if agentDef.CodeConfiguration.DependencyResolution != nil { isBundled = *agentDef.CodeConfiguration.DependencyResolution == "bundled" } @@ -966,6 +966,7 @@ func (p *AgentServiceTargetProvider) packageCodeDeploy(serviceConfig *azdext.Ser } // Exclusion patterns + // TODO: support a .azdignore or similar ignore file for user-configurable exclusions. excludeDirs := map[string]bool{ "__pycache__": true, ".venv": true, @@ -1216,6 +1217,15 @@ func (p *AgentServiceTargetProvider) packageDotnetBundled(srcDir string) (string return "", "", fmt.Errorf("failed to close temp file: %w", err) } + // Enforce maximum ZIP size (250 MB) — same limit as packageCodeDeploy + const maxZipSizeBundled = 250 * 1024 * 1024 + if fi, statErr := os.Stat(tmpPath); statErr == nil && fi.Size() > maxZipSizeBundled { + return "", "", fmt.Errorf( + "bundled package too large: %d MB (max 250 MB). Consider using remote_build for dependency resolution", + fi.Size()/(1024*1024), + ) + } + sha256Hex := hex.EncodeToString(hasher.Sum(nil)) success = true @@ -1285,10 +1295,7 @@ func (p *AgentServiceTargetProvider) deployHostedCodeAgent( if agentDef.CodeConfiguration != nil { fmt.Fprintf(os.Stderr, "Runtime: %s\n", agentDef.CodeConfiguration.Runtime) - cmdPrefix := "python" - if strings.HasPrefix(agentDef.CodeConfiguration.Runtime, "dotnet_") { - cmdPrefix = "dotnet" - } + cmdPrefix := agent_yaml.RuntimeCmdPrefix(agentDef.CodeConfiguration.Runtime) fmt.Fprintf(os.Stderr, "Entry Point: [\"%s\", \"%s\"]\n", cmdPrefix, agentDef.CodeConfiguration.EntryPoint) depRes := "remote_build" if agentDef.CodeConfiguration.DependencyResolution != nil { From 17ed20018ce94eadc4bd78570ba2b24dfdaffc09 Mon Sep 17 00:00:00 2001 From: Jian Wu Date: Thu, 14 May 2026 11:07:48 +0800 Subject: [PATCH 7/7] fix: remove duplicate doc comment on isDotnetProject --- .../extensions/azure.ai.agents/internal/cmd/init_from_code.go | 1 - 1 file changed, 1 deletion(-) 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 12ee1535d6c..260467dd124 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 @@ -1270,7 +1270,6 @@ func isPythonProject(dir string) bool { return false } -// isDotnetProject returns true if the directory contains a .csproj or .fsproj file. // isDotnetProject returns true if the directory contains a .csproj file. // NOTE: .fsproj (F#) is not yet supported by the packaging path (packageDotnetBundled/detectDefaultEntryPoint). func isDotnetProject(dir string) bool {