diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/dev.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/dev.go new file mode 100644 index 00000000000..8d4f2458221 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/dev.go @@ -0,0 +1,370 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type devFlags struct { + src string + port int + name string + startCommand string +} + +func newRunCommand() *cobra.Command { + flags := &devFlags{} + + cmd := &cobra.Command{ + Use: "run", + Short: "Run your agent locally for development.", + Long: `Run your agent locally for development. + +Detects the project type (Python, .NET, Node.js), installs dependencies, +and starts the agent server in the foreground. Press Ctrl+C to stop. + +The startup command is read from the startupCommand property of the +agent service in azure.yaml. If not set, it is auto-detected from the +project type. Use --start-command to override both. + +Use a separate terminal to invoke the running agent: + azd ai agent invoke "Hello!"`, + Example: ` # Start the agent in the current directory + azd ai agent run + + # Start from a specific source directory + azd ai agent run --src ./my-agent + + # Start a specific agent by name + azd ai agent run --name my-agent + + # Start on a custom port + azd ai agent run --port 9090 + + # Start with an explicit command + azd ai agent run --start-command "python app.py"`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + return runDev(ctx, flags) + }, + } + + cmd.Flags().StringVarP(&flags.src, "src", "s", ".", "Project source directory") + cmd.Flags().IntVarP(&flags.port, "port", "p", DefaultPort, "Port to listen on") + cmd.Flags().StringVarP(&flags.name, "name", "n", "", "Agent service name (from azure.yaml)") + cmd.Flags().StringVarP(&flags.startCommand, "start-command", "c", "", + "Explicit startup command (overrides azure.yaml and auto-detection)") + + return cmd +} + +func runDev(ctx context.Context, flags *devFlags) error { + projectDir, err := filepath.Abs(flags.src) + if err != nil { + return fmt.Errorf("invalid source directory: %w", err) + } + + if _, err := os.Stat(projectDir); os.IsNotExist(err) { + return fmt.Errorf("source directory does not exist: %s", projectDir) + } + + // Resolve start command: --start-command flag > azure.yaml startupCommand > detect + startCmd := flags.startCommand + if startCmd == "" { + startCmd = resolveStartupCommandFromService(ctx, flags.name) + } + + if startCmd == "" { + pt := detectProjectType(projectDir) + if pt.StartCmd != "" { + startCmd = pt.StartCmd + fmt.Printf("Detected %s project. Start command: %s\n", pt.Language, startCmd) + } else { + return fmt.Errorf( + "could not detect project type in %s\n\n"+ + "Supported project types:\n"+ + " - Python (pyproject.toml or requirements.txt)\n"+ + " - .NET (*.csproj)\n"+ + " - Node.js (package.json)\n\n"+ + "Use --start-command to specify explicitly, or set startupCommand in azure.yaml", + projectDir, + ) + } + } else { + fmt.Printf("Using startup command from azure.yaml: %s\n", startCmd) + } + + // Install dependencies + if err := installDependencies(projectDir); err != nil { + return fmt.Errorf("failed to install dependencies: %w", err) + } + + // Build the command + cmdParts := parseCommand(startCmd) + if len(cmdParts) == 0 { + return fmt.Errorf("empty start command") + } + + cmdParts = resolveVenvCommand(projectDir, cmdParts) + + env := os.Environ() + env = append(env, fmt.Sprintf("PORT=%d", flags.port)) + + // Load azd environment variables (e.g., AZURE_AI_PROJECT_ENDPOINT) + // so the agent can reach Azure services during local development + if azdEnvVars, err := loadAzdEnvironment(ctx); err == nil { + for k, v := range azdEnvVars { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + } + + url := fmt.Sprintf("http://localhost:%d", flags.port) + fmt.Println() + fmt.Println("In another terminal, try:") + fmt.Printf(" azd ai agent invoke \"Hello!\"\n\n") + fmt.Printf("Starting agent on %s (Ctrl+C to stop)\n\n", url) + + // Create command with stdout/stderr piped to terminal + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + proc := exec.CommandContext(ctx, cmdParts[0], cmdParts[1:]...) + proc.Dir = projectDir + proc.Env = env + proc.Stdout = os.Stdout + proc.Stderr = os.Stderr + proc.Stdin = os.Stdin + + if err := proc.Start(); err != nil { + return fmt.Errorf("failed to start agent: %w", err) + } + + // Handle Ctrl+C: forward signal to child, then wait for it to exit + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt) + go func() { + <-sigCh + fmt.Println("\nStopping agent...") + cancel() + }() + + err = proc.Wait() + + // Suppress the noisy "signal: interrupt" error on Ctrl+C + if ctx.Err() != nil { + fmt.Println("Agent stopped.") + return nil + } + + if err != nil { + return fmt.Errorf("agent exited: %w", err) + } + return nil +} + +// --- Dependency installation --- + +func installDependencies(projectDir string) error { + pt := detectProjectType(projectDir) + + switch pt.Language { + case "python": + return installPythonDeps(projectDir) + case "node": + return installNodeDeps(projectDir) + case "dotnet": + return nil + } + return nil +} + +func installPythonDeps(projectDir string) error { + if _, err := exec.LookPath("uv"); err != nil { + fmt.Println("Warning: uv is not installed. Install it from https://docs.astral.sh/uv/") + fmt.Println("Falling back to pip...") + return installPythonDepsPip(projectDir) + } + + venvDir := filepath.Join(projectDir, ".venv") + if _, err := os.Stat(venvDir); os.IsNotExist(err) { + fmt.Println("Setting up Python environment...") + cmd := exec.Command("uv", "venv", venvDir, "--python", ">=3.12") + cmd.Dir = projectDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to create venv: %w", err) + } + } + + pythonPath := venvPython(venvDir) + + if fileExists(filepath.Join(projectDir, "pyproject.toml")) { + fmt.Println("Installing dependencies (pyproject.toml)...") + cmd := exec.Command("uv", "pip", "install", "-e", ".", "--python", pythonPath, "--prerelease", "allow", "--quiet") + cmd.Dir = projectDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("uv pip install failed: %w", err) + } + fmt.Println(" ✓ Dependencies installed (pyproject.toml)") + } + + if fileExists(filepath.Join(projectDir, "requirements.txt")) { + fmt.Println("Installing dependencies (requirements.txt)...") + cmd := exec.Command("uv", "pip", "install", "-r", "requirements.txt", "--python", pythonPath, "--prerelease", "allow", "--quiet") + cmd.Dir = projectDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("uv pip install failed: %w", err) + } + fmt.Println(" ✓ Dependencies installed (requirements.txt)") + } + + return nil +} + +func installPythonDepsPip(projectDir string) error { + if fileExists(filepath.Join(projectDir, "requirements.txt")) { + fmt.Println("Installing dependencies (requirements.txt)...") + cmd := exec.Command("pip", "install", "-r", "requirements.txt", "-q") + cmd.Dir = projectDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("pip install failed: %w", err) + } + fmt.Println(" ✓ Dependencies installed (requirements.txt)") + } + return nil +} + +func installNodeDeps(projectDir string) error { + if fileExists(filepath.Join(projectDir, "package.json")) { + fmt.Println("Installing dependencies (package.json)...") + cmd := exec.Command("npm", "install", "--quiet") + cmd.Dir = projectDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("npm install failed: %w", err) + } + fmt.Println(" ✓ Dependencies installed (package.json)") + } + return nil +} + +// --- Command parsing utilities --- + +func parseCommand(cmd string) []string { + var parts []string + var current strings.Builder + inQuote := false + quoteChar := byte(0) + + for i := 0; i < len(cmd); i++ { + c := cmd[i] + if inQuote { + if c == quoteChar { + inQuote = false + } else { + current.WriteByte(c) + } + } else if c == '"' || c == '\'' { + inQuote = true + quoteChar = c + } else if c == ' ' { + if current.Len() > 0 { + parts = append(parts, current.String()) + current.Reset() + } + } else { + current.WriteByte(c) + } + } + if current.Len() > 0 { + parts = append(parts, current.String()) + } + return parts +} + +func resolveVenvCommand(projectDir string, cmdParts []string) []string { + if len(cmdParts) == 0 { + return cmdParts + } + + venvDir := filepath.Join(projectDir, ".venv") + if _, err := os.Stat(venvDir); os.IsNotExist(err) { + return cmdParts + } + + pythonPath := venvPython(venvDir) + + if cmdParts[0] == "python" || cmdParts[0] == "python3" { + cmdParts[0] = pythonPath + } else { + binDir := venvBinDir(venvDir) + binPath := filepath.Join(binDir, cmdParts[0]) + if fileExists(binPath) { + cmdParts[0] = binPath + } + } + + return cmdParts +} + +func venvPython(venvDir string) string { + if runtime.GOOS == "windows" { + return filepath.Join(venvDir, "Scripts", "python.exe") + } + return filepath.Join(venvDir, "bin", "python") +} + +func venvBinDir(venvDir string) string { + if runtime.GOOS == "windows" { + return filepath.Join(venvDir, "Scripts") + } + return filepath.Join(venvDir, "bin") +} + +// loadAzdEnvironment reads all key-value pairs from the current azd environment. +func loadAzdEnvironment(ctx context.Context) (map[string]string, error) { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return nil, err + } + defer azdClient.Close() + + envResponse, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + return nil, err + } + + resp, err := azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: envResponse.Environment.Name, + }) + if err != nil { + return nil, err + } + + result := make(map[string]string, len(resp.KeyValues)) + for _, kv := range resp.KeyValues { + result[kv.Key] = kv.Value + } + return result, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go new file mode 100644 index 00000000000..4a2ad32a4b6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go @@ -0,0 +1,386 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "crypto/rand" + "fmt" + "math/big" + "os" + "path/filepath" + "strings" + + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +const ( + // DefaultPort is the default port for local agent servers. + DefaultPort = 8088 + + // Environment variable keys for invoke state + EnvKeyAgentInvokeName = "AZD_AI_AGENT_INVOKE_NAME" + EnvKeyAgentInvokeSessionID = "AZD_AI_AGENT_INVOKE_SESSIONID" + EnvKeyAgentInvokeConversationID = "AZD_AI_AGENT_INVOKE_CONVERSATIONID" +) + +// getInvokeEnvValue reads an invoke state env var from the current azd environment. +func getInvokeEnvValue(ctx context.Context, key string) string { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return "" + } + defer azdClient.Close() + + envResp, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + return "" + } + + val, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envResp.Environment.Name, + Key: key, + }) + if err != nil || val.Value == "" { + return "" + } + return val.Value +} + +// setInvokeEnvValue writes an invoke state env var to the current azd environment. +func setInvokeEnvValue(ctx context.Context, key, value string) error { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return err + } + defer azdClient.Close() + + envResp, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + return err + } + + _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: envResp.Environment.Name, + Key: key, + Value: value, + }) + return err +} + +// resolveSessionID resolves or generates a session ID for invoke. +// State is stored in the azd environment via AZD_AI_AGENT_INVOKE_SESSIONID. +func resolveSessionID(ctx context.Context, agentName string, explicit string, forceNew bool) string { + if explicit != "" { + return explicit + } + + currentName := getInvokeEnvValue(ctx, EnvKeyAgentInvokeName) + if !forceNew && currentName == agentName { + if sid := getInvokeEnvValue(ctx, EnvKeyAgentInvokeSessionID); sid != "" { + return sid + } + } + + sid := generateSessionID() + _ = setInvokeEnvValue(ctx, EnvKeyAgentInvokeName, agentName) + _ = setInvokeEnvValue(ctx, EnvKeyAgentInvokeSessionID, sid) + return sid +} + +// resolveConversationID resolves the conversation ID from the azd environment. +// Returns empty string if no conversation exists or the agent changed. +func resolveConversationID(ctx context.Context, agentName string, forceNew bool) string { + if forceNew { + return "" + } + + currentName := getInvokeEnvValue(ctx, EnvKeyAgentInvokeName) + if currentName != agentName { + return "" + } + + return getInvokeEnvValue(ctx, EnvKeyAgentInvokeConversationID) +} + +// saveConversationID persists a conversation ID in the azd environment. +func saveConversationID(ctx context.Context, agentName, convID string) { + _ = setInvokeEnvValue(ctx, EnvKeyAgentInvokeName, agentName) + _ = setInvokeEnvValue(ctx, EnvKeyAgentInvokeConversationID, convID) +} + +// generateSessionID creates a random 25-character session ID (lowercase + digits). +func generateSessionID() string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, 25) + for i := range b { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars)))) + if err != nil { + panic(fmt.Sprintf("crypto/rand failed: %v", err)) + } + b[i] = chars[n.Int64()] + } + return string(b) +} + +// detectProjectType detects the project type and suggests a start command. +type ProjectType struct { + Language string // "python", "dotnet", "node", "unknown" + StartCmd string // suggested start command +} + +func detectProjectType(projectDir string) ProjectType { + // Detect Python entrypoint + pyEntry := detectPythonEntrypoint(projectDir) + + // Python with pyproject.toml → uv-managed project + if fileExists(filepath.Join(projectDir, "pyproject.toml")) { + cmd := "uv run python main.py" + if pyEntry != "" { + cmd = "uv run python " + pyEntry + } + return ProjectType{Language: "python", StartCmd: cmd} + } + + // Python with requirements.txt + if fileExists(filepath.Join(projectDir, "requirements.txt")) { + cmd := "python main.py" + if pyEntry != "" { + cmd = "python " + pyEntry + } + return ProjectType{Language: "python", StartCmd: cmd} + } + + // .NET: find .csproj file and use its name + matches, _ := filepath.Glob(filepath.Join(projectDir, "*.csproj")) + if len(matches) > 0 { + csprojName := filepath.Base(matches[0]) + return ProjectType{Language: "dotnet", StartCmd: "dotnet " + csprojName} + } + + // Node.js: package.json + if fileExists(filepath.Join(projectDir, "package.json")) { + return ProjectType{Language: "node", StartCmd: "npm start"} + } + + // Bare Python entrypoint as fallback + if pyEntry != "" { + return ProjectType{Language: "python", StartCmd: "python " + pyEntry} + } + + return ProjectType{Language: "unknown", StartCmd: ""} +} + +// detectPythonEntrypoint returns the name of the Python entrypoint file found in projectDir. +func detectPythonEntrypoint(projectDir string) string { + for _, name := range []string{"main.py", "app.py"} { + if fileExists(filepath.Join(projectDir, name)) { + return name + } + } + return "" +} + +// promptStartupCommand detects the project startup command and prompts the user to confirm or override. +// If flagValue is set (from --startup-command), it is returned directly. +// If noPrompt is true, the auto-detected value is returned without prompting. +func promptStartupCommand( + ctx context.Context, + azdClient *azdext.AzdClient, + projectDir string, + flagValue string, + noPrompt bool, +) (string, error) { + if flagValue != "" { + return flagValue, nil + } + + detected := detectProjectType(projectDir) + defaultCmd := detected.StartCmd + + if noPrompt { + return defaultCmd, nil + } + + message := "Enter startup command for the agent" + if detected.Language != "unknown" && detected.Language != "" { + message = fmt.Sprintf("Enter startup command for the agent (detected %s project)", detected.Language) + } + + resp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: message, + DefaultValue: defaultCmd, + IgnoreHintKeys: true, + }, + }) + if err != nil { + return "", fmt.Errorf("prompting for startup command: %w", err) + } + + return resp.Value, nil +} + +// resolveProjectDir resolves a relative targetDir to an absolute path using the azd project root. +func resolveProjectDir(ctx context.Context, azdClient *azdext.AzdClient, targetDir string) (string, error) { + if filepath.IsAbs(targetDir) { + return targetDir, nil + } + + projectResponse, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + return "", fmt.Errorf("getting project path: %w", err) + } + + return filepath.Join(projectResponse.Project.Path, targetDir), nil +} + +// parseEndpoint extracts account and project names from a Foundry project endpoint URL. +// e.g., "https://myaccount.services.ai.azure.com/api/projects/myproject" → ("myaccount", "myproject") +func parseEndpoint(endpoint string) (account, project string, err error) { + endpoint = strings.TrimRight(endpoint, "/") + // Extract account from hostname + if !strings.Contains(endpoint, "://") { + return "", "", fmt.Errorf("invalid endpoint URL: %s", endpoint) + } + hostPath := strings.SplitN(endpoint, "://", 2)[1] + hostParts := strings.SplitN(hostPath, "/", 2) + hostname := hostParts[0] + account = strings.SplitN(hostname, ".", 2)[0] + + // Extract project from path + pathParts := strings.Split(endpoint, "/") + if len(pathParts) > 0 { + project = pathParts[len(pathParts)-1] + } + if account == "" || project == "" { + return "", "", fmt.Errorf("could not parse account/project from endpoint: %s", endpoint) + } + return account, project, nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// AgentServiceInfo holds the resolved name for an agent service. +type AgentServiceInfo struct { + ServiceName string // azure.yaml service key + AgentName string // agent name (same as service name) +} + +// resolveAgentServiceFromProject finds the first azure.ai.agent service in azure.yaml. +// The name parameter filters to a specific service; empty means use the first one found. +// The service name from azure.yaml is used directly as the agent name. +func resolveAgentServiceFromProject(ctx context.Context, name string) (*AgentServiceInfo, error) { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return nil, fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + projectResponse, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil || projectResponse.Project == nil { + return nil, fmt.Errorf("failed to get project config: %w", err) + } + + // Find the matching azure.ai.agent service + var svc *azdext.ServiceConfig + for _, s := range projectResponse.Project.Services { + if s.Host != AiAgentHost { + continue + } + if name != "" && s.Name != name { + continue + } + svc = s + break + } + + if svc == nil { + if name != "" { + return nil, fmt.Errorf("no azure.ai.agent service named '%s' found in azure.yaml", name) + } + return nil, fmt.Errorf("no azure.ai.agent service found in azure.yaml") + } + + return &AgentServiceInfo{ + ServiceName: svc.Name, + AgentName: svc.Name, + }, nil +} + +// resolveLatestVersion fetches the latest deployed version of an agent via the API. +func resolveLatestVersion(ctx context.Context, accountName, projectName, agentName string) (string, error) { + endpoint, err := resolveAgentEndpoint(ctx, accountName, projectName) + if err != nil { + return "", err + } + + credential, err := newAgentCredential() + if err != nil { + return "", err + } + + client := agent_api.NewAgentClient(endpoint, credential) + agent, err := client.GetAgent(ctx, agentName, DefaultAgentAPIVersion) + if err != nil { + return "", fmt.Errorf("failed to get agent '%s': %w", agentName, err) + } + + version := agent.Versions.Latest.Version + if version == "" { + return "", fmt.Errorf("agent '%s' has no deployed versions", agentName) + } + + return version, nil +} + +// resolveStartupCommandFromService reads startupCommand from an azure.ai.agent +// service's AdditionalProperties. Returns empty string if unavailable. +func resolveStartupCommandFromService(ctx context.Context, name string) string { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return "" + } + defer azdClient.Close() + + projectResponse, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil || projectResponse.Project == nil { + return "" + } + + for _, s := range projectResponse.Project.Services { + if s.Host != AiAgentHost { + continue + } + if name != "" && s.Name != name { + continue + } + if s.AdditionalProperties == nil { + return "" + } + fields := s.AdditionalProperties.GetFields() + if fields == nil { + return "" + } + v, ok := fields["startupCommand"] + if !ok || v == nil { + return "" + } + return v.GetStringValue() + } + + return "" +} + +// toServiceKey converts a service name into the env var key format (uppercase, underscores). +func toServiceKey(serviceName string) string { + key := strings.ReplaceAll(serviceName, " ", "_") + key = strings.ReplaceAll(key, "-", "_") + return strings.ToUpper(key) +} 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 f81019fa402..fe8ea5cb2c2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -15,7 +15,6 @@ import ( "os" "path/filepath" "regexp" - "strconv" "strings" "time" @@ -47,9 +46,12 @@ type initFlags struct { *rootFlagsDefinition projectResourceId string manifestPointer string + templateUrl string src string host string env string + infra bool + startupCommand string } // AiProjectResourceConfig represents the configuration for an AI project resource @@ -109,7 +111,7 @@ func newInitCommand(rootFlags *rootFlagsDefinition) *cobra.Command { } cmd := &cobra.Command{ - Use: "init [-m ] [--src ]", + Use: "init [-m ] [-t