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 5a7ebe3a710..3441ca67ee4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -671,6 +671,10 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, // Auto-detect an existing agent manifest in the target directory // when no --manifest flag was provided. + // + // manifestDetectedButDeclined: gates the definition-reuse scan below so + // a declined manifest is not re-discovered and mis-classified. + manifestDetectedButDeclined := false if flags.manifestPointer == "" { checkDir := flags.src if checkDir == "" { @@ -705,6 +709,49 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, if flags.src == "" { flags.src = checkDir } + } else { + manifestDetectedButDeclined = true + } + } + } + + // When no manifest was detected, look for a bare agent.yaml definition + // to reuse (issue #7268). Skips the init-mode prompt and from-code + // scaffolding. Bypassed when the user already declined a manifest above. + if flags.manifestPointer == "" && !manifestDetectedButDeclined { + checkDir := flags.src + if checkDir == "" { + checkDir = "." + } + existing, findErr := findExistingAgentYaml(checkDir) + if findErr != nil { + return findErr + } + if existing != "" { + useExisting := flags.noPrompt + if !flags.noPrompt { + confirmResp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: fmt.Sprintf( + "An existing agent definition was found at %q. Use it?", + existing, + ), + DefaultValue: new(true), + }, + }) + if promptErr != nil { + if exterrors.IsCancellation(promptErr) { + return exterrors.Cancelled("initialization was cancelled") + } + return fmt.Errorf("prompting for definition reuse: %w", promptErr) + } + useExisting = *confirmResp.Value + } + if useExisting { + if flags.src == "" { + flags.src = checkDir + } + return runReuseDefinition(ctx, flags, azdClient, httpClient, checkDir, existing) } } } 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 242b9d18a14..266358d2928 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 @@ -89,21 +89,28 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error { srcDir = "." } - // Check if agent.yaml already exists before the interactive setup so the user - // doesn't complete the full agent configuration only to have it discarded. - agentYamlPath := filepath.Join(srcDir, "agent.yaml") - if _, statErr := os.Stat(agentYamlPath); statErr == nil { + // Guard against silently overwriting an existing agent definition. Reached + // when the user declined the reuse prompt in RunE or bypassed it; we still + // refuse in --no-prompt and confirm interactively. + if existing, statErr := findExistingAgentYaml(srcDir); statErr == nil && existing != "" { + displayPath, relErr := filepath.Rel(srcDir, existing) + if relErr != nil || displayPath == "" { + displayPath = existing + } if a.flags.noPrompt { return exterrors.Validation( exterrors.CodeInvalidAgentManifest, - fmt.Sprintf("agent.yaml already exists at %q", agentYamlPath), - "delete or move the existing agent.yaml, or run interactively to confirm overwrite", + fmt.Sprintf("%s already exists at %q", displayPath, existing), + fmt.Sprintf( + "delete or move the existing %s, or run interactively to confirm overwrite", + displayPath, + ), ) } confirmResp, err := a.azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ Options: &azdext.ConfirmOptions{ - Message: fmt.Sprintf("An agent.yaml already exists in %q. Overwrite?", srcDir), + Message: fmt.Sprintf("An agent definition already exists at %q. Overwrite?", displayPath), DefaultValue: new(false), }, }) @@ -114,7 +121,7 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error { return fmt.Errorf("prompting for overwrite confirmation: %w", err) } if !*confirmResp.Value { - return exterrors.Cancelled("agent.yaml already exists; overwrite declined") + return exterrors.Cancelled(fmt.Sprintf("%s already exists; overwrite declined", displayPath)) } } @@ -997,16 +1004,21 @@ func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, 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 + // Detect language from the on-disk definition. Skip manifest filenames: + // their fields are nested under template: and would not match here. + for _, name := range []string{"agent.yaml", "agent.yml"} { + langDetectPath := filepath.Join(a.projectConfig.Path, targetDir, name) + data, err := os.ReadFile(langDetectPath) //nolint:gosec // path from project config + if err != nil { + continue + } var langDef agent_yaml.ContainerAgent if err := yaml.Unmarshal(data, &langDef); err == nil && langDef.CodeConfiguration != nil && strings.HasPrefix(langDef.CodeConfiguration.Runtime, "dotnet_") { language = "csharp" } + break } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_reuse.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_reuse.go new file mode 100644 index 00000000000..1694f25f0c7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_reuse.go @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" + "context" + "errors" + "fmt" + "io/fs" + "net/http" + "os" + "path/filepath" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" + "go.yaml.in/yaml/v3" +) + +// agentYamlCandidates lists the file names (in priority order) scanned by the +// reuse path. Order matches detectLocalManifest in init_from_templates_helpers.go. +var agentYamlCandidates = []string{ + "agent.manifest.yaml", + "agent.yaml", + "agent.manifest.yml", + "agent.yml", +} + +// findExistingAgentYaml returns the first agent yaml file found in srcDir, or +// an empty string when none exists. The scan is shallow. +// +// Called from RunE after detectLocalManifest. A path returned here is either a +// bare definition or a malformed manifest; runReuseDefinition distinguishes them. +func findExistingAgentYaml(srcDir string) (string, error) { + for _, name := range agentYamlCandidates { + candidate := filepath.Join(srcDir, name) + info, err := os.Stat(candidate) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return "", fmt.Errorf("checking for %s: %w", candidate, err) + } + if info.IsDir() { + continue + } + return candidate, nil + } + + return "", nil +} + +// runReuseDefinition wires an existing bare agent.yaml definition into +// azure.yaml without rewriting the file or running the from-code prompts. +// +// Foundry project resolution and model deployment selection are intentionally +// skipped (issue #7268: "less to ask and just setup azure.yaml"). Users who +// need a project bound before azd deploy can set AZURE_AI_PROJECT_ID by hand. +func runReuseDefinition( + ctx context.Context, + flags *initFlags, + azdClient *azdext.AzdClient, + httpClient *http.Client, + srcDir string, + existingPath string, +) error { + displayPath, err := filepath.Rel(srcDir, existingPath) + if err != nil || displayPath == "" { + displayPath = existingPath + } + + def, err := loadAgentDefinitionFile(existingPath) + if err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("agent definition in %s is invalid: %s", displayPath, err), + fmt.Sprintf("Fix %s and retry, or remove the file to start a fresh init.", displayPath), + ) + } + + fmt.Println(color.HiBlackString( + "Detected existing agent definition: %s (name: %s).", + displayPath, def.Name, + )) + + projectConfig, err := ensureProject(ctx, flags, azdClient) + if err != nil { + return err + } + + // Mirror InitFromCodeAction.Run: convert absolute --src to project-relative + // so azure.yaml's RelativePath stays portable. + if flags.src != "" && filepath.IsAbs(flags.src) { + relPath, err := filepath.Rel(projectConfig.Path, flags.src) + if err != nil { + return fmt.Errorf("failed to convert src path to relative path: %w", err) + } + flags.src = relPath + srcDir = relPath + } + + env := getExistingEnvironment(ctx, flags.env, azdClient) + if env == nil { + envName := flags.env + if envName == "" { + envName = sanitizeAgentName(def.Name + "-dev") + } + env, err = createNewEnvironment(ctx, azdClient, envName) + if err != nil { + return fmt.Errorf("failed to create azd environment: %w", err) + } + flags.env = env.Name + } + + action := &InitFromCodeAction{ + azdClient: azdClient, + flags: flags, + projectConfig: projectConfig, + environment: env, + httpClient: httpClient, + } + + isCodeDeploy := def.CodeConfiguration != nil + if err := action.addToProject(ctx, srcDir, def.Name, isCodeDeploy); err != nil { + return fmt.Errorf("failed to add agent to azure.yaml: %w", err) + } + + fmt.Println(color.HiBlackString("Reusing existing %s (name: %s).", displayPath, def.Name)) + + validatePostInit(srcDir, def.CodeConfiguration) + + return nil +} + +// loadAgentDefinitionFile parses path as a bare AgentDefinition (no surrounding +// "template:" wrapper) and runs the same schema validation the manifest +// pipeline does. +func loadAgentDefinitionFile(path string) (*agent_yaml.ContainerAgent, error) { + //nolint:gosec // path comes from findExistingAgentYaml against a user-controlled directory + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + + // Reject manifest-shaped files. A valid manifest would have been routed + // upstream; an invalid one reaching here is a malformed template. + var top map[string]any + if err := yaml.Unmarshal(data, &top); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + if _, hasTemplate := top["template"]; hasTemplate { + return nil, fmt.Errorf( + "file contains a 'template:' field but did not parse as a valid agent manifest; " + + "fix the manifest schema and retry", + ) + } + + if err := agent_yaml.ValidateAgentDefinition(data); err != nil { + return nil, err + } + + var def agent_yaml.ContainerAgent + if err := yaml.Unmarshal(data, &def); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + + return &def, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_reuse_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_reuse_test.go new file mode 100644 index 00000000000..f0f36d7f520 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_reuse_test.go @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "azureaiagent/internal/exterrors" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// Helpers backing the issue #7268 reuse path. runReuseDefinition's happy path +// is covered by manual e2e; only its failure branches are unit-tested here +// because they short-circuit before any azd gRPC calls. + +func TestFindExistingAgentYaml(t *testing.T) { + t.Parallel() + + t.Run("empty dir returns no match", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + got, err := findExistingAgentYaml(dir) + require.NoError(t, err) + require.Empty(t, got) + }) + + t.Run("finds agent.yaml", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeReuseTestFile(t, dir, "agent.yaml", "kind: hosted\nname: foo\n") + + got, err := findExistingAgentYaml(dir) + require.NoError(t, err) + require.Equal(t, filepath.Join(dir, "agent.yaml"), got) + }) + + t.Run("agent.manifest.yaml takes priority over agent.yaml", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeReuseTestFile(t, dir, "agent.yaml", "kind: hosted\nname: foo\n") + writeReuseTestFile(t, dir, "agent.manifest.yaml", "template:\n kind: hosted\n name: foo\n") + + got, err := findExistingAgentYaml(dir) + require.NoError(t, err) + require.Equal(t, filepath.Join(dir, "agent.manifest.yaml"), got) + }) + + t.Run("directory entries are ignored", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "agent.yaml"), 0o750)) + + got, err := findExistingAgentYaml(dir) + require.NoError(t, err) + require.Empty(t, got) + }) + + t.Run("scan does not recurse into subdirectories", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + nested := filepath.Join(dir, "src") + require.NoError(t, os.Mkdir(nested, 0o750)) + writeReuseTestFile(t, nested, "agent.yaml", "kind: hosted\nname: foo\n") + + got, err := findExistingAgentYaml(dir) + require.NoError(t, err) + require.Empty(t, got, "shallow scan only; nested agent.yaml must be ignored") + }) +} + +func TestLoadAgentDefinitionFile(t *testing.T) { + t.Parallel() + + t.Run("happy path: bare definition with hosted kind", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := writeReuseTestFile(t, dir, "agent.yaml", + "kind: hosted\nname: my-agent\nmodel:\n id: gpt-4o-mini\n") + + def, err := loadAgentDefinitionFile(path) + require.NoError(t, err) + require.NotNil(t, def) + require.Equal(t, "my-agent", def.Name) + require.Nil(t, def.CodeConfiguration) + }) + + t.Run("happy path: code_configuration preserved", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := writeReuseTestFile(t, dir, "agent.yaml", + "kind: hosted\nname: my-agent\ncode_configuration:\n runtime: python_3_12\n entry_point: main.py\n") + + def, err := loadAgentDefinitionFile(path) + require.NoError(t, err) + require.NotNil(t, def.CodeConfiguration) + require.Equal(t, "python_3_12", def.CodeConfiguration.Runtime) + }) + + t.Run("rejects manifest-shaped file (top-level template key)", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := writeReuseTestFile(t, dir, "agent.yaml", "template:\n kind: hosted\n name: foo\n") + + _, err := loadAgentDefinitionFile(path) + require.Error(t, err) + require.Contains(t, err.Error(), "template") + }) + + t.Run("rejects missing kind via ValidateAgentDefinition", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := writeReuseTestFile(t, dir, "agent.yaml", "name: my-agent\n") + + _, err := loadAgentDefinitionFile(path) + require.Error(t, err) + require.Contains(t, err.Error(), "kind") + }) + + t.Run("rejects invalid agent name via ValidateAgentDefinition", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := writeReuseTestFile(t, dir, "agent.yaml", "kind: hosted\nname: \"!!! invalid\"\n") + + _, err := loadAgentDefinitionFile(path) + require.Error(t, err) + }) + + t.Run("rejects broken yaml", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := writeReuseTestFile(t, dir, "agent.yaml", "name: : :\nmodel: [unterminated\n") + + _, err := loadAgentDefinitionFile(path) + require.Error(t, err) + }) +} + +// Malformed YAML must surface as CodeInvalidAgentManifest. Runs against the +// failure path so no gRPC mock is needed. +func TestRunReuseDefinition_InvalidFileReturnsStructuredError(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := writeReuseTestFile(t, dir, "agent.yaml", "name: : :\nmodel: [unterminated\n") + + err := runReuseDefinition(t.Context(), &initFlags{}, nil, nil, dir, path) + require.Error(t, err) + + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok, "expected *azdext.LocalError, got %T", err) + require.Equal(t, exterrors.CodeInvalidAgentManifest, localErr.Code) + require.NotEmpty(t, localErr.Suggestion) + require.Contains(t, localErr.Message, "agent.yaml") + require.Contains(t, localErr.Suggestion, "agent.yaml") +} + +// A manifest-shaped file that failed upstream validation must produce a +// targeted error here, not fall into scaffolding. +func TestRunReuseDefinition_RejectsManifestShapedFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := writeReuseTestFile(t, dir, "agent.manifest.yaml", + "template:\n # intentionally incomplete\n") + + err := runReuseDefinition(t.Context(), &initFlags{}, nil, nil, dir, path) + require.Error(t, err) + + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok) + require.Equal(t, exterrors.CodeInvalidAgentManifest, localErr.Code) + require.Contains(t, localErr.Message, "agent.manifest.yaml", + "error message must name the actual file, not a hardcoded agent.yaml") +} + +func writeReuseTestFile(t *testing.T, dir, name, contents string) string { + t.Helper() + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + return path +}