Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
Expand Down
137 changes: 123 additions & 14 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -860,6 +860,18 @@ func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string,
language := "python"
if !isCodeDeploy {
language = "docker"
} else {
// Detect language from agent.yaml runtime
// 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"
}
}
}

serviceConfig := &azdext.ServiceConfig{
Expand Down Expand Up @@ -899,7 +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 {
return "python " + agentDef.CodeConfiguration.EntryPoint
return agent_yaml.RuntimeCmdPrefix(agentDef.CodeConfiguration.Runtime) + " " + agentDef.CodeConfiguration.EntryPoint
}
}
return "python main.py"
Expand Down Expand Up @@ -1062,25 +1074,107 @@ 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.
// TODO: reuse this logic in the `run` command (tracked as future work item).
func detectDefaultEntryPoint(srcDir, runtime string) string {
Comment thread
v1212 marked this conversation as resolved.
if strings.HasPrefix(runtime, "dotnet_") {
// Look for .csproj file and derive DLL name from <AssemblyName> or project filename
entries, err := os.ReadDir(srcDir)
if err == nil {
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".csproj") {
dllName := strings.TrimSuffix(e.Name(), ".csproj") + ".dll"
// Try to parse <AssemblyName> 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
}
}
}
Comment thread
v1212 marked this conversation as resolved.
Comment thread
v1212 marked this conversation as resolved.
return "App.dll"
}

// Python default
if _, err := os.Stat(filepath.Join(srcDir, "app.py")); err == nil {
return "app.py"
}
return "main.py"
}

// extractAssemblyName parses the <AssemblyName> property from a .csproj file content.
// Returns empty string if not found.
func extractAssemblyName(csprojContent string) string {
const startTag = "<AssemblyName>"
const endTag = "</AssemblyName>"
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) {
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"},
// Prompt for runtime — filter choices based on detected project type
var runtimeChoices []*azdext.SelectChoice
isDotnet := isDotnetProject(srcDir)
isPython := isPythonProject(srcDir)

if isDotnet && !isPython {
Comment thread
trangevi marked this conversation as resolved.
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 && !isPython {
runtime = "dotnet_9"
} else {
runtime = "python_3_12" // default to python for backward compatibility (including mixed repos)
}
} 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",
Expand All @@ -1098,10 +1192,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 {
Expand Down Expand Up @@ -1178,3 +1269,21 @@ func isPythonProject(dir string) bool {
}
return false
}

// 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 = "."
}
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".csproj") {
return true
}
}
return false
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -357,7 +366,8 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB

// Code deploy path
if hostedAgent.CodeConfiguration != nil {
entryPoint := []string{"python", hostedAgent.CodeConfiguration.EntryPoint}
cmdPrefix := RuntimeCmdPrefix(hostedAgent.CodeConfiguration.Runtime)
entryPoint := []string{cmdPrefix, hostedAgent.CodeConfiguration.EntryPoint}
depRes := ""
if hostedAgent.CodeConfiguration.DependencyResolution != nil {
depRes = *hostedAgent.CodeConfiguration.DependencyResolution
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading
Loading